mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-10 20:51:50 +00:00
Merge upstream/main into ui-text-hinting
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
66e169c343
123 files changed
+5185
-430
No files matched your search
@@ -18,7 +18,14 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
|
||||
credits:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
|
||||
release:
|
||||
needs: credits
|
||||
# Only this job publishes the GitHub release, so only it gets write access.
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -31,6 +38,7 @@ jobs:
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
web-craft-fonts: true
|
||||
version: ${{ inputs.version }}
|
||||
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
||||
+54
-2
@@ -1,14 +1,18 @@
|
||||
# WordCraft roadmap
|
||||
|
||||
> **CLI path update:** 2026-11-04 · Options before a non-Unicode input filename no longer make the CLI reject that filename; text option values still require Unicode; `run --file` and `run --script` retain native paths. No ranked gap or readiness estimate changes.
|
||||
|
||||
> **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged.
|
||||
|
||||
**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 (dark-theme interface text on Linux no longer looks semi-bold); 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 anchored on a later page of a long paragraph is drawn there, #4250); previously minor (progress log: `file.closeDocument` over the control channel and MCP bridge is refusedLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** trivial (dark-theme interface text on Linux no longer looks semi-bold); 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 shape dropped just after a page break inside a Line 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.
|
||||
>
|
||||
> **Headered CSV prefix update:** 2026-10-31 · Unquoted whitespace-only prefix lines are skipped before choosing recipient headers (#4249); quoted whitespace headers, headerless records, the shared CSV reader and recipient limits remain unchanged.
|
||||
|
||||
> **Page Setup update:** 2026-11-07 · Page Setup number boxes keep long input inside the field, including accepted margin values after focus leaves (#5650); accepted ranges and readiness estimates are unchanged.
|
||||
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
|
||||
> **Legacy form update:** 2026-10-22 · trivial: numeric XML references decode in field names and labels; readiness estimates unchanged.
|
||||
|
||||
@@ -144,8 +148,20 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|
||||
## Progress log
|
||||
|
||||
- 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.
|
||||
- 2026-11-05: ODT: a watermark added, changed or removed, and Different Odd & Even Pages turned on or off, in a file read from ODT are saved.
|
||||
- 2026-11-05: ODT font declarations are kept (gap 19): replacing them made LibreOffice pick other substitutes for fonts not installed, reflowing a document from 83 pages to 70 on a save with no edits.
|
||||
- 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-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.
|
||||
@@ -153,6 +169,11 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
- 2026-11-04: MHT import keeps semicolons and escaped quotes inside quoted MIME parameters, so an unrelated parameter cannot supply a false multipart boundary and hide the document text. Readiness estimates unchanged.
|
||||
- 2026-10-30: Equations: `\int`, `\sum` and the other n-ary operators typed with AutoCorrect wait for their limits. `\int` + Space, then `_0^1` and Space, gives ∫ with its limits and the caret in its operand, and `\int_0^1` typed in one go does the same, since any character that can't continue `\name` corrects it; this is observed in Word for Windows on #4938. Enter or an arrow key builds it without going into the operand, and `∫∫` puts the operand in the inner operator; those two are not observed in Word (#4917). Readiness unchanged.
|
||||
|
||||
- 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.
|
||||
@@ -202,11 +223,16 @@ 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: 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.
|
||||
@@ -217,6 +243,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.
|
||||
@@ -318,6 +345,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
- 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096).
|
||||
|
||||
| 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-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 |
|
||||
@@ -466,6 +494,8 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
| 2026-10-17 | Non-overlapping body floating tables clear header tables. Original native controls distinguish never-overlap from allowed overlap. |
|
||||
| 2026-10-14 | Comment text being typed is no longer lost or misplaced (#1348): an edit or reply the document refuses (for example read-only protection) keeps the typed text in its field and says why on the status line; drafts belong to their document, so after New or Open a comment with the same number shows its own text; Save, Export, Print, Close, New and Open write pending comment text first; if the document refuses it, Save, Export and Print carry on with the stored comments and show why on the status line (a refused draft is dropped when Save is the only change), while Close, New and Open show the Save Changes prompt (Don't Save drops text the document refuses); Escape discards what was typed; in a document protected as read only or for forms the comment fields can't be typed in |
|
||||
|---|---|
|
||||
| 2026-11-07 | Page Setup also bounds the unfocused margin display after a long numeric paste; the accepted value is retained (#5650). |
|
||||
| 2026-11-05 | Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. |
|
||||
| 2026-10-29 | Print Markup discovers comment balloon anchors in one forward paragraph-object walk; anchor positions, filtering and the existing cap stay unchanged (#3614) |
|
||||
| 2026-10-26 | Spelling & Grammar opens a right-side Proofing task pane observed in Word for Windows: sentence context, directly clickable suggestion cards and ignore/dictionary actions. Current-story traversal and stale-target checks are retained. Right-click keeps its compact menu. |
|
||||
| 2026-10-22 | Documents that keep content in another format (`w:altChunk`: a web page, RTF, text or another Word document, as generators and mail-merge tools write them) show it where it stands, as Word does, instead of leaving it out; one such file in the docxide-pdf corpus opened as an empty page and now matches Word's 8 pages. `.mht` web archives open, and web pages open the way Word opens them (style sheets, paragraph margins, Times New Roman, single lines, automatic spacing). Insert › Text from File keeps the inserted file's pictures |
|
||||
@@ -797,11 +827,18 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
|
||||
Footer page-table height and body wrapping are corrected. Other pagination gaps remain open.
|
||||
|
||||
Quoted field escapes are interpreted like Word, and body `PAGE` fields use their active section number format in paragraphs and tables. Synthetic regressions cover fields and section layout.
|
||||
|
||||
Section page-numbering settings preserve `w:chapStyle` values across the full unsigned-byte range.
|
||||
MATH glyphs that reference the same construction now share its immutable variants and assembly data (#3630). Glyph mappings, values and the existing partial-record fallback stay unchanged. Repeated glyph IDs keep their last valid reference without retaining superseded constructions. Distinct overlapping constructions and assembly-only sharing remain separate.
|
||||
|
||||
## Revision history
|
||||
|
||||
> 2026-11-05 · Linux self-update ignores another program's AppImage variables (#5653); recorded in the progress log, readiness unchanged.
|
||||
> 2026-11-05 · A template opened from the file manager starts a new document instead of being edited and saved over (#3433); recorded in the progress log, readiness unchanged.
|
||||
> 2026-11-05 · Arabic letters join across a change of formatting and past hidden or deleted text (#4093); recorded in the progress log, readiness unchanged.
|
||||
> 2026-11-05 · Shapes, text boxes, groups and canvases in Strict Open XML documents open (#3548); recorded in the progress log, readiness unchanged.
|
||||
>
|
||||
> 2026-11-03 · Arabic dialogs read right to left (rtl::Table); recorded in the progress log, readiness unchanged.
|
||||
>
|
||||
> 2026-10-30 · Technical synchronization: retain the Proofing pane and command/selection/undo contracts on main `3b7f7ed`; new-SHA validation pending, readiness unchanged.
|
||||
@@ -814,6 +851,15 @@ MATH glyphs that reference the same construction now share its immutable variant
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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 | 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) |
|
||||
@@ -822,9 +868,14 @@ MATH glyphs that reference the same construction now share its immutable variant
|
||||
| 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 |
|
||||
| 2026-10-30 | trivial | Progress log: typed n-ary operators such as ∫ and ∑ wait for their limits, and any character that cannot continue a math name corrects it, as observed in Word for Windows on #4938; Enter, arrow keys and nested operators are WordCraft's own choices (#4917); readiness unchanged |
|
||||
| 2026-11-04 | trivial | CLI input paths keep their native filename when a value-taking option comes first; regression coverage includes conversion to Flat OPC and rejection of non-Unicode option values. |
|
||||
| 2026-11-05 | trivial | DOCX paragraph `w:adjustRightInd` is preserved with true, false and absent values; OOXML properties follow schema order. Grid layout remains open. |
|
||||
| 2026-11-04 | trivial | Progress log: RTF Unicode fallback counts; readiness estimates unchanged |
|
||||
| 2026-10-28 | trivial | Per-page ink object lookups, #3614. Drawing order, geometry and saved data retain their behavior; no readiness estimate change |
|
||||
| 2026-11-05 | trivial | Progress log: ODT heading text of another size (gap 19) |
|
||||
| 2026-11-05 | trivial | Progress log: ODT table rows that don't break (gap 19) |
|
||||
| 2026-11-05 | trivial | Progress log: a watermark or odd and even headers change to a file is saved |
|
||||
| 2026-11-05 | trivial | Progress log: ODT font declarations kept (gap 19) |
|
||||
| 2026-11-05 | trivial | Progress log: ODT pictures offered in several formats (gap 19) |
|
||||
| 2026-10-28 | trivial | Shared MATH construction records reuse variants and assembly data without changing glyph mappings or existing limits (#3630); readiness estimates unchanged |
|
||||
| 2026-10-29 | trivial | Page drawing indexes text-box warp lookups per paragraph (#3614); requested offsets only, bounded prefix traversal, original Linux display and persistence controls; readiness estimates unchanged |
|
||||
@@ -852,13 +903,14 @@ MATH glyphs that reference the same construction now share its immutable variant
|
||||
| 2026-11-03 | trivial | Progress log: craft-proof pin, French elision suggestions and Valencian (CP4.3) |
|
||||
| 2026-11-03 | trivial | Progress log: a long comment's card and balloon stop at ten lines (#3382) |
|
||||
| 2026-11-03 | trivial | Progress log: Shape Format › Size in the interface unit (#4196) |
|
||||
| 2026-11-03 | trivial | Find smart-quoted text with a keyboard apostrophe (#5388); exact search modes and replacement spelling preserved. |
|
||||
| 2026-11-03 | trivial | Progress log: Replace and Remove Table of Contents with Word's title above a section break (#4041) |
|
||||
| 2026-11-03 | trivial | Progress log: language-packs-2 published and pinned, 75 Language packs |
|
||||
| 2026-11-02 | trivial | Progress log: language-packs-1 published and pinned, 61 Language packs (CP4.4) |
|
||||
| 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) |
|
||||
|
||||
@@ -98,8 +98,8 @@ fn open(path: &Path, password: Option<&str>) -> Result<Session, String> {
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// The commands that open a file, and which of their arguments not starting with `--` it is (as
|
||||
/// `pos` counts). `run` opens the value of `--file`.
|
||||
/// The commands that open a file, and which positional argument it is, excluding option values.
|
||||
/// `run` opens the value of `--file`.
|
||||
const OPENS_FILE: &[(&str, usize)] = &[("convert", 0), ("info", 0), ("text", 0), ("inspect", 0), ("render", 0), ("zotero", 1)];
|
||||
|
||||
/// The arguments as text. Only the file the command opens may have a name that isn't valid Unicode,
|
||||
@@ -108,8 +108,14 @@ const OPENS_FILE: &[(&str, usize)] = &[("convert", 0), ("info", 0), ("text", 0),
|
||||
fn text_args<S: AsRef<OsStr>>(raw: &[S]) -> Result<(Vec<String>, Option<PathBuf>), String> {
|
||||
let args: Vec<String> = raw.iter().map(|a| a.as_ref().to_string_lossy().into_owned()).collect();
|
||||
let cmd = args.first().map(String::as_str).unwrap_or("");
|
||||
let specs = match cmd {
|
||||
"convert" => CONVERT_OPTIONS,
|
||||
"render" => RENDER_OPTIONS,
|
||||
"zotero" => ZOTERO_OPTIONS,
|
||||
_ => PASSWORD_OPTIONS,
|
||||
};
|
||||
let file_at = match OPENS_FILE.iter().find(|(c, _)| *c == cmd) {
|
||||
Some((_, n)) => args.iter().enumerate().skip(1).filter(|(_, a)| !a.starts_with("--")).nth(*n).map(|(i, _)| i),
|
||||
Some((_, n)) => positional_indices(args.get(1..).unwrap_or_default(), specs).get(*n).map(|i| i + 1),
|
||||
None if cmd == "run" => args.iter().position(|a| a == "--file").map(|i| i + 1),
|
||||
None => None,
|
||||
};
|
||||
@@ -145,8 +151,13 @@ fn password_from(args: &[String]) -> Result<Option<String>, String> {
|
||||
}
|
||||
|
||||
/// The positional arguments, skipping every option's value so an option value is never read as a
|
||||
/// path. Mirrors [`validate_options`], which has already checked the options are known.
|
||||
/// path. Uses the same option specifications as [`validate_options`].
|
||||
fn positionals<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a String> {
|
||||
positional_indices(args, specs).into_iter().filter_map(|i| args.get(i)).collect()
|
||||
}
|
||||
|
||||
/// Keep the original indices too, so [`text_args`] can open a positional path by its native name.
|
||||
fn positional_indices(args: &[String], specs: &[OptionSpec]) -> Vec<usize> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some(arg) = args.get(i) {
|
||||
@@ -156,7 +167,7 @@ fn positionals<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a String>
|
||||
i += 2;
|
||||
} else {
|
||||
if !arg.starts_with("--") {
|
||||
out.push(arg);
|
||||
out.push(i);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
@@ -211,6 +211,10 @@ fn only_the_opened_file_may_be_other_than_unicode() {
|
||||
let ok = |raw: Vec<OsString>| text_args(&raw).unwrap().1;
|
||||
assert_eq!(ok(vec!["zotero".into(), "refresh".into(), latin1()]), Some(PathBuf::from(latin1())));
|
||||
assert_eq!(ok(vec!["render".into(), latin1(), "out.png".into(), "--page".into(), "2".into()]), Some(PathBuf::from(latin1())));
|
||||
assert_eq!(ok(vec!["render".into(), "--page".into(), "2".into(), latin1(), "out.png".into()]), Some(PathBuf::from(latin1())));
|
||||
assert_eq!(ok(vec!["convert".into(), "--format".into(), "flatopc".into(), latin1(), "out.xml".into()]), Some(PathBuf::from(latin1())));
|
||||
assert_eq!(ok(vec!["info".into(), "--password".into(), "secret".into(), latin1()]), Some(PathBuf::from(latin1())));
|
||||
assert_eq!(ok(vec!["zotero".into(), "--port".into(), "23119".into(), "refresh".into(), latin1()]), Some(PathBuf::from(latin1())));
|
||||
assert_eq!(ok(vec!["run".into(), "--print".into(), "--file".into(), latin1()]), Some(PathBuf::from(latin1())));
|
||||
assert_eq!(ok(vec!["info".into(), "in.docx".into()]), None);
|
||||
// Every command that opens a file reaches the open with it (no such file here), so
|
||||
@@ -228,6 +232,7 @@ fn only_the_opened_file_may_be_other_than_unicode() {
|
||||
for (raw, n) in [
|
||||
(vec!["zotero".into(), latin1(), "in.docx".into()], 2),
|
||||
(vec!["render".into(), "in.docx".into(), latin1()], 3),
|
||||
(vec!["render".into(), "--page".into(), latin1(), "in.docx".into(), "out.png".into()], 3),
|
||||
(vec!["run".into(), "--save".into(), latin1()], 3),
|
||||
(vec!["commands".into(), latin1()], 2),
|
||||
] {
|
||||
|
||||
@@ -53,6 +53,15 @@ fn the_file_a_command_opens_may_have_any_name() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_option_before_the_input_keeps_its_native_name() {
|
||||
let (dir, file) = folder("option-first");
|
||||
let saved = dir.join("out.xml");
|
||||
ok(&cli(&["convert".into(), "--format".into(), "flatopc".into(), file, saved.clone().into()]));
|
||||
assert!(std::fs::read_to_string(saved).unwrap().contains("Bonjour"));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_other_such_argument_is_an_error_naming_it() {
|
||||
let (dir, file) = folder("error");
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! An AppImage's environment inherited from another program (#5653).
|
||||
//!
|
||||
//! The AppImage runtime gives the program it starts `APPIMAGE` (the image's file), `APPDIR` (where
|
||||
//! it is mounted), `ARGV0` and `OWD`, and whatever that program starts inherits them. So an AppImage
|
||||
//! app that opens a document through `xdg-open` starts an installed WordCraft with *its* `APPIMAGE`,
|
||||
//! and the self-update, which trusts that variable, replaces the other app's file with WordCraft.
|
||||
//!
|
||||
//! This copy is in the image only when its executable is under `APPDIR`. Otherwise the variables
|
||||
//! are another program's, and the process starts over without them, before anything reads them.
|
||||
//! (Removing them in place is `unsafe` under this edition, and `exec` keeps the process id, the
|
||||
//! arguments and the standard streams.)
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::io::Write;
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// What the AppImage runtime sets; the same list as the documented workaround on the issue.
|
||||
const RUNTIME_VARS: [&str; 4] = ["APPIMAGE", "APPDIR", "ARGV0", "OWD"];
|
||||
|
||||
/// Whether the executable runs from the image mounted at `appdir`. Anything unclear counts as not:
|
||||
/// the update then goes through the package or the plain file this copy really is, and at worst
|
||||
/// says it can't, where the other answer would overwrite a file that isn't WordCraft's.
|
||||
fn runs_from_image(exe: Option<&Path>, appdir: Option<&OsStr>) -> bool {
|
||||
let (Some(exe), Some(appdir)) = (exe, appdir) else { return false };
|
||||
let appdir = Path::new(appdir);
|
||||
if !appdir.is_absolute() {
|
||||
return false;
|
||||
}
|
||||
let Ok(appdir) = appdir.canonicalize() else { return false };
|
||||
// No image mounts at the root, where every program on the machine would count as inside it.
|
||||
appdir.parent().is_some() && exe.starts_with(&appdir)
|
||||
}
|
||||
|
||||
/// Starts this process over without another program's AppImage variables; returns only when there
|
||||
/// are none to drop (`None`) or starting over failed. Call it first, before any other thread starts.
|
||||
pub(crate) fn drop_inherited() -> Option<std::io::Error> {
|
||||
let appimage = std::env::var_os("APPIMAGE")?;
|
||||
let exe = std::env::current_exe().ok();
|
||||
if runs_from_image(exe.as_deref(), std::env::var_os("APPDIR").as_deref()) {
|
||||
return None;
|
||||
}
|
||||
// The logger isn't up yet, and wouldn't survive `exec`. Unlike `eprintln!`, never panics.
|
||||
let _ = writeln!(std::io::stderr(), "wordcraft: ignoring the AppImage {:?} inherited from another program", Path::new(&appimage));
|
||||
let mut args = std::env::args_os();
|
||||
// This file, whatever has happened to its path since it started.
|
||||
let mut again = Command::new("/proc/self/exe");
|
||||
if let Some(arg0) = args.next() {
|
||||
again.arg0(arg0);
|
||||
}
|
||||
again.args(args);
|
||||
for var in RUNTIME_VARS {
|
||||
again.env_remove(var);
|
||||
}
|
||||
Some(again.exec())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::runs_from_image;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A folder of its own for one test, removed when it ends.
|
||||
struct Tmp(PathBuf);
|
||||
|
||||
impl Tmp {
|
||||
/// An empty folder named after `test` and this process, made afresh.
|
||||
fn new(test: &str) -> Tmp {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-appimage-{test}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
Tmp(dir)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Tmp {
|
||||
/// Removes the folder and what the test left in it.
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A mount with the executable inside it, by real path (`/tmp` may be a link).
|
||||
fn mount(root: &Path, name: &str) -> (PathBuf, PathBuf) {
|
||||
let dir = root.join(name);
|
||||
std::fs::create_dir_all(dir.join("usr/bin")).unwrap();
|
||||
let dir = dir.canonicalize().unwrap();
|
||||
let exe = dir.join("usr/bin/wordcraft");
|
||||
(dir, exe)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_executable_in_the_mount_is_the_image() {
|
||||
let tmp = Tmp::new("own");
|
||||
let (dir, exe) = mount(&tmp.0, ".mount_WordCr");
|
||||
assert!(runs_from_image(Some(&exe), Some(dir.as_os_str())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_to_the_mount_is_the_same_mount() {
|
||||
let tmp = Tmp::new("link");
|
||||
let (dir, exe) = mount(&tmp.0, ".mount_WordCr");
|
||||
let link = tmp.0.join("link");
|
||||
std::os::unix::fs::symlink(&dir, &link).unwrap();
|
||||
assert!(runs_from_image(Some(&exe), Some(link.as_os_str())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_installed_executable_is_not_in_another_programs_mount() {
|
||||
let tmp = Tmp::new("other");
|
||||
let (other, _) = mount(&tmp.0, ".mount_other");
|
||||
assert!(!runs_from_image(Some(Path::new("/usr/bin/wordcraft")), Some(other.as_os_str())));
|
||||
}
|
||||
|
||||
/// Paths compare by component: one mount's name can start with another's.
|
||||
#[test]
|
||||
fn a_mount_whose_name_starts_the_same_is_another_mount() {
|
||||
let tmp = Tmp::new("prefix");
|
||||
let (other, _) = mount(&tmp.0, ".mount_ab");
|
||||
let (_, exe) = mount(&tmp.0, ".mount_abc");
|
||||
assert!(!runs_from_image(Some(&exe), Some(other.as_os_str())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anything_unclear_is_not_the_image() {
|
||||
let tmp = Tmp::new("unclear");
|
||||
let (dir, exe) = mount(&tmp.0, ".mount_WordCr");
|
||||
// No `APPDIR`, one that's gone (the other program has exited) or empty.
|
||||
assert!(!runs_from_image(Some(&exe), None));
|
||||
assert!(!runs_from_image(Some(&exe), Some(tmp.0.join("gone").as_os_str())));
|
||||
assert!(!runs_from_image(Some(&exe), Some("".as_ref())));
|
||||
// The root, and a relative path that happens to lead here.
|
||||
assert!(!runs_from_image(Some(&exe), Some("/".as_ref())));
|
||||
assert!(!runs_from_image(Some(&exe), Some(".".as_ref())));
|
||||
// No executable path at all.
|
||||
assert!(!runs_from_image(None, Some(dir.as_os_str())));
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod animations;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod appimage_env;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod apple_events;
|
||||
mod clipboard;
|
||||
@@ -421,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);
|
||||
@@ -449,9 +452,22 @@ fn main() -> eframe::Result {
|
||||
if let Some(result) = craft_update::self_update::finish(&wordcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
std::process::exit(i32::from(result.is_err()));
|
||||
}
|
||||
// Linux: before anything reads another program's AppImage variables, or a thread starts (#5653).
|
||||
#[cfg(target_os = "linux")]
|
||||
let start_over_failed = appimage_env::drop_inherited();
|
||||
// Still carrying them, this process must never update itself: the update would replace the
|
||||
// other program's file.
|
||||
#[cfg(target_os = "linux")]
|
||||
let updates_unsafe = start_over_failed.is_some();
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let updates_unsafe = false;
|
||||
// First, so the panic hook and every start-up warning are recorded (`logging`).
|
||||
let logger = logging::install();
|
||||
logging::install_panic_hook();
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(e) = start_over_failed {
|
||||
log::error!("couldn't start again without another program's AppImage variables, so the self-update is off in this window: {e}");
|
||||
}
|
||||
let mut control_port: Option<u16> = std::env::var("WORDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut files = Vec::new();
|
||||
let mut sample = false;
|
||||
@@ -566,6 +582,10 @@ fn main() -> eframe::Result {
|
||||
let mut app = WordApp::new(session, services(file_dialog, &cc.egui_ctx));
|
||||
app.set_startup_update_check(true);
|
||||
load_prefs(&mut app);
|
||||
// After the preferences, so the reason it gives is in the interface language.
|
||||
if updates_unsafe {
|
||||
app.disable_self_update();
|
||||
}
|
||||
load_user_dictionary(&mut app);
|
||||
// Language packs from the suite's font store; an Install click is the person's own.
|
||||
wordcraft_engine::packs::use_font_store(true);
|
||||
@@ -605,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//! A WordCraft started by an AppImage app inherits that app's `APPIMAGE`, which the self-update
|
||||
//! would take for this copy's file and replace (#5653). The real program, started both ways.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const NOTICE: &str = "ignoring the AppImage";
|
||||
|
||||
/// A folder of its own for one test, removed when it ends.
|
||||
struct Tmp(PathBuf);
|
||||
|
||||
impl Tmp {
|
||||
/// An empty folder named after `test` and this process, made afresh.
|
||||
fn new(test: &str) -> Tmp {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-appimage-it-{test}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
Tmp(dir)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Tmp {
|
||||
/// Removes the folder and what the test left in it.
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the real app with `args`, as an AppImage app with `appimage` mounted at `appdir` would
|
||||
/// start it, and waits up to 30 s for it to exit (with no display it can't open a window).
|
||||
fn app(args: &[OsString], appimage: &Path, appdir: &Path) -> Output {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_wordcraft"))
|
||||
.args(args)
|
||||
.env("APPIMAGE", appimage)
|
||||
.env("APPDIR", appdir)
|
||||
.env("ARGV0", "other-app")
|
||||
.env("OWD", "/")
|
||||
// With no display, an app that tries to open a window fails at once instead.
|
||||
.env_remove("DISPLAY")
|
||||
.env_remove("WAYLAND_DISPLAY")
|
||||
.env_remove("WAYLAND_SOCKET")
|
||||
.env_remove("WORDCRAFT_CONTROL_PORT")
|
||||
.env("WORDCRAFT_NO_PREFS", "1")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
// A start-over that kept the variables would start over for ever, and end here.
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
while child.try_wait().unwrap().is_none() {
|
||||
if Instant::now() > deadline {
|
||||
let _ = child.kill();
|
||||
panic!("wordcraft {args:?} was still running after 30 s");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
child.wait_with_output().unwrap()
|
||||
}
|
||||
|
||||
/// The notice comes once: the program it started over as no longer sees the variables.
|
||||
#[test]
|
||||
fn another_programs_variables_are_dropped_once_and_the_program_carries_on() {
|
||||
let tmp = Tmp::new("dropped");
|
||||
let other = tmp.0.join("other-app.AppImage");
|
||||
std::fs::write(&other, b"another program").unwrap();
|
||||
let out = app(&["--version".into()], &other, &tmp.0);
|
||||
let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
|
||||
assert!(out.status.success(), "status {:?}, stderr: {stderr}", out.status);
|
||||
assert!(stdout.starts_with("wordcraft "), "stdout: {stdout}");
|
||||
assert_eq!(stderr.matches(NOTICE).count(), 1, "stderr: {stderr}");
|
||||
assert!(stderr.contains("other-app.AppImage"), "stderr: {stderr}");
|
||||
assert_eq!(std::fs::read(&other).unwrap(), b"another program");
|
||||
}
|
||||
|
||||
/// Arguments reach the program it starts over as byte for byte, even one that isn't Unicode.
|
||||
#[test]
|
||||
fn starting_over_keeps_the_arguments() {
|
||||
let tmp = Tmp::new("args");
|
||||
let out = app(&["--sample".into(), "--control".into(), OsString::from_vec(b"79\xe981".to_vec())], &tmp.0.join("gone"), &tmp.0);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(2), "stderr: {stderr}");
|
||||
assert!(stderr.contains("argument 3 is not valid Unicode: 79\u{FFFD}81"), "stderr: {stderr}");
|
||||
}
|
||||
|
||||
/// Its own image's variables stay, or the self-update couldn't find the file to replace.
|
||||
#[test]
|
||||
fn its_own_images_variables_stay() {
|
||||
let exe = Path::new(env!("CARGO_BIN_EXE_wordcraft")).canonicalize().unwrap();
|
||||
let mount = exe.parent().unwrap();
|
||||
let out = app(&["--version".into()], &mount.join("WordCraft.AppImage"), mount);
|
||||
let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
|
||||
assert!(out.status.success(), "status {:?}, stderr: {stderr}", out.status);
|
||||
assert!(stdout.starts_with("wordcraft "), "stdout: {stdout}");
|
||||
assert!(!stderr.contains(NOTICE), "stderr: {stderr}");
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
#[cfg(target_os = "linux")]
|
||||
mod appimage_env;
|
||||
mod control_arg;
|
||||
mod help;
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -478,8 +478,9 @@ fn family_fingerprint(doc: &crate::Document, root: u32, replies: &HashMap<u32, V
|
||||
}
|
||||
|
||||
/// What `doc`'s page setup is: each section's setup (where it starts, its size, margins,
|
||||
/// columns, headers and footers), the headers' and footers' content, the page colour and
|
||||
/// picture, and the notes' numbering.
|
||||
/// columns, headers and footers), the headers' and footers' content, whether odd and even pages
|
||||
/// differ, the page colour and picture, the watermark (one with no text isn't written), and the
|
||||
/// notes' numbering.
|
||||
pub fn pages_fingerprint(doc: &crate::Document) -> u64 {
|
||||
let mut sections: Vec<&crate::SectionProps> = doc.body.iter().filter_map(|b| b.as_para()).filter_map(|p| p.section.as_deref()).collect();
|
||||
sections.push(&doc.last_section);
|
||||
@@ -490,8 +491,10 @@ pub fn pages_fingerprint(doc: &crate::Document) -> u64 {
|
||||
match serde_json::to_vec(&(
|
||||
sections,
|
||||
parts,
|
||||
s.even_odd_headers,
|
||||
&s.page_color,
|
||||
&s.page_picture,
|
||||
s.watermark.as_ref().filter(|w| !w.text.trim().is_empty()),
|
||||
(&s.footnote_format, &s.endnote_format, &s.footnote_pr, &s.endnote_pr),
|
||||
(&doc.footnote_separators, &doc.endnote_separators),
|
||||
)) {
|
||||
|
||||
@@ -125,6 +125,7 @@ mod tests {
|
||||
("HYPERLINK\t\"http://example.com\"", Some("http://example.com")),
|
||||
(r#"HYPERLINK"http://example.com""#, Some("http://example.com")),
|
||||
(r#"HYPERLINK "http://example.com/path\latest""#, Some("http://example.com/pathlatest")),
|
||||
(r#"HYPERLINK "x \* Upper""#, Some("x * Upper")),
|
||||
(r#"HYPERLINK "C:\\Docs\\My file.docx""#, Some(r"C:\Docs\My file.docx")),
|
||||
(r#"HYPERLINK "say \"hi\"""#, Some(r#"say "hi""#)),
|
||||
(r#"HYPERLINK " http://example.com ""#, Some("http://example.com")),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -289,13 +289,14 @@ impl NumFormat {
|
||||
pub fn from_ooxml(s: &str) -> NumFormat {
|
||||
NumFormat::ALL.into_iter().find(|f| f.ooxml() == s).unwrap_or(NumFormat::Decimal)
|
||||
}
|
||||
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Hindi, Thai,
|
||||
/// Vietnamese, text amounts) show decimal, as do values past a sequence's end
|
||||
/// (the 21st item of ①…⑳).
|
||||
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Hindi, Thai letters
|
||||
/// and counting, Vietnamese, text amounts) show decimal, as do values past a sequence's end
|
||||
/// (the 21st item of ①…⑳). Thai numbers are the decimal number in Thai digits (๑, ๒ … ๑๐).
|
||||
pub fn format(self, n: u32) -> String {
|
||||
use crate::cjk_numbers as cjk;
|
||||
match self {
|
||||
NumFormat::DecimalZero => format!("{n:02}"),
|
||||
NumFormat::ThaiNumbers => thai_digits(n),
|
||||
NumFormat::UpperRoman => roman(n).to_uppercase(),
|
||||
NumFormat::LowerRoman => roman(n),
|
||||
NumFormat::UpperLetter => letters(n).to_uppercase(),
|
||||
@@ -349,7 +350,6 @@ impl NumFormat {
|
||||
| NumFormat::HindiNumbers
|
||||
| NumFormat::HindiCounting
|
||||
| NumFormat::ThaiLetters
|
||||
| NumFormat::ThaiNumbers
|
||||
| NumFormat::ThaiCounting
|
||||
| NumFormat::BahtText
|
||||
| NumFormat::DollarText => n.to_string(),
|
||||
@@ -357,6 +357,11 @@ impl NumFormat {
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` in Thai digits, ๐ (U+0E50) to ๙ (#5757).
|
||||
fn thai_digits(n: u32) -> String {
|
||||
n.to_string().chars().filter_map(|c| c.to_digit(10).and_then(|d| char::from_u32(0x0E50 + d))).collect()
|
||||
}
|
||||
|
||||
/// Chicago Manual of Style note marks: *, †, ‡, §, then doubled (**, ††, …) and so on.
|
||||
fn chicago(n: u32) -> String {
|
||||
const MARKS: [char; 4] = ['*', '\u{2020}', '\u{2021}', '\u{a7}'];
|
||||
@@ -999,6 +1004,18 @@ mod tests {
|
||||
assert_eq!(NumFormat::from_ooxml("klingon"), NumFormat::Decimal, "unknown formats are decimal");
|
||||
}
|
||||
|
||||
/// #5757: Thai numbers are the decimal number in Thai digits, every digit, at any length.
|
||||
#[test]
|
||||
fn thai_numbers_use_thai_digits() {
|
||||
let labels: Vec<String> = [1, 2, 3, 9, 10, 11, 20, 105, 2569].iter().map(|n| NumFormat::ThaiNumbers.format(*n)).collect();
|
||||
assert_eq!(labels.join(" "), "๑ ๒ ๓ ๙ ๑๐ ๑๑ ๒๐ ๑๐๕ ๒๕๖๙");
|
||||
assert_eq!(NumFormat::ThaiNumbers.format(0), "๐");
|
||||
assert_eq!(NumFormat::ThaiNumbers.format(u32::MAX), "๔๒๙๔๙๖๗๒๙๕");
|
||||
assert_eq!(NumFormat::from_ooxml("thaiNumbers"), NumFormat::ThaiNumbers);
|
||||
// Formats not drawn yet still show decimal.
|
||||
assert_eq!(NumFormat::ThaiLetters.format(3), "3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_letters_follow_word() {
|
||||
// Word's own labels for a russianLower list: no ё, й, ъ or ь, then doubled letters.
|
||||
|
||||
+52
-13
@@ -7,9 +7,26 @@ use crate::DocbinError;
|
||||
/// Largest bin table (entries) we accept.
|
||||
const MAX_BINS: usize = 100_000;
|
||||
|
||||
/// A Chpx grpprl and the FCs `[lo, hi)` for which it is the one [`Bins::chpx`] returns.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct ChpxRun<'w> {
|
||||
pub(crate) grpprl: &'w [u8],
|
||||
lo: u32,
|
||||
hi: u32,
|
||||
}
|
||||
|
||||
impl ChpxRun<'_> {
|
||||
pub(crate) fn contains(&self, fc: u32) -> bool {
|
||||
self.lo <= fc && fc < self.hi
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed `PlcBte*`: FC breakpoints and the FKP page each range lives in.
|
||||
pub(crate) struct Bins {
|
||||
fcs: Vec<u32>,
|
||||
/// `fcs` ascends, so the bin of an FC holds every FC up to the next breakpoint (a corrupt
|
||||
/// table that does not gets no cached ranges, [`ChpxRun`]).
|
||||
sorted: bool,
|
||||
/// Page number (× 512 = byte offset in the WordDocument stream).
|
||||
pns: Vec<u32>,
|
||||
}
|
||||
@@ -20,7 +37,7 @@ impl Bins {
|
||||
let fc = fc as usize;
|
||||
let lcb = lcb as usize;
|
||||
if lcb < 4 || !(lcb - 4).is_multiple_of(8) {
|
||||
return Ok(Bins { fcs: Vec::new(), pns: Vec::new() });
|
||||
return Ok(Bins { fcs: Vec::new(), sorted: true, pns: Vec::new() });
|
||||
}
|
||||
let end = fc.checked_add(lcb).ok_or_else(|| DocbinError::Malformed("bin table overflow".into()))?;
|
||||
let plc = table.get(fc..end).ok_or_else(|| DocbinError::Malformed("bin table outside the Table stream".into()))?;
|
||||
@@ -38,36 +55,58 @@ impl Bins {
|
||||
// PnBte* keeps the page number in its low bits; the rest must be zero.
|
||||
pns.push(at(n + 1 + i).ok_or_else(|| DocbinError::Malformed("truncated bin aPn".into()))? & 0x003F_FFFF);
|
||||
}
|
||||
Ok(Bins { fcs, pns })
|
||||
let sorted = fcs.windows(2).all(|w| w.first() <= w.get(1));
|
||||
Ok(Bins { fcs, sorted, pns })
|
||||
}
|
||||
|
||||
/// The page containing `fc`, as a slice of the WordDocument stream.
|
||||
fn page<'w>(&self, word: &'w [u8], fc: u32) -> Option<&'w [u8]> {
|
||||
/// The page containing `fc`, as a slice of the WordDocument stream, and the FC range
|
||||
/// `[lo, hi)` of its bin (`hi` is `u32::MAX` for the last; an empty range when the table
|
||||
/// is not ascending, so nothing is cached from it).
|
||||
fn page<'w>(&self, word: &'w [u8], fc: u32) -> Option<(&'w [u8], u32, u32)> {
|
||||
let i = self.fcs.partition_point(|&f| f <= fc).checked_sub(1)?;
|
||||
let pn = *self.pns.get(i)?;
|
||||
let at = pn.checked_mul(512)? as usize;
|
||||
word.get(at..at.checked_add(512)?)
|
||||
let page = word.get(at..at.checked_add(512)?)?;
|
||||
if !self.sorted {
|
||||
return Some((page, 1, 0));
|
||||
}
|
||||
let lo = self.fcs.get(i).copied().unwrap_or(0);
|
||||
let hi = self.fcs.get(i + 1).copied().unwrap_or(u32::MAX);
|
||||
Some((page, lo, hi))
|
||||
}
|
||||
|
||||
/// The grpprl of the Chpx covering `fc` ([MS-DOC] ChpxFkp: rgfc u32s, rgb offsets ×2,
|
||||
/// crun in the last byte).
|
||||
pub(crate) fn chpx<'w>(&self, word: &'w [u8], fc: u32) -> &'w [u8] {
|
||||
let Some(page) = self.page(word, fc) else { return &[] };
|
||||
let Some(&crun) = page.get(511) else { return &[] };
|
||||
self.chpx_run(word, fc).grpprl
|
||||
}
|
||||
|
||||
/// [`Bins::chpx`] with the FC range over which the same grpprl is the answer, so a walk
|
||||
/// over consecutive characters looks it up once per run, not once per character.
|
||||
pub(crate) fn chpx_run<'w>(&self, word: &'w [u8], fc: u32) -> ChpxRun<'w> {
|
||||
let none = ChpxRun { grpprl: &[], lo: 1, hi: 0 };
|
||||
let Some((page, page_lo, page_hi)) = self.page(word, fc) else { return none };
|
||||
let Some(&crun) = page.get(511) else { return none };
|
||||
let crun = crun.min(0x65) as usize;
|
||||
let rgfc = |i: usize| -> Option<u32> { page.get(i * 4..i * 4 + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) };
|
||||
// rgb array sits right after rgfc (crun+1 u32s); each byte is a half-offset into the
|
||||
// region between rgb's end and crun.
|
||||
let rgb_at = (crun + 1) * 4;
|
||||
// The last run starting at or before `fc`. It stays the answer for every FC from its own
|
||||
// start up to the smallest start after it (also in a page whose starts do not ascend).
|
||||
let j = (0..crun).rev().find(|&j| rgfc(j).is_some_and(|f| f <= fc));
|
||||
let Some(j) = j else { return &[] };
|
||||
let Some(&off) = page.get(rgb_at + j) else { return &[] };
|
||||
let first_after = j.map_or(0, |j| j + 1);
|
||||
let hi = (first_after..crun).filter_map(rgfc).min().map_or(page_hi, |m| m.min(page_hi));
|
||||
let lo = j.and_then(rgfc).map_or(page_lo, |f| f.max(page_lo));
|
||||
let run = |grpprl: &'w [u8]| ChpxRun { grpprl, lo, hi };
|
||||
let Some(j) = j else { return run(&[]) };
|
||||
let Some(&off) = page.get(rgb_at + j) else { return run(&[]) };
|
||||
if off == 0 {
|
||||
return &[];
|
||||
return run(&[]);
|
||||
}
|
||||
let at = off as usize * 2;
|
||||
let Some(&cb) = page.get(at) else { return &[] };
|
||||
page.get(at + 1..at + 1 + cb as usize).unwrap_or(&[])
|
||||
let Some(&cb) = page.get(at) else { return run(&[]) };
|
||||
run(page.get(at + 1..at + 1 + cb as usize).unwrap_or(&[]))
|
||||
}
|
||||
|
||||
/// The (istd, grpprl) of the Papx of the paragraph whose mark is just before `fc`
|
||||
@@ -75,7 +114,7 @@ impl Bins {
|
||||
/// entries (1 offset byte + 12 PHE bytes), crun in the last byte; the PAPX itself is a
|
||||
/// PapxInFkp with the cb/cb' size quirk). Query with the FC of the character after the mark.
|
||||
pub(crate) fn papx<'w>(&self, word: &'w [u8], fc: u32) -> (u16, &'w [u8]) {
|
||||
let Some(page) = self.page(word, fc) else { return (0, &[]) };
|
||||
let Some((page, _, _)) = self.page(word, fc) else { return (0, &[]) };
|
||||
let Some(&crun) = page.get(511) else { return (0, &[]) };
|
||||
// BX is 13 bytes; cap crun at what fits before byte 511.
|
||||
let max_run = (511usize.saturating_sub((crun as usize + 1) * 4)) / 13;
|
||||
|
||||
+81
-42
@@ -502,29 +502,37 @@ struct ParaBuild {
|
||||
runs: Vec<Run>,
|
||||
props: wordcraft_doc::props::ParaProps,
|
||||
mark: CharProps,
|
||||
/// The current run's properties, so runs only break when formatting changes.
|
||||
cur: Option<CharProps>,
|
||||
/// Which resolved formatting ([`FmtState::generation`]) the last character came from, while
|
||||
/// the last run is still the one it extended: the same generation means equal properties.
|
||||
run_gen: Option<u64>,
|
||||
/// Inline objects, each anchored at a U+FFFC pushed into `text`.
|
||||
objects: Vec<InlineObject>,
|
||||
}
|
||||
|
||||
impl ParaBuild {
|
||||
fn push(&mut self, c: char, props: CharProps) {
|
||||
self.run_gen = None;
|
||||
let len = c.len_utf8();
|
||||
match &self.cur {
|
||||
Some(p) if *p == props => {
|
||||
if let Some(r) = self.runs.last_mut() {
|
||||
r.len += len;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.runs.push(Run { len, props: props.clone() });
|
||||
self.cur = Some(props);
|
||||
}
|
||||
match self.runs.last_mut() {
|
||||
Some(r) if r.props == props => r.len += len,
|
||||
_ => self.runs.push(Run { len, props }),
|
||||
}
|
||||
self.text.push(c);
|
||||
}
|
||||
|
||||
/// [`ParaBuild::push`] for properties that are resolved once and shared by many characters:
|
||||
/// they are cloned only when a run starts, and not even compared while `generation` stays
|
||||
/// the same.
|
||||
fn push_shared(&mut self, c: char, props: &CharProps, generation: u64) {
|
||||
let len = c.len_utf8();
|
||||
match self.runs.last_mut() {
|
||||
Some(r) if self.run_gen == Some(generation) || r.props == *props => r.len += len,
|
||||
_ => self.runs.push(Run { len, props: props.clone() }),
|
||||
}
|
||||
self.run_gen = Some(generation);
|
||||
self.text.push(c);
|
||||
}
|
||||
|
||||
/// Append `c` to the current run, whose properties the caller knows it matches.
|
||||
fn extend(&mut self, c: char) {
|
||||
if let Some(r) = self.runs.last_mut() {
|
||||
@@ -552,7 +560,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
|
||||
// Field assembly state: instruction characters are swallowed, the result is kept until
|
||||
// the end character, then emitted as one object (hyperlinks keep their result text).
|
||||
let mut field: Option<FieldBuild> = None;
|
||||
let mut memo: Option<FmtMemo<'a>> = None;
|
||||
let mut fmt = FmtState::default();
|
||||
while cp < cp_end {
|
||||
// Bookmark boundaries at this CP (main-document walks only; subdocument walks get
|
||||
// an empty table and `cp` never matches the main-document CPs).
|
||||
@@ -715,16 +723,16 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
|
||||
pb.objects.push(InlineObject::NoteRef { kind: *kind, id: *id, custom: String::new() });
|
||||
}
|
||||
}
|
||||
'\t' => push_formatted(&mut pb, cp, '\t', ctx, field.as_mut(), &mut memo),
|
||||
'\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, field.as_mut(), &mut memo),
|
||||
'\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, field.as_mut(), &mut memo),
|
||||
'\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, field.as_mut(), &mut memo),
|
||||
'\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, field.as_mut(), &mut memo),
|
||||
'\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, field.as_mut(), &mut memo),
|
||||
'\t' => push_formatted(&mut pb, cp, '\t', ctx, field.as_mut(), &mut fmt),
|
||||
'\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, field.as_mut(), &mut fmt),
|
||||
'\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, field.as_mut(), &mut fmt),
|
||||
'\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, field.as_mut(), &mut fmt),
|
||||
'\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, field.as_mut(), &mut fmt),
|
||||
'\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, field.as_mut(), &mut fmt),
|
||||
// Object anchors, comment references and other special characters are dropped
|
||||
// (or handled by later passes).
|
||||
c if (c as u32) < 0x20 => {}
|
||||
c => push_formatted(&mut pb, cp, c, ctx, field.as_mut(), &mut memo),
|
||||
c => push_formatted(&mut pb, cp, c, ctx, field.as_mut(), &mut fmt),
|
||||
}
|
||||
cp = cp.saturating_add(units);
|
||||
}
|
||||
@@ -854,34 +862,53 @@ fn num_of(ilfo: i32) -> Option<u32> {
|
||||
/// plus the piece's Prm, over the paragraph style's base character props. Inside a field,
|
||||
/// the character is recorded into the instruction or the cached result instead of (or as
|
||||
/// well as) the visible text.
|
||||
fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, field: Option<&mut FieldBuild>, memo: &mut Option<FmtMemo<'a>>) {
|
||||
let fc = ctx.pieces.fc_of_cp(cp).unwrap_or(0);
|
||||
let grpprl = ctx.chpx_bins.chpx(ctx.word, fc);
|
||||
let prm = ctx.pieces.prm_of(cp);
|
||||
let style = pb.props.style.as_deref();
|
||||
let mut props = match memo.as_ref().filter(|m| std::ptr::eq(m.chpx, grpprl) && m.prm == prm && m.style.as_deref() == style) {
|
||||
Some(m) => m.props.clone(),
|
||||
fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, field: Option<&mut FieldBuild>, st: &mut FmtState<'a>) {
|
||||
// The piece and the CHPX run of the previous character usually cover this one too.
|
||||
let span = match st.span.filter(|s| s.contains(cp)) {
|
||||
Some(s) => Some(s),
|
||||
None => {
|
||||
let default;
|
||||
let base = match style.and_then(|id| ctx.sheet.get(id)) {
|
||||
Some(st) => &st.chr,
|
||||
None => {
|
||||
default = CharProps::default();
|
||||
&default
|
||||
}
|
||||
};
|
||||
let mut props = char_props(grpprl, ctx.fonts, base);
|
||||
apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base, ctx.work);
|
||||
*memo = Some(FmtMemo { chpx: grpprl, prm, style: style.map(str::to_owned), props: props.clone() });
|
||||
props
|
||||
st.span = ctx.pieces.span(cp);
|
||||
st.span
|
||||
}
|
||||
};
|
||||
let fc = span.and_then(|s| s.fc_at(cp)).unwrap_or(0);
|
||||
let run = match st.chpx.filter(|r| r.contains(fc)) {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
let r = ctx.chpx_bins.chpx_run(ctx.word, fc);
|
||||
st.chpx = Some(r);
|
||||
r
|
||||
}
|
||||
};
|
||||
let grpprl = run.grpprl;
|
||||
let prm = span.map_or(0, |s| s.prm());
|
||||
let style = pb.props.style.as_deref();
|
||||
let hit = st.memo.as_ref().is_some_and(|m| std::ptr::eq(m.chpx, grpprl) && m.prm == prm && m.style.as_deref() == style);
|
||||
if !hit {
|
||||
let default;
|
||||
let base = match style.and_then(|id| ctx.sheet.get(id)) {
|
||||
Some(sty) => &sty.chr,
|
||||
None => {
|
||||
default = CharProps::default();
|
||||
&default
|
||||
}
|
||||
};
|
||||
let mut props = char_props(grpprl, ctx.fonts, base);
|
||||
apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base, ctx.work);
|
||||
st.memo = Some(FmtMemo { chpx: grpprl, prm, style: style.map(str::to_owned), props });
|
||||
st.generation += 1;
|
||||
}
|
||||
let Some(m) = st.memo.as_ref() else { return };
|
||||
// A symbol character (Insert › Symbol) is stored as the placeholder U+0028 with
|
||||
// sprmCSymbol carrying the font and the character code ([MS-DOC] §2.6.1).
|
||||
let symbol = if c == '(' { symbol_operand(grpprl) } else { None };
|
||||
if field.is_none() && symbol.is_none() {
|
||||
pb.push_shared(c, &m.props, st.generation);
|
||||
return;
|
||||
}
|
||||
let mut props = m.props.clone();
|
||||
let mut c = c;
|
||||
if c == '('
|
||||
&& let Some((ftc, xchar)) = symbol_operand(grpprl)
|
||||
{
|
||||
if let Some((ftc, xchar)) = symbol {
|
||||
if let Some(font) = ctx.fonts.get(usize::from(ftc)) {
|
||||
props.font = Some(font.clone());
|
||||
}
|
||||
@@ -900,6 +927,18 @@ fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, f
|
||||
}
|
||||
}
|
||||
|
||||
/// What the walk carries from one character to the next while it resolves formatting: the piece
|
||||
/// and the CHPX run the last character was in (so most characters need no search), and the last
|
||||
/// formatting resolved.
|
||||
#[derive(Default)]
|
||||
struct FmtState<'a> {
|
||||
span: Option<piece::PieceSpan<'a>>,
|
||||
chpx: Option<fkp::ChpxRun<'a>>,
|
||||
memo: Option<FmtMemo<'a>>,
|
||||
/// Counts the memo's replacements, so equal values mean the same properties.
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
/// The last character formatting resolved, reused while the CHPX, the piece's Prm and the
|
||||
/// paragraph style stay the same, so a Prm grpprl (up to 64 KB) is interpreted once per
|
||||
/// formatting change, not once per character (#3692).
|
||||
|
||||
@@ -117,12 +117,6 @@ impl PieceTable {
|
||||
}
|
||||
}
|
||||
|
||||
/// The raw Prm of the piece holding `cp` (0, no properties, when there is none): two CPs
|
||||
/// with the same value get the same [`PrmRef`].
|
||||
pub(crate) fn prm_of(&self, cp: u32) -> u16 {
|
||||
self.piece_of(cp).map_or(0, |p| p.prm)
|
||||
}
|
||||
|
||||
fn piece_of(&self, cp: u32) -> Option<&Pcd> {
|
||||
let i = self.cps.partition_point(|&c| c <= cp).checked_sub(1)?;
|
||||
if i >= self.pieces.len() {
|
||||
@@ -143,6 +137,17 @@ impl PieceTable {
|
||||
if p.compressed { p.fc.checked_add(step)?.checked_div(2) } else { p.fc.checked_add(step) }
|
||||
}
|
||||
|
||||
/// The piece holding `cp`, with the CP range it covers: for every CP in that range the
|
||||
/// answers of [`PieceTable::fc_of_cp`] and the piece's Prm come from this span alone,
|
||||
/// so a sequential walk searches the piece table once per piece.
|
||||
pub(crate) fn span(&self, cp: u32) -> Option<PieceSpan<'_>> {
|
||||
let i = self.cps.partition_point(|&c| c <= cp).checked_sub(1)?;
|
||||
let pcd = self.pieces.get(i)?;
|
||||
let cp_lo = *self.cps.get(i)?;
|
||||
let cp_hi = *self.cps.get(i + 1)?;
|
||||
Some(PieceSpan { cp_lo, cp_hi, pcd })
|
||||
}
|
||||
|
||||
/// The CP just past the last piece: no text exists at or beyond it.
|
||||
pub(crate) fn cp_end(&self) -> u32 {
|
||||
self.cps.last().copied().unwrap_or(0)
|
||||
@@ -187,6 +192,31 @@ impl PieceTable {
|
||||
}
|
||||
}
|
||||
|
||||
/// One piece and the CPs `[cp_lo, cp_hi)` it covers ([`PieceTable::span`]).
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct PieceSpan<'a> {
|
||||
cp_lo: u32,
|
||||
cp_hi: u32,
|
||||
pcd: &'a Pcd,
|
||||
}
|
||||
|
||||
impl PieceSpan<'_> {
|
||||
pub(crate) fn contains(&self, cp: u32) -> bool {
|
||||
self.cp_lo <= cp && cp < self.cp_hi
|
||||
}
|
||||
|
||||
/// [`PieceTable::fc_of_cp`] for a CP inside the span.
|
||||
pub(crate) fn fc_at(&self, cp: u32) -> Option<u32> {
|
||||
let step = cp.checked_sub(self.cp_lo)?.checked_mul(2)?;
|
||||
if self.pcd.compressed { self.pcd.fc.checked_add(step)?.checked_div(2) } else { self.pcd.fc.checked_add(step) }
|
||||
}
|
||||
|
||||
/// The raw Prm of the piece: two CPs with the same value get the same [`PrmRef`].
|
||||
pub(crate) fn prm(&self) -> u16 {
|
||||
self.pcd.prm
|
||||
}
|
||||
}
|
||||
|
||||
/// What a piece's `Prm` refers to.
|
||||
pub(crate) enum PrmRef<'a> {
|
||||
None,
|
||||
|
||||
@@ -2880,3 +2880,153 @@ fn field_results_keep_their_text_and_runs() {
|
||||
p.objects
|
||||
);
|
||||
}
|
||||
|
||||
/// A one-page CHPX bin table over `page`: FCs 0..`end` live in page 1 of the returned stream.
|
||||
fn one_page_bins(page: &[u8], end: u32) -> (Vec<u8>, crate::fkp::Bins) {
|
||||
let mut word = vec![0u8; 512];
|
||||
word.extend_from_slice(page);
|
||||
let plc = [0u32.to_le_bytes().as_slice(), end.to_le_bytes().as_slice(), 1u32.to_le_bytes().as_slice()].concat();
|
||||
let bins = crate::fkp::Bins::parse(&plc, 0, 12).expect("bins");
|
||||
(word, bins)
|
||||
}
|
||||
|
||||
/// The CHPX run a lookup reports covers exactly the FCs that give the same grpprl: the walk
|
||||
/// reuses it for the following characters without asking again.
|
||||
#[test]
|
||||
fn a_chpx_run_covers_the_fcs_with_the_same_answer() {
|
||||
let runs = [(0u32, grpprl(&[(0x0835, &[1])])), (5, Vec::new()), (9, grpprl(&[(0x0836, &[1])])), (30, grpprl(&[(0x0835, &[1])]))];
|
||||
let (word, bins) = one_page_bins(&chpx_fkp(&runs, 60), 0x2000);
|
||||
for fc in (fc_of(0) - 4)..(fc_of(70)) {
|
||||
let run = bins.chpx_run(&word, fc);
|
||||
assert_eq!(run.grpprl, bins.chpx(&word, fc), "fc {fc}");
|
||||
for other in (fc_of(0) - 4)..(fc_of(70)) {
|
||||
if run.contains(other) {
|
||||
assert_eq!(bins.chpx(&word, other), run.grpprl, "fc {fc} run reused at {other}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(bins.chpx_run(&word, fc_of(6)).contains(fc_of(8)) && !bins.chpx_run(&word, fc_of(6)).contains(fc_of(9)));
|
||||
}
|
||||
|
||||
/// A page whose run starts do not ascend (a corrupt file) gives the same answers as before and
|
||||
/// a range that never claims an FC with a different answer.
|
||||
#[test]
|
||||
fn a_chpx_run_is_sound_on_a_page_whose_starts_do_not_ascend() {
|
||||
let runs = [(10u32, grpprl(&[(0x0835, &[1])])), (3, grpprl(&[(0x0836, &[1])])), (20, Vec::new()), (7, grpprl(&[(0x0835, &[1])]))];
|
||||
let (word, bins) = one_page_bins(&chpx_fkp(&runs, 60), 0x2000);
|
||||
for fc in (fc_of(0) - 4)..(fc_of(70)) {
|
||||
let run = bins.chpx_run(&word, fc);
|
||||
assert_eq!(run.grpprl, bins.chpx(&word, fc));
|
||||
for other in (fc_of(0) - 4)..(fc_of(70)) {
|
||||
if run.contains(other) {
|
||||
assert_eq!(bins.chpx(&word, other), run.grpprl, "fc {fc} run reused at {other}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A piece span answers exactly what the per-CP lookup answers, in every piece (compressed
|
||||
/// and UTF-16) and past the last.
|
||||
#[test]
|
||||
fn a_piece_span_matches_the_per_cp_lookups() {
|
||||
let doc = text_doc(
|
||||
&[
|
||||
Piece { compressed: true, text: "abc".into() },
|
||||
Piece { compressed: false, text: "d\u{e9}fgh".into() },
|
||||
Piece { compressed: true, text: "ij".into() },
|
||||
],
|
||||
&[],
|
||||
0,
|
||||
);
|
||||
let mut comp = crate::open_container(&doc).expect("container");
|
||||
let table = crate::stream(&mut comp, "0Table").expect("table");
|
||||
let pieces = crate::piece::PieceTable::parse(&table, CLX_AT, table.len() as u32 - CLX_AT).expect("pieces");
|
||||
for cp in 0..14 {
|
||||
let span = pieces.span(cp);
|
||||
assert_eq!(span.and_then(|s| s.fc_at(cp)), pieces.fc_of_cp(cp), "cp {cp}");
|
||||
for other in 0..14 {
|
||||
if span.is_some_and(|s| s.contains(other)) {
|
||||
assert_eq!(pieces.span(other).and_then(|s| s.fc_at(other)), pieces.fc_of_cp(other));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(pieces.span(10).is_none(), "past the last piece");
|
||||
}
|
||||
|
||||
/// Characters of one formatting share one `CharProps`: it is cloned when a run starts, and not
|
||||
/// even compared while the formatting stays the same.
|
||||
#[test]
|
||||
fn shared_props_start_one_run_per_formatting() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
let bold = CharProps { bold: Some(true), ..Default::default() };
|
||||
let mut pb = super::ParaBuild::default();
|
||||
for _ in 0..100_000 {
|
||||
pb.push_shared('a', &bold, 1);
|
||||
}
|
||||
pb.push_shared('b', &CharProps::default(), 2);
|
||||
pb.push_shared('c', &bold, 3);
|
||||
pb.push_shared('d', &bold, 4);
|
||||
assert_eq!(pb.runs.iter().map(|r| r.len).collect::<Vec<_>>(), [100_000, 1, 2], "equal props under a new generation join the run");
|
||||
pb.push('e', bold.clone());
|
||||
pb.push_shared('f', &bold, 4);
|
||||
assert_eq!(
|
||||
pb.runs.iter().map(|r| r.len).collect::<Vec<_>>(),
|
||||
[100_000, 1, 4],
|
||||
"a plain push forgets the generation but still joins an equal run"
|
||||
);
|
||||
assert_eq!(pb.text.len(), 100_005);
|
||||
}
|
||||
|
||||
/// Several bins, equal and descending breakpoints, a missing page and the last FC: wherever a
|
||||
/// run is reported as valid it gives the answer a fresh lookup gives.
|
||||
#[test]
|
||||
fn chpx_runs_are_sound_across_bins() {
|
||||
let page_a = chpx_fkp(&[(0, grpprl(&[(0x0835, &[1])])), (8, grpprl(&[(0x0836, &[1])]))], 20);
|
||||
let page_b = chpx_fkp(&[(20, Vec::new()), (26, grpprl(&[(0x0835, &[1])]))], 40);
|
||||
let mut word = vec![0u8; 512];
|
||||
word.extend_from_slice(&page_a);
|
||||
word.extend_from_slice(&page_b);
|
||||
let (a, b, end) = (fc_of(20), fc_of(40), fc_of(60));
|
||||
let plc = |fcs: &[u32], pns: &[u32]| {
|
||||
[fcs.iter().flat_map(|f| f.to_le_bytes()).collect::<Vec<u8>>(), pns.iter().flat_map(|p| p.to_le_bytes()).collect()].concat()
|
||||
};
|
||||
let tables = [
|
||||
("two bins", plc(&[0, a, end], &[1, 2])),
|
||||
("an empty bin", plc(&[0, a, a, end], &[1, 2, 2])),
|
||||
("a missing page", plc(&[0, a, end], &[1, 9])),
|
||||
("descending breakpoints", plc(&[0, b, a, end], &[1, 2, 1])),
|
||||
("fcs up to the maximum", plc(&[0, a, u32::MAX], &[1, 2])),
|
||||
];
|
||||
for (what, t) in &tables {
|
||||
let bins = crate::fkp::Bins::parse(t, 0, t.len() as u32).expect("bins");
|
||||
let probes: Vec<u32> = (0..fc_of(50)).step_by(2).chain([u32::MAX - 1, u32::MAX]).collect();
|
||||
for &fc in &probes {
|
||||
let run = bins.chpx_run(&word, fc);
|
||||
assert_eq!(run.grpprl, bins.chpx(&word, fc), "{what}: fc {fc}");
|
||||
for &other in &probes {
|
||||
if run.contains(other) {
|
||||
assert_eq!(bins.chpx(&word, other), run.grpprl, "{what}: fc {fc} run reused at {other}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The same formatting changes through a whole read: runs break exactly where the CHPX changes
|
||||
/// (with a field and a symbol character in between), and nowhere else.
|
||||
#[test]
|
||||
fn formatting_runs_break_where_the_chpx_changes() {
|
||||
let spec = RichSpec {
|
||||
text: "aaaabbbb\u{13}PAGE\u{14}12\u{15}ccdd(ee\r",
|
||||
styles: vec![(1, 0x0FFF, "Normal", Vec::new(), Vec::new())],
|
||||
fonts: vec!["Times New Roman", "Arial"],
|
||||
chpx_runs: vec![(0, grpprl(&[(0x0835, &[1])])), (4, Vec::new()), (8, grpprl(&[(0x0835, &[1])])), (20, grpprl(&[(0x0836, &[1])]))],
|
||||
papx_entries: vec![(30, 0, Vec::new())],
|
||||
};
|
||||
let doc = read(&rich_doc(&spec)).expect("opens");
|
||||
let p = para0(&doc);
|
||||
let shape: Vec<(usize, Option<bool>, Option<bool>)> = p.runs.iter().map(|r| (r.len, r.props.bold, r.props.italic)).collect();
|
||||
// "aaaa" bold, "bbbb" plain plus the field's anchor (3 bytes, default formatting), "ccd" bold
|
||||
// (the run from the field to CP 20), "d(ee" italic.
|
||||
assert_eq!(shape, [(4, Some(true), None), (7, None, None), (3, Some(true), None), (4, None, Some(true))], "{shape:?}");
|
||||
}
|
||||
@@ -118,9 +118,33 @@ impl Reader<'_> {
|
||||
Some(obj)
|
||||
}
|
||||
|
||||
/// The hyperlink Word keeps on a drawing itself: its `wp:docPr`'s `a:hlinkClick` (ECMA-376
|
||||
/// Part 1 §20.4.2.5, §21.1.2.3.5) naming an external hyperlink relationship. Word stores a
|
||||
/// picture's link there rather than in a `w:hyperlink` around its run (#5267). Its `#name`
|
||||
/// part, when the address has one, stays part of the address.
|
||||
pub(super) fn drawing_link(d: &El, rels: &Rels) -> Option<String> {
|
||||
let frame = d.child("wp:inline").or_else(|| d.child("wp:anchor"))?;
|
||||
let id = frame.child("wp:docPr")?.child("a:hlinkClick")?.attr("r:id")?;
|
||||
let rel = rels.by_id(id).filter(|r| r.external && crate::package::rel_is(&r.kind, rt::HYPERLINK))?;
|
||||
Some(rel.target.trim().to_string()).filter(|u| !u.is_empty())
|
||||
}
|
||||
|
||||
/// Native ink or an Office 2016 chart kept with its fallback (`mc:AlternateContent`), and the
|
||||
/// [`Self::drawing_link`] of the choice it was read from, else of its fallback: another
|
||||
/// choice's link is not this object's.
|
||||
pub(super) fn read_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<(InlineObject, Option<String>)> {
|
||||
let (object, choice) = match self.read_ink_alternate(sc, ac, rels) {
|
||||
Some(o) => (o, children_of_choice(ac).filter(|e| e.name == "mc:Choice")),
|
||||
None => (self.read_chartex_alternate(sc, ac, rels)?, chartex_choice(ac)),
|
||||
};
|
||||
let link =
|
||||
[choice, ac.child("mc:Fallback")].into_iter().flatten().filter_map(|e| e.child("w:drawing")).find_map(|d| Self::drawing_link(d, rels));
|
||||
Some((object, link))
|
||||
}
|
||||
|
||||
/// Keep the selected native-ink branch and its picture fallback as one object. Reading only
|
||||
/// the fallback flattens the ink; reading only the choice loses its preview on the next save.
|
||||
pub(super) fn read_ink_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
fn read_ink_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
let choice = children_of_choice(ac).filter(|e| e.name == "mc:Choice")?;
|
||||
let drawing = choice.child("w:drawing")?;
|
||||
if drawing.find("a:graphicData")?.attr("uri") != Some(INK_URI) {
|
||||
@@ -149,10 +173,8 @@ impl Reader<'_> {
|
||||
/// An Office 2016 chart (`cx:chart`: waterfall, sunburst, box & whisker…) drawn from its
|
||||
/// fallback picture, keeping both branches so saving writes the chart back rather than only
|
||||
/// the picture. `None` (read the fallback alone) when it can't be kept whole.
|
||||
pub(super) fn read_chartex_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
let choice = ac
|
||||
.children("mc:Choice")
|
||||
.find(|c| c.child("w:drawing").and_then(|d| d.find("a:graphicData")).and_then(|g| g.attr("uri")) == Some(CHARTEX_URI))?;
|
||||
fn read_chartex_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
let choice = chartex_choice(ac)?;
|
||||
// A requirement in a namespace we can't name couldn't be written back.
|
||||
let requires = choice.attr("Requires").unwrap_or("");
|
||||
if requires.split_whitespace().next().is_none() || requires.split_whitespace().any(|p| p == "?") {
|
||||
@@ -563,6 +585,11 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `mc:Choice` holding an Office 2016 chart (`cx:chart`) in an `mc:AlternateContent`.
|
||||
fn chartex_choice(ac: &El) -> Option<&El> {
|
||||
ac.children("mc:Choice").find(|c| c.child("w:drawing").and_then(|d| d.find("a:graphicData")).and_then(|g| g.attr("uri")) == Some(CHARTEX_URI))
|
||||
}
|
||||
|
||||
/// The alternative text of a `wp:docPr`, `pic:cNvPr` or `wps:cNvPr` (ECMA-376
|
||||
/// `CT_NonVisualDrawingProps`): the description (the title when there is none, as before), and
|
||||
/// the title and decorative mark (the `adec:decorative` extension of [MS-ODRAWXML]).
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -10,9 +10,19 @@ pub(super) struct DrawingInfo<'a> {
|
||||
pub docpr: &'a str,
|
||||
pub name: &'a str,
|
||||
pub alt: &'a str,
|
||||
/// The relationship id of the drawing's hyperlink (`a:hlinkClick`), see [`Writer::drawing_link`].
|
||||
pub link: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl Writer<'_> {
|
||||
/// The relationship of the hyperlink a drawing in a run with `props` carries on itself, as
|
||||
/// Word writes a picture's link (`wp:docPr/a:hlinkClick`, #5267): a web or file address. A
|
||||
/// link to a place in the document (`#name`) is only the `w:hyperlink` around the run.
|
||||
pub(super) fn drawing_link(props: &wordcraft_doc::CharProps, rels: &mut PartRels) -> Option<String> {
|
||||
let url = props.link.as_deref().filter(|l| !l.is_empty() && !l.starts_with('#'))?;
|
||||
Some(rels.add(rt::HYPERLINK, url, true))
|
||||
}
|
||||
|
||||
/// A picture's `pic:pic` (the image in `media/{file}`), at `off` in its group (or 0, 0).
|
||||
pub(super) fn pic(&mut self, w: &mut W, o: &InlineObject, file: &str, off: (f32, f32), rels: &mut PartRels) {
|
||||
let InlineObject::Image { media, w: iw, h: ih, alt, crop, float, fx, .. } = o else { return };
|
||||
@@ -20,7 +30,7 @@ impl Writer<'_> {
|
||||
self.used_media.insert(media.clone());
|
||||
w.open("pic:pic", &[]);
|
||||
w.open("pic:nvPicPr", &[]);
|
||||
nv_props(w, "pic:cNvPr", "0", file, alt, &float.alt, float.hidden);
|
||||
nv_props(w, "pic:cNvPr", ("0", file), alt, &float.alt, float.hidden, None);
|
||||
w.open("pic:cNvPicPr", &[]);
|
||||
w.empty("a:picLocks", &[("noChangeAspect", "1"), ("noChangeArrowheads", "1")]);
|
||||
w.close("pic:cNvPicPr");
|
||||
@@ -66,7 +76,7 @@ impl Writer<'_> {
|
||||
w.open("wps:wsp", &[]);
|
||||
if let Some(id) = id {
|
||||
let name = shape_name(*kind, freeform.as_deref(), id);
|
||||
nv_props(w, "wps:cNvPr", id, &name, &float.alt.text, &float.alt, float.hidden);
|
||||
nv_props(w, "wps:cNvPr", (id, &name), &float.alt.text, &float.alt, float.hidden, None);
|
||||
}
|
||||
if kind.is_connector() {
|
||||
// A connector's glued ends (ECMA-376 §20.1.2.2.8 `cNvCxnSpPr`).
|
||||
@@ -235,7 +245,7 @@ impl Writer<'_> {
|
||||
}
|
||||
|
||||
pub(super) fn drawing_open(&mut self, w: &mut W, info: DrawingInfo<'_>) {
|
||||
let DrawingInfo { float, size: (iw, ih), docpr, name, alt } = info;
|
||||
let DrawingInfo { float, size: (iw, ih), docpr, name, alt, link } = info;
|
||||
let (cx, cy) = (emu(iw.max(0.0)), emu(ih.max(0.0)));
|
||||
if float.wrap == Wrap::Inline {
|
||||
w.open("wp:inline", &[("distT", "0"), ("distB", "0"), ("distL", "0"), ("distR", "0")]);
|
||||
@@ -316,7 +326,7 @@ impl Writer<'_> {
|
||||
Wrap::TopAndBottom => w.empty("wp:wrapTopAndBottom", &[]),
|
||||
Wrap::BehindText | Wrap::InFrontOfText => w.empty("wp:wrapNone", &[]),
|
||||
}
|
||||
nv_props(w, "wp:docPr", docpr, name, alt, &float.alt, float.hidden);
|
||||
nv_props(w, "wp:docPr", (docpr, name), alt, &float.alt, float.hidden, link);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,8 +512,9 @@ pub(in super::super) fn spin_attrs(spin: wordcraft_geom::Spin) -> Vec<(&'static
|
||||
/// A drawing's `wp:docPr` (or a group member's `pic:cNvPr`/`wps:cNvPr`, ECMA-376
|
||||
/// `CT_NonVisualDrawingProps`) with its alt text `descr`, its title, and the decorative mark as
|
||||
/// the `adec:decorative` extension ([MS-ODRAWXML]). A description that is only the title (read
|
||||
/// from a file with no description) is not written twice.
|
||||
pub(in super::super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, descr: &str, alt: &AltText, hidden: bool) {
|
||||
/// from a file with no description) is not written twice. `link` is the relationship id of the
|
||||
/// drawing's hyperlink (`a:hlinkClick`, the first child the schema allows).
|
||||
pub(in super::super) fn nv_props(w: &mut W, tag: &str, (id, name): (&str, &str), descr: &str, alt: &AltText, hidden: bool, link: Option<&str>) {
|
||||
let mut attrs = vec![("id", id), ("name", name)];
|
||||
if descr != alt.title || descr.is_empty() {
|
||||
attrs.push(("descr", descr));
|
||||
@@ -514,16 +525,21 @@ pub(in super::super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, des
|
||||
if hidden {
|
||||
attrs.push(("hidden", "1"));
|
||||
}
|
||||
if !alt.decorative {
|
||||
if !alt.decorative && link.is_none() {
|
||||
w.empty(tag, &attrs);
|
||||
return;
|
||||
}
|
||||
w.open(tag, &attrs);
|
||||
w.open("a:extLst", &[]);
|
||||
w.open("a:ext", &[("uri", crate::xml::DECORATIVE_EXT)]);
|
||||
w.empty("adec:decorative", &[("xmlns:adec", crate::xml::DECORATIVE_NS), ("val", "1")]);
|
||||
w.close("a:ext");
|
||||
w.close("a:extLst");
|
||||
if let Some(rid) = link {
|
||||
w.empty("a:hlinkClick", &[("r:id", rid)]);
|
||||
}
|
||||
if alt.decorative {
|
||||
w.open("a:extLst", &[]);
|
||||
w.open("a:ext", &[("uri", crate::xml::DECORATIVE_EXT)]);
|
||||
w.empty("adec:decorative", &[("xmlns:adec", crate::xml::DECORATIVE_NS), ("val", "1")]);
|
||||
w.close("a:ext");
|
||||
w.close("a:extLst");
|
||||
}
|
||||
w.close(tag);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+83
-4
@@ -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() };
|
||||
@@ -593,6 +636,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`.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -23,6 +23,7 @@ mod ligatures;
|
||||
mod malformed;
|
||||
mod note_comments;
|
||||
mod passthrough;
|
||||
mod picture_links;
|
||||
mod positional_tabs;
|
||||
mod privacy;
|
||||
mod roundtrip;
|
||||
@@ -34,6 +35,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,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()));
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -228,6 +228,7 @@ fn hyperlink_field_targets_follow_the_field_code() {
|
||||
for (para, link) in [
|
||||
(complex(r#"HYPERLINK "https://example.org/a" \l "sec""#), Some("https://example.org/a#sec")),
|
||||
(complex(r#"hyperlink \o "A tip" \t "_blank" "https://example.org/""#), Some("https://example.org/")),
|
||||
(complex(r#"HYPERLINK "x \* Upper""#), Some("x * Upper")),
|
||||
(complex(r#"HYPERLINK "C:\\Docs\\My file.docx""#), Some(r"C:\Docs\My file.docx")),
|
||||
(simple(r#"HYPERLINK \l "Sec" \m"#), Some("#Sec")),
|
||||
(simple("HYPERLINK https://example.org/b"), Some("https://example.org/b")),
|
||||
|
||||
@@ -323,6 +323,7 @@ fn select_all(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
let st = s.sel.focus.story;
|
||||
s.sel = Selection { anchor: s.doc.start_of(st), focus: s.doc.end_of(st) };
|
||||
s.pending = None;
|
||||
s.column = None;
|
||||
s.column_mode = false;
|
||||
sel_result(s)
|
||||
@@ -332,6 +333,7 @@ fn select_range(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let a = v.get("anchor").and_then(parse_pos).ok_or_else(|| CmdError::Params("bad `anchor`".into()))?;
|
||||
let b = v.get("focus").and_then(parse_pos).ok_or_else(|| CmdError::Params("bad `focus`".into()))?;
|
||||
s.sel = Selection { anchor: s.doc.clamp(&a), focus: s.doc.clamp(&b) };
|
||||
s.pending = None;
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
@@ -392,6 +394,7 @@ fn select_unit(s: &mut Session, unit: Unit) -> CmdResult {
|
||||
if s.doc.para_at(&f).is_some() {
|
||||
let (a, b) = unit_range(s, &f, unit);
|
||||
s.sel = Selection { anchor: a, focus: b };
|
||||
s.pending = None;
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
@@ -486,6 +489,9 @@ fn select_text(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if count == n {
|
||||
s.sel =
|
||||
Selection { anchor: Pos { story, path: path.clone(), off: i }, focus: Pos { story, path: path.clone(), off: i + needle.len() } };
|
||||
// Formatting picked for the old caret doesn't carry over (as `select.word`): what
|
||||
// replaces the selection takes its formatting.
|
||||
s.pending = None;
|
||||
return sel_result(s);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}]*",
|
||||
|
||||
@@ -63,6 +63,17 @@ pub(super) fn link(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let end = s.doc.insert_text(&at, &text, &props)?;
|
||||
s.sel = Selection::caret(end);
|
||||
s.pending = Some(CharProps::default());
|
||||
} else if let Some(text) = retyped_text(s, v) {
|
||||
// Text to display changed: the selection is retyped with it, then linked (as in Word).
|
||||
crate::cmd::paste_text(s, &text)?;
|
||||
let end = s.sel.focus.clone();
|
||||
let start = Pos { off: end.off.saturating_sub(text.len()), ..end.clone() };
|
||||
s.doc.format_range(&start, &end, &|c| {
|
||||
c.link = Some(url.clone());
|
||||
c.style = style.clone();
|
||||
})?;
|
||||
s.sel = Selection::caret(end);
|
||||
s.pending = Some(CharProps::default());
|
||||
} else {
|
||||
let (a, b) = s.sel.ordered();
|
||||
s.doc.format_range(&a, &b, &|c| {
|
||||
@@ -73,6 +84,33 @@ pub(super) fn link(s: &mut Session, v: &Value) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// The `text` to retype a selection with before linking it: only when it differs from the selected
|
||||
/// text and both fit in one line of one paragraph without pictures or fields, outside any field's
|
||||
/// result. Otherwise (as Word shows `<<Selection in Document>>`) the selection keeps its text.
|
||||
fn retyped_text(s: &Session, v: &Value) -> Option<String> {
|
||||
use wordcraft_doc::para::{COLUMN_BREAK, LINE_BREAK, OBJ, PAGE_BREAK};
|
||||
/// What can't stand in one line of text: breaks, and pictures or fields.
|
||||
const NOT_ONE_LINE: [char; 7] = [LINE_BREAK, '\r', '\u{2029}', '\u{b}', PAGE_BREAK, COLUMN_BREAK, OBJ];
|
||||
let text = p::str(v, "text").filter(|t| !t.is_empty() && t.len() <= 4096)?;
|
||||
if text.contains(NOT_ONE_LINE) {
|
||||
return None;
|
||||
}
|
||||
let (a, b) = s.sel.ordered();
|
||||
if a.story != b.story || a.path != b.path {
|
||||
return None;
|
||||
}
|
||||
let shown = s.doc.para(a.story, &a.path)?.text.get(a.off..b.off)?;
|
||||
if shown.contains(NOT_ONE_LINE) || shown == text {
|
||||
return None;
|
||||
}
|
||||
// A selection in a field's result (between its markers, which it doesn't hold) is retyped by
|
||||
// the next update of the field, not here.
|
||||
if s.doc.field_ranges(a.story).iter().any(|r| r.start < b && a <= r.end) {
|
||||
return None;
|
||||
}
|
||||
Some(text.to_string())
|
||||
}
|
||||
|
||||
/// Whether `name` can name a bookmark: starts with a letter or underscore, contains only alphanumeric characters
|
||||
/// or underscores, and is at most 40 characters (#3491). Word allows leading underscores for hidden bookmarks (_Toc, _Ref).
|
||||
pub fn bookmark_name_ok(name: &str) -> bool {
|
||||
|
||||
@@ -452,6 +452,36 @@ mod tests {
|
||||
assert_eq!(numbered(&mut b), want);
|
||||
}
|
||||
|
||||
/// #5757: a list numbered in Thai digits shows them on the page, with the level's text around
|
||||
/// them (`๑.`, `(๒)`), and keeps its format through a save to .docx and back.
|
||||
#[test]
|
||||
fn a_thai_numbered_list_shows_thai_digits_and_survives_docx() {
|
||||
let mut s = Session::new(Document::new());
|
||||
for (i, t) in ["หนึ่ง", "สอง", "สาม", "a", "b", "c", "d", "e", "f", "g", "h"].iter().enumerate() {
|
||||
if i > 0 {
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
}
|
||||
s.run("text.insert", &json!({"text": t})).unwrap();
|
||||
}
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
let levels = json!([{"format": "thaiNumbers", "text": "%1.", "start": 1}]);
|
||||
s.run("list.define", &json!({"levels": levels})).unwrap();
|
||||
let page: Vec<Option<String>> = numbered(&mut s).into_iter().map(|x| x.0).collect();
|
||||
let want: Vec<Option<String>> =
|
||||
["๑.", "๒.", "๓.", "๔.", "๕.", "๖.", "๗.", "๘.", "๙.", "๑๐.", "๑๑."].iter().map(|l| Some(l.to_string())).collect();
|
||||
assert_eq!(page, want);
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
|
||||
let mut b = Session::new(back);
|
||||
let again: Vec<Option<String>> = numbered(&mut b).into_iter().map(|x| x.0).collect();
|
||||
assert_eq!(again, want, "thaiNumbers survives .docx");
|
||||
// In parentheses too.
|
||||
let mut s = Session::new(Document::new());
|
||||
s.run("text.insert", &json!({"text": "x"})).unwrap();
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
s.run("list.define", &json!({"levels": [{"format": "thaiNumbers", "text": "(%1)", "start": 2}]})).unwrap();
|
||||
assert_eq!(numbered(&mut s)[0].0.as_deref(), Some("(๒)"));
|
||||
}
|
||||
|
||||
/// #1793: the other heading presets of Word's List Library, level by level.
|
||||
#[test]
|
||||
fn every_heading_preset_numbers_three_heading_levels() {
|
||||
@@ -459,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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -183,9 +183,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.key(if cfg!(target_os = "macos") { "Ctrl+Shift+Right" } else { "Alt+Shift+Right" }),
|
||||
CommandSpec::new("para.shading", "Shading", "Home › Paragraph", |s, v| {
|
||||
let c = p::str(v, "color").and_then(Rgb::parse);
|
||||
// Text selected within one paragraph is shaded itself, as in Word, so No Color takes
|
||||
// off a shading a file gave the text (#4685); a caret or paragraphs shade paragraphs.
|
||||
// `paragraph` (Borders and Shading › Apply to: Paragraph) shades the paragraphs anyway.
|
||||
if p::bool(v, "paragraph") != Some(true) && shades_text(s) {
|
||||
return super::format::apply(s, &|x| x.shading = c);
|
||||
}
|
||||
fmt(s, &|p| p.shading = c)
|
||||
})
|
||||
.params(r#"{"color": "RRGGBB" | null}"#),
|
||||
.params(
|
||||
r#"{"color": "RRGGBB" | null, "paragraph"?: bool} (text selected inside one paragraph is shaded itself, as in Word; `paragraph: true` shades the paragraphs instead, as Borders and Shading › Apply to: Paragraph)"#,
|
||||
),
|
||||
CommandSpec::new("para.borders", "Borders", "Home › Paragraph", borders).params(
|
||||
r#"{"kind": "bottom|top|left|right|none|all|outside|inside|horizontalLine", "width"?: pt, "color"?: "RRGGBB", "style"?: "single|double|dotted|dashed|thick|triple|dotDash|wave", "sides"?: Sides} where Sides = {"top"|"left"|"bottom"|"right"|"between": {"style"?, "width"?: pt, "color"?: "RRGGBB"} | null}: exactly these sides (others off), instead of `kind`"#,
|
||||
),
|
||||
@@ -264,3 +272,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
/// Whether Shading applies to the selected text rather than to paragraphs: a selection inside one
|
||||
/// paragraph, short of its mark. A selection reaching past the mark ends at the next paragraph's
|
||||
/// start, so all of a paragraph's text is still text, except in the last paragraph of its story,
|
||||
/// cell or box, where it stands for the paragraph with its mark. A selected picture, shape or text
|
||||
/// box is its object character, not text (a field's object character is).
|
||||
fn shades_text(s: &Session) -> bool {
|
||||
let (a, b) = s.sel.ordered();
|
||||
if a == b || a.story != b.story || a.path != b.path {
|
||||
return false;
|
||||
}
|
||||
let Some(p) = s.doc.para_at(&a) else { return false };
|
||||
if super::objects::object_selection(s).is_some() {
|
||||
return false;
|
||||
}
|
||||
let last = s.doc.next_para(a.story, &a.path).is_none_or(|q| q.parent() != a.path.parent());
|
||||
!(last && a.off == 0 && b.off >= p.len())
|
||||
}
|
||||
@@ -103,8 +103,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
/// A field instruction split into its name, positional arguments and switches (ECMA-376
|
||||
/// §17.16.1): `TOC \o "1-3" \h` → name `TOC`, switches `o` = `1-3` and `h`. Quoted text stays
|
||||
/// whole (without the quotes; `\"` and `\\` inside it are a quote and a backslash). A switch
|
||||
/// takes the word after it as its argument unless that word is another switch.
|
||||
/// whole (without the quotes; a backslash inside it makes the next character literal, as in Word:
|
||||
/// `\"` is a quote, `\\` a backslash, `\*` an asterisk). A switch takes the word after it as its
|
||||
/// argument unless that word is another switch.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct FieldCode {
|
||||
pub name: String,
|
||||
@@ -126,7 +127,7 @@ pub fn field_words(instr: &str) -> Vec<(String, bool)> {
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'"' => break,
|
||||
'\\' if matches!(chars.peek(), Some('"' | '\\')) => w.extend(chars.next()),
|
||||
'\\' if chars.peek().is_some() => w.extend(chars.next()),
|
||||
c => w.push(c),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::properties::{new_property_value, text_result};
|
||||
use super::*;
|
||||
|
||||
fn fields(s: &Session) -> Vec<(String, String)> {
|
||||
@@ -446,7 +447,7 @@ fn update_fields_apply_the_capitalization_switches() {
|
||||
"seventh",
|
||||
"Seventh",
|
||||
"BUDGET",
|
||||
"new \\* Upper"
|
||||
"new * Upper"
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -548,6 +549,85 @@ 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]
|
||||
fn quoted_field_argument_backslash_escapes() {
|
||||
for (instr, shown) in [(r#"TITLE "x \* Upper""#, "x * Upper"), (r#"TITLE "x \\* Upper""#, r"x \* Upper"), (r#"TITLE "a \"b\" c""#, r#"a "b" c"#)]
|
||||
{
|
||||
let value = new_property_value(instr).unwrap();
|
||||
assert_eq!(value, shown, "{instr}");
|
||||
assert_eq!(text_result(instr, value), shown, "{instr}");
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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");
|
||||
|
||||
+298
-8
@@ -12,6 +12,7 @@ use crate::{Session, cmd};
|
||||
|
||||
mod bidi;
|
||||
mod documents;
|
||||
mod find_apostrophes;
|
||||
mod font_sizes;
|
||||
mod fonts;
|
||||
mod ime_noop;
|
||||
@@ -4681,10 +4682,8 @@ fn typed_paragraphs_are_capped_for_every_caller() {
|
||||
assert!(!body_texts(&s).iter().any(|p| p.contains('y')));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_params_never_panic() {
|
||||
let reg = cmd::registry();
|
||||
let junk = [
|
||||
fn hostile_junk() -> [serde_json::Value; 8] {
|
||||
[
|
||||
json!(null),
|
||||
json!({}),
|
||||
json!({"text": 5, "size": "x", "path": [], "pos": {"block": 9999, "off": 99999}}),
|
||||
@@ -4693,11 +4692,23 @@ fn hostile_params_never_panic() {
|
||||
json!({"page": 0, "x": 1e30, "y": -1e30, "extend": 1}),
|
||||
json!({"width": 1e-9, "height": 1e30, "top": -1e9, "left": 1e9}),
|
||||
json!({"section": {"pageW": -1e308, "pageH": 0, "marginTop": 1e30, "header": -5, "columns": {"count": 1000000, "space": -1e300, "widths": [[0, 0]]}}}),
|
||||
];
|
||||
]
|
||||
}
|
||||
|
||||
/// `file.*`, `insert.picture` and `insert.textFromFile`: `hostile_file_params_never_panic` runs
|
||||
/// them, held to a folder of its own, and `hostile_params_never_panic` every other command.
|
||||
fn reaches_files(id: &str) -> bool {
|
||||
id.starts_with("file.") || id == "insert.picture" || id == "insert.textFromFile"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_params_never_panic() {
|
||||
let reg = cmd::registry();
|
||||
let junk = hostile_junk();
|
||||
for spec in reg.all() {
|
||||
// These reach outside the session (files). Read Aloud (`review.readAloud`, `readAloud.*`)
|
||||
// is fuzzed too: under `cfg(test)` its backend is the silent `Hold`, asserted below.
|
||||
if spec.id.starts_with("file.") || spec.id == "insert.picture" || spec.id == "insert.textFromFile" {
|
||||
// Read Aloud (`review.readAloud`, `readAloud.*`) is fuzzed too: under `cfg(test)` its
|
||||
// backend is the silent `Hold`, asserted below.
|
||||
if reaches_files(spec.id) {
|
||||
continue;
|
||||
}
|
||||
for j in &junk {
|
||||
@@ -4711,6 +4722,127 @@ fn hostile_params_never_panic() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `file.*`, `insert.picture` and `insert.textFromFile` take junk params and paths to files they
|
||||
/// can't use (#3719). They may read only `in` and write only `out`, so a save can't replace a file
|
||||
/// another run is meant to find broken, and nothing outside those folders is touched.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn hostile_file_params_never_panic() {
|
||||
/// What is directly in `dir`: each name with its bytes (`None` for a folder).
|
||||
fn contents(dir: &std::path::Path) -> std::collections::BTreeMap<String, Option<Vec<u8>>> {
|
||||
let entries = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().path());
|
||||
entries.map(|p| (p.file_name().unwrap().to_string_lossy().into_owned(), std::fs::read(&p).ok())).collect()
|
||||
}
|
||||
/// The names whose bytes differ, so a failure lists them instead of printing every file.
|
||||
fn changed(before: &std::collections::BTreeMap<String, Option<Vec<u8>>>, dir: &std::path::Path) -> Vec<String> {
|
||||
let after = contents(dir);
|
||||
let names: std::collections::BTreeSet<_> = before.keys().chain(after.keys()).collect();
|
||||
names.into_iter().filter(|n| before.get(*n) != after.get(*n)).cloned().collect()
|
||||
}
|
||||
let reg = cmd::registry();
|
||||
let base = scratch_dir("hostile-files");
|
||||
let (input, output, outside) = (base.join("in"), base.join("out"), base.join("outside"));
|
||||
for dir in [&outside, &input.join("folder.docx"), &output.join("folder.docx")] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
}
|
||||
let at = |dir: &std::path::Path, name: &str| dir.join(name).to_string_lossy().into_owned();
|
||||
|
||||
// Fixed bytes, so a failure comes back on the next run.
|
||||
let noise: Vec<u8> = (0..512u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 24) as u8).collect();
|
||||
let picture_exts = ["png", "jpg", "gif", "bmp", "svg", "emf"];
|
||||
let mut paths = vec![];
|
||||
for ext in crate::io::OPEN_EXTS.iter().chain(&picture_exts) {
|
||||
let name = format!("random.{ext}");
|
||||
std::fs::write(input.join(&name), &noise).unwrap();
|
||||
paths.push(at(&input, &name));
|
||||
}
|
||||
paths.extend(crate::io::SAVE_EXTS.iter().map(|ext| at(&output, &format!("new.{ext}"))));
|
||||
std::fs::write(input.join("empty"), b"").unwrap();
|
||||
std::fs::write(input.join("empty.docx"), b"").unwrap();
|
||||
std::fs::write(input.join("good.txt"), "Good text").unwrap();
|
||||
std::fs::write(input.join("good.png"), tiny_png()).unwrap();
|
||||
paths.extend(["empty", "empty.docx", "folder.docx", "missing", "missing.docx"].map(|name| at(&input, name)));
|
||||
paths.extend(["folder.docx", "missing/new.docx", &format!("{}.docx", "x".repeat(5000))].map(|name| at(&output, name)));
|
||||
// Outside the roots, however it is spelled, and not paths at all.
|
||||
paths.extend(["new.docx", "new"].map(|name| at(&outside, name)));
|
||||
paths.push(format!("{}/../outside/new.docx", input.to_string_lossy()));
|
||||
paths.extend(["hostile-relative.docx", "hostile-relative", "", "\0", "a\0b.docx"].map(String::from));
|
||||
|
||||
// Every path alone; each of these also with every other kind of junk param. The numbers that
|
||||
// cost time (`copies`, `scale`) stay small: huge ones are #3720.
|
||||
let mut params: Vec<serde_json::Value> = hostile_junk().into();
|
||||
params.extend(paths.iter().map(|p| json!({"path": p})));
|
||||
for name in [
|
||||
"in/random.docx",
|
||||
"in/empty.docx",
|
||||
"in/folder.docx",
|
||||
"in/missing.docx",
|
||||
"in/good.txt",
|
||||
"in/good.png",
|
||||
"out/new.docx",
|
||||
"out/new.pdf",
|
||||
"out/new.png",
|
||||
] {
|
||||
let path = at(&base, name);
|
||||
params.extend([
|
||||
json!({"path": path, "password": "x", "format": "wordml2003", "markup": "x", "confirmDamaged": 1}),
|
||||
json!({"path": path, "password": 5, "format": 5, "markup": true}),
|
||||
json!({"path": path, "page": 0, "scale": 0, "width": -1e308, "alt": 5}),
|
||||
json!({"path": path, "data": "not base64!"}),
|
||||
json!({"path": path, "data": "AAAA"}),
|
||||
json!({"path": path, "pagesPerSheet": 7, "range": "x", "pages": "9-1,,", "parity": "x", "copies": 3, "paper": "x"}),
|
||||
json!({"dir": path, "name": path, "template": path, "userTemplatesFolder": path, "id": 1e30, "restore": 1e30, "save": path}),
|
||||
]);
|
||||
}
|
||||
|
||||
let before = contents(&input);
|
||||
let working_dir = std::path::Path::new(".");
|
||||
let working_dir_before = contents(working_dir);
|
||||
let roots = crate::file_access::AutomationRoots::new(Some(&input), Some(&output)).unwrap();
|
||||
let confined = |s: &mut Session, id: &str, v: &serde_json::Value| crate::file_access::confine(roots.as_ref(), || s.run(id, v));
|
||||
for spec in reg.all() {
|
||||
if !reaches_files(spec.id) {
|
||||
continue;
|
||||
}
|
||||
for v in ¶ms {
|
||||
let mut s = Session::new(crate::sample::sample_document());
|
||||
let r = confined(&mut s, spec.id, v);
|
||||
assert!(!caught_panic(®, &r), "{} panicked on {v}: {r:?}", spec.id);
|
||||
s.clamp_selection();
|
||||
let _ = s.layout();
|
||||
}
|
||||
}
|
||||
|
||||
let nothing = Vec::<String>::new();
|
||||
assert_eq!(changed(&before, &input), nothing, "the read root is only read");
|
||||
assert_eq!(changed(&Default::default(), &outside), nothing, "nothing outside the write root");
|
||||
// Every name counts: an empty path comes out as `.pdf` or `.dotx` here.
|
||||
assert_eq!(changed(&working_dir_before, working_dir), nothing, "nothing in the working directory");
|
||||
|
||||
// The junk above met the files, not a closed door: usable files work through the same roots...
|
||||
let mut s = s();
|
||||
confined(&mut s, "file.open", &json!({"path": at(&input, "good.txt")})).unwrap();
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Good text");
|
||||
confined(&mut s, "insert.textFromFile", &json!({"path": at(&input, "good.txt")})).unwrap();
|
||||
confined(&mut s, "insert.picture", &json!({"path": at(&input, "good.png")})).unwrap();
|
||||
let saved: [(&str, &str, &[u8]); 3] =
|
||||
[("file.saveAs", "control.docx", b"PK\x03\x04"), ("file.exportPdf", "control.pdf", b"%PDF-"), ("file.exportPng", "control.png", b"\x89PNG")];
|
||||
for (id, name, signature) in saved {
|
||||
confined(&mut s, id, &json!({"path": at(&output, name)})).unwrap();
|
||||
assert_eq!(std::fs::read(output.join(name)).unwrap().get(..signature.len()), Some(signature), "{name}");
|
||||
}
|
||||
// ...the noise is refused by the reader, and only a path outside by the roots.
|
||||
let refused = |s: &mut Session, path: String| match confined(s, "file.open", &json!({"path": path})) {
|
||||
Err(crate::CmdError::Failed(m)) => m,
|
||||
r => panic!("not refused: {r:?}"),
|
||||
};
|
||||
let unreadable = refused(&mut s, at(&input, "random.docx"));
|
||||
assert!(!unreadable.contains("automation path rejected"), "{unreadable}");
|
||||
let rejected = refused(&mut s, at(&outside, "new.docx"));
|
||||
assert!(rejected.contains("automation path rejected"), "{rejected}");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// A panic that `Session::run`'s guard turned into an error, in a command or one it ran: still a
|
||||
/// crash path (#3718). Only the guard's own `"{id} panicked: "` counts, not an error that echoes
|
||||
/// those words.
|
||||
@@ -7812,6 +7944,97 @@ fn colour_commands_take_any_custom_hex() {
|
||||
assert_eq!(s.doc.settings.page_color, wordcraft_doc::Rgb::parse("123ABC"));
|
||||
}
|
||||
|
||||
/// #4685: Home › Paragraph › Shading on text selected inside a paragraph shades that text, as in
|
||||
/// Word, so No Color takes off a text shading a file gave it (an orange "highlight" from a .docx is
|
||||
/// `w:shd`, which Text Highlight Color › No Color leaves, in Word too). A caret or a whole
|
||||
/// paragraph still shades the paragraph.
|
||||
#[test]
|
||||
fn shading_selected_text_shades_the_text_and_no_color_clears_it() {
|
||||
let orange = wordcraft_doc::Rgb::parse("FFA500");
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Orange text here"}));
|
||||
run(&mut s, "select.text", json!({"text": "Orange"}));
|
||||
run(&mut s, "para.shading", json!({"color": "FFA500"}));
|
||||
let p = s.doc.para_at(&s.sel.focus).unwrap();
|
||||
assert_eq!((p.props.shading, p.props_of_char(0).shading, p.props_of_char(7).shading), (None, orange, None));
|
||||
// Highlight › No Color doesn't take it off; Shading › No Color does.
|
||||
run(&mut s, "format.highlight", json!({"color": "none"}));
|
||||
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().props_of_char(0).shading, orange);
|
||||
run(&mut s, "para.shading", json!({"color": null}));
|
||||
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().props_of_char(0).shading, None);
|
||||
// A caret, or the whole paragraph, shades the paragraph and leaves the text alone.
|
||||
for whole in [false, true] {
|
||||
let mut s = self::s();
|
||||
run(&mut s, "text.insert", json!({"text": "Orange text here"}));
|
||||
run(&mut s, if whole { "select.all" } else { "caret.left" }, json!({}));
|
||||
run(&mut s, "para.shading", json!({"color": "FFA500"}));
|
||||
let p = s.doc.para_at(&s.sel.focus).unwrap();
|
||||
assert_eq!((p.props.shading, p.props_of_char(0).shading), (orange, None), "whole: {whole}");
|
||||
}
|
||||
// All the text of a paragraph that isn't the last is still text: a selection with its mark
|
||||
// would end at the next paragraph's start.
|
||||
let mut s = self::s();
|
||||
run(
|
||||
&mut s,
|
||||
"document.setText",
|
||||
json!({"text": "Word
|
||||
More"}),
|
||||
);
|
||||
run(&mut s, "select.text", json!({"text": "Word"}));
|
||||
run(&mut s, "para.shading", json!({"color": "FFA500"}));
|
||||
let p = s.doc.para_at(&s.sel.focus).unwrap();
|
||||
assert_eq!((p.props.shading, p.props_of_char(0).shading), (None, orange));
|
||||
// A selected text box shades its anchor's paragraph, not every line of the box.
|
||||
let mut s = self::s();
|
||||
run(&mut s, "text.insert", json!({"text": "Anchor"}));
|
||||
run(&mut s, "insert.textBox", json!({"text": "Inside"}));
|
||||
let id = s.doc.parts.iter().find(|(_, p)| p.kind == wordcraft_doc::PartKind::TextBox).map(|(id, _)| *id).unwrap();
|
||||
let end = s.doc.text_box_anchor(id).unwrap();
|
||||
let start = wordcraft_doc::Pos { off: end.off - wordcraft_doc::para::OBJ.len_utf8(), ..end.clone() };
|
||||
run(&mut s, "select.range", json!({"anchor": start, "focus": end}));
|
||||
run(&mut s, "para.shading", json!({"color": "FFA500"}));
|
||||
let boxed = s.doc.parts.get(&id).and_then(|p| p.blocks.first()).and_then(|b| b.as_para()).unwrap();
|
||||
assert_eq!(boxed.props_of_char(0).shading, None, "the box's text isn't shaded");
|
||||
assert_eq!(s.doc.para_at(&end).unwrap().props.shading, orange);
|
||||
// A selected field is text (its object character isn't a drawing): No Color clears its shading.
|
||||
let mut s = self::s();
|
||||
run(&mut s, "text.insert", json!({"text": "Page "}));
|
||||
run(&mut s, "insert.pageNumber", json!({"position": "current"}));
|
||||
let end = s.sel.focus.clone();
|
||||
let start = wordcraft_doc::Pos { off: end.off - wordcraft_doc::para::OBJ.len_utf8(), ..end.clone() };
|
||||
run(&mut s, "select.range", json!({"anchor": start, "focus": end}));
|
||||
run(&mut s, "para.shading", json!({"color": "FFA500"}));
|
||||
let p = s.doc.para_at(&end).unwrap();
|
||||
assert_eq!((p.props.shading, p.props_of_char(start.off).shading), (None, orange), "the field is shaded");
|
||||
run(&mut s, "para.shading", json!({"color": null}));
|
||||
assert_eq!(s.doc.para_at(&end).unwrap().props_of_char(start.off).shading, None);
|
||||
// Borders and Shading › Apply to: Paragraph shades the paragraph, whatever is selected.
|
||||
run(&mut s, "select.text", json!({"text": "Page"}));
|
||||
run(&mut s, "para.shading", json!({"color": "FFA500", "paragraph": true}));
|
||||
let p = s.doc.para_at(&s.sel.focus).unwrap();
|
||||
assert_eq!((p.props.shading, p.props_of_char(0).shading), (orange, None));
|
||||
}
|
||||
|
||||
/// #4685 review: in a cell's last paragraph all of its text stands for the paragraph with its
|
||||
/// mark (a selection can't reach the next paragraph's start there), so Shading shades the
|
||||
/// paragraph; part of the text is text.
|
||||
#[test]
|
||||
fn shading_all_of_a_cells_last_paragraph_shades_the_paragraph() {
|
||||
let orange = wordcraft_doc::Rgb::parse("FFA500");
|
||||
for (from, to, paragraph) in [(0, 9, true), (0, 4, false)] {
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
|
||||
run(&mut s, "text.insert", json!({"text": "Cell text"}));
|
||||
let cell = s.sel.focus.clone();
|
||||
assert!(cell.path.cell().is_some());
|
||||
run(&mut s, "select.range", json!({"anchor": Pos { off: from, ..cell.clone() }, "focus": Pos { off: to, ..cell.clone() }}));
|
||||
run(&mut s, "para.shading", json!({"color": "FFA500"}));
|
||||
let p = s.doc.para_at(&cell).unwrap();
|
||||
let want = if paragraph { (orange, None) } else { (None, orange) };
|
||||
assert_eq!((p.props.shading, p.props_of_char(0).shading), want, "{from}..{to}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #2738: Home › Underline › Underline Color sets the underline's own colour and leaves its style
|
||||
/// alone; `auto` drops it again. One undo step each, kept through a DOCX round trip, and junk is
|
||||
/// a params error that changes nothing.
|
||||
@@ -10167,6 +10390,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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1539,6 +1539,110 @@ fn typing_at_either_end_of_a_link_is_not_linked() {
|
||||
assert_eq!(links(&s), [("sixte".into(), false), (String::new(), false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_on_a_selection_shows_the_text_to_display() {
|
||||
// Ctrl+K on selected text with a new Text to display: the selection is retyped with it and
|
||||
// linked, keeping its formatting; typing after it stays outside the link (as in Word).
|
||||
let mut s = typed("go see kiln now");
|
||||
run(&mut s, "select.text", json!({"text": "kiln"}));
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Oven"}));
|
||||
typ(&mut s, "!");
|
||||
assert_eq!(texts(&s), ["go see oven! now"]);
|
||||
assert_eq!(links(&s), [("Oven".into(), false)]);
|
||||
let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap();
|
||||
assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "Oven" && c.bold == Some(true)), "the new text keeps the selection's formatting");
|
||||
// The same text, or text that can't stand in one line, leaves the selection as it is.
|
||||
run(&mut s, "select.text", json!({"text": "see"}));
|
||||
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "see"}));
|
||||
run(&mut s, "select.text", json!({"text": "now"}));
|
||||
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "two\nlines"}));
|
||||
assert_eq!(texts(&s), ["go see oven! now"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_selection_drops_the_formatting_left_after_a_link() {
|
||||
// A link leaves plain formatting for what is typed after it. A selection made next doesn't
|
||||
// take it: retyping that selection keeps its own formatting.
|
||||
let bold_run = |s: &Session, text: &str| {
|
||||
let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap();
|
||||
p.run_ranges().any(|(r, c)| &p.text[r] == text && c.bold == Some(true))
|
||||
};
|
||||
let mut s = typed("kiln");
|
||||
run(&mut s, "select.text", json!({"text": "kiln"}));
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Oven"}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Stove"}));
|
||||
assert!(bold_run(&s, "Stove"), "select.all");
|
||||
run(&mut s, "select.word", json!({}));
|
||||
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Range"}));
|
||||
assert!(bold_run(&s, "Range"), "select.word");
|
||||
let anchor = serde_json::to_value(s.doc.start_of(s.sel.focus.story)).unwrap();
|
||||
let focus = serde_json::to_value(s.sel.focus.clone()).unwrap();
|
||||
run(&mut s, "select.range", json!({"anchor": anchor, "focus": focus}));
|
||||
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Hob"}));
|
||||
assert!(bold_run(&s, "Hob"), "select.range");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_over_two_paragraphs_keeps_their_text() {
|
||||
// Word shows <<Selection in Document>> for such a selection: the text to display is ignored.
|
||||
let mut s = typed("one\ntwo");
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "both"}));
|
||||
assert_eq!(texts(&s), ["one", "two"]);
|
||||
assert_eq!(links(&s), [("One".into(), false), ("two".into(), false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_retyped_link_text_is_tracked() {
|
||||
let mut s = typed("go see kiln now");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
run(&mut s, "select.text", json!({"text": "kiln"}));
|
||||
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Oven"}));
|
||||
let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap();
|
||||
assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "kiln" && c.del.is_some()), "the old text stays as a deletion");
|
||||
assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "Oven" && c.ins.is_some() && c.link.is_some()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_keeps_a_selection_it_cannot_retype() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::{InlineObject, Path};
|
||||
let url = "https://example.org/";
|
||||
// A second retyped link keeps its own selection's formatting: the first one's left no
|
||||
// formatting pending for it.
|
||||
let mut s = typed("go see kiln and vat now");
|
||||
run(&mut s, "select.text", json!({"text": "kiln"}));
|
||||
run(&mut s, "insert.link", json!({"url": url, "text": "Oven"}));
|
||||
run(&mut s, "select.text", json!({"text": "vat"}));
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
run(&mut s, "insert.link", json!({"url": url, "text": "Tub"}));
|
||||
let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap();
|
||||
assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "Tub" && c.bold == Some(true) && c.link.is_some()), "{:?}", p.text);
|
||||
// A selection holding a manual line break keeps both lines.
|
||||
let mut s = typed("one");
|
||||
run(&mut s, "text.lineBreak", json!({}));
|
||||
typ(&mut s, "two");
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "insert.link", json!({"url": url, "text": "both"}));
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "one\ntwo");
|
||||
// A selection in a field's result is only linked; the field's next update owns that text.
|
||||
let mut doc = wordcraft_doc::Document::from_text("See ");
|
||||
let c = CharProps::default();
|
||||
let p = doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
let start = p.len();
|
||||
p.insert_object(start, InlineObject::FieldStart { instr: "ADDIN ZOTERO_ITEM CSL_CITATION {}".into(), locked: false, dirty: false }, &c).unwrap();
|
||||
let n = p.insert_text(p.len(), "(Smith 2020)", &c).unwrap();
|
||||
p.insert_object(start + 3 + n, InlineObject::FieldEnd, &c).unwrap();
|
||||
let mut s = Session::new(doc);
|
||||
run(&mut s, "select.text", json!({"text": "Smith"}));
|
||||
run(&mut s, "insert.link", json!({"url": url, "text": "Jones"}));
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "See (Smith 2020)");
|
||||
assert_eq!(s.doc.field_ranges(StoryRef::Body).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_then_selecting_then_typing_are_two_undo_steps() {
|
||||
// #419: a selection that moves the insertion point ends the typing group, like a caret move.
|
||||
|
||||
+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>> {
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
|
||||
/// Decode an entity body (`amp`, `#38`, `#x26`) to its text.
|
||||
pub fn decode_entity(name: &str) -> Option<String> {
|
||||
decode_char(name).map(String::from)
|
||||
}
|
||||
|
||||
fn decode_char(name: &str) -> Option<char> {
|
||||
if let Some(num) = name.strip_prefix('#') {
|
||||
let (digits, radix) = match num.strip_prefix(['x', 'X']) {
|
||||
Some(h) => (h, 16),
|
||||
@@ -17,7 +21,7 @@ pub fn decode_entity(name: &str) -> Option<String> {
|
||||
0x80..=0x9F => crate::txt::cp1252(v as u8),
|
||||
_ => char::from_u32(v).unwrap_or('\u{FFFD}'),
|
||||
};
|
||||
return Some(c.to_string());
|
||||
return Some(c);
|
||||
}
|
||||
let c = match name {
|
||||
"amp" | "AMP" => '&',
|
||||
@@ -229,11 +233,54 @@ pub fn decode_entity(name: &str) -> Option<String> {
|
||||
"zwnj" => '\u{200C}',
|
||||
_ => return None,
|
||||
};
|
||||
Some(c.to_string())
|
||||
Some(c)
|
||||
}
|
||||
|
||||
/// Replace entity references in `s`.
|
||||
/// Legacy names HTML5 also decodes without a `;` (only those [`decode_char`] knows), sorted by bytes.
|
||||
const BARE_NAMES: &[&str] = &[
|
||||
"AElig", "AMP", "Aacute", "Acirc", "Agrave", "Aring", "Atilde", "Auml", "COPY", "Ccedil", "Eacute", "Ecirc", "Egrave", "Euml", "GT", "Iacute",
|
||||
"Icirc", "Igrave", "Iuml", "LT", "Ntilde", "Oacute", "Ocirc", "Ograve", "Oslash", "Otilde", "Ouml", "QUOT", "REG", "Uacute", "Ucirc", "Ugrave",
|
||||
"Uuml", "Yacute", "aacute", "acirc", "aelig", "agrave", "amp", "aring", "atilde", "auml", "ccedil", "cent", "copy", "curren", "deg", "divide",
|
||||
"eacute", "ecirc", "egrave", "euml", "frac12", "frac14", "frac34", "gt", "iacute", "icirc", "iexcl", "igrave", "iquest", "iuml", "laquo", "lt",
|
||||
"micro", "middot", "nbsp", "ntilde", "oacute", "ocirc", "ograve", "oslash", "otilde", "ouml", "para", "plusmn", "pound", "quot", "raquo", "reg",
|
||||
"sect", "shy", "sup1", "sup2", "sup3", "szlig", "times", "uacute", "ucirc", "ugrave", "uuml", "yacute", "yen", "yuml",
|
||||
];
|
||||
|
||||
/// A reference written without its `;` at the start of `after` (the text after `&`): a numeric one,
|
||||
/// or the longest legacy name. In attribute values a name followed by `=` or an alphanumeric stays text.
|
||||
fn bare_reference(after: &str, attr: bool) -> Option<(char, usize)> {
|
||||
if let Some(num) = after.strip_prefix('#') {
|
||||
let hex = num.starts_with(['x', 'X']);
|
||||
let radix = if hex { 16 } else { 10 };
|
||||
let skip = usize::from(hex);
|
||||
let n = num.get(skip..)?.bytes().take_while(|b| char::from(*b).is_digit(radix)).count();
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
let len = 1 + skip + n;
|
||||
return decode_char(after.get(..len)?).map(|c| (c, len));
|
||||
}
|
||||
// Legacy names are 2 to 6 ASCII alphanumerics: try the longest prefix first.
|
||||
let run = after.bytes().take(6).take_while(u8::is_ascii_alphanumeric).count();
|
||||
let len = (2..=run).rev().find(|&l| after.get(..l).is_some_and(|p| BARE_NAMES.binary_search(&p).is_ok()))?;
|
||||
let next = after.as_bytes().get(len);
|
||||
if attr && next.is_some_and(|b| b.is_ascii_alphanumeric() || *b == b'=') {
|
||||
return None;
|
||||
}
|
||||
decode_char(after.get(..len)?).map(|c| (c, len))
|
||||
}
|
||||
|
||||
/// Replace entity references in text `s`.
|
||||
pub fn unescape(s: &str) -> String {
|
||||
unescape_in(s, false)
|
||||
}
|
||||
|
||||
/// Replace entity references in an attribute value `s`.
|
||||
pub fn unescape_attr(s: &str) -> String {
|
||||
unescape_in(s, true)
|
||||
}
|
||||
|
||||
fn unescape_in(s: &str, attr: bool) -> String {
|
||||
if !s.contains('&') {
|
||||
return s.to_string();
|
||||
}
|
||||
@@ -248,10 +295,10 @@ pub fn unescape(s: &str) -> String {
|
||||
cut -= 1;
|
||||
}
|
||||
let end = after.get(..cut).and_then(|w| w.find(';'));
|
||||
match end.and_then(|e| decode_entity(&after[..e]).map(|t| (t, e))) {
|
||||
Some((t, e)) => {
|
||||
out.push_str(&t);
|
||||
rest = &after[e + 1..];
|
||||
match end.and_then(|e| decode_char(&after[..e]).map(|c| (c, e + 1))).or_else(|| bare_reference(after, attr)) {
|
||||
Some((c, e)) => {
|
||||
out.push(c);
|
||||
rest = &after[e..];
|
||||
}
|
||||
None => {
|
||||
out.push('&');
|
||||
@@ -310,3 +357,14 @@ pub fn decode_as(b: &[u8], charset: Option<&str>) -> String {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bare_names_are_sorted_and_known() {
|
||||
assert!(BARE_NAMES.windows(2).all(|w| w[0] < w[1]));
|
||||
assert!(BARE_NAMES.iter().all(|n| (2..=6).contains(&n.len()) && decode_char(n).is_some()));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::entity::unescape;
|
||||
use super::entity::{unescape, unescape_attr};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Tokenizer
|
||||
@@ -154,7 +154,7 @@ impl Lexer<'_> {
|
||||
Some(&q) if q == b'"' || q == b'\'' => {
|
||||
let v0 = i + 1;
|
||||
let e = rest.get(v0..).and_then(|r| r.find(q as char)).map(|k| v0 + k).unwrap_or(rest.len());
|
||||
val = unescape(rest.get(v0..e).unwrap_or(""));
|
||||
val = unescape_attr(rest.get(v0..e).unwrap_or(""));
|
||||
i = e + 1;
|
||||
}
|
||||
_ => {
|
||||
@@ -162,7 +162,7 @@ impl Lexer<'_> {
|
||||
while b.get(i).is_some_and(|c| !c.is_ascii_whitespace() && *c != b'>') {
|
||||
i += 1;
|
||||
}
|
||||
val = unescape(rest.get(v0..i).unwrap_or(""));
|
||||
val = unescape_attr(rest.get(v0..i).unwrap_or(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,6 +232,21 @@ fn entities() {
|
||||
assert_eq!(unescape("θ λ Δ ∇ ∑ √ ≠ ≤ ≥ ♥"), "θ λ Δ ∇ ∑ √ ≠ ≤ ≥ ♥");
|
||||
}
|
||||
|
||||
/// Browsers decode numeric and legacy named references without their `;` in text,
|
||||
/// but keep `&name=` and `&nameX` intact inside attribute values (query strings).
|
||||
#[test]
|
||||
fn references_without_semicolon() {
|
||||
assert_eq!(unescape("© 2024 Acme"), "© 2024 Acme");
|
||||
assert_eq!(unescape("© 2024 Acme"), "© 2024 Acme");
|
||||
assert_eq!(unescape("© 2024 & <"), "© 2024 & <");
|
||||
assert_eq!(unescape("&bogus &# &#x &"), "&bogus &# &#x &");
|
||||
assert_eq!(unescape(" x"), "\u{00A0}x");
|
||||
let f = parse("<p><a href=\"/q?a=1©=2®2=3&b=<\">l</a> © 2024</p>");
|
||||
let FBlock::Para(p) = &f.blocks[0] else { panic!() };
|
||||
assert!(p.inlines.iter().any(|i| matches!(i, Inline::Text(t, f) if t == "l" && f.link.as_deref() == Some("/q?a=1©=2®2=3&b=<"))));
|
||||
assert!(p.text().ends_with("© 2024"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wbr_word_break_opportunity() {
|
||||
let f = parse("<p>Super<wbr>cali<wbr>fragilistic</p>");
|
||||
|
||||
@@ -330,6 +330,18 @@ pub fn export(doc: &Document) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
/// A heading's text on one line, with a trailing run of '#' escaped so a reader does not take it
|
||||
/// for the optional closing sequence (`## Item #` reads back as "Item").
|
||||
fn heading_text(body: &str) -> String {
|
||||
let mut s = body.replace("\\\n", " ");
|
||||
let t = s.trim_end();
|
||||
let core = t.trim_end_matches('#');
|
||||
if core.len() != t.len() && (core.is_empty() || core.ends_with([' ', '\t'])) {
|
||||
s.insert(core.len(), '\\');
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
pub(super) fn write_blocks(blocks: &[FBlock], out: &mut Vec<String>) {
|
||||
let mut i = 0;
|
||||
let mut counters = [0u32; 9];
|
||||
@@ -410,8 +422,8 @@ pub(super) fn write_blocks(blocks: &[FBlock], out: &mut Vec<String>) {
|
||||
None => {
|
||||
prev_list = false;
|
||||
let line = match p.kind {
|
||||
Kind::Heading(n) => format!("{} {}", "#".repeat(n.clamp(1, 6) as usize), body.replace("\\\n", " ")),
|
||||
Kind::Title => format!("# {}", body.replace("\\\n", " ")),
|
||||
Kind::Heading(n) => format!("{} {}", "#".repeat(n.clamp(1, 6) as usize), heading_text(&body)),
|
||||
Kind::Title => format!("# {}", heading_text(&body)),
|
||||
Kind::Rule => "---".to_string(),
|
||||
Kind::Quote => {
|
||||
let b = escape_lines(body);
|
||||
|
||||
@@ -775,3 +775,25 @@ fn exported_link_destinations_read_back_unchanged() {
|
||||
assert_eq!(md("https://example.com/a b"), "[x](<https://example.com/a b>)");
|
||||
assert_eq!(md("https://e.com/x\ny"), "[x](https://e.com/xy)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_trailing_hash_survives_round_trip() {
|
||||
let heading = |md: &str| {
|
||||
let f = parse(md);
|
||||
let Some(FBlock::Para(p)) = f.blocks.first() else { panic!("no paragraph in {md:?}") };
|
||||
assert_eq!(p.kind, Kind::Heading(2), "{md:?}");
|
||||
p.text()
|
||||
};
|
||||
for (src, text, want) in [
|
||||
("## Item \\#\n", "Item #", "## Item \\#\n"),
|
||||
("## Issue \\##\n", "Issue ##", "## Issue \\##\n"),
|
||||
("## \\#\n", "#", "## \\#\n"),
|
||||
("## C#\n", "C#", "## C#\n"),
|
||||
] {
|
||||
assert_eq!(heading(src), text, "{src:?}");
|
||||
let md = export(&import(src));
|
||||
assert_eq!(md, want, "first export of {src:?}");
|
||||
assert_eq!(heading(&md), text, "md was {md:?}");
|
||||
assert_eq!(export(&import(&md)), want, "second export of {src:?}");
|
||||
}
|
||||
}
|
||||
@@ -1122,6 +1122,7 @@ fn flow_table(doc: &Document, t: &Table, depth: usize, tracked: FlowOpts) -> FTa
|
||||
width: t.props.width,
|
||||
width_pct: t.props.width_pct,
|
||||
heights,
|
||||
keep_rows: t.rows.iter().map(|r| r.props.cant_split).collect(),
|
||||
fixed: t.props.fixed,
|
||||
space: [t.props.space_before, t.props.space_after],
|
||||
header_rows: t.rows.iter().take_while(|r| r.props.header).count(),
|
||||
|
||||
@@ -844,6 +844,7 @@ impl<'a> Builder<'a> {
|
||||
row.props.height = Some(h.min(1584.0));
|
||||
row.props.height_rule = if exact { wordcraft_doc::props::HeightRule::Exact } else { wordcraft_doc::props::HeightRule::AtLeast };
|
||||
}
|
||||
row.props.cant_split = t.extra.as_ref().is_some_and(|x| x.keep_rows.get(ri) == Some(&true));
|
||||
let mut g = 0usize;
|
||||
for c in r.iter().take(wordcraft_doc::table::MAX_COLS) {
|
||||
let span = (c.colspan.clamp(1, 63) as usize).min(cols.saturating_sub(g).max(1));
|
||||
|
||||
@@ -943,6 +943,8 @@ pub struct TableExtra {
|
||||
pub width_pct: Option<f32>,
|
||||
/// Each row's height (points) and whether it is exact (else at least), by row.
|
||||
pub heights: Vec<Option<(f32, bool)>>,
|
||||
/// Whether each row keeps from breaking across pages, by row.
|
||||
pub keep_rows: Vec<bool>,
|
||||
/// Its columns keep their widths, never fitted to their content (Word's fixed layout,
|
||||
/// `w:tblLayout`): an ODT table's always do.
|
||||
pub fixed: bool,
|
||||
|
||||
@@ -1391,19 +1391,23 @@ impl Body<'_> {
|
||||
// LibreOffice's tables keep their columns' widths, whatever their content (and however
|
||||
// far past the margins): Word's fixed layout.
|
||||
let space = get(a, "table:style-name").map(|s| self.styles.table_space(s)).unwrap_or_default();
|
||||
let extra = model::TableExtra { align, width: None, width_pct, heights: Vec::new(), fixed: true, space, header_rows: 0 };
|
||||
let extra =
|
||||
model::TableExtra { align, width: None, width_pct, heights: Vec::new(), keep_rows: Vec::new(), fixed: true, space, header_rows: 0 };
|
||||
self.tables.push(TableB { rows: Vec::new(), row: Vec::new(), hcover: 0, widths: Vec::new(), extra });
|
||||
}
|
||||
|
||||
/// A table row (`table:table-row`) begins: its height, kept as rows are (see the row's end).
|
||||
fn start_row(&mut self, a: &Attrs) {
|
||||
let height = get(a, "table:style-name").and_then(|s| self.styles.row_height(s));
|
||||
let keep = self.styles.row_keep(get(a, "table:style-name"));
|
||||
if let Some(t) = self.tables.last_mut() {
|
||||
t.row.clear();
|
||||
t.hcover = 0;
|
||||
if t.extra.heights.len() < wordcraft_doc::table::MAX_ROWS {
|
||||
t.extra.heights.truncate(t.rows.len());
|
||||
t.extra.heights.push(height);
|
||||
t.extra.keep_rows.truncate(t.rows.len());
|
||||
t.extra.keep_rows.push(keep);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,11 @@ impl TableB {
|
||||
let stated = rows.iter().flatten().filter(|c| !c.covered).all(|c| c.borders.is_some());
|
||||
let borderless = stated && rows.iter().flatten().all(|c| !c.borders.is_some_and(|b| b.any_visible()));
|
||||
let extra = self.extra;
|
||||
let kept = extra.fixed || extra.align.is_some() || extra.width_pct.is_some() || extra.heights.iter().any(Option::is_some);
|
||||
let kept = extra.fixed
|
||||
|| extra.align.is_some()
|
||||
|| extra.width_pct.is_some()
|
||||
|| extra.heights.iter().any(Option::is_some)
|
||||
|| extra.keep_rows.contains(&true);
|
||||
FTable { rows, widths, borderless, extra: kept.then(|| Box::new(extra)), kept: None, wrap: None }
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ pub(super) fn defs(xml: &[u8], content: bool) -> Vec<(String, Arc<str>)> {
|
||||
let mut path: Vec<String> = Vec::new();
|
||||
let mut open: Option<(String, usize, usize)> = None;
|
||||
let mut before_1_3 = false;
|
||||
let mut prefixes = std::collections::BTreeSet::new();
|
||||
events_at(xml, |ev, at| match ev {
|
||||
Ev::Start(q, a) => {
|
||||
path.push(q.clone());
|
||||
@@ -39,11 +40,23 @@ pub(super) fn defs(xml: &[u8], content: bool) -> Vec<(String, Arc<str>)> {
|
||||
if let Some(p) = k.strip_prefix("xmlns:").filter(|p| !p.is_empty() && p.len() <= 64)
|
||||
&& out.len() < MAX_DEFS
|
||||
{
|
||||
prefixes.insert(p.to_string());
|
||||
out.push((format!("ns:{p}"), Arc::from(v.as_str())));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Those a part's container declares too: what is kept of it leaves the container behind.
|
||||
if depth == 2 {
|
||||
for (k, v) in &a {
|
||||
if let Some(p) = k.strip_prefix("xmlns:").filter(|p| !p.is_empty() && p.len() <= 64)
|
||||
&& out.len() < MAX_DEFS
|
||||
&& prefixes.insert(p.to_string())
|
||||
{
|
||||
out.push((format!("ns:{p}"), Arc::from(v.as_str())));
|
||||
}
|
||||
}
|
||||
}
|
||||
// A reference mark (§6.6.2), which `text:reference-ref` names: WordCraft reads it as a
|
||||
// bookmark, and writes it as both.
|
||||
if content
|
||||
@@ -183,20 +196,41 @@ fn space(q: &str, family: &str) -> String {
|
||||
pub(super) fn defined(xml: &str, def: &str) -> bool {
|
||||
let head = def.split('>').next().unwrap_or("");
|
||||
let el = head.trim_start_matches('<').split([' ', '/', '\t', '\n', '\r']).next().unwrap_or("");
|
||||
let name = ["style:name=\"", "draw:name=\""].into_iter().find_map(|a| {
|
||||
let at = head.find(a)? + a.len();
|
||||
let len = head.get(at..)?.find('"')?;
|
||||
head.get(at - a.len()..at + len + 1)
|
||||
});
|
||||
let (Some(name), false) = (name, el.is_empty()) else { return false };
|
||||
let name = ["style:name", "draw:name"].into_iter().find_map(|a| Some((a, raw_attr(head, a)?)));
|
||||
let (Some((attr, name)), false) = (name, el.is_empty()) else { return false };
|
||||
let open = format!("<{el} ");
|
||||
xml.match_indices(&open).any(|(i, _)| xml.get(i..).and_then(|t| t.split('>').next()).is_some_and(|tag| tag.contains(name)))
|
||||
xml.match_indices(&open).any(|(i, _)| {
|
||||
xml.get(i..).and_then(|t| t.split('>').next()).is_some_and(|tag| raw_attr(tag, attr).is_some_and(|n| attr_text(n) == attr_text(name)))
|
||||
})
|
||||
}
|
||||
|
||||
/// An attribute's value as it reads, however XML spells it (`O'Brien`, `O'Brien`).
|
||||
pub(super) fn attr_text(raw: &str) -> std::borrow::Cow<'_, str> {
|
||||
quick_xml::escape::unescape(raw).unwrap_or(std::borrow::Cow::Borrowed(raw))
|
||||
}
|
||||
|
||||
/// Attribute `name`'s value as `tag` (a start tag) writes it, spaced and quoted however XML allows.
|
||||
pub(super) fn raw_attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
|
||||
let past_space = |at: usize| Some(at + tag.get(at..)?.find(|c: char| !c.is_ascii_whitespace())?);
|
||||
let mut at = tag.find(|c: char| c.is_ascii_whitespace())?;
|
||||
loop {
|
||||
let name_at = past_space(at)?;
|
||||
let eq = name_at + tag.get(name_at..)?.find('=')?;
|
||||
let value_at = past_space(eq + 1)?;
|
||||
let rest = tag.get(value_at..)?;
|
||||
let quote = rest.chars().next().filter(|c| matches!(c, '"' | '\''))?;
|
||||
let len = rest.get(1..)?.find(quote)?;
|
||||
if tag.get(name_at..eq)?.trim_end() == name {
|
||||
return rest.get(1..1 + len);
|
||||
}
|
||||
at = value_at + 1 + len + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// The children of `container` (a child of the root) in `xml` (a part WordCraft wrote): each
|
||||
/// one's key in `group` (as [`defs`] gives it) and its markup's byte range; and the range of
|
||||
/// the container's content (`None` when there is no such container).
|
||||
fn children(xml: &str, container: &str, group: &str) -> (Vec<(String, (usize, usize))>, Option<(usize, usize)>) {
|
||||
pub(super) fn children(xml: &str, container: &str, group: &str) -> (Vec<(String, (usize, usize))>, Option<(usize, usize)>) {
|
||||
let mut out = Vec::new();
|
||||
let (mut depth, mut inside) = (0usize, false);
|
||||
let mut open: Option<(String, usize)> = None;
|
||||
|
||||
@@ -900,6 +900,11 @@ fn with_kept_defs(
|
||||
};
|
||||
add(&mut content, "</office:automatic-styles>", auto);
|
||||
add(&mut styles, "</office:styles>", common);
|
||||
// A font the file declares is declared as the file does: a reader without the font picks its
|
||||
// substitute by this, and a different one would reflow every page.
|
||||
let faces: Vec<&str> = defs.iter().filter(|(k, _)| k.starts_with("font:")).map(|(_, d)| &**d).collect();
|
||||
with_file_faces(&mut content, &faces);
|
||||
with_file_faces(&mut styles, &faces);
|
||||
for (xml, before) in [(&mut content, "<office:automatic-styles"), (&mut styles, "<office:styles")] {
|
||||
// No declarations yet: the element they go in, where it belongs (before the styles).
|
||||
if !fonts.is_empty()
|
||||
@@ -913,6 +918,54 @@ fn with_kept_defs(
|
||||
(content, styles)
|
||||
}
|
||||
|
||||
/// `xml` (a part WordCraft wrote) with each font it declares that `faces` declare too (the file's
|
||||
/// own `style:font-face` elements) as `faces` has it: under that name, or else of that family (a
|
||||
/// face named `F1` of the family `Mystery`, which WordCraft names by its family), then keeping
|
||||
/// WordCraft's name for it.
|
||||
fn with_file_faces(xml: &mut String, faces: &[&str]) {
|
||||
use super::kept::{attr_text, raw_attr};
|
||||
let mut named = std::collections::HashMap::new();
|
||||
let mut of_family = std::collections::HashMap::new();
|
||||
for face in faces {
|
||||
let Some(head) = face.split('>').next() else { continue };
|
||||
if let Some(name) = raw_attr(head, "style:name") {
|
||||
named.entry(attr_text(name).into_owned()).or_insert(*face);
|
||||
}
|
||||
if let Some(family) = raw_attr(head, "svg:font-family") {
|
||||
let family = attr_text(family);
|
||||
let first = family.split(',').next().unwrap_or("").trim().trim_matches(['\'', '"']);
|
||||
if let Some(font) = model::kept_font(first) {
|
||||
of_family.entry(font).or_insert(*face);
|
||||
}
|
||||
}
|
||||
}
|
||||
// WordCraft's own declarations are empty elements.
|
||||
let (kids, _) = super::kept::children(xml, "office:font-face-decls", "font");
|
||||
let mut out = String::with_capacity(xml.len());
|
||||
let mut done = 0;
|
||||
for (_, (from, to)) in kids {
|
||||
let (Some(before), Some(tag)) = (xml.get(done..from), xml.get(from..to)) else { continue };
|
||||
out.push_str(before);
|
||||
let name = raw_attr(tag, "style:name").map(attr_text);
|
||||
let same = name.as_deref().and_then(|n| named.get(n)).map(|face| face.to_string());
|
||||
let family = || name.as_deref().and_then(|n| of_family.get(n).and_then(|face| face_named(face, n)));
|
||||
let empty = tag.find('>').is_some_and(|end| end + 1 == tag.len());
|
||||
out.push_str(same.or_else(family).filter(|_| empty).as_deref().unwrap_or(tag));
|
||||
done = to;
|
||||
}
|
||||
out.push_str(xml.get(done..).unwrap_or(""));
|
||||
*xml = out;
|
||||
}
|
||||
|
||||
/// `face` (a `style:font-face` element) with the name `name`.
|
||||
fn face_named(face: &str, name: &str) -> Option<String> {
|
||||
let head = face.split('>').next()?;
|
||||
let was = super::kept::raw_attr(head, "style:name")?;
|
||||
// `was` is a piece of `head`, which starts `face`.
|
||||
let at = (was.as_ptr() as usize).checked_sub(head.as_ptr() as usize)?;
|
||||
Some(format!("{}{}{}", face.get(..at)?, x(name), face.get(at + was.len()..)?))
|
||||
}
|
||||
|
||||
/// An OpenDocument text document's media type, and a template's (ODF 1.3 Part 2 §3.3, Appendix C).
|
||||
pub const TEXT_MIME: &str = "application/vnd.oasis.opendocument.text";
|
||||
pub const TEMPLATE_MIME: &str = "application/vnd.oasis.opendocument.text-template";
|
||||
|
||||
@@ -252,6 +252,8 @@ struct StyleInfo {
|
||||
/// A table-row style's `style:table-row-properties`: its height (`style:row-height`, exact)
|
||||
/// or least height (`style:min-row-height`), points.
|
||||
row_height: Option<(f32, bool)>,
|
||||
/// `fo:keep-together` of a table-row style: the row doesn't break across pages.
|
||||
row_keep: Option<bool>,
|
||||
/// `style:master-page-name` of a paragraph or table style: its paragraphs (tables) start a
|
||||
/// page of that master page. Empty: explicitly none, which stops the lookup up the chain.
|
||||
master_page: Option<String>,
|
||||
@@ -623,6 +625,9 @@ fn style_child(name: &str, q: &str, a: &Attrs, s: &mut StyleInfo) -> bool {
|
||||
} else if let Some(h) = get(a, "style:min-row-height").and_then(length).filter(|h| h.is_finite() && *h > 0.0) {
|
||||
s.row_height = Some((h.min(MAX_LEN), false));
|
||||
}
|
||||
if let Some(k) = get(a, "fo:keep-together") {
|
||||
s.row_keep = Some(k.trim() == "always");
|
||||
}
|
||||
}
|
||||
"table-column-properties" => {
|
||||
if let Some(w) = get(a, "style:column-width").and_then(length) {
|
||||
@@ -1043,6 +1048,8 @@ pub(super) struct Styles {
|
||||
default_color: Option<wordcraft_doc::props::Rgb>,
|
||||
/// `style:default-style` for graphics: what every drawing shape's style falls back on.
|
||||
default_graphic: super::frames::Graphic,
|
||||
/// `style:default-style` for table rows: `fo:keep-together`, for a row whose style doesn't say.
|
||||
default_row_keep: Option<bool>,
|
||||
/// The named dashes and gradients graphic styles use.
|
||||
pub(super) defs: super::strokes::Defs,
|
||||
/// Date and time styles (`number:date-style`, `number:time-style`) as Word pictures.
|
||||
@@ -1055,6 +1062,7 @@ impl Styles {
|
||||
let mut cur: Option<(String, StyleInfo)> = None;
|
||||
let mut in_default = false;
|
||||
let mut in_graphic_default = false;
|
||||
let mut in_row_default = false;
|
||||
let mut dates = DateStyles::default();
|
||||
let mut list: Option<(String, Vec<ListLevel>)> = None;
|
||||
// The list or outline level being read.
|
||||
@@ -1083,8 +1091,12 @@ impl Styles {
|
||||
"default-style" if q.starts_with("style:") => {
|
||||
in_default = get(&a, "style:family") == Some("paragraph");
|
||||
in_graphic_default = get(&a, "style:family") == Some("graphic");
|
||||
in_row_default = get(&a, "style:family") == Some("table-row");
|
||||
}
|
||||
"graphic-properties" if in_graphic_default && cur.is_none() => self.default_graphic = super::frames::Graphic::read(&a),
|
||||
"table-row-properties" if in_row_default && cur.is_none() => {
|
||||
self.default_row_keep = get(&a, "fo:keep-together").map(|k| k.trim() == "always")
|
||||
}
|
||||
"text-properties" if in_default && cur.is_none() => {
|
||||
self.default_font = default_font(&a, &self.faces);
|
||||
if let Some(sz) = get(&a, "fo:font-size").and_then(length).filter(|v| v.is_finite() && *v >= 1.0) {
|
||||
@@ -1151,7 +1163,7 @@ impl Styles {
|
||||
},
|
||||
Ev::End(q) => match local(&q) {
|
||||
"automatic-styles" => in_auto = false,
|
||||
"default-style" if q.starts_with("style:") => (in_default, in_graphic_default) = (false, false),
|
||||
"default-style" if q.starts_with("style:") => (in_default, in_graphic_default, in_row_default) = (false, false, false),
|
||||
"style" if q.starts_with("style:") => {
|
||||
if let Some((n, s)) = cur.take()
|
||||
&& self.map.len() < 100_000
|
||||
@@ -1355,6 +1367,12 @@ impl Styles {
|
||||
self.chain(name).iter().find_map(|(_, s)| s.row_height)
|
||||
}
|
||||
|
||||
/// Whether a table row doesn't break across pages: its style says (whole chain), else the
|
||||
/// default style.
|
||||
pub(super) fn row_keep(&self, name: Option<&str>) -> bool {
|
||||
name.and_then(|n| self.chain(n).iter().find_map(|(_, s)| s.row_keep)).or(self.default_row_keep).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Background colour of a table-cell style (whole chain).
|
||||
/// A table-cell style's background picture: its package path, repeat and position.
|
||||
pub(super) fn cell_picture(&self, name: &str) -> Option<(String, String, String)> {
|
||||
|
||||
@@ -159,3 +159,75 @@ fn a_table_starting_a_page_starts_it() {
|
||||
let c = content_of(&crate::odt::tests::export_model(&d).unwrap());
|
||||
assert_eq!(c.matches(r#"fo:break-before="page""#).count(), 2, "{c}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_that_keeps_together_says_so() {
|
||||
// Table › Text Flow: a row not to break across pages. It came back from a save free to break.
|
||||
let styles = r#"<style:style style:name="R" style:family="table-row"><style:table-row-properties fo:keep-together="always"/></style:style><style:style style:name="H" style:family="table-row"><style:table-row-properties style:min-row-height="1in" fo:keep-together="always"/></style:style>"#;
|
||||
let row = |s: &str, t: &str| format!(r#"<table:table-row{s}><table:table-cell><text:p>{t}</text:p></table:table-cell></table:table-row>"#);
|
||||
let table = format!(
|
||||
"<table:table>{}{}{}</table:table>",
|
||||
row(r#" table:style-name="R""#, "kept"),
|
||||
row("", "free"),
|
||||
row(r#" table:style-name="H""#, "kept and tall")
|
||||
);
|
||||
let shape = |d: &wordcraft_doc::Document| {
|
||||
d.body
|
||||
.iter()
|
||||
.find_map(|b| b.as_table())
|
||||
.map(|t| t.rows.iter().map(|r| (r.props.cant_split, r.props.height)).collect::<Vec<_>>())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let d = doc(styles, &table);
|
||||
assert_eq!(shape(&d), [(true, None), (false, None), (true, Some(72.0))]);
|
||||
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||
assert!(content_of(&saved).contains(r#"fo:keep-together="always""#));
|
||||
assert_eq!(shape(&import(&saved).unwrap()), [(true, None), (false, None), (true, Some(72.0))]);
|
||||
}
|
||||
|
||||
/// A row whose style says nothing of it, or a row with no style, follows the default style; one
|
||||
/// that says otherwise doesn't.
|
||||
#[test]
|
||||
fn a_rows_keep_together_defaults_to_the_default_style() {
|
||||
let styles = r#"<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"><office:styles><style:default-style style:family="table-row"><style:table-row-properties fo:keep-together="always"/></style:default-style></office:styles></office:document-styles>"#;
|
||||
let row = |s: &str| format!(r#"<table:table-row{s}><table:table-cell><text:p>x</text:p></table:table-cell></table:table-row>"#);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="H" style:family="table-row"><style:table-row-properties style:min-row-height="1in"/></style:style><style:style style:name="F" style:family="table-row"><style:table-row-properties fo:keep-together="auto"/></style:style></office:automatic-styles><office:body><office:text><table:table>{}{}{}</table:table></office:text></office:body></office:document-content>"#,
|
||||
row(""),
|
||||
row(r#" table:style-name="H""#),
|
||||
row(r#" table:style-name="F""#)
|
||||
);
|
||||
let keeps = |d: &wordcraft_doc::Document| {
|
||||
d.body.iter().find_map(|b| b.as_table()).map(|t| t.rows.iter().map(|r| r.props.cant_split).collect::<Vec<_>>()).unwrap_or_default()
|
||||
};
|
||||
let d = import(&package(&content, Some(styles))).unwrap();
|
||||
assert_eq!(keeps(&d), [true, true, false]);
|
||||
assert_eq!(keeps(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap()), [true, true, false]);
|
||||
}
|
||||
|
||||
/// A row free to break under a default row style that keeps rows together says so when written from
|
||||
/// the model, or a save takes that away: the file's default style stays.
|
||||
#[test]
|
||||
fn a_row_free_to_break_stays_so_over_the_default_style() {
|
||||
let styles = r#"<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"><office:styles><style:default-style style:family="table-row"><style:table-row-properties fo:keep-together="always"/></style:default-style></office:styles></office:document-styles>"#;
|
||||
let row = |s: &str| format!(r#"<table:table-row{s}><table:table-cell><text:p>x</text:p></table:table-cell></table:table-row>"#);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="F" style:family="table-row"><style:table-row-properties fo:keep-together="auto"/></style:style></office:automatic-styles><office:body><office:text><table:table><table:table-column/>{}{}</table:table></office:text></office:body></office:document-content>"#,
|
||||
row(r#" table:style-name="F""#),
|
||||
row("")
|
||||
);
|
||||
let mut d = import(&package(&content, Some(styles))).unwrap();
|
||||
let keeps = |d: &wordcraft_doc::Document| {
|
||||
d.body.iter().find_map(|b| b.as_table()).map(|t| t.rows.iter().map(|r| r.props.cant_split).collect::<Vec<_>>()).unwrap_or_default()
|
||||
};
|
||||
assert_eq!(keeps(&d), [false, true]);
|
||||
if let Some(wordcraft_doc::Block::Table(t)) = d.body.get_mut(0).map(std::sync::Arc::make_mut)
|
||||
&& let Some(wordcraft_doc::Block::Para(p)) = t.rows[0].cells[0].blocks.get_mut(0).map(std::sync::Arc::make_mut)
|
||||
{
|
||||
p.text.push('!');
|
||||
if let Some(r) = p.runs.last_mut() {
|
||||
r.len += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(keeps(&import(&export(&d).unwrap()).unwrap()), [false, true], "after an edit and a save");
|
||||
}
|
||||
@@ -287,6 +287,97 @@ fn a_watermark_is_a_shape_in_the_header_and_reads_back_as_one() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A watermark added, changed or removed in WordCraft reaches a file's page definitions.
|
||||
#[test]
|
||||
fn a_watermark_change_to_a_file_is_saved() {
|
||||
// A file's own page definitions were written back whatever happened to its watermark, so one
|
||||
// added, changed or removed in WordCraft was gone from the saved file.
|
||||
let ns = crate::odt::tests::NS_T;
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {ns}><office:automatic-styles><style:page-layout style:name="pm1" style:page-usage="mirrored"><style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm" fo:margin-top="2cm" fo:margin-bottom="2cm" fo:margin-left="2cm" fo:margin-right="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm1"/></office:master-styles></office:document-styles>"#
|
||||
);
|
||||
let content =
|
||||
format!("<office:document-content {ns}><office:body><office:text><text:p>x</text:p></office:text></office:body></office:document-content>");
|
||||
let plain = crate::odt::tests::package(&content, Some(&styles));
|
||||
let styles_of = |d: &Document| crate::odt::tests::entry(&export(d).unwrap(), "styles.xml");
|
||||
let shape = "PowerPlusWaterMarkObject";
|
||||
|
||||
// Added.
|
||||
let mut d = import(&plain).unwrap();
|
||||
d.settings.watermark = Some(wordcraft_doc::Watermark { text: "DRAFT".into(), ..Default::default() });
|
||||
let saved = export(&d).unwrap();
|
||||
let s = crate::odt::tests::entry(&saved, "styles.xml");
|
||||
assert!(s.contains(shape) && s.contains("DRAFT"), "added: {s}");
|
||||
assert_eq!(import(&saved).unwrap().settings.watermark.map(|w| w.text).as_deref(), Some("DRAFT"));
|
||||
|
||||
// Saved again with nothing changed, it stays as the file has it.
|
||||
let read = import(&saved).unwrap();
|
||||
let again = styles_of(&read);
|
||||
assert_eq!(again.matches(shape).count(), s.matches(shape).count(), "{again}");
|
||||
|
||||
// Changed.
|
||||
let mut d = import(&saved).unwrap();
|
||||
if let Some(w) = d.settings.watermark.as_mut() {
|
||||
w.text = "COPY".into();
|
||||
}
|
||||
let s = styles_of(&d);
|
||||
assert!(s.contains("COPY") && !s.contains("DRAFT"), "changed: {s}");
|
||||
|
||||
// Removed.
|
||||
let mut d = import(&saved).unwrap();
|
||||
d.settings.watermark = None;
|
||||
let s = styles_of(&d);
|
||||
assert!(!s.contains(shape) && !s.contains("DRAFT"), "removed: {s}");
|
||||
|
||||
// One with no text isn't written, so it's no change to the file's page definitions.
|
||||
let mut d = import(&plain).unwrap();
|
||||
d.settings.watermark = Some(wordcraft_doc::Watermark { text: " ".into(), ..Default::default() });
|
||||
let s = styles_of(&d);
|
||||
assert!(s.contains(r#"style:page-usage="mirrored""#) && !s.contains(shape), "no text: {s}");
|
||||
}
|
||||
|
||||
/// Different Odd & Even Pages turned on or off in WordCraft reaches a file's page definitions.
|
||||
#[test]
|
||||
fn different_odd_and_even_pages_changed_in_a_file_is_saved() {
|
||||
// A file's own page definitions were written back whatever happened to this setting, so the
|
||||
// left pages' header stayed after it was turned off.
|
||||
let ns = crate::odt::tests::NS_T;
|
||||
let file = |left: &str| {
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {ns}><office:automatic-styles><style:page-layout style:name="pm1" style:page-usage="mirrored"><style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm" fo:margin-top="2cm" fo:margin-bottom="2cm" fo:margin-left="2cm" fo:margin-right="2cm"/><style:header-style><style:header-footer-properties fo:min-height="1cm"/></style:header-style></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm1"><style:header><text:p>Right head</text:p></style:header>{left}</style:master-page></office:master-styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
"<office:document-content {ns}><office:body><office:text><text:p>x</text:p></office:text></office:body></office:document-content>"
|
||||
);
|
||||
crate::odt::tests::package(&content, Some(&styles))
|
||||
};
|
||||
let styles_of = |d: &Document| crate::odt::tests::entry(&export(d).unwrap(), "styles.xml");
|
||||
|
||||
// On in the file, and nothing changed: the file's own.
|
||||
let both = file("<style:header-left><text:p>Left head</text:p></style:header-left>");
|
||||
let d = import(&both).unwrap();
|
||||
assert!(d.settings.even_odd_headers);
|
||||
let s = styles_of(&d);
|
||||
assert!(s.contains(r#"style:page-usage="mirrored""#) && s.contains("Left head"), "as read: {s}");
|
||||
|
||||
// Turned off.
|
||||
let mut d = import(&both).unwrap();
|
||||
d.settings.even_odd_headers = false;
|
||||
let saved = export(&d).unwrap();
|
||||
let s = crate::odt::tests::entry(&saved, "styles.xml");
|
||||
assert!(!s.contains("<style:header-left") && s.contains("Right head"), "off: {s}");
|
||||
assert!(!import(&saved).unwrap().settings.even_odd_headers);
|
||||
|
||||
// Off in the file, and turned on: left pages have their own header, an empty one so far.
|
||||
let mut d = import(&file("")).unwrap();
|
||||
assert!(!d.settings.even_odd_headers);
|
||||
d.settings.even_odd_headers = true;
|
||||
let saved = export(&d).unwrap();
|
||||
let s = crate::odt::tests::entry(&saved, "styles.xml");
|
||||
assert!(s.contains("<style:header-left") && s.contains("Right head"), "on: {s}");
|
||||
assert!(import(&saved).unwrap().settings.even_odd_headers);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn libreoffices_watermark_reads_as_the_watermark() {
|
||||
// As LibreOffice's Format › Watermark writes it (observed): turned, half see-through.
|
||||
|
||||
@@ -159,3 +159,161 @@ fn a_file_a_kept_definition_names_is_written_with_it() {
|
||||
let m = crate::odt::tests::entry(&saved, "META-INF/manifest.xml");
|
||||
assert!(m.contains(r#"manifest:full-path="Pictures/paper.png""#), "{m}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_files_font_declarations_are_kept() {
|
||||
// A reader without a font picks its substitute by how the file declares it. WordCraft's own
|
||||
// guess at a font's kind replaced the file's, and a document came back from a save with no
|
||||
// edits 13 pages shorter in LibreOffice on a computer without its font.
|
||||
let ns = NS_T;
|
||||
let face = r#"<style:font-face style:name="Odd Roman" svg:font-family="'Odd Roman'" style:font-family-generic="roman"/>"#;
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {ns}><office:font-face-decls>{face}</office:font-face-decls><office:styles><style:default-style style:family="paragraph"><style:text-properties style:font-name="Odd Roman" fo:font-size="12pt"/></style:default-style></office:styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {ns}><office:font-face-decls>{face}</office:font-face-decls><office:automatic-styles><style:style style:name="P1" style:family="paragraph"><style:text-properties style:font-name="Odd Roman"/></style:style></office:automatic-styles><office:body><office:text><text:p text:style-name="P1">Text</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let check = |saved: &[u8], what: &str| {
|
||||
for part in [styles_of(saved), content_of(saved)] {
|
||||
assert!(part.contains(face), "{what}: {part}");
|
||||
assert_eq!(part.matches(r#"<style:font-face style:name="Odd Roman""#).count(), 1, "{what}: declared once: {part}");
|
||||
}
|
||||
};
|
||||
let mut d = import(&package(&content, Some(&styles))).unwrap();
|
||||
check(&export(&d).unwrap(), "no edits");
|
||||
// Text edited, written from the model: still the file's declaration.
|
||||
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) {
|
||||
p.text.push('!');
|
||||
if let Some(r) = p.runs.last_mut() {
|
||||
r.len += 1;
|
||||
}
|
||||
}
|
||||
check(&export(&d).unwrap(), "an edit");
|
||||
}
|
||||
|
||||
/// A font's name spelled another way in XML (`O'Brien`, `O'Brien`) is the same font: its
|
||||
/// declaration is the file's, once.
|
||||
#[test]
|
||||
fn a_fonts_name_spelled_another_way_is_declared_once_as_the_file_has_it() {
|
||||
for spelled in ["O'Brien", "O'Brien"] {
|
||||
let face = format!(r#"<style:font-face style:name="{spelled}" svg:font-family="O'Brien" style:font-family-generic="roman"/>"#);
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:font-face-decls>{face}</office:font-face-decls><office:styles><style:default-style style:family="paragraph"><style:text-properties style:font-name="{spelled}" fo:font-size="12pt"/></style:default-style></office:styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:font-face-decls>{face}</office:font-face-decls><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties style:font-name="{spelled}"/></style:style></office:automatic-styles><office:body><office:text><text:p><text:span text:style-name="T1">One</text:span></text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let mut d = import(&package(&content, Some(&styles))).unwrap();
|
||||
// Text edited, written from the model.
|
||||
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) {
|
||||
p.text.push('!');
|
||||
if let Some(r) = p.runs.last_mut() {
|
||||
r.len += 1;
|
||||
}
|
||||
}
|
||||
let saved = export(&d).unwrap();
|
||||
for part in [styles_of(&saved), content_of(&saved)] {
|
||||
assert!(part.contains(&face), "{spelled}: the file's: {part}");
|
||||
let is_it = |t: &&str| {
|
||||
let name = t.split('>').next().and_then(|tag| tag.split("style:name=\"").nth(1)).and_then(|v| v.split('"').next());
|
||||
name.is_some_and(|n| n == "O'Brien" || n == "O'Brien" || n == "O'Brien")
|
||||
};
|
||||
assert_eq!(part.split("<style:font-face ").skip(1).filter(is_it).count(), 1, "{spelled}: declared once: {part}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A face named other than its family (`F1`, `Mystery`): text written from the model names the
|
||||
/// family, and that declaration says what the file's does, not WordCraft's guess at the font's kind.
|
||||
#[test]
|
||||
fn a_face_named_other_than_its_family_gives_the_family_the_files_declaration() {
|
||||
let face =
|
||||
r#"<style:font-face style:name="F1" svg:font-family="'Mystery'" style:font-family-generic="roman" style:font-pitch="variable"/>"#;
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:font-face-decls>{face}</office:font-face-decls><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="12pt"/></style:default-style></office:styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:font-face-decls>{face}</office:font-face-decls><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties style:font-name="F1"/></style:style></office:automatic-styles><office:body><office:text><text:p><text:span text:style-name="T1">One</text:span></text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let mut d = import(&package(&content, Some(&styles))).unwrap();
|
||||
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) {
|
||||
p.text.push('!');
|
||||
if let Some(r) = p.runs.last_mut() {
|
||||
r.len += 1;
|
||||
}
|
||||
}
|
||||
let saved = export(&d).unwrap();
|
||||
let part = content_of(&saved);
|
||||
assert!(part.contains(r#"style:font-name="Mystery""#), "{part}");
|
||||
let mystery = part.split("<style:font-face ").find(|t| t.starts_with(r#"style:name="Mystery""#)).and_then(|t| t.split("/>").next());
|
||||
let mystery = mystery.unwrap_or_else(|| panic!("declared: {part}"));
|
||||
assert!(mystery.contains(r#"style:font-family-generic="roman""#), "{mystery}");
|
||||
}
|
||||
|
||||
/// Only declarations are declarations: not a script's text that reads like one. And a face's name
|
||||
/// is its own attribute, not another with `style:name` in its name.
|
||||
#[test]
|
||||
fn a_face_is_found_and_named_by_its_own_markup() {
|
||||
let script =
|
||||
r#"<office:scripts><office:script script:language="x"><![CDATA[<style:font-face style:name="Mystery" />]]></office:script></office:scripts>"#;
|
||||
let face = r#"<style:font-face xstyle:name="F1" style:name="F1" svg:font-family="'Mystery'" style:font-family-generic="roman"/>"#;
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:font-face-decls>{face}</office:font-face-decls><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="12pt"/></style:default-style></office:styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}>{script}<office:font-face-decls>{face}</office:font-face-decls><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties style:font-name="F1"/></style:style></office:automatic-styles><office:body><office:text><text:p><text:span text:style-name="T1">One</text:span></text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let mut d = import(&package(&content, Some(&styles))).unwrap();
|
||||
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) {
|
||||
p.text.push('!');
|
||||
if let Some(r) = p.runs.last_mut() {
|
||||
r.len += 1;
|
||||
}
|
||||
}
|
||||
let saved = export(&d).unwrap();
|
||||
let part = content_of(&saved);
|
||||
assert!(part.contains(script), "the script as it was: {part}");
|
||||
let decls = part.split("<office:font-face-decls>").last().and_then(|t| t.split("</office:font-face-decls>").next()).unwrap();
|
||||
assert!(decls.contains(r#"xstyle:name="F1" style:name="Mystery""#), "{decls}");
|
||||
assert!(import(&saved).is_ok());
|
||||
}
|
||||
|
||||
/// A font declaration in single quotes is the file's too: it replaces WordCraft's, and isn't
|
||||
/// declared a second time.
|
||||
#[test]
|
||||
fn a_font_declaration_in_single_quotes_is_kept() {
|
||||
let ns = format!(r#"{NS_T} xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0""#);
|
||||
let face = r#"<style:font-face style:name='Odd Roman' svg:font-family='Odd Roman' style:font-family-generic='roman'/>"#;
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {ns}><office:font-face-decls>{face}</office:font-face-decls><office:styles><style:default-style style:family="paragraph"><style:text-properties style:font-name="Odd Roman" fo:font-size="12pt"/></style:default-style></office:styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {ns}><office:font-face-decls>{face}</office:font-face-decls><office:automatic-styles><style:style style:name="P1" style:family="paragraph"><style:text-properties style:font-name="Odd Roman"/></style:style></office:automatic-styles><office:body><office:text><text:p text:style-name="P1">x</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let saved = export(&import(&package(&content, Some(&styles))).unwrap()).unwrap();
|
||||
for part in [styles_of(&saved), content_of(&saved)] {
|
||||
assert!(part.contains(face), "{part}");
|
||||
assert_eq!(part.matches("<style:font-face").count(), part.matches("<style:font-face style:name=").count());
|
||||
assert_eq!(part.matches(r#"style:name="Odd Roman""#).count(), 0, "declared once: {part}");
|
||||
assert_eq!(part.matches("style:name='Odd Roman'").count(), 1, "declared once: {part}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A name space only the declarations' container states goes with the declaration: without it the
|
||||
/// saved part isn't well-formed in its name spaces.
|
||||
#[test]
|
||||
fn a_name_space_on_the_font_declarations_goes_with_them() {
|
||||
let ns = format!(r#"{NS_T} xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0""#);
|
||||
let decls = r#"<office:font-face-decls xmlns:x="urn:example:x"><style:font-face style:name="Odd Roman" svg:font-family="Odd Roman" x:odd="1"/></office:font-face-decls>"#;
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {ns}>{decls}<office:styles><style:default-style style:family="paragraph"><style:text-properties style:font-name="Odd Roman" fo:font-size="12pt"/></style:default-style></office:styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {ns}>{decls}<office:automatic-styles><style:style style:name="P1" style:family="paragraph"><style:text-properties style:font-name="Odd Roman"/></style:style></office:automatic-styles><office:body><office:text><text:p text:style-name="P1">x</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let saved = export(&import(&package(&content, Some(&styles))).unwrap()).unwrap();
|
||||
for part in [styles_of(&saved), content_of(&saved)] {
|
||||
assert!(part.contains(r#"x:odd="1""#), "{part}");
|
||||
assert!(part.contains(r#"xmlns:x="urn:example:x""#), "{part}");
|
||||
}
|
||||
}
|
||||
@@ -161,19 +161,33 @@ impl Writer {
|
||||
out.push_str("<table:table-header-rows>");
|
||||
}
|
||||
// A row of a set height: exact (`style:row-height`) or at least (`style:min-row-height`).
|
||||
match t.extra.as_ref().and_then(|x| x.heights.get(ri).copied().flatten()) {
|
||||
Some((h, exact)) => {
|
||||
let props = if exact { format!(" style:row-height=\"{}\"", pt(h)) } else { format!(" style:min-row-height=\"{}\"", pt(h)) };
|
||||
let k = match self.row_styles.iter().position(|s| *s == props) {
|
||||
Some(k) => k,
|
||||
None => {
|
||||
self.row_styles.push(props);
|
||||
self.row_styles.len() - 1
|
||||
}
|
||||
};
|
||||
out.push_str(&format!("<table:table-row table:style-name=\"{}Row{}\">", self.prefix, k + 1));
|
||||
}
|
||||
None => out.push_str("<table:table-row>"),
|
||||
let height = match t.extra.as_ref().and_then(|x| x.heights.get(ri).copied().flatten()) {
|
||||
Some((h, true)) => format!(" style:row-height=\"{}\"", pt(h)),
|
||||
Some((h, false)) => format!(" style:min-row-height=\"{}\"", pt(h)),
|
||||
None => String::new(),
|
||||
};
|
||||
// A row that doesn't break across pages (§20.247 on a row). One that may says so where
|
||||
// the file's default row style, which stays, would keep it together.
|
||||
let keep = if t.extra.as_ref().is_some_and(|x| x.keep_rows.get(ri) == Some(&true)) {
|
||||
" fo:keep-together=\"always\""
|
||||
} else if self.kept_defs.get("common~style:default-style~table-row").is_some_and(|d| d.contains("keep-together") && d.contains("always"))
|
||||
{
|
||||
" fo:keep-together=\"auto\""
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let props = format!("{height}{keep}");
|
||||
if props.is_empty() {
|
||||
out.push_str("<table:table-row>");
|
||||
} else {
|
||||
let k = match self.row_styles.iter().position(|s| *s == props) {
|
||||
Some(k) => k,
|
||||
None => {
|
||||
self.row_styles.push(props);
|
||||
self.row_styles.len() - 1
|
||||
}
|
||||
};
|
||||
out.push_str(&format!("<table:table-row table:style-name=\"{}Row{}\">", self.prefix, k + 1));
|
||||
}
|
||||
let mut g = 0usize;
|
||||
for c in row {
|
||||
|
||||
@@ -105,22 +105,27 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
if word == "info" {
|
||||
r.info_depth = Some(r.depth());
|
||||
}
|
||||
if r.skip_chars > 0
|
||||
&& matches!(r.st.dest, Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark | Dest::FontTbl)
|
||||
&& !matches!(word, "par" | "pard" | "cell" | "row")
|
||||
&& matches!(r.st.dest, Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark | Dest::FontTbl | Dest::RevTbl)
|
||||
{
|
||||
r.skip_chars -= 1;
|
||||
first_in_group = false;
|
||||
continue;
|
||||
}
|
||||
if word == "info" {
|
||||
r.info_depth = Some(r.depth());
|
||||
}
|
||||
r.word(word, param, starred, first_in_group);
|
||||
first_in_group = false;
|
||||
starred = false;
|
||||
continue;
|
||||
}
|
||||
if r.skip_chars > 0 && n != b'\'' {
|
||||
r.skip_chars -= 1;
|
||||
i += 2;
|
||||
first_in_group = false;
|
||||
continue;
|
||||
}
|
||||
match n {
|
||||
b'\'' => {
|
||||
let h = bytes.get(i + 2..i + 4).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok());
|
||||
|
||||
@@ -37,6 +37,62 @@ fn basic_reader() {
|
||||
assert!(matches!(&l.inlines[0], Inline::Text(_, f) if f.link.as_deref() == Some("http://a.b")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_fallback_uses_the_declared_count() {
|
||||
for (rtf, expected) in [
|
||||
(br"{\rtf1\uc18\u8804less than or equal rest}".as_slice(), "≤ rest"),
|
||||
(br"{\rtf1\uc10\u233\'30\'31\'32\'33\'34\'35\'36\'37\'38\'39 rest}", "é rest"),
|
||||
(br"{\rtf1\uc2\u8804\:ab rest}", "≤b rest"),
|
||||
(br"{\rtf1\uc18\u8804\:12345678901234567 rest}", "≤ rest"),
|
||||
(br"{\rtf1\uc1\u8804\:ab rest}", "≤ab rest"),
|
||||
(br"{\rtf1\uc0\u8804\:ab rest}", "≤ab rest"),
|
||||
(br"{\rtf1\uc9\u233abcdefgh\par VISIBLE}", "éVISIBLE"),
|
||||
(br"{\rtf1\uc1\u233\pard VISIBLE}", "éVISIBLE"),
|
||||
(br"{\rtf1\uc1\u233\cell VISIBLE}", "éVISIBLE"),
|
||||
(br"{\rtf1\uc1\u233\row VISIBLE}", "éVISIBLE"),
|
||||
(br"{\rtf1\uc0\u233\par VISIBLE}", "é\nVISIBLE"),
|
||||
(b"{\\rtf1\\uc9\\u233abcdefgh\\\nVISIBLE}", "éVISIBLE"),
|
||||
(b"{\\rtf1\\uc9\\u233abcdefgh\\\rVISIBLE}", "éVISIBLE"),
|
||||
(br"{\rtf1\uc9\u233abcdefgh\*VISIBLE}", "éVISIBLE"),
|
||||
(b"{\\rtf1\\uc0\\u233\\\nVISIBLE}", "é\nVISIBLE"),
|
||||
(b"{\\rtf1\\uc0\\u233\\\rVISIBLE}", "é\nVISIBLE"),
|
||||
(br"{\rtf1\uc0\u233\*VISIBLE}", "éVISIBLE"),
|
||||
(br"{\rtf1\uc0\u233X}", "éX"),
|
||||
(br"{\rtf1\uc1\u233?X}", "éX"),
|
||||
(br"{\rtf1{\uc2147483647\u233?}X}", "éX"),
|
||||
(br"{\rtf1\uc-1\u233X}", "éX"),
|
||||
] {
|
||||
let doc = import(rtf).unwrap();
|
||||
assert_eq!(doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), expected, "{rtf:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_fallback_info_words_preserve_following_metadata() {
|
||||
for (rtf, title, body) in [
|
||||
(br"{\rtf1{\info{\title\uc9\u233abcdefgh\info X}{\author Alice}} Text}".as_slice(), "éX", "Text"),
|
||||
(br"{\rtf1{\info{\title\uc1\u233\info X}{\author Alice}} Text}", "éX", "Text"),
|
||||
(br"{\rtf1{\info{\title\uc0\u233X}{\author Alice}} Text}", "éX", "Text"),
|
||||
(br"{\rtf1\uc0\u233{\info{\title Plain}{\author Alice}}Text}", "Plain", "éText"),
|
||||
] {
|
||||
let doc = import(rtf).unwrap();
|
||||
assert_eq!(doc.core.title, title, "{rtf:?}");
|
||||
assert_eq!(doc.core.creator, "Alice", "{rtf:?}");
|
||||
assert_eq!(doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), body, "{rtf:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_fallback_in_revision_authors_counts_control_words() {
|
||||
for (rtf, expected) in [
|
||||
(br"{\rtf1{\*\revtbl{Unknown;}{\uc9\u233abcdefgh\par Zed;}}{\revised\revauth1 text}}".as_slice(), "éZed"),
|
||||
(br"{\rtf1{\*\revtbl{Unknown;}{\uc0\u233\par Zed;}}{\revised\revauth1 text}}", "éZed"),
|
||||
] {
|
||||
let doc = import(rtf).unwrap();
|
||||
assert_eq!(doc.revisions.first().map(|r| r.author.as_str()), Some(expected), "{rtf:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_borders() {
|
||||
// A heading with a grey rule under it, as hh.ru résumés write their section titles.
|
||||
|
||||
@@ -89,7 +89,7 @@ impl Reader {
|
||||
}
|
||||
self.skip_chars = self.st.uc;
|
||||
}
|
||||
"uc" => self.st.uc = pv.clamp(0, 8) as usize,
|
||||
"uc" => self.st.uc = pv.max(0) as usize,
|
||||
"plain" => {
|
||||
self.st.fmt = Fmt { link: self.st.fmt.link.clone(), ..Default::default() };
|
||||
self.st.rev = RevSt::default();
|
||||
|
||||
@@ -59,9 +59,9 @@ pub(crate) struct Ctx<'a> {
|
||||
pub(crate) pin: Option<Pin>,
|
||||
pub(crate) footer_wraps: HashMap<(usize, u32, bool), Vec<WrapArea>>,
|
||||
pub(crate) header_tables: HashMap<(usize, u32, bool), Vec<Rect>>,
|
||||
/// Heights of text boxes that resize to fit their text, by (story, width), worked out once
|
||||
/// per layout ([`textbox::fitted_height`]).
|
||||
pub(crate) fitted: HashMap<(u32, u32), Option<f32>>,
|
||||
/// Heights of text boxes that resize to fit their text, by story, width and page fields,
|
||||
/// worked out once per matching layout context ([`textbox::fitted_height`]).
|
||||
pub(crate) fitted: HashMap<(u32, u32, (u32, u32, u32, u32, u64, wordcraft_doc::section::NumFormat)), Option<f32>>,
|
||||
/// Each body float's latest placement: on which page and with what wrap area, when it is
|
||||
/// above its anchor paragraph with text before it ([`EarlyFloats`]).
|
||||
pub(crate) floats_above: HashMap<(usize, usize), Option<(usize, WrapArea)>>,
|
||||
@@ -344,7 +344,7 @@ impl Ctx<'_> {
|
||||
|
||||
/// The height of text box `id`, `w` wide, when it resizes to fit its text (once per layout).
|
||||
fn fitted_height(&mut self, id: u32, w: f32) -> Option<f32> {
|
||||
let key = (id, w.to_bits());
|
||||
let key = (id, w.to_bits(), self.fields.page_key());
|
||||
if let Some(h) = self.fitted.get(&key) {
|
||||
return *h;
|
||||
}
|
||||
@@ -455,3 +455,85 @@ pub(crate) fn with_pct_base<T>(ctx: &mut Ctx, base: Option<f32>, f: impl FnOnce(
|
||||
ctx.pct_base = before;
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wordcraft_doc::para::{Float, ShapeKind, Wrap};
|
||||
use wordcraft_doc::{PartKind, TextBody, para_block};
|
||||
|
||||
#[test]
|
||||
fn fitted_text_box_cache_separates_page_field_contexts() {
|
||||
let mut doc = Document::from_text("Parent");
|
||||
let mut story_para = Paragraph::new();
|
||||
story_para
|
||||
.insert_object(
|
||||
0,
|
||||
InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let id = doc.add_part(PartKind::TextBox, vec![para_block(story_para)]);
|
||||
if let Some(part) = doc.parts.get_mut(&id) {
|
||||
part.body = TextBody { auto_fit: true, ..Default::default() };
|
||||
}
|
||||
let mut p = Paragraph::new();
|
||||
p.insert_object(
|
||||
0,
|
||||
InlineObject::Shape {
|
||||
kind: ShapeKind::TextBox,
|
||||
w: 100.0,
|
||||
h: 20.0,
|
||||
fill: None,
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float: Float { wrap: Wrap::Inline, ..Default::default() },
|
||||
story: Some(id),
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
},
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let opts = LayoutOptions::default();
|
||||
let mut cache = LayoutCache::new();
|
||||
let mut ctx = Ctx {
|
||||
doc: &doc,
|
||||
cache: &mut cache,
|
||||
opts: &opts,
|
||||
counters: Default::default(),
|
||||
fields: FieldCtx::default(),
|
||||
notes_hash: 0,
|
||||
numbers: Default::default(),
|
||||
eq_count: 0,
|
||||
boxes: Default::default(),
|
||||
chains: Default::default(),
|
||||
grid: Default::default(),
|
||||
hide_deleted: opts.hides_deleted(StoryRef::Body),
|
||||
uses_totals: false,
|
||||
track_totals: false,
|
||||
pct_base: None,
|
||||
quiet_marks: Vec::new(),
|
||||
stacked_marks: Vec::new(),
|
||||
mirror: false,
|
||||
table_keys: Vec::new(),
|
||||
pin: None,
|
||||
footer_wraps: Default::default(),
|
||||
header_tables: Default::default(),
|
||||
fitted: Default::default(),
|
||||
floats_above: Default::default(),
|
||||
hf: Arc::new(HfLinks::new(doc.sections())),
|
||||
alphas: Default::default(),
|
||||
header_repeats: 0,
|
||||
};
|
||||
|
||||
ctx.fitted_boxes(&p);
|
||||
assert_eq!(ctx.fitted.len(), 1);
|
||||
ctx.fields.page_format = wordcraft_doc::section::NumFormat::CardinalText;
|
||||
ctx.fitted_boxes(&p);
|
||||
assert_eq!(ctx.fitted.len(), 2, "the same box with a different PAGE format needs its own height");
|
||||
ctx.fitted_boxes(&p);
|
||||
assert_eq!(ctx.fitted.len(), 2, "matching field context reuses the fitted height");
|
||||
}
|
||||
}
|
||||
@@ -656,7 +656,7 @@ fn inline_objects(ctx: &LineContext<'_, '_, '_>, out: &mut Vec<Draw>) {
|
||||
Some(o @ InlineObject::Field { .. }) => {
|
||||
if let Some(wordcraft_doc::legacy_form::LegacyForm::CheckBox { checked, .. }) = o.legacy_form() {
|
||||
let color = pl.styles.get(c.style as usize).map_or(Rgb::BLACK, |st| text_color(&st.rc.color, None));
|
||||
let frame = Rect::new(cx, base - c.obj_h, c.adv, c.obj_h + c.obj_d);
|
||||
let frame = Rect::new(cx, base - c.obj_h.max(0.0), c.adv, c.obj_h + c.obj_d);
|
||||
check_box(frame, checked, color, alpha, out);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,7 +491,8 @@ pub(crate) fn effects_in(effects: wordcraft_doc::effects::ShapeEffects, spin: Sp
|
||||
pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f32, (obj_h, obj_d): (f32, f32), line: f32) -> Rect {
|
||||
let frame = obj.and_then(InlineObject::frame);
|
||||
let [l, t, r, b] = frame.map_or([0.0; 4], |(_, _, float)| float.effect_extent());
|
||||
let room = Rect::new(cx + l, base - obj_h + t, (adv - l - r).max(0.0), (obj_h + obj_d - t - b).max(0.0));
|
||||
// Lowered further than its height, it is drawn with its top on the baseline (Word).
|
||||
let room = Rect::new(cx + l, base - obj_h.max(0.0) + t, (adv - l - r).max(0.0), (obj_h + obj_d - t - b).max(0.0));
|
||||
// A horizontal rule is as wide as its share of the paragraph's width, whatever narrows its
|
||||
// line, and is placed by its alignment in what the line has left: it runs past the margin
|
||||
// beside a float or a first-line indent, as in Word (see `ParaLayout::rule_room`).
|
||||
|
||||
@@ -463,6 +463,8 @@ fn anchor_floats_but(
|
||||
late: &[usize],
|
||||
) -> (HashMap<usize, Rect>, Arc<ParaLayout>, Vec<para::Exclusion>) {
|
||||
let FloatAnchor { p, block, y0, before, excl_mark, label, place } = anchor;
|
||||
ctx.fields.page = pb.number;
|
||||
ctx.fields.page_format = pb.page_format;
|
||||
let (col_x, width) = (pb.col_x(), pb.col_w());
|
||||
let mut rects = HashMap::new();
|
||||
if !pb.web && place {
|
||||
|
||||
@@ -816,7 +816,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);
|
||||
}
|
||||
|
||||
@@ -204,6 +204,7 @@ pub(crate) fn header_table_areas(ctx: &mut Ctx, pb: &PageBuilder) -> Vec<Rect> {
|
||||
let fields = ctx.fields.clone();
|
||||
let grid = std::mem::take(&mut ctx.grid);
|
||||
let track = std::mem::replace(&mut ctx.track_totals, false);
|
||||
ctx.fields.page_format = sect.page_num_format;
|
||||
ctx.fields.page = page.number;
|
||||
let mut areas = again(ctx, 0, |ctx| {
|
||||
with_pct_base(ctx, base, |ctx| layout_box(ctx, BoxInput::other(StoryRef::Part(id), &blocks, sect.text_width(), 0, Some(frame)))).table_areas
|
||||
|
||||
@@ -9,6 +9,8 @@ use super::{Block, Border, FloatAlign, Page, ParaLayout, Rect, SectionProps, Sto
|
||||
pub(crate) struct PageBuilder<'a> {
|
||||
pub(crate) pages: Vec<Page>,
|
||||
pub(crate) sect: &'a SectionProps,
|
||||
/// The current document section's numbering format, even when reflow uses first-section page geometry.
|
||||
pub(crate) page_format: wordcraft_doc::section::NumFormat,
|
||||
pub(crate) sect_idx: usize,
|
||||
pub(crate) col: usize,
|
||||
pub(crate) cols: Vec<(f32, f32)>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
@@ -438,8 +442,10 @@ impl<'e> Breaker<'e> {
|
||||
if matches!(rp.line_spacing, LineSpacing::Exactly(_)) { (st.ascent, st.descent) } else { (c.obj_h, c.obj_d) }
|
||||
} else {
|
||||
ex.obj_asc = ex.obj_asc.max(c.obj_h);
|
||||
// A lowered picture reaches below the baseline.
|
||||
ex.obj_desc = ex.obj_desc.max(c.obj_d);
|
||||
// A lowered picture reaches below the baseline, with exact spacing no further
|
||||
// than its height (the line's box is fixed; its baseline stays where it was).
|
||||
let reach = if matches!(rp.line_spacing, LineSpacing::Exactly(_)) { c.obj_d.min(c.obj_h + c.obj_d) } else { c.obj_d };
|
||||
ex.obj_desc = ex.obj_desc.max(reach);
|
||||
(st.ascent, st.descent)
|
||||
}
|
||||
} else {
|
||||
@@ -912,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 };
|
||||
@@ -920,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 {
|
||||
@@ -338,7 +368,8 @@ impl<'a> Shaped<'a> {
|
||||
/// A picture, shape or text box: inline, as wide and high as it is; a floating one takes no room.
|
||||
/// An inline drawing: it stands on the baseline, raised or lowered by its run's position
|
||||
/// (`raise`, points, as Word moves an inline picture with `w:position`). Its cluster reaches
|
||||
/// `obj_h` above the baseline and `obj_d` below it (negative when raised clear of it).
|
||||
/// `obj_h` above the baseline and `obj_d` below it (negative when raised clear of it; `obj_h`
|
||||
/// negative when lowered further than its height, drawn with its top on the baseline).
|
||||
fn drawing(&mut self, cx: &Cx, (k, o): (usize, &InlineObject), slot: Slot, raise: f32) {
|
||||
let env = cx.env;
|
||||
// A hidden one takes no room, and with no height its line doesn't draw it.
|
||||
@@ -368,10 +399,10 @@ impl<'a> Shaped<'a> {
|
||||
let [el, et, er, eb] = float.effect_extent();
|
||||
let (px, py) = float.spin_pad(w * s, h * s);
|
||||
let tall = h * s + et + eb + 2.0 * py;
|
||||
// Lowered at most until its top is on the baseline. The bound is made finite and
|
||||
// not above `clamp`'s top whatever the extents give (`clamp` panics otherwise).
|
||||
let floor = if tall.is_finite() { -tall.max(0.0) } else { 0.0 };
|
||||
let raise = if raise.is_finite() { raise.clamp(floor, 1584.0) } else { 0.0 };
|
||||
// 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
|
||||
// makes a 30 pt line); `obj_h` is then negative. Word's positions run to 1584 pt.
|
||||
let raise = if raise.is_finite() { raise.clamp(-1584.0, 1584.0) } else { 0.0 };
|
||||
self.b.clusters.push(slot.cluster(ClKind::Object(k), w * s + el + er + 2.0 * px, (tall + raise, -raise)));
|
||||
} else {
|
||||
self.push(slot, ClKind::Object(k), 0.0, 0.0);
|
||||
@@ -592,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 {
|
||||
#[cfg(test)]
|
||||
@@ -610,7 +661,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);
|
||||
|
||||
@@ -43,6 +43,7 @@ struct Tops {
|
||||
struct Sec<'p> {
|
||||
si: usize,
|
||||
sect: &'p SectionProps,
|
||||
page_format: wordcraft_doc::section::NumFormat,
|
||||
start: SectionStart,
|
||||
tops: Tops,
|
||||
}
|
||||
@@ -158,10 +159,17 @@ fn reflow_sect(first: &SectionProps, opts: &LayoutOptions) -> SectionProps {
|
||||
s
|
||||
}
|
||||
|
||||
fn new_page_builder<'p>(sect: &'p SectionProps, early: &'p EarlyFloats, web: bool, settle: bool) -> PageBuilder<'p> {
|
||||
fn new_page_builder<'p>(
|
||||
sect: &'p SectionProps,
|
||||
page_format: wordcraft_doc::section::NumFormat,
|
||||
early: &'p EarlyFloats,
|
||||
web: bool,
|
||||
settle: bool,
|
||||
) -> PageBuilder<'p> {
|
||||
PageBuilder {
|
||||
pages: Vec::new(),
|
||||
sect,
|
||||
page_format,
|
||||
sect_idx: 0,
|
||||
col: 0,
|
||||
cols: Vec::new(),
|
||||
@@ -221,6 +229,7 @@ fn turned_sections(ctx: &mut Ctx, sections: &[(usize, &SectionProps)], web: bool
|
||||
.iter()
|
||||
.map(|(_, s)| {
|
||||
(!web && s.text_direction.is_turned()).then(|| {
|
||||
ctx.fields.page_format = s.page_num_format;
|
||||
let top = body_top_for(ctx, s, s.headers.default);
|
||||
vertical::unturned_sect(s, top)
|
||||
})
|
||||
@@ -232,6 +241,7 @@ fn turned_sections(ctx: &mut Ctx, sections: &[(usize, &SectionProps)], web: bool
|
||||
/// previous section's, and take its room. `fixed`: the margins are the room (continuous views and
|
||||
/// turned sections).
|
||||
fn section_tops(ctx: &mut Ctx, hf: &HfLinks, (si, sect): (usize, &SectionProps), fixed: bool) -> Tops {
|
||||
ctx.fields.page_format = sect.page_num_format;
|
||||
let (heads, feet) = (hf.heads.set(si), hf.feet.set(si));
|
||||
let hb = |i: Option<u32>| hf.defining_width(false, si, i);
|
||||
let fb = |i: Option<u32>| hf.defining_width(true, si, i);
|
||||
@@ -246,6 +256,8 @@ fn section_tops(ctx: &mut Ctx, hf: &HfLinks, (si, sect): (usize, &SectionProps),
|
||||
fn begin_section_state<'p>(ctx: &mut Ctx, pb: &mut PageBuilder<'p>, sec: &Sec<'p>, totals: &Totals, web: bool) {
|
||||
let (doc, sect, si) = (ctx.doc, sec.sect, sec.si);
|
||||
pb.sect = sect;
|
||||
pb.page_format = sec.page_format;
|
||||
ctx.fields.page_format = sec.page_format;
|
||||
pb.sect_idx = si;
|
||||
pb.sect_beneath = doc.note_options(sect, false).pos == wordcraft_doc::section::NotePos::BeneathText;
|
||||
if !web && !doc.footnote_separators.is_empty() {
|
||||
@@ -263,6 +275,8 @@ fn begin_section_state<'p>(ctx: &mut Ctx, pb: &mut PageBuilder<'p>, sec: &Sec<'p
|
||||
fn end_previous_section<'p>(ctx: &mut Ctx, pb: &mut PageBuilder<'p>, hf: &HfLinks<'p>, si: usize, block: usize) {
|
||||
let Some((_, prev)) = si.checked_sub(1).and_then(|k| hf.sections.get(k)) else { return };
|
||||
pb.sect = prev;
|
||||
pb.page_format = prev.page_num_format;
|
||||
ctx.fields.page_format = prev.page_num_format;
|
||||
pb.sect_idx = si - 1;
|
||||
let (ph, pf) = (hf.heads.set(si - 1), hf.feet.set(si - 1));
|
||||
let top = body_top_for(ctx, prev, ph.default);
|
||||
@@ -281,6 +295,8 @@ fn start_section_page<'p>(ctx: &mut Ctx, pb: &mut PageBuilder<'p>, hf: &HfLinks<
|
||||
if filler && !mode.web {
|
||||
end_previous_section(ctx, pb, hf, sec.si, block);
|
||||
pb.sect = sec.sect;
|
||||
pb.page_format = sec.page_format;
|
||||
ctx.fields.page_format = sec.page_format;
|
||||
pb.sect_idx = sec.si;
|
||||
}
|
||||
if restart.is_some() {
|
||||
@@ -492,7 +508,7 @@ pub(crate) fn layout_pass(
|
||||
let first_sect = sections.first().map_or(&default_sect, |(_, s)| *s);
|
||||
let web_sect = mode.reflow.then(|| reflow_sect(first_sect, opts));
|
||||
let sect_ref = web_sect.as_ref().unwrap_or(first_sect);
|
||||
let mut pb = new_page_builder(sect_ref, early, mode.web, !may_rewrap && !mode.reflow);
|
||||
let mut pb = new_page_builder(sect_ref, first_sect.page_num_format, early, mode.web, !may_rewrap && !mode.reflow);
|
||||
let mut endnotes = endnote_queue(&ctx);
|
||||
// Text boxes laid out in balancing attempts that were thrown away.
|
||||
let mut boxes_discarded = 0usize;
|
||||
@@ -500,15 +516,17 @@ pub(crate) fn layout_pass(
|
||||
let turned = turned_sections(&mut ctx, sections, mode.web);
|
||||
let mut prev_turn = TextDirection::Horizontal;
|
||||
for (si, (end, sect)) in sections.iter().enumerate() {
|
||||
let page_format = sect.page_num_format;
|
||||
let turn = if mode.reflow { TextDirection::Horizontal } else { sect.text_direction };
|
||||
let sect: &SectionProps = match turned.get(si) {
|
||||
Some(Some(t)) => t,
|
||||
_ if mode.reflow => sect_ref,
|
||||
_ => sect,
|
||||
};
|
||||
ctx.fields.page_format = page_format;
|
||||
let tops = section_tops(&mut ctx, &hf, (si, sect), mode.reflow || turn.is_turned());
|
||||
let start = if si == 0 { SectionStart::NextPage } else { sect.start };
|
||||
let sec = Sec { si, sect, start, tops };
|
||||
let sec = Sec { si, sect, page_format, start, tops };
|
||||
if !mode.web && !pb.pages.is_empty() && (start != SectionStart::Continuous || turn != prev_turn) {
|
||||
// The page the section before ends on is finished with its own margins and footnote
|
||||
// separators, before this section's replace them: the footnotes pending on it
|
||||
|
||||
@@ -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)
|
||||
@@ -443,7 +493,14 @@ mod tests {
|
||||
#[test]
|
||||
fn suppress_keeps_the_number() {
|
||||
crate::test_fonts();
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -1860,6 +1860,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() {
|
||||
crate::test_fonts();
|
||||
@@ -5728,8 +5752,13 @@ fn an_inline_picture_moves_with_its_runs_position() {
|
||||
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);
|
||||
assert_eq!((r.y, r.h), (100.0, 30.0));
|
||||
// Lowered further than that: still only to its top.
|
||||
assert_eq!(line_of(-500.0).0.obj_d, 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);
|
||||
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));
|
||||
@@ -10442,6 +10471,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() {
|
||||
crate::test_fonts();
|
||||
@@ -14002,7 +14045,7 @@ fn lowered_pictures_and_check_boxes_take_words_line_heights() {
|
||||
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();
|
||||
let (line, st) = (¶.lines[0], ¶.styles[c.style as usize]);
|
||||
(line.height, line.baseline - c.obj_h - line.top, st.ascent + st.descent)
|
||||
(line.height, line.baseline - c.obj_h.max(0.0) - line.top, st.ascent + st.descent)
|
||||
};
|
||||
for m in [1.0, 1.15, 1.5] {
|
||||
let share = |font: f32| (m - 1.0) * font;
|
||||
@@ -14019,10 +14062,36 @@ fn lowered_pictures_and_check_boxes_take_words_line_heights() {
|
||||
let (h, top, font) = line_of("", m, 6.0, raise);
|
||||
assert!((h - font * m).abs() < 0.01 && (top - (font - reach)).abs() < 0.01, "{raise} at {m}: {h}, {top}");
|
||||
}
|
||||
// Lowered further than its height: as far down as it's lowered, the font's height at least,
|
||||
// its top on the baseline (Word: a 6 pt picture lowered 10 pt takes the font's 11.5 pt,
|
||||
// lowered 30 pt 30 pt; beside text, the text's ascent and the 30 pt).
|
||||
let (h, top, font) = line_of("", m, 6.0, -10.0);
|
||||
assert!((h - font * m).abs() < 0.01 && (top - (font - 10.0)).abs() < 0.01, "6 lowered 10 at {m}: {h}, {top}");
|
||||
let (h, top, font) = line_of("", m, 6.0, -30.0);
|
||||
assert!((h - 30.0 - share(font)).abs() < 0.01 && top.abs() < 0.01, "6 lowered 30 at {m}: {h}, {top}");
|
||||
let (h, top, font) = line_of("x", m, 6.0, -30.0);
|
||||
let asc = h - 30.0 - share(font);
|
||||
assert!(asc > 0.0 && asc < font && (top - asc).abs() < 0.01, "6 lowered 30 beside text at {m}: {h}, {top}");
|
||||
// Beside text, lowered: the text's ascent is no matter, the picture reaching further.
|
||||
let (h, top, font) = line_of("x", m, 30.0, -10.0);
|
||||
assert!((h - 30.0 - share(font)).abs() < 0.01 && top.abs() < 0.01, "beside text at {m}: {h}, {top}");
|
||||
}
|
||||
// Exact spacing keeps the line's box and its baseline: a picture lowered past its height sits
|
||||
// where one lowered by its height does.
|
||||
let exact = |raise: f32| {
|
||||
let mut d = Document::from_text("x");
|
||||
let props = wordcraft_doc::CharProps { position: Some(raise), ..Default::default() };
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.props.line_spacing = Some(LineSpacing::Exactly(20.0));
|
||||
let at = p.len();
|
||||
p.insert_object(at, picture(40.0, 6.0, Default::default()), &props).unwrap();
|
||||
let l = lay(&d);
|
||||
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
|
||||
(para.lines[0].height, para.lines[0].baseline - para.lines[0].top)
|
||||
};
|
||||
let (h, base) = exact(-6.0);
|
||||
assert!((h - 20.0).abs() < 0.01, "{h}");
|
||||
assert_eq!(exact(-30.0), (h, base));
|
||||
// A check box sizes its line as text of its size, alone or beside text.
|
||||
let box_line = |lead: &str, m: f32, size: u32| {
|
||||
let mut d = Document::from_text(lead);
|
||||
|
||||
@@ -661,3 +661,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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -626,3 +626,93 @@ fn a_page_ends_when_a_multiple_spaced_lines_text_no_longer_fits() {
|
||||
assert!(past_margin > 0, "{multiple}: some shift should leave the last line's leading past the margin");
|
||||
}
|
||||
}
|
||||
|
||||
/// A body PAGE field shows the page number in the section's number format, as Word does (a
|
||||
/// cardinal-text section shows "one"), and `\*` overrides it.
|
||||
#[test]
|
||||
fn body_page_field_uses_the_section_number_format() {
|
||||
let mut d = Document::from_text("Page \nPage ");
|
||||
d.last_section.page_num_format = wordcraft_doc::section::NumFormat::CardinalText;
|
||||
for (i, instr) in ["PAGE", "PAGE \\* Upper"].into_iter().enumerate() {
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(i)).unwrap();
|
||||
let off = p.len();
|
||||
let field = InlineObject::Field { instr: instr.into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() };
|
||||
p.insert_object(off, field, &Default::default()).unwrap();
|
||||
}
|
||||
let l = lay(&d);
|
||||
let shown: Vec<String> = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| if let Placed::Lines { para, .. } = it { Some(para.shown.iter().map(|x| x.1.clone()).collect::<String>()) } else { None })
|
||||
.collect();
|
||||
assert_eq!(shown, ["one", "ONE"], "{shown:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflow_page_field_uses_the_current_section_number_format() {
|
||||
let mut d = Document::from_text("First section\nSecond section");
|
||||
let first = d.last_section.clone();
|
||||
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(first));
|
||||
d.last_section.page_num_format = wordcraft_doc::section::NumFormat::CardinalText;
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
let off = p.len();
|
||||
p.insert_object(
|
||||
off,
|
||||
InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
for view in [ViewMode::Web, ViewMode::Draft, ViewMode::Read, ViewMode::Outline] {
|
||||
let mut cache = LayoutCache::new();
|
||||
let layout = layout(&d, &mut cache, &LayoutOptions { view, ..Default::default() });
|
||||
let shown: Vec<_> = layout
|
||||
.pages
|
||||
.iter()
|
||||
.flat_map(|page| {
|
||||
page.items.iter().filter_map(move |item| {
|
||||
if let Placed::Lines { para, path, .. } = item {
|
||||
(path == &Path::top(1)).then(|| (page.number, para.shown.iter().map(|x| x.1.as_str()).collect::<String>()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
assert!(!shown.is_empty(), "{view:?}: no second-section text");
|
||||
for (number, text) in shown {
|
||||
let expected = wordcraft_doc::section::NumFormat::CardinalText.format(number).to_lowercase();
|
||||
assert!(text.ends_with(&expected), "{view:?}, page {number}: {text:?}, expected {expected:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A PAGE field in a table that opens the body uses the section's number format too: the table
|
||||
/// is placed without the paragraph path that sets it.
|
||||
#[test]
|
||||
fn table_cell_page_field_uses_the_section_number_format() {
|
||||
use wordcraft_doc::Table;
|
||||
let mut d = Document::from_text("after");
|
||||
d.last_section.page_num_format = wordcraft_doc::section::NumFormat::CardinalText;
|
||||
let mut t = Table::new(1, 1, 200.0);
|
||||
let mut p = wordcraft_doc::Paragraph::with_text("", Default::default());
|
||||
let field = InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() };
|
||||
p.insert_object(0, field, &Default::default()).unwrap();
|
||||
if let Some(c) = t.rows.first_mut().and_then(|r| r.cells.first_mut()) {
|
||||
c.blocks = vec![para_block(p)];
|
||||
}
|
||||
d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(t)).unwrap();
|
||||
let l = lay(&d);
|
||||
let shown: String = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| {
|
||||
if let Placed::Lines { para, path, .. } = it {
|
||||
(path.0.len() > 1).then(|| para.shown.iter().map(|x| x.1.clone()).collect::<String>())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(shown, "one");
|
||||
}
|
||||
@@ -102,6 +102,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() {
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -2862,3 +2862,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");
|
||||
}
|
||||
@@ -13,6 +13,8 @@ use super::{
|
||||
mod commands;
|
||||
mod control;
|
||||
mod files;
|
||||
#[cfg(test)]
|
||||
pub(crate) use files::suggested_stem;
|
||||
mod fonts;
|
||||
mod frame;
|
||||
mod input;
|
||||
@@ -439,7 +441,11 @@ impl WordApp {
|
||||
return;
|
||||
}
|
||||
let t = theme::Tokens::get(&ctx);
|
||||
self.escape_free = self.dialog.is_none() && !self.ui.backstage && self.ui.keytips == keytips::Phase::Off && !egui::Popup::is_any_open(&ctx);
|
||||
self.escape_free = self.dialog.is_none()
|
||||
&& !self.ui.backstage
|
||||
&& self.ui.keytips == keytips::Phase::Off
|
||||
&& !egui::Popup::is_any_open(&ctx)
|
||||
&& !crate::chrome::title_search_open(self, &ctx);
|
||||
// Keytips are read before layout so the frame paints the new state; badges paint after
|
||||
// the ribbon so they sit on top of it.
|
||||
crate::keytips::logic(self, &ctx);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -298,12 +298,16 @@ impl WordApp {
|
||||
}
|
||||
|
||||
/// The name Save suggests: the open file's own Word format (so a .docm keeps its macros),
|
||||
/// otherwise .docx.
|
||||
/// otherwise .docx. A document never saved and without a title of its own is named after its
|
||||
/// first words, as Word suggests them (#5793), instead of `Document1`.
|
||||
pub(crate) fn default_save_name(&self) -> String {
|
||||
let ext = self.session.path.as_ref().and_then(|p| p.extension()).map(|e| e.to_string_lossy().to_string());
|
||||
let unnamed = self.session.path.is_none() && self.session.file_name.is_none() && self.session.doc.core.title.trim().is_empty();
|
||||
let first = || self.session.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).find_map(|t| suggested_stem(&t));
|
||||
let stem = unnamed.then(first).flatten().unwrap_or_else(|| self.title_stem());
|
||||
match ext {
|
||||
Some(e) if wordcraft_engine::io::is_word_package(&e) => format!("{}.{e}", self.title_stem()),
|
||||
_ => format!("{}.docx", self.title_stem()),
|
||||
Some(e) if wordcraft_engine::io::is_word_package(&e) => format!("{stem}.{e}"),
|
||||
_ => format!("{stem}.docx"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,3 +467,33 @@ impl WordApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The most characters a name taken from the text keeps ([`suggested_stem`]).
|
||||
const SUGGESTED_MAX: usize = 50;
|
||||
|
||||
/// A file name from a paragraph's text (#5793): up to its first sentence or clause end
|
||||
/// (`.`, `!`, `?`, `;`, `:` and their full-width forms) or line break, without the characters a
|
||||
/// file name can't hold (`\ / : * ? " < > |` and controls), white space collapsed, at most
|
||||
/// [`SUGGESTED_MAX`] characters cut at a word's end, and no dot or space at either end (Windows
|
||||
/// drops them). `None` when nothing is left, so the next paragraph, or `Document1`, is used.
|
||||
pub(crate) fn suggested_stem(text: &str) -> Option<String> {
|
||||
let end = |c: char| matches!(c, '.' | '!' | '?' | ';' | ':' | '\n' | '\r' | '\u{0B}' | '\u{2029}' | '。' | '!' | '?' | ';' | ':');
|
||||
let head = text.split(end).next().unwrap_or("");
|
||||
let kept: String = head.chars().map(|c| if c.is_control() || matches!(c, '\\' | '/' | '*' | '"' | '<' | '>' | '|') { ' ' } else { c }).collect();
|
||||
let mut words = String::new();
|
||||
for w in kept.split_whitespace() {
|
||||
let next = if words.is_empty() { w.chars().count() } else { words.chars().count() + 1 + w.chars().count() };
|
||||
if next > SUGGESTED_MAX {
|
||||
if words.is_empty() {
|
||||
words = w.chars().take(SUGGESTED_MAX).collect();
|
||||
}
|
||||
break;
|
||||
}
|
||||
if !words.is_empty() {
|
||||
words.push(' ');
|
||||
}
|
||||
words.push_str(w);
|
||||
}
|
||||
let words = words.trim_matches(|c: char| c == '.' || c.is_whitespace()).to_string();
|
||||
(!words.is_empty()).then_some(words)
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,17 @@ fn back_chevron_c(band_min_x: f32, center_y: f32, fullscreen: bool, content_left
|
||||
pos2(band_min_x + x, center_y)
|
||||
}
|
||||
|
||||
/// The bottom of the backstage menu: About, and the community link unless the user turned the
|
||||
/// community button off — the title bar pill honors the same setting, so the menu hides the
|
||||
/// link with it.
|
||||
fn bottom_links(app: &WordApp) -> Vec<(&'static str, &'static str)> {
|
||||
let mut links = vec![("About", "about")];
|
||||
if app.ui.show_discord {
|
||||
links.push(("Discord community", "discord"));
|
||||
}
|
||||
links
|
||||
}
|
||||
|
||||
/// Draws the Backstage side menu and the active page. Without a document only the Home, New, Open
|
||||
/// and Options pages work, and it handles Back, Escape, Save, Save As and Close routing.
|
||||
pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
@@ -155,7 +166,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| {
|
||||
for (label, id) in [("About", "about"), ("Discord community", "discord")] {
|
||||
for (label, id) in bottom_links(app) {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(200.0, 32.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(label)));
|
||||
if resp.hovered() {
|
||||
|
||||
@@ -48,6 +48,7 @@ use geometry::{floating_comments, on_page_gap, on_visible_page, white_space_hidd
|
||||
use input::{EDGE_MARGIN, EDGE_MAX_DT, EDGE_SPEED_MAX};
|
||||
use input::{UnitDrag, clamped_scroll, edge_scroll, mouse, wheel_hovered};
|
||||
pub(crate) use input::{hold_document_selection, press_count};
|
||||
pub(crate) use links::follow_link;
|
||||
#[cfg(test)]
|
||||
use links::{field_link, is_followable_link, is_generated_bookmark_link};
|
||||
#[cfg(test)]
|
||||
@@ -90,6 +91,24 @@ const TILE_HEIGHT_PX: usize = 1024;
|
||||
/// are rendered again when they come back.
|
||||
const TEXTURE_KEEP_SCREENS: f32 = 3.0;
|
||||
|
||||
/// The document's scroll bars (#5746): solid, so they show wherever the pointer is (egui's
|
||||
/// floating ones are invisible until the pointer nears them), and with their margin exactly as
|
||||
/// wide as the [`SCROLLBAR`] room the pages leave, so they neither cover nor move the pages.
|
||||
fn canvas_scroll_style() -> egui::style::ScrollStyle {
|
||||
let solid = egui::style::ScrollStyle::solid();
|
||||
egui::style::ScrollStyle { bar_width: SCROLLBAR - solid.bar_inner_margin - solid.bar_outer_margin, ..solid }
|
||||
}
|
||||
|
||||
/// The document's scroll bar thumb (#5746): egui paints a solid bar's track in
|
||||
/// `extreme_bg_color` and its thumb in the widget fills, which our palette sets alike (`input`), so
|
||||
/// the thumb would vanish into its track. Grey at rest, darker (lighter in the dark theme) under
|
||||
/// the pointer and while dragged. Only the scroll area's own visuals: the pages' contents keep theirs.
|
||||
fn canvas_scroll_thumb(visuals: &mut egui::Visuals, t: &Tokens) {
|
||||
visuals.widgets.inactive.bg_fill = t.border_strong;
|
||||
visuals.widgets.hovered.bg_fill = t.input_border;
|
||||
visuals.widgets.active.bg_fill = t.text_dim;
|
||||
}
|
||||
|
||||
pub struct CanvasState {
|
||||
textures: HashMap<usize, (u64, TextureHandle)>,
|
||||
/// Visible full-resolution tiles of pages too tall for one GPU texture.
|
||||
@@ -1415,9 +1434,14 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let CanvasView { area, hruler, vruler, paper, hinted, geo, dim_body, grid_story, caret, .. } = view;
|
||||
let mut origin = area.min;
|
||||
let mut ui_area = ui.new_child(egui::UiBuilder::new().max_rect(area));
|
||||
// The bars are drawn with `ui_area`'s style; what the pages draw gets the inherited one back.
|
||||
let inherited = std::sync::Arc::clone(ui_area.style());
|
||||
ui_area.spacing_mut().scroll = canvas_scroll_style();
|
||||
canvas_scroll_thumb(ui_area.visuals_mut(), &t);
|
||||
let mut ime_geometry: Option<(Rect, Rect)> = None;
|
||||
let mut ime_interrupt = false;
|
||||
let out = scroll_area.show_viewport(&mut ui_area, |ui, viewport| {
|
||||
ui.set_style(inherited);
|
||||
origin = ui.min_rect().min - viewport.min.to_vec2();
|
||||
let content = Rect::from_min_size(ui.min_rect().min, geo.size);
|
||||
let resp = canvas_response(ui, content, scroll_target);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::balloons::over_balloon;
|
||||
use super::links::{field_showing_code, is_followable_link, is_generated_bookmark_link, link_under};
|
||||
use super::links::{field_showing_code, follow_link, is_generated_bookmark_link, link_under};
|
||||
use super::navigation::page_at;
|
||||
use super::story::{enters_header_footer, in_text_box, part_kind, text_box_host};
|
||||
use super::{CanvasState, DocLayout, PartKind, Pos, Pos2, Rect, StoryRef, Ui, WordApp, json};
|
||||
@@ -110,7 +110,7 @@ pub(super) fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[
|
||||
};
|
||||
let Some((page, x, y)) = page_at(rects, layout, scale, p) else { return };
|
||||
let story = app.session.sel.focus.story;
|
||||
handle_page_pointer(app, PagePointer { ui, resp, layout, p, page, x, y, story });
|
||||
handle_page_pointer(app, PagePointer { ui, resp, layout, rects, scale, p, page, x, y, story });
|
||||
}
|
||||
|
||||
fn route_pointer_targets(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layout: &DocLayout, scale: f32) -> Option<Pos2> {
|
||||
@@ -143,6 +143,8 @@ struct PagePointer<'a> {
|
||||
ui: &'a Ui,
|
||||
resp: &'a egui::Response,
|
||||
layout: &'a DocLayout,
|
||||
rects: &'a [Rect],
|
||||
scale: f32,
|
||||
p: Pos2,
|
||||
page: usize,
|
||||
x: f32,
|
||||
@@ -329,16 +331,7 @@ fn handle_primary_press(
|
||||
if mods.command
|
||||
&& let Some(link) = link_under(app, layout, page, x, y, &pos)
|
||||
{
|
||||
if let Some(name) = link.strip_prefix('#') {
|
||||
// The caret goes to the bookmark's start, as in Word (Go To selects its text).
|
||||
if app.run("edit.goto", json!({"bookmark": name})).is_ok() {
|
||||
let _ = app.run("select.collapse", json!({}));
|
||||
}
|
||||
} else if is_followable_link(&link) {
|
||||
app.canvas.open_url = Some(link);
|
||||
} else {
|
||||
app.status(tl!("Only web (http, https) and email (mailto) links can be followed"));
|
||||
}
|
||||
follow_link(app, link);
|
||||
return;
|
||||
}
|
||||
let pj = serde_json::to_value(&pos).unwrap_or_default();
|
||||
@@ -479,16 +472,17 @@ fn skip_equation_or_outline(app: &mut WordApp, pointer: &PagePointer<'_>) -> boo
|
||||
}
|
||||
|
||||
fn show_hover_link(app: &WordApp, pointer: &PagePointer<'_>) {
|
||||
let PagePointer { ui, resp, layout, page, x, y, story, .. } = pointer;
|
||||
let PagePointer { ui, resp, layout, rects, scale, p, page, x, y, story } = pointer;
|
||||
// Hover a link: show its target, unless the context menu is opening or open: a tooltip draws
|
||||
// above the menu and would cover its first items (#3368).
|
||||
// above the menu and would cover its first items (#3368). A linked picture or shape under the
|
||||
// pointer comes first: it lies over the text (#5267).
|
||||
if resp.hovered()
|
||||
&& !app.canvas.dragging
|
||||
&& !app.canvas.context_menu_open
|
||||
&& !resp.secondary_clicked()
|
||||
&& !ui.input(|i| i.pointer.secondary_down())
|
||||
&& let Some(pos) = layout.hit(*page, *x, *y, *story)
|
||||
&& let Some(link) = link_under(app, layout, *page, *x, *y, &pos)
|
||||
&& let Some(link) = crate::objects::link_at(app, layout, rects, *scale, *p)
|
||||
.or_else(|| layout.hit(*page, *x, *y, *story).and_then(|pos| link_under(app, layout, *page, *x, *y, &pos)))
|
||||
{
|
||||
let key = if cfg!(target_os = "macos") { "⌘" } else { "Ctrl" };
|
||||
let target = if is_generated_bookmark_link(&link) { tl!("Current Document").to_string() } else { link };
|
||||
|
||||
@@ -43,6 +43,21 @@ pub(super) fn is_generated_bookmark_link(link: &str) -> bool {
|
||||
link.strip_prefix("#_").is_some()
|
||||
}
|
||||
|
||||
/// Ctrl/⌘+click on `link` (in the text or on a picture): a place in this document takes the caret
|
||||
/// to its bookmark; a web or email address opens in the browser; any other is not followed.
|
||||
pub(crate) fn follow_link(app: &mut WordApp, link: String) {
|
||||
if let Some(name) = link.strip_prefix('#') {
|
||||
// The caret goes to the bookmark's start, as in Word (Go To selects its text).
|
||||
if app.run("edit.goto", serde_json::json!({"bookmark": name})).is_ok() {
|
||||
let _ = app.run("select.collapse", serde_json::json!({}));
|
||||
}
|
||||
} else if is_followable_link(&link) {
|
||||
app.canvas.open_url = Some(link);
|
||||
} else {
|
||||
app.status(tl!("Only web (http, https) and email (mailto) links can be followed"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether Ctrl/⌘+click may hand a document's hyperlink to the system: web and email links only,
|
||||
/// so a document can't launch `file:` paths, programs or custom-scheme handlers.
|
||||
pub(super) fn is_followable_link(url: &str) -> bool {
|
||||
|
||||
@@ -755,6 +755,86 @@ fn dragging_the_scrollbar_names_the_page_at_the_top() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #5746: the document's scroll bars are drawn wherever the pointer is: before it has moved and
|
||||
/// with it over the page, not only at the window's edge, with a thumb that stands out from its
|
||||
/// track in the light and the dark theme. The vertical one fills the room the pages leave for it,
|
||||
/// so it covers none of them; zoomed in, the horizontal one shows the same way.
|
||||
#[test]
|
||||
fn the_document_scroll_bars_show_with_the_pointer_away() {
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let bar = |h: &H, vertical: bool| {
|
||||
h.query_all_by_role(Role::ScrollBar)
|
||||
.map(|n| n.rect())
|
||||
.filter(|r| (r.height() > r.width()) == vertical)
|
||||
.max_by(|a, b| a.area().total_cmp(&b.area()))
|
||||
};
|
||||
// The opaque fills across the bar's whole width, longest first: its track, then its thumb.
|
||||
let drawn = |h: &H, bar: Rect, vertical: bool| {
|
||||
let across = |r: &Rect| if vertical { r.width() } else { r.height() };
|
||||
let along = |r: &Rect| if vertical { r.height() } else { r.width() };
|
||||
let mut fills: Vec<(f32, egui::Color32)> = h
|
||||
.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|c| match &c.shape {
|
||||
egui::Shape::Rect(r) if r.fill.a() > 0 && bar.expand(0.5).contains_rect(r.rect) && across(&r.rect) >= across(&bar) - 0.5 => {
|
||||
Some((along(&r.rect), r.fill))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
fills.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||
fills
|
||||
};
|
||||
// The thumb shows on its track: a clear step in grey, not the same fill.
|
||||
let thumb_shows = |fills: &[(f32, egui::Color32)], what: &str| {
|
||||
let [(track_len, track), (thumb_len, thumb), ..] = fills else { panic!("{what}: a track and a thumb are drawn: {fills:?}") };
|
||||
assert!(thumb_len < track_len, "{what}: the thumb is shorter than its track: {fills:?}");
|
||||
let step = (0..3).map(|i| (i32::from(track[i]) - i32::from(thumb[i])).abs()).max().unwrap_or(0);
|
||||
assert!(step >= 40, "{what}: the thumb {thumb:?} stands out from its track {track:?}");
|
||||
};
|
||||
for theme in ["light", "dark"] {
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
|
||||
for _ in 0..3 {
|
||||
a.run("text.insert", json!({"text": "Page text"})).unwrap();
|
||||
a.run("layout.break", json!({"kind": "page"})).unwrap();
|
||||
}
|
||||
a.run("view.commentsPane", json!({"value": false})).unwrap();
|
||||
a.run("ui.theme", json!({"value": theme})).unwrap();
|
||||
let mut h: H = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
for _ in 0..6 {
|
||||
h.step();
|
||||
}
|
||||
assert_eq!(Tokens::get(&h.ctx).dark, theme == "dark", "the {theme} theme is on");
|
||||
let v = bar(&h, true).expect("the document's vertical scroll bar");
|
||||
thumb_shows(&drawn(&h, v, true), &format!("{theme}, before the pointer has moved"));
|
||||
let viewport = h.state().canvas.viewport.expect("the canvas is shown");
|
||||
assert!((v.right() - viewport.right() - SCROLLBAR).abs() < 0.5, "the bar fills the room left for it: {v:?} beside {viewport:?}");
|
||||
assert!(h.state().canvas.page_rects.iter().all(|p| p.right() <= v.left()), "the bar covers no page");
|
||||
let page = h.state().canvas.page_rects.first().copied().expect("a page on screen");
|
||||
h.event(egui::Event::PointerMoved(page.center()));
|
||||
for _ in 0..30 {
|
||||
h.step();
|
||||
}
|
||||
let v = bar(&h, true).expect("the document's vertical scroll bar");
|
||||
thumb_shows(&drawn(&h, v, true), &format!("{theme}, the pointer over the page"));
|
||||
h.state_mut().run("view.zoom", json!({"value": 400.0})).unwrap();
|
||||
for _ in 0..30 {
|
||||
h.step();
|
||||
}
|
||||
let across = bar(&h, false).expect("zoomed in, a horizontal scroll bar");
|
||||
thumb_shows(&drawn(&h, across, false), &format!("{theme}, the horizontal bar"));
|
||||
}
|
||||
}
|
||||
|
||||
/// #3880: the status bar names the page being looked at. With the caret scrolled out of view, that
|
||||
/// is the page at the top of the window; while the caret is on screen, its page.
|
||||
#[test]
|
||||
@@ -5011,3 +5091,116 @@ fn a_pictures_corrections_and_crop_change_its_page_key() {
|
||||
assert_ne!(plain, pages::page_content_key(&at([0.1, 0.0, 0.0, 0.0], PicFx::default())), "crop");
|
||||
assert_ne!(plain, pages::page_content_key(&at([0.0; 4], PicFx::new(10_000, 0, 0))), "brightness");
|
||||
}
|
||||
|
||||
/// #5267: a picture linked from its context menu (Link…) keeps the link, shows it on hover and
|
||||
/// follows it on Ctrl/⌘+click, in line with the text and floating over it.
|
||||
#[test]
|
||||
fn a_picture_linked_from_its_context_menu_shows_and_follows_its_link() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
for floating in [false, true] {
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
|
||||
a.run("text.insert", json!({"text": "Text before the picture "})).unwrap();
|
||||
a.run("select.text", json!({"text": "before"})).unwrap();
|
||||
a.run("insert.bookmark", json!({"name": "Spot"})).unwrap();
|
||||
a.run("caret.docEnd", json!({})).unwrap();
|
||||
let mut png = Vec::new();
|
||||
let img = image::RgbaImage::from_pixel(40, 20, image::Rgba([200, 30, 30, 255]));
|
||||
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
a.run("insert.picture", json!({"data": wordcraft_engine::cmd::insert::base64_encode(&png), "width": 160})).unwrap();
|
||||
if floating {
|
||||
a.run("arrange.wrap", json!({"wrap": "inFrontOfText"})).unwrap();
|
||||
}
|
||||
let pos = wordcraft_engine::cmd::objects::selected(&a.session).unwrap().0;
|
||||
a.session.sel = wordcraft_engine::Selection::caret(Pos::body(0, 0));
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
steps(&mut h, 6);
|
||||
let rect = h.state_mut().session.layout().object(&pos, 0).expect("the picture is laid out").rect;
|
||||
let at = crate::frame::to_screen(h.state().canvas.page_rects[0], h.state().canvas.scale, rect).center();
|
||||
h.event(egui::Event::PointerMoved(at));
|
||||
for pressed in [true, false] {
|
||||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() });
|
||||
h.step();
|
||||
}
|
||||
h.step();
|
||||
h.get_by_label_contains("Link…").click();
|
||||
steps(&mut h, 4);
|
||||
h.event(egui::Event::Text("https://example.com/picture".into()));
|
||||
steps(&mut h, 3);
|
||||
h.get_by_label_contains("OK").click();
|
||||
steps(&mut h, 4);
|
||||
let s = &h.state().session;
|
||||
let link = s.doc.para_at(&pos).map(|p| p.props_of_char(pos.off).link.clone());
|
||||
assert_eq!(link, Some(Some("https://example.com/picture".to_string())), "floating {floating}: the link is on the picture");
|
||||
// Hovering the picture shows where it goes.
|
||||
h.event(egui::Event::PointerMoved(at + egui::vec2(1.0, 0.0)));
|
||||
steps(&mut h, 3);
|
||||
assert!(h.query_by_label_contains("https://example.com/picture").is_some(), "floating {floating}: the hover tip names the link");
|
||||
// Ctrl/⌘+click follows it, instead of picking the picture for a copy: here a link to a
|
||||
// place in the document, which moves the caret (a web address goes to the browser).
|
||||
h.state_mut().run("insert.link", json!({"bookmark": "Spot"})).unwrap();
|
||||
steps(&mut h, 2);
|
||||
let press = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::COMMAND };
|
||||
h.event_modifiers(press, egui::Modifiers::COMMAND);
|
||||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
steps(&mut h, 3);
|
||||
let s = &h.state().session;
|
||||
let (start, _) = wordcraft_engine::cmd::insert::bookmark_range(&s.doc, "Spot").unwrap();
|
||||
assert!(s.sel.is_collapsed() && s.sel.focus == start, "floating {floating}: the caret went to the bookmark: {:?}", s.sel);
|
||||
assert!(h.state().canvas.obj_drag.is_none(), "floating {floating}: no copy-drag started");
|
||||
}
|
||||
}
|
||||
|
||||
/// #5267: inside a linked text box, hovering shows the box's link and Ctrl/⌘+click follows it,
|
||||
/// although a click there reaches the box's text rather than the box.
|
||||
#[test]
|
||||
fn a_linked_text_box_shows_and_follows_its_link_from_inside() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
|
||||
a.run("text.insert", json!({"text": "Text before the box "})).unwrap();
|
||||
a.run("select.text", json!({"text": "before"})).unwrap();
|
||||
a.run("insert.bookmark", json!({"name": "Spot"})).unwrap();
|
||||
a.run("caret.docEnd", json!({})).unwrap();
|
||||
a.run("insert.textBox", json!({"text": "Boxed words"})).unwrap();
|
||||
let link_box = |a: &mut WordApp, link: serde_json::Value| {
|
||||
let bx = a.session.layout().find_object(0, |o| o.text_box.is_some()).expect("the box");
|
||||
let end = Pos { off: bx.off + wordcraft_doc::para::OBJ.len_utf8(), ..bx.pos() };
|
||||
a.run("select.range", json!({"anchor": bx.pos(), "focus": end})).unwrap();
|
||||
a.run("insert.link", link).unwrap();
|
||||
a.session.sel = wordcraft_engine::Selection::caret(Pos::body(0, 0));
|
||||
bx.pos()
|
||||
};
|
||||
let pos = link_box(&mut a, json!({"url": "https://example.com/box"}));
|
||||
let link = a.session.doc.para_at(&pos).map(|p| p.props_of_char(pos.off).link.clone());
|
||||
assert_eq!(link, Some(Some("https://example.com/box".to_string())), "the link is on the text box");
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
steps(&mut h, 6);
|
||||
let rect = h.state_mut().session.layout().find_object(0, |o| o.text_box.is_some()).expect("the box is laid out").rect;
|
||||
let at = crate::frame::to_screen(h.state().canvas.page_rects[0], h.state().canvas.scale, rect).center();
|
||||
h.event(egui::Event::PointerMoved(at));
|
||||
steps(&mut h, 3);
|
||||
assert!(h.query_by_label_contains("https://example.com/box").is_some(), "the hover tip names the box's link");
|
||||
// Ctrl/⌘+click inside the box follows it: here a place in the document, which moves the caret.
|
||||
link_box(h.state_mut(), json!({"bookmark": "Spot"}));
|
||||
steps(&mut h, 2);
|
||||
let press = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::COMMAND };
|
||||
h.event_modifiers(press, egui::Modifiers::COMMAND);
|
||||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
steps(&mut h, 3);
|
||||
let s = &h.state().session;
|
||||
let (start, _) = wordcraft_engine::cmd::insert::bookmark_range(&s.doc, "Spot").unwrap();
|
||||
assert!(s.sel.is_collapsed() && s.sel.focus == start, "the caret went to the bookmark: {:?}", s.sel);
|
||||
}
|
||||
@@ -446,12 +446,148 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// The title bar's Search box while it is typed in (#5398): the query, whether the text of the
|
||||
/// shortcut that opened it (Alt+Q's `q` on some platforms) is still to be dropped, and where the
|
||||
/// results showed last frame.
|
||||
#[derive(Clone, Default)]
|
||||
struct TitleSearch {
|
||||
query: String,
|
||||
swallow: bool,
|
||||
results: Option<Rect>,
|
||||
}
|
||||
|
||||
/// Width of the Search box, and of its results under it.
|
||||
const SEARCH_BOX_W: f32 = 260.0;
|
||||
const SEARCH_RESULTS_W: f32 = 380.0;
|
||||
|
||||
/// Search in the title bar's box, as Word's: when this window drew the box in its last frame
|
||||
/// (the title bar, or Full Screen's Commands bar), Search opens there, typed into in place with
|
||||
/// its results under it, instead of in the Search Commands dialog. `true` when it did.
|
||||
pub(crate) fn open_title_search(app: &mut WordApp) -> bool {
|
||||
let Some(ctx) = app.ctx.clone() else { return false };
|
||||
// Focus hides the box even when the last frame, before Focus came on, drew it.
|
||||
if Screen::of(&app.session.view) == Screen::Focus || !search_box_drawn(app, &ctx) {
|
||||
return false;
|
||||
}
|
||||
let id = app.window_id("title_search");
|
||||
// Only Alt+Q may send its `q` as text after it; a click sends none. Alt is read from the
|
||||
// shortcut's key event too: the box takes the keys from its first frame, that same text
|
||||
// included.
|
||||
let swallow =
|
||||
ctx.input(|i| i.modifiers.alt || i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, modifiers, .. } if modifiers.alt)));
|
||||
ctx.data_mut(|d| d.insert_temp(id, TitleSearch { swallow, ..Default::default() }));
|
||||
ctx.request_repaint();
|
||||
true
|
||||
}
|
||||
|
||||
/// What is typed in the title bar's Search, while it is open.
|
||||
pub(crate) fn title_search_query(app: &WordApp, ctx: &egui::Context) -> Option<String> {
|
||||
ctx.data(|d| d.get_temp::<TitleSearch>(app.window_id("title_search"))).map(|s| s.query)
|
||||
}
|
||||
|
||||
/// Whether this window drew the Search box in its last frame.
|
||||
fn search_box_drawn(app: &WordApp, ctx: &egui::Context) -> bool {
|
||||
let shown = ctx.data(|d| d.get_temp::<u64>(app.window_id("title_search_shown")));
|
||||
shown.is_some_and(|f| f.saturating_add(1) >= ctx.cumulative_frame_nr())
|
||||
}
|
||||
|
||||
/// Whether the title bar's Search is being typed in (Escape closes it, not Full Screen). Search
|
||||
/// whose box is no longer drawn (Focus came on) is dropped, so it can't keep Escape.
|
||||
pub(crate) fn title_search_open(app: &WordApp, ctx: &egui::Context) -> bool {
|
||||
if title_search_query(app, ctx).is_none() {
|
||||
return false;
|
||||
}
|
||||
if search_box_drawn(app, ctx) {
|
||||
return true;
|
||||
}
|
||||
ctx.data_mut(|d| d.remove::<TitleSearch>(app.window_id("title_search")));
|
||||
false
|
||||
}
|
||||
|
||||
/// The Search box being typed in: a text box in its place and the results under it. `false`
|
||||
/// once it closes: a command or the find ran, Escape, or a click outside both.
|
||||
fn typing_search(app: &mut WordApp, ui: &mut Ui, control_h: f32, s: &mut TitleSearch) -> bool {
|
||||
if s.swallow {
|
||||
ui.ctx().input_mut(|i| i.events.retain(|e| !crate::dialogs::swallow_shortcut_text(&mut s.swallow, e)));
|
||||
}
|
||||
let ph = tl!("Search commands and help");
|
||||
// The box takes the keys from its first frame: focused before it reads this frame's input,
|
||||
// so what is typed straight after Alt+Q goes into it. It takes the focus back whenever it lost
|
||||
// it, except to one of its results (Tab moves there, so a keyboard user can pick one), and
|
||||
// not while a press on a result is under way (#2986).
|
||||
let id = app.window_id("title_search_edit");
|
||||
let on_result = ui
|
||||
.memory(|m| m.focused())
|
||||
.filter(|f| *f != id)
|
||||
.and_then(|f| ui.ctx().read_response(f))
|
||||
.is_some_and(|r| s.results.is_some_and(|x| x.contains_rect(r.rect)));
|
||||
if !on_result && !ui.memory(|m| m.has_focus(id)) && !ui.input(|i| i.pointer.any_down()) {
|
||||
ui.memory_mut(|m| m.request_focus(id));
|
||||
}
|
||||
let typing = ui.memory(|m| m.has_focus(id));
|
||||
let edit = ui.add_sized(
|
||||
vec2(SEARCH_BOX_W, control_h),
|
||||
egui::TextEdit::singleline(&mut s.query).id(id).hint_text(ph).align(if crate::rtl::on() {
|
||||
Align2::RIGHT_CENTER
|
||||
} else {
|
||||
Align2::LEFT_CENTER
|
||||
}),
|
||||
);
|
||||
let r = edit.rect;
|
||||
// Enter in the box runs the first result, and is used up: a dialog that result opens (Word
|
||||
// Count) mustn't take the same Enter as its OK. A result Tab moved to takes Enter or Space
|
||||
// itself.
|
||||
let (enter, escape, pressed_at) = ui.input_mut(|i| {
|
||||
let pressed = i.pointer.any_pressed().then(|| i.pointer.press_origin()).flatten();
|
||||
(typing && i.consume_key(egui::Modifiers::NONE, egui::Key::Enter), i.key_pressed(egui::Key::Escape), pressed)
|
||||
});
|
||||
let inside = |p: egui::Pos2| r.contains(p) || s.results.is_some_and(|x| x.contains(p));
|
||||
if escape || pressed_at.is_some_and(|p| !inside(p)) {
|
||||
return false;
|
||||
}
|
||||
let rtl = crate::rtl::on();
|
||||
let (pivot, at) = if rtl { (Align2::RIGHT_TOP, r.right_bottom()) } else { (Align2::LEFT_TOP, r.left_bottom()) };
|
||||
let mut done = false;
|
||||
let shown = egui::Area::new(app.window_id("title_search_results"))
|
||||
.order(egui::Order::Foreground)
|
||||
.pivot(pivot)
|
||||
.fixed_pos(at + vec2(0.0, 2.0))
|
||||
.show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_width(SEARCH_RESULTS_W);
|
||||
// Rows of a list, as a menu's, not a stack of framed buttons.
|
||||
ui.style_mut().visuals.button_frame = false;
|
||||
done = crate::dialogs::search_results(app, ui, &s.query, enter, SEARCH_RESULTS_W);
|
||||
});
|
||||
});
|
||||
s.results = Some(shown.response.rect);
|
||||
if done {
|
||||
// A result run with Enter from the keyboard: the dialog it opens mustn't take that Enter.
|
||||
ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
|
||||
}
|
||||
!done
|
||||
}
|
||||
|
||||
/// Search ("Tell me"): a box that opens command search. In the title bar, and in the Commands bar
|
||||
/// of Full Screen (#1678).
|
||||
/// of Full Screen (#1678). Clicked (or Alt+Q), it takes the typing in place (#5398).
|
||||
fn search_box(app: &mut WordApp, ui: &mut Ui, control_h: f32) {
|
||||
let frame = ui.ctx().cumulative_frame_nr();
|
||||
ui.ctx().data_mut(|d| d.insert_temp(app.window_id("title_search_shown"), frame));
|
||||
let id = app.window_id("title_search");
|
||||
if let Some(mut s) = ui.ctx().data(|d| d.get_temp::<TitleSearch>(id)) {
|
||||
let open = typing_search(app, ui, control_h, &mut s);
|
||||
ui.ctx().data_mut(|d| {
|
||||
if open {
|
||||
d.insert_temp(id, s);
|
||||
} else {
|
||||
d.remove::<TitleSearch>(id);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let rtl = crate::rtl::on();
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(260.0, control_h), Sense::click());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(SEARCH_BOX_W, control_h), Sense::click());
|
||||
let ph = tl!("Search commands and help");
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, ph));
|
||||
ui.painter().rect(r, 6.0, if resp.hovered() { t.input } else { t.ribbon }, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
|
||||
@@ -35,9 +35,11 @@ use bookmarks::bookmark_at_caret;
|
||||
use bookmarks::bookmark_names;
|
||||
pub use chrome::show;
|
||||
pub(crate) use chrome::{block_behind, buttons, cancel_key_pressed, color_menu, forget_nudge, ok_cancel_responses, release_keyboard};
|
||||
pub(crate) use dispatch::search_results;
|
||||
pub(crate) use find::swallow_shortcut_text;
|
||||
pub use find::{FindExtra, FindTab, GoToWhat};
|
||||
#[cfg(test)]
|
||||
use find::{command_matches, goto_page, swallow_shortcut_text};
|
||||
use find::{command_matches, goto_page};
|
||||
#[cfg(test)]
|
||||
use font::{apply_font, preview_size};
|
||||
use font::{east_asian_char, font_props};
|
||||
@@ -414,10 +416,12 @@ pub enum Dialog {
|
||||
#[serde(skip)]
|
||||
basis: Box<CellMarginsForm>,
|
||||
},
|
||||
/// Opening a password-protected document (#55): the password, then `file.open` with
|
||||
/// `params` again. The password is never serialised or printed.
|
||||
/// Opening a password-protected document (#55): the password, then the command that asked
|
||||
/// for it (`then`: `file.open`, or `file.newFromTemplate` for a protected template, #3433)
|
||||
/// with `params` again. The password is never serialised or printed.
|
||||
Password {
|
||||
name: String,
|
||||
then: String,
|
||||
#[serde(skip)]
|
||||
params: Value,
|
||||
#[serde(skip)]
|
||||
@@ -575,6 +579,10 @@ impl Dialog {
|
||||
}
|
||||
|
||||
pub fn open(name: &str, app: &mut WordApp) -> Option<Dialog> {
|
||||
// Search opens in the title bar's box when the window shows it (#5398).
|
||||
if name == "commands" && crate::chrome::open_title_search(app) {
|
||||
return None;
|
||||
}
|
||||
if let Some(d) = crate::dialogs_insert::open(name, app) {
|
||||
return Some(Dialog::Insert(Box::new(d)));
|
||||
}
|
||||
|
||||
@@ -534,7 +534,12 @@ impl BordersForm {
|
||||
}
|
||||
if self.fill != b.fill || self.fill_to_text != b.fill_to_text {
|
||||
let color = Rgb::parse(&self.fill).map(|c| json!(c.hex())).unwrap_or(Value::Null);
|
||||
out.push((if self.fill_to_text { "format.shading" } else { "para.shading" }, json!({"color": color})));
|
||||
// Apply to: Paragraph shades the paragraphs even for text selected inside one.
|
||||
out.push(if self.fill_to_text {
|
||||
("format.shading", json!({"color": color}))
|
||||
} else {
|
||||
("para.shading", json!({"color": color, "paragraph": true}))
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -581,8 +581,20 @@ fn insert_table_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
|
||||
/// A margin's number box, in the unit the interface measures in.
|
||||
fn margin(value: &mut f32) -> egui::DragValue<'_> {
|
||||
egui::DragValue::new(value).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2)
|
||||
fn margin(value: &mut f32) -> impl egui::Widget + '_ {
|
||||
move |ui: &mut Ui| {
|
||||
// DragValue's unfocused button extends to fit its value; keep it inside the field too.
|
||||
let (_, rect) = ui.allocate_space(egui::vec2(80.0, ui.spacing().interact_size.y));
|
||||
let mut field = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(crate::rtl::row_layout(ui, egui::Align::Center)));
|
||||
field.shrink_clip_rect(rect);
|
||||
field.add(
|
||||
egui::DragValue::new(value)
|
||||
.speed(3.6 / crate::options::unit().pt_per_unit())
|
||||
.measure(crate::options::unit())
|
||||
.max_decimals(2)
|
||||
.clip_text(true),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn page_setup_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
@@ -640,9 +652,9 @@ fn page_setup_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
crate::rtl::Table::new("ps_grid").columns(4).show(ui, |t| {
|
||||
t.row(|r| {
|
||||
let chars_label = r.cell(|ui| ui.add_enabled(grid.0 >= 2, egui::Label::new(tl!("Characters per line:"))));
|
||||
r.cell(|ui| ui.add_enabled(grid.0 >= 2, egui::DragValue::new(&mut grid.2).range(1..=1000)).labelled_by(chars_label.id));
|
||||
r.cell(|ui| ui.add_enabled(grid.0 >= 2, egui::DragValue::new(&mut grid.2).range(1..=1000).clip_text(true)).labelled_by(chars_label.id));
|
||||
let lines_label = r.cell(|ui| ui.add_enabled(grid.0 >= 1, egui::Label::new(tl!("Lines per page:"))));
|
||||
r.cell(|ui| ui.add_enabled(grid.0 >= 1, egui::DragValue::new(&mut grid.1).range(1..=1000)).labelled_by(lines_label.id));
|
||||
r.cell(|ui| ui.add_enabled(grid.0 >= 1, egui::DragValue::new(&mut grid.1).range(1..=1000).clip_text(true)).labelled_by(lines_label.id));
|
||||
});
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
@@ -1055,6 +1067,15 @@ fn commands_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
if !r.has_focus() {
|
||||
r.request_focus();
|
||||
}
|
||||
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
search_results(app, ui, query, enter, 380.0) || ui.input(|i| i.key_pressed(egui::Key::Escape))
|
||||
}
|
||||
|
||||
/// Search's results under its box (the Search Commands dialog, or the title bar's drop-down,
|
||||
/// #5398): suggestions for an empty box, else the matching commands and "Find … in the
|
||||
/// document". A click, or `enter` (the first command, else the find), does it: `true` when one
|
||||
/// was done, so the search closes.
|
||||
pub(crate) fn search_results(app: &mut WordApp, ui: &mut Ui, query: &str, enter: bool, width: f32) -> bool {
|
||||
let reg = app.session.registry.clone();
|
||||
// An empty box suggests a few common commands, as Word's Search does.
|
||||
let mut hits: Vec<&wordcraft_engine::CommandSpec> = if query.trim().is_empty() {
|
||||
@@ -1064,12 +1085,11 @@ fn commands_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
reg.all().iter().filter(|c| !NOT_IN_SEARCH.contains(&c.id) && command_matches(c, query)).collect()
|
||||
};
|
||||
hits.truncate(14);
|
||||
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
let mut run: Option<&str> = enter.then(|| hits.first().map(|c| c.id)).flatten();
|
||||
for c in &hits {
|
||||
let sc = crate::widgets::shortcut_text(app, c.id);
|
||||
let label = format!("{} — {}", tl!(c.label), crate::i18n::location(c.location));
|
||||
if ui.add(egui::Button::new(label).shortcut_text(sc).min_size(vec2(380.0, 0.0))).clicked() {
|
||||
if ui.add(egui::Button::new(label).shortcut_text(sc).min_size(vec2(width, 0.0))).clicked() {
|
||||
run = Some(c.id);
|
||||
}
|
||||
}
|
||||
@@ -1079,14 +1099,14 @@ fn commands_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
if !text.is_empty() {
|
||||
ui.separator();
|
||||
let label = crate::i18n::fmt(tl!("Find “{text}” in the document"), &[("text", &text)]);
|
||||
find = ui.add(egui::Button::new(label).min_size(vec2(380.0, 0.0))).clicked() || (enter && hits.is_empty());
|
||||
find = ui.add(egui::Button::new(label).min_size(vec2(width, 0.0))).clicked() || (enter && hits.is_empty());
|
||||
}
|
||||
if let Some(id) = run {
|
||||
let _ = app.run(id, json!({}));
|
||||
} else if find {
|
||||
find_in_navigation_pane(app, ui.ctx(), &text);
|
||||
}
|
||||
run.is_some() || find || ui.input(|i| i.key_pressed(egui::Key::Escape))
|
||||
run.is_some() || find
|
||||
}
|
||||
|
||||
fn paste_special_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
@@ -1270,7 +1290,7 @@ fn insert_merge_field_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) ->
|
||||
}
|
||||
|
||||
fn password_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
let Dialog::Password { name, params, password, message } = d else {
|
||||
let Dialog::Password { name, then, params, password, message } = d else {
|
||||
return false;
|
||||
};
|
||||
ui.label(crate::i18n::fmt(tl!("{name} is protected with a password."), &[("name", name)]));
|
||||
@@ -1291,8 +1311,9 @@ fn password_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
if let Some(o) = p.as_object_mut() {
|
||||
o.insert("password".into(), json!(password.as_str()));
|
||||
}
|
||||
// `execute`, not `run`: Save Changes was asked when the open began.
|
||||
match app.execute("file.open", p) {
|
||||
// `execute`, not `run`: Save Changes was asked when the open began. The command that
|
||||
// asked runs again, so a protected template still starts a new document (#3433).
|
||||
match app.execute(then, p) {
|
||||
Ok(_) => {
|
||||
app.ui.backstage = false;
|
||||
return true;
|
||||
|
||||
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user