From ccb8ce84947c3a2005cdf71de268698c472c6948 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 29 Oct 2026 18:48:31 -0400 Subject: [PATCH 01/73] packaging: Flatpak finds and prints to CUPS printers (#4909) * packaging: Flatpak finds and prints to CUPS printers The Print dialog listed no printers in the Flatpak: the runtime has libcups but not lpstat/lp, the sandbox couldn't reach the CUPS socket, and driverless network printers (lpstat -e) need Avahi. Bundle lpstat/lp/lpoptions from CUPS 2.4.20 (Apache-2.0, statically linked, licence installed) and grant --socket=cups and --system-talk-name=org.freedesktop.Avahi in both manifests. * packaging: Flatpak reads the user's CUPS settings (~/.cups, read-only) Review (CodeRabbit): without it the sandbox's lp/lpstat ignore the user's default printer and saved options (lpoptions) and a print server named in their client.conf. * packaging: Flatpak CUPS tools reach the host socket when client.conf names a custom path Review: Flatpak mounts the host's CUPS socket at /var/run/cups/cups.sock whatever path ~/.cups/client.conf or CUPS_SERVER names, so lp/lpstat/lpoptions now run through a wrapper that points a socket path missing in the sandbox there. Server names (remote servers) and sockets that exist are left alone. Tested in the sandbox: CUPS_SERVER=/run/custom/cups.sock lists the host's printers through the wrapper and none without it; a host name is passed through unchanged. * packaging: redirect a custom CUPS socket only when it is the one Flatpak mounted Review (CodeRabbit): Flatpak picks the socket it mounts at /var/run/cups/cups.sock with stricter rules than CUPS (flatpak-run-cups.c: a socket CUPS_SERVER, else the first line spelled exactly "ServerName /path" in ~/.cups/client.conf, else the default), while CUPS reads the keyword in any case and takes the last ServerName. The wrapper now redirects only when CUPS' socket is the one Flatpak chose, so a job can't reach a different server; otherwise CUPS fails on the missing path. * packaging: never redirect a CUPS_SERVER the sandbox was given Review (CodeRabbit): Flatpak picks the socket it mounts from the host's CUPS_SERVER, so one set by flatpak run --env or an override may not be what it mounted. The wrapper now leaves any CUPS_SERVER alone and redirects only a client.conf socket that matches Flatpak's choice; otherwise CUPS fails on the missing path instead of reaching a different server. * packaging: point CUPS at the mounted socket only when it is the one Flatpak chose, else fail Review (CodeRabbit): with a custom socket listed first and the default last in ~/.cups/client.conf, Flatpak mounts the custom one at /var/run/cups/cups.sock while CUPS picks the last line, finds that path, and talks to the custom server. The wrapper now compares CUPS' socket with Flatpak's choice even when the path exists: equal, CUPS is pointed at the mount; different, the tool fails with an error instead of reaching another server. --- .../ai.storyteller.wordcraft.bundle.yml | 52 +++++++++++++++++++ .../flatpak/ai.storyteller.wordcraft.yml | 52 +++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/packaging/linux/flatpak/ai.storyteller.wordcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.wordcraft.bundle.yml index 0e23bafec0..00cd75f155 100644 --- a/packaging/linux/flatpak/ai.storyteller.wordcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.wordcraft.bundle.yml @@ -24,6 +24,14 @@ finish-args: - --share=ipc # GPU access for the wgpu renderer (Vulkan, or GL as a fallback). - --device=dri + # Printing: lpstat/lp (the cups-client module) reach the host's CUPS through its socket and + # find driverless network printers through Avahi (`lpstat -e`). Without these the Print + # dialog lists no printers. + - --socket=cups + - --system-talk-name=org.freedesktop.Avahi + # The user's CUPS settings, read-only: their default printer and saved options (lpoptions) + # and a print server named in their client.conf, which lp reaches over the network. + - --filesystem=~/.cups:ro # Open/Save dialogs go through the file-chooser portal (rfd's xdg-portal backend) and # drag-and-drop through the document portal, so picked files work anywhere without broad # filesystem access. Documents and Pictures are granted so files passed by path (the command @@ -35,6 +43,50 @@ finish-args: - --share=network # Deliberately not granted: home/host filesystem access. modules: + # CUPS client tools for craft-print: the runtime has libcups but not lpstat/lp/lpoptions. + # Linked statically against this release's libcups, so a runtime update can't break them; + # the socket path matches the host's, which --socket=cups mounts. + - name: cups-client + buildsystem: simple + build-commands: + - ./configure --prefix=/app --sysconfdir=/etc --localstatedir=/var --with-rundir=/run/cups + --with-domainsocket=/run/cups/cups.sock --disable-shared --with-components=libcups + --with-dnssd=avahi --with-tls=gnutls --disable-pam --disable-dbus + - make -C cups libs + - make -C systemv lp lpoptions lpstat + - install -Dm755 -t /app/libexec/cups systemv/lp systemv/lpoptions systemv/lpstat + - for tool in lp lpoptions lpstat; do install -Dm755 cups-socket.sh "/app/bin/$tool"; done + - install -Dm644 -t /app/share/licenses/cups LICENSE NOTICE + sources: + - type: archive + url: https://github.com/OpenPrinting/cups/releases/download/v2.4.20/cups-2.4.20-source.tar.gz + sha256: ab4d9cd7f3e58060091d2b24972223d6401675f11c49b65abf4f6ef31dea22ff + # Runs each tool. Flatpak mounts the host's CUPS socket at /var/run/cups/cups.sock, choosing + # it as flatpak-run-cups.c does from the host's CUPS_SERVER, else the first line spelled + # exactly `ServerName /path` in ~/.cups/client.conf, else the default; it mounts nothing + # when that socket is missing. CUPS reads the keyword in any case and takes the last + # ServerName. When CUPS' socket from client.conf is the one Flatpak chose, it is pointed at + # the mount; when it isn't, the tool fails rather than reach a different server. A + # CUPS_SERVER is left alone: one set by `flatpak run --env` may not be what was mounted. + - type: script + dest-filename: cups-socket.sh + commands: + - 'conf="$HOME/.cups/client.conf"' + - 'if [ -z "${CUPS_SERVER:-}" ] && [ -r "$conf" ]; then' + - ' server=$(sed -n ''s/^[[:space:]]*[Ss][Ee][Rr][Vv][Ee][Rr][Nn][Aa][Mm][Ee][[:space:]]\{1,\}\([^[:space:]]*\).*/\1/p'' "$conf" | tail -n 1)' + - ' case "$server" in' + - ' /*)' + - ' mounted=$(sed -n ''s/^[[:space:]]*ServerName \(.*\)$/\1/p'' "$conf" | sed ''s/^[[:space:]]*//'' | grep -m 1 ''^/[^:]*$'')' + - ' if [ "$mounted" = "$server" ]; then' + - ' export CUPS_SERVER=/var/run/cups/cups.sock' + - ' else' + - ' echo "${0##*/}: ~/.cups/client.conf names the CUPS socket $server, but Flatpak gave this app ${mounted:-a different one}; give each socket one ServerName line, spelled ServerName" >&2' + - ' exit 1' + - ' fi' + - ' ;;' + - ' esac' + - 'fi' + - 'exec "/app/libexec/cups/${0##*/}" "$@"' - name: wordcraft buildsystem: simple build-commands: diff --git a/packaging/linux/flatpak/ai.storyteller.wordcraft.yml b/packaging/linux/flatpak/ai.storyteller.wordcraft.yml index 83fb9f6cc5..01935727b7 100644 --- a/packaging/linux/flatpak/ai.storyteller.wordcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.wordcraft.yml @@ -25,6 +25,14 @@ finish-args: - --share=ipc # GPU access for the wgpu renderer (Vulkan, or GL as a fallback). - --device=dri + # Printing: lpstat/lp (the cups-client module) reach the host's CUPS through its socket and + # find driverless network printers through Avahi (`lpstat -e`). Without these the Print + # dialog lists no printers. + - --socket=cups + - --system-talk-name=org.freedesktop.Avahi + # The user's CUPS settings, read-only: their default printer and saved options (lpoptions) + # and a print server named in their client.conf, which lp reaches over the network. + - --filesystem=~/.cups:ro # Open/Save dialogs go through the file-chooser portal (rfd's xdg-portal backend) and # drag-and-drop through the document portal, so picked files work anywhere without broad # filesystem access. Documents and Pictures are granted so files passed by path (the command @@ -38,6 +46,50 @@ finish-args: build-options: append-path: /usr/lib/sdk/rust-stable/bin modules: + # CUPS client tools for craft-print: the runtime has libcups but not lpstat/lp/lpoptions. + # Linked statically against this release's libcups, so a runtime update can't break them; + # the socket path matches the host's, which --socket=cups mounts. + - name: cups-client + buildsystem: simple + build-commands: + - ./configure --prefix=/app --sysconfdir=/etc --localstatedir=/var --with-rundir=/run/cups + --with-domainsocket=/run/cups/cups.sock --disable-shared --with-components=libcups + --with-dnssd=avahi --with-tls=gnutls --disable-pam --disable-dbus + - make -C cups libs + - make -C systemv lp lpoptions lpstat + - install -Dm755 -t /app/libexec/cups systemv/lp systemv/lpoptions systemv/lpstat + - for tool in lp lpoptions lpstat; do install -Dm755 cups-socket.sh "/app/bin/$tool"; done + - install -Dm644 -t /app/share/licenses/cups LICENSE NOTICE + sources: + - type: archive + url: https://github.com/OpenPrinting/cups/releases/download/v2.4.20/cups-2.4.20-source.tar.gz + sha256: ab4d9cd7f3e58060091d2b24972223d6401675f11c49b65abf4f6ef31dea22ff + # Runs each tool. Flatpak mounts the host's CUPS socket at /var/run/cups/cups.sock, choosing + # it as flatpak-run-cups.c does from the host's CUPS_SERVER, else the first line spelled + # exactly `ServerName /path` in ~/.cups/client.conf, else the default; it mounts nothing + # when that socket is missing. CUPS reads the keyword in any case and takes the last + # ServerName. When CUPS' socket from client.conf is the one Flatpak chose, it is pointed at + # the mount; when it isn't, the tool fails rather than reach a different server. A + # CUPS_SERVER is left alone: one set by `flatpak run --env` may not be what was mounted. + - type: script + dest-filename: cups-socket.sh + commands: + - 'conf="$HOME/.cups/client.conf"' + - 'if [ -z "${CUPS_SERVER:-}" ] && [ -r "$conf" ]; then' + - ' server=$(sed -n ''s/^[[:space:]]*[Ss][Ee][Rr][Vv][Ee][Rr][Nn][Aa][Mm][Ee][[:space:]]\{1,\}\([^[:space:]]*\).*/\1/p'' "$conf" | tail -n 1)' + - ' case "$server" in' + - ' /*)' + - ' mounted=$(sed -n ''s/^[[:space:]]*ServerName \(.*\)$/\1/p'' "$conf" | sed ''s/^[[:space:]]*//'' | grep -m 1 ''^/[^:]*$'')' + - ' if [ "$mounted" = "$server" ]; then' + - ' export CUPS_SERVER=/var/run/cups/cups.sock' + - ' else' + - ' echo "${0##*/}: ~/.cups/client.conf names the CUPS socket $server, but Flatpak gave this app ${mounted:-a different one}; give each socket one ServerName line, spelled ServerName" >&2' + - ' exit 1' + - ' fi' + - ' ;;' + - ' esac' + - 'fi' + - 'exec "/app/libexec/cups/${0##*/}" "$@"' - name: wordcraft buildsystem: simple build-options: From a066fe5f134e106c84298ebf5b18899c4564400e Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Fri, 30 Oct 2026 01:38:01 +0000 Subject: [PATCH 02/73] Keep zero-sized paragraph frames in normal flow (#2810) * 411: Keep zero-sized paragraph frames in normal flow * M2.1: keep frame controls consistent with document defaults (#2810) * M2.1: check the final frame round trip (#2810) --------- Co-authored-by: Brandon Thomas --- crates/doc/src/resolve.rs | 7 +++- crates/docx/src/read/props.rs | 4 +-- crates/docx/tests/it/fixtures.rs | 62 ++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/crates/doc/src/resolve.rs b/crates/doc/src/resolve.rs index 49b6904822..af3a8f2abb 100644 --- a/crates/doc/src/resolve.rs +++ b/crates/doc/src/resolve.rs @@ -270,7 +270,12 @@ impl ResolvedPara { drop_cap_margin: p.drop_cap_margin.unwrap_or(false), drop_cap_distance: p.drop_cap_distance.filter(|d| d.is_finite()).unwrap_or(0.0).clamp(0.0, MAX_DROP_CAP_DISTANCE), drop_cap_font: p.drop_cap_font.clone().filter(|f| !f.trim().is_empty()), - frame: p.frame.clone().filter(|_| p.drop_cap.unwrap_or(0) == 0), + // Explicit zero dimensions cancel a frame; omitted dimensions still fit its text. + frame: p.frame.clone().filter(|f| p.drop_cap.unwrap_or(0) == 0 && (f.w != Some(0.0) || f.h != Some(0.0))).map(|mut f| { + f.w = f.w.filter(|v| *v != 0.0); + f.h = f.h.filter(|v| *v != 0.0); + f + }), suppress_hyphens: p.suppress_hyphens.unwrap_or(false), suppress_line_numbers: p.suppress_line_numbers.unwrap_or(false), kinsoku: p.kinsoku.unwrap_or(true), diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index acd56b2ef4..57223c22f2 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -23,8 +23,8 @@ fn text_frame(k: &El) -> wordcraft_doc::props::TextFrame { let word = |name: &str, allowed: &[&str], default: &str| k.attr(name).filter(|v| allowed.contains(v)).unwrap_or(default).to_string(); let align = |name: &str, allowed: &[&str]| k.attr(name).filter(|v| allowed.contains(v)).map(str::to_string); wordcraft_doc::props::TextFrame { - w: len("w:w").filter(|v| *v > 0.0), - h: len("w:h").map(f32::abs).filter(|v| *v > 0.0), + w: len("w:w").filter(|v| *v >= 0.0), + h: len("w:h").map(f32::abs), h_exact: k.attr("w:hRule") == Some("exact"), x: len("w:x").unwrap_or(0.0), y: len("w:y").unwrap_or(0.0), diff --git a/crates/docx/tests/it/fixtures.rs b/crates/docx/tests/it/fixtures.rs index 5d9e128fba..34b3bd0746 100644 --- a/crates/docx/tests/it/fixtures.rs +++ b/crates/docx/tests/it/fixtures.rs @@ -114,6 +114,68 @@ fn paragraph_default_presence_survives_repeated_saves() { } } +#[test] +fn zero_frame_keeps_normal_flow() { + const ROUND_TRIPS: usize = 2; + let zero = r#""#; + let sized = r#""#; + for frame in [zero, sized] { + let styles = format!( + r#"{frame} + {sized}"# + ); + let header = format!(r#"Header alphaHeader beta"#); + let footer = format!(r#"Footer delta"#); + let body = format!( + r#"Body one + {sized}Direct frame + Styled frame + {zero}Frame reset + "# + ); + let mut document = wordcraft_docx::read(&docx( + &body, + &[("styles", "styles", "styles.xml"), ("header", "header", "header1.xml"), ("footer", "footer", "footer1.xml")], + &[("word/styles.xml", &styles), ("word/header1.xml", &header), ("word/footer1.xml", &footer)], + )) + .unwrap(); + for pass in 0..=ROUND_TRIPS { + let body = paras(&document); + let resolved = document.styles.resolve_para(&body[0].props); + assert!(resolved.frame.is_none(), "document defaults never create a frame"); + assert!(document.styles.default_para.frame.is_none()); + let reset = body[3].props.frame.as_ref().unwrap(); + assert_eq!((reset.w, reset.h), (Some(0.0), Some(0.0)), "the direct reset survives saving"); + assert_eq!(resolved.space_after, 8.0); + assert!(document.styles.resolve_para(&body[1].props).frame.is_some()); + assert!(document.styles.resolve_para(&body[2].props).frame.is_some()); + assert!(document.styles.resolve_para(&body[3].props).frame.is_none()); + for id in [document.last_section.headers.default, document.last_section.footers.default] { + for block in &document.parts.get(&id.unwrap()).unwrap().blocks { + let paragraph = block.as_para().unwrap(); + assert!(document.styles.resolve_para(¶graph.props).frame.is_none(), "defaults do not frame headers or footers"); + } + } + if pass == ROUND_TRIPS { + break; + } + document = wordcraft_docx::read(&wordcraft_docx::write(&document).unwrap()).unwrap(); + } + } +} + +#[test] +fn frame_zero_side_is_automatic() { + for (attrs, width, height) in + [(r#"w:w="0" w:h="1200""#, None, Some(60.0)), (r#"w:w="2400" w:h="0" w:hRule="exact""#, Some(120.0), None), ("", None, None)] + { + let document = read_body(&format!(r#"Original automatic frame"#)); + let paragraph = paras(&document)[0]; + let frame = document.styles.resolve_para(¶graph.props).frame.unwrap(); + assert_eq!((frame.w, frame.h), (width, height)); + } +} + #[test] fn omitted_paragraph_defaults_can_be_edited() { let styles = From f658c872a23a13cd1a5a81d63f12993f51a4f938 Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Fri, 30 Oct 2026 01:40:41 +0000 Subject: [PATCH 03/73] Tables: share spanning minima by preferred widths (#1883) * Tables: share span minima by preferred widths (#1883) * 1222: Match nested preferred span weights * docs: word the #1883 layout-parity entries --------- Co-authored-by: Brandon Thomas --- crates/layout/src/table.rs | 42 ++++++++++++--- crates/layout/src/tests.rs | 108 +++++++++++++++++++++++++++++++++++++ docs/layout-parity.md | 5 +- 3 files changed, 147 insertions(+), 8 deletions(-) diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 8cbc414585..701aad2fff 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -391,9 +391,9 @@ struct TableFrame { x: f32, } -fn table_frame(ctx: &mut Ctx, t: &Table, avail: f32, depth: usize, style: Option<&TableStyleProps>) -> TableFrame { +// Nested measurement needs the same preferred weights as layout before sharing span minima. +fn preferred_grid(t: &Table, avail: f32) -> Vec { let ncols = grid_cols(t).max(1); - // Column widths. let mut grid: Vec = if t.grid.len() == ncols { // Grid units can be narrower than a text box, including omitted row-end columns. t.grid @@ -408,6 +408,12 @@ fn table_frame(ctx: &mut Ctx, t: &Table, avail: f32, depth: usize, style: Option vec![avail / ncols as f32; ncols] }; apply_cell_widths(t, &mut grid); + grid +} + +fn table_frame(ctx: &mut Ctx, t: &Table, avail: f32, depth: usize, style: Option<&TableStyleProps>) -> TableFrame { + let ncols = grid_cols(t).max(1); + let mut grid = preferred_grid(t, avail); let mut total: f32 = grid.iter().sum(); // A top-level table in an inherited header or footer takes its share of the defining // section's width (`Ctx::pct_base`). @@ -437,7 +443,7 @@ fn table_frame(ctx: &mut Ctx, t: &Table, avail: f32, depth: usize, style: Option if !t.props.fixed { // Measuring lays paragraphs out once more; it mustn't count their equations twice. let eq_count = ctx.eq_count; - let content = column_content(ctx, t, style, depth); + let content = column_content(ctx, t, style, depth, &grid); ctx.eq_count = eq_count; if let Some(content) = content { autofit(&mut grid, &content, t, avail); @@ -1167,7 +1173,7 @@ fn region_text(st: &TableStyleProps, region: Region) -> CellText { /// Each column's narrowest and widest content, cell margins included: its longest word and its /// longest line. A cell spanning columns shares what they lack between them; nested tables count /// with their own. `None` when every cell is empty. -fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, depth: usize) -> Option> { +fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, depth: usize, grid: &[f32]) -> Option> { let margins_def = default_margins(t, style); // The grid's width once: counting it walks every row. let ncols = grid_cols(t); @@ -1196,15 +1202,36 @@ fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, dep for (g, span, need) in spanning { let Some(cs) = cols.get_mut(g..g.saturating_add(span).min(ncols)) else { continue }; let n = cs.len().max(1) as f32; - let (have_lo, have_hi) = cs.iter().fold((0.0, 0.0), |(l, h), c| (l + c.0, h + c.1)); + let have_hi: f32 = cs.iter().map(|c| c.1).sum(); + let preferred = (!is_automatic(t)).then(|| grid.get(g..g + cs.len())).flatten(); + share_span_min(cs, preferred, need.0); for c in cs { - c.0 += (need.0 - have_lo).max(0.0) / n; c.1 += (need.1 - have_hi).max(0.0) / n; } } any.then_some(cols) } +// Share a span's extra minimum in preferred-column proportions. Each column keeps +// its own text minimum; automatic tables share the extra equally. +fn share_span_min(cols: &mut [(f32, f32)], grid: Option<&[f32]>, need: f32) { + let extra = (need - cols.iter().map(|c| c.0).sum::()).max(0.0); + let weight = |w: &f32| if w.is_finite() { w.clamp(0.0, UNBOUNDED) } else { 0.0 }; + let total = grid.map_or(0.0, |grid| grid.iter().take(cols.len()).map(weight).sum::()); + if total > 0.0 { + if let Some(grid) = grid { + for (c, w) in cols.iter_mut().zip(grid) { + c.0 += extra * weight(w) / total; + } + } + return; + } + let share = extra / cols.len().max(1) as f32; + for c in cols { + c.0 += share; + } +} + /// The grid columns row `row` leaves empty before its first cell and after its last one (which /// ends at grid column `end`) when it gives their width (`w:wBefore`, `w:wAfter`): (first column, /// columns, width in points). Column widths make room for them like for an empty cell. @@ -1234,7 +1261,8 @@ fn blocks_content(ctx: &mut Ctx, blocks: &Blocks, text: Option<&CellText>, depth (width, width) } else { let style = table_style(ctx, nt); - let cols = column_content(ctx, nt, style.as_ref(), depth + 1).unwrap_or_default(); + let weights = preferred_grid(nt, width); + let cols = column_content(ctx, nt, style.as_ref(), depth + 1, &weights).unwrap_or_default(); let (l, h) = cols.iter().fold((0.0, 0.0), |(l, h), c| (l + c.0, h + c.1)); (width.max(l), width.max(h)) } diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 2c904e9a62..85d44c13a2 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -11232,6 +11232,114 @@ fn caret_height_ignores_line_spacing() { } } +enum SpanWidth { + Preferred, + Automatic, + TextOnly, +} + +fn banner_layout(width: SpanWidth) -> DocLayout { + let widths = [150.0, 100.0, 25.0, 25.0]; + let mut d = Document::new(); + let mut t = Table::new(2, widths.len(), 300.0); + t.grid = widths.to_vec(); + t.props.width = match width { + SpanWidth::Preferred => Some(300.0), + SpanWidth::Automatic | SpanWidth::TextOnly => None, + }; + t.props.cell_margins = Some([0.0; 4]); + t.rows[0].cells.truncate(1); + t.rows[0].cells[0].props.span = 4; + t.rows[0].cells[0].props.width = t.props.width; + let mut banner = wordcraft_doc::Paragraph::new(); + if !matches!(width, SpanWidth::TextOnly) { + banner.insert_object(0, picture(300.0, 30.0, Default::default()), &Default::default()).unwrap(); + } + t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(banner)]; + for (i, cell) in t.rows[1].cells.iter_mut().enumerate() { + cell.props.width = match width { + SpanWidth::Preferred => Some(widths[i]), + SpanWidth::Automatic | SpanWidth::TextOnly => None, + }; + cell.blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("A", Default::default()))]; + } + d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(t)).unwrap(); + lay(&d) +} + +#[test] +fn spanning_banner_uses_weights() { + let text = banner_layout(SpanWidth::TextOnly); + let minimum = text.pages[0] + .items + .iter() + .find_map(|item| match item { + Placed::Cell { rect, row: 1, .. } => Some(rect.w), + _ => None, + }) + .unwrap(); + let widths = [150.0 - minimum, 100.0 - minimum / 3.0, 25.0 + 2.0 * minimum / 3.0, 25.0 + 2.0 * minimum / 3.0]; + let l = banner_layout(SpanWidth::Preferred); + let actual: Vec<_> = + l.pages[0].items.iter().filter_map(|it| if let Placed::Cell { rect, row: 1, .. } = it { Some(rect.w) } else { None }).collect(); + assert_eq!(actual.len(), widths.len()); + for (got, want) in actual.iter().zip(widths) { + assert!((got - want).abs() < 0.01, "spanning banner changed columns: {actual:?}"); + } +} + +#[test] +fn automatic_banner_shares_width() { + let l = banner_layout(SpanWidth::Automatic); + let widths: Vec<_> = + l.pages[0].items.iter().filter_map(|it| if let Placed::Cell { rect, row: 1, .. } = it { Some(rect.w) } else { None }).collect(); + assert_eq!(widths.len(), 4); + assert!(widths.iter().all(|w| (w - 75.0).abs() < 0.01), "automatic widths: {widths:?}"); +} + +#[test] +fn nested_spans_use_grid_weights() { + const NESTED_WIDTH: f32 = 40.0; + const IMAGE_WIDTH: f32 = 50.0; + const OUTER_WIDTH: f32 = 51.0; + let widths: Vec<_> = [0.0, 4.0] + .into_iter() + .map(|first| { + let mut nested = Table::new(2, 3, NESTED_WIDTH); + nested.props.style = None; + nested.props.width = Some(NESTED_WIDTH); + nested.props.cell_margins = Some([0.0; 4]); + nested.grid = vec![first, 100.0, 100.0]; + for (index, row) in nested.rows.iter_mut().enumerate() { + row.cells.truncate(1); + row.props.grid_before = index as u32; + row.cells[0].props.span = 2; + row.cells[0].props.width = None; + let mut p = Paragraph::new(); + p.insert_object(0, picture(IMAGE_WIDTH, 10.0, Default::default()), &Default::default()).unwrap(); + row.cells[0].blocks = vec![wordcraft_doc::para_block(p)]; + } + let mut outer = Table::new(1, 1, OUTER_WIDTH); + outer.props.style = None; + outer.props.width = Some(OUTER_WIDTH); + outer.props.cell_margins = Some([0.0; 4]); + outer.rows[0].cells[0].blocks = vec![Arc::new(Block::Table(nested))]; + let mut d = Document::new(); + d.body = vec![Arc::new(Block::Table(outer))]; + lay(&d).pages[0] + .items + .iter() + .find_map(|item| match item { + Placed::Cell { rect, table, .. } if table.0 == [0] => Some(rect.w), + _ => None, + }) + .unwrap() + }) + .collect(); + assert!(widths[1] > OUTER_WIDTH + 0.5, "overlapping spans must expand the outer table: {widths:?}"); + assert!((widths[0] - widths[1]).abs() < 0.01, "equivalent nested grids changed outer fitting: {widths:?}"); +} + /// #2048: a picture anchored in a later paragraph but placed higher up the page (fixed on the /// page, or moved up from its paragraph) keeps the text of the paragraphs above its anchor clear /// too, as in Word, not just its own paragraph's. diff --git a/docs/layout-parity.md b/docs/layout-parity.md index db93a1c0d6..be964d4c5b 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** trivial (continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; 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: Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; 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: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; 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: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (a cell spanning columns shares its extra minimum width by the columns' preferred widths, equally in automatic tables, #1883; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; 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: Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; 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: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; 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: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -236,6 +236,8 @@ Footer flow-table strokes reserve their drawn top edge, including the half-width Page-relative footer tables keep their physical wrap bounds without adding to footer text height. Body text resumes below the table. Original native controls cover empty, text-bearing and minimum-height tables. Body textbox boundaries and nested footer painting remain separate gaps. +Spanning cell minima retain each column's text minimum and share extra width in preferred-column proportions; automatic tables share equally. Nested measurement normalizes preferred grid weights as layout does. + ## Validation fonts The deleted/hidden-text wrapping, ellipse wrapping and widow-control regressions request Source Sans 3 explicitly. Their fixed widths and word counts use that font's metrics. This changes test inputs only, not renderer font substitution or Word parity claims. @@ -266,6 +268,7 @@ layout suite are pending; this is not a new Word/native-IME acceptance claim. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | trivial | A cell spanning columns shares its extra minimum width by the columns' preferred widths (equally in automatic tables); nested tables are measured with the same weights (#1883) | | 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-24 | trivial | Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; original emoji regression retained, new mixed-style/bundled-font LTR/bidi controls pending managed validation | | 2026-10-24 | trivial | Exact-spacing table contact uses the specified line height in row selection and word skipping; large/small mark regressions in modes 11/12/14/15 (#3977); shape estimates unchanged | From 22e99fc2b7cedf351b65a14c879feddaac5653ac Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Fri, 30 Oct 2026 02:47:27 +0100 Subject: [PATCH 04/73] Tables can be copied and cut: whole tables, rows and cells (#1744) (#1784) * #1744: WIP * #1744: Select Table copies and cuts the whole table Copy of a whole-table selection used to keep only the cells' paragraphs (copy_blocks flattened a cross-cell range), and Cut only cleared the text. Now Copy puts the table on the clipboard and Cut removes the table. Co-Authored-By: Claude Opus 5.5 * #1744: selected table rows and cells copy as a table; Cut clears their text Copy of whole rows, cells of a row or a block of cells puts a table of just those cells on the clipboard (Table::slice). Cut of rows or cells clears their text and keeps the rows and cells, as in Word (Word check A). Co-Authored-By: Claude Opus 5.5 * #1744: review fixes Table::slice widens every row to a merged cell partly selected (no ragged copy); an empty selected table or cell copies and cuts; a text selection over exactly a nested table copies it; one selected_table per Cut; tests for slice merges/widths/controls, tracked and locked cuts, and the text route. Co-Authored-By: Claude Opus 5.5 * #1744: Cut clears every cell Copy takes; Spike stays off for empty cell selections Co-Authored-By: Claude Sonnet 5.5 * #1744: a copied part of a percentage-width table keeps a point width Co-Authored-By: Claude Sonnet 5.5 * ROADMAP: tidy merged header Co-Authored-By: Claude Sonnet 5.5 * ROADMAP: keep only the #1744 rows over main's version Co-Authored-By: Claude Sonnet 5.5 * ROADMAP: remove conflict markers; keep only the #1744 rows over main's version Co-Authored-By: Claude Sonnet 5.5 * #1744: rename the range Cut helper clear_range_cells (main added clear_cells) Co-Authored-By: Claude Sonnet 5.5 * Remove conflict markers from ROADMAP.md; leave main's header line alone Co-Authored-By: Claude Sonnet 5.5 * cargo fmt Co-Authored-By: Claude Sonnet 5.5 * Import Path in table structure after merging main --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 + crates/doc/src/edit.rs | 64 +++++ crates/doc/src/table.rs | 137 +++++++++++ crates/engine/src/cmd/column.rs | 2 + crates/engine/src/cmd/edit.rs | 56 ++++- crates/engine/src/cmd/table.rs | 2 + crates/engine/src/cmd/table/clear.rs | 24 ++ crates/engine/src/cmd/table/structure.rs | 20 ++ crates/engine/src/tests.rs | 285 +++++++++++++++++++++++ 9 files changed, 585 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a794c35fc4..aaafccf460 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -255,6 +255,8 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-16 | DOCX: a `HYPERLINK` field (complex or simple) reads as a link, as a `w:hyperlink` does: its result stays ordinary text carrying the link (`url`, `url#bookmark` or `#bookmark`), so a tab in it reaches its tab stop and a `PAGEREF` in it stays a field (#2174). A table of contents from Word for the web, whose entries are such fields, showed a box for each tab and no dot leaders; the entries now open as in Word and their links work | | 2026-10-17 | The document's right-click menu offers the ribbon's Paste menu, item for item (#2583): Paste (the clipboard's formatting kept, Word's Keep Source Formatting), Keep Text Only, Merge Formatting and Paste Special…, where it had a single Paste. Same commands and shortcuts, with the system clipboard | | 2026-10-17 | SmartArt Text Pane: Tab demotes and Shift+Tab promotes the focused item, as Word's pane does (#2701). The row keeps its focus, so a second Tab acts on the same item; an item that can't go down a level is left alone | +| 2026-10-15 | Selected table rows and cells copy as a table (#1744): Copy of whole rows, of cells in a row, or of a block of cells over several rows, then Paste outside the table, inserts a table of just those cells; Cut of rows or cells clears their text and leaves the rows and cells in place (the clipboard still gets the table), as Word does. Copy used to take only the cells' paragraphs | +| 2026-10-14 | Tables can be copied and cut (#1744): with the whole table selected (Select Table, or a drag over all of it), Copy puts the table itself on the clipboard, so Paste outside it inserts a copy of the table with its formatting (the plain text is tab and newline separated); Cut removes the table in one undo step, as in Word. Copy used to keep only the cells' paragraphs and Cut left the table in place | | 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. | | 2026-10-19 | Typing no longer starts render threads on every repaint (#3027): page, area and icon renders share one kept vello_cpu context, reset to each size, instead of building a multi-threaded one (and its thread pool) per render. Typing about 150 characters in 15 s created 295–388 threads before and none now; CPU time while typing fell by about a third (Windows, debug build). | | 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture | @@ -803,6 +805,8 @@ 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-15 | trivial | Progress log: selected table rows and cells copy as a table; Cut clears their text (#1744) | +| 2026-10-14 | trivial | Progress log: Select Table copies and cuts the whole table (#1744) | | 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 | diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index b823d892dc..833c3878d2 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -792,6 +792,63 @@ impl Document { Fragment { blocks, parts } } + /// A copy of the block at `path` (a whole table, say) as a fragment, with its text boxes. + pub fn copy_block(&self, story: crate::StoryRef, path: &Path) -> Option { + Some(self.fragment_of(vec![self.block(story, path)?.clone()])) + } + + /// A copy of rows `rows` of the table at `tp`, with the cells of each that reach into grid + /// columns `cols` (see [`crate::Table::slice`]), as a fragment holding that table. + pub fn copy_table_cells(&self, story: crate::StoryRef, tp: &Path, rows: (usize, usize), cols: (usize, usize)) -> Option { + let t = self.table(story, tp)?.slice(rows, cols); + if t.rows.is_empty() { + return None; + } + Some(self.fragment_of(vec![Block::Table(t)])) + } + + /// `blocks` as a fragment: half content controls dropped, text boxes copied along. + fn fragment_of(&self, mut blocks: Vec) -> Fragment { + crate::control::balance_blocks(&mut blocks); + let parts = self.text_box_parts(&blocks); + Fragment { blocks, parts } + } + + /// The table whose whole text `a..b` covers when the range runs across its cells: from the + /// start of its first paragraph to the end of its last. Of the tables holding both ends, the + /// outermost one covered whole (a table nested in a cell when the range covers just it). + /// `None` for a range inside one cell, or one that doesn't cover a whole table. + pub fn whole_table(&self, a: &Pos, b: &Pos) -> Option { + let (a, b) = order(a, b); + if a.story != b.story || a.off != 0 || a.path.parent() == b.path.parent() { + return None; + } + let mut paths: Option> = None; + // Table block paths are the prefixes of a cell paragraph's path one block deep, then + // every three steps (block, row, cell) deeper. + let mut n = 1usize; + while n.saturating_add(3) <= a.path.0.len() { + let pre = a.path.0.get(..n)?; + if !b.path.0.starts_with(pre) { + return None; + } + let tp = Path(pre.to_vec()); + if self.table(a.story, &tp).is_some() { + let paths = paths.get_or_insert_with(|| self.para_paths(a.story)); + let mut inside = paths.iter().filter(|p| p.0.starts_with(pre)); + if let Some(first) = inside.next() { + let last = inside.next_back().unwrap_or(first); + let end = self.para(a.story, last).map_or(0, Paragraph::len); + if *first == a.path && *last == b.path && b.off >= end { + return Some(tp); + } + } + } + n = n.saturating_add(3); + } + None + } + /// The whole body as a fragment (with its text boxes), to insert into another document. pub fn body_fragment(&self) -> Fragment { let blocks: Vec = self.body.iter().map(|b| (**b).clone()).collect(); @@ -846,6 +903,13 @@ impl Document { } return out; } + // Across the cells of a whole table: the table itself, not its cells' paragraphs (#1744). + if let Some(tp) = self.whole_table(&a, &b) + && let Some(blk) = self.block(a.story, &tp) + { + out.push(blk.clone()); + return out; + } for p in self.paths_between(&a, &b) { if let Some(para) = self.para(a.story, &p) { let mut q = para.clone(); diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 76911dd4f8..abdda89241 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -77,6 +77,80 @@ pub const MAX_COLS: usize = 63; pub const MAX_TABLE_DEPTH: usize = 24; impl Table { + /// Grid columns `lo..hi` that [`Table::slice`] copies for `rows`/`cols` (widened over merged cells), + /// and whether any cell touches them (Cut clears the same cells it copies). + pub fn slice_bounds(&self, rows: (usize, usize), cols: (usize, usize)) -> (usize, usize, bool) { + let ((r0, r1), (g0, g1)) = (rows, cols); + let kept = || self.rows.iter().take(r1.saturating_add(1)).skip(r0); + // Grid columns `lo..hi` the cells touching them cover: a cell merged across that is partly + // in them widens them, for every row, so the copy isn't ragged. Each round only widens + // (at most once per grid column), so a bound of the grid's width plus one round is enough. + let (mut lo, mut hi) = (g0, g1.saturating_add(1)); + let mut any = false; + for _ in 0..=MAX_COLS.saturating_add(1) { + let (mut nlo, mut nhi, mut found) = (lo, hi, false); + for row in kept() { + let mut g = 0usize; + for cell in &row.cells { + let end = g.saturating_add(cell.span()); + if g < hi && end > lo { + (nlo, nhi, found) = (nlo.min(g), nhi.max(end), true); + } + g = end; + } + } + any = found; + if (nlo, nhi) == (lo, hi) { + break; + } + (lo, hi) = (nlo, nhi); + } + (lo, hi, any) + } + + /// A copy of rows `rows` of this table holding, of each row, the cells that reach into grid + /// columns `cols` (both inclusive): what Copy takes from selected rows or cells. Content + /// controls around rows and the table aren't copied (they'd lose their other end); a cell + /// that continues a vertical merge from a row left out starts the merge instead. + pub fn slice(&self, rows: (usize, usize), cols: (usize, usize)) -> Table { + let (r0, r1) = rows; + let kept = || self.rows.iter().take(r1.saturating_add(1)).skip(r0); + let (lo, hi, any) = self.slice_bounds(rows, cols); + let mut out_rows = Vec::new(); + for row in kept() { + let mut g = 0usize; + let mut cells = Vec::new(); + for cell in &row.cells { + let end = g.saturating_add(cell.span()); + if any && g < hi && end > lo { + cells.push(cell.clone()); + } + g = end; + } + if !cells.is_empty() { + out_rows.push(Row { props: row.props.clone(), cells, controls: ControlWrap::default() }); + } + } + if let Some(first) = out_rows.first_mut() { + for c in &mut first.cells { + if c.props.vmerge == VMerge::Continue { + c.props.vmerge = VMerge::Restart; + } + } + } + let whole = lo == 0 && hi >= self.grid.len(); + let grid = if whole { self.grid.clone() } else { self.grid.get(lo..hi.min(self.grid.len())).map(<[f32]>::to_vec).unwrap_or_default() }; + let mut props = self.props.clone(); + if !whole { + // Part of the columns: as wide as they are, not the whole table's width. + if props.width.is_some() || props.width_pct.is_some() { + props.width = Some(grid.iter().sum()); + } + props.width_pct = None; + } + Table { props, grid, rows: out_rows, controls: ControlWrap::default() } + } + /// A `rows × cols` table spanning `width` points, "Table Grid" style. pub fn new(rows: usize, cols: usize, width: f32) -> Table { let rows = rows.clamp(1, MAX_ROWS); @@ -566,6 +640,69 @@ impl Table { mod tests { use super::*; + /// A 3×3 table, 100 pt columns: (0,0)–(0,1) merged across, (1,2)–(2,2) merged down. + fn merged() -> Table { + let mut t = Table::new(3, 3, 300.0); + t.merge(0, 0, 0, 1); + t.merge(1, 2, 2, 2); + assert_eq!(t.rows[0].cells.iter().map(Cell::span).collect::>(), [2, 1]); + assert_eq!((t.rows[1].cells[2].props.vmerge, t.rows[2].cells[2].props.vmerge), (VMerge::Restart, VMerge::Continue)); + t + } + + fn spans(t: &Table) -> Vec> { + t.rows.iter().map(|r| r.cells.iter().map(Cell::span).collect()).collect() + } + + /// #1744 review: a cell merged across that is partly selected takes its whole width, and the + /// other rows widen with it, so the copy isn't ragged. + #[test] + fn slice_widens_to_a_merged_cell_in_every_row() { + let t = merged().slice((0, 1), (1, 2)); + assert_eq!(t.grid, [100.0, 100.0, 100.0]); + assert_eq!(spans(&t), [vec![2, 1], vec![1, 1, 1]]); + // Rows without the merged cell: just the selected columns. + let t = merged().slice((1, 2), (1, 2)); + assert_eq!(t.grid, [100.0, 100.0]); + assert_eq!(spans(&t), [vec![1, 1], vec![1, 1]]); + } + + /// #1744 review: a cell continuing a vertical merge from a row left out starts it instead. + #[test] + fn slice_starts_a_vertical_merge_whose_first_row_is_left_out() { + let t = merged().slice((2, 2), (0, 2)); + assert_eq!(t.rows.len(), 1); + assert_eq!(t.rows[0].cells[2].props.vmerge, VMerge::Restart); + // Both rows kept: the merge is kept as it is. + let t = merged().slice((1, 2), (2, 2)); + assert_eq!((t.rows[0].cells[0].props.vmerge, t.rows[1].cells[0].props.vmerge), (VMerge::Restart, VMerge::Continue)); + } + + /// #1744 review: part of the columns is as wide as they are; content controls around rows + /// and the table aren't copied. + #[test] + fn slice_width_and_controls() { + let mut t = Table::new(3, 3, 300.0); + (t.props.width, t.props.width_pct) = (Some(300.0), Some(50.0)); + let wrap = ControlWrap { open: vec![crate::control::ContentControl::default()], close: 1 }; + t.controls = wrap.clone(); + t.rows[1].controls = wrap; + let part = t.slice((0, 2), (2, 2)); + assert_eq!((part.grid.len(), part.props.width, part.props.width_pct), (1, Some(100.0), None)); + let whole = t.slice((0, 2), (0, 2)); + assert_eq!((whole.props.width, whole.props.width_pct), (Some(300.0), Some(50.0)), "all columns keep the table's width"); + let row = t.slice((1, 1), (0, 2)); + assert!(row.controls.is_empty() && row.rows[0].controls.is_empty()); + // A table sized only by a percentage keeps a width for the part. + let mut pct = Table::new(3, 3, 300.0); + (pct.props.width, pct.props.width_pct) = (None, Some(50.0)); + let part = pct.slice((0, 2), (2, 2)); + assert_eq!((part.props.width, part.props.width_pct), (Some(100.0), None)); + // Nothing in range: no rows. + assert!(t.slice((5, 6), (0, 2)).rows.is_empty()); + assert!(t.slice((0, 2), (9, 9)).rows.is_empty()); + } + #[test] fn grid_before_shifts_the_row() { let mut t = Table::new(2, 3, 300.0); diff --git a/crates/engine/src/cmd/column.rs b/crates/engine/src/cmd/column.rs index cd68024d6e..6d6c83b805 100644 --- a/crates/engine/src/cmd/column.rs +++ b/crates/engine/src/cmd/column.rs @@ -180,6 +180,8 @@ pub(crate) fn dispatch(s: &mut Session, id: &str, mutates: bool, run: fn(&mut Se // Whole cells: table commands take the rectangle between the corners, and Tab moves on // from it without clearing it, as in Word. _ if cells && (id.starts_with("table.") || matches!(id, "text.tab" | "text.backTab")) => run(s, v), + // Copy and Cut of whole cells take a table of them (#1744). + "edit.copy" | "edit.cut" if cells => run(s, v), "edit.copy" => copy(s, &segs), "edit.cut" => { let r = copy(s, &segs)?; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 641fe1f1f0..42388f392b 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -2,7 +2,7 @@ use serde_json::{Value, json}; use wordcraft_doc::edit::Fragment; -use wordcraft_doc::{Pos, StoryRef}; +use wordcraft_doc::{Path, Pos, StoryRef}; use super::{delete_selection, find_pattern, pos_json, sel_result}; use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; @@ -27,10 +27,16 @@ fn undo_to(s: &mut Session, v: &Value) -> CmdResult { Ok(r) } -fn has_sel(s: &Session) -> Option<&'static str> { +/// Something the Spike can collect: text (it doesn't take empty cells). +fn has_text_sel(s: &Session) -> Option<&'static str> { if s.sel.is_collapsed() { Some("nothing is selected") } else { None } } +/// Something to cut: text, or whole table cells (an empty cell's selection has no length, #1744). +fn has_sel(s: &Session) -> Option<&'static str> { + if s.sel.is_collapsed() && s.cell_range().is_none() { Some("nothing is selected") } else { None } +} + pub fn specs() -> Vec { vec![ CommandSpec::new("edit.undo", "Undo", "Quick Access Toolbar", |s, _| { @@ -63,7 +69,7 @@ pub fn specs() -> Vec { CommandSpec::new("edit.copy", "Copy", "Home › Clipboard", copy).key("Mod+C / Mod+Insert").pure(), // Word's Spike (Ctrl+F3, Ctrl+Shift+F3; ⌘ on the Mac): the AutoText entry "Spike", so Quick // Parts and AutoText insert it without emptying it, as in Word. - CommandSpec::new("edit.spike", "Spike", "Editing", spike).key("Mod+F3").when(has_sel), + CommandSpec::new("edit.spike", "Spike", "Editing", spike).key("Mod+F3").when(has_text_sel), CommandSpec::new("edit.insertSpike", "Insert Spike", "Editing", insert_spike).key("Mod+Shift+F3").when(|s| { if s.math.is_some() { Some("leave the equation first") @@ -167,11 +173,25 @@ pub fn specs() -> Vec { } fn copy(s: &mut Session, _: &Value) -> CmdResult { + let whole = super::table::selected_table(s); + copy_with(s, whole.as_ref()) +} + +/// Copy, `whole` being the table the selection covers whole, if it does. +fn copy_with(s: &mut Session, whole: Option<&Path>) -> CmdResult { let (a, b) = s.sel.ordered(); - if a == b { - return Ok(json!({"text": ""})); - } - let f = s.doc.copy_range(&a, &b); + // Whole table cells selected copy as a table, even empty ones (their selection has no + // length): the whole table, or a table of just the selected rows or cells (#1744). + let table = match (whole, s.cell_range()) { + (Some(tp), _) => s.doc.copy_block(a.story, tp), + (None, Some(r)) => s.doc.copy_table_cells(r.story, &r.table, r.rows, r.cols), + (None, None) => None, + }; + let f = match table { + Some(f) => f, + None if a == b => return Ok(json!({"text": ""})), + None => s.doc.copy_range(&a, &b), + }; s.clipboard_text = f.clipboard_text(); s.clip_history.push(&f, &s.clipboard_text); s.clipboard = Some(f); @@ -200,8 +220,26 @@ pub(crate) fn copy_result(s: &mut Session) -> Value { r } -fn cut(s: &mut Session, v: &Value) -> CmdResult { - let r = copy(s, v)?; +fn cut(s: &mut Session, _: &Value) -> CmdResult { + let whole = super::table::selected_table(s); + let r = copy_with(s, whole.as_ref())?; + // A whole table selected: Cut removes the table, as Delete Table does (#1744). With Track + // Changes on, the cut below marks its text deleted (a tracked cut may become a move). + if !s.doc.settings.track_changes + && let Some(tp) = &whole + { + super::table::delete_table_at(s, tp)?; + super::controls::restore_placeholder(s)?; + return Ok(r); + } + // Whole rows or some cells: their text goes, the rows and cells stay, as in Word. + if whole.is_none() + && let Some(range) = s.cell_range().cloned() + { + super::table::clear_range_cells(s, &range)?; + super::controls::restore_placeholder(s)?; + return Ok(r); + } // A tracked cut of a paragraph or sentence may become a move when pasted. if !super::moves::cut_tracked(s)? { delete_selection(s)?; diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index c471ead45e..edaeab194c 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -20,6 +20,7 @@ pub(super) use cells::grid_cols; pub use cells::selected_cells; use cells::{cell, with_cells, with_table}; pub(crate) use cells::{cell_text, row_ends}; +pub(super) use clear::clear_range_cells; pub(crate) use clear::{backspace_cells, clear_cells}; use clear::{cell_prefix, guard_delete, text_spans}; use convert::{formula, from_text, quick_table, sort_table, to_text}; @@ -31,6 +32,7 @@ use select::{select_cells, select_column}; pub use structure::split_targets; pub(super) use structure::{col, row}; use structure::{del_col, del_row, del_table, merge, split, split_table}; +pub(super) use structure::{delete_table_at, selected_table}; fn in_table(s: &Session) -> Option<&'static str> { if s.sel.focus.path.cell().is_some() { None } else { Some("the cursor isn't in a table") } diff --git a/crates/engine/src/cmd/table/clear.rs b/crates/engine/src/cmd/table/clear.rs index a25bea6b18..2412366050 100644 --- a/crates/engine/src/cmd/table/clear.rs +++ b/crates/engine/src/cmd/table/clear.rs @@ -30,6 +30,30 @@ pub(crate) fn backspace_cells(s: &mut Session) -> Option { Some(r) } +/// Clear the text of the cells `range` selects, leaving the cells (Cut of rows or cells, #1744); +/// tracked when Track Changes is on. The caret goes to the first cell. +pub(in crate::cmd) fn clear_range_cells(s: &mut Session, range: &crate::CellRange) -> Result<(), CmdError> { + let Some(t) = s.doc.table(range.story, &range.table) else { return Ok(()) }; + // The cells Copy takes: columns widened over merged cells, as `Table::slice` does. + let (lo, hi, any) = t.slice_bounds(range.rows, range.cols); + let cells = if any { crate::CellRange { cols: (lo, hi.saturating_sub(1)), ..range.clone() }.cells(t) } else { Vec::new() }; + let prefixes: Vec> = cells.into_iter().map(|(r, c)| cell_prefix(&range.table, r, c)).collect(); + let spans = text_spans(s, range.story, &prefixes); + let first = spans.first().map(|(a, _)| a.clone()); + if guard_delete(s, &spans)?.is_none() { + for (a, b) in spans.iter().rev() { + if a != b { + s.doc.delete_range(a, b)?; + } + } + } + s.column = None; + if let Some(a) = first { + s.sel = Selection::caret(s.doc.clamp(&a)); + } + Ok(()) +} + /// Delete with whole cells selected (#2802): each cell is emptied to one empty paragraph, as in /// Word, nested tables included. That paragraph keeps the formatting of the cell's last one (its /// mark is the end-of-cell mark). `None` without whole cells, and with Track Changes on (the text diff --git a/crates/engine/src/cmd/table/structure.rs b/crates/engine/src/cmd/table/structure.rs index f1818c8daa..bb5e293315 100644 --- a/crates/engine/src/cmd/table/structure.rs +++ b/crates/engine/src/cmd/table/structure.rs @@ -2,6 +2,7 @@ use super::cells::{cell, selected_cells}; use super::clear::{cell_prefix, guard_delete, text_spans}; use super::layout::{fix_caret, sync_cell_widths}; use super::{Block, CmdError, CmdResult, Paragraph, Pos, Selection, Session, Value, json, p, sel_result}; +use wordcraft_doc::Path; /// Insert Above / Below: rows like the caret's, as many as the selection spans; the caret goes into the first. pub(in crate::cmd) fn row(s: &mut Session, below: bool) -> CmdResult { @@ -104,6 +105,25 @@ pub(super) fn del_col(s: &mut Session, _: &Value) -> CmdResult { pub(super) fn del_table(s: &mut Session, _: &Value) -> CmdResult { let (tp, _, _) = cell(s)?; + delete_table_at(s, &tp) +} + +/// The table the selection covers whole (Select Table, a drag over all of it), if it does (#1744). +pub(in crate::cmd) fn selected_table(s: &Session) -> Option { + if let Some(r) = s.cell_range() + && let Some(t) = s.doc.table(r.story, &r.table) + && r.rows == (0, t.rows.len().saturating_sub(1)) + && r.cols == (0, t.cols().saturating_sub(1)) + { + return Some(r.table.clone()); + } + let (a, b) = s.sel.ordered(); + s.doc.whole_table(&a, &b) +} + +/// Delete Table for the table at `tp` (in the selection's story): tracked when Track Changes is on. +pub(in crate::cmd) fn delete_table_at(s: &mut Session, tp: &Path) -> CmdResult { + let tp = tp.clone(); let story = s.sel.focus.story; let spans = text_spans(s, story, std::slice::from_ref(&tp.0)); if let Some(done) = guard_delete(s, &spans)? { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a61b696af5..ab72f5e6be 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1943,6 +1943,291 @@ fn tables_commands() { assert!(s.run("table.merge", &json!({})).is_err()); } +/// A 2×2 table "A1 B1 / A2 B2" after an "Intro" paragraph, the caret in its first cell. +fn table_doc() -> Session { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "Intro"})); + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + for (i, t) in ["A1", "B1", "A2", "B2"].iter().enumerate() { + if i > 0 { + run(&mut s, "text.tab", json!({})); + } + run(&mut s, "text.insert", json!({"text": t})); + } + s +} + +fn body_table_cells(s: &Session) -> Vec> { + s.doc + .body + .iter() + .filter_map(|b| b.as_table()) + .map(|t| { + t.rows + .iter() + .flat_map(|r| r.cells.iter()) + .map(|c| c.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::()) + .collect() + }) + .collect() +} + +/// #1744: Select Table → Copy → Paste outside the table pastes a copy of the whole table. +#[test] +fn copy_whole_table_pastes_a_table() { + let mut s = table_doc(); + run(&mut s, "table.selectTable", json!({})); + let r = run(&mut s, "edit.copy", json!({})); + assert!(r["text"].as_str().unwrap().contains("A1\tB1"), "clipboard text is tab separated: {r}"); + assert!(matches!(s.clipboard.as_ref().unwrap().blocks.as_slice(), [wordcraft_doc::Block::Table(_)]), "the copy is the table"); + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "edit.paste", json!({})); + let want = vec!["A1".to_string(), "B1".into(), "A2".into(), "B2".into()]; + assert_eq!(body_table_cells(&s), vec![want.clone(), want]); +} + +/// #1744: Select Table → Cut removes the whole table, and one Undo brings it back. +#[test] +fn cut_whole_table_removes_it_and_undoes() { + let mut s = table_doc(); + run(&mut s, "table.selectTable", json!({})); + run(&mut s, "edit.cut", json!({})); + assert!(body_table_cells(&s).is_empty(), "the table is gone"); + assert!(s.sel.focus.path.cell().is_none(), "the caret left the table"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(body_table_cells(&s), vec![vec!["A1".to_string(), "B1".into(), "A2".into(), "B2".into()]]); + // Paste puts it back as a table. + run(&mut s, "table.selectTable", json!({})); + run(&mut s, "edit.cut", json!({})); + run(&mut s, "edit.paste", json!({})); + assert_eq!(body_table_cells(&s).len(), 1); +} + +/// A 3×3 table "A1 B1 C1 / A2 B2 C2 / A3 B3 C3" after an "Intro" paragraph (#1744). +fn table3_doc() -> Session { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "Intro"})); + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "insert.table", json!({"rows": 3, "cols": 3})); + for (i, t) in ["A1", "B1", "C1", "A2", "B2", "C2", "A3", "B3", "C3"].iter().enumerate() { + if i > 0 { + run(&mut s, "text.tab", json!({})); + } + run(&mut s, "text.insert", json!({"text": t})); + } + s +} + +/// Select whole cells of the body's table: rows and grid columns, both inclusive. +fn select_table_cells(s: &mut Session, rows: (usize, usize), cols: (usize, usize)) { + let tp = wordcraft_doc::Path(vec![1]); + crate::cmd::table::select_range(s, StoryRef::Body, &tp, rows, cols).unwrap(); + assert_eq!(s.cell_range().map(|r| (r.rows, r.cols)), Some((rows, cols))); +} + +fn row_cells(texts: &[&str]) -> Vec { + texts.iter().map(|t| t.to_string()).collect() +} + +/// #1744 (Word check A): copying whole rows, two cells of a row, or a block of cells over +/// several rows, then pasting outside the table, pastes a table of just those cells. +#[test] +fn copy_rows_and_cells_pastes_a_table_of_them() { + let full = row_cells(&["A1", "B1", "C1", "A2", "B2", "C2", "A3", "B3", "C3"]); + for (rows, cols, want) in [ + ((1, 1), (0, 2), row_cells(&["A2", "B2", "C2"])), + ((0, 0), (0, 1), row_cells(&["A1", "B1"])), + ((1, 2), (1, 2), row_cells(&["B2", "C2", "B3", "C3"])), + ] { + let mut s = table3_doc(); + select_table_cells(&mut s, rows, cols); + run(&mut s, "edit.copy", json!({})); + let Some([wordcraft_doc::Block::Table(t)]) = s.clipboard.as_ref().map(|f| f.blocks.as_slice()) else { + panic!("{rows:?} {cols:?}: the copy is a table, not {:?}", s.clipboard_text); + }; + assert_eq!(t.rows.len(), rows.1 - rows.0 + 1, "{rows:?} {cols:?}"); + assert_eq!(t.grid.len(), cols.1 - cols.0 + 1, "{rows:?} {cols:?}: the grid has the copied columns"); + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "edit.paste", json!({})); + assert_eq!(body_table_cells(&s), vec![want, full.clone()], "{rows:?} {cols:?}"); + } +} + +/// #1744 (Word check A): Cut of whole rows, or of some cells, clears their text and leaves +/// the rows and cells; the clipboard still gets a table of them. One Undo brings the text back. +#[test] +fn cut_rows_and_cells_clears_their_text() { + let full = row_cells(&["A1", "B1", "C1", "A2", "B2", "C2", "A3", "B3", "C3"]); + for (rows, cols, left, copied) in [ + ((1, 1), (0, 2), row_cells(&["A1", "B1", "C1", "", "", "", "A3", "B3", "C3"]), row_cells(&["A2", "B2", "C2"])), + ((0, 0), (0, 1), row_cells(&["", "", "C1", "A2", "B2", "C2", "A3", "B3", "C3"]), row_cells(&["A1", "B1"])), + ((1, 2), (1, 2), row_cells(&["A1", "B1", "C1", "A2", "", "", "A3", "", ""]), row_cells(&["B2", "C2", "B3", "C3"])), + ] { + let mut s = table3_doc(); + select_table_cells(&mut s, rows, cols); + run(&mut s, "edit.cut", json!({})); + assert_eq!(body_table_cells(&s), vec![left.clone()], "{rows:?} {cols:?}: rows and cells stay"); + assert!(s.sel.is_collapsed(), "{rows:?} {cols:?}"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(body_table_cells(&s), vec![full.clone()], "{rows:?} {cols:?}: one undo step"); + select_table_cells(&mut s, rows, cols); + run(&mut s, "edit.cut", json!({})); + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "edit.paste", json!({})); + assert_eq!(body_table_cells(&s), vec![copied, left], "{rows:?} {cols:?}: the cut pastes as a table"); + } +} + +/// #1744 review: Cut clears every cell Copy takes: a cell merged across the selected columns +/// widens them, so the cell below the merge's second column is copied and cleared too. +#[test] +fn cut_clears_the_cells_a_merged_cell_widens_the_copy_to() { + let mut s = table3_doc(); + select_table_cells(&mut s, (0, 0), (0, 1)); + run(&mut s, "table.merge", json!({})); + select_table_cells(&mut s, (0, 1), (0, 0)); + run(&mut s, "edit.copy", json!({})); + run(&mut s, "edit.cut", json!({})); + let cells = body_table_cells(&s).remove(0); + assert_eq!(cells.len(), 8, "one merged cell and seven others: {cells:?}"); + assert_eq!(cells[1], "C1"); + assert!(cells[2..4].iter().all(String::is_empty), "A2 and B2 are cleared: {cells:?}"); + assert_eq!(cells[4..], ["C2", "A3", "B3", "C3"], "the rest stays"); +} + +/// #1744 review: an empty cell's selection enables Cut but not the Spike, which takes text only. +#[test] +fn spike_stays_disabled_on_an_empty_cell_selection() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "Intro"})); + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + select_table_cells(&mut s, (0, 0), (0, 0)); + let r = s.run("edit.spike", &json!({})); + assert!(matches!(r, Err(crate::CmdError::Disabled(_))), "{r:?}"); +} + +fn clip_is_table(s: &Session) -> bool { + matches!(s.clipboard.as_ref().map(|f| f.blocks.as_slice()), Some([wordcraft_doc::Block::Table(_)])) +} + +/// The body table's cells as they read with every change accepted. +fn body_table_final(s: &Session) -> Vec { + let t = s.doc.body.iter().find_map(|b| b.as_table()).expect("a table"); + t.rows.iter().flat_map(|r| r.cells.iter()).map(|c| c.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.final_text()).collect()).collect() +} + +/// #1744 review: an empty table or cell selected whole still copies, and Cut of the empty +/// table removes it, as in Word. +#[test] +fn copy_and_cut_of_an_empty_table_or_cell() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "Intro"})); + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); + run(&mut s, "table.selectTable", json!({})); + run(&mut s, "edit.copy", json!({})); + assert!(clip_is_table(&s), "an empty 1×1 table copies: {:?}", s.clipboard); + run(&mut s, "edit.cut", json!({})); + assert!(body_table_cells(&s).is_empty(), "Cut removes the empty table"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(body_table_cells(&s), vec![vec![String::new()]]); + + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "document.setText", json!({"text": "Intro"})); + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + select_table_cells(&mut s, (0, 0), (0, 0)); + run(&mut s, "edit.copy", json!({})); + assert!(clip_is_table(&s), "an empty cell copies as a one-cell table"); + run(&mut s, "edit.cut", json!({})); + assert_eq!(body_table_cells(&s).len(), 1, "Cut of an empty cell leaves the table"); +} + +/// #1744 review: a text selection over exactly a whole table (no whole-cell record) copies the +/// table; so does one over exactly a table nested in a cell. Other users of the copied range +/// read it as a table too: the selected text is tab separated, a Quick Part saved from it +/// inserts a table. +#[test] +fn text_selection_of_a_whole_table_copies_the_table() { + let mut s = table_doc(); + let at = |path: Vec, off: usize| json!({"story": "body", "path": path, "off": off}); + run(&mut s, "select.range", json!({"anchor": at(vec![1, 0, 0, 0], 0), "focus": at(vec![1, 1, 1, 0], 2)})); + assert!(s.cell_range().is_none(), "a text selection"); + assert_eq!(s.selected_text(), "A1\tB1\nA2\tB2"); + run(&mut s, "edit.copy", json!({})); + assert!(clip_is_table(&s)); + run(&mut s, "insert.quickParts", json!({"save": "grid"})); + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "insert.quickParts", json!({"insert": "grid"})); + assert_eq!(body_table_cells(&s).len(), 2, "the Quick Part is a table"); + + // A table nested in cell A1: selecting its text whole copies just it. + let mut s = table_doc(); + run(&mut s, "select.range", json!({"anchor": at(vec![1, 0, 0, 0], 2), "focus": at(vec![1, 0, 0, 0], 2)})); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 1})); + run(&mut s, "text.insert", json!({"text": "N1"})); + run(&mut s, "text.tab", json!({})); + run(&mut s, "text.insert", json!({"text": "N2"})); + let (inner, _, _) = s.sel.focus.path.cell().unwrap(); + assert_eq!(inner.0.len(), 4, "nested in a cell"); + let first = [inner.0.clone(), vec![0, 0, 0]].concat(); + let last = [inner.0.clone(), vec![1, 0, 0]].concat(); + run(&mut s, "select.range", json!({"anchor": at(first, 0), "focus": at(last, 2)})); + assert!(s.cell_range().is_none(), "a text selection"); + run(&mut s, "edit.copy", json!({})); + let Some([wordcraft_doc::Block::Table(t)]) = s.clipboard.as_ref().map(|f| f.blocks.as_slice()) else { panic!("{:?}", s.clipboard_text) }; + assert_eq!(t.rows.len(), 2, "the nested table, not the outer one"); +} + +/// #1744 review: with Track Changes on, Cut of the whole table or of a row marks the text +/// deleted and keeps the table; one Undo takes the marks back. +#[test] +fn tracked_cut_of_a_table_or_row_marks_text_deleted() { + let full = row_cells(&["A1", "B1", "C1", "A2", "B2", "C2", "A3", "B3", "C3"]); + let mut s = table3_doc(); + s.doc.settings.track_changes = true; + run(&mut s, "table.selectTable", json!({})); + run(&mut s, "edit.cut", json!({})); + assert!(clip_is_table(&s)); + assert_eq!(body_table_cells(&s), vec![full.clone()], "the table and its text stay, marked"); + assert!(body_table_final(&s).iter().all(String::is_empty), "{:?}", body_table_final(&s)); + run(&mut s, "edit.undo", json!({})); + assert_eq!(body_table_final(&s), full); + + select_table_cells(&mut s, (1, 1), (0, 2)); + run(&mut s, "edit.cut", json!({})); + assert!(clip_is_table(&s)); + assert_eq!(body_table_cells(&s), vec![full.clone()]); + assert_eq!(body_table_final(&s), row_cells(&["A1", "B1", "C1", "", "", "", "A3", "B3", "C3"])); + run(&mut s, "edit.undo", json!({})); + assert_eq!(body_table_final(&s), full); +} + +/// #1744 review: Cut of a table, or of cells, holding a content control locked against deletion +/// is refused and changes nothing in the document; the copy is made first, so the clipboard +/// holds the table, as for any refused Cut. +#[test] +fn cut_of_cells_with_a_locked_control_is_refused() { + for whole in [true, false] { + let mut s = table3_doc(); + run(&mut s, "edit.find", json!({"text": "B2"})); + run(&mut s, "developer.richText", json!({"tag": "keep"})); + run(&mut s, "developer.properties", json!({"control": "keep", "lockDelete": true})); + if whole { + run(&mut s, "table.selectTable", json!({})); + } else { + select_table_cells(&mut s, (1, 1), (0, 2)); + } + let before = s.doc.clone(); + assert!(s.run("edit.cut", &json!({})).is_err(), "whole table: {whole}"); + assert_eq!(s.doc, before, "whole table: {whole}"); + assert!(s.status.contains("can't be deleted"), "{}", s.status); + assert!(clip_is_table(&s), "whole table: {whole}"); + } +} + #[test] fn inserting_a_column_keeps_cell_widths_in_step_with_the_grid() { let mut s = s(); From dc48046057db4cfb047e84c8fe63c09a7f791cbd Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Fri, 30 Oct 2026 02:58:32 +0100 Subject: [PATCH 05/73] #1103: ignore macOS launch arguments and name the real cause when a file won't open (#2622) * #1103: ignore macOS launch arguments and name the real cause when a file won't open Co-Authored-By: Claude Sonnet 5.5 * #1103: filter launch args on macOS only; leave Backstage after a failed Finder open Co-Authored-By: Claude Sonnet 5.5 * #1103: leave Backstage after a failed drag-and-drop open Co-Authored-By: Claude Sonnet 5.5 * #1103: name the file when an open fails after the Save changes prompt Co-Authored-By: Claude Sonnet 5.5 * #1103: report failed opens from every deferred path in execute_user Co-Authored-By: Claude Sonnet 5.5 * #1103: keep the named failed-open message after a browser save Co-Authored-By: Claude Sonnet 5.5 * #1103: only opens held back by a prompt or save leave Backstage on failure A direct open from Recent keeps File open and reports there (#2938). Co-Authored-By: Claude Sonnet 5.5 * #1103: re-apply open-failure reporting on main's split ui-egui modules Co-Authored-By: Claude Sonnet 5.5 * Qualify i18n paths in report_open_failure after merging main --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 ++ apps/wordcraft/src/apple_events.rs | 7 ++-- apps/wordcraft/src/main.rs | 55 ++++++++++++++++++++++++++++-- crates/ui-egui/src/app/commands.rs | 17 +++++++++ crates/ui-egui/src/app/files.rs | 15 ++++++-- crates/ui-egui/src/app/frame.rs | 9 +++++ crates/ui-egui/src/file_dialogs.rs | 2 +- crates/ui-egui/src/tests.rs | 38 +++++++++++++++++++++ docs/file-format-parity.md | 8 +++++ 9 files changed, 143 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index aaafccf460..2ab3a1a164 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -202,6 +202,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 2026-10-17 | macOS launch arguments (`-psn_*`, `-NS*`, `-Apple*`) are no longer opened as files, and a file that fails to open names the file and the real cause on the status bar and in the log (#1103). The reported "corrupt" error could not be reproduced. | | 2026-10-17 | Password-protected Word files from Word 2007 (Standard encryption: AES-128/192/256, SHA-1) open with their password, also when named `.doc` or `.dot`; a wrong or missing password is reported as for newer files. Word 97-2003 files with an AES header, which the public spec doesn't define, are refused with a clear message (gap 9) | | 2026-10-23 | A command that changes nothing is not an edit: it adds no undo step, marks no unsaved changes and forces no relayout, so the redo stack survives it and five hundred of them can no longer push the user's own edits out of the undo history. | | 2026-10-23 | Interface translations come from storytold/craft-i18n, shared with the other Crafting Apps: the catalogs are embedded at build time from the commit in `craft-i18n.rev` and looked up through craft-libs' `craft-i18n` crate; `crates/ui-egui/src/i18n/*.tsv` are gone (translations now go to craft-i18n). Strings other apps already translate show translated; a build without `CRAFT_I18N_DIR` is English only | @@ -752,6 +753,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-18 | trivial | Progress log: header-cell picture watermarks drawn behind the text (#1775); readiness estimates unchanged | | 2026-10-14 | trivial | Progress log: comment drafts kept and written before saving or closing (#1348) | | 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged | +| 2026-10-17 | trivial | Progress log: macOS launch arguments and open failures (#1103) | | 2026-10-14 | trivial | Progress log: ASK and SET fields show nothing (#1765) | | 2026-10-15 | trivial | Progress log: at-least table rows start the next page whole (#1742) | | 2026-10-14 | trivial | Progress log: ODT sections on export (gap 19) | diff --git a/apps/wordcraft/src/apple_events.rs b/apps/wordcraft/src/apple_events.rs index 0eae596a9d..e79d923a9a 100644 --- a/apps/wordcraft/src/apple_events.rs +++ b/apps/wordcraft/src/apple_events.rs @@ -47,10 +47,9 @@ pub fn poll(inbox: &Inbox, app: &mut WordApp, ctx: &egui::Context) { // Leave Backstage, as opening from the Open dialog does. Ok(_) => app.ui.backstage = false, Err(e) => { - log::warn!("{p}: {e}"); - let e = e.to_string(); - let msg = wordcraft_ui_egui::tl!("Couldn't open {path}: {error}"); - app.status(wordcraft_ui_egui::i18n::fmt(msg, &[("path", p), ("error", &e)])); + app.report_open_failure(p, &e); + // Backstage hides the status bar, so leave it to show the error. + app.ui.backstage = false; } } } diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index bc66c94115..765a0f3a95 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -329,6 +329,26 @@ fn text_args() -> (Vec, Vec) { (args, files) } +/// The arguments without the ones macOS adds when it launches an app (#1103): `-psn_0_12345`, and +/// the `-NS…` / `-Apple…` options (`-AppleLanguages (en)`, `-NSDocumentRevisionsDebugMode YES`) with +/// the value after each. They are not files, and opening them as files fails. +fn without_launch_args(args: Vec) -> Vec { + let mut kept = Vec::with_capacity(args.len()); + let mut args = args.into_iter().peekable(); + while let Some(a) = args.next() { + if a.starts_with("-psn_") { + continue; + } + if a.starts_with("-NS") || a.starts_with("-Apple") { + // Its value, unless the next argument is another flag. + args.next_if(|v| !v.starts_with('-')); + continue; + } + kept.push(a); + } + kept +} + /// Open a file whose name isn't valid Unicode (#1440). `file.open` takes its path as text, so it /// gets the file's bytes under the lossy name, and the document then keeps the real path to save to. /// The bytes are read as `file.open` reads a path: only a regular file, and at most 2 GB. @@ -340,7 +360,9 @@ fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) { // Not when it waits for a password: that open names the document itself. Ok(r) if r.get("pending").is_none() => app.session.path = Some(path.into()), Ok(_) => {} - Err(e) => log::warn!("{name}: {e}"), + Err(e) => { + app.report_open_failure(&name, &e); + } } } @@ -360,6 +382,8 @@ fn main() -> eframe::Result { let mut files = Vec::new(); let mut sample = false; let (args, other_files) = text_args(); + // Only macOS (Finder / LaunchServices) adds these; elsewhere `-NS…` could be a real file name. + let args = if cfg!(target_os = "macos") { without_launch_args(args) } else { args }; let mut bad_control: Option> = None; let mut args = args.into_iter().peekable(); while let Some(a) = args.next() { @@ -504,7 +528,7 @@ fn main() -> eframe::Result { } for f in files { if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) { - log::warn!("{f}: {e}"); + app.report_open_failure(&f, &e); } } for f in other_files { @@ -590,6 +614,33 @@ mod tests { } } +#[cfg(test)] +mod launch_arg_tests { + use super::without_launch_args; + + fn strip(args: &[&str]) -> Vec { + without_launch_args(args.iter().map(|a| a.to_string()).collect()) + } + + #[test] + fn macos_launch_arguments_are_not_files() { + assert_eq!(strip(&["-psn_0_12345", "a.docx"]), ["a.docx"]); + assert_eq!(strip(&["-AppleLanguages", "(en)", "a.docx"]), ["a.docx"]); + assert_eq!(strip(&["a.docx", "-NSDocumentRevisionsDebugMode", "YES", "b.docx"]), ["a.docx", "b.docx"]); + // A flag with no value after it, or followed by another flag, takes nothing with it. + assert_eq!(strip(&["a.docx", "-AppleLocale"]), ["a.docx"]); + assert_eq!(strip(&["-NSFoo", "--sample", "a.docx"]), ["--sample", "a.docx"]); + } + + #[test] + fn real_arguments_are_kept() { + assert_eq!(strip(&["--control", "7981", "--sample", "-h"]), ["--control", "7981", "--sample", "-h"]); + // Files whose names merely start like a launch argument keep their place when they are paths. + assert_eq!(strip(&["./-psn_x.docx", "Apple.docx", "-", "-nsfoo"]), ["./-psn_x.docx", "Apple.docx", "-", "-nsfoo"]); + assert!(strip(&[]).is_empty()); + } +} + #[cfg(test)] mod prefs_tests { use super::write_prefs; diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index da20111fcd..79b795ddf5 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -316,6 +316,7 @@ impl WordApp { }; } let retry = (id == "file.open").then(|| params.clone()); + let retry_path = retry.clone(); let r = self.execute(id, params); if let (Err(e), Some(params)) = (&r, retry) && wordcraft_engine::io::needs_password(e) @@ -325,6 +326,22 @@ impl WordApp { self.dialog = Some(dialogs::Dialog::Password { name, params, password: Default::default(), message: String::new() }); return Ok(json!({"pending": "password"})); } + // Whoever asked, a failed open names the file and the cause (#1103). + if let (Err(e), Some(params)) = (&r, retry_path) { + let path = params.get("path").and_then(Value::as_str).unwrap_or(""); + self.report_open_failure(path, e); + } + r + } + + /// [`WordApp::execute_user`] for a command a prompt or a finished save held back. A failed + /// open also leaves Backstage, whose list isn't where that failure gets shown (#1103); a + /// direct open from Recent keeps File open and reports there instead (#2938). + pub(crate) fn execute_held_back(&mut self, id: &str, params: Value) -> Result { + let r = self.execute_user(id, params); + if r.is_err() && id == "file.open" { + self.ui.backstage = false; + } r } diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 88416ffe23..1591f13f5e 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -51,7 +51,7 @@ impl WordApp { if !go { return Ok(json!({"done": false})); } - self.execute_user(&then, params).map(|r| json!({"done": true, "result": r})) + self.execute_held_back(&then, params).map(|r| json!({"done": true, "result": r})) } /// Save before New/Open/Close (and the mailings that replace the document): true once the @@ -144,7 +144,11 @@ impl WordApp { match outcome { // Only when nothing changed since and no later save of it is still unconfirmed. async_saves::SaveOutcome::Saved if !self.session.dirty && self.session.rev() == wait.rev && !self.save_unconfirmed() => { - if let Err(e) = self.execute_user(&wait.then, wait.params) { + // A failed open was already reported by name in `execute_user`. + let is_open = wait.then == "file.open"; + if let Err(e) = self.execute_held_back(&wait.then, wait.params) + && !is_open + { self.status(e); } } @@ -328,7 +332,7 @@ impl WordApp { let lp = path.to_ascii_lowercase(); let img = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".wmf", ".emf"].iter().any(|e| lp.ends_with(e)); let sheet = wordcraft_engine::cmd::mailings::is_spreadsheet_name(&lp); - let _ = if img { + let opened = if img { self.caret_after_placed_picture(placed_picture); let r = self.run("insert.picture", json!({"path": path})); placed_picture = Self::placed_in_text(&r); @@ -341,6 +345,11 @@ impl WordApp { } else { self.open_file(&path, None) }; + if let Err(e) = opened { + self.report_open_failure(&path, &e); + // Backstage hides the status bar; leave it so the error shows. + self.ui.backstage = false; + } } if self.session.document_id() != before { self.autosave_path = None; diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 0c5bb2acda..e7aea7ddff 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -6,6 +6,15 @@ use super::{ }; impl WordApp { + /// A file that wouldn't open (#1103): log it, and say on the status bar which file and why, + /// whatever handed it over (the command line, a macOS open event, a drop). Returns the message. + pub fn report_open_failure(&mut self, path: &str, error: &str) -> String { + log::warn!("{path}: {error}"); + let msg = crate::i18n::fmt(crate::tl!("Couldn't open {path}: {error}"), &[("path", path), ("error", error)]); + self.status(msg.clone()); + msg + } + pub fn status(&mut self, s: impl Into) { self.status_msg = Some((s.into(), now_ms())); } diff --git a/crates/ui-egui/src/file_dialogs.rs b/crates/ui-egui/src/file_dialogs.rs index 9b04472eda..6bec025c88 100644 --- a/crates/ui-egui/src/file_dialogs.rs +++ b/crates/ui-egui/src/file_dialogs.rs @@ -356,7 +356,7 @@ impl WordApp { if !self.safely_saved(saved) || document != self.session.document_id() { return Ok(json!({"done": false})); } - self.execute_user(&then, params).map(|r| json!({"done": true, "result": r})) + self.execute_held_back(&then, params).map(|r| json!({"done": true, "result": r})) } } } diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 8bd4b80898..fe836c3b61 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -724,6 +724,44 @@ fn templates_and_files_open_from_the_start_screen() { let _ = std::fs::remove_dir_all(dir); } +/// #1103: a file that fails to open names the file and the real cause on the status bar. +#[test] +fn a_file_that_wont_open_says_which_file_and_why() { + let dir = scratch("open-failure"); + let bad = dir.join("broken.docx"); + std::fs::write(&bad, b"PK\x03\x04 this is not really a zip").unwrap(); + let mut a = app(); + let path = bad.to_string_lossy().to_string(); + let error = a.run("file.open", json!({"path": path})).unwrap_err(); + assert!(error.contains("zip") || error.contains("Word"), "the engine names the cause: {error}"); + let msg = a.report_open_failure(&path, &error); + assert!(msg.contains(&path) && msg.contains(&error), "{msg}"); + assert_eq!(a.status_msg.as_ref().map(|(m, _)| m.as_str()), Some(msg.as_str())); + + let missing = dir.join("gone.docx").to_string_lossy().to_string(); + let error = a.run("file.open", json!({"path": missing})).unwrap_err(); + let msg = a.report_open_failure(&missing, &error); + assert!(msg.contains("gone.docx") && (msg.to_lowercase().contains("no such file") || msg.contains("os error")), "{msg}"); + let _ = std::fs::remove_dir_all(dir); +} + +/// A failed open of a file, with unsaved typing in the window, still names the file and the +/// cause (#1103). (An open no longer waits on "Save changes?": the file opens in its own window.) +#[test] +fn failed_open_with_unsaved_work_names_the_file() { + let dir = scratch("open-over-unsaved"); + let bad = dir.join("broken.docx").to_string_lossy().to_string(); + std::fs::write(&bad, b"PK\x03\x04 not really a zip").unwrap(); + let mut a = typed(); + a.ui.backstage = true; + assert!(a.execute_held_back("file.open", json!({"path": bad})).is_err()); + let shown = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default(); + assert!(shown.contains("broken.docx"), "{shown}"); + assert!(!a.ui.backstage, "Backstage hides the status bar"); + assert!(body_text(&a).contains(UNSAVED), "the document is untouched"); + let _ = std::fs::remove_dir_all(dir); +} + /// The Start screen setting is a UI command, kept between runs; settings saved before it /// existed leave it on, and opening saved settings never reopens the Backstage by itself. #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 6b38552f56..d19bd1dbc1 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -139,6 +139,13 @@ Double-clicking a document, Open With, and making WordCraft the default app (#29 register WordCraft for these types but never take over a default; File › Options › General has **Make WordCraft the default for Word documents…** where the system allows it. +Arguments macOS adds when it launches an app (`-psn_…`, `-NS…`/`-Apple…` options with their +values) are dropped before the rest are opened as files, and a file that fails to open, whether it +came from the command line, a macOS open event or a drop on the window, names the file and the +real cause on the status bar and in the log (#1103). The report of "file is corrupt" after +choosing "Always open with" could not be reproduced on 0.7.0 in a Mac check; these two +hardenings are what the code supports. + | Platform | Registered types | Default app | Notes | |---|---|---|---| | Windows (MSI) | `.docx` `.docm` `.dotx` `.dotm` `.doc` `.dot` `.rtf` `.odt`; `.md` `.txt` under Open With only | Options button opens Settings › Default apps on WordCraft's page | One ProgId per type, `OpenWithProgids`, Capabilities + `RegisteredApplications` (`packaging/windows/wordcraft.wxs`). Not yet tried on a real install | @@ -264,6 +271,7 @@ Repeated DOCX style IDs use the last complete definition. The original regressio | Date | Change | Summary | |---|---|---| +| 2026-10-17 | trivial | macOS launch arguments (`-psn_*`, `-NS*`, `-Apple*`) are no longer opened as files; a file that fails to open (command line, open event, drop) names the file and the cause on the status bar and in the log (#1103) | | 2026-10-17 | trivial | Encryption rows: Standard (Word 2007, AES-ECB) encrypted packages open with a password, also when named `.doc`/`.dot`; AES headers in Word 97-2003 files are outside the public spec and refused (gap 9). The duplicate combined `.docx`/`.doc` row is removed; the `.doc` row reads 🟡 ~60% (it opens XOR/RC4/RC4 CryptoAPI); hours 2–4 → 1–2 and 4–6 → 1–2 (left: checks against Word-written files); total re-summed from the rows, 175–287 h | | 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`) | From acccf913ab35c89c91f7b4d978fb97955a15334e Mon Sep 17 00:00:00 2001 From: TobyWu <131847204+TobyWu666@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:03:12 +0800 Subject: [PATCH 06/73] Traditional Chinese interface: draw hanzi to the Taiwan standard with an installed TC font (#3380) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * i18n: Traditional Chinese interface draws hanzi with an installed TC font The zh-hant interface shared the Simplified Chinese font order, so its first CJK face was the embedded Noto Sans CJK SC and traditional characters were drawn with mainland glyph shapes (說, 資, 這, 為, 戶). On the desktop, a Traditional Chinese interface now puts an installed Traditional Chinese font before every CJK face: PingFang TC on macOS, Microsoft JhengHei on Windows, Noto Sans CJK TC on Linux. Noto Sans CJK SC stays after it as the fallback. Without an installed TC font, and on wasm, the order is unchanged. Co-Authored-By: Claude Opus 5.5 * Credits: add TobyWu666 to contributors/people.toml Co-Authored-By: Claude Opus 5.5 * i18n: find Traditional Chinese fonts at the root of a font folder Linux user font folders (~/.fonts, ~/.local/share/fonts) often hold font files directly; the Traditional Chinese candidates only named files in distribution subfolders, so those fonts were missed (CodeRabbit review). Co-Authored-By: Claude Opus 5.5 * i18n: skip the Traditional Chinese font test without embedded catalogs Since the catalogs come from craft-i18n (#4072), a build without CRAFT_I18N_DIR is English only, so the test now skips like the other catalog tests. Also rustfmt hant_font_chars. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + contributors/people.toml | 3 + crates/fonts/src/fontdb.rs | 2 +- crates/fonts/src/fontdb/tests.rs | 2 +- crates/fonts/src/fontdb/tests/sysfonts.rs | 4 +- crates/fonts/src/lib.rs | 2 +- crates/fonts/src/ui_fallback.rs | 163 +++++++++++++++++++--- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/fonts.rs | 9 +- crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/i18n.rs | 16 +++ crates/ui-egui/src/i18n/tests.rs | 34 ++++- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/theme.rs | 37 ++++- crates/ui-egui/src/window_ui.rs | 1 + docs/localization-parity.md | 7 +- 16 files changed, 245 insertions(+), 39 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2ab3a1a164..795e54b1fe 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -202,6 +202,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 2026-10-28 | The Traditional Chinese interface draws hanzi to the Taiwan standard: on the desktop an installed Traditional Chinese font (PingFang TC on macOS, Microsoft JhengHei on Windows, Noto Sans CJK TC on Linux) comes before the embedded Noto Sans CJK SC, which drew 說, 這, 為, 戶 in mainland shapes; without one, and on the web, nothing changes. | | 2026-10-17 | macOS launch arguments (`-psn_*`, `-NS*`, `-Apple*`) are no longer opened as files, and a file that fails to open names the file and the real cause on the status bar and in the log (#1103). The reported "corrupt" error could not be reproduced. | | 2026-10-17 | Password-protected Word files from Word 2007 (Standard encryption: AES-128/192/256, SHA-1) open with their password, also when named `.doc` or `.dot`; a wrong or missing password is reported as for newer files. Word 97-2003 files with an AES header, which the public spec doesn't define, are refused with a clear message (gap 9) | | 2026-10-23 | A command that changes nothing is not an edit: it adds no undo step, marks no unsaved changes and forces no relayout, so the redo stack survives it and five hundred of them can no longer push the user's own edits out of the undo history. | diff --git a/contributors/people.toml b/contributors/people.toml index ea73277286..231ad63530 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -181,3 +181,6 @@ sync_github_name = true [people.pakiat] display_name = "OtorO" + +[people.TobyWu666] +display_name = "tobywu" diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 389e7f346f..44898b8711 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -161,4 +161,4 @@ impl FontDb { } #[cfg(test)] -mod tests; +pub(crate) mod tests; diff --git a/crates/fonts/src/fontdb/tests.rs b/crates/fonts/src/fontdb/tests.rs index 48e1573938..a21221e416 100644 --- a/crates/fonts/src/fontdb/tests.rs +++ b/crates/fonts/src/fontdb/tests.rs @@ -1,5 +1,5 @@ #[cfg(not(target_arch = "wasm32"))] -mod sysfonts; +pub(crate) mod sysfonts; #[test] #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/fonts/src/fontdb/tests/sysfonts.rs b/crates/fonts/src/fontdb/tests/sysfonts.rs index 30a1a0f047..c95adb6b3b 100644 --- a/crates/fonts/src/fontdb/tests/sysfonts.rs +++ b/crates/fonts/src/fontdb/tests/sysfonts.rs @@ -12,7 +12,7 @@ fn renamed(file: &str) -> Vec { } /// A bundled Source Sans 3 file renamed `family` (13 characters, as long as the original name). -fn renamed_to(file: &str, family: &str) -> Vec { +pub(crate) fn renamed_to(file: &str, family: &str) -> Vec { assert_eq!(family.len(), "Source Sans 3".len()); let mut data = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap(); let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); @@ -27,7 +27,7 @@ fn renamed_to(file: &str, family: &str) -> Vec { } /// The fonts in `faces` as one collection (TTC) file. -fn collection(faces: &[Vec]) -> Vec { +pub(crate) fn collection(faces: &[Vec]) -> Vec { let mut out = b"ttcf".to_vec(); out.extend_from_slice(&0x0001_0000_u32.to_be_bytes()); out.extend_from_slice(&(faces.len() as u32).to_be_bytes()); diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index b7655439b1..3c70572c31 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -24,7 +24,7 @@ pub use kurbo::BezPath; pub use script::{fallback_rank, is_emoji, needs_layout_tables, script_fallbacks}; use skrifa::MetadataProvider; use skrifa::instance::Size; -pub use ui_fallback::{SystemUiFont, is_cjk, system_cjk_ui_font, system_cjk_ui_font_for, ui_needs_system_cjk, ui_uncovered_cjk}; +pub use ui_fallback::{SystemUiFont, is_cjk, system_cjk_ui_font, system_cjk_ui_font_for, system_hant_ui_font, ui_needs_system_cjk, ui_uncovered_cjk}; /// A font from the optional craft-fonts build input (https://github.com/storytold/craft-fonts; /// empty unless built with `CRAFT_FONTS_DIR`, see `build.rs`). diff --git a/crates/fonts/src/ui_fallback.rs b/crates/fonts/src/ui_fallback.rs index 3ef38e55be..63dca56161 100644 --- a/crates/fonts/src/ui_fallback.rs +++ b/crates/fonts/src/ui_fallback.rs @@ -70,6 +70,31 @@ const MAX_NEEDED: usize = 4096; /// as blank space. pub(crate) const OUTLINE_TABLES: [&[u8; 4]; 4] = [b"glyf", b"CFF ", b"CFF2", b"VARC"]; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Region { + Hans, + Hant, + Other, +} + +impl Region { + fn from_hans(hans: bool) -> Region { + if hans { Region::Hans } else { Region::Other } + } + + fn rank(self, family: &str) -> u8 { + match self { + Region::Hans => u8::from(family.ends_with(" SC") || family.ends_with(" GB") || family.contains("YaHei")), + Region::Hant => { + let taiwan = + [" TC", " TW"].iter().any(|s| family.ends_with(s)) || ["JhengHei", "CNS", "LiHei", "MingLiU"].iter().any(|s| family.contains(s)); + if taiwan { 2 } else { u8::from(family.ends_with(" HK")) } + } + Region::Other => 1, + } + } +} + /// A character only a font for the language has: a simplified-only hanzi, kana, or Hangul. fn probe(hans: bool, korean: bool) -> char { if korean { @@ -139,6 +164,46 @@ fn candidates(hans: bool, korean: bool) -> Vec<&'static str> { } } +fn hant_candidates() -> &'static [&'static str] { + if cfg!(windows) { + &["msjh.ttc", "msjh.ttf", "mingliu.ttc"] + } else if cfg!(target_os = "macos") { + &[ + "PingFang.ttc", + "Hiragino Sans CNS.ttc", + "LiHeiPro.ttf", + "/System/Library/PrivateFrameworks/FontServices.framework/Resources/Reserved/PingFangUI.ttc", + ] + } else { + &[ + "noto-cjk/NotoSansCJK-Regular.ttc", + "opentype/noto/NotoSansCJK-Regular.ttc", + "google-noto-sans-cjk-fonts/NotoSansCJK-Regular.ttc", + "google-noto-cjk/NotoSansCJK-Regular.ttc", + "noto-cjk/NotoSansCJKtc-Regular.otf", + "opentype/noto/NotoSansCJKtc-Regular.otf", + "google-noto-cjk/NotoSansCJKtc-Regular.otf", + "adobe-source-han-sans/SourceHanSansTW-Regular.otf", + "adobe-source-han-sans/SourceHanSansTC-Regular.otf", + "opentype/adobe-source-han-sans/SourceHanSansTW-Regular.otf", + "NotoSansCJK-Regular.ttc", + "NotoSansCJKtc-Regular.otf", + "SourceHanSansTW-Regular.otf", + "SourceHanSansTC-Regular.otf", + ] + } +} + +pub fn system_hant_ui_font(needed: &[char]) -> Option { + let probe = ['體']; + let needed = if needed.is_empty() { &probe[..] } else { needed.get(..MAX_NEEDED).unwrap_or(needed) }; + hant_font_in(&with_asset_data(crate::system_font_dirs()), needed) +} + +fn hant_font_in(dirs: &[std::path::PathBuf], needed: &[char]) -> Option { + find_ui_font(dirs, hant_candidates(), needed, Region::Hant).filter(|f| Region::Hant.rank(&f.family) > 0) +} + /// The installed CJK font for a Chinese (`hans`) or other interface that best covers `needed` /// (the characters the embedded faces lack; empty: a character typical of the language), if one /// is found. Never panics on damaged files. @@ -150,7 +215,7 @@ pub fn system_cjk_ui_font(hans: bool, needed: &[char]) -> Option { pub fn system_cjk_ui_font_for(hans: bool, korean: bool, needed: &[char]) -> Option { let probe = [probe(hans, korean)]; let needed = if needed.is_empty() { &probe[..] } else { needed.get(..MAX_NEEDED).unwrap_or(needed) }; - find_ui_font(&with_asset_data(crate::system_font_dirs()), &candidates(hans, korean), needed, hans) + find_ui_font(&with_asset_data(crate::system_font_dirs()), &candidates(hans, korean), needed, Region::from_hans(hans)) } /// `dirs` and, for each macOS font asset folder among them (`com_apple_MobileAsset_Font`), @@ -175,9 +240,9 @@ pub(crate) fn with_asset_data(dirs: Vec) -> Vec Option { +pub(crate) fn find_ui_font(dirs: &[std::path::PathBuf], names: &[&str], needed: &[char], region: Region) -> Option { let mut tried = std::collections::HashSet::new(); - let mut best: Option<(usize, SystemUiFont)> = None; + let mut best: Option<((u8, usize), SystemUiFont)> = None; for path in names.iter().flat_map(|n| dirs.iter().map(move |d| d.join(n))) { if !tried.insert(path.clone()) { continue; @@ -187,13 +252,15 @@ pub(crate) fn find_ui_font(dirs: &[std::path::PathBuf], names: &[&str], needed: continue; } let Ok(bytes) = std::fs::read(&path) else { continue }; - let Some((index, family, covered)) = pick_face(&bytes, needed, hans) else { continue }; - if best.as_ref().is_some_and(|(b, _)| *b >= covered) { + let Some((index, family, covered)) = pick_face(&bytes, needed, region) else { continue }; + let fit = if region == Region::Hant { region.rank(&family) } else { 0 }; + if best.as_ref().is_some_and(|(b, _)| *b >= (fit, covered)) { continue; } log::debug!("interface CJK fallback: {family} ({}), {covered} of {} characters", path.display(), needed.len()); - best = Some((covered, SystemUiFont { family, bytes, index })); - if covered >= needed.len() { + let done = covered >= needed.len() && (region != Region::Hant || fit == 2); + best = Some(((fit, covered), SystemUiFont { family, bytes, index })); + if done { break; } } @@ -205,7 +272,7 @@ pub(crate) fn find_ui_font(dirs: &[std::path::PathBuf], names: &[&str], needed: /// family (`… SC`, `… GB`, as in a pan-CJK collection). Faces with none of the /// [`OUTLINE_TABLES`] are skipped. `None` if the header isn't a font's or no face with outlines /// maps any of `needed`. -pub(crate) fn pick_face(data: &[u8], needed: &[char], hans: bool) -> Option<(u32, String, usize)> { +pub(crate) fn pick_face(data: &[u8], needed: &[char], region: Region) -> Option<(u32, String, usize)> { let magic = data.get(..4)?; if ![&[0, 1, 0, 0][..], b"OTTO", b"ttcf"].contains(&magic) { return None; @@ -214,7 +281,7 @@ pub(crate) fn pick_face(data: &[u8], needed: &[char], hans: bool) -> Option<(u32 FileRef::Font(_) => 1, FileRef::Collection(c) => c.len().min(MAX_FACES), }; - let mut best: Option<((usize, bool, bool), u32, String)> = None; + let mut best: Option<((u8, usize, bool, u8), u32, String)> = None; for i in 0..count { let Ok(f) = skrifa::FontRef::from_index(data, i) else { continue }; if !OUTLINE_TABLES.iter().any(|t| f.table_data(skrifa::Tag::new(t)).is_some()) { @@ -226,13 +293,14 @@ pub(crate) fn pick_face(data: &[u8], needed: &[char], hans: bool) -> Option<(u32 continue; } let Some((family, style, _)) = crate::fontdb::face_names(&f) else { continue }; - let regional = !hans || family.ends_with(" SC") || family.ends_with(" GB") || family.contains("YaHei"); - let score = (covered, style.eq_ignore_ascii_case("Regular"), regional); + let rank = region.rank(&family); + let first = if region == Region::Hant { rank } else { 0 }; + let score = (first, covered, style.eq_ignore_ascii_case("Regular"), rank); if best.as_ref().is_none_or(|(s, ..)| score > *s) { best = Some((score, i, family)); } } - best.map(|((covered, ..), i, family)| (i, family, covered)) + best.map(|((_, covered, ..), i, family)| (i, family, covered)) } #[cfg(test)] @@ -255,6 +323,57 @@ mod tests { assert_eq!(probe(false, true), '한'); } + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn traditional_chinese_interface_picks_the_taiwan_face() { + use crate::fontdb::tests::sysfonts::{collection, renamed_to}; + assert_eq!( + hant_candidates().first(), + Some(&if cfg!(windows) { + "msjh.ttc" + } else if cfg!(target_os = "macos") { + "PingFang.ttc" + } else { + "noto-cjk/NotoSansCJK-Regular.ttc" + }) + ); + let face = |family| renamed_to("SourceSans3-Regular.ttf", family); + let pan = collection(&[face("Probe Sans JP"), face("Probe Sans KR"), face("Probe Sans SC"), face("Probe Sans TC"), face("Probe Sans HK")]); + assert_eq!(pick_face(&pan, &['A'], Region::Hant), Some((3, "Probe Sans TC".to_string(), 1))); + assert_eq!(pick_face(&pan, &['A'], Region::Hans), Some((2, "Probe Sans SC".to_string(), 1))); + assert_eq!(pick_face(&pan, &['A'], Region::Other), Some((0, "Probe Sans JP".to_string(), 1))); + let hk = collection(&[face("Probe Sans SC"), face("Probe Sans HK")]); + assert_eq!(pick_face(&hk, &['A'], Region::Hant).map(|f| f.0), Some(1)); + assert_eq!(Region::Hant.rank("Microsoft JhengHei"), 2); + assert_eq!(Region::Hant.rank("Microsoft YaHei"), 0); + let dir = std::env::temp_dir().join(format!("wc-uifallback-hant-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let names = ["missing.ttc", "damaged.ttc", "sc.ttc", "pan.ttc"]; + std::fs::write(dir.join("damaged.ttc"), b"ttcf\0\x01\0\0\xff\xff\xff\xff").unwrap(); + std::fs::write(dir.join("sc.ttc"), collection(&[face("Probe Sans SC"), face("Probe Sans JP")])).unwrap(); + std::fs::write(dir.join("pan.ttc"), &pan).unwrap(); + let found = find_ui_font(std::slice::from_ref(&dir), &names, &['A'], Region::Hant).unwrap(); + assert_eq!((found.family.as_str(), found.index), ("Probe Sans TC", 3)); + assert_eq!(find_ui_font(std::slice::from_ref(&dir), &names, &['A'], Region::Hans).unwrap().family, "Probe Sans SC"); + let only_sc = dir.join("only-sc"); + std::fs::create_dir_all(only_sc.join("noto-cjk")).unwrap(); + std::fs::write(only_sc.join("noto-cjk/NotoSansCJK-Regular.ttc"), collection(&[face("Probe Sans SC")])).unwrap(); + assert!(hant_font_in(std::slice::from_ref(&only_sc), &['A']).is_none()); + std::fs::write(only_sc.join("noto-cjk/NotoSansCJK-Regular.ttc"), &pan).unwrap(); + if !cfg!(windows) && !cfg!(target_os = "macos") { + assert_eq!(hant_font_in(std::slice::from_ref(&only_sc), &['A']).map(|f| f.index), Some(3)); + } + assert!(hant_font_in(std::slice::from_ref(&only_sc), &['體']).is_none()); + let user = dir.join("user-fonts"); + std::fs::create_dir_all(&user).unwrap(); + std::fs::write(user.join("NotoSansCJKtc-Regular.otf"), face("Probe Sans TC")).unwrap(); + if !cfg!(windows) && !cfg!(target_os = "macos") { + assert_eq!(hant_font_in(std::slice::from_ref(&user), &['A']).map(|f| f.family), Some("Probe Sans TC".to_string())); + } + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn needs_system_cjk_when_an_embedded_face_lacks_an_interface_character() { // A face without CJK glyphs stands in for a craft-fonts face that lacks some (#486). @@ -289,21 +408,21 @@ mod tests { std::fs::write(dir.join("msyh.ttc"), b"not a font").unwrap(); std::fs::write(dir.join("sub/good.ttf"), sans).unwrap(); let names = ["missing.ttf", "Deng.ttf", "msyh.ttc", "sub/good.ttf"]; - let found = find_ui_font(std::slice::from_ref(&dir), &names, &['A'], true).unwrap(); + let found = find_ui_font(std::slice::from_ref(&dir), &names, &['A'], Region::Hans).unwrap(); assert_eq!((found.family.as_str(), found.index, found.bytes.len()), ("Source Sans 3", 0, sans.len())); // No candidate maps a CJK character: nothing is chosen. - assert!(find_ui_font(std::slice::from_ref(&dir), &names, &['页'], true).is_none()); - assert!(pick_face(b"OTT", &['A'], false).is_none()); + assert!(find_ui_font(std::slice::from_ref(&dir), &names, &['页'], Region::Hans).is_none()); + assert!(pick_face(b"OTT", &['A'], Region::Other).is_none()); // macOS 12 keeps PingFang in `AssetsV2/com_apple_MobileAsset_Font7/.asset/AssetData`. std::fs::write(assets.join("0a1b.asset/AssetData/PingFang.ttc"), sans).unwrap(); std::fs::write(assets.join("not-an-asset/AssetData/Hidden.ttc"), sans).unwrap(); let dirs = with_asset_data(vec![dir.clone(), assets.clone()]); assert_eq!(dirs, [dir.clone(), assets.join("0a1b.asset/AssetData"), assets.clone()]); - let found = find_ui_font(&dirs, &["PingFang.ttc"], &['A'], true).unwrap(); + let found = find_ui_font(&dirs, &["PingFang.ttc"], &['A'], Region::Hans).unwrap(); assert_eq!(found.family, "Source Sans 3"); - assert!(find_ui_font(&dirs, &["Hidden.ttc"], &['A'], true).is_none()); + assert!(find_ui_font(&dirs, &["Hidden.ttc"], &['A'], Region::Hans).is_none()); // No file maps every needed character: the one mapping the most is used. - let found = find_ui_font(&dirs, &["sub/good.ttf", "PingFang.ttc"], &['A', '页'], true).unwrap(); + let found = find_ui_font(&dirs, &["sub/good.ttf", "PingFang.ttc"], &['A', '页'], Region::Hans).unwrap(); assert_eq!(found.family, "Source Sans 3"); let _ = std::fs::remove_dir_all(&dir); } @@ -318,17 +437,17 @@ mod tests { let glyf = (0..tables).map(|r| 12 + r * 16).find(|&at| &hvgl[at..at + 4] == b"glyf").unwrap(); hvgl[glyf..glyf + 4].copy_from_slice(b"hvgl"); assert!(skrifa::FontRef::new(&hvgl).unwrap().charmap().map('A').is_some()); - assert!(pick_face(&hvgl, &['A'], true).is_none()); - assert_eq!(pick_face(sans, &['A'], true), Some((0, "Source Sans 3".to_string(), 1))); + assert!(pick_face(&hvgl, &['A'], Region::Hans).is_none()); + assert_eq!(pick_face(sans, &['A'], Region::Hans), Some((0, "Source Sans 3".to_string(), 1))); // `find_ui_font` uses the next file, and returns `None` when no other file is listed. let dir = std::env::temp_dir().join(format!("wc-uifallback-outlines-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("hvgl.ttf"), &hvgl).unwrap(); std::fs::write(dir.join("glyf.ttf"), sans).unwrap(); - let found = find_ui_font(std::slice::from_ref(&dir), &["hvgl.ttf", "glyf.ttf"], &['A'], true).unwrap(); + let found = find_ui_font(std::slice::from_ref(&dir), &["hvgl.ttf", "glyf.ttf"], &['A'], Region::Hans).unwrap(); assert!(found.bytes == sans); - assert!(find_ui_font(std::slice::from_ref(&dir), &["hvgl.ttf"], &['A'], true).is_none()); + assert!(find_ui_font(std::slice::from_ref(&dir), &["hvgl.ttf"], &['A'], Region::Hans).is_none()); let _ = std::fs::remove_dir_all(&dir); } } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index e865118d09..6223b5714c 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -112,6 +112,7 @@ impl WordApp { styled: false, fonts_hans: false, fonts_korean: false, + fonts_hant: false, fonts_system_cjk: false, want_system_cjk: false, recover_fonts_for: None, diff --git a/crates/ui-egui/src/app/fonts.rs b/crates/ui-egui/src/app/fonts.rs index 31ac0189a6..7f320ca371 100644 --- a/crates/ui-egui/src/app/fonts.rs +++ b/crates/ui-egui/src/app/fonts.rs @@ -155,7 +155,11 @@ impl WordApp { /// face has changed since the last install. pub(super) fn install_ui_fonts(&mut self, ctx: &egui::Context, lang: i18n::Lang) { let system_cjk = self.fonts_system_cjk || self.want_system_cjk || lang.uses_cjk(); - if !self.styled || lang.prefers_hans() != self.fonts_hans || lang.prefers_korean() != self.fonts_korean || system_cjk != self.fonts_system_cjk + if !self.styled + || lang.prefers_hans() != self.fonts_hans + || lang.prefers_korean() != self.fonts_korean + || lang.prefers_hant() != self.fonts_hant + || system_cjk != self.fonts_system_cjk { // Every window's borrowed faces: the fonts are the app's. let mut faces = self.canvas.ime_faces.clone(); @@ -166,9 +170,10 @@ impl WordApp { } } } - theme::install_fonts_language_extra(ctx, lang.prefers_hans(), lang.prefers_korean(), system_cjk, &faces); + theme::install_fonts_language_extra(ctx, lang.prefers_hans(), lang.prefers_korean(), lang.prefers_hant(), system_cjk, &faces); self.fonts_hans = lang.prefers_hans(); self.fonts_korean = lang.prefers_korean(); + self.fonts_hant = lang.prefers_hant(); self.fonts_system_cjk = system_cjk; // Mod with -, = and 0 are Word shortcuts (optional hyphen, subscript, paragraph spacing); // egui's keyboard zoom would also scale the whole window on them. Zoom is View › Zoom. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 6129bf4831..e773a682b7 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1546,6 +1546,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { ui.ctx(), app.fonts_hans, app.fonts_korean, + app.fonts_hant, app.fonts_system_cjk, &app.canvas.ime_faces, ); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 320dc8adf6..63a1c52d34 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -179,6 +179,10 @@ impl Lang { self.code() == "ko" } + pub fn prefers_hant(self) -> bool { + self.code() == "zh-hant" + } + /// The language new blank documents are written in with this interface ([`LangInfo::editing_lang`]). pub fn editing_lang(self) -> Option<&'static str> { self.info().and_then(|l| l.editing_lang) @@ -210,6 +214,18 @@ impl Lang { (catalogs, Lang::all().map(Lang::name).collect::>().join(" ")) } + /// The CJK characters of the Traditional Chinese catalogs, which an installed Traditional + /// Chinese UI font must cover to be put first. + pub fn hant_font_chars() -> Vec { + let chars: std::collections::BTreeSet = Lang::all() + .filter(|l| l.prefers_hant()) + .flat_map(Lang::catalog_texts) + .flat_map(str::chars) + .filter(|c| wordcraft_fonts::is_cjk(*c)) + .collect(); + chars.into_iter().collect() + } + /// A language by its exact code (any case): one of [`LANGUAGES`] whose catalogs are embedded. pub fn from_code(code: &str) -> Option { let info = LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code))?; diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 4e65f66e14..a5930b5b49 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -348,11 +348,41 @@ fn chinese_interface_fonts_load_and_cover_simplified_hanzi() { } } +#[test] +fn traditional_chinese_interface_puts_an_installed_traditional_font_first() { + needs_catalogs!(); + assert!(Lang::all().filter(|l| l.prefers_hant()).map(Lang::code).eq(["zh-hant"])); + assert!(lang("zh-hant").prefers_hans()); + assert!(Lang::hant_font_chars().contains(&'說')); + let order = |hant: bool, system_cjk: bool| { + crate::theme::font_definitions_language(true, false, hant, system_cjk).families[&egui::FontFamily::Proportional].clone() + }; + let is_hant = |n: &String| n.starts_with("system hant "); + let hant = order(true, true); + assert!(!order(false, true).iter().any(is_hant)); + assert!(!order(true, false).iter().any(is_hant)); + let Some(installed) = wordcraft_fonts::system_hant_ui_font(&[]) else { + assert_eq!(hant, order(false, true), "no Traditional Chinese font installed: the Simplified Chinese order"); + return; + }; + let at = hant.iter().position(is_hant).unwrap(); + assert_eq!(hant[at], format!("system hant {}", installed.family)); + for f in wordcraft_fonts::ui_cjk_fonts(true) { + let embedded = hant.iter().position(|n| *n == format!("{} {}", f.family, f.style)).unwrap(); + assert!(at < embedded, "{} comes before {}", hant[embedded], hant[at]); + } + assert!(hant.iter().position(|n| n == "Inter").is_some_and(|i| i < at), "{hant:?}"); + let ctx = egui::Context::default(); + ctx.set_fonts(crate::theme::font_definitions_language(true, false, true, true)); + ctx.run_ui(Default::default(), |_| {}).textures_delta.clear(); + assert!(ctx.fonts_mut(|f| f.has_glyphs(&egui::FontId::proportional(14.0), "說這為戶"))); +} + #[test] fn korean_interface_fonts_load_and_cover_hangul() { needs_catalogs!(); let ctx = egui::Context::default(); - ctx.set_fonts(crate::theme::font_definitions_language(false, true, true)); + ctx.set_fonts(crate::theme::font_definitions_language(false, true, false, true)); ctx.run_ui(Default::default(), |_| {}).textures_delta.clear(); let covered = ctx.fonts_mut(|f| f.has_glyphs(&egui::FontId::proportional(14.0), "한글")); let (catalogs, names) = Lang::cjk_font_text_for(false, true); @@ -380,7 +410,7 @@ fn cjk_catalogs_are_covered_by_the_embedded_interface_faces() { for code in ["zh-hans", "zh-hant", "ja", "ko"] { let l = lang(code); let ctx = egui::Context::default(); - ctx.set_fonts(crate::theme::font_definitions_language(l.prefers_hans(), l.prefers_korean(), false)); + ctx.set_fonts(crate::theme::font_definitions_language(l.prefers_hans(), l.prefers_korean(), l.prefers_hant(), false)); ctx.run_ui(Default::default(), |_| {}).textures_delta.clear(); let entries = translations(code); let chars: std::collections::BTreeSet = diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index eef1725a1c..97dca88179 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -513,6 +513,7 @@ pub struct WordApp { fonts_hans: bool, /// Whether the installed UI fonts use the Korean CJK face order. fonts_korean: bool, + fonts_hant: bool, /// Whether the installed UI fonts include an installed CJK fallback font (#241). fonts_system_cjk: bool, /// CJK text is (about to be) on screen in a non-CJK interface, e.g. the language names in diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 4a70c56267..562d12c53a 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -453,7 +453,7 @@ pub fn install_fonts_for(ctx: &egui::Context, prefer_hans: bool) { /// [`install_fonts_for`] with the CJK fallback order for a Korean interface. pub fn install_fonts_language(ctx: &egui::Context, prefer_hans: bool, prefer_korean: bool) { - install_fonts_language_with(ctx, prefer_hans, prefer_korean, false); + install_fonts_language_with(ctx, prefer_hans, prefer_korean, false, false); } /// [`install_fonts_for`], also adding an installed CJK font after the embedded faces when @@ -465,15 +465,15 @@ pub fn install_fonts_with(ctx: &egui::Context, prefer_hans: bool, system_cjk: bo } /// [`install_fonts_with`] with the CJK font order selected for the interface language. -pub fn install_fonts_language_with(ctx: &egui::Context, prefer_hans: bool, prefer_korean: bool, system_cjk: bool) { - install_fonts_language_extra(ctx, prefer_hans, prefer_korean, system_cjk, &[]); +pub fn install_fonts_language_with(ctx: &egui::Context, prefer_hans: bool, prefer_korean: bool, prefer_hant: bool, system_cjk: bool) { + install_fonts_language_extra(ctx, prefer_hans, prefer_korean, prefer_hant, system_cjk, &[]); } /// [`install_fonts_with`], also adding document-font `faces` as last-resort fallbacks (#565): the /// faces that cover text the interface fonts cannot draw (an input method's composition, or a /// file name in the window caption). pub fn install_fonts_extra(ctx: &egui::Context, prefer_hans: bool, system_cjk: bool, faces: &[Arc]) { - install_fonts_language_extra(ctx, prefer_hans, false, system_cjk, faces); + install_fonts_language_extra(ctx, prefer_hans, false, false, system_cjk, faces); } /// [`install_fonts_extra`] with the CJK font order selected for the interface language. @@ -481,10 +481,11 @@ pub fn install_fonts_language_extra( ctx: &egui::Context, prefer_hans: bool, prefer_korean: bool, + prefer_hant: bool, system_cjk: bool, faces: &[Arc], ) { - let mut fonts = font_definitions_language(prefer_hans, prefer_korean, system_cjk); + let mut fonts = font_definitions_language(prefer_hans, prefer_korean, prefer_hant, system_cjk); add_fallback_faces(&mut fonts, faces); ctx.set_fonts(fonts); } @@ -535,11 +536,11 @@ pub fn faces_for_preedit( /// The interface fonts; see [`install_fonts_with`]. pub fn font_definitions(prefer_hans: bool, system_cjk: bool) -> FontDefinitions { - font_definitions_language(prefer_hans, false, system_cjk) + font_definitions_language(prefer_hans, false, false, system_cjk) } /// The interface fonts, ordered for the preferred CJK writing system. -pub fn font_definitions_language(prefer_hans: bool, prefer_korean: bool, system_cjk: bool) -> FontDefinitions { +pub fn font_definitions_language(prefer_hans: bool, prefer_korean: bool, prefer_hant: bool, system_cjk: bool) -> FontDefinitions { let mut fonts = FontDefinitions::default(); let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| { fonts.font_data.insert(name.into(), Arc::new(FontData::from_static(bytes))); @@ -566,6 +567,17 @@ pub fn font_definitions_language(prefer_hans: bool, prefer_korean: bool, system_ } } }; + #[cfg(not(target_arch = "wasm32"))] + if prefer_hant + && system_cjk + && let Some(f) = system_hant_font() + { + let mut data = FontData::from_static(&f.bytes); + data.index = f.index; + add_cjk(&mut fonts, format!("system hant {}", f.family), data); + } + #[cfg(target_arch = "wasm32")] + let _ = prefer_hant; let cjk = wordcraft_fonts::ui_cjk_fonts_for(prefer_hans, prefer_korean); for f in &cjk { // The same static bytes the document fonts use: one copy in the binary. @@ -622,6 +634,17 @@ fn system_cjk_font(hans: bool, korean: bool) -> Option<&'static wordcraft_fonts: .as_ref() } +#[cfg(not(target_arch = "wasm32"))] +fn system_hant_font() -> Option<&'static wordcraft_fonts::SystemUiFont> { + static HANT: std::sync::OnceLock> = std::sync::OnceLock::new(); + HANT.get_or_init(|| { + let f = wordcraft_fonts::system_hant_ui_font(&crate::i18n::Lang::hant_font_chars()); + log::debug!("interface: Traditional Chinese font {:?}", f.as_ref().map(|f| &f.family)); + f + }) + .as_ref() +} + pub fn medium(size: f32) -> FontId { FontId::new(size, FontFamily::Name("medium".into())) } diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs index 9d938ee243..29464e87aa 100644 --- a/crates/ui-egui/src/window_ui.rs +++ b/crates/ui-egui/src/window_ui.rs @@ -176,6 +176,7 @@ impl WordApp { styled: _, fonts_hans: _, fonts_korean: _, + fonts_hant: _, fonts_system_cjk: _, want_system_cjk: _, recent_fonts_for: _, diff --git a/docs/localization-parity.md b/docs/localization-parity.md index dae22c4562..ea71a9fe3f 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (the egui 0.37 migration preserves the equation “Delete Around Caret in Equation” translation in all 27 catalogs through the shared catalog pin; previously minor: Spanish proofing: es-ES, es-MX, es-419 and es-UY from the installed Hunspell dictionaries, Uruguayan `vos` forms, opening `¿`/`¡` checks, #3360, #3553; previously minor: the interface catalogs moved to storytold/craft-i18n and are looked up through the `craft-i18n` crate, embedded at build time from the commit in `craft-i18n.rev`; previously trivial: interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: interface audit: missing catalog entries are reported per language and don't fail the audit; previously minor: disabled-control reasons in ribbon hints translated in every catalog, #2678; previously trivial: Slovenian interface catalog: every key of every other catalog, 2,614 entries; previously minor: Romanian interface catalog; previously trivial: the File › Home greeting is translated in every catalog; previously trivial: Japanese row: ruby (Phonetic Guide) is supported and HTML export/import keeps it, #1681; previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously minor: English dialects: en-GB, en-CA, en-AU and the other British-spelling Englishes proof their own spellings and flag the other dialect's, #1281, #1045; previously minor: how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** trivial (the Traditional Chinese interface draws hanzi to the Taiwan standard with an installed Traditional Chinese font first: PingFang TC, Microsoft JhengHei, Noto Sans CJK TC; previously minor: the egui 0.37 migration preserves the equation “Delete Around Caret in Equation” translation in all 27 catalogs through the shared catalog pin; previously minor: Spanish proofing: es-ES, es-MX, es-419 and es-UY from the installed Hunspell dictionaries, Uruguayan `vos` forms, opening `¿`/`¡` checks, #3360, #3553; previously minor: the interface catalogs moved to storytold/craft-i18n and are looked up through the `craft-i18n` crate, embedded at build time from the commit in `craft-i18n.rev`; previously trivial: interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: interface audit: missing catalog entries are reported per language and don't fail the audit; previously minor: disabled-control reasons in ribbon hints translated in every catalog, #2678; previously trivial: Slovenian interface catalog: every key of every other catalog, 2,614 entries; previously minor: Romanian interface catalog; previously trivial: the File › Home greeting is translated in every catalog; previously trivial: Japanese row: ruby (Phonetic Guide) is supported and HTML export/import keeps it, #1681; previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously minor: English dialects: en-GB, en-CA, en-AU and the other British-spelling Englishes proof their own spellings and flag the other dialect's, #1281, #1045; previously minor: how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Interface languages, script support and proofing per language. How translation works (adding a language, the embedded catalogs): `crates/ui-egui/src/i18n.rs`. The catalogs, their format and @@ -78,6 +78,10 @@ count-neutral wording (`{words} ord`: the noun is the same in the singular and t catalog is an AI-assisted draft whose terminology and running app were reviewed by a native speaker. Swedish proofing is not included; terminology: [`localization-sv.md`](localization-sv.md). Romanian (`ro-RO` and `ro-MD` system locales, `{"value":"ro"}`) has 2,674 entries covering every key of the other catalogs. Commands use the noun form ("Salvare", "Deschidere…") and messages address the user formally. Counts keep the number and noun as the English does; Romanian proofing is a separate change. The catalog is an AI-assisted draft that a native speaker has not yet reviewed line by line. +Traditional Chinese draws its hanzi to the Taiwan standard on the desktop: the embedded Chinese face (Noto Sans CJK SC) has +mainland glyph shapes (說, 這, 為, 戶), so an installed Traditional Chinese font comes before every CJK face — PingFang TC on +macOS, Microsoft JhengHei on Windows, Noto Sans CJK TC (or Source Han Sans TW) on Linux; without one, and on the web, the +Simplified Chinese order stays. Croatian (`hr-HR` system locales, `{"value":"hr"}`) has 2,507 entries: every key of the reference catalog, built-in style names and the equation gallery. Counts use count-neutral wording (`Riječi: 21`) because Croatian numerals take three noun forms. The catalog was written and checked by a native speaker; Croatian proofing is a separate change. Estonian uses the existing fonts, follows `et-EE` system locales, and is available in Options or through `ui.language` with `{"value":"et"}`. Its 1,307 entries cover the existing interface catalog. @@ -223,6 +227,7 @@ WordCraft keeps 2,54 cm). | Date | Change | Summary | |---|---|---| +| 2026-10-28 | trivial | Traditional Chinese interface: an installed Traditional Chinese font (PingFang TC, Microsoft JhengHei, Noto Sans CJK TC; the TC face of a pan-CJK collection) goes before the embedded Noto Sans CJK SC, whose mainland glyph shapes looked wrong to Taiwan readers; native only, wasm unchanged; estimates unchanged | | 2026-10-24 | minor | Preserved “Delete Around Caret in Equation” in all 27 shared WordCraft catalogs; the native/release pin and Flatpak source advance together. | | 2026-10-23 | minor | Interface catalogs moved to storytold/craft-i18n (`locales//wordcraft.tsv`), looked up through craft-libs' `craft-i18n` crate and embedded from the pinned commit (`craft-i18n.rev`); strings other Crafting Apps already translate in the shared catalog now show translated; the "Open" paragraph-spacing preset no longer shows the file verb | | 2026-10-23 | trivial | Lookup, context, template, style-name and command-search tests use inline fixture catalogs or the pseudo-language; tests that asserted one language's catalog wording removed | From 8148a239e61c43d4fd04af68ac30f863034d1707 Mon Sep 17 00:00:00 2001 From: madhasoeki <71951183+madhasoeki@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:04:29 +0700 Subject: [PATCH 07/73] Content controls: resolve lone table-open markers in linear time (#3826) (#4227) Count table opens in a single linear pass instead of rescanning earlier events for every lone table-open marker (O(events) instead of O(lone * events)). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Brandon Thomas --- crates/doc/src/control.rs | 39 ++++++++++++++++-------- crates/doc/src/tests.rs | 63 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 13 deletions(-) diff --git a/crates/doc/src/control.rs b/crates/doc/src/control.rs index 31ff6814af..4647706892 100644 --- a/crates/doc/src/control.rs +++ b/crates/doc/src/control.rs @@ -14,7 +14,7 @@ //! outside what's deleted (so deleting across a control's edge empties it rather than breaking //! it); writers call [`Document::balance_controls`] so a saved file always has whole controls. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use serde::{Deserialize, Serialize}; @@ -422,24 +422,37 @@ fn scan(story: StoryRef, bl: &Blocks, prefix: &mut Vec, depth: usize, out: } /// Positions of the markers without a partner in a block list and the cells inside it. -fn orphans(bl: &Blocks, prefix: &mut Vec, depth: usize, out: &mut Vec<(Path, usize)>, tables: &mut Vec<(Path, usize, bool)>) { +pub(crate) fn orphans(bl: &Blocks, prefix: &mut Vec, depth: usize, out: &mut Vec<(Path, usize)>, tables: &mut Vec<(Path, usize, bool)>) { if depth > MAX_DEPTH { return; } let events = control_events(bl); let (_, lone) = pair_events(&events); - for k in lone { - let Some(e) = events.get(k) else { continue }; - let mut path = prefix.clone(); - path.push(u32::try_from(e.block()).unwrap_or(u32::MAX)); - match (e, e.off()) { - (_, Some(off)) => out.push((Path(path), off)), - (ControlEvent::Open { .. }, None) => { - // Which of the table's opens: count those before it on the same table. - let nth = events.iter().take(k).filter(|x| matches!(x, ControlEvent::Open { block, off: None, .. } if *block == e.block())).count(); - tables.push((Path(path), nth, true)); + if !lone.is_empty() { + let mut table_counts: HashMap = HashMap::new(); + let mut open_nth = Vec::with_capacity(events.len()); + for e in &events { + if let ControlEvent::Open { block, off: None, .. } = e { + let count = table_counts.entry(*block).or_insert(0); + open_nth.push(*count); + *count += 1; + } else { + open_nth.push(0); + } + } + for k in lone { + let Some(e) = events.get(k) else { continue }; + let mut path = prefix.clone(); + path.push(u32::try_from(e.block()).unwrap_or(u32::MAX)); + match (e, e.off()) { + (_, Some(off)) => out.push((Path(path), off)), + (ControlEvent::Open { .. }, None) => { + // Which of the table's opens: index among table opens on the same table. + let nth = open_nth.get(k).copied().unwrap_or(0); + tables.push((Path(path), nth, true)); + } + (ControlEvent::Close { .. }, None) => tables.push((Path(path), 0, false)), } - (ControlEvent::Close { .. }, None) => tables.push((Path(path), 0, false)), } } for (i, b) in bl.iter().enumerate() { diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 67256fd78c..86b2040119 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -651,3 +651,66 @@ fn control_markers_kept_are_sorted() { assert!(kept.windows(2).all(|p| p[0] < p[1]), "sorted, no repeats: {kept:?}"); } } + +#[test] +fn lone_table_open_markers_linear_time() { + let mut d = Document::new(); + let mut t = Table::new(1, 1, 100.0); + const COUNT: usize = 2_000; + t.controls.open = (0..COUNT).map(|i| ContentControl { tag: format!("ctrl_{i}"), ..Default::default() }).collect(); + d.body.insert(0, Arc::new(Block::Table(t))); + + let mut paras = Vec::new(); + let mut tables = Vec::new(); + let timer = std::time::Instant::now(); + crate::control::orphans(&d.body, &mut Vec::new(), 0, &mut paras, &mut tables); + + assert_eq!(paras.len(), 0); + assert_eq!(tables.len(), COUNT); + for (i, (path, nth, is_open)) in tables.iter().enumerate() { + assert_eq!(*path, Path::top(0)); + assert_eq!(*nth, i); + assert!(*is_open); + } + + assert!(d.has_unbalanced_controls()); + let removed = d.balance_controls(); + assert_eq!(removed, COUNT); + assert!(!d.has_unbalanced_controls()); + assert!(timer.elapsed().as_secs_f32() < 10.0, "{:?}", timer.elapsed()); + let Block::Table(tbl) = &*d.body[0] else { panic!("expected table") }; + assert!(tbl.controls.open.is_empty()); +} + +#[test] +fn lone_table_open_markers_mixed_tables_and_paired() { + let mut d = Document::new(); + let mut t0 = Table::new(1, 1, 100.0); + t0.controls.open = (0..3).map(|i| ContentControl { tag: format!("t0_{i}"), ..Default::default() }).collect(); + t0.controls.close = 1; + + let mut t1 = Table::new(1, 1, 100.0); + t1.controls.open = (0..2).map(|i| ContentControl { tag: format!("t1_{i}"), ..Default::default() }).collect(); + + d.body = vec![Arc::new(Block::Table(t0)), Arc::new(Block::Table(t1))]; + + let mut paras = Vec::new(); + let mut tables = Vec::new(); + crate::control::orphans(&d.body, &mut Vec::new(), 0, &mut paras, &mut tables); + + assert_eq!(paras.len(), 0); + assert_eq!(tables, vec![(Path::top(0), 0, true), (Path::top(0), 1, true), (Path::top(1), 0, true), (Path::top(1), 1, true),]); + + let removed = d.balance_controls(); + assert_eq!(removed, 4); + assert!(!d.has_unbalanced_controls()); + + let Block::Table(res0) = &*d.body[0] else { panic!("expected table") }; + assert_eq!(res0.controls.open.len(), 1); + assert_eq!(res0.controls.open[0].tag, "t0_2"); + assert_eq!(res0.controls.close, 1); + + let Block::Table(res1) = &*d.body[1] else { panic!("expected table") }; + assert!(res1.controls.open.is_empty()); + assert_eq!(res1.controls.close, 0); +} From 3508cac3b004366e8f2a682e653d2140259587e7 Mon Sep 17 00:00:00 2001 From: Simone Date: Fri, 30 Oct 2026 03:15:58 +0100 Subject: [PATCH 08/73] Prevent X11 crashes from NUL in document titles (#3634) (#4301) Ported onto current main (title_stem now lives in app/frame.rs; the window title is built in windows.rs). Co-authored-by: Lucenx9 <185146821+Lucenx9@users.noreply.github.com> --- ROADMAP.md | 4 ++- crates/ui-egui/src/app/frame.rs | 7 +++-- crates/ui-egui/src/tests.rs | 48 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/windows.rs | 4 ++- docs/ui-parity.md | 3 ++- 5 files changed, 61 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 795e54b1fe..a709846a7a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; 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: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; 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: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: 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: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #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 fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; 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: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; 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: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: 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: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #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 fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously 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. @@ -202,6 +202,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 2026-10-29 | A NUL in a document title no longer reaches the native window title, which panicked on X11 (#3634): displayed titles and suggested file names drop NUL characters and fall back to the untitled name when empty; the stored title is unchanged | | 2026-10-28 | The Traditional Chinese interface draws hanzi to the Taiwan standard: on the desktop an installed Traditional Chinese font (PingFang TC on macOS, Microsoft JhengHei on Windows, Noto Sans CJK TC on Linux) comes before the embedded Noto Sans CJK SC, which drew 說, 這, 為, 戶 in mainland shapes; without one, and on the web, nothing changes. | | 2026-10-17 | macOS launch arguments (`-psn_*`, `-NS*`, `-Apple*`) are no longer opened as files, and a file that fails to open names the file and the real cause on the status bar and in the log (#1103). The reported "corrupt" error could not be reproduced. | | 2026-10-17 | Password-protected Word files from Word 2007 (Standard encryption: AES-128/192/256, SHA-1) open with their password, also when named `.doc` or `.dot`; a wrong or missing password is reported as for newer files. Word 97-2003 files with an AES header, which the public spec doesn't define, are refused with a clear message (gap 9) | @@ -649,6 +650,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-29 | trivial | Progress log: window titles omit NUL characters (#3634) | | 2026-10-29 | trivial | Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Filtered/sorted order, product geometry and single-label mode retained; readiness estimates unchanged. | | 2026-10-22 | trivial | Progress log: one native window per open document | | 2026-10-22 | trivial | Progress log: every open document keeps its own recovery copy | diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index e7aea7ddff..2ecbe402d3 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -22,7 +22,7 @@ impl WordApp { /// Document title for the title bar (and the suggested file name). An untitled document is /// named in the interface language, as `Document1` is in English. pub fn title_stem(&self) -> String { - match &self.session.path { + let mut title = match &self.session.path { Some(p) => p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| tl!("Document1").into()), None => match &self.session.file_name { Some(name) => name.clone(), @@ -31,7 +31,10 @@ impl WordApp { } None => self.session.doc.core.title.clone(), }, - } + }; + // X11 converts window titles to C strings, which cannot contain NUL (#3634). + title.retain(|c| c != '\0'); + if title.is_empty() { tl!("Document1").into() } else { title } } /// Tell the host whether the document has unsaved changes ([`Services::on_dirty`]). diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index fe836c3b61..308beb8a19 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -48,6 +48,54 @@ fn window_title_is_sent_only_when_it_changes() { assert_eq!(titles(&mut app), 1); } +/// #3634: a NUL in the document title never reaches the window title (X11 needs a C string) +/// or the suggested file name. +#[test] +fn window_title_with_nul_is_safe_for_x11() { + let ctx = egui::Context::default(); + let mut app = app(); + let fallback = app.title_stem(); + app.run("file.properties", json!({"title": "\u{0}"})).unwrap(); + let mut titles = Vec::new(); + for _ in 0..4 { + let out = ctx.run_ui(egui::RawInput::default(), |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + for command in out.viewport_output.values().flat_map(|v| &v.commands) { + if let egui::ViewportCommand::Title(title) = command { + titles.push(title.clone()); + } + } + out.drop_without_applying_deltas(); + } + assert!(!titles.is_empty()); + for title in &titles { + assert!(std::ffi::CString::new(title.as_str()).is_ok(), "X11 needs a title without NUL: {title:?}"); + } + assert_eq!(app.title_stem(), fallback); + assert_eq!(app.default_save_name(), format!("{fallback}.docx")); + assert_eq!(app.session.doc.core.title, "\u{0}"); +} + +/// #3634: every source of the title (path, file name, document title) drops NUL. +#[test] +fn title_stem_omits_nul_from_each_title_source() { + let mut app = app(); + let fallback = app.title_stem(); + for title in ["Résumé\u{0} notes", "\u{0}"] { + let expected = if title == "\u{0}" { fallback.as_str() } else { "Résumé notes" }; + app.session.doc.core.title = title.into(); + assert_eq!(app.title_stem(), expected); + app.session.file_name = Some(title.into()); + assert_eq!(app.title_stem(), expected); + app.session.path = Some(std::path::PathBuf::from(format!("{title}.docx"))); + assert_eq!(app.title_stem(), expected); + app.session.path = None; + app.session.file_name = None; + } +} + /// #1678: Focus and Full Screen View put the window in full screen, asked of the system once /// per change (switching between the two keeps it), and the usual window comes back after. #[test] diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index e23b0ff9be..e23abb86f9 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -564,7 +564,9 @@ impl WordApp { if !self.has_document() { return "WordCraft".to_string(); } - let name = self.windows.menu_title(self.windows.shown).map(str::to_string).unwrap_or_else(|| self.title_stem()); + let mut name = self.windows.menu_title(self.windows.shown).map(str::to_string).unwrap_or_else(|| self.title_stem()); + // X11 converts window titles to C strings, which cannot contain NUL (#3634). + name.retain(|c| c != '\0'); title(&name, self.session.dirty) } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 9ab3319f11..09353d8392 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; 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 Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (window titles and suggested file names omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; 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 Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 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). @@ -84,6 +84,7 @@ Labels → New Document with “One label per recipient” includes all filtered | Date | Change | Summary | |---|---|---| +| 2026-10-29 | trivial | Displayed document titles and suggested file names omit NUL characters (X11 panicked on them) and use the localized untitled name when nothing remains (#3634) | | 2026-10-29 | trivial | Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Filtered/sorted order, product geometry and single-label mode retained; readiness estimates unchanged. | | 2026-10-22 | trivial | View › Window › New Window opens another window on the document, focused and cascaded; two windows on one document are titled `Name:1` and `Name:2` (one window: the plain name). Each window keeps its own selection, scroll position, zoom, view mode, ruler and panes; the text, undo history, unsaved state and file are the document's, so an edit or Undo in either window shows in both, and a caret in text the other window deleted moves to the nearest place left. Closing one of a document's windows asks nothing; closing its last asks about unsaved changes, and quitting asks once per document. Switch Windows lists every window. The web app keeps one window | | 2026-10-22 | trivial | One native window per open document on the desktop (the web app keeps one window): New and Open open the document in a window of its own, focused and cascaded from the last; Open replaces an untouched blank document in its window and brings an already-open file's window forward; each window keeps its own ribbon tab, File page, dialogs, panes and composition; View › Window › Switch Windows lists the windows and brings the chosen one forward; File › Close and the window's close button close the window (asking about unsaved changes) and the window focused before comes forward, while the last window stays open with no document; quitting asks Save / Don't Save / Cancel about each document in its own window | From 964a7bc5a8cc4a86a8622319dc4f89a870b9ced8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 29 Oct 2026 22:17:20 -0400 Subject: [PATCH 09/73] CI: workflows only call craft-common-actions at @main (#4919) AGENTS.md (and CLAUDE.md where present) gets a CI workflows section: workflows only call craft-common-actions at @main, with no other jobs; feature and fix PRs don't touch CI and link or open a craft-common-actions issue (a PR only for a small fix that is safe for every app and does not increase CI time); legacy jobs aren't added to and move only as their own task, staying until a shared workflow runs them. windows-arm64.yml moves from its SHA pin to @main. The pinned revision is on craft-common-actions main and the workflow (and the actions it calls) is identical there, so nothing changes in behaviour. --- .github/workflows/windows-arm64.yml | 4 ++-- AGENTS.md | 5 +++++ CLAUDE.md | 5 +++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 85a3f19518..a7b3e0d2f6 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -26,8 +26,8 @@ permissions: jobs: windows-arm64: - # Immutable supporting workflow revision until the new install contract lands upstream. - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main with: app-name: wordcraft app-dir: WordCraft diff --git a/AGENTS.md b/AGENTS.md index 44ec9f17be..1983632ac9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,3 +82,8 @@ Progress docs follow [`craftrules/standards/progress-docs.md`](https://github.co GitHub profiles). Never hand-edit `contributors.json`. - Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also re-verifies who wrote each `people.toml` entry; `--check` only verifies). + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/CLAUDE.md b/CLAUDE.md index 55b2767729..69d6249018 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,3 +63,8 @@ Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's o ## Roadmap Progress docs follow [`craftrules/standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md): `ROADMAP.md` (stage, headline numbers, progress log), `docs/target-app-parity.md` (the assessment), `docs/gaps.md` (ranked work list), `docs/roadmap.md` (milestones, Current focus), `docs/architecture.md`, and the parity checklists `docs/file-format-parity.md`, `docs/layout-parity.md`, `docs/typing-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the gap, the parity doc and the progress log, and bump each touched doc's status line and revision history. + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. From 5714408dd8f1712e8d5d6f78532529cf74105529 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kalm=C3=A1r=20R=C3=B3bert?= Date: Fri, 30 Oct 2026 03:20:19 +0100 Subject: [PATCH 10/73] M14: validate window fallback geometry and avoid duplicate symbol names (#4343) * M14: validate window fallback geometry and avoid duplicate symbol names Verified regression cases and full cargo xtask ci before publication. * M14: document review helpers and regression contracts Documentation-only follow-up; full cargo xtask ci passed. --------- Co-authored-by: kalmarr <680152+kalmarr@users.noreply.github.com> Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +++- crates/ui-egui/src/equation_tab.rs | 24 ++++++++++++++++++++--- crates/ui-egui/src/window_geometry.rs | 28 +++++++++++++++++++++++++-- docs/ui-parity.md | 3 ++- 4 files changed, 52 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a709846a7a..a8cf9d7ae6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-29 · **Change:** trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; 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: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; 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: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: 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: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #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 fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; 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: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; 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: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: 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: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #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 fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously 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. @@ -202,6 +202,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 2026-10-29 | Window sizing falls back to the screen rectangle when the reported inner size is invalid, and an Equation symbol tooltip no longer repeats a missing glyph's name or code point | | 2026-10-29 | A NUL in a document title no longer reaches the native window title, which panicked on X11 (#3634): displayed titles and suggested file names drop NUL characters and fall back to the untitled name when empty; the stored title is unchanged | | 2026-10-28 | The Traditional Chinese interface draws hanzi to the Taiwan standard: on the desktop an installed Traditional Chinese font (PingFang TC on macOS, Microsoft JhengHei on Windows, Noto Sans CJK TC on Linux) comes before the embedded Noto Sans CJK SC, which drew 說, 這, 為, 戶 in mainland shapes; without one, and on the web, nothing changes. | | 2026-10-17 | macOS launch arguments (`-psn_*`, `-NS*`, `-Apple*`) are no longer opened as files, and a file that fails to open names the file and the real cause on the status bar and in the log (#1103). The reported "corrupt" error could not be reproduced. | @@ -650,6 +651,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-29 | trivial | Progress log: window fallback geometry and equation symbol tooltips | | 2026-10-29 | trivial | Progress log: window titles omit NUL characters (#3634) | | 2026-10-29 | trivial | Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Filtered/sorted order, product geometry and single-label mode retained; readiness estimates unchanged. | | 2026-10-22 | trivial | Progress log: one native window per open document | diff --git a/crates/ui-egui/src/equation_tab.rs b/crates/ui-egui/src/equation_tab.rs index 89fd35ad31..5a44575bf6 100644 --- a/crates/ui-egui/src/equation_tab.rs +++ b/crates/ui-egui/src/equation_tab.rs @@ -325,9 +325,16 @@ fn symbol_button(ui: &mut Ui, app: &mut WordApp, c: char, t: &Tokens) -> bool { let nodes = vec![wordcraft_doc::math::MNode::Run(wordcraft_doc::math::MRun { text: s.clone(), lit: true, ..Default::default() })]; paint_math(app, ui, MathPreview { rect: r, key: &format!("sym:{s}"), linear: &s, nodes: Some(nodes), display: false, points: 11.0 }); let display = math_placeholder(ui, &s); - let name = - wordcraft_doc::math_symbols::name_of(c).map(|n| format!("{display} \\{n}")).unwrap_or_else(|| format!("{display} U+{:04X}", c as u32)); - resp.on_hover_text(name).clicked() + resp.on_hover_text(symbol_tooltip(c, &display)).clicked() +} + +/// Append the symbol name or codepoint only when the display is the glyph itself. +fn symbol_tooltip(c: char, display: &str) -> String { + if display != c.to_string() { + // Missing glyphs already display their linear name or codepoint. + return display.to_owned(); + } + wordcraft_doc::math_symbols::name_of(c).map(|n| format!("{display} \\{n}")).unwrap_or_else(|| format!("{display} U+{:04X}", c as u32)) } /// The Equation tab. @@ -461,6 +468,17 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { #[cfg(test)] mod placeholder_tests { + /// Named and unnamed missing glyphs appear once; covered glyphs retain their suffix. + #[test] + fn symbol_tooltips_do_not_repeat_missing_glyph_fallbacks() { + for c in ['α', '\u{0378}'] { + let display = super::math_placeholder_with(&c.to_string(), |_| false); + assert_eq!(super::symbol_tooltip(c, &display), display); + } + assert_eq!(super::symbol_tooltip('α', "α"), "α \\alpha"); + assert_eq!(super::symbol_tooltip('\u{0378}', "\u{0378}"), "\u{0378} U+0378"); + } + /// `∁` falls back to `\complement` in the placeholder while covered `x+y` is unchanged. #[test] fn a_missing_math_glyph_has_a_readable_linear_placeholder() { diff --git a/crates/ui-egui/src/window_geometry.rs b/crates/ui-egui/src/window_geometry.rs index b58129b3ed..6308eed151 100644 --- a/crates/ui-egui/src/window_geometry.rs +++ b/crates/ui-egui/src/window_geometry.rs @@ -47,8 +47,12 @@ impl RawWindowState { /// The content size reported by this frame, if any. Never the last painted size: a hidden /// Wayland tick with neither an `inner_rect` nor a screen rect has no usable geometry. pub fn content_size(self) -> Option { - let size = self.inner_rect.or(self.screen_rect)?.size(); - (size.is_finite() && size.x > 0.0 && size.y > 0.0).then_some(size) + [self.inner_rect, self.screen_rect] + .into_iter() + .flatten() + .filter(|r| r.is_finite()) + .map(|r| r.size()) + .find(|s| s.is_finite() && s.x > 0.0 && s.y > 0.0) } } @@ -310,6 +314,26 @@ mod tests { assert_eq!((g.position, g.width, g.height, g.monitor), (None, 1000.0, 700.0, Some([1920.0, 1080.0]))); } + /// Invalid inner dimensions use fresh screen geometry; valid inner dimensions take precedence. + #[test] + fn invalid_inner_size_falls_back_to_fresh_screen_geometry() { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 700.0)); + for size in [egui::vec2(0.0, 0.0), egui::vec2(-1.0, 10.0), egui::vec2(f32::NAN, 10.0)] { + let raw = RawWindowState { + inner_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), + screen_rect: Some(screen), + ..Default::default() + }; + assert_eq!(raw.content_size(), Some(screen.size())); + } + let inner = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); + assert_eq!(RawWindowState { inner_rect: Some(inner), screen_rect: Some(screen), ..Default::default() }.content_size(), Some(inner.size())); + assert_eq!( + RawWindowState { inner_rect: Some(egui::Rect::ZERO), screen_rect: Some(egui::Rect::ZERO), ..Default::default() }.content_size(), + None + ); + } + /// Only the current frame's root viewport contributes inner, outer, content and monitor sizes; a /// missing viewport or non-finite data leaves defaults. #[test] diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 09353d8392..c17b84064b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (window titles and suggested file names omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; 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 Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (window size falls back to the screen rectangle when the reported inner size is invalid; equation symbol tooltips no longer repeat a missing glyph's name); previously trivial (window titles and suggested file names omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; 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 Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 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). @@ -84,6 +84,7 @@ Labels → New Document with “One label per recipient” includes all filtered | Date | Change | Summary | |---|---|---| +| 2026-10-29 | trivial | Window sizing uses the screen rectangle when the inner rectangle is missing, empty or not finite; an Equation symbol tooltip shows a missing glyph's linear name or code point once instead of twice | | 2026-10-29 | trivial | Displayed document titles and suggested file names omit NUL characters (X11 panicked on them) and use the localized untitled name when nothing remains (#3634) | | 2026-10-29 | trivial | Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Filtered/sorted order, product geometry and single-label mode retained; readiness estimates unchanged. | | 2026-10-22 | trivial | View › Window › New Window opens another window on the document, focused and cascaded; two windows on one document are titled `Name:1` and `Name:2` (one window: the plain name). Each window keeps its own selection, scroll position, zoom, view mode, ruler and panes; the text, undo history, unsaved state and file are the document's, so an edit or Undo in either window shows in both, and a caret in text the other window deleted moves to the nearest place left. Closing one of a document's windows asks nothing; closing its last asks about unsaved changes, and quitting asks once per document. Switch Windows lists every window. The web app keeps one window | From 39ae5f677843c07242ba2abba9e95a50e077f992 Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Thu, 29 Oct 2026 19:22:55 -0700 Subject: [PATCH 11/73] Layout parity: field results take \* DollarText, \* Hex and \* ArabicDash (#4361) * Layout parity: field results take \* DollarText, \* Hex and \* ArabicDash docs/layout-parity.md gets a row for the three field switches that #4226 added, a revision entry and a new status-line clause. The row describes main as it is after 8c035fbb, where \* ArabicDash formats any number field and table formulas, not PAGE alone. * Layout parity: dollar text keeps its digits only past 999,999.99 Cardinal and ordinal text keep their digits past 999,999. `\* DollarText` writes results up to 999,999.99 (crates/layout/src/fields.rs dollar_text). --------- Co-authored-by: Brandon Thomas --- docs/layout-parity.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/layout-parity.md b/docs/layout-parity.md index be964d4c5b..566b9b4fe5 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (a cell spanning columns shares its extra minimum width by the columns' preferred widths, equally in automatic tables, #1883; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; 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: Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; 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: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; 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: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (field results take the `\* DollarText`, `\* Hex` and `\* ArabicDash` switches, #4226; previously trivial: a cell spanning columns shares its extra minimum width by the columns' preferred widths, equally in automatic tables, #1883; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; 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: Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; 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: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; 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: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -87,6 +87,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Compatibility modes (Word 2003/2007/2010 layout for old files: `w:compat` options) | 🟡 | `allowSpaceOfSameStyleInTable` supported in modes 11/12/14; mode 15 retains contextual suppression. `doNotExpandShiftReturn` keeps justified lines that end in a manual line break short (#3924). Most other legacy compat options remain ignored | 10–20 | | Fields: STYLEREF in headers and footers (running chapter titles) | ✅ | Evaluated per page at layout time: the page's first (or with `\l` last) paragraph of the style, else the nearest before it; style name, id or heading level (a level finds the style named `heading N` whatever its id, so localized documents match, #1286); character styles; `\n \r \w \s \t \p` (`\r` treated as `\w`); body STYLEREF updated with the other fields (#775). Field results with right-to-left text (a Hebrew heading, "פרק א") are laid out with bidi reordering like ordinary text: the result is an isolate taking its first strong letter's direction (else the text's around the field), and each level run is shaped and mirrored in its own direction, so `{ PAGE }` in field-code view and `(Chapter 1)` keep their brackets in right-to-left paragraphs; this covers every field and note mark shown as text (#1670). Header paragraphs are cached on their per-page results, so typing in body text no STYLEREF shows doesn't lay the headers out again (#1286) | — | | Fields: PAGE, NUMPAGES, SECTIONPAGES, TOC page numbers, PAGEREF in headers and footers | ✅ | #52; totals in the body need a second pass once the page count is known (#451); SECTIONPAGES includes shared continuous-section start pages; a PAGE field in a paragraph that runs onto a later page (a page break inside it, or a natural split) shows that page's number, the rest of the paragraph broken again for the wider result (#1352); PAGEREF in headers and footers shows its bookmark's page, filled in after pagination like their totals (#1477); in the body it keeps its result until fields are updated | — | +| Fields: `\* DollarText`, `\* Hex` and `\* ArabicDash` | 🟡 | `\* DollarText` writes lowercase cardinal text, "and", and the first two decimals over 100 (`{ = 9.20 + 5.35 \* DollarText }` shows "fourteen and 55/100"), `\* Hex` uppercase hexadecimal digits (458 shows "1CA"), and `\* ArabicDash` the number between hyphens (`{ PAGE \* ArabicDash }` shows "- 31 -"), in table formulas and in the page-number, SEQ, PAGEREF, NUMWORDS, NUMCHARS and REVNUM fields; a capitalization switch applies after them (#4226). A result past 999,999 in cardinal or ordinal text, or past 999,999.99 in dollar text, keeps its digits, and so does a negative result or a fractional Hex or ArabicDash result. `layout::fields::tests::dollar_text_hex_and_arabic_dash_switches`, `engine::cmd::table_formula::tests::dollar_text_and_hex_switches_write_the_result`, `engine::tests::table_formula_results_take_dollar_text_and_hex`; not compared with Word | — | | Page-by-page comparison harness against Word | ❌ | Run Word locally on our own synthetic documents, export PDF, compare line breaks; Word output stays under `plan/` (never committed) | 8–12 | ## Missing black-circle glyphs @@ -258,6 +259,8 @@ layout suite are pending; this is not a new Word/native-IME acceptance claim. ## Revision history +> 2026-10-25 · Field results take the `\* DollarText`, `\* Hex` and `\* ArabicDash` switches in table formulas and in the page-number, SEQ, PAGEREF and count fields (#4226) (`layout::fields::tests::dollar_text_hex_and_arabic_dash_switches`). + > 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`). > 2026-10-23 · cu20we review follow-up: Only paragraph-relative box floats use the pre-spacing origin. Line-relative floats and page/margin fallbacks in cells and text boxes keep the text-top origin. Synthetic controls compare zero and 24 pt space-before across all four box contexts. New cap conversion helpers and regression tests document their contracts; readiness estimates unchanged. @@ -269,6 +272,7 @@ layout suite are pending; this is not a new Word/native-IME acceptance claim. | Date | Change | Summary | |---|---|---| | 2026-10-29 | trivial | A cell spanning columns shares its extra minimum width by the columns' preferred widths (equally in automatic tables); nested tables are measured with the same weights (#1883) | +| 2026-10-25 | trivial | Field results take the `\* DollarText`, `\* Hex` and `\* ArabicDash` switches in table formulas and in the page-number, SEQ, PAGEREF and count fields (#4226); readiness estimates unchanged | | 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-24 | trivial | Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; original emoji regression retained, new mixed-style/bundled-font LTR/bidi controls pending managed validation | | 2026-10-24 | trivial | Exact-spacing table contact uses the specified line height in row selection and word skipping; large/small mark regressions in modes 11/12/14/15 (#3977); shape estimates unchanged | From 983f1e0267a684164014750f6f81e381e1768b02 Mon Sep 17 00:00:00 2001 From: Bra1nZ <95569966+Bra1nZ@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:24:23 +0300 Subject: [PATCH 12/73] docx: pin Standard-encryption key derivation to a real-world package (#4472) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the key that Apache POI's Excel-written test file protected_passtika.xlsx (password "tika") derives, computed from [MS-OFFCRYPTO] §2.3.4.7 independently of the implementation, so real Word 2007 files stay pinned. Also adds the contributor's people.toml entry. Co-authored-by: Bra1nZ Co-authored-by: Brandon Thomas --- contributors/people.toml | 3 +++ crates/docx/src/crypt/tests.rs | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 231ad63530..7d6eaaa0f3 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -184,3 +184,6 @@ display_name = "OtorO" [people.TobyWu666] display_name = "tobywu" + +[people.Bra1nZ] +sync_github_name = true diff --git a/crates/docx/src/crypt/tests.rs b/crates/docx/src/crypt/tests.rs index 44421be745..0b316061bd 100644 --- a/crates/docx/src/crypt/tests.rs +++ b/crates/docx/src/crypt/tests.rs @@ -193,6 +193,25 @@ fn hostile_encryption_info_errors_without_panicking() { assert!(decrypt(&fake, Some("pw")).is_err()); } +/// The key a real-world Standard-encrypted package derives: Apache POI's test file +/// `protected_passtika.xlsx` (Apache-2.0 test data), password "tika", written by Excel +/// itself. The value was computed from [MS-OFFCRYPTO] §2.3.4.7 independently of this code — +/// the file decrypts to a valid package under it and nothing else opens that file — so real +/// Word 2007 documents stay pinned, not just spec-derived ones. +#[test] +fn standard_key_matches_a_real_world_package() { + let salt: [u8; 16] = [0x19, 0xd0, 0xc7, 0xf3, 0x95, 0x28, 0x78, 0x43, 0x6a, 0x49, 0x49, 0xd7, 0xf0, 0xa2, 0xcf, 0x1e]; + const KEY: &str = "8d4d00143a5f2e4918af7e6e7410d46c"; + let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::(); + assert_eq!(hex(&standard_key(&salt, "tika", 16)), KEY); + // A different password or salt gives a different key; the key is exactly the length + // asked for, for each AES width. + assert_ne!(hex(&standard_key(&salt, "tika2", 16)), KEY); + assert_ne!(hex(&standard_key(&[0; 16], "tika", 16)), KEY); + assert_eq!(standard_key(&salt, "tika", 24).len(), 24); + assert_eq!(standard_key(&salt, "tika", 32).len(), 32); +} + /// Standard encryption's key derivation (§2.3.4.7) for the password "Password1234_" and the /// salt 00 01 … 0F. The expected keys were computed outside WordCraft (Python's hashlib) /// following the specification's steps, so the encryptor and decryptor can't share a mistake. From 931324e0499384c8fd8394952169472d870a040d Mon Sep 17 00:00:00 2001 From: Michael Mu <69072633+MichaelMuClaude@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:32:41 +0800 Subject: [PATCH 13/73] WordArt gallery: hand the keyboard back to the page after a pick (#4061) (#4358) Inserting WordArt selects its placeholder text, so typing should replace it. From the Insert gallery the first keystrokes went nowhere: the gallery's cells (and the Text Fill, Text Outline, Glow and Transform galleries beside them) ran their command with `app.run` instead of `run_from_ribbon`, so the canvas never asked for focus back and the page ignored the keys. They now go through `run_from_ribbon` like every menu item. Regression test renders the style gallery, picks a style and checks the page asks for the keyboard; it fails without the change. Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/tests.rs | 31 +++++++++++++++++++++++++++++ crates/ui-egui/src/wordart_menus.rs | 12 +++++------ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 308beb8a19..eb975ec419 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -3944,6 +3944,37 @@ fn escape_selects_a_wordart_box_and_delete_removes_it() { assert!(s.sel.is_collapsed() && s.sel.focus.story == wordcraft_doc::StoryRef::Body, "a second Escape leaves a caret in the body"); } +/// #4061: a WordArt picked from a style gallery has its text selected and the page gets the +/// keyboard back, so typing replaces the placeholder text at once. The gallery's cells ran +/// their command without handing focus back, so the first keystrokes went nowhere. +#[test] +fn picking_a_wordart_style_hands_the_keyboard_back_to_the_page() { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + // The first pass installs the fonts the gallery's cells are drawn in. + if ctx.cumulative_pass_nr() > 1 { + crate::wordart_menus::style_gallery(ui, app, "insert.wordArt"); + } + }, + app(), + ); + h.run_steps(3); + h.state_mut().canvas.want_focus = false; + // The first cell, "Solid Accent": a click on its middle (the cells carry no label here). + let at = egui::pos2(30.0, 30.0); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + h.event(egui::Event::PointerMoved(at)); + h.run_steps(1); + h.event(button(true)); + h.run_steps(1); + h.event(button(false)); + h.run_steps(2); + assert!(canvas::in_text_box(h.state()), "the WordArt's text is selected"); + assert!(h.state().canvas.want_focus, "the page takes the keyboard back"); +} + /// #1218: Ctrl+Q resets the paragraph formatting and nothing else; it also closed the window /// (asking to save) on Windows and Linux. ⌘Q still quits on the Mac. #[test] diff --git a/crates/ui-egui/src/wordart_menus.rs b/crates/ui-egui/src/wordart_menus.rs index f312bc22c1..1af2397d44 100644 --- a/crates/ui-egui/src/wordart_menus.rs +++ b/crates/ui-egui/src/wordart_menus.rs @@ -79,7 +79,7 @@ pub fn style_gallery(ui: &mut Ui, app: &mut WordApp, cmd: &str) { egui::Grid::new(("wordart-styles", cmd)).spacing(vec2(4.0, 4.0)).show(ui, |ui| { for (i, (id, label)) in ART_STYLES.iter().enumerate() { if cell(ui, 40.0, 36.0, label, |p, r| paint_style(p, r, id, &theme)) { - let _ = app.run(cmd, json!({"style": id})); + crate::widgets::run_from_ribbon(app, cmd, json!({"style": id})); ui.close(); } if i % 4 == 3 { @@ -95,7 +95,7 @@ pub fn fill_menu(ui: &mut Ui, app: &mut WordApp, theme: &[Rgb]) { mi(ui, app, "Automatic", "format.textEffects", json!({"fill": null})); mi(ui, app, "No Fill", "format.textEffects", json!({"fill": "none"})); if let Some(hex) = color_grid(ui, theme) { - let _ = app.run("format.textEffects", json!({"fill": hex})); + crate::widgets::run_from_ribbon(app, "format.textEffects", json!({"fill": hex})); ui.close(); } ui.add_space(4.0); @@ -113,7 +113,7 @@ pub fn fill_menu(ui: &mut Ui, app: &mut WordApp, theme: &[Rgb]) { } }); if clicked { - let _ = app.run("format.textEffects", json!({"fill": {"gradient": [a.hex(), b.hex()], "angle": 90}})); + crate::widgets::run_from_ribbon(app, "format.textEffects", json!({"fill": {"gradient": [a.hex(), b.hex()], "angle": 90}})); ui.close(); } } @@ -126,7 +126,7 @@ pub fn outline_menu(ui: &mut Ui, app: &mut WordApp, theme: &[Rgb], current: Opti mi(ui, app, "No Outline", "format.textEffects", json!({"outline": "none"})); let width = current.map_or(0.75, |c| c.1); if let Some(hex) = color_grid(ui, theme) { - let _ = app.run("format.textEffects", json!({"outline": {"color": hex, "width": width}})); + crate::widgets::run_from_ribbon(app, "format.textEffects", json!({"outline": {"color": hex, "width": width}})); ui.close(); } ui.separator(); @@ -174,7 +174,7 @@ pub fn effects_menu(ui: &mut Ui, app: &mut WordApp, theme: &[Rgb], transform: bo ui.separator(); ui.menu_button(crate::tl!("Glow Colors"), |ui| { if let Some(hex) = color_grid(ui, theme) { - let _ = app.run("format.textEffects", json!({"glow": {"color": hex, "size": 5.0}})); + crate::widgets::run_from_ribbon(app, "format.textEffects", json!({"glow": {"color": hex, "size": 5.0}})); ui.close(); } }); @@ -199,7 +199,7 @@ pub fn transform_menu(ui: &mut Ui, app: &mut WordApp) { egui::Grid::new("wordart-warps").spacing(vec2(4.0, 4.0)).show(ui, |ui| { for (i, (id, label)) in DRAWN_WARPS.iter().enumerate() { if cell(ui, 46.0, 32.0, label, |p, r| paint_warp(p, r.shrink(2.0), id, t.accent)) { - let _ = app.run("wordArt.transform", json!({"preset": id})); + crate::widgets::run_from_ribbon(app, "wordArt.transform", json!({"preset": id})); ui.close(); } if i % 4 == 3 { From 41d5284fa85224f902ad6ea94e4acb0655bd6417 Mon Sep 17 00:00:00 2001 From: Lucien Date: Fri, 30 Oct 2026 03:33:21 +0100 Subject: [PATCH 14/73] Options: a blank User Name stays blank after a restart (#4368) (#4421) * Options: a blank User Name stays blank after a restart (#4368) * docs: ui-parity row for the blank User Name (#4368) --------- Co-authored-by: Brandon Thomas --- apps/wordcraft-web/src/prefs.rs | 4 ++-- crates/ui-egui/src/app/prefs.rs | 8 +++++--- crates/ui-egui/src/lib.rs | 7 ++++--- crates/ui-egui/src/tests.rs | 21 ++++++++++++++++++++- docs/ui-parity.md | 3 ++- 5 files changed, 33 insertions(+), 10 deletions(-) diff --git a/apps/wordcraft-web/src/prefs.rs b/apps/wordcraft-web/src/prefs.rs index 1131a7551b..5b3f9a99b6 100644 --- a/apps/wordcraft-web/src/prefs.rs +++ b/apps/wordcraft-web/src/prefs.rs @@ -49,9 +49,9 @@ impl PrefsSync { pub fn changed(&mut self, app: &WordApp) -> Option { let mut prefs = app.prefs(); if let Some((host, own)) = &self.host_author - && prefs.author == *host + && prefs.author.as_deref() == Some(host.as_str()) { - prefs.author = own.clone(); + prefs.author = Some(own.clone()); } let json = match serde_json::to_string(&prefs) { Ok(j) => j, diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 83cd2eb069..872cd05188 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -12,7 +12,7 @@ impl WordApp { /// preferences. pub fn prefs(&self) -> UiState { let mut ui = self.ui.clone(); - ui.author = self.session.author.clone(); + ui.author = Some(self.session.author.clone()); ui.initials = self.session.initials.clone(); ui.dictionary = wordcraft_engine::proof::user_dictionary(); ui.editing = self.session.prefs.clone(); @@ -47,8 +47,10 @@ impl WordApp { self.legacy_interface_size = self.ui.legacy_interface_size.take().filter(|_| self.ui.interface_size.is_auto()).and_then(|old| theme::legacy_interface_percent(&old)); // The session owns the name from here on; `prefs` copies it back when saving. - let author = std::mem::take(&mut self.ui.author); - if !author.trim().is_empty() { + // A saved empty name stays empty (#4368); one of only spaces keeps the default. + if let Some(author) = self.ui.author.take() + && (author.is_empty() || !author.trim().is_empty()) + { self.session.author = author; } self.session.initials = std::mem::take(&mut self.ui.initials).chars().take(wordcraft_engine::cmd::options::MAX_INITIALS_CHARS).collect(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 97dca88179..8c61a5b3eb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -192,8 +192,9 @@ pub struct UiState { #[serde(skip)] pub nav_collapsed: std::collections::HashSet, pub show_discord: bool, - /// User name (File › Options) for comments and tracked changes; empty keeps the default. - pub author: String, + /// User name (File › Options) for comments and tracked changes. `None` (no saved name) keeps + /// the default; a saved empty name stays empty (#4368). + pub author: Option, /// Desktop: the main window's size and position, restored at the next launch. pub window: Option, /// Interface language: `auto` (follow the system) or a code from [`i18n::LANGUAGES`]. @@ -315,7 +316,7 @@ impl Default for UiState { pane_order: Vec::new(), nav_collapsed: Default::default(), show_discord: true, - author: String::new(), + author: None, window: None, language: i18n::AUTO.into(), editing: wordcraft_engine::Prefs::default(), diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index eb975ec419..a98d667e7d 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -2519,7 +2519,26 @@ fn user_name_survives_restart() { // A later rename is what gets saved next, not the name loaded at startup. second.run("file.setAuthor", json!({"name": "Grace Hopper"})).unwrap(); - assert_eq!(second.prefs().author, "Grace Hopper"); + assert_eq!(second.prefs().author.as_deref(), Some("Grace Hopper")); +} + +/// #4368: a user name cleared in File › Options stays empty after a restart. +#[test] +fn blank_user_name_survives_restart() { + let mut first = app(); + first.session.author.clear(); + let saved = serde_json::to_vec(&first.prefs()).unwrap(); + + let mut second = app(); + assert!(!second.session.author.is_empty()); + second.apply_prefs(serde_json::from_slice(&saved).unwrap()); + assert_eq!(second.session.author, ""); + + // A name of only spaces is not a name: the default stays. + let default = app().session.author; + let mut third = app(); + third.apply_prefs(serde_json::from_str(r#"{"author": " "}"#).unwrap()); + assert_eq!(third.session.author, default); } const UNSAVED: &str = "My unsaved novel chapter"; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c17b84064b..e6806755f5 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (window size falls back to the screen rectangle when the reported inner size is invalid; equation symbol tooltips no longer repeat a missing glyph's name); previously trivial (window titles and suggested file names omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; 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 Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (a blank User Name in File › Options stays blank after a restart, #4368); previously trivial (window size falls back to the screen rectangle when the reported inner size is invalid; equation symbol tooltips no longer repeat a missing glyph's name); previously trivial (window titles and suggested file names omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; 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 Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 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). @@ -84,6 +84,7 @@ Labels → New Document with “One label per recipient” includes all filtered | Date | Change | Summary | |---|---|---| +| 2026-10-29 | trivial | File › Options › User Name left blank stays blank after a restart (it came back as the default name); a name of only spaces still keeps the default (#4368) | | 2026-10-29 | trivial | Window sizing uses the screen rectangle when the inner rectangle is missing, empty or not finite; an Equation symbol tooltip shows a missing glyph's linear name or code point once instead of twice | | 2026-10-29 | trivial | Displayed document titles and suggested file names omit NUL characters (X11 panicked on them) and use the localized untitled name when nothing remains (#3634) | | 2026-10-29 | trivial | Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Filtered/sorted order, product geometry and single-label mode retained; readiness estimates unchanged. | From 4a12ab9a19360d7cf76f621dafa9be3de12f8fee Mon Sep 17 00:00:00 2001 From: Lucien Date: Fri, 30 Oct 2026 03:34:01 +0100 Subject: [PATCH 15/73] =?UTF-8?q?Layout=20=E2=80=BA=20Arrange=20=E2=80=BA?= =?UTF-8?q?=20Group=20stays=20on=20while=20a=20group=20is=20selected,=20so?= =?UTF-8?q?=20Ungroup=20is=20reachable=20(#4229)=20(#4440)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Brandon Thomas --- crates/ui-egui/src/ribbon/arrange.rs | 9 ++++++++- crates/ui-egui/src/ribbon/tests.rs | 29 ++++++++++++++++++++++++++++ docs/ui-parity.md | 1 + 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/ribbon/arrange.rs b/crates/ui-egui/src/ribbon/arrange.rs index 998d44905a..df446d10e9 100644 --- a/crates/ui-egui/src/ribbon/arrange.rs +++ b/crates/ui-egui/src/ribbon/arrange.rs @@ -90,9 +90,16 @@ pub(super) fn rotation_field(ui: &mut Ui, app: &mut WordApp) { } } +/// The command the Group menu button follows: Ungroup while a group is selected (Group is off +/// then, as one object has nothing to group), Group otherwise (#4229). +pub(super) fn group_menu_command(app: &WordApp) -> &'static str { + if crate::widgets::enabled(app, "arrange.ungroup") { "arrange.ungroup" } else { "arrange.group" } +} + /// Arrange › Group: Group (Shift+click objects to select several) and Ungroup. pub(super) fn group_menu(ui: &mut Ui, app: &mut WordApp, label: Option<&str>) { - menu_button(ui, app, crate::widgets::MenuButtonOptions::new("group", label, "Group", false, "arrange.group"), |ui, app| { + let id = group_menu_command(app); + menu_button(ui, app, crate::widgets::MenuButtonOptions::new("group", label, "Group", false, id), |ui, app| { mi(ui, app, "Group", "arrange.group", json!({})); mi(ui, app, "Ungroup", "arrange.ungroup", json!({})); }); diff --git a/crates/ui-egui/src/ribbon/tests.rs b/crates/ui-egui/src/ribbon/tests.rs index d2e138a4d8..edbe0d3b2b 100644 --- a/crates/ui-egui/src/ribbon/tests.rs +++ b/crates/ui-egui/src/ribbon/tests.rs @@ -1,3 +1,4 @@ +use super::arrange::group_menu_command; use super::*; use wordcraft_engine::Session; @@ -117,6 +118,34 @@ fn menu_buttons_follow_their_command() { } } +/// #4229: with a group selected the Group button stays on, so Ungroup in its menu can be +/// reached; with nothing selected it is off, and with two objects it offers Group. +#[test] +fn the_group_menu_stays_on_for_a_selected_group() { + use crate::widgets::{enabled, menu_enabled}; + let mut a = crate::WordApp::new( + Session::new(wordcraft_doc::Document::from_text( + "one +two +three", + )), + crate::Services::default(), + ); + assert!(!menu_enabled(&a, group_menu_command(&a)), "nothing selected"); + a.run("insert.shape", json!({"kind": "rectangle", "width": 80, "height": 40})).unwrap(); + a.run("arrange.bounds", json!({"page": 0, "x": 100, "y": 150})).unwrap(); + a.run("select.collapse", json!({"end": true})).unwrap(); + a.run("insert.shape", json!({"kind": "ellipse", "width": 80, "height": 40})).unwrap(); + a.run("arrange.bounds", json!({"page": 0, "x": 300, "y": 150})).unwrap(); + a.run("select.addObject", json!({"index": 0})).unwrap(); + assert_eq!(group_menu_command(&a), "arrange.group"); + assert!(menu_enabled(&a, group_menu_command(&a)), "two objects to group"); + a.run("arrange.group", json!({})).unwrap(); + assert!(!enabled(&a, "arrange.group"), "one group: nothing to group"); + assert_eq!(group_menu_command(&a), "arrange.ungroup"); + assert!(menu_enabled(&a, group_menu_command(&a)), "the menu reaches Ungroup"); +} + /// #1206: Margins, Orientation, Size and Columns mark the caret section's setting, as Word /// does. Every value differs from a new document's, so a default can't satisfy it. #[test] diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e6806755f5..66dc806f86 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -88,6 +88,7 @@ Labels → New Document with “One label per recipient” includes all filtered | 2026-10-29 | trivial | Window sizing uses the screen rectangle when the inner rectangle is missing, empty or not finite; an Equation symbol tooltip shows a missing glyph's linear name or code point once instead of twice | | 2026-10-29 | trivial | Displayed document titles and suggested file names omit NUL characters (X11 panicked on them) and use the localized untitled name when nothing remains (#3634) | | 2026-10-29 | trivial | Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Filtered/sorted order, product geometry and single-label mode retained; readiness estimates unchanged. | +| 2026-10-26 | trivial | Layout › Arrange › Group stays on while a group is selected, so Ungroup in its menu can be reached; it was greyed out because one object has nothing to group (#4229) | | 2026-10-22 | trivial | View › Window › New Window opens another window on the document, focused and cascaded; two windows on one document are titled `Name:1` and `Name:2` (one window: the plain name). Each window keeps its own selection, scroll position, zoom, view mode, ruler and panes; the text, undo history, unsaved state and file are the document's, so an edit or Undo in either window shows in both, and a caret in text the other window deleted moves to the nearest place left. Closing one of a document's windows asks nothing; closing its last asks about unsaved changes, and quitting asks once per document. Switch Windows lists every window. The web app keeps one window | | 2026-10-22 | trivial | One native window per open document on the desktop (the web app keeps one window): New and Open open the document in a window of its own, focused and cascaded from the last; Open replaces an untouched blank document in its window and brings an already-open file's window forward; each window keeps its own ribbon tab, File page, dialogs, panes and composition; View › Window › Switch Windows lists the windows and brings the chosen one forward; File › Close and the window's close button close the window (asking about unsaved changes) and the window focused before comes forward, while the last window stays open with no document; quitting asks Save / Don't Save / Cancel about each document in its own window | | 2026-10-22 | trivial | Several open documents in one window (one window per document comes next): View › Window › Switch Windows lists the open documents, the shown one marked and unsaved ones dotted, and shows the one chosen, with its own scroll position, zoom, comment drafts and AutoSave file; File › New, Open, Envelopes, Labels, Finish & Merge and personal templates open beside the open documents without asking about unsaved changes; File › Close shows the next open document; the window's close button asks Save / Don't Save / Cancel about each document with unsaved changes, showing it, and Cancel keeps the app open. Untitled documents are numbered (Document2) in the interface language | From 8a964609c4c5c223c0927c62bcd1168d0a53b597 Mon Sep 17 00:00:00 2001 From: Michael Mu <69072633+MichaelMuClaude@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:34:44 +0800 Subject: [PATCH 16/73] Format Painter: double-click a word to paint it (#4410) (#4502) With the painter armed, only dragging across the text applied the copied formatting: a double-click (a word) or triple-click (a paragraph) selected the text and returned before the painter was used. Both paths now go through one helper, so the click paints what it selected and the painter goes off. Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/canvas/input.rs | 15 ++++++++--- crates/ui-egui/src/canvas/tests.rs | 43 ++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 56ab7cf984..663d88bbfd 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -82,6 +82,14 @@ pub(super) fn clamped_scroll(before: egui::Vec2, delta: egui::Vec2, max: egui::V (before - delta).clamp(egui::Vec2::ZERO, max) } +/// Paint the selection with the Format Painter's formatting, when it is armed and something is +/// selected. +fn apply_format_painter(app: &mut WordApp) { + if app.session.painter.is_some() && !app.session.sel.is_collapsed() { + let _ = app.run("edit.pasteFormat", json!({})); + } +} + /// Routes pointer input: pen or eraser ink, object and crop drags, table border, selection and move /// handles, then page hit testing, outlines, headers and footers, equations, selection and caret /// interaction. @@ -214,9 +222,7 @@ fn handle_page_pointer(app: &mut WordApp, pointer: PagePointer<'_>) { app.canvas.dragging = false; app.canvas.column_drag = None; app.canvas.unit_drag = None; - if app.session.painter.is_some() && !app.session.sel.is_collapsed() { - let _ = app.run("edit.pasteFormat", json!({})); - } + apply_format_painter(app); } show_hover_link(app, &pointer); } @@ -394,6 +400,9 @@ fn handle_completed_click(app: &mut WordApp, pointer: &PagePointer<'_>) -> bool if released_clicks > 0 && app.canvas.unit_drag.as_ref().is_some_and(|d| d.clicks == released_clicks) { app.canvas.unit_drag = None; app.canvas.dragging = false; + // Double-clicking a word (or triple-clicking a paragraph) with the Format Painter armed + // paints it, as dragging across it does (#4410). + apply_format_painter(app); return true; } // Double-click in the header/footer area edits it (creating it when the page has none, #960); diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 10e0ffd98d..6450f1292a 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -1053,6 +1053,49 @@ fn an_armed_format_painter_changes_the_pointer() { assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Default, "the selection bar keeps its arrow"); } +/// #4410: with the Format Painter armed, a double-click on a word paints that word, and the +/// painter goes off again (it was only dragging across the text that painted it). +#[test] +fn double_clicking_a_word_with_the_format_painter_paints_it() { + use egui::{Event, PointerButton}; + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + a.run("text.insert", json!({"text": "Bold source then target words"})).unwrap(); + a.run("view.commentsPane", json!({"value": false})).unwrap(); + a.run("select.text", json!({"text": "Bold"})).unwrap(); + a.run("format.bold", json!({})).unwrap(); + a.run("edit.formatPainter", json!({})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + for _ in 0..6 { + h.step(); + } + assert!(h.state().session.painter.is_some(), "armed"); + let word_off = "Bold source then ".len() + 2; + let c = h.state_mut().session.layout().caret(&Pos::body(0, word_off)).expect("caret"); + let at = page_to_screen(h.state_mut(), c.page, c.x, c.top + c.height / 2.0).expect("on screen"); + h.event(Event::PointerMoved(at)); + for _ in 0..2 { + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.step(); + } + for _ in 0..3 { + h.step(); + } + let s = &h.state().session; + let para = s.doc.para_at(&Pos::body(0, 0)).expect("paragraph"); + assert_eq!(para.props_of_char("Bold source then t".len()).bold, Some(true), "the double-clicked word is painted"); + assert_ne!(para.props_of_char("Bold source ".len()).bold, Some(true), "the other words are not"); + assert!(s.painter.is_none(), "the painter is used up"); +} + /// #1085: a selected balloon's buttons work (pressing them is not a click on the page that /// leaves the balloon): a new comment with no text resolves and deletes from its balloon. #[test] From bd85c72f3941b6086690c29144c4a859b9911b28 Mon Sep 17 00:00:00 2001 From: Michael Mu <69072633+MichaelMuClaude@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:36:42 +0800 Subject: [PATCH 17/73] Shading buttons show the last picked colour (#4412) (#4505) * Shading buttons show the last picked colour as their bar (#4412) The paragraph and table Shading buttons always drew the icon's yellow bar, whatever colour a click applied. They now draw the remembered colour as the bar, as Font Color and Text Highlight Color do (#4087), on an icon without a bar of its own. Co-Authored-By: Claude Sonnet 5.5 * docs: ui-parity row for the Shading button colour (#4412) --------- Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/icons.rs | 13 +++++++++++++ crates/ui-egui/src/ribbon/home.rs | 5 +++-- crates/ui-egui/src/ribbon/table.rs | 5 +++-- crates/ui-egui/src/ribbon/tests.rs | 26 ++++++++++++++++++++++++++ docs/ui-parity.md | 3 ++- 5 files changed, 47 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 6331cac480..1360ddd609 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -263,6 +263,12 @@ fn paint_clipboard_to_insert(pen: &Pen, name: &str) -> bool { pen.fcircle(16.5, 13.5, 1.6, a); pen.frect(2.0, 16.5, 18.0, 19.0, yellow); } + // The Shading buttons draw the last colour as their own bar, so this has no bar of its own. + "shadingNoBar" => { + pen.fill(&[(4.0, 9.0), (10.0, 3.0), (16.0, 9.0), (10.0, 15.0)], Color32::TRANSPARENT); + pen.closed(&[(4.0, 9.0), (10.0, 3.0), (16.0, 9.0), (10.0, 15.0)], c); + pen.fcircle(16.5, 13.5, 1.6, a); + } "borders" => { for y in [3.0, 10.0, 17.0] { for x in [3.0, 6.5, 10.0, 13.5, 17.0] { @@ -1694,4 +1700,11 @@ mod tests { assert!(has_default_highlight_bar("highlight")); assert!(!has_default_highlight_bar("highlightNoColor")); } + + /// #4412: the Shading buttons paint the last colour as their bar, so their icon has none of its own. + #[test] + fn the_shading_button_icon_has_no_bar_of_its_own() { + assert!(has_default_highlight_bar("shading")); + assert!(!has_default_highlight_bar("shadingNoBar")); + } } diff --git a/crates/ui-egui/src/ribbon/home.rs b/crates/ui-egui/src/ribbon/home.rs index 051e5e9989..1eb15a6772 100644 --- a/crates/ui-egui/src/ribbon/home.rs +++ b/crates/ui-egui/src/ribbon/home.rs @@ -286,13 +286,14 @@ pub(super) fn home(app: &mut WordApp, ui: &mut Ui) { mi(ui, app, "Line Spacing Options…", "para.dialog", json!({})); }, ); + let sw = wordcraft_doc::Rgb::parse(&app.canvas.last_shading).map(crate::theme::c32); split( ui, app, - "shading", + "shadingNoBar", "Shading", crate::widgets::ButtonAction::new("para.shading", json!({"color": app.canvas.last_shading.clone()}), false), - None, + sw, |ui, app| { mi(ui, app, "No Color", "para.shading", json!({"color": null})); let theme = app.session.doc.settings.theme_colors.clone(); diff --git a/crates/ui-egui/src/ribbon/table.rs b/crates/ui-egui/src/ribbon/table.rs index 237e8660d7..d902460070 100644 --- a/crates/ui-egui/src/ribbon/table.rs +++ b/crates/ui-egui/src/ribbon/table.rs @@ -78,13 +78,14 @@ pub(super) fn table_design(app: &mut WordApp, ui: &mut Ui) { } }, ); + let sw = wordcraft_doc::Rgb::parse(&app.canvas.last_shading).map(crate::theme::c32); split( ui, app, - "shading", + "shadingNoBar", "Shading", crate::widgets::ButtonAction::new("table.shading", json!({"color": app.canvas.last_shading.clone()}), false), - None, + sw, |ui, app| { mi(ui, app, "No Color", "table.shading", json!({"color": null})); let theme = app.session.doc.settings.theme_colors.clone(); diff --git a/crates/ui-egui/src/ribbon/tests.rs b/crates/ui-egui/src/ribbon/tests.rs index edbe0d3b2b..063d7fd938 100644 --- a/crates/ui-egui/src/ribbon/tests.rs +++ b/crates/ui-egui/src/ribbon/tests.rs @@ -2,6 +2,32 @@ use super::arrange::group_menu_command; use super::*; use wordcraft_engine::Session; +/// #4412: the Shading button's bar is the colour a click applies (the last one picked), as +/// the Font Color and Text Highlight Color buttons' bars are, not always the icon's yellow. +#[test] +fn the_shading_button_shows_the_last_picked_colour() { + let mut a = WordApp::new(Session::new(wordcraft_doc::Document::from_text("Text")), crate::Services::default()); + a.run("view.commentsPane", json!({"value": false})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + let ribbon_bars = |h: &egui_kittest::Harness<'_, WordApp>, hex: &str| { + let want = crate::theme::c32(wordcraft_doc::Rgb::parse(hex).unwrap()); + h.output().shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.fill == want && r.rect.height() < 4.0 && r.rect.width() > 10.0)) + }; + h.run_steps(6); + assert!(ribbon_bars(&h, "FFF2CC"), "the default colour"); + h.state_mut().canvas.last_shading = "00B0F0".into(); + h.run_steps(3); + assert!(ribbon_bars(&h, "00B0F0"), "the picked colour"); + assert!(!ribbon_bars(&h, "FFF2CC"), "and not the old one"); +} + /// Two clicks arm and disarm the painter; the double-click handling then leaves it sticky (#2785). #[test] fn double_click_on_format_painter_makes_it_sticky() { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 66dc806f86..58d70eebe5 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (a blank User Name in File › Options stays blank after a restart, #4368); previously trivial (window size falls back to the screen rectangle when the reported inner size is invalid; equation symbol tooltips no longer repeat a missing glyph's name); previously trivial (window titles and suggested file names omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; 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 Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (the Shading buttons show the last picked colour, #4412); previously trivial (a blank User Name in File › Options stays blank after a restart, #4368); previously trivial (window size falls back to the screen rectangle when the reported inner size is invalid; equation symbol tooltips no longer repeat a missing glyph's name); previously trivial (window titles and suggested file names omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; 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 Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 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). @@ -84,6 +84,7 @@ Labels → New Document with “One label per recipient” includes all filtered | Date | Change | Summary | |---|---|---| +| 2026-10-29 | trivial | Home › Shading and Table Design › Shading draw the last picked colour as their bar, as Word does, with a code-drawn icon that has no bar of its own (#4412) | | 2026-10-29 | trivial | File › Options › User Name left blank stays blank after a restart (it came back as the default name); a name of only spaces still keeps the default (#4368) | | 2026-10-29 | trivial | Window sizing uses the screen rectangle when the inner rectangle is missing, empty or not finite; an Equation symbol tooltip shows a missing glyph's linear name or code point once instead of twice | | 2026-10-29 | trivial | Displayed document titles and suggested file names omit NUL characters (X11 panicked on them) and use the localized untitled name when nothing remains (#3634) | From 4fcc409c8b4fe5117e67d9c7ef087e896ad3dd0d Mon Sep 17 00:00:00 2001 From: naxvog Date: Fri, 30 Oct 2026 03:37:44 +0100 Subject: [PATCH 18/73] AutoCorrect Options opens with no document open (#4585) (#4626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File › Options › Proofing › AutoCorrect Options did nothing on the Start screen: tools.autocorrect wasn't in works_without_document, so the click was dropped. Its options are user preferences (session.prefs), like the other File › Options settings, so it now works without a document. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +++- crates/ui-egui/src/app/commands.rs | 4 +++- crates/ui-egui/src/tests.rs | 4 ++++ docs/ui-parity.md | 5 +++-- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a8cf9d7ae6..00716565ec 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-29 · **Change:** trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; 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: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; 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: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: 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: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #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 fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; 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: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; 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: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: 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: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #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 fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously 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. @@ -140,6 +140,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-10-27: File › Options › Proofing › AutoCorrect Options opens with no document open (#4585). On the Start screen, or after closing the last document, the button did nothing: AutoCorrect wasn't among the commands allowed without a document. Its options are user preferences, so changes made there are kept for the next document. - 2026-10-29: Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Labels → New Document with one label per recipient includes every selected address, with unused labels blank on the final sheet. Fixed-text and single-label output remain one sheet. Finish & Merge template expansion is separate. - 2026-10-22: View › Window › New Window opens a second window on the document (desktop). The windows are titled `Name:1` and `Name:2` and each keeps its own caret, scroll position, zoom and view; edits and Undo are the document's and show in both. Closing one of them asks nothing; the last one asks about unsaved changes, and quitting asks once per document. - 2026-10-22: One native window per open document on the desktop. File › New and Open (from the ribbon, File, shortcuts, the file manager, the command line or the control channel) open the document in a window of its own, focused and placed a step below and right of the last one; opening a file replaces a blank document nobody has used in its own window, and a file that is already open brings its window to the front. Each window keeps its own ribbon tab, File page, dialogs, panes, scroll position and composition. View › Window › Switch Windows lists the windows and brings the chosen one forward. File › Close and a window's close button close that window, asking about unsaved changes first; the last window stays open with no document. Quitting asks about each document with unsaved changes in its own window, and Cancel keeps everything open. The control channel's input and screenshots go to the window focused last. The web app keeps one window. @@ -1019,3 +1020,4 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-23 | trivial | Progress log: justified lines ending in a manual line break reach the margin (#3924) | | 2026-10-23 | trivial | Progress log: "NaN" in the Font dialog's Size box no longer closes the app (#3663) | | 2026-10-23 | trivial | Progress log: a table cell span from a `.json` file no longer overflows (#3637) | +| 2026-10-27 | trivial | Progress log: AutoCorrect Options with no document open (#4585) | diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 79b795ddf5..a188c5df57 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -992,7 +992,8 @@ fn input_dialog_other(id: &str, params: &Value) -> Option<&'static str> { /// Commands that work while no document is open (#1129): opening or creating one, closing the /// window, Help, adding fonts (a font file dropped on the empty window), and the settings (File › -/// Options, the view and interface preferences it sets, Customize Ribbon and Keyboard). +/// Options, the view and interface preferences it sets, AutoCorrect Options, Customize Ribbon and +/// Keyboard). /// Everything else needs a document, `ui.*` included: only the interface commands listed here /// (tabs, Backstage, dialogs, appearance, opening files) work without one. pub(crate) fn works_without_document(id: &str) -> bool { @@ -1010,6 +1011,7 @@ pub(crate) fn works_without_document(id: &str) -> bool { | "font.remove" | "readAloud.speed" | "readAloud.skipCitations" + | "tools.autocorrect" | "tools.customizeKeyboard" | "tools.customizeRibbon" | "view.darkMode" diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index a98d667e7d..2e6c8108a5 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -661,6 +661,10 @@ fn start_screen_shows_file_home_and_esc_leaves_no_document() { let refused = a.run("ui.zotero.status", json!({})).unwrap_err(); assert!(refused.contains("No document is open"), "refused before it runs: {refused}"); assert!(works_without_document("font.add"), "a font file dropped on the empty window is added"); + // #4585: File › Options › Proofing › AutoCorrect Options edits a user setting, not the document. + a.run("tools.autocorrect", json!({})).unwrap(); + assert_eq!(a.dialog.as_ref().map(|d| d.name()), Some("autoCorrect")); + a.dialog = None; // Neither keys nor commands reach the hidden placeholder. assert!(a.run("text.insert", json!({"text": "Hello"})).is_err()); assert!(a.execute("format.bold", json!({})).is_err()); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 58d70eebe5..18af735ad4 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (the Shading buttons show the last picked colour, #4412); previously trivial (a blank User Name in File › Options stays blank after a restart, #4368); previously trivial (window size falls back to the screen rectangle when the reported inner size is invalid; equation symbol tooltips no longer repeat a missing glyph's name); previously trivial (window titles and suggested file names omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; 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 Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (File › Options › Proofing › AutoCorrect Options opens with no document open, #4585); previously trivial (the Shading buttons show the last picked colour, #4412); previously trivial (a blank User Name in File › Options stays blank after a restart, #4368); previously trivial (window size falls back to the screen rectangle when the reported inner size is invalid; equation symbol tooltips no longer repeat a missing glyph's name); previously trivial (window titles and suggested file names omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; 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 Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 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). @@ -35,7 +35,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings wrap onto further lines within the pane instead of expanding it, #759, #3015), Styles, Comments (balloons and cards read in both themes, #2045), Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | | Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339; Update Field on a table of contents entry updates the table, #1197, and after a save and reopen, #2182); right-click selects a picture, shape or text box and offers Insert Caption… (#1230) and Wrap Text (styles, Move with Text / Fix Position on Page, #513); tables, the items following the selection as in Word (Insert, Delete › Cells, Columns, Rows or Table whatever is selected (#1198), Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…; no Border Styles yet) | rich per context | 65% | 2–4 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings wrap onto further lines within the pane instead of expanding it, #759, #3015), Styles, Comments (times in the user's own time zone, in the pane and the balloons, #2322, #2337; typed comment and reply text stays with its document, is written before Save, Export, Print, Close, New or Open, Escape discards it, and the fields are read-only where comments can't change, #1348), Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | -| Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; no document is open until one is picked or opened, so Esc or Back shows an empty window with the ribbon greyed out but for Help, #1129), with File › Options › General › Start-up to turn it off (#766); Info › Inspect Document finds and removes hidden text by its effective formatting (direct, character or paragraph style) in every story: body, headers, footers, notes, comments and text boxes, and lists what it couldn't reach (#1344); File › New, Open, the mailings and personal templates open their document beside the open ones, so they ask nothing; File › Close (Mod+W, Mod+F4) closes the shown document and keeps the window, showing the next open document, or no page after the last one, as Word does; the window's close button asks about each document with unsaved changes in turn, showing it, and Cancel stops the quit (#1712); each side-menu item has an icon, drawn in code (#1714); Info › Properties › Advanced Properties… opens the Custom Document Properties dialog (#2982); New lists personal templates (`.dotx`/`.dotm`) from the user templates folder under Personal, a subfolder per category, and its search covers them; a click makes an untitled document attached to the template; Save As › Save as Word Template starts in that folder; the folder is set in Options › Advanced › File locations (#2852; no thumbnails yet, no workgroup templates) | | 68% | 4–6 | +| Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; no document is open until one is picked or opened, so Esc or Back shows an empty window with the ribbon greyed out but for Help, #1129; File › Options works there, AutoCorrect Options included, #4585), with File › Options › General › Start-up to turn it off (#766); Info › Inspect Document finds and removes hidden text by its effective formatting (direct, character or paragraph style) in every story: body, headers, footers, notes, comments and text boxes, and lists what it couldn't reach (#1344); File › New, Open, the mailings and personal templates open their document beside the open ones, so they ask nothing; File › Close (Mod+W, Mod+F4) closes the shown document and keeps the window, showing the next open document, or no page after the last one, as Word does; the window's close button asks about each document with unsaved changes in turn, showing it, and Cancel stops the quit (#1712); each side-menu item has an icon, drawn in code (#1714); Info › Properties › Advanced Properties… opens the Custom Document Properties dialog (#2982); New lists personal templates (`.dotx`/`.dotm`) from the user templates folder under Personal, a subfolder per category, and its search covers them; a click makes an untitled document attached to the template; Save As › Save as Word Template starts in that folder; the folder is set in Options › Advanced › File locations (#2852; no thumbnails yet, no workgroup templates) | | 68% | 4–6 | | Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tab stops: corner selector (Left/Center/Right/Decimal/Bar), click to set, drag to move, drag off to clear, per-kind markers, Alt skips the snap, one undo step per gesture, double-click opens the Tabs dialog (#320); table column and row markers drag their borders (one undo) and in a cell the indents are measured from it (#700); the numbers thin out as the page shrinks on screen (every 1, 2, 4… units, never closer than 30 px, the small ticks never closer than 4 px, the text keeps its size), give way to an indent, tab or column marker on them, and the margins get ticks only; both rulers span the caret's page at its own scale in every layout (one column, One Page, Multiple Pages) and follow the caret to another page (#1878) | ✅ | 92% | 1 | | On-canvas objects: select, drag, resize handles, nudge | ✅ tested (#228); a picture or shape behind text can be dragged across the overlapping text (body and header/footer), whether or not it was selected first, while a click without movement still edits that text | ✅ | 75% | — | | On-canvas objects: select, drag, Ctrl+drag to copy, resize handles, nudge | ✅ tested (#228, #1014); an In Line with Text object drags within the text as in Word: a dashed insertion cursor follows the pointer through the text and the drop (or Ctrl+drop copy) lands there, still inline (#2233, #2234) | ✅ | 75% | — | @@ -321,3 +321,4 @@ labels unambiguous. Those extra-number assignments have not been compared with W | 2026-10-20 | trivial | File › Home and New: each template (thumbnail and caption) and each recent document is one button named by its caption or file name (with the path when two recent file names are the same), so a screen reader announces it instead of unnamed text (#3176) | | 2026-10-23 | trivial | Font dialog: "NaN" or "inf" in the Size box previews the text at 12 pt instead of stopping the app while drawing the preview (#3663) | | 2026-10-25 | trivial | Customize Ribbon, Quick Access Toolbar and Customize Keyboard pickers: filter-as-you-type command search across all commands (label or id, case-insensitive); clearing restores the category scope (#1845) | +| 2026-10-27 | trivial | File › Options › Proofing › AutoCorrect Options opens its dialog with no document open (Start screen); it did nothing there (#4585) | From 685b42010470fb7c4f5dc892df946a56dbf8318e Mon Sep 17 00:00:00 2001 From: Aritz Velasco <118175629+iAmRarted@users.noreply.github.com> Date: Fri, 30 Oct 2026 03:39:17 +0100 Subject: [PATCH 19/73] Ctrl+V pastes a clipboard picture again with egui 0.37 (#4787) * Ctrl+V pastes a clipboard picture again with egui 0.37 Since the egui 0.37 migration, egui-winit keeps both the Mod+V press and its release, so the canvas never sees the V release it pasted a clipboard picture on. Outside Wayland, egui-winit instead sends Event::PasteImage when the clipboard holds a picture and no text, which the canvas ignored. Ctrl+V pasted nothing while the ribbon and context-menu Paste still worked. Answer PasteImage with edit.paste, which reads the picture through the host like the ribbon's Paste. The web build sends no PasteImage, so nothing pastes twice there. Part of #4782 Co-Authored-By: Claude Opus 5.5 * Mark the clipboard picture as pasted on PasteImage so a later V release cannot paste twice --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/keys/canvas.rs | 12 ++++++++++++ crates/ui-egui/src/keys/tests.rs | 13 +++++++++++++ 2 files changed, 25 insertions(+) diff --git a/crates/ui-egui/src/keys/canvas.rs b/crates/ui-egui/src/keys/canvas.rs index 74d4ed3cd3..f5890f382c 100644 --- a/crates/ui-egui/src/keys/canvas.rs +++ b/crates/ui-egui/src/keys/canvas.rs @@ -243,6 +243,18 @@ fn prefilter(app: &mut WordApp, ctx: &egui::Context, e: &egui::Event, alt_codes: return true; } } + // egui-winit 0.37 (outside Wayland) answers Mod+V on a clipboard with no text but a + // picture with this event and sends no V release, so the release below never comes. + // Paste through `edit.paste`, which reads the picture through the host like the ribbon's + // Paste; the event's own pixels are left alone. + if let egui::Event::PasteImage(_) = &e { + // Pasted here: a V release, if a platform still sends one, mustn't paste again. + app.canvas.pasted = true; + if app.session.math.is_none() && app.services.clipboard_picture.is_some() { + let _ = app.run("edit.paste", json!({})); + } + return true; + } false } diff --git a/crates/ui-egui/src/keys/tests.rs b/crates/ui-egui/src/keys/tests.rs index 931e9b3af1..528f3bf83c 100644 --- a/crates/ui-egui/src/keys/tests.rs +++ b/crates/ui-egui/src/keys/tests.rs @@ -216,6 +216,19 @@ fn mod_v_within_one_frame_pastes_the_clipboard_picture() { assert_eq!(pictures(&mut a), 1); } +/// egui-winit 0.37 keeps both the Mod+V press and its release, and sends `PasteImage` when the +/// clipboard holds a picture and no text (Windows, macOS, X11): that pastes the picture, once. +#[test] +fn paste_image_event_pastes_the_clipboard_picture() { + let ctx = egui::Context::default(); + let mut a = picture_app(); + let ctrl = Modifiers { ctrl: true, command: true, ..Default::default() }; + let image = egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([4, 4], egui::Color32::WHITE))); + mods_frame(&ctx, &mut a, ctrl, vec![key_event(Key::ControlLeft, true, ctrl), image]); + mods_frame(&ctx, &mut a, Modifiers::NONE, vec![key_event(Key::ControlLeft, false, Modifiers::NONE)]); + assert_eq!(pictures(&mut a), 1); +} + /// A V release keeps its own modifiers unless Mod went up first and no V press arrived. #[test] fn paste_release_modifiers_fall_back_to_those_held_with_mod() { From 1be606de0341e1482b9b5a1be4a1793b217b0573 Mon Sep 17 00:00:00 2001 From: dhruvLearner8 <83899022+dhruvLearner8@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:41:10 -0600 Subject: [PATCH 20/73] =?UTF-8?q?Insert=20=E2=80=BA=20Shapes=20shows=20the?= =?UTF-8?q?=20basic=20shapes=20as=20an=20icon=20grid=20(#4349)=20(#4739)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 ++- crates/ui-egui/src/ribbon/insert.rs | 56 ++++++++++++++++++++++------- crates/ui-egui/src/ribbon/tests.rs | 29 +++++++++++++++ docs/ui-parity.md | 3 +- 4 files changed, 77 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 00716565ec..c7f1f4f594 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-29 · **Change:** trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; 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: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; 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: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: 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: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #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 fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; 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: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; 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: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: 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: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #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 fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously 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. @@ -203,6 +203,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 2026-10-29 | Insert › Shapes shows the basic shapes as a grid of code-drawn icon tiles, each with an accessible name and tooltip, instead of a text list (#4349) | | 2026-10-29 | Window sizing falls back to the screen rectangle when the reported inner size is invalid, and an Equation symbol tooltip no longer repeats a missing glyph's name or code point | | 2026-10-29 | A NUL in a document title no longer reaches the native window title, which panicked on X11 (#3634): displayed titles and suggested file names drop NUL characters and fall back to the untitled name when empty; the stored title is unchanged | | 2026-10-28 | The Traditional Chinese interface draws hanzi to the Taiwan standard: on the desktop an installed Traditional Chinese font (PingFang TC on macOS, Microsoft JhengHei on Windows, Noto Sans CJK TC on Linux) comes before the embedded Noto Sans CJK SC, which drew 說, 這, 為, 戶 in mainland shapes; without one, and on the web, nothing changes. | @@ -652,6 +653,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-29 | trivial | Progress log: Insert › Shapes icon grid (#4349) | | 2026-10-29 | trivial | Progress log: window fallback geometry and equation symbol tooltips | | 2026-10-29 | trivial | Progress log: window titles omit NUL characters (#3634) | | 2026-10-29 | trivial | Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Filtered/sorted order, product geometry and single-label mode retained; readiness estimates unchanged. | diff --git a/crates/ui-egui/src/ribbon/insert.rs b/crates/ui-egui/src/ribbon/insert.rs index 36a5a1dd8f..d4e62f2be3 100644 --- a/crates/ui-egui/src/ribbon/insert.rs +++ b/crates/ui-egui/src/ribbon/insert.rs @@ -2,6 +2,48 @@ use super::common::{mi, mi_icon, stack}; use super::language::mi_check; use super::{Ui, WordApp, big, group, json, menu_button, small}; +/// The basic shapes of Insert › Shapes: tooltip and `insert.shape` kind. +pub(super) const SHAPE_TILES: [(&str, &str); 9] = [ + ("Rectangle", "rectangle"), + ("Rounded Rectangle", "roundedRectangle"), + ("Oval", "ellipse"), + ("Triangle", "triangle"), + ("Diamond", "diamond"), + ("Line", "line"), + ("Arrow", "arrow"), + ("Star", "star"), + ("Heart", "heart"), +]; + +/// Shapes per row of the Insert › Shapes grid. +const SHAPE_TILES_PER_ROW: usize = 5; + +/// The basic shapes as a grid of drawn tiles (#4349), each named by its tooltip; a click inserts it. +pub(super) fn shape_gallery(ui: &mut Ui, app: &mut WordApp) { + use crate::{icons, theme::Tokens}; + use egui::{Rect, Sense, vec2}; + let t = Tokens::get(ui.ctx()); + let enabled = crate::widgets::enabled(app, "insert.shape"); + ui.add_enabled_ui(enabled, |ui| { + ui.spacing_mut().item_spacing = vec2(2.0, 2.0); + for row in SHAPE_TILES.chunks(SHAPE_TILES_PER_ROW) { + ui.horizontal(|ui| { + for (label, kind) in row { + let (r, resp) = ui.allocate_exact_size(vec2(34.0, 34.0), Sense::click()); + let name = crate::tl!(label); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), name)); + crate::widgets::bg(ui, r, &resp, false, &t); + icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(24.0, 24.0)), &format!("shape.{kind}"), t.icon, t.accent); + if resp.on_hover_text(name).clicked() { + crate::widgets::run_from_ribbon(app, "insert.shape", json!({"kind": kind})); + ui.close(); + } + } + }); + } + }); +} + pub(super) fn insert(app: &mut WordApp, ui: &mut Ui) { group(ui, "Pages", None, app, |ui, app| { stack(ui, |ui| { @@ -35,19 +77,7 @@ pub(super) fn insert(app: &mut WordApp, ui: &mut Ui) { group(ui, "Illustrations", None, app, |ui, app| { big(ui, app, "picture", "Pictures", "insert.picture", json!({}), false); menu_button(ui, app, crate::widgets::MenuButtonOptions::new("shapes", Some("Shapes"), "Shapes", true, "insert.shape"), |ui, app| { - for (l, k) in [ - ("Rectangle", "rectangle"), - ("Rounded Rectangle", "roundedRectangle"), - ("Oval", "ellipse"), - ("Triangle", "triangle"), - ("Diamond", "diamond"), - ("Line", "line"), - ("Arrow", "arrow"), - ("Star", "star"), - ("Heart", "heart"), - ] { - mi_icon(ui, app, &format!("shape.{k}"), l, "insert.shape", json!({"kind": k})); - } + shape_gallery(ui, app); ui.separator(); mi_icon(ui, app, "canvas", "New Drawing Canvas", "insert.canvas", json!({})); // Connectors: glue their ends to shapes by dragging them (shape.connect). diff --git a/crates/ui-egui/src/ribbon/tests.rs b/crates/ui-egui/src/ribbon/tests.rs index 063d7fd938..1f1aeab9db 100644 --- a/crates/ui-egui/src/ribbon/tests.rs +++ b/crates/ui-egui/src/ribbon/tests.rs @@ -2,6 +2,35 @@ use super::arrange::group_menu_command; use super::*; use wordcraft_engine::Session; +/// #4349: Insert › Shapes offers the basic shapes as a grid of drawn tiles, each named for +/// screen readers and by its tooltip; a click inserts that shape. +#[test] +fn the_shapes_menu_offers_its_basic_shapes_as_a_grid_of_tiles() { + use egui_kittest::kittest::Queryable; + let app = WordApp::new(Session::new(wordcraft_doc::Document::from_text("Lanterns")), crate::Services::default()); + let mut h = egui_kittest::Harness::builder() + .with_size(vec2(400.0, 300.0)) + .build_ui_state(|ui, app: &mut WordApp| super::insert::shape_gallery(ui, app), app); + h.run_steps(2); + let rects: Vec = super::insert::SHAPE_TILES.iter().map(|(label, _)| h.get_by_label(label).rect()).collect(); + // A grid, not a list: tiles sit side by side, wrapping into rows. + assert_eq!(rects[0].top(), rects[1].top(), "the first two shapes share a row"); + assert!(rects[0].right() <= rects[1].left(), "side by side"); + let rows = rects.iter().map(|r| r.top().round() as i32).collect::>().len(); + assert!(rows >= 2 && rows < super::insert::SHAPE_TILES.len(), "{rows} rows for {} shapes", super::insert::SHAPE_TILES.len()); + assert!(rects.iter().all(|r| r.width() >= 24.0 && r.height() >= 24.0), "tiles are big enough to read and hit"); + // Every tile inserts its own shape. + for (label, kind) in super::insert::SHAPE_TILES { + let before = h.state().session.undo_depth(); + h.get_by_label(label).click(); + h.run_steps(2); + assert_eq!(h.state().session.undo_depth(), before + 1, "{label} inserts a shape"); + let doc = serde_json::to_string(&h.state().session.doc).unwrap(); + assert!(doc.contains(&format!("\"{kind}\"")), "{label} inserts a {kind}"); + assert!(h.state_mut().session.undo()); + } +} + /// #4412: the Shading button's bar is the colour a click applies (the last one picked), as /// the Font Color and Text Highlight Color buttons' bars are, not always the icon's yellow. #[test] diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 18af735ad4..9b85d83221 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (File › Options › Proofing › AutoCorrect Options opens with no document open, #4585); previously trivial (the Shading buttons show the last picked colour, #4412); previously trivial (a blank User Name in File › Options stays blank after a restart, #4368); previously trivial (window size falls back to the screen rectangle when the reported inner size is invalid; equation symbol tooltips no longer repeat a missing glyph's name); previously trivial (window titles and suggested file names omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; 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 Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (Insert › Shapes shows the basic shapes as a grid of drawn icon tiles, #4349); previously trivial (File › Options › Proofing › AutoCorrect Options opens with no document open, #4585); previously trivial (the Shading buttons show the last picked colour, #4412); previously trivial (a blank User Name in File › Options stays blank after a restart, #4368); previously trivial (window size falls back to the screen rectangle when the reported inner size is invalid; equation symbol tooltips no longer repeat a missing glyph's name); previously trivial (window titles and suggested file names omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; 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 Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 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). @@ -84,6 +84,7 @@ Labels → New Document with “One label per recipient” includes all filtered | Date | Change | Summary | |---|---|---| +| 2026-10-29 | trivial | Insert › Shapes lists the basic shapes as a grid of code-drawn icon tiles with accessible names and tooltips, as Word's gallery does, instead of a text list (#4349) | | 2026-10-29 | trivial | Home › Shading and Table Design › Shading draw the last picked colour as their bar, as Word does, with a code-drawn icon that has no bar of its own (#4412) | | 2026-10-29 | trivial | File › Options › User Name left blank stays blank after a restart (it came back as the default name); a name of only spaces still keeps the default (#4368) | | 2026-10-29 | trivial | Window sizing uses the screen rectangle when the inner rectangle is missing, empty or not finite; an Equation symbol tooltip shows a missing glyph's linear name or code point once instead of twice | From 250a9c51645be0c57cac95ae51d843a02205a63f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 29 Oct 2026 23:33:41 -0400 Subject: [PATCH 21/73] ci: Zotero peer check judges an unaccepted connection by its listening socket The Linux same-user check read the uid of the listener's side of the connection from /proc/net/tcp. Until the listener accepts it, that row has no socket file (inode 0) and reads uid 0 whoever listens, so a Zotero that hadn't accepted yet was refused as "another user (uid 0)". The ui-egui Zotero tests failed about one run in three on a busy machine because of it. Rows without a socket file (unaccepted connections, TIME_WAIT) are now skipped, and the listening row on the listener's address stands in for the connection's own; two users listening there is refused. Co-Authored-By: Claude Opus 5.5 --- crates/zotero/src/peer.rs | 64 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 3 deletions(-) diff --git a/crates/zotero/src/peer.rs b/crates/zotero/src/peer.rs index 09abd8de43..64b41300e9 100644 --- a/crates/zotero/src/peer.rs +++ b/crates/zotero/src/peer.rs @@ -75,11 +75,15 @@ fn scan_tables( Ok(rows) } -/// The uids found for the two rows of one connection. +/// The uids found for the two rows of one connection, and of the socket listening on the +/// listener's address (which speaks for a connection it hasn't accepted yet). #[derive(Default)] struct Rows { ours: Option, theirs: Option, + listener: Option, + /// Listening rows on that address with different uids. + listeners_differ: bool, } impl Rows { @@ -109,11 +113,27 @@ fn scan_table(mut table: impl std::io::BufRead, local: SocketAddr, peer: SocketA } let line = String::from_utf8_lossy(&line); let mut f = line.split_whitespace(); - let (Some(l), Some(r), Some(uid)) = (f.nth(1).and_then(parse_addr), f.next().and_then(parse_addr), f.nth(4)) else { + let (Some(l), Some(r), Some(state), Some(uid), Some(inode)) = + (f.nth(1).and_then(parse_addr), f.next().and_then(parse_addr), f.next(), f.nth(3), f.nth(1)) + else { continue; }; let Ok(uid) = uid.parse::() else { continue }; let (l, r) = (key(l), key(r)); + // The listening socket (`0A`) on the listener's port, at its address or every address. + if state == "0A" { + if l.1 == peer.1 && (l.0 == peer.0 || l.0.is_unspecified()) { + rows.listeners_differ |= rows.listener.is_some_and(|u| u != uid); + rows.listener = Some(uid); + } + continue; + } + // A row with no socket file (inode 0) belongs to no program: a connection still waiting + // to be accepted, or one that ended (TIME_WAIT). Its uid column reads 0 whoever + // listens, so it says nothing about the owner; the listening row speaks for it. + if inode == "0" { + continue; + } if l == local && r == peer { rows.ours = Some(uid); } else if l == peer && r == local { @@ -156,7 +176,10 @@ fn read_line_capped(r: &mut impl std::io::BufRead, out: &mut Vec) -> std::io /// `me` (this process's). When the listener isn't listed in a table that was read in full (WSL2, /// a proxy) or our uid is unknown, its owner is unknown and the loopback rule decides. fn decide(rows: &Rows, peer: SocketAddr, me: Option) -> Result<(), ZoteroError> { - match (rows.ours.or(me), rows.theirs) { + if rows.listeners_differ && rows.theirs.is_none() { + return Err(ZoteroError::Refused(format!("more than one user listens on port {}, so the program there may not be your Zotero", peer.port()))); + } + match (rows.ours.or(me), rows.theirs.or(rows.listener)) { (Some(a), Some(b)) if a == b => Ok(()), (Some(_), Some(b)) => { Err(ZoteroError::Refused(format!("the program on port {} runs as another user (uid {b}), so it isn't your Zotero", peer.port()))) @@ -249,6 +272,41 @@ mod tests { assert_eq!(same_owner(&junk, local, peer, None), Ok(())); } + /// A connection the listener hasn't accepted yet (or one that ended, TIME_WAIT) has no + /// socket file: its row reads uid 0 whoever listens. The listening row speaks for it, so a + /// busy Zotero isn't taken for another user's program, and another user's still is. + #[test] + fn an_unaccepted_connection_is_judged_by_its_listening_socket() { + let (local, peer) = addrs(); + let unowned = + |l: SocketAddr, r: SocketAddr, state: &str| row(l, r, 0).replacen(" 01 ", &format!(" {state} "), 1).replacen(" 12345 ", " 0 ", 1); + let listening = |uid: u32, any: bool| { + let at: SocketAddr = if any { SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), peer.port()) } else { peer }; + row(at, "0.0.0.0:0".parse().unwrap(), uid).replacen(" 01 ", " 0A ", 1) + }; + for state in ["01", "06"] { + let mine = format!("{HEADER}{}{}{}", listening(1000, false), row(local, peer, 1000), unowned(peer, local, state)); + assert_eq!(same_owner(&mine, local, peer, None), Ok(()), "{state}"); + let mine_any = format!("{HEADER}{}{}{}", row(local, peer, 1000), unowned(peer, local, state), listening(1000, true)); + assert_eq!(same_owner(&mine_any, local, peer, None), Ok(()), "{state}"); + let other = format!("{HEADER}{}{}{}", row(local, peer, 1000), unowned(peer, local, state), listening(1001, false)); + assert!(matches!(same_owner(&other, local, peer, None), Err(ZoteroError::Refused(e)) if e.contains("another user")), "{state}"); + // No listening row: unknown, so the loopback rule decides. + let unlisted = format!("{HEADER}{}{}", row(local, peer, 1000), unowned(peer, local, state)); + assert_eq!(same_owner(&unlisted, local, peer, None), Ok(()), "{state}"); + } + // An accepted connection's own row wins over the listening row. + let accepted = format!("{HEADER}{}{}{}", listening(1000, false), row(local, peer, 1000), row(peer, local, 1001)); + assert!(matches!(same_owner(&accepted, local, peer, None), Err(ZoteroError::Refused(e)) if e.contains("another user"))); + // A listening row on another port is not the listener. + let elsewhere = row("127.0.0.1:9".parse().unwrap(), "0.0.0.0:0".parse().unwrap(), 1001).replacen(" 01 ", " 0A ", 1); + let table = format!("{HEADER}{elsewhere}{}{}", row(local, peer, 1000), unowned(peer, local, "01")); + assert_eq!(same_owner(&table, local, peer, None), Ok(())); + // Two users listening on the address: refused. + let both = format!("{HEADER}{}{}{}{}", listening(1000, false), listening(1001, true), row(local, peer, 1000), unowned(peer, local, "01")); + assert!(matches!(same_owner(&both, local, peer, None), Err(ZoteroError::Refused(_)))); + } + /// WSL2 mirrored networking (Zotero on Windows) or a local proxy: the listener /// isn't in our table, so the loopback rule decides, as on other systems. #[test] From c99552c958a1f14917c6a4660b8bec263374b204 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:39:32 +0100 Subject: [PATCH 22/73] Carry rounded ISO duration seconds into minutes and hours (#4555) Found while testing crates/formats/src/sheet/date.rs:136-152 (iso_time, PT duration branch) Co-authored-by: Claude Haiku 5.5 Co-authored-by: Brandon Thomas --- crates/formats/src/sheet/date.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/formats/src/sheet/date.rs b/crates/formats/src/sheet/date.rs index 92bc0e15d8..9e7015a636 100644 --- a/crates/formats/src/sheet/date.rs +++ b/crates/formats/src/sheet/date.rs @@ -147,7 +147,11 @@ pub(super) fn iso_time(v: &str) -> Option { _ => return None, } } - let s = s.round() as u64; + let mut s = s.round() as u64; + m = m.saturating_add(s / 60); + s %= 60; + h = h.saturating_add(m / 60); + m %= 60; return Some(if s == 0 { format!("{h:02}:{m:02}") } else { format!("{h:02}:{m:02}:{s:02}") }); } // xs:time: hh:mm[:ss[.sss]][Z|+...] @@ -159,3 +163,16 @@ pub(super) fn iso_time(v: &str) -> Option { } None } + +#[cfg(test)] +mod tests { + use super::iso_time; + + #[test] + fn duration_seconds_round_up_carries() { + assert_eq!(iso_time("PT1H59M59.6S").as_deref(), Some("02:00")); + assert_eq!(iso_time("PT59M59.5S").as_deref(), Some("01:00")); + assert_eq!(iso_time("PT0H0M59.4S").as_deref(), Some("00:00:59")); + assert_eq!(iso_time("PT1H2M3S").as_deref(), Some("01:02:03")); + } +} From 64ebfda971b28e45533cef1fa3007f14b61bfe13 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:40:01 +0100 Subject: [PATCH 23/73] Reject '+' signs in 8-digit hex colours in parse_color (#4653) Found while testing crates/formats/src/model/color.rs:45-47 (parse_color, 8-digit hex branch) Co-authored-by: Claude Haiku 5.5 Co-authored-by: Brandon Thomas --- crates/formats/src/model/color.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/formats/src/model/color.rs b/crates/formats/src/model/color.rs index 527e2372d3..48a83b5c4f 100644 --- a/crates/formats/src/model/color.rs +++ b/crates/formats/src/model/color.rs @@ -42,7 +42,7 @@ pub fn parse_color(s: &str) -> Option { let d = |i: usize| h.get(i..i + 1).and_then(|x| u8::from_str_radix(x, 16).ok()).map(|v| v * 17); return Some(Rgb(d(0)?, d(1)?, d(2)?)); } - if h.len() == 8 && h.is_ascii() { + if h.len() == 8 && h.bytes().all(|b| b.is_ascii_hexdigit()) { let p = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()); return Some(Rgb(p(0)?, p(2)?, p(4)?)); } @@ -119,3 +119,14 @@ pub fn parse_color(s: &str) -> Option { _ => return None, }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn eight_digit_hex_rejects_signs() { + assert_eq!(parse_color("#+1+2+3+4"), None); + assert_eq!(parse_color("#11223344"), Some(Rgb(0x11, 0x22, 0x33))); + } +} From 6e8a420d28b4a5ae67a7ffc4c572d7a2781fc51d Mon Sep 17 00:00:00 2001 From: jayzed80 <121670721+jayzed80@users.noreply.github.com> Date: Fri, 30 Oct 2026 03:40:21 +0000 Subject: [PATCH 24/73] fix(formats): HTML offsets no longer overflow at the integer limits (#4755) * fix(formats): saturate HTML offsets so extreme values cannot overflow * test(formats): assert the saturated HTML font sizes, not only that the text survives --------- Co-authored-by: Brandon Thomas --- crates/formats/src/html/builder.rs | 4 ++-- crates/formats/src/html/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/formats/src/html/builder.rs b/crates/formats/src/html/builder.rs index b61021fe77..86ffda5aa2 100644 --- a/crates/formats/src/html/builder.rs +++ b/crates/formats/src/html/builder.rs @@ -66,9 +66,9 @@ fn apply_font_attributes(attrs: &[(String, String)], fmt: &mut Fmt) { let Some(size) = attr(attrs, "size") else { return }; let size = size.trim(); let points = if let Some(rest) = size.strip_prefix('+') { - rest.parse::().ok().map(|delta| 3 + delta) + rest.parse::().ok().map(|delta| 3i32.saturating_add(delta)) } else if let Some(rest) = size.strip_prefix('-') { - rest.parse::().ok().map(|delta| 3 - delta) + rest.parse::().ok().map(|delta| 3i32.saturating_sub(delta)) } else { size.parse::().ok() }; diff --git a/crates/formats/src/html/tests.rs b/crates/formats/src/html/tests.rs index 5922caff7d..ed7bfaa0bd 100644 --- a/crates/formats/src/html/tests.rs +++ b/crates/formats/src/html/tests.rs @@ -474,3 +474,23 @@ fn many_formatted_leading_breaks_trim_quickly() { let texts: Vec = f.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p.text()) } else { None }).collect(); assert_eq!(texts, ["x"]); } + +#[test] +fn font_size_at_the_integer_limits_does_not_overflow() { + // `` offsets were added to 3 unchecked, so the extreme values panicked in debug builds. + for size in ["+2147483647", "--2147483648", "-2147483647", "+99999999999"] { + let f = parse(&format!("x")); + assert!(texts(&f).iter().any(|t| t.ends_with(":x")), "size={size}"); + } + // The offsets saturate, then clamp to the 1..=7 scale: 7 is 36 pt, 1 is 7.5 pt. + let size = |html: &str| match &parse(html).blocks[0] { + FBlock::Para(p) => p.inlines.iter().find_map(|i| match i { + Inline::Text(_, fmt) => fmt.size, + _ => None, + }), + _ => None, + }; + assert_eq!(size("x"), Some(36.0)); + assert_eq!(size("x"), Some(36.0)); + assert_eq!(size("x"), Some(7.5)); +} From bc437049fa3703adb5eedfc11ba7737daa8943b2 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:40:50 +0100 Subject: [PATCH 25/73] Fix HTML import leaking text into body outside <head> (#4725) Found while testing crates/formats/src/html/builder.rs:338-344 (Builder::text) Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com> --- crates/formats/src/html/builder.rs | 7 ++++--- crates/formats/src/tests.rs | 9 +++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/formats/src/html/builder.rs b/crates/formats/src/html/builder.rs index 86ffda5aa2..5de71edd67 100644 --- a/crates/formats/src/html/builder.rs +++ b/crates/formats/src/html/builder.rs @@ -336,10 +336,11 @@ impl<'r> Builder<'r> { } pub(super) fn text(&mut self, t: &str) { + if let Some(title) = &mut self.title { + title.push_str(t); + return; + } if self.in_head() { - if let Some(title) = &mut self.title { - title.push_str(t); - } return; } if t.is_empty() { diff --git a/crates/formats/src/tests.rs b/crates/formats/src/tests.rs index bc2442d80a..0715bc9eb6 100644 --- a/crates/formats/src/tests.rs +++ b/crates/formats/src/tests.rs @@ -1002,3 +1002,12 @@ fn dropped_names_and_links_are_logged_once_per_document() { let (_, warned) = crate::test_log::warnings(|| crate::latex::import(tex.as_bytes())); assert_eq!(count(&warned, "links longer"), 1, "{warned:?}"); } + +#[test] +fn html_title_outside_head_is_metadata_not_body() { + for html in ["<title>T

b

", "Tb"] { + let d = crate::html::import(html); + assert_eq!(para_texts(&d), vec!["b".to_string()], "{html}"); + assert_eq!(d.core.title, "T", "{html}"); + } +} From 0debb7afdadd8e7ea51ed7b5c95b4060308ee578 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:41:10 +0100 Subject: [PATCH 26/73] Keep -- and '' apart across text runs in LaTeX export (#4822) Found while testing crates/formats/src/latex/export.rs:402-444 (escape_text, `prev` resets per call) and :230-237 (inlines, runs concatenated) Co-authored-by: Claude Haiku 5.5 Co-authored-by: Brandon Thomas --- crates/formats/src/latex/export.rs | 16 ++++++++++++++-- crates/formats/src/latex/tests.rs | 11 +++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/formats/src/latex/export.rs b/crates/formats/src/latex/export.rs index d75dc2c0fc..00e9f4577f 100644 --- a/crates/formats/src/latex/export.rs +++ b/crates/formats/src/latex/export.rs @@ -233,7 +233,7 @@ impl Writer { match i { Inline::Text(t, f) => { let s = self.span(t, f); - o.push_str(&s); + push_apart(&mut o, &s); } Inline::Image(img) => { let alt = if img.alt.trim().is_empty() { "Picture".to_string() } else { format!("Picture: {}", img.alt.trim()) }; @@ -244,7 +244,7 @@ impl Writer { // LaTeX keeps no ruby (no package): its base text, in the run's formatting. Inline::Ruby { base, fmt, .. } => { let s = self.span(base, fmt); - o.push_str(&s); + push_apart(&mut o, &s); } Inline::Equation { linear, display } => { self.uses.math = true; @@ -398,6 +398,18 @@ fn num(v: f32) -> String { if r.fract() == 0.0 { format!("{r:.0}") } else { format!("{r}") } } +/// Appends a run, writing `{}` between a `-` or `'` ending `o` and the same character starting +/// `s`, so TeX does not join them across runs into a dash or quote. +fn push_apart(o: &mut String, s: &str) { + if let Some(c) = s.chars().next() + && (c == '-' || c == '\'') + && o.ends_with(c) + { + o.push_str("{}"); + } + o.push_str(s); +} + /// Text with LaTeX's special characters escaped. pub fn escape_text(s: &str) -> String { let mut o = String::with_capacity(s.len() + 8); diff --git a/crates/formats/src/latex/tests.rs b/crates/formats/src/latex/tests.rs index c31fccd543..60638d6f84 100644 --- a/crates/formats/src/latex/tests.rs +++ b/crates/formats/src/latex/tests.rs @@ -449,3 +449,14 @@ fn nested_multicolumn_wrappers_are_bounded() { assert_eq!((t.rows[0][0].colspan, t.rows[0][0].rowspan), (2, 2)); assert_eq!(t.rows[0][0].text(), "Both"); } + +#[test] +fn ligature_characters_split_across_runs_stay_apart() { + let d = crate::html::import( + "

a--b ''x

", + ); + let tex = export(&d); + assert!(tex.contains("a-{}-b '{}'x"), "{tex}"); + let back = import(tex.as_bytes()); + assert_eq!(back.plain_text(Default::default()).trim(), "a--b ''x", "{tex}"); +} From 990b57a1e8d9bbce61e4f2515a85d65ba2e1d108 Mon Sep 17 00:00:00 2001 From: Rees <109076338+OPUZZLES@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:42:07 +0100 Subject: [PATCH 27/73] HTML: preserve preformatted lines with CR input newlines (#4810) (#4815) * Formats: preserve HTML input newlines (#4810) * docs: fold the #4810 notes into the existing status lines --------- Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +++- crates/formats/src/html/import.rs | 15 ++++++++++++++- crates/formats/src/html/tests.rs | 20 ++++++++++++++++++++ docs/file-format-parity.md | 5 +++-- docs/gaps.md | 3 ++- docs/roadmap.md | 3 ++- 6 files changed, 44 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c7f1f4f594..51abb85968 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-29 · **Change:** trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; 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: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; 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: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: 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: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #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 fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; 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: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; 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: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: 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: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #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 fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously 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. @@ -142,6 +142,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-27: File › Options › Proofing › AutoCorrect Options opens with no document open (#4585). On the Start screen, or after closing the last document, the button did nothing: AutoCorrect wasn't among the commands allowed without a document. Its options are user preferences, so changes made there are kept for the next document. - 2026-10-29: Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Labels → New Document with one label per recipient includes every selected address, with unused labels blank on the final sheet. Fixed-text and single-label output remain one sheet. Finish & Merge template expansion is separate. +- 2026-10-29: HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged. - 2026-10-22: View › Window › New Window opens a second window on the document (desktop). The windows are titled `Name:1` and `Name:2` and each keeps its own caret, scroll position, zoom and view; edits and Undo are the document's and show in both. Closing one of them asks nothing; the last one asks about unsaved changes, and quitting asks once per document. - 2026-10-22: One native window per open document on the desktop. File › New and Open (from the ribbon, File, shortcuts, the file manager, the command line or the control channel) open the document in a window of its own, focused and placed a step below and right of the last one; opening a file replaces a blank document nobody has used in its own window, and a file that is already open brings its window to the front. Each window keeps its own ribbon tab, File page, dialogs, panes, scroll position and composition. View › Window › Switch Windows lists the windows and brings the chosen one forward. File › Close and a window's close button close that window, asking about unsaved changes first; the last window stays open with no document. Quitting asks about each document with unsaved changes in its own window, and Cancel keeps everything open. The control channel's input and screenshots go to the window focused last. The web app keeps one window. - 2026-10-22: Recovery covers every open document. Each dirty document keeps its own copy, written on the same ten-second cadence whether it is shown or not; closing a document without saving deletes its copy, as saving does, and a clean exit deletes the copies of every document saved or closed since its last copy. File › Recover lists only copies no open document keeps, and Restore opens a copy as another document (or in place of an untouched blank one), so it never replaces work. After a crash with several unsaved documents, the next start lists all of them. @@ -654,6 +655,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| | 2026-10-29 | trivial | Progress log: Insert › Shapes icon grid (#4349) | +| 2026-10-29 | trivial | HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged. | | 2026-10-29 | trivial | Progress log: window fallback geometry and equation symbol tooltips | | 2026-10-29 | trivial | Progress log: window titles omit NUL characters (#3634) | | 2026-10-29 | trivial | Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Filtered/sorted order, product geometry and single-label mode retained; readiness estimates unchanged. | diff --git a/crates/formats/src/html/import.rs b/crates/formats/src/html/import.rs index 2df7e9d1f3..2d6233bafe 100644 --- a/crates/formats/src/html/import.rs +++ b/crates/formats/src/html/import.rs @@ -10,7 +10,20 @@ pub fn parse(s: &str) -> Flow { /// [`parse`], loading pictures that aren't `data:` URIs through `images`. pub fn parse_with(s: &str, images: ImageLoader) -> Flow { - let mut lx = Lexer { s, pos: 0 }; + // HTML normalizes input newlines before tokenization, not after decoding character references. + let normalized = if s.contains('\r') { + let mut out = String::with_capacity(s.len()); + let mut chunks = s.split('\r'); + out.push_str(chunks.next().unwrap_or_default()); + for chunk in chunks { + out.push('\n'); + out.push_str(chunk.strip_prefix('\n').unwrap_or(chunk)); + } + std::borrow::Cow::Owned(out) + } else { + std::borrow::Cow::Borrowed(s) + }; + let mut lx = Lexer { s: &normalized, pos: 0 }; let mut b = Builder::new(images); let mut guard = 0usize; while let Some(t) = lx.next() { diff --git a/crates/formats/src/html/tests.rs b/crates/formats/src/html/tests.rs index ed7bfaa0bd..2b8e919013 100644 --- a/crates/formats/src/html/tests.rs +++ b/crates/formats/src/html/tests.rs @@ -50,6 +50,26 @@ fn borderless_tables_export_without_cell_borders() { } } +#[test] +fn input_newlines_preserve_preformatted_lines() { + for newline in ["\n", "\r\n", "\r"] { + let f = parse(&format!("
{newline}  Alpha{newline}  Beta{newline}{newline}  Gamma
")); + assert_eq!(texts(&f), ["CodeNone: Alpha", "CodeNone: Beta", "CodeNone:", "CodeNone: Gamma"], "{newline:?}"); + let FBlock::Para(p) = &f.blocks[1] else { panic!() }; + assert!(matches!(p.inlines.last(), Some(Inline::Text(t, f)) if t == "Beta" && f.bold)); + } +} + +#[test] +fn input_newlines_are_normalized_before_character_references() { + let f = parse("

A B\r\nC\rD

A\r\nB\rC

"); + assert_eq!(texts(&f), ["NormalNone:A\rB\nC\nD", "NormalNone:A B C"]); + let f = parse("

α\r\r\nβ γ

link"); + assert_eq!(texts(&f), ["NormalNone:α\n\nβ\rγ", "NormalNone:link"]); + let FBlock::Para(p) = &f.blocks[1] else { panic!() }; + assert!(matches!(p.inlines.first(), Some(Inline::Text(_, f)) if f.link.as_deref() == Some("https://example.test/a\nb\nc\rd"))); +} + #[test] fn css_preformatted_paragraphs_keep_edge_spaces() { // #439: `white-space:pre` keeps leading and trailing spaces like `
`; normal
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index d19bd1dbc1..4b1a75831f 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -1,6 +1,6 @@
 # File-format parity with Microsoft Word
 
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-26 · **Change:** trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-29 · **Change:** trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats 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; @@ -55,7 +55,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). East Asian multibyte code pages decoded as runs: 932 Shift-JIS (`\fcharset128`, Japanese), 936 GBK (134, Simplified Chinese), 949 UHC/windows-949 (129, Korean) and 950 Big5 (136, Traditional Chinese) through `\ansicpg`, `\cpg` or the font's charset, escaped or raw, a lead byte and its trail byte together; invalid sequences read as U+FFFD, and a run that starts `EF BB BF` is text, not a BOM (#1653). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; tracked paragraph marks (`\par` inside `\revised`/`\deleted` groups, including a `\sect` inside a deletion) and character and paragraph formatting revisions (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) read and written; section and table formatting revisions (`\oldsprops`, `\oldtprops`) not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export writes every section too (break type, page size and orientation, margins, header/footer distance, columns, page numbering, title page; not headers/footers). Thin: comments, notes, RTL | | `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/it/odt_sections.rs` | Styles, lists (#87), tables, pictures, page breaks (#86), headers and footers of each master page (every, left and first page) read on open, not yet written (#863), keep with next (#2813), an embedded formula object (`draw:object`) is read from its own `content.xml`'s MathML, same as HTML (#3099; export still drops equations, as before), page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379); export writes them back: a master page and page layout per new-page section (size, orientation, margins, columns, page-numbering restart on the first paragraph or table), a `text:section` with its columns per continuous section whose columns differ from the page's (one column included), a list cut by a section break goes on numbering. Limits: a continuous section's page-numbering restart isn't written (ODF applies it only with a master page); a continuous section with the page's columns merges into the section before it on reopening; even and odd page starts are written as plain new pages; a tracked deletion of a section break is lost; an empty last section is dropped; tracked insertions and deletions with author and date, inserted and deleted paragraph marks and text inserted then deleted included (ODF change tracking; format changes read as none; pictures and tables inside a tracked deletion are dropped). No footnotes, comments, fields or RTL | | `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`; a Word XML Document (Word 2003 XML or Flat OPC) or a web page (``, ``) under a `.doc`, `.docx` or `.rtf` name opens as what it is (#2853, `tests::word_xml_and_web_pages_saved_as_doc_open_by_content`). Word's encoding choice dialog and line-ending options missing; such files save only through Save As | -| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `` tolerated (#154). Header cells (``) bold unless their CSS says otherwise; `text-align` on a cell, or inherited from its row, row group or table, aligns the cell's paragraphs (#1381). HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do; `@page` isn't read (#1381). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). A pasted `
` (LibreOffice's paragraph indented on both sides) becomes a Normal paragraph indented by the page's `blockquote` margins, else 0.5 in each side; opening an .html file keeps it as the Quote style (#1270). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816). A copy of one equation alone also goes in the clipboard's MathML formats (Windows "MathML" and "MathML Presentation", macOS `public.mathml.presentation` and `public.mathml`; W3C MathML 3 §6.2.4), which Microsoft documents Word reading first when it pastes math (#1297; the paste into Word is not yet verified); Ruby (furigana, phonetic guides) is written as `basereading` in the base text's formatting and read back from ``/``/`` (`` fallback parentheses dropped; malformed markup keeps its text), so docx/doc → HTML → doc keeps the annotation; HTML states no alignment, size or raise, so the Phonetic Guide's defaults apply (#1681) | +| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `` tolerated (#154). Header cells (``) bold unless their CSS says otherwise; `text-align` on a cell, or inherited from its row, row group or table, aligns the cell's paragraphs (#1381). HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do; `@page` isn't read (#1381). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). A pasted `
` (LibreOffice's paragraph indented on both sides) becomes a Normal paragraph indented by the page's `blockquote` margins, else 0.5 in each side; opening an .html file keeps it as the Quote style (#1270). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816). A copy of one equation alone also goes in the clipboard's MathML formats (Windows "MathML" and "MathML Presentation", macOS `public.mathml.presentation` and `public.mathml`; W3C MathML 3 §6.2.4), which Microsoft documents Word reading first when it pastes math (#1297; the paste into Word is not yet verified); Ruby (furigana, phonetic guides) is written as `basereading` in the base text's formatting and read back from ``/``/`` (`` fallback parentheses dropped; malformed markup keeps its text), so docx/doc → HTML → doc keeps the annotation; HTML states no alignment, size or raise, so the Phonetic Guide's defaults apply (#1681) CRLF and lone CR input newlines normalize before tokenization, preserving preformatted lines and leaving decoded character references separate (#4810). | | `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | | | `.xml` Word XML Document (Flat OPC) | <1% | R/W | ✅ | ✅ | ~65% | — | `crates/docx/tests/it/word_xml.rs`, `roundtrip.rs` (`flat_opc_round_trip_equals_docx`) | The DOCX package in one XML file, read and written by the DOCX code (#548); File › Export › Word XML Document. `.xml` files are told apart by their root element | | `.xml` Word 2003 XML (WordprocessingML 2003) | <1% | R/W | 🟡 | 🟡 | ~55% | 1–2 | `crates/docx/tests/it/word_xml.rs` | Opened by rewriting it as a package for the DOCX reader (#548): styles, default fonts, lists, tables, sections, headers/footers, footnotes/endnotes, comments, bookmarks, tracked insertions/deletions, hyperlinks, `w:binData` pictures, document properties. Not read: formatting changes, `w:docPr` settings, smart tags. Written (#1081) by rewriting the DOCX writer's package into the 2003 vocabulary (`crates/docx/src/wordml2003_write.rs`, `crates/docx/tests/it/wordml2003_write.rs`): core properties, styles (document defaults folded into Normal), lists, text and paragraph/character formatting, tables, hyperlinks, footnotes/endnotes inline, headers/footers in the section properties, bookmarks, comments and tracked insertions/deletions as `aml:annotation`, section page setup, and PNG/JPEG/GIF/BMP/EMF/WMF pictures as `w:pict` with `w:binData`. **Dropped on write:** shapes, text boxes, groups, charts, SmartArt, ink, equations, OLE objects, picture crop, rotation and floating position (pictures become inline), content-control wrappers (their content stays), formatting-change tracking, theme colours/fonts, custom XML, macros, even/odd header setting. File › Export › Word 2003 XML Document, `file.save`/`file.saveAs` with `format: "wordml2003"`, `wordcraft-cli convert … --format wordml2003`; a plain save over a 2003 file keeps that format. Never opened in Word yet (written from the published schemas; needs a tester) | @@ -272,6 +272,7 @@ Repeated DOCX style IDs use the last complete definition. The original regressio | Date | Change | Summary | |---|---|---| | 2026-10-17 | trivial | macOS launch arguments (`-psn_*`, `-NS*`, `-Apple*`) are no longer opened as files; a file that fails to open (command line, open event, drop) names the file and the cause on the status bar and in the log (#1103) | +| 2026-10-29 | trivial | HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged. | | 2026-10-17 | trivial | Encryption rows: Standard (Word 2007, AES-ECB) encrypted packages open with a password, also when named `.doc`/`.dot`; AES headers in Word 97-2003 files are outside the public spec and refused (gap 9). The duplicate combined `.docx`/`.doc` row is removed; the `.doc` row reads 🟡 ~60% (it opens XOR/RC4/RC4 CryptoAPI); hours 2–4 → 1–2 and 4–6 → 1–2 (left: checks against Word-written files); total re-summed from the rows, 175–287 h | | 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`) | diff --git a/docs/gaps.md b/docs/gaps.md index 5b082dc6f3..cbe077dc85 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; 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: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; 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: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **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-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (#7: Custom Document Properties dialog, #2982; previously 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: 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 the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 @@ -153,6 +153,7 @@ Recipient label generation (#4880) now includes all selected recipients across a | Date | Change | Summary | |---|---|---| | 2026-10-29 | trivial | Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Filtered/sorted order, product geometry and single-label mode retained; readiness estimates unchanged. | +| 2026-10-29 | trivial | HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged. | | 2026-10-22 | trivial | View windows: several open documents and Switch Windows in the engine | | 2026-10-25 | trivial | egui 0.37 migration: interface catalogs remain pinned by `craft-i18n.rev` (`9205108…`; 27 languages, English fallback); IME surrounding deletions and composition resets are implemented with regressions, but managed CI passed; native IME acceptance and native-speaker catalog review remain open; no gap or readiness change | | 2026-10-17 | trivial | Gap 9 narrowed: Standard (Word 2007, AES-ECB) encrypted packages open with a password under any Word extension; AES headers in Word 97-2003 files are outside the public spec and refused with a clear message; estimate 3–5 → 0 h, since the remaining AES-in-`.doc` case is refused by design | diff --git a/docs/roadmap.md b/docs/roadmap.md index c92f21f4bf..39bdcb2712 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # WordCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (egui 0.37 migration: 27 interface languages remain pinned by `craft-i18n.rev` (9205108…), and the input-method handling has synthetic regressions while managed CI passed and native acceptance (#155–#164) remains open; previously trivial: M6: Manage Sources imports Word's `Sources.xml`, #2640; previously minor: Czech proofing review fixes; previously minor: Portuguese proofing; previously minor: Polish interface and Polish proofing; previously trivial: M7: formatting revisions landed, #41; previously trivial: M5: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: alpha gate checked: six core workflows pass; previously major: milestones moved here from ROADMAP.md; Current focus set from the re-measured gaps) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged; previously trivial: egui 0.37 migration: 27 interface languages remain pinned by `craft-i18n.rev` (9205108…), and the input-method handling has synthetic regressions while managed CI passed and native acceptance (#155–#164) remains open; previously trivial: M6: Manage Sources imports Word's `Sources.xml`, #2640; previously minor: Czech proofing review fixes; previously minor: Portuguese proofing; previously minor: Polish interface and Polish proofing; previously trivial: M7: formatting revisions landed, #41; previously trivial: M5: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: alpha gate checked: six core workflows pass; previously major: milestones moved here from ROADMAP.md; Current focus set from the re-measured gaps) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Forward-looking plan. The summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); the numbers are in [`target-app-parity.md`](target-app-parity.md). Hours are @@ -74,6 +74,7 @@ languages, screen-reader access, ecosystem decisions (VBA, add-ins, cloud), an i | Date | Change | Summary | |---|---|---| +| 2026-10-29 | trivial | HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged. | | 2026-10-25 | trivial | egui 0.37 migration: 27 interface languages remain pinned by `craft-i18n.rev` (9205108…); input-method surrounding-delete and commit handling has synthetic regressions only, with managed CI passing; native acceptance (#155–#164) and native-speaker review remain open | | 2026-10-18 | trivial | M6: Manage Sources imports Word's `Sources.xml` (#2640) | | 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 | From 48545479b0a0938b90bdc0c7734ecc9789c485e5 Mon Sep 17 00:00:00 2001 From: Rees <109076338+OPUZZLES@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:42:49 +0100 Subject: [PATCH 28/73] Markdown import: bound nested reference-label work (#4659) * M1 #4658: bound Markdown reference-label work * #4658: align Markdown progress status dates * docs: keep ROADMAP's second status line as on main --------- Co-authored-by: Brandon Thomas --- ROADMAP.md | 6 +++- crates/formats/src/markdown/inlines.rs | 35 +++++++++++++------ crates/formats/src/markdown/tests.rs | 47 ++++++++++++++++++++++++++ docs/file-format-parity.md | 3 +- docs/gaps.md | 3 +- 5 files changed, 80 insertions(+), 14 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 51abb85968..da5955393a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-29 · **Change:** trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; 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: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; 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: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: 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: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #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 fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; 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: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; 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: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: 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: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #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 fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously 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. @@ -210,6 +210,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-28 | The Traditional Chinese interface draws hanzi to the Taiwan standard: on the desktop an installed Traditional Chinese font (PingFang TC on macOS, Microsoft JhengHei on Windows, Noto Sans CJK TC on Linux) comes before the embedded Noto Sans CJK SC, which drew 說, 這, 為, 戶 in mainland shapes; without one, and on the web, nothing changes. | | 2026-10-17 | macOS launch arguments (`-psn_*`, `-NS*`, `-Apple*`) are no longer opened as files, and a file that fails to open names the file and the real cause on the status bar and in the log (#1103). The reported "corrupt" error could not be reproduced. | | 2026-10-17 | Password-protected Word files from Word 2007 (Standard encryption: AES-128/192/256, SHA-1) open with their password, also when named `.doc` or `.dot`; a wrong or missing password is reported as for newer files. Word 97-2003 files with an AES header, which the public spec doesn't define, are refused with a clear message (gap 9) | +| 2026-10-28 | Markdown import bounds nested undefined reference-label work within the existing link allowance; literal source and normal reference matching are preserved (#4658). | | 2026-10-23 | A command that changes nothing is not an edit: it adds no undo step, marks no unsaved changes and forces no relayout, so the redo stack survives it and five hundred of them can no longer push the user's own edits out of the undo history. | | 2026-10-23 | Interface translations come from storytold/craft-i18n, shared with the other Crafting Apps: the catalogs are embedded at build time from the commit in `craft-i18n.rev` and looked up through craft-libs' `craft-i18n` crate; `crates/ui-egui/src/i18n/*.tsv` are gone (translations now go to craft-i18n). Strings other apps already translate show translated; a build without `CRAFT_I18N_DIR` is English only | | 2026-10-25 | #4085 mark review: whole-cell and ordinary highlight targets include their selected paragraph marks; empty, inherited and partial-column controls added. | @@ -650,10 +651,13 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps ## Revision history +> 2026-10-28 · Markdown import bounds reference-label scans and normalization within the existing link-work allowance. Nested undefined labels preserve literal text instead of causing quadratic work (#4658). +> > 2026-10-23 · cu20we review follow-up: Only paragraph-relative box floats use the pre-spacing origin. Line-relative floats and page/margin fallbacks in cells and text boxes keep the text-top origin. Synthetic controls compare zero and 24 pt space-before across all four box contexts. New cap conversion helpers and regression tests document their contracts; readiness estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-10-28 | trivial | #4658: bound Markdown reference-label rejection work, preserving literal source and ordinary link matching | | 2026-10-29 | trivial | Progress log: Insert › Shapes icon grid (#4349) | | 2026-10-29 | trivial | HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged. | | 2026-10-29 | trivial | Progress log: window fallback geometry and equation symbol tooltips | diff --git a/crates/formats/src/markdown/inlines.rs b/crates/formats/src/markdown/inlines.rs index 74f0656c08..8cc6b376ef 100644 --- a/crates/formats/src/markdown/inlines.rs +++ b/crates/formats/src/markdown/inlines.rs @@ -116,8 +116,8 @@ struct LinkScan<'a> { budget: usize, } -impl LinkScan<'_> { - fn parse_at(&mut self, open: usize) -> Option<(String, Arc, usize)> { +impl<'a> LinkScan<'a> { + fn parse_at(&mut self, open: usize) -> Option<(&'a str, Arc, usize)> { let bytes = self.text.as_bytes(); let close = match self.known.get(&open).copied() { Some(found) => found, @@ -136,19 +136,32 @@ impl LinkScan<'_> { let destination = link_dest(self.text, close + 1); self.budget = self.budget.saturating_sub(destination.as_ref().map_or(self.text.len(), |d| d.1).saturating_sub(close)); let (destination, end) = destination?; - return Some((label_text.to_string(), Arc::from(destination), end)); + return Some((label_text, Arc::from(destination), end)); } // Full and shortcut reference links use definitions collected before inline parsing. + if self.refs.is_empty() { + return None; + } let (label, end) = match self.text.get(close + 1..).and_then(|rest| rest.strip_prefix('[')) { Some(rest) => { - let shut = rest.find(']')?; + let ahead = within(rest, self.budget); + let shut = ahead.find(']'); + self.budget = self.budget.saturating_sub(shut.map_or(ahead.len(), |n| n + 1)); + let shut = shut?; let inner = rest.get(..shut)?; let label = if inner.trim().is_empty() { label_text } else { inner }; (label, close + 2 + shut + 1) } None => (label_text, close + 1), }; - Some((label_text.to_string(), self.refs.get(&ref_label(label))?.clone(), end)) + // A cached bracket pair still costs a pass over its label. Without charging it, + // nested undefined references normalize every suffix again. + if label.len() > self.budget { + self.budget = 0; + return None; + } + self.budget -= label.len(); + Some((label_text, self.refs.get(&ref_label(label))?.clone(), end)) } } @@ -257,10 +270,10 @@ fn tag_attr(tag: &str, name: &str) -> Option { fn tokenize(s: &str, base: &Fmt, depth: usize, refs: &Refs) -> Vec { let mut nodes: Vec = Vec::new(); - // Bytes the link and image scans below may look ahead in total. Each unmatched `[` or `![(` - // would otherwise rescan the rest of the text, which is quadratic on hostile input; once the - // budget is gone, further brackets are plain text. A scan pays for what it reads; brackets an - // earlier scan passed are looked up in `known` instead. + // Bytes the link/image scans and reference-label lookups may read in total. Unmatched + // brackets and nested undefined references would otherwise rescan the rest of the text, + // which is quadratic on hostile input. Once the budget is gone, further brackets are plain + // text. A scan pays for what it reads; earlier bracket matches are looked up in `known`. let ticks = Backticks::new(s); let mut links = LinkScan { text: s, refs, ticks: &ticks, known: HashMap::new(), budget: s.len().saturating_mul(8).saturating_add(8192) }; let b = s.as_bytes(); @@ -324,7 +337,7 @@ fn tokenize(s: &str, base: &Fmt, depth: usize, refs: &Refs) -> Vec { match parsed { Some((inner, dest, end)) if img => { flush!(); - let alt: String = parse_inlines(&inner, &Fmt::default(), depth + 1, refs) + let alt: String = parse_inlines(inner, &Fmt::default(), depth + 1, refs) .iter() .filter_map(|x| if let Inline::Text(t, _) = x { Some(t.as_str()) } else { None }) .collect(); @@ -343,7 +356,7 @@ fn tokenize(s: &str, base: &Fmt, depth: usize, refs: &Refs) -> Vec { if f.link.is_none() { model::dropped(model::Dropped::Link); } - for x in parse_inlines(&inner, &f, depth + 1, refs) { + for x in parse_inlines(inner, &f, depth + 1, refs) { nodes.push(Node::Inl(x)); } i = end; diff --git a/crates/formats/src/markdown/tests.rs b/crates/formats/src/markdown/tests.rs index f40a2a5cc8..11d591258b 100644 --- a/crates/formats/src/markdown/tests.rs +++ b/crates/formats/src/markdown/tests.rs @@ -79,6 +79,53 @@ fn reference_links_resolve_and_their_definitions_leave_the_body() { assert_eq!(links(fenced), vec![], "a fenced definition defines nothing"); } +/// Cached bracket pairs must not make undefined shortcut references normalize every nested +/// suffix again. One unrelated definition keeps this on the reference-lookup path. +#[test] +fn nested_undefined_reference_labels_have_bounded_work() { + let literal = format!("prefix {}x{} suffix", "[".repeat(128_000), "]".repeat(128_000)); + let src = format!("{literal}\n\n[other]: https://example.com/other\n"); + let started = std::time::Instant::now(); + let doc = import(&src); + let elapsed = started.elapsed(); + assert_eq!(doc.plain_text(wordcraft_doc::StoryRef::Body), literal); + assert!(elapsed < std::time::Duration::from_secs(10), "nested undefined references took {elapsed:?}"); +} + +#[test] +fn reference_lookup_keeps_long_unicode_labels_and_nested_text() { + // The work guard is not a label-length cap. Collapsed/full/shortcut references keep their + // case and whitespace matching, even when lowercasing changes the number of UTF-8 bytes. + let label = format!("{}İ END", "Écho ".repeat(1500)); + let key = label.to_lowercase(); + let src = format!("[{label}] [{label}][] [full][{label}]\n\n[{key}]: https://example.com/long\n"); + let flow = parse(&src); + let FBlock::Para(p) = &flow.blocks[0] else { panic!("paragraph") }; + let links: Vec<_> = p + .inlines + .iter() + .filter_map(|x| match x { + Inline::Text(t, f) if f.link.is_some() => Some((t.as_str(), f.link.as_deref())), + _ => None, + }) + .collect(); + assert_eq!( + links, + vec![ + (label.as_str(), Some("https://example.com/long")), + (label.as_str(), Some("https://example.com/long")), + ("full", Some("https://example.com/long")) + ] + ); + + let refs = Refs::from([("known".into(), Arc::from("https://example.com/known"))]); + let out = parse_inlines("[outer [known]] tail [inline](https://example.com/inline)", &Fmt::default(), 0, &refs); + let text: String = out.iter().filter_map(|x| if let Inline::Text(t, _) = x { Some(t.as_str()) } else { None }).collect(); + assert_eq!(text, "[outer known] tail inline"); + assert!(out.iter().any(|x| matches!(x, Inline::Text(t, f) if t == "known" && f.link.as_deref() == Some("https://example.com/known")))); + assert!(out.iter().any(|x| matches!(x, Inline::Text(t, f) if t == "inline" && f.link.as_deref() == Some("https://example.com/inline")))); +} + /// A definition has to start a block (CommonMark §4.7), and a fence opens and closes by the /// same rules the block parser uses. Reading a line without that context deleted text: a /// definition-shaped line inside a paragraph, and every such line inside a code block whose diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 4b1a75831f..843aaf37a6 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-29 · **Change:** trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-29 · **Change:** trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats 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; @@ -271,6 +271,7 @@ Repeated DOCX style IDs use the last complete definition. The original regressio | Date | Change | Summary | |---|---|---| +| 2026-10-28 | trivial | #4658: bound Markdown reference-label rejection work, preserving literal source and ordinary link matching | | 2026-10-17 | trivial | macOS launch arguments (`-psn_*`, `-NS*`, `-Apple*`) are no longer opened as files; a file that fails to open (command line, open event, drop) names the file and the cause on the status bar and in the log (#1103) | | 2026-10-29 | trivial | HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged. | | 2026-10-17 | trivial | Encryption rows: Standard (Word 2007, AES-ECB) encrypted packages open with a password, also when named `.doc`/`.dot`; AES headers in Word 97-2003 files are outside the public spec and refused (gap 9). The duplicate combined `.docx`/`.doc` row is removed; the `.doc` row reads 🟡 ~60% (it opens XOR/RC4/RC4 CryptoAPI); hours 2–4 → 1–2 and 4–6 → 1–2 (left: checks against Word-written files); total re-summed from the rows, 175–287 h | diff --git a/docs/gaps.md b/docs/gaps.md index cbe077dc85..6dd6e32cdc 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; 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: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; 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: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **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-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (#7: Custom Document Properties dialog, #2982; previously 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: 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 the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 @@ -152,6 +152,7 @@ Recipient label generation (#4880) now includes all selected recipients across a | Date | Change | Summary | |---|---|---| +| 2026-10-28 | trivial | #4658: bound Markdown reference-label rejection work, preserving literal source and ordinary link matching | | 2026-10-29 | trivial | Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Filtered/sorted order, product geometry and single-label mode retained; readiness estimates unchanged. | | 2026-10-29 | trivial | HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged. | | 2026-10-22 | trivial | View windows: several open documents and Switch Windows in the engine | From 81833cfc334790d352b3d7adeecb2ed00ace266c Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:43:16 +0100 Subject: [PATCH 29/73] Keep Markdown emphasis markers flanking next to punctuation (#4801) Found while testing crates/formats/src/markdown/export.rs:167-182 (inlines_md_impl, emphasis marker emission) Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/formats/src/markdown/export.rs | 40 +++++++++++----- crates/formats/src/markdown/inlines.rs | 2 +- crates/formats/src/markdown/tests.rs | 63 ++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 13 deletions(-) diff --git a/crates/formats/src/markdown/export.rs b/crates/formats/src/markdown/export.rs index d4f21533c0..c1dbe52678 100644 --- a/crates/formats/src/markdown/export.rs +++ b/crates/formats/src/markdown/export.rs @@ -1,5 +1,5 @@ use super::blocks::thematic_break; -use super::inlines::within; +use super::inlines::{is_punct, within}; use super::{Align, Arc, Cell, Document, FBlock, FTable, Fmt, Inline, Kind, model}; // --------------------------------------------------------------------------------------------- @@ -166,30 +166,43 @@ fn inlines_md_impl(inlines: &[Inline], in_table: bool) -> String { } let want: Vec = [(f.bold, Mark::Bold), (f.italic, Mark::Italic), (f.strike, Mark::Strike)].iter().filter(|x| x.0).map(|x| x.1).collect(); + let body = if f.code { + if in_table { code_span(&text.replace('|', "\\|")) } else { code_span(&text) } + } else { + escape(&text).replace('\n', "\\\n").replace('\t', " ") + }; // Close until the stack is a prefix of what we want. + let mut close = String::new(); while let Some(pos) = stack.iter().position(|m| !want.contains(m)) { while stack.len() > pos { if let Some(m) = stack.pop() { - out.push_str(marker(m)); + close.push_str(marker(m)); } } } + let mut open = String::new(); for m in want { if !stack.contains(&m) { - out.push_str(marker(m)); + open.push_str(marker(m)); stack.push(m); } } - if f.code { - if in_table { - let t = text.replace('|', "\\|"); - out.push_str(&code_span(&t)); - } else { - out.push_str(&code_span(&text)); - } - } else { - out.push_str(&escape(&text).replace('\n', "\\\n").replace('\t', " ")); + // CommonMark only reads a closing run after punctuation as closing when whitespace or + // punctuation follows it, and an opening run before punctuation as opening when whitespace + // or punctuation precedes it. Where a letter sits there instead, or an opening run would + // directly follow a closing run (the two would merge into one run), an empty HTML comment (which readers drop) supplies + // the punctuation. + let prev = out.chars().next_back(); + let next = body.chars().next(); + let word = |c: Option| c.is_some_and(|c| !c.is_whitespace() && !is_punct(c)); + out.push_str(&close); + let close_needs = !close.is_empty() && prev.is_some_and(is_punct) && (!open.is_empty() || word(next)); + let open_needs = !open.is_empty() && next.is_some_and(is_punct) && (!close.is_empty() || word(prev)); + if close_needs || open_needs { + out.push_str(EMPTY_COMMENT); } + out.push_str(&open); + out.push_str(&body); } close_all(&mut out, &mut stack); if let Some(l) = &link { @@ -229,6 +242,9 @@ fn entity_like(rest: &str) -> bool { !body.is_empty() && body.chars().all(|c| c.is_ascii_alphanumeric()) } +/// Inline HTML that Markdown readers drop, used to separate emphasis markers from letters. +const EMPTY_COMMENT: &str = ""; + fn marker(m: Mark) -> &'static str { match m { Mark::Bold => "**", diff --git a/crates/formats/src/markdown/inlines.rs b/crates/formats/src/markdown/inlines.rs index 8cc6b376ef..99c3972463 100644 --- a/crates/formats/src/markdown/inlines.rs +++ b/crates/formats/src/markdown/inlines.rs @@ -16,7 +16,7 @@ enum Node { Delim { ch: char, orig: usize, left: usize, open: bool, close: bool, opens: Vec, closes: Vec }, } -fn is_punct(c: char) -> bool { +pub(super) fn is_punct(c: char) -> bool { c.is_ascii_punctuation() || (!c.is_alphanumeric() && !c.is_whitespace() && !c.is_control()) } diff --git a/crates/formats/src/markdown/tests.rs b/crates/formats/src/markdown/tests.rs index 11d591258b..32767eeee4 100644 --- a/crates/formats/src/markdown/tests.rs +++ b/crates/formats/src/markdown/tests.rs @@ -201,6 +201,69 @@ fn export_escapes_round_trip() { assert!(q.list.is_none()); } +#[test] +fn export_emphasis_next_to_punctuation_round_trips() { + let marks: [(&str, fn(&mut Fmt)); 3] = [("bold", |f| f.bold = true), ("italic", |f| f.italic = true), ("strike", |f| f.strike = true)]; + let cases: [&[(&str, bool)]; 4] = [ + &[("注意:", true), ("请阅读", false)], + &[("a.", true), ("b", false)], + &[("a", false), ("!", true), ("b", false)], + &[("a", false), ("(x)", true), ("b", false)], + ]; + for (name, set) in marks { + for runs in cases { + let mut p = Para::default(); + for (t, on) in runs { + let mut f = Fmt::default(); + if *on { + set(&mut f); + } + p.push_text(t, &f); + } + let md = inlines_md(&p.inlines); + let back = parse(&md); + let FBlock::Para(q) = &back.blocks[0] else { panic!() }; + let want: String = runs.iter().map(|r| r.0).collect(); + assert_eq!(q.text(), want, "{name} exported as {md:?}"); + let got: Vec<(String, bool)> = q + .inlines + .iter() + .filter_map(|i| if let Inline::Text(t, f) = i { Some((t.clone(), f.bold || f.italic || f.strike)) } else { None }) + .collect(); + let expect: Vec<(String, bool)> = runs.iter().map(|(t, on)| (t.to_string(), *on)).collect(); + assert_eq!(got, expect, "{name} exported as {md:?}"); + } + } +} + +#[test] +fn export_adjacent_emphasis_next_to_punctuation_round_trips() { + let b = Fmt { bold: true, ..Fmt::default() }; + let i = Fmt { italic: true, ..Fmt::default() }; + let s = Fmt { strike: true, ..Fmt::default() }; + let cases: [&[(&str, &Fmt)]; 6] = [ + &[("ab", &b), ("!c", &i)], + &[("ab", &i), ("!c", &b)], + &[("ab", &b), ("!c", &s)], + &[("ab", &s), ("(c)", &b)], + &[("a.", &b), ("b", &i)], + &[("a.", &b), ("!b", &i)], + ]; + for runs in cases { + let mut p = Para::default(); + for (t, f) in runs { + p.push_text(t, f); + } + let md = inlines_md(&p.inlines); + let back = parse(&md); + let FBlock::Para(q) = &back.blocks[0] else { panic!() }; + let got: Vec<(String, bool, bool, bool)> = + q.inlines.iter().filter_map(|x| if let Inline::Text(t, f) = x { Some((t.clone(), f.bold, f.italic, f.strike)) } else { None }).collect(); + let expect: Vec<(String, bool, bool, bool)> = runs.iter().map(|(t, f)| (t.to_string(), f.bold, f.italic, f.strike)).collect(); + assert_eq!(got, expect, "exported as {md:?}"); + } +} + #[test] fn escape_line_start_preserves_indentation() { assert_eq!(escape_line_start(" indented text".to_string()), " indented text"); From 5b952b246378eddbd25824174a230e340a56d438 Mon Sep 17 00:00:00 2001 From: naxvog Date: Fri, 30 Oct 2026 04:43:55 +0100 Subject: [PATCH 30/73] RTF: keep Windows and Enhanced Metafile pictures (#4259) (#4648) The RTF reader only recognised \pngblip and \jpegblip, so a \pict holding \wmetafile8 (how Word writes pasted and floating pictures) or \emfblip was dropped and the document opened without its pictures. Keep them as wmf/emf media when the bytes are a metafile (the extension comes from the bytes; raster bytes go through the usual image path, anything else is dropped), sized from \picwgoal/\pichgoal and the scale, else from \picw/\pich (0.01 mm, or pixels under \wmetafile1; zero or negative falls back to the default). The metafile renderer already draws them and the DOCX writer saves them as .wmf/.emf. RTF export dropped WMF/EMF pictures as well (it re-encoded every other format through the raster decoder): it now writes \emfblip with the raw bytes and \wmetafile8 without the placeable header, \picw/\pich in 0.01 mm. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- Cargo.lock | 1 + ROADMAP.md | 4 +- crates/formats/Cargo.toml | 1 + crates/formats/src/model.rs | 2 +- crates/formats/src/model/media.rs | 11 +++- crates/formats/src/rtf/export.rs | 19 ++++++- crates/formats/src/rtf/reader.rs | 4 ++ crates/formats/src/rtf/tests.rs | 88 +++++++++++++++++++++++++++++++ crates/formats/src/rtf/text.rs | 24 ++++++++- crates/formats/src/rtf/word.rs | 10 ++++ docs/file-format-parity.md | 5 +- docs/gaps.md | 5 +- 12 files changed, 164 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 901e13678b..05fa95ade2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6812,6 +6812,7 @@ dependencies = [ "quick-xml 0.41.0", "wordcraft-doc", "wordcraft-geom", + "wordcraft-metafile", "zip 3.0.0", ] diff --git a/ROADMAP.md b/ROADMAP.md index da5955393a..146ecedcad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-29 · **Change:** trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; 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: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; 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: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: 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: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #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 fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; 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: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; 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: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: 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: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #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 fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously 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. @@ -140,6 +140,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-10-28: RTF import keeps pictures stored as Windows Metafiles (`\wmetafile8`, as Word writes a pasted or floating picture) and Enhanced Metafiles (`\emfblip`); they were dropped, so such a document opened without its pictures. They are drawn by the metafile renderer, saved to .docx as `.wmf`/`.emf` media, and saved to .rtf as metafiles again (RTF export dropped them too). (#4259) - 2026-10-27: File › Options › Proofing › AutoCorrect Options opens with no document open (#4585). On the Start screen, or after closing the last document, the button did nothing: AutoCorrect wasn't among the commands allowed without a document. Its options are user preferences, so changes made there are kept for the next document. - 2026-10-29: Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Labels → New Document with one label per recipient includes every selected address, with unused labels blank on the final sheet. Fixed-text and single-label output remain one sheet. Finish & Merge template expansion is separate. - 2026-10-29: HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged. @@ -1029,3 +1030,4 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-23 | trivial | Progress log: "NaN" in the Font dialog's Size box no longer closes the app (#3663) | | 2026-10-23 | trivial | Progress log: a table cell span from a `.json` file no longer overflows (#3637) | | 2026-10-27 | trivial | Progress log: AutoCorrect Options with no document open (#4585) | +| 2026-10-28 | trivial | Progress log: RTF metafile pictures are imported (#4259) | diff --git a/crates/formats/Cargo.toml b/crates/formats/Cargo.toml index faf6e41583..8ae0610815 100644 --- a/crates/formats/Cargo.toml +++ b/crates/formats/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] wordcraft-doc = { workspace = true } wordcraft-geom = { workspace = true, features = ["image"] } +wordcraft-metafile = { workspace = true } image = { workspace = true } quick-xml = { workspace = true } zip = { workspace = true } diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs index de726ae096..75e7d0d5e2 100644 --- a/crates/formats/src/model.rs +++ b/crates/formats/src/model.rs @@ -21,7 +21,7 @@ mod types; pub use color::parse_color; pub use from_doc::{ListCounter, flow_paras, from_doc, from_doc_tracked, is_mono, para_kind}; -pub use media::{base64_decode, base64_encode, data_uri, decode_bounded, image_px, make_img, mime_of, percent_decode, sniff_image}; +pub use media::{base64_decode, base64_encode, data_uri, decode_bounded, image_px, make_img, mime_of, percent_decode, sized_img, sniff_image}; pub use to_doc::{char_props, part_blocks, to_doc, to_doc_in}; pub use types::{ CODE_STYLE, Cell, FBlock, FTable, Flow, Fmt, FmtChange, Img, Inline, Kind, ListInfo, MAX_DEPTH, MAX_FONT_NAME, MAX_LINK, MONO_FONT, Meta, Para, diff --git a/crates/formats/src/model/media.rs b/crates/formats/src/model/media.rs index cdb0755ea3..cf543af2d8 100644 --- a/crates/formats/src/model/media.rs +++ b/crates/formats/src/model/media.rs @@ -33,7 +33,14 @@ pub fn decode_bounded(data: &[u8]) -> Option { pub fn make_img(data: impl Into>>, w: Option, h: Option, alt: &str) -> Option { let data = data.into(); let ext = sniff_image(&data)?; - let (pw, ph) = image_px(&data).map(|(a, b)| (a as f32 * 0.75, b as f32 * 0.75)).unwrap_or((72.0, 72.0)); + let natural = image_px(&data).map(|(a, b)| (a as f32 * 0.75, b as f32 * 0.75)); + Some(sized_img(data, ext, natural, w, h, alt)) +} + +/// A picture of a known format `ext` whose natural size (points) the caller knows, if at all: a +/// metafile, which `image` can't measure. Sized like [`make_img`]. +pub fn sized_img(data: Arc>, ext: &str, natural: Option<(f32, f32)>, w: Option, h: Option, alt: &str) -> Img { + let (pw, ph) = natural.filter(|(a, b)| a.is_finite() && b.is_finite()).unwrap_or((72.0, 72.0)); let (pw, ph) = (pw.max(1.0), ph.max(1.0)); let (mut w, mut h) = match (w.filter(|v| v.is_finite() && *v > 0.0), h.filter(|v| v.is_finite() && *v > 0.0)) { (Some(w), Some(h)) => (w, h), @@ -45,7 +52,7 @@ pub fn make_img(data: impl Into>>, w: Option, h: Option, a h *= 468.0 / w; w = 468.0; } - Some(Img { data, ext: ext.to_string(), w: w.clamp(1.0, 1584.0), h: h.clamp(1.0, 1584.0), alt: alt.to_string() }) + Img { data, ext: ext.to_string(), w: w.clamp(1.0, 1584.0), h: h.clamp(1.0, 1584.0), alt: alt.to_string() } } const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; diff --git a/crates/formats/src/rtf/export.rs b/crates/formats/src/rtf/export.rs index 70147943b4..0b98c81eb7 100644 --- a/crates/formats/src/rtf/export.rs +++ b/crates/formats/src/rtf/export.rs @@ -341,9 +341,19 @@ impl Writer { } fn picture(&mut self, img: &model::Img, out: &mut String) { + // A metafile's `\picw`/`\pich` are its size in 0.01 mm (#4259). + let hundredths = ((img.w * 2540.0 / 72.0).round() as i64, (img.h * 2540.0 / 72.0).round() as i64); let (data, blip) = match img.ext.as_str() { "png" => (img.data.to_vec(), "\\pngblip"), "jpeg" | "jpg" => (img.data.to_vec(), "\\jpegblip"), + // The bytes decide, as on reading: an EMF starts with its header record, type 1 (a + // DOCX may store one under `.wmf`). + "emf" | "wmf" if img.data.starts_with(&[1, 0, 0, 0]) => (img.data.to_vec(), "\\emfblip"), + // RTF holds a WMF without its 22-byte placeable header. + "emf" | "wmf" => { + let data = if img.data.starts_with(&[0xD7, 0xCD, 0xC6, 0x9A]) { img.data.get(22..).unwrap_or_default() } else { img.data.as_slice() }; + (data.to_vec(), "\\wmetafile8") + } _ => { // Re-encode other formats as PNG, bounding the decode so a tiny file declaring // huge dimensions can't exhaust memory on export. @@ -355,7 +365,14 @@ impl Writer { (buf, "\\pngblip") } }; - let (pw, ph) = model::image_px(&data).unwrap_or((1, 1)); + // A placeable header with nothing after it leaves no picture to write. + if data.is_empty() { + return; + } + let (pw, ph) = match blip { + "\\emfblip" | "\\wmetafile8" => hundredths, + _ => model::image_px(&data).map(|(a, b)| (i64::from(a), i64::from(b))).unwrap_or((1, 1)), + }; out.push_str(&format!( "{{\\pict{blip}\\picw{pw}\\pich{ph}\\picwgoal{}\\pichgoal{}\n", (img.w * 20.0).round() as i64, diff --git a/crates/formats/src/rtf/reader.rs b/crates/formats/src/rtf/reader.rs index 5d10a7cdbe..11a27fd0a0 100644 --- a/crates/formats/src/rtf/reader.rs +++ b/crates/formats/src/rtf/reader.rs @@ -62,6 +62,10 @@ pub(super) struct Reader { pub(super) pict_h: Option, pub(super) pict_sx: f32, pub(super) pict_sy: f32, + /// `\picw`/`\pich` as written, read for metafiles (0 when absent). + pub(super) pict_nat: (i32, i32), + /// The mapping mode `N` of `\wmetafileN`. + pub(super) pict_mm: i32, pub(super) bkmk: Option<(usize, String)>, /// Authors of tracked changes (`\revtbl`), and the entry being read. rev_authors: Vec, diff --git a/crates/formats/src/rtf/tests.rs b/crates/formats/src/rtf/tests.rs index 93bd22ee3a..733bfaad8f 100644 --- a/crates/formats/src/rtf/tests.rs +++ b/crates/formats/src/rtf/tests.rs @@ -457,6 +457,94 @@ fn shppict_imports_one_image_and_skips_legacy_duplicate() -> Result<(), String> Ok(()) } +/// #4259: `\wmetafile8` and `\emfblip` pictures are kept as WMF/EMF media (they were dropped), +/// sized from `\picwgoal`/`\pichgoal` and `\picscalex`/`\picscaley`, else from `\picw`/`\pich` +/// (0.01 mm for a metafile). As Word writes a floating picture: the `\shprslt` copy is read. +#[test] +fn metafile_pictures_are_imported() -> Result<(), String> { + // A plain WMF: the 9-word header (type 1, version 0x300, 12 words, max record 3), then EOF. + let wmf: Vec = [1u16, 9, 0x300, 12, 0, 0, 3, 0, 0, 3, 0, 0].iter().flat_map(|w| w.to_le_bytes()).collect(); + let hex: String = wmf.iter().map(|b| format!("{b:02x}")).collect(); + let images = |rtf: String| -> Result, String> { + let f = parse(rtf.as_bytes())?; + Ok(f.blocks + .iter() + .filter_map(|b| match b { + FBlock::Para(p) => Some(p.inlines.clone()), + FBlock::Table(_) => None, + }) + .flatten() + .filter_map(|i| match i { + Inline::Image(img) => Some(img), + _ => None, + }) + .collect()) + }; + let pict = format!("{{\\pict\\wmetafile8\\picw38761\\pich12832\\picwgoal21972\\pichgoal7272\\picscalex45\\picscaley45 {hex}}}"); + let shape = format!("{{\\shp{{\\*\\shpinst{{\\sp{{\\sn pib}}{{\\sv {pict}}}}}}}{{\\shprslt{pict}}}}}"); + let got = images(format!("{{\\rtf1\\ansi Before{shape}After\\par}}"))?; + let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) }; + assert_eq!((img.ext.as_str(), img.data.as_ref()), ("wmf", &wmf)); + // 21972 twips at 45% is 494.4 pt, shrunk to the 468 pt column like every imported picture. + assert_eq!(img.w, 468.0); + assert!((img.h - 7272.0 / 20.0 * 0.45 * 468.0 / 494.37).abs() < 0.1, "{}", img.h); + // No goal size: the natural size, 2540 × 1270 hundredths of a millimetre = 72 × 36 pt; under + // MM_TEXT (`\wmetafile1`) the same numbers are pixels. + let hex_of = |b: &[u8]| -> String { b.iter().map(|b| format!("{b:02x}")).collect() }; + let mut emf = vec![0u8; 88]; + emf[0] = 1; + emf[4] = 88; + emf[40..44].copy_from_slice(b" EMF"); + let emf_hex = hex_of(&emf); + let got = images(format!("{{\\rtf1\\ansi{{\\pict\\emfblip\\picw2540\\pich1270 {emf_hex}}}\\par}}"))?; + let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) }; + assert_eq!((img.ext.as_str(), img.w, img.h, img.data.as_ref()), ("emf", 72.0, 36.0, &emf)); + let got = images(format!("{{\\rtf1\\ansi{{\\pict\\wmetafile1\\picw96\\pich48 {hex}}}\\par}}"))?; + let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) }; + assert_eq!((img.ext.as_str(), img.w, img.h), ("wmf", 72.0, 36.0)); + // `\picscalex`/`\picscaley` scale the natural size when there is no goal size. + let got = images(format!("{{\\rtf1\\ansi{{\\pict\\wmetafile8\\picw2540\\pich1270\\picscalex50\\picscaley200 {hex}}}\\par}}"))?; + let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) }; + assert_eq!((img.w, img.h), (36.0, 72.0)); + // The other mapping modes use their own units: 0.1 mm, 0.01 in, 0.001 in and twips. + for (mode, w, h) in [(2, 254, 127), (4, 100, 50), (5, 1000, 500), (6, 1440, 720)] { + let got = images(format!("{{\\rtf1\\ansi{{\\pict\\wmetafile{mode}\\picw{w}\\pich{h} {hex}}}\\par}}"))?; + let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) }; + assert!((img.w - 72.0).abs() < 0.01 && (img.h - 36.0).abs() < 0.01, "mode {mode}: {} x {}", img.w, img.h); + } + // Hostile: a zero or negative natural size falls back to the default 72 pt square, and bytes + // that are no picture at all are dropped, whatever the control word says. + let got = images(format!("{{\\rtf1\\ansi{{\\pict\\wmetafile8\\picw0\\pich-5 {hex}}}\\par}}"))?; + let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) }; + assert_eq!((img.w, img.h), (72.0, 72.0)); + for blip in ["wmetafile8", "emfblip"] { + assert!(images(format!("{{\\rtf1\\ansi{{\\pict\\{blip}\\picw2540\\pich1270 0123456789abcdef}}\\par}}"))?.is_empty(), "{blip}"); + } + // Saved as RTF and read back: the same picture, the EMF byte for byte, the WMF without the + // placeable header RTF has no room for. + let placeable: Vec = [0xD7u8, 0xCD, 0xC6, 0x9A].into_iter().chain([0u8; 18]).chain(wmf.iter().copied()).collect(); + for (blip, data, want, ext) in [("emfblip", &emf, &emf, "emf"), ("wmetafile8", &wmf, &wmf, "wmf"), ("wmetafile8", &placeable, &wmf, "wmf")] { + let rtf = format!("{{\\rtf1\\ansi{{\\pict\\{blip}\\picw2540\\pich1270 {}}}\\par}}", hex_of(data)); + let got = images(export(&import(rtf.as_bytes())?))?; + let [img] = got.as_slice() else { return Err(format!("{blip}: {} pictures", got.len())) }; + assert_eq!((img.ext.as_str(), img.data.as_ref(), img.w, img.h), (ext, want, 72.0, 36.0), "{blip}"); + } + // An EMF stored under `.wmf` (a DOCX can) is written as the EMF its bytes are. + let mut doc = import(format!("{{\\rtf1\\ansi{{\\pict\\wmetafile8\\picw2540\\pich1270 {hex}}}\\par}}").as_bytes())?; + let (key, bytes) = doc.media.iter_mut().next().ok_or("no media")?; + assert!(key.ends_with(".wmf"), "{key}"); + *bytes = std::sync::Arc::new(emf.clone()); + let rtf = export(&doc); + assert!(rtf.contains("\\emfblip") && !rtf.contains("\\wmetafile"), "{rtf}"); + let got = images(rtf)?; + let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) }; + assert_eq!((img.ext.as_str(), img.data.as_ref()), ("emf", &emf)); + // A placeable header with no records after it has nothing to write: no empty picture group. + *doc.media.values_mut().next().ok_or("no media")? = std::sync::Arc::new(placeable[..22].to_vec()); + assert!(!export(&doc).contains("\\pict"), "an empty metafile is not written"); + Ok(()) +} + #[test] fn unicode_in_info_bookmarks_and_field_instructions() { // #435: `\uN` (with its `\ucN` fallback skipped) decodes in every text destination. diff --git a/crates/formats/src/rtf/text.rs b/crates/formats/src/rtf/text.rs index 81b4d3b97b..3714b7bb85 100644 --- a/crates/formats/src/rtf/text.rs +++ b/crates/formats/src/rtf/text.rs @@ -1,5 +1,6 @@ use super::state::{Dest, MAX_PICT, St}; use super::{Fmt, Inline, Reader, Rgb, make_img, model}; +use std::sync::Arc; /// The encoding of an East Asian multibyte code page (932 Shift-JIS, 936 GBK, 949 UHC, 950 Big5), /// which [`Reader::finish_multibyte`] decodes a run with; `None` for the single-byte code pages. @@ -155,7 +156,28 @@ impl Reader { let sy = if self.pict_sy > 0.0 { self.pict_sy / 100.0 } else { 1.0 }; let w = self.pict_w.map(|v| v * sx); let h = self.pict_h.map(|v| v * sy); - if let Some(img) = make_img(data, w, h, "") { + // The control word only says "a metafile": the bytes decide what it is (#4259). + let img = if matches!(self.pict_kind, 3 | 4) && wordcraft_metafile::is_metafile(&data) { + // An EMF starts with its header record, type 1. + let ext = if data.get(..4) == Some(&[1, 0, 0, 0]) { "emf" } else { "wmf" }; + // `\picw`/`\pich` are in the units of the `\wmetafileN` mapping mode; 0.01 mm for the + // isotropic and anisotropic modes and for EMF. + let unit = match (self.pict_kind, self.pict_mm) { + (3, 1) => 0.75, // MM_TEXT: pixels + (3, 2) => 72.0 / 254.0, // MM_LOMETRIC: 0.1 mm + (3, 4) => 0.72, // MM_LOENGLISH: 0.01 in + (3, 5) => 0.072, // MM_HIENGLISH: 0.001 in + (3, 6) => 0.05, // MM_TWIPS + _ => 72.0 / 2540.0, // MM_HIMETRIC, MM_ISOTROPIC, MM_ANISOTROPIC + }; + let pt = |v: i32| (v > 0).then(|| v.min(1_000_000) as f32 * unit); + // `\picscalex`/`\picscaley` scale the natural size too, when no goal size is given. + let natural = pt(self.pict_nat.0).zip(pt(self.pict_nat.1)).map(|(a, b)| (a * sx, b * sy)); + Some(model::sized_img(Arc::new(data), ext, natural, w, h, "")) + } else { + make_img(data, w, h, "") + }; + if let Some(img) = img { self.para.inlines.push(Inline::Image(img)); self.para_has = true; } diff --git a/crates/formats/src/rtf/word.rs b/crates/formats/src/rtf/word.rs index 15730fcebd..94815e196e 100644 --- a/crates/formats/src/rtf/word.rs +++ b/crates/formats/src/rtf/word.rs @@ -30,6 +30,8 @@ impl Reader { self.pict_h = None; self.pict_sx = 0.0; self.pict_sy = 0.0; + self.pict_nat = (0, 0); + self.pict_mm = 0; } Dest::FldInst if self.st.field.is_none() => self.start_field(), // Old character formatting holds the properties that changed (Word) or, after a @@ -453,6 +455,14 @@ impl Reader { match word { "pngblip" => self.pict_kind = 1, "jpegblip" => self.pict_kind = 2, + // Windows and Enhanced Metafiles (#4259): drawn by the metafile renderer. + "wmetafile" => { + self.pict_kind = 3; + self.pict_mm = value; + } + "emfblip" => self.pict_kind = 4, + "picw" => self.pict_nat.0 = value, + "pich" => self.pict_nat.1 = value, "picwgoal" => self.pict_w = Some(value.clamp(1, 31_680) as f32 / 20.0), "pichgoal" => self.pict_h = Some(value.clamp(1, 31_680) as f32 / 20.0), "picscalex" => self.pict_sx = value.clamp(1, 1000) as f32, diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 843aaf37a6..c1b9def012 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-29 · **Change:** trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-29 · **Change:** trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats 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; @@ -52,7 +52,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer. DIB conversion caps BMP bytes (header included) and encoded PNG at the supplied BLIP byte limit, and uses it as the decoder's allocation limit; a BMP fallback is kept if it fits (related #1334 and #1554). Hostile files are bounded: a sector table longer than the file is refused before parsing (#3642); formatting and link runs are read once per change, not per character (#3692, #3708); copies of alt text, link targets and drawn objects share a 64 MB budget (#3709, #3710), and what it leaves out is logged once a kind per document; picture decoding shares a 512 MB budget and aliased store entries decode once (#3711, #3712). | | PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383, also with fonts that draw lam-alef as two glyphs at one x, #2310); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Outline destinations follow leading page breaks to the heading text. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice | | PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser | -| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). East Asian multibyte code pages decoded as runs: 932 Shift-JIS (`\fcharset128`, Japanese), 936 GBK (134, Simplified Chinese), 949 UHC/windows-949 (129, Korean) and 950 Big5 (136, Traditional Chinese) through `\ansicpg`, `\cpg` or the font's charset, escaped or raw, a lead byte and its trail byte together; invalid sequences read as U+FFFD, and a run that starts `EF BB BF` is text, not a BOM (#1653). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; tracked paragraph marks (`\par` inside `\revised`/`\deleted` groups, including a `\sect` inside a deletion) and character and paragraph formatting revisions (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) read and written; section and table formatting revisions (`\oldsprops`, `\oldtprops`) not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export writes every section too (break type, page size and orientation, margins, header/footer distance, columns, page numbering, title page; not headers/footers). Thin: comments, notes, RTL | +| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154; Windows and Enhanced Metafiles, `\wmetafile` and `\emfblip`, read and written, #4259), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). East Asian multibyte code pages decoded as runs: 932 Shift-JIS (`\fcharset128`, Japanese), 936 GBK (134, Simplified Chinese), 949 UHC/windows-949 (129, Korean) and 950 Big5 (136, Traditional Chinese) through `\ansicpg`, `\cpg` or the font's charset, escaped or raw, a lead byte and its trail byte together; invalid sequences read as U+FFFD, and a run that starts `EF BB BF` is text, not a BOM (#1653). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; tracked paragraph marks (`\par` inside `\revised`/`\deleted` groups, including a `\sect` inside a deletion) and character and paragraph formatting revisions (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) read and written; section and table formatting revisions (`\oldsprops`, `\oldtprops`) not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export writes every section too (break type, page size and orientation, margins, header/footer distance, columns, page numbering, title page; not headers/footers). Thin: comments, notes, RTL | | `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/it/odt_sections.rs` | Styles, lists (#87), tables, pictures, page breaks (#86), headers and footers of each master page (every, left and first page) read on open, not yet written (#863), keep with next (#2813), an embedded formula object (`draw:object`) is read from its own `content.xml`'s MathML, same as HTML (#3099; export still drops equations, as before), page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379); export writes them back: a master page and page layout per new-page section (size, orientation, margins, columns, page-numbering restart on the first paragraph or table), a `text:section` with its columns per continuous section whose columns differ from the page's (one column included), a list cut by a section break goes on numbering. Limits: a continuous section's page-numbering restart isn't written (ODF applies it only with a master page); a continuous section with the page's columns merges into the section before it on reopening; even and odd page starts are written as plain new pages; a tracked deletion of a section break is lost; an empty last section is dropped; tracked insertions and deletions with author and date, inserted and deleted paragraph marks and text inserted then deleted included (ODF change tracking; format changes read as none; pictures and tables inside a tracked deletion are dropped). No footnotes, comments, fields or RTL | | `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`; a Word XML Document (Word 2003 XML or Flat OPC) or a web page (``, ``) under a `.doc`, `.docx` or `.rtf` name opens as what it is (#2853, `tests::word_xml_and_web_pages_saved_as_doc_open_by_content`). Word's encoding choice dialog and line-ending options missing; such files save only through Save As | | `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `` tolerated (#154). Header cells (``) bold unless their CSS says otherwise; `text-align` on a cell, or inherited from its row, row group or table, aligns the cell's paragraphs (#1381). HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do; `@page` isn't read (#1381). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). A pasted `
` (LibreOffice's paragraph indented on both sides) becomes a Normal paragraph indented by the page's `blockquote` margins, else 0.5 in each side; opening an .html file keeps it as the Quote style (#1270). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816). A copy of one equation alone also goes in the clipboard's MathML formats (Windows "MathML" and "MathML Presentation", macOS `public.mathml.presentation` and `public.mathml`; W3C MathML 3 §6.2.4), which Microsoft documents Word reading first when it pastes math (#1297; the paste into Word is not yet verified); Ruby (furigana, phonetic guides) is written as `basereading` in the base text's formatting and read back from ``/``/`` (`` fallback parentheses dropped; malformed markup keeps its text), so docx/doc → HTML → doc keeps the annotation; HTML states no alignment, size or raise, so the Phonetic Guide's defaults apply (#1681) CRLF and lone CR input newlines normalize before tokenization, preserving preformatted lines and leaving decoded character references separate (#4810). | @@ -456,3 +456,4 @@ Repeated DOCX style IDs use the last complete definition. The original regressio | 2026-10-20 | trivial | A folder that keeps no Unix modes (a NAS share mounted by GVFS) still takes a save instead of failing with "Operation not supported" (#3003) | | 2026-10-16 | trivial | Word's Table of Contents content control (`w:docPartObj`) is kept around a TOC that isn't in the heading-and-entries form (#2427) | | 2026-10-23 | trivial | `.json`: a table cell span past 63 columns no longer overflows when a column is inserted through the cell or the cell is merged (#3637) | +| 2026-10-28 | trivial | RTF import: pictures stored as Windows Metafiles (`\wmetafile`) or Enhanced Metafiles (`\emfblip`) are kept as WMF/EMF media instead of dropped, and RTF export writes WMF/EMF pictures back as `\wmetafile8`/`\emfblip` instead of dropping them (`rtf::tests::metafile_pictures_are_imported`, #4259) | diff --git a/docs/gaps.md b/docs/gaps.md index 6dd6e32cdc..19f1e2be96 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; 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: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (gap 19: RTF reads and writes WMF and EMF pictures, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; 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: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **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-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (#7: Custom Document Properties dialog, #2982; previously 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: 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 the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 @@ -81,7 +81,7 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc | 15 | **References depth**: no EndNote/Mendeley desktop integration (bibliography sources are saved in DOCX, #790; Zotero landed, #189; Word's own `Sources.xml` master list imports into Manage Sources, #2640) | | #790 stores sources in `customXml` and reads them back; `crates/zotero`; `references.sources` `import` reads a Word bibliography file (`b:Sources`) | Students and researchers | 3–6 | [target-app-parity.md](target-app-parity.md) | | 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; six still-open older RTL bug reports to re-verify after #207 (#211, #199, #66, #63, #48, #19; #215 is closed). RTL tables and sections landed (#362); PDF export's Hebrew and Arabic text copies and searches as whole words (#1383) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) | | 17 | **East Asian typography**: hint-reliant DFKai-SB contours repaired (local Windows regression; no readiness estimate change); Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | `para.asianTypography` is live (Paragraph dialog Asian Typography tab, #323), automatic Asian/Latin and Asian/number spacing is laid out (#2358), and no East Asian item is left in the catalog's missing list; the remaining depth is not measured | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) | -| 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done; RTF paragraph-mark and character/paragraph formatting revisions done; RTF section/table and ODT formatting revisions left), sections (RTF and ODT import and export done, #1378, #1379), RTL. ODT sections landed on import (#1379): page-style changes and columned `text:section`s; ODT table column widths are read (they were dropped on every open). ODT export writes sections back (a master page per new-page section, a columned `text:section` per continuous one); RTF export writes every section (`\sect`, `\sectd`) | | ~2,100 (RTF) and ~1,600 (ODT) lines in `crates/formats`; RTF code pages (#896) and page setup (#902), ODT page setup (#829) landed; open PRs: RTF notes and comments (#1056), ODT notes and comments (#1057), ODT headers and footers (#873) | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | +| 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done; RTF paragraph-mark and character/paragraph formatting revisions done; RTF section/table and ODT formatting revisions left), sections (RTF and ODT import and export done, #1378, #1379), RTL. ODT sections landed on import (#1379): page-style changes and columned `text:section`s; ODT table column widths are read (they were dropped on every open). ODT export writes sections back (a master page per new-page section, a columned `text:section` per continuous one); RTF export writes every section (`\sect`, `\sectd`); RTF reads and writes WMF and EMF pictures (#4259) | | ~2,100 (RTF) and ~1,600 (ODT) lines in `crates/formats`; RTF code pages (#896) and page setup (#902), ODT page setup (#829) landed; open PRs: RTF notes and comments (#1056), ODT notes and comments (#1057), ODT headers and footers (#873) | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | | 18 | **Screen-reader access** to the document canvas: exposed, not yet tried with real screen readers | | The canvas describes paragraphs, headings, lists, tables, links, pictures and the caret to AccessKit (#488), tested headlessly; the ribbon's font name and size boxes, and the dropdown arrows beside them, say what they are (#3172); Windows Magnifier follows the caret, onto a new line too (#1119); no NVDA, VoiceOver or Orca run yet | Blind and low-vision users; public-sector procurement | 3–6 | [ui-parity.md](ui-parity.md) | | 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | Only the 188-page sample benchmark; scrolling a 1,096-page one-table document went from ~600 ms to ~2–5 ms a frame (#811), but opening it still lays out for seconds; opening a document with pictures no longer copies them (92 MB with 20 pictures: about 90 MB less); opening a document with many relationships now costs the size of its part and not that size times the number of relationships (1024 relationships and a 64 MiB part: 224 s → 0.2 s, #3411); a word with no break opportunity in it no longer lays out quadratically (1 MiB: 29.4 s → 0.35 s, #3412); saving a document with many content controls now costs the time the controls take (32,000 controls: 3.6 s → 0.1 s, #13) | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) | | 21 | **Mail merge data sources**: no Outlook/Contacts, Access or database sources, no email merge; CSV, typed lists (#247) and Excel/OpenDocument spreadsheets (#334, legacy .xls #778) only | | Select Recipients reads .csv/.tsv/.txt in any common encoding (UTF-8/16, GBK, Big5, Shift_JIS…), .xlsx/.xlsm, .xls and .ods; Edit Recipient List filters and sorts (#778), finds records and can show only the merged ones (#1576) | Office administrators | 4–8 | [target-app-parity.md](target-app-parity.md) | @@ -357,6 +357,7 @@ Recipient label generation (#4880) now includes all selected recipients across a | 2026-10-12 | trivial | #811: large-table scrolling fixed; performance gap (#20) narrowed | | 2026-10-12 | trivial | #13: radar, stock and other undrawn chart types show a placeholder instead of blank space (#998) | | 2026-10-19 | trivial | Personal templates in File › New and Save as Word Template (#2852); no gap entry tracked it | +| 2026-10-28 | trivial | Gap 19 narrowed: RTF reads and writes WMF and EMF pictures, chosen by their bytes (#4259); ranking unchanged | ## Layout validation fonts From f774f6f5c0ff25e420667e07628838355eb0d142 Mon Sep 17 00:00:00 2001 From: Kingsley Wong <7207924+datanerdie@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:49:56 +0800 Subject: [PATCH 31/73] #4548: comment ranges inside footnotes and endnotes are kept on read (#4550) * #4548: comment ranges inside footnotes and endnotes are kept on read read_opened read footnotes.xml and endnotes.xml before comments.xml, so a comment range inside a note looked its id up in an empty comment map and was dropped. Comments are now read first. read_comments uses nothing the notes set up. Regression test: tests/it/note_comments.rs (synthetic markup). Co-Authored-By: Claude Opus 5.5 * docs: file-format-parity row for comment ranges in notes (#4548) --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/docx/src/read.rs | 14 +++--- crates/docx/tests/it/main.rs | 1 + crates/docx/tests/it/note_comments.rs | 68 +++++++++++++++++++++++++++ docs/file-format-parity.md | 3 +- 4 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 crates/docx/tests/it/note_comments.rs diff --git a/crates/docx/src/read.rs b/crates/docx/src/read.rs index 878dd487dd..8f77ec63c5 100644 --- a/crates/docx/src/read.rs +++ b/crates/docx/src/read.rs @@ -210,18 +210,20 @@ pub(crate) fn read_opened(pkg: &Package) -> Result { if let Some(p) = part(rt::SETTINGS) { lenient("settings", r.read_settings(&p)); } - if let Some(p) = part(rt::FOOTNOTES) { - lenient("footnotes", r.read_notes(&p, true)); - } - if let Some(p) = part(rt::ENDNOTES) { - lenient("endnotes", r.read_notes(&p, false)); - } + // Comments before notes: a comment range inside a footnote or endnote resolves its id against + // the comments already read, and was dropped when the notes came first. if let Some(p) = part(rt::COMMENTS) { let ex = part(rt::COMMENTS_EX); let ids = part(rt::COMMENTS_IDS); let cex = part(rt::COMMENTS_EXTENSIBLE); lenient("comments", r.read_comments(&p, ex.as_deref(), ids.as_deref(), cex.as_deref())); } + if let Some(p) = part(rt::FOOTNOTES) { + lenient("footnotes", r.read_notes(&p, true)); + } + if let Some(p) = part(rt::ENDNOTES) { + lenient("endnotes", r.read_notes(&p, false)); + } // Bibliography sources live in a custom XML data store (`b:Sources`), related from the // package root or the main part; Word's own files sometimes leave the relationship out, so // the conventional `customXml/itemN.xml` names are tried too. Each part is read at most once. diff --git a/crates/docx/tests/it/main.rs b/crates/docx/tests/it/main.rs index c3661f2c31..d69bb70990 100644 --- a/crates/docx/tests/it/main.rs +++ b/crates/docx/tests/it/main.rs @@ -16,6 +16,7 @@ mod glossary; mod legacy_forms; mod ligatures; mod malformed; +mod note_comments; mod passthrough; mod positional_tabs; mod privacy; diff --git a/crates/docx/tests/it/note_comments.rs b/crates/docx/tests/it/note_comments.rs new file mode 100644 index 0000000000..d9dfba865d --- /dev/null +++ b/crates/docx/tests/it/note_comments.rs @@ -0,0 +1,68 @@ +//! Comments anchored inside footnotes and endnotes keep their ranges on read. All markup here is +//! synthetic, written from ECMA-376 §17.13.4 and §17.11. + +use wordcraft_doc::para::{NoteKind, OBJ}; +use wordcraft_doc::{Document, InlineObject}; + +use crate::fixtures::zip; + +const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#; +const ROOT_RELS: &str = r#""#; +const DOC_RELS: &str = r#""#; + +fn note(tag: &str, mark: &str, comment: u32, text: &str) -> String { + format!( + r#" {text}"# + ) +} + +fn read_doc() -> Document { + let doc = format!( + r#"Body"# + ); + let footnotes = format!(r#"{}"#, note("footnote", "footnoteRef", 7, "Foot.")); + let endnotes = format!(r#"{}"#, note("endnote", "endnoteRef", 8, "End.")); + let comments = format!( + r#"On the footnoteOn the endnote"# + ); + let bytes = zip(&[ + ("_rels/.rels", ROOT_RELS.as_bytes()), + ("word/document.xml", doc.as_bytes()), + ("word/_rels/document.xml.rels", DOC_RELS.as_bytes()), + ("word/footnotes.xml", footnotes.as_bytes()), + ("word/endnotes.xml", endnotes.as_bytes()), + ("word/comments.xml", comments.as_bytes()), + ]); + wordcraft_docx::read(&bytes).unwrap() +} + +/// comments.xml has to be read before the notes: a note's `w:commentRangeStart` resolves its id +/// against the comments already read, and was dropped when the notes came first. +#[test] +fn comment_ranges_inside_notes_are_kept() { + let d = read_doc(); + let body = d.body[0].as_para().unwrap(); + let refs: Vec<(NoteKind, u32)> = body + .objects + .iter() + .filter_map(|o| match o { + InlineObject::NoteRef { kind, id, .. } => Some((*kind, *id)), + _ => None, + }) + .collect(); + assert_eq!(refs.len(), 2, "{:?}", body.objects); + for ((kind, id), (author, text)) in refs.into_iter().zip([("Ann", " Foot."), ("Bob", " End.")]) { + let p = d.parts.get(&id).unwrap().blocks[0].as_para().unwrap(); + let comment = match &p.objects[..] { + [InlineObject::NoteRef { kind: k, id: n, .. }, InlineObject::CommentStart { id: c }, InlineObject::CommentEnd { id: e }] + if *k == kind && *n == id && c == e => + { + *c + } + o => panic!("{kind:?} {id}: {o:?}"), + }; + assert_eq!(d.comments.get(&comment).unwrap().author, author); + assert_eq!(p.text.chars().filter(|c| *c == OBJ).count(), 3); + assert_eq!(p.plain_text(), text); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c1b9def012..93eeac5e16 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-29 · **Change:** trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-29 · **Change:** trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats 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; @@ -271,6 +271,7 @@ Repeated DOCX style IDs use the last complete definition. The original regressio | Date | Change | Summary | |---|---|---| +| 2026-10-29 | trivial | DOCX: comments.xml is read before footnotes and endnotes, so a comment range inside a note keeps its comment (it was dropped) (#4548) | | 2026-10-28 | trivial | #4658: bound Markdown reference-label rejection work, preserving literal source and ordinary link matching | | 2026-10-17 | trivial | macOS launch arguments (`-psn_*`, `-NS*`, `-Apple*`) are no longer opened as files; a file that fails to open (command line, open event, drop) names the file and the cause on the status bar and in the log (#1103) | | 2026-10-29 | trivial | HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged. | From e855daf27908094d90f603e474cd515c4be36788 Mon Sep 17 00:00:00 2001 From: Diego Guerini <106814760+D13GOOOO@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:50:27 +0100 Subject: [PATCH 32/73] Built-in heading styles have their outline level without w:outlineLvl, so the Navigation pane lists them (#4696) (#4706) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * #4696: built-in heading styles have their outline level without w:outlineLvl Some producers write "heading 1"…"heading 9" in styles.xml without w:outlineLvl. Word gives those built-in styles their outline level anyway (checked: Word for Windows reports OutlineLevel 1 and 2, whatever the style id), so their headings show in the Navigation pane. WordCraft read no level, and the Headings tab stayed empty. Co-Authored-By: Claude Opus 5.5 * #4696: review — w:customStyle="on" marks a custom style too A custom style named "heading 4" with customStyle="on" (ST_OnOff) was taken for Word's built-in heading and got an outline level. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/docx/src/read/styles.rs | 19 ++++++++- crates/docx/tests/it/heading_levels.rs | 57 ++++++++++++++++++++++++++ crates/docx/tests/it/main.rs | 1 + docs/file-format-parity.md | 5 ++- 4 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 crates/docx/tests/it/heading_levels.rs diff --git a/crates/docx/src/read/styles.rs b/crates/docx/src/read/styles.rs index 55d01e8a49..cf9bccf51b 100644 --- a/crates/docx/src/read/styles.rs +++ b/crates/docx/src/read/styles.rs @@ -42,6 +42,13 @@ pub(crate) fn ui_style_name(n: &str) -> String { n.to_string() } +/// The outline level (0 for Heading 1) of Word's built-in style named `heading 1` … `heading 9`. +fn builtin_heading_level(name: &str) -> Option { + let name = name.trim(); + let digit = name.get(..8).filter(|p| p.eq_ignore_ascii_case("heading ")).and(name.get(8..))?; + digit.parse::().ok().filter(|d| digit.len() == 1 && (1..=9).contains(d)).map(|d| d - 1) +} + impl Reader<'_> { pub(super) fn read_styles(&mut self, path: &str) -> Result<(), DocxError> { /// Largest `w:latentStyles` kept (Word's has ~380 exceptions, ~40 KB). @@ -115,7 +122,7 @@ impl Reader<'_> { priority: s.child_val("w:uiPriority").and_then(u32_of), quick: flag(s, "w:qFormat").unwrap_or(false), hidden: flag(s, "w:semiHidden").unwrap_or(false) || flag(s, "w:hidden").unwrap_or(false), - builtin: !s.attr("w:customStyle").is_some_and(|v| v == "1" || v == "true"), + builtin: !s.attr("w:customStyle").is_some_and(|v| matches!(v.trim(), "1" | "true" | "on")), locked: flag(s, "w:locked").unwrap_or(false), // Only where the writer couldn't tell from the id (it flags the English ids). default: s.attr("w:default").is_some_and(|v| v == "1" || v == "true") @@ -131,6 +138,16 @@ impl Reader<'_> { if let Some(p) = s.child("w:pPr") { st.para = self.pc.ppr(p).0; } + // Word gives its built-in heading styles their outline level whether or not the file + // writes `w:outlineLvl` (some producers leave it out): "heading 2" is level 2 in the + // Navigation pane and the table of contents, whatever its id (#4696). + if kind == StyleKind::Paragraph + && st.builtin + && st.para.outline_level.is_none() + && let Some(level) = s.child_val("w:name").and_then(builtin_heading_level) + { + st.para.outline_level = Some(level); + } if let Some(rp) = s.child("w:rPr") { st.chr = self.pc.rpr(rp); } diff --git a/crates/docx/tests/it/heading_levels.rs b/crates/docx/tests/it/heading_levels.rs new file mode 100644 index 0000000000..e32059dce8 --- /dev/null +++ b/crates/docx/tests/it/heading_levels.rs @@ -0,0 +1,57 @@ +//! #4696: Word's built-in heading styles have their outline level even when the file's +//! `styles.xml` doesn't write `w:outlineLvl` (some producers leave it out). Word for Windows, +//! opening such a file, reports `OutlineLevel` 1 and 2 for "heading 1" and "heading 2" (whatever +//! their style ids) and body text for a custom style. +use std::io::Write; +use wordcraft_doc::Paragraph; + +const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + +fn package(styles: &str, body: &str) -> Vec { + let root = r#""#; + let rels = r#""#; + let doc = format!(r#"{body}"#); + let styles = format!(r#"{styles}"#); + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, body) in + [("_rels/.rels", root), ("word/document.xml", doc.as_str()), ("word/_rels/document.xml.rels", rels), ("word/styles.xml", styles.as_str())] + { + zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zw.write_all(body.as_bytes()).unwrap(); + } + zw.finish().unwrap().into_inner() +} + +fn para(style: &str, text: &str) -> String { + format!(r#"{text}"#) +} + +fn levels(d: &wordcraft_doc::Document) -> Vec> { + d.body.iter().filter_map(|b| b.as_para()).map(|p: &Paragraph| d.styles.resolve_para(&p.props).outline_level).collect() +} + +#[test] +fn built_in_heading_styles_have_their_outline_level_without_outline_lvl() { + let styles = r#" + + + + + + + + "#; + let body = ["Heading1", "Titolo2", "Heading9", "Below", "MyHead", "Heading10", "Fake4", "Heading3", "Normal"] + .iter() + .map(|s| para(s, "x")) + .collect::(); + let d = wordcraft_docx::read(&package(styles, &body)).unwrap(); + // Heading 1, 2 and 9 by name; a style based on a heading inherits its level; a custom style, + // "heading 10", a custom style named "heading 4" (`w:customStyle="on"`) and Normal have none; a + // level the file gives is kept. + let want = vec![Some(0), Some(1), Some(8), Some(1), None, None, None, Some(5), None]; + assert_eq!(levels(&d), want); + // The level survives a save and reopen. + let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + assert_eq!(levels(&again), want); +} diff --git a/crates/docx/tests/it/main.rs b/crates/docx/tests/it/main.rs index d69bb70990..cff24dab69 100644 --- a/crates/docx/tests/it/main.rs +++ b/crates/docx/tests/it/main.rs @@ -13,6 +13,7 @@ mod east_asian_lang; mod east_asian_layout; mod fixtures; mod glossary; +mod heading_levels; mod legacy_forms; mod ligatures; mod malformed; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 93eeac5e16..385eb84376 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-29 · **Change:** trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-29 · **Change:** trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats 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; @@ -75,7 +75,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Feature | Read | Write | Notes | |---|---|---|---| -| Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including a Styles gallery for files that mark no style `w:qFormat` (LibreOffice: Word's usual quick styles are added, #1238), complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…); runs that mix right-to-left and left-to-right text are split on save where the direction changes, and the right-to-left pieces get `w:rtl` and a bidi language (`w:lang w:bidi`, e.g. he-IL), so Word lays Hebrew around Latin names, numbers and e-mail addresses out as WordCraft does and doesn't call it Arabic (#1543, checked in Word for Mac 16.113.4), East Asian fonts kept apart from Latin (#111); independent complex-script formatting inherited through defaults/styles and preserved over repeated saves (`complex_script_inheritance.rs`), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats and Arabic letters (`arabicAlpha`, `arabicAbjad`) are drawn, the rest (Hebrew, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are) | +| Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including a Styles gallery for files that mark no style `w:qFormat` (LibreOffice: Word's usual quick styles are added, #1238), complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…); runs that mix right-to-left and left-to-right text are split on save where the direction changes, and the right-to-left pieces get `w:rtl` and a bidi language (`w:lang w:bidi`, e.g. he-IL), so Word lays Hebrew around Latin names, numbers and e-mail addresses out as WordCraft does and doesn't call it Arabic (#1543, checked in Word for Mac 16.113.4), East Asian fonts kept apart from Latin (#111); independent complex-script formatting inherited through defaults/styles and preserved over repeated saves (`complex_script_inheritance.rs`), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats and Arabic letters (`arabicAlpha`, `arabicAbjad`) are drawn, the rest (Hebrew, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are). Word's built-in heading styles (`heading 1`…`heading 9`, by name, whatever their id) have their outline level when `styles.xml` leaves out `w:outlineLvl`, as in Word, so their paragraphs show in the Navigation pane and the table of contents (#4696) | | Tables incl. floating (`w:tblpPr`), table styles and conditional formatting | ✅ | ✅ | Custom table styles round-trip (#256); right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) round-trip (#362) | | Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…), East Asian fonts kept apart from Latin (#111); independent complex-script formatting inherited through defaults/styles and preserved over repeated saves (`complex_script_inheritance.rs`), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats and Arabic letters (`arabicAlpha`, `arabicAbjad`) are drawn, the rest (Hebrew, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are) | | Positional tabs (`w:ptab`: alignment, margin or indent, leader) | ✅ | ✅ | Laid out at their place whatever the tab stops; one with an alignment or base the spec doesn't define reads as a plain tab (#2367). `crates/docx/tests/it/positional_tabs.rs` | @@ -271,6 +271,7 @@ Repeated DOCX style IDs use the last complete definition. The original regressio | Date | Change | Summary | |---|---|---| +| 2026-10-28 | trivial | Built-in heading styles (`heading 1`…`heading 9`) have their outline level when `styles.xml` leaves out `w:outlineLvl`, as in Word: their headings show in the Navigation pane (#4696) | | 2026-10-29 | trivial | DOCX: comments.xml is read before footnotes and endnotes, so a comment range inside a note keeps its comment (it was dropped) (#4548) | | 2026-10-28 | trivial | #4658: bound Markdown reference-label rejection work, preserving literal source and ordinary link matching | | 2026-10-17 | trivial | macOS launch arguments (`-psn_*`, `-NS*`, `-Apple*`) are no longer opened as files; a file that fails to open (command line, open event, drop) names the file and the cause on the status bar and in the log (#1103) | From 4abb77aa70357619c06f9fc57ae08e7f3ec32835 Mon Sep 17 00:00:00 2001 From: Rees <109076338+OPUZZLES@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:50:49 +0100 Subject: [PATCH 33/73] DOCX: avoid repeated searches when writing comment IDs (#4841) Co-authored-by: Brandon Thomas --- ROADMAP.md | 5 ++- crates/docx/src/write/parts.rs | 8 ++-- crates/docx/src/write/settings.rs | 4 +- crates/docx/tests/it/comment_save_cost.rs | 55 +++++++++++++++++++++++ crates/docx/tests/it/main.rs | 1 + crates/docx/tests/it/passthrough.rs | 48 ++++++++++++++++++++ docs/file-format-parity.md | 5 ++- docs/gaps.md | 5 ++- 8 files changed, 122 insertions(+), 9 deletions(-) create mode 100644 crates/docx/tests/it/comment_save_cost.rs diff --git a/ROADMAP.md b/ROADMAP.md index 146ecedcad..72e4952443 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-29 · **Change:** trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; 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: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; 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: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: 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: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #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 fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; 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: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; 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: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: 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: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #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 fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously 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. @@ -140,6 +140,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-10-29: Saving a DOCX with many comments no longer searches all comment paragraph IDs and all reserved durable IDs for each comment. Paragraph IDs use their existing sorted order; durable-ID reservations are indexed once. Parent links, resolved flags, authored text and existing ID rules are unchanged. + - 2026-10-28: RTF import keeps pictures stored as Windows Metafiles (`\wmetafile8`, as Word writes a pasted or floating picture) and Enhanced Metafiles (`\emfblip`); they were dropped, so such a document opened without its pictures. They are drawn by the metafile renderer, saved to .docx as `.wmf`/`.emf` media, and saved to .rtf as metafiles again (RTF export dropped them too). (#4259) - 2026-10-27: File › Options › Proofing › AutoCorrect Options opens with no document open (#4585). On the Start screen, or after closing the last document, the button did nothing: AutoCorrect wasn't among the commands allowed without a document. Its options are user preferences, so changes made there are kept for the next document. - 2026-10-29: Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Labels → New Document with one label per recipient includes every selected address, with unused labels blank on the final sheet. Fixed-text and single-label output remain one sheet. Finish & Merge template expansion is separate. @@ -658,6 +660,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-29 | trivial | DOCX comment IDs use ordered paragraph lookup and one durable-ID reservation set; threading and ID rules unchanged | | 2026-10-28 | trivial | #4658: bound Markdown reference-label rejection work, preserving literal source and ordinary link matching | | 2026-10-29 | trivial | Progress log: Insert › Shapes icon grid (#4349) | | 2026-10-29 | trivial | HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged. | diff --git a/crates/docx/src/write/parts.rs b/crates/docx/src/write/parts.rs index 7cea957d46..39c2e58d24 100644 --- a/crates/docx/src/write/parts.rs +++ b/crates/docx/src/write/parts.rs @@ -429,13 +429,15 @@ fn write_comments( ("xmlns:mc", "http://schemas.openxmlformats.org/markup-compatibility/2006"), ], ); - let pid_of = |cid: u32| comment_paras.iter().find(|(c, _)| *c == cid).map(|(_, p)| p.clone()); + // Assigned in BTreeMap comment-ID order above, including gaps for missing parts. + let pid_of = + |cid: u32| comment_paras.binary_search_by_key(&cid, |(c, _)| *c).ok().and_then(|i| comment_paras.get(i)).map(|(_, p)| p.as_str()); for (cid, c) in &doc.comments { let Some(pid) = pid_of(*cid) else { continue }; let done = if c.resolved { "1" } else { "0" }; match c.parent.and_then(pid_of) { - Some(parent) => w.empty("w15:commentEx", &[("w15:paraId", &pid), ("w15:paraIdParent", &parent), ("w15:done", done)]), - None => w.empty("w15:commentEx", &[("w15:paraId", &pid), ("w15:done", done)]), + Some(parent) => w.empty("w15:commentEx", &[("w15:paraId", pid), ("w15:paraIdParent", parent), ("w15:done", done)]), + None => w.empty("w15:commentEx", &[("w15:paraId", pid), ("w15:done", done)]), } } w.close("w15:commentsEx"); diff --git a/crates/docx/src/write/settings.rs b/crates/docx/src/write/settings.rs index df4e77fa64..1c7d20e53b 100644 --- a/crates/docx/src/write/settings.rs +++ b/crates/docx/src/write/settings.rs @@ -172,6 +172,8 @@ pub(super) fn write_comment_ids( if comment_paras.is_empty() { return; } + // Keep the existing exact-string reservation rule, including comments without a written part. + let reserved: std::collections::HashSet<&str> = doc.comments.values().filter_map(|c| c.durable_id.as_deref()).collect(); let mut used: std::collections::HashSet = std::collections::HashSet::new(); let mut next = 0x1000_0000u32; let mut rows: Vec<(String, String, Option)> = Vec::new(); @@ -183,7 +185,7 @@ pub(super) fn write_comment_ids( None => loop { next = next.saturating_add(1); let d = format!("{next:08X}"); - if !used.contains(&d) && !doc.comments.values().any(|o| o.durable_id.as_deref() == Some(d.as_str())) { + if !used.contains(&d) && !reserved.contains(d.as_str()) { break d; } }, diff --git a/crates/docx/tests/it/comment_save_cost.rs b/crates/docx/tests/it/comment_save_cost.rs new file mode 100644 index 0000000000..2716578b29 --- /dev/null +++ b/crates/docx/tests/it/comment_save_cost.rs @@ -0,0 +1,55 @@ +//! Saving large comment threads must not search all comments for each emitted ID. + +use wordcraft_doc::{Comment, Document, Paragraph, Part, PartKind, para_block}; + +fn comments(n: u32, imported: bool) -> Document { + let mut d = Document::from_text("Original comment save fixture."); + for i in 0..n { + // Sparse IDs and replies to a later comment also exercise paragraph-ID lookup order. + let id = i * 3 + 7; + d.parts.insert(id, Part { kind: PartKind::Comment, blocks: vec![para_block(Paragraph::new())], ..Default::default() }); + d.comments.insert( + id, + Comment { part: id, parent: (i + 1 < n).then_some(id + 3), durable_id: imported.then(|| format!("{:08X}", i + 1)), ..Default::default() }, + ); + } + d +} + +fn cpu_cost(mut f: impl FnMut()) -> f64 { + let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::().ok(); + let (wall, before) = (std::time::Instant::now(), ran()); + f(); + match (before, ran()) { + (Some(a), Some(b)) if b > a => (b - a) as f64 * 1e-9, + _ => wall.elapsed().as_secs_f64(), + } +} + +#[test] +fn many_comments_save_without_quadratic_id_searches() { + for imported in [true, false] { + let small = comments(4_000, imported); + let big = comments(32_000, imported); + let save = |d: &Document| drop(std::hint::black_box(wordcraft_docx::write(d).unwrap())); + save(&small); + save(&big); + let mut ratios = Vec::new(); + // Alternating inputs and thread CPU time leave scheduler delays out on Linux. Elsewhere + // use elapsed time; retry ratios far from linear before declaring a regression. + for _ in 0..3 { + let (mut s, mut b) = (f64::INFINITY, f64::INFINITY); + for _ in 0..3 { + s = s.min(cpu_cost(|| (0..8).for_each(|_| save(&small))) / 8.0); + b = b.min(cpu_cost(|| save(&big))); + } + let ratio = b / s.max(1e-9); + eprintln!("comments imported={imported}: {s:.4}s -> {b:.4}s, ratio {ratio:.2}"); + if ratio < 24.0 { + break; + } + ratios.push(ratio); + } + assert!(ratios.len() < 3, "8x comments took {ratios:?}x CPU: repeated ID searches"); + } +} diff --git a/crates/docx/tests/it/main.rs b/crates/docx/tests/it/main.rs index cff24dab69..340e7e1fc6 100644 --- a/crates/docx/tests/it/main.rs +++ b/crates/docx/tests/it/main.rs @@ -3,6 +3,7 @@ mod bidi_runs; mod cell_margin_inheritance; mod char_units; mod citations; +mod comment_save_cost; mod complex_script_inheritance; mod content_control_save_cost; mod content_controls; diff --git a/crates/docx/tests/it/passthrough.rs b/crates/docx/tests/it/passthrough.rs index d5182922b2..cedb8d12ac 100644 --- a/crates/docx/tests/it/passthrough.rs +++ b/crates/docx/tests/it/passthrough.rs @@ -443,3 +443,51 @@ fn embedded_fonts_load_and_save_when_the_package_spells_the_font_table_in_capita assert_eq!(fonts.len(), 1, "the font survives a save: {:?}", files.keys()); assert_eq!(fonts[0].data, font_bytes); } + +#[test] +fn comment_ids_keep_sparse_threads_reservations_and_missing_parts() { + use wordcraft_doc::{Comment, Document, Paragraph, Part, PartKind, para_block}; + let mut doc = Document::from_text("Original thread fixture."); + // New IDs must skip the exact durable ID reserved by a later comment, even one whose + // missing part keeps it out of commentsIds.xml. Duplicate imported IDs are reassigned. + let rows = [ + (3, None, Some(90), false, true), + (11, Some("5a1b2c3d"), Some(90), true, true), + (25, Some("5A1B2C3D"), Some(999), false, true), + (90, Some("10000001"), None, true, true), + (999, Some("10000002"), None, false, false), + (u32::MAX, Some("not-hex"), Some(3), false, true), + ]; + for (id, durable, parent, resolved, has_part) in rows { + let part = id; + if has_part { + doc.parts.insert( + part, + Part { + kind: PartKind::Comment, + blocks: vec![para_block(Paragraph::with_text(&format!("Comment {id} β"), Default::default()))], + ..Default::default() + }, + ); + } + doc.comments.insert(id, Comment { part, parent, resolved, durable_id: durable.map(str::to_string), ..Default::default() }); + } + let files = save(&doc); + let (ids, _) = written_ids(&files); + assert_eq!(ids.iter().map(|(_, id)| id.as_str()).collect::>(), ["10000003", "5A1B2C3D", "10000004", "10000001", "10000005"]); + let ex = text(&files, "word/commentsExtended.xml"); + assert_eq!(ex.matches(" **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-29 · **Change:** trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-29 · **Change:** trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats 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; @@ -84,7 +84,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Tables incl. floating (`w:tblpPr`), table styles and conditional formatting | ✅ | ✅ | Row table border exceptions (`w:tblPrEx/w:tblBorders`) round-trip (`row_table_borders_round_trip`); other row table exceptions are not preserved. Custom table styles round-trip (#256); right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) round-trip (#362) | | Headers/footers (first, even/odd), page borders, line numbers, gutter, mirror margins | ✅ | ✅ | | | Hyphenation settings (`w:autoHyphenation`, `w:hyphenationZone`, `w:doNotHyphenateCaps`, `w:consecutiveHyphenLimit`) | ✅ | ✅ | Zone, capitals and limit kept and honoured by layout (#407) | -| Footnotes, endnotes, comments | ✅ | ✅ | The document's own note separators and continuation notice (`w:type` `separator`, `continuationSeparator`, `continuationNotice`) round-trip as ids -1, 0 and 1 (#352); note options (`w:pos`, `w:numFmt`, `w:numStart`, `w:numRestart`) round-trip in the settings and in each `w:sectPr` (#385); `commentsExtended` written; `commentsIds` (durable ids) and `commentsExtensible` (UTC dates) read and regenerated consistently with the written paragraph ids; `people.xml` carried verbatim | +| Footnotes, endnotes, comments | ✅ | ✅ | The document's own note separators and continuation notice (`w:type` `separator`, `continuationSeparator`, `continuationNotice`) round-trip as ids -1, 0 and 1 (#352); note options (`w:pos`, `w:numFmt`, `w:numStart`, `w:numRestart`) round-trip in the settings and in each `w:sectPr` (#385); `commentsExtended` written; `commentsIds` (durable ids) and `commentsExtensible` (UTC dates) read and regenerated consistently with the written paragraph ids; `people.xml` carried verbatim; saving comment IDs uses sorted paragraph-ID lookup and one borrowed durable-ID reservation set, retaining parent links and ID allocation rules | | Tracked insertions and deletions (`w:ins`/`w:del`), paragraph-mark revisions | ✅ | ✅ | #125, #244 | | Formatting revisions (`w:rPrChange`, `w:pPrChange`, `w:sectPrChange`, `w:tblPrChange`, `w:trPrChange`, `w:tcPrChange`, `w:numberingChange`) | ✅ | ✅ | #41: kept on open and save, schema order (change element last); changes inside `styles.xml` are dropped. RTF reads and writes character and paragraph changes (`\oldcprops`, `\oldpprops`, with the formatting the flow model knows, list membership included); as Word writes them, `\oldcprops` holds only the properties that changed (with `\crauth`/`\crdate` inside it) and `\oldpprops` the full old set from `\pard`; a run without a direct font or size has its paragraph style's written there (heading, Title, Quote, Code), Normal's as `\f0\fs24`; on import a heading's, Title's, Quote's or Code run's size, bold, italic or code font counts as the style's only when it equals the file's own stylesheet entry (`\b`, `\i`, `\fs`, `\f`, followed through `\sbasedon`; what the chain doesn't set is RTF's default, not bold and 12 pt, so Word 365's non-bold headings keep a bold set by hand; WordCraft's style values only where the chain sets no character formatting at all, and the code font where it sets no `\f`), so a size or bold set by hand there is kept, before and after a tracked change, and Reject gives it back; a formatting change on the paragraph mark itself and section, table, row and cell changes are dropped. ODT ignores them | | Move tracking (`w:moveFrom`/`w:moveTo`, `w:moveFromRangeStart/End`, `w:moveToRangeStart/End`), moved paragraph marks | ✅ | ✅ | #400: both ends linked by the range name; ranges rewritten around each moved stretch (ids renumbered); a move missing an end or a range end opens as a plain deletion/insertion. `w:doNotTrackMoves` (Track moves off) round-trips; with it a tracked cut and paste records a plain deletion and insertion. ODT/PDF export the result (moved-away text hidden like deletions); RTF writes a move as a plain tracked deletion and insertion | @@ -271,6 +271,7 @@ Repeated DOCX style IDs use the last complete definition. The original regressio | Date | Change | Summary | |---|---|---| +| 2026-10-29 | trivial | DOCX comment IDs use ordered paragraph lookup and one durable-ID reservation set; threading and ID rules unchanged | | 2026-10-28 | trivial | Built-in heading styles (`heading 1`…`heading 9`) have their outline level when `styles.xml` leaves out `w:outlineLvl`, as in Word: their headings show in the Navigation pane (#4696) | | 2026-10-29 | trivial | DOCX: comments.xml is read before footnotes and endnotes, so a comment range inside a note keeps its comment (it was dropped) (#4548) | | 2026-10-28 | trivial | #4658: bound Markdown reference-label rejection work, preserving literal source and ordinary link matching | diff --git a/docs/gaps.md b/docs/gaps.md index 19f1e2be96..00ff622812 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (gap 19: RTF reads and writes WMF and EMF pictures, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; 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: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (DOCX comment-ID save lookups); previously trivial (gap 19: RTF reads and writes WMF and EMF pictures, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; 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: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **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-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (#7: Custom Document Properties dialog, #2982; previously 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: 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 the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 @@ -83,7 +83,7 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc | 17 | **East Asian typography**: hint-reliant DFKai-SB contours repaired (local Windows regression; no readiness estimate change); Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | `para.asianTypography` is live (Paragraph dialog Asian Typography tab, #323), automatic Asian/Latin and Asian/number spacing is laid out (#2358), and no East Asian item is left in the catalog's missing list; the remaining depth is not measured | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) | | 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done; RTF paragraph-mark and character/paragraph formatting revisions done; RTF section/table and ODT formatting revisions left), sections (RTF and ODT import and export done, #1378, #1379), RTL. ODT sections landed on import (#1379): page-style changes and columned `text:section`s; ODT table column widths are read (they were dropped on every open). ODT export writes sections back (a master page per new-page section, a columned `text:section` per continuous one); RTF export writes every section (`\sect`, `\sectd`); RTF reads and writes WMF and EMF pictures (#4259) | | ~2,100 (RTF) and ~1,600 (ODT) lines in `crates/formats`; RTF code pages (#896) and page setup (#902), ODT page setup (#829) landed; open PRs: RTF notes and comments (#1056), ODT notes and comments (#1057), ODT headers and footers (#873) | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | | 18 | **Screen-reader access** to the document canvas: exposed, not yet tried with real screen readers | | The canvas describes paragraphs, headings, lists, tables, links, pictures and the caret to AccessKit (#488), tested headlessly; the ribbon's font name and size boxes, and the dropdown arrows beside them, say what they are (#3172); Windows Magnifier follows the caret, onto a new line too (#1119); no NVDA, VoiceOver or Orca run yet | Blind and low-vision users; public-sector procurement | 3–6 | [ui-parity.md](ui-parity.md) | -| 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | Only the 188-page sample benchmark; scrolling a 1,096-page one-table document went from ~600 ms to ~2–5 ms a frame (#811), but opening it still lays out for seconds; opening a document with pictures no longer copies them (92 MB with 20 pictures: about 90 MB less); opening a document with many relationships now costs the size of its part and not that size times the number of relationships (1024 relationships and a 64 MiB part: 224 s → 0.2 s, #3411); a word with no break opportunity in it no longer lays out quadratically (1 MiB: 29.4 s → 0.35 s, #3412); saving a document with many content controls now costs the time the controls take (32,000 controls: 3.6 s → 0.1 s, #13) | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) | +| 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | DOCX comment saves avoid repeated paragraph-ID and durable-ID reservation searches; large threaded-comment fixtures retain metadata through save/reopen. Only the 188-page sample benchmark; scrolling a 1,096-page one-table document went from ~600 ms to ~2–5 ms a frame (#811), but opening it still lays out for seconds; opening a document with pictures no longer copies them (92 MB with 20 pictures: about 90 MB less); opening a document with many relationships now costs the size of its part and not that size times the number of relationships (1024 relationships and a 64 MiB part: 224 s → 0.2 s, #3411); a word with no break opportunity in it no longer lays out quadratically (1 MiB: 29.4 s → 0.35 s, #3412); saving a document with many content controls now costs the time the controls take (32,000 controls: 3.6 s → 0.1 s, #13) | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) | | 21 | **Mail merge data sources**: no Outlook/Contacts, Access or database sources, no email merge; CSV, typed lists (#247) and Excel/OpenDocument spreadsheets (#334, legacy .xls #778) only | | Select Recipients reads .csv/.tsv/.txt in any common encoding (UTF-8/16, GBK, Big5, Shift_JIS…), .xlsx/.xlsm, .xls and .ods; Edit Recipient List filters and sorts (#778), finds records and can show only the merged ones (#1576) | Office administrators | 4–8 | [target-app-parity.md](target-app-parity.md) | | 22 | **Ecosystem**: no VBA execution (macros preserved only), no Office add-ins, no cloud storage, no real-time co-authoring | | `.docm` keeps macros (#172) | Enterprise users with macro templates; teams | 40–80 + owner decisions | [target-app-parity.md](target-app-parity.md) | | 23 | **PDF Reflow** (open a PDF as an editable document, Word for Windows) | | | Users who edit PDFs they receive | 30–50 | [file-format-parity.md](file-format-parity.md) | @@ -152,6 +152,7 @@ Recipient label generation (#4880) now includes all selected recipients across a | Date | Change | Summary | |---|---|---| +| 2026-10-29 | trivial | DOCX comment IDs use ordered paragraph lookup and one durable-ID reservation set; threading and ID rules unchanged | | 2026-10-28 | trivial | #4658: bound Markdown reference-label rejection work, preserving literal source and ordinary link matching | | 2026-10-29 | trivial | Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Filtered/sorted order, product geometry and single-label mode retained; readiness estimates unchanged. | | 2026-10-29 | trivial | HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged. | From 48f5a905b11d423a8667e572f15d4edc2d3e22e0 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:51:39 -0500 Subject: [PATCH 34/73] DOCX: keep run-level snapToGrid and webHidden when saving (#4863) Co-authored-by: Brandon Thomas --- crates/doc/src/props/char_props.rs | 16 +++++++ crates/docx/src/read/props.rs | 2 + crates/docx/src/write/props.rs | 2 + crates/docx/tests/it/main.rs | 1 + crates/docx/tests/it/roundtrip.rs | 2 + crates/docx/tests/it/run_props.rs | 74 ++++++++++++++++++++++++++++++ docs/file-format-parity.md | 3 +- 7 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 crates/docx/tests/it/run_props.rs diff --git a/crates/doc/src/props/char_props.rs b/crates/doc/src/props/char_props.rs index b8ef433c52..32757edc89 100644 --- a/crates/doc/src/props/char_props.rs +++ b/crates/doc/src/props/char_props.rs @@ -273,6 +273,12 @@ pub struct CharProps { pub small_caps: Option, #[serde(skip_serializing_if = "Option::is_none")] pub hidden: Option, + /// Hidden in Web Layout (`w:webHidden`); distinct from `w:vanish` (`hidden`). + #[serde(skip_serializing_if = "Option::is_none")] + pub web_hidden: Option, + /// Snap characters to the document grid (`w:snapToGrid`). Unset inherits; Word's default is on. + #[serde(skip_serializing_if = "Option::is_none")] + pub snap_to_grid: Option, /// Character spacing (expanded/condensed), points. #[serde(skip_serializing_if = "Option::is_none")] pub spacing: Option, @@ -448,6 +454,7 @@ impl CharProps { /// overlaid on `base`. pub fn minus(mut self, base: &CharProps) -> CharProps { self.minus_text_style(base); + self.minus_run_visibility(base); self.minus_script_style(base); self.minus_document_marks(base); self @@ -458,6 +465,10 @@ impl CharProps { shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl); } + fn minus_run_visibility(&mut self, base: &CharProps) { + drop_same_fields!(self, base; web_hidden, snap_to_grid); + } + fn minus_script_style(&mut self, base: &CharProps) { drop_same_fields!(self, base; cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout); } @@ -472,6 +483,7 @@ impl CharProps { /// format both scripts must set both values explicitly. pub fn overlay(&mut self, patch: &CharProps) { self.overlay_text_style(patch); + self.overlay_run_visibility(patch); self.overlay_script_style(patch); self.overlay_document_marks(patch); if let Some(p) = &patch.text_effects { @@ -484,6 +496,10 @@ impl CharProps { shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl); } + fn overlay_run_visibility(&mut self, patch: &CharProps) { + overlay_fields!(self, patch; web_hidden, snap_to_grid); + } + fn overlay_script_style(&mut self, patch: &CharProps) { overlay_fields!(self, patch; cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout); } diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index 57223c22f2..ed5ccf0d03 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -258,6 +258,8 @@ impl PropCtx { "w:imprint" => c.engrave = Some(on_off(k)), "w:noProof" => c.no_proof = Some(on_off(k)), "w:vanish" => c.hidden = Some(on_off(k)), + "w:webHidden" => c.web_hidden = Some(on_off(k)), + "w:snapToGrid" => c.snap_to_grid = Some(on_off(k)), "w:rtl" => c.rtl = Some(on_off(k)), "w:color" => { c.color = match (self.theme_color(k, "w:themeColor", "w:themeTint", "w:themeShade"), k.attr("w:val")) { diff --git a/crates/docx/src/write/props.rs b/crates/docx/src/write/props.rs index b67ee6b076..57400cd059 100644 --- a/crates/docx/src/write/props.rs +++ b/crates/docx/src/write/props.rs @@ -53,7 +53,9 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) { toggle(w, "w:emboss", c.emboss); toggle(w, "w:imprint", c.engrave); toggle(w, "w:noProof", c.no_proof); + toggle(w, "w:snapToGrid", c.snap_to_grid); toggle(w, "w:vanish", c.hidden); + toggle(w, "w:webHidden", c.web_hidden); match c.color { Some(TextColor::Auto) => w.val("w:color", "auto"), Some(TextColor::Rgb(rgb)) => w.val("w:color", &rgb.hex()), diff --git a/crates/docx/tests/it/main.rs b/crates/docx/tests/it/main.rs index 340e7e1fc6..5cae0de3e1 100644 --- a/crates/docx/tests/it/main.rs +++ b/crates/docx/tests/it/main.rs @@ -24,6 +24,7 @@ mod positional_tabs; mod privacy; mod roundtrip; mod row_property_exceptions; +mod run_props; mod section_form_protection; mod section_page_number_type; mod section_text_direction; diff --git a/crates/docx/tests/it/roundtrip.rs b/crates/docx/tests/it/roundtrip.rs index 55a532834d..8c294c7605 100644 --- a/crates/docx/tests/it/roundtrip.rs +++ b/crates/docx/tests/it/roundtrip.rs @@ -89,6 +89,8 @@ fn every_char_prop_round_trips() { caps: Some(true), small_caps: Some(false), hidden: Some(true), + web_hidden: Some(true), + snap_to_grid: Some(false), spacing: Some(1.5), scale: Some(150.0), position: Some(3.0), diff --git a/crates/docx/tests/it/run_props.rs b/crates/docx/tests/it/run_props.rs new file mode 100644 index 0000000000..c203b26a05 --- /dev/null +++ b/crates/docx/tests/it/run_props.rs @@ -0,0 +1,74 @@ +//! Run and style properties that must survive repeated DOCX saves. + +use std::io::{Read, Write}; + +fn docx(body: &str, styles: &str) -> Vec { + let root = r#""#; + let rels = r#""#; + let document = format!( + r#"{body}"# + ); + let styles = format!(r#"{styles}"#); + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, xml) in [ + ("_rels/.rels", root.to_string()), + ("word/document.xml", document), + ("word/_rels/document.xml.rels", rels.to_string()), + ("word/styles.xml", styles), + ] { + zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zip.write_all(xml.as_bytes()).unwrap(); + } + zip.finish().unwrap().into_inner() +} + +fn part(bytes: &[u8], name: &str) -> String { + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut xml = String::new(); + zip.by_name(name).unwrap().read_to_string(&mut xml).unwrap(); + xml +} + +#[test] +fn run_and_style_snap_to_grid_and_web_hidden_survive_two_saves() { + let styles = r#" + + + "#; + let body = r#" + + inherit + direct true + override + visible in print + absent + + "#; + let mut doc = wordcraft_docx::read(&docx(body, styles)).unwrap(); + let p = doc.body.iter().find_map(|b| b.as_para()).unwrap(); + assert_eq!(p.runs[0].props.snap_to_grid, None); + assert_eq!(p.runs[0].props.web_hidden, None); + assert_eq!(p.runs[1].props.snap_to_grid, Some(true)); + assert_eq!(p.runs[1].props.web_hidden, Some(true)); + assert_eq!(p.runs[2].props.snap_to_grid, Some(false)); + assert_eq!(p.runs[2].props.web_hidden, Some(false)); + assert_eq!(p.runs[3].props.hidden, Some(true)); + assert_eq!(p.runs[3].props.web_hidden, None); + assert_eq!(p.runs[4].props.snap_to_grid, None); + assert_eq!(p.runs[4].props.web_hidden, None); + assert!(!doc.styles.resolve_char(None, &p.runs[0].props).hidden, "w:webHidden does not hide Print Layout text"); + assert!(doc.styles.resolve_char(None, &p.runs[3].props).hidden, "w:vanish still controls hidden text"); + for pass in 0..2 { + let out = wordcraft_docx::write(&doc).unwrap(); + let style_xml = part(&out, "word/styles.xml"); + assert!(style_xml.contains(r#""#), "pass {pass}: {style_xml}"); + assert!(style_xml.contains(r#""#), "pass {pass}: {style_xml}"); + let document_xml = part(&out, "word/document.xml"); + assert!(document_xml.contains(r#""#), "pass {pass}: {document_xml}"); + assert!(document_xml.contains(r#""#), "pass {pass}: {document_xml}"); + assert!(document_xml.contains(""), "pass {pass}: {document_xml}"); + assert!(document_xml.contains(""), "pass {pass}: {document_xml}"); + assert!(document_xml.contains("visible in print")); + doc = wordcraft_docx::read(&out).unwrap(); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1286ebf8f5..68735b618e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -116,7 +116,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Section text direction (`w:textDirection` in `w:sectPr`, §17.6.20) | ✅ | ✅ | `tbRl`, `btLr` (and `tbRlV`/`tbLrV`, read as `tbRl`); `lrTbV` and unknown values read as horizontal (#491) | | Section vertical alignment (`w:vAlign` in `w:sectPr`, §17.6.23) | ✅ | ✅ | `top`, `center`, `bottom` and `both` (Justified, read as Center before #1320); unknown values read as top, top writes nothing (`crates/docx/tests/it/section_valign.rs`). ODT/RTF don't carry it | | Ruby (`w:ruby`: `w:rubyPr`, `w:rt`, `w:rubyBase`) | ✅ | ✅ | #288; base and ruby text with their run formatting, alignment, sizes, raise, language | -| Document grid (`w:docGrid`: type, line pitch, `w:charSpace`), paragraph `w:snapToGrid` | ✅ | ✅ | #391; run-level `w:snapToGrid` not read | +| Document grid (`w:docGrid`: type, line pitch, `w:charSpace`), `w:snapToGrid` on paragraphs, runs and styles | ✅ | ✅ | #391; run and character-style flags keep absent/true/false across repeated saves; inherited true remains explicit false when overridden. `w:webHidden` remains distinct from `w:vanish` and preserves absent/true/false through runs, styles and repeated saves; Print Layout still shows web-hidden text | | Two lines in one and horizontal in vertical (`w:eastAsianLayout`, §17.3.2.10) | ✅ | ✅ | Every attribute (`w:id`, `w:combine`, `w:combineBrackets`, `w:vert`, `w:vertCompress`) read into the run's formatting and written back after `w:lang`, in runs and styles; values outside the schema are dropped (`crates/docx/tests/it/east_asian_layout.rs`). Not laid out yet: the text shows as ordinary text | | `w:fitText` | ❌ | ❌ | East Asian layout | | Glossary document (building blocks) | ✅ | ✅ | `w:docPart` entries (name, style, gallery, category, types, behaviours, description, guid, body) are read into `Document::glossary` and offered by Insert › Quick Parts and AutoText; `save` with `"in": "document"` stores one in the file. An untouched glossary is written back byte for byte with its own parts (styles, settings, fonts…); an edited one, or one whose parts weren't kept, is written from the model as `word/glossary1.xml` with copies of the document's styles and numbering. The glossary's own styles, settings and fonts, text boxes inside entries, and the notes and comments of a selection saved into the document don't survive an edit. Tests: `crates/docx/tests/it/glossary.rs` | @@ -418,6 +418,7 @@ Repeated DOCX style IDs use the last complete definition. The original regressio | 2026-10-11 | trivial | Hyphenation zone, words in capitals and consecutive-hyphen limit read and written (#407) | | 2026-10-11 | trivial | Alt text, titles and the decorative mark for every drawing, table captions and descriptions round-trip (#386) | | 2026-10-11 | trivial | Document grid (`w:docGrid`) and paragraph `w:snapToGrid` read and written (#391) | +| 2026-10-29 | trivial | Run/style `w:snapToGrid` and distinct `w:webHidden` values survive repeated DOCX saves | | 2026-10-11 | trivial | Move tracking (`w:moveFrom`/`w:moveTo` and their ranges) read and written (#400) | | 2026-10-11 | trivial | DOCX text-box `bodyPr` `vert`/`anchor` and linked text boxes (`wps:linkedTxbx`) read and written (#369) | | 2026-10-10 | trivial | Right-to-left tables (`w:bidiVisual`) read and written (#362) | From f2debdecffbde5fa67398bab8f8f30cfe8738c5b Mon Sep 17 00:00:00 2001 From: vikoabastian <11003051+vianbas@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:52:04 +0700 Subject: [PATCH 35/73] #3674: PDF export writes a glyph id above 65535 as .notdef instead of failing (#4554) * #3674: PDF export writes a glyph id above 65535 as .notdef instead of failing Shaping gives 32-bit glyph ids (a format-12 cmap can map a character to any of them), and the PDF exporter passed them to krilla unchecked. krilla's CID font takes 16-bit ids and unwraps the conversion (vendor/krilla/src/text/cid.rs:161 at craft-libs b926b5c0), so one such glyph failed the whole export natively and aborted the tab on wasm. The three places that build a KrillaGlyph (body text, metafile text and the text watermark) now go through glyph_id(), which writes an id that doesn't fit as glyph 0. The glyph keeps its place and its text range, so the rest of the run is unchanged. Regression test: glyph_id_above_u16_exports lays out a paragraph, sets one glyph's id to 70000 and exports the layout. It failed before with "PDF export failed internally: ... TryFromIntError(PosOverflow)". * #3674: the replaced glyph keeps its place, not its text krilla writes no ToUnicode entry for glyph 0, so a glyph written as .notdef doesn't extract: "Wide glyph" with the W's id set to 70000 extracts as "\u{FFFD}ide glyph". The rest of the run extracts as before. The comment on glyph_id() said the glyph kept its text. * Adapt glyph_id imports to main's exporter text module --------- Co-authored-by: Brandon Thomas --- crates/pdf/src/exporter.rs | 10 +++++----- crates/pdf/src/exporter/text.rs | 6 +++--- crates/pdf/src/glyphs.rs | 10 +++++++++- crates/pdf/src/tests.rs | 26 ++++++++++++++++++++++++++ 4 files changed, 43 insertions(+), 9 deletions(-) diff --git a/crates/pdf/src/exporter.rs b/crates/pdf/src/exporter.rs index 4d184f8663..327aab4517 100644 --- a/crates/pdf/src/exporter.rs +++ b/crates/pdf/src/exporter.rs @@ -1,14 +1,14 @@ use super::export::Fit; -use super::glyphs::{baseline, cluster_ends, glyph_outlines, left_to_right, split_clusters, visual_order}; +use super::glyphs::{baseline, cluster_ends, glyph_id, glyph_outlines, left_to_right, split_clusters, visual_order}; use super::options::{PdfError, PdfOptions}; use super::paint::{append_path, fill, grown, norm, ok, paint_fill, pdf_line_cap, rect_path, shape_effects, shape_path}; use super::picture::{Vector, build_vector, load_image, paint_of, vector_picture}; use super::tags::{Role, TagEntry, figure_alt, graphic_alt, holds_picture}; use super::{ Annotation, Arc, Artifact, ArtifactType, ClKind, ContentTag, DisplayOptions, DocLayout, DocPath, Document, Draw, FaceRef, Fill, FillRule, Font, - GlyphId, HashMap, HashSet, Identifier, Image, InlineObject, ItemDisplay, KrillaGlyph, LineCap, LineEnd, LineJoin, LineStyle, LinkAnnotation, - Node, NonZeroU16, Outline, OutlineNode, Page, PathBuilder, Placed, Point, Rect, Rgb, ShapeKind, Size, SpanTag, StoryRef, Stroke, StrokeDash, - Surface, Tag, TagGroup, TagKind, TagTree, Target, Transform, WriteError, XyzDestination, krilla, rgb, write, + HashMap, HashSet, Identifier, Image, InlineObject, ItemDisplay, KrillaGlyph, LineCap, LineEnd, LineJoin, LineStyle, LinkAnnotation, Node, + NonZeroU16, Outline, OutlineNode, Page, PathBuilder, Placed, Point, Rect, Rgb, ShapeKind, Size, SpanTag, StoryRef, Stroke, StrokeDash, Surface, + Tag, TagGroup, TagKind, TagTree, Target, Transform, WriteError, XyzDestination, krilla, rgb, write, }; mod draw; @@ -346,7 +346,7 @@ impl Exporter<'_> { let end = ends.get(i).copied().unwrap_or(text.len()); let start = g.cluster.min(end); KrillaGlyph::new( - GlyphId::new(g.gid), + glyph_id(g.gid), g.x_advance as f32 / upem, g.x_offset as f32 / upem, g.y_offset as f32 / upem, diff --git a/crates/pdf/src/exporter/text.rs b/crates/pdf/src/exporter/text.rs index f41e17dbe0..74c15fc763 100644 --- a/crates/pdf/src/exporter/text.rs +++ b/crates/pdf/src/exporter/text.rs @@ -1,5 +1,5 @@ use super::{ - Exporter, FaceRef, Fill, FillRule, GlyphId, KrillaGlyph, Point, Rgb, Stroke, Surface, Transform, baseline, cluster_ends, fill, glyph_outlines, + Exporter, FaceRef, Fill, FillRule, KrillaGlyph, Point, Rgb, Stroke, Surface, Transform, baseline, cluster_ends, fill, glyph_id, glyph_outlines, left_to_right, norm, ok, paint_of, rgb, split_clusters, visual_order, }; @@ -126,7 +126,7 @@ impl Exporter<'_> { Some((_, nx, _, _)) => (nx - x) / size, None => face.advance(*g) as f32 / upem, }; - KrillaGlyph::new(GlyphId::new(*g), adv, 0.0, (y0 - y) / size, 0.0, r.clone(), None) + KrillaGlyph::new(glyph_id(*g), adv, 0.0, (y0 - y) / size, 0.0, r.clone(), None) }) .collect(); let pushed = italic; @@ -216,7 +216,7 @@ impl Exporter<'_> { None => g.x_advance as f32 / upem, }; let dy = lay.glyphs.get(i).map_or(0.0, |p| ((lay.baseline - p.1) / lay.em) as f32); - KrillaGlyph::new(GlyphId::new(g.gid), adv, 0.0, dy, 0.0, start..end, None) + KrillaGlyph::new(glyph_id(g.gid), adv, 0.0, dy, 0.0, start..end, None) }) .collect(); let x0 = lay.glyphs.first().map_or(0.0, |p| p.0) as f32; diff --git a/crates/pdf/src/glyphs.rs b/crates/pdf/src/glyphs.rs index be795047cd..cfd3589ec6 100644 --- a/crates/pdf/src/glyphs.rs +++ b/crates/pdf/src/glyphs.rs @@ -1,5 +1,13 @@ use super::paint::append_path; -use super::{FaceRef, HashMap, Path, PathBuilder}; +use super::{FaceRef, GlyphId, HashMap, Path, PathBuilder}; + +/// A shaped glyph id as krilla takes it. Shaping gives 32-bit ids (a format-12 cmap can map to +/// any), but krilla's font subsetting takes 16-bit ones and panics on a larger id (#3674): one +/// that doesn't fit is written as .notdef (glyph 0), keeping its place. Its own character doesn't +/// extract, since krilla maps no text to .notdef. +pub(crate) fn glyph_id(g: u32) -> GlyphId { + GlyphId::new(u16::try_from(g).map_or(0, u32::from)) +} /// The outlines of `glyphs` (glyph id, pen position) at `size`, as one path in page coordinates. pub(crate) fn glyph_outlines(face: &FaceRef, size: f32, glyphs: &[(u32, f32, f32)]) -> Option { diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index c8f1bec54c..7e05daadec 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -773,6 +773,32 @@ fn unsubsettable_font_is_drawn_as_outlines() { assert!(paths > 0, "its glyphs are drawn as outlines"); } +/// #3674: shaping gives 32-bit glyph ids (a format-12 cmap can map a character to any of them), +/// but krilla takes 16-bit ones and panics on a larger id. That failed the export, and on wasm +/// (`panic = "abort"`) lost the tab. A glyph id above 65535 is now written as .notdef. +#[test] +fn glyph_id_above_u16_exports() { + let mut d = Document::new(); + d.body = vec![para_block(Paragraph::with_text("Wide glyph", CharProps { font: Some("Source Sans 3".into()), ..Default::default() }))]; + let opts = PdfOptions { tagged: false, ..Default::default() }; + let mut lay = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); + let (want, _) = count_glyphs_and_paths(&export_layout(&d, &lay, &opts).unwrap()); + assert!(want > 0); + let mut wide = 0; + for item in lay.pages.iter_mut().flat_map(|p| p.items.iter_mut()) { + if let Placed::Lines { para, .. } = item + && let Some(g) = Arc::make_mut(para).glyphs.first_mut() + { + g.gid = 70_000; + wide += 1; + } + } + assert_eq!(wide, 1, "one glyph id is out of range"); + let bytes = export_layout(&d, &lay, &opts).expect("a glyph id above 65535 must not fail the export"); + let (glyphs, _) = count_glyphs_and_paths(&bytes); + assert_eq!(glyphs, want, "every glyph is still drawn, the wide one as .notdef"); +} + #[test] fn flat_lines_in_a_group_are_drawn() { // A horizontal divider inside a group has no height (`a:ext cy="0"`): it used to be left out From d39be3bab8cf4f90e977c8b28fd3c65d1fcf25c2 Mon Sep 17 00:00:00 2001 From: Rees <109076338+OPUZZLES@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:52:43 +0100 Subject: [PATCH 36/73] Avoid repeated reference scans when converting notes (#4750) (#4752) * Fix #4750: walk note references and formatting runs together * docs: keep the gaps.md note in the status line and revision table --------- Co-authored-by: Brandon Thomas --- ROADMAP.md | 5 +- crates/doc/src/edit.rs | 8 +- crates/doc/src/tests.rs | 2 + crates/doc/src/tests/note_conversion.rs | 145 ++++++++++++++++++++++++ docs/file-format-parity.md | 7 +- docs/gaps.md | 3 +- 6 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 crates/doc/src/tests/note_conversion.rs diff --git a/ROADMAP.md b/ROADMAP.md index 72e4952443..f01b4d9db7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-29 · **Change:** trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; 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: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; 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: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: 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: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #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 fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; 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: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; 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: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: 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: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #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 fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously 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. @@ -140,6 +140,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-10-28: Convert Notes walks reference offsets and formatting runs together instead of rescanning all references for every run (#4750). Note text, IDs, custom marks, styles, history and saved content retain their behavior. Synthetic scaling, CLI/MCP and native/DOCX reopen coverage; readiness estimates unchanged. + - 2026-10-29: Saving a DOCX with many comments no longer searches all comment paragraph IDs and all reserved durable IDs for each comment. Paragraph IDs use their existing sorted order; durable-ID reservations are indexed once. Parent links, resolved flags, authored text and existing ID rules are unchanged. - 2026-10-28: RTF import keeps pictures stored as Windows Metafiles (`\wmetafile8`, as Word writes a pasted or floating picture) and Enhanced Metafiles (`\emfblip`); they were dropped, so such a document opened without its pictures. They are drawn by the metafile renderer, saved to .docx as `.wmf`/`.emf` media, and saved to .rtf as metafiles again (RTF export dropped them too). (#4259) @@ -660,6 +662,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-28 | trivial | Note conversion walks reference offsets and formatting runs together, #4750; readiness estimates unchanged. | | 2026-10-29 | trivial | DOCX comment IDs use ordered paragraph lookup and one durable-ID reservation set; threading and ID rules unchanged | | 2026-10-28 | trivial | #4658: bound Markdown reference-label rejection work, preserving literal source and ordinary link matching | | 2026-10-29 | trivial | Progress log: Insert › Shapes icon grid (#4349) | diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 833c3878d2..66d483e81e 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -434,10 +434,16 @@ impl Document { at.extend(offs.get(k).copied()); } } + // Reference offsets and run boundaries are ordered; do not restart at the first + // reference for every run, including runs after the last reference. + let mut at = at.into_iter().peekable(); let mut start = 0usize; for r in &mut p.runs { let end = start.saturating_add(r.len); - if at.iter().any(|&o| o >= start && o < end) { + while at.peek().is_some_and(|&o| o < start) { + at.next(); + } + if at.peek().is_some_and(|&o| o < end) { swap(&mut r.props.style); } start = end; diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 86b2040119..26acf90c22 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -1,5 +1,7 @@ use super::*; +mod note_conversion; + #[test] fn new_document_is_valid() { let d = Document::new(); diff --git a/crates/doc/src/tests/note_conversion.rs b/crates/doc/src/tests/note_conversion.rs new file mode 100644 index 0000000000..3c9a5e2b43 --- /dev/null +++ b/crates/doc/src/tests/note_conversion.rs @@ -0,0 +1,145 @@ +use super::*; +use crate::para::{InlineObject, NoteKind, OBJ}; + +fn reference(id: u32, kind: NoteKind) -> InlineObject { + InlineObject::NoteRef { kind, id, custom: "*".into() } +} + +fn styled(len: usize, style: &str) -> Run { + Run { len, props: CharProps { style: Some(style.into()), italic: Some(true), ..Default::default() } } +} + +#[test] +fn conversion_preserves_reference_run_boundaries_and_unmatched_storage() { + for (foot, end, expected) in [ + (true, false, ["FootnoteReference", "EndnoteReference", "FootnoteReference", "EndnoteReference", "EndnoteReference", "FootnoteReference"]), + (false, true, ["FootnoteReference", "FootnoteReference", "FootnoteReference", "EndnoteReference", "FootnoteReference", "FootnoteReference"]), + (true, true, ["FootnoteReference", "EndnoteReference", "FootnoteReference", "EndnoteReference", "FootnoteReference", "FootnoteReference"]), + ] { + let mut d = Document::new(); + let note = |style: &str| { + let mut p = Paragraph::with_text("Original note 漢", CharProps::default()); + p.props.style = Some(style.into()); + p.mark.style = Some(style.into()); + vec![para_block(p)] + }; + let f = d.add_part(PartKind::Footnote, note("FootnoteText")); + let e = d.add_part(PartKind::Endnote, note("EndnoteText")); + let p = Paragraph { + text: format!("é{OBJ}{OBJ}漢{OBJ}{OBJ}"), + objects: vec![ + reference(f, NoteKind::Footnote), + InlineObject::BookmarkStart { name: "kept".into() }, + reference(e, NoteKind::Endnote), + reference(999, NoteKind::Footnote), + reference(f, NoteKind::Footnote), + ], + runs: vec![ + styled(2, "FootnoteReference"), + styled(3, "FootnoteReference"), + styled(3, "FootnoteReference"), + styled(3, "EndnoteReference"), + styled(3, "EndnoteReference"), + styled(3, "FootnoteReference"), + ], + ..Paragraph::new() + }; + let mut table = Table::new(1, 1, 200.0); + table.rows[0].cells[0].blocks = vec![para_block(p.clone())]; + d.body = vec![para_block(p.clone()), Arc::new(Block::Table(table))]; + let h = d.add_part(PartKind::Header, vec![para_block(p.clone())]); + let t = d.add_part(PartKind::TextBox, vec![para_block(p.clone())]); + let before = d.clone(); + assert_eq!(d.convert_notes(foot, end), usize::from(foot) + usize::from(end)); + for (story, path) in [ + (StoryRef::Body, Path::top(0)), + (StoryRef::Body, Path(vec![1, 0, 0, 0])), + (StoryRef::Part(h), Path::top(0)), + (StoryRef::Part(t), Path::top(0)), + ] { + let got = d.para(story, &path).unwrap(); + assert_eq!(got.text, p.text); + assert_eq!(got.runs.iter().map(|r| r.props.style.as_deref().unwrap()).collect::>(), expected); + assert!(got.runs.iter().all(|r| r.props.italic == Some(true))); + assert_eq!(got.objects[1], p.objects[1]); + assert_eq!(got.objects[3], p.objects[3], "a missing note stays unchanged"); + assert_eq!(got.objects[0], reference(f, if foot { NoteKind::Endnote } else { NoteKind::Footnote })); + assert_eq!(got.objects[2], reference(e, if end { NoteKind::Footnote } else { NoteKind::Endnote })); + assert_eq!(got.objects[4], got.objects[0], "surplus object storage retains the existing conversion"); + } + for (id, flipped, from, to) in [(f, foot, "FootnoteText", "EndnoteText"), (e, end, "EndnoteText", "FootnoteText")] { + let got = d.para(StoryRef::Part(id), &Path::top(0)).unwrap(); + assert_eq!(got.text, before.plain_text(StoryRef::Part(id))); + assert_eq!(got.props.style.as_deref(), Some(if flipped { to } else { from })); + assert_eq!(got.mark.style, got.props.style); + } + let mut missing = Paragraph { + text: format!("{OBJ}{OBJ}"), + objects: vec![reference(f, if foot { NoteKind::Endnote } else { NoteKind::Footnote })], + runs: vec![styled(0, "FootnoteReference"), styled(3, "FootnoteReference"), styled(3, "FootnoteReference")], + ..Paragraph::new() + }; + missing.runs.push(styled(usize::MAX, "FootnoteReference")); + d.body = vec![para_block(missing)]; + d.convert_notes(true, true); + let got = d.para(StoryRef::Body, &Path::top(0)).unwrap(); + assert_eq!( + got.runs.iter().map(|r| r.props.style.as_deref().unwrap()).collect::>(), + ["FootnoteReference", "EndnoteReference", "FootnoteReference", "FootnoteReference"] + ); + } +} + +fn dense(n: usize) -> Document { + let mut d = Document::new(); + let id = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Shared original note", CharProps::default()))]); + d.body = vec![para_block(Paragraph { + text: format!("{}{}", OBJ.to_string().repeat(n), "x".repeat(n)), + objects: (0..n).map(|_| reference(id, NoteKind::Footnote)).collect(), + runs: (0..n).map(|_| styled(OBJ.len_utf8(), "FootnoteReference")).chain((0..n).map(|_| styled(1, "FootnoteReference"))).collect(), + ..Paragraph::new() + })]; + d +} + +// Thread CPU excludes scheduler delays. Missing or nonadvancing counters provide no timing +// evidence; the semantic assertions still run on those platforms. +fn cpu_cost(f: impl FnOnce()) -> Option { + let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::().ok(); + let before = ran(); + f(); + match (before, ran()) { + (Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9), + _ => None, + } +} + +#[test] +fn conversion_work_scales_with_reference_and_formatting_runs() { + let (small, big) = (dense(2_000), dense(16_000)); + let convert = |input: &Document| { + let mut d = input.clone(); + assert_eq!(d.convert_notes(true, false), 1); + let p = d.para(StoryRef::Body, &Path::top(0)).unwrap(); + let n = p.objects.len(); + assert!(p.runs[..n].iter().all(|r| r.props.style.as_deref() == Some("EndnoteReference"))); + assert!(p.runs[n..].iter().all(|r| r.props.style.as_deref() == Some("FootnoteReference"))); + std::hint::black_box(d); + }; + convert(&small); + convert(&big); + let (mut t1, mut t8) = (f64::INFINITY, f64::INFINITY); + for _ in 0..3 { + let a = cpu_cost(|| (0..8).for_each(|_| convert(&small))); + let b = cpu_cost(|| convert(&big)); + if let (Some(a), Some(b)) = (a, b) { + t1 = t1.min(a / 8.0); + t8 = t8.min(b); + } + } + if t1.is_finite() && t8.is_finite() { + let ratio = t8 / t1; + eprintln!("note conversion CPU: small={t1:.6}s big={t8:.6}s ratio={ratio:.2}"); + assert!(ratio < 24.0, "8x the references and runs cost {ratio:.2}x CPU"); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 68735b618e..0f5e97c7b9 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-29 · **Change:** trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-29 · **Change:** trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats 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; @@ -38,6 +38,10 @@ DOCs, templates, accented/Cyrillic/Unicode properties and malformed lengths/offs LibreOffice-generated DOC and DOT containing an original synthetic document reproduced the missing-title bug before this change and retain their title after it. +## Note conversion + +Convert Notes checks reference membership with one ordered walk through the paragraph's converted references and formatting runs (#4750). Conversions keep their whole-run style behavior, note IDs, custom marks, text and other formatting. Synthetic checks cover mixed footnotes/endnotes, Unicode and run boundaries, tables and secondary stories, Undo/Redo, protection and native/DOCX saves with fresh reopens. No import, export or layout policy changes; readiness estimates are unchanged. + ## Formats Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ yes, 🟡 partial, @@ -271,6 +275,7 @@ Repeated DOCX style IDs use the last complete definition. The original regressio | Date | Change | Summary | |---|---|---| +| 2026-10-28 | trivial | Note conversion preserves content with an ordered reference/run walk, #4750; readiness estimates unchanged. | | 2026-10-29 | trivial | DOCX comment IDs use ordered paragraph lookup and one durable-ID reservation set; threading and ID rules unchanged | | 2026-10-28 | trivial | Built-in heading styles (`heading 1`…`heading 9`) have their outline level when `styles.xml` leaves out `w:outlineLvl`, as in Word: their headings show in the Navigation pane (#4696) | | 2026-10-29 | trivial | DOCX: comments.xml is read before footnotes and endnotes, so a comment range inside a note keeps its comment (it was dropped) (#4548) | diff --git a/docs/gaps.md b/docs/gaps.md index 00ff622812..6b240a11dc 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (DOCX comment-ID save lookups); previously trivial (gap 19: RTF reads and writes WMF and EMF pictures, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; 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: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (note conversion reference-run membership uses one ordered walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (gap 19: RTF reads and writes WMF and EMF pictures, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; 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: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **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-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (#7: Custom Document Properties dialog, #2982; previously 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: 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 the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 @@ -152,6 +152,7 @@ Recipient label generation (#4880) now includes all selected recipients across a | Date | Change | Summary | |---|---|---| +| 2026-10-28 | trivial | Note conversion reference-run membership uses one ordered walk, #4750; readiness estimates unchanged. | | 2026-10-29 | trivial | DOCX comment IDs use ordered paragraph lookup and one durable-ID reservation set; threading and ID rules unchanged | | 2026-10-28 | trivial | #4658: bound Markdown reference-label rejection work, preserving literal source and ordinary link matching | | 2026-10-29 | trivial | Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Filtered/sorted order, product geometry and single-label mode retained; readiness estimates unchanged. | From 74b2cbb5c9cafff40b5ae3dc02800b1769209608 Mon Sep 17 00:00:00 2001 From: Rees <109076338+OPUZZLES@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:53:06 +0100 Subject: [PATCH 37/73] Backspace: keep Arabic base letters when removing marks (#4757) (#4761) * 4757: keep Arabic base letters when removing marks * docs: keep the gaps.md note in the status line and revision table --------- Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/doc/src/cluster.rs | 22 +++++++--- crates/doc/src/para.rs | 5 +-- crates/engine/src/tests/typing.rs | 72 +++++++++++++++++++++++++++++++ docs/gaps.md | 5 ++- docs/typing-parity.md | 12 +++++- 6 files changed, 108 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f01b4d9db7..6cfc5ca554 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-29 · **Change:** trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; 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: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; 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: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: 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: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #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 fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; 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: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; 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: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: 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: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #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 fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously 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. @@ -209,6 +209,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 2026-10-28 | **Arabic diacritic editing (#4757):** Backspace removes the last stored Arabic combining mark, so its base letter can stay while the mark is replaced. Caret movement and forward Delete keep their cluster rules. Synthetic command, history, protection and native/DOCX fresh-reopen checks; no native Word comparison or readiness change. | | 2026-10-29 | Insert › Shapes shows the basic shapes as a grid of code-drawn icon tiles, each with an accessible name and tooltip, instead of a text list (#4349) | | 2026-10-29 | Window sizing falls back to the screen rectangle when the reported inner size is invalid, and an Equation symbol tooltip no longer repeats a missing glyph's name or code point | | 2026-10-29 | A NUL in a document title no longer reaches the native window title, which panicked on X11 (#3634): displayed titles and suggested file names drop NUL characters and fall back to the untitled name when empty; the stored title is unchanged | @@ -662,6 +663,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-28 | trivial | Arabic Backspace removes one combining mark and keeps its base letter (#4757); stored mark order, caret/forward-Delete and other-script behavior retained; original synthetic command/history/protection/save-reopen checks, no native Word comparison; readiness estimates unchanged | | 2026-10-28 | trivial | Note conversion walks reference offsets and formatting runs together, #4750; readiness estimates unchanged. | | 2026-10-29 | trivial | DOCX comment IDs use ordered paragraph lookup and one durable-ID reservation set; threading and ID rules unchanged | | 2026-10-28 | trivial | #4658: bound Markdown reference-label rejection work, preserving literal source and ordinary link matching | diff --git a/crates/doc/src/cluster.rs b/crates/doc/src/cluster.rs index 186b79f9d6..4b4db881f9 100644 --- a/crates/doc/src/cluster.rs +++ b/crates/doc/src/cluster.rs @@ -115,12 +115,22 @@ pub fn next_cluster(text: &str, off: usize) -> usize { } } -/// Scripts whose Backspace removes one character at a time (a vowel sign, a medial, a stacked -/// consonant) rather than the whole cluster, as on Windows and macOS: the Brahmic scripts of -/// South and South-East Asia. +/// Characters Backspace removes individually rather than with their whole cluster: Arabic +/// combining marks and characters of the Brahmic scripts of South and South-East Asia. pub fn backspaces_by_char(c: char) -> bool { matches!(u32::from(c), - 0x0900..=0x0DFF // Devanagari … Sinhala + 0x0610..=0x061A // Arabic nonspacing marks (Unicode 17.0) + | 0x064B..=0x065F + | 0x0670 + | 0x06D6..=0x06DC + | 0x06DF..=0x06E4 + | 0x06E7..=0x06E8 + | 0x06EA..=0x06ED + | 0x0897..=0x089F + | 0x08CA..=0x08E1 + | 0x08E3..=0x08FF + | 0x10EFA..=0x10EFF + | 0x0900..=0x0DFF // Devanagari … Sinhala | 0x0E00..=0x0EFF // Thai, Lao | 0x0F00..=0x0FFF // Tibetan | 0x1000..=0x109F // Myanmar @@ -250,8 +260,10 @@ mod tests { } #[test] - fn backspace_by_character_in_brahmic_scripts_only() { + fn backspace_by_character_in_brahmic_scripts_and_arabic_marks() { assert!(backspaces_by_char('ေ') && backspaces_by_char('ि') && backspaces_by_char('្')); + assert!(backspaces_by_char('\u{064E}') && backspaces_by_char('\u{06E7}') && backspaces_by_char('\u{10EFF}')); assert!(!backspaces_by_char('\u{301}') && !backspaces_by_char('a') && !backspaces_by_char('語')); + assert!(!backspaces_by_char('ب') && !backspaces_by_char('\u{06E5}') && !backspaces_by_char('\u{08E2}')); } } diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index 193dc55892..3959bad1d1 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -501,9 +501,8 @@ impl Paragraph { pub fn next_boundary(&self, off: usize) -> usize { crate::cluster::next_cluster(&self.text, self.clamp(off)) } - /// Where Backspace at `off` deletes from: the previous character in the Brahmic scripts - /// (Myanmar, Khmer, Devanagari, Thai…), where it removes one vowel sign or stacked consonant - /// at a time, otherwise the previous caret stop. + /// Where Backspace at `off` deletes from: one Arabic combining mark or one character in the + /// Brahmic scripts (Myanmar, Khmer, Devanagari, Thai…), otherwise the previous caret stop. pub fn backspace_from(&self, off: usize) -> usize { let off = self.clamp(off); match self.text.get(..off).and_then(|t| t.char_indices().next_back()) { diff --git a/crates/engine/src/tests/typing.rs b/crates/engine/src/tests/typing.rs index 331554f00a..4d2cc46d63 100644 --- a/crates/engine/src/tests/typing.rs +++ b/crates/engine/src/tests/typing.rs @@ -1414,6 +1414,78 @@ fn indic_and_khmer_backspace_removes_one_character() { assert_eq!(paras(&s)[0].0, "a"); } +/// Arabic vowel and Quranic marks are individually editable without retyping their base (#4757). +#[test] +fn arabic_backspace_removes_one_mark_and_keeps_the_base() { + for mark in [ + '\u{0610}', + '\u{064B}', + '\u{064C}', + '\u{064D}', + '\u{064E}', + '\u{064F}', + '\u{0650}', + '\u{0651}', + '\u{0652}', + '\u{065F}', + '\u{0670}', + '\u{06D6}', + '\u{06DF}', + '\u{06E7}', + '\u{06EA}', + '\u{0897}', + '\u{089F}', + '\u{08CA}', + '\u{08E1}', + '\u{08E3}', + '\u{08FF}', + '\u{10EFA}', + '\u{10EFF}', + ] { + let original = format!("Λ ب{mark}"); + let mut s = typed(&original); + typ(&mut s, "\u{8}"); + assert_eq!(paras(&s)[0].0, "Λ ب", "mark U+{:04X}", u32::from(mark)); + assert_eq!(s.sel.focus.off, "Λ ب".len()); + run(&mut s, "edit.undo", json!({})); + assert_eq!(paras(&s)[0].0, original); + run(&mut s, "edit.redo", json!({})); + typ(&mut s, "\u{0650}"); + assert_eq!(paras(&s)[0].0, "Λ ب\u{0650}"); + } +} + +#[test] +fn arabic_backspace_uses_stored_mark_order_but_delete_keeps_the_cluster() { + for marks in ["\u{0651}\u{064E}", "\u{064E}\u{0651}"] { + let mut s = typed(&format!("ب{marks}")); + typ(&mut s, "\u{8}"); + assert_eq!(paras(&s)[0].0, format!("ب{}", marks.chars().next().unwrap())); + typ(&mut s, "\u{8}"); + assert_eq!(paras(&s)[0].0, "ب"); + typ(&mut s, "\u{8}"); + assert!(paras(&s)[0].0.is_empty()); + let original = format!("ب{marks}ت"); + let mut s = typed(&original); + // Arabic arrows move visually. They still visit only whole-cluster boundaries. + let boundaries = [0, original.len() - "ت".len(), original.len()]; + let mut visited = vec![s.sel.focus.off]; + for command in ["caret.right", "caret.right", "caret.left", "caret.left"] { + run(&mut s, command, json!({})); + assert!(boundaries.contains(&s.sel.focus.off)); + visited.push(s.sel.focus.off); + } + assert!(boundaries.iter().all(|off| visited.contains(off))); + s.sel = crate::Selection::caret(Pos::body(0, 0)); + run(&mut s, "text.delete", json!({})); + assert_eq!(paras(&s)[0].0, "ت"); + } + for cluster in ["e\u{0301}", "👩‍💻", "👍🏽", "🇵🇱", "ב\u{05B7}"] { + let s = typed(&format!("a{cluster}\u{8}")); + assert_eq!(paras(&s)[0].0, "a", "unchanged grapheme {cluster}"); + } +} + /// Press a shortcut the way the keyboard does: run the command bound to it. fn press(s: &mut Session, combo: &str) -> Result { let id = s.registry.by_shortcut(combo).map(|c| c.id).unwrap_or_else(|| panic!("nothing bound to {combo}")); diff --git a/docs/gaps.md b/docs/gaps.md index 6b240a11dc..eb09e00554 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,8 +1,8 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (note conversion reference-run membership uses one ordered walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (gap 19: RTF reads and writes WMF and EMF pictures, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; 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: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion reference-run membership uses one ordered walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (gap 19: RTF reads and writes WMF and EMF pictures, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; 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: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **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-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (#7: Custom Document Properties dialog, #2982; previously 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: 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 the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (#7: Custom Document Properties dialog, #2982; previously 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: 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 the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 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). @@ -152,6 +152,7 @@ Recipient label generation (#4880) now includes all selected recipients across a | Date | Change | Summary | |---|---|---| +| 2026-10-28 | trivial | Arabic Backspace removes one combining mark and keeps its base letter (#4757); stored mark order, caret/forward-Delete and other-script behavior retained; original synthetic command/history/protection/save-reopen checks, no native Word comparison; readiness estimates unchanged | | 2026-10-28 | trivial | Note conversion reference-run membership uses one ordered walk, #4750; readiness estimates unchanged. | | 2026-10-29 | trivial | DOCX comment IDs use ordered paragraph lookup and one durable-ID reservation set; threading and ID rules unchanged | | 2026-10-28 | trivial | #4658: bound Markdown reference-label rejection work, preserving literal source and ordinary link matching | diff --git a/docs/typing-parity.md b/docs/typing-parity.md index 363d51ca77..8d488a46da 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -1,6 +1,6 @@ # Typing parity with Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** trivial (`*Bold* and _italic_ as real formatting` in AutoFormat As You Type; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: queued IME replacements are rejected before mutation after their marked composition loses its body/equation target; previously trivial: the transactional equation delete proves its commit with the engine revision; previously trivial: a refused byte-counted equation deletion drops only the immediately adjacent stale replacement, so a later direct commit still applies; previously trivial: a tracked own-insertion shifts the rest of a two-sided IME deletion; previously trivial: AutoCorrect/AutoFormat classification compares the actual paragraph; previously trivial: a replaced body starts a new input-method context and interrupts the composition; a quote the engine leaves as typed is the input method's own change; equation `DeleteSurrounding` erases counted characters; previously trivial: AutoCorrect finds the word after a no-break space or an ideographic space; 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 trivial: while an input method composes, the caret and its candidate window sit at the input method's cursor inside the composition; previously trivial: Ctrl+Tab types a tab in a table cell, #3137; previously trivial: List AutoFormat triggers in front of an already-written paragraph, not only a fresh empty one, checked in Word for Windows, #3352; previously trivial: AltGr types its characters without opening KeyTips on Linux, #2388; previously trivial: Delete on whole selected cells leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too, #2802; 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: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343; the body IME deletion maps signed placeholder growth/shrink, keeps control markers outside the range and scopes its undo join to the matching replacement) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-28 · **Change:** trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial: `*Bold* and _italic_ as real formatting` in AutoFormat As You Type; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: queued IME replacements are rejected before mutation after their marked composition loses its body/equation target; previously trivial: the transactional equation delete proves its commit with the engine revision; previously trivial: a refused byte-counted equation deletion drops only the immediately adjacent stale replacement, so a later direct commit still applies; previously trivial: a tracked own-insertion shifts the rest of a two-sided IME deletion; previously trivial: AutoCorrect/AutoFormat classification compares the actual paragraph; previously trivial: a replaced body starts a new input-method context and interrupts the composition; a quote the engine leaves as typed is the input method's own change; equation `DeleteSurrounding` erases counted characters; previously trivial: AutoCorrect finds the word after a no-break space or an ideographic space; 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 trivial: while an input method composes, the caret and its candidate window sit at the input method's cursor inside the composition; previously trivial: Ctrl+Tab types a tab in a table cell, #3137; previously trivial: List AutoFormat triggers in front of an already-written paragraph, not only a fresh empty one, checked in Word for Windows, #3352; previously trivial: AltGr types its characters without opening KeyTips on Linux, #2388; previously trivial: Delete on whole selected cells leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too, #2802; 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: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343; the body IME deletion maps signed placeholder growth/shrink, keeps control markers outside the range and scopes its undo join to the matching replacement) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 What Word does when you type, observed black-box (same keystrokes typed into Word and WordCraft, results read back paragraph by paragraph) and pinned by tests in @@ -357,6 +357,15 @@ again. ## Complex scripts +- Arabic Backspace removes the last stored nonspacing mark, including harakat and Quranic marks, + without deleting its base letter (#4757). Multiple marks are removed in stored order, with no + normalization or decomposition. Left/Right and forward Delete keep whole-cluster behavior; + Latin accents, emoji and the existing Brahmic policy retain their behavior. Original synthetic + command regressions cover deletion, replacement, Undo/Redo and controls + (`arabic_backspace_removes_one_mark_and_keeps_the_base`, + `arabic_backspace_uses_stored_mark_order_but_delete_keeps_the_cluster`). Native keyboard and + Microsoft Word behavior have not been observed for this change. + - Myanmar, Khmer and the Indic scripts are typed in memory order (consonant, medials, then vowel signs such as ေ, which is drawn before its consonant); the caret steps over whole syllables (Myanmar vowel signs and stacked consonants never split off), Backspace removes one @@ -492,6 +501,7 @@ again. | Date | Change | Summary | |---|---|---| +| 2026-10-28 | trivial | Arabic Backspace removes one combining mark and keeps its base letter (#4757); stored mark order, caret/forward-Delete and other-script behavior retained; original synthetic command/history/protection/save-reopen checks, no native Word comparison; readiness estimates unchanged | | 2026-10-25 | trivial | Highlight decisions include fully selected paragraph marks in whole cells and ordinary ranges, including empty paragraphs; partial text columns exclude unselected marks (#4085). | | 2026-10-25 | trivial | Highlight toggle checks the full target beyond 10,000 runs without collecting resolved formatting; mixed and inherited tails and Undo/Redo covered (#4085). | | 2026-10-24 | trivial | Input methods: reject queued old-composition replacements before mutation after body/equation target replacement (new regression validation pending); equation `DeleteSurrounding` is one transactional command whose checked revision proves a receipt; a caret-only step in a mixed request refuses the whole deletion, and an over-cap request clears the painted pre-edit and the previous replacement join; a refused byte-counted equation deletion drops only the immediately adjacent stale replacement, never a later direct commit; a tracked own-insertion shifts the following range; a body deletion maps signed placeholder growth/shrink, keeps control markers outside the range and scopes its replacement undo join to the matching `text.insert`; refusals clear the painted pre-edit and joins; AutoCorrect/AutoFormat classification compares the paragraph as typed; equation replacement interrupts the composition | From 2e790d549cd5db25a90a7e6ea3e6c88f7c31fff4 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:07:53 -0500 Subject: [PATCH 38/73] Backspace after a floating shape's anchor keeps the shape (#4592) (#4641) * Backspace after a floating shape's anchor joins paragraphs and keeps the shape (#4592) * wc-upkeep-6: protect Backspace joins and markers --------- Co-authored-by: Brandon Thomas --- crates/engine/src/cmd/text/delete.rs | 19 +++++++++-- crates/engine/src/tests/typing.rs | 48 ++++++++++++++++++++++++++++ docs/typing-parity.md | 3 ++ 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/text/delete.rs b/crates/engine/src/cmd/text/delete.rs index b25a86fa30..5c692e523f 100644 --- a/crates/engine/src/cmd/text/delete.rs +++ b/crates/engine/src/cmd/text/delete.rs @@ -1,5 +1,5 @@ use super::enter_tab::tab_indent_on; -use super::{Block, CmdResult, NumRef, Pos, Selection, Session, StoryRef, Value, delete_selection, delete_selection_effect, sel_result}; +use super::{Block, CmdResult, NumRef, OBJ, Pos, Selection, Session, StoryRef, Value, delete_selection, delete_selection_effect, sel_result}; pub(super) fn backspace(s: &mut Session, v: &Value) -> CmdResult { let r = backspace_inner(s, v)?; @@ -43,8 +43,17 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult { }); return sel_result(s); } - let f = s.sel.focus.clone(); + let mut f = s.sel.focus.clone(); let Some(para) = s.doc.para_at(&f) else { return sel_result(s) }; + // Only floating objects before the caret: they are anchors, not characters, so this is the + // paragraph start and the shape stays (Word). + let before = para.text.get(..f.off).unwrap_or_default(); + if !before.is_empty() + && before.chars().all(|c| c == OBJ) + && para.objects_with_offsets().take_while(|(o, _)| *o < f.off).all(|(_, o)| o.is_marker() || o.is_floating()) + { + f.off = 0; + } if f.off > 0 { let a = Pos { off: para.backspace_from(f.off), ..f.clone() }; if let Some(c) = s.autocap.as_mut() { @@ -98,6 +107,12 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult { delete_selection(s)?; return sel_result(s); } + // Joining moves a content control whose start is at the paragraph edge. Check its + // contents explicitly: the paragraph-mark range itself ends before those contents. + for control in s.doc.control_ranges(f.story).into_iter().filter(|r| r.start.path == f.path && r.start.off == f.off) { + crate::cmd::controls::check_edit(s, &control.content_start(), &control.end)?; + } + crate::cmd::controls::check_delete(s, &a, &f)?; let at = s.doc.delete_range(&a, &f)?; s.sel = Selection::caret(at); } diff --git a/crates/engine/src/tests/typing.rs b/crates/engine/src/tests/typing.rs index 4d2cc46d63..b154bfabca 100644 --- a/crates/engine/src/tests/typing.rs +++ b/crates/engine/src/tests/typing.rs @@ -3458,3 +3458,51 @@ fn double_space_adds_a_period_when_asked() { typ(&mut t, "Hello world"); assert_eq!(text(&t), "Hello world"); } + +#[test] +fn backspace_after_a_floating_shape_anchor_joins_paragraphs_and_keeps_the_shape() { + let mut s = typed("Line one\nLine two"); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 0}})); + run(&mut s, "insert.textBox", json!({"text": "Box"})); + let (a, f) = (Pos::body(1, 0), Pos::body(1, 3)); + run(&mut s, "select.range", json!({"anchor": a, "focus": f})); + run(&mut s, "arrange.wrap", json!({"wrap": "inFrontOfText"})); + let objects = |s: &Session| s.doc.para_at(&Pos::body(1, 0)).map(|p| p.object_offsets().len()); + assert_eq!(objects(&s), Some(1)); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 3}})); + run(&mut s, "text.backspace", json!({})); + assert_eq!(s.doc.body.iter().filter(|b| b.as_para().is_some()).count(), 1); + assert_eq!(objects(&s), None); + assert_eq!( + s.doc.para_at(&Pos::body(0, 0)).map(|p| (p.text.replace('\u{FFFC}', ""), p.object_offsets().len())), + Some(("Line oneLine two".to_string(), 1)) + ); +} + +#[test] +fn backspace_after_marker_and_floating_anchors_still_joins_paragraphs() { + let mut s = typed("Line one\nLine two"); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 0}})); + run(&mut s, "insert.textBox", json!({"text": "Box"})); + let (a, f) = (Pos::body(1, 0), Pos::body(1, 3)); + run(&mut s, "select.range", json!({"anchor": a, "focus": f})); + run(&mut s, "arrange.wrap", json!({"wrap": "inFrontOfText"})); + s.doc.insert_object(&Pos::body(1, 0), wordcraft_doc::InlineObject::BookmarkStart { name: "anchor".into() }, &Default::default()).unwrap(); + s.doc.insert_object(&Pos::body(1, 0), wordcraft_doc::InlineObject::CommentStart { id: 1 }, &Default::default()).unwrap(); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 9}})); + run(&mut s, "text.backspace", json!({})); + assert_eq!(s.doc.body.iter().filter(|b| b.as_para().is_some()).count(), 1); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).map(|p| p.text.replace('\u{FFFC}', "")), Some("Line oneLine two".to_string())); +} + +#[test] +fn backspace_cannot_join_into_locked_content_control() { + let mut s = typed("Above\nInside"); + run(&mut s, "select.range", json!({"anchor": {"story": "body", "path": [1], "off": 0}, "focus": {"story": "body", "path": [1], "off": 6}})); + run(&mut s, "developer.richText", json!({"tag": "locked"})); + run(&mut s, "developer.properties", json!({"control": "locked", "lockEdit": true})); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 0}})); + let before = s.doc.clone(); + assert!(s.run("text.backspace", &json!({})).is_err()); + assert_eq!(s.doc, before); +} diff --git a/docs/typing-parity.md b/docs/typing-parity.md index 8d488a46da..101f1443e9 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -81,6 +81,7 @@ Bullets and Numbering decide whether to toggle off from the selected paragraphs' | type, select some of it, type over it | two undo steps: the first ⌘Z brings the selected text back (still selected), the second removes the typing. Uninterrupted typing stays one step | `typing_then_selecting_then_typing_are_two_undo_steps` | | ⌘B with nothing selected, ⌘Z, type | the text isn't bold (Undo takes the waiting caret formatting back too); ⌘Y brings it back | `undoing_bold_at_the_caret_turns_bold_typing_off_again` | | ⌫ at the start of the document, or typing nothing | nothing happens: no undo step is added, the document is not marked edited, and the redo stack survives ³ | `backspace_at_the_start_of_the_document_is_not_an_edit`, `a_command_that_changes_nothing_is_not_an_edit`, `a_no_op_command_keeps_the_redo_stack` | +| ⌫ right after a floating shape's anchor at the paragraph start (In Front of Text, Square…) | joins the paragraph above and keeps the shape; the anchor is not a character to delete (checked in Word for Windows, #4592) | `backspace_after_a_floating_shape_anchor_joins_paragraphs_and_keeps_the_shape` | ³ Not re-observed side by side in Word for this change: Word's undo list does not grow when a key changes nothing, and the document is not marked edited. The gap was found by the read-only @@ -565,3 +566,5 @@ again. | 2026-10-10 | minor | Track Changes rows: tracked Enter, Backspace and Delete on paragraph marks (#244) | | 2026-10-10 | major | First version: lists, AutoCorrect and AutoFormat as you type, observed against Word (#204) | | 2026-10-15 | trivial | Text direction: typed and inserted text takes its direction from its own letters, not the caret's run; spaces typed into reopened Hebrew draw in the Latin font until reopened (#1543) | + +| 2026-10-27 | trivial | Backspace after a floating shape's anchor at a paragraph start joins paragraphs and keeps the shape (#4592) | \ No newline at end of file From d40e0eeb47f22e71b1d616bda8b29f52196413b1 Mon Sep 17 00:00:00 2001 From: Yasas Sri <164534752+Yasas-Sri@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:38:31 +0530 Subject: [PATCH 39/73] #4086: highlighting at a caret leaves the word alone (#4701) * #4086: highlighting at a caret leaves the word alone * #4086: the highlight toggle reads pending caret formatting; word formatting updates it --------- Co-authored-by: Brandon Thomas --- crates/engine/src/cmd/format.rs | 29 ++++++++++++++++++++++++++--- crates/engine/src/tests/typing.rs | 31 +++++++++++++++++++++++++++++++ docs/typing-parity.md | 8 +++++--- 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 1cccd5e239..ae1c9ef0fa 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -197,6 +197,17 @@ fn column_rows(s: &Session) -> Option> { /// Apply a change to the selection (or the pending caret formatting). pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult { + apply_to(s, target, f) +} + +/// The selection only, not the word at a caret: what highlighting formats (#4086). +fn selection(s: &Session) -> Option<(Pos, Pos)> { + let (a, b) = s.sel.ordered(); + (a != b).then_some((a, b)) +} + +/// [`apply`], with `target` choosing what a non-column selection formats. +fn apply_to(s: &mut Session, target: fn(&Session) -> Option<(Pos, Pos)>, f: &dyn Fn(&mut CharProps)) -> CmdResult { // A column selection formats each row's piece. if let Some(rows) = column_rows(s) { for (a, b) in rows { @@ -211,6 +222,12 @@ pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult { match selected_text_box(s).or_else(|| target(s)) { Some((a, b)) => { s.doc.format_range(&a, &b, f)?; + // The word at a caret took the change: so does formatting waiting for typing (#4086). + if s.sel.is_collapsed() + && let Some(pend) = s.pending.as_mut() + { + f(pend); + } } None => { let mut pend = s.typing_props(); @@ -410,7 +427,7 @@ fn target_has_highlight(s: &Session, h: Highlight) -> bool { return true; } } - if let Some((a, b)) = selected_text_box(s).or_else(|| target(s)) { + if let Some((a, b)) = selected_text_box(s).or_else(|| selection(s)) { for path in s.doc.paths_between(&a, &b) { let Some(p) = s.doc.para(a.story, &path) else { continue }; let from = if path == a.path { a.off } else { 0 }; @@ -422,7 +439,12 @@ fn target_has_highlight(s: &Session, h: Highlight) -> bool { } } } - // No runs: retain the caret/pending and empty text-box paragraph-mark rules. + // No runs: retain the caret/pending and empty text-box paragraph-mark rules. A caret checks + // what is typed next, not the word it's in (#4086). + if selected_text_box(s).or_else(|| selection(s)).is_none() { + let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.as_deref()); + return seen || matches(style, &s.typing_props()); + } seen || resolved(s).iter().all(|r| r.highlight == h.rgb()) } @@ -472,7 +494,8 @@ fn highlight(s: &mut Session, v: &Value) -> CmdResult { }; let remove = color.is_none() && target_has_highlight(s, h); let value = if remove { Highlight::None } else { h }; - let result = apply(s, &|x| x.highlight = Some(value))?; + // A caret highlights what is typed next, not the word it's in, as in Word (#4086). + let result = apply_to(s, selection, &|x| x.highlight = Some(value))?; s.last_highlight = h; Ok(result) } diff --git a/crates/engine/src/tests/typing.rs b/crates/engine/src/tests/typing.rs index b154bfabca..e8d68429b9 100644 --- a/crates/engine/src/tests/typing.rs +++ b/crates/engine/src/tests/typing.rs @@ -3196,6 +3196,37 @@ fn highlight_toggle_handles_mixed_inherited_and_pending_formatting() { assert_eq!(serde_json::to_value(&s.doc).unwrap(), before); } +/// #4086: a caret inside a word highlights what is typed next, not the word; Bold still +/// formats the word. +#[test] +fn highlight_at_a_caret_leaves_the_word_alone() { + let mut s = typed("Alpha Beta"); + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "caret.right", json!({})); + run(&mut s, "format.highlight", json!({"color":"yellow"})); + assert!(s.doc.body[0].as_para().unwrap().runs.iter().all(|r| r.props.highlight.is_none())); + assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::Yellow)); + run(&mut s, "text.insert", json!({"text":"X"})); + run(&mut s, "select.text", json!({"text":"X"})); + assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], json!("FFFF00")); + run(&mut s, "select.text", json!({"text":"Beta"})); + assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], serde_json::Value::Null); + // The toggle reads the pending colour, not the word's: a second press turns it off. + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "caret.right", json!({})); + run(&mut s, "format.highlight", json!({})); + assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::Yellow)); + run(&mut s, "format.highlight", json!({})); + assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::None)); + // Bold after Highlight formats the word and keeps both for what is typed next. + run(&mut s, "format.highlight", json!({})); + run(&mut s, "format.bold", json!({})); + run(&mut s, "text.insert", json!({"text":"Y"})); + run(&mut s, "select.text", json!({"text":"Y"})); + let state = s.run("format.state", &json!({})).unwrap(); + assert_eq!((state["highlight"].clone(), state["bold"].clone()), (json!("FFFF00"), json!(true))); +} + /// A differently coloured final paragraph must remain visible to the shortcut beyond /// the formatting-state query limit, including a style-inherited tail and Undo/Redo. #[cfg(not(target_os = "macos"))] diff --git a/docs/typing-parity.md b/docs/typing-parity.md index 101f1443e9..4fc27b7428 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -1,6 +1,6 @@ # Typing parity with Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-28 · **Change:** trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial: `*Bold* and _italic_ as real formatting` in AutoFormat As You Type; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: queued IME replacements are rejected before mutation after their marked composition loses its body/equation target; previously trivial: the transactional equation delete proves its commit with the engine revision; previously trivial: a refused byte-counted equation deletion drops only the immediately adjacent stale replacement, so a later direct commit still applies; previously trivial: a tracked own-insertion shifts the rest of a two-sided IME deletion; previously trivial: AutoCorrect/AutoFormat classification compares the actual paragraph; previously trivial: a replaced body starts a new input-method context and interrupts the composition; a quote the engine leaves as typed is the input method's own change; equation `DeleteSurrounding` erases counted characters; previously trivial: AutoCorrect finds the word after a no-break space or an ideographic space; 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 trivial: while an input method composes, the caret and its candidate window sit at the input method's cursor inside the composition; previously trivial: Ctrl+Tab types a tab in a table cell, #3137; previously trivial: List AutoFormat triggers in front of an already-written paragraph, not only a fresh empty one, checked in Word for Windows, #3352; previously trivial: AltGr types its characters without opening KeyTips on Linux, #2388; previously trivial: Delete on whole selected cells leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too, #2802; 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: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343; the body IME deletion maps signed placeholder growth/shrink, keeps control markers outside the range and scopes its undo join to the matching replacement) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-29 · **Change:** trivial (highlight at a caret leaves the word alone, #4086); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial: `*Bold* and _italic_ as real formatting` in AutoFormat As You Type; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: queued IME replacements are rejected before mutation after their marked composition loses its body/equation target; previously trivial: the transactional equation delete proves its commit with the engine revision; previously trivial: a refused byte-counted equation deletion drops only the immediately adjacent stale replacement, so a later direct commit still applies; previously trivial: a tracked own-insertion shifts the rest of a two-sided IME deletion; previously trivial: AutoCorrect/AutoFormat classification compares the actual paragraph; previously trivial: a replaced body starts a new input-method context and interrupts the composition; a quote the engine leaves as typed is the input method's own change; equation `DeleteSurrounding` erases counted characters; previously trivial: AutoCorrect finds the word after a no-break space or an ideographic space; 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 trivial: while an input method composes, the caret and its candidate window sit at the input method's cursor inside the composition; previously trivial: Ctrl+Tab types a tab in a table cell, #3137; previously trivial: List AutoFormat triggers in front of an already-written paragraph, not only a fresh empty one, checked in Word for Windows, #3352; previously trivial: AltGr types its characters without opening KeyTips on Linux, #2388; previously trivial: Delete on whole selected cells leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too, #2802; 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: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343; the body IME deletion maps signed placeholder growth/shrink, keeps control markers outside the range and scopes its undo join to the matching replacement) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 What Word does when you type, observed black-box (same keystrokes typed into Word and WordCraft, results read back paragraph by paragraph) and pinned by tests in @@ -20,8 +20,9 @@ The highlight decision checks the complete target, including mixed colours after formatting-state queries retain their existing sampling limit. Fully selected paragraph marks (including whole cells and empty paragraphs) participate in that decision; partial text columns ignore marks outside the selected range. Palette state is outside document undo; the formatting itself is undoable. -The existing selection/word/pending-caret targeting is unchanged (the separate request #4086 -is not addressed). +Highlighting formats the selection only: with a caret, even inside a word, it sets the +colour for what is typed next and leaves the word alone, as in Word (#4086; Bold and other +character formats still take the word at the caret). Regressions: `highlight_shortcut_toggles_the_last_colour` and `highlight_toggle_handles_mixed_inherited_and_pending_formatting`. Synthetic command/key @@ -502,6 +503,7 @@ again. | Date | Change | Summary | |---|---|---| +| 2026-10-28 | trivial | Highlighting at a caret leaves the word alone and sets the colour for what is typed next (#4086). | | 2026-10-28 | trivial | Arabic Backspace removes one combining mark and keeps its base letter (#4757); stored mark order, caret/forward-Delete and other-script behavior retained; original synthetic command/history/protection/save-reopen checks, no native Word comparison; readiness estimates unchanged | | 2026-10-25 | trivial | Highlight decisions include fully selected paragraph marks in whole cells and ordinary ranges, including empty paragraphs; partial text columns exclude unselected marks (#4085). | | 2026-10-25 | trivial | Highlight toggle checks the full target beyond 10,000 runs without collecting resolved formatting; mixed and inherited tails and Undo/Redo covered (#4085). | From 665ded28001281af190cae3667e2d6932b3ec267 Mon Sep 17 00:00:00 2001 From: TheRenegadeDev Date: Thu, 29 Oct 2026 23:09:01 -0500 Subject: [PATCH 40/73] Cross-reference: captions in a style based on Caption (#4411) (#4881) * #4411: captions in a style based on Caption are cross-reference targets Cross-reference and Table of Figures found captions only by the exact Caption style, so a caption restyled with a style based on Caption (a label's own "Caption Table") was left out: of five tableX captions, only the two still in Caption were offered. A caption is now a paragraph whose style is Caption or based on it. Co-Authored-By: Claude Opus 5.5 * #4411: test captions two styles from Caption and the Table type Covers a style based on a style based on Caption, a Table caption in such a style offered under the built-in Table type, and a caption label used only in a style not based on Caption staying out of the cross-reference types. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/cmd/citations/cross_refs.rs | 12 ++++-- crates/engine/src/cmd/citations/generated.rs | 14 +++--- crates/engine/src/cmd/citations/tests.rs | 43 +++++++++++++++++++ 3 files changed, 59 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/citations/cross_refs.rs b/crates/engine/src/cmd/citations/cross_refs.rs index 64967f0bd7..265a73fabc 100644 --- a/crates/engine/src/cmd/citations/cross_refs.rs +++ b/crates/engine/src/cmd/citations/cross_refs.rs @@ -1,5 +1,5 @@ use super::generated::page_of; -use super::{CharProps, CmdError, CmdResult, InlineObject, Paragraph, Pos, Selection, Session, StoryRef, Value, delete_selection, json, p}; +use super::{CharProps, CmdError, CmdResult, Document, InlineObject, Paragraph, Pos, Selection, Session, StoryRef, Value, delete_selection, json, p}; pub(super) fn cross_ref(s: &mut Session, v: &Value) -> CmdResult { let to = p::str(v, "to").unwrap_or("heading"); @@ -77,13 +77,19 @@ pub(super) fn caption_label(p: &Paragraph, text: &str) -> Option { (!label.is_empty() && label.chars().count() <= 40).then_some(label) } +/// Whether `p` is a caption: its style is Caption or one based on it, as a caption restyled with a +/// style of its own (a `Table Caption` based on Caption) still is (#4411). +pub(super) fn is_caption(doc: &Document, p: &Paragraph) -> bool { + p.props.style.as_deref().is_some_and(|id| id == "Caption" || doc.styles.chain(id).iter().any(|s| s.id == "Caption")) +} + /// The caption paragraphs of the body: (text, position, label). fn captions(s: &Session) -> Vec<(String, Pos, Option)> { s.doc .para_paths(StoryRef::Body) .into_iter() .filter_map(|path| { - let p = s.doc.para(StoryRef::Body, &path).filter(|p| p.props.style.as_deref() == Some("Caption"))?; + let p = s.doc.para(StoryRef::Body, &path).filter(|p| is_caption(&s.doc, p))?; let text = p.plain_text(); let label = caption_label(p, &text); Some((text, Pos { story: StoryRef::Body, path, off: 0 }, label)) @@ -125,7 +131,7 @@ pub fn ref_targets(s: &Session, to: &str) -> Vec<(String, Pos)> { }; for path in s.doc.para_paths(StoryRef::Body) { if let Some(p) = s.doc.para(StoryRef::Body, &path) - && p.props.style.as_deref() == Some("Caption") + && is_caption(&s.doc, p) && p.plain_text().starts_with(label) { targets.push((p.plain_text(), Pos { story: StoryRef::Body, path: path.clone(), off: 0 })); diff --git a/crates/engine/src/cmd/citations/generated.rs b/crates/engine/src/cmd/citations/generated.rs index f0946e10b2..364a6223be 100644 --- a/crates/engine/src/cmd/citations/generated.rs +++ b/crates/engine/src/cmd/citations/generated.rs @@ -1,10 +1,10 @@ -use super::cross_refs::caption_label; +use super::cross_refs::{caption_label, is_caption}; use super::render::entry; use super::sources::bib_order; use super::styles::style; use super::{ - Block, CharProps, CmdError, CmdResult, FieldCode, InlineObject, Paragraph, Path, Pos, Session, SortKey, StoryRef, TabAlign, TabLeader, TabStop, - Value, delete_selection, heading_letter, p, sel_result, sort_key, + Block, CharProps, CmdError, CmdResult, Document, FieldCode, InlineObject, Paragraph, Path, Pos, Session, SortKey, StoryRef, TabAlign, TabLeader, + TabStop, Value, delete_selection, heading_letter, p, sel_result, sort_key, }; /// The TOC field for `references.tableOfFigures` (ECMA-376 §17.16.5.68): `\c "