Compare commits

..
Author SHA1 Message Date
Martin Ball a98d0a71fe Merge remote-tracking branch 'origin/main' into HEAD
; Conflicts:
;	docs/layout-parity.md
2026-11-08 02:59:46 +00:00
0f5a701171 #5469: Size keeps the side it isn't asked to change (#5474)
* #5469: Size keeps the side it isn't asked to change

Typing only the width of a straight line (height 0) gave it a 1 pt
height: picture.size fitted both sides into 1-4000 pt, also the one the
call never set. A side the call leaves as it was now keeps its size,
a line's 0 and a side past 4000 pt from a file too.

Sides set in proportion (the original size, or a scale or one side
typed with the proportions locked while both sides have a length) still
fit the limits together, even when one of them comes out as it was;
any other side the call sets fits on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #5469: the table test checks exact sizes

Where both sides follow each other the test only checked that they
stay within 1-4000 pt and keep their proportions. The rule gives one
size there (the one factor nearest 1 that fits both), so each case now
checks that size, worked out by hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 03:57:17 +01:00
Valentin KleinandClaude Sonnet 5.5 677e610886 Tests: the file name field updates on Save As and Rename (#3796) (#5308)
Pin that FILENAME and FILENAME \p in a header show the new name and path after Save As, that a Word-style FILENAME \* MERGEFORMAT field drops its cached name, and that Rename is picked up by Update Field. No production code changes.

Refs #3796

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 03:55:02 +01:00
d2a2709e14 Pictures: a linked picture shows its link on hover and follows it on Ctrl+click; DOCX keeps the link on the drawing (#5267) (#5295)
* #5267: a linked picture shows and follows its link; DOCX keeps it on the drawing

A link put on a picture from its context menu was stored, but nothing showed
it: hovering the picture gave no tip, and Ctrl+click picked the picture for a
copy instead of following the link. Now the hover tip and Ctrl/Cmd+click work
on a linked picture, shape or text box, in line or floating, as on linked text.

DOCX: Word keeps a picture's hyperlink on the drawing (wp:docPr/a:hlinkClick,
ECMA-376 Part 1 20.4.2.5). It is now read (a w:hyperlink around the run wins)
and written beside the w:hyperlink, sharing one relationship. A link to a place
in the document stays the w:hyperlink w:anchor only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #5267: ink and Office 2016 charts take their drawing's link; Ctrl+click over text on a Behind Text picture stays a text click

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #5267: rustfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #5267: a chart or ink takes the link of the choice it was read from; Ctrl/⌘+click in the docs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #5267: the decoy choice in the chart-link fixture needs a namespace no reader knows

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #5267: inside a linked text box, hover and Ctrl/⌘+click take the box's link

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #5267: the picture-link test sets the image's fx after merging main

Main gave InlineObject::Image picture effects (fx); the round-trip test this PR adds built
an image without them and no longer compiled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 03:50:38 +01:00
050b6b0da3 Arabic letters join across a change of formatting (#4093) (#5691)
* Arabic letters join across a change of formatting (#4093)

Each run of a paragraph was shaped on its own, so a letter at a run's edge
took the form of a word's start or end. A coloured letter stood apart from
its word, and a word edited with Track Changes on read with a space in it
(ال ذكاء).

Runs are now shaped with the text shown on either side of them, through the
shaper's pre- and post-context (fonts::ShapeContext). Runs left out of the
layout (hidden text, deletions in a final view) are skipped, so letters join
what is on the page. Only paragraphs with a joining script do the work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvG6E2QwHfTULQtWYgVEXk

* #4093 review: no letter joins a drop cap formatted on its own

A drop cap letter in a run of its own (coloured, say) was still passed to
the next run as the text before it, so that run's first letter joined the
drop cap, which is drawn apart from the word. The shown text now has a floor
past the drop cap, and no run takes context from before it. The drop cap
test also covers the letter in a run of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvG6E2QwHfTULQtWYgVEXk

---------

Co-authored-by: Filip Strajnar <filip.strajnar@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 03:44:01 +01:00
Dylan M. TaylorandMartin Ball 6b163b33dc xtask: no symlink walks, exclusive test dir; Paste Special picture files must be regular files (#5471)
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 03:39:35 +01:00
ReesandMartin Ball 4ba444a342 Table Sort: validate explicit columns (#5531) (#5533)
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 03:39:17 +01:00
4821a13ee0 Font menus: entries are drawn from a few shared textures, so Windows no longer shows them on black, white or red strips (#5105) (#5111)
* #5105: font menu entries are drawn from a few shared textures

Each font menu entry was a texture of its own, created and freed as the list
was browsed: on Windows (DirectX 12) the entries showed other textures' memory,
black strips in light mode, white or red ones in dark mode, as the Equation
tab's pictures did before #1002. They now share an atlas written in place, the
one the equation pictures use (MathAtlas, now PreviewAtlas).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #5105: font menu entries are drawn again in every window once fonts change

The font atlas remembers the font database generation its entries were drawn
with and starts over when it changes, so another window's entries and a removed
font's entry (Recently Used Fonts) don't keep an old face.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #5105: the atlas test doesn't compare with the shared font generation

Review: tests running alongside add and remove fonts, so the database's
generation may change between the draw and the assertion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 03:36:54 +01:00
Dylan M. Taylor b9d0ebbd26 Merge main (keep both layout-parity lines) 2026-11-04 04:06:28 +00:00
Dylan M. Taylor 1825b9250f Merge branch 'main' into fix/sec-decimal-tab-scan 2026-11-03 17:35:55 -05:00
Dylan M. Taylor 49cabcafc7 address review: layout progress doc for decimal-tab scan 2026-11-03 14:26:57 +00:00
Dylan M. Taylor 4ee514b103 Layout: look at the text after a decimal tab once, not once per cluster
Text after a decimal tab is shifted after each of its clusters, and each
shift walked the text again from the tab to find its first '.'. A long run
without a '.' (soft hyphens take no room, so it stays on one line) made a
single paragraph's line breaking quadratic. The scan now carries on from the
cluster it reached.
2026-11-02 22:23:48 -05:00
35 changed files with 1213 additions and 155 deletions
+8
View File
@@ -157,6 +157,8 @@ 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.
@@ -170,6 +172,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
- 2026-11-04: RTF import skips the declared Unicode fallback count even when it exceeds eight fallback units, so longer ANSI alternatives do not leak into the document text. Skipping stays bounded by the available input and ends at a group boundary. Readiness estimates unchanged.
- 2026-11-05: Linux: a WordCraft started by an AppImage app no longer takes that app's file for its own (#5653). The AppImage runtime's `APPIMAGE`, `APPDIR`, `ARGV0` and `OWD` pass to whatever the app starts, so an installed WordCraft opened through `xdg-open` saw another program's `APPIMAGE`, and its self-update replaced that program's file. The app now starts over without those variables unless its executable is under `APPDIR`, so the update goes through the package or plain file it really is. Should starting over fail, the self-update is off in that window and Help › Check for Updates says why. Readiness estimates unchanged.
- 2026-11-05: A Word template (`.dotx`, `.dotm`, `.dot`) opened from the file manager, the command line or the Finder starts a new, untitled document based on it, as Word's default verb for a template does (#3433). It opened the template itself, so Save (and AutoSave) wrote over it. The new document is attached to the template and Save asks where to write; a protected template asks for its password and still starts a new document. File › Open and a file dropped on the window still open a template itself, to edit it.
- 2026-11-05: Arabic letters join across a change of formatting (gap 16, #4093). Each run was shaped on its own, so a letter at a run's edge took the form of a word's start or end: a coloured letter stood apart, and a word edited with Track Changes on read with a space in it (ال ذكاء). Each run is now shaped knowing the text shown on either side, past hidden or deleted text left out of the view, as for Syriac, Mongolian and the other joining scripts. Readiness estimates unchanged.
- 2026-10-28: Ink replay, eraser picking, lasso selection and arrow-candidate discovery reuse the paragraph prefixes they have already scanned (#3614). Page item order, stroke geometry and saved data retain their behavior. This narrows the stability work; ink import fidelity, native pen input and broader corpus qualification remain open.
- 2026-10-29: Page drawing finds text-box warps without rescanning the paragraph for every box (#3614). Only requested offsets are indexed, and each paragraph walk stops after its last requested character. Original Linux discovery, complete display-output and CLI/MCP save/reopen controls; native Word comparison untested. Readiness estimates unchanged.
- 2026-10-28: Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged. Existing mutation and protection checks retain their costs; this does not close the broader performance gap.
@@ -219,12 +222,14 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
- 2026-10-31: ODT: paragraph spacing, indents and line spacing are read and written, and an ODT's own default spacing (none, single lines) replaces Word's 8 pt after and 1.15 lines, so LibreOffice documents keep their vertical rhythm on open and save (gap 19). Readiness unchanged.
- 2026-11-01: PDF export, printing and page images show the body's SEQ, STYLEREF and REF fields with their current results, as Word fills them in when it prints or exports even with Update fields before printing off: a caption numbered 7 when it is the first shows 1 on paper, and a REF to a bookmark the document lacks prints Word's "Error! Reference source not found.". NUMWORDS, FILENAME, the document properties, formulas and locked fields keep their stored results there, as in Word; the editor and the saved document keep every stored result.
- 2026-10-28: Print Markup HTML, Markdown and text copies discover comment anchors in one paragraph walk, so unrelated bookmark markers no longer cause repeated prefix scans (#3614). Existing quotes, replies, order and export limits are unchanged; original synthetic semantic and scaling regressions cover the traversal.
- 2026-11-04: Table Sort refuses malformed and nonexistent explicit columns before reordering rows (#5531). Omitted column 0, valid columns, header/order/language behavior and Undo/Redo are retained. Original Linux CLI/MCP controls and repeated native/DOCX reopens cover the command; native Word and desktop comparison remain untested. Readiness estimates unchanged.
- 2026-10-30: Layout resource caps (#3867, #4385) and linear-time passes (#3866, #4392): repeated table-header rows are capped by item counts, a clipped group counting every nested member, the wrap cache by decoded pixels and the widths a linked text box chain lays out by their lines and items; PDF export, hit testing, tracked-change and clipped-table costs, and long endnote selections no longer grow quadratically. Synthetic regression tests only; no new Word comparison.
- 2026-11-03: Proofing (CP4.3): craft-proof moves to craft-libs main with #589 and #590. French spelling suggestions offer the elided form first (`Jadore` → `J'adore`, before `J adore`), and Valencian text (`ca-ES-valencia`) is proofed with the `ca-valencia` pack instead of plain Catalan. Readiness unchanged.
- 2026-11-03: A long comment no longer grows its card or balloon without limit (#3382). Its field in the Comments pane and in a selected balloon stops at ten lines and scrolls, keeping the caret in view; a balloon that isn't selected shows the first ten lines of the comment and of each reply, and clicking it opens the scrolling field. The buttons and the next comment stay in view.
- 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: 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.
@@ -823,6 +828,7 @@ MATH glyphs that reference the same construction now share its immutable variant
> 2026-11-05 · Linux self-update ignores another program's AppImage variables (#5653); recorded in the progress log, readiness unchanged.
> 2026-11-05 · A template opened from the file manager starts a new document instead of being edited and saved over (#3433); recorded in the progress log, readiness unchanged.
> 2026-11-05 · Arabic letters join across a change of formatting and past hidden or deleted text (#4093); recorded in the progress log, readiness unchanged.
>
> 2026-11-03 · Arabic dialogs read right to left (rtl::Table); recorded in the progress log, readiness unchanged.
>
@@ -842,6 +848,8 @@ 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) |
+32 -5
View File
@@ -118,9 +118,33 @@ 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.
pub(super) fn read_ink_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
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) {
@@ -149,10 +173,8 @@ 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.
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))?;
fn read_chartex_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
let choice = chartex_choice(ac)?;
// 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 == "?") {
@@ -563,6 +585,11 @@ 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]).
+13 -5
View File
@@ -98,9 +98,9 @@ impl Reader<'_> {
}
}
"mc:AlternateContent" => {
if let Some(o) = self.read_ink_alternate(sc, k, rels).or_else(|| self.read_chartex_alternate(sc, k, rels)) {
if let Some((o, link)) = self.read_alternate(sc, k, rels) {
let props = self.run_props_none(ctx);
self.emit_obj(sc, pb, o, &props);
self.emit_linked_obj(sc, pb, o, &props, link);
} 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_obj(sc, pb, o, props);
self.emit_linked_obj(sc, pb, o, props, Self::drawing_link(k, rels));
}
}
"w:pict" => {
@@ -298,8 +298,8 @@ impl Reader<'_> {
),
"w:ruby" => self.read_ruby(sc, pb, k, props),
"mc:AlternateContent" => {
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);
if let Some((o, link)) = self.read_alternate(sc, k, rels) {
self.emit_linked_obj(sc, pb, o, props, link);
} 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,6 +376,14 @@ 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) => {
+28 -12
View File
@@ -10,9 +10,19 @@ 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 };
@@ -20,7 +30,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);
nv_props(w, "pic:cNvPr", ("0", file), alt, &float.alt, float.hidden, None);
w.open("pic:cNvPicPr", &[]);
w.empty("a:picLocks", &[("noChangeAspect", "1"), ("noChangeArrowheads", "1")]);
w.close("pic:cNvPicPr");
@@ -66,7 +76,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);
nv_props(w, "wps:cNvPr", (id, &name), &float.alt.text, &float.alt, float.hidden, None);
}
if kind.is_connector() {
// A connector's glued ends (ECMA-376 §20.1.2.2.8 `cNvCxnSpPr`).
@@ -235,7 +245,7 @@ impl Writer<'_> {
}
pub(super) fn drawing_open(&mut self, w: &mut W, info: DrawingInfo<'_>) {
let DrawingInfo { float, size: (iw, ih), docpr, name, alt } = info;
let DrawingInfo { float, size: (iw, ih), docpr, name, alt, link } = 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")]);
@@ -316,7 +326,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);
nv_props(w, "wp:docPr", (docpr, name), alt, &float.alt, float.hidden, link);
}
}
@@ -502,8 +512,9 @@ 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.
pub(in super::super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, descr: &str, alt: &AltText, hidden: bool) {
/// 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>) {
let mut attrs = vec![("id", id), ("name", name)];
if descr != alt.title || descr.is_empty() {
attrs.push(("descr", descr));
@@ -514,16 +525,21 @@ pub(in super::super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, des
if hidden {
attrs.push(("hidden", "1"));
}
if !alt.decorative {
if !alt.decorative && link.is_none() {
w.empty(tag, &attrs);
return;
}
w.open(tag, &attrs);
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");
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.close(tag);
}
+17 -5
View File
@@ -220,13 +220,18 @@ 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 }, native.unwrap_or(&root));
self.graphic_frame(
w,
DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt, link: link.as_deref() },
native.unwrap_or(&root),
);
if let Some(fallback) = fallback {
w.close("mc:Choice");
match picture {
@@ -234,7 +239,11 @@ 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 }, picture);
self.graphic_frame(
w,
DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt, link: link.as_deref() },
picture,
);
w.close("mc:Fallback");
}
None => w.raw(&fallback.to_xml()),
@@ -266,8 +275,9 @@ 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 });
self.drawing_open(w, DrawingInfo { float, size: (*iw, *ih), docpr: &docpr, name: &name, alt, link: link.as_deref() });
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
w.open("a:graphicData", &[("uri", "http://schemas.openxmlformats.org/drawingml/2006/picture")]);
@@ -355,6 +365,7 @@ 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.
@@ -369,7 +380,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() });
self.drawing_open(w, DrawingInfo { float, size: (*sw, *sh), docpr: &docpr, name: &name, alt: o.alt_text(), link: link.as_deref() });
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape")]);
@@ -389,8 +400,9 @@ 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() });
self.drawing_open(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt: o.alt_text(), link: link.as_deref() });
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
let outer = match canvas {
+37 -3
View File
@@ -1766,12 +1766,27 @@ 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> {
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/>"#;
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 body = format!(
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>"#
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>"#
);
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"/></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"/><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 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>"#
@@ -1827,6 +1842,25 @@ 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();
+1
View File
@@ -23,6 +23,7 @@ mod ligatures;
mod malformed;
mod note_comments;
mod passthrough;
mod picture_links;
mod positional_tabs;
mod privacy;
mod roundtrip;
+127
View File
@@ -0,0 +1,127 @@
//! 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()));
}
+14 -3
View File
@@ -76,9 +76,11 @@ 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 (mut w, mut h) = obj_size(&o);
let (w0, h0) = obj_size(&o);
let (mut w, mut h) = (w0, h0);
let scale = p::f32(v, "scale");
if p::bool(v, "original").unwrap_or(false) {
let original = p::bool(v, "original").unwrap_or(false);
if original {
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;
@@ -113,7 +115,16 @@ pub(super) fn size(s: &mut Session, v: &Value) -> CmdResult {
(None, None) => true,
_ => lock,
};
let (w, h) = fit_size(w, h, keep);
// 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 })
};
// 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, a side past 4000 pt shrinks the other with it)"#,
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)"#,
)
.when(has_sizable),
CommandSpec::new("picture.crop", "Crop", "Picture Format › Size", crop)
+60
View File
@@ -1034,6 +1034,66 @@ 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,6 +549,72 @@ 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]
@@ -561,6 +627,7 @@ 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"?: n, "descending"?: bool, "header"?: bool}"#)),
.params(r#"{"column"?: non-negative JSON integer (0-based, default 0; must name a column in the table), "descending"?: bool, "header"?: bool}"#)),
t(CommandSpec::new("table.toText", "Convert to Text", "Table Layout › Data", to_text)
.params(r#"{"separator"?: "tab|comma|paragraph|other", "char"?: string (with other)} (the selected rows, or the whole table)"#)),
t(CommandSpec::new("table.formula", "Formula", "Table Layout › Data", formula).params(r#"{"formula"?: "=SUM(ABOVE)"}"#)),
+8 -1
View File
@@ -8,11 +8,18 @@ use super::{
/// Sort the table's rows by a column. Numbers are read in each cell's language (`1,5` is one and
/// a half in Polish) and sort first, numerically; other text sorts in the column's language.
pub(super) fn sort_table(s: &mut Session, v: &Value) -> CmdResult {
let col = p::u64(v, "column").unwrap_or(0) as usize;
let column = match v.get("column") {
None => 0,
Some(value) => value.as_u64().ok_or_else(|| CmdError::Params("`column` must be a non-negative JSON integer".into()))?,
};
let desc = p::bool(v, "descending").unwrap_or(false);
let header = p::bool(v, "header");
let (tp, _, _) = cell(s)?;
let t = s.doc.table(s.sel.focus.story, &tp).ok_or_else(|| CmdError::Failed("no table".into()))?;
let col = usize::try_from(column)
.ok()
.filter(|&col| t.rows.iter().any(|row| col < row.cells.len()))
.ok_or_else(|| CmdError::Params("`column` is outside the table".into()))?;
let skip = usize::from(header.unwrap_or(t.rows.first().is_some_and(|r| r.props.header))).min(t.rows.len());
let column = || t.rows.iter().skip(skip).map(|r| r.cells.get(col));
let lang = column().flatten().find_map(|c| cell_lang(&s.doc, c)).or_else(|| crate::cmd::default_lang(s));
+67
View File
@@ -10389,6 +10389,73 @@ 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() {
+55 -7
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, needs_layout_tables, script_fallbacks};
pub use script::{fallback_rank, is_emoji, joins_cursively, needs_layout_tables, script_fallbacks};
use skrifa::MetadataProvider;
use skrifa::instance::Size;
pub use ui_fallback::{
@@ -185,13 +185,34 @@ fn direction_runs(text: &str) -> Vec<(std::ops::Range<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);
return shape_dir(face, text, features, &map, false, around);
}
let mut out = Vec::with_capacity(text.len());
for (r, rtl) in direction_runs(text) {
let Some(sub) = text.get(r.clone()) else { continue };
let mut g = shape_run(face, sub, features, &map, rtl);
let mut g = shape_run_in(face, sub, features, &map, rtl, around.at_edges_of(text, &r));
for x in &mut g {
x.cluster += r.start;
}
@@ -205,7 +226,19 @@ pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(cha
/// mark positioning. Glyphs come back in logical order like [`shape`]: clusters in text order,
/// each cluster's glyphs in the shaper's (visual) order.
pub fn shape_run(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char, rtl: bool) -> Vec<ShapedGlyph> {
let g = shape_dir(face, text, features, &map, rtl);
shape_run_in(face, text, features, map, rtl, ShapeContext::default())
}
/// [`shape_run`] of a piece of longer text (see [`ShapeContext`]).
pub fn shape_run_in(
face: &FontFace,
text: &str,
features: &[Feature],
map: impl Fn(char) -> char,
rtl: bool,
around: ShapeContext<'_>,
) -> Vec<ShapedGlyph> {
let g = shape_dir(face, text, features, &map, rtl, around);
if !rtl {
return g;
}
@@ -221,7 +254,14 @@ pub fn shape_run(face: &FontFace, text: &str, features: &[Feature], map: impl Fn
groups.into_iter().flatten().collect()
}
fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(char) -> char, rtl: bool) -> Vec<ShapedGlyph> {
fn shape_dir(
face: &FontFace,
text: &str,
features: &[Feature],
map: &impl Fn(char) -> char,
rtl: bool,
around: ShapeContext<'_>,
) -> 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();
@@ -234,6 +274,10 @@ fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(ch
for (i, c) in sub.char_indices() {
buf.add(map(c), (r.start + i) as u32);
}
// After the text: adding to a buffer clears what follows it.
let around = around.at_edges_of(text, &r);
buf.set_pre_context(around.before);
buf.set_post_context(around.after);
buf.guess_segment_properties();
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
let plan = shape_plan(face, &shaper, &buf, features);
@@ -386,7 +430,10 @@ mod tests {
gb.glyph_infos().iter().zip(gb.glyph_positions()).map(|(i, p)| (i.glyph_id, i.cluster, p.x_advance, p.x_offset, p.y_offset)).collect()
};
let kept = |face: &FontFace, text: &str, features: &[Feature], rtl: bool| -> Vec<(u32, u32, i32, i32, i32)> {
shape_dir(face, text, features, &|c| c, rtl).iter().map(|g| (g.gid, g.cluster as u32, g.x_advance, g.x_offset, g.y_offset)).collect()
shape_dir(face, text, features, &|c| c, rtl, ShapeContext::default())
.iter()
.map(|g| (g.gid, g.cluster as u32, g.x_advance, g.x_offset, g.y_offset))
.collect()
};
let db = FontDb::global();
let faces = [db.face(DEFAULT_FAMILY, "Regular"), db.face(DEFAULT_FAMILY, "Italic"), db.face("Source Sans 3", "Regular")];
@@ -421,7 +468,8 @@ mod tests {
for n in 0..3 * MAX_SHAPE_PLANS as u32 {
let features = [feature_value(b"kern", n % 2), feature_value(b"liga", n)];
let rtl = n % 3 == 0;
let kept: Vec<(u32, i32)> = shape_dir(&face, "office", &features, &|c| c, rtl).iter().map(|g| (g.gid, g.x_advance)).collect();
let kept: Vec<(u32, i32)> =
shape_dir(&face, "office", &features, &|c| c, rtl, ShapeContext::default()).iter().map(|g| (g.gid, g.x_advance)).collect();
assert_eq!(kept, fresh(&features, rtl), "{n}");
assert!(kept_shape_plans() <= MAX_SHAPE_PLANS, "{}", kept_shape_plans());
}
+22
View File
@@ -18,6 +18,28 @@ 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 -2
View File
@@ -6,6 +6,7 @@ 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>,
@@ -226,6 +227,19 @@ 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;
}
@@ -374,10 +388,15 @@ 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(&st.face, shape_text, features, map, key.rtl)
wordcraft_fonts::shape_run_in(&st.face, shape_text, features, map, key.rtl, around)
} else {
wordcraft_fonts::shape(&st.face, shape_text, features, map)
wordcraft_fonts::shape_in(&st.face, shape_text, features, map, around)
};
let k = st.size / st.face.upem.max(1.0) as f32;
let hscale = rc.scale / 100.0;
+36 -12
View File
@@ -92,6 +92,8 @@ struct Scan {
j: usize,
/// Tab cluster, stop, and x where the tab started.
pending_tab: Option<(usize, TabStop, f32)>,
/// The text after a pending decimal tab looked at so far for its '.'.
decimal: Option<DecimalScan>,
/// The width of the spaces so far on this line.
space_w: f32,
has_tab: bool,
@@ -113,17 +115,15 @@ pub(crate) fn takes_room(c: &Cluster) -> bool {
c.kind != ClKind::Marker && !(matches!(c.kind, ClKind::Object(_)) && c.adv == 0.0 && c.obj_h == 0.0)
}
/// The width up to the first '.' of the text after the tab at `tj`, up to cluster `j`.
fn width_to_decimal(pl: &ParaLayout, tj: usize, j: usize) -> Option<f32> {
let mut w = 0.0;
for k in tj + 1..=j {
let cc = pl.clusters.get(k)?;
if pl_text_at(cc) {
return Some(w);
}
w += cc.adv;
}
None
/// How far the text after a decimal tab has been looked at for its first '.' (see
/// [`Scan::width_to_decimal`]): the tab, the next cluster to look at, the width before it, and
/// whether that cluster is the '.'.
#[derive(Clone, Copy, Default)]
struct DecimalScan {
tab: usize,
next: usize,
width: f32,
found: bool,
}
/// The objects' exclusions mirrored across a line of `width`, for a right-to-left paragraph.
@@ -279,6 +279,7 @@ impl<'e> Breaker<'e> {
end: LineEnd::Para,
j: self.i,
pending_tab: None,
decimal: None,
space_w: 0.0,
has_tab: false,
skip: false,
@@ -917,6 +918,29 @@ impl Scan {
false
}
/// The width up to the first '.' of the text after the tab at `tj`, up to cluster `j`. The
/// text is shifted after each of its clusters, so the clusters already looked at are not
/// walked again: a long run without a '.' after a decimal tab is looked at once, not once
/// per cluster.
fn width_to_decimal(&mut self, pl: &ParaLayout, tj: usize, j: usize) -> Option<f32> {
let start = DecimalScan { tab: tj, next: tj + 1, width: 0.0, found: false };
let d = self.decimal.get_or_insert(start);
if d.tab != tj || d.next > j.saturating_add(1) {
*d = start;
}
while !d.found && d.next <= j {
crate::work::steps(1);
let cc = pl.clusters.get(d.next)?;
if pl_text_at(cc) {
d.found = true;
} else {
d.width += cc.adv;
d.next += 1;
}
}
d.found.then_some(d.width)
}
/// Text after a decimal/centre/right tab shifts as it grows.
fn shift_pending_tab(&mut self, pl: &mut ParaLayout) {
let Some((tj, stop, tx)) = self.pending_tab else { return };
@@ -925,7 +949,7 @@ impl Scan {
TabAlign::Right => stop.pos - tx - seg_w,
TabAlign::Center => stop.pos - tx - seg_w / 2.0,
// Width up to the first '.' in the segment.
TabAlign::Decimal => stop.pos - tx - width_to_decimal(pl, tj, self.j).unwrap_or(seg_w),
TabAlign::Decimal => stop.pos - tx - self.width_to_decimal(pl, tj, self.j).unwrap_or(seg_w),
_ => 0.0,
};
let shift = shift.max(0.0);
+55 -3
View File
@@ -6,6 +6,8 @@ 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,
@@ -33,6 +35,8 @@ 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.
@@ -83,6 +87,10 @@ 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 {
@@ -186,6 +194,8 @@ impl<'a> Shaped<'a> {
left: Vec::new(),
bare: Vec::new(),
runs: Vec::new(),
shown_at: 0,
shown_floor: 0,
}
}
@@ -236,24 +246,44 @@ 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(s, range.start + seg, rc, None);
self.b.shape_around(s, range.start + seg, rc, None, Dir::Para, ShapeContext { before, after: "" });
}
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(s, range.start + seg, rc, None);
self.b.shape_around(s, range.start + seg, rc, None, Dir::Para, ShapeContext { before, after: around.after });
}
}
/// 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 {
@@ -593,6 +623,26 @@ 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;
@@ -609,7 +659,9 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
shown: Vec::new(),
substituted: Vec::new(),
};
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 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 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,3 +639,149 @@ 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:?}");
}
}
+22
View File
@@ -482,3 +482,25 @@ fn selecting_a_header_on_the_hinted_page_does_not_walk_every_page() {
eprintln!("hinted header: {small} steps for 20 pages, {big} for 160");
assert!(small > 0 && big <= small + 8, "the pages aren't walked: {small} → {big}");
}
/// A paragraph of a decimal tab at 200 pt followed by `text`.
fn after_decimal_tab(text: &str) -> Document {
use wordcraft_doc::props::{TabAlign, TabStop};
let mut d = Document::from_text(&format!("\t{text}"));
let at = Pos::body(0, 0);
d.format_paragraphs(&at, &at, &|p| p.tabs = Some(vec![TabStop { pos: 200.0, align: TabAlign::Decimal, leader: Default::default() }])).unwrap();
d
}
/// The text after a decimal tab was looked at again from the tab for its '.' after each of its
/// clusters, so a long run of clusters without a '.' (soft hyphens take no room, so they stay on
/// one line) took quadratic time.
#[test]
fn text_after_a_decimal_tab_is_looked_at_once() {
let opts = LayoutOptions::default();
// The text after the tab still ends at the stop, where it ended before.
let l = lay(&after_decimal_tab("12.5"), &opts);
let (Some(end), Some(start)) = (l.caret(&Pos::body(0, 5)), l.caret(&Pos::body(0, 0))) else { panic!("no carets") };
assert!((end.x - start.x - 200.0).abs() < 1.0, "the text ends at the stop: {} from {}", end.x, start.x);
assert_layout_linear("decimal tab", 2000, |n| after_decimal_tab(&format!("1{}", "\u{AD}".repeat(n))), &opts);
}
+1 -1
View File
@@ -11,7 +11,7 @@ impl WordApp {
}
self.font_generation = generation;
self.session.relayout();
self.previews.fonts_changed(ctx, db.user_fonts().iter().flat_map(|f| f.families()));
self.previews.fonts_changed();
ctx.request_repaint();
}
+1
View File
@@ -48,6 +48,7 @@ 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)]
+10 -16
View File
@@ -1,5 +1,5 @@
use super::balloons::over_balloon;
use super::links::{field_showing_code, is_followable_link, is_generated_bookmark_link, link_under};
use super::links::{field_showing_code, follow_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, p, page, x, y, story });
handle_page_pointer(app, PagePointer { ui, resp, layout, rects, scale, 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,6 +143,8 @@ struct PagePointer<'a> {
ui: &'a Ui,
resp: &'a egui::Response,
layout: &'a DocLayout,
rects: &'a [Rect],
scale: f32,
p: Pos2,
page: usize,
x: f32,
@@ -329,16 +331,7 @@ fn handle_primary_press(
if mods.command
&& let Some(link) = link_under(app, layout, page, x, y, &pos)
{
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"));
}
follow_link(app, link);
return;
}
let pj = serde_json::to_value(&pos).unwrap_or_default();
@@ -479,16 +472,17 @@ fn skip_equation_or_outline(app: &mut WordApp, pointer: &PagePointer<'_>) -> boo
}
fn show_hover_link(app: &WordApp, pointer: &PagePointer<'_>) {
let PagePointer { ui, resp, layout, page, x, y, story, .. } = pointer;
let PagePointer { ui, resp, layout, rects, scale, p, 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).
// 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).
if resp.hovered()
&& !app.canvas.dragging
&& !app.canvas.context_menu_open
&& !resp.secondary_clicked()
&& !ui.input(|i| i.pointer.secondary_down())
&& let Some(pos) = layout.hit(*page, *x, *y, *story)
&& let Some(link) = link_under(app, layout, *page, *x, *y, &pos)
&& 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 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,6 +43,21 @@ 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,3 +5073,116 @@ 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);
}
+50 -1
View File
@@ -354,13 +354,36 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect]
});
return true;
}
let grabbed = grab_at(app, layout, pages, scale, at).or_else(|| pressed.then(|| behind_drag_at(app, layout, pages, scale, at)).flatten());
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 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) {
@@ -591,6 +614,32 @@ 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)> {
+78 -52
View File
@@ -28,30 +28,44 @@ pub struct Previews {
budget: std::cell::Cell<u32>,
frame: u64,
/// The Equation tab's pictures, packed into one texture (#1002).
math: MathAtlas,
math: PreviewAtlas,
/// The font menus' entries, packed the same way (#5105); shared with the closures
/// [`Previews::font_preview_fn`] hands out.
fonts: Arc<egui::mutex::Mutex<FontAtlas>>,
}
/// Side of each square texture page the equation pictures share, in pixels.
const MATH_ATLAS: usize = 2048;
/// The font menus' atlas, and the font database generation its entries were drawn with. When
/// fonts are added or removed, from this window or another one, every entry is drawn again: an
/// entry drawn in the fallback face before its font was added, or in a removed font's face (a
/// Recently Used Fonts entry), never stays.
#[derive(Default)]
struct FontAtlas {
generation: u64,
atlas: PreviewAtlas,
}
/// Side of each square texture page the pictures of a [`PreviewAtlas`] share, in pixels.
const ATLAS: usize = 2048;
/// At most this many pages: enough for a frame's pictures at a high display scale (the 22
/// built-in equations alone take two pages at 3×), still a handful of textures in all.
const MATH_ATLAS_PAGES: usize = 4;
const ATLAS_PAGES: usize = 4;
/// Transparent pixels between pictures, so linear filtering never bleeds one into the next.
const MATH_ATLAS_GAP: usize = 2;
const ATLAS_GAP: usize = 2;
/// Every equation picture (gallery tiles, structure buttons, symbols) packed into a few shared
/// textures ("pages"), row by row, and drawn as a part of one (#1002).
/// Small pictures packed into a few shared textures ("pages"), row by row, and drawn as a part
/// of one: every equation picture (gallery tiles, structure buttons, symbols, #1002), and in an
/// atlas of their own the font menus' entries (#5105).
///
/// They used to be a texture each: hundreds of small textures, created as galleries opened and
/// all freed at once when the preview cache filled. On Windows (DirectX 12) the tiles then
/// showed other textures' memory: inverted pictures, red and teal fills, and even damaged
/// interface letters. A texture written in place is what egui does for its own glyphs; the
/// atlas creates at most [`MATH_ATLAS_PAGES`] textures and frees none while galleries are
/// They used to be a texture each: hundreds of small textures, created as galleries and font
/// menus were browsed and freed again. On Windows (DirectX 12) the pictures then showed other
/// textures' memory: inverted pictures (font names on black strips in light mode, white ones in
/// dark mode), red and teal fills, and even damaged interface letters. A texture written in place is what egui does for its own glyphs; the
/// atlas creates at most [`ATLAS_PAGES`] textures and frees none while galleries are
/// browsed. When every page is full, the page least recently drawn from starts over, but never
/// one drawn from in the current frame (its pictures are already on screen): a frame whose
/// pictures don't fit shows the rest as linear text.
#[derive(Default)]
struct MathAtlas {
struct PreviewAtlas {
pages: Vec<AtlasPage>,
/// Picture key → its page and part of that page's texture (UV coordinates).
uv: HashMap<String, (usize, Rect)>,
@@ -73,19 +87,19 @@ struct AtlasPage {
impl AtlasPage {
/// A free spot for a `w`×`h` picture, or `None` when the page is full or it can't fit.
fn place(&mut self, w: usize, h: usize) -> Option<[usize; 2]> {
if w == 0 || h == 0 || w > MATH_ATLAS || h > MATH_ATLAS {
if w == 0 || h == 0 || w > ATLAS || h > ATLAS {
return None;
}
let (mut x, mut y, mut row_h) = (self.x, self.y, self.row_h);
if x.saturating_add(w) > MATH_ATLAS {
if x.saturating_add(w) > ATLAS {
x = 0;
y = y.saturating_add(row_h).saturating_add(MATH_ATLAS_GAP);
y = y.saturating_add(row_h).saturating_add(ATLAS_GAP);
row_h = 0;
}
if y.saturating_add(h) > MATH_ATLAS {
if y.saturating_add(h) > ATLAS {
return None;
}
self.x = x.saturating_add(w).saturating_add(MATH_ATLAS_GAP);
self.x = x.saturating_add(w).saturating_add(ATLAS_GAP);
self.y = y;
self.row_h = row_h.max(h);
Some([x, y])
@@ -97,12 +111,12 @@ impl AtlasPage {
self.y = 0;
self.row_h = 0;
if let Some(tex) = &mut self.tex {
tex.set(egui::ColorImage::filled([MATH_ATLAS, MATH_ATLAS], egui::Color32::TRANSPARENT), egui::TextureOptions::LINEAR);
tex.set(egui::ColorImage::filled([ATLAS, ATLAS], egui::Color32::TRANSPARENT), egui::TextureOptions::LINEAR);
}
}
}
impl MathAtlas {
impl PreviewAtlas {
/// The picture stored under `key`, marking its page as drawn from in `frame`.
fn get(&mut self, frame: u64, key: &str) -> Option<(egui::TextureId, Rect)> {
let (page, uv) = *self.uv.get(key)?;
@@ -116,11 +130,11 @@ impl MathAtlas {
/// this frame without wiping pictures already handed out in it.
fn add(&mut self, ctx: &egui::Context, frame: u64, key: &str, img: egui::ColorImage) -> Option<(egui::TextureId, Rect)> {
let [w, h] = img.size;
if w == 0 || h == 0 || w > MATH_ATLAS || h > MATH_ATLAS {
if w == 0 || h == 0 || w > ATLAS || h > ATLAS {
return None;
}
let mut spot = self.pages.iter_mut().enumerate().find_map(|(i, p)| p.place(w, h).map(|at| (i, at)));
if spot.is_none() && self.pages.len() < MATH_ATLAS_PAGES {
if spot.is_none() && self.pages.len() < ATLAS_PAGES {
let mut p = AtlasPage::default();
spot = p.place(w, h).map(|at| (self.pages.len(), at));
self.pages.push(p);
@@ -138,18 +152,31 @@ impl MathAtlas {
let p = self.pages.get_mut(i)?;
let tex = p.tex.get_or_insert_with(|| {
ctx.load_texture(
format!("math_atlas_{i}"),
egui::ColorImage::filled([MATH_ATLAS, MATH_ATLAS], egui::Color32::TRANSPARENT),
format!("preview_atlas_{i}"),
egui::ColorImage::filled([ATLAS, ATLAS], egui::Color32::TRANSPARENT),
egui::TextureOptions::LINEAR,
)
});
tex.set_partial(at, img, egui::TextureOptions::LINEAR);
p.used = frame;
let s = MATH_ATLAS as f32;
let s = ATLAS as f32;
let uv = Rect::from_min_max(pos2(at[0] as f32 / s, at[1] as f32 / s), pos2((at[0] + w) as f32 / s, (at[1] + h) as f32 / s));
self.uv.insert(key.to_string(), (i, uv));
Some((tex.id(), uv))
}
/// Forget every picture and clear the pages, keeping their textures.
fn clear(&mut self) {
for p in &mut self.pages {
p.reset();
}
self.uv.clear();
}
}
/// A font menu entry's key in the fonts' atlas: its display scale and family.
fn font_key(name: &str, ppp: f32) -> String {
format!("{ppp}:{name}")
}
impl Previews {
@@ -210,30 +237,37 @@ impl Previews {
self.english_names.get(shown).cloned().unwrap_or_else(|| shown.to_string())
}
/// Fonts were added or removed: list the families again and redraw the entries of
/// `families` (an entry drawn before its font was added shows the fallback face).
pub fn fonts_changed(&mut self, ctx: &egui::Context, families: impl Iterator<Item = 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) {
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.
/// A closure drawing a font-menu entry in its own face, from the fonts' atlas.
pub fn font_preview_fn(&mut self) -> Box<dyn Fn(&mut Ui, &str) -> Response> {
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 atlas = Arc::clone(&self.fonts);
Box::new(move |ui: &mut Ui, name: &str| {
let ppp = ui.ctx().pixels_per_point();
let frame = ui.ctx().cumulative_frame_nr();
let key = font_key(name, ppp);
let (r, resp) = ui.allocate_exact_size(vec2(260.0, crate::widgets::COMBO_PREVIEW_ROW_H), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), name));
let t = Tokens::get(ui.ctx());
if resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
let tex = tex.or_else(|| {
// The atlas lock is never held while the Context's is, nor the other way round.
let generation = wordcraft_fonts::FontDb::global().generation();
let hit = {
let mut fonts = atlas.lock();
if fonts.generation != generation {
fonts.generation = generation;
fonts.atlas.clear();
}
fonts.atlas.get(frame, &key)
};
let hit = hit.or_else(|| {
// Context accessors share a lock: read input before taking the data write lock.
let now = ui.input(|i| i.time);
let n = ui.ctx().data_mut(|d| {
@@ -248,22 +282,14 @@ impl Previews {
ui.ctx().request_repaint();
return None;
}
let ppp = ui.ctx().pixels_per_point();
let mut img = font_entry_image(name, ppp);
ink_mask(&mut img);
let h = ui.ctx().load_texture(&key, img, egui::TextureOptions::LINEAR);
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique(&key), h.clone()));
Some(h)
atlas.lock().atlas.add(ui.ctx(), frame, &key, img)
});
match tex {
Some(h) => {
let sz = h.size_vec2() / ui.ctx().pixels_per_point();
ui.painter().image(
h.id(),
Rect::from_min_size(pos2(r.min.x + 6.0, r.center().y - sz.y / 2.0), sz),
Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)),
t.text,
);
match hit {
Some((id, uv)) => {
let sz = uv.size() * ATLAS as f32 / ppp;
ui.painter().image(id, Rect::from_min_size(pos2(r.min.x + 6.0, r.center().y - sz.y / 2.0), sz), uv, t.text);
}
None => {
ui.painter().text(pos2(r.min.x + 8.0, r.center().y), egui::Align2::LEFT_CENTER, name, regular(12.0), t.text);
@@ -316,7 +342,7 @@ impl Previews {
/// An equation rasterised for a gallery tile or a button (`pt`: font size; `size`: the tile in
/// points), with its empty slots shown: the shared texture and the picture's UV rectangle in it
/// ([`MathAtlas`]). An ink mask, drawn tinted with the theme's text colour. `None` while waiting
/// ([`PreviewAtlas`]). An ink mask, drawn tinted with the theme's text colour. `None` while waiting
/// for this frame's rendering budget.
pub fn math_texture(
app: &mut WordApp,
+87 -23
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 preview = Previews::default().font_preview_fn();
let mut previews = Previews::default();
let preview = previews.font_preview_fn();
let budget_id = egui::Id::unique("font_preview_budget");
let sans_id = egui::Id::unique("font:Source Sans 3");
let inter_id = egui::Id::unique("font:Inter");
let (sans, inter) = (font_key("Source Sans 3", 1.0), font_key("Inter", 1.0));
ctx.data_mut(|d| d.insert_temp(budget_id, (1.0_f64, 11_u32)));
for time in [1.0, 2.0, 3.0] {
@@ -134,16 +134,16 @@ fn font_menu_previews_render_defer_and_reuse_without_deadlocking() {
preview(ui, "Inter");
})
.drop_without_applying_deltas();
ctx.data(|d| {
assert!(d.get_temp::<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");
}
});
let drawn = |key: &str| previews.fonts.lock().atlas.uv.contains_key(key);
assert!(drawn(&sans));
let budget = ctx.data(|d| d.get_temp::<(f64, u32)>(budget_id));
if time == 1.0 {
assert!(!drawn(&inter), "over-budget entry should defer");
assert_eq!(budget, Some((1.0, 13)));
} else {
assert!(drawn(&inter), "deferred entry should render next frame");
assert_eq!(budget, Some((2.0, 1)), "cached entries should not consume budget");
}
}
tx.send(()).unwrap();
});
@@ -262,12 +262,12 @@ fn equation_pictures_share_one_texture() {
fn math_atlas_packs_rows() {
let mut a = AtlasPage::default();
assert_eq!(a.place(100, 40), Some([0, 0]));
assert_eq!(a.place(100, 20), Some([100 + MATH_ATLAS_GAP, 0]));
assert_eq!(a.place(MATH_ATLAS, 10), Some([0, 40 + MATH_ATLAS_GAP]), "a new row under the tallest");
assert_eq!(a.place(MATH_ATLAS + 1, 10), None);
assert_eq!(a.place(100, 20), Some([100 + ATLAS_GAP, 0]));
assert_eq!(a.place(ATLAS, 10), Some([0, 40 + ATLAS_GAP]), "a new row under the tallest");
assert_eq!(a.place(ATLAS + 1, 10), None);
assert_eq!(a.place(10, 0), None);
assert_eq!(a.place(10, MATH_ATLAS), None, "full");
assert_eq!(a.place(10, 10), Some([0, 40 + MATH_ATLAS_GAP + 10 + MATH_ATLAS_GAP]), "still room after a refusal");
assert_eq!(a.place(10, ATLAS), None, "full");
assert_eq!(a.place(10, 10), Some([0, 40 + ATLAS_GAP + 10 + ATLAS_GAP]), "still room after a refusal");
}
/// #1002: a page is never wiped in a frame that handed out one of its pictures (they are
@@ -275,16 +275,16 @@ fn math_atlas_packs_rows() {
#[test]
fn math_atlas_never_wipes_a_page_drawn_from_this_frame() {
let ctx = egui::Context::default();
let img = || egui::ColorImage::filled([MATH_ATLAS, MATH_ATLAS], egui::Color32::TRANSPARENT);
let img = || egui::ColorImage::filled([ATLAS, ATLAS], egui::Color32::TRANSPARENT);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
let mut a = MathAtlas::default();
let first: Vec<_> = (0..MATH_ATLAS_PAGES).map(|i| a.add(ui.ctx(), 1, &format!("p{i}"), img())).collect();
let mut a = PreviewAtlas::default();
let first: Vec<_> = (0..ATLAS_PAGES).map(|i| a.add(ui.ctx(), 1, &format!("p{i}"), img())).collect();
assert!(first.iter().all(Option::is_some), "a page each");
let ids: std::collections::HashSet<_> = first.iter().flatten().map(|(id, _)| *id).collect();
assert_eq!(ids.len(), MATH_ATLAS_PAGES);
assert_eq!(ids.len(), ATLAS_PAGES);
assert!(a.add(ui.ctx(), 1, "more", img()).is_none(), "every page was drawn from this frame");
assert_eq!(a.wipes, 0);
assert!(a.uv.len() == MATH_ATLAS_PAGES, "nothing forgotten");
assert!(a.uv.len() == ATLAS_PAGES, "nothing forgotten");
// Next frame: page 0 is drawn from again, so the one drawn longest ago is page 1.
assert_eq!(a.get(2, "p0"), first.first().copied().flatten());
let (id, _) = a.add(ui.ctx(), 2, "more", img()).expect("a page starts over");
@@ -347,6 +347,70 @@ fn ink_mask_keeps_the_ink_and_drops_the_paper() {
assert_eq!(img.pixels[2].a(), 127, "antialiased ink keeps its coverage");
}
/// #5105: on Windows, font menu entries showed on black strips in light mode and on white or
/// red ones in dark mode: each entry was a texture of its own, and browsing the list created
/// and freed hundreds of them, the DirectX 12 texture mix-up of #1002. Entries are now drawn
/// from a few shared pages written in place: scrolling through a long font list, at a high
/// display scale too, creates at most [`ATLAS_PAGES`] textures and frees none. Once fonts are
/// added or removed (from any window), every entry is drawn again.
#[test]
fn font_menu_entries_share_a_few_textures_and_free_none() {
for ppp in [1.0, 3.0] {
let ctx = egui::Context::default();
ctx.set_pixels_per_point(ppp);
let mut previews = Previews::default();
let preview = previews.font_preview_fn();
let names: Vec<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]
+2
View File
@@ -120,6 +120,7 @@ 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 |
@@ -337,6 +338,7 @@ 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 | 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. |
+7 -1
View File
@@ -18,6 +18,8 @@ Group connectors (gap 10): re-routing preserves displayed rotated and flipped fr
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
Table Sort rejects invalid explicit column selectors instead of silently sorting another column (#5531). This command-boundary correction leaves the ranked gaps and readiness estimates unchanged.
The work list. One entry per shortfall, ranked by how much it stops a Word user from switching
(**B** = blocks beta). Each says what's missing, the evidence, who it hurts, an Opus 5.5 agent
wall-clock estimate, and which parity document tracks it. Agents: pick from the top, check open PRs
@@ -94,7 +96,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**: 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** (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) |
| 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) |
@@ -172,6 +174,8 @@ 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.
@@ -190,6 +194,7 @@ 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) |
@@ -214,6 +219,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text
| 2026-11-01 | trivial | #10 narrowed: text in a turned text box is drawn turned with it on screen and in PNG and PDF (upside down when flipped across, never mirrored); editing it still goes by its upright text; estimate unchanged |
| 2026-10-29 | trivial | Print Markup balloon anchor discovery no longer repeats paragraph-prefix scans (#3614); readiness estimates unchanged |
| 2026-10-28 | trivial | Print Markup text-copy comment discovery walks paragraph objects once (#3614); quotes, order, limits and readiness estimates unchanged |
| 2026-11-04 | trivial | Table Sort rejects malformed or nonexistent explicit columns before mutation; valid columns, the omitted-column default and history are retained (#5531). Readiness estimates unchanged. |
| 2026-10-30 | trivial | #20 narrowed: layout resource caps and linear-time passes (#3867, #3866) |
| 2026-10-17 | trivial | UI/UX gaps: copies put RTF on the clipboard (Windows, macOS, Wayland; not X11), and paste reads RTF on macOS and X11 too |
| 2026-11-03 | minor | #11: Arabic interface with mirrored shell (#1195); key languages missing are now hi and id (plus pt-PT); Hebrew UI still open |
+4
View File
@@ -30,6 +30,7 @@ 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 |
@@ -278,6 +279,8 @@ 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`).
@@ -311,6 +314,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc
| 2026-11-01 | trivial | SEQ fields in text boxes are numbered where their boxes are anchored, in output and after F9, and a REF finds a bookmark in a text box |
| 2026-11-01 | trivial | SEQ, STYLEREF and REF fields show fresh results in output; readiness estimates unchanged |
| 2026-10-29 | trivial | Print Markup balloon anchor discovery walks paragraph objects once (#3614); readiness estimates unchanged |
| 2026-11-03 | trivial | A decimal tab scans the text after it once, not once per cluster. Separate from the #3412 word-measurement fix |
| 2026-10-30 | trivial | Layout work bounds for hostile documents (#3867): header-row repeats budgeted per layout and counting canvas members; picture-outline decode budget charged for cached outlines too, so cold and warm layouts wrap alike; linked-box chains keep exact widths up to a work cap, not 16 widths; `gridAfter` bounded |
| 2026-10-29 | trivial | A missing font is drawn in an installed metric twin before any other substitute (`fonts::word::METRIC_TWINS`, also what the missing-font warning calls metric-compatible): Arial for Helvetica on Windows, Arial for Liberation Sans on a Mac, Tinos before macOS Times for Times New Roman, Cousine before Menlo for Courier New. Documents in fonts with no installed twin lay out as before |
| 2026-11-01 | trivial | Columns: the line between them runs from the section's top on the page to the bottom of its tallest column; a continuous section mid-page gets its line (#5178) |
+2 -1
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); 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); 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) |
| 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,6 +276,7 @@ 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 |
+5
View File
@@ -77,6 +77,8 @@ Save Changes exposes the document-specific question as an alert-dialog name and
The Language dialog reads effective character formatting, including document defaults, paragraph styles and character styles. Accepting it unchanged keeps inherited language and no-proof choices (#2717); explicit direct formatting still takes precedence. Other proofing and Windows-specific reports remain separate.
The `table.sort` command requires an explicit `column` to be a non-negative JSON integer naming a column in the table, indexed from 0 (#5531). Omitting it still sorts by column 0. Invalid selectors return an error before changing rows or history; the Sort dialog and collation rules are unchanged.
## Find and Replace: regular expressions (#4177)
With **Regular expressions** on (Search Options), Replace with expands capture groups: `$1`…`$n`, `${n}`, `${name}` and `$name` (the longest run of letters, digits and `_`), `$0` / `${0}` (the whole match), and `\1`…`\9` as in wildcard Replace; `$$` is a dollar sign, `\t` a tab, `\\` a backslash, `\$` and `\^` a `$` or `^` as typed, and `\n` a paragraph mark, which splits the paragraph as `^p` does (Replace All stays one undo step). Word's `^&`, `^p`, `^t`, `^c` and the other `^` codes work there too (with Special characters off, only the `^` codes are taken literally). A group that doesn't exist, didn't take part in the match or is out of range expands to nothing; a `$` that starts no group, an unclosed `${`, and other `\` escapes stay as written. Plain and wildcard Replace are unchanged. Word has no regular-expression mode; this follows the Rust `regex` crate's Replace syntax.
@@ -111,11 +113,14 @@ 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) |