Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor e67e4ee3f0 merge main into fix/sec-markdown-inline-scans 2026-11-11 01:01:53 +00:00
Dylan M. Taylor a34c94e4c1 packaging: use [[ for bash tests (SonarCloud S7688) (#6224)
Behaviour-preserving: `[[ ]]` in place of `[ ]` for bash tests (SonarCloud shell:S7688). Every script has a bash shebang; no test uses `-a`/`-o`, escaped parentheses or an unquoted pattern on the right of `=`. `bash -n` passes on each script; the rest is left to CI.

- `packaging/freebsd/package.sh`
2026-11-10 18:45:02 -05:00
FredApps a42c9eaab3 Merge pull request #6187 from storytold/sonar/format-mandated-sha1
sonar: SHA-1 in ODT decryption is the format's, not a choice
2026-11-10 18:43:18 +01:00
FredApps 12869b8e02 Merge branch 'main' into sonar/format-mandated-sha1 2026-11-10 18:23:24 +01:00
8548b33be1 MacOS: the window moves by its title bar behind a modal dialog (#5124) (#5125)
* #5124: the window moves by its title bar behind a modal dialog

On macOS WordCraft draws its own title bar, and since modal dialogs
(#3859, #4076) the dialog's backdrop covers it, so the window couldn't be
dragged while a dialog was open. Word for Mac lets you drag the document
window by its title bar behind its Font dialog (and beeps on the click).

- The title bar (and the Backstage's top band on macOS) notes the part
  that drags the window each frame. When the modal backdrop's drag starts
  there, it moves the window; the press still shakes the dialog, as Word
  beeps.
- Only the backdrop's own drag counts, so a drag on the dialog, its tabs
  or its popups, where it reaches over the title bar, moves only the
  dialog.

Tested by the developer on a Mac.

Fixes #5124.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #5124: only the macOS title bar moves the window behind a dialog

Review (CodeRabbit):
- Elsewhere the system's title bar already moves the window; WordCraft's
  own bar under it now doesn't, behind a modal dialog either, so nothing
  changes on Linux and Windows.
- A drag from just inside the dialog's edge, where it reaches over the
  title bar, moves only the dialog, as the nudge already tells it apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #5124: the window moves by its title bar behind Colors too; modal_open tests

Colors is an egui Modal, not a window with the backdrop of block_behind, so
a drag on the macOS title bar did nothing behind it. It now shares the check
with the other modal windows.

The new tests pin modal_open with nothing open, a modeless dialog, a modal
dialog, the file browser and Colors, and that the title bar moves the window
in each of those while the dialog stays open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #5124: the modal drag test covers the top of File's side menu

On macOS File's side menu covers the title bar, and its top band moves the
window. The test now drags there too, so losing the band's note_title_drag
call fails it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-10 14:04:45 +01:00
FredApps abd5a6e957 sonar: cite the ODF section the SHA-1 algorithms come from 2026-11-10 11:21:57 +01:00
FredApps 8dac5c7802 sonar: SHA-1 in ODT decryption is the format's, not a choice 2026-11-10 11:21:35 +01:00
2922fe566b RTF: style-sheet names take Unicode escapes; a malformed \' escape leaks no bytes (#6165)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: galagersharp-code <319712501+galagersharp-code@users.noreply.github.com>
2026-11-10 01:24:23 +01:00
43f94a5dca Paste: composite CSS colours with alpha over the page instead of dropping it (#6164)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: galagersharp-code <319712501+galagersharp-code@users.noreply.github.com>
2026-11-10 01:09:36 +01:00
Martin BallandClaude Opus 5.5 d40d1b2718 CLI: --strict on convert and render refuses missing glyphs and pictures (#6039)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 20:16:05 +01:00
05f781cfda ODT: columns stating only their shares keep their proportions (gap 19) (#5722)
* ODT gap 19: columns stating only their shares keep their proportions

A table's columns giving only style:rel-column-width, as LibreOffice's CV
template does, were read as equal columns: wrong on screen, and written back
that way once anything rewrote the table. Divide the table's width by the
shares when no column states a width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ODT: table column shares divide the right width (gap 19)

An aligned table's stated width is what shares divide, however wide. A column with
only a width takes it first, and the shares divide the rest, as in LibreOffice. A
column with no share takes the default style's. From CodeRabbit on this PR, checked
in LibreOffice 26.8.2.2: a share on every column still goes before a stated width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ODT: a table in a cell shares out no page width (gap 19)

A share-only table with nothing to divide, or one inside a cell, frame or narrower column with widths stated too, was given the page's width: an inner table got two 306 pt columns in a 100 pt cell. It now splits equally as main does. Also puts a doc comment back on its function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 19: read the column and row table defaults through one helper

Main's row default (keep-together) beside this PR's column default took the style
reader one over clippy's complexity limit; both now go through `read_table_default`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 19: docs status lines are main's, with this change in front

Merging main had kept this branch's older status lines in ROADMAP.md and
docs/file-format-parity.md, which set main's 2026-11-08 date back and dropped
main's newer entries and the parity doc's CLI path note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 19: an aligned table's shares go before stated widths; no column under 6 pt

LibreOffice 26.8 lays out an aligned table whose columns give both a width and a share
by the shares (measured). A share too small for a column gave a width the model
rejects, losing the whole grid to the equal split; it now gives the narrowest column.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 19: the progress log says which share-only tables keep their proportions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-09 16:35:36 +01:00
8a97427f51 ODT: a font size reaches every script's text (gap 19) (#5728)
* ODT gap 19: a font size reaches every script's text

Only fo:font-size was written, so Asian and complex-script text kept the default
size in LibreOffice: Chinese text set to 24 pt stayed at 12. Write the Asian and
complex sizes with it, as bold and italic are, and read a complex size equal to
the text's as no size of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ODT: a complex size like the text's stays over a style or default with another (gap 19)

Dropped as no size of its own, the default's showed instead: a 24 pt Arabic run drew
at 12, and a heading saved its complex size as 12. It's now dropped only where that
changes nothing, and a default stating one size for all goes with that size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ODT: a complex size only goes when no run's complex size changes (gap 19)

The check looked at each style alone, so a parent's complex size went from under a child or run that states only a size, and the size stated alone then showed through. The default's and the styles' now go together, and stay as the file says if any run's complex size would change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 19: docs status lines are main's, with this change in front

Merging main had kept this branch's older status lines in ROADMAP.md and
docs/file-format-parity.md, which set main's 2026-11-08 date back and dropped
main's newer entries and the parity doc's CLI path note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 19: a size stated alone keeps the complex size above it when saved

A run or paragraph style stating only a size took that size as its complex-script size
on save, though the model takes it from what is above it. The writer now writes the
complex size the model resolves where it differs from the size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-09 16:34:47 +01:00
92eac2aa7c ODT: text can turn off what its style turns on (gap 19) (#5724)
* ODT gap 19: text can turn off what its style turns on

A word not bold in a bold heading showed bold on screen, and came back bold in
LibreOffice after a save: an "off" on a run was dropped because the run's own
formatting said nothing, so the paragraph's or character style's showed through.
Keep it on the run for bold, italic, underline, strike-through, capitals and
small capitals, and write it back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ODT: an Asian or complex normal says nothing of Latin text (gap 19)

With "off" now kept on the text, `style:font-weight-complex="normal"` alone took a
word's bold in a bold heading, and saved it as `fo:font-weight="normal"`. Those
attributes stay fallbacks only to turn it on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ODT: strike-through turned off over a double one stays off (gap 19)

A style or span saying line-through none over a double strike still showed the double strike, on screen and after a save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs: a missing full stop in the ODT revision row (gap 19)

Docs only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ODT: a span's character style wins over its paragraph turning off (gap 19)

A paragraph saying not bold made a span in a bold character style not bold too, on screen and after a save. The span's style now wins, for bold, italic, underline, strike-through and capitals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 19: docs status lines are main's, with this change in front

Merging main had kept this branch's older status lines in ROADMAP.md and
docs/file-format-parity.md, which set main's 2026-11-08 date back and dropped
main's newer entries and the parity doc's CLI path note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-09 16:33:58 +01:00
29a5abf3e7 ODT: custom styles with an outline level keep their name when edited (gap 19) (#5770)
* ODT gap 19: custom styles with an outline level keep their name when edited

A paragraph in a custom style with style:default-outline-level was read as
WordCraft's Heading 1 or whichever level it named. Unedited, it was written as
read. Edited, it was saved as Heading_20_1, so LibreOffice showed WordCraft's
blue heading instead of the file's look, and WordCraft drew it that way too.

Read such a style as a custom style that carries the outline level. The
paragraph stays a heading at that level in WordCraft, is numbered with the
file's outline as LibreOffice numbers it, and is saved as
<text:h text:style-name="Head" text:outline-level="1">. The level comes from
the style, not the paragraph kind, because kinds stop at Heading 6.

The writer dropped a custom style's name on any heading, title, quotation or
code paragraph, so custom styles based on those lost their look when edited
too. It now keeps the name for every kind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ODT: a heading's own outline level gives its number (gap 19)

LibreOffice numbers a heading by its text:outline-level. WordCraft took the
number from the style's level, so a heading stating another showed the wrong
number, or one on a level the outline doesn't number. A heading in a list stays
the list's. Found by CodeRabbit on this PR, as was the Title-based style test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ODT: a heading in a list of its own keeps it, and an unnumbered level takes no number (gap 19)

Any list whose first level was linked to a style counted as the outline's, so a
heading deeper in one lost its list on save. Now only the number the outline
itself gives counts. A style based on a numbered heading, at a level the outline
doesn't number, took its parent's number. Found by CodeRabbit on this PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ODT: a heading past level 6 keeps its list (gap 19)

The written outline has no number past Heading 6, so counting such a heading's
list as the outline's lost its number. Found by CodeRabbit on this PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ODT: a style with a heading numbered by its own level stays on the outline (gap 19)

Writing it with no list style took the number from that heading in LibreOffice.
Written so, the numbers match the file's. Found by CodeRabbit on this PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ODT: a heading past level six numbered through its style keeps its list (gap 19)

The written outline numbers only six levels, and the list was dropped for a heading numbered only through its style, so at levels 7 to 9 it lost its number once its style was rewritten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 19: docs status lines are main's, with this change in front

Merging main had kept this branch's older status lines in ROADMAP.md and
docs/file-format-parity.md, which set main's 2026-11-08 date back and dropped
main's newer entries and the parity doc's CLI path note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-09 16:16:06 +01:00
bd9e726cda ODT: pictures keep the size their file gives (gap 19) (#5721)
* ODT gap 19: pictures keep the size their file gives

Opening a file fitted a picture wider than the text to the text, and shrank any
picture wider than 6.5 in to that, even with its size stated. So a save with no
edit to it took its paragraph off being written as read, and LibreOffice laid
it out differently. Pictures in OpenDocument files keep the size they state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ODT: a picture keeps a size stated on one side, in proportion (gap 19)

Only a frame stating both sides kept them. On one side alone a picture past a
column's width was still fitted to it, and past 1,584 pt each side was cut
separately, which distorted it. A size past any page can no longer overflow the
sizing either. Found by CodeRabbit on this PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ODT: an inline picture with no stated size is fitted to the text (gap 19)

Skipping the fit for OpenDocument files left one wider than the text on a small
page, which `main` fits. A size the file states still stands, and a test now fails
without that. The fit happens when it is read, so its paragraph stays as read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ODT: an object's picture is fitted too, and one side follows the picture's own shape (gap 19)

An inline object with no stated size kept its picture's width on a small page, which main fitted. A picture stated on one side took the other from the size already capped to the page: 10 by 1000 pixels at 500 pt wide came out 468 by 1584 pt, not 15.84 by 1584.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ODT: a frame stating a size of nothing is fitted like one stating none (gap 19)

svg:width="0pt" counted as a stated size for fitting, though the picture then came at its own size, so it could run past the text on a small page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ODT: a picture in line that is raised, turned or mirrored is fitted too (gap 19)

Such a picture has a float record that is still in line, which the reader's fit skipped, so with no size stated it stayed 468 pt wide on a small page. main fitted it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 19: docs status lines are main's, with this change in front

Merging main had kept this branch's older status lines in ROADMAP.md and
docs/file-format-parity.md, which set main's 2026-11-08 date back and dropped
main's newer entries and the parity doc's CLI path note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Order the parity header's change notes newest first

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-09 16:15:23 +01:00
15b608660f ODT: frames keep the page they are anchored to (gap 19) (#5705)
* ODT gap 19: frames keep the page they are anchored to

A frame or shape anchored to a page was written without its
text:anchor-page-number, so one on page 3 came back on page 1 in LibreOffice
after a save. Keep the number and write it back. WordCraft's own layout still
places such a frame on its anchor paragraph's page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ODT: a page number only counts on a page anchor, and groups keep theirs (gap 19)

A paragraph anchor with a text:anchor-page-number was saved as a page anchor,
which LibreOffice ignores the number on and so moved the drawing. A group
anchored to a page lost its page. Found by CodeRabbit on this PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ODT: a frame moved in WordCraft loses its page number (gap 19)

Nothing cleared it, so the save put the frame back on its old page. One placed by
its paragraph is no page anchor any more, and one dropped on a page takes that page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ODT tests: put the moved frame's test with the other moves (gap 19)

At the end of the file it collided with other ODT PRs' tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ODT: a frame moved onto its paragraph loses its page number (gap 19)

The number was only hidden while the frame was placed by its paragraph, so placing it by the page again (arrange.position) brought back the old page. The merge of main also lost the test attribute on a test from main, put back here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 19: docs status lines are main's, with this change in front

Merging main had kept this branch's older status lines in ROADMAP.md and
docs/file-format-parity.md, which set main's 2026-11-08 date back and dropped
main's newer entries and the parity doc's CLI path note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 19: parity row says when a moved frame keeps its page number

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-09 15:57:37 +01:00
52fbc7f068 ODT: a document keeps its language when styles are rewritten (gap 19) (#5704)
* ODT gap 19: a document keeps its language when styles are rewritten

The default language was only ever saved by copying the file's own styles
back. Adding numbering or creating a style writes them from the model, which
never stated it, so LibreOffice showed the document in its own default
language. Import ignored it too, so such a document was proofed as English and
the styles WordCraft adds said en-US.

- Read the language from Standard, else the default paragraph style
- Write it in the model-written default style
- A file stating none has none, rather than en-US from new documents

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ODT: a language keeps its script, and a style with no parent the default style's language (gap 19)

sr-Latn-RS was written as fo:country="Latn" with RS dropped, and fo:script
wasn't read, in all three of the language attributes. A style with no parent
took Standard's language when it differed from the default style's. Found by
CodeRabbit on this PR, as were the added assertions and doc comments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ODT: the language written is Normal's, as WordCraft has it (gap 19)

The default style took its language from the defaults, though WordCraft's document language is Normal's: a Word file with a French Normal over en-US defaults saved as English.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 19: docs status lines are main's, with this change in front

Merging main had kept this branch's older status lines in ROADMAP.md and
docs/file-format-parity.md, which set main's 2026-11-08 date back and dropped
main's newer entries and the parity doc's CLI path note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-09 15:56:59 +01:00
Aleksander HelgakerandClaude Opus 5.5 d75364067d ODT: table and row backgrounds show behind their cells (gap 19) (#5727)
* ODT gap 19: table and row backgrounds show behind their cells

fo:background-color on a table or row wasn't read, so it was missing on screen
and gone in LibreOffice after a save. WordCraft's tables have their backgrounds
on their cells, so it goes on the cells with none of their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ODT: a column's default cell style shows, and transparent clears a background (gap 19)

A cell with no style took the row's or table's colour over its column's default
cell style, and `transparent` on a table or row fell through to its parent style's.
From CodeRabbit on this PR, checked in LibreOffice 26.8.2.2: a row's default cell
style isn't applied there, so it isn't here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ODT: a column's default background is read once, not its style's name (gap 19)

The name was cloned for every repeat of a column, which a hostile file could make
use to run out of memory, and allocation failure can't be caught.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ODT: a transparent cell style stops its parent's colour (gap 19)

A cell style saying transparent over a coloured parent took the parent's colour and hid the row behind it. LibreOffice 26.8 shows the row, from a column's default cell style or the cell's own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 19: docs status lines are main's, with this change in front

Merging main had kept this branch's older status lines in ROADMAP.md and
docs/file-format-parity.md, which set main's 2026-11-08 date back and dropped
main's newer entries and the parity doc's CLI path note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 15:56:09 +01:00
Aleksander HelgakerandClaude Opus 5.5 d102b2f08b Gap 19: ODT parity row says how tab stops are saved (#6094)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:41:02 +01:00
Dylan M. Taylor 4462548ce3 Merge main (keep both progress-log lines) 2026-11-03 22:50:25 +00:00
Dylan M. Taylor 0c7f14f7a6 fix(formats): skip the comment-end search when no --> follows an inline <!-- 2026-11-03 11:41:02 +00:00
93 changed files with 5423 additions and 310 deletions
Generated
+3
View File
@@ -6846,13 +6846,16 @@ dependencies = [
name = "wordcraft-cli"
version = "0.15.1"
dependencies = [
"image",
"serde_json",
"sys-locale",
"wordcraft-doc",
"wordcraft-engine",
"wordcraft-fonts",
"wordcraft-mcp",
"wordcraft-render",
"wordcraft-zotero",
"zip 3.0.0",
]
[[package]]
+18
View File
@@ -48,3 +48,21 @@ _Avoid_: User dictionary (in the interface)
A folder of Hunspell dictionaries you supply, used before the system's (an installed Language pack
comes after it).
_Avoid_: Custom dictionaries
## Export
**Strict export**:
An export that writes nothing, and reports why, when a character or picture the output is meant
to show would be missing from it. Text the output doesn't show (hidden text, deletions without
markup) isn't checked.
_Avoid_: Safe mode, validated export
**Missing glyph**:
A character the output draws that no face in the font fallback chain can draw, so it would appear
as an empty box.
_Avoid_: Tofu, box, notdef character
**Missing picture**:
A picture the document refers to that the output cannot include: its part is absent, its data
cannot be decoded, its format is unsupported, or it is linked from outside the file.
_Avoid_: Broken image, dropped image
+35
View File
@@ -157,6 +157,7 @@ Command line:
```sh
wordcraft-cli convert report.docx report.pdf # docx, pdf, odt, rtf, html, md, tex, txt, png
wordcraft-cli convert report.docx report.pdf --strict # exit 3, no file, if a character or picture would be left out
wordcraft-cli text report.docx # plain text
wordcraft-cli inspect report.docx # structure as JSON
wordcraft-cli run --template sample \
@@ -164,6 +165,40 @@ wordcraft-cli run --template sample \
wordcraft-cli run --script job.json # the same from a file: commands, then save to docx and pdf
```
`convert --strict` (a `.pdf` or `.png` output only) and `render --strict` (a PNG of one page) refuse a silently incomplete render: they exit 3
and write nothing if the output would draw a character no font has (an empty box) or leave out a
picture. Exit codes are 0 (written), 1 (unreadable input, usage error, or `--strict` with another
output type, checked before the input is read) and 3 (strict refusal). On exit 3, stderr has one
`wordcraft-cli: strict: …` line per kind, then one JSON line:
```sh
wordcraft-cli convert in.docx out.pdf --strict
# wordcraft-cli: strict: 1 character cannot be drawn: U+2D30 (×3)
# wordcraft-cli: strict: 1 picture(s) left out: word/media/image2.bin (header2.xml): unsupported format
# {"schema":1,"strict":"failed","missing_glyphs":[{"cp":"U+2D30","count":3}],"missing_pictures":[{"part":"word/media/image2.bin","source_part":"word/header2.xml","reason":"unsupported_format"}],"truncated":false,"total_missing_glyphs":1,"total_missing_pictures":1,"glyphs_truncated":false,"pictures_truncated":false}
```
The JSON line (schema 1, also what `file.strictReport` returns) has `schema`, `strict`
(`"passed"` or `"failed"`), `missing_glyphs` (entries `{"cp", "count"}`, sorted by code point),
`missing_pictures` (entries `{"part", "source_part", "reason"}`: the picture's part or target, the
story part it sits in such as `word/header2.xml`, and a reason code; `source_part` is also
`background` (the page picture), `body`, `header`, `footer`, `footnote`, `endnote`, `comment` or
`text box` where the part name is not known (a document not opened from a DOCX), or a chunk path such as
`word/afchunk1.docx > word/header1.xml` for a picture inside an embedded DOCX chunk), `truncated`, the two totals
`total_missing_glyphs` and `total_missing_pictures`, and `glyphs_truncated` and
`pictures_truncated` for each list. The reason codes are `missing_part`, `external_target`,
`link_only`, `undecodable` and `unsupported_format`; the stderr lines use plain words for them. When a list is cut, the stderr line gives the total
(`300 characters cannot be drawn (first 256 listed)`).
Each list stops at 256 entries separately; `truncated` is true when either was cut, and the totals
count every distinct character and picture found, those past the cap included. Only characters the output draws count (not hidden text, or deleted text
without `--markup`), and a character a fallback font draws is fine. Text inside a WMF or EMF picture counts too, shaped in the font the picture names with no fallback. A Private Use Area character
fails only if no installed font maps it. The result depends on the fonts installed on the host
(see #5624 for a fixed font set). An external picture target is never fetched: it is reported, not
loaded. Pictures dropped when a DOCX is opened (a missing part, an external target, or a `link only` picture with no embedded copy) are reported for DOCX input only. They have no page, so a PNG of any page is also refused for a missing or external picture anywhere in the document, unused headers included. `render --strict` checks only the page it
writes (`--page`; `--scale` doesn't matter), prints nothing on stdout when it refuses, and reports the
same way. The engine takes `strict: true` on `file.save`, `file.saveAs`, `file.exportPdf` and
`file.exportPng`, and `file.strictReport {markup?, page?}` returns the report without writing.
Web: `cd apps/wordcraft-web && trunk serve`, then open <http://127.0.0.1:8771/?sample>.
## For agents: CLI and MCP
+23 -1
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -21,3 +21,8 @@ wordcraft-mcp = { workspace = true }
wordcraft-zotero = { workspace = true }
serde_json = { workspace = true }
sys-locale = "0.3"
[dev-dependencies]
wordcraft-fonts = { workspace = true }
image = { workspace = true }
zip = { workspace = true }
+173 -55
View File
@@ -17,17 +17,23 @@ const USAGE: &str = "\
wordcraft-cli — WordCraft from the command line
USAGE:
wordcraft-cli convert <in> <out> [--markup] [--update-fields] [--format wordml2003|flatopc]
wordcraft-cli convert <in> <out> [--markup] [--update-fields] [--strict] [--format wordml2003|flatopc]
convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png,
xml); --update-fields refreshes unlocked fields before saving;
an .xml file is a Word XML Document (Flat OPC), or with
--format wordml2003 a Word 2003 XML Document;
--markup (Print Markup): comments beside each page and
tracked changes in pdf/png, a comment list in html/md/txt
tracked changes in pdf/png, a comment list in html/md/txt;
--strict (pdf or png output only) writes nothing and exits 3
if a character can't be drawn or a picture is missing or
undecodable; the report is on stderr, with one JSON line
wordcraft-cli info <file> pages, words, paragraphs, properties (JSON)
wordcraft-cli text <file> plain text
wordcraft-cli inspect <file> document structure (JSON)
wordcraft-cli render <file> <out.png> [--page N] [--scale S]
wordcraft-cli render <file> <out.png> [--page N] [--scale S] [--strict]
--strict writes nothing and exits 3 if a character on the
page can't be drawn or a picture is missing or undecodable
(report on stderr, as for convert --strict)
wordcraft-cli run [--file F | --template T] [--script FILE|-] --cmd 'id={json}' [--cmd …]
[--save OUT] [--print] [--author NAME]
run commands headlessly, then save (--author names
@@ -77,6 +83,14 @@ fn wants_help(cmd: &str, rest: &[String]) -> bool {
SUBCOMMANDS.contains(&cmd) && rest.iter().any(|a| a == "--help" || a == "-h")
}
/// A failure whose message may carry text from a file or from the engine's reading of one (a
/// picture's part name, a parse error quoting the input): control, bidi and invisible characters
/// are escaped, so it can't forge or spoof a line (the CLI's own messages, which have line breaks
/// on purpose, are not routed through here). Backslashes stay: messages quote Windows paths.
fn engine_error(e: impl fmt::Display) -> Failure {
Failure::Error(escape_text(&e.to_string(), false))
}
/// The error every subcommand gives when a required path is not there.
fn missing_argument() -> Failure {
format!("missing argument\n\n{USAGE}").into()
@@ -85,11 +99,11 @@ fn missing_argument() -> Failure {
/// Open a document for a subcommand. A password-protected Word file needs its password
/// ([`password_from`]). As in the app, a document opened with one is saved with it again, so
/// `convert` does not quietly write a copy the password no longer protects.
fn open(path: &Path, password: Option<&str>) -> Result<Session, String> {
fn open(path: &Path, password: Option<&str>) -> Result<Session, Failure> {
// A file that gives no page size (HTML, Markdown, plain text) opens on the system region's
// paper, as in the app: A4 in Italy, Letter in the US (#1381).
let region = system_region();
let (doc, encrypted) = wordcraft_engine::io::open_path_in(path, password, &region)?;
let (doc, encrypted) = wordcraft_engine::io::open_path_in(path, password, &region).map_err(engine_error)?;
let mut s = session(doc);
s.path = Some(path.into());
if encrypted {
@@ -198,7 +212,7 @@ fn listed_text(doc: &wordcraft_doc::Document) -> Result<String, String> {
/// The command also checks whether the open document is protected: this name determines which
/// named editing exceptions the session can use, so the flag must not bypass that check.
fn set_author(s: &mut Session, name: &str) -> Result<(), String> {
s.run("file.setAuthor", &json!({"name": name})).map(|_| ()).map_err(|e| format!("--author: {e}"))
s.run("file.setAuthor", &json!({"name": name})).map(|_| ()).map_err(|e| escape_text(&format!("--author: {e}"), false))
}
fn arg_value(args: &[String], name: &str) -> Option<String> {
@@ -310,10 +324,12 @@ const CONVERT_OPTIONS: &[OptionSpec] = &[
OptionSpec { name: "--markup", takes_value: false },
OptionSpec { name: "--format", takes_value: true },
OptionSpec { name: "--update-fields", takes_value: false },
OptionSpec { name: "--strict", takes_value: false },
OptionSpec { name: "--password", takes_value: true },
OptionSpec { name: "--password-file", takes_value: true },
];
const RENDER_OPTIONS: &[OptionSpec] = &[
OptionSpec { name: "--strict", takes_value: false },
OptionSpec { name: "--page", takes_value: true },
OptionSpec { name: "--scale", takes_value: true },
OptionSpec { name: "--password", takes_value: true },
@@ -350,6 +366,9 @@ enum Failure {
/// Whoever read stdout closed it (`wordcraft-cli commands --json | head`): there is nobody left
/// to write to, so stop quietly and exit 0 like other command-line tools.
StdoutClosed,
/// `convert --strict` refused: the text for stderr (the report's lines, then its JSON line).
/// Exit status 3.
Strict(String),
}
impl fmt::Display for Failure {
@@ -357,6 +376,7 @@ impl fmt::Display for Failure {
match self {
Failure::Error(e) => f.write_str(e),
Failure::StdoutClosed => f.write_str("stdout closed"),
Failure::Strict(report) => f.write_str(report),
}
}
}
@@ -402,11 +422,80 @@ fn valid_connect_addr(addr: &str) -> bool {
!host.is_empty() && port.parse::<u16>().is_ok()
}
/// A failed `--strict` export: a strict refusal becomes [`Failure::Strict`] carrying what stderr
/// shows (each line of the report as `wordcraft-cli: …`, then its JSON on one line, from
/// `file.strictReport` for `page` of a page image, else the PDF); any other error is an ordinary
/// failure.
fn refusal(s: &mut Session, e: wordcraft_engine::CmdError, markup: bool, page: Option<u64>) -> Failure {
if !matches!(&e, wordcraft_engine::CmdError::Failed(m) if m.starts_with("strict: ")) {
return engine_error(e);
}
let mut params = json!({"markup": markup});
if let Some(page) = page {
params["page"] = json!(page);
}
Failure::Strict(match s.run("file.strictReport", &params).map_err(|e| e.to_string()) {
Ok(json) => {
let mut text = String::new();
for line in report_lines(&json) {
text += &format!("wordcraft-cli: {}\n", escape_peer(&line));
}
text + &json_line_safe(&json.to_string())
}
Err(e) => format!("wordcraft-cli: strict: refused, and the report could not be made: {}", escape_text(&e, false)),
})
}
/// The report's human lines, from its JSON (the engine's `StrictReport::lines`).
fn report_lines(json: &Value) -> Vec<String> {
wordcraft_engine::strict::lines_from_json(json)
}
/// `convert <in> <out>`: `rest` is the command line after `convert`, `open` opens the input.
fn run_convert(rest: &[String], open: impl Fn(&str, Option<&str>) -> Result<Session, Failure>) -> Result<(), Failure> {
validate_options("convert", rest, CONVERT_OPTIONS)?;
// `--format wordml2003` (or `flatopc`) picks the kind of `.xml` file; its value isn't a path.
let format = match find_option_arg(rest, "--format") {
OptionArg::Absent => None,
OptionArg::Duplicate => return Err("convert: duplicate --format option".into()),
OptionArg::MissingValue => return Err("convert: --format requires a value".into()),
OptionArg::Value(value) => Some(value),
};
let paths = positionals(rest, CONVERT_OPTIONS);
let (i, o) = (paths.first().ok_or_else(missing_argument)?.to_string(), paths.get(1).ok_or_else(missing_argument)?.to_string());
let markup = rest.iter().any(|a| a == "--markup");
let strict = rest.iter().any(|a| a == "--strict");
// Before the input is opened: a wrong output type is a usage error, not a file error.
let to_png = o.to_ascii_lowercase().ends_with(".png");
if strict && !(to_png || o.to_ascii_lowercase().ends_with(".pdf")) {
return Err("convert: --strict needs a .pdf or .png output".into());
}
let password = password_from(rest)?;
let mut s = open(&i, password.as_deref())?;
if rest.iter().any(|a| a == "--update-fields") {
s.run("references.updateFields", &json!({})).map_err(engine_error)?;
}
let r = if to_png {
s.run("file.exportPng", &json!({"path": o, "page": 1, "markup": markup, "strict": strict}))
} else {
let mut params = json!({"path": o, "markup": markup, "strict": strict});
if let Some(f) = format {
params["format"] = json!(f);
}
s.run("file.save", &params)
};
if let Err(e) = r {
return Err(if strict { refusal(&mut s, e, markup, to_png.then_some(1)) } else { engine_error(e) });
}
eprintln!("wrote {o}");
Ok(())
}
fn load_run_script(rest: &[String], script_path: Option<&Path>) -> Result<Option<script::Script>, Failure> {
match arg_value(rest, "--script") {
Some(path) if !path.starts_with("--") => {
let source = script_path.unwrap_or(Path::new(&path));
Ok(Some(script::load(source)?))
Ok(Some(script::load(source).map_err(engine_error)?))
}
_ if rest.iter().any(|arg| arg == "--script") => Err("run: --script requires a value".into()),
_ => Ok(None),
@@ -438,42 +527,13 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
return write_stdout(|o| write!(o, "{USAGE}"));
}
match cmd {
"convert" => {
validate_options("convert", &rest, CONVERT_OPTIONS)?;
// `--format wordml2003` (or `flatopc`) picks the kind of `.xml` file; its value isn't a path.
let format = match find_option_arg(&rest, "--format") {
OptionArg::Absent => None,
OptionArg::Duplicate => return Err("convert: duplicate --format option".into()),
OptionArg::MissingValue => return Err("convert: --format requires a value".into()),
OptionArg::Value(value) => Some(value),
};
let paths = positionals(&rest, CONVERT_OPTIONS);
let (i, o) = (paths.first().ok_or_else(missing_argument)?.to_string(), paths.get(1).ok_or_else(missing_argument)?.to_string());
let markup = rest.iter().any(|a| a == "--markup");
let password = password_from(&rest)?;
let mut s = open(&i, password.as_deref())?;
if rest.iter().any(|a| a == "--update-fields") {
s.run("references.updateFields", &json!({})).map_err(|e| e.to_string())?;
}
let r = if o.to_ascii_lowercase().ends_with(".png") {
s.run("file.exportPng", &json!({"path": o, "page": 1, "markup": markup}))
} else {
let mut params = json!({"path": o, "markup": markup});
if let Some(f) = format {
params["format"] = json!(f);
}
s.run("file.save", &params)
};
r.map_err(|e| e.to_string())?;
eprintln!("wrote {o}");
Ok(())
}
"convert" => run_convert(&rest, open),
"info" => {
validate_options("info", &rest, PASSWORD_OPTIONS)?;
let paths = positionals(&rest, PASSWORD_OPTIONS);
let password = password_from(&rest)?;
let mut s = open(paths.first().ok_or_else(missing_argument)?, password.as_deref())?;
let info = serde_json::to_string_pretty(&s.run("file.info", &json!({})).map_err(|e| e.to_string())?).unwrap_or_default();
let info = serde_json::to_string_pretty(&s.run("file.info", &json!({})).map_err(engine_error)?).unwrap_or_default();
write_stdout(|o| writeln!(o, "{info}"))
}
"text" => {
@@ -489,7 +549,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
let paths = positionals(&rest, PASSWORD_OPTIONS);
let password = password_from(&rest)?;
let mut s = open(paths.first().ok_or_else(missing_argument)?, password.as_deref())?;
let tree = serde_json::to_string_pretty(&s.run("document.inspect", &json!({})).map_err(|e| e.to_string())?).unwrap_or_default();
let tree = serde_json::to_string_pretty(&s.run("document.inspect", &json!({})).map_err(engine_error)?).unwrap_or_default();
write_stdout(|o| writeln!(o, "{tree}"))
}
"render" => {
@@ -503,7 +563,17 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
let page = parse_page_arg("render", &rest)?.unwrap_or(1);
let scale = parse_scale_arg("render", &rest)?.unwrap_or(2.0);
let mut s = open(&i, password.as_deref())?;
let r = s.run("file.exportPng", &json!({"path": o, "page": page, "scale": scale})).map_err(|e| e.to_string())?;
let strict = rest.iter().any(|a| a == "--strict");
let mut params = json!({"path": o, "page": page, "scale": scale, "strict": strict});
if strict {
// The check and its report must see the page as it is written.
params["markup"] = json!(false);
}
let r = match s.run("file.exportPng", &params) {
Ok(r) => r,
Err(e) if strict => return Err(refusal(&mut s, e, false, Some(page))),
Err(e) => return Err(engine_error(e)),
};
write_stdout(|o| writeln!(o, "{r}"))
}
"run" => {
@@ -522,8 +592,8 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
let mut s = session(wordcraft_doc::Document::new());
let first = s.document_id();
let params = t.map_or_else(|| json!({}), |t| json!({"template": t}));
s.run("file.new", &params).map_err(|e| e.to_string())?;
s.close_document(first).map_err(|e| e.to_string())?;
s.run("file.new", &params).map_err(engine_error)?;
s.close_document(first).map_err(engine_error)?;
s
}
};
@@ -532,7 +602,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
}
let print = rest.iter().any(|a| a == "--print");
for (n, (id, params)) in script.iter().flat_map(|c| c.commands.iter()).enumerate() {
let r = s.run(id, params).map_err(|e| format!("command {} ({id}): {e}", n + 1))?;
let r = s.run(id, params).map_err(|e| engine_error(format!("command {} ({id}): {e}", n + 1)))?;
if print {
write_stdout(|o| writeln!(o, "{id}: {r}"))?;
}
@@ -542,10 +612,12 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
if rest.get(i).map(String::as_str) == Some("--cmd") {
let spec = rest.get(i + 1).ok_or("--cmd needs a value")?;
let (id, params) = match spec.split_once('=') {
Some((id, p)) => (id.to_string(), serde_json::from_str::<Value>(p).map_err(|e| format!("{id}: bad JSON params: {e}"))?),
Some((id, p)) => {
(id.to_string(), serde_json::from_str::<Value>(p).map_err(|e| engine_error(format!("{id}: bad JSON params: {e}")))?)
}
None => (spec.clone(), json!({})),
};
let r = s.run(&id, &params).map_err(|e| format!("{id}: {e}"))?;
let r = s.run(&id, &params).map_err(|e| engine_error(format!("{id}: {e}")))?;
if print {
write_stdout(|o| writeln!(o, "{id}: {r}"))?;
}
@@ -565,7 +637,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
return Err(format!("{out}: is a folder, not a file").into());
}
if !out.to_ascii_lowercase().ends_with(".png") {
wordcraft_engine::io::save_bytes(out, &s.doc)?;
wordcraft_engine::io::save_bytes(out, &s.doc).map_err(engine_error)?;
}
}
for out in outputs {
@@ -581,7 +653,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
} else {
s.run("file.save", &json!({"path": out}))
};
r.map_err(|e| e.to_string())?;
r.map_err(engine_error)?;
eprintln!("wrote {out}");
}
Ok(())
@@ -680,10 +752,12 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
let mut s = open(file, None)?;
for spec in rest.iter().zip(rest.iter().skip(1)).filter(|(a, _)| *a == "--cmd").map(|(_, v)| v) {
let (id, params) = match spec.split_once('=') {
Some((id, p)) => (id.to_string(), serde_json::from_str::<Value>(p).map_err(|e| format!("{id}: bad JSON params: {e}"))?),
Some((id, p)) => {
(id.to_string(), serde_json::from_str::<Value>(p).map_err(|e| engine_error(format!("{id}: bad JSON params: {e}")))?)
}
None => (spec.clone(), json!({})),
};
s.run(&id, &params).map_err(|e| format!("{id}: {e}"))?;
s.run(&id, &params).map_err(|e| engine_error(format!("{id}: {e}")))?;
}
let mut opts = wordcraft_zotero::client::Options::default();
if let Some(p) = arg_value(&rest, "--port") {
@@ -700,13 +774,13 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
}
r
})
.map_err(|e| e.to_string())?;
.map_err(engine_error)?;
for a in &host.alerts {
eprintln!("Zotero: {}", escape_peer(a));
}
eprintln!("{} calls, {}", out.calls, if out.completed { "completed" } else { "ended without completing (cancelled?)" });
if let Some(o) = arg_value(&rest, "--save") {
s.run("file.save", &json!({"path": o})).map_err(|e| e.to_string())?;
s.run("file.save", &json!({"path": o})).map_err(engine_error)?;
eprintln!("wrote {o}");
}
Ok(())
@@ -730,22 +804,62 @@ fn clip(s: &str) -> String {
/// overrides are shown as escapes (`\\`, `\n`, `\u{1b}`), so it can't end its line, drive the
/// terminal or pass for other output.
fn escape_peer(s: &str) -> String {
escape_text(s, true)
}
/// Whether `c` must not reach a terminal as itself: a control character, a line or paragraph
/// separator, a bidi control, or one that shows nothing (soft hyphen, zero-width characters, word
/// joiners, the byte order mark, tag characters), which can hide or reorder what a line says.
fn is_unsafe_char(c: char) -> bool {
c.is_control()
|| matches!(
c,
'\u{00ad}'
| '\u{061c}'
| '\u{200b}'..='\u{200f}'
| '\u{2028}'..='\u{202e}'
| '\u{2060}'..='\u{2064}'
| '\u{2066}'..='\u{2069}'
| '\u{feff}'
| '\u{e0000}'..='\u{e007f}'
)
}
/// [`escape_peer`], with backslashes escaped only when `backslash` (error messages quote Windows
/// paths, and a backslash can't forge or hide anything).
fn escape_text(s: &str, backslash: bool) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'\\' if backslash => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c.is_control() || matches!(c, '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{2028}'..='\u{202e}' | '\u{2066}'..='\u{2069}') => {
out.extend(c.escape_unicode())
}
c if is_unsafe_char(c) => out.extend(c.escape_unicode()),
c => out.push(c),
}
}
out
}
/// A JSON line safe to print: serde escapes control characters below U+0020 but not the other
/// characters [`is_unsafe_char`] names, which a crafted file could put in a report. They become
/// `\uXXXX` escapes (a surrogate pair above U+FFFF), so the line parses to the same value and
/// can't drive or spoof a terminal.
fn json_line_safe(json: &str) -> String {
let mut out = String::with_capacity(json.len());
for c in json.chars() {
if is_unsafe_char(c) {
for unit in c.encode_utf16(&mut [0u16; 2]) {
out.push_str(&format!("\\u{unit:04x}"));
}
} else {
out.push(c);
}
}
out
}
fn parity_markdown(p: &Value) -> String {
let mut s = String::from(
"# WordCraft feature parity\n\nGenerated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.\n\n",
@@ -786,6 +900,10 @@ fn main() -> ExitCode {
eprintln!("wordcraft-cli: {e}");
ExitCode::FAILURE
}
Err(Failure::Strict(report)) => {
eprintln!("{report}");
ExitCode::from(3)
}
}
}
+16
View File
@@ -564,3 +564,19 @@ fn script_parse_resolves_paths_against_the_base() {
assert_eq!(s.save, vec!["o.docx".to_string()]);
assert_eq!(s.commands.len(), 1);
}
#[test]
fn invisible_characters_are_escaped_and_json_lines_still_parse_back() {
let hidden = "a\u{ad}b\u{200b}c\u{200d}d\u{2060}e\u{2064}f\u{feff}g\u{e0041}h\u{e007f}i\u{85}j\u{202e}k";
let shown = escape_peer(hidden);
assert!(!shown.chars().any(is_unsafe_char), "{shown}");
assert!(shown.contains("\\u{e0041}") && shown.contains("\\u{ad}"), "{shown}");
// Errors keep a Windows path readable.
assert_eq!(escape_text("C:\\dir\\a.docx", false), "C:\\dir\\a.docx");
let value = json!({"part": hidden, "plain": "ünïcode ok"});
let line = json_line_safe(&value.to_string());
assert!(!line.chars().any(is_unsafe_char), "{line}");
assert!(line.contains("\\ud800") || line.contains("\\udb40"), "{line}");
assert_eq!(serde_json::from_str::<Value>(&line).unwrap(), value);
}
+1
View File
@@ -6,4 +6,5 @@ mod help;
#[cfg(all(unix, not(target_os = "macos")))]
mod non_utf8_args;
mod picture_then_typing;
mod strict;
mod text_tracked;
+495
View File
@@ -0,0 +1,495 @@
//! `convert --strict` (#6005): exit 3 and a report when a PDF or PNG would silently lack a
//! character or a picture, nothing written; exit 0 otherwise.
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use wordcraft_doc::Document;
use wordcraft_fonts::FontDb;
const NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture""#;
const REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
const RELS_NS: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
fn dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("wc-cli-strict-{name}-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d
}
fn convert(input: &Path, output: &Path, strict: bool) -> Output {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
cmd.arg("convert").arg(input).arg(output);
if strict {
cmd.arg("--strict");
}
cmd.output().unwrap()
}
fn stderr(o: &Output) -> String {
String::from_utf8_lossy(&o.stderr).into_owned()
}
/// The report's JSON line: the last line of stderr.
fn report_json(o: &Output) -> serde_json::Value {
let text = stderr(o);
let last = text.lines().last().unwrap_or_default();
serde_json::from_str(last).unwrap_or_else(|e| panic!("last stderr line is not JSON ({e}): {text}"))
}
fn json_input(d: &Path, text: &str) -> PathBuf {
let path = d.join("in.json");
std::fs::write(&path, serde_json::to_vec(&Document::from_text(text)).unwrap()).unwrap();
path
}
/// Built without craft-fonts, the host's own fonts decide what is drawable: skip, as the layout
/// tests do.
fn without_fonts() -> bool {
let missing = option_env!("CRAFT_FONTS_DIR").is_none_or(str::is_empty);
if missing {
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a checkout)");
}
missing
}
/// A code point in unassigned plane 4 that no face of the host covers.
fn uncovered() -> char {
let db = FontDb::global();
let primary = wordcraft_fonts::word::resolve(&Document::default().settings.minor_font, false, false).face;
(0x40000u32..0x4FFF0)
.filter_map(char::from_u32)
.find(|c| !primary.covers(*c) && db.fallback_styled(*c, primary.id(), false, false).is_none() && db.fallback_for(*c, primary.id()).is_none())
.expect("the host has faces covering all of plane 4")
}
fn png() -> Vec<u8> {
let mut out = Vec::new();
image::RgbaImage::from_pixel(4, 4, image::Rgba([20, 90, 200, 255]))
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
.unwrap();
out
}
fn pic(rid: &str) -> String {
format!(
r#"<w:r><w:drawing><wp:inline><wp:extent cx="508000" cy="508000"/><wp:docPr id="1" name="P"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="p"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="{rid}"/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="508000" cy="508000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
)
}
/// A DOCX whose header holds a picture of `header_media` (`word/media/image2.bin`).
fn docx_with_header_picture(header_media: &[u8]) -> Vec<u8> {
let doc = format!(
r#"<w:document {NS}><w:body><w:p><w:r><w:t>text</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr></w:body></w:document>"#
);
let drels = format!(r#"<Relationships xmlns="{RELS_NS}"><Relationship Id="rIdH" Type="{REL}/header" Target="header2.xml"/></Relationships>"#);
let hrels = format!(r#"<Relationships xmlns="{RELS_NS}"><Relationship Id="rIdI" Type="{REL}/image" Target="media/image2.bin"/></Relationships>"#);
let hdr = format!(r#"<w:hdr {NS}><w:p>{}</w:p></w:hdr>"#, pic("rIdI"));
package(
&doc,
&drels,
&[("word/header2.xml", hdr.as_bytes()), ("word/_rels/header2.xml.rels", hrels.as_bytes()), ("word/media/image2.bin", header_media)],
)
}
/// A DOCX whose body picture points outside the file, at `target`.
fn docx_with_external_picture(target: &str) -> Vec<u8> {
let doc = format!(r#"<w:document {NS}><w:body><w:p>{}<w:r><w:t>text</w:t></w:r></w:p></w:body></w:document>"#, pic("rIdX"));
let drels = format!(
r#"<Relationships xmlns="{RELS_NS}"><Relationship Id="rIdX" Type="{REL}/image" Target="{target}" TargetMode="External"/></Relationships>"#
);
package(&doc, &drels, &[])
}
fn package(document: &str, document_rels: &str, extra: &[(&str, &[u8])]) -> Vec<u8> {
let rels = format!(
r#"<Relationships xmlns="{RELS_NS}"><Relationship Id="rId1" Type="{REL}/officeDocument" Target="word/document.xml"/></Relationships>"#
);
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="png" ContentType="image/png"/><Default Extension="bin" ContentType="application/octet-stream"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let mut parts: Vec<(&str, &[u8])> = vec![
("[Content_Types].xml", types.as_bytes()),
("_rels/.rels", rels.as_bytes()),
("word/document.xml", document.as_bytes()),
("word/_rels/document.xml.rels", document_rels.as_bytes()),
];
parts.extend_from_slice(extra);
for (name, bytes) in parts {
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap().into_inner()
}
#[test]
fn multilingual_names_pass_strict() {
if without_fonts() {
return;
}
let names = "Łukasz Żmijewski\nGheorghe Ștefănescu\nKrejčí\nŞahın Gül\nLlŷr Grŵn\n王小明\n김민수\nสมชาย ใจดี\nराम शर्मा";
let d = dir("names");
let input = json_input(&d, names);
for out in ["names.pdf", "names.png"] {
let out = d.join(out);
let r = convert(&input, &out, true);
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
assert!(out.exists());
}
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn an_uncovered_code_point_fails_strict_with_a_report_and_no_file() {
let c = uncovered();
let cp = format!("U+{:04X}", c as u32);
let d = dir("uncovered");
let input = json_input(&d, &format!("a{c}b{c}{c}"));
for out in ["out.pdf", "out.png"] {
let out = d.join(out);
let r = convert(&input, &out, true);
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
let text = stderr(&r);
assert!(text.contains(&format!("wordcraft-cli: strict: 1 character cannot be drawn: {cp} (×3)")), "{text}");
let json = report_json(&r);
assert_eq!(json["schema"], 1);
assert_eq!(json["strict"], "failed");
assert_eq!(json["missing_glyphs"][0]["cp"], cp.as_str());
assert_eq!(json["missing_glyphs"][0]["count"], 3);
assert!(!out.exists(), "{} was written", out.display());
eprintln!("{text}");
// Without --strict the same input converts, as before.
let r = convert(&input, &out, false);
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
assert!(out.exists());
std::fs::remove_file(&out).unwrap();
}
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn a_real_png_in_a_header_passes_strict() {
let d = dir("header-png");
let input = d.join("in.docx");
std::fs::write(&input, docx_with_header_picture(&png())).unwrap();
for out in ["out.pdf", "out.png"] {
let out = d.join(out);
let r = convert(&input, &out, true);
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
assert!(out.exists());
}
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn a_corrupt_header_picture_fails_strict_naming_the_part_and_the_header() {
let d = dir("header-corrupt");
let input = d.join("in.docx");
std::fs::write(&input, docx_with_header_picture(b"corrupt bytes, not a picture")).unwrap();
for out in ["out.pdf", "out.png"] {
let out = d.join(out);
let r = convert(&input, &out, true);
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
let text = stderr(&r);
assert!(text.contains("word/media/image2.bin (header2.xml): unsupported format"), "{text}");
let json = report_json(&r);
assert_eq!(json["strict"], "failed");
assert_eq!(
json["missing_pictures"][0],
serde_json::json!({"part": "word/media/image2.bin", "source_part": "word/header2.xml", "reason": "unsupported_format"})
);
assert!(!out.exists());
eprintln!("{text}");
}
// Without --strict the grey box is drawn, as before.
let out = d.join("lenient.pdf");
assert_eq!(convert(&input, &out, false).status.code(), Some(0));
assert!(out.exists());
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn an_external_picture_target_is_never_fetched_and_is_reported_under_strict() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let target = format!("http://{}/canary.png", listener.local_addr().unwrap());
let d = dir("external");
let input = d.join("in.docx");
std::fs::write(&input, docx_with_external_picture(&target)).unwrap();
let lenient = convert(&input, &d.join("lenient.pdf"), false);
assert_eq!(lenient.status.code(), Some(0), "{}", stderr(&lenient));
let out = d.join("strict.pdf");
let strict = convert(&input, &out, true);
assert_eq!(strict.status.code(), Some(3), "{}", stderr(&strict));
let json = report_json(&strict);
assert_eq!(json["missing_pictures"][0]["reason"], "external_target");
assert!(stderr(&strict).contains("external target"));
assert!(!out.exists());
match listener.accept() {
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
Ok((_, from)) => panic!("the converter connected to the external target from {from}"),
Err(e) => panic!("{e}"),
}
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn bad_input_exits_1_with_the_usual_message_with_and_without_strict() {
let d = dir("bad-input");
let good = docx_with_header_picture(&png());
let truncated = d.join("truncated.docx");
std::fs::write(&truncated, &good[..good.len() / 2]).unwrap();
let not_zip = d.join("not-zip.docx");
std::fs::write(&not_zip, b"this is plainly not a zip archive").unwrap();
for input in [&truncated, &not_zip] {
let plain = convert(input, &d.join("a.pdf"), false);
let strict = convert(input, &d.join("b.pdf"), true);
assert_eq!(plain.status.code(), Some(1), "{}", stderr(&plain));
assert_eq!(strict.status.code(), Some(1), "{}", stderr(&strict));
assert_eq!(stderr(&plain), stderr(&strict));
assert!(stderr(&plain).starts_with("wordcraft-cli: "), "{}", stderr(&plain));
assert!(!d.join("a.pdf").exists() && !d.join("b.pdf").exists());
}
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn strict_with_another_output_type_exits_1_before_the_input_is_read() {
let d = dir("wrong-output");
let missing = d.join("does-not-exist.docx");
for out in ["out.docx", "out.txt", "out.odt"] {
let r = convert(&missing, &d.join(out), true);
assert_eq!(r.status.code(), Some(1));
let text = stderr(&r);
assert!(text.contains("--strict needs a .pdf or .png output"), "{text}");
assert!(!d.join(out).exists());
}
// Without --strict the same command fails on the missing input.
let r = convert(&missing, &d.join("out.docx"), false);
assert_eq!(r.status.code(), Some(1));
assert!(!stderr(&r).contains("--strict"));
std::fs::remove_dir_all(d).unwrap();
}
fn render(input: &Path, output: &Path, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).arg("render").arg(input).arg(output).args(args).output().unwrap()
}
/// Page 1 has `a{c}b`, page 2 plain text.
fn two_page_input(d: &Path, text: &str) -> PathBuf {
let mut doc = Document::from_text(&format!("{text}\nSecond page"));
doc.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap().props.page_break_before = Some(true);
let path = d.join("two.json");
std::fs::write(&path, serde_json::to_vec(&doc).unwrap()).unwrap();
path
}
#[test]
fn render_strict_refuses_an_uncovered_character_on_the_page_with_no_stdout_or_file() {
let c = uncovered();
let cp = format!("U+{:04X}", c as u32);
let d = dir("render-glyph");
let input = two_page_input(&d, &format!("a{c}b"));
let out = d.join("p1.png");
let r = render(&input, &out, &["--strict"]);
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
assert!(r.stdout.is_empty(), "{}", String::from_utf8_lossy(&r.stdout));
assert!(!out.exists());
let text = stderr(&r);
assert!(text.contains(&format!("wordcraft-cli: strict: 1 character cannot be drawn: {cp} (×1)")), "{text}");
assert_eq!(report_json(&r)["missing_glyphs"][0]["cp"], cp.as_str());
eprintln!("{text}");
// The character is not on page 2, and --scale does not matter to the check.
let out2 = d.join("p2.png");
let r = render(&input, &out2, &["--page", "2", "--strict", "--scale", "0.5"]);
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
assert!(out2.exists());
assert!(String::from_utf8_lossy(&r.stdout).contains("\"path\""));
// And the same page 1 without --strict renders, with the usual stdout.
let out3 = d.join("lenient.png");
let r = render(&input, &out3, &[]);
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
assert!(out3.exists());
let json: serde_json::Value = serde_json::from_slice(&r.stdout).unwrap();
assert_eq!(json["path"], out3.to_string_lossy().as_ref());
assert!(json["width"].as_u64().is_some() && json["bytes"].as_u64().is_some());
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn render_strict_refuses_a_corrupt_header_picture_and_bad_input_still_exits_1() {
let d = dir("render-pic");
let input = d.join("in.docx");
std::fs::write(&input, docx_with_header_picture(b"corrupt bytes, not a picture")).unwrap();
let out = d.join("out.png");
let r = render(&input, &out, &["--strict"]);
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
assert!(r.stdout.is_empty());
assert!(stderr(&r).contains("word/media/image2.bin (header2.xml)"), "{}", stderr(&r));
assert!(!out.exists());
let bad = d.join("bad.docx");
std::fs::write(&bad, b"this is plainly not a zip archive").unwrap();
let (plain, strict) = (render(&bad, &out, &[]), render(&bad, &out, &["--strict"]));
assert_eq!((plain.status.code(), strict.status.code()), (Some(1), Some(1)));
assert_eq!(stderr(&plain), stderr(&strict));
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn a_picture_target_with_a_newline_and_a_bidi_override_cannot_forge_a_report_line() {
let d = dir("escape");
let input = d.join("in.docx");
// XML character references put a newline and U+202E in the target.
std::fs::write(&input, docx_with_external_picture("http://example.invalid/a&#10;wordcraft-cli: strict: forged&#x202E;gnp.png")).unwrap();
let out = d.join("out.pdf");
let r = convert(&input, &out, true);
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
let text = stderr(&r);
let lines: Vec<&str> = text.lines().collect();
// One human line for the picture, then the JSON line.
assert_eq!(lines.len(), 2, "{text}");
assert!(lines[0].starts_with("wordcraft-cli: strict: 1 picture(s) left out: "), "{text}");
assert!(lines[0].contains("\\nwordcraft-cli: strict: forged\\u{202e}gnp.png"), "{text}");
assert!(!text.contains('\u{202e}'), "the raw bidi override reached the terminal");
// The JSON keeps the raw value, escaped by the JSON encoder.
let json = report_json(&r);
let part = json["missing_pictures"][0]["part"].as_str().unwrap();
assert!(part.contains('\n') && part.contains('\u{202e}'), "{part:?}");
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn run_with_strict_cannot_forge_an_error_line_either() {
let d = dir("run-escape");
let input = d.join("in.docx");
std::fs::write(&input, docx_with_external_picture("http://example.invalid/a&#10;wordcraft-cli: strict: forged&#x1B;[2J&#x202E;gnp.png")).unwrap();
let out = d.join("out.pdf");
let cmd = format!("file.exportPdf={}", serde_json::json!({"path": out, "strict": true}));
let r = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).arg("run").arg("--file").arg(&input).arg("--cmd").arg(cmd).output().unwrap();
assert_eq!(r.status.code(), Some(1));
let text = stderr(&r);
assert_eq!(text.lines().count(), 1, "{text}");
assert!(text.contains("\\u{1b}") || text.contains("\\u{1b}[2J"), "{text}");
assert!(!text.contains('\u{1b}') && !text.contains('\u{202e}'), "{text:?}");
assert!(!out.exists());
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn usage_errors_keep_their_line_breaks() {
// Errors go to stderr with the usage after a blank line (`missing argument`, `unknown command`).
for args in [&["frobnicate"][..], &["convert"][..]] {
let r = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).args(args).output().unwrap();
let text = stderr(&r);
assert_eq!(r.status.code(), Some(1), "{args:?}: {text}");
assert!(text.lines().count() > 3, "{args:?}: {text}");
assert!(text.lines().any(|l| l == "USAGE:"), "{args:?}: {text}");
assert!(!text.contains("\\n"), "{args:?}: {text}");
}
// No arguments at all prints the usage as help, on stdout.
let r = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).output().unwrap();
let all = format!("{}{}", String::from_utf8_lossy(&r.stdout), stderr(&r));
assert!(all.lines().count() > 3 && all.lines().any(|l| l == "USAGE:"), "{all}");
}
/// A minimal EMF (88-byte header, 100 mm square frame) with one EMR_EXTTEXTOUTW record showing
/// `text` in the default font.
fn emf_with_text(text: &str) -> Vec<u8> {
let units: Vec<u16> = text.encode_utf16().collect();
let mut rec = vec![0u8; 76];
rec[0..4].copy_from_slice(&84u32.to_le_bytes());
rec[36..40].copy_from_slice(&10i32.to_le_bytes());
rec[40..44].copy_from_slice(&40i32.to_le_bytes());
rec[44..48].copy_from_slice(&(units.len() as u32).to_le_bytes());
rec[48..52].copy_from_slice(&76u32.to_le_bytes());
rec.extend(units.iter().flat_map(|u| u.to_le_bytes()));
while !rec.len().is_multiple_of(4) {
rec.push(0);
}
let len = rec.len() as u32;
rec[4..8].copy_from_slice(&len.to_le_bytes());
let eof: Vec<u8> = [14u32.to_le_bytes(), 20u32.to_le_bytes(), [0; 4], [0; 4], 20u32.to_le_bytes()].concat();
let total = 88 + rec.len() + eof.len();
let mut h = vec![0u8; 88];
h[0..4].copy_from_slice(&1u32.to_le_bytes());
h[4..8].copy_from_slice(&88u32.to_le_bytes());
h[8..24].copy_from_slice(&[0i32, 0, 100, 100].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<u8>>());
h[24..40].copy_from_slice(&[0i32, 0, 10000, 10000].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<u8>>());
h[40..44].copy_from_slice(&0x464D_4520u32.to_le_bytes());
h[44..48].copy_from_slice(&0x0001_0000u32.to_le_bytes());
h[48..52].copy_from_slice(&(total as u32).to_le_bytes());
h[52..56].copy_from_slice(&2u32.to_le_bytes());
h[56..58].copy_from_slice(&1u16.to_le_bytes());
h[72..76].copy_from_slice(&1000i32.to_le_bytes());
h[76..80].copy_from_slice(&1000i32.to_le_bytes());
h[80..84].copy_from_slice(&250i32.to_le_bytes());
h[84..88].copy_from_slice(&250i32.to_le_bytes());
[h, rec, eof].concat()
}
/// The text a metafile picture draws is checked like the page's own: a character no face covers
/// refuses the export from `convert` and `render`, naming the code point, and nothing is written.
#[test]
fn a_character_in_a_metafile_pictures_text_fails_strict_like_the_pages_own() {
if without_fonts() {
return;
}
let c = uncovered();
let cp = format!("U+{:04X}", c as u32);
let d = dir("emf-text");
let emf = emf_with_text(&format!("ab{c}"));
let input = d.join("in.docx");
std::fs::write(&input, docx_with_header_picture(&emf)).unwrap();
for out in ["out.pdf", "out.png"] {
let out = d.join(out);
let r = convert(&input, &out, true);
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
assert_eq!(report_json(&r)["missing_glyphs"][0]["cp"], cp.as_str(), "{}", stderr(&r));
assert!(!out.exists(), "{} was written", out.display());
// Without --strict it converts, the character a hollow box.
let r = convert(&input, &out, false);
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
std::fs::remove_file(&out).unwrap();
}
let out = d.join("p1.png");
let r = render(&input, &out, &["--strict"]);
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
assert!(r.stdout.is_empty() && !out.exists());
assert_eq!(report_json(&r)["missing_glyphs"][0]["cp"], cp.as_str());
// Text the faces cover passes.
std::fs::write(&input, docx_with_header_picture(&emf_with_text("abc"))).unwrap();
let r = convert(&input, &d.join("ok.pdf"), true);
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
std::fs::remove_dir_all(d).unwrap();
}
/// The page raster leaves out a metafile page background (it only draws raster ones), so a PNG
/// would silently lack it: strict refuses that output, and a PDF, which draws it as vector, passes.
#[test]
fn a_metafile_page_background_fails_strict_for_png_but_not_for_pdf() {
if without_fonts() {
return;
}
let d = dir("emf-background");
let mut doc = Document::from_text("text");
doc.media.insert("bg.emf".into(), std::sync::Arc::new(emf_with_text("abc")));
doc.settings.page_picture = Some(wordcraft_doc::PagePicture { media: "bg.emf".into(), fill_type: "frame".into(), title: String::new() });
let input = d.join("in.json");
std::fs::write(&input, serde_json::to_vec(&doc).unwrap()).unwrap();
let png = d.join("out.png");
let r = convert(&input, &png, true);
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
let json = report_json(&r);
assert_eq!(json["missing_pictures"][0]["part"], "bg.emf", "{}", stderr(&r));
assert_eq!(json["missing_pictures"][0]["source_part"], "background");
assert_eq!(json["missing_pictures"][0]["reason"], "unsupported_format");
assert!(!png.exists());
let pdf = d.join("out.pdf");
let r = convert(&input, &pdf, true);
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
assert!(pdf.exists());
std::fs::remove_dir_all(d).unwrap();
}
+3
View File
@@ -204,3 +204,6 @@ real_name = "Helen Kwok"
[people.ashokram-si]
real_name = "Ashok Ram"
[people.benandez]
sync_github_name = true
+3
View File
@@ -77,6 +77,7 @@ impl Document {
glossary: Vec::new(),
media_index: MediaIndex::default(),
kept: Default::default(),
missing_pictures: Default::default(),
}
}
@@ -154,6 +155,7 @@ impl Document {
glossary,
media_index: _,
kept: _,
missing_pictures: _,
} = self;
let Document {
body: other_body,
@@ -174,6 +176,7 @@ impl Document {
glossary: other_glossary,
media_index: _,
kept: _,
missing_pictures: _,
} = other;
same_blocks(body, other_body)
&& same_parts(parts, other_parts)
+6
View File
@@ -75,6 +75,7 @@ mod block;
mod compare;
mod document;
mod error;
mod missing;
mod parts;
mod position;
mod settings;
@@ -84,6 +85,7 @@ pub use block::{Block, Blocks, BoxBudget, MAX_TEXT_BOX_DEPTH};
pub use compare::para_block;
use document::MediaIndex;
pub use error::{DocError, Result};
pub use missing::{MAX_MISSING_PICTURES, MissingPicture, MissingPictureReason, MissingPictures, picture_hash};
pub use parts::{MAX_LINKED_BOXES, MAX_TEXT_BOX_INSET, NoteSeparators, Part, PartKind, PermRange, TEXT_BOX_INSET, TextBody, free_id};
pub use position::{Path, Pos, StoryRef};
pub use settings::{
@@ -138,6 +140,10 @@ pub struct Document {
/// [`kept`]); never saved, ignored by `==`.
#[serde(skip)]
pub kept: kept::Kept,
/// Pictures a reader dropped because their target is missing, external or link-only (strict
/// export reports them); never saved, never drawn, ignored by `==`.
#[serde(skip)]
pub missing_pictures: MissingPictures,
}
#[cfg(test)]
+143
View File
@@ -0,0 +1,143 @@
//! Pictures a reader could not bring in or an exporter could not draw, for strict export (#6005).
//! Kept beside the document while it is open; never saved, never drawn, and not part of the
//! document's content (`==` ignores it).
use serde::{Deserialize, Serialize};
/// Most pictures remembered per document; past it [`MissingPictures::truncated`] is set.
pub const MAX_MISSING_PICTURES: usize = 256;
/// Most characters of a picture's part or story name a record keeps (names come from the file).
pub const MAX_PICTURE_NAME_CHARS: usize = 1024;
/// Why a picture is not in the document or not drawn.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum MissingPictureReason {
/// The relationship names a part the file does not contain, or no relationship at all.
MissingPart,
/// The target is outside the file (`TargetMode="External"`); it is never fetched.
ExternalTarget,
/// The picture only links to a file (`r:link`) and holds no embedded copy.
LinkOnly,
/// The bytes are a picture format we know but they do not decode.
Undecodable,
/// The bytes are not a picture format we can draw.
UnsupportedFormat,
}
impl MissingPictureReason {
/// The words used in reports (`"missing part"`, `"external target"`, `"link only"`,
/// `"undecodable"`, `"unsupported format"`).
pub fn label(self) -> &'static str {
match self {
Self::MissingPart => "missing part",
Self::ExternalTarget => "external target",
Self::LinkOnly => "link only",
Self::Undecodable => "undecodable",
Self::UnsupportedFormat => "unsupported format",
}
}
/// The reason a [`code`](Self::code) names.
pub fn from_code(code: &str) -> Option<Self> {
[Self::MissingPart, Self::ExternalTarget, Self::LinkOnly, Self::Undecodable, Self::UnsupportedFormat].into_iter().find(|r| r.code() == code)
}
/// The stable snake_case code the JSON report carries (`"missing_part"`, `"external_target"`,
/// `"link_only"`, `"undecodable"`, `"unsupported_format"`); never reworded.
pub fn code(self) -> &'static str {
match self {
Self::MissingPart => "missing_part",
Self::ExternalTarget => "external_target",
Self::LinkOnly => "link_only",
Self::Undecodable => "undecodable",
Self::UnsupportedFormat => "unsupported_format",
}
}
}
/// One picture that is missing.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct MissingPicture {
/// The target as written, or the part name it resolved to (a media key for a decoded one).
pub part: String,
/// The story part it is in (`word/document.xml`, `word/header2.xml`), as the source file
/// names it; empty when not known.
pub story: String,
pub reason: MissingPictureReason,
}
/// A capped list of [`MissingPicture`]s, with the source names a report needs to say where a
/// picture is (a reader fills them; a document made or opened another way leaves them empty).
#[derive(Clone, Debug, Default)]
pub struct MissingPictures {
pub list: Vec<MissingPicture>,
/// More than [`MAX_MISSING_PICTURES`] were found; the rest are not listed.
pub truncated: bool,
/// Pictures found, those past the cap included (saturating). Each distinct one counts once.
pub total: u64,
/// The main story's part name in the source file (`word/document.xml`).
pub main: String,
/// A header, footer, note or comment story's part id ([`crate::Document::parts`]) to the
/// part it was read from (`word/header2.xml`). Ids freed and reused after opening may name
/// the old part.
pub part_names: std::collections::BTreeMap<u32, std::sync::Arc<str>>,
/// A [`crate::Document::media`] key to the part its bytes were read from
/// (`word/media/image2.bin`).
pub media_parts: std::collections::BTreeMap<String, String>,
/// What `list` holds, for an O(1) test of "already there" (a hostile file has many).
seen: std::collections::HashSet<MissingPicture>,
/// Hashes of the distinct pictures past the cap (8 bytes each), so the total counts them once.
unlisted: std::collections::HashSet<u64>,
/// The chunk parts whose unlisted pictures [`Self::add_unlisted`] has counted, so a chunk
/// imported twice counts them once.
counted_chunks: std::collections::HashSet<String>,
}
impl MissingPictures {
/// Add `n` pictures of the chunk part `chunk` that its reader counted but did not list. The
/// same chunk part counts once however often it is imported (its listed pictures dedupe
/// through [`Self::push`]).
pub fn add_unlisted(&mut self, chunk: &str, n: u64) {
if n > 0 && self.counted_chunks.insert(chunk.to_string()) {
self.total = self.total.saturating_add(n);
}
}
/// Add one (the same part, story and reason once).
pub fn push(&mut self, mut item: MissingPicture) {
for name in [&mut item.part, &mut item.story] {
if name.len() > MAX_PICTURE_NAME_CHARS {
*name = name.chars().take(MAX_PICTURE_NAME_CHARS).collect();
}
}
if self.seen.contains(&item) {
return;
}
if self.list.len() >= MAX_MISSING_PICTURES {
self.truncated = true;
if self.unlisted.insert(picture_hash(&item)) {
self.total = self.total.saturating_add(1);
}
return;
}
self.total = self.total.saturating_add(1);
self.seen.insert(item.clone());
self.list.push(item);
}
}
/// A 64-bit hash of a record, to tell the ones past a cap apart without keeping them.
pub fn picture_hash(item: &MissingPicture) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
item.hash(&mut h);
h.finish()
}
impl PartialEq for MissingPictures {
/// Not part of the document's content.
fn eq(&self, _: &Self) -> bool {
true
}
}
+5
View File
@@ -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`].
+28
View File
@@ -786,3 +786,31 @@ fn lone_table_open_markers_mixed_tables_and_paired() {
assert!(res1.controls.open.is_empty());
assert_eq!(res1.controls.close, 0);
}
#[test]
fn missing_picture_names_are_capped_at_a_char_boundary() {
let mut d = Document::new();
let long: String = "é".repeat(5000);
d.missing_pictures.push(MissingPicture { part: long.clone(), story: long, reason: MissingPictureReason::MissingPart });
let item = &d.missing_pictures.list[0];
assert_eq!((item.part.chars().count(), item.story.chars().count()), (1024, 1024));
}
#[test]
fn missing_pictures_are_capped_deduplicated_and_not_content() {
let mut d = Document::new();
let item = |n: usize| MissingPicture { part: format!("p{n}"), story: "word/document.xml".into(), reason: MissingPictureReason::MissingPart };
d.missing_pictures.push(item(0));
d.missing_pictures.push(item(0));
assert_eq!(d.missing_pictures.list.len(), 1);
for n in 1..MAX_MISSING_PICTURES + 3 {
d.missing_pictures.push(item(n));
}
assert_eq!(d.missing_pictures.list.len(), MAX_MISSING_PICTURES);
assert!(d.missing_pictures.truncated);
let e = Document::new();
assert_eq!(d, e);
assert!(d.same_content(&e));
assert!(!serde_json::to_string(&d).unwrap().contains("p0"));
assert_eq!(MissingPictureReason::ExternalTarget.label(), "external target");
}
+10 -1
View File
@@ -27,7 +27,8 @@ use wordcraft_doc::props::VAlign;
use wordcraft_doc::section::NumFormat;
use wordcraft_doc::styles::{PartFormat, Style, StyleKind, StyleSheet, TablePart, TableStyleParts};
use wordcraft_doc::{
Block, Blocks, Comment, Document, InlineObject, PagePicture, Paragraph, PartKind, Revision, RevisionKind, Zoom, ZoomFit, para_block,
Block, Blocks, Comment, Document, InlineObject, MissingPicture, MissingPictureReason, PagePicture, Paragraph, PartKind, Revision, RevisionKind,
Zoom, ZoomFit, para_block,
};
use crate::DocxError;
@@ -103,6 +104,8 @@ pub(crate) struct Reader<'p> {
pub shape_ids: bool,
/// The main document part's name.
main: String,
/// The part being read (`word/header2.xml`), for naming where a dropped picture was.
story: Arc<str>,
/// The package's content types, read when first needed.
content_types: Option<ContentTypes>,
/// Parts kept for charts, diagrams and OLE objects (see `embed`).
@@ -183,6 +186,7 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize,
glued: HashSet::new(),
shape_ids: false,
main: main.clone(),
story: main.as_str().into(),
content_types: None,
embedded: EmbeddedManifest::default(),
txbx_links: Vec::new(),
@@ -190,6 +194,7 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize,
group_fill: None,
chunks,
};
r.doc.missing_pictures.main = main.clone();
r.pc.major_font = r.doc.settings.major_font.clone();
r.pc.minor_font = r.doc.settings.minor_font.clone();
let part = |t: &str| rels.by_type(t).filter(|x| !x.external).map(|x| x.target.clone());
@@ -230,14 +235,18 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize,
let ex = part(rt::COMMENTS_EX);
let ids = part(rt::COMMENTS_IDS);
let cex = part(rt::COMMENTS_EXTENSIBLE);
r.story = p.as_str().into();
lenient("comments", r.read_comments(&p, ex.as_deref(), ids.as_deref(), cex.as_deref()));
}
if let Some(p) = part(rt::FOOTNOTES) {
r.story = p.as_str().into();
lenient("footnotes", r.read_notes(&p, true));
}
if let Some(p) = part(rt::ENDNOTES) {
r.story = p.as_str().into();
lenient("endnotes", r.read_notes(&p, false));
}
r.story = main.as_str().into();
// Bibliography sources live in a custom XML data store (`b:Sources`), related from the
// package root or the main part; Word's own files sometimes leave the relationship out, so
// the conventional `customXml/itemN.xml` names are tried too. Each part is read at most once.
+15
View File
@@ -80,6 +80,21 @@ impl Reader<'_> {
// `w:matchSrc`: a Word document's content keeps its own styles (other formats' formatting
// is their own already).
let keep = is_docx && e.child("w:altChunkPr").and_then(|p| p.child("w:matchSrc")).is_some_and(crate::units::on_off);
// The pictures the chunk's own reader dropped are the file's too (strict export, #6005),
// each named by the chunk and its part in it (`word/afchunk1.docx > word/header1.xml`).
// A chunk in another format brings none: those importers record nothing yet (#6007).
let nested = std::mem::take(&mut doc.missing_pictures);
let list_len = nested.list.len() as u64;
for mut item in nested.list {
let inner = if item.story.is_empty() { nested.main.as_str() } else { item.story.as_str() };
item.story = format!("{part} > {inner}");
self.doc.missing_pictures.push(item);
}
// The nested records past its list are the file's too; their stories carry the chunk's
// part, so none is one the outer list already counted.
let unlisted = nested.total.saturating_sub(list_len);
self.doc.missing_pictures.add_unlisted(&part, unlisted);
self.doc.missing_pictures.truncated |= nested.truncated;
let mut blocks = self.doc.adopt_blocks(doc, keep, &mut self.next_perm);
// Bookmarks and comment marks read before the chunk stay before it.
self.flush_pending_before(sc, out, &mut blocks);
+3
View File
@@ -53,7 +53,10 @@ impl Reader<'_> {
// Without content types (a converted package) the root element alone decides.
if let Some(root) = root.filter(|r| r.name == "w:glossaryDocument" && ct.as_deref().is_none_or(|t| t == GLOSSARY_CONTENT_TYPE)) {
let rels = self.pkg.rels(&rel.target);
// Pictures in a building block belong to the glossary part, not the main story.
let outer = std::mem::replace(&mut self.story, rel.target.as_str().into());
entries = self.doc_parts(&root, &rels);
self.story = outer;
}
}
self.doc.glossary = entries;
+2
View File
@@ -117,6 +117,7 @@ impl Reader<'_> {
// Reserve the id first: the note's own reference mark points at it.
let (pk, kind) = if foot { (PartKind::Footnote, NoteKind::Footnote) } else { (PartKind::Endnote, NoteKind::Endnote) };
let id = self.doc.add_part(pk, Blocks::new());
self.doc.missing_pictures.part_names.insert(id, self.story.clone());
let mut sc = StoryCtx::default();
let mut blocks = Blocks::new();
self.current_note = Some((kind, id));
@@ -214,6 +215,7 @@ impl Reader<'_> {
self.read_blocks(&mut sc, c, &rels, &mut blocks, self.chunks.depth);
self.flush_pending(&mut sc, &mut blocks);
let part = self.doc.add_part(PartKind::Comment, blocks);
self.doc.missing_pictures.part_names.insert(part, self.story.clone());
if let Some(pid) = c.children("w:p").last().and_then(|p| p.attr("w14:paraId")) {
by_para.insert(pid.to_string(), id);
}
+24 -4
View File
@@ -1,6 +1,6 @@
use super::{
Arc, Blocks, ContentTypes, DocxError, El, MAX_FONT_NAME, MAX_HF_DEPTH, MAX_PARTS, MAX_TABLE_FONTS, MAX_VBA_RELATED, PartKind, Reader, Rels,
Revision, RevisionKind, Rgb, StoryCtx, VBA_PROJECT_PART, VBA_RELATED, props, shape_style,
Arc, Blocks, ContentTypes, DocxError, El, MAX_FONT_NAME, MAX_HF_DEPTH, MAX_PARTS, MAX_TABLE_FONTS, MAX_VBA_RELATED, MissingPicture,
MissingPictureReason, PartKind, Reader, Rels, Revision, RevisionKind, Rgb, StoryCtx, VBA_PROJECT_PART, VBA_RELATED, props, shape_style,
};
impl Reader<'_> {
@@ -91,14 +91,22 @@ impl Reader<'_> {
/// Media key for an image relationship (loads the bytes once).
pub fn media_for(&mut self, rels: &Rels, id: &str) -> Option<String> {
let rel = rels.by_id(id)?;
let Some(rel) = rels.by_id(id) else {
self.note_missing_picture(id, MissingPictureReason::MissingPart);
return None;
};
if rel.external {
// Never fetched: a picture whose target is outside the file is dropped (and reported).
self.note_missing_picture(&rel.target, MissingPictureReason::ExternalTarget);
return None;
}
if let Some(k) = self.media_by_path.get(&rel.target) {
return Some(k.clone());
}
let bytes = self.pkg.get_shared(&rel.target)?;
let Some(bytes) = self.pkg.get_shared(&rel.target) else {
self.note_missing_picture(&rel.target, MissingPictureReason::MissingPart);
return None;
};
let base: String =
rel.target.rsplit('/').next().unwrap_or("image").chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')).collect();
let base = if base.is_empty() || base.starts_with('.') { format!("image{base}") } else { base };
@@ -112,10 +120,18 @@ impl Reader<'_> {
};
}
self.doc.media.insert(key.clone(), bytes.clone());
self.doc.missing_pictures.media_parts.insert(key.clone(), rel.target.clone());
self.media_by_path.insert(rel.target.clone(), key.clone());
Some(key)
}
/// Remember a picture that was dropped at open, in the story being read.
pub(super) fn note_missing_picture(&mut self, part: &str, reason: MissingPictureReason) {
let part: String = part.chars().take(1024).collect();
let story = self.story.to_string();
self.doc.missing_pictures.push(MissingPicture { part, story, reason });
}
/// A chart or diagram part, parsed once however often it's referenced (`None` when unreadable).
pub(super) fn graphic_part(&mut self, path: &str) -> Option<Arc<El>> {
if let Some(p) = self.graphic_parts.get(path) {
@@ -137,6 +153,8 @@ impl Reader<'_> {
let kind = if footer { PartKind::Footer } else { PartKind::Header };
let id = self.doc.add_part(kind, Blocks::new());
self.hf_by_path.insert(path.to_string(), id);
let name: Arc<str> = path.into();
self.doc.missing_pictures.part_names.insert(id, Arc::clone(&name));
let root = match self.xml(path) {
Ok(Some(r)) => r,
_ => return Some(id),
@@ -146,7 +164,9 @@ impl Reader<'_> {
sc.in_hf = true;
let mut blocks = Blocks::new();
self.hf_depth += 1;
let outer = std::mem::replace(&mut self.story, name);
self.read_blocks(&mut sc, &root, &rels, &mut blocks, self.chunks.depth);
self.story = outer;
self.hf_depth = self.hf_depth.saturating_sub(1);
self.flush_pending(&mut sc, &mut blocks);
if let Some(p) = self.doc.parts.get_mut(&id)
+9 -1
View File
@@ -279,7 +279,15 @@ impl Reader<'_> {
fn read_pic(&mut self, pic: &El, rels: &Rels, w: f32, h: f32, alt: String, mut float: Float) -> Option<InlineObject> {
let blip = pic.find("a:blip")?;
float.set_spin(xfrm_spin(pic.find("pic:spPr")));
let media = blip.attr("r:embed").and_then(|id| self.media_for(rels, id))?;
let Some(embed) = blip.attr("r:embed") else {
// A linked picture holds no copy: nothing to draw, and nothing is fetched.
if let Some(link) = blip.attr("r:link") {
let target = rels.by_id(link).map_or_else(|| link.to_string(), |r| r.target.clone());
self.note_missing_picture(&target, wordcraft_doc::MissingPictureReason::LinkOnly);
}
return None;
};
let media = self.media_for(rels, embed)?;
let mut crop = [0.0f32; 4];
if let Some(sr) = pic.find("a:srcRect") {
for (i, n) in ["l", "t", "r", "b"].iter().enumerate() {
+8 -1
View File
@@ -131,7 +131,14 @@ impl Reader<'_> {
None => vml_float(shape, style, sc.in_hf),
};
if let Some(img) = shape.find("v:imagedata") {
let id = img.attr("r:id").or_else(|| img.attr("r:pict"))?;
let Some(id) = img.attr("r:id").or_else(|| img.attr("r:pict")) else {
// `r:href` names a file outside the package: never fetched.
if let Some(href) = img.attr("r:href") {
let target = rels.by_id(href).map_or_else(|| href.to_string(), |r| r.target.clone());
self.note_missing_picture(&target, wordcraft_doc::MissingPictureReason::LinkOnly);
}
return None;
};
let media = self.media_for(rels, id)?;
let alt = shape.attr("alt").or_else(|| img.attr("o:title")).unwrap_or("").to_string();
// Brightness and contrast (Word's picture watermark "Washout" sets them, #1711).
+71
View File
@@ -372,3 +372,74 @@ fn hostile_chunks_never_panic() {
let bytes = package(&format!(r#"<w:altChunk r:id="nope"/><w:altChunk/>{}"#, p("y")), None);
assert_eq!(body_lines(&read(&bytes).unwrap()), ["y"]);
}
/// A DOCX whose only picture names a part the package lacks (`media/gone.png`), or sits outside it.
fn inner_with_dropped_picture(external: bool) -> Vec<u8> {
inner_with_dropped_pictures(1, external)
}
/// As [`inner_with_dropped_picture`], with `n` pictures each naming its own missing part.
fn inner_with_dropped_pictures(n: usize, external: bool) -> Vec<u8> {
let drawing = r#"<w:r><w:drawing><wp:inline xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"><wp:extent cx="254000" cy="254000"/><wp:docPr id="1" name="P"/><a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:nvPicPr><pic:cNvPr id="0" name="p"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdP{i}"/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="254000" cy="254000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#;
let body: String = (0..n).map(|i| drawing.replace("{i}", &i.to_string())).collect();
let doc = format!(r#"<w:document {NS}><w:body><w:p>{body}</w:p><w:sectPr/></w:body></w:document>"#);
let mode = if external { r#" TargetMode="External""# } else { "" };
let image = |i: usize| {
let target = if external { format!("http://127.0.0.1:9/never-fetched{i}.png") } else { format!("media/gone{i}.png") };
format!(
r#"<Relationship Id="rIdP{i}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="{target}"{mode}/>"#
)
};
let rels = format!(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{}</Relationships>"#,
(0..n).map(image).collect::<String>()
);
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [
("[Content_Types].xml", types.as_bytes()),
("_rels/.rels", ROOT_RELS.as_bytes()),
("word/document.xml", doc.as_bytes()),
("word/_rels/document.xml.rels", rels.as_bytes()),
] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
/// A picture the chunk's own reader dropped is the file's too (#6005), named by the chunk part.
#[test]
fn a_picture_dropped_inside_a_chunk_is_recorded_against_the_chunk() {
for (external, reason) in [(false, wordcraft_doc::MissingPictureReason::MissingPart), (true, wordcraft_doc::MissingPictureReason::ExternalTarget)]
{
let inner = inner_with_dropped_picture(external);
let bytes = package(&format!("{}{CHUNK}", p("Before")), Some(("docx", DOCX_MAIN, &inner)));
let d = read(&bytes).unwrap();
let got: Vec<(&str, &str, _)> = d.missing_pictures.list.iter().map(|m| (m.part.as_str(), m.story.as_str(), m.reason)).collect();
assert_eq!(got.len(), 1, "{got:?}");
assert_eq!(got[0].1, "word/afchunk.docx > word/document.xml");
assert_eq!(got[0].2, reason);
}
}
/// A chunk with more dropped pictures than the list holds still counts them all (#6005).
#[test]
fn a_chunks_total_of_dropped_pictures_counts_past_the_list() {
let n = wordcraft_doc::MAX_MISSING_PICTURES + 44;
let inner = inner_with_dropped_pictures(n, false);
let d = read(&package(&format!("{}{CHUNK}", p("Before")), Some(("docx", DOCX_MAIN, &inner)))).unwrap();
assert_eq!(d.missing_pictures.list.len(), wordcraft_doc::MAX_MISSING_PICTURES);
assert!(d.missing_pictures.truncated);
assert_eq!(d.missing_pictures.total, n as u64);
}
/// The same chunk imported twice counts its unlisted pictures once, as it dedupes its listed ones.
#[test]
fn a_chunk_imported_twice_counts_its_dropped_pictures_past_the_list_once() {
let n = wordcraft_doc::MAX_MISSING_PICTURES + 44;
let inner = inner_with_dropped_pictures(n, false);
let d = read(&package(&format!("{}{CHUNK}{CHUNK}", p("Before")), Some(("docx", DOCX_MAIN, &inner)))).unwrap();
assert_eq!(d.missing_pictures.list.len(), wordcraft_doc::MAX_MISSING_PICTURES);
assert_eq!(d.missing_pictures.total, n as u64);
}
+20
View File
@@ -560,3 +560,23 @@ fn a_private_save_keeps_character_references_in_attribute_values_as_written() {
let again = wordcraft_docx::read(&saved).unwrap();
assert_eq!(again.glossary.first().map(|e| e.description.as_str()), Some(before.as_str()));
}
/// A picture in a building block that names a part the package lacks is recorded against the
/// glossary part, not the main document (#6005).
#[test]
fn a_missing_picture_in_a_building_block_names_the_glossary_part() {
let drawing = r#"<w:r><w:drawing><wp:inline xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"><wp:extent cx="254000" cy="254000"/><wp:docPr id="1" name="P"/><a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:nvPicPr><pic:cNvPr id="0" name="p"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdPic"/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="254000" cy="254000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#;
let part =
format!(r#"<w:docPart><w:docPartPr><w:name w:val="Logo"/></w:docPartPr><w:docPartBody><w:p>{drawing}</w:p></w:docPartBody></w:docPart>"#);
let bytes = package(
&[("rId7", GLOSSARY_REL, "glossary/document.xml")],
&[
("[Content_Types].xml", content_types(&[("/word/glossary/document.xml", GLOSSARY_CT)])),
("word/glossary/document.xml", glossary_xml(&part)),
("word/glossary/_rels/document.xml.rels", rels(&[("rIdPic", &format!("{REL}/image"), "media/gone.png")])),
],
);
let d = wordcraft_docx::read(&bytes).unwrap();
let stories: Vec<&str> = d.missing_pictures.list.iter().map(|m| m.story.as_str()).collect();
assert_eq!(stories, ["word/glossary/document.xml"], "{:?}", d.missing_pictures.list);
}
+1
View File
@@ -21,6 +21,7 @@ mod heading_levels;
mod legacy_forms;
mod ligatures;
mod malformed;
mod missing_pictures;
mod nested_block_controls;
mod note_comments;
mod passthrough;
+163
View File
@@ -0,0 +1,163 @@
//! Pictures dropped at open are recorded for strict export (#6005): a missing part, an external
//! target and a link-only picture (ECMA-376 Part 1 §20.1.8.13 `a:blip` `r:embed`/`r:link`).
//! Original synthetic packages; nothing is fetched, the reader has no network code.
use std::io::Write;
use wordcraft_doc::{Document, InlineObject, MissingPictureReason};
const NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture" xmlns:v="urn:schemas-microsoft-com:vml""#;
const REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
fn png() -> Vec<u8> {
let mut out = Vec::new();
image::RgbaImage::from_pixel(2, 2, image::Rgba([20, 90, 200, 255]))
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
.unwrap();
out
}
fn blip(attrs: &str) -> String {
format!(
r#"<w:r><w:drawing><wp:inline><wp:extent cx="254000" cy="254000"/><wp:docPr id="1" name="P"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="p"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip {attrs}/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="254000" cy="254000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
)
}
/// A package: `body` in `word/document.xml` (whose relationships are `doc_rels`), an optional
/// header with `header` as its content (relationships `header_rels`) and `extra` parts.
fn package(body: &str, doc_rels: &str, header: Option<(&str, &str)>, extra: &[(&str, Vec<u8>)]) -> Vec<u8> {
let hdr_rel = if header.is_some() { format!(r#"<Relationship Id="rIdH" Type="{REL}/header" Target="header2.xml"/>"#) } else { String::new() };
let sect = if header.is_some() { r#"<w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr>"# } else { "<w:sectPr/>" };
let doc = format!(r#"<w:document {NS}><w:body><w:p>{body}</w:p>{sect}</w:body></w:document>"#);
let rels = format!(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{REL}/officeDocument" Target="word/document.xml"/></Relationships>"#
);
let drels = format!(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{doc_rels}{hdr_rel}</Relationships>"#);
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="png" ContentType="image/png"/><Default Extension="bin" ContentType="application/octet-stream"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let mut put = |name: &str, bytes: &[u8]| {
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(bytes).unwrap();
};
put("[Content_Types].xml", types.as_bytes());
put("_rels/.rels", rels.as_bytes());
put("word/document.xml", doc.as_bytes());
put("word/_rels/document.xml.rels", drels.as_bytes());
if let Some((content, hrels)) = header {
put("word/header2.xml", format!(r#"<w:hdr {NS}><w:p>{content}</w:p></w:hdr>"#).as_bytes());
put(
"word/_rels/header2.xml.rels",
format!(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{hrels}</Relationships>"#).as_bytes(),
);
}
for (n, b) in extra {
put(n, b);
}
zip.finish().unwrap().into_inner()
}
fn image_rel(id: &str, target: &str, external: bool) -> String {
let mode = if external { r#" TargetMode="External""# } else { "" };
format!(r#"<Relationship Id="{id}" Type="{REL}/image" Target="{target}"{mode}/>"#)
}
fn missing(d: &Document) -> Vec<(String, String, MissingPictureReason)> {
d.missing_pictures.list.iter().map(|m| (m.part.clone(), m.story.clone(), m.reason)).collect()
}
fn pictures(d: &Document) -> usize {
d.body[0].as_para().unwrap().objects.iter().filter(|o| matches!(o, InlineObject::Image { .. })).count()
}
#[test]
fn external_missing_and_link_only_pictures_are_recorded_and_still_dropped() {
let body = [blip(r#"r:embed="rIdExt""#), blip(r#"r:embed="rIdGone""#), blip(r#"r:link="rIdLink""#), blip(r#"r:embed="rIdNone""#)].concat();
let rels = [
image_rel("rIdExt", "http://127.0.0.1:9/never-fetched.png", true),
image_rel("rIdGone", "media/absent.png", false),
image_rel("rIdLink", "file:///C:/pics/linked.png", true),
]
.concat();
let d = wordcraft_docx::read(&package(&body, &rels, None, &[])).unwrap();
assert_eq!(pictures(&d), 0, "nothing is drawn for them, as before");
assert_eq!(
missing(&d),
[
("http://127.0.0.1:9/never-fetched.png".into(), "word/document.xml".into(), MissingPictureReason::ExternalTarget),
("word/media/absent.png".into(), "word/document.xml".into(), MissingPictureReason::MissingPart),
("file:///C:/pics/linked.png".into(), "word/document.xml".into(), MissingPictureReason::LinkOnly),
("rIdNone".into(), "word/document.xml".into(), MissingPictureReason::MissingPart),
]
);
assert!(!d.missing_pictures.truncated);
}
#[test]
fn a_header_pictures_story_is_the_header_part() {
let rels = [image_rel("rIdHx", "https://example.invalid/logo.png", true), image_rel("rIdOk", "media/ok.png", false)].concat();
let header = [blip(r#"r:embed="rIdHx""#), blip(r#"r:embed="rIdOk""#)].concat();
let bytes = package("", "", Some((&header, &rels)), &[("word/media/ok.png", png())]);
let d = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(missing(&d), [("https://example.invalid/logo.png".into(), "word/header2.xml".into(), MissingPictureReason::ExternalTarget)]);
assert_eq!(d.missing_pictures.main, "word/document.xml");
assert!(d.missing_pictures.part_names.values().any(|n| &**n == "word/header2.xml"));
assert_eq!(d.missing_pictures.media_parts.get("ok.png").map(String::as_str), Some("word/media/ok.png"));
}
#[test]
fn a_picture_that_is_there_is_not_recorded() {
let body = blip(r#"r:embed="rIdOk""#);
let d = wordcraft_docx::read(&package(&body, &image_rel("rIdOk", "media/ok.png", false), None, &[("word/media/ok.png", png())])).unwrap();
assert_eq!(pictures(&d), 1);
assert!(d.missing_pictures.list.is_empty());
}
#[test]
fn vml_image_data_links_are_recorded() {
let pict =
|attrs: &str| format!(r#"<w:r><w:pict><v:shape id="s" style="width:50pt;height:50pt"><v:imagedata {attrs}/></v:shape></w:pict></w:r>"#);
let body = [pict(r#"r:id="rIdExt""#), pict(r#"r:href="rIdLink""#), pict(r#"r:id="rIdGone""#)].concat();
let rels = [
image_rel("rIdExt", "https://example.invalid/a.png", true),
image_rel("rIdLink", "https://example.invalid/b.png", true),
image_rel("rIdGone", "media/none.png", false),
]
.concat();
let d = wordcraft_docx::read(&package(&body, &rels, None, &[])).unwrap();
let got: Vec<_> = missing(&d).into_iter().map(|(p, _, r)| (p, r)).collect();
assert_eq!(
got,
[
("https://example.invalid/a.png".to_string(), MissingPictureReason::ExternalTarget),
("https://example.invalid/b.png".to_string(), MissingPictureReason::LinkOnly),
("word/media/none.png".to_string(), MissingPictureReason::MissingPart),
]
);
}
#[test]
fn the_list_is_capped_and_the_document_is_still_equal() {
let n = wordcraft_doc::MAX_MISSING_PICTURES + 5;
let body: String = (0..n).map(|i| blip(&format!(r#"r:embed="r{i}""#))).collect();
let rels: String = (0..n).map(|i| image_rel(&format!("r{i}"), &format!("media/gone{i}.png"), false)).collect();
let d = wordcraft_docx::read(&package(&body, &rels, None, &[])).unwrap();
assert_eq!(d.missing_pictures.list.len(), wordcraft_doc::MAX_MISSING_PICTURES);
assert!(d.missing_pictures.truncated);
// Not part of the document: `==` and the clone ignore it, and a save doesn't carry it.
let mut e = d.clone();
e.missing_pictures = Default::default();
assert_eq!(d, e);
assert!(!serde_json::to_string(&d).unwrap().contains("gone"));
}
/// Every note of a part shares the one allocation of its source name, so a file with many notes
/// in a part with a long name does not keep a copy of the name per note.
#[test]
fn the_notes_of_a_part_share_one_copy_of_its_name() {
let notes: String = (1..=3).map(|i| format!(r#"<w:footnote w:id="{i}"><w:p><w:r><w:t>n{i}</w:t></w:r></w:p></w:footnote>"#)).collect();
let rels = format!(r#"<Relationship Id="rIdF" Type="{REL}/footnotes" Target="footnotes.xml"/>"#);
let part = format!(r#"<w:footnotes {NS}>{notes}</w:footnotes>"#);
let d = wordcraft_docx::read(&package("x", &rels, None, &[("word/footnotes.xml", part.into_bytes())])).unwrap();
let names: Vec<_> = d.missing_pictures.part_names.values().collect();
assert!(names.len() >= 3, "{names:?}");
assert!(names.iter().all(|n| &***n == "word/footnotes.xml"));
assert!(names.windows(2).all(|w| std::sync::Arc::ptr_eq(w[0], w[1])), "each note holds its own copy of the part name");
}
+46 -6
View File
@@ -94,9 +94,9 @@ pub fn specs() -> Vec<CommandSpec> {
.pure(),
CommandSpec::new("file.save", "Save", "File", save)
.key("Mod+S / Shift+F12 / Alt+Shift+F2")
.params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does), "markup"?: bool (Print Markup for a PDF, PNG, HTML, Markdown or text copy: comments shown; default file.printMarkup), "format"?: "wordml2003" (an .xml file as a Word 2003 XML Document) | "flatopc" (as a Word XML Document, the default; saving over a Word 2003 XML file keeps that format)}"#)
.params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does), "markup"?: bool (Print Markup for a PDF, PNG, HTML, Markdown or text copy: comments shown; default file.printMarkup), "format"?: "wordml2003" (an .xml file as a Word 2003 XML Document) | "flatopc" (as a Word XML Document, the default; saving over a Word 2003 XML file keeps that format), "strict"?: bool (a .pdf or .png only: refuse, writing nothing, if a character can't be drawn or a picture is missing; see file.strictReport)}"#)
.pure(),
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key(super::mac_or("F12 / Mod+Shift+S", "F12 / Alt+F2")).params(r#"{"path": string, "password"?: string, "markup"?: bool, "format"?: string (as file.save)}"#).pure(),
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key(super::mac_or("F12 / Mod+Shift+S", "F12 / Alt+F2")).params(r#"{"path": string, "password"?: string, "markup"?: bool, "format"?: string (as file.save), "strict"?: bool (as file.save)}"#).pure(),
// Pure: the password must not land in Repeat or a recorded macro, and it isn't an edit
// to undo; it marks the document as changed itself.
CommandSpec::new("file.encrypt", "Encrypt with Password", "File › Info › Protect Document", encrypt)
@@ -114,10 +114,13 @@ pub fn specs() -> Vec<CommandSpec> {
crate::io::save_path_with(std::path::Path::new(&path), &s.doc, None, &opts).map_err(CmdError::Failed)?;
Ok(json!({"path": path, "markup": opts.markup}))
})
.params(r#"{"path": string, "markup"?: bool (Print Markup: tracked changes marked up and each page scaled down beside the comments in balloons; default file.printMarkup)}"#)
.params(r#"{"path": string, "markup"?: bool (Print Markup: tracked changes marked up and each page scaled down beside the comments in balloons; default file.printMarkup), "strict"?: bool (refuse, writing nothing, if a character can't be drawn or a picture is missing; see file.strictReport)}"#)
.pure(),
CommandSpec::new("file.exportPng", "Export Page as PNG", "File › Export", export_png)
.params(r#"{"path": string, "page"?: n (1-based), "scale"?: px per pt, "markup"?: bool (the page with its comments in balloons beside it; default file.printMarkup)}"#)
.params(r#"{"path": string, "page"?: n (1-based), "scale"?: px per pt, "markup"?: bool (the page with its comments in balloons beside it; default file.printMarkup), "strict"?: bool (refuse, writing nothing, if a character on the page can't be drawn or a picture is missing; see file.strictReport)}"#)
.pure(),
CommandSpec::new("file.strictReport", "Strict Export Report", "File › Export", strict_report)
.params(r#"{"markup"?: bool (as file.exportPdf), "page"?: n (1-based: check that page of the page image file.exportPng writes; omit for every page of a PDF)} → {"schema": 1, "strict": "passed" | "failed", "missing_glyphs": [{"cp": "U+2D30", "count": n}], "missing_pictures": [{"part", "source_part": the story part it is in (word/header2.xml; background, body, header, footer, text box or "" where unknown), "reason": "missing_part" | "external_target" | "link_only" | "undecodable" | "unsupported_format"}], "truncated": bool (either list was cut at 256 entries), "total_missing_glyphs": n (distinct, past the cap too), "total_missing_pictures": n (past the cap too), "glyphs_truncated": bool, "pictures_truncated": bool}"#)
.pure(),
// A print setting, like Word's: not part of the document, so no undo step.
CommandSpec::new("file.printMarkup", "Print Markup", "File › Print", |s, v| {
@@ -369,6 +372,7 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
return Ok(json!({"saved": false}));
}
};
check_strict_output(p::bool(v, "strict").unwrap_or(false), &path.to_string_lossy())?;
if let Some(pw) = v.get("password").filter(|pw| !pw.is_null()) {
let Some(pw) = pw.as_str().filter(|pw| !pw.is_empty()) else {
return Err(CmdError::Params("`password` must be a non-empty string (file.encrypt removes a password)".into()));
@@ -450,9 +454,39 @@ pub fn export_options(s: &Session, v: &Value) -> crate::io::ExportOptions {
comments: markup && s.prefs.markup.comments,
word2003: p::str(v, "format") == Some(FORMAT_WORD_2003),
numeral: s.view.numeral.resolve(crate::session::system_locale()),
strict: p::bool(v, "strict").unwrap_or(false),
}
}
/// `strict: true` is for a PDF or a page image only: any other output is a parameter error.
fn check_strict_output(strict: bool, path: &str) -> Result<(), CmdError> {
if strict && !crate::strict::is_strict_output(path) {
return Err(CmdError::Params("`strict` applies to a .pdf or .png output only".into()));
}
Ok(())
}
/// The strict report of a PDF export of the document (every page), or with `page` of the page
/// image `file.exportPng` writes. Pure: it reads the document and writes nothing.
fn strict_report(s: &mut Session, v: &Value) -> CmdResult {
let opts = export_options(s, v);
let report = match p::u64(v, "page") {
Some(n) => {
let page = crate::p::page_index(n);
let l = s.export_layout_with(opts.markup);
if l.pages.get(page).is_none() {
return Err(CmdError::Params(format!("no page {}", page + 1)));
}
crate::strict::strict_report(&s.doc, &l, &crate::strict::png_display(), Some(page..=page), wordcraft_layout::StrictOutput::Png)
}
None => {
let out = super::references::output_fields(&s.doc);
crate::strict::pdf_report(out.as_ref().unwrap_or(&s.doc), &opts)
}
};
Ok(report.to_json())
}
fn export_png(s: &mut Session, v: &Value) -> CmdResult {
// No path: the app asks where to save, as file.exportPdf does (#2313).
let Some(path) = p::str(v, "path") else {
@@ -460,16 +494,22 @@ fn export_png(s: &mut Session, v: &Value) -> CmdResult {
return Ok(json!({"exported": false}));
};
let page = crate::p::page_index(p::u64(v, "page").unwrap_or(1));
let strict = p::bool(v, "strict").unwrap_or(false);
let scale = p::f32(v, "scale").unwrap_or(2.0).clamp(0.1, 8.0);
let l = s.export_layout_with(export_options(s, v).markup);
let pg = l.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {}", page + 1)))?;
// A page image is the printed page: headers and footers at full strength. Dimming them is
// the on-screen cue while editing the body, not part of the page.
let mut opts = wordcraft_render::RenderOptions::default();
opts.display.dim_header = false;
let opts = wordcraft_render::RenderOptions { display: crate::strict::png_display(), ..Default::default() };
if !path.to_ascii_lowercase().ends_with(".png") {
return Err(CmdError::Params("path must end in .png".into()));
}
if strict {
let report = crate::strict::strict_report(&s.doc, &l, &opts.display, Some(page..=page), wordcraft_layout::StrictOutput::Png);
if !report.passed() {
return Err(CmdError::Failed(report.refusal()));
}
}
let img = wordcraft_render::render_page(&s.doc, pg, scale, &opts);
let png = img.to_png();
crate::io::write_file(path, &png).map_err(CmdError::Failed)?;
+6
View File
@@ -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);
+4
View File
@@ -66,6 +66,10 @@ pub struct ExportOptions {
pub word2003: bool,
/// How digits look in PDFs and page images (the Numeral setting, `System` resolved).
pub numeral: wordcraft_layout::NumeralMode,
/// A PDF or page image is written only if nothing in it is silently missing (a character no
/// face can draw, a picture that is missing or can't be decoded); else the save fails with
/// `strict: …` (#6005). Ignored for other formats.
pub strict: bool,
}
/// A document password: wiped from memory when dropped, never printed (`Debug` shows `***`).
+14 -9
View File
@@ -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
})
@@ -60,13 +65,7 @@ pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Optio
// Output shows the fields Word fills in as it prints (see `output_fields`).
let out = crate::cmd::references::output_fields(doc);
let doc = out.as_ref().unwrap_or(doc);
let pdf = wordcraft_pdf::PdfOptions {
include_markup: opts.markup,
comments: opts.markup && opts.comments,
numeral: opts.numeral,
..Default::default()
};
Some(wordcraft_pdf::export(doc, &pdf).map_err(|e| e.to_string()))
Some(crate::strict::export_pdf(doc, opts))
}
other => wordcraft_formats::export_with(other, doc, &wordcraft_formats::ExportOptions { comments: opts.markup && opts.comments }),
}
@@ -80,6 +79,12 @@ pub fn render_png(doc: &Document, page: usize, scale: f32, opts: &crate::io::Exp
let lo = wordcraft_layout::LayoutOptions { comment_balloons: opts.markup && opts.comments, numeral: opts.numeral, ..Default::default() };
let l = wordcraft_layout::layout(doc, &mut wordcraft_layout::LayoutCache::new(), &lo);
let p = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?;
if opts.strict {
let report = crate::strict::strict_report(doc, &l, &crate::strict::png_display(), Some(page..=page), wordcraft_layout::StrictOutput::Png);
if !report.passed() {
return Err(report.refusal());
}
}
let img = wordcraft_render::render_page(doc, p, scale, &Default::default());
Ok(img.to_png())
}
+1
View File
@@ -25,6 +25,7 @@ pub mod sample;
mod session;
pub(crate) mod snapshot;
pub mod speech;
pub mod strict;
pub(crate) mod view;
use std::collections::HashMap;
+233
View File
@@ -0,0 +1,233 @@
//! Strict export (#6005): the report of what a PDF or PNG would leave out silently (characters no
//! face can draw, pictures that are missing or can't be decoded), and the refusal built from it.
//!
//! The check runs on the layout and display options the export itself uses, so what it reports is
//! what the output lacks.
use std::ops::RangeInclusive;
use serde_json::{Value, json};
use wordcraft_doc::{Document, MAX_MISSING_PICTURES, MissingPicture};
use wordcraft_layout::display::DisplayOptions;
use wordcraft_layout::{DocLayout, LayoutCache, StrictOutput};
use crate::io::ExportOptions;
/// The report's JSON schema version; fields may be added without raising it.
pub const SCHEMA: u32 = 1;
/// What a strict export found missing.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct StrictReport {
/// (code point, times drawn), sorted by code point.
pub glyphs: Vec<(char, u64)>,
pub pictures: Vec<MissingPicture>,
/// More distinct characters were missing than are listed.
pub glyphs_truncated: bool,
/// More pictures were missing than are listed.
pub pictures_truncated: bool,
/// Distinct characters found missing, those past the list's cap included.
pub total_glyphs: usize,
/// Pictures found missing, those past the list's cap included.
pub total_pictures: u64,
}
impl StrictReport {
/// Nothing is missing: the strict export passes.
pub fn passed(&self) -> bool {
self.glyphs.is_empty() && self.pictures.is_empty() && !self.truncated()
}
/// Either list was cut at its cap.
pub fn truncated(&self) -> bool {
self.glyphs_truncated || self.pictures_truncated
}
/// The schema-1 JSON of the report.
pub fn to_json(&self) -> Value {
json!({
"schema": SCHEMA,
"strict": if self.passed() { "passed" } else { "failed" },
"missing_glyphs": self.glyphs.iter().map(|(c, n)| json!({"cp": code_point(*c), "count": n})).collect::<Vec<_>>(),
"missing_pictures": self.pictures.iter().map(|p| json!({"part": p.part, "source_part": p.story, "reason": p.reason.code()})).collect::<Vec<_>>(),
"truncated": self.truncated(),
"total_missing_glyphs": self.total_glyphs,
"total_missing_pictures": self.total_pictures,
"glyphs_truncated": self.glyphs_truncated,
"pictures_truncated": self.pictures_truncated,
})
}
/// The human lines (without the `strict: ` prefix): one for the characters, one for the
/// pictures; none when nothing is missing.
fn parts(&self) -> Vec<String> {
let glyphs: Vec<(String, u64)> = self.glyphs.iter().map(|(c, n)| (code_point(*c), *n)).collect();
let pictures: Vec<(String, String, String)> =
self.pictures.iter().map(|p| (p.part.clone(), story_label(&p.story).to_string(), p.reason.label().to_string())).collect();
human_parts(&glyphs, &pictures, (self.glyphs_truncated, self.pictures_truncated), (self.total_glyphs as u64, self.total_pictures))
}
/// The human lines, each `strict: …`.
pub fn lines(&self) -> Vec<String> {
self.parts().into_iter().map(|p| format!("strict: {p}")).collect()
}
/// The refusal's message: `strict: …`, the lines joined.
pub fn refusal(&self) -> String {
format!("strict: {}", self.parts().join("; "))
}
}
/// The human text of a report: (code point text, count) and (part, story label, reason words).
fn human_parts(glyphs: &[(String, u64)], pictures: &[(String, String, String)], cut: (bool, bool), totals: (u64, u64)) -> Vec<String> {
let (glyphs_cut, pictures_cut) = cut;
let mut out = Vec::new();
if !glyphs.is_empty() {
let list: Vec<String> = glyphs.iter().map(|(cp, n)| format!("{cp} (×{n})")).collect();
let more = if glyphs_cut { ", …" } else { "" };
let listed = glyphs.len() as u64;
let n = if glyphs_cut { totals.0.max(listed) } else { listed };
let first = if n > listed { format!(" (first {listed} listed)") } else { String::new() };
out.push(format!("{n} character{} cannot be drawn{first}: {}{more}", if n == 1 { "" } else { "s" }, list.join(", ")));
}
if !pictures.is_empty() {
let list: Vec<String> = pictures
.iter()
.map(|(part, at, reason)| if at.is_empty() { format!("{part}: {reason}") } else { format!("{part} ({at}): {reason}") })
.collect();
let more = if pictures_cut { "; …" } else { "" };
let listed = pictures.len() as u64;
let n = if pictures_cut { totals.1.max(listed) } else { listed };
let first = if n > listed { format!(" (first {listed} listed)") } else { String::new() };
out.push(format!("{n} picture(s) left out{first}: {}{more}", list.join("; ")));
}
out
}
/// The `strict: …` human lines of a report in its JSON form (what `file.strictReport` returns).
/// Anything the JSON lacks is left out.
pub fn lines_from_json(report: &Value) -> Vec<String> {
let text = |v: &Value, k: &str| v.get(k).and_then(Value::as_str).unwrap_or_default().to_string();
let glyphs: Vec<(String, u64)> = report
.get("missing_glyphs")
.and_then(Value::as_array)
.map(|a| a.iter().map(|g| (text(g, "cp"), g.get("count").and_then(Value::as_u64).unwrap_or(0))).collect())
.unwrap_or_default();
let pictures: Vec<(String, String, String)> = report
.get("missing_pictures")
.and_then(Value::as_array)
.map(|a| a.iter().map(|p| (text(p, "part"), story_label(&text(p, "source_part")).to_string(), reason_label(&text(p, "reason")))).collect())
.unwrap_or_default();
let flag = |k: &str| report.get(k).and_then(Value::as_bool).unwrap_or(false);
// A report without the per-list flags (the top-level one alone) marks both lists.
let (glyphs_cut, pictures_cut) = match (report.get("glyphs_truncated"), report.get("pictures_truncated")) {
(None, None) => (flag("truncated"), flag("truncated")),
_ => (flag("glyphs_truncated"), flag("pictures_truncated")),
};
let total = |k: &str, listed: usize| report.get(k).and_then(Value::as_u64).unwrap_or(listed as u64);
let totals = (total("total_missing_glyphs", glyphs.len()), total("total_missing_pictures", pictures.len()));
human_parts(&glyphs, &pictures, (glyphs_cut, pictures_cut), totals).into_iter().map(|p| format!("strict: {p}")).collect()
}
/// The words for a JSON reason code; a code this version does not know is shown as it is.
fn reason_label(code: &str) -> String {
wordcraft_doc::MissingPictureReason::from_code(code).map_or_else(|| code.to_string(), |r| r.label().to_string())
}
/// `U+2D30`: upper-case hex, at least four digits.
fn code_point(c: char) -> String {
format!("U+{:04X}", c as u32)
}
/// The part a picture is in, as the report names it: `header2.xml` for `word/header2.xml`.
fn story_label(story: &str) -> &str {
story.strip_prefix("word/").unwrap_or(story)
}
/// The report of `doc` as drawn by `lay` with `display` into `output`, on `pages` (0-based; `None`
/// is every page). The watermark is checked with the page, and the pictures dropped when the
/// document was opened are added to those that can't be decoded now, capped like each.
pub fn strict_report(
doc: &Document,
lay: &DocLayout,
display: &DisplayOptions,
pages: Option<RangeInclusive<usize>>,
output: StrictOutput,
) -> StrictReport {
let mut missing = lay.missing_glyphs(doc, display, pages.clone());
let any_page = lay.pages.iter().enumerate().any(|(n, _)| pages.as_ref().is_none_or(|r| r.contains(&n)));
if any_page && let Some(wm) = &doc.settings.watermark {
let max = match output {
StrictOutput::Pdf => wordcraft_pdf::WATERMARK_MAX_CHARS,
StrictOutput::Png => usize::MAX,
};
missing.merge(wordcraft_layout::missing_watermark_glyphs(wm, max));
}
let (undecodable, mut pictures_truncated, undecodable_total) = wordcraft_layout::undecodable_pictures(doc, lay, pages, display, output);
pictures_truncated |= doc.missing_pictures.truncated;
// The two lists overlap where the same picture is in both; those are counted once.
let mut overlap: u64 = 0;
let mut pictures: Vec<MissingPicture> = Vec::new();
for p in doc.missing_pictures.list.iter().cloned().chain(undecodable) {
if pictures.contains(&p) {
overlap = overlap.saturating_add(1);
continue;
}
if pictures.len() >= MAX_MISSING_PICTURES {
pictures_truncated = true;
break;
}
pictures.push(p);
}
let total_pictures = doc.missing_pictures.total.saturating_add(undecodable_total).saturating_sub(overlap).max(pictures.len() as u64);
StrictReport {
glyphs: missing.chars,
pictures,
glyphs_truncated: missing.truncated,
pictures_truncated,
total_glyphs: missing.total,
total_pictures,
}
}
/// The PDF export's options for `opts`: the one place they are built, shared by the export and
/// its strict check.
pub(crate) fn pdf_options(opts: &ExportOptions) -> wordcraft_pdf::PdfOptions {
wordcraft_pdf::PdfOptions { include_markup: opts.markup, comments: opts.markup && opts.comments, numeral: opts.numeral, ..Default::default() }
}
/// What a page image shows: the display options `file.exportPng` renders with.
pub(crate) fn png_display() -> DisplayOptions {
// A page image is the printed page: headers and footers at full strength.
let mut opts = wordcraft_render::RenderOptions::default();
opts.display.dim_header = false;
opts.display
}
/// The strict report of a PDF export of `doc`: every page, laid out as the export lays it out.
pub(crate) fn pdf_report(doc: &Document, opts: &ExportOptions) -> StrictReport {
let pdf = pdf_options(opts);
let lay = wordcraft_layout::layout(doc, &mut LayoutCache::new(), &pdf.layout_options());
strict_report(doc, &lay, &pdf.display_options(), None, StrictOutput::Pdf)
}
/// The PDF of `doc`; with `opts.strict`, only if nothing is missing, laid out once for the check
/// and the export.
pub(crate) fn export_pdf(doc: &Document, opts: &ExportOptions) -> Result<Vec<u8>, String> {
let pdf = pdf_options(opts);
if !opts.strict {
return wordcraft_pdf::export(doc, &pdf).map_err(|e| e.to_string());
}
let lay = wordcraft_layout::layout(doc, &mut LayoutCache::new(), &pdf.layout_options());
let report = strict_report(doc, &lay, &pdf.display_options(), None, StrictOutput::Pdf);
if !report.passed() {
return Err(report.refusal());
}
wordcraft_pdf::export_layout(doc, &lay, &pdf).map_err(|e| e.to_string())
}
/// Is `path` a PDF or PNG output (the only outputs a strict export applies to)?
pub(crate) fn is_strict_output(path: &str) -> bool {
let lower = path.to_ascii_lowercase();
lower.ends_with(".pdf") || lower.ends_with(".png")
}
+151 -1
View File
@@ -4683,7 +4683,7 @@ fn typed_paragraphs_are_capped_for_every_caller() {
assert!(!body_texts(&s).iter().any(|p| p.contains('y')));
}
fn hostile_junk() -> [serde_json::Value; 8] {
fn hostile_junk() -> [serde_json::Value; 10] {
[
json!(null),
json!({}),
@@ -4693,6 +4693,9 @@ fn hostile_junk() -> [serde_json::Value; 8] {
json!({"page": 0, "x": 1e30, "y": -1e30, "extend": 1}),
json!({"width": 1e-9, "height": 1e30, "top": -1e9, "left": 1e9}),
json!({"section": {"pageW": -1e308, "pageH": 0, "marginTop": 1e30, "header": -5, "columns": {"count": 1000000, "space": -1e300, "widths": [[0, 0]]}}}),
// Strict export (#6005): the flag, and file.strictReport's `page`, with junk.
json!({"strict": true, "page": 0, "scale": 0, "markup": true}),
json!({"strict": "x", "page": 1e30, "scale": 1e-9, "markup": "x"}),
]
}
@@ -4789,6 +4792,9 @@ fn hostile_file_params_never_panic() {
json!({"path": path, "password": "x", "format": "wordml2003", "markup": "x", "confirmDamaged": 1}),
json!({"path": path, "password": 5, "format": 5, "markup": true}),
json!({"path": path, "page": 0, "scale": 0, "width": -1e308, "alt": 5}),
json!({"path": path, "strict": true, "page": 1, "scale": 0.1}),
json!({"path": path, "strict": true, "page": 1e30, "markup": true, "format": "flatopc"}),
json!({"path": path, "strict": 5, "page": -1}),
json!({"path": path, "data": "not base64!"}),
json!({"path": path, "data": "AAAA"}),
json!({"path": path, "pagesPerSheet": 7, "range": "x", "pages": "9-1,,", "parity": "x", "copies": 3, "paper": "x"}),
@@ -5218,6 +5224,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]
@@ -12688,6 +12730,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> {
@@ -12932,6 +13041,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`.
+322
View File
@@ -0,0 +1,322 @@
//! Strict export (#6005): `strict: true` on the PDF and PNG exports refuses an output that would
//! silently lack something, before any byte is written; `file.strictReport` says what.
use serde_json::{Value, json};
use wordcraft_doc::Document;
use wordcraft_engine::{CmdError, Session};
use wordcraft_fonts::FontDb;
/// Code points in unassigned plane 4 that no face of the host covers (as the layout tests find them).
fn uncovered_many(n: usize) -> Vec<char> {
let db = FontDb::global();
let primary = wordcraft_fonts::word::resolve(&Document::default().settings.minor_font, false, false).face;
let out: Vec<char> = (0x40000u32..0x4FFF0)
.filter_map(char::from_u32)
.filter(|c| {
!primary.covers(*c) && db.fallback_styled(*c, primary.id(), false, false).is_none() && db.fallback_for(*c, primary.id()).is_none()
})
.take(n)
.collect();
assert_eq!(out.len(), n, "the host has faces covering all of plane 4");
out
}
fn uncovered() -> char {
uncovered_many(1)[0]
}
fn dir(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("wc-strict-export-{name}-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d
}
fn refused(r: Result<Value, CmdError>) -> String {
match r {
Err(CmdError::Failed(m)) => {
assert!(m.starts_with("strict: "), "{m}");
m
}
other => panic!("expected a strict refusal, got {other:?}"),
}
}
#[test]
fn strict_refuses_an_uncovered_character_and_writes_nothing() {
let c = uncovered();
let d = dir("refuse");
let mut s = Session::new(Document::from_text(&format!("a{c}b")));
let pdf = d.join("out.pdf");
let png = d.join("out.png");
let msg = refused(s.run("file.exportPdf", &json!({"path": pdf, "strict": true})));
assert!(msg.contains(&format!("U+{:04X} (×1)", c as u32)), "{msg}");
refused(s.run("file.save", &json!({"path": pdf, "strict": true})));
refused(s.run("file.saveAs", &json!({"path": pdf, "strict": true})));
refused(s.run("file.exportPng", &json!({"path": png, "strict": true})));
refused(s.run("file.save", &json!({"path": png, "strict": true})));
assert!(!pdf.exists() && !png.exists());
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn a_refusal_leaves_an_existing_output_untouched() {
let c = uncovered();
let d = dir("keep");
let pdf = d.join("out.pdf");
std::fs::write(&pdf, b"previous").unwrap();
let mut s = Session::new(Document::from_text(&format!("a{c}")));
refused(s.run("file.exportPdf", &json!({"path": pdf, "strict": true})));
assert_eq!(std::fs::read(&pdf).unwrap(), b"previous");
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn a_refused_save_leaves_the_stamps_path_and_dirty_flag_alone() {
let c = uncovered();
let d = dir("stamp");
let mut s = Session::new(Document::from_text(&format!("a{c}")));
s.dirty = true;
let before =
(s.doc.core.modified.clone(), s.doc.core.created.clone(), s.doc.core.revision, s.doc.core.last_modified_by.clone(), s.doc.settings.zoom);
refused(s.run("file.save", &json!({"path": d.join("out.pdf"), "strict": true})));
let after =
(s.doc.core.modified.clone(), s.doc.core.created.clone(), s.doc.core.revision, s.doc.core.last_modified_by.clone(), s.doc.settings.zoom);
assert_eq!(before, after);
assert!(s.dirty);
assert_eq!(s.path, None);
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn strict_passes_and_writes_when_nothing_is_missing() {
let d = dir("pass");
let mut s = Session::new(Document::from_text("Hello, world."));
for (cmd, file, magic) in [
("file.exportPdf", "a.pdf", &b"%PDF-"[..]),
("file.save", "b.pdf", &b"%PDF-"[..]),
("file.exportPng", "a.png", &b"\x89PNG"[..]),
("file.save", "b.png", &b"\x89PNG"[..]),
] {
let path = d.join(file);
s.run(cmd, &json!({"path": path, "strict": true})).unwrap();
assert!(std::fs::read(&path).unwrap().starts_with(magic), "{file}");
}
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn strict_passes_a_document_the_same_pdf_as_without_it() {
let d = dir("pdfsame");
let mut s = Session::new(Document::from_text("Plain text"));
s.run("file.exportPdf", &json!({"path": d.join("a.pdf")})).unwrap();
s.run("file.exportPdf", &json!({"path": d.join("b.pdf"), "strict": true})).unwrap();
let (a, b) = (std::fs::read(d.join("a.pdf")).unwrap(), std::fs::read(d.join("b.pdf")).unwrap());
assert_eq!(a.len(), b.len());
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn strict_is_for_pdf_and_png_outputs_only() {
let d = dir("other");
let mut s = Session::new(Document::from_text("Hello"));
for file in ["a.docx", "a.txt", "a.odt", "a.html"] {
let path = d.join(file);
let r = s.run("file.save", &json!({"path": path, "strict": true}));
assert!(matches!(r, Err(CmdError::Params(_))), "{file}: {r:?}");
assert!(!path.exists(), "{file}");
}
let r = s.run("file.exportPng", &json!({"path": d.join("a.jpg"), "strict": true}));
assert!(matches!(r, Err(CmdError::Params(_))), "{r:?}");
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn strict_report_has_the_schema_1_shape() {
let c = uncovered();
let mut s = Session::new(Document::from_text(&format!("a{c}b{c}")));
let r = s.run("file.strictReport", &json!({})).unwrap();
assert_eq!(
r,
json!({
"schema": 1,
"strict": "failed",
"missing_glyphs": [{"cp": format!("U+{:04X}", c as u32), "count": 2}],
"missing_pictures": [],
"truncated": false,
"total_missing_glyphs": 1,
"total_missing_pictures": 0,
"glyphs_truncated": false,
"pictures_truncated": false,
})
);
let png = s.run("file.strictReport", &json!({"page": 1})).unwrap();
assert_eq!(png, r);
assert!(matches!(s.run("file.strictReport", &json!({"page": 99})), Err(CmdError::Params(_))));
let mut ok = Session::new(Document::from_text("fine"));
let r = ok.run("file.strictReport", &json!({})).unwrap();
assert_eq!(
r,
json!({"schema": 1, "strict": "passed", "missing_glyphs": [], "missing_pictures": [], "truncated": false, "total_missing_glyphs": 0, "total_missing_pictures": 0, "glyphs_truncated": false, "pictures_truncated": false})
);
}
#[test]
fn strict_report_changes_nothing() {
let c = uncovered();
let mut s = Session::new(Document::from_text(&format!("a{c}")));
let before = serde_json::to_string(&s.doc).unwrap();
s.run("file.strictReport", &json!({})).unwrap();
assert_eq!(serde_json::to_string(&s.doc).unwrap(), before);
assert!(!s.dirty);
}
fn garbage_background(d: &mut Document) {
d.media.insert("bg.bin".into(), std::sync::Arc::new(b"this is not a picture".to_vec()));
d.settings.page_picture = Some(wordcraft_doc::PagePicture { media: "bg.bin".into(), fill_type: "stretch".into(), title: String::new() });
}
#[test]
fn an_undecodable_page_background_picture_is_refused_for_pdf_and_png() {
let d = dir("background");
let mut doc = Document::from_text("Hello");
garbage_background(&mut doc);
let mut s = Session::new(doc);
let r = s.run("file.strictReport", &json!({})).unwrap();
assert_eq!(r["strict"], "failed");
assert_eq!(r["missing_pictures"][0], json!({"part": "bg.bin", "source_part": "background", "reason": "unsupported_format"}));
let msg = refused(s.run("file.exportPdf", &json!({"path": d.join("a.pdf"), "strict": true})));
assert!(msg.contains("bg.bin (background)"), "{msg}");
refused(s.run("file.exportPng", &json!({"path": d.join("a.png"), "strict": true})));
refused(s.run("file.save", &json!({"path": d.join("b.png"), "strict": true})));
assert!(!d.join("a.pdf").exists() && !d.join("a.png").exists() && !d.join("b.png").exists());
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn a_decodable_page_background_picture_passes() {
let mut png = Vec::new();
image::RgbaImage::from_pixel(4, 4, image::Rgba([200, 200, 255, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let mut doc = Document::from_text("Hello");
doc.media.insert("bg.png".into(), std::sync::Arc::new(png));
doc.settings.page_picture = Some(wordcraft_doc::PagePicture { media: "bg.png".into(), fill_type: "stretch".into(), title: String::new() });
let mut s = Session::new(doc);
assert_eq!(s.run("file.strictReport", &json!({})).unwrap()["strict"], "passed");
}
#[test]
fn a_picture_wider_than_the_pdf_draws_is_refused_for_pdf_only() {
// 40000 x 10: past the PDF's decode limit (30000), within what the page raster draws.
let mut png = Vec::new();
image::RgbaImage::from_pixel(40_000, 10, image::Rgba([9, 9, 9, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let mut doc = Document::from_text("Hello");
doc.media.insert("wide.png".into(), std::sync::Arc::new(png));
doc.settings.page_picture = Some(wordcraft_doc::PagePicture { media: "wide.png".into(), fill_type: "stretch".into(), title: String::new() });
let mut s = Session::new(doc);
assert_eq!(s.run("file.strictReport", &json!({})).unwrap()["strict"], "failed", "PDF");
assert_eq!(s.run("file.strictReport", &json!({"page": 1})).unwrap()["strict"], "passed", "PNG");
}
fn watermark(text: &str) -> Document {
let mut doc = Document::from_text("Hello");
doc.settings.watermark = Some(wordcraft_doc::Watermark { text: text.into(), ..Default::default() });
doc
}
#[test]
fn watermark_text_no_font_can_draw_is_refused_and_latin_passes() {
let cs = uncovered_many(2);
let d = dir("watermark");
let mut s = Session::new(watermark(&cs.iter().collect::<String>()));
let r = s.run("file.strictReport", &json!({})).unwrap();
assert_eq!(r["strict"], "failed");
let cps: Vec<String> = cs.iter().map(|c| format!("U+{:04X}", *c as u32)).collect();
let got: Vec<String> = r["missing_glyphs"].as_array().unwrap().iter().map(|g| g["cp"].as_str().unwrap().to_string()).collect();
assert_eq!(got, cps);
refused(s.run("file.exportPdf", &json!({"path": d.join("w.pdf"), "strict": true})));
refused(s.run("file.exportPng", &json!({"path": d.join("w.png"), "strict": true})));
assert!(!d.join("w.pdf").exists() && !d.join("w.png").exists());
let mut ok = Session::new(watermark("CONFIDENTIAL"));
assert_eq!(ok.run("file.strictReport", &json!({})).unwrap()["strict"], "passed");
ok.run("file.exportPdf", &json!({"path": d.join("ok.pdf"), "strict": true})).unwrap();
ok.run("file.exportPng", &json!({"path": d.join("ok.png"), "strict": true})).unwrap();
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn wordart_text_effects_keep_their_glyphs_in_the_check() {
let c = uncovered();
let props = wordcraft_doc::CharProps { shadow: Some(true), outline: Some(true), ..Default::default() };
let mut doc = Document::from_text("");
doc.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&format!("a{c}b"), props))].into_iter().collect();
let mut s = Session::new(doc);
let r = s.run("file.strictReport", &json!({})).unwrap();
assert_eq!(r["missing_glyphs"][0]["cp"], format!("U+{:04X}", c as u32), "{r}");
}
#[test]
fn each_list_says_for_itself_that_it_was_cut() {
let many = uncovered_many(300);
let mut doc = Document::from_text(&many.iter().collect::<String>());
garbage_background(&mut doc);
let mut s = Session::new(doc);
let r = s.run("file.strictReport", &json!({})).unwrap();
assert_eq!(r["missing_glyphs"].as_array().unwrap().len(), 256);
assert_eq!((r["truncated"].clone(), r["glyphs_truncated"].clone(), r["pictures_truncated"].clone()), (json!(true), json!(true), json!(false)));
let lines = wordcraft_engine::strict::lines_from_json(&r);
assert!(lines[0].ends_with(", …"), "{}", lines[0]);
assert!(lines[0].starts_with("strict: 300 characters cannot be drawn (first 256 listed): "), "{}", lines[0]);
assert!(!lines[1].contains('…'), "{}", lines[1]);
assert_eq!((r["total_missing_glyphs"].clone(), r["total_missing_pictures"].clone()), (json!(300), json!(1)));
}
#[test]
fn a_truncated_report_carries_totals_past_the_cap() {
let many = uncovered_many(300);
let mut doc = Document::from_text(&many.iter().collect::<String>());
for n in 0..300 {
doc.missing_pictures.push(wordcraft_doc::MissingPicture {
part: format!("word/media/gone{n}.png"),
story: "word/document.xml".into(),
reason: wordcraft_doc::MissingPictureReason::MissingPart,
});
// The same record again is one picture.
doc.missing_pictures.push(wordcraft_doc::MissingPicture {
part: format!("word/media/gone{n}.png"),
story: "word/document.xml".into(),
reason: wordcraft_doc::MissingPictureReason::MissingPart,
});
}
let mut s = Session::new(doc);
let r = s.run("file.strictReport", &json!({})).unwrap();
assert_eq!(r["missing_glyphs"].as_array().unwrap().len(), 256);
assert_eq!(r["missing_pictures"].as_array().unwrap().len(), 256);
assert_eq!(r["truncated"], true);
assert_eq!(r["total_missing_glyphs"], 300);
assert_eq!(r["total_missing_pictures"], 300);
assert_eq!(r["missing_pictures"][0]["reason"], "missing_part");
assert_eq!(r["missing_pictures"][0]["source_part"], "word/document.xml");
}
#[test]
fn strict_off_gives_the_same_png_as_before_even_with_a_missing_character() {
let c = uncovered();
let d = dir("pngsame");
let mut s = Session::new(Document::from_text(&format!("Hello {c}")));
let png: Vec<Vec<u8>> = [json!({}), json!({"strict": false})]
.into_iter()
.enumerate()
.map(|(i, mut p)| {
p["path"] = json!(d.join(format!("{i}.png")));
s.run("file.exportPng", &p).unwrap();
std::fs::read(d.join(format!("{i}.png"))).unwrap()
})
.collect();
assert!(png[0].starts_with(b"\x89PNG"));
assert_eq!(png[0], png[1]);
std::fs::remove_dir_all(d).unwrap();
}
+100
View File
@@ -0,0 +1,100 @@
//! Pictures a layout draws that the exporters can't decode (#6005): a DOCX opened, laid out, and
//! asked which of its pictures would be grey boxes, and in which story.
use std::io::Write;
use wordcraft_doc::{Document, MissingPictureReason};
use wordcraft_layout::display::DisplayOptions;
use wordcraft_layout::{LayoutCache, LayoutOptions, StrictOutput, layout, media_check, undecodable_pictures};
const NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture""#;
const REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
fn png() -> Vec<u8> {
let mut out = Vec::new();
image::RgbaImage::from_pixel(4, 4, image::Rgba([20, 90, 200, 255]))
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
.unwrap();
out
}
fn pic(rid: &str) -> String {
format!(
r#"<w:r><w:drawing><wp:inline><wp:extent cx="508000" cy="508000"/><wp:docPr id="1" name="P"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="p"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="{rid}"/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="508000" cy="508000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
)
}
/// A document with one header picture (`header_media`) and one body picture (`body_media`), the
/// two parts' bytes as given.
fn docx(header_media: &[u8], body_media: &[u8]) -> Vec<u8> {
let doc = format!(
r#"<w:document {NS}><w:body><w:p>{}<w:r><w:t>text</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr></w:body></w:document>"#,
pic("rIdB")
);
let rels = format!(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{REL}/officeDocument" Target="word/document.xml"/></Relationships>"#
);
let drels = format!(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdH" Type="{REL}/header" Target="header2.xml"/><Relationship Id="rIdB" Type="{REL}/image" Target="media/body.png"/></Relationships>"#
);
let hrels = format!(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdI" Type="{REL}/image" Target="media/image2.bin"/></Relationships>"#
);
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="png" ContentType="image/png"/><Default Extension="bin" ContentType="application/octet-stream"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let hdr = format!(r#"<w:hdr {NS}><w:p>{}</w:p></w:hdr>"#, pic("rIdI"));
for (name, bytes) in [
("[Content_Types].xml", types.as_bytes()),
("_rels/.rels", rels.as_bytes()),
("word/document.xml", doc.as_bytes()),
("word/_rels/document.xml.rels", drels.as_bytes()),
("word/header2.xml", hdr.as_bytes()),
("word/_rels/header2.xml.rels", hrels.as_bytes()),
("word/media/image2.bin", header_media),
("word/media/body.png", body_media),
] {
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap().into_inner()
}
fn report(d: &Document, pages: Option<std::ops::RangeInclusive<usize>>) -> Vec<(String, String, MissingPictureReason)> {
let l = layout(d, &mut LayoutCache::new(), &LayoutOptions::default());
let (list, truncated, total) = undecodable_pictures(d, &l, pages, &DisplayOptions::default(), StrictOutput::Pdf);
assert!(!truncated);
assert_eq!(total, list.len() as u64);
list.into_iter().map(|m| (m.part, m.story, m.reason)).collect()
}
#[test]
fn a_header_picture_of_corrupt_bytes_is_reported_with_its_part_and_story() {
let d = wordcraft_docx::read(&docx(b"corrupt bytes, not a picture", &png())).unwrap();
assert_eq!(report(&d, None), [("word/media/image2.bin".into(), "word/header2.xml".into(), MissingPictureReason::UnsupportedFormat)]);
}
#[test]
fn a_truncated_picture_is_undecodable_not_unsupported() {
let mut cut = png();
cut.truncate(cut.len() - 20);
let d = wordcraft_docx::read(&docx(&png(), &cut)).unwrap();
assert_eq!(report(&d, None), [("word/media/body.png".into(), "word/document.xml".into(), MissingPictureReason::Undecodable)]);
}
#[test]
fn a_real_png_in_a_header_is_not_reported() {
let d = wordcraft_docx::read(&docx(&png(), &png())).unwrap();
assert_eq!(report(&d, None), []);
}
#[test]
fn only_the_selected_pages_are_checked() {
let d = wordcraft_docx::read(&docx(b"corrupt", &png())).unwrap();
assert_eq!(report(&d, Some(0..=0)).len(), 1);
assert_eq!(report(&d, Some(5..=9)), []);
}
#[test]
fn the_check_names_what_the_exporters_draw() {
assert_eq!(media_check(&png(), StrictOutput::Pdf), Ok(()));
assert!(media_check(b"", StrictOutput::Pdf).is_err());
assert!(media_check(&[1, 0, 0, 0, 88, 0, 0, 0], StrictOutput::Pdf).is_err(), "a metafile that doesn't parse is a grey box");
}
+7 -3
View File
@@ -276,6 +276,9 @@ fn tokenize(s: &str, base: &Fmt, depth: usize, refs: &Refs) -> Vec<Node> {
// text. A scan pays for what it reads; earlier bracket matches are looked up in `known`.
let ticks = Backticks::new(s);
let mut links = LinkScan { text: s, refs, ticks: &ticks, known: HashMap::new(), budget: s.len().saturating_mul(8).saturating_add(8192) };
// Where the last comment end is: a comment opener after it has none to find, so a run of
// unclosed `<!--` doesn't search the rest of the text once each.
let last_comment_end = s.rfind("-->");
let b = s.as_bytes();
let mut i = 0usize;
let mut text_start = 0usize;
@@ -366,7 +369,7 @@ fn tokenize(s: &str, base: &Fmt, depth: usize, refs: &Refs) -> Vec<Node> {
}
}
b'<' => {
if let Some((end, parsed)) = angle_inlines(s, i, base) {
if let Some((end, parsed)) = angle_inlines(s, i, base, last_comment_end) {
flush!();
nodes.extend(parsed);
i = end;
@@ -394,8 +397,9 @@ fn tokenize(s: &str, base: &Fmt, depth: usize, refs: &Refs) -> Vec<Node> {
nodes
}
fn angle_inlines(s: &str, start: usize, base: &Fmt) -> Option<(usize, Vec<Node>)> {
if let Some(pos) = s.get(start..).and_then(|rest| rest.strip_prefix("<!--")).and_then(|rest| rest.find("-->")) {
fn angle_inlines(s: &str, start: usize, base: &Fmt, last_comment_end: Option<usize>) -> Option<(usize, Vec<Node>)> {
let closable = last_comment_end.is_some_and(|e| e >= start + 4);
if let Some(pos) = s.get(start..).and_then(|rest| rest.strip_prefix("<!--")).filter(|_| closable).and_then(|rest| rest.find("-->")) {
return Some((start + 4 + pos + 3, Vec::new()));
}
let rest = s.get(start + 1..).unwrap_or("");
+13
View File
@@ -797,3 +797,16 @@ fn heading_trailing_hash_survives_round_trip() {
assert_eq!(export(&import(&md)), want, "second export of {src:?}");
}
}
#[test]
fn unclosed_inline_comments_are_text_without_a_search_each() {
// #3714-style: each unclosed `<!--` searched the rest of the paragraph for `-->`.
let src = "<!--".repeat(200_000);
let t = std::time::Instant::now();
let text: String = inl(&src).into_iter().filter_map(|i| if let Inline::Text(t, _) = i { Some(t) } else { None }).collect();
assert_eq!(text, src);
assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed());
// A comment that does close still goes, after an unclosed one too.
let text: String = inl("a <!-- b --> c <!-- d").into_iter().filter_map(|i| if let Inline::Text(t, _) = i { Some(t) } else { None }).collect();
assert_eq!(text, "a c <!-- d");
}
+5 -3
View File
@@ -123,13 +123,15 @@ mod tests {
assert_eq!(parse_color("#f00"), Some(Rgb(255, 0, 0)));
assert_eq!(parse_color("#f00f"), Some(Rgb(255, 0, 0)));
assert_eq!(parse_color("#0080FF"), Some(Rgb(0, 128, 255)));
assert_eq!(parse_color("#0080ff80"), Some(Rgb(0, 128, 255)));
assert_eq!(parse_color("#0080FFff"), Some(Rgb(0, 128, 255)));
// A colour with alpha is composited over the white page it lands on (#0080ff at 50%).
assert_eq!(parse_color("#0080ff80"), Some(Rgb(127, 191, 255)));
assert_eq!(parse_color("rgb(1, 2, 3)"), Some(Rgb(1, 2, 3)));
assert_eq!(parse_color("rgb(1 2 3)"), Some(Rgb(1, 2, 3)));
assert_eq!(parse_color("rgb(1 2 3 / 50%)"), Some(Rgb(1, 2, 3)));
assert_eq!(parse_color("rgb(1 2 3 / 50%)"), Some(Rgb(128, 129, 129)));
assert_eq!(parse_color("hsl(0, 100%, 50%)"), Some(Rgb(255, 0, 0)));
assert_eq!(parse_color("hsl(120deg 100% 50%)"), Some(Rgb(0, 255, 0)));
assert_eq!(parse_color("hsla(240, 100%, 50%, 0.8)"), Some(Rgb(0, 0, 255)));
assert_eq!(parse_color("hsla(240, 100%, 50%, 0.8)"), Some(Rgb(51, 51, 255)));
assert_eq!(parse_color("navy"), Some(Rgb(0, 0, 128)));
assert_eq!(parse_color("#zz"), None);
assert_eq!(parse_color("rgb(1)"), None);
+73 -9
View File
@@ -34,24 +34,59 @@ fn hsl_to_rgb(h: f64, s: f64, l: f64) -> Rgb {
Rgb(r, g, b)
}
/// Composite `rgb` over the white page a paste lands on, at CSS alpha `a` (0..1): `rgba(0, 0, 0,
/// 0.45)` reads like the rgb(140, 140, 140) it looks like on the site, instead of solid black.
fn over_white(r: u8, g: u8, b: u8, a: f32) -> Rgb {
let mix = |c: u8| (c as f32 * a + 255.0 * (1.0 - a)).round().clamp(0.0, 255.0) as u8;
Rgb(mix(r), mix(g), mix(b))
}
/// The alpha of a CSS colour's `/ alpha` or 4th-value part, where present: 1.0 (opaque) otherwise.
fn css_alpha(after: Option<&str>) -> f32 {
let Some(a) = after else { return 1.0 };
let a = a.trim();
// CSS Colour 4's `none` is a missing component, and a missing alpha is transparent.
if a.eq_ignore_ascii_case("none") {
return 0.0;
}
// A value that is not a finite number (`nan`, `inf`) is no alpha at all: clamp would keep
// NaN, and `over_white` would turn the colour black.
let parse = |t: &str| t.parse::<f32>().ok().filter(|f| f.is_finite());
if let Some(p) = a.strip_suffix('%') {
return parse(p.trim()).map(|f| f.clamp(0.0, 100.0) / 100.0).unwrap_or(1.0);
}
parse(a).map(|f| f.clamp(0.0, 1.0)).unwrap_or(1.0)
}
/// Parse a CSS / HTML colour: `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb(...)`, `hsl(...)` or a basic name.
/// A colour with alpha is composited over white: the model keeps opaque colours, and a paste lands
/// on a white page.
pub fn parse_color(s: &str) -> Option<Rgb> {
let s = s.trim().to_ascii_lowercase();
if let Some(h) = s.strip_prefix('#') {
if (h.len() == 3 || h.len() == 4) && h.is_ascii() {
let d = |i: usize| h.get(i..i + 1).and_then(|x| u8::from_str_radix(x, 16).ok()).map(|v| v * 17);
return Some(Rgb(d(0)?, d(1)?, d(2)?));
let (r, g, b) = (d(0)?, d(1)?, d(2)?);
// A 4th digit is the alpha, composited over the page like `rgba(...)`'s is.
let a = if h.len() == 4 { d(3)? as f32 / 255.0 } else { 1.0 };
return Some(over_white(r, g, b, a));
}
if h.len() == 8 && h.bytes().all(|b| b.is_ascii_hexdigit()) {
let p = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok());
return Some(Rgb(p(0)?, p(2)?, p(4)?));
return Some(over_white(p(0)?, p(2)?, p(4)?, p(6)? as f32 / 255.0));
}
return Rgb::parse(h);
}
if let Some(inner) = s.strip_prefix("rgb(").or_else(|| s.strip_prefix("rgba(")) {
let inner = inner.trim_end_matches(')').trim();
let before_slash = inner.split('/').next().unwrap_or(inner);
let parts: Vec<&str> = if before_slash.contains(',') { before_slash.split(',').collect() } else { before_slash.split_whitespace().collect() };
let (rgb_part, alpha_part) = inner.split_once('/').map_or((inner, None), |(a, b)| (a, Some(b)));
// Legacy comma syntax carries the alpha as a 4th value (`rgba(0, 0, 0, 0.45)`).
let parts: Vec<&str> = if rgb_part.contains(',') { rgb_part.split(',').collect() } else { rgb_part.split_whitespace().collect() };
let alpha = match alpha_part {
Some(a) => css_alpha(Some(a)),
None if parts.len() >= 4 => css_alpha(Some(parts[3])),
_ => 1.0,
};
let v: Vec<u8> = parts
.iter()
.take(3)
@@ -65,14 +100,19 @@ pub fn parse_color(s: &str) -> Option<Rgb> {
})
.collect();
if let [r, g, b] = v[..] {
return Some(Rgb(r, g, b));
return Some(over_white(r, g, b, alpha));
}
return None;
}
if let Some(inner) = s.strip_prefix("hsl(").or_else(|| s.strip_prefix("hsla(")) {
let inner = inner.trim_end_matches(')').trim();
let before_slash = inner.split('/').next().unwrap_or(inner);
let parts: Vec<&str> = if before_slash.contains(',') { before_slash.split(',').collect() } else { before_slash.split_whitespace().collect() };
let (hsl_part, alpha_part) = inner.split_once('/').map_or((inner, None), |(a, b)| (a, Some(b)));
let parts: Vec<&str> = if hsl_part.contains(',') { hsl_part.split(',').collect() } else { hsl_part.split_whitespace().collect() };
let alpha = match alpha_part {
Some(a) => css_alpha(Some(a)),
None if parts.len() >= 4 => css_alpha(Some(parts[3])),
_ => 1.0,
};
if parts.len() < 3 {
return None;
}
@@ -96,7 +136,8 @@ pub fn parse_color(s: &str) -> Option<Rgb> {
};
let s = parse_pct(parts[1])?;
let l = parse_pct(parts[2])?;
return Some(hsl_to_rgb(h, s, l));
let Rgb(r, g, b) = hsl_to_rgb(h, s, l);
return Some(over_white(r, g, b, alpha));
}
Some(match s.as_str() {
"black" => Rgb(0, 0, 0),
@@ -127,6 +168,29 @@ mod tests {
#[test]
fn eight_digit_hex_rejects_signs() {
assert_eq!(parse_color("#+1+2+3+4"), None);
assert_eq!(parse_color("#11223344"), Some(Rgb(0x11, 0x22, 0x33)));
// #11223344 is alpha 0x44 over white.
assert_eq!(parse_color("#11223344"), Some(Rgb(192, 196, 201)));
}
#[test]
fn colors_with_alpha_composite_over_white() {
// The secondary text colour of dark-theme sites: solid black would read as the site's
// own emphasis, white is invisible on the page; the composite is what the eye saw.
assert_eq!(parse_color("rgba(0, 0, 0, 0.45)"), Some(Rgb(140, 140, 140)));
assert_eq!(parse_color("rgb(0 0 0 / 0.45)"), Some(Rgb(140, 140, 140)));
assert_eq!(parse_color("rgba(0, 0, 0, 0)"), Some(Rgb(255, 255, 255)));
assert_eq!(parse_color("rgba(255, 0, 0, 1)"), Some(Rgb(255, 0, 0)));
assert_eq!(parse_color("rgba(255, 0, 0, 45%)"), Some(Rgb(255, 140, 140)));
assert_eq!(parse_color("hsla(120, 100%, 50%, 0.5)"), Some(Rgb(128, 255, 128)));
// A 4-digit hex's last digit is the alpha, too: #0008 is black at 8/15 over white.
assert_eq!(parse_color("#0008"), Some(Rgb(119, 119, 119)));
// `none` is a missing alpha, and a missing alpha is transparent, not the opaque default
// an omitted alpha takes.
assert_eq!(parse_color("rgb(0 0 0 / none)"), Some(Rgb(255, 255, 255)));
assert_eq!(parse_color("rgb(0 0 0)"), Some(Rgb(0, 0, 0)));
// An alpha that is not a finite number is no alpha: the colour stays opaque, not black.
assert_eq!(parse_color("rgba(0, 0, 0, nan)"), Some(Rgb(0, 0, 0)));
assert_eq!(parse_color("rgba(0, 0, 0, inf)"), Some(Rgb(0, 0, 0)));
assert_eq!(parse_color("rgba(0, 0, 0, nan%)"), Some(Rgb(0, 0, 0)));
}
}
+39 -11
View File
@@ -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) }));
+14
View File
@@ -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));
}
+35 -2
View File
@@ -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 {
+56 -5
View File
@@ -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
+55 -2
View File
@@ -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.
+18 -1
View File
@@ -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"
+157 -3
View File
@@ -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;
}
+63 -15
View File
@@ -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!(
+167 -34
View File
@@ -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));
+76 -1
View File
@@ -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();
+425
View File
@@ -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}");
}
+3 -2
View File
@@ -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)) => {
+2 -1
View File
@@ -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));
+44 -35
View File
@@ -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));
+18 -2
View File
@@ -135,7 +135,13 @@ fn parse_full(bytes: &[u8], marks: bool) -> Result<Parsed, String> {
}
match n {
b'\'' => {
let h = bytes.get(i + 2..i + 4).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok());
// `from_str_radix` takes a leading `+`: a pair that isn't both hex digits
// is not an escape at all.
let h = bytes
.get(i + 2..i + 4)
.and_then(|h| std::str::from_utf8(h).ok())
.filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit()))
.and_then(|h| u8::from_str_radix(h, 16).ok());
match h {
Some(v) => {
if r.st.dest == Dest::Pict {
@@ -150,7 +156,17 @@ fn parse_full(bytes: &[u8], marks: bool) -> Result<Parsed, String> {
}
i += 4;
}
None => i += 2,
// Not two hex digits: the escape is malformed. Consume the escape and
// any hex digits of a truncated pair, and leave every other byte,
// `}` or a control word's `\`, to be read as the structure it is.
None => {
i += 2;
let mut n = 0;
while n < 2 && bytes.get(i).is_some_and(u8::is_ascii_hexdigit) {
i += 1;
n += 1;
}
}
}
}
b'*' => {
+67
View File
@@ -37,6 +37,73 @@ fn basic_reader() {
assert!(matches!(&l.inlines[0], Inline::Text(_, f) if f.link.as_deref() == Some("http://a.b")));
}
#[test]
fn stylesheet_names_take_unicode_escapes() {
// \u116?=t \u105?=i \u116?=t \u108?=l \u101?=e — how Word writes a non-Latin style name.
let rtf = br"{\rtf1\ansi{\stylesheet{\s0 Normal;}{\s2 \u116?\u105?\u116?\u108?\u101?;}}\pard\s2 T\par}";
let f = parse(rtf).unwrap();
assert_eq!(texts(&f)[0], "Title:T");
}
#[test]
fn stylesheet_names_take_negative_escapes() {
// 't' as a signed 16-bit escape: 116 - 65536 = -65420.
let rtf = br"{\rtf1\ansi{\stylesheet{\s0 Normal;}{\s2 \u-65420?\u105?\u116?\u108?\u101?;}}\pard\s2 T\par}";
let f = parse(rtf).unwrap();
assert_eq!(texts(&f)[0], "Title:T");
}
#[test]
fn stylesheet_names_honour_the_uc_fallback_count() {
// \uc2: two fallback bytes follow each \uN? — here a second `?`.
let rtf = br"{\rtf1\ansi{\stylesheet{\s0 Normal;}{\s2 \uc2\u116??\u105??\u116??\u108??\u101??;}}\pard\s2 T\par}";
let f = parse(rtf).unwrap();
assert_eq!(texts(&f)[0], "Title:T");
}
#[test]
fn a_malformed_hex_escape_leaves_no_stray_text() {
// A `\'` that is not followed by two hex digits is malformed: the escape itself goes, any
// hex digits of a truncated pair go with it, and everything else — non-hex bytes, `}` —
// is read as what it is. `\'gZ` keeps "gZ" as text; `\'4}` keeps the closing brace.
let rtf = br"{\rtf1\ansi \'gZok\par}";
let f = parse(rtf).unwrap();
assert_eq!(texts(&f)[0], "Normal:gZok");
// The bytes after a truncated pair must not swallow the group's closing brace or the next
// control word: the group and its destination state end where they should. Paragraph text
// alone would not show that: `survivor` reads the same inside the bold group, so the run's
// formatting is checked too.
let rtf = br"{\rtf1\ansi{\b \'4}survivor\par}";
let f = parse(rtf).unwrap();
assert!(texts(&f).iter().any(|t| t.contains("survivor")), "{:?}", texts(&f));
let survivor_bold = f.blocks.iter().find_map(|b| match b {
FBlock::Para(p) => p.inlines.iter().find_map(|i| match i {
Inline::Text(t, fmt) if t.contains("survivor") => Some(fmt.bold),
_ => None,
}),
FBlock::Table(_) => None,
});
assert_eq!(survivor_bold, Some(false), "survivor must not be inside the bold group: {:?}", f.blocks);
// The truncated pair's hex digit goes with the escape: no stray "4" is left in the text.
assert!(!texts(&f).iter().any(|t| t.contains('4')), "{:?}", texts(&f));
// A leading `+` is not a hex digit: `\'+4` is no escape: it stays text, not the byte 0x04.
let rtf = br"{\rtf1\ansi \'+4z\par}";
let f = parse(rtf).unwrap();
assert_eq!(texts(&f)[0], "Normal:+4z");
let rtf = br"{\rtf1\ansi{\'}}after\par}";
let f = parse(rtf).unwrap();
assert!(texts(&f).iter().any(|t| t.contains("after")), "{:?}", texts(&f));
// A hex digit that starts a valid pair with the byte after it is part of the escape:
// \'4} reads '4' as the escape's byte and leaves `}` to close the group.
let rtf = br"{\rtf1\ansi\'4}x\par}";
let f = parse(rtf).unwrap();
assert!(texts(&f).iter().any(|t| t.contains('x') && !t.contains('}')), "{:?}", texts(&f));
}
#[test]
fn unicode_fallback_uses_the_declared_count() {
for (rtf, expected) in [
+3 -1
View File
@@ -403,7 +403,8 @@ impl Reader {
}
true
}
Dest::StyleSheet => {
// Style names take Unicode escapes (with fallback skipping) like the body.
Dest::StyleSheet if !matches!(word, "u" | "uc") => {
let def = &mut self.style_def_cur;
match word {
"s" | "cs" | "ds" | "ts" => {
@@ -420,6 +421,7 @@ impl Reader {
}
true
}
Dest::StyleSheet => false,
Dest::ListTable => {
self.list_table_word(word, value);
true
+93
View File
@@ -194,6 +194,87 @@ pub fn decode(bytes: &[u8], budget: u64) -> Result<DynamicImage, Refusal> {
probe(bytes)?.decode(budget)
}
/// Why a picture can't be drawn, as strict export reports it (#6005).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DrawFailure {
/// A picture format we know, but it doesn't decode (damaged, truncated, too large).
Undecodable,
/// Bytes that are not a picture format we draw.
UnsupportedFormat,
}
impl DrawFailure {
/// The words reports use.
pub fn label(self) -> &'static str {
match self {
DrawFailure::Undecodable => "undecodable",
DrawFailure::UnsupportedFormat => "unsupported format",
}
}
}
impl From<&Refusal> for DrawFailure {
fn from(r: &Refusal) -> Self {
match r {
Refusal::NotAPicture | Refusal::Unsupported(_) => DrawFailure::UnsupportedFormat,
Refusal::TooLarge { .. } | Refusal::Broken(_) => DrawFailure::Undecodable,
}
}
}
/// Decode a raster picture for drawing: the one decision the PDF export, the raster renderer and
/// strict export's check all take. The decoder is held to `max_side` pixels a side and the header
/// is charged against `budget` bytes before anything is allocated. A picture with no pixels or a
/// side past `max_side` is refused.
pub fn decode_for_drawing(bytes: &[u8], budget: u64, max_side: u32) -> Result<DynamicImage, Refusal> {
let mut limits = image::Limits::default();
limits.max_image_width = Some(max_side);
limits.max_image_height = Some(max_side);
let reader = || {
let mut r = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(|_| Refusal::NotAPicture)?;
r.limits(limits.clone());
Ok::<_, Refusal>(r)
};
// Header-only size check before anything is allocated: the decoder's buffer at its own pixel
// size (8 bytes for 16-bit RGBA) and the RGBA conversion are charged.
let (hw, hh, native) = {
let d = reader()?.into_decoder().map_err(broken)?;
let (w, h) = d.dimensions();
(w, h, u64::from(d.color_type().bytes_per_pixel()))
};
if !crate::decode_peak_fits(hw, hh, native, budget) {
return Err(Refusal::TooLarge { width: hw, height: hh });
}
let img = reader()?.decode().map_err(broken)?;
let (w, h) = (img.width(), img.height());
if w == 0 || h == 0 || w > max_side || h > max_side {
return Err(Refusal::Broken("it has no pixels or a side past the limit".into()));
}
Ok(img)
}
/// Largest page-background picture the page raster draws, in pixels (hostile files).
pub const MAX_BACKGROUND_PIXELS: u64 = 64 * 1024 * 1024;
/// The pixel size of a page-background picture the page raster draws (Design > Page Color > Fill
/// Effects), or None when it leaves the background out: not a raster picture it can read (a
/// metafile is not) or one with no pixels or more than [`MAX_BACKGROUND_PIXELS`]. The raster and
/// the strict check both ask this, so they can't drift.
pub fn background_size(bytes: &[u8]) -> Option<(u32, u32)> {
let (w, h) = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()?;
(w > 0 && h > 0 && u64::from(w) * u64::from(h) <= MAX_BACKGROUND_PIXELS).then_some((w, h))
}
/// Whether a raster picture decodes for drawing, by the rule the exporters use ([`probe`] within
/// [`crate::MAX_DECODED_BYTES`], then [`decode_for_drawing`] with `max_side`).
pub fn check_for_drawing(bytes: &[u8], max_side: u32) -> Result<(), DrawFailure> {
let fail = |r: Refusal| DrawFailure::from(&r);
if !probe(bytes).map_err(fail)?.fits(crate::MAX_DECODED_BYTES) {
return Err(DrawFailure::Undecodable);
}
decode_for_drawing(bytes, crate::MAX_DECODED_BYTES, max_side).map(|_| ()).map_err(fail)
}
/// Bytes decoding a `width` × `height` picture holds at its peak: the decoder's output (`native`
/// bytes a pixel) beside what the decoder keeps while it decodes (`held`), then the output beside
/// its RGBA8 conversion, then that beside a premultiplied copy. None on overflow.
@@ -340,6 +421,18 @@ mod tests {
out
}
#[test]
fn drawing_check_tells_unsupported_from_undecodable() {
let png = encode(image::RgbaImage::new(4, 4), ImageFormat::Png);
assert_eq!(check_for_drawing(&png, 100), Ok(()));
assert_eq!(check_for_drawing(b"junk", 100), Err(DrawFailure::UnsupportedFormat));
assert_eq!(check_for_drawing(&png[..png.len() - 20], 100), Err(DrawFailure::Undecodable));
// A side past the limit is refused, by the same function the exporters draw with.
assert_eq!(check_for_drawing(&png, 3), Err(DrawFailure::Undecodable));
assert!(decode_for_drawing(&png, crate::MAX_DECODED_BYTES, 100).is_ok());
assert_eq!(DrawFailure::Undecodable.label(), "undecodable");
}
/// A PNG whose header declares `w` × `h` pixels, with no pixel data.
fn png_header(w: u32, h: u32) -> Vec<u8> {
let mut png = encode(image::RgbaImage::new(1, 1), ImageFormat::Png);
+2 -3
View File
@@ -848,7 +848,7 @@ fn section_break_mark(start: SectionStart, x0: f32, x1: f32, base: f32, size: f3
fn equation(items: &[MItem], x: f32, base: f32, alpha: f32, screen: bool, out: &mut Vec<Draw>) {
for it in items {
match it {
MItem::Glyphs { face, size, color, synth_bold, synth_italic, glyphs, text } => out.push(Draw::Glyphs {
MItem::Glyphs { face, size, color, synth_bold, synth_italic, glyphs, text, ranges } => out.push(Draw::Glyphs {
face: *face,
size: *size,
glyphs: glyphs.iter().map(|(g, gx, gy)| (*g, x + gx, base - gy)).collect(),
@@ -858,8 +858,7 @@ fn equation(items: &[MItem], x: f32, base: f32, alpha: f32, screen: bool, out: &
synth_italic: *synth_italic,
text: text.clone(),
link: None,
// Equation text maps back through the font (no per-glyph ranges from the math layout).
ranges: Vec::new(),
ranges: ranges.clone(),
text_bg: None,
x_scale: 1.0,
}),
+2 -1
View File
@@ -307,6 +307,7 @@ fn text_glyphs(doc: &Document, r: Rect, it: &GraphicItem, alpha: f32, k: f32) ->
let mut out = Vec::with_capacity(runs.len());
for (face, s, shaped) in runs {
let k = scale(face);
let ranges = crate::missing::cluster_ranges(s, &shaped);
let mut glyphs = Vec::with_capacity(shaped.len());
for g in &shaped {
glyphs.push((g.gid, pen + g.x_offset as f32 * k, baseline - g.y_offset as f32 * k));
@@ -324,7 +325,7 @@ fn text_glyphs(doc: &Document, r: Rect, it: &GraphicItem, alpha: f32, k: f32) ->
synth_italic: false,
text: s.to_string(),
link: None,
ranges: Vec::new(),
ranges,
text_bg: None,
x_scale: 1.0,
});
+4
View File
@@ -58,10 +58,12 @@ mod ctx;
mod floats;
mod flow;
mod headers;
mod missing;
mod notes;
mod page;
mod pass;
mod placed;
mod strict;
mod substituted;
mod table_flow;
@@ -82,6 +84,7 @@ use headers::MAX_HEADER_ITEMS;
#[cfg(test)]
use headers::placed_cost;
use headers::placed_cost_by;
pub use missing::{MAX_MISSING_GLYPHS, MissingGlyphs, metafile_text_run, missing_watermark_glyphs, watermark_run};
pub use notes::DECOR_STORY;
pub(crate) use notes::NOTE_SEP_W;
use notes::{MAX_NOTE_PASSES, footnote_pages};
@@ -91,6 +94,7 @@ use page::item_bottom;
use pass::layout_pass;
pub use pass::now_ms;
pub use placed::{HfSlot, Placed, turn_point, turn_rect, unturn_point};
pub use strict::{MAX_PDF_PICTURE_SIDE, MAX_PNG_PICTURE_SIDE, StrictOutput, media_check, undecodable_pictures};
pub use substituted::MAX_SUBSTITUTED;
/// How the document is viewed.
+2
View File
@@ -41,6 +41,8 @@ pub enum MItem {
/// Glyph id, x, y (baseline, up).
glyphs: Vec<(u32, f32, f32)>,
text: String,
/// Byte range of `text` each glyph shows (empty when `text` is: a glyph picked by id).
ranges: Vec<std::ops::Range<usize>>,
},
/// A filled rectangle; `y` is its bottom edge.
Rect {
+30 -3
View File
@@ -103,7 +103,16 @@ impl Ctx {
b.glyph = Some((face, g, attach));
}
if !glyphs.is_empty() {
b.items.push(MItem::Glyphs { face, size, color, synth_bold: synth.0, synth_italic: synth.1, glyphs, text: text.to_string() });
b.items.push(MItem::Glyphs {
face,
size,
color,
synth_bold: synth.0,
synth_italic: synth.1,
glyphs,
text: text.to_string(),
ranges: crate::missing::cluster_ranges(text, &shaped),
});
}
b
}
@@ -132,7 +141,16 @@ impl Ctx {
if let Some(t) = &self.t {
b.ic = t.italics.get(&gid).copied().unwrap_or(0.0) * k;
}
b.items.push(MItem::Glyphs { face, size, color, synth_bold: false, synth_italic: false, glyphs: vec![(gid, 0.0, 0.0)], text: String::new() });
b.items.push(MItem::Glyphs {
face,
size,
color,
synth_bold: false,
synth_italic: false,
glyphs: vec![(gid, 0.0, 0.0)],
text: String::new(),
ranges: Vec::new(),
});
b
}
@@ -227,7 +245,16 @@ impl Ctx {
} else {
b.w = pos * k;
}
b.items.push(MItem::Glyphs { face: self.face, size, color: self.color, synth_bold: false, synth_italic: false, glyphs, text: String::new() });
b.items.push(MItem::Glyphs {
face: self.face,
size,
color: self.color,
synth_bold: false,
synth_italic: false,
glyphs,
text: String::new(),
ranges: Vec::new(),
});
Some(b)
}
+309
View File
@@ -0,0 +1,309 @@
//! The characters the output would draw as an empty box ([`DocLayout::missing_glyphs`]): glyph
//! id 0 in a [`Draw::Glyphs`] of the display list, traced back to its character through the
//! run's `text` and per-glyph `ranges`. Backs strict export (#6005).
use std::collections::{BTreeMap, HashMap};
use std::ops::{Range, RangeInclusive};
use wordcraft_doc::Document;
use wordcraft_fonts::ShapedGlyph;
use crate::DocLayout;
use crate::display::{DisplayOptions, Draw, page_display};
use crate::para::draws_something;
/// Most distinct code points [`DocLayout::missing_glyphs`] lists: a hostile file full of unmapped
/// characters can't make the report unbounded.
pub const MAX_MISSING_GLYPHS: usize = 256;
/// The missing glyphs of a render: characters it draws that no face in the font fallback chain can
/// draw (glyph id 0, an empty box).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct MissingGlyphs {
/// (code point, times drawn), sorted by code point, at most [`MAX_MISSING_GLYPHS`] long.
pub chars: Vec<(char, u64)>,
/// More than [`MAX_MISSING_GLYPHS`] distinct code points were missing: the rest aren't listed.
pub truncated: bool,
/// How many distinct code points were missing, those past the cap included.
pub total: usize,
/// Every distinct code point found, kept only once the cap was passed (136 KiB), so merging
/// the watermark's can still count the distinct ones.
pub(crate) seen: Option<Box<CodePointSet>>,
}
impl MissingGlyphs {
/// Nothing is missing.
pub fn is_empty(&self) -> bool {
self.chars.is_empty() && !self.truncated
}
/// A list of `chars` that fits under the cap: its total is its length.
pub fn from_listed(chars: Vec<(char, u64)>) -> Self {
let total = chars.len();
Self { chars, truncated: false, total, seen: None }
}
/// Add `other` (the watermark's characters): counts of a shared code point are summed, new
/// ones are listed while the cap allows, and the total counts each distinct one once.
pub fn merge(&mut self, other: MissingGlyphs) {
let mut all: BTreeMap<char, u64> = std::mem::take(&mut self.chars).into_iter().collect();
let mut seen = self.seen.take();
for (c, n) in other.chars {
if let Some(count) = all.get_mut(&c) {
*count = count.saturating_add(n);
} else if let Some(set) = seen.as_mut() {
set.insert(c);
} else if all.len() < MAX_MISSING_GLYPHS {
all.insert(c, n);
} else {
let mut set = CodePointSet::of(all.keys().copied());
set.insert(c);
seen = Some(Box::new(set));
}
}
if let Some(theirs) = other.seen {
seen.get_or_insert_with(|| Box::new(CodePointSet::of(all.keys().copied()))).union(&theirs);
}
self.truncated |= other.truncated || seen.is_some();
self.total = seen.as_ref().map_or(all.len(), |s| s.len());
self.chars = all.into_iter().collect();
self.seen = seen;
}
}
/// A set of code points as a bitset over the whole code point space: bounded at 136 KiB however
/// many distinct characters a hostile file holds.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct CodePointSet(Vec<u64>);
impl CodePointSet {
fn empty() -> Self {
Self(vec![0; 0x11_0000 / 64])
}
fn of(chars: impl Iterator<Item = char>) -> Self {
let mut set = Self::empty();
for c in chars {
set.insert(c);
}
set
}
fn insert(&mut self, c: char) {
let n = c as usize;
if let Some(word) = self.0.get_mut(n / 64) {
*word |= 1 << (n % 64);
}
}
fn union(&mut self, other: &Self) {
for (a, b) in self.0.iter_mut().zip(&other.0) {
*a |= b;
}
}
fn len(&self) -> usize {
self.0.iter().map(|w| w.count_ones() as usize).sum()
}
}
impl DocLayout {
/// The characters drawn as glyph id 0 on `pages` (0-based, inclusive; `None` is every page),
/// from the display list the PDF and PNG exports draw with `opts`: body, headers, footers,
/// text boxes, notes, table cells, charts, diagrams and equations. A character counts once per
/// glyph drawn for it. Default-ignorable and control characters never count (they draw
/// nothing), and a Private Use Area character counts only if it really drew as glyph 0.
/// Pages past the end are ignored.
pub fn missing_glyphs(&self, doc: &Document, opts: &DisplayOptions, pages: Option<RangeInclusive<usize>>) -> MissingGlyphs {
let mut found = Found::default();
for (i, page) in self.pages.iter().enumerate() {
if pages.as_ref().is_some_and(|r| !r.contains(&i)) {
continue;
}
for d in page_display(doc, page, opts) {
found.walk(doc, &d);
}
// The page picture is drawn behind every page, outside the display list.
if let Some(pic) = &doc.settings.page_picture {
found.metafile_text(doc, &pic.media);
}
}
found.finish()
}
}
/// How the watermark text is shaped for output, as the PDF export and the page raster both do:
/// its first `max_chars` characters in the font its name resolves to (no fallback face), so a
/// character that font lacks shapes to glyph id 0. Strict export checks these same glyphs
/// ([`missing_watermark_glyphs`]), so the check and the exporters can't drift. `None` for a
/// blank watermark.
pub fn watermark_run(wm: &wordcraft_doc::Watermark, max_chars: usize) -> Option<(wordcraft_fonts::FaceRef, String, Vec<ShapedGlyph>)> {
if wm.text.trim().is_empty() {
return None;
}
let face = wordcraft_fonts::word::resolve(&wm.font, false, false).face;
let text: String = wm.text.chars().take(max_chars).collect();
let shaped = wordcraft_fonts::shape(&face, &text, &[], |c| c);
Some((face, text, shaped))
}
/// How a metafile text run is shaped for output, as the PDF export and the page raster both do:
/// in the face its font name resolves to (no fallback face), so a character that face lacks shapes
/// to glyph id 0. Strict export checks these same glyphs, so the check and the exporters can't
/// drift.
pub fn metafile_text_run(t: &wordcraft_metafile::Text) -> (wordcraft_fonts::FaceRef, Vec<ShapedGlyph>) {
let face = wordcraft_fonts::word::resolve(&t.font, t.bold, t.italic).face;
let shaped = wordcraft_fonts::shape(&face, &t.text, &[], |c| c);
(face, shaped)
}
/// The characters of the watermark that draw as an empty box (glyph id 0), counted as
/// [`DocLayout::missing_glyphs`] counts them; sorted by code point. The watermark is on every
/// page, so it counts once.
pub fn missing_watermark_glyphs(wm: &wordcraft_doc::Watermark, max_chars: usize) -> MissingGlyphs {
let mut found = Found::default();
if let Some((face, text, shaped)) = watermark_run(wm, max_chars) {
let ranges = cluster_ranges(&text, &shaped);
found.run(&face, shaped.iter().map(|g| g.gid), &text, &ranges);
}
found.finish()
}
#[derive(Default)]
struct Found {
/// The text runs of each metafile picture met, by media key, shaped once: (face, text, ranges, glyph ids).
metafiles: HashMap<String, Vec<MetaRun>>,
counts: BTreeMap<char, u64>,
truncated: bool,
/// Every distinct code point, started when the list passes its cap.
over: Option<Box<CodePointSet>>,
}
/// A metafile text run as drawn: its face, text, per-glyph ranges and glyph ids.
struct MetaRun {
face: wordcraft_fonts::FaceRef,
text: String,
ranges: Vec<Range<usize>>,
gids: Vec<u32>,
}
impl Found {
/// The text of the metafile picture `key` (nothing for a picture that is not a metafile, does
/// not parse or has no size: the exporters draw none of it), counted once per call.
fn metafile_text(&mut self, doc: &Document, key: &str) {
if !self.metafiles.contains_key(key) {
let runs = doc.media.get(key).map(|bytes| meta_runs(bytes)).unwrap_or_default();
self.metafiles.insert(key.to_string(), runs);
}
let Some(runs) = self.metafiles.remove(key) else { return };
for r in &runs {
self.run(&r.face, r.gids.iter().copied(), &r.text, &r.ranges);
}
self.metafiles.insert(key.to_string(), runs);
}
fn finish(self) -> MissingGlyphs {
let total = self.over.as_ref().map_or(self.counts.len(), |s| s.len());
MissingGlyphs { chars: self.counts.into_iter().collect(), truncated: self.truncated, total, seen: self.over }
}
fn walk(&mut self, doc: &Document, d: &Draw) {
match d {
Draw::Glyphs { face, glyphs, text, ranges, .. } => self.run(face, glyphs.iter().map(|g| g.0), text, ranges),
Draw::Image { media, .. } => self.metafile_text(doc, media),
Draw::Figure { draws: items, .. } | Draw::Turned { items, .. } | Draw::Rotated { items, .. } | Draw::Clip { items, .. } => {
for d in items {
self.walk(doc, d);
}
}
_ => {}
}
}
/// A run of glyphs of `face` (`gids`) showing `text`, glyph `k` showing `ranges[k]`.
fn run(&mut self, face: &wordcraft_fonts::FaceRef, gids: impl Iterator<Item = u32>, text: &str, ranges: &[Range<usize>]) {
let glyph_count = gids.size_hint().1.unwrap_or(0);
let one_each = ranges.is_empty() && text.chars().count() == glyph_count;
let mut chars = text.chars();
for (k, gid) in gids.enumerate() {
// A run without ranges (a list label) maps one character to one glyph, when the
// counts agree; else it can't be traced and isn't counted.
let own = chars.next();
if gid != 0 {
continue;
}
let shown: Vec<char> = if one_each {
own.into_iter().collect()
} else {
ranges.get(k).and_then(|r| text.get(r.clone())).map(|t| t.chars().collect()).unwrap_or_default()
};
let drawn: Vec<char> = shown.into_iter().filter(|c| draws_something(*c)).collect();
// A cluster of several characters drew one empty box: it is the one the face
// lacks, not the letter beside it.
let lacking: Vec<char> = drawn.iter().copied().filter(|c| !face.covers(*c)).collect();
for c in if lacking.is_empty() { drawn } else { lacking } {
self.count(c);
}
}
}
fn count(&mut self, c: char) {
if let Some(n) = self.counts.get_mut(&c) {
*n = n.saturating_add(1);
} else if let Some(set) = self.over.as_mut() {
set.insert(c);
} else if self.counts.len() < MAX_MISSING_GLYPHS {
self.counts.insert(c, 1);
} else {
self.truncated = true;
let mut set = CodePointSet::of(self.counts.keys().copied());
set.insert(c);
self.over = Some(Box::new(set));
}
}
}
/// The text runs of a metafile picture that the exporters draw (a run whose layout fails is not).
fn meta_runs(bytes: &[u8]) -> Vec<MetaRun> {
if !wordcraft_metafile::is_metafile(bytes) {
return Vec::new();
}
let Ok(pic) = wordcraft_metafile::parse(bytes) else { return Vec::new() };
if !pic.has_size() {
return Vec::new();
}
let mut out = Vec::new();
for item in &pic.items {
let wordcraft_metafile::Item::Text(t) = item else { continue };
let (face, shaped) = metafile_text_run(t);
let glyphs: Vec<_> = shaped.iter().map(|g| (g.cluster, f64::from(g.x_advance), f64::from(g.x_offset), f64::from(g.y_offset))).collect();
if t.layout(face.upem, face.ascent, face.descent, &glyphs).is_none() {
continue;
}
let ranges = cluster_ranges(&t.text, &shaped);
out.push(MetaRun { gids: shaped.iter().map(|g| g.gid).collect(), text: t.text.clone(), ranges, face });
}
out
}
/// The byte range of `text` each of `shaped` shows: from its cluster to the next cluster start in
/// the text (or its end), whichever order the glyphs are in. A ligature's glyph covers all its
/// characters; the glyphs of one cluster share its range. Ranges always lie on character
/// boundaries of `text`.
pub(crate) fn cluster_ranges(text: &str, shaped: &[ShapedGlyph]) -> Vec<Range<usize>> {
let mut starts: Vec<usize> = shaped.iter().map(|g| g.cluster.min(text.len())).filter(|b| text.is_char_boundary(*b)).collect();
starts.sort_unstable();
starts.dedup();
shaped
.iter()
.map(|g| {
let from = g.cluster.min(text.len());
let from = if text.is_char_boundary(from) { from } else { (0..from).rev().find(|b| text.is_char_boundary(*b)).unwrap_or(0) };
let to = match starts.binary_search(&from) {
Ok(i) => starts.get(i + 1).copied().unwrap_or(text.len()),
Err(_) => text.len(),
};
from..to.max(from)
})
.collect()
}
+201
View File
@@ -0,0 +1,201 @@
//! Pictures a layout draws that the exporters cannot decode, for strict export (#6005).
//!
//! The PDF export, the raster renderer and this check decide whether a picture is drawn the same
//! way (`wordcraft_geom::picture::decode_for_drawing`, and the metafile parser): what fails here
//! is what shows as a grey box there.
use std::collections::{HashMap, HashSet};
use std::ops::RangeInclusive;
use wordcraft_doc::{Document, MAX_MISSING_PICTURES, MissingPicture, MissingPictureReason, StoryRef, picture_hash};
use wordcraft_geom::picture::DrawFailure;
use crate::display::{DisplayOptions, Draw, ItemDisplay};
use crate::{DocLayout, Placed};
/// The widest or tallest picture the PDF export draws, pixels (its decoder limit).
pub const MAX_PDF_PICTURE_SIDE: u32 = 30_000;
/// The widest or tallest picture the page raster draws, pixels (what a `Pixmap` holds).
pub const MAX_PNG_PICTURE_SIDE: u32 = u16::MAX as u32;
/// The output a strict export checks, which decides what its exporter can draw.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StrictOutput {
Pdf,
Png,
}
impl StrictOutput {
/// The widest or tallest picture this output decodes.
pub fn max_picture_side(self) -> u32 {
match self {
StrictOutput::Pdf => MAX_PDF_PICTURE_SIDE,
StrictOutput::Png => MAX_PNG_PICTURE_SIDE,
}
}
}
/// Whether `bytes` are a picture `output` draws: a metafile that parses, or a raster picture that
/// decodes within the decode budget. The same decision `wordcraft-pdf` and `wordcraft-render`
/// take, so a picture that fails here is drawn as a grey box (or not at all) by that output.
pub fn media_check(bytes: &[u8], output: StrictOutput) -> Result<(), DrawFailure> {
if wordcraft_metafile::is_metafile(bytes) && wordcraft_metafile::parse(bytes).is_ok_and(|p| p.has_size()) {
return Ok(());
}
wordcraft_geom::picture::check_for_drawing(bytes, output.max_picture_side())
}
/// The pictures drawn on `pages` (0-based, `None` for every page) that fail [`media_check`] for
/// `output`, the page's background picture included (named `background`), one
/// per picture and story, at most [`MAX_MISSING_PICTURES`]; the flag says more were found, and the
/// count is how many distinct ones there were in all (saturating).
///
/// `part` is the part the bytes were read from when the document knows it (a DOCX opened by the
/// reader), else the media key. `story` is the source part of the header, footer, note or comment
/// the picture is in (`word/header2.xml`), `word/document.xml` for the body; where the document
/// holds no source names (a document not opened from a DOCX) it is `header`, `footer`,
/// `footnote`, `endnote`, `comment`, `text box` or `body`. A picture in a text box or a shape is
/// named for the header, footer or body area it is placed in, not for the box.
pub fn undecodable_pictures(
doc: &Document,
layout: &DocLayout,
pages: Option<RangeInclusive<usize>>,
opts: &DisplayOptions,
output: StrictOutput,
) -> (Vec<MissingPicture>, bool, u64) {
let mut out: Vec<MissingPicture> = Vec::new();
let mut truncated = false;
let mut total: u64 = 0;
// Hashes of the distinct ones past the cap, so the total counts each once.
let mut unlisted: HashSet<u64> = HashSet::new();
// What `out` holds, for an O(1) test of "already listed".
let mut listed: HashSet<MissingPicture> = HashSet::new();
let mut verdicts: HashMap<String, Option<DrawFailure>> = HashMap::new();
let range = pages.unwrap_or(0..=usize::MAX);
for (n, page) in layout.pages.iter().enumerate() {
if !range.contains(&n) {
continue;
}
let shown = ItemDisplay::new(doc, page);
let regions: [(&[Placed], Region); 3] =
[(&page.header, Region::Header(page.header_story)), (&page.footer, Region::Footer(page.footer_story)), (&page.items, Region::Body)];
for (items, region) in regions {
for it in items {
let mut media: Vec<String> = Vec::new();
let draws = shown.draws(it, opts);
collect_media(&draws, &mut media);
for key in media {
let verdict = *verdicts.entry(key.clone()).or_insert_with(|| match doc.media.get(&key) {
Some(bytes) => media_check(bytes, output).err(),
None => Some(DrawFailure::Undecodable),
});
let Some(failure) = verdict else { continue };
let reason = match failure {
DrawFailure::Undecodable => MissingPictureReason::Undecodable,
DrawFailure::UnsupportedFormat => MissingPictureReason::UnsupportedFormat,
};
let part = doc.missing_pictures.media_parts.get(&key).cloned().unwrap_or(key);
let item = MissingPicture { part, story: story_name(doc, region, it), reason };
if listed.contains(&item) {
continue;
}
if out.len() >= MAX_MISSING_PICTURES {
truncated = true;
if unlisted.insert(picture_hash(&item)) {
total = total.saturating_add(1);
}
continue;
}
total = total.saturating_add(1);
listed.insert(item.clone());
out.push(item);
}
}
}
}
// The page picture is drawn behind every page, outside the display list.
if let Some(pic) = &doc.settings.page_picture
&& layout.pages.iter().enumerate().any(|(n, _)| range.contains(&n))
{
// The page raster leaves out a background it can't read as a raster picture (a metafile),
// which the PDF draws as vector.
let failure = match doc.media.get(&pic.media) {
Some(bytes) => media_check(bytes, output).err().or_else(|| {
(output == StrictOutput::Png && wordcraft_geom::picture::background_size(bytes).is_none()).then_some(DrawFailure::UnsupportedFormat)
}),
None => Some(DrawFailure::Undecodable),
};
if let Some(failure) = failure {
let reason = match failure {
DrawFailure::Undecodable => MissingPictureReason::Undecodable,
DrawFailure::UnsupportedFormat => MissingPictureReason::UnsupportedFormat,
};
let part = doc.missing_pictures.media_parts.get(&pic.media).cloned().unwrap_or_else(|| pic.media.clone());
let item = MissingPicture { part, story: "background".into(), reason };
if !listed.contains(&item) {
if out.len() >= MAX_MISSING_PICTURES {
truncated = true;
if unlisted.insert(picture_hash(&item)) {
total = total.saturating_add(1);
}
} else {
total = total.saturating_add(1);
listed.insert(item.clone());
out.push(item);
}
}
}
}
(out, truncated, total)
}
#[derive(Clone, Copy)]
enum Region {
Header(Option<u32>),
Footer(Option<u32>),
Body,
}
fn collect_media(draws: &[Draw], out: &mut Vec<String>) {
for d in draws {
match d {
Draw::Image { media, .. } => out.push(media.clone()),
Draw::Figure { draws, .. } => collect_media(draws, out),
Draw::Turned { items, .. } | Draw::Rotated { items, .. } | Draw::Clip { items, .. } => collect_media(items, out),
_ => {}
}
}
}
/// The story a placed item is in, named as [`undecodable_pictures`] says.
fn story_name(doc: &Document, region: Region, it: &Placed) -> String {
let names = &doc.missing_pictures;
let known = |id: Option<u32>| id.and_then(|id| names.part_names.get(&id)).map(|n| n.to_string());
match region {
Region::Header(id) => known(id).unwrap_or_else(|| "header".into()),
Region::Footer(id) => known(id).unwrap_or_else(|| "footer".into()),
Region::Body => {
let story = match it {
Placed::Lines { story, .. } | Placed::Image { story, .. } | Placed::Graphic { story, .. } => *story,
_ => StoryRef::Body,
};
match story {
StoryRef::Body if names.main.is_empty() => "body".into(),
StoryRef::Body => names.main.clone(),
StoryRef::Part(id) => known(Some(id)).unwrap_or_else(|| {
use wordcraft_doc::PartKind::*;
match doc.parts.get(&id).map(|p| p.kind) {
Some(Header) => "header",
Some(Footer) => "footer",
Some(Footnote) => "footnote",
Some(Endnote) => "endnote",
Some(Comment) => "comment",
Some(TextBox) | None => "text box",
}
.into()
}),
}
}
}
}
+1
View File
@@ -13,6 +13,7 @@ mod float_top;
mod grid;
mod ligature;
pub(crate) mod linear;
mod missing_glyphs;
mod nbsp;
mod nested_cant_split;
mod numeral;
+244
View File
@@ -0,0 +1,244 @@
//! [`DocLayout::missing_glyphs`] (strict export, #6005): glyph id 0 in the display list, named by
//! its character, wherever the text is drawn.
use std::sync::Arc;
use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, TextAlign};
use wordcraft_doc::para::InlineObject;
use wordcraft_doc::props::Rgb;
use wordcraft_doc::{Block, Document, Path, Pos, StoryRef, Table};
use wordcraft_fonts::FontDb;
use super::{footnote, lay, page_float, text_box_at};
use crate::display::DisplayOptions;
use crate::{DocLayout, MAX_MISSING_GLYPHS, MissingGlyphs};
/// Code points in unassigned plane 4 that no face of the host covers, checked against the body
/// font and the whole fallback chain (so the test never guesses).
fn uncovered(n: usize) -> Vec<char> {
crate::test_fonts();
let db = FontDb::global();
let primary = wordcraft_fonts::word::resolve(&Document::default().settings.minor_font, false, false).face;
let out: Vec<char> = (0x40000u32..0x4FFF0)
.filter_map(char::from_u32)
.filter(|c| {
!primary.covers(*c) && db.fallback_styled(*c, primary.id(), false, false).is_none() && db.fallback_for(*c, primary.id()).is_none()
})
.take(n)
.collect();
assert_eq!(out.len(), n, "the host has faces covering all of plane 4");
out
}
fn missing(d: &Document, l: &DocLayout) -> MissingGlyphs {
l.missing_glyphs(d, &DisplayOptions::default(), None)
}
fn chars(m: &MissingGlyphs) -> Vec<char> {
m.chars.iter().map(|(c, _)| *c).collect()
}
fn hf_part(d: &mut Document, kind: wordcraft_doc::PartKind, text: &str) -> u32 {
d.add_part(kind, vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))])
}
fn chart_with(d: &mut Document, label: &str) {
let items = vec![GraphicItem::Text {
rect: [0.0, 100.0, 216.0, 20.0],
text: label.into(),
size: 10.0,
color: Rgb::BLACK,
bold: false,
align: TextAlign::Center,
font: None,
}];
let graphic = Graphic { kind: GraphicKind::Chart, items, ..Default::default() };
let obj = InlineObject::Graphic { w: 216.0, h: 144.0, alt: String::new(), float: Default::default(), graphic: Arc::new(graphic) };
d.insert_object(&Pos::body(0, 0), obj, &Default::default()).unwrap();
}
fn equation_with(d: &mut Document, linear: &str) {
let math = wordcraft_doc::math::Math { nodes: wordcraft_doc::math::parse_linear(linear), ..Default::default() };
let n = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().len();
d.para_mut(StoryRef::Body, &Path::top(0))
.unwrap()
.insert_object(n, InlineObject::Equation { linear: linear.into(), display: false, math }, &Default::default())
.unwrap();
}
#[test]
fn nothing_is_missing_in_plain_text() {
let d = Document::from_text("Hello, world.");
assert!(missing(&d, &lay(&d)).is_empty());
}
#[test]
fn a_character_no_face_covers_is_counted_once_per_occurrence_in_the_body() {
let c = uncovered(1)[0];
let d = Document::from_text(&format!("a{c}b{c}{c}c"));
let m = missing(&d, &lay(&d));
assert_eq!(m, MissingGlyphs::from_listed(vec![(c, 3)]));
}
#[test]
fn a_missing_glyph_is_found_in_a_header() {
let c = uncovered(1)[0];
let mut d = Document::from_text("Body");
d.last_section.headers.default = Some(hf_part(&mut d, wordcraft_doc::PartKind::Header, &format!("H{c}{c}")));
assert_eq!(missing(&d, &lay(&d)).chars, vec![(c, 2)]);
}
#[test]
fn a_missing_glyph_is_found_in_a_footer() {
let c = uncovered(1)[0];
let mut d = Document::from_text("Body");
d.last_section.footers.default = Some(hf_part(&mut d, wordcraft_doc::PartKind::Footer, &format!("F{c}")));
assert_eq!(missing(&d, &lay(&d)).chars, vec![(c, 1)]);
}
#[test]
fn a_missing_glyph_is_found_in_a_footnote() {
let c = uncovered(1)[0];
let mut d = Document::from_text("Body text.");
footnote(&mut d, &Pos::body(0, 4), &format!("Note {c}"));
assert_eq!(missing(&d, &lay(&d)).chars, vec![(c, 1)]);
}
#[test]
fn a_missing_glyph_is_found_in_a_text_box() {
let c = uncovered(1)[0];
let mut d = Document::from_text("Body text.");
text_box_at(&mut d, &Pos::body(0, 0), &format!("Box {c}{c}{c}"), 120.0, 40.0, page_float(300.0, 300.0));
assert_eq!(missing(&d, &lay(&d)).chars, vec![(c, 3)]);
}
#[test]
fn a_missing_glyph_is_found_in_a_table_cell() {
let c = uncovered(1)[0];
let mut d = Document::from_text("before");
let mut t = Table::new(1, 2, 300.0);
t.rows[0].cells[1].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&format!("cell {c}"), Default::default()))];
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap();
assert_eq!(missing(&d, &lay(&d)).chars, vec![(c, 1)]);
}
#[test]
fn a_missing_glyph_is_found_in_a_chart_label() {
let c = uncovered(1)[0];
let mut d = Document::from_text("Chart");
chart_with(&mut d, &format!("Q{c}Q{c}"));
assert_eq!(missing(&d, &lay(&d)).chars, vec![(c, 2)]);
}
#[test]
fn a_chart_label_run_names_each_glyphs_characters() {
use crate::display::Draw;
let mut d = Document::from_text("Chart");
chart_with(&mut d, "Q1 total");
let l = lay(&d);
fn runs(d: &Draw, out: &mut Vec<(String, usize, usize)>) {
match d {
Draw::Glyphs { text, glyphs, ranges, .. } if text == "Q1 total" || !ranges.is_empty() => {
out.push((text.clone(), glyphs.len(), ranges.len()));
assert!(ranges.iter().all(|r| text.get(r.clone()).is_some()), "ranges on character boundaries");
}
Draw::Figure { draws, .. } => draws.iter().for_each(|x| runs(x, out)),
_ => {}
}
}
let mut found = Vec::new();
for x in crate::display::page_display(&d, &l.pages[0], &DisplayOptions::default()) {
runs(&x, &mut found);
}
assert!(found.iter().any(|(t, g, r)| t == "Q1 total" && g == r), "{found:?}");
}
#[test]
fn a_missing_glyph_is_found_in_an_equation() {
let c = uncovered(1)[0];
let mut d = Document::from_text("Eq: ");
equation_with(&mut d, &format!("x+{c}"));
let m = missing(&d, &lay(&d));
assert_eq!(m.chars, vec![(c, 1)], "{m:?}");
}
#[test]
fn joiners_and_variation_selectors_are_never_counted() {
let d = Document::from_text("a\u{200D}b\u{200C}c\u{FE0F}d\u{FE0E}e\u{00AD}f\u{200E}g\u{2028}h\ti");
assert!(missing(&d, &lay(&d)).is_empty());
// A ZWJ emoji sequence: the joiners and selectors in it never appear, whatever the host's
// emoji face covers.
let d = Document::from_text("\u{1F469}\u{200D}\u{1F4BB} \u{2764}\u{FE0F} \u{1F44D}\u{1F3FD}");
let m = missing(&d, &lay(&d));
assert!(!chars(&m).iter().any(|c| matches!(c, '\u{200D}' | '\u{FE0F}' | '\u{FE0E}')), "{m:?}");
}
#[test]
fn a_selector_beside_a_missing_character_is_not_counted_with_it() {
let c = uncovered(1)[0];
let d = Document::from_text(&format!("{c}\u{FE0F}"));
assert_eq!(chars(&missing(&d, &lay(&d))), vec![c]);
}
#[test]
fn the_list_is_sorted_and_capped_at_256_distinct_code_points() {
let all = uncovered(MAX_MISSING_GLYPHS + 44);
let text: String = all.iter().rev().collect();
let d = Document::from_text(&text);
let m = missing(&d, &lay(&d));
assert!(m.truncated);
assert_eq!(m.chars.len(), MAX_MISSING_GLYPHS);
assert_eq!(m.total, MAX_MISSING_GLYPHS + 44, "the total counts the unlisted ones too");
assert!(m.chars.windows(2).all(|w| w[0].0 < w[1].0), "sorted by code point");
// Exactly the cap's worth is no truncation.
let d = Document::from_text(&all.iter().take(MAX_MISSING_GLYPHS).collect::<String>());
let m = missing(&d, &lay(&d));
assert!(!m.truncated);
assert_eq!(m.chars.len(), MAX_MISSING_GLYPHS);
assert_eq!(m.total, MAX_MISSING_GLYPHS);
}
#[test]
fn the_page_filter_selects_pages() {
let [x, y] = <[char; 2]>::try_from(uncovered(2)).unwrap();
let d = Document::from_text(&format!("first {x}\n{}last {y}", "filler line\n".repeat(150)));
let l = lay(&d);
let last = l.pages.len() - 1;
assert!(last >= 1, "the document runs over pages");
let on = |r| l.missing_glyphs(&d, &DisplayOptions::default(), r);
assert_eq!(on(None).chars, vec![(x.min(y), 1), (x.max(y), 1)]);
assert_eq!(on(Some(0..=0)).chars, vec![(x, 1)]);
assert_eq!(on(Some(last..=last)).chars, vec![(y, 1)]);
assert!(on(Some(last + 1..=last + 9)).is_empty());
#[allow(clippy::reversed_empty_ranges)]
let backwards = Some(1..=0);
assert!(on(backwards).is_empty());
}
#[test]
fn an_equation_run_names_each_glyphs_characters() {
use crate::display::Draw;
let mut d = Document::from_text("Eq: ");
equation_with(&mut d, "x+ab");
let l = lay(&d);
fn runs(d: &Draw, out: &mut Vec<(String, usize, usize)>) {
match d {
Draw::Glyphs { text, glyphs, ranges, .. } if !text.is_empty() => {
assert!(ranges.iter().all(|r| text.get(r.clone()).is_some()), "ranges on character boundaries");
out.push((text.clone(), glyphs.len(), ranges.len()));
}
Draw::Figure { draws, .. } | Draw::Clip { items: draws, .. } | Draw::Rotated { items: draws, .. } => {
draws.iter().for_each(|x| runs(x, out))
}
_ => {}
}
}
let mut found = Vec::new();
for x in crate::display::page_display(&d, &l.pages[0], &DisplayOptions::default()) {
runs(&x, &mut found);
}
// The equation's runs (the body text "Eq: " is a run too) each carry one range per glyph.
// The letters are the math italic ones where the font has them, else plain.
assert!(found.iter().any(|(t, g, _)| (t.contains('\u{1D44E}') || t == "ab") && *g == 2), "{found:?}");
assert!(found.iter().all(|(_, g, r)| g == r), "{found:?}");
}
+3 -4
View File
@@ -3,8 +3,8 @@ use super::metadata::metadata;
use super::options::{PdfError, PdfOptions};
use super::paint::{clamp_side, ok, rect_path};
use super::{
DocLayout, Document, FillRule, HashMap, HashSet, KrillaError, LayoutCache, LayoutOptions, Page, PageSettings, Rect, Size, Transform, WriteError,
Writer, imposition, krilla, layout, write,
DocLayout, Document, FillRule, HashMap, HashSet, KrillaError, LayoutCache, Page, PageSettings, Rect, Size, Transform, WriteError, Writer,
imposition, krilla, layout, write,
};
/// Lay out `doc` and write it as PDF. Without markup the layout is the final text: tracked
@@ -12,8 +12,7 @@ use super::{
/// markup area with the comment balloons.
pub fn export(doc: &Document, opts: &PdfOptions) -> Result<Vec<u8>, PdfError> {
write::guard(|| {
let lo = LayoutOptions { hide_deleted: !opts.include_markup, comment_balloons: opts.comments, numeral: opts.numeral, ..Default::default() };
let lay = layout(doc, &mut LayoutCache::new(), &lo);
let lay = layout(doc, &mut LayoutCache::new(), &opts.layout_options());
export_layout_inner(doc, &lay, opts)
})
}
+6 -16
View File
@@ -5,10 +5,10 @@ use super::paint::{append_path, fill, grown, norm, ok, paint_fill, pdf_line_cap,
use super::picture::{Vector, build_vector, load_image, paint_of, vector_picture};
use super::tags::{Role, TagEntry, figure_alt, graphic_alt, holds_picture, holds_text};
use super::{
Annotation, Arc, Artifact, ArtifactType, ClKind, ContentTag, DisplayOptions, DocLayout, DocPath, Document, Draw, FaceRef, Fill, FillRule, Font,
HashMap, HashSet, Identifier, Image, InlineObject, ItemDisplay, KrillaGlyph, LineCap, LineEnd, LineJoin, LineStyle, LinkAnnotation, Node,
NonZeroU16, Outline, OutlineNode, Page, PathBuilder, Placed, Point, Rect, Rgb, ShapeKind, Size, SpanTag, StoryRef, Stroke, StrokeDash, Surface,
Tag, TagGroup, TagKind, TagTree, Target, Transform, WriteError, XyzDestination, krilla, rgb, write,
Annotation, Arc, Artifact, ArtifactType, ClKind, ContentTag, DocLayout, DocPath, Document, Draw, FaceRef, Fill, FillRule, Font, HashMap, HashSet,
Identifier, Image, InlineObject, ItemDisplay, KrillaGlyph, LineCap, LineEnd, LineJoin, LineStyle, LinkAnnotation, Node, NonZeroU16, Outline,
OutlineNode, Page, PathBuilder, Placed, Point, Rect, Rgb, ShapeKind, Size, SpanTag, StoryRef, Stroke, StrokeDash, Surface, Tag, TagGroup,
TagKind, TagTree, Target, Transform, WriteError, XyzDestination, krilla, rgb, write,
};
use wordcraft_doc::picfx::PicFx;
@@ -240,14 +240,7 @@ impl Exporter<'_> {
let area = page.body;
self.tagged(s, Role::Artifact(ArtifactType::Watermark), None, |me, s| me.watermark(s, &wm, w, h, area));
}
let dopts = DisplayOptions {
marks: false,
dim_header: false,
dim_body: false,
markup: self.opts.include_markup,
placeholders: false,
..Default::default()
};
let dopts = self.opts.display_options();
// What the items share (the authors' colours, the clipped cells) is worked out once per page.
let shown = ItemDisplay::new(self.doc, page);
for it in page.header.iter().chain(page.footer.iter()) {
@@ -356,10 +349,7 @@ impl Exporter<'_> {
/// the baseline) on the box's bottom; the box centred on the margins (`area`, else the page)
/// and, when diagonal, turned 45° counterclockwise.
fn watermark(&mut self, s: &mut Surface, wm: &wordcraft_doc::Watermark, w: f32, h: f32, area: Rect) {
let r = wordcraft_fonts::word::resolve(&wm.font, false, false);
let face = r.face;
let text: String = wm.text.chars().take(200).collect();
let shaped = wordcraft_fonts::shape(&face, &text, &[], |c| c);
let Some((face, text, shaped)) = wordcraft_layout::watermark_run(wm, super::WATERMARK_MAX_CHARS) else { return };
let upem = face.upem.max(1.0) as f32;
let raw_w: f32 = shaped.iter().map(|g| g.x_advance as f32).sum::<f32>() / upem;
if raw_w <= 0.0 || !raw_w.is_finite() {
+1 -2
View File
@@ -183,8 +183,7 @@ impl Exporter<'_> {
/// Draws a metafile text run as PDF text (outlines when the font cannot be embedded). `tf` maps
/// the run's own space (see [`PlacedItem::Text`]) to the page.
pub(crate) fn vector_text(&mut self, s: &mut Surface, t: &wordcraft_metafile::Text, tf: kurbo::Affine) {
let face = wordcraft_fonts::word::resolve(&t.font, t.bold, t.italic).face;
let shaped = wordcraft_fonts::shape(&face, &t.text, &[], |c| c);
let (face, shaped) = wordcraft_layout::metafile_text_run(t);
let glyphs: Vec<_> = shaped.iter().map(|g| (g.cluster, f64::from(g.x_advance), f64::from(g.x_offset), f64::from(g.y_offset))).collect();
let Some(lay) = t.layout(face.upem, face.ascent, face.descent, &glyphs) else { return };
let font = self.font(&face);
+3 -3
View File
@@ -49,9 +49,9 @@ use wordcraft_doc::{Document, Path as DocPath, Rgb, StoryRef};
use wordcraft_fonts::FaceRef;
use wordcraft_geom::Rect;
use wordcraft_layout::display::Paint;
use wordcraft_layout::display::{DisplayOptions, Draw, ItemDisplay, Stroke as LineStyle};
use wordcraft_layout::display::{Draw, ItemDisplay, Stroke as LineStyle};
use wordcraft_layout::para::{ClKind, LineEnd};
use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, NumeralMode, Page, Placed, layout};
use wordcraft_layout::{DocLayout, LayoutCache, NumeralMode, Page, Placed, layout};
use wordcraft_metafile::{Picture, PlacedItem};
mod export;
@@ -72,7 +72,7 @@ use exporter::Exporter;
use glyphs::{baseline, cluster_ends, left_to_right, split_clusters, visual_order};
#[cfg(test)]
use metadata::{civil, parse_iso};
pub use options::{PdfError, PdfOptions};
pub use options::{PdfError, PdfOptions, WATERMARK_MAX_CHARS};
#[cfg(test)]
use paint::shape_path;
#[cfg(test)]
+29
View File
@@ -52,6 +52,35 @@ pub struct PdfOptions {
pub numeral: NumeralMode,
}
/// How many characters of the watermark text the PDF draws.
pub const WATERMARK_MAX_CHARS: usize = 200;
impl PdfOptions {
/// What a page shows when drawn into the PDF: no marks or dimming, tracked changes as markup
/// only with `include_markup`. The one place that says so; strict export (#6005) checks the
/// same display list.
pub fn display_options(&self) -> wordcraft_layout::display::DisplayOptions {
wordcraft_layout::display::DisplayOptions {
marks: false,
dim_header: false,
dim_body: false,
markup: self.include_markup,
placeholders: false,
..Default::default()
}
}
/// How [`crate::export`] lays a document out for these options.
pub fn layout_options(&self) -> wordcraft_layout::LayoutOptions {
wordcraft_layout::LayoutOptions {
hide_deleted: !self.include_markup,
comment_balloons: self.comments,
numeral: self.numeral,
..Default::default()
}
}
}
impl Default for PdfOptions {
/// Keep ordinary document export unchanged unless a print layout is requested.
fn default() -> Self {
+3 -24
View File
@@ -116,32 +116,11 @@ pub(crate) fn load_image(data: &Arc<Vec<u8>>, fx: PicFx) -> Option<Image> {
/// [`load_image`] with an explicit RGBA byte budget (a small one in tests).
pub(crate) fn load_image_within(data: &Arc<Vec<u8>>, fx: PicFx, budget: u64) -> Option<Image> {
use image::ImageDecoder;
let fmt = image::guess_format(data).ok()?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(MAX_IMAGE_SIDE);
limits.max_image_height = Some(MAX_IMAGE_SIDE);
let reader = || {
let mut r = image::ImageReader::new(std::io::Cursor::new(data.as_slice())).with_guessed_format().ok()?;
r.limits(limits.clone());
Some(r)
};
// Header-only size check before anything is allocated (the decoder's buffer at its own pixel
// size, 8 bytes for 16-bit RGBA, plus the RGBA conversion are charged); the side limits and
// the default 512 MB allocation cap apply to the header read and the decode alike.
let (hw, hh, native) = {
let d = reader()?.into_decoder().ok()?;
let (w, h) = d.dimensions();
(w, h, u64::from(d.color_type().bytes_per_pixel()))
};
if !wordcraft_geom::decode_peak_fits(hw, hh, native, budget) {
return None;
}
let dec = reader()?.decode().ok()?;
// The decision to draw or not is the shared one (`wordcraft_geom::picture`), the one strict
// export checks with.
let dec = wordcraft_geom::picture::decode_for_drawing(data, budget, MAX_IMAGE_SIDE).ok()?;
let (w, h) = (dec.width(), dec.height());
if w == 0 || h == 0 || w > MAX_IMAGE_SIDE || h > MAX_IMAGE_SIDE {
return None;
}
if !fx.changes_pixels() && fmt == image::ImageFormat::Jpeg && matches!(dec.color(), image::ColorType::L8 | image::ColorType::Rgb8) {
return Image::from_jpeg(data.clone().into(), true).ok();
}
+88
View File
@@ -6,6 +6,8 @@ use wordcraft_doc::props::NumRef;
use wordcraft_doc::{Block, CharProps, Document, ListKind, Paragraph, Table, Watermark, para_block};
use crate::*;
use wordcraft_layout::LayoutOptions;
use wordcraft_layout::display::DisplayOptions;
/// Verify preset lines and connectors emit the requested PDF line-cap operator.
#[test]
@@ -2912,3 +2914,89 @@ fn the_author_is_left_out_when_personal_information_is_removed() {
d.settings.remove_personal_info = true;
assert!(!raw(&d).contains("Ada Writer"), "the creator stays out of the PDF");
}
/// A page with text and three pictures that don't draw: garbage, a PNG cut off in its data and a
/// metafile that is only its header. The export is the same bytes as before strict export
/// existed (#6005): each is a grey box, and the rest of the page is untouched.
#[test]
fn undecodable_pictures_keep_their_grey_box_bytes() {
let mut whole = png();
let cut = whole.len() - 24;
whole.truncate(cut);
let mut d = Document::new();
d.core.created = "2024-01-02T03:04:05Z".into();
let mut p = Paragraph::new();
for (bytes, ext) in [(b"this is not a picture at all".to_vec(), "bin"), (whole, "png"), (vec![1, 0, 0, 0, 88, 0, 0, 0], "emf")] {
let media = d.add_media(bytes, ext);
let n = p.len();
let obj = InlineObject::Image {
media,
w: 60.0,
h: 40.0,
alt: String::new(),
float: Default::default(),
crop: [0.0; 4],
fx: Default::default(),
ole: None,
};
p.insert_object(n, obj, &CharProps::default()).unwrap();
}
d.body = vec![para_block(p)];
let opts = PdfOptions { compress: false, ..Default::default() };
let a = export(&d, &opts).unwrap();
assert_eq!(a, export(&d, &opts).unwrap(), "the export must be deterministic for the golden hash to mean anything");
let text = String::from_utf8_lossy(&a);
assert!(text.contains("0.8157 0.8157 0.8157") || text.contains(".81"), "the grey box is drawn: {}", text.len());
// FNV-1a of the file, recorded from the export before the shared decode check existed.
let h = a.iter().fold(0xcbf29ce484222325u64, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x100000001b3));
assert_eq!(h, 0x1edbbffab353d744, "{h:#x}");
}
/// A page with a chart label and an equation, exported without compression. Chart and equation
/// runs carry the text each glyph shows since strict export (#6005); this pins the PDF they
/// produce, ToUnicode maps included, to what they were before.
#[test]
fn chart_and_equation_text_keep_their_pdf_bytes() {
use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, TextAlign};
let mut d = Document::from_text("Body text");
d.core.created = "2024-01-02T03:04:05Z".into();
let items = vec![GraphicItem::Text {
rect: [0.0, 100.0, 216.0, 20.0],
text: "Revenue 2024".into(),
size: 10.0,
color: wordcraft_doc::props::Rgb::BLACK,
bold: false,
align: TextAlign::Center,
font: None,
}];
let graphic = Graphic { kind: GraphicKind::Chart, items, ..Default::default() };
let obj = InlineObject::Graphic { w: 216.0, h: 144.0, alt: String::new(), float: Default::default(), graphic: Arc::new(graphic) };
d.insert_object(&wordcraft_doc::Pos::body(0, 0), obj, &CharProps::default()).unwrap();
let linear = "x^2+1/2";
let math = wordcraft_doc::math::Math { nodes: wordcraft_doc::math::parse_linear(linear), ..Default::default() };
let n = d.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().len();
d.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(0))
.unwrap()
.insert_object(n, InlineObject::Equation { linear: linear.into(), display: false, math }, &CharProps::default())
.unwrap();
let opts = PdfOptions { compress: false, ..Default::default() };
let a = export(&d, &opts).unwrap();
assert_eq!(a, export(&d, &opts).unwrap(), "the export must be deterministic for the golden hash to mean anything");
// What the ToUnicode maps say each glyph shows, across all fonts: the text of the body, the
// chart label and the equation (a mathematical italic x is one character beyond the BMP).
// Recorded on the code before strict export: the maps are the same. (The whole file's hash
// is not pinned: it follows the fonts the host resolves, which differ between runs.)
let text = String::from_utf8_lossy(&a).into_owned();
let mut shown = std::collections::BTreeSet::new();
for block in text.split("beginbfchar").skip(1) {
for line in block.split("endbfchar").next().unwrap_or("").lines() {
let Some((_, to)) = line.trim().split_once("> <") else { continue };
let units: Vec<u16> =
to.trim_end_matches('>').as_bytes().chunks(4).filter_map(|c| u16::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok()).collect();
shown.insert(String::from_utf16_lossy(&units));
}
}
let expected: std::collections::BTreeSet<String> =
["B", "o", "d", "y", " ", "t", "e", "x", "R", "v", "n", "u", "2", "0", "4", "+", "1", "\u{1D465}"].into_iter().map(String::from).collect();
assert_eq!(shown, expected);
}
+1 -3
View File
@@ -461,9 +461,7 @@ pub fn render_text_line(face: &wordcraft_fonts::FontFace, text: &str, size: f32,
}
fn draw_watermark(ctx: &mut RenderContext, view: Affine, page: &Page, wm: &wordcraft_doc::Watermark, opts: &RenderOptions) {
let r = wordcraft_fonts::word::resolve(&wm.font, false, false);
let face = r.face;
let glyphs = wordcraft_fonts::shape(&face, &wm.text, &[], |c| c);
let Some((face, _, glyphs)) = wordcraft_layout::watermark_run(wm, usize::MAX) else { return };
let upem = face.upem.max(1.0);
let raw_w: f64 = glyphs.iter().map(|g| g.x_advance as f64).sum::<f64>() / upem;
if raw_w <= 0.0 {
+8 -31
View File
@@ -82,30 +82,14 @@ pub fn decode_pixmap_fx(bytes: &[u8], fx: wordcraft_doc::picfx::PicFx) -> Option
/// [`decode_pixmap_fx`] with an explicit RGBA byte budget (a small one in tests).
pub(crate) fn decode_pixmap_within(bytes: &[u8], fx: wordcraft_doc::picfx::PicFx, budget: u64) -> Option<Pixmap> {
use image::ImageDecoder;
let mut limits = image::Limits::default();
limits.max_image_width = Some(MAX_DECODE_SIDE);
limits.max_image_height = Some(MAX_DECODE_SIDE);
let reader = || {
let mut r = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
r.limits(limits.clone());
Some(r)
};
// The header gives the size and the decoder's pixel format (16-bit RGBA takes 8 bytes a pixel
// before its RGBA8 copy); refuse before the decoder (and the RGBA copy) allocate anything.
let (hw, hh, native) = {
let d = reader()?.into_decoder().ok()?;
let (w, h) = d.dimensions();
(w, h, u64::from(d.color_type().bytes_per_pixel()))
};
if !wordcraft_geom::decode_peak_fits(hw, hh, native, budget) {
// The decision to draw or not is the shared one (`wordcraft_geom::picture`), the one strict
// export checks with.
let decoded = wordcraft_geom::picture::decode_for_drawing(bytes, budget, MAX_DECODE_SIDE);
if let Err(wordcraft_geom::picture::Refusal::TooLarge { width, height }) = &decoded {
// Logged once a picture: its refusal is cached with it (`pixmap_for`).
log::warn!(
"a {hw}×{hh} picture of {native} bytes a pixel needs more than {budget} bytes to decode: it is not drawn (in the text, a grey box takes its place)"
);
return None;
log::warn!("a {width}×{height} picture needs more than {budget} bytes to decode: it is not drawn (in the text, a grey box takes its place)");
}
let mut img = reader()?.decode().ok()?.into_rgba8();
let mut img = decoded.ok()?.into_rgba8();
let (w, h) = img.dimensions();
fx.apply_rgba(&mut img);
premul_pixmap(w, h, img.as_raw())
@@ -270,8 +254,7 @@ pub(crate) fn draw_vector(ctx: &mut RenderContext, v: &Vector, r: &kurbo::Rect,
/// Draws a metafile text run: `tf` maps the run's own space (see [`PlacedItem::Text`]) to the device.
fn draw_vector_text(ctx: &mut RenderContext, t: &wordcraft_metafile::Text, tf: Affine) {
let face = wordcraft_fonts::word::resolve(&t.font, t.bold, t.italic).face;
let shaped = wordcraft_fonts::shape(&face, &t.text, &[], |c| c);
let (face, shaped) = wordcraft_layout::metafile_text_run(t);
let glyphs: Vec<_> = shaped.iter().map(|g| (g.cluster, f64::from(g.x_advance), f64::from(g.x_offset), f64::from(g.y_offset))).collect();
let Some(lay) = t.layout(face.upem, face.ascent, face.descent, &glyphs) else { return };
let k = lay.em / face.upem.max(1.0);
@@ -326,18 +309,12 @@ fn device_pixel(view: Affine) -> f64 {
if scale.is_finite() && scale > 1e-9 { 1.0 / scale } else { 1.0 }
}
/// Largest page-background picture decoded, in pixels (hostile files).
const MAX_BACKGROUND_PIXELS: u64 = 64 * 1024 * 1024;
/// Draw the page's picture or texture fill (Design › Page Color › Fill Effects) behind the content:
/// tiled at its natural size (96 dpi) for a `tile` fill, stretched over the page otherwise.
pub(crate) fn draw_page_picture(ctx: &mut RenderContext, doc: &Document, page: &Page, view: Affine) {
let Some(pic) = &doc.settings.page_picture else { return };
let Some(bytes) = doc.media.get(&pic.media) else { return };
let (nw, nh) = match image_size(bytes) {
Some((w, h)) if w > 0 && h > 0 && (w as u64) * (h as u64) <= MAX_BACKGROUND_PIXELS => (w as f64, h as f64),
_ => return,
};
let Some((nw, nh)) = wordcraft_geom::picture::background_size(bytes).map(|(w, h)| (f64::from(w), f64::from(h))) else { return };
let Some(pm) = pixmap_for(doc, &pic.media, wordcraft_doc::picfx::PicFx::default()) else { return };
let (pw, ph) = (pm.width().max(1) as f64, pm.height().max(1) as f64);
let (w, h) = (page.w as f64, (page.h as f64).min(1e6));
+38
View File
@@ -1487,3 +1487,41 @@ fn paths_far_past_every_canvas_are_not_flattened() {
assert!(!path_extent_ok(Affine::scale(1e12), &line(10.0)));
assert!(!path_extent_ok(Affine::IDENTITY, &line(f64::NAN)));
}
/// The same three pictures that don't draw as in the PDF test: each stays a grey box, and the page's
/// pixels are what they were before strict export existed (#6005).
#[test]
fn undecodable_pictures_keep_their_grey_box_pixels() {
let mut whole = Vec::new();
image::RgbaImage::from_fn(16, 8, |x, y| image::Rgba([(x * 15) as u8, (y * 30) as u8, 200, 255]))
.write_to(&mut std::io::Cursor::new(&mut whole), image::ImageFormat::Png)
.unwrap();
let cut = whole.len() - 24;
whole.truncate(cut);
let mut d = Document::new();
let mut p = wordcraft_doc::Paragraph::new();
for (bytes, ext) in [(b"this is not a picture at all".to_vec(), "bin"), (whole, "png"), (vec![1, 0, 0, 0, 88, 0, 0, 0], "emf")] {
let media = d.add_media(bytes, ext);
let n = p.len();
let obj = wordcraft_doc::para::InlineObject::Image {
media,
w: 60.0,
h: 40.0,
alt: String::new(),
float: Default::default(),
crop: [0.0; 4],
fx: Default::default(),
ole: None,
};
p.insert_object(n, obj, &Default::default()).unwrap();
}
d.body = vec![wordcraft_doc::para_block(p)];
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
let opts = RenderOptions::default();
let img = render_page(&d, &l.pages[0], 1.0, &opts);
let boxes = page_display(&d, &l.pages[0], &opts.display).iter().filter(|i| matches!(i, Draw::Image { .. })).count();
assert_eq!(boxes, 3);
// FNV-1a of the pixels, recorded before the shared decode check existed.
let h = img.pixels.iter().fold(0xcbf29ce484222325u64, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x100000001b3));
assert_eq!(h, 0x79f9f1d3602a9925, "{h:#x}");
}
+1
View File
@@ -92,6 +92,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
if drag.drag_started() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
crate::chrome::note_title_drag(ui.ctx(), band);
// The chevron's stroke starts at the content edge.
let c = if rtl {
pos2(band.max.x - 26.0, band.center().y)
+22
View File
@@ -281,6 +281,24 @@ fn autosave_knob_x(rtl: bool, on: bool, r: Rect) -> f32 {
crate::rtl::lead_x(rtl != on, r, 8.0)
}
/// The `ctx.data` key for the part of the title bar that drags this document window.
fn title_drag_id(ctx: &egui::Context) -> egui::Id {
egui::Id::unique(("title_drag_rect", ctx.viewport_id()))
}
/// Notes `rect` as the part of the title bar that drags the window in this frame: behind a modal
/// dialog its backdrop has the pointer there, and moves the window instead ([`title_drag_rect`]).
pub(crate) fn note_title_drag(ctx: &egui::Context, rect: Rect) {
let (key, pass) = (title_drag_id(ctx), ctx.cumulative_pass_nr());
ctx.data_mut(|d| d.insert_temp(key, (rect, pass)));
}
/// The part of the title bar that drags the window, if it was drawn in this frame.
pub(crate) fn title_drag_rect(ctx: &egui::Context) -> Option<Rect> {
let (key, pass) = (title_drag_id(ctx), ctx.cumulative_pass_nr());
ctx.data(|d| d.get_temp::<(Rect, u64)>(key)).filter(|(_, p)| *p == pass).map(|(r, _)| r)
}
/// Draws the title bar with window dragging, AutoSave, quick access, account, community and search
/// controls, and the centred clickable document title.
pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
@@ -301,6 +319,10 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
if drag.drag_started() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
// Elsewhere the system's own title bar above this one moves the window.
if mac {
note_title_drag(ui.ctx(), full);
}
if drag.double_clicked() {
let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false));
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max));
+1
View File
@@ -455,6 +455,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
let resp = egui::Modal::new(Id::unique("wordcraft.colorsDialog")).show(ctx, |ui| body(ui, &mut d));
// egui's Modal blocks the window behind it, but not the document's keyboard.
crate::dialogs::release_keyboard(app, ctx);
crate::dialogs::drag_by_title_bar(ctx, &resp.backdrop_response, resp.response.rect);
let mut done = resp.inner;
if done.is_none() && resp.should_close() {
done = Some(false);
+3 -1
View File
@@ -34,7 +34,9 @@ use bookmarks::bookmark_at_caret;
#[cfg(test)]
use bookmarks::bookmark_names;
pub use chrome::show;
pub(crate) use chrome::{block_behind, buttons, cancel_key_pressed, color_menu, forget_nudge, ok_cancel_responses, release_keyboard};
pub(crate) use chrome::{
block_behind, buttons, cancel_key_pressed, color_menu, drag_by_title_bar, forget_nudge, ok_cancel_responses, release_keyboard,
};
pub(crate) use dispatch::search_results;
pub(crate) use find::swallow_shortcut_text;
pub use find::{FindExtra, FindTab, GoToWhat};
+11
View File
@@ -67,6 +67,16 @@ fn count_dialog_frame(ctx: &egui::Context, id: egui::Id) {
}
}
/// Behind a modal window, whose `backdrop` has the pointer, the app still moves by its title bar,
/// as in Word for Mac (#5124); not from just inside the window's edge, where it reaches over the
/// title bar.
pub(crate) fn drag_by_title_bar(ctx: &egui::Context, backdrop: &egui::Response, window: egui::Rect) {
let origin = ctx.input(|i| i.pointer.press_origin()).filter(|p| !window.contains(*p));
if backdrop.drag_started() && origin.is_some_and(|p| crate::chrome::title_drag_rect(ctx).is_some_and(|r| r.contains(p))) {
ctx.send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
}
/// Makes `window` modal (#3859): the document gives up the keyboard now, and from the next frame
/// nothing beneath the window takes the pointer or Tab. A press beside it nudges it ([`nudge`]).
pub(crate) fn block_behind(app: &WordApp, ctx: &egui::Context, window: &egui::Response) {
@@ -81,6 +91,7 @@ pub(crate) fn block_behind(app: &WordApp, ctx: &egui::Context, window: &egui::Re
.inner;
// The backdrop also has the pointer just inside the window's edge (egui's hit radius).
let beside = ctx.input(|i| i.pointer.any_pressed() && i.pointer.press_origin().is_some_and(|p| !window.rect.contains(p)));
drag_by_title_bar(ctx, &backdrop, window.rect);
ctx.move_to_top(layer);
ctx.memory_mut(|m| m.set_modal_layer(layer));
ctx.accesskit_node_builder(window.id, |node| node.set_modal());
+110
View File
@@ -2078,6 +2078,116 @@ fn the_page_waits_for_the_keyboard_until_a_modal_dialog_closes() {
assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "!", "the page has it after the dialog");
}
/// Drags from `at` to 30 points right in `h`, and counts the window drags the app asked for.
fn window_drags(h: &mut egui_kittest::Harness<WordApp>, at: egui::Pos2) -> usize {
let mut drags = 0;
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
let events = [egui::Event::PointerMoved(at), button(at, true)]
.into_iter()
.chain([10.0, 20.0, 30.0].map(|dx| egui::Event::PointerMoved(at + vec2(dx, 0.0))))
.chain([button(at + vec2(30.0, 0.0), false)]);
for e in events {
h.event(e);
h.step();
drags += h.output().viewport_output.values().flat_map(|v| &v.commands).filter(|c| matches!(c, egui::ViewportCommand::StartDrag)).count();
}
drags
}
/// #5124: behind a modal dialog the window still moves by its title bar, as in Word for Mac, where
/// WordCraft draws it, or by the top of File's side menu, which covers it there; elsewhere the
/// system's title bar does, and WordCraft's bar under it doesn't.
#[test]
fn the_window_moves_by_its_title_bar_behind_a_modal_dialog() {
for (mac, backstage) in [(true, false), (true, true), (false, false), (false, true)] {
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default());
a.integrated_titlebar = mac;
a.ui.backstage = backstage;
let mut h = app_harness(a);
h.run_steps(6);
let font = Dialog::open("font", h.state_mut());
h.state_mut().dialog = font;
h.run_steps(4);
let at = egui::pos2(if backstage { 100.0 } else { 300.0 }, 12.0);
assert_eq!(window_drags(&mut h, at), usize::from(mac), "macOS: {mac}, File: {backstage}");
assert!(matches!(h.state().dialog, Some(Dialog::Font { .. })), "Font stays");
assert_eq!(h.state().ui.backstage, backstage, "nothing behind it opened or closed");
}
}
/// Opens a modal state in `a`: the built-in file browser, a dialog by name, or Colors.
fn open_modal_state(a: &mut WordApp, state: &str) {
match state {
"none" => {}
"fileBrowser" => {
let document = a.session.document_id();
a.show_file_browser(
crate::file_dialogs::FileDialogRequest::Open { purpose: "document".into() },
crate::file_dialogs::AfterPick::OpenDocument { document },
false,
);
}
"colors" => a.colors = Some(crate::colors::ColorsDialog::new(None, crate::colors::Target::cmd("format.color"))),
dialog => a.dialog = Dialog::open(dialog, a),
}
}
/// The file browser, a modal dialog and Colors each block the window; nothing open or a modeless
/// dialog doesn't.
#[test]
fn modal_open_is_true_for_the_file_browser_a_modal_dialog_and_colors() {
for (state, modal) in [("none", false), ("find", false), ("font", true), ("fileBrowser", true), ("colors", true)] {
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default());
open_modal_state(&mut a, state);
assert_eq!(a.modal_open(), modal, "{state}");
}
}
/// #5124: whatever is open, a drag on the title bar macOS draws moves the window; where the system
/// draws it, WordCraft's bar under it only does while nothing blocks it.
#[test]
fn the_title_bar_moves_the_window_in_every_modal_state() {
for (state, modal) in [("none", false), ("find", false), ("font", true), ("fileBrowser", true), ("colors", true)] {
for mac in [true, false] {
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default());
a.integrated_titlebar = mac;
let mut h = app_harness(a);
h.run_steps(6);
open_modal_state(h.state_mut(), state);
h.run_steps(4);
assert_eq!(h.state().modal_open(), modal, "{state}");
assert_eq!(window_drags(&mut h, egui::pos2(300.0, 12.0)), usize::from(mac || !modal), "{state}, macOS: {mac}");
assert_eq!(h.state().modal_open(), modal, "{state} stays open");
}
}
}
/// #5124: where a dialog reaches over the title bar (a short window), a drag on the dialog is the
/// dialog's own and doesn't move the window.
#[test]
fn a_drag_on_a_dialog_over_the_title_bar_doesnt_move_the_window() {
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default());
a.integrated_titlebar = true;
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 420.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
h.run_steps(6);
let font = Dialog::open("font", h.state_mut());
h.state_mut().dialog = font;
h.run_steps(4);
let rect = h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", "Font")))).expect("the dialog is open");
let at = egui::pos2(rect.center().x, rect.top() + 8.0);
assert!(at.y < 30.0, "the dialog reaches over the title bar: {rect:?}");
assert_eq!(window_drags(&mut h, at), 0, "a drag on its title");
let rect = h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", "Font")))).expect("the dialog is open");
assert_eq!(window_drags(&mut h, egui::pos2(rect.center().x, rect.top() + 1.0)), 0, "nor just inside its edge");
}
#[test]
fn font_sample_props_map_each_field_to_its_property() {
use super::font::font_sample_props;
File diff suppressed because one or more lines are too long
+2 -1
View File
File diff suppressed because one or more lines are too long
+5 -2
View File
@@ -1,6 +1,6 @@
# How WordCraft interprets OpenDocument files
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-04 · **Change:** major (no more settings: one choice for each case the standard leaves open); previously trivial (what is written as read: unchanged paragraphs with drawings, comments, changes or marks too); previously minor (the two presets are Microsoft Word style and LibreOffice style; CLI values listed one by one); previously minor (deviations from the standard listed; ODF 1.2 → 1.3 checked); previously major (new: the ODF interpretation profile and the list of every case)
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-05 · **Change:** trivial (a document's language: kept on any save, and stated when written from the model); previously major (no more settings: one choice for each case the standard leaves open); previously trivial (what is written as read: unchanged paragraphs with drawings, comments, changes or marks too); previously minor (the two presets are Microsoft Word style and LibreOffice style; CLI values listed one by one); previously minor (deviations from the standard listed; ODF 1.2 → 1.3 checked); previously major (new: the ODF interpretation profile and the list of every case)
OpenDocument (ODF 1.3, OASIS) leaves some of a document's look to the application. Part 3 §16.2
says that when no style sets a property and the standard gives no default, "an
@@ -68,6 +68,7 @@ These are documented so they aren't mistaken for law:
| `style:wrap` unstated on a frame | §20.400: no default | text wraps on both sides (LibreOffice too) |
| A drawing shape's `draw:fill` / `draw:stroke` unstated | §20.118, §20.167: no default | solid (LibreOffice too); a text frame's are none; the colours are listed above |
| A document's default font and size when no default style gives them | not defined | WordCraft's defaults |
| A document's language when neither its default style nor Standard states one | `fo:language`: no default | none: WordCraft proofs it in the editing language and writes none, and each reader uses its own default |
## How WordCraft writes ODF
@@ -106,7 +107,8 @@ states what the standard leaves open instead of relying on any application's def
- drawings not held in the text's area (`style:flow-with-text="false"`, §20.267 gives no default);
- tab stops measured as the file it was opened from measured them (`TabsRelativeToIndent` in
`settings.xml`), from the margin for WordCraft's own documents;
- widow and orphan control, on or off, in the default paragraph style.
- widow and orphan control, on or off, in the default paragraph style;
- the document's language, in the default paragraph style, when it has one.
## Deviations from the standard, out of necessity
@@ -162,6 +164,7 @@ from public bug reports and ONLYOFFICE's converter test snapshots):
| Date | Change |
|---|---|
| 2026-11-05 | A document's language is read from its default style or Standard and written back from the model. It was lost when a style or list definition changed (numbering, a new style), and readers then showed the document in their own default language. Styles written from the model no longer say English for a document that isn't. A language's script (`fo:script`) is kept with it, and a style with no parent takes the default style's language when Standard's differs. |
| 2026-11-04 | The settings removed: File › Options › Advanced › OpenDocument files, `file.options` `odf`, `WORDCRAFT_ODF`, the LibreOffice style and custom values. WordCraft makes one choice for each case the standard leaves open, the one the default made, so nothing changes for a document opened with the default. What a file states is still followed, its `TabsRelativeToIndent` and `AddParaTableSpacing` among it. LibreOffice's choices stay listed beside WordCraft's. |
| 2026-11-04 | How WordCraft writes ODF: unchanged paragraphs and tables with drawings, comments, tracked changes, notes or marks are written as read too (the text said from the model); what is edited, and the cases left in the element audit, from the model. |
| 2026-11-04 | The two presets are the two styles a document can be shown in: Microsoft Word style (`word`, the default; `wordcraft`, its earlier id, still taken) and LibreOffice style (`libreoffice`). The CLI's values listed one by one. |
+3 -2
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -55,7 +55,7 @@ esac
# Only binaries for this machine run here (the --version check at the end); a FreeBSD target built
# on another OS never does.
HOST_ARCH="$(release_arch "$(uname -m)" 2>/dev/null)" || HOST_ARCH="$(uname -m)"
if [ -n "${CARGO_BUILD_TARGET:-}" ] && [ "$(uname -s)" != FreeBSD ]; then
if [[ -n "${CARGO_BUILD_TARGET:-}" ]] && [[ "$(uname -s)" != FreeBSD ]]; then
HOST_ARCH="$(uname -s) $HOST_ARCH"
fi
BASENAME="wordcraft-$VERSION-freebsd-$ARCH"
@@ -96,12 +96,12 @@ mkdir -p "$DIST"
tar -C "$WORK" -czf "$DIST/$BASENAME.tar.gz" "$BASENAME"
echo "wrote $DIST/$BASENAME.tar.gz"
if [ -z "${CARGO_BUILD_TARGET:-}" ] && [ "$ARCH" = "$HOST_ARCH" ]; then
if [[ -z "${CARGO_BUILD_TARGET:-}" ]] && [[ "$ARCH" = "$HOST_ARCH" ]]; then
"$STAGE/bin/wordcraft-cli" --version
else
# They cannot run here, so check what they are instead: executables, not libraries or objects.
# An older file(1) calls a PIE a shared object; its interpreter still marks it as a program.
want="$ARCH"; [ "$ARCH" != x86_64 ] || want=x86-64
want="$ARCH"; [[ "$ARCH" != x86_64 ]] || want=x86-64
for b in "$STAGE"/bin/*; do
case "$(file -b "$b")" in
ELF*" executable, "*"$want"*FreeBSD* | ELF*"$want"*FreeBSD*interpreter*) ;;
+7
View File
@@ -1,2 +1,9 @@
sonar.projectKey=storytold_wordcraft
sonar.organization=storytold
# S4790 (weak hash): SHA-1 is a start-key and "SHA1/1K" checksum algorithm that ODF package
# encryption (ODF 1.3 Part 2 §4.16) names, and older office suites wrote it. It is needed to open
# those files, not chosen for new security: WordCraft writes a SHA-256 start key and checksum.
sonar.issue.ignore.multicriteria=odt_sha1
sonar.issue.ignore.multicriteria.odt_sha1.ruleKey=rust:S4790
sonar.issue.ignore.multicriteria.odt_sha1.resourceKey=crates/formats/src/odt/crypt.rs