Compare commits

..
Author SHA1 Message Date
Martin Ball 49b3adb858 Merge remote-tracking branch 'origin/main' into HEAD
; Conflicts:
;	ROADMAP.md
;	docs/gaps.md
2026-11-08 02:27:52 +00:00
Dylan M. Taylor 6ba25ee491 xtask: no symlink walks, exclusive test dir; Paste Special picture files must be regular files 2026-11-03 19:11:23 +00:00
42 changed files with 157 additions and 1535 deletions
-14
View File
@@ -157,8 +157,6 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
- 2026-11-07: An in-line picture lowered past its own height (`w:position`) lowers its line as far, as in Word; all 156 synthetic lines measured for #3984 match Word (#5796). Exact spacing keeps the line's baseline.
- 2026-11-03: Search types in place: a click on the title bar's Search box, or Alt+Q, takes the typing in that box, with the results under it, as in Word, instead of a second box in a dialog (#5398).
- 2026-10-31: List labels: a numbering level with format `none` and literal text, such as `RESPONSE:` with a one-inch hanging indent, shows that text as its label, as Word does; it used to show nothing. As in Word, a `none` level still counts and restarts the levels below it, its own number is empty (in legal numbering too), and an empty label still tabs the text to the indent (#4985). Readiness unchanged.
- 2026-10-31: Font menus draw their entries from a few shared textures written in place, as the Equation tab's pictures are (#1002), instead of a texture per font: on Windows (DirectX 12) browsing the list showed the names on black strips in light mode and white or red ones in dark mode (#5105). Readiness unchanged.
- 2026-11-02: A picture linked from its context menu shows its address on hover and opens it with Ctrl/⌘+click, in line or floating, as linked text does; DOCX files keep the link where Word does, on the drawing (`a:hlinkClick`), so links on pictures from Word open too (#5267). Readiness unchanged.
- 2026-11-01: DOCX tables keep rows and cells wrapped in `mc:AlternateContent`, selecting one supported Choice or Fallback with the existing reader policy (#5219). Original synthetic checks cover namespace aliases, unsupported requirements, nested row/cell content controls and two save/reopen cycles; broader corpus and native Word qualification remain open.
- 2026-11-01: Shapes: a line's arrowheads are as long and as wide as Word draws them: 2, 3 or 5 times the line's width for small, medium and large (a line under 2 points as a 2-point one). Their kind, width and length are read from DOCX (DrawingML and VML) and saved back; before, every arrowhead was one size and saved as a medium triangle.
@@ -172,8 +170,6 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
- 2026-11-04: RTF import skips the declared Unicode fallback count even when it exceeds eight fallback units, so longer ANSI alternatives do not leak into the document text. Skipping stays bounded by the available input and ends at a group boundary. Readiness estimates unchanged.
- 2026-11-05: Linux: a WordCraft started by an AppImage app no longer takes that app's file for its own (#5653). The AppImage runtime's `APPIMAGE`, `APPDIR`, `ARGV0` and `OWD` pass to whatever the app starts, so an installed WordCraft opened through `xdg-open` saw another program's `APPIMAGE`, and its self-update replaced that program's file. The app now starts over without those variables unless its executable is under `APPDIR`, so the update goes through the package or plain file it really is. Should starting over fail, the self-update is off in that window and Help › Check for Updates says why. Readiness estimates unchanged.
- 2026-11-05: A Word template (`.dotx`, `.dotm`, `.dot`) opened from the file manager, the command line or the Finder starts a new, untitled document based on it, as Word's default verb for a template does (#3433). It opened the template itself, so Save (and AutoSave) wrote over it. The new document is attached to the template and Save asks where to write; a protected template asks for its password and still starts a new document. File › Open and a file dropped on the window still open a template itself, to edit it.
- 2026-11-05: Arabic letters join across a change of formatting (gap 16, #4093). Each run was shaped on its own, so a letter at a run's edge took the form of a word's start or end: a coloured letter stood apart, and a word edited with Track Changes on read with a space in it (ال ذكاء). Each run is now shaped knowing the text shown on either side, past hidden or deleted text left out of the view, as for Syriac, Mongolian and the other joining scripts. Readiness estimates unchanged.
- 2026-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.
@@ -223,17 +219,12 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
- 2026-10-31: ODT: paragraph spacing, indents and line spacing are read and written, and an ODT's own default spacing (none, single lines) replaces Word's 8 pt after and 1.15 lines, so LibreOffice documents keep their vertical rhythm on open and save (gap 19). Readiness unchanged.
- 2026-11-01: PDF export, printing and page images show the body's SEQ, STYLEREF and REF fields with their current results, as Word fills them in when it prints or exports even with Update fields before printing off: a caption numbered 7 when it is the first shows 1 on paper, and a REF to a bookmark the document lacks prints Word's "Error! Reference source not found.". NUMWORDS, FILENAME, the document properties, formulas and locked fields keep their stored results there, as in Word; the editor and the saved document keep every stored result.
- 2026-10-28: Print Markup HTML, Markdown and text copies discover comment anchors in one paragraph walk, so unrelated bookmark markers no longer cause repeated prefix scans (#3614). Existing quotes, replies, order and export limits are unchanged; original synthetic semantic and scaling regressions cover the traversal.
- 2026-11-04: Table Sort refuses malformed and nonexistent explicit columns before reordering rows (#5531). Omitted column 0, valid columns, header/order/language behavior and Undo/Redo are retained. Original Linux CLI/MCP controls and repeated native/DOCX reopens cover the command; native Word and desktop comparison remain untested. Readiness estimates unchanged.
- 2026-10-30: Layout resource caps (#3867, #4385) and linear-time passes (#3866, #4392): repeated table-header rows are capped by item counts, a clipped group counting every nested member, the wrap cache by decoded pixels and the widths a linked text box chain lays out by their lines and items; PDF export, hit testing, tracked-change and clipped-table costs, and long endnote selections no longer grow quadratically. Synthetic regression tests only; no new Word comparison.
- 2026-11-03: Proofing (CP4.3): craft-proof moves to craft-libs main with #589 and #590. French spelling suggestions offer the elided form first (`Jadore` → `J'adore`, before `J adore`), and Valencian text (`ca-ES-valencia`) is proofed with the `ca-valencia` pack instead of plain Catalan. Readiness unchanged.
- 2026-11-03: 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.
@@ -832,8 +823,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.
>
@@ -853,8 +842,6 @@ MATH glyphs that reference the same construction now share its immutable variant
| 2026-11-07 | trivial | Progress log: An in-line picture lowered past its own height (`w:position`) lowers its line as far, as in Word; all 156 synthetic lines measured for #3984 match Word (#5796). |
| 2026-11-03 | trivial | Progress log: the title bar's Search box takes the typing in place (#5398) |
| 2026-10-31 | trivial | Progress log: a numFmt none list level shows its literal text (#4985); readiness unchanged |
| 2026-10-31 | trivial | Progress log: font menu entries are drawn from shared textures (#5105) |
| 2026-11-02 | trivial | Progress log: a linked picture shows and follows its link; DOCX `a:hlinkClick` (#5267) |
| 2026-11-02 | trivial | Progress log: alternative format chunks and MHT open |
| 2026-11-06 | trivial | Print helpers remove loaded PDF/settings files before driver work, tolerate prior removal and report cleanup errors; a held-driver regression checks the files are already absent (#5710) |
| 2026-11-06 | trivial | Windows print submissions isolated per helper process; cancelled driver work cannot block a retry (#5710) |
@@ -899,7 +886,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) |
+5 -32
View File
@@ -118,33 +118,9 @@ impl Reader<'_> {
Some(obj)
}
/// The hyperlink Word keeps on a drawing itself: its `wp:docPr`'s `a:hlinkClick` (ECMA-376
/// Part 1 §20.4.2.5, §21.1.2.3.5) naming an external hyperlink relationship. Word stores a
/// picture's link there rather than in a `w:hyperlink` around its run (#5267). Its `#name`
/// part, when the address has one, stays part of the address.
pub(super) fn drawing_link(d: &El, rels: &Rels) -> Option<String> {
let frame = d.child("wp:inline").or_else(|| d.child("wp:anchor"))?;
let id = frame.child("wp:docPr")?.child("a:hlinkClick")?.attr("r:id")?;
let rel = rels.by_id(id).filter(|r| r.external && crate::package::rel_is(&r.kind, rt::HYPERLINK))?;
Some(rel.target.trim().to_string()).filter(|u| !u.is_empty())
}
/// Native ink or an Office 2016 chart kept with its fallback (`mc:AlternateContent`), and the
/// [`Self::drawing_link`] of the choice it was read from, else of its fallback: another
/// choice's link is not this object's.
pub(super) fn read_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<(InlineObject, Option<String>)> {
let (object, choice) = match self.read_ink_alternate(sc, ac, rels) {
Some(o) => (o, children_of_choice(ac).filter(|e| e.name == "mc:Choice")),
None => (self.read_chartex_alternate(sc, ac, rels)?, chartex_choice(ac)),
};
let link =
[choice, ac.child("mc:Fallback")].into_iter().flatten().filter_map(|e| e.child("w:drawing")).find_map(|d| Self::drawing_link(d, rels));
Some((object, link))
}
/// Keep the selected native-ink branch and its picture fallback as one object. Reading only
/// the fallback flattens the ink; reading only the choice loses its preview on the next save.
fn read_ink_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
pub(super) fn read_ink_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
let choice = children_of_choice(ac).filter(|e| e.name == "mc:Choice")?;
let drawing = choice.child("w:drawing")?;
if drawing.find("a:graphicData")?.attr("uri") != Some(INK_URI) {
@@ -173,8 +149,10 @@ impl Reader<'_> {
/// An Office 2016 chart (`cx:chart`: waterfall, sunburst, box & whisker…) drawn from its
/// fallback picture, keeping both branches so saving writes the chart back rather than only
/// the picture. `None` (read the fallback alone) when it can't be kept whole.
fn read_chartex_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
let choice = chartex_choice(ac)?;
pub(super) fn read_chartex_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
let choice = ac
.children("mc:Choice")
.find(|c| c.child("w:drawing").and_then(|d| d.find("a:graphicData")).and_then(|g| g.attr("uri")) == Some(CHARTEX_URI))?;
// A requirement in a namespace we can't name couldn't be written back.
let requires = choice.attr("Requires").unwrap_or("");
if requires.split_whitespace().next().is_none() || requires.split_whitespace().any(|p| p == "?") {
@@ -585,11 +563,6 @@ impl Reader<'_> {
}
}
/// The `mc:Choice` holding an Office 2016 chart (`cx:chart`) in an `mc:AlternateContent`.
fn chartex_choice(ac: &El) -> Option<&El> {
ac.children("mc:Choice").find(|c| c.child("w:drawing").and_then(|d| d.find("a:graphicData")).and_then(|g| g.attr("uri")) == Some(CHARTEX_URI))
}
/// The alternative text of a `wp:docPr`, `pic:cNvPr` or `wps:cNvPr` (ECMA-376
/// `CT_NonVisualDrawingProps`): the description (the title when there is none, as before), and
/// the title and decorative mark (the `adec:decorative` extension of [MS-ODRAWXML]).
+5 -13
View File
@@ -98,9 +98,9 @@ impl Reader<'_> {
}
}
"mc:AlternateContent" => {
if let Some((o, link)) = self.read_alternate(sc, k, rels) {
if let Some(o) = self.read_ink_alternate(sc, k, rels).or_else(|| self.read_chartex_alternate(sc, k, rels)) {
let props = self.run_props_none(ctx);
self.emit_linked_obj(sc, pb, o, &props, link);
self.emit_obj(sc, pb, o, &props);
} else if let Some(c) = children_of_choice(k) {
self.read_inline_children(sc, pb, c, rels, ctx, depth + 1);
}
@@ -237,7 +237,7 @@ impl Reader<'_> {
},
"w:drawing" => {
if let Some(o) = self.read_drawing(sc, k, rels) {
self.emit_linked_obj(sc, pb, o, props, Self::drawing_link(k, rels));
self.emit_obj(sc, pb, o, props);
}
}
"w:pict" => {
@@ -298,8 +298,8 @@ impl Reader<'_> {
),
"w:ruby" => self.read_ruby(sc, pb, k, props),
"mc:AlternateContent" => {
if let Some((o, link)) = self.read_alternate(sc, k, rels) {
self.emit_linked_obj(sc, pb, o, props, link);
if let Some(o) = self.read_ink_alternate(sc, k, rels).or_else(|| self.read_chartex_alternate(sc, k, rels)) {
self.emit_obj(sc, pb, o, props);
} else if let Some(c) = children_of_choice(k) {
for x in c.els() {
self.read_run_child(sc, pb, x, (props, rels), note, depth + 1);
@@ -376,14 +376,6 @@ impl Reader<'_> {
}
}
/// [`Self::emit_obj`] for a drawing that carries its own `link`: a link around the run wins.
fn emit_linked_obj(&mut self, sc: &mut StoryCtx, pb: &mut PB, o: InlineObject, props: &CharProps, link: Option<String>) {
match link.filter(|_| props.link.is_none()) {
Some(link) => self.emit_obj(sc, pb, o, &CharProps { link: Some(link), ..props.clone() }),
None => self.emit_obj(sc, pb, o, props),
}
}
pub(super) fn emit_obj(&mut self, sc: &mut StoryCtx, pb: &mut PB, o: InlineObject, props: &CharProps) {
match sink(sc) {
Some(f) => {
+12 -28
View File
@@ -10,19 +10,9 @@ pub(super) struct DrawingInfo<'a> {
pub docpr: &'a str,
pub name: &'a str,
pub alt: &'a str,
/// The relationship id of the drawing's hyperlink (`a:hlinkClick`), see [`Writer::drawing_link`].
pub link: Option<&'a str>,
}
impl Writer<'_> {
/// The relationship of the hyperlink a drawing in a run with `props` carries on itself, as
/// Word writes a picture's link (`wp:docPr/a:hlinkClick`, #5267): a web or file address. A
/// link to a place in the document (`#name`) is only the `w:hyperlink` around the run.
pub(super) fn drawing_link(props: &wordcraft_doc::CharProps, rels: &mut PartRels) -> Option<String> {
let url = props.link.as_deref().filter(|l| !l.is_empty() && !l.starts_with('#'))?;
Some(rels.add(rt::HYPERLINK, url, true))
}
/// A picture's `pic:pic` (the image in `media/{file}`), at `off` in its group (or 0, 0).
pub(super) fn pic(&mut self, w: &mut W, o: &InlineObject, file: &str, off: (f32, f32), rels: &mut PartRels) {
let InlineObject::Image { media, w: iw, h: ih, alt, crop, float, fx, .. } = o else { return };
@@ -30,7 +20,7 @@ impl Writer<'_> {
self.used_media.insert(media.clone());
w.open("pic:pic", &[]);
w.open("pic:nvPicPr", &[]);
nv_props(w, "pic:cNvPr", ("0", file), alt, &float.alt, float.hidden, None);
nv_props(w, "pic:cNvPr", "0", file, alt, &float.alt, float.hidden);
w.open("pic:cNvPicPr", &[]);
w.empty("a:picLocks", &[("noChangeAspect", "1"), ("noChangeArrowheads", "1")]);
w.close("pic:cNvPicPr");
@@ -76,7 +66,7 @@ impl Writer<'_> {
w.open("wps:wsp", &[]);
if let Some(id) = id {
let name = shape_name(*kind, freeform.as_deref(), id);
nv_props(w, "wps:cNvPr", (id, &name), &float.alt.text, &float.alt, float.hidden, None);
nv_props(w, "wps:cNvPr", id, &name, &float.alt.text, &float.alt, float.hidden);
}
if kind.is_connector() {
// A connector's glued ends (ECMA-376 §20.1.2.2.8 `cNvCxnSpPr`).
@@ -245,7 +235,7 @@ impl Writer<'_> {
}
pub(super) fn drawing_open(&mut self, w: &mut W, info: DrawingInfo<'_>) {
let DrawingInfo { float, size: (iw, ih), docpr, name, alt, link } = info;
let DrawingInfo { float, size: (iw, ih), docpr, name, alt } = info;
let (cx, cy) = (emu(iw.max(0.0)), emu(ih.max(0.0)));
if float.wrap == Wrap::Inline {
w.open("wp:inline", &[("distT", "0"), ("distB", "0"), ("distL", "0"), ("distR", "0")]);
@@ -326,7 +316,7 @@ impl Writer<'_> {
Wrap::TopAndBottom => w.empty("wp:wrapTopAndBottom", &[]),
Wrap::BehindText | Wrap::InFrontOfText => w.empty("wp:wrapNone", &[]),
}
nv_props(w, "wp:docPr", (docpr, name), alt, &float.alt, float.hidden, link);
nv_props(w, "wp:docPr", docpr, name, alt, &float.alt, float.hidden);
}
}
@@ -512,9 +502,8 @@ pub(in super::super) fn spin_attrs(spin: wordcraft_geom::Spin) -> Vec<(&'static
/// A drawing's `wp:docPr` (or a group member's `pic:cNvPr`/`wps:cNvPr`, ECMA-376
/// `CT_NonVisualDrawingProps`) with its alt text `descr`, its title, and the decorative mark as
/// the `adec:decorative` extension ([MS-ODRAWXML]). A description that is only the title (read
/// from a file with no description) is not written twice. `link` is the relationship id of the
/// drawing's hyperlink (`a:hlinkClick`, the first child the schema allows).
pub(in super::super) fn nv_props(w: &mut W, tag: &str, (id, name): (&str, &str), descr: &str, alt: &AltText, hidden: bool, link: Option<&str>) {
/// from a file with no description) is not written twice.
pub(in super::super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, descr: &str, alt: &AltText, hidden: bool) {
let mut attrs = vec![("id", id), ("name", name)];
if descr != alt.title || descr.is_empty() {
attrs.push(("descr", descr));
@@ -525,21 +514,16 @@ pub(in super::super) fn nv_props(w: &mut W, tag: &str, (id, name): (&str, &str),
if hidden {
attrs.push(("hidden", "1"));
}
if !alt.decorative && link.is_none() {
if !alt.decorative {
w.empty(tag, &attrs);
return;
}
w.open(tag, &attrs);
if let Some(rid) = link {
w.empty("a:hlinkClick", &[("r:id", rid)]);
}
if alt.decorative {
w.open("a:extLst", &[]);
w.open("a:ext", &[("uri", crate::xml::DECORATIVE_EXT)]);
w.empty("adec:decorative", &[("xmlns:adec", crate::xml::DECORATIVE_NS), ("val", "1")]);
w.close("a:ext");
w.close("a:extLst");
}
w.open("a:extLst", &[]);
w.open("a:ext", &[("uri", crate::xml::DECORATIVE_EXT)]);
w.empty("adec:decorative", &[("xmlns:adec", crate::xml::DECORATIVE_NS), ("val", "1")]);
w.close("a:ext");
w.close("a:extLst");
w.close(tag);
}
+5 -17
View File
@@ -220,18 +220,13 @@ impl Writer<'_> {
w.open("mc:Choice", &[("Requires", requires)]);
}
let docpr = self.next_docpr();
let link = Self::drawing_link(props, rels);
let name = match graphic.kind {
wordcraft_doc::graphic::GraphicKind::Chart => format!("Chart {docpr}"),
wordcraft_doc::graphic::GraphicKind::Diagram => format!("Diagram {docpr}"),
wordcraft_doc::graphic::GraphicKind::Ink => format!("Ink {docpr}"),
};
let z = self.z;
self.graphic_frame(
w,
DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt, link: link.as_deref() },
native.unwrap_or(&root),
);
self.graphic_frame(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt }, native.unwrap_or(&root));
if let Some(fallback) = fallback {
w.close("mc:Choice");
match picture {
@@ -239,11 +234,7 @@ impl Writer<'_> {
// Both branches are one object: same id and stacking order.
self.z = z;
w.open("mc:Fallback", &[]);
self.graphic_frame(
w,
DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt, link: link.as_deref() },
picture,
);
self.graphic_frame(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt }, picture);
w.close("mc:Fallback");
}
None => w.raw(&fallback.to_xml()),
@@ -275,9 +266,8 @@ impl Writer<'_> {
self.rpr(w, props);
w.open("w:drawing", &[]);
let docpr = self.next_docpr();
let link = Self::drawing_link(props, rels);
let name = format!("Picture {docpr}");
self.drawing_open(w, DrawingInfo { float, size: (*iw, *ih), docpr: &docpr, name: &name, alt, link: link.as_deref() });
self.drawing_open(w, DrawingInfo { float, size: (*iw, *ih), docpr: &docpr, name: &name, alt });
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
w.open("a:graphicData", &[("uri", "http://schemas.openxmlformats.org/drawingml/2006/picture")]);
@@ -365,7 +355,6 @@ impl Writer<'_> {
self.rpr(w, props);
w.open("w:drawing", &[]);
let docpr = self.shape_docpr(extra.id);
let link = Self::drawing_link(props, rels);
// Ink is told apart by its name (and its pen), which reading looks for.
let name = shape_name(*kind, freeform.as_deref(), &docpr);
// The effect extent leaves room for the shadow and glow.
@@ -380,7 +369,7 @@ impl Writer<'_> {
.map(std::sync::Arc::new);
}
let float = &float;
self.drawing_open(w, DrawingInfo { float, size: (*sw, *sh), docpr: &docpr, name: &name, alt: o.alt_text(), link: link.as_deref() });
self.drawing_open(w, DrawingInfo { float, size: (*sw, *sh), docpr: &docpr, name: &name, alt: o.alt_text() });
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape")]);
@@ -400,9 +389,8 @@ impl Writer<'_> {
self.rpr(w, props);
w.open("w:drawing", &[]);
let docpr = self.next_docpr();
let link = Self::drawing_link(props, rels);
let name = format!("{} {docpr}", if canvas.is_some() { "Canvas" } else { "Group" });
self.drawing_open(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt: o.alt_text(), link: link.as_deref() });
self.drawing_open(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt: o.alt_text() });
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
let outer = match canvas {
+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`.
+3 -37
View File
@@ -1766,27 +1766,12 @@ const CT_CHARTEX: &str = "application/vnd.ms-office.chartex+xml";
/// A package holding one box & whisker chart the way Word writes it: the `cx:chart` in an
/// `mc:Choice` requiring `requires` (bound to `requires_ns`), a picture of it in the `mc:Fallback`.
fn chartex_docx(requires: &str, requires_ns: &str) -> Vec<u8> {
chartex_docx_linked(requires, requires_ns, false)
}
/// [`chartex_docx`], its drawing linked to a web address (`wp:docPr/a:hlinkClick`) when `link`,
/// after a first choice linked elsewhere, which needs a namespace no reader knows, so a reader
/// takes the chart's choice.
fn chartex_docx_linked(requires: &str, requires_ns: &str, link: bool) -> Vec<u8> {
let click = if link { r#"<a:hlinkClick r:id="rIdWeb"/>"# } else { "" };
let other = if link {
r#"<mc:Choice xmlns:w99="urn:example:unsupported" Requires="w99"><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="254000" cy="254000"/><wp:docPr id="9" name="Other"><a:hlinkClick r:id="rIdOther"/></wp:docPr><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><wps:wsp><wps:spPr><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></wps:spPr><wps:bodyPr/></wps:wsp></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Choice>"#
} else {
""
};
let frame = format!(
r#"<wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="2743200" cy="1371600"/><wp:docPr id="1" name="Chart 1">{click}</wp:docPr><wp:cNvGraphicFramePr/>"#
);
let frame = r#"<wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="2743200" cy="1371600"/><wp:docPr id="1" name="Chart 1"/><wp:cNvGraphicFramePr/>"#;
let body = format!(
r#"<w:p><w:r><w:t>Spread</w:t></w:r><w:r><mc:AlternateContent>{other}<mc:Choice xmlns:{requires}="{requires_ns}" Requires="{requires}"><w:drawing>{frame}<a:graphic><a:graphicData uri="{CHARTEX_NS}"><cx:chart xmlns:cx="{CHARTEX_NS}" r:id="rIdCx"/></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Choice><mc:Fallback><w:drawing>{frame}<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="1" name="Chart 1"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdPic"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="2743200" cy="1371600"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Fallback></mc:AlternateContent></w:r></w:p>"#
r#"<w:p><w:r><w:t>Spread</w:t></w:r><w:r><mc:AlternateContent><mc:Choice xmlns:{requires}="{requires_ns}" Requires="{requires}"><w:drawing>{frame}<a:graphic><a:graphicData uri="{CHARTEX_NS}"><cx:chart xmlns:cx="{CHARTEX_NS}" r:id="rIdCx"/></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Choice><mc:Fallback><w:drawing>{frame}<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="1" name="Chart 1"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdPic"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="2743200" cy="1371600"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Fallback></mc:AlternateContent></w:r></w:p>"#
);
let doc = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {W_NS}><w:body>{body}</w:body></w:document>"#);
let doc_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdCx" Type="http://schemas.microsoft.com/office/2014/relationships/chartEx" Target="charts/chartEx1.xml"/><Relationship Id="rIdPic" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/><Relationship Id="rIdWeb" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/chart" TargetMode="External"/><Relationship Id="rIdOther" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/other" TargetMode="External"/></Relationships>"#;
let doc_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdCx" Type="http://schemas.microsoft.com/office/2014/relationships/chartEx" Target="charts/chartEx1.xml"/><Relationship Id="rIdPic" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/></Relationships>"#;
let chart_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/package" Target="../embeddings/Microsoft_Excel_Worksheet.xlsx"/><Relationship Id="rId2" Type="http://schemas.microsoft.com/office/2011/relationships/chartColorStyle" Target="colors1.xml"/></Relationships>"#;
let types = format!(
r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="png" ContentType="image/png"/><Default Extension="xlsx" ContentType="{CT_XLSX}"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/charts/chartEx1.xml" ContentType="{CT_CHARTEX}"/><Override PartName="/word/charts/colors1.xml" ContentType="application/vnd.ms-office.chartcolorstyle+xml"/></Types>"#
@@ -1842,25 +1827,6 @@ fn office_2016_chart_keeps_its_chart_part_and_fallback_through_saves() {
}
}
/// #5267: a link on an Office 2016 chart's drawing is the chart's link (not an earlier choice's),
/// and saving keeps it on both branches (the writer rebuilds their frames).
#[test]
fn a_link_on_an_office_2016_charts_drawing_is_kept() {
let mut d = wordcraft_docx::read(&chartex_docx_linked("cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex", true)).unwrap();
for _ in 0..2 {
let p = d.body[0].as_para().unwrap();
let off = p.object_offsets().into_iter().find(|o| matches!(p.object_at(*o), Some(InlineObject::Graphic { .. }))).unwrap();
assert_eq!(p.props_of_char(off).link.as_deref(), Some("https://example.com/chart"));
let files = unzip(&wordcraft_docx::write(&d).unwrap());
let body = text(&files, "word/document.xml");
let ids = attr_values(&body, "a:hlinkClick", "r:id");
assert_eq!(ids.len(), 2, "on the chart and its fallback picture: {body}");
assert_eq!(rel(&text(&files, "word/_rels/document.xml.rels"), &ids[0]).1, "https://example.com/chart");
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
d = wordcraft_docx::read(&zip(&entries)).unwrap();
}
}
#[test]
fn resized_office_2016_chart_resizes_its_fallback_too() {
let mut d = wordcraft_docx::read(&chartex_docx("cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex")).unwrap();
-2
View File
@@ -23,7 +23,6 @@ mod ligatures;
mod malformed;
mod note_comments;
mod passthrough;
mod picture_links;
mod positional_tabs;
mod privacy;
mod roundtrip;
@@ -35,7 +34,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;
-127
View File
@@ -1,127 +0,0 @@
//! A picture's hyperlink (#5267): Word keeps it on the drawing (`wp:docPr/a:hlinkClick`,
//! ECMA-376 Part 1 §20.4.2.5) rather than in a `w:hyperlink` around the run. Original
//! synthetic packages; the 2×2 picture is generated in code.
use std::io::{Read, Write};
use std::sync::Arc;
use wordcraft_doc::{CharProps, Document, InlineObject, Pos};
fn png() -> Vec<u8> {
let mut out = Vec::new();
image::RgbaImage::from_pixel(2, 2, image::Rgba([20, 90, 200, 255]))
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
.unwrap();
out
}
/// An inline picture's run; `link` goes into its `wp:docPr`.
fn picture_run(id: u32, link: &str) -> String {
format!(
r#"<w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="254000" cy="254000"/><wp:docPr id="{id}" name="Picture {id}">{link}</wp:docPr><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="square.png"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdPic"/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="254000" cy="254000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
)
}
fn package(body: &str) -> Vec<u8> {
let xml = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document 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:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><w:body>{body}<w:sectPr/></w:body></w:document>"#
);
let rels = r#"<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>"#;
let doc_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdPic" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/square.png"/><Relationship Id="rIdWeb" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/picture" TargetMode="External"/><Relationship Id="rIdRun" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/run" TargetMode="External"/></Relationships>"#;
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="png" ContentType="image/png"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
let png = png();
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [
("[Content_Types].xml", types.as_bytes()),
("_rels/.rels", rels.as_bytes()),
("word/document.xml", xml.as_bytes()),
("word/_rels/document.xml.rels", doc_rels.as_bytes()),
("word/media/square.png", png.as_slice()),
] {
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap().into_inner()
}
/// The link on each picture of the first paragraph, in order.
fn picture_links(doc: &Document) -> Vec<Option<String>> {
let p = doc.body[0].as_para().unwrap();
p.object_offsets()
.into_iter()
.filter(|off| matches!(p.object_at(*off), Some(InlineObject::Image { .. })))
.map(|off| p.props_of_char(off).link.clone())
.collect()
}
fn part(bytes: &[u8], name: &str) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
z.by_name(name).unwrap().read_to_string(&mut s).unwrap();
s
}
#[test]
fn a_link_on_a_pictures_drawing_is_read() {
let body = format!(
"<w:p>{}{}{}<w:hyperlink r:id=\"rIdRun\">{}</w:hyperlink></w:p>",
// Word's way: the link on the drawing.
picture_run(1, r#"<a:hlinkClick r:id="rIdWeb"/>"#),
// A click naming a relationship that is no hyperlink, and one naming none.
picture_run(2, r#"<a:hlinkClick r:id="rIdPic"/>"#),
picture_run(3, r#"<a:hlinkClick r:id="rIdMissing"/>"#),
// A link around the run wins over the drawing's.
picture_run(4, r#"<a:hlinkClick r:id="rIdWeb"/>"#),
);
let doc = wordcraft_docx::read(&package(&body)).unwrap();
assert_eq!(picture_links(&doc), [Some("https://example.com/picture".to_string()), None, None, Some("https://example.com/run".to_string())]);
}
#[test]
fn a_linked_picture_is_saved_with_its_link_on_the_drawing_too() {
let mut doc = Document::new();
doc.media.insert("square.png".into(), Arc::new(png()));
let picture = || InlineObject::Image {
media: "square.png".into(),
w: 20.0,
h: 20.0,
alt: String::new(),
float: Default::default(),
crop: [0.0; 4],
ole: None,
fx: Default::default(),
};
let linked = |l: &str| CharProps { link: Some(l.to_string()), ..Default::default() };
// A place in this document stays a `w:hyperlink w:anchor` only: `a:hlinkClick` names a relationship.
doc.insert_object(&Pos::body(0, 0), picture(), &linked("#Spot")).unwrap();
doc.insert_object(&Pos::body(0, 0), picture(), &linked("https://example.com/saved")).unwrap();
let bytes = wordcraft_docx::write(&doc).unwrap();
let xml = part(&bytes, "word/document.xml");
let clicks: Vec<&str> = xml.match_indices("<a:hlinkClick ").map(|(i, _)| &xml[i..]).collect();
assert_eq!(clicks.len(), 1, "{xml}");
let rid = clicks[0].split('"').nth(1).unwrap();
let docpr = xml.find("<wp:docPr ").unwrap();
assert!(docpr < xml.find("<a:hlinkClick ").unwrap() && xml.find("<a:hlinkClick ").unwrap() < xml.find("</wp:docPr>").unwrap(), "{xml}");
let rels = part(&bytes, "word/_rels/document.xml.rels");
assert!(rels.contains(&format!(r#"Id="{rid}""#)) && rels.contains(r#"Target="https://example.com/saved""#), "{rels}");
// The run's own hyperlink names the same relationship, so the address is stored once.
assert_eq!(rels.matches("https://example.com/saved").count(), 1, "{rels}");
let back = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(picture_links(&back), [Some("https://example.com/saved".to_string()), Some("#Spot".to_string())]);
// The drawing's link alone (as Word writes it) reads back the same.
let without_run_link = xml.replace(&format!(r#"<w:hyperlink r:id="{rid}" w:history="1">"#), "").replacen("</w:hyperlink>", "", 1);
assert_ne!(without_run_link, xml);
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())).unwrap();
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for i in 0..z.len() {
let mut f = z.by_index(i).unwrap();
let name = f.name().to_string();
let mut data = Vec::new();
f.read_to_end(&mut data).unwrap();
if name == "word/document.xml" {
data = without_run_link.clone().into_bytes();
}
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
out.write_all(&data).unwrap();
}
let edited = out.finish().unwrap().into_inner();
assert_eq!(picture_links(&wordcraft_docx::read(&edited).unwrap())[0], Some("https://example.com/saved".to_string()));
}
-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}]*",
+3 -14
View File
@@ -76,11 +76,9 @@ pub fn cropped_original_size(s: &Session, o: &InlineObject) -> Option<(f32, f32)
pub(super) fn size(s: &mut Session, v: &Value) -> CmdResult {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no object".into()))?;
let lock = p::bool(v, "lockAspect").unwrap_or(true);
let (w0, h0) = obj_size(&o);
let (mut w, mut h) = (w0, h0);
let (mut w, mut h) = obj_size(&o);
let scale = p::f32(v, "scale");
let original = p::bool(v, "original").unwrap_or(false);
if original {
if p::bool(v, "original").unwrap_or(false) {
let (ow, oh) =
cropped_original_size(s, &o).ok_or_else(|| CmdError::Disabled("only a picture whose image can be read has an original size".into()))?;
let k = scale.unwrap_or(100.0) / 100.0;
@@ -115,16 +113,7 @@ pub(super) fn size(s: &mut Session, v: &Value) -> CmdResult {
(None, None) => true,
_ => lock,
};
// Sides set in proportion (from the original, or from a size whose sides both have a length)
// fit the limits together. Otherwise each side the call sets fits on its own, and a side it
// leaves as it was keeps its size, a straight line's 0 too.
let (w, h) = if keep && (original || (w0 > 0.0 && h0 > 0.0 && (w, h) != (w0, h0))) {
fit_size(w, h, true)
} else {
let (fw, fh) = fit_size(w, h, false);
let left = |new: f32, old: f32| !original && new == old;
(if left(w, w0) { w } else { fw }, if left(h, h0) { h } else { fh })
};
let (w, h) = fit_size(w, h, keep);
// Any drawing: a group's members scale with its frame (its child space is kept); a chart or
// SmartArt graphic lays itself out again at the new size.
let theme = s.doc.settings.theme_colors.clone();
+1 -1
View File
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("picture.size", "Size", "Picture Format › Size", size)
.params(
r#"{"width"?: pt, "height"?: pt, "lockAspect"?: bool, "scale"?: percent, "original"?: bool} (scale: of the current size, or with original of the picture's original size, its pixels at 96 ppi less what is cropped; original alone restores that size; one side alone keeps the proportions unless lockAspect is false; 1–4000 pt: when the proportions are kept (from the original, or with both sides having a length), a side past 4000 pt shrinks the other with it; a side the call leaves as it was keeps its size, a straight line's 0 too)"#,
r#"{"width"?: pt, "height"?: pt, "lockAspect"?: bool, "scale"?: percent, "original"?: bool} (scale: of the current size, or with original of the picture's original size, its pixels at 96 ppi less what is cropped; original alone restores that size; one side alone keeps the proportions unless lockAspect is false; 1–4000 pt: when the proportions are kept, a side past 4000 pt shrinks the other with it)"#,
)
.when(has_sizable),
CommandSpec::new("picture.crop", "Crop", "Picture Format › Size", crop)
-60
View File
@@ -1034,66 +1034,6 @@ fn picture_size_past_the_limit_keeps_the_proportions() {
}
}
/// A side the call leaves as it was keeps its size, even outside 1–4000 pt: a straight line's 0
/// stays 0 when only its length is typed. Sides set in proportion (the original, or a size whose
/// sides both have a length) fit the limits together; any other side the call sets, on its own.
#[test]
fn picture_size_keeps_the_side_it_leaves() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.shape", &json!({"kind": "straightConnector", "width": 100.0, "height": 0.0})).unwrap();
let size = |s: &Session| obj_size(&selected(s).unwrap().1);
// A side a scale sets on its own: 0 has no length to scale.
let scaled = |x: f32, k: f32| if x == 0.0 { 0.0 } else { (x * k).clamp(1.0, 4000.0) };
// Sizes with both sides follow each other: they fit 1–4000 pt by the one factor nearest 1, worked out by hand
// for the calls `width` locked, `height` locked, `scale` 50 and `scale` 200 with a width (in that order).
let states = [
((100.0, 0.0), None),
((0.0, 100.0), None),
((5000.0, 0.0), None),
((0.0, 5000.0), None),
((100.0, 0.5), Some([(200.0, 1.0), (4000.0, 20.0), (200.0, 1.0), (200.0, 1.0)])),
((5000.0, 50.0), Some([(200.0, 2.0), (4000.0, 40.0), (2500.0, 25.0), (200.0, 2.0)])),
((100.0, 50.0), Some([(200.0, 100.0), (400.0, 200.0), (50.0, 25.0), (200.0, 100.0)])),
];
for ((w0, h0), together) in states {
let follow = |i: usize, alone: (f32, f32)| together.map_or(alone, |t: [(f32, f32); 4]| t[i]);
let cases = [
(json!({"width": 200.0, "lockAspect": false}), (200.0, h0)),
(json!({"height": 200.0, "lockAspect": false}), (w0, 200.0)),
(json!({"width": 200.0}), follow(0, (200.0, h0))),
(json!({"height": 200.0}), follow(1, (w0, 200.0))),
(json!({"scale": 50}), follow(2, (scaled(w0, 0.5), scaled(h0, 0.5)))),
(json!({"scale": 200, "width": 200.0}), follow(3, (200.0, scaled(h0, 2.0)))),
(json!({"scale": 100}), (w0, h0)),
(json!({}), (w0, h0)),
// The Size dialog: both sides sent, the height as it was.
(json!({"width": 200.0, "height": h0, "lockAspect": false}), (200.0, h0)),
(json!({"width": 200.0, "height": 300.0}), (200.0, 300.0)),
];
for (v, (ew, eh)) in cases {
with_obj(&mut s, |o| o.set_size(w0, h0)).unwrap();
s.run("picture.size", &v).unwrap();
let (w, h) = size(&s);
assert!((w - ew).abs() < 1e-3 && (h - eh).abs() < 1e-3, "{w0}×{h0} {v}: {w}×{h}, not {ew}×{eh}");
}
}
// Both sides of a picture's original size fit together, whatever size it had, even when the
// height comes out as it was: 4050×1012.5 pt is 4000×1000, not 4000×1012.5.
let data = super::super::insert::base64_encode(&png(400, 100));
s.run("insert.picture", &json!({"data": data})).unwrap();
for (w0, h0) in [(3000.0, 1012.5), (4050.0, 0.0), (100.0, 100.0)] {
with_obj(&mut s, |o| o.set_size(w0, h0)).unwrap();
s.run("picture.size", &json!({"original": true, "scale": 1350})).unwrap();
let (w, h) = size(&s);
assert!((w - 4000.0).abs() < 0.01 && (h - 1000.0).abs() < 0.01, "{w0}×{h0}: {w}×{h}");
}
// A height the original sets is set, though it is the one the picture had: 300×75 pt at
// 5400 % is 16200×4050, so with a width of 200 the height fits to 4000.
with_obj(&mut s, |o| o.set_size(100.0, 4050.0)).unwrap();
s.run("picture.size", &json!({"original": true, "scale": 5400, "width": 200.0, "lockAspect": false})).unwrap();
assert_eq!(size(&s), (200.0, 4000.0));
}
/// A crop handle drop: `picture.crop` with the new size is one edit, one undo step.
#[test]
fn picture_crop_with_size_is_one_undo_step() {
-67
View File
@@ -549,72 +549,6 @@ fn caption_for_an_object_in_a_cell_stays_in_the_cell() {
assert_eq!(texts.get(1).map(String::as_str), Some("Figure 1"), "{texts:?}");
}
/// A fresh scratch directory for the file-name tests below.
#[cfg(not(target_arch = "wasm32"))]
fn filename_scratch_dir(tag: &str) -> std::path::PathBuf {
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("wordcraft-filename-field-{tag}-{}-{nanos}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// #3796: after Save As to a new name, Update Field with the caret in a header shows the new
/// name for FILENAME and the new full path for FILENAME \p.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn filename_fields_in_a_header_follow_save_as() {
let dir = filename_scratch_dir("save-as");
let (mut s, hdr) = header_fields_session(wordcraft_doc::Document::from_text("body"), &["FILENAME", r"FILENAME \p"]);
let first = dir.join("report_v1.docx");
let second = dir.join("report_v2.docx");
s.run("file.save", &json!({"path": first.to_string_lossy()})).unwrap();
s.run("references.updateFields", &json!({})).unwrap();
let got = story_fields(&s, StoryRef::Part(hdr));
assert_eq!(got[0].1, "report_v1.docx");
assert_eq!(got[1].1, first.to_string_lossy());
s.run("file.saveAs", &json!({"path": second.to_string_lossy()})).unwrap();
s.run("references.updateFields", &json!({})).unwrap();
let got = story_fields(&s, StoryRef::Part(hdr));
assert_eq!(got[0].1, "report_v2.docx");
assert_eq!(got[1].1, second.to_string_lossy());
let _ = std::fs::remove_dir_all(&dir);
}
/// #3796: a Word-style `FILENAME \* MERGEFORMAT` field whose cached text names the old file
/// shows the new name after Save As and Update Field.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn filename_mergeformat_field_replaces_its_cached_name_after_save_as() {
let dir = filename_scratch_dir("mergeformat");
let (mut s, hdr) = header_fields_session(wordcraft_doc::Document::from_text("body"), &[r"FILENAME \* MERGEFORMAT"]);
let first = dir.join("report_v1.docx");
s.run("file.save", &json!({"path": first.to_string_lossy()})).unwrap();
// The cached text a file written earlier would carry.
let path = s.doc.para_paths(StoryRef::Part(hdr)).remove(0);
if let Some(InlineObject::Field { result, .. }) = s.doc.para_mut(StoryRef::Part(hdr), &path).unwrap().objects.first_mut() {
*result = "report_v1.docx".into();
}
s.run("file.saveAs", &json!({"path": dir.join("report_v2.docx").to_string_lossy()})).unwrap();
s.run("references.updateFields", &json!({})).unwrap();
assert_eq!(story_fields(&s, StoryRef::Part(hdr))[0].1, "report_v2.docx");
let _ = std::fs::remove_dir_all(&dir);
}
/// #3796: renaming the document (File > Info) changes what FILENAME shows on the next Update
/// Field; the extension stays.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn filename_field_in_a_header_follows_rename() {
let dir = filename_scratch_dir("rename");
let (mut s, hdr) = header_fields_session(wordcraft_doc::Document::from_text("body"), &["FILENAME"]);
s.run("file.save", &json!({"path": dir.join("report_v1.docx").to_string_lossy()})).unwrap();
s.run("file.rename", &json!({"name": "report_v3"})).unwrap();
s.run("references.updateFields", &json!({})).unwrap();
assert_eq!(story_fields(&s, StoryRef::Part(hdr))[0].1, "report_v3.docx");
let _ = std::fs::remove_dir_all(&dir);
}
/// A backslash in a quoted field argument makes the next character literal, so `\*` there is an
/// asterisk and not the `\* Upper` switch (Word shows `x * Upper` for `TITLE "x \* Upper"`).
#[test]
@@ -627,7 +561,6 @@ fn quoted_field_argument_backslash_escapes() {
}
assert_eq!(text_result(r#"TITLE "ab" \* Upper"#, new_property_value(r#"TITLE "ab" \* Upper"#).unwrap()), "AB");
}
/// Output (PDF, print, page images) shows the body's unlocked SEQ, STYLEREF and REF fields with
/// their fresh results, as Word fills them in when it prints or exports even with "Update fields
/// before printing" off; NUMWORDS and a locked field keep their stored results there, and the
+1 -1
View File
@@ -195,7 +195,7 @@ pub fn specs() -> Vec<CommandSpec> {
})
.params(r#"{"text"?: string, "title"?: string} (empty clears)"#)),
t(CommandSpec::new("table.sort", "Sort", "Table Layout › Data", sort_table)
.params(r#"{"column"?: non-negative JSON integer (0-based, default 0; must name a column in the table), "descending"?: bool, "header"?: bool}"#)),
.params(r#"{"column"?: n, "descending"?: bool, "header"?: bool}"#)),
t(CommandSpec::new("table.toText", "Convert to Text", "Table Layout › Data", to_text)
.params(r#"{"separator"?: "tab|comma|paragraph|other", "char"?: string (with other)} (the selected rows, or the whole table)"#)),
t(CommandSpec::new("table.formula", "Formula", "Table Layout › Data", formula).params(r#"{"formula"?: "=SUM(ABOVE)"}"#)),
+1 -8
View File
@@ -8,18 +8,11 @@ use super::{
/// Sort the table's rows by a column. Numbers are read in each cell's language (`1,5` is one and
/// a half in Polish) and sort first, numerically; other text sorts in the column's language.
pub(super) fn sort_table(s: &mut Session, v: &Value) -> CmdResult {
let column = match v.get("column") {
None => 0,
Some(value) => value.as_u64().ok_or_else(|| CmdError::Params("`column` must be a non-negative JSON integer".into()))?,
};
let col = p::u64(v, "column").unwrap_or(0) as usize;
let desc = p::bool(v, "descending").unwrap_or(false);
let header = p::bool(v, "header");
let (tp, _, _) = cell(s)?;
let t = s.doc.table(s.sel.focus.story, &tp).ok_or_else(|| CmdError::Failed("no table".into()))?;
let col = usize::try_from(column)
.ok()
.filter(|&col| t.rows.iter().any(|row| col < row.cells.len()))
.ok_or_else(|| CmdError::Params("`column` is outside the table".into()))?;
let skip = usize::from(header.unwrap_or(t.rows.first().is_some_and(|r| r.props.header))).min(t.rows.len());
let column = || t.rows.iter().skip(skip).map(|r| r.cells.get(col));
let lang = column().flatten().find_map(|c| cell_lang(&s.doc, c)).or_else(|| crate::cmd::default_lang(s));
-68
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;
@@ -10390,73 +10389,6 @@ fn column_texts(s: &Session) -> Vec<String> {
t.rows.iter().map(|r| r.cells[0].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::<String>()).collect()
}
#[test]
fn table_sort_rejects_invalid_columns_without_changing_rows_or_history() {
for column in [
json!(-1),
json!(0.5),
json!(0.0),
json!("0"),
json!(null),
json!(true),
json!([]),
json!({}),
json!(1),
json!(u32::MAX as u64 + 1),
json!(u64::MAX),
] {
let mut s = column_table("en-US", &["Charlie", "Alpha", "Bravo"]);
s.close_typing();
run(&mut s, "text.insert", json!({"text": "!"}));
let before = s.doc.clone();
let selection = s.sel.clone();
let dirty = s.dirty;
assert!(s.run("table.sort", &json!({"column": column, "header": false})).is_err(), "column {column}");
assert_eq!(s.doc, before, "column {column}");
assert_eq!(s.sel, selection, "column {column}");
assert_eq!(s.dirty, dirty, "column {column}");
run(&mut s, "edit.undo", json!({}));
assert_eq!(column_texts(&s), ["Charlie", "Alpha", "Bravo"], "column {column}: refusal must not consume Undo");
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc, before, "column {column}: Redo must restore the preceding edit");
}
}
#[test]
fn table_sort_keeps_default_and_valid_columns() {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 3, "cols": 2}));
for (k, value) in ["Charlie", "1", "Alpha", "3", "Bravo", "2"].into_iter().enumerate() {
if k > 0 {
run(&mut s, "text.tab", json!({}));
}
run(&mut s, "text.insert", json!({"text": value}));
}
let original = s.doc.clone();
for params in [json!({"header": false}), json!({"column": 0, "header": false})] {
run(&mut s, "table.sort", params);
assert_eq!(column_texts(&s), ["Alpha", "Bravo", "Charlie"]);
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc, original);
}
run(&mut s, "table.sort", json!({"column": 1, "header": false}));
assert_eq!(column_texts(&s), ["Charlie", "Bravo", "Alpha"]);
run(&mut s, "table.sort", json!({"column": 1, "header": false, "descending": true}));
assert_eq!(column_texts(&s), ["Alpha", "Bravo", "Charlie"]);
// A column may be missing in some rows. Its present cells still sort normally.
let at = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
let table = s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![at])).unwrap();
table.rows[0].cells.remove(1);
run(&mut s, "table.sort", json!({"column": 1, "header": false}));
assert_eq!(column_texts(&s), ["Charlie", "Bravo", "Alpha"]);
let mut s = column_table("en-US", &["Title", "3", "1", "2"]);
let at = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![at])).unwrap().rows[0].props.header = true;
run(&mut s, "table.sort", json!({"column": 0}));
assert_eq!(column_texts(&s), ["Title", "1", "2", "3"]);
}
/// #836: table sort reads numbers in the text's language: `1,5` is one and a half in Polish.
#[test]
fn table_sort_reads_decimal_comma_in_polish() {
@@ -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);
}
+7 -55
View File
@@ -21,7 +21,7 @@ pub use fontdb::{
pub use harfrust::Feature;
use harfrust::{Direction, ShapeOptions, ShapePlan, ShapePlanKey, Shaper, Tag, UnicodeBuffer};
pub use kurbo::BezPath;
pub use script::{fallback_rank, is_emoji, joins_cursively, needs_layout_tables, script_fallbacks};
pub use script::{fallback_rank, is_emoji, needs_layout_tables, script_fallbacks};
use skrifa::MetadataProvider;
use skrifa::instance::Size;
pub use ui_fallback::{
@@ -185,34 +185,13 @@ fn direction_runs(text: &str) -> Vec<(std::ops::Range<usize>, bool)> {
/// order: right-to-left runs are shaped right to left, then their clusters put back in text
/// order (each cluster's glyphs keep the shaper's order); line layout reorders them visually.
pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
shape_in(face, text, features, map, ShapeContext::default())
}
/// The text shown on either side of what is being shaped. Only a letter's form depends on it: the
/// letters of an Arabic word join across a change of formatting, so each piece has to know what
/// it continues (#4093). Nothing outside the shaped text is drawn or measured.
#[derive(Clone, Copy, Debug, Default)]
pub struct ShapeContext<'a> {
pub before: &'a str,
pub after: &'a str,
}
impl<'a> ShapeContext<'a> {
/// This context for the part `r` of `text`: what's beyond `text` where `r` reaches its edge.
fn at_edges_of(self, text: &str, r: &std::ops::Range<usize>) -> ShapeContext<'a> {
ShapeContext { before: if r.start == 0 { self.before } else { "" }, after: if r.end == text.len() { self.after } else { "" } }
}
}
/// [`shape`] of a piece of longer text (see [`ShapeContext`]).
pub fn shape_in(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char, around: ShapeContext<'_>) -> Vec<ShapedGlyph> {
if !text.chars().any(is_rtl) {
return shape_dir(face, text, features, &map, false, around);
return shape_dir(face, text, features, &map, false);
}
let mut out = Vec::with_capacity(text.len());
for (r, rtl) in direction_runs(text) {
let Some(sub) = text.get(r.clone()) else { continue };
let mut g = shape_run_in(face, sub, features, &map, rtl, around.at_edges_of(text, &r));
let mut g = shape_run(face, sub, features, &map, rtl);
for x in &mut g {
x.cluster += r.start;
}
@@ -226,19 +205,7 @@ pub fn shape_in(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(
/// mark positioning. Glyphs come back in logical order like [`shape`]: clusters in text order,
/// each cluster's glyphs in the shaper's (visual) order.
pub fn shape_run(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char, rtl: bool) -> Vec<ShapedGlyph> {
shape_run_in(face, text, features, map, rtl, ShapeContext::default())
}
/// [`shape_run`] of a piece of longer text (see [`ShapeContext`]).
pub fn shape_run_in(
face: &FontFace,
text: &str,
features: &[Feature],
map: impl Fn(char) -> char,
rtl: bool,
around: ShapeContext<'_>,
) -> Vec<ShapedGlyph> {
let g = shape_dir(face, text, features, &map, rtl, around);
let g = shape_dir(face, text, features, &map, rtl);
if !rtl {
return g;
}
@@ -254,14 +221,7 @@ pub fn shape_run_in(
groups.into_iter().flatten().collect()
}
fn shape_dir(
face: &FontFace,
text: &str,
features: &[Feature],
map: &impl Fn(char) -> char,
rtl: bool,
around: ShapeContext<'_>,
) -> Vec<ShapedGlyph> {
fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(char) -> char, rtl: bool) -> Vec<ShapedGlyph> {
let mut out = Vec::with_capacity(text.len());
let shaped = face.hb().map(|hb| {
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
@@ -274,10 +234,6 @@ fn shape_dir(
for (i, c) in sub.char_indices() {
buf.add(map(c), (r.start + i) as u32);
}
// After the text: adding to a buffer clears what follows it.
let around = around.at_edges_of(text, &r);
buf.set_pre_context(around.before);
buf.set_post_context(around.after);
buf.guess_segment_properties();
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
let plan = shape_plan(face, &shaper, &buf, features);
@@ -430,10 +386,7 @@ mod tests {
gb.glyph_infos().iter().zip(gb.glyph_positions()).map(|(i, p)| (i.glyph_id, i.cluster, p.x_advance, p.x_offset, p.y_offset)).collect()
};
let kept = |face: &FontFace, text: &str, features: &[Feature], rtl: bool| -> Vec<(u32, u32, i32, i32, i32)> {
shape_dir(face, text, features, &|c| c, rtl, ShapeContext::default())
.iter()
.map(|g| (g.gid, g.cluster as u32, g.x_advance, g.x_offset, g.y_offset))
.collect()
shape_dir(face, text, features, &|c| c, rtl).iter().map(|g| (g.gid, g.cluster as u32, g.x_advance, g.x_offset, g.y_offset)).collect()
};
let db = FontDb::global();
let faces = [db.face(DEFAULT_FAMILY, "Regular"), db.face(DEFAULT_FAMILY, "Italic"), db.face("Source Sans 3", "Regular")];
@@ -468,8 +421,7 @@ mod tests {
for n in 0..3 * MAX_SHAPE_PLANS as u32 {
let features = [feature_value(b"kern", n % 2), feature_value(b"liga", n)];
let rtl = n % 3 == 0;
let kept: Vec<(u32, i32)> =
shape_dir(&face, "office", &features, &|c| c, rtl, ShapeContext::default()).iter().map(|g| (g.gid, g.x_advance)).collect();
let kept: Vec<(u32, i32)> = shape_dir(&face, "office", &features, &|c| c, rtl).iter().map(|g| (g.gid, g.x_advance)).collect();
assert_eq!(kept, fresh(&features, rtl), "{n}");
assert!(kept_shape_plans() <= MAX_SHAPE_PLANS, "{}", kept_shape_plans());
}
-22
View File
@@ -18,28 +18,6 @@ fn real_script(c: char) -> Option<Script> {
}
}
/// Whether `c` belongs to a script whose letters join their neighbours (Arabic, Syriac, Mongolian…),
/// so a letter's form depends on the text on either side of it.
pub fn joins_cursively(c: char) -> bool {
c >= '\u{0600}'
&& matches!(
c.script(),
Script::Adlam
| Script::Arabic
| Script::Chorasmian
| Script::Hanifi_Rohingya
| Script::Mandaic
| Script::Manichaean
| Script::Mongolian
| Script::Nko
| Script::Old_Uyghur
| Script::Phags_Pa
| Script::Psalter_Pahlavi
| Script::Sogdian
| Script::Syriac
)
}
/// Maximal single-script byte ranges of `text`. Characters of no particular script join the run
/// before them (or, at the start, the first run), so the ranges cover `text` exactly.
pub(crate) fn script_runs(text: &str) -> Vec<std::ops::Range<usize>> {
-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") })
}
+2 -21
View File
@@ -6,7 +6,6 @@ use super::chars::{
use super::grid::east_asian;
use super::model::{ClKind, Cluster, Enclosed, Glyph, ParaEnv, RubyLayout, StyleRun, SymbolRun};
use super::{Arc, Document, FaceRef, NumeralMode, ResolvedChar, RubyAlign, SOFT_HYPHEN};
use wordcraft_fonts::ShapeContext;
pub(super) struct Builder<'a> {
pub(super) env: &'a ParaEnv<'a>,
@@ -227,19 +226,6 @@ impl<'a> Builder<'a> {
/// `dir`: the bidi levels `text` is shaped by (see [`Dir`]).
pub(super) fn shape_with(&mut self, text: &str, base: usize, rc: &Arc<ResolvedChar>, kind_override: Option<ClKind>, dir: Dir<'_>) {
self.shape_around(text, base, rc, kind_override, dir, ShapeContext::default());
}
/// `around`: the text shown beside `text`, which its first and last letters may join.
pub(super) fn shape_around(
&mut self,
text: &str,
base: usize,
rc: &Arc<ResolvedChar>,
kind_override: Option<ClKind>,
dir: Dir<'_>,
around: ShapeContext<'_>,
) {
if text.is_empty() {
return;
}
@@ -388,15 +374,10 @@ impl<'a> Builder<'a> {
let from = usize::from(rc.kerned());
let to = if ligatures.is_some() { all.len() } else { 1 };
let features: &[wordcraft_fonts::Feature] = all.get(from..to).unwrap_or_default();
// Letters join across a change of font or direction inside the run too.
let around = ShapeContext {
before: if a == 0 { around.before } else { text.get(..a).unwrap_or("") },
after: if b == text.len() { around.after } else { text.get(b..).unwrap_or("") },
};
let shaped = if use_levels {
wordcraft_fonts::shape_run_in(&st.face, shape_text, features, map, key.rtl, around)
wordcraft_fonts::shape_run(&st.face, shape_text, features, map, key.rtl)
} else {
wordcraft_fonts::shape_in(&st.face, shape_text, features, map, around)
wordcraft_fonts::shape(&st.face, shape_text, features, map)
};
let k = st.size / st.face.upem.max(1.0) as f32;
let hscale = rc.scale / 100.0;
+3 -55
View File
@@ -6,8 +6,6 @@ use super::justify::hyphenation_points;
use super::lines::break_lines;
use super::model::{ClKind, Cluster, Enclosed, Glyph, Label, LabelRun, ParaEnv, RubyLayout, undrawable_bullet};
use super::proof::{ProofRun, is_list_item, proof_issues};
use wordcraft_fonts::ShapeContext;
use super::{
Arc, COLUMN_BREAK, CharProps, Document, FIELD_SHADING, InlineObject, LINE_BREAK, NoteKind, OBJ, PAGE_BREAK, PTabAlign, PTabBase, ParaLayout,
Paragraph, ResolvedChar, ResolvedPara, TabLeader, field_code_text, field_text_on, reference_link,
@@ -35,8 +33,6 @@ struct Cx<'a> {
math_props: wordcraft_doc::math::MathProps,
/// The first character, if it is to be a drop cap.
drop_first: Option<char>,
/// The text on the page, when its letters can join across runs (see [`shown_text`]).
shown: Option<String>,
}
/// Where a cluster for an object or a break goes in the paragraph: its text, style and first glyph.
@@ -87,10 +83,6 @@ struct Shaped<'a> {
bare: Vec<std::ops::Range<usize>>,
/// Each run's proofing language and whether it is checked (spelling, grammar, hyphenation).
runs: Vec<ProofRun>,
/// How far into the shown text ([`Cx::shown`]) the runs so far have come.
shown_at: usize,
/// Where in the shown text a letter's context may begin: past the drop cap, once one is drawn.
shown_floor: usize,
}
fn is_special(c: char) -> bool {
@@ -194,8 +186,6 @@ impl<'a> Shaped<'a> {
left: Vec::new(),
bare: Vec::new(),
runs: Vec::new(),
shown_at: 0,
shown_floor: 0,
}
}
@@ -246,44 +236,24 @@ impl<'a> Shaped<'a> {
/// A run shown: its text, with the breaks and objects in it as clusters of their own.
fn visible_run(&mut self, cx: &Cx, (rc, props): (&Arc<ResolvedChar>, &CharProps), range: &std::ops::Range<usize>, text: &str) {
let shown_start = self.shown_at;
let around = self.next_shown(cx, text.len());
let mut seg = self.drop_cap_letter(cx, rc, text, range.start);
if seg > 0 {
// A drop cap is drawn apart from its word: nothing after it joins it, in this run or
// in the runs after it (the letter can be formatted on its own).
self.shown_floor = shown_start.saturating_add(seg);
}
// The first piece continues what came before the run. After a drop cap or a break nothing
// joins: that letter is drawn apart, and a break is no letter. The same goes for the end.
let mut before = if seg == 0 { around.before } else { "" };
for (i, c) in text.char_indices() {
if i < seg || !is_special(c) {
continue;
}
if let Some(s) = text.get(seg..i) {
self.b.shape_around(s, range.start + seg, rc, None, Dir::Para, ShapeContext { before, after: "" });
self.b.shape(s, range.start + seg, rc, None);
}
before = "";
seg = i + c.len_utf8();
let style = self.b.style(rc, None, false);
let slot = Slot { start: range.start + i, end: range.start + i + c.len_utf8(), style, g: self.b.glyphs.len() as u32 };
self.special(cx, c, slot, (rc, props));
}
if let Some(s) = text.get(seg..) {
self.b.shape_around(s, range.start + seg, rc, None, Dir::Para, ShapeContext { before, after: around.after });
self.b.shape(s, range.start + seg, rc, None);
}
}
/// The text shown on either side of the next `len` bytes shown, and moves past them. What
/// comes before starts after a drop cap ([`Shaped::shown_floor`]).
fn next_shown<'c>(&mut self, cx: &'c Cx, len: usize) -> ShapeContext<'c> {
let Some(shown) = &cx.shown else { return ShapeContext::default() };
let start = self.shown_at;
self.shown_at = start.saturating_add(len);
ShapeContext { before: shown.get(self.shown_floor.min(start)..start).unwrap_or(""), after: shown.get(self.shown_at..).unwrap_or("") }
}
/// Drop cap: the first character, sized so its capital spans `lines` lines. Returns how much
/// of the run's text it took.
fn drop_cap_letter(&mut self, cx: &Cx, rc: &Arc<ResolvedChar>, text: &str, start: usize) -> usize {
@@ -623,26 +593,6 @@ fn joined_cluster_starts(p: &Paragraph, clusters: &[Cluster]) -> Vec<usize> {
joined
}
/// The paragraph's text without the runs left out of the layout, if it has letters that join their
/// neighbours: a letter joins the next one on the page, across a change of formatting and past
/// text that isn't shown (#4093). `None` for every other paragraph, which don't need it.
fn shown_text(cx: &Cx) -> Option<String> {
let text = &cx.p.text;
if text.is_ascii() || !text.chars().any(wordcraft_fonts::joins_cursively) {
return None;
}
let mut shown = String::with_capacity(text.len());
for (range, props) in cx.p.run_ranges() {
let rc = cx.res.char(props);
if !left_out(cx.env.doc, &rc, cx.env.show_hidden, cx.env.hide_deleted)
&& let Some(run) = text.get(range)
{
shown.push_str(run);
}
}
Some(shown)
}
/// Lay out one paragraph.
pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
let doc = env.doc;
@@ -659,9 +609,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
shown: Vec::new(),
substituted: Vec::new(),
};
let mut cx =
Cx { p, env, rp: &rp, res, math_props: doc.settings.math.clone().unwrap_or_default(), drop_first: drop_cap_char(p, &rp), shown: None };
cx.shown = shown_text(&cx);
let cx = Cx { p, env, rp: &rp, res, math_props: doc.settings.math.clone().unwrap_or_default(), drop_first: drop_cap_char(p, &rp) };
let mut sh = Shaped::new(b, env.eq_number);
let mark_rc = cx.res.char(&p.mark);
let mark_style = sh.b.style(&mark_rc, None, false);
-146
View File
@@ -639,149 +639,3 @@ fn list_label_after_leading_page_break_uses_its_text_lines_alignment() {
}
}
}
/// The glyph ids of each letter of paragraph 0 as laid out (marks of runs left out have none).
fn letters(l: &DocLayout) -> Vec<Vec<u32>> {
let (pl, _) = first_line(l, 0);
pl.clusters.iter().filter(|c| c.g1 > c.g0).map(|c| pl.glyphs[c.g0 as usize..c.g1 as usize].iter().map(|g| g.gid).collect()).collect()
}
/// `text` as one right-to-left paragraph with `format` applied to each `(start, end)` byte range.
fn formatted(text: &str, splits: &[(usize, usize)], format: &dyn Fn(&mut wordcraft_doc::props::CharProps)) -> Document {
let mut d = doc_of(&[(text, true)]);
for &(start, end) in splits {
d.format_range(&Pos::body(0, start), &Pos::body(0, end), format).unwrap();
}
d
}
const BEH: usize = 'ب'.len_utf8();
fn red(c: &mut wordcraft_doc::props::CharProps) {
c.color = Some(wordcraft_doc::props::TextColor::Rgb(wordcraft_doc::props::Rgb(0xC0, 0, 0)));
}
/// The reporter's symptom: after a change of formatting inside a word, the letters on either side
/// took the forms of the end and the start of a word, which reads as a space in it (#4093).
#[test]
fn letters_join_across_a_change_of_formatting() {
if arabic_face().is_none() {
eprintln!("skipped: no font with Persian letters installed");
return;
}
let word = "بببب";
let whole = letters(&lay(&doc_of(&[(word, true)])));
assert_eq!(whole.len(), 4, "one cluster per letter: {whole:?}");
assert!(whole.first() != whole.get(1) && whole.get(1) != whole.last(), "initial, medial and final forms: {whole:?}");
// A letter of its own at each place, then the two middle letters, then every other letter.
let pieces: [&[(usize, usize)]; 6] =
[&[(0, BEH)], &[(BEH, 2 * BEH)], &[(2 * BEH, 3 * BEH)], &[(3 * BEH, 4 * BEH)], &[(BEH, 3 * BEH)], &[(0, BEH), (2 * BEH, 3 * BEH)]];
for runs in pieces {
let d = formatted(word, runs, &red);
assert!(d.para_at(&Pos::body(0, 0)).unwrap().runs.len() > 1, "{runs:?} split the run");
assert_eq!(letters(&lay(&d)), whole, "split at {runs:?}");
}
}
/// A letter joins what is on the page: with a tracked deletion shown, the deleted letter; with it
/// left out, the letter past it. The same goes for hidden text.
#[test]
fn letters_join_past_text_left_out_of_the_layout() {
if arabic_face().is_none() {
eprintln!("skipped: no font with Persian letters installed");
return;
}
let (three, two) = (letters(&lay(&doc_of(&[("ببب", true)]))), letters(&lay(&doc_of(&[("بب", true)]))));
assert_eq!((three.len(), two.len()), (3, 2));
let middle = [(BEH, 2 * BEH)];
let deleted = formatted("ببب", &middle, &|c| c.del = Some(0));
assert_eq!(letters(&lay(&deleted)), three, "deletion shown: it joins both neighbours");
let mut c = LayoutCache::new();
let final_text = layout(&deleted, &mut c, &LayoutOptions { hide_deleted: true, ..LayoutOptions::default() });
assert_eq!(letters(&final_text), two, "deletion left out: its neighbours join each other");
let hidden = formatted("ببب", &middle, &|c| c.hidden = Some(true));
assert_eq!(letters(&lay(&hidden)), two, "hidden text left out: its neighbours join each other");
let mut c = LayoutCache::new();
let shown = layout(&hidden, &mut c, &LayoutOptions { show_hidden: true, ..LayoutOptions::default() });
assert_eq!(letters(&shown), three, "hidden text shown: it joins both neighbours");
}
/// A mark in a run of its own (a coloured vowel sign) doesn't stop the letters around it joining.
#[test]
fn letters_join_past_a_mark_in_a_run_of_its_own() {
if arabic_face().is_none() {
eprintln!("skipped: no font with Persian letters installed");
return;
}
let word = "بَب";
let whole = letters(&lay(&doc_of(&[(word, true)])));
let split = letters(&lay(&formatted(word, &[(BEH, BEH + 'َ'.len_utf8())], &red)));
assert_eq!(split.last(), whole.last(), "the letter after the mark stays joined");
// Together with the mark, the letter before it has more glyphs, in the shaper's order.
let (before, with_mark) = (split.first().unwrap(), whole.first().unwrap());
assert!(before.iter().all(|g| with_mark.contains(g)), "and the letter before it: {before:?} {with_mark:?}");
}
/// What can't join still doesn't: a tab between the letters, in one run or at the edge of a run, a
/// Latin neighbour, and a letter beside a drop cap, which is drawn apart from the word.
#[test]
fn letters_stay_apart_where_nothing_joins_them() {
if arabic_face().is_none() {
eprintln!("skipped: no font with Persian letters installed");
return;
}
let alone = letters(&lay(&doc_of(&[("ب", true)])));
assert_eq!(alone.len(), 1);
let beh = alone.first();
let tabbed = "ب\tب";
for d in [doc_of(&[(tabbed, true)]), formatted(tabbed, &[(0, BEH)], &red), formatted(tabbed, &[(BEH + 1, tabbed.len())], &red)] {
let got = letters(&lay(&d));
assert_eq!((got.first(), got.last()), (beh, beh), "across a tab: {got:?}");
}
let got = letters(&lay(&formatted("xب", &[(0, 1)], &red)));
assert_eq!(got.last(), beh, "after a Latin letter: {got:?}");
// The drop cap in the word's run, and formatted on its own (a run of its own).
let two = letters(&lay(&doc_of(&[("بب", true)])));
for mut d in [doc_of(&[("ببب", true)]), formatted("ببب", &[(0, BEH)], &red)] {
let at = Pos::body(0, 0);
d.format_paragraphs(&at, &at, &|p: &mut ParaProps| p.drop_cap = Some(2)).unwrap();
let runs = d.para_at(&at).unwrap().runs.len();
let got = letters(&lay(&d));
assert_eq!(got.get(got.len().saturating_sub(2)..), two.get(..), "{runs} run(s): the letters after a drop cap start a word: {got:?} {two:?}");
}
}
#[test]
fn the_shaper_takes_a_letters_form_from_the_text_around_it() {
use wordcraft_fonts::{ShapeContext, shape_run_in};
let Some(face) = arabic_face() else {
eprintln!("skipped: no font with Persian letters installed");
return;
};
let form = |around| cluster_glyphs(&shape_run_in(&face, "ب", &[], |c| c, true, around));
let isolated = form(ShapeContext::default());
let joined = cluster_glyphs(&wordcraft_fonts::shape_run(&face, "ببب", &[], |c| c, true));
assert_eq!(form(ShapeContext { before: "", after: "ب" }).first(), joined.first(), "initial");
assert_eq!(form(ShapeContext { before: "ب", after: "ب" }).first(), joined.get(1), "medial");
assert_eq!(form(ShapeContext { before: "ب", after: "" }).first(), joined.get(2), "final");
// A vowel sign is transparent, and ZWNJ and a Latin letter stop the join.
assert_eq!(form(ShapeContext { before: "بَ", after: "" }).first(), joined.get(2), "past a mark");
assert_eq!(form(ShapeContext { before: "ب\u{200C}", after: "" }), isolated, "after ZWNJ");
assert_eq!(form(ShapeContext { before: "a", after: "b" }), isolated, "between Latin letters");
// Only the letter's form depends on the context: nothing of it is drawn.
assert_eq!(form(ShapeContext { before: "ببب", after: "ببب" }).len(), 1);
}
#[test]
fn scripts_that_join_their_letters_are_told_apart() {
use wordcraft_fonts::joins_cursively;
for c in ['ب', 'پ', 'ܐ', 'ᠠ', 'ߊ', '𞤀'] {
assert!(joins_cursively(c), "{c}");
}
for c in ['a', 'é', '’', 'я', 'א', '中', ' ', '\t', '\u{FFFC}', '\u{200C}', 'َ'] {
assert!(!joins_cursively(c), "{c:?}");
}
}
+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");
}
+1 -1
View File
@@ -11,7 +11,7 @@ impl WordApp {
}
self.font_generation = generation;
self.session.relayout();
self.previews.fonts_changed();
self.previews.fonts_changed(ctx, db.user_fonts().iter().flat_map(|f| f.families()));
ctx.request_repaint();
}
-1
View File
@@ -48,7 +48,6 @@ use geometry::{floating_comments, on_page_gap, on_visible_page, white_space_hidd
use input::{EDGE_MARGIN, EDGE_MAX_DT, EDGE_SPEED_MAX};
use input::{UnitDrag, clamped_scroll, edge_scroll, mouse, wheel_hovered};
pub(crate) use input::{hold_document_selection, press_count};
pub(crate) use links::follow_link;
#[cfg(test)]
use links::{field_link, is_followable_link, is_generated_bookmark_link};
#[cfg(test)]
+16 -10
View File
@@ -1,5 +1,5 @@
use super::balloons::over_balloon;
use super::links::{field_showing_code, follow_link, is_generated_bookmark_link, link_under};
use super::links::{field_showing_code, is_followable_link, is_generated_bookmark_link, link_under};
use super::navigation::page_at;
use super::story::{enters_header_footer, in_text_box, part_kind, text_box_host};
use super::{CanvasState, DocLayout, PartKind, Pos, Pos2, Rect, StoryRef, Ui, WordApp, json};
@@ -110,7 +110,7 @@ pub(super) fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[
};
let Some((page, x, y)) = page_at(rects, layout, scale, p) else { return };
let story = app.session.sel.focus.story;
handle_page_pointer(app, PagePointer { ui, resp, layout, rects, scale, p, page, x, y, story });
handle_page_pointer(app, PagePointer { ui, resp, layout, p, page, x, y, story });
}
fn route_pointer_targets(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layout: &DocLayout, scale: f32) -> Option<Pos2> {
@@ -143,8 +143,6 @@ struct PagePointer<'a> {
ui: &'a Ui,
resp: &'a egui::Response,
layout: &'a DocLayout,
rects: &'a [Rect],
scale: f32,
p: Pos2,
page: usize,
x: f32,
@@ -331,7 +329,16 @@ fn handle_primary_press(
if mods.command
&& let Some(link) = link_under(app, layout, page, x, y, &pos)
{
follow_link(app, link);
if let Some(name) = link.strip_prefix('#') {
// The caret goes to the bookmark's start, as in Word (Go To selects its text).
if app.run("edit.goto", json!({"bookmark": name})).is_ok() {
let _ = app.run("select.collapse", json!({}));
}
} else if is_followable_link(&link) {
app.canvas.open_url = Some(link);
} else {
app.status(tl!("Only web (http, https) and email (mailto) links can be followed"));
}
return;
}
let pj = serde_json::to_value(&pos).unwrap_or_default();
@@ -472,17 +479,16 @@ fn skip_equation_or_outline(app: &mut WordApp, pointer: &PagePointer<'_>) -> boo
}
fn show_hover_link(app: &WordApp, pointer: &PagePointer<'_>) {
let PagePointer { ui, resp, layout, rects, scale, p, page, x, y, story } = pointer;
let PagePointer { ui, resp, layout, page, x, y, story, .. } = pointer;
// Hover a link: show its target, unless the context menu is opening or open: a tooltip draws
// above the menu and would cover its first items (#3368). A linked picture or shape under the
// pointer comes first: it lies over the text (#5267).
// above the menu and would cover its first items (#3368).
if resp.hovered()
&& !app.canvas.dragging
&& !app.canvas.context_menu_open
&& !resp.secondary_clicked()
&& !ui.input(|i| i.pointer.secondary_down())
&& let Some(link) = crate::objects::link_at(app, layout, rects, *scale, *p)
.or_else(|| layout.hit(*page, *x, *y, *story).and_then(|pos| link_under(app, layout, *page, *x, *y, &pos)))
&& let Some(pos) = layout.hit(*page, *x, *y, *story)
&& let Some(link) = link_under(app, layout, *page, *x, *y, &pos)
{
let key = if cfg!(target_os = "macos") { "⌘" } else { "Ctrl" };
let target = if is_generated_bookmark_link(&link) { tl!("Current Document").to_string() } else { link };
-15
View File
@@ -43,21 +43,6 @@ pub(super) fn is_generated_bookmark_link(link: &str) -> bool {
link.strip_prefix("#_").is_some()
}
/// Ctrl/⌘+click on `link` (in the text or on a picture): a place in this document takes the caret
/// to its bookmark; a web or email address opens in the browser; any other is not followed.
pub(crate) fn follow_link(app: &mut WordApp, link: String) {
if let Some(name) = link.strip_prefix('#') {
// The caret goes to the bookmark's start, as in Word (Go To selects its text).
if app.run("edit.goto", serde_json::json!({"bookmark": name})).is_ok() {
let _ = app.run("select.collapse", serde_json::json!({}));
}
} else if is_followable_link(&link) {
app.canvas.open_url = Some(link);
} else {
app.status(tl!("Only web (http, https) and email (mailto) links can be followed"));
}
}
/// Whether Ctrl/⌘+click may hand a document's hyperlink to the system: web and email links only,
/// so a document can't launch `file:` paths, programs or custom-scheme handlers.
pub(super) fn is_followable_link(url: &str) -> bool {
-113
View File
@@ -5073,116 +5073,3 @@ fn a_pictures_corrections_and_crop_change_its_page_key() {
assert_ne!(plain, pages::page_content_key(&at([0.1, 0.0, 0.0, 0.0], PicFx::default())), "crop");
assert_ne!(plain, pages::page_content_key(&at([0.0; 4], PicFx::new(10_000, 0, 0))), "brightness");
}
/// #5267: a picture linked from its context menu (Link…) keeps the link, shows it on hover and
/// follows it on Ctrl/⌘+click, in line with the text and floating over it.
#[test]
fn a_picture_linked_from_its_context_menu_shows_and_follows_its_link() {
use egui_kittest::kittest::Queryable;
for floating in [false, true] {
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
a.run("text.insert", json!({"text": "Text before the picture "})).unwrap();
a.run("select.text", json!({"text": "before"})).unwrap();
a.run("insert.bookmark", json!({"name": "Spot"})).unwrap();
a.run("caret.docEnd", json!({})).unwrap();
let mut png = Vec::new();
let img = image::RgbaImage::from_pixel(40, 20, image::Rgba([200, 30, 30, 255]));
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
a.run("insert.picture", json!({"data": wordcraft_engine::cmd::insert::base64_encode(&png), "width": 160})).unwrap();
if floating {
a.run("arrange.wrap", json!({"wrap": "inFrontOfText"})).unwrap();
}
let pos = wordcraft_engine::cmd::objects::selected(&a.session).unwrap().0;
a.session.sel = wordcraft_engine::Selection::caret(Pos::body(0, 0));
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,
);
steps(&mut h, 6);
let rect = h.state_mut().session.layout().object(&pos, 0).expect("the picture is laid out").rect;
let at = crate::frame::to_screen(h.state().canvas.page_rects[0], h.state().canvas.scale, rect).center();
h.event(egui::Event::PointerMoved(at));
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() });
h.step();
}
h.step();
h.get_by_label_contains("Link…").click();
steps(&mut h, 4);
h.event(egui::Event::Text("https://example.com/picture".into()));
steps(&mut h, 3);
h.get_by_label_contains("OK").click();
steps(&mut h, 4);
let s = &h.state().session;
let link = s.doc.para_at(&pos).map(|p| p.props_of_char(pos.off).link.clone());
assert_eq!(link, Some(Some("https://example.com/picture".to_string())), "floating {floating}: the link is on the picture");
// Hovering the picture shows where it goes.
h.event(egui::Event::PointerMoved(at + egui::vec2(1.0, 0.0)));
steps(&mut h, 3);
assert!(h.query_by_label_contains("https://example.com/picture").is_some(), "floating {floating}: the hover tip names the link");
// Ctrl/⌘+click follows it, instead of picking the picture for a copy: here a link to a
// place in the document, which moves the caret (a web address goes to the browser).
h.state_mut().run("insert.link", json!({"bookmark": "Spot"})).unwrap();
steps(&mut h, 2);
let press = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::COMMAND };
h.event_modifiers(press, egui::Modifiers::COMMAND);
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
steps(&mut h, 3);
let s = &h.state().session;
let (start, _) = wordcraft_engine::cmd::insert::bookmark_range(&s.doc, "Spot").unwrap();
assert!(s.sel.is_collapsed() && s.sel.focus == start, "floating {floating}: the caret went to the bookmark: {:?}", s.sel);
assert!(h.state().canvas.obj_drag.is_none(), "floating {floating}: no copy-drag started");
}
}
/// #5267: inside a linked text box, hovering shows the box's link and Ctrl/⌘+click follows it,
/// although a click there reaches the box's text rather than the box.
#[test]
fn a_linked_text_box_shows_and_follows_its_link_from_inside() {
use egui_kittest::kittest::Queryable;
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
a.run("text.insert", json!({"text": "Text before the box "})).unwrap();
a.run("select.text", json!({"text": "before"})).unwrap();
a.run("insert.bookmark", json!({"name": "Spot"})).unwrap();
a.run("caret.docEnd", json!({})).unwrap();
a.run("insert.textBox", json!({"text": "Boxed words"})).unwrap();
let link_box = |a: &mut WordApp, link: serde_json::Value| {
let bx = a.session.layout().find_object(0, |o| o.text_box.is_some()).expect("the box");
let end = Pos { off: bx.off + wordcraft_doc::para::OBJ.len_utf8(), ..bx.pos() };
a.run("select.range", json!({"anchor": bx.pos(), "focus": end})).unwrap();
a.run("insert.link", link).unwrap();
a.session.sel = wordcraft_engine::Selection::caret(Pos::body(0, 0));
bx.pos()
};
let pos = link_box(&mut a, json!({"url": "https://example.com/box"}));
let link = a.session.doc.para_at(&pos).map(|p| p.props_of_char(pos.off).link.clone());
assert_eq!(link, Some(Some("https://example.com/box".to_string())), "the link is on the text box");
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,
);
steps(&mut h, 6);
let rect = h.state_mut().session.layout().find_object(0, |o| o.text_box.is_some()).expect("the box is laid out").rect;
let at = crate::frame::to_screen(h.state().canvas.page_rects[0], h.state().canvas.scale, rect).center();
h.event(egui::Event::PointerMoved(at));
steps(&mut h, 3);
assert!(h.query_by_label_contains("https://example.com/box").is_some(), "the hover tip names the box's link");
// Ctrl/⌘+click inside the box follows it: here a place in the document, which moves the caret.
link_box(h.state_mut(), json!({"bookmark": "Spot"}));
steps(&mut h, 2);
let press = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::COMMAND };
h.event_modifiers(press, egui::Modifiers::COMMAND);
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
steps(&mut h, 3);
let s = &h.state().session;
let (start, _) = wordcraft_engine::cmd::insert::bookmark_range(&s.doc, "Spot").unwrap();
assert!(s.sel.is_collapsed() && s.sel.focus == start, "the caret went to the bookmark: {:?}", s.sel);
}
+1 -50
View File
@@ -354,36 +354,13 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect]
});
return true;
}
let front = grab_at(app, layout, pages, scale, at);
// Ctrl/⌘+click inside a linked text box follows the text box's link (its text is not grabbed
// as the object, so the object branch below can't see it).
if pressed
&& front.is_none()
&& ui.input(|i| i.modifiers.command && !i.modifiers.shift)
&& let Some(link) = text_box_at(layout, pages, scale, at).and_then(|object| object_link(app, &object))
{
crate::canvas::follow_link(app, link);
return true;
}
// A Behind Text object reached only for dragging lies under the text: text over it keeps its click.
let hit = front.is_some();
let grabbed = front.or_else(|| pressed.then(|| behind_drag_at(app, layout, pages, scale, at)).flatten());
let grabbed = grab_at(app, layout, pages, scale, at).or_else(|| pressed.then(|| behind_drag_at(app, layout, pages, scale, at)).flatten());
let Some((object, grab)) = grabbed else { return false };
// Double-click a SmartArt graphic (selected by the first click) to edit its text.
if resp.double_clicked() && wordcraft_engine::cmd::smart_art::selected_smart_art(&app.session).is_some() {
let _ = app.run("smartArt.textPane", json!({"value": true}));
return true;
}
// Ctrl/⌘+click on a linked picture or shape follows its link, as on linked text (#5267).
if pressed
&& hit
&& grab == Grab::Move
&& ui.input(|i| i.modifiers.command && !i.modifiers.shift)
&& let Some(link) = object_link(app, &object)
{
crate::canvas::follow_link(app, link);
return true;
}
// Shift/Ctrl+click adds an object to the selected ones (or takes it out), for Group.
let adding = ui.input(|i| i.modifiers.shift || i.modifiers.command);
if let Some(index) = object.member.filter(|_| pressed && !adding) {
@@ -614,32 +591,6 @@ pub fn select_at(app: &mut WordApp, layout: &DocLayout, pages: &[Rect], scale: f
true
}
/// The hyperlink of the picture, shape or text box under `at` (Insert › Link with it selected,
/// or a link read from the file), not of a frame handle: what hovering shows and Ctrl/⌘+click
/// follows, as for linked text (#5267).
pub fn link_at(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Pos2) -> Option<String> {
match grab_at(app, layout, pages, scale, at) {
Some((object, Grab::Move)) => object_link(app, &object),
Some(_) => None,
// Inside a text box its text is what a click reaches, but a linked text box's link
// comes before its text's own, as for a picture or shape.
None => text_box_at(layout, pages, scale, at).and_then(|object| object_link(app, &object)),
}
}
/// The text box whose text is under `at`, if any.
fn text_box_at(layout: &DocLayout, pages: &[Rect], scale: f32, at: Pos2) -> Option<ObjectHit> {
let (page, x, y) = crate::canvas::page_at(pages, layout, scale, at)?;
let StoryRef::Part(id) = layout.story_at(page, x, y)? else { return None };
layout.text_box(id, page)
}
/// The hyperlink on `object`'s character, if it has one.
fn object_link(app: &WordApp, object: &ObjectHit) -> Option<String> {
let pos = object.pos();
app.session.doc.para_at(&pos)?.props_of_char(pos.off).link.clone().filter(|l| !l.is_empty())
}
/// What a press at `at` would grab: a handle of the shown frame, else an object of the story
/// being edited (body, or the header/footer).
fn grab_at(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Pos2) -> Option<(ObjectHit, Grab)> {
+52 -78
View File
@@ -28,44 +28,30 @@ pub struct Previews {
budget: std::cell::Cell<u32>,
frame: u64,
/// The Equation tab's pictures, packed into one texture (#1002).
math: PreviewAtlas,
/// The font menus' entries, packed the same way (#5105); shared with the closures
/// [`Previews::font_preview_fn`] hands out.
fonts: Arc<egui::mutex::Mutex<FontAtlas>>,
math: MathAtlas,
}
/// The font menus' atlas, and the font database generation its entries were drawn with. When
/// fonts are added or removed, from this window or another one, every entry is drawn again: an
/// entry drawn in the fallback face before its font was added, or in a removed font's face (a
/// Recently Used Fonts entry), never stays.
#[derive(Default)]
struct FontAtlas {
generation: u64,
atlas: PreviewAtlas,
}
/// Side of each square texture page the pictures of a [`PreviewAtlas`] share, in pixels.
const ATLAS: usize = 2048;
/// Side of each square texture page the equation pictures share, in pixels.
const MATH_ATLAS: usize = 2048;
/// At most this many pages: enough for a frame's pictures at a high display scale (the 22
/// built-in equations alone take two pages at 3×), still a handful of textures in all.
const ATLAS_PAGES: usize = 4;
const MATH_ATLAS_PAGES: usize = 4;
/// Transparent pixels between pictures, so linear filtering never bleeds one into the next.
const ATLAS_GAP: usize = 2;
const MATH_ATLAS_GAP: usize = 2;
/// Small pictures packed into a few shared textures ("pages"), row by row, and drawn as a part
/// of one: every equation picture (gallery tiles, structure buttons, symbols, #1002), and in an
/// atlas of their own the font menus' entries (#5105).
/// Every equation picture (gallery tiles, structure buttons, symbols) packed into a few shared
/// textures ("pages"), row by row, and drawn as a part of one (#1002).
///
/// They used to be a texture each: hundreds of small textures, created as galleries and font
/// menus were browsed and freed again. On Windows (DirectX 12) the pictures then showed other
/// textures' memory: inverted pictures (font names on black strips in light mode, white ones in
/// dark mode), red and teal fills, and even damaged interface letters. A texture written in place is what egui does for its own glyphs; the
/// atlas creates at most [`ATLAS_PAGES`] textures and frees none while galleries are
/// They used to be a texture each: hundreds of small textures, created as galleries opened and
/// all freed at once when the preview cache filled. On Windows (DirectX 12) the tiles then
/// showed other textures' memory: inverted pictures, red and teal fills, and even damaged
/// interface letters. A texture written in place is what egui does for its own glyphs; the
/// atlas creates at most [`MATH_ATLAS_PAGES`] textures and frees none while galleries are
/// browsed. When every page is full, the page least recently drawn from starts over, but never
/// one drawn from in the current frame (its pictures are already on screen): a frame whose
/// pictures don't fit shows the rest as linear text.
#[derive(Default)]
struct PreviewAtlas {
struct MathAtlas {
pages: Vec<AtlasPage>,
/// Picture key → its page and part of that page's texture (UV coordinates).
uv: HashMap<String, (usize, Rect)>,
@@ -87,19 +73,19 @@ struct AtlasPage {
impl AtlasPage {
/// A free spot for a `w`×`h` picture, or `None` when the page is full or it can't fit.
fn place(&mut self, w: usize, h: usize) -> Option<[usize; 2]> {
if w == 0 || h == 0 || w > ATLAS || h > ATLAS {
if w == 0 || h == 0 || w > MATH_ATLAS || h > MATH_ATLAS {
return None;
}
let (mut x, mut y, mut row_h) = (self.x, self.y, self.row_h);
if x.saturating_add(w) > ATLAS {
if x.saturating_add(w) > MATH_ATLAS {
x = 0;
y = y.saturating_add(row_h).saturating_add(ATLAS_GAP);
y = y.saturating_add(row_h).saturating_add(MATH_ATLAS_GAP);
row_h = 0;
}
if y.saturating_add(h) > ATLAS {
if y.saturating_add(h) > MATH_ATLAS {
return None;
}
self.x = x.saturating_add(w).saturating_add(ATLAS_GAP);
self.x = x.saturating_add(w).saturating_add(MATH_ATLAS_GAP);
self.y = y;
self.row_h = row_h.max(h);
Some([x, y])
@@ -111,12 +97,12 @@ impl AtlasPage {
self.y = 0;
self.row_h = 0;
if let Some(tex) = &mut self.tex {
tex.set(egui::ColorImage::filled([ATLAS, ATLAS], egui::Color32::TRANSPARENT), egui::TextureOptions::LINEAR);
tex.set(egui::ColorImage::filled([MATH_ATLAS, MATH_ATLAS], egui::Color32::TRANSPARENT), egui::TextureOptions::LINEAR);
}
}
}
impl PreviewAtlas {
impl MathAtlas {
/// The picture stored under `key`, marking its page as drawn from in `frame`.
fn get(&mut self, frame: u64, key: &str) -> Option<(egui::TextureId, Rect)> {
let (page, uv) = *self.uv.get(key)?;
@@ -130,11 +116,11 @@ impl PreviewAtlas {
/// this frame without wiping pictures already handed out in it.
fn add(&mut self, ctx: &egui::Context, frame: u64, key: &str, img: egui::ColorImage) -> Option<(egui::TextureId, Rect)> {
let [w, h] = img.size;
if w == 0 || h == 0 || w > ATLAS || h > ATLAS {
if w == 0 || h == 0 || w > MATH_ATLAS || h > MATH_ATLAS {
return None;
}
let mut spot = self.pages.iter_mut().enumerate().find_map(|(i, p)| p.place(w, h).map(|at| (i, at)));
if spot.is_none() && self.pages.len() < ATLAS_PAGES {
if spot.is_none() && self.pages.len() < MATH_ATLAS_PAGES {
let mut p = AtlasPage::default();
spot = p.place(w, h).map(|at| (self.pages.len(), at));
self.pages.push(p);
@@ -152,31 +138,18 @@ impl PreviewAtlas {
let p = self.pages.get_mut(i)?;
let tex = p.tex.get_or_insert_with(|| {
ctx.load_texture(
format!("preview_atlas_{i}"),
egui::ColorImage::filled([ATLAS, ATLAS], egui::Color32::TRANSPARENT),
format!("math_atlas_{i}"),
egui::ColorImage::filled([MATH_ATLAS, MATH_ATLAS], egui::Color32::TRANSPARENT),
egui::TextureOptions::LINEAR,
)
});
tex.set_partial(at, img, egui::TextureOptions::LINEAR);
p.used = frame;
let s = ATLAS as f32;
let s = MATH_ATLAS as f32;
let uv = Rect::from_min_max(pos2(at[0] as f32 / s, at[1] as f32 / s), pos2((at[0] + w) as f32 / s, (at[1] + h) as f32 / s));
self.uv.insert(key.to_string(), (i, uv));
Some((tex.id(), uv))
}
/// Forget every picture and clear the pages, keeping their textures.
fn clear(&mut self) {
for p in &mut self.pages {
p.reset();
}
self.uv.clear();
}
}
/// A font menu entry's key in the fonts' atlas: its display scale and family.
fn font_key(name: &str, ppp: f32) -> String {
format!("{ppp}:{name}")
}
impl Previews {
@@ -237,37 +210,30 @@ impl Previews {
self.english_names.get(shown).cloned().unwrap_or_else(|| shown.to_string())
}
/// Fonts were added or removed: list the families again. The font menus' entries see the
/// change themselves ([`FontAtlas`]), in every window.
pub fn fonts_changed(&mut self) {
/// Fonts were added or removed: list the families again and redraw the entries of
/// `families` (an entry drawn before its font was added shows the fallback face).
pub fn fonts_changed(&mut self, ctx: &egui::Context, families: impl Iterator<Item = String>) {
self.families = None;
self.east_asian = None;
ctx.data_mut(|d| {
for f in families {
d.remove::<TextureHandle>(egui::Id::unique(format!("font:{f}")));
}
});
}
/// A closure drawing a font-menu entry in its own face, from the fonts' atlas.
/// A closure drawing a font-menu entry in its own face.
pub fn font_preview_fn(&mut self) -> Box<dyn Fn(&mut Ui, &str) -> Response> {
let atlas = Arc::clone(&self.fonts);
Box::new(move |ui: &mut Ui, name: &str| {
let ppp = ui.ctx().pixels_per_point();
let frame = ui.ctx().cumulative_frame_nr();
let key = font_key(name, ppp);
Box::new(|ui: &mut Ui, name: &str| {
let key = format!("font:{name}");
let tex: Option<TextureHandle> = ui.ctx().data(|d| d.get_temp::<TextureHandle>(egui::Id::unique(&key)));
let (r, resp) = ui.allocate_exact_size(vec2(260.0, crate::widgets::COMBO_PREVIEW_ROW_H), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), name));
let t = Tokens::get(ui.ctx());
if resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
// The atlas lock is never held while the Context's is, nor the other way round.
let generation = wordcraft_fonts::FontDb::global().generation();
let hit = {
let mut fonts = atlas.lock();
if fonts.generation != generation {
fonts.generation = generation;
fonts.atlas.clear();
}
fonts.atlas.get(frame, &key)
};
let hit = hit.or_else(|| {
let tex = tex.or_else(|| {
// Context accessors share a lock: read input before taking the data write lock.
let now = ui.input(|i| i.time);
let n = ui.ctx().data_mut(|d| {
@@ -282,14 +248,22 @@ impl Previews {
ui.ctx().request_repaint();
return None;
}
let ppp = ui.ctx().pixels_per_point();
let mut img = font_entry_image(name, ppp);
ink_mask(&mut img);
atlas.lock().atlas.add(ui.ctx(), frame, &key, img)
let h = ui.ctx().load_texture(&key, img, egui::TextureOptions::LINEAR);
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique(&key), h.clone()));
Some(h)
});
match hit {
Some((id, uv)) => {
let sz = uv.size() * ATLAS as f32 / ppp;
ui.painter().image(id, Rect::from_min_size(pos2(r.min.x + 6.0, r.center().y - sz.y / 2.0), sz), uv, t.text);
match tex {
Some(h) => {
let sz = h.size_vec2() / ui.ctx().pixels_per_point();
ui.painter().image(
h.id(),
Rect::from_min_size(pos2(r.min.x + 6.0, r.center().y - sz.y / 2.0), sz),
Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)),
t.text,
);
}
None => {
ui.painter().text(pos2(r.min.x + 8.0, r.center().y), egui::Align2::LEFT_CENTER, name, regular(12.0), t.text);
@@ -342,7 +316,7 @@ impl Previews {
/// An equation rasterised for a gallery tile or a button (`pt`: font size; `size`: the tile in
/// points), with its empty slots shown: the shared texture and the picture's UV rectangle in it
/// ([`PreviewAtlas`]). An ink mask, drawn tinted with the theme's text colour. `None` while waiting
/// ([`MathAtlas`]). An ink mask, drawn tinted with the theme's text colour. `None` while waiting
/// for this frame's rendering budget.
pub fn math_texture(
app: &mut WordApp,
+23 -87
View File
@@ -121,10 +121,10 @@ fn font_menu_previews_render_defer_and_reuse_without_deadlocking() {
let (tx, rx) = std::sync::mpsc::channel();
let worker = std::thread::spawn(move || {
let ctx = egui::Context::default();
let mut previews = Previews::default();
let preview = previews.font_preview_fn();
let preview = Previews::default().font_preview_fn();
let budget_id = egui::Id::unique("font_preview_budget");
let (sans, inter) = (font_key("Source Sans 3", 1.0), font_key("Inter", 1.0));
let sans_id = egui::Id::unique("font:Source Sans 3");
let inter_id = egui::Id::unique("font:Inter");
ctx.data_mut(|d| d.insert_temp(budget_id, (1.0_f64, 11_u32)));
for time in [1.0, 2.0, 3.0] {
@@ -134,16 +134,16 @@ fn font_menu_previews_render_defer_and_reuse_without_deadlocking() {
preview(ui, "Inter");
})
.drop_without_applying_deltas();
let drawn = |key: &str| previews.fonts.lock().atlas.uv.contains_key(key);
assert!(drawn(&sans));
let budget = ctx.data(|d| d.get_temp::<(f64, u32)>(budget_id));
if time == 1.0 {
assert!(!drawn(&inter), "over-budget entry should defer");
assert_eq!(budget, Some((1.0, 13)));
} else {
assert!(drawn(&inter), "deferred entry should render next frame");
assert_eq!(budget, Some((2.0, 1)), "cached entries should not consume budget");
}
ctx.data(|d| {
assert!(d.get_temp::<TextureHandle>(sans_id).is_some());
if time == 1.0 {
assert!(d.get_temp::<TextureHandle>(inter_id).is_none(), "over-budget entry should defer");
assert_eq!(d.get_temp::<(f64, u32)>(budget_id), Some((1.0, 13)));
} else {
assert!(d.get_temp::<TextureHandle>(inter_id).is_some(), "deferred entry should render next frame");
assert_eq!(d.get_temp::<(f64, u32)>(budget_id), Some((2.0, 1)), "cached entries should not consume budget");
}
});
}
tx.send(()).unwrap();
});
@@ -262,12 +262,12 @@ fn equation_pictures_share_one_texture() {
fn math_atlas_packs_rows() {
let mut a = AtlasPage::default();
assert_eq!(a.place(100, 40), Some([0, 0]));
assert_eq!(a.place(100, 20), Some([100 + ATLAS_GAP, 0]));
assert_eq!(a.place(ATLAS, 10), Some([0, 40 + ATLAS_GAP]), "a new row under the tallest");
assert_eq!(a.place(ATLAS + 1, 10), None);
assert_eq!(a.place(100, 20), Some([100 + MATH_ATLAS_GAP, 0]));
assert_eq!(a.place(MATH_ATLAS, 10), Some([0, 40 + MATH_ATLAS_GAP]), "a new row under the tallest");
assert_eq!(a.place(MATH_ATLAS + 1, 10), None);
assert_eq!(a.place(10, 0), None);
assert_eq!(a.place(10, ATLAS), None, "full");
assert_eq!(a.place(10, 10), Some([0, 40 + ATLAS_GAP + 10 + ATLAS_GAP]), "still room after a refusal");
assert_eq!(a.place(10, MATH_ATLAS), None, "full");
assert_eq!(a.place(10, 10), Some([0, 40 + MATH_ATLAS_GAP + 10 + MATH_ATLAS_GAP]), "still room after a refusal");
}
/// #1002: a page is never wiped in a frame that handed out one of its pictures (they are
@@ -275,16 +275,16 @@ fn math_atlas_packs_rows() {
#[test]
fn math_atlas_never_wipes_a_page_drawn_from_this_frame() {
let ctx = egui::Context::default();
let img = || egui::ColorImage::filled([ATLAS, ATLAS], egui::Color32::TRANSPARENT);
let img = || egui::ColorImage::filled([MATH_ATLAS, MATH_ATLAS], egui::Color32::TRANSPARENT);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
let mut a = PreviewAtlas::default();
let first: Vec<_> = (0..ATLAS_PAGES).map(|i| a.add(ui.ctx(), 1, &format!("p{i}"), img())).collect();
let mut a = MathAtlas::default();
let first: Vec<_> = (0..MATH_ATLAS_PAGES).map(|i| a.add(ui.ctx(), 1, &format!("p{i}"), img())).collect();
assert!(first.iter().all(Option::is_some), "a page each");
let ids: std::collections::HashSet<_> = first.iter().flatten().map(|(id, _)| *id).collect();
assert_eq!(ids.len(), ATLAS_PAGES);
assert_eq!(ids.len(), MATH_ATLAS_PAGES);
assert!(a.add(ui.ctx(), 1, "more", img()).is_none(), "every page was drawn from this frame");
assert_eq!(a.wipes, 0);
assert!(a.uv.len() == ATLAS_PAGES, "nothing forgotten");
assert!(a.uv.len() == MATH_ATLAS_PAGES, "nothing forgotten");
// Next frame: page 0 is drawn from again, so the one drawn longest ago is page 1.
assert_eq!(a.get(2, "p0"), first.first().copied().flatten());
let (id, _) = a.add(ui.ctx(), 2, "more", img()).expect("a page starts over");
@@ -347,70 +347,6 @@ fn ink_mask_keeps_the_ink_and_drops_the_paper() {
assert_eq!(img.pixels[2].a(), 127, "antialiased ink keeps its coverage");
}
/// #5105: on Windows, font menu entries showed on black strips in light mode and on white or
/// red ones in dark mode: each entry was a texture of its own, and browsing the list created
/// and freed hundreds of them, the DirectX 12 texture mix-up of #1002. Entries are now drawn
/// from a few shared pages written in place: scrolling through a long font list, at a high
/// display scale too, creates at most [`ATLAS_PAGES`] textures and frees none. Once fonts are
/// added or removed (from any window), every entry is drawn again.
#[test]
fn font_menu_entries_share_a_few_textures_and_free_none() {
for ppp in [1.0, 3.0] {
let ctx = egui::Context::default();
ctx.set_pixels_per_point(ppp);
let mut previews = Previews::default();
let preview = previews.font_preview_fn();
let names: Vec<String> = (0..300).map(|i| format!("Font {i}")).collect();
let (mut created, mut freed, mut ids) = (std::collections::HashSet::new(), 0, std::collections::HashSet::new());
// A menu shows about fifteen entries a frame; scroll it through the list twice.
for (frame, rows) in names.chunks(15).chain(names.chunks(15)).enumerate() {
let input = egui::RawInput { time: Some(frame as f64), ..Default::default() };
let mut out = ctx.run_ui(input, |ui| {
for name in rows {
preview(ui, name);
}
});
created.extend(
out.textures_delta
.set
.iter()
.filter(|(id, ds)| **id != egui::TextureId::default() && ds.iter().any(|d| d.pos.is_none()))
.map(|(id, _)| *id),
);
freed += out.textures_delta.free.len();
for s in &out.shapes {
if let egui::Shape::Mesh(m) = &s.shape
&& m.texture_id != egui::TextureId::default()
{
ids.insert(m.texture_id);
}
}
out.textures_delta.clear();
}
assert!(!created.is_empty() && created.len() <= ATLAS_PAGES, "{ppp}: {} textures created", created.len());
assert_eq!(freed, 0, "{ppp}: no texture is freed while the list is browsed");
assert!(ids.len() <= ATLAS_PAGES, "{ppp}: entries drawn from {} textures", ids.len());
// An entry is drawn from the atlas, not as plain text.
ctx.run_ui(egui::RawInput { time: Some(1000.0), ..Default::default() }, |ui| {
preview(ui, "Font 298");
preview(ui, "Font 299");
})
.drop_without_applying_deltas();
assert!(previews.fonts.lock().atlas.uv.contains_key(&font_key("Font 299", ppp)));
// Fonts were added or removed since the entries were drawn: the next entry drawn starts
// the atlas over, so none keeps a face it no longer has.
previews.fonts.lock().generation = u64::MAX;
ctx.run_ui(egui::RawInput { time: Some(1001.0), ..Default::default() }, |ui| {
preview(ui, "Font 298");
})
.drop_without_applying_deltas();
let fonts = previews.fonts.lock();
// Not compared with the database's generation: tests running alongside add and remove fonts.
assert_ne!(fonts.generation, u64::MAX, "the atlas took the database's generation");
assert!(fonts.atlas.uv.contains_key(&font_key("Font 298", ppp)) && !fonts.atlas.uv.contains_key(&font_key("Font 299", ppp)));
}
}
/// #535: font menu entries drew on a white strip in dark mode. They are now tinted with the
/// theme's text colour and draw no background of their own.
#[test]
-6
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 |
@@ -123,7 +120,6 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| DrawingML pictures inline and anchored (`wp:anchor`), wrap square/tight/through/top-bottom | ✅ | ✅ | Tight/through read and written with their `wp:wrapPolygon` (and `edited`); without one, a shape's outline is written (a picture's rectangle). `wrapText` (both sides, left, right, largest) read and written for square, tight and through |
| Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | |
| DrawingML pictures inline and anchored (`wp:anchor`), wrap square/tight/through/top-bottom | ✅ | ✅ | Tight/through read and written with their `wp:wrapPolygon` (and `edited`); without one, a shape's outline is written (a picture's rectangle). `wrapText` (both sides, left, right, largest) read and written for square, tight and through. DrawingML `layoutInCell="0"` is kept and written back; a VML shape in a header or footer table cell is always treated the same way (user-checked in Word for Mac, whatever `o:allowincell` says; saved as DrawingML `layoutInCell="0"`). In a header or footer table cell, such a picture placed relative to the page or margins is placed on the page, so a picture watermark in a header that starts with a table is centred behind the text instead of sitting in the cell (#1775) |
| Picture and shape hyperlinks (`wp:docPr/a:hlinkClick`, ECMA-376 Part 1 §20.4.2.5) | ✅ | ✅ | Read as the drawing's link (a `w:hyperlink` around the run wins); a linked picture, shape or chart is written with both the `w:hyperlink` around its run and the `a:hlinkClick` Word keeps on the drawing, one relationship for both; a link to a place in the document is the `w:hyperlink w:anchor` only (#5267). `picture_links.rs` |
| WordArt text effects (`w14:textFill`, `w14:textOutline`, `w14:shadow`, `w14:glow`, `w14:reflection` in `w:rPr`) | ✅ | ✅ | Solid, gradient (linear) and no fill, outline colour/width, shadow, glow, reflection (#416); `w14:scene3d`/`w14:props3d` dropped |
| Legacy font effects (`w:outline`, `w:shadow`, `w:emboss`, `w:imprint` in `w:rPr`) | ✅ | ✅ | Drawn on screen and in PDFs as Word's PDFs show them (#2534): hollow outlined letters; a sharp grey copy down-right (shadow, emboss) or up-left (imprint); WordArt effects on the same run win; tracked insertions and deletions keep them. Not drawn on colour emoji, or on top-to-bottom text with upright East Asian characters. Emboss and imprint are approximations (no highlight edge) |
| OpenType ligatures (`w14:ligatures` in `w:rPr`: runs, styles, document defaults) | ✅ | ✅ | All 16 values; an unknown value is ignored; nothing is written when the document has none |
@@ -341,8 +337,6 @@ For field results, the engine parser treats a backslash in quoted arguments as e
| 2026-11-03 | trivial | Quoted field arguments honor Word backslash escapes, including literal asterisks |
| 2026-11-04 | trivial | CLI input paths keep their native filename when a value-taking option comes first; regression coverage includes conversion to Flat OPC and rejection of non-Unicode option values. |
| 2026-11-05 | trivial | `.dotx`/`.dotm`/`.dot`: a template the platform hands the app starts a new, untitled document attached to it, as Word's default verb does; File › Open still edits the template (#3433); fidelity estimate unchanged |
| 2026-11-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. |
+1 -10
View File
@@ -18,8 +18,6 @@ Group connectors (gap 10): re-routing preserves displayed rotated and flipped fr
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
Table Sort rejects invalid explicit column selectors instead of silently sorting another column (#5531). This command-boundary correction leaves the ranked gaps and readiness estimates unchanged.
The work list. One entry per shortfall, ranked by how much it stops a Word user from switching
(**B** = blocks beta). Each says what's missing, the evidence, who it hurts, an Opus 5.5 agent
wall-clock estimate, and which parity document tracks it. Agents: pick from the top, check open PRs
@@ -96,7 +94,7 @@ Page drawing indexes text-box warp lookups by paragraph and story for the curren
| 13 | **Charts and SmartArt editing** (editing charts and SmartArt from Word, more chart types and formatting) | | `insert.chart` and Chart Design basics landed (#409): WordCraft's own charts are editable; charts from Word stay read-only (no embedded-workbook editing), radar charts are drawn (#1890); no combo/3D/stock charts (charts of those types show a placeholder with their title and type, #998), axis or series formatting; Insert › SmartArt landed (#497) for graphics WordCraft makes | Reports and business documents | 35–55 | [target-app-parity.md](target-app-parity.md) |
| 14 | **`.doc` can't be written; Word 6/95 files refused** | | `crates/docbin` is read-only | Users exchanging files with old Word or legacy systems | 25–40 | [file-format-parity.md](file-format-parity.md) |
| 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** (letters join across a change of formatting since #4093): 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); mirrored Arabic UI landed (#1195); PDF export's Hebrew and Arabic text copies and searches as whole words (#1383) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 5–9 | [layout-parity.md](layout-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); mirrored Arabic UI landed (#1195); PDF export's Hebrew and Arabic text copies and searches as whole words (#1383) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 5–9 | [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; ODT formatting revisions done; RTF section/table revisions left), sections (RTF and ODT import and export done, #1378, #1379), RTL. ODT is read and written nearly whole (~90%, see [file-format-parity.md](file-format-parity.md)): sections, headers and footers, page setup, styles (named paragraph and character styles), character and paragraph formatting, lists and heading numbering, tab stops, tables (borders, padding, alignment, width, row heights, header rows), notes, comments, tracked changes, fields (dates, sequences, chapters, references, index marks), bookmarks, tables of contents and indexes, pictures, text boxes, shapes, groups, rotation, strokes and gradients, equations, charts, embedded objects kept whole, form controls, ruby, drop caps, password protection, flat `.fodt`; checked against LibreOffice by round trips compared page by page and the ODF 1.3 schema. Left in ODT: turned characters (no Word equivalent), conditional text as fields (variables done, as SET/REF), control looks, drawing embedded spreadsheets' contents, dates in locales other than US English (`number:automatic-order`). Emphasis marks, fixed-width tables, tables starting a page, page background pictures, the default font size and styles turning things off are done. Checked against LibreOffice's 78 bundled templates as well as synthetic documents: all open and round-trip; placeholders, first-page styles, unsized pages, WMF/EMF logos and `.ott` templates done; what ODF leaves to the application is WordCraft's one choice for each case ([odf-interpretation.md](odf-interpretation.md)); WordCraft's own documents (templates, themes, every feature) saved as ODT open in LibreOffice with the same pages, valid against the ODF 1.3 schema, with table styles, SmartArt, watermarks and cell pictures kept; RTF export writes every section (`\sect`, `\sectd`); RTF reads and writes WMF and EMF pictures (#4259); an ODT saved without edits keeps what WordCraft doesn't model: unedited paragraphs, tables, named sections, indexes, forms, style definitions, page layouts and document properties are written back as read, checked by a no-edit loss test and a loss check over the corpus (left: paragraphs showing form controls are written from the model; every schema element audited in [odt-element-audit.md](odt-element-audit.md)) | | ~2,100 (RTF) and ~11,000 (ODT, plus ~7,000 of tests) lines in `crates/formats`; RTF code pages (#896) and page setup (#902) landed; open PRs: RTF notes and comments (#1056) | Users exchanging with LibreOffice or older tools | 10–16 | [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) |
@@ -118,8 +116,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.
@@ -176,8 +172,6 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text
> 2026-11-03 · Section `w:chapStyle` values now preserve the complete unsigned-byte range, including 10; values above 255 are rejected.
> 2026-11-05 · Gap 16: Arabic letters join across a change of formatting and past hidden or deleted text (#4093); readiness unchanged.
>
> 2026-11-03 · Gap 11: the Arabic dialogs, tables and galleries read right to left (rtl::Table); readiness unchanged.
>
> 2026-10-27 · Technical synchronization: existing Proofing pane hooks and regression tests follow the extracted engine/UI modules on main `436d444`; compact context menus remain upstream. New-SHA validation is pending; readiness unchanged.
@@ -196,7 +190,6 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text
| 2026-11-03 | trivial | Field argument parsing follows Word quoted backslash escapes; body PAGE formatting follows the active section in paragraphs and tables; no ranked gap changed |
| 2026-11-07 | trivial | Pagination gap #4: An in-line picture lowered past its own height (`w:position`) lowers its line as far, as in Word; all 156 synthetic lines measured for #3984 match Word (#5796). |
| 2026-10-31 | trivial | A list level with numFmt none shows its literal text as its label and still counts, as in Word (#4985); no ranked gap changed; readiness estimates unchanged |
| 2026-11-02 | trivial | Picture hyperlinks show on hover, follow on Ctrl/⌘+click and round-trip as Word stores them (#5267); no ranked gap changed; readiness estimates unchanged |
| 2026-11-02 | trivial | #26 narrowed: MHT opens and `w:altChunk` content is brought in on open; saving MHT remains |
| 2026-11-01 | trivial | Preserve table rows and cells inside selected alternate-content branches (#5219); readiness estimates unchanged. |
| 2026-11-06 | trivial | Print helpers remove loaded PDF/settings files before driver work, tolerate prior removal and report cleanup errors; a held-driver regression checks the files are already absent (#5710) |
@@ -221,10 +214,8 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text
| 2026-11-01 | trivial | #10 narrowed: text in a turned text box is drawn turned with it on screen and in PNG and PDF (upside down when flipped across, never mirrored); editing it still goes by its upright text; estimate unchanged |
| 2026-10-29 | trivial | Print Markup balloon anchor discovery no longer repeats paragraph-prefix scans (#3614); readiness estimates unchanged |
| 2026-10-28 | trivial | Print Markup text-copy comment discovery walks paragraph objects once (#3614); quotes, order, limits and readiness estimates unchanged |
| 2026-11-04 | trivial | Table Sort rejects malformed or nonexistent explicit columns before mutation; valid columns, the omitted-column default and history are retained (#5531). Readiness estimates unchanged. |
| 2026-10-30 | trivial | #20 narrowed: layout resource caps and linear-time passes (#3867, #3866) |
| 2026-10-17 | trivial | UI/UX gaps: copies put RTF on the clipboard (Windows, macOS, Wayland; not X11), and paste reads RTF on macOS and X11 too |
| 2026-11-03 | 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 |
-3
View File
@@ -30,7 +30,6 @@ Exact line spacing keeps the run font baseline around tall equations; automatic
| Justification, Word 2013+ space shrinking (compat mode 15) | ✅ | #109; spaces before a line's first character (paragraph indented with spaces, after a line break or a tab, a bookmark, hidden text or a floating object's anchor before them) keep their width in justified and distributed lines, `layout::tests::leading_spaces_keep_their_width_in_justified_lines` | — |
| Spaces at the end of a line hang past the right margin instead of wrapping; the caret after them stays at the margin, in the body and in headers and footers | ✅ | `layout::tests::caret_after_hanging_spaces_stays_at_the_margin` (#2146): a letter typed after them wraps to the next line | — |
| Complex scripts (Myanmar, Khmer, Indic): shaped per script run with OpenType fallback fonts, one cluster per syllable, Myanmar wraps between syllables | 🟡 | `layout::tests::complex` (#604). Khmer, Thai and Lao still wrap only at spaces (need a dictionary) | 4–8 |
| Cursive joining (Arabic, Syriac, Mongolian, N'Ko…) across runs: a letter takes its form from the text shown beside it, across a change of formatting, past a vowel sign in a run of its own and past hidden or tracked-deleted text left out of the view; a tab, break, object or drop cap still stops the join | ✅ | `layout::tests::bidi::letters_join_across_a_change_of_formatting`, `letters_join_past_text_left_out_of_the_layout`, `letters_join_past_a_mark_in_a_run_of_its_own`, `letters_stay_apart_where_nothing_joins_them` (#4093); not compared with Word on this machine | 0 |
| Kashida justification (Arabic) | ❌ | no kashida in `crates/layout` | 3–5 |
| Missing Thai document fonts (TH Sarabun New, Angsana New, Cordia New, Browallia New, the UPC families, Leelawadee): one Thai substitute face for the whole run, Latin letters, digits and ฿ included, so the run keeps one font at one size as Word's Cordia New substitution does | ✅ | `fonts::word::tests::a_missing_thai_font_resolves_to_an_installed_thai_face`, `fonts::fontdb::tests::a_thai_run_with_latin_falls_back_to_one_face`, `layout::tests::complex::a_missing_thai_font_draws_the_whole_run_in_one_thai_face`, `layout::tests::complex::a_thai_substitute_without_latin_hands_the_run_to_one_face_covering_it`, `fonts::fontdb::tests::sysfonts::a_run_no_preferred_face_covers_whole_loads_an_installed_font_that_does` (#1549). Windows: Cordia New, then Leelawadee UI, Tahoma, Noto Sans Thai; macOS: Thonburi (Sathu for the serif families); Linux: Noto Sans Thai, then the TLWG fonts. The first listed face with Latin letters and digits as well as Thai wins. Other Thai names (`TH …`, `…UPC`, names with `Thai`) use the same lists. When no listed face is installed, or the one found lacks the run's Thai or Latin letters, the run moves to one installed face covering its Thai and Latin text if there is one, even when a preferred Thai family without Latin letters (Noto Sans Thai) is installed; that face's whole family is loaded, so regular text keeps the Regular face. Only Thai fonts: a missing Latin font keeps Latin text in its own substitute, and a run with its own complex-script font keeps it for the Thai letters, as Word does | — |
| Distributed / Thai distributed alignment | 🟡 | read; layout untested | 1–2 |
@@ -279,8 +278,6 @@ The front-of-text opt-out is retained from the earlier implementation; this patc
## Revision history
> 2026-11-05 · Arabic letters join across a change of formatting and past hidden or deleted text (#4093): each run is shaped with the text shown on either side of it.
> 2026-11-01 · Text in a turned text box is drawn turned with it, about its centre, on screen and in PNG and PDF export; flipped across, upside down; flipped side to side, still readable (as Word draws it). Clicks and the caret inside a turned box still go by its upright text (`layout::tests::a_turned_text_box_turns_its_text_with_it`).
> 2026-11-01 · An in-line picture, shape or chart is raised or lowered by its run's position (Word's `w:position`, Font › Advanced › Position): lowered, it reaches below the baseline and the line grows down to hold it (at most until its top is on the baseline); raised, it stands clear of the baseline. It used to stand on the baseline whatever the position (`layout::tests::an_inline_picture_moves_with_its_runs_position`).
+1 -2
View File
@@ -62,7 +62,7 @@ shared items below.
| Simplified Chinese (Mandarin) | zh-hans | 929 entries; 98% of extracted, ~95% of visible | most; newer panes English | CJK ✅, IME ✅; Windows UI font fixed (#248); interface font chain covers every catalog character, installed CJK font after the embedded ones when it doesn't, PingFang found on macOS 12 (#486); font list by Chinese names (宋体, 微软雅黑, #505); word count counts each Han ideograph and kana as a word, not punctuation (#4547); no vertical text | no | partial | 3–5 |
| Spanish | es | ~930; 98% / ~95% | most | Latin ✅ | no | partial | 1–2 |
| Hindi | hi | 0 | ❌ | Devanagari shaping via HarfRust, untested; no hyphenation | no | none | 4–6 |
| Arabic | ar | ~2,730 entries (craft-i18n `locales/ar/wordcraft.tsv`); 98% / ~95% | most; newer panes English | shaping, joining, lam-alef, bidi ✅ (#207); letters join across a change of formatting and past hidden or deleted text ✅ (#4093); mirrored UI ✅ (#1195; dialogs, tables and galleries read right to left); Arabic UI font fallback; no kashida; no proofing | no | partial | 2–4 (review + kashida/proofing elsewhere) |
| Arabic | ar | ~2,730 entries (craft-i18n `locales/ar/wordcraft.tsv`); 98% / ~95% | most; newer panes English | shaping, joining, lam-alef, bidi ✅ (#207); mirrored UI ✅ (#1195; dialogs, tables and galleries read right to left); Arabic UI font fallback; no kashida; no proofing | no | partial | 2–4 (review + kashida/proofing elsewhere) |
| French | fr | 2,615 entries (complete catalog) | all | Latin ✅ | no | partial (spelling and suggestions from the French Language pack, elided forms first: `Jadore` → `J'adore`; no grammar marks) | 1–2 (grammar) |
| Portuguese | pt-br (pt-PT none) | ~930; 98% / ~95% (pt-BR) | most | Latin ✅ | no | partial | 2–4 (pt-BR finish + pt-PT) |
| Indonesian | id | 0 | ❌ | Latin ✅ | no | none | 3–4 |
@@ -276,7 +276,6 @@ WordCraft keeps 2,54 cm).
| Date | Change | Summary |
|---|---|---|
| 2026-11-06 | trivial | Arabic: the Home tab's Styles gallery reads from the right: Normal is the rightmost tile and the scroll arrows and More sit at the gallery's left end, as the rest of the mirrored ribbon (#5566); left to right is unchanged; regression test `the_styles_gallery_reads_from_the_right_in_a_right_to_left_interface` |
| 2026-11-05 | trivial | Arabic letters join across a change of formatting and past hidden or deleted text (#4093) |
| 2026-11-04 | trivial | Table sort and formulas read the Panamanian balboa's `B/.` around a number (`B/. 1,250.50`, `1,250.50 B/.`), as Word does; `B/.1,250.50` and `B/ 1,250.50` stay text, matching Word 16.0, and the sign is matched only at the ends of the text, so `1/.5` is unchanged |
| 2026-11-04 | trivial | craft-i18n.rev at 9d6d54d (craft-i18n#145): 69 region names and the `{language} ({region})` template (full-width in ja, ko, zh) in 35 locales, AI-assisted without native-speaker review |
| 2026-11-04 | trivial | craft-i18n.rev at cde4900 (craft-i18n#136): translated rows for Options › Language (Your languages, Add a language…, Set as default, Dictionary files), the missing-pack bar and 28 Language pack language names in 35 locales, AI-assisted without native-speaker review |
-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). |
-5
View File
@@ -77,8 +77,6 @@ Save Changes exposes the document-specific question as an alert-dialog name and
The Language dialog reads effective character formatting, including document defaults, paragraph styles and character styles. Accepting it unchanged keeps inherited language and no-proof choices (#2717); explicit direct formatting still takes precedence. Other proofing and Windows-specific reports remain separate.
The `table.sort` command requires an explicit `column` to be a non-negative JSON integer naming a column in the table, indexed from 0 (#5531). Omitting it still sorts by column 0. Invalid selectors return an error before changing rows or history; the Sort dialog and collation rules are unchanged.
## Find and Replace: regular expressions (#4177)
With **Regular expressions** on (Search Options), Replace with expands capture groups: `$1`…`$n`, `${n}`, `${name}` and `$name` (the longest run of letters, digits and `_`), `$0` / `${0}` (the whole match), and `\1`…`\9` as in wildcard Replace; `$$` is a dollar sign, `\t` a tab, `\\` a backslash, `\$` and `\^` a `$` or `^` as typed, and `\n` a paragraph mark, which splits the paragraph as `^p` does (Replace All stays one undo step). Word's `^&`, `^p`, `^t`, `^c` and the other `^` codes work there too (with Special characters off, only the `^` codes are taken literally). A group that doesn't exist, didn't take part in the match or is out of range expands to nothing; a `$` that starts no group, an unclosed `${`, and other `\` escapes stay as written. Plain and wildcard Replace are unchanged. Word has no regular-expression mode; this follows the Rust `regex` crate's Replace syntax.
@@ -113,14 +111,11 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text
| 2026-11-05 | trivial | Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. |
| 2026-10-28 | trivial | Home › Paragraph › Shading: text selected within part of one paragraph is shaded itself, as in Word, so No Color removes text shading imported from a file (#4685) |
| 2026-11-03 | trivial | Search ("Tell me"): a click on the title bar's Search box, or Alt+Q, types into that box itself, as in Word, with the suggestions and results (commands, then Find in the document) dropping down under it; Enter runs the first, Escape or a click elsewhere closes it. It used to open a second box in a Search Commands dialog, which is still what shows where the box isn't drawn (#5398) |
| 2026-10-31 | trivial | Font menus (ribbon, mini toolbar, Font and other dialogs): entries are drawn from at most four shared textures written in place, as the Equation tab's pictures are (#1002), instead of a texture per font; on Windows (DirectX 12) they showed on black strips in light mode and white or red ones in dark mode (#5105) |
| 2026-11-02 | trivial | A picture, shape or text box with a link shows its address on hover and Ctrl/⌘+click follows it, in line or floating, instead of starting a copy (#5267) |
| 2026-10-17 | trivial | Drawing Canvas members are selected with a click and dragged, resized, turned, nudged, formatted, deleted and copied on their own inside the canvas; a click in a text box member's text puts the caret there (#2637) |
| 2026-11-05 | trivial | On-canvas objects: dragging an In Line with Text picture, shape or text box shows a bold, solid insertion cursor, twice the caret's width, as in Word; the dashed one was hard to see (#4195) |
| 2026-10-28 | trivial | Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged. |
| 2026-10-31 | trivial | Dialogs are modal (#3859): while one is open, clicks on the ribbon, File or the page, typing and shortcuts do nothing, as in Word. Find and Replace, Symbol, Mark Index Entry, Cross-reference and Insert Merge Field stay modeless, as in Word for Mac. A press beside a modal dialog shakes it a little, or flashes its border with animation effects off, so it is clear why nothing behind it reacts |
| 2026-10-17 | trivial | Copy and Cut put RTF beside the text and HTML on the system clipboard, for programs that read RTF rather than HTML (Windows "Rich Text Format", macOS `public.rtf`, Wayland `text/rtf`; an X11 copy stays text and HTML, since arboard owns the selection there; a copy of only pictures or shapes carries no HTML or RTF, so pasting it back keeps every detail); Paste reads another program's RTF on macOS and X11 too, as on Windows and Wayland |
| 2026-11-04 | trivial | Table Sort rejects malformed or nonexistent explicit columns before mutation; valid columns, the omitted-column default and history are retained (#5531). Readiness estimates unchanged. |
| 2026-11-03 | trivial | Comments: a long comment's card, balloon and fields stop at ten lines; the fields scroll, and a balloon that isn't selected shows the first ten lines of the comment and of each reply (#3382) |
| 2026-11-03 | trivial | Over a turned picture, shape or text box, the pointer on a resize handle shows the arrow along which that handle resizes: turned 90°, the handle now at the top shows an up-down arrow (it showed left-right), turned 45° the middle handles show diagonal ones; also while the handle is dragged (#3932) |
| 2026-11-03 | trivial | Shape Format › Size: the width and height show the interface unit and take a size typed in any unit (`3 cm`, `300 px`), applied once typed (Enter, Tab or a click elsewhere; Esc cancels), each side on its own, as Picture Format › Size does; in both, a field clicked and left without typing no longer resizes the object by the rounding of the size it shows (#4196) |