mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-10 20:51:50 +00:00
Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
0a3fb5828e
124 files changed
+5264
-421
No files matched your search
Generated
+13
-1
@@ -1158,10 +1158,21 @@ dependencies = [
|
||||
"zip 9.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-api"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -7098,6 +7109,7 @@ dependencies = [
|
||||
name = "wordcraft-web"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"craft-web-api",
|
||||
"craft-web-host",
|
||||
"eframe",
|
||||
"egui",
|
||||
|
||||
+56
-2
@@ -6,7 +6,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** trivial (progress log: the Arabic interface's Styles gallery reads from the right (#5566)); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT heading text of another size); 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: progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; 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: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; 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 (progress log: a shape dropped just after a page break inside a paragraph stays on the new page, #4262); previously trivial (progress log: a float Line truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** trivial (progress log: a turned inline picture whose file keeps no room for the turn keeps its line to its frame); previously trivial (progress log: the Arabic interface's Styles gallery reads from the right (#5566)); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT heading text of another size); 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: progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; 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: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; 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 (progress log: a shapLine 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.
|
||||
>
|
||||
@@ -148,6 +148,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|
||||
## Progress log
|
||||
|
||||
- 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-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.
|
||||
@@ -156,6 +158,15 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
- 2026-11-05: ODT pictures offered in several formats show and survive a save (gap 19): LibreOffice's letter templates lost theirs.
|
||||
- 2026-11-07: An in-line picture lowered past its own height (`w:position`) lowers its line as far, as in Word; all 156 synthetic lines measured for #3984 match Word (#5796). Exact spacing keeps the line's baseline.
|
||||
- 2026-11-03: Search types in place: a click on the title bar's Search box, or Alt+Q, takes the typing in that box, with the results under it, as in Word, instead of a second box in a dialog (#5398).
|
||||
- 2026-10-31: List labels: a numbering level with format `none` and literal text, such as `RESPONSE:` with a one-inch hanging indent, shows that text as its label, as Word does; it used to show nothing. As in Word, a `none` level still counts and restarts the levels below it, its own number is empty (in legal numbering too), and an empty label still tabs the text to the indent (#4985). Readiness unchanged.
|
||||
- 2026-10-31: Font menus draw their entries from a few shared textures written in place, as the Equation tab's pictures are (#1002), instead of a texture per font: on Windows (DirectX 12) browsing the list showed the names on black strips in light mode and white or red ones in dark mode (#5105). Readiness unchanged.
|
||||
- 2026-11-02: A picture linked from its context menu shows its address on hover and opens it with Ctrl/⌘+click, in line or floating, as linked text does; DOCX files keep the link where Word does, on the drawing (`a:hlinkClick`), so links on pictures from Word open too (#5267). Readiness unchanged.
|
||||
- 2026-11-01: DOCX tables keep rows and cells wrapped in `mc:AlternateContent`, selecting one supported Choice or Fallback with the existing reader policy (#5219). Original synthetic checks cover namespace aliases, unsupported requirements, nested row/cell content controls and two save/reopen cycles; broader corpus and native Word qualification remain open.
|
||||
|
||||
- 2026-11-01: Shapes: a line's arrowheads are as long and as wide as Word draws them: 2, 3 or 5 times the line's width for small, medium and large (a line under 2 points as a 2-point one). Their kind, width and length are read from DOCX (DrawingML and VML) and saved back; before, every arrowhead was one size and saved as a medium triangle.
|
||||
- 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: 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.
|
||||
@@ -165,6 +176,9 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|
||||
- 2026-11-04: RTF import skips the declared Unicode fallback count even when it exceeds eight fallback units, so longer ANSI alternatives do not leak into the document text. Skipping stays bounded by the available input and ends at a group boundary. Readiness estimates unchanged.
|
||||
- 2026-11-05: Linux: a WordCraft started by an AppImage app no longer takes that app's file for its own (#5653). The AppImage runtime's `APPIMAGE`, `APPDIR`, `ARGV0` and `OWD` pass to whatever the app starts, so an installed WordCraft opened through `xdg-open` saw another program's `APPIMAGE`, and its self-update replaced that program's file. The app now starts over without those variables unless its executable is under `APPDIR`, so the update goes through the package or plain file it really is. Should starting over fail, the self-update is off in that window and Help › Check for Updates says why. Readiness estimates unchanged.
|
||||
- 2026-11-05: A Word template (`.dotx`, `.dotm`, `.dot`) opened from the file manager, the command line or the Finder starts a new, untitled document based on it, as Word's default verb for a template does (#3433). It opened the template itself, so Save (and AutoSave) wrote over it. The new document is attached to the template and Save asks where to write; a protected template asks for its password and still starts a new document. File › Open and a file dropped on the window still open a template itself, to edit it.
|
||||
- 2026-11-05: Arabic letters join across a change of formatting (gap 16, #4093). Each run was shaped on its own, so a letter at a run's edge took the form of a word's start or end: a coloured letter stood apart, and a word edited with Track Changes on read with a space in it (ال ذكاء). Each run is now shaped knowing the text shown on either side, past hidden or deleted text left out of the view, as for Syriac, Mongolian and the other joining scripts. Readiness estimates unchanged.
|
||||
- 2026-11-05: Shapes, text boxes, groups and drawing canvases in Strict Open XML documents open (#3548). Strict puts them in its one drawing namespace, where Transitional documents use Word 2010's `wps`, `wpg` and `wpc`, and Word writes a Strict text box's text in `wne:txbxContent`; the reader knew neither, so a Strict file opened with its text and no shapes at all. They now read like their Transitional twins and are saved as Transitional. Charts and SmartArt in Strict namespaces are not mapped yet.
|
||||
- 2026-10-28: Ink replay, eraser picking, lasso selection and arrow-candidate discovery reuse the paragraph prefixes they have already scanned (#3614). Page item order, stroke geometry and saved data retain their behavior. This narrows the stability work; ink import fidelity, native pen input and broader corpus qualification remain open.
|
||||
- 2026-10-29: Page drawing finds text-box warps without rescanning the paragraph for every box (#3614). Only requested offsets are indexed, and each paragraph walk stops after its last requested character. Original Linux discovery, complete display-output and CLI/MCP save/reopen controls; native Word comparison untested. Readiness estimates unchanged.
|
||||
- 2026-10-28: Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged. Existing mutation and protection checks retain their costs; this does not close the broader performance gap.
|
||||
@@ -214,14 +228,26 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
- 2026-10-31: ODT: paragraph spacing, indents and line spacing are read and written, and an ODT's own default spacing (none, single lines) replaces Word's 8 pt after and 1.15 lines, so LibreOffice documents keep their vertical rhythm on open and save (gap 19). Readiness unchanged.
|
||||
- 2026-11-01: PDF export, printing and page images show the body's SEQ, STYLEREF and REF fields with their current results, as Word fills them in when it prints or exports even with Update fields before printing off: a caption numbered 7 when it is the first shows 1 on paper, and a REF to a bookmark the document lacks prints Word's "Error! Reference source not found.". NUMWORDS, FILENAME, the document properties, formulas and locked fields keep their stored results there, as in Word; the editor and the saved document keep every stored result.
|
||||
- 2026-10-28: Print Markup HTML, Markdown and text copies discover comment anchors in one paragraph walk, so unrelated bookmark markers no longer cause repeated prefix scans (#3614). Existing quotes, replies, order and export limits are unchanged; original synthetic semantic and scaling regressions cover the traversal.
|
||||
- 2026-11-04: Table Sort refuses malformed and nonexistent explicit columns before reordering rows (#5531). Omitted column 0, valid columns, header/order/language behavior and Undo/Redo are retained. Original Linux CLI/MCP controls and repeated native/DOCX reopens cover the command; native Word and desktop comparison remain untested. Readiness estimates unchanged.
|
||||
- 2026-10-30: Layout resource caps (#3867, #4385) and linear-time passes (#3866, #4392): repeated table-header rows are capped by item counts, a clipped group counting every nested member, the wrap cache by decoded pixels and the widths a linked text box chain lays out by their lines and items; PDF export, hit testing, tracked-change and clipped-table costs, and long endnote selections no longer grow quadratically. Synthetic regression tests only; no new Word comparison.
|
||||
- 2026-11-03: Proofing (CP4.3): craft-proof moves to craft-libs main with #589 and #590. French spelling suggestions offer the elided form first (`Jadore` → `J'adore`, before `J adore`), and Valencian text (`ca-ES-valencia`) is proofed with the `ca-valencia` pack instead of plain Catalan. Readiness unchanged.
|
||||
- 2026-11-03: Security: PDF export leaves the document's author out of the PDF's properties when "Remove personal information from file properties on save" is on, as DOCX saving already does. Readiness unchanged.
|
||||
- 2026-11-03: A long comment no longer grows its card or balloon without limit (#3382). Its field in the Comments pane and in a selected balloon stops at ten lines and scrolls, keeping the caret in view; a balloon that isn't selected shows the first ten lines of the comment and of each reply, and clicking it opens the scrolling field. The buttons and the next comment stay in view.
|
||||
- 2026-11-03: Arabic dialogs: the dialogs of the mirrored interface now read right to left. Rows of a label and its field, lists of columns (Compare, AutoCorrect, Special Characters, Track Changes Options, the Columns widths), the tile galleries (chart types, SmartArt layouts) and the ribbon Paragraph group start at the right edge, through an `rtl::Table` helper that is an `egui::Grid` in English; the Add >> and << Remove arrows swap, the Columns preset captions and the Macros buttons no longer overlap, and the Recipient List grid and About › Models scroll instead of running past the left edge. Readiness unchanged.
|
||||
- 2026-11-03: Shape Format › Size: the width and height fields show the interface unit (centimetres, inches…) instead of always points, and take a size typed in any unit, such as `3 cm` or `300 px`, applied once typed (Esc cancels), each side on its own, as Picture Format › Size does (#4196). In both, a field clicked and left with Enter or Tab without typing no longer resizes the object by the rounding of the size it shows (a 100 pt shape became 100.08 pt in inches).
|
||||
- 2026-11-03: Security: Paste Special with picture files on the clipboard refuses anything that isn't a regular file (a pipe or device reports no length and can produce endless data), and `cargo xtask stats` no longer follows directory symlinks. Readiness unchanged.
|
||||
- 2026-11-03: Size keeps the side it isn't asked to change (#5469): typing only the width of a straight line keeps its height of 0 instead of making it 1 pt; the 1–4000 pt limits apply only to the sides a `picture.size` call changes. Readiness unchanged.
|
||||
- 2026-11-03: Find and Replace: a keyboard apostrophe also finds typographic single quotes, so smart-quoted typing stays searchable (#5388). Regex, wildcards, literal searches and numeric character codes remain exact. Original UTF-8 match ranges and replacement spelling are preserved; readiness unchanged.
|
||||
- 2026-11-03: Security: XLSX import finds each sheet's worksheet part through an index of the workbook's relationships, built once, instead of searching every relationship for every sheet. Readiness unchanged.
|
||||
- 2026-11-03: Layout: a row of a nested table marked Can't Split isn't split with its outer row any more. The outer row moves to the next page whole when that row comes first in it, as Word does (docxide-pdf `nested-first-row-*-cant1`), and otherwise splits above it (inferred, not yet checked in Word); on a fresh page a nested row taller than the room still splits rather than running into the margin. Readiness unchanged.
|
||||
- 2026-11-03: Layout: no line break between a full stop and an opening parenthesis. UAX #14 allows one there, Word doesn't: `29.05.2023г.(оформление ЗУ)` and `2023.(Example detail)` now wrap whole at the space before, as in the docxide-pdf `dot-open-token*` Word references. Readiness unchanged.
|
||||
- 2026-11-03: Layout: before Word 2013 layout a floating table that may not overlap another and is moved below it keeps its own left edge, as Word places it, also when its first cell has no left margin; the layout took a zero margin for Word 2013 layout and stepped the table in by its distance from the text. Readiness unchanged.
|
||||
- 2026-11-03: Layout: an empty paragraph after a document's leading floating table takes no space before, as in Word's PDFs of docxide-pdf's `leading-empty-before-float` cases (beside the table); below a full-width one it loses it too, which is untested. Readiness unchanged.
|
||||
- 2026-11-03: Security: pasting text boxes charges each copied story for its formatting too (run, mark, paragraph and section properties), not only its text and objects, so a story with huge style or font names can't copy past the paste's 32 MiB text box budget. Readiness unchanged.
|
||||
- 2026-11-03: Security: `.doc` import keeps one width, shading, alignment and merge override per table cell, the last one winning as before, instead of a copy every time a paragraph's properties repeat the sprm, so a shared PAPX can't pile up copies on every paragraph mark. Readiness unchanged.
|
||||
- 2026-11-03: Security: `.doc` import attaches each section to the paragraph that ends it through an index of paragraph ends, instead of scanning every paragraph for every section, so a file with a thousand sections that end nowhere can't multiply the work over its paragraphs. Readiness unchanged.
|
||||
- 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: 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.
|
||||
@@ -229,6 +255,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
- 2026-11-02: Language packs (CP4.4): craft-i18n's first pack release, language-packs-1, is published and WordCraft pins its index (sha256): 61 languages install from File › Options › Language, among them Lithuanian (#4707), Romanian (#3438), Catalan (#2895, #4947), French (#1325), Greek (#2561), Slovak (#3806), Serbian (#4968) and Latvian (#4438). Dutch (#4145, #2505), German and the other compounding languages wait on craft-proof engine support. Readiness unchanged.
|
||||
- 2026-11-01: Language packs (CP4.3–CP4.5): languages beyond English install their spelling dictionary and hyphenation as a pack from File › Options › Language, or from a bar a document in that language shows ("Polish proofing isn't installed. Install (4.6 MB)"), through the font store and its single download setting; About › Language data shows every licence; `proof.packs`, `proof.installPack` and `proof.removePack` for agents. Install works once language-packs-1 is published (until then with a test index); the default build then embeds English data only, and keeps today's carry-over data embedded until then (`cargo xtask language`). Lithuanian (#4707) and Romanian (#3438) are available as a Language pack once language-packs-1 is published; the Dutch, Catalan, French, Greek, Slovak, Serbian and Latvian packs (#4145, #2505, #2895, #4947, #1325, #2561, #3806, #4968, #4438) are defined and wait on craft-proof engine support (FLAG long/num, OCONV, ISO 8859-7, CIRCUMFIX, FULLSTRIP). Readiness unchanged.
|
||||
- 2026-11-01: DOCX tables keep rows and cells wrapped in `mc:AlternateContent`, selecting one supported Choice or Fallback with the existing reader policy (#5219). Original synthetic checks cover namespace aliases, unsupported requirements, nested row/cell content controls and two save/reopen cycles; broader corpus and native Word qualification remain open.- 2026-11-01: Shapes: a line's arrowheads are as long and as wide as Word draws them: 2, 3 or 5 times the line's width for small, medium and large (a line under 2 points as a 2-point one). Their kind, width and length are read from DOCX (DrawingML and VML) and saved back; before, every arrowhead was one size and saved as a medium triangle.
|
||||
- 2026-11-02: Page break before applies to a paragraph opening a continuous section mid-page in paginated layout (#5287). The original Word for Mac 16.113 probe puts each heading on a new page. At the top of a later column in such a section, the break also starts a new page; that case remains unmeasured in Word. Readiness unchanged.
|
||||
- 2026-11-01: Columns with Line between, made for selected text (a section in the middle of a page), draw their line; the line runs as tall as the section's text on the page, not the whole page (#5178). Readiness unchanged.
|
||||
- 2026-10-31: `.doc` import: text that names no font takes the stylesheet's standard font, so a Word 365 `.doc` in Aptos opens in Aptos (or its substitute) instead of Times New Roman, the font table's first entry (#5066). Readiness unchanged.
|
||||
- 2026-10-31: Tables: a row that doesn't fit at the bottom of a page no longer splits off its picture. When a cell's inline picture (one line, which is never split) or first line runs past the room left on a page that already holds something, the row moves to the next page whole instead of leaving the other cells' text beside an empty cell, so a photo row at least 193 pt tall grown to 207 pt by its picture goes on in one piece (#4272). At the top of a page such a row still splits. The reporter's 4-page file is still 5 pages: there the row has less room than its minimum and already moves on whole (#1742); the extra page now comes from the pages above taking more room than in Word.
|
||||
@@ -331,6 +358,8 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
|
||||
| Date | What landed |
|
||||
| 2026-11-04 | The CLI opens a non-Unicode input filename even after options such as `--page 2` or `--format flatopc`; text option values still require Unicode; `run --file` and `run --script` retain native paths. |
|
||||
| 2026-11-06 | Accept All and Reject All no longer restyle the paragraph after an empty one carrying a section break (#5667): a Compare that added a section, then Reject All, left `Body` in the removed paragraph's Normal style and indents. The empty paragraph now goes whole and its section joins the following one, as deleting the break does. |
|
||||
| 2026-11-07 | Deleting a preceding empty paragraph keeps the following paragraph's style, outline and mark formatting rather than promoting body text to a heading (Leaf #152). Non-empty merges and section absorption retain their existing rules; synthetic model, command, DOCX and Undo/Redo coverage. |
|
||||
| 2026-11-04 | Pictures keep the brightness, contrast and transparency their file gives them (#1711, #1766): a "Washout" picture watermark (VML `gain`/`blacklevel`) and a washed-out letterhead picture behind the text (`a:lum`) are drawn faded on screen and in PDF instead of at full colour, with the pixel curve measured on Word's own PDF export; `a:alphaModFix` transparency too. Saved back as DrawingML (and as VML `gain`/`blacklevel` in Word 2003 XML, without the transparency). Picture Format › Corrections, Transparency and Color › Washout replace the file's values (other Color modes leave them alone), and Reset Picture discards them all, as in Word; the PDF embeds the corrected pixels once per picture |
|
||||
|---|---|
|
||||
| 2026-10-17 | Copies keep their formatting in programs that read RTF rather than HTML: Copy and Cut put RTF beside the text and HTML on the system clipboard on Windows, macOS and Wayland (an X11 copy stays text and HTML, since arboard owns the selection there; a copy of only pictures or shapes carries no HTML or RTF, so pasting it back keeps every detail), and Paste reads another program's RTF on macOS and X11 too, not only on Windows and Wayland |
|
||||
@@ -345,6 +374,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
| 2026-10-29 | Insert › Shapes shows the basic shapes as a grid of code-drawn icon tiles, each with an accessible name and tooltip, instead of a text list (#4349) |
|
||||
| 2026-11-03 | A password-protected file named on the command line (double-clicked in the file manager) asks for its password when the first blank document was replaced because File › Options chose a region; the window now follows that replacement at once, so the first frame no longer takes it for a document switch and drops the Password box (#5299) |
|
||||
| 2026-11-01 | Markdown export: a list inside a block quote stays in the quote (`> - item`), its continuation lines included; it was read as quoted list paragraphs but written without `>`, so the list moved out of the quote on every save |
|
||||
| 2026-11-01 | Rulers: in a section of several text columns the horizontal ruler shades the space between each two columns like the margins (Print Layout, in the main text outside tables, with the selection in one section); dragging an edge of a space changes the spacing (equal columns stay equal, as in Word for Mac), one undo step per drag (#5197) |
|
||||
| 2026-10-29 | Window sizing falls back to the screen rectangle when the reported inner size is invalid, and an Equation symbol tooltip no longer repeats a missing glyph's name or code point |
|
||||
| 2026-10-29 | A NUL in a document title no longer reaches the native window title, which panicked on X11 (#3634): displayed titles and suggested file names drop NUL characters and fall back to the untitled name when empty; the stored title is unchanged |
|
||||
| 2026-10-28 | The Traditional Chinese interface draws hanzi to the Taiwan standard: on the desktop an installed Traditional Chinese font (PingFang TC on macOS, Microsoft JhengHei on Windows, Noto Sans CJK TC on Linux) comes before the embedded Noto Sans CJK SC, which drew 說, 這, 為, 戶 in mainland shapes; without one, and on the web, nothing changes. |
|
||||
@@ -358,6 +388,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
| 2026-10-31 | A picture or shape anchored in a line of a long paragraph on a later page is drawn on that page, as in Word, instead of the page the paragraph starts on; one dropped on such a page stays where it was dropped (#4250) |
|
||||
| 2026-10-31 | A picture or shape anchored in a line of a long paragraph on a later page is drawn on that page, as in Word, instead of the page the paragraph starts on; one dropped on such a page stays where it was dropped (#4250) |
|
||||
| 2026-10-31 | A picture or shape dropped beside the first line after a page break inside a paragraph stays on the new page, as in Word, instead of going back to the page before the break (#4262) |
|
||||
| 2026-11-07 | A brace or bracket shape in a `.doc` file is drawn without a fill unless the file gives it one, as in Word, instead of as a white box that hides the text behind it (#5943) |
|
||||
| 2026-10-27 | Connector rerouting indexes laid-out object positions once per batch, preserving page hints, first-match order and same-page glue; synthetic equivalence, geometry and scaling regressions (#3631); routing policy and readiness estimates unchanged |
|
||||
| 2026-10-28 | Markdown import bounds nested undefined reference-label work within the existing link allowance; literal source and normal reference matching are preserved (#4658). |
|
||||
| 2026-10-23 | A command that changes nothing is not an edit: it adds no undo step, marks no unsaved changes and forces no relayout, so the redo stack survives it and five hundred of them can no longer push the user's own edits out of the undo history. |
|
||||
@@ -816,9 +847,16 @@ MATH glyphs that reference the same construction now share its immutable variant
|
||||
|
||||
## Revision history
|
||||
|
||||
> 2026-11-08 · Progress log: turned inline pictures without a recorded effect extent keep their line to the frame, as in Word.
|
||||
>
|
||||
> 2026-11-05 · Linux self-update ignores another program's AppImage variables (#5653); recorded in the progress log, readiness unchanged.
|
||||
> 2026-11-05 · A template opened from the file manager starts a new document instead of being edited and saved over (#3433); recorded in the progress log, readiness unchanged.
|
||||
> 2026-11-05 · Arabic letters join across a change of formatting and past hidden or deleted text (#4093); recorded in the progress log, readiness unchanged.
|
||||
> 2026-11-05 · Shapes, text boxes, groups and canvases in Strict Open XML documents open (#3548); recorded in the progress log, readiness unchanged.
|
||||
> 2026-11-07 · Progress log: VML text boxes keep their text direction (#5193).
|
||||
>
|
||||
> 2026-11-03 · Arabic dialogs read right to left (rtl::Table); recorded in the progress log, readiness unchanged.
|
||||
> 2026-11-03 · Before Word 2013 layout a floating table moved below another keeps its own left edge, also when its first cell has no left margin; readiness unchanged.
|
||||
>
|
||||
> 2026-10-30 · Technical synchronization: retain the Proofing pane and command/selection/undo contracts on main `3b7f7ed`; new-SHA validation pending, readiness unchanged.
|
||||
>
|
||||
@@ -830,15 +868,23 @@ MATH glyphs that reference the same construction now share its immutable variant
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-08 | trivial | Progress log: XML line ends in text normalised (#5897) |
|
||||
| 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. |
|
||||
| 2026-11-07 | trivial | Progress log: An in-line picture lowered past its own height (`w:position`) lowers its line as far, as in Word; all 156 synthetic lines measured for #3984 match Word (#5796). |
|
||||
| 2026-11-03 | trivial | Progress log: the title bar's Search box takes the typing in place (#5398) |
|
||||
| 2026-10-31 | trivial | Progress log: a numFmt none list level shows its literal text (#4985); readiness unchanged |
|
||||
| 2026-10-31 | trivial | Progress log: font menu entries are drawn from shared textures (#5105) |
|
||||
| 2026-11-02 | trivial | Progress log: a linked picture shows and follows its link; DOCX `a:hlinkClick` (#5267) |
|
||||
| 2026-11-02 | trivial | Progress log: alternative format chunks and MHT open |
|
||||
| 2026-11-06 | trivial | 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 | 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) |
|
||||
| 2026-11-06 | trivial | Progress log: the printer list no longer waits on printer drivers (#5710) |
|
||||
| 2026-11-06 | trivial | Progress log: resolving away an empty paragraph with a section break keeps the next paragraph's properties, #5667; readiness 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-04 | trivial | Progress log: CLI convert format options; readiness estimates unchanged |
|
||||
| 2026-11-04 | trivial | Progress log: MHT quoted MIME parameters; readiness estimates unchanged |
|
||||
@@ -866,6 +912,7 @@ MATH glyphs that reference the same construction now share its immutable variant
|
||||
| 2026-11-04 | trivial | Progress log: ODT element audit (gap 19) |
|
||||
| 2026-11-04 | trivial | Progress log: ODT checked against 220 files from other applications (gap 19) |
|
||||
| 2026-11-04 | trivial | Progress log: OpenDocument files shown in Microsoft Word style or LibreOffice style (gap 19) |
|
||||
| 2026-11-07 | trivial | Progress log: `.doc` brace and bracket shapes unfilled by default (#5943) |
|
||||
| 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. |
|
||||
| 2026-11-01 | trivial | Progress log: SEQ, STYLEREF and REF fields show fresh results in output |
|
||||
| 2026-10-29 | trivial | Progress log: Print Markup balloon anchors use one object walk per paragraph (#3614); readiness estimates unchanged |
|
||||
@@ -878,13 +925,18 @@ MATH glyphs that reference the same construction now share its immutable variant
|
||||
| 2026-11-03 | trivial | Progress log: craft-proof pin, French elision suggestions and Valencian (CP4.3) |
|
||||
| 2026-11-03 | trivial | Progress log: a long comment's card and balloon stop at ten lines (#3382) |
|
||||
| 2026-11-03 | trivial | Progress log: Shape Format › Size in the interface unit (#4196) |
|
||||
| 2026-11-03 | trivial | Find smart-quoted text with a keyboard apostrophe (#5388); exact search modes and replacement spelling preserved. |
|
||||
| 2026-11-03 | trivial | Progress log: a picture in a paragraph whose hidden mark closes a section keeps its room (#5288) |
|
||||
| 2026-11-03 | trivial | Progress log: a nested table's Can't Split row moves on with its outer row |
|
||||
| 2026-11-03 | trivial | Progress log: no line break between a full stop and an opening parenthesis |
|
||||
| 2026-11-03 | trivial | Progress log: an empty paragraph after a document's leading floating table takes no space before |
|
||||
| 2026-11-03 | trivial | Progress log: Replace and Remove Table of Contents with Word's title above a section break (#4041) |
|
||||
| 2026-11-03 | trivial | Progress log: language-packs-2 published and pinned, 75 Language packs |
|
||||
| 2026-11-02 | trivial | Progress log: language-packs-1 published and pinned, 61 Language packs (CP4.4) |
|
||||
| 2026-11-01 | trivial | Progress log: Language packs install from File › Options › Language and the missing-pack bar once language-packs-1 is published (CP4) |
|
||||
| 2026-11-01 | trivial | Progress log: English spelling reads SCOWL 2026 (CP2) |
|
||||
| 2026-11-02 | trivial | Honor page break before at a continuous section's mid-page top (#5287); its later-column case remains unmeasured in Word. Readiness unchanged. |
|
||||
| 2026-11-01 | trivial | Progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes |
|
||||
| 2026-11-02 | trivial | Progress log: alternative format chunks and MHT open |
|
||||
| 2026-11-01 | trivial | Progress log: columns for selected text draw their line between (#5178) |
|
||||
| 2026-10-31 | trivial | Progress log: a Word 365 .doc in Aptos opens in Aptos (#5066); readiness unchanged |
|
||||
| 2026-10-31 | trivial | Progress log: a table row doesn't split off a picture that doesn't fit (#4272) |
|
||||
@@ -913,6 +965,7 @@ MATH glyphs that reference the same construction now share its immutable variant
|
||||
| 2026-10-28 | trivial | Note conversion walks reference offsets and formatting runs together, #4750; readiness estimates unchanged. |
|
||||
| 2026-10-29 | trivial | DOCX comment IDs use ordered paragraph lookup and one durable-ID reservation set; threading and ID rules unchanged |
|
||||
| 2026-10-28 | trivial | #4658: bound Markdown reference-label rejection work, preserving literal source and ordinary link matching |
|
||||
| 2026-11-01 | trivial | Progress log: the ruler shows the spaces between text columns (#5197) |
|
||||
| 2026-10-29 | trivial | Progress log: Insert › Shapes icon grid (#4349) |
|
||||
| 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-29 | trivial | Progress log: window fallback geometry and equation symbol tooltips |
|
||||
@@ -1318,3 +1371,4 @@ MATH glyphs that reference the same construction now share its immutable variant
|
||||
> 2026-10-29 · Port #3915 to split layout modules: ordinary LTR tab groups follow paragraph alignment. Positional tabs, lists, RTL and display math retain their alignment paths. Source port only; no test execution or new native measurements.
|
||||
|
||||
> 2026-10-29 · Port #2437 to split layout modules: finite border extents and terminal-break keep preflight. Recursive keep lookahead remains outside scope. Source port only; no test execution or new native measurements.
|
||||
| 2026-11-03 | trivial | Progress log: a line breaks after a hyphen before a slash (#3408) |
|
||||
@@ -17,7 +17,9 @@ path = "src/main.rs"
|
||||
wordcraft-engine = { workspace = true }
|
||||
wordcraft-ui-egui = { workspace = true }
|
||||
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
|
||||
# The page command API (storytold/craft-libs#413): window.wordcraft over the control channel.
|
||||
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! `window.wordcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the
|
||||
//! app's own control channel, the one the desktop control server drives (`docs/control-protocol.md`).
|
||||
//!
|
||||
//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`,
|
||||
//! `screenshot`, as promises with a timeout). This module is WordCraft's part: a [`Request`] that
|
||||
//! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the
|
||||
//! reply. WordCraft's control request has no abandon state, so a call the page gave up on still runs
|
||||
//! when the app reaches it, as it does for a desktop client that timed out.
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::{Receiver, TryRecvError, channel};
|
||||
|
||||
use craft_web_api::methods::Methods;
|
||||
use craft_web_api::page::{PageApi, Request};
|
||||
use serde_json::{Value, json};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use wordcraft_ui_egui::ControlRequest;
|
||||
|
||||
/// The app id in `window.<app>`.
|
||||
const APP: &str = "wordcraft";
|
||||
|
||||
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
|
||||
const POLL_MS: i32 = 16;
|
||||
|
||||
/// Put `window.wordcraft` on the page and return the queue the app drains each frame (hand it to
|
||||
/// `WordApp::with_control`). An existing `window.wordcraft` keeps the functions it already has.
|
||||
pub fn install(ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (queue, requests) = channel::<ControlRequest>();
|
||||
let request: Request = Rc::new(move |method, params| {
|
||||
let (control, reply) = ControlRequest::new(method, params);
|
||||
if queue.send(control).is_err() {
|
||||
return Box::pin(async { json!({"ok": false, "error": "the app is not running"}) });
|
||||
}
|
||||
ctx.request_repaint();
|
||||
Box::pin(wait(reply))
|
||||
});
|
||||
if let Err(e) = Rc::new(PageApi::new(APP, Methods::default(), request)).install() {
|
||||
log::warn!("couldn't install window.{APP}: {e:?}");
|
||||
}
|
||||
requests
|
||||
}
|
||||
|
||||
/// The app's reply, polled about once a frame.
|
||||
async fn wait(reply: Receiver<Value>) -> Value {
|
||||
loop {
|
||||
match reply.try_recv() {
|
||||
Ok(reply) => return reply,
|
||||
Err(TryRecvError::Empty) => sleep(POLL_MS).await,
|
||||
Err(TryRecvError::Disconnected) => return json!({"ok": false, "error": "the app dropped the request"}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn sleep(ms: i32) {
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
|
||||
}
|
||||
});
|
||||
let _ = JsFuture::from(promise).await;
|
||||
}
|
||||
@@ -20,6 +20,8 @@ mod prefs;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod save_fallback;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod api;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -76,7 +76,8 @@ pub fn start() {
|
||||
if let Some(bridge) = &app_bridge {
|
||||
bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone());
|
||||
}
|
||||
let mut app = WordApp::new(session, services);
|
||||
let control = crate::api::install(cc.egui_ctx.clone());
|
||||
let mut app = WordApp::new(session, services).with_control(control);
|
||||
// Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349).
|
||||
app.services.pasted = Some(pasted);
|
||||
app.autosave = false;
|
||||
|
||||
@@ -36,14 +36,15 @@ impl AppleEvents {
|
||||
|
||||
/// Open the documents and act on the quit requests that arrived since the last frame. A document
|
||||
/// opens like File › Open: beside the open ones, in place of an untouched blank one, or, when it
|
||||
/// is already open, by showing it. Quitting closes the window, which asks about every document
|
||||
/// is already open, by showing it; a Word template starts a new document based on it, as Word's
|
||||
/// default verb for one does (#3433). Quitting closes the window, which asks about every document
|
||||
/// with unsaved changes.
|
||||
pub fn poll(inbox: &Inbox, app: &mut WordApp, ctx: &egui::Context) {
|
||||
for e in inbox.drain() {
|
||||
match e {
|
||||
Event::Open(paths) => {
|
||||
for p in paths.iter().filter_map(|p| p.to_str()) {
|
||||
match app.open_file(p, None) {
|
||||
match app.open_from_platform(p, None) {
|
||||
// Leave Backstage, as opening from the Open dialog does.
|
||||
Ok(_) => app.ui.backstage = false,
|
||||
Err(e) => {
|
||||
|
||||
@@ -423,14 +423,15 @@ fn without_launch_args(args: Vec<String>) -> Vec<String> {
|
||||
|
||||
/// Open a file whose name isn't valid Unicode (#1440). `file.open` takes its path as text, so it
|
||||
/// gets the file's bytes under the lossy name, and the document then keeps the real path to save to.
|
||||
/// The bytes are read as `file.open` reads a path: only a regular file, and at most 2 GB.
|
||||
/// The bytes are read as `file.open` reads a path: only a regular file, and at most 2 GB. A Word
|
||||
/// template starts a new document instead ([`WordApp::open_from_platform`]), which has no path.
|
||||
fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
|
||||
let name = path.to_string_lossy();
|
||||
let opened = wordcraft_engine::io::read_document(path)
|
||||
.and_then(|bytes| app.run("file.open", serde_json::json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})));
|
||||
.and_then(|bytes| app.open_from_platform(&name, Some(&wordcraft_engine::cmd::insert::base64_encode(&bytes))));
|
||||
match opened {
|
||||
// Not when it waits for a password: that open names the document itself.
|
||||
Ok(r) if r.get("pending").is_none() => app.session.path = Some(path.into()),
|
||||
Ok(r) if r.get("pending").is_none() && !wordcraft_ui_egui::documents::is_word_template(&name) => app.session.path = Some(path.into()),
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
app.report_open_failure(&name, &e);
|
||||
@@ -624,8 +625,9 @@ fn main() -> eframe::Result {
|
||||
if files.is_empty() && other_files.is_empty() && !sample && control_port.is_none() {
|
||||
app.start_screen();
|
||||
}
|
||||
// A Word template named here starts a new document based on it (#3433).
|
||||
for f in files {
|
||||
if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) {
|
||||
if let Err(e) = app.open_from_platform(&f, None) {
|
||||
app.report_open_failure(&f, &e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.SenuraMalaka]
|
||||
real_name = "Senura Dissanayake"
|
||||
sync_github_name = true
|
||||
|
||||
[people.divyanshu2074]
|
||||
real_name = "Divyanshu Gupta"
|
||||
sync_github_name = true
|
||||
@@ -197,3 +201,6 @@ display_name = "entropybeater"
|
||||
|
||||
[people.helenkwok]
|
||||
real_name = "Helen Kwok"
|
||||
|
||||
[people.ashokram-si]
|
||||
real_name = "Ashok Ram"
|
||||
@@ -772,6 +772,10 @@ impl Document {
|
||||
if a.path.parent() == b.path.parent() {
|
||||
// Same container: trim the ends, drop the blocks between, join.
|
||||
let (ia, ib) = (a.path.last(), b.path.last());
|
||||
// Removing an already-empty paragraph must not impose its style or mark formatting
|
||||
// on the following content. Test before trimming: deleting actual heading text is
|
||||
// still an ordinary merge that keeps the first paragraph's properties.
|
||||
let empty_start = self.para(a.story, &a.path).is_some_and(Paragraph::is_empty);
|
||||
// Kept markers of the paragraphs in between move to the join.
|
||||
let mut moved: Vec<(InlineObject, CharProps)> = Vec::new();
|
||||
// `kept` is sorted: one walk of each paragraph's objects and runs (#3614).
|
||||
@@ -819,15 +823,18 @@ impl Document {
|
||||
if ib < bl.len() && ia < ib {
|
||||
bl.drain(ia + 1..=ib);
|
||||
}
|
||||
tail.props = ParaProps::default();
|
||||
// The deleted marks include `a`'s, so a section break there goes too: the joined
|
||||
// paragraph ends where `b`'s mark did, and its text takes the following section's
|
||||
// setup, as in Word (#2127).
|
||||
let sect = tail.section.take();
|
||||
let pa = self.para_mut(a.story, &a.path)?;
|
||||
let keep_props = pa.props.clone();
|
||||
let keep_props = if empty_start { tail.props.clone() } else { pa.props.clone() };
|
||||
let keep_mark = empty_start.then(|| tail.mark.clone());
|
||||
pa.append(tail);
|
||||
pa.props = keep_props;
|
||||
if let Some(mark) = keep_mark {
|
||||
pa.mark = mark;
|
||||
}
|
||||
let before = std::mem::replace(&mut pa.section, sect);
|
||||
if a.story == crate::StoryRef::Body && a.path.0.len() == 1 {
|
||||
// Nearest first, as "link to previous" resolved them before the delete.
|
||||
|
||||
@@ -228,7 +228,7 @@ impl Numbering {
|
||||
/// Whether a paragraph in list `num` at `level` gets a label ([`Counters::next_label`] gives
|
||||
/// one), without counting it.
|
||||
pub fn shows_label(&self, num: u32, level: u8) -> bool {
|
||||
self.abstract_of(num).is_some() && self.level(num, level.min(8)).is_some_and(|d| d.format != NumFormat::None)
|
||||
self.abstract_of(num).is_some() && self.level(num, level.min(8)).is_some()
|
||||
}
|
||||
pub fn level(&self, num: u32, level: u8) -> Option<&Level> {
|
||||
if let Some((_, l)) = self.num(num)?.level_overrides.iter().find(|(i, _)| *i == level) {
|
||||
@@ -325,12 +325,9 @@ impl Counters {
|
||||
let lv = (level as usize).min(8);
|
||||
// This list's own definition of a level wins over its abstract list's.
|
||||
let level_def = |k: usize| numbering.level(num, k as u8);
|
||||
// A "none" level (Word's numFmt none) still counts and restarts the levels below it; only
|
||||
// its number reads as nothing, so its literal text ("RESPONSE:") shows (#4985).
|
||||
let def = level_def(lv)?.clone();
|
||||
// A "none" level shows no label at all (Word's numFmt none) — not even its text.
|
||||
if def.format == NumFormat::None {
|
||||
self.labels.insert((abs.id, lv as u8), CountedLabel { label: None, levels: Vec::new() });
|
||||
return None;
|
||||
}
|
||||
let st = self.state.entry(abs.id).or_insert([0; 9]);
|
||||
let fl = self.flags.entry(abs.id).or_default();
|
||||
// Only this level's override, not the num's others: the counters are shared by every
|
||||
@@ -382,7 +379,14 @@ impl Counters {
|
||||
let started = *fl;
|
||||
let numeric = def.format != NumFormat::Bullet;
|
||||
let label = if numeric { render_label(numbering, num, &def, &vals, &started) } else { def.text.clone() };
|
||||
self.labels.insert((abs.id, lv as u8), CountedLabel { label: numeric.then(|| label.clone()), levels: template_levels(&def.text) });
|
||||
// A "none" level shows its literal text, but has no number of its own for a reference to
|
||||
// show (#4985): it is kept as one that shows none.
|
||||
let counted = if def.format == NumFormat::None {
|
||||
CountedLabel { label: None, levels: Vec::new() }
|
||||
} else {
|
||||
CountedLabel { label: numeric.then(|| label.clone()), levels: template_levels(&def.text) }
|
||||
};
|
||||
self.labels.insert((abs.id, lv as u8), counted);
|
||||
Some((label, def))
|
||||
}
|
||||
/// Which levels of `num`'s list have counted a paragraph yet (a level never used has no
|
||||
@@ -455,9 +459,9 @@ fn render_label(numbering: &Numbering, num: u32, def: &Level, vals: &[u32; 9], s
|
||||
let counted = started.get(k).is_some_and(|f| f.started);
|
||||
let val = if counted { val } else { level_def(k).map(|l| l.start).unwrap_or(1) };
|
||||
// Legal numbering (w:isLgl) shows every level in Arabic digits, whatever its own
|
||||
// format (二 of a Chinese outline becomes 2 in "2.1").
|
||||
// format (二 of a Chinese outline becomes 2 in "2.1"); a "none" level stays empty.
|
||||
let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) {
|
||||
NumFormat::DecimalZero => NumFormat::DecimalZero,
|
||||
f @ (NumFormat::DecimalZero | NumFormat::None) => f,
|
||||
_ if def.legal => NumFormat::Decimal,
|
||||
f => f,
|
||||
};
|
||||
@@ -556,6 +560,27 @@ mod tests {
|
||||
assert_eq!(n.level(id, 2).map(|l| l.format), Some(NumFormat::DecimalZero));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_none_level_shows_its_text_and_still_counts() {
|
||||
// As Word shows them (#4985): a "none" level's literal text is its label, its own number
|
||||
// reads as nothing (also in legal numbering), and it restarts the levels below it.
|
||||
let mut n = Numbering::default();
|
||||
let id = n.add_list(ListKind::Numbered);
|
||||
let lvl = |format, text: &str| Level { format, text: text.into(), ..Level::default() };
|
||||
let legal = Level { legal: true, ..lvl(NumFormat::Decimal, "%2.%3") };
|
||||
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
|
||||
num.level_overrides = vec![(0, lvl(NumFormat::None, "RESPONSE:")), (1, lvl(NumFormat::None, "[%2]")), (2, legal)];
|
||||
}
|
||||
assert!(n.shows_label(id, 0));
|
||||
let mut c = Counters::default();
|
||||
assert_eq!(c.next_label(&n, id, 0).unwrap().0, "RESPONSE:");
|
||||
assert_eq!(c.next_label(&n, id, 1).unwrap().0, "[]");
|
||||
assert_eq!(c.next_label(&n, id, 2).unwrap().0, ".1");
|
||||
assert_eq!(c.next_label(&n, id, 2).unwrap().0, ".2");
|
||||
assert_eq!(c.next_label(&n, id, 1).unwrap().0, "[]");
|
||||
assert_eq!(c.next_label(&n, id, 2).unwrap().0, ".1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_start_override_restarts_only_its_own_level_of_a_shared_list() {
|
||||
// One abstract list numbers the headings through two nums: one for levels 1-4, and one
|
||||
|
||||
@@ -186,6 +186,11 @@ pub struct Float {
|
||||
/// top. 0 = unknown, drawn in document order (objects inserted here, or from other formats).
|
||||
#[serde(skip_serializing_if = "is_zero_u32")]
|
||||
pub z: u32,
|
||||
/// Turned, but the file keeps no room for the turn (`wp:effectExtent` missing or zero, as
|
||||
/// files from other tools have): Word then keeps an inline one's line to its frame and lets
|
||||
/// the turned object overhang it (see [`Float::line_pad`]). Cleared by [`Float::set_spin`].
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub bare_turn: bool,
|
||||
}
|
||||
|
||||
/// Longest alternative text kept (characters): hostile files and commands are capped.
|
||||
@@ -241,6 +246,7 @@ impl Float {
|
||||
self.rot = wordcraft_geom::normalize_degrees(s.deg);
|
||||
self.flip_h = s.flip_h;
|
||||
self.flip_v = s.flip_v;
|
||||
self.bare_turn = false;
|
||||
}
|
||||
/// How far a `w` × `h` frame's rotated bounds reach past it on each side (x, y): the room a
|
||||
/// rotated object takes beyond its frame (zero unrotated; negative where the turned object
|
||||
@@ -250,6 +256,23 @@ impl Float {
|
||||
let (bw, bh) = self.spin().extent(w, h);
|
||||
((bw - w) / 2.0, (bh - h) / 2.0)
|
||||
}
|
||||
/// A [`Float::bare_turn`] still inline and without effects room: made floating, or given room
|
||||
/// for a shadow or glow since, it saves, and so lays out, with the turn's room again.
|
||||
pub fn is_bare_turn(&self) -> bool {
|
||||
self.bare_turn && self.wrap == Wrap::Inline && self.effect_extent() == [0.0; 4]
|
||||
}
|
||||
/// The room an inline `w` × `h` object's line keeps past its frame on each side (x, y) for its
|
||||
/// turn: its rotated bounds ([`Float::spin_pad`]) — or, for a [`Float::is_bare_turn`], none, except
|
||||
/// that Word 2013+ layout (`modern`) exchanges the frame's width and height from 45° up to 135°
|
||||
/// and from 225° up to 315°.
|
||||
pub fn line_pad(&self, w: f32, h: f32, modern: bool) -> (f32, f32) {
|
||||
if !self.is_bare_turn() {
|
||||
return self.spin_pad(w, h);
|
||||
}
|
||||
let (w, h) = (wordcraft_geom::finite(w).clamp(0.0, 4000.0), wordcraft_geom::finite(h).clamp(0.0, 4000.0));
|
||||
let deg = wordcraft_geom::normalize_degrees(self.rot);
|
||||
if modern && ((45.0..135.0).contains(°) || (225.0..315.0).contains(°)) { ((h - w) / 2.0, (w - h) / 2.0) } else { (0.0, 0.0) }
|
||||
}
|
||||
/// The effect extents, finite and clamped (left, top, right, bottom).
|
||||
pub fn effect_extent(&self) -> [f32; 4] {
|
||||
self.effect.map(|v| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 })
|
||||
|
||||
@@ -27,7 +27,8 @@ pub use char_props::{
|
||||
};
|
||||
pub use color::{Rgb, TextColor};
|
||||
pub use para_props::{
|
||||
Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextAlignment, TextFrame,
|
||||
Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, ParagraphTextDirection, TAB_POS_EPS, TabAlign, TabLeader, TabStop,
|
||||
TextAlignment, TextFrame,
|
||||
};
|
||||
pub use table_props::{
|
||||
CellPicture, CellProps, HeightRule, PictureRepeat, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge,
|
||||
|
||||
@@ -26,6 +26,67 @@ impl Align {
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph-level text flow (`w:pPr/w:textDirection`, ECMA-376 §17.3.1.41).
|
||||
///
|
||||
/// Kept separate from [`super::TextDirection`], whose three values model the table-cell direction
|
||||
/// control. These values preserve the exact OOXML direction through JSON and DOCX saves.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ParagraphTextDirection {
|
||||
LrTb,
|
||||
LrTbV,
|
||||
Tb,
|
||||
TbRl,
|
||||
TbRlV,
|
||||
TbLr,
|
||||
TbLrV,
|
||||
BtLr,
|
||||
TbV,
|
||||
Rl,
|
||||
RlV,
|
||||
Lr,
|
||||
LrV,
|
||||
}
|
||||
|
||||
impl ParagraphTextDirection {
|
||||
pub fn from_ooxml(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"lrTb" => Some(Self::LrTb),
|
||||
"lrTbV" => Some(Self::LrTbV),
|
||||
"tb" => Some(Self::Tb),
|
||||
"tbRl" => Some(Self::TbRl),
|
||||
"tbRlV" => Some(Self::TbRlV),
|
||||
"tbLr" => Some(Self::TbLr),
|
||||
"tbLrV" => Some(Self::TbLrV),
|
||||
"btLr" => Some(Self::BtLr),
|
||||
"tbV" => Some(Self::TbV),
|
||||
"rl" => Some(Self::Rl),
|
||||
"rlV" => Some(Self::RlV),
|
||||
"lr" => Some(Self::Lr),
|
||||
"lrV" => Some(Self::LrV),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
Self::LrTb => "lrTb",
|
||||
Self::LrTbV => "lrTbV",
|
||||
Self::Tb => "tb",
|
||||
Self::TbRl => "tbRl",
|
||||
Self::TbRlV => "tbRlV",
|
||||
Self::TbLr => "tbLr",
|
||||
Self::TbLrV => "tbLrV",
|
||||
Self::BtLr => "btLr",
|
||||
Self::TbV => "tbV",
|
||||
Self::Rl => "rl",
|
||||
Self::RlV => "rlV",
|
||||
Self::Lr => "lr",
|
||||
Self::LrV => "lrV",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vertical alignment of characters of different sizes on a line (`w:textAlignment`,
|
||||
/// ECMA-376 §17.3.1.39).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
@@ -391,6 +452,9 @@ pub struct ParaProps {
|
||||
pub suppress_line_numbers: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bidi: Option<bool>,
|
||||
/// Paragraph text direction (`w:textDirection`); `None` inherits from the paragraph style.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text_direction: Option<ParagraphTextDirection>,
|
||||
/// Drop cap: lines to drop (0 = none) — applies to the first character(s).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub drop_cap: Option<u8>,
|
||||
@@ -462,6 +526,7 @@ impl ParaProps {
|
||||
}
|
||||
|
||||
fn overlay_script_layout(&mut self, patch: &ParaProps) {
|
||||
overlay_fields!(self, patch; bidi, text_direction, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn);
|
||||
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn, text_alignment);
|
||||
}
|
||||
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
|
||||
|
||||
@@ -137,9 +137,12 @@ impl ResolvedChar {
|
||||
pub fn uses_complex(&self, c: char) -> bool {
|
||||
self.rtl || self.cs || crate::bidi::is_complex_script(c)
|
||||
}
|
||||
/// The size glyphs are drawn at (super/subscript shrink to ~2/3).
|
||||
/// The size glyphs are drawn and measured at. Word sets superscript and subscript text at
|
||||
/// 0.65 of the run's size rounded to the half point, a font size the format can store
|
||||
/// (measured in Word for Mac from digit advances: 8 pt → 5, 9 → 6, 11 → 7, 12 → 8,
|
||||
/// 16 → 10.5, 24 → 15.5, 36 → 23.5).
|
||||
pub fn draw_size(&self) -> f32 {
|
||||
if self.vert_align == VertAlign::Baseline { self.size } else { self.size * 0.65 }
|
||||
if self.vert_align == VertAlign::Baseline { self.size } else { (self.size * 0.65 * 2.0).round() / 2.0 }
|
||||
}
|
||||
/// Baseline shift, points (positive = up).
|
||||
pub fn baseline_shift(&self) -> f32 {
|
||||
@@ -453,6 +456,32 @@ fn diff_patch<T: Serialize + serde::de::DeserializeOwned + Default>(with: &T, wi
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Superscript and subscript sizes as Word for Mac sets them (measured from digit advances in
|
||||
/// its PDF export): 0.65 of the size, rounded to the half point.
|
||||
#[test]
|
||||
fn raised_and_lowered_text_is_sized_to_the_half_point() {
|
||||
for (size, want) in [
|
||||
(8.0, 5.0),
|
||||
(9.0, 6.0),
|
||||
(10.0, 6.5),
|
||||
(11.0, 7.0),
|
||||
(12.0, 8.0),
|
||||
(13.0, 8.5),
|
||||
(14.0, 9.0),
|
||||
(16.0, 10.5),
|
||||
(20.0, 13.0),
|
||||
(24.0, 15.5),
|
||||
(36.0, 23.5),
|
||||
] {
|
||||
for vert_align in [VertAlign::Superscript, VertAlign::Subscript] {
|
||||
let rc = ResolvedChar { size, vert_align, ..StyleSheet::builtin().resolve_char(None, &CharProps::default()) };
|
||||
assert_eq!(rc.draw_size(), want, "{size} pt {vert_align:?}");
|
||||
}
|
||||
}
|
||||
let rc = StyleSheet::builtin().resolve_char(None, &CharProps { size: Some(11.0), ..Default::default() });
|
||||
assert_eq!(rc.draw_size(), 11.0, "baseline text keeps its size");
|
||||
}
|
||||
|
||||
/// #2036: only a character itself (direct formatting) has a symbol font of its own; a
|
||||
/// style never gives one to its text.
|
||||
#[test]
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
//!
|
||||
//! Tables are indexed by code − 0x20; 0 = no Unicode equivalent.
|
||||
|
||||
/// The symbol fonts this module knows, by lowercase family name.
|
||||
fn table(family: &str) -> Option<&'static [u32; 224]> {
|
||||
/// The symbol table for a single family name, by lowercase family name.
|
||||
fn single_table(family: &str) -> Option<&'static [u32; 224]> {
|
||||
let f = family.trim();
|
||||
if f.eq_ignore_ascii_case("symbol") || f.eq_ignore_ascii_case("symbol (pcl6)") {
|
||||
Some(&SYMBOL)
|
||||
@@ -37,6 +37,11 @@ fn table(family: &str) -> Option<&'static [u32; 224]> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The symbol fonts this module knows, by lowercase family name.
|
||||
fn table(family: &str) -> Option<&'static [u32; 224]> {
|
||||
if family.contains(';') { family.split(';').map(str::trim).filter(|s| !s.is_empty()).find_map(single_table) } else { single_table(family) }
|
||||
}
|
||||
|
||||
/// Whether `family` is one of Word's symbol fonts (Symbol, Wingdings, Wingdings 2, Wingdings 3,
|
||||
/// Webdings), whose characters [`symbol_char`] maps.
|
||||
pub fn is_symbol_font(family: &str) -> bool {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::props::{Align, CharProps, ParaProps};
|
||||
use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, StoryRef, Table, para_block};
|
||||
|
||||
/// Build an empty heading followed by explicitly formatted, mixed-run body text.
|
||||
fn fixture(first: &str, style: Option<&str>) -> Document {
|
||||
let mut d = Document::from_text(&format!("{first}\nBody paragraph."));
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.props.style = Some("Heading1".into());
|
||||
p.props.outline_level = Some(0);
|
||||
p.mark = CharProps { size: Some(40.0), bold: Some(true), ..Default::default() };
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
p.props = ParaProps {
|
||||
style: style.map(str::to_string),
|
||||
align: Some(Align::Right),
|
||||
indent_left: Some(18.0),
|
||||
space_after: Some(7.0),
|
||||
keep_next: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
p.mark = CharProps { size: Some(11.0), bold: Some(false), lang: Some("en-GB".into()), ..Default::default() };
|
||||
p.format(0, 4, &|c| c.italic = Some(true)).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// Compare paragraph, mark and inline-run formatting independently of text.
|
||||
fn assert_format(p: &Paragraph, expected: &Paragraph) {
|
||||
assert_eq!(p.props, expected.props, "the following paragraph keeps all its properties");
|
||||
assert_eq!(p.mark, expected.mark, "the empty heading's mark must not replace the following mark");
|
||||
assert_eq!(serde_json::to_value(&p.runs).unwrap(), serde_json::to_value(&expected.runs).unwrap());
|
||||
}
|
||||
|
||||
/// Deleting an empty paragraph preserves the following style, including implicit Normal.
|
||||
#[test]
|
||||
fn deleting_an_empty_paragraph_keeps_the_following_format() {
|
||||
for first_style in ["Heading1", "Heading3", "Normal"] {
|
||||
for style in [None, Some("Normal"), Some("Heading2")] {
|
||||
let mut d = fixture("", style);
|
||||
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props.style = Some(first_style.into());
|
||||
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
|
||||
assert_eq!(d.delete_range(&Pos::body(0, 0), &Pos::body(1, 0)).unwrap(), Pos::body(0, 0));
|
||||
let p = d.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.text, expected.text);
|
||||
assert_format(p, &expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A selection spanning several paragraphs keeps the surviving tail format in both directions.
|
||||
#[test]
|
||||
fn an_empty_start_preserves_the_last_paragraph_over_a_multi_paragraph_selection() {
|
||||
for backwards in [false, true] {
|
||||
let mut d = fixture("", None);
|
||||
d.body.insert(1, para_block(Paragraph::with_text("Removed middle paragraph", Default::default()).styled("Heading2")));
|
||||
let mut expected = d.para_at(&Pos::body(2, 0)).unwrap().clone();
|
||||
expected.delete(0, 5).unwrap();
|
||||
let (a, b) = (Pos::body(0, 0), Pos::body(2, 5));
|
||||
d.delete_range(if backwards { &b } else { &a }, if backwards { &a } else { &b }).unwrap();
|
||||
assert_eq!(d.body.len(), 1);
|
||||
let p = d.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.text, "paragraph.");
|
||||
assert_format(p, &expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// A control whose closing marker survives must move across the join without replacing tail formatting.
|
||||
#[test]
|
||||
fn an_empty_start_keeps_a_control_from_a_deleted_middle_paragraph() {
|
||||
use wordcraft_doc::control::ContentControl;
|
||||
use wordcraft_doc::{InlineObject, Revision, RevisionKind};
|
||||
|
||||
let mut d = fixture("", Some("Normal"));
|
||||
d.body.insert(1, para_block(Paragraph::with_text("Removed", Default::default())));
|
||||
d.body.push(para_block(Paragraph::with_text("After", Default::default())));
|
||||
d.revisions.push(Revision { kind: RevisionKind::Insert, ..Default::default() });
|
||||
d.revisions.push(Revision { kind: RevisionKind::Delete, ..Default::default() });
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(2)).unwrap();
|
||||
p.mark.ins = Some(0);
|
||||
p.mark.del = Some(1);
|
||||
let expected = p.clone();
|
||||
let marker = CharProps { bold: Some(true), ins: Some(0), ..Default::default() };
|
||||
d.insert_object(&Pos::body(1, 0), InlineObject::ControlStart { control: Box::new(ContentControl::default()) }, &marker).unwrap();
|
||||
d.insert_object(&Pos::body(3, 5), InlineObject::ControlEnd, &Default::default()).unwrap();
|
||||
assert_eq!(d.control_ranges(StoryRef::Body).len(), 1);
|
||||
|
||||
d.delete_range(&Pos::body(0, 0), &Pos::body(2, 0)).unwrap();
|
||||
let p = d.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.props, expected.props);
|
||||
assert_eq!(p.mark, expected.mark, "tail revision IDs and formatting survive the moved marker");
|
||||
assert_eq!(p.props_of_char(0), &marker);
|
||||
assert!(matches!(p.objects.as_slice(), [InlineObject::ControlStart { .. }]));
|
||||
assert_eq!(p.text, format!("\u{fffc}{}", expected.text));
|
||||
let ranges = d.control_ranges(StoryRef::Body);
|
||||
assert_eq!(ranges.len(), 1);
|
||||
assert_eq!(ranges[0].start, Pos::body(0, 0));
|
||||
assert_eq!(ranges[0].end, Pos::body(1, 5));
|
||||
assert!(!d.has_unbalanced_controls());
|
||||
}
|
||||
|
||||
/// The empty-paragraph rule applies in header stories and table-cell containers.
|
||||
#[test]
|
||||
fn empty_paragraph_merges_work_in_a_header_and_a_table_cell() {
|
||||
for cell in [false, true] {
|
||||
let mut d = fixture("", Some("Normal"));
|
||||
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
|
||||
let (story, a, b) = if cell {
|
||||
let mut table = Table::new(1, 1, 300.0);
|
||||
table.rows[0].cells[0].blocks = d.body.clone();
|
||||
d.body = vec![Arc::new(Block::Table(table)), para_block(Paragraph::new())];
|
||||
(StoryRef::Body, Path(vec![0, 0, 0, 0]), Path(vec![0, 0, 0, 1]))
|
||||
} else {
|
||||
let id = d.add_part(wordcraft_doc::PartKind::Header, d.body.clone());
|
||||
(StoryRef::Part(id), Path::top(0), Path::top(1))
|
||||
};
|
||||
d.delete_range(&Pos { story, path: a.clone(), off: 0 }, &Pos { story, path: b, off: 0 }).unwrap();
|
||||
assert_format(d.para(story, &a).unwrap(), &expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// Absorbing an empty section keeps the following format and inherited header.
|
||||
#[test]
|
||||
fn deleting_an_empty_section_paragraph_keeps_format_and_linked_headers() {
|
||||
let mut d = fixture("", None);
|
||||
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
|
||||
let header = d.add_part(wordcraft_doc::PartKind::Header, vec![para_block(Paragraph::with_text("Header", Default::default()))]);
|
||||
let mut section = d.last_section.clone();
|
||||
section.headers.default = Some(header);
|
||||
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(section));
|
||||
d.last_section.margin_left = 96.0;
|
||||
d.delete_range(&Pos::body(0, 0), &Pos::body(1, 0)).unwrap();
|
||||
assert_format(d.para_at(&Pos::body(0, 0)).unwrap(), &expected);
|
||||
assert!(d.para_at(&Pos::body(0, 0)).unwrap().section.is_none());
|
||||
assert_eq!(d.last_section.headers.default, Some(header));
|
||||
assert_eq!(d.last_section.margin_left, 96.0);
|
||||
}
|
||||
|
||||
/// Deleting actual text continues to use the first paragraph merge rule.
|
||||
#[test]
|
||||
fn joining_nonempty_paragraphs_keeps_the_existing_first_paragraph_rule() {
|
||||
for off in [0, 7] {
|
||||
let mut d = fixture("Heading", None);
|
||||
let expected = d.para_at(&Pos::body(0, 0)).unwrap().props.clone();
|
||||
d.delete_range(&Pos::body(0, off), &Pos::body(1, 0)).unwrap();
|
||||
assert_eq!(d.para_at(&Pos::body(0, 0)).unwrap().props, expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// Removing a soft line break must not change paragraph formatting.
|
||||
#[test]
|
||||
fn a_soft_break_is_not_an_empty_paragraph_merge() {
|
||||
let mut d = fixture("", None);
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.insert_text(0, "\nHeading", &Default::default()).unwrap();
|
||||
let expected = p.props.clone();
|
||||
d.delete_range(&Pos::body(0, 0), &Pos::body(0, 1)).unwrap();
|
||||
assert_eq!(d.body.len(), 2);
|
||||
assert_eq!(d.para_at(&Pos::body(0, 0)).unwrap().props, expected);
|
||||
}
|
||||
@@ -211,7 +211,11 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -
|
||||
kind = ShapeKind::TextBox;
|
||||
}
|
||||
let line = kind == ShapeKind::Line;
|
||||
let fill = if line || p.filled == Some(false) { None } else { Some(Fill::solid(p.fill_color.unwrap_or(Rgb::WHITE))) };
|
||||
// Brackets and braces (85 to 88, bracket and brace pairs 185, 186) are unfilled unless
|
||||
// `fFilled` says otherwise, as Word's VML shapetypes for them say (`filled="f"`). They are
|
||||
// drawn as their box here, which a default fill would make an opaque block over the text.
|
||||
let unfilled = p.filled == Some(false) || (matches!(sp.spt, 85..=88 | 185 | 186) && p.filled != Some(true));
|
||||
let fill = if line || unfilled { None } else { Some(Fill::solid(p.fill_color.unwrap_or(Rgb::WHITE))) };
|
||||
let stroke = if p.line == Some(false) { None } else { Some(p.line_color.unwrap_or(Rgb::BLACK)) };
|
||||
// Line widths are EMUs; Word's default is 0.75 pt.
|
||||
let stroke_width = p.line_width.map_or(0.75, |e| (e as f32 / 12_700.0).clamp(0.0, 100.0));
|
||||
|
||||
@@ -260,6 +260,9 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
};
|
||||
|
||||
let mut doc = Document::new();
|
||||
// Word opens Word 97-2003 files in compatibility mode 11 (Word 2003); the model's default
|
||||
// 15 would apply Word 2013+ justification and line-breaking rules to them.
|
||||
doc.settings.compat_mode = 11;
|
||||
if let Some(core) = metadata::read(&mut comp) {
|
||||
doc.core = core;
|
||||
}
|
||||
|
||||
@@ -337,6 +337,15 @@ fn empty_document_gets_a_paragraph() {
|
||||
assert_eq!(doc.body.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_doc_opens_in_word_97_2003_compatibility_mode() {
|
||||
let f = text_doc(&[Piece { compressed: false, text: "body\r".into() }], &[], 0);
|
||||
let doc = read(&f).expect("opens");
|
||||
// Word 97-2003 files are mode 11 in Word 2013+; mode 15 would change justification and
|
||||
// line breaking.
|
||||
assert_eq!(doc.settings.compat_mode, 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_text_stays_out_of_body() {
|
||||
let f = text_doc(
|
||||
|
||||
@@ -183,6 +183,41 @@ fn shapes_keep_kind_colours_wrap_and_text_box_text() {
|
||||
assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn braces_and_brackets_are_unfilled_unless_filled_is_set() {
|
||||
// A right brace (88) in front of the text with no fill properties, and a left bracket (85)
|
||||
// whose fFilled is set: only the bracket is filled. A plain rectangle (1) keeps its default
|
||||
// white fill.
|
||||
let brace = sp(&[fsp(88, 1026, 0xA00)]);
|
||||
let bracket = sp(&[fsp(85, 1027, 0xA00), fopt(&[(0x01BF, (1 << 20) | (1 << 4))], &[])]);
|
||||
let rect = sp(&[fsp(1, 1028, 0xA00)]);
|
||||
let art = art_content(&[], &[brace, bracket, rect]);
|
||||
let flags = spa_flags(0, 0, 3, false);
|
||||
let spas =
|
||||
plc(&[1, 2, 3, 5], &[spa(1026, [0, 0, 240, 1140], flags), spa(1027, [0, 0, 240, 1140], flags), spa(1028, [0, 0, 240, 1140], flags)].concat());
|
||||
let text = "A\u{8}\u{8}\u{8}B\r";
|
||||
let ccp = text.chars().count() as u32;
|
||||
let spec = StructSpec { text, extra_pairs: vec![(40, spas), (50, art)], ..structured_spec(text, ccp) };
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
|
||||
let fills: Vec<_> = p0
|
||||
.objects
|
||||
.iter()
|
||||
.map(|o| match o {
|
||||
InlineObject::Shape { fill, stroke, w, h, .. } => (fill.clone(), *stroke, *w, *h),
|
||||
other => panic!("{other:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
fills,
|
||||
[
|
||||
(None, Some(Rgb::BLACK), 12.0, 57.0),
|
||||
(Some(Fill::solid(Rgb::WHITE)), Some(Rgb::BLACK), 12.0, 57.0),
|
||||
(Some(Fill::solid(Rgb::WHITE)), Some(Rgb::BLACK), 12.0, 57.0),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_boxes_pointing_at_the_same_story_text_read_it_once() {
|
||||
// Two text boxes whose PlcftxbxTxt stories are both [0, 7) (the table steps back to CP 0
|
||||
|
||||
@@ -55,6 +55,8 @@ impl Reader<'_> {
|
||||
.children("w:font")
|
||||
.filter(|f| f.child("w:family").and_then(|v| v.attr("w:val")) == Some("roman"))
|
||||
.filter_map(|f| f.attr("w:name"))
|
||||
.flat_map(|n| n.split(';'))
|
||||
.map(str::trim)
|
||||
.filter(|n| !n.is_empty() && n.len() <= MAX_FONT_NAME)
|
||||
.take(MAX_TABLE_FONTS)
|
||||
.map(str::to_string)
|
||||
|
||||
@@ -5,14 +5,15 @@ use std::collections::HashMap;
|
||||
use wordcraft_doc::para::cap_alt;
|
||||
use wordcraft_doc::props::{
|
||||
Align, Border, BorderStyle, Borders, CellProps, CharProps, CombineBrackets, EastAsianLayout, HeightRule, Highlight, LineSpacing, NumRef,
|
||||
ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor, TextDirection, Underline,
|
||||
VAlign, VMerge, VertAlign,
|
||||
ParaProps, ParagraphTextDirection, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor,
|
||||
TextDirection, Underline, VAlign, VMerge, VertAlign,
|
||||
};
|
||||
use wordcraft_doc::section::{
|
||||
Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, NotePos, NoteProps, NoteRestart, NumFormat,
|
||||
PageVAlign, SectionProps, SectionStart,
|
||||
};
|
||||
|
||||
use super::MAX_FONT_NAME;
|
||||
use super::drawing_color::adjust_lum;
|
||||
use crate::units::{int, line_multiple, measure, on_off, tw, u32_of};
|
||||
|
||||
@@ -145,6 +146,29 @@ pub(crate) fn resolve_level_only(sheet: &wordcraft_doc::styles::StyleSheet, p: &
|
||||
}
|
||||
}
|
||||
|
||||
/// A `w:rFonts` value as the document keeps it: `None` when it names no family, else its leading
|
||||
/// families up to [`MAX_FONT_NAME`] bytes, cut at a `;` so no name is split. A LibreOffice file
|
||||
/// names its Latin font as a semicolon-separated fallback list, and a real list names a few
|
||||
/// families; a longer value is corrupt or hostile, and every piece of text drawn in it would pay
|
||||
/// for its whole length as the list is classified and resolved. Keeping the leading families keeps
|
||||
/// the family such a list picks, and the rest of the value is dropped like `w:cs`/`w:eastAsia`
|
||||
/// drop an over-long name.
|
||||
fn bounded_font_list(v: &str) -> Option<String> {
|
||||
if v.len() <= MAX_FONT_NAME {
|
||||
return (!v.is_empty()).then(|| v.to_string());
|
||||
}
|
||||
let mut end = 0usize;
|
||||
for part in v.split_inclusive(';') {
|
||||
let next = end + part.len();
|
||||
if next > MAX_FONT_NAME {
|
||||
break;
|
||||
}
|
||||
end = next;
|
||||
}
|
||||
let kept = v.get(..end)?.trim_end_matches(';');
|
||||
(!kept.is_empty()).then(|| kept.to_string())
|
||||
}
|
||||
|
||||
impl PropCtx {
|
||||
pub fn style_id(&self, id: &str) -> String {
|
||||
self.alias.get(id).cloned().unwrap_or_else(|| id.to_string())
|
||||
@@ -226,6 +250,8 @@ impl PropCtx {
|
||||
shd_fill(e)
|
||||
}
|
||||
|
||||
/// The character properties `e` (a `w:rPr`) sets: direct formatting, plus the fonts, size,
|
||||
/// weight and posture of the complex-script and East Asian text beside them.
|
||||
pub fn rpr(&self, e: &El) -> CharProps {
|
||||
let mut c = CharProps::default();
|
||||
for k in e.els() {
|
||||
@@ -234,7 +260,11 @@ impl PropCtx {
|
||||
"w:rFonts" => {
|
||||
// A theme font supersedes the fixed font beside it (`w:asciiTheme` over
|
||||
// `w:ascii`, `w:hAnsiTheme` over `w:hAnsi`).
|
||||
let named = |a: &str| k.attr(a).filter(|f| !f.is_empty()).map(str::to_string);
|
||||
// A LibreOffice file names the Latin font as a semicolon-separated fallback
|
||||
// list. Names are bounded like `w:cs`/`w:eastAsia` below: a longer one is
|
||||
// corrupt or hostile, and every piece of text drawn in it would pay for its
|
||||
// whole length as the list is classified (see `bounded_font_list`).
|
||||
let named = |a: &str| k.attr(a).and_then(bounded_font_list);
|
||||
let themed = |a: &str| k.attr(a).and_then(|t| self.theme_font(t));
|
||||
// `w:eastAsia` and `w:cs` name fonts for other scripts and stay in `font_ea` /
|
||||
// `font_cs`: `w:hint` only picks between them for ambiguous characters, never for
|
||||
@@ -338,6 +368,7 @@ impl PropCtx {
|
||||
"w:suppressAutoHyphens" => p.suppress_hyphens = Some(on_off(k)),
|
||||
"w:suppressLineNumbers" => p.suppress_line_numbers = Some(on_off(k)),
|
||||
"w:bidi" => p.bidi = Some(on_off(k)),
|
||||
"w:textDirection" => p.text_direction = k.attr("w:val").and_then(ParagraphTextDirection::from_ooxml),
|
||||
"w:kinsoku" => p.kinsoku = Some(on_off(k)),
|
||||
"w:wordWrap" => p.word_wrap = Some(on_off(k)),
|
||||
"w:overflowPunct" => p.overflow_punct = Some(on_off(k)),
|
||||
|
||||
@@ -41,6 +41,7 @@ impl Reader<'_> {
|
||||
}
|
||||
let source = self.keep_embedded(c.child("a:graphic")?, rels, &[])?;
|
||||
float.set_spin(xfrm_spin(content.child("w14:xfrm")));
|
||||
float.bare_turn = !anchored && float.rot != 0.0 && float.effect == [0.0; 4];
|
||||
let (px, py) = float.spin_pad(w, h);
|
||||
for (e, p) in float.effect.iter_mut().zip([px, py, px, py]) {
|
||||
if *e != 0.0 {
|
||||
@@ -106,7 +107,11 @@ impl Reader<'_> {
|
||||
self.set_shape_id(&mut obj, c.child("wp:docPr").and_then(|p| p.attr("id")));
|
||||
// Word's effect extent also covers a rotated object's overhang; the model keeps that
|
||||
// apart (it follows from the angle), so take it back out.
|
||||
// Shadow or glow room the file didn't record: saving records it (and the turn's room).
|
||||
let fx_room = matches!(&obj, InlineObject::Shape { effects, .. } if effects.extent() != [0.0; 4]);
|
||||
if let Some(f) = obj.float_mut() {
|
||||
// None recorded: Word keeps an inline one's line to the frame (see `Float::bare_turn`).
|
||||
f.bare_turn = !anchored && !fx_room && f.rot != 0.0 && f.effect == [0.0; 4];
|
||||
let (px, py) = f.spin_pad(w, h);
|
||||
for (e, p) in f.effect.iter_mut().zip([px, py, px, py]) {
|
||||
// Zero adds no effects room when rotation contracts this axis.
|
||||
@@ -118,9 +123,33 @@ impl Reader<'_> {
|
||||
Some(obj)
|
||||
}
|
||||
|
||||
/// The hyperlink Word keeps on a drawing itself: its `wp:docPr`'s `a:hlinkClick` (ECMA-376
|
||||
/// Part 1 §20.4.2.5, §21.1.2.3.5) naming an external hyperlink relationship. Word stores a
|
||||
/// picture's link there rather than in a `w:hyperlink` around its run (#5267). Its `#name`
|
||||
/// part, when the address has one, stays part of the address.
|
||||
pub(super) fn drawing_link(d: &El, rels: &Rels) -> Option<String> {
|
||||
let frame = d.child("wp:inline").or_else(|| d.child("wp:anchor"))?;
|
||||
let id = frame.child("wp:docPr")?.child("a:hlinkClick")?.attr("r:id")?;
|
||||
let rel = rels.by_id(id).filter(|r| r.external && crate::package::rel_is(&r.kind, rt::HYPERLINK))?;
|
||||
Some(rel.target.trim().to_string()).filter(|u| !u.is_empty())
|
||||
}
|
||||
|
||||
/// Native ink or an Office 2016 chart kept with its fallback (`mc:AlternateContent`), and the
|
||||
/// [`Self::drawing_link`] of the choice it was read from, else of its fallback: another
|
||||
/// choice's link is not this object's.
|
||||
pub(super) fn read_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<(InlineObject, Option<String>)> {
|
||||
let (object, choice) = match self.read_ink_alternate(sc, ac, rels) {
|
||||
Some(o) => (o, children_of_choice(ac).filter(|e| e.name == "mc:Choice")),
|
||||
None => (self.read_chartex_alternate(sc, ac, rels)?, chartex_choice(ac)),
|
||||
};
|
||||
let link =
|
||||
[choice, ac.child("mc:Fallback")].into_iter().flatten().filter_map(|e| e.child("w:drawing")).find_map(|d| Self::drawing_link(d, rels));
|
||||
Some((object, link))
|
||||
}
|
||||
|
||||
/// Keep the selected native-ink branch and its picture fallback as one object. Reading only
|
||||
/// the fallback flattens the ink; reading only the choice loses its preview on the next save.
|
||||
pub(super) fn read_ink_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
fn read_ink_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
let choice = children_of_choice(ac).filter(|e| e.name == "mc:Choice")?;
|
||||
let drawing = choice.child("w:drawing")?;
|
||||
if drawing.find("a:graphicData")?.attr("uri") != Some(INK_URI) {
|
||||
@@ -149,10 +178,8 @@ impl Reader<'_> {
|
||||
/// An Office 2016 chart (`cx:chart`: waterfall, sunburst, box & whisker…) drawn from its
|
||||
/// fallback picture, keeping both branches so saving writes the chart back rather than only
|
||||
/// the picture. `None` (read the fallback alone) when it can't be kept whole.
|
||||
pub(super) fn read_chartex_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
let choice = ac
|
||||
.children("mc:Choice")
|
||||
.find(|c| c.child("w:drawing").and_then(|d| d.find("a:graphicData")).and_then(|g| g.attr("uri")) == Some(CHARTEX_URI))?;
|
||||
fn read_chartex_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
let choice = chartex_choice(ac)?;
|
||||
// A requirement in a namespace we can't name couldn't be written back.
|
||||
let requires = choice.attr("Requires").unwrap_or("");
|
||||
if requires.split_whitespace().next().is_none() || requires.split_whitespace().any(|p| p == "?") {
|
||||
@@ -563,6 +590,11 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `mc:Choice` holding an Office 2016 chart (`cx:chart`) in an `mc:AlternateContent`.
|
||||
fn chartex_choice(ac: &El) -> Option<&El> {
|
||||
ac.children("mc:Choice").find(|c| c.child("w:drawing").and_then(|d| d.find("a:graphicData")).and_then(|g| g.attr("uri")) == Some(CHARTEX_URI))
|
||||
}
|
||||
|
||||
/// The alternative text of a `wp:docPr`, `pic:cNvPr` or `wps:cNvPr` (ECMA-376
|
||||
/// `CT_NonVisualDrawingProps`): the description (the title when there is none, as before), and
|
||||
/// the title and decorative mark (the `adec:decorative` extension of [MS-ODRAWXML]).
|
||||
|
||||
@@ -98,9 +98,9 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
"mc:AlternateContent" => {
|
||||
if let Some(o) = self.read_ink_alternate(sc, k, rels).or_else(|| self.read_chartex_alternate(sc, k, rels)) {
|
||||
if let Some((o, link)) = self.read_alternate(sc, k, rels) {
|
||||
let props = self.run_props_none(ctx);
|
||||
self.emit_obj(sc, pb, o, &props);
|
||||
self.emit_linked_obj(sc, pb, o, &props, link);
|
||||
} else if let Some(c) = children_of_choice(k) {
|
||||
self.read_inline_children(sc, pb, c, rels, ctx, depth + 1);
|
||||
}
|
||||
@@ -237,7 +237,7 @@ impl Reader<'_> {
|
||||
},
|
||||
"w:drawing" => {
|
||||
if let Some(o) = self.read_drawing(sc, k, rels) {
|
||||
self.emit_obj(sc, pb, o, props);
|
||||
self.emit_linked_obj(sc, pb, o, props, Self::drawing_link(k, rels));
|
||||
}
|
||||
}
|
||||
"w:pict" => {
|
||||
@@ -298,8 +298,8 @@ impl Reader<'_> {
|
||||
),
|
||||
"w:ruby" => self.read_ruby(sc, pb, k, props),
|
||||
"mc:AlternateContent" => {
|
||||
if let Some(o) = self.read_ink_alternate(sc, k, rels).or_else(|| self.read_chartex_alternate(sc, k, rels)) {
|
||||
self.emit_obj(sc, pb, o, props);
|
||||
if let Some((o, link)) = self.read_alternate(sc, k, rels) {
|
||||
self.emit_linked_obj(sc, pb, o, props, link);
|
||||
} else if let Some(c) = children_of_choice(k) {
|
||||
for x in c.els() {
|
||||
self.read_run_child(sc, pb, x, (props, rels), note, depth + 1);
|
||||
@@ -376,6 +376,14 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::emit_obj`] for a drawing that carries its own `link`: a link around the run wins.
|
||||
fn emit_linked_obj(&mut self, sc: &mut StoryCtx, pb: &mut PB, o: InlineObject, props: &CharProps, link: Option<String>) {
|
||||
match link.filter(|_| props.link.is_none()) {
|
||||
Some(link) => self.emit_obj(sc, pb, o, &CharProps { link: Some(link), ..props.clone() }),
|
||||
None => self.emit_obj(sc, pb, o, props),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn emit_obj(&mut self, sc: &mut StoryCtx, pb: &mut PB, o: InlineObject, props: &CharProps) {
|
||||
match sink(sc) {
|
||||
Some(f) => {
|
||||
|
||||
@@ -224,6 +224,15 @@ impl Reader<'_> {
|
||||
{
|
||||
let id = self.read_textbox(sc, t, rels);
|
||||
if let Some(part) = self.doc.parts.get_mut(&id) {
|
||||
// Text direction (#5193), as Word reads it: `mso-layout-flow-alt:bottom-to-top` is
|
||||
// `vert270` whatever `layout-flow` says; otherwise `layout-flow:vertical…` (also
|
||||
// `vertical-ideographic`, our `eaVert`) is `vert`, and anything else horizontal.
|
||||
let tb_style = tb.attr("style").unwrap_or("");
|
||||
if vml_style(tb_style, "mso-layout-flow-alt").is_some_and(|v| v.eq_ignore_ascii_case("bottom-to-top")) {
|
||||
part.body.vert = wordcraft_doc::props::TextVert::Vert270;
|
||||
} else if vml_style(tb_style, "layout-flow").is_some_and(|v| v.to_ascii_lowercase().starts_with("vertical")) {
|
||||
part.body.vert = wordcraft_doc::props::TextVert::Vert;
|
||||
}
|
||||
// Internal margins `inset="l,t,r,b"` (ECMA-376 Part 4 §19.1.2.22), each one left
|
||||
// out keeping Word's default; a bare number is in EMUs, VML's base unit.
|
||||
// `o:insetmode="auto"` leaves them to the application.
|
||||
|
||||
@@ -83,13 +83,14 @@ pub(super) fn toc_control_to_toc(body: &mut Blocks) {
|
||||
}
|
||||
// An empty paragraph opening the control takes the field. It may end a section, as above
|
||||
// entries in columns (#3889).
|
||||
if engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty()) {
|
||||
let empty_heading = engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty());
|
||||
if empty_heading {
|
||||
field_to_heading(&mut engine, s + 1);
|
||||
} else {
|
||||
toc_field_to_heading(&mut engine);
|
||||
}
|
||||
if crate::write::written_toc_span(&engine) == Some((s, last)) {
|
||||
*body = toc;
|
||||
*body = if empty_heading { toc } else { engine };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +98,7 @@ pub(super) fn toc_control_to_toc(body: &mut Blocks) {
|
||||
/// reproduce exactly. Other imported TOCs keep their explicit boundaries, independent of styles.
|
||||
pub(super) fn toc_field_to_heading(body: &mut Blocks) {
|
||||
for i in 1..body.len() {
|
||||
if body[i - 1].as_para().is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) {
|
||||
if body.get(i - 1).and_then(|b| b.as_para()).is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) {
|
||||
field_to_heading(body, i);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,8 +499,9 @@ impl super::Writer<'_> {
|
||||
}
|
||||
}
|
||||
"wp:effectExtent" => {
|
||||
// Word's effect extent also covers a rotated object's overhang (its rotated bounds).
|
||||
let (px, py) = float.spin_pad(w, h);
|
||||
// Word's effect extent also covers a rotated object's overhang (its rotated bounds),
|
||||
// unless the file it came from kept none.
|
||||
let (px, py) = if float.is_bare_turn() { (0.0, 0.0) } else { float.spin_pad(w, h) };
|
||||
let [el, et, er, eb] = float.effect_extent();
|
||||
let ext = [el + px, et + py, er + px, eb + py].map(emu);
|
||||
e.attrs.retain(|(k, _)| !matches!(k.as_str(), "l" | "t" | "r" | "b"));
|
||||
|
||||
@@ -287,6 +287,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
|
||||
if let Some(a) = p.align {
|
||||
w.val("w:jc", align_val(a));
|
||||
}
|
||||
if let Some(direction) = p.text_direction {
|
||||
w.val("w:textDirection", direction.ooxml());
|
||||
}
|
||||
if let Some(t) = p.text_alignment {
|
||||
w.val("w:textAlignment", t.ooxml());
|
||||
}
|
||||
|
||||
@@ -86,11 +86,28 @@ pub fn plan(bl: &Blocks) -> Plan<'_> {
|
||||
&& (0..off).step_by(OBJ.len_utf8()).all(|o| matches!(p.object_at(o), Some(wordcraft_doc::InlineObject::ControlStart { .. })))
|
||||
})
|
||||
};
|
||||
// Bookmark, comment and permission markers after the end may stay there: a body-level
|
||||
// bookmark after a block control (Word's `_GoBack`) is read onto the end of its paragraph,
|
||||
// and the control still covers the whole paragraph.
|
||||
let at_end = |b: usize, off: usize| {
|
||||
use wordcraft_doc::InlineObject as O;
|
||||
para(b).is_some_and(|p| {
|
||||
let from = off + OBJ.len_utf8();
|
||||
p.text.get(from..).is_some_and(|t| t.chars().all(|c| c == OBJ))
|
||||
&& (from..p.len()).step_by(OBJ.len_utf8()).all(|o| matches!(p.object_at(o), Some(wordcraft_doc::InlineObject::ControlEnd)))
|
||||
&& (from..p.len()).step_by(OBJ.len_utf8()).all(|o| {
|
||||
matches!(
|
||||
p.object_at(o),
|
||||
Some(
|
||||
O::ControlEnd
|
||||
| O::BookmarkStart { .. }
|
||||
| O::BookmarkEnd { .. }
|
||||
| O::CommentStart { .. }
|
||||
| O::CommentEnd { .. }
|
||||
| O::PermStart { .. }
|
||||
| O::PermEnd { .. }
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
};
|
||||
let mut mode = vec![Mode::Inline; ranges.len()];
|
||||
|
||||
@@ -10,9 +10,19 @@ pub(super) struct DrawingInfo<'a> {
|
||||
pub docpr: &'a str,
|
||||
pub name: &'a str,
|
||||
pub alt: &'a str,
|
||||
/// The relationship id of the drawing's hyperlink (`a:hlinkClick`), see [`Writer::drawing_link`].
|
||||
pub link: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl Writer<'_> {
|
||||
/// The relationship of the hyperlink a drawing in a run with `props` carries on itself, as
|
||||
/// Word writes a picture's link (`wp:docPr/a:hlinkClick`, #5267): a web or file address. A
|
||||
/// link to a place in the document (`#name`) is only the `w:hyperlink` around the run.
|
||||
pub(super) fn drawing_link(props: &wordcraft_doc::CharProps, rels: &mut PartRels) -> Option<String> {
|
||||
let url = props.link.as_deref().filter(|l| !l.is_empty() && !l.starts_with('#'))?;
|
||||
Some(rels.add(rt::HYPERLINK, url, true))
|
||||
}
|
||||
|
||||
/// A picture's `pic:pic` (the image in `media/{file}`), at `off` in its group (or 0, 0).
|
||||
pub(super) fn pic(&mut self, w: &mut W, o: &InlineObject, file: &str, off: (f32, f32), rels: &mut PartRels) {
|
||||
let InlineObject::Image { media, w: iw, h: ih, alt, crop, float, fx, .. } = o else { return };
|
||||
@@ -20,7 +30,7 @@ impl Writer<'_> {
|
||||
self.used_media.insert(media.clone());
|
||||
w.open("pic:pic", &[]);
|
||||
w.open("pic:nvPicPr", &[]);
|
||||
nv_props(w, "pic:cNvPr", "0", file, alt, &float.alt, float.hidden);
|
||||
nv_props(w, "pic:cNvPr", ("0", file), alt, &float.alt, float.hidden, None);
|
||||
w.open("pic:cNvPicPr", &[]);
|
||||
w.empty("a:picLocks", &[("noChangeAspect", "1"), ("noChangeArrowheads", "1")]);
|
||||
w.close("pic:cNvPicPr");
|
||||
@@ -66,7 +76,7 @@ impl Writer<'_> {
|
||||
w.open("wps:wsp", &[]);
|
||||
if let Some(id) = id {
|
||||
let name = shape_name(*kind, freeform.as_deref(), id);
|
||||
nv_props(w, "wps:cNvPr", id, &name, &float.alt.text, &float.alt, float.hidden);
|
||||
nv_props(w, "wps:cNvPr", (id, &name), &float.alt.text, &float.alt, float.hidden, None);
|
||||
}
|
||||
if kind.is_connector() {
|
||||
// A connector's glued ends (ECMA-376 §20.1.2.2.8 `cNvCxnSpPr`).
|
||||
@@ -235,7 +245,7 @@ impl Writer<'_> {
|
||||
}
|
||||
|
||||
pub(super) fn drawing_open(&mut self, w: &mut W, info: DrawingInfo<'_>) {
|
||||
let DrawingInfo { float, size: (iw, ih), docpr, name, alt } = info;
|
||||
let DrawingInfo { float, size: (iw, ih), docpr, name, alt, link } = info;
|
||||
let (cx, cy) = (emu(iw.max(0.0)), emu(ih.max(0.0)));
|
||||
if float.wrap == Wrap::Inline {
|
||||
w.open("wp:inline", &[("distT", "0"), ("distB", "0"), ("distL", "0"), ("distR", "0")]);
|
||||
@@ -288,8 +298,9 @@ impl Writer<'_> {
|
||||
}
|
||||
}
|
||||
w.empty("wp:extent", &[("cx", &cx), ("cy", &cy)]);
|
||||
// Word's effect extent also covers a rotated object's overhang (its rotated bounds).
|
||||
let (px, py) = float.spin_pad(iw, ih);
|
||||
// Word's effect extent also covers a rotated object's overhang (its rotated bounds), unless
|
||||
// the file it came from kept none.
|
||||
let (px, py) = if float.is_bare_turn() { (0.0, 0.0) } else { float.spin_pad(iw, ih) };
|
||||
let [el, et, er, eb] = float.effect_extent();
|
||||
let [el, et, er, eb] = [el + px, et + py, er + px, eb + py].map(emu);
|
||||
w.empty("wp:effectExtent", &[("l", &el), ("t", &et), ("r", &er), ("b", &eb)]);
|
||||
@@ -316,7 +327,7 @@ impl Writer<'_> {
|
||||
Wrap::TopAndBottom => w.empty("wp:wrapTopAndBottom", &[]),
|
||||
Wrap::BehindText | Wrap::InFrontOfText => w.empty("wp:wrapNone", &[]),
|
||||
}
|
||||
nv_props(w, "wp:docPr", docpr, name, alt, &float.alt, float.hidden);
|
||||
nv_props(w, "wp:docPr", (docpr, name), alt, &float.alt, float.hidden, link);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,8 +513,9 @@ pub(in super::super) fn spin_attrs(spin: wordcraft_geom::Spin) -> Vec<(&'static
|
||||
/// A drawing's `wp:docPr` (or a group member's `pic:cNvPr`/`wps:cNvPr`, ECMA-376
|
||||
/// `CT_NonVisualDrawingProps`) with its alt text `descr`, its title, and the decorative mark as
|
||||
/// the `adec:decorative` extension ([MS-ODRAWXML]). A description that is only the title (read
|
||||
/// from a file with no description) is not written twice.
|
||||
pub(in super::super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, descr: &str, alt: &AltText, hidden: bool) {
|
||||
/// from a file with no description) is not written twice. `link` is the relationship id of the
|
||||
/// drawing's hyperlink (`a:hlinkClick`, the first child the schema allows).
|
||||
pub(in super::super) fn nv_props(w: &mut W, tag: &str, (id, name): (&str, &str), descr: &str, alt: &AltText, hidden: bool, link: Option<&str>) {
|
||||
let mut attrs = vec![("id", id), ("name", name)];
|
||||
if descr != alt.title || descr.is_empty() {
|
||||
attrs.push(("descr", descr));
|
||||
@@ -514,16 +526,21 @@ pub(in super::super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, des
|
||||
if hidden {
|
||||
attrs.push(("hidden", "1"));
|
||||
}
|
||||
if !alt.decorative {
|
||||
if !alt.decorative && link.is_none() {
|
||||
w.empty(tag, &attrs);
|
||||
return;
|
||||
}
|
||||
w.open(tag, &attrs);
|
||||
w.open("a:extLst", &[]);
|
||||
w.open("a:ext", &[("uri", crate::xml::DECORATIVE_EXT)]);
|
||||
w.empty("adec:decorative", &[("xmlns:adec", crate::xml::DECORATIVE_NS), ("val", "1")]);
|
||||
w.close("a:ext");
|
||||
w.close("a:extLst");
|
||||
if let Some(rid) = link {
|
||||
w.empty("a:hlinkClick", &[("r:id", rid)]);
|
||||
}
|
||||
if alt.decorative {
|
||||
w.open("a:extLst", &[]);
|
||||
w.open("a:ext", &[("uri", crate::xml::DECORATIVE_EXT)]);
|
||||
w.empty("adec:decorative", &[("xmlns:adec", crate::xml::DECORATIVE_NS), ("val", "1")]);
|
||||
w.close("a:ext");
|
||||
w.close("a:extLst");
|
||||
}
|
||||
w.close(tag);
|
||||
}
|
||||
|
||||
|
||||
@@ -220,13 +220,18 @@ impl Writer<'_> {
|
||||
w.open("mc:Choice", &[("Requires", requires)]);
|
||||
}
|
||||
let docpr = self.next_docpr();
|
||||
let link = Self::drawing_link(props, rels);
|
||||
let name = match graphic.kind {
|
||||
wordcraft_doc::graphic::GraphicKind::Chart => format!("Chart {docpr}"),
|
||||
wordcraft_doc::graphic::GraphicKind::Diagram => format!("Diagram {docpr}"),
|
||||
wordcraft_doc::graphic::GraphicKind::Ink => format!("Ink {docpr}"),
|
||||
};
|
||||
let z = self.z;
|
||||
self.graphic_frame(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt }, native.unwrap_or(&root));
|
||||
self.graphic_frame(
|
||||
w,
|
||||
DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt, link: link.as_deref() },
|
||||
native.unwrap_or(&root),
|
||||
);
|
||||
if let Some(fallback) = fallback {
|
||||
w.close("mc:Choice");
|
||||
match picture {
|
||||
@@ -234,7 +239,11 @@ impl Writer<'_> {
|
||||
// Both branches are one object: same id and stacking order.
|
||||
self.z = z;
|
||||
w.open("mc:Fallback", &[]);
|
||||
self.graphic_frame(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt }, picture);
|
||||
self.graphic_frame(
|
||||
w,
|
||||
DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt, link: link.as_deref() },
|
||||
picture,
|
||||
);
|
||||
w.close("mc:Fallback");
|
||||
}
|
||||
None => w.raw(&fallback.to_xml()),
|
||||
@@ -266,8 +275,9 @@ impl Writer<'_> {
|
||||
self.rpr(w, props);
|
||||
w.open("w:drawing", &[]);
|
||||
let docpr = self.next_docpr();
|
||||
let link = Self::drawing_link(props, rels);
|
||||
let name = format!("Picture {docpr}");
|
||||
self.drawing_open(w, DrawingInfo { float, size: (*iw, *ih), docpr: &docpr, name: &name, alt });
|
||||
self.drawing_open(w, DrawingInfo { float, size: (*iw, *ih), docpr: &docpr, name: &name, alt, link: link.as_deref() });
|
||||
w.empty("wp:cNvGraphicFramePr", &[]);
|
||||
w.open("a:graphic", &[]);
|
||||
w.open("a:graphicData", &[("uri", "http://schemas.openxmlformats.org/drawingml/2006/picture")]);
|
||||
@@ -355,6 +365,7 @@ impl Writer<'_> {
|
||||
self.rpr(w, props);
|
||||
w.open("w:drawing", &[]);
|
||||
let docpr = self.shape_docpr(extra.id);
|
||||
let link = Self::drawing_link(props, rels);
|
||||
// Ink is told apart by its name (and its pen), which reading looks for.
|
||||
let name = shape_name(*kind, freeform.as_deref(), &docpr);
|
||||
// The effect extent leaves room for the shadow and glow.
|
||||
@@ -369,7 +380,7 @@ impl Writer<'_> {
|
||||
.map(std::sync::Arc::new);
|
||||
}
|
||||
let float = &float;
|
||||
self.drawing_open(w, DrawingInfo { float, size: (*sw, *sh), docpr: &docpr, name: &name, alt: o.alt_text() });
|
||||
self.drawing_open(w, DrawingInfo { float, size: (*sw, *sh), docpr: &docpr, name: &name, alt: o.alt_text(), link: link.as_deref() });
|
||||
w.empty("wp:cNvGraphicFramePr", &[]);
|
||||
w.open("a:graphic", &[]);
|
||||
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape")]);
|
||||
@@ -389,8 +400,9 @@ impl Writer<'_> {
|
||||
self.rpr(w, props);
|
||||
w.open("w:drawing", &[]);
|
||||
let docpr = self.next_docpr();
|
||||
let link = Self::drawing_link(props, rels);
|
||||
let name = format!("{} {docpr}", if canvas.is_some() { "Canvas" } else { "Group" });
|
||||
self.drawing_open(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt: o.alt_text() });
|
||||
self.drawing_open(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt: o.alt_text(), link: link.as_deref() });
|
||||
w.empty("wp:cNvGraphicFramePr", &[]);
|
||||
w.open("a:graphic", &[]);
|
||||
let outer = match canvas {
|
||||
|
||||
+114
-6
@@ -94,6 +94,49 @@ fn prefix_for(uri: &[u8], extra: &[(&'static str, &str)]) -> &'static str {
|
||||
"?"
|
||||
}
|
||||
|
||||
/// Word's 2006 namespace (`wne`). A Strict document from Word keeps a text box's content in
|
||||
/// `wne:txbxContent`; a Transitional one uses `w:txbxContent`.
|
||||
const WNE: &[u8] = b"http://schemas.microsoft.com/office/word/2006/wordml";
|
||||
|
||||
/// The canonical prefix of an element named `local` in namespace `uri`, inside an element
|
||||
/// named `parent` (canonical, `None` at the root). This is [`prefix_for`] with two exceptions
|
||||
/// for Strict documents (#3548):
|
||||
///
|
||||
/// - Shapes, groups and canvases (`wsp`, `wgp`, `wpc`) and their parts are elements of the
|
||||
/// WordprocessingDrawing namespace in ECMA-376 Part 1, 5th edition (`dml-wordprocessingDrawing.xsd`,
|
||||
/// Strict and Transitional alike). A Strict document from Word puts them there; a Transitional
|
||||
/// one uses Word 2010's own namespaces, whose names the reader knows. So they read as `wps:`,
|
||||
/// `wpg:` and `wpc:` (see [`drawing_prefix`]).
|
||||
/// - A shape's text box content reads as `w:txbxContent` from the drawing namespace (the
|
||||
/// schema's name) and from Word's `wne:txbxContent`.
|
||||
fn element_prefix(uri: &[u8], local: &str, parent: Option<&str>, extra: &[(&'static str, &str)]) -> &'static str {
|
||||
match prefix_for(uri, extra) {
|
||||
"wp" => drawing_prefix(local, parent),
|
||||
"?" if uri == WNE && local == "txbxContent" && parent == Some("wps:txbx") => "w",
|
||||
p => p,
|
||||
}
|
||||
}
|
||||
|
||||
/// The prefix for `local`, an element of the WordprocessingDrawing namespace inside `parent`.
|
||||
/// Word 2010's namespaces split what this one holds: a shape and its parts are `wps:`, a group
|
||||
/// (`wgp`, a nested `grpSp`) and a graphic frame in it are `wpg:`, and a canvas is `wpc:`. Its
|
||||
/// members are named for what they are. A text box's content is `w:txbxContent`, and anything
|
||||
/// else (`inline`, `anchor`, `docPr`…) stays `wp:`.
|
||||
fn drawing_prefix(local: &str, parent: Option<&str>) -> &'static str {
|
||||
match (parent, local) {
|
||||
(_, "wsp") => "wps",
|
||||
(_, "wgp" | "grpSp" | "graphicFrame") => "wpg",
|
||||
(_, "wpc") => "wpc",
|
||||
(Some("wps:wsp"), "cNvPr" | "cNvSpPr" | "cNvCnPr" | "spPr" | "style" | "extLst" | "txbx" | "linkedTxbx" | "bodyPr") => "wps",
|
||||
(Some("wps:txbx"), "txbxContent") => "w",
|
||||
(Some("wps:txbx" | "wps:linkedTxbx"), "extLst") => "wps",
|
||||
(Some("wpg:wgp" | "wpg:grpSp"), "cNvPr" | "cNvGrpSpPr" | "grpSpPr" | "extLst") => "wpg",
|
||||
(Some("wpg:graphicFrame"), "cNvPr" | "cNvFrPr" | "xfrm" | "extLst") => "wpg",
|
||||
(Some("wpc:wpc"), "bg" | "whole" | "extLst") => "wpc",
|
||||
_ => "wp",
|
||||
}
|
||||
}
|
||||
|
||||
/// Compatibility feature namespaces whose elements are not otherwise handled by this tree.
|
||||
/// Keep the reader's existing feature set when canonicalizing `mc:Choice/@Requires` aliases.
|
||||
fn requirement_prefix(uri: &[u8]) -> &'static str {
|
||||
@@ -342,8 +385,8 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
|
||||
loop {
|
||||
buf.clear();
|
||||
let (res, ev) = r.read_resolved_event_into(&mut buf).map_err(|e| DocxError::Xml(e.to_string()))?;
|
||||
let el_prefix = match &res {
|
||||
ResolveResult::Bound(ns) => Some(prefix_for(ns.as_ref(), extra)),
|
||||
let el_ns = match &res {
|
||||
ResolveResult::Bound(ns) => Some(ns.as_ref()),
|
||||
_ => None,
|
||||
};
|
||||
match ev {
|
||||
@@ -356,8 +399,8 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
|
||||
return Err(DocxError::Limit("too many XML elements".into()));
|
||||
}
|
||||
let local = String::from_utf8_lossy(e.local_name().as_ref()).into_owned();
|
||||
let name = match el_prefix {
|
||||
Some(p) => format!("{p}:{local}"),
|
||||
let name = match el_ns {
|
||||
Some(ns) => format!("{}:{local}", element_prefix(ns, &local, stack.last().map(|p| p.name.as_str()), extra)),
|
||||
None => local,
|
||||
};
|
||||
let mut el = El { name, attrs: Vec::new(), kids: Vec::new() };
|
||||
@@ -419,15 +462,22 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
|
||||
attach(&mut stack, &mut root, el);
|
||||
}
|
||||
}
|
||||
// Line ends are normalised as XML 1.0 says (§2.11): CR LF and a lone CR are read as
|
||||
// LF, so a file saved with Windows line ends has no CR in its text. A CR written as
|
||||
// ` ` is a character reference and stays.
|
||||
Event::Text(t) => {
|
||||
if let Some(top) = stack.last_mut() {
|
||||
let s = t.decode().map_err(|e| DocxError::Xml(e.to_string()))?;
|
||||
let s = t.xml_content(XmlVersion::Explicit1_0).map_err(|e| DocxError::Xml(e.to_string()))?;
|
||||
push_text(top, &s);
|
||||
}
|
||||
}
|
||||
Event::CData(t) => {
|
||||
if let Some(top) = stack.last_mut() {
|
||||
let s = String::from_utf8_lossy(&t).into_owned();
|
||||
// CDATA that isn't UTF-8 is decoded lossily, with its line ends normalised the same way.
|
||||
let s = t
|
||||
.xml_content(XmlVersion::Explicit1_0)
|
||||
.map(|s| s.into_owned())
|
||||
.unwrap_or_else(|_| String::from_utf8_lossy(&t).replace("\r\n", "\n").replace('\r', "\n"));
|
||||
push_text(top, &s);
|
||||
}
|
||||
}
|
||||
@@ -593,6 +643,42 @@ mod tests {
|
||||
assert!(parse(b"<a></a><b></b>").is_ok(), "lenient parsing still stops at the root");
|
||||
}
|
||||
|
||||
/// Every element name in `e`, depth first.
|
||||
fn names(e: &El, out: &mut Vec<String>) {
|
||||
out.push(e.name.clone());
|
||||
for c in e.els() {
|
||||
names(c, out);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_drawing_names_read_as_word_2010_names() {
|
||||
// A Strict shape with a text box, a group with a nested group and a graphic frame, and
|
||||
// a canvas, each in the one Strict WordprocessingDrawing namespace (#3548).
|
||||
let xml = br#"<w:body xmlns:w="http://purl.oclc.org/ooxml/wordprocessingml/main" xmlns:a="http://purl.oclc.org/ooxml/drawingml/main" xmlns:d="http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml">
|
||||
<d:anchor><d:extent/><d:docPr/><d:cNvGraphicFramePr/><a:graphic><a:graphicData>
|
||||
<d:wsp><d:cNvPr/><d:cNvSpPr/><d:spPr/><d:style/><d:extLst/><d:txbx><wne:txbxContent><w:p/></wne:txbxContent></d:txbx><d:bodyPr/></d:wsp>
|
||||
<d:wgp><d:cNvPr/><d:cNvGrpSpPr/><d:grpSpPr/><d:grpSp><d:cNvGrpSpPr/><d:grpSpPr/><d:wsp><d:cNvCnPr/><d:spPr/><d:linkedTxbx/><d:bodyPr/></d:wsp></d:grpSp>
|
||||
<d:graphicFrame><d:cNvPr/><d:cNvFrPr/><d:xfrm/><a:graphic/><d:extLst/></d:graphicFrame><d:extLst/></d:wgp>
|
||||
<d:wpc><d:bg/><d:whole/><d:wsp><d:spPr/><d:txbx><d:txbxContent/><d:extLst/></d:txbx></d:wsp><d:wgp><d:grpSpPr/></d:wgp><d:extLst/></d:wpc>
|
||||
</a:graphicData></a:graphic></d:anchor>
|
||||
<wne:txbxContent/><d:inline><d:docPr/><d:bodyPr/><d:bg/></d:inline></w:body>"#;
|
||||
let mut got = Vec::new();
|
||||
names(&parse(xml).unwrap(), &mut got);
|
||||
let want = "w:body wp:anchor wp:extent wp:docPr wp:cNvGraphicFramePr a:graphic a:graphicData \
|
||||
wps:wsp wps:cNvPr wps:cNvSpPr wps:spPr wps:style wps:extLst wps:txbx w:txbxContent w:p wps:bodyPr \
|
||||
wpg:wgp wpg:cNvPr wpg:cNvGrpSpPr wpg:grpSpPr wpg:grpSp wpg:cNvGrpSpPr wpg:grpSpPr wps:wsp wps:cNvCnPr wps:spPr wps:linkedTxbx wps:bodyPr \
|
||||
wpg:graphicFrame wpg:cNvPr wpg:cNvFrPr wpg:xfrm a:graphic wpg:extLst wpg:extLst \
|
||||
wpc:wpc wpc:bg wpc:whole wps:wsp wps:spPr wps:txbx w:txbxContent wps:extLst wpg:wgp wpg:grpSpPr wpc:extLst \
|
||||
?:txbxContent wp:inline wp:docPr wp:bodyPr wp:bg";
|
||||
assert_eq!(got.join(" "), want);
|
||||
// The Transitional namespaces read as before.
|
||||
let xml = br#"<w:body xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml"><wp:inline><wp:docPr/></wp:inline><wps:wsp><wps:txbx><w:txbxContent/></wps:txbx></wps:wsp><wne:toolbars/></w:body>"#;
|
||||
let mut got = Vec::new();
|
||||
names(&parse(xml).unwrap(), &mut got);
|
||||
assert_eq!(got.join(" "), "w:body wp:inline wp:docPr wps:wsp wps:txbx w:txbxContent ?:toolbars");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_relationship_attribute_is_the_namespaced_one_or_the_alias_of_one() {
|
||||
// The relationship namespace, and VML's `o:relid`.
|
||||
@@ -645,6 +731,28 @@ mod tests {
|
||||
assert_eq!(t.attr("xml:space"), Some("preserve"));
|
||||
}
|
||||
|
||||
/// CDATA that isn't UTF-8 is decoded lossily, and its line ends are still normalised.
|
||||
#[test]
|
||||
fn line_ends_in_cdata_that_isnt_utf8_are_normalised() {
|
||||
let x =
|
||||
b"<x:document xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><x:t><![CDATA[a\xff\r\nb\rc]]></x:t></x:document>";
|
||||
assert_eq!(parse(x).unwrap().child("w:t").unwrap().text(), "a\u{FFFD}\nb\nc");
|
||||
}
|
||||
|
||||
/// XML 1.0 §2.11: CR LF and a lone CR in text and CDATA are read as LF; ` ` stays a CR.
|
||||
#[test]
|
||||
fn line_ends_in_text_are_normalised_but_a_cr_reference_stays() {
|
||||
let x = b"<x:document xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><x:t>a\r\nb\rc d<![CDATA[e\r\nf]]></x:t></x:document>";
|
||||
let e = parse(x).unwrap();
|
||||
assert_eq!(e.child("w:t").unwrap().text(), "a\nb\nc\rde\nf");
|
||||
// The writer escapes a CR, so a CR in the text survives a save.
|
||||
let x = format!(
|
||||
"<x:document xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><x:t>{}</x:t></x:document>",
|
||||
esc("a\r\nb\rc")
|
||||
);
|
||||
assert_eq!(parse(x.as_bytes()).unwrap().child("w:t").unwrap().text(), "a\r\nb\rc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choice_requirements_resolve_namespace_aliases_and_rebindings() {
|
||||
let x = br#"<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:pen="http://schemas.microsoft.com/office/word/2010/wordprocessingInk"><mc:Choice Requires="pen"/><mc:Choice xmlns:pen="urn:future" Requires="pen"/><mc:Choice Requires="undeclared"/></mc:AlternateContent>"#;
|
||||
|
||||
@@ -342,3 +342,57 @@ fn toc_gallery_around_a_toc_whose_heading_is_in_a_block_control_round_trips() {
|
||||
assert!(xml.rfind("</w:sdt>").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_toc_gallery_with_field_end_after_entries_round_trips_without_update() {
|
||||
let style = |s: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{s}"/></w:pPr>{inner}</w:p>"#);
|
||||
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
|
||||
let content = format!(
|
||||
"{}{}{}<w:p><w:r><w:fldChar w:fldCharType=\"end\"/></w:r></w:p>",
|
||||
style("TOCHeading", &t("Contents")),
|
||||
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
|
||||
style("TOC2", &t("Section\t2"))
|
||||
);
|
||||
let pr = r#"<w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj>"#;
|
||||
let mut bytes = package(&format!("{}{}", sdt(pr, &content), p(&t("Chapter One"))));
|
||||
for pass in 0..2 {
|
||||
let d = read(&bytes).unwrap();
|
||||
bytes = write(&d).unwrap();
|
||||
let xml = document_xml(&bytes);
|
||||
assert_eq!(xml.matches("<w:sdt>").count(), 1, "pass {pass}: {xml}");
|
||||
assert!(xml.contains(r#"<w:docPartGallery w:val="Table of Contents"/>"#), "pass {pass}: {xml}");
|
||||
assert!(xml.find("</w:sdt>").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_toc_without_control_stays_unwrapped_and_section_break_toc_control_hands_over() {
|
||||
let style = |s: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{s}"/></w:pPr>{inner}</w:p>"#);
|
||||
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
|
||||
// A range TOC that was never in a content control (with FieldEnd in its own paragraph) must
|
||||
// not gain a Table of Contents w:sdt on save.
|
||||
let bare = format!(
|
||||
"{}{}{}<w:p><w:r><w:fldChar w:fldCharType=\"end\"/></w:r></w:p>{}",
|
||||
style("TOCHeading", &t("Contents")),
|
||||
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
|
||||
style("TOC2", &t("Section\t2")),
|
||||
p(&t("Chapter One")),
|
||||
);
|
||||
let d_bare = read(&package(&bare)).unwrap();
|
||||
let xml_bare = document_xml(&write(&d_bare).unwrap());
|
||||
assert_eq!(xml_bare.matches("<w:sdt>").count(), 0, "{xml_bare}");
|
||||
|
||||
// #3889: a Table of Contents control opening on an empty paragraph that ends a section above
|
||||
// the entries hands its control over on read so Update Table reuses the section-break paragraph.
|
||||
let sect_toc = format!(
|
||||
r#"<w:p><w:pPr><w:sectPr><w:pgSz w:w="11906" w:h="16838"/></w:sectPr></w:pPr></w:p>{}{}<w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#,
|
||||
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
|
||||
style("TOC2", &t("Section\t2")),
|
||||
);
|
||||
let pr = r#"<w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj>"#;
|
||||
let d_sect = read(&package(&format!("{}{}", sdt(pr, §_toc), p(&t("Chapter One"))))).unwrap();
|
||||
assert!(controls(&d_sect).is_empty());
|
||||
assert!(d_sect.body[0].as_para().unwrap().section.is_some());
|
||||
let xml_sect = document_xml(&write(&d_sect).unwrap());
|
||||
assert!(xml_sect.contains("Chapter One") && xml_sect.contains("Section"), "{xml_sect}");
|
||||
}
|
||||
@@ -1766,12 +1766,27 @@ const CT_CHARTEX: &str = "application/vnd.ms-office.chartex+xml";
|
||||
/// A package holding one box & whisker chart the way Word writes it: the `cx:chart` in an
|
||||
/// `mc:Choice` requiring `requires` (bound to `requires_ns`), a picture of it in the `mc:Fallback`.
|
||||
fn chartex_docx(requires: &str, requires_ns: &str) -> Vec<u8> {
|
||||
let frame = r#"<wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="2743200" cy="1371600"/><wp:docPr id="1" name="Chart 1"/><wp:cNvGraphicFramePr/>"#;
|
||||
chartex_docx_linked(requires, requires_ns, false)
|
||||
}
|
||||
|
||||
/// [`chartex_docx`], its drawing linked to a web address (`wp:docPr/a:hlinkClick`) when `link`,
|
||||
/// after a first choice linked elsewhere, which needs a namespace no reader knows, so a reader
|
||||
/// takes the chart's choice.
|
||||
fn chartex_docx_linked(requires: &str, requires_ns: &str, link: bool) -> Vec<u8> {
|
||||
let click = if link { r#"<a:hlinkClick r:id="rIdWeb"/>"# } else { "" };
|
||||
let other = if link {
|
||||
r#"<mc:Choice xmlns:w99="urn:example:unsupported" Requires="w99"><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="254000" cy="254000"/><wp:docPr id="9" name="Other"><a:hlinkClick r:id="rIdOther"/></wp:docPr><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><wps:wsp><wps:spPr><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></wps:spPr><wps:bodyPr/></wps:wsp></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Choice>"#
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let frame = format!(
|
||||
r#"<wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="2743200" cy="1371600"/><wp:docPr id="1" name="Chart 1">{click}</wp:docPr><wp:cNvGraphicFramePr/>"#
|
||||
);
|
||||
let body = format!(
|
||||
r#"<w:p><w:r><w:t>Spread</w:t></w:r><w:r><mc:AlternateContent><mc:Choice xmlns:{requires}="{requires_ns}" Requires="{requires}"><w:drawing>{frame}<a:graphic><a:graphicData uri="{CHARTEX_NS}"><cx:chart xmlns:cx="{CHARTEX_NS}" r:id="rIdCx"/></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Choice><mc:Fallback><w:drawing>{frame}<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="1" name="Chart 1"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdPic"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="2743200" cy="1371600"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Fallback></mc:AlternateContent></w:r></w:p>"#
|
||||
r#"<w:p><w:r><w:t>Spread</w:t></w:r><w:r><mc:AlternateContent>{other}<mc:Choice xmlns:{requires}="{requires_ns}" Requires="{requires}"><w:drawing>{frame}<a:graphic><a:graphicData uri="{CHARTEX_NS}"><cx:chart xmlns:cx="{CHARTEX_NS}" r:id="rIdCx"/></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Choice><mc:Fallback><w:drawing>{frame}<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="1" name="Chart 1"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdPic"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="2743200" cy="1371600"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Fallback></mc:AlternateContent></w:r></w:p>"#
|
||||
);
|
||||
let doc = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {W_NS}><w:body>{body}</w:body></w:document>"#);
|
||||
let doc_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdCx" Type="http://schemas.microsoft.com/office/2014/relationships/chartEx" Target="charts/chartEx1.xml"/><Relationship Id="rIdPic" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/></Relationships>"#;
|
||||
let doc_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdCx" Type="http://schemas.microsoft.com/office/2014/relationships/chartEx" Target="charts/chartEx1.xml"/><Relationship Id="rIdPic" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/><Relationship Id="rIdWeb" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/chart" TargetMode="External"/><Relationship Id="rIdOther" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/other" TargetMode="External"/></Relationships>"#;
|
||||
let chart_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/package" Target="../embeddings/Microsoft_Excel_Worksheet.xlsx"/><Relationship Id="rId2" Type="http://schemas.microsoft.com/office/2011/relationships/chartColorStyle" Target="colors1.xml"/></Relationships>"#;
|
||||
let types = format!(
|
||||
r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="png" ContentType="image/png"/><Default Extension="xlsx" ContentType="{CT_XLSX}"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/charts/chartEx1.xml" ContentType="{CT_CHARTEX}"/><Override PartName="/word/charts/colors1.xml" ContentType="application/vnd.ms-office.chartcolorstyle+xml"/></Types>"#
|
||||
@@ -1827,6 +1842,25 @@ fn office_2016_chart_keeps_its_chart_part_and_fallback_through_saves() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #5267: a link on an Office 2016 chart's drawing is the chart's link (not an earlier choice's),
|
||||
/// and saving keeps it on both branches (the writer rebuilds their frames).
|
||||
#[test]
|
||||
fn a_link_on_an_office_2016_charts_drawing_is_kept() {
|
||||
let mut d = wordcraft_docx::read(&chartex_docx_linked("cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex", true)).unwrap();
|
||||
for _ in 0..2 {
|
||||
let p = d.body[0].as_para().unwrap();
|
||||
let off = p.object_offsets().into_iter().find(|o| matches!(p.object_at(*o), Some(InlineObject::Graphic { .. }))).unwrap();
|
||||
assert_eq!(p.props_of_char(off).link.as_deref(), Some("https://example.com/chart"));
|
||||
let files = unzip(&wordcraft_docx::write(&d).unwrap());
|
||||
let body = text(&files, "word/document.xml");
|
||||
let ids = attr_values(&body, "a:hlinkClick", "r:id");
|
||||
assert_eq!(ids.len(), 2, "on the chart and its fallback picture: {body}");
|
||||
assert_eq!(rel(&text(&files, "word/_rels/document.xml.rels"), &ids[0]).1, "https://example.com/chart");
|
||||
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
|
||||
d = wordcraft_docx::read(&zip(&entries)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resized_office_2016_chart_resizes_its_fallback_too() {
|
||||
let mut d = wordcraft_docx::read(&chartex_docx("cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex")).unwrap();
|
||||
@@ -2462,6 +2496,31 @@ fn east_asian_number_formats_read_label_and_round_trip() {
|
||||
assert_eq!(formats(&back), want);
|
||||
}
|
||||
|
||||
/// #4985: a level with numFmt none and literal text (`RESPONSE:`) labels its paragraph with that
|
||||
/// text, as in Word, and keeps both through a save.
|
||||
#[test]
|
||||
fn a_none_level_s_literal_text_is_its_label() {
|
||||
use wordcraft_doc::numbering::Counters;
|
||||
use wordcraft_doc::section::NumFormat;
|
||||
let numbering = format!(
|
||||
r#"<w:numbering {W_NS}><w:abstractNum w:abstractNumId="0"><w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="none"/><w:lvlText w:val="RESPONSE:"/><w:pPr><w:ind w:left="1440" w:hanging="1440"/></w:pPr></w:lvl></w:abstractNum><w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num></w:numbering>"#
|
||||
);
|
||||
let body = r#"<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>X</w:t></w:r></w:p>"#;
|
||||
let bytes = docx(body, &[("rId1", "numbering", "numbering.xml")], &[("word/numbering.xml", &numbering)]);
|
||||
let label = |d: &Document| {
|
||||
let n = paras(d).first().and_then(|p| p.props.numbering)?;
|
||||
Counters::default().next_label(&d.numbering, n.num, n.level).map(|l| l.0)
|
||||
};
|
||||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||||
assert_eq!(label(&d).as_deref(), Some("RESPONSE:"));
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
back.numbering.level(1, 0).map(|l| (l.format, l.text.as_str(), l.indent, l.hanging)),
|
||||
Some((NumFormat::None, "RESPONSE:", 72.0, 72.0))
|
||||
);
|
||||
assert_eq!(label(&back).as_deref(), Some("RESPONSE:"));
|
||||
}
|
||||
|
||||
/// #464: a bookmark whose end follows the document's final table keeps its end (it goes into
|
||||
/// the table's last cell) and is saved as a matched pair.
|
||||
#[test]
|
||||
@@ -3888,6 +3947,33 @@ fn bidi_theme_slots_follow_the_theme_font_language() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `w:rFonts` fallback list longer than the reader keeps is cut at the last complete family
|
||||
/// within the bound, so the family a real list would pick survives while a hostile list cannot
|
||||
/// cost its whole length in every piece of text drawn in it.
|
||||
#[test]
|
||||
fn a_hostile_fallback_list_keeps_its_leading_families() {
|
||||
let candidates: Vec<String> = (0..500).map(|i| format!("MissingCandidateFont{i}")).collect();
|
||||
let list = format!("{};Source Serif 4", candidates.join(";"));
|
||||
let body = format!(r#"<w:p><w:r><w:rPr><w:rFonts w:ascii="{list}" w:hAnsi="{list}"/></w:rPr><w:t>x</w:t></w:r></w:p>"#);
|
||||
let d = read_body(&body);
|
||||
let c = &d.body[0].as_para().unwrap().runs[0].props;
|
||||
let kept = c.font.as_deref().expect("the leading families are kept");
|
||||
assert!(kept.len() <= 256, "{} bytes kept", kept.len());
|
||||
assert!(kept.starts_with("MissingCandidateFont0"), "{kept}");
|
||||
// Every kept entry is a whole family name: the cut lands on a `;`, never inside a name.
|
||||
assert!(kept.split(';').all(|f| f.starts_with("MissingCandidateFont") || f.contains("Source Serif")), "{kept}");
|
||||
assert!(!kept.ends_with(';'), "no dangling separator: {kept}");
|
||||
}
|
||||
|
||||
/// A real LibreOffice fallback list is read whole: a few families, well under any bound.
|
||||
#[test]
|
||||
fn a_real_fallback_list_is_read_whole() {
|
||||
let body = r#"<w:p><w:r><w:rPr><w:rFonts w:ascii="Liberation Sans;Arial" w:hAnsi="Liberation Sans;Arial"/></w:rPr><w:t>x</w:t></w:r></w:p>"#;
|
||||
let d = read_body(body);
|
||||
let c = &d.body[0].as_para().unwrap().runs[0].props;
|
||||
assert_eq!(c.font.as_deref(), Some("Liberation Sans;Arial"));
|
||||
}
|
||||
|
||||
/// The font table's family classes: Word draws a missing roman font in a serif face.
|
||||
#[test]
|
||||
fn roman_fonts_are_read_from_the_font_table() {
|
||||
@@ -3897,6 +3983,15 @@ fn roman_fonts_are_read_from_the_font_table() {
|
||||
assert_eq!(d.settings.roman_fonts, ["Brand Serif"]);
|
||||
}
|
||||
|
||||
/// Semicolon-separated roman font fallback lists in `fontTable.xml` register each candidate family.
|
||||
#[test]
|
||||
fn roman_fonts_split_fallback_lists() {
|
||||
let fonts = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:font w:name="Liberation Serif;Times New Roman"><w:family w:val="roman"/></w:font></w:fonts>"#;
|
||||
let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable", "fontTable.xml")];
|
||||
let d = wordcraft_docx::read(&docx("<w:p/>", &rels, &[("word/fontTable.xml", fonts)])).unwrap();
|
||||
assert_eq!(d.settings.roman_fonts, ["Liberation Serif", "Times New Roman"]);
|
||||
}
|
||||
|
||||
/// `w:doNotUseHTMLParagraphAutoSpacing` in the settings' `w:compat`: paragraph spacing adds up.
|
||||
#[test]
|
||||
fn html_auto_spacing_off_is_read_and_kept() {
|
||||
@@ -4295,6 +4390,54 @@ fn zero_effects_stay_zero_rotated() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A turned picture whose file keeps no room for the turn (no or a zero `wp:effectExtent`) is
|
||||
/// marked so its line keeps to its frame as in Word, and saves that way; one with the room keeps
|
||||
/// its rotated bounds.
|
||||
#[test]
|
||||
fn bare_turns_are_kept_through_a_save() {
|
||||
let pic = |effects: &str| {
|
||||
format!(
|
||||
r#"<w:p><w:r><w:drawing><wp:inline><wp:extent cx="1524000" cy="762000"/>{effects}<wp:docPr id="1" name="Synthetic picture"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rId1"/></pic:blipFill><pic:spPr><a:xfrm rot="2700000"><a:off x="0" y="0"/><a:ext cx="1524000" cy="762000"/></a:xfrm></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"#
|
||||
)
|
||||
};
|
||||
let read =
|
||||
|body: &str| wordcraft_docx::read(&docx(body, &[("rId1", "image", "media/marker.png")], &[("word/media/marker.png", "synthetic")])).unwrap();
|
||||
let bare = |d: &Document| paras(d)[0].objects[0].frame().unwrap().2.bare_turn;
|
||||
for effects in ["", r#"<wp:effectExtent l="0" t="0" r="0" b="0"/>"#] {
|
||||
let d = read(&pic(effects));
|
||||
assert!(bare(&d), "{effects:?}");
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let body = text(&unzip(&out), "word/document.xml");
|
||||
assert_eq!(attr_values(&body, "wp:effectExtent", "t"), ["0"]);
|
||||
assert!(bare(&wordcraft_docx::read(&out).unwrap()));
|
||||
}
|
||||
// 120 × 60 pt at 45°: Word's rotated-bounds room, 3.64 pt wider and 33.64 pt taller each side.
|
||||
let d = read(&pic(r#"<wp:effectExtent l="46210" t="427210" r="46210" b="427210"/>"#));
|
||||
assert!(!bare(&d));
|
||||
let mut d = read(&pic(""));
|
||||
let f = first_object(&mut d).float_mut().unwrap();
|
||||
f.set_spin(wordcraft_geom::Spin::new(30.0, false, false));
|
||||
assert!(!f.bare_turn, "turned here: the rotated bounds again");
|
||||
// A shadow's room added later: saved, and so laid out, with the turn's room again.
|
||||
let mut d = read(&pic(""));
|
||||
let f = first_object(&mut d).float_mut().unwrap();
|
||||
f.effect = [2.0; 4];
|
||||
assert!(!f.is_bare_turn());
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
assert_ne!(attr_values(&text(&unzip(&out), "word/document.xml"), "wp:effectExtent", "t"), ["0"]);
|
||||
// Made floating: saved with the turn's room, so it opens again as an ordinary turn.
|
||||
let mut d = read(&pic(""));
|
||||
let f = first_object(&mut d).float_mut().unwrap();
|
||||
f.wrap = wordcraft_doc::para::Wrap::Square;
|
||||
assert!(!f.is_bare_turn());
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
assert_ne!(attr_values(&text(&unzip(&out), "word/document.xml"), "wp:effectExtent", "t"), ["0"]);
|
||||
assert!(!bare(&wordcraft_docx::read(&out).unwrap()));
|
||||
// Only inline ones: a floating one keeps text clear of its rotated bounds as before.
|
||||
let anchored = pic("").replace("<wp:inline>", "<wp:anchor>").replace("</wp:inline>", "</wp:anchor>");
|
||||
assert!(!bare(&read(&anchored)));
|
||||
}
|
||||
|
||||
/// The math namespace, declared on the math element itself (the fixture root doesn't).
|
||||
const M_NS: &str = r#"xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math""#;
|
||||
|
||||
|
||||
@@ -21,8 +21,10 @@ mod heading_levels;
|
||||
mod legacy_forms;
|
||||
mod ligatures;
|
||||
mod malformed;
|
||||
mod nested_block_controls;
|
||||
mod note_comments;
|
||||
mod passthrough;
|
||||
mod picture_links;
|
||||
mod positional_tabs;
|
||||
mod privacy;
|
||||
mod roundtrip;
|
||||
@@ -34,6 +36,7 @@ mod section_page_number_type;
|
||||
mod section_text_direction;
|
||||
mod section_valign;
|
||||
mod settings_merge;
|
||||
mod strict_drawings;
|
||||
mod text_alignment;
|
||||
mod text_boxes;
|
||||
mod toc_ranges;
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//! A body-level content control nested directly in another body-level one stays body-level when
|
||||
//! saved, even when it holds a single paragraph (ECMA-376 §17.5.2.29-30; [MS-DOCX] `w15`
|
||||
//! repeating sections, which must wrap whole paragraphs or rows). All markup here is synthetic.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use wordcraft_doc::{ContentControl, Document, StoryRef};
|
||||
use wordcraft_docx::{read, write};
|
||||
|
||||
const NS: &str =
|
||||
r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml""#;
|
||||
const ROOT_RELS: &str = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
|
||||
fn package(body: &str) -> Vec<u8> {
|
||||
let doc = format!(r#"<w:document {NS}><w:body>{body}</w:body></w:document>"#);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in [("_rels/.rels", ROOT_RELS.as_bytes()), ("word/document.xml", doc.as_bytes())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn document_xml(docx: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(docx)).unwrap();
|
||||
let mut s = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn t(s: &str) -> String {
|
||||
format!(r#"<w:r><w:t xml:space="preserve">{s}</w:t></w:r>"#)
|
||||
}
|
||||
fn p(inner: &str) -> String {
|
||||
format!("<w:p>{inner}</w:p>")
|
||||
}
|
||||
fn sdt(pr: &str, content: &str) -> String {
|
||||
format!("<w:sdt><w:sdtPr>{pr}</w:sdtPr><w:sdtContent>{content}</w:sdtContent></w:sdt>")
|
||||
}
|
||||
|
||||
fn controls(d: &Document) -> Vec<ContentControl> {
|
||||
let mut out = Vec::new();
|
||||
wordcraft_doc::control::walk_controls(&d.body, &mut |c| out.push(c.clone()));
|
||||
out
|
||||
}
|
||||
|
||||
/// How many `w:p` elements are open at byte `at` of `xml`.
|
||||
fn open_paragraphs(xml: &str, at: usize) -> usize {
|
||||
let head = &xml[..at];
|
||||
let opened = head.matches("<w:p>").count() + head.matches("<w:p ").count();
|
||||
opened - head.matches("</w:p>").count()
|
||||
}
|
||||
|
||||
/// Paragraph depth at the start of the `w:sdt` whose `w:sdtPr` holds `marker`.
|
||||
fn depth_of_control(xml: &str, marker: &str) -> usize {
|
||||
let at = xml.find(marker).unwrap_or_else(|| panic!("{marker} not written:\n{xml}"));
|
||||
let start = xml[..at].rfind("<w:sdt>").expect("control start");
|
||||
open_paragraphs(xml, start)
|
||||
}
|
||||
|
||||
fn round_trip(body: &str) -> (Document, Document, String) {
|
||||
let d = read(&package(body)).unwrap();
|
||||
let bytes = write(&d).unwrap();
|
||||
let back = read(&bytes).unwrap();
|
||||
(d, back, document_xml(&bytes))
|
||||
}
|
||||
|
||||
fn repeating_section() -> String {
|
||||
let field = sdt(r#"<w:alias w:val="note"/><w:id w:val="3"/><w:text/>"#, &t("note 1"));
|
||||
let item = sdt(r#"<w:alias w:val="item"/><w:id w:val="2"/><w15:repeatingSectionItem/>"#, &p(&format!("{field}{}", t(" "))));
|
||||
let section = sdt(r#"<w:alias w:val="notes"/><w:id w:val="1"/><w15:repeatingSection/>"#, &item);
|
||||
format!("{}{section}{}", p(&t("Notes")), p(&t("End")))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeating_section_around_one_paragraph_stays_around_the_paragraph() {
|
||||
let (_, _, xml) = round_trip(&repeating_section());
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="notes"/>"#), 0, "section written inside a paragraph:\n{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="item"/>"#), 0, "item written inside a paragraph:\n{xml}");
|
||||
// The text control stays inside the paragraph, and the item holds that paragraph.
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="note"/>"#), 1);
|
||||
assert!(xml.contains("<w15:repeatingSectionItem/></w:sdtPr><w:sdtContent><w:p"), "{xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeating_section_round_trips_its_controls_and_text() {
|
||||
let (d, back, _) = round_trip(&repeating_section());
|
||||
assert_eq!(controls(&back), controls(&d));
|
||||
assert_eq!(back.plain_text(StoryRef::Body), d.plain_text(StoryRef::Body));
|
||||
assert_eq!(back.body.len(), d.body.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_nested_block_controls_around_one_paragraph_stay_block_level() {
|
||||
let inner = sdt(r#"<w:alias w:val="inner"/><w:id w:val="11"/>"#, &p(&t("Only paragraph")));
|
||||
let outer = sdt(r#"<w:alias w:val="outer"/><w:id w:val="10"/>"#, &inner);
|
||||
let (d, back, xml) = round_trip(&format!("{outer}{}", p(&t("After"))));
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="outer"/>"#), 0, "{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inner"/>"#), 0, "{xml}");
|
||||
assert_eq!(controls(&back), controls(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_block_controls_in_a_table_cell_stay_block_level() {
|
||||
let inner = sdt(r#"<w:alias w:val="cellInner"/><w:id w:val="21"/>"#, &p(&t("In cell")));
|
||||
let outer = sdt(r#"<w:alias w:val="cellOuter"/><w:id w:val="20"/>"#, &inner);
|
||||
let table = format!(r#"<w:tbl><w:tblGrid><w:gridCol w:w="4000"/></w:tblGrid><w:tr><w:tc>{outer}</w:tc></w:tr></w:tbl>"#);
|
||||
let (_, _, xml) = round_trip(&format!("{table}{}", p(&t("After"))));
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="cellOuter"/>"#), 0, "{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="cellInner"/>"#), 0, "{xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_level_control_stays_inside_its_paragraph() {
|
||||
let field = sdt(r#"<w:alias w:val="inline"/><w:id w:val="30"/>"#, &t("value"));
|
||||
let (_, _, xml) = round_trip(&p(&format!("{}{field}", t("Name: "))));
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inline"/>"#), 1, "{xml}");
|
||||
}
|
||||
|
||||
/// A bound repeating section as Word writes one at the end of a form, optionally followed by
|
||||
/// the body-level `_GoBack` bookmark Word leaves after the last control.
|
||||
fn bound_section_body(bound: bool, bookmark: bool) -> String {
|
||||
let section_binding = if bound {
|
||||
r#"<w15:dataBinding w:prefixMappings="" w:xpath="/a[1]/n[1]" w:storeItemID="{00000000-0000-0000-0000-0000000000AA}"/>"#
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let trailing = if bookmark { r#"<w:bookmarkStart w:id="0" w:name="_GoBack"/><w:bookmarkEnd w:id="0"/>"# } else { "" };
|
||||
let bind = |x: &str| format!(r#"<w:dataBinding w:prefixMappings="" w:xpath="{x}" w:storeItemID="{{00000000-0000-0000-0000-0000000000AA}}"/>"#);
|
||||
let field = format!(
|
||||
r#"<w:sdt><w:sdtPr><w:alias w:val="note"/><w:placeholder><w:docPart w:val="DefaultPlaceholder_1"/></w:placeholder>{}<w:text/></w:sdtPr><w:sdtEndPr/><w:sdtContent>{}</w:sdtContent></w:sdt>"#,
|
||||
if bound { bind("/a[1]/n[1]") } else { String::new() },
|
||||
t("note 1")
|
||||
);
|
||||
let item = format!(
|
||||
r#"<w:sdt><w:sdtPr><w:alias w:val="item"/><w:placeholder><w:docPart w:val="DefaultPlaceholder_2"/></w:placeholder><w15:repeatingSectionItem/></w:sdtPr><w:sdtEndPr/><w:sdtContent>{}</w:sdtContent></w:sdt>"#,
|
||||
p(&format!("{field}{}", t(" ")))
|
||||
);
|
||||
let section = format!(
|
||||
r#"<w:sdt><w:sdtPr><w:alias w:val="notes"/>{section_binding}<w15:repeatingSection/></w:sdtPr><w:sdtContent>{item}</w:sdtContent></w:sdt>"#
|
||||
);
|
||||
format!(r#"{}{section}{trailing}<w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr>"#, p(&t("Notes")))
|
||||
}
|
||||
|
||||
fn assert_section_block_level(body: &str) {
|
||||
let (d, back, xml) = round_trip(body);
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="notes"/>"#), 0, "section written inside a paragraph:\n{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="item"/>"#), 0, "item written inside a paragraph:\n{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="note"/>"#), 1, "{xml}");
|
||||
assert_eq!(controls(&back), controls(&d));
|
||||
assert_eq!(back.plain_text(StoryRef::Body), d.plain_text(StoryRef::Body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bound_repeating_section_followed_by_a_body_bookmark_stays_block_level() {
|
||||
assert_section_block_level(&bound_section_body(true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bound_repeating_section_at_the_end_of_the_body_stays_block_level() {
|
||||
assert_section_block_level(&bound_section_body(true, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeating_section_followed_by_a_body_bookmark_stays_block_level() {
|
||||
assert_section_block_level(&bound_section_body(false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_control_followed_by_a_body_bookmark_stays_around_its_paragraph() {
|
||||
let inner = sdt(r#"<w:alias w:val="inner"/><w:id w:val="41"/>"#, &p(&t("Last paragraph")));
|
||||
let outer = sdt(r#"<w:alias w:val="outer"/><w:id w:val="40"/>"#, &inner);
|
||||
let body = format!(r#"{}{outer}<w:bookmarkStart w:id="0" w:name="_GoBack"/><w:bookmarkEnd w:id="0"/>"#, p(&t("First")));
|
||||
let (d, back, xml) = round_trip(&body);
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="outer"/>"#), 0, "{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inner"/>"#), 0, "{xml}");
|
||||
assert_eq!(controls(&back), controls(&d));
|
||||
// The bookmark is kept.
|
||||
assert!(xml.contains(r#"w:name="_GoBack""#), "{xml}");
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! A picture's hyperlink (#5267): Word keeps it on the drawing (`wp:docPr/a:hlinkClick`,
|
||||
//! ECMA-376 Part 1 §20.4.2.5) rather than in a `w:hyperlink` around the run. Original
|
||||
//! synthetic packages; the 2×2 picture is generated in code.
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::{CharProps, Document, InlineObject, Pos};
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
image::RgbaImage::from_pixel(2, 2, image::Rgba([20, 90, 200, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// An inline picture's run; `link` goes into its `wp:docPr`.
|
||||
fn picture_run(id: u32, link: &str) -> String {
|
||||
format!(
|
||||
r#"<w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="254000" cy="254000"/><wp:docPr id="{id}" name="Picture {id}">{link}</wp:docPr><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="square.png"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdPic"/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="254000" cy="254000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn package(body: &str) -> Vec<u8> {
|
||||
let xml = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><w:body>{body}<w:sectPr/></w:body></w:document>"#
|
||||
);
|
||||
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdPic" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/square.png"/><Relationship Id="rIdWeb" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/picture" TargetMode="External"/><Relationship Id="rIdRun" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/run" TargetMode="External"/></Relationships>"#;
|
||||
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="png" ContentType="image/png"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
|
||||
let png = png();
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in [
|
||||
("[Content_Types].xml", types.as_bytes()),
|
||||
("_rels/.rels", rels.as_bytes()),
|
||||
("word/document.xml", xml.as_bytes()),
|
||||
("word/_rels/document.xml.rels", doc_rels.as_bytes()),
|
||||
("word/media/square.png", png.as_slice()),
|
||||
] {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(bytes).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// The link on each picture of the first paragraph, in order.
|
||||
fn picture_links(doc: &Document) -> Vec<Option<String>> {
|
||||
let p = doc.body[0].as_para().unwrap();
|
||||
p.object_offsets()
|
||||
.into_iter()
|
||||
.filter(|off| matches!(p.object_at(*off), Some(InlineObject::Image { .. })))
|
||||
.map(|off| p.props_of_char(off).link.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn part(bytes: &[u8], name: &str) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name(name).unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_on_a_pictures_drawing_is_read() {
|
||||
let body = format!(
|
||||
"<w:p>{}{}{}<w:hyperlink r:id=\"rIdRun\">{}</w:hyperlink></w:p>",
|
||||
// Word's way: the link on the drawing.
|
||||
picture_run(1, r#"<a:hlinkClick r:id="rIdWeb"/>"#),
|
||||
// A click naming a relationship that is no hyperlink, and one naming none.
|
||||
picture_run(2, r#"<a:hlinkClick r:id="rIdPic"/>"#),
|
||||
picture_run(3, r#"<a:hlinkClick r:id="rIdMissing"/>"#),
|
||||
// A link around the run wins over the drawing's.
|
||||
picture_run(4, r#"<a:hlinkClick r:id="rIdWeb"/>"#),
|
||||
);
|
||||
let doc = wordcraft_docx::read(&package(&body)).unwrap();
|
||||
assert_eq!(picture_links(&doc), [Some("https://example.com/picture".to_string()), None, None, Some("https://example.com/run".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linked_picture_is_saved_with_its_link_on_the_drawing_too() {
|
||||
let mut doc = Document::new();
|
||||
doc.media.insert("square.png".into(), Arc::new(png()));
|
||||
let picture = || InlineObject::Image {
|
||||
media: "square.png".into(),
|
||||
w: 20.0,
|
||||
h: 20.0,
|
||||
alt: String::new(),
|
||||
float: Default::default(),
|
||||
crop: [0.0; 4],
|
||||
ole: None,
|
||||
fx: Default::default(),
|
||||
};
|
||||
let linked = |l: &str| CharProps { link: Some(l.to_string()), ..Default::default() };
|
||||
// A place in this document stays a `w:hyperlink w:anchor` only: `a:hlinkClick` names a relationship.
|
||||
doc.insert_object(&Pos::body(0, 0), picture(), &linked("#Spot")).unwrap();
|
||||
doc.insert_object(&Pos::body(0, 0), picture(), &linked("https://example.com/saved")).unwrap();
|
||||
let bytes = wordcraft_docx::write(&doc).unwrap();
|
||||
let xml = part(&bytes, "word/document.xml");
|
||||
let clicks: Vec<&str> = xml.match_indices("<a:hlinkClick ").map(|(i, _)| &xml[i..]).collect();
|
||||
assert_eq!(clicks.len(), 1, "{xml}");
|
||||
let rid = clicks[0].split('"').nth(1).unwrap();
|
||||
let docpr = xml.find("<wp:docPr ").unwrap();
|
||||
assert!(docpr < xml.find("<a:hlinkClick ").unwrap() && xml.find("<a:hlinkClick ").unwrap() < xml.find("</wp:docPr>").unwrap(), "{xml}");
|
||||
let rels = part(&bytes, "word/_rels/document.xml.rels");
|
||||
assert!(rels.contains(&format!(r#"Id="{rid}""#)) && rels.contains(r#"Target="https://example.com/saved""#), "{rels}");
|
||||
// The run's own hyperlink names the same relationship, so the address is stored once.
|
||||
assert_eq!(rels.matches("https://example.com/saved").count(), 1, "{rels}");
|
||||
let back = wordcraft_docx::read(&bytes).unwrap();
|
||||
assert_eq!(picture_links(&back), [Some("https://example.com/saved".to_string()), Some("#Spot".to_string())]);
|
||||
// The drawing's link alone (as Word writes it) reads back the same.
|
||||
let without_run_link = xml.replace(&format!(r#"<w:hyperlink r:id="{rid}" w:history="1">"#), "").replacen("</w:hyperlink>", "", 1);
|
||||
assert_ne!(without_run_link, xml);
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())).unwrap();
|
||||
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
let name = f.name().to_string();
|
||||
let mut data = Vec::new();
|
||||
f.read_to_end(&mut data).unwrap();
|
||||
if name == "word/document.xml" {
|
||||
data = without_run_link.clone().into_bytes();
|
||||
}
|
||||
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
out.write_all(&data).unwrap();
|
||||
}
|
||||
let edited = out.finish().unwrap().into_inner();
|
||||
assert_eq!(picture_links(&wordcraft_docx::read(&edited).unwrap())[0], Some("https://example.com/saved".to_string()));
|
||||
}
|
||||
@@ -6,8 +6,8 @@ use wordcraft_doc::fill::Fill;
|
||||
use wordcraft_doc::numbering::ListKind;
|
||||
use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::{
|
||||
Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign, TabLeader,
|
||||
TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
|
||||
Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, ParagraphTextDirection, Rgb, RowProps,
|
||||
TabAlign, TabLeader, TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
|
||||
};
|
||||
use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, PageVAlign, SectionProps, SectionStart};
|
||||
use wordcraft_doc::styles::{Style, StyleKind};
|
||||
@@ -195,6 +195,7 @@ fn every_para_prop_round_trips() {
|
||||
suppress_hyphens: Some(true),
|
||||
suppress_line_numbers: Some(true),
|
||||
bidi: Some(false),
|
||||
text_direction: Some(ParagraphTextDirection::TbRl),
|
||||
drop_cap: None,
|
||||
drop_cap_margin: None,
|
||||
drop_cap_distance: None,
|
||||
|
||||
@@ -18,6 +18,25 @@ fn docx(body: &str) -> Vec<u8> {
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn docx_with_styles(body: &str, styles: &str) -> Vec<u8> {
|
||||
let mut bytes = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let document_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
for (name, value) in [
|
||||
("_rels/.rels", root.as_bytes()),
|
||||
("word/_rels/document.xml.rels", document_rels.as_bytes()),
|
||||
("word/document.xml", doc.as_bytes()),
|
||||
("word/styles.xml", styles.as_bytes()),
|
||||
] {
|
||||
bytes.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
bytes.write_all(value).unwrap();
|
||||
}
|
||||
bytes.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
@@ -59,3 +78,52 @@ fn explicit_horizontal_direction_is_kept_in_sections_and_cells() {
|
||||
let plain = wordcraft_docx::read(&docx(r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr/>"#)).unwrap();
|
||||
assert!(!document_xml(&wordcraft_docx::write(&plain).unwrap()).contains("textDirection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_text_direction_round_trips_through_two_saves_and_json() {
|
||||
use wordcraft_doc::props::ParaProps;
|
||||
use wordcraft_doc::props::ParagraphTextDirection;
|
||||
|
||||
let values = ["lrTb", "lrTbV", "tb", "tbRl", "tbRlV", "tbLr", "tbLrV", "btLr", "tbV", "rl", "rlV", "lr", "lrV"];
|
||||
let mut body: String =
|
||||
values.iter().map(|value| format!(r#"<w:p><w:pPr><w:textDirection w:val="{value}"/></w:pPr><w:r><w:t>{value}</w:t></w:r></w:p>"#)).collect();
|
||||
body.push_str(r#"<w:p><w:r><w:t>Inherited</w:t></w:r></w:p>"#);
|
||||
let d = wordcraft_docx::read(&docx(&body)).unwrap();
|
||||
let paras: Vec<_> = d.body.iter().filter_map(|b| b.as_para()).collect();
|
||||
for (para, value) in paras.iter().zip(values) {
|
||||
assert_eq!(para.props.text_direction, ParagraphTextDirection::from_ooxml(value), "{value}");
|
||||
}
|
||||
assert_eq!(paras[values.len()].props.text_direction, None);
|
||||
let json = serde_json::to_value(¶s[0].props).unwrap();
|
||||
assert_eq!(json["textDirection"], "lrTb");
|
||||
let decoded: ParaProps = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(decoded.text_direction, Some(ParagraphTextDirection::LrTb));
|
||||
|
||||
let first = wordcraft_docx::write(&d).unwrap();
|
||||
let xml = document_xml(&first);
|
||||
for value in values {
|
||||
assert!(xml.contains(&format!(r#"<w:textDirection w:val="{value}"/>"#)), "{xml}");
|
||||
}
|
||||
assert_eq!(xml.matches("w:textDirection").count(), values.len(), "inherited direction stays absent: {xml}");
|
||||
let second = wordcraft_docx::write(&wordcraft_docx::read(&first).unwrap()).unwrap();
|
||||
let twice = document_xml(&second);
|
||||
assert_eq!(twice.matches("w:textDirection").count(), values.len(), "{twice}");
|
||||
for value in values {
|
||||
assert!(twice.contains(&format!(r#"<w:textDirection w:val="{value}"/>"#)), "{twice}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_style_text_direction_is_preserved_and_direct_value_overrides_it() {
|
||||
let styles = r#"<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:style w:type="paragraph" w:styleId="Vertical"><w:name w:val="Vertical"/><w:pPr><w:textDirection w:val="tbRl"/></w:pPr></w:style></w:styles>"#;
|
||||
let body = r#"<w:p><w:pPr><w:pStyle w:val="Vertical"/></w:pPr><w:r><w:t>Style</w:t></w:r></w:p><w:p><w:pPr><w:pStyle w:val="Vertical"/><w:textDirection w:val="lrTb"/></w:pPr><w:r><w:t>Override</w:t></w:r></w:p>"#;
|
||||
let d = wordcraft_docx::read(&docx_with_styles(body, styles)).unwrap();
|
||||
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
|
||||
assert_eq!(xml.matches(r#"<w:pStyle w:val="Vertical"/>"#).count(), 2, "both paragraphs keep their style: {xml}");
|
||||
assert!(xml.contains(r#"<w:pStyle w:val="Vertical"/><w:textDirection w:val="lrTb"/>"#), "direct horizontal is explicit: {xml}");
|
||||
assert_eq!(xml.matches("w:textDirection").count(), 1, "only the direct paragraph writes a direction: {xml}");
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_docx::write(&d).unwrap())).unwrap();
|
||||
let mut styles_xml = String::new();
|
||||
z.by_name("word/styles.xml").unwrap().read_to_string(&mut styles_xml).unwrap();
|
||||
assert!(styles_xml.contains(r#"<w:textDirection w:val="tbRl"/>"#), "style value survives: {styles_xml}");
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Shapes, text boxes, groups and canvases in a Strict document (#3548). ISO/IEC 29500 Strict
|
||||
//! puts them in the one WordprocessingDrawing namespace, where Transitional documents use Word
|
||||
//! 2010's `wps`, `wpg` and `wpc` namespaces. Fixtures are original and hand-written from
|
||||
//! ECMA-376 Part 1, 5th edition (`dml-wordprocessingDrawing.xsd`, Strict and Transitional): each
|
||||
//! drawing is written once and filled in with either set of namespaces.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use wordcraft_doc::{Document, InlineObject, PartKind};
|
||||
|
||||
const STRICT_NS: &str = r#"xmlns:w="http://purl.oclc.org/ooxml/wordprocessingml/main" xmlns:r="http://purl.oclc.org/ooxml/officeDocument/relationships" xmlns:wp="http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing" xmlns:a="http://purl.oclc.org/ooxml/drawingml/main" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml" w:conformance="strict""#;
|
||||
const TRANSITIONAL_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas""#;
|
||||
const STRICT_RELS: &str = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://purl.oclc.org/ooxml/officeDocument/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
const TRANSITIONAL_RELS: &str = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
|
||||
/// How a fixture names its drawing elements: the shape, group and canvas prefixes, and the
|
||||
/// text box content element.
|
||||
struct Names {
|
||||
ns: &'static str,
|
||||
rels: &'static str,
|
||||
shape: &'static str,
|
||||
group: &'static str,
|
||||
canvas: &'static str,
|
||||
content: &'static str,
|
||||
}
|
||||
|
||||
/// Word's Strict output: everything in `wp:`, text box content in `wne:txbxContent`.
|
||||
const STRICT_WORD: Names = Names { ns: STRICT_NS, rels: STRICT_RELS, shape: "wp", group: "wp", canvas: "wp", content: "wne:txbxContent" };
|
||||
/// The Strict schema's own names: the text box content in `wp:` too.
|
||||
const STRICT_SCHEMA: Names = Names { ns: STRICT_NS, rels: STRICT_RELS, shape: "wp", group: "wp", canvas: "wp", content: "wp:txbxContent" };
|
||||
/// The same drawings in a Transitional document.
|
||||
const TRANSITIONAL: Names =
|
||||
Names { ns: TRANSITIONAL_NS, rels: TRANSITIONAL_RELS, shape: "wps", group: "wpg", canvas: "wpc", content: "w:txbxContent" };
|
||||
|
||||
fn zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in entries {
|
||||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn solid(rgb: &str) -> String {
|
||||
format!(r#"<a:solidFill><a:srgbClr val="{rgb}"/></a:solidFill>"#)
|
||||
}
|
||||
|
||||
/// A `wsp` at (`x`, `y`) EMUs in its group, `cx` × `cy`. `text` gives it a text box.
|
||||
fn shape(n: &Names, prst: &str, (x, y, cx, cy): (u32, u32, u32, u32), fill: &str, text: Option<&str>) -> String {
|
||||
let s = n.shape;
|
||||
let connector = prst.contains("Connector");
|
||||
let nv = if connector {
|
||||
format!("<{s}:cNvCnPr/>")
|
||||
} else if text.is_some() {
|
||||
format!(r#"<{s}:cNvSpPr txBox="1"/>"#)
|
||||
} else {
|
||||
format!("<{s}:cNvSpPr/>")
|
||||
};
|
||||
let fill = if connector { String::new() } else { solid(fill) };
|
||||
let line =
|
||||
if connector { r#"<a:ln w="25400"><a:solidFill><a:srgbClr val="117A65"/></a:solidFill><a:tailEnd type="triangle"/></a:ln>"# } else { "" };
|
||||
let txbx = match text {
|
||||
Some(t) => {
|
||||
let c = n.content;
|
||||
format!(r#"<{s}:txbx><{c}><w:p><w:r><w:t>{t}</w:t></w:r></w:p></{c}></{s}:txbx>"#)
|
||||
}
|
||||
None => String::new(),
|
||||
};
|
||||
format!(
|
||||
r#"<{s}:wsp><{s}:cNvPr id="{cx}" name="{prst}"/>{nv}<{s}:spPr><a:xfrm><a:off x="{x}" y="{y}"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm><a:prstGeom prst="{prst}"><a:avLst/></a:prstGeom>{fill}{line}</{s}:spPr>{txbx}<{s}:bodyPr anchor="ctr"/></{s}:wsp>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Drawing `id`, anchored `x` EMUs from the page's left edge, 1 in × ½ in.
|
||||
fn anchor(id: u32, x: u32, uri: &str, payload: &str) -> String {
|
||||
format!(
|
||||
r#"<w:r><w:drawing><wp:anchor distT="0" distB="0" distL="0" distR="0" simplePos="0" relativeHeight="{id}" behindDoc="0" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="page"><wp:posOffset>{x}</wp:posOffset></wp:positionH><wp:positionV relativeFrom="page"><wp:posOffset>914400</wp:posOffset></wp:positionV><wp:extent cx="914400" cy="457200"/><wp:effectExtent l="0" t="0" r="0" b="0"/><wp:wrapNone/><wp:docPr id="{id}" name="Drawing {id}"/><wp:cNvGraphicFramePr/><a:graphic><a:graphicData uri="{uri}">{payload}</a:graphicData></a:graphic></wp:anchor></w:drawing></w:r>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// A rectangle, a text box, an arrow, a group holding a nested group, and a canvas holding a
|
||||
/// shape and a group, each 1 in × ½ in.
|
||||
fn package(n: &Names) -> Vec<u8> {
|
||||
const WPS: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingShape";
|
||||
const WPG: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup";
|
||||
const WPC: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas";
|
||||
let full = (0, 0, 914_400, 457_200);
|
||||
let half = (0, 0, 457_200, 457_200);
|
||||
let g = n.group;
|
||||
let group_xfrm = |(x, y, cx, cy): (u32, u32, u32, u32)| {
|
||||
format!(
|
||||
r#"<{g}:cNvGrpSpPr/><{g}:grpSpPr><a:xfrm><a:off x="{x}" y="{y}"/><a:ext cx="{cx}" cy="{cy}"/><a:chOff x="0" y="0"/><a:chExt cx="{cx}" cy="{cy}"/></a:xfrm>{}</{g}:grpSpPr>"#,
|
||||
solid("F39C12")
|
||||
)
|
||||
};
|
||||
let group = format!(
|
||||
"<{g}:wgp>{}{}<{g}:grpSp>{}{}</{g}:grpSp></{g}:wgp>",
|
||||
group_xfrm(full),
|
||||
shape(n, "ellipse", half, "28B463", None),
|
||||
group_xfrm((457_200, 0, 457_200, 457_200)),
|
||||
shape(n, "triangle", half, "8E44AD", None),
|
||||
);
|
||||
let c = n.canvas;
|
||||
let canvas = format!(
|
||||
r#"<{c}:wpc><{c}:bg>{}</{c}:bg><{c}:whole><a:ln w="12700">{}</a:ln></{c}:whole>{}<{g}:wgp>{}{}</{g}:wgp></{c}:wpc>"#,
|
||||
solid("FDF2E9"),
|
||||
solid("A04000"),
|
||||
shape(n, "roundRect", (45_720, 45_720, 365_760, 365_760), "16A085", None),
|
||||
group_xfrm((457_200, 45_720, 365_760, 365_760)),
|
||||
shape(n, "diamond", (0, 0, 365_760, 365_760), "2E86C1", None),
|
||||
);
|
||||
let drawings = [
|
||||
anchor(1, 914_400, WPS, &shape(n, "rect", full, "C0392B", None)),
|
||||
anchor(2, 2_286_000, WPS, &shape(n, "rect", full, "EAF2F8", Some("Strict text box"))),
|
||||
anchor(3, 3_657_600, WPS, &shape(n, "straightConnector1", full, "", None)),
|
||||
anchor(4, 5_029_200, WPG, &group),
|
||||
anchor(5, 6_400_800, WPC, &canvas),
|
||||
];
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {}><w:body><w:p><w:r><w:t>Shapes</w:t></w:r>{}</w:p><w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr></w:body></w:document>"#,
|
||||
n.ns,
|
||||
drawings.concat()
|
||||
);
|
||||
zip(&[("_rels/.rels", n.rels.as_bytes()), ("word/document.xml", doc.as_bytes())])
|
||||
}
|
||||
|
||||
fn objects(d: &Document) -> Vec<InlineObject> {
|
||||
d.body[0].as_para().unwrap().objects.clone()
|
||||
}
|
||||
|
||||
/// The text of each text box story, in reading order.
|
||||
fn box_texts(d: &Document) -> Vec<String> {
|
||||
let mut ids = Vec::new();
|
||||
d.objects_in_reading_order(&mut |o| ids.extend(o.text_boxes()));
|
||||
ids.iter()
|
||||
.filter_map(|id| d.parts.get(id).filter(|p| p.kind == PartKind::TextBox))
|
||||
.map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect::<Vec<_>>().join("\n"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_shapes_read_like_their_transitional_twins() {
|
||||
let transitional = wordcraft_docx::read(&package(&TRANSITIONAL)).unwrap();
|
||||
let want = objects(&transitional);
|
||||
// The twin itself reads every drawing: a rectangle, a text box, an arrow, a group whose
|
||||
// nested group is flattened into it, and a canvas with its background.
|
||||
assert_eq!(want.len(), 5, "{want:#?}");
|
||||
assert!(matches!(&want[0], InlineObject::Shape { story: None, freeform: None, .. }), "{:?}", want[0]);
|
||||
assert!(matches!(&want[1], InlineObject::Shape { story: Some(_), .. }), "{:?}", want[1]);
|
||||
assert!(matches!(&want[3], InlineObject::Group { children, canvas: None, .. } if children.len() == 2), "{:?}", want[3]);
|
||||
assert!(
|
||||
matches!(&want[4], InlineObject::Group { children, canvas: Some(c), .. } if children.len() == 2 && c.fill.is_some() && c.stroke.is_some()),
|
||||
"{:?}",
|
||||
want[4]
|
||||
);
|
||||
assert_eq!(box_texts(&transitional), ["Strict text box"]);
|
||||
for (name, names) in [("Word's Strict names", &STRICT_WORD), ("the Strict schema's names", &STRICT_SCHEMA)] {
|
||||
let strict = wordcraft_docx::read(&package(names)).unwrap();
|
||||
assert_eq!(objects(&strict), want, "{name}");
|
||||
assert_eq!(box_texts(&strict), ["Strict text box"], "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_shapes_are_saved_and_reopened() {
|
||||
let strict = wordcraft_docx::read(&package(&STRICT_WORD)).unwrap();
|
||||
let saved = wordcraft_docx::write(&strict).unwrap();
|
||||
let xml = document_xml(&saved);
|
||||
for name in ["<wps:wsp>", "<wpg:wgp>", "<wpc:wpc>", "<wpc:bg>", "<w:txbxContent>", "<wps:cNvCnPr"] {
|
||||
assert!(xml.contains(name), "{name} missing from the saved document");
|
||||
}
|
||||
let reopened = wordcraft_docx::read(&saved).unwrap();
|
||||
assert_eq!(objects(&reopened), objects(&strict));
|
||||
assert_eq!(box_texts(&reopened), ["Strict text box"]);
|
||||
}
|
||||
@@ -132,6 +132,15 @@ fn vml_text_boxes_keep_their_inset_fit_and_anchor() {
|
||||
shape("", r#"o:insetmode="auto" inset="0,0,0,0""#),
|
||||
// In a group, a member's margins go with that member's text, not an empty box's before it.
|
||||
format!(r#"<w:p><w:r><w:pict><v:group style="width:100pt;height:40pt"><v:shape><v:textbox inset="9pt,9pt,9pt,9pt"/></v:shape><v:shape><v:textbox inset="0,0,0,0">{content}</v:textbox></v:shape></v:group></w:pict></w:r></w:p>"#),
|
||||
// Text direction (#5193), as Word for Windows reads it: `vertical` and
|
||||
// `vertical-ideographic` are `vert`; a `bottom-to-top` alt is `vert270` with any
|
||||
// `layout-flow` or none; `horizontal-ideographic` stays horizontal.
|
||||
shape("", r#"style="layout-flow:vertical""#),
|
||||
shape("", r#"style="layout-flow:vertical;mso-layout-flow-alt:bottom-to-top;mso-next-textbox:#Text Box 4""#),
|
||||
shape("", r#"style="layout-flow:vertical-ideographic""#),
|
||||
shape("", r#"style="mso-layout-flow-alt:bottom-to-top""#),
|
||||
shape("", r#"style="layout-flow:horizontal;mso-layout-flow-alt:bottom-to-top""#),
|
||||
shape("", r#"style="layout-flow:horizontal-ideographic""#),
|
||||
]
|
||||
.concat();
|
||||
let doc = format!(
|
||||
@@ -140,13 +149,15 @@ fn vml_text_boxes_keep_their_inset_fit_and_anchor() {
|
||||
let rels = br#"<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 d = wordcraft_docx::read(&zip(&[("_rels/.rels", rels), ("word/document.xml", doc.as_bytes())])).unwrap();
|
||||
let bodies: Vec<TextBody> = boxes(&d).iter().map(|id| d.parts[id].body).collect();
|
||||
assert_eq!(bodies.len(), 6);
|
||||
assert_eq!(bodies.len(), 12);
|
||||
assert_eq!(bodies[0].inset, Some([0, 0, 0, 0]));
|
||||
assert_eq!((bodies[1].inset, bodies[1].auto_fit, bodies[1].anchor), (Some([36_000, 45_720, 25_400, 45_720]), true, VAlign::Center));
|
||||
assert_eq!(bodies[2], TextBody::default());
|
||||
assert_eq!((bodies[3].inset, bodies[3].anchor), (Some([12_700, 45_720, 0, wordcraft_doc::MAX_TEXT_BOX_INSET]), VAlign::Bottom));
|
||||
assert_eq!(bodies[4].inset, None);
|
||||
assert_eq!(bodies[5].inset, Some([0, 0, 0, 0]));
|
||||
let verts: Vec<TextVert> = bodies[6..].iter().map(|b| b.vert).collect();
|
||||
assert_eq!(verts, [TextVert::Vert, TextVert::Vert270, TextVert::Vert, TextVert::Vert270, TextVert::Vert270, TextVert::Horz]);
|
||||
// Saved and read again, they stay.
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
let again: Vec<TextBody> = boxes(&back).iter().map(|id| back.parts[id].body).collect();
|
||||
|
||||
@@ -270,3 +270,12 @@ fn flat_opc_limits_count_what_is_not_kept() {
|
||||
// A part nothing reads, one element over what the XML tree reader takes in a part (8 000 000).
|
||||
assert!(limited(&format!(r#"<pkg:part pkg:name="/word/unused.xml"><pkg:xmlData><r>{}</r></pkg:xmlData></pkg:part>"#, "<y/>".repeat(8_000_000))));
|
||||
}
|
||||
|
||||
/// A Word 2003 XML Document saved with Windows line ends: XML reads CR LF as one line feed (XML
|
||||
/// 1.0 §2.11), so a line break inside `w:t` is one space, not two.
|
||||
#[test]
|
||||
fn word_2003_xml_with_windows_line_ends_reads_one_space_per_line_break() {
|
||||
let xml = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n<w:wordDocument xmlns:w=\"http://schemas.microsoft.com/office/word/2003/wordml\">\r\n<w:body><w:p><w:r><w:t>a\r\nb</w:t></w:r></w:p></w:body></w:wordDocument>\r\n";
|
||||
let d = read_xml(xml.as_bytes()).unwrap();
|
||||
assert_eq!(paras(&d)[0].plain_text(), "a b");
|
||||
}
|
||||
@@ -253,27 +253,8 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
|
||||
);
|
||||
}
|
||||
let password = p::str(v, "password");
|
||||
let (mut doc, encrypted) = if let Some(data) = p::str(v, "data") {
|
||||
let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?;
|
||||
// An empty file (File Explorer's New › Microsoft Word Document makes one) opens as a new
|
||||
// blank document that saves back to it (#1384).
|
||||
if bytes.is_empty() {
|
||||
(normal_document(s), false)
|
||||
} else {
|
||||
crate::io::open_bytes_in(path, &bytes, password, &s.region()).map_err(CmdError::Failed)?
|
||||
}
|
||||
} else if crate::io::is_empty_file(std::path::Path::new(path)) {
|
||||
(normal_document(s), false)
|
||||
} else {
|
||||
crate::io::open_path_in(std::path::Path::new(path), password, &s.region()).map_err(CmdError::Failed)?
|
||||
};
|
||||
// Plain text and Markdown carry no language: they are in the editing language.
|
||||
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase();
|
||||
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref())
|
||||
&& matches!(ext.as_str(), "txt" | "text" | "md" | "markdown")
|
||||
{
|
||||
Arc::make_mut(&mut doc.styles).default_chr.lang = Some(lang.clone());
|
||||
}
|
||||
// An empty file opens as a new blank document that saves back to it (#1384).
|
||||
let (doc, encrypted) = read_document(s, path, v)?;
|
||||
match s.used_because() {
|
||||
None => s.set_document(doc),
|
||||
Some(why) => {
|
||||
@@ -294,6 +275,53 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
|
||||
)
|
||||
}
|
||||
|
||||
/// The document the file at `path` holds (or its bytes, base64 in `data`, with `path` only
|
||||
/// naming it), read with `password`, and whether it was encrypted: what `file.open` and
|
||||
/// `file.newFromTemplate` read. An empty file (File Explorer's New › Microsoft Word Document makes
|
||||
/// one) is a new blank document (#1384). Plain text and Markdown carry no language, so they are
|
||||
/// in the editing language.
|
||||
fn read_document(s: &Session, path: &str, v: &Value) -> Result<(Document, bool), CmdError> {
|
||||
let password = p::str(v, "password");
|
||||
let (mut doc, encrypted) = if let Some(data) = p::str(v, "data") {
|
||||
let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?;
|
||||
if bytes.is_empty() {
|
||||
(normal_document(s), false)
|
||||
} else {
|
||||
crate::io::open_bytes_in(path, &bytes, password, &s.region()).map_err(CmdError::Failed)?
|
||||
}
|
||||
} else if crate::io::is_empty_file(std::path::Path::new(path)) {
|
||||
(normal_document(s), false)
|
||||
} else {
|
||||
crate::io::open_path_in(std::path::Path::new(path), password, &s.region()).map_err(CmdError::Failed)?
|
||||
};
|
||||
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase();
|
||||
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref())
|
||||
&& matches!(ext.as_str(), "txt" | "text" | "md" | "markdown")
|
||||
{
|
||||
Arc::make_mut(&mut doc.styles).default_chr.lang = Some(lang.clone());
|
||||
}
|
||||
Ok((doc, encrypted))
|
||||
}
|
||||
|
||||
/// File › New from a template (#2852): a new, untitled document with the template's content,
|
||||
/// attached to the template as in Word, read as `file.open` reads a file (`data`, `password`).
|
||||
/// It opens beside the open documents, as `file.new` does. With `replaceBlank` an untouched blank
|
||||
/// document gives way to it, as to a file `file.open` opens: that is how a template the platform
|
||||
/// hands the app starts (#3433). The template is only read; the new document has no file, so
|
||||
/// saving it asks where to write, and a protected template's password doesn't come along.
|
||||
pub(crate) fn new_from_template(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let path = p::req_str(v, "path")?;
|
||||
let (mut doc, _) = read_document(s, path, v)?;
|
||||
doc.settings.attached_template = Some(path.to_string());
|
||||
if p::bool(v, "replaceBlank") == Some(true) && s.used_because().is_none() {
|
||||
s.set_document(doc);
|
||||
} else {
|
||||
s.add_document(doc);
|
||||
}
|
||||
s.dirty = true;
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Close a document with no window to ask about it (agents, headless sessions).
|
||||
fn close_document(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let id = match v.get("id").filter(|x| !x.is_null()) {
|
||||
|
||||
@@ -198,7 +198,9 @@ fn special(query: &str, o: &Opts) -> (String, bool) {
|
||||
if (o.ignore_punct && is_punct(c)) || (o.ignore_space && is_space(c)) {
|
||||
continue;
|
||||
}
|
||||
atoms.push(lit(c));
|
||||
// A keyboard apostrophe also finds text produced by smart quotes. Explicit character
|
||||
// codes above, and literal/regex/wildcard searches, keep exact character matching.
|
||||
atoms.push(if c == '\'' { "['‘’]".into() } else { lit(c) });
|
||||
}
|
||||
let filler = match (o.ignore_punct, o.ignore_space) {
|
||||
(true, true) => r"[\p{P} \t\x{A0}]*",
|
||||
|
||||
@@ -489,7 +489,8 @@ mod tests {
|
||||
("headings", [Some("1"), Some("1.1"), Some("1.1.1")]),
|
||||
("article", [Some("Article I."), Some("Section 1.01"), Some("(a)")]),
|
||||
("outlineHeadings", [Some("I."), Some("A."), Some("1.")]),
|
||||
("chapter", [Some("Chapter 1"), None, None]),
|
||||
// The lower levels are "none" levels with no text: an empty label, as in Word (#4985).
|
||||
("chapter", [Some("Chapter 1"), Some(""), Some("")]),
|
||||
] {
|
||||
let mut s = headings("Heading3");
|
||||
s.run("list.multilevelPreset", &json!({"preset": preset})).unwrap();
|
||||
|
||||
@@ -76,9 +76,11 @@ pub fn cropped_original_size(s: &Session, o: &InlineObject) -> Option<(f32, f32)
|
||||
pub(super) fn size(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no object".into()))?;
|
||||
let lock = p::bool(v, "lockAspect").unwrap_or(true);
|
||||
let (mut w, mut h) = obj_size(&o);
|
||||
let (w0, h0) = obj_size(&o);
|
||||
let (mut w, mut h) = (w0, h0);
|
||||
let scale = p::f32(v, "scale");
|
||||
if p::bool(v, "original").unwrap_or(false) {
|
||||
let original = p::bool(v, "original").unwrap_or(false);
|
||||
if original {
|
||||
let (ow, oh) =
|
||||
cropped_original_size(s, &o).ok_or_else(|| CmdError::Disabled("only a picture whose image can be read has an original size".into()))?;
|
||||
let k = scale.unwrap_or(100.0) / 100.0;
|
||||
@@ -113,7 +115,16 @@ pub(super) fn size(s: &mut Session, v: &Value) -> CmdResult {
|
||||
(None, None) => true,
|
||||
_ => lock,
|
||||
};
|
||||
let (w, h) = fit_size(w, h, keep);
|
||||
// Sides set in proportion (from the original, or from a size whose sides both have a length)
|
||||
// fit the limits together. Otherwise each side the call sets fits on its own, and a side it
|
||||
// leaves as it was keeps its size, a straight line's 0 too.
|
||||
let (w, h) = if keep && (original || (w0 > 0.0 && h0 > 0.0 && (w, h) != (w0, h0))) {
|
||||
fit_size(w, h, true)
|
||||
} else {
|
||||
let (fw, fh) = fit_size(w, h, false);
|
||||
let left = |new: f32, old: f32| !original && new == old;
|
||||
(if left(w, w0) { w } else { fw }, if left(h, h0) { h } else { fh })
|
||||
};
|
||||
// Any drawing: a group's members scale with its frame (its child space is kept); a chart or
|
||||
// SmartArt graphic lays itself out again at the new size.
|
||||
let theme = s.doc.settings.theme_colors.clone();
|
||||
|
||||
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("picture.size", "Size", "Picture Format › Size", size)
|
||||
.params(
|
||||
r#"{"width"?: pt, "height"?: pt, "lockAspect"?: bool, "scale"?: percent, "original"?: bool} (scale: of the current size, or with original of the picture's original size, its pixels at 96 ppi less what is cropped; original alone restores that size; one side alone keeps the proportions unless lockAspect is false; 1–4000 pt: when the proportions are kept, a side past 4000 pt shrinks the other with it)"#,
|
||||
r#"{"width"?: pt, "height"?: pt, "lockAspect"?: bool, "scale"?: percent, "original"?: bool} (scale: of the current size, or with original of the picture's original size, its pixels at 96 ppi less what is cropped; original alone restores that size; one side alone keeps the proportions unless lockAspect is false; 1–4000 pt: when the proportions are kept (from the original, or with both sides having a length), a side past 4000 pt shrinks the other with it; a side the call leaves as it was keeps its size, a straight line's 0 too)"#,
|
||||
)
|
||||
.when(has_sizable),
|
||||
CommandSpec::new("picture.crop", "Crop", "Picture Format › Size", crop)
|
||||
@@ -132,14 +132,26 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
} else {
|
||||
return Err(CmdError::Params("`path` or `data` required".into()));
|
||||
};
|
||||
wordcraft_geom::picture::probe(&bytes).map_err(|e| match e {
|
||||
wordcraft_geom::picture::Refusal::Unsupported(why) => CmdError::Failed(why.into()),
|
||||
_ => CmdError::Failed("not a supported image".into()),
|
||||
})?;
|
||||
let (px_w, px_h) = wordcraft_geom::picture::probe(&bytes)
|
||||
.map(|p| (p.width, p.height))
|
||||
.map_err(|e| match e {
|
||||
wordcraft_geom::picture::Refusal::Unsupported(why) => CmdError::Failed(why.into()),
|
||||
_ => CmdError::Failed("not a supported image".into()),
|
||||
})?;
|
||||
let key = s.doc.add_media(bytes.clone(), crate::cmd::insert::image_ext(&bytes));
|
||||
with_obj(s, |o| {
|
||||
if let InlineObject::Image { media, .. } = o {
|
||||
if let InlineObject::Image { media, w, h, crop, .. } = o {
|
||||
*media = key.clone();
|
||||
// The replacement keeps the picture's width and takes its own aspect ratio, so a
|
||||
// picture of another shape isn't stretched into the old frame. The old crop
|
||||
// described the old picture, so it is dropped.
|
||||
if px_w > 0 && px_h > 0 && w.is_finite() && *w > 0.0 {
|
||||
let nh = *w * px_h as f32 / px_w as f32;
|
||||
if nh.is_finite() && nh > 0.0 {
|
||||
*h = nh;
|
||||
}
|
||||
}
|
||||
*crop = [0.0; 4];
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1034,6 +1034,66 @@ fn picture_size_past_the_limit_keeps_the_proportions() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A side the call leaves as it was keeps its size, even outside 1–4000 pt: a straight line's 0
|
||||
/// stays 0 when only its length is typed. Sides set in proportion (the original, or a size whose
|
||||
/// sides both have a length) fit the limits together; any other side the call sets, on its own.
|
||||
#[test]
|
||||
fn picture_size_keeps_the_side_it_leaves() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("insert.shape", &json!({"kind": "straightConnector", "width": 100.0, "height": 0.0})).unwrap();
|
||||
let size = |s: &Session| obj_size(&selected(s).unwrap().1);
|
||||
// A side a scale sets on its own: 0 has no length to scale.
|
||||
let scaled = |x: f32, k: f32| if x == 0.0 { 0.0 } else { (x * k).clamp(1.0, 4000.0) };
|
||||
// Sizes with both sides follow each other: they fit 1–4000 pt by the one factor nearest 1, worked out by hand
|
||||
// for the calls `width` locked, `height` locked, `scale` 50 and `scale` 200 with a width (in that order).
|
||||
let states = [
|
||||
((100.0, 0.0), None),
|
||||
((0.0, 100.0), None),
|
||||
((5000.0, 0.0), None),
|
||||
((0.0, 5000.0), None),
|
||||
((100.0, 0.5), Some([(200.0, 1.0), (4000.0, 20.0), (200.0, 1.0), (200.0, 1.0)])),
|
||||
((5000.0, 50.0), Some([(200.0, 2.0), (4000.0, 40.0), (2500.0, 25.0), (200.0, 2.0)])),
|
||||
((100.0, 50.0), Some([(200.0, 100.0), (400.0, 200.0), (50.0, 25.0), (200.0, 100.0)])),
|
||||
];
|
||||
for ((w0, h0), together) in states {
|
||||
let follow = |i: usize, alone: (f32, f32)| together.map_or(alone, |t: [(f32, f32); 4]| t[i]);
|
||||
let cases = [
|
||||
(json!({"width": 200.0, "lockAspect": false}), (200.0, h0)),
|
||||
(json!({"height": 200.0, "lockAspect": false}), (w0, 200.0)),
|
||||
(json!({"width": 200.0}), follow(0, (200.0, h0))),
|
||||
(json!({"height": 200.0}), follow(1, (w0, 200.0))),
|
||||
(json!({"scale": 50}), follow(2, (scaled(w0, 0.5), scaled(h0, 0.5)))),
|
||||
(json!({"scale": 200, "width": 200.0}), follow(3, (200.0, scaled(h0, 2.0)))),
|
||||
(json!({"scale": 100}), (w0, h0)),
|
||||
(json!({}), (w0, h0)),
|
||||
// The Size dialog: both sides sent, the height as it was.
|
||||
(json!({"width": 200.0, "height": h0, "lockAspect": false}), (200.0, h0)),
|
||||
(json!({"width": 200.0, "height": 300.0}), (200.0, 300.0)),
|
||||
];
|
||||
for (v, (ew, eh)) in cases {
|
||||
with_obj(&mut s, |o| o.set_size(w0, h0)).unwrap();
|
||||
s.run("picture.size", &v).unwrap();
|
||||
let (w, h) = size(&s);
|
||||
assert!((w - ew).abs() < 1e-3 && (h - eh).abs() < 1e-3, "{w0}×{h0} {v}: {w}×{h}, not {ew}×{eh}");
|
||||
}
|
||||
}
|
||||
// Both sides of a picture's original size fit together, whatever size it had, even when the
|
||||
// height comes out as it was: 4050×1012.5 pt is 4000×1000, not 4000×1012.5.
|
||||
let data = super::super::insert::base64_encode(&png(400, 100));
|
||||
s.run("insert.picture", &json!({"data": data})).unwrap();
|
||||
for (w0, h0) in [(3000.0, 1012.5), (4050.0, 0.0), (100.0, 100.0)] {
|
||||
with_obj(&mut s, |o| o.set_size(w0, h0)).unwrap();
|
||||
s.run("picture.size", &json!({"original": true, "scale": 1350})).unwrap();
|
||||
let (w, h) = size(&s);
|
||||
assert!((w - 4000.0).abs() < 0.01 && (h - 1000.0).abs() < 0.01, "{w0}×{h0}: {w}×{h}");
|
||||
}
|
||||
// A height the original sets is set, though it is the one the picture had: 300×75 pt at
|
||||
// 5400 % is 16200×4050, so with a width of 200 the height fits to 4000.
|
||||
with_obj(&mut s, |o| o.set_size(100.0, 4050.0)).unwrap();
|
||||
s.run("picture.size", &json!({"original": true, "scale": 5400, "width": 200.0, "lockAspect": false})).unwrap();
|
||||
assert_eq!(size(&s), (200.0, 4000.0));
|
||||
}
|
||||
|
||||
/// A crop handle drop: `picture.crop` with the new size is one edit, one undo step.
|
||||
#[test]
|
||||
fn picture_crop_with_size_is_one_undo_step() {
|
||||
|
||||
@@ -102,7 +102,9 @@ struct SeqCount {
|
||||
|
||||
/// The fresh results of the unlocked SEQ fields in the body and in the text boxes anchored there
|
||||
/// ([`update_seq`]). A text box's fields count where the box is anchored, before the fields after
|
||||
/// the anchor in its paragraph and after those before it (measured in Word for Windows).
|
||||
/// the anchor in its paragraph and after those before it (measured in Word for Windows). A locked
|
||||
/// SEQ keeps its result but still counts, `\r` included; a SEQ in a tracked deletion keeps its
|
||||
/// result and doesn't count, with markup shown or not (measured in Word for Windows, exporting).
|
||||
fn seq_results(doc: &wordcraft_doc::Document) -> Vec<FieldResult> {
|
||||
let mut count = SeqCount::default();
|
||||
for path in doc.para_paths(StoryRef::Body) {
|
||||
@@ -120,7 +122,11 @@ fn seq_results(doc: &wordcraft_doc::Document) -> Vec<FieldResult> {
|
||||
|
||||
/// Number the SEQ fields of paragraph `p` (at `path` in `story`) and of the text boxes anchored in it.
|
||||
fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Paragraph, count: &mut SeqCount) {
|
||||
for (k, o) in p.objects.iter().enumerate() {
|
||||
// The runs come in order and the objects by offset, so one cursor walks the deleted ranges.
|
||||
let mut deleted = p.deleted_ranges().into_iter().peekable();
|
||||
for (k, (off, o)) in p.objects_with_offsets().enumerate() {
|
||||
while deleted.next_if(|r| r.end <= off).is_some() {}
|
||||
let in_deletion = deleted.peek().is_some_and(|r| r.contains(&off));
|
||||
for id in o.text_boxes() {
|
||||
if count.boxes.insert(id) {
|
||||
let part = StoryRef::Part(id);
|
||||
@@ -131,9 +137,9 @@ fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Pa
|
||||
}
|
||||
}
|
||||
}
|
||||
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
|
||||
let InlineObject::Field { instr, locked, .. } = o else { continue };
|
||||
let f = FieldCode::parse(instr);
|
||||
if f.name != "SEQ" {
|
||||
if f.name != "SEQ" || in_deletion {
|
||||
continue;
|
||||
}
|
||||
let Some(id) = f.args.first() else { continue };
|
||||
@@ -149,6 +155,9 @@ fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Pa
|
||||
} else if !f.has('c') {
|
||||
n.0 = n.0.saturating_add(1);
|
||||
}
|
||||
if *locked {
|
||||
continue;
|
||||
}
|
||||
let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or_default();
|
||||
let r = if f.has('h') { String::new() } else { wordcraft_layout::fields::field_number(instr, fmt, n.0) };
|
||||
count.out.push((story, path.clone(), k, r));
|
||||
|
||||
@@ -549,6 +549,72 @@ fn caption_for_an_object_in_a_cell_stays_in_the_cell() {
|
||||
assert_eq!(texts.get(1).map(String::as_str), Some("Figure 1"), "{texts:?}");
|
||||
}
|
||||
|
||||
/// A fresh scratch directory for the file-name tests below.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn filename_scratch_dir(tag: &str) -> std::path::PathBuf {
|
||||
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-filename-field-{tag}-{}-{nanos}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// #3796: after Save As to a new name, Update Field with the caret in a header shows the new
|
||||
/// name for FILENAME and the new full path for FILENAME \p.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn filename_fields_in_a_header_follow_save_as() {
|
||||
let dir = filename_scratch_dir("save-as");
|
||||
let (mut s, hdr) = header_fields_session(wordcraft_doc::Document::from_text("body"), &["FILENAME", r"FILENAME \p"]);
|
||||
let first = dir.join("report_v1.docx");
|
||||
let second = dir.join("report_v2.docx");
|
||||
s.run("file.save", &json!({"path": first.to_string_lossy()})).unwrap();
|
||||
s.run("references.updateFields", &json!({})).unwrap();
|
||||
let got = story_fields(&s, StoryRef::Part(hdr));
|
||||
assert_eq!(got[0].1, "report_v1.docx");
|
||||
assert_eq!(got[1].1, first.to_string_lossy());
|
||||
|
||||
s.run("file.saveAs", &json!({"path": second.to_string_lossy()})).unwrap();
|
||||
s.run("references.updateFields", &json!({})).unwrap();
|
||||
let got = story_fields(&s, StoryRef::Part(hdr));
|
||||
assert_eq!(got[0].1, "report_v2.docx");
|
||||
assert_eq!(got[1].1, second.to_string_lossy());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #3796: a Word-style `FILENAME \* MERGEFORMAT` field whose cached text names the old file
|
||||
/// shows the new name after Save As and Update Field.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn filename_mergeformat_field_replaces_its_cached_name_after_save_as() {
|
||||
let dir = filename_scratch_dir("mergeformat");
|
||||
let (mut s, hdr) = header_fields_session(wordcraft_doc::Document::from_text("body"), &[r"FILENAME \* MERGEFORMAT"]);
|
||||
let first = dir.join("report_v1.docx");
|
||||
s.run("file.save", &json!({"path": first.to_string_lossy()})).unwrap();
|
||||
// The cached text a file written earlier would carry.
|
||||
let path = s.doc.para_paths(StoryRef::Part(hdr)).remove(0);
|
||||
if let Some(InlineObject::Field { result, .. }) = s.doc.para_mut(StoryRef::Part(hdr), &path).unwrap().objects.first_mut() {
|
||||
*result = "report_v1.docx".into();
|
||||
}
|
||||
s.run("file.saveAs", &json!({"path": dir.join("report_v2.docx").to_string_lossy()})).unwrap();
|
||||
s.run("references.updateFields", &json!({})).unwrap();
|
||||
assert_eq!(story_fields(&s, StoryRef::Part(hdr))[0].1, "report_v2.docx");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #3796: renaming the document (File > Info) changes what FILENAME shows on the next Update
|
||||
/// Field; the extension stays.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn filename_field_in_a_header_follows_rename() {
|
||||
let dir = filename_scratch_dir("rename");
|
||||
let (mut s, hdr) = header_fields_session(wordcraft_doc::Document::from_text("body"), &["FILENAME"]);
|
||||
s.run("file.save", &json!({"path": dir.join("report_v1.docx").to_string_lossy()})).unwrap();
|
||||
s.run("file.rename", &json!({"name": "report_v3"})).unwrap();
|
||||
s.run("references.updateFields", &json!({})).unwrap();
|
||||
assert_eq!(story_fields(&s, StoryRef::Part(hdr))[0].1, "report_v3.docx");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A backslash in a quoted field argument makes the next character literal, so `\*` there is an
|
||||
/// asterisk and not the `\* Upper` switch (Word shows `x * Upper` for `TITLE "x \* Upper"`).
|
||||
#[test]
|
||||
@@ -561,6 +627,7 @@ fn quoted_field_argument_backslash_escapes() {
|
||||
}
|
||||
assert_eq!(text_result(r#"TITLE "ab" \* Upper"#, new_property_value(r#"TITLE "ab" \* Upper"#).unwrap()), "AB");
|
||||
}
|
||||
|
||||
/// Output (PDF, print, page images) shows the body's unlocked SEQ, STYLEREF and REF fields with
|
||||
/// their fresh results, as Word fills them in when it prints or exports even with "Update fields
|
||||
/// before printing" off; NUMWORDS and a locked field keep their stored results there, and the
|
||||
@@ -726,6 +793,45 @@ fn output_shows_references_to_bookmarks_outside_the_body() {
|
||||
assert_eq!(results, expected);
|
||||
}
|
||||
|
||||
/// Printed or exported, a locked SEQ keeps its result but still counts, its `\r` too, and a SEQ
|
||||
/// in a tracked deletion keeps its result and doesn't count (measured in Word for Windows,
|
||||
/// exporting with markup shown and hidden: Del 1, 9, 2; Lock 1, 7, 3; R 7, 6).
|
||||
#[test]
|
||||
fn output_counts_locked_seq_fields_and_not_deleted_ones() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text(""));
|
||||
let fields_in = ["SEQ Del", "SEQ Del", "SEQ Del", "SEQ Lock", "SEQ Lock", "SEQ Lock", "SEQ R \\r 5", "SEQ R"];
|
||||
for instr in fields_in {
|
||||
s.run("insert.field", &json!({"instr": instr, "result": "9"})).unwrap();
|
||||
s.run("text.insert", &json!({"text": " "})).unwrap();
|
||||
}
|
||||
let top = wordcraft_doc::Path::top(0);
|
||||
let para = s.doc.para_mut(StoryRef::Body, &top).unwrap();
|
||||
for (k, o) in para.objects.iter_mut().enumerate() {
|
||||
if let InlineObject::Field { result, locked, .. } = o {
|
||||
*locked = matches!(k, 4 | 6);
|
||||
*result = if *locked { "7" } else { "9" }.into();
|
||||
}
|
||||
}
|
||||
para.touch();
|
||||
// The second Del field is a tracked deletion.
|
||||
let off = s.doc.para(StoryRef::Body, &top).unwrap().object_offsets()[1];
|
||||
let at = |off| Pos { story: StoryRef::Body, path: top.clone(), off };
|
||||
s.doc.format_range(&at(off), &at(off + '\u{FFFC}'.len_utf8()), &|c| c.del = Some(1)).unwrap();
|
||||
let out = output_fields(&s.doc).expect("the stale results change");
|
||||
let shown: Vec<String> = out
|
||||
.para(StoryRef::Body, &top)
|
||||
.unwrap()
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::Field { result, .. } => Some(result.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(shown, ["1", "9", "2", "1", "7", "3", "7", "6"]);
|
||||
assert!(fields(&s).iter().all(|f| f.1 == "9" || f.1 == "7"), "the document keeps its results");
|
||||
}
|
||||
|
||||
/// Printed or exported, a REF to a bookmark the document doesn't have shows Word's error
|
||||
/// (measured in Word for Windows: "Error! Reference source not found."); the editor and the
|
||||
/// document keep the stored result, and a REF showing "above" or "below" (`\p`) keeps it too.
|
||||
|
||||
@@ -54,11 +54,20 @@ fn resolve_para(
|
||||
// An inserted paragraph mark that is rejected, or a deleted one that is accepted, goes away:
|
||||
// the next paragraph joins this one (the split is undone). A paragraph left empty goes away
|
||||
// whole, so the next one keeps its own style and numbering, as in Word. The mark at the end
|
||||
// of a story or cell can't go, so it just stops being a revision.
|
||||
if ((del.is_some() && accept) || (ins.is_some() && !accept))
|
||||
&& (crate::cmd::remove_empty_para(s, story, path)? || crate::cmd::join_next_para(s, story, path)?)
|
||||
{
|
||||
return Ok(());
|
||||
// of a story or cell can't go, so it just stops being a revision. An empty paragraph whose
|
||||
// mark carries a section break goes whole too: its section joins the following one, as when
|
||||
// the break is deleted (#5667). Joining instead gave the next paragraph its properties.
|
||||
if (del.is_some() && accept) || (ins.is_some() && !accept) {
|
||||
let sectioned = story == StoryRef::Body
|
||||
&& path.0.len() == 1
|
||||
&& s.doc.para(story, path).is_some_and(|p| p.is_empty() && p.section.is_some())
|
||||
&& crate::cmd::has_next_para(s, story, path);
|
||||
if sectioned {
|
||||
s.doc.remove_section_break(path.last());
|
||||
}
|
||||
if crate::cmd::remove_empty_para(s, story, path)? || crate::cmd::join_next_para(s, story, path)? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let para = s.doc.para_mut(story, path)?;
|
||||
para.mark.ins = None;
|
||||
|
||||
@@ -195,7 +195,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.params(r#"{"text"?: string, "title"?: string} (empty clears)"#)),
|
||||
t(CommandSpec::new("table.sort", "Sort", "Table Layout › Data", sort_table)
|
||||
.params(r#"{"column"?: n, "descending"?: bool, "header"?: bool}"#)),
|
||||
.params(r#"{"column"?: non-negative JSON integer (0-based, default 0; must name a column in the table), "descending"?: bool, "header"?: bool}"#)),
|
||||
t(CommandSpec::new("table.toText", "Convert to Text", "Table Layout › Data", to_text)
|
||||
.params(r#"{"separator"?: "tab|comma|paragraph|other", "char"?: string (with other)} (the selected rows, or the whole table)"#)),
|
||||
t(CommandSpec::new("table.formula", "Formula", "Table Layout › Data", formula).params(r#"{"formula"?: "=SUM(ABOVE)"}"#)),
|
||||
|
||||
@@ -8,11 +8,18 @@ use super::{
|
||||
/// Sort the table's rows by a column. Numbers are read in each cell's language (`1,5` is one and
|
||||
/// a half in Polish) and sort first, numerically; other text sorts in the column's language.
|
||||
pub(super) fn sort_table(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let col = p::u64(v, "column").unwrap_or(0) as usize;
|
||||
let column = match v.get("column") {
|
||||
None => 0,
|
||||
Some(value) => value.as_u64().ok_or_else(|| CmdError::Params("`column` must be a non-negative JSON integer".into()))?,
|
||||
};
|
||||
let desc = p::bool(v, "descending").unwrap_or(false);
|
||||
let header = p::bool(v, "header");
|
||||
let (tp, _, _) = cell(s)?;
|
||||
let t = s.doc.table(s.sel.focus.story, &tp).ok_or_else(|| CmdError::Failed("no table".into()))?;
|
||||
let col = usize::try_from(column)
|
||||
.ok()
|
||||
.filter(|&col| t.rows.iter().any(|row| col < row.cells.len()))
|
||||
.ok_or_else(|| CmdError::Params("`column` is outside the table".into()))?;
|
||||
let skip = usize::from(header.unwrap_or(t.rows.first().is_some_and(|r| r.props.header))).min(t.rows.len());
|
||||
let column = || t.rows.iter().skip(skip).map(|r| r.cells.get(col));
|
||||
let lang = column().flatten().find_map(|c| cell_lang(&s.doc, c)).or_else(|| crate::cmd::default_lang(s));
|
||||
|
||||
@@ -121,17 +121,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("file.recover", "Recover Unsaved Documents", "File › Info", |s, v| s.recover(v))
|
||||
.params(r#"{"restore"?: recoveryId, "discard"?: recoveryId, "refresh"?: true}; desktop only; returns {enabled,busy,documents,error}; restore loads asynchronously without a file path"#)
|
||||
.pure(),
|
||||
CommandSpec::new("file.newFromTemplate", "New from Template", "File › New", |s, v| {
|
||||
let path = p::req_str(v, "path")?;
|
||||
let mut doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?;
|
||||
// As in Word, the new document is attached to the template it came from (#2852).
|
||||
doc.settings.attached_template = Some(path.to_string());
|
||||
s.add_document(doc);
|
||||
s.dirty = true;
|
||||
sel_result(s)
|
||||
})
|
||||
.params(r#"{"path": string} → a new, untitled document from the template, attached to it"#)
|
||||
.pure(),
|
||||
CommandSpec::new("file.newFromTemplate", "New from Template", "File › New", crate::cmd::file::new_from_template)
|
||||
.params(
|
||||
r#"{"path": string, "data"?: base64 (the template's bytes; `path` then only names it), "password"?: string (for a password-protected template), "replaceBlank"?: bool (an untouched blank document gives way to it, as to a file file.open opens)} → a new, untitled document from the template, attached to it"#,
|
||||
)
|
||||
.pure(),
|
||||
CommandSpec::new("file.saveTemplate", "Save as Template", "File › Save As", |s, v| {
|
||||
let path = p::req_str(v, "path")?;
|
||||
// A .dotm keeps its name (#2852); anything else becomes a .dotx.
|
||||
|
||||
@@ -337,6 +337,73 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// #3433: a template the platform hands the app takes an untouched blank document's place
|
||||
/// (`replaceBlank`), as a file `file.open` opens does; otherwise, and without the flag, the
|
||||
/// new document opens beside the others, as File › New's does. Either way the template on
|
||||
/// disk is only read: the new document has no path, so a save can't write over it.
|
||||
#[test]
|
||||
fn new_from_template_replaces_an_untouched_blank_document_only_when_asked() {
|
||||
let d = scratch("replace-blank");
|
||||
let path = d.join("Letterhead.dotx");
|
||||
template(&path, "Letterhead text", "");
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let from = json!({"path": path.to_string_lossy(), "replaceBlank": true});
|
||||
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("file.newFromTemplate", &from).unwrap();
|
||||
assert_eq!(s.documents().len(), 1, "the untouched blank document gave way");
|
||||
assert_eq!(s.path, None);
|
||||
assert!(s.dirty);
|
||||
assert_eq!(s.doc.settings.attached_template.as_deref(), Some(path.to_string_lossy().as_ref()));
|
||||
assert!(s.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Letterhead text"));
|
||||
let saved = s.run("file.save", &json!({})).unwrap();
|
||||
assert_eq!(saved["saved"], json!(false), "no file to save to: Save As asks where");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes, "the template is never written over");
|
||||
|
||||
// The new document has unsaved changes, so a second one opens beside it.
|
||||
s.run("file.newFromTemplate", &from).unwrap();
|
||||
assert_eq!(s.documents().len(), 2);
|
||||
// Without the flag, even an untouched blank document stays (File › New).
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("file.newFromTemplate", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!(s.documents().len(), 2);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// #3433: a protected template asks for its password like `file.open`, and the document made
|
||||
/// from it isn't protected; a template's bytes can come in `data` (a file name that isn't
|
||||
/// valid Unicode, #1440), its path then only naming it.
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn new_from_template_reads_a_protected_template_and_bytes_like_file_open() {
|
||||
let d = scratch("protected");
|
||||
let path = d.join("Locked.dotx");
|
||||
let mut t = Session::new(wordcraft_doc::Document::from_text("Locked template"));
|
||||
t.run("file.save", &json!({"path": path.to_string_lossy(), "password": "sesame"})).unwrap();
|
||||
let name = path.to_string_lossy();
|
||||
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let e = s.run("file.newFromTemplate", &json!({"path": name})).unwrap_err().to_string();
|
||||
assert!(crate::io::needs_password(&e), "{e}");
|
||||
let e = s.run("file.newFromTemplate", &json!({"path": name, "password": "wrong"})).unwrap_err().to_string();
|
||||
assert!(crate::io::wrong_password(&e), "{e}");
|
||||
assert_eq!(s.documents().len(), 1, "nothing opened yet");
|
||||
s.run("file.newFromTemplate", &json!({"path": name, "password": "sesame", "replaceBlank": true})).unwrap();
|
||||
assert!(s.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Locked template"));
|
||||
assert_eq!((s.path.as_ref(), s.password.is_some()), (None, false));
|
||||
|
||||
let plain = d.join("Plain.dotx");
|
||||
template(&plain, "From bytes", "");
|
||||
let data = crate::cmd::insert::base64_encode(&std::fs::read(&plain).unwrap());
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("file.newFromTemplate", &json!({"path": "Pl\u{FFFD}in.dotx", "data": data, "replaceBlank": true})).unwrap();
|
||||
assert!(s.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("From bytes"));
|
||||
assert_eq!(s.doc.settings.attached_template.as_deref(), Some("Pl\u{FFFD}in.dotx"));
|
||||
assert_eq!(s.path, None);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_templates_folder_option_is_saved_and_checked() {
|
||||
let d = scratch("option");
|
||||
|
||||
@@ -771,8 +771,8 @@ pub fn list_dir(path: &std::path::Path, max: usize) -> Result<(Vec<DirEntry>, bo
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn package_title(path: &std::path::Path, max_file: u64, max_part: u64) -> Option<String> {
|
||||
crate::file_access::check_read(&path.to_string_lossy()).ok()?;
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
if file.metadata().ok()?.len() > max_file {
|
||||
let (file, meta) = open_regular_file(path).ok()?;
|
||||
if meta.len() > max_file {
|
||||
return None;
|
||||
}
|
||||
wordcraft_docx::package_title(std::io::BufReader::new(file), max_part)
|
||||
|
||||
@@ -390,7 +390,7 @@ impl Session {
|
||||
let at = self.others.iter().position(|d| d.document_id == id).ok_or_else(|| unknown(id))?;
|
||||
let mut d = self.others.swap_remove(at);
|
||||
self.swap_doc(&mut d);
|
||||
self.view.read_page_color = self.prefs.read_page_color;
|
||||
self.view.follow_prefs(&self.prefs);
|
||||
self.others.push(d);
|
||||
Ok(())
|
||||
}
|
||||
@@ -471,7 +471,7 @@ impl Session {
|
||||
doc.ensure_nonempty();
|
||||
let mut view = self.view.clone();
|
||||
view.outline.collapsed.clear();
|
||||
view.read_page_color = self.prefs.read_page_color;
|
||||
view.follow_prefs(&self.prefs);
|
||||
let mut d = DocState::new(doc, untitled, view);
|
||||
let id = d.document_id;
|
||||
self.order.push(id);
|
||||
|
||||
@@ -32,7 +32,7 @@ impl Session {
|
||||
self.hf_return = None;
|
||||
self.go_back_step = 0;
|
||||
self.view.outline.collapsed.clear();
|
||||
self.view.read_page_color = self.prefs.read_page_color;
|
||||
self.view.follow_prefs(&self.prefs);
|
||||
// A copy the replaced document kept stays in the recovery list.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
|
||||
@@ -135,7 +135,7 @@ impl Session {
|
||||
let current = document_id == self.document_id && rev == self.rev;
|
||||
self.sel = sel;
|
||||
self.view = view;
|
||||
self.view.read_page_color = self.prefs.read_page_color;
|
||||
self.view.follow_prefs(&self.prefs);
|
||||
self.pending = pending;
|
||||
self.goal_x = goal_x;
|
||||
self.page_hint = page_hint;
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::{Session, cmd};
|
||||
|
||||
mod bidi;
|
||||
mod documents;
|
||||
mod find_apostrophes;
|
||||
mod font_sizes;
|
||||
mod fonts;
|
||||
mod ime_noop;
|
||||
@@ -5746,6 +5747,39 @@ fn opening_a_named_pipe_or_device_is_refused_without_blocking() {
|
||||
assert!(device.unwrap_err().contains("not a file"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn reading_a_template_title_from_a_named_pipe_is_refused_without_blocking() {
|
||||
let dir = scratch_dir("template-title-fifo");
|
||||
let fifo = dir.join("pipe.dotx");
|
||||
|
||||
let made = std::process::Command::new("mkfifo").arg(&fifo).status();
|
||||
if !made.is_ok_and(|s| s.success()) {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
eprintln!("mkfifo unavailable; skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let path = fifo.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let title = crate::io::package_title(&path, 64 << 20, 64 << 10);
|
||||
let _ = tx.send(title);
|
||||
});
|
||||
|
||||
let result = rx.recv_timeout(std::time::Duration::from_secs(10));
|
||||
|
||||
if result.is_err() {
|
||||
// Unblock a stuck reader so the test thread can finish.
|
||||
let _ = std::os::unix::fs::OpenOptionsExt::custom_flags(std::fs::OpenOptions::new().write(true), libc::O_NONBLOCK).open(&fifo);
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
assert_eq!(result.expect("reading a template title blocked on a named pipe"), None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn html_picture_that_is_a_named_pipe_is_skipped_without_blocking() {
|
||||
@@ -10389,6 +10423,73 @@ fn column_texts(s: &Session) -> Vec<String> {
|
||||
t.rows.iter().map(|r| r.cells[0].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::<String>()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_sort_rejects_invalid_columns_without_changing_rows_or_history() {
|
||||
for column in [
|
||||
json!(-1),
|
||||
json!(0.5),
|
||||
json!(0.0),
|
||||
json!("0"),
|
||||
json!(null),
|
||||
json!(true),
|
||||
json!([]),
|
||||
json!({}),
|
||||
json!(1),
|
||||
json!(u32::MAX as u64 + 1),
|
||||
json!(u64::MAX),
|
||||
] {
|
||||
let mut s = column_table("en-US", &["Charlie", "Alpha", "Bravo"]);
|
||||
s.close_typing();
|
||||
run(&mut s, "text.insert", json!({"text": "!"}));
|
||||
let before = s.doc.clone();
|
||||
let selection = s.sel.clone();
|
||||
let dirty = s.dirty;
|
||||
assert!(s.run("table.sort", &json!({"column": column, "header": false})).is_err(), "column {column}");
|
||||
assert_eq!(s.doc, before, "column {column}");
|
||||
assert_eq!(s.sel, selection, "column {column}");
|
||||
assert_eq!(s.dirty, dirty, "column {column}");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(column_texts(&s), ["Charlie", "Alpha", "Bravo"], "column {column}: refusal must not consume Undo");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc, before, "column {column}: Redo must restore the preceding edit");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_sort_keeps_default_and_valid_columns() {
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 3, "cols": 2}));
|
||||
for (k, value) in ["Charlie", "1", "Alpha", "3", "Bravo", "2"].into_iter().enumerate() {
|
||||
if k > 0 {
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
}
|
||||
run(&mut s, "text.insert", json!({"text": value}));
|
||||
}
|
||||
let original = s.doc.clone();
|
||||
for params in [json!({"header": false}), json!({"column": 0, "header": false})] {
|
||||
run(&mut s, "table.sort", params);
|
||||
assert_eq!(column_texts(&s), ["Alpha", "Bravo", "Charlie"]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc, original);
|
||||
}
|
||||
run(&mut s, "table.sort", json!({"column": 1, "header": false}));
|
||||
assert_eq!(column_texts(&s), ["Charlie", "Bravo", "Alpha"]);
|
||||
run(&mut s, "table.sort", json!({"column": 1, "header": false, "descending": true}));
|
||||
assert_eq!(column_texts(&s), ["Alpha", "Bravo", "Charlie"]);
|
||||
// A column may be missing in some rows. Its present cells still sort normally.
|
||||
let at = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
|
||||
let table = s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![at])).unwrap();
|
||||
table.rows[0].cells.remove(1);
|
||||
run(&mut s, "table.sort", json!({"column": 1, "header": false}));
|
||||
assert_eq!(column_texts(&s), ["Charlie", "Bravo", "Alpha"]);
|
||||
|
||||
let mut s = column_table("en-US", &["Title", "3", "1", "2"]);
|
||||
let at = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
|
||||
s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![at])).unwrap().rows[0].props.header = true;
|
||||
run(&mut s, "table.sort", json!({"column": 0}));
|
||||
assert_eq!(column_texts(&s), ["Title", "1", "2", "3"]);
|
||||
}
|
||||
|
||||
/// #836: table sort reads numbers in the text's language: `1,5` is one and a half in Polish.
|
||||
#[test]
|
||||
fn table_sort_reads_decimal_comma_in_polish() {
|
||||
@@ -10716,6 +10817,44 @@ fn picture_change_keeps_the_replacement_format() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Change Picture keeps the width and takes the replacement's aspect ratio: swapping a figure for
|
||||
/// a revised one of a different shape must not stretch it into the old frame.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn picture_change_keeps_width_and_takes_new_aspect() {
|
||||
let dir = scratch_dir("picture-change-aspect");
|
||||
let mut wide = Vec::new();
|
||||
image::RgbImage::from_pixel(40, 20, image::Rgb([200, 30, 30])).write_to(&mut std::io::Cursor::new(&mut wide), image::ImageFormat::Png).unwrap();
|
||||
let mut tall = Vec::new();
|
||||
image::RgbImage::from_pixel(30, 60, image::Rgb([30, 30, 200])).write_to(&mut std::io::Cursor::new(&mut tall), image::ImageFormat::Png).unwrap();
|
||||
let (first, second) = (dir.join("wide.png"), dir.join("tall.png"));
|
||||
std::fs::write(&first, &wide).unwrap();
|
||||
std::fs::write(&second, &tall).unwrap();
|
||||
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
run(&mut s, "insert.picture", json!({"path": first.to_string_lossy()}));
|
||||
let size = |s: &Session| {
|
||||
let mut out = (0.0f32, 0.0f32, [1.0f32; 4]);
|
||||
s.doc.objects_in_reading_order(&mut |o| {
|
||||
if let wordcraft_doc::para::InlineObject::Image { w, h, crop, .. } = o {
|
||||
out = (*w, *h, *crop);
|
||||
}
|
||||
});
|
||||
out
|
||||
};
|
||||
let (w0, h0, _) = size(&s);
|
||||
assert!((w0 / h0 - 2.0).abs() < 1e-3, "inserted at its own 2:1 shape: {w0} x {h0}");
|
||||
run(&mut s, "picture.crop", json!({"left": 0.1}));
|
||||
let (w_cropped, _, crop) = size(&s);
|
||||
assert!(crop[0] > 0.0, "cropped before the change");
|
||||
run(&mut s, "picture.change", json!({"path": second.to_string_lossy()}));
|
||||
let (w1, h1, crop) = size(&s);
|
||||
assert!((w1 - w_cropped).abs() < 1e-3, "the width is kept: {w_cropped} -> {w1}");
|
||||
assert!((h1 / w1 - 2.0).abs() < 1e-3, "the height follows the new 1:2 picture: {w1} x {h1}");
|
||||
assert_eq!(crop, [0.0; 4], "the old picture's crop is dropped");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_open_repairs_hostile_run_lengths() {
|
||||
use wordcraft_doc::{Block, Document};
|
||||
@@ -12719,3 +12858,48 @@ fn page_down_extends_a_selection_that_ends_in_collapsed_outline_text() {
|
||||
assert!(s.sel.focus.path.0[0] > 6, "Page Down extends the selection a page on: {:?}", s.sel.focus);
|
||||
assert!(s.layout().caret(&s.sel.focus).is_some(), "to a place the outline shows");
|
||||
}
|
||||
|
||||
/// Issue #5667's document: `Intro`, then an inserted `Appendix` paragraph whose mark carries a
|
||||
/// section break (Normal, indented), then `Body` in Heading 1. The whole of `Appendix` is
|
||||
/// tracked as an insertion by `rev`, or as a deletion when `delete`.
|
||||
fn sectioned_revision(delete: bool) -> Session {
|
||||
use wordcraft_doc::{Revision, RevisionKind};
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Intro\nAppendix\nBody"));
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 2, "off": 0}}));
|
||||
run(&mut s, "para.style", json!({"style": "Heading1"}));
|
||||
let kind = if delete { RevisionKind::Delete } else { RevisionKind::Insert };
|
||||
s.doc.revisions.push(Revision { kind, author: "Ada".into(), date: String::new(), move_name: None });
|
||||
s.doc.last_section.margin_left = 72.0;
|
||||
let mut sect = s.doc.last_section.clone();
|
||||
sect.margin_left = 144.0;
|
||||
let p = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path(vec![1])).unwrap();
|
||||
let len = p.len();
|
||||
p.format(0, len, &|c| if delete { c.del = Some(0) } else { c.ins = Some(0) }).unwrap();
|
||||
if delete {
|
||||
p.mark.del = Some(0)
|
||||
} else {
|
||||
p.mark.ins = Some(0)
|
||||
}
|
||||
p.props.indent_left = Some(36.0);
|
||||
p.section = Some(Box::new(sect));
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolving_away_an_empty_paragraph_with_a_section_break_keeps_the_next_paragraphs_properties() {
|
||||
// #5667: the resolved paragraph was left empty but kept for its section break, so the next
|
||||
// paragraph joined it and took its properties (Normal, its indent). It goes whole instead,
|
||||
// as an empty paragraph without a break does, and its section joins the following one.
|
||||
for (delete, cmd) in [(false, "review.rejectAll"), (true, "review.acceptAll")] {
|
||||
let mut s = sectioned_revision(delete);
|
||||
assert_eq!(s.doc.sections().len(), 2);
|
||||
run(&mut s, cmd, json!({}));
|
||||
assert_eq!(body_texts(&s), ["Intro", "Body"], "{cmd}");
|
||||
let body = s.doc.para(StoryRef::Body, &wordcraft_doc::Path(vec![1])).unwrap();
|
||||
assert_eq!(body.props.style.as_deref(), Some("Heading1"), "{cmd}: Body keeps its style");
|
||||
assert_eq!(body.props.indent_left, None, "{cmd}: and not the removed paragraph's indent");
|
||||
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.section.is_none()), "{cmd}: the break went with it");
|
||||
assert_eq!(s.doc.sections().len(), 1, "{cmd}");
|
||||
assert_eq!(s.doc.last_section.margin_left, 72.0, "{cmd}: the following section's setup stays");
|
||||
}
|
||||
}
|
||||
@@ -346,3 +346,51 @@ fn input_method_joins_stay_with_their_window() {
|
||||
assert_eq!(text(&s).trim(), "Hello", "B's typing is its own undo step");
|
||||
drop(a);
|
||||
}
|
||||
|
||||
/// Check spelling as you type is one setting for the app, not one per open document (#5740):
|
||||
/// turned off (File › Options › Proofing, or Review's command) with one document active, it is off
|
||||
/// in the others too, and switching to another document doesn't turn it back on, so what is saved
|
||||
/// for the next run is what was chosen, whichever document is active then.
|
||||
#[test]
|
||||
fn check_spelling_is_one_setting_for_every_open_document() {
|
||||
let mut s = s();
|
||||
let a = s.document_id();
|
||||
run(&mut s, "file.new", json!({}));
|
||||
let b = s.document_id();
|
||||
run(&mut s, "file.new", json!({}));
|
||||
let c = s.document_id();
|
||||
assert!(s.view.proofing);
|
||||
s.activate(a).unwrap();
|
||||
run(&mut s, "file.options", json!({"checkSpelling": false}));
|
||||
for id in [b, c, a] {
|
||||
s.activate(id).unwrap();
|
||||
assert!(!s.view.proofing, "off in document {id}");
|
||||
assert!(!s.prefs.check_spelling);
|
||||
}
|
||||
run(&mut s, "review.proofing", json!({"value": true}));
|
||||
for id in [b, c] {
|
||||
s.activate(id).unwrap();
|
||||
assert!(s.view.proofing, "back on in document {id}");
|
||||
}
|
||||
// A document opened now starts with the setting as it is.
|
||||
run(&mut s, "review.proofing", json!({"value": false}));
|
||||
run(&mut s, "file.new", json!({}));
|
||||
assert!(!s.view.proofing);
|
||||
}
|
||||
|
||||
/// A window's view put back after Check spelling as you type changed in another window takes the
|
||||
/// setting as it is now, not as it was when the window's view was taken (#5740): every view made
|
||||
/// active follows the saved preference.
|
||||
#[test]
|
||||
fn a_window_view_put_back_follows_the_spelling_setting() {
|
||||
let mut s = s();
|
||||
assert!(s.view.proofing);
|
||||
let held = s.take_view();
|
||||
run(&mut s, "file.options", json!({"checkSpelling": false}));
|
||||
s.put_view(held);
|
||||
assert!(!s.view.proofing, "the view taken with spelling on comes back with it off");
|
||||
let held = s.take_view();
|
||||
run(&mut s, "review.proofing", json!({"value": true}));
|
||||
s.put_view(held);
|
||||
assert!(s.view.proofing);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use super::*;
|
||||
|
||||
/// Keyboard smart quotes stay searchable, and replacement keeps their original spelling.
|
||||
#[test]
|
||||
fn keyboard_apostrophe_finds_smart_quoted_typing() {
|
||||
let mut s = s();
|
||||
for ch in "Tau'va".chars() {
|
||||
run(&mut s, "text.insert", json!({"text": ch.to_string()}));
|
||||
}
|
||||
assert_eq!(text(&s), "Tau’va");
|
||||
for query in ["Tau'", "Tau'v", "Tau'va", "tau'VA"] {
|
||||
assert_eq!(run(&mut s, "edit.find", json!({"text": query}))["count"], 1, "{query}");
|
||||
}
|
||||
assert_eq!(run(&mut s, "edit.find", json!({"text": "tau'va", "matchCase": true}))["count"], 0);
|
||||
assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "Tau'va", "with": "[^&]", "matchCase": false}))["replaced"], 1);
|
||||
assert_eq!(text(&s), "[Tau’va]");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s), "Tau’va");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(text(&s), "[Tau’va]");
|
||||
}
|
||||
|
||||
/// Normal search uses raw UTF-8 ranges while explicit syntax and modifier letters stay exact.
|
||||
#[test]
|
||||
fn normal_apostrophe_search_keeps_exact_modes_and_original_byte_ranges() {
|
||||
let mut s = s();
|
||||
let original = "🙂 Tau'va Tau’va Tau‘va Tauʼva";
|
||||
run(&mut s, "document.setText", json!({"text": original}));
|
||||
let found = run(&mut s, "edit.find", json!({"text": "Tau'va", "wholeWord": true}));
|
||||
assert_eq!(found["count"], 3);
|
||||
let ranges: Vec<_> =
|
||||
found["matches"].as_array().unwrap().iter().map(|m| (m["start"]["off"].as_u64().unwrap(), m["end"]["off"].as_u64().unwrap())).collect();
|
||||
assert_eq!(ranges, [(5, 11), (12, 20), (21, 29)]);
|
||||
for options in [json!({"special": false}), json!({"wildcards": true}), json!({"regex": true})] {
|
||||
let mut exact = super::s();
|
||||
run(&mut exact, "document.setText", json!({"text": original}));
|
||||
let mut params = options;
|
||||
params["text"] = json!("Tau'va");
|
||||
assert_eq!(run(&mut exact, "edit.find", params)["count"], 1);
|
||||
}
|
||||
for query in ["Tau^u39va", "Tau^u8217va", "Tau’va", "Tau‘va", "Tauʼva"] {
|
||||
assert_eq!(run(&mut s, "edit.find", json!({"text": query}))["count"], 1, "{query}");
|
||||
}
|
||||
assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "Tau'va", "with": "<^&>"}))["replaced"], 3);
|
||||
assert_eq!(text(&s), "🙂 <Tau'va> <Tau’va> <Tau‘va> Tauʼva");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s), original);
|
||||
}
|
||||
@@ -469,3 +469,43 @@ fn an_east_asian_font_is_shown_as_the_face_its_text_is_drawn_in() {
|
||||
assert_eq!(m[0].shown_as, [shown], "{m:?}");
|
||||
assert!(crate::cmd::font::east_asian_shown_as(&doc, "JetBrains Mono", "JetBrains Mono").is_none(), "installed");
|
||||
}
|
||||
|
||||
/// A semicolon-separated LibreOffice font fallback list is not reported as missing when any
|
||||
/// candidate is installed, and substitutes by candidate family when none is.
|
||||
#[test]
|
||||
fn libreoffice_font_fallback_lists_are_not_reported_as_missing_when_installed() {
|
||||
let db = wordcraft_fonts::FontDb::global();
|
||||
let doc = doc_of(vec![para_in("Text", "Liberation Sans;Arial")]);
|
||||
if db.has_family("Liberation Sans") || db.has_family("Arial") {
|
||||
let m = missing_in(doc.clone());
|
||||
assert!(m.is_empty(), "expected not to be reported as missing: {m:?}");
|
||||
}
|
||||
let bundled_fallback = doc_of(vec![para_in("Text", "NonExistentSans123;Source Sans 3")]);
|
||||
assert!(missing_in(bundled_fallback).is_empty(), "bundled fallback candidate is installed");
|
||||
|
||||
// Roundtrip through DOCX export and import:
|
||||
let docx_bytes = crate::io::save_bytes("out.docx", &doc).unwrap();
|
||||
let loaded = wordcraft_docx::read(&docx_bytes).unwrap();
|
||||
assert_eq!(loaded.body[0].as_para().unwrap().runs[0].props.font.as_deref(), Some("Liberation Sans;Arial"));
|
||||
if db.has_family("Liberation Sans") || db.has_family("Arial") {
|
||||
let m = missing_in(loaded);
|
||||
assert!(m.is_empty(), "expected not to report installed fallback font as missing: {m:?}");
|
||||
}
|
||||
|
||||
// When none of the fonts is installed, missing_fonts flags it with an appropriate substitute:
|
||||
if !db.has_family("NonExistentSans123") && !db.has_family("Times New Roman") {
|
||||
let m = missing_in(doc_of(vec![para_in("Text", "NonExistentSans123;Times New Roman")]));
|
||||
assert_eq!(m.len(), 1);
|
||||
assert_eq!(m[0].font, "NonExistentSans123;Times New Roman");
|
||||
let drawn = m[0].shown_as.first().expect("drawn face");
|
||||
assert!(drawn.contains("Serif") || drawn == "Times" || drawn == "Tinos", "drawn: {drawn}");
|
||||
}
|
||||
let m = missing_in(doc_of(vec![para_in("Text", "NonExistentSans123;NonExistentSerif123")]));
|
||||
assert_eq!(m.len(), 1);
|
||||
assert_eq!(m[0].font, "NonExistentSans123;NonExistentSerif123");
|
||||
let drawn = m[0].shown_as.first().expect("drawn face");
|
||||
assert!(
|
||||
drawn.contains("Serif") || drawn == "Times" || drawn == "Times New Roman" || drawn == "Tinos" || drawn == "Caladea" || drawn == "Georgia",
|
||||
"drawn: {drawn}"
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,19 @@ use wordcraft_doc::{Pos, StoryRef};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Leaf #152: deleting the empty heading above body text must not promote that text to a heading.
|
||||
/// Deleting the empty heading mark keeps the next paragraph as body text.
|
||||
#[test]
|
||||
fn deleting_a_preceding_empty_heading_keeps_the_body_paragraph_style() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("\nBody paragraph."));
|
||||
run(&mut s, "para.style", json!({"style": "Heading1"}));
|
||||
run(&mut s, "text.delete", json!({}));
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.text, "Body paragraph.");
|
||||
assert_eq!(p.props.style, None);
|
||||
assert_eq!(wordcraft_layout::outline::levels(&s.doc), [wordcraft_layout::outline::BODY]);
|
||||
}
|
||||
|
||||
/// Create an empty session for command-driven typing fixtures.
|
||||
fn s() -> Session {
|
||||
Session::new(wordcraft_doc::Document::new())
|
||||
|
||||
@@ -161,6 +161,17 @@ fn default_read_zoom() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
impl ViewState {
|
||||
/// Take the app-wide settings a view keeps a copy of from the saved preferences: Read Mode's
|
||||
/// page colour and Check spelling as you type (#5740). A view made active goes through this
|
||||
/// (a document switched to, opened or made, a window's view put back), so a change made while
|
||||
/// another view was active holds in this one too.
|
||||
pub(crate) fn follow_prefs(&mut self, prefs: &crate::prefs::Prefs) {
|
||||
self.read_page_color = prefs.read_page_color;
|
||||
self.proofing = prefs.check_spelling;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ViewState {
|
||||
fn default() -> Self {
|
||||
ViewState {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::{Document, Path, Pos, StoryRef};
|
||||
use wordcraft_engine::{Selection, Session};
|
||||
use wordcraft_layout::outline;
|
||||
|
||||
/// Dispatch an engine command and report its ID if it fails.
|
||||
fn run(s: &mut Session, id: &str, params: Value) {
|
||||
s.run(id, ¶ms).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
}
|
||||
|
||||
/// Create an empty heading, mixed-run body text and a genuine TOC heading.
|
||||
fn fixture() -> Session {
|
||||
let mut s = Session::new(Document::from_text("\nBody paragraph.\nReal heading\n"));
|
||||
run(&mut s, "para.style", json!({"style": "Heading1"}));
|
||||
s.doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap().mark =
|
||||
CharProps { font: Some("Source Sans 3".into()), size: Some(40.0), bold: Some(true), ..Default::default() };
|
||||
let p = s.doc.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
p.mark = CharProps { size: Some(11.0), bold: Some(false), ..Default::default() };
|
||||
p.format(5, 14, &|c| c.italic = Some(true)).unwrap();
|
||||
s.doc.para_mut(StoryRef::Body, &Path::top(2)).unwrap().props.style = Some("Heading2".into());
|
||||
s
|
||||
}
|
||||
|
||||
/// Keyboard and selection deletes preserve body formatting through Undo and Redo.
|
||||
#[test]
|
||||
fn empty_heading_delete_backspace_and_selection_preserve_body_format_and_history() {
|
||||
for mode in ["delete", "backspace", "selection", "reverse selection"] {
|
||||
for pending in [false, true] {
|
||||
let mut s = fixture();
|
||||
let expected = s.doc.para_at(&Pos::body(1, 0)).unwrap().clone();
|
||||
s.sel = match mode {
|
||||
"backspace" => Selection::caret(Pos::body(1, 0)),
|
||||
"selection" => Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 0) },
|
||||
"reverse selection" => Selection { anchor: Pos::body(1, 0), focus: Pos::body(0, 0) },
|
||||
_ => Selection::caret(Pos::body(0, 0)),
|
||||
};
|
||||
if pending {
|
||||
s.pending = Some(CharProps { size: Some(48.0), bold: Some(true), ..Default::default() });
|
||||
}
|
||||
let before = serde_json::to_value(&s.doc.body).unwrap();
|
||||
run(&mut s, if mode == "backspace" { "text.backspace" } else { "text.delete" }, json!({}));
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.text, expected.text, "{mode}, pending={pending}");
|
||||
assert_eq!(p.props, expected.props);
|
||||
assert_eq!(p.mark, expected.mark);
|
||||
assert_eq!(serde_json::to_value(&p.runs).unwrap(), serde_json::to_value(&expected.runs).unwrap());
|
||||
let actual = s.doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(0));
|
||||
let old = s.doc.styles.resolve_char(expected.props.style.as_deref(), expected.props_of_char(0));
|
||||
assert_eq!((actual.font, actual.size, actual.bold, actual.italic), (old.font, old.size, old.bold, old.italic));
|
||||
assert!(s.pending.is_none());
|
||||
assert_eq!(outline::levels(&s.doc)[..2], [outline::BODY, 2]);
|
||||
let after = serde_json::to_value(&s.doc.body).unwrap();
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), before);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), after);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Saving to DOCX and generating a TOC must keep the surviving body text out of the outline.
|
||||
#[test]
|
||||
fn deleted_empty_heading_does_not_promote_body_text_into_toc_or_docx() {
|
||||
let mut s = fixture();
|
||||
run(&mut s, "text.delete", json!({}));
|
||||
assert_eq!(outline::levels(&s.doc)[..2], [outline::BODY, 2]);
|
||||
let bytes = wordcraft_docx::write(&s.doc).unwrap();
|
||||
let mut s = Session::new(wordcraft_docx::read(&bytes).unwrap());
|
||||
assert_eq!(outline::levels(&s.doc)[..2], [outline::BODY, 2]);
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.props.style.as_deref(), None);
|
||||
assert_eq!(p.mark.size, Some(11.0));
|
||||
assert_eq!(p.props_of_char(6).italic, Some(true));
|
||||
s.sel = Selection::caret(Pos::body(2, 0));
|
||||
run(&mut s, "references.toc", json!({"levels": 3}));
|
||||
let text = s.doc.plain_text(StoryRef::Body);
|
||||
assert_eq!(text.matches("Body paragraph.").count(), 1, "body text is not a TOC entry: {text}");
|
||||
assert_eq!(text.matches("Real heading").count(), 2, "the genuine heading stays in the TOC: {text}");
|
||||
}
|
||||
+55
-7
@@ -21,7 +21,7 @@ pub use fontdb::{
|
||||
pub use harfrust::Feature;
|
||||
use harfrust::{Direction, ShapeOptions, ShapePlan, ShapePlanKey, Shaper, Tag, UnicodeBuffer};
|
||||
pub use kurbo::BezPath;
|
||||
pub use script::{fallback_rank, is_emoji, needs_layout_tables, script_fallbacks};
|
||||
pub use script::{fallback_rank, is_emoji, joins_cursively, needs_layout_tables, script_fallbacks};
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::instance::Size;
|
||||
pub use ui_fallback::{
|
||||
@@ -185,13 +185,34 @@ fn direction_runs(text: &str) -> Vec<(std::ops::Range<usize>, bool)> {
|
||||
/// order: right-to-left runs are shaped right to left, then their clusters put back in text
|
||||
/// order (each cluster's glyphs keep the shaper's order); line layout reorders them visually.
|
||||
pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
|
||||
shape_in(face, text, features, map, ShapeContext::default())
|
||||
}
|
||||
|
||||
/// The text shown on either side of what is being shaped. Only a letter's form depends on it: the
|
||||
/// letters of an Arabic word join across a change of formatting, so each piece has to know what
|
||||
/// it continues (#4093). Nothing outside the shaped text is drawn or measured.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct ShapeContext<'a> {
|
||||
pub before: &'a str,
|
||||
pub after: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> ShapeContext<'a> {
|
||||
/// This context for the part `r` of `text`: what's beyond `text` where `r` reaches its edge.
|
||||
fn at_edges_of(self, text: &str, r: &std::ops::Range<usize>) -> ShapeContext<'a> {
|
||||
ShapeContext { before: if r.start == 0 { self.before } else { "" }, after: if r.end == text.len() { self.after } else { "" } }
|
||||
}
|
||||
}
|
||||
|
||||
/// [`shape`] of a piece of longer text (see [`ShapeContext`]).
|
||||
pub fn shape_in(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char, around: ShapeContext<'_>) -> Vec<ShapedGlyph> {
|
||||
if !text.chars().any(is_rtl) {
|
||||
return shape_dir(face, text, features, &map, false);
|
||||
return shape_dir(face, text, features, &map, false, around);
|
||||
}
|
||||
let mut out = Vec::with_capacity(text.len());
|
||||
for (r, rtl) in direction_runs(text) {
|
||||
let Some(sub) = text.get(r.clone()) else { continue };
|
||||
let mut g = shape_run(face, sub, features, &map, rtl);
|
||||
let mut g = shape_run_in(face, sub, features, &map, rtl, around.at_edges_of(text, &r));
|
||||
for x in &mut g {
|
||||
x.cluster += r.start;
|
||||
}
|
||||
@@ -205,7 +226,19 @@ pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(cha
|
||||
/// mark positioning. Glyphs come back in logical order like [`shape`]: clusters in text order,
|
||||
/// each cluster's glyphs in the shaper's (visual) order.
|
||||
pub fn shape_run(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char, rtl: bool) -> Vec<ShapedGlyph> {
|
||||
let g = shape_dir(face, text, features, &map, rtl);
|
||||
shape_run_in(face, text, features, map, rtl, ShapeContext::default())
|
||||
}
|
||||
|
||||
/// [`shape_run`] of a piece of longer text (see [`ShapeContext`]).
|
||||
pub fn shape_run_in(
|
||||
face: &FontFace,
|
||||
text: &str,
|
||||
features: &[Feature],
|
||||
map: impl Fn(char) -> char,
|
||||
rtl: bool,
|
||||
around: ShapeContext<'_>,
|
||||
) -> Vec<ShapedGlyph> {
|
||||
let g = shape_dir(face, text, features, &map, rtl, around);
|
||||
if !rtl {
|
||||
return g;
|
||||
}
|
||||
@@ -221,7 +254,14 @@ pub fn shape_run(face: &FontFace, text: &str, features: &[Feature], map: impl Fn
|
||||
groups.into_iter().flatten().collect()
|
||||
}
|
||||
|
||||
fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(char) -> char, rtl: bool) -> Vec<ShapedGlyph> {
|
||||
fn shape_dir(
|
||||
face: &FontFace,
|
||||
text: &str,
|
||||
features: &[Feature],
|
||||
map: &impl Fn(char) -> char,
|
||||
rtl: bool,
|
||||
around: ShapeContext<'_>,
|
||||
) -> Vec<ShapedGlyph> {
|
||||
let mut out = Vec::with_capacity(text.len());
|
||||
let shaped = face.hb().map(|hb| {
|
||||
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
|
||||
@@ -234,6 +274,10 @@ fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(ch
|
||||
for (i, c) in sub.char_indices() {
|
||||
buf.add(map(c), (r.start + i) as u32);
|
||||
}
|
||||
// After the text: adding to a buffer clears what follows it.
|
||||
let around = around.at_edges_of(text, &r);
|
||||
buf.set_pre_context(around.before);
|
||||
buf.set_post_context(around.after);
|
||||
buf.guess_segment_properties();
|
||||
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
|
||||
let plan = shape_plan(face, &shaper, &buf, features);
|
||||
@@ -386,7 +430,10 @@ mod tests {
|
||||
gb.glyph_infos().iter().zip(gb.glyph_positions()).map(|(i, p)| (i.glyph_id, i.cluster, p.x_advance, p.x_offset, p.y_offset)).collect()
|
||||
};
|
||||
let kept = |face: &FontFace, text: &str, features: &[Feature], rtl: bool| -> Vec<(u32, u32, i32, i32, i32)> {
|
||||
shape_dir(face, text, features, &|c| c, rtl).iter().map(|g| (g.gid, g.cluster as u32, g.x_advance, g.x_offset, g.y_offset)).collect()
|
||||
shape_dir(face, text, features, &|c| c, rtl, ShapeContext::default())
|
||||
.iter()
|
||||
.map(|g| (g.gid, g.cluster as u32, g.x_advance, g.x_offset, g.y_offset))
|
||||
.collect()
|
||||
};
|
||||
let db = FontDb::global();
|
||||
let faces = [db.face(DEFAULT_FAMILY, "Regular"), db.face(DEFAULT_FAMILY, "Italic"), db.face("Source Sans 3", "Regular")];
|
||||
@@ -421,7 +468,8 @@ mod tests {
|
||||
for n in 0..3 * MAX_SHAPE_PLANS as u32 {
|
||||
let features = [feature_value(b"kern", n % 2), feature_value(b"liga", n)];
|
||||
let rtl = n % 3 == 0;
|
||||
let kept: Vec<(u32, i32)> = shape_dir(&face, "office", &features, &|c| c, rtl).iter().map(|g| (g.gid, g.x_advance)).collect();
|
||||
let kept: Vec<(u32, i32)> =
|
||||
shape_dir(&face, "office", &features, &|c| c, rtl, ShapeContext::default()).iter().map(|g| (g.gid, g.x_advance)).collect();
|
||||
assert_eq!(kept, fresh(&features, rtl), "{n}");
|
||||
assert!(kept_shape_plans() <= MAX_SHAPE_PLANS, "{}", kept_shape_plans());
|
||||
}
|
||||
|
||||
@@ -18,6 +18,28 @@ fn real_script(c: char) -> Option<Script> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `c` belongs to a script whose letters join their neighbours (Arabic, Syriac, Mongolian…),
|
||||
/// so a letter's form depends on the text on either side of it.
|
||||
pub fn joins_cursively(c: char) -> bool {
|
||||
c >= '\u{0600}'
|
||||
&& matches!(
|
||||
c.script(),
|
||||
Script::Adlam
|
||||
| Script::Arabic
|
||||
| Script::Chorasmian
|
||||
| Script::Hanifi_Rohingya
|
||||
| Script::Mandaic
|
||||
| Script::Manichaean
|
||||
| Script::Mongolian
|
||||
| Script::Nko
|
||||
| Script::Old_Uyghur
|
||||
| Script::Phags_Pa
|
||||
| Script::Psalter_Pahlavi
|
||||
| Script::Sogdian
|
||||
| Script::Syriac
|
||||
)
|
||||
}
|
||||
|
||||
/// Maximal single-script byte ranges of `text`. Characters of no particular script join the run
|
||||
/// before them (or, at the start, the first run), so the ranges cover `text` exactly.
|
||||
pub(crate) fn script_runs(text: &str) -> Vec<std::ops::Range<usize>> {
|
||||
|
||||
+377
-21
@@ -32,12 +32,27 @@ fn substitutes(family: &str) -> &'static [&'static str] {
|
||||
/// as Liberation Serif for Times New Roman, or the same design under another name), rather than
|
||||
/// the generic lists any unknown font gets?
|
||||
pub fn has_named_substitutes(family: &str) -> bool {
|
||||
if family.contains(';') {
|
||||
return list_class(family).named;
|
||||
}
|
||||
substitutes_by_name(charset_base_family(family)).1
|
||||
}
|
||||
|
||||
const SANS: &[&str] = &["Carlito", "Helvetica Neue", "Arial", "Liberation Sans", "Noto Sans", "DejaVu Sans", "Source Sans 3"];
|
||||
|
||||
/// [`substitutes`], and whether they were chosen for this family by name.
|
||||
fn substitutes_by_name(family: &str) -> (&'static [&'static str], bool) {
|
||||
const SANS: &[&str] = &["Carlito", "Helvetica Neue", "Arial", "Liberation Sans", "Noto Sans", "DejaVu Sans", "Source Sans 3"];
|
||||
if family.contains(';') {
|
||||
for cand in family.split(';').map(str::trim).filter(|s| !s.is_empty()) {
|
||||
let (subs, named) = substitutes_by_name(cand);
|
||||
if named || is_thai_family(cand) || subs != SANS {
|
||||
return (subs, named);
|
||||
}
|
||||
}
|
||||
if let Some(first) = family.split(';').map(str::trim).find(|s| !s.is_empty()) {
|
||||
return substitutes_by_name(first);
|
||||
}
|
||||
}
|
||||
const SERIF: &[&str] = &["Caladea", "Times New Roman", "Liberation Serif", "Georgia", "Noto Serif", "DejaVu Serif", "Source Serif 4"];
|
||||
const MONO: &[&str] = &["Courier New", "Liberation Mono", "Menlo", "Consolas", "DejaVu Sans Mono", "JetBrains Mono"];
|
||||
const ARIAL: &[&str] = &["Liberation Sans", "Helvetica", "Helvetica Neue", "Arimo", "Source Sans 3"];
|
||||
@@ -157,11 +172,11 @@ fn substitutes_by_name(family: &str) -> (&'static [&'static str], bool) {
|
||||
/// itself: a Symbol bullet makes its line taller than the text alone. The values are those Word's
|
||||
/// PDFs give the fonts (`/Ascent`, `/Descent`).
|
||||
pub fn symbol_font_metrics(family: &str) -> Option<(f64, f64)> {
|
||||
match family.trim().to_ascii_lowercase().as_str() {
|
||||
family.split(';').map(str::trim).filter(|f| !f.is_empty()).find_map(|f| match f.to_ascii_lowercase().as_str() {
|
||||
"symbol" | "symbol (pcl6)" => Some((1.005, 0.216)),
|
||||
"wingdings" => Some((0.899, 0.205)),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The (ascent, descent) in em Word gives a line with a run in a symbol font, when that isn't the
|
||||
@@ -224,7 +239,10 @@ fn metric_twins(family: &str) -> impl Iterator<Item = &'static str> + '_ {
|
||||
/// wraps and paginates as in `family`.
|
||||
pub fn metric_compatible(family: &str, substitute: &str) -> bool {
|
||||
let s = substitute.trim();
|
||||
metric_twins(charset_base_family(family.trim())).any(|t| t.eq_ignore_ascii_case(s))
|
||||
family.split(';').map(str::trim).filter(|f| !f.is_empty()).any(|f| {
|
||||
let base = charset_base_family(f);
|
||||
base.eq_ignore_ascii_case(s) || metric_twins(base).any(|t| t.eq_ignore_ascii_case(s))
|
||||
})
|
||||
}
|
||||
|
||||
/// Naskh-style Persian/Arabic document fonts (the "B …" family names come from the common
|
||||
@@ -356,10 +374,63 @@ fn is_thai_serif(f: &str) -> bool {
|
||||
/// Is `family` a Thai document font ([`is_thai`])? A missing one is drawn in a Thai face for the
|
||||
/// whole run, never in a Latin substitute (#1549).
|
||||
pub fn is_thai_family(family: &str) -> bool {
|
||||
let f = family.to_ascii_lowercase();
|
||||
if family.contains(';') {
|
||||
return list_class(family).thai;
|
||||
}
|
||||
let f = family.trim().to_ascii_lowercase();
|
||||
is_thai(&f) || is_thai_serif(&f)
|
||||
}
|
||||
|
||||
/// What the name-based predicates answer about one semicolon-separated fallback list. Shaping asks
|
||||
/// them for every piece of every run set in the list, so the answer is worked out once per list
|
||||
/// rather than once per piece.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct ListClass {
|
||||
/// [`is_thai_family`].
|
||||
thai: bool,
|
||||
/// [`has_named_substitutes`].
|
||||
named: bool,
|
||||
}
|
||||
|
||||
/// [`ListClass`] of the semicolon-separated `family`, memoised by its text.
|
||||
fn list_class(family: &str) -> ListClass {
|
||||
let key = family_list_key(family);
|
||||
let generation = FontDb::global().generation();
|
||||
{
|
||||
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.generation != generation {
|
||||
*c = FallbackListMemo { generation, ..Default::default() };
|
||||
} else if let Some((cached, class)) = c.classes.get(&key)
|
||||
&& cached == family
|
||||
{
|
||||
return *class;
|
||||
}
|
||||
}
|
||||
let class = ListClass { thai: classify_thai(family), named: classify_named(family) };
|
||||
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.generation == generation {
|
||||
if c.classes.len() >= FAMILY_CACHE_MAX {
|
||||
c.classes.clear();
|
||||
}
|
||||
c.classes.insert(key, (family.to_string(), class));
|
||||
}
|
||||
class
|
||||
}
|
||||
|
||||
/// Whether any family of the list is a Thai document font: what [`is_thai_family`] answers.
|
||||
fn classify_thai(family: &str) -> bool {
|
||||
family.split(';').map(str::trim).filter(|f| !f.is_empty()).any(|f| {
|
||||
let f = f.to_ascii_lowercase();
|
||||
is_thai(&f) || is_thai_serif(&f)
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether any family of the list has substitutes of its own: what [`has_named_substitutes`]
|
||||
/// answers.
|
||||
fn classify_named(family: &str) -> bool {
|
||||
family.split(';').map(str::trim).filter(|f| !f.is_empty()).any(|f| substitutes_by_name(charset_base_family(f)).1)
|
||||
}
|
||||
|
||||
/// Khmer document fonts: the Khmer OS family, Khmer Mool1 and the like, Windows' and the Mac's
|
||||
/// own and Noto's. Matched by name only.
|
||||
fn is_khmer(f: &str) -> bool {
|
||||
@@ -416,8 +487,168 @@ fn resolve_cache() -> &'static Mutex<Memo> {
|
||||
}
|
||||
|
||||
const FAMILY_CACHE_MAX: usize = 4096;
|
||||
/// Family names longer than this (bytes) are resolved but never remembered.
|
||||
/// Family names longer than this (bytes) are resolved but never remembered as string keys.
|
||||
const FAMILY_KEY_MAX: usize = 256;
|
||||
/// Maximum number of direct `FontDb::has_family` catalog scans per uncached fallback list
|
||||
/// for candidate names not present in [`FontDb::families`].
|
||||
const MAX_DIRECT_CANDIDATE_PROBES: usize = 32;
|
||||
|
||||
/// Memoised fallback-list resolutions and installed-family lookups for the current [`FontDb::generation`].
|
||||
#[derive(Default)]
|
||||
struct FallbackListMemo {
|
||||
generation: u64,
|
||||
families: std::collections::HashSet<String>,
|
||||
probed: HashMap<String, bool>,
|
||||
lists: HashMap<(u64, usize), (String, (String, bool))>,
|
||||
long_resolved: HashMap<(u64, usize, bool, bool), (String, Resolved)>,
|
||||
classes: HashMap<(u64, usize), (String, ListClass)>,
|
||||
}
|
||||
|
||||
/// Shared cache for fallback-list resolutions and candidate availability checks.
|
||||
fn fallback_list_cache() -> &'static Mutex<FallbackListMemo> {
|
||||
static CACHE: OnceLock<Mutex<FallbackListMemo>> = OnceLock::new();
|
||||
CACHE.get_or_init(|| Mutex::new(FallbackListMemo::default()))
|
||||
}
|
||||
|
||||
/// Hash and byte length of `family`, used as a compact cache key for fallback lists.
|
||||
fn family_list_key(family: &str) -> (u64, usize) {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
family.hash(&mut h);
|
||||
(h.finish(), family.len())
|
||||
}
|
||||
|
||||
/// Whether `cand` is an installed family in `db`, using the memoised family set and bounded
|
||||
/// direct catalog probes so long fallback lists do not rescan the font catalog for every candidate.
|
||||
fn candidate_has_family(db: &FontDb, cand: &str, direct_probes: &mut usize) -> bool {
|
||||
let cand = cand.trim();
|
||||
if cand.is_empty() || cand.len() > FAMILY_KEY_MAX {
|
||||
return false;
|
||||
}
|
||||
let generation = db.generation();
|
||||
let lower = cand.to_ascii_lowercase();
|
||||
{
|
||||
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.generation != generation {
|
||||
*c = FallbackListMemo { generation, ..Default::default() };
|
||||
}
|
||||
if c.families.is_empty() {
|
||||
c.families = db.families().into_iter().map(|f| f.to_ascii_lowercase()).collect();
|
||||
}
|
||||
if c.families.contains(&lower) {
|
||||
return true;
|
||||
}
|
||||
if let Some(&hit) = c.probed.get(&lower) {
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
if *direct_probes == 0 {
|
||||
return false;
|
||||
}
|
||||
*direct_probes -= 1;
|
||||
let hit = db.has_family(cand);
|
||||
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.generation == generation {
|
||||
if c.probed.len() >= FAMILY_CACHE_MAX {
|
||||
c.probed.clear();
|
||||
}
|
||||
c.probed.insert(lower, hit);
|
||||
}
|
||||
hit
|
||||
}
|
||||
|
||||
/// Record a resolved `(family, substituted)` pair for a semicolon-separated fallback list.
|
||||
fn remember_list_resolution(generation: u64, key: (u64, usize), family: &str, result: (String, bool)) -> (String, bool) {
|
||||
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.generation == generation {
|
||||
if c.lists.len() >= FAMILY_CACHE_MAX {
|
||||
c.lists.clear();
|
||||
}
|
||||
c.lists.insert(key, (family.to_string(), result.clone()));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Resolve a single family or semicolon-separated fallback list to `(family_name, substituted)`.
|
||||
fn resolve_family(db: &FontDb, family: &str) -> (String, bool) {
|
||||
if !family.contains(';') {
|
||||
let base = if family.trim().eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(family) };
|
||||
return if db.has_family(family) {
|
||||
(family.to_string(), false)
|
||||
} else if base != family && db.has_family(base) {
|
||||
(base.to_string(), false)
|
||||
} else {
|
||||
(substitute(db, base).unwrap_or(crate::FALLBACK_FAMILY).to_string(), true)
|
||||
};
|
||||
}
|
||||
|
||||
let generation = db.generation();
|
||||
let list_key = family_list_key(family);
|
||||
{
|
||||
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.generation != generation {
|
||||
*c = FallbackListMemo { generation, ..Default::default() };
|
||||
} else if let Some((cached_family, r)) = c.lists.get(&list_key)
|
||||
&& cached_family == family
|
||||
{
|
||||
return r.clone();
|
||||
}
|
||||
}
|
||||
|
||||
let candidates: Vec<&str> = family.split(';').map(str::trim).filter(|s| !s.is_empty() && s.len() <= FAMILY_KEY_MAX).collect();
|
||||
if candidates.is_empty() {
|
||||
return remember_list_resolution(generation, list_key, family, (crate::FALLBACK_FAMILY.to_string(), true));
|
||||
}
|
||||
|
||||
let mut direct_probes = MAX_DIRECT_CANDIDATE_PROBES;
|
||||
|
||||
// 1. First installed family in the list:
|
||||
for &cand in &candidates {
|
||||
let base = if cand.eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(cand) };
|
||||
if candidate_has_family(db, cand, &mut direct_probes) {
|
||||
return remember_list_resolution(generation, list_key, family, (cand.to_string(), false));
|
||||
}
|
||||
if base != cand && candidate_has_family(db, base, &mut direct_probes) {
|
||||
return remember_list_resolution(generation, list_key, family, (base.to_string(), false));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. None installed: fall back to an installed substitute for an entry in the list rather than generic Carlito.
|
||||
// Try Thai substitutes before metric twins of later candidates:
|
||||
for &cand in &candidates {
|
||||
let base = if cand.eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(cand) };
|
||||
if is_thai_family(base)
|
||||
&& let Some(sub) = substitute(db, base)
|
||||
{
|
||||
return remember_list_resolution(generation, list_key, family, (sub.to_string(), true));
|
||||
}
|
||||
let mut twin_probes = MAX_DIRECT_CANDIDATE_PROBES;
|
||||
if let Some(twin) = metric_twins(base).find(|t| candidate_has_family(db, t, &mut twin_probes)) {
|
||||
return remember_list_resolution(generation, list_key, family, (twin.to_string(), true));
|
||||
}
|
||||
}
|
||||
|
||||
// Try candidates with named or category substitutes (Thai, Hebrew, Khmer, Persian, serif, mono):
|
||||
for &cand in &candidates {
|
||||
let base = if cand.eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(cand) };
|
||||
let (subs, named) = substitutes_by_name(base);
|
||||
if (named || is_thai_family(base) || subs != SANS)
|
||||
&& let Some(sub) = substitute(db, base)
|
||||
{
|
||||
return remember_list_resolution(generation, list_key, family, (sub.to_string(), true));
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to the first candidate's substitute:
|
||||
if let Some(&first) = candidates.first() {
|
||||
let base = if first.eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(first) };
|
||||
if let Some(sub) = substitute(db, base) {
|
||||
return remember_list_resolution(generation, list_key, family, (sub.to_string(), true));
|
||||
}
|
||||
}
|
||||
|
||||
remember_list_resolution(generation, list_key, family, (crate::FALLBACK_FAMILY.to_string(), true))
|
||||
}
|
||||
|
||||
/// Resolve `family` with bold/italic to a face (memoised until user fonts are added or removed,
|
||||
/// [`FontDb::generation`]).
|
||||
@@ -427,6 +658,10 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved {
|
||||
let generation = db.generation();
|
||||
let cacheable = family.len() <= FAMILY_KEY_MAX;
|
||||
let key = (if cacheable { family.to_string() } else { String::new() }, bold, italic);
|
||||
let long_key = (!cacheable && family.contains(';')).then(|| {
|
||||
let (h, len) = family_list_key(family);
|
||||
(h, len, bold, italic)
|
||||
});
|
||||
{
|
||||
let mut c = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.0 != generation {
|
||||
@@ -436,24 +671,35 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved {
|
||||
return *r;
|
||||
}
|
||||
}
|
||||
// Printer-driver Symbol aliases use the same encoding as Symbol.
|
||||
let base = if family.trim().eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(family) };
|
||||
let (fam, substituted) = if db.has_family(family) {
|
||||
(family.to_string(), false)
|
||||
} else if base != family && db.has_family(base) {
|
||||
// Windows maps the charset names to the font itself: not a substitution.
|
||||
(base.to_string(), false)
|
||||
} else {
|
||||
(substitute(db, base).unwrap_or(crate::FALLBACK_FAMILY).to_string(), true)
|
||||
};
|
||||
if let Some(lk) = long_key {
|
||||
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.generation != generation {
|
||||
*c = FallbackListMemo { generation, ..Default::default() };
|
||||
} else if let Some((cached_family, r)) = c.long_resolved.get(&lk)
|
||||
&& cached_family == family
|
||||
{
|
||||
return *r;
|
||||
}
|
||||
}
|
||||
let (fam, substituted) = resolve_family(db, family);
|
||||
let face = db.face(&fam, style_name(bold, italic));
|
||||
let r = Resolved { synth_bold: bold && face.weight < 600.0, synth_italic: italic && !face.italic, substituted, face: FaceRef::of(&face) };
|
||||
let mut c = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if cacheable && c.0 == generation {
|
||||
if c.1.len() >= FAMILY_CACHE_MAX {
|
||||
c.1.clear();
|
||||
if cacheable {
|
||||
let mut c = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.0 == generation {
|
||||
if c.1.len() >= FAMILY_CACHE_MAX {
|
||||
c.1.clear();
|
||||
}
|
||||
c.1.insert(key, r);
|
||||
}
|
||||
} else if let Some(lk) = long_key {
|
||||
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.generation == generation {
|
||||
if c.long_resolved.len() >= FAMILY_CACHE_MAX {
|
||||
c.long_resolved.clear();
|
||||
}
|
||||
c.long_resolved.insert(lk, (family.to_string(), r));
|
||||
}
|
||||
c.1.insert(key, r);
|
||||
}
|
||||
r
|
||||
}
|
||||
@@ -787,4 +1033,114 @@ mod tests {
|
||||
assert!(!has_named_substitutes(f), "{f}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Semicolon-separated fallback lists resolve to the first installed family, or to a
|
||||
/// candidate's substitute when no listed family is installed.
|
||||
#[test]
|
||||
fn libreoffice_fallback_list_resolves_first_installed_family() {
|
||||
let db = FontDb::global();
|
||||
if db.has_family("Liberation Sans") || db.has_family("Arial") {
|
||||
let r = resolve("Liberation Sans;Arial", false, false);
|
||||
assert!(!r.substituted, "should not be substituted when a candidate is installed: got {:?}", r.face.family);
|
||||
if db.has_family("Liberation Sans") {
|
||||
assert_eq!(r.face.family, "Liberation Sans");
|
||||
} else {
|
||||
assert_eq!(r.face.family, "Arial");
|
||||
}
|
||||
}
|
||||
let bundled = resolve("NonExistentSansFace123;Source Serif 4", false, false);
|
||||
assert!(!bundled.substituted);
|
||||
assert_eq!(bundled.face.family, "Source Serif 4");
|
||||
|
||||
// When none of the families is installed, fall back to a substitute for an entry in the list rather than generic Carlito:
|
||||
if !db.has_family("NonExistentSansFace123") && !db.has_family("Times New Roman") {
|
||||
let r = resolve("NonExistentSansFace123;Times New Roman", false, false);
|
||||
assert!(r.substituted);
|
||||
assert_ne!(r.face.family, "Carlito", "should not fall back to generic Carlito when an entry is Times New Roman");
|
||||
assert!(r.face.family.contains("Serif") || r.face.family == "Times" || r.face.family == "Tinos", "got {:?}", r.face.family);
|
||||
}
|
||||
let r = resolve("NonExistentSansFace123;NonExistentSerifFace123", false, false);
|
||||
assert!(r.substituted);
|
||||
assert_ne!(r.face.family, "Carlito", "should not fall back to generic Carlito when an entry is a serif family");
|
||||
assert!(
|
||||
r.face.family.contains("Serif")
|
||||
|| r.face.family == "Times"
|
||||
|| r.face.family == "Times New Roman"
|
||||
|| r.face.family == "Tinos"
|
||||
|| r.face.family == "Caladea"
|
||||
|| r.face.family == "Georgia",
|
||||
"got {:?}",
|
||||
r.face.family
|
||||
);
|
||||
|
||||
// Metric compatibility of fallback list:
|
||||
assert!(metric_compatible("Open Sans;Arial", "Liberation Sans"));
|
||||
assert!(metric_compatible("Liberation Sans;Arial", "Helvetica"));
|
||||
assert!(!metric_compatible("Open Sans;Arial", "Carlito"));
|
||||
}
|
||||
|
||||
/// In a mixed Thai-and-Latin fallback list where neither listed family is installed, an
|
||||
/// earlier Thai substitute takes precedence over a later candidate's metric twin.
|
||||
#[test]
|
||||
fn thai_fallback_list_prefers_thai_substitute_before_later_metric_twin() {
|
||||
let db = FontDb::global();
|
||||
let Some(thai_sub) = substitute(db, "NonExistentThaiUPC") else {
|
||||
eprintln!("skipped: no Thai substitute installed");
|
||||
return;
|
||||
};
|
||||
if !db.has_family("Cordia New") && !db.has_family("Arial") && metric_twins("Arial").any(|t| db.has_family(t)) {
|
||||
let r = resolve("Cordia New;Arial", false, false);
|
||||
assert!(r.substituted);
|
||||
assert!(substitutes("Cordia New").contains(&r.face.family.as_str()), "got {:?}", r.face.family);
|
||||
}
|
||||
if let Some(latin) =
|
||||
METRIC_TWINS.iter().flat_map(|g| g.iter().copied()).find(|&f| !db.has_family(f) && metric_twins(f).any(|t| db.has_family(t)))
|
||||
{
|
||||
let list = format!("NonExistentThaiUPC;{latin}");
|
||||
let r = resolve(&list, false, false);
|
||||
assert!(r.substituted);
|
||||
assert_eq!(r.face.family, thai_sub);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback lists longer than `FAMILY_KEY_MAX` still select a later installed family and
|
||||
/// cache their resolution without growing `resolve_cache` string keys.
|
||||
#[test]
|
||||
fn long_fallback_list_selects_later_installed_family_and_caches() {
|
||||
let mut candidates: Vec<String> = (0..120).map(|i| format!("MissingCandidateFont{i}")).collect();
|
||||
candidates.push("Source Serif 4".to_string());
|
||||
let list = candidates.join(";");
|
||||
assert!(list.len() > FAMILY_KEY_MAX);
|
||||
|
||||
let r1 = resolve(&list, false, false);
|
||||
let r2 = resolve(&list, false, false);
|
||||
assert!(!r1.substituted);
|
||||
assert_eq!(r1.face.family, "Source Serif 4");
|
||||
assert_eq!(r1.face.id(), r2.face.id());
|
||||
assert!(!resolve_cache().lock().unwrap_or_else(|e| e.into_inner()).1.keys().any(|k| k.0.len() > FAMILY_KEY_MAX));
|
||||
}
|
||||
|
||||
/// The name-based predicates look at every candidate, and their answer is cached per list: a
|
||||
/// later Thai or named fallback still counts, and a list is classified once rather than for
|
||||
/// every piece of text shaped in it.
|
||||
#[test]
|
||||
fn list_classification_reads_every_candidate_and_is_cached() {
|
||||
// The Thai family is last, past any shorter bound, and still classifies the list.
|
||||
let mut candidates: Vec<String> = (0..200).map(|i| format!("MissingCandidateFont{i}")).collect();
|
||||
candidates.push("TH Sarabun New".to_string());
|
||||
let thai_list = candidates.join(";");
|
||||
assert!(is_thai_family(&thai_list), "a later Thai family still counts");
|
||||
|
||||
// A named substitute at the end classifies too (Times New Roman has Liberation Serif).
|
||||
candidates.pop();
|
||||
candidates.push("Times New Roman".to_string());
|
||||
let named_list = candidates.join(";");
|
||||
assert!(has_named_substitutes(&named_list), "a later named fallback still counts");
|
||||
|
||||
// Both are memoised by list text: a second ask answers without rescanning, and a list
|
||||
// whose text differs only after the key's hash is not mistaken for the cached one.
|
||||
assert!(is_thai_family(&thai_list) && !is_thai_family(&named_list));
|
||||
let cached = fallback_list_cache().lock().unwrap_or_else(|e| e.into_inner()).classes.len();
|
||||
assert!(cached >= 2, "{cached} lists classified");
|
||||
}
|
||||
}
|
||||
@@ -427,6 +427,27 @@ mod tests {
|
||||
|
||||
const RELS: &str = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>"#;
|
||||
|
||||
#[test]
|
||||
fn workbook_sheets_find_their_relationships_by_index() {
|
||||
// Every sheet searched every relationship; 4,096 sheets over 20,000 relationships.
|
||||
let sheets: String = (0..MAX_SHEETS).map(|i| format!(r#"<sheet name="S{i}" sheetId="{i}" r:id="rId{i}"/>"#)).collect();
|
||||
let wb = format!(
|
||||
r#"<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>{sheets}</sheets></workbook>"#
|
||||
);
|
||||
let mut rels = String::from(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#);
|
||||
// A chart sheet sharing a worksheet's id doesn't hide it.
|
||||
rels.push_str(r#"<Relationship Id="rId0" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chartsheet" Target="chartsheets/c.xml"/>"#);
|
||||
for i in 0..20_000 {
|
||||
rels.push_str(&format!(
|
||||
r#"<Relationship Id="rId{i}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet{i}.xml"/>"#
|
||||
));
|
||||
}
|
||||
rels.push_str("</Relationships>");
|
||||
let names = sheet_names(&zip(&[("_rels/.rels", RELS), ("xl/workbook.xml", &wb), ("xl/_rels/workbook.xml.rels", &rels)])).unwrap();
|
||||
assert_eq!(names.len(), MAX_SHEETS);
|
||||
assert_eq!(names.first().map(String::as_str), Some("S0"));
|
||||
}
|
||||
|
||||
fn xlsx(sheet1: &str) -> Vec<u8> {
|
||||
let wb = r#"<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><workbookPr/><sheets><sheet name="People" sheetId="1" r:id="rId1"/><sheet name="Chart" sheetId="2" r:id="rId9"/><sheet name="Notes" sheetId="3" r:id="rId2"/></sheets></workbook>"#;
|
||||
let wb_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="/xl/worksheets/sheet2.xml"/><Relationship Id="rId9" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chartsheet" Target="chartsheets/sheet1.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/><Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
|
||||
|
||||
@@ -83,14 +83,13 @@ fn workbook(pkg: &mut Package<'_>) -> Result<Workbook, String> {
|
||||
}
|
||||
}
|
||||
});
|
||||
let sheets = listed
|
||||
.into_iter()
|
||||
.filter_map(|(name, id)| {
|
||||
// Worksheets only: chart and dialog sheets hold no cells.
|
||||
let r = rels.iter().find(|r| r.id == id && r.kind.ends_with("/worksheet"))?;
|
||||
Some(SheetRef { name, path: r.target.clone() })
|
||||
})
|
||||
.collect();
|
||||
// Worksheets only: chart and dialog sheets hold no cells. Indexed by id once (the first
|
||||
// relationship with an id wins), not searched for every sheet.
|
||||
let mut worksheets: HashMap<&str, &str> = HashMap::new();
|
||||
for r in rels.iter().filter(|r| r.kind.ends_with("/worksheet")) {
|
||||
worksheets.entry(r.id.as_str()).or_insert(r.target.as_str());
|
||||
}
|
||||
let sheets = listed.into_iter().filter_map(|(name, id)| Some(SheetRef { name, path: worksheets.get(id.as_str())?.to_string() })).collect();
|
||||
let part = |suffix: &str| rels.iter().find(|r| r.kind.ends_with(suffix)).map(|r| r.target.clone());
|
||||
Ok(Workbook { sheets, date1904, shared: part("/sharedStrings"), styles: part("/styles") })
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ fn layout_table_block(input: TableBlockContext<'_, '_, '_>) -> bool {
|
||||
let layout = table::layout_table(ctx, story, t, path, width, depth + 1);
|
||||
let (tx, ty) = match t.props.float {
|
||||
Some(float) => {
|
||||
let legacy = if ctx.doc.settings.compat_mode < 15 { table::first_cell_left_margin(ctx, t) } else { 0.0 };
|
||||
let legacy = (ctx.doc.settings.compat_mode < 15).then(|| table::first_cell_left_margin(ctx, t));
|
||||
let (rect, area) = box_float_table_rect(frame, width, *y, &layout, &float, legacy, float_tables);
|
||||
exclusions.push((area, false, None, WrapText::BothSides, true));
|
||||
float_tables.push(area);
|
||||
@@ -464,14 +464,15 @@ pub(crate) struct BoxLayout {
|
||||
|
||||
/// Where floating table `tl` goes in a box `width` wide whose next paragraph starts at `y`, and
|
||||
/// the area text keeps clear of. Positions relative to the page or its margins need the box's
|
||||
/// `frame`; without one (a table cell) they're relative to the box.
|
||||
/// `frame`; without one (a table cell) they're relative to the box. `legacy` is the first cell's
|
||||
/// left margin before Word 2013 layout, `None` in it.
|
||||
fn box_float_table_rect(
|
||||
frame: Option<PageFrame>,
|
||||
width: f32,
|
||||
y: f32,
|
||||
tl: &table::TableLayout,
|
||||
f: &TableFloat,
|
||||
legacy: f32,
|
||||
legacy: Option<f32>,
|
||||
float_tables: &[Rect],
|
||||
) -> (Rect, Rect) {
|
||||
let h = tl.rows.iter().map(|r| r.height).sum::<f32>().clamp(0.0, 100_000.0);
|
||||
@@ -507,18 +508,19 @@ fn box_float_table_rect(
|
||||
};
|
||||
// Nor is there an area to align in: the table starts where the text is.
|
||||
let v_align = if frame.is_none() && matches!(f.v_rel, Anchor::Page | Anchor::Margin) { None } else { f.v_align };
|
||||
let x = align_in(h_area, f.x, w, PageFrame::h_align(frame.as_ref(), f.h_align)) - if f.h_align.is_none() { legacy } else { 0.0 };
|
||||
let r = clear_of_float_tables(Rect::new(x, align_in(v_area, y_off, h, v_align), w, h), f, legacy, h_area.0, float_tables);
|
||||
let x = align_in(h_area, f.x, w, PageFrame::h_align(frame.as_ref(), f.h_align)) - if f.h_align.is_none() { legacy.unwrap_or(0.0) } else { 0.0 };
|
||||
let r = clear_of_float_tables(Rect::new(x, align_in(v_area, y_off, h, v_align), w, h), f, legacy.is_none(), h_area.0, float_tables);
|
||||
let [dl, dt, dr, db] = f.dist_from_text();
|
||||
(r, r.inset(-dl, -dt, -dr, -db))
|
||||
}
|
||||
|
||||
/// Floating table `f` at `r`, moved below the floating tables in its way when it may not
|
||||
/// overlap them. In Word 2013+ layout one placed by an offset keeps its distance from the text's
|
||||
/// left edge (`text_left`).
|
||||
pub(crate) fn clear_of_float_tables(mut r: Rect, f: &TableFloat, legacy: f32, text_left: f32, others: &[Rect]) -> Rect {
|
||||
/// overlap them. In Word 2013+ layout (`modern`) one placed by an offset keeps its distance from
|
||||
/// the text's left edge (`text_left`); before it, it keeps its own left edge, also when its first
|
||||
/// cell has no left margin.
|
||||
pub(crate) fn clear_of_float_tables(mut r: Rect, f: &TableFloat, modern: bool, text_left: f32, others: &[Rect]) -> Rect {
|
||||
let [dl, dt, dr, db] = f.dist_from_text();
|
||||
let keeps_text_distance = legacy == 0.0 && f.h_align.is_none() && f.x >= 0.0 && matches!(f.h_rel, Anchor::Margin | Anchor::Column);
|
||||
let keeps_text_distance = modern && f.h_align.is_none() && f.x >= 0.0 && matches!(f.h_rel, Anchor::Margin | Anchor::Column);
|
||||
for _ in 0..64 {
|
||||
let area = r.inset(-dl, -dt, -dr, -db);
|
||||
let Some(below) = others.iter().filter(|o| !f.overlap && o.intersects(&area)).map(|o| o.bottom()).reduce(f32::max) else { break };
|
||||
|
||||
@@ -637,9 +637,20 @@ fn inline_objects(ctx: &LineContext<'_, '_, '_>, out: &mut Vec<Draw>) {
|
||||
}
|
||||
let cx = x + line.cl_left(k).unwrap_or(0.0);
|
||||
let obj = para.and_then(|p| p.objects.get(oi));
|
||||
let rect = inline_rect(obj, cx, base, c.adv, (c.obj_h, c.obj_d), pl.rule_room(line, k));
|
||||
let modern = doc.settings.compat_mode >= wordcraft_doc::COMPAT_MODE_CURRENT;
|
||||
let rect = inline_rect(obj, cx, base, c.adv, (c.obj_h, c.obj_d), pl.rule_room(line, k), modern);
|
||||
let spin = obj.and_then(InlineObject::frame).map(|(_, _, f)| f.spin()).unwrap_or_default();
|
||||
match obj {
|
||||
// A turn its line keeps no room for overhangs the line; Word cuts it at the line's top
|
||||
// and bottom, not at its sides.
|
||||
Some(o @ (InlineObject::Image { .. } | InlineObject::Shape { .. } | InlineObject::Group { .. }))
|
||||
if o.frame().is_some_and(|(w, h, f)| f.is_bare_turn() && f.spin_pad(w, h).1 > f.line_pad(w, h, modern).1 + 0.01) =>
|
||||
{
|
||||
let mut items = Vec::new();
|
||||
object_draws(o, rect, Spin::default(), alpha, opts, &mut items);
|
||||
let band = Rect::new(rect.x - 4000.0, ctx.top, rect.w + 8000.0, (ctx.bottom - ctx.top).max(0.0));
|
||||
out.push(Draw::Clip { rect: band, items });
|
||||
}
|
||||
Some(o @ (InlineObject::Image { .. } | InlineObject::Shape { .. } | InlineObject::Group { .. })) => {
|
||||
object_draws(o, rect, Spin::default(), alpha, opts, out)
|
||||
}
|
||||
|
||||
@@ -487,8 +487,9 @@ pub(crate) fn effects_in(effects: wordcraft_doc::effects::ShapeEffects, spin: Sp
|
||||
/// `obj_h` above the baseline and `obj_d` below it): inside the room kept for its effects.
|
||||
/// A rotated one's unrotated frame, centred in that room.
|
||||
///
|
||||
/// `line` is the room a horizontal rule is placed in, from `cx`.
|
||||
pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f32, (obj_h, obj_d): (f32, f32), line: f32) -> Rect {
|
||||
/// `line` is the room a horizontal rule is placed in, from `cx`; `modern`: Word 2013+ layout
|
||||
/// (see [`wordcraft_doc::para::Float::line_pad`]).
|
||||
pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f32, (obj_h, obj_d): (f32, f32), line: f32, modern: bool) -> Rect {
|
||||
let frame = obj.and_then(InlineObject::frame);
|
||||
let [l, t, r, b] = frame.map_or([0.0; 4], |(_, _, float)| float.effect_extent());
|
||||
// Lowered further than its height, it is drawn with its top on the baseline (Word).
|
||||
@@ -503,9 +504,9 @@ pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f
|
||||
return Rect::new(hr.left(room.x, line, rw), room.y, rw, room.h);
|
||||
}
|
||||
let Some((w, h, float)) = frame.filter(|(_, _, f)| f.rot != 0.0) else { return room };
|
||||
// The room is the rotated bounds of the frame: undo the rotation.
|
||||
// The room is what the line kept for the turned frame: undo that.
|
||||
let (w, h) = (wordcraft_geom::finite(w).clamp(1.0, 4000.0), wordcraft_geom::finite(h).clamp(1.0, 4000.0));
|
||||
let (bw, _) = float.spin().extent(w, h);
|
||||
let bw = w + 2.0 * float.line_pad(w, h, modern).0;
|
||||
let k = if bw > 0.0 { room.w / bw } else { 1.0 };
|
||||
let (fw, fh) = (w * k, h * k);
|
||||
Rect::new(room.x + (room.w - fw) / 2.0, room.y + (room.h - fh) / 2.0, fw, fh)
|
||||
|
||||
@@ -751,7 +751,8 @@ pub(crate) fn place_objects(ctx: &mut Ctx, req: &ObjectPlacement) -> (Vec<Placed
|
||||
}
|
||||
} else {
|
||||
let cx = x + line.cl_left(k).unwrap_or(0.0);
|
||||
display::inline_rect(Some(obj), cx, y + (line.baseline - fl.top), c.adv, (c.obj_h, c.obj_d), pl.rule_room(line, k))
|
||||
let modern = ctx.doc.settings.compat_mode >= wordcraft_doc::COMPAT_MODE_CURRENT;
|
||||
display::inline_rect(Some(obj), cx, y + (line.baseline - fl.top), c.adv, (c.obj_h, c.obj_d), pl.rule_room(line, k), modern)
|
||||
};
|
||||
let (mut behind, mut front) = (Vec::new(), Vec::new());
|
||||
let layer = if float.wrap == Wrap::BehindText { &mut behind } else { &mut front };
|
||||
|
||||
@@ -281,6 +281,17 @@ impl<'a> Flow<'a> {
|
||||
if self.pl.rp.before_auto && self.block == 0 {
|
||||
before = 0.0;
|
||||
}
|
||||
// An empty paragraph after a document's leading table, placed floating on this page, has no
|
||||
// space before (Word's PDFs: 0 pt and 12 pt before put the text after it at the same place).
|
||||
// Only that shape is observed: later pages and columns keep theirs.
|
||||
if self.block == 1
|
||||
&& pb.fresh()
|
||||
&& !pb.web
|
||||
&& pb.pages.last().is_some_and(|pg| pg.float_tables.contains(&Path::top(0)))
|
||||
&& only_markers(self.p, 0..self.p.text.len())
|
||||
{
|
||||
before = 0.0;
|
||||
}
|
||||
before
|
||||
}
|
||||
|
||||
@@ -816,7 +827,8 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
|
||||
let mut f = Flow::new(ctx, pb, p, (block, body_top));
|
||||
let spot = |pb: &PageBuilder| (pb.pages.len(), pb.col);
|
||||
let placed_at = spot(pb);
|
||||
let broke_before = f.pl.rp.page_break_before && !pb.at_top() && !pb.web;
|
||||
// A continuous section's top mid-page is not a page top, so the break still applies.
|
||||
let broke_before = f.pl.rp.page_break_before && !pb.fresh() && !pb.web;
|
||||
if broke_before {
|
||||
pb.new_page(block, body_top);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::chars::{
|
||||
use super::grid::east_asian;
|
||||
use super::model::{ClKind, Cluster, Enclosed, Glyph, ParaEnv, RubyLayout, StyleRun, SymbolRun};
|
||||
use super::{Arc, Document, FaceRef, NumeralMode, ResolvedChar, RubyAlign, SOFT_HYPHEN};
|
||||
use wordcraft_fonts::ShapeContext;
|
||||
|
||||
pub(super) struct Builder<'a> {
|
||||
pub(super) env: &'a ParaEnv<'a>,
|
||||
@@ -226,6 +227,19 @@ impl<'a> Builder<'a> {
|
||||
|
||||
/// `dir`: the bidi levels `text` is shaped by (see [`Dir`]).
|
||||
pub(super) fn shape_with(&mut self, text: &str, base: usize, rc: &Arc<ResolvedChar>, kind_override: Option<ClKind>, dir: Dir<'_>) {
|
||||
self.shape_around(text, base, rc, kind_override, dir, ShapeContext::default());
|
||||
}
|
||||
|
||||
/// `around`: the text shown beside `text`, which its first and last letters may join.
|
||||
pub(super) fn shape_around(
|
||||
&mut self,
|
||||
text: &str,
|
||||
base: usize,
|
||||
rc: &Arc<ResolvedChar>,
|
||||
kind_override: Option<ClKind>,
|
||||
dir: Dir<'_>,
|
||||
around: ShapeContext<'_>,
|
||||
) {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -374,10 +388,15 @@ impl<'a> Builder<'a> {
|
||||
let from = usize::from(rc.kerned());
|
||||
let to = if ligatures.is_some() { all.len() } else { 1 };
|
||||
let features: &[wordcraft_fonts::Feature] = all.get(from..to).unwrap_or_default();
|
||||
// Letters join across a change of font or direction inside the run too.
|
||||
let around = ShapeContext {
|
||||
before: if a == 0 { around.before } else { text.get(..a).unwrap_or("") },
|
||||
after: if b == text.len() { around.after } else { text.get(b..).unwrap_or("") },
|
||||
};
|
||||
let shaped = if use_levels {
|
||||
wordcraft_fonts::shape_run(&st.face, shape_text, features, map, key.rtl)
|
||||
wordcraft_fonts::shape_run_in(&st.face, shape_text, features, map, key.rtl, around)
|
||||
} else {
|
||||
wordcraft_fonts::shape(&st.face, shape_text, features, map)
|
||||
wordcraft_fonts::shape_in(&st.face, shape_text, features, map, around)
|
||||
};
|
||||
let k = st.size / st.face.upem.max(1.0) as f32;
|
||||
let hscale = rc.scale / 100.0;
|
||||
|
||||
@@ -130,13 +130,17 @@ pub(super) const MISSING_ROMAN_FONT: &str = "Cambria";
|
||||
/// and where it isn't installed Liberation Serif has its metrics; Word never meets it missing.
|
||||
pub(super) fn lookup_family<'f>(doc: &Document, font: &'f str) -> &'f str {
|
||||
let db = wordcraft_fonts::FontDb::global();
|
||||
let roman = doc.settings.roman_fonts.iter().any(|f| f.eq_ignore_ascii_case(font));
|
||||
let roman = !doc.settings.roman_fonts.is_empty()
|
||||
&& font.split(';').map(str::trim).filter(|f| !f.is_empty()).any(|f| doc.settings.roman_fonts.iter().any(|r| r.eq_ignore_ascii_case(f)));
|
||||
if roman
|
||||
&& !wordcraft_fonts::word::is_thai_family(font)
|
||||
&& !wordcraft_doc::symbol_fonts::is_symbol_font(font)
|
||||
&& !wordcraft_fonts::word::has_named_substitutes(font)
|
||||
&& !db.has_family(font)
|
||||
&& !db.has_family(wordcraft_fonts::word::charset_base_family(font))
|
||||
&& if font.contains(';') {
|
||||
wordcraft_fonts::word::resolve(font, false, false).substituted
|
||||
} else {
|
||||
!db.has_family(font) && !db.has_family(wordcraft_fonts::word::charset_base_family(font))
|
||||
}
|
||||
{
|
||||
MISSING_ROMAN_FONT
|
||||
} else {
|
||||
|
||||
@@ -92,6 +92,8 @@ struct Scan {
|
||||
j: usize,
|
||||
/// Tab cluster, stop, and x where the tab started.
|
||||
pending_tab: Option<(usize, TabStop, f32)>,
|
||||
/// The text after a pending decimal tab looked at so far for its '.'.
|
||||
decimal: Option<DecimalScan>,
|
||||
/// The width of the spaces so far on this line.
|
||||
space_w: f32,
|
||||
has_tab: bool,
|
||||
@@ -113,17 +115,15 @@ pub(crate) fn takes_room(c: &Cluster) -> bool {
|
||||
c.kind != ClKind::Marker && !(matches!(c.kind, ClKind::Object(_)) && c.adv == 0.0 && c.obj_h == 0.0)
|
||||
}
|
||||
|
||||
/// The width up to the first '.' of the text after the tab at `tj`, up to cluster `j`.
|
||||
fn width_to_decimal(pl: &ParaLayout, tj: usize, j: usize) -> Option<f32> {
|
||||
let mut w = 0.0;
|
||||
for k in tj + 1..=j {
|
||||
let cc = pl.clusters.get(k)?;
|
||||
if pl_text_at(cc) {
|
||||
return Some(w);
|
||||
}
|
||||
w += cc.adv;
|
||||
}
|
||||
None
|
||||
/// How far the text after a decimal tab has been looked at for its first '.' (see
|
||||
/// [`Scan::width_to_decimal`]): the tab, the next cluster to look at, the width before it, and
|
||||
/// whether that cluster is the '.'.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct DecimalScan {
|
||||
tab: usize,
|
||||
next: usize,
|
||||
width: f32,
|
||||
found: bool,
|
||||
}
|
||||
|
||||
/// The objects' exclusions mirrored across a line of `width`, for a right-to-left paragraph.
|
||||
@@ -279,6 +279,7 @@ impl<'e> Breaker<'e> {
|
||||
end: LineEnd::Para,
|
||||
j: self.i,
|
||||
pending_tab: None,
|
||||
decimal: None,
|
||||
space_w: 0.0,
|
||||
has_tab: false,
|
||||
skip: false,
|
||||
@@ -297,6 +298,9 @@ impl<'e> Breaker<'e> {
|
||||
};
|
||||
let end = lab.x + lab.width;
|
||||
match self.level.map(|l| l.suffix).unwrap_or(LevelSuffix::Tab) {
|
||||
// An empty label (a "none" level with no text) that ends right at the left indent takes
|
||||
// no tab: the text starts there, as in Word (#4985), and not at the next stop.
|
||||
LevelSuffix::Tab if lab.width <= 0.01 && (end - self.rp.indent_left).abs() <= 0.01 => end,
|
||||
LevelSuffix::Tab => {
|
||||
let t = next_tab(end, &self.rp.tabs, self.default_tab, self.hanging_at, self.tab_origin).pos;
|
||||
// The level's own tab stop after the number, when it comes first.
|
||||
@@ -914,6 +918,29 @@ impl Scan {
|
||||
false
|
||||
}
|
||||
|
||||
/// The width up to the first '.' of the text after the tab at `tj`, up to cluster `j`. The
|
||||
/// text is shifted after each of its clusters, so the clusters already looked at are not
|
||||
/// walked again: a long run without a '.' after a decimal tab is looked at once, not once
|
||||
/// per cluster.
|
||||
fn width_to_decimal(&mut self, pl: &ParaLayout, tj: usize, j: usize) -> Option<f32> {
|
||||
let start = DecimalScan { tab: tj, next: tj + 1, width: 0.0, found: false };
|
||||
let d = self.decimal.get_or_insert(start);
|
||||
if d.tab != tj || d.next > j.saturating_add(1) {
|
||||
*d = start;
|
||||
}
|
||||
while !d.found && d.next <= j {
|
||||
crate::work::steps(1);
|
||||
let cc = pl.clusters.get(d.next)?;
|
||||
if pl_text_at(cc) {
|
||||
d.found = true;
|
||||
} else {
|
||||
d.width += cc.adv;
|
||||
d.next += 1;
|
||||
}
|
||||
}
|
||||
d.found.then_some(d.width)
|
||||
}
|
||||
|
||||
/// Text after a decimal/centre/right tab shifts as it grows.
|
||||
fn shift_pending_tab(&mut self, pl: &mut ParaLayout) {
|
||||
let Some((tj, stop, tx)) = self.pending_tab else { return };
|
||||
@@ -922,7 +949,7 @@ impl Scan {
|
||||
TabAlign::Right => stop.pos - tx - seg_w,
|
||||
TabAlign::Center => stop.pos - tx - seg_w / 2.0,
|
||||
// Width up to the first '.' in the segment.
|
||||
TabAlign::Decimal => stop.pos - tx - width_to_decimal(pl, tj, self.j).unwrap_or(seg_w),
|
||||
TabAlign::Decimal => stop.pos - tx - self.width_to_decimal(pl, tj, self.j).unwrap_or(seg_w),
|
||||
_ => 0.0,
|
||||
};
|
||||
let shift = shift.max(0.0);
|
||||
|
||||
@@ -6,6 +6,8 @@ use super::justify::hyphenation_points;
|
||||
use super::lines::break_lines;
|
||||
use super::model::{ClKind, Cluster, Enclosed, Glyph, Label, LabelRun, ParaEnv, RubyLayout, undrawable_bullet};
|
||||
use super::proof::{ProofRun, is_list_item, proof_issues};
|
||||
use wordcraft_fonts::ShapeContext;
|
||||
|
||||
use super::{
|
||||
Arc, COLUMN_BREAK, CharProps, Document, FIELD_SHADING, InlineObject, LINE_BREAK, NoteKind, OBJ, PAGE_BREAK, PTabAlign, PTabBase, ParaLayout,
|
||||
Paragraph, ResolvedChar, ResolvedPara, TabLeader, field_code_text, field_text_on, reference_link,
|
||||
@@ -33,6 +35,8 @@ struct Cx<'a> {
|
||||
math_props: wordcraft_doc::math::MathProps,
|
||||
/// The first character, if it is to be a drop cap.
|
||||
drop_first: Option<char>,
|
||||
/// The text on the page, when its letters can join across runs (see [`shown_text`]).
|
||||
shown: Option<String>,
|
||||
}
|
||||
|
||||
/// Where a cluster for an object or a break goes in the paragraph: its text, style and first glyph.
|
||||
@@ -83,6 +87,10 @@ struct Shaped<'a> {
|
||||
bare: Vec<std::ops::Range<usize>>,
|
||||
/// Each run's proofing language and whether it is checked (spelling, grammar, hyphenation).
|
||||
runs: Vec<ProofRun>,
|
||||
/// How far into the shown text ([`Cx::shown`]) the runs so far have come.
|
||||
shown_at: usize,
|
||||
/// Where in the shown text a letter's context may begin: past the drop cap, once one is drawn.
|
||||
shown_floor: usize,
|
||||
}
|
||||
|
||||
fn is_special(c: char) -> bool {
|
||||
@@ -186,6 +194,8 @@ impl<'a> Shaped<'a> {
|
||||
left: Vec::new(),
|
||||
bare: Vec::new(),
|
||||
runs: Vec::new(),
|
||||
shown_at: 0,
|
||||
shown_floor: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,24 +246,44 @@ impl<'a> Shaped<'a> {
|
||||
|
||||
/// A run shown: its text, with the breaks and objects in it as clusters of their own.
|
||||
fn visible_run(&mut self, cx: &Cx, (rc, props): (&Arc<ResolvedChar>, &CharProps), range: &std::ops::Range<usize>, text: &str) {
|
||||
let shown_start = self.shown_at;
|
||||
let around = self.next_shown(cx, text.len());
|
||||
let mut seg = self.drop_cap_letter(cx, rc, text, range.start);
|
||||
if seg > 0 {
|
||||
// A drop cap is drawn apart from its word: nothing after it joins it, in this run or
|
||||
// in the runs after it (the letter can be formatted on its own).
|
||||
self.shown_floor = shown_start.saturating_add(seg);
|
||||
}
|
||||
// The first piece continues what came before the run. After a drop cap or a break nothing
|
||||
// joins: that letter is drawn apart, and a break is no letter. The same goes for the end.
|
||||
let mut before = if seg == 0 { around.before } else { "" };
|
||||
for (i, c) in text.char_indices() {
|
||||
if i < seg || !is_special(c) {
|
||||
continue;
|
||||
}
|
||||
if let Some(s) = text.get(seg..i) {
|
||||
self.b.shape(s, range.start + seg, rc, None);
|
||||
self.b.shape_around(s, range.start + seg, rc, None, Dir::Para, ShapeContext { before, after: "" });
|
||||
}
|
||||
before = "";
|
||||
seg = i + c.len_utf8();
|
||||
let style = self.b.style(rc, None, false);
|
||||
let slot = Slot { start: range.start + i, end: range.start + i + c.len_utf8(), style, g: self.b.glyphs.len() as u32 };
|
||||
self.special(cx, c, slot, (rc, props));
|
||||
}
|
||||
if let Some(s) = text.get(seg..) {
|
||||
self.b.shape(s, range.start + seg, rc, None);
|
||||
self.b.shape_around(s, range.start + seg, rc, None, Dir::Para, ShapeContext { before, after: around.after });
|
||||
}
|
||||
}
|
||||
|
||||
/// The text shown on either side of the next `len` bytes shown, and moves past them. What
|
||||
/// comes before starts after a drop cap ([`Shaped::shown_floor`]).
|
||||
fn next_shown<'c>(&mut self, cx: &'c Cx, len: usize) -> ShapeContext<'c> {
|
||||
let Some(shown) = &cx.shown else { return ShapeContext::default() };
|
||||
let start = self.shown_at;
|
||||
self.shown_at = start.saturating_add(len);
|
||||
ShapeContext { before: shown.get(self.shown_floor.min(start)..start).unwrap_or(""), after: shown.get(self.shown_at..).unwrap_or("") }
|
||||
}
|
||||
|
||||
/// Drop cap: the first character, sized so its capital spans `lines` lines. Returns how much
|
||||
/// of the run's text it took.
|
||||
fn drop_cap_letter(&mut self, cx: &Cx, rc: &Arc<ResolvedChar>, text: &str, start: usize) -> usize {
|
||||
@@ -367,7 +397,7 @@ impl<'a> Shaped<'a> {
|
||||
// The line makes room for effects (shadows) around the picture too, and
|
||||
// for a rotated one's bounds, as Word does.
|
||||
let [el, et, er, eb] = float.effect_extent();
|
||||
let (px, py) = float.spin_pad(w * s, h * s);
|
||||
let (px, py) = float.line_pad(w * s, h * s, env.doc.settings.compat_mode >= wordcraft_doc::COMPAT_MODE_CURRENT);
|
||||
let tall = h * s + et + eb + 2.0 * py;
|
||||
// Lowered further than its height, its top stays on the baseline while the line
|
||||
// reaches as far down as the lowering (Word, measured: a 6 pt picture lowered 30 pt
|
||||
@@ -593,6 +623,26 @@ fn joined_cluster_starts(p: &Paragraph, clusters: &[Cluster]) -> Vec<usize> {
|
||||
joined
|
||||
}
|
||||
|
||||
/// The paragraph's text without the runs left out of the layout, if it has letters that join their
|
||||
/// neighbours: a letter joins the next one on the page, across a change of formatting and past
|
||||
/// text that isn't shown (#4093). `None` for every other paragraph, which don't need it.
|
||||
fn shown_text(cx: &Cx) -> Option<String> {
|
||||
let text = &cx.p.text;
|
||||
if text.is_ascii() || !text.chars().any(wordcraft_fonts::joins_cursively) {
|
||||
return None;
|
||||
}
|
||||
let mut shown = String::with_capacity(text.len());
|
||||
for (range, props) in cx.p.run_ranges() {
|
||||
let rc = cx.res.char(props);
|
||||
if !left_out(cx.env.doc, &rc, cx.env.show_hidden, cx.env.hide_deleted)
|
||||
&& let Some(run) = text.get(range)
|
||||
{
|
||||
shown.push_str(run);
|
||||
}
|
||||
}
|
||||
Some(shown)
|
||||
}
|
||||
|
||||
/// Lay out one paragraph.
|
||||
pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
let doc = env.doc;
|
||||
@@ -609,7 +659,9 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
shown: Vec::new(),
|
||||
substituted: Vec::new(),
|
||||
};
|
||||
let cx = Cx { p, env, rp: &rp, res, math_props: doc.settings.math.clone().unwrap_or_default(), drop_first: drop_cap_char(p, &rp) };
|
||||
let mut cx =
|
||||
Cx { p, env, rp: &rp, res, math_props: doc.settings.math.clone().unwrap_or_default(), drop_first: drop_cap_char(p, &rp), shown: None };
|
||||
cx.shown = shown_text(&cx);
|
||||
let mut sh = Shaped::new(b, env.eq_number);
|
||||
let mark_rc = cx.res.char(&p.mark);
|
||||
let mark_style = sh.b.style(&mark_rc, None, false);
|
||||
@@ -785,27 +837,36 @@ fn break_positions(text: &str, rp: &wordcraft_doc::resolve::ResolvedPara) -> Vec
|
||||
}
|
||||
|
||||
// Word breaks after a hyphen even when a digit follows ("2016-" / "2022"), where UAX #14
|
||||
// keeps a hyphen and a number together (LB25): add those positions back. Only U+002D before
|
||||
// ASCII digits is added here; a non-breaking hyphen (U+2011) and a minus sign (U+2212) stay
|
||||
// unbreakable, and the other dashes keep their ordinary UAX #14 breaks.
|
||||
// keeps a hyphen and a number together (LB25), and when a slash and a letter follow
|
||||
// ("Dataprotection-" / "/Secrecyviolation", #3408), where LB13 forbids a break before the
|
||||
// slash: add those positions back. Word keeps "fitness-/1589" whole, so a slash before a digit
|
||||
// gains no break. Only U+002D is added here; a non-breaking hyphen (U+2011) and a minus sign
|
||||
// (U+2212) stay unbreakable, and the other dashes keep their ordinary UAX #14 breaks.
|
||||
let hyphen_breaks: Vec<usize> = text
|
||||
.match_indices('-')
|
||||
.filter_map(|(pos, _)| {
|
||||
let i = pos + 1;
|
||||
text.get(i..).and_then(|t| t.chars().next()).is_some_and(|n| n.is_ascii_digit()).then_some(i)
|
||||
let mut next = text.get(i..).unwrap_or_default().chars();
|
||||
match next.next() {
|
||||
Some(n) if n.is_ascii_digit() => Some(i),
|
||||
Some('/') => next.next().is_some_and(char::is_alphabetic).then_some(i),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if !hyphen_breaks.is_empty() {
|
||||
found.extend(hyphen_breaks);
|
||||
found.sort_unstable();
|
||||
found.dedup();
|
||||
}
|
||||
found.extend(hyphen_breaks);
|
||||
// The breaks added above come after UAX #14's: put them back in order.
|
||||
found.sort_unstable();
|
||||
found.dedup();
|
||||
// Myanmar has no spaces between words: it wraps between syllables.
|
||||
if text.chars().any(|c| matches!(u32::from(c), 0x1000..=0x109F | 0xA9E0..=0xA9FF | 0xAA60..=0xAA7F)) {
|
||||
found.extend(wordcraft_doc::cluster::myanmar_breaks(text));
|
||||
found.sort_unstable();
|
||||
found.dedup();
|
||||
}
|
||||
// UAX #14 breaks between a full stop and an opening parenthesis; Word keeps
|
||||
// "2023.(Example" whole and wraps it at the space before.
|
||||
found.retain(|&i| !(text.get(..i).is_some_and(|t| t.ends_with('.')) && text.get(i..).is_some_and(|t| t.starts_with('('))));
|
||||
// Asian typography: kinsoku and breaking Latin words anywhere.
|
||||
crate::kinsoku::apply(text, &mut found, rp.kinsoku, rp.word_wrap);
|
||||
found
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::collections::VecDeque;
|
||||
|
||||
use super::cache::{LayoutCache, Totals, env_hash, hash_of};
|
||||
use super::ctx::{Ctx, Pass, with_pct_base};
|
||||
use super::floats::{EarlyFloats, WrapArea, early_covered};
|
||||
use super::floats::{EarlyFloats, WrapArea, early_covered, left_out_objects};
|
||||
use super::flow::{OutlinePass, place_outline_para, place_para};
|
||||
use super::headers::{HfLinks, bookmark_pages, headers_footers, headers_footers_refer_to_pages, marks_key, mirror_even_pages, section_side};
|
||||
use super::notes::{MAX_NOTE_PAGES, NoteSeps, note_info, place_endnotes};
|
||||
@@ -10,9 +10,10 @@ use super::page::{PageBuilder, body_top_for, foot_top_for, item_bottom};
|
||||
use super::placed::Placed;
|
||||
use super::table_flow::place_table;
|
||||
use super::{
|
||||
Arc, Block, Counters, DocLayout, Document, FieldCtx, HashMap, LayoutOptions, Page, Path, SectionProps, SectionStart, StoryRef, TextDirection,
|
||||
ViewMode, balloons, para, vertical,
|
||||
Arc, Block, Counters, DocLayout, Document, FieldCtx, HashMap, InlineObject, LayoutOptions, Page, Paragraph, Path, SectionProps, SectionStart,
|
||||
StoryRef, TextDirection, ViewMode, balloons, para, vertical,
|
||||
};
|
||||
use wordcraft_doc::legacy_form::LegacyForm;
|
||||
|
||||
/// How the view lays the document out: `web` for the continuous views, `reflow` for those and
|
||||
/// the reading view, which both ignore the sections' page geometry.
|
||||
@@ -127,18 +128,35 @@ fn section_marks(doc: &Document, opts: &LayoutOptions, sections: &[(usize, &Sect
|
||||
end.checked_sub(1).and_then(|i| doc.body.get(i)).and_then(|b| b.as_para()).is_some_and(|p| p.section.is_some() && p.text.is_empty())
|
||||
});
|
||||
if !opts.show_hidden {
|
||||
quiet.extend(
|
||||
doc.body
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, b)| b.as_para().filter(|p| p.mark.hidden == Some(true) && p.plain_text().is_empty()).map(|_| i)),
|
||||
);
|
||||
quiet.extend(doc.body.iter().enumerate().filter_map(|(i, b)| {
|
||||
b.as_para().filter(|p| p.mark.hidden == Some(true) && p.plain_text().is_empty() && !has_inline_content(doc, opts, p)).map(|_| i)
|
||||
}));
|
||||
}
|
||||
quiet.sort_unstable();
|
||||
quiet.dedup();
|
||||
(stacked, quiet)
|
||||
}
|
||||
|
||||
/// An in-line picture, shape or chart, a note reference mark, or a field shown as something other
|
||||
/// than its result, is visible content with no plain text, so its line keeps its room even when
|
||||
/// the paragraph mark is hidden (#5288). Not one the layout leaves out (a hidden drawing, or one
|
||||
/// in hidden or deleted text).
|
||||
fn has_inline_content(doc: &Document, opts: &LayoutOptions, p: &Paragraph) -> bool {
|
||||
let left_out = left_out_objects(doc, p, None, opts.show_hidden, opts.hides_deleted(StoryRef::Body));
|
||||
// As the paragraph layout shows a field: its code, a legacy check box, a drop-down's entry.
|
||||
let shown_field = |o: &InlineObject| match o {
|
||||
InlineObject::Field { code, .. } => {
|
||||
*code != opts.field_codes
|
||||
|| o.legacy_form().is_some_and(|f| matches!(f, LegacyForm::CheckBox { .. }) || f.shown_text().is_some_and(|t| !t.is_empty()))
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
p.objects
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(k, o)| !left_out(k) && (matches!(o, InlineObject::NoteRef { .. }) || (o.frame().is_some() && !o.is_floating()) || shown_field(o)))
|
||||
}
|
||||
|
||||
/// The page the continuous views lay out on: the first section's, as wide as the window (and as
|
||||
/// high in the reading view), with small margins and one column.
|
||||
fn reflow_sect(first: &SectionProps, opts: &LayoutOptions) -> SectionProps {
|
||||
|
||||
@@ -125,9 +125,11 @@ struct Entry {
|
||||
runs: Vec<(String, String)>,
|
||||
/// The list label as shown (`Chapter 2.`).
|
||||
label: Option<String>,
|
||||
/// Each level's value up to the paragraph's, formatted; and whether the label's own
|
||||
/// template shows that level.
|
||||
levels: Vec<(String, bool)>,
|
||||
/// Each level up to the paragraph's that has a number (not a bullet, nor a "none" level):
|
||||
/// its index, its value formatted, and whether the label's own template shows it.
|
||||
levels: Vec<(u8, String, bool)>,
|
||||
/// The paragraph's own list level.
|
||||
level: u8,
|
||||
/// How many paragraphs of its style there are up to this one.
|
||||
ordinal: u32,
|
||||
}
|
||||
@@ -180,7 +182,7 @@ impl StyleIndex {
|
||||
*ord = ord.saturating_add(1);
|
||||
let ordinal = *ord;
|
||||
let num = p.props.numbering.or_else(|| doc.styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0);
|
||||
let (mut label, mut levels) = (None, Vec::new());
|
||||
let (mut label, mut levels, mut level) = (None, Vec::new(), 0u8);
|
||||
if let Some(n) = num
|
||||
&& let Some((l, def)) = counters.next_label(&doc.numbering, n.num, n.level)
|
||||
&& def.format != NumFormat::Bullet
|
||||
@@ -193,9 +195,10 @@ impl StyleIndex {
|
||||
}
|
||||
let v = if *v == 0 { lv.start } else { *v };
|
||||
let fmt = if def.legal && lv.format != NumFormat::DecimalZero { NumFormat::Decimal } else { lv.format };
|
||||
levels.push((fmt.format(v), def.text.contains(&format!("%{}", k + 1))));
|
||||
levels.push((k as u8, fmt.format(v), def.text.contains(&format!("%{}", k + 1))));
|
||||
}
|
||||
label = Some(l);
|
||||
level = n.level.min(8);
|
||||
}
|
||||
let mut runs: Vec<(String, String)> = Vec::new();
|
||||
let mut off = 0usize;
|
||||
@@ -221,7 +224,7 @@ impl StyleIndex {
|
||||
list.push(i);
|
||||
}
|
||||
}
|
||||
entries.push(Entry { path, style, text: clean(&p.plain_text()), runs, label, levels, ordinal });
|
||||
entries.push(Entry { path, style, text: clean(&p.plain_text()), runs, label, levels, level, ordinal });
|
||||
}
|
||||
StyleIndex { entries, by_path, styles, para_entries, char_entries }
|
||||
}
|
||||
@@ -323,18 +326,32 @@ impl StyleIndex {
|
||||
let mut out = match code.number {
|
||||
Number::Text if code.position => String::new(),
|
||||
Number::Text => text,
|
||||
Number::Chapter => e.label.as_deref().map(chapter_number).filter(|s| !s.is_empty()).unwrap_or_else(|| e.ordinal.to_string()),
|
||||
// The last number the label shows (`3` of `3 Response`, or of a "none" level's `%1`);
|
||||
// a label of the paragraph's own number that shows none recognisably, its last word;
|
||||
// else, as for an unnumbered heading or a "none" level's `Note`, the heading's count.
|
||||
Number::Chapter => {
|
||||
let own = e.levels.iter().any(|(k, ..)| *k == e.level);
|
||||
e.label
|
||||
.as_deref()
|
||||
.and_then(|l| {
|
||||
let shown = l.split_whitespace().rev().find_map(|w| number_span(w, &e.levels));
|
||||
shown.map(|n| n.trim_matches(|c: char| !c.is_alphanumeric()).to_string()).or_else(|| own.then(|| chapter_number(l)))
|
||||
})
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| e.ordinal.to_string())
|
||||
}
|
||||
Number::Plain | Number::Full => {
|
||||
let own = match &e.label {
|
||||
Some(l) if code.suppress => suppress(l, &e.levels),
|
||||
Some(l) if code.suppress => suppress(l, &e.levels, e.level),
|
||||
Some(l) => l.trim().trim_end_matches('.').to_string(),
|
||||
None => e.ordinal.to_string(),
|
||||
};
|
||||
if code.number == Number::Full && e.label.is_some() {
|
||||
// Levels above that the label's own template doesn't show.
|
||||
let above: Vec<&str> =
|
||||
e.levels.iter().take(e.levels.len().saturating_sub(1)).filter(|(_, shown)| !shown).map(|(v, _)| v.as_str()).collect();
|
||||
if above.is_empty() { own } else { format!("{}.{own}", above.join(".")) }
|
||||
// Levels above the paragraph's that the label's own template doesn't show. By
|
||||
// level, not position: a "none" level has no entry, its own (#4985) included.
|
||||
let above: Vec<&str> = e.levels.iter().filter(|(k, _, shown)| *k < e.level && !shown).map(|(_, v, _)| v.as_str()).collect();
|
||||
// An empty label of its own (a "none" level with no text) adds no part: `3`, not `3.`.
|
||||
above.into_iter().chain(Some(own.as_str()).filter(|o| !o.is_empty())).collect::<Vec<_>>().join(".")
|
||||
} else {
|
||||
own
|
||||
}
|
||||
@@ -356,13 +373,46 @@ fn chapter_number(label: &str) -> String {
|
||||
word.trim_matches(|c: char| !c.is_alphanumeric()).to_string()
|
||||
}
|
||||
|
||||
/// `\t`: the label without its words that hold no number (`Chapter 2.` → `2`).
|
||||
fn suppress(label: &str, levels: &[(String, bool)]) -> String {
|
||||
let kept: Vec<&str> = label.split_whitespace().filter(|w| levels.iter().any(|(v, _)| !v.is_empty() && w.contains(v.as_str()))).collect();
|
||||
let s = if kept.is_empty() { label.trim().to_string() } else { kept.join(" ") };
|
||||
/// `\t`: the label without its words that hold no number (`Chapter 2.` → `2`). A "none" level's
|
||||
/// label (`level` has no number of its own) holding none is left with nothing (`Note`, #4985);
|
||||
/// any other label that holds none is kept whole.
|
||||
fn suppress(label: &str, levels: &[(u8, String, bool)], level: u8) -> String {
|
||||
// Each word's numbers, without the text attached to them (`Section3` → `3`).
|
||||
let kept: Vec<&str> = label.split_whitespace().filter_map(|w| number_span(w, levels)).collect();
|
||||
let none = !levels.iter().any(|(k, ..)| *k == level);
|
||||
let s = match kept.is_empty() {
|
||||
true if none => String::new(),
|
||||
true => label.trim().to_string(),
|
||||
false => kept.join(" "),
|
||||
};
|
||||
s.trim_end_matches('.').to_string()
|
||||
}
|
||||
|
||||
/// The part of `word` that holds the numbers the label's template shows (`levels` flagged shown),
|
||||
/// with the delimiters around it (`(3)`, `1.1`, `2.`), without the text attached (`Section3` →
|
||||
/// `3`); `None` when it holds none. A number written in letters (`a`, `iv`) counts only standing
|
||||
/// apart from other letters: the `a` of `Chapter` isn't one.
|
||||
fn number_span<'w>(word: &'w str, levels: &[(u8, String, bool)]) -> Option<&'w str> {
|
||||
let alpha = |c: Option<char>| c.is_some_and(char::is_alphabetic);
|
||||
let mut span: Option<(usize, usize)> = None;
|
||||
for (_, v, _) in levels.iter().filter(|(_, v, shown)| *shown && !v.is_empty()) {
|
||||
let letters = v.chars().any(char::is_alphabetic);
|
||||
for (i, _) in word.match_indices(v.as_str()) {
|
||||
let end = i.saturating_add(v.len());
|
||||
let before = word.get(..i).and_then(|s| s.chars().next_back());
|
||||
let after = word.get(end..).and_then(|s| s.chars().next());
|
||||
if letters && (alpha(before) || alpha(after)) {
|
||||
continue;
|
||||
}
|
||||
span = Some(span.map_or((i, end), |(a, b)| (a.min(i), b.max(end))));
|
||||
}
|
||||
}
|
||||
let (a, b) = span?;
|
||||
let lead: usize = word.get(..a)?.chars().rev().take_while(|c| !c.is_alphanumeric()).map(char::len_utf8).sum();
|
||||
let trail: usize = word.get(b..)?.chars().take_while(|c| !c.is_alphanumeric()).map(char::len_utf8).sum();
|
||||
word.get(a.saturating_sub(lead)..b.saturating_add(trail))
|
||||
}
|
||||
|
||||
/// Does `p` hold a STYLEREF field?
|
||||
pub fn has_styleref(p: &wordcraft_doc::Paragraph) -> bool {
|
||||
p.objects.iter().any(is_styleref)
|
||||
@@ -441,7 +491,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn suppress_keeps_the_number() {
|
||||
assert_eq!(suppress("Chapter 2.", &[("2".into(), true)]), "2");
|
||||
assert_eq!(suppress("Chapter 2.", &[(0, "2".into(), true)], 0), "2");
|
||||
assert_eq!(suppress("Chapter", &[(0, "a".into(), false)], 1), "", "an ancestor's number the label doesn't show");
|
||||
assert_eq!(suppress("Section3", &[(0, "3".into(), true)], 1), "3", "the text attached to the number");
|
||||
assert_eq!(suppress("Chapter a.", &[(0, "a".into(), true)], 0), "a", "a letter number standing apart, not Chapter's a");
|
||||
assert_eq!(suppress("(iv)", &[(0, "iv".into(), true)], 0), "(iv)", "delimiters stay");
|
||||
assert_eq!(suppress("1.2", &[(0, "1".into(), true), (1, "2".into(), true)], 1), "1.2");
|
||||
assert_eq!(suppress("Note", &[(0, "3".into(), false)], 1), "", "a none level's label");
|
||||
assert_eq!(suppress("Appendix", &[(0, "3".into(), false)], 0), "Appendix", "kept whole when the level has a number");
|
||||
assert_eq!(chapter_number("Chapter IV."), "IV");
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,62 @@ fn row_keeps_next(row: &table::RowLayout) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn row_can_start(row: &wordcraft_doc::Row, laid: &table::RowLayout, room: f32, page_height: f32) -> bool {
|
||||
/// The cut that leaves every row of a nested table marked "can't split" whole: just below the
|
||||
/// top of the highest such row the cut at `room` runs through, moved up again while it runs
|
||||
/// through another one (Word moves such a row on with the rest of the outer row, observed in the
|
||||
/// docxide-pdf `nested-first-row-*-cant1` cases). Like the split itself, a row "runs through"
|
||||
/// the cut when it ends below the room left for text there, above the row's bottom band; the cut
|
||||
/// goes that band below the row's top, so the text and rows ending at its top stay.
|
||||
/// shortcut: the moved row's side borders and shading keep that band's height on the page, and
|
||||
/// on the next page its cell area and shading start that band less its top margin below its
|
||||
/// first line (clicks there miss the cell). Only an explicit bottom cell margin or a thick outer
|
||||
/// border shows it, never Word's defaults; upgrade with a separate drawing cut in split_row that
|
||||
/// moves a nested cell starting between the limit and the cut whole from its top.
|
||||
fn nested_keep_cut(doc: &wordcraft_doc::Document, row: &table::RowLayout, room: f32) -> f32 {
|
||||
let own = row.items.iter().filter_map(|it| if let Placed::Cell { table, .. } = it { Some(table.0.len()) } else { None }).min();
|
||||
// Each nested row's top and bottom when it can't split. A vertically merged cell runs past
|
||||
// its own row, so a row ends where its shortest cell does.
|
||||
let mut bands: std::collections::BTreeMap<(StoryRef, &[u32], usize), Option<(f32, f32)>> = std::collections::BTreeMap::new();
|
||||
// The split runs a cell aligned to the middle or bottom from its top: so do its bands.
|
||||
let mut shift = vec![0.0f32; row.items.len()];
|
||||
for al in row.cells.iter().filter_map(|c| c.align.as_ref()) {
|
||||
shift.get_mut(al.content.clone()).into_iter().flatten().for_each(|d| *d = al.dy);
|
||||
}
|
||||
for (it, &dy) in row.items.iter().zip(&shift) {
|
||||
let Placed::Cell { rect, table, row: r, story, .. } = it else { continue };
|
||||
if Some(table.0.len()) <= own {
|
||||
continue;
|
||||
}
|
||||
let rect = Rect { y: rect.y - dy, ..*rect };
|
||||
let band = bands
|
||||
.entry((*story, table.0.as_slice(), *r))
|
||||
.or_insert_with(|| doc.table(*story, table).and_then(|t| t.rows.get(*r)).filter(|r| r.props.cant_split).map(|_| (rect.y, rect.bottom())));
|
||||
if let Some((top, bottom)) = band {
|
||||
*top = top.min(rect.y);
|
||||
*bottom = bottom.min(rect.bottom());
|
||||
}
|
||||
}
|
||||
let below = row.bottom.clamp(0.0, room.max(0.0));
|
||||
let mut bands: Vec<(f32, f32)> = bands.into_values().flatten().collect();
|
||||
// From the lowest top up: once the cut moves up to a row, the rows starting below it are
|
||||
// wholly under it, and a row the old cut ran through still runs through the new one.
|
||||
bands.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||
let mut cut = room;
|
||||
for (top, bottom) in bands {
|
||||
let limit = cut - below;
|
||||
if top < limit && bottom > limit + 0.01 {
|
||||
cut = top + below;
|
||||
}
|
||||
}
|
||||
cut
|
||||
}
|
||||
|
||||
/// [`table::split_row_keeping_cells`] that doesn't split a nested "can't split" row either.
|
||||
fn split_keeping_nested(doc: &wordcraft_doc::Document, row: &table::RowLayout, room: f32) -> Option<(table::RowLayout, table::RowLayout)> {
|
||||
table::split_row_keeping_cells(row, nested_keep_cut(doc, row, room))
|
||||
}
|
||||
|
||||
fn row_can_start(doc: &wordcraft_doc::Document, row: &wordcraft_doc::Row, laid: &table::RowLayout, room: f32, page_height: f32) -> bool {
|
||||
if laid.height <= room + 0.01 {
|
||||
return true;
|
||||
}
|
||||
@@ -104,7 +159,7 @@ fn row_can_start(row: &wordcraft_doc::Row, laid: &table::RowLayout, room: f32, p
|
||||
if row.props.height_rule == HeightRule::AtLeast && row.props.height.is_some_and(|h| h > room + 0.01 && h <= page_height + 0.01) {
|
||||
return false;
|
||||
}
|
||||
table::split_row_keeping_cells(laid, room).is_some()
|
||||
split_keeping_nested(doc, laid, room).is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, block: usize, body_top: f32) {
|
||||
@@ -187,8 +242,7 @@ pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc
|
||||
{
|
||||
let room = pb.bottom - pb.y - tl.rows.iter().take(first).map(|r| r.height).sum::<f32>();
|
||||
let splittable = !t.rows.get(first).is_some_and(|r| r.props.cant_split);
|
||||
if row.height + row.edge > room + 0.01 && (short_of_min(first, room) || !(splittable && table::split_row_keeping_cells(row, room).is_some()))
|
||||
{
|
||||
if row.height + row.edge > room + 0.01 && (short_of_min(first, room) || !(splittable && split_keeping_nested(ctx.doc, row, room).is_some())) {
|
||||
pb.advance(block, body_top);
|
||||
}
|
||||
}
|
||||
@@ -223,7 +277,7 @@ pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc
|
||||
}
|
||||
let (Some(next), Some(laid)) = (t.rows.get(end), tl.rows.get(end)) else { return false };
|
||||
let page_height = pb.bottom - body_top - repeated_height;
|
||||
height <= page_height + 0.01 && !row_can_start(next, laid, pb.bottom - pb.y - height, page_height)
|
||||
height <= page_height + 0.01 && !row_can_start(ctx.doc, next, laid, pb.bottom - pb.y - height, page_height)
|
||||
});
|
||||
// A row another row follows needs room for the border below it too (measured in Word).
|
||||
if pb.y + cur.height + cur.edge <= pb.bottom + 0.01 && !defer_kept {
|
||||
@@ -238,8 +292,9 @@ pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc
|
||||
let moves_on = first_part && short_of_min(ri, room);
|
||||
let split = match (splittable && !moves_on && !defer_kept, first_part) {
|
||||
(false, _) => None,
|
||||
(true, true) => table::split_row_keeping_cells(cur, room),
|
||||
(true, false) => table::split_row(cur, room),
|
||||
(true, true) => split_keeping_nested(ctx.doc, cur, room),
|
||||
// On a fresh page, a nested row taller than the room splits rather than overflowing.
|
||||
(true, false) => table::split_row(cur, nested_keep_cut(ctx.doc, cur, room)).or_else(|| table::split_row(cur, room)),
|
||||
};
|
||||
match split {
|
||||
Some((a, b)) => {
|
||||
@@ -294,7 +349,13 @@ fn place_floating_table(ctx: &mut Ctx, pb: &mut PageBuilder, tl: &table::TableLa
|
||||
let x = float_table_x(pb, w, f, legacy, ctx.mirror);
|
||||
let mut obstacles = if f.overlap { Vec::new() } else { header_table_areas(ctx, pb) };
|
||||
obstacles.extend_from_slice(&pb.float_tables);
|
||||
let r = clear_of_float_tables(Rect::new(x, align_in(v_area, f.y, h, f.v_align), w, h), f, legacy, h_area.0, &obstacles);
|
||||
let r = clear_of_float_tables(
|
||||
Rect::new(x, align_in(v_area, f.y, h, f.v_align), w, h),
|
||||
f,
|
||||
ctx.doc.settings.compat_mode >= 15,
|
||||
h_area.0,
|
||||
&obstacles,
|
||||
);
|
||||
// Like Word, a table that doesn't fit below the text before it moves to the next page with
|
||||
// its anchor: one placed relative to the paragraph when it would run off the page, one
|
||||
// placed on the page or margin when it would also cover the text already on the page.
|
||||
|
||||
+317
-4
@@ -9,10 +9,12 @@ mod bidi;
|
||||
mod budget;
|
||||
mod cell_floats;
|
||||
mod complex;
|
||||
mod float_top;
|
||||
mod grid;
|
||||
mod ligature;
|
||||
pub(crate) mod linear;
|
||||
mod nbsp;
|
||||
mod nested_cant_split;
|
||||
mod numeral;
|
||||
mod pages;
|
||||
mod styleref;
|
||||
@@ -1820,6 +1822,30 @@ fn lists_get_labels_and_indent() {
|
||||
assert!((x - 108.0).abs() < 0.5, "{x}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_none_format_label_shows_its_text_and_tabs_to_the_indent() {
|
||||
// #4985: Word draws a "none" level's literal text ("RESPONSE:"), and an empty one still tabs
|
||||
// the text to a hanging indent, as a numbered item's. An empty label already at the left indent
|
||||
// takes no tab (Word's Chapter 1 list: Heading 2 starts at the margin, not at 0.5").
|
||||
use wordcraft_doc::section::NumFormat;
|
||||
for (text, indent, hanging, want) in [("RESPONSE:", 72.0, 72.0, 144.0), ("", 72.0, 72.0, 144.0), ("", 0.0, 0.0, 72.0), ("", 50.0, 0.0, 122.0)] {
|
||||
let mut d = Document::from_text("X");
|
||||
let id = std::sync::Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Numbered);
|
||||
for a in &mut std::sync::Arc::make_mut(&mut d.numbering).abstracts {
|
||||
if let Some(l) = a.levels.first_mut() {
|
||||
l.format = NumFormat::None;
|
||||
l.text = text.into();
|
||||
(l.indent, l.hanging) = (indent, hanging);
|
||||
}
|
||||
}
|
||||
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 1), &|p| p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 })).unwrap();
|
||||
let l = lay(&d);
|
||||
assert_eq!(labels(&l), [text]);
|
||||
let x = l.caret(&Pos::body(0, 0)).unwrap().x;
|
||||
assert!((x - want).abs() < 0.5, "{text:?} at {indent}/{hanging}: {x}, not {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn break_only_list_has_no_label() {
|
||||
use wordcraft_doc::ListKind;
|
||||
@@ -5569,7 +5595,7 @@ fn inline_picture_keeps_room_for_its_effects() {
|
||||
assert_eq!((c.adv, c.obj_h), (124.0, 89.0), "picture plus effect extents");
|
||||
// Drawn inside that room.
|
||||
let obj = picture(100.0, 50.0, shadow);
|
||||
let r = display::inline_rect(Some(&obj), 10.0, 200.0, c.adv, (c.obj_h, c.obj_d), c.adv);
|
||||
let r = display::inline_rect(Some(&obj), 10.0, 200.0, c.adv, (c.obj_h, c.obj_d), c.adv, true);
|
||||
assert_eq!((r.x, r.y, r.w, r.h), (16.0, 123.0, 100.0, 50.0));
|
||||
}
|
||||
|
||||
@@ -5596,22 +5622,67 @@ fn an_inline_picture_moves_with_its_runs_position() {
|
||||
let (c, line, _) = line_of(-30.0);
|
||||
assert_eq!((c.obj_h, c.obj_d), (0.0, 30.0));
|
||||
assert!(line.top + line.height - line.baseline >= 30.0 - 0.01, "{line:?}");
|
||||
let r = display::inline_rect(Some(&picture(40.0, 30.0, Default::default())), 0.0, 100.0, c.adv, (c.obj_h, c.obj_d), c.adv);
|
||||
let r = display::inline_rect(Some(&picture(40.0, 30.0, Default::default())), 0.0, 100.0, c.adv, (c.obj_h, c.obj_d), c.adv, true);
|
||||
assert_eq!((r.y, r.h), (100.0, 30.0));
|
||||
// Lowered further than that: the line reaches as far down, the picture's top staying on the
|
||||
// baseline (Word).
|
||||
let (c, line, _) = line_of(-500.0);
|
||||
assert_eq!((c.obj_h, c.obj_d), (-470.0, 500.0));
|
||||
assert!(line.top + line.height - line.baseline >= 500.0 - 0.01, "{line:?}");
|
||||
let r = display::inline_rect(Some(&picture(40.0, 30.0, Default::default())), 0.0, 100.0, c.adv, (c.obj_h, c.obj_d), c.adv);
|
||||
let r = display::inline_rect(Some(&picture(40.0, 30.0, Default::default())), 0.0, 100.0, c.adv, (c.obj_h, c.obj_d), c.adv, true);
|
||||
assert_eq!((r.y, r.h), (100.0, 30.0));
|
||||
// Raised: it stands clear of the baseline, as tall as before.
|
||||
let (c, _, _) = line_of(6.0);
|
||||
assert_eq!((c.obj_h, c.obj_d), (36.0, -6.0));
|
||||
let r = display::inline_rect(Some(&picture(40.0, 30.0, Default::default())), 0.0, 100.0, c.adv, (c.obj_h, c.obj_d), c.adv);
|
||||
let r = display::inline_rect(Some(&picture(40.0, 30.0, Default::default())), 0.0, 100.0, c.adv, (c.obj_h, c.obj_d), c.adv, true);
|
||||
assert_eq!((r.y, r.h), (64.0, 30.0));
|
||||
}
|
||||
|
||||
/// A turned inline picture whose file kept no room for the turn: Word keeps its line to the frame
|
||||
/// (Word 2007 layout) or to the frame with width and height exchanged near a quarter turn (Word
|
||||
/// 2013+), and the picture overhangs it. One with room recorded takes its rotated bounds.
|
||||
#[test]
|
||||
fn bare_turned_inline_picture_keeps_its_frame() {
|
||||
let room = |rot: f32, bare_turn: bool, compat: u32| {
|
||||
let mut d = Document::from_text("");
|
||||
d.settings.compat_mode = compat;
|
||||
let float = wordcraft_doc::para::Float { rot, bare_turn, ..Default::default() };
|
||||
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) {
|
||||
p.insert_object(0, picture(120.0, 60.0, float), &Default::default()).unwrap();
|
||||
}
|
||||
let l = lay(&d);
|
||||
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
|
||||
let c = para.clusters.iter().find(|c| matches!(c.kind, para::ClKind::Object(_))).unwrap();
|
||||
// Drawn unscaled, centred in that room.
|
||||
let obj = picture(120.0, 60.0, wordcraft_doc::para::Float { rot, bare_turn, ..Default::default() });
|
||||
let r = display::inline_rect(Some(&obj), 0.0, 200.0, c.adv, (c.obj_h, c.obj_d), c.adv, compat >= 15);
|
||||
assert_eq!((r.w, r.h), (120.0, 60.0), "{rot}° bare {bare_turn} compat {compat}");
|
||||
assert_eq!((r.x + r.w / 2.0, r.y + r.h / 2.0), (c.adv / 2.0, 200.0 - c.obj_h / 2.0));
|
||||
// Only a bare turn that overhangs its line is cut, at the line's top and bottom.
|
||||
let overhangs =
|
||||
bare_turn && (c.obj_h as f64) < 120.0 * (rot as f64).to_radians().sin().abs() + 60.0 * (rot as f64).to_radians().cos().abs() - 0.1;
|
||||
let clips: Vec<Rect> = display::page_display(&d, &l.pages[0], &Default::default())
|
||||
.iter()
|
||||
.filter_map(|i| if let display::Draw::Clip { rect, .. } = i { Some(*rect) } else { None })
|
||||
.collect();
|
||||
assert_eq!(clips.len(), usize::from(overhangs), "{rot}° bare {bare_turn} compat {compat}: {clips:?}");
|
||||
if let Some(band) = clips.first() {
|
||||
assert!(band.h >= c.obj_h && band.w > 4000.0, "{band:?}");
|
||||
}
|
||||
(c.adv.round(), c.obj_h.round())
|
||||
};
|
||||
assert_eq!(room(90.0, true, 12), (120.0, 60.0));
|
||||
assert_eq!(room(45.0, true, 12), (120.0, 60.0));
|
||||
assert_eq!(room(180.0, true, 12), (120.0, 60.0));
|
||||
assert_eq!(room(90.0, true, 15), (60.0, 120.0));
|
||||
assert_eq!(room(45.0, true, 15), (60.0, 120.0));
|
||||
assert_eq!(room(135.0, true, 15), (120.0, 60.0));
|
||||
assert_eq!(room(270.0, true, 15), (60.0, 120.0));
|
||||
// Room recorded (Word's own files, WordCraft's turns): the rotated bounds, in any layout.
|
||||
assert_eq!(room(90.0, false, 12), (60.0, 120.0));
|
||||
assert_eq!(room(45.0, false, 15), (127.0, 127.0));
|
||||
}
|
||||
|
||||
/// Tops of the body text lines drawn on page one, in order.
|
||||
fn line_tops(l: &DocLayout) -> Vec<f32> {
|
||||
l.pages[0].items.iter().filter_map(|i| if let Placed::Lines { y, story: StoryRef::Body, .. } = i { Some(*y) } else { None }).collect()
|
||||
@@ -6944,6 +7015,48 @@ fn floating_tables_take_no_room_and_text_wraps_beside_them() {
|
||||
assert!(bx + bleft > nx + 200.0, "body text at {}, right of the narrow table", bx + bleft);
|
||||
}
|
||||
|
||||
/// Before Word 2013 layout a floating table moved below another keeps its own left edge, also
|
||||
/// when its first cell has no left margin (docxide `displaced-anchor-*-compat12-overlap0-*`), in
|
||||
/// the body and in a table cell.
|
||||
#[test]
|
||||
fn a_floating_table_moved_below_another_keeps_its_edge_before_word_2013_layout() {
|
||||
use wordcraft_doc::para::Anchor;
|
||||
use wordcraft_doc::props::TableFloat;
|
||||
let narrow_x = |mode: u32, in_cell: bool| {
|
||||
let mut d = Document::from_text("Body.");
|
||||
d.settings.compat_mode = mode;
|
||||
let table = |w: f32, text: &str, dist: [f32; 4]| {
|
||||
let mut t = Table::new(1, 1, w);
|
||||
t.props.cell_margins = Some([0.0; 4]);
|
||||
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))];
|
||||
t.props.float = Some(TableFloat { h_rel: Anchor::Margin, v_rel: Anchor::Paragraph, dist, overlap: false, ..Default::default() });
|
||||
t
|
||||
};
|
||||
let floats = [table(300.0, "Wide", [0.0; 4]), table(100.0, "Narrow", [9.0, 0.0, 9.0, 0.0])];
|
||||
if in_cell {
|
||||
let mut outer = Table::new(1, 1, 468.0);
|
||||
outer.rows[0].cells[0].blocks = floats.into_iter().map(|t| std::sync::Arc::new(wordcraft_doc::Block::Table(t))).collect();
|
||||
outer.rows[0].cells[0].blocks.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Cell.", Default::default())));
|
||||
d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(outer)).unwrap();
|
||||
} else {
|
||||
for (i, t) in floats.into_iter().enumerate() {
|
||||
d.insert_block(StoryRef::Body, &Path::top(i), wordcraft_doc::Block::Table(t)).unwrap();
|
||||
}
|
||||
}
|
||||
let l = lay(&d);
|
||||
let xs: Vec<(f32, f32)> =
|
||||
l.pages[0].items.iter().filter_map(|i| if let Placed::Lines { x, y, .. } = i { Some((*x, *y)) } else { None }).collect();
|
||||
let [(wx, wy), (nx, ny), ..] = xs.as_slice() else { panic!("{xs:?}") };
|
||||
assert!(*ny > *wy + 10.0, "mode {mode}, in cell {in_cell}: narrow at {ny}, below the wide table at {wy}: {xs:?}");
|
||||
nx - wx
|
||||
};
|
||||
for in_cell in [false, true] {
|
||||
let (old, new) = (narrow_x(12, in_cell), narrow_x(15, in_cell));
|
||||
assert!(old.abs() < 0.01, "mode 12, in cell {in_cell}: narrow table {old} right of the wide one");
|
||||
assert!((new - 9.0).abs() < 0.01, "mode 15, in cell {in_cell}: narrow table {new} right of the wide one");
|
||||
}
|
||||
}
|
||||
|
||||
/// A margin-anchored floating table is placed from the anchor page's margin even when a body
|
||||
/// paragraph keeps trailing empty lines: all 24 preceding paragraphs stay on page one and every
|
||||
/// table row is drawn exactly once there.
|
||||
@@ -8867,6 +8980,34 @@ fn superscript_and_subscript_lines_are_as_tall_as_full_size_text() {
|
||||
assert!((lowered - plain - 6.0).abs() < 0.01, "lowered {lowered} vs plain {plain}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superscript_digits_advance_at_the_half_point_size() {
|
||||
use wordcraft_doc::props::VertAlign;
|
||||
// Word sets an 11pt superscript at 7pt (0.65 × 11 = 7.15, rounded to the half point): its
|
||||
// digits advance by 7pt digit widths (Word for Mac, measured from its PDF export).
|
||||
let width = |size: f32, vert_align: VertAlign| {
|
||||
let mut p = wordcraft_doc::Paragraph::new();
|
||||
let props =
|
||||
wordcraft_doc::CharProps { font: Some(WRAP_TEST_FONT.into()), size: Some(size), vert_align: Some(vert_align), ..Default::default() };
|
||||
p.runs.push(wordcraft_doc::para::Run { len: 10, props: props.clone() });
|
||||
p.text.push_str("1234567890");
|
||||
p.mark = props;
|
||||
let mut d = Document::new();
|
||||
d.body = vec![wordcraft_doc::para_block(p)];
|
||||
let l = lay(&d);
|
||||
// From the first digit's start to the last's: nine digit advances.
|
||||
let xs = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|it| if let Placed::Lines { para, .. } = it { para.lines.first().map(|x| x.xs.clone()) } else { None })
|
||||
.unwrap();
|
||||
xs.last().unwrap() - xs.first().unwrap()
|
||||
};
|
||||
let seven = width(7.0, VertAlign::Baseline);
|
||||
assert!((width(11.0, VertAlign::Superscript) - seven).abs() < 0.01, "{} vs {seven}", width(11.0, VertAlign::Superscript));
|
||||
assert!((width(11.0, VertAlign::Subscript) - seven).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruby_text_sits_over_its_base_and_the_line_grows_to_fit() {
|
||||
use wordcraft_doc::para::RubyAlign;
|
||||
@@ -10214,6 +10355,20 @@ fn empty_page_section_keeps_line() {
|
||||
/// side by side). A section break on a paragraph with text keeps its line, and so does any break
|
||||
/// before a section on a new page. The SARP form's fields drifted a line lower at each of its
|
||||
/// many continuous breaks.
|
||||
/// A continuous section starting mid-page leaves its first paragraph's page break before in
|
||||
/// force, measured in Word for Mac: "Cover", an empty mark closing a continuous section, then
|
||||
/// "Heading" with page break before puts "Heading" on page 2.
|
||||
#[test]
|
||||
fn a_page_break_before_after_a_continuous_mark_starts_a_page() {
|
||||
use wordcraft_doc::section::{SectionProps, SectionStart};
|
||||
let mut d = Document::from_text("Cover\n\nHeading");
|
||||
d.para_mut(StoryRef::Body, &Path::top(1)).unwrap().section =
|
||||
Some(Box::new(SectionProps { start: SectionStart::Continuous, ..Default::default() }));
|
||||
d.para_mut(StoryRef::Body, &Path::top(2)).unwrap().props.page_break_before = Some(true);
|
||||
d.last_section.start = SectionStart::Continuous;
|
||||
assert_eq!(lay(&d).caret(&Pos::body(2, 0)).unwrap().page, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_continuous_section_mark_takes_no_room() {
|
||||
use wordcraft_doc::section::{SectionProps, SectionStart};
|
||||
@@ -10247,6 +10402,79 @@ fn an_empty_continuous_section_mark_takes_no_room() {
|
||||
assert_eq!(new_page[1].0, 1, "a next-page section starts a page: {new_page:?}");
|
||||
}
|
||||
|
||||
/// A hidden mark closing a section before a continuous one, holding only an in-line picture:
|
||||
/// the picture keeps its line, so the next section starts below it, not over it (#5288). A
|
||||
/// floating picture there still leaves the mark quiet, and so does a hidden one or one in hidden
|
||||
/// text.
|
||||
#[test]
|
||||
fn a_hidden_mark_with_an_inline_picture_keeps_its_room() {
|
||||
use wordcraft_doc::para::{Float, ShapeKind};
|
||||
use wordcraft_doc::section::{SectionProps, SectionStart};
|
||||
// Where Pb starts, and the bottom of the closing mark's picture (none: no picture).
|
||||
let tops = |float: Option<Float>, run: CharProps| {
|
||||
let mut d = Document::from_text("Pa\n\nPb");
|
||||
let closer = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
closer.mark.hidden = Some(true);
|
||||
closer.section = Some(Box::new(SectionProps::default()));
|
||||
d.last_section.start = SectionStart::Continuous;
|
||||
if let Some(float) = float {
|
||||
let shape = InlineObject::Shape {
|
||||
kind: ShapeKind::Rectangle,
|
||||
w: 144.0,
|
||||
h: 72.0,
|
||||
fill: None,
|
||||
stroke: None,
|
||||
stroke_width: 1.0,
|
||||
float,
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
freeform: None,
|
||||
extra: Default::default(),
|
||||
};
|
||||
d.insert_object(&Pos::body(1, 0), shape, &run).unwrap();
|
||||
}
|
||||
let l = lay(&d);
|
||||
let bottom = l.object(&Pos::body(1, 0), 0).map(|o| o.rect.bottom());
|
||||
(l.caret(&Pos::body(2, 0)).unwrap(), bottom)
|
||||
};
|
||||
let wrapped = |wrap| Some(Float { wrap, ..Default::default() });
|
||||
let (empty, _) = tops(None, CharProps::default());
|
||||
let (pb, bottom) = tops(wrapped(Wrap::Inline), CharProps::default());
|
||||
let bottom = bottom.unwrap();
|
||||
assert_eq!(pb.page, 0, "the continuous section stays on the page: {pb:?}");
|
||||
assert!(pb.top >= empty.top + 72.0 - 0.01, "Pb starts below the 72 pt picture: {pb:?} vs {empty:?}");
|
||||
assert!(pb.top >= bottom - 0.01, "Pb starts below the picture's frame: {pb:?} vs {bottom}");
|
||||
let (floating, drawn) = tops(wrapped(Wrap::InFrontOfText), CharProps::default());
|
||||
assert!(drawn.is_some(), "the floating picture is drawn");
|
||||
assert!((floating.top - empty.top).abs() < 0.01, "a floating picture leaves the mark quiet: {floating:?} vs {empty:?}");
|
||||
let (hidden, _) = tops(Some(Float { hidden: true, ..Default::default() }), CharProps::default());
|
||||
assert!((hidden.top - empty.top).abs() < 0.01, "a hidden picture leaves the mark quiet: {hidden:?} vs {empty:?}");
|
||||
let (in_hidden_text, _) = tops(wrapped(Wrap::Inline), CharProps { hidden: Some(true), ..Default::default() });
|
||||
assert!((in_hidden_text.top - empty.top).abs() < 0.01, "a picture in hidden text leaves the mark quiet: {in_hidden_text:?} vs {empty:?}");
|
||||
}
|
||||
|
||||
/// A hidden closing mark holding only a legacy check box (a FORMCHECKBOX field, empty result):
|
||||
/// the box is drawn with a line's room, so the next section starts below it (#5288).
|
||||
#[test]
|
||||
fn a_hidden_mark_with_a_check_box_keeps_its_room() {
|
||||
use wordcraft_doc::section::{SectionProps, SectionStart};
|
||||
let top = |boxed: bool| {
|
||||
let mut d = Document::from_text("Pa\n\nPb");
|
||||
let closer = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
closer.mark.hidden = Some(true);
|
||||
closer.section = Some(Box::new(SectionProps::default()));
|
||||
d.last_section.start = SectionStart::Continuous;
|
||||
if boxed {
|
||||
let form = r#"<w:ffData><w:name w:val="Box"/><w:checkBox><w:sizeAuto/></w:checkBox></w:ffData>"#.to_string();
|
||||
let field = InlineObject::Field { instr: "FORMCHECKBOX".into(), result: String::new(), locked: false, dirty: false, code: false, form };
|
||||
d.insert_object(&Pos::body(1, 0), field, &Default::default()).unwrap();
|
||||
}
|
||||
lay(&d).caret(&Pos::body(2, 0)).unwrap()
|
||||
};
|
||||
let (empty, boxed) = (top(false), top(true));
|
||||
assert!(boxed.top >= empty.top + 8.0, "Pb starts a line below the check box: {boxed:?} vs {empty:?}");
|
||||
}
|
||||
|
||||
/// Empty marks closing continuous sections one after another each keep their line while it
|
||||
/// fits; only one right after text is quiet. Past the page's bottom they stay there, so the text
|
||||
/// after the run starts the next page. Measured in Word (#3907): "Start", N such marks, "End":
|
||||
@@ -13467,6 +13695,91 @@ fn a_non_breaking_hyphen_and_a_minus_sign_stay_unbreakable() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #3408: Word breaks a compound right after a hyphen that a slash follows ("Dataprotection-" /
|
||||
/// "/Secrecyviolation"), where UAX #14 forbids a break before the slash. The first width that
|
||||
/// wraps must break after the hyphen, not move the compound whole, and the slash stays with its
|
||||
/// word. Table auto-fit reads the same break, so such a column no longer widens to the compound.
|
||||
/// Word keeps "fitness-/1589" whole: a slash before a digit gains no break.
|
||||
#[test]
|
||||
fn a_hyphen_before_a_slash_is_a_break_opportunity_like_word() {
|
||||
let digits = "Deed fitness-/1589";
|
||||
let mut wrapped = false;
|
||||
for m in 100..=540 {
|
||||
let mut d = Document::from_text(digits);
|
||||
d.last_section.margin_right = m as f32;
|
||||
let l = lay(&d);
|
||||
let lines: Vec<&str> = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| if let Placed::Lines { para, l0, l1, .. } = it { Some(¶.lines[*l0..*l1]) } else { None })
|
||||
.flatten()
|
||||
.map(|line| &digits[line.start..line.stop])
|
||||
.collect();
|
||||
if lines.len() >= 2 {
|
||||
assert_eq!(lines, ["Deed ", "fitness-/1589"], "the first wrap breaks at the space, not after the hyphen");
|
||||
wrapped = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(wrapped, "no width in the sweep wrapped the digit case");
|
||||
|
||||
let text = "Deed Dataprotection-/Secrecyviolation";
|
||||
for m in 100..=540 {
|
||||
let mut d = Document::from_text(text);
|
||||
d.last_section.margin_right = m as f32;
|
||||
let l = lay(&d);
|
||||
let lines: Vec<String> = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| {
|
||||
if let Placed::Lines { para, l0, l1, .. } = it {
|
||||
Some(para.lines[*l0..*l1].iter().map(|line| text[line.start..line.stop].to_string()).collect::<Vec<_>>())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.flatten()
|
||||
.collect();
|
||||
if lines.len() < 2 {
|
||||
continue; // still fits on one line at this width
|
||||
}
|
||||
assert_eq!(lines[0], "Deed Dataprotection-", "the first wrap breaks after the hyphen: {lines:?}");
|
||||
assert_eq!(lines[1], "/Secrecyviolation", "the slash starts the next line with its word: {lines:?}");
|
||||
return;
|
||||
}
|
||||
panic!("no width in the sweep wrapped the paragraph");
|
||||
}
|
||||
|
||||
/// docxide-pdf `dot-open-token*`: UAX #14 allows a break between a full stop and an opening
|
||||
/// parenthesis, Word doesn't. The first width that wraps `Deed 2023.(Example` breaks at the space.
|
||||
#[test]
|
||||
fn a_full_stop_before_a_parenthesis_is_no_break() {
|
||||
let text = "Deed 2023.(Example";
|
||||
for m in 100..=540 {
|
||||
let mut d = Document::from_text(text);
|
||||
d.last_section.margin_right = m as f32;
|
||||
let l = lay(&d);
|
||||
let lines: Vec<String> = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| {
|
||||
if let Placed::Lines { para, l0, l1, .. } = it {
|
||||
Some(para.lines[*l0..*l1].iter().map(|line| text[line.start..line.stop].to_string()).collect::<Vec<_>>())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.flatten()
|
||||
.collect();
|
||||
if lines.len() < 2 {
|
||||
continue; // still fits on one line at this width
|
||||
}
|
||||
assert_eq!(lines, ["Deed ", "2023.(Example"], "the first wrap breaks at the space");
|
||||
return;
|
||||
}
|
||||
panic!("no width in the sweep wrapped the paragraph");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_cache_invalidates_on_changed_filename() {
|
||||
let mut doc = Document::new();
|
||||
|
||||
@@ -639,3 +639,149 @@ fn list_label_after_leading_page_break_uses_its_text_lines_alignment() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The glyph ids of each letter of paragraph 0 as laid out (marks of runs left out have none).
|
||||
fn letters(l: &DocLayout) -> Vec<Vec<u32>> {
|
||||
let (pl, _) = first_line(l, 0);
|
||||
pl.clusters.iter().filter(|c| c.g1 > c.g0).map(|c| pl.glyphs[c.g0 as usize..c.g1 as usize].iter().map(|g| g.gid).collect()).collect()
|
||||
}
|
||||
|
||||
/// `text` as one right-to-left paragraph with `format` applied to each `(start, end)` byte range.
|
||||
fn formatted(text: &str, splits: &[(usize, usize)], format: &dyn Fn(&mut wordcraft_doc::props::CharProps)) -> Document {
|
||||
let mut d = doc_of(&[(text, true)]);
|
||||
for &(start, end) in splits {
|
||||
d.format_range(&Pos::body(0, start), &Pos::body(0, end), format).unwrap();
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
const BEH: usize = 'ب'.len_utf8();
|
||||
|
||||
fn red(c: &mut wordcraft_doc::props::CharProps) {
|
||||
c.color = Some(wordcraft_doc::props::TextColor::Rgb(wordcraft_doc::props::Rgb(0xC0, 0, 0)));
|
||||
}
|
||||
|
||||
/// The reporter's symptom: after a change of formatting inside a word, the letters on either side
|
||||
/// took the forms of the end and the start of a word, which reads as a space in it (#4093).
|
||||
#[test]
|
||||
fn letters_join_across_a_change_of_formatting() {
|
||||
if arabic_face().is_none() {
|
||||
eprintln!("skipped: no font with Persian letters installed");
|
||||
return;
|
||||
}
|
||||
let word = "بببب";
|
||||
let whole = letters(&lay(&doc_of(&[(word, true)])));
|
||||
assert_eq!(whole.len(), 4, "one cluster per letter: {whole:?}");
|
||||
assert!(whole.first() != whole.get(1) && whole.get(1) != whole.last(), "initial, medial and final forms: {whole:?}");
|
||||
// A letter of its own at each place, then the two middle letters, then every other letter.
|
||||
let pieces: [&[(usize, usize)]; 6] =
|
||||
[&[(0, BEH)], &[(BEH, 2 * BEH)], &[(2 * BEH, 3 * BEH)], &[(3 * BEH, 4 * BEH)], &[(BEH, 3 * BEH)], &[(0, BEH), (2 * BEH, 3 * BEH)]];
|
||||
for runs in pieces {
|
||||
let d = formatted(word, runs, &red);
|
||||
assert!(d.para_at(&Pos::body(0, 0)).unwrap().runs.len() > 1, "{runs:?} split the run");
|
||||
assert_eq!(letters(&lay(&d)), whole, "split at {runs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A letter joins what is on the page: with a tracked deletion shown, the deleted letter; with it
|
||||
/// left out, the letter past it. The same goes for hidden text.
|
||||
#[test]
|
||||
fn letters_join_past_text_left_out_of_the_layout() {
|
||||
if arabic_face().is_none() {
|
||||
eprintln!("skipped: no font with Persian letters installed");
|
||||
return;
|
||||
}
|
||||
let (three, two) = (letters(&lay(&doc_of(&[("ببب", true)]))), letters(&lay(&doc_of(&[("بب", true)]))));
|
||||
assert_eq!((three.len(), two.len()), (3, 2));
|
||||
let middle = [(BEH, 2 * BEH)];
|
||||
|
||||
let deleted = formatted("ببب", &middle, &|c| c.del = Some(0));
|
||||
assert_eq!(letters(&lay(&deleted)), three, "deletion shown: it joins both neighbours");
|
||||
let mut c = LayoutCache::new();
|
||||
let final_text = layout(&deleted, &mut c, &LayoutOptions { hide_deleted: true, ..LayoutOptions::default() });
|
||||
assert_eq!(letters(&final_text), two, "deletion left out: its neighbours join each other");
|
||||
|
||||
let hidden = formatted("ببب", &middle, &|c| c.hidden = Some(true));
|
||||
assert_eq!(letters(&lay(&hidden)), two, "hidden text left out: its neighbours join each other");
|
||||
let mut c = LayoutCache::new();
|
||||
let shown = layout(&hidden, &mut c, &LayoutOptions { show_hidden: true, ..LayoutOptions::default() });
|
||||
assert_eq!(letters(&shown), three, "hidden text shown: it joins both neighbours");
|
||||
}
|
||||
|
||||
/// A mark in a run of its own (a coloured vowel sign) doesn't stop the letters around it joining.
|
||||
#[test]
|
||||
fn letters_join_past_a_mark_in_a_run_of_its_own() {
|
||||
if arabic_face().is_none() {
|
||||
eprintln!("skipped: no font with Persian letters installed");
|
||||
return;
|
||||
}
|
||||
let word = "بَب";
|
||||
let whole = letters(&lay(&doc_of(&[(word, true)])));
|
||||
let split = letters(&lay(&formatted(word, &[(BEH, BEH + 'َ'.len_utf8())], &red)));
|
||||
assert_eq!(split.last(), whole.last(), "the letter after the mark stays joined");
|
||||
// Together with the mark, the letter before it has more glyphs, in the shaper's order.
|
||||
let (before, with_mark) = (split.first().unwrap(), whole.first().unwrap());
|
||||
assert!(before.iter().all(|g| with_mark.contains(g)), "and the letter before it: {before:?} {with_mark:?}");
|
||||
}
|
||||
|
||||
/// What can't join still doesn't: a tab between the letters, in one run or at the edge of a run, a
|
||||
/// Latin neighbour, and a letter beside a drop cap, which is drawn apart from the word.
|
||||
#[test]
|
||||
fn letters_stay_apart_where_nothing_joins_them() {
|
||||
if arabic_face().is_none() {
|
||||
eprintln!("skipped: no font with Persian letters installed");
|
||||
return;
|
||||
}
|
||||
let alone = letters(&lay(&doc_of(&[("ب", true)])));
|
||||
assert_eq!(alone.len(), 1);
|
||||
let beh = alone.first();
|
||||
let tabbed = "ب\tب";
|
||||
for d in [doc_of(&[(tabbed, true)]), formatted(tabbed, &[(0, BEH)], &red), formatted(tabbed, &[(BEH + 1, tabbed.len())], &red)] {
|
||||
let got = letters(&lay(&d));
|
||||
assert_eq!((got.first(), got.last()), (beh, beh), "across a tab: {got:?}");
|
||||
}
|
||||
let got = letters(&lay(&formatted("xب", &[(0, 1)], &red)));
|
||||
assert_eq!(got.last(), beh, "after a Latin letter: {got:?}");
|
||||
|
||||
// The drop cap in the word's run, and formatted on its own (a run of its own).
|
||||
let two = letters(&lay(&doc_of(&[("بب", true)])));
|
||||
for mut d in [doc_of(&[("ببب", true)]), formatted("ببب", &[(0, BEH)], &red)] {
|
||||
let at = Pos::body(0, 0);
|
||||
d.format_paragraphs(&at, &at, &|p: &mut ParaProps| p.drop_cap = Some(2)).unwrap();
|
||||
let runs = d.para_at(&at).unwrap().runs.len();
|
||||
let got = letters(&lay(&d));
|
||||
assert_eq!(got.get(got.len().saturating_sub(2)..), two.get(..), "{runs} run(s): the letters after a drop cap start a word: {got:?} {two:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shaper_takes_a_letters_form_from_the_text_around_it() {
|
||||
use wordcraft_fonts::{ShapeContext, shape_run_in};
|
||||
let Some(face) = arabic_face() else {
|
||||
eprintln!("skipped: no font with Persian letters installed");
|
||||
return;
|
||||
};
|
||||
let form = |around| cluster_glyphs(&shape_run_in(&face, "ب", &[], |c| c, true, around));
|
||||
let isolated = form(ShapeContext::default());
|
||||
let joined = cluster_glyphs(&wordcraft_fonts::shape_run(&face, "ببب", &[], |c| c, true));
|
||||
assert_eq!(form(ShapeContext { before: "", after: "ب" }).first(), joined.first(), "initial");
|
||||
assert_eq!(form(ShapeContext { before: "ب", after: "ب" }).first(), joined.get(1), "medial");
|
||||
assert_eq!(form(ShapeContext { before: "ب", after: "" }).first(), joined.get(2), "final");
|
||||
// A vowel sign is transparent, and ZWNJ and a Latin letter stop the join.
|
||||
assert_eq!(form(ShapeContext { before: "بَ", after: "" }).first(), joined.get(2), "past a mark");
|
||||
assert_eq!(form(ShapeContext { before: "ب\u{200C}", after: "" }), isolated, "after ZWNJ");
|
||||
assert_eq!(form(ShapeContext { before: "a", after: "b" }), isolated, "between Latin letters");
|
||||
// Only the letter's form depends on the context: nothing of it is drawn.
|
||||
assert_eq!(form(ShapeContext { before: "ببب", after: "ببب" }).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripts_that_join_their_letters_are_told_apart() {
|
||||
use wordcraft_fonts::joins_cursively;
|
||||
for c in ['ب', 'پ', 'ܐ', 'ᠠ', 'ߊ', '𞤀'] {
|
||||
assert!(joins_cursively(c), "{c}");
|
||||
}
|
||||
for c in ['a', 'é', '’', 'я', 'א', '中', ' ', '\t', '\u{FFFC}', '\u{200C}', 'َ'] {
|
||||
assert!(!joins_cursively(c), "{c:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! An empty paragraph after a document's leading floating table takes no space before. Word's PDFs
|
||||
//! of docxide-pdf's `leading-empty-before-float-0` and `-12` put the text after the empty anchor at
|
||||
//! the same place for 0 pt and 12 pt before.
|
||||
|
||||
use super::lay;
|
||||
use crate::{Arc, Placed, StoryRef};
|
||||
use wordcraft_doc::para::{Anchor, InlineObject};
|
||||
use wordcraft_doc::props::TableFloat;
|
||||
use wordcraft_doc::{Block, Document, Paragraph, Table, para_block};
|
||||
|
||||
/// Where the lines of body block `block` start on page one.
|
||||
fn line_y(d: &Document, block: u32) -> f32 {
|
||||
lay(d).pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|i| match i {
|
||||
Placed::Lines { story: StoryRef::Body, path, y, .. } if path.0 == [block] => Some(*y),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// `table` (a floating one) or nothing, then a paragraph of `text` with `before`, then "After".
|
||||
fn document(table: bool, text: &str, before: f32) -> Document {
|
||||
let mut p = Paragraph::with_text(text, Default::default());
|
||||
// "#" stands for a bookmark, a zero-width marker.
|
||||
if text == "#" {
|
||||
p = Paragraph::new();
|
||||
p.insert_object(0, InlineObject::BookmarkStart { name: "_GoBack".into() }, &Default::default()).unwrap();
|
||||
}
|
||||
p.props.space_before = Some(before);
|
||||
let mut d = Document::new();
|
||||
d.body = vec![para_block(p), para_block(Paragraph::with_text("After", Default::default()))];
|
||||
if table {
|
||||
let mut t = Table::new(1, 1, 200.0);
|
||||
t.rows[0].cells[0].blocks = vec![para_block(Paragraph::with_text("Table", Default::default()))];
|
||||
t.props.float = Some(TableFloat { h_rel: Anchor::Margin, v_rel: Anchor::Paragraph, ..Default::default() });
|
||||
d.body.insert(0, Arc::new(Block::Table(t)));
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_paragraph_after_a_floating_table_at_the_page_top_takes_no_space_before() {
|
||||
let empty = line_y(&document(true, "", 12.0), 2);
|
||||
let plain = line_y(&document(false, "", 0.0), 1);
|
||||
assert!((empty - plain).abs() < 0.01, "after the empty anchor at {empty}, with no table nor space before at {plain}");
|
||||
// A bookmark alone doesn't make it a paragraph with text.
|
||||
assert!((line_y(&document(true, "#", 12.0), 2) - plain).abs() < 0.01);
|
||||
// One with text keeps it, and so does an empty first paragraph with no table before it.
|
||||
assert!(line_y(&document(true, "Text", 12.0), 2) > empty + 11.99);
|
||||
assert!(line_y(&document(false, "", 12.0), 1) > plain + 11.99);
|
||||
}
|
||||
@@ -482,3 +482,25 @@ fn selecting_a_header_on_the_hinted_page_does_not_walk_every_page() {
|
||||
eprintln!("hinted header: {small} steps for 20 pages, {big} for 160");
|
||||
assert!(small > 0 && big <= small + 8, "the pages aren't walked: {small} → {big}");
|
||||
}
|
||||
|
||||
/// A paragraph of a decimal tab at 200 pt followed by `text`.
|
||||
fn after_decimal_tab(text: &str) -> Document {
|
||||
use wordcraft_doc::props::{TabAlign, TabStop};
|
||||
let mut d = Document::from_text(&format!("\t{text}"));
|
||||
let at = Pos::body(0, 0);
|
||||
d.format_paragraphs(&at, &at, &|p| p.tabs = Some(vec![TabStop { pos: 200.0, align: TabAlign::Decimal, leader: Default::default() }])).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// The text after a decimal tab was looked at again from the tab for its '.' after each of its
|
||||
/// clusters, so a long run of clusters without a '.' (soft hyphens take no room, so they stay on
|
||||
/// one line) took quadratic time.
|
||||
#[test]
|
||||
fn text_after_a_decimal_tab_is_looked_at_once() {
|
||||
let opts = LayoutOptions::default();
|
||||
// The text after the tab still ends at the stop, where it ended before.
|
||||
let l = lay(&after_decimal_tab("12.5"), &opts);
|
||||
let (Some(end), Some(start)) = (l.caret(&Pos::body(0, 5)), l.caret(&Pos::body(0, 0))) else { panic!("no carets") };
|
||||
assert!((end.x - start.x - 200.0).abs() < 1.0, "the text ends at the stop: {} from {}", end.x, start.x);
|
||||
assert_layout_linear("decimal tab", 2000, |n| after_decimal_tab(&format!("1{}", "\u{AD}".repeat(n))), &opts);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
//! A row of a nested table marked "can't split" moves on with its outer row, as in Word
|
||||
//! (docxide-pdf `nested-first-row-*-cant1`): the outer row splits only above it.
|
||||
use crate::*;
|
||||
use wordcraft_doc::{Table, para_block};
|
||||
|
||||
fn one_line_paras(what: &str, n: usize) -> Vec<Arc<Block>> {
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let mut p = Paragraph::with_text(&format!("{what} {i}"), Default::default());
|
||||
p.props.space_before = Some(0.0);
|
||||
p.props.space_after = Some(0.0);
|
||||
para_block(p)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A filler paragraph pushed `push` points down, then a one-row table whose cells each hold
|
||||
/// `prefix` one-line paragraphs and a nested table of one row with `nested` of them. Returns,
|
||||
/// per cell, the prefix lines and the nested lines on each page.
|
||||
fn lines_per_page(push: f32, cells: &[(usize, usize)], cant_split: bool) -> Vec<(Vec<usize>, Vec<usize>)> {
|
||||
let mut d = Document::from_text("Filler");
|
||||
if let Some(Block::Para(p)) = d.body.first_mut().map(Arc::make_mut) {
|
||||
p.props.space_before = Some(push);
|
||||
}
|
||||
let mut outer = Table::new(1, cells.len(), 440.0);
|
||||
for (cell, &(prefix, nested)) in outer.rows[0].cells.iter_mut().zip(cells) {
|
||||
let mut inner = Table::new(1, 1, 200.0);
|
||||
inner.rows[0].props.cant_split = cant_split;
|
||||
inner.rows[0].cells[0].blocks = one_line_paras("Nested", nested);
|
||||
cell.blocks = one_line_paras("Prefix", prefix);
|
||||
cell.blocks.push(Arc::new(Block::Table(inner)));
|
||||
cell.blocks.push(para_block(Paragraph::with_text("", Default::default())));
|
||||
}
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(outer)).unwrap();
|
||||
let mut c = LayoutCache::new();
|
||||
let l = layout(&d, &mut c, &LayoutOptions::default());
|
||||
let count = |cell: u32, nested: bool| -> Vec<usize> {
|
||||
l.pages
|
||||
.iter()
|
||||
.map(|pg| {
|
||||
pg.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Placed::Lines { path, l0, l1, .. } => {
|
||||
let p = &path.0;
|
||||
let hit = if nested {
|
||||
p.len() == 7 && p[..3] == [1, 0, cell] && p[4..6] == [0, 0]
|
||||
} else {
|
||||
p.len() == 4 && p[..3] == [1, 0, cell]
|
||||
};
|
||||
hit.then_some(l1 - l0)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.sum()
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
(0..cells.len() as u32).map(|c| (count(c, false), count(c, true))).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_cant_split_row_moves_on_with_its_outer_row() {
|
||||
let split = &lines_per_page(520.0, &[(0, 20)], false)[0].1;
|
||||
assert!(split[0] > 0 && split[1] > 0, "a splittable nested row splits with its outer row: {split:?}");
|
||||
assert_eq!(lines_per_page(520.0, &[(0, 20)], true)[0].1[..2], [0, 20], "a can't-split nested row moves on whole");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_outer_row_splits_above_a_nested_cant_split_row() {
|
||||
// About 22 lines of room: the nested row (lines 10 to 40) runs through it.
|
||||
let cell = &lines_per_page(340.0, &[(10, 30)], true)[0];
|
||||
assert!(cell.0[0] > 0, "the text above the nested row stays: {cell:?}");
|
||||
assert_eq!(cell.1[..2], [0, 30], "the nested row moves on whole: {cell:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cut_moves_up_until_no_nested_cant_split_row_runs_through_it() {
|
||||
// Room for about 22 lines. Only cell 0's nested row (lines 10 to 40) runs through it; the
|
||||
// cut at its top then runs through cell 1's (lines 5 to 13), which moves on whole too.
|
||||
let cells = lines_per_page(340.0, &[(10, 30), (5, 8)], true);
|
||||
assert_eq!(cells[0].1[..2], [0, 30], "{cells:?}");
|
||||
assert_eq!(cells[1].1[..2], [0, 8], "{cells:?}");
|
||||
assert!(cells[0].0[0] > 0 && cells[1].0[0] > 0, "the text above both stays: {cells:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_cant_split_row_taller_than_a_page_still_splits() {
|
||||
let pages = &lines_per_page(520.0, &[(0, 60)], true)[0].1;
|
||||
assert_eq!(pages[0], 0, "it leaves the partly filled page: {pages:?}");
|
||||
assert_eq!(pages.iter().sum::<usize>(), 60, "every line is placed once: {pages:?}");
|
||||
assert!(pages.iter().filter(|&&n| n > 0).count() >= 2, "it runs on over pages instead of into the margin: {pages:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_outer_row_breaks_between_nested_cant_split_rows_above_its_bottom_band() {
|
||||
let mut nested = Table::new(40, 1, 200.0);
|
||||
nested.props.cell_margins = Some([0.0; 4]);
|
||||
for (i, row) in nested.rows.iter_mut().enumerate() {
|
||||
row.props.cant_split = true;
|
||||
row.cells[0].blocks = one_line_paras(&format!("Row {i}"), 2);
|
||||
}
|
||||
let mut outer = Table::new(1, 1, 400.0);
|
||||
// A bottom margin: the part left on the page keeps it below the last row there.
|
||||
outer.props.cell_margins = Some([0.0, 5.4, 4.0, 5.4]);
|
||||
outer.rows[0].cells[0].blocks = vec![Arc::new(Block::Table(nested)), empty()];
|
||||
let l = lay_pushed(520.0, outer);
|
||||
let pages = per_page(&l, |p| p.len() == 7 && p[..4] == [1, 0, 0, 0]);
|
||||
assert!(pages[0] > 0, "the rows that fit stay on the page: {pages:?}");
|
||||
assert_eq!(pages.iter().sum::<usize>(), 80, "every line is placed once: {pages:?}");
|
||||
for r in 0..40u32 {
|
||||
let on: Vec<usize> = per_page(&l, |p| p.len() == 7 && p[..5] == [1, 0, 0, 0, r]);
|
||||
assert_eq!(on.iter().filter(|&&n| n > 0).count(), 1, "row {r} stays on one page: {on:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn empty() -> Arc<Block> {
|
||||
para_block(Paragraph::with_text("", Default::default()))
|
||||
}
|
||||
|
||||
/// A nested table of one row, marked "can't split", with `lines` one-line paragraphs.
|
||||
fn kept_nested(lines: usize) -> Arc<Block> {
|
||||
let mut t = Table::new(1, 1, 200.0);
|
||||
t.rows[0].props.cant_split = true;
|
||||
t.rows[0].cells[0].blocks = one_line_paras("Nested", lines);
|
||||
Arc::new(Block::Table(t))
|
||||
}
|
||||
|
||||
/// Lay out a filler paragraph pushed `push` points down, then `outer`.
|
||||
fn lay_pushed(push: f32, outer: Table) -> DocLayout {
|
||||
let mut d = Document::from_text("Filler");
|
||||
if let Some(Block::Para(p)) = d.body.first_mut().map(Arc::make_mut) {
|
||||
p.props.space_before = Some(push);
|
||||
}
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(outer)).unwrap();
|
||||
let mut c = LayoutCache::new();
|
||||
layout(&d, &mut c, &LayoutOptions::default())
|
||||
}
|
||||
|
||||
/// The lines on each page of the body paragraphs whose path `hit` picks.
|
||||
fn per_page(l: &DocLayout, hit: impl Fn(&[u32]) -> bool) -> Vec<usize> {
|
||||
l.pages
|
||||
.iter()
|
||||
.map(|pg| {
|
||||
pg.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Placed::Lines { path, l0, l1, .. } => hit(&path.0).then_some(l1 - l0),
|
||||
_ => None,
|
||||
})
|
||||
.sum()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bottom_aligned_cell_keeps_its_nested_cant_split_row_whole() {
|
||||
// Cell 1 sits at the bottom of the row as laid out, but the split runs it from its top,
|
||||
// where its nested row (lines 2 to 32) runs through the room of about 22 lines.
|
||||
let mut outer = Table::new(1, 2, 440.0);
|
||||
outer.rows[0].cells[0].blocks = one_line_paras("Tall", 50);
|
||||
outer.rows[0].cells[1].props.valign = Some(wordcraft_doc::props::VAlign::Bottom);
|
||||
let mut blocks = one_line_paras("Prefix", 2);
|
||||
blocks.extend([kept_nested(30), empty()]);
|
||||
outer.rows[0].cells[1].blocks = blocks;
|
||||
let l = lay_pushed(340.0, outer);
|
||||
let nested = per_page(&l, |p| p.len() == 7 && p[..4] == [1, 0, 1, 2]);
|
||||
assert_eq!(nested[..2], [0, 30], "the nested row moves on whole: {nested:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_rows_go_on_with_a_first_row_whose_nested_cant_split_row_comes_first() {
|
||||
let mut outer = Table::new(2, 1, 400.0);
|
||||
outer.rows[0].props.header = true;
|
||||
outer.rows[0].cells[0].blocks = one_line_paras("Header", 1);
|
||||
outer.rows[1].cells[0].blocks = vec![kept_nested(20), empty()];
|
||||
let l = lay_pushed(520.0, outer);
|
||||
let header = per_page(&l, |p| p == [1, 0, 0, 0]);
|
||||
assert_eq!(header[0], 0, "the header row doesn't stay alone at the page bottom: {header:?}");
|
||||
assert_eq!(per_page(&l, |p| p.len() == 7 && p[..4] == [1, 1, 0, 0])[..2], [0, 20]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kept_row_goes_on_with_a_row_whose_nested_cant_split_row_cant_start_below_it() {
|
||||
let mut outer = Table::new(2, 1, 400.0);
|
||||
let mut kept = one_line_paras("Kept", 1);
|
||||
if let Some(Block::Para(p)) = kept.first_mut().map(Arc::make_mut) {
|
||||
p.props.keep_next = Some(true);
|
||||
}
|
||||
outer.rows[0].cells[0].blocks = kept;
|
||||
outer.rows[1].cells[0].blocks = vec![kept_nested(20), empty()];
|
||||
let l = lay_pushed(520.0, outer);
|
||||
let kept = per_page(&l, |p| p == [1, 0, 0, 0]);
|
||||
assert_eq!(kept[..2], [0, 1], "the kept row moves on with the next one: {kept:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_merged_cell_doesnt_stretch_its_cant_split_row() {
|
||||
// Row 0 can't split; its first cell is merged down into row 1, which can and runs through
|
||||
// the room. The cut stays in row 1.
|
||||
let mut nested = Table::new(2, 2, 300.0);
|
||||
nested.rows[0].props.cant_split = true;
|
||||
nested.rows[0].cells[0].props.vmerge = wordcraft_doc::props::VMerge::Restart;
|
||||
nested.rows[1].cells[0].props.vmerge = wordcraft_doc::props::VMerge::Continue;
|
||||
nested.rows[0].cells[0].blocks = one_line_paras("Merged", 1);
|
||||
nested.rows[0].cells[1].blocks = one_line_paras("Short", 1);
|
||||
nested.rows[1].cells[1].blocks = one_line_paras("Long", 20);
|
||||
let mut outer = Table::new(1, 1, 400.0);
|
||||
outer.rows[0].cells[0].blocks = vec![Arc::new(Block::Table(nested)), empty()];
|
||||
let l = lay_pushed(520.0, outer);
|
||||
let long = per_page(&l, |p| p.len() == 7 && p[..6] == [1, 0, 0, 0, 1, 1]);
|
||||
assert!(long[0] > 0 && long[1] > 0, "the splittable row splits: {long:?}");
|
||||
}
|
||||
@@ -97,6 +97,49 @@ fn character_styles_and_paragraph_numbers() {
|
||||
assert_eq!(h[3], ["two", "3", "3.a", "3"]);
|
||||
}
|
||||
|
||||
/// #4985: a heading on a "none" list level (its label is its text, `Note`) shows its parent's
|
||||
/// number in full context (`\w`): the parent is not taken for the heading's own level, which
|
||||
/// has no number to list.
|
||||
#[test]
|
||||
fn full_context_keeps_the_parent_of_a_none_level_heading() {
|
||||
let mut d = doc(CHAPTERS, &[r#"STYLEREF "Heading 2" \w"#, r#"STYLEREF "Heading 2" \r"#]);
|
||||
let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
|
||||
if let Some(n) = Arc::make_mut(&mut d.numbering).nums.iter_mut().find(|n| n.id == num) {
|
||||
let note = wordcraft_doc::numbering::Level { format: wordcraft_doc::section::NumFormat::None, text: "Note".into(), ..Default::default() };
|
||||
n.level_overrides = vec![(1, note)];
|
||||
}
|
||||
for (i, lvl) in [(0, 0u8), (2, 0), (5, 0), (6, 1)] {
|
||||
d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().props.numbering = Some(NumRef { num, level: lvl });
|
||||
}
|
||||
assert_eq!(headers(&d)[3], ["3.Note", "3.Note"]);
|
||||
}
|
||||
|
||||
/// #4985 review: `\t` leaves a "none" level's label, which holds no number, with nothing, and an
|
||||
/// empty label of its own adds no part in full context: `3`, not `3.`.
|
||||
#[test]
|
||||
fn a_none_level_heading_s_number_has_no_text_and_no_trailing_period() {
|
||||
let numbered = |text: &str, codes: &[&str]| {
|
||||
let mut d = doc(CHAPTERS, codes);
|
||||
let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
|
||||
if let Some(n) = Arc::make_mut(&mut d.numbering).nums.iter_mut().find(|n| n.id == num) {
|
||||
let level = wordcraft_doc::numbering::Level { format: wordcraft_doc::section::NumFormat::None, text: text.into(), ..Default::default() };
|
||||
n.level_overrides = vec![(1, level)];
|
||||
}
|
||||
for (i, lvl) in [(0, 0u8), (2, 0), (5, 0), (6, 1)] {
|
||||
d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().props.numbering = Some(NumRef { num, level: lvl });
|
||||
}
|
||||
headers(&d)[3].clone()
|
||||
};
|
||||
assert!(numbered("Note", &[r#"STYLEREF "Heading 2" \n \t"#]).is_empty(), "nothing is drawn");
|
||||
// `\s` is a chapter number: a "none" label isn't one, so the heading's count stands in; a
|
||||
// label showing its parent's number (`%1`, `%1 Response`) gives that number.
|
||||
assert_eq!(numbered("Note", &[r#"STYLEREF "Heading 2" \s"#]), ["1"]);
|
||||
assert_eq!(numbered("%1", &[r#"STYLEREF "Heading 2" \s"#]), ["3"]);
|
||||
assert_eq!(numbered("%1 Response", &[r#"STYLEREF "Heading 2" \s"#, r#"STYLEREF "Heading 2" \n \t"#]), ["3", "3"]);
|
||||
assert_eq!(numbered("Section%1", &[r#"STYLEREF "Heading 2" \n \t"#]), ["3"]);
|
||||
assert_eq!(numbered("", &[r#"STYLEREF "Heading 2" \w"#, r#"STYLEREF "Heading 2" \r"#]), ["3", "3"]);
|
||||
}
|
||||
|
||||
/// Editing a heading updates the headers that show it, through the paragraph cache.
|
||||
#[test]
|
||||
fn headers_follow_heading_edits() {
|
||||
|
||||
@@ -208,6 +208,7 @@ fn links_out_of_a_root_are_refused() {
|
||||
}
|
||||
|
||||
/// The names in `dir`, sorted.
|
||||
#[cfg(unix)]
|
||||
fn names(dir: &Path) -> Vec<String> {
|
||||
let mut names: Vec<String> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
names.sort();
|
||||
|
||||
+44
-21
@@ -166,6 +166,15 @@ impl Exporter<'_> {
|
||||
}
|
||||
s.pop();
|
||||
}
|
||||
// A turned picture cut at its line's top and bottom.
|
||||
Draw::Clip { rect, items } => {
|
||||
let Some(clip) = rect_path(rect) else { return };
|
||||
s.push_clip_path(&clip, &FillRule::NonZero);
|
||||
for it in items {
|
||||
self.rotated_pictures(s, it);
|
||||
}
|
||||
s.pop();
|
||||
}
|
||||
Draw::Image { decorative: true, .. } => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)),
|
||||
Draw::Image { alt, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| me.draw(s, d)),
|
||||
Draw::Figure { alt, draws, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| {
|
||||
@@ -177,6 +186,40 @@ impl Exporter<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// One draw of a paragraph's line, tagged for what it is: text as the paragraph's content
|
||||
/// (structure entry `idx`), pictures and figures on their own, anything else an artifact.
|
||||
fn line_item(&mut self, s: &mut Surface, d: &Draw, idx: usize) {
|
||||
match d {
|
||||
Draw::Glyphs { .. } | Draw::Turned { .. } => self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)),
|
||||
// A picture marked decorative: an artifact screen readers skip.
|
||||
Draw::Image { decorative: true, .. } => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)),
|
||||
Draw::Image { alt, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| me.draw(s, d)),
|
||||
// A rotated inline picture, or a rotated group holding pictures: turned as a
|
||||
// whole, each picture tagged on its own (see `rotated_pictures`).
|
||||
Draw::Rotated { items, .. } if items.iter().any(holds_picture) => self.rotated_pictures(s, d),
|
||||
// A clip (a turned picture cut at its line, an exact-height cell's lines, a canvas's
|
||||
// members): each draw in it tagged as it would be outside it.
|
||||
Draw::Clip { rect, items } => {
|
||||
let Some(clip) = rect_path(rect) else { return };
|
||||
s.push_clip_path(&clip, &FillRule::NonZero);
|
||||
for it in items {
|
||||
self.line_item(s, it, idx);
|
||||
}
|
||||
s.pop();
|
||||
}
|
||||
// A turned text box's line: still the paragraph's text. A rotated shape
|
||||
// without text falls to the artifacts below.
|
||||
Draw::Rotated { items, .. } if items.iter().any(holds_text) => self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)),
|
||||
// An inline chart, diagram or described shape: its text is part of the figure, not the paragraph.
|
||||
Draw::Figure { alt, kind, draws: inner } => self.tagged(s, Role::Figure, Some(figure_alt(alt, *kind)), |me, s| {
|
||||
for d in inner {
|
||||
me.draw(s, d);
|
||||
}
|
||||
}),
|
||||
_ => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn page(&mut self, s: &mut Surface, page: &Page, w: f32, h: f32) {
|
||||
if let Some(c) = self.doc.settings.page_color
|
||||
&& let Some(p) = rect_path(&Rect::new(0.0, 0.0, w, h))
|
||||
@@ -221,27 +264,7 @@ impl Exporter<'_> {
|
||||
Placed::Lines { story, path, para, .. } => {
|
||||
let idx = self.entry(*story, path, para);
|
||||
for d in &draws {
|
||||
match d {
|
||||
Draw::Glyphs { .. } | Draw::Turned { .. } => self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)),
|
||||
// A picture marked decorative: an artifact screen readers skip.
|
||||
Draw::Image { decorative: true, .. } => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)),
|
||||
Draw::Image { alt, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| me.draw(s, d)),
|
||||
// A rotated inline picture, or a rotated group holding pictures: turned as a
|
||||
// whole, each picture tagged on its own (see `rotated_pictures`).
|
||||
Draw::Rotated { items, .. } if items.iter().any(holds_picture) => self.rotated_pictures(s, d),
|
||||
// A turned text box's line: still the paragraph's text. A rotated shape
|
||||
// without text falls to the artifacts below.
|
||||
Draw::Rotated { items, .. } if items.iter().any(holds_text) => {
|
||||
self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d))
|
||||
}
|
||||
// An inline chart, diagram or described shape: its text is part of the figure, not the paragraph.
|
||||
Draw::Figure { alt, kind, draws: inner } => self.tagged(s, Role::Figure, Some(figure_alt(alt, *kind)), |me, s| {
|
||||
for d in inner {
|
||||
me.draw(s, d);
|
||||
}
|
||||
}),
|
||||
_ => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)),
|
||||
}
|
||||
self.line_item(s, d, idx);
|
||||
}
|
||||
}
|
||||
Placed::Image { story, path, off, .. } => {
|
||||
|
||||
@@ -8,7 +8,10 @@ pub(crate) fn metadata(doc: &Document, opts: &PdfOptions) -> Metadata {
|
||||
if !title.trim().is_empty() {
|
||||
m.title = Some(title);
|
||||
}
|
||||
let author = opts.author.clone().unwrap_or_else(|| c.creator.clone());
|
||||
// "Remove personal information from file properties on save" covers the PDF's author too:
|
||||
// the document's creator is left out (an author the caller names is still written).
|
||||
let kept = if doc.settings.remove_personal_info { String::new() } else { c.creator.clone() };
|
||||
let author = opts.author.clone().unwrap_or(kept);
|
||||
if !author.trim().is_empty() {
|
||||
m.authors = vec![author];
|
||||
}
|
||||
|
||||
@@ -5,11 +5,12 @@ pub(crate) fn graphic_alt(alt: &str, kind: GraphicKind) -> &str {
|
||||
if alt.trim().is_empty() { kind.noun() } else { alt }
|
||||
}
|
||||
|
||||
/// A picture, or a rotation holding one at any depth (a rotated group's turned members).
|
||||
/// A picture, or a rotation or clip holding one at any depth (a rotated group's turned members, a
|
||||
/// turned picture cut at its line).
|
||||
pub(crate) fn holds_picture(d: &Draw) -> bool {
|
||||
match d {
|
||||
Draw::Image { .. } => true,
|
||||
Draw::Rotated { items, .. } => items.iter().any(holds_picture),
|
||||
Draw::Rotated { items, .. } | Draw::Clip { items, .. } => items.iter().any(holds_picture),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1190,6 +1190,8 @@ fn decorative_pictures_are_artifacts_not_figures() {
|
||||
let cases = [
|
||||
("inline", Float::default()),
|
||||
("rotated inline", Float { rot: 30.0, ..Default::default() }),
|
||||
// Turned with no room kept for it: drawn cut at its line (a clip), still tagged.
|
||||
("bare turned inline", Float { rot: 45.0, bare_turn: true, ..Default::default() }),
|
||||
("floating", floating.clone()),
|
||||
("rotated floating", Float { rot: 30.0, ..floating }),
|
||||
];
|
||||
@@ -1202,6 +1204,43 @@ fn decorative_pictures_are_artifacts_not_figures() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A row of exact height cuts its cell's lines (one clip around them): text and a picture in
|
||||
/// it are tagged as they are in a row that fits, the text as the paragraph's, the picture as a
|
||||
/// Figure with its alt text.
|
||||
#[test]
|
||||
fn a_clipped_cell_tags_its_text_and_picture() {
|
||||
let raw = |exact: bool| {
|
||||
let mut d = Document::new();
|
||||
let media = d.add_media(red_right_half_emf(), "emf");
|
||||
let mut p = Paragraph::with_text("Cell text ", CharProps::default());
|
||||
let n = p.len();
|
||||
let obj = InlineObject::Image {
|
||||
media,
|
||||
w: 70.0,
|
||||
h: 70.0,
|
||||
alt: "A red square".into(),
|
||||
float: Default::default(),
|
||||
crop: [0.0; 4],
|
||||
ole: None,
|
||||
fx: Default::default(),
|
||||
};
|
||||
p.insert_object(n, obj, &CharProps::default()).unwrap();
|
||||
let mut t = Table::new(1, 1, 300.0);
|
||||
t.rows[0].cells[0].blocks = vec![para_block(p)];
|
||||
if exact {
|
||||
t.rows[0].props.height = Some(30.0);
|
||||
t.rows[0].props.height_rule = wordcraft_doc::props::HeightRule::Exact;
|
||||
}
|
||||
d.body = vec![Arc::new(Block::Table(t)), para_block(Paragraph::with_text("Below", CharProps::default()))];
|
||||
String::from_utf8_lossy(&export(&d, &PdfOptions { compress: false, tagged: true, ..Default::default() }).unwrap()).into_owned()
|
||||
};
|
||||
let (fits, cut) = (raw(false), raw(true));
|
||||
assert!(cut.contains("/Alt(A red square)"), "the picture in the cut cell is a Figure with its alt text");
|
||||
// The same marked content either way: the cut cell's text is not dropped as an artifact.
|
||||
assert_eq!(cut.matches("/MCID").count(), fits.matches("/MCID").count());
|
||||
assert_eq!(cut.matches("/S/P").count(), fits.matches("/S/P").count());
|
||||
}
|
||||
|
||||
/// In a rotated group, each picture is tagged on its own: the decorative one is an artifact, the
|
||||
/// other stays a Figure with its alt text.
|
||||
#[test]
|
||||
@@ -2862,3 +2901,14 @@ fn baseline_is_the_most_shared_y() {
|
||||
assert_eq!(baseline(&[(1, 0.0, 9.0), (2, 3.0, 4.0)]), Some(4.0));
|
||||
assert_eq!(baseline(&[(1, 0.0, -0.0), (2, 3.0, 0.0)]), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_author_is_left_out_when_personal_information_is_removed() {
|
||||
let mut d = Document::from_text("Private");
|
||||
d.core.creator = "Ada Writer".into();
|
||||
let opts = PdfOptions { tagged: false, compress: false, ..Default::default() };
|
||||
let raw = |d: &Document| String::from_utf8_lossy(&export(d, &opts).unwrap()).to_string();
|
||||
assert!(raw(&d).contains("Ada Writer"));
|
||||
d.settings.remove_personal_info = true;
|
||||
assert!(!raw(&d).contains("Ada Writer"), "the creator stays out of the PDF");
|
||||
}
|
||||
@@ -318,7 +318,8 @@ impl WordApp {
|
||||
file_dialogs::Asked::Busy => Ok(json!({"saved": false})),
|
||||
};
|
||||
}
|
||||
let retry = (id == "file.open").then(|| params.clone());
|
||||
// Opening a file, or a new document from a template, reads a file that may be protected.
|
||||
let retry = matches!(id, "file.open" | "file.newFromTemplate").then(|| params.clone());
|
||||
let retry_path = retry.clone();
|
||||
let r = self.execute(id, params);
|
||||
if let (Err(e), Some(params)) = (&r, retry)
|
||||
@@ -326,7 +327,8 @@ impl WordApp {
|
||||
{
|
||||
let path = params.get("path").and_then(Value::as_str).unwrap_or("");
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
self.dialog = Some(dialogs::Dialog::Password { name, params, password: Default::default(), message: String::new() });
|
||||
self.dialog =
|
||||
Some(dialogs::Dialog::Password { name, then: id.to_string(), params, password: Default::default(), message: String::new() });
|
||||
return Ok(json!({"pending": "password"}));
|
||||
}
|
||||
// Whoever asked, a failed open names the file and the cause (#1103).
|
||||
|
||||
@@ -11,7 +11,7 @@ impl WordApp {
|
||||
}
|
||||
self.font_generation = generation;
|
||||
self.session.relayout();
|
||||
self.previews.fonts_changed(ctx, db.user_fonts().iter().flat_map(|f| f.families()));
|
||||
self.previews.fonts_changed();
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
|
||||
@@ -184,23 +184,28 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
});
|
||||
});
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin::symmetric(40, 30))).show(ui, |ui| {
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin::symmetric(0, 30))).show(ui, |ui| {
|
||||
// The side padding sits inside the pages, so the scroll area reaches the window's edge.
|
||||
let pad = egui::Frame::NONE.inner_margin(egui::Margin::symmetric(40, 0));
|
||||
// The Print page fills the pane and scrolls its settings and its preview itself (#1156).
|
||||
if app.ui.backstage_page == "print" {
|
||||
crate::rtl::root(ui, |ui| export_page(app, ui));
|
||||
pad.show(ui, |ui| crate::rtl::root(ui, |ui| export_page(app, ui)));
|
||||
return;
|
||||
}
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
crate::rtl::root(ui, |ui| match app.ui.backstage_page.as_str() {
|
||||
"home" => home_page(app, ui),
|
||||
"new" => new_page(app, ui),
|
||||
"open" => open_page(app, ui),
|
||||
"saveAs" => save_as_page(app, ui),
|
||||
"info" => info_page(app, ui),
|
||||
"recover" => recovery_page(app, ui),
|
||||
"print" | "export" => export_page(app, ui),
|
||||
"options" => crate::options::page(app, ui),
|
||||
_ => home_page(app, ui),
|
||||
// Full width, so the bar sits at the window's edge and the wheel works over the whole page (#5282).
|
||||
egui::ScrollArea::vertical().auto_shrink([false, true]).show(ui, |ui| {
|
||||
pad.show(ui, |ui| {
|
||||
crate::rtl::root(ui, |ui| match app.ui.backstage_page.as_str() {
|
||||
"home" => home_page(app, ui),
|
||||
"new" => new_page(app, ui),
|
||||
"open" => open_page(app, ui),
|
||||
"saveAs" => save_as_page(app, ui),
|
||||
"info" => info_page(app, ui),
|
||||
"recover" => recovery_page(app, ui),
|
||||
"print" | "export" => export_page(app, ui),
|
||||
"options" => crate::options::page(app, ui),
|
||||
_ => home_page(app, ui),
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1054,3 +1054,55 @@ fn backstage_save_as_sidebar_opens_save_as_page() {
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().ui.backstage_page, "saveAs");
|
||||
}
|
||||
|
||||
/// Scrolls the File page with the wheel at `x` and returns doc0.docx's top before and after,
|
||||
/// plus the rectangle of the page's vertical scrollbar.
|
||||
fn file_page_wheel_at(x: f32) -> (f32, f32, egui::Rect) {
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default());
|
||||
a.ui.recent = (0..12).map(|i| format!("/docs/doc{i}.docx")).collect();
|
||||
a.run("ui.backstage", json!({"value": true, "page": "home"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 500.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
h.run_steps(4);
|
||||
|
||||
let bar = h
|
||||
.query_all_by_role(egui::accesskit::Role::ScrollBar)
|
||||
.map(|n| n.rect())
|
||||
.filter(|r| r.height() > r.width())
|
||||
.max_by(|a, b| a.height().total_cmp(&b.height()))
|
||||
.expect("the File page's vertical scrollbar");
|
||||
let before = h.get_by_label("doc0.docx").rect();
|
||||
h.event(egui::Event::PointerMoved(egui::pos2(x, 300.0)));
|
||||
h.event(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(0.0, -200.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
});
|
||||
h.run_steps(4);
|
||||
(before.min.y, h.get_by_label("doc0.docx").rect().min.y, bar)
|
||||
}
|
||||
|
||||
/// #5282: the File page scrolls with the wheel over its right side.
|
||||
#[test]
|
||||
fn the_file_page_scrolls_with_the_wheel_over_its_right_side() {
|
||||
let (before, after, _) = file_page_wheel_at(1300.0);
|
||||
assert!(after < before - 1.0, "expected doc0.docx to scroll up: before={before}, after={after}");
|
||||
}
|
||||
|
||||
/// #5282: the scroll area reaches the window's right edge, so its bar sits there and the wheel
|
||||
/// works there too.
|
||||
#[test]
|
||||
fn the_file_page_scrolls_with_the_wheel_at_the_window_edge() {
|
||||
// The harness keeps an 8 point margin, so the window edge is at x=1432.
|
||||
let (before, after, bar) = file_page_wheel_at(1428.0);
|
||||
assert!(after < before - 1.0, "expected doc0.docx to scroll up at the edge: before={before}, after={after}");
|
||||
assert!((bar.right() - 1432.0).abs() < 1.0, "expected the scrollbar at the window edge: {bar:?}");
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use crate::WordApp;
|
||||
use crate::theme::{Tokens, regular, semibold};
|
||||
|
||||
mod balloons;
|
||||
mod column_ruler;
|
||||
mod context_menu;
|
||||
#[cfg(test)]
|
||||
pub(crate) use context_menu::issue_heading;
|
||||
@@ -48,6 +49,7 @@ use geometry::{floating_comments, on_page_gap, on_visible_page, white_space_hidd
|
||||
use input::{EDGE_MARGIN, EDGE_MAX_DT, EDGE_SPEED_MAX};
|
||||
use input::{UnitDrag, clamped_scroll, edge_scroll, mouse, wheel_hovered};
|
||||
pub(crate) use input::{hold_document_selection, press_count};
|
||||
pub(crate) use links::follow_link;
|
||||
#[cfg(test)]
|
||||
use links::{field_link, is_followable_link, is_generated_bookmark_link};
|
||||
#[cfg(test)]
|
||||
|
||||
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user