mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-12 01:01:12 +00:00
Merge branch 'main' into fix/preserve-page-number-stories
This commit is contained in:
143 files changed
+7159
-345
No files matched your search
Generated
+3
-4
@@ -1081,7 +1081,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
dependencies = [
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1135,7 +1135,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -2981,7 +2981,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
@@ -7562,7 +7562,6 @@ dependencies = [
|
||||
"nix",
|
||||
"typed-path",
|
||||
"windows-sys 0.61.2",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+3
-3
@@ -67,9 +67,9 @@ wuff = "0.2.9"
|
||||
brotli-decompressor = "5"
|
||||
vello_cpu = "0.2"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates.
|
||||
@@ -134,7 +134,7 @@ debug = 0
|
||||
|
||||
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" }
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
|
||||
+40
-1
@@ -1,5 +1,6 @@
|
||||
# WordCraft roadmap
|
||||
|
||||
> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (#3398: persistent screen paper colors; parity estimates unchanged); previously minor (#3693: additional Delete Previous Sentence command and shortcut; Word-parity estimates unchanged)
|
||||
> **CLI path update:** 2026-11-04 · Options before a non-Unicode input filename no longer make the CLI reject that filename; text option values still require Unicode; `run --file` and `run --script` retain native paths. No ranked gap or readiness estimate changes.
|
||||
> **Floating anchor row segments:** 2026-11-07 · A floating anchor after a manual page/column break counts visible content on the far side of the same wrapped row. Marker-only cover anchors retain their previous-page rule. Synthetic picture spacing, placement and history controls; no new native Word measurement. Readiness estimates unchanged.
|
||||
|
||||
@@ -7,7 +8,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** trivial (progress log: a turned inline picture whose file keeps no room for the turn keeps its line to its frame); previously trivial (progress log: the Arabic interface's Styles gallery reads from the right (#5566)); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT heading text of another size); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial (progress log: a shapLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** trivial (progress log: ODT columns stating only their shares); previously trivial (progress log: ODT font size for every script); previously trivial (progress log: ODT text turning off what its style turns on); previously trivial (progress log: edited ODT paragraphs keep custom styles with an outline level); previously trivial (progress log: ODT pictures keep the size their file gives); previously trivial (progress log: ODT frames keep the page they are anchored to); previously trivial (pointer zoom stretches page rasters until it stops); previously trivial (dark-theme interface text on Linux no longer looks semi-bold); previously trivial (Control/MCP #2596 review: partial-write outcomes, path-bearing image exports, accept-thread startup cleanup and malformed UTF-8 handling); previously trivial (progress log: a turned inline picture whose file keeps no room for the turn keeps its line to its frame); previously trivial (progress log: the Arabic interface's Styles gallery reads from the right (#5566)); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT 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 sLine 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.
|
||||
>
|
||||
@@ -151,6 +152,14 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-11-05: ODT tables with a usable width keep their column proportions when the file gives only shares (gap 19): LibreOffice's CV template opened and saved with equal columns.
|
||||
- 2026-11-05: ODT font sizes reach Asian and complex-script text (gap 19): Chinese text set to 24 pt stayed at the default size in LibreOffice.
|
||||
- 2026-11-05: ODT text can turn off what its style turns on (gap 19): a word not bold in a bold heading showed bold, and came back bold in LibreOffice after a save.
|
||||
- 2026-11-06: An edited ODT paragraph keeps its custom paragraph style when that style has an outline level, or is based on a heading, Title, Quotations or Preformatted Text (gap 19). It was saved under the built-in style instead, such as WordCraft's blue Heading 1, and looked wrong in LibreOffice; a style with an outline level also drew that way in WordCraft. The style's outline level and numbering still work in WordCraft.
|
||||
- 2026-11-05: ODT pictures keep the size their file gives (gap 19): opening a file shrank ones wider than the text, so a save with no edit to them changed how LibreOffice laid them out.
|
||||
- 2026-11-05: ODT frames keep the page they are anchored to (gap 19): written without its number, a frame on page 3 came back on page 1 in LibreOffice after a save.
|
||||
- 2026-11-05: ODT documents keep their language (gap 19). It was lost on save after numbering or a new style, and LibreOffice then showed the document in its own default language; a document in another language was also proofed as English.
|
||||
- 2026-11-05: ODT table and row backgrounds are kept (gap 19): they were dropped, and gone in LibreOffice after a save.
|
||||
- 2026-11-08: Layout: a turned inline picture whose file keeps no room for the turn (no or a zero `wp:effectExtent`, as other tools write) keeps its line to its frame and overhangs it, cut at the line's top and bottom, as Word does: the frame as it is in Word 2007 layout, with width and height exchanged from 45° to 135° and 225° to 315° in Word 2013+ layout. Such a file saves the same way. Pictures with the room recorded (Word's own files) still take their rotated bounds. Readiness unchanged.
|
||||
- 2026-11-08: XML text and CDATA read with their line ends normalised (XML 1.0 §2.11): CR LF and a lone CR are one line feed, ` ` stays a CR, so a line break inside `w:t` in a file saved with Windows line ends is one space, not two (#5897); readiness estimates unchanged.
|
||||
- 2026-11-08: Layout: contextual spacing drops the space after a table cell's last paragraph when that paragraph is in the Normal style, the end-of-cell mark counting as a Normal paragraph next to it; a last paragraph in another style keeps its space after. The rule is inferred from docxide-pdf's notes and its `cell-end-*` fixtures (`cell-end-text-context`, `cell-end-empty-context`); a Word comparison that tells it apart from the neighbouring paragraph's style is still open. Readiness unchanged.
|
||||
@@ -283,6 +292,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
- 2026-10-31: Table of contents: with Word's table over entries in columns, where the title sits in a one-column section above a continuous break, Insert Table of Contents with new settings keeps the title above the break and the entries in their columns (the new title went under the break, into the columns), and Remove Table of Contents takes the title, the blank paragraph and its break with the entries, as Word does (observed in Word for Mac); a paragraph of another style above the break stays (#4041).
|
||||
- 2026-10-31: A table of contents writes its page numbers as `PAGEREF` fields to the headings' bookmarks, as Word does, so updating the fields in Word moves the numbers with the headings; they were plain text (#5097).
|
||||
- 2026-10-31: Keytips reach the Backstage: Alt, F badges its pages with Word's letters, and Alt, F, P (or V) opens Print (#4960).
|
||||
- **2026-10-31 (#3398):** General Options offers persistent Document background / Soft green / Warm cream / Soft gray screen paper. The view.paperColor command reaches the same setting headlessly. Document page colors and pictures, saves, printing and exports retain their own styling; the screen raster/tile cache and page backing share the selected color. Read Mode None uses the preset; explicit Sepia/Inverse keep priority. Parity counts and readiness estimates unchanged.
|
||||
- **2026-10-31 (#3693, editing extension):** Delete Previous Sentence is a headless registry command and Mod+Shift+Backspace shortcut, using existing sentence boundaries and deletion/undo/protection paths. Paragraph and cell boundaries are preserved. Additional WordCraft behavior; Word-parity counts and estimates unchanged.
|
||||
- 2026-10-29: Documents with hyperlinks or a table of contents print again on the desktop (#4734). The print job scales each page to the paper, and the spooler refused a page with link annotations ("flatten page annotations before using shrink-to-fit"); the PDF sent to the printer now omits link annotations, which print nothing; the link and table-of-contents text stays on the page. Exported PDFs and Save these pages as PDF (`file.printPdf`) keep the annotations.
|
||||
- 2026-10-28: Word count counts each Chinese and Japanese character as a word, as Word does, in the status bar, the Word Count dialog and NUMWORDS fields. Unspaced Chinese text used to count as one word per run of text; Korean, which is written with spaces, still counts by words. (#4547)
|
||||
- 2026-10-28: Aligned-copy review fixes keep the document axis locked through middle-button scrolling and let Ctrl/Command+Shift start on a secondary selected object. Plain Ctrl and Shift clicks keep their existing behavior (#4764).
|
||||
@@ -373,6 +384,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-05 | trivial | Progress log: ODT documents keep their language (gap 19) |
|
||||
| 2026-11-04 | The CLI opens a non-Unicode input filename even after options such as `--page 2` or `--format flatopc`; text option values still require Unicode; `run --file` and `run --script` retain native paths. |
|
||||
| 2026-11-06 | Accept All and Reject All no longer restyle the paragraph after an empty one carrying a section break (#5667): a Compare that added a section, then Reject All, left `Body` in the removed paragraph's Normal style and indents. The empty paragraph now goes whole and its section joins the following one, as deleting the break does. |
|
||||
| 2026-11-07 | Deleting a preceding empty paragraph keeps the following paragraph's style, outline and mark formatting rather than promoting body text to a heading (Leaf #152). Non-empty merges and section absorption retain their existing rules; synthetic model, command, DOCX and Undo/Redo coverage. |
|
||||
@@ -444,12 +456,21 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
| 2026-10-19 | Format Page Numbers (Insert › Header & Footer › Page Number ▸ Format Page Numbers…, #1113): the dialog sets the number format (1, 2, 3 / I, II, III / i, ii, iii / A, B, C / a, b, c) and whether the pages number on from the section before or start at a number; the ribbon item was a plain command call that did nothing and cleared the section's start |
|
||||
| 2026-10-19 | A picture, shape or text box dragged near the bottom of a page lands where it is dropped. It used to jump far below the page, cut off at its edge with only its selection box showing over the next page (#3169) |
|
||||
| 2026-10-19 | Insert Caption picks the label used last, as Word does, and keeps it between runs (#3120). |
|
||||
| 2026-10-20 | Pinned layout test fonts also initialize before the new caret-height and ligature fixtures, keeping isolated test runs independent of system fonts (#2729). |
|
||||
| 2026-10-19 | Ctrl/⌘+wheel and pinch zoom feel lighter: while the zoom moves, each page's last raster is stretched to the new size instead of rendered at every in-between size; it is rendered once the zoom stops, or again whenever stretching would pass 1.5x. On a 4K screen at 145 % the frames of a zoom went from 11–15 ms to 0–1 ms, one notch from 12 page renders to 1. Wheel steps with animation effects off still land finished in their own frame. |
|
||||
| 2026-10-19 | Dark-theme interface text on Linux and the BSDs no longer looks semi-bold: egui thickened light-on-dark glyph edges by default (about 21 % more ink than Firefox draws the same text); it now adds a light 7 %, chosen side by side on a 145 % desktop (Firefox's plain weight looked soft on the dark tab row). Light-theme interface text was measured against headless Firefox and already matches it (Inter's ttfautohint hints draw as FreeType's light autohinting does). Headless screenshots take a scale (`{"ppp": 1.45}` in `ui_shot`) to see text as a fractional desktop scale draws it. |
|
||||
| 2026-10-19 | Page text on Linux and the BSDs is no longer soft: it is hinted the way FreeType's `hintslight` draws text in Firefox and other apps there, its autohinter putting baselines, x-heights and cap heights on whole pixels without moving anything sideways, so lines break as before. Measured against headless Firefox on Arial at 21.27 px: the same rows of pixels and the same weight. Re-rendering costs the same; the first render at a new zoom about 1.5 ms more per page. macOS, Windows and exports are unchanged. |
|
||||
| 2026-10-19 | Save As, Export and Open no longer do nothing on Linux desktops whose file chooser portal fails to show its dialog (#2999): a "cancel" quicker than anyone could make one asks again without the parent window, then opens the built-in file browser. |
|
||||
| 2026-10-20 | Russian sorting keeps ё with е and recognises decomposed ё/й: case-insensitive words and index headings share the corrected collation, with regressions and no new dependencies. |
|
||||
| 2026-10-18 | Long navigation headings wrap onto further lines inside the resizable pane instead of ending in an ellipsis: the row grows with the text and the pane keeps its width (#759, #3015). |
|
||||
| 2026-10-17 | Layout unit tests use the pinned bundled fonts without system/user fonts or optional craft-fonts inputs (#2729); every test initializes the fixture before font lookup, and pagination fixtures are measured against that fixed set |
|
||||
| 2026-10-18 | Clearing a font query with no matches keeps Recently Used Fonts visible at the top of a scrollable list. The initial scroll position no longer skips its heading; measured keyboard scrolling is unchanged (#2068). |
|
||||
| 2026-10-19 | Centred numbered headings after a leading page break keep their number before the text (#2964). The label uses the alignment of the line it is drawn on; heading text positions and page count stay the same. |
|
||||
| 2026-10-17 | Edit Recipient List finds records (#1576): a Find in list box hides rows with no cell containing the text (any case) and counts the matches, and "Show only records that will be merged" hides the rows the filter leaves out; both only change the view. Rows are striped, and edits and removes go to the right record however the grid is narrowed |
|
||||
| 2026-10-17 | Recent documents can be taken off the list (#2716): hovering a row in File › Home/Open ▸ Recent shows a × that removes that document, a Clear all button under the list empties it, and the change is written to the preferences right away, so it survives a crash. `ui.recentRemove {"path"}` and `ui.recentClear` do the same for CLI, control and MCP |
|
||||
| 2026-10-31 | Control/MCP #2596 review: write/flush failures after sending begins are outcome-unknown; image exports to a path are not replayed; accept-thread startup failures disable the server and remove the key; invalid UTF-8 is rejected per line without ending stdio. Synthetic regressions cover partial JSON delivery at EOF, flush failure, export vs in-memory retry and failed startup. |
|
||||
| 2026-10-16 | Control/MCP delivery (#1326, #1331): the MCP bridge never re-sends a mutation whose outcome is unknown (explicit "outcome unknown" error; reads and not-sent requests still retry); a control request whose caller timed out while queued never runs, and a timeout before execution is told apart from an unknown outcome; bounded MCP and reply line reads; capped unauthenticated control connections with fallible thread creation |
|
||||
| 2026-10-16 | MCP `insert_table` tool (#2242): a filled table in one call from `values`, with `header` (repeating first row) and `style`; `insert.spreadsheet` takes the same `values`/`header`/`style` and rejects ragged, oversized or non-array input |
|
||||
| 2026-10-17 | AccessKit focus guard (#2726): every tree update egui sends carries its focused node, so a frame that drops it falls back to the root instead of panicking in `accesskit_consumer` and closing the window on Windows |
|
||||
| 2026-10-17 | Equation arrays centre a row that has no `&` among rows that do, as Word does, so case equations like Word's "First Property of Radicals" no longer push the aligned row right (#2617). |
|
||||
| 2026-10-17 | Formatting revisions: a table, row, cell or section property change made while tracking now records as `w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange` (structure stays out), so Reject All restores and Accept All keeps it; test tracked_table_changes_are_kept_as_formatting_changes |
|
||||
@@ -522,6 +543,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
| 2026-10-17 | Non-overlapping body floating tables clear header tables. Original native controls distinguish never-overlap from allowed overlap. |
|
||||
| 2026-10-14 | Comment text being typed is no longer lost or misplaced (#1348): an edit or reply the document refuses (for example read-only protection) keeps the typed text in its field and says why on the status line; drafts belong to their document, so after New or Open a comment with the same number shows its own text; Save, Export, Print, Close, New and Open write pending comment text first; if the document refuses it, Save, Export and Print carry on with the stored comments and show why on the status line (a refused draft is dropped when Save is the only change), while Close, New and Open show the Save Changes prompt (Don't Save drops text the document refuses); Escape discards what was typed; in a document protected as read only or for forms the comment fields can't be typed in |
|
||||
|---|---|
|
||||
| 2026-10-12 | RTF reads and writes footnotes, endnotes and comments (author, initials, date, commented range), so they survive a round trip through RTF (#1053) |
|
||||
| 2026-11-07 | Page Setup also bounds the unfocused margin display after a long numeric paste; the accepted value is retained (#5650). |
|
||||
| 2026-11-05 | Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. |
|
||||
| 2026-10-29 | Print Markup discovers comment balloon anchors in one forward paragraph-object walk; anchor positions, filtering and the existing cap stay unchanged (#3614) |
|
||||
@@ -529,6 +551,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
| 2026-10-22 | Documents that keep content in another format (`w:altChunk`: a web page, RTF, text or another Word document, as generators and mail-merge tools write them) show it where it stands, as Word does, instead of leaving it out; one such file in the docxide-pdf corpus opened as an empty page and now matches Word's 8 pages. `.mht` web archives open, and web pages open the way Word opens them (style sheets, paragraph margins, Times New Roman, single lines, automatic spacing). Insert › Text from File keeps the inserted file's pictures |
|
||||
| 2026-10-18 | Explicit New/Open keeps the requested document visible when startup recovery discovery finishes; older copies stay available. Recover uses the regular theme. See docs/recovery.md. |
|
||||
| 2026-10-15 | A floating picture or shape in a table cell set to align centre or bottom no longer lands half its height above its line (#1887); it starts at the anchor line |
|
||||
| 2026-10-26 | View › Window across the native windows (#322): Arrange All, View Side by Side, Synchronous Scrolling |
|
||||
| 2026-10-18 | Picture Format › Size (#2573): width and height in the interface unit, and a size typed in any unit works, pixels too (`300 px` keeps the picture's proportions, #1901), applied once typed (Enter, Tab or a click elsewhere; Esc cancels) in one undo step. The group's launcher opens a Size dialog: height and width, scale of the original size (or of the size it opened with), Lock aspect ratio, the original size with its pixels, and Reset. `picture.size {"original": true, "scale"?}` sizes from the original; sizes past 4000 pt that keep the proportions shrink as a whole. |
|
||||
| 2026-10-16 | Save Changes gives assistive technology a named alert dialog with the loss warning and any save failure; visual layout and save policy unchanged (#1015). Native screen-reader verification remains pending. |
|
||||
| 2026-10-17 | `design.scaleFontSizes` (Design › Document Formatting): grow or shrink every style's font size at once, by points (`delta`) or percent, rounded to half points and clamped to 1–1638 pt; inherited sizes follow, runs are untouched, one undo step (#1475) |
|
||||
@@ -889,6 +912,8 @@ MATH glyphs that reference the same construction now share its immutable variant
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-06 | trivial | Progress log: edited ODT paragraphs keep custom styles with an outline level (gap 19) |
|
||||
| 2026-10-31 | trivial | Control/MCP #2596 review fixes; readiness estimates unchanged |
|
||||
| 2026-11-08 | trivial | Progress log: XML line ends in text normalised (#5897) |
|
||||
| 2026-11-08 | trivial | Progress log: contextual spacing drops the space after a cell's last Normal paragraph (#5450) |
|
||||
| 2026-11-06 | trivial | Progress log: the Arabic interface's Styles gallery reads from the right (#5566) |
|
||||
@@ -918,6 +943,12 @@ MATH glyphs that reference the same construction now share its immutable variant
|
||||
| 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 columns stating only their shares (gap 19) |
|
||||
| 2026-11-05 | trivial | Progress log: ODT font size for every script (gap 19) |
|
||||
| 2026-11-05 | trivial | Progress log: ODT text turning off what its style turns on (gap 19) |
|
||||
| 2026-11-05 | trivial | Progress log: ODT pictures keep the size their file gives (gap 19) |
|
||||
| 2026-11-05 | trivial | Progress log: ODT frames keep the page they are anchored to (gap 19) |
|
||||
| 2026-11-05 | trivial | Progress log: ODT table and row backgrounds (gap 19) |
|
||||
| 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 |
|
||||
@@ -945,6 +976,7 @@ MATH glyphs that reference the same construction now share its immutable variant
|
||||
| 2026-11-03 | trivial | Progress log: Language pack languages in every language list (CP4.3) |
|
||||
| 2026-11-03 | trivial | Progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language (CP4.3) |
|
||||
| 2026-11-03 | trivial | Progress log: Options › Language and Proofing redesign (CP4.3) |
|
||||
| 2026-11-03 | minor | Home › Paragraph Tools (#5043): nine selection- or document-scoped conversion and cleanup commands; one undo step per command, with tracked deletions preserved; readiness estimates unchanged |
|
||||
| 2026-10-30 | trivial | Progress log: layout resource caps — repeated table headers are bounded by item counts, the wrap cache by decoded pixels and linked text box layout by its lines and items; PDF export, hit testing, tracked changes, clipped tables and long endnote selections cost linear time (#3867, #3866) |
|
||||
| 2026-10-17 | trivial | Progress log: RTF on the system clipboard |
|
||||
| 2026-11-03 | trivial | Progress log: craft-proof pin, French elision suggestions and Valencian (CP4.3) |
|
||||
@@ -973,8 +1005,10 @@ MATH glyphs that reference the same construction now share its immutable variant
|
||||
| 2026-11-01 | trivial | #3915: preserve guarded whole-line ordinary-tab alignment with current main. Existing controls retained; source checks only, primary qualification pending. |
|
||||
| 2026-11-01 | trivial | #2437: Preserve finite paragraph-border flow extents and terminal-break keep preflight with current-main changes. Recursive terminal-successor lookahead remains outside scope. Source checks only; primary qualification pending. |
|
||||
| 2026-10-31 | trivial | Progress log: a paragraph naming an undefined DOCX style is formatted as Normal (#5077) |
|
||||
| 2026-10-31 | minor | #3398: persistent screen-only green, cream and gray paper presets, with document backgrounds and export colors retained. Read Mode None follows the screen preset; Sepia/Inverse retain priority. |
|
||||
| 2026-10-31 | trivial | Progress log: TOC page numbers are PAGEREF fields (#5097) |
|
||||
| 2026-10-31 | trivial | Progress log: Backstage keytips, Alt, F, P prints (#4960) |
|
||||
| 2026-10-31 | minor | #3693: Delete Previous Sentence command and keyboard dispatch, with sentence/paragraph/cell, selection, undo and tracking regressions. Additional WordCraft behavior; Word-parity estimates unchanged. |
|
||||
| 2026-10-30 | trivial | Progress log: only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them (#4734) |
|
||||
| 2026-10-29 | trivial | Progress log: documents with links print (#4734) |
|
||||
| 2026-10-31 | trivial | Synchronize the existing Proofing pane with main `eecbb8f`; preserve explicit-position lookup, implicit selection, navigation, undo and per-window state; fresh exact-SHA hosted validation pending, readiness unchanged |
|
||||
@@ -1072,6 +1106,9 @@ MATH glyphs that reference the same construction now share its immutable variant
|
||||
| 2026-10-19 | trivial | A right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, starts the next line with its text instead of ending the line (a TOC entry's page number keeps its leader); a left tab still past the right indent there takes no room (#1840) |
|
||||
| 2026-10-19 | trivial | Progress log: a picture dragged near the bottom of a page lands there (#3169) |
|
||||
| 2026-10-19 | trivial | Progress log: a Thai run with Latin finds one installed font covering it all on Linux too (#1549) |
|
||||
| 2026-10-19 | trivial | Progress log: pointer zoom no longer re-renders pages every frame |
|
||||
| 2026-10-19 | trivial | Progress log: dark-theme interface text on Linux no longer looks semi-bold |
|
||||
| 2026-10-19 | trivial | Progress log: page text on Linux is hinted like Firefox draws text there |
|
||||
| 2026-10-20 | trivial | Progress log: Times and Courier take their Windows counterparts' line spacing |
|
||||
| 2026-10-19 | trivial | Progress log: forms protection keeps its content controls: no inserting, Properties or Remove Content Control |
|
||||
| 2026-10-19 | trivial | Progress log: text typed into a content control takes the control's formatting; localized placeholder styles recognised |
|
||||
@@ -1079,11 +1116,13 @@ MATH glyphs that reference the same construction now share its immutable variant
|
||||
| 2026-10-17 | trivial | A line breaks after a hyphen before a digit, like Word (#2712) |
|
||||
| 2026-10-15 | trivial | Progress log: text above a float's anchor wraps around it, #2048 |
|
||||
| 2026-10-18 | trivial | Progress log: long navigation headings wrap within the pane instead of ending in an ellipsis (#3015) |
|
||||
| 2026-10-26 | trivial | View › Window across the native windows (#322): View catalog 26/29 → 29/29 |
|
||||
| 2026-10-18 | trivial | Font list starts at its first row after clearing a no-match query; recent heading remains visible (#2068) |
|
||||
| 2026-10-18 | trivial | A word after a slash wraps whole, like Word (#2963) |
|
||||
| 2026-10-19 | trivial | Progress log: render threads reused while typing (#3027) |
|
||||
| 2026-10-19 | trivial | #2964: a centred or end-aligned list label after a leading page break uses its text line's alignment. Synthetic LTR/RTL numbered/bulleted controls and the issue's DOCX export; broader pagination gaps and readiness estimates unchanged |
|
||||
| 2026-10-17 | trivial | Progress log: recipient list search and merged-only view (#1576) |
|
||||
| 2026-10-17 | trivial | Progress log: removing documents from the recent list (#2716) |
|
||||
| 2026-10-18 | trivial | Progress log: a single selected cell's border drags in its row only |
|
||||
| 2026-10-18 | trivial | Progress log: symbols keep their font when the text font changes (#2036) |
|
||||
| 2026-10-17 | trivial | Progress log: Restrict Editing guards tables, Paste Formatting and merge fields (#1327) |
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -21,6 +22,38 @@ const MAX_LINE: usize = 1 << 20;
|
||||
/// A connection that sends no authorised request in this time is closed.
|
||||
const FIRST_REQUEST: Duration = Duration::from_secs(30);
|
||||
|
||||
/// How long a caller waits for the UI to answer a request. A request still queued after this
|
||||
/// is cancelled and never runs.
|
||||
const REPLY_WAIT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Connections that have not yet sent an authorised request are limited to this many; more are
|
||||
/// closed at once. Each one holds a thread until its first request or [`FIRST_REQUEST`].
|
||||
const MAX_UNAUTHENTICATED: usize = 64;
|
||||
|
||||
/// Counts one unauthenticated connection until it authenticates or ends.
|
||||
struct Admission(Option<Arc<AtomicUsize>>);
|
||||
|
||||
impl Admission {
|
||||
fn enter(count: &Arc<AtomicUsize>, max: usize) -> Option<Self> {
|
||||
if count.fetch_add(1, Ordering::SeqCst) >= max {
|
||||
count.fetch_sub(1, Ordering::SeqCst);
|
||||
return None;
|
||||
}
|
||||
Some(Self(Some(count.clone())))
|
||||
}
|
||||
fn release(&mut self) {
|
||||
if let Some(c) = self.0.take() {
|
||||
c.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Admission {
|
||||
fn drop(&mut self) {
|
||||
self.release();
|
||||
}
|
||||
}
|
||||
|
||||
/// The key file of a running control server; [`KeyFile::remove`] it when the window closes.
|
||||
pub struct KeyFile(PathBuf);
|
||||
|
||||
@@ -37,6 +70,15 @@ impl KeyFile {
|
||||
/// cannot be bound or there is no random key (the control channel is off); the key file is
|
||||
/// `None` when it could not be written (requests are then refused).
|
||||
pub fn start(port: u16, ctx: egui::Context, settings: Option<&Path>) -> Option<(Receiver<ControlRequest>, Option<KeyFile>)> {
|
||||
start_with(port, ctx, settings, |listener, ctx, key| serve_on(listener, ctx, key, FIRST_REQUEST))
|
||||
}
|
||||
|
||||
fn start_with(
|
||||
port: u16,
|
||||
ctx: egui::Context,
|
||||
settings: Option<&Path>,
|
||||
serve: impl FnOnce(TcpListener, egui::Context, String) -> std::io::Result<Receiver<ControlRequest>>,
|
||||
) -> Option<(Receiver<ControlRequest>, Option<KeyFile>)> {
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
@@ -67,11 +109,21 @@ pub fn start(port: u16, ctx: egui::Context, settings: Option<&Path>) -> Option<(
|
||||
None
|
||||
}
|
||||
};
|
||||
let rx = match serve(listener, ctx, key) {
|
||||
Ok(rx) => rx,
|
||||
Err(e) => {
|
||||
if let Some(f) = &key_file {
|
||||
f.remove();
|
||||
}
|
||||
log::error!("control server off: cannot start the accept thread ({e})");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
match &key_file {
|
||||
Some(f) => log::info!("control server listening on 127.0.0.1:{port}, key in {}", f.path().display()),
|
||||
None => log::info!("control server listening on 127.0.0.1:{port}"),
|
||||
}
|
||||
Some((serve_on(listener, ctx, key, FIRST_REQUEST), key_file))
|
||||
Some((rx, key_file))
|
||||
}
|
||||
|
||||
/// A new key: 32 random bytes from the operating system, as hex.
|
||||
@@ -103,16 +155,47 @@ fn keys_match(given: &str, key: &str) -> bool {
|
||||
!b.is_empty() && a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||
}
|
||||
|
||||
fn serve_on(listener: TcpListener, ctx: egui::Context, key: String, first: Duration) -> Receiver<ControlRequest> {
|
||||
fn serve_on(listener: TcpListener, ctx: egui::Context, key: String, first: Duration) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
serve_with(listener, ctx, key, Limits { first, wait: REPLY_WAIT, max_unauthenticated: MAX_UNAUTHENTICATED })
|
||||
}
|
||||
|
||||
/// The time and count budgets of a server (small ones in tests).
|
||||
#[derive(Clone, Copy)]
|
||||
struct Limits {
|
||||
first: Duration,
|
||||
wait: Duration,
|
||||
max_unauthenticated: usize,
|
||||
}
|
||||
|
||||
fn serve_with(listener: TcpListener, ctx: egui::Context, key: String, limits: Limits) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
serve_with_spawner(listener, ctx, key, limits, |accept| std::thread::Builder::new().name("control-accept".into()).spawn(accept).map(|_| ()))
|
||||
}
|
||||
|
||||
fn serve_with_spawner(
|
||||
listener: TcpListener,
|
||||
ctx: egui::Context,
|
||||
key: String,
|
||||
limits: Limits,
|
||||
spawn: impl FnOnce(Box<dyn FnOnce() + Send>) -> std::io::Result<()>,
|
||||
) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let key: Arc<str> = key.into();
|
||||
std::thread::spawn(move || {
|
||||
let unauthenticated = Arc::new(AtomicUsize::new(0));
|
||||
let accept = move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let Some(admission) = Admission::enter(&unauthenticated, limits.max_unauthenticated) else {
|
||||
log::warn!("control server: too many unauthenticated connections, one closed");
|
||||
continue;
|
||||
};
|
||||
let (tx, ctx, key) = (tx.clone(), ctx.clone(), key.clone());
|
||||
std::thread::spawn(move || serve(stream, tx, ctx, &key, first));
|
||||
// A thread that cannot start closes this connection only (the guard releases its slot).
|
||||
if let Err(e) = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx, &key, limits, admission)) {
|
||||
log::warn!("control server: no thread for a connection: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
rx
|
||||
};
|
||||
spawn(Box::new(accept))?;
|
||||
Ok(rx)
|
||||
}
|
||||
|
||||
/// The read side of a connection with an optional overall deadline: each read waits only for
|
||||
@@ -175,11 +258,11 @@ fn discard_rest_of_line(r: &mut BufReader<Timed>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key: &str, first: Duration) {
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key: &str, limits: Limits, mut admission: Admission) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
// One overall deadline from accept to the first authorised request; after it, none.
|
||||
let mut reader = BufReader::new(Timed { s: read, deadline: Some(Instant::now() + first) });
|
||||
let mut reader = BufReader::new(Timed { s: read, deadline: Some(Instant::now() + limits.first) });
|
||||
let mut authorised = false;
|
||||
// The key an `auth` request gave this connection.
|
||||
let mut conn_key: Option<String> = None;
|
||||
@@ -228,6 +311,7 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key:
|
||||
}
|
||||
if !authorised {
|
||||
authorised = true;
|
||||
admission.release();
|
||||
reader.get_mut().set_deadline(None);
|
||||
}
|
||||
if method == "auth" {
|
||||
@@ -237,12 +321,18 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key:
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
let (req, rrx, waiter) = ControlRequest::with_waiter(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
let r = rrx.recv_timeout(limits.wait).unwrap_or_else(|_| {
|
||||
if waiter.cancel() {
|
||||
json!({"ok": false, "error": "timeout before execution: the request was not run and never will be"})
|
||||
} else {
|
||||
json!({"ok": false, "error": "timeout: outcome unknown, the request had started when the wait ended; inspect the document to check"})
|
||||
}
|
||||
});
|
||||
if !reply(r) {
|
||||
break;
|
||||
}
|
||||
@@ -260,7 +350,7 @@ mod tests {
|
||||
fn boot_with(first: Duration) -> (u16, Receiver<ControlRequest>) {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
(port, serve_on(listener, egui::Context::default(), KEY.to_string(), first))
|
||||
(port, serve_on(listener, egui::Context::default(), KEY.to_string(), first).unwrap())
|
||||
}
|
||||
|
||||
fn boot() -> (u16, Receiver<ControlRequest>) {
|
||||
@@ -512,6 +602,40 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_accept_thread_spawn_failure_aborts_start_and_removes_the_key() {
|
||||
let dir = temp_dir("spawn-failure");
|
||||
let path = wordcraft_control_key::key_path(&dir, 0);
|
||||
let mut bound = None;
|
||||
let result = start_with(0, egui::Context::default(), Some(&dir), |listener, ctx, key| {
|
||||
bound = Some(listener.local_addr().unwrap());
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), key, "startup wrote the key before spawning");
|
||||
serve_with_spawner(listener, ctx, key, small(150, 8), |accept| {
|
||||
drop(accept);
|
||||
Err(std::io::Error::other("injected accept thread spawn failure"))
|
||||
})
|
||||
});
|
||||
assert!(result.is_none(), "a server without its accept thread must not report success");
|
||||
assert!(!path.exists(), "failed startup must remove its published key");
|
||||
let _listener = TcpListener::bind(bound.unwrap()).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_complete_json_request_at_eof_can_reach_the_ui() {
|
||||
// This is why a bridge write failure before the newline is outcome-unknown.
|
||||
let (port, rx) = boot();
|
||||
let mut c = Conn::open(port);
|
||||
let body = json!({"id": 1, "key": KEY, "method": "engine.execute", "params": {"command": "text.insert", "params": {"text": "once"}}});
|
||||
c.1.write_all(body.to_string().as_bytes()).unwrap();
|
||||
c.1.shutdown(std::net::Shutdown::Write).unwrap();
|
||||
let req = answer(&rx);
|
||||
assert_eq!(req.params["params"]["text"], "once");
|
||||
assert_eq!(c.recv()["ok"], true);
|
||||
assert!(c.closed());
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_key_file_is_replaced() {
|
||||
let dir = temp_dir("stale");
|
||||
@@ -532,4 +656,52 @@ mod tests {
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn boot_limits(limits: Limits) -> (u16, Receiver<ControlRequest>) {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
(port, serve_with(listener, egui::Context::default(), KEY.to_string(), limits).unwrap())
|
||||
}
|
||||
|
||||
fn small(wait_ms: u64, max_unauthenticated: usize) -> Limits {
|
||||
Limits { first: FIRST_REQUEST, wait: Duration::from_millis(wait_ms), max_unauthenticated }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_that_timed_out_in_the_queue_never_runs() {
|
||||
let (port, rx) = boot_limits(small(150, 8));
|
||||
let mut c = Conn::open(port);
|
||||
let r = c.call(&format!(r#"{{"id":1,"key":"{KEY}","method":"engine.execute","params":{{"command":"text.insert"}}}}"#));
|
||||
assert_eq!(r["ok"], false);
|
||||
assert!(r["error"].as_str().unwrap_or("").starts_with("timeout before execution"), "{r}");
|
||||
// The UI gets to it late: it must not run it.
|
||||
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert!(!req.start(), "a cancelled request was started");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_that_started_before_the_timeout_reports_an_unknown_outcome() {
|
||||
let (port, rx) = boot_limits(small(150, 8));
|
||||
let mut c = Conn::open(port);
|
||||
c.send(&format!(r#"{{"id":1,"key":"{KEY}","method":"engine.execute","params":{{"command":"text.insert"}}}}"#));
|
||||
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert!(req.start());
|
||||
let r = c.recv();
|
||||
assert!(r["error"].as_str().unwrap_or("").contains("outcome unknown"), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unauthenticated_connections_are_limited() {
|
||||
let (port, rx) = boot_limits(small(5_000, 2));
|
||||
let mut idle = [Conn::open(port), Conn::open(port)];
|
||||
// Give the server time to count both.
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
assert!(Conn::open(port).closed(), "the third idle connection was admitted");
|
||||
// One authenticates: its slot is free again.
|
||||
assert_eq!(idle[0].call(&format!(r#"{{"id":1,"method":"auth","key":"{KEY}"}}"#))["ok"], true);
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
let mut again = Conn::open(port);
|
||||
assert_eq!(again.call(&format!(r#"{{"id":1,"method":"auth","key":"{KEY}"}}"#))["ok"], true);
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
}
|
||||
+19
-16
@@ -133,6 +133,7 @@ impl eframe::App for App {
|
||||
if let Some(key_file) = &self.2 {
|
||||
key_file.remove();
|
||||
}
|
||||
self.0.start_pending_update();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +193,8 @@ fn schedule_macos_terminate(app: &mut WordApp, key_file: Option<&control_server:
|
||||
if let Some(key_file) = key_file {
|
||||
key_file.remove();
|
||||
}
|
||||
// `terminate:` ends the process without dropping the app, so a ready update's helper starts here.
|
||||
app.start_pending_update();
|
||||
dispatch2::DispatchQueue::main().exec_async(|| {
|
||||
if let Some(mtm) = objc2::MainThreadMarker::new() {
|
||||
objc2_app_kit::NSApplication::sharedApplication(mtm).terminate(None);
|
||||
@@ -299,25 +302,24 @@ fn uses_x11(setting: wordcraft_ui_egui::DisplayServer, env: impl Fn(&str) -> Opt
|
||||
}
|
||||
|
||||
fn save_prefs(app: &WordApp) {
|
||||
if let Err(e) = try_save_prefs(app) {
|
||||
log::warn!("preferences: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the preferences and says why when it could not. `Ok(false)`: nothing was written
|
||||
/// because preferences are switched off or there is no settings folder.
|
||||
fn try_save_prefs(app: &WordApp) -> Result<bool, String> {
|
||||
if !prefs_enabled() {
|
||||
return;
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(p) = prefs_path() {
|
||||
if let Some(dir) = p.parent()
|
||||
&& let Err(e) = std::fs::create_dir_all(dir)
|
||||
{
|
||||
log::warn!("preferences: cannot create {}: {e}", dir.display());
|
||||
return;
|
||||
}
|
||||
match serde_json::to_vec_pretty(&app.prefs()) {
|
||||
Ok(bytes) => {
|
||||
if let Err(e) = write_prefs(&p, &bytes) {
|
||||
log::warn!("preferences: cannot save {}: {e}", p.display());
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("preferences: cannot serialize: {e}"),
|
||||
}
|
||||
let Some(p) = prefs_path() else { return Ok(false) };
|
||||
if let Some(dir) = p.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
|
||||
}
|
||||
let bytes = serde_json::to_vec_pretty(&app.prefs()).map_err(|e| format!("cannot serialize: {e}"))?;
|
||||
write_prefs(&p, &bytes).map_err(|e| format!("cannot save {}: {e}", p.display()))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Writes `bytes` to a sibling temporary file and renames it over `path`, so a failed or
|
||||
@@ -352,6 +354,7 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
|
||||
set_clipboard_rich: Some(Box::new(clipboard::set_rich)),
|
||||
clipboard_rtf: Some(Box::new(clipboard::rtf)),
|
||||
clipboard_formats: Some(Box::new(clipboard::formats)),
|
||||
save_prefs: Some(Box::new(try_save_prefs)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,6 +186,11 @@ pub struct Float {
|
||||
/// top. 0 = unknown, drawn in document order (objects inserted here, or from other formats).
|
||||
#[serde(skip_serializing_if = "is_zero_u32")]
|
||||
pub z: u32,
|
||||
/// The page a drawing anchored to a page is on, from 1 (ODF's `text:anchor-page-number`,
|
||||
/// §19.872); 0 = anchored to its paragraph or not said. Kept for saving: the layout places a
|
||||
/// drawing on its anchor paragraph's page.
|
||||
#[serde(skip_serializing_if = "is_zero_u32")]
|
||||
pub page: u32,
|
||||
/// Turned, but the file keeps no room for the turn (`wp:effectExtent` missing or zero, as
|
||||
/// files from other tools have): Word then keeps an inline one's line to its frame and lets
|
||||
/// the turned object overhang it (see [`Float::line_pad`]). Cleared by [`Float::set_spin`].
|
||||
|
||||
@@ -212,6 +212,32 @@ pub(crate) fn record(s: &mut Session, id: &str, before: &Document) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the tracked paragraph-property change for a command that also changes the paragraph's
|
||||
/// text. The ordinary after-command recorder deliberately compares only paragraphs with identical
|
||||
/// text, so mixed text/paragraph-format transformations use this before assigning their props.
|
||||
/// Made once per command, before it creates revisions: it keeps the revisions as they were then
|
||||
/// (all `Recorder::own` needs), instead of copying the list for every paragraph.
|
||||
pub(crate) struct ParaPropTracker {
|
||||
before: Vec<Revision>,
|
||||
}
|
||||
|
||||
impl ParaPropTracker {
|
||||
pub(crate) fn new(s: &Session) -> Self {
|
||||
let tracking = s.doc.settings.track_changes && s.prefs.markup.track_formatting;
|
||||
ParaPropTracker { before: if tracking { s.doc.revisions.clone() } else { Vec::new() } }
|
||||
}
|
||||
|
||||
pub(crate) fn changed(&self, s: &mut Session, before: &ParaProps, after: &ParaProps) -> Option<Box<PropChange<ParaProps>>> {
|
||||
if !s.doc.settings.track_changes || !s.prefs.markup.track_formatting {
|
||||
return after.fmt_change.clone();
|
||||
}
|
||||
let mut r = Recorder { author: s.author.clone(), date: super::now_iso(), before: &self.before, revisions: &mut s.doc.revisions, rid: None };
|
||||
r.change(before, after, ParaProps::formatting, |p| &p.fmt_change)
|
||||
.map(|Replacement(change)| change)
|
||||
.unwrap_or_else(|| after.fmt_change.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the paragraph formatting a command changed on the paragraph at `path` whose text it also
|
||||
/// changed, which [`record`] doesn't compare: `before` is the paragraph's properties before the
|
||||
/// command. As in [`record`], the author's own earlier change keeps the formatting before it and
|
||||
|
||||
@@ -185,6 +185,10 @@ fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w,
|
||||
if f.wrap == Wrap::Inline {
|
||||
f.wrap = Wrap::Square;
|
||||
}
|
||||
// A drawing anchored to a page is on this one now.
|
||||
if f.page > 0 {
|
||||
f.page = u32::try_from(page).map_or(f.page, |p| p.saturating_add(1));
|
||||
}
|
||||
f.h_rel = Anchor::Page;
|
||||
f.v_rel = Anchor::Page;
|
||||
f.h_align = None;
|
||||
@@ -199,6 +203,8 @@ fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w,
|
||||
s.touch();
|
||||
let origin = s.layout().object(&at, page).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?.origin;
|
||||
edit_float(s, &at, |f| {
|
||||
// On its paragraph now: a page it was anchored to is gone, not only hidden.
|
||||
f.page = 0;
|
||||
f.h_rel = Anchor::Column;
|
||||
f.v_rel = Anchor::Paragraph;
|
||||
f.x = (x - origin.x).clamp(-MAX_OFFSET, MAX_OFFSET);
|
||||
|
||||
@@ -16,6 +16,7 @@ mod lists;
|
||||
mod outline;
|
||||
mod styles;
|
||||
mod tabs_borders;
|
||||
mod tools;
|
||||
|
||||
use format::{align, asian_typography, cur, direction, line_spacing, ls, set_align, set_props, tog};
|
||||
pub use format::{cur as selection_para, fmt, indent, outdent};
|
||||
@@ -26,6 +27,10 @@ use outline::{move_paras, outline_step, sort};
|
||||
use styles::{apply_style, apply_styles, create_style, delete_style, modify_style, set_visibility, style_id, update_to_match};
|
||||
pub use tabs_borders::border_sides;
|
||||
use tabs_borders::{borders, tabs};
|
||||
use tools::{
|
||||
blank_between, convert_breaks, indent_to_spaces, remove_blank, remove_extra_spaces, remove_line_breaks, separate_paragraphs, spaces_to_indent,
|
||||
trim_leading,
|
||||
};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -270,6 +275,26 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
sel_result(s)
|
||||
}),
|
||||
// Advanced paragraph tools (#5043, as in WPS Office): conversions and cleanup over the
|
||||
// selection, or the whole document with `scope: "all"`. One undo step per command.
|
||||
CommandSpec::new("para.convertBreaks", "Convert Line Breaks to Paragraphs", "Home › Paragraph › Paragraph Tools", convert_breaks)
|
||||
.params(r#"{"scope"?: "selection|all"}"#),
|
||||
CommandSpec::new("para.blankBetween", "Insert Blank Paragraphs Between", "Home › Paragraph › Paragraph Tools", blank_between)
|
||||
.params(r#"{"scope"?: "selection|all", "after"?: bool (also after the last paragraph)}"#),
|
||||
CommandSpec::new("para.separateParagraphs", "Separate Paragraphs", "Home › Paragraph › Paragraph Tools", separate_paragraphs)
|
||||
.params(r#"{"scope"?: "selection|all"}"#),
|
||||
CommandSpec::new("para.removeBlank", "Delete Blank Paragraphs", "Home › Paragraph › Paragraph Tools", remove_blank)
|
||||
.params(r#"{"scope"?: "selection|all"}"#),
|
||||
CommandSpec::new("para.trimLeadingSpaces", "Remove Leading Spaces", "Home › Paragraph › Paragraph Tools", trim_leading)
|
||||
.params(r#"{"scope"?: "selection|all"}"#),
|
||||
CommandSpec::new("para.removeExtraSpaces", "Remove Extra Spaces", "Home › Paragraph › Paragraph Tools", remove_extra_spaces)
|
||||
.params(r#"{"scope"?: "selection|all"}"#),
|
||||
CommandSpec::new("para.spacesToIndent", "Convert Spaces to Indentation", "Home › Paragraph › Paragraph Tools", spaces_to_indent)
|
||||
.params(r#"{"scope"?: "selection|all"}"#),
|
||||
CommandSpec::new("para.indentToSpaces", "Convert Indents to Spaces", "Home › Paragraph › Paragraph Tools", indent_to_spaces)
|
||||
.params(r#"{"scope"?: "selection|all"}"#),
|
||||
CommandSpec::new("para.removeLineBreaks", "Remove Line Breaks", "Home › Paragraph › Paragraph Tools", remove_line_breaks)
|
||||
.params(r#"{"scope"?: "selection|all"}"#),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
//! Advanced paragraph tools (#5043): converting and cleaning up paragraphs, like WPS Office's
|
||||
//! paragraph tools. Each command works on the paragraphs the selection covers, or on the whole
|
||||
//! document with `scope: "all"`, and runs as one undo step. Deletions respect Track Changes.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use wordcraft_doc::para::LINE_BREAK;
|
||||
use wordcraft_doc::{Path, Pos, RevisionKind, StoryRef};
|
||||
|
||||
use super::{CmdError, CmdResult, Session, p, sel_result};
|
||||
use crate::cmd::{has_next_para, new_revision, split_para, track_delete};
|
||||
|
||||
/// The maximum number of paragraphs one call may touch: the work, and the blank paragraphs a
|
||||
/// command can add, stay bounded whatever the document says.
|
||||
const MAX_TOOL_PARAS: usize = 10_000;
|
||||
|
||||
/// Leading spaces one paragraph may gain from an indent, whatever the file's indent says.
|
||||
const MAX_INDENT_SPACES: usize = 1_000;
|
||||
|
||||
/// The scope of a tools command: `{"scope": "selection|all"}`, `selection` by default.
|
||||
/// `all` selects the whole document first, so the command, its undo step and the result all
|
||||
/// follow the full selection like any other command.
|
||||
fn scope_all(s: &mut Session, v: &Value) -> Result<(), CmdError> {
|
||||
match p::str(v, "scope").unwrap_or("selection") {
|
||||
"selection" => Ok(()),
|
||||
"all" => {
|
||||
s.run("select.all", &json!({"whole": true}))?;
|
||||
Ok(())
|
||||
}
|
||||
x => Err(CmdError::Params(format!("unknown scope `{x}` (selection|all)"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse a paragraph rewrite that would touch content locked against editing. Commands operate
|
||||
/// on whole paragraphs, so checking each full paragraph also covers controls outside the exact
|
||||
/// selection offsets in a selected paragraph.
|
||||
fn check_target_edits(s: &mut Session, story: StoryRef, paths: &[Path]) -> Result<(), CmdError> {
|
||||
let ranges: Vec<(Pos, Pos)> = paths
|
||||
.iter()
|
||||
.filter_map(|path| {
|
||||
let len = s.doc.para(story, path)?.len();
|
||||
Some((Pos { story, path: path.clone(), off: 0 }, Pos { story, path: path.clone(), off: len }))
|
||||
})
|
||||
.collect();
|
||||
for (a, b) in ranges {
|
||||
crate::cmd::controls::check_edit(s, &a, &b)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The paragraphs a command runs over, in document order, and the story they are in.
|
||||
fn targets(s: &Session) -> Result<(StoryRef, Vec<Path>), CmdError> {
|
||||
let (a, b) = s.sel.ordered();
|
||||
let paths = s.doc.para_paths_in(&a, &b);
|
||||
if paths.is_empty() {
|
||||
return Err(CmdError::Failed("no paragraphs selected".into()));
|
||||
}
|
||||
if paths.len() > MAX_TOOL_PARAS {
|
||||
return Err(CmdError::Params(format!("select at most {MAX_TOOL_PARAS} paragraphs to process them in one command")));
|
||||
}
|
||||
Ok((a.story, paths))
|
||||
}
|
||||
|
||||
/// Deletes `a..b` inside one paragraph, recording a tracked deletion when Track Changes is on.
|
||||
/// Offsets past the paragraph's end are clamped, so the document is never half-edited.
|
||||
fn delete_in_para(s: &mut Session, story: StoryRef, path: &Path, a: usize, b: usize) -> Result<(), CmdError> {
|
||||
let len = s.doc.para(story, path).ok_or_else(|| CmdError::Failed("no paragraph here".into()))?.len();
|
||||
let (a, b) = (a.min(b).min(len), b.min(len));
|
||||
if a >= b {
|
||||
return Ok(());
|
||||
}
|
||||
let at = |off: usize| Pos { story, path: path.clone(), off };
|
||||
crate::cmd::controls::check_delete(s, &at(a), &at(b))?;
|
||||
if s.doc.settings.track_changes {
|
||||
track_delete(s, &at(a), &at(b))?;
|
||||
} else {
|
||||
s.doc.delete_range(&at(a), &at(b))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The deletions to make in one paragraph, as byte ranges, merged where they touch and applied
|
||||
/// last first so the earlier offsets stay valid.
|
||||
fn apply_deletions(s: &mut Session, story: StoryRef, path: &Path, mut ranges: Vec<(usize, usize)>) -> Result<(), CmdError> {
|
||||
ranges.sort();
|
||||
let mut merged: Vec<(usize, usize)> = Vec::new();
|
||||
for (a, b) in ranges {
|
||||
match merged.last_mut() {
|
||||
Some(last) if a <= last.1 => last.1 = last.1.max(b),
|
||||
_ => merged.push((a, b)),
|
||||
}
|
||||
}
|
||||
for (a, b) in merged.into_iter().rev() {
|
||||
delete_in_para(s, story, path, a, b)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The characters of the paragraph that are not tracked-deleted, as (byte offset, char). Under
|
||||
/// Track Changes deleted text stays in `para.text`, so every scan that decides what to delete or
|
||||
/// count goes through this.
|
||||
fn live_chars(para: &wordcraft_doc::Paragraph) -> impl Iterator<Item = (usize, char)> + '_ {
|
||||
para.run_ranges().filter(|(_, c)| c.del.is_none()).flat_map(|(r, _)| para.text[r.clone()].char_indices().map(move |(i, c)| (r.start + i, c)))
|
||||
}
|
||||
|
||||
/// The byte ranges of every manual line break in the paragraph that is not already deleted.
|
||||
fn break_ranges(para: &wordcraft_doc::Paragraph) -> Vec<(usize, usize)> {
|
||||
live_chars(para).filter(|(_, c)| *c == LINE_BREAK).map(|(i, _)| (i, i + 1)).collect()
|
||||
}
|
||||
|
||||
/// `para.convertBreaks`: every manual line break (Shift+Enter) in the selection becomes a
|
||||
/// paragraph break. Paragraphs are handled last first: a split only shifts the paths after it.
|
||||
pub(super) fn convert_breaks(s: &mut Session, v: &Value) -> CmdResult {
|
||||
scope_all(s, v)?;
|
||||
let (story, paths) = targets(s)?;
|
||||
check_target_edits(s, story, &paths)?;
|
||||
let breaks: usize = paths.iter().filter_map(|path| s.doc.para(story, path)).map(|p| break_ranges(p).len()).sum();
|
||||
if breaks > MAX_TOOL_PARAS {
|
||||
return Err(CmdError::Params(format!("convert at most {MAX_TOOL_PARAS} line breaks at once")));
|
||||
}
|
||||
for path in paths.iter().rev() {
|
||||
let Some(para) = s.doc.para(story, path) else { continue };
|
||||
for (i, _) in break_ranges(para).into_iter().rev() {
|
||||
let new = split_para(s, &Pos { story, path: path.clone(), off: i })?;
|
||||
// The break stays at the head of the new paragraph; the paragraph break replaces it.
|
||||
delete_in_para(s, story, &new.path, 0, 1)?;
|
||||
}
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// `para.blankBetween`: insert one empty paragraph between the selected paragraphs; `after`
|
||||
/// adds one blank paragraph after the last selected paragraph too.
|
||||
pub(super) fn blank_between(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let after = p::bool(v, "after").unwrap_or(false);
|
||||
scope_all(s, v)?;
|
||||
let (story, paths) = targets(s)?;
|
||||
check_target_edits(s, story, &paths)?;
|
||||
let count = paths.len().saturating_sub(1) + usize::from(after);
|
||||
if count > MAX_TOOL_PARAS {
|
||||
return Err(CmdError::Params(format!("insert at most {MAX_TOOL_PARAS} blank paragraphs at once")));
|
||||
}
|
||||
// Last first, each split at a paragraph's end: a split only shifts the paths after it.
|
||||
if after {
|
||||
let last = paths.last().ok_or_else(|| CmdError::Failed("no paragraph selected".into()))?;
|
||||
let len = s.doc.para(story, last).map(|p| p.len()).unwrap_or(0);
|
||||
split_para(s, &Pos { story, path: last.clone(), off: len })?;
|
||||
}
|
||||
for path in paths[..paths.len().saturating_sub(1)].iter().rev() {
|
||||
let len = s.doc.para(story, path).map(|p| p.len()).unwrap_or(0);
|
||||
split_para(s, &Pos { story, path: path.clone(), off: len })?;
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// `para.separateParagraphs`: every selected paragraph becomes a paragraph of its own with a
|
||||
/// blank line between them.
|
||||
pub(super) fn separate_paragraphs(s: &mut Session, v: &Value) -> CmdResult {
|
||||
blank_between(s, &json!({"scope": p::str(v, "scope").unwrap_or("selection"), "after": true}))
|
||||
}
|
||||
|
||||
/// `para.removeBlank`: delete every empty paragraph in the selection.
|
||||
pub(super) fn remove_blank(s: &mut Session, v: &Value) -> CmdResult {
|
||||
scope_all(s, v)?;
|
||||
let (story, paths) = targets(s)?;
|
||||
check_target_edits(s, story, &paths)?;
|
||||
// Last first: removing a block leaves the paths before it as they are.
|
||||
for path in paths.iter().rev() {
|
||||
let Some(para) = s.doc.para(story, path) else { continue };
|
||||
// A paragraph must be empty, carry no section break and have a paragraph after it in
|
||||
// its container: a story's, part's or cell's last mark can't be removed, and a paragraph
|
||||
// before a table can't be joined across it.
|
||||
if !para.is_empty() || para.section.is_some() || !has_next_para(s, story, path) {
|
||||
continue;
|
||||
}
|
||||
let next = path.with_last(path.last() + 1);
|
||||
let (a, b) = (Pos { story, path: path.clone(), off: 0 }, Pos { story, path: next, off: 0 });
|
||||
crate::cmd::controls::check_delete(s, &a, &b)?;
|
||||
if s.doc.settings.track_changes {
|
||||
track_delete(s, &a, &b)?;
|
||||
} else {
|
||||
s.doc.remove_block(story, path)?;
|
||||
}
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// The number of leading spaces in the paragraph's text, deleted ones included (the range
|
||||
/// `trimLeadingSpaces` deletes).
|
||||
fn leading_spaces(para: &wordcraft_doc::Paragraph) -> usize {
|
||||
para.text.chars().take_while(|c| *c == ' ').count()
|
||||
}
|
||||
|
||||
/// The byte offsets of the paragraph's leading spaces that are not tracked-deleted.
|
||||
fn live_leading_spaces(para: &wordcraft_doc::Paragraph) -> Vec<usize> {
|
||||
live_chars(para).take_while(|(_, c)| *c == ' ').map(|(i, _)| i).collect()
|
||||
}
|
||||
|
||||
/// `para.trimLeadingSpaces`: delete the spaces at the start of each selected paragraph.
|
||||
pub(super) fn trim_leading(s: &mut Session, v: &Value) -> CmdResult {
|
||||
scope_all(s, v)?;
|
||||
let (story, paths) = targets(s)?;
|
||||
check_target_edits(s, story, &paths)?;
|
||||
for path in paths {
|
||||
let Some(para) = s.doc.para(story, &path) else { continue };
|
||||
let n = leading_spaces(para);
|
||||
apply_deletions(s, story, &path, vec![(0, n)])?;
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// `para.removeExtraSpaces`: every run of spaces outside the paragraph's start becomes one
|
||||
/// space. Tabs and page breaks are document structure, not redundant spaces, and stay in place.
|
||||
/// Leading spaces stay (that is `para.trimLeadingSpaces`).
|
||||
pub(super) fn remove_extra_spaces(s: &mut Session, v: &Value) -> CmdResult {
|
||||
scope_all(s, v)?;
|
||||
let (story, paths) = targets(s)?;
|
||||
check_target_edits(s, story, &paths)?;
|
||||
for path in paths {
|
||||
let Some(para) = s.doc.para(story, &path) else { continue };
|
||||
// Runs of live spaces (deleted characters are skipped, as if absent). A run at the
|
||||
// paragraph's start stays; a run inside the text collapses to its last space, and one
|
||||
// that reaches the paragraph's end to its first.
|
||||
let mut runs = Vec::new();
|
||||
let mut run: Vec<usize> = Vec::new();
|
||||
let mut leading = true;
|
||||
for (i, c) in live_chars(para) {
|
||||
if c == ' ' {
|
||||
run.push(i);
|
||||
continue;
|
||||
}
|
||||
if !leading && run.len() > 1 {
|
||||
runs.extend(run[..run.len() - 1].iter().map(|&a| (a, a + 1)));
|
||||
}
|
||||
run.clear();
|
||||
leading = false;
|
||||
}
|
||||
if !leading && run.len() > 1 {
|
||||
runs.extend(run[1..].iter().map(|&a| (a, a + 1)));
|
||||
}
|
||||
apply_deletions(s, story, &path, runs)?;
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// `para.spacesToIndent`: each paragraph's leading spaces become a first-line indent of 72 pt
|
||||
/// per space (Word's point-based indent from typing spaces), and are deleted.
|
||||
pub(super) fn spaces_to_indent(s: &mut Session, v: &Value) -> CmdResult {
|
||||
scope_all(s, v)?;
|
||||
let (story, paths) = targets(s)?;
|
||||
check_target_edits(s, story, &paths)?;
|
||||
let tracker = crate::cmd::fmt_revisions::ParaPropTracker::new(s);
|
||||
for path in paths {
|
||||
let Some(para) = s.doc.para(story, &path) else { continue };
|
||||
let live = live_leading_spaces(para);
|
||||
let spaces = live.len();
|
||||
if spaces == 0 {
|
||||
continue;
|
||||
}
|
||||
let current = para.props.indent_first.unwrap_or(0.0).clamp(-1584.0, 1584.0);
|
||||
let represented = spaces.min(((1584.0 - current).max(0.0) / 72.0).floor() as usize);
|
||||
if represented == 0 {
|
||||
continue;
|
||||
}
|
||||
let before = para.props.clone();
|
||||
let mut after = before.clone();
|
||||
after.indent_first = Some(current + represented as f32 * 72.0);
|
||||
after.cancel_char_indents(false, false, true);
|
||||
after.fmt_change = tracker.changed(s, &before, &after);
|
||||
{
|
||||
let p = s.doc.para_mut(story, &path)?;
|
||||
p.props = after;
|
||||
p.touch();
|
||||
}
|
||||
// A space is one byte; deleted spaces in between stay as they are.
|
||||
apply_deletions(s, story, &path, live[..represented].iter().map(|&a| (a, a + 1)).collect())?;
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// `para.indentToSpaces`: each paragraph's first-line and left indents become leading spaces
|
||||
/// (72 pt per space, as typing spaces indents), and the indents go to zero.
|
||||
pub(super) fn indent_to_spaces(s: &mut Session, v: &Value) -> CmdResult {
|
||||
scope_all(s, v)?;
|
||||
let (story, paths) = targets(s)?;
|
||||
check_target_edits(s, story, &paths)?;
|
||||
let tracker = crate::cmd::fmt_revisions::ParaPropTracker::new(s);
|
||||
for path in paths {
|
||||
let Some(para) = s.doc.para(story, &path) else { continue };
|
||||
let pts = para.props.indent_left.unwrap_or(0.0) + para.props.indent_first.unwrap_or(0.0);
|
||||
let n = (pts / 72.0).round().clamp(0.0, MAX_INDENT_SPACES as f32) as usize;
|
||||
if n == 0 && para.props.indent_left.unwrap_or(0.0) == 0.0 && para.props.indent_first.unwrap_or(0.0) == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let mut props = s.doc.para(story, &path).map(|p| p.props_at(0)).cloned().unwrap_or_default();
|
||||
// The revision first: `new_revision` edits `s.doc`, so it runs before the paragraph borrow.
|
||||
// The spaces are marked as a tracked insertion through `props.ins`, as typing is.
|
||||
props.fmt_change = None;
|
||||
props.del = None;
|
||||
if s.doc.settings.track_changes && n > 0 {
|
||||
props.ins = Some(new_revision(s, RevisionKind::Insert));
|
||||
} else {
|
||||
props.ins = None;
|
||||
}
|
||||
let before = s.doc.para(story, &path).map(|p| p.props.clone()).unwrap_or_default();
|
||||
let mut after = before.clone();
|
||||
after.indent_first = Some(0.0);
|
||||
after.indent_left = Some(0.0);
|
||||
after.cancel_char_indents(true, false, true);
|
||||
after.fmt_change = tracker.changed(s, &before, &after);
|
||||
let p = s.doc.para_mut(story, &path)?;
|
||||
if n > 0 {
|
||||
p.insert_text(0, &" ".repeat(n), &props)?;
|
||||
}
|
||||
p.props = after;
|
||||
p.touch();
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// `para.removeLineBreaks`: delete every manual line break in the selection, so the lines
|
||||
/// join into one paragraph.
|
||||
pub(super) fn remove_line_breaks(s: &mut Session, v: &Value) -> CmdResult {
|
||||
scope_all(s, v)?;
|
||||
let (story, paths) = targets(s)?;
|
||||
check_target_edits(s, story, &paths)?;
|
||||
for path in paths {
|
||||
let Some(para) = s.doc.para(story, &path) else { continue };
|
||||
apply_deletions(s, story, &path, break_ranges(para))?;
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
@@ -14,7 +14,7 @@ mod enter_tab;
|
||||
mod unicode;
|
||||
|
||||
use autoformat::insert;
|
||||
use delete::{backspace, delete, delete_word_back, delete_word_fwd};
|
||||
use delete::{backspace, delete, delete_sentence_back, delete_word_back, delete_word_fwd};
|
||||
pub use enter_tab::MAX_TYPED_PARAGRAPHS;
|
||||
use enter_tab::{back_tab, new_paragraph, tab};
|
||||
pub use unicode::{alt_code_char, oem_code_page, system_code_pages, system_oem_code_page, windows_code_pages};
|
||||
@@ -36,6 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("text.backspace", "Backspace", "Editing", backspace).key("Backspace"),
|
||||
CommandSpec::new("text.delete", "Delete", "Editing", delete).key("Delete"),
|
||||
CommandSpec::new("text.deleteWordBack", "Delete Previous Word", "Editing", delete_word_back).key("Mod+Backspace / Alt+Backspace"),
|
||||
CommandSpec::new("text.deleteSentenceBack", "Delete Previous Sentence", "Editing", delete_sentence_back).key("Mod+Shift+Backspace"),
|
||||
CommandSpec::new("text.deleteWordForward", "Delete Next Word", "Editing", delete_word_fwd).key("Mod+Delete / Alt+Delete"),
|
||||
CommandSpec::new("text.nbsp", "Nonbreaking Space", "Insert › Symbols", |s, _| ins_char(s, NBSP)).key("Mod+Shift+Space"),
|
||||
CommandSpec::new("text.nbHyphen", "Nonbreaking Hyphen", "Insert › Symbols", |s, _| ins_char(s, NB_HYPHEN)).key("Mod+Shift+-"),
|
||||
|
||||
@@ -237,3 +237,20 @@ pub(super) fn delete_word_fwd(s: &mut Session, v: &Value) -> CmdResult {
|
||||
crate::cmd::controls::restore_placeholder(s)?;
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Delete to the current sentence start, or the preceding sentence when exactly at its boundary.
|
||||
/// Sentence boundaries match Select Sentence. Never remove a paragraph or cell boundary.
|
||||
pub(super) fn delete_sentence_back(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if !s.sel.is_collapsed() {
|
||||
return backspace(s, v);
|
||||
}
|
||||
let mut f = s.sel.focus.clone();
|
||||
let Some(para) = s.doc.para_at(&f) else { return sel_result(s) };
|
||||
f.off = para.clamp(f.off);
|
||||
let Some((previous, _)) = para.text.get(..f.off).and_then(|text| text.char_indices().next_back()) else { return sel_result(s) };
|
||||
let start = para.sentence_at(previous).0;
|
||||
s.sel = Selection { anchor: Pos { off: start, ..f.clone() }, focus: f };
|
||||
delete_selection(s)?;
|
||||
crate::cmd::controls::restore_placeholder(s)?;
|
||||
sel_result(s)
|
||||
}
|
||||
@@ -341,3 +341,42 @@ pub(super) fn customize_ribbon(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
Ok(Value::Object(out))
|
||||
}
|
||||
|
||||
/// The rows of an `insert.spreadsheet` call: `values` (a rectangular array of cells) or `csv`.
|
||||
pub(super) fn spreadsheet_rows(v: &Value) -> Result<Vec<Vec<String>>, CmdError> {
|
||||
// The CSV fallback is for an absent `values`; an explicit null is a malformed argument.
|
||||
let Some(values) = v.get("values") else {
|
||||
return Ok(crate::cmd::mailings::parse_csv(p::str(v, "csv").unwrap_or("A,B,C\n1,2,3\n4,5,6")));
|
||||
};
|
||||
let list = values.as_array().ok_or_else(|| CmdError::Params("`values` must be an array of rows".into()))?;
|
||||
if list.is_empty() || list.len() > 1000 {
|
||||
return Err(CmdError::Params("`values` needs 1 to 1000 rows".into()));
|
||||
}
|
||||
let mut rows: Vec<Vec<String>> = Vec::with_capacity(list.len());
|
||||
for (r, row) in list.iter().enumerate() {
|
||||
let cells = row.as_array().ok_or_else(|| CmdError::Params(format!("`values` row {} is not an array", r + 1)))?;
|
||||
if cells.is_empty() || cells.len() > 63 {
|
||||
return Err(CmdError::Params(format!("`values` row {} needs 1 to 63 cells", r + 1)));
|
||||
}
|
||||
if rows.first().is_some_and(|f| f.len() != cells.len()) {
|
||||
return Err(CmdError::Params(format!(
|
||||
"`values` row {} has {} cells, row 1 has {}",
|
||||
r + 1,
|
||||
cells.len(),
|
||||
rows.first().map_or(0, Vec::len)
|
||||
)));
|
||||
}
|
||||
let mut out = Vec::with_capacity(cells.len());
|
||||
for cell in cells {
|
||||
out.push(match cell {
|
||||
Value::String(x) => x.clone(),
|
||||
Value::Number(x) => x.to_string(),
|
||||
Value::Bool(x) => x.to_string(),
|
||||
Value::Null => String::new(),
|
||||
_ => return Err(CmdError::Params(format!("`values` row {} holds a cell that is not text, a number or a boolean", r + 1))),
|
||||
});
|
||||
}
|
||||
rows.push(out);
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::actions::{
|
||||
customize_keyboard, customize_ribbon, hf_nav, language, macros, preferred_language, quick_parts, search_commands, select_similar,
|
||||
spreadsheet_rows,
|
||||
};
|
||||
use super::autocorrect::autocorrect_options;
|
||||
use super::compare::{COMPARE_PARAMS, compare};
|
||||
@@ -213,10 +214,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.params(r#"{"path": string}"#),
|
||||
CommandSpec::new("insert.spreadsheet", "Spreadsheet Table", "Insert › Tables", |s, v| {
|
||||
let csv = p::str(v, "csv").unwrap_or("A,B,C\n1,2,3\n4,5,6");
|
||||
let rows = crate::cmd::mailings::parse_csv(csv);
|
||||
let rows = spreadsheet_rows(v)?;
|
||||
let cols = rows.iter().map(Vec::len).max().unwrap_or(1).clamp(1, 63);
|
||||
s.run("insert.table", &json!({"rows": rows.len().max(1), "cols": cols, "style": "GridTable1Light"}))?;
|
||||
let style = p::str(v, "style").unwrap_or("GridTable1Light");
|
||||
s.run("insert.table", &json!({"rows": rows.len().max(1), "cols": cols, "style": style}))?;
|
||||
let Some((tp, _, _)) = s.sel.focus.path.cell() else { return sel_result(s) };
|
||||
let t = s.doc.table_mut(s.sel.focus.story, &tp)?;
|
||||
for (r, row) in rows.iter().enumerate() {
|
||||
@@ -226,9 +227,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if p::bool(v, "header").unwrap_or(false)
|
||||
&& let Some(first) = t.rows.first_mut()
|
||||
{
|
||||
first.props.header = true;
|
||||
}
|
||||
sel_result(s)
|
||||
})
|
||||
.params(r#"{"csv"?: string}"#),
|
||||
.params(r#"{"csv"?: string, "values"?: [[cell, …], …] (rows of equal length, at most 1000 rows × 63 columns; wins over csv), "header"?: bool (first row repeats on each page), "style"?: table style name (default GridTable1Light)}"#),
|
||||
CommandSpec::new("layout.linkToPrevious", "Link to Previous", "Header & Footer › Navigation", |s, v| {
|
||||
let header = p::bool(v, "footer").map(|f| !f).unwrap_or(true);
|
||||
let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize;
|
||||
|
||||
@@ -255,6 +255,59 @@ fn proofing_language_options() {
|
||||
assert!(s.run("review.language", &json!({"lang": "<script>"})).is_err());
|
||||
}
|
||||
|
||||
/// #2242: `insert.spreadsheet` fills a table from `values`, with a repeating header and a style.
|
||||
#[test]
|
||||
fn spreadsheet_takes_values_header_and_style() {
|
||||
let mut s = Session::new(Document::from_text(""));
|
||||
s.run("insert.spreadsheet", &json!({"values": [["Month", "Cases"], ["Jan", 12], ["Feb", null]], "header": true, "style": "TableGrid"})).unwrap();
|
||||
let Some(Block::Table(t)) = s.doc.body.iter().map(|b| &**b).find(|b| matches!(b, Block::Table(_))) else { panic!("no table") };
|
||||
assert_eq!((t.rows.len(), t.rows[0].cells.len()), (3, 2));
|
||||
assert!(t.rows[0].props.header && !t.rows[1].props.header);
|
||||
assert_eq!(t.rows[1].cells[1].blocks[0].as_para().unwrap().text, "12");
|
||||
assert_eq!(t.props.style.as_deref(), Some("TableGrid"));
|
||||
}
|
||||
|
||||
/// An explicit `values: null` is a parameter error, not the legacy CSV sample table: the document
|
||||
/// and the undo history stay as they were. Only an absent `values` falls back to `csv`.
|
||||
#[test]
|
||||
fn spreadsheet_rejects_an_explicit_null_values_without_changing_anything() {
|
||||
let mut s = Session::new(Document::from_text("keep"));
|
||||
let depth = s.undo_depth();
|
||||
for args in [json!({"values": null}), json!({"values": null, "csv": "a,b\n1,2"})] {
|
||||
assert!(s.run("insert.spreadsheet", &args).is_err(), "{args}");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "keep");
|
||||
assert!(!s.doc.body.iter().any(|b| matches!(&**b, Block::Table(_))));
|
||||
assert_eq!(s.undo_depth(), depth, "{args}: no undo step");
|
||||
}
|
||||
s.run("insert.spreadsheet", &json!({"csv": "a,b\n1,2"})).unwrap();
|
||||
let Some(Block::Table(t)) = s.doc.body.iter().map(|b| &**b).find(|b| matches!(b, Block::Table(_))) else { panic!("no table") };
|
||||
assert_eq!((t.rows.len(), t.rows[0].cells.len()), (2, 2), "an absent `values` still takes the csv");
|
||||
}
|
||||
|
||||
/// #2242: malformed `values` is an error, never a panic, and leaves the document alone.
|
||||
#[test]
|
||||
fn spreadsheet_rejects_hostile_values() {
|
||||
let wide: Vec<Value> = (0..64).map(|i| json!(i)).collect();
|
||||
let many: Vec<Value> = (0..1001).map(|_| json!(["x"])).collect();
|
||||
for bad in [
|
||||
json!("x"),
|
||||
json!(5),
|
||||
json!([]),
|
||||
json!(["a"]),
|
||||
json!([["a", "b"], ["c"]]),
|
||||
json!([wide]),
|
||||
json!(many),
|
||||
json!([[{"a": 1}]]),
|
||||
json!([[[1]]]),
|
||||
json!([[]]),
|
||||
] {
|
||||
let mut s = Session::new(Document::from_text("keep"));
|
||||
assert!(s.run("insert.spreadsheet", &json!({"values": bad})).is_err(), "{bad}");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "keep");
|
||||
assert!(!s.doc.body.iter().any(|b| matches!(&**b, Block::Table(_))));
|
||||
}
|
||||
}
|
||||
|
||||
/// The inspector's removals delete straight from the document, so they aren't tracked: on a
|
||||
/// document that tracks changes the text would go with no revision to show for it, and the
|
||||
/// revision trail would be broken behind the user's back. The removal is refused instead,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! View tab and status bar: views, zoom, show/hide, panes.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::{Zoom, ZoomFit};
|
||||
use wordcraft_layout::{NumeralMode, ViewMode};
|
||||
@@ -40,6 +41,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("view.readPageColor", "Reading Page Color", "Reading › Layout", read_page_color)
|
||||
.params(r#"{"value": "none" | "sepia" | "inverse"}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("view.paperColor", "Screen Paper Color", "View › Display", |s, v| {
|
||||
let color: PaperColor = serde_json::from_value(v.get("value").cloned().unwrap_or(Value::Null))
|
||||
.map_err(|_| CmdError::Params("screen paper color must be document, green, cream, or gray".into()))?;
|
||||
s.prefs.paper_color = color;
|
||||
if color != PaperColor::Document {
|
||||
s.view.dark_mode = false;
|
||||
}
|
||||
Ok(json!({"paperColor": color}))
|
||||
}).params(r#"{"value": "document" | "green" | "cream" | "gray"}"#).pure(),
|
||||
// Print Layout: hide the white space (margins, headers, footers) between pages. Screen only.
|
||||
CommandSpec::new("view.hideWhiteSpace", "Hide White Space", "View › Show", |s, v| toggle(s, v, |x| &mut x.hide_white_space))
|
||||
.params(r#"{"value"?: bool}"#)
|
||||
@@ -141,6 +151,30 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.key("Mod+Alt+S")
|
||||
.pure(),
|
||||
// View › Window across WordCraft windows (#322): the desktop app's native windows
|
||||
// perform these (see `Windows`), elsewhere they are disabled.
|
||||
CommandSpec::new("view.arrangeAll", "Arrange All", "View › Window", |s, _| {
|
||||
s.ui_requests.push(json!({"windows": "arrange"}));
|
||||
Ok(json!({"windows": s.windows.others.len() + 1}))
|
||||
})
|
||||
.when(windows_available)
|
||||
.pure(),
|
||||
CommandSpec::new("view.sideBySide", "View Side by Side", "View › Window", side_by_side)
|
||||
.params(r#"{"value"?: bool, "window"?: id of the other window (optional when one other window is open)}"#)
|
||||
.when(|s| match windows_available(s) {
|
||||
None if s.windows.side_by_side.is_none() => has_other_window(s),
|
||||
r => r,
|
||||
})
|
||||
.pure(),
|
||||
CommandSpec::new("view.syncScroll", "Synchronous Scrolling", "View › Window", |s, v| {
|
||||
let Some(with) = s.windows.side_by_side else { return Err(CmdError::Disabled("turn on View Side by Side first".into())) };
|
||||
s.windows.sync_scroll = p::bool(v, "value").unwrap_or(!s.windows.sync_scroll);
|
||||
s.ui_requests.push(json!({"windows": "syncScroll", "with": with, "value": s.windows.sync_scroll}));
|
||||
Ok(json!({"value": s.windows.sync_scroll}))
|
||||
})
|
||||
.params(r#"{"value"?: bool}"#)
|
||||
.when(|s| if s.windows.side_by_side.is_some() { None } else { Some("turn on View Side by Side first") })
|
||||
.pure(),
|
||||
// The window fills the screen, OS-level full screen (#980). F11 as in browsers and most
|
||||
// desktop apps; ⌃⌘F first on macOS, where Ctrl is a key of its own (elsewhere Mod is Ctrl,
|
||||
// so "Mod+Ctrl+F" would read Ctrl+Ctrl+F). Escape also leaves it. The front end applies it.
|
||||
@@ -170,6 +204,83 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// View › Window (#322): the other native windows of the app as the window being drawn sees them
|
||||
/// (by window id, the ids of `ui.inspect`'s `windows`), and its Side by Side pairing. The front
|
||||
/// end keeps these current as each window draws; elsewhere (web, CLI, MCP without the app) they
|
||||
/// stay empty and the window commands are disabled.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Windows {
|
||||
/// The host can list and move its windows (the desktop app).
|
||||
pub available: bool,
|
||||
/// The other open windows, by id.
|
||||
pub others: Vec<OtherWindow>,
|
||||
/// View Side by Side is on, with this window.
|
||||
pub side_by_side: Option<u64>,
|
||||
/// Synchronous Scrolling (only while Side by Side is on).
|
||||
pub sync_scroll: bool,
|
||||
}
|
||||
|
||||
/// Another open WordCraft window.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct OtherWindow {
|
||||
pub id: u64,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
fn windows_available(s: &Session) -> Option<&'static str> {
|
||||
if s.windows.available { None } else { Some("only the desktop app has several windows") }
|
||||
}
|
||||
|
||||
fn has_other_window(s: &Session) -> Option<&'static str> {
|
||||
windows_available(s).or(if s.windows.others.is_empty() { Some("no other WordCraft window is open") } else { None })
|
||||
}
|
||||
|
||||
/// The window a command means: `window`, or the only other one.
|
||||
fn pick_window(s: &Session, v: &Value) -> Result<u64, CmdError> {
|
||||
let others = &s.windows.others;
|
||||
match p::u64(v, "window") {
|
||||
Some(id) if others.iter().any(|w| w.id == id) => Ok(id),
|
||||
Some(id) => Err(CmdError::Params(format!("no open window {id}"))),
|
||||
None => match others.as_slice() {
|
||||
[only] => Ok(only.id),
|
||||
[] => Err(CmdError::Disabled("no other WordCraft window is open".into())),
|
||||
several => {
|
||||
let list: Vec<String> = several.iter().map(|w| format!("{} ({})", w.id, w.title)).collect();
|
||||
Err(CmdError::Params(format!("pass `window`: one of {}", list.join(", "))))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// View Side by Side: on with a window (left this one, right that one, scrolling together), or
|
||||
/// off.
|
||||
fn side_by_side(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let on = p::bool(v, "value").unwrap_or(s.windows.side_by_side.is_none() || v.get("window").is_some());
|
||||
if !on {
|
||||
if let Some(id) = s.windows.side_by_side.take() {
|
||||
s.ui_requests.push(json!({"windows": "sideBySide", "off": id}));
|
||||
}
|
||||
s.windows.sync_scroll = false;
|
||||
return Ok(json!({"value": false}));
|
||||
}
|
||||
let id = match (s.windows.side_by_side, v.get("window")) {
|
||||
(Some(id), None) => id,
|
||||
_ => pick_window(s, v)?,
|
||||
};
|
||||
if let Some(old) = s.windows.side_by_side.filter(|old| *old != id) {
|
||||
s.ui_requests.push(json!({"windows": "sideBySide", "off": old}));
|
||||
}
|
||||
// The partner it already has keeps the user's Synchronous Scrolling choice and is not asked
|
||||
// again; a new pairing starts with scrolling on.
|
||||
if s.windows.side_by_side != Some(id) {
|
||||
s.windows.side_by_side = Some(id);
|
||||
s.windows.sync_scroll = true;
|
||||
s.ui_requests.push(json!({"windows": "sideBySide", "with": id}));
|
||||
}
|
||||
Ok(json!({"value": true, "window": id, "syncScroll": s.windows.sync_scroll}))
|
||||
}
|
||||
|
||||
/// The browser keeps full screen to itself (its own F11); the web app can't ask for it.
|
||||
fn full_screen_available(_: &Session) -> Option<&'static str> {
|
||||
cfg!(target_arch = "wasm32").then_some("Use the browser's full screen (F11)")
|
||||
@@ -234,6 +345,40 @@ fn read_line_spacing(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(json!({"spacing": spacing}))
|
||||
}
|
||||
|
||||
/// Screen-only paper presets, saved with user preferences, never in the document.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PaperColor {
|
||||
#[default]
|
||||
Document,
|
||||
Green,
|
||||
Cream,
|
||||
Gray,
|
||||
}
|
||||
|
||||
impl PaperColor {
|
||||
pub const ALL: [Self; 4] = [Self::Document, Self::Green, Self::Cream, Self::Gray];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Document => "Document background",
|
||||
Self::Green => "Soft green",
|
||||
Self::Cream => "Warm cream",
|
||||
Self::Gray => "Soft gray",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn color(self) -> wordcraft_doc::props::Rgb {
|
||||
use wordcraft_doc::props::Rgb;
|
||||
match self {
|
||||
Self::Document => Rgb::WHITE,
|
||||
Self::Green => Rgb(0xCC, 0xE8, 0xCF),
|
||||
Self::Cream => Rgb(0xFA, 0xF0, 0xDC),
|
||||
Self::Gray => Rgb(0xE8, 0xEA, 0xED),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read Mode › View › Page Color, as in Word. Screen only: the document and its own page color
|
||||
/// are unchanged.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
|
||||
@@ -10,10 +10,15 @@ pub fn open(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Opti
|
||||
"doc" | "dot" => Some(wordcraft_docbin::read(bytes).map_err(|e| e.to_string())),
|
||||
// Word XML Document (Flat OPC) or Word 2003 XML, told apart by the root element.
|
||||
"xml" => Some(wordcraft_docx::read_xml(bytes).map_err(|e| e.to_string())),
|
||||
// HTML, Markdown, ODT, RTF…: pictures fit the text width (see `io::fit_pictures`).
|
||||
// HTML, Markdown, RTF…: pictures fit the text width (see `io::fit_pictures`). An
|
||||
// OpenDocument file says how big they are, and one wider than the text is meant to be;
|
||||
// changing it would take its paragraph off being written as read. Its reader fits those
|
||||
// that state no size.
|
||||
other => wordcraft_formats::import_on(other, bytes, page).map(|r| {
|
||||
r.map(|mut doc| {
|
||||
crate::io::fit_pictures(&mut doc);
|
||||
if !matches!(other, "odt" | "ott" | "fodt") {
|
||||
crate::io::fit_pictures(&mut doc);
|
||||
}
|
||||
draw_charts(&mut doc);
|
||||
doc
|
||||
})
|
||||
|
||||
@@ -7,6 +7,9 @@ use serde::{Deserialize, Serialize};
|
||||
pub struct Prefs {
|
||||
/// Word Count includes text boxes, footnotes and endnotes (Word's default).
|
||||
pub count_notes: bool,
|
||||
/// On-screen paper for pages without a document background; excluded from saved documents.
|
||||
#[serde(deserialize_with = "lenient_paper_color")]
|
||||
pub paper_color: crate::cmd::view::PaperColor,
|
||||
/// Track Changes Options: what markup shows and how revisions are drawn (per user, as in
|
||||
/// Word).
|
||||
pub markup: wordcraft_layout::display::MarkupOptions,
|
||||
@@ -64,6 +67,7 @@ pub struct Prefs {
|
||||
impl Default for Prefs {
|
||||
fn default() -> Self {
|
||||
Prefs {
|
||||
paper_color: Default::default(),
|
||||
count_notes: true,
|
||||
markup: Default::default(),
|
||||
print_markup: false,
|
||||
@@ -105,6 +109,11 @@ fn lenient_default_language<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Op
|
||||
Ok(value.as_str().and_then(wordcraft_proof::lang::normalize_tag))
|
||||
}
|
||||
|
||||
fn lenient_paper_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result<crate::cmd::view::PaperColor, D::Error> {
|
||||
let value = serde_json::Value::deserialize(d)?;
|
||||
Ok(serde_json::from_value(value).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Ignore an invalid saved Page Color without discarding other preferences.
|
||||
fn lenient_read_page_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result<crate::cmd::view::ReadPageColor, D::Error> {
|
||||
let value = serde_json::Value::deserialize(d)?;
|
||||
|
||||
@@ -183,6 +183,8 @@ pub struct Session {
|
||||
pub merge: crate::cmd::mailings::MergeState,
|
||||
/// Requests from commands to the UI (open a dialog, scroll…), drained by the front end.
|
||||
pub ui_requests: Vec<Value>,
|
||||
/// View › Window: the other WordCraft windows and Side by Side (#322).
|
||||
pub windows: crate::cmd::view::Windows,
|
||||
/// Read Aloud player (Review › Speech).
|
||||
pub read_aloud: crate::speech::ReadAloud,
|
||||
/// Preferences the front end saves between runs.
|
||||
|
||||
@@ -250,6 +250,8 @@ impl Session {
|
||||
author: _,
|
||||
initials: _,
|
||||
painter: _,
|
||||
// What the drawn window knows of the others: its front end sets it as it draws.
|
||||
windows: _,
|
||||
status: _,
|
||||
last_command: _,
|
||||
recording: _,
|
||||
|
||||
@@ -157,6 +157,7 @@ impl Session {
|
||||
ribbon: crate::RibbonLayout::default(),
|
||||
editing_lang: None,
|
||||
read_aloud: Default::default(),
|
||||
windows: Default::default(),
|
||||
password,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
recovery: None,
|
||||
|
||||
@@ -139,6 +139,8 @@ impl Session {
|
||||
pending_ime_application_change: _,
|
||||
ime_origin: _,
|
||||
read_aloud: _,
|
||||
// The other windows, like `view`: not part of the document or its history.
|
||||
windows: _,
|
||||
prefs: _,
|
||||
system_region: _,
|
||||
keymap: _,
|
||||
|
||||
@@ -16,6 +16,7 @@ mod find_apostrophes;
|
||||
mod font_sizes;
|
||||
mod fonts;
|
||||
mod ime_noop;
|
||||
mod para_tools;
|
||||
mod revision_checks;
|
||||
mod typing;
|
||||
mod wordart;
|
||||
@@ -5217,6 +5218,42 @@ fn arrange_bounds_resizes_and_moves_objects() {
|
||||
assert_eq!(box_area(&mut s).rect, n);
|
||||
}
|
||||
|
||||
/// A frame anchored to a page and moved in WordCraft is placed by its paragraph from then on: the
|
||||
/// file's page number goes, or the save would put it back. Only resized, it stays on that page.
|
||||
#[test]
|
||||
fn an_odt_frame_moved_from_its_page_loses_the_page_number() {
|
||||
let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#;
|
||||
let content = format!(
|
||||
r#"<office:document-content {ns}><office:body><office:text><draw:frame text:anchor-type="page" text:anchor-page-number="3" svg:x="5cm" svg:y="5cm" svg:width="4cm" svg:height="2cm"><draw:text-box><text:p>On it</text:p></draw:text-box></draw:frame><text:p>One</text:p><text:p>Two</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str())] {
|
||||
z.start_file(name, o).unwrap();
|
||||
std::io::Write::write_all(&mut z, data.as_bytes()).unwrap();
|
||||
}
|
||||
let odt = z.finish().unwrap().into_inner();
|
||||
let frame_tag = |s: &Session| {
|
||||
let mut a = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&s.doc).unwrap())).unwrap();
|
||||
let mut c = String::new();
|
||||
std::io::Read::read_to_string(&mut a.by_name("content.xml").unwrap(), &mut c).unwrap();
|
||||
c.split("<draw:frame").nth(1).and_then(|t| t.split('>').next()).unwrap_or_default().to_string()
|
||||
};
|
||||
let mut s = Session::new(crate::io::open_bytes("t.odt", &odt).unwrap());
|
||||
select_box(&mut s);
|
||||
run(&mut s, "arrange.bounds", json!({"width": 150, "height": 80}));
|
||||
assert!(frame_tag(&s).contains(r#"text:anchor-page-number="3""#), "resized: {}", frame_tag(&s));
|
||||
let mut s = Session::new(crate::io::open_bytes("t.odt", &odt).unwrap());
|
||||
select_box(&mut s);
|
||||
run(&mut s, "arrange.bounds", json!({"page": 0, "x": 200, "y": 300}));
|
||||
let tag = frame_tag(&s);
|
||||
assert!(!tag.contains("anchor-page-number") && !tag.contains(r#"text:anchor-type="page""#), "moved: {tag}");
|
||||
// Moved, then placed by the margins: the file's page number is gone, not only hidden.
|
||||
run(&mut s, "arrange.position", json!({"preset": "topLeft"}));
|
||||
let tag = frame_tag(&s);
|
||||
assert!(!tag.contains("anchor-page-number"), "moved, then placed: {tag}");
|
||||
}
|
||||
|
||||
/// An object dropped near the bottom of a page lands there, though at the top of the paragraph
|
||||
/// it anchors to it would push that paragraph onto the next page (#3169).
|
||||
#[test]
|
||||
@@ -10788,6 +10825,44 @@ fn insert_picture_takes_a_windows_metafile() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_commands_need_the_desktop_and_name_a_window() {
|
||||
use crate::cmd::view::OtherWindow;
|
||||
let mut s = s();
|
||||
// Web, CLI, MCP: one window, so View › Window is disabled.
|
||||
for id in ["view.arrangeAll", "view.sideBySide", "view.syncScroll"] {
|
||||
assert!(s.run(id, &json!({})).is_err(), "{id} without a window host");
|
||||
}
|
||||
s.windows.available = true;
|
||||
run(&mut s, "view.arrangeAll", json!({}));
|
||||
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "arrange"})));
|
||||
assert!(s.run("view.sideBySide", &json!({})).is_err(), "no other window");
|
||||
s.windows.others = vec![OtherWindow { id: 7, title: "Notes".into() }, OtherWindow { id: 9, title: "Draft".into() }];
|
||||
let e = s.run("view.sideBySide", &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("7 (Notes)") && e.contains("9 (Draft)"), "several windows: say which: {e}");
|
||||
assert!(s.run("view.sideBySide", &json!({"window": 8})).is_err(), "an unknown window");
|
||||
assert!(s.run("view.sideBySide", &json!({"window": "9"})).is_err());
|
||||
// Side by Side pairs with one window and scrolls together until turned off.
|
||||
assert!(s.run("view.syncScroll", &json!({})).is_err(), "only while Side by Side is on");
|
||||
let r = run(&mut s, "view.sideBySide", json!({"window": 7}));
|
||||
assert_eq!((r["value"].as_bool(), s.windows.side_by_side, s.windows.sync_scroll), (Some(true), Some(7), true));
|
||||
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "sideBySide", "with": 7})));
|
||||
run(&mut s, "view.syncScroll", json!({}));
|
||||
assert!(!s.windows.sync_scroll);
|
||||
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "syncScroll", "with": 7, "value": false})));
|
||||
// Asking again for the partner it already has keeps scrolling off and pairs nothing anew.
|
||||
let r = run(&mut s, "view.sideBySide", json!({"window": 7, "value": true}));
|
||||
assert_eq!((r["syncScroll"].as_bool(), s.windows.sync_scroll), (Some(false), false));
|
||||
assert!(s.ui_requests.is_empty());
|
||||
// Another partner replaces the first; the bare button turns it off.
|
||||
run(&mut s, "view.sideBySide", json!({"window": 9}));
|
||||
assert_eq!(std::mem::take(&mut s.ui_requests), [json!({"windows": "sideBySide", "off": 7}), json!({"windows": "sideBySide", "with": 9})]);
|
||||
run(&mut s, "view.sideBySide", json!({}));
|
||||
assert_eq!((s.windows.side_by_side, s.windows.sync_scroll), (None, false));
|
||||
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "sideBySide", "off": 9})));
|
||||
assert!(!s.dirty, "window commands never touch the document");
|
||||
}
|
||||
|
||||
/// Change Picture must keep the replacement's own format: the part's name and content type
|
||||
/// come from the extension, so a JPEG stored as `imageN.png` saves declared `image/png` (Word
|
||||
/// and LibreOffice then show a broken picture).
|
||||
@@ -12568,6 +12643,40 @@ fn export_png_rejects_a_page_number_that_does_not_fit() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_paper_color_is_a_persistent_preference_not_document_formatting() {
|
||||
use crate::cmd::view::PaperColor;
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Screen paper test", "raw": true}));
|
||||
let before = serde_json::to_value(&s.doc).unwrap();
|
||||
let docx_before = wordcraft_docx::write(&s.doc).unwrap();
|
||||
s.dirty = false;
|
||||
let dirty = s.dirty;
|
||||
s.view.dark_mode = true;
|
||||
for (value, expected) in
|
||||
[("green", PaperColor::Green), ("cream", PaperColor::Cream), ("gray", PaperColor::Gray), ("document", PaperColor::Document)]
|
||||
{
|
||||
run(&mut s, "view.paperColor", json!({"value": value}));
|
||||
assert_eq!(s.prefs.paper_color, expected);
|
||||
assert_eq!(serde_json::to_value(&s.doc).unwrap(), before);
|
||||
assert_eq!(s.dirty, dirty);
|
||||
assert_eq!(wordcraft_docx::write(&s.doc).unwrap(), docx_before);
|
||||
assert!(!s.view.dark_mode, "choosing a tint turns off dark paper");
|
||||
let saved = serde_json::to_value(&s.prefs).unwrap();
|
||||
let restored: crate::Prefs = serde_json::from_value(saved).unwrap();
|
||||
assert_eq!(restored.paper_color, expected);
|
||||
}
|
||||
for v in [json!({}), json!({"value": "red"}), json!({"value": 42})] {
|
||||
assert!(s.run("view.paperColor", &v).is_err());
|
||||
assert_eq!(s.prefs.paper_color, PaperColor::Document);
|
||||
}
|
||||
let legacy: crate::Prefs = serde_json::from_value(json!({})).unwrap();
|
||||
assert_eq!(legacy.paper_color, PaperColor::Document);
|
||||
let future: crate::Prefs = serde_json::from_value(json!({"paperColor":"future", "countNotes":false})).unwrap();
|
||||
assert_eq!(future.paper_color, PaperColor::Document);
|
||||
assert!(!future.count_notes);
|
||||
}
|
||||
|
||||
/// Font › Emphasis mark puts a mark over (or under) each selected character, and the layout
|
||||
/// draws one per character; Format state reports it, and none takes it off.
|
||||
#[test]
|
||||
@@ -12615,6 +12724,73 @@ fn an_odt_charts_paragraph_is_written_as_read_after_drawing_it() {
|
||||
assert!(content.contains(r#"draw:name="Chart1""#) && !content.contains("wcChart"), "{content}");
|
||||
}
|
||||
|
||||
/// A picture an OpenDocument file makes wider than the text (a rule across the whole page) stays
|
||||
/// that wide, and its paragraph is written as read: opening a file changes nothing in it.
|
||||
#[test]
|
||||
fn an_odts_picture_wider_than_the_text_keeps_its_size() {
|
||||
let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text: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" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink""#;
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {ns}><office:automatic-styles><style:page-layout style:name="pm"><style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm" fo:margin-left="3cm" fo:margin-right="3cm" fo:margin-top="2cm" fo:margin-bottom="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm"/></office:master-styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {ns}><office:body><office:text><text:p><draw:frame draw:name="Rule" text:anchor-type="paragraph" svg:x="-1cm" svg:width="18cm" svg:height="0.2cm"><draw:image xlink:href="Pictures/r.png"/></draw:frame>Text</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let png = [b"\x89PNG\r\n\x1a\n".as_slice(), &[7u8; 64]].concat();
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
for (name, data) in [
|
||||
("mimetype", b"application/vnd.oasis.opendocument.text".as_slice()),
|
||||
("content.xml", content.as_bytes()),
|
||||
("styles.xml", styles.as_bytes()),
|
||||
("Pictures/r.png", &png),
|
||||
] {
|
||||
z.start_file(name, o).unwrap();
|
||||
std::io::Write::write_all(&mut z, data).unwrap();
|
||||
}
|
||||
let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap();
|
||||
let width = doc.body[0]
|
||||
.as_para()
|
||||
.unwrap()
|
||||
.objects
|
||||
.iter()
|
||||
.find_map(|o| if let wordcraft_doc::para::InlineObject::Image { w, .. } = o { Some(*w) } else { None });
|
||||
let want = 18.0 * 72.0 / 2.54;
|
||||
assert!(width.is_some_and(|w| (w - want).abs() < 0.1), "{width:?}, not fitted to the text");
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&doc).unwrap())).unwrap();
|
||||
let mut saved = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("content.xml").unwrap(), &mut saved).unwrap();
|
||||
assert!(saved.contains(r#"draw:name="Rule""#) && saved.contains(r#"svg:width="18cm""#), "written as read: {saved}");
|
||||
}
|
||||
|
||||
/// Numbering or a new style makes the styles be written from the model on save: a document in
|
||||
/// German stays in German, rather than taking the default language of what opens it next.
|
||||
#[test]
|
||||
fn an_odts_language_survives_a_save_after_numbering_or_a_new_style() {
|
||||
let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#;
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {ns}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:language="de" fo:country="DE"/></style:default-style></office:styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {ns}><office:body><office:text><text:p>Hallo</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str()), ("styles.xml", styles.as_str())]
|
||||
{
|
||||
z.start_file(name, o).unwrap();
|
||||
std::io::Write::write_all(&mut z, data.as_bytes()).unwrap();
|
||||
}
|
||||
let odt = z.finish().unwrap().into_inner();
|
||||
for (command, params) in [("para.numbering", json!({})), ("styles.create", json!({"name": "Notiz"}))] {
|
||||
let mut s = Session::new(crate::io::open_bytes("t.odt", &odt).unwrap());
|
||||
assert_eq!(cmd::default_lang(&s).as_deref(), Some("de-DE"), "the document's, not English");
|
||||
run(&mut s, command, params);
|
||||
let saved = wordcraft_formats::odt::export(&s.doc).unwrap();
|
||||
let back = crate::io::open_bytes("t.odt", &saved).unwrap();
|
||||
assert_eq!(back.styles.default_chr.lang.as_deref(), Some("de-DE"), "after {command}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A Word package whose body holds `body` and, as `rIdC`, the chunk part `word/afchunk.{ext}`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn docx_with_chunk(body: &str, ext: &str, content_type: &str, chunk: &[u8]) -> Vec<u8> {
|
||||
@@ -12859,6 +13035,47 @@ fn page_down_extends_a_selection_that_ends_in_collapsed_outline_text() {
|
||||
assert!(s.layout().caret(&s.sel.focus).is_some(), "to a place the outline shows");
|
||||
}
|
||||
|
||||
/// A picture an OpenDocument file gives no size is shown at its own, fitted to the text as other
|
||||
/// formats' are; one with a stated size keeps it, in line too.
|
||||
#[test]
|
||||
fn an_odts_picture_is_fitted_only_when_it_states_no_size() {
|
||||
let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink""#;
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {ns}><office:automatic-styles><style:page-layout style:name="pm"><style:page-layout-properties fo:page-width="14.8cm" fo:page-height="21cm" fo:margin-left="2cm" fo:margin-right="2cm" fo:margin-top="2cm" fo:margin-bottom="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm"/></office:master-styles></office:document-styles>"#
|
||||
);
|
||||
let mut png = std::io::Cursor::new(Vec::new());
|
||||
image::RgbImage::new(1000, 500).write_to(&mut png, image::ImageFormat::Png).unwrap();
|
||||
let png = png.into_inner();
|
||||
let open = |attrs: &str| {
|
||||
let content = format!(
|
||||
r#"<office:document-content {ns}><office:body><office:text><text:p><draw:frame text:anchor-type="as-char"{attrs}><draw:image xlink:href="Pictures/a.png"/></draw:frame></text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
for (name, data) in [
|
||||
("mimetype", b"application/vnd.oasis.opendocument.text".as_slice()),
|
||||
("content.xml", content.as_bytes()),
|
||||
("styles.xml", styles.as_bytes()),
|
||||
("Pictures/a.png", &png),
|
||||
] {
|
||||
z.start_file(name, o).unwrap();
|
||||
std::io::Write::write_all(&mut z, data).unwrap();
|
||||
}
|
||||
let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap();
|
||||
doc.body[0]
|
||||
.as_para()
|
||||
.unwrap()
|
||||
.objects
|
||||
.iter()
|
||||
.find_map(|o| if let wordcraft_doc::para::InlineObject::Image { w, h, .. } = o { Some((*w, *h)) } else { None })
|
||||
.unwrap()
|
||||
};
|
||||
let (w, h) = open("");
|
||||
assert!((w - 306.14).abs() < 0.5 && (h - 153.07).abs() < 0.5, "fitted to the text: {w} {h}");
|
||||
let (w, _) = open(r#" svg:width="18cm" svg:height="9cm""#);
|
||||
assert!((w - 18.0 * 72.0 / 2.54).abs() < 0.5, "as the file says: {w}");
|
||||
}
|
||||
|
||||
/// Issue #5667's document: `Intro`, then an inserted `Appendix` paragraph whose mark carries a
|
||||
/// section break (Normal, indented), then `Body` in Heading 1. The whole of `Appendix` is
|
||||
/// tracked as an insertion by `rev`, or as a deletion when `delete`.
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
//! Advanced paragraph tools (#5043): conversion and cleanup over the selection or the whole
|
||||
//! document, one undo step, Track Changes respected.
|
||||
|
||||
use serde_json::json;
|
||||
use wordcraft_doc::{InlineObject, Path, Pos, RevisionKind, SectionProps, StoryRef};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn s() -> Session {
|
||||
Session::new(wordcraft_doc::Document::new())
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, v: serde_json::Value) {
|
||||
s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
}
|
||||
|
||||
fn text(s: &Session) -> String {
|
||||
s.doc.plain_text(StoryRef::Body)
|
||||
}
|
||||
|
||||
/// Select the whole document (Mod+A's command).
|
||||
fn select_all(s: &mut Session) {
|
||||
run(s, "select.all", json!({}));
|
||||
}
|
||||
|
||||
/// One paragraph with `\n` as manual line breaks, typed as text.
|
||||
fn type_breaks(s: &mut Session, t: &str) {
|
||||
run(s, "text.insert", json!({"text": t}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_breaks_become_paragraphs() {
|
||||
let mut s = s();
|
||||
type_breaks(&mut s, "one\ntwo\nthree");
|
||||
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 1);
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.convertBreaks", json!({"scope": "all"}));
|
||||
assert_eq!(text(&s), "one\ntwo\nthree", "the breaks are paragraph marks, not text");
|
||||
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 3);
|
||||
// One undo step brings the manual line breaks back.
|
||||
assert!(s.undo());
|
||||
let p0 = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
assert_eq!(p0.text, "one\ntwo\nthree", "back to one paragraph with its breaks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_paragraphs_go_in_between() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "a\nb\nc"}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.blankBetween", json!({"scope": "all"}));
|
||||
assert_eq!(text(&s), "a\n\nb\n\nc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_paragraphs_go_after_too() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "a\nb\nc"}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.blankBetween", json!({"scope": "all", "after": true}));
|
||||
assert_eq!(text(&s), "a\n\nb\n\nc\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_paragraphs_blanks_after_every_one() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "a\nb\nc"}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.separateParagraphs", json!({"scope": "all"}));
|
||||
assert_eq!(text(&s), "a\n\nb\n\nc\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_paragraphs_are_deleted() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "a\n\nb\n\n\nc"}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.removeBlank", json!({"scope": "all"}));
|
||||
assert_eq!(text(&s), "a\nb\nc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_section_break_paragraph_stays() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "a\nb"}));
|
||||
// The second paragraph carries a section break and is emptied: removing it would drop the
|
||||
// section, so it stays.
|
||||
let second = Path::top(1);
|
||||
let p = s.doc.para_mut(StoryRef::Body, &second).unwrap();
|
||||
p.text.clear();
|
||||
p.runs.clear();
|
||||
p.section = Some(Box::new(SectionProps::default()));
|
||||
p.touch();
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.removeBlank", json!({"scope": "all"}));
|
||||
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2, "the section-break paragraph stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leading_spaces_are_removed() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": " a\nb\n c"}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"}));
|
||||
assert_eq!(text(&s), "a\nb\nc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_spaces_collapse_to_one() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "a b c d \n e"}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
|
||||
assert_eq!(text(&s), "a b c d \n e", "inner runs collapse; the leading spaces of the second paragraph stay");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_space_run_at_the_end_collapses() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "a "}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
|
||||
assert_eq!(text(&s), "a ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_spaces_collapse_without_removing_tabs_or_page_breaks() {
|
||||
use wordcraft_doc::para::PAGE_BREAK;
|
||||
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": format!("a \t b {PAGE_BREAK} c\t ")}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
|
||||
assert_eq!(text(&s), format!("a \t b {PAGE_BREAK} c\t "), "only repeated spaces collapse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indented_spaces_become_an_indent() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": " a\nb"}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.spacesToIndent", json!({"scope": "all"}));
|
||||
assert_eq!(text(&s), "a\nb");
|
||||
let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
assert_eq!(p.props.indent_first, Some(216.0), "three spaces are 3 x 72 pt");
|
||||
assert_eq!(s.doc.para(StoryRef::Body, &Path::top(1)).unwrap().props.indent_first, None, "b has no spaces");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spaces_to_indent_keeps_spaces_the_indent_limit_cannot_represent() {
|
||||
let mut many = s();
|
||||
run(&mut many, "document.setText", json!({"text": format!("{}a", " ".repeat(24))}));
|
||||
select_all(&mut many);
|
||||
run(&mut many, "para.spacesToIndent", json!({"scope": "all"}));
|
||||
assert_eq!(text(&many), " a", "22 spaces fit in the 1584 pt maximum; two stay as text");
|
||||
assert_eq!(many.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(1584.0));
|
||||
|
||||
let mut nearly_full = s();
|
||||
run(&mut nearly_full, "document.setText", json!({"text": " a"}));
|
||||
run(&mut nearly_full, "para.indents", json!({"firstLine": 1548.0}));
|
||||
select_all(&mut nearly_full);
|
||||
run(&mut nearly_full, "para.spacesToIndent", json!({"scope": "all"}));
|
||||
assert_eq!(text(&nearly_full), " a", "less than 72 pt of room cannot replace one space");
|
||||
assert_eq!(nearly_full.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(1548.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indents_become_leading_spaces() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "a"}));
|
||||
run(&mut s, "para.indents", json!({"left": 72.0, "firstLine": 144.0}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.indentToSpaces", json!({"scope": "all"}));
|
||||
assert_eq!(text(&s), " a", "72 + 144 pt become three spaces");
|
||||
let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
assert_eq!((p.props.indent_left, p.props.indent_first), (Some(0.0), Some(0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hanging_indent_that_sums_to_zero_is_still_removed() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "a"}));
|
||||
run(&mut s, "para.indents", json!({"left": 72.0, "firstLine": -72.0}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.indentToSpaces", json!({"scope": "all"}));
|
||||
assert_eq!(text(&s), "a", "the cancelling indents need no leading spaces");
|
||||
let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
assert_eq!((p.props.indent_left, p.props.indent_first), (Some(0.0), Some(0.0)));
|
||||
|
||||
let mut tracked = Session::new(wordcraft_doc::Document::new());
|
||||
run(&mut tracked, "document.setText", json!({"text": "a"}));
|
||||
run(&mut tracked, "para.indents", json!({"left": 72.0, "firstLine": -72.0}));
|
||||
run(&mut tracked, "review.trackChanges", json!({"value": true}));
|
||||
select_all(&mut tracked);
|
||||
run(&mut tracked, "para.indentToSpaces", json!({"scope": "all"}));
|
||||
assert!(!tracked.doc.revisions.iter().any(|r| r.kind == RevisionKind::Insert), "no inserted text means no insert revision");
|
||||
assert!(tracked.doc.revisions.iter().any(|r| r.kind == RevisionKind::Format), "the removed hanging indent is tracked as formatting");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_keeps_fields_footnote_anchors_and_mixed_formatting() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "aa bb "}));
|
||||
s.sel = crate::Selection { anchor: Pos::body(0, 4), focus: Pos::body(0, 6) };
|
||||
run(&mut s, "format.bold", json!({"value": true}));
|
||||
s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body));
|
||||
run(&mut s, "insert.field", json!({"instr": "AUTHOR", "result": "Ada"}));
|
||||
let note = s.run("references.footnote", &json!({"text": "keep this note"})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||
s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body));
|
||||
run(&mut s, "text.insert", json!({"text": " tail"}));
|
||||
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
|
||||
|
||||
let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
assert_eq!((p.props_of_char(3).bold, p.props_of_char(4).bold), (Some(true), Some(true)), "the bold run moved but kept its formatting");
|
||||
assert!(p.object_offsets().iter().any(|off| matches!(p.object_at(*off), Some(InlineObject::Field { .. }))), "the field stays");
|
||||
assert!(p.object_offsets().iter().any(|off| matches!(p.object_at(*off), Some(InlineObject::NoteRef { .. }))), "the footnote anchor stays");
|
||||
assert!(s.doc.parts.contains_key(¬e), "the anchored footnote story stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_inside_a_table_keeps_the_table_and_its_cell() {
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
|
||||
run(&mut s, "text.insert", json!({"text": "a b"}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
|
||||
let table = s.doc.body.iter().find_map(|block| block.as_table()).expect("the table stays");
|
||||
let cell = table.rows[0].cells[0].blocks.iter().find_map(|block| block.as_para()).expect("the cell paragraph stays");
|
||||
assert_eq!(cell.text, "a b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_document_scope_from_a_table_cell_reaches_the_body() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "body text"}));
|
||||
s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body));
|
||||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 2}));
|
||||
let table_index = s.doc.body.iter().position(|block| block.as_table().is_some()).expect("the table was inserted") as u32;
|
||||
s.sel = crate::Selection::caret(Pos { story: StoryRef::Body, path: Path(vec![table_index, 0, 0, 0]), off: 0 });
|
||||
run(&mut s, "text.insert", json!({"text": "cell text"}));
|
||||
assert!(s.sel.focus.path.depth() > 0, "the caret is inside the table");
|
||||
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
|
||||
assert!(text(&s).contains("body text"), "body text outside the table was cleaned too: {}", text(&s));
|
||||
let table = s.doc.body.iter().find_map(|block| block.as_table()).expect("the table stays");
|
||||
let cell = table.rows[0].cells[0].blocks.iter().find_map(|block| block.as_para()).expect("the cell stays");
|
||||
assert_eq!(cell.text, "cell text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_tools_refuse_content_locked_controls() {
|
||||
for (text, command) in [("a b", "para.removeExtraSpaces"), ("a\nb", "para.convertBreaks")] {
|
||||
let mut s = s();
|
||||
type_breaks(&mut s, text);
|
||||
select_all(&mut s);
|
||||
run(&mut s, "developer.richText", json!({"tag": "locked"}));
|
||||
run(&mut s, "developer.properties", json!({"control": "locked", "lockEdit": true}));
|
||||
select_all(&mut s);
|
||||
let before = s.doc.clone();
|
||||
let error = s.run(command, &json!({"scope": "all"})).unwrap_err();
|
||||
assert!(error.to_string().contains("can't be edited"), "{command}: {error}");
|
||||
assert_eq!(s.doc, before, "{command} changed locked content");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_breaks_are_removed() {
|
||||
let mut s = s();
|
||||
type_breaks(&mut s, "one\ntwo\nthree");
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.removeLineBreaks", json!({"scope": "all"}));
|
||||
assert_eq!(text(&s), "onetwothree");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_selection_is_the_scope() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "a b\nc d"}));
|
||||
// Only the first paragraph: select from its start to its end.
|
||||
s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 4) };
|
||||
run(&mut s, "para.removeExtraSpaces", json!({}));
|
||||
assert_eq!(text(&s), "a b\nc d", "the second paragraph is untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structural_tools_do_not_cross_the_selection() {
|
||||
let mut breaks = s();
|
||||
type_breaks(&mut breaks, "one\ntwo");
|
||||
run(&mut breaks, "text.newParagraph", json!({}));
|
||||
run(&mut breaks, "text.insert", json!({"text": "outside"}));
|
||||
breaks.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 7) };
|
||||
run(&mut breaks, "para.convertBreaks", json!({}));
|
||||
let last = breaks.doc.para_paths(StoryRef::Body).last().cloned().unwrap();
|
||||
assert_eq!(breaks.doc.para(StoryRef::Body, &last).unwrap().text, "outside");
|
||||
|
||||
let mut between = s();
|
||||
run(&mut between, "document.setText", json!({"text": "a\nb\nc"}));
|
||||
between.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 1) };
|
||||
run(&mut between, "para.blankBetween", json!({}));
|
||||
assert_eq!(text(&between), "a\n\nb\nc", "only a and b get a blank between them");
|
||||
|
||||
let mut separate = s();
|
||||
run(&mut separate, "document.setText", json!({"text": "a\nb\nc"}));
|
||||
separate.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 1) };
|
||||
run(&mut separate, "para.separateParagraphs", json!({}));
|
||||
assert_eq!(text(&separate), "a\n\nb\n\nc", "the blank after b stays before the unselected c");
|
||||
|
||||
let mut blank = s();
|
||||
run(&mut blank, "document.setText", json!({"text": "a\n\nb\n\nc"}));
|
||||
blank.sel = crate::Selection::caret(Pos::body(1, 0));
|
||||
run(&mut blank, "para.removeBlank", json!({}));
|
||||
assert_eq!(text(&blank), "a\nb\n\nc", "the other blank paragraph is outside the selection");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_space_conversions_are_reversible_track_changes() {
|
||||
let mut to_indent = s();
|
||||
run(&mut to_indent, "document.setText", json!({"text": " a"}));
|
||||
run(&mut to_indent, "review.trackChanges", json!({"value": true}));
|
||||
select_all(&mut to_indent);
|
||||
run(&mut to_indent, "para.spacesToIndent", json!({"scope": "all"}));
|
||||
assert_eq!(to_indent.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(144.0));
|
||||
run(&mut to_indent, "review.rejectAll", json!({}));
|
||||
let p = to_indent.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
assert_eq!((p.text.as_str(), p.props.indent_first), (" a", None));
|
||||
|
||||
let mut to_spaces = s();
|
||||
run(&mut to_spaces, "document.setText", json!({"text": "a"}));
|
||||
run(&mut to_spaces, "para.indents", json!({"firstLine": 144.0}));
|
||||
run(&mut to_spaces, "review.trackChanges", json!({"value": true}));
|
||||
select_all(&mut to_spaces);
|
||||
run(&mut to_spaces, "para.indentToSpaces", json!({"scope": "all"}));
|
||||
run(&mut to_spaces, "review.rejectAll", json!({}));
|
||||
let p = to_spaces.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
assert_eq!((p.text.as_str(), p.props.indent_first), ("a", Some(144.0)));
|
||||
|
||||
let mut accepted = s();
|
||||
run(&mut accepted, "document.setText", json!({"text": "a"}));
|
||||
run(&mut accepted, "para.indents", json!({"firstLine": 144.0}));
|
||||
run(&mut accepted, "review.trackChanges", json!({"value": true}));
|
||||
select_all(&mut accepted);
|
||||
run(&mut accepted, "para.indentToSpaces", json!({"scope": "all"}));
|
||||
run(&mut accepted, "review.acceptAll", json!({}));
|
||||
let p = accepted.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
assert_eq!((p.text.as_str(), p.props.indent_first), (" a", Some(0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_to_spaces_does_not_inherit_revision_marks() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "a"}));
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) };
|
||||
run(&mut s, "format.bold", json!({"value": true}));
|
||||
run(&mut s, "text.delete", json!({}));
|
||||
run(&mut s, "review.trackChanges", json!({"value": false}));
|
||||
run(&mut s, "para.indents", json!({"firstLine": 72.0}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.indentToSpaces", json!({"scope": "all"}));
|
||||
let space = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props_of_char(0);
|
||||
assert_eq!((space.ins, space.del, space.fmt_change.as_ref().map(|_| ())), (None, None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converting_too_many_line_breaks_is_atomic() {
|
||||
let original = format!("{}end", "x\n".repeat(10_001));
|
||||
let mut s = s();
|
||||
type_breaks(&mut s, &original);
|
||||
select_all(&mut s);
|
||||
let before = s.doc.clone();
|
||||
let error = s.run("para.convertBreaks", &json!({"scope": "all"})).unwrap_err();
|
||||
assert!(error.to_string().contains("at most"), "{error}");
|
||||
assert_eq!(s.doc, before, "the refused conversion is all-or-nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeating_a_tracked_deletion_creates_no_empty_revision() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": " a"}));
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"}));
|
||||
let revisions = s.doc.revisions.len();
|
||||
run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"}));
|
||||
assert_eq!(s.doc.revisions.len(), revisions, "already-deleted spaces create no revision");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletions_are_recorded_under_track_changes() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "a b\n c"}));
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"}));
|
||||
// The text stays marked deleted, not gone, as Word records the deletion.
|
||||
assert_eq!(text(&s), "a b\n c", "tracked deletions keep their text");
|
||||
assert!(s.doc.revisions.iter().any(|r| r.kind == RevisionKind::Delete));
|
||||
run(&mut s, "review.acceptAll", json!({}));
|
||||
assert_eq!(text(&s), "a b\nc", "accepting removes the leading space");
|
||||
}
|
||||
|
||||
/// Tracked-delete the bytes `a..b` of the first body paragraph (Track Changes must be on).
|
||||
fn track_delete_in_first(s: &mut Session, a: usize, b: usize) {
|
||||
let path = s.doc.para_paths(StoryRef::Body)[0].clone();
|
||||
let at = |off: usize| Pos { story: StoryRef::Body, path: path.clone(), off };
|
||||
crate::cmd::track_delete(s, &at(a), &at(b)).expect("tracked delete");
|
||||
}
|
||||
|
||||
fn first_indent(s: &Session) -> Option<f32> {
|
||||
let path = s.doc.para_paths(StoryRef::Body)[0].clone();
|
||||
s.doc.para(StoryRef::Body, &path).expect("paragraph").props.indent_first
|
||||
}
|
||||
|
||||
fn first_deleted(s: &Session, off: usize) -> bool {
|
||||
let path = s.doc.para_paths(StoryRef::Body)[0].clone();
|
||||
s.doc.para(StoryRef::Body, &path).expect("paragraph").props_of_char(off).del.is_some()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_extra_spaces_skips_tracked_deleted_spaces() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "a b"}));
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
track_delete_in_first(&mut s, 2, 3); // the second space is already deleted
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
|
||||
run(&mut s, "review.acceptAll", json!({}));
|
||||
assert_eq!(text(&s), "a b", "the one live space survives");
|
||||
|
||||
// Three spaces, the middle one already deleted: of the two live ones the last is kept.
|
||||
let mut s = self::s();
|
||||
run(&mut s, "document.setText", json!({"text": "a b"}));
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
track_delete_in_first(&mut s, 2, 3);
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
|
||||
assert!(first_deleted(&s, 1) && first_deleted(&s, 2), "the first and the already deleted space are deleted");
|
||||
assert!(!first_deleted(&s, 3), "the last live space stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spaces_to_indent_twice_does_not_count_deleted_spaces_again() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": " x"}));
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.spacesToIndent", json!({"scope": "all"}));
|
||||
assert_eq!(first_indent(&s), Some(144.0));
|
||||
run(&mut s, "para.spacesToIndent", json!({"scope": "all"}));
|
||||
assert_eq!(first_indent(&s), Some(144.0), "the deleted spaces are not indented a second time");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_breaks_leaves_a_tracked_deleted_break_alone() {
|
||||
let mut s = s();
|
||||
type_breaks(&mut s, "a\nb\nc");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
track_delete_in_first(&mut s, 1, 2);
|
||||
select_all(&mut s);
|
||||
run(&mut s, "para.convertBreaks", json!({"scope": "all"}));
|
||||
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2, "only the live break becomes a paragraph break");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_scope_is_refused() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "a b"}));
|
||||
select_all(&mut s);
|
||||
let e = s.run("para.removeExtraSpaces", &json!({"scope": "junk"})).unwrap_err();
|
||||
assert!(format!("{e}").contains("scope"), "{e}");
|
||||
assert_eq!(text(&s), "a b", "the document is unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_many_paragraphs_are_refused_before_any_change() {
|
||||
let text = "x\n".repeat(15_000);
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text(&text));
|
||||
select_all(&mut s);
|
||||
let e = s.run("para.blankBetween", &json!({"scope": "all"})).unwrap_err();
|
||||
assert!(format!("{e}").contains("at most"), "{e}");
|
||||
let kept = s.doc.para_paths(StoryRef::Body).len();
|
||||
assert!((15_000..=15_001).contains(&kept), "nothing changed, {kept}");
|
||||
}
|
||||
@@ -3876,6 +3876,88 @@ fn backspace_cannot_join_into_locked_content_control() {
|
||||
assert_eq!(s.doc, before);
|
||||
}
|
||||
|
||||
// #3693 is an additional editing shortcut, not a claim of Word's default key behaviour.
|
||||
#[test]
|
||||
fn delete_previous_sentence_respects_caret_boundaries_and_unicode() {
|
||||
for (text, before, expected) in [
|
||||
("First sentence. Second sentence.", "First sentence. Second sentence.", "First sentence. "),
|
||||
("First sentence. Second sentence.", "First sentence. Sec", "First sentence. ond sentence."),
|
||||
("First sentence. Second sentence.", "First sentence. ", "Second sentence."),
|
||||
("Grüße! Überall café.", "Grüße! Überall café.", "Grüße! "),
|
||||
("No punctuation here", "No punctuation", " here"),
|
||||
("One. Two.", "", "One. Two."),
|
||||
("", "", ""),
|
||||
] {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": text, "raw": true}));
|
||||
let mut pos = s.sel.focus.clone();
|
||||
pos.off = before.len();
|
||||
s.sel = crate::Selection::caret(pos);
|
||||
run(&mut s, "text.deleteSentenceBack", json!({}));
|
||||
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, expected, "{text:?} at {before:?}");
|
||||
if expected != text {
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, text);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_previous_sentence_keeps_paragraph_boundaries_and_deletes_selections() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "First.\nSecond sentence.", "raw": true, "paragraphs": true}));
|
||||
let end = s.sel.focus.clone();
|
||||
s.sel = crate::Selection::caret(Pos { off: 0, ..end.clone() });
|
||||
let count = s.doc.body.len();
|
||||
run(&mut s, "text.deleteSentenceBack", json!({}));
|
||||
assert_eq!(s.doc.body.len(), count);
|
||||
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Second sentence.");
|
||||
s.sel = crate::Selection { anchor: Pos { off: 0, ..end.clone() }, focus: Pos { off: 6, ..end } };
|
||||
run(&mut s, "text.deleteSentenceBack", json!({}));
|
||||
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, " sentence.");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Second sentence.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_previous_sentence_tracks_changes_and_respects_read_only() {
|
||||
for accept in [true, false] {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "One. Two.", "raw": true}));
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
run(&mut s, "text.deleteSentenceBack", json!({}));
|
||||
let changes = s.run("review.changes", &json!({})).unwrap();
|
||||
assert!(changes.as_array().unwrap().iter().any(|c| c["kind"] == "delete"));
|
||||
run(&mut s, if accept { "review.acceptAll" } else { "review.rejectAll" }, json!({}));
|
||||
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, if accept { "One. " } else { "One. Two." });
|
||||
}
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Keep this.", "raw": true}));
|
||||
run(&mut s, "review.restrict", json!({"mode": "readOnly"}));
|
||||
assert!(s.run("text.deleteSentenceBack", &json!({})).is_err());
|
||||
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Keep this.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_previous_sentence_does_not_cross_a_table_cell_boundary() {
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 2}));
|
||||
run(&mut s, "text.insert", json!({"text": "First cell.", "raw": true}));
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": "Second cell.", "raw": true}));
|
||||
let end = s.sel.focus.clone();
|
||||
s.sel = crate::Selection::caret(Pos { off: 0, ..end.clone() });
|
||||
let before = s.doc.plain_text(StoryRef::Body);
|
||||
run(&mut s, "text.deleteSentenceBack", json!({}));
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), before);
|
||||
s.sel = crate::Selection::caret(end);
|
||||
run(&mut s, "text.deleteSentenceBack", json!({}));
|
||||
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "");
|
||||
assert!(s.doc.plain_text(StoryRef::Body).contains("First cell."));
|
||||
}
|
||||
|
||||
// English spelling from SCOWL 2026 (CP2): each English reads its own SCOWL dictionary, with
|
||||
// `en-variants.txt` deciding the other dialect's spellings. Typed text, as a person types it.
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ description = "WordCraft fonts: font database (bundled OFL + system), metrics, g
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
# Opt-in test harness support; enabling this alone never changes the application database.
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
kurbo = { workspace = true }
|
||||
skrifa = { workspace = true }
|
||||
|
||||
@@ -7,7 +7,9 @@ use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
use kurbo::BezPath;
|
||||
use skrifa::instance::{Location, LocationRef, Size};
|
||||
use skrifa::outline::{DrawSettings, Engine, Hinting, HintingInstance, HintingOptions, OutlinePen, Target};
|
||||
use skrifa::outline::{
|
||||
DrawSettings, Engine, GlyphStyles, Hinting, HintingInstance, HintingOptions, OutlineGlyphFormat, OutlinePen, SmoothMode, Target,
|
||||
};
|
||||
use skrifa::raw::FileRef;
|
||||
use skrifa::string::StringId;
|
||||
use skrifa::{GlyphId, MetadataProvider};
|
||||
@@ -39,10 +41,16 @@ use names::{MAX_NAME_CHARS, file_face_names, font_faces, mini_font, name};
|
||||
#[cfg(test)]
|
||||
pub(crate) use names::{last_resort_face, legacy_family};
|
||||
|
||||
static GLOBAL: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
|
||||
|
||||
/// Process-wide font database.
|
||||
pub struct FontDb {
|
||||
faces: RwLock<Vec<Arc<FontFace>>>,
|
||||
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
|
||||
/// Screen-hinted outlines per (face, glyph, ppem bits); `None` where the face can't be hinted.
|
||||
hinted: Mutex<HashMap<(u32, u32, u32), Option<Arc<BezPath>>>>,
|
||||
/// Light hinting instances per (face, ppem bits); `None` where the face can't be hinted.
|
||||
hinters: Mutex<HashMap<(u32, u32), Option<Arc<HintingInstance>>>>,
|
||||
/// Fallback faces found per (character, excluded face), valid while the face count is the
|
||||
/// one stored (faces are only ever added). Layout asks once per uncovered character, and the
|
||||
/// search sorts every loaded face, so without this text in a script the requested font lacks
|
||||
@@ -90,10 +98,14 @@ impl FontDb {
|
||||
/// Arabic, so Arabic and Persian text draws without system fonts (#1652) (all empty without
|
||||
/// `CRAFT_FONTS_DIR`), whose system font scan reads `font_dirs`.
|
||||
pub fn with_font_dirs(font_dirs: Vec<std::path::PathBuf>) -> Self {
|
||||
let mut faces = Vec::new();
|
||||
let others = crate::CRAFT_FONTS.iter().filter(|f| !f.scripts.contains(&"Jpan"));
|
||||
let craft = crate::japanese_document_fonts().into_iter().chain(others).map(|f| f.bytes);
|
||||
for data in BUNDLED.iter().copied().chain(craft) {
|
||||
Self::with_static_fonts(font_dirs, BUNDLED.iter().copied().chain(craft))
|
||||
}
|
||||
|
||||
fn with_static_fonts(font_dirs: Vec<std::path::PathBuf>, fonts: impl Iterator<Item = &'static [u8]>) -> Self {
|
||||
let mut faces = Vec::new();
|
||||
for data in fonts {
|
||||
for f in enumerate_faces(data, MAX_FILE_FACES) {
|
||||
if let Some(mut face) = make_face(FontBytes::Static(data), f.index, f.family, f.style, f.legacy, f.coords) {
|
||||
face.local_names = f.local;
|
||||
@@ -104,6 +116,8 @@ impl FontDb {
|
||||
Self {
|
||||
faces: RwLock::new(faces),
|
||||
outlines: Mutex::new(HashMap::new()),
|
||||
hinted: Mutex::new(HashMap::new()),
|
||||
hinters: Mutex::new(HashMap::new()),
|
||||
fallbacks: RwLock::new(FallbackCache::default()),
|
||||
run_fallbacks: RwLock::new(RunFallbackCache::default()),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -144,8 +158,26 @@ impl FontDb {
|
||||
/// first time a lookup by family name needs them (or ahead of time by
|
||||
/// [`FontDb::scan_in_background`]).
|
||||
pub fn global() -> &'static FontDb {
|
||||
static DB: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
|
||||
DB.get_or_init(|| FontDb::with_font_dirs(system_font_dirs()))
|
||||
GLOBAL.get_or_init(|| FontDb::with_font_dirs(system_font_dirs()))
|
||||
}
|
||||
|
||||
/// Initialize a test process with only the repository's pinned bundled fonts: no optional
|
||||
/// craft-fonts, user fonts or system font directories. Call before any global font lookup.
|
||||
/// Returns false if a normal global database was already initialized; never replaces it.
|
||||
/// Repeated/concurrent calls are safe. Merely enabling `test-support` changes nothing.
|
||||
#[cfg(feature = "test-support")]
|
||||
pub fn init_test_fonts() -> bool {
|
||||
static INIT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*INIT.get_or_init(|| {
|
||||
let mut installed = false;
|
||||
GLOBAL.get_or_init(|| {
|
||||
installed = true;
|
||||
let db = Self::with_static_fonts(Vec::new(), BUNDLED.iter().copied());
|
||||
db.set_system_fallback(false);
|
||||
db
|
||||
});
|
||||
installed
|
||||
})
|
||||
}
|
||||
|
||||
fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::names::{LocalName, east_asian_font};
|
||||
use super::{
|
||||
Arc, AtomicU32, BezPath, DrawSettings, Engine, GlyphId, Hinting, HintingInstance, HintingOptions, Location, LocationRef, MetadataProvider,
|
||||
Ordering, OutlinePen, RwLock, Size, Target,
|
||||
Arc, AtomicU32, BezPath, DrawSettings, Engine, GlyphId, GlyphStyles, Hinting, HintingInstance, HintingOptions, Location, LocationRef,
|
||||
MetadataProvider, Ordering, OutlineGlyphFormat, OutlinePen, RwLock, Size, SmoothMode, Target,
|
||||
};
|
||||
|
||||
/// The family used when a requested family is unknown (the UI sans).
|
||||
@@ -77,6 +77,8 @@ pub struct FontFace {
|
||||
pub(super) bmp: std::sync::OnceLock<Box<[u64]>>,
|
||||
// Some legacy CJK fonts assemble their contours with TrueType instructions.
|
||||
pub(super) outline_hinter: std::sync::OnceLock<Option<HintingInstance>>,
|
||||
/// The autohinter's analysis of the font for screen hinting, made once for every size.
|
||||
pub(super) glyph_styles: std::sync::OnceLock<GlyphStyles>,
|
||||
/// Hinted draws so far, those that ran too long, and whether that turned hinting off
|
||||
/// ([`FontFace::count_hinted`]).
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
@@ -288,6 +290,42 @@ impl FontFace {
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
/// Glyph `gid`'s outline hinted for screen text at `ppem` device pixels per em, in device
|
||||
/// pixels, y-down (flipped), or `None` when the face can't be hinted. The hinting is light,
|
||||
/// as FreeType's `hintslight`: horizontal edges (baseline, x-height, cap height) move onto
|
||||
/// whole pixels, nothing moves sideways, so advances and layout stay as they are.
|
||||
pub(super) fn hinted_glyph_outline(&self, gid: u32, hinter: &HintingInstance) -> Option<BezPath> {
|
||||
let f = self.skrifa()?;
|
||||
let g = f.outline_glyphs().get(GlyphId::new(gid))?;
|
||||
let mut pen = FlipPen { path: BezPath::new(), room: usize::MAX, over: false, hinted: true };
|
||||
g.draw(DrawSettings::hinted(hinter, false), &mut pen).map_err(|e| log::warn!("Cannot hint glyph {gid} in {:?}: {e}", self.family)).ok()?;
|
||||
Some(pen.path)
|
||||
}
|
||||
|
||||
/// A light hinting instance for `ppem` device pixels per em, with the engine FreeType's light
|
||||
/// hinting picks: the autohinter for TrueType outlines (their own instructions are ignored;
|
||||
/// measured against Firefox on Linux, the autohinter matches its rows of pixels and the
|
||||
/// TrueType instructions don't), the font's own hints for PostScript (CFF) outlines, and for
|
||||
/// fonts whose glyphs need their instructions to be assembled at all their instructions with
|
||||
/// the monochrome target, as skrifa requires for them.
|
||||
pub(super) fn screen_hinter(&self, ppem: f32) -> Option<HintingInstance> {
|
||||
let f = self.skrifa()?;
|
||||
let outlines = f.outline_glyphs();
|
||||
let light = Target::Smooth { mode: SmoothMode::Light, symmetric_rendering: true, preserve_linear_metrics: true };
|
||||
let (engine, target) = if outlines.require_interpreter() {
|
||||
// FreeType's "tricky" fonts (old CJK fonts that assemble glyphs with their
|
||||
// instructions): skrifa asks for the interpreter with the monochrome target, under
|
||||
// which the instructions may move points sideways, or glyphs come apart.
|
||||
(Engine::Interpreter, Target::Mono)
|
||||
} else if outlines.format() == Some(OutlineGlyphFormat::Glyf) {
|
||||
(Engine::Auto(Some(self.glyph_styles.get_or_init(|| GlyphStyles::new(&outlines)).clone())), light)
|
||||
} else {
|
||||
(Engine::Interpreter, light)
|
||||
};
|
||||
HintingInstance::new(&outlines, Size::new(ppem), self.location(), HintingOptions { engine, target })
|
||||
.map_err(|e| log::warn!("Cannot hint {:?} at {ppem} px: {e}", self.family))
|
||||
.ok()
|
||||
}
|
||||
/// Is `family` this face's typographic, legacy, full or PostScript name?
|
||||
pub fn named(&self, family: &str) -> bool {
|
||||
self.family.eq_ignore_ascii_case(family)
|
||||
@@ -358,13 +396,18 @@ impl FontFace {
|
||||
pub(super) static NEXT_ID: AtomicU32 = AtomicU32::new(1);
|
||||
|
||||
pub(super) const OUTLINE_CACHE_MAX: usize = 50_000;
|
||||
/// Hinting instances kept (one per face and text size on screen).
|
||||
pub(super) const HINTER_CACHE_MAX: usize = 256;
|
||||
/// Sizes hinted, in device pixels per em: below, text is too small to read anyway; above, the
|
||||
/// half-pixel hinting moves is invisible (and each size would cost a cache entry).
|
||||
pub(super) const HINT_PPEM: std::ops::RangeInclusive<f32> = 4.0..=96.0;
|
||||
|
||||
/// Collects an outline flipped to y-down, keeping at most `room` path elements (`over` once it
|
||||
/// had to drop one).
|
||||
pub(super) struct FlipPen {
|
||||
pub(super) path: BezPath,
|
||||
room: usize,
|
||||
over: bool,
|
||||
pub(super) room: usize,
|
||||
pub(super) over: bool,
|
||||
/// Drawn with the face's TrueType interpreter.
|
||||
pub(super) hinted: bool,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::face::{FALLBACK_FAMILY, FontBytes, FontFace, OUTLINE_CACHE_MAX};
|
||||
use super::face::{FALLBACK_FAMILY, FontBytes, FontFace, HINT_PPEM, HINTER_CACHE_MAX, OUTLINE_CACHE_MAX};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use super::fallback_data::read_fallback_file;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -227,4 +227,41 @@ impl FontDb {
|
||||
cache.insert(key, p.clone());
|
||||
p
|
||||
}
|
||||
|
||||
/// Glyph outline hinted for the screen at `ppem` device pixels per em ([`HINT_PPEM`]), in
|
||||
/// device pixels, y-down, for drawing at a baseline on a whole pixel; cached per (face, glyph,
|
||||
/// size). `None` for sizes out of range and faces that can't be hinted: draw [`Self::outline`].
|
||||
pub fn hinted_outline(&self, face: &FontFace, gid: u32, ppem: f32) -> Option<Arc<BezPath>> {
|
||||
if !HINT_PPEM.contains(&ppem) {
|
||||
return None;
|
||||
}
|
||||
let key = (face.id, gid, ppem.to_bits());
|
||||
if let Some(p) = self.hinted.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
|
||||
return p.clone();
|
||||
}
|
||||
let hinter = {
|
||||
let hkey = (face.id, ppem.to_bits());
|
||||
let known = self.hinters.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&hkey).cloned();
|
||||
match known {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
// Built outside the lock: running a font's hinting programs takes a while.
|
||||
let h = face.screen_hinter(ppem).map(Arc::new);
|
||||
let mut cache = self.hinters.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if cache.len() >= HINTER_CACHE_MAX {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(hkey, h.clone());
|
||||
h
|
||||
}
|
||||
}
|
||||
};
|
||||
let p = hinter.and_then(|h| face.hinted_glyph_outline(gid, &h)).map(Arc::new);
|
||||
let mut cache = self.hinted.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if cache.len() >= OUTLINE_CACHE_MAX {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(key, p.clone());
|
||||
p
|
||||
}
|
||||
}
|
||||
@@ -394,6 +394,7 @@ pub(super) fn make_face(
|
||||
index,
|
||||
bmp: std::sync::OnceLock::new(),
|
||||
outline_hinter: std::sync::OnceLock::new(),
|
||||
glyph_styles: std::sync::OnceLock::new(),
|
||||
hint_draws: Default::default(),
|
||||
hint_slow: Default::default(),
|
||||
hinting_off: Default::default(),
|
||||
|
||||
@@ -38,6 +38,7 @@ fn kai_contours_include_complete_character_body() {
|
||||
}
|
||||
}
|
||||
|
||||
use super::face::FlipPen;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -92,6 +93,67 @@ fn run_fallback_is_cached_per_char_set() {
|
||||
assert!(db.run_fallbacks.read().unwrap().map.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_hinting_puts_horizontal_edges_on_whole_pixels_and_moves_nothing_sideways() {
|
||||
let db = FontDb::global();
|
||||
let face = db.face(FALLBACK_FAMILY, "Regular");
|
||||
let x = face.glyph_for('x');
|
||||
// 11 pt at 100 % zoom on a 145 % desktop: a fractional size, as on most screens.
|
||||
let ppem = 11.0 * 96.0 / 72.0 * 1.45;
|
||||
let hinted = db.hinted_outline(&face, x, ppem).expect("Source Sans 3 can be hinted");
|
||||
let plain = db.outline(&face, x);
|
||||
let k = f64::from(ppem) / face.upem;
|
||||
let (h, u) = (kurbo::Shape::bounding_box(&*hinted), kurbo::Shape::bounding_box(&*plain));
|
||||
// The x-height (the top, y-down) and the baseline sit on the pixel grid…
|
||||
assert!((h.y0 - h.y0.round()).abs() < 0.02, "x-height at {}", h.y0);
|
||||
assert!((h.y1 - h.y1.round()).abs() < 0.02, "baseline at {}", h.y1);
|
||||
assert!((u.y0 * k - (u.y0 * k).round()).abs() > 0.02, "the unhinted x-height is off the grid: {}", u.y0 * k);
|
||||
// …and the sides stay where the font puts them, so advances and line breaks don't change.
|
||||
assert!((h.x0 - u.x0 * k).abs() < 0.01 && (h.x1 - u.x1 * k).abs() < 0.01, "{h:?} vs {:?}", u.scale_from_origin(k));
|
||||
// Cached per size.
|
||||
assert!(Arc::ptr_eq(&hinted, &db.hinted_outline(&face, x, ppem).expect("cached")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_hinting_uses_the_autohinter_even_for_fonts_with_their_own_instructions() {
|
||||
// FreeType's light hinting (`hintslight`, Firefox on Linux) runs its autohinter on
|
||||
// TrueType outlines and ignores their instructions: measured against Firefox, the
|
||||
// instructions drew Arial's rows elsewhere and heavier.
|
||||
let db = FontDb::global();
|
||||
let face = db.face(FALLBACK_FAMILY, "Regular");
|
||||
let font = face.skrifa().unwrap();
|
||||
let outlines = font.outline_glyphs();
|
||||
assert!(outlines.prefer_interpreter(), "Source Sans 3 carries TrueType instructions (ttfautohint)");
|
||||
let ppem = 11.0 * 96.0 / 72.0 * 1.45;
|
||||
let target = Target::Smooth { mode: SmoothMode::Light, symmetric_rendering: true, preserve_linear_metrics: true };
|
||||
let drawn = |engine: Engine, gid: u32| {
|
||||
let h = HintingInstance::new(&outlines, Size::new(ppem), face.location(), HintingOptions { engine, target }).unwrap();
|
||||
let mut pen = FlipPen { path: BezPath::new(), room: usize::MAX, over: false, hinted: true };
|
||||
outlines.get(GlyphId::new(gid)).unwrap().draw(DrawSettings::hinted(&h, false), &mut pen).unwrap();
|
||||
pen.path
|
||||
};
|
||||
let mut differs = false;
|
||||
for c in "Every Thursday".chars().filter(|c| !c.is_whitespace()) {
|
||||
let gid = face.glyph_for(c);
|
||||
let ours = db.hinted_outline(&face, gid, ppem).unwrap();
|
||||
assert_eq!(*ours, drawn(Engine::Auto(None), gid), "{c}: the autohinter's outline");
|
||||
differs |= *ours != drawn(Engine::Interpreter, gid);
|
||||
}
|
||||
assert!(differs, "the font's own instructions would draw it differently");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_hinting_refuses_hostile_sizes() {
|
||||
let db = FontDb::global();
|
||||
let face = db.face(FALLBACK_FAMILY, "Regular");
|
||||
let x = face.glyph_for('x');
|
||||
for ppem in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -12.0, 0.0, 1.0, 3.99, 96.01, 1e9] {
|
||||
assert!(db.hinted_outline(&face, x, ppem).is_none(), "{ppem}");
|
||||
}
|
||||
// A glyph id past the font's glyphs has no outline to hint.
|
||||
assert!(db.hinted_outline(&face, u32::MAX, 20.0).is_none_or(|o| o.elements().is_empty()));
|
||||
}
|
||||
|
||||
/// The issue's run (#1549): Thai letters and digits, Latin letters, digits and the baht sign.
|
||||
const THAI_RUN: &str = "[D] น้ำ ค่ำ ปั้น ฝั่ง ปู่ ๑๒๓๔๕๖๗๘๙๐ ฿1,250.50";
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#[allow(unused_imports)]
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::fontdb::face::FlipPen;
|
||||
use crate::fontdb::*;
|
||||
|
||||
const FAMILY: &str = "Sysfont Sans3";
|
||||
@@ -420,6 +421,39 @@ fn a_family_partly_loaded_by_a_character_fallback_is_loaded_whole_for_a_run() {
|
||||
assert_eq!(db.read_faces().iter().filter(|f| f.family == "Sysfont Famly").count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_hinting_runs_a_tricky_fonts_instructions_with_the_monochrome_target() {
|
||||
// FreeType's "tricky" fonts (old CJK fonts listed by name, such as DynaLab's "cpop") assemble
|
||||
// their glyphs with their instructions; skrifa requires the interpreter with `Target::Mono`
|
||||
// for them. A bundled font renamed into the list stands in for one.
|
||||
let dir = font_dir("tricky");
|
||||
std::fs::write(dir.join("Tricky.ttf"), renamed_to("SourceSans3-Regular.ttf", "Sysfont cpop1")).unwrap();
|
||||
let db = FontDb::with_font_dirs(vec![dir]);
|
||||
let face = db.face("Sysfont cpop1", "Regular");
|
||||
assert_eq!(face.family, "Sysfont cpop1");
|
||||
let font = face.skrifa().unwrap();
|
||||
let outlines = font.outline_glyphs();
|
||||
assert!(outlines.require_interpreter(), "the name makes it tricky");
|
||||
let ppem = 11.0 * 96.0 / 72.0 * 1.45;
|
||||
let drawn = |target: Target, gid: u32| {
|
||||
let h = HintingInstance::new(&outlines, Size::new(ppem), face.location(), HintingOptions { engine: Engine::Interpreter, target }).unwrap();
|
||||
let mut pen = FlipPen { path: BezPath::new(), room: usize::MAX, over: false, hinted: true };
|
||||
outlines.get(GlyphId::new(gid)).unwrap().draw(DrawSettings::hinted(&h, false), &mut pen).unwrap();
|
||||
pen.path
|
||||
};
|
||||
// The target decides whether the instructions may move points sideways (the light target's
|
||||
// backward compatibility forbids it): this font's instructions only move points vertically,
|
||||
// so the target itself is checked, and the outlines drawn with it.
|
||||
assert!(matches!(face.screen_hinter(ppem).unwrap().target(), Target::Mono));
|
||||
for c in "Every Thursday".chars().filter(|c| !c.is_whitespace()) {
|
||||
let gid = face.glyph_for(c);
|
||||
assert_eq!(*db.hinted_outline(&face, gid, ppem).unwrap(), drawn(Target::Mono, gid), "{c}: the monochrome target");
|
||||
}
|
||||
// Any other font keeps the light target.
|
||||
let plain = db.face(FALLBACK_FAMILY, "Regular");
|
||||
assert!(matches!(plain.screen_hinter(ppem).unwrap().target(), Target::Smooth { mode: SmoothMode::Light, preserve_linear_metrics: true, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coverage_ranges_hold_exactly_the_mapped_characters() {
|
||||
let data = renamed("SourceSans3-Regular.ttf");
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Separate process: verifies the global test fixture before any other lookup initializes it.
|
||||
#![cfg(feature = "test-support")]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use wordcraft_fonts::FontDb;
|
||||
|
||||
#[test]
|
||||
fn fixed_fonts_exclude_system_and_optional_faces() {
|
||||
let threads: Vec<_> = (0..8).map(|_| std::thread::spawn(FontDb::init_test_fonts)).collect();
|
||||
assert!(threads.into_iter().all(|t| t.join().unwrap()));
|
||||
let db = FontDb::global();
|
||||
let families = [
|
||||
"Inter",
|
||||
"Inter Medium",
|
||||
"Inter SemiBold",
|
||||
"JetBrains Mono",
|
||||
"Source Sans 3",
|
||||
"Source Sans 3 Semibold",
|
||||
"Source Serif 4",
|
||||
"Source Serif 4 Semibold",
|
||||
];
|
||||
assert_eq!(db.families(), families);
|
||||
assert_eq!(db.load_system_fonts(), 0);
|
||||
assert_eq!(db.families(), families);
|
||||
for family in ["Arial", "Calibri", "Carlito", "Noto Sans Arabic", "BIZ UDPGothic"] {
|
||||
assert!(!db.has_family(family), "external family {family} leaked into the test fixture");
|
||||
}
|
||||
assert!(db.fallback_for('你', 0).is_none(), "missing glyph lookup must not consult system fonts");
|
||||
assert_eq!(db.loaded_font_bytes(), 0);
|
||||
assert!(FontDb::init_test_fonts());
|
||||
}
|
||||
@@ -19,3 +19,11 @@ fn a_fresh_session_finds_installed_fonts_by_name() {
|
||||
assert_eq!(&FontDb::global().face(family, "Regular").family, family);
|
||||
assert!(FontDb::global().families().contains(family));
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
#[test]
|
||||
fn test_initialization_cannot_replace_an_application_database() {
|
||||
let db = FontDb::global();
|
||||
assert!(!FontDb::init_test_fonts());
|
||||
assert!(std::ptr::eq(db, FontDb::global()));
|
||||
}
|
||||
@@ -78,7 +78,7 @@ fn fmt_of(doc: &Document, cp: &CharProps, in_code: bool) -> Fmt {
|
||||
/// The flow formatting of a run with properties `props`: with `opts.chars`, its extra formatting,
|
||||
/// its custom character style by name (only its direct formatting then its own) and a
|
||||
/// monospaced font by its name.
|
||||
fn run_fmt(doc: &Document, props: &CharProps, in_code: bool, opts: FlowOpts) -> Fmt {
|
||||
fn run_fmt(doc: &Document, para_style: Option<&str>, props: &CharProps, in_code: bool, opts: FlowOpts) -> Fmt {
|
||||
let mut f = fmt_of(doc, props, in_code);
|
||||
if !opts.chars {
|
||||
return f;
|
||||
@@ -104,6 +104,16 @@ fn run_fmt(doc: &Document, props: &CharProps, in_code: bool, opts: FlowOpts) ->
|
||||
if let Some(id) = custom {
|
||||
f.extra.get_or_insert_with(Default::default).style = Some(id.to_string());
|
||||
}
|
||||
// A size stated without a complex-script size leaves the complex size to what is above it
|
||||
// (a style's, or this size where none says one): said where it isn't this size, since a
|
||||
// writer that gives every script the one size would otherwise change it.
|
||||
let sized = props.size.is_some() || custom.is_some_and(|id| doc.styles.chain(id).iter().any(|s| s.chr.size.is_some()));
|
||||
if props.size_cs.is_none() && sized && f.extra.as_ref().is_none_or(|x| x.size_cs.is_none()) {
|
||||
let cs = doc.styles.resolve_char(para_style, props).size_cs;
|
||||
if f.size != Some(cs) {
|
||||
f.extra.get_or_insert_with(Default::default).size_cs = Some(cs);
|
||||
}
|
||||
}
|
||||
if f.code {
|
||||
f.code = false;
|
||||
f.font = e.shown_font().map(Arc::from);
|
||||
@@ -116,11 +126,15 @@ fn run_fmt(doc: &Document, props: &CharProps, in_code: bool, opts: FlowOpts) ->
|
||||
fn extra_of(e: &CharProps) -> Option<Box<CharProps>> {
|
||||
let set = |v: Option<bool>| v.filter(|b| *b);
|
||||
let x = CharProps {
|
||||
underline: e.underline.filter(|u| !matches!(u, Underline::None | Underline::Single)),
|
||||
// What it turns off, over what the style of its paragraph or span turns on, too.
|
||||
bold: e.bold.filter(|b| !*b),
|
||||
italic: e.italic.filter(|b| !*b),
|
||||
strike: e.strike.filter(|b| !*b),
|
||||
underline: e.underline.filter(|u| *u != Underline::Single),
|
||||
underline_color: e.underline_color,
|
||||
double_strike: set(e.double_strike),
|
||||
caps: set(e.caps),
|
||||
small_caps: set(e.small_caps),
|
||||
caps: e.caps,
|
||||
small_caps: e.small_caps,
|
||||
hidden: set(e.hidden),
|
||||
spacing: e.spacing.filter(|v| *v != 0.0 && v.is_finite()),
|
||||
scale: e.scale.filter(|v| *v != 100.0 && v.is_finite()),
|
||||
@@ -182,6 +196,9 @@ fn para_extra(doc: &Document, p: &wordcraft_doc::props::ParaProps) -> Option<Box
|
||||
tabs
|
||||
});
|
||||
let style = p.style.clone().filter(|id| is_custom_style(doc, id));
|
||||
// Its own, else its custom style's: the kind doesn't say (it stops at Heading 6, and is the
|
||||
// heading style's where the style is based on one).
|
||||
let outline_level = p.outline_level.or_else(|| style.as_ref().and_then(|_| doc.styles.resolve_para(p).outline_level));
|
||||
let x = wordcraft_doc::props::ParaProps {
|
||||
tabs,
|
||||
shading: p.shading,
|
||||
@@ -191,7 +208,7 @@ fn para_extra(doc: &Document, p: &wordcraft_doc::props::ParaProps) -> Option<Box
|
||||
drop_cap: p.drop_cap.filter(|l| *l > 1),
|
||||
drop_cap_distance: p.drop_cap_distance.filter(|_| p.drop_cap.is_some_and(|l| l > 1)),
|
||||
keep_lines: p.keep_lines.filter(|k| *k),
|
||||
outline_level: p.outline_level,
|
||||
outline_level,
|
||||
widow_control: p.widow_control.filter(|w| !*w),
|
||||
// Hyphenated (the document hyphenates and the paragraph doesn't opt out): `Some(false)`.
|
||||
suppress_hyphens: (doc.settings.auto_hyphenation && p.suppress_hyphens != Some(true)).then_some(false),
|
||||
@@ -413,14 +430,25 @@ fn flow_comment(doc: &Document, c: &wordcraft_doc::Comment, parent: Option<u32>,
|
||||
FComment { author: c.author.clone(), initials: c.initials.clone(), date: c.date.clone(), resolved: c.resolved, parent, blocks }
|
||||
}
|
||||
|
||||
/// Whether `li`, the list of heading `p`, is the number its outline level has from its heading
|
||||
/// style's list, not a list of its own: it states that level, and the heading style of that level
|
||||
/// numbers with that list there. Past Heading 6 no such style gives the written outline a number.
|
||||
fn numbered_by_outline(doc: &Document, p: &Paragraph, li: &ListInfo) -> bool {
|
||||
use wordcraft_doc::props::NumRef;
|
||||
p.props.outline_level == Some(li.level)
|
||||
&& li.level < 6
|
||||
&& doc.styles.para_style_props(&format!("Heading{}", li.level + 1)).0.numbering == Some(NumRef { num: li.list, level: li.level })
|
||||
}
|
||||
|
||||
/// A paragraph's list membership as the flow keeps it: without the list's identity and label
|
||||
/// unless `opts` asks for them, and none for a heading numbered only through its style, which a
|
||||
/// format with heading numbering of its own (ODT's outline style) numbers instead.
|
||||
fn flow_list(kind: Kind, list: Option<ListInfo>, p: &Paragraph, opts: FlowOpts) -> Option<ListInfo> {
|
||||
/// unless `opts` asks for them, and none for a heading numbered only through its style or
|
||||
/// [`numbered_by_outline`], which a format with heading numbering of its own (ODT's outline style)
|
||||
/// numbers instead, in its first six levels.
|
||||
fn flow_list(doc: &Document, kind: Kind, list: Option<ListInfo>, p: &Paragraph, opts: FlowOpts) -> Option<ListInfo> {
|
||||
let mut li = list?;
|
||||
if !opts.lists {
|
||||
(li.list, li.label) = (0, None);
|
||||
} else if matches!(kind, Kind::Heading(_)) && p.props.numbering.is_none() {
|
||||
} else if matches!(kind, Kind::Heading(_)) && li.level < 6 && (p.props.numbering.is_none() || numbered_by_outline(doc, p, &li)) {
|
||||
return None;
|
||||
}
|
||||
Some(li)
|
||||
@@ -458,7 +486,7 @@ pub fn flow_paras(doc: &Document, p: &Paragraph) -> Vec<Para> {
|
||||
fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec<Para> {
|
||||
let tracked = opts.tracked;
|
||||
let (kind, list) = para_kind(doc, p);
|
||||
let list = flow_list(kind, list, p, opts);
|
||||
let list = flow_list(doc, kind, list, p, opts);
|
||||
let mut done: Vec<Para> = Vec::new();
|
||||
let borders = if kind == Kind::Rule { None } else { p.props.borders.filter(|b| b.takes_room()) };
|
||||
let mut out = Para {
|
||||
@@ -494,7 +522,7 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec<Para> {
|
||||
// Formats that keep hidden text get it, marked hidden.
|
||||
let hidden_text = (props.hidden.unwrap_or(false) && !opts.chars) || (props.del.is_some() && !tracked);
|
||||
let hidden_obj = (props.hidden.unwrap_or(false) && !opts.chars) || props.del.is_some();
|
||||
let mut f = run_fmt(doc, props, in_code, opts);
|
||||
let mut f = run_fmt(doc, p.props.style.as_deref(), props, in_code, opts);
|
||||
if tracked {
|
||||
(f.ins, f.del) = rev_marks(doc, props);
|
||||
f.fmt_change = props.fmt_change.as_deref().map(|c| Box::new(FmtChange { mark: rev_mark(doc, c.rev), old: fmt_of(doc, &c.old, in_code) }));
|
||||
|
||||
@@ -50,6 +50,20 @@ impl WithExtra for CharProps {
|
||||
if f.underline {
|
||||
self.underline_color = x.underline_color;
|
||||
}
|
||||
// Turned off on the text, over what the style of its paragraph or span turns on.
|
||||
if !f.bold && x.bold == Some(false) {
|
||||
self.bold = Some(false);
|
||||
}
|
||||
if !f.italic && x.italic == Some(false) {
|
||||
self.italic = Some(false);
|
||||
}
|
||||
if !f.strike && x.strike == Some(false) {
|
||||
// A double strike under it goes too.
|
||||
(self.strike, self.double_strike) = (Some(false), Some(false));
|
||||
}
|
||||
if !f.underline && x.underline == Some(Underline::None) {
|
||||
self.underline = Some(Underline::None);
|
||||
}
|
||||
if x.double_strike == Some(true) {
|
||||
(self.strike, self.double_strike) = (None, Some(true));
|
||||
}
|
||||
|
||||
@@ -1259,6 +1259,7 @@ impl Body<'_> {
|
||||
if self.frame.is_none() && self.note.is_none() && self.del.is_none() && !self.in_tc {
|
||||
self.group = Some(OpenGroup {
|
||||
anchor: get(a, "text:anchor-type").map(String::from),
|
||||
page: get(a, "text:anchor-page-number").map(String::from),
|
||||
style: get(a, "draw:style-name").map(String::from),
|
||||
members: Vec::new(),
|
||||
});
|
||||
@@ -1300,10 +1301,14 @@ impl Body<'_> {
|
||||
}
|
||||
"table-column" => {
|
||||
let w = get(a, "table:style-name").and_then(|n| self.styles.column_width(n)).unwrap_or(0.0);
|
||||
let share = self.styles.column_share(get(a, "table:style-name")).unwrap_or(0.0);
|
||||
let cells = get(a, "table:default-cell-style-name").and_then(|s| self.styles.cell_background(s));
|
||||
let n = get(a, "table:number-columns-repeated").and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).max(1);
|
||||
if let Some(t) = self.tables.last_mut() {
|
||||
let room = wordcraft_doc::table::MAX_COLS.saturating_sub(t.widths.len());
|
||||
t.widths.extend(std::iter::repeat_n(w, n.min(room)));
|
||||
t.shares.extend(std::iter::repeat_n(share, n.min(room)));
|
||||
t.column_backgrounds.extend(std::iter::repeat_n(cells, n.min(room)));
|
||||
}
|
||||
}
|
||||
"table-row" => self.start_row(a),
|
||||
@@ -1311,7 +1316,16 @@ impl Body<'_> {
|
||||
self.end_para();
|
||||
let span = get(a, "table:number-columns-spanned").and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(1).clamp(1, 63);
|
||||
let rows = get(a, "table:number-rows-spanned").and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(1).clamp(1, 1000);
|
||||
let shading = get(a, "table:style-name").and_then(|s| self.styles.cell_background(s));
|
||||
// WordCraft's tables have their backgrounds on their cells: a row's or table's on
|
||||
// those with none of their own, as it shows behind them. A cell with no style has
|
||||
// its column's default cell style; LibreOffice doesn't apply a row's.
|
||||
let behind = self.tables.last().and_then(|t| t.row_background.or(t.background));
|
||||
let in_column = self.tables.last().and_then(|t| {
|
||||
let at: usize = t.row.iter().map(|c| c.colspan.max(1) as usize).sum();
|
||||
t.column_backgrounds.get(at).copied().flatten()
|
||||
});
|
||||
let own = get(a, "table:style-name").map_or(in_column, |s| self.styles.cell_background(s));
|
||||
let shading = own.or(behind);
|
||||
let valign = get(a, "table:style-name").and_then(|s| self.styles.cell_valign(s));
|
||||
let borders = get(a, "table:style-name").and_then(|s| self.styles.cell_borders(s));
|
||||
let margins = get(a, "table:style-name").and_then(|s| self.styles.cell_padding(s));
|
||||
@@ -1391,16 +1405,35 @@ 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();
|
||||
// What the shares divide: an aligned table's stated width, however wide, as LibreOffice
|
||||
// keeps it; else its part of the text width.
|
||||
let room = width.filter(|_| align.is_some() && rel.is_none()).or_else(|| width_pct.map(|p| self.sec.text_width() * p / 100.0));
|
||||
let contained = !self.tables.is_empty() || self.containers.len() > 1 || self.sec.columns.count > 1;
|
||||
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 });
|
||||
let background = get(a, "table:style-name").and_then(|s| self.styles.table_background(s));
|
||||
self.tables.push(TableB {
|
||||
rows: Vec::new(),
|
||||
row: Vec::new(),
|
||||
hcover: 0,
|
||||
widths: Vec::new(),
|
||||
shares: Vec::new(),
|
||||
room,
|
||||
contained,
|
||||
column_backgrounds: Vec::new(),
|
||||
extra,
|
||||
background,
|
||||
row_background: None,
|
||||
});
|
||||
}
|
||||
|
||||
/// 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 background = get(a, "table:style-name").and_then(|s| self.styles.table_background(s));
|
||||
let keep = self.styles.row_keep(get(a, "table:style-name"));
|
||||
if let Some(t) = self.tables.last_mut() {
|
||||
t.row_background = background;
|
||||
t.row.clear();
|
||||
t.hcover = 0;
|
||||
if t.extra.heights.len() < wordcraft_doc::table::MAX_ROWS {
|
||||
|
||||
@@ -61,9 +61,12 @@ pub(in crate::odt) fn read_formula(xml: &[u8]) -> Option<(String, bool)> {
|
||||
|
||||
/// A frame being read: its width and height, picture bytes, alt text and the equation read from
|
||||
/// an embedded formula object.
|
||||
/// A group being read (ODF 1.3 §10.3.15 `draw:g`): its anchor, its style, and its members so far.
|
||||
/// A group being read (ODF 1.3 §10.3.15 `draw:g`): its anchor and page, its style, and its members
|
||||
/// so far.
|
||||
pub(in crate::odt) struct OpenGroup {
|
||||
pub(in crate::odt) anchor: Option<String>,
|
||||
/// Its `text:anchor-page-number`.
|
||||
pub(in crate::odt) page: Option<String>,
|
||||
pub(in crate::odt) style: Option<String>,
|
||||
pub(in crate::odt) members: Vec<model::Member>,
|
||||
}
|
||||
@@ -108,6 +111,13 @@ impl Body<'_> {
|
||||
self.frame = None;
|
||||
return;
|
||||
}
|
||||
// In line with no stated size, a picture comes at its own: fitted to the text, as
|
||||
// other formats' pictures are. A stated size is the file's.
|
||||
let room = self.sec.text_width();
|
||||
let says = |v: Option<f32>| v.is_some_and(|v| v.is_finite() && v > 0.0);
|
||||
// (Still in line when raised, turned or mirrored.)
|
||||
let in_line = float.as_ref().is_none_or(|f| f.wrap == wordcraft_doc::para::Wrap::Inline);
|
||||
let fit = (!says(w) && !says(h) && in_line && room.is_finite() && room > 0.0).then_some(room);
|
||||
let inline = match (boxed, eq) {
|
||||
(Some(blocks), _) => {
|
||||
let mut look = std::mem::take(&mut self.frame_look);
|
||||
@@ -146,12 +156,14 @@ impl Body<'_> {
|
||||
.and_then(|data| picture(data, w, h, alt.trim()))
|
||||
.or_else(|| crate::odt::objects::placeholder(&o.mime).and_then(|p| make_img(p, w, h, alt.trim())));
|
||||
shown.map(|mut img| {
|
||||
fit_to(&mut img, fit);
|
||||
img.float = float.map(Box::new);
|
||||
img.object = Some(std::sync::Arc::new(o));
|
||||
Inline::Image(img)
|
||||
})
|
||||
}
|
||||
(None, None) => img.and_then(|data| picture(data, w, h, alt.trim())).map(|mut img| {
|
||||
fit_to(&mut img, fit);
|
||||
img.float = float.map(Box::new);
|
||||
if let Some(clip) = self.frame_clip.take() {
|
||||
img.crop = crop_fractions(clip, &img.data);
|
||||
@@ -338,7 +350,9 @@ impl Body<'_> {
|
||||
}
|
||||
let members = g.members.into_iter().map(|m| model::Member { x: fin(m.x) - x0, y: fin(m.y) - y0, ..m }).collect();
|
||||
let graphic = self.styles.shape_graphic(g.style.as_deref());
|
||||
let float = crate::odt::frames::float_of(g.anchor.as_deref(), Some(&crate::odt::writer::pt(x0)), Some(&crate::odt::writer::pt(y0)), &graphic);
|
||||
let mut float =
|
||||
crate::odt::frames::float_of(g.anchor.as_deref(), Some(&crate::odt::writer::pt(x0)), Some(&crate::odt::writer::pt(y0)), &graphic);
|
||||
float.page = crate::odt::frames::page_of(g.anchor.as_deref(), g.page.as_deref(), &graphic);
|
||||
let float = (float.wrap != wordcraft_doc::para::Wrap::Inline).then(|| Box::new(float));
|
||||
let fit = |v: f32| if v.is_finite() { v.clamp(1.0, MAX_LEN) } else { 1.0 };
|
||||
self.push_inline(Inline::Group { w: fit(x1 - x0), h: fit(y1 - y0), float, members });
|
||||
@@ -358,6 +372,7 @@ impl Body<'_> {
|
||||
let at = |v: Option<f32>| v.map(crate::odt::writer::pt);
|
||||
let mut f = crate::odt::frames::float_of(get(a, "text:anchor-type"), at(x).as_deref(), at(y).as_deref(), &g);
|
||||
f.z = crate::odt::frames::z_of(a);
|
||||
f.page = crate::odt::frames::page_of(get(a, "text:anchor-type"), get(a, "text:anchor-page-number"), &g);
|
||||
f
|
||||
} else {
|
||||
crate::odt::frames::place(a, w, h, &g, self.text_size())
|
||||
@@ -393,14 +408,50 @@ pub(in crate::odt) fn readable_picture(data: &[u8]) -> bool {
|
||||
/// A picture from a package's bytes: a raster image, or a Windows metafile (WMF, EMF: LibreOffice
|
||||
/// keeps clip art and logos so), sized `w` × `h` (points) when the frame says.
|
||||
pub(in crate::odt) fn picture(data: Vec<u8>, w: Option<f32>, h: Option<f32>, alt: &str) -> Option<model::Img> {
|
||||
if wordcraft_metafile::is_metafile(&data) {
|
||||
// What the frame states, if it could be a size (far past any page, kept from overflowing).
|
||||
let stated = |v: Option<f32>| v.filter(|v| v.is_finite() && *v > 0.0).map(|v| v.min(1e6));
|
||||
let (w, h) = (stated(w), stated(h));
|
||||
let mut img = if wordcraft_metafile::is_metafile(&data) {
|
||||
// An EMF starts with its header record, type 1.
|
||||
let ext = if data.get(..4) == Some(&[1, 0, 0, 0]) { "emf" } else { "wmf" };
|
||||
// Its own size, when the frame gives none: a placeable WMF's or an EMF's frame, points.
|
||||
let natural = if w.is_some() && h.is_some() { None } else { metafile_size(&data) };
|
||||
return Some(model::sized_img(std::sync::Arc::new(data), ext, natural, w, h, alt));
|
||||
model::sized_img(std::sync::Arc::new(data), ext, natural, w, h, alt)
|
||||
} else {
|
||||
make_img(data, w, h, alt)?
|
||||
};
|
||||
// A size the frame states is the file's, however large (a picture across a whole page): only
|
||||
// a picture with none is shrunk to a column. Stated on one side, the other follows the
|
||||
// picture's proportions.
|
||||
let (w, h) = match (w, h) {
|
||||
(Some(w), Some(h)) => (w, h),
|
||||
(Some(w), None) => (w, w * height_per_width(&img.data)),
|
||||
(None, Some(h)) => (h / height_per_width(&img.data), h),
|
||||
(None, None) => return Some(img),
|
||||
};
|
||||
// Past what a document holds, it shrinks in proportion.
|
||||
let (w, h) = match w.max(h) {
|
||||
most if most <= 1584.0 => (w, h),
|
||||
_ if w >= h => (1584.0, h * 1584.0 / w),
|
||||
_ => (w * 1584.0 / h, 1584.0),
|
||||
};
|
||||
(img.w, img.h) = (w.max(1.0), h.max(1.0));
|
||||
Some(img)
|
||||
}
|
||||
|
||||
/// `img` shrunk in proportion to `room` points wide, when it's given and the picture is wider.
|
||||
fn fit_to(img: &mut model::Img, room: Option<f32>) {
|
||||
if let Some(room) = room.filter(|room| img.w > *room) {
|
||||
img.h = (img.h * room / img.w).max(1.0);
|
||||
img.w = room.max(1.0);
|
||||
}
|
||||
make_img(data, w, h, alt)
|
||||
}
|
||||
|
||||
/// A picture's height for each point of its width, by its own size, before anything caps it (1
|
||||
/// when it doesn't say).
|
||||
fn height_per_width(data: &[u8]) -> f32 {
|
||||
let own = if wordcraft_metafile::is_metafile(data) { metafile_size(data) } else { model::image_px(data).map(|(w, h)| (w as f32, h as f32)) };
|
||||
own.filter(|(w, h)| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0).map_or(1.0, |(w, h)| h / w)
|
||||
}
|
||||
|
||||
/// A metafile's own size, points, from its header: a placeable WMF's bounding box in its units
|
||||
|
||||
@@ -73,14 +73,67 @@ pub(in crate::odt) struct TableB {
|
||||
pub(in crate::odt) hcover: usize,
|
||||
/// Widths in points of the `table:table-column`s so far, 0 where the style gives none.
|
||||
pub(in crate::odt) widths: Vec<f32>,
|
||||
/// Each column's share of the table (`style:rel-column-width`), 0 where the style gives none.
|
||||
pub(in crate::odt) shares: Vec<f32>,
|
||||
/// The table's width, points: what the shares divide.
|
||||
pub(in crate::odt) room: Option<f32>,
|
||||
/// It is in a cell, a frame or a column narrower than the page, whose width isn't known here.
|
||||
pub(in crate::odt) contained: bool,
|
||||
/// Its alignment and width, and each row's height so far (see [`crate::model::TableExtra`]).
|
||||
pub(in crate::odt) extra: crate::model::TableExtra,
|
||||
/// The background of each column's default cell style (`table:default-cell-style-name`).
|
||||
pub(in crate::odt) column_backgrounds: Vec<Option<wordcraft_doc::props::Rgb>>,
|
||||
/// The table's background, and the row's being read.
|
||||
pub(in crate::odt) background: Option<wordcraft_doc::props::Rgb>,
|
||||
pub(in crate::odt) row_background: Option<wordcraft_doc::props::Rgb>,
|
||||
}
|
||||
|
||||
/// The narrowest column the document model keeps (a narrower one would lose the table its grid).
|
||||
const MIN_COLUMN: f32 = 6.0;
|
||||
|
||||
/// Each column's width out of `room` points, as LibreOffice shares it out: by its share if it has
|
||||
/// one, else its stated width, which comes off the room first; none narrower than a column can be
|
||||
/// (6 pt). Empty if a column has neither, or no room is left to share.
|
||||
fn widths_of_shares(widths: &[f32], shares: &[f32], room: f32) -> Vec<f32> {
|
||||
let columns = || widths.iter().zip(shares);
|
||||
let usable = |v: f32| v.is_finite() && v > 0.0;
|
||||
let left = room - columns().filter(|(_, s)| **s <= 0.0).map(|(w, _)| *w).sum::<f32>();
|
||||
let total: f32 = shares.iter().filter(|s| **s > 0.0).sum();
|
||||
if widths.len() != shares.len() || !usable(left) || !usable(total) || columns().any(|(w, s)| *w <= 0.0 && *s <= 0.0) {
|
||||
return Vec::new();
|
||||
}
|
||||
columns().map(|(w, s)| if *s > 0.0 { (s / total * left).max(MIN_COLUMN) } else { *w }).collect()
|
||||
}
|
||||
|
||||
impl TableB {
|
||||
pub(in crate::odt) fn finish(self) -> FTable {
|
||||
// A column without a width leaves the whole grid to the equal split.
|
||||
let widths = if self.widths.iter().all(|w| *w > 0.0) { self.widths } else { Vec::new() };
|
||||
// Columns stating their shares (LibreOffice's tables filling the margins, §20.334) divide
|
||||
// the table's width by them; a column with neither leaves the whole grid to the equal split.
|
||||
// An aligned table's shares go before the widths its columns state too, as LibreOffice
|
||||
// reads them. (A table filling the margins is fitted to them whatever its widths say, and
|
||||
// files give it both in proportion; in something narrower than the page, whose width isn't
|
||||
// known here, stated widths are the best guess.)
|
||||
let all_widths = self.widths.iter().all(|w| *w > 0.0);
|
||||
let all_shares = !self.shares.is_empty() && self.shares.iter().all(|s| *s > 0.0);
|
||||
let aligned = self.extra.align.is_some() && !self.contained;
|
||||
let by_shares = self
|
||||
.room
|
||||
.filter(|_| all_shares && (aligned || !all_widths))
|
||||
.map(|room| widths_of_shares(&self.widths, &self.shares, room))
|
||||
.filter(|w| !w.is_empty());
|
||||
let widths = if let Some(w) = by_shares {
|
||||
w
|
||||
} else if all_widths {
|
||||
self.widths
|
||||
} else {
|
||||
// With nothing to divide, or in something narrower than the page with widths stated
|
||||
// too, the shares can't be worked out here: the equal split, as before.
|
||||
let mixed = self.shares.iter().any(|s| *s <= 0.0);
|
||||
match self.room.filter(|_| !(self.contained && mixed)) {
|
||||
Some(room) => widths_of_shares(&self.widths, &self.shares, room),
|
||||
None => Vec::new(),
|
||||
}
|
||||
};
|
||||
let mut rows = self.rows;
|
||||
share_edges(&mut rows);
|
||||
// A table whose cells all say "no line" draws none.
|
||||
|
||||
@@ -417,12 +417,26 @@ pub(super) fn place(a: &Attrs, w: f32, h: f32, g: &Graphic, size: f32) -> Float
|
||||
f.rot = rot;
|
||||
}
|
||||
f.z = z_of(a);
|
||||
f.page = page_of(get(a, "text:anchor-type"), get(a, "text:anchor-page-number"), g);
|
||||
if f.wrap == Wrap::Inline {
|
||||
f.y = inline_raise(g, y.as_deref().and_then(length), h, size);
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// The page a drawing anchored as `anchor` (or as its style says) is on: `number`, its
|
||||
/// `text:anchor-page-number` (§19.872). 0 when it states none, or isn't anchored to a page, where
|
||||
/// LibreOffice ignores the number.
|
||||
pub(super) fn page_of(anchor: Option<&str>, number: Option<&str>, g: &Graphic) -> u32 {
|
||||
if anchor.or(g.anchor.as_deref()).map(str::trim) != Some("page") {
|
||||
return 0;
|
||||
}
|
||||
number.and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(0).min(MAX_ANCHOR_PAGE)
|
||||
}
|
||||
|
||||
/// Highest anchor page kept.
|
||||
const MAX_ANCHOR_PAGE: u32 = 1_000_000;
|
||||
|
||||
/// A drawing's place in the stacking order (§19.231 `draw:z-index`, from 0 at the bottom) as
|
||||
/// WordCraft's [`Float::z`] (higher on top, 0 for unknown): one more.
|
||||
pub(super) fn z_of(a: &Attrs) -> u32 {
|
||||
@@ -564,7 +578,10 @@ pub(super) fn turned(f: &Float) -> bool {
|
||||
/// `style:graphic-properties` attributes, and the frame's `svg:x`/`svg:y` attributes, or its
|
||||
/// `draw:transform` when it is turned (`w` × `h` its size).
|
||||
pub(super) fn frame_of(f: &Float, w: f32, h: f32) -> (&'static str, String, String) {
|
||||
let anchor = if f.h_rel == Anchor::Page && f.v_rel == Anchor::Page {
|
||||
// A page number holds until it is placed by its paragraph: moved in WordCraft.
|
||||
let by_paragraph =
|
||||
matches!(f.h_rel, Anchor::Column | Anchor::Character) || matches!(f.v_rel, Anchor::Paragraph | Anchor::Line | Anchor::Character);
|
||||
let anchor = if (f.page > 0 && !by_paragraph) || (f.h_rel == Anchor::Page && f.v_rel == Anchor::Page) {
|
||||
"page"
|
||||
} else if f.v_rel == Anchor::Line {
|
||||
"char"
|
||||
|
||||
@@ -296,6 +296,8 @@ fn add_custom_styles(doc: &mut Document, named: &[super::styles::NamedStyle], od
|
||||
}),
|
||||
contextual_spacing: look.contextual,
|
||||
bidi: look.rtl.filter(|r| *r),
|
||||
// The document's nine levels; ODF's tenth is the last of them.
|
||||
outline_level: def.outline.map(|o| o.clamp(1, 9) - 1),
|
||||
..Default::default()
|
||||
};
|
||||
let name = super::writer::generating_style(name).map_or_else(|| def.name.clone(), |(_, n)| n);
|
||||
@@ -327,7 +329,7 @@ fn turned_off(mut chr: wordcraft_doc::props::CharProps, off: [bool; 4]) -> wordc
|
||||
chr.underline = Some(wordcraft_doc::props::Underline::None);
|
||||
}
|
||||
if strike {
|
||||
chr.strike = Some(false);
|
||||
(chr.strike, chr.double_strike) = (Some(false), Some(false));
|
||||
}
|
||||
chr
|
||||
}
|
||||
@@ -362,7 +364,7 @@ fn apply_look(doc: &mut Document, kind: model::Kind, look: &super::styles::ParaL
|
||||
fn number_headings(doc: &mut Document, levels: &[super::styles::ListLevel]) {
|
||||
use wordcraft_doc::numbering::{AbstractNum, ListKind, Num, levels_for};
|
||||
use wordcraft_doc::props::NumRef;
|
||||
let numbered: Vec<bool> = (0..6).map(|i| levels.get(i).is_some_and(|l| l.ordered)).collect();
|
||||
let numbered: Vec<bool> = (0..9).map(|i| levels.get(i).is_some_and(|l| l.ordered)).collect();
|
||||
if !numbered.contains(&true) {
|
||||
return;
|
||||
}
|
||||
@@ -395,11 +397,155 @@ fn number_headings(doc: &mut Document, levels: &[super::styles::ListLevel]) {
|
||||
numbering.abstracts.push(AbstractNum { id: aid, name: None, levels: out, ..Default::default() });
|
||||
let nid = numbering.nums.iter().map(|n| n.id + 1).max().unwrap_or(1).max(1);
|
||||
numbering.nums.push(Num { id: nid, abstract_id: aid, ..Default::default() });
|
||||
let styles = Arc::make_mut(&mut doc.styles);
|
||||
for (i, on) in numbered.iter().enumerate() {
|
||||
if *on && let Some(st) = Arc::make_mut(&mut doc.styles).get_mut(&format!("Heading{}", i + 1)) {
|
||||
if *on && let Some(st) = styles.get_mut(&format!("Heading{}", i + 1)) {
|
||||
st.para.numbering = Some(NumRef { num: nid, level: i as u8 });
|
||||
}
|
||||
}
|
||||
// LibreOffice numbers a custom style at an outline level along with that level's headings.
|
||||
for st in &mut styles.styles {
|
||||
if let Some(l) = st.para.outline_level.filter(|l| numbered.get(usize::from(*l)) == Some(&true)) {
|
||||
st.para.numbering = Some(NumRef { num: nid, level: l });
|
||||
}
|
||||
}
|
||||
// A style at a level the outline doesn't number doesn't take its parent's number from it.
|
||||
let of_outline = |st: &wordcraft_doc::styles::Style| {
|
||||
let at = wordcraft_doc::props::ParaProps { style: Some(st.id.clone()), ..Default::default() };
|
||||
st.para.numbering.is_none()
|
||||
&& st.para.outline_level.is_some_and(|l| numbered.get(usize::from(l)) != Some(&true))
|
||||
&& styles.resolve_para(&at).numbering.is_some_and(|n| n.num == nid)
|
||||
};
|
||||
let unnumbered: Vec<String> = styles.styles.iter().filter(|st| of_outline(st)).map(|st| st.id.clone()).collect();
|
||||
for id in unnumbered {
|
||||
if let Some(st) = styles.get_mut(&id) {
|
||||
// (`num: 0` switches the number off.)
|
||||
st.para.numbering = Some(NumRef { num: 0, level: 0 });
|
||||
}
|
||||
}
|
||||
// A heading's own outline level goes before its style's: LibreOffice numbers it by that level.
|
||||
// A heading in a list is that list's.
|
||||
let styles = doc.styles.clone();
|
||||
each_body_para(&mut doc.body, 0, &mut |p| {
|
||||
let Some(l) = p.props.outline_level.filter(|_| p.props.numbering.is_none()) else { return };
|
||||
let style = styles.resolve_para(&wordcraft_doc::props::ParaProps { style: p.props.style.clone(), ..Default::default() });
|
||||
if style.outline_level.is_none() {
|
||||
return;
|
||||
}
|
||||
let own = (numbered.get(usize::from(l)) == Some(&true)).then_some(NumRef { num: nid, level: l });
|
||||
if own != style.numbering {
|
||||
// (`num: 0` switches the style's number off.)
|
||||
p.props.numbering = Some(own.unwrap_or(NumRef { num: 0, level: 0 }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Each paragraph of `blocks`, those in tables too (to 16 levels deep).
|
||||
fn each_body_para(blocks: &mut [Arc<wordcraft_doc::Block>], depth: usize, f: &mut dyn FnMut(&mut wordcraft_doc::Paragraph)) {
|
||||
for b in blocks {
|
||||
match Arc::make_mut(b) {
|
||||
wordcraft_doc::Block::Para(p) => f(p),
|
||||
wordcraft_doc::Block::Table(t) if depth < 16 => {
|
||||
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
|
||||
each_body_para(&mut cell.blocks, depth + 1, f);
|
||||
}
|
||||
}
|
||||
wordcraft_doc::Block::Table(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a run's complex size comes from: its paragraph's style, its character style, and the size
|
||||
/// and complex size it states (as bits).
|
||||
type SizeSource<'a> = (Option<&'a str>, Option<&'a str>, Option<u32>, Option<u32>);
|
||||
|
||||
/// A complex-script size the same as the text's (what WordCraft writes for every script) is no size
|
||||
/// of its own: a later change of size takes it along. The default's and the styles' go together,
|
||||
/// unless a run's complex size would change: a size stated alone, or another complex size between
|
||||
/// them, shows what's above it, so they stay as the file says. A run's goes where its stays the same.
|
||||
fn one_size_for_all_scripts(doc: &mut Document) {
|
||||
let mut sources = std::collections::BTreeSet::new();
|
||||
for blocks in std::iter::once(&doc.body).chain(doc.parts.values().map(|part| &part.blocks)) {
|
||||
size_sources(blocks, 0, &mut sources);
|
||||
}
|
||||
let sizes = |styles: &wordcraft_doc::styles::StyleSheet| -> Vec<f32> {
|
||||
let of = |(para, chr, size, size_cs): &SizeSource| {
|
||||
let run = wordcraft_doc::props::CharProps {
|
||||
style: chr.map(str::to_string),
|
||||
size: size.map(f32::from_bits),
|
||||
size_cs: size_cs.map(f32::from_bits),
|
||||
..Default::default()
|
||||
};
|
||||
styles.resolve_char(*para, &run).size_cs
|
||||
};
|
||||
sources.iter().map(of).collect()
|
||||
};
|
||||
let as_read = Arc::clone(&doc.styles);
|
||||
let was = sizes(&as_read);
|
||||
let styles = Arc::make_mut(&mut doc.styles);
|
||||
let one_size = |c: &mut wordcraft_doc::props::CharProps| {
|
||||
if c.size.is_some() && c.size_cs == c.size {
|
||||
c.size_cs = None;
|
||||
}
|
||||
};
|
||||
one_size(&mut styles.default_chr);
|
||||
styles.styles.iter_mut().for_each(|st| one_size(&mut st.chr));
|
||||
if sizes(&doc.styles) != was {
|
||||
doc.styles = as_read;
|
||||
}
|
||||
let styles = Arc::clone(&doc.styles);
|
||||
let stories = std::iter::once(&mut doc.body).chain(doc.parts.values_mut().map(|part| &mut part.blocks));
|
||||
for blocks in stories {
|
||||
runs_one_size(blocks, 0, &styles);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the runs in `blocks` (and their tables, to 16 levels deep) take their complex sizes from.
|
||||
fn size_sources<'a>(blocks: &'a [Arc<wordcraft_doc::Block>], depth: usize, out: &mut std::collections::BTreeSet<SizeSource<'a>>) {
|
||||
for b in blocks {
|
||||
match &**b {
|
||||
wordcraft_doc::Block::Para(p) => {
|
||||
for r in &p.runs {
|
||||
out.insert((
|
||||
p.props.style.as_deref(),
|
||||
r.props.style.as_deref(),
|
||||
r.props.size.map(f32::to_bits),
|
||||
r.props.size_cs.map(f32::to_bits),
|
||||
));
|
||||
}
|
||||
}
|
||||
wordcraft_doc::Block::Table(t) if depth < 16 => {
|
||||
for cell in t.rows.iter().flat_map(|r| r.cells.iter()) {
|
||||
size_sources(&cell.blocks, depth + 1, out);
|
||||
}
|
||||
}
|
||||
wordcraft_doc::Block::Table(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`one_size_for_all_scripts`] for the runs in `blocks` (and their tables, to 16 levels deep).
|
||||
fn runs_one_size(blocks: &mut [Arc<wordcraft_doc::Block>], depth: usize, styles: &wordcraft_doc::styles::StyleSheet) {
|
||||
for b in blocks {
|
||||
match Arc::make_mut(b) {
|
||||
wordcraft_doc::Block::Para(p) => {
|
||||
let style = p.props.style.clone();
|
||||
for r in &mut p.runs {
|
||||
let Some(said) = r.props.size_cs.filter(|s| Some(*s) == r.props.size) else { continue };
|
||||
r.props.size_cs = None;
|
||||
if styles.resolve_char(style.as_deref(), &r.props).size_cs != said {
|
||||
r.props.size_cs = Some(said);
|
||||
}
|
||||
}
|
||||
}
|
||||
wordcraft_doc::Block::Table(t) if depth < 16 => {
|
||||
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
|
||||
runs_one_size(&mut cell.blocks, depth + 1, styles);
|
||||
}
|
||||
}
|
||||
wordcraft_doc::Block::Table(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The boolean configuration item `name` of `settings.xml` (ODF 1.3 §3.10), when it has one.
|
||||
@@ -655,6 +801,8 @@ struct Parsed {
|
||||
widows: Option<bool>,
|
||||
/// The document's default Asian and complex-script fonts and languages.
|
||||
scripts: wordcraft_doc::props::CharProps,
|
||||
/// The document's default language, if it states one.
|
||||
default_lang: Option<String>,
|
||||
/// The default tab interval, points.
|
||||
default_tab: Option<f32>,
|
||||
/// Tab stops are measured from the indent (LibreOffice's `TabsRelativeToIndent`).
|
||||
@@ -850,6 +998,7 @@ fn parse_tracked(bytes: &[u8], paper: (f32, f32)) -> Result<Parsed, String> {
|
||||
kern: styles.default_kern,
|
||||
widows: styles.stated_widows(),
|
||||
scripts: styles.default_scripts.clone(),
|
||||
default_lang: styles.document_lang(),
|
||||
default_tab: styles.default_tab,
|
||||
tabs_from_indent: styles.tabs_from_indent,
|
||||
page_doc: pages.doc.clone(),
|
||||
@@ -895,6 +1044,7 @@ pub fn import_on(bytes: &[u8], page: &SectionProps) -> Result<Document, String>
|
||||
kern,
|
||||
widows,
|
||||
scripts,
|
||||
default_lang,
|
||||
default_tab,
|
||||
tabs_from_indent,
|
||||
page_doc,
|
||||
@@ -932,6 +1082,9 @@ pub fn import_on(bytes: &[u8], page: &SectionProps) -> Result<Document, String>
|
||||
}
|
||||
Arc::make_mut(&mut doc.styles).default_chr.kern = kern.then_some(1.0);
|
||||
Arc::make_mut(&mut doc.styles).default_chr.overlay(&scripts);
|
||||
// The file's own, or none when it states none (each application then uses its own default):
|
||||
// not the English of WordCraft's new documents.
|
||||
Arc::make_mut(&mut doc.styles).default_chr.lang = default_lang;
|
||||
if let Some(t) = default_tab {
|
||||
doc.settings.default_tab = t;
|
||||
}
|
||||
@@ -970,6 +1123,7 @@ pub fn import_on(bytes: &[u8], page: &SectionProps) -> Result<Document, String>
|
||||
}
|
||||
doc.last_section = last;
|
||||
page_parts::add(&mut doc, parts);
|
||||
one_size_for_all_scripts(&mut doc);
|
||||
if let Some(t) = track {
|
||||
doc.settings.track_changes = t;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ fn columns_xml(c: &Columns) -> String {
|
||||
/// Preformatted Text), each with the look of the document's own style: font, size, bold,
|
||||
/// italic, colour and the rest of its text formatting, spacing, indents, alignment and keep with
|
||||
/// next, written out in full so other programs show them as WordCraft does.
|
||||
fn kind_styles(doc: &Document) -> String {
|
||||
fn kind_styles(doc: &Document, on_outline: &std::collections::BTreeSet<String>) -> String {
|
||||
let mut s = String::new();
|
||||
let mut kinds: Vec<(String, String, String, Option<u8>, &str)> =
|
||||
(1..=6).map(|n| (format!("Heading_20_{n}"), format!("Heading {n}"), format!("Heading{n}"), Some(n), "text")).collect();
|
||||
@@ -63,14 +63,7 @@ fn kind_styles(doc: &Document) -> String {
|
||||
if let Some(c) = pp.contextual_spacing {
|
||||
para.push_str(&format!(" style:contextual-spacing=\"{c}\""));
|
||||
}
|
||||
// A heading level the outline doesn't number names no list style: LibreOffice would
|
||||
// otherwise give it the outline's (unnumbered) level, and its indent.
|
||||
let numbered = pp
|
||||
.numbering
|
||||
.and_then(|r| doc.numbering.level(r.num, r.level))
|
||||
.is_some_and(|l| !matches!(l.format, wordcraft_doc::section::NumFormat::None | wordcraft_doc::section::NumFormat::Bullet));
|
||||
let detach = if outline.is_some() && !numbered { " style:list-style-name=\"\"" } else { "" };
|
||||
let outline = outline.map(|n| format!(" style:default-outline-level=\"{n}\"{detach}")).unwrap_or_default();
|
||||
let outline = outline.map(|n| outline_attrs(doc, n, &pp, on_outline.contains(&id))).unwrap_or_default();
|
||||
s.push_str(&format!(
|
||||
"<style:style style:name=\"{name}\" style:display-name=\"{display}\" style:family=\"paragraph\" style:parent-style-name=\"Standard\" style:next-style-name=\"Standard\"{outline} style:class=\"{class}\"><style:paragraph-properties{para}/><style:text-properties{}/></style:style>",
|
||||
props_text(&cp)
|
||||
@@ -79,6 +72,42 @@ fn kind_styles(doc: &Document) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
/// The styles of the headings in `blocks` (and their tables, to 16 levels deep) with a number by
|
||||
/// their own outline level.
|
||||
fn styles_numbered_by_own_level(blocks: &[std::sync::Arc<wordcraft_doc::Block>], depth: usize, out: &mut std::collections::BTreeSet<String>) {
|
||||
for b in blocks {
|
||||
match &**b {
|
||||
wordcraft_doc::Block::Para(p) => {
|
||||
if p.props.outline_level.is_some()
|
||||
&& p.props.numbering.is_some_and(|n| n.num != 0)
|
||||
&& let Some(s) = &p.props.style
|
||||
{
|
||||
out.insert(s.clone());
|
||||
}
|
||||
}
|
||||
wordcraft_doc::Block::Table(t) if depth < 16 => {
|
||||
for cell in t.rows.iter().flat_map(|r| r.cells.iter()) {
|
||||
styles_numbered_by_own_level(&cell.blocks, depth + 1, out);
|
||||
}
|
||||
}
|
||||
wordcraft_doc::Block::Table(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A paragraph style's outline level `n` (`style:default-outline-level`), its properties being `pp`.
|
||||
fn outline_attrs(doc: &Document, n: u8, pp: &wordcraft_doc::props::ParaProps, on_outline: bool) -> String {
|
||||
// A level the outline doesn't number names no list style: LibreOffice would otherwise give
|
||||
// it the outline's (unnumbered) level, and its indent. Unless a heading in the style has a
|
||||
// number by its own level (`on_outline`), which that would take from it.
|
||||
let numbered = pp
|
||||
.numbering
|
||||
.and_then(|r| doc.numbering.level(r.num, r.level))
|
||||
.is_some_and(|l| !matches!(l.format, wordcraft_doc::section::NumFormat::None | wordcraft_doc::section::NumFormat::Bullet));
|
||||
let detach = if numbered || on_outline { "" } else { " style:list-style-name=\"\"" };
|
||||
format!(" style:default-outline-level=\"{n}\"{detach}")
|
||||
}
|
||||
|
||||
/// The ODF parent of document style `id`: Standard for Normal, the kinds' styles for theirs, a
|
||||
/// custom style by its id.
|
||||
fn odf_parent(doc: &Document, id: Option<&str>) -> String {
|
||||
@@ -98,7 +127,7 @@ fn odf_parent(doc: &Document, id: Option<&str>) -> String {
|
||||
/// The custom paragraph styles in `named` and the custom styles they are based on, as common
|
||||
/// styles (ODF 1.3 §16.2): named by their document id, shown by their name, each with its own
|
||||
/// paragraph and text formatting over its parent's.
|
||||
fn custom_styles(doc: &Document, named: &std::collections::BTreeSet<String>) -> String {
|
||||
fn custom_styles(doc: &Document, named: &std::collections::BTreeSet<String>, on_outline: &std::collections::BTreeSet<String>) -> String {
|
||||
let mut todo: Vec<String> = named.iter().cloned().collect();
|
||||
let mut done = std::collections::BTreeSet::new();
|
||||
let mut s = String::new();
|
||||
@@ -146,16 +175,29 @@ fn custom_styles(doc: &Document, named: &std::collections::BTreeSet<String>) ->
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let parent = odf_parent(doc, st.based_on.as_deref());
|
||||
let outline = pp.outline_level.filter(|l| *l < 9).map(|l| outline_attrs(doc, l + 1, pp, on_outline.contains(&id))).unwrap_or_default();
|
||||
s.push_str(&format!(
|
||||
"<style:style style:name=\"{}\" style:display-name=\"{}\" style:family=\"paragraph\" style:parent-style-name=\"{parent}\"><style:paragraph-properties{attrs}>{tabs}</style:paragraph-properties><style:text-properties{}/></style:style>",
|
||||
"<style:style style:name=\"{}\" style:display-name=\"{}\" style:family=\"paragraph\" style:parent-style-name=\"{parent}\"{outline}><style:paragraph-properties{attrs}>{tabs}</style:paragraph-properties><style:text-properties{}/></style:style>",
|
||||
super::writer::odf_name(&id),
|
||||
x(&super::writer::generated_style(&id).map_or_else(|| st.name.clone(), |(_, d)| d)),
|
||||
props_text(&st.chr)
|
||||
props_text(&with_complex_size(doc, &id, &st.chr))
|
||||
));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Paragraph style `id`'s own text formatting `chr`, with the complex-script size it takes when it
|
||||
/// states only a size: a parent's complex size where one sets it (the model takes it from there,
|
||||
/// and the writer would otherwise give it this style's size), else its own size.
|
||||
fn with_complex_size(doc: &Document, id: &str, chr: &wordcraft_doc::props::CharProps) -> wordcraft_doc::props::CharProps {
|
||||
let mut chr = chr.clone();
|
||||
if chr.size.is_some() && chr.size_cs.is_none() {
|
||||
let cs = doc.styles.resolve_char(Some(id), &wordcraft_doc::props::CharProps::default()).size_cs;
|
||||
chr.size_cs = (chr.size != Some(cs)).then_some(cs);
|
||||
}
|
||||
chr
|
||||
}
|
||||
|
||||
/// The custom character styles in `named` and those they are based on, as common text styles.
|
||||
fn char_styles(doc: &Document, named: &std::collections::BTreeSet<String>) -> String {
|
||||
let mut todo: Vec<String> = named.iter().cloned().collect();
|
||||
@@ -346,25 +388,31 @@ fn styles_xml(
|
||||
let tab = doc.settings.default_tab.clamp(1.0, 1584.0);
|
||||
let mut s = format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<office:document-styles {NS}><office:styles>");
|
||||
s.push_str(&format!(
|
||||
"<style:default-style style:family=\"paragraph\"><style:paragraph-properties{}{widows} style:tab-stop-distance=\"{tab}\"/><style:text-properties fo:font-family=\"'{}'\" fo:font-size=\"{size}\" style:letter-kerning=\"{kern}\"{color}{scripts}/></style:default-style>",
|
||||
"<style:default-style style:family=\"paragraph\"><style:paragraph-properties{}{widows} style:tab-stop-distance=\"{tab}\"/><style:text-properties fo:font-family=\"'{}'\" fo:font-size=\"{size}\" style:font-size-asian=\"{size}\"{size_cs} style:letter-kerning=\"{kern}\"{color}{lang}{scripts}/></style:default-style>",
|
||||
spacing_props(defaults),
|
||||
x(&body_font),
|
||||
// Widow and orphan control as Normal has it, said either way (the standard leaves it open
|
||||
// when unsaid, and readers differ: docs/odf-interpretation.md).
|
||||
widows = if normal.widow_control { " fo:widows=\"2\" fo:orphans=\"2\"" } else { " fo:widows=\"0\" fo:orphans=\"0\"" },
|
||||
size = pt(size),
|
||||
size_cs = if doc.styles.default_chr.size_cs.is_none() { format!(" style:font-size-complex=\"{}\"", pt(size)) } else { String::new() },
|
||||
// The document's text colour (a colour, not automatic).
|
||||
color = match doc.styles.default_chr.color {
|
||||
Some(wordcraft_doc::props::TextColor::Rgb(c)) => format!(" fo:color=\"#{}\"", c.hex().to_ascii_lowercase()),
|
||||
_ => String::new(),
|
||||
},
|
||||
tab = pt(tab),
|
||||
// The document's language, without which each reader uses its own default: Normal's, as
|
||||
// WordCraft has it (a Word file's Normal may state another than its defaults).
|
||||
lang = doc.styles.resolve_char(None, &Default::default()).lang.as_deref().map(|l| super::writer::lang_attrs(l, "")).unwrap_or_default(),
|
||||
scripts = super::writer::script_attrs(&doc.styles.default_chr)
|
||||
));
|
||||
s.push_str("<style:style style:name=\"Standard\" style:family=\"paragraph\" style:class=\"text\"/>");
|
||||
s.push_str(&kind_styles(doc));
|
||||
let mut on_outline = std::collections::BTreeSet::new();
|
||||
styles_numbered_by_own_level(&doc.body, 0, &mut on_outline);
|
||||
s.push_str(&kind_styles(doc, &on_outline));
|
||||
s.push_str(&outline_style(doc));
|
||||
s.push_str(&custom_styles(doc, named));
|
||||
s.push_str(&custom_styles(doc, named, &on_outline));
|
||||
// The note text styles, as WordCraft's Footnote Text and Endnote Text: 10 pt, no spacing.
|
||||
for (name, class) in [("Footnote", "extra"), ("Endnote", "extra")] {
|
||||
s.push_str(&format!(
|
||||
|
||||
@@ -39,6 +39,26 @@ impl FmtDelta {
|
||||
($($k:ident),*) => { $( if let Some(v) = self.$k.clone() { f.$k = v; } )* };
|
||||
}
|
||||
set!(bold, italic, underline, strike, sup, sub, code);
|
||||
// What this turns off stays said on the text, or the style of its paragraph or span would
|
||||
// show through; turned back on, it no longer is.
|
||||
if self.bold.is_some() || self.italic.is_some() || self.underline.is_some() || self.strike.is_some() {
|
||||
use wordcraft_doc::props::Underline;
|
||||
let mut x = f.extra.take().map(|b| *b).unwrap_or_default();
|
||||
if self.strike == Some(false) {
|
||||
x.double_strike = None;
|
||||
}
|
||||
for (state, said) in [(self.bold, &mut x.bold), (self.italic, &mut x.italic), (self.strike, &mut x.strike)] {
|
||||
if let Some(on) = state {
|
||||
*said = (!on).then_some(false);
|
||||
}
|
||||
}
|
||||
match self.underline {
|
||||
Some(false) => x.underline = Some(Underline::None),
|
||||
Some(true) if x.underline == Some(Underline::None) => x.underline = None,
|
||||
_ => {}
|
||||
}
|
||||
f.extra = (x != wordcraft_doc::props::CharProps::default()).then(|| Box::new(x));
|
||||
}
|
||||
if self.color.is_some() {
|
||||
f.color = self.color;
|
||||
}
|
||||
@@ -61,10 +81,12 @@ impl FmtDelta {
|
||||
let size = f.size.unwrap_or(12.0);
|
||||
x.position = Some((r / 100.0 * size).clamp(-1584.0, 1584.0)).filter(|p| *p != 0.0);
|
||||
}
|
||||
// An explicit "off" only cancels what a parent style set.
|
||||
for b in
|
||||
[&mut x.double_strike, &mut x.caps, &mut x.small_caps, &mut x.hidden, &mut x.outline, &mut x.shadow, &mut x.emboss, &mut x.engrave]
|
||||
{
|
||||
// An explicit "off" only cancels what a parent style set (capitals are kept: a
|
||||
// paragraph's or span's style may set them too).
|
||||
if self.strike == Some(false) {
|
||||
x.double_strike = None;
|
||||
}
|
||||
for b in [&mut x.double_strike, &mut x.hidden, &mut x.outline, &mut x.shadow, &mut x.emboss, &mut x.engrave] {
|
||||
if *b == Some(false) {
|
||||
*b = None;
|
||||
}
|
||||
@@ -123,14 +145,10 @@ fn script_properties(a: &Attrs, x: &mut wordcraft_doc::props::CharProps, faces:
|
||||
if let Some(s) = get(a, "style:font-style-complex") {
|
||||
x.italic_cs = Some(matches!(s.trim(), "italic" | "oblique"));
|
||||
}
|
||||
fn tag(v: Option<&str>) -> Option<&str> {
|
||||
v.map(str::trim).filter(|v| (1..=8).contains(&v.len()) && v.chars().all(|c| c.is_ascii_alphanumeric()) && *v != "none" && *v != "zxx")
|
||||
}
|
||||
let lang = |l: &str, c: &str| tag(get(a, l)).map(|l| tag(get(a, c)).map_or_else(|| l.to_string(), |c| format!("{l}-{c}")));
|
||||
if let Some(l) = lang("style:language-asian", "style:country-asian") {
|
||||
if let Some(l) = language_tag(a, "style:language-asian", "style:script-asian", "style:country-asian") {
|
||||
x.lang_ea = Some(l);
|
||||
}
|
||||
if let Some(l) = lang("style:language-complex", "style:country-complex") {
|
||||
if let Some(l) = language_tag(a, "style:language-complex", "style:script-complex", "style:country-complex") {
|
||||
x.lang_bidi = Some(l);
|
||||
}
|
||||
}
|
||||
@@ -203,15 +221,26 @@ fn extra_text_properties(a: &Attrs, d: &mut FmtDelta) {
|
||||
if let Some(v) = get(a, "style:font-relief") {
|
||||
(x.emboss, x.engrave) = (Some(v.trim() == "embossed"), Some(v.trim() == "engraved"));
|
||||
}
|
||||
if let Some(l) = language(a) {
|
||||
x.lang = Some(l);
|
||||
}
|
||||
}
|
||||
|
||||
/// The language `fo:language`, `fo:script` and `fo:country` state: see [`language_tag`].
|
||||
fn language(a: &Attrs) -> Option<String> {
|
||||
language_tag(a, "fo:language", "fo:script", "fo:country")
|
||||
}
|
||||
|
||||
/// The language the attributes `language`, `script` and `country` state, as a tag (`de-DE`,
|
||||
/// `sr-Latn-RS`); `None` when they state none, or no language at all (`none`, `zxx`). A script of
|
||||
/// any other shape than a script's is left out: written back, it would read as a country.
|
||||
fn language_tag(a: &Attrs, language: &str, script: &str, country: &str) -> Option<String> {
|
||||
fn tag(v: Option<&str>) -> Option<&str> {
|
||||
v.map(str::trim).filter(|v| (1..=8).contains(&v.len()) && v.chars().all(|c| c.is_ascii_alphanumeric()))
|
||||
}
|
||||
if let Some(l) = tag(get(a, "fo:language")).filter(|l| *l != "none" && *l != "zxx") {
|
||||
x.lang = Some(match tag(get(a, "fo:country")).filter(|c| *c != "none") {
|
||||
Some(c) => format!("{l}-{c}"),
|
||||
None => l.to_string(),
|
||||
});
|
||||
v.map(str::trim).filter(|v| (1..=8).contains(&v.len()) && v.chars().all(|c| c.is_ascii_alphanumeric()) && *v != "none")
|
||||
}
|
||||
let language = tag(get(a, language)).filter(|l| *l != "zxx")?;
|
||||
let script = tag(get(a, script)).filter(|s| super::writer::is_script(s));
|
||||
Some([Some(language), script, tag(get(a, country))].into_iter().flatten().collect::<Vec<_>>().join("-"))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -229,7 +258,7 @@ struct StyleInfo {
|
||||
outline: Option<u8>,
|
||||
border_bottom: bool,
|
||||
/// `fo:background-color` of a table-cell style's `style:table-cell-properties`.
|
||||
cell_background: Option<Rgb>,
|
||||
cell_background: Option<Option<Rgb>>,
|
||||
/// A table-cell style's background picture (§17.3 `style:background-image`): its package
|
||||
/// path, `style:repeat` and `style:position`.
|
||||
cell_picture: Option<(String, String, String)>,
|
||||
@@ -242,6 +271,8 @@ struct StyleInfo {
|
||||
cell_padding: [Option<f32>; 4],
|
||||
/// `style:column-width` of a table-column style's `style:table-column-properties`, in points.
|
||||
column_width: Option<f32>,
|
||||
/// `style:rel-column-width` of the same: its share of the table (`1*`).
|
||||
column_share: Option<f32>,
|
||||
/// A table style's `style:table-properties`: `table:align`, and its width (`style:width`,
|
||||
/// points, or `style:rel-width`, percent of the text width).
|
||||
table_align: Option<Align>,
|
||||
@@ -252,6 +283,9 @@ 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:background-color` of a table or table-row style (§20.175): what shows behind cells
|
||||
/// with none of their own. `Some(None)`: `transparent`, which clears a parent style's.
|
||||
table_background: Option<Option<Rgb>>,
|
||||
/// `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
|
||||
@@ -455,6 +489,8 @@ pub(super) struct NamedStyle {
|
||||
/// What it turns off, which a style it is based on may turn on: bold, italic, underline,
|
||||
/// strike-through (`fo:font-weight="normal"`…).
|
||||
pub(super) off: [bool; 4],
|
||||
/// Its outline level, 1–10 (`style:default-outline-level`).
|
||||
pub(super) outline: Option<u8>,
|
||||
}
|
||||
|
||||
/// Reads date and time styles (`number:date-style`, `number:time-style`) as Word pictures.
|
||||
@@ -571,8 +607,28 @@ pub(super) fn parse_border(v: &str) -> Option<Border> {
|
||||
any.then_some(b)
|
||||
}
|
||||
|
||||
/// The properties element a table column's or row's `style:default-style` (of `family`) holds.
|
||||
fn table_default_props(family: Option<&str>) -> Option<&'static str> {
|
||||
match family {
|
||||
Some("table-column") => Some("table-column-properties"),
|
||||
Some("table-row") => Some("table-row-properties"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A table column's share of its table (`style:rel-column-width`, `1*`).
|
||||
fn column_share_of(a: &Attrs) -> Option<f32> {
|
||||
get(a, "style:rel-column-width").and_then(|v| v.trim().strip_suffix('*')?.trim().parse::<f32>().ok()).filter(|r| r.is_finite() && *r > 0.0)
|
||||
}
|
||||
|
||||
/// A property element `name` (`q` in full) of the style being read, into `s`: true when it is
|
||||
/// one (paragraph, table, column, cell, section and graphic properties, tab stops).
|
||||
/// What `fo:background-color` says of a table or row: a colour, or none (`transparent`).
|
||||
fn table_background_of(a: &Attrs) -> Option<Option<Rgb>> {
|
||||
let bg = get(a, "fo:background-color")?;
|
||||
if bg.trim() == "transparent" { Some(None) } else { model::parse_color(bg).map(Some) }
|
||||
}
|
||||
|
||||
fn style_child(name: &str, q: &str, a: &Attrs, s: &mut StyleInfo) -> bool {
|
||||
match name {
|
||||
"paragraph-properties" => paragraph_properties(a, s),
|
||||
@@ -599,6 +655,7 @@ fn style_child(name: &str, q: &str, a: &Attrs, s: &mut StyleInfo) -> bool {
|
||||
s.page_number = Some(v.trim().parse::<u32>().ok().map(|n| n.min(32_767)));
|
||||
}
|
||||
// §20.300 `table:align`: `margins` fills the text width, as no alignment does.
|
||||
s.table_background = table_background_of(a).or(s.table_background);
|
||||
s.table_align = match get(a, "table:align").map(str::trim) {
|
||||
Some("left") => Some(Align::Left),
|
||||
Some("center") => Some(Align::Center),
|
||||
@@ -620,6 +677,7 @@ fn style_child(name: &str, q: &str, a: &Attrs, s: &mut StyleInfo) -> bool {
|
||||
}
|
||||
}
|
||||
"table-row-properties" => {
|
||||
s.table_background = table_background_of(a).or(s.table_background);
|
||||
if let Some(h) = get(a, "style:row-height").and_then(length).filter(|h| h.is_finite() && *h > 0.0) {
|
||||
s.row_height = Some((h.min(MAX_LEN), true));
|
||||
} else if let Some(h) = get(a, "style:min-row-height").and_then(length).filter(|h| h.is_finite() && *h > 0.0) {
|
||||
@@ -633,6 +691,9 @@ fn style_child(name: &str, q: &str, a: &Attrs, s: &mut StyleInfo) -> bool {
|
||||
if let Some(w) = get(a, "style:column-width").and_then(length) {
|
||||
s.column_width = Some(w);
|
||||
}
|
||||
if let Some(r) = column_share_of(a) {
|
||||
s.column_share = Some(r);
|
||||
}
|
||||
}
|
||||
"table-cell-properties" => table_cell_properties(a, s),
|
||||
"section-properties" => {
|
||||
@@ -677,7 +738,7 @@ pub(super) fn writing_direction(m: &str) -> wordcraft_doc::props::TextDirection
|
||||
/// A table-cell style's `style:table-cell-properties` (ODF 1.3 §17.18): background, borders,
|
||||
/// padding and vertical alignment.
|
||||
fn table_cell_properties(a: &Attrs, s: &mut StyleInfo) {
|
||||
if let Some(c) = get(a, "fo:background-color").and_then(model::parse_color) {
|
||||
if let Some(c) = table_background_of(a) {
|
||||
s.cell_background = Some(c);
|
||||
}
|
||||
cell_borders_and_padding(a, s);
|
||||
@@ -792,13 +853,18 @@ fn text_properties(a: &Attrs, style: &mut StyleInfo, faces: &HashMap<String, Str
|
||||
let weight = weight.trim();
|
||||
weight.eq_ignore_ascii_case("bold") || weight.parse::<u32>().is_ok_and(|n| n >= 600)
|
||||
};
|
||||
// Latin attributes take precedence; Asian and complex-script attributes are fallbacks.
|
||||
if let Some(weight) = get(a, "fo:font-weight").or_else(|| get(a, "style:font-weight-asian")).or_else(|| get(a, "style:font-weight-complex")) {
|
||||
// Latin attributes take precedence; Asian and complex-script attributes are fallbacks, to turn
|
||||
// it on: one of those saying normal says nothing of Latin text.
|
||||
if let Some(weight) = get(a, "fo:font-weight") {
|
||||
fmt.bold = Some(is_bold(weight));
|
||||
} else if get(a, "style:font-weight-asian").or_else(|| get(a, "style:font-weight-complex")).is_some_and(is_bold) {
|
||||
fmt.bold = Some(true);
|
||||
}
|
||||
let is_italic = |value: &str| value.trim().eq_ignore_ascii_case("italic") || value.trim().eq_ignore_ascii_case("oblique");
|
||||
if let Some(value) = get(a, "fo:font-style").or_else(|| get(a, "style:font-style-asian")).or_else(|| get(a, "style:font-style-complex")) {
|
||||
if let Some(value) = get(a, "fo:font-style") {
|
||||
fmt.italic = Some(is_italic(value));
|
||||
} else if get(a, "style:font-style-asian").or_else(|| get(a, "style:font-style-complex")).is_some_and(is_italic) {
|
||||
fmt.italic = Some(true);
|
||||
}
|
||||
let is_not_none = |value: &str| !value.trim().eq_ignore_ascii_case("none");
|
||||
if let Some(value) = get(a, "style:text-underline-style").or_else(|| get(a, "style:text-underline-type")) {
|
||||
@@ -1039,6 +1105,8 @@ pub(super) struct Styles {
|
||||
pub(super) default_tab: Option<f32>,
|
||||
/// The Asian and complex-script fonts and languages of `style:default-style` for paragraphs.
|
||||
pub(super) default_scripts: wordcraft_doc::props::CharProps,
|
||||
/// The language the default paragraph style states.
|
||||
pub(super) default_lang: Option<String>,
|
||||
/// Pair kerning of `style:default-style` for paragraphs (`style:letter-kerning`, §20.317;
|
||||
/// off unless it says so).
|
||||
pub(super) default_kern: bool,
|
||||
@@ -1048,6 +1116,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 columns: the share of a column whose style gives none.
|
||||
default_column_share: Option<f32>,
|
||||
/// `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.
|
||||
@@ -1057,12 +1127,23 @@ pub(super) struct Styles {
|
||||
}
|
||||
|
||||
impl Styles {
|
||||
/// A table column's or row's `style:default-style` properties (`props` names which): what a
|
||||
/// column or row whose own style doesn't say falls back on.
|
||||
fn read_table_default(&mut self, props: &str, a: &Attrs) {
|
||||
if props == "table-row-properties" {
|
||||
self.default_row_keep = get(a, "fo:keep-together").map(|k| k.trim() == "always");
|
||||
} else {
|
||||
self.default_column_share = column_share_of(a);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn read(&mut self, xml: &[u8], auto_section_is_auto: bool) {
|
||||
let mut in_auto = false;
|
||||
let mut cur: Option<(String, StyleInfo)> = None;
|
||||
let mut in_default = false;
|
||||
let mut in_graphic_default = false;
|
||||
let mut in_row_default = false;
|
||||
// In a table column's or row's `style:default-style`: the properties element it reads.
|
||||
let mut in_table_default: Option<&str> = None;
|
||||
let mut dates = DateStyles::default();
|
||||
let mut list: Option<(String, Vec<ListLevel>)> = None;
|
||||
// The list or outline level being read.
|
||||
@@ -1091,12 +1172,10 @@ 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");
|
||||
in_table_default = table_default_props(get(&a, "style:family"));
|
||||
}
|
||||
"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")
|
||||
}
|
||||
props if in_table_default == Some(props) && cur.is_none() => self.read_table_default(props, &a),
|
||||
"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) {
|
||||
@@ -1105,6 +1184,7 @@ impl Styles {
|
||||
self.default_kern = get(&a, "style:letter-kerning").is_some_and(|k| k.trim() == "true");
|
||||
self.default_color = get(&a, "fo:color").and_then(model::parse_color);
|
||||
script_properties(&a, &mut self.default_scripts, &self.faces);
|
||||
self.default_lang = language(&a);
|
||||
}
|
||||
"paragraph-properties" if in_default && cur.is_none() => {
|
||||
paragraph_spacing(&a, &mut self.default_spacing);
|
||||
@@ -1163,7 +1243,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, in_row_default) = (false, false, false),
|
||||
"default-style" if q.starts_with("style:") => (in_default, in_graphic_default, in_table_default) = (false, false, None),
|
||||
"style" if q.starts_with("style:") => {
|
||||
if let Some((n, s)) = cur.take()
|
||||
&& self.map.len() < 100_000
|
||||
@@ -1202,6 +1282,12 @@ impl Styles {
|
||||
.map(|v| v.min(1638.0))
|
||||
}
|
||||
|
||||
/// The document's language: its Standard style's, else its default paragraph style's. (Written
|
||||
/// from the model, Standard is empty and the default style says it for both.)
|
||||
pub(super) fn document_lang(&self) -> Option<String> {
|
||||
self.map.get("Standard").filter(|s| !s.auto).and_then(|s| s.fmt.extra.lang.clone()).or_else(|| self.default_lang.clone())
|
||||
}
|
||||
|
||||
/// The document's default font: the Standard paragraph style's, else the default style's.
|
||||
pub(super) fn doc_font(&self) -> Option<String> {
|
||||
self.map.get("Standard").filter(|s| !s.auto).and_then(|s| s.fmt.font.as_deref().map(str::to_string)).or_else(|| self.default_font.clone())
|
||||
@@ -1297,6 +1383,32 @@ impl Styles {
|
||||
let chain = self.chain(name);
|
||||
let Some(at) = chain.iter().position(|(n, _)| self.is_named_text(n)) else { return self.text_fmt(name, base) };
|
||||
let mut f = base.clone();
|
||||
// What the span's character style says is its own: its paragraph turning that off doesn't
|
||||
// reach past it (ODF: the span's style wins).
|
||||
if let Some(x) = f.extra.as_deref_mut() {
|
||||
use wordcraft_doc::props::Underline;
|
||||
for (_, s) in chain.iter().skip(at) {
|
||||
let (d, e) = (&s.fmt, &s.fmt.extra);
|
||||
if d.bold.is_some() && x.bold == Some(false) {
|
||||
x.bold = None;
|
||||
}
|
||||
if d.italic.is_some() && x.italic == Some(false) {
|
||||
x.italic = None;
|
||||
}
|
||||
if d.underline.is_some() && x.underline == Some(Underline::None) {
|
||||
x.underline = None;
|
||||
}
|
||||
if (d.strike.is_some() || e.double_strike.is_some()) && x.strike == Some(false) {
|
||||
(x.strike, x.double_strike) = (None, None);
|
||||
}
|
||||
if e.caps.is_some() && x.caps == Some(false) {
|
||||
x.caps = None;
|
||||
}
|
||||
if e.small_caps.is_some() && x.small_caps == Some(false) {
|
||||
x.small_caps = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (_, s) in chain.iter().take(at).rev() {
|
||||
s.fmt.apply(&mut f);
|
||||
}
|
||||
@@ -1340,6 +1452,12 @@ impl Styles {
|
||||
self.chain(name).iter().find_map(|(_, s)| s.column_width)
|
||||
}
|
||||
|
||||
/// A table column's share of its table (`style:rel-column-width`): its style's, whole chain,
|
||||
/// else the default style's.
|
||||
pub(super) fn column_share(&self, name: Option<&str>) -> Option<f32> {
|
||||
name.and_then(|n| self.chain(n).iter().find_map(|(_, s)| s.column_share)).or(self.default_column_share)
|
||||
}
|
||||
|
||||
/// A table style's alignment, width (points) and relative width (percent), whole chain.
|
||||
/// Whether a table style starts a page (`fo:break-before="page"`, whole chain).
|
||||
pub(super) fn table_breaks(&self, name: &str) -> bool {
|
||||
@@ -1379,8 +1497,13 @@ impl Styles {
|
||||
self.chain(name).iter().find_map(|(_, s)| s.cell_picture.clone())
|
||||
}
|
||||
|
||||
pub(super) fn table_background(&self, name: &str) -> Option<Rgb> {
|
||||
self.chain(name).iter().find_map(|(_, s)| s.table_background).flatten()
|
||||
}
|
||||
|
||||
/// Background of a table-cell style (whole chain).
|
||||
pub(super) fn cell_background(&self, name: &str) -> Option<Rgb> {
|
||||
self.chain(name).iter().find_map(|(_, s)| s.cell_background)
|
||||
self.chain(name).iter().find_map(|(_, s)| s.cell_background).flatten()
|
||||
}
|
||||
|
||||
/// Borders of a table-cell style (whole chain).
|
||||
@@ -1507,6 +1630,13 @@ impl Styles {
|
||||
if s.parent.is_none() && fmt.size.is_none() && self.doc_size() != self.default_size {
|
||||
fmt.size = self.default_size;
|
||||
}
|
||||
// And its language, likewise.
|
||||
if s.parent.is_none()
|
||||
&& let Some(lang) = self.default_lang.as_ref().filter(|l| self.document_lang().as_ref() != Some(*l))
|
||||
&& fmt.extra.as_ref().is_none_or(|x| x.lang.is_none())
|
||||
{
|
||||
fmt.extra.get_or_insert_with(Default::default).lang = Some(lang.clone());
|
||||
}
|
||||
let look = ParaLook {
|
||||
align: s.align,
|
||||
keep_next: s.keep_next == Some(true),
|
||||
@@ -1521,13 +1651,16 @@ impl Styles {
|
||||
};
|
||||
let name = s.display.clone().unwrap_or_else(|| odt.replace("_20_", " "));
|
||||
let off = [s.fmt.bold, s.fmt.italic, s.fmt.underline, s.fmt.strike].map(|v| v == Some(false));
|
||||
Some(NamedStyle { name: name.chars().take(super::super::model::MAX_FONT_NAME).collect(), parent, look, off })
|
||||
let outline = s.outline.filter(|o| (1..=10).contains(o));
|
||||
Some(NamedStyle { name: name.chars().take(super::super::model::MAX_FONT_NAME).collect(), parent, look, off, outline })
|
||||
}
|
||||
|
||||
pub(super) fn para(&self, name: &str) -> ParaLook {
|
||||
let chain = self.chain(name);
|
||||
let mut kind = Kind::Normal;
|
||||
let mut kind_at = chain.len();
|
||||
// The nearest outline level; a style with one is no kind's style: its look is its own.
|
||||
let mut outline = None;
|
||||
for (i, (n, s)) in chain.iter().enumerate() {
|
||||
kind_at = i;
|
||||
let dn = s.display.as_deref().unwrap_or(n).to_ascii_lowercase().replace("_20_", " ");
|
||||
@@ -1546,12 +1679,12 @@ impl Styles {
|
||||
if kind != Kind::Normal {
|
||||
break;
|
||||
}
|
||||
if let Some(o) = s.outline.filter(|o| (1..=10).contains(o)) {
|
||||
kind = Kind::Heading(o.min(6));
|
||||
break;
|
||||
}
|
||||
outline = outline.or(s.outline.filter(|o| (1..=10).contains(o)));
|
||||
kind_at = chain.len();
|
||||
}
|
||||
if let Some(o) = outline {
|
||||
kind = Kind::Heading(o.min(6));
|
||||
}
|
||||
// The nearest custom style: its formatting stays in it; the automatic styles over it give
|
||||
// the paragraph's own.
|
||||
let named_at = chain.iter().take(kind_at).position(|(n, _)| self.is_named(n));
|
||||
|
||||
@@ -205,6 +205,81 @@ fn object_without_mathml_falls_back_to_its_replacement_image() {
|
||||
assert!(imgs[0].data.as_slice() == png.as_slice());
|
||||
}
|
||||
|
||||
/// A picture of `w` by `h` pixels, as a PNG.
|
||||
fn png_of(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut png = Cursor::new(Vec::new());
|
||||
image::RgbImage::new(w, h).write_to(&mut png, image::ImageFormat::Png).unwrap();
|
||||
png.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_stated_on_one_side_keeps_its_own_proportions() {
|
||||
// A hundred times as tall as wide: only the cap on a page's size may shrink it, in proportion.
|
||||
let frame = r#"<draw:frame text:anchor-type="as-char" svg:width="500pt"><draw:image xlink:href="Pictures/a.png"/></draw:frame>"#;
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:body><office:text><text:p>{frame}</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let pkg = zip_of(&[("content.xml", content.as_bytes()), ("Pictures/a.png", &png_of(10, 1000))]);
|
||||
let flow = parse(&pkg).unwrap();
|
||||
let img = paras(&flow).into_iter().flat_map(|p| &p.inlines).find_map(|i| if let Inline::Image(img) = i { Some(img) } else { None }).unwrap();
|
||||
assert!((img.w - 15.84).abs() < 0.01 && (img.h - 1584.0).abs() < 0.01, "{} {}", img.w, img.h);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_objects_picture_with_no_stated_size_is_fitted_to_the_text() {
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:automatic-styles><style:page-layout style:name="pm"><style:page-layout-properties fo:page-width="14.8cm" fo:page-height="21cm" fo:margin-left="2cm" fo:margin-right="2cm" fo:margin-top="2cm" fo:margin-bottom="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm"/></office:master-styles></office:document-styles>"#
|
||||
);
|
||||
let frame =
|
||||
r#"<draw:frame text:anchor-type="as-char"><draw:object xlink:href="./Object 1"/><draw:image xlink:href="Pictures/a.png"/></draw:frame>"#;
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:body><office:text><text:p>{frame}</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let pkg = zip_of(&[
|
||||
("content.xml", content.as_bytes()),
|
||||
("styles.xml", styles.as_bytes()),
|
||||
("Object 1/content.xml", b"<office:spreadsheet/>"),
|
||||
("Pictures/a.png", &png_of(1000, 500)),
|
||||
]);
|
||||
let flow = parse(&pkg).unwrap();
|
||||
let img = paras(&flow).into_iter().flat_map(|p| &p.inlines).find_map(|i| if let Inline::Image(img) = i { Some(img) } else { None }).unwrap();
|
||||
assert!(img.object.is_some(), "still the object");
|
||||
assert!((img.w - 306.14).abs() < 0.5 && (img.h - 153.07).abs() < 0.5, "{} {}", img.w, img.h);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_stating_a_size_of_nothing_is_fitted_to_the_text() {
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:automatic-styles><style:page-layout style:name="pm"><style:page-layout-properties fo:page-width="14.8cm" fo:page-height="21cm" fo:margin-left="2cm" fo:margin-right="2cm" fo:margin-top="2cm" fo:margin-bottom="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm"/></office:master-styles></office:document-styles>"#
|
||||
);
|
||||
let frame = r#"<draw:frame text:anchor-type="as-char" svg:width="0pt"><draw:image xlink:href="Pictures/a.png"/></draw:frame>"#;
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:body><office:text><text:p>{frame}</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let pkg = zip_of(&[("content.xml", content.as_bytes()), ("styles.xml", styles.as_bytes()), ("Pictures/a.png", &png_of(1000, 500))]);
|
||||
let flow = parse(&pkg).unwrap();
|
||||
let img = paras(&flow).into_iter().flat_map(|p| &p.inlines).find_map(|i| if let Inline::Image(img) = i { Some(img) } else { None }).unwrap();
|
||||
assert!((img.w - 306.14).abs() < 0.5 && (img.h - 153.07).abs() < 0.5, "{} {}", img.w, img.h);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_in_line_raised_or_turned_with_no_size_is_fitted_to_the_text() {
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:automatic-styles><style:page-layout style:name="pm"><style:page-layout-properties fo:page-width="14.8cm" fo:page-height="21cm" fo:margin-left="2cm" fo:margin-right="2cm" fo:margin-top="2cm" fo:margin-bottom="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm"/></office:master-styles></office:document-styles>"#
|
||||
);
|
||||
let auto = r#"<office:automatic-styles><style:style style:name="fr1" style:family="graphic"><style:graphic-properties style:vertical-pos="middle" style:vertical-rel="char"/></style:style></office:automatic-styles>"#;
|
||||
for attrs in [r#" draw:style-name="fr1""#, r#" draw:transform="rotate (0.5) translate (0cm 0cm)""#] {
|
||||
let frame = format!(r#"<draw:frame text:anchor-type="as-char"{attrs}><draw:image xlink:href="Pictures/a.png"/></draw:frame>"#);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}>{auto}<office:body><office:text><text:p>{frame}</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let pkg = zip_of(&[("content.xml", content.as_bytes()), ("styles.xml", styles.as_bytes()), ("Pictures/a.png", &png_of(1000, 500))]);
|
||||
let flow = parse(&pkg).unwrap();
|
||||
let img = paras(&flow).into_iter().flat_map(|p| &p.inlines).find_map(|i| if let Inline::Image(img) = i { Some(img) } else { None }).unwrap();
|
||||
assert!(img.w <= 306.5, "{attrs}: {} {}", img.w, img.h);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) const NS_T: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#;
|
||||
|
||||
/// A package with `content.xml` and, when given, `styles.xml`.
|
||||
@@ -1269,7 +1344,7 @@ fn the_default_styles_font_size_is_the_documents() {
|
||||
let s = entry(&crate::odt::tests::export_model(&d).unwrap(), "styles.xml");
|
||||
assert!(
|
||||
s.contains(r#"<style:text-properties fo:font-family="'Liberation Serif'" fo:font-size="11pt""#)
|
||||
|| s.contains(r#"fo:font-size="11pt" style:letter-kerning"#),
|
||||
|| s.contains(r#"fo:font-size="11pt" style:font-size-asian="11pt""#),
|
||||
"{s}"
|
||||
);
|
||||
let back = import(&crate::odt::tests::export_model(&d).unwrap()).unwrap();
|
||||
|
||||
@@ -285,6 +285,431 @@ fn objects_in_hidden_text_are_kept() {
|
||||
assert!(p.objects.iter().any(|o| matches!(o, wordcraft_doc::para::InlineObject::BookmarkStart { name } if name == "Secret")), "{:?}", p.objects);
|
||||
}
|
||||
|
||||
/// A paragraph in style `style` holding `span`, with `auto` among its automatic styles.
|
||||
fn off_doc(styles: &str, style: &str, auto: &str, span: &str) -> Document {
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:automatic-styles>{auto}</office:automatic-styles><office:body><office:text><text:p text:style-name="{style}">{span}</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let styles = format!("<office:document-styles {NS_T}><office:styles>{styles}</office:styles></office:document-styles>");
|
||||
import(&crate::odt::tests::package(&content, Some(&styles))).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_turning_off_what_its_style_turns_on_stays_off() {
|
||||
// A word not bold in a bold heading: it showed bold, on screen and after a save.
|
||||
let on = r#"fo:font-weight="bold" fo:font-style="italic" style:text-underline-style="solid" style:text-line-through-style="solid" fo:font-variant="small-caps" fo:text-transform="uppercase""#;
|
||||
let off = r#"fo:font-weight="normal" fo:font-style="normal" style:text-underline-style="none" style:text-line-through-style="none" fo:font-variant="normal" fo:text-transform="none""#;
|
||||
let styles = format!(r#"<style:style style:name="Laut" style:family="paragraph"><style:text-properties {on}/></style:style>"#);
|
||||
let auto = format!(r#"<style:style style:name="T1" style:family="text"><style:text-properties {off}/></style:style>"#);
|
||||
let check = |d: &Document, what: &str| {
|
||||
let p = d.body[0].as_para().unwrap();
|
||||
let shown = |run: usize| d.styles.resolve_char(p.props.style.as_deref(), &p.runs[run].props);
|
||||
assert_eq!(p.text, "onoff", "{what}");
|
||||
let (a, b) = (shown(0), shown(1));
|
||||
assert_eq!((a.bold, a.italic, a.strike, a.small_caps, a.caps), (true, true, true, true, true), "{what}: the style's");
|
||||
assert!(a.underline != Underline::None, "{what}");
|
||||
assert_eq!((b.bold, b.italic, b.strike, b.small_caps, b.caps), (false, false, false, false, false), "{what}: turned off");
|
||||
assert_eq!(b.underline, Underline::None, "{what}");
|
||||
};
|
||||
let d = off_doc(&styles, "Laut", &auto, r#"on<text:span text:style-name="T1">off</text:span>"#);
|
||||
check(&d, "read");
|
||||
check(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap(), "written from the model");
|
||||
check(&import(&export(&d).unwrap()).unwrap(), "written as read");
|
||||
// Turned back on inside it: on.
|
||||
let auto = format!(
|
||||
r#"{auto}<style:style style:name="T2" style:family="text"><style:text-properties fo:font-weight="bold" style:text-underline-style="solid"/></style:style>"#
|
||||
);
|
||||
let d = off_doc(&styles, "Laut", &auto, r#"<text:span text:style-name="T1"><text:span text:style-name="T2">x</text:span></text:span>"#);
|
||||
let p = d.body[0].as_para().unwrap();
|
||||
let x = d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props);
|
||||
assert!(x.bold);
|
||||
assert!(x.underline != Underline::None, "{x:?}");
|
||||
assert!(!x.italic, "still off");
|
||||
// A character style's: off in the automatic style based on it.
|
||||
let styles = r#"<style:style style:name="Zeichen" style:family="text"><style:text-properties fo:font-weight="bold" style:text-underline-style="solid"/></style:style>"#;
|
||||
let auto = r#"<style:style style:name="T3" style:family="text" style:parent-style-name="Zeichen"><style:text-properties fo:font-weight="normal" style:text-underline-style="none"/></style:style>"#;
|
||||
let d = off_doc(styles, "Standard", auto, r#"<text:span text:style-name="T3">x</text:span>"#);
|
||||
let p = d.body[0].as_para().unwrap();
|
||||
let x = d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props);
|
||||
assert_eq!((x.bold, x.underline), (false, Underline::None), "{x:?}");
|
||||
// Text nothing turned on says nothing.
|
||||
let d = off_doc("", "Standard", "", "plain");
|
||||
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
|
||||
assert!(!c.contains(r#"fo:font-weight="normal""#) && !c.contains(r#"style:text-underline-style="none""#), "{c}");
|
||||
}
|
||||
|
||||
/// A weight or slant only for Asian or complex-script text says nothing of Latin text when it is
|
||||
/// normal: the heading's bold stays, on screen and saved. Latin text's own "normal" still counts.
|
||||
#[test]
|
||||
fn an_asian_or_complex_normal_doesnt_turn_latin_text_off() {
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph" style:default-outline-level="1"><style:text-properties fo:font-weight="bold" fo:font-style="italic"/></style:style></office:styles></office:document-styles>"#
|
||||
);
|
||||
let open = |attr: &str| {
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties {attr}/></style:style></office:automatic-styles><office:body><office:text><text:h text:style-name="Heading_20_1" text:outline-level="1">Bold <text:span text:style-name="T1">word</text:span></text:h></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
import(&package(&content, Some(&styles))).unwrap()
|
||||
};
|
||||
let run = |d: &Document| d.body[0].as_para().unwrap().runs.last().unwrap().props.clone();
|
||||
for attr in [
|
||||
r#"style:font-weight-complex="normal""#,
|
||||
r#"style:font-weight-asian="normal""#,
|
||||
r#"style:font-style-complex="normal""#,
|
||||
r#"style:font-style-asian="normal""#,
|
||||
] {
|
||||
let d = open(attr);
|
||||
assert_eq!((run(&d).bold, run(&d).italic), (None, None), "{attr}");
|
||||
let saved = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
|
||||
assert!(!saved.contains(r#"fo:font-weight="normal""#) && !saved.contains(r#"fo:font-style="normal""#), "{attr}: {saved}");
|
||||
}
|
||||
assert_eq!(run(&open(r#"fo:font-weight="normal""#)).bold, Some(false));
|
||||
assert_eq!(run(&open(r#"fo:font-style="normal""#)).italic, Some(false));
|
||||
}
|
||||
|
||||
/// Strike-through turned off over a double one is off, not the double one again.
|
||||
#[test]
|
||||
fn strike_turned_off_over_a_double_strike_stays_off() {
|
||||
let double = r#"<style:text-properties style:text-line-through-style="solid" style:text-line-through-type="double"/>"#;
|
||||
let none = r#"<style:text-properties style:text-line-through-style="none"/>"#;
|
||||
let check = |d: &Document, what: &str| {
|
||||
let p = d.body[0].as_para().unwrap();
|
||||
let c = d.styles.resolve_char(p.props.style.as_deref(), &p.runs.last().unwrap().props);
|
||||
assert_eq!((c.strike, c.double_strike), (false, false), "{what}");
|
||||
};
|
||||
// A style over its parent.
|
||||
let styles = format!(
|
||||
r#"<style:style style:name="Parent" style:family="paragraph">{double}</style:style><style:style style:name="Kid" style:family="paragraph" style:parent-style-name="Parent">{none}</style:style>"#
|
||||
);
|
||||
let d = off_doc(&styles, "Kid", "", "One");
|
||||
check(&d, "a style, read");
|
||||
check(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap(), "a style, written from the model");
|
||||
// A span over its paragraph's style.
|
||||
let styles = format!(r#"<style:style style:name="Parent" style:family="paragraph">{double}</style:style>"#);
|
||||
let auto = format!(r#"<style:style style:name="T1" style:family="text">{none}</style:style>"#);
|
||||
let d = off_doc(&styles, "Parent", &auto, r#"<text:span text:style-name="T1">One</text:span>"#);
|
||||
check(&d, "a span, read");
|
||||
check(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap(), "a span, written from the model");
|
||||
}
|
||||
|
||||
/// A span's character style is its own: a paragraph turning bold or italic off doesn't reach past it.
|
||||
#[test]
|
||||
fn a_spans_character_style_wins_over_its_paragraph_turning_off() {
|
||||
let styles = r#"<style:style style:name="Standard" style:family="paragraph"/><style:style style:name="Strong_20_Emphasis" style:display-name="Strong Emphasis" style:family="text"><style:text-properties fo:font-weight="bold" fo:font-style="italic"/></style:style>"#;
|
||||
let auto = r#"<style:style style:name="P1" style:family="paragraph" style:parent-style-name="Standard"><style:text-properties fo:font-weight="normal" fo:font-style="normal"/></style:style>"#;
|
||||
let d = off_doc(styles, "P1", auto, r#"<text:span text:style-name="Strong_20_Emphasis">One</text:span>"#);
|
||||
let check = |d: &Document, what: &str| {
|
||||
let p = d.body[0].as_para().unwrap();
|
||||
let c = d.styles.resolve_char(p.props.style.as_deref(), &p.runs.last().unwrap().props);
|
||||
assert_eq!((c.bold, c.italic), (true, true), "{what}: {:?}", p.runs.last().unwrap().props);
|
||||
};
|
||||
check(&d, "read");
|
||||
check(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap(), "written from the model");
|
||||
}
|
||||
|
||||
/// `default` as a document's default paragraph style's text properties, with `standard` as its
|
||||
/// Standard style's.
|
||||
fn with_default_text(default: &str, standard: &str) -> Vec<u8> {
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties {default}/></style:default-style><style:style style:name="Standard" style:family="paragraph"><style:text-properties {standard}/></style:style></office:styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:body><office:text><text:p>Hallo</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
crate::odt::tests::package(&content, Some(&styles))
|
||||
}
|
||||
|
||||
/// `styles`' default paragraph style.
|
||||
fn default_style(styles: &str) -> &str {
|
||||
let at = styles.find(r#"<style:default-style style:family="paragraph">"#).expect("a default paragraph style");
|
||||
styles[at..].split("</style:default-style>").next().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A document's language is the one its file states, and a model-written save says it.
|
||||
#[test]
|
||||
fn the_default_language_is_the_files_and_written_back_from_the_model() {
|
||||
let d = import(&with_default_text(r#"fo:language="de" fo:country="DE""#, "")).unwrap();
|
||||
assert_eq!(d.styles.default_chr.lang.as_deref(), Some("de-DE"));
|
||||
assert_eq!(d.styles.resolve_char(None, &Default::default()).lang.as_deref(), Some("de-DE"));
|
||||
// Written from the model (a style or list definition changed), it is still the file's: without
|
||||
// it LibreOffice shows the document in its own default language.
|
||||
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||
let styles = crate::odt::tests::entry(&saved, "styles.xml");
|
||||
assert!(default_style(&styles).contains(r#"fo:language="de" fo:country="DE""#), "{styles}");
|
||||
assert!(!styles.contains(r#"fo:language="en""#), "no style says English: {styles}");
|
||||
assert_eq!(import(&saved).unwrap().styles.default_chr.lang.as_deref(), Some("de-DE"));
|
||||
// Saved as read, the styles WordCraft adds beside the file's don't say English either.
|
||||
let styles = crate::odt::tests::entry(&export(&d).unwrap(), "styles.xml");
|
||||
assert!(!styles.contains(r#"fo:language="en""#), "{styles}");
|
||||
}
|
||||
|
||||
/// The document's language as WordCraft has it is Normal's (a Word file's Normal may state
|
||||
/// another than its defaults): that is what the default style says when written from the model.
|
||||
#[test]
|
||||
fn the_documents_language_written_is_normals() {
|
||||
use std::sync::Arc;
|
||||
let mut d = Document::new();
|
||||
Arc::make_mut(&mut d.styles).default_chr.lang = Some("en-US".into());
|
||||
Arc::make_mut(&mut d.styles).get_mut("Normal").unwrap().chr.lang = Some("fr-FR".into());
|
||||
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||
let s = crate::odt::tests::styles_of(&saved);
|
||||
let def = s.split(r#"<style:default-style style:family="paragraph""#).nth(1).and_then(|t| t.split("</style:default-style>").next()).unwrap();
|
||||
assert!(def.contains(r#"fo:language="fr""#) && def.contains(r#"fo:country="FR""#), "{def}");
|
||||
let back = import(&saved).unwrap();
|
||||
assert_eq!(back.styles.resolve_char(None, &Default::default()).lang.as_deref(), Some("fr-FR"));
|
||||
}
|
||||
|
||||
/// A file stating no language, or `none` or `zxx`, has none: not the English of new documents.
|
||||
#[test]
|
||||
fn a_file_stating_no_language_has_none_and_writes_none() {
|
||||
let d = import(&with_default_text("", "")).unwrap();
|
||||
assert_eq!(d.styles.default_chr.lang, None, "not the English of WordCraft's new documents");
|
||||
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||
assert!(!crate::odt::tests::entry(&saved, "styles.xml").contains("fo:language"));
|
||||
assert_eq!(import(&saved).unwrap().styles.default_chr.lang, None);
|
||||
// `none` and `zxx` are no language.
|
||||
for none in ["none", "zxx"] {
|
||||
let d = import(&with_default_text(&format!(r#"fo:language="{none}" fo:country="none""#), "")).unwrap();
|
||||
assert_eq!(d.styles.default_chr.lang, None, "{none}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A language only Standard states is the document's, and a model-written save says it in the
|
||||
/// default style.
|
||||
#[test]
|
||||
fn a_language_only_the_standard_style_states_survives_a_rewrite() {
|
||||
let d = import(&with_default_text("", r#"fo:language="nb" fo:country="NO""#)).unwrap();
|
||||
assert_eq!(d.styles.default_chr.lang.as_deref(), Some("nb-NO"));
|
||||
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||
let styles = crate::odt::tests::entry(&saved, "styles.xml");
|
||||
assert!(default_style(&styles).contains(r#"fo:language="nb" fo:country="NO""#), "{styles}");
|
||||
let back = import(&saved).unwrap();
|
||||
assert_eq!(back.styles.default_chr.lang.as_deref(), Some("nb-NO"));
|
||||
let p = back.body[0].as_para().unwrap();
|
||||
let lang =
|
||||
p.runs.first().and_then(|r| r.props.lang.clone()).or_else(|| back.styles.resolve_char(p.props.style.as_deref(), &Default::default()).lang);
|
||||
assert_eq!(lang.as_deref(), Some("nb-NO"), "{}", crate::odt::tests::entry(&saved, "styles.xml"));
|
||||
}
|
||||
|
||||
/// A language with a script keeps it, read and written: the document's, a style's for each kind
|
||||
/// of text, and a run's.
|
||||
#[test]
|
||||
fn a_language_with_a_script_keeps_it() {
|
||||
let d = import(&with_default_text(r#"fo:language="sr" fo:script="Latn" fo:country="RS""#, "")).unwrap();
|
||||
assert_eq!(d.styles.default_chr.lang.as_deref(), Some("sr-Latn-RS"));
|
||||
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||
let styles = crate::odt::tests::entry(&saved, "styles.xml");
|
||||
assert!(default_style(&styles).contains(r#"fo:language="sr" fo:script="Latn" fo:country="RS""#), "{styles}");
|
||||
assert!(!styles.contains(r#"fo:country="Latn""#), "{styles}");
|
||||
assert_eq!(import(&saved).unwrap().styles.default_chr.lang.as_deref(), Some("sr-Latn-RS"));
|
||||
|
||||
let (latin, asian, complex) = (
|
||||
r#"fo:language="sr" fo:script="Cyrl" fo:country="BA""#,
|
||||
r#"style:language-asian="zh" style:script-asian="Hant" style:country-asian="TW""#,
|
||||
r#"style:language-complex="az" style:script-complex="Arab" style:country-complex="IR""#,
|
||||
);
|
||||
let run = r#"fo:language="bs" fo:script="Latn" fo:country="BA""#;
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:style style:name="Standard" style:family="paragraph" style:class="text"/><style:style style:name="Eigen" style:display-name="Eigen" style:family="paragraph" style:parent-style-name="Standard"><style:text-properties {latin} {asian} {complex}/></style:style></office:styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties {run}/></style:style></office:automatic-styles><office:body><office:text><text:p text:style-name="Eigen">Own <text:span text:style-name="T1">span</text:span></text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let check = |d: &wordcraft_doc::Document| {
|
||||
let chr = &d.styles.get("Eigen").unwrap().chr;
|
||||
assert_eq!(
|
||||
(chr.lang.as_deref(), chr.lang_ea.as_deref(), chr.lang_bidi.as_deref()),
|
||||
(Some("sr-Cyrl-BA"), Some("zh-Hant-TW"), Some("az-Arab-IR"))
|
||||
);
|
||||
assert_eq!(d.body[0].as_para().unwrap().runs.iter().find_map(|r| r.props.lang.as_deref()), Some("bs-Latn-BA"));
|
||||
};
|
||||
let d = import(&crate::odt::tests::package(&content, Some(&styles))).unwrap();
|
||||
check(&d);
|
||||
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||
let written = crate::odt::tests::entry(&saved, "styles.xml");
|
||||
assert!(written.contains(latin) && written.contains(asian) && written.contains(complex), "{written}");
|
||||
assert!(crate::odt::tests::entry(&saved, "content.xml").contains(run));
|
||||
check(&import(&saved).unwrap());
|
||||
|
||||
// No country, a region of digits, and what isn't a script.
|
||||
let attrs = crate::odt::writer::lang_attrs;
|
||||
assert_eq!(attrs("zh-Hant", ""), r#" fo:language="zh" fo:script="Hant""#);
|
||||
assert_eq!(attrs("es-419", ""), r#" fo:language="es" fo:country="419""#);
|
||||
assert_eq!(attrs("de-DE", "-asian"), r#" style:language-asian="de" style:country-asian="DE""#);
|
||||
let d = import(&with_default_text(r#"fo:language="sr" fo:script="Latin1" fo:country="RS""#, "")).unwrap();
|
||||
assert_eq!(d.styles.default_chr.lang.as_deref(), Some("sr-RS"));
|
||||
}
|
||||
|
||||
/// A style with no parent inherits the default style's language, not Standard's (the document's
|
||||
/// here), and one based on Standard takes Standard's.
|
||||
#[test]
|
||||
fn a_style_with_no_parent_has_the_default_styles_language() {
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:language="en" fo:country="US"/></style:default-style><style:style style:name="Standard" style:family="paragraph" style:class="text"><style:text-properties fo:language="de" fo:country="DE"/></style:style><style:style style:name="Eigen" style:display-name="Eigen" style:family="paragraph"/><style:style style:name="Gesagt" style:display-name="Gesagt" style:family="paragraph"><style:text-properties fo:language="fr" fo:country="FR"/></style:style><style:style style:name="Kind" style:display-name="Kind" style:family="paragraph" style:parent-style-name="Standard"/></office:styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:body><office:text><text:p text:style-name="Eigen">a</text:p><text:p text:style-name="Gesagt">b</text:p><text:p text:style-name="Kind">c</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let check = |d: &wordcraft_doc::Document| {
|
||||
assert_eq!(d.styles.default_chr.lang.as_deref(), Some("de-DE"));
|
||||
let lang = |id: &str| d.styles.resolve_char(Some(id), &Default::default()).lang;
|
||||
assert_eq!(lang("Eigen").as_deref(), Some("en-US"), "no parent: the default style's");
|
||||
assert_eq!(lang("Gesagt").as_deref(), Some("fr-FR"), "its own");
|
||||
assert_eq!(lang("Kind").as_deref(), Some("de-DE"), "based on Standard");
|
||||
};
|
||||
let d = import(&crate::odt::tests::package(&content, Some(&styles))).unwrap();
|
||||
check(&d);
|
||||
check(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap());
|
||||
}
|
||||
|
||||
/// WordCraft's own documents say their language, so a reader doesn't use its own default.
|
||||
#[test]
|
||||
fn a_new_documents_language_is_stated() {
|
||||
let styles = crate::odt::tests::entry(&export(&wordcraft_doc::Document::new()).unwrap(), "styles.xml");
|
||||
assert!(default_style(&styles).contains(r#"fo:language="en" fo:country="US""#), "{styles}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_size_is_every_scripts_size() {
|
||||
// Chinese text set to 24 pt stayed at the default size in LibreOffice: only the Latin size was
|
||||
// written.
|
||||
let mut d = Document::new();
|
||||
let mut p = Paragraph::with_text("漢字", CharProps { size: Some(24.0), ..Default::default() });
|
||||
p.props = Default::default();
|
||||
d.body = vec![para_block(p)];
|
||||
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||
let c = crate::odt::tests::content_of(&saved);
|
||||
assert!(c.contains(r#"fo:font-size="24pt" style:font-size-asian="24pt" style:font-size-complex="24pt""#), "{c}");
|
||||
let styles = crate::odt::tests::entry(&saved, "styles.xml");
|
||||
assert!(styles.contains(r#"fo:font-size="12pt" style:font-size-asian="12pt" style:font-size-complex="12pt""#), "{styles}");
|
||||
// Read back every script has it.
|
||||
let back = import(&saved).unwrap();
|
||||
let run = &back.body[0].as_para().unwrap().runs[0].props;
|
||||
assert_eq!(back.styles.resolve_char(None, run).size_cs, 24.0);
|
||||
// A complex-script size of its own stays its own, written once.
|
||||
let mut d = Document::new();
|
||||
d.body = vec![para_block(Paragraph::with_text("x", CharProps { size: Some(24.0), size_cs: Some(30.0), ..Default::default() }))];
|
||||
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
|
||||
assert_eq!(c.matches("style:font-size-complex").count(), 1, "{c}");
|
||||
assert!(c.contains(r#"style:font-size-complex="30pt""#), "{c}");
|
||||
}
|
||||
|
||||
/// Text and its complex-script size stated alike, over a default style with a smaller one (as
|
||||
/// LibreOffice's files have): that size stays, on screen and saved, for a run and for a heading.
|
||||
#[test]
|
||||
fn a_complex_size_like_the_texts_stays_over_the_default() {
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="12pt" style:font-size-complex="14pt"/></style:default-style><style:style style:name="Standard" style:family="paragraph" style:class="text"/><style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph" style:default-outline-level="1"><style:text-properties fo:font-size="18pt" style:font-size-complex="18pt"/></style:style></office:styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties fo:font-size="24pt" style:font-size-complex="24pt"/></style:style></office:automatic-styles><office:body><office:text><text:p>a <text:span text:style-name="T1">big</text:span></text:p><text:h text:style-name="Heading_20_1" text:outline-level="1">h</text:h></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let d = import(&package(&content, Some(&styles))).unwrap();
|
||||
let check = |d: &Document| {
|
||||
let p = d.body[0].as_para().unwrap();
|
||||
let c = d.styles.resolve_char(p.props.style.as_deref(), &p.runs.last().unwrap().props);
|
||||
assert_eq!((c.size, c.size_cs), (24.0, 24.0));
|
||||
let h = d.body[1].as_para().unwrap();
|
||||
let c = d.styles.resolve_char(h.props.style.as_deref(), &h.runs[0].props);
|
||||
assert_eq!((c.size, c.size_cs), (18.0, 18.0));
|
||||
};
|
||||
check(&d);
|
||||
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||
let written = crate::odt::tests::entry(&saved, "styles.xml");
|
||||
let heading = written.split(r#"style:name="Heading_20_1""#).nth(1).and_then(|t| t.split("</style:style>").next()).unwrap();
|
||||
assert!(heading.contains(r#"style:font-size-complex="18pt""#) && !heading.contains(r#"style:font-size-complex="14pt""#), "{heading}");
|
||||
check(&import(&saved).unwrap());
|
||||
|
||||
// A default stating one size for all goes with that size: Normal changed takes it along.
|
||||
let same = styles.replace(r#"style:font-size-complex="14pt""#, r#"style:font-size-complex="12pt""#);
|
||||
let d = import(&package(&content, Some(&same))).unwrap();
|
||||
assert_eq!(d.styles.default_chr.size_cs, None);
|
||||
}
|
||||
|
||||
/// A complex size equal to the text's goes only where no run's complex size changes without it:
|
||||
/// not under a character style over another size, nor under a size stated alone.
|
||||
#[test]
|
||||
fn a_complex_size_something_shows_stays() {
|
||||
let open = |s: &str, auto: &str, body: &str| {
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="12pt" style:font-size-complex="12pt"/></style:default-style><style:style style:name="Standard" style:family="paragraph" style:class="text"/>{s}</office:styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:automatic-styles>{auto}</office:automatic-styles><office:body><office:text>{body}</office:text></office:body></office:document-content>"#
|
||||
);
|
||||
import(&package(&content, Some(&styles))).unwrap()
|
||||
};
|
||||
let sizes = |d: &Document| {
|
||||
let p = d.body[0].as_para().unwrap();
|
||||
let c = d.styles.resolve_char(p.props.style.as_deref(), &p.runs.last().unwrap().props);
|
||||
(c.size, c.size_cs)
|
||||
};
|
||||
// A character style's, over a paragraph style with another complex size.
|
||||
let d = open(
|
||||
r#"<style:style style:name="P" style:family="paragraph" style:parent-style-name="Standard"><style:text-properties fo:font-size="12pt" style:font-size-complex="18pt"/></style:style><style:style style:name="C" style:family="text"><style:text-properties fo:font-size="24pt" style:font-size-complex="24pt"/></style:style>"#,
|
||||
"",
|
||||
r#"<text:p text:style-name="P"><text:span text:style-name="C">One</text:span></text:p>"#,
|
||||
);
|
||||
assert_eq!(sizes(&d), (24.0, 24.0));
|
||||
let parent = r#"<style:style style:name="Parent" style:family="paragraph" style:parent-style-name="Standard"><style:text-properties fo:font-size="16pt" style:font-size-complex="16pt"/></style:style>"#;
|
||||
// A parent's, under a child stating only a size.
|
||||
let kid = format!(
|
||||
r#"{parent}<style:style style:name="Kid" style:family="paragraph" style:parent-style-name="Parent"><style:text-properties fo:font-size="20pt"/></style:style>"#
|
||||
);
|
||||
assert_eq!(sizes(&open(&kid, "", r#"<text:p text:style-name="Kid">One</text:p>"#)), (20.0, 16.0));
|
||||
// A style's, under a run stating only a size.
|
||||
let d = open(
|
||||
parent,
|
||||
r#"<style:style style:name="T1" style:family="text"><style:text-properties fo:font-size="30pt"/></style:style>"#,
|
||||
r#"<text:p text:style-name="Parent"><text:span text:style-name="T1">One</text:span></text:p>"#,
|
||||
);
|
||||
assert_eq!(sizes(&d), (30.0, 16.0));
|
||||
// Nothing shows it: one size for all, so a change of size takes it along.
|
||||
let d = open(parent, "", r#"<text:p text:style-name="Parent">One</text:p>"#);
|
||||
assert_eq!(sizes(&d), (16.0, 16.0));
|
||||
assert_eq!(d.styles.get("Parent").unwrap().chr.size_cs, None);
|
||||
}
|
||||
|
||||
/// A size stated alone, by a run or a style, under a different complex size above it: saved from the
|
||||
/// model, and saved again, the complex size is still the one above, not the stated size.
|
||||
#[test]
|
||||
fn a_size_stated_alone_keeps_the_complex_size_above_it_when_saved() {
|
||||
let sizes = |d: &Document, i: usize| {
|
||||
let p = d.body[i].as_para().unwrap();
|
||||
let c = d.styles.resolve_char(p.props.style.as_deref(), &p.runs.last().unwrap().props);
|
||||
(c.size, c.size_cs)
|
||||
};
|
||||
let twice = |d: Document| {
|
||||
let once = import(&crate::odt::tests::export_model(&d).unwrap()).unwrap();
|
||||
let again = import(&crate::odt::tests::export_model(&once).unwrap()).unwrap();
|
||||
(once, again)
|
||||
};
|
||||
// From a file: a run stating only a size, and a style stating only a size, under a parent
|
||||
// style with its own complex size.
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="12pt" style:font-size-complex="12pt"/></style:default-style><style:style style:name="Standard" style:family="paragraph" style:class="text"/><style:style style:name="Parent" style:family="paragraph" style:parent-style-name="Standard"><style:text-properties fo:font-size="16pt" style:font-size-complex="16pt"/></style:style><style:style style:name="Kid" style:family="paragraph" style:parent-style-name="Parent"><style:text-properties fo:font-size="20pt"/></style:style></office:styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties fo:font-size="30pt"/></style:style></office:automatic-styles><office:body><office:text><text:p text:style-name="Parent"><text:span text:style-name="T1">One</text:span></text:p><text:p text:style-name="Kid">Two</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let d = import(&package(&content, Some(&styles))).unwrap();
|
||||
assert_eq!((sizes(&d, 0), sizes(&d, 1)), ((30.0, 16.0), (20.0, 16.0)));
|
||||
let (once, again) = twice(d);
|
||||
assert_eq!((sizes(&once, 0), sizes(&once, 1)), ((30.0, 16.0), (20.0, 16.0)), "saved once");
|
||||
assert_eq!((sizes(&again, 0), sizes(&again, 1)), ((30.0, 16.0), (20.0, 16.0)), "saved twice");
|
||||
// Built in the model (a DOCX run with `w:sz` and no `w:szCs`): the default's complex size.
|
||||
let mut d = Document::new();
|
||||
let styles = std::sync::Arc::make_mut(&mut d.styles);
|
||||
styles.default_chr.size = Some(12.0);
|
||||
styles.default_chr.size_cs = Some(16.0);
|
||||
let mut p = Paragraph::with_text("x", CharProps { size: Some(30.0), ..Default::default() });
|
||||
p.props = Default::default();
|
||||
d.body = vec![para_block(p)];
|
||||
assert_eq!(sizes(&d, 0), (30.0, 16.0));
|
||||
let (once, again) = twice(d);
|
||||
assert_eq!((sizes(&once, 0), sizes(&again, 0)), ((30.0, 16.0), (30.0, 16.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_headings_text_of_another_size_keeps_it() {
|
||||
// Only the size its style gives goes with the style: a smaller word in a heading came back
|
||||
|
||||
@@ -368,6 +368,65 @@ fn a_frame_keeps_its_place_in_the_stacking_order() {
|
||||
assert!(crate::odt::tests::entry(&crate::odt::tests::export_model(&d).unwrap(), "content.xml").contains(r#"draw:z-index="0""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_anchored_to_a_page_stays_on_that_page() {
|
||||
// LibreOffice's page-anchored frames name their page. Written without it, a frame on page 3
|
||||
// came back on page 1 in LibreOffice.
|
||||
let frame = |attrs: &str| {
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T} {NS_D}><office:body><office:text><draw:frame {attrs} svg:x="5cm" svg:y="5cm" svg:width="4cm" svg:height="2cm"><draw:text-box><text:p>On it</text:p></draw:text-box></draw:frame><text:p>One</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
import(&crate::odt::tests::package(&content, None)).unwrap()
|
||||
};
|
||||
let d = frame(r#"text:anchor-type="page" text:anchor-page-number="3""#);
|
||||
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
|
||||
assert!(c.contains(r#"text:anchor-type="page" text:anchor-page-number="3""#), "{c}");
|
||||
let back =
|
||||
crate::odt::tests::content_of(&crate::odt::tests::export_model(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap()).unwrap());
|
||||
assert!(back.contains(r#"text:anchor-page-number="3""#), "again: {back}");
|
||||
// A frame anchored to its paragraph names no page, and junk is none.
|
||||
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&frame(r#"text:anchor-type="paragraph""#)).unwrap());
|
||||
assert!(!c.contains("anchor-page-number"), "{c}");
|
||||
for junk in ["-1", "abc", "", "99999999999999"] {
|
||||
let d = frame(&format!(r#"text:anchor-type="page" text:anchor-page-number="{junk}""#));
|
||||
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
|
||||
assert!(!c.contains("anchor-page-number"), "{junk}: {c}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A page number only says something on a page anchor. On another LibreOffice ignores it, so it
|
||||
/// doesn't make a drawing anchored to a page when saved.
|
||||
#[test]
|
||||
fn a_page_number_on_another_anchor_is_no_page_anchor() {
|
||||
let saved = |styles: &str, body: &str| {
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T} {NS_D}><office:automatic-styles>{styles}</office:automatic-styles><office:body><office:text>{body}</office:text></office:body></office:document-content>"#
|
||||
);
|
||||
crate::odt::tests::content_of(&crate::odt::tests::export_model(&import(&crate::odt::tests::package(&content, None)).unwrap()).unwrap())
|
||||
};
|
||||
let frame = |attrs: &str| {
|
||||
format!(
|
||||
r#"<text:p>One<draw:frame {attrs} svg:x="5cm" svg:y="5cm" svg:width="4cm" svg:height="2cm"><draw:text-box><text:p>On it</text:p></draw:text-box></draw:frame></text:p>"#
|
||||
)
|
||||
};
|
||||
for anchor in ["paragraph", "char"] {
|
||||
let c = saved("", &frame(&format!(r#"text:anchor-type="{anchor}" text:anchor-page-number="3""#)));
|
||||
assert!(!c.contains(r#"text:anchor-type="page""#) && !c.contains("anchor-page-number"), "{anchor}: {c}");
|
||||
}
|
||||
let c = saved(
|
||||
"",
|
||||
r#"<text:p>One<draw:line text:anchor-type="paragraph" text:anchor-page-number="3" svg:x1="1cm" svg:y1="1cm" svg:x2="3cm" svg:y2="3cm"/></text:p>"#,
|
||||
);
|
||||
assert!(!c.contains(r#"text:anchor-type="page""#) && !c.contains("anchor-page-number"), "a line: {c}");
|
||||
// An anchor its style gives counts as one the frame states.
|
||||
let by_style = r#"<style:style style:name="fr" style:family="graphic"><style:graphic-properties text:anchor-type="page"/></style:style>"#;
|
||||
let c = saved(by_style, &frame(r#"draw:style-name="fr" text:anchor-page-number="3""#));
|
||||
assert!(c.contains(r#"text:anchor-type="page" text:anchor-page-number="3""#), "{c}");
|
||||
let by_style = r#"<style:style style:name="fr" style:family="graphic"><style:graphic-properties text:anchor-type="paragraph"/></style:style>"#;
|
||||
let c = saved(by_style, &frame(r#"draw:style-name="fr" text:anchor-page-number="3""#));
|
||||
assert!(!c.contains("anchor-page-number"), "{c}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_offering_several_formats_shows_the_first_it_can_read() {
|
||||
// LibreOffice's letter templates: its own metafile, then a PNG. Taking the first left nothing
|
||||
|
||||
@@ -160,6 +160,243 @@ fn a_table_starting_a_page_starts_it() {
|
||||
assert_eq!(c.matches(r#"fo:break-before="page""#).count(), 2, "{c}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_stating_only_their_shares_keep_their_proportions() {
|
||||
// LibreOffice's CV template: a table filling the margins whose columns give only
|
||||
// `style:rel-column-width`. They were read as equal.
|
||||
let column = |name: &str, share: &str| {
|
||||
format!(
|
||||
r#"<style:style style:name="{name}" style:family="table-column"><style:table-column-properties style:rel-column-width="{share}"/></style:style>"#
|
||||
)
|
||||
};
|
||||
let table = r#"<table:table><table:table-column table:style-name="A"/><table:table-column table:style-name="B"/><table:table-row><table:table-cell><text:p>a</text:p></table:table-cell><table:table-cell><text:p>b</text:p></table:table-cell></table:table-row></table:table>"#;
|
||||
let grid = |d: &wordcraft_doc::Document| d.body.iter().find_map(|b| b.as_table()).map(|t| t.grid.clone()).unwrap_or_default();
|
||||
let d = doc(&format!("{}{}", column("A", "1000*"), column("B", "3000*")), table);
|
||||
let g = grid(&d);
|
||||
assert_eq!(g.len(), 2, "{g:?}");
|
||||
assert!((g[1] / g[0] - 3.0).abs() < 0.01, "{g:?}");
|
||||
// Rewritten from the model, the proportions stay.
|
||||
let back = import(&crate::odt::tests::export_model(&d).unwrap()).unwrap();
|
||||
let g = grid(&back);
|
||||
assert!((g[1] / g[0] - 3.0).abs() < 0.01, "{g:?}");
|
||||
// A column without a share leaves the equal split, and junk shares are none.
|
||||
for junk in ["0*", "-1*", "NaN*", "1e39*", "abc"] {
|
||||
let d = doc(&format!("{}{}", column("A", "1000*"), column("B", junk)), table);
|
||||
let g = grid(&d);
|
||||
assert_eq!(g.len(), 2, "{junk}: {g:?}");
|
||||
assert!((g[0] - g[1]).abs() < 0.01, "{junk}: {g:?}");
|
||||
}
|
||||
// A table filling the margins with a width stated beside each share keeps the widths: files
|
||||
// give them in proportion, and the table is fitted to the margins.
|
||||
let stated = r#"<style:style style:name="A" style:family="table-column"><style:table-column-properties style:column-width="2in" style:rel-column-width="2*"/></style:style><style:style style:name="B" style:family="table-column"><style:table-column-properties style:column-width="1in" style:rel-column-width="1*"/></style:style>"#;
|
||||
let g = grid(&doc(stated, table));
|
||||
assert!((g[0] / g[1] - 2.0).abs() < 0.01, "{g:?}");
|
||||
}
|
||||
|
||||
/// A share-only table wider than the text keeps its stated width, as in LibreOffice; a column with
|
||||
/// only a width takes it first, and the shares divide the rest; a column with no share of its own
|
||||
/// takes the default style's.
|
||||
#[test]
|
||||
fn columns_by_shares_divide_the_right_width() {
|
||||
let column = |name: &str, props: &str| {
|
||||
format!(r#"<style:style style:name="{name}" style:family="table-column"><style:table-column-properties {props}/></style:style>"#)
|
||||
};
|
||||
let table_style = |props: &str| format!(r#"<style:style style:name="T" style:family="table"><style:table-properties {props}/></style:style>"#);
|
||||
let table = |names: &[&str]| {
|
||||
let columns: String = names
|
||||
.iter()
|
||||
.map(|n| if n.is_empty() { "<table:table-column/>".to_string() } else { format!(r#"<table:table-column table:style-name="{n}"/>"#) })
|
||||
.collect();
|
||||
let cells: String = names.iter().map(|_| "<table:table-cell><text:p>x</text:p></table:table-cell>").collect();
|
||||
format!(r#"<table:table table:style-name="T">{columns}<table:table-row>{cells}</table:table-row></table:table>"#)
|
||||
};
|
||||
let grid = |d: &wordcraft_doc::Document| d.body.iter().find_map(|b| b.as_table()).map(|t| t.grid.clone()).unwrap_or_default();
|
||||
let near = |got: &[f32], want: &[f32]| {
|
||||
assert!(got.len() == want.len() && got.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.5), "{got:?}, not {want:?}")
|
||||
};
|
||||
// 576 pt, aligned, in a narrower text area.
|
||||
let d = doc(
|
||||
&format!(
|
||||
"{}{}{}",
|
||||
table_style(r#"style:width="8in" table:align="left""#),
|
||||
column("A", r#"style:rel-column-width="1000*""#),
|
||||
column("B", r#"style:rel-column-width="3000*""#)
|
||||
),
|
||||
&table(&["A", "B"]),
|
||||
);
|
||||
near(&grid(&d), &[144.0, 432.0]);
|
||||
near(&grid(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap()), &[144.0, 432.0]);
|
||||
// 72 pt stated, the other 300 by shares.
|
||||
let d = doc(
|
||||
&format!(
|
||||
"{}{}{}{}",
|
||||
table_style(r#"style:width="372pt" table:align="left""#),
|
||||
column("A", r#"style:column-width="72pt""#),
|
||||
column("B", r#"style:rel-column-width="1000*""#),
|
||||
column("C", r#"style:rel-column-width="3000*""#)
|
||||
),
|
||||
&table(&["A", "B", "C"]),
|
||||
);
|
||||
near(&grid(&d), &[72.0, 75.0, 225.0]);
|
||||
// A share on every column goes before a stated width, as in LibreOffice.
|
||||
let d = doc(
|
||||
&format!(
|
||||
"{}{}{}",
|
||||
table_style(r#"style:width="400pt" table:align="left""#),
|
||||
column("A", r#"style:column-width="72pt" style:rel-column-width="1000*""#),
|
||||
column("B", r#"style:rel-column-width="3000*""#)
|
||||
),
|
||||
&table(&["A", "B"]),
|
||||
);
|
||||
near(&grid(&d), &[100.0, 300.0]);
|
||||
// An aligned table whose every column states both: the shares (measured in LibreOffice 26.8).
|
||||
let d = doc(
|
||||
&format!(
|
||||
"{}{}{}",
|
||||
table_style(r#"style:width="400pt" table:align="left""#),
|
||||
column("A", r#"style:column-width="300pt" style:rel-column-width="1*""#),
|
||||
column("B", r#"style:column-width="100pt" style:rel-column-width="1*""#)
|
||||
),
|
||||
&table(&["A", "B"]),
|
||||
);
|
||||
near(&grid(&d), &[200.0, 200.0]);
|
||||
// A share too small for a column is the narrowest one, not the grid lost to the equal split.
|
||||
let d = doc(
|
||||
&format!(
|
||||
"{}{}{}",
|
||||
table_style(r#"style:width="400pt" table:align="left""#),
|
||||
column("A", r#"style:rel-column-width="1*""#),
|
||||
column("B", r#"style:rel-column-width="65535*""#)
|
||||
),
|
||||
&table(&["A", "B"]),
|
||||
);
|
||||
near(&grid(&d), &[6.0, 400.0]);
|
||||
// The default style's share, for a column with none and one whose style says none.
|
||||
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"><office:styles><style:default-style style:family="table-column"><style:table-column-properties style:rel-column-width="1000*"/></style:default-style></office:styles></office:document-styles>"#;
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:automatic-styles>{}{}{}</office:automatic-styles><office:body><office:text>{}</office:text></office:body></office:document-content>"#,
|
||||
table_style(r#"style:width="400pt" table:align="left""#),
|
||||
column("B", r#"style:rel-column-width="3000*""#),
|
||||
column("E", r#"style:use-optimal-column-width="false""#),
|
||||
table(&["", "B"])
|
||||
);
|
||||
near(&grid(&import(&package(&content, Some(styles))).unwrap()), &[100.0, 300.0]);
|
||||
}
|
||||
|
||||
/// A table inside a cell doesn't know its width from the page: with only shares and no width of
|
||||
/// its own to divide, or with some columns stating widths, its columns split it equally, as before.
|
||||
#[test]
|
||||
fn a_table_in_a_cell_shares_out_no_page_width() {
|
||||
let styles = r#"<style:style style:name="W" style:family="table-column"><style:table-column-properties style:column-width="100pt"/></style:style><style:style style:name="S" style:family="table-column"><style:table-column-properties style:rel-column-width="1*"/></style:style><style:style style:name="L" style:family="table"><style:table-properties table:align="left"/></style:style>"#;
|
||||
for (style, columns) in [
|
||||
(r#" table:style-name="L""#, r#"<table:table-column table:style-name="S"/><table:table-column table:style-name="S"/>"#),
|
||||
("", r#"<table:table-column table:style-name="W"/><table:table-column table:style-name="S"/>"#),
|
||||
] {
|
||||
let inner = format!(
|
||||
r#"<table:table{style}>{columns}<table:table-row><table:table-cell><text:p>a</text:p></table:table-cell><table:table-cell><text:p>b</text:p></table:table-cell></table:table-row></table:table>"#
|
||||
);
|
||||
let body = format!(
|
||||
r#"<table:table><table:table-column table:style-name="W"/><table:table-column table:style-name="W"/><table:table-row><table:table-cell>{inner}</table:table-cell><table:table-cell><text:p>c</text:p></table:table-cell></table:table-row></table:table>"#
|
||||
);
|
||||
let d = doc(styles, &body);
|
||||
let outer = d.body.iter().find_map(|b| b.as_table()).unwrap();
|
||||
let inner = outer.rows[0].cells[0].blocks.iter().find_map(|b| b.as_table()).unwrap();
|
||||
// The equal split main gives, which layout fits to the cell (not two page-wide columns).
|
||||
let equal = inner.grid.windows(2).all(|w| (w[0] - w[1]).abs() < 0.01);
|
||||
assert!(equal && inner.grid.first().is_some_and(|w| *w < 300.0), "{style}: {:?}", inner.grid);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tables_and_rows_background_show_behind_their_cells() {
|
||||
// Table Properties › Background: it was dropped, on screen and in LibreOffice after a save.
|
||||
let styles = r##"<style:style style:name="T" style:family="table"><style:table-properties fo:background-color="#ddffdd"/></style:style><style:style style:name="R" style:family="table-row"><style:table-row-properties fo:background-color="#ffdddd"/></style:style><style:style style:name="C" style:family="table-cell"><style:table-cell-properties fo:background-color="#ddddff"/></style:style>"##;
|
||||
let table = r#"<table:table table:style-name="T"><table:table-row><table:table-cell><text:p>table</text:p></table:table-cell><table:table-cell table:style-name="C"><text:p>cell</text:p></table:table-cell></table:table-row><table:table-row table:style-name="R"><table:table-cell><text:p>row</text:p></table:table-cell><table:table-cell table:style-name="C"><text:p>cell</text:p></table:table-cell></table:table-row></table:table>"#;
|
||||
let colours = |d: &wordcraft_doc::Document| {
|
||||
d.body
|
||||
.iter()
|
||||
.find_map(|b| b.as_table())
|
||||
.map(|t| t.rows.iter().flat_map(|r| r.cells.iter().map(|c| c.props.shading.map(|s| s.hex().to_ascii_lowercase()))).collect::<Vec<_>>())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let want = |t: &str, r: &str, c: &str| vec![Some(t.to_string()), Some(c.to_string()), Some(r.to_string()), Some(c.to_string())];
|
||||
let d = doc(styles, table);
|
||||
assert_eq!(colours(&d), want("ddffdd", "ffdddd", "ddddff"));
|
||||
assert_eq!(colours(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap()), want("ddffdd", "ffdddd", "ddddff"));
|
||||
}
|
||||
|
||||
/// As LibreOffice shows them: a cell's own style, else its column's default cell style (a row's
|
||||
/// isn't applied), else the row's background, else the table's. A row or table clearing its
|
||||
/// background (`transparent`) doesn't take its parent's; the table's shows behind that row.
|
||||
#[test]
|
||||
fn a_cells_background_comes_from_its_column_then_row_then_table() {
|
||||
let cell = |n: &str, c: &str| {
|
||||
format!(
|
||||
r##"<style:style style:name="{n}" style:family="table-cell"><style:table-cell-properties fo:background-color="#{c}"/></style:style>"##
|
||||
)
|
||||
};
|
||||
let styles = format!(
|
||||
r##"{}{}<style:style style:name="T" style:family="table"><style:table-properties fo:background-color="#ffff00"/></style:style><style:style style:name="R" style:family="table-row"><style:table-row-properties fo:background-color="#0000ff"/></style:style><style:style style:name="Clear" style:family="table-row" style:parent-style-name="R"><style:table-row-properties fo:background-color="transparent"/></style:style>"##,
|
||||
cell("InRow", "ff0000"),
|
||||
cell("InColumn", "00ff00"),
|
||||
);
|
||||
let c = "<table:table-cell><text:p>x</text:p></table:table-cell>";
|
||||
let table = format!(
|
||||
r#"<table:table table:style-name="T"><table:table-column table:default-cell-style-name="InColumn"/><table:table-column/><table:table-row table:style-name="R" table:default-cell-style-name="InRow">{c}{c}</table:table-row><table:table-row>{c}{c}</table:table-row><table:table-row table:style-name="Clear">{c}{c}</table:table-row></table:table>"#
|
||||
);
|
||||
let colours = |d: &wordcraft_doc::Document| {
|
||||
d.body
|
||||
.iter()
|
||||
.find_map(|b| b.as_table())
|
||||
.map(|t| t.rows.iter().flat_map(|r| r.cells.iter().map(|c| c.props.shading.map(|s| s.hex().to_ascii_lowercase()))).collect::<Vec<_>>())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let s = |v: &str| Some(v.to_string());
|
||||
let want = vec![s("00ff00"), s("0000ff"), s("00ff00"), s("ffff00"), s("00ff00"), s("ffff00")];
|
||||
let d = doc(&styles, &table);
|
||||
assert_eq!(colours(&d), want);
|
||||
assert_eq!(colours(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap()), want);
|
||||
}
|
||||
|
||||
/// A column default after a spanning cell belongs to the column the cell is in.
|
||||
#[test]
|
||||
fn a_column_default_counts_spanned_columns() {
|
||||
let styles =
|
||||
r##"<style:style style:name="C" style:family="table-cell"><style:table-cell-properties fo:background-color="#00ff00"/></style:style>"##;
|
||||
let table = r#"<table:table><table:table-column/><table:table-column/><table:table-column table:default-cell-style-name="C"/><table:table-row><table:table-cell table:number-columns-spanned="2"><text:p>a</text:p></table:table-cell><table:covered-table-cell/><table:table-cell><text:p>b</text:p></table:table-cell></table:table-row></table:table>"#;
|
||||
let d = doc(styles, table);
|
||||
let t = d.body.iter().find_map(|b| b.as_table()).unwrap();
|
||||
let last = t.rows[0].cells.iter().rev().find(|c| !c.blocks.is_empty()).unwrap();
|
||||
assert_eq!(last.props.shading.map(|s| s.hex().to_ascii_lowercase()).as_deref(), Some("00ff00"));
|
||||
}
|
||||
|
||||
/// A column's default cell style that names nothing there is no background, however long its name.
|
||||
#[test]
|
||||
fn a_column_default_naming_no_style_is_no_background() {
|
||||
let long = "x".repeat(100_000);
|
||||
let table = format!(
|
||||
r#"<table:table><table:table-column table:number-columns-repeated="63" table:default-cell-style-name="{long}"/><table:table-row><table:table-cell><text:p>a</text:p></table:table-cell></table:table-row></table:table>"#
|
||||
);
|
||||
let d = doc("", &table);
|
||||
let t = d.body.iter().find_map(|b| b.as_table()).unwrap();
|
||||
assert_eq!(t.rows[0].cells[0].props.shading, None);
|
||||
}
|
||||
|
||||
/// A cell style saying `transparent` over a coloured parent has no background of its own: what's
|
||||
/// behind it shows, in LibreOffice too, from a column's default cell style or the cell's own.
|
||||
#[test]
|
||||
fn a_transparent_cell_style_stops_its_parents_colour() {
|
||||
let styles = r##"<style:style style:name="PC" style:family="table-cell"><style:table-cell-properties fo:background-color="#ff0000"/></style:style><style:style style:name="KC" style:family="table-cell" style:parent-style-name="PC"><style:table-cell-properties fo:background-color="transparent"/></style:style><style:style style:name="R" style:family="table-row"><style:table-row-properties fo:background-color="#0000ff"/></style:style>"##;
|
||||
for (column, cell) in [(r#" table:default-cell-style-name="KC""#, ""), ("", r#" table:style-name="KC""#)] {
|
||||
let table = format!(
|
||||
r#"<table:table><table:table-column{column}/><table:table-row table:style-name="R"><table:table-cell{cell}><text:p>a</text:p></table:table-cell></table:table-row></table:table>"#
|
||||
);
|
||||
let d = doc(styles, &table);
|
||||
let t = d.body.iter().find_map(|b| b.as_table()).unwrap();
|
||||
assert_eq!(t.rows[0].cells[0].props.shading, crate::model::parse_color("#0000ff"), "{column}{cell}");
|
||||
}
|
||||
}
|
||||
|
||||
#[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.
|
||||
|
||||
@@ -152,3 +152,23 @@ fn a_diagram_is_saved_as_a_group_of_its_drawing() {
|
||||
assert!(kinds.contains(&ShapeKind::RoundedRectangle) && kinds.contains(&ShapeKind::Arrow), "{kinds:?}");
|
||||
assert!(members.iter().any(|m| m.4 == "Plan"), "its text: {members:?}");
|
||||
}
|
||||
|
||||
/// A group anchored to a page stays on that page, and a number on another anchor says nothing.
|
||||
#[test]
|
||||
fn a_group_anchored_to_a_page_stays_on_that_page() {
|
||||
let saved = |attrs: &str| {
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T} {NS_D}><office:body><office:text><draw:g {attrs}><draw:rect svg:x="3cm" svg:y="3cm" svg:width="2cm" svg:height="2cm"/><draw:rect svg:x="6cm" svg:y="3cm" svg:width="2cm" svg:height="2cm"/></draw:g><text:p>One</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let d = import(&package(&content, None)).unwrap();
|
||||
crate::odt::tests::export_model(&d).unwrap()
|
||||
};
|
||||
let on_two = saved(r#"text:anchor-type="page" text:anchor-page-number="2""#);
|
||||
let c = content_of(&on_two);
|
||||
assert!(c.contains(r#"text:anchor-type="page" text:anchor-page-number="2""#), "{c}");
|
||||
let back = import(&on_two).unwrap();
|
||||
let again = content_of(&crate::odt::tests::export_model(&back).unwrap());
|
||||
assert!(again.contains(r#"text:anchor-page-number="2""#), "again: {again}");
|
||||
let c = content_of(&saved(r#"text:anchor-type="paragraph" text:anchor-page-number="2""#));
|
||||
assert!(!c.contains(r#"text:anchor-type="page""#) && !c.contains("anchor-page-number"), "{c}");
|
||||
}
|
||||
@@ -168,6 +168,38 @@ fn an_object_in_a_header_is_kept_too() {
|
||||
assert!(entry(&saved, "styles.xml").contains("<draw:object xlink:href=\"./Object M"));
|
||||
}
|
||||
|
||||
/// A frame's stated size goes before fitting a column: on either side alone the other follows the
|
||||
/// picture's proportions, and only past what a document holds does it shrink, in proportion.
|
||||
#[test]
|
||||
fn a_picture_keeps_the_size_its_frame_states_on_either_side() {
|
||||
let png = |w: u32, h: u32| {
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
image::RgbImage::new(w, h).write_to(&mut out, image::ImageFormat::Png).unwrap();
|
||||
out.into_inner()
|
||||
};
|
||||
let size = |w: Option<f32>, h: Option<f32>| {
|
||||
let img = crate::odt::body::drawing::picture(png(200, 100), w, h, "").unwrap();
|
||||
(img.w, img.h)
|
||||
};
|
||||
let near = |got: (f32, f32), want: (f32, f32)| assert!((got.0 - want.0).abs() < 0.01 && (got.1 - want.1).abs() < 0.01, "{got:?}, not {want:?}");
|
||||
// Both, one or the other, past a column's 468 pt.
|
||||
near(size(Some(600.0), Some(100.0)), (600.0, 100.0));
|
||||
near(size(Some(600.0), None), (600.0, 300.0));
|
||||
near(size(None, Some(400.0)), (800.0, 400.0));
|
||||
// Past what a document holds: in proportion, whichever side is stated.
|
||||
near(size(Some(2000.0), Some(1000.0)), (1584.0, 792.0));
|
||||
near(size(Some(2000.0), None), (1584.0, 792.0));
|
||||
near(size(None, Some(1000.0)), (1584.0, 792.0));
|
||||
near(size(Some(f32::MAX), None), (1584.0, 792.0));
|
||||
// None stated, or nothing a size could be: its own, fitted to a column.
|
||||
near(size(None, None), (150.0, 75.0));
|
||||
for junk in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 0.0, -5.0] {
|
||||
near(size(Some(junk), Some(junk)), (150.0, 75.0));
|
||||
}
|
||||
let wide = crate::odt::body::drawing::picture(png(1000, 500), None, None, "").unwrap();
|
||||
near((wide.w, wide.h), (468.0, 234.0));
|
||||
}
|
||||
|
||||
/// A replacement picture may come before its object. It is kept whatever it is, though WordCraft
|
||||
/// can't show it, and a save from the model writes it back.
|
||||
#[test]
|
||||
|
||||
@@ -112,3 +112,327 @@ fn a_heading_looks_as_its_style_and_has_the_level_its_element_gives() {
|
||||
let back = import(&saved).unwrap();
|
||||
assert_eq!((props(&back, 0).style.as_deref(), props(&back, 0).outline_level), (Some("Heading2"), Some(0)));
|
||||
}
|
||||
|
||||
/// A document with these common `styles` and this `body`.
|
||||
fn in_custom_style(styles: &str, body: &str) -> Document {
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:style style:name="Standard" style:family="paragraph"/>{styles}</office:styles></office:document-styles>"#
|
||||
);
|
||||
let content =
|
||||
format!(r#"<office:document-content {NS_T}><office:body><office:text>{body}</office:text></office:body></office:document-content>"#);
|
||||
import(&package(&content, Some(&styles))).unwrap()
|
||||
}
|
||||
|
||||
const HEAD: &str = r#"<style:style style:name="Head" style:family="paragraph" style:parent-style-name="Standard" style:default-outline-level="1" style:class="text"><style:text-properties fo:font-size="26pt" fo:font-weight="bold"/></style:style>"#;
|
||||
|
||||
fn saved_after_an_edit(d: &mut Document) -> Vec<u8> {
|
||||
d.insert_text(&wordcraft_doc::Pos::body(0, 0), "Yearly ", &Default::default()).unwrap();
|
||||
export(d).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_custom_style_with_an_outline_level_stays_the_paragraphs_style() {
|
||||
// The outline level is the style's; the look stays the file's own, not Heading 1's.
|
||||
let mut d = in_custom_style(HEAD, r#"<text:p text:style-name="Head">Annual report edition</text:p>"#);
|
||||
let p = d.body[0].as_para().unwrap().props.clone();
|
||||
assert_eq!(p.style.as_deref(), Some("Head"));
|
||||
assert_eq!(d.styles.resolve_para(&p).outline_level, Some(0), "still a heading in WordCraft");
|
||||
let look = d.styles.resolve_char(p.style.as_deref(), &Default::default());
|
||||
assert_eq!((look.size, look.bold), (26.0, true));
|
||||
let r = d.body[0].as_para().unwrap().runs.first().unwrap().props.clone();
|
||||
assert!(r.size.is_none() && r.bold.is_none(), "the look stays in the style: {r:?}");
|
||||
|
||||
// Edited, it is written from the model, under its own name, at its level.
|
||||
let saved = saved_after_an_edit(&mut d);
|
||||
let c = crate::odt::tests::content_of(&saved);
|
||||
assert!(c.contains(r#"<text:h text:style-name="Head" text:outline-level="1">Yearly Annual"#), "{c}");
|
||||
let s = crate::odt::tests::styles_of(&saved);
|
||||
let head = s.split("<style:style ").find(|x| x.starts_with(r#"style:name="Head""#)).unwrap_or("");
|
||||
assert!(head.contains(r#"style:default-outline-level="1""#) && head.contains(r#"fo:font-size="26pt""#), "{head}");
|
||||
|
||||
// And read back, it is the same heading.
|
||||
let back = import(&saved).unwrap();
|
||||
let p = back.body[0].as_para().unwrap().props.clone();
|
||||
assert_eq!((p.style.as_deref(), back.styles.resolve_para(&p).outline_level), (Some("Head"), Some(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unedited_paragraph_in_such_a_style_is_written_as_read() {
|
||||
let body = r#"<text:p text:style-name="Head">Annual report edition</text:p>"#;
|
||||
let saved = export(&in_custom_style(HEAD, body)).unwrap();
|
||||
assert!(crate::odt::tests::content_of(&saved).contains(body));
|
||||
let s = crate::odt::tests::styles_of(&saved);
|
||||
assert_eq!(s.matches(r#"style:name="Head""#).count(), 1, "{s}");
|
||||
assert!(s.contains(r#"style:default-outline-level="1""#), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_level_of_such_a_style_is_written_as_it_is() {
|
||||
// The paragraph kinds stop at Heading 6; the outline levels go on, and a style based on another
|
||||
// heading has its own.
|
||||
let styles = r#"<style:style style:name="Heading_20_2" style:display-name="Heading 2" style:family="paragraph"/><style:style style:name="Deep" style:family="paragraph" style:default-outline-level="8"/><style:style style:name="Under" style:family="paragraph" style:parent-style-name="Heading_20_2" style:default-outline-level="3"/><style:style style:name="Child" style:family="paragraph" style:parent-style-name="Deep"/>"#;
|
||||
let body =
|
||||
r#"<text:p text:style-name="Deep">deep</text:p><text:p text:style-name="Under">under</text:p><text:p text:style-name="Child">child</text:p>"#;
|
||||
let d = in_custom_style(styles, body);
|
||||
let level = |d: &Document, i: usize| d.styles.resolve_para(&d.body[i].as_para().unwrap().props).outline_level;
|
||||
assert_eq!([level(&d, 0), level(&d, 1), level(&d, 2)], [Some(7), Some(2), Some(7)]);
|
||||
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||
let c = crate::odt::tests::content_of(&saved);
|
||||
assert!(c.contains(r#"<text:h text:style-name="Deep" text:outline-level="8">deep"#), "{c}");
|
||||
assert!(c.contains(r#"<text:h text:style-name="Under" text:outline-level="3">under"#), "{c}");
|
||||
assert!(c.contains(r#"<text:h text:style-name="Child" text:outline-level="8">child"#), "{c}");
|
||||
let back = import(&saved).unwrap();
|
||||
assert_eq!([level(&back, 0), level(&back, 1), level(&back, 2)], [Some(7), Some(2), Some(7)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paragraphs_own_level_and_formatting_stay_over_such_a_style() {
|
||||
let styles = format!(r#"<office:document-styles {NS_T}><office:styles>{HEAD}</office:styles></office:document-styles>"#);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="P1" style:family="paragraph" style:parent-style-name="Head"><style:paragraph-properties fo:text-align="center"/></style:style></office:automatic-styles><office:body><office:text><text:h text:style-name="P1" text:outline-level="2">Moved</text:h></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let d = import(&package(&content, Some(&styles))).unwrap();
|
||||
let p = d.body[0].as_para().unwrap().props.clone();
|
||||
assert_eq!((p.style.as_deref(), p.outline_level, p.align), (Some("Head"), Some(1), Some(wordcraft_doc::Align::Center)));
|
||||
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
|
||||
assert!(c.contains(r#"text:outline-level="2">Moved"#), "{c}");
|
||||
assert!(c.contains(r#"style:parent-style-name="Head""#) && c.contains(r#"fo:text-align="center""#), "{c}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hostile_outline_level_on_a_custom_style_is_no_heading() {
|
||||
for bad in ["0", "11", "255", "999", "-1", "x", ""] {
|
||||
let styles = format!(r#"<style:style style:name="Head" style:family="paragraph" style:default-outline-level="{bad}"/>"#);
|
||||
let d = in_custom_style(&styles, r#"<text:p text:style-name="Head">Plain</text:p>"#);
|
||||
let p = d.body[0].as_para().unwrap().props.clone();
|
||||
assert_eq!((p.style.as_deref(), d.styles.resolve_para(&p).outline_level), (Some("Head"), None), "{bad}");
|
||||
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
|
||||
assert!(c.contains(r#"<text:p text:style-name="Head">Plain"#), "{bad}: {c}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn such_a_style_is_numbered_with_its_level() {
|
||||
// LibreOffice numbers it through the outline style, one list with the level's headings.
|
||||
let d = in_custom_style(
|
||||
&format!(
|
||||
r#"{HEAD}<style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph" style:default-outline-level="1"/>{NUMBERED}"#
|
||||
),
|
||||
r#"<text:p text:style-name="Head">First</text:p><text:h text:style-name="Heading_20_1" text:outline-level="1">Second</text:h>"#,
|
||||
);
|
||||
let num = |d: &Document, i: usize| d.styles.resolve_para(&d.body[i].as_para().unwrap().props).numbering;
|
||||
assert!(num(&d, 0).is_some());
|
||||
assert_eq!(num(&d, 0), num(&d, 1));
|
||||
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||
let c = crate::odt::tests::content_of(&saved);
|
||||
assert!(!c.contains("<text:list"), "the outline numbers it: {c}");
|
||||
assert!(c.contains(r#"<text:h text:style-name="Head" text:outline-level="1">First"#), "{c}");
|
||||
// A numbered level stays attached to the outline; an unnumbered one doesn't take its indent.
|
||||
let head = |xml: String| xml.split("<style:style ").find(|x| x.starts_with(r#"style:name="Head""#)).map(String::from).unwrap_or_default();
|
||||
assert!(!head(crate::odt::tests::styles_of(&saved)).contains("style:list-style-name"));
|
||||
let back = import(&saved).unwrap();
|
||||
assert!(num(&back, 0).is_some());
|
||||
assert_eq!(num(&back, 0), num(&back, 1));
|
||||
let plain = in_custom_style(HEAD, r#"<text:p text:style-name="Head">First</text:p>"#);
|
||||
let s = crate::odt::tests::styles_of(&crate::odt::tests::export_model(&plain).unwrap());
|
||||
assert!(head(s).contains(r#"style:list-style-name="""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_styles_based_on_any_kinds_style_keep_their_name_when_edited() {
|
||||
// The kind's style took their place, without their own look.
|
||||
let styles = r##"<style:style style:name="Title" style:family="paragraph"/><style:style style:name="Quotations" style:family="paragraph"/><style:style style:name="Preformatted_20_Text" style:display-name="Preformatted Text" style:family="paragraph"/><style:style style:name="Heading_20_2" style:display-name="Heading 2" style:family="paragraph"/><style:style style:name="Banner" style:family="paragraph" style:parent-style-name="Title"><style:text-properties fo:color="#cc6600"/></style:style><style:style style:name="Aside" style:family="paragraph" style:parent-style-name="Quotations"/><style:style style:name="Listing" style:family="paragraph" style:parent-style-name="Preformatted_20_Text"/><style:style style:name="Plaque" style:family="paragraph" style:parent-style-name="Heading_20_2"/>"##;
|
||||
let body = r#"<text:p text:style-name="Banner">b</text:p><text:p text:style-name="Aside">a</text:p><text:p text:style-name="Listing">l</text:p><text:p text:style-name="Plaque">p</text:p>"#;
|
||||
let saved = crate::odt::tests::export_model(&in_custom_style(styles, body)).unwrap();
|
||||
let c = crate::odt::tests::content_of(&saved);
|
||||
for want in [
|
||||
r#"<text:p text:style-name="Banner">b"#,
|
||||
r#"<text:p text:style-name="Aside">a"#,
|
||||
r#"<text:p text:style-name="Listing">l"#,
|
||||
r#"<text:h text:style-name="Plaque" text:outline-level="2">p"#,
|
||||
] {
|
||||
assert!(c.contains(want), "{want} in {c}");
|
||||
}
|
||||
let s = crate::odt::tests::styles_of(&saved);
|
||||
assert!(s.contains(r#"style:name="Banner" style:display-name="Banner" style:family="paragraph" style:parent-style-name="Title""#), "{s}");
|
||||
}
|
||||
|
||||
/// A heading's number is its own outline level's, as in LibreOffice, not its style's: `(level)` of
|
||||
/// each paragraph's number, as layout takes it.
|
||||
fn number_levels(d: &Document) -> Vec<Option<u8>> {
|
||||
d.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.map(|p| p.props.numbering.or(d.styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0).map(|n| n.level))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_headings_own_outline_level_gives_its_number() {
|
||||
let outline = r#"<text:outline-style style:name="Outline"><text:outline-level-style text:level="1" style:num-format="1" style:num-suffix="."/><text:outline-level-style text:level="2" style:num-format="1" text:display-levels="2" style:num-suffix="."/><text:outline-level-style text:level="3" style:num-format=""/></text:outline-style>"#;
|
||||
let styles = format!(
|
||||
r#"{HEAD}<style:style style:name="Low" style:family="paragraph" style:parent-style-name="Standard" style:default-outline-level="3"/><style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph" style:default-outline-level="1"/>{outline}"#
|
||||
);
|
||||
let h = |style: &str, level: u8| format!(r#"<text:h text:style-name="{style}" text:outline-level="{level}">{style} {level}</text:h>"#);
|
||||
let body = [h("Head", 1), h("Head", 2), h("Head", 3), h("Low", 3), h("Low", 1), h("Low", 2), h("Heading_20_1", 2), h("Heading_20_1", 3)].concat();
|
||||
let d = in_custom_style(&styles, &body);
|
||||
let want = vec![Some(0), Some(1), None, None, Some(0), Some(1), Some(1), None];
|
||||
assert_eq!(number_levels(&d), want);
|
||||
let nums: std::collections::BTreeSet<u32> = d
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.filter_map(|p| p.props.numbering.or(d.styles.resolve_para(&p.props).numbering))
|
||||
.map(|n| n.num)
|
||||
.filter(|n| *n != 0)
|
||||
.collect();
|
||||
assert_eq!(nums.len(), 1, "one list with the headings': {nums:?}");
|
||||
// Written from the model the outline still numbers them, by the levels stated, and read back
|
||||
// they are as they were.
|
||||
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||
let c = crate::odt::tests::content_of(&saved);
|
||||
assert!(!c.contains("<text:list"), "{c}");
|
||||
for (style, level) in [("Head", 2), ("Low", 1), ("Heading_20_1", 3)] {
|
||||
assert!(c.contains(&format!(r#"<text:h text:style-name="{style}" text:outline-level="{level}">{style} {level}"#)), "{c}");
|
||||
}
|
||||
assert_eq!(number_levels(&import(&saved).unwrap()), want);
|
||||
}
|
||||
|
||||
/// A heading in a list is that list's, whatever its outline level would number.
|
||||
#[test]
|
||||
fn a_heading_in_a_list_keeps_the_lists_number() {
|
||||
let styles = format!(
|
||||
r#"{HEAD}{NUMBERED}<text:list-style style:name="L"><text:list-level-style-number text:level="1" style:num-format="a"/></text:list-style>"#
|
||||
);
|
||||
let d = in_custom_style(
|
||||
&styles,
|
||||
r#"<text:list text:style-name="L"><text:list-item><text:h text:style-name="Head" text:outline-level="2">x</text:h></text:list-item></text:list>"#,
|
||||
);
|
||||
let p = d.body[0].as_para().unwrap();
|
||||
let n = p.props.numbering.expect("its list");
|
||||
assert_eq!(d.numbering.level(n.num, n.level).map(|l| l.format), Some(NumFormat::LowerLetter));
|
||||
}
|
||||
|
||||
/// A style based on Title with an outline level is a heading at that level, in its own name.
|
||||
#[test]
|
||||
fn a_title_based_style_with_an_outline_level_stays_a_heading() {
|
||||
let styles = r#"<style:style style:name="Title" style:family="paragraph"/><style:style style:name="Banner" style:family="paragraph" style:parent-style-name="Title" style:default-outline-level="1"/>"#;
|
||||
let mut d = in_custom_style(styles, r#"<text:p text:style-name="Banner">report</text:p>"#);
|
||||
let p = d.body[0].as_para().unwrap().props.clone();
|
||||
assert_eq!((p.style.as_deref(), d.styles.resolve_para(&p).outline_level), (Some("Banner"), Some(0)));
|
||||
let saved = saved_after_an_edit(&mut d);
|
||||
let c = crate::odt::tests::content_of(&saved);
|
||||
assert!(c.contains(r#"<text:h text:style-name="Banner" text:outline-level="1">Yearly report"#), "{c}");
|
||||
}
|
||||
|
||||
/// A style based on a numbered heading, at a level the outline doesn't number, has no number: as
|
||||
/// in LibreOffice, not its parent's.
|
||||
#[test]
|
||||
fn a_style_at_an_unnumbered_level_has_no_number_from_its_parent() {
|
||||
let outline = r#"<text:outline-style style:name="Outline"><text:outline-level-style text:level="1" style:num-format="1" style:num-suffix="."/><text:outline-level-style text:level="2" style:num-format=""/><text:outline-level-style text:level="3" style:num-format="1" style:num-suffix="."/></text:outline-style>"#;
|
||||
let styles = format!(
|
||||
r#"<style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph" style:parent-style-name="Standard" style:default-outline-level="1"/><style:style style:name="Child" style:family="paragraph" style:parent-style-name="Heading_20_1" style:default-outline-level="2"/><style:style style:name="Grand" style:family="paragraph" style:parent-style-name="Heading_20_1" style:default-outline-level="3"/>{outline}"#
|
||||
);
|
||||
let body = r#"<text:h text:style-name="Heading_20_1" text:outline-level="1">One</text:h><text:p text:style-name="Child">As p</text:p><text:h text:style-name="Child" text:outline-level="2">As h</text:h><text:p text:style-name="Grand">Third</text:p><text:h text:style-name="Heading_20_1" text:outline-level="1">Two</text:h>"#;
|
||||
let d = in_custom_style(&styles, body);
|
||||
let want = vec![Some(0), None, None, Some(2), Some(0)];
|
||||
assert_eq!(number_levels(&d), want);
|
||||
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||
let written = crate::odt::tests::styles_of(&saved);
|
||||
let child = written.split("<style:style ").find(|x| x.starts_with(r#"style:name="Child""#)).unwrap_or_default();
|
||||
assert!(child.contains(r#"style:list-style-name="""#), "LibreOffice gives it no number: {child}");
|
||||
assert_eq!(number_levels(&import(&saved).unwrap()), want);
|
||||
}
|
||||
|
||||
/// A heading in a list of its own stays in it, whatever the list's levels are linked to: only the
|
||||
/// number the outline itself gives a heading goes with the outline.
|
||||
#[test]
|
||||
fn a_heading_in_a_list_linked_to_a_style_stays_in_it() {
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::props::NumRef;
|
||||
let mut d = Document::new();
|
||||
let numbering = Arc::make_mut(&mut d.numbering);
|
||||
let num = numbering.add_list(wordcraft_doc::numbering::ListKind::Numbered);
|
||||
let first = numbering.abstracts.last_mut().and_then(|a| a.levels.first_mut()).unwrap();
|
||||
first.style = Some("Heading1".into());
|
||||
let mut p = wordcraft_doc::Paragraph::with_text("x", Default::default());
|
||||
p.props.style = Some("Heading2".into());
|
||||
p.props.numbering = Some(NumRef { num, level: 1 });
|
||||
let has_list = |d: &Document| crate::odt::tests::content_of(&crate::odt::tests::export_model(d).unwrap()).contains("<text:list");
|
||||
// At a deeper level than the link, no level of its own stated.
|
||||
d.body = vec![wordcraft_doc::para_block(p.clone())];
|
||||
assert!(has_list(&d), "a list of its own");
|
||||
// At its own level too, but its heading style isn't numbered: the outline wouldn't number it.
|
||||
p.props.outline_level = Some(1);
|
||||
d.body = vec![wordcraft_doc::para_block(p)];
|
||||
assert!(has_list(&d), "the outline has no number for it");
|
||||
// Its heading style numbered there by this list: the outline's number.
|
||||
Arc::make_mut(&mut d.styles).get_mut("Heading2").unwrap().para.numbering = Some(NumRef { num, level: 1 });
|
||||
assert!(!has_list(&d), "the outline's");
|
||||
}
|
||||
|
||||
/// Past Heading 6 the outline written has no number to give, so a heading there keeps its list.
|
||||
#[test]
|
||||
fn a_heading_past_level_six_keeps_its_list() {
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::props::NumRef;
|
||||
let mut d = Document::new();
|
||||
let num = Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::numbering::ListKind::Numbered);
|
||||
Arc::make_mut(&mut d.styles).get_mut("Heading1").unwrap().para.numbering = Some(NumRef { num, level: 0 });
|
||||
for level in 6..=8 {
|
||||
let mut p = wordcraft_doc::Paragraph::with_text("x", Default::default());
|
||||
p.props.style = Some("Heading6".into());
|
||||
p.props.outline_level = Some(level);
|
||||
p.props.numbering = Some(NumRef { num, level });
|
||||
d.body = vec![wordcraft_doc::para_block(p)];
|
||||
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
|
||||
assert!(c.contains("<text:list"), "level {level}: {c}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A heading past level six numbered through its style keeps its list too, as the outline written
|
||||
/// numbers only six: read, written from the model and with its style edited.
|
||||
#[test]
|
||||
fn a_heading_past_level_six_numbered_by_its_style_keeps_its_list() {
|
||||
use std::sync::Arc;
|
||||
for level in 7..=9u8 {
|
||||
let styles = format!(
|
||||
r#"<style:style style:name="Deep" style:family="paragraph" style:parent-style-name="Standard" style:default-outline-level="{level}"/><text:outline-style style:name="Outline"><text:outline-level-style text:level="{level}" style:num-format="1" style:num-suffix="."/></text:outline-style>"#
|
||||
);
|
||||
let body = format!(r#"<text:h text:style-name="Deep" text:outline-level="{level}">x</text:h>"#);
|
||||
let mut d = in_custom_style(&styles, &body);
|
||||
let check = |saved: &[u8], what: &str| {
|
||||
let c = crate::odt::tests::content_of(saved);
|
||||
assert!(c.contains("<text:list"), "level {level}, {what}: {c}");
|
||||
};
|
||||
check(&crate::odt::tests::export_model(&d).unwrap(), "from the model");
|
||||
let id = d.body[0].as_para().unwrap().props.style.clone().unwrap();
|
||||
Arc::make_mut(&mut d.styles).get_mut(&id).unwrap().chr.italic = Some(true);
|
||||
let saved = export(&d).unwrap();
|
||||
check(&saved, "style edited");
|
||||
let back = import(&saved).unwrap();
|
||||
let p = back.body[0].as_para().unwrap();
|
||||
let n = p.props.numbering.or_else(|| back.styles.para_style_props(p.props.style.as_deref().unwrap_or("Normal")).0.numbering);
|
||||
assert_eq!(n.map(|n| n.level), Some(level - 1), "level {level}, reopened");
|
||||
}
|
||||
}
|
||||
|
||||
/// A style at an unnumbered level with a heading numbered by its own level stays on the outline,
|
||||
/// or LibreOffice shows that heading no number; with none, it comes off, for the level's indent.
|
||||
#[test]
|
||||
fn a_style_with_a_heading_numbered_by_its_own_level_stays_on_the_outline() {
|
||||
let styles = format!(
|
||||
r#"<style:style style:name="Low" style:family="paragraph" style:parent-style-name="Standard" style:default-outline-level="3"/><style:style style:name="Heading_20_3" style:display-name="Heading 3" style:family="paragraph" style:default-outline-level="3"/>{NUMBERED}"#
|
||||
);
|
||||
let saved = |body: &str| crate::odt::tests::styles_of(&crate::odt::tests::export_model(&in_custom_style(&styles, body)).unwrap());
|
||||
let style_of = |xml: &str, name: &str| {
|
||||
xml.split("<style:style ").find(|x| x.starts_with(&format!(r#"style:name="{name}""#))).map(String::from).unwrap_or_default()
|
||||
};
|
||||
let xml = saved(
|
||||
r#"<text:h text:style-name="Low" text:outline-level="1">a</text:h><text:h text:style-name="Heading_20_3" text:outline-level="2">b</text:h>"#,
|
||||
);
|
||||
assert!(!style_of(&xml, "Low").contains("style:list-style-name"), "{xml}");
|
||||
assert!(!style_of(&xml, "Heading_20_3").contains("style:list-style-name"), "{xml}");
|
||||
let xml = saved(r#"<text:h text:style-name="Low" text:outline-level="3">a</text:h>"#);
|
||||
assert!(style_of(&xml, "Low").contains(r#"style:list-style-name="""#), "{xml}");
|
||||
}
|
||||
@@ -16,7 +16,8 @@ use indexes::index_at;
|
||||
pub(super) use indexes::{IndexKind, index_instr};
|
||||
use notes::annotation_name;
|
||||
pub(super) use props::{
|
||||
align_attr, border_attrs, generated_style, generating_style, odf_border, odf_name, props_text, script_attrs, spacing_props, tab_stops,
|
||||
align_attr, border_attrs, generated_style, generating_style, is_script, lang_attrs, odf_border, odf_name, props_text, script_attrs,
|
||||
spacing_props, tab_stops,
|
||||
};
|
||||
use props::{parent_style, text_props};
|
||||
use tracked::{Item, join_deleted_marks};
|
||||
@@ -321,7 +322,7 @@ impl Writer {
|
||||
/// The paragraph style of `p`; `start` makes it begin a page of that master page (ODF 1.3
|
||||
/// §19.471 `style:master-page-name`), restarting page numbering where asked (§20.322).
|
||||
fn para_style_at(&mut self, p: &Para, start: Option<&SecStart>) -> String {
|
||||
let named = p.extra.as_ref().and_then(|x| x.style.as_deref()).filter(|_| matches!(p.kind, Kind::Normal | Kind::Rule));
|
||||
let named = p.extra.as_ref().and_then(|x| x.style.as_deref());
|
||||
let parent = match (p.kind, self.note_parent, named) {
|
||||
// A custom style: written as a common style of the same name (see `custom_styles`).
|
||||
(_, _, Some(id)) => {
|
||||
|
||||
@@ -114,7 +114,8 @@ pub(in crate::odt) fn placement(place: Place<'_>, w: f32, h: f32) -> (String, St
|
||||
match place {
|
||||
Place::Text(Some(f)) if f.wrap != wordcraft_doc::para::Wrap::Inline => {
|
||||
let (a, props, xy) = crate::odt::frames::frame_of(f, w, h);
|
||||
(anchor(a), props, xy)
|
||||
let page = if a == "page" && f.page > 0 { format!(" text:anchor-page-number=\"{}\"", f.page) } else { String::new() };
|
||||
(format!("{}{page}", anchor(a)), props, xy)
|
||||
}
|
||||
Place::Text(f) => {
|
||||
let turn = f.filter(|f| crate::odt::frames::turned(f)).map(|f| crate::odt::frames::transform_attr(0.0, 0.0, w, h, f.rot));
|
||||
|
||||
@@ -10,9 +10,13 @@ pub(in crate::odt) fn extra_props(x: &wordcraft_doc::props::CharProps, size: Opt
|
||||
let on = |v: Option<bool>| v == Some(true);
|
||||
if on(x.caps) {
|
||||
p.push_str(" fo:text-transform=\"uppercase\"");
|
||||
} else if x.caps == Some(false) {
|
||||
p.push_str(" fo:text-transform=\"none\"");
|
||||
}
|
||||
if on(x.small_caps) {
|
||||
p.push_str(" fo:font-variant=\"small-caps\"");
|
||||
} else if x.small_caps == Some(false) {
|
||||
p.push_str(" fo:font-variant=\"normal\"");
|
||||
}
|
||||
if on(x.hidden) {
|
||||
p.push_str(" text:display=\"none\"");
|
||||
@@ -54,14 +58,7 @@ pub(in crate::odt) fn extra_props(x: &wordcraft_doc::props::CharProps, size: Opt
|
||||
}
|
||||
p.push_str(&script_attrs(x));
|
||||
if let Some(l) = x.lang.as_deref() {
|
||||
let mut parts = l.split(['-', '_']);
|
||||
let ok = |s: &&str| (1..=8).contains(&s.len()) && s.chars().all(|c| c.is_ascii_alphanumeric());
|
||||
if let Some(lang) = parts.next().filter(ok) {
|
||||
p.push_str(&format!(" fo:language=\"{lang}\""));
|
||||
if let Some(country) = parts.next().filter(ok) {
|
||||
p.push_str(&format!(" fo:country=\"{country}\""));
|
||||
}
|
||||
}
|
||||
p.push_str(&lang_attrs(l, ""));
|
||||
}
|
||||
p
|
||||
}
|
||||
@@ -85,28 +82,7 @@ pub(in crate::odt) fn props_text(cp: &wordcraft_doc::props::CharProps) -> String
|
||||
extra: Some(Box::new(cp.clone())),
|
||||
..Default::default()
|
||||
};
|
||||
let mut p = text_props(&f);
|
||||
// What a style turns off that the style it is based on may turn on: said so, or the parent's
|
||||
// bold, italic, underline or strike-through would show through.
|
||||
if cp.bold == Some(false) {
|
||||
p.push_str(" fo:font-weight=\"normal\" style:font-weight-asian=\"normal\"");
|
||||
if cp.bold_cs.is_none() {
|
||||
p.push_str(" style:font-weight-complex=\"normal\"");
|
||||
}
|
||||
}
|
||||
if cp.italic == Some(false) {
|
||||
p.push_str(" fo:font-style=\"normal\" style:font-style-asian=\"normal\"");
|
||||
if cp.italic_cs.is_none() {
|
||||
p.push_str(" style:font-style-complex=\"normal\"");
|
||||
}
|
||||
}
|
||||
if cp.underline == Some(Underline::None) {
|
||||
p.push_str(" style:text-underline-style=\"none\"");
|
||||
}
|
||||
if cp.strike == Some(false) && cp.double_strike != Some(true) {
|
||||
p.push_str(" style:text-line-through-style=\"none\"");
|
||||
}
|
||||
p
|
||||
text_props(&f)
|
||||
}
|
||||
|
||||
/// `fo:text-align` for `a` (ODF 1.3 §20.223).
|
||||
@@ -120,8 +96,8 @@ pub(in crate::odt) fn align_attr(a: Option<Align>) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// `fo:language`/`fo:country` (with `suffix`: `-asian`, `-complex` on `style:`) for BCP 47 tag
|
||||
/// `tag`.
|
||||
/// `fo:language`, `fo:script` and `fo:country` (with `suffix`: `-asian`, `-complex` on `style:`)
|
||||
/// for BCP 47 tag `tag`. A script is the four-letter subtag after the language.
|
||||
pub(in crate::odt) fn lang_attrs(tag: &str, suffix: &str) -> String {
|
||||
let mut parts = tag.split(['-', '_']);
|
||||
let ok = |s: &&str| (1..=8).contains(&s.len()) && s.chars().all(|c| c.is_ascii_alphanumeric());
|
||||
@@ -129,13 +105,23 @@ pub(in crate::odt) fn lang_attrs(tag: &str, suffix: &str) -> String {
|
||||
let mut p = String::new();
|
||||
if let Some(lang) = parts.next().filter(ok) {
|
||||
p.push_str(&format!(" {ns}:language{suffix}=\"{lang}\""));
|
||||
if let Some(country) = parts.next().filter(ok) {
|
||||
let mut next = parts.next().filter(ok);
|
||||
if let Some(script) = next.filter(|s| is_script(s)) {
|
||||
p.push_str(&format!(" {ns}:script{suffix}=\"{script}\""));
|
||||
next = parts.next().filter(ok);
|
||||
}
|
||||
if let Some(country) = next {
|
||||
p.push_str(&format!(" {ns}:country{suffix}=\"{country}\""));
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Whether a language tag's subtag is a script (BCP 47: four letters).
|
||||
pub(in crate::odt) fn is_script(subtag: &str) -> bool {
|
||||
subtag.len() == 4 && subtag.chars().all(|c| c.is_ascii_alphabetic())
|
||||
}
|
||||
|
||||
/// The Asian and complex-script attributes of `x`: fonts, complex size, weight, posture and the
|
||||
/// two languages.
|
||||
pub(in crate::odt) fn script_attrs(x: &wordcraft_doc::props::CharProps) -> String {
|
||||
@@ -168,19 +154,32 @@ pub(in crate::odt) fn text_props(f: &Fmt) -> String {
|
||||
let mut p = String::new();
|
||||
// Complex-script weight and posture of their own are written with the other script extras.
|
||||
let own_cs = |pick: fn(&wordcraft_doc::props::CharProps) -> Option<bool>| f.extra.as_deref().and_then(pick).is_some();
|
||||
let extra = f.extra.as_deref();
|
||||
// What it turns off that its style, or the style that is based on, may turn on: said so, or
|
||||
// that bold, italic, underline or strike-through would show through.
|
||||
let off = |pick: fn(&wordcraft_doc::props::CharProps) -> Option<bool>| extra.and_then(pick) == Some(false);
|
||||
if f.bold {
|
||||
p.push_str(" fo:font-weight=\"bold\" style:font-weight-asian=\"bold\"");
|
||||
if !own_cs(|x| x.bold_cs) {
|
||||
p.push_str(" style:font-weight-complex=\"bold\"");
|
||||
}
|
||||
} else if off(|x| x.bold) {
|
||||
p.push_str(" fo:font-weight=\"normal\" style:font-weight-asian=\"normal\"");
|
||||
if !own_cs(|x| x.bold_cs) {
|
||||
p.push_str(" style:font-weight-complex=\"normal\"");
|
||||
}
|
||||
}
|
||||
if f.italic {
|
||||
p.push_str(" fo:font-style=\"italic\" style:font-style-asian=\"italic\"");
|
||||
if !own_cs(|x| x.italic_cs) {
|
||||
p.push_str(" style:font-style-complex=\"italic\"");
|
||||
}
|
||||
} else if off(|x| x.italic) {
|
||||
p.push_str(" fo:font-style=\"normal\" style:font-style-asian=\"normal\"");
|
||||
if !own_cs(|x| x.italic_cs) {
|
||||
p.push_str(" style:font-style-complex=\"normal\"");
|
||||
}
|
||||
}
|
||||
let extra = f.extra.as_deref();
|
||||
if f.underline {
|
||||
use wordcraft_doc::props::Underline as U;
|
||||
let (style, kind, width, mode) = match extra.and_then(|e| e.underline) {
|
||||
@@ -201,11 +200,16 @@ pub(in crate::odt) fn text_props(f: &Fmt) -> String {
|
||||
" style:text-underline-style=\"{style}\" style:text-underline-type=\"{kind}\" style:text-underline-width=\"{width}\" style:text-underline-color=\"{color}\"{mode}"
|
||||
));
|
||||
}
|
||||
if !f.underline && extra.is_some_and(|e| e.underline == Some(wordcraft_doc::props::Underline::None)) {
|
||||
p.push_str(" style:text-underline-style=\"none\"");
|
||||
}
|
||||
if f.strike || extra.is_some_and(|e| e.double_strike == Some(true)) {
|
||||
p.push_str(" style:text-line-through-style=\"solid\"");
|
||||
if extra.is_some_and(|e| e.double_strike == Some(true)) {
|
||||
p.push_str(" style:text-line-through-type=\"double\"");
|
||||
}
|
||||
} else if off(|x| x.strike) {
|
||||
p.push_str(" style:text-line-through-style=\"none\"");
|
||||
}
|
||||
if let Some(e) = extra {
|
||||
if (f.sup || f.sub) && e.position.is_some() {
|
||||
@@ -233,8 +237,13 @@ pub(in crate::odt) fn text_props(f: &Fmt) -> String {
|
||||
if let Some(c) = f.background {
|
||||
p.push_str(&format!(" fo:background-color=\"#{}\"", c.hex().to_ascii_lowercase()));
|
||||
}
|
||||
// One size in WordCraft's model is every script's in ODF's, as bold is: without these,
|
||||
// LibreOffice shows Asian and complex-script text at the default size whatever was chosen.
|
||||
if let Some(s) = f.size {
|
||||
p.push_str(&format!(" fo:font-size=\"{}\"", pt(s)));
|
||||
p.push_str(&format!(" fo:font-size=\"{0}\" style:font-size-asian=\"{0}\"", pt(s)));
|
||||
if f.extra.as_deref().and_then(|x| x.size_cs).is_none() {
|
||||
p.push_str(&format!(" style:font-size-complex=\"{}\"", pt(s)));
|
||||
}
|
||||
}
|
||||
if f.code {
|
||||
p.push_str(&format!(" fo:font-family=\"'{}'\" style:font-family-generic=\"modern\" style:font-pitch=\"fixed\"", model::MONO_FONT));
|
||||
|
||||
@@ -4,11 +4,15 @@
|
||||
//! Quote, Code), list tables (`\listtable` / `\listoverridetable` with `\listtext` labels for
|
||||
//! older readers), document info, page setup, paragraph formatting (`\pard`, alignment, page
|
||||
//! break before), character formatting (`\b \i \ul \strike \super \sub \f \fs \cf \chcbpat`),
|
||||
//! hyperlinks as `HYPERLINK` fields, bookmarks, pictures (`\pngblip` / `\jpegblip`) and tables
|
||||
//! (`\trowd … \cellx … \cell … \row`, header rows, vertical merges, shading).
|
||||
//! hyperlinks as `HYPERLINK` fields, bookmarks, pictures (`\pngblip` / `\jpegblip`), tables
|
||||
//! (`\trowd … \cellx … \cell … \row`, header rows, vertical merges, shading), footnotes and
|
||||
//! endnotes (`{\super\chftn}{\footnote[\ftnalt] …}`) and comments (`{\*\atrfstart n}` …
|
||||
//! `{\*\atrfend n}` around the commented text, then `{\*\atnid initials}{\*\atnauthor name}`
|
||||
//! `\chatn{\*\annotation{\*\atnref n}{\*\atndate …}` text `}`).
|
||||
//!
|
||||
//! Import: a tolerant reader for groups and destinations (unknown `\*` destinations, headers,
|
||||
//! footers, notes and objects are skipped), `\par \line \tab \page`, `\uN` with `\ucN`
|
||||
//! Import: a tolerant reader for groups and destinations (unknown `\*` destinations and objects
|
||||
//! are skipped; headers, footers, notes and comments are read into stories of their own),
|
||||
//! `\par \line \tab \page`, `\uN` with `\ucN`
|
||||
//! fallback skipping, `\'hh` and 8-bit text in the document's code page (`\ansicpg`, `\mac`, `\pc`, `\pca`) or
|
||||
//! the current font's (`\fcharset`, `\cpg`: Cyrillic, Central European, Greek, Mac OS Roman, the
|
||||
//! East Asian multibyte pages 932/936/949/950…; Windows-1252 when none is named). A multibyte
|
||||
@@ -16,13 +20,16 @@
|
||||
//! together and a run that looks like a UTF-8 BOM is not mistaken for one; the formatting words
|
||||
//! above, stylesheet-based
|
||||
//! headings and `\outlinelevel`, lists through `\ls`/`\ilvl`, fields, bookmarks, pictures and
|
||||
//! tables. Group nesting and picture sizes are capped.
|
||||
//! tables, footnotes, endnotes (`\footnote` with `\ftnalt`) and comments (author, initials, date,
|
||||
//! text, the commented range). Group nesting, picture sizes and the number of notes and comments
|
||||
//! are capped. Not kept: custom note marks, comment replies (`\atnparent`), notes inside
|
||||
//! headers, footers and other notes, tables inside note and comment texts (flattened).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::props::VAlign;
|
||||
use wordcraft_doc::{Align, Document, Rgb};
|
||||
use wordcraft_doc::{Align, Block, Blocks, Document, InlineObject, Paragraph, Rgb};
|
||||
|
||||
use crate::model::{self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, RevMark, make_img};
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/// An ISO 8601 date-time from year, month, day, hour and minute.
|
||||
pub(super) fn iso_date((y, mo, d, h, mi): (u32, u32, u32, u32, u32)) -> String {
|
||||
format!("{y:04}-{:02}-{:02}T{:02}:{:02}:00Z", mo.clamp(1, 12), d.clamp(1, 31), h.min(23), mi.min(59))
|
||||
}
|
||||
|
||||
pub(super) fn rtf_date(s: &str) -> Option<String> {
|
||||
let num = |a: usize, b: usize| s.get(a..b).and_then(|x| x.parse::<u32>().ok());
|
||||
let (y, mo, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use super::date::{iso_to_dttm, rtf_date};
|
||||
use super::state::{MARK_CLOSE, MARK_OPEN, MAX_NOTE_BLOCKS};
|
||||
use super::styles::{style_fmt, styled};
|
||||
use super::{Align, Document, FBlock, FTable, Fmt, Inline, Kind, Meta, Para, RevMark, Rgb, VAlign, model};
|
||||
use super::{
|
||||
Align, Arc, Block, Blocks, Document, FBlock, FTable, Fmt, HashSet, Inline, InlineObject, Kind, Meta, Para, Paragraph, RevMark, Rgb, VAlign, model,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Export
|
||||
@@ -66,7 +69,30 @@ impl Tables {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a place marker in the exported text stands for (see [`MARK_OPEN`]).
|
||||
pub(super) enum Mark {
|
||||
Note {
|
||||
endnote: bool,
|
||||
blocks: Vec<FBlock>,
|
||||
},
|
||||
RangeStart(u32),
|
||||
/// The end of a comment's range; the first end of each comment also carries its annotation.
|
||||
RangeEnd {
|
||||
id: u32,
|
||||
ann: Option<Annotation>,
|
||||
},
|
||||
Done,
|
||||
}
|
||||
|
||||
pub(super) struct Annotation {
|
||||
author: String,
|
||||
initials: String,
|
||||
date: String,
|
||||
blocks: Vec<FBlock>,
|
||||
}
|
||||
|
||||
pub(super) struct Writer {
|
||||
pub(super) marks: Vec<Mark>,
|
||||
pub(super) t: Tables,
|
||||
/// List definitions: per list, the ordered flag per level.
|
||||
pub(super) lists: Vec<[bool; 9]>,
|
||||
@@ -81,6 +107,121 @@ pub(super) struct Writer {
|
||||
pub(super) sect_i: usize,
|
||||
}
|
||||
|
||||
pub(super) fn has_marks(bl: &Blocks, depth: usize) -> bool {
|
||||
bl.iter().any(|b| match &**b {
|
||||
Block::Para(p) => {
|
||||
p.objects.iter().any(|o| matches!(o, InlineObject::NoteRef { .. } | InlineObject::CommentStart { .. } | InlineObject::CommentEnd { .. }))
|
||||
}
|
||||
Block::Table(t) => depth < 8 && t.rows.iter().any(|r| r.cells.iter().any(|c| has_marks(&c.blocks, depth + 1))),
|
||||
})
|
||||
}
|
||||
|
||||
/// A copy of `doc` whose note references and comment range ends are place markers in the text
|
||||
/// (see [`MARK_OPEN`]), with what each stands for; none when the body has neither.
|
||||
fn mark_doc(doc: &Document) -> Option<(Document, Vec<Mark>)> {
|
||||
if !has_marks(&doc.body, 0) {
|
||||
return None;
|
||||
}
|
||||
let mut m = Marker { doc, marks: Vec::new(), annotated: HashSet::new() };
|
||||
let body = m.blocks(&doc.body, None, 0);
|
||||
let mut d = doc.clone();
|
||||
d.body = body;
|
||||
Some((d, m.marks))
|
||||
}
|
||||
|
||||
/// The story id standing for "inside a note or comment": no nested notes, no comment ranges.
|
||||
const IN_STORY: u32 = u32::MAX;
|
||||
|
||||
struct Marker<'a> {
|
||||
doc: &'a Document,
|
||||
marks: Vec<Mark>,
|
||||
annotated: HashSet<u32>,
|
||||
}
|
||||
|
||||
impl Marker<'_> {
|
||||
fn token(&mut self, m: Mark) -> String {
|
||||
self.marks.push(m);
|
||||
self.marks.len().saturating_sub(1).to_string()
|
||||
}
|
||||
|
||||
/// The flow of a story (a note's or comment's text); tables are flattened to their paragraphs.
|
||||
fn story(&mut self, bl: &Blocks, note: u32, depth: usize, out: &mut Vec<FBlock>) {
|
||||
for b in bl.iter().take(MAX_NOTE_BLOCKS) {
|
||||
match &**b {
|
||||
Block::Para(p) => {
|
||||
let q = self.para(p, Some(note));
|
||||
out.extend(model::flow_paras(self.doc, &q).into_iter().map(FBlock::Para));
|
||||
}
|
||||
Block::Table(t) if depth < 4 => {
|
||||
for c in t.rows.iter().flat_map(|r| r.cells.iter()) {
|
||||
self.story(&c.blocks, note, depth + 1, out);
|
||||
}
|
||||
}
|
||||
Block::Table(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn blocks(&mut self, bl: &Blocks, note: Option<u32>, depth: usize) -> Blocks {
|
||||
bl.iter()
|
||||
.map(|b| match &**b {
|
||||
Block::Para(p) if p.objects.is_empty() => b.clone(),
|
||||
Block::Para(p) => Arc::new(Block::Para(self.para(p, note))),
|
||||
Block::Table(t) if depth < 8 => {
|
||||
let mut t = t.clone();
|
||||
for c in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
|
||||
c.blocks = self.blocks(&c.blocks, note, depth + 1);
|
||||
}
|
||||
Arc::new(Block::Table(t))
|
||||
}
|
||||
Block::Table(_) => b.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `p` with its note references and comment range ends replaced by place markers. `note` is
|
||||
/// the note being written, when `p` is part of one.
|
||||
fn para(&mut self, p: &Paragraph, note: Option<u32>) -> Paragraph {
|
||||
let mut q = p.clone();
|
||||
for off in p.object_offsets().into_iter().rev() {
|
||||
let Some(obj) = q.object_at(off).cloned() else { continue };
|
||||
let tok = match obj {
|
||||
InlineObject::NoteRef { id, .. } if note == Some(id) => Some(String::from("S")),
|
||||
InlineObject::NoteRef { .. } if note.is_some() => Some(String::new()),
|
||||
InlineObject::NoteRef { id, kind, .. } => self.doc.parts.get(&id).map(|part| {
|
||||
let mut blocks = Vec::new();
|
||||
self.story(&part.blocks, id, 0, &mut blocks);
|
||||
self.token(Mark::Note { endnote: kind == wordcraft_doc::para::NoteKind::Endnote, blocks })
|
||||
}),
|
||||
InlineObject::CommentStart { id } if note.is_none() => Some(self.token(Mark::RangeStart(id))),
|
||||
InlineObject::CommentEnd { id } if note.is_none() => {
|
||||
let ann = if self.annotated.insert(id) { self.annotation(id) } else { None };
|
||||
Some(self.token(Mark::RangeEnd { id, ann }))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let Some(tok) = tok else { continue };
|
||||
let end = off + wordcraft_doc::para::OBJ.len_utf8();
|
||||
let text = if tok.is_empty() { tok } else { format!("{MARK_OPEN}{tok}{MARK_CLOSE}") };
|
||||
if q.delete(off, end).is_err() {
|
||||
continue;
|
||||
}
|
||||
if !text.is_empty() && q.insert_text(off, &text, &Default::default()).is_err() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
q
|
||||
}
|
||||
|
||||
fn annotation(&mut self, id: u32) -> Option<Annotation> {
|
||||
let c = self.doc.comments.get(&id)?;
|
||||
let part = self.doc.parts.get(&c.part)?;
|
||||
let mut blocks = Vec::new();
|
||||
self.story(&part.blocks, IN_STORY, 0, &mut blocks);
|
||||
Some(Annotation { author: c.author.clone(), initials: c.initials.clone(), date: c.date.clone(), blocks })
|
||||
}
|
||||
}
|
||||
|
||||
/// `\sectd` and a section's formatting, in the words the reader reads back.
|
||||
fn sect_words(s: &wordcraft_doc::SectionProps) -> String {
|
||||
use wordcraft_doc::section::{NumFormat, SectionStart};
|
||||
@@ -232,7 +373,68 @@ impl Writer {
|
||||
s
|
||||
}
|
||||
|
||||
/// A run of text; place markers in it become notes and comment ranges.
|
||||
fn run(&mut self, t: &str, f: &Fmt, out: &mut String) {
|
||||
if !t.contains(MARK_OPEN) {
|
||||
return self.run_plain(t, f, out);
|
||||
}
|
||||
let mut rest = t;
|
||||
while !rest.is_empty() {
|
||||
let Some((pre, after)) = rest.split_once(MARK_OPEN) else {
|
||||
self.run_plain(rest, f, out);
|
||||
break;
|
||||
};
|
||||
if !pre.is_empty() {
|
||||
self.run_plain(pre, f, out);
|
||||
}
|
||||
let Some((tok, tail)) = after.split_once(MARK_CLOSE) else {
|
||||
self.run_plain(after, f, out);
|
||||
break;
|
||||
};
|
||||
self.mark(tok, out);
|
||||
rest = tail;
|
||||
}
|
||||
}
|
||||
|
||||
/// The RTF for one place marker: `S` is a note's own number, a number indexes `marks`.
|
||||
fn mark(&mut self, tok: &str, out: &mut String) {
|
||||
if tok == "S" {
|
||||
out.push_str("{\\super\\chftn}");
|
||||
return;
|
||||
}
|
||||
let Some(m) = tok.parse::<usize>().ok().and_then(|i| self.marks.get_mut(i)) else { return };
|
||||
match std::mem::replace(m, Mark::Done) {
|
||||
Mark::Note { endnote, blocks } => {
|
||||
out.push_str("{\\super\\chftn}{\\footnote");
|
||||
if endnote {
|
||||
out.push_str("\\ftnalt");
|
||||
}
|
||||
out.push(' ');
|
||||
self.blocks(&blocks, false, "\\par", out, 0);
|
||||
out.push('}');
|
||||
}
|
||||
Mark::RangeStart(id) => out.push_str(&format!("{{\\*\\atrfstart {id}}}")),
|
||||
Mark::RangeEnd { id, ann } => {
|
||||
out.push_str(&format!("{{\\*\\atrfend {id}}}"));
|
||||
if let Some(a) = ann {
|
||||
out.push_str(&format!(
|
||||
"{{\\*\\atnid {}}}{{\\*\\atnauthor {}}}\\chatn{{\\*\\annotation{{\\*\\atnref {id}}}",
|
||||
esc(&a.initials),
|
||||
esc(&a.author)
|
||||
));
|
||||
if let Some(d) = rtf_date(&a.date) {
|
||||
out.push_str(&format!("{{\\*\\atndate {d}}}"));
|
||||
}
|
||||
out.push('\n');
|
||||
self.blocks(&a.blocks, false, "\\par", out, 0);
|
||||
out.push('}');
|
||||
}
|
||||
}
|
||||
Mark::Done => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_plain(&mut self, t: &str, f: &Fmt, out: &mut String) {
|
||||
let mut ctl = String::new();
|
||||
if let Some(c) = &f.fmt_change {
|
||||
// RTF 1.9.1, as Word writes it: the properties the change altered, as they were,
|
||||
@@ -685,9 +887,15 @@ fn level_def(ordered: bool, l: usize) -> String {
|
||||
|
||||
/// RTF text of a document.
|
||||
pub fn export(doc: &Document) -> String {
|
||||
let (marked, marks) = match mark_doc(doc) {
|
||||
Some((d, m)) => (Some(d), m),
|
||||
None => (None, Vec::new()),
|
||||
};
|
||||
let doc = marked.as_ref().unwrap_or(doc);
|
||||
let flow = model::from_doc_tracked(doc);
|
||||
let base_font = doc.styles.default_chr.font.clone().unwrap_or_else(|| wordcraft_doc::styles::BODY_FONT.to_string());
|
||||
let mut w = Writer {
|
||||
marks,
|
||||
t: Tables { fonts: vec![base_font], colors: Vec::new() },
|
||||
lists: Vec::new(),
|
||||
authors: None,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use super::reader::Reader;
|
||||
use super::state::{Dest, HeaderFooter, HfPages, MAX_PICT, PageGeom};
|
||||
use super::{Cell, Document, FBlock, Flow, Para, model};
|
||||
use std::sync::Arc;
|
||||
use super::state::{CommentData, Dest, HeaderFooter, HfPages, MARK_CLOSE, MARK_OPEN, MAX_PICT, NoteData, PageGeom};
|
||||
use super::{Arc, Block, Blocks, Cell, Document, FBlock, Flow, HashMap, InlineObject, Para, Paragraph, model};
|
||||
|
||||
/// Parse RTF bytes into the flow model.
|
||||
pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
|
||||
@@ -11,7 +10,7 @@ pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
|
||||
/// The flow, the page, and the default font (`\deff`, font 0 when the file names none), which
|
||||
/// is the font of all text that doesn't select one.
|
||||
fn parse_with_page(bytes: &[u8]) -> Result<(Flow, PageGeom, Option<String>), String> {
|
||||
parse_full(bytes).map(|r| (r.flow, r.page, r.default_font))
|
||||
parse_full(bytes, false).map(|r| (r.flow, r.page, r.default_font))
|
||||
}
|
||||
|
||||
/// Everything an import takes from an RTF file.
|
||||
@@ -23,14 +22,19 @@ struct Parsed {
|
||||
default_font: Option<String>,
|
||||
/// Each section's headers and footers, in order, the last section's last.
|
||||
headers_footers: Vec<Vec<(HeaderFooter, Vec<FBlock>)>>,
|
||||
notes: Vec<NoteData>,
|
||||
comments: Vec<CommentData>,
|
||||
}
|
||||
|
||||
fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
|
||||
/// `marks`: also read footnotes, endnotes and comments (as place markers in the flow's text, for
|
||||
/// [`import`] to turn into objects); without it they are skipped.
|
||||
fn parse_full(bytes: &[u8], marks: bool) -> Result<Parsed, String> {
|
||||
let start = bytes.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(0);
|
||||
if !bytes.get(start..).is_some_and(|b| b.starts_with(b"{\\rtf")) {
|
||||
return Err("not an RTF document (it doesn't start with {\\rtf)".into());
|
||||
}
|
||||
let mut r = Reader::default();
|
||||
r.marks_on = marks;
|
||||
let mut i = start;
|
||||
let mut first_in_group = false;
|
||||
let mut starred = false;
|
||||
@@ -106,7 +110,10 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
|
||||
continue;
|
||||
}
|
||||
if r.skip_chars > 0
|
||||
&& matches!(r.st.dest, Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark | Dest::FontTbl | Dest::RevTbl)
|
||||
&& matches!(
|
||||
r.st.dest,
|
||||
Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark | Dest::FontTbl | Dest::RevTbl | Dest::Atn(_)
|
||||
)
|
||||
{
|
||||
r.skip_chars -= 1;
|
||||
first_in_group = false;
|
||||
@@ -206,8 +213,10 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
|
||||
}
|
||||
|
||||
fn finalize(mut r: Reader) -> Parsed {
|
||||
// A header or footer left open by a truncated file ends here, and so does a multibyte run.
|
||||
// A multibyte run ends here, and so does a note, comment, header or footer left open by a
|
||||
// truncated file.
|
||||
r.finish_multibyte();
|
||||
r.end_sub();
|
||||
r.end_header_footer();
|
||||
if r.para_has || !r.para.inlines.is_empty() {
|
||||
r.end_para(false);
|
||||
@@ -234,7 +243,7 @@ fn finalize(mut r: Reader) -> Parsed {
|
||||
blocks.push(FBlock::Para(Para::default()));
|
||||
}
|
||||
let default_font = r.fonts.get(&r.deff).filter(|n| !model::is_mono(n)).map(|n| n.to_string());
|
||||
Parsed { flow: Flow { blocks, meta: r.meta }, page: r.page, last_section, default_font, headers_footers }
|
||||
Parsed { flow: Flow { blocks, meta: r.meta }, page: r.page, last_section, default_font, headers_footers, notes: r.notes, comments: r.comments }
|
||||
}
|
||||
|
||||
/// The column grid of a table from its rows' cell right edges: every distinct edge starts a grid
|
||||
@@ -277,7 +286,7 @@ pub(super) fn column_grid(rows: &mut [Vec<Cell>], rights: &[Vec<i32>]) -> Vec<f3
|
||||
/// Parse RTF bytes into a document.
|
||||
pub fn import(bytes: &[u8]) -> Result<Document, String> {
|
||||
model::begin_import();
|
||||
let Parsed { flow, last_section, default_font, headers_footers, .. } = parse_full(bytes)?;
|
||||
let Parsed { flow, last_section, default_font, headers_footers, notes, comments, .. } = parse_full(bytes, true)?;
|
||||
let mut doc = model::to_doc_in(&flow, last_section);
|
||||
// Each section's own headers and footers; those it leaves out stay linked to the previous.
|
||||
let mut sets = Vec::with_capacity(headers_footers.len());
|
||||
@@ -309,6 +318,7 @@ pub fn import(bytes: &[u8]) -> Result<Document, String> {
|
||||
if let Some((h, f)) = sets.next() {
|
||||
(doc.last_section.headers, doc.last_section.footers) = (h, f);
|
||||
}
|
||||
place_notes_and_comments(&mut doc, notes, comments);
|
||||
// RTF's paragraph defaults are single spacing and no space around paragraphs; what a file
|
||||
// wants beyond that it states on each paragraph.
|
||||
let dp = &mut Arc::make_mut(&mut doc.styles).default_para;
|
||||
@@ -320,3 +330,127 @@ pub fn import(bytes: &[u8]) -> Result<Document, String> {
|
||||
}
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// Turn the place markers the reader left in the text into note references and comment ranges,
|
||||
/// and add the notes' and comments' stories to `doc`.
|
||||
fn place_notes_and_comments(doc: &mut Document, notes: Vec<NoteData>, comments: Vec<CommentData>) {
|
||||
use wordcraft_doc::para::NoteKind;
|
||||
use wordcraft_doc::{Comment, PartKind};
|
||||
if notes.is_empty() && comments.is_empty() {
|
||||
return;
|
||||
}
|
||||
// (kind, part id) per note, by index.
|
||||
let mut note_parts: Vec<(NoteKind, u32)> = Vec::new();
|
||||
for n in notes {
|
||||
let (pk, nk, style) =
|
||||
if n.endnote { (PartKind::Endnote, NoteKind::Endnote, "EndnoteText") } else { (PartKind::Footnote, NoteKind::Footnote, "FootnoteText") };
|
||||
let mut blocks = model::part_blocks(doc, &n.blocks);
|
||||
for b in &mut blocks {
|
||||
if let Block::Para(p) = Arc::make_mut(b)
|
||||
&& p.props.style.is_none()
|
||||
{
|
||||
p.props.style = Some(style.to_string());
|
||||
}
|
||||
}
|
||||
note_parts.push((nk, doc.add_part(pk, blocks)));
|
||||
}
|
||||
// RTF range id to the comment's id in the document.
|
||||
let mut by_rtf: HashMap<i64, u32> = HashMap::new();
|
||||
for (k, c) in comments.into_iter().enumerate() {
|
||||
let id = k as u32;
|
||||
let blocks = model::part_blocks(doc, &c.blocks);
|
||||
let part = doc.add_part(PartKind::Comment, blocks);
|
||||
doc.comments
|
||||
.insert(id, Comment { author: c.author, initials: c.initials, date: c.date, parent: None, resolved: false, part, ..Default::default() });
|
||||
by_rtf.insert(i64::from(c.rtf_id), id);
|
||||
}
|
||||
let ctx = Placer { note_parts, by_rtf };
|
||||
let mut body = std::mem::take(&mut doc.body);
|
||||
ctx.blocks(&mut body, None, 0);
|
||||
doc.body = body;
|
||||
let ids: Vec<u32> = doc.parts.keys().copied().collect();
|
||||
for id in ids {
|
||||
let Some(part) = doc.parts.get_mut(&id) else { continue };
|
||||
let own = ctx.note_parts.iter().find(|(_, p)| *p == id).copied();
|
||||
ctx.blocks(&mut part.blocks, own, 0);
|
||||
// A note starts with its own number, as Word's do.
|
||||
if let Some((kind, pid)) = own
|
||||
&& let Some(first) = part.blocks.first_mut()
|
||||
&& let Block::Para(p) = Arc::make_mut(first)
|
||||
&& !p.objects.iter().any(|o| matches!(o, InlineObject::NoteRef { id, .. } if *id == pid))
|
||||
{
|
||||
let props = Placer::ref_props(kind);
|
||||
if p.insert_object(0, InlineObject::NoteRef { kind, id: pid, custom: String::new() }, &props).is_err() {
|
||||
// The paragraph can't take it; the note simply has no number mark.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Placer {
|
||||
note_parts: Vec<(wordcraft_doc::para::NoteKind, u32)>,
|
||||
by_rtf: HashMap<i64, u32>,
|
||||
}
|
||||
|
||||
impl Placer {
|
||||
fn ref_props(kind: wordcraft_doc::para::NoteKind) -> wordcraft_doc::CharProps {
|
||||
let style = if kind == wordcraft_doc::para::NoteKind::Endnote { "EndnoteReference" } else { "FootnoteReference" };
|
||||
wordcraft_doc::CharProps { style: Some(style.into()), ..Default::default() }
|
||||
}
|
||||
|
||||
fn blocks(&self, bl: &mut Blocks, own: Option<(wordcraft_doc::para::NoteKind, u32)>, depth: usize) {
|
||||
for b in bl.iter_mut() {
|
||||
let needs = match &**b {
|
||||
Block::Para(p) => p.text.contains(MARK_OPEN),
|
||||
Block::Table(_) => depth < 8,
|
||||
};
|
||||
if !needs {
|
||||
continue;
|
||||
}
|
||||
match Arc::make_mut(b) {
|
||||
Block::Para(p) => self.para(p, own),
|
||||
Block::Table(t) => {
|
||||
for r in &mut t.rows {
|
||||
for c in &mut r.cells {
|
||||
self.blocks(&mut c.blocks, own, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn para(&self, p: &mut Paragraph, own: Option<(wordcraft_doc::para::NoteKind, u32)>) {
|
||||
// Every pass removes one marker, so this ends; the bound is only a seat belt.
|
||||
for _ in 0..100_000 {
|
||||
let Some(a) = p.text.find(MARK_OPEN) else { return };
|
||||
let Some(len) = p.text.get(a..).and_then(|t| t.find(MARK_CLOSE)) else { return };
|
||||
let end = a + len + MARK_CLOSE.len_utf8();
|
||||
let inner = p.text.get(a + MARK_OPEN.len_utf8()..a + len).unwrap_or("").to_string();
|
||||
let mut chars = inner.chars();
|
||||
let tag = chars.next().unwrap_or(' ');
|
||||
let n = chars.as_str().parse::<i64>().unwrap_or(i64::MIN);
|
||||
let (obj, props) = match tag {
|
||||
'N' => match usize::try_from(n).ok().and_then(|i| self.note_parts.get(i)) {
|
||||
Some(&(kind, id)) => (Some(InlineObject::NoteRef { kind, id, custom: String::new() }), Self::ref_props(kind)),
|
||||
None => (None, Default::default()),
|
||||
},
|
||||
'S' => match own {
|
||||
Some((kind, id)) => (Some(InlineObject::NoteRef { kind, id, custom: String::new() }), Self::ref_props(kind)),
|
||||
None => (None, Default::default()),
|
||||
},
|
||||
'B' => (self.by_rtf.get(&n).map(|&id| InlineObject::CommentStart { id }), Default::default()),
|
||||
'E' => (self.by_rtf.get(&n).map(|&id| InlineObject::CommentEnd { id }), Default::default()),
|
||||
_ => (None, Default::default()),
|
||||
};
|
||||
if p.delete(a, end).is_err() {
|
||||
return;
|
||||
}
|
||||
if let Some(o) = obj
|
||||
&& p.insert_object(a, o, &props).is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
use super::date::dttm_to_iso;
|
||||
use super::date::{dttm_to_iso, iso_date};
|
||||
use super::parse::column_grid;
|
||||
use super::state::{CellDef, Dest, HeaderFooter, MAX_GROUPS, MAX_REV_AUTHORS, MAX_SECTIONS, Output, PageGeom, SecFmt, St};
|
||||
use super::state::{
|
||||
CellDef, CommentData, Dest, HeaderFooter, MARK_CLOSE, MARK_OPEN, MAX_GROUPS, MAX_NOTE_BLOCKS, MAX_NOTES, MAX_REV_AUTHORS, MAX_SECTIONS, NoteData,
|
||||
Output, PageGeom, SecFmt, St, Sub,
|
||||
};
|
||||
use super::styles::{StyleDef, drop_style_formatting, style_fmt};
|
||||
use super::{Arc, Cell, FBlock, FTable, HashMap, Inline, Kind, Meta, Para, Rgb, model};
|
||||
use super::{Arc, Cell, FBlock, FTable, Fmt, HashMap, HashSet, Inline, Kind, Meta, Para, Rgb, model};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Reader {
|
||||
@@ -91,6 +94,22 @@ pub(super) struct Reader {
|
||||
pub(super) in_creatim: bool,
|
||||
pub(super) rule_pending: bool,
|
||||
pub(super) info_depth: Option<usize>,
|
||||
// Notes and comments (only when the caller wants them).
|
||||
pub(super) marks_on: bool,
|
||||
pub(super) sub: Option<Sub>,
|
||||
pub(super) notes: Vec<NoteData>,
|
||||
pub(super) comments: Vec<CommentData>,
|
||||
pub(super) atn_text: String,
|
||||
pub(super) atn_initials: String,
|
||||
pub(super) atn_author: String,
|
||||
pub(super) atn_date: String,
|
||||
pub(super) atn_parts: Option<(u32, u32, u32, u32, u32)>,
|
||||
pub(super) atn_ref: Option<i32>,
|
||||
pub(super) last_end: Option<i32>,
|
||||
pub(super) range_starts: HashSet<i32>,
|
||||
pub(super) range_ends: HashSet<i32>,
|
||||
pub(super) claimed: HashSet<i32>,
|
||||
pub(super) synth: i32,
|
||||
}
|
||||
|
||||
impl Reader {
|
||||
@@ -152,12 +171,103 @@ impl Reader {
|
||||
}
|
||||
}
|
||||
|
||||
/// A footnote, endnote or comment group starts: read it into an output of its own.
|
||||
pub(super) fn start_sub(&mut self, comment: bool) {
|
||||
if self.overflow > 0 || self.notes.len() >= MAX_NOTES || self.comments.len() >= MAX_NOTES {
|
||||
self.st.dest = Dest::Skip;
|
||||
return;
|
||||
}
|
||||
let saved = self.swap_output(Output::default());
|
||||
self.sub = Some(Sub { depth: self.depth(), comment, endnote: false, saved: Box::new(saved) });
|
||||
self.st.intbl = false;
|
||||
}
|
||||
|
||||
/// A place marker as text in the current paragraph.
|
||||
pub(super) fn push_mark(&mut self, tag: char, n: i64, fmt: Fmt) {
|
||||
self.para.push_text(&format!("{MARK_OPEN}{tag}{n}{MARK_CLOSE}"), &fmt);
|
||||
self.para_has = true;
|
||||
}
|
||||
|
||||
/// The footnote or comment group closed: keep its text and leave a marker in the body.
|
||||
pub(super) fn end_sub(&mut self) {
|
||||
let Some(sub) = self.sub.take() else { return };
|
||||
if self.para_has || !self.para.inlines.is_empty() {
|
||||
self.end_para(false);
|
||||
}
|
||||
self.close_table();
|
||||
let fmt = self.stack.last().map(|s| s.fmt.clone()).unwrap_or_default();
|
||||
let mut inner = self.swap_output(*sub.saved);
|
||||
inner.body.truncate(MAX_NOTE_BLOCKS);
|
||||
if !sub.comment {
|
||||
self.push_mark('N', self.notes.len() as i64, fmt);
|
||||
self.notes.push(NoteData { endnote: sub.endnote, blocks: inner.body });
|
||||
return;
|
||||
}
|
||||
let want = self.atn_ref.take().or(self.last_end.take());
|
||||
let id = match want {
|
||||
Some(i) if self.range_starts.contains(&i) && self.range_ends.contains(&i) && self.claimed.insert(i) => i,
|
||||
_ => {
|
||||
// No range of its own: the comment sits at this point.
|
||||
self.synth = self.synth.saturating_sub(1);
|
||||
let id = self.synth;
|
||||
self.push_mark('B', i64::from(id), fmt.clone());
|
||||
self.push_mark('E', i64::from(id), fmt);
|
||||
id
|
||||
}
|
||||
};
|
||||
let date =
|
||||
if !self.atn_date.is_empty() { std::mem::take(&mut self.atn_date) } else { self.atn_parts.take().map(iso_date).unwrap_or_default() };
|
||||
self.atn_parts = None;
|
||||
self.comments.push(CommentData {
|
||||
rtf_id: id,
|
||||
author: std::mem::take(&mut self.atn_author),
|
||||
initials: std::mem::take(&mut self.atn_initials),
|
||||
date,
|
||||
blocks: inner.body,
|
||||
});
|
||||
}
|
||||
|
||||
/// A comment word's text group closed (`\atrfstart 3`, `\atnauthor Ann`…).
|
||||
pub(super) fn commit_atn(&mut self, kind: u8) {
|
||||
let text = std::mem::take(&mut self.atn_text);
|
||||
let text = text.trim();
|
||||
let id = text.parse::<i32>().ok();
|
||||
let fmt = self.stack.last().map(|s| s.fmt.clone()).unwrap_or_default();
|
||||
match kind {
|
||||
1 => {
|
||||
if let Some(id) = id {
|
||||
self.range_starts.insert(id);
|
||||
self.push_mark('B', i64::from(id), fmt);
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
if let Some(id) = id {
|
||||
self.range_ends.insert(id);
|
||||
self.last_end = Some(id);
|
||||
self.push_mark('E', i64::from(id), fmt);
|
||||
}
|
||||
}
|
||||
3 => self.atn_initials = text.to_string(),
|
||||
4 => self.atn_author = text.to_string(),
|
||||
5 => {
|
||||
// Either `\yr\mo\dy\hr\min` words (read as they came) or a packed date-time number.
|
||||
if let Some(parts) = self.atn_parts.take() {
|
||||
self.atn_date = iso_date(parts);
|
||||
} else if let Some(n) = text.parse::<u32>().ok().filter(|n| *n != 0) {
|
||||
self.atn_date = iso_date((1900 + ((n >> 20) & 0x1FF), (n >> 16) & 0xF, (n >> 11) & 0x1F, (n >> 6) & 0x1F, n & 0x3F));
|
||||
}
|
||||
}
|
||||
6 => self.atn_ref = id,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// `\sect`: end the paragraph, and with it the section, which takes its formatting and the
|
||||
/// headers and footers read since the last break. Inside a header, footer or table, and past
|
||||
/// [`MAX_SECTIONS`], it only ends the paragraph.
|
||||
pub(super) fn end_section(&mut self) {
|
||||
self.end_para_mark();
|
||||
if self.hf.is_some() || self.st.intbl || self.section_parts.len() + 1 >= MAX_SECTIONS {
|
||||
if self.hf.is_some() || self.sub.is_some() || self.st.intbl || self.section_parts.len() + 1 >= MAX_SECTIONS {
|
||||
return;
|
||||
}
|
||||
let props = self.sec.props(&self.page);
|
||||
@@ -175,7 +285,7 @@ impl Reader {
|
||||
let pv = p.unwrap_or(0);
|
||||
// Inside a header or footer the word is read into a scratch copy and dropped.
|
||||
let mut scratch;
|
||||
let sec = if self.hf.is_some() {
|
||||
let sec = if self.hf.is_some() || self.sub.is_some() {
|
||||
scratch = self.sec.clone();
|
||||
&mut scratch
|
||||
} else {
|
||||
@@ -231,6 +341,9 @@ impl Reader {
|
||||
self.overflow -= 1;
|
||||
return;
|
||||
}
|
||||
if self.sub.as_ref().is_some_and(|s| s.depth == self.depth()) {
|
||||
self.end_sub();
|
||||
}
|
||||
let closing = self.st.dest;
|
||||
// Destination-level commits.
|
||||
match closing {
|
||||
@@ -249,6 +362,7 @@ impl Reader {
|
||||
}
|
||||
self.in_creatim = false;
|
||||
}
|
||||
Dest::Atn(k) => self.commit_atn(k),
|
||||
_ => {}
|
||||
}
|
||||
let Some(mut prev) = self.stack.pop() else { return };
|
||||
|
||||
@@ -5,6 +5,15 @@ pub(super) const MAX_GROUPS: usize = 512;
|
||||
|
||||
/// Largest picture we decode (bytes).
|
||||
pub(super) const MAX_PICT: usize = 64 << 20;
|
||||
/// Most notes, and most comments, one file may bring in.
|
||||
pub(super) const MAX_NOTES: usize = 5_000;
|
||||
/// Most blocks kept of one note's or comment's text.
|
||||
pub(super) const MAX_NOTE_BLOCKS: usize = 500;
|
||||
/// Brackets of a place marker in text: a note reference or a comment range end travels through the
|
||||
/// flow model as `OPEN tag number CLOSE` (private-use characters, which both the reader and the
|
||||
/// writer keep out of real text) and becomes an inline object once the document exists.
|
||||
pub(super) const MARK_OPEN: char = '\u{F8F0}';
|
||||
pub(super) const MARK_CLOSE: char = '\u{F8F1}';
|
||||
|
||||
/// Most sections we read (`\sect`); later breaks end paragraphs only.
|
||||
pub(super) const MAX_SECTIONS: usize = 10_000;
|
||||
@@ -25,6 +34,9 @@ pub(super) enum Dest {
|
||||
FldInst,
|
||||
Pict,
|
||||
Bookmark,
|
||||
/// A comment's text-valued word: 1 `\atrfstart`, 2 `\atrfend`, 3 `\atnid`, 4 `\atnauthor`,
|
||||
/// 5 `\atndate`/`\atntime`, 6 `\atnref`.
|
||||
Atn(u8),
|
||||
/// The author table of tracked changes (`\revtbl`).
|
||||
RevTbl,
|
||||
/// Formatting before a tracked change (`\oldpprops` when true, else `\oldcprops`): read
|
||||
@@ -32,6 +44,29 @@ pub(super) enum Dest {
|
||||
OldProps(bool),
|
||||
}
|
||||
|
||||
/// A footnote, endnote or comment group being read into an output of its own.
|
||||
pub(super) struct Sub {
|
||||
pub(super) depth: usize,
|
||||
pub(super) comment: bool,
|
||||
pub(super) endnote: bool,
|
||||
pub(super) saved: Box<Output>,
|
||||
}
|
||||
|
||||
/// A note read: its kind and text.
|
||||
pub(super) struct NoteData {
|
||||
pub(super) endnote: bool,
|
||||
pub(super) blocks: Vec<FBlock>,
|
||||
}
|
||||
|
||||
/// A comment read: the id its range markers carry, who wrote it and when, and its text.
|
||||
pub(super) struct CommentData {
|
||||
pub(super) rtf_id: i32,
|
||||
pub(super) author: String,
|
||||
pub(super) initials: String,
|
||||
pub(super) date: String,
|
||||
pub(super) blocks: Vec<FBlock>,
|
||||
}
|
||||
|
||||
/// A tracked formatting change being read (`\crauth`/`\crdate` with `\oldcprops`,
|
||||
/// `\prauth`/`\prdate` with `\oldpprops`): author (`\revtbl` index), date (DTTM) and the old
|
||||
/// formatting, once its group has been read.
|
||||
|
||||
@@ -1097,6 +1097,7 @@ fn old_formatting_in_a_code_paragraph_has_the_code_font() {
|
||||
let mut p = Para { kind: Kind::Code, ..Default::default() };
|
||||
p.push_text("x", &Fmt { font: Some("Arial".into()), fmt_change: Some(Box::new(FmtChange { mark, old: Fmt::default() })), ..Default::default() });
|
||||
let mut w = Writer {
|
||||
marks: Vec::new(),
|
||||
t: Tables { fonts: vec!["Aptos".into()], colors: Vec::new() },
|
||||
lists: Vec::new(),
|
||||
authors: None,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::state::{Dest, MAX_PICT, St};
|
||||
use super::state::{Dest, MARK_CLOSE, MARK_OPEN, MAX_PICT, St};
|
||||
use super::{Fmt, Inline, Reader, Rgb, make_img, model};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -196,6 +196,11 @@ impl Reader {
|
||||
return;
|
||||
}
|
||||
let mut f = self.st.fmt.clone();
|
||||
if s.contains([MARK_OPEN, MARK_CLOSE]) {
|
||||
self.para.push_text(&s.replace([MARK_OPEN, MARK_CLOSE], "\u{FFFD}"), &f);
|
||||
self.para_has = true;
|
||||
return;
|
||||
}
|
||||
let r = self.st.rev;
|
||||
if r.ins {
|
||||
f.ins = Some(self.rev_mark(r.auth, r.dttm));
|
||||
@@ -245,6 +250,11 @@ impl Reader {
|
||||
}
|
||||
}
|
||||
Dest::Info(_) => self.info_text.push_str(s),
|
||||
Dest::Atn(_) => {
|
||||
if self.atn_text.len() < 256 {
|
||||
self.atn_text.push_str(s);
|
||||
}
|
||||
}
|
||||
Dest::FldInst => {
|
||||
if let Some(f) = self.st.field.and_then(|i| self.fields.get_mut(i))
|
||||
&& f.len() < 4096
|
||||
|
||||
@@ -15,6 +15,9 @@ impl Reader {
|
||||
if w == "shppict" || self.start_header_footer_word(w) {
|
||||
return;
|
||||
}
|
||||
if self.start_note_word(w) {
|
||||
return;
|
||||
}
|
||||
let dest = self.destination_for_word(w);
|
||||
if let Some(d) = dest {
|
||||
match d {
|
||||
@@ -295,6 +298,22 @@ impl Reader {
|
||||
// Transparent wrapper: read the nested pict without changing the destination.
|
||||
"pict" => Some(Dest::Pict),
|
||||
"bkmkstart" => Some(Dest::Bookmark),
|
||||
"atrfstart" | "atrfend" | "atnid" | "atnauthor" | "atndate" | "atntime" | "atnref"
|
||||
if self.marks_on
|
||||
&& self.hf.is_none()
|
||||
&& self.st.dest == Dest::Body
|
||||
&& self.sub.as_ref().is_none_or(|s| s.comment && !w.starts_with("atrf")) =>
|
||||
{
|
||||
self.atn_text.clear();
|
||||
Some(Dest::Atn(match w {
|
||||
"atrfstart" => 1,
|
||||
"atrfend" => 2,
|
||||
"atnid" => 3,
|
||||
"atnauthor" => 4,
|
||||
"atndate" | "atntime" => 5,
|
||||
_ => 6,
|
||||
}))
|
||||
}
|
||||
"header" | "footer" | "headerl" | "headerr" | "headerf" | "footerl" | "footerr" | "footerf" | "footnote" | "annotation" | "pntext"
|
||||
| "listtext" | "object" | "nonshppict" | "xe" | "tc" | "txe" | "private" | "bkmkend" | "atnid" | "atnauthor" | "comment" | "shpinst"
|
||||
| "do" | "operator" | "company" | "manager" | "category" | "hlinkbase" | "revtime" | "printim" | "buptim" | "version" | "nofpages"
|
||||
@@ -309,6 +328,32 @@ impl Reader {
|
||||
}
|
||||
}
|
||||
|
||||
/// The words that open a note or comment story of their own (`\footnote`, `\annotation`) and
|
||||
/// the two inside a note: `\ftnalt` (an endnote) and `\chftn` (the note's own number).
|
||||
fn start_note_word(&mut self, word: &str) -> bool {
|
||||
if self.st.dest == Dest::Body {
|
||||
match word {
|
||||
"ftnalt" => {
|
||||
if let Some(sub) = self.sub.as_mut() {
|
||||
sub.endnote = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
"chftn" if self.sub.as_ref().is_some_and(|s| !s.comment) => {
|
||||
let f = self.st.fmt.clone();
|
||||
self.push_mark('S', 0, f);
|
||||
return true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !matches!(word, "footnote" | "annotation") || !self.marks_on || self.hf.is_some() || self.sub.is_some() || self.st.dest != Dest::Body {
|
||||
return false;
|
||||
}
|
||||
self.start_sub(word == "annotation");
|
||||
true
|
||||
}
|
||||
|
||||
fn start_header_footer_word(&mut self, word: &str) -> bool {
|
||||
if !matches!(word, "header" | "footer" | "headerl" | "headerr" | "headerf" | "footerl" | "footerr" | "footerf")
|
||||
|| self.hf.is_some()
|
||||
@@ -398,6 +443,12 @@ impl Reader {
|
||||
self.picture_word(word, value);
|
||||
true
|
||||
}
|
||||
Dest::Atn(kind) => {
|
||||
if kind == 5 {
|
||||
self.record_atn_part(word, value);
|
||||
}
|
||||
!matches!(word, "u" | "uc")
|
||||
}
|
||||
Dest::Info(_) | Dest::FldInst | Dest::Bookmark if !matches!(word, "u" | "uc") => true,
|
||||
Dest::Info(_) | Dest::FldInst | Dest::Bookmark => false,
|
||||
Dest::OldProps(false) if matches!(word, "crauth" | "crdate") => {
|
||||
@@ -421,6 +472,19 @@ impl Reader {
|
||||
}
|
||||
}
|
||||
|
||||
fn record_atn_part(&mut self, word: &str, value: i32) {
|
||||
let v = value.clamp(0, 9999) as u32;
|
||||
let c = self.atn_parts.get_or_insert((0, 1, 1, 0, 0));
|
||||
match word {
|
||||
"yr" => c.0 = v,
|
||||
"mo" => c.1 = v,
|
||||
"dy" => c.2 = v,
|
||||
"hr" => c.3 = v,
|
||||
"min" => c.4 = v,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn font_table_word(&mut self, word: &str, value: i32) {
|
||||
match word {
|
||||
"f" => {
|
||||
|
||||
@@ -879,6 +879,142 @@ fn exported_links_keep_only_safe_targets() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// RTF footnotes, endnotes and comments (#1053)
|
||||
|
||||
/// The note and comment texts of `d` as `(kind, text)` strings, and the references in the body.
|
||||
fn rtf_notes(d: &Document) -> (Vec<String>, Vec<String>) {
|
||||
use wordcraft_doc::PartKind;
|
||||
let story = |bl: &wordcraft_doc::Blocks| {
|
||||
bl.iter().filter_map(|b| if let Block::Para(p) = &**b { Some(p.plain_text().trim().to_string()) } else { None }).collect::<Vec<_>>().join("|")
|
||||
};
|
||||
let notes = d
|
||||
.parts
|
||||
.values()
|
||||
.filter(|p| matches!(p.kind, PartKind::Footnote | PartKind::Endnote))
|
||||
.map(|p| format!("{:?}:{}", p.kind, story(&p.blocks)))
|
||||
.collect();
|
||||
let comments = d.comments.values().filter_map(|c| d.parts.get(&c.part)).map(|p| story(&p.blocks)).collect();
|
||||
(notes, comments)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtf_footnote_and_endnote_round_trip() {
|
||||
use wordcraft_doc::para::NoteKind;
|
||||
let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Arial;}}
|
||||
\pard Alpha{\super\chftn}{\footnote \pard\plain {\super\chftn} First {\b note}\par} beta
|
||||
gamma{\super\chftn}{\footnote\ftnalt \pard\plain {\super\chftn} The end\par\pard second line\par} delta.\par}";
|
||||
let d = import("rtf", rtf).unwrap().unwrap();
|
||||
let body = d.body.iter().find_map(|b| b.as_para()).unwrap();
|
||||
let refs: Vec<NoteKind> =
|
||||
body.objects.iter().filter_map(|o| if let InlineObject::NoteRef { kind, .. } = o { Some(*kind) } else { None }).collect();
|
||||
assert_eq!(refs, [NoteKind::Footnote, NoteKind::Endnote]);
|
||||
assert_eq!(body.plain_text().trim(), "Alpha beta gamma delta.");
|
||||
let (notes, _) = rtf_notes(&d);
|
||||
assert_eq!(notes, ["Footnote:First note", "Endnote:The end|second line"]);
|
||||
// The note's own number sits at the start of its text, and its first words are bold.
|
||||
let foot = d.parts.values().find(|p| p.kind == wordcraft_doc::PartKind::Footnote).unwrap();
|
||||
let fp = foot.blocks.iter().find_map(|b| b.as_para()).unwrap();
|
||||
assert!(matches!(fp.objects.first(), Some(InlineObject::NoteRef { kind: NoteKind::Footnote, .. })));
|
||||
// Out and in again keeps both.
|
||||
let out = String::from_utf8(export("rtf", &d).unwrap().unwrap()).unwrap();
|
||||
assert!(out.contains("\\footnote") && out.contains("\\footnote\\ftnalt") && out.contains("\\chftn"), "{out}");
|
||||
let d2 = import("rtf", out.as_bytes()).unwrap().unwrap();
|
||||
assert_eq!(rtf_notes(&d2).0, notes);
|
||||
let body2 = d2.body.iter().find_map(|b| b.as_para()).unwrap();
|
||||
assert_eq!(body2.objects.len(), 2);
|
||||
assert_eq!(body2.plain_text().trim(), "Alpha beta gamma delta.");
|
||||
// The flow reader (no document) still just skips them.
|
||||
let flow = crate::rtf::parse(rtf).unwrap();
|
||||
assert!(matches!(&flow.blocks[0], FBlock::Para(p) if p.text().trim() == "Alpha beta gamma delta."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtf_comment_round_trip_with_author_and_date() {
|
||||
let rtf = br"{\rtf1\ansi
|
||||
\pard Before {\*\atrfstart 7}commented words{\*\atrfend 7}{\*\atnid AK}{\*\atnauthor Ada Kline}\chatn{\*\annotation{\*\atnref 7}{\*\atndate \yr2026\mo3\dy9\hr14\min5}\pard\plain Please \b check\b0 this.\par} after.\par
|
||||
\pard Second {\*\atrfstart 8}one{\*\atrfend 8}{\*\atnid BX}{\*\atnauthor Bo}\chatn{\*\annotation{\*\atnref 8}\pard\plain Fine.\par}\par}";
|
||||
let d = import("rtf", rtf).unwrap().unwrap();
|
||||
assert_eq!(d.comments.len(), 2);
|
||||
let c = d.comments.values().next().unwrap();
|
||||
assert_eq!((c.author.as_str(), c.initials.as_str(), c.date.as_str()), ("Ada Kline", "AK", "2026-03-09T14:05:00Z"));
|
||||
assert_eq!(rtf_notes(&d).1, ["Please check this.", "Fine."]);
|
||||
let p = d.body.iter().find_map(|b| b.as_para()).unwrap();
|
||||
assert_eq!(p.plain_text().trim(), "Before commented words after.");
|
||||
// The range wraps exactly the commented words.
|
||||
let (s, e) = (p.object_offsets()[0], p.object_offsets()[1]);
|
||||
assert!(matches!(p.object_at(s), Some(InlineObject::CommentStart { .. })) && matches!(p.object_at(e), Some(InlineObject::CommentEnd { .. })));
|
||||
assert_eq!(p.text.get(s + 3..e), Some("commented words"));
|
||||
let out = String::from_utf8(export("rtf", &d).unwrap().unwrap()).unwrap();
|
||||
assert!(out.contains("\\atrfstart") && out.contains("\\atnauthor Ada Kline") && out.contains("\\annotation"), "{out}");
|
||||
let d2 = import("rtf", out.as_bytes()).unwrap().unwrap();
|
||||
assert_eq!(d2.comments.len(), 2);
|
||||
let (a, b) = (d.comments.values().next().unwrap(), d2.comments.values().next().unwrap());
|
||||
assert_eq!((&a.author, &a.initials, &a.date), (&b.author, &b.initials, &b.date));
|
||||
assert_eq!(rtf_notes(&d2).1, ["Please check this.", "Fine."]);
|
||||
let p2 = d2.body.iter().find_map(|b| b.as_para()).unwrap();
|
||||
assert_eq!(p2.objects.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtf_hostile_notes_and_comments_do_not_panic() {
|
||||
// Truncated everywhere, unbalanced groups, forged markers, huge ids, nesting.
|
||||
let good = br"{\rtf1\ansi Hi{\super\chftn}{\footnote\ftnalt \pard\plain {\super\chftn} Note \u8364? text\par} {\*\atrfstart 1}x{\*\atrfend 1}{\*\atnid A}{\*\atnauthor Z}{\*\annotation{\*\atnref 1}{\*\atndate 123456789}\pard c\par}\par}";
|
||||
for n in 0..good.len() {
|
||||
let _ = import("rtf", &good[..n]);
|
||||
}
|
||||
let junk: [&[u8]; 6] = [
|
||||
br"{\rtf1 \u63472?\u63473?\u63472?N0\u63473? {\footnote x}{\footnote {\footnote deep}}}",
|
||||
br"{\rtf1 {\*\atrfstart 99999999999}{\*\atrfend -5}{\*\annotation{\*\atnref 99999999999}x}{\*\annotation}}",
|
||||
br"{\rtf1 {\footnote{\annotation{\footnote{\*\annotation{\*\atrfstart 1}",
|
||||
br"{\rtf1 {\*\atndate \yr99999\mo-4\dy0\hr77\min999}{\*\annotation y}{\*\atndate 4294967295}{\*\annotation z}}",
|
||||
b"{\\rtf1 {\\footnote \\trowd\\cellx100\\pard\\intbl a\\cell\\row}}",
|
||||
b"{\\rtf1 {\\annotation \\chatn\\chftn\\atnparent 3}{\\header {\\footnote q}}}",
|
||||
];
|
||||
for j in junk {
|
||||
let d = import("rtf", j).unwrap().unwrap();
|
||||
// Whatever came in, a second trip through RTF is fine too.
|
||||
let out = export("rtf", &d).unwrap().unwrap();
|
||||
assert!(import("rtf", &out).unwrap().is_ok());
|
||||
}
|
||||
// Many notes: bounded.
|
||||
let mut many = String::from("{\\rtf1 ");
|
||||
for _ in 0..6000 {
|
||||
many.push_str("{\\footnote x}");
|
||||
}
|
||||
many.push('}');
|
||||
let d = import("rtf", many.as_bytes()).unwrap().unwrap();
|
||||
assert!(d.parts.len() <= 5000);
|
||||
// Private-use marker characters in ordinary text stay text.
|
||||
let d = import("rtf", "{\\rtf1 a\u{F8F0}N0\u{F8F1}b}".as_bytes()).unwrap().unwrap();
|
||||
assert!(d.parts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtf_notes_comments_tracked_changes_and_sections_work_together() {
|
||||
// Two sections; the second holds a footnote, a comment and a tracked insertion, and has its
|
||||
// own footer. A `\sect` inside the footnote's text must not end a section.
|
||||
let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Arial;}}{\*\revtbl {Unknown;}{Alice;}}
|
||||
\sectd{\footerr \pard Foot one\par}\pard First section.\par \sect
|
||||
\sectd\lndscpsxn{\footerr \pard Foot two\par}\pard Second{\super\chftn}{\footnote \pard\plain {\super\chftn} Note text\sect\par} {\*\atrfstart 3}section{\*\atrfend 3}{\*\atnid AB}{\*\atnauthor Ann}\chatn{\*\annotation{\*\atnref 3}{\*\atndate 1000}\pard Remark\par}{\revised\revauth1\revdttm-1480361314 added}.\par}";
|
||||
let d = import("rtf", rtf).unwrap().unwrap();
|
||||
assert_eq!(d.sections().len(), 2);
|
||||
let check = |d: &Document| {
|
||||
let (notes, comments) = rtf_notes(d);
|
||||
assert_eq!(notes.len(), 1, "{notes:?}");
|
||||
assert!(notes[0].contains("Note text"), "{notes:?}");
|
||||
assert_eq!(comments.len(), 1, "{comments:?}");
|
||||
assert!(comments[0].contains("Remark"), "{comments:?}");
|
||||
let ins = d.body.iter().filter_map(|b| b.as_para()).any(|p| p.plain_text().contains("added"));
|
||||
assert!(ins);
|
||||
};
|
||||
check(&d);
|
||||
let out = String::from_utf8(export("rtf", &d).unwrap().unwrap()).unwrap();
|
||||
assert!(out.contains("\\revised") && out.contains("\\footnote") && out.contains("\\atrfstart"), "{out}");
|
||||
let d2 = import("rtf", out.as_bytes()).unwrap().unwrap();
|
||||
check(&d2);
|
||||
}
|
||||
|
||||
/// A link target with any whitespace (a no-break or ideographic space too) is written in `<…>`,
|
||||
/// so the reader, which ends a bare destination at the first whitespace, reads it back whole.
|
||||
#[test]
|
||||
|
||||
@@ -25,6 +25,7 @@ log = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
wordcraft-doc = { workspace = true, features = ["test-support"] }
|
||||
wordcraft-fonts = { workspace = true, features = ["test-support"] }
|
||||
|
||||
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
|
||||
# The browser clock (`SystemTime::now()`/`Instant::now()` panic on wasm32-unknown-unknown).
|
||||
|
||||
@@ -349,6 +349,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn occupied_follows_the_outline() {
|
||||
crate::test_fonts();
|
||||
let c = diamond(false);
|
||||
let s = c.occupied(0.0, 10.0);
|
||||
assert_eq!(s.len(), 1);
|
||||
@@ -363,6 +364,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn through_keeps_interior_gaps() {
|
||||
crate::test_fonts();
|
||||
// A U: two prongs joined at the bottom.
|
||||
let u = [(0.0, 0.0), (20.0, 0.0), (20.0, 80.0), (80.0, 80.0), (80.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)];
|
||||
let (_, tight) = Contour::new(&u, 0.0, 0.0, 0.0, false).unwrap();
|
||||
@@ -374,6 +376,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pictures_wrap_around_their_opaque_pixels() {
|
||||
crate::test_fonts();
|
||||
let png = |img: image::DynamicImage| {
|
||||
let mut buf = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).unwrap();
|
||||
@@ -429,6 +432,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn hostile_points_are_dropped() {
|
||||
crate::test_fonts();
|
||||
assert!(Contour::new(&[(f32::NAN, 0.0), (1.0, 1.0), (f32::INFINITY, 2.0)], 0.0, 0.0, 0.0, false).is_none());
|
||||
let c = diamond(true);
|
||||
assert!(c.occupied(f32::NAN, 1.0).is_empty());
|
||||
|
||||
@@ -815,6 +815,8 @@ pub fn section_break_label(start: SectionStart) -> &'static str {
|
||||
|
||||
/// Width of a [`Draw::MarkText`] label.
|
||||
pub fn mark_text_width(text: &str, size: f32) -> f32 {
|
||||
#[cfg(test)]
|
||||
crate::test_fonts();
|
||||
let face = wordcraft_fonts::word::resolve("Source Sans 3", false, false).face;
|
||||
let k = size / face.upem.max(1.0) as f32;
|
||||
text.chars().map(|c| face.advance(face.glyph_for(c)) as f32 * k).sum()
|
||||
|
||||
@@ -363,6 +363,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn ref_link_switches() {
|
||||
crate::test_fonts();
|
||||
for instr in [r"REF mark\w\h", r#" REF "mark" \h"#, "REF\tmark \\H"] {
|
||||
assert_eq!(reference_link(instr).as_deref(), Some("#mark"), "{instr}");
|
||||
}
|
||||
@@ -386,6 +387,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn page_fields() {
|
||||
crate::test_fonts();
|
||||
let ctx = FieldCtx { page: 4, pages: 12, ..Default::default() };
|
||||
assert_eq!(field_text("PAGE", "", &ctx), ("4".into(), true));
|
||||
assert_eq!(field_text(" PAGE \\* ROMAN ", "", &ctx).0, "IV");
|
||||
@@ -480,6 +482,7 @@ mod tests {
|
||||
/// #1765: ASK and SET only assign text to a bookmark; they show nothing, whatever result is stored.
|
||||
#[test]
|
||||
fn ask_and_set_show_nothing() {
|
||||
crate::test_fonts();
|
||||
let ctx = FieldCtx::default();
|
||||
assert_eq!(field_text(r#" ASK Nominativo "Inserire nome" \d "«NOME»" "#, "«NOME» «COGNOME»", &ctx), (String::new(), false));
|
||||
assert_eq!(field_text(r#"SET Codice "COD""#, "COD", &ctx), (String::new(), false));
|
||||
@@ -490,6 +493,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn page_references_show_their_bookmarks_page() {
|
||||
crate::test_fonts();
|
||||
let pages = HashMap::from([
|
||||
("End_1".to_string(), (3, NumFormat::Decimal)),
|
||||
("Intro".to_string(), (4, NumFormat::LowerRoman)),
|
||||
|
||||
@@ -122,6 +122,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn auto_space_pairs() {
|
||||
crate::test_fonts();
|
||||
assert_eq!(auto_space('院', '9', true, true), Some(AsianSide::Left));
|
||||
assert_eq!(auto_space('A', 'カ', true, true), Some(AsianSide::Right));
|
||||
assert_eq!(auto_space('院', '9', true, false), None);
|
||||
@@ -140,6 +141,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn kinsoku_and_word_wrap_break_opportunities() {
|
||||
crate::test_fonts();
|
||||
let ja = "日本語、「漢字」。テスト";
|
||||
let on = breaks(ja, true, true);
|
||||
for c in ["、", "」", "。", "漢"] {
|
||||
|
||||
@@ -8,6 +8,18 @@
|
||||
//! - [`hit`]: point ↔ position, caret geometry, line navigation, selection rectangles.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
/// Installs the pinned test fonts as the process-wide font database. The layout entry points
|
||||
/// (`layout`, `layout_para`, `layout_equation`, `display::mark_text_width`) call it first in test
|
||||
/// builds, so the shared `lay` helper and every test that goes through them are covered; a test
|
||||
/// that looks up fonts itself before any such call (fixtures, `word::resolve`, `FontDb::global`)
|
||||
/// calls it at its start. It is idempotent and never calls back into layout, so it cannot recurse.
|
||||
/// The choice is process-wide and immutable, which keeps font and math caches consistent across
|
||||
/// test threads.
|
||||
#[cfg(test)]
|
||||
fn test_fonts() {
|
||||
assert!(wordcraft_fonts::FontDb::init_test_fonts(), "layout tests must initialize fixed fonts before any global font lookup");
|
||||
}
|
||||
|
||||
pub mod balloons;
|
||||
mod contour;
|
||||
pub mod display;
|
||||
@@ -312,6 +324,8 @@ pub struct DocLayout {
|
||||
|
||||
/// Lay out the whole document.
|
||||
pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) -> DocLayout {
|
||||
#[cfg(test)]
|
||||
crate::test_fonts();
|
||||
let t0 = now_ms();
|
||||
// Page totals shown in the body (`NUMPAGES`, `SECTIONPAGES`) are known only after
|
||||
// paginating: lay out again with the totals found until they hold (bounded). Footnotes
|
||||
|
||||
@@ -207,6 +207,8 @@ pub fn layout_equation(
|
||||
props: &MathProps,
|
||||
counter: &mut u32,
|
||||
) -> MathLayout {
|
||||
#[cfg(test)]
|
||||
crate::test_fonts();
|
||||
let parsed;
|
||||
let nodes: &Arg = if math.nodes.is_empty() && !math.linear {
|
||||
parsed = parse_linear(linear);
|
||||
|
||||
@@ -35,6 +35,7 @@ fn lay_in(math: &Math, lin: &str, display: bool, avail: f32, mp: &MathProps) ->
|
||||
|
||||
#[test]
|
||||
fn eq_array_rows_without_ampersand_are_centred() {
|
||||
crate::test_fonts();
|
||||
// Word centres a row with no `&` in the array (#2617: a case row "a, n odd" above
|
||||
// "|a|, &n even"), whether or not the other rows have one.
|
||||
use wordcraft_doc::math::*;
|
||||
@@ -65,6 +66,7 @@ fn eq_array_rows_without_ampersand_are_centred() {
|
||||
|
||||
#[test]
|
||||
fn long_display_equations_wrap_before_operators_and_indent() {
|
||||
crate::test_fonts();
|
||||
let sum = "y=a+b+c+d+e+f+g+h+i+j+k+l+m+n+o+p+q+r+s+t+u+v+w";
|
||||
let mp = MathProps::default();
|
||||
let full = lay_in(&Math::default(), sum, true, 1e4, &mp);
|
||||
@@ -105,6 +107,7 @@ fn long_display_equations_wrap_before_operators_and_indent() {
|
||||
|
||||
#[test]
|
||||
fn manual_breaks_start_new_lines() {
|
||||
crate::test_fonts();
|
||||
let mp = MathProps::default();
|
||||
let nodes = vec![MNode::Run(MRun::new("a=b")), MNode::Run(MRun { brk: Some(0), ..MRun::new("+c") })];
|
||||
let m = lay_in(&Math { nodes, ..Default::default() }, "", true, 400.0, &mp);
|
||||
@@ -121,6 +124,7 @@ fn manual_breaks_start_new_lines() {
|
||||
|
||||
#[test]
|
||||
fn short_display_equations_stay_on_one_line() {
|
||||
crate::test_fonts();
|
||||
let mp = MathProps::default();
|
||||
let one = lay_in(&Math::default(), "a+b=c", true, 400.0, &mp);
|
||||
assert_eq!(lines(&one).len(), 1);
|
||||
@@ -130,6 +134,7 @@ fn short_display_equations_stay_on_one_line() {
|
||||
|
||||
#[test]
|
||||
fn fraction_is_stacked_with_a_rule() {
|
||||
crate::test_fonts();
|
||||
let flat = lay("ab", true);
|
||||
let f = lay("a/b", true);
|
||||
assert!(f.ascent > flat.ascent + 2.0 && f.descent > flat.descent + 2.0, "{f:?}");
|
||||
@@ -139,6 +144,7 @@ fn fraction_is_stacked_with_a_rule() {
|
||||
|
||||
#[test]
|
||||
fn superscript_is_raised_and_smaller() {
|
||||
crate::test_fonts();
|
||||
let m = lay("x^2", false);
|
||||
let sizes: Vec<(f32, f32)> = m
|
||||
.items
|
||||
@@ -154,6 +160,7 @@ fn superscript_is_raised_and_smaller() {
|
||||
|
||||
#[test]
|
||||
fn operators_get_spaces_and_unary_minus_does_not() {
|
||||
crate::test_fonts();
|
||||
let tight = lay("ab", false);
|
||||
let spaced = lay("a+b", false);
|
||||
let unary = lay("−b", false);
|
||||
@@ -164,6 +171,7 @@ fn operators_get_spaces_and_unary_minus_does_not() {
|
||||
|
||||
#[test]
|
||||
fn display_operators_are_big_with_limits_over_and_under() {
|
||||
crate::test_fonts();
|
||||
let d = lay("∑_(i=1)^n▒i", true);
|
||||
let t = lay("∑_(i=1)^n▒i", false);
|
||||
assert!(d.ascent + d.descent > t.ascent + t.descent, "display sum taller");
|
||||
@@ -172,6 +180,7 @@ fn display_operators_are_big_with_limits_over_and_under() {
|
||||
|
||||
#[test]
|
||||
fn delimiters_grow_around_tall_content() {
|
||||
crate::test_fonts();
|
||||
let small = lay("(x)", true);
|
||||
let big = lay("((a)/(b))", true);
|
||||
let h = |m: &MathLayout| m.ascent + m.descent;
|
||||
@@ -180,6 +189,7 @@ fn delimiters_grow_around_tall_content() {
|
||||
|
||||
#[test]
|
||||
fn every_structure_lays_out() {
|
||||
crate::test_fonts();
|
||||
use wordcraft_doc::math::*;
|
||||
let x = || vec![MNode::Run(MRun::new("x"))];
|
||||
let nodes = vec![
|
||||
@@ -217,6 +227,7 @@ fn every_structure_lays_out() {
|
||||
/// drawn from a fallback font, not as a missing-glyph box.
|
||||
#[test]
|
||||
fn characters_the_math_font_lacks_fall_back() {
|
||||
crate::test_fonts();
|
||||
let (face, _) = wordcraft_fonts::math::resolve_math("Cambria Math");
|
||||
let Some(c) = ['⨌', '⫿', '⟅', 'ア', '中', '가'].into_iter().find(|c| !face.covers(*c) && FontDb::global().fallback_for(*c, face.id()).is_some())
|
||||
else {
|
||||
@@ -243,6 +254,7 @@ fn characters_the_math_font_lacks_fall_back() {
|
||||
|
||||
#[test]
|
||||
fn caret_slots_cover_every_position() {
|
||||
crate::test_fonts();
|
||||
use wordcraft_doc::math_edit::{MathPos, move_right, units};
|
||||
let math = Math { nodes: wordcraft_doc::math::parse_linear("x=(-b±√(b^2-4ac))/2a+■(a&b@c&d)"), ..Default::default() };
|
||||
let m = layout_equation(&math, "", &rc(), true, 400.0, &MathProps::default(), &mut 0);
|
||||
@@ -285,6 +297,7 @@ fn an_arguments_extent_spans_its_caret_positions() {
|
||||
|
||||
#[test]
|
||||
fn empty_slots_show_placeholders_on_screen_only() {
|
||||
crate::test_fonts();
|
||||
let math = Math { nodes: wordcraft_doc::math::parse_linear("⬚/⬚"), ..Default::default() };
|
||||
let m = layout_equation(&math, "", &rc(), true, 400.0, &MathProps::default(), &mut 0);
|
||||
assert!(m.items.iter().all(|i| matches!(i, MItem::ScreenOnly(_) | MItem::Rect { .. })), "{:?}", m.items);
|
||||
@@ -295,6 +308,7 @@ fn empty_slots_show_placeholders_on_screen_only() {
|
||||
|
||||
#[test]
|
||||
fn numbered_equations_fill_the_line_with_the_number_at_the_right() {
|
||||
crate::test_fonts();
|
||||
let math = Math { nodes: wordcraft_doc::math::parse_linear("E=mc^2#(1)"), ..Default::default() };
|
||||
let m = layout_equation(&math, "", &rc(), true, 400.0, &MathProps::default(), &mut 0);
|
||||
assert!((m.width - 400.0).abs() < 0.01, "{}", m.width);
|
||||
@@ -313,6 +327,7 @@ fn numbered_equations_fill_the_line_with_the_number_at_the_right() {
|
||||
|
||||
#[test]
|
||||
fn math_alphabets() {
|
||||
crate::test_fonts();
|
||||
assert_eq!(math_alnum('x', false, true, MScr::Roman), '𝑥');
|
||||
assert_eq!(math_alnum('h', false, true, MScr::Roman), 'ℎ');
|
||||
assert_eq!(math_alnum('R', false, false, MScr::DoubleStruck), 'ℝ');
|
||||
@@ -362,6 +377,7 @@ fn eqarr_row_without_an_align_marker_wider_than_the_columns_widens_the_array() {
|
||||
|
||||
#[test]
|
||||
fn hostile_trees_never_panic() {
|
||||
crate::test_fonts();
|
||||
use wordcraft_doc::math::*;
|
||||
// Deep nesting.
|
||||
let mut n = vec![MNode::Run(MRun::new("x"))];
|
||||
|
||||
@@ -226,6 +226,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn show_level_and_collapse_hide_what_is_under_a_heading() {
|
||||
crate::test_fonts();
|
||||
// H1, body, H2, body, H1, body
|
||||
let levels = [1, BODY, 2, BODY, 1, BODY];
|
||||
let all = OutlineView::default();
|
||||
|
||||
@@ -376,6 +376,7 @@ mod label_spacing_tests {
|
||||
|
||||
#[test]
|
||||
fn fallback_label_ascent() {
|
||||
crate::test_fonts();
|
||||
let body = (1921.0 / 2048.0 * 11.0, 434.0 / 2048.0 * 11.0);
|
||||
let low = measured_label_lines(LineSpacing::Multiple(1.15), (9.802246, 2.379395), 9.802246, false, body);
|
||||
let tall = measured_label_lines(LineSpacing::Multiple(1.15), (11.0, 3.586), 11.0, false, body);
|
||||
@@ -385,6 +386,7 @@ mod label_spacing_tests {
|
||||
|
||||
#[test]
|
||||
fn large_label_overflow_is_not_multiplied() {
|
||||
crate::test_fonts();
|
||||
for (multiple, expected) in [(1.0, 25.20), (1.25, 28.80), (2.0, 39.36)] {
|
||||
let lines = measured_label_lines(LineSpacing::Multiple(multiple), (22.265625, 5.859375), 22.265625, false, ROBOTO_METRICS);
|
||||
assert!((lines[0].height - expected).abs() <= 0.25, "{multiple}: {} vs {expected}", lines[0].height);
|
||||
@@ -396,12 +398,14 @@ mod label_spacing_tests {
|
||||
|
||||
#[test]
|
||||
fn small_label_descent_does_not_inflate_text_spacing() {
|
||||
crate::test_fonts();
|
||||
let lines = measured_label_lines(LineSpacing::Multiple(1.25), (8.499023, 3.5009766), 9.0, false, ROBOTO_METRICS);
|
||||
assert!((lines[0].height - 17.52).abs() <= 0.25, "{}", lines[0].height);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_leading_and_earlier_font_ascent_are_retained() {
|
||||
crate::test_fonts();
|
||||
// Apple Symbols 24 pt: full leading gives an 18 pt label ascent. The text
|
||||
// pitch is still Roboto 12 pt. A shorter final fallback run must not hide it.
|
||||
for metrics in [(16.998047, 7.001953), (8.0, 3.0)] {
|
||||
@@ -412,6 +416,7 @@ mod label_spacing_tests {
|
||||
|
||||
#[test]
|
||||
fn minimum_spacing_keeps_text_descent_and_label_leading() {
|
||||
crate::test_fonts();
|
||||
for (metrics, ascent, expected) in
|
||||
[((22.265625, 5.859375), 22.265625, 25.20), ((8.499023, 3.5009766), 9.0, 13.92), ((16.998047, 7.001953), 18.0, 20.88)]
|
||||
{
|
||||
@@ -422,6 +427,7 @@ mod label_spacing_tests {
|
||||
|
||||
#[test]
|
||||
fn labels_keep_fixed_and_minimum_spacing_rules() {
|
||||
crate::test_fonts();
|
||||
for (spacing, expected) in [(LineSpacing::Exactly(24.0), 24.0), (LineSpacing::AtLeast(30.0), 30.0)] {
|
||||
let lines = measured_label_lines(spacing, (22.265625, 5.859375), 22.265625, false, ROBOTO_METRICS);
|
||||
assert!((lines[0].height - expected).abs() < 0.001);
|
||||
@@ -430,6 +436,7 @@ mod label_spacing_tests {
|
||||
|
||||
#[test]
|
||||
fn label_overflow_only_affects_the_first_wrapped_line() {
|
||||
crate::test_fonts();
|
||||
let lines = measured_label_lines(LineSpacing::Multiple(1.25), (22.265625, 5.859375), 22.265625, true, ROBOTO_METRICS);
|
||||
assert!(lines.len() > 1);
|
||||
assert!((lines[0].height - 28.80).abs() <= 0.25);
|
||||
@@ -461,6 +468,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn symbols_ignore_roman_hint() {
|
||||
crate::test_fonts();
|
||||
let mut d = Document::new();
|
||||
d.settings.roman_fonts = vec!["Symbol (PCL6)".into(), "Wingdings".into(), "No Such Roman Font".into()];
|
||||
for family in ["Symbol (PCL6)", "Wingdings"] {
|
||||
|
||||
@@ -645,6 +645,8 @@ fn shown_text(cx: &Cx) -> Option<String> {
|
||||
|
||||
/// Lay out one paragraph.
|
||||
pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
#[cfg(test)]
|
||||
crate::test_fonts();
|
||||
let doc = env.doc;
|
||||
let rp = paragraph_props(p, env);
|
||||
let res = Resolver { doc, style: p.props.style.as_deref(), table: env.table.map(|t| &t.chr) };
|
||||
|
||||
@@ -433,6 +433,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parses_names_levels_and_switches() {
|
||||
crate::test_fonts();
|
||||
let c = parse(r#"STYLEREF "Heading 1" \l \n \t \* MERGEFORMAT"#);
|
||||
assert_eq!(c.style, "Heading 1");
|
||||
assert!(c.last && c.suppress && !c.position);
|
||||
@@ -491,6 +492,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn suppress_keeps_the_number() {
|
||||
crate::test_fonts();
|
||||
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");
|
||||
|
||||
@@ -1586,6 +1586,7 @@ mod width_tests {
|
||||
|
||||
#[test]
|
||||
fn autofit_shares_donor_loss() {
|
||||
crate::test_fonts();
|
||||
let table = Table::new(1, 4, 260.0);
|
||||
let mut grid = [30.0, 120.0, 20.0, 90.0];
|
||||
let content = [(28.0, 28.0), (40.0, 120.0), (40.0, 40.0), (30.0, 90.0)];
|
||||
@@ -1595,6 +1596,7 @@ mod width_tests {
|
||||
|
||||
#[test]
|
||||
fn autofit_exhausts_donors() {
|
||||
crate::test_fonts();
|
||||
let table = Table::new(1, 2, 50.0);
|
||||
let mut grid = [30.0, 20.0];
|
||||
autofit(&mut grid, &[(25.0, 25.0), (40.0, 40.0)], &table, 50.0);
|
||||
@@ -1603,6 +1605,7 @@ mod width_tests {
|
||||
|
||||
#[test]
|
||||
fn autofit_keeps_fitting_widths() {
|
||||
crate::test_fonts();
|
||||
let table = Table::new(1, 3, 120.0);
|
||||
let mut grid = [30.0, 40.0, 50.0];
|
||||
autofit(&mut grid, &[(10.0, 10.0), (20.0, 20.0), (30.0, 30.0)], &table, 120.0);
|
||||
|
||||
+312
-2
File diff suppressed because it is too large.
Load diff
@@ -50,6 +50,7 @@ fn quarter(d: &Document) -> f32 {
|
||||
|
||||
#[test]
|
||||
fn digits_and_latin_beside_asian_text_get_a_quarter_of_the_font_size() {
|
||||
crate::test_fonts();
|
||||
let mut d = Document::from_text("法院94年A字");
|
||||
let q = quarter(&d);
|
||||
assert!(q > 0.0);
|
||||
@@ -65,6 +66,7 @@ fn digits_and_latin_beside_asian_text_get_a_quarter_of_the_font_size() {
|
||||
|
||||
#[test]
|
||||
fn the_space_crosses_runs_and_takes_the_asian_size() {
|
||||
crate::test_fonts();
|
||||
let mut d = Document::from_text("漢A漢");
|
||||
let size = |s: f32| move |c: &mut CharProps| c.size = Some(s);
|
||||
let p = para0(&mut d);
|
||||
@@ -76,6 +78,7 @@ fn the_space_crosses_runs_and_takes_the_asian_size() {
|
||||
|
||||
#[test]
|
||||
fn spaces_punctuation_hangul_and_latin_alone_get_nothing() {
|
||||
crate::test_fonts();
|
||||
for text in ["例 a", "5 例", "5、", "、8", "abc 123", "漢한", "한A", "漢%", "(A)"] {
|
||||
let d = Document::from_text(text);
|
||||
assert!(gaps(&d).iter().all(|g| *g == 0.0), "{text}: {:?}", gaps(&d));
|
||||
@@ -84,6 +87,7 @@ fn spaces_punctuation_hangul_and_latin_alone_get_nothing() {
|
||||
|
||||
#[test]
|
||||
fn a_field_result_is_classed_by_the_text_it_shows() {
|
||||
crate::test_fonts();
|
||||
let mut d = Document::from_text("漢漢");
|
||||
let field =
|
||||
InlineObject::Field { instr: "MERGEFIELD n".into(), result: "12".into(), locked: false, dirty: false, code: false, form: String::new() };
|
||||
@@ -99,6 +103,7 @@ fn a_field_result_is_classed_by_the_text_it_shows() {
|
||||
|
||||
#[test]
|
||||
fn a_character_grid_the_paragraph_snaps_to_turns_it_off() {
|
||||
crate::test_fonts();
|
||||
let with = |kind: DocGridType, snap: bool| {
|
||||
let mut d = Document::from_text("法院94年");
|
||||
d.last_section.doc_grid = Some(DocGrid { kind, line_pitch: 18.0, char_space: 0 });
|
||||
@@ -116,6 +121,7 @@ fn a_character_grid_the_paragraph_snaps_to_turns_it_off() {
|
||||
|
||||
#[test]
|
||||
fn the_space_takes_no_room_at_the_end_of_a_justified_line() {
|
||||
crate::test_fonts();
|
||||
let text = "abcdef 1漢漢漢";
|
||||
let mut d = Document::from_text(text);
|
||||
let pl = first_para(&lay(&d));
|
||||
|
||||
@@ -59,6 +59,7 @@ fn at(s: &str, n: usize) -> usize {
|
||||
|
||||
#[test]
|
||||
fn visual_order_follows_uax9_rule_l2() {
|
||||
crate::test_fonts();
|
||||
// The example from UAX #9 / unicode-bidi: levels 0 0 0 1 1 1 2 2.
|
||||
assert_eq!(visual_order(&[0, 0, 0, 1, 1, 1, 2, 2]), vec![0, 1, 2, 6, 7, 5, 4, 3]);
|
||||
assert_eq!(visual_order(&[1, 1, 1]), vec![2, 1, 0]);
|
||||
@@ -70,6 +71,7 @@ fn visual_order_follows_uax9_rule_l2() {
|
||||
|
||||
#[test]
|
||||
fn levels_for_persian_with_latin_and_numbers() {
|
||||
crate::test_fonts();
|
||||
assert!(bidi_levels("plain English", false).is_empty(), "pure LTR paragraphs skip the algorithm");
|
||||
let s = "سلام Word ۱۴۰۳";
|
||||
let lv = bidi_levels(s, true);
|
||||
@@ -85,6 +87,7 @@ fn levels_for_persian_with_latin_and_numbers() {
|
||||
|
||||
#[test]
|
||||
fn rtl_paragraph_is_right_aligned_and_reads_right_to_left() {
|
||||
crate::test_fonts();
|
||||
let s = "سلام دنیا";
|
||||
let d = doc_of(&[(s, true)]);
|
||||
let l = lay(&d);
|
||||
@@ -101,6 +104,7 @@ fn rtl_paragraph_is_right_aligned_and_reads_right_to_left() {
|
||||
|
||||
#[test]
|
||||
fn ltr_paragraph_with_embedded_persian_word() {
|
||||
crate::test_fonts();
|
||||
let s = "Hello سلام world";
|
||||
let d = doc_of(&[(s, false)]);
|
||||
let l = lay(&d);
|
||||
@@ -111,6 +115,7 @@ fn ltr_paragraph_with_embedded_persian_word() {
|
||||
|
||||
#[test]
|
||||
fn numbers_and_latin_inside_persian_keep_their_order() {
|
||||
crate::test_fonts();
|
||||
// "Price 250 toman" in Persian, with a Latin product name and Persian punctuation.
|
||||
let s = "قیمت 250 تومان برای Word، امسال ۱۴۰۳.";
|
||||
let d = doc_of(&[(s, true)]);
|
||||
@@ -132,6 +137,7 @@ fn numbers_and_latin_inside_persian_keep_their_order() {
|
||||
|
||||
#[test]
|
||||
fn caret_and_hit_agree_in_bidi_text() {
|
||||
crate::test_fonts();
|
||||
for (s, rtl) in [("سلام world ۱۲۳", true), ("Hello سلام دنیا end", false), ("کتاب «WordCraft» (نسخه ۲)", true)] {
|
||||
let d = doc_of(&[(s, rtl)]);
|
||||
let l = lay(&d);
|
||||
@@ -152,6 +158,7 @@ fn caret_and_hit_agree_in_bidi_text() {
|
||||
|
||||
#[test]
|
||||
fn visual_steps_cover_the_line_in_order() {
|
||||
crate::test_fonts();
|
||||
let s = "سلام world";
|
||||
let d = doc_of(&[(s, true)]);
|
||||
let l = lay(&d);
|
||||
@@ -182,6 +189,7 @@ fn visual_steps_cover_the_line_in_order() {
|
||||
|
||||
#[test]
|
||||
fn selection_of_mixed_text_can_be_two_pieces() {
|
||||
crate::test_fonts();
|
||||
// Logical range "lo wo" of "Hello سلام world"? Select across the Persian word's start:
|
||||
// "lo سل" is two visual pieces (the end of Hello, and the right part of سلام).
|
||||
let s = "Hello سلام world";
|
||||
@@ -196,6 +204,7 @@ fn selection_of_mixed_text_can_be_two_pieces() {
|
||||
|
||||
#[test]
|
||||
fn rtl_indents_and_alignment_are_measured_from_the_right() {
|
||||
crate::test_fonts();
|
||||
let s = "متن فارسی";
|
||||
let mut d = doc_of(&[(s, true), (s, true), (s, true)]);
|
||||
let p0 = Pos::body(0, 0);
|
||||
@@ -214,6 +223,7 @@ fn rtl_indents_and_alignment_are_measured_from_the_right() {
|
||||
|
||||
#[test]
|
||||
fn rtl_list_label_sits_on_the_right() {
|
||||
crate::test_fonts();
|
||||
let mut d = doc_of(&[("مورد اول", true), ("مورد دوم", true)]);
|
||||
let id = Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Numbered);
|
||||
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(1, 0), &|p: &mut ParaProps| {
|
||||
@@ -235,6 +245,7 @@ fn rtl_list_label_sits_on_the_right() {
|
||||
|
||||
#[test]
|
||||
fn rtl_empty_and_trailing_lines_mirror() {
|
||||
crate::test_fonts();
|
||||
let d = doc_of(&[("", true)]);
|
||||
let l = lay(&d);
|
||||
assert!((caret_x(&l, 0, 0) - 540.0).abs() < 0.5, "empty right-to-left paragraph: caret at the right margin");
|
||||
@@ -248,6 +259,7 @@ fn rtl_empty_and_trailing_lines_mirror() {
|
||||
|
||||
#[test]
|
||||
fn bidi_display_draws_every_glyph_inside_the_line() {
|
||||
crate::test_fonts();
|
||||
let s = "این یک متن آزمایشی با English و عدد ۱۲۳ است. ".repeat(12);
|
||||
let d = doc_of(&[(&s, true)]);
|
||||
let l = lay(&d);
|
||||
@@ -274,6 +286,7 @@ fn bidi_display_draws_every_glyph_inside_the_line() {
|
||||
|
||||
#[test]
|
||||
fn hostile_bidi_text_never_panics() {
|
||||
crate::test_fonts();
|
||||
// Explicit embeddings, isolates, unmatched pops, marks with no base, lone surrogates' stand-ins.
|
||||
let junk = [
|
||||
"\u{202B}\u{202B}\u{202C}\u{202C}\u{202C}abc\u{2067}سلام\u{2069}\u{2069}",
|
||||
@@ -301,6 +314,7 @@ fn hostile_bidi_text_never_panics() {
|
||||
|
||||
/// A face that has Arabic letters (installed system fonts), or `None` to skip.
|
||||
fn arabic_face() -> Option<wordcraft_fonts::FaceRef> {
|
||||
crate::test_fonts();
|
||||
wordcraft_fonts::FontDb::global().fallback_for('ب', 0).map(|f| wordcraft_fonts::FaceRef::of(&f)).filter(|f| f.covers('پ'))
|
||||
}
|
||||
|
||||
@@ -319,6 +333,7 @@ fn cluster_glyphs(glyphs: &[wordcraft_fonts::ShapedGlyph]) -> Vec<Vec<u32>> {
|
||||
|
||||
#[test]
|
||||
fn persian_letters_join_and_brackets_mirror() {
|
||||
crate::test_fonts();
|
||||
let Some(face) = arabic_face() else {
|
||||
eprintln!("skipped: no font with Persian letters installed");
|
||||
return;
|
||||
@@ -355,6 +370,7 @@ fn persian_letters_join_and_brackets_mirror() {
|
||||
|
||||
#[test]
|
||||
fn persian_text_in_a_latin_font_stays_in_one_fallback_face() {
|
||||
crate::test_fonts();
|
||||
if arabic_face().is_none() {
|
||||
eprintln!("skipped: no font with Persian letters installed");
|
||||
return;
|
||||
@@ -373,6 +389,7 @@ fn persian_text_in_a_latin_font_stays_in_one_fallback_face() {
|
||||
|
||||
#[test]
|
||||
fn glyph_runs_carry_their_text_for_pdf_export() {
|
||||
crate::test_fonts();
|
||||
let s = "سلام WordCraft میخواهم";
|
||||
let d = doc_of(&[(s, true)]);
|
||||
let l = lay(&d);
|
||||
@@ -402,6 +419,7 @@ fn glyph_runs_carry_their_text_for_pdf_export() {
|
||||
|
||||
#[test]
|
||||
fn character_border_boxes_a_whole_rtl_word() {
|
||||
crate::test_fonts();
|
||||
let s = "کتاب خوب";
|
||||
let mut d = doc_of(&[(s, true)]);
|
||||
let b = wordcraft_doc::props::Border { space: 0.0, ..wordcraft_doc::props::Border::single(0.5) };
|
||||
@@ -426,6 +444,7 @@ fn character_border_boxes_a_whole_rtl_word() {
|
||||
|
||||
#[test]
|
||||
fn inline_picture_in_rtl_text_sits_in_its_place() {
|
||||
crate::test_fonts();
|
||||
// "متن [picture] بیشتر": in a right-to-left paragraph the picture is drawn where its cluster
|
||||
// is on the line (left of the first word), not at its logical x.
|
||||
let s = "متن بیشتر";
|
||||
@@ -464,6 +483,7 @@ fn inline_picture_in_rtl_text_sits_in_its_place() {
|
||||
/// margin and border are drawn on its right. Clicks find cells by where they are drawn.
|
||||
#[test]
|
||||
fn rtl_table_lays_its_columns_out_right_to_left() {
|
||||
crate::test_fonts();
|
||||
use wordcraft_doc::props::{Border, Borders};
|
||||
use wordcraft_doc::{Path, Table};
|
||||
let mut d = Document::from_text("before\nafter");
|
||||
@@ -511,6 +531,7 @@ fn rtl_table_lays_its_columns_out_right_to_left() {
|
||||
/// first column at the right, and the line between them stays midway.
|
||||
#[test]
|
||||
fn rtl_section_puts_its_first_column_on_the_right() {
|
||||
crate::test_fonts();
|
||||
use wordcraft_doc::section::Columns;
|
||||
let text = "Columns flow from the right edge in a right-to-left section. ".repeat(150);
|
||||
let mut d = Document::from_text(&text);
|
||||
@@ -531,6 +552,7 @@ fn rtl_section_puts_its_first_column_on_the_right() {
|
||||
|
||||
#[test]
|
||||
fn list_label_moves_with_its_text_when_the_paragraph_is_aligned() {
|
||||
crate::test_fonts();
|
||||
// Issue #1159: centring or end-aligning a list item moved the text and left the bullet at the
|
||||
// margin. In Word the label stays attached to the start of the text, in both directions.
|
||||
for (rtl, text) in [(false, "alpha beta"), (true, "مورد اول")] {
|
||||
|
||||
@@ -29,6 +29,7 @@ const MYANMAR: &str = "\u{1019}\u{103C}\u{1014}\u{103A}\u{1019}\u{102C}\u{1005}\
|
||||
|
||||
#[test]
|
||||
fn myanmar_syllables_are_single_clusters() {
|
||||
crate::test_fonts();
|
||||
let l = lay(&Document::from_text(MYANMAR));
|
||||
let pl = para_layout(&l, 0);
|
||||
let starts: Vec<usize> = pl.clusters.iter().map(|c| c.start).collect();
|
||||
@@ -46,6 +47,7 @@ fn myanmar_syllables_are_single_clusters() {
|
||||
|
||||
#[test]
|
||||
fn myanmar_without_spaces_wraps_between_syllables() {
|
||||
crate::test_fonts();
|
||||
let text = MYANMAR.repeat(80);
|
||||
let l = lay(&Document::from_text(&text));
|
||||
let pl = para_layout(&l, 0);
|
||||
@@ -66,11 +68,13 @@ fn myanmar_without_spaces_wraps_between_syllables() {
|
||||
|
||||
/// A face with Myanmar letters (an installed system font), or `None` to skip.
|
||||
fn myanmar_face() -> Option<wordcraft_fonts::FaceRef> {
|
||||
crate::test_fonts();
|
||||
wordcraft_fonts::FontDb::global().fallback_for('\u{1000}', 0).map(|f| wordcraft_fonts::FaceRef::of(&f)).filter(|f| f.has_layout_tables())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn myanmar_prevowel_is_drawn_before_its_consonant() {
|
||||
crate::test_fonts();
|
||||
let Some(face) = myanmar_face() else {
|
||||
eprintln!("skipped: no Myanmar font with OpenType tables installed");
|
||||
return;
|
||||
@@ -92,6 +96,7 @@ fn myanmar_prevowel_is_drawn_before_its_consonant() {
|
||||
|
||||
#[test]
|
||||
fn myanmar_in_a_latin_font_falls_back_to_a_regular_myanmar_face() {
|
||||
crate::test_fonts();
|
||||
if myanmar_face().is_none() {
|
||||
eprintln!("skipped: no Myanmar font with OpenType tables installed");
|
||||
return;
|
||||
@@ -115,12 +120,14 @@ const THAI_RUN: &str = "[D] น้ำ ค่ำ ปั้น ฝั่ง ปู
|
||||
|
||||
/// Is a font with Thai letters installed? `false` skips the test.
|
||||
fn thai_font_installed() -> bool {
|
||||
crate::test_fonts();
|
||||
wordcraft_fonts::FontDb::global().fallback_for('\u{0E19}', 0).is_some()
|
||||
}
|
||||
|
||||
/// Is a Thai font with Latin letters and digits installed (Tahoma, Leelawadee UI, Thonburi, the
|
||||
/// TLWG fonts…)? Noto Sans Thai alone has none, so no face covers [`THAI_RUN`]: `false` skips.
|
||||
fn thai_latin_font_installed() -> bool {
|
||||
crate::test_fonts();
|
||||
let face = wordcraft_fonts::word::resolve("TH Sarabun New", false, false).face;
|
||||
THAI_RUN.chars().filter(|c| !c.is_whitespace()).all(|c| face.covers(c))
|
||||
}
|
||||
@@ -139,6 +146,7 @@ fn cluster_faces(text: &str, font: Option<&str>) -> Vec<(usize, wordcraft_fonts:
|
||||
|
||||
#[test]
|
||||
fn a_missing_thai_font_draws_the_whole_run_in_one_thai_face() {
|
||||
crate::test_fonts();
|
||||
if !thai_latin_font_installed() {
|
||||
eprintln!("skipped: no Thai font with Latin letters installed");
|
||||
return;
|
||||
@@ -161,6 +169,7 @@ fn a_missing_thai_font_draws_the_whole_run_in_one_thai_face() {
|
||||
|
||||
#[test]
|
||||
fn a_thai_substitute_without_latin_hands_the_run_to_one_face_covering_it() {
|
||||
crate::test_fonts();
|
||||
if !thai_latin_font_installed() {
|
||||
eprintln!("skipped: no Thai font with Latin letters installed");
|
||||
return;
|
||||
@@ -184,6 +193,7 @@ fn a_thai_substitute_without_latin_hands_the_run_to_one_face_covering_it() {
|
||||
|
||||
#[test]
|
||||
fn latin_text_in_a_missing_latin_font_stays_in_its_substitute_beside_thai() {
|
||||
crate::test_fonts();
|
||||
if !thai_font_installed() {
|
||||
eprintln!("skipped: no Thai font installed");
|
||||
return;
|
||||
@@ -215,6 +225,7 @@ fn latin_text_in_a_missing_latin_font_stays_in_its_substitute_beside_thai() {
|
||||
|
||||
#[test]
|
||||
fn thai_in_an_installed_latin_font_keeps_the_latin_face_for_latin() {
|
||||
crate::test_fonts();
|
||||
if !thai_font_installed() {
|
||||
eprintln!("skipped: no Thai font installed");
|
||||
return;
|
||||
@@ -230,6 +241,7 @@ fn thai_in_an_installed_latin_font_keeps_the_latin_face_for_latin() {
|
||||
|
||||
#[test]
|
||||
fn a_missing_latin_font_with_a_thai_complex_script_font_keeps_both() {
|
||||
crate::test_fonts();
|
||||
if !thai_font_installed() {
|
||||
eprintln!("skipped: no Thai font installed");
|
||||
return;
|
||||
@@ -251,6 +263,7 @@ fn a_missing_latin_font_with_a_thai_complex_script_font_keeps_both() {
|
||||
|
||||
#[test]
|
||||
fn a_missing_roman_thai_font_is_not_drawn_in_cambria() {
|
||||
crate::test_fonts();
|
||||
// Word draws a missing font the font table classes as roman in Cambria, but a missing Thai
|
||||
// font keeps its Thai substitute: the same face whether or not the table calls it roman.
|
||||
let db = wordcraft_fonts::FontDb::global();
|
||||
|
||||
@@ -42,6 +42,7 @@ fn lines(l: &DocLayout) -> (usize, Vec<f32>) {
|
||||
|
||||
#[test]
|
||||
fn line_grid_snaps_line_heights_and_fixes_lines_per_page() {
|
||||
crate::test_fonts();
|
||||
let text = vec!["Grid line"; 80].join("\n");
|
||||
let d = with_grid(&text, DocGridType::Lines, 0);
|
||||
let (first, hs) = lines(&lay(&d));
|
||||
@@ -65,6 +66,7 @@ fn line_grid_snaps_line_heights_and_fixes_lines_per_page() {
|
||||
|
||||
#[test]
|
||||
fn character_grid_places_east_asian_text_on_the_pitch() {
|
||||
crate::test_fonts();
|
||||
let text = "日本語の文書abc";
|
||||
// The default font size plus 2.5 pt: a pitch unlike any font's advance.
|
||||
let lines = wordcraft_doc::SectionProps { doc_grid: Some(DocGrid { kind: DocGridType::Lines, ..Default::default() }), ..Default::default() };
|
||||
|
||||
@@ -44,6 +44,7 @@ fn has_fi_ligature(l: &DocLayout) -> bool {
|
||||
|
||||
#[test]
|
||||
fn caret_between_the_letters_of_a_ligature_is_inside_it() {
|
||||
crate::test_fonts();
|
||||
let l = lay(&doc("fi"));
|
||||
assert!(has_fi_ligature(&l), "the default font should draw fi as a ligature");
|
||||
let (a, b, c) = (caret_x(&l, 0), caret_x(&l, 1), caret_x(&l, 2));
|
||||
@@ -62,6 +63,7 @@ fn fi_width_with_spacing(sp: Option<f32>) -> (DocLayout, f32) {
|
||||
|
||||
#[test]
|
||||
fn character_spacing_inside_a_ligature_is_kept() {
|
||||
crate::test_fonts();
|
||||
let (l0, plain) = fi_width_with_spacing(None);
|
||||
assert!(has_fi_ligature(&l0), "the default font should draw fi as a ligature");
|
||||
for sp in [3.0_f32, -0.5] {
|
||||
@@ -75,6 +77,7 @@ fn character_spacing_inside_a_ligature_is_kept() {
|
||||
|
||||
#[test]
|
||||
fn clicks_inside_a_ligature_land_between_its_letters() {
|
||||
crate::test_fonts();
|
||||
let l = lay(&doc("fi"));
|
||||
let (a, b, c) = (caret_x(&l, 0), caret_x(&l, 1), caret_x(&l, 2));
|
||||
let cr = l.caret(&Pos::body(0, 1)).unwrap();
|
||||
@@ -88,6 +91,7 @@ fn clicks_inside_a_ligature_land_between_its_letters() {
|
||||
|
||||
#[test]
|
||||
fn every_letter_of_a_three_letter_ligature_has_its_own_caret() {
|
||||
crate::test_fonts();
|
||||
let l = lay(&doc("ffi"));
|
||||
assert!(has_fi_ligature(&l), "the default font should draw ffi as a ligature");
|
||||
let xs: Vec<f32> = (0..=3).map(|o| caret_x(&l, o)).collect();
|
||||
@@ -96,6 +100,7 @@ fn every_letter_of_a_three_letter_ligature_has_its_own_caret() {
|
||||
|
||||
#[test]
|
||||
fn a_combining_mark_stays_in_its_letters_cluster() {
|
||||
crate::test_fonts();
|
||||
let l = lay(&doc("fi\u{301}"));
|
||||
let pl = para_layout(&l);
|
||||
let offs = pl.line_offsets(0);
|
||||
@@ -104,6 +109,7 @@ fn a_combining_mark_stays_in_its_letters_cluster() {
|
||||
|
||||
#[test]
|
||||
fn selection_edges_inside_a_ligature_use_the_same_positions() {
|
||||
crate::test_fonts();
|
||||
let d = doc("fi");
|
||||
let l = lay(&d);
|
||||
let rects = l.selection_rects(&d, &Pos::body(0, 0), &Pos::body(0, 1), 0);
|
||||
@@ -134,6 +140,7 @@ fn lam_alef_ligature() -> Option<DocLayout> {
|
||||
|
||||
#[test]
|
||||
fn a_right_to_left_ligature_splits_mirrored() {
|
||||
crate::test_fonts();
|
||||
let Some(l) = lam_alef_ligature() else {
|
||||
eprintln!("skipped: no lam-alef ligature font");
|
||||
return;
|
||||
@@ -146,6 +153,7 @@ fn a_right_to_left_ligature_splits_mirrored() {
|
||||
|
||||
#[test]
|
||||
fn a_right_to_left_ligature_is_drawn_from_its_left_edge() {
|
||||
crate::test_fonts();
|
||||
// The glyph stays on lam, the rightmost letter; it must still start at alef's left edge.
|
||||
let Some(l) = lam_alef_ligature() else {
|
||||
eprintln!("skipped: no lam-alef ligature font");
|
||||
@@ -162,6 +170,7 @@ fn a_right_to_left_ligature_is_drawn_from_its_left_edge() {
|
||||
|
||||
#[test]
|
||||
fn an_emoji_zwj_sequence_stays_one_cluster() {
|
||||
crate::test_fonts();
|
||||
// 👩💻: woman, zero-width joiner, laptop — one grapheme, one caret stop on each side.
|
||||
let t = "a\u{1F469}\u{200D}\u{1F4BB}b";
|
||||
let l = lay(&Document::from_text(t));
|
||||
@@ -269,6 +278,7 @@ fn a_mixed_bundled_font_combining_grapheme_preserves_drawing_and_atomic_boundari
|
||||
|
||||
#[test]
|
||||
fn an_indic_conjunct_stays_one_cluster() {
|
||||
crate::test_fonts();
|
||||
// क्ष: ka, virama, ssa — one conjunct; the caret never stops inside it.
|
||||
let l = lay(&Document::from_text("\u{915}\u{94D}\u{937}"));
|
||||
let offs = para_layout(&l).line_offsets(0);
|
||||
@@ -277,6 +287,7 @@ fn an_indic_conjunct_stays_one_cluster() {
|
||||
|
||||
#[test]
|
||||
fn a_long_word_never_wraps_inside_a_ligature() {
|
||||
crate::test_fonts();
|
||||
// A narrow page forces an emergency break in the unbroken word; whatever the offset, a line
|
||||
// never ends between a ligature's glyph and its glyphless letters.
|
||||
let mut seen_ligature = false;
|
||||
@@ -299,6 +310,7 @@ fn a_long_word_never_wraps_inside_a_ligature() {
|
||||
|
||||
#[test]
|
||||
fn a_hyphenation_point_never_splits_a_ligature() {
|
||||
crate::test_fonts();
|
||||
// Soft hyphens sit between the letters of "ffi"; a line never ends between a ligature's glyph
|
||||
// and its glyphless letters, whether or not the soft hyphen leaves the ligature intact.
|
||||
for k in 0..24 {
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::{Document, LayoutCache, LayoutOptions, Placed, layout};
|
||||
/// Inter with its U+00A0 glyph twice as wide as its space, under the family name "Wnbsp".
|
||||
/// Built from the bundled font so the test needs no installed font.
|
||||
fn wide_nbsp_family() -> &'static str {
|
||||
crate::test_fonts();
|
||||
const FAMILY: &str = "Wnbsp";
|
||||
let inter = wordcraft_fonts::word::resolve("Inter", false, false).face;
|
||||
let mut data = inter.data().to_vec();
|
||||
@@ -54,6 +55,7 @@ fn line_width(text: &str) -> f32 {
|
||||
|
||||
#[test]
|
||||
fn a_no_break_space_is_as_wide_as_a_space() {
|
||||
crate::test_fonts();
|
||||
let face = wordcraft_fonts::word::resolve(wide_nbsp_family(), false, false).face;
|
||||
assert_eq!(face.family, wide_nbsp_family(), "the test font is installed under its own name");
|
||||
assert!(
|
||||
|
||||
@@ -65,6 +65,7 @@ fn gids_pair(text: &str, a: (bool, NumeralMode), b: (bool, NumeralMode)) -> (Vec
|
||||
|
||||
#[test]
|
||||
fn parse_accepts_the_four_modes_any_case() {
|
||||
crate::test_fonts();
|
||||
assert_eq!(NumeralMode::parse("arabic"), Some(NumeralMode::Arabic));
|
||||
assert_eq!(NumeralMode::parse("hindi"), Some(NumeralMode::Hindi));
|
||||
assert_eq!(NumeralMode::parse("context"), Some(NumeralMode::Context));
|
||||
@@ -78,6 +79,7 @@ fn parse_accepts_the_four_modes_any_case() {
|
||||
|
||||
#[test]
|
||||
fn map_substitutes_ascii_digits_only() {
|
||||
crate::test_fonts();
|
||||
for d in '0'..='9' {
|
||||
let hindi = char::from_u32(0x660 + (d as u32 - '0' as u32)).unwrap();
|
||||
assert_eq!(NumeralMode::Hindi.map(d, false), hindi);
|
||||
@@ -101,6 +103,7 @@ fn map_substitutes_ascii_digits_only() {
|
||||
|
||||
#[test]
|
||||
fn resolve_uses_the_given_system_locale() {
|
||||
crate::test_fonts();
|
||||
for m in [NumeralMode::Arabic, NumeralMode::Hindi, NumeralMode::Context] {
|
||||
assert_eq!(m.resolve(Some("ar-EG")), m, "only System is resolved");
|
||||
}
|
||||
@@ -115,6 +118,7 @@ fn resolve_uses_the_given_system_locale() {
|
||||
|
||||
#[test]
|
||||
fn context_mode_on_a_very_long_digit_run_stays_linear() {
|
||||
crate::test_fonts();
|
||||
// Every digit used to scan back over all the digits and spaces before it (O(n²)).
|
||||
let text = format!("سنة {}", "1 ".repeat(100_000));
|
||||
let start = std::time::Instant::now();
|
||||
@@ -129,6 +133,7 @@ fn context_mode_on_a_very_long_digit_run_stays_linear() {
|
||||
|
||||
#[test]
|
||||
fn hindi_mode_reshapes_digits_but_keeps_offsets() {
|
||||
crate::test_fonts();
|
||||
let text = "Year 2024!";
|
||||
let latin = lay_opts(text, false, NumeralMode::Arabic);
|
||||
let hindi = lay_opts(text, false, NumeralMode::Hindi);
|
||||
@@ -150,6 +155,7 @@ fn hindi_mode_reshapes_digits_but_keeps_offsets() {
|
||||
|
||||
#[test]
|
||||
fn arabic_mode_keeps_typed_hindi_digits() {
|
||||
crate::test_fonts();
|
||||
let text = "عام ٢٠٢٤";
|
||||
let (hindi, arabic) = gids_pair(text, (true, NumeralMode::Hindi), (true, NumeralMode::Arabic));
|
||||
assert_eq!(hindi, arabic, "typed Hindi digits display as typed in Arabic mode");
|
||||
@@ -157,6 +163,7 @@ fn arabic_mode_keeps_typed_hindi_digits() {
|
||||
|
||||
#[test]
|
||||
fn context_mode_follows_run_direction() {
|
||||
crate::test_fonts();
|
||||
// Right-to-left paragraph: digits behave like Hindi mode.
|
||||
let rtl = "سنة 2024";
|
||||
let (ctx, hin) = gids_pair(rtl, (true, NumeralMode::Context), (true, NumeralMode::Hindi));
|
||||
@@ -173,6 +180,7 @@ fn context_mode_follows_run_direction() {
|
||||
|
||||
#[test]
|
||||
fn remap_range_maps_display_bytes_to_original() {
|
||||
crate::test_fonts();
|
||||
use crate::para::remap_range;
|
||||
// "a5ب": '5' (1 byte) displays as '٥' (2 bytes), so display offsets shift after it.
|
||||
let orig = "a5ب";
|
||||
|
||||
@@ -35,6 +35,7 @@ fn body_left(l: &DocLayout) -> Vec<f32> {
|
||||
|
||||
#[test]
|
||||
fn mirrored_margins_put_the_inside_margin_toward_the_binding() {
|
||||
crate::test_fonts();
|
||||
// #452: inside 108 (plus a 10pt gutter), outside 36. Odd pages: inside on the left; even
|
||||
// pages: on the right.
|
||||
let mut d = Document::from_text(&"Body line\n".repeat(90));
|
||||
@@ -55,6 +56,7 @@ fn mirrored_margins_put_the_inside_margin_toward_the_binding() {
|
||||
|
||||
#[test]
|
||||
fn inside_and_outside_floats_swap_sides_on_even_pages() {
|
||||
crate::test_fonts();
|
||||
// #1181: shapes Word places in the outside margin of a book (mirrored margins) all came out
|
||||
// in the right margin. Inside 108 plus a 10pt gutter, outside 36; the paragraph on page 2
|
||||
// carries the same six shapes as page 1's, told apart by their widths.
|
||||
@@ -124,6 +126,7 @@ fn inside_and_outside_floats_swap_sides_on_even_pages() {
|
||||
|
||||
#[test]
|
||||
fn outside_floating_tables_swap_sides_on_even_pages() {
|
||||
crate::test_fonts();
|
||||
// #1181, for a floating table (`w:tblpXSpec="outside"`): on page 2 of a book the outside is
|
||||
// the left. One positioned from the page edge stays there on both pages.
|
||||
use wordcraft_doc::para::{Anchor, FloatAlign};
|
||||
@@ -159,6 +162,7 @@ fn outside_floating_tables_swap_sides_on_even_pages() {
|
||||
|
||||
#[test]
|
||||
fn a_tall_footer_pushes_the_body_up() {
|
||||
crate::test_fonts();
|
||||
// #453: an eight-line footer reaches far above the 72pt bottom margin.
|
||||
let mut d = Document::from_text(&"Body line\n".repeat(60));
|
||||
let id = d.add_part(PartKind::Footer, paras("Footer line", 8));
|
||||
@@ -184,6 +188,7 @@ fn a_tall_footer_pushes_the_body_up() {
|
||||
|
||||
#[test]
|
||||
fn a_linked_tall_header_pushes_the_next_sections_body_down() {
|
||||
crate::test_fonts();
|
||||
// #454: section 2 has no header of its own and shows section 1's (link to previous).
|
||||
let mut d = Document::from_text("First section\nSecond section");
|
||||
let id = d.add_part(PartKind::Header, paras("Header line", 8));
|
||||
@@ -203,6 +208,7 @@ fn a_linked_tall_header_pushes_the_next_sections_body_down() {
|
||||
|
||||
#[test]
|
||||
fn long_endnotes_continue_on_new_pages() {
|
||||
crate::test_fonts();
|
||||
// #456: an 80-paragraph endnote after a one-line body.
|
||||
let mut d = Document::from_text("Body");
|
||||
let id = d.add_part(PartKind::Endnote, paras("Endnote paragraph", 80));
|
||||
@@ -230,6 +236,7 @@ fn long_endnotes_continue_on_new_pages() {
|
||||
|
||||
#[test]
|
||||
fn caret_in_a_continued_footnote_is_found_on_its_page() {
|
||||
crate::test_fonts();
|
||||
// #457: one long footnote paragraph split over pages 1 and 2.
|
||||
let mut d = Document::from_text(&"Body text line.\n".repeat(16));
|
||||
let id = d.add_part(
|
||||
@@ -264,6 +271,7 @@ fn caret_in_a_continued_footnote_is_found_on_its_page() {
|
||||
/// own pages). The footer shows each letter's last page, not the file's stale result.
|
||||
#[test]
|
||||
fn page_references_in_footers_show_their_bookmarks_page() {
|
||||
crate::test_fonts();
|
||||
let field =
|
||||
|instr: &str| InlineObject::Field { instr: instr.into(), result: "9".into(), locked: false, dirty: false, code: false, form: String::new() };
|
||||
let footer = |d: &mut Document, mark: &str| {
|
||||
@@ -379,6 +387,7 @@ fn mark(d: &mut Document, path: &Path, off: usize, name: &str) {
|
||||
/// section numbered in roman numerals are found on the page and in the format they land in.
|
||||
#[test]
|
||||
fn page_references_find_bookmarks_in_split_paragraphs_tables_and_roman_sections() {
|
||||
crate::test_fonts();
|
||||
// One long paragraph over two pages, marked at its start and its end.
|
||||
let mut d = Document::from_text(&"Long paragraph text running on. ".repeat(160));
|
||||
let n = d.para(StoryRef::Body, &Path::top(0)).unwrap().len();
|
||||
@@ -423,6 +432,7 @@ fn page_references_find_bookmarks_in_split_paragraphs_tables_and_roman_sections(
|
||||
/// A text box in a footer resolves its page references; one in the body keeps its result.
|
||||
#[test]
|
||||
fn page_references_in_footer_text_boxes_are_resolved() {
|
||||
crate::test_fonts();
|
||||
use wordcraft_doc::para::{Float, ShapeKind, Wrap};
|
||||
let text_box = |d: &mut Document| {
|
||||
let mut p = Paragraph::new();
|
||||
@@ -475,9 +485,11 @@ fn page_references_in_footer_text_boxes_are_resolved() {
|
||||
|
||||
#[test]
|
||||
fn widow_control_moves_a_three_line_paragraph_whole() {
|
||||
crate::test_fonts();
|
||||
// A three-line paragraph with room for two at the bottom of a page can't leave a widow (two
|
||||
// and one) nor an orphan (one and two): Word moves it whole. A four-line one splits two and
|
||||
// two. Sweep the paragraph down the page a point at a time and check every split.
|
||||
// Shift the filler immediately before the probe, so the sweep tests partial-line room.
|
||||
let mut split4 = false;
|
||||
for (want, words) in [(3usize, 45usize), (4, 55)] {
|
||||
for (fill, shift) in (30usize..60).flat_map(|f| (0..15).step_by(2).map(move |s| (f, s))) {
|
||||
@@ -508,6 +520,7 @@ fn widow_control_moves_a_three_line_paragraph_whole() {
|
||||
|
||||
#[test]
|
||||
fn footer_border_limits_body() {
|
||||
crate::test_fonts();
|
||||
use wordcraft_doc::props::{Border, Borders, HeightRule};
|
||||
let mut d = Document::from_text(&"Body line\n".repeat(40));
|
||||
d.last_section.page_h = 400.0;
|
||||
@@ -537,6 +550,7 @@ fn footer_border_limits_body() {
|
||||
/// number format is known, isn't taken for the page's own footer.
|
||||
#[test]
|
||||
fn a_roman_page_number_in_the_footer_stays_roman_with_the_page_count_in_the_body() {
|
||||
crate::test_fonts();
|
||||
let field = |instr: &str| InlineObject::Field {
|
||||
instr: instr.into(),
|
||||
result: String::new(),
|
||||
|
||||
@@ -52,6 +52,7 @@ const CHAPTERS: &[(&str, &str, bool)] = &[
|
||||
|
||||
#[test]
|
||||
fn each_page_s_header_shows_its_chapter() {
|
||||
crate::test_fonts();
|
||||
let d = doc(CHAPTERS, &[r#"STYLEREF "Heading 1" \* MERGEFORMAT"#, "STYLEREF 1", "STYLEREF heading1 \\l"]);
|
||||
let h = headers(&d);
|
||||
assert_eq!(h.len(), 4);
|
||||
@@ -62,6 +63,7 @@ fn each_page_s_header_shows_its_chapter() {
|
||||
|
||||
#[test]
|
||||
fn search_from_the_bottom_with_l() {
|
||||
crate::test_fonts();
|
||||
let paras = [("Heading2", "First", false), ("Heading2", "Second", false), ("Normal", "Body", false), ("Heading2", "Third", true)];
|
||||
let d = doc(¶s, &[r#"STYLEREF "Heading 2""#, r#"STYLEREF "Heading 2" \l"#]);
|
||||
assert_eq!(headers(&d), [vec!["First", "Second"], vec!["Third", "Third"]]);
|
||||
@@ -69,6 +71,7 @@ fn search_from_the_bottom_with_l() {
|
||||
|
||||
#[test]
|
||||
fn a_missing_style_or_no_paragraph_of_it_is_an_error() {
|
||||
crate::test_fonts();
|
||||
let d = doc(CHAPTERS, &[r#"STYLEREF "No Such Style""#, r#"STYLEREF "Heading 3""#, "STYLEREF 12"]);
|
||||
let err = styleref::NO_TEXT;
|
||||
assert_eq!(headers(&d)[0], [err, err, err]);
|
||||
@@ -77,6 +80,7 @@ fn a_missing_style_or_no_paragraph_of_it_is_an_error() {
|
||||
|
||||
#[test]
|
||||
fn before_the_first_heading_the_next_one_shows() {
|
||||
crate::test_fonts();
|
||||
let paras = [("Normal", "Preface", false), ("Heading1", "Chapter", true)];
|
||||
let d = doc(¶s, &[r#"STYLEREF "Heading 1""#, r#"STYLEREF "Heading 1" \p"#]);
|
||||
assert_eq!(headers(&d), [vec!["Chapter", "below"], vec!["Chapter", "below"]]);
|
||||
@@ -84,6 +88,7 @@ fn before_the_first_heading_the_next_one_shows() {
|
||||
|
||||
#[test]
|
||||
fn character_styles_and_paragraph_numbers() {
|
||||
crate::test_fonts();
|
||||
let mut d = doc(CHAPTERS, &[r#"STYLEREF Strong"#, r#"STYLEREF "Heading 1" \n"#, r#"STYLEREF "Heading 2" \w"#, r#"STYLEREF "Heading 1" \s"#]);
|
||||
// Bold a word on page 2 with the Strong character style.
|
||||
d.format_range(&Pos::body(3, 8), &Pos::body(3, 11), &|c: &mut CharProps| c.style = Some("Strong".into())).unwrap();
|
||||
@@ -102,6 +107,7 @@ fn character_styles_and_paragraph_numbers() {
|
||||
/// has no number to list.
|
||||
#[test]
|
||||
fn full_context_keeps_the_parent_of_a_none_level_heading() {
|
||||
crate::test_fonts();
|
||||
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) {
|
||||
@@ -118,6 +124,7 @@ fn full_context_keeps_the_parent_of_a_none_level_heading() {
|
||||
/// 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() {
|
||||
crate::test_fonts();
|
||||
let numbered = |text: &str, codes: &[&str]| {
|
||||
let mut d = doc(CHAPTERS, codes);
|
||||
let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
|
||||
@@ -143,6 +150,7 @@ fn a_none_level_heading_s_number_has_no_text_and_no_trailing_period() {
|
||||
/// Editing a heading updates the headers that show it, through the paragraph cache.
|
||||
#[test]
|
||||
fn headers_follow_heading_edits() {
|
||||
crate::test_fonts();
|
||||
let mut d = doc(CHAPTERS, &[r#"STYLEREF "Heading 1""#]);
|
||||
let mut c = LayoutCache::new();
|
||||
let first = layout(&d, &mut c, &LayoutOptions::default());
|
||||
@@ -158,6 +166,7 @@ fn headers_follow_heading_edits() {
|
||||
/// documents lay out on every keystroke); an edited heading or a changed style rebuilds it.
|
||||
#[test]
|
||||
fn style_index_is_reused_until_the_body_or_styles_change() {
|
||||
crate::test_fonts();
|
||||
let mut d = doc(CHAPTERS, &[r#"STYLEREF "Heading 1""#]);
|
||||
let mut c = LayoutCache::new();
|
||||
let index = |c: &LayoutCache| c.styleref.as_ref().map(|(_, _, ix)| ix.clone()).unwrap();
|
||||
@@ -186,6 +195,7 @@ fn style_index_is_reused_until_the_body_or_styles_change() {
|
||||
/// `STYLEREF 1` finds the style named `heading 1` when its id is localized (#1286).
|
||||
#[test]
|
||||
fn heading_level_matches_a_localized_style_id() {
|
||||
crate::test_fonts();
|
||||
let paras: Vec<(&str, &str, bool)> = CHAPTERS.iter().map(|(s, t, b)| (if *s == "Heading1" { "berschrift1" } else { *s }, *t, *b)).collect();
|
||||
let mut d = doc(¶s, &["STYLEREF 1"]);
|
||||
let st = Arc::make_mut(&mut d.styles).styles.iter_mut().find(|s| s.id == "Heading1").unwrap();
|
||||
@@ -200,6 +210,7 @@ fn heading_level_matches_a_localized_style_id() {
|
||||
/// edited heading still updates the headers that show it.
|
||||
#[test]
|
||||
fn body_edits_keep_styleref_headers_cached() {
|
||||
crate::test_fonts();
|
||||
let mut d = doc(CHAPTERS, &[r#"STYLEREF "Heading 1""#, r#"STYLEREF "Heading 1" \p"#]);
|
||||
let mut c = LayoutCache::new();
|
||||
let opts = LayoutOptions::default();
|
||||
@@ -244,6 +255,7 @@ fn field_char_xs(d: &Document) -> Vec<(String, Vec<f32>)> {
|
||||
/// also in a right-to-left header paragraph.
|
||||
#[test]
|
||||
fn hebrew_styleref_result_reads_right_to_left() {
|
||||
crate::test_fonts();
|
||||
let d = doc(&[("Heading1", "פרק א", false), ("Normal", "Text.", false)], &[r#"STYLEREF "Heading 1""#]);
|
||||
let got = field_char_xs(&d);
|
||||
let (text, xs) = &got[0];
|
||||
@@ -312,6 +324,7 @@ fn rtl_header(d: &mut Document) {
|
||||
/// Hebrew word in brackets in a left-to-right result draws its brackets the right way round.
|
||||
#[test]
|
||||
fn field_results_mirror_brackets_with_their_bidi_runs() {
|
||||
crate::test_fonts();
|
||||
let mut got = Vec::new();
|
||||
// The field-code view of a PAGE field in a right-to-left header.
|
||||
let mut d = doc(&[("Normal", "Text.", false)], &["PAGE"]);
|
||||
|
||||
@@ -57,6 +57,7 @@ fn frame_of(l: &DocLayout, id: u32) -> Rect {
|
||||
|
||||
#[test]
|
||||
fn align_text_and_text_direction_place_the_text_in_the_box() {
|
||||
crate::test_fonts();
|
||||
let at = |body: TextBody| {
|
||||
let mut d = Document::from_text("x");
|
||||
let id = text_box(&mut d, 0, "Hi", 144.0, 144.0, body);
|
||||
@@ -87,6 +88,7 @@ fn align_text_and_text_direction_place_the_text_in_the_box() {
|
||||
|
||||
#[test]
|
||||
fn linked_boxes_continue_the_text() {
|
||||
crate::test_fonts();
|
||||
let mut d = Document::from_text("one\ntwo");
|
||||
let text = "Words that run on well past the first little box. ".repeat(6);
|
||||
let a = text_box(&mut d, 0, &text, 144.0, 48.0, TextBody::default());
|
||||
@@ -125,6 +127,7 @@ fn linked_boxes_continue_the_text() {
|
||||
|
||||
#[test]
|
||||
fn hostile_link_chains_stay_bounded() {
|
||||
crate::test_fonts();
|
||||
// Boxes linking in a circle show their own text; a box linking to itself too.
|
||||
let mut d = Document::from_text("one\ntwo\nthree");
|
||||
let a = text_box(&mut d, 0, "A", 72.0, 36.0, TextBody::default());
|
||||
@@ -142,6 +145,7 @@ fn hostile_link_chains_stay_bounded() {
|
||||
|
||||
#[test]
|
||||
fn own_margins_and_resize_to_fit_keep_the_text_in_view() {
|
||||
crate::test_fonts();
|
||||
// #743: a cover page's two-line box with no margins (`lIns="0"`…) is just tall enough for
|
||||
// its text; with Word's default margins its second line would be cut.
|
||||
let two = |h: f32, body: TextBody| {
|
||||
|
||||
@@ -30,6 +30,7 @@ fn body_lines(l: &DocLayout) -> Vec<(f32, f32, TextDirection)> {
|
||||
|
||||
#[test]
|
||||
fn top_to_bottom_section_stacks_lines_from_the_right() {
|
||||
crate::test_fonts();
|
||||
let d = vertical_doc(TextDirection::Down);
|
||||
let l = lay(&d);
|
||||
let page = &l.pages[0];
|
||||
@@ -70,6 +71,7 @@ fn top_to_bottom_section_stacks_lines_from_the_right() {
|
||||
|
||||
#[test]
|
||||
fn bottom_to_top_section_stacks_lines_from_the_left() {
|
||||
crate::test_fonts();
|
||||
let l = lay(&vertical_doc(TextDirection::Up));
|
||||
assert!(body_lines(&l).iter().all(|(.., t)| *t == TextDirection::Up));
|
||||
let c0 = l.caret(&Pos::body(0, 0)).unwrap();
|
||||
@@ -81,6 +83,7 @@ fn bottom_to_top_section_stacks_lines_from_the_left() {
|
||||
|
||||
#[test]
|
||||
fn east_asian_characters_stand_upright_in_top_to_bottom_text() {
|
||||
crate::test_fonts();
|
||||
let l = lay(&vertical_doc(TextDirection::Down));
|
||||
let d = vertical_doc(TextDirection::Down);
|
||||
let draws = display::page_display(&d, &l.pages[0], &Default::default());
|
||||
@@ -106,6 +109,7 @@ fn east_asian_characters_stand_upright_in_top_to_bottom_text() {
|
||||
|
||||
#[test]
|
||||
fn headers_stay_horizontal_and_a_vertical_section_starts_a_page() {
|
||||
crate::test_fonts();
|
||||
use wordcraft_doc::section::SectionStart;
|
||||
let mut d = Document::from_text("Horizontal\nVertical");
|
||||
// Section 1 ends after paragraph 0 (continuous), section 2 is vertical.
|
||||
|
||||
@@ -336,6 +336,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn arch_baselines_lie_on_the_arc() {
|
||||
crate::test_fonts();
|
||||
let frame = Rect::new(10.0, 20.0, 200.0, 100.0);
|
||||
let w = Warp::new(&TextWarp::new("textArchUp").unwrap(), frame, 0.25).unwrap();
|
||||
let (cx, cy, rx, ry) = (110.0, 70.0, 100.0, 50.0);
|
||||
@@ -363,6 +364,7 @@ mod tests {
|
||||
/// #2534: the legacy font effects become text effects, under any WordArt effects.
|
||||
#[test]
|
||||
fn legacy_font_effects_are_drawn_as_text_effects() {
|
||||
crate::test_fonts();
|
||||
use wordcraft_doc::CharProps;
|
||||
let rc = |c: CharProps| wordcraft_doc::styles::StyleSheet::default().resolve_char(None, &CharProps { size: Some(20.0), ..c });
|
||||
let red = Rgb(200, 0, 0);
|
||||
@@ -394,6 +396,7 @@ mod tests {
|
||||
/// #2534: synthetic bold widens the outline, or adds one in the fill's colour.
|
||||
#[test]
|
||||
fn embolden_widens_or_adds_the_outline() {
|
||||
crate::test_fonts();
|
||||
let red = Rgb(200, 0, 0);
|
||||
let mut plain = TextEffects::default();
|
||||
embolden(&mut plain, red, 20.0);
|
||||
@@ -418,6 +421,7 @@ mod tests {
|
||||
/// draws its glyphs plainly.
|
||||
#[test]
|
||||
fn effects_on_tracked_and_turned_text() {
|
||||
crate::test_fonts();
|
||||
use crate::display::{DisplayOptions, Draw, InsertMark, page_display};
|
||||
use wordcraft_doc::props::TextDirection;
|
||||
use wordcraft_doc::{CharProps, Document, Paragraph, Revision, RevisionKind, para_block};
|
||||
@@ -456,6 +460,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn flat_and_unknown_warps_draw_flat() {
|
||||
crate::test_fonts();
|
||||
let frame = Rect::new(0.0, 0.0, 100.0, 50.0);
|
||||
assert!(Warp::new(&TextWarp::new("textPlain").unwrap(), frame, 0.2).is_none());
|
||||
assert!(Warp::new(&TextWarp::new("textCanUp").unwrap(), frame, 0.2).is_none());
|
||||
|
||||
+167
-17
@@ -184,48 +184,64 @@ impl Remote {
|
||||
Ok(req.to_string())
|
||||
}
|
||||
|
||||
fn roundtrip(&mut self, line: &str) -> std::io::Result<String> {
|
||||
/// Send one line and read its reply. Once a write is attempted, any failure leaves the
|
||||
/// outcome unknown: even a failed write may have delivered a complete request at EOF.
|
||||
fn roundtrip(&mut self, line: &str) -> Result<String, Failure> {
|
||||
let Some((reader, writer)) = self.conn.as_mut() else {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected"));
|
||||
return Err(Failure::NotSent(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected")));
|
||||
};
|
||||
writer.write_all(line.as_bytes())?;
|
||||
writer.write_all(b"\n")?;
|
||||
writer.flush()?;
|
||||
match read_bounded_line(reader, MAX_REPLY_LINE)? {
|
||||
BoundedLine::Line(reply) => Ok(reply),
|
||||
BoundedLine::Eof => Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed")),
|
||||
BoundedLine::TooLong => Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "reply from app too long")),
|
||||
}
|
||||
roundtrip_io(reader, writer, line)
|
||||
}
|
||||
|
||||
/// One request and its reply, with one retry on a new connection (the app may have
|
||||
/// restarted; a new connection also reads the key again).
|
||||
/// One request and its reply. A request that was not sent, or one that only reads, is tried
|
||||
/// once more on a new connection (the app may have restarted; a new connection also reads
|
||||
/// the key again). A mutation whose outcome is unknown is never sent again: it may have run.
|
||||
fn exchange(&mut self, id: u64, method: &str, params: &Value) -> Result<Value, String> {
|
||||
self.exchange_with(id, method, params, Self::roundtrip)
|
||||
}
|
||||
|
||||
fn exchange_with(
|
||||
&mut self,
|
||||
id: u64,
|
||||
method: &str,
|
||||
params: &Value,
|
||||
mut roundtrip: impl FnMut(&mut Self, &str) -> Result<String, Failure>,
|
||||
) -> Result<Value, String> {
|
||||
let unreachable = |addr: &str, e: std::io::Error, connected: bool| {
|
||||
let retry = if connected { String::new() } else { "; will retry on each call".to_string() };
|
||||
format!("WordCraft app at {addr} is not reachable: {e}{retry}")
|
||||
};
|
||||
let mut first = true;
|
||||
let reply = loop {
|
||||
let tried = if self.conn.is_some() { Ok(()) } else { self.reconnect() };
|
||||
let tried = if self.conn.is_some() { Ok(()) } else { self.reconnect().map_err(Failure::NotSent) };
|
||||
let result = match tried {
|
||||
Ok(()) => {
|
||||
let line = self.request_line(id, method, params)?;
|
||||
self.roundtrip(&line)
|
||||
roundtrip(self, &line)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
match result {
|
||||
Ok(r) => break r,
|
||||
Err(e) => {
|
||||
Err(f) => {
|
||||
self.conn = None;
|
||||
// A reply that arrived but can't be read (too long, not UTF-8): the app has
|
||||
// run the request, so sending it again could run a state-changing one twice.
|
||||
if e.kind() == std::io::ErrorKind::InvalidData {
|
||||
if let Failure::Unknown(e) = &f
|
||||
&& e.kind() == std::io::ErrorKind::InvalidData
|
||||
{
|
||||
return Err(format!("bad reply from app: {e}"));
|
||||
}
|
||||
if let Failure::Unknown(e) = &f
|
||||
&& !is_read_only(method, params)
|
||||
{
|
||||
return Err(format!(
|
||||
"outcome unknown: the connection to the WordCraft app at {} failed while sending `{method}` or waiting for its reply ({e}); it may have been executed, so it is not sent again. Inspect the document to check",
|
||||
self.addr
|
||||
));
|
||||
}
|
||||
if !first {
|
||||
return Err(unreachable(&self.addr, e, self.connected));
|
||||
return Err(unreachable(&self.addr, f.into_error(), self.connected));
|
||||
}
|
||||
first = false;
|
||||
}
|
||||
@@ -235,6 +251,44 @@ impl Remote {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared socket exchange, also used with a writer that fails after partial delivery in tests.
|
||||
fn roundtrip_io(reader: &mut impl std::io::BufRead, writer: &mut impl Write, line: &str) -> Result<String, Failure> {
|
||||
let mut out = Vec::with_capacity(line.len() + 1);
|
||||
out.extend_from_slice(line.as_bytes());
|
||||
out.push(b'\n');
|
||||
writer.write_all(&out).and_then(|()| writer.flush()).map_err(Failure::Unknown)?;
|
||||
match read_bounded_line(reader, MAX_REPLY_LINE).map_err(Failure::Unknown)? {
|
||||
BoundedLine::Line(reply) => Ok(reply),
|
||||
BoundedLine::Eof => Err(Failure::Unknown(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed"))),
|
||||
BoundedLine::TooLong => Err(Failure::Unknown(std::io::Error::new(std::io::ErrorKind::InvalidData, "reply from app too long"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a round trip failed.
|
||||
enum Failure {
|
||||
/// No write was attempted: the app did not run it.
|
||||
NotSent(std::io::Error),
|
||||
/// After the request was sent: the app may have run it.
|
||||
Unknown(std::io::Error),
|
||||
}
|
||||
|
||||
impl Failure {
|
||||
fn into_error(self) -> std::io::Error {
|
||||
match self {
|
||||
Self::NotSent(e) | Self::Unknown(e) => e,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Methods that only read: sending them twice does no harm.
|
||||
fn is_read_only(method: &str, params: &Value) -> bool {
|
||||
match method {
|
||||
"document.inspect" | "engine.commands" | "ui.inspect" | "ui.parity" => true,
|
||||
"ui.render" | "ui.screenshot" => params.get("path").and_then(Value::as_str).is_none(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for Remote {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
let id = self.next_id;
|
||||
@@ -498,4 +552,100 @@ mod tests {
|
||||
assert!(e.contains("not the app's address") && e.contains("not sent"), "{e}");
|
||||
assert!(seen.recv_timeout(WAIT).unwrap().get("key").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mutation_with_a_lost_reply_is_not_sent_again() {
|
||||
// The app runs the request, then the connection drops without a reply.
|
||||
let (port, seen) = fake_app(|_| None);
|
||||
let mut r = Remote::connect_with(&format!("127.0.0.1:{port}"), fixed_key("k")).unwrap();
|
||||
let e = r.call("engine.execute", json!({"command": "text.insert", "params": {"text": "x"}})).unwrap_err();
|
||||
assert!(e.contains("outcome unknown"), "{e}");
|
||||
assert_eq!(seen.recv_timeout(WAIT).unwrap()["method"], "engine.execute");
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
assert!(seen.try_recv().is_err(), "the mutation was sent a second time");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_read_with_a_lost_reply_is_sent_again() {
|
||||
let n = Arc::new(AtomicUsize::new(0));
|
||||
let (port, seen) =
|
||||
fake_app(move |_| if n.fetch_add(1, Ordering::SeqCst) == 0 { None } else { Some((json!({"ok": true, "result": 3}), false)) });
|
||||
let mut r = Remote::connect_with(&format!("127.0.0.1:{port}"), fixed_key("k")).unwrap();
|
||||
assert_eq!(r.call("document.inspect", json!({})).unwrap(), json!(3));
|
||||
assert_eq!(seen.try_iter().count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_write_after_delivering_json_never_replays_a_mutation() {
|
||||
// Deliver a complete request, then fail either its newline write or its flush. The
|
||||
// stand-in, like the production server, accepts a complete JSON body at EOF.
|
||||
struct FailingWriter<'a> {
|
||||
socket: &'a mut TcpStream,
|
||||
fail_flush: bool,
|
||||
wrote: bool,
|
||||
}
|
||||
impl Write for FailingWriter<'_> {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
if self.wrote {
|
||||
return Err(std::io::ErrorKind::BrokenPipe.into());
|
||||
}
|
||||
self.wrote = true;
|
||||
let len = buf.len() - usize::from(!self.fail_flush);
|
||||
self.socket.write_all(&buf[..len])?;
|
||||
self.socket.shutdown(std::net::Shutdown::Write)?;
|
||||
Ok(len)
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Err(std::io::ErrorKind::BrokenPipe.into())
|
||||
}
|
||||
}
|
||||
for fail_flush in [false, true] {
|
||||
let (port, seen) = fake_app(|_| None);
|
||||
let mut r = Remote::connect_with(&format!("127.0.0.1:{port}"), fixed_key("k")).unwrap();
|
||||
let mut attempts = 0;
|
||||
let e = r
|
||||
.exchange_with(1, "engine.execute", &json!({"command": "text.insert", "params": {"text": "once"}}), |r, line| {
|
||||
attempts += 1;
|
||||
let (reader, socket) = r.conn.as_mut().unwrap();
|
||||
roundtrip_io(reader, &mut FailingWriter { socket, fail_flush, wrote: false }, line)
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(e.contains("outcome unknown"), "{e}");
|
||||
assert_eq!(attempts, 1, "a failed write must not trigger another exchange");
|
||||
assert_eq!(seen.recv_timeout(WAIT).unwrap()["params"]["params"]["text"], "once");
|
||||
assert!(seen.try_recv().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_exports_with_a_lost_reply_are_not_replayed_but_in_memory_reads_are() {
|
||||
for method in ["ui.render", "ui.screenshot"] {
|
||||
for path in [json!("out.png"), json!(""), Value::Null] {
|
||||
let count = Arc::new(AtomicUsize::new(0));
|
||||
let n = count.clone();
|
||||
let (port, seen) =
|
||||
fake_app(move |_| if n.fetch_add(1, Ordering::SeqCst) == 0 { None } else { Some((json!({"ok": true, "result": 3}), false)) });
|
||||
let mut r = Remote::connect_with(&format!("127.0.0.1:{port}"), fixed_key("k")).unwrap();
|
||||
let result = r.call(method, json!({"path": path}));
|
||||
if path.is_string() {
|
||||
assert!(result.unwrap_err().contains("outcome unknown"));
|
||||
assert_eq!(count.load(Ordering::SeqCst), 1);
|
||||
} else {
|
||||
assert_eq!(result.unwrap(), json!(3));
|
||||
assert_eq!(count.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
assert_eq!(seen.try_iter().count(), if path.is_string() { 1 } else { 2 });
|
||||
}
|
||||
assert!(is_read_only(method, &json!({})), "no path means PNG in the reply");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mutation_not_sent_goes_out_on_a_new_connection() {
|
||||
let (port, seen) = fake_app(|_| Some((json!({"ok": true, "result": 1}), false)));
|
||||
let mut r = Remote::connect_with(&format!("127.0.0.1:{port}"), fixed_key("k")).unwrap();
|
||||
r.conn = None;
|
||||
assert_eq!(r.call("engine.execute", json!({"command": "file.new"})).unwrap(), json!(1));
|
||||
assert_eq!(seen.try_iter().count(), 1);
|
||||
}
|
||||
}
|
||||
@@ -106,10 +106,14 @@ impl Server {
|
||||
/// the protocol stream).
|
||||
pub fn serve(&mut self, mut input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
|
||||
loop {
|
||||
let reply = match read_bounded_line(&mut input, MAX_REQUEST_LINE)? {
|
||||
BoundedLine::Eof => break,
|
||||
BoundedLine::Line(line) => self.handle_line(&line),
|
||||
BoundedLine::TooLong => Some(error(Value::Null, INVALID_REQUEST, "request line too long").to_string()),
|
||||
let reply = match read_bounded_line(&mut input, MAX_REQUEST_LINE) {
|
||||
Ok(BoundedLine::Eof) => break,
|
||||
Ok(BoundedLine::Line(line)) => self.handle_line(&line),
|
||||
Ok(BoundedLine::TooLong) => Some(error(Value::Null, INVALID_REQUEST, "request line too long").to_string()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
|
||||
Some(error(Value::Null, PARSE_ERROR, "request line is not UTF-8").to_string())
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
if let Some(reply) = reply {
|
||||
output.write_all(reply.as_bytes())?;
|
||||
|
||||
@@ -356,6 +356,70 @@ fn tools_address_an_open_document_by_id() {
|
||||
assert_eq!(tool(&mut s, "get_text", json!({"doc": first}))["text"].as_str().unwrap().trim(), "First more again");
|
||||
}
|
||||
|
||||
/// #2242: `insert_table` fills a table in one call; the header row and style survive a DOCX round trip.
|
||||
#[test]
|
||||
fn insert_table_fills_a_table_and_survives_docx() {
|
||||
let mut s = Server::new(Box::new(Headless::default()));
|
||||
tool(&mut s, "new_document", json!({}));
|
||||
tool(&mut s, "insert_table", json!({"values": [["Month", "Cases"], ["January", 12], ["February", 7]], "header": true, "style": "TableGrid"}));
|
||||
let path = std::env::temp_dir().join(format!("wordcraft-mcp-table-{}.docx", std::process::id()));
|
||||
let path_str = path.to_str().expect("utf-8 path").to_string();
|
||||
tool(&mut s, "save_document", json!({"path": path_str}));
|
||||
let mut s2 = Server::new(Box::new(Headless::default()));
|
||||
tool(&mut s2, "open_document", json!({"path": path_str}));
|
||||
let doc = tool(&mut s2, "inspect_document", json!({}));
|
||||
let table = doc["blocks"].as_array().unwrap().iter().find(|b| b["type"] == "table").expect("a table");
|
||||
let text = table.to_string();
|
||||
for cell in ["Month", "Cases", "January", "12", "February", "7"] {
|
||||
assert!(text.contains(cell), "{cell} missing in {text}");
|
||||
}
|
||||
assert!(text.contains("TableGrid"), "{text}");
|
||||
// The header flag is not in `inspect_document`; read it from the reopened document.
|
||||
let mut h = Headless::default();
|
||||
h.session.run("file.open", &json!({"path": path_str})).expect("reopened");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let table = h.session.doc.body.iter().find_map(|b| match &**b {
|
||||
wordcraft_doc::Block::Table(t) => Some(t.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let table = table.expect("a table");
|
||||
assert!(table.rows[0].props.header && !table.rows[1].props.header);
|
||||
}
|
||||
|
||||
/// #2242: malformed `insert_table` arguments are tool errors, not panics.
|
||||
#[test]
|
||||
fn insert_table_rejects_hostile_values() {
|
||||
let mut s = Server::new(Box::new(Headless::default()));
|
||||
let wide: Vec<Value> = (0..64).map(|i| json!(i)).collect();
|
||||
for args in [
|
||||
json!({}),
|
||||
json!({"values": "x"}),
|
||||
json!({"values": [["a", "b"], ["c"]]}),
|
||||
json!({"values": [wide]}),
|
||||
json!({"values": [[{"a": 1}]]}),
|
||||
json!({"values": []}),
|
||||
] {
|
||||
let r = call(&mut s, 12, "tools/call", json!({"name": "insert_table", "arguments": args}));
|
||||
assert_eq!(r["result"]["isError"], true, "{args}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An explicit `values: null` is a tool error and changes nothing: no table, and the undo history
|
||||
/// still ends with the earlier edit (one Undo takes that back, not a phantom table insertion).
|
||||
#[test]
|
||||
fn insert_table_rejects_null_values_without_changing_the_document() {
|
||||
let mut s = Server::new(Box::new(Headless::default()));
|
||||
tool(&mut s, "new_document", json!({}));
|
||||
tool(&mut s, "type_text", json!({"text": "keep"}));
|
||||
let r = call(&mut s, 13, "tools/call", json!({"name": "insert_table", "arguments": {"values": null}}));
|
||||
assert_eq!(r["result"]["isError"], true, "{r}");
|
||||
assert_eq!(paragraphs(&mut s).len(), 1, "no table or paragraph added");
|
||||
assert_eq!(tool(&mut s, "get_text", json!({}))["text"].as_str().unwrap(), "keep");
|
||||
assert!(!tool(&mut s, "inspect_document", json!({}))["blocks"].to_string().contains("table"));
|
||||
tool(&mut s, "execute", json!({"command": "edit.undo"}));
|
||||
assert_eq!(tool(&mut s, "get_text", json!({}))["text"].as_str().unwrap(), "", "the undo step is the typing, not an insert");
|
||||
}
|
||||
|
||||
/// #3676: a page number that cannot fit an index is rejected, not silently rendered as
|
||||
/// page 1 (the old cast truncated 2^32 to 0 on 32-bit and wasm).
|
||||
#[test]
|
||||
@@ -367,3 +431,24 @@ fn render_page_rejects_a_page_number_that_does_not_fit() {
|
||||
let r = call(&mut s, 31, "tools/call", json!({"name": "render_page", "arguments": {"page": 1, "scale": 0.5}}));
|
||||
assert_eq!(r["result"]["isError"], false, "page 1 still renders: {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_utf8_does_not_change_arguments_or_stop_later_requests() {
|
||||
let mut s = Server::new(Box::new(Headless::default()));
|
||||
tool(&mut s, "new_document", json!({}));
|
||||
let mut input = br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"type_text","arguments":{"text":"bad"}}}"#.to_vec();
|
||||
let pos = input.windows(3).position(|w| w == b"bad").unwrap();
|
||||
input[pos] = 0xff;
|
||||
input.push(b'\n');
|
||||
input.extend_from_slice(
|
||||
b"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"type_text\",\"arguments\":{\"text\":\"good\"}}}\n",
|
||||
);
|
||||
let mut out = Vec::new();
|
||||
s.serve(std::io::Cursor::new(input), &mut out).unwrap();
|
||||
let replies: Vec<Value> = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(replies.len(), 2);
|
||||
assert_eq!(replies[0]["error"]["code"], -32700);
|
||||
assert_eq!(replies[1]["id"], 2);
|
||||
assert_eq!(replies[1]["result"]["isError"], false);
|
||||
assert_eq!(tool(&mut s, "get_text", json!({}))["text"], "good", "malformed text must never reach the backend");
|
||||
}
|
||||
+27
-1
@@ -75,10 +75,24 @@ pub fn tool_definitions() -> Value {
|
||||
tool(
|
||||
"execute",
|
||||
"Run a command",
|
||||
"Run one command by id with params, e.g. {command:\"format.bold\"} or {command:\"insert.table\", params:{rows:3, cols:4}}. Commands work on the active document, or on the open document `doc` names. file.new and file.open open another document beside the open ones and make it active; view.switchWindows {id} makes another one active and file.closeDocument {id, force?} closes one.",
|
||||
"Run one command by id with params, e.g. {command:\"format.bold\"} or {command:\"insert.table\", params:{rows:3, cols:4}}. To insert a table that is already filled, use `insert_table`. Commands work on the active document, or on the open document `doc` names. file.new and file.open open another document beside the open ones and make it active; view.switchWindows {id} makes another one active and file.closeDocument {id, force?} closes one.",
|
||||
with_doc(obj(json!({"command": s("Command id"), "params": {"type": "object", "description": "Command parameters"}}), &["command"])),
|
||||
false
|
||||
),
|
||||
tool(
|
||||
"insert_table",
|
||||
"Insert filled table",
|
||||
"Insert a table at the caret, filled from `values` in one call (an array of rows, each an array of cells; all rows the same length, up to 1000 rows and 63 columns). Set `header` to make the first row a repeating header row.",
|
||||
with_doc(obj(
|
||||
json!({
|
||||
"values": {"type": "array", "description": "Rows of cells (text, numbers or booleans)", "items": {"type": "array", "items": {"type": ["string", "number", "boolean", "null"]}}},
|
||||
"header": {"type": "boolean", "description": "First row is a header row that repeats on each page"},
|
||||
"style": s("Table style name (default GridTable1Light)")
|
||||
}),
|
||||
&["values"]
|
||||
)),
|
||||
false
|
||||
),
|
||||
tool(
|
||||
"batch",
|
||||
"Run several commands",
|
||||
@@ -238,6 +252,18 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
|
||||
Some(c) => wrap(exec(b, c, a.get("params").cloned().unwrap_or(json!({})), doc)),
|
||||
None => ToolResult::error("missing `command`"),
|
||||
},
|
||||
"insert_table" => match a.get("values") {
|
||||
Some(values) => {
|
||||
let mut params = json!({"values": values});
|
||||
for k in ["header", "style"] {
|
||||
if let Some(x) = a.get(k) {
|
||||
params[k] = x.clone();
|
||||
}
|
||||
}
|
||||
wrap(exec(b, "insert.spreadsheet", params, doc))
|
||||
}
|
||||
None => ToolResult::error("missing `values`: an array of rows, each an array of cells"),
|
||||
},
|
||||
"batch" => {
|
||||
let keep = a.get("keepGoing").and_then(Value::as_bool).unwrap_or(false);
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -163,6 +163,11 @@ pub struct RenderOptions {
|
||||
/// Thicken glyphs slightly the way macOS draws text on screen ("font smoothing"), so pages
|
||||
/// look like they do in other Mac apps. For on-screen rasters only: exports stay exact.
|
||||
pub text_darkening: bool,
|
||||
/// Hint text for the screen the way FreeType's `hintslight` does on Linux desktops: glyph
|
||||
/// baselines sit on whole pixels and their horizontal edges (x-height, cap height) are moved
|
||||
/// onto the pixel grid, so text looks as crisp as in other apps there. Nothing moves
|
||||
/// sideways, so lines break as without it. For on-screen rasters only: exports stay exact.
|
||||
pub hinting: bool,
|
||||
/// Dark page (View › Switch Modes): every colour but pictures has its lightness inverted, so
|
||||
/// white paper turns black and black text white while hues stay the same. Screen only.
|
||||
pub dark: bool,
|
||||
@@ -265,6 +270,7 @@ impl Default for RenderOptions {
|
||||
paper: Rgb::WHITE,
|
||||
mark_color: Rgb(0x2B, 0x57, 0x9A),
|
||||
text_darkening: false,
|
||||
hinting: false,
|
||||
dark: false,
|
||||
dark_paper: DARK_PAPER,
|
||||
read_paper: None,
|
||||
@@ -491,6 +497,13 @@ fn draw_watermark(ctx: &mut RenderContext, view: Affine, page: &Page, wm: &wordc
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `view` map page points to device pixels without rotation, skew or mirroring, scaling both
|
||||
/// axes alike? Only then do hinted outlines (made on the pixel grid) line up with the screen.
|
||||
fn is_upright(view: Affine) -> bool {
|
||||
let [a, b, c, d, e, f] = view.as_coeffs();
|
||||
[a, b, c, d, e, f].iter().all(|v| v.is_finite()) && b == 0.0 && c == 0.0 && a > 0.0 && (a - d).abs() <= a * 1e-6
|
||||
}
|
||||
|
||||
/// How far, in device pixels, macOS-style font smoothing pushes each side of a glyph outline out
|
||||
/// at `ppem` device pixels per em. Values from Pathfinder (MIT/Apache-2.0), which measured them
|
||||
/// against macOS: 1.21% of the pixel size horizontally and 1.25 times that vertically, each
|
||||
@@ -729,13 +742,19 @@ fn draw_glyphs(ctx: &mut RenderContext, fr: &Frame, it: &Draw) {
|
||||
let darken_px = if opts.text_darkening && *alpha >= 1.0 { stem_darkening(*size as f64 * px_per_pt) } else { 0.0 };
|
||||
let darken = if darken_px > 0.0 && k * px_per_pt > 0.0 { 2.0 * darken_px / (k * px_per_pt) } else { 0.0 };
|
||||
let outline_stroke = if *synth_bold { face.upem * 0.03 } else { 0.0 } + darken;
|
||||
let join = if darken > 0.0 { kurbo::Join::Round } else { kurbo::Join::Miter };
|
||||
if outline_stroke > 0.0 {
|
||||
let join = if darken > 0.0 { kurbo::Join::Round } else { kurbo::Join::Miter };
|
||||
ctx.set_stroke(kurbo::Stroke::new(outline_stroke).with_join(join));
|
||||
}
|
||||
// Emoji and other colour glyphs (#1269) are drawn from the font's colour data.
|
||||
let colour = face.has_color_glyphs();
|
||||
let ppem = *size as f64 * px_per_pt;
|
||||
// Hinted outlines are in device pixels: only for an upright, unmirrored, evenly scaled
|
||||
// view (not rotated text boxes), where the pixel grid lines up with the glyphs.
|
||||
let hint_ppem = (opts.hinting && !colour && is_upright(view)).then_some(ppem as f32);
|
||||
// The outline strokes in device pixels, for the hinted outlines.
|
||||
let stroke_px = outline_stroke * k * px_per_pt;
|
||||
let mut hinted_stroke = false;
|
||||
for (gid, x, y) in glyphs {
|
||||
let (gx, gy) = (*x as f64, *y as f64);
|
||||
// Written so a NaN position is skipped too.
|
||||
@@ -749,6 +768,23 @@ fn draw_glyphs(ctx: &mut RenderContext, fr: &Frame, it: &Draw) {
|
||||
ctx.set_paint(color(opts.ink(*c), *alpha));
|
||||
continue;
|
||||
}
|
||||
if let Some(o) = hint_ppem.and_then(|p| db.hinted_outline(face, *gid, p)) {
|
||||
if o.elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
// The baseline on a whole pixel, as the hinting assumes.
|
||||
let at = view * kurbo::Point::new(gx, gy);
|
||||
ctx.set_transform(Affine::translate((at.x, at.y.round())) * skew);
|
||||
ctx.fill_path(&o);
|
||||
if outline_stroke > 0.0 {
|
||||
if !hinted_stroke {
|
||||
ctx.set_stroke(kurbo::Stroke::new(stroke_px).with_join(join));
|
||||
hinted_stroke = true;
|
||||
}
|
||||
ctx.stroke_path(&o);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let o = db.outline(face, *gid);
|
||||
if o.elements().is_empty() {
|
||||
continue;
|
||||
@@ -756,6 +792,10 @@ fn draw_glyphs(ctx: &mut RenderContext, fr: &Frame, it: &Draw) {
|
||||
ctx.set_transform(view * Affine::translate((gx, gy)) * skew * Affine::scale(k));
|
||||
ctx.fill_path(&o);
|
||||
if outline_stroke > 0.0 {
|
||||
if hinted_stroke {
|
||||
ctx.set_stroke(kurbo::Stroke::new(outline_stroke).with_join(join));
|
||||
hinted_stroke = false;
|
||||
}
|
||||
ctx.stroke_path(&o);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,6 +290,67 @@ fn text_darkening_adds_ink_on_screen_only() {
|
||||
assert!(!RenderOptions::default().text_darkening);
|
||||
}
|
||||
|
||||
/// Inked pixels that are only partly covered (grey): an edge between two pixel rows.
|
||||
fn grey(img: &Rendered) -> usize {
|
||||
img.pixels.as_chunks::<4>().0.iter().filter(|p| (40..215).contains(&p[0])).count()
|
||||
}
|
||||
|
||||
/// The first and last pixel columns with any ink.
|
||||
fn ink_columns(img: &Rendered) -> (usize, usize) {
|
||||
let w = img.width as usize;
|
||||
let inked: Vec<usize> = img.pixels.as_chunks::<4>().0.iter().enumerate().filter(|(_, p)| p[0] < 250).map(|(i, _)| i % w.max(1)).collect();
|
||||
(inked.iter().copied().min().unwrap_or(0), inked.iter().copied().max().unwrap_or(0))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hinting_sharpens_screen_text_without_making_it_bolder_or_moving_it() {
|
||||
let mut d =
|
||||
Document::from_text("Every Thursday evening the old print shop fills with people who make things. Painters set up by the north windows.");
|
||||
// A bundled face at a fixed size, so the pixel thresholds below do not follow the machine's
|
||||
// default-font substitution.
|
||||
let styles = std::sync::Arc::make_mut(&mut d.styles);
|
||||
styles.default_chr.font = Some("Source Sans 3".into());
|
||||
styles.default_chr.size = Some(12.0);
|
||||
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
|
||||
// 100 % zoom on a 145 % desktop: glyph edges land between pixels.
|
||||
let scale = 96.0 / 72.0 * 1.45;
|
||||
let plain = render_page(&d, &l.pages[0], scale, &RenderOptions::default());
|
||||
let hinted = render_page(&d, &l.pages[0], scale, &RenderOptions { hinting: true, ..Default::default() });
|
||||
// Fewer grey pixels: the baseline, x-height and cap height fill whole pixel rows. Only
|
||||
// horizontal edges snap (the vertical stems stay put), so on the bundled face the drop is
|
||||
// 4 to 5 %: asked for at least 3 %.
|
||||
let (g0, g1) = (grey(&plain), grey(&hinted));
|
||||
assert!((g1 as f64) < g0 as f64 * 0.97, "grey pixels: plain {g0}, hinted {g1}");
|
||||
// The same weight, as FreeType's light hinting draws it: no darker than a few percent.
|
||||
let (a, b) = (ink(&plain), ink(&hinted));
|
||||
assert!(b as f64 > a as f64 * 0.95 && (b as f64) < a as f64 * 1.12, "ink: plain {a}, hinted {b}");
|
||||
// Nothing moves sideways: the text starts and ends in the same pixel columns.
|
||||
let ((l0, r0), (l1, r1)) = (ink_columns(&plain), ink_columns(&hinted));
|
||||
assert!(l0.abs_diff(l1) <= 1 && r0.abs_diff(r1) <= 1, "plain {l0}..{r0}, hinted {l1}..{r1}");
|
||||
// Off by default: exports and thumbnails are untouched.
|
||||
assert!(!RenderOptions::default().hinting);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_or_mirrored_views_are_not_hinted() {
|
||||
let d = Document::from_text("Rotated text stays as the font draws it.");
|
||||
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
|
||||
let hint = RenderOptions { hinting: true, ..Default::default() };
|
||||
for view in [
|
||||
Affine::translate((300.0, -50.0)) * Affine::rotate(0.3) * Affine::scale(1.9),
|
||||
Affine::translate((600.0, 0.0)) * Affine::scale_non_uniform(-1.9, 1.9),
|
||||
Affine::scale_non_uniform(1.9, 2.4),
|
||||
] {
|
||||
let plain = render_region(&d, &l.pages[0], 600, 300, view, &RenderOptions::default());
|
||||
let hinted = render_region(&d, &l.pages[0], 600, 300, view, &hint);
|
||||
assert!(ink(&plain) > 0, "{view:?}");
|
||||
assert_eq!(plain.pixels, hinted.pixels, "{view:?}");
|
||||
}
|
||||
assert!(is_upright(Affine::translate((3.5, 7.25)) * Affine::scale(1.93)));
|
||||
assert!(!is_upright(Affine::scale(f64::NAN)));
|
||||
assert!(!is_upright(Affine::scale(0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_picture_is_painted_behind_the_text() {
|
||||
let mut png = Vec::new();
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
//! background work such as a Zotero session). The window is 1440×900 pt at 2× and opens the sample
|
||||
//! document unless the first line is `{"empty": true}`; `{"empty": true, "startScreen": true}`
|
||||
//! starts the way the desktop app does with nothing to open (File › Home, #766). A line
|
||||
//! `{"width": w}`, `{"height": h}` or `{"ppp": s}` sets the window size in points or its pixels
|
||||
//! per point (e.g. 1.45 for a 145 % desktop, to see text as it is drawn there).
|
||||
//! `{"demoPacks": true}` lists a few sample Language packs (installs only record themselves), and
|
||||
//! (with `{"network": true}` on a line, downloading is already on) `{"demoPacks": "store"}` uses a real font store, as the desktop app does, in a new temporary
|
||||
//! folder rather than the person's, or in `"storeDir"` given on the same line (with
|
||||
@@ -57,8 +59,9 @@ fn main() {
|
||||
}
|
||||
let w = lines.iter().find_map(|l| l.get("width").and_then(|v| v.as_f64())).unwrap_or(1440.0) as f32;
|
||||
let h = lines.iter().find_map(|l| l.get("height").and_then(|v| v.as_f64())).unwrap_or(900.0) as f32;
|
||||
let ppp = lines.iter().find_map(|l| l.get("ppp").and_then(|v| v.as_f64())).map(|p| p.clamp(0.5, 4.0)).unwrap_or(2.0) as f32;
|
||||
let mut harness =
|
||||
egui_kittest::Harness::builder().with_size(egui::vec2(w, h)).with_pixels_per_point(2.0).with_max_steps(1_000_000).wgpu().build_ui_state(
|
||||
egui_kittest::Harness::builder().with_size(egui::vec2(w, h)).with_pixels_per_point(ppp).with_max_steps(1_000_000).wgpu().build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
if !READY.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return;
|
||||
|
||||
@@ -377,6 +377,7 @@ impl WordApp {
|
||||
/// Read Aloud, its file dialog, AutoSave of its document. Runs in [`WordApp::logic`] for the
|
||||
/// active window and, with native windows, as each window draws.
|
||||
pub(crate) fn window_work(&mut self, ctx: &egui::Context) {
|
||||
crate::windows::arrange::poll(self, ctx);
|
||||
if self.windows.native() {
|
||||
let doc = self.session.document_id();
|
||||
if self.print_document.is_none_or(|d| d == doc || !self.session.is_open(d)) {
|
||||
|
||||
@@ -397,6 +397,9 @@ impl WordApp {
|
||||
for req in std::mem::take(&mut self.session.ui_requests) {
|
||||
self.handle_request(&req);
|
||||
}
|
||||
// The open document goes to the front of the recent list after every command. The recent
|
||||
// commands (`ui.recentRemove`, `ui.recentClear`) return from `ui_command` before this
|
||||
// runs, so taking a document off the list is never undone by the re-add (#2716).
|
||||
if let Some(p) = self.session.path.as_ref().map(|p| p.to_string_lossy().to_string())
|
||||
&& self.ui.recent.first() != Some(&p)
|
||||
{
|
||||
@@ -474,6 +477,7 @@ impl WordApp {
|
||||
if req.get("close").is_some() {
|
||||
self.quit_requested = true;
|
||||
}
|
||||
crate::windows::arrange::request(self, req);
|
||||
}
|
||||
|
||||
/// A request made for document `document`, which isn't shown and which no other window
|
||||
@@ -799,6 +803,38 @@ impl WordApp {
|
||||
}
|
||||
json!({"startScreen": self.ui.start_screen})
|
||||
}
|
||||
"ui.recentRemove" => {
|
||||
// Backstage ▸ Home/Open ▸ Recent: take one document off the list (Word's context
|
||||
// menu Remove). The path must be an entry that is on the list (#2716).
|
||||
let Some(path) = p.get("path").and_then(Value::as_str) else {
|
||||
return Some(Err("`path` must name a document on the recent list".into()));
|
||||
};
|
||||
let before = self.ui.recent.len();
|
||||
self.ui.recent.retain(|r| r != path);
|
||||
if self.ui.recent.len() == before {
|
||||
return Some(Err(format!("`{path}` is not on the recent list")));
|
||||
}
|
||||
let saved = self.save_prefs_now();
|
||||
if !matches!(saved, Some(Err(_))) {
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.to_string());
|
||||
self.status(i18n::fmt(tl!("Removed {name} from the recent list"), &[("name", &name)]));
|
||||
}
|
||||
let mut r = json!({"removed": 1, "recent": self.ui.recent});
|
||||
add_saved(&mut r, saved);
|
||||
r
|
||||
}
|
||||
"ui.recentClear" => {
|
||||
// Clear the whole list, as Word's registry clear does for recent documents (#2716).
|
||||
let removed = self.ui.recent.len();
|
||||
self.ui.recent.clear();
|
||||
let saved = self.save_prefs_now();
|
||||
if !matches!(saved, Some(Err(_))) {
|
||||
self.status(i18n::fmt(tl!("Removed {count} documents from the recent list"), &[("count", &removed.to_string())]));
|
||||
}
|
||||
let mut r = json!({"removed": removed, "recent": self.ui.recent});
|
||||
add_saved(&mut r, saved);
|
||||
r
|
||||
}
|
||||
"ui.displayServer" => {
|
||||
// File › Options › Advanced › Linux display server (#2184): `value` `auto`,
|
||||
// `wayland` or `x11`; no value reads it. The desktop app reads it at the next launch.
|
||||
@@ -1021,6 +1057,19 @@ fn input_dialog_other(id: &str, params: &Value) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The result fields of a command that saves the preferences at once: `saved` is true when the
|
||||
/// write worked, false with `saveError` when it failed (the change still holds for this run) and
|
||||
/// null when the host has no preferences hook or wrote nothing.
|
||||
fn add_saved(r: &mut Value, saved: Option<Result<(), String>>) {
|
||||
r["saved"] = match &saved {
|
||||
None => Value::Null,
|
||||
Some(res) => json!(res.is_ok()),
|
||||
};
|
||||
if let Some(Err(e)) = saved {
|
||||
r["saveError"] = json!(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Commands that work while no document is open (#1129): opening or creating one, closing the
|
||||
/// window, Help, adding fonts (a font file dropped on the empty window), and the settings (File ›
|
||||
/// Options, the view and interface preferences it sets, AutoCorrect Options, Customize Ribbon and
|
||||
@@ -1046,6 +1095,7 @@ pub(crate) fn works_without_document(id: &str) -> bool {
|
||||
| "tools.customizeKeyboard"
|
||||
| "tools.customizeRibbon"
|
||||
| "view.darkMode"
|
||||
| "view.paperColor"
|
||||
| "view.marks"
|
||||
| "view.ruler"
|
||||
| "view.numeral"
|
||||
@@ -1069,6 +1119,8 @@ pub(crate) fn works_without_document(id: &str) -> bool {
|
||||
| "ui.discord"
|
||||
| "ui.caretAnimation"
|
||||
| "ui.startScreen"
|
||||
| "ui.recentRemove"
|
||||
| "ui.recentClear"
|
||||
| "ui.displayServer"
|
||||
| "ui.statusCount"
|
||||
| "ui.checkForUpdates"
|
||||
|
||||
@@ -39,6 +39,11 @@ impl WordApp {
|
||||
let Some(rx) = self.control_rx.take() else { return };
|
||||
while let Ok(req) = rx.try_recv() {
|
||||
let reply = req.reply.clone();
|
||||
if !req.start() {
|
||||
// The caller timed out while this waited in the queue and was told it did not run.
|
||||
log::debug!("control request {} dropped: its caller gave up", req.method);
|
||||
continue;
|
||||
}
|
||||
match control::handle(self, ctx, &req) {
|
||||
control::Outcome::Done(v) => {
|
||||
let _ = reply.send(v);
|
||||
|
||||
@@ -5,6 +5,8 @@ use super::{
|
||||
zotero,
|
||||
};
|
||||
|
||||
use crate::i18n;
|
||||
|
||||
impl WordApp {
|
||||
/// A file that wouldn't open (#1103): log it, and say on the status bar which file and why,
|
||||
/// whatever handed it over (the command line, a macOS open event, a drop). Returns the message.
|
||||
@@ -19,6 +21,23 @@ impl WordApp {
|
||||
self.status_msg = Some((s.into(), now_ms()));
|
||||
}
|
||||
|
||||
/// Write the current preferences out through the host's hook, if it has one (#2716). The
|
||||
/// desktop app saves them at exit; a change that should survive a crash, like taking a
|
||||
/// document off the recent list, needs a save at the moment of the change. `None`: the host
|
||||
/// has no hook or wrote nothing; `Some(Err)`: the write failed, and the status line says so.
|
||||
pub(super) fn save_prefs_now(&mut self) -> Option<Result<(), String>> {
|
||||
let save = self.services.save_prefs.as_ref()?;
|
||||
match save(self) {
|
||||
Ok(true) => Some(Ok(())),
|
||||
// The host wrote nothing (preferences switched off): same as having no hook.
|
||||
Ok(false) => None,
|
||||
Err(e) => {
|
||||
self.status(i18n::fmt(tl!("Could not save the preferences: {error}"), &[("error", &e)]));
|
||||
Some(Err(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Document title for the title bar (and the suggested file name). An untitled document is
|
||||
/// named in the interface language, as `Document1` is in English.
|
||||
pub fn title_stem(&self) -> String {
|
||||
|
||||
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user