Compare commits

..
Author SHA1 Message Date
Martin BallandClaude Opus 5.5 2c99020d1b #6005: README lists footnote, endnote and comment as source_part values
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8G9hbs6XYZFsgjbL
2026-11-09 00:05:10 +00:00
Martin BallandClaude Opus 5.5 7be581d90b #6005: count a chunk's unlisted pictures, document the real schema, show totals in human lines
A nested altChunk DOCX adds its records past its 256-entry list to the outer
total. file.strictReport's params doc lists source_part, the snake_case codes
and the totals. The human line says "300 characters cannot be drawn (first 256
listed)" when a list is cut. README gains the picture stderr line and the
possible source_part values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8G9hbs6XYZFsgjbL
2026-11-08 23:22:44 +00:00
Martin BallandClaude Opus 5.5 530e7b63bb #6005: credit benandez by their GitHub profile name
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8G9hbs6XYZFsgjbL
2026-11-08 22:55:06 +00:00
Martin BallandClaude Opus 5.5 94a6466bf0 #6005: machine reason codes, source_part and totals in the strict report
JSON reasons are snake_case codes (missing_part, external_target, link_only,
undecodable, unsupported_format); "in" becomes "source_part" (full story part);
total_missing_glyphs and total_missing_pictures count past the 256 caps (code
point bitset, saturating picture counter). Human lines unchanged. README
documents the schema. Layout missing_glyphs tests call test_fonts().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8G9hbs6XYZFsgjbL
2026-11-08 22:54:37 +00:00
Martin Ball 44e188d8c0 Merge remote-tracking branch 'origin/main' into 6005-c
; Conflicts:
;	ROADMAP.md
2026-11-08 21:44:40 +00:00
FredApps 4b0d399599 Merge pull request #3195 from vangel76/zoom-stretch
Pointer zoom stretches page rasters until it stops
2026-11-08 18:14:29 +01:00
FredApps fda8758ec9 Merge upstream text hinting and window scrolling 2026-11-08 17:59:05 +01:00
FredApps c5fe6c6816 Merge pull request #3197 from vangel76/ui-text-hinting
Dark-theme interface text on Linux no longer looks semi-bold
2026-11-08 17:55:10 +01:00
FredApps 438f82977a Merge pull request #2723 from vangel76/feat/recent-remove-2716
Remove documents from the recent list, one by one or all at once
2026-11-08 17:53:13 +01:00
vangel76andClaude Opus 5.5 72a7afd9b5 Merge upstream/main into PR #3195
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:37:24 +01:00
FredApps 36ed94a09e Merge upstream control delivery changes 2026-11-08 17:35:46 +01:00
FredApps 0655e651fb Merge upstream control delivery changes 2026-11-08 17:35:06 +01:00
FredApps 0e3f4dd315 Merge pull request #2596 from vangel76/control-mcp-delivery-1326-1331
Control/MCP: no replay of mutations with unknown outcome; queued requests honour timeouts (#1326, #1331)
2026-11-08 17:32:33 +01:00
FredApps e01a88b0be Fix paper color tests for hinted page keys 2026-11-08 17:29:14 +01:00
FredApps ae257ba784 Merge upstream font tests and window scrolling 2026-11-08 17:26:55 +01:00
FredApps 0495fbb789 Merge pull request #2783 from vangel76/test/fixed-layout-fonts-2729
#2729: Layout tests use pinned bundled fonts
2026-11-08 17:24:43 +01:00
vangel76andClaude Opus 5.5 e7e9bc2759 Merge upstream/main into PR #3195
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:24:03 +01:00
FredApps be75ee90e3 Merge upstream main and reconcile control documentation 2026-11-08 17:22:59 +01:00
Martin BallandClaude Opus 5.5 a0baff5daa #6005: glossary limits strict export to what the output shows; escape run's save errors
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8G9hbs6XYZFsgjbL
2026-11-08 16:17:53 +00:00
FredApps 480511eaa5 Merge upstream main into control delivery changes 2026-11-08 17:15:38 +01:00
FredApps aa262cf7d8 Merge pull request #2595 from vangel76/mcp-insert-table-2242
MCP: insert_table tool for filled tables (#2242)
2026-11-08 17:11:08 +01:00
Martin BallandClaude Sonnet 5.5 28c5cbf139 #6005: escape file-derived errors where they become a Failure, not where they print
main() prints Failure::Error raw again, so the CLI's own multi-line messages (usage
after "missing argument" / "unknown command") keep their line breaks. A new
engine_error helper escapes control, bidi and invisible characters in text that
comes from the engine or a file, and every such conversion goes through it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8G9hbs6XYZFsgjbL
2026-11-08 16:08:46 +00:00
FredApps 2d97c8443e Merge pull request #1056 from vangel76/feat/1053-rtf-notes-comments
RTF: footnotes, endnotes and comments are read and written (#1053)
2026-11-08 16:38:59 +01:00
FredApps d5d2b22dac Merge pull request #331 from vangel76/feat/322-view-windows
View › Window: Switch Windows, Arrange All, View Side by Side and Synchronous Scrolling
2026-11-08 16:37:54 +01:00
FredApps 2794ae44e9 Merge pull request #5115 from vangel76/feat/paper-colors-3398
Add persistent screen-only paper color presets (#3398)
2026-11-08 16:36:10 +01:00
Martin BallandClaude Sonnet 5.5 00bbb147a1 #6005: escape every CLI error message, widen invisible-character escaping, cap picture names
main() prints Failure::Error through escape_text (control, bidi and invisible
characters; backslashes kept, since errors quote paths), so engine errors that
carry file text (run --cmd with strict:true) can't forge or spoof a line.
escape_peer and the JSON report line also escape soft hyphen, zero-width, word
joiner, BOM and tag characters (surrogate pairs above U+FFFF). A picture's part and
story are capped at 1024 characters in MissingPictures::push.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8G9hbs6XYZFsgjbL
2026-11-08 15:25:10 +00:00
vangel76andClaude Opus 5.5 5125de6f1f Merge upstream/main into PR #1056
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 16:18:07 +01:00
vangel76andClaude Opus 5.5 59030b1bb5 Merge upstream/main into PR #331
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 16:18:07 +01:00
vangel76andClaude Opus 5.5 4050f1bf8b Merge upstream/main into PR #5115
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 16:18:07 +01:00
02e74a5d9c Add Delete Previous Sentence with Ctrl+Shift+Backspace (#5094)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-08 16:13:33 +01:00
FredApps d5a1c0e181 Merge pull request #5116 from vangel76/feat/paragraph-tools-5043
Home › Paragraph Tools: nine paragraph conversion and cleanup commands (#5043)
2026-11-08 16:12:57 +01:00
Valentin KleinandClaude Opus 5.5 69d2a2e131 docs/gaps: gap 16 RTL in HTML/ODT/RTF landed (#2696)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 15:57:36 +01:00
Martin BallandClaude Sonnet 5.5 2600e22c8c #6005: keep pictures dropped inside altChunk DOCX, escape report lines
A DOCX altChunk's own dropped pictures are merged into the parent through
MissingPictures::push, named "<chunk part> > <story>". The strict report's human
lines go through escape_peer, and the JSON line escapes C1, line separator and bidi
characters (serde leaves them raw), so a crafted picture target can't forge or
spoof a terminal line.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8G9hbs6XYZFsgjbL
2026-11-08 14:56:09 +00:00
Martin BallandClaude Sonnet 5.5 3f38d0b8aa #6008: wordcraft-cli render --strict
render --strict passes strict to file.exportPng and, on refusal, prints the
report for the same page (human lines, then the JSON line) on stderr, exits 3,
and prints nothing on stdout. convert and render share one refusal helper.
Adds the engine guard that file.exportPng with strict off is byte-identical.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8G9hbs6XYZFsgjbL
2026-11-08 14:26:25 +00:00
Martin BallandClaude Sonnet 5.5 23f658e353 #6005: strict export checks the page background and watermark, per-output picture limit
Page background picture and watermark text are checked like the display list
(watermark shaped by one shared helper used by the PDF export, the page raster and
the check). Picture side limit is per output (PDF 30000, PNG 65535). Separate
truncation flags per list. Glossary pictures name the glossary part.
MissingPictures::push dedupes in O(1). Hostile params cover strict, README note,
ToUnicode regression test for chart and equation runs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8G9hbs6XYZFsgjbL
2026-11-08 14:01:38 +00:00
Martin BallandClaude Sonnet 5.5 e3baa26815 #6005: strict export for PDF and PNG, wordcraft-cli convert --strict
Engine: StrictReport (missing glyphs + missing pictures, schema-1 JSON), pure
file.strictReport, and strict: true on file.save/saveAs/exportPdf/exportPng that
refuses with "strict: ..." before any write. PDF strict lays out once through
wordcraft_pdf::export_layout; PdfOptions now owns the display/layout options the
exporter and the check share. CLI: --strict, exit 3, report plus JSON line on
stderr; a non-pdf/png output is rejected before the input is read.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8G9hbs6XYZFsgjbL
2026-11-08 13:24:00 +00:00
Martin Ball f063e66f9c #6005: merge part B (missing pictures) 2026-11-08 12:59:38 +00:00
Martin Ball dec3513fee #6005: merge part A (missing glyphs) 2026-11-08 12:59:37 +00:00
Martin BallandClaude Sonnet 5.5 34f6a1ff46 #6005: record dropped pictures at open and check drawability for strict export
doc: Document.missing_pictures (MissingPicture, MissingPictureReason), capped at 256,
never saved, ignored by ==.
docx reader: records a missing part, an external target, and r:link / VML r:href
only pictures with their story part; nothing is fetched.
geom: decode_for_drawing / check_for_drawing, the one decision pdf and render now
take (output unchanged, pinned by golden hash tests); layout: media_check and
undecodable_pictures walk a layout's drawn pictures per story.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8G9hbs6XYZFsgjbL
2026-11-08 12:47:32 +00:00
Martin BallandClaude Sonnet 5.5 fa3b297951 #6005: DocLayout::missing_glyphs, ranges on chart and equation runs
Walks the display list (as the PDF and PNG exports draw it) for glyph id 0
and names each by code point, capped at 256 distinct. Chart/SmartArt label
runs and equation runs now carry per-glyph text ranges.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8G9hbs6XYZFsgjbL
2026-11-08 12:34:07 +00:00
Martin BallandClaude Opus 5.5 ec6ad47f69 #6005: glossary terms for strict export
Strict export, Missing glyph and Missing picture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8G9hbs6XYZFsgjbL
2026-11-08 12:27:38 +00:00
Dylan M. TaylorandMartin Ball 661a790540 Render: skip drawing paths far past every canvas before flattening (#5453)
* fix(render): skip drawing paths far past every canvas before flattening

* fix(render): refuse paths with non-finite points too

---------

Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 08:15:55 +01:00
vangel76andClaude Opus 5.5 833c0154aa Merge upstream/main into control-mcp-delivery-1326-1331
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 06:18:12 +01:00
vangel76andClaude Opus 5.5 0a3fb5828e Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 06:18:12 +01:00
vangel76andClaude Opus 5.5 e344bb4243 Merge upstream/main into PR #3197
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:19:14 +01:00
vangel76andClaude Opus 5.5 4a507de3d6 Merge upstream/main into PR #3195
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:19:14 +01:00
vangel76andClaude Opus 5.5 d07f50dd8b Merge upstream/main into PR #5116
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:19:14 +01:00
vangel76andClaude Opus 5.5 87e08051bf Merge upstream/main into PR #2596
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:19:14 +01:00
vangel76andClaude Opus 5.5 6367400215 Merge upstream/main into control-mcp-delivery-1326-1331
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:21:51 +01:00
vangel76andClaude Opus 5.5 9655b2da50 Merge upstream/main into test/fixed-layout-fonts-2729
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:21:38 +01:00
vangel76andClaude Opus 5.5 66e169c343 Merge upstream/main into ui-text-hinting
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:21:35 +01:00
vangel76andClaude Opus 5.5 1c6c7c732e Merge upstream/main into feat/paper-colors-3398
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:20:39 +01:00
vangel76andClaude Opus 5.5 dba2b3b5e9 Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 03:26:05 +01:00
vangel76andClaude Opus 5.5 beab4e8e9c Merge upstream/main into zoom-stretch
Upstream's new layout tests start with crate::test_fonts(), like the others.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 03:25:18 +01:00
vangel76andClaude Opus 5.5 c9090ba95a Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 03:17:59 +01:00
vangel76andClaude Opus 5.5 c606533853 Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 03:17:59 +01:00
vangel76andClaude Opus 5.5 4774749a05 Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 02:27:44 +01:00
vangel76andClaude Opus 5.5 20a10a5f33 Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 02:26:30 +01:00
vangel76andClaude Sonnet 5.5 e4310ec5c7 Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 01:17:09 +01:00
vangel76 ecbabc327c Merge remote-tracking branch 'upstream/main' into m1056
# Conflicts:
#	docs/file-format-parity.md
2026-11-08 00:30:11 +01:00
vangel76 34b4caf317 Merge remote-tracking branch 'upstream/main' into m2596
# Conflicts:
#	ROADMAP.md
2026-11-08 00:29:41 +01:00
vangel76 8ab285e61d Merge remote-tracking branch 'upstream/main' into m3195
# Conflicts:
#	ROADMAP.md
2026-11-08 00:27:34 +01:00
vangel76 490437fa87 Merge remote-tracking branch 'upstream/main' into m3197
# Conflicts:
#	ROADMAP.md
2026-11-08 00:27:30 +01:00
vangel76 4925e06391 Merge remote-tracking branch 'upstream/main' into m1056
# Conflicts:
#	docs/file-format-parity.md
2026-11-08 00:19:35 +01:00
vangel76 5c2e5ce0d0 Merge remote-tracking branch 'upstream/main' into m2596
# Conflicts:
#	ROADMAP.md
2026-11-08 00:19:30 +01:00
vangel76 274d40fc4d Merge remote-tracking branch 'upstream/main' into m3195
# Conflicts:
#	ROADMAP.md
2026-11-08 00:19:26 +01:00
vangel76 570c39e287 Merge remote-tracking branch 'upstream/main' into m3197
# Conflicts:
#	ROADMAP.md
2026-11-08 00:19:22 +01:00
vangel76andClaude Sonnet 5.5 f1b5f0b4de Layout test: install the pinned fonts before the #4720 chart fallback lookup
The test asked the global font database for a fallback face before any
layout call, so the pinned fonts could no longer be installed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:24:08 +01:00
vangel76andClaude Sonnet 5.5 ddb1d37de3 Merge upstream/main into test/fixed-layout-fonts-2729
Keep upstream's split of layout_para and install the pinned fonts at its
start. Refit the main tests added since (#4250 widow and footnote length,
#4720 chart fallback font) to the pinned test fonts. Merge the two
wordcraft-layout dev-dependency tables.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:22:44 +01:00
vangel76andClaude Sonnet 5.5 d98a335ae8 Hinting test: render a bundled face at a fixed size; refit a #4250 footnote test to the pinned fonts
The hinting test took whatever default font the machine resolved, so its grey-pixel threshold
depended on the system. It now sets Source Sans 3 at 12 pt (4.7 percent fewer grey pixels).
The footnote test's note length was chosen against system fonts and is now the one that fits
the pinned test fonts, which every layout call now installs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:35:41 +01:00
vangel76andClaude Sonnet 5.5 bbc6d94dbe Layout tests: install the pinned fonts in the layout entry points and font helpers
layout, layout_para, layout_equation and mark_text_width call test_fonts() first in test
builds, so the shared lay helper and everything that goes through them is covered. Tests and
helpers that look up fonts directly (the Times New Roman substitute test, the Symbol bullet
test, the Arabic/Myanmar/Thai face probes and the nbsp fixture) call it themselves. Audited by
running every layout test in its own process with a temporary guard that panicked on any
global font access before the pinned fonts were installed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:32:22 +01:00
vangel76andClaude Sonnet 5.5 d0498254a2 insert_table: an explicit null values is a parameter error, not the CSV sample table
The CSV fallback is now only for an absent values field. A null values argument is rejected
before anything is inserted. Null cells inside a valid row still work. Engine and MCP
regressions check that the document and the undo history stay unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:31:38 +01:00
vangel76andClaude Sonnet 5.5 9fd9ce54b7 Side by Side keeps the scrolling choice for an unchanged partner; drop queued partner scroll on pairing changes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:27:10 +01:00
vangel76andClaude Sonnet 5.5 d94bbc8fe0 Layout tests: install the pinned fonts in the layout entry points and font helpers
layout, layout_para, layout_equation and mark_text_width call test_fonts() first in test
builds, so the shared lay helper and everything that goes through them is covered. Tests and
helpers that look up fonts directly (the Times New Roman substitute test, the Symbol bullet
test, the Arabic/Myanmar/Thai face probes and the nbsp fixture) call it themselves. Audited by
running every layout test in its own process with a temporary guard that panicked on any
global font access before the pinned fonts were installed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:16:20 +01:00
vangel76andClaude Sonnet 5.5 18a26b41ce layout tests: give a #4250 widow test a paragraph-length range that fits the pinned test fonts
With the fixed test fonts the paragraph with a widow needs fewer repeats
than the search range started at.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 12:22:51 +01:00
vangel76andClaude Sonnet 5.5 9f9190f0d7 layout tests: give two #4250 tests geometry that fits the pinned test fonts
The footnote text length and the paragraph-length search range in these two
tests were chosen against system fonts; with the fixed test fonts the page
breaks fall elsewhere.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 12:16:50 +01:00
vangel76andClaude Sonnet 5.5 1c7c4e66e0 Merge upstream/main into control-mcp-delivery-1326-1331
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 11:38:09 +01:00
vangel76andClaude Sonnet 5.5 2d6da7a326 Merge upstream/main into ui-text-hinting
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 11:38:09 +01:00
vangel76andClaude Sonnet 5.5 30a439899b Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 11:37:52 +01:00
vangel76andClaude Sonnet 5.5 875a62043a Merge upstream/main into feat/322-view-windows
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 11:37:52 +01:00
vangel76andClaude Sonnet 5.5 7ca1dd1802 Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 11:37:52 +01:00
vangel76andClaude Sonnet 5.5 a5b1518a10 Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 01:10:51 +01:00
vangel76andClaude Sonnet 5.5 4e334166e5 Merge upstream/main into control-mcp-delivery-1326-1331
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 01:10:42 +01:00
vangel76andClaude Sonnet 5.5 8df01e481f Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 01:10:42 +01:00
vangel76andClaude Sonnet 5.5 4ac7292d62 Merge upstream/main into feat/recent-remove-2716
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 01:09:58 +01:00
vangel76andClaude Sonnet 5.5 67c9b8ac31 Merge upstream/main into ui-text-hinting
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 01:06:04 +01:00
vangel76andClaude Sonnet 5.5 401e7c9169 Merge upstream/main into feat/322-view-windows
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 04:13:02 +01:00
vangel76andClaude Sonnet 5.5 3daa6b1e78 Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 04:09:53 +01:00
vangel76andClaude Sonnet 5.5 e4ba85346d Merge upstream/main into mcp-insert-table-2242
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 04:06:29 +01:00
vangel76andClaude Sonnet 5.5 ebdca4feb8 Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 03:51:36 +01:00
vangel76andClaude Sonnet 5.5 44eb54eef9 layout: one dev-dependencies table after the merge
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 03:36:34 +01:00
vangel76andClaude Sonnet 5.5 75a905802c Merge upstream/main into ui-text-hinting
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 03:36:06 +01:00
vangel76andClaude Sonnet 5.5 2c17b04f9a Merge upstream/main into feat/paragraph-tools-5043
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 03:29:32 +01:00
vangel76andClaude Sonnet 5.5 951b255b80 Merge upstream/main into control-mcp-delivery-1326-1331
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 03:26:18 +01:00
vangel76andClaude Sonnet 5.5 37a627c61e Merge upstream/main into feat/paper-colors-3398
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 03:23:54 +01:00
vangel76andClaude Sonnet 5.5 0ff06b9d86 backstage: fix Align2 path after merge
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 03:23:46 +01:00
vangel76andClaude Sonnet 5.5 d5495ea4f8 Merge upstream/main into feat/recent-remove-2716
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 00:12:16 +01:00
vangel76andClaude Sonnet 5.5 afc01f8328 Paragraph tools: walk runs once for live characters; stronger tracked-change tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 02:14:50 +01:00
vangel76andClaude Sonnet 5.5 9e391e97f4 Paragraph tools: skip tracked-deleted characters; snapshot revisions once per command (review)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 02:04:56 +01:00
vangel76 2113f52db4 Merge upstream/main into feat/paragraph-tools-5043 2026-11-04 02:00:59 +01:00
vangel76 098cf86dc8 Merge upstream/main into feat/paragraph-tools-5043 2026-11-04 01:01:08 +01:00
vangel76andCodex e99e28aebd M8: finish paragraph tools (#5043)
Co-Authored-By: Codex <noreply@openai.com>
2026-11-04 00:53:50 +01:00
vangel76andCodex 4abae255e0 M8: refresh screen paper colors (#3398)
Co-Authored-By: Codex <noreply@openai.com>
2026-11-03 19:57:50 +01:00
vangel76andClaude Sonnet 5.5 0b6e052d73 Merge upstream/main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 00:01:00 +01:00
vangel76andClaude Sonnet 5.5 45cfc55116 Merge upstream/main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 23:54:30 +01:00
vangel76andClaude Sonnet 5.5 a3b50342f9 Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 23:53:36 +01:00
vangel76andClaude Sonnet 5.5 ce4c401c4a Merge upstream/main into ui-text-hinting
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 23:53:35 +01:00
vangel76andClaude Sonnet 5.5 b8ae10e453 Merge upstream/main into feat/paragraph-tools-5043
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 23:47:13 +01:00
vangel76andClaude Sonnet 5.5 f4f9d28d99 Merge upstream/main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 19:34:13 +01:00
vangel76andClaude Sonnet 5.5 e17ad54b15 Merge upstream/main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 19:34:13 +01:00
vangel76andClaude Sonnet 5.5 9e2f6ad330 Merge upstream/main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 19:31:57 +01:00
vangel76andClaude Sonnet 5.5 6f125461dd Merge upstream/main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 19:31:57 +01:00
vangel76andClaude Sonnet 5.5 31a37bcf00 Merge upstream/main into feat/322-view-windows
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 19:30:37 +01:00
vangel76andClaude Sonnet 5.5 2025c48e9b Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 19:30:37 +01:00
vangel76andClaude Sonnet 5.5 83b319e7c3 Merge upstream/main into control-mcp-delivery-1326-1331
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 19:30:37 +01:00
vangel76andClaude Haiku 5.5 ca1f584ae0 Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-01 14:52:50 +01:00
vangel76andClaude Sonnet 5.5 7378ac5063 Layout tests: install the fixed fonts in mark_text_width too
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 14:50:06 +01:00
vangel76andClaude Sonnet 5.5 7e967d4a3a Layout tests: initialize the fixed fonts centrally in the layout entry points
layout, layout_para and layout_equation call crate::test_fonts() first in test
builds, so no test can reach font lookup through them before the pinned fonts
are installed. Tests and helpers that look up fonts directly (word::resolve,
FontDb::global, the nbsp fixture) call it themselves. Fixes init_test_fonts()
returning false when an uninitialised test ran first in a shared process.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 14:46:27 +01:00
vangel76andClaude Sonnet 5.5 f5038a12e3 Merge upstream/main into ui-text-hinting
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 14:40:14 +01:00
vangel76andClaude Sonnet 5.5 1bbced51a4 Recent list: saved is null when no preferences were written
The desktop hook returns Ok(false) when preferences are switched off or there is
no settings folder; the commands report that like a host without a hook. Also
move add_saved out of works_without_document's doc comment.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 13:53:50 +01:00
vangel76andClaude Sonnet 5.5 9de2227af2 Report a failed preferences save from ui.recentRemove and ui.recentClear
The save_prefs hook now returns a Result; both commands add saved/saveError to
their result and the status line says when the write failed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 13:33:45 +01:00
vangel76andClaude Haiku 5.5 7d0ba2be63 Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-01 13:28:06 +01:00
vangel76andClaude Haiku 5.5 f69b003fe1 Merge upstream/main into mcp-insert-table-2242
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-01 13:20:55 +01:00
vangel76 c9046233a0 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-01 13:13:36 +01:00
vangel76andCodex 0e7dc8da53 fix(control): prevent ambiguous mutation replay (#2596)
Propagate accept-thread startup errors and remove the key file. Keep image exports from being retried and continue stdio after invalid UTF-8.

Co-Authored-By: Codex <noreply@openai.com>
2026-10-31 22:12:18 +01:00
vangel76 837d2b0748 Merge remote-tracking branch 'upstream/main' into fix/pr2596-delivery-review 2026-10-31 21:52:47 +01:00
vangel76 220bcf947a Home › Paragraph Tools: nine WPS-style paragraph commands (#5043)
Conversions: line breaks to paragraphs, blank paragraphs between, separate
paragraphs, spaces to indentation, indents to leading spaces, join lines.
Cleanup: delete blank paragraphs, remove leading spaces, collapse extra
spaces. Each command works over the selection, or the whole document with
scope "all"; one undo step per command; deletions are recorded under Track
Changes. Home ribbon drop-down with per-command entries, including whole-
document variants. 16 engine tests, 1 ribbon kittest.

Closes #5043
2026-10-31 17:29:23 +01:00
vangel76andCodex 7d27d8299a Merge upstream/main into screen paper presets (#3398)
Co-Authored-By: Codex <noreply@openai.com>
2026-10-31 17:17:07 +01:00
vangel76andCodex 71dba44a0a M8: add persistent screen-only paper color presets (#3398)
Co-Authored-By: Codex <noreply@openai.com>
2026-10-31 17:12:52 +01:00
vangel76andCodex 3a22b72132 Merge upstream/main into zoom-stretch
Keep the current upstream roadmap status entries and wording together with the pointer-zoom note. No feature code changes in the conflict resolution.

Co-Authored-By: Codex <noreply@openai.com>
2026-10-31 11:10:09 +01:00
vangel76andCodex f342b6ff1f Merge upstream/main into ui-text-hinting
Keep the current upstream roadmap status entries and wording together with the dark-theme text note. No feature code changes in the conflict resolution.

Co-Authored-By: Codex <noreply@openai.com>
2026-10-31 11:09:08 +01:00
vangel76 95669b9eb9 Merge upstream/main into ui-text-hinting 2026-10-31 00:08:15 +01:00
vangel76 88ac0d7174 Merge upstream/main into zoom-stretch 2026-10-31 00:05:05 +01:00
vangel76 3b20ea46de Merge upstream/main into feat/1053-rtf-notes-comments 2026-10-30 23:35:18 +01:00
vangel76andClaude Sonnet 5.5 4524bc6ba9 Merge upstream/main into ui-text-hinting
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 22:41:52 +01:00
vangel76andClaude Sonnet 5.5 49a3cd223b Format canvas.rs after the merge
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 22:40:27 +01:00
vangel76andClaude Sonnet 5.5 061d40d5eb Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 22:39:52 +01:00
vangel76andClaude Sonnet 5.5 05a37f6fe2 Apply cargo fmt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 22:24:10 +01:00
vangel76andClaude Sonnet 5.5 d95cb46da5 Merge upstream/main into feat/322-view-windows
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 22:22:47 +01:00
vangel76andClaude Sonnet 5.5 8c44eb1b98 Merge upstream/main into control-mcp-delivery-1326-1331
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 22:22:42 +01:00
vangel76andClaude Sonnet 5.5 933bccf052 Merge upstream/main into feat/1053-rtf-notes-comments
Re-apply the RTF notes and comments reader changes on the refactored rtf/word.rs and
rtf/parse.rs, fix the imports of the split modules and drop stray .rej files.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 22:18:50 +01:00
vangel76andClaude Sonnet 5.5 9aa3d93ecf Merge upstream/main into feat/recent-remove-2716
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 22:18:00 +01:00
vangel76 bffb6e6705 Merge upstream/main into mcp-insert-table-2242 2026-10-29 22:15:47 +01:00
vangel76andClaude Sonnet 5.5 a449c59232 Merge upstream/main into feat/322-view-windows
Main split the session, the control handler, the app and the ribbon into modules; the
View Window hooks are reapplied to the new locations.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 20:12:37 +01:00
vangel76andClaude Sonnet 5.5 a82d509793 Merge upstream/main into feat/1053-rtf-notes-comments
Main split rtf.rs into modules; the notes and comments code is reapplied to the new files.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 20:04:09 +01:00
vangel76andClaude Sonnet 5.5 4bfd84514c Merge upstream/main into test/fixed-layout-fonts-2729
Main split fontdb.rs, math.rs and the layout test files into modules; the pinned-font
changes are reapplied to the new locations.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 19:47:34 +01:00
vangel76andClaude Sonnet 5.5 c40d5159b7 docs: list insert_table among the tools that take doc
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 20:35:50 +01:00
vangel76andClaude Sonnet 5.5 841dd538dc Merge upstream/main into feat/322-view-windows
Main now gives every document its own native window in one process, with Switch Windows and
New Window. Drop the one-process-per-window registry and peer channel and build Arrange All,
View Side by Side and Synchronous Scrolling on those windows: the drawn window sees the others
by window id, tiles and halves are placed through the windows' viewports, and the scrolling is
handed to the partner window in document points.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 19:46:57 +01:00
vangel76andClaude Sonnet 5.5 364495deb5 Merge upstream/main into mcp-insert-table-2242
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 19:34:56 +01:00
vangel76andClaude Sonnet 5.5 c85d454e09 Merge upstream/main into feat/recent-remove-2716
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 19:27:54 +01:00
vangel76andClaude Sonnet 5.5 1c1ee15cff Fix FlipPen initialisers after the hinting-strike change on main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 19:22:52 +01:00
vangel76andClaude Sonnet 5.5 1a950a3631 Fix FlipPen initialisers after the hinting-strike change on main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 19:18:45 +01:00
vangel76 0c859e334d Merge upstream/main
# Conflicts:
#	ROADMAP.md
#	docs/ui-parity.md
2026-10-26 19:14:40 +01:00
vangel76 f4ff1f842d Merge upstream/main
# Conflicts:
#	ROADMAP.md
#	docs/ui-parity.md
2026-10-26 19:14:40 +01:00
vangel76 d8899ed345 Merge upstream/main 2026-10-26 19:13:12 +01:00
vangel76 c10df4e7bc Merge remote-tracking branch 'upstream/main' into w3-1056 2026-10-26 19:12:44 +01:00
vangel76andClaude Sonnet 5.5 2a82cc4a22 Fix test layout after merging upstream/main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 02:20:51 +01:00
vangel76 5ce611091e Merge upstream/main into feat/1053-rtf-notes-comments 2026-10-26 02:20:26 +01:00
vangel76 878e5e3457 Merge upstream/main into test/fixed-layout-fonts-2729 2026-10-26 02:19:00 +01:00
vangel76 3cfa9ca721 x 2026-10-26 02:18:45 +01:00
vangel76 ff4377bf7e Merge upstream/main into zoom-stretch 2026-10-26 02:16:56 +01:00
vangel76andClaude Sonnet 5.5 71aa12c076 Fix hinting tests and imports after merging upstream/main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 02:16:31 +01:00
vangel76 cfa99b45ce Merge upstream/main into ui-text-hinting 2026-10-26 02:15:38 +01:00
vangel76andClaude Sonnet 5.5 e11943f818 Read the canvas scroll offset from the frame's clamped state after the merge
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 01:00:10 +01:00
vangel76andClaude Sonnet 5.5 33070c575c Merge upstream/main into feat/322-view-windows
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 00:55:58 +01:00
vangel76andClaude Sonnet 5.5 86a1b000ef Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 00:55:06 +01:00
vangel76andClaude Sonnet 5.5 b6d89388c8 Merge upstream/main into control-mcp-delivery-1326-1331
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 00:49:08 +01:00
vangel76andClaude Sonnet 5.5 914e1d5872 Use Ui::scope_id for the recent-row remove button
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 00:47:31 +01:00
vangel76andClaude Sonnet 5.5 ba62c2255e Merge upstream/main into feat/recent-remove-2716
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 00:44:35 +01:00
vangel76andClaude Sonnet 5.5 0ea610d0ff Merge upstream/main into test/fixed-layout-fonts-2729
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 00:26:16 +01:00
vangel76andClaude Sonnet 5.5 a4e9b07701 Fix egui MouseWheel source field in zoom tests after merge
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 00:10:57 +01:00
vangel76andClaude Sonnet 5.5 2fb626e548 Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 00:04:27 +01:00
vangel76andClaude Sonnet 5.5 827fc6e07a Fix tests after merge: egui MouseWheel source field, keep upstream's pinned fixtures
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 00:04:01 +01:00
vangel76andClaude Sonnet 5.5 e2b92cde84 Merge upstream/main into ui-text-hinting
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 23:56:15 +01:00
vangel76andClaude Sonnet 5.5 ed3c3ea038 Pass hinted=true in the sepia/print view_key tests after merge
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 10:20:25 +01:00
vangel76andClaude Sonnet 5.5 4940d07dad Merge upstream/main into feat/322-view-windows
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 10:19:42 +01:00
vangel76andClaude Sonnet 5.5 dc2b990602 Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 10:19:03 +01:00
vangel76andClaude Sonnet 5.5 92683b6796 Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 10:18:49 +01:00
vangel76andClaude Sonnet 5.5 d858066cf7 Pass hinted=true in the sepia/print view_key tests after merge
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 10:18:34 +01:00
vangel76andClaude Sonnet 5.5 006b6480ed Merge upstream/main into control-mcp-delivery-1326-1331
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 10:17:03 +01:00
vangel76andClaude Sonnet 5.5 402ee4893c Merge upstream/main into ui-text-hinting
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 10:14:50 +01:00
vangel76andClaude Sonnet 5.5 21bba8e7de Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 21:01:39 +02:00
vangel76andClaude Sonnet 5.5 d5de454641 Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 21:00:39 +02:00
vangel76andClaude Sonnet 5.5 11fce726e3 Merge upstream/main into ui-text-hinting
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 21:00:14 +02:00
vangel76andClaude Opus 5.5 ed97087907 Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:04:32 +02:00
vangel76andClaude Opus 5.5 2b2398ae55 Merge upstream/main into ui-text-hinting
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 19:54:22 +02:00
vangel76andClaude Sonnet 5.5 d7c7889c82 Merge upstream/main into feat/recent-remove-2716
Interface catalogs now live in craft-i18n; the PR's tsv rows are dropped.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 14:18:34 +02:00
vangel76andClaude Sonnet 5.5 0b1704677d Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 14:01:46 +02:00
vangel76andClaude Sonnet 5.5 ec1cb78a20 Merge upstream/main into mcp-insert-table-2242
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 13:58:11 +02:00
vangel76andClaude Sonnet 5.5 8b06dd8666 Merge upstream/main into ui-text-hinting
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 13:56:07 +02:00
vangel76andClaude Sonnet 5.5 28b5cba00c Merge upstream/main into feat/322-view-windows
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 13:53:17 +02:00
vangel76andClaude Sonnet 5.5 14526d0d81 Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 13:51:55 +02:00
vangel76andClaude Sonnet 5.5 97eea6e12f Hinting test: ask for 3% fewer grey pixels, not 5%
Hinting only snaps horizontal edges, so on the fonts the render tests
resolve to the grey-pixel drop is 3.6 to 4.7 %, never the 5 % the test
asked for. It failed the same way on CI and locally (3767 vs 3630).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 21:11:30 +02:00
vangel76andClaude Sonnet 5.5 8b679452eb Hinting test: ask for 3% fewer grey pixels, not 5%
Hinting only snaps horizontal edges, so on the fonts the render tests
resolve to the grey-pixel drop is 3.6 to 4.7 %, never the 5 % the test
asked for. It failed the same way on CI and locally (3767 vs 3630).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 21:07:07 +02:00
vangel76 c488558a1c Merge upstream/main into feat/recent-remove-2716 2026-10-22 19:10:34 +02:00
vangel76 addf03751d Merge upstream/main into control-mcp-delivery-1326-1331 2026-10-22 19:06:45 +02:00
vangel76 8578d99ccd Merge upstream/main into mcp-insert-table-2242 2026-10-22 19:05:36 +02:00
vangel76 77bfbd6f11 Merge upstream/main into feat/1053-rtf-notes-comments 2026-10-22 19:03:25 +02:00
vangel76 38253611d2 Add View Window strings to the da, nl, ro and sl catalogs 2026-10-22 19:02:20 +02:00
vangel76 876ce9ab48 Merge remote-tracking branch 'upstream/main' into s1-ui-text-hinting
# Conflicts:
#	ROADMAP.md
#	crates/fonts/src/fontdb.rs
#	crates/layout/src/tests_pages.rs
#	crates/render/src/lib.rs
#	crates/ui-egui/src/canvas.rs
#	docs/ui-parity.md
2026-10-22 18:56:32 +02:00
vangel76 0a23c4febe Merge remote-tracking branch 'upstream/main' into zoom-stretch
# Conflicts:
#	ROADMAP.md
#	crates/fonts/src/fontdb.rs
#	crates/layout/src/tests_pages.rs
#	crates/render/src/lib.rs
#	crates/ui-egui/src/canvas.rs
#	docs/ui-parity.md
2026-10-22 18:53:36 +02:00
vangel76 65f36f9b8a Merge upstream/main into feat/322-view-windows 2026-10-22 18:53:05 +02:00
vangel76 f50a5cc5e9 Merge remote-tracking branch 'upstream/main' into test/fixed-layout-fonts-2729
# Conflicts:
#	ROADMAP.md
#	crates/layout/src/tests_pages.rs
#	crates/render/src/lib.rs
2026-10-22 18:48:49 +02:00
vangel76 12582bb529 render: count legacy effect copies past the glyph box, not the glyph's antialiasing
The grey band 120-136 also holds the glyph's own antialiasing pixels on
the vertical stem edges; on platforms with many of them (FreeBSD's
Mesa AA), their centroid sits at the glyph centre and hides the copy's
offset in x (0.95 instead of ~6). The copy is drawn fully past the
glyph's bounding box in its own direction, so count grey only outside
the box in the copy's quadrant. Fixes the FreeBSD failure of #2783.
2026-10-22 00:04:52 +02:00
vangel76 badd0c5ef9 render: count legacy effect copies past the glyph box, not the glyph's antialiasing
The grey band 120-136 also holds the glyph's own antialiasing pixels on
the vertical stem edges; on platforms with many of them (FreeBSD's
Mesa AA), their centroid sits at the glyph centre and hides the copy's
offset in x (0.95 instead of ~6). The copy is drawn fully past the
glyph's bounding box in its own direction, so count grey only outside
the box in the copy's quadrant. Fixes the FreeBSD failure of #2783.
2026-10-22 00:04:05 +02:00
vangel76 2f46f194a1 render: count legacy effect copies past the glyph box, not the glyph's antialiasing
The grey band 120-136 also holds the glyph's own antialiasing pixels on
the vertical stem edges; on platforms with many of them (FreeBSD's
Mesa AA), their centroid sits at the glyph centre and hides the copy's
offset in x (0.95 instead of ~6). The copy is drawn fully past the
glyph's bounding box in its own direction, so count grey only outside
the box in the copy's quadrant. Fixes the FreeBSD failure of #2783.
2026-10-21 23:48:49 +02:00
vangel76 6bc7810c8c fmt: rustfmt backstage recent list 2026-10-21 23:23:44 +02:00
vangel76andClaude Sonnet 5.5 f9c6ddba3a fix(ui): show the Remove x on a hovered recent row, not only on the x itself (#2716)
The x was painted only while the pointer was already on the 28 pt button, so a
hovered row gave no cue that a per-row Remove exists. Paint it dimmed when the
row (or the button) is hovered or focused, with the stronger fill only on the x.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 20:23:29 +02:00
vangel76 00d4e7a291 #2729: use pinned layout fixtures to fix FreeBSD CI 2026-10-20 01:46:00 +02:00
vangel76 bfbb3d05a4 #2729: use pinned layout fixtures to fix FreeBSD CI 2026-10-20 01:46:00 +02:00
vangel76 9b6d326730 #2729: initialize pinned fonts in current ligature and caret tests 2026-10-20 01:45:42 +02:00
vangel76 212df61bc7 #3197: gate text-ink fixture to the platforms that use it 2026-10-20 01:40:47 +02:00
vangel76 0abe06e236 Merge remote-tracking branch 'upstream/main' into control-mcp-delivery-1326-1331 2026-10-20 01:40:44 +02:00
vangel76 843bf26ae8 #322: merge upstream and preserve window-command progress 2026-10-20 01:39:10 +02:00
vangel76 44055678c5 #1053: merge upstream while retaining RTF notes and comments 2026-10-20 01:38:35 +02:00
vangel76 9ea1d93a8d #2729: merge upstream and retain pinned-font test progress 2026-10-20 01:37:56 +02:00
vangel76andClaude Sonnet 5.5 ab3ad718a0 feat(ui): remove documents from the recent list, one by one or all (#2716)
Hovering a row in File > Home/Open > Recent shows a Remove button at its
right end, and a Clear all button under the list empties it. The commands
behind them, ui.recentRemove and ui.recentClear, work the same for the
CLI, the control channel and MCP. On the desktop the new list is written
to the preferences at once, so a removal survives the app being killed
before a normal close.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 01:34:12 +02:00
Mikael Tollefsen ad7eea67c1 ui_shot: an out-of-range {"ppp": s} is clamped to 0.5–4, not ignored
A scale outside the range fell back to 2 instead of the nearest allowed
one (review on #3101): {"ppp": 5} now gives 4, {"ppp": 0.1} gives 0.5.
2026-10-20 01:07:36 +02:00
Mikael Tollefsen be492ddc98 Dark-theme interface text on Linux no longer looks semi-bold 2026-10-20 01:07:35 +02:00
Mikael Tollefsen e8c852d490 ui_shot: a {"ppp": s} line sets the window's pixels per point
Headless screenshots were always taken at 2x, so text could not be seen
as it is drawn on a fractional desktop scale (e.g. 1.45 on a 4K screen at
145 %). The scale is clamped to 0.5–4; 2 stays the default.
2026-10-20 01:07:31 +02:00
Mikael Tollefsen 7a3c3b40af Screen hinting: tricky fonts run their instructions with the Mono target
FreeType's "tricky" fonts (old CJK fonts that assemble their glyphs with
their instructions) were hinted with the light smooth target, whose
backward compatibility forbids the instructions' sideways moves, so their
glyphs could come apart. skrifa requires the interpreter with
Target::Mono for them (OutlineGlyphCollection::require_interpreter), as
the unhinted path already does; the screen hinter now does the same.

Test: a bundled font renamed into FreeType's list ("cpop") is hinted with
the Mono target and drawn as it hints; any other font keeps the light
target.
2026-10-20 01:07:31 +02:00
Mikael Tollefsen 5288fed0ea Zoom hinting test: the wheel's own frame is checked too
The frame the Ctrl+wheel event arrives in already changes the scale; the
test now checks it is unhinted, not only the eased frames after it.
2026-10-20 01:07:31 +02:00
Mikael Tollefsen 1e306ed5ab Pages drawn mid-zoom are unhinted, and hinted once the zoom stops
A Ctrl/Cmd+wheel notch eases in over several frames and a pinch changes
the scale every frame: each frame was a new size, so its pages were
hinted afresh (a new hinting instance per face and every glyph hinted
again, about 1.5 ms more per page) and the hint caches filled with sizes
nobody rests on, which could clear the one the zoom ends at.

While a pointer zoom changes the scale, pages are now drawn unhinted;
the hinting is part of the page's texture key, so the first frame at
rest draws the page hinted, also at the size the last zoom frame drew.
A zoom that leaves the scale as it is (at 10 % or 500 %, or over the
ribbon) keeps the pages hinted, and other scale changes (Zoom dialog,
slider, a fit zoom following the window) are drawn hinted at once.

Rounding sizes into buckets was considered instead: steps between
eased frames are far larger than any bucket fine enough to keep the
glyphs on the pixel grid at the size the zoom ends at.

Tests: an easing Ctrl+wheel zoom draws every in-between size unhinted
and the page hinted the frame it stops; the decision for a gesture,
the zoom limits and other scale changes; the page key holds the
hinting.
2026-10-20 01:07:31 +02:00
Mikael Tollefsen 9e25e978e5 Page text on Linux is hinted like FreeType's hintslight 2026-10-20 01:07:31 +02:00
Mikael Tollefsen 3248047b76 Zoom stretch: only a raster of what the page shows now stands in
A pointer zoom that reflows the pages (Read Mode's text size follows the
wheel; Web Layout's width follows the zoom) gives a page new content while
the zoom is still moving. The stretched raster failed the page key but
still stood in when its size fitted, so the page showed the previous
layout's text until the zoom stopped (review on #3111).

Each page raster now keeps what the page showed (its content and view,
without scale or hinting); a raster is stretched only when that matches
what the page shows now, else the page is rendered.

Tests: in Read Mode a wheel zoom renders the reflowed pages while it
moves (none rendered without the change, over 11 frames); the eased notch
is checked frame by frame: no raster while it moves, one the frame it
stops.
2026-10-20 01:01:19 +02:00
Mikael Tollefsen 5edcd6765b Pointer zoom stretches page rasters until it stops 2026-10-20 01:01:19 +02:00
Mikael Tollefsen 80bf6ed96b Screen hinting: tricky fonts run their instructions with the Mono target
FreeType's "tricky" fonts (old CJK fonts that assemble their glyphs with
their instructions) were hinted with the light smooth target, whose
backward compatibility forbids the instructions' sideways moves, so their
glyphs could come apart. skrifa requires the interpreter with
Target::Mono for them (OutlineGlyphCollection::require_interpreter), as
the unhinted path already does; the screen hinter now does the same.

Test: a bundled font renamed into FreeType's list ("cpop") is hinted with
the Mono target and drawn as it hints; any other font keeps the light
target.
2026-10-20 01:01:15 +02:00
Mikael Tollefsen 665af07277 Zoom hinting test: the wheel's own frame is checked too
The frame the Ctrl+wheel event arrives in already changes the scale; the
test now checks it is unhinted, not only the eased frames after it.
2026-10-20 01:01:15 +02:00
Mikael Tollefsen 2e7852fa4d Pages drawn mid-zoom are unhinted, and hinted once the zoom stops
A Ctrl/Cmd+wheel notch eases in over several frames and a pinch changes
the scale every frame: each frame was a new size, so its pages were
hinted afresh (a new hinting instance per face and every glyph hinted
again, about 1.5 ms more per page) and the hint caches filled with sizes
nobody rests on, which could clear the one the zoom ends at.

While a pointer zoom changes the scale, pages are now drawn unhinted;
the hinting is part of the page's texture key, so the first frame at
rest draws the page hinted, also at the size the last zoom frame drew.
A zoom that leaves the scale as it is (at 10 % or 500 %, or over the
ribbon) keeps the pages hinted, and other scale changes (Zoom dialog,
slider, a fit zoom following the window) are drawn hinted at once.

Rounding sizes into buckets was considered instead: steps between
eased frames are far larger than any bucket fine enough to keep the
glyphs on the pixel grid at the size the zoom ends at.

Tests: an easing Ctrl+wheel zoom draws every in-between size unhinted
and the page hinted the frame it stops; the decision for a gesture,
the zoom limits and other scale changes; the page key holds the
hinting.
2026-10-20 01:01:15 +02:00
Mikael Tollefsen 3ee5840d6d Page text on Linux is hinted like FreeType's hintslight 2026-10-20 01:01:15 +02:00
vangel76andClaude Sonnet 5.5 ee649d03da i18n: translate the View Side by Side disabled-reason strings
The two disabled reasons added by the Window commands ("only the desktop app has
several windows", "turn on View Side by Side first") had no catalog rows, so
i18n::audit::untranslated_interface_text and the cs/vi coverage tests failed.
Add rows to ru, hu, tr, bg, it, sv, hr, pl, cs and vi.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 02:28:02 +02:00
vangel76andClaude Sonnet 5.5 19094d4f3a Layout tests: start every test with test_fonts and refit three fixtures to the pinned fonts
Adds crate::test_fonts() to the 95 layout tests that lacked it (several arrived
with upstream merges). Three fixtures were sized for installed fonts and failed
with the pinned set: exact_row_moves_whole (one line less of filler),
an_at_least_row_at_the_top_of_a_page_splits_instead_of_moving_on (60 lines so
the split row does not spill an empty page) and the inline text box test
(FILLER 16).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 02:20:09 +02:00
vangel76andClaude Sonnet 5.5 fb3d600428 cargo fmt --all
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 02:12:04 +02:00
vangel76andClaude Sonnet 5.5 7b1c0402b0 Merge upstream/main into mcp-insert-table-2242
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 02:11:48 +02:00
vangel76andClaude Sonnet 5.5 365b8c003e Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 02:11:33 +02:00
vangel76andClaude Sonnet 5.5 1bec25c02d Merge upstream/main into control-mcp-delivery-1326-1331
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 02:11:16 +02:00
vangel76andClaude Sonnet 5.5 c3703ebc73 Regenerate parity checklist and headline coverage after merging main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 02:10:30 +02:00
vangel76andClaude Sonnet 5.5 ede933dcc2 Merge upstream/main into feat/322-view-windows
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 02:09:57 +02:00
vangel76andClaude Sonnet 5.5 d955ff2874 Merge upstream/main into test/fixed-layout-fonts-2729
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 02:00:30 +02:00
vangel76 bd320e65a5 M2.1: pin layout tests to bundled fonts (#2729) 2026-10-17 21:14:47 +02:00
vangel76 3445b7226d M-ctl: cargo fmt the MCP bridge test (long fake_app line broke cargo fmt --all --check in CI) 2026-10-17 11:50:34 +02:00
vangel76andClaude Sonnet 5.5 f2d7a4fe7d M-ctl: MCP bridge never replays a mutation of unknown outcome; queued control requests honour the caller's timeout (#1326, #1331)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 22:28:30 +02:00
vangel76andClaude Sonnet 5.5 25e4986998 MCP: insert_table tool; insert.spreadsheet takes values, header and style (#2242)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 22:27:46 +02:00
vangel76andClaude Sonnet 5.5 a912aacf73 Merge upstream/main into feat/322-view-windows; translations for hr, ko, sv, vi; catalog 493/504
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 23:11:06 +02:00
vangel76andClaude Sonnet 5.5 256d5d084d Merge upstream/main into feat/1053-rtf-notes-comments
Notes and comments now work with RTF tracked changes (#1516) and per-section
headers and footers (#1495); a \sect inside a note or comment no longer ends
a section. Adds a test for the combination.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 23:05:08 +02:00
vangel76andClaude Sonnet 5.5 f0c1b64bc8 RTF: footnotes, endnotes and comments are read and written (#1053)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 21:00:56 +02:00
vangel76andClaude Sonnet 5.5 0164ac86fa #322: translations for the other locales, catalog count 491/502, parity checklist after rebase
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 19:28:33 +02:00
vangel76andClaude Opus 5.5 12a1316f10 View › Window across WordCraft windows: Switch Windows, Arrange All, Side by Side, Synchronous Scrolling (#322)
Each desktop window is its own process with one document, so the window
commands work across processes. Every window joins a small registry in the
settings folder (windows/<id>.json, mode 0600, rewritten every 30 s, removed
on exit; stale and malformed entries are ignored or cleaned up) and serves a
minimal authenticated loopback channel that can only focus, place and scroll
the window. The full control channel stays opt-in.

- engine: view.switchWindows, view.arrangeAll, view.sideBySide and
  view.syncScroll record what to do in Session::windows and ask the UI;
  disabled where there is no window host (web, CLI, MCP).
- ui-egui: WindowHost service, tiling and half-screen geometry, sync-scroll
  plumbing in the canvas, ribbon buttons and keytips, strings in every
  catalog.
- desktop app: registry and peer channel (4 KiB lines, constant-time key
  check, 2 s first-line deadline, 16 connections at most).
- Parity 393/434 -> 397/434; docs updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:12:06 +02:00
152 changed files with 7698 additions and 316 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. 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
+27 -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;
+396
View File
@@ -0,0 +1,396 @@
//! `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}");
}
+183 -11
View File
@@ -9,6 +9,7 @@ use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::{Duration, Instant};
@@ -21,6 +22,38 @@ const MAX_LINE: usize = 1 << 20;
/// A connection that sends no authorised request in this time is closed.
const FIRST_REQUEST: Duration = Duration::from_secs(30);
/// How long a caller waits for the UI to answer a request. A request still queued after this
/// is cancelled and never runs.
const REPLY_WAIT: Duration = Duration::from_secs(60);
/// Connections that have not yet sent an authorised request are limited to this many; more are
/// closed at once. Each one holds a thread until its first request or [`FIRST_REQUEST`].
const MAX_UNAUTHENTICATED: usize = 64;
/// Counts one unauthenticated connection until it authenticates or ends.
struct Admission(Option<Arc<AtomicUsize>>);
impl Admission {
fn enter(count: &Arc<AtomicUsize>, max: usize) -> Option<Self> {
if count.fetch_add(1, Ordering::SeqCst) >= max {
count.fetch_sub(1, Ordering::SeqCst);
return None;
}
Some(Self(Some(count.clone())))
}
fn release(&mut self) {
if let Some(c) = self.0.take() {
c.fetch_sub(1, Ordering::SeqCst);
}
}
}
impl Drop for Admission {
fn drop(&mut self) {
self.release();
}
}
/// The key file of a running control server; [`KeyFile::remove`] it when the window closes.
pub struct KeyFile(PathBuf);
@@ -37,6 +70,15 @@ impl KeyFile {
/// cannot be bound or there is no random key (the control channel is off); the key file is
/// `None` when it could not be written (requests are then refused).
pub fn start(port: u16, ctx: egui::Context, settings: Option<&Path>) -> Option<(Receiver<ControlRequest>, Option<KeyFile>)> {
start_with(port, ctx, settings, |listener, ctx, key| serve_on(listener, ctx, key, FIRST_REQUEST))
}
fn start_with(
port: u16,
ctx: egui::Context,
settings: Option<&Path>,
serve: impl FnOnce(TcpListener, egui::Context, String) -> std::io::Result<Receiver<ControlRequest>>,
) -> Option<(Receiver<ControlRequest>, Option<KeyFile>)> {
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
@@ -67,11 +109,21 @@ pub fn start(port: u16, ctx: egui::Context, settings: Option<&Path>) -> Option<(
None
}
};
let rx = match serve(listener, ctx, key) {
Ok(rx) => rx,
Err(e) => {
if let Some(f) = &key_file {
f.remove();
}
log::error!("control server off: cannot start the accept thread ({e})");
return None;
}
};
match &key_file {
Some(f) => log::info!("control server listening on 127.0.0.1:{port}, key in {}", f.path().display()),
None => log::info!("control server listening on 127.0.0.1:{port}"),
}
Some((serve_on(listener, ctx, key, FIRST_REQUEST), key_file))
Some((rx, key_file))
}
/// A new key: 32 random bytes from the operating system, as hex.
@@ -103,16 +155,47 @@ fn keys_match(given: &str, key: &str) -> bool {
!b.is_empty() && a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
fn serve_on(listener: TcpListener, ctx: egui::Context, key: String, first: Duration) -> Receiver<ControlRequest> {
fn serve_on(listener: TcpListener, ctx: egui::Context, key: String, first: Duration) -> std::io::Result<Receiver<ControlRequest>> {
serve_with(listener, ctx, key, Limits { first, wait: REPLY_WAIT, max_unauthenticated: MAX_UNAUTHENTICATED })
}
/// The time and count budgets of a server (small ones in tests).
#[derive(Clone, Copy)]
struct Limits {
first: Duration,
wait: Duration,
max_unauthenticated: usize,
}
fn serve_with(listener: TcpListener, ctx: egui::Context, key: String, limits: Limits) -> std::io::Result<Receiver<ControlRequest>> {
serve_with_spawner(listener, ctx, key, limits, |accept| std::thread::Builder::new().name("control-accept".into()).spawn(accept).map(|_| ()))
}
fn serve_with_spawner(
listener: TcpListener,
ctx: egui::Context,
key: String,
limits: Limits,
spawn: impl FnOnce(Box<dyn FnOnce() + Send>) -> std::io::Result<()>,
) -> std::io::Result<Receiver<ControlRequest>> {
let (tx, rx) = channel::<ControlRequest>();
let key: Arc<str> = key.into();
std::thread::spawn(move || {
let unauthenticated = Arc::new(AtomicUsize::new(0));
let accept = move || {
for stream in listener.incoming().flatten() {
let Some(admission) = Admission::enter(&unauthenticated, limits.max_unauthenticated) else {
log::warn!("control server: too many unauthenticated connections, one closed");
continue;
};
let (tx, ctx, key) = (tx.clone(), ctx.clone(), key.clone());
std::thread::spawn(move || serve(stream, tx, ctx, &key, first));
// A thread that cannot start closes this connection only (the guard releases its slot).
if let Err(e) = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx, &key, limits, admission)) {
log::warn!("control server: no thread for a connection: {e}");
}
}
});
rx
};
spawn(Box::new(accept))?;
Ok(rx)
}
/// The read side of a connection with an optional overall deadline: each read waits only for
@@ -175,11 +258,11 @@ fn discard_rest_of_line(r: &mut BufReader<Timed>) {
}
}
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key: &str, first: Duration) {
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key: &str, limits: Limits, mut admission: Admission) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
// One overall deadline from accept to the first authorised request; after it, none.
let mut reader = BufReader::new(Timed { s: read, deadline: Some(Instant::now() + first) });
let mut reader = BufReader::new(Timed { s: read, deadline: Some(Instant::now() + limits.first) });
let mut authorised = false;
// The key an `auth` request gave this connection.
let mut conn_key: Option<String> = None;
@@ -228,6 +311,7 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key:
}
if !authorised {
authorised = true;
admission.release();
reader.get_mut().set_deadline(None);
}
if method == "auth" {
@@ -237,12 +321,18 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key:
}
continue;
}
let (req, rrx) = ControlRequest::new(method, params);
let (req, rrx, waiter) = ControlRequest::with_waiter(method, params);
if tx.send(req).is_err() {
break;
}
ctx.request_repaint();
let r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
let r = rrx.recv_timeout(limits.wait).unwrap_or_else(|_| {
if waiter.cancel() {
json!({"ok": false, "error": "timeout before execution: the request was not run and never will be"})
} else {
json!({"ok": false, "error": "timeout: outcome unknown, the request had started when the wait ended; inspect the document to check"})
}
});
if !reply(r) {
break;
}
@@ -260,7 +350,7 @@ mod tests {
fn boot_with(first: Duration) -> (u16, Receiver<ControlRequest>) {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let port = listener.local_addr().unwrap().port();
(port, serve_on(listener, egui::Context::default(), KEY.to_string(), first))
(port, serve_on(listener, egui::Context::default(), KEY.to_string(), first).unwrap())
}
fn boot() -> (u16, Receiver<ControlRequest>) {
@@ -512,6 +602,40 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_accept_thread_spawn_failure_aborts_start_and_removes_the_key() {
let dir = temp_dir("spawn-failure");
let path = wordcraft_control_key::key_path(&dir, 0);
let mut bound = None;
let result = start_with(0, egui::Context::default(), Some(&dir), |listener, ctx, key| {
bound = Some(listener.local_addr().unwrap());
assert_eq!(std::fs::read_to_string(&path).unwrap(), key, "startup wrote the key before spawning");
serve_with_spawner(listener, ctx, key, small(150, 8), |accept| {
drop(accept);
Err(std::io::Error::other("injected accept thread spawn failure"))
})
});
assert!(result.is_none(), "a server without its accept thread must not report success");
assert!(!path.exists(), "failed startup must remove its published key");
let _listener = TcpListener::bind(bound.unwrap()).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_complete_json_request_at_eof_can_reach_the_ui() {
// This is why a bridge write failure before the newline is outcome-unknown.
let (port, rx) = boot();
let mut c = Conn::open(port);
let body = json!({"id": 1, "key": KEY, "method": "engine.execute", "params": {"command": "text.insert", "params": {"text": "once"}}});
c.1.write_all(body.to_string().as_bytes()).unwrap();
c.1.shutdown(std::net::Shutdown::Write).unwrap();
let req = answer(&rx);
assert_eq!(req.params["params"]["text"], "once");
assert_eq!(c.recv()["ok"], true);
assert!(c.closed());
assert!(rx.try_recv().is_err());
}
#[test]
fn a_stale_key_file_is_replaced() {
let dir = temp_dir("stale");
@@ -532,4 +656,52 @@ mod tests {
}
let _ = std::fs::remove_dir_all(&dir);
}
fn boot_limits(limits: Limits) -> (u16, Receiver<ControlRequest>) {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let port = listener.local_addr().unwrap().port();
(port, serve_with(listener, egui::Context::default(), KEY.to_string(), limits).unwrap())
}
fn small(wait_ms: u64, max_unauthenticated: usize) -> Limits {
Limits { first: FIRST_REQUEST, wait: Duration::from_millis(wait_ms), max_unauthenticated }
}
#[test]
fn a_request_that_timed_out_in_the_queue_never_runs() {
let (port, rx) = boot_limits(small(150, 8));
let mut c = Conn::open(port);
let r = c.call(&format!(r#"{{"id":1,"key":"{KEY}","method":"engine.execute","params":{{"command":"text.insert"}}}}"#));
assert_eq!(r["ok"], false);
assert!(r["error"].as_str().unwrap_or("").starts_with("timeout before execution"), "{r}");
// The UI gets to it late: it must not run it.
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert!(!req.start(), "a cancelled request was started");
}
#[test]
fn a_request_that_started_before_the_timeout_reports_an_unknown_outcome() {
let (port, rx) = boot_limits(small(150, 8));
let mut c = Conn::open(port);
c.send(&format!(r#"{{"id":1,"key":"{KEY}","method":"engine.execute","params":{{"command":"text.insert"}}}}"#));
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert!(req.start());
let r = c.recv();
assert!(r["error"].as_str().unwrap_or("").contains("outcome unknown"), "{r}");
}
#[test]
fn unauthenticated_connections_are_limited() {
let (port, rx) = boot_limits(small(5_000, 2));
let mut idle = [Conn::open(port), Conn::open(port)];
// Give the server time to count both.
std::thread::sleep(Duration::from_millis(200));
assert!(Conn::open(port).closed(), "the third idle connection was admitted");
// One authenticates: its slot is free again.
assert_eq!(idle[0].call(&format!(r#"{{"id":1,"method":"auth","key":"{KEY}"}}"#))["ok"], true);
std::thread::sleep(Duration::from_millis(200));
let mut again = Conn::open(port);
assert_eq!(again.call(&format!(r#"{{"id":1,"method":"auth","key":"{KEY}"}}"#))["ok"], true);
assert!(rx.try_recv().is_err());
}
}
+16 -16
View File
@@ -299,25 +299,24 @@ fn uses_x11(setting: wordcraft_ui_egui::DisplayServer, env: impl Fn(&str) -> Opt
}
fn save_prefs(app: &WordApp) {
if let Err(e) = try_save_prefs(app) {
log::warn!("preferences: {e}");
}
}
/// Saves the preferences and says why when it could not. `Ok(false)`: nothing was written
/// because preferences are switched off or there is no settings folder.
fn try_save_prefs(app: &WordApp) -> Result<bool, String> {
if !prefs_enabled() {
return;
return Ok(false);
}
if let Some(p) = prefs_path() {
if let Some(dir) = p.parent()
&& let Err(e) = std::fs::create_dir_all(dir)
{
log::warn!("preferences: cannot create {}: {e}", dir.display());
return;
}
match serde_json::to_vec_pretty(&app.prefs()) {
Ok(bytes) => {
if let Err(e) = write_prefs(&p, &bytes) {
log::warn!("preferences: cannot save {}: {e}", p.display());
}
}
Err(e) => log::warn!("preferences: cannot serialize: {e}"),
}
let Some(p) = prefs_path() else { return Ok(false) };
if let Some(dir) = p.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
}
let bytes = serde_json::to_vec_pretty(&app.prefs()).map_err(|e| format!("cannot serialize: {e}"))?;
write_prefs(&p, &bytes).map_err(|e| format!("cannot save {}: {e}", p.display()))?;
Ok(true)
}
/// Writes `bytes` to a sibling temporary file and renames it over `path`, so a failed or
@@ -352,6 +351,7 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
set_clipboard_rich: Some(Box::new(clipboard::set_rich)),
clipboard_rtf: Some(Box::new(clipboard::rtf)),
clipboard_formats: Some(Box::new(clipboard::formats)),
save_prefs: Some(Box::new(try_save_prefs)),
..Default::default()
}
}
+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)]
+131
View File
@@ -0,0 +1,131 @@
//! 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, String>,
/// 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>,
}
impl MissingPictures {
/// 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
}
}
+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: String,
/// 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.clone(),
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.clone();
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.clone();
lenient("footnotes", r.read_notes(&p, true));
}
if let Some(p) = part(rt::ENDNOTES) {
r.story = p.clone();
lenient("endnotes", r.read_notes(&p, false));
}
r.story = main.clone();
// 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.total = self.doc.missing_pictures.total.saturating_add(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.clone());
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);
}
+23 -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.clone();
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,7 @@ 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);
self.doc.missing_pictures.part_names.insert(id, path.to_string());
let root = match self.xml(path) {
Ok(Some(r)) => r,
_ => return Some(id),
@@ -146,7 +163,9 @@ impl Reader<'_> {
sc.in_hf = true;
let mut blocks = Blocks::new();
self.hf_depth += 1;
let outer = std::mem::replace(&mut self.story, path.to_string());
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).
+61
View File
@@ -372,3 +372,64 @@ 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);
}
+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;
+149
View File
@@ -0,0 +1,149 @@
//! 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"));
}
+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)?;
+26
View File
@@ -212,6 +212,32 @@ pub(crate) fn record(s: &mut Session, id: &str, before: &Document) {
}
}
/// Builds the tracked paragraph-property change for a command that also changes the paragraph's
/// text. The ordinary after-command recorder deliberately compares only paragraphs with identical
/// text, so mixed text/paragraph-format transformations use this before assigning their props.
/// Made once per command, before it creates revisions: it keeps the revisions as they were then
/// (all `Recorder::own` needs), instead of copying the list for every paragraph.
pub(crate) struct ParaPropTracker {
before: Vec<Revision>,
}
impl ParaPropTracker {
pub(crate) fn new(s: &Session) -> Self {
let tracking = s.doc.settings.track_changes && s.prefs.markup.track_formatting;
ParaPropTracker { before: if tracking { s.doc.revisions.clone() } else { Vec::new() } }
}
pub(crate) fn changed(&self, s: &mut Session, before: &ParaProps, after: &ParaProps) -> Option<Box<PropChange<ParaProps>>> {
if !s.doc.settings.track_changes || !s.prefs.markup.track_formatting {
return after.fmt_change.clone();
}
let mut r = Recorder { author: s.author.clone(), date: super::now_iso(), before: &self.before, revisions: &mut s.doc.revisions, rid: None };
r.change(before, after, ParaProps::formatting, |p| &p.fmt_change)
.map(|Replacement(change)| change)
.unwrap_or_else(|| after.fmt_change.clone())
}
}
/// Record the paragraph formatting a command changed on the paragraph at `path` whose text it also
/// changed, which [`record`] doesn't compare: `before` is the paragraph's properties before the
/// command. As in [`record`], the author's own earlier change keeps the formatting before it and
+25
View File
@@ -16,6 +16,7 @@ mod lists;
mod outline;
mod styles;
mod tabs_borders;
mod tools;
use format::{align, asian_typography, cur, direction, line_spacing, ls, set_align, set_props, tog};
pub use format::{cur as selection_para, fmt, indent, outdent};
@@ -26,6 +27,10 @@ use outline::{move_paras, outline_step, sort};
use styles::{apply_style, apply_styles, create_style, delete_style, modify_style, set_visibility, style_id, update_to_match};
pub use tabs_borders::border_sides;
use tabs_borders::{borders, tabs};
use tools::{
blank_between, convert_breaks, indent_to_spaces, remove_blank, remove_extra_spaces, remove_line_breaks, separate_paragraphs, spaces_to_indent,
trim_leading,
};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -270,6 +275,26 @@ pub fn specs() -> Vec<CommandSpec> {
}
sel_result(s)
}),
// Advanced paragraph tools (#5043, as in WPS Office): conversions and cleanup over the
// selection, or the whole document with `scope: "all"`. One undo step per command.
CommandSpec::new("para.convertBreaks", "Convert Line Breaks to Paragraphs", "Home › Paragraph › Paragraph Tools", convert_breaks)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.blankBetween", "Insert Blank Paragraphs Between", "Home › Paragraph › Paragraph Tools", blank_between)
.params(r#"{"scope"?: "selection|all", "after"?: bool (also after the last paragraph)}"#),
CommandSpec::new("para.separateParagraphs", "Separate Paragraphs", "Home › Paragraph › Paragraph Tools", separate_paragraphs)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.removeBlank", "Delete Blank Paragraphs", "Home › Paragraph › Paragraph Tools", remove_blank)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.trimLeadingSpaces", "Remove Leading Spaces", "Home › Paragraph › Paragraph Tools", trim_leading)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.removeExtraSpaces", "Remove Extra Spaces", "Home › Paragraph › Paragraph Tools", remove_extra_spaces)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.spacesToIndent", "Convert Spaces to Indentation", "Home › Paragraph › Paragraph Tools", spaces_to_indent)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.indentToSpaces", "Convert Indents to Spaces", "Home › Paragraph › Paragraph Tools", indent_to_spaces)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.removeLineBreaks", "Remove Line Breaks", "Home › Paragraph › Paragraph Tools", remove_line_breaks)
.params(r#"{"scope"?: "selection|all"}"#),
]
}
+332
View File
@@ -0,0 +1,332 @@
//! Advanced paragraph tools (#5043): converting and cleaning up paragraphs, like WPS Office's
//! paragraph tools. Each command works on the paragraphs the selection covers, or on the whole
//! document with `scope: "all"`, and runs as one undo step. Deletions respect Track Changes.
use serde_json::{Value, json};
use wordcraft_doc::para::LINE_BREAK;
use wordcraft_doc::{Path, Pos, RevisionKind, StoryRef};
use super::{CmdError, CmdResult, Session, p, sel_result};
use crate::cmd::{has_next_para, new_revision, split_para, track_delete};
/// The maximum number of paragraphs one call may touch: the work, and the blank paragraphs a
/// command can add, stay bounded whatever the document says.
const MAX_TOOL_PARAS: usize = 10_000;
/// Leading spaces one paragraph may gain from an indent, whatever the file's indent says.
const MAX_INDENT_SPACES: usize = 1_000;
/// The scope of a tools command: `{"scope": "selection|all"}`, `selection` by default.
/// `all` selects the whole document first, so the command, its undo step and the result all
/// follow the full selection like any other command.
fn scope_all(s: &mut Session, v: &Value) -> Result<(), CmdError> {
match p::str(v, "scope").unwrap_or("selection") {
"selection" => Ok(()),
"all" => {
s.run("select.all", &json!({"whole": true}))?;
Ok(())
}
x => Err(CmdError::Params(format!("unknown scope `{x}` (selection|all)"))),
}
}
/// Refuse a paragraph rewrite that would touch content locked against editing. Commands operate
/// on whole paragraphs, so checking each full paragraph also covers controls outside the exact
/// selection offsets in a selected paragraph.
fn check_target_edits(s: &mut Session, story: StoryRef, paths: &[Path]) -> Result<(), CmdError> {
let ranges: Vec<(Pos, Pos)> = paths
.iter()
.filter_map(|path| {
let len = s.doc.para(story, path)?.len();
Some((Pos { story, path: path.clone(), off: 0 }, Pos { story, path: path.clone(), off: len }))
})
.collect();
for (a, b) in ranges {
crate::cmd::controls::check_edit(s, &a, &b)?;
}
Ok(())
}
/// The paragraphs a command runs over, in document order, and the story they are in.
fn targets(s: &Session) -> Result<(StoryRef, Vec<Path>), CmdError> {
let (a, b) = s.sel.ordered();
let paths = s.doc.para_paths_in(&a, &b);
if paths.is_empty() {
return Err(CmdError::Failed("no paragraphs selected".into()));
}
if paths.len() > MAX_TOOL_PARAS {
return Err(CmdError::Params(format!("select at most {MAX_TOOL_PARAS} paragraphs to process them in one command")));
}
Ok((a.story, paths))
}
/// Deletes `a..b` inside one paragraph, recording a tracked deletion when Track Changes is on.
/// Offsets past the paragraph's end are clamped, so the document is never half-edited.
fn delete_in_para(s: &mut Session, story: StoryRef, path: &Path, a: usize, b: usize) -> Result<(), CmdError> {
let len = s.doc.para(story, path).ok_or_else(|| CmdError::Failed("no paragraph here".into()))?.len();
let (a, b) = (a.min(b).min(len), b.min(len));
if a >= b {
return Ok(());
}
let at = |off: usize| Pos { story, path: path.clone(), off };
crate::cmd::controls::check_delete(s, &at(a), &at(b))?;
if s.doc.settings.track_changes {
track_delete(s, &at(a), &at(b))?;
} else {
s.doc.delete_range(&at(a), &at(b))?;
}
Ok(())
}
/// The deletions to make in one paragraph, as byte ranges, merged where they touch and applied
/// last first so the earlier offsets stay valid.
fn apply_deletions(s: &mut Session, story: StoryRef, path: &Path, mut ranges: Vec<(usize, usize)>) -> Result<(), CmdError> {
ranges.sort();
let mut merged: Vec<(usize, usize)> = Vec::new();
for (a, b) in ranges {
match merged.last_mut() {
Some(last) if a <= last.1 => last.1 = last.1.max(b),
_ => merged.push((a, b)),
}
}
for (a, b) in merged.into_iter().rev() {
delete_in_para(s, story, path, a, b)?;
}
Ok(())
}
/// The characters of the paragraph that are not tracked-deleted, as (byte offset, char). Under
/// Track Changes deleted text stays in `para.text`, so every scan that decides what to delete or
/// count goes through this.
fn live_chars(para: &wordcraft_doc::Paragraph) -> impl Iterator<Item = (usize, char)> + '_ {
para.run_ranges().filter(|(_, c)| c.del.is_none()).flat_map(|(r, _)| para.text[r.clone()].char_indices().map(move |(i, c)| (r.start + i, c)))
}
/// The byte ranges of every manual line break in the paragraph that is not already deleted.
fn break_ranges(para: &wordcraft_doc::Paragraph) -> Vec<(usize, usize)> {
live_chars(para).filter(|(_, c)| *c == LINE_BREAK).map(|(i, _)| (i, i + 1)).collect()
}
/// `para.convertBreaks`: every manual line break (Shift+Enter) in the selection becomes a
/// paragraph break. Paragraphs are handled last first: a split only shifts the paths after it.
pub(super) fn convert_breaks(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
let breaks: usize = paths.iter().filter_map(|path| s.doc.para(story, path)).map(|p| break_ranges(p).len()).sum();
if breaks > MAX_TOOL_PARAS {
return Err(CmdError::Params(format!("convert at most {MAX_TOOL_PARAS} line breaks at once")));
}
for path in paths.iter().rev() {
let Some(para) = s.doc.para(story, path) else { continue };
for (i, _) in break_ranges(para).into_iter().rev() {
let new = split_para(s, &Pos { story, path: path.clone(), off: i })?;
// The break stays at the head of the new paragraph; the paragraph break replaces it.
delete_in_para(s, story, &new.path, 0, 1)?;
}
}
sel_result(s)
}
/// `para.blankBetween`: insert one empty paragraph between the selected paragraphs; `after`
/// adds one blank paragraph after the last selected paragraph too.
pub(super) fn blank_between(s: &mut Session, v: &Value) -> CmdResult {
let after = p::bool(v, "after").unwrap_or(false);
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
let count = paths.len().saturating_sub(1) + usize::from(after);
if count > MAX_TOOL_PARAS {
return Err(CmdError::Params(format!("insert at most {MAX_TOOL_PARAS} blank paragraphs at once")));
}
// Last first, each split at a paragraph's end: a split only shifts the paths after it.
if after {
let last = paths.last().ok_or_else(|| CmdError::Failed("no paragraph selected".into()))?;
let len = s.doc.para(story, last).map(|p| p.len()).unwrap_or(0);
split_para(s, &Pos { story, path: last.clone(), off: len })?;
}
for path in paths[..paths.len().saturating_sub(1)].iter().rev() {
let len = s.doc.para(story, path).map(|p| p.len()).unwrap_or(0);
split_para(s, &Pos { story, path: path.clone(), off: len })?;
}
sel_result(s)
}
/// `para.separateParagraphs`: every selected paragraph becomes a paragraph of its own with a
/// blank line between them.
pub(super) fn separate_paragraphs(s: &mut Session, v: &Value) -> CmdResult {
blank_between(s, &json!({"scope": p::str(v, "scope").unwrap_or("selection"), "after": true}))
}
/// `para.removeBlank`: delete every empty paragraph in the selection.
pub(super) fn remove_blank(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
// Last first: removing a block leaves the paths before it as they are.
for path in paths.iter().rev() {
let Some(para) = s.doc.para(story, path) else { continue };
// A paragraph must be empty, carry no section break and have a paragraph after it in
// its container: a story's, part's or cell's last mark can't be removed, and a paragraph
// before a table can't be joined across it.
if !para.is_empty() || para.section.is_some() || !has_next_para(s, story, path) {
continue;
}
let next = path.with_last(path.last() + 1);
let (a, b) = (Pos { story, path: path.clone(), off: 0 }, Pos { story, path: next, off: 0 });
crate::cmd::controls::check_delete(s, &a, &b)?;
if s.doc.settings.track_changes {
track_delete(s, &a, &b)?;
} else {
s.doc.remove_block(story, path)?;
}
}
sel_result(s)
}
/// The number of leading spaces in the paragraph's text, deleted ones included (the range
/// `trimLeadingSpaces` deletes).
fn leading_spaces(para: &wordcraft_doc::Paragraph) -> usize {
para.text.chars().take_while(|c| *c == ' ').count()
}
/// The byte offsets of the paragraph's leading spaces that are not tracked-deleted.
fn live_leading_spaces(para: &wordcraft_doc::Paragraph) -> Vec<usize> {
live_chars(para).take_while(|(_, c)| *c == ' ').map(|(i, _)| i).collect()
}
/// `para.trimLeadingSpaces`: delete the spaces at the start of each selected paragraph.
pub(super) fn trim_leading(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
let n = leading_spaces(para);
apply_deletions(s, story, &path, vec![(0, n)])?;
}
sel_result(s)
}
/// `para.removeExtraSpaces`: every run of spaces outside the paragraph's start becomes one
/// space. Tabs and page breaks are document structure, not redundant spaces, and stay in place.
/// Leading spaces stay (that is `para.trimLeadingSpaces`).
pub(super) fn remove_extra_spaces(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
// Runs of live spaces (deleted characters are skipped, as if absent). A run at the
// paragraph's start stays; a run inside the text collapses to its last space, and one
// that reaches the paragraph's end to its first.
let mut runs = Vec::new();
let mut run: Vec<usize> = Vec::new();
let mut leading = true;
for (i, c) in live_chars(para) {
if c == ' ' {
run.push(i);
continue;
}
if !leading && run.len() > 1 {
runs.extend(run[..run.len() - 1].iter().map(|&a| (a, a + 1)));
}
run.clear();
leading = false;
}
if !leading && run.len() > 1 {
runs.extend(run[1..].iter().map(|&a| (a, a + 1)));
}
apply_deletions(s, story, &path, runs)?;
}
sel_result(s)
}
/// `para.spacesToIndent`: each paragraph's leading spaces become a first-line indent of 72 pt
/// per space (Word's point-based indent from typing spaces), and are deleted.
pub(super) fn spaces_to_indent(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
let tracker = crate::cmd::fmt_revisions::ParaPropTracker::new(s);
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
let live = live_leading_spaces(para);
let spaces = live.len();
if spaces == 0 {
continue;
}
let current = para.props.indent_first.unwrap_or(0.0).clamp(-1584.0, 1584.0);
let represented = spaces.min(((1584.0 - current).max(0.0) / 72.0).floor() as usize);
if represented == 0 {
continue;
}
let before = para.props.clone();
let mut after = before.clone();
after.indent_first = Some(current + represented as f32 * 72.0);
after.cancel_char_indents(false, false, true);
after.fmt_change = tracker.changed(s, &before, &after);
{
let p = s.doc.para_mut(story, &path)?;
p.props = after;
p.touch();
}
// A space is one byte; deleted spaces in between stay as they are.
apply_deletions(s, story, &path, live[..represented].iter().map(|&a| (a, a + 1)).collect())?;
}
sel_result(s)
}
/// `para.indentToSpaces`: each paragraph's first-line and left indents become leading spaces
/// (72 pt per space, as typing spaces indents), and the indents go to zero.
pub(super) fn indent_to_spaces(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
let tracker = crate::cmd::fmt_revisions::ParaPropTracker::new(s);
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
let pts = para.props.indent_left.unwrap_or(0.0) + para.props.indent_first.unwrap_or(0.0);
let n = (pts / 72.0).round().clamp(0.0, MAX_INDENT_SPACES as f32) as usize;
if n == 0 && para.props.indent_left.unwrap_or(0.0) == 0.0 && para.props.indent_first.unwrap_or(0.0) == 0.0 {
continue;
}
let mut props = s.doc.para(story, &path).map(|p| p.props_at(0)).cloned().unwrap_or_default();
// The revision first: `new_revision` edits `s.doc`, so it runs before the paragraph borrow.
// The spaces are marked as a tracked insertion through `props.ins`, as typing is.
props.fmt_change = None;
props.del = None;
if s.doc.settings.track_changes && n > 0 {
props.ins = Some(new_revision(s, RevisionKind::Insert));
} else {
props.ins = None;
}
let before = s.doc.para(story, &path).map(|p| p.props.clone()).unwrap_or_default();
let mut after = before.clone();
after.indent_first = Some(0.0);
after.indent_left = Some(0.0);
after.cancel_char_indents(true, false, true);
after.fmt_change = tracker.changed(s, &before, &after);
let p = s.doc.para_mut(story, &path)?;
if n > 0 {
p.insert_text(0, &" ".repeat(n), &props)?;
}
p.props = after;
p.touch();
}
sel_result(s)
}
/// `para.removeLineBreaks`: delete every manual line break in the selection, so the lines
/// join into one paragraph.
pub(super) fn remove_line_breaks(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
apply_deletions(s, story, &path, break_ranges(para))?;
}
sel_result(s)
}
+2 -1
View File
@@ -14,7 +14,7 @@ mod enter_tab;
mod unicode;
use autoformat::insert;
use delete::{backspace, delete, delete_word_back, delete_word_fwd};
use delete::{backspace, delete, delete_sentence_back, delete_word_back, delete_word_fwd};
pub use enter_tab::MAX_TYPED_PARAGRAPHS;
use enter_tab::{back_tab, new_paragraph, tab};
pub use unicode::{alt_code_char, oem_code_page, system_code_pages, system_oem_code_page, windows_code_pages};
@@ -36,6 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("text.backspace", "Backspace", "Editing", backspace).key("Backspace"),
CommandSpec::new("text.delete", "Delete", "Editing", delete).key("Delete"),
CommandSpec::new("text.deleteWordBack", "Delete Previous Word", "Editing", delete_word_back).key("Mod+Backspace / Alt+Backspace"),
CommandSpec::new("text.deleteSentenceBack", "Delete Previous Sentence", "Editing", delete_sentence_back).key("Mod+Shift+Backspace"),
CommandSpec::new("text.deleteWordForward", "Delete Next Word", "Editing", delete_word_fwd).key("Mod+Delete / Alt+Delete"),
CommandSpec::new("text.nbsp", "Nonbreaking Space", "Insert › Symbols", |s, _| ins_char(s, NBSP)).key("Mod+Shift+Space"),
CommandSpec::new("text.nbHyphen", "Nonbreaking Hyphen", "Insert › Symbols", |s, _| ins_char(s, NB_HYPHEN)).key("Mod+Shift+-"),
+17
View File
@@ -237,3 +237,20 @@ pub(super) fn delete_word_fwd(s: &mut Session, v: &Value) -> CmdResult {
crate::cmd::controls::restore_placeholder(s)?;
sel_result(s)
}
/// Delete to the current sentence start, or the preceding sentence when exactly at its boundary.
/// Sentence boundaries match Select Sentence. Never remove a paragraph or cell boundary.
pub(super) fn delete_sentence_back(s: &mut Session, v: &Value) -> CmdResult {
if !s.sel.is_collapsed() {
return backspace(s, v);
}
let mut f = s.sel.focus.clone();
let Some(para) = s.doc.para_at(&f) else { return sel_result(s) };
f.off = para.clamp(f.off);
let Some((previous, _)) = para.text.get(..f.off).and_then(|text| text.char_indices().next_back()) else { return sel_result(s) };
let start = para.sentence_at(previous).0;
s.sel = Selection { anchor: Pos { off: start, ..f.clone() }, focus: f };
delete_selection(s)?;
crate::cmd::controls::restore_placeholder(s)?;
sel_result(s)
}
+39
View File
@@ -341,3 +341,42 @@ pub(super) fn customize_ribbon(s: &mut Session, v: &Value) -> CmdResult {
}
Ok(Value::Object(out))
}
/// The rows of an `insert.spreadsheet` call: `values` (a rectangular array of cells) or `csv`.
pub(super) fn spreadsheet_rows(v: &Value) -> Result<Vec<Vec<String>>, CmdError> {
// The CSV fallback is for an absent `values`; an explicit null is a malformed argument.
let Some(values) = v.get("values") else {
return Ok(crate::cmd::mailings::parse_csv(p::str(v, "csv").unwrap_or("A,B,C\n1,2,3\n4,5,6")));
};
let list = values.as_array().ok_or_else(|| CmdError::Params("`values` must be an array of rows".into()))?;
if list.is_empty() || list.len() > 1000 {
return Err(CmdError::Params("`values` needs 1 to 1000 rows".into()));
}
let mut rows: Vec<Vec<String>> = Vec::with_capacity(list.len());
for (r, row) in list.iter().enumerate() {
let cells = row.as_array().ok_or_else(|| CmdError::Params(format!("`values` row {} is not an array", r + 1)))?;
if cells.is_empty() || cells.len() > 63 {
return Err(CmdError::Params(format!("`values` row {} needs 1 to 63 cells", r + 1)));
}
if rows.first().is_some_and(|f| f.len() != cells.len()) {
return Err(CmdError::Params(format!(
"`values` row {} has {} cells, row 1 has {}",
r + 1,
cells.len(),
rows.first().map_or(0, Vec::len)
)));
}
let mut out = Vec::with_capacity(cells.len());
for cell in cells {
out.push(match cell {
Value::String(x) => x.clone(),
Value::Number(x) => x.to_string(),
Value::Bool(x) => x.to_string(),
Value::Null => String::new(),
_ => return Err(CmdError::Params(format!("`values` row {} holds a cell that is not text, a number or a boolean", r + 1))),
});
}
rows.push(out);
}
Ok(rows)
}
+10 -4
View File
@@ -1,5 +1,6 @@
use super::actions::{
customize_keyboard, customize_ribbon, hf_nav, language, macros, preferred_language, quick_parts, search_commands, select_similar,
spreadsheet_rows,
};
use super::autocorrect::autocorrect_options;
use super::compare::{COMPARE_PARAMS, compare};
@@ -213,10 +214,10 @@ pub fn specs() -> Vec<CommandSpec> {
})
.params(r#"{"path": string}"#),
CommandSpec::new("insert.spreadsheet", "Spreadsheet Table", "Insert › Tables", |s, v| {
let csv = p::str(v, "csv").unwrap_or("A,B,C\n1,2,3\n4,5,6");
let rows = crate::cmd::mailings::parse_csv(csv);
let rows = spreadsheet_rows(v)?;
let cols = rows.iter().map(Vec::len).max().unwrap_or(1).clamp(1, 63);
s.run("insert.table", &json!({"rows": rows.len().max(1), "cols": cols, "style": "GridTable1Light"}))?;
let style = p::str(v, "style").unwrap_or("GridTable1Light");
s.run("insert.table", &json!({"rows": rows.len().max(1), "cols": cols, "style": style}))?;
let Some((tp, _, _)) = s.sel.focus.path.cell() else { return sel_result(s) };
let t = s.doc.table_mut(s.sel.focus.story, &tp)?;
for (r, row) in rows.iter().enumerate() {
@@ -226,9 +227,14 @@ pub fn specs() -> Vec<CommandSpec> {
}
}
}
if p::bool(v, "header").unwrap_or(false)
&& let Some(first) = t.rows.first_mut()
{
first.props.header = true;
}
sel_result(s)
})
.params(r#"{"csv"?: string}"#),
.params(r#"{"csv"?: string, "values"?: [[cell, …], …] (rows of equal length, at most 1000 rows × 63 columns; wins over csv), "header"?: bool (first row repeats on each page), "style"?: table style name (default GridTable1Light)}"#),
CommandSpec::new("layout.linkToPrevious", "Link to Previous", "Header & Footer › Navigation", |s, v| {
let header = p::bool(v, "footer").map(|f| !f).unwrap_or(true);
let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize;
+53
View File
@@ -255,6 +255,59 @@ fn proofing_language_options() {
assert!(s.run("review.language", &json!({"lang": "<script>"})).is_err());
}
/// #2242: `insert.spreadsheet` fills a table from `values`, with a repeating header and a style.
#[test]
fn spreadsheet_takes_values_header_and_style() {
let mut s = Session::new(Document::from_text(""));
s.run("insert.spreadsheet", &json!({"values": [["Month", "Cases"], ["Jan", 12], ["Feb", null]], "header": true, "style": "TableGrid"})).unwrap();
let Some(Block::Table(t)) = s.doc.body.iter().map(|b| &**b).find(|b| matches!(b, Block::Table(_))) else { panic!("no table") };
assert_eq!((t.rows.len(), t.rows[0].cells.len()), (3, 2));
assert!(t.rows[0].props.header && !t.rows[1].props.header);
assert_eq!(t.rows[1].cells[1].blocks[0].as_para().unwrap().text, "12");
assert_eq!(t.props.style.as_deref(), Some("TableGrid"));
}
/// An explicit `values: null` is a parameter error, not the legacy CSV sample table: the document
/// and the undo history stay as they were. Only an absent `values` falls back to `csv`.
#[test]
fn spreadsheet_rejects_an_explicit_null_values_without_changing_anything() {
let mut s = Session::new(Document::from_text("keep"));
let depth = s.undo_depth();
for args in [json!({"values": null}), json!({"values": null, "csv": "a,b\n1,2"})] {
assert!(s.run("insert.spreadsheet", &args).is_err(), "{args}");
assert_eq!(s.doc.plain_text(StoryRef::Body), "keep");
assert!(!s.doc.body.iter().any(|b| matches!(&**b, Block::Table(_))));
assert_eq!(s.undo_depth(), depth, "{args}: no undo step");
}
s.run("insert.spreadsheet", &json!({"csv": "a,b\n1,2"})).unwrap();
let Some(Block::Table(t)) = s.doc.body.iter().map(|b| &**b).find(|b| matches!(b, Block::Table(_))) else { panic!("no table") };
assert_eq!((t.rows.len(), t.rows[0].cells.len()), (2, 2), "an absent `values` still takes the csv");
}
/// #2242: malformed `values` is an error, never a panic, and leaves the document alone.
#[test]
fn spreadsheet_rejects_hostile_values() {
let wide: Vec<Value> = (0..64).map(|i| json!(i)).collect();
let many: Vec<Value> = (0..1001).map(|_| json!(["x"])).collect();
for bad in [
json!("x"),
json!(5),
json!([]),
json!(["a"]),
json!([["a", "b"], ["c"]]),
json!([wide]),
json!(many),
json!([[{"a": 1}]]),
json!([[[1]]]),
json!([[]]),
] {
let mut s = Session::new(Document::from_text("keep"));
assert!(s.run("insert.spreadsheet", &json!({"values": bad})).is_err(), "{bad}");
assert_eq!(s.doc.plain_text(StoryRef::Body), "keep");
assert!(!s.doc.body.iter().any(|b| matches!(&**b, Block::Table(_))));
}
}
/// The inspector's removals delete straight from the document, so they aren't tracked: on a
/// document that tracks changes the text would go with no revision to show for it, and the
/// revision trail would be broken behind the user's back. The removal is refused instead,
+145
View File
@@ -1,5 +1,6 @@
//! View tab and status bar: views, zoom, show/hide, panes.
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use wordcraft_doc::{Zoom, ZoomFit};
use wordcraft_layout::{NumeralMode, ViewMode};
@@ -40,6 +41,15 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("view.readPageColor", "Reading Page Color", "Reading › Layout", read_page_color)
.params(r#"{"value": "none" | "sepia" | "inverse"}"#)
.pure(),
CommandSpec::new("view.paperColor", "Screen Paper Color", "View › Display", |s, v| {
let color: PaperColor = serde_json::from_value(v.get("value").cloned().unwrap_or(Value::Null))
.map_err(|_| CmdError::Params("screen paper color must be document, green, cream, or gray".into()))?;
s.prefs.paper_color = color;
if color != PaperColor::Document {
s.view.dark_mode = false;
}
Ok(json!({"paperColor": color}))
}).params(r#"{"value": "document" | "green" | "cream" | "gray"}"#).pure(),
// Print Layout: hide the white space (margins, headers, footers) between pages. Screen only.
CommandSpec::new("view.hideWhiteSpace", "Hide White Space", "View › Show", |s, v| toggle(s, v, |x| &mut x.hide_white_space))
.params(r#"{"value"?: bool}"#)
@@ -141,6 +151,30 @@ pub fn specs() -> Vec<CommandSpec> {
})
.key("Mod+Alt+S")
.pure(),
// View › Window across WordCraft windows (#322): the desktop app's native windows
// perform these (see `Windows`), elsewhere they are disabled.
CommandSpec::new("view.arrangeAll", "Arrange All", "View › Window", |s, _| {
s.ui_requests.push(json!({"windows": "arrange"}));
Ok(json!({"windows": s.windows.others.len() + 1}))
})
.when(windows_available)
.pure(),
CommandSpec::new("view.sideBySide", "View Side by Side", "View › Window", side_by_side)
.params(r#"{"value"?: bool, "window"?: id of the other window (optional when one other window is open)}"#)
.when(|s| match windows_available(s) {
None if s.windows.side_by_side.is_none() => has_other_window(s),
r => r,
})
.pure(),
CommandSpec::new("view.syncScroll", "Synchronous Scrolling", "View › Window", |s, v| {
let Some(with) = s.windows.side_by_side else { return Err(CmdError::Disabled("turn on View Side by Side first".into())) };
s.windows.sync_scroll = p::bool(v, "value").unwrap_or(!s.windows.sync_scroll);
s.ui_requests.push(json!({"windows": "syncScroll", "with": with, "value": s.windows.sync_scroll}));
Ok(json!({"value": s.windows.sync_scroll}))
})
.params(r#"{"value"?: bool}"#)
.when(|s| if s.windows.side_by_side.is_some() { None } else { Some("turn on View Side by Side first") })
.pure(),
// The window fills the screen, OS-level full screen (#980). F11 as in browsers and most
// desktop apps; ⌃⌘F first on macOS, where Ctrl is a key of its own (elsewhere Mod is Ctrl,
// so "Mod+Ctrl+F" would read Ctrl+Ctrl+F). Escape also leaves it. The front end applies it.
@@ -170,6 +204,83 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// View › Window (#322): the other native windows of the app as the window being drawn sees them
/// (by window id, the ids of `ui.inspect`'s `windows`), and its Side by Side pairing. The front
/// end keeps these current as each window draws; elsewhere (web, CLI, MCP without the app) they
/// stay empty and the window commands are disabled.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Windows {
/// The host can list and move its windows (the desktop app).
pub available: bool,
/// The other open windows, by id.
pub others: Vec<OtherWindow>,
/// View Side by Side is on, with this window.
pub side_by_side: Option<u64>,
/// Synchronous Scrolling (only while Side by Side is on).
pub sync_scroll: bool,
}
/// Another open WordCraft window.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct OtherWindow {
pub id: u64,
pub title: String,
}
fn windows_available(s: &Session) -> Option<&'static str> {
if s.windows.available { None } else { Some("only the desktop app has several windows") }
}
fn has_other_window(s: &Session) -> Option<&'static str> {
windows_available(s).or(if s.windows.others.is_empty() { Some("no other WordCraft window is open") } else { None })
}
/// The window a command means: `window`, or the only other one.
fn pick_window(s: &Session, v: &Value) -> Result<u64, CmdError> {
let others = &s.windows.others;
match p::u64(v, "window") {
Some(id) if others.iter().any(|w| w.id == id) => Ok(id),
Some(id) => Err(CmdError::Params(format!("no open window {id}"))),
None => match others.as_slice() {
[only] => Ok(only.id),
[] => Err(CmdError::Disabled("no other WordCraft window is open".into())),
several => {
let list: Vec<String> = several.iter().map(|w| format!("{} ({})", w.id, w.title)).collect();
Err(CmdError::Params(format!("pass `window`: one of {}", list.join(", "))))
}
},
}
}
/// View Side by Side: on with a window (left this one, right that one, scrolling together), or
/// off.
fn side_by_side(s: &mut Session, v: &Value) -> CmdResult {
let on = p::bool(v, "value").unwrap_or(s.windows.side_by_side.is_none() || v.get("window").is_some());
if !on {
if let Some(id) = s.windows.side_by_side.take() {
s.ui_requests.push(json!({"windows": "sideBySide", "off": id}));
}
s.windows.sync_scroll = false;
return Ok(json!({"value": false}));
}
let id = match (s.windows.side_by_side, v.get("window")) {
(Some(id), None) => id,
_ => pick_window(s, v)?,
};
if let Some(old) = s.windows.side_by_side.filter(|old| *old != id) {
s.ui_requests.push(json!({"windows": "sideBySide", "off": old}));
}
// The partner it already has keeps the user's Synchronous Scrolling choice and is not asked
// again; a new pairing starts with scrolling on.
if s.windows.side_by_side != Some(id) {
s.windows.side_by_side = Some(id);
s.windows.sync_scroll = true;
s.ui_requests.push(json!({"windows": "sideBySide", "with": id}));
}
Ok(json!({"value": true, "window": id, "syncScroll": s.windows.sync_scroll}))
}
/// The browser keeps full screen to itself (its own F11); the web app can't ask for it.
fn full_screen_available(_: &Session) -> Option<&'static str> {
cfg!(target_arch = "wasm32").then_some("Use the browser's full screen (F11)")
@@ -234,6 +345,40 @@ fn read_line_spacing(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"spacing": spacing}))
}
/// Screen-only paper presets, saved with user preferences, never in the document.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PaperColor {
#[default]
Document,
Green,
Cream,
Gray,
}
impl PaperColor {
pub const ALL: [Self; 4] = [Self::Document, Self::Green, Self::Cream, Self::Gray];
pub fn label(self) -> &'static str {
match self {
Self::Document => "Document background",
Self::Green => "Soft green",
Self::Cream => "Warm cream",
Self::Gray => "Soft gray",
}
}
pub fn color(self) -> wordcraft_doc::props::Rgb {
use wordcraft_doc::props::Rgb;
match self {
Self::Document => Rgb::WHITE,
Self::Green => Rgb(0xCC, 0xE8, 0xCF),
Self::Cream => Rgb(0xFA, 0xF0, 0xDC),
Self::Gray => Rgb(0xE8, 0xEA, 0xED),
}
}
}
/// Read Mode › View › Page Color, as in Word. Screen only: the document and its own page color
/// are unchanged.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
+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 `***`).
+7 -7
View File
@@ -60,13 +60,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 +74,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;
+9
View File
@@ -7,6 +7,9 @@ use serde::{Deserialize, Serialize};
pub struct Prefs {
/// Word Count includes text boxes, footnotes and endnotes (Word's default).
pub count_notes: bool,
/// On-screen paper for pages without a document background; excluded from saved documents.
#[serde(deserialize_with = "lenient_paper_color")]
pub paper_color: crate::cmd::view::PaperColor,
/// Track Changes Options: what markup shows and how revisions are drawn (per user, as in
/// Word).
pub markup: wordcraft_layout::display::MarkupOptions,
@@ -64,6 +67,7 @@ pub struct Prefs {
impl Default for Prefs {
fn default() -> Self {
Prefs {
paper_color: Default::default(),
count_notes: true,
markup: Default::default(),
print_markup: false,
@@ -105,6 +109,11 @@ fn lenient_default_language<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Op
Ok(value.as_str().and_then(wordcraft_proof::lang::normalize_tag))
}
fn lenient_paper_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result<crate::cmd::view::PaperColor, D::Error> {
let value = serde_json::Value::deserialize(d)?;
Ok(serde_json::from_value(value).unwrap_or_default())
}
/// Ignore an invalid saved Page Color without discarding other preferences.
fn lenient_read_page_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result<crate::cmd::view::ReadPageColor, D::Error> {
let value = serde_json::Value::deserialize(d)?;
+2
View File
@@ -183,6 +183,8 @@ pub struct Session {
pub merge: crate::cmd::mailings::MergeState,
/// Requests from commands to the UI (open a dialog, scroll…), drained by the front end.
pub ui_requests: Vec<Value>,
/// View › Window: the other WordCraft windows and Side by Side (#322).
pub windows: crate::cmd::view::Windows,
/// Read Aloud player (Review › Speech).
pub read_aloud: crate::speech::ReadAloud,
/// Preferences the front end saves between runs.
+2
View File
@@ -250,6 +250,8 @@ impl Session {
author: _,
initials: _,
painter: _,
// What the drawn window knows of the others: its front end sets it as it draws.
windows: _,
status: _,
last_command: _,
recording: _,
+1
View File
@@ -157,6 +157,7 @@ impl Session {
ribbon: crate::RibbonLayout::default(),
editing_lang: None,
read_aloud: Default::default(),
windows: Default::default(),
password,
#[cfg(not(target_arch = "wasm32"))]
recovery: None,
+2
View File
@@ -139,6 +139,8 @@ impl Session {
pending_ime_application_change: _,
ime_origin: _,
read_aloud: _,
// The other windows, like `view`: not part of the document or its history.
windows: _,
prefs: _,
system_region: _,
keymap: _,
+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")
}
+80 -1
View File
@@ -16,6 +16,7 @@ mod find_apostrophes;
mod font_sizes;
mod fonts;
mod ime_noop;
mod para_tools;
mod revision_checks;
mod typing;
mod wordart;
@@ -4682,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!({}),
@@ -4692,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"}),
]
}
@@ -4788,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"}),
@@ -10788,6 +10795,44 @@ fn insert_picture_takes_a_windows_metafile() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn window_commands_need_the_desktop_and_name_a_window() {
use crate::cmd::view::OtherWindow;
let mut s = s();
// Web, CLI, MCP: one window, so View › Window is disabled.
for id in ["view.arrangeAll", "view.sideBySide", "view.syncScroll"] {
assert!(s.run(id, &json!({})).is_err(), "{id} without a window host");
}
s.windows.available = true;
run(&mut s, "view.arrangeAll", json!({}));
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "arrange"})));
assert!(s.run("view.sideBySide", &json!({})).is_err(), "no other window");
s.windows.others = vec![OtherWindow { id: 7, title: "Notes".into() }, OtherWindow { id: 9, title: "Draft".into() }];
let e = s.run("view.sideBySide", &json!({})).unwrap_err().to_string();
assert!(e.contains("7 (Notes)") && e.contains("9 (Draft)"), "several windows: say which: {e}");
assert!(s.run("view.sideBySide", &json!({"window": 8})).is_err(), "an unknown window");
assert!(s.run("view.sideBySide", &json!({"window": "9"})).is_err());
// Side by Side pairs with one window and scrolls together until turned off.
assert!(s.run("view.syncScroll", &json!({})).is_err(), "only while Side by Side is on");
let r = run(&mut s, "view.sideBySide", json!({"window": 7}));
assert_eq!((r["value"].as_bool(), s.windows.side_by_side, s.windows.sync_scroll), (Some(true), Some(7), true));
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "sideBySide", "with": 7})));
run(&mut s, "view.syncScroll", json!({}));
assert!(!s.windows.sync_scroll);
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "syncScroll", "with": 7, "value": false})));
// Asking again for the partner it already has keeps scrolling off and pairs nothing anew.
let r = run(&mut s, "view.sideBySide", json!({"window": 7, "value": true}));
assert_eq!((r["syncScroll"].as_bool(), s.windows.sync_scroll), (Some(false), false));
assert!(s.ui_requests.is_empty());
// Another partner replaces the first; the bare button turns it off.
run(&mut s, "view.sideBySide", json!({"window": 9}));
assert_eq!(std::mem::take(&mut s.ui_requests), [json!({"windows": "sideBySide", "off": 7}), json!({"windows": "sideBySide", "with": 9})]);
run(&mut s, "view.sideBySide", json!({}));
assert_eq!((s.windows.side_by_side, s.windows.sync_scroll), (None, false));
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "sideBySide", "off": 9})));
assert!(!s.dirty, "window commands never touch the document");
}
/// Change Picture must keep the replacement's own format: the part's name and content type
/// come from the extension, so a JPEG stored as `imageN.png` saves declared `image/png` (Word
/// and LibreOffice then show a broken picture).
@@ -12568,6 +12613,40 @@ fn export_png_rejects_a_page_number_that_does_not_fit() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn screen_paper_color_is_a_persistent_preference_not_document_formatting() {
use crate::cmd::view::PaperColor;
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Screen paper test", "raw": true}));
let before = serde_json::to_value(&s.doc).unwrap();
let docx_before = wordcraft_docx::write(&s.doc).unwrap();
s.dirty = false;
let dirty = s.dirty;
s.view.dark_mode = true;
for (value, expected) in
[("green", PaperColor::Green), ("cream", PaperColor::Cream), ("gray", PaperColor::Gray), ("document", PaperColor::Document)]
{
run(&mut s, "view.paperColor", json!({"value": value}));
assert_eq!(s.prefs.paper_color, expected);
assert_eq!(serde_json::to_value(&s.doc).unwrap(), before);
assert_eq!(s.dirty, dirty);
assert_eq!(wordcraft_docx::write(&s.doc).unwrap(), docx_before);
assert!(!s.view.dark_mode, "choosing a tint turns off dark paper");
let saved = serde_json::to_value(&s.prefs).unwrap();
let restored: crate::Prefs = serde_json::from_value(saved).unwrap();
assert_eq!(restored.paper_color, expected);
}
for v in [json!({}), json!({"value": "red"}), json!({"value": 42})] {
assert!(s.run("view.paperColor", &v).is_err());
assert_eq!(s.prefs.paper_color, PaperColor::Document);
}
let legacy: crate::Prefs = serde_json::from_value(json!({})).unwrap();
assert_eq!(legacy.paper_color, PaperColor::Document);
let future: crate::Prefs = serde_json::from_value(json!({"paperColor":"future", "countNotes":false})).unwrap();
assert_eq!(future.paper_color, PaperColor::Document);
assert!(!future.count_notes);
}
/// Font › Emphasis mark puts a mark over (or under) each selected character, and the layout
/// draws one per character; Format state reports it, and none takes it off.
#[test]
+484
View File
@@ -0,0 +1,484 @@
//! Advanced paragraph tools (#5043): conversion and cleanup over the selection or the whole
//! document, one undo step, Track Changes respected.
use serde_json::json;
use wordcraft_doc::{InlineObject, Path, Pos, RevisionKind, SectionProps, StoryRef};
use crate::Session;
fn s() -> Session {
Session::new(wordcraft_doc::Document::new())
}
fn run(s: &mut Session, id: &str, v: serde_json::Value) {
s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}"));
}
fn text(s: &Session) -> String {
s.doc.plain_text(StoryRef::Body)
}
/// Select the whole document (Mod+A's command).
fn select_all(s: &mut Session) {
run(s, "select.all", json!({}));
}
/// One paragraph with `\n` as manual line breaks, typed as text.
fn type_breaks(s: &mut Session, t: &str) {
run(s, "text.insert", json!({"text": t}));
}
#[test]
fn line_breaks_become_paragraphs() {
let mut s = s();
type_breaks(&mut s, "one\ntwo\nthree");
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 1);
select_all(&mut s);
run(&mut s, "para.convertBreaks", json!({"scope": "all"}));
assert_eq!(text(&s), "one\ntwo\nthree", "the breaks are paragraph marks, not text");
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 3);
// One undo step brings the manual line breaks back.
assert!(s.undo());
let p0 = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!(p0.text, "one\ntwo\nthree", "back to one paragraph with its breaks");
}
#[test]
fn blank_paragraphs_go_in_between() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a\nb\nc"}));
select_all(&mut s);
run(&mut s, "para.blankBetween", json!({"scope": "all"}));
assert_eq!(text(&s), "a\n\nb\n\nc");
}
#[test]
fn blank_paragraphs_go_after_too() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a\nb\nc"}));
select_all(&mut s);
run(&mut s, "para.blankBetween", json!({"scope": "all", "after": true}));
assert_eq!(text(&s), "a\n\nb\n\nc\n");
}
#[test]
fn separate_paragraphs_blanks_after_every_one() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a\nb\nc"}));
select_all(&mut s);
run(&mut s, "para.separateParagraphs", json!({"scope": "all"}));
assert_eq!(text(&s), "a\n\nb\n\nc\n");
}
#[test]
fn blank_paragraphs_are_deleted() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a\n\nb\n\n\nc"}));
select_all(&mut s);
run(&mut s, "para.removeBlank", json!({"scope": "all"}));
assert_eq!(text(&s), "a\nb\nc");
}
#[test]
fn a_section_break_paragraph_stays() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a\nb"}));
// The second paragraph carries a section break and is emptied: removing it would drop the
// section, so it stays.
let second = Path::top(1);
let p = s.doc.para_mut(StoryRef::Body, &second).unwrap();
p.text.clear();
p.runs.clear();
p.section = Some(Box::new(SectionProps::default()));
p.touch();
select_all(&mut s);
run(&mut s, "para.removeBlank", json!({"scope": "all"}));
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2, "the section-break paragraph stays");
}
#[test]
fn leading_spaces_are_removed() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": " a\nb\n c"}));
select_all(&mut s);
run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"}));
assert_eq!(text(&s), "a\nb\nc");
}
#[test]
fn extra_spaces_collapse_to_one() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a b c d \n e"}));
select_all(&mut s);
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
assert_eq!(text(&s), "a b c d \n e", "inner runs collapse; the leading spaces of the second paragraph stay");
}
#[test]
fn a_space_run_at_the_end_collapses() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a "}));
select_all(&mut s);
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
assert_eq!(text(&s), "a ");
}
#[test]
fn extra_spaces_collapse_without_removing_tabs_or_page_breaks() {
use wordcraft_doc::para::PAGE_BREAK;
let mut s = s();
run(&mut s, "document.setText", json!({"text": format!("a \t b {PAGE_BREAK} c\t ")}));
select_all(&mut s);
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
assert_eq!(text(&s), format!("a \t b {PAGE_BREAK} c\t "), "only repeated spaces collapse");
}
#[test]
fn indented_spaces_become_an_indent() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": " a\nb"}));
select_all(&mut s);
run(&mut s, "para.spacesToIndent", json!({"scope": "all"}));
assert_eq!(text(&s), "a\nb");
let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!(p.props.indent_first, Some(216.0), "three spaces are 3 x 72 pt");
assert_eq!(s.doc.para(StoryRef::Body, &Path::top(1)).unwrap().props.indent_first, None, "b has no spaces");
}
#[test]
fn spaces_to_indent_keeps_spaces_the_indent_limit_cannot_represent() {
let mut many = s();
run(&mut many, "document.setText", json!({"text": format!("{}a", " ".repeat(24))}));
select_all(&mut many);
run(&mut many, "para.spacesToIndent", json!({"scope": "all"}));
assert_eq!(text(&many), " a", "22 spaces fit in the 1584 pt maximum; two stay as text");
assert_eq!(many.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(1584.0));
let mut nearly_full = s();
run(&mut nearly_full, "document.setText", json!({"text": " a"}));
run(&mut nearly_full, "para.indents", json!({"firstLine": 1548.0}));
select_all(&mut nearly_full);
run(&mut nearly_full, "para.spacesToIndent", json!({"scope": "all"}));
assert_eq!(text(&nearly_full), " a", "less than 72 pt of room cannot replace one space");
assert_eq!(nearly_full.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(1548.0));
}
#[test]
fn indents_become_leading_spaces() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a"}));
run(&mut s, "para.indents", json!({"left": 72.0, "firstLine": 144.0}));
select_all(&mut s);
run(&mut s, "para.indentToSpaces", json!({"scope": "all"}));
assert_eq!(text(&s), " a", "72 + 144 pt become three spaces");
let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!((p.props.indent_left, p.props.indent_first), (Some(0.0), Some(0.0)));
}
#[test]
fn a_hanging_indent_that_sums_to_zero_is_still_removed() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a"}));
run(&mut s, "para.indents", json!({"left": 72.0, "firstLine": -72.0}));
select_all(&mut s);
run(&mut s, "para.indentToSpaces", json!({"scope": "all"}));
assert_eq!(text(&s), "a", "the cancelling indents need no leading spaces");
let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!((p.props.indent_left, p.props.indent_first), (Some(0.0), Some(0.0)));
let mut tracked = Session::new(wordcraft_doc::Document::new());
run(&mut tracked, "document.setText", json!({"text": "a"}));
run(&mut tracked, "para.indents", json!({"left": 72.0, "firstLine": -72.0}));
run(&mut tracked, "review.trackChanges", json!({"value": true}));
select_all(&mut tracked);
run(&mut tracked, "para.indentToSpaces", json!({"scope": "all"}));
assert!(!tracked.doc.revisions.iter().any(|r| r.kind == RevisionKind::Insert), "no inserted text means no insert revision");
assert!(tracked.doc.revisions.iter().any(|r| r.kind == RevisionKind::Format), "the removed hanging indent is tracked as formatting");
}
#[test]
fn cleanup_keeps_fields_footnote_anchors_and_mixed_formatting() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "aa bb "}));
s.sel = crate::Selection { anchor: Pos::body(0, 4), focus: Pos::body(0, 6) };
run(&mut s, "format.bold", json!({"value": true}));
s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body));
run(&mut s, "insert.field", json!({"instr": "AUTHOR", "result": "Ada"}));
let note = s.run("references.footnote", &json!({"text": "keep this note"})).unwrap()["id"].as_u64().unwrap() as u32;
s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body));
run(&mut s, "text.insert", json!({"text": " tail"}));
select_all(&mut s);
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!((p.props_of_char(3).bold, p.props_of_char(4).bold), (Some(true), Some(true)), "the bold run moved but kept its formatting");
assert!(p.object_offsets().iter().any(|off| matches!(p.object_at(*off), Some(InlineObject::Field { .. }))), "the field stays");
assert!(p.object_offsets().iter().any(|off| matches!(p.object_at(*off), Some(InlineObject::NoteRef { .. }))), "the footnote anchor stays");
assert!(s.doc.parts.contains_key(&note), "the anchored footnote story stays");
}
#[test]
fn cleanup_inside_a_table_keeps_the_table_and_its_cell() {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
run(&mut s, "text.insert", json!({"text": "a b"}));
select_all(&mut s);
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
let table = s.doc.body.iter().find_map(|block| block.as_table()).expect("the table stays");
let cell = table.rows[0].cells[0].blocks.iter().find_map(|block| block.as_para()).expect("the cell paragraph stays");
assert_eq!(cell.text, "a b");
}
#[test]
fn whole_document_scope_from_a_table_cell_reaches_the_body() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "body text"}));
s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body));
run(&mut s, "insert.table", json!({"rows": 1, "cols": 2}));
let table_index = s.doc.body.iter().position(|block| block.as_table().is_some()).expect("the table was inserted") as u32;
s.sel = crate::Selection::caret(Pos { story: StoryRef::Body, path: Path(vec![table_index, 0, 0, 0]), off: 0 });
run(&mut s, "text.insert", json!({"text": "cell text"}));
assert!(s.sel.focus.path.depth() > 0, "the caret is inside the table");
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
assert!(text(&s).contains("body text"), "body text outside the table was cleaned too: {}", text(&s));
let table = s.doc.body.iter().find_map(|block| block.as_table()).expect("the table stays");
let cell = table.rows[0].cells[0].blocks.iter().find_map(|block| block.as_para()).expect("the cell stays");
assert_eq!(cell.text, "cell text");
}
#[test]
fn paragraph_tools_refuse_content_locked_controls() {
for (text, command) in [("a b", "para.removeExtraSpaces"), ("a\nb", "para.convertBreaks")] {
let mut s = s();
type_breaks(&mut s, text);
select_all(&mut s);
run(&mut s, "developer.richText", json!({"tag": "locked"}));
run(&mut s, "developer.properties", json!({"control": "locked", "lockEdit": true}));
select_all(&mut s);
let before = s.doc.clone();
let error = s.run(command, &json!({"scope": "all"})).unwrap_err();
assert!(error.to_string().contains("can't be edited"), "{command}: {error}");
assert_eq!(s.doc, before, "{command} changed locked content");
}
}
#[test]
fn line_breaks_are_removed() {
let mut s = s();
type_breaks(&mut s, "one\ntwo\nthree");
select_all(&mut s);
run(&mut s, "para.removeLineBreaks", json!({"scope": "all"}));
assert_eq!(text(&s), "onetwothree");
}
#[test]
fn the_selection_is_the_scope() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a b\nc d"}));
// Only the first paragraph: select from its start to its end.
s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 4) };
run(&mut s, "para.removeExtraSpaces", json!({}));
assert_eq!(text(&s), "a b\nc d", "the second paragraph is untouched");
}
#[test]
fn structural_tools_do_not_cross_the_selection() {
let mut breaks = s();
type_breaks(&mut breaks, "one\ntwo");
run(&mut breaks, "text.newParagraph", json!({}));
run(&mut breaks, "text.insert", json!({"text": "outside"}));
breaks.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 7) };
run(&mut breaks, "para.convertBreaks", json!({}));
let last = breaks.doc.para_paths(StoryRef::Body).last().cloned().unwrap();
assert_eq!(breaks.doc.para(StoryRef::Body, &last).unwrap().text, "outside");
let mut between = s();
run(&mut between, "document.setText", json!({"text": "a\nb\nc"}));
between.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 1) };
run(&mut between, "para.blankBetween", json!({}));
assert_eq!(text(&between), "a\n\nb\nc", "only a and b get a blank between them");
let mut separate = s();
run(&mut separate, "document.setText", json!({"text": "a\nb\nc"}));
separate.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 1) };
run(&mut separate, "para.separateParagraphs", json!({}));
assert_eq!(text(&separate), "a\n\nb\n\nc", "the blank after b stays before the unselected c");
let mut blank = s();
run(&mut blank, "document.setText", json!({"text": "a\n\nb\n\nc"}));
blank.sel = crate::Selection::caret(Pos::body(1, 0));
run(&mut blank, "para.removeBlank", json!({}));
assert_eq!(text(&blank), "a\nb\n\nc", "the other blank paragraph is outside the selection");
}
#[test]
fn indent_space_conversions_are_reversible_track_changes() {
let mut to_indent = s();
run(&mut to_indent, "document.setText", json!({"text": " a"}));
run(&mut to_indent, "review.trackChanges", json!({"value": true}));
select_all(&mut to_indent);
run(&mut to_indent, "para.spacesToIndent", json!({"scope": "all"}));
assert_eq!(to_indent.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(144.0));
run(&mut to_indent, "review.rejectAll", json!({}));
let p = to_indent.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!((p.text.as_str(), p.props.indent_first), (" a", None));
let mut to_spaces = s();
run(&mut to_spaces, "document.setText", json!({"text": "a"}));
run(&mut to_spaces, "para.indents", json!({"firstLine": 144.0}));
run(&mut to_spaces, "review.trackChanges", json!({"value": true}));
select_all(&mut to_spaces);
run(&mut to_spaces, "para.indentToSpaces", json!({"scope": "all"}));
run(&mut to_spaces, "review.rejectAll", json!({}));
let p = to_spaces.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!((p.text.as_str(), p.props.indent_first), ("a", Some(144.0)));
let mut accepted = s();
run(&mut accepted, "document.setText", json!({"text": "a"}));
run(&mut accepted, "para.indents", json!({"firstLine": 144.0}));
run(&mut accepted, "review.trackChanges", json!({"value": true}));
select_all(&mut accepted);
run(&mut accepted, "para.indentToSpaces", json!({"scope": "all"}));
run(&mut accepted, "review.acceptAll", json!({}));
let p = accepted.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!((p.text.as_str(), p.props.indent_first), (" a", Some(0.0)));
}
#[test]
fn indent_to_spaces_does_not_inherit_revision_marks() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) };
run(&mut s, "format.bold", json!({"value": true}));
run(&mut s, "text.delete", json!({}));
run(&mut s, "review.trackChanges", json!({"value": false}));
run(&mut s, "para.indents", json!({"firstLine": 72.0}));
select_all(&mut s);
run(&mut s, "para.indentToSpaces", json!({"scope": "all"}));
let space = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props_of_char(0);
assert_eq!((space.ins, space.del, space.fmt_change.as_ref().map(|_| ())), (None, None, None));
}
#[test]
fn converting_too_many_line_breaks_is_atomic() {
let original = format!("{}end", "x\n".repeat(10_001));
let mut s = s();
type_breaks(&mut s, &original);
select_all(&mut s);
let before = s.doc.clone();
let error = s.run("para.convertBreaks", &json!({"scope": "all"})).unwrap_err();
assert!(error.to_string().contains("at most"), "{error}");
assert_eq!(s.doc, before, "the refused conversion is all-or-nothing");
}
#[test]
fn repeating_a_tracked_deletion_creates_no_empty_revision() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": " a"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
select_all(&mut s);
run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"}));
let revisions = s.doc.revisions.len();
run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"}));
assert_eq!(s.doc.revisions.len(), revisions, "already-deleted spaces create no revision");
}
#[test]
fn deletions_are_recorded_under_track_changes() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a b\n c"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
select_all(&mut s);
run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"}));
// The text stays marked deleted, not gone, as Word records the deletion.
assert_eq!(text(&s), "a b\n c", "tracked deletions keep their text");
assert!(s.doc.revisions.iter().any(|r| r.kind == RevisionKind::Delete));
run(&mut s, "review.acceptAll", json!({}));
assert_eq!(text(&s), "a b\nc", "accepting removes the leading space");
}
/// Tracked-delete the bytes `a..b` of the first body paragraph (Track Changes must be on).
fn track_delete_in_first(s: &mut Session, a: usize, b: usize) {
let path = s.doc.para_paths(StoryRef::Body)[0].clone();
let at = |off: usize| Pos { story: StoryRef::Body, path: path.clone(), off };
crate::cmd::track_delete(s, &at(a), &at(b)).expect("tracked delete");
}
fn first_indent(s: &Session) -> Option<f32> {
let path = s.doc.para_paths(StoryRef::Body)[0].clone();
s.doc.para(StoryRef::Body, &path).expect("paragraph").props.indent_first
}
fn first_deleted(s: &Session, off: usize) -> bool {
let path = s.doc.para_paths(StoryRef::Body)[0].clone();
s.doc.para(StoryRef::Body, &path).expect("paragraph").props_of_char(off).del.is_some()
}
#[test]
fn remove_extra_spaces_skips_tracked_deleted_spaces() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a b"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
track_delete_in_first(&mut s, 2, 3); // the second space is already deleted
select_all(&mut s);
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
run(&mut s, "review.acceptAll", json!({}));
assert_eq!(text(&s), "a b", "the one live space survives");
// Three spaces, the middle one already deleted: of the two live ones the last is kept.
let mut s = self::s();
run(&mut s, "document.setText", json!({"text": "a b"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
track_delete_in_first(&mut s, 2, 3);
select_all(&mut s);
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
assert!(first_deleted(&s, 1) && first_deleted(&s, 2), "the first and the already deleted space are deleted");
assert!(!first_deleted(&s, 3), "the last live space stays");
}
#[test]
fn spaces_to_indent_twice_does_not_count_deleted_spaces_again() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": " x"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
select_all(&mut s);
run(&mut s, "para.spacesToIndent", json!({"scope": "all"}));
assert_eq!(first_indent(&s), Some(144.0));
run(&mut s, "para.spacesToIndent", json!({"scope": "all"}));
assert_eq!(first_indent(&s), Some(144.0), "the deleted spaces are not indented a second time");
}
#[test]
fn convert_breaks_leaves_a_tracked_deleted_break_alone() {
let mut s = s();
type_breaks(&mut s, "a\nb\nc");
run(&mut s, "review.trackChanges", json!({"value": true}));
track_delete_in_first(&mut s, 1, 2);
select_all(&mut s);
run(&mut s, "para.convertBreaks", json!({"scope": "all"}));
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2, "only the live break becomes a paragraph break");
}
#[test]
fn junk_scope_is_refused() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a b"}));
select_all(&mut s);
let e = s.run("para.removeExtraSpaces", &json!({"scope": "junk"})).unwrap_err();
assert!(format!("{e}").contains("scope"), "{e}");
assert_eq!(text(&s), "a b", "the document is unchanged");
}
#[test]
fn too_many_paragraphs_are_refused_before_any_change() {
let text = "x\n".repeat(15_000);
let mut s = Session::new(wordcraft_doc::Document::from_text(&text));
select_all(&mut s);
let e = s.run("para.blankBetween", &json!({"scope": "all"})).unwrap_err();
assert!(format!("{e}").contains("at most"), "{e}");
let kept = s.doc.para_paths(StoryRef::Body).len();
assert!((15_000..=15_001).contains(&kept), "nothing changed, {kept}");
}
+82
View File
@@ -3876,6 +3876,88 @@ fn backspace_cannot_join_into_locked_content_control() {
assert_eq!(s.doc, before);
}
// #3693 is an additional editing shortcut, not a claim of Word's default key behaviour.
#[test]
fn delete_previous_sentence_respects_caret_boundaries_and_unicode() {
for (text, before, expected) in [
("First sentence. Second sentence.", "First sentence. Second sentence.", "First sentence. "),
("First sentence. Second sentence.", "First sentence. Sec", "First sentence. ond sentence."),
("First sentence. Second sentence.", "First sentence. ", "Second sentence."),
("Grüße! Überall café.", "Grüße! Überall café.", "Grüße! "),
("No punctuation here", "No punctuation", " here"),
("One. Two.", "", "One. Two."),
("", "", ""),
] {
let mut s = s();
run(&mut s, "text.insert", json!({"text": text, "raw": true}));
let mut pos = s.sel.focus.clone();
pos.off = before.len();
s.sel = crate::Selection::caret(pos);
run(&mut s, "text.deleteSentenceBack", json!({}));
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, expected, "{text:?} at {before:?}");
if expected != text {
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, text);
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, expected);
}
}
}
#[test]
fn delete_previous_sentence_keeps_paragraph_boundaries_and_deletes_selections() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "First.\nSecond sentence.", "raw": true, "paragraphs": true}));
let end = s.sel.focus.clone();
s.sel = crate::Selection::caret(Pos { off: 0, ..end.clone() });
let count = s.doc.body.len();
run(&mut s, "text.deleteSentenceBack", json!({}));
assert_eq!(s.doc.body.len(), count);
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Second sentence.");
s.sel = crate::Selection { anchor: Pos { off: 0, ..end.clone() }, focus: Pos { off: 6, ..end } };
run(&mut s, "text.deleteSentenceBack", json!({}));
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, " sentence.");
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Second sentence.");
}
#[test]
fn delete_previous_sentence_tracks_changes_and_respects_read_only() {
for accept in [true, false] {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "One. Two.", "raw": true}));
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "text.deleteSentenceBack", json!({}));
let changes = s.run("review.changes", &json!({})).unwrap();
assert!(changes.as_array().unwrap().iter().any(|c| c["kind"] == "delete"));
run(&mut s, if accept { "review.acceptAll" } else { "review.rejectAll" }, json!({}));
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, if accept { "One. " } else { "One. Two." });
}
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Keep this.", "raw": true}));
run(&mut s, "review.restrict", json!({"mode": "readOnly"}));
assert!(s.run("text.deleteSentenceBack", &json!({})).is_err());
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Keep this.");
}
#[test]
fn delete_previous_sentence_does_not_cross_a_table_cell_boundary() {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 1, "cols": 2}));
run(&mut s, "text.insert", json!({"text": "First cell.", "raw": true}));
run(&mut s, "text.tab", json!({}));
run(&mut s, "text.insert", json!({"text": "Second cell.", "raw": true}));
let end = s.sel.focus.clone();
s.sel = crate::Selection::caret(Pos { off: 0, ..end.clone() });
let before = s.doc.plain_text(StoryRef::Body);
run(&mut s, "text.deleteSentenceBack", json!({}));
assert_eq!(s.doc.plain_text(StoryRef::Body), before);
s.sel = crate::Selection::caret(end);
run(&mut s, "text.deleteSentenceBack", json!({}));
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "");
assert!(s.doc.plain_text(StoryRef::Body).contains("First cell."));
}
// English spelling from SCOWL 2026 (CP2): each English reads its own SCOWL dictionary, with
// `en-variants.txt` deciding the other dialect's spellings. Typed text, as a person types it.
+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");
}
+4
View File
@@ -8,6 +8,10 @@ description = "WordCraft fonts: font database (bundled OFL + system), metrics, g
[lints]
workspace = true
[features]
# Opt-in test harness support; enabling this alone never changes the application database.
test-support = []
[dependencies]
kurbo = { workspace = true }
skrifa = { workspace = true }
+37 -5
View File
@@ -7,7 +7,9 @@ use std::sync::{Arc, Mutex, RwLock};
use kurbo::BezPath;
use skrifa::instance::{Location, LocationRef, Size};
use skrifa::outline::{DrawSettings, Engine, Hinting, HintingInstance, HintingOptions, OutlinePen, Target};
use skrifa::outline::{
DrawSettings, Engine, GlyphStyles, Hinting, HintingInstance, HintingOptions, OutlineGlyphFormat, OutlinePen, SmoothMode, Target,
};
use skrifa::raw::FileRef;
use skrifa::string::StringId;
use skrifa::{GlyphId, MetadataProvider};
@@ -39,10 +41,16 @@ use names::{MAX_NAME_CHARS, file_face_names, font_faces, mini_font, name};
#[cfg(test)]
pub(crate) use names::{last_resort_face, legacy_family};
static GLOBAL: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
/// Process-wide font database.
pub struct FontDb {
faces: RwLock<Vec<Arc<FontFace>>>,
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
/// Screen-hinted outlines per (face, glyph, ppem bits); `None` where the face can't be hinted.
hinted: Mutex<HashMap<(u32, u32, u32), Option<Arc<BezPath>>>>,
/// Light hinting instances per (face, ppem bits); `None` where the face can't be hinted.
hinters: Mutex<HashMap<(u32, u32), Option<Arc<HintingInstance>>>>,
/// Fallback faces found per (character, excluded face), valid while the face count is the
/// one stored (faces are only ever added). Layout asks once per uncovered character, and the
/// search sorts every loaded face, so without this text in a script the requested font lacks
@@ -90,10 +98,14 @@ impl FontDb {
/// Arabic, so Arabic and Persian text draws without system fonts (#1652) (all empty without
/// `CRAFT_FONTS_DIR`), whose system font scan reads `font_dirs`.
pub fn with_font_dirs(font_dirs: Vec<std::path::PathBuf>) -> Self {
let mut faces = Vec::new();
let others = crate::CRAFT_FONTS.iter().filter(|f| !f.scripts.contains(&"Jpan"));
let craft = crate::japanese_document_fonts().into_iter().chain(others).map(|f| f.bytes);
for data in BUNDLED.iter().copied().chain(craft) {
Self::with_static_fonts(font_dirs, BUNDLED.iter().copied().chain(craft))
}
fn with_static_fonts(font_dirs: Vec<std::path::PathBuf>, fonts: impl Iterator<Item = &'static [u8]>) -> Self {
let mut faces = Vec::new();
for data in fonts {
for f in enumerate_faces(data, MAX_FILE_FACES) {
if let Some(mut face) = make_face(FontBytes::Static(data), f.index, f.family, f.style, f.legacy, f.coords) {
face.local_names = f.local;
@@ -104,6 +116,8 @@ impl FontDb {
Self {
faces: RwLock::new(faces),
outlines: Mutex::new(HashMap::new()),
hinted: Mutex::new(HashMap::new()),
hinters: Mutex::new(HashMap::new()),
fallbacks: RwLock::new(FallbackCache::default()),
run_fallbacks: RwLock::new(RunFallbackCache::default()),
#[cfg(not(target_arch = "wasm32"))]
@@ -144,8 +158,26 @@ impl FontDb {
/// first time a lookup by family name needs them (or ahead of time by
/// [`FontDb::scan_in_background`]).
pub fn global() -> &'static FontDb {
static DB: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
DB.get_or_init(|| FontDb::with_font_dirs(system_font_dirs()))
GLOBAL.get_or_init(|| FontDb::with_font_dirs(system_font_dirs()))
}
/// Initialize a test process with only the repository's pinned bundled fonts: no optional
/// craft-fonts, user fonts or system font directories. Call before any global font lookup.
/// Returns false if a normal global database was already initialized; never replaces it.
/// Repeated/concurrent calls are safe. Merely enabling `test-support` changes nothing.
#[cfg(feature = "test-support")]
pub fn init_test_fonts() -> bool {
static INIT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*INIT.get_or_init(|| {
let mut installed = false;
GLOBAL.get_or_init(|| {
installed = true;
let db = Self::with_static_fonts(Vec::new(), BUNDLED.iter().copied());
db.set_system_fallback(false);
db
});
installed
})
}
fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
+47 -4
View File
@@ -1,7 +1,7 @@
use super::names::{LocalName, east_asian_font};
use super::{
Arc, AtomicU32, BezPath, DrawSettings, Engine, GlyphId, Hinting, HintingInstance, HintingOptions, Location, LocationRef, MetadataProvider,
Ordering, OutlinePen, RwLock, Size, Target,
Arc, AtomicU32, BezPath, DrawSettings, Engine, GlyphId, GlyphStyles, Hinting, HintingInstance, HintingOptions, Location, LocationRef,
MetadataProvider, Ordering, OutlineGlyphFormat, OutlinePen, RwLock, Size, SmoothMode, Target,
};
/// The family used when a requested family is unknown (the UI sans).
@@ -77,6 +77,8 @@ pub struct FontFace {
pub(super) bmp: std::sync::OnceLock<Box<[u64]>>,
// Some legacy CJK fonts assemble their contours with TrueType instructions.
pub(super) outline_hinter: std::sync::OnceLock<Option<HintingInstance>>,
/// The autohinter's analysis of the font for screen hinting, made once for every size.
pub(super) glyph_styles: std::sync::OnceLock<GlyphStyles>,
/// Hinted draws so far, those that ran too long, and whether that turned hinting off
/// ([`FontFace::count_hinted`]).
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
@@ -288,6 +290,42 @@ impl FontFace {
})
.as_ref()
}
/// Glyph `gid`'s outline hinted for screen text at `ppem` device pixels per em, in device
/// pixels, y-down (flipped), or `None` when the face can't be hinted. The hinting is light,
/// as FreeType's `hintslight`: horizontal edges (baseline, x-height, cap height) move onto
/// whole pixels, nothing moves sideways, so advances and layout stay as they are.
pub(super) fn hinted_glyph_outline(&self, gid: u32, hinter: &HintingInstance) -> Option<BezPath> {
let f = self.skrifa()?;
let g = f.outline_glyphs().get(GlyphId::new(gid))?;
let mut pen = FlipPen { path: BezPath::new(), room: usize::MAX, over: false, hinted: true };
g.draw(DrawSettings::hinted(hinter, false), &mut pen).map_err(|e| log::warn!("Cannot hint glyph {gid} in {:?}: {e}", self.family)).ok()?;
Some(pen.path)
}
/// A light hinting instance for `ppem` device pixels per em, with the engine FreeType's light
/// hinting picks: the autohinter for TrueType outlines (their own instructions are ignored;
/// measured against Firefox on Linux, the autohinter matches its rows of pixels and the
/// TrueType instructions don't), the font's own hints for PostScript (CFF) outlines, and for
/// fonts whose glyphs need their instructions to be assembled at all their instructions with
/// the monochrome target, as skrifa requires for them.
pub(super) fn screen_hinter(&self, ppem: f32) -> Option<HintingInstance> {
let f = self.skrifa()?;
let outlines = f.outline_glyphs();
let light = Target::Smooth { mode: SmoothMode::Light, symmetric_rendering: true, preserve_linear_metrics: true };
let (engine, target) = if outlines.require_interpreter() {
// FreeType's "tricky" fonts (old CJK fonts that assemble glyphs with their
// instructions): skrifa asks for the interpreter with the monochrome target, under
// which the instructions may move points sideways, or glyphs come apart.
(Engine::Interpreter, Target::Mono)
} else if outlines.format() == Some(OutlineGlyphFormat::Glyf) {
(Engine::Auto(Some(self.glyph_styles.get_or_init(|| GlyphStyles::new(&outlines)).clone())), light)
} else {
(Engine::Interpreter, light)
};
HintingInstance::new(&outlines, Size::new(ppem), self.location(), HintingOptions { engine, target })
.map_err(|e| log::warn!("Cannot hint {:?} at {ppem} px: {e}", self.family))
.ok()
}
/// Is `family` this face's typographic, legacy, full or PostScript name?
pub fn named(&self, family: &str) -> bool {
self.family.eq_ignore_ascii_case(family)
@@ -358,13 +396,18 @@ impl FontFace {
pub(super) static NEXT_ID: AtomicU32 = AtomicU32::new(1);
pub(super) const OUTLINE_CACHE_MAX: usize = 50_000;
/// Hinting instances kept (one per face and text size on screen).
pub(super) const HINTER_CACHE_MAX: usize = 256;
/// Sizes hinted, in device pixels per em: below, text is too small to read anyway; above, the
/// half-pixel hinting moves is invisible (and each size would cost a cache entry).
pub(super) const HINT_PPEM: std::ops::RangeInclusive<f32> = 4.0..=96.0;
/// Collects an outline flipped to y-down, keeping at most `room` path elements (`over` once it
/// had to drop one).
pub(super) struct FlipPen {
pub(super) path: BezPath,
room: usize,
over: bool,
pub(super) room: usize,
pub(super) over: bool,
/// Drawn with the face's TrueType interpreter.
pub(super) hinted: bool,
}
+38 -1
View File
@@ -1,4 +1,4 @@
use super::face::{FALLBACK_FAMILY, FontBytes, FontFace, OUTLINE_CACHE_MAX};
use super::face::{FALLBACK_FAMILY, FontBytes, FontFace, HINT_PPEM, HINTER_CACHE_MAX, OUTLINE_CACHE_MAX};
#[cfg(not(target_arch = "wasm32"))]
use super::fallback_data::read_fallback_file;
#[cfg(not(target_arch = "wasm32"))]
@@ -227,4 +227,41 @@ impl FontDb {
cache.insert(key, p.clone());
p
}
/// Glyph outline hinted for the screen at `ppem` device pixels per em ([`HINT_PPEM`]), in
/// device pixels, y-down, for drawing at a baseline on a whole pixel; cached per (face, glyph,
/// size). `None` for sizes out of range and faces that can't be hinted: draw [`Self::outline`].
pub fn hinted_outline(&self, face: &FontFace, gid: u32, ppem: f32) -> Option<Arc<BezPath>> {
if !HINT_PPEM.contains(&ppem) {
return None;
}
let key = (face.id, gid, ppem.to_bits());
if let Some(p) = self.hinted.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return p.clone();
}
let hinter = {
let hkey = (face.id, ppem.to_bits());
let known = self.hinters.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&hkey).cloned();
match known {
Some(h) => h,
None => {
// Built outside the lock: running a font's hinting programs takes a while.
let h = face.screen_hinter(ppem).map(Arc::new);
let mut cache = self.hinters.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if cache.len() >= HINTER_CACHE_MAX {
cache.clear();
}
cache.insert(hkey, h.clone());
h
}
}
};
let p = hinter.and_then(|h| face.hinted_glyph_outline(gid, &h)).map(Arc::new);
let mut cache = self.hinted.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if cache.len() >= OUTLINE_CACHE_MAX {
cache.clear();
}
cache.insert(key, p.clone());
p
}
}
+1
View File
@@ -394,6 +394,7 @@ pub(super) fn make_face(
index,
bmp: std::sync::OnceLock::new(),
outline_hinter: std::sync::OnceLock::new(),
glyph_styles: std::sync::OnceLock::new(),
hint_draws: Default::default(),
hint_slow: Default::default(),
hinting_off: Default::default(),
+62
View File
@@ -38,6 +38,7 @@ fn kai_contours_include_complete_character_body() {
}
}
use super::face::FlipPen;
use super::*;
#[test]
@@ -92,6 +93,67 @@ fn run_fallback_is_cached_per_char_set() {
assert!(db.run_fallbacks.read().unwrap().map.is_empty());
}
#[test]
fn screen_hinting_puts_horizontal_edges_on_whole_pixels_and_moves_nothing_sideways() {
let db = FontDb::global();
let face = db.face(FALLBACK_FAMILY, "Regular");
let x = face.glyph_for('x');
// 11 pt at 100 % zoom on a 145 % desktop: a fractional size, as on most screens.
let ppem = 11.0 * 96.0 / 72.0 * 1.45;
let hinted = db.hinted_outline(&face, x, ppem).expect("Source Sans 3 can be hinted");
let plain = db.outline(&face, x);
let k = f64::from(ppem) / face.upem;
let (h, u) = (kurbo::Shape::bounding_box(&*hinted), kurbo::Shape::bounding_box(&*plain));
// The x-height (the top, y-down) and the baseline sit on the pixel grid…
assert!((h.y0 - h.y0.round()).abs() < 0.02, "x-height at {}", h.y0);
assert!((h.y1 - h.y1.round()).abs() < 0.02, "baseline at {}", h.y1);
assert!((u.y0 * k - (u.y0 * k).round()).abs() > 0.02, "the unhinted x-height is off the grid: {}", u.y0 * k);
// …and the sides stay where the font puts them, so advances and line breaks don't change.
assert!((h.x0 - u.x0 * k).abs() < 0.01 && (h.x1 - u.x1 * k).abs() < 0.01, "{h:?} vs {:?}", u.scale_from_origin(k));
// Cached per size.
assert!(Arc::ptr_eq(&hinted, &db.hinted_outline(&face, x, ppem).expect("cached")));
}
#[test]
fn screen_hinting_uses_the_autohinter_even_for_fonts_with_their_own_instructions() {
// FreeType's light hinting (`hintslight`, Firefox on Linux) runs its autohinter on
// TrueType outlines and ignores their instructions: measured against Firefox, the
// instructions drew Arial's rows elsewhere and heavier.
let db = FontDb::global();
let face = db.face(FALLBACK_FAMILY, "Regular");
let font = face.skrifa().unwrap();
let outlines = font.outline_glyphs();
assert!(outlines.prefer_interpreter(), "Source Sans 3 carries TrueType instructions (ttfautohint)");
let ppem = 11.0 * 96.0 / 72.0 * 1.45;
let target = Target::Smooth { mode: SmoothMode::Light, symmetric_rendering: true, preserve_linear_metrics: true };
let drawn = |engine: Engine, gid: u32| {
let h = HintingInstance::new(&outlines, Size::new(ppem), face.location(), HintingOptions { engine, target }).unwrap();
let mut pen = FlipPen { path: BezPath::new(), room: usize::MAX, over: false, hinted: true };
outlines.get(GlyphId::new(gid)).unwrap().draw(DrawSettings::hinted(&h, false), &mut pen).unwrap();
pen.path
};
let mut differs = false;
for c in "Every Thursday".chars().filter(|c| !c.is_whitespace()) {
let gid = face.glyph_for(c);
let ours = db.hinted_outline(&face, gid, ppem).unwrap();
assert_eq!(*ours, drawn(Engine::Auto(None), gid), "{c}: the autohinter's outline");
differs |= *ours != drawn(Engine::Interpreter, gid);
}
assert!(differs, "the font's own instructions would draw it differently");
}
#[test]
fn screen_hinting_refuses_hostile_sizes() {
let db = FontDb::global();
let face = db.face(FALLBACK_FAMILY, "Regular");
let x = face.glyph_for('x');
for ppem in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -12.0, 0.0, 1.0, 3.99, 96.01, 1e9] {
assert!(db.hinted_outline(&face, x, ppem).is_none(), "{ppem}");
}
// A glyph id past the font's glyphs has no outline to hint.
assert!(db.hinted_outline(&face, u32::MAX, 20.0).is_none_or(|o| o.elements().is_empty()));
}
/// The issue's run (#1549): Thai letters and digits, Latin letters, digits and the baht sign.
const THAI_RUN: &str = "[D] น้ำ ค่ำ ปั้น ฝั่ง ปู่ ๑๒๓๔๕๖๗๘๙๐ ฿1,250.50";
+34
View File
@@ -2,6 +2,7 @@
#[allow(unused_imports)]
use std::path::{Path, PathBuf};
use crate::fontdb::face::FlipPen;
use crate::fontdb::*;
const FAMILY: &str = "Sysfont Sans3";
@@ -420,6 +421,39 @@ fn a_family_partly_loaded_by_a_character_fallback_is_loaded_whole_for_a_run() {
assert_eq!(db.read_faces().iter().filter(|f| f.family == "Sysfont Famly").count(), 3);
}
#[test]
fn screen_hinting_runs_a_tricky_fonts_instructions_with_the_monochrome_target() {
// FreeType's "tricky" fonts (old CJK fonts listed by name, such as DynaLab's "cpop") assemble
// their glyphs with their instructions; skrifa requires the interpreter with `Target::Mono`
// for them. A bundled font renamed into the list stands in for one.
let dir = font_dir("tricky");
std::fs::write(dir.join("Tricky.ttf"), renamed_to("SourceSans3-Regular.ttf", "Sysfont cpop1")).unwrap();
let db = FontDb::with_font_dirs(vec![dir]);
let face = db.face("Sysfont cpop1", "Regular");
assert_eq!(face.family, "Sysfont cpop1");
let font = face.skrifa().unwrap();
let outlines = font.outline_glyphs();
assert!(outlines.require_interpreter(), "the name makes it tricky");
let ppem = 11.0 * 96.0 / 72.0 * 1.45;
let drawn = |target: Target, gid: u32| {
let h = HintingInstance::new(&outlines, Size::new(ppem), face.location(), HintingOptions { engine: Engine::Interpreter, target }).unwrap();
let mut pen = FlipPen { path: BezPath::new(), room: usize::MAX, over: false, hinted: true };
outlines.get(GlyphId::new(gid)).unwrap().draw(DrawSettings::hinted(&h, false), &mut pen).unwrap();
pen.path
};
// The target decides whether the instructions may move points sideways (the light target's
// backward compatibility forbids it): this font's instructions only move points vertically,
// so the target itself is checked, and the outlines drawn with it.
assert!(matches!(face.screen_hinter(ppem).unwrap().target(), Target::Mono));
for c in "Every Thursday".chars().filter(|c| !c.is_whitespace()) {
let gid = face.glyph_for(c);
assert_eq!(*db.hinted_outline(&face, gid, ppem).unwrap(), drawn(Target::Mono, gid), "{c}: the monochrome target");
}
// Any other font keeps the light target.
let plain = db.face(FALLBACK_FAMILY, "Regular");
assert!(matches!(plain.screen_hinter(ppem).unwrap().target(), Target::Smooth { mode: SmoothMode::Light, preserve_linear_metrics: true, .. }));
}
#[test]
fn coverage_ranges_hold_exactly_the_mapped_characters() {
let data = renamed("SourceSans3-Regular.ttf");
+31
View File
@@ -0,0 +1,31 @@
//! Separate process: verifies the global test fixture before any other lookup initializes it.
#![cfg(feature = "test-support")]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use wordcraft_fonts::FontDb;
#[test]
fn fixed_fonts_exclude_system_and_optional_faces() {
let threads: Vec<_> = (0..8).map(|_| std::thread::spawn(FontDb::init_test_fonts)).collect();
assert!(threads.into_iter().all(|t| t.join().unwrap()));
let db = FontDb::global();
let families = [
"Inter",
"Inter Medium",
"Inter SemiBold",
"JetBrains Mono",
"Source Sans 3",
"Source Sans 3 Semibold",
"Source Serif 4",
"Source Serif 4 Semibold",
];
assert_eq!(db.families(), families);
assert_eq!(db.load_system_fonts(), 0);
assert_eq!(db.families(), families);
for family in ["Arial", "Calibri", "Carlito", "Noto Sans Arabic", "BIZ UDPGothic"] {
assert!(!db.has_family(family), "external family {family} leaked into the test fixture");
}
assert!(db.fallback_for('你', 0).is_none(), "missing glyph lookup must not consult system fonts");
assert_eq!(db.loaded_font_bytes(), 0);
assert!(FontDb::init_test_fonts());
}
+8
View File
@@ -19,3 +19,11 @@ fn a_fresh_session_finds_installed_fonts_by_name() {
assert_eq!(&FontDb::global().face(family, "Regular").family, family);
assert!(FontDb::global().families().contains(family));
}
#[cfg(feature = "test-support")]
#[test]
fn test_initialization_cannot_replace_an_application_database() {
let db = FontDb::global();
assert!(!FontDb::init_test_fonts());
assert!(std::ptr::eq(db, FontDb::global()));
}
+14 -7
View File
@@ -4,11 +4,15 @@
//! Quote, Code), list tables (`\listtable` / `\listoverridetable` with `\listtext` labels for
//! older readers), document info, page setup, paragraph formatting (`\pard`, alignment, page
//! break before), character formatting (`\b \i \ul \strike \super \sub \f \fs \cf \chcbpat`),
//! hyperlinks as `HYPERLINK` fields, bookmarks, pictures (`\pngblip` / `\jpegblip`) and tables
//! (`\trowd … \cellx … \cell … \row`, header rows, vertical merges, shading).
//! hyperlinks as `HYPERLINK` fields, bookmarks, pictures (`\pngblip` / `\jpegblip`), tables
//! (`\trowd … \cellx … \cell … \row`, header rows, vertical merges, shading), footnotes and
//! endnotes (`{\super\chftn}{\footnote[\ftnalt] …}`) and comments (`{\*\atrfstart n}` …
//! `{\*\atrfend n}` around the commented text, then `{\*\atnid initials}{\*\atnauthor name}`
//! `\chatn{\*\annotation{\*\atnref n}{\*\atndate …}` text `}`).
//!
//! Import: a tolerant reader for groups and destinations (unknown `\*` destinations, headers,
//! footers, notes and objects are skipped), `\par \line \tab \page`, `\uN` with `\ucN`
//! Import: a tolerant reader for groups and destinations (unknown `\*` destinations and objects
//! are skipped; headers, footers, notes and comments are read into stories of their own),
//! `\par \line \tab \page`, `\uN` with `\ucN`
//! fallback skipping, `\'hh` and 8-bit text in the document's code page (`\ansicpg`, `\mac`, `\pc`, `\pca`) or
//! the current font's (`\fcharset`, `\cpg`: Cyrillic, Central European, Greek, Mac OS Roman, the
//! East Asian multibyte pages 932/936/949/950…; Windows-1252 when none is named). A multibyte
@@ -16,13 +20,16 @@
//! together and a run that looks like a UTF-8 BOM is not mistaken for one; the formatting words
//! above, stylesheet-based
//! headings and `\outlinelevel`, lists through `\ls`/`\ilvl`, fields, bookmarks, pictures and
//! tables. Group nesting and picture sizes are capped.
//! tables, footnotes, endnotes (`\footnote` with `\ftnalt`) and comments (author, initials, date,
//! text, the commented range). Group nesting, picture sizes and the number of notes and comments
//! are capped. Not kept: custom note marks, comment replies (`\atnparent`), notes inside
//! headers, footers and other notes, tables inside note and comment texts (flattened).
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use wordcraft_doc::props::VAlign;
use wordcraft_doc::{Align, Document, Rgb};
use wordcraft_doc::{Align, Block, Blocks, Document, InlineObject, Paragraph, Rgb};
use crate::model::{self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, RevMark, make_img};
+5
View File
@@ -1,3 +1,8 @@
/// An ISO 8601 date-time from year, month, day, hour and minute.
pub(super) fn iso_date((y, mo, d, h, mi): (u32, u32, u32, u32, u32)) -> String {
format!("{y:04}-{:02}-{:02}T{:02}:{:02}:00Z", mo.clamp(1, 12), d.clamp(1, 31), h.min(23), mi.min(59))
}
pub(super) fn rtf_date(s: &str) -> Option<String> {
let num = |a: usize, b: usize| s.get(a..b).and_then(|x| x.parse::<u32>().ok());
let (y, mo, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?);
+209 -1
View File
@@ -1,6 +1,9 @@
use super::date::{iso_to_dttm, rtf_date};
use super::state::{MARK_CLOSE, MARK_OPEN, MAX_NOTE_BLOCKS};
use super::styles::{style_fmt, styled};
use super::{Align, Document, FBlock, FTable, Fmt, Inline, Kind, Meta, Para, RevMark, Rgb, VAlign, model};
use super::{
Align, Arc, Block, Blocks, Document, FBlock, FTable, Fmt, HashSet, Inline, InlineObject, Kind, Meta, Para, Paragraph, RevMark, Rgb, VAlign, model,
};
// ---------------------------------------------------------------------------------------------
// Export
@@ -66,7 +69,30 @@ impl Tables {
}
}
/// What a place marker in the exported text stands for (see [`MARK_OPEN`]).
pub(super) enum Mark {
Note {
endnote: bool,
blocks: Vec<FBlock>,
},
RangeStart(u32),
/// The end of a comment's range; the first end of each comment also carries its annotation.
RangeEnd {
id: u32,
ann: Option<Annotation>,
},
Done,
}
pub(super) struct Annotation {
author: String,
initials: String,
date: String,
blocks: Vec<FBlock>,
}
pub(super) struct Writer {
pub(super) marks: Vec<Mark>,
pub(super) t: Tables,
/// List definitions: per list, the ordered flag per level.
pub(super) lists: Vec<[bool; 9]>,
@@ -81,6 +107,121 @@ pub(super) struct Writer {
pub(super) sect_i: usize,
}
pub(super) fn has_marks(bl: &Blocks, depth: usize) -> bool {
bl.iter().any(|b| match &**b {
Block::Para(p) => {
p.objects.iter().any(|o| matches!(o, InlineObject::NoteRef { .. } | InlineObject::CommentStart { .. } | InlineObject::CommentEnd { .. }))
}
Block::Table(t) => depth < 8 && t.rows.iter().any(|r| r.cells.iter().any(|c| has_marks(&c.blocks, depth + 1))),
})
}
/// A copy of `doc` whose note references and comment range ends are place markers in the text
/// (see [`MARK_OPEN`]), with what each stands for; none when the body has neither.
fn mark_doc(doc: &Document) -> Option<(Document, Vec<Mark>)> {
if !has_marks(&doc.body, 0) {
return None;
}
let mut m = Marker { doc, marks: Vec::new(), annotated: HashSet::new() };
let body = m.blocks(&doc.body, None, 0);
let mut d = doc.clone();
d.body = body;
Some((d, m.marks))
}
/// The story id standing for "inside a note or comment": no nested notes, no comment ranges.
const IN_STORY: u32 = u32::MAX;
struct Marker<'a> {
doc: &'a Document,
marks: Vec<Mark>,
annotated: HashSet<u32>,
}
impl Marker<'_> {
fn token(&mut self, m: Mark) -> String {
self.marks.push(m);
self.marks.len().saturating_sub(1).to_string()
}
/// The flow of a story (a note's or comment's text); tables are flattened to their paragraphs.
fn story(&mut self, bl: &Blocks, note: u32, depth: usize, out: &mut Vec<FBlock>) {
for b in bl.iter().take(MAX_NOTE_BLOCKS) {
match &**b {
Block::Para(p) => {
let q = self.para(p, Some(note));
out.extend(model::flow_paras(self.doc, &q).into_iter().map(FBlock::Para));
}
Block::Table(t) if depth < 4 => {
for c in t.rows.iter().flat_map(|r| r.cells.iter()) {
self.story(&c.blocks, note, depth + 1, out);
}
}
Block::Table(_) => {}
}
}
}
fn blocks(&mut self, bl: &Blocks, note: Option<u32>, depth: usize) -> Blocks {
bl.iter()
.map(|b| match &**b {
Block::Para(p) if p.objects.is_empty() => b.clone(),
Block::Para(p) => Arc::new(Block::Para(self.para(p, note))),
Block::Table(t) if depth < 8 => {
let mut t = t.clone();
for c in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
c.blocks = self.blocks(&c.blocks, note, depth + 1);
}
Arc::new(Block::Table(t))
}
Block::Table(_) => b.clone(),
})
.collect()
}
/// `p` with its note references and comment range ends replaced by place markers. `note` is
/// the note being written, when `p` is part of one.
fn para(&mut self, p: &Paragraph, note: Option<u32>) -> Paragraph {
let mut q = p.clone();
for off in p.object_offsets().into_iter().rev() {
let Some(obj) = q.object_at(off).cloned() else { continue };
let tok = match obj {
InlineObject::NoteRef { id, .. } if note == Some(id) => Some(String::from("S")),
InlineObject::NoteRef { .. } if note.is_some() => Some(String::new()),
InlineObject::NoteRef { id, kind, .. } => self.doc.parts.get(&id).map(|part| {
let mut blocks = Vec::new();
self.story(&part.blocks, id, 0, &mut blocks);
self.token(Mark::Note { endnote: kind == wordcraft_doc::para::NoteKind::Endnote, blocks })
}),
InlineObject::CommentStart { id } if note.is_none() => Some(self.token(Mark::RangeStart(id))),
InlineObject::CommentEnd { id } if note.is_none() => {
let ann = if self.annotated.insert(id) { self.annotation(id) } else { None };
Some(self.token(Mark::RangeEnd { id, ann }))
}
_ => None,
};
let Some(tok) = tok else { continue };
let end = off + wordcraft_doc::para::OBJ.len_utf8();
let text = if tok.is_empty() { tok } else { format!("{MARK_OPEN}{tok}{MARK_CLOSE}") };
if q.delete(off, end).is_err() {
continue;
}
if !text.is_empty() && q.insert_text(off, &text, &Default::default()).is_err() {
continue;
}
}
q
}
fn annotation(&mut self, id: u32) -> Option<Annotation> {
let c = self.doc.comments.get(&id)?;
let part = self.doc.parts.get(&c.part)?;
let mut blocks = Vec::new();
self.story(&part.blocks, IN_STORY, 0, &mut blocks);
Some(Annotation { author: c.author.clone(), initials: c.initials.clone(), date: c.date.clone(), blocks })
}
}
/// `\sectd` and a section's formatting, in the words the reader reads back.
fn sect_words(s: &wordcraft_doc::SectionProps) -> String {
use wordcraft_doc::section::{NumFormat, SectionStart};
@@ -232,7 +373,68 @@ impl Writer {
s
}
/// A run of text; place markers in it become notes and comment ranges.
fn run(&mut self, t: &str, f: &Fmt, out: &mut String) {
if !t.contains(MARK_OPEN) {
return self.run_plain(t, f, out);
}
let mut rest = t;
while !rest.is_empty() {
let Some((pre, after)) = rest.split_once(MARK_OPEN) else {
self.run_plain(rest, f, out);
break;
};
if !pre.is_empty() {
self.run_plain(pre, f, out);
}
let Some((tok, tail)) = after.split_once(MARK_CLOSE) else {
self.run_plain(after, f, out);
break;
};
self.mark(tok, out);
rest = tail;
}
}
/// The RTF for one place marker: `S` is a note's own number, a number indexes `marks`.
fn mark(&mut self, tok: &str, out: &mut String) {
if tok == "S" {
out.push_str("{\\super\\chftn}");
return;
}
let Some(m) = tok.parse::<usize>().ok().and_then(|i| self.marks.get_mut(i)) else { return };
match std::mem::replace(m, Mark::Done) {
Mark::Note { endnote, blocks } => {
out.push_str("{\\super\\chftn}{\\footnote");
if endnote {
out.push_str("\\ftnalt");
}
out.push(' ');
self.blocks(&blocks, false, "\\par", out, 0);
out.push('}');
}
Mark::RangeStart(id) => out.push_str(&format!("{{\\*\\atrfstart {id}}}")),
Mark::RangeEnd { id, ann } => {
out.push_str(&format!("{{\\*\\atrfend {id}}}"));
if let Some(a) = ann {
out.push_str(&format!(
"{{\\*\\atnid {}}}{{\\*\\atnauthor {}}}\\chatn{{\\*\\annotation{{\\*\\atnref {id}}}",
esc(&a.initials),
esc(&a.author)
));
if let Some(d) = rtf_date(&a.date) {
out.push_str(&format!("{{\\*\\atndate {d}}}"));
}
out.push('\n');
self.blocks(&a.blocks, false, "\\par", out, 0);
out.push('}');
}
}
Mark::Done => {}
}
}
fn run_plain(&mut self, t: &str, f: &Fmt, out: &mut String) {
let mut ctl = String::new();
if let Some(c) = &f.fmt_change {
// RTF 1.9.1, as Word writes it: the properties the change altered, as they were,
@@ -685,9 +887,15 @@ fn level_def(ordered: bool, l: usize) -> String {
/// RTF text of a document.
pub fn export(doc: &Document) -> String {
let (marked, marks) = match mark_doc(doc) {
Some((d, m)) => (Some(d), m),
None => (None, Vec::new()),
};
let doc = marked.as_ref().unwrap_or(doc);
let flow = model::from_doc_tracked(doc);
let base_font = doc.styles.default_chr.font.clone().unwrap_or_else(|| wordcraft_doc::styles::BODY_FONT.to_string());
let mut w = Writer {
marks,
t: Tables { fonts: vec![base_font], colors: Vec::new() },
lists: Vec::new(),
authors: None,
+143 -9
View File
@@ -1,7 +1,6 @@
use super::reader::Reader;
use super::state::{Dest, HeaderFooter, HfPages, MAX_PICT, PageGeom};
use super::{Cell, Document, FBlock, Flow, Para, model};
use std::sync::Arc;
use super::state::{CommentData, Dest, HeaderFooter, HfPages, MARK_CLOSE, MARK_OPEN, MAX_PICT, NoteData, PageGeom};
use super::{Arc, Block, Blocks, Cell, Document, FBlock, Flow, HashMap, InlineObject, Para, Paragraph, model};
/// Parse RTF bytes into the flow model.
pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
@@ -11,7 +10,7 @@ pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
/// The flow, the page, and the default font (`\deff`, font 0 when the file names none), which
/// is the font of all text that doesn't select one.
fn parse_with_page(bytes: &[u8]) -> Result<(Flow, PageGeom, Option<String>), String> {
parse_full(bytes).map(|r| (r.flow, r.page, r.default_font))
parse_full(bytes, false).map(|r| (r.flow, r.page, r.default_font))
}
/// Everything an import takes from an RTF file.
@@ -23,14 +22,19 @@ struct Parsed {
default_font: Option<String>,
/// Each section's headers and footers, in order, the last section's last.
headers_footers: Vec<Vec<(HeaderFooter, Vec<FBlock>)>>,
notes: Vec<NoteData>,
comments: Vec<CommentData>,
}
fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
/// `marks`: also read footnotes, endnotes and comments (as place markers in the flow's text, for
/// [`import`] to turn into objects); without it they are skipped.
fn parse_full(bytes: &[u8], marks: bool) -> Result<Parsed, String> {
let start = bytes.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(0);
if !bytes.get(start..).is_some_and(|b| b.starts_with(b"{\\rtf")) {
return Err("not an RTF document (it doesn't start with {\\rtf)".into());
}
let mut r = Reader::default();
r.marks_on = marks;
let mut i = start;
let mut first_in_group = false;
let mut starred = false;
@@ -106,7 +110,10 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
continue;
}
if r.skip_chars > 0
&& matches!(r.st.dest, Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark | Dest::FontTbl | Dest::RevTbl)
&& matches!(
r.st.dest,
Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark | Dest::FontTbl | Dest::RevTbl | Dest::Atn(_)
)
{
r.skip_chars -= 1;
first_in_group = false;
@@ -206,8 +213,10 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
}
fn finalize(mut r: Reader) -> Parsed {
// A header or footer left open by a truncated file ends here, and so does a multibyte run.
// A multibyte run ends here, and so does a note, comment, header or footer left open by a
// truncated file.
r.finish_multibyte();
r.end_sub();
r.end_header_footer();
if r.para_has || !r.para.inlines.is_empty() {
r.end_para(false);
@@ -234,7 +243,7 @@ fn finalize(mut r: Reader) -> Parsed {
blocks.push(FBlock::Para(Para::default()));
}
let default_font = r.fonts.get(&r.deff).filter(|n| !model::is_mono(n)).map(|n| n.to_string());
Parsed { flow: Flow { blocks, meta: r.meta }, page: r.page, last_section, default_font, headers_footers }
Parsed { flow: Flow { blocks, meta: r.meta }, page: r.page, last_section, default_font, headers_footers, notes: r.notes, comments: r.comments }
}
/// The column grid of a table from its rows' cell right edges: every distinct edge starts a grid
@@ -277,7 +286,7 @@ pub(super) fn column_grid(rows: &mut [Vec<Cell>], rights: &[Vec<i32>]) -> Vec<f3
/// Parse RTF bytes into a document.
pub fn import(bytes: &[u8]) -> Result<Document, String> {
model::begin_import();
let Parsed { flow, last_section, default_font, headers_footers, .. } = parse_full(bytes)?;
let Parsed { flow, last_section, default_font, headers_footers, notes, comments, .. } = parse_full(bytes, true)?;
let mut doc = model::to_doc_in(&flow, last_section);
// Each section's own headers and footers; those it leaves out stay linked to the previous.
let mut sets = Vec::with_capacity(headers_footers.len());
@@ -309,6 +318,7 @@ pub fn import(bytes: &[u8]) -> Result<Document, String> {
if let Some((h, f)) = sets.next() {
(doc.last_section.headers, doc.last_section.footers) = (h, f);
}
place_notes_and_comments(&mut doc, notes, comments);
// RTF's paragraph defaults are single spacing and no space around paragraphs; what a file
// wants beyond that it states on each paragraph.
let dp = &mut Arc::make_mut(&mut doc.styles).default_para;
@@ -320,3 +330,127 @@ pub fn import(bytes: &[u8]) -> Result<Document, String> {
}
Ok(doc)
}
/// Turn the place markers the reader left in the text into note references and comment ranges,
/// and add the notes' and comments' stories to `doc`.
fn place_notes_and_comments(doc: &mut Document, notes: Vec<NoteData>, comments: Vec<CommentData>) {
use wordcraft_doc::para::NoteKind;
use wordcraft_doc::{Comment, PartKind};
if notes.is_empty() && comments.is_empty() {
return;
}
// (kind, part id) per note, by index.
let mut note_parts: Vec<(NoteKind, u32)> = Vec::new();
for n in notes {
let (pk, nk, style) =
if n.endnote { (PartKind::Endnote, NoteKind::Endnote, "EndnoteText") } else { (PartKind::Footnote, NoteKind::Footnote, "FootnoteText") };
let mut blocks = model::part_blocks(doc, &n.blocks);
for b in &mut blocks {
if let Block::Para(p) = Arc::make_mut(b)
&& p.props.style.is_none()
{
p.props.style = Some(style.to_string());
}
}
note_parts.push((nk, doc.add_part(pk, blocks)));
}
// RTF range id to the comment's id in the document.
let mut by_rtf: HashMap<i64, u32> = HashMap::new();
for (k, c) in comments.into_iter().enumerate() {
let id = k as u32;
let blocks = model::part_blocks(doc, &c.blocks);
let part = doc.add_part(PartKind::Comment, blocks);
doc.comments
.insert(id, Comment { author: c.author, initials: c.initials, date: c.date, parent: None, resolved: false, part, ..Default::default() });
by_rtf.insert(i64::from(c.rtf_id), id);
}
let ctx = Placer { note_parts, by_rtf };
let mut body = std::mem::take(&mut doc.body);
ctx.blocks(&mut body, None, 0);
doc.body = body;
let ids: Vec<u32> = doc.parts.keys().copied().collect();
for id in ids {
let Some(part) = doc.parts.get_mut(&id) else { continue };
let own = ctx.note_parts.iter().find(|(_, p)| *p == id).copied();
ctx.blocks(&mut part.blocks, own, 0);
// A note starts with its own number, as Word's do.
if let Some((kind, pid)) = own
&& let Some(first) = part.blocks.first_mut()
&& let Block::Para(p) = Arc::make_mut(first)
&& !p.objects.iter().any(|o| matches!(o, InlineObject::NoteRef { id, .. } if *id == pid))
{
let props = Placer::ref_props(kind);
if p.insert_object(0, InlineObject::NoteRef { kind, id: pid, custom: String::new() }, &props).is_err() {
// The paragraph can't take it; the note simply has no number mark.
}
}
}
}
struct Placer {
note_parts: Vec<(wordcraft_doc::para::NoteKind, u32)>,
by_rtf: HashMap<i64, u32>,
}
impl Placer {
fn ref_props(kind: wordcraft_doc::para::NoteKind) -> wordcraft_doc::CharProps {
let style = if kind == wordcraft_doc::para::NoteKind::Endnote { "EndnoteReference" } else { "FootnoteReference" };
wordcraft_doc::CharProps { style: Some(style.into()), ..Default::default() }
}
fn blocks(&self, bl: &mut Blocks, own: Option<(wordcraft_doc::para::NoteKind, u32)>, depth: usize) {
for b in bl.iter_mut() {
let needs = match &**b {
Block::Para(p) => p.text.contains(MARK_OPEN),
Block::Table(_) => depth < 8,
};
if !needs {
continue;
}
match Arc::make_mut(b) {
Block::Para(p) => self.para(p, own),
Block::Table(t) => {
for r in &mut t.rows {
for c in &mut r.cells {
self.blocks(&mut c.blocks, own, depth + 1);
}
}
}
}
}
}
fn para(&self, p: &mut Paragraph, own: Option<(wordcraft_doc::para::NoteKind, u32)>) {
// Every pass removes one marker, so this ends; the bound is only a seat belt.
for _ in 0..100_000 {
let Some(a) = p.text.find(MARK_OPEN) else { return };
let Some(len) = p.text.get(a..).and_then(|t| t.find(MARK_CLOSE)) else { return };
let end = a + len + MARK_CLOSE.len_utf8();
let inner = p.text.get(a + MARK_OPEN.len_utf8()..a + len).unwrap_or("").to_string();
let mut chars = inner.chars();
let tag = chars.next().unwrap_or(' ');
let n = chars.as_str().parse::<i64>().unwrap_or(i64::MIN);
let (obj, props) = match tag {
'N' => match usize::try_from(n).ok().and_then(|i| self.note_parts.get(i)) {
Some(&(kind, id)) => (Some(InlineObject::NoteRef { kind, id, custom: String::new() }), Self::ref_props(kind)),
None => (None, Default::default()),
},
'S' => match own {
Some((kind, id)) => (Some(InlineObject::NoteRef { kind, id, custom: String::new() }), Self::ref_props(kind)),
None => (None, Default::default()),
},
'B' => (self.by_rtf.get(&n).map(|&id| InlineObject::CommentStart { id }), Default::default()),
'E' => (self.by_rtf.get(&n).map(|&id| InlineObject::CommentEnd { id }), Default::default()),
_ => (None, Default::default()),
};
if p.delete(a, end).is_err() {
return;
}
if let Some(o) = obj
&& p.insert_object(a, o, &props).is_err()
{
return;
}
}
}
}
+119 -5
View File
@@ -1,8 +1,11 @@
use super::date::dttm_to_iso;
use super::date::{dttm_to_iso, iso_date};
use super::parse::column_grid;
use super::state::{CellDef, Dest, HeaderFooter, MAX_GROUPS, MAX_REV_AUTHORS, MAX_SECTIONS, Output, PageGeom, SecFmt, St};
use super::state::{
CellDef, CommentData, Dest, HeaderFooter, MARK_CLOSE, MARK_OPEN, MAX_GROUPS, MAX_NOTE_BLOCKS, MAX_NOTES, MAX_REV_AUTHORS, MAX_SECTIONS, NoteData,
Output, PageGeom, SecFmt, St, Sub,
};
use super::styles::{StyleDef, drop_style_formatting, style_fmt};
use super::{Arc, Cell, FBlock, FTable, HashMap, Inline, Kind, Meta, Para, Rgb, model};
use super::{Arc, Cell, FBlock, FTable, Fmt, HashMap, HashSet, Inline, Kind, Meta, Para, Rgb, model};
#[derive(Default)]
pub(super) struct Reader {
@@ -91,6 +94,22 @@ pub(super) struct Reader {
pub(super) in_creatim: bool,
pub(super) rule_pending: bool,
pub(super) info_depth: Option<usize>,
// Notes and comments (only when the caller wants them).
pub(super) marks_on: bool,
pub(super) sub: Option<Sub>,
pub(super) notes: Vec<NoteData>,
pub(super) comments: Vec<CommentData>,
pub(super) atn_text: String,
pub(super) atn_initials: String,
pub(super) atn_author: String,
pub(super) atn_date: String,
pub(super) atn_parts: Option<(u32, u32, u32, u32, u32)>,
pub(super) atn_ref: Option<i32>,
pub(super) last_end: Option<i32>,
pub(super) range_starts: HashSet<i32>,
pub(super) range_ends: HashSet<i32>,
pub(super) claimed: HashSet<i32>,
pub(super) synth: i32,
}
impl Reader {
@@ -152,12 +171,103 @@ impl Reader {
}
}
/// A footnote, endnote or comment group starts: read it into an output of its own.
pub(super) fn start_sub(&mut self, comment: bool) {
if self.overflow > 0 || self.notes.len() >= MAX_NOTES || self.comments.len() >= MAX_NOTES {
self.st.dest = Dest::Skip;
return;
}
let saved = self.swap_output(Output::default());
self.sub = Some(Sub { depth: self.depth(), comment, endnote: false, saved: Box::new(saved) });
self.st.intbl = false;
}
/// A place marker as text in the current paragraph.
pub(super) fn push_mark(&mut self, tag: char, n: i64, fmt: Fmt) {
self.para.push_text(&format!("{MARK_OPEN}{tag}{n}{MARK_CLOSE}"), &fmt);
self.para_has = true;
}
/// The footnote or comment group closed: keep its text and leave a marker in the body.
pub(super) fn end_sub(&mut self) {
let Some(sub) = self.sub.take() else { return };
if self.para_has || !self.para.inlines.is_empty() {
self.end_para(false);
}
self.close_table();
let fmt = self.stack.last().map(|s| s.fmt.clone()).unwrap_or_default();
let mut inner = self.swap_output(*sub.saved);
inner.body.truncate(MAX_NOTE_BLOCKS);
if !sub.comment {
self.push_mark('N', self.notes.len() as i64, fmt);
self.notes.push(NoteData { endnote: sub.endnote, blocks: inner.body });
return;
}
let want = self.atn_ref.take().or(self.last_end.take());
let id = match want {
Some(i) if self.range_starts.contains(&i) && self.range_ends.contains(&i) && self.claimed.insert(i) => i,
_ => {
// No range of its own: the comment sits at this point.
self.synth = self.synth.saturating_sub(1);
let id = self.synth;
self.push_mark('B', i64::from(id), fmt.clone());
self.push_mark('E', i64::from(id), fmt);
id
}
};
let date =
if !self.atn_date.is_empty() { std::mem::take(&mut self.atn_date) } else { self.atn_parts.take().map(iso_date).unwrap_or_default() };
self.atn_parts = None;
self.comments.push(CommentData {
rtf_id: id,
author: std::mem::take(&mut self.atn_author),
initials: std::mem::take(&mut self.atn_initials),
date,
blocks: inner.body,
});
}
/// A comment word's text group closed (`\atrfstart 3`, `\atnauthor Ann`…).
pub(super) fn commit_atn(&mut self, kind: u8) {
let text = std::mem::take(&mut self.atn_text);
let text = text.trim();
let id = text.parse::<i32>().ok();
let fmt = self.stack.last().map(|s| s.fmt.clone()).unwrap_or_default();
match kind {
1 => {
if let Some(id) = id {
self.range_starts.insert(id);
self.push_mark('B', i64::from(id), fmt);
}
}
2 => {
if let Some(id) = id {
self.range_ends.insert(id);
self.last_end = Some(id);
self.push_mark('E', i64::from(id), fmt);
}
}
3 => self.atn_initials = text.to_string(),
4 => self.atn_author = text.to_string(),
5 => {
// Either `\yr\mo\dy\hr\min` words (read as they came) or a packed date-time number.
if let Some(parts) = self.atn_parts.take() {
self.atn_date = iso_date(parts);
} else if let Some(n) = text.parse::<u32>().ok().filter(|n| *n != 0) {
self.atn_date = iso_date((1900 + ((n >> 20) & 0x1FF), (n >> 16) & 0xF, (n >> 11) & 0x1F, (n >> 6) & 0x1F, n & 0x3F));
}
}
6 => self.atn_ref = id,
_ => {}
}
}
/// `\sect`: end the paragraph, and with it the section, which takes its formatting and the
/// headers and footers read since the last break. Inside a header, footer or table, and past
/// [`MAX_SECTIONS`], it only ends the paragraph.
pub(super) fn end_section(&mut self) {
self.end_para_mark();
if self.hf.is_some() || self.st.intbl || self.section_parts.len() + 1 >= MAX_SECTIONS {
if self.hf.is_some() || self.sub.is_some() || self.st.intbl || self.section_parts.len() + 1 >= MAX_SECTIONS {
return;
}
let props = self.sec.props(&self.page);
@@ -175,7 +285,7 @@ impl Reader {
let pv = p.unwrap_or(0);
// Inside a header or footer the word is read into a scratch copy and dropped.
let mut scratch;
let sec = if self.hf.is_some() {
let sec = if self.hf.is_some() || self.sub.is_some() {
scratch = self.sec.clone();
&mut scratch
} else {
@@ -231,6 +341,9 @@ impl Reader {
self.overflow -= 1;
return;
}
if self.sub.as_ref().is_some_and(|s| s.depth == self.depth()) {
self.end_sub();
}
let closing = self.st.dest;
// Destination-level commits.
match closing {
@@ -249,6 +362,7 @@ impl Reader {
}
self.in_creatim = false;
}
Dest::Atn(k) => self.commit_atn(k),
_ => {}
}
let Some(mut prev) = self.stack.pop() else { return };
+35
View File
@@ -5,6 +5,15 @@ pub(super) const MAX_GROUPS: usize = 512;
/// Largest picture we decode (bytes).
pub(super) const MAX_PICT: usize = 64 << 20;
/// Most notes, and most comments, one file may bring in.
pub(super) const MAX_NOTES: usize = 5_000;
/// Most blocks kept of one note's or comment's text.
pub(super) const MAX_NOTE_BLOCKS: usize = 500;
/// Brackets of a place marker in text: a note reference or a comment range end travels through the
/// flow model as `OPEN tag number CLOSE` (private-use characters, which both the reader and the
/// writer keep out of real text) and becomes an inline object once the document exists.
pub(super) const MARK_OPEN: char = '\u{F8F0}';
pub(super) const MARK_CLOSE: char = '\u{F8F1}';
/// Most sections we read (`\sect`); later breaks end paragraphs only.
pub(super) const MAX_SECTIONS: usize = 10_000;
@@ -25,6 +34,9 @@ pub(super) enum Dest {
FldInst,
Pict,
Bookmark,
/// A comment's text-valued word: 1 `\atrfstart`, 2 `\atrfend`, 3 `\atnid`, 4 `\atnauthor`,
/// 5 `\atndate`/`\atntime`, 6 `\atnref`.
Atn(u8),
/// The author table of tracked changes (`\revtbl`).
RevTbl,
/// Formatting before a tracked change (`\oldpprops` when true, else `\oldcprops`): read
@@ -32,6 +44,29 @@ pub(super) enum Dest {
OldProps(bool),
}
/// A footnote, endnote or comment group being read into an output of its own.
pub(super) struct Sub {
pub(super) depth: usize,
pub(super) comment: bool,
pub(super) endnote: bool,
pub(super) saved: Box<Output>,
}
/// A note read: its kind and text.
pub(super) struct NoteData {
pub(super) endnote: bool,
pub(super) blocks: Vec<FBlock>,
}
/// A comment read: the id its range markers carry, who wrote it and when, and its text.
pub(super) struct CommentData {
pub(super) rtf_id: i32,
pub(super) author: String,
pub(super) initials: String,
pub(super) date: String,
pub(super) blocks: Vec<FBlock>,
}
/// A tracked formatting change being read (`\crauth`/`\crdate` with `\oldcprops`,
/// `\prauth`/`\prdate` with `\oldpprops`): author (`\revtbl` index), date (DTTM) and the old
/// formatting, once its group has been read.
+1
View File
@@ -1097,6 +1097,7 @@ fn old_formatting_in_a_code_paragraph_has_the_code_font() {
let mut p = Para { kind: Kind::Code, ..Default::default() };
p.push_text("x", &Fmt { font: Some("Arial".into()), fmt_change: Some(Box::new(FmtChange { mark, old: Fmt::default() })), ..Default::default() });
let mut w = Writer {
marks: Vec::new(),
t: Tables { fonts: vec!["Aptos".into()], colors: Vec::new() },
lists: Vec::new(),
authors: None,
+11 -1
View File
@@ -1,4 +1,4 @@
use super::state::{Dest, MAX_PICT, St};
use super::state::{Dest, MARK_CLOSE, MARK_OPEN, MAX_PICT, St};
use super::{Fmt, Inline, Reader, Rgb, make_img, model};
use std::sync::Arc;
@@ -196,6 +196,11 @@ impl Reader {
return;
}
let mut f = self.st.fmt.clone();
if s.contains([MARK_OPEN, MARK_CLOSE]) {
self.para.push_text(&s.replace([MARK_OPEN, MARK_CLOSE], "\u{FFFD}"), &f);
self.para_has = true;
return;
}
let r = self.st.rev;
if r.ins {
f.ins = Some(self.rev_mark(r.auth, r.dttm));
@@ -245,6 +250,11 @@ impl Reader {
}
}
Dest::Info(_) => self.info_text.push_str(s),
Dest::Atn(_) => {
if self.atn_text.len() < 256 {
self.atn_text.push_str(s);
}
}
Dest::FldInst => {
if let Some(f) = self.st.field.and_then(|i| self.fields.get_mut(i))
&& f.len() < 4096
+64
View File
@@ -15,6 +15,9 @@ impl Reader {
if w == "shppict" || self.start_header_footer_word(w) {
return;
}
if self.start_note_word(w) {
return;
}
let dest = self.destination_for_word(w);
if let Some(d) = dest {
match d {
@@ -295,6 +298,22 @@ impl Reader {
// Transparent wrapper: read the nested pict without changing the destination.
"pict" => Some(Dest::Pict),
"bkmkstart" => Some(Dest::Bookmark),
"atrfstart" | "atrfend" | "atnid" | "atnauthor" | "atndate" | "atntime" | "atnref"
if self.marks_on
&& self.hf.is_none()
&& self.st.dest == Dest::Body
&& self.sub.as_ref().is_none_or(|s| s.comment && !w.starts_with("atrf")) =>
{
self.atn_text.clear();
Some(Dest::Atn(match w {
"atrfstart" => 1,
"atrfend" => 2,
"atnid" => 3,
"atnauthor" => 4,
"atndate" | "atntime" => 5,
_ => 6,
}))
}
"header" | "footer" | "headerl" | "headerr" | "headerf" | "footerl" | "footerr" | "footerf" | "footnote" | "annotation" | "pntext"
| "listtext" | "object" | "nonshppict" | "xe" | "tc" | "txe" | "private" | "bkmkend" | "atnid" | "atnauthor" | "comment" | "shpinst"
| "do" | "operator" | "company" | "manager" | "category" | "hlinkbase" | "revtime" | "printim" | "buptim" | "version" | "nofpages"
@@ -309,6 +328,32 @@ impl Reader {
}
}
/// The words that open a note or comment story of their own (`\footnote`, `\annotation`) and
/// the two inside a note: `\ftnalt` (an endnote) and `\chftn` (the note's own number).
fn start_note_word(&mut self, word: &str) -> bool {
if self.st.dest == Dest::Body {
match word {
"ftnalt" => {
if let Some(sub) = self.sub.as_mut() {
sub.endnote = true;
}
return true;
}
"chftn" if self.sub.as_ref().is_some_and(|s| !s.comment) => {
let f = self.st.fmt.clone();
self.push_mark('S', 0, f);
return true;
}
_ => {}
}
}
if !matches!(word, "footnote" | "annotation") || !self.marks_on || self.hf.is_some() || self.sub.is_some() || self.st.dest != Dest::Body {
return false;
}
self.start_sub(word == "annotation");
true
}
fn start_header_footer_word(&mut self, word: &str) -> bool {
if !matches!(word, "header" | "footer" | "headerl" | "headerr" | "headerf" | "footerl" | "footerr" | "footerf")
|| self.hf.is_some()
@@ -398,6 +443,12 @@ impl Reader {
self.picture_word(word, value);
true
}
Dest::Atn(kind) => {
if kind == 5 {
self.record_atn_part(word, value);
}
!matches!(word, "u" | "uc")
}
Dest::Info(_) | Dest::FldInst | Dest::Bookmark if !matches!(word, "u" | "uc") => true,
Dest::Info(_) | Dest::FldInst | Dest::Bookmark => false,
Dest::OldProps(false) if matches!(word, "crauth" | "crdate") => {
@@ -421,6 +472,19 @@ impl Reader {
}
}
fn record_atn_part(&mut self, word: &str, value: i32) {
let v = value.clamp(0, 9999) as u32;
let c = self.atn_parts.get_or_insert((0, 1, 1, 0, 0));
match word {
"yr" => c.0 = v,
"mo" => c.1 = v,
"dy" => c.2 = v,
"hr" => c.3 = v,
"min" => c.4 = v,
_ => {}
}
}
fn font_table_word(&mut self, word: &str, value: i32) {
match word {
"f" => {
+136
View File
@@ -879,6 +879,142 @@ fn exported_links_keep_only_safe_targets() {
}
}
// ---------------------------------------------------------------------------------------------
// RTF footnotes, endnotes and comments (#1053)
/// The note and comment texts of `d` as `(kind, text)` strings, and the references in the body.
fn rtf_notes(d: &Document) -> (Vec<String>, Vec<String>) {
use wordcraft_doc::PartKind;
let story = |bl: &wordcraft_doc::Blocks| {
bl.iter().filter_map(|b| if let Block::Para(p) = &**b { Some(p.plain_text().trim().to_string()) } else { None }).collect::<Vec<_>>().join("|")
};
let notes = d
.parts
.values()
.filter(|p| matches!(p.kind, PartKind::Footnote | PartKind::Endnote))
.map(|p| format!("{:?}:{}", p.kind, story(&p.blocks)))
.collect();
let comments = d.comments.values().filter_map(|c| d.parts.get(&c.part)).map(|p| story(&p.blocks)).collect();
(notes, comments)
}
#[test]
fn rtf_footnote_and_endnote_round_trip() {
use wordcraft_doc::para::NoteKind;
let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Arial;}}
\pard Alpha{\super\chftn}{\footnote \pard\plain {\super\chftn} First {\b note}\par} beta
gamma{\super\chftn}{\footnote\ftnalt \pard\plain {\super\chftn} The end\par\pard second line\par} delta.\par}";
let d = import("rtf", rtf).unwrap().unwrap();
let body = d.body.iter().find_map(|b| b.as_para()).unwrap();
let refs: Vec<NoteKind> =
body.objects.iter().filter_map(|o| if let InlineObject::NoteRef { kind, .. } = o { Some(*kind) } else { None }).collect();
assert_eq!(refs, [NoteKind::Footnote, NoteKind::Endnote]);
assert_eq!(body.plain_text().trim(), "Alpha beta gamma delta.");
let (notes, _) = rtf_notes(&d);
assert_eq!(notes, ["Footnote:First note", "Endnote:The end|second line"]);
// The note's own number sits at the start of its text, and its first words are bold.
let foot = d.parts.values().find(|p| p.kind == wordcraft_doc::PartKind::Footnote).unwrap();
let fp = foot.blocks.iter().find_map(|b| b.as_para()).unwrap();
assert!(matches!(fp.objects.first(), Some(InlineObject::NoteRef { kind: NoteKind::Footnote, .. })));
// Out and in again keeps both.
let out = String::from_utf8(export("rtf", &d).unwrap().unwrap()).unwrap();
assert!(out.contains("\\footnote") && out.contains("\\footnote\\ftnalt") && out.contains("\\chftn"), "{out}");
let d2 = import("rtf", out.as_bytes()).unwrap().unwrap();
assert_eq!(rtf_notes(&d2).0, notes);
let body2 = d2.body.iter().find_map(|b| b.as_para()).unwrap();
assert_eq!(body2.objects.len(), 2);
assert_eq!(body2.plain_text().trim(), "Alpha beta gamma delta.");
// The flow reader (no document) still just skips them.
let flow = crate::rtf::parse(rtf).unwrap();
assert!(matches!(&flow.blocks[0], FBlock::Para(p) if p.text().trim() == "Alpha beta gamma delta."));
}
#[test]
fn rtf_comment_round_trip_with_author_and_date() {
let rtf = br"{\rtf1\ansi
\pard Before {\*\atrfstart 7}commented words{\*\atrfend 7}{\*\atnid AK}{\*\atnauthor Ada Kline}\chatn{\*\annotation{\*\atnref 7}{\*\atndate \yr2026\mo3\dy9\hr14\min5}\pard\plain Please \b check\b0 this.\par} after.\par
\pard Second {\*\atrfstart 8}one{\*\atrfend 8}{\*\atnid BX}{\*\atnauthor Bo}\chatn{\*\annotation{\*\atnref 8}\pard\plain Fine.\par}\par}";
let d = import("rtf", rtf).unwrap().unwrap();
assert_eq!(d.comments.len(), 2);
let c = d.comments.values().next().unwrap();
assert_eq!((c.author.as_str(), c.initials.as_str(), c.date.as_str()), ("Ada Kline", "AK", "2026-03-09T14:05:00Z"));
assert_eq!(rtf_notes(&d).1, ["Please check this.", "Fine."]);
let p = d.body.iter().find_map(|b| b.as_para()).unwrap();
assert_eq!(p.plain_text().trim(), "Before commented words after.");
// The range wraps exactly the commented words.
let (s, e) = (p.object_offsets()[0], p.object_offsets()[1]);
assert!(matches!(p.object_at(s), Some(InlineObject::CommentStart { .. })) && matches!(p.object_at(e), Some(InlineObject::CommentEnd { .. })));
assert_eq!(p.text.get(s + 3..e), Some("commented words"));
let out = String::from_utf8(export("rtf", &d).unwrap().unwrap()).unwrap();
assert!(out.contains("\\atrfstart") && out.contains("\\atnauthor Ada Kline") && out.contains("\\annotation"), "{out}");
let d2 = import("rtf", out.as_bytes()).unwrap().unwrap();
assert_eq!(d2.comments.len(), 2);
let (a, b) = (d.comments.values().next().unwrap(), d2.comments.values().next().unwrap());
assert_eq!((&a.author, &a.initials, &a.date), (&b.author, &b.initials, &b.date));
assert_eq!(rtf_notes(&d2).1, ["Please check this.", "Fine."]);
let p2 = d2.body.iter().find_map(|b| b.as_para()).unwrap();
assert_eq!(p2.objects.len(), 2);
}
#[test]
fn rtf_hostile_notes_and_comments_do_not_panic() {
// Truncated everywhere, unbalanced groups, forged markers, huge ids, nesting.
let good = br"{\rtf1\ansi Hi{\super\chftn}{\footnote\ftnalt \pard\plain {\super\chftn} Note \u8364? text\par} {\*\atrfstart 1}x{\*\atrfend 1}{\*\atnid A}{\*\atnauthor Z}{\*\annotation{\*\atnref 1}{\*\atndate 123456789}\pard c\par}\par}";
for n in 0..good.len() {
let _ = import("rtf", &good[..n]);
}
let junk: [&[u8]; 6] = [
br"{\rtf1 \u63472?\u63473?\u63472?N0\u63473? {\footnote x}{\footnote {\footnote deep}}}",
br"{\rtf1 {\*\atrfstart 99999999999}{\*\atrfend -5}{\*\annotation{\*\atnref 99999999999}x}{\*\annotation}}",
br"{\rtf1 {\footnote{\annotation{\footnote{\*\annotation{\*\atrfstart 1}",
br"{\rtf1 {\*\atndate \yr99999\mo-4\dy0\hr77\min999}{\*\annotation y}{\*\atndate 4294967295}{\*\annotation z}}",
b"{\\rtf1 {\\footnote \\trowd\\cellx100\\pard\\intbl a\\cell\\row}}",
b"{\\rtf1 {\\annotation \\chatn\\chftn\\atnparent 3}{\\header {\\footnote q}}}",
];
for j in junk {
let d = import("rtf", j).unwrap().unwrap();
// Whatever came in, a second trip through RTF is fine too.
let out = export("rtf", &d).unwrap().unwrap();
assert!(import("rtf", &out).unwrap().is_ok());
}
// Many notes: bounded.
let mut many = String::from("{\\rtf1 ");
for _ in 0..6000 {
many.push_str("{\\footnote x}");
}
many.push('}');
let d = import("rtf", many.as_bytes()).unwrap().unwrap();
assert!(d.parts.len() <= 5000);
// Private-use marker characters in ordinary text stay text.
let d = import("rtf", "{\\rtf1 a\u{F8F0}N0\u{F8F1}b}".as_bytes()).unwrap().unwrap();
assert!(d.parts.is_empty());
}
#[test]
fn rtf_notes_comments_tracked_changes_and_sections_work_together() {
// Two sections; the second holds a footnote, a comment and a tracked insertion, and has its
// own footer. A `\sect` inside the footnote's text must not end a section.
let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Arial;}}{\*\revtbl {Unknown;}{Alice;}}
\sectd{\footerr \pard Foot one\par}\pard First section.\par \sect
\sectd\lndscpsxn{\footerr \pard Foot two\par}\pard Second{\super\chftn}{\footnote \pard\plain {\super\chftn} Note text\sect\par} {\*\atrfstart 3}section{\*\atrfend 3}{\*\atnid AB}{\*\atnauthor Ann}\chatn{\*\annotation{\*\atnref 3}{\*\atndate 1000}\pard Remark\par}{\revised\revauth1\revdttm-1480361314 added}.\par}";
let d = import("rtf", rtf).unwrap().unwrap();
assert_eq!(d.sections().len(), 2);
let check = |d: &Document| {
let (notes, comments) = rtf_notes(d);
assert_eq!(notes.len(), 1, "{notes:?}");
assert!(notes[0].contains("Note text"), "{notes:?}");
assert_eq!(comments.len(), 1, "{comments:?}");
assert!(comments[0].contains("Remark"), "{comments:?}");
let ins = d.body.iter().filter_map(|b| b.as_para()).any(|p| p.plain_text().contains("added"));
assert!(ins);
};
check(&d);
let out = String::from_utf8(export("rtf", &d).unwrap().unwrap()).unwrap();
assert!(out.contains("\\revised") && out.contains("\\footnote") && out.contains("\\atrfstart"), "{out}");
let d2 = import("rtf", out.as_bytes()).unwrap().unwrap();
check(&d2);
}
/// A link target with any whitespace (a no-break or ideographic space too) is written in `<…>`,
/// so the reader, which ends a bare destination at the first whitespace, reads it back whole.
#[test]
+81
View File
@@ -194,6 +194,75 @@ 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)
}
/// 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 +409,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);
+1
View File
@@ -25,6 +25,7 @@ log = { workspace = true }
[dev-dependencies]
wordcraft-doc = { workspace = true, features = ["test-support"] }
wordcraft-fonts = { workspace = true, features = ["test-support"] }
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
# The browser clock (`SystemTime::now()`/`Instant::now()` panic on wasm32-unknown-unknown).
+4
View File
@@ -349,6 +349,7 @@ mod tests {
#[test]
fn occupied_follows_the_outline() {
crate::test_fonts();
let c = diamond(false);
let s = c.occupied(0.0, 10.0);
assert_eq!(s.len(), 1);
@@ -363,6 +364,7 @@ mod tests {
#[test]
fn through_keeps_interior_gaps() {
crate::test_fonts();
// A U: two prongs joined at the bottom.
let u = [(0.0, 0.0), (20.0, 0.0), (20.0, 80.0), (80.0, 80.0), (80.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)];
let (_, tight) = Contour::new(&u, 0.0, 0.0, 0.0, false).unwrap();
@@ -374,6 +376,7 @@ mod tests {
#[test]
fn pictures_wrap_around_their_opaque_pixels() {
crate::test_fonts();
let png = |img: image::DynamicImage| {
let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).unwrap();
@@ -429,6 +432,7 @@ mod tests {
#[test]
fn hostile_points_are_dropped() {
crate::test_fonts();
assert!(Contour::new(&[(f32::NAN, 0.0), (1.0, 1.0), (f32::INFINITY, 2.0)], 0.0, 0.0, 0.0, false).is_none());
let c = diamond(true);
assert!(c.occupied(f32::NAN, 1.0).is_empty());
+4 -3
View File
@@ -815,6 +815,8 @@ pub fn section_break_label(start: SectionStart) -> &'static str {
/// Width of a [`Draw::MarkText`] label.
pub fn mark_text_width(text: &str, size: f32) -> f32 {
#[cfg(test)]
crate::test_fonts();
let face = wordcraft_fonts::word::resolve("Source Sans 3", false, false).face;
let k = size / face.upem.max(1.0) as f32;
text.chars().map(|c| face.advance(face.glyph_for(c)) as f32 * k).sum()
@@ -846,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(),
@@ -856,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
@@ -363,6 +363,7 @@ mod tests {
#[test]
fn ref_link_switches() {
crate::test_fonts();
for instr in [r"REF mark\w\h", r#" REF "mark" \h"#, "REF\tmark \\H"] {
assert_eq!(reference_link(instr).as_deref(), Some("#mark"), "{instr}");
}
@@ -386,6 +387,7 @@ mod tests {
#[test]
fn page_fields() {
crate::test_fonts();
let ctx = FieldCtx { page: 4, pages: 12, ..Default::default() };
assert_eq!(field_text("PAGE", "", &ctx), ("4".into(), true));
assert_eq!(field_text(" PAGE \\* ROMAN ", "", &ctx).0, "IV");
@@ -480,6 +482,7 @@ mod tests {
/// #1765: ASK and SET only assign text to a bookmark; they show nothing, whatever result is stored.
#[test]
fn ask_and_set_show_nothing() {
crate::test_fonts();
let ctx = FieldCtx::default();
assert_eq!(field_text(r#" ASK Nominativo "Inserire nome" \d "«NOME»" "#, "«NOME» «COGNOME»", &ctx), (String::new(), false));
assert_eq!(field_text(r#"SET Codice "COD""#, "COD", &ctx), (String::new(), false));
@@ -490,6 +493,7 @@ mod tests {
#[test]
fn page_references_show_their_bookmarks_page() {
crate::test_fonts();
let pages = HashMap::from([
("End_1".to_string(), (3, NumFormat::Decimal)),
("Intro".to_string(), (4, NumFormat::LowerRoman)),
+2
View File
@@ -122,6 +122,7 @@ mod tests {
#[test]
fn auto_space_pairs() {
crate::test_fonts();
assert_eq!(auto_space('院', '9', true, true), Some(AsianSide::Left));
assert_eq!(auto_space('A', 'カ', true, true), Some(AsianSide::Right));
assert_eq!(auto_space('院', '9', true, false), None);
@@ -140,6 +141,7 @@ mod tests {
#[test]
fn kinsoku_and_word_wrap_break_opportunities() {
crate::test_fonts();
let ja = "日本語、「漢字」。テスト";
let on = breaks(ja, true, true);
for c in ["、", "」", "。", "漢"] {
+18
View File
@@ -8,6 +8,18 @@
//! - [`hit`]: point ↔ position, caret geometry, line navigation, selection rectangles.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
/// Installs the pinned test fonts as the process-wide font database. The layout entry points
/// (`layout`, `layout_para`, `layout_equation`, `display::mark_text_width`) call it first in test
/// builds, so the shared `lay` helper and every test that goes through them are covered; a test
/// that looks up fonts itself before any such call (fixtures, `word::resolve`, `FontDb::global`)
/// calls it at its start. It is idempotent and never calls back into layout, so it cannot recurse.
/// The choice is process-wide and immutable, which keeps font and math caches consistent across
/// test threads.
#[cfg(test)]
fn test_fonts() {
assert!(wordcraft_fonts::FontDb::init_test_fonts(), "layout tests must initialize fixed fonts before any global font lookup");
}
pub mod balloons;
mod contour;
pub mod display;
@@ -46,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;
@@ -70,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, missing_watermark_glyphs, watermark_run};
pub use notes::DECOR_STORY;
pub(crate) use notes::NOTE_SEP_W;
use notes::{MAX_NOTE_PASSES, footnote_pages};
@@ -79,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.
@@ -312,6 +328,8 @@ pub struct DocLayout {
/// Lay out the whole document.
pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) -> DocLayout {
#[cfg(test)]
crate::test_fonts();
let t0 = now_ms();
// Page totals shown in the body (`NUMPAGES`, `SECTIONPAGES`) are known only after
// paginating: lay out again with the totals found until they hold (bounded). Footnotes
+4
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 {
@@ -207,6 +209,8 @@ pub fn layout_equation(
props: &MathProps,
counter: &mut u32,
) -> MathLayout {
#[cfg(test)]
crate::test_fonts();
let parsed;
let nodes: &Arg = if math.nodes.is_empty() && !math.linear {
parsed = parse_linear(linear);
+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)
}
+16
View File
@@ -35,6 +35,7 @@ fn lay_in(math: &Math, lin: &str, display: bool, avail: f32, mp: &MathProps) ->
#[test]
fn eq_array_rows_without_ampersand_are_centred() {
crate::test_fonts();
// Word centres a row with no `&` in the array (#2617: a case row "a, n odd" above
// "|a|, &n even"), whether or not the other rows have one.
use wordcraft_doc::math::*;
@@ -65,6 +66,7 @@ fn eq_array_rows_without_ampersand_are_centred() {
#[test]
fn long_display_equations_wrap_before_operators_and_indent() {
crate::test_fonts();
let sum = "y=a+b+c+d+e+f+g+h+i+j+k+l+m+n+o+p+q+r+s+t+u+v+w";
let mp = MathProps::default();
let full = lay_in(&Math::default(), sum, true, 1e4, &mp);
@@ -105,6 +107,7 @@ fn long_display_equations_wrap_before_operators_and_indent() {
#[test]
fn manual_breaks_start_new_lines() {
crate::test_fonts();
let mp = MathProps::default();
let nodes = vec![MNode::Run(MRun::new("a=b")), MNode::Run(MRun { brk: Some(0), ..MRun::new("+c") })];
let m = lay_in(&Math { nodes, ..Default::default() }, "", true, 400.0, &mp);
@@ -121,6 +124,7 @@ fn manual_breaks_start_new_lines() {
#[test]
fn short_display_equations_stay_on_one_line() {
crate::test_fonts();
let mp = MathProps::default();
let one = lay_in(&Math::default(), "a+b=c", true, 400.0, &mp);
assert_eq!(lines(&one).len(), 1);
@@ -130,6 +134,7 @@ fn short_display_equations_stay_on_one_line() {
#[test]
fn fraction_is_stacked_with_a_rule() {
crate::test_fonts();
let flat = lay("ab", true);
let f = lay("a/b", true);
assert!(f.ascent > flat.ascent + 2.0 && f.descent > flat.descent + 2.0, "{f:?}");
@@ -139,6 +144,7 @@ fn fraction_is_stacked_with_a_rule() {
#[test]
fn superscript_is_raised_and_smaller() {
crate::test_fonts();
let m = lay("x^2", false);
let sizes: Vec<(f32, f32)> = m
.items
@@ -154,6 +160,7 @@ fn superscript_is_raised_and_smaller() {
#[test]
fn operators_get_spaces_and_unary_minus_does_not() {
crate::test_fonts();
let tight = lay("ab", false);
let spaced = lay("a+b", false);
let unary = lay("−b", false);
@@ -164,6 +171,7 @@ fn operators_get_spaces_and_unary_minus_does_not() {
#[test]
fn display_operators_are_big_with_limits_over_and_under() {
crate::test_fonts();
let d = lay("∑_(i=1)^n▒i", true);
let t = lay("∑_(i=1)^n▒i", false);
assert!(d.ascent + d.descent > t.ascent + t.descent, "display sum taller");
@@ -172,6 +180,7 @@ fn display_operators_are_big_with_limits_over_and_under() {
#[test]
fn delimiters_grow_around_tall_content() {
crate::test_fonts();
let small = lay("(x)", true);
let big = lay("((a)/(b))", true);
let h = |m: &MathLayout| m.ascent + m.descent;
@@ -180,6 +189,7 @@ fn delimiters_grow_around_tall_content() {
#[test]
fn every_structure_lays_out() {
crate::test_fonts();
use wordcraft_doc::math::*;
let x = || vec![MNode::Run(MRun::new("x"))];
let nodes = vec![
@@ -217,6 +227,7 @@ fn every_structure_lays_out() {
/// drawn from a fallback font, not as a missing-glyph box.
#[test]
fn characters_the_math_font_lacks_fall_back() {
crate::test_fonts();
let (face, _) = wordcraft_fonts::math::resolve_math("Cambria Math");
let Some(c) = ['⨌', '⫿', '⟅', 'ア', '中', '가'].into_iter().find(|c| !face.covers(*c) && FontDb::global().fallback_for(*c, face.id()).is_some())
else {
@@ -243,6 +254,7 @@ fn characters_the_math_font_lacks_fall_back() {
#[test]
fn caret_slots_cover_every_position() {
crate::test_fonts();
use wordcraft_doc::math_edit::{MathPos, move_right, units};
let math = Math { nodes: wordcraft_doc::math::parse_linear("x=(-b±√(b^2-4ac))/2a+■(a&b@c&d)"), ..Default::default() };
let m = layout_equation(&math, "", &rc(), true, 400.0, &MathProps::default(), &mut 0);
@@ -285,6 +297,7 @@ fn an_arguments_extent_spans_its_caret_positions() {
#[test]
fn empty_slots_show_placeholders_on_screen_only() {
crate::test_fonts();
let math = Math { nodes: wordcraft_doc::math::parse_linear("⬚/⬚"), ..Default::default() };
let m = layout_equation(&math, "", &rc(), true, 400.0, &MathProps::default(), &mut 0);
assert!(m.items.iter().all(|i| matches!(i, MItem::ScreenOnly(_) | MItem::Rect { .. })), "{:?}", m.items);
@@ -295,6 +308,7 @@ fn empty_slots_show_placeholders_on_screen_only() {
#[test]
fn numbered_equations_fill_the_line_with_the_number_at_the_right() {
crate::test_fonts();
let math = Math { nodes: wordcraft_doc::math::parse_linear("E=mc^2#(1)"), ..Default::default() };
let m = layout_equation(&math, "", &rc(), true, 400.0, &MathProps::default(), &mut 0);
assert!((m.width - 400.0).abs() < 0.01, "{}", m.width);
@@ -313,6 +327,7 @@ fn numbered_equations_fill_the_line_with_the_number_at_the_right() {
#[test]
fn math_alphabets() {
crate::test_fonts();
assert_eq!(math_alnum('x', false, true, MScr::Roman), '𝑥');
assert_eq!(math_alnum('h', false, true, MScr::Roman), 'ℎ');
assert_eq!(math_alnum('R', false, false, MScr::DoubleStruck), 'ℝ');
@@ -362,6 +377,7 @@ fn eqarr_row_without_an_align_marker_wider_than_the_columns_widens_the_array() {
#[test]
fn hostile_trees_never_panic() {
crate::test_fonts();
use wordcraft_doc::math::*;
// Deep nesting.
let mut n = vec![MNode::Run(MRun::new("x"))];
+247
View File
@@ -0,0 +1,247 @@
//! 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;
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(&d);
}
}
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))
}
/// 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 {
counts: BTreeMap<char, u64>,
truncated: bool,
/// Every distinct code point, started when the list passes its cap.
over: Option<Box<CodePointSet>>,
}
impl Found {
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, d: &Draw) {
match d {
Draw::Glyphs { face, glyphs, text, ranges, .. } => self.run(face, glyphs.iter().map(|g| g.0), text, ranges),
Draw::Figure { draws: items, .. } | Draw::Turned { items, .. } | Draw::Rotated { items, .. } | Draw::Clip { items, .. } => {
for d in items {
self.walk(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 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()
}
+1
View File
@@ -226,6 +226,7 @@ mod tests {
#[test]
fn show_level_and_collapse_hide_what_is_under_a_heading() {
crate::test_fonts();
// H1, body, H2, body, H1, body
let levels = [1, BODY, 2, BODY, 1, BODY];
let all = OutlineView::default();
+8
View File
@@ -376,6 +376,7 @@ mod label_spacing_tests {
#[test]
fn fallback_label_ascent() {
crate::test_fonts();
let body = (1921.0 / 2048.0 * 11.0, 434.0 / 2048.0 * 11.0);
let low = measured_label_lines(LineSpacing::Multiple(1.15), (9.802246, 2.379395), 9.802246, false, body);
let tall = measured_label_lines(LineSpacing::Multiple(1.15), (11.0, 3.586), 11.0, false, body);
@@ -385,6 +386,7 @@ mod label_spacing_tests {
#[test]
fn large_label_overflow_is_not_multiplied() {
crate::test_fonts();
for (multiple, expected) in [(1.0, 25.20), (1.25, 28.80), (2.0, 39.36)] {
let lines = measured_label_lines(LineSpacing::Multiple(multiple), (22.265625, 5.859375), 22.265625, false, ROBOTO_METRICS);
assert!((lines[0].height - expected).abs() <= 0.25, "{multiple}: {} vs {expected}", lines[0].height);
@@ -396,12 +398,14 @@ mod label_spacing_tests {
#[test]
fn small_label_descent_does_not_inflate_text_spacing() {
crate::test_fonts();
let lines = measured_label_lines(LineSpacing::Multiple(1.25), (8.499023, 3.5009766), 9.0, false, ROBOTO_METRICS);
assert!((lines[0].height - 17.52).abs() <= 0.25, "{}", lines[0].height);
}
#[test]
fn label_leading_and_earlier_font_ascent_are_retained() {
crate::test_fonts();
// Apple Symbols 24 pt: full leading gives an 18 pt label ascent. The text
// pitch is still Roboto 12 pt. A shorter final fallback run must not hide it.
for metrics in [(16.998047, 7.001953), (8.0, 3.0)] {
@@ -412,6 +416,7 @@ mod label_spacing_tests {
#[test]
fn minimum_spacing_keeps_text_descent_and_label_leading() {
crate::test_fonts();
for (metrics, ascent, expected) in
[((22.265625, 5.859375), 22.265625, 25.20), ((8.499023, 3.5009766), 9.0, 13.92), ((16.998047, 7.001953), 18.0, 20.88)]
{
@@ -422,6 +427,7 @@ mod label_spacing_tests {
#[test]
fn labels_keep_fixed_and_minimum_spacing_rules() {
crate::test_fonts();
for (spacing, expected) in [(LineSpacing::Exactly(24.0), 24.0), (LineSpacing::AtLeast(30.0), 30.0)] {
let lines = measured_label_lines(spacing, (22.265625, 5.859375), 22.265625, false, ROBOTO_METRICS);
assert!((lines[0].height - expected).abs() < 0.001);
@@ -430,6 +436,7 @@ mod label_spacing_tests {
#[test]
fn label_overflow_only_affects_the_first_wrapped_line() {
crate::test_fonts();
let lines = measured_label_lines(LineSpacing::Multiple(1.25), (22.265625, 5.859375), 22.265625, true, ROBOTO_METRICS);
assert!(lines.len() > 1);
assert!((lines[0].height - 28.80).abs() <= 0.25);
@@ -461,6 +468,7 @@ mod tests {
#[test]
fn symbols_ignore_roman_hint() {
crate::test_fonts();
let mut d = Document::new();
d.settings.roman_fonts = vec!["Symbol (PCL6)".into(), "Wingdings".into(), "No Such Roman Font".into()];
for family in ["Symbol (PCL6)", "Wingdings"] {
+2
View File
@@ -645,6 +645,8 @@ fn shown_text(cx: &Cx) -> Option<String> {
/// Lay out one paragraph.
pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
#[cfg(test)]
crate::test_fonts();
let doc = env.doc;
let rp = paragraph_props(p, env);
let res = Resolver { doc, style: p.props.style.as_deref(), table: env.table.map(|t| &t.chr) };
+193
View File
@@ -0,0 +1,193 @@
//! 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();
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 out.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);
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))
{
let failure = match doc.media.get(&pic.media) {
Some(bytes) => media_check(bytes, output).err(),
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 !out.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);
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)).cloned();
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()
}),
}
}
}
}
+2
View File
@@ -433,6 +433,7 @@ mod tests {
#[test]
fn parses_names_levels_and_switches() {
crate::test_fonts();
let c = parse(r#"STYLEREF "Heading 1" \l \n \t \* MERGEFORMAT"#);
assert_eq!(c.style, "Heading 1");
assert!(c.last && c.suppress && !c.position);
@@ -491,6 +492,7 @@ mod tests {
#[test]
fn suppress_keeps_the_number() {
crate::test_fonts();
assert_eq!(suppress("Chapter 2.", &[(0, "2".into(), true)], 0), "2");
assert_eq!(suppress("Chapter", &[(0, "a".into(), false)], 1), "", "an ancestor's number the label doesn't show");
assert_eq!(suppress("Section3", &[(0, "3".into(), true)], 1), "3", "the text attached to the number");
+3
View File
@@ -1586,6 +1586,7 @@ mod width_tests {
#[test]
fn autofit_shares_donor_loss() {
crate::test_fonts();
let table = Table::new(1, 4, 260.0);
let mut grid = [30.0, 120.0, 20.0, 90.0];
let content = [(28.0, 28.0), (40.0, 120.0), (40.0, 40.0), (30.0, 90.0)];
@@ -1595,6 +1596,7 @@ mod width_tests {
#[test]
fn autofit_exhausts_donors() {
crate::test_fonts();
let table = Table::new(1, 2, 50.0);
let mut grid = [30.0, 20.0];
autofit(&mut grid, &[(25.0, 25.0), (40.0, 40.0)], &table, 50.0);
@@ -1603,6 +1605,7 @@ mod width_tests {
#[test]
fn autofit_keeps_fitting_widths() {
crate::test_fonts();
let table = Table::new(1, 3, 120.0);
let mut grid = [30.0, 40.0, 50.0];
autofit(&mut grid, &[(10.0, 10.0), (20.0, 20.0), (30.0, 30.0)], &table, 120.0);
File diff suppressed because it is too large Load Diff
+6
View File
@@ -50,6 +50,7 @@ fn quarter(d: &Document) -> f32 {
#[test]
fn digits_and_latin_beside_asian_text_get_a_quarter_of_the_font_size() {
crate::test_fonts();
let mut d = Document::from_text("法院94年A字");
let q = quarter(&d);
assert!(q > 0.0);
@@ -65,6 +66,7 @@ fn digits_and_latin_beside_asian_text_get_a_quarter_of_the_font_size() {
#[test]
fn the_space_crosses_runs_and_takes_the_asian_size() {
crate::test_fonts();
let mut d = Document::from_text("漢A漢");
let size = |s: f32| move |c: &mut CharProps| c.size = Some(s);
let p = para0(&mut d);
@@ -76,6 +78,7 @@ fn the_space_crosses_runs_and_takes_the_asian_size() {
#[test]
fn spaces_punctuation_hangul_and_latin_alone_get_nothing() {
crate::test_fonts();
for text in ["例 a", "5 例", "5、", "、8", "abc 123", "漢한", "한A", "漢%", "(A)"] {
let d = Document::from_text(text);
assert!(gaps(&d).iter().all(|g| *g == 0.0), "{text}: {:?}", gaps(&d));
@@ -84,6 +87,7 @@ fn spaces_punctuation_hangul_and_latin_alone_get_nothing() {
#[test]
fn a_field_result_is_classed_by_the_text_it_shows() {
crate::test_fonts();
let mut d = Document::from_text("漢漢");
let field =
InlineObject::Field { instr: "MERGEFIELD n".into(), result: "12".into(), locked: false, dirty: false, code: false, form: String::new() };
@@ -99,6 +103,7 @@ fn a_field_result_is_classed_by_the_text_it_shows() {
#[test]
fn a_character_grid_the_paragraph_snaps_to_turns_it_off() {
crate::test_fonts();
let with = |kind: DocGridType, snap: bool| {
let mut d = Document::from_text("法院94年");
d.last_section.doc_grid = Some(DocGrid { kind, line_pitch: 18.0, char_space: 0 });
@@ -116,6 +121,7 @@ fn a_character_grid_the_paragraph_snaps_to_turns_it_off() {
#[test]
fn the_space_takes_no_room_at_the_end_of_a_justified_line() {
crate::test_fonts();
let text = "abcdef 1漢漢漢";
let mut d = Document::from_text(text);
let pl = first_para(&lay(&d));
+22
View File
@@ -59,6 +59,7 @@ fn at(s: &str, n: usize) -> usize {
#[test]
fn visual_order_follows_uax9_rule_l2() {
crate::test_fonts();
// The example from UAX #9 / unicode-bidi: levels 0 0 0 1 1 1 2 2.
assert_eq!(visual_order(&[0, 0, 0, 1, 1, 1, 2, 2]), vec![0, 1, 2, 6, 7, 5, 4, 3]);
assert_eq!(visual_order(&[1, 1, 1]), vec![2, 1, 0]);
@@ -70,6 +71,7 @@ fn visual_order_follows_uax9_rule_l2() {
#[test]
fn levels_for_persian_with_latin_and_numbers() {
crate::test_fonts();
assert!(bidi_levels("plain English", false).is_empty(), "pure LTR paragraphs skip the algorithm");
let s = "سلام Word ۱۴۰۳";
let lv = bidi_levels(s, true);
@@ -85,6 +87,7 @@ fn levels_for_persian_with_latin_and_numbers() {
#[test]
fn rtl_paragraph_is_right_aligned_and_reads_right_to_left() {
crate::test_fonts();
let s = "سلام دنیا";
let d = doc_of(&[(s, true)]);
let l = lay(&d);
@@ -101,6 +104,7 @@ fn rtl_paragraph_is_right_aligned_and_reads_right_to_left() {
#[test]
fn ltr_paragraph_with_embedded_persian_word() {
crate::test_fonts();
let s = "Hello سلام world";
let d = doc_of(&[(s, false)]);
let l = lay(&d);
@@ -111,6 +115,7 @@ fn ltr_paragraph_with_embedded_persian_word() {
#[test]
fn numbers_and_latin_inside_persian_keep_their_order() {
crate::test_fonts();
// "Price 250 toman" in Persian, with a Latin product name and Persian punctuation.
let s = "قیمت 250 تومان برای Word، امسال ۱۴۰۳.";
let d = doc_of(&[(s, true)]);
@@ -132,6 +137,7 @@ fn numbers_and_latin_inside_persian_keep_their_order() {
#[test]
fn caret_and_hit_agree_in_bidi_text() {
crate::test_fonts();
for (s, rtl) in [("سلام world ۱۲۳", true), ("Hello سلام دنیا end", false), ("کتاب «WordCraft» (نسخه ۲)", true)] {
let d = doc_of(&[(s, rtl)]);
let l = lay(&d);
@@ -152,6 +158,7 @@ fn caret_and_hit_agree_in_bidi_text() {
#[test]
fn visual_steps_cover_the_line_in_order() {
crate::test_fonts();
let s = "سلام world";
let d = doc_of(&[(s, true)]);
let l = lay(&d);
@@ -182,6 +189,7 @@ fn visual_steps_cover_the_line_in_order() {
#[test]
fn selection_of_mixed_text_can_be_two_pieces() {
crate::test_fonts();
// Logical range "lo wo" of "Hello سلام world"? Select across the Persian word's start:
// "lo سل" is two visual pieces (the end of Hello, and the right part of سلام).
let s = "Hello سلام world";
@@ -196,6 +204,7 @@ fn selection_of_mixed_text_can_be_two_pieces() {
#[test]
fn rtl_indents_and_alignment_are_measured_from_the_right() {
crate::test_fonts();
let s = "متن فارسی";
let mut d = doc_of(&[(s, true), (s, true), (s, true)]);
let p0 = Pos::body(0, 0);
@@ -214,6 +223,7 @@ fn rtl_indents_and_alignment_are_measured_from_the_right() {
#[test]
fn rtl_list_label_sits_on_the_right() {
crate::test_fonts();
let mut d = doc_of(&[("مورد اول", true), ("مورد دوم", true)]);
let id = Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Numbered);
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(1, 0), &|p: &mut ParaProps| {
@@ -235,6 +245,7 @@ fn rtl_list_label_sits_on_the_right() {
#[test]
fn rtl_empty_and_trailing_lines_mirror() {
crate::test_fonts();
let d = doc_of(&[("", true)]);
let l = lay(&d);
assert!((caret_x(&l, 0, 0) - 540.0).abs() < 0.5, "empty right-to-left paragraph: caret at the right margin");
@@ -248,6 +259,7 @@ fn rtl_empty_and_trailing_lines_mirror() {
#[test]
fn bidi_display_draws_every_glyph_inside_the_line() {
crate::test_fonts();
let s = "این یک متن آزمایشی با English و عدد ۱۲۳ است. ".repeat(12);
let d = doc_of(&[(&s, true)]);
let l = lay(&d);
@@ -274,6 +286,7 @@ fn bidi_display_draws_every_glyph_inside_the_line() {
#[test]
fn hostile_bidi_text_never_panics() {
crate::test_fonts();
// Explicit embeddings, isolates, unmatched pops, marks with no base, lone surrogates' stand-ins.
let junk = [
"\u{202B}\u{202B}\u{202C}\u{202C}\u{202C}abc\u{2067}سلام\u{2069}\u{2069}",
@@ -301,6 +314,7 @@ fn hostile_bidi_text_never_panics() {
/// A face that has Arabic letters (installed system fonts), or `None` to skip.
fn arabic_face() -> Option<wordcraft_fonts::FaceRef> {
crate::test_fonts();
wordcraft_fonts::FontDb::global().fallback_for('ب', 0).map(|f| wordcraft_fonts::FaceRef::of(&f)).filter(|f| f.covers('پ'))
}
@@ -319,6 +333,7 @@ fn cluster_glyphs(glyphs: &[wordcraft_fonts::ShapedGlyph]) -> Vec<Vec<u32>> {
#[test]
fn persian_letters_join_and_brackets_mirror() {
crate::test_fonts();
let Some(face) = arabic_face() else {
eprintln!("skipped: no font with Persian letters installed");
return;
@@ -355,6 +370,7 @@ fn persian_letters_join_and_brackets_mirror() {
#[test]
fn persian_text_in_a_latin_font_stays_in_one_fallback_face() {
crate::test_fonts();
if arabic_face().is_none() {
eprintln!("skipped: no font with Persian letters installed");
return;
@@ -373,6 +389,7 @@ fn persian_text_in_a_latin_font_stays_in_one_fallback_face() {
#[test]
fn glyph_runs_carry_their_text_for_pdf_export() {
crate::test_fonts();
let s = "سلام WordCraft می‌خواهم";
let d = doc_of(&[(s, true)]);
let l = lay(&d);
@@ -402,6 +419,7 @@ fn glyph_runs_carry_their_text_for_pdf_export() {
#[test]
fn character_border_boxes_a_whole_rtl_word() {
crate::test_fonts();
let s = "کتاب خوب";
let mut d = doc_of(&[(s, true)]);
let b = wordcraft_doc::props::Border { space: 0.0, ..wordcraft_doc::props::Border::single(0.5) };
@@ -426,6 +444,7 @@ fn character_border_boxes_a_whole_rtl_word() {
#[test]
fn inline_picture_in_rtl_text_sits_in_its_place() {
crate::test_fonts();
// "متن [picture] بیشتر": in a right-to-left paragraph the picture is drawn where its cluster
// is on the line (left of the first word), not at its logical x.
let s = "متن بیشتر";
@@ -464,6 +483,7 @@ fn inline_picture_in_rtl_text_sits_in_its_place() {
/// margin and border are drawn on its right. Clicks find cells by where they are drawn.
#[test]
fn rtl_table_lays_its_columns_out_right_to_left() {
crate::test_fonts();
use wordcraft_doc::props::{Border, Borders};
use wordcraft_doc::{Path, Table};
let mut d = Document::from_text("before\nafter");
@@ -511,6 +531,7 @@ fn rtl_table_lays_its_columns_out_right_to_left() {
/// first column at the right, and the line between them stays midway.
#[test]
fn rtl_section_puts_its_first_column_on_the_right() {
crate::test_fonts();
use wordcraft_doc::section::Columns;
let text = "Columns flow from the right edge in a right-to-left section. ".repeat(150);
let mut d = Document::from_text(&text);
@@ -531,6 +552,7 @@ fn rtl_section_puts_its_first_column_on_the_right() {
#[test]
fn list_label_moves_with_its_text_when_the_paragraph_is_aligned() {
crate::test_fonts();
// Issue #1159: centring or end-aligning a list item moved the text and left the bullet at the
// margin. In Word the label stays attached to the start of the text, in both directions.
for (rtl, text) in [(false, "alpha beta"), (true, "مورد اول")] {
+13
View File
@@ -29,6 +29,7 @@ const MYANMAR: &str = "\u{1019}\u{103C}\u{1014}\u{103A}\u{1019}\u{102C}\u{1005}\
#[test]
fn myanmar_syllables_are_single_clusters() {
crate::test_fonts();
let l = lay(&Document::from_text(MYANMAR));
let pl = para_layout(&l, 0);
let starts: Vec<usize> = pl.clusters.iter().map(|c| c.start).collect();
@@ -46,6 +47,7 @@ fn myanmar_syllables_are_single_clusters() {
#[test]
fn myanmar_without_spaces_wraps_between_syllables() {
crate::test_fonts();
let text = MYANMAR.repeat(80);
let l = lay(&Document::from_text(&text));
let pl = para_layout(&l, 0);
@@ -66,11 +68,13 @@ fn myanmar_without_spaces_wraps_between_syllables() {
/// A face with Myanmar letters (an installed system font), or `None` to skip.
fn myanmar_face() -> Option<wordcraft_fonts::FaceRef> {
crate::test_fonts();
wordcraft_fonts::FontDb::global().fallback_for('\u{1000}', 0).map(|f| wordcraft_fonts::FaceRef::of(&f)).filter(|f| f.has_layout_tables())
}
#[test]
fn myanmar_prevowel_is_drawn_before_its_consonant() {
crate::test_fonts();
let Some(face) = myanmar_face() else {
eprintln!("skipped: no Myanmar font with OpenType tables installed");
return;
@@ -92,6 +96,7 @@ fn myanmar_prevowel_is_drawn_before_its_consonant() {
#[test]
fn myanmar_in_a_latin_font_falls_back_to_a_regular_myanmar_face() {
crate::test_fonts();
if myanmar_face().is_none() {
eprintln!("skipped: no Myanmar font with OpenType tables installed");
return;
@@ -115,12 +120,14 @@ const THAI_RUN: &str = "[D] น้ำ ค่ำ ปั้น ฝั่ง ปู
/// Is a font with Thai letters installed? `false` skips the test.
fn thai_font_installed() -> bool {
crate::test_fonts();
wordcraft_fonts::FontDb::global().fallback_for('\u{0E19}', 0).is_some()
}
/// Is a Thai font with Latin letters and digits installed (Tahoma, Leelawadee UI, Thonburi, the
/// TLWG fonts…)? Noto Sans Thai alone has none, so no face covers [`THAI_RUN`]: `false` skips.
fn thai_latin_font_installed() -> bool {
crate::test_fonts();
let face = wordcraft_fonts::word::resolve("TH Sarabun New", false, false).face;
THAI_RUN.chars().filter(|c| !c.is_whitespace()).all(|c| face.covers(c))
}
@@ -139,6 +146,7 @@ fn cluster_faces(text: &str, font: Option<&str>) -> Vec<(usize, wordcraft_fonts:
#[test]
fn a_missing_thai_font_draws_the_whole_run_in_one_thai_face() {
crate::test_fonts();
if !thai_latin_font_installed() {
eprintln!("skipped: no Thai font with Latin letters installed");
return;
@@ -161,6 +169,7 @@ fn a_missing_thai_font_draws_the_whole_run_in_one_thai_face() {
#[test]
fn a_thai_substitute_without_latin_hands_the_run_to_one_face_covering_it() {
crate::test_fonts();
if !thai_latin_font_installed() {
eprintln!("skipped: no Thai font with Latin letters installed");
return;
@@ -184,6 +193,7 @@ fn a_thai_substitute_without_latin_hands_the_run_to_one_face_covering_it() {
#[test]
fn latin_text_in_a_missing_latin_font_stays_in_its_substitute_beside_thai() {
crate::test_fonts();
if !thai_font_installed() {
eprintln!("skipped: no Thai font installed");
return;
@@ -215,6 +225,7 @@ fn latin_text_in_a_missing_latin_font_stays_in_its_substitute_beside_thai() {
#[test]
fn thai_in_an_installed_latin_font_keeps_the_latin_face_for_latin() {
crate::test_fonts();
if !thai_font_installed() {
eprintln!("skipped: no Thai font installed");
return;
@@ -230,6 +241,7 @@ fn thai_in_an_installed_latin_font_keeps_the_latin_face_for_latin() {
#[test]
fn a_missing_latin_font_with_a_thai_complex_script_font_keeps_both() {
crate::test_fonts();
if !thai_font_installed() {
eprintln!("skipped: no Thai font installed");
return;
@@ -251,6 +263,7 @@ fn a_missing_latin_font_with_a_thai_complex_script_font_keeps_both() {
#[test]
fn a_missing_roman_thai_font_is_not_drawn_in_cambria() {
crate::test_fonts();
// Word draws a missing font the font table classes as roman in Cambria, but a missing Thai
// font keeps its Thai substitute: the same face whether or not the table calls it roman.
let db = wordcraft_fonts::FontDb::global();
+2
View File
@@ -42,6 +42,7 @@ fn lines(l: &DocLayout) -> (usize, Vec<f32>) {
#[test]
fn line_grid_snaps_line_heights_and_fixes_lines_per_page() {
crate::test_fonts();
let text = vec!["Grid line"; 80].join("\n");
let d = with_grid(&text, DocGridType::Lines, 0);
let (first, hs) = lines(&lay(&d));
@@ -65,6 +66,7 @@ fn line_grid_snaps_line_heights_and_fixes_lines_per_page() {
#[test]
fn character_grid_places_east_asian_text_on_the_pitch() {
crate::test_fonts();
let text = "日本語の文書abc";
// The default font size plus 2.5 pt: a pitch unlike any font's advance.
let lines = wordcraft_doc::SectionProps { doc_grid: Some(DocGrid { kind: DocGridType::Lines, ..Default::default() }), ..Default::default() };
+12
View File
@@ -44,6 +44,7 @@ fn has_fi_ligature(l: &DocLayout) -> bool {
#[test]
fn caret_between_the_letters_of_a_ligature_is_inside_it() {
crate::test_fonts();
let l = lay(&doc("fi"));
assert!(has_fi_ligature(&l), "the default font should draw fi as a ligature");
let (a, b, c) = (caret_x(&l, 0), caret_x(&l, 1), caret_x(&l, 2));
@@ -62,6 +63,7 @@ fn fi_width_with_spacing(sp: Option<f32>) -> (DocLayout, f32) {
#[test]
fn character_spacing_inside_a_ligature_is_kept() {
crate::test_fonts();
let (l0, plain) = fi_width_with_spacing(None);
assert!(has_fi_ligature(&l0), "the default font should draw fi as a ligature");
for sp in [3.0_f32, -0.5] {
@@ -75,6 +77,7 @@ fn character_spacing_inside_a_ligature_is_kept() {
#[test]
fn clicks_inside_a_ligature_land_between_its_letters() {
crate::test_fonts();
let l = lay(&doc("fi"));
let (a, b, c) = (caret_x(&l, 0), caret_x(&l, 1), caret_x(&l, 2));
let cr = l.caret(&Pos::body(0, 1)).unwrap();
@@ -88,6 +91,7 @@ fn clicks_inside_a_ligature_land_between_its_letters() {
#[test]
fn every_letter_of_a_three_letter_ligature_has_its_own_caret() {
crate::test_fonts();
let l = lay(&doc("ffi"));
assert!(has_fi_ligature(&l), "the default font should draw ffi as a ligature");
let xs: Vec<f32> = (0..=3).map(|o| caret_x(&l, o)).collect();
@@ -96,6 +100,7 @@ fn every_letter_of_a_three_letter_ligature_has_its_own_caret() {
#[test]
fn a_combining_mark_stays_in_its_letters_cluster() {
crate::test_fonts();
let l = lay(&doc("fi\u{301}"));
let pl = para_layout(&l);
let offs = pl.line_offsets(0);
@@ -104,6 +109,7 @@ fn a_combining_mark_stays_in_its_letters_cluster() {
#[test]
fn selection_edges_inside_a_ligature_use_the_same_positions() {
crate::test_fonts();
let d = doc("fi");
let l = lay(&d);
let rects = l.selection_rects(&d, &Pos::body(0, 0), &Pos::body(0, 1), 0);
@@ -134,6 +140,7 @@ fn lam_alef_ligature() -> Option<DocLayout> {
#[test]
fn a_right_to_left_ligature_splits_mirrored() {
crate::test_fonts();
let Some(l) = lam_alef_ligature() else {
eprintln!("skipped: no lam-alef ligature font");
return;
@@ -146,6 +153,7 @@ fn a_right_to_left_ligature_splits_mirrored() {
#[test]
fn a_right_to_left_ligature_is_drawn_from_its_left_edge() {
crate::test_fonts();
// The glyph stays on lam, the rightmost letter; it must still start at alef's left edge.
let Some(l) = lam_alef_ligature() else {
eprintln!("skipped: no lam-alef ligature font");
@@ -162,6 +170,7 @@ fn a_right_to_left_ligature_is_drawn_from_its_left_edge() {
#[test]
fn an_emoji_zwj_sequence_stays_one_cluster() {
crate::test_fonts();
// 👩‍💻: woman, zero-width joiner, laptop — one grapheme, one caret stop on each side.
let t = "a\u{1F469}\u{200D}\u{1F4BB}b";
let l = lay(&Document::from_text(t));
@@ -269,6 +278,7 @@ fn a_mixed_bundled_font_combining_grapheme_preserves_drawing_and_atomic_boundari
#[test]
fn an_indic_conjunct_stays_one_cluster() {
crate::test_fonts();
// क्ष: ka, virama, ssa — one conjunct; the caret never stops inside it.
let l = lay(&Document::from_text("\u{915}\u{94D}\u{937}"));
let offs = para_layout(&l).line_offsets(0);
@@ -277,6 +287,7 @@ fn an_indic_conjunct_stays_one_cluster() {
#[test]
fn a_long_word_never_wraps_inside_a_ligature() {
crate::test_fonts();
// A narrow page forces an emergency break in the unbroken word; whatever the offset, a line
// never ends between a ligature's glyph and its glyphless letters.
let mut seen_ligature = false;
@@ -299,6 +310,7 @@ fn a_long_word_never_wraps_inside_a_ligature() {
#[test]
fn a_hyphenation_point_never_splits_a_ligature() {
crate::test_fonts();
// Soft hyphens sit between the letters of "ffi"; a line never ends between a ligature's glyph
// and its glyphless letters, whether or not the soft hyphen leaves the ligature intact.
for k in 0..24 {
+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:?}");
}
+2
View File
@@ -6,6 +6,7 @@ use crate::{Document, LayoutCache, LayoutOptions, Placed, layout};
/// Inter with its U+00A0 glyph twice as wide as its space, under the family name "Wnbsp".
/// Built from the bundled font so the test needs no installed font.
fn wide_nbsp_family() -> &'static str {
crate::test_fonts();
const FAMILY: &str = "Wnbsp";
let inter = wordcraft_fonts::word::resolve("Inter", false, false).face;
let mut data = inter.data().to_vec();
@@ -54,6 +55,7 @@ fn line_width(text: &str) -> f32 {
#[test]
fn a_no_break_space_is_as_wide_as_a_space() {
crate::test_fonts();
let face = wordcraft_fonts::word::resolve(wide_nbsp_family(), false, false).face;
assert_eq!(face.family, wide_nbsp_family(), "the test font is installed under its own name");
assert!(
+8
View File
@@ -65,6 +65,7 @@ fn gids_pair(text: &str, a: (bool, NumeralMode), b: (bool, NumeralMode)) -> (Vec
#[test]
fn parse_accepts_the_four_modes_any_case() {
crate::test_fonts();
assert_eq!(NumeralMode::parse("arabic"), Some(NumeralMode::Arabic));
assert_eq!(NumeralMode::parse("hindi"), Some(NumeralMode::Hindi));
assert_eq!(NumeralMode::parse("context"), Some(NumeralMode::Context));
@@ -78,6 +79,7 @@ fn parse_accepts_the_four_modes_any_case() {
#[test]
fn map_substitutes_ascii_digits_only() {
crate::test_fonts();
for d in '0'..='9' {
let hindi = char::from_u32(0x660 + (d as u32 - '0' as u32)).unwrap();
assert_eq!(NumeralMode::Hindi.map(d, false), hindi);
@@ -101,6 +103,7 @@ fn map_substitutes_ascii_digits_only() {
#[test]
fn resolve_uses_the_given_system_locale() {
crate::test_fonts();
for m in [NumeralMode::Arabic, NumeralMode::Hindi, NumeralMode::Context] {
assert_eq!(m.resolve(Some("ar-EG")), m, "only System is resolved");
}
@@ -115,6 +118,7 @@ fn resolve_uses_the_given_system_locale() {
#[test]
fn context_mode_on_a_very_long_digit_run_stays_linear() {
crate::test_fonts();
// Every digit used to scan back over all the digits and spaces before it (O(n²)).
let text = format!("سنة {}", "1 ".repeat(100_000));
let start = std::time::Instant::now();
@@ -129,6 +133,7 @@ fn context_mode_on_a_very_long_digit_run_stays_linear() {
#[test]
fn hindi_mode_reshapes_digits_but_keeps_offsets() {
crate::test_fonts();
let text = "Year 2024!";
let latin = lay_opts(text, false, NumeralMode::Arabic);
let hindi = lay_opts(text, false, NumeralMode::Hindi);
@@ -150,6 +155,7 @@ fn hindi_mode_reshapes_digits_but_keeps_offsets() {
#[test]
fn arabic_mode_keeps_typed_hindi_digits() {
crate::test_fonts();
let text = "عام ٢٠٢٤";
let (hindi, arabic) = gids_pair(text, (true, NumeralMode::Hindi), (true, NumeralMode::Arabic));
assert_eq!(hindi, arabic, "typed Hindi digits display as typed in Arabic mode");
@@ -157,6 +163,7 @@ fn arabic_mode_keeps_typed_hindi_digits() {
#[test]
fn context_mode_follows_run_direction() {
crate::test_fonts();
// Right-to-left paragraph: digits behave like Hindi mode.
let rtl = "سنة 2024";
let (ctx, hin) = gids_pair(rtl, (true, NumeralMode::Context), (true, NumeralMode::Hindi));
@@ -173,6 +180,7 @@ fn context_mode_follows_run_direction() {
#[test]
fn remap_range_maps_display_bytes_to_original() {
crate::test_fonts();
use crate::para::remap_range;
// "a5ب": '5' (1 byte) displays as '٥' (2 bytes), so display offsets shift after it.
let orig = "a5ب";
+14
View File
@@ -35,6 +35,7 @@ fn body_left(l: &DocLayout) -> Vec<f32> {
#[test]
fn mirrored_margins_put_the_inside_margin_toward_the_binding() {
crate::test_fonts();
// #452: inside 108 (plus a 10pt gutter), outside 36. Odd pages: inside on the left; even
// pages: on the right.
let mut d = Document::from_text(&"Body line\n".repeat(90));
@@ -55,6 +56,7 @@ fn mirrored_margins_put_the_inside_margin_toward_the_binding() {
#[test]
fn inside_and_outside_floats_swap_sides_on_even_pages() {
crate::test_fonts();
// #1181: shapes Word places in the outside margin of a book (mirrored margins) all came out
// in the right margin. Inside 108 plus a 10pt gutter, outside 36; the paragraph on page 2
// carries the same six shapes as page 1's, told apart by their widths.
@@ -124,6 +126,7 @@ fn inside_and_outside_floats_swap_sides_on_even_pages() {
#[test]
fn outside_floating_tables_swap_sides_on_even_pages() {
crate::test_fonts();
// #1181, for a floating table (`w:tblpXSpec="outside"`): on page 2 of a book the outside is
// the left. One positioned from the page edge stays there on both pages.
use wordcraft_doc::para::{Anchor, FloatAlign};
@@ -159,6 +162,7 @@ fn outside_floating_tables_swap_sides_on_even_pages() {
#[test]
fn a_tall_footer_pushes_the_body_up() {
crate::test_fonts();
// #453: an eight-line footer reaches far above the 72pt bottom margin.
let mut d = Document::from_text(&"Body line\n".repeat(60));
let id = d.add_part(PartKind::Footer, paras("Footer line", 8));
@@ -184,6 +188,7 @@ fn a_tall_footer_pushes_the_body_up() {
#[test]
fn a_linked_tall_header_pushes_the_next_sections_body_down() {
crate::test_fonts();
// #454: section 2 has no header of its own and shows section 1's (link to previous).
let mut d = Document::from_text("First section\nSecond section");
let id = d.add_part(PartKind::Header, paras("Header line", 8));
@@ -203,6 +208,7 @@ fn a_linked_tall_header_pushes_the_next_sections_body_down() {
#[test]
fn long_endnotes_continue_on_new_pages() {
crate::test_fonts();
// #456: an 80-paragraph endnote after a one-line body.
let mut d = Document::from_text("Body");
let id = d.add_part(PartKind::Endnote, paras("Endnote paragraph", 80));
@@ -230,6 +236,7 @@ fn long_endnotes_continue_on_new_pages() {
#[test]
fn caret_in_a_continued_footnote_is_found_on_its_page() {
crate::test_fonts();
// #457: one long footnote paragraph split over pages 1 and 2.
let mut d = Document::from_text(&"Body text line.\n".repeat(16));
let id = d.add_part(
@@ -264,6 +271,7 @@ fn caret_in_a_continued_footnote_is_found_on_its_page() {
/// own pages). The footer shows each letter's last page, not the file's stale result.
#[test]
fn page_references_in_footers_show_their_bookmarks_page() {
crate::test_fonts();
let field =
|instr: &str| InlineObject::Field { instr: instr.into(), result: "9".into(), locked: false, dirty: false, code: false, form: String::new() };
let footer = |d: &mut Document, mark: &str| {
@@ -379,6 +387,7 @@ fn mark(d: &mut Document, path: &Path, off: usize, name: &str) {
/// section numbered in roman numerals are found on the page and in the format they land in.
#[test]
fn page_references_find_bookmarks_in_split_paragraphs_tables_and_roman_sections() {
crate::test_fonts();
// One long paragraph over two pages, marked at its start and its end.
let mut d = Document::from_text(&"Long paragraph text running on. ".repeat(160));
let n = d.para(StoryRef::Body, &Path::top(0)).unwrap().len();
@@ -423,6 +432,7 @@ fn page_references_find_bookmarks_in_split_paragraphs_tables_and_roman_sections(
/// A text box in a footer resolves its page references; one in the body keeps its result.
#[test]
fn page_references_in_footer_text_boxes_are_resolved() {
crate::test_fonts();
use wordcraft_doc::para::{Float, ShapeKind, Wrap};
let text_box = |d: &mut Document| {
let mut p = Paragraph::new();
@@ -475,9 +485,11 @@ fn page_references_in_footer_text_boxes_are_resolved() {
#[test]
fn widow_control_moves_a_three_line_paragraph_whole() {
crate::test_fonts();
// A three-line paragraph with room for two at the bottom of a page can't leave a widow (two
// and one) nor an orphan (one and two): Word moves it whole. A four-line one splits two and
// two. Sweep the paragraph down the page a point at a time and check every split.
// Shift the filler immediately before the probe, so the sweep tests partial-line room.
let mut split4 = false;
for (want, words) in [(3usize, 45usize), (4, 55)] {
for (fill, shift) in (30usize..60).flat_map(|f| (0..15).step_by(2).map(move |s| (f, s))) {
@@ -508,6 +520,7 @@ fn widow_control_moves_a_three_line_paragraph_whole() {
#[test]
fn footer_border_limits_body() {
crate::test_fonts();
use wordcraft_doc::props::{Border, Borders, HeightRule};
let mut d = Document::from_text(&"Body line\n".repeat(40));
d.last_section.page_h = 400.0;
@@ -537,6 +550,7 @@ fn footer_border_limits_body() {
/// number format is known, isn't taken for the page's own footer.
#[test]
fn a_roman_page_number_in_the_footer_stays_roman_with_the_page_count_in_the_body() {
crate::test_fonts();
let field = |instr: &str| InlineObject::Field {
instr: instr.into(),
result: String::new(),
+13
View File
@@ -52,6 +52,7 @@ const CHAPTERS: &[(&str, &str, bool)] = &[
#[test]
fn each_page_s_header_shows_its_chapter() {
crate::test_fonts();
let d = doc(CHAPTERS, &[r#"STYLEREF "Heading 1" \* MERGEFORMAT"#, "STYLEREF 1", "STYLEREF heading1 \\l"]);
let h = headers(&d);
assert_eq!(h.len(), 4);
@@ -62,6 +63,7 @@ fn each_page_s_header_shows_its_chapter() {
#[test]
fn search_from_the_bottom_with_l() {
crate::test_fonts();
let paras = [("Heading2", "First", false), ("Heading2", "Second", false), ("Normal", "Body", false), ("Heading2", "Third", true)];
let d = doc(&paras, &[r#"STYLEREF "Heading 2""#, r#"STYLEREF "Heading 2" \l"#]);
assert_eq!(headers(&d), [vec!["First", "Second"], vec!["Third", "Third"]]);
@@ -69,6 +71,7 @@ fn search_from_the_bottom_with_l() {
#[test]
fn a_missing_style_or_no_paragraph_of_it_is_an_error() {
crate::test_fonts();
let d = doc(CHAPTERS, &[r#"STYLEREF "No Such Style""#, r#"STYLEREF "Heading 3""#, "STYLEREF 12"]);
let err = styleref::NO_TEXT;
assert_eq!(headers(&d)[0], [err, err, err]);
@@ -77,6 +80,7 @@ fn a_missing_style_or_no_paragraph_of_it_is_an_error() {
#[test]
fn before_the_first_heading_the_next_one_shows() {
crate::test_fonts();
let paras = [("Normal", "Preface", false), ("Heading1", "Chapter", true)];
let d = doc(&paras, &[r#"STYLEREF "Heading 1""#, r#"STYLEREF "Heading 1" \p"#]);
assert_eq!(headers(&d), [vec!["Chapter", "below"], vec!["Chapter", "below"]]);
@@ -84,6 +88,7 @@ fn before_the_first_heading_the_next_one_shows() {
#[test]
fn character_styles_and_paragraph_numbers() {
crate::test_fonts();
let mut d = doc(CHAPTERS, &[r#"STYLEREF Strong"#, r#"STYLEREF "Heading 1" \n"#, r#"STYLEREF "Heading 2" \w"#, r#"STYLEREF "Heading 1" \s"#]);
// Bold a word on page 2 with the Strong character style.
d.format_range(&Pos::body(3, 8), &Pos::body(3, 11), &|c: &mut CharProps| c.style = Some("Strong".into())).unwrap();
@@ -102,6 +107,7 @@ fn character_styles_and_paragraph_numbers() {
/// has no number to list.
#[test]
fn full_context_keeps_the_parent_of_a_none_level_heading() {
crate::test_fonts();
let mut d = doc(CHAPTERS, &[r#"STYLEREF "Heading 2" \w"#, r#"STYLEREF "Heading 2" \r"#]);
let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
if let Some(n) = Arc::make_mut(&mut d.numbering).nums.iter_mut().find(|n| n.id == num) {
@@ -118,6 +124,7 @@ fn full_context_keeps_the_parent_of_a_none_level_heading() {
/// empty label of its own adds no part in full context: `3`, not `3.`.
#[test]
fn a_none_level_heading_s_number_has_no_text_and_no_trailing_period() {
crate::test_fonts();
let numbered = |text: &str, codes: &[&str]| {
let mut d = doc(CHAPTERS, codes);
let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
@@ -143,6 +150,7 @@ fn a_none_level_heading_s_number_has_no_text_and_no_trailing_period() {
/// Editing a heading updates the headers that show it, through the paragraph cache.
#[test]
fn headers_follow_heading_edits() {
crate::test_fonts();
let mut d = doc(CHAPTERS, &[r#"STYLEREF "Heading 1""#]);
let mut c = LayoutCache::new();
let first = layout(&d, &mut c, &LayoutOptions::default());
@@ -158,6 +166,7 @@ fn headers_follow_heading_edits() {
/// documents lay out on every keystroke); an edited heading or a changed style rebuilds it.
#[test]
fn style_index_is_reused_until_the_body_or_styles_change() {
crate::test_fonts();
let mut d = doc(CHAPTERS, &[r#"STYLEREF "Heading 1""#]);
let mut c = LayoutCache::new();
let index = |c: &LayoutCache| c.styleref.as_ref().map(|(_, _, ix)| ix.clone()).unwrap();
@@ -186,6 +195,7 @@ fn style_index_is_reused_until_the_body_or_styles_change() {
/// `STYLEREF 1` finds the style named `heading 1` when its id is localized (#1286).
#[test]
fn heading_level_matches_a_localized_style_id() {
crate::test_fonts();
let paras: Vec<(&str, &str, bool)> = CHAPTERS.iter().map(|(s, t, b)| (if *s == "Heading1" { "berschrift1" } else { *s }, *t, *b)).collect();
let mut d = doc(&paras, &["STYLEREF 1"]);
let st = Arc::make_mut(&mut d.styles).styles.iter_mut().find(|s| s.id == "Heading1").unwrap();
@@ -200,6 +210,7 @@ fn heading_level_matches_a_localized_style_id() {
/// edited heading still updates the headers that show it.
#[test]
fn body_edits_keep_styleref_headers_cached() {
crate::test_fonts();
let mut d = doc(CHAPTERS, &[r#"STYLEREF "Heading 1""#, r#"STYLEREF "Heading 1" \p"#]);
let mut c = LayoutCache::new();
let opts = LayoutOptions::default();
@@ -244,6 +255,7 @@ fn field_char_xs(d: &Document) -> Vec<(String, Vec<f32>)> {
/// also in a right-to-left header paragraph.
#[test]
fn hebrew_styleref_result_reads_right_to_left() {
crate::test_fonts();
let d = doc(&[("Heading1", "פרק א", false), ("Normal", "Text.", false)], &[r#"STYLEREF "Heading 1""#]);
let got = field_char_xs(&d);
let (text, xs) = &got[0];
@@ -312,6 +324,7 @@ fn rtl_header(d: &mut Document) {
/// Hebrew word in brackets in a left-to-right result draws its brackets the right way round.
#[test]
fn field_results_mirror_brackets_with_their_bidi_runs() {
crate::test_fonts();
let mut got = Vec::new();
// The field-code view of a PAGE field in a right-to-left header.
let mut d = doc(&[("Normal", "Text.", false)], &["PAGE"]);
+4
View File
@@ -57,6 +57,7 @@ fn frame_of(l: &DocLayout, id: u32) -> Rect {
#[test]
fn align_text_and_text_direction_place_the_text_in_the_box() {
crate::test_fonts();
let at = |body: TextBody| {
let mut d = Document::from_text("x");
let id = text_box(&mut d, 0, "Hi", 144.0, 144.0, body);
@@ -87,6 +88,7 @@ fn align_text_and_text_direction_place_the_text_in_the_box() {
#[test]
fn linked_boxes_continue_the_text() {
crate::test_fonts();
let mut d = Document::from_text("one\ntwo");
let text = "Words that run on well past the first little box. ".repeat(6);
let a = text_box(&mut d, 0, &text, 144.0, 48.0, TextBody::default());
@@ -125,6 +127,7 @@ fn linked_boxes_continue_the_text() {
#[test]
fn hostile_link_chains_stay_bounded() {
crate::test_fonts();
// Boxes linking in a circle show their own text; a box linking to itself too.
let mut d = Document::from_text("one\ntwo\nthree");
let a = text_box(&mut d, 0, "A", 72.0, 36.0, TextBody::default());
@@ -142,6 +145,7 @@ fn hostile_link_chains_stay_bounded() {
#[test]
fn own_margins_and_resize_to_fit_keep_the_text_in_view() {
crate::test_fonts();
// #743: a cover page's two-line box with no margins (`lIns="0"`…) is just tall enough for
// its text; with Word's default margins its second line would be cut.
let two = |h: f32, body: TextBody| {

Some files were not shown because too many files have changed in this diff Show More