diff --git a/ROADMAP.md b/ROADMAP.md index 9054020b94..46a538053f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) | diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 1c29e22899..83dc231769 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -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; diff --git a/crates/doc/src/link.rs b/crates/doc/src/link.rs index 5ef9e0e752..0dc4b17694 100644 --- a/crates/doc/src/link.rs +++ b/crates/doc/src/link.rs @@ -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:?}"); diff --git a/crates/docx/src/package.rs b/crates/docx/src/package.rs index 3835bdc0b6..656a2ad274 100644 --- a/crates/docx/src/package.rs +++ b/crates/docx/src/package.rs @@ -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, + /// Each id's first relationship in `list`, so a lookup doesn't scan every relationship. + ids: HashMap, } 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#""#); + for i in 0..20_000 { + rels.push_str(&format!(r#""#)); + } + rels.push_str(r#""#); + 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])]); diff --git a/crates/docx/src/read/chart/model.rs b/crates/docx/src/read/chart/model.rs index a3ee99637a..71ee7040f6 100644 --- a/crates/docx/src/read/chart/model.rs +++ b/crates/docx/src/read/chart/model.rs @@ -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, pub(super) color: Option, pub(super) line_w: f32, /// Whether the series is drawn as a line (scatter series without one get markers). diff --git a/crates/docx/src/read/chart/parse.rs b/crates/docx/src/read/chart/parse.rs index 737effde32..3c09fcfe9d 100644 --- a/crates/docx/src/read/chart/parse.rs +++ b/crates/docx/src/read/chart/parse.rs @@ -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::().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() } } } diff --git a/crates/docx/src/read/chart/tests.rs b/crates/docx/src/read/chart/tests.rs index 4e064e9a85..b943f1cfd5 100644 --- a/crates/docx/src/read/chart/tests.rs +++ b/crates/docx/src/read/chart/tests.rs @@ -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, Vec) { + let plot = format!( + r#"{}"#, + 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("").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#""#), renumbered(&b, r#""#)]; + 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#""#)]; + 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#""#), renumbered(&b, r#""#)]; + 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#""#)]; + 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#""#)), renumbered(&b, &format!(r#""#))]; + 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#""#, u32::MAX))]; + assert_eq!(column_colours(&big).0.len(), 1); +} diff --git a/crates/docx/src/read/embed.rs b/crates/docx/src/read/embed.rs index 1cc97bb038..a2a91f4e57 100644 --- a/crates/docx/src/read/embed.rs +++ b/crates/docx/src/read/embed.rs @@ -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#"text"#, + &[("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] diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index 182ee179f2..2a218b2c47 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -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. diff --git a/crates/docx/src/read/styles.rs b/crates/docx/src/read/styles.rs index 878edddf98..6e972a7604 100644 --- a/crates/docx/src/read/styles.rs +++ b/crates/docx/src/read/styles.rs @@ -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; } } diff --git a/crates/docx/src/units.rs b/crates/docx/src/units.rs index c9862fb047..2e5dfb0cd5 100644 --- a/crates/docx/src/units.rs +++ b/crates/docx/src/units.rs @@ -116,6 +116,27 @@ pub fn tw(e: &El, attr: &str) -> Option { 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 { + let v = e.attr(attr)?; + match v.trim().parse::() { + 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 { + let v = e.attr(attr)?; + match v.trim().parse::() { + 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)); diff --git a/crates/docx/src/write/embed.rs b/crates/docx/src/write/embed.rs index 19154a8790..d8f40a209e 100644 --- a/crates/docx/src/write/embed.rs +++ b/crates/docx/src/write/embed.rs @@ -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, 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, 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#""#, + ); + for i in 0..40_000 { + xml.push_str(&format!(r#""#)); + } + xml.push_str(""); + 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 diff --git a/crates/docx/tests/it/main.rs b/crates/docx/tests/it/main.rs index 68caa1f5be..df7e28fdff 100644 --- a/crates/docx/tests/it/main.rs +++ b/crates/docx/tests/it/main.rs @@ -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; diff --git a/crates/docx/tests/it/twips_wrap.rs b/crates/docx/tests/it/twips_wrap.rs new file mode 100644 index 0000000000..a560070428 --- /dev/null +++ b/crates/docx/tests/it/twips_wrap.rs @@ -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 { + let root = r#""#; + let doc = format!( + r#"{body}"# + ); + zip(&[("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())]) +} + +fn props(body: &str) -> Vec { + 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#"a + b + c + d"#, + ); + 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#"a"#); + 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#"a + b"#, + ); + 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))); +} diff --git a/crates/engine/src/cmd/fmt_revisions.rs b/crates/engine/src/cmd/fmt_revisions.rs index 330bdfd93f..06f92831c0 100644 --- a/crates/engine/src/cmd/fmt_revisions.rs +++ b/crates/engine/src/cmd/fmt_revisions.rs @@ -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, +} + +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>> { + 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 diff --git a/crates/engine/src/cmd/objects/tests.rs b/crates/engine/src/cmd/objects/tests.rs index 51d04872d1..13836d60c5 100644 --- a/crates/engine/src/cmd/objects/tests.rs +++ b/crates/engine/src/cmd/objects/tests.rs @@ -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) { diff --git a/crates/engine/src/cmd/para.rs b/crates/engine/src/cmd/para.rs index a96e16562c..06068ee65d 100644 --- a/crates/engine/src/cmd/para.rs +++ b/crates/engine/src/cmd/para.rs @@ -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 { vec![ @@ -270,6 +275,26 @@ pub fn specs() -> Vec { } 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"}"#), ] } diff --git a/crates/engine/src/cmd/para/tools.rs b/crates/engine/src/cmd/para/tools.rs new file mode 100644 index 0000000000..b900b6c205 --- /dev/null +++ b/crates/engine/src/cmd/para/tools.rs @@ -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), 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 + '_ { + 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 { + 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 = 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) +} diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index c554cc885f..a62b0b6f25 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -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::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+-"), diff --git a/crates/engine/src/cmd/text/delete.rs b/crates/engine/src/cmd/text/delete.rs index 5c692e523f..bb78661566 100644 --- a/crates/engine/src/cmd/text/delete.rs +++ b/crates/engine/src/cmd/text/delete.rs @@ -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) +} diff --git a/crates/engine/src/cmd/tools/actions.rs b/crates/engine/src/cmd/tools/actions.rs index e917aa7048..28bc721c13 100644 --- a/crates/engine/src/cmd/tools/actions.rs +++ b/crates/engine/src/cmd/tools/actions.rs @@ -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>, 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::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) +} diff --git a/crates/engine/src/cmd/tools/specs.rs b/crates/engine/src/cmd/tools/specs.rs index f9a9684d5f..fc4daab3e4 100644 --- a/crates/engine/src/cmd/tools/specs.rs +++ b/crates/engine/src/cmd/tools/specs.rs @@ -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 { }) .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 { } } } + 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; diff --git a/crates/engine/src/cmd/tools/tests.rs b/crates/engine/src/cmd/tools/tests.rs index 4683817e60..3d2de2d844 100644 --- a/crates/engine/src/cmd/tools/tests.rs +++ b/crates/engine/src/cmd/tools/tests.rs @@ -255,6 +255,59 @@ fn proofing_language_options() { assert!(s.run("review.language", &json!({"lang": "