mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-10 20:51:50 +00:00
Merge upstream main into control delivery changes
This commit is contained in:
commit
480511eaa5
89 files changed
+3754
-130
No files matched your search
+27
@@ -1,6 +1,8 @@
|
||||
# WordCraft roadmap
|
||||
|
||||
> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (#3398: persistent screen paper colors; parity estimates unchanged); previously minor (#3693: additional Delete Previous Sentence command and shortcut; Word-parity estimates unchanged)
|
||||
> **CLI path update:** 2026-11-04 · Options before a non-Unicode input filename no longer make the CLI reject that filename; text option values still require Unicode; `run --file` and `run --script` retain native paths. No ranked gap or readiness estimate changes.
|
||||
> **Floating anchor row segments:** 2026-11-07 · A floating anchor after a manual page/column break counts visible content on the far side of the same wrapped row. Marker-only cover anchors retain their previous-page rule. Synthetic picture spacing, placement and history controls; no new native Word measurement. Readiness estimates unchanged.
|
||||
|
||||
> **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged.
|
||||
|
||||
@@ -150,6 +152,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|
||||
- 2026-11-08: Layout: a turned inline picture whose file keeps no room for the turn (no or a zero `wp:effectExtent`, as other tools write) keeps its line to its frame and overhangs it, cut at the line's top and bottom, as Word does: the frame as it is in Word 2007 layout, with width and height exchanged from 45° to 135° and 225° to 315° in Word 2013+ layout. Such a file saves the same way. Pictures with the room recorded (Word's own files) still take their rotated bounds. Readiness unchanged.
|
||||
- 2026-11-08: XML text and CDATA read with their line ends normalised (XML 1.0 §2.11): CR LF and a lone CR are one line feed, ` ` stays a CR, so a line break inside `w:t` in a file saved with Windows line ends is one space, not two (#5897); readiness estimates unchanged.
|
||||
- 2026-11-08: Layout: contextual spacing drops the space after a table cell's last paragraph when that paragraph is in the Normal style, the end-of-cell mark counting as a Normal paragraph next to it; a last paragraph in another style keeps its space after. The rule is inferred from docxide-pdf's notes and its `cell-end-*` fixtures (`cell-end-text-context`, `cell-end-empty-context`); a Word comparison that tells it apart from the neighbouring paragraph's style is still open. Readiness unchanged.
|
||||
- 2026-11-06: Arabic interface: the Home tab's Styles gallery reads from the right, as the rest of the mirrored ribbon does (#5566). Normal is the rightmost tile, the next styles follow leftwards and the scroll arrows and More sit at the gallery's left end; left to right is unchanged. Readiness unchanged.
|
||||
- 2026-11-05: ODT heading text of another size keeps it (gap 19): a smaller word in a heading came back at the heading's size in LibreOffice after a save.
|
||||
- 2026-11-05: ODT table rows kept from breaking across pages stay so (gap 19): they came back from a save free to break.
|
||||
@@ -167,6 +170,9 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
- 2026-11-07: DOCX: a VML text box keeps its text direction: `mso-layout-flow-alt:bottom-to-top` reads as Rotate all text 270° whatever `layout-flow` says, and otherwise `layout-flow:vertical` (or `vertical-ideographic`) as 90°, so the turned text box in a compatibility-mode document's header is drawn turned, as in Word. (#5193)
|
||||
- 2026-11-06: Change Picture keeps the picture's width and takes the new picture's aspect ratio, dropping the old crop, instead of stretching the new picture into the old frame (a revised figure came out about 2% taller in a real manuscript) (#5761). Readiness estimates unchanged.
|
||||
- 2026-11-06: Superscript and subscript text is set at 0.65 of its size rounded to the half point, as Word does (8 pt → 5, 11 → 7, 12 → 8, 24 → 15.5; measured in Word for Mac from digit advances with `cargo xtask word-compare`), not 0.65 exactly: lines with citation numbers and footnote references break where Word's do (the Arial superscript and author-line probes now match Word line for line). Readiness estimates unchanged.
|
||||
- 2026-11-06: Line numbers take the document's "Line Number" style over its defaults, as in Word (Aptos 12 pt black in a manuscript whose defaults are Aptos 12 pt, measured in Word for Mac), instead of a fixed 10 pt grey. Readiness estimates unchanged.
|
||||
- 2026-11-07: Newly opened secondary windows constrain their initial viewport dimensions to the monitor's usable area with margins for system panels, preventing new windows from exceeding screen width on high-DPI scaled displays (#5871).
|
||||
- 2026-11-07: A floating anchor after a page break stays with text on the far side of the same wrapped row (Leaf #107); marker-only cover anchors retain their rule. Picture spacing, drop position, Undo/Redo and DOCX regressions.
|
||||
- 2026-11-06: Windows print submissions run in separate helper processes (#5710). A helper acknowledges cancellation before it stops, so a driver stalled in a cancelled job cannot hold another job’s provider lock. A private temporary PDF and bounded settings messages cross the process boundary; normal shutdown reaps helpers and removes their inputs. The helper erases its plaintext inputs before driver work; a hard app crash during startup can still leave private temporary files. Jobs already sending still complete. Readiness estimates unchanged.
|
||||
- 2026-11-06: File › Print: before sending starts, Cancel printing stops a waiting job, frees the Print page and keeps the settings; a successfully cancelled job is never sent. Cancellation remains available after the last document closes. Quitting cancels waiting jobs and joins jobs already sending (#5710). On Windows, winprint repeats the DEVMODE conversion after sending starts. A stall during that repeat prevents cancellation and can keep shutdown waiting, even before a native job exists. Readiness estimates unchanged.
|
||||
- 2026-11-06: File › Print on Windows no longer waits on printer drivers to list printers (#5710). The list used to wait until each printer's driver said whether it prints on both sides, and a networked printer asleep in power save could keep it empty with Refresh unavailable; now discovery asks no driver (the Windows spooler still supplies the list), and printing still checks the job's options with the driver. The reporter's sleeping printer has not been retested yet. Readiness estimates unchanged.
|
||||
@@ -248,6 +254,14 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
- 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.
|
||||
- 2026-11-03: Layout: a line can break after a hyphen that a slash and a letter follow ("Dataprotection-" / "/Secrecyviolation"), as Word does ("fitness-/1589", with a digit after the slash, stays whole), where UAX #14 forbids a break before the slash; an auto-fit table column holding such a compound wraps it instead of widening and taking the width from its neighbours. (#3408)
|
||||
- 2026-11-03: Layout: a section's closing paragraph whose mark is hidden and that holds only an in-line picture (or a note reference) is no longer laid out as an empty hidden mark. The picture keeps its room, so the next continuous section's text starts below it instead of being drawn over it. A paragraph with only floating objects and a hidden mark still takes no room. Not observed in Word. (#5288)
|
||||
- 2026-11-03: Security: DOCX import looks relationships up by id through an index built once per part, the first relationship with an id still winning, instead of scanning every relationship for each hyperlink, picture or embedded part that names one. Readiness unchanged.
|
||||
- 2026-11-03: Security: DOCX import no longer carries a file's part named like WordCraft's own bookkeeping (`wordcraft:unmodelled.roots` and the like), which a save read back as that bookkeeping, adding relationships the file chose. Readiness unchanged.
|
||||
- 2026-11-03: Security: LaTeX import parses at most 63 cells of a `tabular` row (what a table row keeps), so a very wide row can no longer widen the multirow tracking that every later row walks. Readiness unchanged.
|
||||
- 2026-11-03: Security: XLSX and ODS import (mail merge recipient lists) keep each cell's character count as text is added, instead of counting the whole cell again for every piece of text, so a full cell followed by many small text events no longer costs its length each time. Readiness unchanged.
|
||||
- 2026-11-03: Security: exported hyperlinks (ODT, LaTeX, HTML, Markdown, RTF) holding a control character or noncharacter that the writer drops are written as plain text, so `file\u{85}:///…` can't pass the link check and come out as a `file:` link. Readiness unchanged.
|
||||
- 2026-11-03: Security: pictures are measured, and checked for transparency for tight text wrapping, from a PNG's chunk headers, instead of through the decoder, whose header read inflates an embedded ICC profile of up to 512 MiB for every picture it only measures. Readiness unchanged.
|
||||
- 2026-11-03: Security: saving an embedded object (a native document's kept markup included) collects the relationship ids its markup uses in a set and checks them against the writable ones in one pass, instead of scanning every id collected so far for each attribute. Readiness unchanged.
|
||||
- 2026-11-03: Security: the renderer leaves out a drawing path reaching more than a million device pixels (64 times the largest canvas) or of no finite size, which the rasteriser would otherwise flatten into points by the billion before clipping it. Readiness unchanged.
|
||||
- 2026-11-03: Language packs: language-packs-2 is published and WordCraft pins its index: 75 languages, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch (#4145, #2505) and Swedish (Sweden, Finland), which need craft-proof's flag-based compounding (storytold/craft-libs#497, pinned). English (Australia) is checked against SCOWL's en-AU list as well as en-GB's (storytold/craft-libs#496). Mongolian and Thai stay hidden. Readiness unchanged.
|
||||
- 2026-11-03: Language lists (CP4.3): a language installed from a Language pack (Bulgarian, say) can now be chosen. Set Proofing Language lists every built-in language and every installed or available pack's, each once and sorted by name; Review › Language and the status bar's menu add the installed packs' languages to the short list, with one way of working out the check mark, by tag. A pack's language is stored as a tag that finds the pack again (`sr-Latn-RS` is proofed by the Latin `sr-latn` pack, not the Cyrillic one; Valencian is Word's `ca-ES-valencia`). Every pack in language-packs-2 now has an English name (Belarusian used to show its own name, Беларуская). Readiness unchanged.
|
||||
- 2026-11-03: Language packs (CP4.3): on a fresh install (or after an upgrade, with the list cached for an older pin) the missing-pack bar still offers a document language that has no dictionary, "Turkish proofing isn't installed. Install": the person's click turns on downloading, fetches the list and installs the pack if it lists one (if not, the bar goes). With downloading already on, a missing or stale list is fetched once in the background. Choosing a language with only the caret counts before anything is typed, and the spelling context menu's heading (Possible spelling mistake) is in the interface language, not the text's. Closing the bar is remembered per document, per pack and, before the list is known, per language tag. Readiness unchanged.
|
||||
@@ -267,6 +281,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
- 2026-10-31: Table of contents: with Word's table over entries in columns, where the title sits in a one-column section above a continuous break, Insert Table of Contents with new settings keeps the title above the break and the entries in their columns (the new title went under the break, into the columns), and Remove Table of Contents takes the title, the blank paragraph and its break with the entries, as Word does (observed in Word for Mac); a paragraph of another style above the break stays (#4041).
|
||||
- 2026-10-31: A table of contents writes its page numbers as `PAGEREF` fields to the headings' bookmarks, as Word does, so updating the fields in Word moves the numbers with the headings; they were plain text (#5097).
|
||||
- 2026-10-31: Keytips reach the Backstage: Alt, F badges its pages with Word's letters, and Alt, F, P (or V) opens Print (#4960).
|
||||
- **2026-10-31 (#3398):** General Options offers persistent Document background / Soft green / Warm cream / Soft gray screen paper. The view.paperColor command reaches the same setting headlessly. Document page colors and pictures, saves, printing and exports retain their own styling; the screen raster/tile cache and page backing share the selected color. Read Mode None uses the preset; explicit Sepia/Inverse keep priority. Parity counts and readiness estimates unchanged.
|
||||
- **2026-10-31 (#3693, editing extension):** Delete Previous Sentence is a headless registry command and Mod+Shift+Backspace shortcut, using existing sentence boundaries and deletion/undo/protection paths. Paragraph and cell boundaries are preserved. Additional WordCraft behavior; Word-parity counts and estimates unchanged.
|
||||
- 2026-10-29: Documents with hyperlinks or a table of contents print again on the desktop (#4734). The print job scales each page to the paper, and the spooler refused a page with link annotations ("flatten page annotations before using shrink-to-fit"); the PDF sent to the printer now omits link annotations, which print nothing; the link and table-of-contents text stays on the page. Exported PDFs and Save these pages as PDF (`file.printPdf`) keep the annotations.
|
||||
- 2026-10-28: Word count counts each Chinese and Japanese character as a word, as Word does, in the status bar, the Word Count dialog and NUMWORDS fields. Unspaced Chinese text used to count as one word per run of text; Korean, which is written with spaces, still counts by words. (#4547)
|
||||
- 2026-10-28: Aligned-copy review fixes keep the document axis locked through middle-button scrolling and let Ctrl/Command+Shift start on a secondary selected object. Plain Ctrl and Shift clicks keep their existing behavior (#4764).
|
||||
@@ -436,6 +452,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
| 2026-10-17 | Edit Recipient List finds records (#1576): a Find in list box hides rows with no cell containing the text (any case) and counts the matches, and "Show only records that will be merged" hides the rows the filter leaves out; both only change the view. Rows are striped, and edits and removes go to the right record however the grid is narrowed |
|
||||
| 2026-10-31 | Control/MCP #2596 review: write/flush failures after sending begins are outcome-unknown; image exports to a path are not replayed; accept-thread startup failures disable the server and remove the key; invalid UTF-8 is rejected per line without ending stdio. Synthetic regressions cover partial JSON delivery at EOF, flush failure, export vs in-memory retry and failed startup. |
|
||||
| 2026-10-16 | Control/MCP delivery (#1326, #1331): the MCP bridge never re-sends a mutation whose outcome is unknown (explicit "outcome unknown" error; reads and not-sent requests still retry); a control request whose caller timed out while queued never runs, and a timeout before execution is told apart from an unknown outcome; bounded MCP and reply line reads; capped unauthenticated control connections with fallible thread creation |
|
||||
| 2026-10-16 | MCP `insert_table` tool (#2242): a filled table in one call from `values`, with `header` (repeating first row) and `style`; `insert.spreadsheet` takes the same `values`/`header`/`style` and rejects ragged, oversized or non-array input |
|
||||
| 2026-10-17 | AccessKit focus guard (#2726): every tree update egui sends carries its focused node, so a frame that drops it falls back to the root instead of panicking in `accesskit_consumer` and closing the window on Windows |
|
||||
| 2026-10-17 | Equation arrays centre a row that has no `&` among rows that do, as Word does, so case equations like Word's "First Property of Radicals" no longer push the aligned row right (#2617). |
|
||||
| 2026-10-17 | Formatting revisions: a table, row, cell or section property change made while tracking now records as `w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange` (structure stays out), so Reject All restores and Accept All keeps it; test tracked_table_changes_are_kept_as_formatting_changes |
|
||||
@@ -508,6 +525,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
| 2026-10-17 | Non-overlapping body floating tables clear header tables. Original native controls distinguish never-overlap from allowed overlap. |
|
||||
| 2026-10-14 | Comment text being typed is no longer lost or misplaced (#1348): an edit or reply the document refuses (for example read-only protection) keeps the typed text in its field and says why on the status line; drafts belong to their document, so after New or Open a comment with the same number shows its own text; Save, Export, Print, Close, New and Open write pending comment text first; if the document refuses it, Save, Export and Print carry on with the stored comments and show why on the status line (a refused draft is dropped when Save is the only change), while Close, New and Open show the Save Changes prompt (Don't Save drops text the document refuses); Escape discards what was typed; in a document protected as read only or for forms the comment fields can't be typed in |
|
||||
|---|---|
|
||||
| 2026-10-12 | RTF reads and writes footnotes, endnotes and comments (author, initials, date, commented range), so they survive a round trip through RTF (#1053) |
|
||||
| 2026-11-07 | Page Setup also bounds the unfocused margin display after a long numeric paste; the accepted value is retained (#5650). |
|
||||
| 2026-11-05 | 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-29 | Print Markup discovers comment balloon anchors in one forward paragraph-object walk; anchor positions, filtering and the existing cap stay unchanged (#3614) |
|
||||
@@ -515,6 +533,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
| 2026-10-22 | Documents that keep content in another format (`w:altChunk`: a web page, RTF, text or another Word document, as generators and mail-merge tools write them) show it where it stands, as Word does, instead of leaving it out; one such file in the docxide-pdf corpus opened as an empty page and now matches Word's 8 pages. `.mht` web archives open, and web pages open the way Word opens them (style sheets, paragraph margins, Times New Roman, single lines, automatic spacing). Insert › Text from File keeps the inserted file's pictures |
|
||||
| 2026-10-18 | Explicit New/Open keeps the requested document visible when startup recovery discovery finishes; older copies stay available. Recover uses the regular theme. See docs/recovery.md. |
|
||||
| 2026-10-15 | A floating picture or shape in a table cell set to align centre or bottom no longer lands half its height above its line (#1887); it starts at the anchor line |
|
||||
| 2026-10-26 | View › Window across the native windows (#322): Arrange All, View Side by Side, Synchronous Scrolling |
|
||||
| 2026-10-18 | Picture Format › Size (#2573): width and height in the interface unit, and a size typed in any unit works, pixels too (`300 px` keeps the picture's proportions, #1901), applied once typed (Enter, Tab or a click elsewhere; Esc cancels) in one undo step. The group's launcher opens a Size dialog: height and width, scale of the original size (or of the size it opened with), Lock aspect ratio, the original size with its pixels, and Reset. `picture.size {"original": true, "scale"?}` sizes from the original; sizes past 4000 pt that keep the proportions shrink as a whole. |
|
||||
| 2026-10-16 | Save Changes gives assistive technology a named alert dialog with the loss warning and any save failure; visual layout and save policy unchanged (#1015). Native screen-reader verification remains pending. |
|
||||
| 2026-10-17 | `design.scaleFontSizes` (Design › Document Formatting): grow or shrink every style's font size at once, by points (`delta`) or percent, rounded to half points and clamped to 1–1638 pt; inherited sizes follow, runs are untouched, one undo step (#1475) |
|
||||
@@ -871,6 +890,7 @@ MATH glyphs that reference the same construction now share its immutable variant
|
||||
|---|---|---|
|
||||
| 2026-10-31 | trivial | Control/MCP #2596 review fixes; readiness estimates unchanged |
|
||||
| 2026-11-08 | trivial | Progress log: XML line ends in text normalised (#5897) |
|
||||
| 2026-11-08 | trivial | Progress log: contextual spacing drops the space after a cell's last Normal paragraph (#5450) |
|
||||
| 2026-11-06 | trivial | Progress log: the Arabic interface's Styles gallery reads from the right (#5566) |
|
||||
| 2026-11-07 | trivial | Page Setup also bounds the unfocused margin display after a long numeric paste; the accepted value is retained (#5650). |
|
||||
| 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. |
|
||||
@@ -882,6 +902,9 @@ MATH glyphs that reference the same construction now share its immutable variant
|
||||
| 2026-11-02 | trivial | Progress log: alternative format chunks and MHT open |
|
||||
| 2026-11-06 | trivial | Progress log: Change Picture keeps the width and takes the new picture's aspect ratio (#5761) |
|
||||
| 2026-11-06 | trivial | Progress log: superscript and subscript text at 0.65 of its size rounded to the half point (Word for Mac) |
|
||||
| 2026-11-06 | trivial | Progress log: line numbers take the document's "Line Number" style |
|
||||
| 2026-11-07 | trivial | Charts: a series without its own colour takes the accent its `c:idx` picks, as Word does, not the one its place in the file picks (#5963) |
|
||||
| 2026-11-07 | trivial | A floating anchor after a manual page/column break counts visible content on the far side of the same wrapped row. Marker-only cover anchors retain their previous-page rule. Synthetic picture spacing, placement and history controls; no new native Word measurement. 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) |
|
||||
| 2026-11-06 | trivial | Windows print submissions isolated per helper process; cancelled driver work cannot block a retry (#5710) |
|
||||
| 2026-11-06 | trivial | Progress log: print cancellation before sending, including without a document, and the Windows repeated-conversion limitation (#5710) |
|
||||
@@ -922,6 +945,7 @@ MATH glyphs that reference the same construction now share its immutable variant
|
||||
| 2026-11-03 | trivial | Progress log: Language pack languages in every language list (CP4.3) |
|
||||
| 2026-11-03 | trivial | Progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language (CP4.3) |
|
||||
| 2026-11-03 | trivial | Progress log: Options › Language and Proofing redesign (CP4.3) |
|
||||
| 2026-11-03 | minor | Home › Paragraph Tools (#5043): nine selection- or document-scoped conversion and cleanup commands; one undo step per command, with tracked deletions preserved; readiness estimates unchanged |
|
||||
| 2026-10-30 | trivial | Progress log: layout resource caps — repeated table headers are bounded by item counts, the wrap cache by decoded pixels and linked text box layout by its lines and items; PDF export, hit testing, tracked changes, clipped tables and long endnote selections cost linear time (#3867, #3866) |
|
||||
| 2026-10-17 | trivial | Progress log: RTF on the system clipboard |
|
||||
| 2026-11-03 | trivial | Progress log: craft-proof pin, French elision suggestions and Valencian (CP4.3) |
|
||||
@@ -950,8 +974,10 @@ MATH glyphs that reference the same construction now share its immutable variant
|
||||
| 2026-11-01 | trivial | #3915: preserve guarded whole-line ordinary-tab alignment with current main. Existing controls retained; source checks only, primary qualification pending. |
|
||||
| 2026-11-01 | trivial | #2437: Preserve finite paragraph-border flow extents and terminal-break keep preflight with current-main changes. Recursive terminal-successor lookahead remains outside scope. Source checks only; primary qualification pending. |
|
||||
| 2026-10-31 | trivial | Progress log: a paragraph naming an undefined DOCX style is formatted as Normal (#5077) |
|
||||
| 2026-10-31 | minor | #3398: persistent screen-only green, cream and gray paper presets, with document backgrounds and export colors retained. Read Mode None follows the screen preset; Sepia/Inverse retain priority. |
|
||||
| 2026-10-31 | trivial | Progress log: TOC page numbers are PAGEREF fields (#5097) |
|
||||
| 2026-10-31 | trivial | Progress log: Backstage keytips, Alt, F, P prints (#4960) |
|
||||
| 2026-10-31 | minor | #3693: Delete Previous Sentence command and keyboard dispatch, with sentence/paragraph/cell, selection, undo and tracking regressions. Additional WordCraft behavior; Word-parity estimates unchanged. |
|
||||
| 2026-10-30 | trivial | Progress log: only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them (#4734) |
|
||||
| 2026-10-29 | trivial | Progress log: documents with links print (#4734) |
|
||||
| 2026-10-31 | trivial | Synchronize the existing Proofing pane with main `eecbb8f`; preserve explicit-position lookup, implicit selection, navigation, undo and per-window state; fresh exact-SHA hosted validation pending, readiness unchanged |
|
||||
@@ -1056,6 +1082,7 @@ MATH glyphs that reference the same construction now share its immutable variant
|
||||
| 2026-10-17 | trivial | A line breaks after a hyphen before a digit, like Word (#2712) |
|
||||
| 2026-10-15 | trivial | Progress log: text above a float's anchor wraps around it, #2048 |
|
||||
| 2026-10-18 | trivial | Progress log: long navigation headings wrap within the pane instead of ending in an ellipsis (#3015) |
|
||||
| 2026-10-26 | trivial | View › Window across the native windows (#322): View catalog 26/29 → 29/29 |
|
||||
| 2026-10-18 | trivial | Font list starts at its first row after clearing a no-match query; recent heading remains visible (#2068) |
|
||||
| 2026-10-18 | trivial | A word after a slash wraps whole, like Word (#2963) |
|
||||
| 2026-10-19 | trivial | Progress log: render threads reused while typing (#3027) |
|
||||
|
||||
@@ -69,6 +69,8 @@ impl eframe::App for App {
|
||||
if let Some((pos, size)) = self.1.and_then(|g| g.fit_on_monitor(&window)) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
|
||||
let b = self.0.window_builder().clone().with_inner_size(size);
|
||||
self.0.set_window_builder(b);
|
||||
}
|
||||
// The geometry saved for the next start is the active window's.
|
||||
let prev = self.0.ui.window;
|
||||
|
||||
@@ -19,6 +19,12 @@ pub fn is_web_or_mail_link(url: &str) -> bool {
|
||||
/// names another machine — following it on Windows leaks the user's NTLM credentials — so it is
|
||||
/// refused too.
|
||||
pub fn is_safe_href(url: &str) -> bool {
|
||||
// Exporters drop other control characters and noncharacters when they write the link
|
||||
// (`file\u{85}:` comes out `file:`), so a link holding one is refused, not judged as it
|
||||
// stands and written as something else.
|
||||
if url.chars().any(|c| (c.is_control() && !c.is_ascii_control()) || matches!(c, '\u{FFFE}' | '\u{FFFF}')) {
|
||||
return false;
|
||||
}
|
||||
let cleaned: String = url.chars().filter(|c| !c.is_ascii_control() && *c != ' ').collect();
|
||||
// `//host` (protocol-relative) and `\\host` (UNC), with `\` read as `/` the way Windows does.
|
||||
if cleaned.replace('\\', "/").starts_with("//") {
|
||||
@@ -200,6 +206,10 @@ mod tests {
|
||||
"\\\\attacker.example\\share\\x",
|
||||
"/\\attacker.example\\x",
|
||||
" //attacker.example/x",
|
||||
// Characters the exporters drop on writing don't hide a scheme.
|
||||
"file\u{85}:///etc/passwd",
|
||||
"java\u{9F}script:alert(1)",
|
||||
"/\u{FFFE}/attacker.example/x",
|
||||
"",
|
||||
] {
|
||||
assert!(!is_safe_href(bad), "{bad:?}");
|
||||
|
||||
@@ -350,6 +350,7 @@ impl Package {
|
||||
let path = resolve(dir, target);
|
||||
self.canonical(&path).map_or(path, str::to_string)
|
||||
};
|
||||
rels.ids.entry(id.to_string()).or_insert(rels.list.len());
|
||||
rels.list.push(Rel { id: id.to_string(), kind: r.attr("Type").unwrap_or("").to_string(), target: resolved, external });
|
||||
}
|
||||
rels
|
||||
@@ -405,11 +406,13 @@ pub struct Rel {
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Rels {
|
||||
pub list: Vec<Rel>,
|
||||
/// Each id's first relationship in `list`, so a lookup doesn't scan every relationship.
|
||||
ids: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl Rels {
|
||||
pub fn by_id(&self, id: &str) -> Option<&Rel> {
|
||||
self.list.iter().find(|r| r.id == id)
|
||||
self.ids.get(id).and_then(|&i| self.list.get(i))
|
||||
}
|
||||
pub fn by_type(&self, full: &str) -> Option<&Rel> {
|
||||
self.list.iter().find(|r| rel_is(&r.kind, full))
|
||||
@@ -547,6 +550,22 @@ mod tests {
|
||||
assert_eq!(crate::package_title(std::io::Cursor::new(z), 1 << 20).as_deref(), Some("Plan"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relationships_are_looked_up_by_id_the_first_one_winning() {
|
||||
let mut rels = String::from(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#);
|
||||
for i in 0..20_000 {
|
||||
rels.push_str(&format!(r#"<Relationship Id="rId{i}" Type="hyperlink" Target="https://example.com/{i}" TargetMode="External"/>"#));
|
||||
}
|
||||
rels.push_str(r#"<Relationship Id="rId7" Type="hyperlink" Target="https://example.com/dup" TargetMode="External"/></Relationships>"#);
|
||||
let z = zip_of(&[("word/_rels/document.xml.rels", rels.into_bytes())]);
|
||||
let r = Package::open(&z).unwrap().rels("word/document.xml");
|
||||
assert_eq!(r.by_id("rId7").map(|r| r.target.as_str()), Some("https://example.com/7"), "the first rId7 wins");
|
||||
assert_eq!(r.by_id("rId19999").map(|r| r.target.as_str()), Some("https://example.com/19999"));
|
||||
assert!(r.by_id("rId20000").is_none());
|
||||
// Hyperlink-style lookups, one per relationship, are each a map lookup.
|
||||
assert_eq!((0..20_000).filter(|i| r.by_id(&format!("rId{i}")).is_some()).count(), 20_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompression_bomb_is_rejected() {
|
||||
let z = zip_of(&[("word/document.xml", vec![b' '; 2_000_000])]);
|
||||
|
||||
@@ -66,6 +66,9 @@ pub(super) struct Axis {
|
||||
|
||||
pub(super) struct Series {
|
||||
pub(super) name: String,
|
||||
/// The series' `c:idx` (an `xsd:unsignedInt`), which picks its automatic colour; `None`
|
||||
/// when missing or not a valid number.
|
||||
pub(super) idx: Option<usize>,
|
||||
pub(super) color: Option<Rgb>,
|
||||
pub(super) line_w: f32,
|
||||
/// Whether the series is drawn as a line (scatter series without one get markers).
|
||||
|
||||
@@ -91,8 +91,11 @@ impl Chart {
|
||||
self.accents.get(i % 6).copied().unwrap_or(Rgb::BLACK)
|
||||
}
|
||||
|
||||
/// Colour of series `j`: its own, else the accent its `c:idx` picks (as Word does; `c:order`
|
||||
/// doesn't matter), else the one its place in the file picks.
|
||||
pub(super) fn series_colour(&self, j: usize) -> Rgb {
|
||||
self.series.get(j).and_then(|s| s.color).unwrap_or_else(|| self.accent(j))
|
||||
let s = self.series.get(j);
|
||||
s.and_then(|s| s.color).unwrap_or_else(|| self.accent(s.and_then(|s| s.idx).unwrap_or(j)))
|
||||
}
|
||||
|
||||
/// Colour of point `i` of series `j`: its `c:dPt` override, else the varied accent (pie) or
|
||||
@@ -199,6 +202,7 @@ pub(super) fn text_size(space: &El) -> f32 {
|
||||
impl Series {
|
||||
fn parse(ser: &El, scatter: bool, theme: &[Rgb], cap: usize) -> Series {
|
||||
let name = ser.child("c:tx").and_then(|t| t.find("c:v")).map(|v| label(v.text().trim())).unwrap_or_default();
|
||||
let idx = ser.child("c:idx").and_then(|e| e.attr("val")).and_then(|v| v.trim().parse::<u32>().ok()).and_then(|v| usize::try_from(v).ok());
|
||||
let sp = ser.child("c:spPr");
|
||||
let ln = sp.and_then(|p| p.child("a:ln"));
|
||||
let marker = ser.child("c:marker").and_then(|m| m.child("c:spPr")).and_then(|p| fill_colour(p, theme));
|
||||
@@ -219,7 +223,7 @@ impl Series {
|
||||
}
|
||||
}
|
||||
let labels = show_values(ser.child("c:dLbls"));
|
||||
Series { name, color, line_w, line, values, xs, points, fmt, x_fmt, labels, raw: Vec::new() }
|
||||
Series { name, idx, color, line_w, line, values, xs, points, fmt, x_fmt, labels, raw: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -528,3 +528,65 @@ fn negative_sections_and_scientific_numbers() {
|
||||
assert_eq!(format_value(0.0, "0.00E+00", 1.0), "0.00E+00");
|
||||
assert_eq!(format_value(-12345.0, "0.0E-00", 1.0), "-1.2E04");
|
||||
}
|
||||
|
||||
// An automatic series colour follows the series' `c:idx`, not its place in the file (#5963), as
|
||||
// measured in Word: `idx` 3 then 0 are accent4 then accent1, a lone `idx` 2 is accent3, and
|
||||
// `c:order` changes nothing.
|
||||
|
||||
/// The fill colours of the bars of a column chart of `sers`, and of its legend swatches.
|
||||
fn column_colours(sers: &[String]) -> (Vec<Rgb>, Vec<Rgb>) {
|
||||
let plot = format!(
|
||||
r#"<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/><c:varyColors val="0"/>{}<c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/></c:valAx>"#,
|
||||
sers.concat()
|
||||
);
|
||||
let items = with_legend(&plot, "r", 300.0, 200.0);
|
||||
let bars = filled(&items).iter().map(|b| b.0).collect();
|
||||
let keys = items
|
||||
.iter()
|
||||
.filter_map(|it| if let GraphicItem::Shape { rect, fill, .. } = it { (rect[2] == SWATCH).then_some(*fill).flatten() } else { None })
|
||||
.collect();
|
||||
(bars, keys)
|
||||
}
|
||||
|
||||
/// `ser` with its `c:idx` / `c:order` replaced by `idx_order` (an empty string drops them).
|
||||
fn renumbered(ser: &str, idx_order: &str) -> String {
|
||||
let start = ser.find("<c:idx").expect("ser writes c:idx");
|
||||
let end = ser.find("<c:tx>").expect("ser writes c:tx");
|
||||
format!("{}{idx_order}{}", &ser[..start], &ser[end..])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_series_colours_follow_c_idx() {
|
||||
let accent = |k: usize| theme()[4 + k];
|
||||
let a = ser(0, "A", None, &["Q1"], &["1"]);
|
||||
let b = ser(1, "B", None, &["Q1"], &["2"]);
|
||||
// idx 3 first, idx 0 second: accent4, accent1, in the bars and the legend.
|
||||
let sers = [renumbered(&a, r#"<c:idx val="3"/><c:order val="0"/>"#), renumbered(&b, r#"<c:idx val="0"/><c:order val="1"/>"#)];
|
||||
assert_eq!(column_colours(&sers), (vec![accent(3), accent(0)], vec![accent(3), accent(0)]));
|
||||
// A lone series with idx 2 is accent3.
|
||||
let lone = [renumbered(&a, r#"<c:idx val="2"/><c:order val="0"/>"#)];
|
||||
assert_eq!(column_colours(&lone).0, [accent(2)]);
|
||||
// c:order doesn't matter: idx 0 and 1 stay accent1 and accent2 with their orders swapped.
|
||||
let swapped = [renumbered(&a, r#"<c:idx val="0"/><c:order val="1"/>"#), renumbered(&b, r#"<c:idx val="1"/><c:order val="0"/>"#)];
|
||||
assert_eq!(column_colours(&swapped).0, [accent(0), accent(1)]);
|
||||
// A series' own colour still wins.
|
||||
let own = [renumbered(&ser(0, "A", Some("FF0000"), &["Q1"], &["1"]), r#"<c:idx val="3"/><c:order val="0"/>"#)];
|
||||
assert_eq!(column_colours(&own).0, [Rgb(0xFF, 0, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn series_without_a_usable_c_idx_take_their_place() {
|
||||
let accent = |k: usize| theme()[4 + k];
|
||||
let a = ser(0, "A", None, &["Q1"], &["1"]);
|
||||
let b = ser(1, "B", None, &["Q1"], &["2"]);
|
||||
// No c:idx at all: the place in the file.
|
||||
assert_eq!(column_colours(&[renumbered(&a, ""), renumbered(&b, "")]).0, [accent(0), accent(1)]);
|
||||
// Junk, negative and out-of-range values: no panic, the place in the file.
|
||||
for junk in ["x", "-1", "1e3", "99999999999999999999999"] {
|
||||
let sers = [renumbered(&a, &format!(r#"<c:idx val="{junk}"/>"#)), renumbered(&b, &format!(r#"<c:idx val="{junk}"/>"#))];
|
||||
assert_eq!(column_colours(&sers).0, [accent(0), accent(1)], "{junk:?}");
|
||||
}
|
||||
// The largest unsignedInt `c:idx` allows draws, without a panic.
|
||||
let big = [renumbered(&a, &format!(r#"<c:idx val="{}"/>"#, u32::MAX))];
|
||||
assert_eq!(column_colours(&big).0.len(), 1);
|
||||
}
|
||||
@@ -239,7 +239,13 @@ impl Reader<'_> {
|
||||
/// Keep one part's bytes and content type (not its relationships).
|
||||
fn keep_part(&mut self, part: &str) -> bool {
|
||||
let lower = part.to_ascii_lowercase();
|
||||
if !manifest_field_ok(part) || lower.ends_with(".rels") || lower == "[content_types].xml" || part.eq_ignore_ascii_case(&self.main) {
|
||||
// `wordcraft:` names are WordCraft's own bookkeeping in `passthrough`, never a file's part.
|
||||
if !manifest_field_ok(part)
|
||||
|| lower.ends_with(".rels")
|
||||
|| lower == "[content_types].xml"
|
||||
|| lower.starts_with("wordcraft:")
|
||||
|| part.eq_ignore_ascii_case(&self.main)
|
||||
{
|
||||
log::warn!("docx: not carrying part {part:?} for an embedded object");
|
||||
return false;
|
||||
}
|
||||
@@ -388,6 +394,23 @@ mod tests {
|
||||
assert!(roots.contains("keep.bin"), "the part is dropped: {roots}");
|
||||
}
|
||||
|
||||
/// A part named like WordCraft's own bookkeeping (`wordcraft:unmodelled.roots`) is not carried:
|
||||
/// kept, it would be read back as that bookkeeping and add relationships the file chose.
|
||||
#[test]
|
||||
fn a_part_named_like_bookkeeping_is_not_carried() {
|
||||
let forged = b"D\thttp://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\tword/evil.xml\n";
|
||||
let bytes = package(
|
||||
r#"<w:p><w:r><w:t>text</w:t></w:r></w:p>"#,
|
||||
&[("rIdDoc1", "/wordcraft:unmodelled.roots"), ("rIdDoc2", "/wordcraft:embedded.parts")],
|
||||
&[("wordcraft:unmodelled.roots", forged), ("wordcraft:embedded.parts", b"P\tword/evil.xml\tapplication/xml\n")],
|
||||
);
|
||||
let doc = crate::read(&bytes).unwrap();
|
||||
let roots = unmodelled_roots(&doc);
|
||||
assert!(!roots.contains("wordcraft:") && !roots.contains("evil"), "{roots}");
|
||||
let manifest = doc.passthrough.get(crate::package::EMBEDDED_PARTS).map(|v| String::from_utf8_lossy(v).to_string()).unwrap_or_default();
|
||||
assert!(!manifest.contains("evil"), "{manifest}");
|
||||
}
|
||||
|
||||
/// An unqualified attribute is a relationship only where the markup names a relationship
|
||||
/// that way: a hyperlink's `id` names the part, a VML shape's `id` is its own.
|
||||
#[test]
|
||||
|
||||
@@ -15,7 +15,7 @@ use wordcraft_doc::section::{
|
||||
|
||||
use super::MAX_FONT_NAME;
|
||||
use super::drawing_color::adjust_lum;
|
||||
use crate::units::{int, line_multiple, measure, on_off, tw, u32_of};
|
||||
use crate::units::{int, line_multiple, measure, on_off, tw, tw_i16, tw_u16, u32_of};
|
||||
|
||||
/// A text frame (`w:framePr` without a drop cap, ECMA-376 §17.3.1.11), sizes and offsets in
|
||||
/// points (clamped), names kept as OOXML writes them.
|
||||
@@ -407,10 +407,10 @@ impl PropCtx {
|
||||
p.tabs = Some(tabs);
|
||||
}
|
||||
"w:spacing" => {
|
||||
if let Some(v) = tw(k, "w:before") {
|
||||
if let Some(v) = tw_u16(k, "w:before") {
|
||||
p.space_before = Some(v.max(0.0));
|
||||
}
|
||||
if let Some(v) = tw(k, "w:after") {
|
||||
if let Some(v) = tw_u16(k, "w:after") {
|
||||
p.space_after = Some(v.max(0.0));
|
||||
}
|
||||
// HTML-style automatic spacing; "0" switches an inherited one off.
|
||||
@@ -433,15 +433,15 @@ impl PropCtx {
|
||||
}
|
||||
}
|
||||
"w:ind" => {
|
||||
if let Some(v) = tw(k, "w:left").or_else(|| tw(k, "w:start")) {
|
||||
if let Some(v) = tw_i16(k, "w:left").or_else(|| tw_i16(k, "w:start")) {
|
||||
p.indent_left = Some(v);
|
||||
}
|
||||
if let Some(v) = tw(k, "w:right").or_else(|| tw(k, "w:end")) {
|
||||
if let Some(v) = tw_i16(k, "w:right").or_else(|| tw_i16(k, "w:end")) {
|
||||
p.indent_right = Some(v);
|
||||
}
|
||||
if let Some(v) = tw(k, "w:hanging") {
|
||||
if let Some(v) = tw_i16(k, "w:hanging") {
|
||||
p.indent_first = Some(-v);
|
||||
} else if let Some(v) = tw(k, "w:firstLine") {
|
||||
} else if let Some(v) = tw_i16(k, "w:firstLine") {
|
||||
p.indent_first = Some(v);
|
||||
}
|
||||
// Character units (hundredths of a character), which win when non-zero.
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::{
|
||||
AbstractNum, DocxError, El, Level, LevelSuffix, Num, NumFormat, PartFormat, Reader, Style, StyleKind, StyleSheet, TablePart, TableStyleParts,
|
||||
VAlign, flag, int, props, tw, u32_of,
|
||||
};
|
||||
use crate::units::tw_i16;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Built-in style names Word stores in lower case, and how the UI shows them.
|
||||
@@ -317,12 +318,12 @@ impl Reader<'_> {
|
||||
lv.align = props::align(j);
|
||||
}
|
||||
if let Some(ind) = l.child("w:pPr").and_then(|p| p.child("w:ind")) {
|
||||
if let Some(v) = tw(ind, "w:left").or_else(|| tw(ind, "w:start")) {
|
||||
if let Some(v) = tw_i16(ind, "w:left").or_else(|| tw_i16(ind, "w:start")) {
|
||||
lv.indent = v;
|
||||
}
|
||||
if let Some(h) = tw(ind, "w:hanging") {
|
||||
if let Some(h) = tw_i16(ind, "w:hanging") {
|
||||
lv.hanging = h;
|
||||
} else if let Some(f) = tw(ind, "w:firstLine") {
|
||||
} else if let Some(f) = tw_i16(ind, "w:firstLine") {
|
||||
lv.hanging = -f;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,27 @@ pub fn tw(e: &El, attr: &str) -> Option<f32> {
|
||||
e.attr(attr).and_then(|v| measure(v, 20.0))
|
||||
}
|
||||
|
||||
/// Signed twips Word keeps in 16 bits (paragraph and list indents, ECMA-376 Part 1 §17.3.1.12):
|
||||
/// a plain integer outside `i16` keeps its low 16 bits, two's complement, as Word reads it
|
||||
/// (66256 → 720). Values with a unit or a fraction read as [`tw`].
|
||||
pub fn tw_i16(e: &El, attr: &str) -> Option<f32> {
|
||||
let v = e.attr(attr)?;
|
||||
match v.trim().parse::<i64>() {
|
||||
Ok(i) => Some(f32::from(i as i16) / 20.0),
|
||||
Err(_) => measure(v, 20.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Unsigned twips Word keeps in 16 bits (paragraph spacing, §17.3.1.33): a plain integer above
|
||||
/// 65535 keeps its low 16 bits (65537 → 1). Other values read as [`tw`].
|
||||
pub fn tw_u16(e: &El, attr: &str) -> Option<f32> {
|
||||
let v = e.attr(attr)?;
|
||||
match v.trim().parse::<i64>() {
|
||||
Ok(i) if i > 0xFFFF => Some(f32::from((i & 0xFFFF) as u16) / 20.0),
|
||||
_ => measure(v, 20.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// ST_OnOff: absent `w:val` = on; `0`, `false`, `off` = off.
|
||||
pub fn on_off(e: &El) -> bool {
|
||||
match e.attr("w:val") {
|
||||
@@ -148,6 +169,24 @@ pub fn emu(pt: f32) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn twips_beyond_16_bits_wrap() {
|
||||
let el = |a: &str| El { attrs: vec![("w:v".into(), a.into())], ..El::default() };
|
||||
let s = |a: &str| tw_i16(&el(a), "w:v");
|
||||
let u = |a: &str| tw_u16(&el(a), "w:v");
|
||||
assert_eq!(s("66256"), Some(36.0));
|
||||
assert_eq!(s("-65516"), Some(1.0));
|
||||
assert_eq!(s("98304"), Some(-1638.4));
|
||||
assert_eq!(s("32767"), Some(1638.35));
|
||||
assert_eq!(s("-32768"), Some(-1638.4));
|
||||
assert_eq!(s("1in"), Some(72.0));
|
||||
assert_eq!(s("x"), None);
|
||||
assert_eq!(u("65537"), Some(0.05));
|
||||
assert_eq!(u("65535"), Some(3276.75));
|
||||
assert_eq!(u("12pt"), Some(12.0));
|
||||
assert_eq!(u("-20"), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measures() {
|
||||
assert_eq!(measure("1440", 20.0), Some(72.0));
|
||||
|
||||
@@ -353,11 +353,11 @@ impl super::Writer<'_> {
|
||||
};
|
||||
self.embeds.markup_bytes = left;
|
||||
let mut root = xml::parse(src.xml.as_bytes()).ok()?;
|
||||
let mut used = Vec::new();
|
||||
let mut used = HashSet::new();
|
||||
rel_attr_values(&root, &mut used, 0);
|
||||
// Every relationship the markup uses must be writable, or the object would be broken.
|
||||
let writable = |id: &String| src.refs.iter().any(|r| r.id == *id && (r.external || self.embeds.available(&r.target)));
|
||||
if !used.iter().all(writable) {
|
||||
let writable: HashSet<&str> = src.refs.iter().filter(|r| r.external || self.embeds.available(&r.target)).map(|r| r.id.as_str()).collect();
|
||||
if !used.iter().all(|id| writable.contains(id.as_str())) {
|
||||
log::warn!("docx: an embedded object's parts are missing; it is left out");
|
||||
return None;
|
||||
}
|
||||
@@ -638,13 +638,15 @@ fn set_attr(e: &mut El, k: &str, v: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn rel_attr_values(e: &El, out: &mut Vec<String>, depth: usize) {
|
||||
/// The distinct relationship ids `e` uses: a set, so a large tree (a native document's markup
|
||||
/// never went through the reader's id limit) costs no scan per attribute.
|
||||
fn rel_attr_values(e: &El, out: &mut HashSet<String>, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for (k, v) in &e.attrs {
|
||||
if xml::is_rel_attr(&e.name, k) && !out.contains(v) {
|
||||
out.push(v.clone());
|
||||
out.insert(v.clone());
|
||||
}
|
||||
}
|
||||
for c in e.els() {
|
||||
@@ -852,6 +854,24 @@ fn valid_part_name(name: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn relationship_ids_are_collected_once_each() {
|
||||
// A native document's embedded markup skips the reader's id limit: 40,000 distinct ids
|
||||
// (each named twice) were a scan of every id collected so far, per attribute.
|
||||
let mut xml = String::from(
|
||||
r#"<w:p xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">"#,
|
||||
);
|
||||
for i in 0..40_000 {
|
||||
xml.push_str(&format!(r#"<w:r r:id="rId{i}"/><w:r r:id="rId{i}"/>"#));
|
||||
}
|
||||
xml.push_str("</w:p>");
|
||||
let root = xml::parse(xml.as_bytes()).unwrap();
|
||||
let mut used = HashSet::new();
|
||||
rel_attr_values(&root, &mut used, 0);
|
||||
assert_eq!(used.len(), 40_000);
|
||||
assert!(used.contains("rId39999"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn charts_cut_to_the_limits_on_save_say_so_once() {
|
||||
// A model deserialised as is can hold more than a chart part keeps; the save left the rest
|
||||
|
||||
@@ -40,6 +40,7 @@ mod strict_drawings;
|
||||
mod text_alignment;
|
||||
mod text_boxes;
|
||||
mod toc_ranges;
|
||||
mod twips_wrap;
|
||||
mod undefined_style;
|
||||
mod vml_shapes;
|
||||
mod word_xml;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Paragraph indents and spacing written as plain twips beyond 16 bits: Word keeps only the low
|
||||
//! 16 bits (ECMA-376 Part 1 §17.3.1.12 `w:ind`, §17.3.1.33 `w:spacing`). Measured in Word: a
|
||||
//! `w:left` of 66256 starts the text 36 pt in, a `w:right` of 66976 leaves a 72 pt right indent.
|
||||
//! Fixtures are hand-written.
|
||||
|
||||
use wordcraft_doc::Document;
|
||||
use wordcraft_doc::props::ParaProps;
|
||||
|
||||
use crate::fixtures::zip;
|
||||
|
||||
/// A package whose body is `body`.
|
||||
fn docx(body: &str) -> Vec<u8> {
|
||||
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
zip(&[("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())])
|
||||
}
|
||||
|
||||
fn props(body: &str) -> Vec<ParaProps> {
|
||||
let d: Document = wordcraft_docx::read(&docx(body)).unwrap();
|
||||
d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indents_beyond_16_bits_wrap() {
|
||||
let p = props(
|
||||
r#"<w:p><w:pPr><w:ind w:left="66256"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:ind w:right="66976"/></w:pPr><w:r><w:t>b</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:ind w:start="65556" w:end="-65516"/></w:pPr><w:r><w:t>c</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:ind w:left="98304" w:hanging="65896"/></w:pPr><w:r><w:t>d</w:t></w:r></w:p>"#,
|
||||
);
|
||||
assert_eq!(p[0].indent_left, Some(36.0));
|
||||
assert_eq!(p[1].indent_right, Some(72.0));
|
||||
// 65556 -> 20 twips; -65516 -> 20 twips.
|
||||
assert_eq!((p[2].indent_left, p[2].indent_right), (Some(1.0), Some(1.0)));
|
||||
// 98304 = 0x18000 -> 0x8000 = -32768 as a signed 16-bit value; hanging 65896 -> 360.
|
||||
assert_eq!((p[3].indent_left, p[3].indent_first), (Some(-1638.4), Some(-18.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spacing_beyond_16_bits_wraps() {
|
||||
let p = props(r#"<w:p><w:pPr><w:spacing w:before="65537" w:after="65776"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>"#);
|
||||
assert_eq!((p[0].space_before, p[0].space_after), (Some(0.05), Some(12.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_range_and_unit_values_are_unchanged() {
|
||||
let p = props(
|
||||
r#"<w:p><w:pPr><w:ind w:left="32767" w:right="-32768"/><w:spacing w:before="65535"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:ind w:left="1in" w:right="720"/><w:spacing w:before="12pt"/></w:pPr><w:r><w:t>b</w:t></w:r></w:p>"#,
|
||||
);
|
||||
assert_eq!((p[0].indent_left, p[0].indent_right, p[0].space_before), (Some(1638.35), Some(-1638.4), Some(3276.75)));
|
||||
assert_eq!((p[1].indent_left, p[1].indent_right, p[1].space_before), (Some(72.0), Some(36.0), Some(12.0)));
|
||||
}
|
||||
@@ -212,6 +212,32 @@ pub(crate) fn record(s: &mut Session, id: &str, before: &Document) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the tracked paragraph-property change for a command that also changes the paragraph's
|
||||
/// text. The ordinary after-command recorder deliberately compares only paragraphs with identical
|
||||
/// text, so mixed text/paragraph-format transformations use this before assigning their props.
|
||||
/// Made once per command, before it creates revisions: it keeps the revisions as they were then
|
||||
/// (all `Recorder::own` needs), instead of copying the list for every paragraph.
|
||||
pub(crate) struct ParaPropTracker {
|
||||
before: Vec<Revision>,
|
||||
}
|
||||
|
||||
impl ParaPropTracker {
|
||||
pub(crate) fn new(s: &Session) -> Self {
|
||||
let tracking = s.doc.settings.track_changes && s.prefs.markup.track_formatting;
|
||||
ParaPropTracker { before: if tracking { s.doc.revisions.clone() } else { Vec::new() } }
|
||||
}
|
||||
|
||||
pub(crate) fn changed(&self, s: &mut Session, before: &ParaProps, after: &ParaProps) -> Option<Box<PropChange<ParaProps>>> {
|
||||
if !s.doc.settings.track_changes || !s.prefs.markup.track_formatting {
|
||||
return after.fmt_change.clone();
|
||||
}
|
||||
let mut r = Recorder { author: s.author.clone(), date: super::now_iso(), before: &self.before, revisions: &mut s.doc.revisions, rid: None };
|
||||
r.change(before, after, ParaProps::formatting, |p| &p.fmt_change)
|
||||
.map(|Replacement(change)| change)
|
||||
.unwrap_or_else(|| after.fmt_change.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the paragraph formatting a command changed on the paragraph at `path` whose text it also
|
||||
/// changed, which [`record`] doesn't compare: `before` is the paragraph's properties before the
|
||||
/// command. As in [`record`], the author's own earlier change keeps the formatting before it and
|
||||
|
||||
@@ -77,6 +77,43 @@ fn a_shape_dropped_just_after_a_page_break_in_a_paragraph_stays_there() {
|
||||
assert!((o.rect.x - 100.0).abs() < 0.5 && (o.rect.y - 80.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
|
||||
}
|
||||
|
||||
/// Leaf #107: wrapping can leave the anchor alone to the left and put the text on the right
|
||||
/// of the same physical row. A drop after the break must still stay on the destination page.
|
||||
#[test]
|
||||
fn a_picture_with_wrap_spacing_dropped_after_a_leading_break_stays_there() {
|
||||
for gap in [0.0, 6.0, 12.0] {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Source\nDestination"));
|
||||
s.run("caret.set", &json!({"pos": Pos::body(1, 0)})).unwrap();
|
||||
s.run("layout.break", &json!({"kind": "page"})).unwrap();
|
||||
s.run("caret.set", &json!({"pos": Pos::body(0, 0)})).unwrap();
|
||||
let data = super::super::insert::base64_encode(&png(20, 10));
|
||||
s.run("insert.picture", &json!({"data": data, "width": 120})).unwrap();
|
||||
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
|
||||
super::select::with_float(&mut s, |f| {
|
||||
f.dist = gap;
|
||||
f.dist_top = gap;
|
||||
f.dist_bottom = gap;
|
||||
})
|
||||
.unwrap();
|
||||
let before = serde_json::to_value(&s.doc.body).unwrap();
|
||||
let depth = s.undo_depth();
|
||||
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 110})).unwrap();
|
||||
let hit = object_area(&mut s);
|
||||
assert_eq!(hit.page, 1, "gap={gap}: {hit:?}");
|
||||
assert!((hit.rect.x - 100.0).abs() < 0.5 && (hit.rect.y - 110.0).abs() < 0.5, "gap={gap}: {:?}", hit.rect);
|
||||
assert_eq!(s.undo_depth(), depth + 1);
|
||||
let after = serde_json::to_value(&s.doc.body).unwrap();
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), before);
|
||||
s.run("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), after);
|
||||
let mut copy = Session::new(wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap());
|
||||
let saved = object_area(&mut copy);
|
||||
assert_eq!(saved.page, 1, "DOCX gap={gap}: {saved:?}");
|
||||
assert!((saved.rect.x - hit.rect.x).abs() < 0.5 && (saved.rect.y - hit.rect.y).abs() < 0.5, "saved at the drop position");
|
||||
}
|
||||
}
|
||||
|
||||
/// What a click and a handle or body drag do in the editor (#142, #82): hit-test the object,
|
||||
/// select it, then resize and move it with `arrange.bounds`.
|
||||
fn click_resize_and_drag(insert: &str, params: Value) {
|
||||
|
||||
@@ -16,6 +16,7 @@ mod lists;
|
||||
mod outline;
|
||||
mod styles;
|
||||
mod tabs_borders;
|
||||
mod tools;
|
||||
|
||||
use format::{align, asian_typography, cur, direction, line_spacing, ls, set_align, set_props, tog};
|
||||
pub use format::{cur as selection_para, fmt, indent, outdent};
|
||||
@@ -26,6 +27,10 @@ use outline::{move_paras, outline_step, sort};
|
||||
use styles::{apply_style, apply_styles, create_style, delete_style, modify_style, set_visibility, style_id, update_to_match};
|
||||
pub use tabs_borders::border_sides;
|
||||
use tabs_borders::{borders, tabs};
|
||||
use tools::{
|
||||
blank_between, convert_breaks, indent_to_spaces, remove_blank, remove_extra_spaces, remove_line_breaks, separate_paragraphs, spaces_to_indent,
|
||||
trim_leading,
|
||||
};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -270,6 +275,26 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
sel_result(s)
|
||||
}),
|
||||
// Advanced paragraph tools (#5043, as in WPS Office): conversions and cleanup over the
|
||||
// selection, or the whole document with `scope: "all"`. One undo step per command.
|
||||
CommandSpec::new("para.convertBreaks", "Convert Line Breaks to Paragraphs", "Home › Paragraph › Paragraph Tools", convert_breaks)
|
||||
.params(r#"{"scope"?: "selection|all"}"#),
|
||||
CommandSpec::new("para.blankBetween", "Insert Blank Paragraphs Between", "Home › Paragraph › Paragraph Tools", blank_between)
|
||||
.params(r#"{"scope"?: "selection|all", "after"?: bool (also after the last paragraph)}"#),
|
||||
CommandSpec::new("para.separateParagraphs", "Separate Paragraphs", "Home › Paragraph › Paragraph Tools", separate_paragraphs)
|
||||
.params(r#"{"scope"?: "selection|all"}"#),
|
||||
CommandSpec::new("para.removeBlank", "Delete Blank Paragraphs", "Home › Paragraph › Paragraph Tools", remove_blank)
|
||||
.params(r#"{"scope"?: "selection|all"}"#),
|
||||
CommandSpec::new("para.trimLeadingSpaces", "Remove Leading Spaces", "Home › Paragraph › Paragraph Tools", trim_leading)
|
||||
.params(r#"{"scope"?: "selection|all"}"#),
|
||||
CommandSpec::new("para.removeExtraSpaces", "Remove Extra Spaces", "Home › Paragraph › Paragraph Tools", remove_extra_spaces)
|
||||
.params(r#"{"scope"?: "selection|all"}"#),
|
||||
CommandSpec::new("para.spacesToIndent", "Convert Spaces to Indentation", "Home › Paragraph › Paragraph Tools", spaces_to_indent)
|
||||
.params(r#"{"scope"?: "selection|all"}"#),
|
||||
CommandSpec::new("para.indentToSpaces", "Convert Indents to Spaces", "Home › Paragraph › Paragraph Tools", indent_to_spaces)
|
||||
.params(r#"{"scope"?: "selection|all"}"#),
|
||||
CommandSpec::new("para.removeLineBreaks", "Remove Line Breaks", "Home › Paragraph › Paragraph Tools", remove_line_breaks)
|
||||
.params(r#"{"scope"?: "selection|all"}"#),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
//! Advanced paragraph tools (#5043): converting and cleaning up paragraphs, like WPS Office's
|
||||
//! paragraph tools. Each command works on the paragraphs the selection covers, or on the whole
|
||||
//! document with `scope: "all"`, and runs as one undo step. Deletions respect Track Changes.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use wordcraft_doc::para::LINE_BREAK;
|
||||
use wordcraft_doc::{Path, Pos, RevisionKind, StoryRef};
|
||||
|
||||
use super::{CmdError, CmdResult, Session, p, sel_result};
|
||||
use crate::cmd::{has_next_para, new_revision, split_para, track_delete};
|
||||
|
||||
/// The maximum number of paragraphs one call may touch: the work, and the blank paragraphs a
|
||||
/// command can add, stay bounded whatever the document says.
|
||||
const MAX_TOOL_PARAS: usize = 10_000;
|
||||
|
||||
/// Leading spaces one paragraph may gain from an indent, whatever the file's indent says.
|
||||
const MAX_INDENT_SPACES: usize = 1_000;
|
||||
|
||||
/// The scope of a tools command: `{"scope": "selection|all"}`, `selection` by default.
|
||||
/// `all` selects the whole document first, so the command, its undo step and the result all
|
||||
/// follow the full selection like any other command.
|
||||
fn scope_all(s: &mut Session, v: &Value) -> Result<(), CmdError> {
|
||||
match p::str(v, "scope").unwrap_or("selection") {
|
||||
"selection" => Ok(()),
|
||||
"all" => {
|
||||
s.run("select.all", &json!({"whole": true}))?;
|
||||
Ok(())
|
||||
}
|
||||
x => Err(CmdError::Params(format!("unknown scope `{x}` (selection|all)"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse a paragraph rewrite that would touch content locked against editing. Commands operate
|
||||
/// on whole paragraphs, so checking each full paragraph also covers controls outside the exact
|
||||
/// selection offsets in a selected paragraph.
|
||||
fn check_target_edits(s: &mut Session, story: StoryRef, paths: &[Path]) -> Result<(), CmdError> {
|
||||
let ranges: Vec<(Pos, Pos)> = paths
|
||||
.iter()
|
||||
.filter_map(|path| {
|
||||
let len = s.doc.para(story, path)?.len();
|
||||
Some((Pos { story, path: path.clone(), off: 0 }, Pos { story, path: path.clone(), off: len }))
|
||||
})
|
||||
.collect();
|
||||
for (a, b) in ranges {
|
||||
crate::cmd::controls::check_edit(s, &a, &b)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The paragraphs a command runs over, in document order, and the story they are in.
|
||||
fn targets(s: &Session) -> Result<(StoryRef, Vec<Path>), CmdError> {
|
||||
let (a, b) = s.sel.ordered();
|
||||
let paths = s.doc.para_paths_in(&a, &b);
|
||||
if paths.is_empty() {
|
||||
return Err(CmdError::Failed("no paragraphs selected".into()));
|
||||
}
|
||||
if paths.len() > MAX_TOOL_PARAS {
|
||||
return Err(CmdError::Params(format!("select at most {MAX_TOOL_PARAS} paragraphs to process them in one command")));
|
||||
}
|
||||
Ok((a.story, paths))
|
||||
}
|
||||
|
||||
/// Deletes `a..b` inside one paragraph, recording a tracked deletion when Track Changes is on.
|
||||
/// Offsets past the paragraph's end are clamped, so the document is never half-edited.
|
||||
fn delete_in_para(s: &mut Session, story: StoryRef, path: &Path, a: usize, b: usize) -> Result<(), CmdError> {
|
||||
let len = s.doc.para(story, path).ok_or_else(|| CmdError::Failed("no paragraph here".into()))?.len();
|
||||
let (a, b) = (a.min(b).min(len), b.min(len));
|
||||
if a >= b {
|
||||
return Ok(());
|
||||
}
|
||||
let at = |off: usize| Pos { story, path: path.clone(), off };
|
||||
crate::cmd::controls::check_delete(s, &at(a), &at(b))?;
|
||||
if s.doc.settings.track_changes {
|
||||
track_delete(s, &at(a), &at(b))?;
|
||||
} else {
|
||||
s.doc.delete_range(&at(a), &at(b))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The deletions to make in one paragraph, as byte ranges, merged where they touch and applied
|
||||
/// last first so the earlier offsets stay valid.
|
||||
fn apply_deletions(s: &mut Session, story: StoryRef, path: &Path, mut ranges: Vec<(usize, usize)>) -> Result<(), CmdError> {
|
||||
ranges.sort();
|
||||
let mut merged: Vec<(usize, usize)> = Vec::new();
|
||||
for (a, b) in ranges {
|
||||
match merged.last_mut() {
|
||||
Some(last) if a <= last.1 => last.1 = last.1.max(b),
|
||||
_ => merged.push((a, b)),
|
||||
}
|
||||
}
|
||||
for (a, b) in merged.into_iter().rev() {
|
||||
delete_in_para(s, story, path, a, b)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The characters of the paragraph that are not tracked-deleted, as (byte offset, char). Under
|
||||
/// Track Changes deleted text stays in `para.text`, so every scan that decides what to delete or
|
||||
/// count goes through this.
|
||||
fn live_chars(para: &wordcraft_doc::Paragraph) -> impl Iterator<Item = (usize, char)> + '_ {
|
||||
para.run_ranges().filter(|(_, c)| c.del.is_none()).flat_map(|(r, _)| para.text[r.clone()].char_indices().map(move |(i, c)| (r.start + i, c)))
|
||||
}
|
||||
|
||||
/// The byte ranges of every manual line break in the paragraph that is not already deleted.
|
||||
fn break_ranges(para: &wordcraft_doc::Paragraph) -> Vec<(usize, usize)> {
|
||||
live_chars(para).filter(|(_, c)| *c == LINE_BREAK).map(|(i, _)| (i, i + 1)).collect()
|
||||
}
|
||||
|
||||
/// `para.convertBreaks`: every manual line break (Shift+Enter) in the selection becomes a
|
||||
/// paragraph break. Paragraphs are handled last first: a split only shifts the paths after it.
|
||||
pub(super) fn convert_breaks(s: &mut Session, v: &Value) -> CmdResult {
|
||||
scope_all(s, v)?;
|
||||
let (story, paths) = targets(s)?;
|
||||
check_target_edits(s, story, &paths)?;
|
||||
let breaks: usize = paths.iter().filter_map(|path| s.doc.para(story, path)).map(|p| break_ranges(p).len()).sum();
|
||||
if breaks > MAX_TOOL_PARAS {
|
||||
return Err(CmdError::Params(format!("convert at most {MAX_TOOL_PARAS} line breaks at once")));
|
||||
}
|
||||
for path in paths.iter().rev() {
|
||||
let Some(para) = s.doc.para(story, path) else { continue };
|
||||
for (i, _) in break_ranges(para).into_iter().rev() {
|
||||
let new = split_para(s, &Pos { story, path: path.clone(), off: i })?;
|
||||
// The break stays at the head of the new paragraph; the paragraph break replaces it.
|
||||
delete_in_para(s, story, &new.path, 0, 1)?;
|
||||
}
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// `para.blankBetween`: insert one empty paragraph between the selected paragraphs; `after`
|
||||
/// adds one blank paragraph after the last selected paragraph too.
|
||||
pub(super) fn blank_between(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let after = p::bool(v, "after").unwrap_or(false);
|
||||
scope_all(s, v)?;
|
||||
let (story, paths) = targets(s)?;
|
||||
check_target_edits(s, story, &paths)?;
|
||||
let count = paths.len().saturating_sub(1) + usize::from(after);
|
||||
if count > MAX_TOOL_PARAS {
|
||||
return Err(CmdError::Params(format!("insert at most {MAX_TOOL_PARAS} blank paragraphs at once")));
|
||||
}
|
||||
// Last first, each split at a paragraph's end: a split only shifts the paths after it.
|
||||
if after {
|
||||
let last = paths.last().ok_or_else(|| CmdError::Failed("no paragraph selected".into()))?;
|
||||
let len = s.doc.para(story, last).map(|p| p.len()).unwrap_or(0);
|
||||
split_para(s, &Pos { story, path: last.clone(), off: len })?;
|
||||
}
|
||||
for path in paths[..paths.len().saturating_sub(1)].iter().rev() {
|
||||
let len = s.doc.para(story, path).map(|p| p.len()).unwrap_or(0);
|
||||
split_para(s, &Pos { story, path: path.clone(), off: len })?;
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// `para.separateParagraphs`: every selected paragraph becomes a paragraph of its own with a
|
||||
/// blank line between them.
|
||||
pub(super) fn separate_paragraphs(s: &mut Session, v: &Value) -> CmdResult {
|
||||
blank_between(s, &json!({"scope": p::str(v, "scope").unwrap_or("selection"), "after": true}))
|
||||
}
|
||||
|
||||
/// `para.removeBlank`: delete every empty paragraph in the selection.
|
||||
pub(super) fn remove_blank(s: &mut Session, v: &Value) -> CmdResult {
|
||||
scope_all(s, v)?;
|
||||
let (story, paths) = targets(s)?;
|
||||
check_target_edits(s, story, &paths)?;
|
||||
// Last first: removing a block leaves the paths before it as they are.
|
||||
for path in paths.iter().rev() {
|
||||
let Some(para) = s.doc.para(story, path) else { continue };
|
||||
// A paragraph must be empty, carry no section break and have a paragraph after it in
|
||||
// its container: a story's, part's or cell's last mark can't be removed, and a paragraph
|
||||
// before a table can't be joined across it.
|
||||
if !para.is_empty() || para.section.is_some() || !has_next_para(s, story, path) {
|
||||
continue;
|
||||
}
|
||||
let next = path.with_last(path.last() + 1);
|
||||
let (a, b) = (Pos { story, path: path.clone(), off: 0 }, Pos { story, path: next, off: 0 });
|
||||
crate::cmd::controls::check_delete(s, &a, &b)?;
|
||||
if s.doc.settings.track_changes {
|
||||
track_delete(s, &a, &b)?;
|
||||
} else {
|
||||
s.doc.remove_block(story, path)?;
|
||||
}
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// The number of leading spaces in the paragraph's text, deleted ones included (the range
|
||||
/// `trimLeadingSpaces` deletes).
|
||||
fn leading_spaces(para: &wordcraft_doc::Paragraph) -> usize {
|
||||
para.text.chars().take_while(|c| *c == ' ').count()
|
||||
}
|
||||
|
||||
/// The byte offsets of the paragraph's leading spaces that are not tracked-deleted.
|
||||
fn live_leading_spaces(para: &wordcraft_doc::Paragraph) -> Vec<usize> {
|
||||
live_chars(para).take_while(|(_, c)| *c == ' ').map(|(i, _)| i).collect()
|
||||
}
|
||||
|
||||
/// `para.trimLeadingSpaces`: delete the spaces at the start of each selected paragraph.
|
||||
pub(super) fn trim_leading(s: &mut Session, v: &Value) -> CmdResult {
|
||||
scope_all(s, v)?;
|
||||
let (story, paths) = targets(s)?;
|
||||
check_target_edits(s, story, &paths)?;
|
||||
for path in paths {
|
||||
let Some(para) = s.doc.para(story, &path) else { continue };
|
||||
let n = leading_spaces(para);
|
||||
apply_deletions(s, story, &path, vec![(0, n)])?;
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// `para.removeExtraSpaces`: every run of spaces outside the paragraph's start becomes one
|
||||
/// space. Tabs and page breaks are document structure, not redundant spaces, and stay in place.
|
||||
/// Leading spaces stay (that is `para.trimLeadingSpaces`).
|
||||
pub(super) fn remove_extra_spaces(s: &mut Session, v: &Value) -> CmdResult {
|
||||
scope_all(s, v)?;
|
||||
let (story, paths) = targets(s)?;
|
||||
check_target_edits(s, story, &paths)?;
|
||||
for path in paths {
|
||||
let Some(para) = s.doc.para(story, &path) else { continue };
|
||||
// Runs of live spaces (deleted characters are skipped, as if absent). A run at the
|
||||
// paragraph's start stays; a run inside the text collapses to its last space, and one
|
||||
// that reaches the paragraph's end to its first.
|
||||
let mut runs = Vec::new();
|
||||
let mut run: Vec<usize> = Vec::new();
|
||||
let mut leading = true;
|
||||
for (i, c) in live_chars(para) {
|
||||
if c == ' ' {
|
||||
run.push(i);
|
||||
continue;
|
||||
}
|
||||
if !leading && run.len() > 1 {
|
||||
runs.extend(run[..run.len() - 1].iter().map(|&a| (a, a + 1)));
|
||||
}
|
||||
run.clear();
|
||||
leading = false;
|
||||
}
|
||||
if !leading && run.len() > 1 {
|
||||
runs.extend(run[1..].iter().map(|&a| (a, a + 1)));
|
||||
}
|
||||
apply_deletions(s, story, &path, runs)?;
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// `para.spacesToIndent`: each paragraph's leading spaces become a first-line indent of 72 pt
|
||||
/// per space (Word's point-based indent from typing spaces), and are deleted.
|
||||
pub(super) fn spaces_to_indent(s: &mut Session, v: &Value) -> CmdResult {
|
||||
scope_all(s, v)?;
|
||||
let (story, paths) = targets(s)?;
|
||||
check_target_edits(s, story, &paths)?;
|
||||
let tracker = crate::cmd::fmt_revisions::ParaPropTracker::new(s);
|
||||
for path in paths {
|
||||
let Some(para) = s.doc.para(story, &path) else { continue };
|
||||
let live = live_leading_spaces(para);
|
||||
let spaces = live.len();
|
||||
if spaces == 0 {
|
||||
continue;
|
||||
}
|
||||
let current = para.props.indent_first.unwrap_or(0.0).clamp(-1584.0, 1584.0);
|
||||
let represented = spaces.min(((1584.0 - current).max(0.0) / 72.0).floor() as usize);
|
||||
if represented == 0 {
|
||||
continue;
|
||||
}
|
||||
let before = para.props.clone();
|
||||
let mut after = before.clone();
|
||||
after.indent_first = Some(current + represented as f32 * 72.0);
|
||||
after.cancel_char_indents(false, false, true);
|
||||
after.fmt_change = tracker.changed(s, &before, &after);
|
||||
{
|
||||
let p = s.doc.para_mut(story, &path)?;
|
||||
p.props = after;
|
||||
p.touch();
|
||||
}
|
||||
// A space is one byte; deleted spaces in between stay as they are.
|
||||
apply_deletions(s, story, &path, live[..represented].iter().map(|&a| (a, a + 1)).collect())?;
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// `para.indentToSpaces`: each paragraph's first-line and left indents become leading spaces
|
||||
/// (72 pt per space, as typing spaces indents), and the indents go to zero.
|
||||
pub(super) fn indent_to_spaces(s: &mut Session, v: &Value) -> CmdResult {
|
||||
scope_all(s, v)?;
|
||||
let (story, paths) = targets(s)?;
|
||||
check_target_edits(s, story, &paths)?;
|
||||
let tracker = crate::cmd::fmt_revisions::ParaPropTracker::new(s);
|
||||
for path in paths {
|
||||
let Some(para) = s.doc.para(story, &path) else { continue };
|
||||
let pts = para.props.indent_left.unwrap_or(0.0) + para.props.indent_first.unwrap_or(0.0);
|
||||
let n = (pts / 72.0).round().clamp(0.0, MAX_INDENT_SPACES as f32) as usize;
|
||||
if n == 0 && para.props.indent_left.unwrap_or(0.0) == 0.0 && para.props.indent_first.unwrap_or(0.0) == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let mut props = s.doc.para(story, &path).map(|p| p.props_at(0)).cloned().unwrap_or_default();
|
||||
// The revision first: `new_revision` edits `s.doc`, so it runs before the paragraph borrow.
|
||||
// The spaces are marked as a tracked insertion through `props.ins`, as typing is.
|
||||
props.fmt_change = None;
|
||||
props.del = None;
|
||||
if s.doc.settings.track_changes && n > 0 {
|
||||
props.ins = Some(new_revision(s, RevisionKind::Insert));
|
||||
} else {
|
||||
props.ins = None;
|
||||
}
|
||||
let before = s.doc.para(story, &path).map(|p| p.props.clone()).unwrap_or_default();
|
||||
let mut after = before.clone();
|
||||
after.indent_first = Some(0.0);
|
||||
after.indent_left = Some(0.0);
|
||||
after.cancel_char_indents(true, false, true);
|
||||
after.fmt_change = tracker.changed(s, &before, &after);
|
||||
let p = s.doc.para_mut(story, &path)?;
|
||||
if n > 0 {
|
||||
p.insert_text(0, &" ".repeat(n), &props)?;
|
||||
}
|
||||
p.props = after;
|
||||
p.touch();
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// `para.removeLineBreaks`: delete every manual line break in the selection, so the lines
|
||||
/// join into one paragraph.
|
||||
pub(super) fn remove_line_breaks(s: &mut Session, v: &Value) -> CmdResult {
|
||||
scope_all(s, v)?;
|
||||
let (story, paths) = targets(s)?;
|
||||
check_target_edits(s, story, &paths)?;
|
||||
for path in paths {
|
||||
let Some(para) = s.doc.para(story, &path) else { continue };
|
||||
apply_deletions(s, story, &path, break_ranges(para))?;
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
@@ -14,7 +14,7 @@ mod enter_tab;
|
||||
mod unicode;
|
||||
|
||||
use autoformat::insert;
|
||||
use delete::{backspace, delete, delete_word_back, delete_word_fwd};
|
||||
use delete::{backspace, delete, delete_sentence_back, delete_word_back, delete_word_fwd};
|
||||
pub use enter_tab::MAX_TYPED_PARAGRAPHS;
|
||||
use enter_tab::{back_tab, new_paragraph, tab};
|
||||
pub use unicode::{alt_code_char, oem_code_page, system_code_pages, system_oem_code_page, windows_code_pages};
|
||||
@@ -36,6 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("text.backspace", "Backspace", "Editing", backspace).key("Backspace"),
|
||||
CommandSpec::new("text.delete", "Delete", "Editing", delete).key("Delete"),
|
||||
CommandSpec::new("text.deleteWordBack", "Delete Previous Word", "Editing", delete_word_back).key("Mod+Backspace / Alt+Backspace"),
|
||||
CommandSpec::new("text.deleteSentenceBack", "Delete Previous Sentence", "Editing", delete_sentence_back).key("Mod+Shift+Backspace"),
|
||||
CommandSpec::new("text.deleteWordForward", "Delete Next Word", "Editing", delete_word_fwd).key("Mod+Delete / Alt+Delete"),
|
||||
CommandSpec::new("text.nbsp", "Nonbreaking Space", "Insert › Symbols", |s, _| ins_char(s, NBSP)).key("Mod+Shift+Space"),
|
||||
CommandSpec::new("text.nbHyphen", "Nonbreaking Hyphen", "Insert › Symbols", |s, _| ins_char(s, NB_HYPHEN)).key("Mod+Shift+-"),
|
||||
|
||||
@@ -237,3 +237,20 @@ pub(super) fn delete_word_fwd(s: &mut Session, v: &Value) -> CmdResult {
|
||||
crate::cmd::controls::restore_placeholder(s)?;
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Delete to the current sentence start, or the preceding sentence when exactly at its boundary.
|
||||
/// Sentence boundaries match Select Sentence. Never remove a paragraph or cell boundary.
|
||||
pub(super) fn delete_sentence_back(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if !s.sel.is_collapsed() {
|
||||
return backspace(s, v);
|
||||
}
|
||||
let mut f = s.sel.focus.clone();
|
||||
let Some(para) = s.doc.para_at(&f) else { return sel_result(s) };
|
||||
f.off = para.clamp(f.off);
|
||||
let Some((previous, _)) = para.text.get(..f.off).and_then(|text| text.char_indices().next_back()) else { return sel_result(s) };
|
||||
let start = para.sentence_at(previous).0;
|
||||
s.sel = Selection { anchor: Pos { off: start, ..f.clone() }, focus: f };
|
||||
delete_selection(s)?;
|
||||
crate::cmd::controls::restore_placeholder(s)?;
|
||||
sel_result(s)
|
||||
}
|
||||
@@ -341,3 +341,42 @@ pub(super) fn customize_ribbon(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
Ok(Value::Object(out))
|
||||
}
|
||||
|
||||
/// The rows of an `insert.spreadsheet` call: `values` (a rectangular array of cells) or `csv`.
|
||||
pub(super) fn spreadsheet_rows(v: &Value) -> Result<Vec<Vec<String>>, CmdError> {
|
||||
// The CSV fallback is for an absent `values`; an explicit null is a malformed argument.
|
||||
let Some(values) = v.get("values") else {
|
||||
return Ok(crate::cmd::mailings::parse_csv(p::str(v, "csv").unwrap_or("A,B,C\n1,2,3\n4,5,6")));
|
||||
};
|
||||
let list = values.as_array().ok_or_else(|| CmdError::Params("`values` must be an array of rows".into()))?;
|
||||
if list.is_empty() || list.len() > 1000 {
|
||||
return Err(CmdError::Params("`values` needs 1 to 1000 rows".into()));
|
||||
}
|
||||
let mut rows: Vec<Vec<String>> = Vec::with_capacity(list.len());
|
||||
for (r, row) in list.iter().enumerate() {
|
||||
let cells = row.as_array().ok_or_else(|| CmdError::Params(format!("`values` row {} is not an array", r + 1)))?;
|
||||
if cells.is_empty() || cells.len() > 63 {
|
||||
return Err(CmdError::Params(format!("`values` row {} needs 1 to 63 cells", r + 1)));
|
||||
}
|
||||
if rows.first().is_some_and(|f| f.len() != cells.len()) {
|
||||
return Err(CmdError::Params(format!(
|
||||
"`values` row {} has {} cells, row 1 has {}",
|
||||
r + 1,
|
||||
cells.len(),
|
||||
rows.first().map_or(0, Vec::len)
|
||||
)));
|
||||
}
|
||||
let mut out = Vec::with_capacity(cells.len());
|
||||
for cell in cells {
|
||||
out.push(match cell {
|
||||
Value::String(x) => x.clone(),
|
||||
Value::Number(x) => x.to_string(),
|
||||
Value::Bool(x) => x.to_string(),
|
||||
Value::Null => String::new(),
|
||||
_ => return Err(CmdError::Params(format!("`values` row {} holds a cell that is not text, a number or a boolean", r + 1))),
|
||||
});
|
||||
}
|
||||
rows.push(out);
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::actions::{
|
||||
customize_keyboard, customize_ribbon, hf_nav, language, macros, preferred_language, quick_parts, search_commands, select_similar,
|
||||
spreadsheet_rows,
|
||||
};
|
||||
use super::autocorrect::autocorrect_options;
|
||||
use super::compare::{COMPARE_PARAMS, compare};
|
||||
@@ -213,10 +214,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.params(r#"{"path": string}"#),
|
||||
CommandSpec::new("insert.spreadsheet", "Spreadsheet Table", "Insert › Tables", |s, v| {
|
||||
let csv = p::str(v, "csv").unwrap_or("A,B,C\n1,2,3\n4,5,6");
|
||||
let rows = crate::cmd::mailings::parse_csv(csv);
|
||||
let rows = spreadsheet_rows(v)?;
|
||||
let cols = rows.iter().map(Vec::len).max().unwrap_or(1).clamp(1, 63);
|
||||
s.run("insert.table", &json!({"rows": rows.len().max(1), "cols": cols, "style": "GridTable1Light"}))?;
|
||||
let style = p::str(v, "style").unwrap_or("GridTable1Light");
|
||||
s.run("insert.table", &json!({"rows": rows.len().max(1), "cols": cols, "style": style}))?;
|
||||
let Some((tp, _, _)) = s.sel.focus.path.cell() else { return sel_result(s) };
|
||||
let t = s.doc.table_mut(s.sel.focus.story, &tp)?;
|
||||
for (r, row) in rows.iter().enumerate() {
|
||||
@@ -226,9 +227,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if p::bool(v, "header").unwrap_or(false)
|
||||
&& let Some(first) = t.rows.first_mut()
|
||||
{
|
||||
first.props.header = true;
|
||||
}
|
||||
sel_result(s)
|
||||
})
|
||||
.params(r#"{"csv"?: string}"#),
|
||||
.params(r#"{"csv"?: string, "values"?: [[cell, …], …] (rows of equal length, at most 1000 rows × 63 columns; wins over csv), "header"?: bool (first row repeats on each page), "style"?: table style name (default GridTable1Light)}"#),
|
||||
CommandSpec::new("layout.linkToPrevious", "Link to Previous", "Header & Footer › Navigation", |s, v| {
|
||||
let header = p::bool(v, "footer").map(|f| !f).unwrap_or(true);
|
||||
let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize;
|
||||
|
||||
@@ -255,6 +255,59 @@ fn proofing_language_options() {
|
||||
assert!(s.run("review.language", &json!({"lang": "<script>"})).is_err());
|
||||
}
|
||||
|
||||
/// #2242: `insert.spreadsheet` fills a table from `values`, with a repeating header and a style.
|
||||
#[test]
|
||||
fn spreadsheet_takes_values_header_and_style() {
|
||||
let mut s = Session::new(Document::from_text(""));
|
||||
s.run("insert.spreadsheet", &json!({"values": [["Month", "Cases"], ["Jan", 12], ["Feb", null]], "header": true, "style": "TableGrid"})).unwrap();
|
||||
let Some(Block::Table(t)) = s.doc.body.iter().map(|b| &**b).find(|b| matches!(b, Block::Table(_))) else { panic!("no table") };
|
||||
assert_eq!((t.rows.len(), t.rows[0].cells.len()), (3, 2));
|
||||
assert!(t.rows[0].props.header && !t.rows[1].props.header);
|
||||
assert_eq!(t.rows[1].cells[1].blocks[0].as_para().unwrap().text, "12");
|
||||
assert_eq!(t.props.style.as_deref(), Some("TableGrid"));
|
||||
}
|
||||
|
||||
/// An explicit `values: null` is a parameter error, not the legacy CSV sample table: the document
|
||||
/// and the undo history stay as they were. Only an absent `values` falls back to `csv`.
|
||||
#[test]
|
||||
fn spreadsheet_rejects_an_explicit_null_values_without_changing_anything() {
|
||||
let mut s = Session::new(Document::from_text("keep"));
|
||||
let depth = s.undo_depth();
|
||||
for args in [json!({"values": null}), json!({"values": null, "csv": "a,b\n1,2"})] {
|
||||
assert!(s.run("insert.spreadsheet", &args).is_err(), "{args}");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "keep");
|
||||
assert!(!s.doc.body.iter().any(|b| matches!(&**b, Block::Table(_))));
|
||||
assert_eq!(s.undo_depth(), depth, "{args}: no undo step");
|
||||
}
|
||||
s.run("insert.spreadsheet", &json!({"csv": "a,b\n1,2"})).unwrap();
|
||||
let Some(Block::Table(t)) = s.doc.body.iter().map(|b| &**b).find(|b| matches!(b, Block::Table(_))) else { panic!("no table") };
|
||||
assert_eq!((t.rows.len(), t.rows[0].cells.len()), (2, 2), "an absent `values` still takes the csv");
|
||||
}
|
||||
|
||||
/// #2242: malformed `values` is an error, never a panic, and leaves the document alone.
|
||||
#[test]
|
||||
fn spreadsheet_rejects_hostile_values() {
|
||||
let wide: Vec<Value> = (0..64).map(|i| json!(i)).collect();
|
||||
let many: Vec<Value> = (0..1001).map(|_| json!(["x"])).collect();
|
||||
for bad in [
|
||||
json!("x"),
|
||||
json!(5),
|
||||
json!([]),
|
||||
json!(["a"]),
|
||||
json!([["a", "b"], ["c"]]),
|
||||
json!([wide]),
|
||||
json!(many),
|
||||
json!([[{"a": 1}]]),
|
||||
json!([[[1]]]),
|
||||
json!([[]]),
|
||||
] {
|
||||
let mut s = Session::new(Document::from_text("keep"));
|
||||
assert!(s.run("insert.spreadsheet", &json!({"values": bad})).is_err(), "{bad}");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "keep");
|
||||
assert!(!s.doc.body.iter().any(|b| matches!(&**b, Block::Table(_))));
|
||||
}
|
||||
}
|
||||
|
||||
/// The inspector's removals delete straight from the document, so they aren't tracked: on a
|
||||
/// document that tracks changes the text would go with no revision to show for it, and the
|
||||
/// revision trail would be broken behind the user's back. The removal is refused instead,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! View tab and status bar: views, zoom, show/hide, panes.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::{Zoom, ZoomFit};
|
||||
use wordcraft_layout::{NumeralMode, ViewMode};
|
||||
@@ -40,6 +41,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("view.readPageColor", "Reading Page Color", "Reading › Layout", read_page_color)
|
||||
.params(r#"{"value": "none" | "sepia" | "inverse"}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("view.paperColor", "Screen Paper Color", "View › Display", |s, v| {
|
||||
let color: PaperColor = serde_json::from_value(v.get("value").cloned().unwrap_or(Value::Null))
|
||||
.map_err(|_| CmdError::Params("screen paper color must be document, green, cream, or gray".into()))?;
|
||||
s.prefs.paper_color = color;
|
||||
if color != PaperColor::Document {
|
||||
s.view.dark_mode = false;
|
||||
}
|
||||
Ok(json!({"paperColor": color}))
|
||||
}).params(r#"{"value": "document" | "green" | "cream" | "gray"}"#).pure(),
|
||||
// Print Layout: hide the white space (margins, headers, footers) between pages. Screen only.
|
||||
CommandSpec::new("view.hideWhiteSpace", "Hide White Space", "View › Show", |s, v| toggle(s, v, |x| &mut x.hide_white_space))
|
||||
.params(r#"{"value"?: bool}"#)
|
||||
@@ -141,6 +151,30 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.key("Mod+Alt+S")
|
||||
.pure(),
|
||||
// View › Window across WordCraft windows (#322): the desktop app's native windows
|
||||
// perform these (see `Windows`), elsewhere they are disabled.
|
||||
CommandSpec::new("view.arrangeAll", "Arrange All", "View › Window", |s, _| {
|
||||
s.ui_requests.push(json!({"windows": "arrange"}));
|
||||
Ok(json!({"windows": s.windows.others.len() + 1}))
|
||||
})
|
||||
.when(windows_available)
|
||||
.pure(),
|
||||
CommandSpec::new("view.sideBySide", "View Side by Side", "View › Window", side_by_side)
|
||||
.params(r#"{"value"?: bool, "window"?: id of the other window (optional when one other window is open)}"#)
|
||||
.when(|s| match windows_available(s) {
|
||||
None if s.windows.side_by_side.is_none() => has_other_window(s),
|
||||
r => r,
|
||||
})
|
||||
.pure(),
|
||||
CommandSpec::new("view.syncScroll", "Synchronous Scrolling", "View › Window", |s, v| {
|
||||
let Some(with) = s.windows.side_by_side else { return Err(CmdError::Disabled("turn on View Side by Side first".into())) };
|
||||
s.windows.sync_scroll = p::bool(v, "value").unwrap_or(!s.windows.sync_scroll);
|
||||
s.ui_requests.push(json!({"windows": "syncScroll", "with": with, "value": s.windows.sync_scroll}));
|
||||
Ok(json!({"value": s.windows.sync_scroll}))
|
||||
})
|
||||
.params(r#"{"value"?: bool}"#)
|
||||
.when(|s| if s.windows.side_by_side.is_some() { None } else { Some("turn on View Side by Side first") })
|
||||
.pure(),
|
||||
// The window fills the screen, OS-level full screen (#980). F11 as in browsers and most
|
||||
// desktop apps; ⌃⌘F first on macOS, where Ctrl is a key of its own (elsewhere Mod is Ctrl,
|
||||
// so "Mod+Ctrl+F" would read Ctrl+Ctrl+F). Escape also leaves it. The front end applies it.
|
||||
@@ -170,6 +204,83 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// View › Window (#322): the other native windows of the app as the window being drawn sees them
|
||||
/// (by window id, the ids of `ui.inspect`'s `windows`), and its Side by Side pairing. The front
|
||||
/// end keeps these current as each window draws; elsewhere (web, CLI, MCP without the app) they
|
||||
/// stay empty and the window commands are disabled.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Windows {
|
||||
/// The host can list and move its windows (the desktop app).
|
||||
pub available: bool,
|
||||
/// The other open windows, by id.
|
||||
pub others: Vec<OtherWindow>,
|
||||
/// View Side by Side is on, with this window.
|
||||
pub side_by_side: Option<u64>,
|
||||
/// Synchronous Scrolling (only while Side by Side is on).
|
||||
pub sync_scroll: bool,
|
||||
}
|
||||
|
||||
/// Another open WordCraft window.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct OtherWindow {
|
||||
pub id: u64,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
fn windows_available(s: &Session) -> Option<&'static str> {
|
||||
if s.windows.available { None } else { Some("only the desktop app has several windows") }
|
||||
}
|
||||
|
||||
fn has_other_window(s: &Session) -> Option<&'static str> {
|
||||
windows_available(s).or(if s.windows.others.is_empty() { Some("no other WordCraft window is open") } else { None })
|
||||
}
|
||||
|
||||
/// The window a command means: `window`, or the only other one.
|
||||
fn pick_window(s: &Session, v: &Value) -> Result<u64, CmdError> {
|
||||
let others = &s.windows.others;
|
||||
match p::u64(v, "window") {
|
||||
Some(id) if others.iter().any(|w| w.id == id) => Ok(id),
|
||||
Some(id) => Err(CmdError::Params(format!("no open window {id}"))),
|
||||
None => match others.as_slice() {
|
||||
[only] => Ok(only.id),
|
||||
[] => Err(CmdError::Disabled("no other WordCraft window is open".into())),
|
||||
several => {
|
||||
let list: Vec<String> = several.iter().map(|w| format!("{} ({})", w.id, w.title)).collect();
|
||||
Err(CmdError::Params(format!("pass `window`: one of {}", list.join(", "))))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// View Side by Side: on with a window (left this one, right that one, scrolling together), or
|
||||
/// off.
|
||||
fn side_by_side(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let on = p::bool(v, "value").unwrap_or(s.windows.side_by_side.is_none() || v.get("window").is_some());
|
||||
if !on {
|
||||
if let Some(id) = s.windows.side_by_side.take() {
|
||||
s.ui_requests.push(json!({"windows": "sideBySide", "off": id}));
|
||||
}
|
||||
s.windows.sync_scroll = false;
|
||||
return Ok(json!({"value": false}));
|
||||
}
|
||||
let id = match (s.windows.side_by_side, v.get("window")) {
|
||||
(Some(id), None) => id,
|
||||
_ => pick_window(s, v)?,
|
||||
};
|
||||
if let Some(old) = s.windows.side_by_side.filter(|old| *old != id) {
|
||||
s.ui_requests.push(json!({"windows": "sideBySide", "off": old}));
|
||||
}
|
||||
// The partner it already has keeps the user's Synchronous Scrolling choice and is not asked
|
||||
// again; a new pairing starts with scrolling on.
|
||||
if s.windows.side_by_side != Some(id) {
|
||||
s.windows.side_by_side = Some(id);
|
||||
s.windows.sync_scroll = true;
|
||||
s.ui_requests.push(json!({"windows": "sideBySide", "with": id}));
|
||||
}
|
||||
Ok(json!({"value": true, "window": id, "syncScroll": s.windows.sync_scroll}))
|
||||
}
|
||||
|
||||
/// The browser keeps full screen to itself (its own F11); the web app can't ask for it.
|
||||
fn full_screen_available(_: &Session) -> Option<&'static str> {
|
||||
cfg!(target_arch = "wasm32").then_some("Use the browser's full screen (F11)")
|
||||
@@ -234,6 +345,40 @@ fn read_line_spacing(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(json!({"spacing": spacing}))
|
||||
}
|
||||
|
||||
/// Screen-only paper presets, saved with user preferences, never in the document.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PaperColor {
|
||||
#[default]
|
||||
Document,
|
||||
Green,
|
||||
Cream,
|
||||
Gray,
|
||||
}
|
||||
|
||||
impl PaperColor {
|
||||
pub const ALL: [Self; 4] = [Self::Document, Self::Green, Self::Cream, Self::Gray];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Document => "Document background",
|
||||
Self::Green => "Soft green",
|
||||
Self::Cream => "Warm cream",
|
||||
Self::Gray => "Soft gray",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn color(self) -> wordcraft_doc::props::Rgb {
|
||||
use wordcraft_doc::props::Rgb;
|
||||
match self {
|
||||
Self::Document => Rgb::WHITE,
|
||||
Self::Green => Rgb(0xCC, 0xE8, 0xCF),
|
||||
Self::Cream => Rgb(0xFA, 0xF0, 0xDC),
|
||||
Self::Gray => Rgb(0xE8, 0xEA, 0xED),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read Mode › View › Page Color, as in Word. Screen only: the document and its own page color
|
||||
/// are unchanged.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
|
||||
@@ -7,6 +7,9 @@ use serde::{Deserialize, Serialize};
|
||||
pub struct Prefs {
|
||||
/// Word Count includes text boxes, footnotes and endnotes (Word's default).
|
||||
pub count_notes: bool,
|
||||
/// On-screen paper for pages without a document background; excluded from saved documents.
|
||||
#[serde(deserialize_with = "lenient_paper_color")]
|
||||
pub paper_color: crate::cmd::view::PaperColor,
|
||||
/// Track Changes Options: what markup shows and how revisions are drawn (per user, as in
|
||||
/// Word).
|
||||
pub markup: wordcraft_layout::display::MarkupOptions,
|
||||
@@ -64,6 +67,7 @@ pub struct Prefs {
|
||||
impl Default for Prefs {
|
||||
fn default() -> Self {
|
||||
Prefs {
|
||||
paper_color: Default::default(),
|
||||
count_notes: true,
|
||||
markup: Default::default(),
|
||||
print_markup: false,
|
||||
@@ -105,6 +109,11 @@ fn lenient_default_language<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Op
|
||||
Ok(value.as_str().and_then(wordcraft_proof::lang::normalize_tag))
|
||||
}
|
||||
|
||||
fn lenient_paper_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result<crate::cmd::view::PaperColor, D::Error> {
|
||||
let value = serde_json::Value::deserialize(d)?;
|
||||
Ok(serde_json::from_value(value).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Ignore an invalid saved Page Color without discarding other preferences.
|
||||
fn lenient_read_page_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result<crate::cmd::view::ReadPageColor, D::Error> {
|
||||
let value = serde_json::Value::deserialize(d)?;
|
||||
|
||||
@@ -183,6 +183,8 @@ pub struct Session {
|
||||
pub merge: crate::cmd::mailings::MergeState,
|
||||
/// Requests from commands to the UI (open a dialog, scroll…), drained by the front end.
|
||||
pub ui_requests: Vec<Value>,
|
||||
/// View › Window: the other WordCraft windows and Side by Side (#322).
|
||||
pub windows: crate::cmd::view::Windows,
|
||||
/// Read Aloud player (Review › Speech).
|
||||
pub read_aloud: crate::speech::ReadAloud,
|
||||
/// Preferences the front end saves between runs.
|
||||
|
||||
@@ -250,6 +250,8 @@ impl Session {
|
||||
author: _,
|
||||
initials: _,
|
||||
painter: _,
|
||||
// What the drawn window knows of the others: its front end sets it as it draws.
|
||||
windows: _,
|
||||
status: _,
|
||||
last_command: _,
|
||||
recording: _,
|
||||
|
||||
@@ -157,6 +157,7 @@ impl Session {
|
||||
ribbon: crate::RibbonLayout::default(),
|
||||
editing_lang: None,
|
||||
read_aloud: Default::default(),
|
||||
windows: Default::default(),
|
||||
password,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
recovery: None,
|
||||
|
||||
@@ -139,6 +139,8 @@ impl Session {
|
||||
pending_ime_application_change: _,
|
||||
ime_origin: _,
|
||||
read_aloud: _,
|
||||
// The other windows, like `view`: not part of the document or its history.
|
||||
windows: _,
|
||||
prefs: _,
|
||||
system_region: _,
|
||||
keymap: _,
|
||||
|
||||
@@ -16,6 +16,7 @@ mod find_apostrophes;
|
||||
mod font_sizes;
|
||||
mod fonts;
|
||||
mod ime_noop;
|
||||
mod para_tools;
|
||||
mod revision_checks;
|
||||
mod typing;
|
||||
mod wordart;
|
||||
@@ -10788,6 +10789,44 @@ fn insert_picture_takes_a_windows_metafile() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_commands_need_the_desktop_and_name_a_window() {
|
||||
use crate::cmd::view::OtherWindow;
|
||||
let mut s = s();
|
||||
// Web, CLI, MCP: one window, so View › Window is disabled.
|
||||
for id in ["view.arrangeAll", "view.sideBySide", "view.syncScroll"] {
|
||||
assert!(s.run(id, &json!({})).is_err(), "{id} without a window host");
|
||||
}
|
||||
s.windows.available = true;
|
||||
run(&mut s, "view.arrangeAll", json!({}));
|
||||
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "arrange"})));
|
||||
assert!(s.run("view.sideBySide", &json!({})).is_err(), "no other window");
|
||||
s.windows.others = vec![OtherWindow { id: 7, title: "Notes".into() }, OtherWindow { id: 9, title: "Draft".into() }];
|
||||
let e = s.run("view.sideBySide", &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("7 (Notes)") && e.contains("9 (Draft)"), "several windows: say which: {e}");
|
||||
assert!(s.run("view.sideBySide", &json!({"window": 8})).is_err(), "an unknown window");
|
||||
assert!(s.run("view.sideBySide", &json!({"window": "9"})).is_err());
|
||||
// Side by Side pairs with one window and scrolls together until turned off.
|
||||
assert!(s.run("view.syncScroll", &json!({})).is_err(), "only while Side by Side is on");
|
||||
let r = run(&mut s, "view.sideBySide", json!({"window": 7}));
|
||||
assert_eq!((r["value"].as_bool(), s.windows.side_by_side, s.windows.sync_scroll), (Some(true), Some(7), true));
|
||||
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "sideBySide", "with": 7})));
|
||||
run(&mut s, "view.syncScroll", json!({}));
|
||||
assert!(!s.windows.sync_scroll);
|
||||
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "syncScroll", "with": 7, "value": false})));
|
||||
// Asking again for the partner it already has keeps scrolling off and pairs nothing anew.
|
||||
let r = run(&mut s, "view.sideBySide", json!({"window": 7, "value": true}));
|
||||
assert_eq!((r["syncScroll"].as_bool(), s.windows.sync_scroll), (Some(false), false));
|
||||
assert!(s.ui_requests.is_empty());
|
||||
// Another partner replaces the first; the bare button turns it off.
|
||||
run(&mut s, "view.sideBySide", json!({"window": 9}));
|
||||
assert_eq!(std::mem::take(&mut s.ui_requests), [json!({"windows": "sideBySide", "off": 7}), json!({"windows": "sideBySide", "with": 9})]);
|
||||
run(&mut s, "view.sideBySide", json!({}));
|
||||
assert_eq!((s.windows.side_by_side, s.windows.sync_scroll), (None, false));
|
||||
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "sideBySide", "off": 9})));
|
||||
assert!(!s.dirty, "window commands never touch the document");
|
||||
}
|
||||
|
||||
/// Change Picture must keep the replacement's own format: the part's name and content type
|
||||
/// come from the extension, so a JPEG stored as `imageN.png` saves declared `image/png` (Word
|
||||
/// and LibreOffice then show a broken picture).
|
||||
@@ -12568,6 +12607,40 @@ fn export_png_rejects_a_page_number_that_does_not_fit() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_paper_color_is_a_persistent_preference_not_document_formatting() {
|
||||
use crate::cmd::view::PaperColor;
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Screen paper test", "raw": true}));
|
||||
let before = serde_json::to_value(&s.doc).unwrap();
|
||||
let docx_before = wordcraft_docx::write(&s.doc).unwrap();
|
||||
s.dirty = false;
|
||||
let dirty = s.dirty;
|
||||
s.view.dark_mode = true;
|
||||
for (value, expected) in
|
||||
[("green", PaperColor::Green), ("cream", PaperColor::Cream), ("gray", PaperColor::Gray), ("document", PaperColor::Document)]
|
||||
{
|
||||
run(&mut s, "view.paperColor", json!({"value": value}));
|
||||
assert_eq!(s.prefs.paper_color, expected);
|
||||
assert_eq!(serde_json::to_value(&s.doc).unwrap(), before);
|
||||
assert_eq!(s.dirty, dirty);
|
||||
assert_eq!(wordcraft_docx::write(&s.doc).unwrap(), docx_before);
|
||||
assert!(!s.view.dark_mode, "choosing a tint turns off dark paper");
|
||||
let saved = serde_json::to_value(&s.prefs).unwrap();
|
||||
let restored: crate::Prefs = serde_json::from_value(saved).unwrap();
|
||||
assert_eq!(restored.paper_color, expected);
|
||||
}
|
||||
for v in [json!({}), json!({"value": "red"}), json!({"value": 42})] {
|
||||
assert!(s.run("view.paperColor", &v).is_err());
|
||||
assert_eq!(s.prefs.paper_color, PaperColor::Document);
|
||||
}
|
||||
let legacy: crate::Prefs = serde_json::from_value(json!({})).unwrap();
|
||||
assert_eq!(legacy.paper_color, PaperColor::Document);
|
||||
let future: crate::Prefs = serde_json::from_value(json!({"paperColor":"future", "countNotes":false})).unwrap();
|
||||
assert_eq!(future.paper_color, PaperColor::Document);
|
||||
assert!(!future.count_notes);
|
||||
}
|
||||
|
||||
/// Font › Emphasis mark puts a mark over (or under) each selected character, and the layout
|
||||
/// draws one per character; Format state reports it, and none takes it off.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
//! Advanced paragraph tools (#5043): conversion and cleanup over the selection or the whole
|
||||
//! document, one undo step, Track Changes respected.
|
||||
|
||||
use serde_json::json;
|
||||
use wordcraft_doc::{InlineObject, Path, Pos, RevisionKind, SectionProps, StoryRef};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn s() -> Session {
|
||||
Session::new(wordcraft_doc::Document::new())
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, v: serde_json::Value) {
|
||||
s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
}
|
||||
|
||||
fn text(s: &Session) -> String {
|
||||
s.doc.plain_text(StoryRef::Body)
|
||||
}
|
||||
|
||||
/// Select the whole document (Mod+A's command).
|
||||
fn select_all(s: &mut Session) {
|
||||
run(s, "select.all", json!({}));
|
||||
}
|
||||
|
||||
/// One paragraph with `\n` as manual line breaks, typed as text.
|
||||
fn type_breaks(s: &mut Session, t: &str) {
|
||||
run(s, "text.insert", json!({"text": t}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_breaks_become_paragraphs() {
|
||||
let mut s = s();
|
||||
type_breaks(&mut s, "one\ntwo\nthree");
|
||||
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 1);
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.convertBreaks", json!({"scope": "all"}));
|
||||
assert_eq!(text(&s), "one\ntwo\nthree", "the breaks are paragraph marks, not text");
|
||||
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 3);
|
||||
// One undo step brings the manual line breaks back.
|
||||
assert!(s.undo());
|
||||
let p0 = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
assert_eq!(p0.text, "one\ntwo\nthree", "back to one paragraph with its breaks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_paragraphs_go_in_between() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "a\nb\nc"}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.blankBetween", json!({"scope": "all"}));
|
||||
assert_eq!(text(&s), "a\n\nb\n\nc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_paragraphs_go_after_too() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "a\nb\nc"}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.blankBetween", json!({"scope": "all", "after": true}));
|
||||
assert_eq!(text(&s), "a\n\nb\n\nc\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_paragraphs_blanks_after_every_one() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "a\nb\nc"}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.separateParagraphs", json!({"scope": "all"}));
|
||||
assert_eq!(text(&s), "a\n\nb\n\nc\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_paragraphs_are_deleted() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "a\n\nb\n\n\nc"}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.removeBlank", json!({"scope": "all"}));
|
||||
assert_eq!(text(&s), "a\nb\nc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_section_break_paragraph_stays() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "a\nb"}));
|
||||
// The second paragraph carries a section break and is emptied: removing it would drop the
|
||||
// section, so it stays.
|
||||
let second = Path::top(1);
|
||||
let p = s.doc.para_mut(StoryRef::Body, &second).unwrap();
|
||||
p.text.clear();
|
||||
p.runs.clear();
|
||||
p.section = Some(Box::new(SectionProps::default()));
|
||||
p.touch();
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.removeBlank", json!({"scope": "all"}));
|
||||
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2, "the section-break paragraph stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leading_spaces_are_removed() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": " a\nb\n c"}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"}));
|
||||
assert_eq!(text(&s), "a\nb\nc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_spaces_collapse_to_one() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "a b c d \n e"}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
|
||||
assert_eq!(text(&s), "a b c d \n e", "inner runs collapse; the leading spaces of the second paragraph stay");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_space_run_at_the_end_collapses() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "a "}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
|
||||
assert_eq!(text(&s), "a ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_spaces_collapse_without_removing_tabs_or_page_breaks() {
|
||||
use wordcraft_doc::para::PAGE_BREAK;
|
||||
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": format!("a \t b {PAGE_BREAK} c\t ")}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
|
||||
assert_eq!(text(&s), format!("a \t b {PAGE_BREAK} c\t "), "only repeated spaces collapse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indented_spaces_become_an_indent() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": " a\nb"}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.spacesToIndent", json!({"scope": "all"}));
|
||||
assert_eq!(text(&s), "a\nb");
|
||||
let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
assert_eq!(p.props.indent_first, Some(216.0), "three spaces are 3 x 72 pt");
|
||||
assert_eq!(s.doc.para(StoryRef::Body, &Path::top(1)).unwrap().props.indent_first, None, "b has no spaces");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spaces_to_indent_keeps_spaces_the_indent_limit_cannot_represent() {
|
||||
let mut many = s();
|
||||
run(&mut many, "document.setText", json!({"text": format!("{}a", " ".repeat(24))}));
|
||||
select_all(&mut many);
|
||||
run(&mut many, "para.spacesToIndent", json!({"scope": "all"}));
|
||||
assert_eq!(text(&many), " a", "22 spaces fit in the 1584 pt maximum; two stay as text");
|
||||
assert_eq!(many.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(1584.0));
|
||||
|
||||
let mut nearly_full = s();
|
||||
run(&mut nearly_full, "document.setText", json!({"text": " a"}));
|
||||
run(&mut nearly_full, "para.indents", json!({"firstLine": 1548.0}));
|
||||
select_all(&mut nearly_full);
|
||||
run(&mut nearly_full, "para.spacesToIndent", json!({"scope": "all"}));
|
||||
assert_eq!(text(&nearly_full), " a", "less than 72 pt of room cannot replace one space");
|
||||
assert_eq!(nearly_full.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(1548.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indents_become_leading_spaces() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "a"}));
|
||||
run(&mut s, "para.indents", json!({"left": 72.0, "firstLine": 144.0}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.indentToSpaces", json!({"scope": "all"}));
|
||||
assert_eq!(text(&s), " a", "72 + 144 pt become three spaces");
|
||||
let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
assert_eq!((p.props.indent_left, p.props.indent_first), (Some(0.0), Some(0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hanging_indent_that_sums_to_zero_is_still_removed() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "a"}));
|
||||
run(&mut s, "para.indents", json!({"left": 72.0, "firstLine": -72.0}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.indentToSpaces", json!({"scope": "all"}));
|
||||
assert_eq!(text(&s), "a", "the cancelling indents need no leading spaces");
|
||||
let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
assert_eq!((p.props.indent_left, p.props.indent_first), (Some(0.0), Some(0.0)));
|
||||
|
||||
let mut tracked = Session::new(wordcraft_doc::Document::new());
|
||||
run(&mut tracked, "document.setText", json!({"text": "a"}));
|
||||
run(&mut tracked, "para.indents", json!({"left": 72.0, "firstLine": -72.0}));
|
||||
run(&mut tracked, "review.trackChanges", json!({"value": true}));
|
||||
select_all(&mut tracked);
|
||||
run(&mut tracked, "para.indentToSpaces", json!({"scope": "all"}));
|
||||
assert!(!tracked.doc.revisions.iter().any(|r| r.kind == RevisionKind::Insert), "no inserted text means no insert revision");
|
||||
assert!(tracked.doc.revisions.iter().any(|r| r.kind == RevisionKind::Format), "the removed hanging indent is tracked as formatting");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_keeps_fields_footnote_anchors_and_mixed_formatting() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "aa bb "}));
|
||||
s.sel = crate::Selection { anchor: Pos::body(0, 4), focus: Pos::body(0, 6) };
|
||||
run(&mut s, "format.bold", json!({"value": true}));
|
||||
s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body));
|
||||
run(&mut s, "insert.field", json!({"instr": "AUTHOR", "result": "Ada"}));
|
||||
let note = s.run("references.footnote", &json!({"text": "keep this note"})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||
s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body));
|
||||
run(&mut s, "text.insert", json!({"text": " tail"}));
|
||||
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
|
||||
|
||||
let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
assert_eq!((p.props_of_char(3).bold, p.props_of_char(4).bold), (Some(true), Some(true)), "the bold run moved but kept its formatting");
|
||||
assert!(p.object_offsets().iter().any(|off| matches!(p.object_at(*off), Some(InlineObject::Field { .. }))), "the field stays");
|
||||
assert!(p.object_offsets().iter().any(|off| matches!(p.object_at(*off), Some(InlineObject::NoteRef { .. }))), "the footnote anchor stays");
|
||||
assert!(s.doc.parts.contains_key(¬e), "the anchored footnote story stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_inside_a_table_keeps_the_table_and_its_cell() {
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
|
||||
run(&mut s, "text.insert", json!({"text": "a b"}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
|
||||
let table = s.doc.body.iter().find_map(|block| block.as_table()).expect("the table stays");
|
||||
let cell = table.rows[0].cells[0].blocks.iter().find_map(|block| block.as_para()).expect("the cell paragraph stays");
|
||||
assert_eq!(cell.text, "a b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_document_scope_from_a_table_cell_reaches_the_body() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "body text"}));
|
||||
s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body));
|
||||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 2}));
|
||||
let table_index = s.doc.body.iter().position(|block| block.as_table().is_some()).expect("the table was inserted") as u32;
|
||||
s.sel = crate::Selection::caret(Pos { story: StoryRef::Body, path: Path(vec![table_index, 0, 0, 0]), off: 0 });
|
||||
run(&mut s, "text.insert", json!({"text": "cell text"}));
|
||||
assert!(s.sel.focus.path.depth() > 0, "the caret is inside the table");
|
||||
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
|
||||
assert!(text(&s).contains("body text"), "body text outside the table was cleaned too: {}", text(&s));
|
||||
let table = s.doc.body.iter().find_map(|block| block.as_table()).expect("the table stays");
|
||||
let cell = table.rows[0].cells[0].blocks.iter().find_map(|block| block.as_para()).expect("the cell stays");
|
||||
assert_eq!(cell.text, "cell text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_tools_refuse_content_locked_controls() {
|
||||
for (text, command) in [("a b", "para.removeExtraSpaces"), ("a\nb", "para.convertBreaks")] {
|
||||
let mut s = s();
|
||||
type_breaks(&mut s, text);
|
||||
select_all(&mut s);
|
||||
run(&mut s, "developer.richText", json!({"tag": "locked"}));
|
||||
run(&mut s, "developer.properties", json!({"control": "locked", "lockEdit": true}));
|
||||
select_all(&mut s);
|
||||
let before = s.doc.clone();
|
||||
let error = s.run(command, &json!({"scope": "all"})).unwrap_err();
|
||||
assert!(error.to_string().contains("can't be edited"), "{command}: {error}");
|
||||
assert_eq!(s.doc, before, "{command} changed locked content");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_breaks_are_removed() {
|
||||
let mut s = s();
|
||||
type_breaks(&mut s, "one\ntwo\nthree");
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.removeLineBreaks", json!({"scope": "all"}));
|
||||
assert_eq!(text(&s), "onetwothree");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_selection_is_the_scope() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "a b\nc d"}));
|
||||
// Only the first paragraph: select from its start to its end.
|
||||
s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 4) };
|
||||
run(&mut s, "para.removeExtraSpaces", json!({}));
|
||||
assert_eq!(text(&s), "a b\nc d", "the second paragraph is untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structural_tools_do_not_cross_the_selection() {
|
||||
let mut breaks = s();
|
||||
type_breaks(&mut breaks, "one\ntwo");
|
||||
run(&mut breaks, "text.newParagraph", json!({}));
|
||||
run(&mut breaks, "text.insert", json!({"text": "outside"}));
|
||||
breaks.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 7) };
|
||||
run(&mut breaks, "para.convertBreaks", json!({}));
|
||||
let last = breaks.doc.para_paths(StoryRef::Body).last().cloned().unwrap();
|
||||
assert_eq!(breaks.doc.para(StoryRef::Body, &last).unwrap().text, "outside");
|
||||
|
||||
let mut between = s();
|
||||
run(&mut between, "document.setText", json!({"text": "a\nb\nc"}));
|
||||
between.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 1) };
|
||||
run(&mut between, "para.blankBetween", json!({}));
|
||||
assert_eq!(text(&between), "a\n\nb\nc", "only a and b get a blank between them");
|
||||
|
||||
let mut separate = s();
|
||||
run(&mut separate, "document.setText", json!({"text": "a\nb\nc"}));
|
||||
separate.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 1) };
|
||||
run(&mut separate, "para.separateParagraphs", json!({}));
|
||||
assert_eq!(text(&separate), "a\n\nb\n\nc", "the blank after b stays before the unselected c");
|
||||
|
||||
let mut blank = s();
|
||||
run(&mut blank, "document.setText", json!({"text": "a\n\nb\n\nc"}));
|
||||
blank.sel = crate::Selection::caret(Pos::body(1, 0));
|
||||
run(&mut blank, "para.removeBlank", json!({}));
|
||||
assert_eq!(text(&blank), "a\nb\n\nc", "the other blank paragraph is outside the selection");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_space_conversions_are_reversible_track_changes() {
|
||||
let mut to_indent = s();
|
||||
run(&mut to_indent, "document.setText", json!({"text": " a"}));
|
||||
run(&mut to_indent, "review.trackChanges", json!({"value": true}));
|
||||
select_all(&mut to_indent);
|
||||
run(&mut to_indent, "para.spacesToIndent", json!({"scope": "all"}));
|
||||
assert_eq!(to_indent.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(144.0));
|
||||
run(&mut to_indent, "review.rejectAll", json!({}));
|
||||
let p = to_indent.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
assert_eq!((p.text.as_str(), p.props.indent_first), (" a", None));
|
||||
|
||||
let mut to_spaces = s();
|
||||
run(&mut to_spaces, "document.setText", json!({"text": "a"}));
|
||||
run(&mut to_spaces, "para.indents", json!({"firstLine": 144.0}));
|
||||
run(&mut to_spaces, "review.trackChanges", json!({"value": true}));
|
||||
select_all(&mut to_spaces);
|
||||
run(&mut to_spaces, "para.indentToSpaces", json!({"scope": "all"}));
|
||||
run(&mut to_spaces, "review.rejectAll", json!({}));
|
||||
let p = to_spaces.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
assert_eq!((p.text.as_str(), p.props.indent_first), ("a", Some(144.0)));
|
||||
|
||||
let mut accepted = s();
|
||||
run(&mut accepted, "document.setText", json!({"text": "a"}));
|
||||
run(&mut accepted, "para.indents", json!({"firstLine": 144.0}));
|
||||
run(&mut accepted, "review.trackChanges", json!({"value": true}));
|
||||
select_all(&mut accepted);
|
||||
run(&mut accepted, "para.indentToSpaces", json!({"scope": "all"}));
|
||||
run(&mut accepted, "review.acceptAll", json!({}));
|
||||
let p = accepted.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
assert_eq!((p.text.as_str(), p.props.indent_first), (" a", Some(0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_to_spaces_does_not_inherit_revision_marks() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "a"}));
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) };
|
||||
run(&mut s, "format.bold", json!({"value": true}));
|
||||
run(&mut s, "text.delete", json!({}));
|
||||
run(&mut s, "review.trackChanges", json!({"value": false}));
|
||||
run(&mut s, "para.indents", json!({"firstLine": 72.0}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.indentToSpaces", json!({"scope": "all"}));
|
||||
let space = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props_of_char(0);
|
||||
assert_eq!((space.ins, space.del, space.fmt_change.as_ref().map(|_| ())), (None, None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converting_too_many_line_breaks_is_atomic() {
|
||||
let original = format!("{}end", "x\n".repeat(10_001));
|
||||
let mut s = s();
|
||||
type_breaks(&mut s, &original);
|
||||
select_all(&mut s);
|
||||
let before = s.doc.clone();
|
||||
let error = s.run("para.convertBreaks", &json!({"scope": "all"})).unwrap_err();
|
||||
assert!(error.to_string().contains("at most"), "{error}");
|
||||
assert_eq!(s.doc, before, "the refused conversion is all-or-nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeating_a_tracked_deletion_creates_no_empty_revision() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": " a"}));
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"}));
|
||||
let revisions = s.doc.revisions.len();
|
||||
run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"}));
|
||||
assert_eq!(s.doc.revisions.len(), revisions, "already-deleted spaces create no revision");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletions_are_recorded_under_track_changes() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "a b\n c"}));
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"}));
|
||||
// The text stays marked deleted, not gone, as Word records the deletion.
|
||||
assert_eq!(text(&s), "a b\n c", "tracked deletions keep their text");
|
||||
assert!(s.doc.revisions.iter().any(|r| r.kind == RevisionKind::Delete));
|
||||
run(&mut s, "review.acceptAll", json!({}));
|
||||
assert_eq!(text(&s), "a b\nc", "accepting removes the leading space");
|
||||
}
|
||||
|
||||
/// Tracked-delete the bytes `a..b` of the first body paragraph (Track Changes must be on).
|
||||
fn track_delete_in_first(s: &mut Session, a: usize, b: usize) {
|
||||
let path = s.doc.para_paths(StoryRef::Body)[0].clone();
|
||||
let at = |off: usize| Pos { story: StoryRef::Body, path: path.clone(), off };
|
||||
crate::cmd::track_delete(s, &at(a), &at(b)).expect("tracked delete");
|
||||
}
|
||||
|
||||
fn first_indent(s: &Session) -> Option<f32> {
|
||||
let path = s.doc.para_paths(StoryRef::Body)[0].clone();
|
||||
s.doc.para(StoryRef::Body, &path).expect("paragraph").props.indent_first
|
||||
}
|
||||
|
||||
fn first_deleted(s: &Session, off: usize) -> bool {
|
||||
let path = s.doc.para_paths(StoryRef::Body)[0].clone();
|
||||
s.doc.para(StoryRef::Body, &path).expect("paragraph").props_of_char(off).del.is_some()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_extra_spaces_skips_tracked_deleted_spaces() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "a b"}));
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
track_delete_in_first(&mut s, 2, 3); // the second space is already deleted
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
|
||||
run(&mut s, "review.acceptAll", json!({}));
|
||||
assert_eq!(text(&s), "a b", "the one live space survives");
|
||||
|
||||
// Three spaces, the middle one already deleted: of the two live ones the last is kept.
|
||||
let mut s = self::s();
|
||||
run(&mut s, "document.setText", json!({"text": "a b"}));
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
track_delete_in_first(&mut s, 2, 3);
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
|
||||
assert!(first_deleted(&s, 1) && first_deleted(&s, 2), "the first and the already deleted space are deleted");
|
||||
assert!(!first_deleted(&s, 3), "the last live space stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spaces_to_indent_twice_does_not_count_deleted_spaces_again() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": " x"}));
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.spacesToIndent", json!({"scope": "all"}));
|
||||
assert_eq!(first_indent(&s), Some(144.0));
|
||||
run(&mut s, "para.spacesToIndent", json!({"scope": "all"}));
|
||||
assert_eq!(first_indent(&s), Some(144.0), "the deleted spaces are not indented a second time");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_breaks_leaves_a_tracked_deleted_break_alone() {
|
||||
let mut s = s();
|
||||
type_breaks(&mut s, "a\nb\nc");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
track_delete_in_first(&mut s, 1, 2);
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.convertBreaks", json!({"scope": "all"}));
|
||||
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2, "only the live break becomes a paragraph break");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_scope_is_refused() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "a b"}));
|
||||
select_all(&mut s);
|
||||
let e = s.run("para.removeExtraSpaces", &json!({"scope": "junk"})).unwrap_err();
|
||||
assert!(format!("{e}").contains("scope"), "{e}");
|
||||
assert_eq!(text(&s), "a b", "the document is unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_many_paragraphs_are_refused_before_any_change() {
|
||||
let text = "x\n".repeat(15_000);
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text(&text));
|
||||
select_all(&mut s);
|
||||
let e = s.run("para.blankBetween", &json!({"scope": "all"})).unwrap_err();
|
||||
assert!(format!("{e}").contains("at most"), "{e}");
|
||||
let kept = s.doc.para_paths(StoryRef::Body).len();
|
||||
assert!((15_000..=15_001).contains(&kept), "nothing changed, {kept}");
|
||||
}
|
||||
@@ -3876,6 +3876,88 @@ fn backspace_cannot_join_into_locked_content_control() {
|
||||
assert_eq!(s.doc, before);
|
||||
}
|
||||
|
||||
// #3693 is an additional editing shortcut, not a claim of Word's default key behaviour.
|
||||
#[test]
|
||||
fn delete_previous_sentence_respects_caret_boundaries_and_unicode() {
|
||||
for (text, before, expected) in [
|
||||
("First sentence. Second sentence.", "First sentence. Second sentence.", "First sentence. "),
|
||||
("First sentence. Second sentence.", "First sentence. Sec", "First sentence. ond sentence."),
|
||||
("First sentence. Second sentence.", "First sentence. ", "Second sentence."),
|
||||
("Grüße! Überall café.", "Grüße! Überall café.", "Grüße! "),
|
||||
("No punctuation here", "No punctuation", " here"),
|
||||
("One. Two.", "", "One. Two."),
|
||||
("", "", ""),
|
||||
] {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": text, "raw": true}));
|
||||
let mut pos = s.sel.focus.clone();
|
||||
pos.off = before.len();
|
||||
s.sel = crate::Selection::caret(pos);
|
||||
run(&mut s, "text.deleteSentenceBack", json!({}));
|
||||
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, expected, "{text:?} at {before:?}");
|
||||
if expected != text {
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, text);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_previous_sentence_keeps_paragraph_boundaries_and_deletes_selections() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "First.\nSecond sentence.", "raw": true, "paragraphs": true}));
|
||||
let end = s.sel.focus.clone();
|
||||
s.sel = crate::Selection::caret(Pos { off: 0, ..end.clone() });
|
||||
let count = s.doc.body.len();
|
||||
run(&mut s, "text.deleteSentenceBack", json!({}));
|
||||
assert_eq!(s.doc.body.len(), count);
|
||||
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Second sentence.");
|
||||
s.sel = crate::Selection { anchor: Pos { off: 0, ..end.clone() }, focus: Pos { off: 6, ..end } };
|
||||
run(&mut s, "text.deleteSentenceBack", json!({}));
|
||||
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, " sentence.");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Second sentence.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_previous_sentence_tracks_changes_and_respects_read_only() {
|
||||
for accept in [true, false] {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "One. Two.", "raw": true}));
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
run(&mut s, "text.deleteSentenceBack", json!({}));
|
||||
let changes = s.run("review.changes", &json!({})).unwrap();
|
||||
assert!(changes.as_array().unwrap().iter().any(|c| c["kind"] == "delete"));
|
||||
run(&mut s, if accept { "review.acceptAll" } else { "review.rejectAll" }, json!({}));
|
||||
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, if accept { "One. " } else { "One. Two." });
|
||||
}
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Keep this.", "raw": true}));
|
||||
run(&mut s, "review.restrict", json!({"mode": "readOnly"}));
|
||||
assert!(s.run("text.deleteSentenceBack", &json!({})).is_err());
|
||||
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Keep this.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_previous_sentence_does_not_cross_a_table_cell_boundary() {
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 2}));
|
||||
run(&mut s, "text.insert", json!({"text": "First cell.", "raw": true}));
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": "Second cell.", "raw": true}));
|
||||
let end = s.sel.focus.clone();
|
||||
s.sel = crate::Selection::caret(Pos { off: 0, ..end.clone() });
|
||||
let before = s.doc.plain_text(StoryRef::Body);
|
||||
run(&mut s, "text.deleteSentenceBack", json!({}));
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), before);
|
||||
s.sel = crate::Selection::caret(end);
|
||||
run(&mut s, "text.deleteSentenceBack", json!({}));
|
||||
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "");
|
||||
assert!(s.doc.plain_text(StoryRef::Body).contains("First cell."));
|
||||
}
|
||||
|
||||
// English spelling from SCOWL 2026 (CP2): each English reads its own SCOWL dictionary, with
|
||||
// `en-variants.txt` deciding the other dialect's spellings. Typed text, as a person types it.
|
||||
|
||||
|
||||
@@ -104,7 +104,9 @@ pub(super) fn table(toks: &[Tok], spec: &str, depth: usize) -> FTable {
|
||||
for r in rows {
|
||||
let mut cells = Vec::new();
|
||||
let mut g = 0usize;
|
||||
for cell_toks in r {
|
||||
// A document row keeps MAX_COLS cells; the rest would only widen `covering`, which is
|
||||
// walked once per row.
|
||||
for cell_toks in r.into_iter().take(wordcraft_doc::table::MAX_COLS) {
|
||||
let mut cell = Cell::default();
|
||||
let mut content = trim_cell(cell_toks);
|
||||
// `\multicolumn{n}{spec}{content}`, `\multirow{n}{width}{content}`. Each wrapper
|
||||
|
||||
@@ -460,3 +460,14 @@ fn ligature_characters_split_across_runs_stay_apart() {
|
||||
let back = import(tex.as_bytes());
|
||||
assert_eq!(back.plain_text(Default::default()).trim(), "a--b ''x", "{tex}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tabular_row_keeps_the_cells_a_table_row_holds() {
|
||||
// A 5,000-cell row with spans widened the multirow tracking every later row walks; the
|
||||
// document keeps MAX_COLS cells a row, so the rest are left out while parsing.
|
||||
let wide = vec!["\\multicolumn{63}{c}{x}"; 5000].join("&");
|
||||
let toks = tokenize(&format!("{wide}\\\\ a & b \\\\"));
|
||||
let t = table::table(&toks, "", 0);
|
||||
assert_eq!(t.rows.first().map(Vec::len), Some(wordcraft_doc::table::MAX_COLS));
|
||||
assert_eq!(t.rows.get(1).map(Vec::len), Some(2));
|
||||
}
|
||||
@@ -4,11 +4,15 @@
|
||||
//! Quote, Code), list tables (`\listtable` / `\listoverridetable` with `\listtext` labels for
|
||||
//! older readers), document info, page setup, paragraph formatting (`\pard`, alignment, page
|
||||
//! break before), character formatting (`\b \i \ul \strike \super \sub \f \fs \cf \chcbpat`),
|
||||
//! hyperlinks as `HYPERLINK` fields, bookmarks, pictures (`\pngblip` / `\jpegblip`) and tables
|
||||
//! (`\trowd … \cellx … \cell … \row`, header rows, vertical merges, shading).
|
||||
//! hyperlinks as `HYPERLINK` fields, bookmarks, pictures (`\pngblip` / `\jpegblip`), tables
|
||||
//! (`\trowd … \cellx … \cell … \row`, header rows, vertical merges, shading), footnotes and
|
||||
//! endnotes (`{\super\chftn}{\footnote[\ftnalt] …}`) and comments (`{\*\atrfstart n}` …
|
||||
//! `{\*\atrfend n}` around the commented text, then `{\*\atnid initials}{\*\atnauthor name}`
|
||||
//! `\chatn{\*\annotation{\*\atnref n}{\*\atndate …}` text `}`).
|
||||
//!
|
||||
//! Import: a tolerant reader for groups and destinations (unknown `\*` destinations, headers,
|
||||
//! footers, notes and objects are skipped), `\par \line \tab \page`, `\uN` with `\ucN`
|
||||
//! Import: a tolerant reader for groups and destinations (unknown `\*` destinations and objects
|
||||
//! are skipped; headers, footers, notes and comments are read into stories of their own),
|
||||
//! `\par \line \tab \page`, `\uN` with `\ucN`
|
||||
//! fallback skipping, `\'hh` and 8-bit text in the document's code page (`\ansicpg`, `\mac`, `\pc`, `\pca`) or
|
||||
//! the current font's (`\fcharset`, `\cpg`: Cyrillic, Central European, Greek, Mac OS Roman, the
|
||||
//! East Asian multibyte pages 932/936/949/950…; Windows-1252 when none is named). A multibyte
|
||||
@@ -16,13 +20,16 @@
|
||||
//! together and a run that looks like a UTF-8 BOM is not mistaken for one; the formatting words
|
||||
//! above, stylesheet-based
|
||||
//! headings and `\outlinelevel`, lists through `\ls`/`\ilvl`, fields, bookmarks, pictures and
|
||||
//! tables. Group nesting and picture sizes are capped.
|
||||
//! tables, footnotes, endnotes (`\footnote` with `\ftnalt`) and comments (author, initials, date,
|
||||
//! text, the commented range). Group nesting, picture sizes and the number of notes and comments
|
||||
//! are capped. Not kept: custom note marks, comment replies (`\atnparent`), notes inside
|
||||
//! headers, footers and other notes, tables inside note and comment texts (flattened).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::props::VAlign;
|
||||
use wordcraft_doc::{Align, Document, Rgb};
|
||||
use wordcraft_doc::{Align, Block, Blocks, Document, InlineObject, Paragraph, Rgb};
|
||||
|
||||
use crate::model::{self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, RevMark, make_img};
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/// An ISO 8601 date-time from year, month, day, hour and minute.
|
||||
pub(super) fn iso_date((y, mo, d, h, mi): (u32, u32, u32, u32, u32)) -> String {
|
||||
format!("{y:04}-{:02}-{:02}T{:02}:{:02}:00Z", mo.clamp(1, 12), d.clamp(1, 31), h.min(23), mi.min(59))
|
||||
}
|
||||
|
||||
pub(super) fn rtf_date(s: &str) -> Option<String> {
|
||||
let num = |a: usize, b: usize| s.get(a..b).and_then(|x| x.parse::<u32>().ok());
|
||||
let (y, mo, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use super::date::{iso_to_dttm, rtf_date};
|
||||
use super::state::{MARK_CLOSE, MARK_OPEN, MAX_NOTE_BLOCKS};
|
||||
use super::styles::{style_fmt, styled};
|
||||
use super::{Align, Document, FBlock, FTable, Fmt, Inline, Kind, Meta, Para, RevMark, Rgb, VAlign, model};
|
||||
use super::{
|
||||
Align, Arc, Block, Blocks, Document, FBlock, FTable, Fmt, HashSet, Inline, InlineObject, Kind, Meta, Para, Paragraph, RevMark, Rgb, VAlign, model,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Export
|
||||
@@ -66,7 +69,30 @@ impl Tables {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a place marker in the exported text stands for (see [`MARK_OPEN`]).
|
||||
pub(super) enum Mark {
|
||||
Note {
|
||||
endnote: bool,
|
||||
blocks: Vec<FBlock>,
|
||||
},
|
||||
RangeStart(u32),
|
||||
/// The end of a comment's range; the first end of each comment also carries its annotation.
|
||||
RangeEnd {
|
||||
id: u32,
|
||||
ann: Option<Annotation>,
|
||||
},
|
||||
Done,
|
||||
}
|
||||
|
||||
pub(super) struct Annotation {
|
||||
author: String,
|
||||
initials: String,
|
||||
date: String,
|
||||
blocks: Vec<FBlock>,
|
||||
}
|
||||
|
||||
pub(super) struct Writer {
|
||||
pub(super) marks: Vec<Mark>,
|
||||
pub(super) t: Tables,
|
||||
/// List definitions: per list, the ordered flag per level.
|
||||
pub(super) lists: Vec<[bool; 9]>,
|
||||
@@ -81,6 +107,121 @@ pub(super) struct Writer {
|
||||
pub(super) sect_i: usize,
|
||||
}
|
||||
|
||||
pub(super) fn has_marks(bl: &Blocks, depth: usize) -> bool {
|
||||
bl.iter().any(|b| match &**b {
|
||||
Block::Para(p) => {
|
||||
p.objects.iter().any(|o| matches!(o, InlineObject::NoteRef { .. } | InlineObject::CommentStart { .. } | InlineObject::CommentEnd { .. }))
|
||||
}
|
||||
Block::Table(t) => depth < 8 && t.rows.iter().any(|r| r.cells.iter().any(|c| has_marks(&c.blocks, depth + 1))),
|
||||
})
|
||||
}
|
||||
|
||||
/// A copy of `doc` whose note references and comment range ends are place markers in the text
|
||||
/// (see [`MARK_OPEN`]), with what each stands for; none when the body has neither.
|
||||
fn mark_doc(doc: &Document) -> Option<(Document, Vec<Mark>)> {
|
||||
if !has_marks(&doc.body, 0) {
|
||||
return None;
|
||||
}
|
||||
let mut m = Marker { doc, marks: Vec::new(), annotated: HashSet::new() };
|
||||
let body = m.blocks(&doc.body, None, 0);
|
||||
let mut d = doc.clone();
|
||||
d.body = body;
|
||||
Some((d, m.marks))
|
||||
}
|
||||
|
||||
/// The story id standing for "inside a note or comment": no nested notes, no comment ranges.
|
||||
const IN_STORY: u32 = u32::MAX;
|
||||
|
||||
struct Marker<'a> {
|
||||
doc: &'a Document,
|
||||
marks: Vec<Mark>,
|
||||
annotated: HashSet<u32>,
|
||||
}
|
||||
|
||||
impl Marker<'_> {
|
||||
fn token(&mut self, m: Mark) -> String {
|
||||
self.marks.push(m);
|
||||
self.marks.len().saturating_sub(1).to_string()
|
||||
}
|
||||
|
||||
/// The flow of a story (a note's or comment's text); tables are flattened to their paragraphs.
|
||||
fn story(&mut self, bl: &Blocks, note: u32, depth: usize, out: &mut Vec<FBlock>) {
|
||||
for b in bl.iter().take(MAX_NOTE_BLOCKS) {
|
||||
match &**b {
|
||||
Block::Para(p) => {
|
||||
let q = self.para(p, Some(note));
|
||||
out.extend(model::flow_paras(self.doc, &q).into_iter().map(FBlock::Para));
|
||||
}
|
||||
Block::Table(t) if depth < 4 => {
|
||||
for c in t.rows.iter().flat_map(|r| r.cells.iter()) {
|
||||
self.story(&c.blocks, note, depth + 1, out);
|
||||
}
|
||||
}
|
||||
Block::Table(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn blocks(&mut self, bl: &Blocks, note: Option<u32>, depth: usize) -> Blocks {
|
||||
bl.iter()
|
||||
.map(|b| match &**b {
|
||||
Block::Para(p) if p.objects.is_empty() => b.clone(),
|
||||
Block::Para(p) => Arc::new(Block::Para(self.para(p, note))),
|
||||
Block::Table(t) if depth < 8 => {
|
||||
let mut t = t.clone();
|
||||
for c in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
|
||||
c.blocks = self.blocks(&c.blocks, note, depth + 1);
|
||||
}
|
||||
Arc::new(Block::Table(t))
|
||||
}
|
||||
Block::Table(_) => b.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `p` with its note references and comment range ends replaced by place markers. `note` is
|
||||
/// the note being written, when `p` is part of one.
|
||||
fn para(&mut self, p: &Paragraph, note: Option<u32>) -> Paragraph {
|
||||
let mut q = p.clone();
|
||||
for off in p.object_offsets().into_iter().rev() {
|
||||
let Some(obj) = q.object_at(off).cloned() else { continue };
|
||||
let tok = match obj {
|
||||
InlineObject::NoteRef { id, .. } if note == Some(id) => Some(String::from("S")),
|
||||
InlineObject::NoteRef { .. } if note.is_some() => Some(String::new()),
|
||||
InlineObject::NoteRef { id, kind, .. } => self.doc.parts.get(&id).map(|part| {
|
||||
let mut blocks = Vec::new();
|
||||
self.story(&part.blocks, id, 0, &mut blocks);
|
||||
self.token(Mark::Note { endnote: kind == wordcraft_doc::para::NoteKind::Endnote, blocks })
|
||||
}),
|
||||
InlineObject::CommentStart { id } if note.is_none() => Some(self.token(Mark::RangeStart(id))),
|
||||
InlineObject::CommentEnd { id } if note.is_none() => {
|
||||
let ann = if self.annotated.insert(id) { self.annotation(id) } else { None };
|
||||
Some(self.token(Mark::RangeEnd { id, ann }))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let Some(tok) = tok else { continue };
|
||||
let end = off + wordcraft_doc::para::OBJ.len_utf8();
|
||||
let text = if tok.is_empty() { tok } else { format!("{MARK_OPEN}{tok}{MARK_CLOSE}") };
|
||||
if q.delete(off, end).is_err() {
|
||||
continue;
|
||||
}
|
||||
if !text.is_empty() && q.insert_text(off, &text, &Default::default()).is_err() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
q
|
||||
}
|
||||
|
||||
fn annotation(&mut self, id: u32) -> Option<Annotation> {
|
||||
let c = self.doc.comments.get(&id)?;
|
||||
let part = self.doc.parts.get(&c.part)?;
|
||||
let mut blocks = Vec::new();
|
||||
self.story(&part.blocks, IN_STORY, 0, &mut blocks);
|
||||
Some(Annotation { author: c.author.clone(), initials: c.initials.clone(), date: c.date.clone(), blocks })
|
||||
}
|
||||
}
|
||||
|
||||
/// `\sectd` and a section's formatting, in the words the reader reads back.
|
||||
fn sect_words(s: &wordcraft_doc::SectionProps) -> String {
|
||||
use wordcraft_doc::section::{NumFormat, SectionStart};
|
||||
@@ -232,7 +373,68 @@ impl Writer {
|
||||
s
|
||||
}
|
||||
|
||||
/// A run of text; place markers in it become notes and comment ranges.
|
||||
fn run(&mut self, t: &str, f: &Fmt, out: &mut String) {
|
||||
if !t.contains(MARK_OPEN) {
|
||||
return self.run_plain(t, f, out);
|
||||
}
|
||||
let mut rest = t;
|
||||
while !rest.is_empty() {
|
||||
let Some((pre, after)) = rest.split_once(MARK_OPEN) else {
|
||||
self.run_plain(rest, f, out);
|
||||
break;
|
||||
};
|
||||
if !pre.is_empty() {
|
||||
self.run_plain(pre, f, out);
|
||||
}
|
||||
let Some((tok, tail)) = after.split_once(MARK_CLOSE) else {
|
||||
self.run_plain(after, f, out);
|
||||
break;
|
||||
};
|
||||
self.mark(tok, out);
|
||||
rest = tail;
|
||||
}
|
||||
}
|
||||
|
||||
/// The RTF for one place marker: `S` is a note's own number, a number indexes `marks`.
|
||||
fn mark(&mut self, tok: &str, out: &mut String) {
|
||||
if tok == "S" {
|
||||
out.push_str("{\\super\\chftn}");
|
||||
return;
|
||||
}
|
||||
let Some(m) = tok.parse::<usize>().ok().and_then(|i| self.marks.get_mut(i)) else { return };
|
||||
match std::mem::replace(m, Mark::Done) {
|
||||
Mark::Note { endnote, blocks } => {
|
||||
out.push_str("{\\super\\chftn}{\\footnote");
|
||||
if endnote {
|
||||
out.push_str("\\ftnalt");
|
||||
}
|
||||
out.push(' ');
|
||||
self.blocks(&blocks, false, "\\par", out, 0);
|
||||
out.push('}');
|
||||
}
|
||||
Mark::RangeStart(id) => out.push_str(&format!("{{\\*\\atrfstart {id}}}")),
|
||||
Mark::RangeEnd { id, ann } => {
|
||||
out.push_str(&format!("{{\\*\\atrfend {id}}}"));
|
||||
if let Some(a) = ann {
|
||||
out.push_str(&format!(
|
||||
"{{\\*\\atnid {}}}{{\\*\\atnauthor {}}}\\chatn{{\\*\\annotation{{\\*\\atnref {id}}}",
|
||||
esc(&a.initials),
|
||||
esc(&a.author)
|
||||
));
|
||||
if let Some(d) = rtf_date(&a.date) {
|
||||
out.push_str(&format!("{{\\*\\atndate {d}}}"));
|
||||
}
|
||||
out.push('\n');
|
||||
self.blocks(&a.blocks, false, "\\par", out, 0);
|
||||
out.push('}');
|
||||
}
|
||||
}
|
||||
Mark::Done => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_plain(&mut self, t: &str, f: &Fmt, out: &mut String) {
|
||||
let mut ctl = String::new();
|
||||
if let Some(c) = &f.fmt_change {
|
||||
// RTF 1.9.1, as Word writes it: the properties the change altered, as they were,
|
||||
@@ -685,9 +887,15 @@ fn level_def(ordered: bool, l: usize) -> String {
|
||||
|
||||
/// RTF text of a document.
|
||||
pub fn export(doc: &Document) -> String {
|
||||
let (marked, marks) = match mark_doc(doc) {
|
||||
Some((d, m)) => (Some(d), m),
|
||||
None => (None, Vec::new()),
|
||||
};
|
||||
let doc = marked.as_ref().unwrap_or(doc);
|
||||
let flow = model::from_doc_tracked(doc);
|
||||
let base_font = doc.styles.default_chr.font.clone().unwrap_or_else(|| wordcraft_doc::styles::BODY_FONT.to_string());
|
||||
let mut w = Writer {
|
||||
marks,
|
||||
t: Tables { fonts: vec![base_font], colors: Vec::new() },
|
||||
lists: Vec::new(),
|
||||
authors: None,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use super::reader::Reader;
|
||||
use super::state::{Dest, HeaderFooter, HfPages, MAX_PICT, PageGeom};
|
||||
use super::{Cell, Document, FBlock, Flow, Para, model};
|
||||
use std::sync::Arc;
|
||||
use super::state::{CommentData, Dest, HeaderFooter, HfPages, MARK_CLOSE, MARK_OPEN, MAX_PICT, NoteData, PageGeom};
|
||||
use super::{Arc, Block, Blocks, Cell, Document, FBlock, Flow, HashMap, InlineObject, Para, Paragraph, model};
|
||||
|
||||
/// Parse RTF bytes into the flow model.
|
||||
pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
|
||||
@@ -11,7 +10,7 @@ pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
|
||||
/// The flow, the page, and the default font (`\deff`, font 0 when the file names none), which
|
||||
/// is the font of all text that doesn't select one.
|
||||
fn parse_with_page(bytes: &[u8]) -> Result<(Flow, PageGeom, Option<String>), String> {
|
||||
parse_full(bytes).map(|r| (r.flow, r.page, r.default_font))
|
||||
parse_full(bytes, false).map(|r| (r.flow, r.page, r.default_font))
|
||||
}
|
||||
|
||||
/// Everything an import takes from an RTF file.
|
||||
@@ -23,14 +22,19 @@ struct Parsed {
|
||||
default_font: Option<String>,
|
||||
/// Each section's headers and footers, in order, the last section's last.
|
||||
headers_footers: Vec<Vec<(HeaderFooter, Vec<FBlock>)>>,
|
||||
notes: Vec<NoteData>,
|
||||
comments: Vec<CommentData>,
|
||||
}
|
||||
|
||||
fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
|
||||
/// `marks`: also read footnotes, endnotes and comments (as place markers in the flow's text, for
|
||||
/// [`import`] to turn into objects); without it they are skipped.
|
||||
fn parse_full(bytes: &[u8], marks: bool) -> Result<Parsed, String> {
|
||||
let start = bytes.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(0);
|
||||
if !bytes.get(start..).is_some_and(|b| b.starts_with(b"{\\rtf")) {
|
||||
return Err("not an RTF document (it doesn't start with {\\rtf)".into());
|
||||
}
|
||||
let mut r = Reader::default();
|
||||
r.marks_on = marks;
|
||||
let mut i = start;
|
||||
let mut first_in_group = false;
|
||||
let mut starred = false;
|
||||
@@ -106,7 +110,10 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
|
||||
continue;
|
||||
}
|
||||
if r.skip_chars > 0
|
||||
&& matches!(r.st.dest, Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark | Dest::FontTbl | Dest::RevTbl)
|
||||
&& matches!(
|
||||
r.st.dest,
|
||||
Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark | Dest::FontTbl | Dest::RevTbl | Dest::Atn(_)
|
||||
)
|
||||
{
|
||||
r.skip_chars -= 1;
|
||||
first_in_group = false;
|
||||
@@ -206,8 +213,10 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
|
||||
}
|
||||
|
||||
fn finalize(mut r: Reader) -> Parsed {
|
||||
// A header or footer left open by a truncated file ends here, and so does a multibyte run.
|
||||
// A multibyte run ends here, and so does a note, comment, header or footer left open by a
|
||||
// truncated file.
|
||||
r.finish_multibyte();
|
||||
r.end_sub();
|
||||
r.end_header_footer();
|
||||
if r.para_has || !r.para.inlines.is_empty() {
|
||||
r.end_para(false);
|
||||
@@ -234,7 +243,7 @@ fn finalize(mut r: Reader) -> Parsed {
|
||||
blocks.push(FBlock::Para(Para::default()));
|
||||
}
|
||||
let default_font = r.fonts.get(&r.deff).filter(|n| !model::is_mono(n)).map(|n| n.to_string());
|
||||
Parsed { flow: Flow { blocks, meta: r.meta }, page: r.page, last_section, default_font, headers_footers }
|
||||
Parsed { flow: Flow { blocks, meta: r.meta }, page: r.page, last_section, default_font, headers_footers, notes: r.notes, comments: r.comments }
|
||||
}
|
||||
|
||||
/// The column grid of a table from its rows' cell right edges: every distinct edge starts a grid
|
||||
@@ -277,7 +286,7 @@ pub(super) fn column_grid(rows: &mut [Vec<Cell>], rights: &[Vec<i32>]) -> Vec<f3
|
||||
/// Parse RTF bytes into a document.
|
||||
pub fn import(bytes: &[u8]) -> Result<Document, String> {
|
||||
model::begin_import();
|
||||
let Parsed { flow, last_section, default_font, headers_footers, .. } = parse_full(bytes)?;
|
||||
let Parsed { flow, last_section, default_font, headers_footers, notes, comments, .. } = parse_full(bytes, true)?;
|
||||
let mut doc = model::to_doc_in(&flow, last_section);
|
||||
// Each section's own headers and footers; those it leaves out stay linked to the previous.
|
||||
let mut sets = Vec::with_capacity(headers_footers.len());
|
||||
@@ -309,6 +318,7 @@ pub fn import(bytes: &[u8]) -> Result<Document, String> {
|
||||
if let Some((h, f)) = sets.next() {
|
||||
(doc.last_section.headers, doc.last_section.footers) = (h, f);
|
||||
}
|
||||
place_notes_and_comments(&mut doc, notes, comments);
|
||||
// RTF's paragraph defaults are single spacing and no space around paragraphs; what a file
|
||||
// wants beyond that it states on each paragraph.
|
||||
let dp = &mut Arc::make_mut(&mut doc.styles).default_para;
|
||||
@@ -320,3 +330,127 @@ pub fn import(bytes: &[u8]) -> Result<Document, String> {
|
||||
}
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// Turn the place markers the reader left in the text into note references and comment ranges,
|
||||
/// and add the notes' and comments' stories to `doc`.
|
||||
fn place_notes_and_comments(doc: &mut Document, notes: Vec<NoteData>, comments: Vec<CommentData>) {
|
||||
use wordcraft_doc::para::NoteKind;
|
||||
use wordcraft_doc::{Comment, PartKind};
|
||||
if notes.is_empty() && comments.is_empty() {
|
||||
return;
|
||||
}
|
||||
// (kind, part id) per note, by index.
|
||||
let mut note_parts: Vec<(NoteKind, u32)> = Vec::new();
|
||||
for n in notes {
|
||||
let (pk, nk, style) =
|
||||
if n.endnote { (PartKind::Endnote, NoteKind::Endnote, "EndnoteText") } else { (PartKind::Footnote, NoteKind::Footnote, "FootnoteText") };
|
||||
let mut blocks = model::part_blocks(doc, &n.blocks);
|
||||
for b in &mut blocks {
|
||||
if let Block::Para(p) = Arc::make_mut(b)
|
||||
&& p.props.style.is_none()
|
||||
{
|
||||
p.props.style = Some(style.to_string());
|
||||
}
|
||||
}
|
||||
note_parts.push((nk, doc.add_part(pk, blocks)));
|
||||
}
|
||||
// RTF range id to the comment's id in the document.
|
||||
let mut by_rtf: HashMap<i64, u32> = HashMap::new();
|
||||
for (k, c) in comments.into_iter().enumerate() {
|
||||
let id = k as u32;
|
||||
let blocks = model::part_blocks(doc, &c.blocks);
|
||||
let part = doc.add_part(PartKind::Comment, blocks);
|
||||
doc.comments
|
||||
.insert(id, Comment { author: c.author, initials: c.initials, date: c.date, parent: None, resolved: false, part, ..Default::default() });
|
||||
by_rtf.insert(i64::from(c.rtf_id), id);
|
||||
}
|
||||
let ctx = Placer { note_parts, by_rtf };
|
||||
let mut body = std::mem::take(&mut doc.body);
|
||||
ctx.blocks(&mut body, None, 0);
|
||||
doc.body = body;
|
||||
let ids: Vec<u32> = doc.parts.keys().copied().collect();
|
||||
for id in ids {
|
||||
let Some(part) = doc.parts.get_mut(&id) else { continue };
|
||||
let own = ctx.note_parts.iter().find(|(_, p)| *p == id).copied();
|
||||
ctx.blocks(&mut part.blocks, own, 0);
|
||||
// A note starts with its own number, as Word's do.
|
||||
if let Some((kind, pid)) = own
|
||||
&& let Some(first) = part.blocks.first_mut()
|
||||
&& let Block::Para(p) = Arc::make_mut(first)
|
||||
&& !p.objects.iter().any(|o| matches!(o, InlineObject::NoteRef { id, .. } if *id == pid))
|
||||
{
|
||||
let props = Placer::ref_props(kind);
|
||||
if p.insert_object(0, InlineObject::NoteRef { kind, id: pid, custom: String::new() }, &props).is_err() {
|
||||
// The paragraph can't take it; the note simply has no number mark.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Placer {
|
||||
note_parts: Vec<(wordcraft_doc::para::NoteKind, u32)>,
|
||||
by_rtf: HashMap<i64, u32>,
|
||||
}
|
||||
|
||||
impl Placer {
|
||||
fn ref_props(kind: wordcraft_doc::para::NoteKind) -> wordcraft_doc::CharProps {
|
||||
let style = if kind == wordcraft_doc::para::NoteKind::Endnote { "EndnoteReference" } else { "FootnoteReference" };
|
||||
wordcraft_doc::CharProps { style: Some(style.into()), ..Default::default() }
|
||||
}
|
||||
|
||||
fn blocks(&self, bl: &mut Blocks, own: Option<(wordcraft_doc::para::NoteKind, u32)>, depth: usize) {
|
||||
for b in bl.iter_mut() {
|
||||
let needs = match &**b {
|
||||
Block::Para(p) => p.text.contains(MARK_OPEN),
|
||||
Block::Table(_) => depth < 8,
|
||||
};
|
||||
if !needs {
|
||||
continue;
|
||||
}
|
||||
match Arc::make_mut(b) {
|
||||
Block::Para(p) => self.para(p, own),
|
||||
Block::Table(t) => {
|
||||
for r in &mut t.rows {
|
||||
for c in &mut r.cells {
|
||||
self.blocks(&mut c.blocks, own, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn para(&self, p: &mut Paragraph, own: Option<(wordcraft_doc::para::NoteKind, u32)>) {
|
||||
// Every pass removes one marker, so this ends; the bound is only a seat belt.
|
||||
for _ in 0..100_000 {
|
||||
let Some(a) = p.text.find(MARK_OPEN) else { return };
|
||||
let Some(len) = p.text.get(a..).and_then(|t| t.find(MARK_CLOSE)) else { return };
|
||||
let end = a + len + MARK_CLOSE.len_utf8();
|
||||
let inner = p.text.get(a + MARK_OPEN.len_utf8()..a + len).unwrap_or("").to_string();
|
||||
let mut chars = inner.chars();
|
||||
let tag = chars.next().unwrap_or(' ');
|
||||
let n = chars.as_str().parse::<i64>().unwrap_or(i64::MIN);
|
||||
let (obj, props) = match tag {
|
||||
'N' => match usize::try_from(n).ok().and_then(|i| self.note_parts.get(i)) {
|
||||
Some(&(kind, id)) => (Some(InlineObject::NoteRef { kind, id, custom: String::new() }), Self::ref_props(kind)),
|
||||
None => (None, Default::default()),
|
||||
},
|
||||
'S' => match own {
|
||||
Some((kind, id)) => (Some(InlineObject::NoteRef { kind, id, custom: String::new() }), Self::ref_props(kind)),
|
||||
None => (None, Default::default()),
|
||||
},
|
||||
'B' => (self.by_rtf.get(&n).map(|&id| InlineObject::CommentStart { id }), Default::default()),
|
||||
'E' => (self.by_rtf.get(&n).map(|&id| InlineObject::CommentEnd { id }), Default::default()),
|
||||
_ => (None, Default::default()),
|
||||
};
|
||||
if p.delete(a, end).is_err() {
|
||||
return;
|
||||
}
|
||||
if let Some(o) = obj
|
||||
&& p.insert_object(a, o, &props).is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
use super::date::dttm_to_iso;
|
||||
use super::date::{dttm_to_iso, iso_date};
|
||||
use super::parse::column_grid;
|
||||
use super::state::{CellDef, Dest, HeaderFooter, MAX_GROUPS, MAX_REV_AUTHORS, MAX_SECTIONS, Output, PageGeom, SecFmt, St};
|
||||
use super::state::{
|
||||
CellDef, CommentData, Dest, HeaderFooter, MARK_CLOSE, MARK_OPEN, MAX_GROUPS, MAX_NOTE_BLOCKS, MAX_NOTES, MAX_REV_AUTHORS, MAX_SECTIONS, NoteData,
|
||||
Output, PageGeom, SecFmt, St, Sub,
|
||||
};
|
||||
use super::styles::{StyleDef, drop_style_formatting, style_fmt};
|
||||
use super::{Arc, Cell, FBlock, FTable, HashMap, Inline, Kind, Meta, Para, Rgb, model};
|
||||
use super::{Arc, Cell, FBlock, FTable, Fmt, HashMap, HashSet, Inline, Kind, Meta, Para, Rgb, model};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Reader {
|
||||
@@ -91,6 +94,22 @@ pub(super) struct Reader {
|
||||
pub(super) in_creatim: bool,
|
||||
pub(super) rule_pending: bool,
|
||||
pub(super) info_depth: Option<usize>,
|
||||
// Notes and comments (only when the caller wants them).
|
||||
pub(super) marks_on: bool,
|
||||
pub(super) sub: Option<Sub>,
|
||||
pub(super) notes: Vec<NoteData>,
|
||||
pub(super) comments: Vec<CommentData>,
|
||||
pub(super) atn_text: String,
|
||||
pub(super) atn_initials: String,
|
||||
pub(super) atn_author: String,
|
||||
pub(super) atn_date: String,
|
||||
pub(super) atn_parts: Option<(u32, u32, u32, u32, u32)>,
|
||||
pub(super) atn_ref: Option<i32>,
|
||||
pub(super) last_end: Option<i32>,
|
||||
pub(super) range_starts: HashSet<i32>,
|
||||
pub(super) range_ends: HashSet<i32>,
|
||||
pub(super) claimed: HashSet<i32>,
|
||||
pub(super) synth: i32,
|
||||
}
|
||||
|
||||
impl Reader {
|
||||
@@ -152,12 +171,103 @@ impl Reader {
|
||||
}
|
||||
}
|
||||
|
||||
/// A footnote, endnote or comment group starts: read it into an output of its own.
|
||||
pub(super) fn start_sub(&mut self, comment: bool) {
|
||||
if self.overflow > 0 || self.notes.len() >= MAX_NOTES || self.comments.len() >= MAX_NOTES {
|
||||
self.st.dest = Dest::Skip;
|
||||
return;
|
||||
}
|
||||
let saved = self.swap_output(Output::default());
|
||||
self.sub = Some(Sub { depth: self.depth(), comment, endnote: false, saved: Box::new(saved) });
|
||||
self.st.intbl = false;
|
||||
}
|
||||
|
||||
/// A place marker as text in the current paragraph.
|
||||
pub(super) fn push_mark(&mut self, tag: char, n: i64, fmt: Fmt) {
|
||||
self.para.push_text(&format!("{MARK_OPEN}{tag}{n}{MARK_CLOSE}"), &fmt);
|
||||
self.para_has = true;
|
||||
}
|
||||
|
||||
/// The footnote or comment group closed: keep its text and leave a marker in the body.
|
||||
pub(super) fn end_sub(&mut self) {
|
||||
let Some(sub) = self.sub.take() else { return };
|
||||
if self.para_has || !self.para.inlines.is_empty() {
|
||||
self.end_para(false);
|
||||
}
|
||||
self.close_table();
|
||||
let fmt = self.stack.last().map(|s| s.fmt.clone()).unwrap_or_default();
|
||||
let mut inner = self.swap_output(*sub.saved);
|
||||
inner.body.truncate(MAX_NOTE_BLOCKS);
|
||||
if !sub.comment {
|
||||
self.push_mark('N', self.notes.len() as i64, fmt);
|
||||
self.notes.push(NoteData { endnote: sub.endnote, blocks: inner.body });
|
||||
return;
|
||||
}
|
||||
let want = self.atn_ref.take().or(self.last_end.take());
|
||||
let id = match want {
|
||||
Some(i) if self.range_starts.contains(&i) && self.range_ends.contains(&i) && self.claimed.insert(i) => i,
|
||||
_ => {
|
||||
// No range of its own: the comment sits at this point.
|
||||
self.synth = self.synth.saturating_sub(1);
|
||||
let id = self.synth;
|
||||
self.push_mark('B', i64::from(id), fmt.clone());
|
||||
self.push_mark('E', i64::from(id), fmt);
|
||||
id
|
||||
}
|
||||
};
|
||||
let date =
|
||||
if !self.atn_date.is_empty() { std::mem::take(&mut self.atn_date) } else { self.atn_parts.take().map(iso_date).unwrap_or_default() };
|
||||
self.atn_parts = None;
|
||||
self.comments.push(CommentData {
|
||||
rtf_id: id,
|
||||
author: std::mem::take(&mut self.atn_author),
|
||||
initials: std::mem::take(&mut self.atn_initials),
|
||||
date,
|
||||
blocks: inner.body,
|
||||
});
|
||||
}
|
||||
|
||||
/// A comment word's text group closed (`\atrfstart 3`, `\atnauthor Ann`…).
|
||||
pub(super) fn commit_atn(&mut self, kind: u8) {
|
||||
let text = std::mem::take(&mut self.atn_text);
|
||||
let text = text.trim();
|
||||
let id = text.parse::<i32>().ok();
|
||||
let fmt = self.stack.last().map(|s| s.fmt.clone()).unwrap_or_default();
|
||||
match kind {
|
||||
1 => {
|
||||
if let Some(id) = id {
|
||||
self.range_starts.insert(id);
|
||||
self.push_mark('B', i64::from(id), fmt);
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
if let Some(id) = id {
|
||||
self.range_ends.insert(id);
|
||||
self.last_end = Some(id);
|
||||
self.push_mark('E', i64::from(id), fmt);
|
||||
}
|
||||
}
|
||||
3 => self.atn_initials = text.to_string(),
|
||||
4 => self.atn_author = text.to_string(),
|
||||
5 => {
|
||||
// Either `\yr\mo\dy\hr\min` words (read as they came) or a packed date-time number.
|
||||
if let Some(parts) = self.atn_parts.take() {
|
||||
self.atn_date = iso_date(parts);
|
||||
} else if let Some(n) = text.parse::<u32>().ok().filter(|n| *n != 0) {
|
||||
self.atn_date = iso_date((1900 + ((n >> 20) & 0x1FF), (n >> 16) & 0xF, (n >> 11) & 0x1F, (n >> 6) & 0x1F, n & 0x3F));
|
||||
}
|
||||
}
|
||||
6 => self.atn_ref = id,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// `\sect`: end the paragraph, and with it the section, which takes its formatting and the
|
||||
/// headers and footers read since the last break. Inside a header, footer or table, and past
|
||||
/// [`MAX_SECTIONS`], it only ends the paragraph.
|
||||
pub(super) fn end_section(&mut self) {
|
||||
self.end_para_mark();
|
||||
if self.hf.is_some() || self.st.intbl || self.section_parts.len() + 1 >= MAX_SECTIONS {
|
||||
if self.hf.is_some() || self.sub.is_some() || self.st.intbl || self.section_parts.len() + 1 >= MAX_SECTIONS {
|
||||
return;
|
||||
}
|
||||
let props = self.sec.props(&self.page);
|
||||
@@ -175,7 +285,7 @@ impl Reader {
|
||||
let pv = p.unwrap_or(0);
|
||||
// Inside a header or footer the word is read into a scratch copy and dropped.
|
||||
let mut scratch;
|
||||
let sec = if self.hf.is_some() {
|
||||
let sec = if self.hf.is_some() || self.sub.is_some() {
|
||||
scratch = self.sec.clone();
|
||||
&mut scratch
|
||||
} else {
|
||||
@@ -231,6 +341,9 @@ impl Reader {
|
||||
self.overflow -= 1;
|
||||
return;
|
||||
}
|
||||
if self.sub.as_ref().is_some_and(|s| s.depth == self.depth()) {
|
||||
self.end_sub();
|
||||
}
|
||||
let closing = self.st.dest;
|
||||
// Destination-level commits.
|
||||
match closing {
|
||||
@@ -249,6 +362,7 @@ impl Reader {
|
||||
}
|
||||
self.in_creatim = false;
|
||||
}
|
||||
Dest::Atn(k) => self.commit_atn(k),
|
||||
_ => {}
|
||||
}
|
||||
let Some(mut prev) = self.stack.pop() else { return };
|
||||
|
||||
@@ -5,6 +5,15 @@ pub(super) const MAX_GROUPS: usize = 512;
|
||||
|
||||
/// Largest picture we decode (bytes).
|
||||
pub(super) const MAX_PICT: usize = 64 << 20;
|
||||
/// Most notes, and most comments, one file may bring in.
|
||||
pub(super) const MAX_NOTES: usize = 5_000;
|
||||
/// Most blocks kept of one note's or comment's text.
|
||||
pub(super) const MAX_NOTE_BLOCKS: usize = 500;
|
||||
/// Brackets of a place marker in text: a note reference or a comment range end travels through the
|
||||
/// flow model as `OPEN tag number CLOSE` (private-use characters, which both the reader and the
|
||||
/// writer keep out of real text) and becomes an inline object once the document exists.
|
||||
pub(super) const MARK_OPEN: char = '\u{F8F0}';
|
||||
pub(super) const MARK_CLOSE: char = '\u{F8F1}';
|
||||
|
||||
/// Most sections we read (`\sect`); later breaks end paragraphs only.
|
||||
pub(super) const MAX_SECTIONS: usize = 10_000;
|
||||
@@ -25,6 +34,9 @@ pub(super) enum Dest {
|
||||
FldInst,
|
||||
Pict,
|
||||
Bookmark,
|
||||
/// A comment's text-valued word: 1 `\atrfstart`, 2 `\atrfend`, 3 `\atnid`, 4 `\atnauthor`,
|
||||
/// 5 `\atndate`/`\atntime`, 6 `\atnref`.
|
||||
Atn(u8),
|
||||
/// The author table of tracked changes (`\revtbl`).
|
||||
RevTbl,
|
||||
/// Formatting before a tracked change (`\oldpprops` when true, else `\oldcprops`): read
|
||||
@@ -32,6 +44,29 @@ pub(super) enum Dest {
|
||||
OldProps(bool),
|
||||
}
|
||||
|
||||
/// A footnote, endnote or comment group being read into an output of its own.
|
||||
pub(super) struct Sub {
|
||||
pub(super) depth: usize,
|
||||
pub(super) comment: bool,
|
||||
pub(super) endnote: bool,
|
||||
pub(super) saved: Box<Output>,
|
||||
}
|
||||
|
||||
/// A note read: its kind and text.
|
||||
pub(super) struct NoteData {
|
||||
pub(super) endnote: bool,
|
||||
pub(super) blocks: Vec<FBlock>,
|
||||
}
|
||||
|
||||
/// A comment read: the id its range markers carry, who wrote it and when, and its text.
|
||||
pub(super) struct CommentData {
|
||||
pub(super) rtf_id: i32,
|
||||
pub(super) author: String,
|
||||
pub(super) initials: String,
|
||||
pub(super) date: String,
|
||||
pub(super) blocks: Vec<FBlock>,
|
||||
}
|
||||
|
||||
/// A tracked formatting change being read (`\crauth`/`\crdate` with `\oldcprops`,
|
||||
/// `\prauth`/`\prdate` with `\oldpprops`): author (`\revtbl` index), date (DTTM) and the old
|
||||
/// formatting, once its group has been read.
|
||||
|
||||
@@ -1097,6 +1097,7 @@ fn old_formatting_in_a_code_paragraph_has_the_code_font() {
|
||||
let mut p = Para { kind: Kind::Code, ..Default::default() };
|
||||
p.push_text("x", &Fmt { font: Some("Arial".into()), fmt_change: Some(Box::new(FmtChange { mark, old: Fmt::default() })), ..Default::default() });
|
||||
let mut w = Writer {
|
||||
marks: Vec::new(),
|
||||
t: Tables { fonts: vec!["Aptos".into()], colors: Vec::new() },
|
||||
lists: Vec::new(),
|
||||
authors: None,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::state::{Dest, MAX_PICT, St};
|
||||
use super::state::{Dest, MARK_CLOSE, MARK_OPEN, MAX_PICT, St};
|
||||
use super::{Fmt, Inline, Reader, Rgb, make_img, model};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -196,6 +196,11 @@ impl Reader {
|
||||
return;
|
||||
}
|
||||
let mut f = self.st.fmt.clone();
|
||||
if s.contains([MARK_OPEN, MARK_CLOSE]) {
|
||||
self.para.push_text(&s.replace([MARK_OPEN, MARK_CLOSE], "\u{FFFD}"), &f);
|
||||
self.para_has = true;
|
||||
return;
|
||||
}
|
||||
let r = self.st.rev;
|
||||
if r.ins {
|
||||
f.ins = Some(self.rev_mark(r.auth, r.dttm));
|
||||
@@ -245,6 +250,11 @@ impl Reader {
|
||||
}
|
||||
}
|
||||
Dest::Info(_) => self.info_text.push_str(s),
|
||||
Dest::Atn(_) => {
|
||||
if self.atn_text.len() < 256 {
|
||||
self.atn_text.push_str(s);
|
||||
}
|
||||
}
|
||||
Dest::FldInst => {
|
||||
if let Some(f) = self.st.field.and_then(|i| self.fields.get_mut(i))
|
||||
&& f.len() < 4096
|
||||
|
||||
@@ -15,6 +15,9 @@ impl Reader {
|
||||
if w == "shppict" || self.start_header_footer_word(w) {
|
||||
return;
|
||||
}
|
||||
if self.start_note_word(w) {
|
||||
return;
|
||||
}
|
||||
let dest = self.destination_for_word(w);
|
||||
if let Some(d) = dest {
|
||||
match d {
|
||||
@@ -295,6 +298,22 @@ impl Reader {
|
||||
// Transparent wrapper: read the nested pict without changing the destination.
|
||||
"pict" => Some(Dest::Pict),
|
||||
"bkmkstart" => Some(Dest::Bookmark),
|
||||
"atrfstart" | "atrfend" | "atnid" | "atnauthor" | "atndate" | "atntime" | "atnref"
|
||||
if self.marks_on
|
||||
&& self.hf.is_none()
|
||||
&& self.st.dest == Dest::Body
|
||||
&& self.sub.as_ref().is_none_or(|s| s.comment && !w.starts_with("atrf")) =>
|
||||
{
|
||||
self.atn_text.clear();
|
||||
Some(Dest::Atn(match w {
|
||||
"atrfstart" => 1,
|
||||
"atrfend" => 2,
|
||||
"atnid" => 3,
|
||||
"atnauthor" => 4,
|
||||
"atndate" | "atntime" => 5,
|
||||
_ => 6,
|
||||
}))
|
||||
}
|
||||
"header" | "footer" | "headerl" | "headerr" | "headerf" | "footerl" | "footerr" | "footerf" | "footnote" | "annotation" | "pntext"
|
||||
| "listtext" | "object" | "nonshppict" | "xe" | "tc" | "txe" | "private" | "bkmkend" | "atnid" | "atnauthor" | "comment" | "shpinst"
|
||||
| "do" | "operator" | "company" | "manager" | "category" | "hlinkbase" | "revtime" | "printim" | "buptim" | "version" | "nofpages"
|
||||
@@ -309,6 +328,32 @@ impl Reader {
|
||||
}
|
||||
}
|
||||
|
||||
/// The words that open a note or comment story of their own (`\footnote`, `\annotation`) and
|
||||
/// the two inside a note: `\ftnalt` (an endnote) and `\chftn` (the note's own number).
|
||||
fn start_note_word(&mut self, word: &str) -> bool {
|
||||
if self.st.dest == Dest::Body {
|
||||
match word {
|
||||
"ftnalt" => {
|
||||
if let Some(sub) = self.sub.as_mut() {
|
||||
sub.endnote = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
"chftn" if self.sub.as_ref().is_some_and(|s| !s.comment) => {
|
||||
let f = self.st.fmt.clone();
|
||||
self.push_mark('S', 0, f);
|
||||
return true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !matches!(word, "footnote" | "annotation") || !self.marks_on || self.hf.is_some() || self.sub.is_some() || self.st.dest != Dest::Body {
|
||||
return false;
|
||||
}
|
||||
self.start_sub(word == "annotation");
|
||||
true
|
||||
}
|
||||
|
||||
fn start_header_footer_word(&mut self, word: &str) -> bool {
|
||||
if !matches!(word, "header" | "footer" | "headerl" | "headerr" | "headerf" | "footerl" | "footerr" | "footerf")
|
||||
|| self.hf.is_some()
|
||||
@@ -398,6 +443,12 @@ impl Reader {
|
||||
self.picture_word(word, value);
|
||||
true
|
||||
}
|
||||
Dest::Atn(kind) => {
|
||||
if kind == 5 {
|
||||
self.record_atn_part(word, value);
|
||||
}
|
||||
!matches!(word, "u" | "uc")
|
||||
}
|
||||
Dest::Info(_) | Dest::FldInst | Dest::Bookmark if !matches!(word, "u" | "uc") => true,
|
||||
Dest::Info(_) | Dest::FldInst | Dest::Bookmark => false,
|
||||
Dest::OldProps(false) if matches!(word, "crauth" | "crdate") => {
|
||||
@@ -421,6 +472,19 @@ impl Reader {
|
||||
}
|
||||
}
|
||||
|
||||
fn record_atn_part(&mut self, word: &str, value: i32) {
|
||||
let v = value.clamp(0, 9999) as u32;
|
||||
let c = self.atn_parts.get_or_insert((0, 1, 1, 0, 0));
|
||||
match word {
|
||||
"yr" => c.0 = v,
|
||||
"mo" => c.1 = v,
|
||||
"dy" => c.2 = v,
|
||||
"hr" => c.3 = v,
|
||||
"min" => c.4 = v,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn font_table_word(&mut self, word: &str, value: i32) {
|
||||
match word {
|
||||
"f" => {
|
||||
|
||||
@@ -323,11 +323,13 @@ fn attr(e: &BytesStart<'_>, name: &[u8]) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// `s` + `t`, keeping `s` at most [`MAX_CELL_CHARS`] characters.
|
||||
fn push_capped(s: &mut String, t: &str) {
|
||||
let have = s.chars().count();
|
||||
if have < MAX_CELL_CHARS {
|
||||
s.extend(t.chars().take(MAX_CELL_CHARS - have));
|
||||
/// `s` + `t`, keeping `s` at most [`MAX_CELL_CHARS`] characters. `have` is `s`'s character
|
||||
/// count, kept up to date here: counting `s` again for every piece of text made a full cell
|
||||
/// cost its length per further text event.
|
||||
fn push_capped(s: &mut String, have: &mut usize, t: &str) {
|
||||
for c in t.chars().take(MAX_CELL_CHARS.saturating_sub(*have)) {
|
||||
s.push(c);
|
||||
*have += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,6 +467,22 @@ mod tests {
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_cell_takes_further_text_without_recounting() {
|
||||
let (mut s, mut n) = (String::new(), 0usize);
|
||||
push_capped(&mut s, &mut n, "é");
|
||||
push_capped(&mut s, &mut n, &"x".repeat(MAX_CELL_CHARS));
|
||||
assert_eq!((s.chars().count(), n), (MAX_CELL_CHARS, MAX_CELL_CHARS));
|
||||
// A million more text events on the full cell each cost nothing (they used to count it).
|
||||
let t = std::time::Instant::now();
|
||||
for _ in 0..1_000_000 {
|
||||
push_capped(&mut s, &mut n, "y");
|
||||
}
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed());
|
||||
assert_eq!(s.chars().count(), MAX_CELL_CHARS);
|
||||
assert!(!s.contains('y'));
|
||||
}
|
||||
|
||||
/// #1287: one long shared string in thousands of cells stays under the text budget.
|
||||
#[test]
|
||||
fn xlsx_shared_strings_cannot_blow_up_memory() {
|
||||
|
||||
@@ -11,6 +11,8 @@ struct OCell {
|
||||
kind: String,
|
||||
value: Option<String>,
|
||||
text: String,
|
||||
/// Characters in `text` (see [`push_capped`]).
|
||||
text_chars: usize,
|
||||
paras: usize,
|
||||
}
|
||||
|
||||
@@ -77,7 +79,7 @@ pub(super) fn ods_read(xml: &[u8], want: Option<usize>) -> Result<(Vec<String>,
|
||||
&& skip == 0
|
||||
{
|
||||
if c.paras > 0 {
|
||||
push_capped(&mut c.text, "\n");
|
||||
push_capped(&mut c.text, &mut c.text_chars, "\n");
|
||||
}
|
||||
c.paras += 1;
|
||||
}
|
||||
@@ -86,17 +88,17 @@ pub(super) fn ods_read(xml: &[u8], want: Option<usize>) -> Result<(Vec<String>,
|
||||
b"s" if in_p > 0 && skip == 0 => {
|
||||
let k = attr(e, b"c").and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, 1_000);
|
||||
if let Some(c) = cell.as_mut() {
|
||||
push_capped(&mut c.text, &" ".repeat(k));
|
||||
push_capped(&mut c.text, &mut c.text_chars, &" ".repeat(k));
|
||||
}
|
||||
}
|
||||
b"tab" if in_p > 0 && skip == 0 => {
|
||||
if let Some(c) = cell.as_mut() {
|
||||
push_capped(&mut c.text, "\t");
|
||||
push_capped(&mut c.text, &mut c.text_chars, "\t");
|
||||
}
|
||||
}
|
||||
b"line-break" if in_p > 0 && skip == 0 => {
|
||||
if let Some(c) = cell.as_mut() {
|
||||
push_capped(&mut c.text, "\n");
|
||||
push_capped(&mut c.text, &mut c.text_chars, "\n");
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -107,7 +109,7 @@ pub(super) fn ods_read(xml: &[u8], want: Option<usize>) -> Result<(Vec<String>,
|
||||
&& skip == 0
|
||||
&& let Some(c) = cell.as_mut()
|
||||
{
|
||||
push_capped(&mut c.text, &t);
|
||||
push_capped(&mut c.text, &mut c.text_chars, &t);
|
||||
}
|
||||
}
|
||||
Ev::End(n) => {
|
||||
@@ -181,7 +183,7 @@ fn ods_value(c: &OCell) -> String {
|
||||
("string", Some(v)) if c.text.trim().is_empty() => {
|
||||
// Attribute text obeys the same per-cell cap as `<text:p>` text.
|
||||
let mut s = String::new();
|
||||
push_capped(&mut s, v);
|
||||
push_capped(&mut s, &mut 0, v);
|
||||
s
|
||||
}
|
||||
_ => c.text.clone(),
|
||||
|
||||
@@ -102,18 +102,18 @@ pub(super) fn xlsx_sheets(pkg: &mut Package<'_>) -> Result<Vec<SheetRef>, String
|
||||
/// (`rPh`) left out.
|
||||
fn shared_strings(xml: &[u8]) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur: Option<String> = None;
|
||||
let mut cur: Option<(String, usize)> = None;
|
||||
let (mut in_t, mut phonetic) = (false, 0usize);
|
||||
walk(xml, |ev| match ev {
|
||||
Ev::Start(e) => match e.local_name().as_ref() {
|
||||
b"si" => cur = Some(String::new()),
|
||||
b"si" => cur = Some((String::new(), 0)),
|
||||
b"t" => in_t = true,
|
||||
b"rPh" => phonetic += 1,
|
||||
_ => {}
|
||||
},
|
||||
Ev::End(n) => match n.as_slice() {
|
||||
b"si" => {
|
||||
if let Some(s) = cur.take()
|
||||
if let Some((s, _)) = cur.take()
|
||||
&& out.len() < MAX_SHARED
|
||||
{
|
||||
out.push(s);
|
||||
@@ -126,9 +126,9 @@ fn shared_strings(xml: &[u8]) -> Vec<String> {
|
||||
Ev::Text(t) => {
|
||||
if in_t
|
||||
&& phonetic == 0
|
||||
&& let Some(s) = cur.as_mut()
|
||||
&& let Some((s, n)) = cur.as_mut()
|
||||
{
|
||||
push_capped(s, &t);
|
||||
push_capped(s, n, &t);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -182,6 +182,9 @@ struct XCell {
|
||||
style: usize,
|
||||
value: String,
|
||||
inline: String,
|
||||
/// Characters in `value` and `inline` (see [`push_capped`]).
|
||||
value_chars: usize,
|
||||
inline_chars: usize,
|
||||
}
|
||||
|
||||
pub(super) fn xlsx_rows(pkg: &mut Package<'_>, path: &str) -> Result<Vec<Vec<String>>, String> {
|
||||
@@ -227,9 +230,9 @@ pub(super) fn xlsx_rows(pkg: &mut Package<'_>, path: &str) -> Result<Vec<Vec<Str
|
||||
Ev::Text(t) => {
|
||||
if let Some(c) = cell.as_mut() {
|
||||
if in_v {
|
||||
push_capped(&mut c.value, &t);
|
||||
push_capped(&mut c.value, &mut c.value_chars, &t);
|
||||
} else if in_is_t && phonetic == 0 {
|
||||
push_capped(&mut c.inline, &t);
|
||||
push_capped(&mut c.inline, &mut c.inline_chars, &t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -879,6 +879,142 @@ fn exported_links_keep_only_safe_targets() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// RTF footnotes, endnotes and comments (#1053)
|
||||
|
||||
/// The note and comment texts of `d` as `(kind, text)` strings, and the references in the body.
|
||||
fn rtf_notes(d: &Document) -> (Vec<String>, Vec<String>) {
|
||||
use wordcraft_doc::PartKind;
|
||||
let story = |bl: &wordcraft_doc::Blocks| {
|
||||
bl.iter().filter_map(|b| if let Block::Para(p) = &**b { Some(p.plain_text().trim().to_string()) } else { None }).collect::<Vec<_>>().join("|")
|
||||
};
|
||||
let notes = d
|
||||
.parts
|
||||
.values()
|
||||
.filter(|p| matches!(p.kind, PartKind::Footnote | PartKind::Endnote))
|
||||
.map(|p| format!("{:?}:{}", p.kind, story(&p.blocks)))
|
||||
.collect();
|
||||
let comments = d.comments.values().filter_map(|c| d.parts.get(&c.part)).map(|p| story(&p.blocks)).collect();
|
||||
(notes, comments)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtf_footnote_and_endnote_round_trip() {
|
||||
use wordcraft_doc::para::NoteKind;
|
||||
let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Arial;}}
|
||||
\pard Alpha{\super\chftn}{\footnote \pard\plain {\super\chftn} First {\b note}\par} beta
|
||||
gamma{\super\chftn}{\footnote\ftnalt \pard\plain {\super\chftn} The end\par\pard second line\par} delta.\par}";
|
||||
let d = import("rtf", rtf).unwrap().unwrap();
|
||||
let body = d.body.iter().find_map(|b| b.as_para()).unwrap();
|
||||
let refs: Vec<NoteKind> =
|
||||
body.objects.iter().filter_map(|o| if let InlineObject::NoteRef { kind, .. } = o { Some(*kind) } else { None }).collect();
|
||||
assert_eq!(refs, [NoteKind::Footnote, NoteKind::Endnote]);
|
||||
assert_eq!(body.plain_text().trim(), "Alpha beta gamma delta.");
|
||||
let (notes, _) = rtf_notes(&d);
|
||||
assert_eq!(notes, ["Footnote:First note", "Endnote:The end|second line"]);
|
||||
// The note's own number sits at the start of its text, and its first words are bold.
|
||||
let foot = d.parts.values().find(|p| p.kind == wordcraft_doc::PartKind::Footnote).unwrap();
|
||||
let fp = foot.blocks.iter().find_map(|b| b.as_para()).unwrap();
|
||||
assert!(matches!(fp.objects.first(), Some(InlineObject::NoteRef { kind: NoteKind::Footnote, .. })));
|
||||
// Out and in again keeps both.
|
||||
let out = String::from_utf8(export("rtf", &d).unwrap().unwrap()).unwrap();
|
||||
assert!(out.contains("\\footnote") && out.contains("\\footnote\\ftnalt") && out.contains("\\chftn"), "{out}");
|
||||
let d2 = import("rtf", out.as_bytes()).unwrap().unwrap();
|
||||
assert_eq!(rtf_notes(&d2).0, notes);
|
||||
let body2 = d2.body.iter().find_map(|b| b.as_para()).unwrap();
|
||||
assert_eq!(body2.objects.len(), 2);
|
||||
assert_eq!(body2.plain_text().trim(), "Alpha beta gamma delta.");
|
||||
// The flow reader (no document) still just skips them.
|
||||
let flow = crate::rtf::parse(rtf).unwrap();
|
||||
assert!(matches!(&flow.blocks[0], FBlock::Para(p) if p.text().trim() == "Alpha beta gamma delta."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtf_comment_round_trip_with_author_and_date() {
|
||||
let rtf = br"{\rtf1\ansi
|
||||
\pard Before {\*\atrfstart 7}commented words{\*\atrfend 7}{\*\atnid AK}{\*\atnauthor Ada Kline}\chatn{\*\annotation{\*\atnref 7}{\*\atndate \yr2026\mo3\dy9\hr14\min5}\pard\plain Please \b check\b0 this.\par} after.\par
|
||||
\pard Second {\*\atrfstart 8}one{\*\atrfend 8}{\*\atnid BX}{\*\atnauthor Bo}\chatn{\*\annotation{\*\atnref 8}\pard\plain Fine.\par}\par}";
|
||||
let d = import("rtf", rtf).unwrap().unwrap();
|
||||
assert_eq!(d.comments.len(), 2);
|
||||
let c = d.comments.values().next().unwrap();
|
||||
assert_eq!((c.author.as_str(), c.initials.as_str(), c.date.as_str()), ("Ada Kline", "AK", "2026-03-09T14:05:00Z"));
|
||||
assert_eq!(rtf_notes(&d).1, ["Please check this.", "Fine."]);
|
||||
let p = d.body.iter().find_map(|b| b.as_para()).unwrap();
|
||||
assert_eq!(p.plain_text().trim(), "Before commented words after.");
|
||||
// The range wraps exactly the commented words.
|
||||
let (s, e) = (p.object_offsets()[0], p.object_offsets()[1]);
|
||||
assert!(matches!(p.object_at(s), Some(InlineObject::CommentStart { .. })) && matches!(p.object_at(e), Some(InlineObject::CommentEnd { .. })));
|
||||
assert_eq!(p.text.get(s + 3..e), Some("commented words"));
|
||||
let out = String::from_utf8(export("rtf", &d).unwrap().unwrap()).unwrap();
|
||||
assert!(out.contains("\\atrfstart") && out.contains("\\atnauthor Ada Kline") && out.contains("\\annotation"), "{out}");
|
||||
let d2 = import("rtf", out.as_bytes()).unwrap().unwrap();
|
||||
assert_eq!(d2.comments.len(), 2);
|
||||
let (a, b) = (d.comments.values().next().unwrap(), d2.comments.values().next().unwrap());
|
||||
assert_eq!((&a.author, &a.initials, &a.date), (&b.author, &b.initials, &b.date));
|
||||
assert_eq!(rtf_notes(&d2).1, ["Please check this.", "Fine."]);
|
||||
let p2 = d2.body.iter().find_map(|b| b.as_para()).unwrap();
|
||||
assert_eq!(p2.objects.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtf_hostile_notes_and_comments_do_not_panic() {
|
||||
// Truncated everywhere, unbalanced groups, forged markers, huge ids, nesting.
|
||||
let good = br"{\rtf1\ansi Hi{\super\chftn}{\footnote\ftnalt \pard\plain {\super\chftn} Note \u8364? text\par} {\*\atrfstart 1}x{\*\atrfend 1}{\*\atnid A}{\*\atnauthor Z}{\*\annotation{\*\atnref 1}{\*\atndate 123456789}\pard c\par}\par}";
|
||||
for n in 0..good.len() {
|
||||
let _ = import("rtf", &good[..n]);
|
||||
}
|
||||
let junk: [&[u8]; 6] = [
|
||||
br"{\rtf1 \u63472?\u63473?\u63472?N0\u63473? {\footnote x}{\footnote {\footnote deep}}}",
|
||||
br"{\rtf1 {\*\atrfstart 99999999999}{\*\atrfend -5}{\*\annotation{\*\atnref 99999999999}x}{\*\annotation}}",
|
||||
br"{\rtf1 {\footnote{\annotation{\footnote{\*\annotation{\*\atrfstart 1}",
|
||||
br"{\rtf1 {\*\atndate \yr99999\mo-4\dy0\hr77\min999}{\*\annotation y}{\*\atndate 4294967295}{\*\annotation z}}",
|
||||
b"{\\rtf1 {\\footnote \\trowd\\cellx100\\pard\\intbl a\\cell\\row}}",
|
||||
b"{\\rtf1 {\\annotation \\chatn\\chftn\\atnparent 3}{\\header {\\footnote q}}}",
|
||||
];
|
||||
for j in junk {
|
||||
let d = import("rtf", j).unwrap().unwrap();
|
||||
// Whatever came in, a second trip through RTF is fine too.
|
||||
let out = export("rtf", &d).unwrap().unwrap();
|
||||
assert!(import("rtf", &out).unwrap().is_ok());
|
||||
}
|
||||
// Many notes: bounded.
|
||||
let mut many = String::from("{\\rtf1 ");
|
||||
for _ in 0..6000 {
|
||||
many.push_str("{\\footnote x}");
|
||||
}
|
||||
many.push('}');
|
||||
let d = import("rtf", many.as_bytes()).unwrap().unwrap();
|
||||
assert!(d.parts.len() <= 5000);
|
||||
// Private-use marker characters in ordinary text stay text.
|
||||
let d = import("rtf", "{\\rtf1 a\u{F8F0}N0\u{F8F1}b}".as_bytes()).unwrap().unwrap();
|
||||
assert!(d.parts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtf_notes_comments_tracked_changes_and_sections_work_together() {
|
||||
// Two sections; the second holds a footnote, a comment and a tracked insertion, and has its
|
||||
// own footer. A `\sect` inside the footnote's text must not end a section.
|
||||
let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Arial;}}{\*\revtbl {Unknown;}{Alice;}}
|
||||
\sectd{\footerr \pard Foot one\par}\pard First section.\par \sect
|
||||
\sectd\lndscpsxn{\footerr \pard Foot two\par}\pard Second{\super\chftn}{\footnote \pard\plain {\super\chftn} Note text\sect\par} {\*\atrfstart 3}section{\*\atrfend 3}{\*\atnid AB}{\*\atnauthor Ann}\chatn{\*\annotation{\*\atnref 3}{\*\atndate 1000}\pard Remark\par}{\revised\revauth1\revdttm-1480361314 added}.\par}";
|
||||
let d = import("rtf", rtf).unwrap().unwrap();
|
||||
assert_eq!(d.sections().len(), 2);
|
||||
let check = |d: &Document| {
|
||||
let (notes, comments) = rtf_notes(d);
|
||||
assert_eq!(notes.len(), 1, "{notes:?}");
|
||||
assert!(notes[0].contains("Note text"), "{notes:?}");
|
||||
assert_eq!(comments.len(), 1, "{comments:?}");
|
||||
assert!(comments[0].contains("Remark"), "{comments:?}");
|
||||
let ins = d.body.iter().filter_map(|b| b.as_para()).any(|p| p.plain_text().contains("added"));
|
||||
assert!(ins);
|
||||
};
|
||||
check(&d);
|
||||
let out = String::from_utf8(export("rtf", &d).unwrap().unwrap()).unwrap();
|
||||
assert!(out.contains("\\revised") && out.contains("\\footnote") && out.contains("\\atrfstart"), "{out}");
|
||||
let d2 = import("rtf", out.as_bytes()).unwrap().unwrap();
|
||||
check(&d2);
|
||||
}
|
||||
|
||||
/// A link target with any whitespace (a no-break or ideographic space too) is written in `<…>`,
|
||||
/// so the reader, which ends a bare destination at the first whitespace, reads it back whole.
|
||||
#[test]
|
||||
|
||||
@@ -51,6 +51,8 @@ pub struct Probe<'a> {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
peak: u64,
|
||||
/// Whether a PNG has transparency, read from its chunk headers; `None` for other formats.
|
||||
png_alpha: Option<bool>,
|
||||
}
|
||||
|
||||
/// TIFF compressions decoded within the caller's limits: none, CCITT Group 4, LZW, Deflate (both
|
||||
@@ -72,9 +74,48 @@ pub fn probe(bytes: &[u8]) -> Result<Probe<'_>, Refusal> {
|
||||
}
|
||||
_ => return Err(Refusal::NotAPicture),
|
||||
};
|
||||
if format == ImageFormat::Png {
|
||||
let (width, height, native, alpha) = png_chunks(bytes).ok_or_else(|| Refusal::Broken("its PNG header can't be read".into()))?;
|
||||
let (width, height, peak) = sized(width, height, native, 0)?;
|
||||
return Ok(Probe { bytes: Cow::Borrowed(bytes), dib: false, format, width, height, peak, png_alpha: Some(alpha) });
|
||||
}
|
||||
let decoder = image::ImageReader::with_format(Cursor::new(bytes), format).into_decoder().map_err(broken)?;
|
||||
let (width, height, peak) = measure(&decoder, held)?;
|
||||
Ok(Probe { bytes: Cow::Borrowed(bytes), dib: false, format, width, height, peak })
|
||||
Ok(Probe { bytes: Cow::Borrowed(bytes), dib: false, format, width, height, peak, png_alpha: None })
|
||||
}
|
||||
|
||||
/// A PNG's width, height, the bytes a pixel its decoder gives (samples under 8 bits widened to 8,
|
||||
/// a palette to RGB, `tRNS` to an alpha channel) and whether it has transparency, from `IHDR` and
|
||||
/// the chunk headers before the image data. The decoder's own header read inflates an ICC profile
|
||||
/// (`iCCP`) up to its 512 MiB default limit, for a picture that may only be measured.
|
||||
fn png_chunks(bytes: &[u8]) -> Option<(u32, u32, u64, bool)> {
|
||||
let be = |at: usize| bytes.get(at..at.checked_add(4)?).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(u32::from_be_bytes);
|
||||
// The 8-byte signature, then IHDR: length, type, width, height, bit depth, colour type.
|
||||
if bytes.get(12..16) != Some(b"IHDR".as_slice()) {
|
||||
return None;
|
||||
}
|
||||
let (width, height) = (be(16)?, be(20)?);
|
||||
let (depth, color) = (*bytes.get(24)?, *bytes.get(25)?);
|
||||
let mut trns = false;
|
||||
let mut at = 8usize;
|
||||
loop {
|
||||
let len = usize::try_from(be(at)?).ok()?;
|
||||
match bytes.get(at.checked_add(4)?..at.checked_add(8)?)? {
|
||||
b"IDAT" => break,
|
||||
b"IEND" => return None,
|
||||
b"tRNS" => trns = true,
|
||||
_ => {}
|
||||
}
|
||||
at = at.checked_add(12)?.checked_add(len)?;
|
||||
}
|
||||
let (channels, alpha) = match color {
|
||||
0 => (1 + u64::from(trns), trns),
|
||||
2 | 3 => (3 + u64::from(trns), trns),
|
||||
4 => (2, true),
|
||||
6 => (4, true),
|
||||
_ => return None,
|
||||
};
|
||||
Some((width, height, channels * if depth == 16 { 2 } else { 1 }, alpha))
|
||||
}
|
||||
|
||||
/// Read a DIB's header: a BMP without its 14-byte file header, as the Windows clipboard holds
|
||||
@@ -86,16 +127,20 @@ pub fn probe_dib(dib: Vec<u8>) -> Result<Probe<'static>, Refusal> {
|
||||
let decoder = image::codecs::bmp::BmpDecoder::new_without_file_header(Cursor::new(dib.as_slice())).map_err(broken)?;
|
||||
measure(&decoder, 0)?
|
||||
};
|
||||
Ok(Probe { bytes: Cow::Owned(dib), dib: true, format: ImageFormat::Bmp, width, height, peak })
|
||||
Ok(Probe { bytes: Cow::Owned(dib), dib: true, format: ImageFormat::Bmp, width, height, peak, png_alpha: None })
|
||||
}
|
||||
|
||||
/// A decoder's size and the peak bytes of decoding it, `held` bytes kept beside its output.
|
||||
fn measure(decoder: &impl ImageDecoder, held: u64) -> Result<(u32, u32, u64), Refusal> {
|
||||
let (width, height) = decoder.dimensions();
|
||||
sized(width, height, u64::from(decoder.color_type().bytes_per_pixel()), held)
|
||||
}
|
||||
|
||||
/// A `width` × `height` picture of `native` bytes a pixel, with its peak (see [`peak_bytes`]).
|
||||
fn sized(width: u32, height: u32, native: u64, held: u64) -> Result<(u32, u32, u64), Refusal> {
|
||||
if width == 0 || height == 0 {
|
||||
return Err(Refusal::Broken("it has no pixels".into()));
|
||||
}
|
||||
let native = u64::from(decoder.color_type().bytes_per_pixel());
|
||||
let peak = peak_bytes(width, height, native, held).ok_or(Refusal::TooLarge { width, height })?;
|
||||
Ok((width, height, peak))
|
||||
}
|
||||
@@ -106,6 +151,18 @@ impl Probe<'_> {
|
||||
self.peak
|
||||
}
|
||||
|
||||
/// Whether the picture has transparency, from its header: a PNG's from its chunk headers
|
||||
/// (an ICC profile isn't inflated to say), another format's from its decoder.
|
||||
pub fn has_alpha(&self) -> bool {
|
||||
if let Some(alpha) = self.png_alpha {
|
||||
return alpha;
|
||||
}
|
||||
if self.dib {
|
||||
return image::codecs::bmp::BmpDecoder::new_without_file_header(Cursor::new(&*self.bytes)).is_ok_and(|d| d.color_type().has_alpha());
|
||||
}
|
||||
image::ImageReader::with_format(Cursor::new(&*self.bytes), self.format).into_decoder().is_ok_and(|d| d.color_type().has_alpha())
|
||||
}
|
||||
|
||||
/// Whether decoding fits `budget` bytes.
|
||||
pub fn fits(&self, budget: u64) -> bool {
|
||||
self.peak <= budget
|
||||
@@ -305,6 +362,39 @@ mod tests {
|
||||
!c
|
||||
}
|
||||
|
||||
/// `png` with a chunk of `kind` holding `data` placed after IHDR.
|
||||
fn with_chunk(png: &[u8], kind: &[u8; 4], data: &[u8]) -> Vec<u8> {
|
||||
let mut chunk = (data.len() as u32).to_be_bytes().to_vec();
|
||||
chunk.extend_from_slice(kind);
|
||||
chunk.extend_from_slice(data);
|
||||
let crc = crc32(&chunk[4..]);
|
||||
chunk.extend_from_slice(&crc.to_be_bytes());
|
||||
let mut out = png.get(..33).unwrap().to_vec();
|
||||
out.extend(chunk);
|
||||
out.extend_from_slice(&png[33..]);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_png_is_measured_from_its_chunk_headers() {
|
||||
// An ICC profile isn't inflated to measure a picture: one whose deflate stream is junk
|
||||
// (the decoder refused it while reading the header) still measures, and isn't decoded.
|
||||
let rgb = encode(image::RgbImage::new(4, 2), ImageFormat::Png);
|
||||
let icc = with_chunk(&rgb, b"iCCP", b"p\0\0\xFF\xFF\xFF\xFF");
|
||||
let p = probe(&icc).unwrap();
|
||||
assert_eq!((p.width, p.height, p.peak_bytes()), (4, 2, 4 * 2 * 8));
|
||||
assert!(!p.has_alpha());
|
||||
assert_eq!(probe(&rgb).unwrap().peak_bytes(), p.peak_bytes(), "as the decoder measures it");
|
||||
// Transparency: an alpha channel, or tRNS before the image data.
|
||||
assert!(probe(&encode(image::GrayAlphaImage::new(2, 2), ImageFormat::Png)).unwrap().has_alpha());
|
||||
let trns = with_chunk(&rgb, b"tRNS", &[0, 0, 0, 0, 0, 0]);
|
||||
let p = probe(&trns).unwrap();
|
||||
assert!(p.has_alpha());
|
||||
assert_eq!(p.decode(1 << 20).map(|i| i.color().has_alpha()), Ok(true), "the decoder agrees");
|
||||
// No image data: broken.
|
||||
assert!(probe(&rgb[..33]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_pictures_decode_and_peaks_count_every_buffer() {
|
||||
let png = encode(image::RgbaImage::from_pixel(16, 8, image::Rgba([1, 2, 3, 4])), ImageFormat::Png);
|
||||
|
||||
@@ -366,8 +366,12 @@ pub(crate) fn layout_box(ctx: &mut Ctx, input: BoxInput<'_>) -> BoxLayout {
|
||||
items.splice(at..at, back);
|
||||
items.extend(front);
|
||||
}
|
||||
// Automatic space after: none after the last paragraph of a cell or box.
|
||||
let after = if pl.rp.after_auto && i + 1 == blocks.len() { 0.0 } else { pl.rp.space_after };
|
||||
// Automatic space after: none after the last paragraph of a cell or box. Nor
|
||||
// contextual spacing after a cell's last paragraph in the default style (or an
|
||||
// undefined one, formatted as Normal): the end-of-cell mark is a Normal paragraph.
|
||||
let last = i + 1 == blocks.len();
|
||||
let normal = || pl.rp.style == "Normal" || ctx.doc.styles.get(&pl.rp.style).is_none();
|
||||
let after = if last && (pl.rp.after_auto || (in_cell && ctxl && normal())) { 0.0 } else { pl.rp.space_after };
|
||||
y += edges.top + pl.height + edges.bottom + after;
|
||||
prev_after = after;
|
||||
prev_after_auto = pl.rp.after_auto;
|
||||
|
||||
@@ -286,16 +286,13 @@ impl Alphas {
|
||||
/// without transparency or that don't decode; [`OverBudget`] for one that would take `decoded`
|
||||
/// past [`MAX_DECODED_PIXELS`]. Pictures whose header says they're opaque cost nothing.
|
||||
fn decode_alpha(bytes: &[u8], decoded: &mut u64) -> Result<Option<Alpha>, OverBudget> {
|
||||
use image::ImageDecoder;
|
||||
let Ok(pic) = wordcraft_geom::picture::probe(bytes) else { return Ok(None) };
|
||||
// Formats that can't be transparent aren't decoded.
|
||||
if pic.format == image::ImageFormat::Jpeg || pic.width > MAX_PICTURE_SIDE || pic.height > MAX_PICTURE_SIDE {
|
||||
return Ok(None);
|
||||
}
|
||||
// Only the header is read here (a PNG's `tRNS` counts as transparency).
|
||||
let has_alpha =
|
||||
image::ImageReader::with_format(std::io::Cursor::new(bytes), pic.format).into_decoder().ok().is_some_and(|d| d.color_type().has_alpha());
|
||||
if !has_alpha {
|
||||
if !pic.has_alpha() {
|
||||
return Ok(None);
|
||||
}
|
||||
let pixels = u64::from(pic.width) * u64::from(pic.height);
|
||||
|
||||
@@ -4,9 +4,12 @@ use super::headers::HfLinks;
|
||||
use super::placed::Placed;
|
||||
use super::{
|
||||
Anchor, Arc, Block, CharProps, Counters, DocLayout, Document, FieldCtx, Float, HashMap, HashSet, HideDeleted, InlineObject, LayoutOptions, Level,
|
||||
ParaLayout, Paragraph, Rect, Rgb, SectionProps, StoryRef, ViewMode, Wrap, contour, math, para, styleref, textbox,
|
||||
ParaLayout, Paragraph, Rect, SectionProps, StoryRef, ViewMode, Wrap, contour, math, para, styleref, textbox,
|
||||
};
|
||||
|
||||
/// The character style Word formats line numbers with (`w:styleId="LineNumber"`).
|
||||
const LINE_NUMBER_STYLE: &str = "LineNumber";
|
||||
|
||||
/// A layout pass: finished, or to be done again with the page totals it found, or with the
|
||||
/// floats placed above their anchors it found ([`EarlyFloats`]).
|
||||
pub(crate) enum Pass {
|
||||
@@ -193,10 +196,11 @@ impl Ctx<'_> {
|
||||
if let Some(p) = self.numbers.get(&n) {
|
||||
return p.clone();
|
||||
}
|
||||
let mut p = Paragraph::with_text(
|
||||
&n.to_string(),
|
||||
CharProps { size: Some(10.0), color: Some(wordcraft_doc::TextColor::Rgb(Rgb(0x60, 0x60, 0x60))), ..Default::default() },
|
||||
);
|
||||
// Word formats line numbers with the "Line Number" character style over the document's
|
||||
// defaults (measured in Word for Mac: Aptos 12 pt black in a document whose defaults are
|
||||
// Aptos 12 pt), not in a fixed small grey.
|
||||
let style = self.doc.styles.get(LINE_NUMBER_STYLE).map(|s| s.id.clone());
|
||||
let mut p = Paragraph::with_text(&n.to_string(), CharProps { style, ..Default::default() });
|
||||
p.props.space_after = Some(0.0);
|
||||
p.props.space_before = Some(0.0);
|
||||
let env = para::ParaEnv {
|
||||
|
||||
@@ -438,13 +438,23 @@ pub(crate) fn place_late(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, oi:
|
||||
/// the one holding its anchor, or for anchors on a line after a page or column break that holds
|
||||
/// nothing taking room ([`para::takes_room`]: only anchors and markers such as bookmarks), the
|
||||
/// break's: Word keeps them on the page before (a cover page's title box, #1632). With text after
|
||||
/// them they are on the new page with it (#4262).
|
||||
/// them they are on the new page with it (#4262). The text may be in the same row's
|
||||
/// continuation on the far side of a wrapping object, not in the anchor's segment (Leaf #107).
|
||||
pub(crate) fn anchor_lines(p: &Paragraph, pl: &ParaLayout) -> Vec<Option<usize>> {
|
||||
let mut out = vec![None; p.objects.len()];
|
||||
for (li, l) in pl.lines.iter().enumerate() {
|
||||
let clusters = pl.clusters.get(l.c0..l.c1).unwrap_or_default();
|
||||
let lead = li.checked_sub(1).and_then(|k| pl.lines.get(k)).is_some_and(|l| matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak))
|
||||
&& !clusters.iter().any(para::takes_room);
|
||||
let after_break = li.checked_sub(1).and_then(|k| pl.lines.get(k)).is_some_and(|l| matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak));
|
||||
// A wrap can leave only the zero-width anchor in the row's left segment, with its
|
||||
// text on the right. That is still visible content after the break, not a cover box.
|
||||
let lead = after_break
|
||||
&& !clusters.iter().any(para::takes_room)
|
||||
&& !pl
|
||||
.lines
|
||||
.iter()
|
||||
.skip(li + 1)
|
||||
.take_while(|next| next.beside)
|
||||
.any(|next| pl.clusters.get(next.c0..next.c1).unwrap_or_default().iter().any(para::takes_room));
|
||||
for c in clusters {
|
||||
let para::ClKind::Object(oi) = c.kind else { continue };
|
||||
if let Some(slot) = out.get_mut(oi).filter(|s| s.is_none()) {
|
||||
|
||||
@@ -1201,14 +1201,16 @@ fn align_in_part(
|
||||
}
|
||||
let limit = cut - row.bottom.clamp(0.0, cut);
|
||||
let mut kept = f32::MIN;
|
||||
for ((items, splits), _) in cells.iter().zip(splits).zip(realign).filter(|(_, al)| al.is_none()) {
|
||||
for (((items, splits), _), end) in cells.iter().zip(splits).zip(realign).zip(ends.iter()).filter(|((_, al), _)| al.is_none()) {
|
||||
// No further than the cell's content: its last paragraph may have dropped its space after.
|
||||
let end = end.unwrap_or(f32::MAX);
|
||||
for (it, &split) in items.iter().zip(splits) {
|
||||
let bottom = match (it.turned_bounds(), it) {
|
||||
(Some(r), _) => Some(r.bottom()),
|
||||
// A paragraph kept whole ends with its space after.
|
||||
(None, Placed::Lines { para, l0, y, .. }) if split > *l0 => crate::item_bottom(*y, para, *l0, split)
|
||||
.filter(|&b| b <= limit + 0.01)
|
||||
.map(|b| if split == para.lines.len() { (b + para.rp.space_after).min(limit) } else { b }),
|
||||
.map(|b| if split == para.lines.len() { (b + para.rp.space_after).min(end.max(b)).min(limit) } else { b }),
|
||||
(None, Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Graphic { rect, .. } | Placed::Clip { rect, .. }) => {
|
||||
Some(rect.bottom())
|
||||
}
|
||||
|
||||
@@ -298,6 +298,82 @@ fn legacy_table_contextual_spacing_flag_is_scoped_to_cells_and_legacy_mode() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Baseline of the paragraph after a one-row table, less that of the first cell's last
|
||||
/// paragraph, which has 12 pt after in `style`, contextual or not. `next_cell` adds a second cell
|
||||
/// whose paragraph has that style; `after_table` styles the paragraph after the table; `text` is
|
||||
/// the last paragraph's text.
|
||||
fn cell_end_gap(style: &str, contextual: bool) -> f32 {
|
||||
cell_end_gap_in(Document::new(), style, contextual, None, None, "Beta probe")
|
||||
}
|
||||
|
||||
fn cell_end_gap_in(mut d: Document, style: &str, contextual: bool, next_cell: Option<&str>, after_table: Option<&str>, text: &str) -> f32 {
|
||||
use wordcraft_doc::props::LineSpacing;
|
||||
Arc::make_mut(&mut d.styles).upsert(wordcraft_doc::Style {
|
||||
id: "Other".into(),
|
||||
name: "Other".into(),
|
||||
based_on: Some("Normal".into()),
|
||||
..Default::default()
|
||||
});
|
||||
let chr = wordcraft_doc::CharProps { font: Some("Source Sans 3".into()), size: Some(12.0), ..Default::default() };
|
||||
let props = ParaProps { space_before: Some(0.0), space_after: Some(0.0), line_spacing: Some(LineSpacing::Exactly(24.0)), ..Default::default() };
|
||||
let para = |text: &str, props: ParaProps| {
|
||||
let mut p = wordcraft_doc::Paragraph::with_text(text, chr.clone());
|
||||
p.props = props;
|
||||
wordcraft_doc::para_block(p)
|
||||
};
|
||||
let styled = |s: Option<&str>| ParaProps { style: s.map(Into::into), ..props.clone() };
|
||||
let mut table = Table::new(1, if next_cell.is_some() { 2 } else { 1 }, 468.0);
|
||||
table.props.style = None;
|
||||
table.props.cell_margins = Some([0.0; 4]);
|
||||
let last = ParaProps { style: Some(style.into()), space_after: Some(12.0), contextual_spacing: Some(contextual), ..props.clone() };
|
||||
table.rows[0].cells[0].blocks = vec![para("Alpha probe", props.clone()), para(text, last)];
|
||||
if let Some(cell) = table.rows[0].cells.get_mut(1) {
|
||||
cell.blocks = vec![para("Delta probe", styled(next_cell))];
|
||||
}
|
||||
d.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(table)), para("Gamma probe", styled(after_table))];
|
||||
let l = lay(&d);
|
||||
let baseline = |at: &[u32]| {
|
||||
l.pages[0].items.iter().find_map(|i| match i {
|
||||
Placed::Lines { para, y, path, .. } if path.0 == at => Some(y + para.lines[0].baseline),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let (Some(cell), Some(after)) = (baseline(&[0, 0, 0, 1]), baseline(&[1])) else { panic!("probe lines missing") };
|
||||
after - cell
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contextual_spacing_drops_the_space_after_a_cells_last_normal_paragraph() {
|
||||
// The end-of-cell mark is a Normal paragraph: contextual spacing drops the 12 pt after a
|
||||
// Normal last paragraph (docxide-pdf `cell-end-text-context`, `cell-end-empty-context`), not
|
||||
// after one in another style (`cell-end-other-style`) or without contextual spacing.
|
||||
let kept = cell_end_gap("Normal", false);
|
||||
assert!((cell_end_gap("Other", true) - kept).abs() < 0.01);
|
||||
let dropped = cell_end_gap("Normal", true);
|
||||
assert!((kept - dropped - 12.0).abs() < 0.01, "kept {kept}, dropped {dropped}");
|
||||
// An undefined style is formatted as Normal, and drops it too.
|
||||
assert!((cell_end_gap("Missing", true) - dropped).abs() < 0.01);
|
||||
// The mark decides, not a neighbour: another style in the next cell or after the table
|
||||
// leaves the drop as it is.
|
||||
for (next_cell, after_table) in [(Some("Other"), None), (None, Some("Other"))] {
|
||||
let gap = cell_end_gap_in(Document::new(), "Normal", true, next_cell, after_table, "Beta probe");
|
||||
assert!((gap - dropped).abs() < 0.01, "{next_cell:?} {after_table:?}: {gap}, dropped {dropped}");
|
||||
}
|
||||
// And the reverse: another style keeps its space beside Normal neighbours.
|
||||
let gap = cell_end_gap_in(Document::new(), "Other", true, Some("Normal"), Some("Normal"), "Beta probe");
|
||||
assert!((gap - kept).abs() < 0.01, "other beside Normal: {gap}, kept {kept}");
|
||||
// An empty last paragraph drops it too (`cell-end-empty-context`).
|
||||
let gap = cell_end_gap_in(Document::new(), "Normal", true, None, None, "");
|
||||
assert!((gap - dropped).abs() < 0.01, "empty: {gap}, dropped {dropped}");
|
||||
// Where `allowSpaceOfSameStyleInTable` keeps same-style space in cells (modes 11/12/14), the
|
||||
// 12 pt stays.
|
||||
let mut legacy = Document::new();
|
||||
legacy.settings.compat_mode = 14;
|
||||
legacy.settings.allow_space_of_same_style_in_table = true;
|
||||
let gap = cell_end_gap_in(legacy, "Normal", true, None, None, "Beta probe");
|
||||
assert!((gap - kept).abs() < 0.01, "legacy {gap}, kept {kept}");
|
||||
}
|
||||
|
||||
fn contextual_gap(after: f32, before: f32, first: bool, second: bool, different_style: bool, header: bool) -> f32 {
|
||||
use wordcraft_doc::props::LineSpacing;
|
||||
let mut d = Document::new();
|
||||
@@ -3851,6 +3927,40 @@ fn extreme_page_and_line_number_starts_do_not_overflow() {
|
||||
assert!(l.pages.len() >= 3, "{} pages", l.pages.len());
|
||||
}
|
||||
|
||||
/// Line numbers take the document's character formatting through the "Line Number" style, as in
|
||||
/// Word (Aptos 12 pt black in a document whose defaults are Aptos 12 pt), not a fixed small grey.
|
||||
#[test]
|
||||
fn line_numbers_follow_the_line_number_style() {
|
||||
let number_runs = |d: &Document| {
|
||||
let l = lay(d);
|
||||
l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Placed::Lines { story, para, .. } if *story == DECOR_STORY => para.styles.first().map(|s| (s.size, s.rc.color)),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let mut d = Document::from_text("one\ntwo\nthree");
|
||||
d.last_section.line_numbers = Some(Default::default());
|
||||
Arc::make_mut(&mut d.styles).default_chr.size = Some(12.0);
|
||||
let plain = number_runs(&d);
|
||||
assert_eq!(plain.len(), 3);
|
||||
assert!(plain.iter().all(|(size, color)| *size == 12.0 && *color == wordcraft_doc::TextColor::Auto), "{plain:?}");
|
||||
|
||||
let red = wordcraft_doc::TextColor::Rgb(wordcraft_doc::Rgb(0xC0, 0, 0));
|
||||
Arc::make_mut(&mut d.styles).upsert(wordcraft_doc::Style {
|
||||
id: "LineNumber".into(),
|
||||
name: "line number".into(),
|
||||
kind: wordcraft_doc::StyleKind::Character,
|
||||
chr: CharProps { size: Some(8.0), color: Some(red), ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
let styled = number_runs(&d);
|
||||
assert!(styled.iter().all(|(size, color)| *size == 8.0 && *color == red), "{styled:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_numbers_borders_text_boxes() {
|
||||
let mut d = Document::from_text("one\ntwo\nthree");
|
||||
@@ -12152,6 +12262,88 @@ fn a_middle_or_bottom_cell_of_a_split_row_lines_up_on_the_first_page() {
|
||||
assert_eq!(pages[1][0].len(), 25 - labels.len(), "{pages:?}");
|
||||
}
|
||||
|
||||
/// The line tops on the first page of a row split across pages, by cell: a label aligned with
|
||||
/// `valign`, 30 lines that split, and a 160 pt spacer above `note`.
|
||||
fn split_row_beside_a_note(valign: wordcraft_doc::props::VAlign, note: &ParaProps) -> [Vec<f32>; 3] {
|
||||
use wordcraft_doc::props::LineSpacing;
|
||||
let plain = split_note_plain();
|
||||
let para = |text: &str, props: ParaProps| {
|
||||
let mut p = Paragraph::with_text(text, Default::default());
|
||||
p.props = props;
|
||||
wordcraft_doc::para_block(p)
|
||||
};
|
||||
let mut d = Document::new();
|
||||
(d.last_section.page_w, d.last_section.page_h) = (400.0, 360.0);
|
||||
d.last_section.margin_top = 36.0;
|
||||
d.last_section.margin_bottom = 36.0;
|
||||
let mut t = Table::new(1, 3, 328.0);
|
||||
t.props.style = None;
|
||||
t.props.cell_margins = Some([0.0; 4]);
|
||||
t.rows[0].cells[0].props.valign = Some(valign);
|
||||
t.rows[0].cells[0].blocks = vec![para("Label", plain.clone())];
|
||||
t.rows[0].cells[1].blocks = (0..30).map(|i| para(&format!("Line {i}"), plain.clone())).collect();
|
||||
let spacer = ParaProps { line_spacing: Some(LineSpacing::Exactly(160.0)), ..plain.clone() };
|
||||
t.rows[0].cells[2].blocks = vec![para("Spacer", spacer), para("Note", note.clone())];
|
||||
d.body = vec![para("Filler", ParaProps { line_spacing: Some(LineSpacing::Exactly(100.0)), ..plain }), Arc::new(Block::Table(t))];
|
||||
let l = lay(&d);
|
||||
let tops = |cell: u32| -> Vec<f32> {
|
||||
l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
Placed::Lines { path, y, .. } if path.0.len() == 4 && path.0[0] == 1 && path.0[2] == cell => Some(*y),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let cells = [tops(0), tops(1), tops(2)];
|
||||
assert!(cells[1].len() > 5 && cells[1].len() < 30, "the row splits: {cells:?}");
|
||||
assert_eq!(cells[2].len(), 2, "the note stays on the page: {cells:?}");
|
||||
cells
|
||||
}
|
||||
|
||||
/// Single 12 pt lines with no space around them.
|
||||
fn split_note_plain() -> ParaProps {
|
||||
ParaProps {
|
||||
space_before: Some(0.0),
|
||||
space_after: Some(0.0),
|
||||
line_spacing: Some(wordcraft_doc::props::LineSpacing::Exactly(12.0)),
|
||||
widow_control: Some(false),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bottom_cell_of_a_split_row_ignores_a_dropped_space_after_beside_it() {
|
||||
use wordcraft_doc::props::VAlign;
|
||||
// The note's space after (contextual 300 pt, or automatic) drops at the cell's end and would
|
||||
// otherwise reach below the split lines: a bottom label lines up with those, a middle one is
|
||||
// centred between their top and bottom.
|
||||
let plain = split_note_plain();
|
||||
let contextual = ParaProps { space_after: Some(300.0), contextual_spacing: Some(true), ..plain.clone() };
|
||||
let automatic = ParaProps { space_after_auto: Some(true), ..plain };
|
||||
for note in [contextual, automatic] {
|
||||
let [labels, lines, _] = split_row_beside_a_note(VAlign::Bottom, ¬e);
|
||||
assert_eq!(labels.last(), lines.last(), "{note:?}: labels {labels:?}, lines {lines:?}");
|
||||
let [labels, lines, _] = split_row_beside_a_note(VAlign::Center, ¬e);
|
||||
let (Some(&first), Some(&last), Some(&label)) = (lines.first(), lines.last(), labels.first()) else { panic!("{labels:?} {lines:?}") };
|
||||
let want = first + (last - first) / 2.0;
|
||||
assert!((label - want).abs() < 0.01, "{note:?}: centred label at {label}, want {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bottom_cell_of_a_split_row_lines_up_with_a_kept_space_after_beside_it() {
|
||||
use wordcraft_doc::props::VAlign;
|
||||
// A note that keeps its 14 pt after (no contextual spacing) still ends there: the bottom
|
||||
// label's line ends 14 pt below the note's.
|
||||
let note = ParaProps { space_after: Some(14.0), ..split_note_plain() };
|
||||
let [labels, lines, notes] = split_row_beside_a_note(VAlign::Bottom, ¬e);
|
||||
let (Some(&label), Some(¬e_top), Some(&line)) = (labels.last(), notes.last(), lines.last()) else { panic!("{labels:?} {notes:?}") };
|
||||
assert!(note_top + 14.0 > line, "the note's space after reaches below the split lines: {notes:?} {lines:?}");
|
||||
assert!((label - (note_top + 14.0)).abs() < 0.01, "label at {label}, note at {note_top}");
|
||||
}
|
||||
|
||||
fn row_keep_pages(d: &Document) -> Vec<usize> {
|
||||
let l = lay(d);
|
||||
(0..d.table(StoryRef::Body, &Path::top(1)).unwrap().rows.len())
|
||||
|
||||
@@ -356,6 +356,70 @@ fn tools_address_an_open_document_by_id() {
|
||||
assert_eq!(tool(&mut s, "get_text", json!({"doc": first}))["text"].as_str().unwrap().trim(), "First more again");
|
||||
}
|
||||
|
||||
/// #2242: `insert_table` fills a table in one call; the header row and style survive a DOCX round trip.
|
||||
#[test]
|
||||
fn insert_table_fills_a_table_and_survives_docx() {
|
||||
let mut s = Server::new(Box::new(Headless::default()));
|
||||
tool(&mut s, "new_document", json!({}));
|
||||
tool(&mut s, "insert_table", json!({"values": [["Month", "Cases"], ["January", 12], ["February", 7]], "header": true, "style": "TableGrid"}));
|
||||
let path = std::env::temp_dir().join(format!("wordcraft-mcp-table-{}.docx", std::process::id()));
|
||||
let path_str = path.to_str().expect("utf-8 path").to_string();
|
||||
tool(&mut s, "save_document", json!({"path": path_str}));
|
||||
let mut s2 = Server::new(Box::new(Headless::default()));
|
||||
tool(&mut s2, "open_document", json!({"path": path_str}));
|
||||
let doc = tool(&mut s2, "inspect_document", json!({}));
|
||||
let table = doc["blocks"].as_array().unwrap().iter().find(|b| b["type"] == "table").expect("a table");
|
||||
let text = table.to_string();
|
||||
for cell in ["Month", "Cases", "January", "12", "February", "7"] {
|
||||
assert!(text.contains(cell), "{cell} missing in {text}");
|
||||
}
|
||||
assert!(text.contains("TableGrid"), "{text}");
|
||||
// The header flag is not in `inspect_document`; read it from the reopened document.
|
||||
let mut h = Headless::default();
|
||||
h.session.run("file.open", &json!({"path": path_str})).expect("reopened");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let table = h.session.doc.body.iter().find_map(|b| match &**b {
|
||||
wordcraft_doc::Block::Table(t) => Some(t.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let table = table.expect("a table");
|
||||
assert!(table.rows[0].props.header && !table.rows[1].props.header);
|
||||
}
|
||||
|
||||
/// #2242: malformed `insert_table` arguments are tool errors, not panics.
|
||||
#[test]
|
||||
fn insert_table_rejects_hostile_values() {
|
||||
let mut s = Server::new(Box::new(Headless::default()));
|
||||
let wide: Vec<Value> = (0..64).map(|i| json!(i)).collect();
|
||||
for args in [
|
||||
json!({}),
|
||||
json!({"values": "x"}),
|
||||
json!({"values": [["a", "b"], ["c"]]}),
|
||||
json!({"values": [wide]}),
|
||||
json!({"values": [[{"a": 1}]]}),
|
||||
json!({"values": []}),
|
||||
] {
|
||||
let r = call(&mut s, 12, "tools/call", json!({"name": "insert_table", "arguments": args}));
|
||||
assert_eq!(r["result"]["isError"], true, "{args}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An explicit `values: null` is a tool error and changes nothing: no table, and the undo history
|
||||
/// still ends with the earlier edit (one Undo takes that back, not a phantom table insertion).
|
||||
#[test]
|
||||
fn insert_table_rejects_null_values_without_changing_the_document() {
|
||||
let mut s = Server::new(Box::new(Headless::default()));
|
||||
tool(&mut s, "new_document", json!({}));
|
||||
tool(&mut s, "type_text", json!({"text": "keep"}));
|
||||
let r = call(&mut s, 13, "tools/call", json!({"name": "insert_table", "arguments": {"values": null}}));
|
||||
assert_eq!(r["result"]["isError"], true, "{r}");
|
||||
assert_eq!(paragraphs(&mut s).len(), 1, "no table or paragraph added");
|
||||
assert_eq!(tool(&mut s, "get_text", json!({}))["text"].as_str().unwrap(), "keep");
|
||||
assert!(!tool(&mut s, "inspect_document", json!({}))["blocks"].to_string().contains("table"));
|
||||
tool(&mut s, "execute", json!({"command": "edit.undo"}));
|
||||
assert_eq!(tool(&mut s, "get_text", json!({}))["text"].as_str().unwrap(), "", "the undo step is the typing, not an insert");
|
||||
}
|
||||
|
||||
/// #3676: a page number that cannot fit an index is rejected, not silently rendered as
|
||||
/// page 1 (the old cast truncated 2^32 to 0 on 32-bit and wasm).
|
||||
#[test]
|
||||
|
||||
+27
-1
@@ -75,10 +75,24 @@ pub fn tool_definitions() -> Value {
|
||||
tool(
|
||||
"execute",
|
||||
"Run a command",
|
||||
"Run one command by id with params, e.g. {command:\"format.bold\"} or {command:\"insert.table\", params:{rows:3, cols:4}}. Commands work on the active document, or on the open document `doc` names. file.new and file.open open another document beside the open ones and make it active; view.switchWindows {id} makes another one active and file.closeDocument {id, force?} closes one.",
|
||||
"Run one command by id with params, e.g. {command:\"format.bold\"} or {command:\"insert.table\", params:{rows:3, cols:4}}. To insert a table that is already filled, use `insert_table`. Commands work on the active document, or on the open document `doc` names. file.new and file.open open another document beside the open ones and make it active; view.switchWindows {id} makes another one active and file.closeDocument {id, force?} closes one.",
|
||||
with_doc(obj(json!({"command": s("Command id"), "params": {"type": "object", "description": "Command parameters"}}), &["command"])),
|
||||
false
|
||||
),
|
||||
tool(
|
||||
"insert_table",
|
||||
"Insert filled table",
|
||||
"Insert a table at the caret, filled from `values` in one call (an array of rows, each an array of cells; all rows the same length, up to 1000 rows and 63 columns). Set `header` to make the first row a repeating header row.",
|
||||
with_doc(obj(
|
||||
json!({
|
||||
"values": {"type": "array", "description": "Rows of cells (text, numbers or booleans)", "items": {"type": "array", "items": {"type": ["string", "number", "boolean", "null"]}}},
|
||||
"header": {"type": "boolean", "description": "First row is a header row that repeats on each page"},
|
||||
"style": s("Table style name (default GridTable1Light)")
|
||||
}),
|
||||
&["values"]
|
||||
)),
|
||||
false
|
||||
),
|
||||
tool(
|
||||
"batch",
|
||||
"Run several commands",
|
||||
@@ -238,6 +252,18 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
|
||||
Some(c) => wrap(exec(b, c, a.get("params").cloned().unwrap_or(json!({})), doc)),
|
||||
None => ToolResult::error("missing `command`"),
|
||||
},
|
||||
"insert_table" => match a.get("values") {
|
||||
Some(values) => {
|
||||
let mut params = json!({"values": values});
|
||||
for k in ["header", "style"] {
|
||||
if let Some(x) = a.get(k) {
|
||||
params[k] = x.clone();
|
||||
}
|
||||
}
|
||||
wrap(exec(b, "insert.spreadsheet", params, doc))
|
||||
}
|
||||
None => ToolResult::error("missing `values`: an array of rows, each an array of cells"),
|
||||
},
|
||||
"batch" => {
|
||||
let keep = a.get("keepGoing").and_then(Value::as_bool).unwrap_or(false);
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -842,10 +842,22 @@ fn draw_image(ctx: &mut RenderContext, fr: &Frame, it: &Draw) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Widest path drawn, in device pixels a side: 64 times the largest canvas. The rasteriser
|
||||
/// flattens a curve into as many points as its size asks for, before clipping, so a path with
|
||||
/// coordinates of 1e15 (file data in a native document) would take it forever or run out of
|
||||
/// memory; one this far past every canvas shows nothing a real drawing would.
|
||||
const MAX_PATH_PIXELS: f64 = 1_048_576.0;
|
||||
|
||||
/// Whether `path` under `view` is small enough to draw ([`MAX_PATH_PIXELS`]) and finite.
|
||||
fn path_extent_ok(view: Affine, path: &kurbo::BezPath) -> bool {
|
||||
let b = view.transform_rect_bbox(path.bounding_box());
|
||||
path.is_finite() && [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_PATH_PIXELS)
|
||||
}
|
||||
|
||||
fn draw_path(ctx: &mut RenderContext, fr: &Frame, it: &Draw) {
|
||||
let Draw::Path { segs, fill, stroke, stroke_width, dash, cap } = it else { return };
|
||||
let path = wordcraft_layout::display::seg_path(segs);
|
||||
if path.elements().is_empty() {
|
||||
if path.elements().is_empty() || !path_extent_ok(fr.view, &path) {
|
||||
return;
|
||||
}
|
||||
ctx.set_transform(fr.view);
|
||||
|
||||
@@ -1411,3 +1411,18 @@ fn a_line_runs_from_top_left_to_bottom_right() {
|
||||
let p = shape_path(ShapeKind::Line, kurbo::Rect::new(10.0, 20.0, 30.0, 60.0), None);
|
||||
assert_eq!(p.elements(), &[kurbo::PathEl::MoveTo((10.0, 20.0).into()), kurbo::PathEl::LineTo((30.0, 60.0).into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_far_past_every_canvas_are_not_flattened() {
|
||||
let line = |x: f64| {
|
||||
let mut p = kurbo::BezPath::new();
|
||||
p.move_to((0.0, 0.0));
|
||||
p.curve_to((x, 0.0), (x, x), (0.0, x));
|
||||
p
|
||||
};
|
||||
assert!(path_extent_ok(Affine::IDENTITY, &line(500.0)));
|
||||
assert!(path_extent_ok(Affine::scale(4.0), &line(200_000.0)), "a large page at a high zoom");
|
||||
assert!(!path_extent_ok(Affine::IDENTITY, &line(1e15)));
|
||||
assert!(!path_extent_ok(Affine::scale(1e12), &line(10.0)));
|
||||
assert!(!path_extent_ok(Affine::IDENTITY, &line(f64::NAN)));
|
||||
}
|
||||
@@ -377,6 +377,7 @@ impl WordApp {
|
||||
/// Read Aloud, its file dialog, AutoSave of its document. Runs in [`WordApp::logic`] for the
|
||||
/// active window and, with native windows, as each window draws.
|
||||
pub(crate) fn window_work(&mut self, ctx: &egui::Context) {
|
||||
crate::windows::arrange::poll(self, ctx);
|
||||
if self.windows.native() {
|
||||
let doc = self.session.document_id();
|
||||
if self.print_document.is_none_or(|d| d == doc || !self.session.is_open(d)) {
|
||||
|
||||
@@ -474,6 +474,7 @@ impl WordApp {
|
||||
if req.get("close").is_some() {
|
||||
self.quit_requested = true;
|
||||
}
|
||||
crate::windows::arrange::request(self, req);
|
||||
}
|
||||
|
||||
/// A request made for document `document`, which isn't shown and which no other window
|
||||
@@ -1046,6 +1047,7 @@ pub(crate) fn works_without_document(id: &str) -> bool {
|
||||
| "tools.customizeKeyboard"
|
||||
| "tools.customizeRibbon"
|
||||
| "view.darkMode"
|
||||
| "view.paperColor"
|
||||
| "view.marks"
|
||||
| "view.ruler"
|
||||
| "view.numeral"
|
||||
|
||||
@@ -223,6 +223,10 @@ pub struct CanvasState {
|
||||
/// The viewport (the content's visible rect on screen) at the end of last frame; the
|
||||
/// drag-select edge auto-scroll decides this frame's scroll from it (#688).
|
||||
pub(crate) viewport: Option<egui::Rect>,
|
||||
/// Synchronous Scrolling (#322): document points the partner window scrolled, still to apply…
|
||||
pub(crate) sync_in: f32,
|
||||
/// …and this window's own scrolling since the last frame, for the partner.
|
||||
pub(crate) sync_out: f32,
|
||||
/// Pages per row last frame; when it changes the caret's page is scrolled back into view.
|
||||
pub cols: usize,
|
||||
/// The comment whose balloon is selected (its text is editable in place, like Word's).
|
||||
@@ -440,6 +444,8 @@ impl Default for CanvasState {
|
||||
scroll_max: egui::Vec2::ZERO,
|
||||
scroll_request: None,
|
||||
viewport: None,
|
||||
sync_in: 0.0,
|
||||
sync_out: 0.0,
|
||||
cols: 1,
|
||||
balloon: None,
|
||||
balloon_focus: false,
|
||||
@@ -525,6 +531,8 @@ struct CanvasView {
|
||||
dim_body: bool,
|
||||
grid_story: Option<StoryRef>,
|
||||
caret: Option<wordcraft_layout::hit::Caret>,
|
||||
/// The zoom changed this frame (#322): the scroll offset moved with it, nobody scrolled.
|
||||
rescaled: bool,
|
||||
}
|
||||
|
||||
fn prepare_view(app: &mut WordApp, ui: &Ui, full: Rect, layout: &DocLayout) -> CanvasView {
|
||||
@@ -575,6 +583,8 @@ fn prepare_view(app: &mut WordApp, ui: &Ui, full: Rect, layout: &DocLayout) -> C
|
||||
if (geo.scale - app.canvas.scale).abs() > 1e-6 {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
// A zoom moves the scroll offset without anyone scrolling: not passed on to a partner window.
|
||||
let rescaled = (geo.scale - app.canvas.scale).abs() > 1e-4;
|
||||
app.canvas.scale = geo.scale;
|
||||
sync_fit_zoom(app, geo.scale);
|
||||
let anchored = if let Some((p, (page, x, y))) = zoom_anchor
|
||||
@@ -602,7 +612,7 @@ fn prepare_view(app: &mut WordApp, ui: &Ui, full: Rect, layout: &DocLayout) -> C
|
||||
{
|
||||
app.session.page_hint = c.page;
|
||||
}
|
||||
CanvasView { area, hruler, vruler, paper, animate, opts, geo, dim_body, grid_story, caret }
|
||||
CanvasView { area, hruler, vruler, paper, animate, opts, geo, dim_body, grid_story, caret, rescaled }
|
||||
}
|
||||
|
||||
fn update_caret_scroll(app: &mut WordApp, view: &CanvasView) -> Option<Rect> {
|
||||
@@ -706,7 +716,9 @@ fn scroll_motion(app: &mut WordApp, ui: &Ui, full: Rect, view: &CanvasView) -> S
|
||||
ScrollMotion { wheel: wheel_delta, auto: auto_delta, middle: middle_delta }
|
||||
}
|
||||
|
||||
fn configure_scroll_area(app: &mut WordApp, ui: &Ui, view: &CanvasView, motion: ScrollMotion) -> egui::ScrollArea {
|
||||
/// The scroll area, and the vertical offset this frame starts from once the partner window's
|
||||
/// scrolling is applied (Synchronous Scrolling, #322): what the window scrolls beyond it is its own.
|
||||
fn configure_scroll_area(app: &mut WordApp, ui: &Ui, view: &CanvasView, motion: ScrollMotion) -> (egui::ScrollArea, f32) {
|
||||
let delta = motion.wheel + motion.auto + motion.middle;
|
||||
// The canvas scrolls itself on wheel/touchpad input (crate::scroll): touchpads 1:1 with
|
||||
// momentum, wheel notches eased in (or all at once with animation effects off). Applied
|
||||
@@ -722,8 +734,11 @@ fn configure_scroll_area(app: &mut WordApp, ui: &Ui, view: &CanvasView, motion:
|
||||
if app.session.view.read_mode {
|
||||
scroll_area = scroll_area.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden);
|
||||
}
|
||||
if delta != egui::Vec2::ZERO || app.canvas.scroll_request.is_some() || app.session.view.read_mode {
|
||||
let before = app.canvas.scroll_offset;
|
||||
// Synchronous Scrolling (#322): the partner window's scrolling, in document points.
|
||||
let synced = std::mem::take(&mut app.canvas.sync_in) * view.geo.scale;
|
||||
let before = app.canvas.scroll_offset;
|
||||
let mut from_partner = 0.0;
|
||||
if delta != egui::Vec2::ZERO || app.canvas.scroll_request.is_some() || app.session.view.read_mode || synced != 0.0 {
|
||||
let step = app.session.view.read_width * view.geo.scale + GAP;
|
||||
let snapped = if step.is_finite() && step > 0.0 { vec2((before.x / step).round() * step, 0.0) } else { egui::Vec2::ZERO };
|
||||
let after = match app.canvas.scroll_request.take() {
|
||||
@@ -731,6 +746,13 @@ fn configure_scroll_area(app: &mut WordApp, ui: &Ui, view: &CanvasView, motion:
|
||||
None if app.session.view.read_mode => snapped.clamp(egui::Vec2::ZERO, app.canvas.scroll_max),
|
||||
None => clamped_scroll(before, delta, app.canvas.scroll_max),
|
||||
};
|
||||
let after = if synced != 0.0 {
|
||||
let y = (after.y + synced).clamp(0.0, app.canvas.scroll_max.y);
|
||||
from_partner = y - after.y;
|
||||
vec2(after.x, y)
|
||||
} else {
|
||||
after
|
||||
};
|
||||
if after == before && motion.wheel != egui::Vec2::ZERO {
|
||||
app.canvas.wheel.hit_edge();
|
||||
}
|
||||
@@ -743,7 +765,7 @@ fn configure_scroll_area(app: &mut WordApp, ui: &Ui, view: &CanvasView, motion:
|
||||
if app.canvas.wheel.is_animating() {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
scroll_area
|
||||
(scroll_area, before.y + from_partner)
|
||||
}
|
||||
|
||||
fn prepare_layout(app: &mut WordApp, ui: &Ui, full: Rect) -> std::sync::Arc<DocLayout> {
|
||||
@@ -780,6 +802,17 @@ struct CanvasFinish<'a> {
|
||||
dim_body: bool,
|
||||
hruler: Option<Rect>,
|
||||
vruler: Option<Rect>,
|
||||
scroll_base: f32,
|
||||
rescaled: bool,
|
||||
}
|
||||
|
||||
/// What a window's own scrolling is measured against for Synchronous Scrolling (#322).
|
||||
#[derive(Clone, Copy)]
|
||||
struct ScrollSync {
|
||||
/// The vertical offset the frame started from, the partner's scrolling included.
|
||||
base: f32,
|
||||
/// The zoom changed: the offset moved, nobody scrolled.
|
||||
rescaled: bool,
|
||||
}
|
||||
|
||||
fn update_canvas_scroll_state(
|
||||
@@ -789,6 +822,7 @@ fn update_canvas_scroll_state(
|
||||
geo: &Geometry,
|
||||
caret: Option<wordcraft_layout::hit::Caret>,
|
||||
out: &egui::scroll_area::ScrollAreaOutput<(egui::Response, Vec<Rect>)>,
|
||||
sync: ScrollSync,
|
||||
) {
|
||||
app.canvas.scroll_offset = out.state.clamped_offset();
|
||||
scrollbar_page_tip(app, ui, out.id, &geo.rects, &geo.bands, layout.pages.len());
|
||||
@@ -804,6 +838,11 @@ fn update_canvas_scroll_state(
|
||||
viewed_page(extent, &geo.rects, &geo.bands, app.canvas.scroll_offset.y, out.inner_rect.height()).min(layout.pages.len().saturating_sub(1))
|
||||
});
|
||||
crate::ink::replay_bar(app, ui, out.inner_rect);
|
||||
// This window's own scrolling (wheel, scroll bar, keys, caret), for a partner window.
|
||||
let own = app.canvas.scroll_offset.y - sync.base;
|
||||
if app.session.windows.sync_scroll && !sync.rescaled && own.abs() > 0.01 && geo.scale > 0.0 {
|
||||
app.canvas.sync_out += own / geo.scale;
|
||||
}
|
||||
app.canvas.scroll_max = (out.content_size - out.inner_rect.size()).max(egui::Vec2::ZERO);
|
||||
if app.session.view.read_mode {
|
||||
let step = app.session.view.read_width * geo.scale + GAP;
|
||||
@@ -1202,8 +1241,8 @@ fn handle_secondary_selection(app: &mut WordApp, resp: &egui::Response, rects: &
|
||||
}
|
||||
|
||||
fn finish_canvas_frame(finish: CanvasFinish<'_>, out: egui::scroll_area::ScrollAreaOutput<(egui::Response, Vec<Rect>)>) {
|
||||
let CanvasFinish { app, ui, layout, geo, caret, ime_geometry, ime_interrupt, area, dim_body, hruler, vruler } = finish;
|
||||
update_canvas_scroll_state(app, ui, layout, geo, caret, &out);
|
||||
let CanvasFinish { app, ui, layout, geo, caret, ime_geometry, ime_interrupt, area, dim_body, hruler, vruler, scroll_base, rescaled } = finish;
|
||||
update_canvas_scroll_state(app, ui, layout, geo, caret, &out, ScrollSync { base: scroll_base, rescaled });
|
||||
let (resp, rects) = out.inner;
|
||||
// A click on the page leaves the selected comment balloon.
|
||||
dismiss_balloon_on_canvas_click(app, ui, &resp);
|
||||
@@ -1406,8 +1445,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let view = prepare_view(app, ui, full, &layout);
|
||||
let scroll_target = if hold_scroll_for_preview(app) { None } else { update_caret_scroll(app, &view) };
|
||||
let motion = scroll_motion(app, ui, full, &view);
|
||||
let scroll_area = configure_scroll_area(app, ui, &view, motion);
|
||||
let CanvasView { area, hruler, vruler, paper, geo, dim_body, grid_story, caret, .. } = view;
|
||||
let (scroll_area, scroll_base) = configure_scroll_area(app, ui, &view, motion);
|
||||
let CanvasView { area, hruler, vruler, paper, geo, dim_body, grid_story, caret, rescaled, .. } = view;
|
||||
let mut origin = area.min;
|
||||
let mut ui_area = ui.new_child(egui::UiBuilder::new().max_rect(area));
|
||||
// The bars are drawn with `ui_area`'s style; what the pages draw gets the inherited one back.
|
||||
@@ -1697,7 +1736,21 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
(resp, rects)
|
||||
});
|
||||
finish_canvas_frame(
|
||||
CanvasFinish { app, ui, layout: &layout, geo: &geo, caret, ime_geometry, ime_interrupt, area, dim_body, hruler, vruler },
|
||||
CanvasFinish {
|
||||
app,
|
||||
ui,
|
||||
layout: &layout,
|
||||
geo: &geo,
|
||||
caret,
|
||||
ime_geometry,
|
||||
ime_interrupt,
|
||||
area,
|
||||
dim_body,
|
||||
hruler,
|
||||
vruler,
|
||||
scroll_base,
|
||||
rescaled,
|
||||
},
|
||||
out,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ pub(super) fn view_key(app: &WordApp, dim_body: bool, hidden: Option<&Pos>) -> u
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
let v = &app.session.view;
|
||||
(v.marks, app.session.prefs.marks, v.show_markup, v.dark_mode, wordcraft_render::DARK_PAPER, v.hide_ink, v.draw.replay).hash(&mut h);
|
||||
(v.read_mode, v.read_page_color).hash(&mut h);
|
||||
(v.read_mode, v.read_page_color, app.session.prefs.paper_color).hash(&mut h);
|
||||
app.session.prefs.markup.hash(&mut h);
|
||||
dim_body.hash(&mut h);
|
||||
format!("{:?}{:?}{:?}", app.session.doc.settings.page_color, app.session.doc.settings.page_picture, app.session.doc.settings.watermark)
|
||||
@@ -244,11 +244,19 @@ pub(super) fn page_render_options(app: &WordApp, dim_body: bool) -> wordcraft_re
|
||||
opts.display.revisions = app.session.prefs.markup.clone();
|
||||
opts.dark = dark_page(app);
|
||||
opts.dark_paper = wordcraft_render::DARK_PAPER;
|
||||
// In Read Mode the Page color sets the paper.
|
||||
// A screen tint (View › Paper color) paints the page, unless the page is drawn dark.
|
||||
if !opts.dark {
|
||||
opts.paper = app.session.prefs.paper_color.color();
|
||||
}
|
||||
// In Read Mode the Page color sets the paper: Sepia and Inverse as before, None the
|
||||
// selected screen tint (white without one).
|
||||
if app.session.view.read_mode {
|
||||
use wordcraft_engine::cmd::view::ReadPageColor;
|
||||
let color = app.session.view.read_page_color;
|
||||
opts.read_paper = Some(if color == ReadPageColor::Sepia { wordcraft_render::SEPIA_PAPER } else { wordcraft_doc::props::Rgb::WHITE });
|
||||
opts.read_paper = Some(match app.session.view.read_page_color {
|
||||
ReadPageColor::None => app.session.prefs.paper_color.color(),
|
||||
ReadPageColor::Sepia => wordcraft_render::SEPIA_PAPER,
|
||||
ReadPageColor::Inverse => wordcraft_doc::props::Rgb::WHITE,
|
||||
});
|
||||
}
|
||||
opts.display.dim_header = !dim_body;
|
||||
opts.display.dim_body = dim_body;
|
||||
|
||||
@@ -4935,6 +4935,47 @@ fn the_view_key_changes_when_a_drag_takes_an_object_off_the_page() {
|
||||
assert_eq!(hidden, view_key(&app, false, Some(&object)), "the same drag keeps the key: no re-render every frame");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_paper_color_changes_raster_and_cache_without_changing_export_paper() {
|
||||
use wordcraft_doc::props::Rgb;
|
||||
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
|
||||
let layout = app.session.layout();
|
||||
let page = &layout.pages[0];
|
||||
let print_before = wordcraft_render::render_page(&app.session.doc, page, 0.1, &Default::default());
|
||||
let mut keys = vec![view_key(&app, false, None)];
|
||||
for (value, expected) in [("green", Rgb(0xCC, 0xE8, 0xCF)), ("cream", Rgb(0xFA, 0xF0, 0xDC)), ("gray", Rgb(0xE8, 0xEA, 0xED))] {
|
||||
app.run("view.paperColor", json!({"value":value})).unwrap();
|
||||
let key = view_key(&app, false, None);
|
||||
assert!(!keys.contains(&key), "each preset invalidates page and tile caches");
|
||||
keys.push(key);
|
||||
let opts = page_render_options(&app, false);
|
||||
assert_eq!(opts.paper_of(&app.session.doc), expected);
|
||||
let screen = wordcraft_render::render_page(&app.session.doc, page, 0.1, &opts);
|
||||
assert_eq!(&screen.pixels[..4], &[expected.0, expected.1, expected.2, 255]);
|
||||
let print = wordcraft_render::render_page(&app.session.doc, page, 0.1, &Default::default());
|
||||
assert_eq!(print.pixels, print_before.pixels, "screen tints cannot leak into output rasters");
|
||||
}
|
||||
app.run("view.darkMode", json!({"value":true})).unwrap();
|
||||
let d = wordcraft_render::DARK_PAPER;
|
||||
assert_eq!(page_render_options(&app, false).paper_of(&app.session.doc), Rgb(d, d, d));
|
||||
app.run("view.darkMode", json!({"value":false})).unwrap();
|
||||
assert_eq!(page_render_options(&app, false).paper_of(&app.session.doc), Rgb(0xE8, 0xEA, 0xED));
|
||||
app.run("design.pageColor", json!({"color":"#102040"})).unwrap();
|
||||
assert_eq!(page_render_options(&app, false).paper_of(&app.session.doc), Rgb(0x10, 0x20, 0x40));
|
||||
app.run("view.readMode", json!({})).unwrap();
|
||||
assert_eq!(page_render_options(&app, false).paper_of(&app.session.doc), Rgb(0xE8, 0xEA, 0xED));
|
||||
app.run("view.darkMode", json!({"value":true})).unwrap();
|
||||
assert!(!page_render_options(&app, false).dark, "Switch Modes is ignored in Read Mode");
|
||||
assert_eq!(page_render_options(&app, false).paper_of(&app.session.doc), Rgb(0xE8, 0xEA, 0xED));
|
||||
app.run("view.darkMode", json!({"value":false})).unwrap();
|
||||
assert_eq!(page_render_options(&app, false).paper_of(&app.session.doc), Rgb(0xE8, 0xEA, 0xED));
|
||||
app.run("view.readPageColor", json!({"value":"sepia"})).unwrap();
|
||||
assert_eq!(page_render_options(&app, false).paper_of(&app.session.doc), wordcraft_render::SEPIA_PAPER);
|
||||
app.run("view.readPageColor", json!({"value":"inverse"})).unwrap();
|
||||
let d = wordcraft_render::DARK_PAPER;
|
||||
assert_eq!(page_render_options(&app, false).paper_of(&app.session.doc), Rgb(d, d, d));
|
||||
}
|
||||
|
||||
/// A two-paragraph document with a 200-line comment (`line` gives each line's text) on the first
|
||||
/// and a short one on the second, drawn without the Comments pane; returns both comment ids.
|
||||
fn long_comment_balloons(line: fn(usize) -> String) -> (egui_kittest::Harness<'static, WordApp>, u32, u32) {
|
||||
|
||||
@@ -20,6 +20,7 @@ pub fn inspect(app: &mut WordApp, ctx: &egui::Context) -> Value {
|
||||
json!({
|
||||
"ui": app.prefs(),
|
||||
"view": app.session.view,
|
||||
"windows": app.session.windows,
|
||||
"proofing": app.spelling_review,
|
||||
"dialog": app.dialog.as_ref().map(|d| serde_json::to_value(d).unwrap_or_default()),
|
||||
"window": [r.width(), r.height()],
|
||||
|
||||
@@ -1102,7 +1102,14 @@ pub(crate) fn search_results(app: &mut WordApp, ui: &mut Ui, query: &str, enter:
|
||||
find = ui.add(egui::Button::new(label).min_size(vec2(width, 0.0))).clicked() || (enter && hits.is_empty());
|
||||
}
|
||||
if let Some(id) = run {
|
||||
let _ = app.run(id, json!({}));
|
||||
if id == "view.paperColor" {
|
||||
// Command Search is an interactive UI entry point: open the selector instead of
|
||||
// sending the engine command an empty value. Programmatic callers still get the
|
||||
// command's normal parameter validation.
|
||||
let _ = app.run("file.options", json!({"pane": "general"}));
|
||||
} else {
|
||||
let _ = app.run(id, json!({}));
|
||||
}
|
||||
} else if find {
|
||||
find_in_navigation_pane(app, ui.ctx(), &text);
|
||||
}
|
||||
|
||||
@@ -249,6 +249,28 @@ fn command_search_matches_every_word() {
|
||||
assert!(!command_matches(table, " "));
|
||||
}
|
||||
|
||||
/// #3398: the interactive command-search result opens the paper-colour selector. The engine
|
||||
/// command itself remains parameterised for scripts, agents and other programmatic callers.
|
||||
#[test]
|
||||
fn screen_paper_color_search_result_opens_general_options() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use wordcraft_engine::cmd::view::PaperColor;
|
||||
|
||||
let mut app = app_with("Unchanged document");
|
||||
app.dialog = Some(Dialog::Commands { query: "screen paper color".into(), swallow: false });
|
||||
let mut h = app_harness(app);
|
||||
h.run_steps(4);
|
||||
h.query(egui_kittest::kittest::By::new().role(egui::accesskit::Role::Button).label_contains("Screen Paper Color"))
|
||||
.expect("the paper-colour command is a search result")
|
||||
.click();
|
||||
h.run_steps(3);
|
||||
|
||||
assert!(h.state().ui.backstage);
|
||||
assert_eq!(h.state().ui.backstage_page, "options");
|
||||
assert_eq!(h.state().ui.options_pane, "general");
|
||||
assert_eq!(h.state().session.prefs.paper_color, PaperColor::Document, "opening the selector does not choose a preset");
|
||||
}
|
||||
|
||||
/// Ordinary Turkish queries must find dotted-I labels, while canonical English labels and
|
||||
/// ASCII command IDs retain their old case-insensitive search in every interface language.
|
||||
#[test]
|
||||
|
||||
@@ -1129,10 +1129,26 @@ fn paint_view_and_tables(pen: &Pen, name: &str) -> bool {
|
||||
pen.line_c(&[(15.0, 14.0), (12.0, 11.5)], a);
|
||||
pen.line_c(&[(5.0, 14.0), (8.0, 11.5)], a);
|
||||
}
|
||||
"sideBySide" | "syncScroll" | "switchWindows" => {
|
||||
"sideBySide" => {
|
||||
pen.rect(2.0, 4.0, 9.5, 16.0, c);
|
||||
pen.rect(10.5, 4.0, 18.0, 16.0, a);
|
||||
}
|
||||
"syncScroll" => {
|
||||
// Two windows side by side, both scrolling down.
|
||||
pen.rect(2.0, 3.0, 9.5, 17.0, c);
|
||||
pen.rect(10.5, 3.0, 18.0, 17.0, c);
|
||||
for x in [5.75, 14.25] {
|
||||
pen.line_c(&[(x, 6.0), (x, 12.0)], a);
|
||||
pen.fill(&[(x - 2.0, 11.0), (x + 2.0, 11.0), (x, 14.0)], a);
|
||||
}
|
||||
}
|
||||
"switchWindows" => {
|
||||
// A window behind another, with a title bar each.
|
||||
pen.rect(2.5, 7.0, 13.0, 17.5, c);
|
||||
pen.line_c(&[(2.5, 9.5), (13.0, 9.5)], c);
|
||||
pen.rect(7.0, 2.5, 17.5, 13.0, a);
|
||||
pen.line_c(&[(7.0, 5.0), (17.5, 5.0)], a);
|
||||
}
|
||||
"macros" => {
|
||||
pen.rect(3.0, 3.0, 17.0, 17.0, c);
|
||||
pen.fill(&[(8.0, 6.5), (8.0, 13.5), (14.0, 10.0)], a);
|
||||
|
||||
@@ -1335,3 +1335,24 @@ fn typing_with_smart_art_selected_types_into_an_item() {
|
||||
let g = wordcraft_engine::cmd::smart_art::selected_smart_art(&a.session).expect("still there");
|
||||
assert_eq!(g.items[0].text, "Ada", "one undo step for the typing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_previous_sentence_shortcut_dispatches_and_can_be_reassigned() {
|
||||
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
app.run("text.insert", json!({"text": "First. Second sentence.", "raw": true})).unwrap();
|
||||
let mods = if cfg!(target_os = "macos") {
|
||||
Modifiers { mac_cmd: true, command: true, shift: true, ..Default::default() }
|
||||
} else {
|
||||
Modifiers { ctrl: true, command: true, shift: true, ..Default::default() }
|
||||
};
|
||||
assert!(dispatch(&mut app, Key::Backspace, mods));
|
||||
assert_eq!(app.session.doc.para_at(&app.session.sel.focus).unwrap().text, "First. ");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert!(dispatch(&mut app, Key::Backspace, Modifiers { shift: false, ..mods }));
|
||||
assert_eq!(app.session.doc.para_at(&app.session.sel.focus).unwrap().text, "First. Second ");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
app.session.keymap = wordcraft_engine::keymap::KeyMap::from_value(&json!({"assigned": {"Mod+Shift+Backspace": "select.all"}}));
|
||||
assert!(dispatch(&mut app, Key::Backspace, mods));
|
||||
assert!(!app.session.sel.is_collapsed());
|
||||
assert_eq!(app.session.doc.para_at(&app.session.sel.focus).unwrap().text, "First. Second sentence.");
|
||||
}
|
||||
@@ -389,6 +389,9 @@ fn controls(tab: &str) -> Vec<Control> {
|
||||
c("New Window", "view.newWindow"),
|
||||
c("Arrange All", "view.arrangeAll"),
|
||||
c("Split", "view.split"),
|
||||
c("View Side by Side", "view.sideBySide"),
|
||||
c("Synchronous Scrolling", "view.syncScroll"),
|
||||
m("Switch Windows", "view.switchWindows"),
|
||||
c("Full Screen", "view.fullScreen"),
|
||||
c("Macros", "tools.macros"),
|
||||
],
|
||||
|
||||
@@ -225,6 +225,21 @@ fn general(app: &mut WordApp, ui: &mut Ui) {
|
||||
section(ui, "Interface");
|
||||
theme_picker(app, ui);
|
||||
interface_size_picker(app, ui);
|
||||
ui.horizontal(|ui| {
|
||||
use wordcraft_engine::cmd::view::PaperColor;
|
||||
let label = ui.label(tl!("Screen paper color:"));
|
||||
let selected = app.session.prefs.paper_color;
|
||||
let combo = craft_ui::ComboBox::from_id_salt("screen_paper_color").selected_text(tl!(selected.label())).width(220.0).show_ui(ui, |ui| {
|
||||
for color in PaperColor::ALL {
|
||||
if ui.selectable_label(selected == color, tl!(color.label())).clicked() {
|
||||
let _ = app.run("view.paperColor", json!({"value": color}));
|
||||
}
|
||||
}
|
||||
});
|
||||
combo.response.labelled_by(label.id).on_hover_text(tl!(
|
||||
"Screen only. Document backgrounds, printing and exports keep their colours. Dark page and explicit Read Mode colours take precedence."
|
||||
));
|
||||
});
|
||||
ui.checkbox(&mut app.ui.mini_toolbar, tl!("Show the mini toolbar when text is selected"));
|
||||
let mut dark_page = app.session.view.dark_mode;
|
||||
if ui
|
||||
|
||||
@@ -494,3 +494,30 @@ fn length_units_follow_the_interface_language() {
|
||||
h.run_steps(4);
|
||||
assert_eq!(length(72.0), "25,4 mm", "English keeps the English names");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_paper_color_options_round_trip_and_work_without_a_document() {
|
||||
use wordcraft_engine::cmd::view::PaperColor;
|
||||
let mut a = app();
|
||||
a.start_screen();
|
||||
a.run("view.paperColor", json!({"value":"green"})).unwrap();
|
||||
let saved = serde_json::to_vec(&a.prefs()).unwrap();
|
||||
let mut b = app();
|
||||
b.apply_prefs(UiState::from_json(&saved).unwrap());
|
||||
assert_eq!(b.session.prefs.paper_color, PaperColor::Green);
|
||||
b.run("file.options", json!({"pane":"general"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
b,
|
||||
);
|
||||
h.run_steps(6);
|
||||
h.get_by_label("Screen paper color:").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Warm cream").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.prefs.paper_color, PaperColor::Cream);
|
||||
}
|
||||
@@ -330,6 +330,32 @@ pub(super) fn home(app: &mut WordApp, ui: &mut Ui) {
|
||||
});
|
||||
});
|
||||
});
|
||||
// Advanced paragraph tools (#5043): conversions and cleanup over the selection, or the
|
||||
// whole document with Ctrl+A first (the commands take `scope: "all"` for that as well).
|
||||
group(ui, "Paragraph Tools", None, app, |ui, app| {
|
||||
menu_button(ui, app, crate::widgets::MenuButtonOptions::new("pilcrow", Some("Paragraph\nTools"), "Paragraph Tools", true, ""), |ui, app| {
|
||||
mi(ui, app, "Split Lines into Paragraphs", "para.convertBreaks", json!({}));
|
||||
mi(ui, app, "Add Blank Paragraphs Between", "para.blankBetween", json!({}));
|
||||
mi(ui, app, "Separate Paragraphs", "para.separateParagraphs", json!({}));
|
||||
ui.separator();
|
||||
mi(ui, app, "Delete Blank Paragraphs", "para.removeBlank", json!({}));
|
||||
mi(ui, app, "Remove Leading Spaces", "para.trimLeadingSpaces", json!({}));
|
||||
mi(ui, app, "Remove Extra Spaces", "para.removeExtraSpaces", json!({}));
|
||||
ui.separator();
|
||||
mi(ui, app, "Indent Leading Spaces", "para.spacesToIndent", json!({}));
|
||||
mi(ui, app, "Indents to Leading Spaces", "para.indentToSpaces", json!({}));
|
||||
mi(ui, app, "Join Lines", "para.removeLineBreaks", json!({}));
|
||||
ui.separator();
|
||||
// The whole document, not just the selection.
|
||||
for (l, id) in [
|
||||
("Delete Blank Paragraphs in the Whole Document", "para.removeBlank"),
|
||||
("Remove Leading Spaces in the Whole Document", "para.trimLeadingSpaces"),
|
||||
("Remove Extra Spaces in the Whole Document", "para.removeExtraSpaces"),
|
||||
] {
|
||||
mi(ui, app, l, id, json!({"scope": "all"}));
|
||||
}
|
||||
});
|
||||
});
|
||||
group(ui, "Styles", Some("styles.pane"), app, |ui, app| {
|
||||
crate::previews::style_gallery(app, ui, &st);
|
||||
});
|
||||
|
||||
@@ -113,6 +113,50 @@ fn design_style_set_gallery_stays_in_its_box() {
|
||||
assert!(next_x >= gallery.right() + 8.0 - 0.01, "{next_x} {gallery:?}");
|
||||
}
|
||||
|
||||
/// #5043: Home › Paragraph Tools: the drop-down's entries each run their own command over the
|
||||
/// selection; "in the Whole Document" entries run the same command with `scope: "all"`.
|
||||
#[test]
|
||||
fn paragraph_tools_menu_entries_run_their_commands() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let a = WordApp::new(Session::new(wordcraft_doc::Document::from_text("a b\n\nc d")), crate::Services::default());
|
||||
let mut h = egui_kittest::Harness::builder()
|
||||
.with_size(vec2(900.0, 300.0))
|
||||
.with_context_setup(|ctx| ctx.set_fonts(crate::theme::font_definitions(false, false)))
|
||||
.build_ui_state(|ui, app: &mut WordApp| super::home::home(app, ui), a);
|
||||
h.run_steps(2);
|
||||
// The accessible label is the menu button's tip, "Paragraph Tools".
|
||||
h.get_by_label("Paragraph Tools").click();
|
||||
h.run_steps(2);
|
||||
// Items carry their (empty) shortcut text in the label, so match by containment.
|
||||
for name in [
|
||||
"Split Lines into Paragraphs",
|
||||
"Add Blank Paragraphs Between",
|
||||
"Separate Paragraphs",
|
||||
"Delete Blank Paragraphs",
|
||||
"Remove Leading Spaces",
|
||||
"Remove Extra Spaces",
|
||||
"Indent Leading Spaces",
|
||||
"Indents to Leading Spaces",
|
||||
"Join Lines",
|
||||
"Delete Blank Paragraphs in the Whole Document",
|
||||
"Remove Leading Spaces in the Whole Document",
|
||||
"Remove Extra Spaces in the Whole Document",
|
||||
] {
|
||||
assert!(!h.query_all_by_label_contains(name).collect::<Vec<_>>().is_empty(), "{name} is a named entry");
|
||||
}
|
||||
// A caret, not a selection: the whole-document entries cover both paragraphs.
|
||||
h.get_by_label_contains("Remove Extra Spaces in the Whole Document").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "a b\n\nc d");
|
||||
// The menu closed with the click; open it again for the next entry.
|
||||
h.get_by_label("Paragraph Tools").click();
|
||||
h.run_steps(2);
|
||||
// Both entries match by containment; the plain one is first in the menu.
|
||||
h.query_all_by_label_contains("Delete Blank Paragraphs").min_by(|a, b| a.rect().min.y.total_cmp(&b.rect().min.y)).unwrap().click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.undo_depth(), 2, "the second entry is its own undo step");
|
||||
}
|
||||
|
||||
/// #5750: in a right-to-left interface, the Design tab's style-set gallery lays out from the
|
||||
/// right: the first style set ("default") is at the right edge, the remaining thumbnails read to
|
||||
/// the left, and the next controls start after a gap to the left of the gallery.
|
||||
|
||||
@@ -93,6 +93,23 @@ pub(super) fn view(app: &mut WordApp, ui: &mut Ui) {
|
||||
big(ui, app, "newWindow", "New\nWindow", "view.newWindow", json!({}), false);
|
||||
big(ui, app, "arrangeAll", "Arrange\nAll", "view.arrangeAll", json!({}), false);
|
||||
big(ui, app, "split", "Split", "view.split", json!({}), false);
|
||||
// Across the app's windows (#322; `windows`).
|
||||
let w = app.session.windows.clone();
|
||||
stack(ui, |ui| {
|
||||
if w.side_by_side.is_none() && w.others.len() > 1 {
|
||||
menu_button(
|
||||
ui,
|
||||
app,
|
||||
crate::widgets::MenuButtonOptions::new("sideBySide", Some("View Side by Side"), "View Side by Side", false, "view.sideBySide"),
|
||||
|ui, app| window_list(ui, app, &w.others, "view.sideBySide"),
|
||||
);
|
||||
} else {
|
||||
let action = crate::widgets::ButtonAction::new("view.sideBySide", json!({}), w.side_by_side.is_some());
|
||||
small(ui, app, "sideBySide", Some("View Side by Side"), "View Side by Side", action);
|
||||
}
|
||||
let action = crate::widgets::ButtonAction::new("view.syncScroll", json!({}), w.sync_scroll);
|
||||
small(ui, app, "syncScroll", Some("Synchronous Scrolling"), "Synchronous Scrolling", action);
|
||||
});
|
||||
menu_button(
|
||||
ui,
|
||||
app,
|
||||
@@ -186,3 +203,16 @@ pub(super) fn outlining(app: &mut WordApp, ui: &mut Ui) {
|
||||
big(ui, app, "closeOutline", "Close Outline\nView", "outline.close", json!({}), false);
|
||||
});
|
||||
}
|
||||
|
||||
/// The other WordCraft windows by title; picking one runs `id` with it.
|
||||
fn window_list(ui: &mut Ui, app: &mut WordApp, others: &[wordcraft_engine::cmd::view::OtherWindow], id: &str) {
|
||||
if others.is_empty() {
|
||||
ui.add_enabled(false, egui::Label::new(tl!("No other WordCraft windows are open.")));
|
||||
}
|
||||
for o in others {
|
||||
if ui.add(egui::Button::new(o.title.as_str()).min_size(vec2(200.0, 0.0))).clicked() {
|
||||
let _ = app.run(id, json!({"window": o.id}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ const MAX_SIZE: egui::Vec2 = egui::vec2(16384.0, 16384.0);
|
||||
const MAX_COORD: f32 = 32768.0;
|
||||
/// Room a window leaves on each side of its monitor for the taskbar, the Dock or a desktop panel,
|
||||
/// which the monitor size (all egui reports) includes.
|
||||
const SYSTEM_BAR: egui::Vec2 = egui::vec2(48.0, 48.0);
|
||||
pub const SYSTEM_BAR: egui::Vec2 = egui::vec2(48.0, 48.0);
|
||||
/// How much of a window's top edge must be on one monitor for the pointer to drag it back.
|
||||
const GRIP: egui::Vec2 = egui::vec2(96.0, 16.0);
|
||||
|
||||
@@ -234,6 +234,30 @@ pub fn requested_size(size: egui::Vec2, max_side: f32) -> Option<egui::Vec2> {
|
||||
Some(size.max(MIN_SIZE).min(max))
|
||||
}
|
||||
|
||||
/// Clamps an inner window size so that the window (including decoration `frame`) fits on `monitor_size`
|
||||
/// leaving room on each side for system bars ([`SYSTEM_BAR`]), bounded below by [`MIN_SIZE`] (#5871).
|
||||
pub fn fit_inner_size(inner: egui::Vec2, monitor_size: egui::Vec2, frame: egui::Vec2) -> egui::Vec2 {
|
||||
if !inner.is_finite() || inner.x <= 0.0 || inner.y <= 0.0 {
|
||||
return MIN_SIZE;
|
||||
}
|
||||
if !monitor_size.is_finite() || monitor_size.x <= 0.0 || monitor_size.y <= 0.0 {
|
||||
return inner.max(MIN_SIZE);
|
||||
}
|
||||
let frame = if frame.is_finite() { frame.max(egui::Vec2::ZERO) } else { egui::Vec2::ZERO };
|
||||
let room = (monitor_size - 2.0 * SYSTEM_BAR).max(MIN_SIZE);
|
||||
let max = (room - frame).max(MIN_SIZE);
|
||||
inner.min(max).max(MIN_SIZE)
|
||||
}
|
||||
|
||||
/// Returns a copy of `builder` whose requested inner size is clamped to fit within `monitor_size`
|
||||
/// (including decoration `frame`) with room for system bars ([`SYSTEM_BAR`]) (#5871).
|
||||
pub fn fit_builder_to_monitor(builder: egui::ViewportBuilder, monitor_size: egui::Vec2, frame: egui::Vec2) -> egui::ViewportBuilder {
|
||||
match builder.inner_size {
|
||||
Some(inner) => builder.with_inner_size(fit_inner_size(inner, monitor_size, frame)),
|
||||
None => builder,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -567,4 +591,38 @@ mod tests {
|
||||
// Wayland reports no rects.
|
||||
assert_eq!(system.fit_on_monitor(&RawWindowState { monitor_size: Some(laptop), ..Default::default() }), None);
|
||||
}
|
||||
|
||||
/// On displays with smaller dimensions or high DPI scaling (e.g. 2560x1440 at 200% scale,
|
||||
/// yielding a 1280x720 pt screen), the requested inner size shrinks to fit the screen
|
||||
/// rather than extending past the boundaries (#5871).
|
||||
#[test]
|
||||
fn fit_inner_size_constrains_window_to_available_screen_dimensions() {
|
||||
let default_inner = egui::vec2(1440.0, 920.0);
|
||||
let frame = egui::vec2(16.0, 39.0);
|
||||
|
||||
// 2560x1440 display with 200% scaling: 1280x720 pt.
|
||||
let scaled_1440p = egui::vec2(1280.0, 720.0);
|
||||
let fitted = fit_inner_size(default_inner, scaled_1440p, frame);
|
||||
assert_eq!(fitted, egui::vec2(1168.0, 585.0));
|
||||
assert!(fitted.x + frame.x <= scaled_1440p.x);
|
||||
assert!(fitted.y + frame.y <= scaled_1440p.y);
|
||||
|
||||
// Standard 2560x1440 at 100% scaling: ample room, size remains untouched.
|
||||
let large_monitor = egui::vec2(2560.0, 1440.0);
|
||||
assert_eq!(fit_inner_size(default_inner, large_monitor, frame), default_inner);
|
||||
|
||||
// Tiny display: clamps to minimum inner size.
|
||||
let tiny = egui::vec2(600.0, 400.0);
|
||||
assert_eq!(fit_inner_size(default_inner, tiny, frame), MIN_SIZE);
|
||||
|
||||
// Hostile / non-finite inputs never panic and fall back safely.
|
||||
assert_eq!(fit_inner_size(egui::vec2(f32::NAN, 100.0), scaled_1440p, frame), MIN_SIZE);
|
||||
assert_eq!(fit_inner_size(default_inner, egui::vec2(f32::INFINITY, f32::NAN), frame), default_inner.max(MIN_SIZE));
|
||||
|
||||
// Viewport builder adaptation preserves other builder properties while clamping inner size.
|
||||
let builder = egui::ViewportBuilder::default().with_inner_size([1440.0, 920.0]).with_title("Test");
|
||||
let adapted = fit_builder_to_monitor(builder, scaled_1440p, frame);
|
||||
assert_eq!(adapted.inner_size, Some(egui::vec2(1168.0, 585.0)));
|
||||
assert_eq!(adapted.title, Some("Test".into()));
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,8 @@ use wordcraft_engine::WindowView;
|
||||
|
||||
use crate::{WordApp, documents, window_ui::WindowUi};
|
||||
|
||||
pub(crate) mod arrange;
|
||||
|
||||
/// The window title for a document called `name`.
|
||||
pub(crate) fn title(name: &str, dirty: bool) -> String {
|
||||
format!("{name}{} - WordCraft", if dirty { " •" } else { "" })
|
||||
@@ -90,6 +92,8 @@ pub struct Windows {
|
||||
input_plugin: bool,
|
||||
/// The viewport driver draws the windows this frame, and titles them.
|
||||
driven: bool,
|
||||
/// View › Window: Side by Side and Synchronous Scrolling (#322).
|
||||
arranged: arrange::State,
|
||||
}
|
||||
|
||||
impl Windows {
|
||||
@@ -113,6 +117,7 @@ impl Windows {
|
||||
builder: egui::ViewportBuilder::default().with_inner_size([1440.0, 920.0]).with_min_inner_size([760.0, 480.0]),
|
||||
input_plugin: false,
|
||||
driven: false,
|
||||
arranged: arrange::State::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,6 +312,11 @@ impl WordApp {
|
||||
self.windows.builder = builder;
|
||||
}
|
||||
|
||||
/// The viewport builder that new windows start from.
|
||||
pub fn window_builder(&self) -> &egui::ViewportBuilder {
|
||||
&self.windows.builder
|
||||
}
|
||||
|
||||
/// Whether a close request for the root viewport closes only the window drawn there: true
|
||||
/// while other windows are open. [`WordApp::ui`] closes it (a [`WindowEvent::CloseRequested`]),
|
||||
/// and the host keeps the app running.
|
||||
@@ -597,7 +607,38 @@ impl WordApp {
|
||||
let mut list = std::mem::take(&mut self.windows.list);
|
||||
self.windows.recent = list.recent().filter_map(|id| list.get(id).copied()).collect();
|
||||
let titles: HashMap<u64, String> = self.windows.open.iter().map(|w| (w.key, w.title.clone())).collect();
|
||||
let base = self.windows.builder.clone();
|
||||
let target_monitor = list
|
||||
.recent()
|
||||
.find_map(|id| {
|
||||
ctx.input_for(viewports.viewport_id(id), |i| {
|
||||
let m = i.viewport().monitor_size?;
|
||||
(m.is_finite() && m.x > 0.0 && m.y > 0.0).then_some(m)
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
ctx.input(|i| {
|
||||
let m = i.viewport().monitor_size?;
|
||||
(m.is_finite() && m.x > 0.0 && m.y > 0.0).then_some(m)
|
||||
})
|
||||
});
|
||||
let frame = list
|
||||
.recent()
|
||||
.find_map(|id| {
|
||||
ctx.input_for(viewports.viewport_id(id), |i| {
|
||||
let v = i.viewport();
|
||||
let (outer, inner) = (v.outer_rect?, v.inner_rect?);
|
||||
if !outer.is_finite() || !inner.is_finite() {
|
||||
return None;
|
||||
}
|
||||
Some((outer.size() - inner.size()).max(egui::Vec2::ZERO))
|
||||
})
|
||||
})
|
||||
.filter(|f| f.is_finite())
|
||||
.unwrap_or_else(|| egui::vec2(16.0, 39.0));
|
||||
let base = match target_monitor {
|
||||
Some(mon) => crate::window_geometry::fit_builder_to_monitor(self.windows.builder.clone(), mon, frame),
|
||||
None => self.windows.builder.clone(),
|
||||
};
|
||||
self.windows.showing = true;
|
||||
let events = viewports.show(
|
||||
ui,
|
||||
@@ -655,6 +696,7 @@ impl WordApp {
|
||||
return;
|
||||
}
|
||||
self.window_ui(ui);
|
||||
arrange::forward_scroll(self, &ctx);
|
||||
// Drawing can close the window or bring another forward: the fields then hold another
|
||||
// window's state.
|
||||
if self.windows.shown != key {
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
//! View › Window across the app's windows (#322): Arrange All, View Side by Side and Synchronous
|
||||
//! Scrolling. The engine's `view.*` window commands decide and record what to do
|
||||
//! (`Session::windows`, as the drawn window sees it) and push a request; [`request`] carries it
|
||||
//! out by placing the windows' viewports, and the pairing lives here. Without native windows (the
|
||||
//! web app, embedded viewports) the commands are disabled.
|
||||
//!
|
||||
//! Synchronous Scrolling hands scrolling from one window to its partner as document points: the
|
||||
//! canvas adds up what the window scrolled itself (`CanvasState::sync_out`); [`forward_scroll`]
|
||||
//! files it for the partner, and [`poll`] gives the partner what is waiting for it
|
||||
//! (`CanvasState::sync_in`) before it draws.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use egui::{Rect, Vec2, ViewportCommand, ViewportId, pos2, vec2};
|
||||
use serde_json::Value;
|
||||
use wordcraft_engine::cmd::view::OtherWindow;
|
||||
|
||||
use crate::WordApp;
|
||||
|
||||
/// Positions farther from the origin than this, and larger sizes, are refused.
|
||||
const MAX_COORD: f32 = 32768.0;
|
||||
/// Larger scroll steps are refused.
|
||||
const MAX_SCROLL: f32 = 1.0e6;
|
||||
|
||||
/// View Side by Side and Synchronous Scrolling for the app's windows.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct State {
|
||||
/// The two windows (their keys) viewed side by side.
|
||||
pair: Option<(u64, u64)>,
|
||||
/// They scroll together.
|
||||
sync: bool,
|
||||
/// Scrolling (document points) the partner did, waiting for the window with this key.
|
||||
waiting: HashMap<u64, f32>,
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// The window that `key` is viewed beside.
|
||||
fn partner(&self, key: u64) -> Option<u64> {
|
||||
match self.pair? {
|
||||
(a, b) if a == key => Some(b),
|
||||
(a, b) if b == key => Some(a),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn clear(&mut self) {
|
||||
self.pair = None;
|
||||
self.sync = false;
|
||||
self.waiting.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// As the drawn window starts its frame: tell the session which other windows there are and
|
||||
/// whether this one is viewed beside one, and give the window the scrolling its partner did.
|
||||
pub(crate) fn poll(app: &mut WordApp, _ctx: &egui::Context) {
|
||||
let key = app.windows.shown;
|
||||
let open: Vec<u64> = app.windows.open.iter().map(|w| w.key).collect();
|
||||
let st = &mut app.windows.arranged;
|
||||
if st.pair.is_some_and(|(a, b)| !open.contains(&a) || !open.contains(&b)) {
|
||||
// One of the two closed.
|
||||
st.clear();
|
||||
}
|
||||
let partner = st.partner(key);
|
||||
let sync = partner.is_some() && st.sync;
|
||||
let waiting = st.waiting.remove(&key).unwrap_or(0.0);
|
||||
let native = app.windows.native && app.windows.driven;
|
||||
let others: Vec<OtherWindow> = if native {
|
||||
app.switch_windows_entries().into_iter().filter(|(k, ..)| *k != key).map(|(id, title, _)| OtherWindow { id, title }).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let w = &mut app.session.windows;
|
||||
w.available = native;
|
||||
w.others = others;
|
||||
w.side_by_side = partner;
|
||||
w.sync_scroll = sync;
|
||||
if !sync {
|
||||
// Scrolling still queued for a partner that is gone, replaced or no longer synced.
|
||||
app.canvas.sync_in = 0.0;
|
||||
} else if waiting.is_finite() {
|
||||
app.canvas.sync_in += waiting.clamp(-MAX_SCROLL, MAX_SCROLL);
|
||||
}
|
||||
}
|
||||
|
||||
/// After the drawn window drew: what it scrolled itself goes to its partner.
|
||||
pub(crate) fn forward_scroll(app: &mut WordApp, ctx: &egui::Context) {
|
||||
let key = app.windows.shown;
|
||||
let dy = std::mem::take(&mut app.canvas.sync_out);
|
||||
let st = &mut app.windows.arranged;
|
||||
let Some(partner) = st.partner(key).filter(|_| st.sync && dy.is_finite() && dy != 0.0) else { return };
|
||||
let total = st.waiting.entry(partner).or_default();
|
||||
*total = (*total + dy).clamp(-MAX_SCROLL, MAX_SCROLL);
|
||||
ctx.request_repaint_of(app.windows.viewport(partner));
|
||||
}
|
||||
|
||||
/// Carry out a window request a `view.*` command made (`{"windows": …}`).
|
||||
pub(crate) fn request(app: &mut WordApp, req: &Value) {
|
||||
let Some(what) = req.get("windows").and_then(Value::as_str) else { return };
|
||||
let key = app.windows.shown;
|
||||
let id = |k: &str| req.get(k).and_then(Value::as_u64);
|
||||
match what {
|
||||
"sideBySide" => {
|
||||
app.canvas.sync_in = 0.0;
|
||||
if id("off").is_some() {
|
||||
app.windows.arranged.clear();
|
||||
}
|
||||
if let Some(with) = id("with").filter(|w| *w != key && app.windows.get(*w).is_some()) {
|
||||
let st = &mut app.windows.arranged;
|
||||
st.waiting.clear();
|
||||
st.pair = Some((key, with));
|
||||
st.sync = true;
|
||||
if let Some(area) = monitor_of(app, key) {
|
||||
let [left, right] = halves(area);
|
||||
place(app, key, left);
|
||||
place(app, with, right);
|
||||
}
|
||||
}
|
||||
}
|
||||
"syncScroll" => {
|
||||
app.canvas.sync_in = 0.0;
|
||||
if let Some(on) = req.get("value").and_then(Value::as_bool) {
|
||||
app.windows.arranged.sync = on;
|
||||
}
|
||||
}
|
||||
"arrange" => {
|
||||
let Some(area) = monitor_of(app, key) else { return };
|
||||
let keys: Vec<u64> = app.windows.open.iter().map(|w| w.key).collect();
|
||||
for (k, r) in keys.into_iter().zip(tile(app.windows.open.len(), area)) {
|
||||
place(app, k, r);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(ctx) = app.ctx.as_ref() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// The monitor window `key` is on.
|
||||
fn monitor_of(app: &WordApp, key: u64) -> Option<Rect> {
|
||||
if !app.windows.native || !app.windows.driven {
|
||||
return None;
|
||||
}
|
||||
let ctx = app.ctx.as_ref()?;
|
||||
ctx.input_for(app.windows.viewport(key), |i| monitor_area(i.viewport()))
|
||||
}
|
||||
|
||||
/// Move window `key`'s frame to `r`: un-maximized, its content `r` less the frame's borders, and
|
||||
/// allowed below the usual minimum size so two halves fit a small screen. Platforms that don't
|
||||
/// let apps place windows (Wayland) ignore the position.
|
||||
fn place(app: &WordApp, key: u64, r: Rect) {
|
||||
let Some(ctx) = app.ctx.as_ref() else { return };
|
||||
let viewport: ViewportId = app.windows.viewport(key);
|
||||
let border = ctx.input_for(viewport, |i| {
|
||||
let v = i.viewport();
|
||||
match (v.outer_rect, v.inner_rect) {
|
||||
(Some(o), Some(inner)) => (o.size() - inner.size()).max(Vec2::ZERO),
|
||||
_ => Vec2::ZERO,
|
||||
}
|
||||
});
|
||||
let inner = (r.size() - border).max(vec2(200.0, 150.0));
|
||||
for cmd in [
|
||||
ViewportCommand::Fullscreen(false),
|
||||
ViewportCommand::Minimized(false),
|
||||
ViewportCommand::Maximized(false),
|
||||
ViewportCommand::MinInnerSize(inner.min(crate::window_geometry::MIN_SIZE)),
|
||||
ViewportCommand::InnerSize(inner),
|
||||
ViewportCommand::OuterPosition(r.min),
|
||||
] {
|
||||
ctx.send_viewport_cmd_to(viewport, cmd);
|
||||
}
|
||||
}
|
||||
|
||||
/// The monitor a window is on, in points. egui reports the monitor's size but not where it
|
||||
/// is, so this assumes monitors of that size laid out in a grid from the primary monitor at the
|
||||
/// origin, and picks the cell under the window's centre; with monitors of different sizes it can
|
||||
/// pick the wrong one.
|
||||
pub(crate) fn monitor_area(v: &egui::ViewportInfo) -> Option<Rect> {
|
||||
let size = v.monitor_size.filter(|s| s.is_finite() && s.x >= 1.0 && s.y >= 1.0)?;
|
||||
let centre = v.outer_rect.or(v.inner_rect).map(|r| r.center()).filter(|c| c.is_finite()).unwrap_or(egui::Pos2::ZERO);
|
||||
let cell = |c: f32, s: f32| ((c / s).floor() * s).clamp(-MAX_COORD, MAX_COORD);
|
||||
Some(Rect::from_min_size(pos2(cell(centre.x, size.x), cell(centre.y, size.y)), size))
|
||||
}
|
||||
|
||||
/// Arrange All: `n` windows tiled over `area`. One or two windows stack top to bottom, like
|
||||
/// Word; more fill a grid of ⌈√n⌉ columns, the last row's windows sharing its full width.
|
||||
pub(crate) fn tile(n: usize, area: Rect) -> Vec<Rect> {
|
||||
let n = n.min(64);
|
||||
if n == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let cols = if n <= 2 { 1 } else { (n as f32).sqrt().ceil() as usize };
|
||||
let rows = n.div_ceil(cols);
|
||||
let h = area.height() / rows as f32;
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let (row, col) = (i / cols, i % cols);
|
||||
let in_row = if row + 1 == rows { n - row * cols } else { cols };
|
||||
let w = area.width() / in_row as f32;
|
||||
Rect::from_min_size(pos2(area.min.x + col as f32 * w, area.min.y + row as f32 * h), vec2(w, h))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// View Side by Side: the left and right halves of `area`.
|
||||
pub(crate) fn halves(area: Rect) -> [Rect; 2] {
|
||||
let mid = area.center().x;
|
||||
[Rect::from_min_max(area.min, pos2(mid, area.max.y)), Rect::from_min_max(pos2(mid, area.min.y), area.max)]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::{Services, WordApp};
|
||||
|
||||
#[test]
|
||||
fn tiles_cover_the_monitor_without_overlap() {
|
||||
let area = Rect::from_min_size(pos2(1920.0, 0.0), vec2(1920.0, 1080.0));
|
||||
assert!(tile(0, area).is_empty());
|
||||
assert_eq!(tile(1, area), vec![area]);
|
||||
// Two windows stack, like Word's Arrange All.
|
||||
assert_eq!(
|
||||
tile(2, area),
|
||||
vec![Rect::from_min_size(pos2(1920.0, 0.0), vec2(1920.0, 540.0)), Rect::from_min_size(pos2(1920.0, 540.0), vec2(1920.0, 540.0))]
|
||||
);
|
||||
for n in 3..=10 {
|
||||
let tiles = tile(n, area);
|
||||
assert_eq!(tiles.len(), n);
|
||||
let covered: f32 = tiles.iter().map(|r| r.area()).sum();
|
||||
assert!((covered - area.area()).abs() < 1.0, "{n} windows cover the monitor");
|
||||
for (i, a) in tiles.iter().enumerate() {
|
||||
assert!(area.expand(0.01).contains_rect(*a), "{n}: {a:?}");
|
||||
for b in &tiles[i + 1..] {
|
||||
assert!(a.intersect(*b).area() < 0.01, "{n}: {a:?} overlaps {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(tile(10_000, area).len(), 64, "hostile counts are capped");
|
||||
let [l, r] = halves(area);
|
||||
assert_eq!(
|
||||
(l, r),
|
||||
(Rect::from_min_size(pos2(1920.0, 0.0), vec2(960.0, 1080.0)), Rect::from_min_size(pos2(2880.0, 0.0), vec2(960.0, 1080.0)))
|
||||
);
|
||||
// The monitor is the one under the window's centre.
|
||||
let info = |x: f32| egui::ViewportInfo {
|
||||
monitor_size: Some(vec2(1920.0, 1080.0)),
|
||||
outer_rect: Some(Rect::from_min_size(pos2(x, 100.0), vec2(800.0, 600.0))),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(monitor_area(&info(100.0)), Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1920.0, 1080.0))));
|
||||
assert_eq!(monitor_area(&info(2000.0)), Some(area));
|
||||
assert_eq!(monitor_area(&egui::ViewportInfo::default()), None);
|
||||
assert_eq!(monitor_area(&egui::ViewportInfo { monitor_size: Some(vec2(f32::NAN, 1.0)), ..Default::default() }), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_by_side_scrolls_with_its_partner_only() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default());
|
||||
app.ctx = Some(ctx.clone());
|
||||
app.new_window();
|
||||
let keys: Vec<u64> = app.windows.open.iter().map(|w| w.key).collect();
|
||||
assert_eq!(keys.len(), 2);
|
||||
let (first, second) = (keys[0], keys[1]);
|
||||
// Headless: no native viewports, so the commands stay disabled.
|
||||
poll(&mut app, &ctx);
|
||||
assert!(!app.session.windows.available);
|
||||
assert!(app.run("view.sideBySide", json!({})).is_err());
|
||||
// Pair the two windows as the request would, then scroll the drawn one.
|
||||
let shown = app.windows.shown;
|
||||
let other = if shown == first { second } else { first };
|
||||
app.windows.arranged.pair = Some((shown, other));
|
||||
app.windows.arranged.sync = true;
|
||||
poll(&mut app, &ctx);
|
||||
assert_eq!((app.session.windows.side_by_side, app.session.windows.sync_scroll), (Some(other), true));
|
||||
app.canvas.sync_out = 40.0;
|
||||
forward_scroll(&mut app, &ctx);
|
||||
assert_eq!(app.canvas.sync_out, 0.0);
|
||||
assert_eq!(app.windows.arranged.waiting.get(&other), Some(&40.0));
|
||||
assert!(!app.windows.arranged.waiting.contains_key(&shown));
|
||||
// Nothing is passed on once Synchronous Scrolling is off, and scrolling still queued for
|
||||
// this window is dropped.
|
||||
app.canvas.sync_in = 9.0;
|
||||
app.windows.arranged.sync = false;
|
||||
poll(&mut app, &ctx);
|
||||
assert_eq!(app.canvas.sync_in, 0.0);
|
||||
app.canvas.sync_in = 9.0;
|
||||
request(&mut app, &json!({"windows": "syncScroll", "value": true}));
|
||||
assert_eq!(app.canvas.sync_in, 0.0);
|
||||
app.windows.arranged.sync = false;
|
||||
app.canvas.sync_out = 5.0;
|
||||
forward_scroll(&mut app, &ctx);
|
||||
assert_eq!(app.windows.arranged.waiting.get(&other), Some(&40.0));
|
||||
// Scrolling that is out of range is clamped, not trusted.
|
||||
app.windows.arranged.sync = true;
|
||||
app.canvas.sync_out = 1.0e30;
|
||||
forward_scroll(&mut app, &ctx);
|
||||
assert_eq!(app.windows.arranged.waiting.get(&other), Some(&MAX_SCROLL));
|
||||
// The partner closing ends the pairing.
|
||||
app.windows.open.retain(|w| w.key == shown);
|
||||
app.canvas.sync_in = 3.0;
|
||||
poll(&mut app, &ctx);
|
||||
assert_eq!(app.canvas.sync_in, 0.0);
|
||||
assert_eq!((app.session.windows.side_by_side, app.session.windows.sync_scroll), (None, false));
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ thread_local! {
|
||||
static UPLOADS: RefCell<usize> = const { RefCell::new(0) };
|
||||
/// Each viewport's last input-method output.
|
||||
static IME: RefCell<HashMap<ViewportId, Option<egui::output::IMEOutput>>> = RefCell::new(HashMap::new());
|
||||
/// Viewport builders passed to immediate viewports, by viewport id.
|
||||
static BUILDERS: RefCell<HashMap<ViewportId, egui::ViewportBuilder>> = RefCell::new(HashMap::new());
|
||||
}
|
||||
|
||||
fn input(id: ViewportId) -> RawInput {
|
||||
@@ -49,10 +51,12 @@ fn backend() -> egui::Context {
|
||||
EVENTS.with(|e| e.borrow_mut().clear());
|
||||
COMMANDS.with(|c| c.borrow_mut().clear());
|
||||
UPLOADS.with(|u| *u.borrow_mut() = 0);
|
||||
BUILDERS.with(|b| b.borrow_mut().clear());
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_embed_viewports(false);
|
||||
egui::Context::set_immediate_viewport_renderer(|ctx, viewport| {
|
||||
let id = viewport.ids.this;
|
||||
BUILDERS.with(|b| b.borrow_mut().insert(id, viewport.builder.clone()));
|
||||
let mut raw = input(id);
|
||||
raw.viewports.entry(id).or_default().parent = Some(viewport.ids.parent);
|
||||
let mut cb = viewport.viewport_ui_cb;
|
||||
@@ -630,3 +634,67 @@ fn every_open_replaces_an_untouched_blank_document_in_its_window() {
|
||||
}
|
||||
std::fs::remove_dir_all(second.parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
/// Opening a second document when another is already open constrains the new window's requested
|
||||
/// dimensions so that it fits within the active monitor's bounds, even on displays with high
|
||||
/// DPI scaling (such as 2560x1440 at 200% scale = 1280x720 pt) where the unconstrained default
|
||||
/// size (1440x920 pt) would extend past the right edge (#5871).
|
||||
#[test]
|
||||
fn newly_opened_window_does_not_exceed_screen_width() {
|
||||
let doc1 = docx_on_disk("doc1", "Doc One");
|
||||
let doc2 = docx_on_disk("doc2", "Doc Two");
|
||||
let ctx = backend();
|
||||
|
||||
// Simulate 2560x1440 at 200% scale (1280x720 pt).
|
||||
let screen = egui::vec2(1280.0, 720.0);
|
||||
let frame_decorations = egui::vec2(16.0, 39.0);
|
||||
INFOS.with(|i| {
|
||||
let mut infos = i.borrow_mut();
|
||||
let root = infos.entry(ViewportId::ROOT).or_default();
|
||||
root.monitor_size = Some(screen);
|
||||
root.inner_rect = Some(egui::Rect::from_min_size(egui::pos2(48.0, 48.0), egui::vec2(1168.0, 585.0)));
|
||||
root.outer_rect = Some(egui::Rect::from_min_size(egui::pos2(40.0, 17.0), egui::vec2(1184.0, 624.0)));
|
||||
});
|
||||
|
||||
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
|
||||
frames(&ctx, &mut a, 2);
|
||||
|
||||
// Open first document in the primary window.
|
||||
a.run("file.open", json!({"path": doc1.to_string_lossy()})).unwrap();
|
||||
frames(&ctx, &mut a, 2);
|
||||
assert_eq!(a.window_count(), 1);
|
||||
|
||||
// Open second document, creating a second window while the first is active.
|
||||
a.run("file.open", json!({"path": doc2.to_string_lossy()})).unwrap();
|
||||
frames(&ctx, &mut a, 3);
|
||||
assert_eq!(a.window_count(), 2);
|
||||
|
||||
// Find the immediate viewport created for the new window.
|
||||
let child_builder = BUILDERS.with(|b| {
|
||||
b.borrow()
|
||||
.iter()
|
||||
.find(|(id, _)| **id != ViewportId::ROOT)
|
||||
.map(|(_, builder)| builder.clone())
|
||||
.expect("child window viewport builder was recorded")
|
||||
});
|
||||
|
||||
let inner_size = child_builder.inner_size.expect("child window has an inner size requested");
|
||||
assert!(inner_size.x <= 1168.0, "new window width ({}) must not exceed available width on 1280pt monitor (1168pt)", inner_size.x);
|
||||
assert!(
|
||||
inner_size.x + frame_decorations.x <= screen.x,
|
||||
"new window outer width ({} + {}) must fit within screen width ({})",
|
||||
inner_size.x,
|
||||
frame_decorations.x,
|
||||
screen.x
|
||||
);
|
||||
assert!(
|
||||
inner_size.y + frame_decorations.y <= screen.y,
|
||||
"new window outer height ({} + {}) must fit within screen height ({})",
|
||||
inner_size.y,
|
||||
frame_decorations.y,
|
||||
screen.y
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(doc1.parent().unwrap()).ok();
|
||||
std::fs::remove_dir_all(doc2.parent().unwrap()).ok();
|
||||
}
|
||||
@@ -67,6 +67,17 @@ A request that times out while still queued is cancelled and cannot run later. I
|
||||
has already started, the timeout reports an unknown outcome rather than promising cancellation.
|
||||
Unauthenticated connections are limited to 64 while they wait for their first authorised request.
|
||||
|
||||
## View › Window
|
||||
|
||||
The app's native windows (one per document, or several on one document) take part in View ›
|
||||
Window (#322): `view.arrangeAll` tiles them over the monitor, `view.sideBySide {value?, window?}`
|
||||
places this window and another one in the left and right halves and turns on Synchronous
|
||||
Scrolling, `view.syncScroll {value?}` turns that on or off, and `view.switchWindows {id?}` lists
|
||||
and switches documents. `window` is a window id from `ui.inspect` → `windows.others`, the windows
|
||||
other than the one the command runs in (optional when only one other window is open). The
|
||||
commands are disabled where there is one window (web, CLI, MCP without the app). Wayland doesn't
|
||||
let apps move windows, so there Arrange All and Side by Side only resize.
|
||||
|
||||
## Methods
|
||||
|
||||
| Method | Params | Result |
|
||||
@@ -75,7 +86,7 @@ Unauthenticated connections are limited to 64 while they wait for their first au
|
||||
| `engine.execute` | `command`, `params`, `doc?` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.statusCount` (`{"value": "words"\|"characters"\|"charactersNoSpaces"}`; no value reads it; what the status bar's count shows — words (the default), characters with spaces, or characters without spaces — kept between runs, #2797), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does), `ui.paneDock` (`{"pane": "navigation"\|"clipboard"\|"styles"\|"styleInspector"\|"accessibility"\|"altText"\|"formatShape"\|"restrictEditing"\|"comments"\|"smartArtText", "dock"?: "left"\|"right"\|"float", "before"?: pane\|null}`; no `dock` reads it; docks or floats a task pane as its menu does, kept between runs; Styles is `view.stylesPaneDock`, #1756; `before` puts the pane directly before that pane in the order, outside it on their edge, as a drop over a docked pane's outer third does, and `null` after every pane; the order is kept between runs)…) |
|
||||
| `engine.commands` | — | every command: id, label, location, shortcut, params, enabled |
|
||||
| `document.inspect` | `text?`, `doc?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages |
|
||||
| `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf; `document`: the active document's id; `documents`: the open documents (`id`, `title`, `path`, `dirty`, `active`) |
|
||||
| `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf; `windows`: the other windows as the drawn window sees them (`available`, `others`, `sideBySide`, `syncScroll`); `document`: the active document's id; `documents`: the open documents (`id`, `title`, `path`, `dirty`, `active`) |
|
||||
| `ui.click` | `x`, `y`, `button?`, `count?`, `shift?`, `cmd?`, `alt?` | real pointer input at window coordinates |
|
||||
| `ui.clickText` | `page` (0-based), `x`, `y` (points from the page's top-left), `count?` | click inside a page |
|
||||
| `ui.move` / `ui.drag` | `x`,`y` / `x`,`y`,`toX`,`toY`,`steps?` | pointer move / drag |
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
> **2026-11-05:** DOCX paragraph `w:adjustRightInd` keeps its default and explicit values through style resolution and schema-ordered repeated saves.
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-08 · **Change:** trivial (XML line ends in text normalised, #5897); previously trivial (ODT pictures offered in several formats); previously trivial (ODT pictures offered in several formats); previously trivial (ODT pictures offered in several formats); previously trivial (ODT heading text of another size); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (ODT: no settings for what the standard leaves open); previously trivial (picture brightness, contrast and transparency); previously trivial (ODT paragraph groups kept as read); previously trivial (ODT paragraphs with charts kept as read); previously trivial (ODT sections in cells and at empty paragraphs); previously trivial (ODT element audit); previously trivial (ODT reference marks written as such); previously trivial (ODT hidden sections stay hidden; lists kept as read); previously trivial (ODT manifest lists every file); previously trivial (ODT paragraphs with bookmarks, notes, objects and page-anchored frames written as read); previously trivial (ODT paragraphs with text boxes written as read); previously trivial (ODT paragraphs with comments and tracked changes written as read); previously trivial (ODT paragraphs with pictures and shapes written as read); previously trivial (ODT sections inside text boxes); previously trivial (ODT definitions sharing a name, sections around tables, chart replacement pictures, note citations, stacking order); previously trivial (ODT form definitions and indexes written as read); previously trivial (ODT gradient angles by the file's version; deviation documented); previously minor (ODT: unedited tables, pages, sections, properties and declarations written as read; no-edit loss gate); previously minor (ODT: unedited paragraphs and style definitions written as read); previously trivial (ODT author fields said not fixed); previously trivial (ODT default text colour; covered cells' content); previously trivial (ODT widow control profile case; tabs from the indent kept); previously trivial (ODT span kerning; note reference formatting; notes starting with a tab); previously trivial (ODT fixed and sender fields); previously trivial (ODT: table styles saved into their cells; ODT tables carry no Word table style; section column balance); previously trivial (ODT interpretation profile: the standard is law, what it leaves open (including unstated page margins) follows WordCraft, LibreOffice or custom settings); previously trivial (ODT form controls keep their place on the line and text areas their lines; custom shapes keep their geometry); previously trivial (ODT in-line frames placed against the text; in-line pictures saved standing on the baseline); previously trivial (ODT charts written back as they were while unchanged; frames' drawn outline; default header height); previously trivial (ODT header rows only where they repeat); previously trivial (ODT padding without a border; unequal columns; column breaks); previously trivial (ODT lines of only pictures laid out as LibreOffice does; default page without a layout); previously trivial (ODT pictures alone in a paragraph keep its size; in-line frames raised under an inherited paragraph relation); previously trivial (ODT tables fit the margins and keep their space above and below; header spacing kept); previously trivial (ODT frames anchored and placed by their style); previously trivial (ODT default font size saved; page-anchored frames placed from the page; text box paragraph styles kept; empty paragraphs keep their size); previously trivial (ODT default font size; locale-ordered dates); previously trivial (ODT page background pictures); previously trivial (ODT tables fixed layout; tables starting a page); previously trivial (emphasis marks: model, layout, DOCX `w:em`, ODT `style:text-emphasize`, `format.emphasisMark`); previously trivial (ODT variables as SET/REF fields; character styles keep what they turn off); previously trivial (ODT styles keep what they turn off; WMF/EMF pictures kept; pictures in text boxes shown by LibreOffice); previously trivial (ODT unstated page margins are LibreOffice's 2 cm; drawings outside a paragraph join the next one); previously trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previously trivial (ODT macros and unknown package parts kept); previously trivial (ODT table cell formulas as formula fields); previously trivial (ODT custom shapes drawn from their own geometry); previouslLine truncated
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-08 · **Change:** trivial (RTF footnotes, endnotes and comments, #1053); previously trivial (XML line ends in text normalised, #5897); previously trivial (ODT pictures offered in several formats); previously trivial (ODT pictures offered in several formats); previously trivial (ODT pictures offered in several formats); previously trivial (ODT heading text of another size); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (ODT: no settings for what the standard leaves open); previously trivial (picture brightness, contrast and transparency); previously trivial (ODT paragraph groups kept as read); previously trivial (ODT paragraphs with charts kept as read); previously trivial (ODT sections in cells and at empty paragraphs); previously trivial (ODT element audit); previously trivial (ODT reference marks written as such); previously trivial (ODT hidden sections stay hidden; lists kept as read); previously trivial (ODT manifest lists every file); previously trivial (ODT paragraphs with bookmarks, notes, objects and page-anchored frames written as read); previously trivial (ODT paragraphs with text boxes written as read); previously trivial (ODT paragraphs with comments and tracked changes written as read); previously trivial (ODT paragraphs with pictures and shapes written as read); previously trivial (ODT sections inside text boxes); previously trivial (ODT definitions sharing a name, sections around tables, chart replacement pictures, note citations, stacking order); previously trivial (ODT form definitions and indexes written as read); previously trivial (ODT gradient angles by the file's version; deviation documented); previously minor (ODT: unedited tables, pages, sections, properties and declarations written as read; no-edit loss gate); previously minor (ODT: unedited paragraphs and style definitions written as read); previously trivial (ODT author fields said not fixed); previously trivial (ODT default text colour; covered cells' content); previously trivial (ODT widow control profile case; tabs from the indent kept); previously trivial (ODT span kerning; note reference formatting; notes starting with a tab); previously trivial (ODT fixed and sender fields); previously trivial (ODT: table styles saved into their cells; ODT tables carry no Word table style; section column balance); previously trivial (ODT interpretation profile: the standard is law, what it leaves open (including unstated page margins) follows WordCraft, LibreOffice or custom settings); previously trivial (ODT form controls keep their place on the line and text areas their lines; custom shapes keep their geometry); previously trivial (ODT in-line frames placed against the text; in-line pictures saved standing on the baseline); previously trivial (ODT charts written back as they were while unchanged; frames' drawn outline; default header height); previously trivial (ODT header rows only where they repeat); previously trivial (ODT padding without a border; unequal columns; column breaks); previously trivial (ODT lines of only pictures laid out as LibreOffice does; default page without a layout); previously trivial (ODT pictures alone in a paragraph keep its size; in-line frames raised under an inherited paragraph relation); previously trivial (ODT tables fit the margins and keep their space above and below; header spacing kept); previously trivial (ODT frames anchored and placed by their style); previously trivial (ODT default font size saved; page-anchored frames placed from the page; text box paragraph styles kept; empty paragraphs keep their size); previously trivial (ODT default font size; locale-ordered dates); previously trivial (ODT page background pictures); previously trivial (ODT tables fixed layout; tables starting a page); previously trivial (emphasis marks: model, layout, DOCX `w:em`, ODT `style:text-emphasize`, `format.emphasisMark`); previously trivial (ODT variables as SET/REF fields; character styles keep what they turn off); previously trivial (ODT styles keep what they turn off; WMF/EMF pictures kept; pictures in text boxes shown by LibreOffice); previously trivial (ODT unstated page margins are LibreOffice's 2 cm; drawings outside a paragraph join the next one); previously trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previously trivial (ODT macros and unknown package parts kept); previously trivial (ODT table cell formulas as formula fields); previously trLine truncated
|
||||
> **CLI path update:** 2026-11-04 · Options before a non-Unicode input filename no longer make the CLI reject that filename; text option values still require Unicode; `run --file` and `run --script` retain native paths. No ranked gap or readiness estimate changes.
|
||||
|
||||
> **Legacy form update:** 2026-10-22 · trivial: field names and selected drop-down labels decode numeric XML character references once.
|
||||
@@ -77,8 +77,8 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
|
||||
| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]); brackets and braces are drawn as their box, unfilled unless the file fills them (#5943). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer. DIB conversion caps BMP bytes (header included) and encoded PNG at the supplied BLIP byte limit, and uses it as the decoder's allocation limit; a BMP fallback is kept if it fits (related #1334 and #1554). Hostile files are bounded: a sector table longer than the file is refused before parsing (#3642); formatting and link runs are read once per change, not per character (#3692, #3708); copies of alt text, link targets and drawn objects share a 64 MB budget (#3709, #3710), and what it leaves out is logged once a kind per document; picture decoding shares a 512 MB budget and aliased store entries decode once (#3711, #3712). The default fonts are the stylesheet's standard fonts (`rgftcStandardChpStsh` and `ftcBi` in the `Stshi`, [MS-DOC] §2.9.273): Aptos in a Word 365 file, whose font table still starts with Times New Roman; a header too short for them, or an index past the font table, keeps the table's first font (#5066). Runs' East Asian and complex-script fonts (`sprmCRgFtc1`, `sprmCFtcBi`) are read. |
|
||||
| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383, also with fonts that draw lam-alef as two glyphs at one x, #2310); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Outline destinations follow leading page breaks to the heading text. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice |
|
||||
| PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser |
|
||||
| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). East Asian multibyte code pages decoded as runs: 932 Shift-JIS (`\fcharset128`, Japanese), 936 GBK (134, Simplified Chinese), 949 UHC/windows-949 (129, Korean) and 950 Big5 (136, Traditional Chinese) through `\ansicpg`, `\cpg` or the font's charset, escaped or raw, a lead byte and its trail byte together; invalid sequences read as U+FFFD, and a run that starts `EF BB BF` is text, not a BOM (#1653). Pasted from the system clipboard where the platform offers it (Windows, macOS, Wayland, X11) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; tracked paragraph marks (`\par` inside `\revised`/`\deleted` groups, including a `\sect` inside a deletion) and character and paragraph formatting revisions (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) read and written; section and table formatting revisions (`\oldsprops`, `\oldtprops`) not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export writes every section too (break type, page size and orientation, margins, header/footer distance, columns, page numbering, title page; not headers/footers). On the system clipboard: Copy and Cut write RTF (Windows, macOS, Wayland; not X11), except for picture- and shape-only copies, which keep to WordCraft's own copy, and Paste reads it on all four. Thin: comments, notes, RTL |
|
||||
| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154; Windows and Enhanced Metafiles, `\wmetafile` and `\emfblip`, read and written, #4259), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). East Asian multibyte code pages decoded as runs: 932 Shift-JIS (`\fcharset128`, Japanese), 936 GBK (134, Simplified Chinese), 949 UHC/windows-949 (129, Korean) and 950 Big5 (136, Traditional Chinese) through `\ansicpg`, `\cpg` or the font's charset, escaped or raw, a lead byte and its trail byte together; invalid sequences read as U+FFFD, and a run that starts `EF BB BF` is text, not a BOM (#1653). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; tracked paragraph marks (`\par` inside `\revised`/`\deleted` groups, including a `\sect` inside a deletion) and character and paragraph formatting revisions (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) read and written; section and table formatting revisions (`\oldsprops`, `\oldtprops`) not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export writes every section too (break type, page size and orientation, margins, header/footer distance, columns, page numbering, title page; not headers/footers). Thin: comments, notes, RTL |
|
||||
| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). East Asian multibyte code pages decoded as runs: 932 Shift-JIS (`\fcharset128`, Japanese), 936 GBK (134, Simplified Chinese), 949 UHC/windows-949 (129, Korean) and 950 Big5 (136, Traditional Chinese) through `\ansicpg`, `\cpg` or the font's charset, escaped or raw, a lead byte and its trail byte together; invalid sequences read as U+FFFD, and a run that starts `EF BB BF` is text, not a BOM (#1653). Pasted from the system clipboard where the platform offers it (Windows, macOS, Wayland, X11) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; tracked paragraph marks (`\par` inside `\revised`/`\deleted` groups, including a `\sect` inside a deletion) and character and paragraph formatting revisions (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) read and written; section and table formatting revisions (`\oldsprops`, `\oldtprops`) not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export writes every section too (break type, page size and orientation, margins, header/footer distance, columns, page numbering, title page; not headers/footers). On the system clipboard: Copy and Cut write RTF (Windows, macOS, Wayland; not X11), except for picture- and shape-only copies, which keep to WordCraft's own copy, and Paste reads it on all four. Footnotes, endnotes (`\footnote`, `\ftnalt`) and comments (`\atrfstart`/`\atrfend` ranges, `\annotation` with author, initials and date) are read and written (#1053; synthetic strings only, not compared with Word's RTF). Not kept: custom note marks, comment replies, notes in headers and footers. Thin: RTL |
|
||||
| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154; Windows and Enhanced Metafiles, `\wmetafile` and `\emfblip`, read and written, #4259), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). East Asian multibyte code pages decoded as runs: 932 Shift-JIS (`\fcharset128`, Japanese), 936 GBK (134, Simplified Chinese), 949 UHC/windows-949 (129, Korean) and 950 Big5 (136, Traditional Chinese) through `\ansicpg`, `\cpg` or the font's charset, escaped or raw, a lead byte and its trail byte together; invalid sequences read as U+FFFD, and a run that starts `EF BB BF` is text, not a BOM (#1653). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; tracked paragraph marks (`\par` inside `\revised`/`\deleted` groups, including a `\sect` inside a deletion) and character and paragraph formatting revisions (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) read and written; section and table formatting revisions (`\oldsprops`, `\oldtprops`) not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export writes every section too (break type, page size and orientation, margins, header/footer distance, columns, page numbering, title page; not headers/footers). Footnotes, endnotes (`\footnote`, `\ftnalt`) and comments (`\atrfstart`/`\atrfend` ranges, `\annotation` with author, initials and date) are read and written (#1053; synthetic strings only, not compared with Word's RTF). Not kept: custom note marks, comment replies, notes in headers and footers. Thin: RTL |
|
||||
| `.odt` OpenDocument Text (and flat `.fodt`, templates `.ott`) | 2% | R/W | 🟡 | 🟡 | ~90% | 20–35 | `crates/formats/src/tests.rs`, `crates/formats/src/odt/tests.rs` and one file per feature in `crates/formats/src/odt/tests_more/`, `crates/engine/tests/it/odt_sections.rs`, `odt_toc.rs`, `odt_fields.rs`, `odt_charts.rs`, `odt_objects.rs`, `odt_password.rs`, `odt_forms.rs`; checked against LibreOffice's own documents (round trips compared page by page) and the ODF 1.3 schema; what the standard leaves open is WordCraft's one choice for each case ([odf-interpretation.md](odf-interpretation.md)) | Ruby (`text:ruby`, §6.4: a base text with ruby text over it) is read as WordCraft's Phonetic Guide and written back as ODF ruby above its base, centred (one inside a tracked change is written as its base text, which the change marks); its own position and alignment aren't kept. A border around text (LibreOffice's `loext:border` or its sides, with `loext:padding`: ODF 1.3 has no attribute for it) is Word's text border, one line all round, and is written back so (a document with one is then extended ODF, not strict). Emphasis marks (`style:text-emphasize`) and rotated text (`style:text-rotation-angle`) aren't read. How text flows: a page break after a paragraph (`fo:break-after`, a page break before the next paragraph as Word has it), lines kept together (`fo:keep-together`), widow and orphan control (`fo:widows`/`fo:orphans`: on with any line count, off with none; the counts themselves aren't kept) and hyphenation (`fo:hyphenate`, a paragraph's in ODF and the document's in WordCraft: a document with a hyphenated paragraph hyphenates, its other paragraphs opting out) are read and written; a table's alignment and width (`table:align`, `style:rel-width`, or `style:width` for an aligned table) and its rows' heights (`style:row-height` exact, `style:min-row-height` at least) are read and written. Form controls (`office:forms` controls placed by `draw:control`, ODF 1.3 §13) open as content controls — text fields and text areas as plain text controls, check boxes as check boxes, radio buttons as check boxes with round symbols, list boxes as drop-down lists, combo boxes as combo boxes, date fields as date controls, each with its value, entries and selection, its name as the control's tag and a label as its title — and content controls of those kinds in the body are written back as form controls LibreOffice shows with their values (in headers and footers, as their text); a control keeps its size (`draw:control` `svg:width`/`svg:height`); buttons, image buttons, labels and the other controls WordCraft has no kind for show their label, locked, and are kept verbatim and written back as they were (one naming a namespace WordCraft doesn't declare is left out); hidden controls (`form:hidden`) are kept and written back with their form; a control's look (colours, font) isn't kept. Placeholders (`text:placeholder`, §7.7.2, as LibreOffice's templates use: "<Recipient's name>") open as plain text content controls showing them, so a click selects one and typing replaces it, and are written back as placeholders of their type (their description as the control's title) while they show it. A master page followed by another (`style:next-style-name`: LibreOffice's "First Page" before "Default") is a section with a different first page, its own headers and footers on the first page and the next one's after it. The default paragraph style's font size (`fo:font-size` in `style:default-style`, or the Standard style's: the document's defaults) is the document's default size (it stayed 12 pt), a style with no parent keeping the default style's, and a date style ordered by the locale (`number:automatic-order`) shows in US English order ("November 2, 2026", as LibreOffice shows it here). A page background picture (`style:background-image` in the page layout, §17.3) is WordCraft's page picture (Design › Page Color › Fill Effects): repeated as tiled, stretched as stretched, not repeated as centred at its own size (a fill WordCraft draws so; DOCX writes it stretched), and is written back with its picture. Tables are fixed layout (Word's `w:tblLayout`), as LibreOffice's columns never fit their content, and a table starting a page (`fo:break-before` on the table) starts it: its first paragraph has a page break before it, which moves the table, and is written back on the table. The columns of a table filling the margins are written with their share of the table (`style:rel-column-width`) too, so on a narrower page they keep their proportions in LibreOffice (an aligned table's aren't: LibreOffice lays one out oddly with shares). A fixed date or time with neither a value nor text (LibreOffice's templates) is a date field showing the day; `text:author-name` is `USERNAME`, which WordCraft now fills with the user's name. A page layout that states no size is the default page's (the user's region's, as LibreOffice uses its default paper), and a maLine truncated
|
||||
| `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`; a Word XML Document (Word 2003 XML or Flat OPC) or a web page (`<html>`, `<!DOCTYPE html>`) or a Single File Web Page (MHT) under a `.doc`, `.docx` or `.rtf` name opens as what it is (#2853, `tests::word_xml_and_web_pages_saved_as_doc_open_by_content`, `tests::a_single_file_web_page_saved_as_doc_opens_by_content`). Word's encoding choice dialog and line-ending options missing; such files save only through Save As |
|
||||
| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~65% | 5–9 | `crates/formats/src/tests.rs`, `html.rs` | Tables (#102), relative pictures embedded (#113), missing `</head>` tolerated (#154); the empty comments `<!-->` and `<!--->` end at once instead of swallowing the rest of the page (#5040). `style` element rules with simple selectors (`p`, `.x`, `p.x`; class names may start with a digit, as Word accepts) in cascade order; paragraph `margin`, `text-indent`, `line-height`; cell `width` and borders. Header cells (`<th>`) bold unless their CSS says otherwise; `text-align` on a cell, or inherited from its row, row group or table, aligns the cell's paragraphs (#1381). An opened page looks as Word opens one: Times New Roman 12 pt, single lines, browser-sized black headings, automatic 14 pt spacing where a `p` or heading states no margin (none before the first), a span's margin replacing its paragraph's. HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do (#1381). No `@page`, descendant selectors or `div` margins. No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). A pasted `<blockquote>` (LibreOffice's paragraph indented on both sides) becomes a Normal paragraph indented by the page's `blockquote` margins, else 0.5 in each side; opening an .html file keeps it as the Quote style (#1270). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816). A copy of one equation alone also goes in the clipboard's MathML formats (Windows "MathML" and "MathML Presentation", macOS `public.mathml.presentation` and `public.mathml`; W3C MathML 3 §6.2.4), which Microsoft documents Word reading first when it pastes math (#1297; the paste into Word is not yet verified); Ruby (furigana, phonetic guides) is written as `<ruby>base<rt>reading</rt></ruby>` in the base text's formatting and read back from `<ruby>`/`<rb>`/`<rt>` (`<rp>` fallback parentheses dropped; malformed markup keeps its text), so docx/doc → HTML → doc keeps the annotation; HTML states no alignment, size or raise, so the Phonetic Guide's defaults apply (#1681) CRLF and lone CR input newlines normalize before tokenization, preserving preformatted lines and leaving decoded character references separate (#4810). |
|
||||
@@ -133,7 +133,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
|
||||
| Group shapes (`wpg:`), drawing canvas (`wpc:`) | ✅ | ✅ | Groups (#267) and canvases (#344) with pictures, shapes, freeforms (custom geometry, #743; a path drawn back to its start without `a:close` is filled, as in Word) and text boxes; nested groups flattened; members' `a:grpFill` takes their group's fill, and a canvas background (`wpc:bg`) may be a gradient or transparent (#1500). The canvas schema has no transform, so a turned or flipped canvas keeps its turn in our own `wpc:extLst` extension (`urn:wordcraft:canvas-xfrm`; Word shows it unturned). Legacy VML canvases (`v:group editas="canvas"`) aren't read as canvases |
|
||||
| VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | 🟡 | Pictures, text boxes, lines (with arrowheads, their kind, width and length kept), rectangles and ovals, best effort (#242, #611); `v:fill` opacity and gradients (`type="gradient"`/`gradientRadial`, `color2`, `colors`, `angle`, `focus`, `focusposition`, `o:opacity2`), saved as DrawingML (#1500). Preset shapes (`v:shape` whose `o:spt`, own or from its `v:shapetype`, or Word's shapetype id `_x0000_tNN`, names a preset; [MS-ODRAW] §2.4.24 numbers) are drawn and saved like the DrawingML preset of the same shape: exactly for rectangle, rounded rectangle, ellipse, diamond, isosceles triangle, five-point star, right arrow, line and straight connector, heart, text box, and the flowchart process, decision and connector; the others as their nearest shape, as for DrawingML (left/up/down arrows as the arrow, right triangle as the triangle, callouts as their box). Picture frames without a picture aren't drawn. Fill, outline and size come from the shape, else its shapetype; text inside keeps the shape around it (#1377). Adjust values (`adj`) aren't read: presets use WordCraft's proportions. Text boxes keep their internal margins (`inset`, unless `o:insetmode="auto"`), Resize shape to fit text (`mso-fit-shape-to-text`) and vertical alignment (`v-text-anchor`). Hidden shapes (`visibility:hidden`, like DrawingML's `wp:docPr/@hidden`) are kept and saved but neither drawn nor laid out, as in Word for Windows (Word for Mac draws them). Picture `gain` and `blacklevel` on `v:imagedata` (Word's picture watermark "Washout") are read as brightness and contrast (#1711). Saving as DOCX writes them as DrawingML `a:lum`; saving as Word 2003 XML writes them back as VML `gain`/`blacklevel` (transparency has no place on `v:imagedata` and is dropped there). Text-box text direction is read as Word reads it: `mso-layout-flow-alt:bottom-to-top` as `vert270` whatever `layout-flow` says, else `layout-flow:vertical` or `vertical-ideographic` as `vert`, else horizontal (#5193). Word's text watermark (a `v:textpath` shape in a header or footer, recognised by Word's `PowerPlusWaterMarkObject` id or by being centred on the page both ways) becomes the document's watermark: text, font, fill colour, semitransparent (`v:fill` `opacity`) and diagonal (any turn); drawn with WordCraft's own size and angle, on every page even when only some sections' headers (or only the first-page header) have it (#1290). Saving writes the watermark back the same way: a `v:textpath` shape centred on the margins, behind the text, at the start of every header's first paragraph; a section whose pages would show no header gets a header holding only the watermark (default, and first-page or even-page ones when those are on), while sections linked to an earlier header stay linked. Freeforms (`v:shape` with its own `path`: moves, lines, Béziers and closes in its `coordsize`/`coordorigin` space, `nf`/`ns`, `v:stroke` opacity) are read as freeforms and saved as DrawingML custom geometry; fill opacity kept (no fill at 0); arcs are drawn straight; shapes whose path uses formulas (`@n`) and paths inherited from a `v:shapetype` aren't read. Word signature lines (Insert › Signature Line: a VML picture with an `o:signatureline`) are shown as their picture and saved back whole with it, at the size set in WordCraft, so they stay signable in Word instead of becoming plain pictures. Other VML is written only for OLE object pictures and signature lines. Other VML WordArt (Word 2007's: `v:textpath` in the body, or a header logo) becomes a text box without fill or outline holding its `string`, in the textpath's font, the shape's fill colour and a size from the shape's height; its WordArt shape (`o:spt` 136–175) becomes the DrawingML text warp of the same shape (#2875). Its outline, shadow and 3-D effects aren't read |
|
||||
| Alt text of drawings (`wp:docPr`/`cNvPr` `descr` and `title`, decorative mark `adec:decorative` from [MS-ODRAWXML]) and tables (`w:tblCaption`, `w:tblDescription`) | ✅ | ✅ | Pictures, shapes, text boxes, groups and their members, charts and SmartArt; OLE objects get theirs written into the kept XML (#386) |
|
||||
| Charts (`c:chart`), SmartArt (`dgm`) | ✅ | ✅ | Drawn from the chart's cached data and SmartArt's stored drawing (#292), with data labels (`c:showVal`); their text (title, axis labels, legend) is drawn in the document's body font, and characters it lacks (Chinese in Calibri's substitute) in a fallback face that covers them, one face per run as in body text (#4720); a chart of only types not drawn yet (radar, stock, bubble, surface, 3-D, pie of pie) shows a placeholder in its frame with its title and type (#998). Charts made in WordCraft (Insert › Chart, #409) are written as a `c:chartSpace` part with their data as literals (`c:strLit`/`c:numLit`, no embedded workbook, so Word shows them and offers to create a workbook on Edit Data) and read back as editable; a chart part holding anything more (any chart from Word, or one re-saved by Word) stays read-only. Saved back as read, with every part they reach (chart, embedded workbook, colours and style; diagram data, layout, quick style, colours and drawing) and their content types; a moved or resized one keeps its new frame, and a pasted copy gets its own parts (#319). SmartArt made in WordCraft (#497) is written from its model: data (`dgm:dataModel` points and connections), our own layout, quick-style and colour definitions under the built-in layout identifiers, and a `dsp:drawing` with every shape placed; it reads back editable only when its data part is exactly what WordCraft writes |
|
||||
| Charts (`c:chart`), SmartArt (`dgm`) | ✅ | ✅ | Drawn from the chart's cached data and SmartArt's stored drawing (#292), with data labels (`c:showVal`); a series without its own colour takes the accent its `c:idx` picks, as Word does (#5963); their text (title, axis labels, legend) is drawn in the document's body font, and characters it lacks (Chinese in Calibri's substitute) in a fallback face that covers them, one face per run as in body text (#4720); a chart of only types not drawn yet (radar, stock, bubble, surface, 3-D, pie of pie) shows a placeholder in its frame with its title and type (#998). Charts made in WordCraft (Insert › Chart, #409) are written as a `c:chartSpace` part with their data as literals (`c:strLit`/`c:numLit`, no embedded workbook, so Word shows them and offers to create a workbook on Edit Data) and read back as editable; a chart part holding anything more (any chart from Word, or one re-saved by Word) stays read-only. Saved back as read, with every part they reach (chart, embedded workbook, colours and style; diagram data, layout, quick style, colours and drawing) and their content types; a moved or resized one keeps its new frame, and a pasted copy gets its own parts (#319). SmartArt made in WordCraft (#497) is written from its model: data (`dgm:dataModel` points and connections), our own layout, quick-style and colour definitions under the built-in layout identifiers, and a `dsp:drawing` with every shape placed; it reads back editable only when its data part is exactly what WordCraft writes |
|
||||
| OLE objects (`w:object`: embedded or linked files, ActiveX) | 🟡 | ✅ | Shown as their picture (VML or DrawingML); saved back whole with the embedding and picture parts, at the size, rotation and flips set in WordCraft (#319, #332). Can't be opened or edited |
|
||||
| Native Word ink (`w14:contentPart`, InkML) | 🟡 | 🟡 | Drawing-level ink kept opaque with its InkML part, relationship and content type; direct run-level `w:contentPart` and run-level `mc:AlternateContent` keep the native branch and VML picture fallback. Direct content parts remain direct through repeated save/reopen cycles, without a native rendering. Drawing-level ink shows the preview or a missing-picture box. Ink members of groups (`wpg:wgp`, nested groups too) and drawing canvases (`wpc:wpc`), bare or in `mc:AlternateContent` with a `pic:pic` fallback, keep their markup, part, relationship and place among the members; they are written at the member's current position in the group and drawn from their fallback picture. Such a group can't be ungrouped yet (Ungroup is unavailable, so the ink is never left in a form it can't be saved in). Their part is dropped with the group (no orphan part); ink whose part is missing or isn't InkML gives way to its fallback, else is dropped. No native stroke rendering, eraser, conversion or replay |
|
||||
| 3D models | ❌ | ❌ | Dropped on read, not preserved on save |
|
||||
@@ -335,6 +335,7 @@ For field results, the engine parser treats a backslash in quoted arguments as e
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-08 | trivial | XML text and CDATA read with their line ends normalised (XML 1.0 §2.11): CR LF and a lone CR are one line feed, ` ` stays a CR, so a line break inside `w:t` in a file saved with Windows line ends is one space, not two (#5897) |
|
||||
| 2026-11-07 | trivial | Charts: a series without its own colour takes the accent its `c:idx` picks, as Word does, not the one its place in the file picks (#5963) |
|
||||
| 2026-11-05 | trivial | ODT: text of another size in a heading, title or code paragraph keeps it. Every run's size went with the style, on screen and in LibreOffice after a save Text at the heading's size goes with the style, stated or not, and a percentage is of the heading's size. Text in a heading style that states no size keeps a size it states. |
|
||||
| 2026-11-05 | trivial | ODT: a table row kept from breaking across pages (`fo:keep-together` on the row) is read and written; it came back from a save free to break A row whose style doesn't say follows the default style. A row free to break says so where the file's default row style, which stays, keeps rows together. |
|
||||
| 2026-11-05 | trivial | ODT: a watermark added, changed or removed, and Different Odd & Even Pages turned on or off, in a file read from ODT are saved. The file's own page definitions were written back whatever happened to these, so the change was gone from the saved file (`odt::tests_more::headers`). Such a change writes the page definitions from the model, as other changes to what a page is made of do, so what WordCraft doesn't model of the page (a header's background) doesn't survive that save. Over 79 LibreOffice-authored files with a watermark added: all 79 have it, with the same page count in LibreOffice 26.8. |
|
||||
@@ -522,6 +523,7 @@ For field results, the engine parser treats a backslash in quoted arguments as e
|
||||
| 2026-10-16 | trivial | A `HYPERLINK` field reads as a link with its result as ordinary text, so a Word for the web table of contents keeps its tabs, dot leaders and page fields (#2174) |
|
||||
| 2026-10-19 | trivial | Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in |
|
||||
| 2026-10-19 | trivial | Content controls: text typed over a placeholder takes the control's own run properties (`w:sdtPr/w:rPr`, ECMA-376 §17.5.2.27), as in Word, and the placeholder style is recognised by its built-in name "Placeholder Text" whatever its localized id (German Word: `Platzhaltertext`), so entries in forms from localized Word no longer stay grey |
|
||||
| 2026-10-12 | trivial | RTF reads and writes footnotes, endnotes and comments with author, initials, date and commented range (#1053) |
|
||||
| 2026-10-18 | trivial | A Word 2003 XML or Flat OPC document, or a web page, saved under a `.doc`, `.docx` or `.rtf` name opens by what it holds, as in Word, instead of going to the Word 97-2003 reader and showing a blank page (#2853) |
|
||||
| 2026-10-18 | trivial | `w:sym`: the symbol's `w:font` is kept apart from the run font (ECMA-376 §17.3.3.30), so changing the text's font (or Clear Formatting) keeps Wingdings/Symbol characters showing, and they are written back as `<w:sym w:font=… w:char=…/>`; Insert › Symbol in a symbol font does the same (#2036). A private-use character with no symbol font of its own in a non-symbol run is not guessed |
|
||||
| 2026-10-18 | trivial | Manage Sources imports Word's `Sources.xml` (`b:Sources`) into the document's bibliography sources, sharing the reader of a document's own bibliography part (#2640) |
|
||||
@@ -683,3 +685,4 @@ For field results, the engine parser treats a backslash in quoted arguments as e
|
||||
| 2026-10-27 | trivial | 2026-10-27 · #4533 review: numeric REF updates lazily index body bookmarks once per update, retaining the first body bookmark for duplicate names. Unsupported switches and ordinary REF/PAGEREF behavior are unchanged; a synthetic 64-reference regression covers repeated updates and duplicate names. Estimates unchanged. |
|
||||
| 2026-10-27 | trivial | 2026-10-27 · #4533: numeric REF fields use paragraph list numbers for no/full/relative context, reusing bounded numbering. Shared context already embedded in a level label is removed; merged-away cells do not count. Unsupported separator/text/position switches and ambiguous contexts or delimiter-free joins retain cached results. Synthetic OOXML regressions; no native Word or real-document validation, estimates unchanged. |
|
||||
| 2026-11-05 | trivial | DOCX: a block-level content control (including `w15:repeatingSection` / `w15:repeatingSectionItem` nested around one paragraph) followed by body-level bookmark, comment or permission markup (Word's `_GoBack` bookmark) is written back around its paragraph instead of inside it, so Word opens the saved file; the markers are kept at the end of that paragraph (P0069). |
|
||||
| 2026-11-04 | trivial | DOCX: paragraph and list-level indents (`w:ind`) and paragraph spacing (`w:spacing` before/after) written as plain twips beyond 16 bits keep their low 16 bits, as Word reads them (`w:left="66256"` is a 36 pt indent), instead of pushing the text off the page (`twips_wrap.rs`, P0059) |
|
||||
+12
-4
@@ -5,8 +5,9 @@
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** trivial (#10 narrowed: a turned inline picture whose file keeps no room for the turn keeps its line to its frame, as in Word); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial (gap 4: a float anchored right after a page break, before text, is drawn on the new page, #4262); previously trivial (Drawing Canvas members picked and moved on their own, #2637); previously trivial (gap 4: a float anchored on a later page of a long paragraph is drawn there, #4250); previously trivial (#29: typed `\int`, `\sum`… + Space wait for their limits, observed in Word for Windows on #4938, #4917); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (print helpers erase loaded plaintext inputs before driver work, #5710); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (gap 6: a print job waiting on its driver can be cancelled, #5710; no ranked gap changed); previously trivial (gap 6: the printer list no longer waits on printer drivers, #5710; no ranked gap changed); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (MATH construction reuse, #3630); previously trivial (DOCX `w:adjustRightInd` defaults true and is written in schema order); previously trivial (ink page lookups reuse scanned paragraph prefixes, #3614); previously trivial (#19: ODT settings for what the standard leaves open removed); previously trivial (#19: ODT element audit; files from other applications checked); previously trivial (#11: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (#4: fonts that aren't installed are flagged in the font box and status bar); previously trivial (#19: ODT sections inside text boxes kept as read); previously trivial (#19: an ODT saved without edits keeps what WordCraft doesn't model); previously trivial (#19: ODT interpretation profile, WordCraft / LibreOffice / custom); previously trivial (#19: ODT form controls placed and multi-line; custom shapes keep their geometry); previously trivial (#19: ODT in-line frames placed against the text; template instantiation listed as left); previously trivial (#19: ODT charts kept as read; frame outlines; default header height); previously trivial (#19: ODT tables no longer gain repeated header rows); previously trivial (#19: ODT padding without a border, unequal columns, column breaks, picture-only lines); previously trivial (#19: ODT tables' margins and spacing, header spacing, pictures alone in a paragraph); previously trivial (#19: no LibreOffice template changes page count through a save); previously trivial (#19: ODT variables, emphasis marks, page backgrounds, fixed tables, default size); previously trivial (#19: ODT checked against LibreOffice's own templates: placeholders, first-page styles, unsized pages, metafile logos, `.ott`); previously trivial (#19: ODT at ~90%Line truncated
|
||||
|
||||
> **Page Setup update:** 2026-11-07 · Page Setup number boxes keep long input inside the field, including accepted margin values after focus leaves (#5650); accepted ranges and readiness estimates are unchanged.
|
||||
> **Floating anchor row segments:** 2026-11-07 · A floating anchor after a manual page/column break counts visible content on the far side of the same wrapped row. Marker-only cover anchors retain their previous-page rule. Synthetic picture spacing, placement and history controls; no new native Word measurement. Readiness estimates unchanged.
|
||||
> **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged.
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (#15: bibliography sources keep a corporate author, #4848); previously trivial (Table Properties applies to the selected rows, columns and cells, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (#7: Custom Document Properties dialog, #2982; previously trivial: egui 0.37 migration: the interface catalogs stay shared and pinned by `craft-i18n.rev` at `9205108cfdced5177706264038848b68b2422d2b` (27 languages, English fallback); input-method handling has new regressions but managed CI passed, and native IME acceptance plus native-speaker catalog review remain open; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove ConLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** trivial (RTF footnotes, endnotes and comments, #1053); previously trivial (RTL paragraph direction in HTML/ODT/RTF implemented in #2695, pending merge); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (#15: bibliography sources keep a corporate author, #4848); previously trivial (Table Properties applies to the selected rows, columns and cells, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (#7: Custom Document Properties dialog, #2982; previously trivial: egui 0.37 migration: the interface catalogs stay shared and pinned by `craft-i18n.rev` at `9205108cfdced5177706264038848b68b2422d2b` (27 languages, English fallback); input-method handling has new regressions but managed CI passed, and native IME acceptance plus native-speaker catalog review remain open; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the coLine truncated
|
||||
>
|
||||
> **Recipient CSV update:** 2026-10-27 · Complete quoted header records choose the delimiter (#4249); detection and regressions follow the current mailings module split. Recipient limits and readiness estimates are unchanged.
|
||||
>
|
||||
@@ -88,6 +89,8 @@ Page drawing indexes text-box warp lookups by paragraph and story for the curren
|
||||
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963); a hyphen before a slash and a letter is also a break opportunity (#3408), while a hyphen before a slash and a digit stays whole A right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, so a TOC entry's page number keeps its leader (#1840). | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
|
||||
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963); a nested table's Can't Split row moves on with its outer row when the row splits across pages (#5456) A right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, so a TOC entry's page number keeps its leader (#1840). | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
|
||||
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963); a hyphen before a slash and a letter is also a break opportunity (#3408), while a hyphen before a slash and a digit stays whole; a full stop followed by an opening parenthesis stays on one line (#5437) A right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, so a TOC entry's page number keeps its leader (#1840). | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
|
||||
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | In-line pictures raised or lowered (`w:position`) set their line's height as in Word, past their own height too (#3984, #5796). SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; a cell's last Normal paragraph drops its contextual space after (#5450); [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963) A right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, so a TOC entry's page number keeps its leader (#1840). A floating object anchored on a later page of a long paragraph is drawn on that page, also when footnotes or widow control move its line on (#4250). Fonts the document uses that aren't installed are flagged in the font box and status bar, except those drawn in a metric-compatible substitute (Carlito for Calibri, Liberation Sans for Arial) and symbol-font text (Symbol, Wingdings, Webdings), so the user knows when line breaks can differ from where the document was made; `font.missing` lists the substituted fonts in the text the current view lays out (capped, for hostile documents), marking the metric-compatible ones `metricCompatible`. | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
|
||||
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; a cell's last Normal paragraph drops its contextual space after (#5450); [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963) A right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, so a TOC entry's page number keeps its leader (#1840). A floating object anchored on a later page of a long paragraph is drawn on that page, also when footnotes or widow control move its line on (#4250); one anchored right after a page break, before text, is drawn on the new page (#4262). Fonts the document uses that aren't installed are flagged in the font box and status bar, except those drawn in a metric-compatible substitute (Carlito for Calibri, Liberation Sans for Arial) and symbol-font text (Symbol, Wingdings, Webdings), so the user knows when line breaks can differ from where the document was made; `font.missing` lists the substituted fonts in the text the current view lays out (capped, for hostile documents), marking the metric-compatible ones `metricCompatible`. | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
|
||||
| 2 | **Word's native ink is not drawn from its strokes**; OLE objects show only their picture | B | Every native ink form is preserved: drawing-level ink with its optional picture fallback, direct run-level content parts, and ink members of groups and drawing canvases (kept in their place among the members, drawn from their fallback picture). Drawing canvases are read and written (#344); group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Pen notes from Word show only as their picture and can't be edited; OLE objects can't be opened | 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) |
|
||||
| 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; Track Changes Options has the Moves rows (Moved from / Moved to mark and colour) and `w:doNotTrackMoves` (Track moves off: a cut and paste is a deletion and an insertion) round-trips; still missing: drag-and-drop moves (no text drag yet). Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes (table, row, cell and section property changes now record in WordCraft too, #41 follow-up). Opaque custom XML backing stores now survive saves; binding evaluation remains absent. Replace All now checks content-control locks before editing any match; a refused batch preserves the document and undo history. | B | List-label pitch corrected in synthetic metric controls; Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641) and scales pages to another paper size (#2108); print-only N-up and booklets are available; driver tray/options remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) |
|
||||
@@ -100,9 +103,9 @@ Page drawing indexes text-box warp lookups by paragraph and story for the curren
|
||||
| 13 | **Charts and SmartArt editing** (editing charts and SmartArt from Word, more chart types and formatting) | | `insert.chart` and Chart Design basics landed (#409): WordCraft's own charts are editable; charts from Word stay read-only (no embedded-workbook editing), radar charts are drawn (#1890); no combo/3D/stock charts (charts of those types show a placeholder with their title and type, #998), axis or series formatting; Insert › SmartArt landed (#497) for graphics WordCraft makes | Reports and business documents | 35–55 | [target-app-parity.md](target-app-parity.md) |
|
||||
| 14 | **`.doc` can't be written; Word 6/95 files refused** | | `crates/docbin` is read-only | Users exchanging files with old Word or legacy systems | 25–40 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 15 | **References depth**: no EndNote/Mendeley desktop integration (bibliography sources are saved in DOCX, #790; Zotero landed, #189; Word's own `Sources.xml` master list imports into Manage Sources, #2640) | | #790 stores sources in `customXml` and reads them back; `crates/zotero`; `references.sources` `import` reads a Word bibliography file (`b:Sources`) | Students and researchers | 3–6 | [target-app-parity.md](target-app-parity.md) |
|
||||
| 16 | **Right-to-left completeness** (letters join across a change of formatting since #4093): kashida, RTL in HTML/ODT/RTF; six still-open older RTL bug reports to re-verify after #207 (#211, #199, #66, #63, #48, #19; #215 is closed). RTL tables and sections landed (#362); mirrored Arabic UI landed (#1195); PDF export's Hebrew and Arabic text copies and searches as whole words (#1383) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 5–9 | [layout-parity.md](layout-parity.md) |
|
||||
| 16 | **Right-to-left completeness** (letters join across a change of formatting since #4093): kashida; RTL in HTML/ODT/RTF implemented in #2695 (pending merge; the estimate drops from 5–9 to 4–8 once it merges); 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) |
|
||||
| 19 | **RTF and ODT depth**: notes and comments (done: RTF #1053), 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; RTF notes and comments landed (#1053) | 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) |
|
||||
| 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | DOCX comment saves avoid repeated paragraph-ID and durable-ID reservation searches; large threaded-comment fixtures retain metadata through save/reopen. Only the 188-page sample benchmark; scrolling a 1,096-page one-table document went from ~600 ms to ~2–5 ms a frame (#811), but opening it still lays out for seconds; opening a document with pictures no longer copies them (92 MB with 20 pictures: about 90 MB less); opening a document with many relationships now costs the size of its part and not that size times the number of relationships (1024 relationships and a 64 MiB part: 224 s → 0.2 s, #3411); a word with no break opportunity in it no longer lays out quadratically (1 MiB: 29.4 s → 0.35 s, #3412); saving a document with many content controls now costs the time the controls take (32,000 controls: 3.6 s → 0.1 s, #13); repeated table headers are capped by item counts, the wrap cache by decoded pixels and linked text box layout by its lines, items and story text, and PDF export, hit testing, tracked changes, clipped tables and long endnote selections cost linear time (#3867, #3866; not measured on real documents) | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) |
|
||||
| 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | DOCX comment saves avoid repeated paragraph-ID and durable-ID reservation searches; large threaded-comment fixtures retain metadata through save/reopen. Only the 188-page sample benchmark; scrolling a 1,096-page one-table document went from ~600 ms to ~2–5 ms a frame (#811), but opening it still lays out for seconds; opening a document with pictures no longer copies them (92 MB with 20 pictures: about 90 MB less); opening a document with many relationships now costs the size of its part and not that size times the number of relationships (1024 relationships and a 64 MiB part: 224 s → 0.2 s, #3411); a word with no break opportunity in it no longer lays out quadratically (1 MiB: 29.4 s → 0.35 s, #3412); saving a document with many content controls now costs the time the controls take (32,000 controls: 3.6 s → 0.1 s, #13); built-in citation refresh and first-cited bibliography ordering use temporary source and number indexes (#4888), retaining existing ordering and source metadata | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) |
|
||||
@@ -111,7 +114,7 @@ Page drawing indexes text-box warp lookups by paragraph and story for the curren
|
||||
| 23 | **PDF Reflow** (open a PDF as an editable document, Word for Windows) | | | Users who edit PDFs they receive | 30–50 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 24 | **Default shortcuts**: 140 (macOS) / 158 (Linux/Windows) of Word's ~250 (#330, #339, #343; Customize Keyboard #368, Customize Ribbon and Quick Access Toolbar #379; inspector, macros, strikethrough, file/edit keys, the Spike) | | [ui-parity.md](ui-parity.md) | Power users | 1–3 | [ui-parity.md](ui-parity.md) |
|
||||
| 25 | **In-app AI assistant** (Copilot-style draft, rewrite, summarize) | | Chat PR #178 and AI assistant PR #815 open | Users expecting Copilot | 15–30 + owner (provider) | [target-app-parity.md](target-app-parity.md) |
|
||||
| 27 | **View windows**: Side by Side, Synchronous Scrolling, Arrange All; a window per open document (the engine holds several open documents and Switch Windows lists and switches them) | | [parity-checklist.md](parity-checklist.md) (View 26/29); PR #331 open | Comparing documents | 3–5 | [ui-parity.md](ui-parity.md) |
|
||||
| 27 | **View windows**: Switch Windows, Arrange All, View Side by Side and Synchronous Scrolling work across the native windows, one per open document or New Window (#322). Left: Split is a placeholder; Wayland doesn't let apps place windows | | [parity-checklist.md](parity-checklist.md) (View 29/29 ids) | Comparing documents | 2–4 | [ui-parity.md](ui-parity.md) |
|
||||
| 26 | **Minor formats**: saving MHT, Works/WordPerfect | | Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens (#548) and saves (#1081, not yet checked in Word); MHT opens and documents' `w:altChunk` content is brought in | Rare | 12–23 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 28 | **Dictate** | | `tools.dictate` missing | Dictation users | 8–15 + owner (speech model) | [hardware-parity.md](hardware-parity.md) |
|
||||
| 29 | **Equations**: ink equations; a copied equation pasting into Word as an editable equation, still to be confirmed (#816, #1297) | | #191 notes; long display equations wrap at operators (#326); typed `\int`, `\sum`… + Space wait for their limits, and `\int_0^1` corrects on `_`, as observed in Word for Windows on #4938 (#4917); a copy holding an equation puts MathML in HTML on the clipboard (#816), and a copy of one equation alone also its MathML in the "MathML" clipboard formats Word reads first (Windows, macOS; #1297; Word/LibreOffice paste not yet verified) | Maths-heavy documents | 2–3 | [layout-parity.md](layout-parity.md) |
|
||||
@@ -195,6 +198,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-08 | trivial | #10 narrowed: a turned inline picture with no recorded effect extent keeps its line to its frame (exchanged near a quarter turn in Word 2013+ layout), cut at the line; gap stays open |
|
||||
| 2026-11-08 | trivial | #4 narrowed: a cell's last Normal paragraph drops its contextual space after, the end-of-cell mark counting as a Normal paragraph (#5450) |
|
||||
| 2026-11-07 | trivial | Page Setup also bounds the unfocused margin display after a long numeric paste; the accepted value is retained (#5650). |
|
||||
| 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-11-04 | trivial | Field argument import and page-format-aware layout/cache follow-ups; no ranked gap changed |
|
||||
@@ -205,6 +209,8 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text
|
||||
| 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-07 | trivial | Empty paragraph deletion preserves following paragraph/mark formatting (Leaf #152); synthetic regressions only, no ranked gap closed or estimate changed. |
|
||||
| 2026-11-07 | trivial | Charts: a series without its own colour takes the accent its `c:idx` picks, as Word does, not the one its place in the file picks (#5963) |
|
||||
| 2026-11-07 | trivial | A floating anchor after a manual page/column break counts visible content on the far side of the same wrapped row. Marker-only cover anchors retain their previous-page rule. Synthetic picture spacing, placement and history controls; no new native Word measurement. 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) |
|
||||
| 2026-11-06 | trivial | Windows print submissions use a separate helper per job, preserving provider serialization inside the helper. An acknowledged cancellation stops that helper, so a stalled cancelled driver cannot block a later job; normal shutdown reaps helpers and removes temporary PDFs. The helper erases its plaintext inputs before driver work; a hard app crash during startup can still leave private temporary files. Startup requires an explicit start grant; after sending begins, jobs complete (#5710) |
|
||||
| 2026-11-06 | trivial | Gap 6: File › Print: before sending starts, Cancel printing (`ui.printCancel`) stops a waiting job and frees the Print page while keeping its settings, including after the last document closes; a successfully cancelled job is never sent. Quitting cancels waiting jobs and joins jobs already sending (#5710). On Windows, winprint repeats the DEVMODE conversion after sending starts. A stall during that repeat prevents cancellation and can keep shutdown waiting, even before a native job exists. No ranked gap changed; readiness estimates unchanged |
|
||||
@@ -319,6 +325,8 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text
|
||||
| 2026-10-18 | trivial | Gap 8 narrowed: German text hyphenates by the TeX German patterns (reformed spelling); German spelling and grammar remain |
|
||||
| 2026-10-19 | trivial | Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in |
|
||||
| 2026-10-19 | trivial | Content controls: text typed over a placeholder takes the control's own run properties (`w:sdtPr/w:rPr`, ECMA-376 §17.5.2.27), as in Word, and the placeholder style is recognised by its built-in name "Placeholder Text" whatever its localized id (German Word: `Platzhaltertext`), so entries in forms from localized Word no longer stay grey |
|
||||
| 2026-10-11 | trivial | #27: Switch Windows, Arrange All, View Side by Side, Synchronous Scrolling landed (#322); New Window and Split left |
|
||||
| 2026-10-17 | trivial | #16: RTL paragraph direction (`w:bidi`) is implemented for HTML/ODT/RTF in #2695 (pending merge) (`dir="rtl"` / CSS `direction:rtl`, `style:writing-mode="rl-tb"`, `\rtlpar`); kashida and the six old RTL reports remain open |
|
||||
| 2026-10-18 | trivial | Font list starts at its first row after clearing a no-match query; recent heading remains visible (#2068) |
|
||||
| 2026-10-19 | trivial | #2964: a centred or end-aligned list label after a leading page break uses its text line's alignment. Synthetic LTR/RTL numbered/bulleted controls and the issue's DOCX export; broader pagination gaps and readiness estimates unchanged |
|
||||
| 2026-10-17 | trivial | #1327: Restrict Editing exceptions no longer let table, row or cell formatting change protected tables; formatting-only protection refuses Paste Formatting, table shading/borders/alignment/margins/text direction, table-style edits and locked table styles; recipient-list commands no longer rewrite merge-field results in a protected document. No gap row (enforcement fix); estimates unchanged |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Hardware parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-06 · **Change:** trivial (print helpers erase loaded plaintext inputs before driver work, #5710); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (a print job waiting on its driver can be cancelled, and quitting doesn't wait for it, #5710); previously trivial (the printer list no longer waits on printer drivers, #5710); previously trivial (Linux Read Aloud: a sentence a screen reader cuts off is said again); previously trivial (ink page lookup work, #3614); previously trivial (only the PDF sent to the printer has no link annotations; Save these pages as PDF keeps them, #4734); previously trivial (a document with links prints: the print job's PDF has no link annotations, #4734; previously trivial: Pen / stylus row: two-stroke and closed-head arrows; previously trivial: Pen / stylus row: ink, eraser and Ink to Shape incl. arrows landed; previously trivial: a window too big for its screen shrinks to fit when it opens, #1215; previously trivial: 32-bit Windows starts on OpenGL; previously major: first version) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 and Word for Windows
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-07 · **Change:** trivial (newly opened window dimensions are constrained to fit available screen dimensions, #5871); previously trivial (print helpers erase loaded plaintext inputs before driver work, #5710); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (a print job waiting on its driver can be cancelled, and quitting doesn't wait for it, #5710); previously trivial (the printer list no longer waits on printer drivers, #5710); previously trivial (Linux Read Aloud: a sentence a screen reader cuts off is said again); previously trivial (ink page lookup work, #3614); previously trivial (only the PDF sent to the printer has no link annotations; Save these pages as PDF keeps them, #4734); previously trivial (a document with links prints: the print job's PDF has no link annotations, #4734; previously trivial: Pen / stylus row: two-stroke and closed-head arrows; previously trivial: Pen / stylus row: ink, eraser and Ink to Shape incl. arrows landed; previously trivial: a window too big for its screen shrinks to fit when it opens, #1215; previously trivial: 32-bit Windows starts on OpenGL; previously major: first version) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 and Word for Windows
|
||||
|
||||
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
|
||||
|
||||
@@ -28,6 +28,7 @@ File > Print offers 2, 4, 6, 9 or 16 logical pages per physical sheet side and l
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-07 | trivial | Newly opened secondary windows constrain their initial viewport dimensions to the monitor's usable area, preventing windows from exceeding screen width on scaled displays (#5871) |
|
||||
| 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 use a separate helper per job, preserving provider serialization inside the helper. An acknowledged cancellation stops that helper, so a stalled cancelled driver cannot block a later job; normal shutdown reaps helpers and removes temporary PDFs. The helper erases its plaintext inputs before driver work; a hard app crash during startup can still leave private temporary files. Startup requires an explicit start grant; after sending begins, jobs complete (#5710) |
|
||||
| 2026-11-06 | trivial | File › Print: before sending starts, Cancel printing (`ui.printCancel`) stops a waiting job and frees the Print page while keeping its settings, including after the last document closes; a successfully cancelled job is never sent. Quitting cancels waiting jobs and joins jobs already sending (#5710). On Windows, winprint repeats the DEVMODE conversion after sending starts. A stall during that repeat prevents cancellation and can keep shutdown waiting, even before a native job exists. |
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Layout and pagination parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** trivial (a turned inline picture whose file keeps no room for the turn keeps its line to its frame, the frame turned a quarter in Word 2013+ layout); previously trivial (a picture lowered further than its own height lowers its line as far, its top on the baseline); previously trivial (a float anchored right after a page break, before text, is drawn on the new page, #4262); previously trivial (a float anchored on a later page of a long paragraph is drawn there, #4250); previously trivial (the line between columns runs as tall as the section's text, so columns for selected text have it, #5178); previously trivial (a line of only pictures is as tall as they reach, never less than its font's height; a legacy check box sizes its line as text of its size, #3984); previously trivial (spaces before a line's first character keep their width in justified and distributed lines, as in Word); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (Times and Courier take their Windows counterparts' line spacing; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversLine truncated
|
||||
> **Floating anchor row segments:** 2026-11-07 · A floating anchor after a manual page/column break counts visible content on the far side of the same wrapped row. Marker-only cover anchors retain their previous-page rule. Synthetic picture spacing, placement and history controls; no new native Word measurement. Readiness estimates unchanged.
|
||||
|
||||
|
||||
A document only "opens correctly" if its pages break where Word breaks them: the same lines, the
|
||||
same page count, footnotes and pictures on the same pages. This checklist covers line breaking,
|
||||
@@ -46,6 +48,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic
|
||||
| List bullets stored as a private-use character (U+E000–U+F8FF) in a font that isn't installed and isn't a mapped symbol-font code: drawn as the level's default bullet (• ○ ▪), not as whatever an installed icon font has at that code | ✅ | `layout::tests::private_use_bullet_no_font_draws_shows_the_levels_default_bullet` (#1683: U+F081 showed Font Awesome's Twitter logo) | — |
|
||||
| Indents in characters (`w:firstLineChars`, `w:leftChars`…), measured in the paragraph mark's font size | ✅ | #534, `layout::tests::character_indents_use_the_paragraph_mark_size`; spacing in lines (`w:beforeLines`/`w:afterLines`) uses the stored points (needs the document grid) | — |
|
||||
| Line spacing (single, 1.5, double, at least, exactly, multiple), spacing before/after, contextual spacing | 🟡 | Contextual spacing measured in body paragraphs and headers (below), with a four-paragraph table-cell regression; list-label ascent overflow is unscaled by multiple spacing and uses descent for automatic and at-least spacing (#601); a superscript or subscript sizes its line at the full font size and its raise or drop doesn't grow the line, while a `w:position` raise or lowering does, and its text is set at 0.65 of its size rounded to the half point (Word for Mac); other line-spacing combinations remain (#108, #135 open) | 3–5 |
|
||||
| Line spacing (single, 1.5, double, at least, exactly, multiple), spacing before/after, contextual spacing | 🟡 | Contextual spacing measured in body paragraphs and headers (below), with a four-paragraph table-cell regression; a cell's last paragraph with contextual spacing, in the Normal style (or an undefined one), drops its space after, the end-of-cell mark counting as a Normal paragraph beside it (not where `allowSpaceOfSameStyleInTable` keeps same-style space in modes 11/12/14); this rule is inferred from docxide-pdf's notes and its `cell-end-*` fixtures, and a Word comparison that tells it apart from the neighbouring paragraph's style is still open; a middle or bottom cell of a split row lines up without that dropped space (`contextual_spacing_drops_the_space_after_a_cells_last_normal_paragraph`, `a_bottom_cell_of_a_split_row_ignores_a_dropped_space_after_beside_it`, `a_bottom_cell_of_a_split_row_lines_up_with_a_kept_space_after_beside_it`); list-label ascent overflow is unscaled by multiple spacing and uses descent for automatic and at-least spacing (#601); a superscript or subscript sizes its line at the full font size and its raise or drop doesn't grow the line, while a `w:position` raise or lowering does (Word for Mac); other line-spacing combinations remain (#108, #135 open) | 3–5 |
|
||||
| Kerning only where Kerning for fonts asks for it (`w:kern`, at or above its size) | ✅ | Measured in Word for Windows: no `w:kern` or `w:kern="0"` is unkerned; WordCraft kerned every run, so its lines held more text than Word's. `layout::tests::kerning_follows_kerning_for_fonts_like_word` | — |
|
||||
| OpenType ligatures as the document asks (`w14:ligatures`: standard `liga`, contextual `clig`, historical `hlig`, discretional `dlig`, any combination, in runs, styles and defaults) | 🟡 | `layout::tests::ligatures_follow_w14_ligatures`, `ligature_flags_map_to_opentype_features`. Without the property WordCraft keeps drawing standard and contextual ligatures, while Word draws none; the Font dialog's Ligatures choice is still missing | 1 |
|
||||
| Automatic (HTML-style) paragraph spacing (`w:beforeAutospacing`, `w:afterAutospacing`) | ✅ | Measured in Word for Windows: 14 pt at any size, none at the top of the document or a cell or after a cell's last paragraph, the larger of two neighbouring spaces. `layout::tests::automatic_paragraph_spacing_like_word`, `…_drops_at_cell_edges` | — |
|
||||
@@ -305,11 +308,14 @@ The front-of-text opt-out is retained from the earlier implementation; this patc
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-08 | trivial | Turned inline pictures with no recorded effect extent keep their line to the frame (exchanged near a quarter turn in Word 2013+ layout) and overhang it, cut at the line's top and bottom, as in Word |
|
||||
| 2026-11-08 | trivial | Contextual spacing drops the space after a table cell's last paragraph in the Normal style, the end-of-cell mark counting as a Normal paragraph; another style keeps it, and a middle or bottom cell of a split row lines up without the dropped space (#5450) (`layout::tests::contextual_spacing_drops_the_space_after_a_cells_last_normal_paragraph`, `a_bottom_cell_of_a_split_row_ignores_a_dropped_space_after_beside_it`). |
|
||||
| 2026-11-04 | trivial | Header table measurements and fitted text-box heights use the page section’s PAGE format; cache reuse follows the field context |
|
||||
| 2026-11-03 | trivial | Body PAGE fields in tables use the active section number format; reflow views measure headers and footers with it before body placement |
|
||||
| 2026-11-06 | trivial | An in-line picture lowered further than its own height (`w:position`) was held with its top on the baseline and its line only as deep as the picture; Word keeps the top there but lowers the line as far as the position says. A 6 pt picture lowered 30 pt now makes a 30 pt line alone and the text's ascent plus 30 pt beside text, as in Word. This closes the gap noted on 2026-11-05: all 156 synthetic lines measured then now match Word within 0.35 pt, heights and picture positions. Exact spacing keeps the line's baseline as before (`tests::lowered_pictures_and_check_boxes_take_words_line_heights`, `tests::an_inline_picture_moves_with_its_runs_position`). |
|
||||
| 2026-10-31 | trivial | List labels: a level with numFmt none is an ordinary level whose own number reads as nothing: it shows its literal text (`RESPONSE:`), counts and restarts the levels below it, stays empty in legal numbering, and an empty label still tabs the text to the indent, as in Word (#4985) |
|
||||
| 2026-11-06 | trivial | Superscript and subscript text: Word for Mac sets it at 0.65 of the run's size rounded to the half point (from digit advances in its PDF export, base sizes 8–36 pt: 8 → 5, 9 → 6, 10 → 6.5, 11 → 7, 12 → 8, 13 → 8.5, 14 → 9, 16 → 10.5, 20 → 13, 24 → 15.5, 36 → 23.5). WordCraft used 0.65 exactly (11 pt → 7.15), so lines with superscripts came out up to 0.5 pt wider and some broke a word early; the `cargo xtask word-compare` superscript and author-line probes in Arial go from 95–97% of lines with Word's breaks to 100%. `doc::resolve::tests::raised_and_lowered_text_is_sized_to_the_half_point`, `layout::tests::superscript_digits_advance_at_the_half_point_size` |
|
||||
| 2026-11-06 | trivial | Line numbers are laid out with the document's "Line Number" character style over its defaults, as Word draws them, not in a fixed 10 pt grey (`layout::tests::line_numbers_follow_the_line_number_style`) |
|
||||
| 2026-11-07 | trivial | A floating anchor after a manual page/column break counts visible content on the far side of the same wrapped row. Marker-only cover anchors retain their previous-page rule. Synthetic picture spacing, placement and history controls; no new native Word measurement. Estimates unchanged. |
|
||||
| 2026-11-05 | trivial | PR #4545: eligible cell floats retain in-bounds offsets and touch the outer cell edge only on overflow, ignoring wrap distances for object placement. Front-of-text, turned-cell and outside-cell opt-outs remain. Synthetic geometry controls; native front/behind-text parity remains unqualified. |
|
||||
| 2026-11-05 | trivial | With multiple spacing a line of only in-line pictures is as tall as they reach, raised or lowered, and never less than its font's height (a list label's font counting too): a picture shorter than the font keeps the font's ascent and descent, as noted for ODT documents below; a taller one ends at the line's bottom with no descent under it. The spacing adds its share of the font's height, not of a lowered picture's. A legacy check box sizes its line as text of its size. Measured in Word with Arial at 10 and 20 pt, single, 1.15, 1.25 and 1.5 spacing, 156 synthetic lines (`tests::lowered_pictures_and_check_boxes_take_words_line_heights`). Known gap: a picture lowered further than its own height is limited to that height where Word's line keeps growing. |
|
||||
| 2026-11-04 | trivial | PR #4609: relative REF keeps context already displayed by the own or a needed ancestor label; six synthetic legal-template cases and separate-list context follow the Word for Windows measurements reported by @pakiat in PR discussion. Existing unknown-switch/cache boundaries and readiness estimates unchanged; no new native Word run. |
|
||||
|
||||
+3
-2
@@ -167,7 +167,8 @@ test source in File › Options › Language and on the bar's Install.
|
||||
| Tool | What it does |
|
||||
|---|---|
|
||||
| `list_commands` | every command (filter with `query`) — over 270 of them |
|
||||
| `execute` | run one command: `{command: "insert.table", params: {rows: 3, cols: 4}}` |
|
||||
| `execute` | run one command: `{command: "insert.table", params: {rows: 3, cols: 4}}` (an empty table; for a filled one use `insert_table`) |
|
||||
| `insert_table` | insert a filled table in one call: `{values: [["Month", "Cases"], ["January", 12]], header: true, style?}` (same as `insert.spreadsheet`) |
|
||||
| `batch` | run several commands in order |
|
||||
| `new_document` | blank, sample, letter, resume, report; opens beside the open documents |
|
||||
| `open_document` / `save_document` | docx, doc (open only), odt, rtf, html, md, tex, txt, json; save also pdf, png |
|
||||
@@ -190,7 +191,7 @@ document beside the ones already open and make it the active one; their reply ca
|
||||
active again (`alreadyOpen`). `list_documents` lists the open documents with their ids.
|
||||
|
||||
Tools work on the active document. `execute`, `batch`, `type_text`, `select_text`, `get_text`,
|
||||
`inspect_document`, `render_page` and `save_document` also take `doc`, the id of another open
|
||||
`insert_table`, `inspect_document`, `render_page` and `save_document` also take `doc`, the id of another open
|
||||
document: the tool works on that document and the active one stays active.
|
||||
|
||||
```json
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.
|
||||
|
||||
**505 of 520 catalog features have commands (97.1%).**
|
||||
**508 of 520 catalog features have commands (97.7%).**
|
||||
|
||||
| Tab | Live | Total |
|
||||
|---|---|---|
|
||||
@@ -17,7 +17,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
|
||||
| References | 25 | 25 |
|
||||
| Mailings | 23 | 23 |
|
||||
| Review | 29 | 31 |
|
||||
| View | 26 | 29 |
|
||||
| View | 29 | 29 |
|
||||
| Read Mode | 2 | 3 |
|
||||
| Developer | 11 | 11 |
|
||||
| Table Design | 8 | 8 |
|
||||
@@ -56,12 +56,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
|
||||
- Language › Translate (`review.translate`)
|
||||
- Protect › Block Authors (`review.blockAuthors`)
|
||||
|
||||
### View
|
||||
|
||||
- Window › Arrange All (`view.arrangeAll`)
|
||||
- Window › View Side by Side (`view.sideBySide`)
|
||||
- Window › Synchronous Scrolling (`view.syncScroll`)
|
||||
|
||||
### Read Mode
|
||||
|
||||
- View › Layout (`view.readLayout`)
|
||||
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
# WordCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (M15: Arabic mirrored UI #1195; 28 UI languages; Hebrew UI and native IME acceptance remain open); previously trivial (HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged; previously trivial: egui 0.37 migration: 27 interface languages remain pinned by `craft-i18n.rev` (9205108…), and the input-method handling has synthetic regressions while managed CI passed and native acceptance (#155–#164) remains open; previously trivial: M6: Manage Sources imports Word's `Sources.xml`, #2640; previously minor: Czech proofing review fixes; previously minor: Portuguese proofing; previously minor: Polish interface and Polish proofing; previously trivial: M7: formatting revisions landed, #41; previously trivial: M5: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: alpha gate checked: six core workflows pass; previously major: milestones moved here from ROADMAP.md; Current focus set from the re-measured gaps) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (#3693: additional Delete Previous Sentence command and shortcut; Word-parity estimates unchanged); previously minor (M15: Arabic mirrored UI #1195; 28 UI languages; Hebrew UI and native IME acceptance remain open); previously trivial (HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged; previously trivial: egui 0.37 migration: 27 interface languages remain pinned by `craft-i18n.rev` (9205108…), and the input-method handling has synthetic regressions while managed CI passed and native acceptance (#155–#164) remains open; previously trivial: M6: Manage Sources imports Word's `Sources.xml`, #2640; previously minor: Czech proofing review fixes; previously minor: Portuguese proofing; previously minor: Polish interface and Polish proofing; previously trivial: M7: formatting revisions landed, #41; previously trivial: M5: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: alpha gate checked: six core workflows pass; previously major: milestones moved here from ROADMAP.md; Current focus set from the re-measured gaps) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
Forward-looking plan. The summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is
|
||||
[`gaps.md`](gaps.md); the numbers are in [`target-app-parity.md`](target-app-parity.md). Hours are
|
||||
@@ -49,13 +49,13 @@ partial rows are fidelity gaps (beta work), not broken workflows. WordCraft stay
|
||||
|---|---|---|---|
|
||||
| M0 | Skeleton and vertical slice (model, layout, render, engine, Word-style UI, CLI, MCP, web) | done | — |
|
||||
| M1 | DOCX I/O | done (first version); real-world corpus not started; charts/SmartArt/OLE dropped | 45–75 h |
|
||||
| M2 | Home tab | done except Paste Special, Clipboard pane, Style Inspector, Manage Styles, Asian typography (PRs open for four) | 5–10 h |
|
||||
| M2 | Home tab | done except Paste Special, Clipboard pane, Style Inspector, Manage Styles, Asian typography (PRs open for four); additional editing command Delete Previous Sentence (#3693), outside the Word-parity count | 5–10 h |
|
||||
| M3 | Insert tab | mostly done; equations done (#191), icons done (#309); charts, SmartArt, 3D, screenshot, online media missing | 50–80 h |
|
||||
| M4 | Layout and Design tabs | done except column balancing, group | 6–10 h |
|
||||
| M5 | Tables | done (row splitting, floating tables, style formatting); custom table styles (#256); Draw Table and Eraser (#303) | 6–10 h |
|
||||
| M6 | References | first version done (TOC fields, cross-references, Zotero); footnote continuation missing; all 12 bibliography styles (#383); bibliography sources import from Word's `Sources.xml` (#2640) | 15–25 h |
|
||||
| M7 | Review | done (balloons, paragraph-mark revisions, formatting revisions #41); move tracking, multilingual proofing missing | 35–60 h |
|
||||
| M8 | View | mostly done; window commands missing | 3–5 h |
|
||||
| M8 | View | mostly done; persistent screen paper presets added (#3398); window commands missing | 3–5 h |
|
||||
| M9 | Mailings | done (first version); Excel/Outlook data sources and email merge missing | 6–10 h |
|
||||
| M10 | File/Backstage | mostly done; native printing, Options depth missing | 12–20 h |
|
||||
| M11 | Draw and objects | text wrap, text boxes, canvas handles, rotation (#332), ink pens and eraser (#307), contour wrap (#354), lasso, Add Pen, Ink to Shape and Ink Replay (#395) done; Ink to Math, grouping missing | 35–55 h |
|
||||
@@ -76,6 +76,8 @@ languages, screen-reader access, ecosystem decisions (VBA, add-ins, cloud), an i
|
||||
|---|---|---|
|
||||
| 2026-11-03 | trivial | DOCX text-frame anchor locks survive JSON and two save/reopen cycles, including position and size edits |
|
||||
| 2026-11-03 | minor | M15: Arabic mirrored interface (#1195); 28 UI languages from craft-i18n; Hebrew UI, native-speaker review and native IME acceptance (#155–#164) remain open |
|
||||
| 2026-10-31 | minor | #3398: persistent screen-only green, cream and gray paper presets, with document backgrounds and export colors retained. Read Mode None follows the screen preset; Sepia/Inverse retain priority. |
|
||||
| 2026-10-31 | minor | #3693: Delete Previous Sentence command and keyboard dispatch, with sentence/paragraph/cell, selection, undo and tracking regressions. Additional WordCraft behavior; Word-parity estimates unchanged. |
|
||||
| 2026-10-29 | trivial | HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged. |
|
||||
| 2026-10-25 | trivial | egui 0.37 migration: 27 interface languages remain pinned by `craft-i18n.rev` (9205108…); input-method surrounding-delete and commit handling has synthetic regressions only, with managed CI passing; native acceptance (#155–#164) and native-speaker review remain open |
|
||||
| 2026-10-18 | trivial | M6: Manage Sources imports Word's `Sources.xml` (#2640) |
|
||||
|
||||
@@ -75,7 +75,7 @@ full parity for the area.
|
||||
| Pictures, shapes, text boxes, WordArt | 6 | 60% | 60% | 20–30 | Insert, crop, recolour, styles, floating placement like Word (#136), wrap (square/top-bottom/behind/front), text boxes edit and overflow (#46), VML text boxes (#242), free rotation and flips with a rotation handle (#332), text-box text direction (90°, 270°, stacked), Align Text and linked text boxes (#369), Icons: an original icon library drawn in code with a searchable picker, inserted as pictures and recoloured with Icon Color (#309), Drawing Canvas with Fit/Expand/Scale and DOCX `wpc:wpc` (#344; members edited by command, not yet dragged inside the canvas; the canvas rotates and flips as a whole). Missing: tight/through contour wrap, text in a freely rotated text box, group (#267 open), shape effects, WordArt, connectors, online pictures, screenshot. |
|
||||
| Equations | 1 | 75% | 20% (with Draw) | 4–8 | OMML read/write, OpenType MATH layout, in-place editor with an Equation tab (#191); typed `\int`, `\sum`… + Space wait for their limits (`_a^b`), any character that can't continue `\name` corrects it (`\int_0^1`), and an operator built without an operand takes the caret into it, as observed in Word for Windows on #4938 (#4917, `equation::tests::an_autocorrected_operator_waits_for_its_limits`). Missing: line breaking of long display equations, ink equations. |
|
||||
| Charts, SmartArt, Draw/ink, 3D models, icons | 4 | 3% | (20% with equations) | 70–110 | Icons landed (#309; an original library drawn in code). Draw: pens, pencil, highlighter, stroke eraser and Hide Ink (#307), ink saved as DrawingML freeforms; Lasso Select (move, recolour, delete), Add Pen gallery, Ink to Shape (line, arrow, rectangle, ellipse, triangle, polygon) and Ink Replay (#395); Ink to Math, pressure and Word's own InkML missing; charts: Insert › Chart makes column, bar (clustered and stacked), line, area, pie, doughnut and scatter charts with Chart Design (change type, Edit Data grid, title, legend, data labels) and saves them as real chart parts (#409); charts from Word are drawn (#292) and kept on save (#319) but stay read-only; SmartArt is drawn and kept (#292, #319); Insert › SmartArt (#497) creates and edits list, process, cycle, hierarchy, pyramid, radial, matrix and Venn graphics with our own layouts, a Text Pane and the SmartArt Design tab, saved as diagram parts with the drawing; SmartArt from other programs stays read-only. |
|
||||
| View (modes, zoom, panes, windows) | 3 | 72% | 70% | 6–10 | Print/web/draft/read/focus, dark page (#194), pages side by side when zoomed out (#251), navigation pane; Outline view with the Outlining tab: levels, promote/demote, moving with collapsed content, expand/collapse, Show Level, first line only, text formatting (#493). Missing: View Side by Side, Synchronous Scrolling, Arrange All, Switch Windows, Master Document, Immersive Reader depth. |
|
||||
| View (modes, zoom, panes, windows) | 3 | 72% | 70% | 6–10 | Print/web/draft/read/focus, dark page (#194), pages side by side when zoomed out (#251), navigation pane; Outline view with the Outlining tab: levels, promote/demote, moving with collapsed content, expand/collapse, Show Level, first line only, text formatting (#493). Switch Windows, Arrange All, View Side by Side and Synchronous Scrolling across the native windows (#322). Missing: Split, Master Document, Immersive Reader depth. |
|
||||
| Backstage, printing, options | 3 | 58% | 55% | 12–20 | New/Home pages (#254), save prompts (#151), web printing via the browser (#209). Desktop printing still goes through PDF; Options has a fraction of Word's panes. |
|
||||
| Right-to-left and complex scripts | 1 | 50% | 55% | 15–25 | UAX #9, Arabic shaping, RTL paragraphs (#207). No RTL sections/tables, kashida justification or mirrored UI; open bug reports (#215, #211, #199, #66, #63, #48, #19) need re-testing against #207. |
|
||||
| East Asian typography | 1 | 25% | not rated | 13–22 | IME input works (winit fixes pending, #155–#164). Vertical text in sections, text boxes and cells with upright ideographs (#491; no `vert` glyph alternates). Ruby/Phonetic Guide with DOCX round trip (#288); document grid (lines per page, characters per line, #391). Two lines in one and horizontal in vertical (`w:eastAsianLayout`) are kept on save but not laid out. No enclose characters, Asian line-break options. |
|
||||
@@ -253,6 +253,7 @@ can be used without committing Word output.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-26 | trivial | View › Window across the native windows (#322): View catalog 26/29 → 29/29; View row text only, percentages unchanged |
|
||||
| 2026-10-30 | trivial | Equations: `\int`, `\sum` and the other n-ary operators typed with AutoCorrect wait for their limits: `_x^y` then Space gives the operator its limits, Space alone builds it without, and the operand typed next goes inside it; any character that can't continue `\name` corrects it (`\int_0^1`); observed in Word for Windows on #4938 (#4917); no percentage change |
|
||||
| 2026-10-30 | trivial | Tables row: tables nest 18 deep at most, so a `.json` save or AutoRecover snapshot reads back, rich content in the deepest cell included (it was 24, which a `.json` save could not reopen); the .docx reader and writer still keep 24 (#3618); no percentage change |
|
||||
| 2026-10-29 | trivial | Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Filtered/sorted order, product geometry and single-label mode retained; readiness estimates unchanged. |
|
||||
|
||||
+19
-1
@@ -1,6 +1,6 @@
|
||||
# Typing parity with Word
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-04 · **Change:** trivial (Ctrl+K on a selection retypes it with a changed Text to display); previously trivial (equation `\int` + Space waits for its limits, `_x^y` then Space builds them and typing goes into the operand, and any character that can't continue `\name` corrects it, observed in Word for Windows on #4938, #4917); previously minor (English spelling by dialect from SCOWL 2026, CP2; previously trivial: a tracked heading or border-line AutoFormat replaces another author's formatting change; previously trivial: Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (highlight at a caret leaves the word alone, #4086); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial: tests and notes for the border-line, table, tab-indent and wide-space rules; previously trivial: `*Bold* and _italic_ as real formatting` in AutoFormat As You Type; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: queued IME replacements are rejected before mutation after their marked composition loses its body/equation target; previously trivial: the transactional equation delete proves its commit with the engine revision; previously trivial: a refused byte-counted equation deletion drops only the immediately adjacent stale replacement, so a later direct commit still applies; previously trivial: a tracked own-insertion shifts the rest of a two-sided IME deletion; previously trivial: AutoCorrect/AutoFormat classification compares the actual paragraph; previously trivial: a replaced body starts a new input-method context and interrupts the composition; a quote the engine leaves as typed is the input method's own change; equation `DeleteSurrounding` erases counted characters; previously trivial: AutoCorrect finds the word after a no-break space or an ideographic space; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously trivial: while an input method composes, the caret and its candidate window sit at the input method's cursor inside the composition; previously trivial: Ctrl+Tab types a tab in a table cell, #3137; previously trivial: List AutoFormat triggers in front of an already-written paragraph, not only a fresh empty one, checked in Word for Windows, #3352; previously trivial: AltGr types its characters without opening KeyTips on Linux, #2388; previously trivial: Delete on whole selected cells leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too, #2802; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document startLine truncated
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-04 · **Change:** minor (#3693: additional Delete Previous Sentence command and shortcut; Word-parity estimates unchanged); previously trivial (Ctrl+K on a selection retypes it with a changed Text to display); previously trivial (equation `\int` + Space waits for its limits, `_x^y` then Space builds them and typing goes into the operand, and any character that can't continue `\name` corrects it, observed in Word for Windows on #4938, #4917); previously minor (English spelling by dialect from SCOWL 2026, CP2; previously trivial: a tracked heading or border-line AutoFormat replaces another author's formatting change; previously trivial: Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (highlight at a caret leaves the word alone, #4086); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial: tests and notes for the border-line, table, tab-indent and wide-space rules; previously trivial: `*Bold* and _italic_ as real formatting` in AutoFormat As You Type; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: queued IME replacements are rejected before mutation after their marked composition loses its body/equation target; previously trivial: the transactional equation delete proves its commit with the engine revision; previously trivial: a refused byte-counted equation deletion drops only the immediately adjacent stale replacement, so a later direct commit still applies; previously trivial: a tracked own-insertion shifts the rest of a two-sided IME deletion; previously trivial: AutoCorrect/AutoFormat classification compares the actual paragraph; previously trivial: a replaced body starts a new input-method context and interrupts the composition; a quote the engine leaves as typed is the input method's own change; equation `DeleteSurrounding` erases counted characters; previously trivial: AutoCorrect finds the word after a no-break space or an ideographic space; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously trivial: while an input method composes, the caret and its candidate window sit at the input method's cursor inside the composition; previously trivial: Ctrl+Tab types a tab in a table cell, #3137; previously trivial: List AutoFormat triggers in front of an already-written paragraph, not only a fresh empty one, checked in Word for Windows, #3352; previously trivial: AltGr types its characters without opening KeyTips on Linux, #2388; previously trivial: Delete on whole selected cells leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too, #2802; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts Line truncated
|
||||
|
||||
What Word does when you type, observed black-box (same keystrokes typed into Word and
|
||||
WordCraft, results read back paragraph by paragraph) and pinned by tests in
|
||||
@@ -8,6 +8,23 @@ WordCraft, results read back paragraph by paragraph) and pinned by tests in
|
||||
tests together. Line breaking and pagination are in [`layout-parity.md`](layout-parity.md); the
|
||||
overall picture is in [`target-app-parity.md`](target-app-parity.md).
|
||||
|
||||
## Additional editing command: Delete Previous Sentence (#3693)
|
||||
|
||||
`text.deleteSentenceBack` is a WordCraft extension requested for Ctrl+Shift+Backspace
|
||||
(Cmd+Shift+Backspace on macOS), not an observed Word default. It deletes from the caret
|
||||
back to the start of its sentence; at the start of a sentence it deletes the previous
|
||||
sentence, including its trailing spaces. It uses the same boundaries as Select Sentence
|
||||
(`.`, `!`, `?` and following spaces), not a new language-specific sentence segmenter.
|
||||
An existing selection is deleted normally. At the start of a paragraph or table cell it
|
||||
does nothing: paragraph marks and cell boundaries are preserved. Deletion uses the normal
|
||||
undo, tracked-changes and protection paths. The shortcut can be reassigned in Customize Keyboard.
|
||||
|
||||
Evidence: `crates/engine/src/tests/typing.rs::delete_previous_sentence_respects_caret_boundaries_and_unicode`,
|
||||
`delete_previous_sentence_keeps_paragraph_boundaries_and_deletes_selections`,
|
||||
`delete_previous_sentence_tracks_changes_and_respects_read_only`,
|
||||
`delete_previous_sentence_does_not_cross_a_table_cell_boundary`, and
|
||||
`crates/ui-egui/src/keys/tests.rs::delete_previous_sentence_shortcut_dispatches_and_can_be_reassigned`.
|
||||
|
||||
## Finding smart-quoted text (#5388)
|
||||
|
||||
Ordinary Find and Replace treat a keyboard apostrophe (`'`) as a straight or typographic single quote (`‘`, `’`), so a search for `Tau'va` finds `Tau’va` typed with smart quotes. Partial queries, case options and whole-word matching still apply. Wildcards, regular expressions, literal searches (`special: false`) and numeric character codes such as `^u39` remain exact. A curly-quote query also stays exact, and the modifier-letter apostrophe `ʼ` is distinct. Replacement `^&` keeps the original matched spelling. This behavior is covered by original engine and CLI/MCP controls; no fresh Word or native desktop comparison is claimed.
|
||||
@@ -604,6 +621,7 @@ DOCX and Undo/Redo (Leaf #152). No new Word or native UI comparison was run.
|
||||
| 2026-11-03 | trivial | Alt+numpad codes type their character in text boxes too (Find what, dialog fields), not the digits or nothing (#4403) |
|
||||
| 2026-11-03 | trivial | Find smart-quoted text with a keyboard apostrophe (#5388); exact search modes and replacement spelling preserved. |
|
||||
| 2026-11-01 | minor | English spelling by dialect from SCOWL 2026: typed British, Canadian and Australian text, modern words and ordinals pinned (#40, #520, #1281, #1045; CP2) |
|
||||
| 2026-10-31 | minor | #3693: Delete Previous Sentence command and keyboard dispatch, with sentence/paragraph/cell, selection, undo and tracking regressions. Additional WordCraft behavior; Word-parity estimates unchanged. |
|
||||
| 2026-10-29 | trivial | Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged |
|
||||
| 2026-10-28 | trivial | Highlighting at a caret leaves the word alone and sets the colour for what is typed next (#4086). |
|
||||
| 2026-10-28 | trivial | Arabic Backspace removes one combining mark and keeps its base letter (#4757); stored mark order, caret/forward-Delete and other-script behavior retained; original synthetic command/history/protection/save-reopen checks, no native Word comparison; readiness estimates unchanged |
|
||||
|
||||
@@ -54,6 +54,7 @@ Document title opens Rename Document; the account badge opens Options › Genera
|
||||
| Selection: word/sentence/paragraph clicks, dragging after a double-click/triple-click/Ctrl+click extends by whole words/paragraphs/sentences (#549), Shift extend, F8 extend mode, column (Alt+drag, #237), selection bar in the left margin (click a line, double-click a paragraph, triple-click or Ctrl+click everything, drag by lines, Shift+click extends; #856) | ✅ Missing: the selection bar in the right margin for right-to-left paragraphs, beside table rows, and Word's right-pointing arrow cursor (egui has none; the plain arrow shows) | ✅ | 92% | 1 |
|
||||
| Headers and footers on the page: double-click the top or bottom margin to edit them, the body to leave | ✅ in Print Layout, also on a page with none yet: the header or footer that page shows is created (first page, even page or the default) and the caret put in it; one left empty is taken away again on leaving and is never saved (#960). Making one is an undo step, refused in a protected document. On a later section's page with no header anywhere it goes in the first section and shows on every section linked to it, as Word links to the previous section by default. With Different First Page (or Odd & Even) on, a double-click goes into the header that page shows even from another header being edited, and Insert › Header/Footer and Edit Header with the caret on page 1 use page 1's own (#2826). Closing it (Esc, Close Header and Footer) puts the caret back where it was in the body, as Word does, instead of on page 1 (#3331); a double-click on the body puts it where clicked. While one is being edited, a single click in the other (the footer while editing the header, or another page's own) edits that one with the caret where clicked, and the view stays put. Web Layout, Draft and Read Mode show no margins and leave it alone. A click in the margin band from the body leaves the caret and the view where they are, so double-clicking a later page's footer opens it there (#2103) | ✅ | 95% | 0.5 |
|
||||
| Zoom: Ctrl+scroll (only zooms; a coast or notch still gliding stops, #1743), pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251) | ✅ | ✅ | 85% | — |
|
||||
| Windows (View › Window): Switch Windows, Arrange All, View Side by Side, Synchronous Scrolling (#322) across the native windows; Split is a placeholder; Wayland ignores placement and may only flash for focus | partial | ✅ | 60% | 2–4 |
|
||||
| Touchpad smooth/inertial scroll | ✅ (#252) | ✅ | 90% | — |
|
||||
| Web Layout width | Text wraps to the whole work area (window width at the current zoom, 144–20,000 pt) and re-wraps once a window resize has settled (not on every frame of a drag) or at once on a zoom, keeping the caret in view; the sheet runs edge to edge with no centred page. Draft and Outline keep their 800 pt column; headless sessions keep 800 pt (#1876) | text across the window | 95% | — |
|
||||
| Document scroll bars | ✅ always drawn beside the page in the room left for them, wherever the pointer is, with a grey thumb that stands out from its track in the light and the dark theme, and more so under the pointer and while dragged; they were invisible until the pointer reached the window's right edge (#5746, `canvas::tests::the_document_scroll_bars_show_with_the_pointer_away`); hidden in Read Mode, as in Word | always shown | 90% | — |
|
||||
@@ -80,6 +81,21 @@ The Language dialog reads effective character formatting, including document def
|
||||
|
||||
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.
|
||||
|
||||
## Screen paper colors (#3398)
|
||||
|
||||
File › Options › General › Screen paper color selects Document background (the default),
|
||||
Soft green, Warm cream or Soft gray. The selection persists in user preferences, not in a document.
|
||||
It changes blank paper in editing views and in Read Mode's None color; a document's explicit
|
||||
page color or background picture remains visible while editing. Read Mode's explicit Sepia and
|
||||
Inverse colors override the preference. Choosing a tint turns off Dark page; enabling Dark page
|
||||
later takes precedence in editing views until it is turned off again (Read Mode ignores it). Printing, PDF/image exports and saved
|
||||
DOCX background styling are unaffected. This is a WordCraft extension (`view.paperColor`), not
|
||||
an increase in the Microsoft Word parity score.
|
||||
|
||||
Evidence: engine `screen_paper_color_is_a_persistent_preference_not_document_formatting`,
|
||||
canvas `screen_paper_color_changes_raster_and_cache_without_changing_export_paper`, and
|
||||
options `screen_paper_color_options_round_trip_and_work_without_a_document` tests.
|
||||
|
||||
## 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.
|
||||
@@ -127,6 +143,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text
|
||||
| 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) |
|
||||
| 2026-11-03 | minor | Arabic mirrored interface: title bar, ribbon, panes, File page, dialogs and menus run from the right; document canvas stays independent (#1195); Hebrew UI still open |
|
||||
| 2026-10-31 | minor | #3398: persistent screen-only green, cream and gray paper presets, with document backgrounds and export colors retained. Read Mode None follows the screen preset; Sepia/Inverse retain priority. |
|
||||
| 2026-11-03 | minor | File › Options › Language: Your languages (English variants in use, installed Language packs, the document's languages) with each one's proofing status, Install, Remove and Set as default; Add a language… opens a searchable picker (dutch, nederlands, nl) sorted by name; the Dictionaries folder and the Dictionary files list moved here from Proofing, which has no Dictionaries section any more (CP4.3) |
|
||||
| 2026-10-28 | trivial | Fonts that aren't installed are flagged: the font box (ribbon, mini toolbar, both Font dialog boxes) shows a warning mark whose hover note names the font drawn instead; the status bar says "N fonts not installed" and lists them with the faces drawn instead (the first 20, then how many more), with Add Font…. The list comes from layout (`ParaLayout::substituted`), so it covers exactly the text shown, in every story, list labels and East Asian and complex-script text included, and a missing roman font drawn in Cambria counts. Metric-compatible substitutes (Carlito for Calibri, Caladea for Cambria, Liberation for Arial, Times New Roman and Courier New, the Palatino look-alikes) aren't flagged, since line breaks don't move, nor symbol fonts drawn as their Unicode equivalents (Word's bullets); headers and footers count only in views that show them, chart and SmartArt labels not yet; the font box reads the same list, so for text already shown the two agree, and it also warns for a missing font no text shows yet when the box shows a font to come: at a collapsed caret, the font the next letter will be typed in, and in the Font dialog, the fonts OK applies (the Asian text font too, over Latin text); Missing Fonts (`font.missing`, from command search or agents; it says what it found in the status line) and `format.state.fontShownAs` for agents |
|
||||
| 2026-10-31 | trivial | Keytips: Alt, F badges the Backstage pages with Word's letters and the letter opens the page; Alt, F, P and Alt, F, V open Print; the badges end with the Backstage (#4960) |
|
||||
@@ -349,6 +366,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text
|
||||
| 2026-10-11 | trivial | Interface size setting in File › Options › General (80–200 %), kept between runs and applied on top of the system's display scale; `ui.interfaceSize` for agents (#475) |
|
||||
| 2026-10-11 | trivial | Font list ends with Add Font… (TTF, OTF, TTC, WOFF, WOFF2; also by dropping the file); added fonts are listed at once and kept between runs (#582) |
|
||||
| 2026-10-11 | trivial | Font box (ribbon and mini toolbar): typing lists the matching fonts (prefix first, then any part of the name); Up/Down, Enter or a click picks one, Escape cancels (#359) |
|
||||
| 2026-10-26 | trivial | View › Window across the native windows: Switch Windows, Arrange All, View Side by Side, Synchronous Scrolling (#322) |
|
||||
| 2026-10-11 | trivial | Templates and Add-ins (template path, Attach…, automatically update document styles) and Organizer (copy styles from another file, delete, rename) dialogs (#377); styles update from the template only when the user asks (OK with the box ticked, or Update Styles), never on open; the three Modal dialogs rows merged into one |
|
||||
| 2026-10-11 | trivial | Field codes (#339): View › Field Codes (Alt+F9), Toggle Field Codes (Shift+F9), a field-code popup (double-click, right-click › Edit Field…; Ctrl+F9 opens the Field dialog), unlink/lock/unlock and next/previous field keys; field items in the context menu |
|
||||
| 2026-10-10 | trivial | Tabs dialog (Paragraph › Tabs…, double-click a ruler tab) and Borders and Shading with Borders, Page Border and Shading tabs (#320) |
|
||||
|
||||
Reference in new issue
Block a user