Compare commits

..
Author SHA1 Message Date
Martin Ball a98d0a71fe Merge remote-tracking branch 'origin/main' into HEAD
; Conflicts:
;	docs/layout-parity.md
2026-11-08 02:59:46 +00:00
Dylan M. Taylor b9d0ebbd26 Merge main (keep both layout-parity lines) 2026-11-04 04:06:28 +00:00
Dylan M. Taylor 1825b9250f Merge branch 'main' into fix/sec-decimal-tab-scan 2026-11-03 17:35:55 -05:00
Dylan M. Taylor 49cabcafc7 address review: layout progress doc for decimal-tab scan 2026-11-03 14:26:57 +00:00
Dylan M. Taylor 4ee514b103 Layout: look at the text after a decimal tab once, not once per cluster
Text after a decimal tab is shifted after each of its clusters, and each
shift walked the text again from the tab to find its first '.'. A long run
without a '.' (soft hyphens take no room, so it stays on one line) made a
single paragraph's line breaking quadratic. The scan now carries on from the
cluster it reached.
2026-11-02 22:23:48 -05:00
17 changed files with 73 additions and 393 deletions
-6
View File
@@ -173,7 +173,6 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
- 2026-11-05: Linux: a WordCraft started by an AppImage app no longer takes that app's file for its own (#5653). The AppImage runtime's `APPIMAGE`, `APPDIR`, `ARGV0` and `OWD` pass to whatever the app starts, so an installed WordCraft opened through `xdg-open` saw another program's `APPIMAGE`, and its self-update replaced that program's file. The app now starts over without those variables unless its executable is under `APPDIR`, so the update goes through the package or plain file it really is. Should starting over fail, the self-update is off in that window and Help › Check for Updates says why. Readiness estimates unchanged.
- 2026-11-05: A Word template (`.dotx`, `.dotm`, `.dot`) opened from the file manager, the command line or the Finder starts a new, untitled document based on it, as Word's default verb for a template does (#3433). It opened the template itself, so Save (and AutoSave) wrote over it. The new document is attached to the template and Save asks where to write; a protected template asks for its password and still starts a new document. File › Open and a file dropped on the window still open a template itself, to edit it.
- 2026-11-05: Arabic letters join across a change of formatting (gap 16, #4093). Each run was shaped on its own, so a letter at a run's edge took the form of a word's start or end: a coloured letter stood apart, and a word edited with Track Changes on read with a space in it (ال ذكاء). Each run is now shaped knowing the text shown on either side, past hidden or deleted text left out of the view, as for Syriac, Mongolian and the other joining scripts. Readiness estimates unchanged.
- 2026-11-05: Shapes, text boxes, groups and drawing canvases in Strict Open XML documents open (#3548). Strict puts them in its one drawing namespace, where Transitional documents use Word 2010's `wps`, `wpg` and `wpc`, and Word writes a Strict text box's text in `wne:txbxContent`; the reader knew neither, so a Strict file opened with its text and no shapes at all. They now read like their Transitional twins and are saved as Transitional. Charts and SmartArt in Strict namespaces are not mapped yet.
- 2026-10-28: Ink replay, eraser picking, lasso selection and arrow-candidate discovery reuse the paragraph prefixes they have already scanned (#3614). Page item order, stroke geometry and saved data retain their behavior. This narrows the stability work; ink import fidelity, native pen input and broader corpus qualification remain open.
- 2026-10-29: Page drawing finds text-box warps without rescanning the paragraph for every box (#3614). Only requested offsets are indexed, and each paragraph walk stops after its last requested character. Original Linux discovery, complete display-output and CLI/MCP save/reopen controls; native Word comparison untested. Readiness estimates unchanged.
- 2026-10-28: Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged. Existing mutation and protection checks retain their costs; this does not close the broader performance gap.
@@ -226,14 +225,11 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
- 2026-11-04: Table Sort refuses malformed and nonexistent explicit columns before reordering rows (#5531). Omitted column 0, valid columns, header/order/language behavior and Undo/Redo are retained. Original Linux CLI/MCP controls and repeated native/DOCX reopens cover the command; native Word and desktop comparison remain untested. Readiness estimates unchanged.
- 2026-10-30: Layout resource caps (#3867, #4385) and linear-time passes (#3866, #4392): repeated table-header rows are capped by item counts, a clipped group counting every nested member, the wrap cache by decoded pixels and the widths a linked text box chain lays out by their lines and items; PDF export, hit testing, tracked-change and clipped-table costs, and long endnote selections no longer grow quadratically. Synthetic regression tests only; no new Word comparison.
- 2026-11-03: Proofing (CP4.3): craft-proof moves to craft-libs main with #589 and #590. French spelling suggestions offer the elided form first (`Jadore` → `J'adore`, before `J adore`), and Valencian text (`ca-ES-valencia`) is proofed with the `ca-valencia` pack instead of plain Catalan. Readiness unchanged.
- 2026-11-03: Security: PDF export leaves the document's author out of the PDF's properties when "Remove personal information from file properties on save" is on, as DOCX saving already does. Readiness unchanged.
- 2026-11-03: A long comment no longer grows its card or balloon without limit (#3382). Its field in the Comments pane and in a selected balloon stops at ten lines and scrolls, keeping the caret in view; a balloon that isn't selected shows the first ten lines of the comment and of each reply, and clicking it opens the scrolling field. The buttons and the next comment stay in view.
- 2026-11-03: Arabic dialogs: the dialogs of the mirrored interface now read right to left. Rows of a label and its field, lists of columns (Compare, AutoCorrect, Special Characters, Track Changes Options, the Columns widths), the tile galleries (chart types, SmartArt layouts) and the ribbon Paragraph group start at the right edge, through an `rtl::Table` helper that is an `egui::Grid` in English; the Add >> and << Remove arrows swap, the Columns preset captions and the Macros buttons no longer overlap, and the Recipient List grid and About › Models scroll instead of running past the left edge. Readiness unchanged.
- 2026-11-03: Shape Format › Size: the width and height fields show the interface unit (centimetres, inches…) instead of always points, and take a size typed in any unit, such as `3 cm` or `300 px`, applied once typed (Esc cancels), each side on its own, as Picture Format › Size does (#4196). In both, a field clicked and left with Enter or Tab without typing no longer resizes the object by the rounding of the size it shows (a 100 pt shape became 100.08 pt in inches).
- 2026-11-03: Security: Paste Special with picture files on the clipboard refuses anything that isn't a regular file (a pipe or device reports no length and can produce endless data), and `cargo xtask stats` no longer follows directory symlinks. Readiness unchanged.
- 2026-11-03: Size keeps the side it isn't asked to change (#5469): typing only the width of a straight line keeps its height of 0 instead of making it 1 pt; the 1–4000 pt limits apply only to the sides a `picture.size` call changes. Readiness unchanged.
- 2026-11-03: Find and Replace: a keyboard apostrophe also finds typographic single quotes, so smart-quoted typing stays searchable (#5388). Regex, wildcards, literal searches and numeric character codes remain exact. Original UTF-8 match ranges and replacement spelling are preserved; readiness unchanged.
- 2026-11-03: Security: XLSX import finds each sheet's worksheet part through an index of the workbook's relationships, built once, instead of searching every relationship for every sheet. Readiness unchanged.
- 2026-11-03: Security: pasting text boxes charges each copied story for its formatting too (run, mark, paragraph and section properties), not only its text and objects, so a story with huge style or font names can't copy past the paste's 32 MiB text box budget. Readiness unchanged.
- 2026-11-03: Security: `.doc` import keeps one width, shading, alignment and merge override per table cell, the last one winning as before, instead of a copy every time a paragraph's properties repeat the sprm, so a shared PAPX can't pile up copies on every paragraph mark. Readiness unchanged.
- 2026-11-03: Security: `.doc` import attaches each section to the paragraph that ends it through an index of paragraph ends, instead of scanning every paragraph for every section, so a file with a thousand sections that end nowhere can't multiply the work over its paragraphs. Readiness unchanged.
@@ -833,7 +829,6 @@ MATH glyphs that reference the same construction now share its immutable variant
> 2026-11-05 · Linux self-update ignores another program's AppImage variables (#5653); recorded in the progress log, readiness unchanged.
> 2026-11-05 · A template opened from the file manager starts a new document instead of being edited and saved over (#3433); recorded in the progress log, readiness unchanged.
> 2026-11-05 · Arabic letters join across a change of formatting and past hidden or deleted text (#4093); recorded in the progress log, readiness unchanged.
> 2026-11-05 · Shapes, text boxes, groups and canvases in Strict Open XML documents open (#3548); recorded in the progress log, readiness unchanged.
>
> 2026-11-03 · Arabic dialogs read right to left (rtl::Table); recorded in the progress log, readiness unchanged.
>
@@ -899,7 +894,6 @@ MATH glyphs that reference the same construction now share its immutable variant
| 2026-11-03 | trivial | Progress log: craft-proof pin, French elision suggestions and Valencian (CP4.3) |
| 2026-11-03 | trivial | Progress log: a long comment's card and balloon stop at ten lines (#3382) |
| 2026-11-03 | trivial | Progress log: Shape Format › Size in the interface unit (#4196) |
| 2026-11-03 | trivial | Find smart-quoted text with a keyboard apostrophe (#5388); exact search modes and replacement spelling preserved. |
| 2026-11-03 | trivial | Progress log: Replace and Remove Table of Contents with Word's title above a section break (#4041) |
| 2026-11-03 | trivial | Progress log: language-packs-2 published and pinned, 75 Language packs |
| 2026-11-02 | trivial | Progress log: language-packs-1 published and pinned, 61 Language packs (CP4.4) |
+4 -83
View File
@@ -94,49 +94,6 @@ fn prefix_for(uri: &[u8], extra: &[(&'static str, &str)]) -> &'static str {
"?"
}
/// Word's 2006 namespace (`wne`). A Strict document from Word keeps a text box's content in
/// `wne:txbxContent`; a Transitional one uses `w:txbxContent`.
const WNE: &[u8] = b"http://schemas.microsoft.com/office/word/2006/wordml";
/// The canonical prefix of an element named `local` in namespace `uri`, inside an element
/// named `parent` (canonical, `None` at the root). This is [`prefix_for`] with two exceptions
/// for Strict documents (#3548):
///
/// - Shapes, groups and canvases (`wsp`, `wgp`, `wpc`) and their parts are elements of the
/// WordprocessingDrawing namespace in ECMA-376 Part 1, 5th edition (`dml-wordprocessingDrawing.xsd`,
/// Strict and Transitional alike). A Strict document from Word puts them there; a Transitional
/// one uses Word 2010's own namespaces, whose names the reader knows. So they read as `wps:`,
/// `wpg:` and `wpc:` (see [`drawing_prefix`]).
/// - A shape's text box content reads as `w:txbxContent` from the drawing namespace (the
/// schema's name) and from Word's `wne:txbxContent`.
fn element_prefix(uri: &[u8], local: &str, parent: Option<&str>, extra: &[(&'static str, &str)]) -> &'static str {
match prefix_for(uri, extra) {
"wp" => drawing_prefix(local, parent),
"?" if uri == WNE && local == "txbxContent" && parent == Some("wps:txbx") => "w",
p => p,
}
}
/// The prefix for `local`, an element of the WordprocessingDrawing namespace inside `parent`.
/// Word 2010's namespaces split what this one holds: a shape and its parts are `wps:`, a group
/// (`wgp`, a nested `grpSp`) and a graphic frame in it are `wpg:`, and a canvas is `wpc:`. Its
/// members are named for what they are. A text box's content is `w:txbxContent`, and anything
/// else (`inline`, `anchor`, `docPr`…) stays `wp:`.
fn drawing_prefix(local: &str, parent: Option<&str>) -> &'static str {
match (parent, local) {
(_, "wsp") => "wps",
(_, "wgp" | "grpSp" | "graphicFrame") => "wpg",
(_, "wpc") => "wpc",
(Some("wps:wsp"), "cNvPr" | "cNvSpPr" | "cNvCnPr" | "spPr" | "style" | "extLst" | "txbx" | "linkedTxbx" | "bodyPr") => "wps",
(Some("wps:txbx"), "txbxContent") => "w",
(Some("wps:txbx" | "wps:linkedTxbx"), "extLst") => "wps",
(Some("wpg:wgp" | "wpg:grpSp"), "cNvPr" | "cNvGrpSpPr" | "grpSpPr" | "extLst") => "wpg",
(Some("wpg:graphicFrame"), "cNvPr" | "cNvFrPr" | "xfrm" | "extLst") => "wpg",
(Some("wpc:wpc"), "bg" | "whole" | "extLst") => "wpc",
_ => "wp",
}
}
/// Compatibility feature namespaces whose elements are not otherwise handled by this tree.
/// Keep the reader's existing feature set when canonicalizing `mc:Choice/@Requires` aliases.
fn requirement_prefix(uri: &[u8]) -> &'static str {
@@ -385,8 +342,8 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
loop {
buf.clear();
let (res, ev) = r.read_resolved_event_into(&mut buf).map_err(|e| DocxError::Xml(e.to_string()))?;
let el_ns = match &res {
ResolveResult::Bound(ns) => Some(ns.as_ref()),
let el_prefix = match &res {
ResolveResult::Bound(ns) => Some(prefix_for(ns.as_ref(), extra)),
_ => None,
};
match ev {
@@ -399,8 +356,8 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
return Err(DocxError::Limit("too many XML elements".into()));
}
let local = String::from_utf8_lossy(e.local_name().as_ref()).into_owned();
let name = match el_ns {
Some(ns) => format!("{}:{local}", element_prefix(ns, &local, stack.last().map(|p| p.name.as_str()), extra)),
let name = match el_prefix {
Some(p) => format!("{p}:{local}"),
None => local,
};
let mut el = El { name, attrs: Vec::new(), kids: Vec::new() };
@@ -636,42 +593,6 @@ mod tests {
assert!(parse(b"<a></a><b></b>").is_ok(), "lenient parsing still stops at the root");
}
/// Every element name in `e`, depth first.
fn names(e: &El, out: &mut Vec<String>) {
out.push(e.name.clone());
for c in e.els() {
names(c, out);
}
}
#[test]
fn strict_drawing_names_read_as_word_2010_names() {
// A Strict shape with a text box, a group with a nested group and a graphic frame, and
// a canvas, each in the one Strict WordprocessingDrawing namespace (#3548).
let xml = br#"<w:body xmlns:w="http://purl.oclc.org/ooxml/wordprocessingml/main" xmlns:a="http://purl.oclc.org/ooxml/drawingml/main" xmlns:d="http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml">
<d:anchor><d:extent/><d:docPr/><d:cNvGraphicFramePr/><a:graphic><a:graphicData>
<d:wsp><d:cNvPr/><d:cNvSpPr/><d:spPr/><d:style/><d:extLst/><d:txbx><wne:txbxContent><w:p/></wne:txbxContent></d:txbx><d:bodyPr/></d:wsp>
<d:wgp><d:cNvPr/><d:cNvGrpSpPr/><d:grpSpPr/><d:grpSp><d:cNvGrpSpPr/><d:grpSpPr/><d:wsp><d:cNvCnPr/><d:spPr/><d:linkedTxbx/><d:bodyPr/></d:wsp></d:grpSp>
<d:graphicFrame><d:cNvPr/><d:cNvFrPr/><d:xfrm/><a:graphic/><d:extLst/></d:graphicFrame><d:extLst/></d:wgp>
<d:wpc><d:bg/><d:whole/><d:wsp><d:spPr/><d:txbx><d:txbxContent/><d:extLst/></d:txbx></d:wsp><d:wgp><d:grpSpPr/></d:wgp><d:extLst/></d:wpc>
</a:graphicData></a:graphic></d:anchor>
<wne:txbxContent/><d:inline><d:docPr/><d:bodyPr/><d:bg/></d:inline></w:body>"#;
let mut got = Vec::new();
names(&parse(xml).unwrap(), &mut got);
let want = "w:body wp:anchor wp:extent wp:docPr wp:cNvGraphicFramePr a:graphic a:graphicData \
wps:wsp wps:cNvPr wps:cNvSpPr wps:spPr wps:style wps:extLst wps:txbx w:txbxContent w:p wps:bodyPr \
wpg:wgp wpg:cNvPr wpg:cNvGrpSpPr wpg:grpSpPr wpg:grpSp wpg:cNvGrpSpPr wpg:grpSpPr wps:wsp wps:cNvCnPr wps:spPr wps:linkedTxbx wps:bodyPr \
wpg:graphicFrame wpg:cNvPr wpg:cNvFrPr wpg:xfrm a:graphic wpg:extLst wpg:extLst \
wpc:wpc wpc:bg wpc:whole wps:wsp wps:spPr wps:txbx w:txbxContent wps:extLst wpg:wgp wpg:grpSpPr wpc:extLst \
?:txbxContent wp:inline wp:docPr wp:bodyPr wp:bg";
assert_eq!(got.join(" "), want);
// The Transitional namespaces read as before.
let xml = br#"<w:body xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml"><wp:inline><wp:docPr/></wp:inline><wps:wsp><wps:txbx><w:txbxContent/></wps:txbx></wps:wsp><wne:toolbars/></w:body>"#;
let mut got = Vec::new();
names(&parse(xml).unwrap(), &mut got);
assert_eq!(got.join(" "), "w:body wp:inline wp:docPr wps:wsp wps:txbx w:txbxContent ?:toolbars");
}
#[test]
fn a_relationship_attribute_is_the_namespaced_one_or_the_alias_of_one() {
// The relationship namespace, and VML's `o:relid`.
-1
View File
@@ -35,7 +35,6 @@ mod section_page_number_type;
mod section_text_direction;
mod section_valign;
mod settings_merge;
mod strict_drawings;
mod text_alignment;
mod text_boxes;
mod toc_ranges;
-182
View File
@@ -1,182 +0,0 @@
//! Shapes, text boxes, groups and canvases in a Strict document (#3548). ISO/IEC 29500 Strict
//! puts them in the one WordprocessingDrawing namespace, where Transitional documents use Word
//! 2010's `wps`, `wpg` and `wpc` namespaces. Fixtures are original and hand-written from
//! ECMA-376 Part 1, 5th edition (`dml-wordprocessingDrawing.xsd`, Strict and Transitional): each
//! drawing is written once and filled in with either set of namespaces.
use std::io::{Read, Write};
use wordcraft_doc::{Document, InlineObject, PartKind};
const STRICT_NS: &str = r#"xmlns:w="http://purl.oclc.org/ooxml/wordprocessingml/main" xmlns:r="http://purl.oclc.org/ooxml/officeDocument/relationships" xmlns:wp="http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing" xmlns:a="http://purl.oclc.org/ooxml/drawingml/main" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml" w:conformance="strict""#;
const TRANSITIONAL_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas""#;
const STRICT_RELS: &str = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://purl.oclc.org/ooxml/officeDocument/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
const TRANSITIONAL_RELS: &str = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
/// How a fixture names its drawing elements: the shape, group and canvas prefixes, and the
/// text box content element.
struct Names {
ns: &'static str,
rels: &'static str,
shape: &'static str,
group: &'static str,
canvas: &'static str,
content: &'static str,
}
/// Word's Strict output: everything in `wp:`, text box content in `wne:txbxContent`.
const STRICT_WORD: Names = Names { ns: STRICT_NS, rels: STRICT_RELS, shape: "wp", group: "wp", canvas: "wp", content: "wne:txbxContent" };
/// The Strict schema's own names: the text box content in `wp:` too.
const STRICT_SCHEMA: Names = Names { ns: STRICT_NS, rels: STRICT_RELS, shape: "wp", group: "wp", canvas: "wp", content: "wp:txbxContent" };
/// The same drawings in a Transitional document.
const TRANSITIONAL: Names =
Names { ns: TRANSITIONAL_NS, rels: TRANSITIONAL_RELS, shape: "wps", group: "wpg", canvas: "wpc", content: "w:txbxContent" };
fn zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in entries {
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn document_xml(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
s
}
fn solid(rgb: &str) -> String {
format!(r#"<a:solidFill><a:srgbClr val="{rgb}"/></a:solidFill>"#)
}
/// A `wsp` at (`x`, `y`) EMUs in its group, `cx` × `cy`. `text` gives it a text box.
fn shape(n: &Names, prst: &str, (x, y, cx, cy): (u32, u32, u32, u32), fill: &str, text: Option<&str>) -> String {
let s = n.shape;
let connector = prst.contains("Connector");
let nv = if connector {
format!("<{s}:cNvCnPr/>")
} else if text.is_some() {
format!(r#"<{s}:cNvSpPr txBox="1"/>"#)
} else {
format!("<{s}:cNvSpPr/>")
};
let fill = if connector { String::new() } else { solid(fill) };
let line =
if connector { r#"<a:ln w="25400"><a:solidFill><a:srgbClr val="117A65"/></a:solidFill><a:tailEnd type="triangle"/></a:ln>"# } else { "" };
let txbx = match text {
Some(t) => {
let c = n.content;
format!(r#"<{s}:txbx><{c}><w:p><w:r><w:t>{t}</w:t></w:r></w:p></{c}></{s}:txbx>"#)
}
None => String::new(),
};
format!(
r#"<{s}:wsp><{s}:cNvPr id="{cx}" name="{prst}"/>{nv}<{s}:spPr><a:xfrm><a:off x="{x}" y="{y}"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm><a:prstGeom prst="{prst}"><a:avLst/></a:prstGeom>{fill}{line}</{s}:spPr>{txbx}<{s}:bodyPr anchor="ctr"/></{s}:wsp>"#
)
}
/// Drawing `id`, anchored `x` EMUs from the page's left edge, 1 in × ½ in.
fn anchor(id: u32, x: u32, uri: &str, payload: &str) -> String {
format!(
r#"<w:r><w:drawing><wp:anchor distT="0" distB="0" distL="0" distR="0" simplePos="0" relativeHeight="{id}" behindDoc="0" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="page"><wp:posOffset>{x}</wp:posOffset></wp:positionH><wp:positionV relativeFrom="page"><wp:posOffset>914400</wp:posOffset></wp:positionV><wp:extent cx="914400" cy="457200"/><wp:effectExtent l="0" t="0" r="0" b="0"/><wp:wrapNone/><wp:docPr id="{id}" name="Drawing {id}"/><wp:cNvGraphicFramePr/><a:graphic><a:graphicData uri="{uri}">{payload}</a:graphicData></a:graphic></wp:anchor></w:drawing></w:r>"#
)
}
/// A rectangle, a text box, an arrow, a group holding a nested group, and a canvas holding a
/// shape and a group, each 1 in × ½ in.
fn package(n: &Names) -> Vec<u8> {
const WPS: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingShape";
const WPG: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup";
const WPC: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas";
let full = (0, 0, 914_400, 457_200);
let half = (0, 0, 457_200, 457_200);
let g = n.group;
let group_xfrm = |(x, y, cx, cy): (u32, u32, u32, u32)| {
format!(
r#"<{g}:cNvGrpSpPr/><{g}:grpSpPr><a:xfrm><a:off x="{x}" y="{y}"/><a:ext cx="{cx}" cy="{cy}"/><a:chOff x="0" y="0"/><a:chExt cx="{cx}" cy="{cy}"/></a:xfrm>{}</{g}:grpSpPr>"#,
solid("F39C12")
)
};
let group = format!(
"<{g}:wgp>{}{}<{g}:grpSp>{}{}</{g}:grpSp></{g}:wgp>",
group_xfrm(full),
shape(n, "ellipse", half, "28B463", None),
group_xfrm((457_200, 0, 457_200, 457_200)),
shape(n, "triangle", half, "8E44AD", None),
);
let c = n.canvas;
let canvas = format!(
r#"<{c}:wpc><{c}:bg>{}</{c}:bg><{c}:whole><a:ln w="12700">{}</a:ln></{c}:whole>{}<{g}:wgp>{}{}</{g}:wgp></{c}:wpc>"#,
solid("FDF2E9"),
solid("A04000"),
shape(n, "roundRect", (45_720, 45_720, 365_760, 365_760), "16A085", None),
group_xfrm((457_200, 45_720, 365_760, 365_760)),
shape(n, "diamond", (0, 0, 365_760, 365_760), "2E86C1", None),
);
let drawings = [
anchor(1, 914_400, WPS, &shape(n, "rect", full, "C0392B", None)),
anchor(2, 2_286_000, WPS, &shape(n, "rect", full, "EAF2F8", Some("Strict text box"))),
anchor(3, 3_657_600, WPS, &shape(n, "straightConnector1", full, "", None)),
anchor(4, 5_029_200, WPG, &group),
anchor(5, 6_400_800, WPC, &canvas),
];
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {}><w:body><w:p><w:r><w:t>Shapes</w:t></w:r>{}</w:p><w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr></w:body></w:document>"#,
n.ns,
drawings.concat()
);
zip(&[("_rels/.rels", n.rels.as_bytes()), ("word/document.xml", doc.as_bytes())])
}
fn objects(d: &Document) -> Vec<InlineObject> {
d.body[0].as_para().unwrap().objects.clone()
}
/// The text of each text box story, in reading order.
fn box_texts(d: &Document) -> Vec<String> {
let mut ids = Vec::new();
d.objects_in_reading_order(&mut |o| ids.extend(o.text_boxes()));
ids.iter()
.filter_map(|id| d.parts.get(id).filter(|p| p.kind == PartKind::TextBox))
.map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect::<Vec<_>>().join("\n"))
.collect()
}
#[test]
fn strict_shapes_read_like_their_transitional_twins() {
let transitional = wordcraft_docx::read(&package(&TRANSITIONAL)).unwrap();
let want = objects(&transitional);
// The twin itself reads every drawing: a rectangle, a text box, an arrow, a group whose
// nested group is flattened into it, and a canvas with its background.
assert_eq!(want.len(), 5, "{want:#?}");
assert!(matches!(&want[0], InlineObject::Shape { story: None, freeform: None, .. }), "{:?}", want[0]);
assert!(matches!(&want[1], InlineObject::Shape { story: Some(_), .. }), "{:?}", want[1]);
assert!(matches!(&want[3], InlineObject::Group { children, canvas: None, .. } if children.len() == 2), "{:?}", want[3]);
assert!(
matches!(&want[4], InlineObject::Group { children, canvas: Some(c), .. } if children.len() == 2 && c.fill.is_some() && c.stroke.is_some()),
"{:?}",
want[4]
);
assert_eq!(box_texts(&transitional), ["Strict text box"]);
for (name, names) in [("Word's Strict names", &STRICT_WORD), ("the Strict schema's names", &STRICT_SCHEMA)] {
let strict = wordcraft_docx::read(&package(names)).unwrap();
assert_eq!(objects(&strict), want, "{name}");
assert_eq!(box_texts(&strict), ["Strict text box"], "{name}");
}
}
#[test]
fn strict_shapes_are_saved_and_reopened() {
let strict = wordcraft_docx::read(&package(&STRICT_WORD)).unwrap();
let saved = wordcraft_docx::write(&strict).unwrap();
let xml = document_xml(&saved);
for name in ["<wps:wsp>", "<wpg:wgp>", "<wpc:wpc>", "<wpc:bg>", "<w:txbxContent>", "<wps:cNvCnPr"] {
assert!(xml.contains(name), "{name} missing from the saved document");
}
let reopened = wordcraft_docx::read(&saved).unwrap();
assert_eq!(objects(&reopened), objects(&strict));
assert_eq!(box_texts(&reopened), ["Strict text box"]);
}
+1 -3
View File
@@ -198,9 +198,7 @@ fn special(query: &str, o: &Opts) -> (String, bool) {
if (o.ignore_punct && is_punct(c)) || (o.ignore_space && is_space(c)) {
continue;
}
// A keyboard apostrophe also finds text produced by smart quotes. Explicit character
// codes above, and literal/regex/wildcard searches, keep exact character matching.
atoms.push(if c == '\'' { "['‘’]".into() } else { lit(c) });
atoms.push(lit(c));
}
let filler = match (o.ignore_punct, o.ignore_space) {
(true, true) => r"[\p{P} \t\x{A0}]*",
-1
View File
@@ -12,7 +12,6 @@ use crate::{Session, cmd};
mod bidi;
mod documents;
mod find_apostrophes;
mod font_sizes;
mod fonts;
mod ime_noop;
@@ -1,48 +0,0 @@
use super::*;
/// Keyboard smart quotes stay searchable, and replacement keeps their original spelling.
#[test]
fn keyboard_apostrophe_finds_smart_quoted_typing() {
let mut s = s();
for ch in "Tau'va".chars() {
run(&mut s, "text.insert", json!({"text": ch.to_string()}));
}
assert_eq!(text(&s), "Tau’va");
for query in ["Tau'", "Tau'v", "Tau'va", "tau'VA"] {
assert_eq!(run(&mut s, "edit.find", json!({"text": query}))["count"], 1, "{query}");
}
assert_eq!(run(&mut s, "edit.find", json!({"text": "tau'va", "matchCase": true}))["count"], 0);
assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "Tau'va", "with": "[^&]", "matchCase": false}))["replaced"], 1);
assert_eq!(text(&s), "[Tau’va]");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "Tau’va");
run(&mut s, "edit.redo", json!({}));
assert_eq!(text(&s), "[Tau’va]");
}
/// Normal search uses raw UTF-8 ranges while explicit syntax and modifier letters stay exact.
#[test]
fn normal_apostrophe_search_keeps_exact_modes_and_original_byte_ranges() {
let mut s = s();
let original = "🙂 Tau'va Tau’va Tau‘va Tauʼva";
run(&mut s, "document.setText", json!({"text": original}));
let found = run(&mut s, "edit.find", json!({"text": "Tau'va", "wholeWord": true}));
assert_eq!(found["count"], 3);
let ranges: Vec<_> =
found["matches"].as_array().unwrap().iter().map(|m| (m["start"]["off"].as_u64().unwrap(), m["end"]["off"].as_u64().unwrap())).collect();
assert_eq!(ranges, [(5, 11), (12, 20), (21, 29)]);
for options in [json!({"special": false}), json!({"wildcards": true}), json!({"regex": true})] {
let mut exact = super::s();
run(&mut exact, "document.setText", json!({"text": original}));
let mut params = options;
params["text"] = json!("Tau'va");
assert_eq!(run(&mut exact, "edit.find", params)["count"], 1);
}
for query in ["Tau^u39va", "Tau^u8217va", "Tau’va", "Tau‘va", "Tauʼva"] {
assert_eq!(run(&mut s, "edit.find", json!({"text": query}))["count"], 1, "{query}");
}
assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "Tau'va", "with": "<^&>"}))["replaced"], 3);
assert_eq!(text(&s), "🙂 <Tau'va> <Tau’va> <Tau‘va> Tauʼva");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), original);
}
-21
View File
@@ -427,27 +427,6 @@ mod tests {
const RELS: &str = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>"#;
#[test]
fn workbook_sheets_find_their_relationships_by_index() {
// Every sheet searched every relationship; 4,096 sheets over 20,000 relationships.
let sheets: String = (0..MAX_SHEETS).map(|i| format!(r#"<sheet name="S{i}" sheetId="{i}" r:id="rId{i}"/>"#)).collect();
let wb = format!(
r#"<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>{sheets}</sheets></workbook>"#
);
let mut rels = String::from(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#);
// A chart sheet sharing a worksheet's id doesn't hide it.
rels.push_str(r#"<Relationship Id="rId0" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chartsheet" Target="chartsheets/c.xml"/>"#);
for i in 0..20_000 {
rels.push_str(&format!(
r#"<Relationship Id="rId{i}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet{i}.xml"/>"#
));
}
rels.push_str("</Relationships>");
let names = sheet_names(&zip(&[("_rels/.rels", RELS), ("xl/workbook.xml", &wb), ("xl/_rels/workbook.xml.rels", &rels)])).unwrap();
assert_eq!(names.len(), MAX_SHEETS);
assert_eq!(names.first().map(String::as_str), Some("S0"));
}
fn xlsx(sheet1: &str) -> Vec<u8> {
let wb = r#"<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><workbookPr/><sheets><sheet name="People" sheetId="1" r:id="rId1"/><sheet name="Chart" sheetId="2" r:id="rId9"/><sheet name="Notes" sheetId="3" r:id="rId2"/></sheets></workbook>"#;
let wb_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="/xl/worksheets/sheet2.xml"/><Relationship Id="rId9" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chartsheet" Target="chartsheets/sheet1.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/><Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
+8 -7
View File
@@ -83,13 +83,14 @@ fn workbook(pkg: &mut Package<'_>) -> Result<Workbook, String> {
}
}
});
// Worksheets only: chart and dialog sheets hold no cells. Indexed by id once (the first
// relationship with an id wins), not searched for every sheet.
let mut worksheets: HashMap<&str, &str> = HashMap::new();
for r in rels.iter().filter(|r| r.kind.ends_with("/worksheet")) {
worksheets.entry(r.id.as_str()).or_insert(r.target.as_str());
}
let sheets = listed.into_iter().filter_map(|(name, id)| Some(SheetRef { name, path: worksheets.get(id.as_str())?.to_string() })).collect();
let sheets = listed
.into_iter()
.filter_map(|(name, id)| {
// Worksheets only: chart and dialog sheets hold no cells.
let r = rels.iter().find(|r| r.id == id && r.kind.ends_with("/worksheet"))?;
Some(SheetRef { name, path: r.target.clone() })
})
.collect();
let part = |suffix: &str| rels.iter().find(|r| r.kind.ends_with(suffix)).map(|r| r.target.clone());
Ok(Workbook { sheets, date1904, shared: part("/sharedStrings"), styles: part("/styles") })
}
+36 -12
View File
@@ -92,6 +92,8 @@ struct Scan {
j: usize,
/// Tab cluster, stop, and x where the tab started.
pending_tab: Option<(usize, TabStop, f32)>,
/// The text after a pending decimal tab looked at so far for its '.'.
decimal: Option<DecimalScan>,
/// The width of the spaces so far on this line.
space_w: f32,
has_tab: bool,
@@ -113,17 +115,15 @@ pub(crate) fn takes_room(c: &Cluster) -> bool {
c.kind != ClKind::Marker && !(matches!(c.kind, ClKind::Object(_)) && c.adv == 0.0 && c.obj_h == 0.0)
}
/// The width up to the first '.' of the text after the tab at `tj`, up to cluster `j`.
fn width_to_decimal(pl: &ParaLayout, tj: usize, j: usize) -> Option<f32> {
let mut w = 0.0;
for k in tj + 1..=j {
let cc = pl.clusters.get(k)?;
if pl_text_at(cc) {
return Some(w);
}
w += cc.adv;
}
None
/// How far the text after a decimal tab has been looked at for its first '.' (see
/// [`Scan::width_to_decimal`]): the tab, the next cluster to look at, the width before it, and
/// whether that cluster is the '.'.
#[derive(Clone, Copy, Default)]
struct DecimalScan {
tab: usize,
next: usize,
width: f32,
found: bool,
}
/// The objects' exclusions mirrored across a line of `width`, for a right-to-left paragraph.
@@ -279,6 +279,7 @@ impl<'e> Breaker<'e> {
end: LineEnd::Para,
j: self.i,
pending_tab: None,
decimal: None,
space_w: 0.0,
has_tab: false,
skip: false,
@@ -917,6 +918,29 @@ impl Scan {
false
}
/// The width up to the first '.' of the text after the tab at `tj`, up to cluster `j`. The
/// text is shifted after each of its clusters, so the clusters already looked at are not
/// walked again: a long run without a '.' after a decimal tab is looked at once, not once
/// per cluster.
fn width_to_decimal(&mut self, pl: &ParaLayout, tj: usize, j: usize) -> Option<f32> {
let start = DecimalScan { tab: tj, next: tj + 1, width: 0.0, found: false };
let d = self.decimal.get_or_insert(start);
if d.tab != tj || d.next > j.saturating_add(1) {
*d = start;
}
while !d.found && d.next <= j {
crate::work::steps(1);
let cc = pl.clusters.get(d.next)?;
if pl_text_at(cc) {
d.found = true;
} else {
d.width += cc.adv;
d.next += 1;
}
}
d.found.then_some(d.width)
}
/// Text after a decimal/centre/right tab shifts as it grows.
fn shift_pending_tab(&mut self, pl: &mut ParaLayout) {
let Some((tj, stop, tx)) = self.pending_tab else { return };
@@ -925,7 +949,7 @@ impl Scan {
TabAlign::Right => stop.pos - tx - seg_w,
TabAlign::Center => stop.pos - tx - seg_w / 2.0,
// Width up to the first '.' in the segment.
TabAlign::Decimal => stop.pos - tx - width_to_decimal(pl, tj, self.j).unwrap_or(seg_w),
TabAlign::Decimal => stop.pos - tx - self.width_to_decimal(pl, tj, self.j).unwrap_or(seg_w),
_ => 0.0,
};
let shift = shift.max(0.0);
+22
View File
@@ -482,3 +482,25 @@ fn selecting_a_header_on_the_hinted_page_does_not_walk_every_page() {
eprintln!("hinted header: {small} steps for 20 pages, {big} for 160");
assert!(small > 0 && big <= small + 8, "the pages aren't walked: {small} → {big}");
}
/// A paragraph of a decimal tab at 200 pt followed by `text`.
fn after_decimal_tab(text: &str) -> Document {
use wordcraft_doc::props::{TabAlign, TabStop};
let mut d = Document::from_text(&format!("\t{text}"));
let at = Pos::body(0, 0);
d.format_paragraphs(&at, &at, &|p| p.tabs = Some(vec![TabStop { pos: 200.0, align: TabAlign::Decimal, leader: Default::default() }])).unwrap();
d
}
/// The text after a decimal tab was looked at again from the tab for its '.' after each of its
/// clusters, so a long run of clusters without a '.' (soft hyphens take no room, so they stay on
/// one line) took quadratic time.
#[test]
fn text_after_a_decimal_tab_is_looked_at_once() {
let opts = LayoutOptions::default();
// The text after the tab still ends at the stop, where it ended before.
let l = lay(&after_decimal_tab("12.5"), &opts);
let (Some(end), Some(start)) = (l.caret(&Pos::body(0, 5)), l.caret(&Pos::body(0, 0))) else { panic!("no carets") };
assert!((end.x - start.x - 200.0).abs() < 1.0, "the text ends at the stop: {} from {}", end.x, start.x);
assert_layout_linear("decimal tab", 2000, |n| after_decimal_tab(&format!("1{}", "\u{AD}".repeat(n))), &opts);
}
+1 -4
View File
@@ -8,10 +8,7 @@ pub(crate) fn metadata(doc: &Document, opts: &PdfOptions) -> Metadata {
if !title.trim().is_empty() {
m.title = Some(title);
}
// "Remove personal information from file properties on save" covers the PDF's author too:
// the document's creator is left out (an author the caller names is still written).
let kept = if doc.settings.remove_personal_info { String::new() } else { c.creator.clone() };
let author = opts.author.clone().unwrap_or(kept);
let author = opts.author.clone().unwrap_or_else(|| c.creator.clone());
if !author.trim().is_empty() {
m.authors = vec![author];
}
-11
View File
@@ -2862,14 +2862,3 @@ fn baseline_is_the_most_shared_y() {
assert_eq!(baseline(&[(1, 0.0, 9.0), (2, 3.0, 4.0)]), Some(4.0));
assert_eq!(baseline(&[(1, 0.0, -0.0), (2, 3.0, 0.0)]), Some(0.0));
}
#[test]
fn the_author_is_left_out_when_personal_information_is_removed() {
let mut d = Document::from_text("Private");
d.core.creator = "Ada Writer".into();
let opts = PdfOptions { tagged: false, compress: false, ..Default::default() };
let raw = |d: &Document| String::from_utf8_lossy(&export(d, &opts).unwrap()).to_string();
assert!(raw(&d).contains("Ada Writer"));
d.settings.remove_personal_info = true;
assert!(!raw(&d).contains("Ada Writer"), "the creator stays out of the PDF");
}
-4
View File
@@ -1,6 +1,5 @@
# File-format parity with Microsoft Word
> **2026-11-05:** A Word template opened from the file manager starts a new document based on it, and is never saved over (#3433).
> **2026-11-05:** Strict Open XML documents open with their shapes, text boxes, groups and canvases (#3548).
> **2026-11-05:** DOCX paragraph `w:adjustRightInd` keeps its default and explicit values through style resolution and schema-ordered repeated saves.
@@ -72,8 +71,6 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
| `.docx` Word Document (ECMA-376 Transitional) | 70% | R/W | ✅ | ✅ | ~65% | 70–110 | `crates/docx/tests` (roundtrip 34, fixtures 29, malformed 8, bidi 5, bidi_runs 12, citations 5); synthetic fixtures only | Word opens our files. Untested on a real-world corpus. Tracked changes inside a field result stay revisions (the field is kept as a range, #1037). Details below |
| `.docx` Strict Open XML | 1% | R/W | ✅ (namespace mapping in `xml.rs`) | ❌ | ~60% | 2–4 | `strict_namespace_and_bad_numbers`, `strict_line_heights_with_a_unit_are_read_as_point_multiples` | Writing Strict is rare; unit-suffixed measures (`13.90pt`) read as points (#2329) |
| `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs`, `a_template_the_platform_hands_the_app_starts_a_new_document` | Macros and signatures kept on save (#172); macros never run. A template (`.dotx`, `.dotm`, `.dot`) the platform hands the app (file manager, command line, Finder) starts a new, untitled document attached to it, as in Word; File › Open edits the template itself (#3433) |
| `.docx` Strict Open XML | 1% | R/W | ✅ (namespace mapping in `xml.rs`) | ❌ | ~60% | 2–4 | `strict_namespace_and_bad_numbers`, `strict_line_heights_with_a_unit_are_read_as_point_multiples`, `strict_drawings` | Writing Strict is rare; unit-suffixed measures (`13.90pt`) read as points (#2329); shapes, text boxes, groups and canvases (`wsp`, `wgp`, `wpc` in the Strict drawing namespace, a text box's `wne:txbxContent` or `txbxContent`) read as their Word 2010 names and are saved as Transitional (#3548). Charts and SmartArt in Strict namespaces are not mapped |
| `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs` | Macros and signatures kept on save (#172); macros never run |
| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer. DIB conversion caps BMP bytes (header included) and encoded PNG at the supplied BLIP byte limit, and uses it as the decoder's allocation limit; a BMP fallback is kept if it fits (related #1334 and #1554). Hostile files are bounded: a sector table longer than the file is refused before parsing (#3642); formatting and link runs are read once per change, not per character (#3692, #3708); copies of alt text, link targets and drawn objects share a 64 MB budget (#3709, #3710), and what it leaves out is logged once a kind per document; picture decoding shares a 512 MB budget and aliased store entries decode once (#3711, #3712). The default fonts are the stylesheet's standard fonts (`rgftcStandardChpStsh` and `ftcBi` in the `Stshi`, [MS-DOC] §2.9.273): Aptos in a Word 365 file, whose font table still starts with Times New Roman; a header too short for them, or an index past the font table, keeps the table's first font (#5066). Runs' East Asian and complex-script fonts (`sprmCRgFtc1`, `sprmCFtcBi`) are read. |
| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383, also with fonts that draw lam-alef as two glyphs at one x, #2310); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Outline destinations follow leading page breaks to the heading text. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice |
| PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser |
@@ -342,7 +339,6 @@ For field results, the engine parser treats a backslash in quoted arguments as e
| 2026-11-04 | trivial | CLI input paths keep their native filename when a value-taking option comes first; regression coverage includes conversion to Flat OPC and rejection of non-Unicode option values. |
| 2026-11-05 | trivial | `.dotx`/`.dotm`/`.dot`: a template the platform hands the app starts a new, untitled document attached to it, as Word's default verb does; File › Open still edits the template (#3433); fidelity estimate unchanged |
| 2026-11-02 | trivial | DOCX: a picture's or shape's hyperlink is read from its `wp:docPr/a:hlinkClick` (Word's place for it) and written there too, beside the `w:hyperlink` around the run (#5267) |
| 2026-11-05 | trivial | Strict Open XML: shapes, text boxes, groups and canvases in the Strict drawing namespace, and Word's `wne:txbxContent`, read as their Word 2010 names (#3548); fidelity estimate unchanged |
| 2026-11-05 | trivial | DOCX paragraph `w:adjustRightInd` keeps its default and explicit values in schema-ordered repeated saves |
| 2026-11-04 | trivial | REF context update: 2026-11-04 · #4609 retains the label and template actually rendered by an ancestor list instance; implicitly counted levels remain distinct from explicitly rendered paragraphs. Synthetic instance-override and field-update regressions; no new native Word qualification or readiness change. |
| 2026-11-04 | trivial | PR #4609: relative REF keeps context already displayed by the own or a needed ancestor label; six synthetic legal-template cases and separate-list context follow the Word for Windows measurements reported by @pakiat in PR discussion. Existing unknown-switch/cache boundaries and readiness estimates unchanged; no new native Word run. |
-3
View File
@@ -118,8 +118,6 @@ full number from ~60% to ~75%. Everything else is depth on the way to full parit
> **Three-column preset update:** 2026-10-15 · the header/footer preset uses editable table cells and screen-only editing guides (#2197). Synthetic command, undo/redo and repeated DOCX save/reopen checks cover this preset; broader template/layout gaps and readiness estimates remain unchanged.
Normal Find and Replace now match a keyboard apostrophe against straight and typographic single quotes (#5388), including text entered with smart quotes. Exact search modes and numeric character codes retain their character identity. This fixes a focused editing case; ranked gaps and readiness estimates are unchanged.
## By kind
List toggles now recognise numbering inherited from paragraph styles (related to #3459). This closes that command-level case; the broader issue and readiness estimates are unchanged. See [typing-parity.md](typing-parity.md) for the original-fixture and round-trip coverage.
@@ -224,7 +222,6 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text
| 2026-11-04 | trivial | Table Sort rejects malformed or nonexistent explicit columns before mutation; valid columns, the omitted-column default and history are retained (#5531). Readiness estimates unchanged. |
| 2026-10-30 | trivial | #20 narrowed: layout resource caps and linear-time passes (#3867, #3866) |
| 2026-10-17 | trivial | UI/UX gaps: copies put RTF on the clipboard (Windows, macOS, Wayland; not X11), and paste reads RTF on macOS and X11 too |
| 2026-11-03 | trivial | Find smart-quoted text with a keyboard apostrophe (#5388); exact search modes and replacement spelling preserved. |
| 2026-11-03 | minor | #11: Arabic interface with mirrored shell (#1195); key languages missing are now hi and id (plus pt-PT); Hebrew UI still open |
| 2026-11-03 | trivial | #8 (CP4.3): one language list: Set Proofing Language, Review › Language and the status bar offer the Language pack languages (Bulgarian was in none); every published pack named in English; Valencian is `ca-ES-valencia`; gap 8 names built-in proofing |
| 2026-11-03 | trivial | #8 (CP4.3): the missing-pack bar offers a language before the list of packs is fetched (a fresh install, or a list cached for an older pin) and gets it on Install; with downloading on, a missing or stale list is fetched once in the background; the caret's typing language counts; the spelling context menu's heading is in the interface language |
+1
View File
@@ -314,6 +314,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc
| 2026-11-01 | trivial | SEQ fields in text boxes are numbered where their boxes are anchored, in output and after F9, and a REF finds a bookmark in a text box |
| 2026-11-01 | trivial | SEQ, STYLEREF and REF fields show fresh results in output; readiness estimates unchanged |
| 2026-10-29 | trivial | Print Markup balloon anchor discovery walks paragraph objects once (#3614); readiness estimates unchanged |
| 2026-11-03 | trivial | A decimal tab scans the text after it once, not once per cluster. Separate from the #3412 word-measurement fix |
| 2026-10-30 | trivial | Layout work bounds for hostile documents (#3867): header-row repeats budgeted per layout and counting canvas members; picture-outline decode budget charged for cached outlines too, so cold and warm layouts wrap alike; linked-box chains keep exact widths up to a work cap, not 16 widths; `gridAfter` bounded |
| 2026-10-29 | trivial | A missing font is drawn in an installed metric twin before any other substitute (`fonts::word::METRIC_TWINS`, also what the missing-font warning calls metric-compatible): Arial for Helvetica on Windows, Arial for Liberation Sans on a Mac, Tinos before macOS Times for Times New Roman, Cousine before Menlo for Courier New. Documents in fonts with no installed twin lay out as before |
| 2026-11-01 | trivial | Columns: the line between them runs from the section's top on the page to the bottom of its tallest column; a continuous section mid-page gets its line (#5178) |
-7
View File
@@ -8,12 +8,6 @@ WordCraft, results read back paragraph by paragraph) and pinned by tests in
tests together. Line breaking and pagination are in [`layout-parity.md`](layout-parity.md); the
overall picture is in [`target-app-parity.md`](target-app-parity.md).
## Finding smart-quoted text (#5388)
Ordinary Find and Replace treat a keyboard apostrophe (`'`) as a straight or typographic single quote (`‘`, `’`), so a search for `Tau'va` finds `Tau’va` typed with smart quotes. Partial queries, case options and whole-word matching still apply. Wildcards, regular expressions, literal searches (`special: false`) and numeric character codes such as `^u39` remain exact. A curly-quote query also stays exact, and the modifier-letter apostrophe `ʼ` is distinct. Replacement `^&` keeps the original matched spelling. This behavior is covered by original engine and CLI/MCP controls; no fresh Word or native desktop comparison is claimed.
## Highlight shortcut (#4085)
On Windows/Linux, Ctrl+Alt+H toggles the last chosen highlighter colour (yellow in a
@@ -590,7 +584,6 @@ this correction; native Windows interaction and Microsoft Word remain untested.
| 2026-11-04 | trivial | Ctrl+K on a selection: a changed Text to display replaces the selected text before it is linked |
| 2026-10-30 | trivial | Equations: `\int` + Space waits for its limits; `_x^y` then Space builds them, Space alone builds the operator without, typing goes into its operand, and Enter or an arrow key builds it without going into the operand (#4917). Review: any character that can't continue `\name` corrects it (`\int_0^1`, `\alpha+`), and `∫∫` takes the operand into the inner operator; the Word rows are now observed in Word for Windows on #4938, Enter, arrows and `∫∫` are not |
| 2026-11-03 | trivial | Alt+numpad codes type their character in text boxes too (Find what, dialog fields), not the digits or nothing (#4403) |
| 2026-11-03 | trivial | Find smart-quoted text with a keyboard apostrophe (#5388); exact search modes and replacement spelling preserved. |
| 2026-11-01 | minor | English spelling by dialect from SCOWL 2026: typed British, Canadian and Australian text, modern words and ordinals pinned (#40, #520, #1281, #1045; CP2) |
| 2026-10-29 | trivial | Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged |
| 2026-10-28 | trivial | Highlighting at a caret leaves the word alone and sets the colour for what is typed next (#4086). |