200 Commits

Author SHA1 Message Date
Marcos Paulo 7df4d2b81c chore: release 0.6.2
FreeBSD / freebsd (push) Has been cancelled
Windows ARM64 / windows-arm64 (push) Has been cancelled
Release / release (push) Has been cancelled
2026-10-12 21:41:48 -03:00
Marcos Paulo 7d35f33ff1 build: refresh Cargo.lock for the masi/craft-libs fork rev 2026-10-12 21:08:22 -03:00
Marcos Paulo c19422ffbb feat(ui): Catppuccin themes
Add Latte, Frappé, Macchiato and Mocha to the interface theme setting, using the
masi/craft-libs fork (craft-ui). Shown in File > Options and View > Interface Theme.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 20:56:02 -03:00
Marcos Paulo 2536471d11 chore: debrand ArtCraft and Discord references
Remove the Discord button, Help/backstage links and brand assets; rewrite docs,
packaging URLs and catalogs. Add .debrand rules and the craft-skills submodule
(debrand skill) to keep upstream syncs clean.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 20:55:51 -03:00
NetterSilver 159b84f54c Track Changes: Accept All no longer joins an extra paragraph after a tracked delete from a paragraph end (#864) (#914)
Packaging lint / lint (push) Has been cancelled
Printing / Print (macos-latest) (push) Has been cancelled
Printing / Print (ubuntu-24.04) (push) Has been cancelled
Printing / Print (windows-latest) (push) Has been cancelled
* Track Changes: emptying a paragraph no longer turns its mark into the text's revision (#864)

Paragraph::delete copies the formatting of what it deletes onto the paragraph
mark when it empties the paragraph. That copy included the revision ids, so
accepting a tracked deletion of a whole paragraph text made the mark a deleted
mark too, and Accept All joined the following paragraph. Rejecting typed text
in an empty paragraph likewise rejected the paragraph mark. The mark now keeps
its own ins, del and fmt_change.

* Credit Silvertree2010 in the About window

* Docs: update the progress docs for this change

* Docs: Last reviewed stays as it was

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 12:08:57 -04:00
Navid Zaman Khan fbacffd282 fix(doc): count generated numbered-list labels as words (#893) (#908)
Microsoft Word treats generated numbered-list labels as independent words
in document word statistics, while omitting them from character counts.
Previously, word counting in WordCraft inspected plain run text only,
causing sample_document() to report 357 words instead of Word's 361.

- Add Document::story_word_count and story_word_count_of to compute words directly,
  including generated numbered labels while skipping bullet markers and empty labels.
- Handle LevelSuffix::Nothing correctly by counting labels as independent words
  without string concatenation artifacts.
- Keep Document::plain_text unadorned to maintain exact run text across docbin,
  clipboard, and tests.
- In review.wordCount, report words via story_word_count while preserving character
  counts from plain_text.
- Add regression tests covering numbered lists, bullets, LevelSuffix::Nothing, and
  review.wordCount word vs character parity.

Fixes #893

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 12:03:41 -04:00
Marco Arnulfo dbfa2af68a Drawing ink no longer jumps the view back to the caret (#859) (#892)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 11:59:20 -04:00
Marco Arnulfo b9899f410b Docs: list the CharProps and ParaProps fields (#865) (#899)
* Docs: list the CharProps and ParaProps fields (#865)

* docs/mcp.md: color also takes RRGGBB and #RRGGBB (#865, #881)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 11:56:50 -04:00
Marco Arnulfo b1d0aab486 styles.modify takes the hex colours format.color takes (#865) (#881)
A character colour given as a string now reads the way format.color reads it: RRGGBB, #RRGGBB, or auto/automatic in any case. The serialized forms ("Auto", {"Rgb": [r, g, b]}) read as before and are still what gets written, so documents and settings on disk round-trip unchanged. A colour that isn't one of these is still a parameter error.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 11:47:23 -04:00
skylar taylor-barrick a381c93e8d Merge pull request #783 from storytold/codex/craft-print-onboarding
Print: select printers and submit jobs through craft-print
2026-10-12 09:46:52 -06:00
witkowski01 5baaf97413 M15: Polish catalog covers style names, watermarks, paper sizes and the equation gallery (#871)
Adds the 374 strings the Polish catalog still lacked, mostly from #750 by
@olekkut, with a few corrections, and drops the Russian exception from the
Polish coverage test now that Polish has every Russian key.

Co-authored-by: Aleksander Kutycki <74899889+olekkut@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 11:42:55 -04:00
xmaks82 8826025f18 RTF: read page size and margins, lay tables out on their cell edges (#902)
* Open RTF in its code page, and Word/RTF files whose extension is wrong

RTF import read every 8-bit byte as Windows-1252, so Cyrillic (and any
other non-Western) RTF came out as mojibake. Bytes now decode in the
current font's code page (\fcharset, \cpg), else the default font's,
else the document's (\ansicpg), for the single-byte Windows code pages
874 and 1250-1258.

A .doc, .docx or .rtf whose content is another of these formats now opens
by its signature, as Word does: RTF saved as .doc (hh.ru résumés and
accounting exports are commonly saved that way), a .docx renamed .doc, a
Word 97-2003 file renamed .docx.

* RTF: read the page size and margins, lay tables out on their cell edges

RTF documents opened on US Letter with 1" margins whatever they said, and
every table got equal columns and a full grid. Russian accounting and HR
exports (A4, 2 cm margins, borderless layout tables whose rows have
different \cellx edges) came out about twice as long as in Word.

- \paperw/\paperh/\margl…\margb (and the …sxn section forms), \headery,
  \footery and \landscape set the document's section; out-of-range values
  keep Word's defaults. Tables size to that page.
- A table's column grid is the union of its rows' \cellx edges; each cell
  spans the grid columns between its left and right edges, as Word does.
- A table with no left/right cell borders is laid out without the grid
  style (\clbrdrb rules alone don't make it ruled; \brdrnone is no border).

On 44 RTF files (mostly .doc-named exports) checked against Word's page
counts: total page-count difference 198 -> 30, exact matches 3 -> 17, none
worse.

* RTF: paragraph spacing and indents, RTF defaults, the default font

- \sb, \sa, \sl/\slmult (multiple, at least, exactly), \li/\lin, \ri/\rin
  and \fi are read onto each paragraph (new model::Spacing); \pard resets
  them.
- The document's paragraph defaults are RTF's (single spacing, no space
  around paragraphs) instead of a new document's 1.15 and 8 pt after.
- Text that never selects a font is in the default font (\deff, font 0
  when the file names none), as Word shows it.

Against Word's page counts on 251 real documents (44 RTF, mostly .doc-named,
plus .docx): exact matches 212 -> 216, within 10% 248 -> 249; a 68-page
Russian federal law RTF goes from 89 to 73 pages (the rest is the Word
installation's own default margins, which the file doesn't name).

* RTF: import headers and footers

\header, \headerr, \headerl, \headerf and the \footer… forms were skipped,
so letterheads, logos and "page N" lines were lost. Each group is now read
into its own output (the body's paragraph, table and row state is set aside
and put back when the group closes), built into the document with its
pictures (new model::part_blocks), and attached to the section as the
default, first-page or even-page header or footer; \titlepg sets the
separate first page. A header left open by a truncated file still ends.

40 of 41 RTF files in the corpus have headers or footers; against Word's
page counts exact matches go 216 -> 218 of 251, none worse.

* RTF: cell and paragraph borders

Tables either got a full grid (any left/right cell border) or none, and
paragraph borders were kept only on empty paragraphs (as a rule), so the
rules under letterheads and section titles were lost.

- Cells: \clbrdrt/l/b/r with their line style (\brdrs, \brdrdb, \brdrdot,
  \brdrdash, \brdrth, \brdrtriple, \brdrwavy…, \brdrnone), \brdrw width and
  \brdrcf colour become the cell's own borders (model::Cell::borders); RTF
  tables get no grid style, so only the borders the file names are drawn,
  as in Word.
- Paragraphs: \brdrt/b/l/r likewise become the paragraph's borders
  (model::Para::borders), reset by \pard; an empty paragraph with only a
  bottom border stays a rule.

40 of 41 RTF files in the corpus name cell borders; page counts against
Word are unchanged (218 of 251 exact).

---------

Co-authored-by: Магда М.Г. <mail@mgmagda.ru>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 11:40:16 -04:00
xmaks82 e989fc2b3d Open RTF in its code page, and Word/RTF files whose extension is wrong (#896)
RTF import read every 8-bit byte as Windows-1252, so Cyrillic (and any
other non-Western) RTF came out as mojibake. Bytes now decode in the
current font's code page (\fcharset, \cpg), else the default font's,
else the document's (\ansicpg), for the single-byte Windows code pages
874 and 1250-1258.

A .doc, .docx or .rtf whose content is another of these formats now opens
by its signature, as Word does: RTF saved as .doc (hh.ru résumés and
accounting exports are commonly saved that way), a .docx renamed .doc, a
Word 97-2003 file renamed .docx.

Co-authored-by: Магда М.Г. <mail@mgmagda.ru>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 11:36:15 -04:00
Maxime BELLET 591cbb6f68 i18n: French wording fixes after #729 (#901)
Native-speaker review of the rows added late in #729:
- Overtype is « Refrappe », the term current versions of Word use in French
- « Attacher… » to match « Attacher un modèle »
- Outline promote/demote and the Outlining tab (« Mode Plan »), consistent
  with the existing Promote/Demote rows and « Fermer le mode Plan »
- SmartArt basic layouts « … de base »
- tense and wording in three help texts, and the Previous 1 or More wildcard

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 11:31:47 -04:00
xmaks82 7af79cd9f0 Fonts: treat Windows charset names as the font itself (#904)
Old Russian and Central European documents name fonts by their Windows 3.1
charset aliases: "Times New Roman Cyr", "Arial CYR", "Courier New CE",
"Arial (Hebrew)". Windows maps these to the font itself (FontSubstitutes);
we treated them as unknown families and fell back to the sans default.
resolve() now tries the base family, and substitutes for it when it isn't
installed (Times New Roman Cyr -> Liberation Serif, like Times New Roman).

Co-authored-by: Магда М.Г. <mail@mgmagda.ru>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 11:29:22 -04:00
Dmitrii Khlapov 0f9dc5c6ae #776: draw Wingdings, Wingdings 2/3, Webdings and Symbol text as Unicode symbols (#876)
Word's symbol fonts are proprietary and never bundled, so text set in them
showed the substitute's letters. New wordcraft_doc::symbol_fonts maps every
code (stored as U+F020-U+F0FF, e.g. w:sym, or as a plain 8-bit code) to its
Unicode equivalent: Symbol from Unicode's Adobe symbol.txt, the Wingdings
and Webdings sets from Unicode L2/12-368 (WG2 N4384), checked against UCD.
Layout shapes the mapped characters (fallback faces draw them); PDF text,
copy as text and TXT export read as the symbols; the stored text and docx
round trips are unchanged.

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 11:26:21 -04:00
Dmitrii Khlapov 9d1e207c78 #802: Insert › Picture takes Windows Metafiles and Enhanced Metafiles (#869)
WMF and EMF pictures already drew (crates/metafile) when they came inside a
document, but Insert › Picture refused them ("not a supported image"), and the
file dialog, drag-and-drop and paste lists left them out. A metafile is now
placed at its own frame size (points for placeable WMF and EMF; very large
frames scaled to 22 inches on the longer side) and stored as .wmf or .emf, so a
save writes it as such. render gains metafile_size().

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 11:19:13 -04:00
Brandon Thomas be7474566b Layout: blank pages put sections on the right side, and paragraph spacing is the larger of after and before (#733, rebased) (#964)
Rebased #733 (paragraph spacing larger of after and before; blank pages for odd/even section starts) onto main.

Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 11:18:57 -04:00
Dmitrii Khlapov 1350cb959b Font list previews no longer keep every family in memory (#848) (#874)
* #848: font list previews no longer load every family they show

The Font box drew each entry in its own face through the full layout
pipeline, which loaded the whole family into the font database for good
(faces are never unloaded). Every trip through the list kept tens of
megabytes. Entries are now drawn with FontDb::preview_face: a loaded
family's face is shared, an installed one is read into a face the
database doesn't keep and is freed when dropped, and render_text_line
draws it without the shared outline cache.

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

* Font preview test tolerates other tests loading fallbacks; gaps rows 1–3 de-duplicated, #848 noted in row 3

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 11:14:56 -04:00
Brandon Thomas 7dfa6b0ee2 Release 0.6.1: rebuild with the fixed shared release workflow
The previous release build failed in craft-common-actions, not in this
repo: riscv64 packaging got --formats deb,rpm,tar (fixed in
craft-common-actions#3), Linux jobs lacked the ALSA headers
(craft-common-actions#5), and Windows signing needed the AZURE_* secrets
listed in the caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:11:12 -04:00
skylar taylor-barrick 36af036233 PRINT: scope XDG absolute-path regression to Unix 2026-10-12 17:10:30 +02:00
Dmitrii Khlapov 7f1085fbbd #856: click in the left margin (selection bar) selects lines (#879)
A click in the left margin beside body text selects that line (trailing
space, and the paragraph mark on a paragraph's last line), a double-click
the paragraph, a triple-click or Ctrl/Cmd+click the document; dragging
there selects whole lines and Shift+click extends by lines. The pointer
shows the arrow there. Layout gains selection_bar_at, body_line_at and
line_span; select.line takes an optional pos; select.dragUnit takes the
units line and document.

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 11:08:43 -04:00
Dmitrii Khlapov 35b3600ea5 #852: a section break inserted over a selection keeps the selected text (#875)
Layout › Breaks (next page, continuous, even, odd) deleted the selection
before splitting, so with "one" selected the first section came out empty.
The break now goes just before the selection, which keeps its text and
direction, as in Word.

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 11:05:06 -04:00
skylar taylor-barrick f0d47b6090 PRINT: integrate current main and expose native print commands 2026-10-12 17:02:36 +02:00
Dmitrii Khlapov 1695705241 #861: the save prompt answers from the keyboard (#870)
Closing an edited document asked Save / Don't Save / Cancel with no button
focused, so the arrow keys did nothing and only the mouse could pick Don't
Save or Cancel. Save now has focus when the prompt opens, as in Word: Enter
saves, and the arrow keys (egui's focus navigation) or Tab move between the
buttons.

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 10:51:49 -04:00
Dmitrii Khlapov 691b136811 #811: smooth scrolling through a very large table (#891)
Every frame fingerprinted every item of every page for the page texture
cache (thousands of cell borders a page, formatted to strings), and the
markup-area check walked every paragraph twice a frame. Page content keys
are now taken once per layout and only for pages on screen or cached; the
document facts behind the markup area are cached per revision; textures of
pages far from the view are dropped. Layout and rendering are unchanged.

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 10:49:03 -04:00
Dmitrii Khlapov 7ee3cea875 Fonts: documents use the craft-fonts Simplified Chinese face too (#888)
Builds with craft-fonts (releases, the FreeBSD job) put only its Japanese
faces in the document font database; Noto Sans CJK SC served the interface
alone. Simplified-only ideographs in a document then had no face unless the
system had one, and the run-level CJK pick from #567 found no face covering a
mixed run, which the FreeBSD job's
east_asian_run_uses_one_face_covering_the_whole_run caught. The Chinese craft
faces now follow the Japanese ones (Mincho stays first for Japanese text).

fallback_reads_each_installed_font_file_once_however_many_characters_miss
used U+30001.., part of which Noto Sans CJK SC maps (CJK Extension G); it now
uses Supplementary Private Use Area-A, which no font here maps.

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 10:46:24 -04:00
Brandon Thomas 3e7f355e5a Ctrl+F searches in the Navigation pane; one Find and Replace dialog (#712) (#921)
* Find, Replace and Go To work like Word (#712)

Before: Ctrl+F opened both the Navigation pane and a Find window,
Advanced Find opened the same window, Ctrl+H failed with nothing to
replace, and Go To was a separate little window that only took a page.

After, as in Word:
- Ctrl+F and Home > Find open only the Navigation pane, with its search
  box focused so typing searches; selected text is searched for.
- Find, Replace and Go To are one Find and Replace dialog with three
  tabs: Advanced Find opens Find, Ctrl+H (edit.findReplace, which never
  marks the document edited) opens Replace, Ctrl+G / F5 open Go To.
- Go To moves to a page (a number, or +2 / -1 from the page the caret
  is on; empty, Next and Previous step a page) or picks a bookmark.
  edit.goto takes pageBy for scripts and agents.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Go To: list bookmarks only while Bookmark is chosen (#712)

Finding a document's bookmarks walks every paragraph; the Go To tab did it every frame even
while it showed pages.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 10:45:23 -04:00
rabis7575 cd6b2bbe46 Tables: Ctrl, Alt, left-edge and selected-cells border drags (#720)
* Rulers: table column and row markers; indents in a cell measured from it (#700)

With the caret in a table, the rulers show the table, as Word's do:

- The horizontal ruler marks the column borders of the caret's row (past
  the table's left edge); the vertical ruler marks the bottom of each of
  the table's rows on the page. Each marker has the resize pointer, a
  tooltip and a name for screen readers.
- Dragging a marker drags that border exactly like a drag on the page
  (#600): a dashed guide, Escape cancels, one undo step on release
  through `table.resizeDrag`. On the horizontal ruler only the column
  left of the border changes and the columns right of it move along, so
  the table's width changes (Word's ruler); a row marker sets the row's
  height. The markers follow any drag of the table's borders, on the
  ruler or on the page.
- In a cell, the indent markers and tabs are measured from the cell's
  text area (its box less its margins); they were placed as if the
  paragraph started at the page's margin.

- Layout: `cell_margins` (a cell's own margins, else the table's, else
  Word's), so the ruler uses what the layout does.
- UI: crates/ui-egui/src/table_ruler.rs; table_resize.rs starts a drag
  from a ruler marker (`grab`) and tells the ruler where a dragged border
  is (`moved`), sharing that computation with the dashed guide.
- Tests: layout (cell_margins matches where each cell's text starts);
  kittest with real pointer events: a column marker drag (the marker
  follows, one undo step, the columns right of it keep their widths), a
  row marker drag, and an 18 pt drag of the left-indent marker in a cell
  indenting the paragraph 18 pt.

Progress docs updated: ui-parity (rulers 70% -> 85%), gaps (the ruler's
column markers are off the list), target-app-parity, ROADMAP. No new
commands or labels (the tooltips reuse "Table Column Width" and "Table
Row Height"). `cargo xtask ci` passes.

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

* Rulers: Shift works the other way round on the ruler; row markers beside a vertical merge

Checked against Word (2019, Windows): on the ruler a column marker drag
changes only the column on its left and the columns right of it move
along (as before), and with Shift the neighbour makes up the difference,
so the table keeps its width; on the page Shift does the opposite. The
ruler now follows that: `ResizeDrag::plan` takes the Shift key and flips
it for a drag started on a ruler marker.

Since #669 a vertically merged cell's box spans all its rows, so a row
marker took the merge's bottom when the merge was the row's leftmost cell.
The marker now sits on the row's shortest box. (`Placed::Cell` patterns
take `..` for #669's new field.)

- Test (kittest, real pointer events): a Shift drag of a column marker
  on the ruler gives the neighbour's width to the column on the left and
  keeps the table's width.

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

* Tables: Ctrl, Alt, left-edge and selected-cells border drags (#711)

Word's border drags that #600 left out:

- Ctrl while dragging a column border: the columns right of it share the
  change in proportion to their widths, so the table keeps its width
  (none narrower than the minimum). Ctrl wins over Shift; on the table's
  right edge only the last column changes.
- The table's left edge can be dragged, from just outside it: the
  table's indent moves and the first column makes up the difference, so
  the other borders stay. A centred or right-aligned table becomes
  left-aligned where the edge was dropped; a floating table moves its
  horizontal offset. The horizontal ruler has a marker on it too.
- Alt while dragging a column border: the horizontal ruler shows each
  column's width as it is going to be.
- With the selection across two or more cells, dragging one of their
  column borders (in one of their rows) moves it only in their rows; the
  grid gets a line for the new border and the cells' spans follow. A
  border of a cell merged down past the selection moves in the whole
  column. Text selected within one cell isn't a cell selection (it can't
  be told from one selected cell yet), so it moves the whole column.

- Engine: `Share` (who makes up a column border's move),
  `Edge::Column(0)`, `ResizeTarget::limit_to_selection`/`only`/
  `planned_x`, `Plan::indent_by`/`cells`; `table.resizeDrag` takes
  `"ctrl"`. `plan` still takes a bool for Shift. Each drag is one command
  and one undo step.
- UI: table_resize.rs (keys, a guide down the rows that move,
  `measuring`), table_ruler.rs (left-edge marker, `widths`).
- Tests: engine (Ctrl in proportion and at the minimum; the left edge,
  also of a centred table; selected cells' rows only, with the grid and
  spans; a merged cell past the selection); kittest with real pointer
  events (a Ctrl drag, the left edge and its ruler marker, Alt's widths
  mid-drag).

Progress docs updated: ui-parity (tables on the page 80% -> 90%),
target-app-parity, ROADMAP. No new commands or labels. `cargo xtask ci`
passes.

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

* Tables: a centred or right-aligned table keeps its alignment when its left edge is dragged

Checked against Word (2019, Windows): dragging the left edge of a centred
table widens the first column by the whole drag and the table stays
centred (each side moves half); a right-aligned table keeps its right edge
and grows to the left. Only a left-aligned table's indent moves. #711 made
such a table left-aligned where the edge was dropped; now `move_left_edge`
leaves a centred or right-aligned table's alignment and indent alone and
says whether it moved anything, and the drop-point correction runs only
then.

The ruler's Shift (from #710) now goes through `share`: on a ruler marker
Shift makes the neighbour take up the change, without it only the column
on the left changes.

- Tests: the left-edge test checks the centred table (first column +20 pt,
  the others unchanged, still centred) and a right-aligned one (only the
  left edge moves); a kittest Shift drag on the ruler.

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

---------

Co-authored-by: rabis <340973947+rabis7575@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 10:43:04 -04:00
Brandon Thomas 03274b3b4f Alt+Q opens Search like Word (#819) (#919)
* Search: Alt+Q opens it from anywhere, like Word (#819)

- Alt+Q (and Alt, then Q on the keytips) opens the Search box with its
  text field focused, from the page or anywhere else; the key's own "q"
  is never typed into the box or the document. The title-bar box shows
  its Q keytip and its shortcut in the tooltip.
- An empty box suggests common commands; every word of a query has to
  match (so "insert table" finds Insert > Tables > Table, as the box's
  own hint promises); Enter runs the top match; "Find ... in the
  document" opens the Navigation pane on the results. Matching is
  about 2x faster than before.
- tools.search lists matching commands for scripts and agents.

Observed in Word for Windows (Microsoft 365): Alt+Q and Alt, Q focus
Search with nothing typed; multi-word queries match commands; the list
also offers finding the words in the document.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Search: Bulgarian, Czech, German, French, Hungarian, Italian and Polish strings

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 10:38:53 -04:00
rabis7575 8c8d5611a4 Rulers: table column and row markers; indents in a cell measured from it (#710)
* Rulers: table column and row markers; indents in a cell measured from it (#700)

With the caret in a table, the rulers show the table, as Word's do:

- The horizontal ruler marks the column borders of the caret's row (past
  the table's left edge); the vertical ruler marks the bottom of each of
  the table's rows on the page. Each marker has the resize pointer, a
  tooltip and a name for screen readers.
- Dragging a marker drags that border exactly like a drag on the page
  (#600): a dashed guide, Escape cancels, one undo step on release
  through `table.resizeDrag`. On the horizontal ruler only the column
  left of the border changes and the columns right of it move along, so
  the table's width changes (Word's ruler); a row marker sets the row's
  height. The markers follow any drag of the table's borders, on the
  ruler or on the page.
- In a cell, the indent markers and tabs are measured from the cell's
  text area (its box less its margins); they were placed as if the
  paragraph started at the page's margin.

- Layout: `cell_margins` (a cell's own margins, else the table's, else
  Word's), so the ruler uses what the layout does.
- UI: crates/ui-egui/src/table_ruler.rs; table_resize.rs starts a drag
  from a ruler marker (`grab`) and tells the ruler where a dragged border
  is (`moved`), sharing that computation with the dashed guide.
- Tests: layout (cell_margins matches where each cell's text starts);
  kittest with real pointer events: a column marker drag (the marker
  follows, one undo step, the columns right of it keep their widths), a
  row marker drag, and an 18 pt drag of the left-indent marker in a cell
  indenting the paragraph 18 pt.

Progress docs updated: ui-parity (rulers 70% -> 85%), gaps (the ruler's
column markers are off the list), target-app-parity, ROADMAP. No new
commands or labels (the tooltips reuse "Table Column Width" and "Table
Row Height"). `cargo xtask ci` passes.

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

* Rulers: Shift works the other way round on the ruler; row markers beside a vertical merge

Checked against Word (2019, Windows): on the ruler a column marker drag
changes only the column on its left and the columns right of it move
along (as before), and with Shift the neighbour makes up the difference,
so the table keeps its width; on the page Shift does the opposite. The
ruler now follows that: `ResizeDrag::plan` takes the Shift key and flips
it for a drag started on a ruler marker.

Since #669 a vertically merged cell's box spans all its rows, so a row
marker took the merge's bottom when the merge was the row's leftmost cell.
The marker now sits on the row's shortest box. (`Placed::Cell` patterns
take `..` for #669's new field.)

- Test (kittest, real pointer events): a Shift drag of a column marker
  on the ruler gives the neighbour's width to the column on the left and
  keeps the table's width.

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

---------

Co-authored-by: rabis <340973947+rabis7575@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 10:38:17 -04:00
rabis7575 f24a5b22b2 Tables: drags and Shift+arrows over cells select whole cells (#722)
* Tables: select, insert and move with the mouse; double-click a border to AutoFit

Like Word, on top of the border resizing:

- Selection areas: just above a column the pointer becomes a down arrow,
  left of a row the row arrow, along a cell's left edge (inside it) the
  cell arrow. A click selects the column, row or cell; a drag selects every
  one up to the pointer. Selected cells are shaded as cells, empty ones too.
- Insert buttons: left of a row border and above a column border a "+"
  shows with the border marked; a click adds a row or column there and puts
  the caret in it.
- Move handle past the table's top-left corner: a click selects the table;
  a drag shows a dashed outline and, on release, the table floats where it
  is dropped with the text wrapping around it (Word's behaviour). One undo
  step. Nested tables and tables outside the main text are only selected.
- A double-click on a column border fits the column left of it to its
  contents (the right neighbour gives or takes the room; on the right edge
  the table's width changes, up to the right margin).
- Table Layout > Select > Select Column (catalog 418/449).

Whole-cell selection: a column (or a rectangle of cells over several rows)
is not one run of the text, so selecting it makes a block of its cells'
paragraphs (`CellRange` on the existing `ColumnBlock`). Bold, alignment,
Delete, typing, copy and numbering act on those cells only (a numbered
column is one list); table commands (shading, merge, delete columns) take
the rectangle; Tab moves on without clearing it; Undo brings the selection
back. Cells of one row, or whole rows, stay an ordinary stream selection.
The format painter no longer clears a block's text (edit.pasteFormat was
handled like a paste). The mini toolbar stays hidden over whole-cell
selections and during these drags: pinned above the selection it covered
the column areas, the "+" buttons and the move handle.

Engine: crates/engine/src/cmd/table_mouse.rs - table.selectAt,
table.insertAt, table.moveDrag, table.fitColumn (page coordinates, so
agents and the control channel can do the same) and table.selectColumn;
hit testing reuses table_resize's drawn geometry, and borders keep priority
for resizing. 10 engine tests (areas, a column edited cell by cell,
numbering, rows/cells/drags, insert buttons, move, fit, format painter,
hostile params).
UI: crates/ui-egui/src/table_mouse.rs, wired into the existing canvas mouse
handler (no extra ui.interact areas, so clicks in a cell's text still place
the caret); kittest tests with real pointer events: column click, row drag,
"+" button, move-handle drag in one undo step, double-click fit.
New labels translated in all eleven catalogs. Progress docs updated: gaps,
ui-parity, target-app-parity, ROADMAP, parity checklist.
`cargo xtask ci` passes (fmt, clippy, test, assets, layers, wasm).

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

* Tables: a selected column's word count is its cells'; merged cells shade once

With whole cells selected (a column, or a rectangle that isn't one run of
the text) the status bar said "12 of 23 words": `Session::selected_text`
took everything between the selection's ends. It now takes each piece's
own text, a line each, for a column block too, so the status bar, Word
Count and the other callers see what is selected.

Since #669 a vertically merged cell is one box as tall as its rows, with a
`covered` box for each continuation row; shading selected cells skips
those, so a merged cell isn't shaded twice. `selected_paths` follows
main's `para_paths_in` (#418): a selection ending at the start of the next
paragraph doesn't take it.

- Test: a selected column's text is its three cells' and Word Count says
  three words.

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

* Tables: double-click AutoFit and the "+" buttons work as in Word

Checked against Word (2019, Windows):

- A double-click on a column border fits the column left of it to its
  contents and the columns right of it keep their widths, moving with the
  border, so the table narrows or widens (up to the right margin). The
  right neighbour used to give or take the difference.
- A "+" insert button selects the new row or column (the caret used to
  go into it); typing then goes into its first cell. If the table can't
  take another row or column, nothing changes.
- A double-click along a cell's left edge (the cell selection area)
  selects its row.

- Tests: engine (fit keeps the columns on the right; insert selects the
  new row or column, typing lands in its first cell) and kittest (real
  double-click on a border; "+" selects the row; double-click by a cell's
  edge selects the row).

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

* Tables: drags and Shift+arrows over cells select whole cells

An ordinary drag, Shift+click or Shift+arrow from one table cell into
another now selects the rectangle of whole cells between them, as Word
does, instead of the run of text between the two points: bold, alignment
and Delete leave the cells outside the rectangle alone (they already did
for Select Column and the table's selection areas). Between a cell and
text outside its table the selection takes whole rows, from the cell's
row out to the table's edge, and the text beyond. Extending again goes on
from where the selection really started and ends: dragging back into the
first cell selects its text again, Shift+Up after Shift+Down shrinks the
rectangle, and from a nested table's cell into another cell of the table
around it the outer table's cells are taken.

- Engine: `table::extend_selection` (the rule, down through nested
  tables) and `Session::sel_ends` (where a widened selection really
  runs); every caret command run with `extend` goes through it in
  `column::dispatch`, so agents get the same with `caret.*`.
- UI: a text drag extends through `caret.set` (still straight to the
  session, so nothing scrolls on every frame); the view follows the real
  caret.
- Tests: engine (drag across cells and back, nested tables, Shift+Down
  and Shift+Up through and past a table, Shift+click, from text into a
  table) and a kittest with a real mouse drag.

Progress docs updated: gaps (the known gap from #637 is gone),
ui-parity, target-app-parity, ROADMAP. `cargo xtask ci` passes.

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

---------

Co-authored-by: rabis <340973947+rabis7575@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 10:34:10 -04:00
rabis7575 284818b467 Tables: the right-click menu offers the table's commands (#694)
* Tables: select, insert and move with the mouse; double-click a border to AutoFit

Like Word, on top of the border resizing:

- Selection areas: just above a column the pointer becomes a down arrow,
  left of a row the row arrow, along a cell's left edge (inside it) the
  cell arrow. A click selects the column, row or cell; a drag selects every
  one up to the pointer. Selected cells are shaded as cells, empty ones too.
- Insert buttons: left of a row border and above a column border a "+"
  shows with the border marked; a click adds a row or column there and puts
  the caret in it.
- Move handle past the table's top-left corner: a click selects the table;
  a drag shows a dashed outline and, on release, the table floats where it
  is dropped with the text wrapping around it (Word's behaviour). One undo
  step. Nested tables and tables outside the main text are only selected.
- A double-click on a column border fits the column left of it to its
  contents (the right neighbour gives or takes the room; on the right edge
  the table's width changes, up to the right margin).
- Table Layout > Select > Select Column (catalog 418/449).

Whole-cell selection: a column (or a rectangle of cells over several rows)
is not one run of the text, so selecting it makes a block of its cells'
paragraphs (`CellRange` on the existing `ColumnBlock`). Bold, alignment,
Delete, typing, copy and numbering act on those cells only (a numbered
column is one list); table commands (shading, merge, delete columns) take
the rectangle; Tab moves on without clearing it; Undo brings the selection
back. Cells of one row, or whole rows, stay an ordinary stream selection.
The format painter no longer clears a block's text (edit.pasteFormat was
handled like a paste). The mini toolbar stays hidden over whole-cell
selections and during these drags: pinned above the selection it covered
the column areas, the "+" buttons and the move handle.

Engine: crates/engine/src/cmd/table_mouse.rs - table.selectAt,
table.insertAt, table.moveDrag, table.fitColumn (page coordinates, so
agents and the control channel can do the same) and table.selectColumn;
hit testing reuses table_resize's drawn geometry, and borders keep priority
for resizing. 10 engine tests (areas, a column edited cell by cell,
numbering, rows/cells/drags, insert buttons, move, fit, format painter,
hostile params).
UI: crates/ui-egui/src/table_mouse.rs, wired into the existing canvas mouse
handler (no extra ui.interact areas, so clicks in a cell's text still place
the caret); kittest tests with real pointer events: column click, row drag,
"+" button, move-handle drag in one undo step, double-click fit.
New labels translated in all eleven catalogs. Progress docs updated: gaps,
ui-parity, target-app-parity, ROADMAP, parity checklist.
`cargo xtask ci` passes (fmt, clippy, test, assets, layers, wasm).

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

* Tables: a selected column's word count is its cells'; merged cells shade once

With whole cells selected (a column, or a rectangle that isn't one run of
the text) the status bar said "12 of 23 words": `Session::selected_text`
took everything between the selection's ends. It now takes each piece's
own text, a line each, for a column block too, so the status bar, Word
Count and the other callers see what is selected.

Since #669 a vertically merged cell is one box as tall as its rows, with a
`covered` box for each continuation row; shading selected cells skips
those, so a merged cell isn't shaded twice. `selected_paths` follows
main's `para_paths_in` (#418): a selection ending at the start of the next
paragraph doesn't take it.

- Test: a selected column's text is its three cells' and Word Count says
  three words.

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

* Tables: double-click AutoFit and the "+" buttons work as in Word

Checked against Word (2019, Windows):

- A double-click on a column border fits the column left of it to its
  contents and the columns right of it keep their widths, moving with the
  border, so the table narrows or widens (up to the right margin). The
  right neighbour used to give or take the difference.
- A "+" insert button selects the new row or column (the caret used to
  go into it); typing then goes into its first cell. If the table can't
  take another row or column, nothing changes.
- A double-click along a cell's left edge (the cell selection area)
  selects its row.

- Tests: engine (fit keeps the columns on the right; insert selects the
  new row or column, typing lands in its first cell) and kittest (real
  double-click on a border; "+" selects the row; double-click by a cell's
  edge selects the row).

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

* Tables: the right-click menu offers the table's commands

Right-clicking a table cell now offers what Word's table menu does, all
through existing commands: Insert (rows above/below, columns left/right),
Delete (cells, rows, columns, table), Select (cell, column, row, table),
Merge Cells, Split Cells, Distribute Rows, Distribute Columns, AutoFit
(contents, window, fixed column width), Text Direction, Cell Alignment
(the nine alignments) and Table Properties… (opens the dialog, as the
ribbon's button does).

A right-click beside selected cells now moves the caret there, as in
Word: with a column selected, the cells between its first and last cell
in the text (the other columns of those rows) counted as "inside the
selection", so the selection stayed and the menu acted on the column
instead of the cell clicked. A right-click on a selected cell keeps the
selection, so Cell Alignment, Distribute and the rest act on it.

- UI only (crates/ui-egui/src/canvas.rs): the table part of
  `context_menu()` and a small check before the right-click caret logic
  (separate from it, so it doesn't overlap #521's change to that block).
  Table Layout's nine cell alignments are one shared list (`CELL_ALIGN`
  in ribbon.rs) for the ribbon grid and the menu.
- Two new labels, "Cell Alignment" and "Table Properties…", translated
  in all eleven catalogs; the rest reuse the ribbon's labels.
- Tests: kittest with real pointer events: right-click in a cell, the
  menu's table entries, Select > Select Column; with a column selected,
  a right-click on it keeps it and Cell Alignment > Align Bottom Right
  aligns just the column (one undo step), and a right-click beside it
  drops it.

Progress docs updated: ui-parity (context menus 55% -> 60%), gaps,
target-app-parity, ROADMAP. No new commands, so the catalog is unchanged.
`cargo xtask ci` passes.

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

* Tables: the right-click menu's table items follow the selection, as in Word

Checked against Word (2019, Windows): Word's table items in the context
menu depend on what is selected, and there is no Select or Cell
Alignment submenu among them.

- Caret in a cell: Insert, Delete Cells, Split Cells, Text Direction,
  Table Properties…
- Several cells: Insert, Delete Cells, Merge Cells, Text Direction,
  Table Properties…
- Whole columns: Insert, Delete Columns, Merge Cells, Distribute Rows,
  Distribute Columns, Text Direction, Table Properties… (whole rows the
  same with Delete Rows; not checked in Word).
- The whole table: Insert, Delete Table, Distribute Rows and Columns,
  AutoFit, Text Direction, Insert Caption…, Table Properties…

Insert lists the columns first, as Word does. Select and Cell Alignment
are gone, and with them the shared CELL_ALIGN constant in the ribbon;
"Insert Caption…" replaces "Cell Alignment" in all fifteen catalogs.
Word's Border Styles and Insert Cells… aren't here yet.

- Tests (kittest, real right-clicks): the menu with the caret in a cell
  and with the whole table; a selected column keeps its column menu and
  Text Direction turns only it; beside it the menu is a cell's again.

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

---------

Co-authored-by: rabis <340973947+rabis7575@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 10:29:45 -04:00
Brandon Thomas 994162fdff Shift+F10 opens the page's context menu at the caret (#821) (#920)
The right-click menu could only be opened with the mouse. Shift+F10,
Windows' keyboard way to the context menu (the Menu key's equivalent),
now opens the same menu just below the caret, with the spelling
suggestions and synonyms for the word there; Escape closes it.
ui.contextMenu opens it for scripts and agents.

The Menu key itself still does nothing: egui-winit drops that key
before the app sees it, so it needs an upstream change (or a winit
hook) to reach the same path.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 10:28:22 -04:00
Brandon Thomas d863811511 No automatic spacing between list items (#924)
* R17: no automatic paragraph spacing between list items

Word drops automatic (HTML-style) space before and after between two list
items, of one list or two, at any level; the first item keeps its space
before and the last its space after, and measured spacing stays. Measured in
Word for Windows: 16 synthetic cases. NIH's bulleted IND report items now
touch as in Word.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* ROADMAP: keep the progress log separator under its header

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 10:22:53 -04:00
Brandon Thomas 681a57fc1a Rendering: kerning and automatic paragraph spacing like Word (#923)
Found by rendering 12 US government Word documents (NIH, EPA, MCC) in
Word for Windows and WordCraft and comparing the pages side by side,
then pinning each rule down with synthetic documents measured in Word.

- Kerning: the shaper kerned every run, so WordCraft's lines held more
  text than Word's and broke differently. Word kerns only a run with
  Kerning for fonts (w:kern) at or above that size; no w:kern and
  w:kern="0" are unkerned. New documents keep kerning from 1 pt, like
  Word's Normal template.
- Automatic paragraph spacing (w:beforeAutospacing/w:afterAutospacing,
  common in text pasted from web pages) was ignored. It is 14 pt at
  any size, none before the document's or a cell's first paragraph,
  none after a cell's last, and the larger of two neighbouring spaces
  counts. Read, written back, and settable through para.spacing
  (beforeAuto/afterAuto); a number, Ctrl+0 and Remove Space After
  replace it as in Word.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 10:18:05 -04:00
rabis7575 8ca285ca9d Tables: select, insert and move with the mouse; double-click a border to AutoFit (#637)
* Tables: select, insert and move with the mouse; double-click a border to AutoFit

Like Word, on top of the border resizing:

- Selection areas: just above a column the pointer becomes a down arrow,
  left of a row the row arrow, along a cell's left edge (inside it) the
  cell arrow. A click selects the column, row or cell; a drag selects every
  one up to the pointer. Selected cells are shaded as cells, empty ones too.
- Insert buttons: left of a row border and above a column border a "+"
  shows with the border marked; a click adds a row or column there and puts
  the caret in it.
- Move handle past the table's top-left corner: a click selects the table;
  a drag shows a dashed outline and, on release, the table floats where it
  is dropped with the text wrapping around it (Word's behaviour). One undo
  step. Nested tables and tables outside the main text are only selected.
- A double-click on a column border fits the column left of it to its
  contents (the right neighbour gives or takes the room; on the right edge
  the table's width changes, up to the right margin).
- Table Layout > Select > Select Column (catalog 418/449).

Whole-cell selection: a column (or a rectangle of cells over several rows)
is not one run of the text, so selecting it makes a block of its cells'
paragraphs (`CellRange` on the existing `ColumnBlock`). Bold, alignment,
Delete, typing, copy and numbering act on those cells only (a numbered
column is one list); table commands (shading, merge, delete columns) take
the rectangle; Tab moves on without clearing it; Undo brings the selection
back. Cells of one row, or whole rows, stay an ordinary stream selection.
The format painter no longer clears a block's text (edit.pasteFormat was
handled like a paste). The mini toolbar stays hidden over whole-cell
selections and during these drags: pinned above the selection it covered
the column areas, the "+" buttons and the move handle.

Engine: crates/engine/src/cmd/table_mouse.rs - table.selectAt,
table.insertAt, table.moveDrag, table.fitColumn (page coordinates, so
agents and the control channel can do the same) and table.selectColumn;
hit testing reuses table_resize's drawn geometry, and borders keep priority
for resizing. 10 engine tests (areas, a column edited cell by cell,
numbering, rows/cells/drags, insert buttons, move, fit, format painter,
hostile params).
UI: crates/ui-egui/src/table_mouse.rs, wired into the existing canvas mouse
handler (no extra ui.interact areas, so clicks in a cell's text still place
the caret); kittest tests with real pointer events: column click, row drag,
"+" button, move-handle drag in one undo step, double-click fit.
New labels translated in all eleven catalogs. Progress docs updated: gaps,
ui-parity, target-app-parity, ROADMAP, parity checklist.
`cargo xtask ci` passes (fmt, clippy, test, assets, layers, wasm).

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

* Tables: a selected column's word count is its cells'; merged cells shade once

With whole cells selected (a column, or a rectangle that isn't one run of
the text) the status bar said "12 of 23 words": `Session::selected_text`
took everything between the selection's ends. It now takes each piece's
own text, a line each, for a column block too, so the status bar, Word
Count and the other callers see what is selected.

Since #669 a vertically merged cell is one box as tall as its rows, with a
`covered` box for each continuation row; shading selected cells skips
those, so a merged cell isn't shaded twice. `selected_paths` follows
main's `para_paths_in` (#418): a selection ending at the start of the next
paragraph doesn't take it.

- Test: a selected column's text is its three cells' and Word Count says
  three words.

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

* Tables: double-click AutoFit and the "+" buttons work as in Word

Checked against Word (2019, Windows):

- A double-click on a column border fits the column left of it to its
  contents and the columns right of it keep their widths, moving with the
  border, so the table narrows or widens (up to the right margin). The
  right neighbour used to give or take the difference.
- A "+" insert button selects the new row or column (the caret used to
  go into it); typing then goes into its first cell. If the table can't
  take another row or column, nothing changes.
- A double-click along a cell's left edge (the cell selection area)
  selects its row.

- Tests: engine (fit keeps the columns on the right; insert selects the
  new row or column, typing lands in its first cell) and kittest (real
  double-click on a border; "+" selects the row; double-click by a cell's
  edge selects the row).

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

---------

Co-authored-by: rabis <340973947+rabis7575@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 10:15:31 -04:00
Brandon Thomas 3ede32e80e Space before at the top of a page like Word (#932)
* Layout: space before at the top of a page after page breaks, as in Word

Found comparing Microsoft's specification documents with Word for
Windows, then measured with 32 synthetic documents in compatibility
modes 12 (Word 2007) and 15 (Word 2013+):

- A paragraph that ends with a page break keeps its mark with the
  break: WordCraft put an empty line at the top of the next page.
- The paragraph after it, first on the new page, gets no space before.
- A paragraph that starts with a page break starts on the new page
  with its space before there (WordCraft put the space on the old page).
- A page break before keeps the space before in modes up to 14 and
  drops it in mode 15.
- Natural breaks, section breaks and the first page were already right.

All 32 cases now land where Word puts them (within 0.3 pt), and the
caret still reaches a paragraph mark kept with its break.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Layout: after a page break before or a section break, the space after above counts

Measured in Word for Windows (10 more cases): at the top of a page after a page break before
(modes up to 14) or a next-page section break, the previous paragraph's space after comes off the
space before (24 pt before after 10 pt after leaves 14 pt; after 30 pt leaves none). This put the
numbered Heading 1s of Microsoft's specification documents 9 pt too low. All 42 page-top cases now
match Word.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 10:12:54 -04:00
Brandon Thomas 97ade895d7 Layout: text after a column break takes its new column's width (#934)
Found in NIH's SARP form compared with Word for Windows: the form's
second section has a narrow label column (67 pt) and a wide text column
(438 pt), and each answer starts with a column break into the wide one.

Before: the paragraph's lines were broken once, for the column it
started in, so the text after the break ran a word or two wide down the
wide column. After: when a column break lands the rest of a paragraph
in a column of another width, that rest is broken again for it, as in
Word. Equal columns are unaffected (nothing is laid out twice).

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 10:09:42 -04:00
Diego Ospina 05e5ff0e0d i18n(es): translate the equation gallery and built-in style names (#854)
322 rows only Italian and Russian had (the 24 that #695 adds are left
to it). The Estonian/Turkish/Polish coverage tests no longer require
Spanish to have exactly their keys: the extras must be gallery/style
keys (in Italian, not in German).

Models: Opus 5.5, Sol 6.1, DeepSeek V4 Flash

Co-authored-by: Diego Ospina <diegozpy@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 10:09:15 -04:00
Brandon Thomas 672522b2ed R22: paragraph-relative floats start above the space before (#941)
Word positions a floating object "relative to paragraph" (and to its line)
from the paragraph's start above its space before, below the previous
paragraph's space after. WordCraft measured from the first line, so floats
in spaced paragraphs sat lower by the space before. Measured in Word for
Windows: 10 synthetic cases.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 10:05:48 -04:00
Dmitrii Khlapov 55bfc33418 Undo history list beside the Quick Access Toolbar's Undo (#701) (#855)
* #701: Undo history list beside the Quick Access Toolbar's Undo

A drop-down arrow next to Undo lists recent steps, newest first; hovering a
row highlights it and every newer step, the footer reads "Undo N Actions"
(Cancel when none is highlighted), and a click undoes them all at once.

Engine: Session::undo_history (newest first, capped at UNDO_LIST_MAX = 100)
and Session::undo_steps; edit.undoTo {"count": n} (0, negative and non-number
counts rejected, counts past the depth clamped, stops at protection) and the
read-only edit.undoHistory for CLI and MCP. Catalog: Undo List.

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

* i18n: Hungarian and French strings for this change

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

* #701: Bulgarian for the Undo history strings; drop a duplicate Turkish entry

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

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 10:05:06 -04:00
Brandon Thomas 5383d80f75 An empty continuous section mark takes no room (#940)
* R21: an empty continuous section mark takes no room

An empty paragraph that only carries a section break before a continuous
section takes no room in Word: the text after it follows right on, and
columns balanced above it don't count it. A break on a paragraph with text
keeps its line. Measured in Word for Windows: 10 synthetic cases. The SARP
form's fields drifted a line lower at each of its continuous breaks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* ROADMAP: keep the progress log separator under its header

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 10:02:07 -04:00
Dmitrii Khlapov 8c67ef7c28 Start on File › Home when launched with nothing to open (#766) (#853)
* #766: start on File › Home (Start screen) when launched with nothing to open

Desktop and web start on Backstage Home (Blank document, templates, Recent)
when no file, --sample or --control is given; Esc/back leaves the empty blank
document. File › Options › General › Start-up checkbox (ui.startScreen, kept
between runs, default on). WordApp::new is unchanged; hosts opt in via
WordApp::start_screen. ui_shot: {"empty":true,"startScreen":true}.

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

* i18n: Hungarian and French strings for this change

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

* #766: Bulgarian for the Start screen setting

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

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 10:00:28 -04:00
Brandon Thomas fc52079f58 R19: keep with next splits a paragraph that doesn't fit anyway (#933)
Word moves a "keep with next" paragraph to the next page only when it would
fit on this one by itself; one that doesn't fit anyway breaks as usual, its
last lines going over with the next paragraph (with keep lines together it
moves whole). A chain of kept paragraphs moves together. Measured in Word
for Windows: 20 synthetic cases. NIH's five-line kept paragraph no longer
leaves a third of a page empty.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 09:58:26 -04:00
Dmitrii Khlapov c9079196db AutoCorrect: the English replacement list applies only to English text (#835) (#846)
* #835: AutoCorrect's English replacement list applies only to English text

Built-in word entries (teh, i, dont…) are replaced only when the word's
language (run w:lang, style, document default) is English or unset;
symbols, fractions and user entries apply in every language. Polish,
Croatian, Bosnian and Serbian "i" ("and") is no longer turned into "I".

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

* #835 review: untagged text follows the editing language; English as proofing decides it

- Text with no language at all (a .docx without w:lang) is in the editing language, so with a
  Polish interface `Ala i kot ` keeps its `i` there too.
- `wordcraft_proof::ProofLang::from_tag` decides what is English (it also takes `eng`), so
  AutoCorrect and proofing agree; the local `is_english` is gone.
- Tests through `set_editing_language` (the document-default path) and with no language at all.
- docs/typing-parity.md: the Croatian/Bosnian/Serbian case needs the text tagged, as those
  interfaces set no editing language yet.

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

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 09:56:29 -04:00
Brandon Thomas 7d37b14679 R16: list indents and style indents combine like Word (#927)
A numbered paragraph style's own indents (and those of styles based on it)
now beat the list level's, its base styles' don't, and the paragraph's own
list beats every style. A paragraph that switches its list off (numId 0)
drops the styles' indents, as Word does; the first Backspace in a List
Paragraph item makes the item's indent the paragraph's own so the text stays
put. Measured in Word for Windows: 27 synthetic cases, all match. NIH's
Heading 3s sit at their 0.9" and its "sample text" paragraphs at the margin.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 09:55:41 -04:00
Brandon Thomas 5aa8151a53 Text frames in headers, footers and cells, kept on save (#935)
Found in NIH's monitoring-plan template compared with Word for Windows:
its footer puts the page number in a text frame (w:framePr: centred,
text wrapping around it), and Word draws "NCCIH DSMP Template" ending
just left of the number on the same line.

Before: frames other than drop caps were ignored on import, so the
number took a line of its own above the footer text (making every page
a line shorter), and the frame was lost on save.

After: a paragraph's text frame is read (size, anchors, alignment,
offsets, wrap, distances) and written back. In headers, footers, table
cells and text boxes it is placed like a floating object, sized to its
text unless it has a width, and the text after it wraps around it; the
DSMP footer now matches Word's. Frames in the body text still lay out
in line.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 09:52:26 -04:00
Brandon Thomas 74548cff57 layout tests: iterate the cells in a_hidden_cell_mark_takes_no_room (clippy) (#948)
clippy::needless_range_loop failed cargo clippy --all-targets -D warnings on
main after #939.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:52:20 -04:00
Brandon Thomas 9f0776a70b ROADMAP: progress log separator back under the header row (#947)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 09:47:00 -04:00
Brandon Thomas 541e10e23a R20: hidden end-of-cell marks (w:hideMark) (#939)
Read and write w:hideMark on table cells, and lay it out like Word: a cell's
empty last paragraph takes no room, spacing and all, so a row of only such
empty cells is just its borders; an ordinary empty cell keeps the row a line
tall. Measured in Word for Windows: 9 synthetic cases. NIH's Table A no
longer shows a blank row.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 09:45:40 -04:00
Dmitrii Khlapov 2f833b6acf Sorting and table numbers follow the text's language (#836, #837) (#844)
* #836, #837: language-aware sorting and locale numbers in tables

Sorts (Table > Sort, Home > Sort, index and its letter headings, bibliography,
Table of Authorities) use the text language's alphabet via a small pure-Rust
collation (crates/doc/src/collate.rs). Table sort and formulas read numbers
with the cell language's decimal/group separators and write formula results
with its decimal separator (crates/doc/src/locale_number.rs). Sort comparators
are now total orders.

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

* #836, #837 review: ordinals, leading zeros, percentages; untagged text; decomposed letters

- parse_number: a trailing separator with no digits is the whole number (`1.`, a
  numbered "Lp." column, sorts as 1); a first group of `0` is never grouping
  (`0.125` in Polish is 0.125, not 125); a trailing `%` is ignored (`12 %` is 12);
  an en dash is a minus.
- Text with no language follows the document default, else the editing language
  (`cmd::default_lang`), as AutoCorrect does: a Polish interface reads `3,50` as
  3.5 in an untagged document. Table sort and formulas, paragraph sort, the
  index, the bibliography and the Table of Authorities use it.
- Collation composes a letter and a following combining mark first (table from
  Unicode NFC), so a decomposed `ż` (z + U+0307) sorts as `ż`.
- Tests: every Polish letter and its index heading, decomposed letters, the
  ordinal column, an untagged Polish SUM, Polish bibliography order.

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

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 09:43:01 -04:00
Brandon Thomas 2e66cbb261 R18: a list label adds ascent, not descent, to its line (#928)
Word sizes a list item's first line with the label's ascent when it is
taller, but keeps the text's descent below the baseline: a Symbol bullet's
deeper descent (and a Courier New label's) doesn't count. Measured in Word
for Windows: 6 synthetic cases. NIH's bulleted items were a fifth of a point
taller each, enough to push lines over a page bottom.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 09:41:37 -04:00
Brandon Thomas 99561097d5 DOCX: a list level without a list takes its style chain's list (#926)
Found in NIH's monitoring-plan template, compared with Word for Windows.

Before: a w:numPr that gives a level (w:ilvl) but no list (w:numId) was
dropped. Outline-numbered templates do this all the time: Heading 3 is
based on Heading 2 (which names the list) and only says "level 3", so
every Heading 3 numbered as a Heading 2 (6.15 where Word shows 6.4.5).

After: such a level takes the list of the nearest style in its based-on
chain that names one (ECMA-376 17.3.1.19), in styles and in paragraphs;
with no list anywhere in the chain there is no numbering. Saved files
carry an ordinary list reference.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 09:37:38 -04:00
Dmitrii Khlapov 45abb5ded3 #743: freeforms in groups keep their shape; text boxes keep their margins and Resize to fit (#843)
A Word 365 cover page lost its look. The missing fills came from
theme-coloured shapes (scheme colours and wps:style fill references),
which the reporter's 0.5.0 build didn't read; #611 fixed that on main.
Two causes were still open on main:

- A freeform (a:custGeom) inside a wpg group was drawn as a rectangle.
  read_wsp now reads custom geometry, so group members get it as well.
- Text boxes ignored wps:bodyPr lIns/tIns/rIns/bIns and a:spAutoFit.
  Layout always used Word's default margins, so a title box with no
  margins that was just tall enough for its text lost its second line.
  TextBody now carries the box's own margins and auto_fit. Layout uses
  those margins and doesn't cut the text of a box that resizes to fit.
  Both are written back on save.

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 09:37:13 -04:00
Brandon Thomas 906e0f8aca .doc import: tab stops, and sections that end at their section marks (#925)
Found by comparing EPA's .doc templates with Word for Windows.

Before: custom tab stops in .doc files were skipped, so centred titles,
right-aligned dates and dot leaders set with tabs lost their alignment;
and a section mark (0x0C) at a section's end became a page break inside
a paragraph, so the file's sections (continuous breaks, footers) were
lost and every section break started a new page.

After: sprmPChgTabsPapx and sprmPChgTabs are read in styles and
paragraphs (removed stops clear inherited ones; each added stop keeps
its alignment and leader), and sprmPChgTabs' one-byte size is decoded
per [MS-DOC] (it was read as two bytes, swallowing the sprms after it).
A section mark at a section's end now ends that section's last
paragraph, so the section's properties and footers are kept. Both EPA
.doc templates in the comparison set now have Word's page count.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 09:34:10 -04:00
Brandon Thomas 64f656a8bc Layout: spaces and tabs don't make a line taller, as in Word (#931)
Found comparing Microsoft's own specification documents with Word for
Windows: their tables of contents were a third taller in WordCraft
(13.4 pt per entry against Word's 10.9). Each entry's 9 pt text has tab
runs in an 11 pt font, and WordCraft sized the line by them.

Measured in Word with 13 synthetic documents: 11-20 pt tabs, spaces and
ideographic spaces next to 9 pt text leave the 9 pt line as it is,
wherever they are on the line; a line of only such whitespace takes the
paragraph mark's height; a big letter or no-break space does grow the
line. WordCraft now skips spaces and tabs when it sizes a line, and all
13 cases match.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 09:30:34 -04:00
Dmitrii Khlapov 6f35313f68 #819: Alt+Q moves to "Tell me" command search (#849)
Word for Windows' Alt+Q opens the "Tell me" box; in WordCraft it did nothing.
Interface commands (ui.*) aren't in the engine's keymap, so keys.rs gets a
small table of interface shortcuts, checked before the keymap from the page
and from the rest of the window. Alt+Q opens command search
(ui.dialog {"name": "commands"}) and its key text isn't typed. Not on macOS,
where Option+Q types a character.

Also fixes the ROADMAP progress log, whose separator row sat below two rows.

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 09:30:18 -04:00
Manuel Carmona 1677c2d9cf Ribbon groups fit above their labels (View › Show, Developer › Controls, Layout › Paragraph) (#910)
View › Show stacked four check boxes in a column made for three: since
Hide White Space (#645) joined Ruler, Gridlines and Navigation Pane, the
fourth row was drawn over the "Show" group label. It now sits in a second
column.

A new test draws every tab in every interface language and fails on any
group taller than the ribbon's content area (CONTENT_H, 66 pt). It found:

- Developer › Controls, 75 pt: 3 pt after each row of small buttons. The
  rows now fill the height with no gaps, like every other stack.
- Layout › Paragraph (#654), which panicked in debug builds: `add_space`
  between grid cells trips egui's "add_space makes no sense in a grid
  layout" assertion. The 8 pt gap before the Spacing column now sits
  inside those cells, so it looks the same. The group was also 68.5 pt:
  1.5 pt between rows instead of 3 fits it.

- widgets.rs: under cfg(test), group_titled records any group whose
  content is taller than CONTENT_H.
- ribbon.rs: ribbon_groups_fit_above_their_labels.
- ROADMAP progress log and docs/ui-parity.md revision history.
- contributors/people.toml: my own entry.

Co-authored-by: Manuel Carmona <328347120+manucarcor@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 09:27:44 -04:00
Brandon Thomas 26cdcfd4c6 DOCX: table-of-contents links look like Word's (#930)
Found comparing Microsoft's own specification documents with Word for
Windows: their table of contents was blue and underlined in WordCraft
and plain in Word.

Measured with nine synthetic documents in Word: a run with the
Hyperlink character style, inside a hyperlink, inside a TOC field's
result, is drawn plain (with or without the TOC's \h switch). A
Hyperlink-styled run outside a hyperlink, another character style,
direct formatting, or a hyperlink in another field keep their look.

The import now drops the Hyperlink style from exactly those runs (the
links stay), so all nine cases render as Word does.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 09:26:56 -04:00
odedigo fccc09e4c9 fix(ui): draw non-Latin file names in the window caption (#840)
The painted title uses a Latin-only face, so names in other scripts were shown as question marks. A covering fallback face is now loaded for the caption.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 09:23:47 -04:00
Brandon Thomas 90515abd1e Keytips: Word for Windows' tab letters (#922)
Observed in Word for Windows (Microsoft 365): pressing Alt badges the
tabs File F, Home H, Insert N, Design G, Layout P, References S,
Mailings M, Review R, View W, Help Y, and the Search box Q.

Before, WordCraft used Design L, References R, Review S and View V,
and File and Help had no letter, so Alt, W or Alt, R went to the wrong
tab for anyone used to Word. Now the letters match; Alt, F opens the
Backstage and ends keytips.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 09:23:21 -04:00
odedigo 0acf6bd512 fix(windows): start 32-bit builds on OpenGL so launch no longer exits (#839)
* fix(windows): start 32-bit builds on OpenGL so launch no longer exits

DirectX 12 graphics pipeline creation fails on 32-bit Windows, so the app quit before the window appeared.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add @odedigo to the contributor names list

The About window credits this username; no real name or display name was chosen.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 09:20:53 -04:00
Brandon Thomas 912d49f38f PDF export: look internal links up in a map, not the whole document (#937)
Found while timing US government documents against Word for Windows.

Before: every internal link (#bookmark: a linked table of contents,
cross-references) found its bookmark by walking every paragraph of the
document, so export time grew with links x length: NIH's 67-page
protocol template (297 links) took 10.6 s to export as PDF.

After: the bookmark positions are gathered once per export (the first
bookmark of a name still wins). The same file exports in 0.9 s with
identical text and links. internal_links_export_in_linear_time guards
it (the old code took 12x as long for 4x the document).

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 09:19:41 -04:00
rabis7575 da5da66e51 Tables: row borders stay apart around a vertical merge (#640) (#841)
Since #669 a vertically merged cell is one `Placed::Cell` as tall as all
its rows. The resize geometry took a row's bottom from its tallest box, so
the row where a merge starts reached down to the merge's bottom: the border
under it could not be grabbed, and dragging the border of the next row
resized the first row instead (to the merge's height plus the drag).

- A row ends at its shortest box, and no lower than where the next row
  starts (a row whose every cell starts a merge has only taller boxes).
- The heights measured after a row drag come from the same geometry.
- Test: with a merge down column 0, the border under the first row is
  still there, the merge's bottom is the second row's border, and
  dragging the first one changes that row alone.

Co-authored-by: rabis <340973947+rabis7575@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 09:10:17 -04:00
MarekSpodBiedry bc60cbaadb Zoom after One Page keeps one page per row (#768) (#832)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 09:05:40 -04:00
MarekSpodBiedry 7fbcd0643a Keep the font table and embedded fonts when saving a .docx (#770) (#831)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 09:02:02 -04:00
MarekSpodBiedry e347ef203e Accept and Reject at the caret cover a tracked change split by a comment (#809) (#830)
* Accept and Reject at the caret cover a tracked change split by a comment (#809)

* Test reads the text past the comment's anchor objects

* rustfmt after merging main

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 08:57:38 -04:00
MarekSpodBiedry 0a82b684f8 fix(ui): equation gallery tiles follow the dark theme (#817) (#827)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 08:51:00 -04:00
MarekSpodBiedry 03e9328d4b Comment cards show the time as well as the date (#812) (#828)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 08:39:57 -04:00
Yasas Sri 9132d1bd40 Ctrl/⌘+F focuses the Find box, so typing searches (#772) (#823)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 08:35:44 -04:00
Dmitrii Khlapov 00ed50515e New documents follow the system region: A4, centimetres and decimal comma outside the US (#838) (#857)
* #838: new documents follow the system region (A4/cm/decimal comma outside the US)

- geom: Region (from a locale tag; CLDR Letter and US-measurement territories,
  decimal separator), Unit::format_with; parse_measure takes either separator
  and a Unicode minus.
- engine: Session.region (plain value, en-US headless), Session::units()
  (explicit Measurement units choice wins, else the region's),
  blank_document(); file.new blank uses the region's paper; file.options
  takes "region" and units "auto" and reports unitsFromRegion/region.
  Prefs.units is now Option and saved as measurementUnits (old saved values
  were the inch default, not a choice).
- ui: region from WORDCRAFT_REGION, LC_PAPER/LC_MEASUREMENT/LC_NUMERIC, the
  platform locale (browser on the web) and Windows' home location; length
  fields show and read the region's decimal separator (MeasureExt); Size
  menu lists sizes in the measurement unit.
- Tests: engine new_documents_follow_the_region, geom region/format/parse,
  ui length_fields_follow_the_region. Docs: localization parity, roadmap.

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

* Review fixes for #857: keep a measurement unit chosen before the rename

Settings saved before #838 kept the unit under `editing.units`, defaulting to inches. On load,
a value there other than inches was the user's own choice, so it now carries over to
`measurementUnits` (unless that key is already present); inches, junk and non-strings follow
the region. Test: `a_unit_chosen_before_regions_survives_the_rename`.

Also merged main (ROADMAP.md, engine lib.rs re-exports: `blank_document` and main's
`set_system_locale`). The Windows region code (`GlobalizationPreferences::HomeGeographicRegion`)
was cross-checked with clippy -D warnings for x86_64-pc-windows-gnu.

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

* #838 review: saved region choice, Unix conventions apart, the browser's locale

- File › Options › Advanced › Region: Automatic (the system's) or a chosen region, saved with
  the preferences (`Prefs::region`; `file.options {"region": "pl-PL" | "auto"}`). The session
  keeps the platform's region as `system_region`; `Session::region()` is the one in effect.
  Measurement units gains an Automatic entry (follows the region).
- Unix: numbers follow LC_NUMERIC (else LANG), paper LC_PAPER and measurement LC_MEASUREMENT,
  each on its own (`Region::with_paper_and_measurement`); WORDCRAFT_REGION and LC_ALL still set
  all three.
- Web: sys-locale's `js` feature, so the browser's language reaches the region (and the
  interface language, as its comment already said).
- Runs without preferences (WORDCRAFT_NO_PREFS) stay en-US unless WORDCRAFT_REGION is set.
- docs/localization-parity.md: where the region comes from, the Windows home location vs. Word's
  regional format, and where the decimal separator comes from.

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

* #838 review: rustfmt

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

* #838: rustfmt after merging main

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

* i18n: Hungarian and French strings for this change

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

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 08:23:43 -04:00
Dmitrii Khlapov 411ce31de7 Paste keeps other programs' formatting; clipboard pictures on Wayland (#687, #833) (#847)
* #687, #833: paste keeps other programs' formatting; Wayland clipboard pictures

- Paste (Ctrl+V, Home > Paste) reads the system clipboard's HTML, else RTF,
  and keeps its formatting; our own copy still wins while the clipboard holds it.
  Windows CF_HTML headers are understood; oversized or empty payloads fall back
  to the text.
- Merge Formatting inserts the content in the destination's font and paragraph,
  keeping bold, italic, underline and strikethrough (it did nothing before).
- Paste Special lists what the system clipboard holds (HTML, RTF, picture,
  text); Mod+Alt+V opens it even with only a picture there.
- Services gain clipboard_html, clipboard_rtf and clipboard_formats hooks; the
  host reads RTF on Windows (clipboard-win) and Wayland (wl-clipboard-rs).
- Linux: arboard's wayland-data-control feature, so pictures copied in a
  browser or Spectacle reach a native Wayland window (KWin only syncs to
  Xwayland while an X11 window has focus); other picture types are read
  from Wayland directly. A paste that finds nothing usable names the formats
  on the clipboard, or why its picture couldn't be read, instead of "empty".

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

* Review fixes for #847: translate the Paste Special picture strings; Windows/macOS unused import

- `Picture` and `Pastes the clipboard's picture.` translated in all 15 interface catalogs.
- clipboard.rs: `MAX_FILE_BYTES` is used only by the Linux/Wayland module, so it is named
  there; the top-level import was unused (a warning, and a clippy -D warnings error) on Windows
  and macOS.
- Merged main (ROADMAP.md, docs/gaps.md, docs/file-format-parity.md, main.rs services: keeps
  both the clipboard hooks and the default-app hook from #295). Cargo.lock adds only the
  wl-clipboard-rs set.

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

* i18n: Hungarian and French strings for this change

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

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 08:18:25 -04:00
MarekSpodBiedry 18e25d0883 ODT import reads page size, orientation and margins from styles.xml (#810) (#829)
* ODT import reads page size, orientation and margins from styles.xml (#810)

* Review fixes for #829: clamp ODT page size and margins

Page width/height from styles.xml are kept to 36..=1584 pt (the DOCX
reader's floor, Word's 22-inch maximum) and each margin is capped at the
page's longer side, like w:pgMar. Adds a hostile-values test and collapses
a nested if for clippy.

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:01:25 -04:00
Dimitar e619b12ceb i18n: add Bulgarian (bg) interface translation (#825)
* i18n: add Bulgarian (bg) interface translation

A 2,386-entry Bulgarian catalog: every key of the reference catalog, the equation gallery's
labels and everything the interface audit finds. Written from the meaning of the English
labels in ordinary Bulgarian computing vocabulary; no other product's localisation and no other
catalog in this repository was used. Counts use count-neutral wording ("Думи: 21").

- bg, bg-BG, bg_BG.UTF-8 select it automatically; "Български" in Options and
  ui.language with {"value":"bg"}.
- The interface audit checks Bulgarian coverage the way it checks Russian.
- Tests: locales and saved preference, catalog coverage and key translations, count wording,
  and the bundled Inter/JetBrains Mono faces covering the Bulgarian letters.

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

* Review fixes for #825: Bulgarian built-in style names

Adds the built-in style names Russian translates (Normal Table, Default
Paragraph Font, Annotation Text/Reference/Subject, Envelope Address/Return,
Index Heading, Index {n}, Line Number, Normal Indent, Table of Figures, TOA
Heading, TOC {n}) and Good afternoon; updates the entry count in the docs.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 07:56:30 -04:00
Dimitar f7ce977e4a Hyphenation: Bulgarian text follows the Bulgarian syllable rules (#826)
Runs in Bulgarian (`w:lang` bg, bg-BG) are hyphenated by the Bulgarian syllable rules
(crates/proof/src/bg.rs, clean-room from the spelling rules; no pattern file or word list):
vowel-vowel splits, one consonant goes with the next syllable, a consonant cluster splits after
its first consonant except that an obstruent before р/л starts the next syllable (до-бре,
та-бли-ца), й stays with its vowel, дж/дз and ьо are never split.

- ProofLang::Bg (bg, bul): hyphenation only. It isn't "proofed", so like Other there are no
  spelling or grammar marks until there is a Bulgarian dictionary.
- Latin-script words in a Bulgarian run are hyphenated as English, as in any other language.
- The layout already takes each word's language from its run (ProofRun), so nothing changes there.

Tests: the rules (срич-ко-пре-на-ся-не, та-бли-ца, шо-фьор, хо-джа, строй-ка), tag resolution,
hyphenate_word_in with the paragraph limits (Бъл-га-рия), and a laid-out bg-BG paragraph whose
hyphenated lines all break at Bulgarian points.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 07:51:05 -04:00
Eduardo Soares 1e53adf9d6 Portuguese proofing: pt-BR spelling, suggestions, grammar and hyphenation (#100) (#797)
* M15: Polish interface, and Polish spelling, grammar and hyphenation

Interface
- Polish is the tenth interface language ("Polski", pl.tsv, 1,045 entries);
  pl, pl-PL and pl_PL.UTF-8 system locales select it automatically.
- Right-click menu labels now go through translation in every language.
- Review > Language is a menu (English US/UK, Polski, Do not check spelling
  or grammar); the status bar shows the language at the caret.
- With a Polish interface, new blank documents and opened .txt/.md files
  default to pl-PL.

Proofing
- Each run is checked in its own language (w:lang, resolved through
  styles): squiggles, F7, review.issues, suggestions and hyphenation.
  Text in languages without proofing tools is no longer flagged as English.
- Polish spelling from the SJP.PL Hunspell dictionary (Apache-2.0 option),
  read by a new Rust affix engine (crates/proof/src/affix.rs); 875 KB asset,
  ~4.5M word forms. Regenerate with the pl_dictionary example.
- Polish suggestions: case, missing diacritics, rz/ż, ó/u, ch/h confusions,
  missing spaces (napewno -> na pewno), edit distance.
- Conservative Polish grammar rules: repeated words, spacing, doubled
  punctuation, sentence capitals, missing comma before że/który/ponieważ/
  aby/żeby/jeśli…, we/ze forms.
- Polish hyphenation with the hyph-utf8 hyph-pl.tex patterns (MIT option);
  pattern keys widened to 20 letters, TeX exceptions parsed; the heuristic
  no longer treats ł, ż, ś… as vowels.

review.language validates and normalizes tags, can set noProof alone and
the document default; format.state reports lang and noProof.

Tests for every part, docs and ATTRIBUTION.md/NOTICE updated;
cargo xtask ci passes (fmt, clippy, test, assets, layers, wasm).

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

* Layout: rustfmt the test module order left unformatted by #391

cargo fmt --check failed on main after #391 added mod tests_grid.

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

* Review fixes for #346: read Polish spelling from the installed Hunspell dictionary

- Polish spelling uses the Hunspell pl_PL dictionary installed on the
  system (OS packages, Homebrew, LibreOffice, ~/.local/share/hunspell, or
  WORDCRAFT_DICTIONARIES) instead of a bundled 870 KB SJP.PL file;
  without one, Polish text gets no spelling marks, like Word without the
  language's proofing tools. .aff/.dic are size-capped and decoded from
  UTF-8 or ISO 8859-2 (crates/proof/src/hunspell.rs).
- The dictionary can be replaced at runtime (install_dictionary) or
  re-searched (reload_dictionary); an empty one accepts every word.
- Tests use small synthetic dictionaries written for them, so they don't
  depend on what the machine has installed.
- The bundled pl.dic, its ATTRIBUTION/NOTICE entries and the generator
  example are removed; docs describe the installed dictionary.

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

* #100: affix — accept FLAG UTF-8 and DEFAULT flag modes

Per-character flag modes are what the reader already parses; only the
multi-character encodings (long, num) stay errors. VERO pt_BR declares
FLAG UTF-8.

* #100: affix — widen flag masks to u128 (128 flag capacity)

VERO pt_BR needs 106 distinct flags; the u64 bitmask capped at 64.
Serialization is unaffected: WCAFF1 stores flag characters and
re-derives the masks on load.

* #100: affix — support FORBIDDENWORD

VERO pt_BR marks spellings it must reject with a forbidden flag (ý).
Stems carrying it are listed but never correct and never inflect.

* #100: affix — continuation classes in check

SFX A 0 able/B: the form derived by A carries flag B, licensing a
further suffix of class B (workable -> workables) while the bare stem
cannot take one. form_has_flag walks the chain depth-bounded
(MAX_CHAIN = 3); with no continuations declared it degenerates to the
stem lookup, so existing dictionaries are unaffected. VERO pt_BR uses
26 continuation rules.

* #100: affix — continuation classes in forms

Enumerate chained suffix forms (workable -> workables) under the same
MAX_CHAIN bound the checker walks, so every form the checker accepts
is listed and vice versa.

* #100: affix — round-trip continuations and FORBIDDENWORD through WCAFF1

The compact format keeps its WCAFF1 magic: continuation classes ride
on the rule's appended field (as in the .aff source) and the forbidden
flag becomes a FORBIDDENWORD line. Old files read as before.

* #100: affix — strip a UTF-8 BOM from .aff/.dic input

VERO's files open with a BOM; glued to the count line it parsed as a
stem (one garbage entry per load) and to SET it hid the directive.

* #782: affix — the module doc still rejected what the code now reads

Continuation classes and FORBIDDENWORD are supported; the header said
Rejected for both.

* #100: pt — Portuguese spelling, following the Polish module

ProofLang::Pt resolves pt/pt-BR/pt-PT/por tags; the pt module reads the
installed Hunspell pt_BR dictionary (VERO) through the affix engine —
FLAG UTF-8 flags, continuation classes and FORBIDDENWORD spellings all
exercised by the synthetic test dictionary. Hyphenated compounds are
checked part by part (guarda-roupa, ex-marido); without a dictionary
installed, Portuguese text gets no marks. Spelling message in
Portuguese; suggestions and grammar land next.

* #100: pt — Portuguese suggestions

Rounds of rising cost, driven by the dictionary's own tables: missing
accents from MAP (coraçao -> coração, saude -> saúde), the REP
confusions (seje -> seja, excessão -> exceção), a missing space after a
short function word (nãoé -> não é, dagua -> da água), one typo over
TRY, and an accent-folding stem scan — the Polish rounds, retuned for
Portuguese.

* #100: pt — Portuguese grammar flags

Conservative rules with Portuguese messages: repeated words (except
the emphatic doubles Portuguese allows), space before punctuation and
double spaces, sentence capitals knowing the Portuguese abbreviations
and dialogue dashes, and doubled punctuation. No comma rule: que opens
a clause only sometimes, so a wrong flag would cost more than a missed
one.

* #100: pt — Portuguese hyphenation

hyph-utf8's hyph-pt.tex bundled unmodified (BSD 3-clause, ATTRIBUTION
row with source and hash): 426 Liang patterns and 2 exception words;
falls back to the generic heuristic outside their reach.

* #100: pt — Review › Language offers Português (Brasil), and a pt-br interface writes pt-BR

The menu's fourth entry sets the run language; the status bar names
Portuguese; editing_lang is now allowed for pt-br (wordcraft-proof
proofs it), so new documents under a Portuguese interface are
Portuguese by default.

* #100: pt — engine test: Portuguese proofing through the review commands

review.language pt-BR normalizes the tag, review.issues reports
Portuguese spelling and grammar with the lang field, F7 and Change
apply a suggestion, Add to Dictionary works for Portuguese.

* #100: pt — Portuguese in the mixed-language proofing tests and docs

check_text proofs a Portuguese span with the Portuguese message; the
never-panic loops and the module docs cover the third language.

* #100: docs — Portuguese proofing in the gap, parity and roadmap docs

Gap 8 narrowed (English, Polish and Portuguese), localization parity
gains a Portuguese row and a revision entry, target-app-parity's
Proofing row moves 40% -> 42%, and the ROADMAP progress log records
what landed.

* #100: hunspell — LibreOffice extension folders follow the language

The search paths hardcoded LibreOffice's dict-pl; a pt_BR user whose
only dictionary is LibreOffice's dict-pt-BR extension — on Windows the
only realistic on-disk source — got silently no spelling. Both the
primary-language folder and the dashed full name are now tried, so
Polish keeps dict-pl and Portuguese finds dict-pt-BR.

* #100: pt — review fixes

Suggestion ties break toward the change nearest the word's start
(saúde before saudê, proven against a fixture that has both); the
Language menu's label is the English source "Portuguese (Brazil)" with
translations in all twelve catalogs (as Polish is); the attribution
counts pt.pat's 2 exception words; the pt fixture drops a duplicate
stem; docs/roadmap.md catches up with the landing; the throwaway spike
is gone.

* Review fixes for #782: two-suffix chain limit, module doc

- MAX_CHAIN = 2 counts suffixes in a row, matching Hunspell's two-suffix limit
  (the old bound of 3 allowed up to four); test pins that a third is rejected.
- Module doc: say probes allocate small scratch strings instead of claiming none.

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

* Review fixes for #797: NOTICE for pt.pat, no indexing in the LibreOffice folder helper

- NOTICE reproduces hyph-pt.tex's BSD 3-Clause copyright notice, conditions and
  disclaimer, which the licence requires for binary redistributions.
- hunspell::lo_ext_dirs builds its list without indexing (never-crash rule).
- Catalogs: main already translates "Portuguese (Brazil)" in every language, so the
  duplicate rows the branch added are dropped.

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

---------

Co-authored-by: witkowski01 <konradwitkowski01@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 07:43:24 -04:00
Eduardo Soares d8940cb317 Affix engine: FLAG UTF-8, 128 flag bits, continuation classes, FORBIDDENWORD (#100) (#782)
* M15: Polish interface, and Polish spelling, grammar and hyphenation

Interface
- Polish is the tenth interface language ("Polski", pl.tsv, 1,045 entries);
  pl, pl-PL and pl_PL.UTF-8 system locales select it automatically.
- Right-click menu labels now go through translation in every language.
- Review > Language is a menu (English US/UK, Polski, Do not check spelling
  or grammar); the status bar shows the language at the caret.
- With a Polish interface, new blank documents and opened .txt/.md files
  default to pl-PL.

Proofing
- Each run is checked in its own language (w:lang, resolved through
  styles): squiggles, F7, review.issues, suggestions and hyphenation.
  Text in languages without proofing tools is no longer flagged as English.
- Polish spelling from the SJP.PL Hunspell dictionary (Apache-2.0 option),
  read by a new Rust affix engine (crates/proof/src/affix.rs); 875 KB asset,
  ~4.5M word forms. Regenerate with the pl_dictionary example.
- Polish suggestions: case, missing diacritics, rz/ż, ó/u, ch/h confusions,
  missing spaces (napewno -> na pewno), edit distance.
- Conservative Polish grammar rules: repeated words, spacing, doubled
  punctuation, sentence capitals, missing comma before że/który/ponieważ/
  aby/żeby/jeśli…, we/ze forms.
- Polish hyphenation with the hyph-utf8 hyph-pl.tex patterns (MIT option);
  pattern keys widened to 20 letters, TeX exceptions parsed; the heuristic
  no longer treats ł, ż, ś… as vowels.

review.language validates and normalizes tags, can set noProof alone and
the document default; format.state reports lang and noProof.

Tests for every part, docs and ATTRIBUTION.md/NOTICE updated;
cargo xtask ci passes (fmt, clippy, test, assets, layers, wasm).

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

* Layout: rustfmt the test module order left unformatted by #391

cargo fmt --check failed on main after #391 added mod tests_grid.

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

* Review fixes for #346: read Polish spelling from the installed Hunspell dictionary

- Polish spelling uses the Hunspell pl_PL dictionary installed on the
  system (OS packages, Homebrew, LibreOffice, ~/.local/share/hunspell, or
  WORDCRAFT_DICTIONARIES) instead of a bundled 870 KB SJP.PL file;
  without one, Polish text gets no spelling marks, like Word without the
  language's proofing tools. .aff/.dic are size-capped and decoded from
  UTF-8 or ISO 8859-2 (crates/proof/src/hunspell.rs).
- The dictionary can be replaced at runtime (install_dictionary) or
  re-searched (reload_dictionary); an empty one accepts every word.
- Tests use small synthetic dictionaries written for them, so they don't
  depend on what the machine has installed.
- The bundled pl.dic, its ATTRIBUTION/NOTICE entries and the generator
  example are removed; docs describe the installed dictionary.

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

* #100: affix — accept FLAG UTF-8 and DEFAULT flag modes

Per-character flag modes are what the reader already parses; only the
multi-character encodings (long, num) stay errors. VERO pt_BR declares
FLAG UTF-8.

* #100: affix — widen flag masks to u128 (128 flag capacity)

VERO pt_BR needs 106 distinct flags; the u64 bitmask capped at 64.
Serialization is unaffected: WCAFF1 stores flag characters and
re-derives the masks on load.

* #100: affix — support FORBIDDENWORD

VERO pt_BR marks spellings it must reject with a forbidden flag (ý).
Stems carrying it are listed but never correct and never inflect.

* #100: affix — continuation classes in check

SFX A 0 able/B: the form derived by A carries flag B, licensing a
further suffix of class B (workable -> workables) while the bare stem
cannot take one. form_has_flag walks the chain depth-bounded
(MAX_CHAIN = 3); with no continuations declared it degenerates to the
stem lookup, so existing dictionaries are unaffected. VERO pt_BR uses
26 continuation rules.

* #100: affix — continuation classes in forms

Enumerate chained suffix forms (workable -> workables) under the same
MAX_CHAIN bound the checker walks, so every form the checker accepts
is listed and vice versa.

* #100: affix — round-trip continuations and FORBIDDENWORD through WCAFF1

The compact format keeps its WCAFF1 magic: continuation classes ride
on the rule's appended field (as in the .aff source) and the forbidden
flag becomes a FORBIDDENWORD line. Old files read as before.

* #100: affix — strip a UTF-8 BOM from .aff/.dic input

VERO's files open with a BOM; glued to the count line it parsed as a
stem (one garbage entry per load) and to SET it hid the directive.

* #782: affix — the module doc still rejected what the code now reads

Continuation classes and FORBIDDENWORD are supported; the header said
Rejected for both.

* Review fixes for #782: two-suffix chain limit, module doc

- MAX_CHAIN = 2 counts suffixes in a row, matching Hunspell's two-suffix limit
  (the old bound of 3 allowed up to four); test pins that a third is rejected.
- Module doc: say probes allocate small scratch strings instead of claiming none.

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

---------

Co-authored-by: witkowski01 <konradwitkowski01@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 07:38:10 -04:00
Valentin Klein 2f9bf4e800 AutoCorrect: TWo INitial CApitals, cAPS LOCK and table-cell capitals (#544) (#545)
- Correct TWo INitial CApitals: a finished word of 3+ letters with two
  leading capitals and the rest lowercase loses its second capital
  ("WOrd" -> "Word"), before the replacement list ("TEh" -> "The").
  Built-in exceptions (IDs, CDs, PCs, TVs, ...) plus user exceptions.
- Correct accidental cAPS LOCK: "wORD" -> "Word" (not iOS, mRNA, ...).
- Capitalize first letter of table cells, under its own option.
- Session::autocorrect_caps (AutoCorrectCaps, all on by default);
  tools.autocorrect takes twoInitialCaps / capsLock / capCells /
  addException / removeException.
- Each fix is one undo step right after it (the existing AutoFormat
  snapshot); case changes replace letter by letter, keeping formatting.
- Tests in tests_typing.rs; docs/typing-parity.md and ROADMAP updated.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 07:35:11 -04:00
Rees 417896301b M1: preserve native Word ink through DOCX saves (#592)
Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 07:30:49 -04:00
RegalDragoon200 b30cda1535 Persist the custom dictionary across sessions (#40) (#796)
* Persist the custom dictionary across sessions (#40)

* Review fixes for #796: session-only Ignore All, desktop-only persistence, capped atomic I/O

- review.ignoreAll no longer adds to (and saves) the custom dictionary: ignored words go to a
  separate session-only list, as Ignore All does in Word.
- Only the desktop app wires the dictionary file. Headless MCP and `wordcraft-cli run` keep
  words in memory, so an agent never reads or changes the person's dictionary (and nothing
  writes outside an automation write root, #769).
- Loading reads through `take(1 MB)`, so a huge file is never read whole; lines that aren't a
  single word (spaces, control characters, invalid UTF-8, over 64 characters) are skipped.
- review.addToDictionary applies the same word rule (proof::custom_word), so a word added is
  never dropped on the next load.
- Saving merges the words on disk first (another process's additions survive; words just
  removed stay out), creates the folder, and replaces the file atomically (temp + rename).
  The file I/O lives in the proof crate, so the engine doesn't touch std::fs itself.
- Tests for each.

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:23:51 -04:00
Isaac Levine 64319e0039 MCP: confine file access with --automation-read-root and --automation-write-root (#769) (#798)
* Engine: check automation read and write roots on every path a command reads or writes (#769)

* MCP: confine the headless session to the automation roots (#769)

* CLI: add --automation-read-root and --automation-write-root to mcp (#769)

* Docs: document --automation-read-root and --automation-write-root for mcp (#769)

* Review fixes for #798: confine file.printPdf; guard all of engine/src

- print.rs::print_pdf_to_file (file.printPdf, added on main) wrote with std::fs::write
  directly, so a confined agent could print a PDF anywhere. It now goes through
  io::write_file, which checks the automation write root.
- The guard test now walks every file under crates/engine/src (not only cmd/), except
  io.rs, file_access.rs and the tests*.rs modules, and also flags OpenOptions.
- Tests: an engine test that file.printPdf is refused outside the write root, works inside
  and is unchanged without roots; the MCP integration test covers it too.

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

* Review fixes for #798: list file.printPdf among the confined writes in docs/mcp.md

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

* Font files added by path go through io, inside the automation read root (#769)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:18:00 -04:00
Ufuk Efe 691e887004 i18n: complete Turkish interface catalog with 2,170 entries and full … (#774)
* i18n: complete Turkish interface catalog with 2,253 entries and full parity

* Review fixes for #774: Turkish entry count after merging main (2,325)

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:04:50 -04:00
ripdog 325e5a6157 M8: Truncate long Navigation headings in resizable pane (#759)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:59:54 -04:00
naxvog e75ff36251 Layout: an inline picture wider than the text keeps its size, like Word (#745)
* Layout: an inline picture wider than the text keeps its size, like Word

WordCraft shrank an inline picture, chart or shape that is wider than
its line to the line's width, height with it. Word draws it at its own
size: it runs past the margin and the page edge clips it, and the line
is as tall as the picture. In docxide-pdf's fixtures that moved the rest
of the page up (case179: a 1014 x 100 pt picture became 468 x 46 pt) and
shrank a header's 1014 pt logo strip to fit (alfies_arc).

All page outputs already clip at the page: the PDF page box, and the
page bitmaps the rasteriser and the editor draw. Text boxes don't clip
what runs past their sides yet, so a picture wider than a text box is
still fitted to the box rather than spilling over the page
(ParaEnv::fit_objects, part of the paragraph cache key).

On the 390 fixtures: case179 SSIM 70.9 -> 99.9%, alfies_arc 18.4 ->
74.8%, case178 98.0 -> 99.9%, nabl_lab 33.2 -> 36.8%; no fixture got
worse.

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

* Review fixes for #745: fit imported and pasted pictures to the text width

Layout no longer shrinks oversized inline pictures, so pictures that come at their pixel
size are fitted when they enter the document, as Insert › Pictures already does:
- opening HTML (also from a path), Markdown, ODT, RTF and other formats-crate imports
  (io::fit_pictures, against the narrowest section text width);
- pasting HTML/RTF (paste::adopt, against our text width).
Only inline pictures wider than the text are scaled, proportionally. DOCX keeps its sizes.
Test: tests::imported_pictures_fit_the_text_width.

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

---------

Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 06:56:54 -04:00
Hevar Hemin 62a17b841f Bidi: auto-detect RTL paragraph direction when typing Kurdish, Arabic and Hebrew (#713)
* Bidi: auto-detect RTL paragraph direction and keep typing caret advancing smoothly

When typing in Kurdish, Arabic or Hebrew in a new document, default
paragraph direction (bidi: None) was resolving to LTR. In an LTR
paragraph, visual reordering pinned newly typed characters and the
caret to the left margin (x = 72.0), resetting to the start of the line
with every keystroke.

- Add first_strong_is_rtl() following UAX #9 rules P2/P3.
- Auto-resolve unassigned paragraph direction to RTL in layout when the
  paragraph starts with an RTL script.
- Auto-promote paragraph to RTL (bidi = Some(true)) when typing text
  starting with an RTL character.
- Add regression test in wordcraft-engine.

* Review fixes for #713: RTL only when typing into an empty paragraph

- Drop the layout fallback that drew any paragraph without w:bidi as RTL when its first
  strong character is Arabic/Hebrew: opened DOCX paragraphs keep their direction, and a
  style that sets bidi=false is respected.
- type_text promotes a paragraph to RTL only when it had no letters before the insert and
  nothing (paragraph, style chain, document defaults) sets its direction; typing into a
  paragraph that already has text no longer flips it.
- docs/typing-parity.md: Text direction section; test in tests_typing.rs.

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:51:03 -04:00
Bihacsy László e22ce1d05e i18n(hu): complete the Hungarian catalog again (#842)
* i18n: add Hungarian (hu) interface translation

- crates/ui-egui/src/i18n/hu.tsv: complete catalog, all 1,572 keys of
  es.tsv in the same order, written clean-room from the plain meaning
  of the English labels; placeholders, escapes and trailing ellipses
  kept. Commands are nouns, messages use the informal second person,
  and only the first word of a label is capitalised. A noun after a
  number stays singular in Hungarian, so the context-free format needs
  no plural forms.
- LANGUAGES gains `hu` (Magyar); `hu`, `hu-HU` and `hu_HU.UTF-8`
  resolve to it.
- The Font dialog's preview sample is "AaBbYyZz ŐőŰű", so All Caps
  shows the double acute capitals.
- Tests: locale resolution and saved preference, key parity with the
  Spanish catalog, sample translations, count wording, the sample's
  capitals and Hungarian letters in the bundled interface fonts.
- Docs: new localization-hu.md (conventions, terminology, scope);
  README, ROADMAP, localization-parity and control-protocol list the
  new language; ATTRIBUTION row for hu.tsv.

* i18n(hu): built-in style names, equation gallery and citation styles

- hu.tsv now carries every key of the Russian catalog: built-in style
  names (Normal, No Spacing, Subtitle, TOC {n}…), the equation gallery
  and structure labels, paper sizes, watermarks and the Backstage
  greetings, plus the citation style names (1,946 entries).
- The interface audit (untranslated_interface_text) checks Hungarian
  alongside Russian and reports every language's gaps in one run.
- Tests: Hungarian keys cover the Russian catalog; built-in style
  names and numbered style templates show in Hungarian.
- Docs: entry counts and scope in localization-hu, localization-parity
  and ROADMAP.

* Review fixes for #731: Hungarian stays a partial catalog

- The i18n audit requires completeness from Russian only again: hu.tsv lacks ~280 strings of the
  dialogs that landed since (Compare Documents, Math AutoCorrect, …), so it can't claim to be
  complete. The catalog test no longer asserts it holds every Russian/Spanish key.
- 43 Hungarian rows for tab and command labels that reached main since (charts, content controls,
  Developer tab, templates and the styles organizer, WordArt), which every_tab_and_command_is_translated
  requires.
- Docs say partial, with the entry count (1,989); localization-parity's header keeps its quote
  marker and the #505 note.

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

* Review fixes for #731: Hungarian rows for the Outlining tab

24 rows for Outline view's tab and commands, which landed on main meanwhile; 2,013 entries.

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

* Review fixes for #731: ROADMAP header keeps main's SmartArt note

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

* i18n(hu): complete the Hungarian catalog again

- hu.tsv: 374 new rows for the strings that reached main while #731 was
  in review (Compare Documents, Restrict Editing, Math AutoCorrect,
  charts, SmartArt, the Options sections, the print page and more).
  The catalog now holds every key of ru.tsv again: 2,397 entries.
- The completeness checks are back: the interface audit requires
  Hungarian alongside Russian, and the catalog test asserts hu.tsv holds
  every key of ru.tsv and es.tsv. The docs describe a complete catalog.
- Seven review rows follow the catalog's existing patterns: Text Pane
  (Szöveg panel, like the other panes), Design Mode (Tervezőmód, one
  compound), Picture and Date Picker Content Control (clearer compounds),
  Promote to Heading 1 / Demote to Body Text (style name after a colon,
  no case ending) and Reset Graphic (Ábra visszaállítása, shorter).

* Hungarian: translate interface strings added on main since this PR

Adds the 10 strings main gained with Polish proofing (spelling and grammar
messages, Do Not Check Spelling or Grammar, comment/browse buttons), so
hu.tsv again covers every key of the other catalogs (2,419 entries), and
drops the stale "Wildcards (regex)" row; updates the documented counts.

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
2026-10-12 06:45:30 -04:00
pechnikov2026-star feaff0f22b i18n: add French (fr) interface (#729)
* i18n: add French (fr) interface

A complete 1813-entry catalog with the same key set and order as
ru.tsv (the most complete reference), written from the meaning of the
English source. Registered in LANGUAGES after Turkish. The
locale-resolution tests that used fr-FR as the example of a language
without a catalog now assert the French mapping and use Italian,
which has no catalog, to keep testing the unsupported path.

* i18n: complete the French catalog and apply review fixes

Rebase onto main (1805 uk.tsv keys, up from 1812 rows at the original
PR): 357 new translations for the charts, Draw, content controls,
Caption/Index/TOC dialogs and formatting features that landed after
the PR was opened. Also applied the reviewer's five fixes: Bring
Forward → Avancer d'un niveau, Print Layout → Mode Page, Not used by
any command → N'est utilisé par aucune commande, Through → Au travers,
and consistent U+2019 apostrophes throughout.

* Review fixes for #729: attribution, French tests, language lists

- ATTRIBUTION row for fr.tsv, in the form the other catalogs use.
- French locale/saved-preference test and bundled-font coverage test (accents, œ, « », ’, and the
  no-break spaces in Inter).
- Tests that need a language without a catalog use a private-use tag (qaa-QM, qtz) instead of
  it-IT/de-DE, so they keep passing as real languages land.
- 11 French rows for commands that landed on main since (templates, styles organizer, Overtype,
  Move with Text), which every_tab_and_command_is_translated requires.
- fr in docs/control-protocol.md, README and localization-parity.

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

* Review fixes for #729: French rows for Outline view and SmartArt

34 rows for the Outlining and SmartArt Design tabs and their commands, which landed on main
meanwhile and every_tab_and_command_is_translated requires; 1,850 entries.

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

* French: translate interface strings added on main since this PR

Adds 174 French entries (fr.tsv now covers every key in the other
catalogs, 2,023 entries) and drops the stale "Wildcards (regex)" row;
updates the documented entry count.

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

* French: translate interface strings added on main since this PR

Adds the 10 strings main gained with Polish proofing (spelling and grammar
messages, Do Not Check Spelling or Grammar, comment/browse buttons): 2,033
entries; documented count updated.

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

* French: strings main added since the last merge (default app, numerals, KeyTips, Reading Mode)

---------

Co-authored-by: sanches <cfic34@ya.ru>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
2026-10-12 06:38:53 -04:00
ripdog 241877d666 Add paginated Reading Mode with smooth column navigation (#740)
* M8: reflow reading mode into viewport-sized pages

Co-Authored-By: ChatGPT (GPT-6) <noreply@openai.com>

* M8: navigate reading columns with wheel, keys and page controls

Co-Authored-By: ChatGPT (GPT-6) <noreply@openai.com>

* M8: keep Outline in current view and add transient reading layout controls

Co-Authored-By: ChatGPT (GPT-6) <noreply@openai.com>

* M8: replace reading ribbon and align navigation to columns

Co-Authored-By: ChatGPT (GPT-6) <noreply@openai.com>

* M8: animate reading column turns and retain visible text through resize

Co-Authored-By: ChatGPT (GPT-6) <noreply@openai.com>

* M8: name reading navigation Outline and complete its translations

Co-Authored-By: ChatGPT (GPT-6) <noreply@openai.com>

* M8: consolidate Reading Mode progress notes

Co-Authored-By: ChatGPT (GPT-6) <noreply@openai.com>

* M8: keep reading clicks from scrolling the selected column

Co-Authored-By: ChatGPT (GPT-6) <noreply@openai.com>

* M8: reveal reading caret only when outside the viewport

Co-Authored-By: ChatGPT (GPT-6) <noreply@openai.com>

* M8: hide floating formatting toolbar in Reading Mode

Co-Authored-By: ChatGPT (GPT-6) <noreply@openai.com>

---------

Co-authored-by: ChatGPT (GPT-6) <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:33:19 -04:00
zuzazzo 7cccdf0f38 KeyTips: Quick Access Toolbar badges, saved option and native Alt fix (#620) (#709)
* M8.1: Quick Access Toolbar KeyTips (#620)

* M8.1: Handle native Alt events for KeyTips (#620)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:24:36 -04:00
Dmitrii Khlapov af622ac843 Layout › Paragraph: fields fit above the title and line up (#654) (#703)
* #659: one drop-down marker (the dropdown icon) on every ribbon button

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

* #658: menu buttons grey out while their command is disabled

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

* #653: large-button labels end above the group title row

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

* #654: Layout › Paragraph fields fit above the title and line up

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

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 06:16:15 -04:00
Dmitrii Khlapov 4a1acb6c3f Ribbon: large-button labels end above the group titles (#653) (#699)
* #659: one drop-down marker (the dropdown icon) on every ribbon button

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

* #658: menu buttons grey out while their command is disabled

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

* #653: large-button labels end above the group title row

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

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 06:13:17 -04:00
Dmitrii Khlapov 3c8840556a Menu buttons grey out while their command is disabled (#658) (#698)
* #659: one drop-down marker (the dropdown icon) on every ribbon button

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

* #658: menu buttons grey out while their command is disabled

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

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 06:09:16 -04:00
Dmitrii Khlapov c1e1c06297 #659: one drop-down marker (the dropdown icon) on every ribbon button (#697)
Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 06:05:30 -04:00
SammyL-eo c19a2aebef Added some fixes to the spanish "Info" Page on Wordcraft (#695)
* Added some fixes to the spanish "Info" Page on Wordcraft

* Added more Spanish fixes

* Review fixes for #695: test and catalog agree; drop rows main already has

Intense Quote is "Cita intensa" in both; "Palabras clave", "Categoría". Caption, Heading {n} and
Title were duplicates of main's rows (main's "Título" kept). TOC, Keywords, List Paragraph and the
Subtle rows moved into alphabetical order. Turkish and Estonian get the same new keys, which their
coverage tests require.

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

* Polish: the style and document-property names the Spanish Info page now translates

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:01:26 -04:00
RegalDragoon200 d4d28e01f4 Skip non-English runs in proofing instead of flagging them (#100) (#741)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:55:40 -04:00
Brandon Thomas b7aa7639a9 Release prep: 0.6.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:44:42 -04:00
Dmitrii Khlapov dd66d5171b Dark theme: gallery previews no longer bright white (#663) (#706)
* #656: dialog buttons, Discord pill, eraser and search highlight use theme tokens

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

* #663: gallery previews dimmed in the dark theme; dark page under Switch Modes

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

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 05:42:58 -04:00
Dmitrii Khlapov d510d9a41a #656: dialog buttons, Discord pill, eraser and search highlight use theme tokens (#705)
Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 05:39:06 -04:00
Brandon Thomas 956963e9d9 ui-egui: fix Windows clippy in desktop_theme (#872)
Bus is a unit struct off Linux, so Bus::default() tripped
clippy::default_constructed_unit_structs on Windows and macOS. Each
platform's Bus now has a plain constructor.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:36:02 -04:00
osaym de8a3daf31 Fix paragraph alignment focus (#690)
* Fix paragraph alignment focus

Fixes #686

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Review fixes for #690: every ribbon command button returns focus to the document

Instead of four hard-coded alignment ids, big, small and split buttons and ribbon menu
items hand keyboard focus back to the canvas after running any engine command (ui.*
commands open dialogs and panes, which take focus themselves). The test also clicks Bold.
The ui-parity.md note moves into the change log.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:32:58 -04:00
ripdog 25048c8b9c M7: ignore ellipses in sentence capitalization proofing (#742)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:29:57 -04:00
Debarghaya Mitra 439cfd2fe9 Backstage: add Save As page and close on successful save (#727)
* Backstage: add Save As page and close on successful save

* Review fixes for #727: Save As page without the open list; unnamed Save opens no native dialog

The Save As page reused the Open page's recent list, so clicking a document there opened it. It now
has only Browse…. Save on a document without a file goes to the Save As page instead of running
file.save, which also opened the native Save As dialog. Test for both.

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

* Print-page test clicks the page's Print button (the backstage list now has a Print entry too)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:26:06 -04:00
Debarghaya Mitra 59b04d7644 web: use File System Access API showSaveFilePicker with download fallback (#728)
* web: use File System Access API showSaveFilePicker with download fallback

* Review fixes for #728: mark the document saved only after the picker writes the file

A cancelled or failed save in the browser's save picker marks the document unsaved again, and
the beforeunload guard keeps warning while a picker save is in flight. The picker's file types use
our own neutral format names.

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:11:28 -04:00
Cyberverse-cent0 6cf0aec0ff Fix bulleted list toolbar button active state (#651)
* Fix bulleted list toolbar button active state (#642)

Add para.state command to return paragraph formatting state (bullets, numbering, multilevel) and update ribbon to use it for list button active states. Previously, the bullets, numbering, and multilevel list buttons never showed as active even when the cursor was inside a list item.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* Review fixes for #651: list buttons from the paragraph's own level

- The Bullets/Numbering state reads the paragraph's list level (ilvl), not
  level 0, and Multilevel is on when the list numbers its levels together
  (a label showing another level's number, or legal numbering); the old
  level-0 '%2.' check was never true.
- Computed from the paragraph props the ribbon already resolves
  (cmd::para::list_buttons) instead of a new para.state command.
- Engine test.

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

* #651: list-button test starts legal numbering from plain text

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

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:08:11 -04:00
abdo4312 2f58d441b6 Numeral display modes: Arabic, Hindi, Context and System digits (view.numeral) (#501)
* Numeral display modes: Arabic, Hindi, Context and System digits

Display-only digit substitution (Word File > Options > Advanced >
Numeral): ASCII and Arabic-Indic digits are substituted before shaping
while clusters keep indexing the original text, so find, spelling,
copy/paste and saved files see the typed digits.

- layout: NumeralMode (arabic|hindi|context|system) with System resolved
  from LC_ALL/LANG, threaded through LayoutOptions/ParaEnv with cache
  invalidation; Context follows the surrounding text
- engine: view.numeral command + catalog row
- tests: layout tests_numeral, engine command test

* Options page: Numerals picker in File > Options > Display

ComboBox for the view.numeral mode (Arabic/Hindi/Context/System),
mirroring Word's File > Options > Advanced > Numeral. Display-only,
session-scoped like the other Display options.

* fmt: collapse numeral direction branch

* #501: translate the Numerals option label in every catalog

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

---------

Co-authored-by: abdo4312 <abdo4312@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:04:43 -04:00
shirizhaki 1c188ba140 M1: preserve independent complex-script DOCX formatting (#366)
Keep inherited complex-script size, bold and italic independent from Latin
properties and retain explicit values during DOCX round trips. Preserve
ordinary formatting-command behavior and add regression coverage.
Resolve eight existing Clippy warnings without changing behavior so all
six required CI gates pass.

Co-authored-by: ChatGPT <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 04:55:44 -04:00
Dylan M. Taylor 326d695335 Linux: default to XWayland for file drag-and-drop on Wayland desktops (#220)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 04:49:37 -04:00
Brandon Thomas 715f85c612 Tests: linear-time checks measure CPU time, not wall clock under load (#850)
The five tests that assert linear layout/export/hyphenation by comparing
the cost of an n-sized and a larger input (layout: unequal formats, hidden
separators with auto hyphenation, hidden float anchors; pdf: fragmented
hidden headings; proof: hyphenating a long word) timed both with wall
clock, fastest of three. On a loaded machine (CI, shared dev boxes, the
test harness running other tests in parallel) a preempted big run, timer
resolution on the small run, or a tight 4x/8.0 limit (hyphenation measured
~7.2 when linear) made them fail now and then.

Each now uses a shared cost_ratio helper (test-only, no production change):
- CPU time of the test thread from /proc/thread-self/schedstat on Linux, so
  time spent waiting for a CPU is left out (wall clock elsewhere);
- the small input runs `scale` times per timing so both timings are long
  against the clock's resolution;
- small and big runs alternate, five rounds after a warm-up, cheapest kept;
- 8x the input everywhere: linear ~8x, quadratic ~64x, limit 24x.

Measured under load (load avg ~15-35 on 32 cores): 60 linear ratios in
7.7-8.8. With the old quadratic code injected locally each test fails:
unequal formats (uncapped style bucket scan) ~90-104x, hidden separators
(hyphenator rescan per point) ~36x, float anchors (run walk restarted per
anchor) ~188x, PDF headings (left-out range scan per char) ~37x,
hyphenation 50-65x.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 04:37:03 -04:00
Valentin Klein ba9bbb48dd Open Word documents with WordCraft by default (file associations + Options button) (#304)
* Open Word documents with WordCraft from the system (#295)

Register WordCraft for Word, RTF and ODT files on Windows and Linux without
taking over anyone's defaults, and add File > Options > General > "Make
WordCraft the default for Word documents..." where the system allows it.

- Windows MSI: a ProgId per type (.docx .docm .dotx .dotm .doc .dot .rtf
  .odt, plus .md and .txt as Open With candidates only) with the app icon
  and `"wordcraft.exe" "%1"`, OpenWithProgids, Applications\wordcraft.exe
  SupportedTypes, and Capabilities + RegisteredApplications so WordCraft
  shows in Settings > Default apps. New component with its own GUID.
- Linux: the desktop entry declares the macro-enabled, template and legacy
  Word types and text/rtf; AppStream lists the media types. deb, rpm,
  AppImage and both Flatpak manifests use this same file.
- macOS: Info.plist also lists legacy .doc.
- The Options button runs `ui.makeDefaultApp` through a new
  Services::make_default_app hook: Windows opens
  ms-settings:defaultapps on WordCraft's page; Linux and BSD run
  `xdg-mime default ai.storyteller.wordcraft.desktop <types>` (refused
  inside Flatpak or without an installed desktop entry); macOS shows the
  Finder Get Info hint; the web shows nothing. The status bar reports the
  outcome. Strings translated in every catalog.

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

* Review fixes for #304: keep Make default out of programmatic dispatch

ui.makeDefaultApp is refused when run programmatically (control channel, MCP, macros,
scripts); only a click on File > Options > General > Make WordCraft the default… reaches
the host's hook, so no agent can change the user's system default apps or open system
settings. Test: programmatic_calls_cannot_change_the_default_apps.

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

* Default-app strings in the newer catalogs; the button's test looks for a General-pane label

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 04:30:57 -04:00
jeleesky f4dfc8eb85 Fix CJK fallback mixing serif/sans faces within one run (#567)
* Fix CJK fallback mixing serif/sans faces within one run

Per-character fallback picked the first covering face in load order, so a
run without an East Asian font could draw JIS-covered ideographs in a
bundled Japanese Mincho and simplified-only ones in the SC sans, mixing
serif and sans glyphs even inside one word. Pick one face covering all
of the run's East Asian characters when available, keeping the
per-character fallback when none does.

* Review fixes for #567: cache the run-level CJK fallback

- fallback_for_east_asian is cached per (character set, excluded face,
  bold, italic) and dropped when the loaded faces change or the system
  fallback is switched, like the per-character cache, so relayout doesn't
  redo the search; the cache is bounded in entries and in key size.
- loaded_fallback and loaded_covering share one fallback_order helper.
- Test for the cache.

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 04:24:03 -04:00
gyurix d3e2e184c2 Linux: the display theme asks the desktop, not winit (#298)
* Linux: the display theme asks the desktop, not winit

winit reports no system theme on Linux — `ActiveEventLoop::system_theme()` is
`None` there on every desktop — so "Use system setting" never saw the desktop's
choice and fell back to light. A new `desktop_theme` module asks the XDG
Desktop Portal's Settings interface for
`org.gnome.desktop.interface color-scheme` on a thread of its own, so no
D-Bus call is ever made from the frame loop, and the app follows the answer,
including a change made while it runs.

The colour scheme is asked for under its GNOME schema name first, which
GNOME's portal and XFCE's both answer, then under the older freedesktop one,
which KDE answers. A desktop that answers neither leaves the user's own choice
alone, exactly as before. macOS and Windows are untouched: winit answers there.

* Review fixes for #298: one D-Bus connection; watcher started by the app

- The watcher keeps one session-bus connection for its lifetime instead of opening a new
  one every 4 s; a connection whose calls all fail is dropped and reopened on the next poll.
- WordApp::new no longer starts the watcher. The desktop app calls
  WordApp::watch_desktop_theme at start-up, so unit tests, kittest and ui_shot don't spawn
  D-Bus pollers and what they draw doesn't depend on the host's desktop theme (test:
  a_new_app_does_not_watch_the_desktop).
- A change wakes the window (request_repaint), so an idle app follows a theme switch.

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

* Test: name the empty Vec's type (a new dependency adds a PartialEq impl for char)

---------

Co-authored-by: GyuriX <gyurix@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 04:18:29 -04:00
DanielKnourek 199ac88a2e i18n: add Czech (cs) interface translation (#387)
* i18n: add Czech (cs) interface translation

Translate the 1,246-entry interface catalog clean-room from the English source labels (cross-referenced with PdfCraft, VectorCraft, and PhotoCraft Czech catalogs) and register Czech (Čeština) for system locale detection and persisted selection. Add tests for Czech locale resolution, full catalog key parity, count-neutral word count formatting, and bundled UI font glyph coverage for Czech diacritics.

# Conflicts:
#	ROADMAP.md

* Czech: translate interface strings added on main since this PR

Adds 221 Czech entries so cs.tsv again has the full zh-hans key set
(2,021 entries) and drops the stale "Wildcards (regex)" row; updates the
documented entry count.

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
2026-10-12 04:09:20 -04:00
witkowski01 1fa5333b26 M15: Polish interface, and Polish spelling, grammar and hyphenation (#346)
* M15: Polish interface, and Polish spelling, grammar and hyphenation

Interface
- Polish is the tenth interface language ("Polski", pl.tsv, 1,045 entries);
  pl, pl-PL and pl_PL.UTF-8 system locales select it automatically.
- Right-click menu labels now go through translation in every language.
- Review > Language is a menu (English US/UK, Polski, Do not check spelling
  or grammar); the status bar shows the language at the caret.
- With a Polish interface, new blank documents and opened .txt/.md files
  default to pl-PL.

Proofing
- Each run is checked in its own language (w:lang, resolved through
  styles): squiggles, F7, review.issues, suggestions and hyphenation.
  Text in languages without proofing tools is no longer flagged as English.
- Polish spelling from the SJP.PL Hunspell dictionary (Apache-2.0 option),
  read by a new Rust affix engine (crates/proof/src/affix.rs); 875 KB asset,
  ~4.5M word forms. Regenerate with the pl_dictionary example.
- Polish suggestions: case, missing diacritics, rz/ż, ó/u, ch/h confusions,
  missing spaces (napewno -> na pewno), edit distance.
- Conservative Polish grammar rules: repeated words, spacing, doubled
  punctuation, sentence capitals, missing comma before że/który/ponieważ/
  aby/żeby/jeśli…, we/ze forms.
- Polish hyphenation with the hyph-utf8 hyph-pl.tex patterns (MIT option);
  pattern keys widened to 20 letters, TeX exceptions parsed; the heuristic
  no longer treats ł, ż, ś… as vowels.

review.language validates and normalizes tags, can set noProof alone and
the document default; format.state reports lang and noProof.

Tests for every part, docs and ATTRIBUTION.md/NOTICE updated;
cargo xtask ci passes (fmt, clippy, test, assets, layers, wasm).

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

* Layout: rustfmt the test module order left unformatted by #391

cargo fmt --check failed on main after #391 added mod tests_grid.

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

* Review fixes for #346: read Polish spelling from the installed Hunspell dictionary

- Polish spelling uses the Hunspell pl_PL dictionary installed on the
  system (OS packages, Homebrew, LibreOffice, ~/.local/share/hunspell, or
  WORDCRAFT_DICTIONARIES) instead of a bundled 870 KB SJP.PL file;
  without one, Polish text gets no spelling marks, like Word without the
  language's proofing tools. .aff/.dic are size-capped and decoded from
  UTF-8 or ISO 8859-2 (crates/proof/src/hunspell.rs).
- The dictionary can be replaced at runtime (install_dictionary) or
  re-searched (reload_dictionary); an empty one accepts every word.
- Tests use small synthetic dictionaries written for them, so they don't
  depend on what the machine has installed.
- The bundled pl.dic, its ATTRIBUTION/NOTICE entries and the generator
  example are removed; docs describe the installed dictionary.

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

* M15: Polish catalog covers the interface text merged from main

The merge of main (98456ac) brought ~575 new interface strings (charts,
SmartArt, content controls, Developer tab, AutoCorrect, outline view,
protection, compare, print, accessibility, references and Language/
Hyphenation dialogs, Draw additions). pl.tsv lacked them, so
polish_covers_every_key_of_every_other_catalog and
every_tab_and_command_is_translated failed. All are now translated.

cargo xtask ci: all 6 steps pass.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 04:04:03 -04:00
arekown 55f7b894c6 Add German (de) interface language (#574)
* Add German (de) interface language

New catalog crates/ui-egui/src/i18n/de.tsv (1,307 entries), key set
byte-identical to zh-hans.tsv; original translations written from the
meaning of the English UI strings in standard German word-processor
vocabulary; MIT OR Apache-2.0.

- LangInfo for de (Deutsch) registered in i18n::LANGUAGES
- Tests: German locale resolution (de, de-DE, de-AT, de-CH,
  de_DE.UTF-8@euro), saved-preference/document-content isolation,
  catalog key-set parity with zh-hans, count-neutral wording,
  bundled-font glyph coverage for ÄäÖöÜüß
- Count strings use count-neutral wording (Wörter: {n}), the
  convention the other catalogs' tests pin
- Docs: localization-parity.md, gaps.md, roadmap.md, ROADMAP.md,
  README.md, control-protocol.md; ATTRIBUTION.md row for de.tsv

cargo test -p wordcraft-ui-egui i18n: 28 passed; --lib: 162 passed.

* rustfmt after merging main

* German: translate interface strings added on main since this PR

Adds 636 German entries so de.tsv again has the full zh-hans key set
(2,021 entries) and drops the stale "Wildcards (regex)" row; updates the
documented entry count.

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

* Fourteen interface languages with German and Italian

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
2026-10-12 03:55:05 -04:00
fabiodalez-dev 99fbd1861a i18n: add Italian interface (it) (#598)
* i18n: add Italian interface (it)

New it.tsv catalog with 1,378 entries: every key of the existing catalogs plus 51 strings that go through tl! but were in no catalog yet (File › Info properties, File › Home greeting, list levels, Equation tab gallery and tooltips, Draw Table, About tabs). Ribbon tabs, groups and command names use the names Italian Word users know; all other text is written from the meaning of the English.

Registers it in LANGUAGES (it, it-IT, it-CH resolve here), adds locale, saved-preference, coverage and bundled-font tests, and updates README, control protocol, localization parity and roadmap. Refs #594.

* i18n(it): shorter Wrap Text Sides label

Lati del testo a capo matches its parent menu, Testo a capo.

* i18n(it): translate the 590 strings main added since the rebase

Options, accessibility, alt text, content controls, document grid, templates, index, citations and the other labels every catalog gained on main. The Italian coverage test (every key of the other catalogs) passes again.

* i18n(it): Issue is Fascicolo in the bibliography source form

* i18n(it): translate the 351 strings main added (equation gallery, built-in styles, paper sizes, animation settings, file browser)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 03:46:44 -04:00
Valentin Klein 6801d89065 .doc import: inline pictures, floating pictures and basic shapes (#551) (#558)
* .doc import: inline pictures, floating pictures and basic shapes (#551)

Word 97-2003 files kept their text but dropped every picture and drawing.
Pictures and drawings are now read from the OfficeArt records ([MS-DOC],
[MS-ODRAW]):

- Inline pictures: the PICF's OfficeArtInlineSpContainer (MM_SHAPE /
  MM_SHAPEFILE) and its FBSE/BLIP, with crop and alt text from the shape
  properties; the older raw-image path stays as a fallback.
- Floating pictures and shapes: PlcSpaMom/PlcSpaHdr anchors joined with
  the shapes of the OfficeArtContent drawings (fcDggInfo), placed by the
  SPA bounds, bx/by or posrelh/posrelv/posh/posv, wrap type and
  fBelowText; rotation and flips; pictures from the BLIP store.
- Shapes: rectangle, rounded rectangle, ellipse, diamond, triangles, star,
  block arrows, heart, lines/connectors, with fill/line colour and width;
  groups flattened into one group space; text boxes linked to their
  PlcftxbxTxt story parts by spid.
- BLIPs: PNG, JPEG, TIFF as-is, DIB converted to PNG, EMF/WMF inflated
  (the metafile renderer draws them), PICT kept as a sized placeholder.
- Hostile input: record lengths clamped to their parent, nesting depth
  and record counts capped, decompression size-limited, picture bytes
  capped per document as before.

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

* Review fixes for #558: miniz_oxide as a workspace dependency

Restores the change @echelon pushed in 04b59287, which a rebase dropped: miniz_oxide is declared once in the root [workspace.dependencies] and docbin uses it with workspace = true.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 03:42:18 -04:00
Rees 4611afba16 SCRUM-28: preserve imported TOC result ranges (#617) 2026-10-12 03:29:32 -04:00
rabis7575 ad8c264420 Tables: resize by dragging column borders, row borders and the corner handle (#217) (#600)
Like Word: the pointer turns into a resize arrow over a column border or a
row's bottom border, and a small handle shows past the table's bottom-right
corner. Dragging shows a dashed guide; releasing runs the new
`table.resizeDrag` command (page coordinates), so a drag is one undo step
and the selection is kept. Esc cancels a drag.

- Column border: the right neighbour gives or takes the difference, so the
  table keeps its width; Shift, or the table's right edge, changes only the
  column on the left. Columns stay at least 6 pt wide.
- Row border: sets the row's "at least" height; the margins and border bands
  the layout adds are measured and taken off, so the row is drawn at the
  dragged height.
- Corner handle: scales every column and the rows on that page.
- Widths come from the drawn layout, so autofit and percentage widths don't
  make the result jump; a merged cell hides the border it covers.
- Borders are hit-tested in the existing canvas mouse handler, only within a
  few pixels of a border, so clicks in cells still place the caret.

Engine: crates/engine/src/cmd/table_resize.rs (7 tests).
UI: crates/ui-egui/src/table_resize.rs, wired into canvas.rs; kittest tests
drive real pointer events through the whole window (one undo step per drag,
selection kept, clicks in cells still move the caret, Esc cancels).
The command label is translated in all nine interface catalogs.
Progress docs updated: gaps, ui-parity, target-app-parity, ROADMAP.
`cargo xtask ci` passes (fmt, clippy, test, assets, layers, wasm).

Co-authored-by: rabis <340973947+rabis7575@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 02:38:05 -04:00
skylar taylor-barrick a38caa4b15 Print: preserve native workers across upstream platform shutdown changes 2026-10-12 08:37:53 +02:00
Rees b0f3d52d80 SCRUM-26: preserve custom XML stores through saves (#612)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 02:29:16 -04:00
Jeremy Yao c08d9f8598 Accessibility Options: Animation effects setting that follows the system (#791)
* Options: Animation effects setting that follows the system

The app eased wheel scrolling, Ctrl+wheel zoom and its fades whatever the
system's animation setting was: with Windows' Animation effects off, the
page still smooth-scrolled.

File > Options > Accessibility > Animation effects: Use system setting
(the default), On or Off; `ui.animations` for agents; kept in ui.json.

- Off: a wheel notch scrolls the page its whole distance at once, egui's
  own scroll areas get the notch whole instead of eased in, Ctrl+wheel
  zoom steps at once, and egui's fades and scroll-to glides are off.
  Touchpad momentum and the caret blink are unchanged.
- Use system setting asks the host (`Services::animations_enabled`) at
  start-up and when the window regains focus. Windows: the Windows
  Runtime's UISettings, a safe API of the `windows` crate already in the
  build. Web: `prefers-reduced-motion`. macOS and Linux/BSD aren't read
  yet (docs/gaps.md); there it means on.
- The eight new strings are in all eleven interface catalogs.

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

* Animation effects off: zoom steps land finished in one frame

Follow-up to the Animation effects setting: zooming could still look
animated with the setting off.

- Ctrl+wheel zooms in whole steps, each at once. A mouse-wheel notch is
  two steps in its frame, the same zoom as before. Turns that arrive in
  small pieces (a touchpad pinch that Windows reports as Ctrl+wheel, a
  high-resolution or free-spinning wheel) add up to a step of about 10%
  first, where each piece used to zoom a little and the zoom glided.
- A page re-rendered for a zoom step is drawn on the pixel grid in that
  frame. It used to be drawn off the grid, slightly soft, and snap sharp
  a frame later.
- The frame a zoom changes in asks for the next one at once, so the
  status bar's percentage and slider follow in the next frame. They
  could lag until the caret's next blink, up to half a second.

The last two apply whatever the setting.

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

* Animation effects: follow the system's setting on macOS, Linux and the BSDs

"Use system setting" only read Windows and the browser; elsewhere it
meant on.

- macOS: System Settings > Accessibility > Display > Reduce motion,
  through NSWorkspace (a safe call in objc2-app-kit 0.3, already in the
  build).
- Linux and the BSDs: the XDG settings portal over D-Bus (zbus, already
  in the build through AccessKit). Three settings say whether animations
  play, and any one saying no turns them off: the cross-desktop
  reduced-motion preference, GNOME's enable-animations and KDE Plasma's
  animation speed. A thread reads them at start-up and follows the
  portal's change signal. Where no portal answers, or it knows none of
  them, KDE's kdeglobals and GTK's settings.ini are read instead.
- The UI now asks the host every frame instead of at start-up and on
  focus, so a change in the system's settings is followed while the app
  runs. Hosts answer from memory: Windows and macOS are asked again at
  most once a second, the web keeps its media query, and Linux/BSD is
  kept current by its thread.

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

* Animation setting on Linux: ask every setting again when one changes

KDE's portal announces a new animation speed only as the cross-desktop
reduced-motion preference, not under its own kdeglobals name. Keeping
each setting's last announced value left KDE's own one stale: after
going from instant back to normal speed, animations stayed off.

Every setting is now read afresh on any relevant change, and the
settings files' answer stands whenever the portal has none.

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

* Caret glide follows Animation effects; its canvas test turns the setting off, then on

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 02:24:58 -04:00
Valentin Klein d69c94efcf Linux: open/save works without a portal file dialog (#576) (#583)
* Linux: built-in file browser when the portal file dialog is unavailable (#576)

rfd 0.17 on Linux/BSD asks the xdg-desktop-portal FileChooser and falls back to
zenity; with neither (labwc with only xdg-desktop-portal-wlr, no zenity) it
returns None, the same as Cancel, so Open/Save As/Insert Picture silently did
nothing. The dialog thread now checks first (zenity on PATH, or the portal's
FileChooser interface via dbus-send) and answers with an error when neither is
there; the app then finishes the job in its own egui file browser (folder
field, Up/Home, folders and files of the chosen type, file name, type filter,
replace confirmation) with a hint on how to get the system dialog.
File > Open > Browse in WordCraft... (Linux/BSD) and `ui.fileBrowser` reach
it at any time, even while a system dialog never showed up;
WORDCRAFT_FILE_DIALOG=builtin forces it. Native only (not wasm32).

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

* Turkish and Russian strings for the file browser (#576)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 02:19:04 -04:00
Bryan Mark cbd9291909 Caret: glide and fade like Word for Windows; File › Options › Advanced › Animation turns it off (#570)
* Add arcanii to the contributor names

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

* Caret: glide and fade like Word for Windows; File › Options › Animation turns it off

The caret no longer jumps and switches on and off. A key or typing glides
it to its new place in 100 ms along cubic-bezier(0, 0, 0, 1), the same for
one character and for a whole line; a second key before it arrives makes
it snap to where it was heading and set off from there. Clicks, and moves
made by scripts or the control channel, don't glide. The blink is a pair
of 100 ms fades (530 ms on, 530 ms off), and a key or a click turns the
caret on from however visible it was.

These are Word for Windows' numbers (16.0.20326, 100% zoom, 144 Hz),
measured black-box: a synthetic document in a separate Word instance, its
window captured frame by frame while key and mouse messages moved the
caret. Fourteen consecutive frames match the curve to 0.003. Word for Mac
was not measured.

Nothing glides when the view changed (zoom, a resized window, scrolling,
another document), when the caret wasn't drawn the frame before, or when
a move starts or ends off screen.

File › Options › Animation › "Animate the caret (it glides and fades)"
turns it off: ui.caretAnimation {"value"} for agents, kept in ui.json, on
by default, translated in the nine catalogs. Off, and whenever egui's
animations are off (the headless harness, so ui_shot and scripted
screenshots), the caret jumps and blinks exactly as before.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 02:15:02 -04:00
Rees 1f09547f4e SCRUM-34: protect original files during native saves (#621)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 02:11:13 -04:00
Wayne Sun 95abff4205 MacOS window close (#539)
* MacOS window close

The red close button was aborting inside AppKit. The close button now cancels that in-loop window drop, saves preferences, and quits on the next turn of the main queue the same way Command-Q does.

* Review fixes for #539: remove the control key file before the macOS quit

Merge main. The new close path quits through terminate:/exit, which skips
on_exit, so it now removes the control key file as on_exit does (prefs were
already saved). Clippy-checked for aarch64-apple-darwin.

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 02:05:32 -04:00
MarekSpodBiedry 520bc2863e Ribbon and context-menu Paste insert the system clipboard's text (#584) (#593)
* Ribbon and context-menu Paste insert the system clipboard's text (#584)

* Review fixes for #593: ribbon Paste of copied picture files inserts the pictures

Picture files copied in a file manager reach the clipboard as their paths;
ribbon/context-menu Paste now pastes the pictures (as Ctrl+V does) instead
of the path text. Test included.

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:48:02 -04:00
max 9c21eb07df fix(ui): the horizontal ruler mirrors for right-to-left paragraphs (#692) (#756)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 01:42:58 -04:00
yefan911 f0431abd23 M8.1: Bibliography DOCX round-trip: support extended fields (#410) and upstream sync (#532) (#790)
* M8.1: Bibliography sources DOCX round-trip

Store bibliography sources in package-root custom XML (customXml/item1.xml) with datastore item properties (customXml/itemProps1.xml), relationships, root customXml relationship (_rels/.rels), and content type overrides per ECMA-376 §15.2.

Map internal source types to Word b:SourceType values (Book, BookSection, JournalArticle, ArticleInAPeriodical, InternetSite, DocumentFromInternetSite, ElectronicSource, Report, ConferenceProceedings, Case, Art, Film, Interview, Patent, Performance, SoundRecording, Standard, Thesis, Misc) and parse them back. Write SelectedStyle and StyleName attributes on b:Sources. Use safe slice indexing for author names parsing. Scan customXml relationships and customXml/item*.xml on read, extracting sources from b:Sources in the bibliography namespace.

Add roundtrip integration tests in crates/docx/tests/roundtrip.rs verifying all parts, relationships, and attributes, update second-generation kitchen sink stability test, add hostile-input tests in crates/docx/tests/malformed.rs, and verify integration with citation styles in engine citations test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Review fixes for #532: bounded source reading, real citation style

- Read at most 10,000 bibliography sources and dedupe them with hash sets, so a hostile
  file with ~100k b:Source elements can't make opening quadratic; each custom XML part is
  read at most once (64 parts at most). Test: hostile_bibliography_source_count_is_capped.
- Drop the rt::BIBLIOGRAPHY relationship branch (not an OPC relationship type) and the
  stray comment in write_as.
- Write the document's citation style (References > Style) in b:Sources/@StyleName and
  @SelectedStyle instead of always APA: Settings::bib_style records it, the style
  command sets it, opening a file reads it back into the session.
- The b: namespace goes with the other non-body namespaces (it isn't declared on
  document.xml), matching #612.

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

* Keep the custom XML properties relationship apart from customXml

So #612, which adds the same rt::CUSTOM_XML line, merges with this branch without a
conflict.

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

* clippy: pass parse_persons by value (needless_borrows_for_generic_args)

* rustfmt after merging main

---------

Co-authored-by: yefan911 <yefan911@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 01:39:24 -04:00
Eduardo Soares 833d0b00c2 #755: Line Numbers and Hyphenation measure in the Options unit (#795)
File > Options > Advanced > Measurement units (#485) missed these two dialogs:
the Line Numbers "From text" distance and the Hyphenation zone read and
sent inches whatever the preference was. They now convert through
options::unit() like every other dialog; params still send points.

Reported by @Ledity.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 01:32:44 -04:00
max 0b950a7f0b fix(ui): dragging a selection auto-scrolls near the viewport edges (#688) (#758)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 01:20:30 -04:00
max d9b76d77f9 fix(ui): Save As saves under the filename typed in the dialog (#689) (#757)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 01:18:16 -04:00
SteamSmith 65c9b2e5ec speech: launch Windows PowerShell by full path for Read Aloud (#792)
Read Aloud on Windows started `powershell` by name, so the program search
decided which executable ran. Start
%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe instead
(C:\Windows when SystemRoot is unset), as PdfCraft already does for
rundll32. The command line and stdin handling are unchanged.

Adds speech::tests::powershell_is_launched_from_the_system_folder.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 01:16:07 -04:00
Dmitrii Khlapov 9d7a972d0b #771: a page total in the footer doesn't make the body lay out again (#773)
foot_top_for measured the footer with total tracking still on, so a
NUMPAGES/SECTIONPAGES footer counted as a page total shown in the body and
every layout ran a second full pass. Footer totals are filled in after
pagination, like the header's, so the measurement now turns tracking off
the way body_top_for does.

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 01:12:26 -04:00
matty a520fb362d Track Changes: show deletions in balloons when 'Show revisions in balloons' is on - balloons now work (#734)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 01:08:26 -04:00
Debarghaya Mitra 9a58b5ba33 Web: add wordcraft.ico as favicon via Trunk (#717)
Registers assets/app-icon/wordcraft.ico in apps/wordcraft-web/index.html
using Trunk's rel=icon asset pipeline.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 01:04:33 -04:00
naxvog 37318985b5 Layout: multiple line spacing adds its leading below the text, like Word (#719)
Benchmarked against docxide-pdf's 390 Word-referenced fixtures, the
largest systematic gap was the vertical position of text: WordCraft put
the baseline one descent above the bottom of the line box, so all the
extra leading of 1.15 or double spacing went above the text (1.8pt at
1.15-spaced 12pt). Word keeps the text one ascent below the line top and
puts the extra leading below it; below single spacing it shrinks the
whole box.

The hhea line gap now counts toward the ascent instead of being split
half above, half below. Line heights stay the same, but every descent
is half a line gap smaller, so text in exact, at-least and grid-snapped
lines (which keep the descent below the baseline or centre the glyph
box) sits up to half a line gap lower: 0.25pt for 12pt Times New Roman.
On the suite that brings those lines closer to Word as well (exact:
5 fixtures better, 1 worse; at-least: 5 better, none worse).

On the suite: mean Jaccard 29.2 -> 33.5%, SSIM 49.4 -> 50.3%, median
baseline offset from Word +0.68 -> -0.06pt, documents within 1pt
35 -> 51%.

Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 00:58:37 -04:00
MarekSpodBiedry 92d6165d4c Let dialogs be dragged by their title bar instead of pinning them to the screen centre (#732) (#748)
* Let dialogs be dragged by their title bar instead of pinning them to the screen centre (#732)

* rustfmt after merging main

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 00:54:25 -04:00
Dmitrii Khlapov 1490d4e594 Dark theme: readable text on the accent (#655) (#704)
* #657: input borders reach 3:1 contrast in both themes

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

* #655: dark-theme accent controls use dark on_accent text (6:1)

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

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 00:51:53 -04:00
skylar taylor-barrick 1d0dff03a4 Print: preserve page planning and pass native copy collation to spooler 2026-10-12 06:50:19 +02:00
MarekSpodBiedry cad4138e7e Backspace still erases while Shift is held (#707) (#747)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 00:50:14 -04:00
MarekSpodBiedry 4fb9c2cb2a Ink: draw the live pen stroke in the dark-page colour while drawing, not only after lifting the pen (#730) (#746)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 00:48:02 -04:00
naxvog 05679497ea Pictures: WMF drawings fill their frame like Word plays them (#739)
Word plays a WMF into the picture's box the way a GDI player does: the
placeable header's bounds are only the first window, mapped onto a
viewport of the picture's size, and the file's own SETWINDOWORG and
SETWINDOWEXT records replace that window. WordCraft mapped the header's
bounds through the file's final mapping instead, which broke three
pictures in docxide-pdf's fixtures:

- bulgarian_farmland_allocation_order: the coat of arms draws in a
  window centred on the origin, nowhere near the header's bounds, so
  the lion was blown up into a solid black box.
- ukrainian_municipal_heating_resolution: a portrait window replaced
  the landscape bounds, so the emblem was drawn at a fifth of its size.
- A plain WMF in anisotropic mode that sets a window but no viewport
  kept the 1 x 1 default viewport and shrank to a point; its first
  window is now its target.

Files that set their own viewport, or use the fixed-scale map modes,
are read as before.

SETROP2 is now read too: with R2_NOP, shapes and paths draw nothing
(the coat of arms first draws its bounding rectangle that way, which
showed as an outline). The mode is saved and restored with the DC.

Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 00:45:53 -04:00
Dmitrii Khlapov b2491a28dd #657: input borders reach 3:1 contrast in both themes (#696)
Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 00:43:19 -04:00
Dmitrii Khlapov 219b125eeb #661: Table of Authorities buttons labelled, with Update Table (#693)
Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 00:41:03 -04:00
Dmitrii Khlapov 1b96e04b2c Add fonts at run time: Add Font…, drop a font file, font.add (#582) (#668)
* Add fonts at run time: Add Font…, drop a font file, font.add (#582)

Rebased onto main as one commit (the branch had merge commits from main).

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

* #582: font test no longer depends on the system's fallback font

The FreeBSD runner has no system fonts, so a missing family fell back to
Source Serif 4, the font the test compared against. The test now adds a
renamed JetBrains Mono under a name without Serif/Sans/Mono (which picks a
fallback of that kind), with narrow-letter sample text: monospaced, it
measures unlike any proportional fallback.

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

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 00:38:20 -04:00
Valentin Klein 8bd3427012 View: Hide White Space between pages in Print Layout (#645) (#684)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 00:34:30 -04:00
Brandon Thomas 19684cff26 layout: make Myanmar fallback tests deterministic (#807)
tests_complex::myanmar_syllables_are_single_clusters and
myanmar_in_a_latin_font_falls_back_to_a_regular_myanmar_face failed about
1 run in 4 of the full layout suite (cluster starts [0, 3, 6, 12, 18], "one
face for the word") but passed alone.

Root cause: a race in FontDb's system font fallback, not the tests. Tests
share FontDb::global(), and layout asks for a fallback per character from
several threads. A search marks a preferred family ("Padauk" for Myanmar) as
tried before loading it; a second search racing it skipped the family as
already tried, fell through to the coverage index, and loaded the first
cataloged file in path order that maps the character (Padauk-Bold.ttf on
its own). Characters of one syllable then fell back to different faces, so
the shaper split the syllable and the word's faces differed. Separately, a
search's answer was cached under the face count read after the search, so
an answer chosen before another thread's load could be cached as current.

Fix (crates/fonts):
- System fallback searches run one at a time (a `searching` lock). A search
  that waited re-checks the loaded faces first, so it uses the family the
  previous search loaded instead of loading another file.
- The fallback cache keys an answer by the face count it was chosen from,
  and doesn't cache an answer from before a concurrent load.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:29:58 -04:00
Brandon Thomas 46e694f5f5 Release workflow: pass the Azure signing secrets so Windows builds are signed (#752)
Since the release workflow moved to storytold/craft-common-actions, it has
passed only the Apple and WINDOWS_CERTIFICATE secrets. The signing job reads
values from the release environment, but only for names the caller lists, so
the AZURE_* names that Trusted Signing uses came through empty and Windows
builds would ship unsigned. Same fix as storytold/craft-launcher#174.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:27:33 -04:00
lunar-me 0918a52e57 docs: fix coordination error in ROADMAP.md (#715)
Co-authored-by: lunar-me <lunar-me@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 00:20:52 -04:00
petycomp 85698c4d79 Security: pictures travel in the reply, exported links keep only safe targets (#678, #679) (#681)
- `ui.render` and `ui.screenshot` without a `path` return the PNG as base64 `png` in the
  reply instead of writing a fixed name into the shared temporary folder, where another
  user could have planted a link to one of this user's files; the MCP bridge's
  `render_page` and `screenshot` use that and no longer round-trip through
  `temp_dir()/wordcraft-mcp-*.png`. A given `path` still works as before (#678).
- One allow-list, `wordcraft_doc::link`: `is_web_or_mail_link` for Ctrl/⌘-click and
  `is_safe_href` (web, mail, `#bookmark`, relative path; schemes read the way browsers
  do, so `java\nscript:` is a script) for the HTML and Markdown exporters and the HTML
  importer. Scripts and other schemes export as plain text (#679).
- Tests: the allow-list, exporters with hostile links, the MCP bridge against a fake app
  (no `path` ever asked for), the control channel's in-reply PNGs.

Co-authored-by: petycomppety <30414255+petycomppety@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 23:57:08 -04:00
Valentin Klein 62b58d1f87 Design tab: style set gallery gets its own box so it can't overlap Colors and Fonts (#638) (#673)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 23:42:20 -04:00
Dmitrii Khlapov f7a84fbfb8 Print Markup: comments in PDF, print, images and text copies (#589) (#667)
Rebased onto main as one commit (the branch had merge commits from main).

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 23:38:29 -04:00
Valentin Klein 381c475edc M-print: File > Print preview, page range, odd/even, copies and collate (#641) (#674)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 23:30:58 -04:00
Dmitrii Khlapov 39f51ca292 More Colors… and a Colors dialog for custom colours (#606) (#666)
Rebased onto main as one commit (the branch had merge commits from main).

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 23:27:34 -04:00
Dmitrii Khlapov 3fd97c3e58 Complex scripts: shape Myanmar, Khmer and Indic text; caret and line breaks respect syllables (#604) (#652)
Rebased onto main as one commit (the branch had merge commits from main).

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 23:22:57 -04:00
Dmitrii Khlapov 2544372a22 #611: DOCX diagrams: theme-styled shapes and connectors, VML lines (#671)
Shapes and connectors that take their fill and outline from the theme
(wps:style a:fillRef/a:lnRef) now show; scheme, preset, system, scRGB and
HSL colours resolve. Callouts and flowchart boxes draw as the nearest
shape. Legacy VML lines (with arrowheads), rectangles and ovals are read.
A DrawingML line runs top-left to bottom-right, as the preset does.

Rebuilt on main, which now has connectors, arrowheads and canvases (#416,
#344): those parts of the earlier branch were dropped in favour of main's.

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 23:15:28 -04:00
Valentin Klein 39e541fc8b Table: a vertical merge lays out and hit-tests as one cell (#640) (#669)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 23:11:40 -04:00
Dmitrii Khlapov ab49e4f5fa Open playlists and other text files (.m3u, .m3u8, .log, .ini, …) (#569) (#648)
Rebased onto main as one commit (the branch had merge commits from main).

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 23:07:41 -04:00
Dmitrii Khlapov 14080c614c Drag after a double/triple-click extends the selection by words/paragraphs (#549) (#649)
Rebased onto main as one commit (the branch had merge commits from main).

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 22:58:11 -04:00
Rees 0eea21c2c8 M1: preserve omitted DOCX paragraph defaults (#647)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 22:47:03 -04:00
Valentin Klein b75bd3c56a Fonts: Palatino Linotype and Book Antiqua prefer Palatino look-alikes (#670) (#677)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 22:42:05 -04:00
Dmitrii Khlapov 4fffa81003 #664: brand licence names WordCraft, not DesignCraft (#682)
Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 22:39:47 -04:00
Dmitrii Khlapov a37253109e #665: rustdoc names private items as code, not links (#683)
Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 22:33:33 -04:00
alexanderalber 29ee256c86 Paragraph dialog: OK keeps Exactly and At Least line spacing (#467) (#564)
The dialog shows line spacing as a multiple, so Exactly and At Least
read as 1.0, and OK (and Tabs…) always sent that back as Multiple 1.0.
Accepting the dialog unchanged, or changing only Keep with next, turned
a fixed rule into single spacing.

The dialog now remembers the value it showed and sends line spacing only
when it changed, as it already does for Asian Typography. A changed
value still sets a multiple.

generated with Claude Code

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 22:30:57 -04:00
DevCoderMax 0652e7f72f Pictures in headers/footers can be moved, resized and rotated (#591)
* Pictures in headers/footers can be moved, resized and rotated

Hit testing (layout.object_at_in), arrange.bounds/position/nudge/group and the canvas selection, drag, resize and rotation handles used to be body-only, so pictures in headers and footers could not be moved (#82). They now work within their own header/footer story: drag to move, handles to resize, rotation handle to turn, arrows to nudge. Regression test: header_pictures_can_be_resized_and_dragged.

* Credit DevCoderMax (Gleyson do Nascimento Carvalho) in About

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 22:27:44 -04:00
MarekSpodBiedry d1064bbaad Zotero: a note with a picture stays when its citation moves to body text (#469) (#581)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 22:23:54 -04:00
skylar taylor-barrick 9644e646a6 Print: preserve main updates without progress-log conflicts 2026-10-12 04:22:41 +02:00
MarekSpodBiedry 387873ae05 Zotero: a pending citation no longer edits a replacement document (#470) (#580)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 22:20:33 -04:00
MarekSpodBiedry cfb65364c7 Web: a CSV picked as recipients no longer turns the next TXT drop into recipients (#472) (#579)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 22:18:07 -04:00
alexanderalber 0db810d0e8 Layout › Page Setup: the group launcher opens the Page Setup dialog (#471) (#563)
The launcher ran ui.dialog, which needs a dialog name, but group
launchers run their command with no parameters, so nothing opened. It
now runs layout.pageSetup, which asks the UI for the Page Setup dialog,
as the Font and Paragraph launchers run their dialog commands.

The test opens the dialog through layout.pageSetup and checks that no
ribbon group uses ui.dialog as its launcher.

generated with Claude Code

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 22:15:27 -04:00
MarekSpodBiedry e9da654903 IME composition text uses document fallback fonts instead of boxes (#565) (#578)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 22:12:39 -04:00
alexanderalber aaaf68c6c7 Contributors: add my own entry (alexanderalber) (#566)
generated with Claude Code

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 22:09:49 -04:00
alexanderalber f91ce21f90 Find: each Find Next in the dialog moves one match (#468) (#556)
The Find and Replace dialog ran edit.find and then edit.findNext on every
click (and Enter). edit.find already selects the first match after the
caret, so the step after it skipped one: with four matches only the
second and fourth were ever reached, with two only the second.

edit.findNext and edit.findPrevious now take the same optional search
options as edit.find (text, matchCase, wholeWord, regex), and the dialog
sends one edit.findNext with its options. Calls without options behave
as before (F3, Mod+G).

generated with Claude Code

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 22:02:37 -04:00
Valentin Klein baeb8d9c6c Open and save Word XML Document (Flat OPC); open Word 2003 XML (#548) (#560)
- docx: Flat OPC (`pkg:package`) read by unpacking the parts into an
  in-memory package for the DOCX reader; written from the DOCX writer's
  parts (XML inline, binary parts in base64). Part count, part size,
  total size and base64 length are capped.
- docx: Word 2003 XML (`w:wordDocument`) imported by rewriting it as the
  corresponding package (hyphenated names, w:hlink, w:listPr, w:lists,
  inline headers/footers, notes, aml annotations, w:binData pictures,
  o:DocumentProperties) and reading that with the DOCX reader.
- `.xml` told apart by root element; other XML is refused with
  "not a Word XML document". File > Export gets "Word XML Document (*.xml)"
  (all 9 catalogs); .xml saves keep the document like .docx.
- Tests: Flat OPC round trip equals DOCX round trip, hand-written Flat OPC
  package, Word 2003 import, hostile inputs, engine io.

Fixes #548

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 21:59:00 -04:00
LloydNicholson 7c5f72a31b DOCX: theme colours and theme fonts take precedence over the fixed values, as in Word (#626)
ECMA-376 says a theme attribute supersedes the fixed value next to it, and
Word renders it that way. The reader did the opposite:

- w:color read only w:val, so a style written as
  <w:color w:val="4472C4" w:themeColor="accent1" w:themeShade="BF"/>
  stayed blue under a theme whose accent1 is another colour. w:themeColor
  (and w:u's theme colour, and w:shd's w:themeFill) now resolve through the
  theme's colour scheme and settings' w:clrSchemeMapping, with
  w:themeTint / w:themeShade applied to the HSL luminance. Without a theme
  part the fixed colour is kept. The mapping is read before the styles,
  which come before settings in the package.
- w:rFonts preferred w:ascii over w:asciiTheme (and w:hAnsi over
  w:hAnsiTheme), so a heading with both showed the fixed font instead of
  the theme's major font. The theme font now wins in each slot.

Saving writes the resolved colour as w:val.

Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 21:53:05 -04:00
skylar taylor-barrick 8889e356d5 Print: synchronize main updates for native CI 2026-10-12 03:51:41 +02:00
Valentin Klein a608332646 Chinese documents: character-unit indents, .doc indents and line spacing, floating tables, blank pages (#534) (#550)
- Indents in characters (w:firstLineChars, w:hangingChars, w:leftChars,
  w:rightChars; sprmPDxc* in .doc) are kept in the model, read and written,
  and laid out in the paragraph mark's font size, winning over the points
  as in Word. Indent commands cancel an inherited character indent.
- .doc: sprmPDxaLeft1 has a two-byte operand (was read as four, so every
  first-line indent was lost); sprmPDxaLeft1_80 is read; LSPD line spacing
  is decoded as signed dyaLine + fMultLinespace (exact 20pt read as 799pt,
  a page per empty paragraph).
- A floating table placed on the page or margin that would cover the text
  already on the page and doesn't fit below it moves to the next page.
- A line holding only a page break stays at the bottom of a full page
  instead of leaving a blank page.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 21:49:25 -04:00
Valentin Klein dab5092e83 Font dialog: separate Asian and Latin text fonts (#505) (#536)
* Font list: installed Chinese fonts are listed, by their Chinese names in a Chinese interface (#505)

The font database read only the English family name of each font, so a
Chinese interface listed SimSun, Microsoft YaHei and WenQuanYi Zen Hei
by their English names and never as 宋体, 微软雅黑 or 文泉驿正黑, and a
document naming a font by its Chinese name fell back to another font.

- fonts: keep each face's localized family names (name IDs 16 and 1,
  every face of a collection, read during the scan without loading the
  file); FontDb::families_in(lang) lists them in place of the English
  names for a matching interface language (zh-hans, zh-hant, ja, ko);
  face, has_family and styles accept either name.
- ui: the ribbon and mini toolbar font boxes show the interface
  language's names and store the English name.
- East Asian font: choosing a Chinese, Japanese or Korean font also sets
  it as the East Asian font (w:eastAsia), read and written in DOCX;
  layout draws CJK text in a run's installed East Asian font.

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

* Font dialog: separate Asian and Latin text fonts (#505)

Word's Font dialog sets the font of Chinese, Japanese and Korean text
apart from the font of Latin text; WordCraft's had one font box.

- Font dialog: an "Asian text font" box (the installed Chinese, Japanese
  and Korean fonts, by their interface-language names, type to find)
  above the "Latin text font" box, shown for a Chinese or Japanese
  interface, East Asian text in the selection or a selection with an
  Asian font. OK sets only the fonts changed, in one undo step.
- format.font takes "eastAsia" (w:eastAsia) besides "name"; either or
  both; format.state reports fontEastAsia.
- fonts: the scan reads each font's OS/2 code pages, so
  FontDb::east_asian_families lists CJK fonts without loading them.
- Tests: dialog sets each font on its own; layout sets CJK text in the
  run's Asian font and Latin text in its Latin font (skipped without an
  installed CJK font); command and undo; the scan finds a CJK font.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 21:42:56 -04:00
Valentin Klein 60cd1bf568 Numbering: Chinese, Japanese and Korean number formats (一、二、三 …) (#534) (#561)
* Numbering: Chinese, Japanese and Korean number formats (一、二、三 …) (#534)

A list with w:numFmt="chineseCounting" and lvlText "%1、" showed "2、", and the
digit was drawn with glyph ids of the CJK font that supplied the comma, so it
came out as an unrelated letter: the list label was laid out in font runs but
drawn with the style of its last run only.

- NumFormat knows every ST_NumberFormat value (ECMA-376 §17.18.59), so a file
  saves back the format it was opened with; unknown values are decimal.
- New doc::cjk_numbers: Chinese/Taiwanese counting (with 十 below 100, digit by
  digit above) and counting-thousand (百/千/万/亿, 零 for gaps), simplified and
  traditional legal numerals, Japanese counting/legal/digital ten-thousand,
  Korean counting/digital/legal, ideograph digits, heavenly stems, earthly
  branches and the sixty-year cycle, aiueo/iroha (half and full width),
  ganada/chosung, enclosed numbers (①, ⒈, ⑴, ㊀) and full-width digits. Values
  past a sequence's end fall back to decimal.
- Legal numbering (w:isLgl) shows every level, the current one too, in decimal.
- List labels keep their font runs and draw each with its own face.
- A list level without w:ind no longer gets a 36pt indent on read.
- .doc number format codes 0x00-0x27 map to the formats (0x16 decimalZero).
- PAGE/NUMPAGES fields take \* ChineseNum1/2/3.

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

* rustfmt after merging main

* Layout test: end the list range inside the third paragraph (#477 skips a paragraph the range ends at the start of)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 21:35:41 -04:00
skylar taylor-barrick 98c457a98d Print: adopt craft-print with native settings and CI 2026-10-12 03:35:23 +02:00
Valentin Klein b2c51c6500 Fonts: index system font coverage once, so fallback no longer re-reads every font per character (#605) (#610)
System fallback read and parsed every cataloged font file (<40 MB) again for each distinct
character no loaded font covers; only that character's miss was remembered. Text in a script
no installed font covers (Tangut, CJK Ext G, ...) froze layout for seconds to minutes.

Each file is now read once, when a fallback search first reaches it, and its coverage is kept
as sorted code point ranges; later searches answer from the ranges and only read a file that
maps the character (to load it). Rescans keep the ranges of files still installed. Preferred
fallback families are tried once each, the miss set is capped, and indexing a damaged file is
bounded (faces, mappings, ranges). Search order and the fonts chosen are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 21:24:48 -04:00
Valentin Klein f422d6ccbb Mini toolbar: every button fits, flush font box, proper dropdown arrows, dark-mode font list (#535) (#540)
- Two rows like Word's mini toolbar (font, size, grow, shrink; B I U S, highlight,
  font colour, four alignments), sized from the ribbon widgets' real metrics so no
  control is clipped any more (the one-row bar cut off everything after Align Left).
- Combo lists drop from the box's left edge instead of from the arrow.
- The dropdown triangle fills its box, so it no longer shrinks to a dot beside
  combos and split buttons.
- Font list entries are an ink mask tinted with the theme's text colour, so they no
  longer sit on white strips in dark mode.

Tests: the bar drawn over a real selection contains every control and is exactly as
wide as them; font entries take the theme text colour (dark and light) and paint no
background; the ink mask keeps coverage and drops the paper.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 21:19:26 -04:00
Valentin Klein 7484a6bd11 Find and Replace: special characters (^p, ^t, …) and wildcards (#543) (#553)
- Find what understands Word's ^ codes (^p ^t ^? ^# ^$ ^^ ^l ^m ^n ^b ^+ ^= ^s ^~ ^- ^w ^g ^% ^v,
  ^nnn / ^unnnn); Replace with adds ^& (found text) and ^c (clipboard). `special: false` reads
  the text literally.
- Use wildcards: ? * [abc] [a-z] [!x] {n} {n,} {n,m} @ < > ( ), \1-\9 in Replace with; always
  matches case, like Word. Patterns are translated to the linear-time regex engine (no
  catastrophic backtracking), length and counts capped, bad patterns are errors.
- ^p matches paragraph marks between sibling paragraphs; a ^p in the replacement splits the
  paragraph. Replace All stays one undo step.
- Match prefix / suffix, Ignore punctuation / white-space characters.
- Dialog: Use wildcards checkbox, Search Options with the new options and a Special menu that
  inserts codes into the focused field (a shorter list with wildcards); strings in all 9 catalogs.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 21:15:31 -04:00
Valentin Klein 850fa0576a AutoFormat: +---+ tables, arrow bullets, smileys inside words, border-line undo (#546) (#547)
- `+---+------+` Enter turns the paragraph into a one-row table across the text
  width, columns proportional to the hyphens, caret in the first cell (body,
  top level, at most 63 columns).
- `> ` `-> ` `=> ` start a bulleted list with an arrow bullet (➢ ➔ ⇨).
- Smileys `:)` `:-)` `:(` `:-(` `:|` are replaced also straight after a word.
- Undo right after a border line or table AutoFormat restores the typed
  characters and keeps the Enter (its own undo step).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 21:11:22 -04:00
Valentin Klein 46d5e99b3e Styles: Update to Match Selection takes the selection's formatting (#555) (#562)
styles.updateToMatch copied only the direct run/paragraph formatting at the
caret onto the style, so text whose look came from its own style (Normal,
Title, ...) changed nothing; it then wiped that paragraph's direct formatting
without applying the style, so the selection lost its look too.

Now the style takes the selection's effective formatting (paragraph style
chain, character style chain, direct formatting) as a diff against what the
style currently gives, keeping its outline level and numbering. The selected
paragraphs take the style and drop only the direct formatting it now carries.
The Home styles gallery tiles get the right-click menu (Update to Match
Selection, Modify...).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 21:06:14 -04:00
Brandon Thomas 7d1f6a07c1 docx: fix clippy large_enum_variant on main (#779)
A field's buffered result items held an InlineObject inline, which grew past
clippy's large_enum_variant limit with today's object changes. Box it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:01:43 -04:00
Valentin Klein 48b2468f19 Windows: compile DX12 shaders with FXC so startup needs no DXC DLLs (#603) (#609)
wgpu 30's default DX12 compiler (Auto) uses DXC and loads whichever
dxcompiler.dll the DLL search path finds first. WordCraft ships none, so
that copy belongs to another program; without a matching dxil.dll, DXC
warns that the DXIL is unsigned, wgpu treats the warning as a compile
error, its indirect-validation pipeline fails, the device is lost and
eframe exits before the first frame ("Parent device is lost").

The window's wgpu instance (and the DirectX 12 adapter probe) now pins
the DX12 shader compiler to FXC (d3dcompiler_47.dll, part of Windows)
unless WGPU_DX12_COMPILER names another. Same approach as
storytold/lightcraft#484. README troubleshooting note and ROADMAP log.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 20:56:45 -04:00
Dmitrii Khlapov 191d8fd48e #509: README names the three AUR packages and when to pick each (#616)
Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 20:45:31 -04:00
Brandon Thomas 8fb782ea02 CI: run FreeBSD and Windows ARM64 checks nightly and on release, not per PR (#530)
They ran on every push to main and on any PR touching Cargo.toml/Cargo.lock,
which is nearly every PR. See craftrules release/playbook.md §2.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:34:27 -04:00
Valentin Klein 6a22d7257b Insert › SmartArt: create and edit list, process, cycle, hierarchy, pyramid, radial, matrix and venn graphics (#497) (#503)
- Model (doc::smart_art): layout, items (text + level), colour variant; capped at 100 items,
  255 characters, 8 levels.
- Layout algorithms of our own (docx::smart_art) place rounded rects, circles, bands, arrows and
  connectors; WordCraft draws the graphic from the same dsp:drawing it saves.
- DOCX: data (dgm:dataModel points and connections), our own layout/quick-style/colour
  definitions under the built-in identifiers, the drawing part, relationships and content types.
  A diagram reads back editable only when its data part is exactly what WordCraft writes.
- Commands: insert.smartArt, smartArt.items/addShape/promote/demote/layout/colors/reset/textPane,
  one undo step each; picture.size relays out a SmartArt graphic.
- UI: layout picker with previews drawn in code, SmartArt Design tab, Text Pane; strings in all
  9 catalogs.
- Diagram reader draws `line` presets top-left to bottom-right (was the other diagonal).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 20:09:32 -04:00
Valentin Klein 900c0f4412 Outline view: Outlining tab, levels, collapse/expand, Show Level (#493) (#499)
* Outline view: Outlining tab, levels, collapse/expand, Show Level (#493)

- View › Outline is its own view mode: the body on one long sheet, each paragraph indented
  by its outline level behind a symbol drawn in code (circled plus for a heading with content,
  circled minus for one without, a dot for body text); paragraph indents and alignment hidden.
- Collapsed headings hide their subtree and get a wavy line; Show Level 1-9 / All; Show First
  Line Only; Show Text Formatting off draws everything in the Normal style. All of it is view
  state (ViewState.outline), not saved; collapsed headings are found again by their text after
  edits, and a caret entering hidden text moves on to the next shown paragraph.
- Commands: outline.promoteToHeading1/promote/demote/demoteToBody/level/moveUp/moveDown,
  outline.expand/collapse/toggle/selectSubtree, outline.showLevel (+ showLevel1-9, showAll),
  outline.firstLineOnly, outline.showFormatting, outline.close, outline.state. In Outline view
  Move Up/Down and Promote/Demote take a collapsed heading's hidden content along.
- Keys: Alt+Shift+Plus/Minus expand/collapse, Alt+Shift+1-9 and Alt+Shift+A Show Level.
- Outlining tab (next to File while in Outline view): level box, promote/demote, move,
  expand/collapse, Show Level, the two check boxes, Close Outline View. Clicking a symbol
  selects the heading with its content; double-clicking expands or collapses it.
- Catalog: Outlining tab rows (421/453, 92.9%); translations in all nine catalogs; docs.

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

* Docs: drop the stale shortcut and dialog rows left by the rebase (#493)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 19:54:56 -04:00
Valentin Klein 50acc0c5f3 Vertical text for sections (w:textDirection) (#491) (#496)
- Model: SectionProps.text_direction (horizontal, tbRl, btLr); unknown
  values read as horizontal.
- DOCX: w:textDirection in w:sectPr read and written in CT_SectPr order
  (after w:titlePg, before w:bidi), also inside w:sectPrChange.
- Layout: a vertical section is laid out on its page turned on its side
  (margins, page borders and columns turned with it) and each page is
  turned onto the real page: tbRl lines run top to bottom stacking from
  the right, btLr bottom to top from the left. Pictures, shapes and text
  boxes stay upright; headers and footers stay horizontal; a section
  turning the other way starts a new page.
- Display: in top-to-bottom text ideographs, kana, Hangul and full-width
  forms stand upright (ideographic comma/full stop moved to the upper
  right); Latin lies on its side. Raster and PDF draw it unchanged.
- Hit testing: clicks outside turned lines find the nearest turned line.
- Command layout.textDirection (Layout › Page Setup › Text Direction):
  turns the selected cells or the text box the caret is in, else the
  section; ribbon menu and keytip.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 19:45:49 -04:00
Valentin Klein e46133817a Templates and Add-ins: attached template, update styles, Organizer (#377) (#382)
* Templates and Add-ins: attached template, update styles, Organizer (#377)

- Model + DOCX: Settings keep the attached template (w:attachedTemplate's external
  relationship of the settings part) and w:linkStyles; both round-trip, written in
  CT_Settings order (after w:mirrorMargins, before w:trackRevisions).
- Engine: tools.templates (state), tools.attachTemplate, tools.detachTemplate,
  tools.linkStyles, tools.updateStyles (copy the template's styles and defaults, one
  undo step; path or base64 data), styles.organizer (list), styles.copyFrom (copies
  missing bases, linked halves and the lists a style numbers with), styles.rename.
  The template path comes from the document, so it is never read automatically (not on
  open either): only an explicit tools.updateStyles / attach with update reads it.
- UI: View > Templates > Templates and Add-ins dialog (path, Attach..., automatically
  update document styles, Organizer...) and a two-list Organizer (Copy, Delete,
  Rename...); strings in every catalog, Estonian included.

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

* Review fixes for #382: never follow a template path to another machine

A document's w:attachedTemplate can name \\host\share\T.dotx, //host/share or
file://host/share. Opening it (Update Styles, Organizer) would make Windows
authenticate to that host over SMB and leak the user's NTLM credentials.
local_path now returns None for network paths; regression test added.

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

* #377: refresh catalog count and parity checklist after rebase

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 19:35:06 -04:00
Valentin Klein 2e5ecb916b File › Options: Word's panes with working settings (#485) (#492)
File › Options is now a pane list (General, Display, Proofing, Save,
Language, Accessibility, Advanced, Agents), one function per pane in
crates/ui-egui/src/options.rs. Every setting shown is wired:

- General: theme, interface size (#475), mini toolbar on selection, dark page, community
  button, user name and initials (comments use the initials).
- Display: tab characters, spaces, paragraph marks and hidden text shown
  with Show/Hide ¶ off; rulers; print the page colour; update fields
  before printing.
- Proofing: ignore words in capitals / with numbers / web and file
  addresses (proof::Options, passed through layout), check spelling and
  mark grammar as you type, custom dictionary add/remove (kept in ui.json).
- Save: AutoSave. Language: interface language. Accessibility: Read Aloud
  speed and citations. Advanced: typing replaces selection, Insert key
  overtype, overtype (text.overtype), recent documents count, measurement
  units (dialogs, Layout ribbon indents and rulers).

file.options reads and sets the engine-side settings headlessly and only
opens the page when given no setting. ui.json loads leniently: a bad value
falls back to its default instead of dropping every setting.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 19:17:50 -04:00
Valentin Klein 88a45329b2 Right-click on a picture or shape: Wrap Text submenu (#513) (#521)
Right-clicking a picture, shape, text box or chart selects it (as a
click would) and its context menu offers Wrap Text: In Line with Text,
Square, Tight, Through, Top and Bottom, Behind Text, In Front of Text
(current one checked), then Move with Text / Fix Position on Page.

The latter two are a new engine command, arrange.moveWithText, which
re-anchors a floating object's vertical position to its paragraph or
the page without moving it. The menu items live in one shared function
(ribbon::wrap_text_items, WRAP_STYLES) for the ribbon to reuse.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 19:11:31 -04:00
Valentin Klein b0431eadc2 Paragraph dialog: mirror indents, same-style spacing, suppress line numbers, don't hyphenate (#512) (#519)
- Model: `ParaProps::mirror_indents` (`w:mirrorIndents`, ECMA-376 §17.3.1.18), read and
  written in DOCX; resolved `mirror_indents` and `suppress_line_numbers` (so styles count).
- Layout: a paragraph with mirrored indents moves over by its indents' difference on even
  (left-hand) pages, so its left (inside) indent is on the right there.
- Paragraph dialog: Mirror indents (Left/Right become Inside/Outside), Don't add space between
  paragraphs of the same style, and Formatting exceptions (Suppress line numbers, Don't
  hyphenate) on Line and Page Breaks; OK sends only the options that changed, in the same
  `para.set` as the indents and spacing.
- Strings in all nine catalogs; Reveal Formatting names mirrorIndents.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 18:50:28 -04:00
Valentin Klein ee04e2b7f8 Font list: installed Chinese fonts are listed, by their Chinese names in a Chinese interface (#505) (#506)
The font database read only the English family name of each font, so a
Chinese interface listed SimSun, Microsoft YaHei and WenQuanYi Zen Hei
by their English names and never as 宋体, 微软雅黑 or 文泉驿正黑, and a
document naming a font by its Chinese name fell back to another font.

- fonts: keep each face's localized family names (name IDs 16 and 1,
  every face of a collection, read during the scan without loading the
  file); FontDb::families_in(lang) lists them in place of the English
  names for a matching interface language (zh-hans, zh-hant, ja, ko);
  face, has_family and styles accept either name.
- ui: the ribbon and mini toolbar font boxes show the interface
  language's names and store the English name.
- East Asian font: choosing a Chinese, Japanese or Korean font also sets
  it as the East Asian font (w:eastAsia), read and written in DOCX;
  layout draws CJK text in a run's installed East Asian font.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 18:27:53 -04:00
Valentin Klein 91b960ed74 Accessibility: the document canvas exposes its text to screen readers (#488) (#489)
The canvas is now the AccessKit document node: body paragraphs become
paragraph, heading (with level) or list item (with level, grouped into
lists, list numbers as list markers) nodes holding one text run per laid-out
line with character lengths, positions, widths and word starts; tables are
table/row/cell nodes, pictures and charts image nodes named by alt text,
hyperlinks link nodes around their runs. The caret and selection are an
AccessKit text selection on the document node.

Only pages on screen and one either side are described, capped at 6000
nodes; nothing is built unless AccessKit is active, and the tree is rebuilt
only when the layout, selection, described pages or their screen place change.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 18:11:05 -04:00
Valentin Klein 35e22e2cef Fixes: footnotes in table cells, NUMPAGES, bookmark link pages, bookmark around final table, INCLUDEPICTURE results (#450, #451, #455, #464, #465) (#483)
- layout: footnotes referenced in table-cell lines join the page's note area (#450)
- layout: NUMPAGES/SECTIONPAGES in the body use the real totals; the body is laid out
  again with the totals found (at most three extra passes); SECTION in the body counts
  sections (#451)
- pdf: internal link destinations use the page and line of the bookmark's start (#455)
- docx: markers left after a final table go to its last cell's last paragraph (#464)
- docx: a field whose cached result holds a picture is kept as a range field around it,
  so the picture survives save; the linked path is never read (#465)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 18:05:27 -04:00
304 changed files with 77447 additions and 3628 deletions
+13
View File
@@ -0,0 +1,13 @@
# Strip upstream (ArtCraft / Discord) branding. Run: perl tools/craft-skills/skills/debrand/debrand.pl . (--check in CI)
delete docs/brand
# MIT/Apache texts stay verbatim. The copyright holder line stays everywhere (legal requirement).
keep ^(LICENSE-(MIT|APACHE)|\.debrand)$
dropline ^crates/ui-egui/src/i18n/.*\.tsv$ :: (?i)\t(?!MIT OR Apache).*(?:ArtCraft|Discord)
dropline ^ATTRIBUTION\.md$ :: \|\s*`docs/brand/
dropline ^assets/spelling/en-extra\.txt$ :: artcraft
sub \s*The one exception is the ArtCraft brand files in `docs/brand/`: ArtCraft trademarks, not open source, used under `docs/brand/LICENSE-brand\.txt`\. =>
sub (?s)\nThe ArtCraft name, wordmark and logos in docs/brand/.*?LICENSE-brand\.txt\.\n =>
sub https://getartcraft\.com/apps/wordcraft => https://github.com/storytold/wordcraft
forbid (?im)^(?!\tMIT OR Apache-2\.0)(?!.*Copyright \(c\) 2026 ArtCraft Team).*artcraft
forbid (?i)discord
sub the Discord pill, =>
+7 -4
View File
@@ -2,16 +2,19 @@
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
name: FreeBSD
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push
# or dependency change: a drifted dependency still shows up within a day, and always before a
# release ships. PRs that change the FreeBSD packaging itself still run it.
on:
schedule:
- cron: "27 4 * * *"
push:
branches: [main]
branches: [release]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- "crates/fonts/**"
- .github/workflows/freebsd.yml
- packaging/env.sh
workflow_dispatch:
concurrency:
+73
View File
@@ -0,0 +1,73 @@
# Exercise the native print host and all UI tests on PRs; the broad FreeBSD/ARM64 gates
# stay on their existing nightly/release schedule.
name: Printing
on:
pull_request:
paths:
- 'Cargo*'
- 'apps/wordcraft/**'
- 'apps/wordcraft-web/**'
- 'crates/ui-egui/**'
- '.github/workflows/printing.yml'
push:
branches: [main]
paths:
- 'Cargo*'
- 'apps/wordcraft/**'
- 'apps/wordcraft-web/**'
- 'crates/ui-egui/**'
- '.github/workflows/printing.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: printing-${{ github.ref }}
cancel-in-progress: true
jobs:
native:
name: Print (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 90
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: '0'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Linux build and software-rendering dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev \
libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev libvulkan1 mesa-vulkan-drivers
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Formatting
run: cargo fmt --all -- --check
- name: Native app and UI Clippy
run: cargo clippy -p wordcraft -p wordcraft-ui-egui --all-targets --locked -- -D warnings
- name: Native app and UI tests
run: cargo test -p wordcraft -p wordcraft-ui-egui --locked
- name: Browser compatibility
if: runner.os == 'Linux'
run: cargo check -p wordcraft-web --target wasm32-unknown-unknown --locked
+6
View File
@@ -35,3 +35,9 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
+5 -7
View File
@@ -4,19 +4,17 @@
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
name: Windows ARM64
# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a
# day, and always before a release ships), and on PRs that change the Windows packaging itself.
on:
schedule:
- cron: "13 6 * * *"
push:
branches: [main]
branches: [release]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- "**/build.rs"
- .github/workflows/windows-arm64.yml
- packaging/windows/**
schedule:
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
workflow_dispatch:
concurrency:
+3
View File
@@ -0,0 +1,3 @@
[submodule "tools/craft-skills"]
path = tools/craft-skills
url = https://git.marcospaulo.dev.br/storytold/craft-skills.git
+9 -10
View File
@@ -2,7 +2,7 @@
Every non-code asset in this repository (fonts, icons, images, example documents) is listed here with its author, source and licence. `cargo xtask assets` (part of `cargo xtask ci`) fails if an asset file is missing from this table.
**Policy (mandatory):** WordCraft contains **no Microsoft, Adobe, Avid or Autodesk iconography, images, templates, themes, clip art, fonts or other assets**. Every asset is original work by WordCraft contributors or third-party material under an open licence (OSI open source, public domain / CC0, or a Creative Commons licence that allows redistribution). Screenshots of other vendors' software are never committed. New font files go to [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not this repo. The one exception is the ArtCraft brand files in `docs/brand/`: ArtCraft trademarks, not open source, used under `docs/brand/LICENSE-brand.txt`.
**Policy (mandatory):** WordCraft contains **no Microsoft, Adobe, Avid or Autodesk iconography, images, templates, themes, clip art, fonts or other assets**. Every asset is original work by WordCraft contributors or third-party material under an open licence (OSI open source, public domain / CC0, or a Creative Commons licence that allows redistribution). Screenshots of other vendors' software are never committed. New font files go to [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not this repo.
Generated-in-code art is original and has no file to list: the UI icon set (`crates/ui-egui/src/icons.rs`), the sample documents and templates (`crates/engine/src/sample.rs`), the table and style previews, and theme colours (`crates/engine/src/cmd/design.rs`).
@@ -11,6 +11,8 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `assets/hyphenation/en-us.pat` | DesignCraft contributors (generated) | trained from the Moby list by DesignCraft's patgen | Public Domain (CC0-1.0) | Our own Liang hyphenation patterns (no TeX patterns) |
| `assets/hyphenation/en-us.dic` | Grady Ward (Moby Hyphenator II word list); converted by DesignCraft contributors | https://www.gutenberg.org/ebooks/3204 (file `mhyph.txt`, SHA-256 `eeb30474c86b8af3469035ec1a0913e35905325ca885290db9dfed9881e230ac`), via DesignCraft | Public Domain | ~165k words with break points; drives spelling and hyphenation |
| `assets/spelling/en-extra.txt` | WordCraft contributors | original word list written for this repository (common modern words the Moby list lacks); no other dictionary copied | MIT OR Apache-2.0 | ~220 lowercase English base forms supplementing spelling (#520) |
| `assets/hyphenation/pl.pat` | Hanna Kołodziejska, Bogusław Jackowski, Marek Ryćko (Polish TeX hyphenation patterns, 1987–1995); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pl.tex`, SHA-256 `83ddbbd21bc06a44bc4f4a4c49c38782e809cff5f026b764c044daa29e818843`), bundled unmodified | MIT (the file is offered under MIT or Knuth's public-domain terms; WordCraft uses MIT, whose notice is in the file's header) | 4,053 Liang patterns and 20 exception words; Polish hyphenation |
| `assets/hyphenation/pt.pat` | Pedro J. de Rezende, J. Joao Dias Almeida, Leonardo Araujo, Aline Benevides (Portuguese TeX hyphenation patterns, 1987–2024); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pt.tex`, SHA-256 `c110a39e501db372d1d755e1c64464321a2efa7d8e9617f66001000389b941f2`), bundled unmodified | BSD 3-clause (the file's licence header names it and carries the notice) | 426 Liang patterns and 2 exception words; Portuguese hyphenation |
| `assets/app-icon/wordcraft-1024.png` | WordCraft contributors | original artwork drawn in SVG (`assets/app-icon/wordcraft.svg`), rendered by `packaging/icons.sh` | MIT OR Apache-2.0 | Placeholder app icon until the WordCraft mascot drawing lands |
| `packaging/macos/dmg/background.svg` | @XusBadia | original work for this repository, derived from the WordCraft app icon (`assets/app-icon/wordcraft.svg`, referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0, like the code ([`LICENSE-MIT`](LICENSE-MIT), [`LICENSE-APACHE`](LICENSE-APACHE)) | WordCraft macOS DMG window background (2026-10-08); used for the Finder window of the macOS DMG, rendered to `background.tiff` by `packaging/macos/dmg/generate.py` |
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0, like the code ([`LICENSE-MIT`](LICENSE-MIT), [`LICENSE-APACHE`](LICENSE-APACHE)) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
@@ -51,18 +53,15 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `docs/images/dark.png` | WordCraft contributors | screenshot of WordCraft itself (sample document written for WordCraft, rendered by `ui_shot`) | MIT OR Apache-2.0 | Original; no Microsoft UI or assets |
| `docs/images/backstage.png` | WordCraft contributors | screenshot of WordCraft itself (sample document written for WordCraft, rendered by `ui_shot`) | MIT OR Apache-2.0 | Original; no Microsoft UI or assets |
| `docs/images/layout.png` | WordCraft contributors | screenshot of WordCraft itself (sample document written for WordCraft, rendered by `ui_shot`) | MIT OR Apache-2.0 | Original; no Microsoft UI or assets |
| `docs/brand/LICENSE-brand.txt` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| craft-fonts (optional build input, not files in this repo): BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic | see https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | embedded only when built with `CRAFT_FONTS_DIR` (all official releases) | OFL-1.1 | Japanese and Chinese UI, Japanese document fallback |
| `crates/ui-egui/src/i18n/uk.tsv` | @dmatviichuk with Codex | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Ukrainian interface catalog; spelling checked with external VESUM and ULIF dictionaries, neither bundled |
| `crates/ui-egui/src/i18n/et.tsv` | @henno with Codex | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Estonian interface catalog |
| `crates/ui-egui/src/i18n/pl.tsv` | Konrad Witkowski with Claude (AI agent) | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Polish interface catalog; not yet reviewed by a second native speaker |
| `crates/ui-egui/src/i18n/pt-br.tsv` | @edurbs with ZCode | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Brazilian Portuguese interface catalog; spelling checked with the external pt_BR Hunspell dictionary, not bundled |
| `crates/ui-egui/src/i18n/cs.tsv` | @DanielKnourek with Antigravity for VS Code | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Czech interface catalog; review suggestions by @Losminek |
| `crates/ui-egui/src/i18n/de.tsv` | @arekown with Kimi | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | German interface catalog |
| `crates/ui-egui/src/i18n/ru.tsv` | Dmitrii Khlapov with Claude | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Russian interface catalog; reviewed by a native speaker |
| `crates/ui-egui/src/i18n/tr.tsv` | @PbyLoyth with Codex and Claude Code | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Turkish interface catalog |
| `crates/ui-egui/src/i18n/bg.tsv` | Dimitar Klaturov (@bulgariamitko) with Claude Code | Original translation from the English interface labels in this repository; no other product localisation or other catalog used | MIT OR Apache-2.0 | Bulgarian interface catalog |
| `crates/ui-egui/src/i18n/hu.tsv` | @Skaywalker | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Hungarian interface catalog |
| `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | French interface catalog |
Generated
+1530 -50
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -4,13 +4,14 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
[workspace.package]
version = "0.5.0"
version = "0.6.2"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
repository = "https://github.com/storytold/wordcraft"
[workspace.dependencies]
craft-ui = { git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "7fcee66b8eef8eac3c7644fd024e1f1b051f5a3e", features = ["serde"] }
# internal
wordcraft-geom = { path = "crates/geom" }
wordcraft-doc = { path = "crates/doc" }
@@ -40,17 +41,23 @@ rayon = "1"
unicode-bidi = "0.3"
unicode-segmentation = "1"
unicode-linebreak = "0.1"
unicode-script = "0.5"
regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] }
regex-automata = { version = "0.4", default-features = false, features = ["std", "syntax", "meta", "nfa-pikevm", "unicode"] }
# text / rendering / formats
skrifa = "0.47"
harfrust = "0.13"
# WOFF / WOFF2 web fonts added by the user (#582): pure Rust (MIT); its brotli and zlib decoders
# (brotli-decompressor, flate2) are already in the tree.
wuff = "0.2.9"
vello_cpu = "0.2"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
krilla = { version = "0.8", features = ["pdf"] }
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates.
miniz_oxide = "0.8"
cfb = "0.15"
# Password-protected (agile-encrypted) Word documents: pure-Rust RustCrypto primitives.
aes = "0.9"
@@ -67,7 +74,7 @@ egui_extras = { version = "0.36", default-features = false }
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
arboard = { version = "3.6", default-features = false, features = ["wayland-data-control"] }
[workspace.lints.rust]
unsafe_code = "forbid"
+29 -4
View File
@@ -3,10 +3,6 @@ Copyright (c) 2026 ArtCraft Team and the WordCraft contributors.
Licensed under the MIT License or the Apache License, Version 2.0, at your option
(LICENSE-MIT, LICENSE-APACHE).
The ArtCraft name, wordmark and logos in docs/brand/ are trademarks of the ArtCraft Team. They are
not open source and are not covered by the license above; their terms are in
docs/brand/LICENSE-brand.txt.
Third-party material
This product bundles third-party material under its own licenses, listed below. The per-file
@@ -23,6 +19,35 @@ list (author, source, license) is in ATTRIBUTION.md.
- English word list (assets/hyphenation/en-us.dic) — derived from the Moby Hyphenator II list by
Grady Ward, public domain (Project Gutenberg eBook #3204). The hyphenation patterns
(assets/hyphenation/en-us.pat) were trained from it and are released as CC0-1.0.
- Polish hyphenation patterns (assets/hyphenation/pl.pat) — hyph-pl.tex from the hyph-utf8
package, Copyright (C) 1987-1995 Hanna Kołodziejska, Bogusław Jackowski, Marek Ryćko, bundled
unmodified and used under the MIT License; the copyright and permission notice is in the file.
- Portuguese hyphenation patterns (assets/hyphenation/pt.pat) — hyph-pt.tex from the hyph-utf8
package, bundled unmodified and used under the BSD 3-Clause License:
Copyright (C) 1987, 1994, 1996, 2015 Pedro J. de Rezende, 1996, 2015 J. Joao Dias Almeida,
2024 Leonardo Araujo and Aline Benevides
Redistribution and use in source and binary forms, with or without modification, are
permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this list of
conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice, this list of
conditions and the following disclaimer in the documentation and/or other materials
provided with the distribution.
* Neither the name of the University of Campinas, of the University of Minho nor the names
of its contributors may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS
OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL PEDRO
J. DE REZENDE OR J.JOAO DIAS ALMEIDA BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE
GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
OF THE POSSIBILITY OF SUCH DAMAGE.
- All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0.
Rust dependencies (egui, eframe, vello_cpu, skrifa, harfrust, krilla, quick-xml, zip, image, …)
+47 -73
View File
@@ -1,12 +1,3 @@
<p align="center">
<a href="https://getartcraft.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/brand/artcraft-logo-white.svg">
<img alt="ArtCraft" src="docs/brand/artcraft-logo.svg" width="200">
</picture>
</a>
</p>
<h1 align="center">WordCraft</h1>
<p align="center">
@@ -16,8 +7,7 @@
<p align="center">
A fast, open-source word processor with the Word workflow you already know: the ribbon, styles,
tables, track changes, references and mail merge. It reads and writes .docx, runs natively on
macOS, Windows, Linux and BSD, and in the browser via WebAssembly.<br>
<i>By the ArtCraft team.</i>
macOS, Windows, Linux and BSD, and in the browser via WebAssembly.
</p>
<p align="center">
@@ -27,16 +17,6 @@
<img alt="Agent-drivable over MCP" src="https://img.shields.io/badge/agents-MCP%20%C2%B7%20CLI-3b5bdb?style=flat-square">
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<p align="center">
<a href="https://getartcraft.com/apps/wordcraft"><b>WordCraft on getartcraft.com</b></a> ·
<a href="https://getartcraft.com/">ArtCraft</a> ·
<a href="https://getartcraft.com/apps">All Crafting Apps</a>
</p>
<br>
<p align="center">
@@ -44,11 +24,6 @@
<br><sub><b>The Open Studio Handbook</b>, WordCraft's built-in sample: the ribbon, live Styles gallery, rulers and the Navigation pane.</sub>
</p>
> [!NOTE]
> **ArtCraft is a community of artists from all walks of life.** Painters, photographers,
> filmmakers, illustrators, designers, animators, hobbyists, and people who picked up a pencil
> last week. If you make things, you're one of us. **[Come say hi on Discord](https://discord.gg/artcraft).**
<p align="center">
<a href="#a-tour">A tour</a> ·
<a href="#why-wordcraft">Why WordCraft</a> ·
@@ -115,9 +90,9 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes
| **Pages** | Margins, orientation, size, columns, page/column/section breaks, headers and footers (first page, odd/even), page numbers, watermark, page borders, line numbers, vertical alignment, drop caps, automatic hyphenation |
| **Objects** | Pictures (resize, crop, recolour, brightness/contrast, transparency, background removal, picture styles, rotate), shapes, text boxes, floating position with text wrapping (square, top and bottom, behind or in front of text) |
| **References** | Table of contents, footnotes and endnotes, citations and bibliography (APA, MLA, Chicago, IEEE), captions, table of figures, cross-references, index, table of authorities |
| **Review** | Spelling and grammar with suggestions, thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector |
| **Review** | Spelling and grammar with suggestions (English and Polish, by each run's language), thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector |
| **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels |
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe or Русский (follows the system language by default) |
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français or Български (follows the system language by default) |
| **Files** | .docx read/write (opens in Word), Word 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images |
The honest picture, area by area, is in [ROADMAP.md](ROADMAP.md), the
@@ -151,6 +126,7 @@ WordCraft was designed to be driven by people *and* by AI agents.
```sh
claude mcp add wordcraft -- wordcraft-cli mcp # headless documents
# confine the agent to folders: wordcraft-cli mcp --automation-read-root DIR --automation-write-root DIR
wordcraft --control 7981 & # or drive the running app…
claude mcp add wordcraft-app -- wordcraft-cli mcp --connect 127.0.0.1:7981
```
@@ -234,6 +210,17 @@ Remove-Item Env:WGPU_BACKEND # restore the default for later
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
and web backend defaults are unchanged.
The **32-bit** build uses OpenGL even when a DirectX 12 graphics card is present. On 32-bit
Windows, creating a DirectX 12 graphics pipeline fails immediately and the app exits before the
window appears. `WGPU_BACKEND=vulkan` (or `dx12`) still overrides that.
**If the app quits right after its window appears with "Parent device is lost" in the log:**
WordCraft compiles DirectX 12 shaders with FXC, the compiler built into Windows, so it needs no
`dxcompiler.dll`/`dxil.dll` and ignores any copy another program put on `PATH` (#603).
`WGPU_DX12_COMPILER` (`fxc`, `dxc`, `auto`) overrides this; with `dxc`, a matching
`dxcompiler.dll` *and* `dxil.dll` must sit next to `wordcraft.exe`. `WGPU_BACKEND = "vulkan"`
also avoids DirectX 12 altogether.
**If the app is slow on an older Windows PC:** when DirectX 12 finds no graphics card with a
driver for it (for example Intel 4th-generation graphics on Windows 11), it only offers Microsoft's
software renderer, which draws every frame on the processor. In that case WordCraft uses OpenGL
@@ -260,6 +247,23 @@ instead, without setting anything. The log file records the choice (lines starti
RISC-V (riscv64): `wordcraft-<ver>-linux-riscv64.tar.gz`, a tarball cross-compiled for glibc 2.39+
(Ubuntu 24.04, Debian 13 and newer).
**Wayland:** on a Wayland desktop the app runs through XWayland when it is available, because
winit (the windowing library) supports dragging files into the window only on X11. To use the
native Wayland backend instead (for example for sharper text with fractional scaling, at the cost
of file drag-and-drop), start it with `WORDCRAFT_WAYLAND=1`:
```sh
WORDCRAFT_WAYLAND=1 wordcraft
```
**Arch Linux (AUR).** Community members maintain three AUR packages; the project doesn't publish
to the AUR itself. Install one of them:
| Package | What it installs | Choose it when |
|---|---|---|
| [`wordcraft-bin`](https://aur.archlinux.org/packages/wordcraft-bin) | The release tarball above, prebuilt (x86_64, aarch64) | You want the latest release without compiling (quickest) |
| [`wordcraft`](https://aur.archlinux.org/packages/wordcraft) | The latest release, built from source with `cargo` | You prefer to build releases yourself |
| [`wordcraft-git`](https://aur.archlinux.org/packages/wordcraft-git) | The current `main` branch, built from source | You want unreleased changes and accept an occasional broken build |
For example `yay -S wordcraft-bin` or `paru -S wordcraft-bin`.
### FreeBSD
| Build | File |
@@ -275,44 +279,22 @@ RISC-V (riscv64): `wordcraft-<ver>-linux-riscv64.tar.gz`, a tarball cross-compil
## The Crafting Apps
WordCraft is one of the **Crafting Apps**: free, open-source creative tools from the
[ArtCraft](https://getartcraft.com/) team, each written from scratch in Rust and each able to
[storytold](https://github.com/storytold) team, each written from scratch in Rust and each able to
stand on its own.
| | App | What it's for | Code | Learn more |
|:-:|---|---|---|---|
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) |
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | **Writing and document design · you are here** | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
| | App | What it's for | Code |
|:-:|---|---|---|
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) |
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) |
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | **Writing and document design · you are here** | [GitHub](https://github.com/storytold/wordcraft) |
<br>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<h3 align="center">Come make things with us</h3>
<p align="center">
Our Discord is where artists of every kind hang out: people who paint, shoot, draw, cut film,
set type, and people still figuring out what they like to make. Share what you're working on,
ask for help, tell us what's broken, or tell us what you wish these tools could do.
Whatever your medium and however long you've been at it, you're welcome here.
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><b>discord.gg/artcraft</b></a> ·
<a href="https://getartcraft.com/">getartcraft.com</a> ·
<a href="https://getartcraft.com/apps">The Crafting Apps</a> ·
<a href="https://getartcraft.com/apps/wordcraft">WordCraft</a>
</p>
## License and credits
WordCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
@@ -321,17 +303,9 @@ Copyright (c) 2026 ArtCraft Team and the WordCraft contributors. Required notice
Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed
with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md).
The spelling dictionary and hyphenation come from Grady Ward's public-domain Moby Hyphenator II
word list. The sample documents and templates are original text written for WordCraft.
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
part of this repository and WordCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
The English spelling dictionary and hyphenation come from Grady Ward's public-domain Moby
Hyphenator II word list. Polish spelling reads the Hunspell `pl_PL` dictionary installed on the system (not bundled) and
Polish hyphenation the TeX patterns by Hanna Kołodziejska, Bogusław Jackowski and Marek Ryćko (MIT
option). The sample documents and templates are original text written for WordCraft.
<sub>Microsoft and Microsoft Word are trademarks of the Microsoft group of companies. Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. WordCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Microsoft Corporation or Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
</p>
+165 -9
View File
@@ -2,7 +2,9 @@
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Turkish interface landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (progress log: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and
agent control. This page is the summary; the assessment is
@@ -14,7 +16,7 @@ agent control. This page is the summary; the assessment is
| Number | Value | Kind |
|---|---|---|
| Ribbon/menu catalog coverage | **458 / 475 (96.4%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
| Ribbon/menu catalog coverage | **487 / 502 (97.0%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
| **Feature breadth** (weighted, incl. dialog options, styles, languages beyond the ribbon) | **~80%** | estimated |
| **Ready for real work** (full target) | **~60%** (55–63%) | estimated, additive weighted sum over the dimensions |
| **Mainstream practitioner** | **~55%** | estimated: weekly-work depth × discounts for interaction (×0.93), stability (×0.90), file exchange (×0.90) |
@@ -58,7 +60,7 @@ the two partial ones (real-world .docx, printing) complete, roughly.
| File formats | 60% | 185–300 | [file-format-parity.md](docs/file-format-parity.md) |
| Layout and pagination fidelity | 55% | 90–145 | [layout-parity.md](docs/layout-parity.md) |
| Hardware | 40% | 20–40 | [hardware-parity.md](docs/hardware-parity.md) |
| Localization | 25% | 80–130 + native review | [localization-parity.md](docs/localization-parity.md) |
| Localization | 27% | 75–120 + native review | [localization-parity.md](docs/localization-parity.md) |
| Performance | 75% | 10–20 | [layout-parity.md](docs/layout-parity.md#performance) |
| Stability | 50% | 25–45 | [gaps.md](docs/gaps.md) (#3) |
| Platforms | 95% (beyond Word on Linux, BSD, web) | 5–10 | this page |
@@ -85,7 +87,7 @@ the two partial ones (real-world .docx, printing) complete, roughly.
| Pictures, shapes, text boxes, WordArt | 60% | 20–30 |
| Backstage, printing, options | 58% | 12–20 |
| Right-to-left and complex scripts | 50% | 15–25 |
| Proofing (spelling, grammar, thesaurus, languages) | 35% | 25–45 |
| Proofing (spelling, grammar, thesaurus, languages) | 40% | 20–40 |
| Accessibility | 35% | 15–25 |
| East Asian typography | 25% | 15–25 |
| Charts, SmartArt, Draw/ink, 3D models, icons | 3% | 70–110 |
@@ -102,16 +104,18 @@ Weights and evidence per area: [docs/target-app-parity.md](docs/target-app-parit
| Spanish | es | partial | ~95% |
| Hindi | hi | none | 0% |
| Arabic | ar | none (document shaping and bidi work; no mirrored UI) | 0% |
| French | fr | none | 0% |
| French | fr | partial (interface) | ~95% |
| Portuguese | pt-br (pt-PT none) | partial | ~95% |
| Indonesian | id | none | 0% |
| Japanese | ja | partial | ~95% |
| German | de | none (PR #203 open) | 0% |
| German | de | partial (#574) | ~95% |
| Korean | ko | none | 0% |
| Vietnamese | vi | none | 0% |
Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`) and Serbian (`sr`, `sr-latn`, #250) and Russian (`ru`), all partial (~95%). Estonian (`et`) and Turkish (`tr`) now have complete 1,307-entry interface catalogs with
system-locale selection and a saved language choice; Estonian and Turkish proofing are not included. Word ships
Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian (`sr`, `sr-latn`, #250), Russian (`ru`) and Polish (`pl`), all partial (~95%). Bulgarian (`bg`) covers everything the interface audit finds (2,419 entries). Estonian (`et`), Italian (`it`, #594) and Czech (`cs`) now have complete interface catalogs, and Turkish (`tr`) a complete 2,325-entry one (every key of the Russian catalog), all with
system-locale selection and a saved language choice (Italian also has 51 strings none of the others had yet); Estonian, Turkish, Italian and Czech proofing are not included. Hungarian (`hu`) has a
complete 2,419-entry catalog, also without proofing.
Proofing: English, Polish and Portuguese (spelling, grammar, hyphenation), chosen per run from its language. Word ships
30 interface languages and proofing for ~50. Detail: [docs/localization-parity.md](docs/localization-parity.md).
## Upcoming
@@ -131,35 +135,134 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| Date | What landed |
|---|---|
| 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word |
| 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them |
| 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started |
| 2026-10-11 | More of the table mouse: arrows above a column, left of a row and along a cell's left edge select it (a drag selects more; a selected column is edited cell by cell, numbering it makes one list), "+" buttons beside the borders insert a row or column, the move handle past the top-left corner selects the table or drags it to float where it's dropped, and a double-click on a column border fits the column; Select Column in the ribbon; `table.selectAt`, `table.insertAt`, `table.moveDrag`, `table.fitColumn` and `table.selectColumn` for agents |
| 2026-10-12 | AutoCorrect keys its built-in English replacement list by language (#835): Polish, Croatian, Bosnian and Serbian `i` ("and") is no longer turned into `I`; English and untagged text unchanged |
| 2026-10-12 | Ribbon groups fit above their labels: View › Show splits its four check boxes into two columns (Hide White Space, #645, had pushed the fourth onto the "Show" label), Developer › Controls loses the gaps that ran into its label, and Layout › Paragraph (#654) no longer trips a debug assertion and sits 2.5 pt higher; a test draws every tab in every language and fails on any group taller than the ribbon |
| 2026-10-12 | Window caption: a non-Latin file name is drawn with a document fallback face (or the CJK UI font), instead of `?` |
| 2026-10-12 | 32-bit Windows starts on OpenGL. DirectX 12 graphics pipeline creation returns "The parameter is incorrect" and the app exited before the window appeared |
| 2026-10-12 | New documents follow the system region (#838): A4 and centimetres outside the US (Letter in the CLDR Letter territories, inches in the US), lengths written with the region's decimal separator (`2,54 cm`) and typed with either; an explicit Measurement units choice wins; `file.options {"region"}` for agents |
| 2026-10-12 | Paste from other programs keeps their formatting (#687): Ctrl+V and Home › Paste read the system clipboard's HTML (and RTF on Windows and Wayland); Merge Formatting takes the destination's font and paragraph and keeps bold, italic, underline and strikethrough; Paste Special lists what the system clipboard holds (HTML, RTF, picture, text). Linux: pictures copied in a browser or Spectacle paste on KDE and other Wayland desktops (data-control clipboard), and a paste that finds nothing usable names what the clipboard holds instead of "empty" (#833) |
| 2026-10-12 | Undo history list (#701): a ▾ beside the Quick Access Toolbar's Undo lists recent steps newest first; hovering highlights what a click undoes ("Undo 3 Actions"), and a click undoes them in one go. `edit.undoTo {count}` and `edit.undoHistory` (newest first, up to 100) for CLI and MCP |
| 2026-10-12 | Start screen (#766): launched with nothing to open, WordCraft starts on File › Home (Blank document, templates, Recent) like Word, desktop and web; Esc leaves a blank document; File › Options › General › Start-up turns it off |
| 2026-10-12 | Word 365 cover pages (#743): a freeform inside a group keeps its shape (it was drawn as a rectangle); text boxes keep their own internal margins and Resize shape to fit text, so a tight title box no longer loses its second line; both survive saving. The missing fills in the report were theme-coloured shapes, fixed by #611 |
| 2026-10-12 | Alt+Q moves to "Tell me" command search, as in Word for Windows (#819); not on macOS, where Option+Q types a character |
| 2026-10-12 | PDF export of long documents with many internal links (a linked table of contents) is linear: NIH's 67-page protocol template 10.6 s → 0.9 s |
| 2026-10-12 | Keytips: Word for Windows' tab letters (File F, Design G, References S, Review R, View W, Help Y) |
| 2026-10-12 | Table-of-contents links look like Word's: plain text, not blue and underlined (Word ignores the Hyperlink style on TOC entry links; measured in 9 cases) |
| 2026-10-12 | Line heights like Word's: spaces and tabs never make a line taller (13 cases measured in Word for Windows); tables of contents with big tab runs were a third taller |
| 2026-10-12 | `.doc` import: tab stops (centred titles, right-aligned dates, leaders) and sections ending at their section marks (continuous breaks, footers); EPA .doc templates now paginate like Word |
| 2026-10-12 | DOCX headings numbered like Word when a heading style gives only a list level (6.4.5, not 6.15) |
| 2026-10-12 | A list label makes its line taller above the baseline only, as in Word (6 cases measured in Word for Windows): Symbol bullets no longer add their deeper descent, so bulleted lists keep Word's height and page breaks |
| 2026-10-12 | Hidden end-of-cell marks (`w:hideMark`) read, written and laid out like Word (9 cases measured in Word for Windows): a cell's empty last paragraph takes no room, so rows of such empty cells shrink to their borders |
| 2026-10-12 | Text frames (`w:framePr`) in headers, footers, cells and text boxes: placed apart with text wrapping around them, and kept on save (a framed footer page number took a line of its own and was lost on save) |
| 2026-10-12 | List indents like Word's when a numbered style has indents of its own: the style giving the list (and styles based on it) beat the level's indents, and a paragraph with its list switched off drops the styles' indents (27 cases measured in Word for Windows); NIH's Heading 3s now sit at their 0.9" |
| 2026-10-12 | Keep with next like Word (20 cases measured in Word for Windows): a kept paragraph moves to the next page only when it would fit on this one by itself, otherwise it breaks as usual; chains of kept paragraphs move together |
| 2026-10-12 | An empty paragraph that only carries a continuous section break takes no room, as in Word (10 cases measured in Word for Windows): text after it follows right on and balanced columns don't count it |
| 2026-10-12 | Floating objects positioned relative to their paragraph start above its space before, as in Word (10 cases measured in Word for Windows) |
| 2026-10-12 | Unequal columns: text after a column break is broken for the column it lands in (NIH's SARP form had a paragraph a word or two wide) |
| 2026-10-12 | Space before at the top of a page like Word after page breaks (32 cases measured): a paragraph ending with a page break keeps its mark with it, the next paragraph gets no space before; page break before drops it in Word 2013 mode |
| 2026-10-12 | Automatic paragraph spacing drops between list items, as in Word (16 cases measured in Word for Windows): bulleted items that ask for automatic space before and after now touch, as in NIH's monitoring plan |
| 2026-10-12 | Rendering like Word for Windows, found by comparing real .gov documents page by page: text is kerned only where Kerning for fonts asks for it (line breaks now match Word's), and automatic paragraph spacing (`w:beforeAutospacing`/`w:afterAutospacing`, common in pasted web text) is 14 pt, dropped at the document's and cells' edges |
| 2026-10-12 | Keyboard context menu (#821): Shift+F10 opens the page's right-click menu below the caret; `ui.contextMenu` |
| 2026-10-12 | The Font list no longer keeps every family it previews in memory (#848): the Font box drew each family's name in its own face by loading the whole family for good, so every trip through the list kept tens of megabytes (1.47 GB after 40 changes on macOS); entries are now drawn from a face read for the preview alone and freed (120 families previewed: +17 MB RSS instead of +411 MB); only fonts actually used load |
| 2026-10-12 | Scrolling a document with one very large table stays smooth (#811): pages are fingerprinted for the texture cache once per layout and only when shown (not every page every frame), the markup-area check is cached per revision, and textures far from the view are dropped. The issue's 1,096-page table: ~600 ms → ~2.4 ms idle frames, ~590 ms → ~4.5 ms scroll frames |
| 2026-10-12 | Search (#819): Alt+Q, or Alt then Q, opens the Search box from anywhere with nothing typed into it; queries match every word; `tools.search` for agents |
| 2026-10-12 | Section breaks (#852): Layout › Breaks with text selected puts the break before the selection and keeps the text, as Word does; it used to delete it, leaving the first section empty |
| 2026-10-12 | Find, Replace and Go To like Word (#712): Ctrl+F types into the Navigation pane's search, one tabbed Find and Replace dialog, Go To by page (absolute or relative) and bookmark; `edit.findReplace`, `edit.goto` `pageBy` |
| 2026-10-12 | Symbol fonts (#776): text in Wingdings, Wingdings 2, Wingdings 3, Webdings and Symbol (proprietary, never bundled) shows its intended symbols — full code tables from Unicode's published correspondence, both storage forms (`w:sym` private-use codes and plain codes); PDF text, copy and TXT export use the Unicode characters; the saved text is unchanged |
| 2026-10-11 | Hide White Space (#645): `view.hideWhiteSpace` (View › Show checkbox, or double-click the gap between pages) collapses margins, headers and footers between pages to a thin line in Print Layout; screen only, page count unchanged |
| 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) |
|---|---|
| 2026-10-12 | Track Changes: Accept All after a tracked deletion from the end of one paragraph into a later one no longer joins the paragraph that follows the range, and Reject All after typing into an empty paragraph keeps that paragraph (#864) |
| 2026-10-11 | Table border drags like Word's (#711): Ctrl shares a column border's change among the columns right of it (the table keeps its width), the table's left edge moves a left-aligned table's indent (a centred or right-aligned table keeps its alignment, as in Word), a border of cells selected across moves only in their rows, and Alt shows the column widths on the ruler |
| 2026-10-11 | Tables on the rulers (#700): with the caret in a table the horizontal ruler marks its row's column borders and the vertical ruler its rows' bottoms; dragging a marker resizes the column (the columns right of it move along; with Shift the neighbour makes up the difference) or the row, one undo step, Escape cancels; in a cell the indents and tabs are measured from the cell |
| 2026-10-12 | Save Changes prompt answers from the keyboard: Save has focus when it opens; arrow keys and Tab move between Save, Don't Save and Cancel; Enter answers (#861) |
| 2026-10-12 | Builds with craft-fonts draw simplified-Chinese characters in documents from the bundled Noto Sans CJK SC (it was only used by the interface), so a CJK run gets one face covering it; fixes the FreeBSD job's `east_asian_run_uses_one_face_covering_the_whole_run` failure after #567 |
| 2026-10-12 | Insert › Picture, drag-and-drop and paste take Windows Metafiles (`.wmf`) and Enhanced Metafiles (`.emf`) at their own frame size (#802) |
| 2026-10-12 | AutoCorrect capitals (#544): Correct TWo INitial CApitals (with exceptions), accidental cAPS LOCK, first letter of table cells; each undoable and switchable |
| 2026-10-11 | Turkish (`tr`) interface completed (#774): 2,325 entries, every key of the Russian catalog (equation gallery, built-in style names, watermarks, paper sizes, preview tiles); the interface audit checks Turkish too |
| 2026-10-12 | Navigation headings truncate within the resizable pane, with an ellipsis for long titles and full text on hover (#759) |
| 2026-10-11 | Reading Mode: existing View ribbon button now opens a read-only, viewport-column layout with animated horizontal paging, transient display controls, heading navigation (Navigation pane) and resize anchoring; document layout and export unchanged |
| 2026-10-11 | DOCX open/save keeps inherited complex-script size, bold and italic independent of Latin formatting; explicit values are retained, and font commands still format both scripts. Covered by synthetic round trips and a local GUI save/reopen check. |
| 2026-10-11 | Czech (`cs`) interface catalog: 2,021 entries (the full zh-hans key set), Czech locale detection and persisted language choice |
| 2026-10-12 | Portuguese proofing: pt-BR spelling against the installed Hunspell `pt_BR` dictionary (VERO; read at runtime, not bundled) — the affix engine grew `FLAG UTF-8`, 128 flag bits, continuation classes and `FORBIDDENWORD` to read it — suggestions tuned for Portuguese (missing accents first), conservative grammar rules with Portuguese messages, TeX Portuguese hyphenation patterns (BSD 3-clause, attributed); Review › Language offers Português (Brasil); a pt-br interface writes new documents in pt-BR |
| 2026-10-11 | Polish: interface (`pl`, the thirteenth language, every catalog key) and proofing — spelling against the installed Hunspell `pl_PL` dictionary (read at runtime, not bundled) through a new Hunspell-style affix engine, suggestions tuned for Polish, Polish grammar rules with Polish messages, TeX Polish hyphenation patterns (MIT option); proofing language per run (`w:lang`) for squiggles, F7, suggestions and hyphenation, languages without tools left unmarked; Review › Language menu and the status bar's language; a Polish interface writes new documents in Polish |
| 2026-10-11 | German (`de`) interface (#574): 2,021 translated entries covering the full zh-hans key set, locale and saved-preference tests, bundled-font checks; no German proofing resources |
| 2026-10-12 | Sorting and table numbers follow the text's language (#836, #837): Table › Sort, Home › Sort, the index (with its letter headings), the bibliography and the Table of Authorities use the language's alphabet (Polish `Łódź` between `Kraków` and `Zakopane`, Swedish `å ä ö` after `z`, German `ä` with `a`); table sort and formulas read `1,5` and `1 250,00` as Polish decimals and `=SUM` writes `5,75` |
| 2026-10-11 | `.doc` import keeps pictures and drawings (#551): inline and floating pictures (PNG, JPEG, DIB, EMF, WMF), shapes, lines, groups and text boxes with position and wrap, from [MS-ODRAW] |
| 2026-10-11 | Tables resize with the mouse (#217): drag a column border (the neighbour makes up the difference; Shift moves one column), a row's bottom border, or the handle past the bottom-right corner to scale the table; a dashed guide while dragging, one undo step per drag; `table.resizeDrag` for agents |
| 2026-10-11 | Animation effects setting: File › Options › Accessibility › Animation effects (Use system setting, On, Off). With the system's animations off (Windows' Animation effects, macOS' Reduce motion, the Linux/BSD desktop's setting, a browser's reduced motion) the page no longer smooth-scrolls: wheel notches and Ctrl+wheel zoom land at once, in the document and in every list and pane, and menus and buttons don't fade; `ui.animations` for agents |
| 2026-10-11 | Linux/BSD: Open, Save As, Export and Insert Picture work without a file chooser portal or zenity (labwc, sway…): WordCraft shows its own file browser and says how to get the system dialog; File › Open › Browse in WordCraft… is always there (#576) |
| 2026-10-11 | Every measurement follows File › Options › Advanced › Measurement units (#755): the Line Numbers "From text" distance and the Hyphenation zone, still fixed in inches after #485, now convert like every other dialog |
| 2026-10-11 | Review › Track Changes Options › Show revisions in balloons: tracked deletions in the body leave the text for "Deleted: …" balloons in the margin, joined to where they were (the option was saved but drew nothing new before) |
| 2026-10-11 | Security review fixes: `ui.render`/`ui.screenshot` without a `path` (and the MCP bridge's `render_page`/`screenshot`) return the PNG in the reply instead of writing a fixed name into the shared temporary folder; exported HTML/Markdown write `href` only for web, mail, bookmark and relative links, never `javascript:` and other schemes (one allow-list in `wordcraft_doc::link`) |
| 2026-10-11 | Design tab: the style set gallery gets a box and gaps of its own, so it no longer runs into Colors and Fonts (#638) |
| 2026-10-11 | File › Print: page preview, page range (all, current, selected, custom 1-3,5), odd/even, copies and collate go into the printed PDF (`file.printPdf`, `ui.print` params); printer, duplex and pages per sheet are still the system dialog's (#641, stage 1) |
| 2026-10-11 | A vertically merged table cell (eraser on a horizontal border) lays out, shows and clicks as one cell spanning its rows (#640) |
| 2026-10-11 | Word XML files (#548): Word XML Document (Flat OPC `.xml`) opens and saves through the DOCX reader and writer; Word 2003 XML opens (styles, lists, tables, sections, notes, comments, pictures, hyperlinks) |
| 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available |
| 2026-10-11 | Font fallback indexes which characters each installed font maps, reading each font file once (#605): text in a script no installed font covers no longer re-reads every system font per distinct character (200 such characters: 13 s → 0.2 s) |
| 2026-10-11 | Find and Replace special characters (`^p`, `^t`, `^#`, `^&` …) and Use wildcards (ranges, counts, word anchors, groups with `\1` in Replace); `^p` finds and splits paragraphs; Special ▾ menu and search options in the dialog (#543) |
| 2026-10-11 | Typing parity (#546): `+---+` Enter makes a table, `> ` `-> ` `=> ` arrow bullets, smileys inside words (`ok:)`), Undo right after a border line brings the characters back |
| 2026-10-11 | Windows: DirectX 12 shaders compile with FXC, built into Windows, so the app no longer quits at launch with "Parent device is lost" when no matching `dxcompiler.dll` + `dxil.dll` pair is around (#603) |
| 2026-10-11 | Insert › SmartArt (#497): list, process, cycle, hierarchy, pyramid, radial, matrix and Venn graphics laid out by our own code, Text Pane, SmartArt Design tab (Add Shape, Promote, Demote, layouts, colours, Reset); saved as DOCX diagram parts with the drawing. Catalog 410/441 (93.0%) |
| 2026-10-11 | Outline view (#493): Outlining tab, outline symbols and indents, collapse/expand, Show Level, first line only, plain text; catalog 428/450 → 441/463 (95.2%) |
| 2026-10-11 | Vertical sections (#491): Layout › Text Direction turns the section (or the cell or text box the caret is in); lines run top to bottom, right to left, ideographs upright; `w:textDirection` in `w:sectPr` read and written |
| 2026-10-11 | File › Options in Word's panes (#485): General, Display, Proofing, Save, Language, Accessibility, Advanced, Agents; initials, individual formatting marks, spelling ignore options, custom dictionary, overtype, typing replaces selection, measurement units, recent-documents count, print options; `file.options` reads and sets them headlessly |
| 2026-10-11 | Right-click Wrap Text submenu on pictures, shapes and text boxes: wrapping styles, Move with Text / Fix Position on Page (#513) |
| 2026-10-11 | Caret animation: keys and typing glide the caret to its new place in 100 ms along the curve measured from Word for Windows, and the blink fades; clicks and scripted moves don't glide. File › Options › Advanced › Animation turns it off, kept between runs; `ui.caretAnimation` for agents |
| 2026-10-11 | Paragraph dialog options (#512): mirror indents (`w:mirrorIndents`, laid out on even pages), same-style spacing, suppress line numbers, don't hyphenate |
| 2026-10-11 | Screen readers can read the document canvas (#488): paragraphs, headings, lists, tables, links, pictures and the caret exposed through AccessKit |
| 2026-10-11 | Add fonts without rebuilding (#582): Home › Font › Add Font… (end of the font list), dropping a TTF/OTF/TTC/WOFF/WOFF2 file, or `font.add`/`font.list`/`font.remove` for agents. Files are checked (50 MB cap, every face must parse and map characters), never replace a bundled or loaded face, and are kept between runs (desktop: `fonts` in the settings folder; web: IndexedDB). Linking a font by URL is still to come |
| 2026-10-11 | Print Markup (#589): File › Print/Export checkbox, `file.printMarkup`, `markup` on save/export commands and `wordcraft-cli convert --markup` print comments (with replies) in balloons beside each page, scaled to the paper like Word's "Document showing markup"; HTML, Markdown and text copies end with the comments |
| 2026-10-11 | Custom colours (#606): every colour drop-down gains Recent Colors and More Colors…, which opens a Colors dialog with a Standard honeycomb and a Custom hue/saturation field, lightness slider, RGB/HSL numbers and a hex box |
| 2026-10-11 | Complex scripts (#604): Myanmar, Khmer and Indic text shaped one script at a time in OpenType fallback fonts (regular weight, not AAT-only fonts), the caret and line breaks never split a syllable, Myanmar wraps between syllables, Backspace removes one sign at a time |
| 2026-10-11 | Any text file opens (#569): `.m3u`/`.m3u8` playlists and other unknown extensions open as plain text with encoding detection (UTF-8, BOMs, UTF-16, Windows-1252), misnamed Word/RTF files by their signature; binary files give a clear error |
| 2026-10-11 | Select by dragging whole units (#549): after a double-click the drag grows by words, after a triple-click by paragraphs, after Ctrl/⌘+click by sentences, as in Word |
| 2026-10-12 | Selection bar (#856): in the left margin beside body text a click selects the line, a double-click the paragraph, a triple-click or Ctrl/⌘+click the document; dragging there selects whole lines and Shift+click extends by lines; the pointer turns to an arrow. Right margin for right-to-left paragraphs not yet |
| 2026-10-11 | Compare / Combine dialog with comparison settings, Restrict Editing pane (exceptions, SHA-512 password, locked styles; DOCX round trip), AutoCorrect Options dialog saved per user (#412) |
| 2026-10-11 | DOCX diagrams (#611): lines, connectors and boxes styled by the theme now show (they were invisible); callouts and flowchart boxes drawn as the nearest shape; VML lines, rectangles and ovals |
| 2026-10-11 | WordArt (#416): text fill/outline/shadow/glow/reflection, Transform warps, our own styles; straight/elbow/curved connectors glued to shapes that re-route; DOCX round trip |
| 2026-10-11 | Content controls (`w:sdt`) kept, shown and edited; Developer tab, Design Mode, Properties dialog (#381) |
| 2026-10-11 | Insert › Chart (#409): column, bar, line, area, pie, doughnut and scatter charts with Chart Design (type, Edit Data, title, legend, data labels), saved as real chart parts; catalog 408/439 (92.9%) |
| 2026-10-11 | Line Numbers, Hyphenation (zone, CAPS, consecutive limit, Manual), Language, Envelopes and Labels dialogs (#407); hyphenation options kept in DOCX; 30 modal dialogs |
| 2026-10-11 | Accessibility (#386): Alt Text pane for every object and table, Mark as decorative, alt text round-trips in DOCX, deeper checker with fix actions |
| 2026-10-11 | References dialogs (#402): Caption (labels, chapter numbering), Index and Mark Entry, Custom Table of Contents, Source Manager and Create/Edit Source, with TOC/INDEX/XE/SEQ switches honoured on update |
| 2026-10-11 | Font dialog: separate Asian text font and Latin text font (#505); `format.font {"eastAsia"}`; East Asian fonts found by the font scan (OS/2 code pages) |
| 2026-10-11 | Document grid (#391): Page Setup › Document Grid sets lines per page and characters per line (`w:docGrid` round trip); lines snap to the line pitch, East Asian characters to the character pitch; View › Gridlines shows it |
| 2026-10-11 | Move tracking (#400): `w:moveFrom`/`w:moveTo` and their named ranges kept in DOCX, a tracked cut and paste of a paragraph or sentence records a move, accept/reject at either end resolves both, green double strikethrough/underline and "Moved from/to" balloons |
| 2026-10-11 | Footnote and Endnote dialog from the References › Footnotes launcher: options per section or document (below text, end of section, number format, start at, restart each section or page), custom marks, convert footnotes ↔ endnotes; kept in DOCX (#385) |
| 2026-10-11 | Shape Format › Text: text direction, Align Text and linked text boxes (Create Link / Break Link), read and written in DOCX (#369) |
| 2026-10-11 | Quick Access Toolbar KeyTips in the customized button order (Alt/F10 then a number), disabled-command checks and a saved File › Options switch (`ui.keytips`), #620 |
| 2026-10-11 | Mail-merge recipients from Excel (.xlsx/.xlsm) and OpenDocument (.ods) spreadsheets, with Select Table to pick the sheet (#334) |
| 2026-10-11 | Insert › Icons (#309): a library of 61 original icons drawn in code, a searchable picker and Icon Color |
| 2026-10-11 | Home › Font › Enclose Characters (#297): circle, square, triangle or diamond around a character, saved as Word's `EQ \o\ac` field |
| 2026-10-11 | Phonetic Guide (#288): ruby text over base text, `w:ruby` round trip |
| 2026-10-12 | Custom XML backing stores preserved as opaque data and related properties through edited DOCX saves; content-control markup is handled by the existing reader/writer and #532 bibliography I/O remains separate; binding evaluation and readiness unchanged |
| 2026-10-12 | Imported TOCs keep actual result boundaries, nested page fields, formatting and locks through DOCX saves without relying on TOC styles; existing engine-managed styled TOCs remain refreshable |
| 2026-10-11 | Customize Ribbon and the Quick Access Toolbar: custom tabs and groups, hide and reorder tabs, add or remove toolbar commands (right-click any button, or the toolbar's … menu); kept between runs (#379, #376) |
| 2026-10-11 | Customize Keyboard: assign, remove and reset keyboard shortcuts; custom keys override built-ins and are kept between runs (#368) |
| 2026-10-12 | Native saves use exclusively created sibling temporary files, synchronize before replacement and clean up on failure; existing temporary-name files/links and original files on failed saves are protected. Synthetic CLI and focused tests on macOS; no new Word parity or readiness claim. |
| 2026-10-11 | Field codes like Word's field keys (#339): show codes (Alt+F9, Shift+F9), edit, unlink, lock and step through fields |
| 2026-10-11 | Word's common keyboard shortcuts (#330): 101 → 125 of ~250 |
| 2026-10-12 | Bulgarian (`bg`) interface: 2,419 catalog entries (every key of the reference catalog, the equation gallery and everything the interface audit finds), `bg-BG` system locales and a saved language choice; locale, catalog and bundled-font tests; no Bulgarian proofing resources |
| 2026-10-11 | Hungarian (`hu`) interface: all 2,419 catalog entries, built-in style names translated, `hu-HU` system locales and a saved language choice; locale, catalog and bundled-font tests; no Hungarian proofing resources |
| 2026-10-11 | Turkish (`tr`) interface: all 1,307 catalog entries, `tr-TR`/`tr-CY` system locales and a saved language choice; command search keeps Turkish dotted and dotless I apart (`italik` finds İtalik); locale, catalog, search and bundled-font tests; no Turkish proofing resources |
| 2026-10-11 | Italian (`it`) interface (#594): 2,399 entries, feature names as Italian Word users know them, locale and saved-preference tests, bundled-font checks; no Italian proofing resources |
| 2026-10-12 | Native drawing-level Word ink preserved through DOCX edits and repeated saves, including its InkML part and optional VML picture fallback; native ink rendering and editing remain missing |
| 2026-10-11 | Citations and bibliography in all 12 of Word's styles: Harvard, ISO 690 (author-date and numerical), Turabian, GB/T 7714, GOST (name and title sort) and SIST02 join APA, MLA, Chicago and IEEE; numbered styles count in order of first citation (#383) |
| 2026-10-11 | Interface size setting (#475): File › Options › General scales the interface 80–200 % on top of the system display scale, kept between runs; `ui.interfaceSize` for agents |
| 2026-10-11 | DOCX paragraph defaults: preserve omission separately from explicit empty defaults, with repeated-save and default-edit regressions |
| 2026-10-11 | Formatting revisions (#41): `w:rPrChange`/`w:pPrChange`, table/row/cell, section and numbering changes kept in DOCX; formatting recorded as revisions while tracking; accept/reject, Reviewing Pane descriptions, "Formatted: …" balloons and change bars |
| 2026-10-11 | Draw: Lasso Select (move, recolour, delete the strokes inside a loop), Add Pen gallery kept between runs, Ink to Shape (own recogniser: line, rectangle/square, ellipse/circle, triangle, polygon), Ink Replay in drawing order with play/pause/rewind/forward; drawing order kept in DOCX (#395); catalog 406/434 (93.5%) |
| 2026-10-10 | Estonian (`et`) interface (#294): 1,161 translated entries, locale and saved-preference tests, bundled-font checks; no Estonian proofing resources |
| 2026-10-10 | Draw tab: pen, pencil and highlighter ink, stroke eraser, Select, Review › Hide Ink; ink saved to DOCX as DrawingML freeforms (#307); catalog 399/431 (92.6%) |
| 2026-10-10 | Draw Table and Eraser (#303): a pen draws one-cell tables and splits cells along drawn lines; the eraser merges the cells beside a border. Catalog 393/431 (91.2%) |
| 2026-10-10 | DOCX charts, SmartArt and OLE objects survive open and save with their parts (#319) |
| 2026-10-10 | Full re-measure against Word for Mac 16.113.4 and progress docs to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, format/layout/UI/hardware/localization parity; `docs/parity.md` → `docs/parity-checklist.md`). Landed the same day (~80 PRs): equations with OMML and an Equation tab (#191); Word 97-2003 `.doc` import (#145); right-to-left and Persian text (#207); Zotero integration (#189); Read Aloud player (#190); text boxes and floating objects editable (#46) and placed like Word (#136); floating tables (#137); track-changes fixes (#125, #244); typing parity with Word (#204); hidden text (#173); save prompts and AutoSave rules (#151); LaTeX import/export (#11); keytips and mini-toolbar (#43); Spanish, Ukrainian and Brazilian Portuguese interfaces; system theme (#249); Linux file dialogs no longer freeze (#246); Chinese UI font on Windows (#248); every installed font weight (#239); cell text direction (#245); Paste Special (#235), Advanced Find (#234), Column Selection (#237), Style Inspector (#236), custom table styles (#256), View gridlines (#243), zoom buttons (#232), inertial touchpad scrolling (#252), File Info fields kept (#268), ¶ in mixed-direction paragraphs (#278), Serbian interface (#250), table Height/Width boxes, AutoFit Contents and Table Properties (#44), AutoSave switch greyed out with the reason where it can't save (.md/.txt/.html, unsaved, browser) (#196, #176), OpenGL fallback on Windows PCs without a DirectX 12 driver (#316), Columns, Symbol and Field dialogs (#321), Tabs and Borders and Shading dialogs (#320); free rotation and flips for pictures, shapes and text boxes with a rotation handle (#332); v0.4.0 released |
| 2026-10-10 | Full re-measure against Word for Mac 16.113.4 and progress docs to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, format/layout/UI/hardware/localization parity; `docs/parity.md` → `docs/parity-checklist.md`). Landed the same day (~80 PRs): equations with OMML and an Equation tab (#191); Word 97-2003 `.doc` import (#145); right-to-left and Persian text (#207); Zotero integration (#189); Read Aloud player (#190); text boxes and floating objects editable (#46) and placed like Word (#136); floating tables (#137); track-changes fixes (#125, #244); typing parity with Word (#204); hidden text (#173); save prompts and AutoSave rules (#151); LaTeX import/export (#11); keytips and mini-toolbar (#43); Spanish, Ukrainian and Brazilian Portuguese interfaces; system theme (#249); Linux file dialogs no longer freeze (#246); Chinese UI font on Windows (#248); every installed font weight (#239); cell text direction (#245); Paste Special (#235), Advanced Find (#234), Column Selection (#237), Style Inspector (#236), custom table styles (#256), View gridlines (#243), zoom buttons (#232), inertial touchpad scrolling (#252), File Info fields kept (#268), ¶ in mixed-direction paragraphs (#278), Serbian interface (#250), table Height/Width boxes, AutoFit Contents and Table Properties (#44), AutoSave switch greyed out with the reason where it can't save (.md/.txt/.html, unsaved, browser) (#196, #176), OpenGL fallback on Windows PCs without a DirectX 12 driver (#316), Columns, Symbol and Field dialogs (#321), Tabs and Borders and Shading dialogs (#320); free rotation and flips for pictures, shapes and text boxes with a rotation handle (#332); v0.4.0 released; file associations on Windows and Linux with a Make-default button in Options (#295) |
| 2026-10-09 | Interface languages follow the system or Options (#12; zh-hans, zh-hant, ja); TOC page numbers as an updatable field (#52); rotating log file (#17); window geometry remembered (#38) |
| 2026-10-08 | v0.2.0 and v0.3.0 released; Flatpak and AppImage auto-update (#22) |
| 2026-10-07 | v0.1.0 released; contributor credits in About (#6) |
@@ -170,6 +273,50 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) |
| 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) |
| 2026-10-11 | trivial | Progress log: table markers on the rulers (#700) |
| 2026-10-11 | trivial | Progress log: the table context menu |
| 2026-10-11 | trivial | Progress log: drags and Shift+arrows over table cells select whole cells |
| 2026-10-11 | trivial | Progress log: table selection areas, insert buttons, move handle and double-click AutoFit; catalog 484/499 |
| 2026-10-12 | trivial | Progress log: Undo history list (#701) |
| 2026-10-12 | trivial | Progress log: Start screen at launch (#766) |
| 2026-10-12 | trivial | Progress log: Alt+Q command search (#819); log table header fixed |
| 2026-10-12 | trivial | Progress log: ribbon groups fit above their labels |
| 2026-10-12 | trivial | Font list previews no longer grow memory (#848) |
| 2026-10-12 | trivial | Progress log: section break keeps the selected text (#852) |
| 2026-10-12 | trivial | Progress log: save prompt from the keyboard (#861) |
| 2026-10-12 | trivial | Progress log: craft-fonts Simplified Chinese in documents |
| 2026-10-12 | trivial | Progress log: symbol-font text drawn as Unicode symbols (#776) |
| 2026-10-12 | trivial | Progress log: WMF and EMF pictures can be inserted (#802) |
| 2026-10-11 | trivial | Progress log and shipped languages: Turkish interface completed, 2,325 entries (#774) |
| 2026-10-12 | trivial | Progress log: Navigation heading truncation (#759) |
| 2026-10-11 | trivial | Progress log: Reading Mode implementation |
| 2026-10-12 | trivial | Progress log: new documents follow the system region (#838) |
| 2026-10-11 | trivial | DOCX complex-script formatting inheritance fix and round-trip coverage |
| 2026-10-11 | minor | Czech (`cs`) interface catalog added |
| 2026-10-12 | trivial | Progress log: AutoCorrect replacement list follows the text's language (#835) |
| 2026-10-11 | trivial | Progress log: German interface (#574) |
| 2026-10-12 | trivial | Progress log: language-aware sorting and table numbers (#836, #837) |
| 2026-10-12 | trivial | Progress log: Word 365 cover pages (#743) |
| 2026-10-11 | trivial | Progress log: tables resize with the mouse (#217) |
| 2026-10-11 | trivial | Progress log: Animation effects setting |
| 2026-10-11 | trivial | Progress log: built-in file browser when the system file dialog is unavailable (#576) |
| 2026-10-12 | trivial | Progress log: large-table scrolling (#811) |
| 2026-10-11 | trivial | Progress log: security review fixes (temp files, exported link schemes) |
| 2026-10-11 | trivial | Progress log: Find and Replace special characters and wildcards (#543) |
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, collation, duplex and grayscale; the upstream preview, ranges, parity and Print Markup remain available, along with browser printing and PDF export. Remaining print parity includes driver-specific tray/options and pages-per-sheet UI; physical printer fidelity needs hardware validation. |
| 2026-10-11 | trivial | Progress log: Windows DX12 shaders compiled with FXC (#603) |
| 2026-10-11 | trivial | Progress log: Insert › SmartArt (#497); catalog 429/451 → 437/458 (95.4%) |
| 2026-10-11 | trivial | Progress log: Outline view (#493) |
| 2026-10-11 | trivial | Progress log: vertical sections (#491) |
| 2026-10-11 | trivial | Progress log: Paragraph dialog options, mirror indents (#512) |
| 2026-10-11 | trivial | Progress log: Print Markup (#589) |
| 2026-10-11 | trivial | Progress log: custom colours via More Colors… (#606) |
| 2026-10-11 | trivial | Progress log: complex-script shaping (#604) |
| 2026-10-11 | trivial | Progress log: unknown text extensions open as text (#569) |
| 2026-10-11 | trivial | Progress log: drag-select by words/paragraphs/sentences (#549) |
| 2026-10-12 | trivial | Progress log: selection bar in the left margin (#856) |
| 2026-10-11 | trivial | Progress log: content controls (#381) |
| 2026-10-11 | trivial | Progress log: Insert › Chart (#409); catalog 408/439 (92.9%) |
| 2026-10-11 | trivial | Progress log: Line Numbers, Hyphenation, Language, Envelopes and Labels dialogs (#407) |
@@ -178,14 +325,22 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-11 | trivial | Progress log: document grid (#391) |
| 2026-10-11 | trivial | Progress log: text direction, Align Text and linked text boxes (#369) |
| 2026-10-10 | trivial | Progress: Drawing Canvas (#344): insert, shapes/pictures/text boxes inside, Fit/Expand/Scale, DOCX `wpc:wpc`, turns and flips as a whole; catalog 404/434 |
| 2026-10-11 | trivial | Quick Access Toolbar KeyTips and persisted enable/disable setting (#620) |
| 2026-10-11 | trivial | Progress log: mail-merge recipients from .xlsx/.xlsm/.ods (#334) |
| 2026-10-11 | trivial | Insert › Icons landed (#309): catalog 402/434 → 403/434 (92.9%) |
| 2026-10-11 | trivial | Enclose Characters (#297): catalog 403/434 (92.9%) |
| 2026-10-12 | minor | Progress log: opaque custom XML store preservation, with synthetic package regressions and explicit limits |
| 2026-10-12 | minor | Preserve imported TOC ranges and retain #538 refresh/undo/save coverage on current main; readiness unchanged |
| 2026-10-11 | trivial | Progress log: Customize Ribbon and the Quick Access Toolbar (#379) |
| 2026-10-11 | trivial | Omitted `w:pPrDefault` stays omitted across saves; explicit empty/default properties and later spacing edits remain writable. Synthetic preservation checks only; readiness estimates unchanged |
| 2026-10-11 | trivial | Progress log: Customize Keyboard (#368) |
| 2026-10-12 | trivial | Native saves protect existing temporary-name entries and originals on pre-replacement failure; synthetic macOS evidence, explicit durability/platform limits; readiness unchanged |
| 2026-10-11 | trivial | Word's common keyboard shortcuts (#330): 125 of ~250 |
| 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges |
| 2026-10-12 | trivial | Bulgarian interface (`bg`): progress log and shipped languages |
| 2026-10-11 | trivial | Hungarian interface (`hu`): progress log and shipped languages |
| 2026-10-11 | trivial | Turkish interface (`tr`): progress log and shipped languages |
| 2026-10-12 | minor | Native drawing-level ink and its picture fallback survive DOCX edits and repeated saves; adapted to the current graphic writer; rendering and editing InkML remain missing |
| 2026-10-11 | trivial | Progress log: eight more bibliography styles (#383) |
| 2026-10-10 | trivial | Progress log: Draw tab ink (#307) |
| 2026-10-10 | trivial | Draw Table and Eraser (#303): catalog 393/431 (91.2%) |
@@ -195,6 +350,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-10 | trivial | Progress log: Windows OpenGL fallback without a DirectX 12 driver (#316) |
| 2026-10-10 | minor | Password-protected .docx (#281): files encrypted with a password (agile encryption) open and save; File › Info › Encrypt with Password |
| 2026-10-10 | minor | Relanded clipboard pane, table styles editor, Manage Styles, Asian typography (kinsoku), Group/Ungroup and Shape Effects (#264 #263 #270 #271 #267 #280): catalog 390/431 (90.5%) |
| 2026-10-10 | minor | Polish interface and proofing landed: Localization 25% → 27%, Proofing 35% → 40% |
| 2026-10-10 | minor | Merged main: catalog 384/430 (89.3%), 431 commands; Paste Special, Advanced Find, Column Selection, Style Inspector, custom table styles, gridlines, zoom buttons, touchpad scrolling, File Info, Serbian landed; mainstream 54→55% |
| 2026-10-10 | minor | Readiness table with hours per audience; full number restored to the additive weighted sum (~47% → ~60%): method aligned with the standard, no new evidence; beta distance back to ~15 points and ~150–230 h |
| 2026-10-10 | minor | Mainstream practitioner (~54%) and essentials user (~63%) numbers added; full number recomputed 60% → ~47% with the standard's discounts; beta distance now ~28 points and ~450–700 h; stage stays alpha |
+90
View File
@@ -0,0 +1,90 @@
//! Parse `--automation-read-root` / `--automation-write-root` before the server starts. A malformed
//! flag must not silently remove a root and leave the server unconfined.
use std::path::PathBuf;
pub struct AutomationArgs {
pub read_root: Option<PathBuf>,
pub write_root: Option<PathBuf>,
read_given: bool,
write_given: bool,
}
impl AutomationArgs {
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
Self { read_root, write_root, read_given: false, write_given: false }
}
/// Returns whether this argument is a root flag. A second occurrence of a flag is an error,
/// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed.
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
let (root, given) = match flag {
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
_ => return Ok(false),
};
if *given {
return Err(format!("{flag} given twice"));
}
let value = match inline {
Some(value) => value.to_owned(),
None => {
let value = args.next().ok_or_else(|| format!("{flag} needs a folder"))?;
if value.starts_with('-') {
return Err(format!("{flag} needs a folder; use {flag}=<dir> for a folder starting with '-'"));
}
value
}
};
if value.is_empty() {
return Err(format!("{flag} needs a non-empty folder"));
}
*root = Some(value.into());
*given = true;
Ok(true)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roots_are_taken_once_and_other_arguments_are_left() {
let mut roots = AutomationArgs::new(None, None);
let mut args = ["a folder".to_owned(), "report.docx".to_owned()].into_iter();
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
assert!(!roots.consume("--author", &mut args).unwrap());
assert_eq!(roots.read_root, Some("a folder".into()));
assert_eq!(roots.write_root, Some("out".into()));
assert_eq!(args.next().as_deref(), Some("report.docx"));
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
assert_eq!(roots.read_root, Some("a folder".into()));
}
#[test]
fn invalid_operands_leave_roots_unchanged() {
for flag in ["--automation-read-root", "--automation-write-root"] {
for operand in [None, Some(""), Some("--version"), Some("--connect"), Some("-folder")] {
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
let mut args = operand.map(str::to_owned).into_iter();
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
assert_eq!(roots.read_root, Some("read".into()));
assert_eq!(roots.write_root, Some("write".into()));
}
let mut roots = AutomationArgs::new(None, None);
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
}
}
#[test]
fn explicit_dash_folder_and_relative_dash_folder_are_valid() {
let mut roots = AutomationArgs::new(None, None);
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
assert_eq!(roots.read_root, Some("-folder".into()));
assert_eq!(roots.write_root, Some("./-folder".into()));
}
}
+109 -7
View File
@@ -1,6 +1,8 @@
//! `wordcraft-cli`: WordCraft from the command line.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod automation_args;
use std::fmt;
use std::io::Write;
use std::process::ExitCode;
@@ -12,7 +14,10 @@ const USAGE: &str = "\
wordcraft-cli — WordCraft from the command line
USAGE:
wordcraft-cli convert <in> <out> convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png)
wordcraft-cli convert <in> <out> [--markup]
convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png);
--markup (Print Markup): comments beside each page and
tracked changes in pdf/png, a comment list in html/md/txt
wordcraft-cli info <file> pages, words, paragraphs, properties (JSON)
wordcraft-cli text <file> plain text
wordcraft-cli inspect <file> document structure (JSON)
@@ -22,8 +27,14 @@ USAGE:
who tracked changes and comments are by)
wordcraft-cli commands [--json] list every command
wordcraft-cli parity [--markdown] feature-catalog parity
wordcraft-cli mcp [--connect HOST:PORT | --author NAME]
MCP server on stdio (headless, or bridged to the app)
wordcraft-cli mcp [--connect HOST:PORT | --author NAME] [--automation-read-root DIR] [--automation-write-root DIR]
MCP server on stdio (headless, or bridged to the app).
--automation-read-root / --automation-write-root confine
the files the agent's commands read (open, insert text
or picture, mail-merge data, templates…) and write (save,
export, merge output…) to DIR, links followed; a root left
out grants none of its access. They apply to the headless
server and are an error with --connect.
wordcraft-cli zotero <command> <file> [--cmd 'id={json}' …] [--save OUT] [--trace] [--port P]
run a Zotero command on a document (Zotero must be
running): addEditCitation, addEditBibliography,
@@ -73,7 +84,28 @@ fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Res
Ok(())
}
/// Take `--automation-read-root` / `--automation-write-root` (each `--flag DIR` or `--flag=DIR`,
/// once) out of `args`, and open the roots, which must be existing folders. Returns the arguments
/// that are left. A malformed root flag is an error, never a root silently left out.
fn automation_roots(
command: &str,
args: &[String],
) -> Result<(Option<std::sync::Arc<wordcraft_engine::file_access::AutomationRoots>>, Vec<String>), String> {
let mut found = automation_args::AutomationArgs::new(None, None);
let mut left = Vec::new();
let mut it = args.iter().cloned();
while let Some(arg) = it.next() {
if !found.consume(&arg, &mut it).map_err(|e| format!("{command}: {e}"))? {
left.push(arg);
}
}
let roots = wordcraft_engine::file_access::AutomationRoots::new(found.read_root.as_deref(), found.write_root.as_deref())
.map_err(|e| format!("{command}: {e}"))?;
Ok((roots, left))
}
const NO_OPTIONS: &[OptionSpec] = &[];
const CONVERT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--markup", takes_value: false }];
const RENDER_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--scale", takes_value: true }];
const RUN_OPTIONS: &[OptionSpec] = &[
OptionSpec { name: "--file", takes_value: true },
@@ -147,13 +179,14 @@ fn run(args: &[String]) -> Result<(), Failure> {
let pos = |i: usize| rest.iter().filter(|a| !a.starts_with("--")).nth(i).cloned().ok_or_else(|| format!("missing argument\n\n{USAGE}"));
match cmd {
"convert" => {
validate_options("convert", &rest, NO_OPTIONS)?;
validate_options("convert", &rest, CONVERT_OPTIONS)?;
let (i, o) = (pos(0)?, pos(1)?);
let markup = rest.iter().any(|a| a == "--markup");
let mut s = open(&i)?;
let r = if o.to_ascii_lowercase().ends_with(".png") {
s.run("file.exportPng", &json!({"path": o, "page": 1}))
s.run("file.exportPng", &json!({"path": o, "page": 1, "markup": markup}))
} else {
s.run("file.save", &json!({"path": o}))
s.run("file.save", &json!({"path": o, "markup": markup}))
};
r.map_err(|e| e.to_string())?;
eprintln!("wrote {o}");
@@ -260,9 +293,17 @@ fn run(args: &[String]) -> Result<(), Failure> {
}
}
"mcp" => {
let (roots, rest) = automation_roots("mcp", &rest)?;
validate_options("mcp", &rest, MCP_OPTIONS)?;
let author = arg_value(&rest, "--author");
let backend: Box<dyn wordcraft_mcp::Backend> = match arg_value(&rest, "--connect") {
let connect = arg_value(&rest, "--connect");
if roots.is_some() && connect.is_some() {
return Err(
"mcp: --automation-read-root and --automation-write-root confine the headless server; a running app is not confined by them"
.into(),
);
}
let backend: Box<dyn wordcraft_mcp::Backend> = match connect {
// The app's user name is the person's own setting; don't rewrite it from a flag.
Some(_) if author.is_some() => {
return Err("mcp: --author applies to the headless server; with --connect, run file.setAuthor".into());
@@ -276,6 +317,13 @@ fn run(args: &[String]) -> Result<(), Failure> {
}
None => {
let mut headless = wordcraft_mcp::Headless::default();
if let Some(roots) = roots {
// Every thread of this process, not only the server's: nothing reaches past the roots.
wordcraft_engine::file_access::confine_process(roots.clone());
let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
eprintln!("wordcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
headless = headless.with_automation_roots(Some(roots));
}
if let Some(name) = author {
set_author(&mut headless.session, &name)?;
}
@@ -423,4 +471,58 @@ mod tests {
// The app's user name is the person's own; a bridge flag must not rewrite it.
assert!(run(&args(&["mcp", "--connect", "127.0.0.1:9", "--author", "Claude"])).is_err_and(|e| e.to_string().contains("--author")));
}
#[test]
fn automation_root_flags_are_taken_out_of_the_mcp_arguments() {
let base = std::env::temp_dir().join(format!("wc-cli-roots-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("in")).unwrap();
std::fs::create_dir_all(base.join("out")).unwrap();
let (read, write) = (base.join("in").to_string_lossy().into_owned(), base.join("out").to_string_lossy().into_owned());
let (roots, left) =
automation_roots("mcp", &args(&["--automation-read-root", &read, "--author", "Claude", &format!("--automation-write-root={write}")]))
.unwrap();
let roots = roots.unwrap();
assert_eq!(left, args(&["--author", "Claude"]));
assert!(roots.read_root().is_some() && roots.write_root().is_some());
assert!(validate_options("mcp", &left, MCP_OPTIONS).is_ok());
// A root left out grants none of its access.
let (roots, _) = automation_roots("mcp", &args(&["--automation-read-root", &read])).unwrap();
let roots = roots.unwrap();
assert!(roots.write_root().is_none());
assert!(
roots.check(&format!("{write}/x.docx"), wordcraft_engine::file_access::Access::Write).unwrap_err().contains("write authority is absent")
);
// No flags: nothing is confined and the arguments pass through.
let (roots, left) = automation_roots("mcp", &args(&["--connect", "127.0.0.1:9"])).unwrap();
assert!(roots.is_none());
assert_eq!(left, args(&["--connect", "127.0.0.1:9"]));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn automation_root_flags_reject_bad_operands_and_missing_folders() {
for bad in [
args(&["--automation-read-root"]),
args(&["--automation-read-root", "--author"]),
args(&["--automation-write-root="]),
args(&["--automation-read-root", "a", "--automation-read-root=b"]),
] {
assert!(automation_roots("mcp", &bad).is_err(), "{bad:?}");
}
let e = automation_roots("mcp", &args(&["--automation-read-root", "/definitely/not/a/folder"])).err().unwrap();
assert!(e.starts_with("mcp: cannot open the automation read root"), "{e}");
// Through the command line: an error, not a server that quietly runs unconfined.
assert!(run(&args(&["mcp", "--automation-write-root", "/definitely/not/a/folder"])).is_err());
}
#[test]
fn automation_roots_are_an_error_with_connect() {
let dir = std::env::temp_dir().to_string_lossy().into_owned();
let e = run(&args(&["mcp", "--connect", "127.0.0.1:9", "--automation-read-root", &dir])).unwrap_err().to_string();
assert!(e.contains("--automation-read-root") && e.contains("headless"), "{e}");
}
}
+2 -1
View File
@@ -20,6 +20,7 @@ egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
wordcraft-doc = { workspace = true }
wordcraft-fonts = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
@@ -28,4 +29,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList"] }
+1
View File
@@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>WordCraft</title>
<link data-trunk rel="icon" href="../../assets/app-icon/wordcraft.ico" />
<link data-trunk rel="rust" data-bin="wordcraft-web" data-wasm-opt="z" data-weak-refs />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #e6e6e6; }
+205 -17
View File
@@ -1,16 +1,17 @@
//! The browser shell: web `Services`, drag-and-drop, the unsaved-changes guard and the eframe
//! web runner.
use std::cell::Cell;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use wasm_bindgen::JsCast as _;
use wordcraft_engine::Session;
use wordcraft_ui_egui::{Inbox, Services, WordApp};
const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json"];
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"];
const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"];
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"];
const RECIPIENT_EXTS: &[&str] = &["csv", "tsv", "txt", "xlsx", "xlsm", "ods"];
mod font_store;
const CANVAS_ID: &str = "wordcraft_canvas";
const LOADING_ID: &str = "wordcraft_loading";
@@ -26,7 +27,8 @@ pub fn start() {
return;
};
let dirty = Rc::new(Cell::new(false));
if let Err(e) = guard_unload(dirty.clone()) {
let saves = Rc::new(PickerSaves::default());
if let Err(e) = guard_unload(dirty.clone(), saves.clone()) {
log::error!("no unsaved-changes guard: {e}");
}
let mut options = eframe::WebOptions::default();
@@ -43,14 +45,26 @@ pub fn start() {
if let Some(rs) = &cc.wgpu_render_state {
log::info!("wordcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
}
// Fonts the user added on an earlier visit (#582); the app notices them arrive.
font_store::load_all(cc.egui_ctx.clone());
let inbox: Inbox = Inbox::default();
if let Err(e) = listen_for_pasted_pictures(inbox.clone(), cc.egui_ctx.clone()) {
log::error!("pasting pictures is unavailable: {e}");
}
let doc = if query().contains("sample") { wordcraft_engine::sample::sample_document() } else { wordcraft_doc::Document::new() };
let mut app = WordApp::new(Session::new(doc), services(inbox.clone(), cc.egui_ctx.clone(), dirty));
// The browser's locale picks the new document's paper, the units and the
// decimal separator (#838).
let region = wordcraft_ui_egui::i18n::system_region();
let sample = query().contains("sample");
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_engine::blank_document(&region) };
let mut session = Session::new(doc);
session.system_region = region;
let mut app = WordApp::new(session, services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone()));
app.autosave = false;
Ok(Box::new(WebShell { app, inbox }))
// No document to show: start on File › Home, as the desktop app does (#766).
if !sample {
app.start_screen();
}
Ok(Box::new(WebShell { app, inbox, saves }))
}),
)
.await;
@@ -72,6 +86,38 @@ fn query() -> String {
struct WebShell {
app: WordApp,
inbox: Inbox,
saves: Rc<PickerSaves>,
}
/// Saves the browser's save picker finishes after `download` has returned (#728). The app marks
/// the document saved as soon as `download` returns, so a cancelled or failed picker save must
/// mark it unsaved again, and the `beforeunload` guard keeps warning while one is in flight.
#[derive(Default)]
struct PickerSaves {
/// Picker saves started and not yet written and closed.
pending: Cell<u32>,
/// Picker saves that didn't write the file: (name, error, or `None` when cancelled).
unsaved: RefCell<Vec<(String, Option<String>)>>,
}
impl WebShell {
/// A picker save that didn't happen leaves the document with unsaved changes.
fn settle_picker_saves(&mut self) {
let unsaved = std::mem::take(&mut *self.saves.unsaved.borrow_mut());
for (name, error) in unsaved {
// Only a save in a format that keeps everything marked the document saved.
if wordcraft_ui_egui::keeps_everything(&name) {
self.app.session.dirty = true;
}
if let Some(error) = error {
let msg = wordcraft_ui_egui::i18n::fmt(
wordcraft_ui_egui::i18n::t("Couldn't download {name}: {error}"),
&[("name", &name), ("error", &error)],
);
self.app.status(msg);
}
}
}
}
impl eframe::App for WebShell {
@@ -91,6 +137,7 @@ impl eframe::App for WebShell {
}
});
}
self.settle_picker_saves();
self.app.logic(ctx);
}
@@ -150,14 +197,17 @@ fn listen_for_pasted_pictures(inbox: Inbox, ctx: egui::Context) -> Result<(), St
}
/// `dirty` is the flag the `beforeunload` guard reads ([`guard_unload`]).
fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>) -> Services {
fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>, saves: Rc<PickerSaves>) -> Services {
let open_inbox = inbox.clone();
let save_ctx = ctx.clone();
Services {
open_async: Some(Box::new(move |purpose: &str| {
let inbox = open_inbox.clone();
let ctx = ctx.clone();
let dialog = if purpose == "picture" {
rfd::AsyncFileDialog::new().add_filter("Pictures", IMAGE_EXTS)
} else if purpose == "font" {
rfd::AsyncFileDialog::new().add_filter("Fonts", wordcraft_fonts::FONT_FILE_EXTS)
} else if purpose == "recipients" {
rfd::AsyncFileDialog::new().add_filter("Recipient lists", RECIPIENT_EXTS)
} else {
@@ -174,20 +224,32 @@ fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>) -> Services
})),
// Exports ask for a name; the browser decides where the download goes.
pick_save: Some(Box::new(|name: &str| Some(name.to_string()))),
download: Some(Box::new(download)),
download: Some(Box::new(move |path: &str, bytes: &[u8]| download(&saves, &save_ctx, path, bytes))),
print: Some(Box::new(print_pdf)),
inbox: Some(inbox),
on_dirty: Some(Box::new(move |d| dirty.set(d))),
animations_enabled: Some(animations_enabled()),
store_font: Some(Box::new(font_store::store)),
forget_font: Some(Box::new(font_store::forget)),
..Default::default()
}
}
/// Whether the system has animation effects on, as the browser passes it on: off while the
/// `prefers-reduced-motion: reduce` media query matches. `None` when the browser can't say. The
/// query is made once and asked every frame; it follows the system's setting by itself.
fn animations_enabled() -> Box<dyn Fn() -> Option<bool>> {
let reduce = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
Box::new(move || reduce.as_ref().map(|query| !query.matches()))
}
/// Closing, reloading or leaving the tab with unsaved changes asks first. Browsers show their own
/// confirmation (a page can only ask for it), so there is no Save button: Cancel, then File › Save.
fn guard_unload(dirty: Rc<Cell<bool>>) -> Result<(), String> {
fn guard_unload(dirty: Rc<Cell<bool>>, saves: Rc<PickerSaves>) -> Result<(), String> {
let window = web_sys::window().ok_or("no window")?;
let on_unload = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::new(move |e: web_sys::BeforeUnloadEvent| {
if dirty.get() {
// A save still in the browser's picker hasn't written anything yet.
if dirty.get() || saves.pending.get() > 0 {
e.prevent_default();
// Older browsers ask only when `returnValue` is set.
e.set_return_value("unsaved changes");
@@ -199,20 +261,145 @@ fn guard_unload(dirty: Rc<Cell<bool>>) -> Result<(), String> {
Ok(())
}
/// Trigger a browser download of `bytes` named after the last component of `path`.
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
/// Trigger a browser save dialog or download of `bytes` named after the last component of `path`.
fn download(saves: &Rc<PickerSaves>, ctx: &egui::Context, path: &str, bytes: &[u8]) -> Result<(), String> {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "document".into());
let window = web_sys::window().ok_or("no window")?;
// On browsers supporting the File System Access API (Chromium: Chrome, Edge, etc.),
// `showSaveFilePicker` lets the user choose the save location and filename natively.
if let Ok(picker_val) = js_sys::Reflect::get(&window, &wasm_bindgen::JsValue::from_str("showSaveFilePicker"))
&& let Ok(picker_fn) = picker_val.dyn_into::<js_sys::Function>()
{
match trigger_save_file_picker(saves, ctx, &window, &picker_fn, &name, bytes) {
Ok(()) => return Ok(()),
Err(e) => {
log::warn!("showSaveFilePicker failed: {e}; falling back to anchor download");
}
}
}
// Fallback for browsers without showSaveFilePicker (e.g. Firefox, Safari):
trigger_anchor_download(&window, &name, bytes)
}
fn trigger_save_file_picker(
saves: &Rc<PickerSaves>,
ctx: &egui::Context,
window: &web_sys::Window,
picker_fn: &js_sys::Function,
name: &str,
bytes: &[u8],
) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let ext = std::path::Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("docx").to_ascii_lowercase();
let dot_ext = format!(".{ext}");
let mime = mime_for(name);
let accept_obj = js_sys::Object::new();
let exts_arr = js_sys::Array::new();
exts_arr.push(&wasm_bindgen::JsValue::from_str(&dot_ext));
let _ = js_sys::Reflect::set(&accept_obj, &wasm_bindgen::JsValue::from_str(mime), &exts_arr);
// Our own neutral description: the format's name, as its extension says it.
let type_desc = match ext.as_str() {
"htm" => "HTML".to_string(),
"txt" => "Text".to_string(),
e if !e.is_empty() && e.len() <= 8 && e.chars().all(|c| c.is_ascii_alphanumeric()) => e.to_ascii_uppercase(),
_ => "Document".to_string(),
};
let type_obj = js_sys::Object::new();
let _ = js_sys::Reflect::set(&type_obj, &wasm_bindgen::JsValue::from_str("description"), &wasm_bindgen::JsValue::from_str(&type_desc));
let _ = js_sys::Reflect::set(&type_obj, &wasm_bindgen::JsValue::from_str("accept"), &accept_obj);
let types_arr = js_sys::Array::new();
types_arr.push(&type_obj);
let picker_opts = js_sys::Object::new();
let _ = js_sys::Reflect::set(&picker_opts, &wasm_bindgen::JsValue::from_str("suggestedName"), &wasm_bindgen::JsValue::from_str(name));
let _ = js_sys::Reflect::set(&picker_opts, &wasm_bindgen::JsValue::from_str("types"), &types_arr);
// Call showSaveFilePicker synchronously while transient user activation is active:
let prom_val = picker_fn.call1(window, &picker_opts).map_err(js)?;
let prom: js_sys::Promise = prom_val.dyn_into().map_err(|_| "not a promise".to_string())?;
let bytes_vec = bytes.to_vec();
let saves = saves.clone();
let ctx = ctx.clone();
let name = name.to_string();
saves.pending.set(saves.pending.get().saturating_add(1));
wasm_bindgen_futures::spawn_local(async move {
// The document counts as saved only once the file is written and closed.
let unsaved = match wasm_bindgen_futures::JsFuture::from(prom).await {
Ok(handle_val) => match write_file_handle(&handle_val, &bytes_vec).await {
Ok(()) => {
log::info!("saved {name} through the save picker");
None
}
Err(e) => {
log::error!("couldn't write {name}: {e}");
Some(Some(e))
}
},
Err(e) => {
let err_name =
js_sys::Reflect::get(&e, &wasm_bindgen::JsValue::from_str("name")).ok().and_then(|v| v.as_string()).unwrap_or_default();
if err_name == "AbortError" {
log::debug!("save picker cancelled");
Some(None)
} else {
log::error!("showSaveFilePicker error: {e:?}");
Some(Some(format!("{e:?}")))
}
}
};
if let Some(error) = unsaved {
saves.unsaved.borrow_mut().push((name, error));
}
saves.pending.set(saves.pending.get().saturating_sub(1));
ctx.request_repaint();
});
Ok(())
}
async fn write_file_handle(handle: &wasm_bindgen::JsValue, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let create_fn: js_sys::Function = js_sys::Reflect::get(handle, &wasm_bindgen::JsValue::from_str("createWritable"))
.map_err(js)?
.dyn_into()
.map_err(|_| "createWritable is not a function".to_string())?;
let stream_prom: js_sys::Promise = create_fn.call0(handle).map_err(js)?.dyn_into().map_err(|_| "not a promise".to_string())?;
let stream = wasm_bindgen_futures::JsFuture::from(stream_prom).await.map_err(js)?;
let write_fn: js_sys::Function = js_sys::Reflect::get(&stream, &wasm_bindgen::JsValue::from_str("write"))
.map_err(js)?
.dyn_into()
.map_err(|_| "write is not a function".to_string())?;
let uint8 = js_sys::Uint8Array::from(bytes);
let write_prom: js_sys::Promise = write_fn.call1(&stream, &uint8).map_err(js)?.dyn_into().map_err(|_| "not a promise".to_string())?;
wasm_bindgen_futures::JsFuture::from(write_prom).await.map_err(js)?;
let close_fn: js_sys::Function = js_sys::Reflect::get(&stream, &wasm_bindgen::JsValue::from_str("close"))
.map_err(js)?
.dyn_into()
.map_err(|_| "close is not a function".to_string())?;
let close_prom: js_sys::Promise = close_fn.call0(&stream).map_err(js)?.dyn_into().map_err(|_| "not a promise".to_string())?;
wasm_bindgen_futures::JsFuture::from(close_prom).await.map_err(js)?;
Ok(())
}
fn trigger_anchor_download(window: &web_sys::Window, name: &str, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let document = window.document().ok_or("no document")?;
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
let opts = web_sys::BlobPropertyBag::new();
opts.set_type(mime_for(&name));
opts.set_type(mime_for(name));
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?;
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor".to_string())?;
a.set_href(&url);
a.set_download(&name);
a.set_download(name);
a.style().set_property("display", "none").map_err(js)?;
let body = document.body().ok_or("no body")?;
body.append_child(&a).map_err(js)?;
@@ -306,6 +493,7 @@ fn mime_for(name: &str) -> &'static str {
Some("md" | "txt") => "text/plain",
Some("tex") => "application/x-tex",
Some("json") => "application/json",
Some("xml") => "application/xml",
_ => "application/octet-stream",
}
}
+116
View File
@@ -0,0 +1,116 @@
//! Fonts the user added in the browser (#582), kept in IndexedDB (`localStorage` holds about
//! 5 MB, less than one CJK font) so they are there again after a reload. Every failure is logged
//! and skipped: a font that can't be stored still works for the session.
use wasm_bindgen::JsCast as _;
use wasm_bindgen::JsValue;
use wasm_bindgen::closure::Closure;
use web_sys::{IdbDatabase, IdbRequest, IdbTransactionMode};
const DB_NAME: &str = "wordcraft";
const STORE: &str = "fonts";
fn js(e: JsValue) -> String {
format!("{e:?}")
}
/// Wait for an IndexedDB request; its result.
async fn done(req: &IdbRequest) -> Result<JsValue, String> {
let promise = js_sys::Promise::new(&mut |resolve: js_sys::Function, reject: js_sys::Function| {
let ok_req = req.clone();
let ok = Closure::once_into_js(move || {
let _ = resolve.call1(&JsValue::NULL, &ok_req.result().unwrap_or(JsValue::UNDEFINED));
});
let err = Closure::once_into_js(move || {
let _ = reject.call1(&JsValue::NULL, &JsValue::from_str("IndexedDB request failed"));
});
req.set_onsuccess(Some(ok.unchecked_ref()));
req.set_onerror(Some(err.unchecked_ref()));
});
wasm_bindgen_futures::JsFuture::from(promise).await.map_err(js)
}
/// The database, with its font store made on first use.
async fn open() -> Result<IdbDatabase, String> {
let factory = web_sys::window().ok_or("no window")?.indexed_db().map_err(js)?.ok_or("IndexedDB is unavailable")?;
let req = factory.open_with_u32(DB_NAME, 1).map_err(js)?;
let upgrade_req = req.clone();
let upgrade = Closure::<dyn FnMut()>::new(move || {
if let Ok(db) = upgrade_req.result().and_then(|r| r.dyn_into::<IdbDatabase>().map_err(JsValue::from))
&& !db.object_store_names().contains(STORE)
&& let Err(e) = db.create_object_store(STORE)
{
log::error!("couldn't make the font store: {e:?}");
}
});
req.set_onupgradeneeded(Some(upgrade.as_ref().unchecked_ref()));
let db = done(&req).await;
req.set_onupgradeneeded(None);
drop(upgrade);
db?.dyn_into::<IdbDatabase>().map_err(js)
}
async fn store_async(name: String, bytes: Vec<u8>) -> Result<(), String> {
let db = open().await?;
let store = db.transaction_with_str_and_mode(STORE, IdbTransactionMode::Readwrite).map_err(js)?.object_store(STORE).map_err(js)?;
let req = store.put_with_key(&js_sys::Uint8Array::from(bytes.as_slice()), &JsValue::from_str(&name)).map_err(js)?;
done(&req).await.map(|_| ())
}
async fn forget_async(name: String) -> Result<(), String> {
let db = open().await?;
let store = db.transaction_with_str_and_mode(STORE, IdbTransactionMode::Readwrite).map_err(js)?.object_store(STORE).map_err(js)?;
let req = store.delete(&JsValue::from_str(&name)).map_err(js)?;
done(&req).await.map(|_| ())
}
/// Keep `bytes` (a font file as the user gave it) under `name` (its stored file name).
pub fn store(name: &str, bytes: &[u8]) {
let (name, bytes) = (name.to_string(), bytes.to_vec());
wasm_bindgen_futures::spawn_local(async move {
if let Err(e) = store_async(name.clone(), bytes).await {
log::error!("couldn't keep the font {name} for the next visit: {e}");
}
});
}
/// Forget the font stored under `name`.
pub fn forget(name: &str) {
let name = name.to_string();
wasm_bindgen_futures::spawn_local(async move {
if let Err(e) = forget_async(name.clone()).await {
log::error!("couldn't forget the stored font {name}: {e}");
}
});
}
/// Add every stored font to the font database (each checked like a new one; one that fails is
/// logged and skipped), then repaint: the app lays out again when fonts change.
pub fn load_all(ctx: egui::Context) {
wasm_bindgen_futures::spawn_local(async move {
let r: Result<(), String> = async {
let db = open().await?;
let store = db.transaction_with_str(STORE).map_err(js)?.object_store(STORE).map_err(js)?;
let keys = done(&store.get_all_keys().map_err(js)?).await?;
let values = done(&store.get_all().map_err(js)?).await?;
let (keys, values) = (js_sys::Array::from(&keys), js_sys::Array::from(&values));
for i in 0..keys.length().min(values.length()) {
let Some(name) = keys.get(i).as_string() else { continue };
let Ok(bytes) = values.get(i).dyn_into::<js_sys::Uint8Array>() else { continue };
if bytes.length() as usize > wordcraft_fonts::MAX_FONT_FILE_BYTES {
log::warn!("stored font {name} is too large; skipped");
continue;
}
if let Err(e) = wordcraft_fonts::FontDb::global().install_font(&name, &bytes.to_vec()) {
log::warn!("stored font {name} skipped: {e}");
}
}
Ok(())
}
.await;
if let Err(e) = r {
log::warn!("no stored fonts: {e}");
}
ctx.request_repaint();
});
}
+35 -3
View File
@@ -13,6 +13,9 @@ workspace = true
[dependencies]
wordcraft-doc = { workspace = true }
wordcraft-engine = { workspace = true }
wordcraft-proof = { workspace = true }
# Fonts the user added (#582) load from the settings folder at start-up.
wordcraft-fonts = { workspace = true }
wordcraft-ui-egui = { workspace = true }
# The control key file's location (shared with the MCP bridge).
wordcraft-control-key = { workspace = true }
@@ -31,8 +34,40 @@ winresource = "0.1"
# by PdfCraft and PhotoCraft) registers the handler (Objective-C interop needs unsafe, which our
# workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping.
fmv-macos-events = "=0.1.0"
# The system's Reduce motion setting (System Settings › Accessibility › Display), read through
# NSWorkspace: a safe call in these bindings. Already in the build through eframe, rfd and arboard,
# at this version; this adds only the `NSAccessibility` bindings.
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility", "NSApplication"] }
objc2 = "0.6"
# Closing the window must quit through NSApplication rather than by dropping the NSWindow
# inside the AppKit run loop (see `schedule_macos_terminate`).
dispatch2 = "0.3"
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
# The desktop's animation setting on Linux and the BSDs, read from the XDG settings portal over
# D-Bus. Already in the build through AccessKit, at this version; this adds only its blocking API.
zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] }
# Wayland's clipboard through the data-control protocol (#687, #833): the RTF and other picture
# types that arboard doesn't read, and the formats offered. Already in the build through arboard's
# `wayland-data-control` feature, at this version.
wl-clipboard-rs = "0.9"
[target.'cfg(target_os = "windows")'.dependencies]
# The upstream default-apps button opens Windows Settings; printing uses craft-print instead.
open = "5"
# The system's Animation effects setting (Settings › Accessibility › Visual effects), read through
# the Windows Runtime's UISettings: a safe API, where the Win32 call needs unsafe, which our
# workspace forbids. Already in the build through wgpu and AccessKit, at this version; this adds
# only the `UI_ViewManagement` bindings, and `System_UserProfile` for the home region new
# documents take their paper and units from (#838).
windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfile"] }
# The clipboard's "Rich Text Format" and the names of its formats (#687, #833), which arboard
# doesn't read. Already in the build through arboard, at this version.
clipboard-win = { version = "5.4", features = ["std"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-print = { git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "7fcee66b8eef8eac3c7644fd024e1f1b051f5a3e" }
rfd = { workspace = true }
# Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94);
# rfd itself already depends on it.
@@ -42,6 +77,3 @@ pollster = "0.4"
arboard = { workspace = true, features = ["image-data"] }
# The control server's random key.
getrandom = "0.3"
# File › Print opens the PDF in the system's default viewer (`xdg-open`/`gio` on Linux and BSD, the
# file association on Windows, `open` on macOS) without blocking; already in the tree via egui_kittest.
open = "5.4"
+99
View File
@@ -0,0 +1,99 @@
//! The system's animation setting, which File › Options › Accessibility › Animation effects
//! follows when set to "Use system setting" (`Services::animations_enabled`).
//!
//! - Windows: Settings › Accessibility › Visual effects › Animation effects, read through the
//! Windows Runtime's `UISettings`.
//! - macOS: System Settings › Accessibility › Display › Reduce motion, read through `NSWorkspace`.
//! - Linux and the BSDs: the desktop's setting, read through the settings portal ([`desktop`]).
//!
//! All three are safe APIs of crates already in the build (our workspace forbids unsafe). The UI
//! asks every frame, so the answer always comes from memory: Windows and macOS are asked again at
//! most once a second, and the Linux/BSD side keeps its answer up to date on a thread of its own.
#[cfg(any(all(unix, not(target_os = "macos")), test))]
mod desktop;
/// The answer to "does the system have animation effects on?" for the UI: `None` when the system
/// doesn't say. `ctx` is asked to redraw when an answer that arrives later changes it.
pub fn hook(ctx: &egui::Context) -> Box<dyn Fn() -> Option<bool>> {
#[cfg(any(target_os = "windows", target_os = "macos"))]
{
let _ = ctx;
Box::new(remembered(system_enabled, REFRESH))
}
#[cfg(all(unix, not(target_os = "macos")))]
{
Box::new(desktop::watch(ctx.clone()))
}
#[cfg(not(any(target_os = "windows", unix)))]
{
let _ = ctx;
Box::new(|| None)
}
}
/// How long an answer from Windows or macOS is kept before the system is asked again.
#[cfg(any(target_os = "windows", target_os = "macos"))]
const REFRESH: std::time::Duration = std::time::Duration::from_secs(1);
/// `read`, asked at most once per `every` and answered from memory in between.
#[cfg(any(target_os = "windows", target_os = "macos", test))]
fn remembered(read: impl Fn() -> Option<bool>, every: std::time::Duration) -> impl Fn() -> Option<bool> {
let last = std::cell::Cell::new(None::<(std::time::Instant, Option<bool>)>);
move || {
if let Some((at, answer)) = last.get()
&& at.elapsed() < every
{
return answer;
}
let answer = read();
last.set(Some((std::time::Instant::now(), answer)));
answer
}
}
/// Whether Windows has animation effects on; `None` when it can't be asked.
#[cfg(target_os = "windows")]
fn system_enabled() -> Option<bool> {
windows::UI::ViewManagement::UISettings::new()
.and_then(|settings| settings.AnimationsEnabled())
.map_err(|e| log::debug!("system animation setting: {e}"))
.ok()
}
/// Whether macOS plays animations: Reduce motion is off.
#[cfg(target_os = "macos")]
fn system_enabled() -> Option<bool> {
Some(!objc2_app_kit::NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceMotion())
}
#[cfg(test)]
mod tests {
use std::cell::Cell;
use std::time::Duration;
use super::remembered;
/// The UI asks every frame; the system is asked once per interval and the answer kept between.
#[test]
fn the_system_is_asked_once_per_interval() {
let asked = Cell::new(0);
let system = Cell::new(Some(false));
let read = || {
asked.set(asked.get() + 1);
system.get()
};
let kept = remembered(read, Duration::from_secs(3600));
assert_eq!((kept(), kept(), kept()), (Some(false), Some(false), Some(false)));
system.set(Some(true));
assert_eq!(kept(), Some(false), "within the interval the remembered answer stands");
assert_eq!(asked.get(), 1);
// Once the interval has passed (here: at once) the system is asked again.
let fresh = remembered(read, Duration::ZERO);
assert_eq!(fresh(), Some(true));
system.set(None);
assert_eq!(fresh(), None);
assert_eq!(asked.get(), 3);
}
}
+305
View File
@@ -0,0 +1,305 @@
//! The desktop's animation setting on Linux and the BSDs.
//!
//! Asked of the XDG settings portal over D-Bus (`org.freedesktop.portal.Settings`), which GNOME,
//! KDE Plasma and the other desktops with a portal answer, inside Flatpak too. Three settings say
//! whether animations play ([`KEYS`]); any one of them saying no turns them off. Where no portal
//! answers, or it knows none of the three (a bare window manager), the user's own KDE and GTK
//! settings files are read instead, once.
//!
//! A thread asks at start-up and then follows the portal's change signal, so the UI's question is
//! always answered from memory and a change in the desktop's settings shows without a restart.
/// What a setting holds, whichever way it was stored.
#[derive(Clone, Debug, PartialEq)]
pub enum Reading {
Bool(bool),
Number(f64),
Text(String),
}
/// A setting as the portal names it: (namespace, key).
type Key = (&'static str, &'static str);
/// The cross-desktop preference: 1 asks for reduced motion; 0, and values not defined yet, are
/// no preference.
const REDUCED_MOTION: Key = ("org.freedesktop.appearance", "reduced-motion");
/// GNOME's switch, the one GTK follows.
const GNOME_ANIMATIONS: Key = ("org.gnome.desktop.interface", "enable-animations");
/// KDE Plasma's animation speed, a factor on every animation's length: 0 makes them instant.
const KDE_ANIMATION_SPEED: Key = ("org.kde.kdeglobals.KDE", "AnimationDurationFactor");
/// The settings that say whether animations play.
pub const KEYS: [Key; 3] = [REDUCED_MOTION, GNOME_ANIMATIONS, KDE_ANIMATION_SPEED];
/// Whether animations play according to one of [`KEYS`]; `None` for any other setting, or a
/// value that isn't one it holds.
pub fn says(namespace: &str, key: &str, value: &Reading) -> Option<bool> {
match (namespace, key) {
k if k == REDUCED_MOTION => number(value).map(|n| n != 1.0),
k if k == GNOME_ANIMATIONS => truth(value),
k if k == KDE_ANIMATION_SPEED => number(value).map(|n| n > 0.0),
_ => None,
}
}
fn number(value: &Reading) -> Option<f64> {
let n = match value {
Reading::Number(n) => *n,
Reading::Text(t) => t.trim().parse().ok()?,
Reading::Bool(_) => return None,
};
n.is_finite().then_some(n)
}
fn truth(value: &Reading) -> Option<bool> {
match value {
Reading::Bool(b) => Some(*b),
Reading::Number(n) => n.is_finite().then_some(*n != 0.0),
Reading::Text(t) => match t.trim().to_ascii_lowercase().as_str() {
"true" | "1" => Some(true),
"false" | "0" => Some(false),
_ => None,
},
}
}
/// Several settings' answers as one: off as soon as one says off, on when at least one says on,
/// `None` when none says anything.
pub fn combine(answers: impl IntoIterator<Item = Option<bool>>) -> Option<bool> {
let mut any = None;
for on in answers.into_iter().flatten() {
if !on {
return Some(false);
}
any = Some(true);
}
any
}
/// The value of `key` in `[section]` of a settings file in INI form (KDE's `kdeglobals`, GTK's
/// `settings.ini`). The last one wins, as the toolkits read it.
pub fn ini_value<'a>(text: &'a str, section: &str, key: &str) -> Option<&'a str> {
let mut inside = false;
let mut found = None;
for line in text.lines().map(str::trim) {
if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
inside = name.trim() == section;
} else if inside
&& !line.starts_with(['#', ';'])
&& let Some((k, v)) = line.split_once('=')
&& k.trim() == key
{
found = Some(v.trim());
}
}
found
}
/// What the user's own settings files say, given their text (`None` for a file that isn't
/// there): KDE Plasma's `kdeglobals` and GTK's `settings.ini`s.
pub fn files_say(kdeglobals: Option<&str>, gtk_settings: [Option<&str>; 2]) -> Option<bool> {
let (namespace, key) = KDE_ANIMATION_SPEED;
let kde = kdeglobals.and_then(|text| ini_value(text, "KDE", key)).and_then(|v| says(namespace, key, &Reading::Text(v.to_string())));
let gtk = gtk_settings
.into_iter()
.map(|text| text.and_then(|t| ini_value(t, "Settings", "gtk-enable-animations")).and_then(|v| truth(&Reading::Text(v.to_string()))));
combine(std::iter::once(kde).chain(gtk))
}
#[cfg(all(unix, not(target_os = "macos")))]
pub use watching::watch;
#[cfg(all(unix, not(target_os = "macos")))]
mod watching {
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
use zbus::blocking::{Connection, Proxy};
use zbus::zvariant::{OwnedValue, Value};
use super::{KEYS, Reading, combine, files_say, says};
// The latest answer, as one byte the UI thread reads without waiting.
const UNKNOWN: u8 = 0;
const ON: u8 = 1;
const OFF: u8 = 2;
/// Start following the desktop's animation setting. The returned function gives the latest
/// answer from memory (`None` until the first one arrives, and when the desktop says nothing);
/// `ctx` is asked to redraw when the answer changes.
pub fn watch(ctx: egui::Context) -> impl Fn() -> Option<bool> {
let latest = Arc::new(AtomicU8::new(UNKNOWN));
let shared = latest.clone();
let spawned = std::thread::Builder::new().name("animation-setting".into()).spawn(move || {
let publish = |answer: Option<bool>| {
let code = match answer {
Some(true) => ON,
Some(false) => OFF,
None => UNKNOWN,
};
if shared.swap(code, Ordering::Relaxed) != code {
ctx.request_repaint();
}
};
if let Err(e) = follow_portal(&publish) {
log::debug!("desktop animation setting: no settings portal ({e}); reading the settings files");
publish(read_files());
}
});
if let Err(e) = spawned {
log::warn!("desktop animation setting: {e}");
}
move || match latest.load(Ordering::Relaxed) {
ON => Some(true),
OFF => Some(false),
_ => None,
}
}
/// Publish what the settings portal says, now and after every change it announces, until the
/// connection ends; an error when there is no session bus to ask on. While the portal knows
/// none of the settings, the files' answer stands.
fn follow_portal(publish: &dyn Fn(Option<bool>)) -> zbus::Result<()> {
let connection = Connection::session()?;
let portal = Proxy::new(&connection, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings")?;
// Listen before the first read, so a change between the two isn't missed.
let changes = portal.receive_signal("SettingChanged")?;
// The files are read once, the first time the portal has nothing to say.
let mut files = None;
let mut ask = || ask_portal(&portal).or_else(|| *files.get_or_insert_with(read_files));
publish(ask());
// A setting can turn up later (KDE's has no value while the speed is at its default), so
// the changes are followed whatever the portal knew at first. Every setting is asked
// again, not just the one announced: KDE announces a new animation speed only as the
// cross-desktop preference, and its own setting must not be left at the old value.
for change in changes {
let ours = change
.body()
.deserialize::<(String, String, OwnedValue)>()
.is_ok_and(|(namespace, key, _)| KEYS.contains(&(namespace.as_str(), key.as_str())));
if ours {
publish(ask());
}
}
Ok(())
}
/// What the portal's settings come to; `None` when it has none of them.
fn ask_portal(portal: &Proxy<'_>) -> Option<bool> {
combine(KEYS.map(|(namespace, key)| read_setting(portal, namespace, key).and_then(|value| says(namespace, key, &value))))
}
/// One setting from the portal; `None` when it doesn't have it.
fn read_setting(portal: &Proxy<'_>, namespace: &str, key: &str) -> Option<Reading> {
// `ReadOne` is the current call; portals from before it only have `Read`.
let value: OwnedValue = portal.call("ReadOne", &(namespace, key)).or_else(|_| portal.call("Read", &(namespace, key))).ok()?;
reading(&value, 0)
}
fn reading(value: &Value<'_>, depth: u8) -> Option<Reading> {
Some(match value {
Value::Bool(b) => Reading::Bool(*b),
Value::U8(n) => Reading::Number(f64::from(*n)),
Value::I16(n) => Reading::Number(f64::from(*n)),
Value::U16(n) => Reading::Number(f64::from(*n)),
Value::I32(n) => Reading::Number(f64::from(*n)),
Value::U32(n) => Reading::Number(f64::from(*n)),
Value::I64(n) => Reading::Number(*n as f64),
Value::U64(n) => Reading::Number(*n as f64),
Value::F64(n) => Reading::Number(*n),
Value::Str(s) => Reading::Text(s.as_str().to_string()),
// `Read` wraps its answer in a second variant.
Value::Value(inner) if depth < 4 => return reading(inner, depth + 1),
_ => return None,
})
}
/// What the user's settings files say (see [`files_say`]).
fn read_files() -> Option<bool> {
let config = std::env::var_os("XDG_CONFIG_HOME")
.filter(|dir| !dir.is_empty())
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?;
let text = |file: &str| read_capped(&config.join(file));
let (kdeglobals, gtk4, gtk3) = (text("kdeglobals"), text("gtk-4.0/settings.ini"), text("gtk-3.0/settings.ini"));
files_say(kdeglobals.as_deref(), [gtk4.as_deref(), gtk3.as_deref()])
}
/// A settings file's text: at most 256 KiB of it (they are a few kilobytes).
fn read_capped(path: &Path) -> Option<String> {
use std::io::Read as _;
let mut text = String::new();
std::fs::File::open(path).ok()?.take(256 * 1024).read_to_string(&mut text).ok()?;
Some(text)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn text(s: &str) -> Reading {
Reading::Text(s.to_string())
}
/// Each desktop's setting, as the portal hands it over, comes to animations on or off.
#[test]
fn each_desktop_setting_says_on_or_off() {
let ((appearance, reduced), (gnome, enable), (kde, factor)) = (REDUCED_MOTION, GNOME_ANIMATIONS, KDE_ANIMATION_SPEED);
assert_eq!(KEYS, [REDUCED_MOTION, GNOME_ANIMATIONS, KDE_ANIMATION_SPEED]);
// The cross-desktop preference: only 1 asks for reduced motion.
assert_eq!(says(appearance, reduced, &Reading::Number(1.0)), Some(false));
assert_eq!(says(appearance, reduced, &Reading::Number(0.0)), Some(true));
assert_eq!(says(appearance, reduced, &Reading::Number(7.0)), Some(true), "a value not defined yet is no preference");
assert_eq!(says(appearance, reduced, &Reading::Bool(true)), None);
// GNOME's switch.
assert_eq!(says(gnome, enable, &Reading::Bool(false)), Some(false));
assert_eq!(says(gnome, enable, &Reading::Bool(true)), Some(true));
assert_eq!(says(gnome, enable, &text("nonsense")), None);
// KDE Plasma's animation speed arrives as text: 0 is instant.
assert_eq!(says(kde, factor, &text("0")), Some(false));
assert_eq!(says(kde, factor, &text(" 0.5 ")), Some(true));
assert_eq!(says(kde, factor, &Reading::Number(1.0)), Some(true));
for junk in [text(""), text("fast"), Reading::Number(f64::NAN), text("inf")] {
assert_eq!(says(kde, factor, &junk), None, "{junk:?}");
}
// Any other setting says nothing.
assert_eq!(says(gnome, "color-scheme", &Reading::Bool(false)), None);
assert_eq!(says("org.example", enable, &Reading::Bool(false)), None);
}
/// One setting saying off is enough; none saying anything is no answer.
#[test]
fn any_setting_saying_off_turns_animations_off() {
assert_eq!(combine([]), None);
assert_eq!(combine([None, None]), None);
assert_eq!(combine([None, Some(true)]), Some(true));
assert_eq!(combine([Some(true), None, Some(false)]), Some(false));
assert_eq!(combine([Some(false), Some(true)]), Some(false));
}
/// Without a portal the user's own settings files answer: KDE's animation speed and GTK's
/// `gtk-enable-animations`.
#[test]
fn settings_files_answer_without_a_portal() {
let kde_instant = "[General]\nColorScheme=Breeze\n\n[KDE]\nAnimationDurationFactor=0\nSingleClick=false\n";
let kde_slow = "[KDE]\nAnimationDurationFactor = 2\n";
let gtk_off = "[Settings]\ngtk-theme-name=Adwaita\ngtk-enable-animations=0\n";
let gtk_on = "# written by hand\n[Settings]\ngtk-enable-animations=true\n";
assert_eq!(files_say(None, [None, None]), None);
assert_eq!(files_say(Some("[KDE]\nSingleClick=false\n"), [Some("[Settings]\n"), None]), None, "files that don't mention it");
assert_eq!(files_say(Some(kde_instant), [None, None]), Some(false));
assert_eq!(files_say(Some(kde_slow), [None, None]), Some(true));
assert_eq!(files_say(None, [Some(gtk_off), None]), Some(false));
assert_eq!(files_say(None, [None, Some(gtk_on)]), Some(true));
assert_eq!(files_say(Some(kde_slow), [Some(gtk_on), Some(gtk_off)]), Some(false), "one file saying off is enough");
// The key counts only in its own section, comments don't, and the last value wins.
assert_eq!(ini_value("[Other]\nAnimationDurationFactor=0\n[KDE]\nSingleClick=true\n", "KDE", "AnimationDurationFactor"), None);
assert_eq!(ini_value("[KDE]\n#AnimationDurationFactor=0\n;AnimationDurationFactor=0\n", "KDE", "AnimationDurationFactor"), None);
assert_eq!(ini_value("[KDE]\r\nAnimationDurationFactor=0\r\nAnimationDurationFactor=1\r\n", "KDE", "AnimationDurationFactor"), Some("1"));
assert_eq!(ini_value("AnimationDurationFactor=0\n", "KDE", "AnimationDurationFactor"), None, "outside any section");
assert_eq!(ini_value("", "KDE", "AnimationDurationFactor"), None);
}
}
+164
View File
@@ -0,0 +1,164 @@
//! The system clipboard beyond egui's text (#45, #584, #687, #833): text for the ribbon's Paste,
//! another program's formatted copy (HTML, and RTF where the platform offers it), pictures and
//! copied files, and the names of the formats there for a paste that finds nothing it can use.
//!
//! On Linux, arboard talks to Wayland compositors through the data-control protocol (KDE Plasma,
//! wlroots compositors); without it, a native Wayland window only sees what the compositor copies
//! over to Xwayland, which KWin does only while an X11 window has focus — so pictures copied in a
//! browser or Spectacle never arrived (#833). Compositors without the protocol fall back to X11.
//!
//! Everything read here comes from other programs: sizes are capped as it is read.
use wordcraft_engine::cmd::paste::MAX_PASTE_BYTES;
use wordcraft_ui_egui::paste_picture::ClipboardPicture;
fn open() -> Option<arboard::Clipboard> {
arboard::Clipboard::new().map_err(|e| log::warn!("clipboard: {e}")).ok()
}
/// The clipboard's text.
pub fn text() -> Option<String> {
open()?.get_text().ok()
}
/// The clipboard's HTML (Windows: the fragment of its "HTML Format"), if not too large.
pub fn html() -> Option<String> {
open()?.get().html().ok().filter(|h| h.len() <= MAX_PASTE_BYTES)
}
/// The clipboard's RTF: Windows ("Rich Text Format") and Wayland (`text/rtf`); not offered by
/// arboard, so read here.
pub fn rtf() -> Option<String> {
let bytes = platform::rtf()?;
if bytes.len() > MAX_PASTE_BYTES {
return None;
}
// RTF is 7-bit by the spec; stray 8-bit bytes become replacement characters.
let s = String::from_utf8_lossy(&bytes).trim_end_matches('\0').to_string();
s.trim_start().starts_with("{\\rtf").then_some(s)
}
/// The names of the formats on the clipboard, where the platform lists them (Wayland, Windows).
pub fn formats() -> Vec<String> {
platform::formats()
}
/// The picture on the clipboard: copied files, else image pixels, else (Wayland) an encoded
/// picture in a format arboard doesn't decode. A picture that is there but can't be read says
/// why (#833). The UI checks the sizes before inserting anything.
pub fn picture() -> Option<ClipboardPicture> {
let mut clipboard = match arboard::Clipboard::new() {
Ok(c) => c,
Err(e) => {
log::warn!("clipboard: {e}");
return Some(ClipboardPicture::Unreadable(format!("the system clipboard couldn't be opened ({e})")));
}
};
if let Ok(files) = clipboard.get().file_list()
&& !files.is_empty()
{
return Some(ClipboardPicture::Files(files));
}
match clipboard.get_image() {
Ok(image) => Some(ClipboardPicture::Pixels { width: image.width, height: image.height, rgba: image.bytes.into_owned() }),
Err(arboard::Error::ContentNotAvailable) => platform::encoded_picture().map(ClipboardPicture::Encoded),
Err(arboard::Error::ConversionFailure) => Some(
platform::encoded_picture()
.map(ClipboardPicture::Encoded)
.unwrap_or_else(|| ClipboardPicture::Unreadable("WordCraft can't decode its format".into())),
),
Err(e) => {
log::warn!("clipboard picture: {e}");
platform::encoded_picture().map(ClipboardPicture::Encoded).or(Some(ClipboardPicture::Unreadable(e.to_string())))
}
}
}
/// Picture types read straight from Wayland when arboard (PNG only) finds none, best first.
#[cfg(all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))))]
const PICTURE_MIMES: [&str; 6] = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp", "image/x-bmp"];
#[cfg(all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))))]
mod platform {
use std::io::Read;
use wl_clipboard_rs::paste::{ClipboardType, MimeType, Seat, get_contents, get_mime_types_ordered};
fn wayland() -> bool {
std::env::var_os("WAYLAND_DISPLAY").is_some()
}
/// One MIME type's bytes, at most `cap`; `None` when absent or larger.
fn read(mime: &str, cap: u64) -> Option<Vec<u8>> {
let (pipe, _) = get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Specific(mime)).ok()?;
let mut out = Vec::new();
pipe.take(cap.saturating_add(1)).read_to_end(&mut out).ok()?;
(out.len() as u64 <= cap).then_some(out)
}
pub fn formats() -> Vec<String> {
if !wayland() {
return Vec::new();
}
get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified).unwrap_or_default()
}
pub fn rtf() -> Option<Vec<u8>> {
if !wayland() {
return None;
}
let offered = formats();
let mime = ["text/rtf", "application/rtf", "text/richtext"].into_iter().find(|m| offered.iter().any(|o| o == m))?;
read(mime, super::MAX_PASTE_BYTES as u64)
}
pub fn encoded_picture() -> Option<Vec<u8>> {
if !wayland() {
return None;
}
let offered = formats();
let mime = super::PICTURE_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))?;
read(mime, wordcraft_ui_egui::paste_picture::MAX_FILE_BYTES)
}
}
#[cfg(target_os = "windows")]
mod platform {
use clipboard_win::{Clipboard, EnumFormats, raw};
pub fn formats() -> Vec<String> {
let Ok(_open) = Clipboard::new_attempts(10) else { return Vec::new() };
// Standard formats are named too (CF_BITMAP, CF_UNICODETEXT…).
EnumFormats::new().take(64).filter_map(raw::format_name_big).collect()
}
pub fn rtf() -> Option<Vec<u8>> {
let format = raw::register_format("Rich Text Format")?;
let _open = Clipboard::new_attempts(10).ok()?;
if raw::size(format.get()).is_none_or(|n| n.get() > super::MAX_PASTE_BYTES) {
return None;
}
let mut out = Vec::new();
raw::get_vec(format.get(), &mut out).ok()?;
Some(out)
}
pub fn encoded_picture() -> Option<Vec<u8>> {
None
}
}
#[cfg(not(any(target_os = "windows", all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))))))]
mod platform {
pub fn formats() -> Vec<String> {
Vec::new()
}
pub fn rtf() -> Option<Vec<u8>> {
None
}
pub fn encoded_picture() -> Option<Vec<u8>> {
None
}
}
+183
View File
@@ -0,0 +1,183 @@
//! File › Options › General › Make WordCraft the default for Word documents (#295).
//!
//! The installers register WordCraft for Word, RTF and ODT files without taking over the
//! defaults (`packaging/windows/wordcraft.wxs`, the `.desktop` file's `MimeType=`, `Info.plist`).
//! This is the button that makes it the default when the user asks:
//! - **Windows** doesn't let apps set defaults: it opens Settings › Default apps on WordCraft's
//! page (`ms-settings:defaultapps?registeredAppMachine=WordCraft`; Windows 10 ignores the query
//! and shows the Default apps list), where the user picks WordCraft per type.
//! - **Linux and BSD**: `xdg-mime default ai.storyteller.wordcraft.desktop <types>` for
//! [`WORD_MIME_TYPES`], which writes the user's `mimeapps.list`. Only when the desktop entry is
//! installed (deb, rpm, Flatpak; a bare AppImage has none), and not from inside the Flatpak
//! sandbox, which can't change the host's defaults.
//! - **macOS** has no API for it here: the Options page explains Finder's Get Info › Change All.
//!
//! [`WORD_MIME_TYPES`]: self::WORD_MIME_TYPES
use wordcraft_ui_egui::DefaultApp;
/// The `Services::make_default_app` hook.
pub type Hook = Box<dyn Fn() -> Result<DefaultApp, String>>;
/// The hook for this platform; `None` where there is no button (macOS).
pub fn hook() -> Option<Hook> {
#[cfg(target_os = "windows")]
let hook: Option<Hook> = Some(Box::new(windows::open_default_apps));
#[cfg(all(unix, not(target_os = "macos")))]
let hook: Option<Hook> = Some(Box::new(xdg::make_default));
#[cfg(not(any(target_os = "windows", all(unix, not(target_os = "macos")))))]
let hook: Option<Hook> = None;
hook
}
/// The desktop entry the Linux packages install, and the Flatpak's app id.
#[cfg(any(all(unix, not(target_os = "macos")), test))]
const DESKTOP_ID: &str = "ai.storyteller.wordcraft.desktop";
/// The types the button makes WordCraft the default for on Linux and BSD: Word documents and
/// templates (also macro-enabled and legacy), RTF and ODT. The same set the Windows installer
/// lists under WordCraft's Capabilities; Markdown and plain text are only offered under Open With.
/// Every one is in the `.desktop` file's `MimeType=` (a test checks).
#[cfg(any(all(unix, not(target_os = "macos")), test))]
const WORD_MIME_TYPES: &[&str] = &[
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
"application/vnd.ms-word.document.macroEnabled.12", // .docm
"application/vnd.openxmlformats-officedocument.wordprocessingml.template", // .dotx
"application/vnd.ms-word.template.macroEnabled.12", // .dotm
"application/msword", // .doc
"application/msword-template", // .dot
"application/rtf", // .rtf
"text/rtf", // .rtf (older name)
"application/vnd.oasis.opendocument.text", // .odt
];
/// The arguments for `xdg-mime` that make `desktop_id` the default for [`WORD_MIME_TYPES`]: one
/// call sets them all.
#[cfg(any(all(unix, not(target_os = "macos")), test))]
fn xdg_mime_args(desktop_id: &str) -> Vec<String> {
["default", desktop_id].into_iter().chain(WORD_MIME_TYPES.iter().copied()).map(str::to_string).collect()
}
/// The `applications` folders the desktop looks for entries in, from `XDG_DATA_HOME` (default
/// `~/.local/share`) and `XDG_DATA_DIRS` (default `/usr/local/share:/usr/share`). Relative paths
/// are ignored, as the XDG Base Directory spec says.
#[cfg(all(unix, any(not(target_os = "macos"), test)))]
fn application_dirs(data_home: Option<&str>, data_dirs: Option<&str>, home: Option<&str>) -> Vec<std::path::PathBuf> {
use std::path::{Path, PathBuf};
let absolute = |s: &&str| Path::new(s).is_absolute();
let home_dir = data_home
.filter(|s| !s.is_empty())
.filter(absolute)
.map(PathBuf::from)
.or_else(|| home.filter(|s| !s.is_empty()).filter(absolute).map(|h| Path::new(h).join(".local/share")));
let system = data_dirs.filter(|s| !s.is_empty()).unwrap_or("/usr/local/share:/usr/share");
home_dir
.into_iter()
.chain(system.split(':').filter(|s| !s.is_empty()).filter(absolute).map(PathBuf::from))
.map(|d| d.join("applications"))
.collect()
}
#[cfg(target_os = "windows")]
mod windows {
use wordcraft_ui_egui::DefaultApp;
/// Settings › Default apps, on the page of the app registered as `WordCraft` under
/// `HKLM\Software\RegisteredApplications` (the MSI writes it).
const SETTINGS_URI: &str = "ms-settings:defaultapps?registeredAppMachine=WordCraft";
pub(super) fn open_default_apps() -> Result<DefaultApp, String> {
open::that_detached(SETTINGS_URI).map_err(|e| format!("couldn't open Settings: {e}"))?;
Ok(DefaultApp::SettingsOpened)
}
}
#[cfg(all(unix, not(target_os = "macos")))]
mod xdg {
use std::process::Command;
use wordcraft_ui_egui::DefaultApp;
use super::{DESKTOP_ID, application_dirs, xdg_mime_args};
/// The longest error text from `xdg-mime` shown in the status bar.
const MAX_ERROR: usize = 200;
pub(super) fn make_default() -> Result<DefaultApp, String> {
if std::env::var_os("FLATPAK_ID").is_some() || std::path::Path::new("/.flatpak-info").exists() {
return Err(
"the Flatpak sandbox can't change default apps; choose WordCraft under Open With in your file manager or system settings".into()
);
}
let var = |k: &str| std::env::var(k).ok();
let dirs = application_dirs(var("XDG_DATA_HOME").as_deref(), var("XDG_DATA_DIRS").as_deref(), var("HOME").as_deref());
if !dirs.iter().any(|d| d.join(DESKTOP_ID).is_file()) {
return Err(format!("{DESKTOP_ID} isn't installed (an AppImage needs desktop integration first)"));
}
let out = Command::new("xdg-mime").args(xdg_mime_args(DESKTOP_ID)).output().map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => "xdg-mime isn't installed (it comes with xdg-utils)".to_string(),
_ => format!("couldn't run xdg-mime: {e}"),
})?;
if out.status.success() {
return Ok(DefaultApp::MadeDefault);
}
let stderr = String::from_utf8_lossy(&out.stderr);
let first = stderr.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("");
let message: String = first.chars().take(MAX_ERROR).collect();
Err(if message.is_empty() { format!("xdg-mime failed ({})", out.status) } else { format!("xdg-mime failed: {message}") })
}
}
#[cfg(test)]
mod tests {
#[cfg(unix)]
use std::path::PathBuf;
use super::*;
#[test]
fn xdg_mime_sets_every_word_type_in_one_call() {
let args = xdg_mime_args(DESKTOP_ID);
assert_eq!(args.first().map(String::as_str), Some("default"));
assert_eq!(args.get(1).map(String::as_str), Some("ai.storyteller.wordcraft.desktop"));
assert_eq!(&args[2..], WORD_MIME_TYPES);
for t in [
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/msword",
"application/rtf",
"application/vnd.oasis.opendocument.text",
] {
assert!(args.iter().any(|a| a == t), "{t}");
}
assert!(!args.iter().any(|a| a == "text/plain" || a == "text/markdown"), "plain text and Markdown are never claimed");
}
/// Every type the button claims is one the desktop entry declares, or desktops ignore it.
#[test]
fn the_desktop_entry_declares_every_claimed_type() {
let entry = include_str!("../../../packaging/linux/ai.storyteller.wordcraft.desktop");
let declared: Vec<&str> =
entry.lines().find_map(|l| l.strip_prefix("MimeType=")).map(|v| v.split(';').filter(|s| !s.is_empty()).collect()).unwrap_or_default();
for t in WORD_MIME_TYPES {
assert!(declared.contains(t), "{t} missing from MimeType=");
}
assert!(entry.lines().any(|l| l == "Exec=wordcraft %F"), "files are passed on the command line");
}
#[cfg(unix)]
#[test]
fn application_dirs_follow_the_xdg_spec() {
let p = |s: &str| PathBuf::from(s);
assert_eq!(
application_dirs(None, None, Some("/home/a")),
[p("/home/a/.local/share/applications"), p("/usr/local/share/applications"), p("/usr/share/applications")]
);
assert_eq!(
application_dirs(Some("/data"), Some("/opt/share::relative:/usr/share"), Some("/home/a")),
[p("/data/applications"), p("/opt/share/applications"), p("/usr/share/applications")]
);
// Empty or relative values fall back to the defaults; no home at all is fine.
assert_eq!(application_dirs(Some(""), Some(""), None), [p("/usr/local/share/applications"), p("/usr/share/applications")]);
assert_eq!(application_dirs(Some("rel"), None, Some("also-rel")), [p("/usr/local/share/applications"), p("/usr/share/applications")]);
}
}
+115 -16
View File
@@ -6,19 +6,25 @@
//! is queued; the frame (which has the window) builds an `rfd::AsyncFileDialog` parented to the
//! window, and a background thread waits for the answer, hands it to the app and wakes the UI.
//!
//! On Linux and the BSDs rfd asks the xdg-desktop-portal FileChooser and, failing that, runs
//! `zenity`; with neither (a minimal Wayland compositor such as labwc with only
//! xdg-desktop-portal-wlr, #576) it answers "cancelled" and nothing ever shows. So the waiting
//! thread first checks that one of them is there and otherwise answers with an error, and the app
//! shows its built-in file browser. `WORDCRAFT_FILE_DIALOG=builtin` always uses the built-in one.
//!
//! [`Services::file_dialog`]: wordcraft_ui_egui::Services::file_dialog
use std::future::Future;
use std::pin::Pin;
use std::sync::mpsc::{Receiver, Sender, channel};
use wordcraft_ui_egui::FileDialogRequest;
use wordcraft_ui_egui::{FileDialogAnswer, FileDialogRequest};
/// A dialog the app asked for, with the channel its answer goes back through.
type Request = (FileDialogRequest, Sender<Option<String>>);
type Request = (FileDialogRequest, Sender<FileDialogAnswer>);
/// The `Services::file_dialog` hook.
pub type Hook = Box<dyn Fn(FileDialogRequest) -> Receiver<Option<String>>>;
pub type Hook = Box<dyn Fn(FileDialogRequest) -> Receiver<FileDialogAnswer>>;
/// A picker's answer, waited for off the UI thread.
type Picking = Pin<Box<dyn Future<Output = Option<rfd::FileHandle>> + Send>>;
@@ -51,11 +57,18 @@ impl Launcher {
let dialog = rfd::AsyncFileDialog::new().set_parent(parent);
let picking: Picking = match req {
FileDialogRequest::Open { purpose } => Box::pin(open_filters(dialog, &purpose).pick_file()),
FileDialogRequest::Save { name } => Box::pin(dialog.set_file_name(name).save_file()),
FileDialogRequest::Save { name } => {
let exts = save_exts(&name);
Box::pin(dialog.set_file_name(name).add_filter("Documents", &exts).save_file())
}
};
let ctx = self.ctx.clone();
let waiting = std::thread::Builder::new().name("file dialog".into()).spawn(move || {
let picked = pollster::block_on(picking).map(|f| f.path().to_string_lossy().to_string());
// The picker only starts when first polled: without a system dialog it never does.
let picked = match no_system_dialog() {
Some(reason) => Err(reason),
None => Ok(pollster::block_on(picking).map(|f| f.path().to_string_lossy().to_string())),
};
let _ = answer.send(picked);
ctx.request_repaint();
});
@@ -67,16 +80,102 @@ impl Launcher {
}
}
/// The file types Open offers: pictures for Insert/Change Picture, recipient lists for Select
/// Recipients › Use an Existing List…, documents otherwise.
fn open_filters(d: rfd::AsyncFileDialog, purpose: &str) -> rfd::AsyncFileDialog {
if purpose == "picture" {
d.add_filter("Pictures", &["png", "jpg", "jpeg", "gif", "webp", "bmp"])
} else if purpose == "recipients" {
d.add_filter("Recipient lists", &["csv", "tsv", "txt", "xlsx", "xlsm", "ods"]).add_filter("All files", &["*"])
} else {
d.add_filter("Documents", &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json"])
.add_filter("Word document", &["docx"])
.add_filter("All files", &["*"])
/// The file types Open offers (shared with the built-in browser).
fn open_filters(mut d: rfd::AsyncFileDialog, purpose: &str) -> rfd::AsyncFileDialog {
for (name, exts) in wordcraft_ui_egui::open_filters(purpose) {
d = d.add_filter(*name, exts);
}
d
}
/// Why no system file dialog can be shown, if it can't (#576); run off the UI thread.
fn no_system_dialog() -> Option<String> {
if std::env::var_os("WORDCRAFT_FILE_DIALOG").is_some_and(|v| v == "builtin") {
return Some("WORDCRAFT_FILE_DIALOG=builtin".into());
}
portal_or_zenity_missing()
}
/// Linux and the BSDs: rfd needs a file chooser portal or zenity. When `dbus-send` can't ask
/// the session bus, the answer is unknown and rfd gets to try.
#[cfg(all(unix, not(target_os = "macos")))]
fn portal_or_zenity_missing() -> Option<String> {
if on_path("zenity") {
return None;
}
let portal = std::process::Command::new("dbus-send")
.args([
"--session",
"--print-reply=literal",
"--reply-timeout=10000",
"--dest=org.freedesktop.portal.Desktop",
"/org/freedesktop/portal/desktop",
"org.freedesktop.DBus.Properties.Get",
"string:org.freedesktop.portal.FileChooser",
"string:version",
])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
match portal {
Ok(status) if !status.success() => Some("no xdg-desktop-portal FileChooser on the session bus and no zenity".into()),
Ok(_) => None,
Err(e) => {
log::debug!("couldn't ask D-Bus for the file chooser portal: {e}");
None
}
}
}
/// Windows and macOS always have their system dialog.
#[cfg(not(all(unix, not(target_os = "macos"))))]
fn portal_or_zenity_missing() -> Option<String> {
None
}
/// Whether an executable named `program` is in a `PATH` folder.
#[cfg(all(unix, not(target_os = "macos")))]
fn on_path(program: &str) -> bool {
std::env::var_os("PATH").is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(program).is_file()))
}
/// The file types Save As offers: every format the engine saves, with the suggested name's own
/// format first. rfd makes a filter's first extension the dialog's default, which the Windows,
/// macOS and Linux panels append to a file name the user typed without one; with no filter at
/// all the name came back bare and the save failed, since the format is chosen by extension
/// (#689).
fn save_exts(name: &str) -> Vec<String> {
let suggested = std::path::Path::new(name)
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.filter(|e| wordcraft_engine::io::SAVE_EXTS.contains(&e.as_str()))
.unwrap_or_else(|| "docx".to_string());
let mut exts: Vec<String> = Vec::with_capacity(wordcraft_engine::io::SAVE_EXTS.len());
exts.push(suggested.clone());
exts.extend(wordcraft_engine::io::SAVE_EXTS.iter().filter(|e| **e != suggested).map(|e| (*e).to_string()));
exts
}
#[cfg(test)]
mod tests {
use super::save_exts;
/// #689: the Save dialog's default extension is the suggested name's own format, so a file
/// name the user types without one is saved in the document's format instead of coming back
/// bare and failing.
#[test]
fn save_dialog_defaults_to_the_suggested_format() {
assert_eq!(save_exts("novel.docx")[0], "docx");
assert_eq!(save_exts("report.odt")[0], "odt");
assert_eq!(save_exts("annual report.pdf")[0], "pdf");
// A suggested name always has an extension; anything unreadable falls back to Word's.
assert_eq!(save_exts("no extension")[0], "docx");
assert_eq!(save_exts("mystery.rtf2")[0], "docx");
// Every savable format is still offered, once.
let exts = save_exts("novel.docx");
assert!(wordcraft_engine::io::SAVE_EXTS.iter().all(|e| exts.iter().any(|x| x == e)));
assert_eq!(exts.len(), wordcraft_engine::io::SAVE_EXTS.len());
}
}
+98 -22
View File
@@ -1,9 +1,10 @@
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
//! Windows graphics instance configuration (backends and the DirectX 12 shader compiler), applied
//! before wgpu can initialize any driver.
use eframe::{
NativeOptions,
egui_wgpu::WgpuSetup,
wgpu::{Backends, DeviceType},
wgpu::{Backends, DeviceType, Dx12Compiler},
};
/// What the start-up probe learned about one DirectX 12 adapter.
@@ -19,18 +20,28 @@ pub struct Dx12Adapter {
/// The backends to initialize on Windows.
///
/// An explicit `backend_override` (normally `Backends::from_env()`, i.e. `WGPU_BACKEND`) always
/// wins. Otherwise DirectX 12 only, as long as `dx12_adapters` (what a DirectX 12-only probe found)
/// contains a usable hardware GPU. Without one, OpenGL is the way to the GPU: graphics chips whose
/// driver has no DirectX 12 support (Intel 4th generation on Windows 11, #316) still have an
/// OpenGL driver, and GL can't reach the AMD driver crash described in [`configure_backends`],
/// because an AMD GPU with a driver shows up as a DirectX 12 adapter and keeps us on DirectX 12.
/// When DirectX 12 offers only WARP, it stays enabled next to GL as a last resort for machines
/// without an OpenGL driver either (virtual machines): wgpu prefers any GPU over a software
/// adapter, so it's only picked when GL has nothing better.
pub fn choose_backends(backend_override: Option<Backends>, dx12_adapters: &[Dx12Adapter]) -> Backends {
/// wins. Otherwise DirectX 12 only, as long as `dx12_graphics` is set and `dx12_adapters` (what a
/// DirectX 12-only probe found) contains a usable hardware GPU. Without one, OpenGL is the way to
/// the GPU: graphics chips whose driver has no DirectX 12 support (Intel 4th generation on Windows
/// 11, #316) still have an OpenGL driver, and GL can't reach the AMD driver crash described in
/// [`configure_backends`], because an AMD GPU with a driver shows up as a DirectX 12 adapter and
/// keeps us on DirectX 12. When DirectX 12 offers only WARP, it stays enabled next to GL as a last
/// resort for machines without an OpenGL driver either (virtual machines): wgpu prefers any GPU
/// over a software adapter, so it's only picked when GL has nothing better.
///
/// `dx12_graphics` is false on 32-bit Windows. wgpu creates the graphics pipeline through
/// `ID3D12Device2::CreatePipelineState`, and that stream is aligned to 8 bytes. The 32-bit runtime
/// expects pointer alignment (4), so creation returns `E_INVALIDARG` ("The parameter is incorrect")
/// and the process exits before the first frame. Compute pipelines use the older API and succeed,
/// which is why startup gets as far as the egui pipeline and then quits. OpenGL does not use that
/// stream.
pub fn choose_backends(backend_override: Option<Backends>, dx12_adapters: &[Dx12Adapter], dx12_graphics: bool) -> Backends {
if let Some(backends) = backend_override {
return backends;
}
if !dx12_graphics {
return Backends::GL;
}
let hardware = |a: &&Dx12Adapter| a.device_type != DeviceType::Cpu;
if dx12_adapters.iter().filter(hardware).any(|a| a.meets_limits) {
Backends::DX12
@@ -55,20 +66,54 @@ pub fn configure_backends(options: &mut NativeOptions, backends: Backends) {
}
}
/// The DirectX 12 shader compiler: FXC (`d3dcompiler_47.dll`, part of Windows) unless `env`
/// (normally `WGPU_DX12_COMPILER`, wgpu's own variable: `fxc`, `dxc`, `auto`…) names another.
///
/// #603: wgpu's default (`Auto`) uses DXC, loading whichever `dxcompiler.dll` the DLL search path
/// finds first. WordCraft ships none, so that copy belongs to another program. Without a matching
/// `dxil.dll` beside it, DXC warns that the DXIL is unsigned; wgpu takes the warning for a compile
/// error, its own indirect-validation pipeline fails, the device is lost and eframe exits before
/// the first frame. FXC is always there and compiles everything egui's shaders need.
pub fn dx12_compiler(env: Option<&str>) -> Dx12Compiler {
let Some(value) = env else { return Dx12Compiler::Fxc };
value.parse().unwrap_or_else(|e| {
log::warn!("graphics: WGPU_DX12_COMPILER={value:?} ignored ({e}), using FXC");
Dx12Compiler::Fxc
})
}
/// Makes the wgpu instance eframe creates compile DirectX 12 shaders with `compiler`.
pub fn configure_dx12_compiler(options: &mut NativeOptions, compiler: Dx12Compiler) {
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
create.instance_descriptor.backend_options.dx12.shader_compiler = compiler;
}
}
/// Chooses the backends ([`choose_backends`]) and applies them to `options`. Without a
/// `backend_override` this first probes DirectX 12 with a short-lived instance of its own, which
/// is dropped before eframe creates the real one.
#[cfg(target_os = "windows")]
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>) {
// First, so the probe's instance doesn't load a stray `dxcompiler.dll` either.
let compiler = dx12_compiler(std::env::var("WGPU_DX12_COMPILER").ok().as_deref());
log::info!("graphics: DirectX 12 shader compiler {compiler:?}");
configure_dx12_compiler(options, compiler);
let backends = match backend_override {
Some(backends) => {
log::info!("graphics: WGPU_BACKEND override, backends {backends:?}");
backends
}
None => {
let adapters = probe_dx12(options);
let backends = choose_backends(None, &adapters);
log::info!("graphics: {} DirectX 12 adapter(s) {adapters:?}, backends {backends:?}", adapters.len());
// 32-bit: skip the DirectX 12 probe. A capable GPU would be selected, then
// CreatePipelineState fails and the app exits with no window.
let dx12_graphics = cfg!(not(target_arch = "x86"));
let adapters = if dx12_graphics { probe_dx12(options) } else { Vec::new() };
let backends = choose_backends(None, &adapters, dx12_graphics);
if dx12_graphics {
log::info!("graphics: {} DirectX 12 adapter(s) {adapters:?}, backends {backends:?}", adapters.len());
} else {
log::info!("graphics: 32-bit Windows uses OpenGL; DirectX 12 graphics pipeline creation fails");
}
backends
}
};
@@ -87,7 +132,11 @@ fn probe_dx12(options: &NativeOptions) -> Vec<Dx12Adapter> {
let WgpuSetup::CreateNew(create) = &options.wgpu_options.wgpu_setup else {
return Vec::new();
};
let instance = Instance::new(InstanceDescriptor { backends: Backends::DX12, ..InstanceDescriptor::new_without_display_handle() });
let instance = Instance::new(InstanceDescriptor {
backends: Backends::DX12,
backend_options: create.instance_descriptor.backend_options.clone(),
..InstanceDescriptor::new_without_display_handle()
});
pollster::block_on(instance.enumerate_adapters(Backends::DX12))
.iter()
.map(|adapter| {
@@ -125,7 +174,7 @@ mod tests {
};
assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL));
let gpu = [adapter(DeviceType::IntegratedGpu, true)];
assert_eq!(configured_backends(choose_backends(None, &gpu)), Backends::DX12);
assert_eq!(configured_backends(choose_backends(None, &gpu, true)), Backends::DX12);
}
#[test]
@@ -133,23 +182,50 @@ mod tests {
let warp_only = [adapter(DeviceType::Cpu, true)];
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
// Whatever the probe would have found, the override wins (the probe doesn't run then).
assert_eq!(choose_backends(Some(backends), &[]), backends);
assert_eq!(choose_backends(Some(backends), &warp_only), backends);
assert_eq!(choose_backends(Some(backends), &[], true), backends);
assert_eq!(choose_backends(Some(backends), &warp_only, true), backends);
assert_eq!(configured_backends(backends), backends);
}
}
/// #603: DirectX 12 shaders compile with FXC, never a `dxcompiler.dll` found on the search
/// path, unless `WGPU_DX12_COMPILER` asks for it.
#[test]
fn dx12_compiles_with_fxc_unless_asked() {
assert!(matches!(dx12_compiler(None), Dx12Compiler::Fxc));
assert!(matches!(dx12_compiler(Some("bogus")), Dx12Compiler::Fxc));
assert!(matches!(dx12_compiler(Some("FXC")), Dx12Compiler::Fxc));
assert!(matches!(dx12_compiler(Some("dxc")), Dx12Compiler::DynamicDxc { .. }));
assert!(matches!(dx12_compiler(Some("auto")), Dx12Compiler::Auto));
let mut options = NativeOptions::default();
configure_dx12_compiler(&mut options, dx12_compiler(None));
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
assert!(matches!(create.instance_descriptor.backend_options.dx12.shader_compiler, Dx12Compiler::Fxc));
}
#[test]
fn gl_when_dx12_has_no_usable_gpu() {
// #316: no DirectX 12 driver, so DirectX 12 offers only WARP (or nothing) and lags.
let warp_only = [adapter(DeviceType::Cpu, true)];
assert_eq!(choose_backends(None, &warp_only), Backends::GL | Backends::DX12);
assert_eq!(choose_backends(None, &[]), Backends::GL);
assert_eq!(choose_backends(None, &warp_only, true), Backends::GL | Backends::DX12);
assert_eq!(choose_backends(None, &[], true), Backends::GL);
// A GPU that can't create eframe's device: GL alone, so it isn't picked over the GL one.
let weak_gpu = [adapter(DeviceType::IntegratedGpu, false), adapter(DeviceType::Cpu, true)];
assert_eq!(choose_backends(None, &weak_gpu), Backends::GL);
assert_eq!(choose_backends(None, &weak_gpu, true), Backends::GL);
// Any usable GPU keeps DirectX 12 only, next to WARP or a weaker GPU.
let mixed = [adapter(DeviceType::Cpu, true), adapter(DeviceType::IntegratedGpu, false), adapter(DeviceType::DiscreteGpu, true)];
assert_eq!(choose_backends(None, &mixed), Backends::DX12);
assert_eq!(choose_backends(None, &mixed, true), Backends::DX12);
}
/// 32-bit Windows: a usable DirectX 12 GPU still starts on OpenGL. `WGPU_BACKEND` still wins.
#[test]
fn x86_uses_opengl_even_with_a_usable_gpu() {
let gpu = [adapter(DeviceType::IntegratedGpu, true)];
assert_eq!(choose_backends(None, &gpu, false), Backends::GL);
assert_eq!(choose_backends(Some(Backends::DX12), &gpu, false), Backends::DX12);
assert_eq!(choose_backends(Some(Backends::VULKAN), &[], false), Backends::VULKAN);
assert_eq!(configured_backends(choose_backends(None, &gpu, false)), Backends::GL);
}
}
+132 -27
View File
@@ -9,19 +9,22 @@
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod animations;
#[cfg(target_os = "macos")]
mod apple_events;
mod clipboard;
mod control_server;
mod default_apps;
mod file_dialogs;
#[cfg(any(target_os = "windows", test))]
mod graphics;
mod logging;
mod print;
mod region;
use wordcraft_engine::Session;
use wordcraft_ui_egui::{
Services, UiState, WordApp,
paste_picture::ClipboardPicture,
window_geometry::{WindowGeometry, take_rescue},
};
@@ -48,11 +51,23 @@ impl eframe::App for App {
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
}
// The window's close button: with unsaved changes, ask first (`WordApp::close_requested`).
if ctx.input(|i| i.viewport().close_requested()) && !self.0.close_requested() {
let closing = ctx.input(|i| i.viewport().close_requested());
if closing && !self.0.close_requested() {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
}
if self.0.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
} else if (closing || self.0.quit_requested) && !self.0.printing.busy() {
// On macOS, letting eframe drop the window inside `-[NSApplication run]` aborts in
// `-[_NSTouchBarFinderObservation invalidate]`. Command-Q calls `terminate:`. Once
// the close is allowed, take that path instead of dropping the window on this turn.
#[cfg(target_os = "macos")]
{
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
self.0.quit_requested = false;
schedule_macos_terminate(&self.0, self.2.as_ref());
}
#[cfg(not(target_os = "macos"))]
if self.0.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
@@ -63,6 +78,7 @@ impl eframe::App for App {
self.3.show(frame);
}
fn on_exit(&mut self) {
print::finish_pending_jobs();
save_prefs(&self.0);
if let Some(key_file) = &self.2 {
key_file.remove();
@@ -70,6 +86,40 @@ impl eframe::App for App {
}
}
/// Quit the way Command-Q does, on the next turn of the main queue.
///
/// Called when the window is closing. Doing it on the next turn lets the close
/// button's display cycle finish while the window still exists; `terminate:`
/// then ends the process. Preferences are written and the control key file is
/// removed first, as `on_exit` does, because a fallback [`std::process::exit`]
/// skips `on_exit`. A second close while a quit is already scheduled is ignored.
#[cfg(target_os = "macos")]
fn schedule_macos_terminate(app: &WordApp, key_file: Option<&control_server::KeyFile>) {
use std::sync::atomic::{AtomicBool, Ordering};
if app.printing.busy() {
return;
}
static QUIT: AtomicBool = AtomicBool::new(false);
if QUIT.swap(true, Ordering::Relaxed) {
return;
}
log::info!("window close: quitting");
// This path can bypass on_exit. Join the owned workers before termination and cleanup.
print::finish_pending_jobs();
save_prefs(app);
if let Some(key_file) = key_file {
key_file.remove();
}
dispatch2::DispatchQueue::main().exec_async(|| {
if let Some(mtm) = objc2::MainThreadMarker::new() {
objc2_app_kit::NSApplication::sharedApplication(mtm).terminate(None);
}
// `terminate:` does not return when it succeeds. If it did, leave anyway:
// staying up after the close was cancelled would trap the user in the window.
std::process::exit(0);
});
}
/// `ui.json` in the settings folder, which the MCP bridge also uses to find the control key.
fn prefs_path() -> Option<std::path::PathBuf> {
wordcraft_control_key::settings_dir().map(|b| b.join("ui.json"))
@@ -80,6 +130,21 @@ fn log_dir() -> Option<std::path::PathBuf> {
Some(prefs_path()?.parent()?.join("logs"))
}
/// `custom-dictionary.txt` in the settings folder: the words Add to Dictionary kept (#40).
fn user_dict_path() -> Option<std::path::PathBuf> {
wordcraft_control_key::settings_dir().map(|b| b.join(wordcraft_engine::Session::USER_DICT_FILE))
}
fn load_user_dictionary(app: &mut WordApp) {
if !prefs_enabled() {
return;
}
if let Some(p) = user_dict_path() {
wordcraft_proof::load_user_dictionary(&p);
app.session.user_dict_path = Some(p);
}
}
/// Runs without preferences (`WORDCRAFT_NO_PREFS`, agents' test runs) neither read nor write them.
fn prefs_enabled() -> bool {
std::env::var_os("WORDCRAFT_NO_PREFS").is_none()
@@ -91,19 +156,32 @@ fn load_prefs(app: &mut WordApp) {
}
if let Some(p) = prefs_path()
&& let Ok(bytes) = std::fs::read(&p)
&& let Ok(ui) = serde_json::from_slice::<UiState>(&bytes)
&& let Some(ui) = UiState::from_json(&bytes)
{
app.apply_prefs(ui);
}
}
/// Fonts the user added (#582) live in `fonts` in the settings folder: load them, and store fonts
/// added later there. Runs without preferences neither read nor keep them.
fn load_user_fonts() {
if !prefs_enabled() {
return;
}
let Some(dir) = wordcraft_control_key::settings_dir().map(|d| d.join("fonts")) else { return };
let (loaded, skipped) = wordcraft_fonts::FontDb::global().load_user_fonts(&dir);
if !loaded.is_empty() || !skipped.is_empty() {
log::info!("user fonts: {} loaded, {} skipped from {}", loaded.len(), skipped.len(), dir.display());
}
}
/// The saved window geometry, read before the window opens (`load_prefs` runs after).
fn saved_window() -> Option<WindowGeometry> {
if !prefs_enabled() {
return None;
}
let bytes = std::fs::read(prefs_path()?).ok()?;
serde_json::from_slice::<UiState>(&bytes).ok()?.window?.sanitized()
UiState::from_json(&bytes)?.window?.sanitized()
}
fn save_prefs(app: &WordApp) {
@@ -121,29 +199,25 @@ fn save_prefs(app: &WordApp) {
}
/// Native file dialogs, shown without blocking the window (`file_dialogs`, #94), File › Print
/// through the system's PDF viewer (`print`, #15), and pictures on the system clipboard (#45).
fn services(file_dialog: file_dialogs::Hook) -> Services {
/// through the system spooler (`print`), the system clipboard's text, formatted copies,
/// pictures and format names (`clipboard`, #45, #584, #687, #833), the system's animation setting
/// (`animations`), and making WordCraft the default app for Word documents (`default_apps`, #295).
fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
Services {
file_dialog: Some(file_dialog),
print: Some(print::hook()),
clipboard_picture: Some(Box::new(clipboard_picture)),
printers: Some(print::discover()),
print_native: Some(print::submit()),
clipboard_picture: Some(Box::new(clipboard::picture)),
make_default_app: default_apps::hook(),
animations_enabled: Some(animations::hook(ctx)),
clipboard_text: Some(Box::new(clipboard::text)),
clipboard_html: Some(Box::new(clipboard::html)),
clipboard_rtf: Some(Box::new(clipboard::rtf)),
clipboard_formats: Some(Box::new(clipboard::formats)),
..Default::default()
}
}
/// The picture on the system clipboard (#45): copied files, else image pixels. The UI checks the
/// sizes before inserting anything.
fn clipboard_picture() -> Option<ClipboardPicture> {
let mut clipboard = arboard::Clipboard::new().map_err(|e| log::warn!("clipboard: {e}")).ok()?;
if let Ok(files) = clipboard.get().file_list()
&& !files.is_empty()
{
return Some(ClipboardPicture::Files(files));
}
let image = clipboard.get_image().ok()?;
Some(ClipboardPicture::Pixels { width: image.width, height: image.height, rgba: image.bytes.into_owned() })
}
/// Window, Dock and taskbar icon.
fn app_icon() -> Option<egui::IconData> {
#[cfg(target_os = "macos")]
@@ -207,13 +281,25 @@ fn main() -> eframe::Result {
options.viewport = options.viewport.with_icon(icon);
}
// Before eframe creates the wgpu instance: default Windows to DirectX 12 only, or OpenGL when
// DirectX 12 has no GPU driver (see graphics.rs).
// DirectX 12 has no GPU driver or this is a 32-bit build, and compile DirectX 12 shaders with
// FXC, which needs no DLL of ours (#603; see graphics.rs).
#[cfg(target_os = "windows")]
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
load_user_fonts();
let restored = saved_window();
if let Some(window) = restored {
options.viewport = window.apply(options.viewport);
}
#[cfg(all(unix, not(target_os = "macos")))]
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
// manager showed a "no" cursor. Run through XWayland when it's there; WORDCRAFT_WAYLAND=1
// keeps the native Wayland backend.
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("WORDCRAFT_WAYLAND").is_none() {
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
}));
}
// Registered before the event loop starts, so it catches the Finder event that launched us as
// well as later ones. Lives until the event loop returns; the app creator only borrows it.
#[cfg(target_os = "macos")]
@@ -224,10 +310,23 @@ fn main() -> eframe::Result {
"WordCraft",
options,
Box::new(move |cc| {
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_doc::Document::new() };
// The system region picks the new document's paper, the units and the decimal
// separator (#838).
let region = region::system();
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_engine::blank_document(&region) };
let (file_dialog, dialogs) = file_dialogs::hook(&cc.egui_ctx);
let mut app = WordApp::new(Session::new(doc), services(file_dialog));
let mut session = Session::new(doc);
session.system_region = region;
let mut app = WordApp::new(session, services(file_dialog, &cc.egui_ctx));
load_prefs(&mut app);
load_user_dictionary(&mut app);
// A region chosen in File › Options replaces the system's for the first blank
// document too (#838).
if !sample && app.session.prefs.region.is_some() {
let doc = app.session.blank_document();
app.session.set_document(doc);
}
app.watch_desktop_theme(&cc.egui_ctx);
app.ui.window = restored;
app.integrated_titlebar = cfg!(target_os = "macos");
let mut key_file = None;
@@ -237,6 +336,12 @@ fn main() -> eframe::Result {
app = app.with_control(rx);
key_file = file;
}
// Nothing to open (no files, no sample, no control channel driving the editor): start
// on File › Home, as Word does (#766), unless the Start screen option is off. Files
// macOS hands over later open on top of it and close it (`apple_events`).
if files.is_empty() && !sample && control_port.is_none() {
app.start_screen();
}
for f in files {
if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) {
log::warn!("{f}: {e}");
+63 -216
View File
@@ -1,229 +1,76 @@
//! Desktop printing (#15): File › Print hands the document to the system's print flow.
//!
//! The app renders the document to PDF (`ui.print`, the Print button on File › Print) and calls
//! [`Services::print`] with the bytes. Here they go to a fresh temporary file
//! (`wordcraft-print-….pdf` in the OS temp folder), which is then opened without waiting for it:
//! in Preview on macOS (`open -a Preview`), and in the default PDF app elsewhere (`xdg-open` and
//! friends on Linux and BSD, the file association on Windows, via the `open` crate). The user
//! prints from there through the system print dialog.
//!
//! The viewer reads the file after we return, so it can't be removed right away: files older than
//! [`KEEP`] are cleaned up off the UI thread when the app starts and after each print.
//!
//! [`Services::print`]: wordcraft_ui_egui::Services::print
//! Desktop spooler adapter. Discovery and submission never run on the UI thread.
use wordcraft_ui_egui::printing::{Discover, Duplex, Printer, Settings, Submit};
use std::fs::OpenOptions;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
/// Temporary print files start with this and end with `.pdf`.
const PREFIX: &str = "wordcraft-print-";
/// How long a print file is kept for its viewer before the clean-up removes it.
const KEEP: Duration = Duration::from_secs(60 * 60);
/// The `Services::print` hook.
pub type Hook = Box<dyn Fn(&[u8]) -> Result<(), String>>;
/// The desktop print hook; also clears print files left over from earlier runs.
pub fn hook() -> Hook {
let dir = std::env::temp_dir();
spawn_cleanup(dir.clone());
Box::new(move |bytes| {
let r = print_with(&dir, bytes, open_viewer);
spawn_cleanup(dir.clone());
r
pub fn discover() -> Discover {
Box::new(|| {
let (sender, receiver) = std::sync::mpsc::channel();
let _ = std::thread::Builder::new().name("print-discovery".into()).spawn(move || {
let printers = craft_print::printers().into_iter().map(|printer| Printer { name: printer.name, default: printer.default }).collect();
let _ = sender.send(printers);
});
receiver
})
}
/// Writes `bytes` to a new print file in `dir` and has `open` show it. When `open` fails the file
/// is removed again.
fn print_with(dir: &Path, bytes: &[u8], open: impl Fn(&Path) -> Result<(), String>) -> Result<(), String> {
let path = write_temp(dir, bytes).map_err(|e| format!("couldn't write the PDF to print: {e}"))?;
open(&path).inspect_err(|_| {
let _ = std::fs::remove_file(&path);
// Keep submission workers reachable until normal app shutdown; dropping a JoinHandle would
// let process exit kill PowerShell/CUPS before it hands the job to the spooler.
static JOBS: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>> = std::sync::Mutex::new(Vec::new());
pub fn finish_pending_jobs() {
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(|poison| poison.into_inner()));
for worker in workers {
if worker.join().is_err() {
log::error!("the print worker stopped unexpectedly during shutdown");
}
}
}
pub fn submit() -> Submit {
Box::new(|bytes, settings: Settings, collate| {
let (sender, receiver) = std::sync::mpsc::channel();
let worker = std::thread::Builder::new().name("print-submit".into()).spawn(move || {
let job = craft_print::Job {
printer: Some(settings.printer),
copies: settings.copies.clamp(1, 999),
collate,
duplex: settings.duplex.map(|value| match value {
Duplex::LongEdge => craft_print::Duplex::LongEdge,
Duplex::ShortEdge => craft_print::Duplex::ShortEdge,
Duplex::Off => craft_print::Duplex::Off,
}),
grayscale: settings.grayscale,
scaling: craft_print::Scaling::ShrinkToFit,
title: "WordCraft document".into(),
..Default::default()
};
let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string()));
});
if let Ok(worker) = worker {
let mut jobs = JOBS.lock().unwrap_or_else(|poison| poison.into_inner());
jobs.retain(|job| !job.is_finished());
jobs.push(worker);
}
receiver
})
}
/// A new file in `dir` with a unique print-file name, holding `bytes`. Never overwrites (or
/// follows a link at) an existing path; readable by the user only where the OS supports it.
fn write_temp(dir: &Path, bytes: &[u8]) -> io::Result<PathBuf> {
static COUNTER: AtomicU32 = AtomicU32::new(0);
let stamp = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);
for _ in 0..16 {
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = dir.join(format!("{PREFIX}{}-{stamp}-{n}.pdf", std::process::id()));
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
match options.open(&path) {
Ok(mut file) => {
return match file.write_all(bytes) {
Ok(()) => Ok(path),
Err(e) => {
drop(file);
let _ = std::fs::remove_file(&path);
Err(e)
}
};
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(io::Error::new(io::ErrorKind::AlreadyExists, "no free temporary file name"))
}
/// Removes print files in `dir` last written `keep` or longer before `now`; returns how many.
/// Other files, links and files it may not remove are left alone.
fn remove_stale(dir: &Path, now: SystemTime, keep: Duration) -> usize {
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
let mut removed = 0;
for entry in entries.flatten() {
if !is_print_file(&entry.file_name().to_string_lossy()) {
continue;
}
// `DirEntry::metadata` doesn't follow links.
let Ok(meta) = entry.metadata() else { continue };
let old = meta.modified().ok().and_then(|m| now.duration_since(m).ok()).is_some_and(|age| age >= keep);
if meta.is_file() && old && std::fs::remove_file(entry.path()).is_ok() {
removed += 1;
}
}
removed
}
fn is_print_file(name: &str) -> bool {
name.starts_with(PREFIX) && name.ends_with(".pdf")
}
/// Runs [`remove_stale`] on a background thread (the temp folder can be large).
fn spawn_cleanup(dir: PathBuf) {
let spawned = std::thread::Builder::new().name("print-cleanup".into()).spawn(move || {
let n = remove_stale(&dir, SystemTime::now(), KEEP);
if n > 0 {
log::info!("removed {n} old print file(s)");
}
});
if let Err(e) = spawned {
log::warn!("print clean-up: {e}");
}
}
/// Opens the PDF in Preview without waiting for it; if Preview can't open it, the default app.
#[cfg(target_os = "macos")]
fn open_viewer(path: &Path) -> Result<(), String> {
let mut child =
std::process::Command::new("open").arg("-a").arg("Preview").arg(path).spawn().map_err(|e| format!("couldn't open Preview: {e}"))?;
let path = path.to_path_buf();
let waiter = std::thread::Builder::new().name("print-open".into()).spawn(move || {
if !child.wait().is_ok_and(|s| s.success())
&& let Err(e) = open::that(&path)
{
log::error!("print: couldn't open {}: {e}", path.display());
}
});
if let Err(e) = waiter {
log::warn!("print: {e}");
}
Ok(())
}
/// Opens the PDF in the default app without waiting for it (`xdg-open`, `gio`… on Linux and BSD;
/// the file association on Windows).
#[cfg(not(target_os = "macos"))]
fn open_viewer(path: &Path) -> Result<(), String> {
open::that_detached(path).map_err(|e| format!("couldn't open a PDF viewer: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::cell::RefCell;
use std::rc::Rc;
use wordcraft_engine::Session;
use wordcraft_ui_egui::{Services, WordApp};
fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("wordcraft-print-test-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// File › Print's Print button (`ui.print`) writes the document's PDF to a print file and
/// hands that file to the viewer (faked here: no process is started); a viewer that can't
/// start is reported in the status bar and leaves no file behind.
#[test]
fn file_print_opens_a_pdf_file() {
let dir = temp_dir("open");
let opened: Rc<RefCell<Vec<(PathBuf, Vec<u8>)>>> = Rc::default();
let seen = opened.clone();
let hook_dir = dir.clone();
let services = Services {
print: Some(Box::new(move |bytes| {
print_with(&hook_dir, bytes, |path| {
seen.borrow_mut().push((path.to_path_buf(), std::fs::read(path).map_err(|e| e.to_string())?));
Ok(())
})
})),
..Default::default()
};
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), services);
app.run("text.insert", json!({"text": "Print me"})).unwrap();
app.run("file.print", json!({})).unwrap();
assert!(app.ui.backstage && app.ui.backstage_page == "print", "File › Print opens the Print page");
app.run("ui.print", json!({})).unwrap();
app.run("ui.print", json!({})).unwrap();
let opened = opened.borrow();
assert_eq!(opened.len(), 2, "each print opens the viewer once");
for (path, bytes) in opened.iter() {
assert!(bytes.starts_with(b"%PDF"), "the viewer gets a PDF");
assert_eq!(path.parent(), Some(dir.as_path()));
assert!(is_print_file(&path.file_name().unwrap().to_string_lossy()), "{}", path.display());
}
assert_ne!(opened[0].0, opened[1].0, "every print gets its own file");
let mut app = WordApp::new(
Session::new(wordcraft_doc::Document::new()),
Services {
print: Some(Box::new({
let dir = dir.clone();
move |bytes| print_with(&dir, bytes, |_| Err("no PDF viewer".into()))
})),
..Default::default()
},
);
let e = app.run("ui.print", json!({})).unwrap_err();
assert!(e.contains("no PDF viewer"), "{e}");
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2, "the failed print's file is removed");
let _ = std::fs::remove_dir_all(&dir);
}
/// The clean-up removes old print files only.
#[test]
fn stale_print_files_are_removed() {
let dir = temp_dir("stale");
let fresh = write_temp(&dir, b"%PDF-1.7").unwrap();
let old = write_temp(&dir, b"%PDF-1.7").unwrap();
let hour_ago = SystemTime::now() - Duration::from_secs(2 * 60 * 60);
OpenOptions::new().write(true).open(&old).unwrap().set_modified(hour_ago).unwrap();
let other = dir.join("notes.pdf");
std::fs::write(&other, b"keep").unwrap();
OpenOptions::new().write(true).open(&other).unwrap().set_modified(hour_ago).unwrap();
assert_eq!(remove_stale(&dir, SystemTime::now(), KEEP), 1);
assert!(fresh.exists() && other.exists() && !old.exists());
assert_eq!(remove_stale(&dir.join("missing"), SystemTime::now(), KEEP), 0, "a missing folder is fine");
let _ = std::fs::remove_dir_all(&dir);
fn shutdown_waits_for_pending_submission_workers() {
let (release, wait) = std::sync::mpsc::channel();
let (completed, completion) = std::sync::mpsc::channel();
let worker = std::thread::spawn(move || {
wait.recv().unwrap();
completed.send(()).unwrap();
});
JOBS.lock().unwrap().push(worker);
let shutdown = std::thread::spawn(finish_pending_jobs);
assert!(completion.try_recv().is_err(), "the print worker is still pending");
release.send(()).unwrap();
shutdown.join().unwrap();
completion.recv().unwrap();
assert!(JOBS.lock().unwrap().is_empty(), "workers were joined before shutdown returned");
}
}
+30
View File
@@ -0,0 +1,30 @@
//! The system region new documents follow (#838): the paper File › New starts on, the default
//! measurement unit and the decimal separator.
//!
//! - Windows: the preferred language with the home location from Settings › Time & language ›
//! Region (`GlobalizationPreferences.HomeGeographicRegion`, a safe Windows Runtime API), since
//! the language list alone says `en-US` for an English interface in Poland.
//! - Elsewhere: the locale the UI crate reads ([`wordcraft_ui_egui::i18n::system_region`]).
/// The system region.
pub fn system() -> wordcraft_engine::Region {
// Runs without preferences (agents' and screenshot runs) behave the same on every machine:
// en-US unless `WORDCRAFT_REGION` says otherwise.
if std::env::var_os("WORDCRAFT_NO_PREFS").is_some() && std::env::var_os("WORDCRAFT_REGION").is_none() {
return wordcraft_engine::Region::default();
}
let region = wordcraft_ui_egui::i18n::system_region();
#[cfg(target_os = "windows")]
{
if std::env::var_os("WORDCRAFT_REGION").is_none() {
return match windows::System::UserProfile::GlobalizationPreferences::HomeGeographicRegion() {
Ok(home) => region.with_territory(&home.to_string()),
Err(e) => {
log::debug!("system home region: {e}");
region
}
};
}
}
region
}
File diff suppressed because it is too large Load Diff
+503
View File
@@ -0,0 +1,503 @@
% title: Hyphenation patterns for Portuguese
% copyright: Copyright (C) 1987, 1994, 1996, 2015 Pedro J. de Rezende, 1996, 2015 J. Joao Dias Almeida, 2024 Leonardo Araujo and Aline Benevides
% notice: This file is part of the hyph-utf8 package.
% See http://www.hyphenation.org/tex for more information.
% language:
% name: Portuguese
% tag: pt
% version: 1.4 2024-07-13
% authors:
% -
% name: Pedro J. de Rezende
% contact: rezende (at) ic.unicamp.br
% -
% name: J. Joao Dias Almeida
% contact: jj (at) di.uminho.pt
% -
% name: Leonardo Araujo
% contact: leolca (at) gmail.com
% -
% name: Aline Benevides
% contact: benevides.aline12 (at) gmail.com
% licence:
% name: BSD 3-clause licence
% url: https://opensource.org/licenses/BSD-3-Clause
% text: >
% Redistribution and use in source and binary forms, with or without
% modification, are permitted provided that the following conditions
% are met:
% * Redistributions of source code must retain the above copyright
% notice, this list of conditions and the following disclaimer.
% * Redistributions in binary form must reproduce the above copyright
% notice, this list of conditions and the following disclaimer in the
% documentation and/or other materials provided with the
% distribution.
% * Neither the name of the University of Campinas, of the University
% of Minho nor the names of its contributors may be used to endorse
% or promote products derived from this software without specific
% prior written permission.
%
% THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
% "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
% LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
% A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL PEDRO J. DE
% REZENDE OR J.JOAO DIAS ALMEIDA BE LIABLE FOR ANY DIRECT, INDIRECT,
% INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
% BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS
% OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
% AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
% LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY
% WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
% POSSIBILITY OF SUCH DAMAGE.
% hyphenmins:
% typesetting:
% left: 2
% right: 3
% changes:
% - Version 1.4 Release date: 13/07/2024 Leonardo Araujo and Aline Benevides
% - Version 1.3 Release date: 12/08/2015 Pedro J. de Rezende and J. Joao Dias Almeida
% - Version 1.2 Release date: 07/21/1996 Pedro J. de Rezende and J. Joao Dias Almeida
% - Version 1.1 Release date: 04/12/1994 Pedro J. de Rezende
% - Version 1.0 Release date: 02/13/1987 Pedro J. de Rezende
% texlive:
% synonyms:
% - portuges
% encoding: ec
% babelname: portuguese
% legacy_patterns: pthyph.tex
% message: Portuguese hyphenation patterns
% description: Hyphenation patterns for Portuguese in T1/EC and UTF-8 encodings.
%
\patterns{
1b2l
1b2r
1ba
1be
1bi
1bo
1bu
1bá
1bâ
1bã
1bé
1bí
1bó
1bú
1bê
1bõ
1c2h
1c2l
1c2r
1ca
1ce
1ci
1co
1cu
1cá
1câ
1cã
1cé
1cí
1có
1cú
1cê
1cõ
1ça
1çe
1çi
1ço
1çu
1çá
1çâ
1çã
1çé
1çí
1çó
1çú
1çê
1çõ
1d2l
1d2r
1da
1de
1di
1do
1du
1dá
1dâ
1dã
1dé
1dí
1dó
1dú
1dê
1dõ
1f2l
1f2r
1fa
1fe
1fi
1fo
1fu
1fá
1fâ
1fã
1fé
1fí
1fó
1fú
1fê
1fõ
1g2l
1g2r
1ga
1ge
1gi
1go
1gu
1gu4a
1gu4e
1gu4i
1gu4o
1gá
1gâ
1gã
1gé
1gí
1gó
1gú
1gê
1gõ
1ja
1je
1ji
1jo
1ju
1já
1jâ
1jã
1jé
1jí
1jó
1jú
1jê
1jõ
1k2l
1k2r
1ka
1ke
1ki
1ko
1ku
1ká
1kâ
1kã
1ké
1kí
1kó
1kú
1kê
1kõ
1l2h
1la
1le
1li
1lo
1lu
1lá
1lâ
1lã
1lé
1lí
1ló
1lú
1lê
1lõ
1ma
1me
1mi
1mo
1mu
1má
1mâ
1mã
1mé
1mí
1mó
1mú
1mê
1mõ
1n2h
1na
1ne
1ni
1no
1nu
1ná
1nâ
1nã
1né
1ní
1nó
1nú
1nê
1nõ
1p2l
1p2r
1pa
1pe
1pi
1po
1pu
1pá
1pâ
1pã
1pé
1pí
1pó
1pú
1pê
1põ
1qu4a
1qu4e
1qu4i
1qu4o
1ra
1re
1ri
1ro
1ru
1rá
1râ
1rã
1ré
1rí
1ró
1rú
1rê
1rõ
1sa
1se
1si
1so
1su
1sá
1sâ
1sã
1sé
1sí
1só
1sú
1sê
1sõ
1t2l
1t2r
1ta
1te
1ti
1to
1tu
1tá
1tâ
1tã
1té
1tí
1tó
1tú
1tê
1tõ
1v2l
1v2r
1va
1ve
1vi
1vo
1vu
1vá
1vâ
1vã
1vé
1ví
1vó
1vú
1vê
1võ
1w2l
1w2r
1xa
1xe
1xi
1xo
1xu
1xá
1xâ
1xã
1xé
1xí
1xó
1xú
1xê
1xõ
1za
1ze
1zi
1zo
1zu
1zá
1zâ
1zã
1zé
1zí
1zó
1zú
1zê
1zõ
a3a
a3e
a3o
c3c
e3a
e3e
e3o
i3a
i3e
i3i
i3o
i3â
i3ê
i3ô
o3a
o3e
o3o
r3r
s3s
u3a
u3e
u3o
u3u
.p2si
.p2sí
.g2no
.g2nó
.g2nô
t2c
1p2neu
.t2m
.p2t
su2b3r
su2b3l
.su3b4li
a1ir.
u1ir.
1qu
1vô
1lô
1cô
1gô
1bô
1tô
1rô
1pô
a1é
a1í
a1ó
a1ú
e1á
e1â
e1ã
e1é
e1ê
e1í
e1ó
é1o
e1ú
i1á
i1ã
í1a
i1é
i1í
i1ó
í1o
i1u
i1ú
o1á
o1é
o1í
o1ó
u1á
u1ã
u1â
u1í
ú1o
1qu2á
1qu2â
1qu2í
1gu2í
a1ind
a1i1nh
e1imp
e1inc
e1inf
e1ing
e1ins
e1int
e1inv
u1iz.
a1iz.
4a.
4e.
4o.
1gu2á
1gu2ã
1qu2ã
.m2n
c2za
.s2
1p2seu1d
1dô
1fô
1mô
1nô
1sô
1zô
tu1i
tu2it
tu2id
bu1i
nu1i
o1in
u1in
su1i
í1e
ju1i
fu1i
du1i
do1im
au1i
u1i1ç
u1ê
1gu2ê
1qu2ê
1çô
u1é
1gu2é
1qu2é
1xô
a1â
a1ã
a1ô
e1ô
.ne4o
o1ã
o1ê
o1im
o2i1na
pro1i1b
co2ima
.t2
1-
}
\hyphenation{% Do NOT make any alterations to this list! --- PdR
hard-ware
soft-ware
}
-1
View File
@@ -4,7 +4,6 @@
# inflection rules in crates/proof/src/lib.rs, so list "desktop", not "desktops".
airbag
airfare
artcraft
autocomplete
autocorrect
autofill
+32
View File
@@ -38,5 +38,37 @@ real_name = "Dmitrii Khlapov"
display_name = "Dima Khlapov"
sync_github_name = false
[people.DanielKnourek]
real_name = "Daniel Kňourek"
display_name = "Daniel Kňourek"
sync_github_name = false
[people.arcanii]
real_name = "Bryan Mark"
display_name = "Arcanii"
sync_github_name = false
[people.henno]
sync_github_name = true
[people.yeniufuklar-com]
real_name = "Ufuk Efe"
display_name = "Ufuk Efe"
sync_github_name = true
[people.GhostMitra]
[people.DevCoderMax]
real_name = "Gleyson do Nascimento Carvalho"
display_name = "Gleyson Carvalho"
sync_github_name = false
[people.alexanderalber]
real_name = "Alexander Alber"
[people.edurbs]
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
[people.manucarcor]
real_name = "Manuel Carmona"
display_name = "manucarcor"
[people.odedigo]
+26
View File
@@ -37,6 +37,26 @@ pub fn has_rtl(text: &str) -> bool {
text.chars().any(is_rtl_script)
}
/// Does the first strong directional character in `text` belong to a right-to-left script?
///
/// Follows UAX #9 rules P2 and P3: characters with strong bidirectional class AL or R
/// (Arabic, Hebrew, Syriac, Thaana…) give `true`; strong L characters (Latin, Greek,
/// Cyrillic, CJK…) give `false`. Neutral, weak and whitespace characters are skipped.
pub fn first_strong_is_rtl(text: &str) -> bool {
for c in text.chars() {
if c == '\u{200F}' {
return true;
}
if c == '\u{200E}' {
return false;
}
if c.is_alphabetic() {
return is_rtl_script(c);
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
@@ -56,5 +76,11 @@ mod tests {
assert!("می‌خواهم".chars().all(is_word_char), "ZWNJ is inside the word");
assert!("کِتاب".chars().all(is_word_char), "so are harakat");
assert!(!is_word_char(' ') && !is_word_char('،'));
assert!(first_strong_is_rtl("سلام"));
assert!(first_strong_is_rtl("من ناوم هێڤاره"));
assert!(first_strong_is_rtl(" 123. سلام"));
assert!(!first_strong_is_rtl("Hello سلام"));
assert!(!first_strong_is_rtl("123 Hello"));
assert!(!first_strong_is_rtl(""));
}
}
+373
View File
@@ -0,0 +1,373 @@
//! East Asian list and page number formats (ECMA-376 §17.18.59 ST_NumberFormat): Chinese,
//! Japanese and Korean counting systems (with units 十/百/千 and myriads 万/億), legal (financial)
//! numerals, digit-by-digit ideographs, the heavenly stems and earthly branches, kana and hangul
//! sequences and enclosed numbers. Written from the number systems' rules; a value outside a
//! sequence's range falls back to plain decimal.
/// Whether a counting system writes the "one" before a unit.
#[derive(Clone, Copy, PartialEq, Eq)]
enum OmitOne {
/// Always written: legal numerals (壹拾, 壱百).
Never,
/// Dropped only before a leading ten: 十, 十一, 十万 but 一百一十 (Chinese).
LeadingTen,
/// Dropped before 十, 百 and 千 inside every group of four: 十, 百, 千 but 一万 (Japanese,
/// Korean).
Units,
}
/// A myriad-based counting system (groups of four digits, like 1|2345|6789).
struct Counting {
/// The digits 1 to 9 (index 0 unused).
digits: [char; 10],
/// The units ten, hundred, thousand.
units: [char; 3],
/// The myriads: ten thousand, hundred million.
myriads: [char; 2],
/// The character written once for a run of zeros between two digits (Chinese 零); `None`
/// skips zeros (Japanese, Korean).
zero: Option<char>,
omit_one: OmitOne,
}
impl Counting {
fn format(&self, n: u32) -> String {
if n == 0 {
return self.zero.unwrap_or('〇').to_string();
}
let groups = [n / 100_000_000, (n / 10_000) % 10_000, n % 10_000];
let mut out = String::new();
let mut zero_pending = false;
for (gi, &g) in groups.iter().enumerate() {
if g == 0 {
zero_pending |= !out.is_empty();
continue;
}
let ds = [g / 1000, (g / 100) % 10, (g / 10) % 10, g % 10];
for (pos, &d) in ds.iter().enumerate() {
if d == 0 {
zero_pending |= !out.is_empty();
continue;
}
if zero_pending && let Some(z) = self.zero {
out.push(z);
}
zero_pending = false;
// pos 0 = thousands, 1 = hundreds, 2 = tens, 3 = ones.
let unit = match pos {
0 => self.units.get(2),
1 => self.units.get(1),
2 => self.units.first(),
_ => None,
};
let omit = d == 1
&& unit.is_some()
&& match self.omit_one {
OmitOne::Never => false,
OmitOne::LeadingTen => pos == 2 && out.is_empty(),
OmitOne::Units => true,
};
if !omit {
out.push(self.digits.get(d as usize).copied().unwrap_or('?'));
}
if let Some(&u) = unit {
out.push(u);
}
}
match gi {
0 => out.extend(self.myriads.get(1)),
1 => out.extend(self.myriads.first()),
_ => {}
}
}
out
}
}
const CN_DIGITS: [char; 10] = ['〇', '一', '二', '三', '四', '五', '六', '七', '八', '九'];
const CHINESE: Counting =
Counting { digits: CN_DIGITS, units: ['十', '百', '千'], myriads: ['万', '亿'], zero: Some('零'), omit_one: OmitOne::LeadingTen };
const TAIWANESE: Counting =
Counting { digits: CN_DIGITS, units: ['十', '百', '千'], myriads: ['萬', '億'], zero: Some('零'), omit_one: OmitOne::LeadingTen };
const CHINESE_LEGAL: Counting = Counting {
digits: ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖'],
units: ['拾', '佰', '仟'],
myriads: ['万', '亿'],
zero: Some('零'),
omit_one: OmitOne::Never,
};
const TRADITIONAL_LEGAL: Counting = Counting {
digits: ['零', '壹', '貳', '參', '肆', '伍', '陸', '柒', '捌', '玖'],
units: ['拾', '佰', '仟'],
myriads: ['萬', '億'],
zero: Some('零'),
omit_one: OmitOne::Never,
};
const JAPANESE: Counting =
Counting { digits: CN_DIGITS, units: ['十', '百', '千'], myriads: ['万', '億'], zero: None, omit_one: OmitOne::Units };
const JAPANESE_LEGAL: Counting = Counting {
digits: ['〇', '壱', '弐', '参', '四', '伍', '六', '七', '八', '九'],
units: ['拾', '百', '阡'],
myriads: ['萬', '億'],
zero: None,
omit_one: OmitOne::Never,
};
const KOREAN: Counting = Counting {
digits: ['영', '일', '이', '삼', '사', '오', '육', '칠', '팔', '구'],
units: ['십', '백', '천'],
myriads: ['만', '억'],
zero: None,
omit_one: OmitOne::Units,
};
/// 一, 二 … 十, 十一 … 九十九 (Chinese counting); from 100 on, digit by digit (一〇〇).
pub(crate) fn chinese_counting(n: u32) -> String {
if n < 100 { CHINESE.format(n) } else { digits(n, &CN_DIGITS) }
}
/// Chinese counting with every unit: 一百零一, 一千, 一万.
pub(crate) fn chinese_counting_thousand(n: u32) -> String {
CHINESE.format(n)
}
/// Simplified Chinese legal (financial) numerals: 壹, 贰, 叁 … 壹拾.
pub(crate) fn chinese_legal_simplified(n: u32) -> String {
CHINESE_LEGAL.format(n)
}
/// Traditional Chinese legal numerals: 壹, 貳, 參 … 壹拾.
pub(crate) fn ideograph_legal_traditional(n: u32) -> String {
TRADITIONAL_LEGAL.format(n)
}
/// Taiwanese counting: like Chinese counting below 100, digit by digit above.
pub(crate) fn taiwanese_counting(n: u32) -> String {
if n < 100 { TAIWANESE.format(n) } else { digits(n, &CN_DIGITS) }
}
/// Taiwanese counting with every unit: 一百零一, 一千, 一萬.
pub(crate) fn taiwanese_counting_thousand(n: u32) -> String {
TAIWANESE.format(n)
}
/// Japanese counting: 一 … 十, 十一, 百, 千, 一万.
pub(crate) fn japanese_counting(n: u32) -> String {
JAPANESE.format(n)
}
/// Japanese legal numerals: 壱, 弐, 参 … 壱拾.
pub(crate) fn japanese_legal(n: u32) -> String {
JAPANESE_LEGAL.format(n)
}
/// Japanese digits with 万/億 marking each group of four: 一二, 一万二三四五.
pub(crate) fn japanese_digital_ten_thousand(n: u32) -> String {
let groups = [(n / 100_000_000, Some('億')), ((n / 10_000) % 10_000, Some('万')), (n % 10_000, None)];
let mut out = String::new();
for (g, mark) in groups {
let s = digits(g, &CN_DIGITS);
if out.is_empty() {
if g == 0 && mark.is_some() {
continue;
}
} else {
// Inner groups keep their four places: 一万〇〇〇一.
out.extend(std::iter::repeat_n('〇', 4usize.saturating_sub(s.chars().count())));
}
out.push_str(&s);
out.extend(mark);
}
out
}
/// Sino-Korean numbers in hangul: 일, 이, 삼 … 십, 십일, 백.
pub(crate) fn korean_counting(n: u32) -> String {
KOREAN.format(n)
}
/// Sino-Korean digits in hangul, one per digit: 일, 일영, 일일.
pub(crate) fn korean_digital(n: u32) -> String {
digits(n, &KOREAN.digits)
}
const KO_ONES: [&str; 10] = ["", "하나", "둘", "셋", "넷", "다섯", "여섯", "일곱", "여덟", "아홉"];
const KO_TENS: [&str; 10] = ["", "열", "스물", "서른", "마흔", "쉰", "예순", "일흔", "여든", "아흔"];
/// Native Korean numbers: 하나, 둘, 셋 … 열, 열하나 … 아흔아홉 (decimal from 100 on).
pub(crate) fn korean_legal(n: u32) -> String {
if n == 0 || n > 99 {
return n.to_string();
}
let (t, o) = ((n / 10) as usize, (n % 10) as usize);
format!("{}{}", KO_TENS.get(t).copied().unwrap_or(""), KO_ONES.get(o).copied().unwrap_or(""))
}
/// One ideograph per decimal digit: 一, 一〇, 一〇一.
pub(crate) fn ideograph_digital(n: u32) -> String {
digits(n, &CN_DIGITS)
}
fn digits(n: u32, set: &[char; 10]) -> String {
n.to_string().bytes().map(|b| set.get(b.saturating_sub(b'0') as usize).copied().unwrap_or('?')).collect()
}
const STEMS: [char; 10] = ['甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸'];
const BRANCHES: [char; 12] = ['子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥'];
/// The ten heavenly stems: 甲, 乙, 丙 … 癸.
pub(crate) fn ideograph_traditional(n: u32) -> String {
nth(&STEMS, n)
}
/// The twelve earthly branches: 子, 丑, 寅 … 亥.
pub(crate) fn ideograph_zodiac(n: u32) -> String {
nth(&BRANCHES, n)
}
/// The sixty-year cycle: 甲子, 乙丑, 丙寅 … 癸亥.
pub(crate) fn ideograph_zodiac_traditional(n: u32) -> String {
if !(1..=60).contains(&n) {
return n.to_string();
}
let i = (n - 1) as usize;
[STEMS.get(i % 10), BRANCHES.get(i % 12)].into_iter().flatten().collect()
}
/// The `n`th (1-based) item of a sequence, or decimal past its end.
fn nth(set: &[char], n: u32) -> String {
n.checked_sub(1).and_then(|i| set.get(i as usize)).map(|c| c.to_string()).unwrap_or_else(|| n.to_string())
}
/// Code points `first..` enclosing 1 to `count`, decimal beyond.
fn enclosed(n: u32, first: u32, count: u32) -> String {
if (1..=count).contains(&n)
&& let Some(c) = char::from_u32(first + n - 1)
{
return c.to_string();
}
n.to_string()
}
/// ① ② ③ … ⑳.
pub(crate) fn decimal_enclosed_circle(n: u32) -> String {
enclosed(n, 0x2460, 20)
}
/// ① … ⑩ (the Chinese set stops at ten).
pub(crate) fn decimal_enclosed_circle_chinese(n: u32) -> String {
enclosed(n, 0x2460, 10)
}
/// ⒈ ⒉ ⒊ … ⒛.
pub(crate) fn decimal_enclosed_fullstop(n: u32) -> String {
enclosed(n, 0x2488, 20)
}
/// ⑴ ⑵ ⑶ … ⒇.
pub(crate) fn decimal_enclosed_paren(n: u32) -> String {
enclosed(n, 0x2474, 20)
}
/// ㊀ ㊁ ㊂ … ㊉ (circled ideographs).
pub(crate) fn ideograph_enclosed_circle(n: u32) -> String {
enclosed(n, 0x3280, 10)
}
/// Full-width digits: 1, 2 … 10.
pub(crate) fn decimal_full_width(n: u32) -> String {
n.to_string().chars().map(|c| c.to_digit(10).and_then(|d| char::from_u32(0xFF10 + d)).unwrap_or(c)).collect()
}
const AIUEO: &str = "アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン";
const AIUEO_HALF: &str = "アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン";
const IROHA: &str = "イロハニホヘトチリヌルヲワカヨタレソツネナラムウヰノオクヤマケフコエテアサキユメミシヱヒモセス";
const IROHA_HALF: &str = "イロハニホヘトチリヌルヲワカヨタレソツネナラムウヰノオクヤマケフコエテアサキユメミシヱヒモセス";
const GANADA: &str = "가나다라마바사아자차카타파하";
const CHOSUNG: &str = "ㄱㄴㄷㄹㅁㅂㅅㅇㅈㅊㅋㅌㅍㅎ";
fn nth_of(set: &str, n: u32) -> String {
n.checked_sub(1).and_then(|i| set.chars().nth(i as usize)).map(|c| c.to_string()).unwrap_or_else(|| n.to_string())
}
/// Half-width katakana in gojūon order: ア, イ, ウ …
pub(crate) fn aiueo(n: u32) -> String {
nth_of(AIUEO_HALF, n)
}
/// Full-width katakana in gojūon order: ア, イ, ウ …
pub(crate) fn aiueo_full_width(n: u32) -> String {
nth_of(AIUEO, n)
}
/// Half-width katakana in iroha order: イ, ロ, ハ …
pub(crate) fn iroha(n: u32) -> String {
nth_of(IROHA_HALF, n)
}
/// Full-width katakana in iroha order: イ, ロ, ハ …
pub(crate) fn iroha_full_width(n: u32) -> String {
nth_of(IROHA, n)
}
/// Hangul syllables: 가, 나, 다 …
pub(crate) fn ganada(n: u32) -> String {
nth_of(GANADA, n)
}
/// Hangul initial consonants: ㄱ, ㄴ, ㄷ …
pub(crate) fn chosung(n: u32) -> String {
nth_of(CHOSUNG, n)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chinese_counting_systems() {
let c = |n| chinese_counting(n);
assert_eq!([c(1), c(2), c(3), c(10), c(11), c(20), c(21), c(99)], ["一", "二", "三", "十", "十一", "二十", "二十一", "九十九"]);
assert_eq!([c(100), c(101), c(1000)], ["一〇〇", "一〇一", "一〇〇〇"]);
let t = |n| chinese_counting_thousand(n);
assert_eq!(
[t(10), t(11), t(20), t(101), t(110), t(111), t(1000), t(1010)],
["十", "十一", "二十", "一百零一", "一百一十", "一百一十一", "一千", "一千零一十"]
);
assert_eq!([t(10_000), t(10_001), t(100_100), t(1_000_000), t(100_000_001)], ["一万", "一万零一", "十万零一百", "一百万", "一亿零一"]);
assert_eq!(t(u32::MAX), "四十二亿九千四百九十六万七千二百九十五");
let l = |n| chinese_legal_simplified(n);
assert_eq!([l(1), l(3), l(10), l(11), l(101), l(1000)], ["壹", "叁", "壹拾", "壹拾壹", "壹佰零壹", "壹仟"]);
assert_eq!([taiwanese_counting_thousand(10_001), ideograph_legal_traditional(20)], ["一萬零一", "貳拾"]);
assert_eq!([ideograph_digital(10), ideograph_digital(101)], ["一〇", "一〇一"]);
}
#[test]
fn japanese_and_korean() {
let j = |n| japanese_counting(n);
assert_eq!([j(10), j(11), j(20), j(101), j(1000), j(10_000), j(10_001)], ["十", "十一", "二十", "百一", "千", "一万", "一万一"]);
assert_eq!([japanese_legal(10), japanese_legal(1000)], ["壱拾", "壱阡"]);
let d = |n| japanese_digital_ten_thousand(n);
assert_eq!([d(12), d(10_000), d(12_345), d(100_000_000)], ["一二", "一万〇〇〇〇", "一万二三四五", "一億〇〇〇〇万〇〇〇〇"]);
assert_eq!([korean_counting(11), korean_counting(20), korean_counting(101)], ["십일", "이십", "백일"]);
assert_eq!(
[korean_digital(10), korean_legal(1), korean_legal(11), korean_legal(20), korean_legal(100)],
["일영", "하나", "열하나", "스물", "100"]
);
}
#[test]
fn sequences_and_enclosed_numbers() {
assert_eq!([ideograph_traditional(1), ideograph_traditional(10), ideograph_traditional(11)], ["甲", "癸", "11"]);
assert_eq!([ideograph_zodiac(1), ideograph_zodiac(12), ideograph_zodiac(13)], ["子", "亥", "13"]);
assert_eq!([ideograph_zodiac_traditional(1), ideograph_zodiac_traditional(11), ideograph_zodiac_traditional(60)], ["甲子", "甲戌", "癸亥"]);
assert_eq!([decimal_enclosed_circle(1), decimal_enclosed_circle(20), decimal_enclosed_circle(21)], ["①", "⑳", "21"]);
assert_eq!([decimal_enclosed_circle_chinese(10), decimal_enclosed_circle_chinese(11)], ["⑩", "11"]);
assert_eq!([decimal_enclosed_fullstop(1), decimal_enclosed_paren(20), ideograph_enclosed_circle(1)], ["⒈", "⒇", "㊀"]);
assert_eq!(decimal_full_width(10), "10");
assert_eq!([aiueo_full_width(1), aiueo_full_width(46), aiueo_full_width(47), aiueo(2)], ["ア", "ン", "47", "イ"]);
assert_eq!([iroha_full_width(1), iroha_full_width(47), iroha(3)], ["イ", "ス", "ハ"]);
assert_eq!([ganada(1), chosung(14), ganada(0)], ["가", "ㅎ", "0"]);
assert_eq!([AIUEO.chars().count(), AIUEO_HALF.chars().count(), IROHA.chars().count(), IROHA_HALF.chars().count()], [46, 46, 47, 47]);
}
}
+236
View File
@@ -0,0 +1,236 @@
//! Text clusters for complex scripts (Myanmar, Khmer, Tai Tham, the Indic scripts): where the
//! caret may stop, what Backspace removes, and where Myanmar text may wrap.
//!
//! Extended grapheme clusters (UAX #29) split some syllables that a shaper draws as one unit: a
//! Myanmar consonant stacked under a virama (`မ္ဘ`), the Myanmar vowel signs U+102B/U+102C and
//! the visarga U+1038 (excluded from the grapheme `SpacingMark` class), Khmer subscript
//! consonants after the coeng. A caret between those would sit inside a glyph cluster, so the
//! caret steps and layout clusters both use [`joins_cluster`] on top of the grapheme rules.
use unicode_segmentation::UnicodeSegmentation;
/// Invisible stackers: the next consonant is drawn under this one (Myanmar virama, Khmer coeng,
/// Tai Tham sakot).
fn is_stacker(c: char) -> bool {
matches!(u32::from(c), 0x1039 | 0x17D2 | 0x1A60)
}
/// Dependent vowel signs, medials, tone marks and other combining signs of Myanmar, Khmer and
/// Tai Tham: never the start of a cluster.
fn is_dependent(c: char) -> bool {
matches!(u32::from(c),
0x102B..=0x103E
| 0x1056..=0x1059
| 0x105E..=0x1060
| 0x1062..=0x1064
| 0x1067..=0x106D
| 0x1071..=0x1074
| 0x1082..=0x108D
| 0x108F
| 0x109A..=0x109D
| 0xA9E5
| 0xAA7B..=0xAA7D
| 0x17B4..=0x17D1
| 0x17D3
| 0x17DD
| 0x1A55..=0x1A5E
| 0x1A61..=0x1A7C
| 0x1A7F)
}
/// No caret stop between `prev` and `next` although a grapheme boundary falls there: `next` is a
/// sign that belongs to the syllable before it, or `prev` stacks the next consonant.
pub fn joins_cluster(prev: char, next: char) -> bool {
is_stacker(prev) || is_dependent(next)
}
/// Is `off` a cluster boundary of `text` (0 and `text.len()` always are)?
pub fn is_cluster_boundary(text: &str, off: usize) -> bool {
if off == 0 || off >= text.len() {
return true;
}
if !text.is_char_boundary(off) {
return false;
}
let mut g = unicode_segmentation::GraphemeCursor::new(off, text.len(), true);
if !g.is_boundary(text, 0).unwrap_or(true) {
return false;
}
let prev = text.get(..off).and_then(|t| t.chars().next_back());
let next = text.get(off..).and_then(|t| t.chars().next());
!matches!((prev, next), (Some(p), Some(n)) if joins_cluster(p, n))
}
/// Byte offsets where clusters start (graphemes, joined per [`joins_cluster`]), in order; the
/// first is 0 for non-empty text.
pub fn cluster_starts(text: &str) -> Vec<usize> {
let mut out: Vec<usize> = Vec::new();
let mut prev: Option<char> = None;
for (i, g) in text.grapheme_indices(true) {
let first = g.chars().next();
let joined = matches!((prev, first), (Some(p), Some(n)) if joins_cluster(p, n));
if !joined {
out.push(i);
}
prev = g.chars().next_back();
}
out
}
/// `off` clamped to `text` and moved back to a character boundary.
fn char_floor(text: &str, off: usize) -> usize {
let mut o = off.min(text.len());
while o > 0 && !text.is_char_boundary(o) {
o -= 1;
}
o
}
/// The previous cluster boundary before `off` (0 at the start).
pub fn prev_cluster(text: &str, off: usize) -> usize {
let mut off = char_floor(text, off);
loop {
let mut c = unicode_segmentation::GraphemeCursor::new(off, text.len(), true);
off = c.prev_boundary(text, 0).ok().flatten().unwrap_or(0);
if is_cluster_boundary(text, off) {
return off;
}
}
}
/// The next cluster boundary after `off` (`text.len()` at the end).
pub fn next_cluster(text: &str, off: usize) -> usize {
let mut off = char_floor(text, off);
loop {
let mut c = unicode_segmentation::GraphemeCursor::new(off, text.len(), true);
off = c.next_boundary(text, 0).ok().flatten().unwrap_or(text.len());
if is_cluster_boundary(text, off) {
return off;
}
}
}
/// Scripts whose Backspace removes one character at a time (a vowel sign, a medial, a stacked
/// consonant) rather than the whole cluster, as on Windows and macOS: the Brahmic scripts of
/// South and South-East Asia.
pub fn backspaces_by_char(c: char) -> bool {
matches!(u32::from(c),
0x0900..=0x0DFF // Devanagari … Sinhala
| 0x0E00..=0x0EFF // Thai, Lao
| 0x0F00..=0x0FFF // Tibetan
| 0x1000..=0x109F // Myanmar
| 0x1780..=0x17FF // Khmer
| 0x1A20..=0x1AAF // Tai Tham
| 0x1B00..=0x1B7F // Balinese
| 0xA980..=0xA9FF // Javanese, Myanmar Extended-B
| 0xAA60..=0xAA7F) // Myanmar Extended-A
}
/// A Myanmar character that starts a syllable: a consonant (with the Mon, Shan and Karen ones),
/// an independent vowel, the great sa.
fn myanmar_syllable_start(c: char) -> bool {
matches!(u32::from(c), 0x1000..=0x102A | 0x103F | 0x1050..=0x1055 | 0x105A..=0x105D | 0x1061 | 0x1065..=0x1066 | 0x106E..=0x1070 | 0x1075..=0x1081 | 0x108E)
}
/// Line-break opportunities inside Myanmar text, which has no spaces between words: before each
/// syllable (UAX #14 leaves class SA to a dictionary; Word breaks Myanmar between syllables). A
/// consonant starts a syllable unless it is stacked (after U+1039) or a final (followed, perhaps
/// after a medial, by the asat U+103A, the virama U+1039 or the dot below U+1037). Byte offsets,
/// sorted; only between two Myanmar characters.
pub fn myanmar_breaks(text: &str) -> Vec<usize> {
let chars: Vec<(usize, char)> = text.char_indices().collect();
let myanmar = |c: char| matches!(u32::from(c), 0x1000..=0x109F | 0xA9E0..=0xA9FF | 0xAA60..=0xAA7F);
let mut out = Vec::new();
for k in 1..chars.len() {
let (Some(&(i, c)), Some(&(_, prev))) = (chars.get(k), chars.get(k - 1)) else { continue };
if !myanmar(prev) || !myanmar_syllable_start(c) || is_stacker(prev) {
continue;
}
// Skip one medial ya/ha, then look for a killer.
let mut j = k + 1;
if chars.get(j).is_some_and(|&(_, n)| matches!(n, '\u{103B}' | '\u{103E}')) {
j += 1;
}
if chars.get(j).is_some_and(|&(_, n)| matches!(n, '\u{1037}' | '\u{1039}' | '\u{103A}')) {
continue;
}
out.push(i);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// "မြန်မာစာ" (Myanmar script): မြ | န် | မာ | စာ.
const MYANMAR: &str = "\u{1019}\u{103C}\u{1014}\u{103A}\u{1019}\u{102C}\u{1005}\u{102C}";
fn split<'a>(s: &'a str, at: &[usize]) -> Vec<&'a str> {
let mut v = Vec::new();
for (n, &a) in at.iter().enumerate() {
let b = at.get(n + 1).copied().unwrap_or(s.len());
v.push(&s[a..b]);
}
v
}
#[test]
fn myanmar_vowel_signs_stay_in_their_syllable() {
// Graphemes alone would cut "မာ" before the aa sign U+102C.
assert_eq!(split(MYANMAR, &cluster_starts(MYANMAR)), vec!["မြ", "န်", "မာ", "စာ"]);
// The prevowel ေ (U+1031) follows its consonant in memory: one cluster, as is visarga း.
assert_eq!(split("ကြေး", &cluster_starts("ကြေး")), vec!["ကြေး"]);
// Stacked consonants (virama U+1039) and kinzi join the syllable they hang from.
let stacked = "\u{1019}\u{1039}\u{1018}\u{102C}"; // မ္ဘာ
assert_eq!(cluster_starts(stacked), vec![0]);
let kinzi = "\u{1021}\u{1004}\u{103A}\u{1039}\u{1002}\u{101C}\u{102D}\u{1015}\u{103A}"; // အင်္ဂလိပ်
assert_eq!(split(kinzi, &cluster_starts(kinzi)), vec!["အ", "င်္ဂ", "လိ", "ပ်"]);
}
#[test]
fn khmer_subscripts_and_indic_conjuncts_are_one_cluster() {
// ខ្មែរ: ខ + coeng + ម + ែ, then រ.
assert_eq!(split("ខ្មែរ", &cluster_starts("ខ្មែរ")), vec!["ខ្មែ", "រ"]);
// Devanagari conjuncts are grapheme clusters already.
assert_eq!(split("क्षि हि", &cluster_starts("क्षि हि")), vec!["क्षि", " ", "हि"]);
// Latin is untouched: one cluster per letter, combining accents included.
assert_eq!(split("e\u{301}a", &cluster_starts("e\u{301}a")), vec!["e\u{301}", "a"]);
}
#[test]
fn caret_steps_skip_over_whole_clusters() {
let s = MYANMAR;
assert_eq!(next_cluster(s, 0), 6);
assert_eq!(next_cluster(s, 12), 18, "past the aa sign, not before it");
assert_eq!(prev_cluster(s, 18), 12);
assert_eq!(prev_cluster(s, 0), 0);
assert_eq!(next_cluster(s, s.len()), s.len());
assert!(!is_cluster_boundary(s, 15) && is_cluster_boundary(s, 12));
// Hostile offsets: past the end, inside a character.
assert_eq!(next_cluster(s, 1_000), s.len());
assert!(!is_cluster_boundary(s, 1));
assert_eq!(next_cluster(s, 1), 6);
assert_eq!(prev_cluster(s, 13), 6);
assert_eq!(prev_cluster("", 5), 0);
assert_eq!(next_cluster("", 0), 0);
}
#[test]
fn myanmar_wraps_between_syllables() {
// မြန်မာစာ: not before န (န် is a final, killed by the asat), so before မာ and စာ.
assert_eq!(myanmar_breaks(MYANMAR), vec![12, 18]);
// Never before a stacked consonant or one followed by medial + asat.
assert!(myanmar_breaks("\u{1019}\u{1039}\u{1018}\u{102C}").is_empty());
assert!(myanmar_breaks("ကျွန်").is_empty(), "န် is the final of ကျွန်");
// Not between Latin and Myanmar (UAX #14 handles that) or inside Latin.
assert!(myanmar_breaks("abcက").is_empty());
assert!(myanmar_breaks("").is_empty());
}
#[test]
fn backspace_by_character_in_brahmic_scripts_only() {
assert!(backspaces_by_char('ေ') && backspaces_by_char('ि') && backspaces_by_char('្'));
assert!(!backspaces_by_char('\u{301}') && !backspaces_by_char('a') && !backspaces_by_char('語'));
}
}
+520
View File
@@ -0,0 +1,520 @@
//! Language-aware sorting of text: a small, pure-Rust collation.
//!
//! Every letter gets a *primary* weight (its place in the alphabet) and a *secondary* one (its
//! diacritic). Strings compare by primary weights first, then by secondary ones, so `é` sorts
//! with `e` (`resume` < `résumé` < `rex`) unless the language makes it a letter of its own:
//! Polish puts `ą` right after `a` and `ż` after `ź` after `z`, Swedish puts `å ä ö` after `z`,
//! Czech puts `ch` after `h`, Spanish `ñ` after `n`, Hungarian `cs`, `gy`, `sz`… after their
//! first letter. The per-language tables follow the published alphabets of those languages.
//! Text without diacritics sorts exactly as a case-insensitive code-point compare.
use std::cmp::Ordering;
/// The diacritic of a precomposed lowercase Latin letter: `(base letters, mark)`. Marks: 1 acute,
/// 2 grave, 3 circumflex, 4 diaeresis, 5 tilde, 6 ring, 7 caron, 8 cedilla, 9 ogonek, 10 dot,
/// 11 stroke, 12 breve, 13 macron, 14 double acute, 15 comma below, 16 ligature, 17 dotless,
/// 18 eth.
const DECOMP: &[(char, &str, u16)] = &[
('à', "a", 2),
('á', "a", 1),
('â', "a", 3),
('ã', "a", 5),
('ä', "a", 4),
('å', "a", 6),
('ā', "a", 13),
('ă', "a", 12),
('ą', "a", 9),
('æ', "ae", 16),
('ç', "c", 8),
('ć', "c", 1),
('ĉ', "c", 3),
('ċ', "c", 10),
('č', "c", 7),
('ď', "d", 7),
('đ', "d", 11),
('ð', "d", 18),
('è', "e", 2),
('é', "e", 1),
('ê', "e", 3),
('ë', "e", 4),
('ē', "e", 13),
('ĕ', "e", 12),
('ė', "e", 10),
('ę', "e", 9),
('ě', "e", 7),
('ĝ', "g", 3),
('ğ', "g", 12),
('ġ', "g", 10),
('ģ', "g", 8),
('ĥ', "h", 3),
('ħ', "h", 11),
('ì', "i", 2),
('í', "i", 1),
('î', "i", 3),
('ï', "i", 4),
('ĩ', "i", 5),
('ī', "i", 13),
('ĭ', "i", 12),
('į', "i", 9),
('ı', "i", 17),
('ij', "ij", 16),
('ĵ', "j", 3),
('ķ', "k", 8),
('ĺ', "l", 1),
('ļ', "l", 8),
('ľ', "l", 7),
('ŀ', "l", 10),
('ł', "l", 11),
('ñ', "n", 5),
('ń', "n", 1),
('ņ', "n", 8),
('ň', "n", 7),
('ò', "o", 2),
('ó', "o", 1),
('ô', "o", 3),
('õ', "o", 5),
('ö', "o", 4),
('ø', "o", 11),
('ō', "o", 13),
('ŏ', "o", 12),
('ő', "o", 14),
('œ', "oe", 16),
('ŕ', "r", 1),
('ŗ', "r", 8),
('ř', "r", 7),
('ś', "s", 1),
('ŝ', "s", 3),
('ş', "s", 8),
('š', "s", 7),
('ș', "s", 15),
('ß', "ss", 16),
('ţ', "t", 8),
('ť', "t", 7),
('ŧ', "t", 11),
('ț', "t", 15),
('ù', "u", 2),
('ú', "u", 1),
('û', "u", 3),
('ü', "u", 4),
('ũ', "u", 5),
('ū', "u", 13),
('ŭ', "u", 12),
('ů', "u", 6),
('ű', "u", 14),
('ų', "u", 9),
('ŵ', "w", 3),
('ý', "y", 1),
('ÿ', "y", 4),
('ŷ', "y", 3),
('ź', "z", 1),
('ż', "z", 10),
('ž', "z", 7),
];
/// A letter (or letter group) a language sorts as a letter of its own: right after `after`
/// (`rank` places after it), with secondary weight `sec` among letters of the same place.
struct Tailor {
seq: &'static str,
after: char,
rank: u32,
sec: u16,
}
const fn t(seq: &'static str, after: char, rank: u32) -> Tailor {
Tailor { seq, after, rank, sec: 0 }
}
const fn ts(seq: &'static str, after: char, rank: u32, sec: u16) -> Tailor {
Tailor { seq, after, rank, sec }
}
const PL: &[Tailor] =
&[t("ą", 'a', 1), t("ć", 'c', 1), t("ę", 'e', 1), t("ł", 'l', 1), t("ń", 'n', 1), t("ó", 'o', 1), t("ś", 's', 1), t("ź", 'z', 1), t("ż", 'z', 2)];
const CS: &[Tailor] = &[t("č", 'c', 1), t("ch", 'h', 1), t("ř", 'r', 1), t("š", 's', 1), t("ž", 'z', 1)];
const SK: &[Tailor] = &[t("ä", 'a', 1), t("č", 'c', 1), t("ch", 'h', 1), t("ô", 'o', 1), t("š", 's', 1), t("ž", 'z', 1)];
const HU: &[Tailor] = &[
t("cs", 'c', 1),
t("dz", 'd', 1),
t("dzs", 'd', 2),
t("gy", 'g', 1),
t("ly", 'l', 1),
t("ny", 'n', 1),
t("ö", 'o', 1),
ts("ő", 'o', 1, 14),
t("sz", 's', 1),
t("ty", 't', 1),
t("ü", 'u', 1),
ts("ű", 'u', 1, 14),
t("zs", 'z', 1),
];
const SV: &[Tailor] = &[t("å", 'z', 1), t("ä", 'z', 2), ts("æ", 'z', 2, 16), t("ö", 'z', 3), ts("ø", 'z', 3, 11)];
const DA: &[Tailor] = &[t("æ", 'z', 1), ts("ä", 'z', 1, 4), t("ø", 'z', 2), ts("ö", 'z', 2, 4), t("å", 'z', 3)];
const ES: &[Tailor] = &[t("ñ", 'n', 1)];
const TR: &[Tailor] = &[t("ç", 'c', 1), t("ğ", 'g', 1), t("ı", 'h', 1), t("ö", 'o', 1), t("ş", 's', 1), t("ü", 'u', 1)];
const HR: &[Tailor] =
&[t("č", 'c', 1), t("ć", 'c', 2), t("dž", 'd', 1), t("đ", 'd', 2), t("lj", 'l', 1), t("nj", 'n', 1), t("š", 's', 1), t("ž", 'z', 1)];
const RO: &[Tailor] = &[t("ă", 'a', 1), t("â", 'a', 2), t("î", 'i', 1), t("ș", 's', 1), ts("ş", 's', 1, 8), t("ț", 't', 1), ts("ţ", 't', 1, 8)];
const ET: &[Tailor] = &[t("š", 's', 1), t("z", 's', 2), t("ž", 's', 3), t("õ", 'w', 1), t("ä", 'w', 2), t("ö", 'w', 3), t("ü", 'w', 4)];
const LT: &[Tailor] = &[t("č", 'c', 1), t("š", 's', 1), t("ž", 'z', 1)];
const LV: &[Tailor] = &[t("č", 'c', 1), t("ģ", 'g', 1), t("ķ", 'k', 1), t("ļ", 'l', 1), t("ņ", 'n', 1), t("š", 's', 1), t("ž", 'z', 1)];
const IS: &[Tailor] = &[
t("á", 'a', 1),
t("ð", 'd', 1),
t("é", 'e', 1),
t("í", 'i', 1),
t("ó", 'o', 1),
t("ú", 'u', 1),
t("ý", 'y', 1),
t("þ", 'z', 1),
t("æ", 'z', 2),
t("ö", 'z', 3),
];
/// The primary language subtag of a BCP 47 tag (`pl-PL` → `pl`), lowercased.
pub fn language(lang: Option<&str>) -> String {
lang.and_then(|l| l.split(['-', '_']).next()).unwrap_or("").trim().to_ascii_lowercase()
}
fn tailoring(lang: Option<&str>) -> &'static [Tailor] {
match language(lang).as_str() {
"pl" => PL,
"cs" => CS,
"sk" => SK,
"hu" => HU,
"sv" | "fi" => SV,
"da" | "nb" | "nn" | "no" => DA,
"es" | "gl" => ES,
"tr" | "az" => TR,
"hr" | "bs" | "sr" => HR,
"ro" => RO,
"et" => ET,
"lt" => LT,
"lv" => LV,
"is" => IS,
"sl" => LT,
_ => &[],
}
}
/// A lowercase Latin letter followed by a combining mark, and the single letter Unicode
/// composes them to (NFC; generated from the Unicode Character Database). Text pasted from
/// decomposed sources (macOS file names: `z` + U+0307) collates like the composed letter.
const COMPOSE: &[(char, char, char)] = &[
('a', '\u{300}', 'à'),
('a', '\u{301}', 'á'),
('a', '\u{302}', 'â'),
('a', '\u{303}', 'ã'),
('a', '\u{304}', 'ā'),
('a', '\u{306}', 'ă'),
('a', '\u{307}', 'ȧ'),
('a', '\u{308}', 'ä'),
('a', '\u{30a}', 'å'),
('a', '\u{30c}', 'ǎ'),
('a', '\u{328}', 'ą'),
('b', '\u{307}', 'ḃ'),
('c', '\u{301}', 'ć'),
('c', '\u{302}', 'ĉ'),
('c', '\u{307}', 'ċ'),
('c', '\u{30c}', 'č'),
('c', '\u{327}', 'ç'),
('d', '\u{307}', 'ḋ'),
('d', '\u{30c}', 'ď'),
('d', '\u{327}', 'ḑ'),
('e', '\u{300}', 'è'),
('e', '\u{301}', 'é'),
('e', '\u{302}', 'ê'),
('e', '\u{303}', 'ẽ'),
('e', '\u{304}', 'ē'),
('e', '\u{306}', 'ĕ'),
('e', '\u{307}', 'ė'),
('e', '\u{308}', 'ë'),
('e', '\u{30c}', 'ě'),
('e', '\u{327}', 'ȩ'),
('e', '\u{328}', 'ę'),
('f', '\u{307}', 'ḟ'),
('g', '\u{301}', 'ǵ'),
('g', '\u{302}', 'ĝ'),
('g', '\u{304}', 'ḡ'),
('g', '\u{306}', 'ğ'),
('g', '\u{307}', 'ġ'),
('g', '\u{30c}', 'ǧ'),
('g', '\u{327}', 'ģ'),
('h', '\u{302}', 'ĥ'),
('h', '\u{307}', 'ḣ'),
('h', '\u{308}', 'ḧ'),
('h', '\u{30c}', 'ȟ'),
('h', '\u{327}', 'ḩ'),
('i', '\u{300}', 'ì'),
('i', '\u{301}', 'í'),
('i', '\u{302}', 'î'),
('i', '\u{303}', 'ĩ'),
('i', '\u{304}', 'ī'),
('i', '\u{306}', 'ĭ'),
('i', '\u{308}', 'ï'),
('i', '\u{30c}', 'ǐ'),
('i', '\u{328}', 'į'),
('j', '\u{302}', 'ĵ'),
('j', '\u{30c}', 'ǰ'),
('k', '\u{301}', 'ḱ'),
('k', '\u{30c}', 'ǩ'),
('k', '\u{327}', 'ķ'),
('l', '\u{301}', 'ĺ'),
('l', '\u{30c}', 'ľ'),
('l', '\u{327}', 'ļ'),
('m', '\u{301}', 'ḿ'),
('m', '\u{307}', 'ṁ'),
('n', '\u{300}', 'ǹ'),
('n', '\u{301}', 'ń'),
('n', '\u{303}', 'ñ'),
('n', '\u{307}', 'ṅ'),
('n', '\u{30c}', 'ň'),
('n', '\u{327}', 'ņ'),
('o', '\u{300}', 'ò'),
('o', '\u{301}', 'ó'),
('o', '\u{302}', 'ô'),
('o', '\u{303}', 'õ'),
('o', '\u{304}', 'ō'),
('o', '\u{306}', 'ŏ'),
('o', '\u{307}', 'ȯ'),
('o', '\u{308}', 'ö'),
('o', '\u{30b}', 'ő'),
('o', '\u{30c}', 'ǒ'),
('o', '\u{328}', 'ǫ'),
('p', '\u{301}', 'ṕ'),
('p', '\u{307}', 'ṗ'),
('r', '\u{301}', 'ŕ'),
('r', '\u{307}', 'ṙ'),
('r', '\u{30c}', 'ř'),
('r', '\u{327}', 'ŗ'),
('s', '\u{301}', 'ś'),
('s', '\u{302}', 'ŝ'),
('s', '\u{307}', 'ṡ'),
('s', '\u{30c}', 'š'),
('s', '\u{326}', 'ș'),
('s', '\u{327}', 'ş'),
('t', '\u{307}', 'ṫ'),
('t', '\u{308}', 'ẗ'),
('t', '\u{30c}', 'ť'),
('t', '\u{326}', 'ț'),
('t', '\u{327}', 'ţ'),
('u', '\u{300}', 'ù'),
('u', '\u{301}', 'ú'),
('u', '\u{302}', 'û'),
('u', '\u{303}', 'ũ'),
('u', '\u{304}', 'ū'),
('u', '\u{306}', 'ŭ'),
('u', '\u{308}', 'ü'),
('u', '\u{30a}', 'ů'),
('u', '\u{30b}', 'ű'),
('u', '\u{30c}', 'ǔ'),
('u', '\u{328}', 'ų'),
('v', '\u{303}', 'ṽ'),
('w', '\u{300}', 'ẁ'),
('w', '\u{301}', 'ẃ'),
('w', '\u{302}', 'ŵ'),
('w', '\u{307}', 'ẇ'),
('w', '\u{308}', 'ẅ'),
('w', '\u{30a}', 'ẘ'),
('x', '\u{307}', 'ẋ'),
('x', '\u{308}', 'ẍ'),
('y', '\u{300}', 'ỳ'),
('y', '\u{301}', 'ý'),
('y', '\u{302}', 'ŷ'),
('y', '\u{303}', 'ỹ'),
('y', '\u{304}', 'ȳ'),
('y', '\u{307}', 'ẏ'),
('y', '\u{308}', 'ÿ'),
('y', '\u{30a}', 'ẙ'),
('z', '\u{301}', 'ź'),
('z', '\u{302}', 'ẑ'),
('z', '\u{307}', 'ż'),
('z', '\u{30c}', 'ž'),
];
/// `s` lowercased, with each letter + combining mark that has a single-letter form composed.
fn lower_composed(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.to_lowercase().chars() {
if let Some(prev) = out.chars().next_back()
&& let Some((_, _, composed)) = COMPOSE.iter().find(|(b, m, _)| *b == prev && *m == c)
{
out.pop();
out.push(*composed);
} else {
out.push(c);
}
}
out
}
fn weight(c: char, rank: u32) -> u32 {
((c as u32) << 4).saturating_add(rank)
}
/// One collation element: primary weight, secondary weight, the text it covers.
struct Elem<'a> {
primary: Vec<u32>,
sec: u16,
text: &'a str,
/// Set when the text is a letter of its own in the language (shown as is in headings).
own: bool,
}
/// The next collation element of `rest` (lowercase), and its length in bytes.
fn next_elem<'a>(rest: &'a str, rules: &[Tailor]) -> Option<(Elem<'a>, usize)> {
if let Some(r) = rules.iter().filter(|r| rest.starts_with(r.seq)).max_by_key(|r| r.seq.len()) {
let text = rest.get(..r.seq.len())?;
return Some((Elem { primary: vec![weight(r.after, r.rank)], sec: r.sec, text, own: true }, r.seq.len()));
}
let c = rest.chars().next()?;
let n = c.len_utf8();
let text = rest.get(..n)?;
if let Some((_, base, mark)) = DECOMP.iter().find(|(x, _, _)| *x == c) {
return Some((Elem { primary: base.chars().map(|b| weight(b, 0)).collect(), sec: *mark, text, own: false }, n));
}
// A combining mark adds a secondary difference to the letter before it.
if ('\u{300}'..='\u{36f}').contains(&c) {
return Some((Elem { primary: Vec::new(), sec: 32 + (c as u32 - 0x300) as u16, text, own: false }, n));
}
Some((Elem { primary: vec![weight(c, 0)], sec: 0, text, own: false }, n))
}
/// A sort key: compare keys to sort strings in a language's order. Equal keys mean the strings
/// differ at most in letter case.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub struct SortKey {
primary: Vec<u32>,
secondary: Vec<u16>,
text: String,
}
/// The sort key of `s` for language `lang` (a BCP 47 tag such as `pl-PL`; `None` sorts like
/// English).
pub fn sort_key(s: &str, lang: Option<&str>) -> SortKey {
let text = lower_composed(s);
let rules = tailoring(lang);
let mut key = SortKey::default();
let mut i = 0;
while let Some(rest) = text.get(i..).filter(|r| !r.is_empty()) {
let Some((e, n)) = next_elem(rest, rules) else { break };
i += n.max(1);
if e.primary.is_empty() {
if let Some(last) = key.secondary.last_mut() {
*last = last.saturating_add(e.sec);
}
continue;
}
for (k, w) in e.primary.iter().enumerate() {
key.primary.push(*w);
key.secondary.push(if k == 0 { e.sec } else { 0 });
}
}
key.text = text;
key
}
/// Compare two strings in the order of language `lang`, ignoring case.
pub fn collate(a: &str, b: &str, lang: Option<&str>) -> Ordering {
sort_key(a, lang).cmp(&sort_key(b, lang))
}
/// The index heading letter `s` files under in language `lang`: `Ł` for `Łódź` in Polish, `L`
/// in English; `Ch` for `chata` in Czech; `A` for `Äpfel` in German, `Ä` in Swedish.
pub fn heading_letter(s: &str, lang: Option<&str>) -> Option<String> {
let low = lower_composed(s.trim_start());
let (e, _) = next_elem(&low, tailoring(lang))?;
let shown = if e.own || e.sec == 0 {
e.text.to_string()
} else {
DECOMP.iter().find(|(x, _, _)| e.text.starts_with(*x)).map(|(_, b, _)| b.chars().take(1).collect()).unwrap_or_else(|| e.text.to_string())
};
let mut cs = shown.chars();
let first = cs.next()?;
Some(first.to_uppercase().chain(cs).collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn sorted(words: &[&str], lang: &str) -> Vec<String> {
let mut v: Vec<String> = words.iter().map(|w| w.to_string()).collect();
v.sort_by(|a, b| collate(a, b, Some(lang)));
v
}
#[test]
fn polish_letters_follow_their_base_letter() {
assert_eq!(sorted(&["Zakopane", "Łódź", "Kraków"], "pl-PL"), ["Kraków", "Łódź", "Zakopane"]);
assert_eq!(sorted(&["zebra", "ćma", "dom"], "pl-PL"), ["ćma", "dom", "zebra"]);
assert_eq!(sorted(&["żaba", "źle", "zebra", "ząb", "lato", "łapa", "mama"], "pl"), ["lato", "łapa", "mama", "ząb", "zebra", "źle", "żaba"]);
assert_eq!(sorted(&["b", "ą", "a"], "pl"), ["a", "ą", "b"]);
// Every Polish letter is a letter of its own, after its base letter (review of #844).
assert_eq!(sorted(&["Malbork", "Łódź", "Lublin"], "pl"), ["Lublin", "Łódź", "Malbork"]);
assert_eq!(sorted(&["ósmy", "owca", "pies"], "pl"), ["owca", "ósmy", "pies"]);
assert_eq!(sorted(&["ściana", "sum", "tak"], "pl"), ["sum", "ściana", "tak"]);
assert_eq!(sorted(&["ęsi", "ezop", "fala"], "pl"), ["ezop", "ęsi", "fala"]);
assert_eq!(sorted(&["ńa", "nz", "oko"], "pl"), ["nz", "ńa", "oko"]);
assert_eq!(heading_letter("ósmy", Some("pl")).as_deref(), Some("Ó"));
assert_eq!(heading_letter("ńa", Some("pl")).as_deref(), Some("Ń"));
// Decomposed letters (`z` + combining dot above, as macOS file names write `ż`) sort like
// the composed ones.
assert_eq!(collate("z\u{307}aba", "żaba", Some("pl-PL")), Ordering::Equal);
assert_eq!(collate("z\u{307}aba", "zebra", Some("pl-PL")), Ordering::Greater);
assert_eq!(heading_letter("Z\u{307}aba", Some("pl")).as_deref(), Some("Ż"));
assert_eq!(collate("a\u{328}", "ą", Some("pl")), Ordering::Equal);
}
#[test]
fn swedish_puts_a_ring_and_umlauts_after_z() {
assert_eq!(sorted(&["ödla", "zebra", "äpple", "åka", "apa"], "sv-SE"), ["apa", "zebra", "åka", "äpple", "ödla"]);
}
#[test]
fn german_sorts_umlauts_with_their_base_letter() {
assert_eq!(sorted(&["Zug", "Äpfel", "Apfel", "Bach"], "de-DE"), ["Apfel", "Äpfel", "Bach", "Zug"]);
assert_eq!(sorted(&["Straße", "Strasse", "Strand"], "de"), ["Strand", "Strasse", "Straße"]);
}
#[test]
fn spanish_n_tilde_after_n() {
assert_eq!(sorted(&["oso", "ñu", "nube"], "es-ES"), ["nube", "ñu", "oso"]);
}
#[test]
fn czech_slovak_hungarian_alphabets() {
assert_eq!(sorted(&["chata", "hrad", "ihla", "cesta", "čaj", "dům"], "cs-CZ"), ["cesta", "čaj", "dům", "hrad", "chata", "ihla"]);
assert_eq!(sorted(&["ôsmy", "oko", "pes", "ä", "b"], "sk"), ["ä", "b", "oko", "ôsmy", "pes"]);
assert_eq!(sorted(&["csak", "cukor", "dob", "szó", "sűrű", "tál"], "hu-HU"), ["cukor", "csak", "dob", "sűrű", "szó", "tál"]);
// ö and ő are one letter after o, told apart by the accent only.
assert_eq!(sorted(&["ötös", "őr", "pálya", "olló", "ör"], "hu"), ["olló", "ör", "őr", "ötös", "pálya"]);
}
#[test]
fn english_keeps_case_insensitive_order_and_folds_accents() {
assert_eq!(sorted(&["rex", "résumé", "resume", "Apple", "banana"], "en-US"), ["Apple", "banana", "resume", "résumé", "rex"]);
assert_eq!(collate("Łódź", "Lodz", None), Ordering::Greater);
assert_eq!(collate("Łódź", "Lodzia", None), Ordering::Less);
assert_eq!(collate("ABC", "abc", None), Ordering::Equal);
}
#[test]
fn heading_letters() {
assert_eq!(heading_letter("Łódź", Some("pl-PL")).as_deref(), Some("Ł"));
assert_eq!(heading_letter("Łódź", Some("en-US")).as_deref(), Some("L"));
assert_eq!(heading_letter("chata", Some("cs")).as_deref(), Some("Ch"));
assert_eq!(heading_letter("Äpfel", Some("de")).as_deref(), Some("A"));
assert_eq!(heading_letter("Äpple", Some("sv")).as_deref(), Some("Ä"));
assert_eq!(heading_letter("", Some("sv")), None);
}
#[test]
fn hostile_input_never_panics() {
for s in ["", "\u{301}", "a\u{301}\u{301}\u{301}", "dzs", "\u{10FFFF}", "ẞ", "İstanbul"] {
for l in [None, Some("hu"), Some(""), Some("-"), Some("tr-TR")] {
let _ = sort_key(s, l);
let _ = heading_letter(s, l);
}
}
}
}
+7
View File
@@ -27,6 +27,13 @@ impl Fragment {
d.body = self.blocks.iter().cloned().map(Arc::new).collect();
d.plain_text(crate::StoryRef::Body)
}
/// The text a copy puts on the clipboard: [`Self::plain_text`] with symbol-font characters
/// (Wingdings…) as their Unicode equivalents.
pub fn clipboard_text(&self) -> String {
let mut d = Document::new();
d.body = self.blocks.iter().cloned().map(Arc::new).collect();
d.shown_text(crate::StoryRef::Body)
}
pub fn from_text(s: &str) -> Fragment {
let lines: Vec<&str> = s.split('\n').collect();
Fragment {
+176 -1
View File
@@ -1,4 +1,6 @@
//! Windows-1252 (the byte encoding legacy Word text pieces and RTF bytes use).
//! Windows-1252 (the byte encoding legacy Word text pieces use) and the other single-byte Windows
//! code pages RTF files name with `\ansicpg` or a font's `\fcharset` (Cyrillic 1251, Central
//! European 1250, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai).
/// Windows-1252 code points for 0x80..=0x9F.
const CP1252: [u16; 32] = [
@@ -14,3 +16,176 @@ pub fn cp1252(b: u8) -> char {
b as char
}
}
/// Windows-874 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP874: [u16; 128] = [
0x20AC, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2026, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x0E01, 0x0E02, 0x0E03,
0x0E04, 0x0E05, 0x0E06, 0x0E07, 0x0E08, 0x0E09, 0x0E0A, 0x0E0B, 0x0E0C, 0x0E0D, 0x0E0E, 0x0E0F, 0x0E10, 0x0E11, 0x0E12, 0x0E13, 0x0E14, 0x0E15,
0x0E16, 0x0E17, 0x0E18, 0x0E19, 0x0E1A, 0x0E1B, 0x0E1C, 0x0E1D, 0x0E1E, 0x0E1F, 0x0E20, 0x0E21, 0x0E22, 0x0E23, 0x0E24, 0x0E25, 0x0E26, 0x0E27,
0x0E28, 0x0E29, 0x0E2A, 0x0E2B, 0x0E2C, 0x0E2D, 0x0E2E, 0x0E2F, 0x0E30, 0x0E31, 0x0E32, 0x0E33, 0x0E34, 0x0E35, 0x0E36, 0x0E37, 0x0E38, 0x0E39,
0x0E3A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x0E3F, 0x0E40, 0x0E41, 0x0E42, 0x0E43, 0x0E44, 0x0E45, 0x0E46, 0x0E47, 0x0E48, 0x0E49, 0x0E4A, 0x0E4B,
0x0E4C, 0x0E4D, 0x0E4E, 0x0E4F, 0x0E50, 0x0E51, 0x0E52, 0x0E53, 0x0E54, 0x0E55, 0x0E56, 0x0E57, 0x0E58, 0x0E59, 0x0E5A, 0x0E5B, 0xFFFD, 0xFFFD,
0xFFFD, 0xFFFD,
];
/// Windows-1250 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1250: [u16; 128] = [
0x20AC, 0xFFFD, 0x201A, 0xFFFD, 0x201E, 0x2026, 0x2020, 0x2021, 0xFFFD, 0x2030, 0x0160, 0x2039, 0x015A, 0x0164, 0x017D, 0x0179, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0x0161, 0x203A, 0x015B, 0x0165, 0x017E, 0x017A, 0x00A0, 0x02C7, 0x02D8, 0x0141,
0x00A4, 0x0104, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x015E, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x017B, 0x00B0, 0x00B1, 0x02DB, 0x0142, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00B8, 0x0105, 0x015F, 0x00BB, 0x013D, 0x02DD, 0x013E, 0x017C, 0x0154, 0x00C1, 0x00C2, 0x0102, 0x00C4, 0x0139, 0x0106, 0x00C7,
0x010C, 0x00C9, 0x0118, 0x00CB, 0x011A, 0x00CD, 0x00CE, 0x010E, 0x0110, 0x0143, 0x0147, 0x00D3, 0x00D4, 0x0150, 0x00D6, 0x00D7, 0x0158, 0x016E,
0x00DA, 0x0170, 0x00DC, 0x00DD, 0x0162, 0x00DF, 0x0155, 0x00E1, 0x00E2, 0x0103, 0x00E4, 0x013A, 0x0107, 0x00E7, 0x010D, 0x00E9, 0x0119, 0x00EB,
0x011B, 0x00ED, 0x00EE, 0x010F, 0x0111, 0x0144, 0x0148, 0x00F3, 0x00F4, 0x0151, 0x00F6, 0x00F7, 0x0159, 0x016F, 0x00FA, 0x0171, 0x00FC, 0x00FD,
0x0163, 0x02D9,
];
/// Windows-1251 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1251: [u16; 128] = [
0x0402, 0x0403, 0x201A, 0x0453, 0x201E, 0x2026, 0x2020, 0x2021, 0x20AC, 0x2030, 0x0409, 0x2039, 0x040A, 0x040C, 0x040B, 0x040F, 0x0452, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0x0459, 0x203A, 0x045A, 0x045C, 0x045B, 0x045F, 0x00A0, 0x040E, 0x045E, 0x0408,
0x00A4, 0x0490, 0x00A6, 0x00A7, 0x0401, 0x00A9, 0x0404, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x0407, 0x00B0, 0x00B1, 0x0406, 0x0456, 0x0491, 0x00B5,
0x00B6, 0x00B7, 0x0451, 0x2116, 0x0454, 0x00BB, 0x0458, 0x0405, 0x0455, 0x0457, 0x0410, 0x0411, 0x0412, 0x0413, 0x0414, 0x0415, 0x0416, 0x0417,
0x0418, 0x0419, 0x041A, 0x041B, 0x041C, 0x041D, 0x041E, 0x041F, 0x0420, 0x0421, 0x0422, 0x0423, 0x0424, 0x0425, 0x0426, 0x0427, 0x0428, 0x0429,
0x042A, 0x042B, 0x042C, 0x042D, 0x042E, 0x042F, 0x0430, 0x0431, 0x0432, 0x0433, 0x0434, 0x0435, 0x0436, 0x0437, 0x0438, 0x0439, 0x043A, 0x043B,
0x043C, 0x043D, 0x043E, 0x043F, 0x0440, 0x0441, 0x0442, 0x0443, 0x0444, 0x0445, 0x0446, 0x0447, 0x0448, 0x0449, 0x044A, 0x044B, 0x044C, 0x044D,
0x044E, 0x044F,
];
/// Windows-1253 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1253: [u16; 128] = [
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0xFFFD, 0x2030, 0xFFFD, 0x2039, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x0385, 0x0386, 0x00A3,
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0xFFFD, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x2015, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x0384, 0x00B5,
0x00B6, 0x00B7, 0x0388, 0x0389, 0x038A, 0x00BB, 0x038C, 0x00BD, 0x038E, 0x038F, 0x0390, 0x0391, 0x0392, 0x0393, 0x0394, 0x0395, 0x0396, 0x0397,
0x0398, 0x0399, 0x039A, 0x039B, 0x039C, 0x039D, 0x039E, 0x039F, 0x03A0, 0x03A1, 0xFFFD, 0x03A3, 0x03A4, 0x03A5, 0x03A6, 0x03A7, 0x03A8, 0x03A9,
0x03AA, 0x03AB, 0x03AC, 0x03AD, 0x03AE, 0x03AF, 0x03B0, 0x03B1, 0x03B2, 0x03B3, 0x03B4, 0x03B5, 0x03B6, 0x03B7, 0x03B8, 0x03B9, 0x03BA, 0x03BB,
0x03BC, 0x03BD, 0x03BE, 0x03BF, 0x03C0, 0x03C1, 0x03C2, 0x03C3, 0x03C4, 0x03C5, 0x03C6, 0x03C7, 0x03C8, 0x03C9, 0x03CA, 0x03CB, 0x03CC, 0x03CD,
0x03CE, 0xFFFD,
];
/// Windows-1254 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1254: [u16; 128] = [
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0xFFFD, 0xFFFD, 0x0178, 0x00A0, 0x00A1, 0x00A2, 0x00A3,
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00AA, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00BA, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x00C0, 0x00C1, 0x00C2, 0x00C3, 0x00C4, 0x00C5, 0x00C6, 0x00C7,
0x00C8, 0x00C9, 0x00CA, 0x00CB, 0x00CC, 0x00CD, 0x00CE, 0x00CF, 0x011E, 0x00D1, 0x00D2, 0x00D3, 0x00D4, 0x00D5, 0x00D6, 0x00D7, 0x00D8, 0x00D9,
0x00DA, 0x00DB, 0x00DC, 0x0130, 0x015E, 0x00DF, 0x00E0, 0x00E1, 0x00E2, 0x00E3, 0x00E4, 0x00E5, 0x00E6, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
0x00EC, 0x00ED, 0x00EE, 0x00EF, 0x011F, 0x00F1, 0x00F2, 0x00F3, 0x00F4, 0x00F5, 0x00F6, 0x00F7, 0x00F8, 0x00F9, 0x00FA, 0x00FB, 0x00FC, 0x0131,
0x015F, 0x00FF,
];
/// Windows-1255 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1255: [u16; 128] = [
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0xFFFD, 0x2039, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x00A1, 0x00A2, 0x00A3,
0x20AA, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00D7, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00F7, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x05B0, 0x05B1, 0x05B2, 0x05B3, 0x05B4, 0x05B5, 0x05B6, 0x05B7,
0x05B8, 0x05B9, 0xFFFD, 0x05BB, 0x05BC, 0x05BD, 0x05BE, 0x05BF, 0x05C0, 0x05C1, 0x05C2, 0x05C3, 0x05F0, 0x05F1, 0x05F2, 0x05F3, 0x05F4, 0xFFFD,
0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x05D0, 0x05D1, 0x05D2, 0x05D3, 0x05D4, 0x05D5, 0x05D6, 0x05D7, 0x05D8, 0x05D9, 0x05DA, 0x05DB,
0x05DC, 0x05DD, 0x05DE, 0x05DF, 0x05E0, 0x05E1, 0x05E2, 0x05E3, 0x05E4, 0x05E5, 0x05E6, 0x05E7, 0x05E8, 0x05E9, 0x05EA, 0xFFFD, 0xFFFD, 0x200E,
0x200F, 0xFFFD,
];
/// Windows-1256 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1256: [u16; 128] = [
0x20AC, 0x067E, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0679, 0x2039, 0x0152, 0x0686, 0x0698, 0x0688, 0x06AF, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x06A9, 0x2122, 0x0691, 0x203A, 0x0153, 0x200C, 0x200D, 0x06BA, 0x00A0, 0x060C, 0x00A2, 0x00A3,
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x06BE, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x061B, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x061F, 0x06C1, 0x0621, 0x0622, 0x0623, 0x0624, 0x0625, 0x0626, 0x0627,
0x0628, 0x0629, 0x062A, 0x062B, 0x062C, 0x062D, 0x062E, 0x062F, 0x0630, 0x0631, 0x0632, 0x0633, 0x0634, 0x0635, 0x0636, 0x00D7, 0x0637, 0x0638,
0x0639, 0x063A, 0x0640, 0x0641, 0x0642, 0x0643, 0x00E0, 0x0644, 0x00E2, 0x0645, 0x0646, 0x0647, 0x0648, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
0x0649, 0x064A, 0x00EE, 0x00EF, 0x064B, 0x064C, 0x064D, 0x064E, 0x00F4, 0x064F, 0x0650, 0x00F7, 0x0651, 0x00F9, 0x0652, 0x00FB, 0x00FC, 0x200E,
0x200F, 0x06D2,
];
/// Windows-1257 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1257: [u16; 128] = [
0x20AC, 0xFFFD, 0x201A, 0xFFFD, 0x201E, 0x2026, 0x2020, 0x2021, 0xFFFD, 0x2030, 0xFFFD, 0x2039, 0xFFFD, 0x00A8, 0x02C7, 0x00B8, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0x00AF, 0x02DB, 0xFFFD, 0x00A0, 0xFFFD, 0x00A2, 0x00A3,
0x00A4, 0xFFFD, 0x00A6, 0x00A7, 0x00D8, 0x00A9, 0x0156, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00C6, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00F8, 0x00B9, 0x0157, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00E6, 0x0104, 0x012E, 0x0100, 0x0106, 0x00C4, 0x00C5, 0x0118, 0x0112,
0x010C, 0x00C9, 0x0179, 0x0116, 0x0122, 0x0136, 0x012A, 0x013B, 0x0160, 0x0143, 0x0145, 0x00D3, 0x014C, 0x00D5, 0x00D6, 0x00D7, 0x0172, 0x0141,
0x015A, 0x016A, 0x00DC, 0x017B, 0x017D, 0x00DF, 0x0105, 0x012F, 0x0101, 0x0107, 0x00E4, 0x00E5, 0x0119, 0x0113, 0x010D, 0x00E9, 0x017A, 0x0117,
0x0123, 0x0137, 0x012B, 0x013C, 0x0161, 0x0144, 0x0146, 0x00F3, 0x014D, 0x00F5, 0x00F6, 0x00F7, 0x0173, 0x0142, 0x015B, 0x016B, 0x00FC, 0x017C,
0x017E, 0x02D9,
];
/// Windows-1258 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1258: [u16; 128] = [
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0xFFFD, 0x2039, 0x0152, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0xFFFD, 0x203A, 0x0153, 0xFFFD, 0xFFFD, 0x0178, 0x00A0, 0x00A1, 0x00A2, 0x00A3,
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00AA, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00BA, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x00C0, 0x00C1, 0x00C2, 0x0102, 0x00C4, 0x00C5, 0x00C6, 0x00C7,
0x00C8, 0x00C9, 0x00CA, 0x00CB, 0x0300, 0x00CD, 0x00CE, 0x00CF, 0x0110, 0x00D1, 0x0309, 0x00D3, 0x00D4, 0x01A0, 0x00D6, 0x00D7, 0x00D8, 0x00D9,
0x00DA, 0x00DB, 0x00DC, 0x01AF, 0x0303, 0x00DF, 0x00E0, 0x00E1, 0x00E2, 0x0103, 0x00E4, 0x00E5, 0x00E6, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
0x0301, 0x00ED, 0x00EE, 0x00EF, 0x0111, 0x00F1, 0x0323, 0x00F3, 0x00F4, 0x01A1, 0x00F6, 0x00F7, 0x00F8, 0x00F9, 0x00FA, 0x00FB, 0x00FC, 0x01B0,
0x20AB, 0x00FF,
];
/// The single-byte Windows code pages [`windows_byte`] decodes (1252 included).
pub const SINGLE_BYTE_CODE_PAGES: [u16; 10] = [874, 1250, 1251, 1252, 1253, 1254, 1255, 1256, 1257, 1258];
/// One byte of a single-byte Windows code page as a char; a code page outside
/// [`SINGLE_BYTE_CODE_PAGES`] reads as Windows-1252.
pub fn windows_byte(code_page: u16, b: u8) -> char {
let table: &[u16; 128] = match code_page {
874 => &CP874,
1250 => &CP1250,
1251 => &CP1251,
1253 => &CP1253,
1254 => &CP1254,
1255 => &CP1255,
1256 => &CP1256,
1257 => &CP1257,
1258 => &CP1258,
_ => return cp1252(b),
};
if b < 0x80 {
return b as char;
}
table.get(usize::from(b - 0x80)).and_then(|u| char::from_u32(u32::from(*u))).unwrap_or('\u{FFFD}')
}
/// The code page of an RTF or Word font charset (`\fcharset`): 204 Russian → 1251, 238 Eastern
/// European → 1250… `None` for the ANSI and default charsets (the document's code page applies),
/// symbol fonts and the double-byte East Asian charsets.
pub fn charset_code_page(charset: i32) -> Option<u16> {
Some(match charset {
161 => 1253,
162 => 1254,
163 => 1258,
177 => 1255,
178 => 1256,
186 => 1257,
204 => 1251,
222 => 874,
238 => 1250,
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn single_byte_code_pages() {
let decode = |cp: u16, bytes: &[u8]| bytes.iter().map(|&b| windows_byte(cp, b)).collect::<String>();
assert_eq!(decode(1251, &[0xD1, 0xF7, 0xE5, 0xF2, 0x20, 0xB9]), "Счет №");
assert_eq!(decode(1251, &[0xA8, 0xB8]), "Ёё");
assert_eq!(decode(1250, &[0xA3, 0xF3, 0x9F]), "Łóź");
assert_eq!(decode(1253, &[0xC1, 0xE2]), "Αβ");
assert_eq!(decode(1252, &[0x80, 0xE9]), "ێ");
assert_eq!(decode(9999, &[0xE9]), "é");
assert_eq!(charset_code_page(204), Some(1251));
assert_eq!(charset_code_page(0), None);
for cp in SINGLE_BYTE_CODE_PAGES {
for b in 0..=255u8 {
let _ = windows_byte(cp, b);
}
}
}
}
+16 -5
View File
@@ -1,12 +1,14 @@
//! Charts and SmartArt diagrams: drawings kept as neutral items ready to draw. Charts WordCraft can
//! edit also keep their [`crate::chart::ChartSpec`]; the rest are shown, not edited.
//! Charts, SmartArt diagrams and native ink: drawings kept as neutral items ready to draw. Charts and SmartArt
//! WordCraft can edit also keep their model ([`crate::chart::ChartSpec`],
//! [`crate::smart_art::SmartArtSpec`]); the rest are shown, not edited.
use serde::{Deserialize, Serialize};
use crate::para::ShapeKind;
use crate::props::Rgb;
/// A chart or a SmartArt diagram, as items ready to draw (and, for an editable chart, its model).
/// A chart, SmartArt diagram or native ink preview, as items ready to draw (and, for an editable chart or SmartArt
/// graphic, its model).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Graphic {
@@ -20,10 +22,15 @@ pub struct Graphic {
pub w: f32,
#[serde(default)]
pub h: f32,
/// Where the graphic was read from, so saving writes the chart or diagram itself back (not
/// Where the graphic was read from, so saving writes the original drawing itself back (not
/// only these items). `None` for graphics made some other way.
#[serde(skip)]
pub source: Option<std::sync::Arc<Embedded>>,
/// A SmartArt graphic WordCraft can edit (one it made, or one read back exactly as it wrote
/// it): saving writes the diagram parts from it, and the items are drawn from it. See
/// [`crate::smart_art`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub smart_art: Option<std::sync::Arc<crate::smart_art::SmartArtSpec>>,
/// A chart WordCraft can edit (one it made, or one read back that holds nothing more): saving
/// writes the chart part from it, and the items are drawn from it. See [`crate::chart`].
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -37,7 +44,8 @@ pub struct Graphic {
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Embedded {
/// The object's markup as a standalone XML element with its namespace declarations (DOCX:
/// the `a:graphic` of a chart or SmartArt diagram, the `w:object` of an OLE object).
/// the `a:graphic` of a chart, SmartArt diagram or native ink, an ink `mc:AlternateContent`,
/// or the `w:object` of an OLE object).
pub xml: String,
/// The relationships it uses, by the ids in `xml` (or in its parts, for a SmartArt drawing).
pub refs: Vec<EmbeddedRef>,
@@ -61,6 +69,8 @@ pub enum GraphicKind {
#[default]
Chart,
Diagram,
/// Word's native InkML, kept opaque; displayed as its fallback picture when available.
Ink,
}
impl GraphicKind {
@@ -69,6 +79,7 @@ impl GraphicKind {
match self {
GraphicKind::Chart => "chart",
GraphicKind::Diagram => "diagram",
GraphicKind::Ink => "ink",
}
}
}
+95 -7
View File
@@ -12,6 +12,9 @@
pub mod bidi;
pub mod chart;
mod cjk_numbers;
pub mod cluster;
pub mod collate;
pub mod connector;
pub mod control;
pub mod edit;
@@ -21,6 +24,8 @@ pub mod eq;
pub mod fields;
pub mod freeform;
pub mod graphic;
pub mod link;
pub mod locale_number;
pub mod math;
pub mod math_edit;
pub mod math_latex;
@@ -31,7 +36,9 @@ pub mod para;
pub mod props;
pub mod resolve;
pub mod section;
pub mod smart_art;
pub mod styles;
pub mod symbol_fonts;
pub mod table;
pub mod wordart;
pub mod wrap;
@@ -132,6 +139,11 @@ impl BoxBudget {
self.open.pop();
}
/// Whether a text box is being expanded now.
pub fn inside(&self) -> bool {
!self.open.is_empty()
}
/// Boxes expanded so far.
pub fn total(&self) -> usize {
self.total
@@ -270,12 +282,28 @@ pub struct TextBody {
/// continues in when it doesn't fit this one. The linked box shows the rest of this box's
/// story; its own blocks are unused. See [`Document::text_box_chains`].
pub next: Option<u32>,
/// Internal margins (left, top, right, bottom), EMU, when the file gives its own
/// (`wps:bodyPr` `lIns`… ECMA-376 Part 1 §21.1.2.1.1); `None` is Word's defaults.
pub inset: Option<[u32; 4]>,
/// Resize shape to fit text (`a:spAutoFit`): Word grows the box to its text, so none of
/// the text is cut.
pub auto_fit: bool,
}
/// Word's default text box margins (left, top, right, bottom), EMU: 0.1" and 0.05".
pub const TEXT_BOX_INSET: [u32; 4] = [91_440, 45_720, 91_440, 45_720];
/// Widest text box margin kept, EMU (22", the largest page side).
pub const MAX_TEXT_BOX_INSET: u32 = 20_116_800;
impl TextBody {
pub fn is_default(&self) -> bool {
*self == TextBody::default()
}
/// The internal margins (left, top, right, bottom) in points.
pub fn insets_pt(&self) -> [f32; 4] {
self.inset.unwrap_or(TEXT_BOX_INSET).map(|v| v.min(MAX_TEXT_BOX_INSET) as f32 / 12_700.0)
}
}
/// Most boxes one chain of linked text boxes runs through (hostile files).
@@ -441,6 +469,16 @@ pub struct Settings {
/// Equation options (`m:mathPr`), when the document has them.
#[serde(skip_serializing_if = "Option::is_none")]
pub math: Option<math::MathProps>,
/// Citation and bibliography style (References › Style) by name, when the document
/// records one (OOXML's `b:Sources/@StyleName`).
#[serde(skip_serializing_if = "Option::is_none")]
pub bib_style: Option<String>,
/// The template the document is attached to (Tools › Templates and Add-ins): the target of
/// `w:attachedTemplate`'s relationship, a path or `file:` URL as the file gives it.
#[serde(skip_serializing_if = "Option::is_none")]
pub attached_template: Option<String>,
/// Update the document's styles from the attached template when it is opened (`w:linkStyles`).
pub link_styles: bool,
}
/// Word's compatibility mode for documents that don't state one.
@@ -515,6 +553,9 @@ impl Default for Settings {
grid_h: DEFAULT_GRID,
grid_v: DEFAULT_GRID,
math: None,
bib_style: None,
attached_template: None,
link_styles: false,
}
}
}
@@ -761,9 +802,19 @@ impl Document {
/// The whole text of a story, paragraphs separated by `\n` (tables: cells by `\t`).
pub fn plain_text(&self, s: StoryRef) -> String {
self.story_text(s, false)
}
/// [`Self::plain_text`] as it reads on screen: symbol-font characters (Wingdings…) as their
/// Unicode equivalents ([`Paragraph::shown_text`]).
pub fn shown_text(&self, s: StoryRef) -> String {
self.story_text(s, true)
}
fn story_text(&self, s: StoryRef, shown: bool) -> String {
let mut out = String::new();
if let Some(bl) = self.story(s) {
text_of(bl, &mut out, 0);
text_of(bl, &mut out, 0, shown);
}
if out.ends_with('\n') {
out.pop();
@@ -841,14 +892,22 @@ impl Document {
id
}
/// Count words like Word's status bar (whitespace-separated tokens containing a letter or digit).
/// Count words like Word's status bar (whitespace-separated tokens containing a letter or digit,
/// including generated numbered-list labels).
pub fn word_count(&self) -> usize {
count_words(&self.plain_text(StoryRef::Body))
self.story_word_count(StoryRef::Body)
}
/// Word's default count: the body plus its text boxes, footnotes and endnotes.
pub fn word_count_including_notes(&self) -> usize {
self.counted_stories().iter().map(|s| count_words(&self.plain_text(*s))).sum()
self.counted_stories().iter().map(|s| self.story_word_count(*s)).sum()
}
/// Word count of one story, including generated numbered-list labels.
pub fn story_word_count(&self, s: StoryRef) -> usize {
let Some(bl) = self.story(s) else { return 0 };
let mut counters = numbering::Counters::default();
story_word_count_of(bl, &self.numbering, &self.styles, &mut counters, 0)
}
/// The body, then the text boxes, footnotes and endnotes it shows (nested ones too), in
@@ -1236,14 +1295,14 @@ fn walk(bl: &Blocks, prefix: &mut Vec<u32>, out: &mut Vec<Path>, depth: usize) {
}
}
fn text_of(bl: &Blocks, out: &mut String, depth: usize) {
fn text_of(bl: &Blocks, out: &mut String, depth: usize, shown: bool) {
if depth > 16 {
return;
}
for b in bl {
match &**b {
Block::Para(p) => {
out.push_str(&p.plain_text());
out.push_str(&if shown { p.shown_text() } else { p.plain_text() });
out.push('\n');
}
Block::Table(t) => {
@@ -1251,7 +1310,7 @@ fn text_of(bl: &Blocks, out: &mut String, depth: usize) {
let mut cells = Vec::new();
for c in &row.cells {
let mut s = String::new();
text_of(&c.blocks, &mut s, depth + 1);
text_of(&c.blocks, &mut s, depth + 1, shown);
cells.push(s.trim_end_matches('\n').replace('\n', " "));
}
out.push_str(&cells.join("\t"));
@@ -1262,5 +1321,34 @@ fn text_of(bl: &Blocks, out: &mut String, depth: usize) {
}
}
fn story_word_count_of(bl: &Blocks, numbering: &Numbering, styles: &styles::StyleSheet, counters: &mut numbering::Counters, depth: usize) -> usize {
if depth > 16 {
return 0;
}
let mut total = 0usize;
for b in bl {
match &**b {
Block::Para(p) => {
let n = p.props.numbering.or_else(|| styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0);
if let Some(n) = n
&& let Some((label, def)) = counters.next_label(numbering, n.num, n.level)
&& !matches!(def.format, section::NumFormat::Bullet | section::NumFormat::None)
{
total = total.saturating_add(count_words(&label));
}
total = total.saturating_add(count_words(&p.plain_text()));
}
Block::Table(t) => {
for row in &t.rows {
for c in &row.cells {
total = total.saturating_add(story_word_count_of(&c.blocks, numbering, styles, counters, depth + 1));
}
}
}
}
}
total
}
#[cfg(test)]
mod tests;
+81
View File
@@ -0,0 +1,81 @@
//! Which hyperlink targets may leave the document: followed by the OS, or written into an
//! exported HTML/Markdown file where a browser would run them.
/// Whether Ctrl/⌘+click may hand a document's hyperlink to the system: web and email links only,
/// so a document can't launch `file:` paths, programs or custom-scheme handlers. Exact: a leading
/// space or a control character makes it not a link.
pub fn is_web_or_mail_link(url: &str) -> bool {
let Some((scheme, _)) = url.split_once(':') else { return false };
["http", "https", "mailto"].iter().any(|s| scheme.eq_ignore_ascii_case(s))
}
/// Whether a hyperlink may be written as an `href` (or Markdown destination) in an exported file:
/// a web or email link, a bookmark (`#name`) or a relative path. Scripts (`javascript:`,
/// `vbscript:`), `data:`, `file:` and every other scheme are refused, read the way a browser reads
/// a URL: ASCII control characters and spaces are dropped first, so `java\nscript:` is a script.
pub fn is_safe_href(url: &str) -> bool {
match scheme(url) {
Some(s) => matches!(s.as_str(), "http" | "https" | "mailto"),
None => !url.trim().is_empty(),
}
}
/// The scheme a browser would see in `url` (RFC 3986 §3.1: a letter, then letters, digits, `+`,
/// `-` or `.`, up to the first `:`), lower-cased; `None` for a relative reference.
fn scheme(url: &str) -> Option<String> {
let cleaned: String = url.chars().filter(|c| !c.is_ascii_control() && *c != ' ').collect();
let (s, _) = cleaned.split_once(':')?;
let mut chars = s.chars();
let first = chars.next()?;
(first.is_ascii_alphabetic() && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))).then(|| s.to_ascii_lowercase())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_web_and_mail_links_are_followable() {
for ok in ["https://example.com/a?b#c", "http://example.com", "HTTPS://EXAMPLE.COM", "mailto:someone@example.com"] {
assert!(is_web_or_mail_link(ok), "{ok}");
}
for bad in [
"file:///etc/passwd",
"C:\\Windows\\System32\\calc.exe",
"/usr/bin/xterm",
"javascript:alert(1)",
"ms-msdt:/id",
" https://example.com",
"https\u{0}://example.com",
"",
"example.com",
"#top",
] {
assert!(!is_web_or_mail_link(bad), "{bad:?}");
}
}
/// An exported `href` may be a web/mail link, a bookmark or a relative path: never a script
/// or another scheme, however a browser would normalise it.
#[test]
fn exported_hrefs_allow_bookmarks_and_relative_paths_but_no_scripts() {
for ok in ["https://example.com/x", "mailto:a@b.c", "#top", "other.html", "docs/guide.html#s2", "../up.html", "?q=1"] {
assert!(is_safe_href(ok), "{ok}");
}
for bad in [
"javascript:alert(1)",
"JavaScript:alert(1)",
"java\nscript:alert(1)",
"\tjavascript:alert(1)",
" javascript:alert(1)",
"java\u{0}script:alert(1)",
"vbscript:MsgBox(1)",
"data:text/html,<script>alert(1)</script>",
"file:///etc/passwd",
"ms-msdt:/id",
"",
] {
assert!(!is_safe_href(bad), "{bad:?}");
}
}
}
+177
View File
@@ -0,0 +1,177 @@
//! Numbers written the way a language writes them: `1 250,50` in Polish, `1.250,50` in German,
//! `1,250.50` in English. Used by table sort and table formulas.
use crate::collate::language;
/// The decimal separator of language `lang` (a BCP 47 tag; `None` means English).
pub fn decimal_separator(lang: Option<&str>) -> char {
let tag = lang.unwrap_or("").trim().to_ascii_lowercase().replace('_', "-");
// Regions that write a decimal point where the language usually writes a comma.
const POINT_REGIONS: &[&str] =
&["de-ch", "de-li", "it-ch", "es-mx", "es-us", "es-419", "es-pr", "es-do", "es-gt", "es-hn", "es-ni", "es-pa", "es-sv"];
if POINT_REGIONS.iter().any(|r| tag == *r || tag.starts_with(&format!("{r}-"))) {
return '.';
}
const COMMA: &[&str] = &[
"af", "az", "be", "bg", "bs", "ca", "cs", "da", "de", "el", "es", "et", "eu", "fi", "fo", "fr", "gl", "hr", "hu", "hy", "id", "is", "it",
"ka", "kk", "ky", "lb", "lt", "lv", "mk", "mn", "nb", "nl", "nn", "no", "pl", "pt", "ro", "ru", "sk", "sl", "sq", "sr", "sv", "tr", "uk",
"uz", "vi",
];
if COMMA.contains(&language(lang).as_str()) { ',' } else { '.' }
}
const SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\u{2009}'];
const CURRENCY: &[char] = &['$', '€', '£', '¥', '¢', '₽', '₴', '₺', '₹', '₩', '₪', '₫', '฿', '₱', '₸', '₼', '₾'];
/// Strip a currency symbol or a short currency word (`zł`, `kr`, `PLN`) from either end.
fn strip_currency(s: &str) -> &str {
let mut s = s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c));
if let Some((head, word)) = s.rsplit_once(SPACES)
&& (1..=3).contains(&word.chars().count())
&& word.chars().all(|c| c.is_alphabetic() || c == '.')
{
s = head;
}
if let Some((word, tail)) = s.split_once(SPACES)
&& word.chars().count() == 3
&& word.chars().all(|c| c.is_ascii_uppercase())
{
s = tail;
}
s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c))
}
/// Digits of an integer part with group separators `groups`: the first group has 1–3 digits,
/// every later one exactly 3.
fn grouped_digits(int: &str, groups: &[char]) -> Option<String> {
if int.chars().all(|c| c.is_ascii_digit()) {
return Some(int.to_string());
}
let parts: Vec<&str> = int.split(groups).collect();
let (first, rest) = parts.split_first()?;
// A first group of `0` is never grouping (`0.125` is a fraction, not 125).
let ok = (1..=3).contains(&first.len())
&& !first.starts_with('0')
&& first.chars().all(|c| c.is_ascii_digit())
&& rest.iter().all(|g| g.len() == 3 && g.chars().all(|c| c.is_ascii_digit()));
ok.then(|| parts.concat())
}
/// Parse `s` as a number written in language `lang`, or `None` when it isn't one. The decimal
/// separator is the language's; group separators are spaces (and `.` or `'` where the decimal
/// separator is a comma, `,` or `'` where it is a point) between groups of three digits. A
/// currency sign or short currency word is ignored, and so is a trailing `%` (`12 %` is 12, as
/// table sort reads it). In comma languages a lone `.` that cannot be a group separator (`1.5`)
/// is read as a decimal point. A trailing separator with no digits after it (`1.`, an ordinal
/// in a numbered column) is the whole number. A minus may be `-`, `−` or an en dash.
pub fn parse_number(s: &str, lang: Option<&str>) -> Option<f64> {
let dec = decimal_separator(lang);
let s = s.trim();
let s = s.strip_suffix('%').map_or(s, str::trim_end);
let mut body = strip_currency(s);
let mut neg = false;
if let Some(rest) = body.strip_prefix(['-', '\u{2212}', '\u{2013}']) {
neg = true;
body = strip_currency(rest);
} else if let Some(rest) = body.strip_prefix('+') {
body = strip_currency(rest);
}
if body.is_empty() || body.len() > 400 {
return None;
}
let mut groups: Vec<char> = SPACES.to_vec();
groups.extend(['\'', '\u{2019}']);
groups.push(if dec == ',' { '.' } else { ',' });
let (int, frac) = match body.split_once(dec) {
Some((i, f)) => (i, Some(f)),
None => (body, None),
};
let int_digits = match grouped_digits(int, &groups) {
Some(d) => d,
None if dec == ',' && frac.is_none() && int.matches('.').count() == 1 => {
return parse_number(&format!("{}{}", if neg { "-" } else { "" }, int.replace('.', ",")), lang);
}
None => return None,
};
let frac_digits = match frac {
Some(f) if f.chars().all(|c| c.is_ascii_digit()) => f,
Some(_) => return None,
None => "",
};
if int_digits.is_empty() && frac_digits.is_empty() {
return None;
}
let v: f64 = format!("0{int_digits}.{frac_digits}0").parse().ok()?;
Some(if neg { -v } else { v })
}
/// Write `v` the way language `lang` writes a formula result: whole numbers without decimals,
/// others with two decimals and the language's decimal separator (`5,75` in Polish).
pub fn format_number(v: f64, lang: Option<&str>) -> String {
if !v.is_finite() {
return "0".into();
}
if v.fract() == 0.0 && v.abs() < 1e15 {
return format!("{}", v as i64);
}
let s = format!("{v:.2}");
if decimal_separator(lang) == ',' { s.replace('.', ",") } else { s }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn polish_numbers() {
let pl = Some("pl-PL");
assert_eq!(parse_number("1,5", pl), Some(1.5));
assert_eq!(parse_number("3,50", pl), Some(3.5));
assert_eq!(parse_number("1 250,00", pl), Some(1250.0));
assert_eq!(parse_number("1\u{a0}250,00 zł", pl), Some(1250.0));
assert_eq!(parse_number("1.250,00", pl), Some(1250.0));
assert_eq!(parse_number("1.5", pl), Some(1.5));
assert_eq!(parse_number("-2,25", pl), Some(-2.25));
assert_eq!(parse_number("PLN 12", pl), Some(12.0));
assert_eq!(parse_number("abc", pl), None);
assert_eq!(parse_number("1,2,3", pl), None);
// From review: ordinals, a leading zero, percentages and an en dash minus.
assert_eq!(parse_number("1.", pl), Some(1.0), "a numbered column: 1.");
assert_eq!(parse_number("10.", pl), Some(10.0));
assert_eq!(parse_number("0.125", pl), Some(0.125), "a leading 0 is never a group");
assert_eq!(parse_number("0,125", pl), Some(0.125));
assert_eq!(parse_number("12 %", pl), Some(12.0));
assert_eq!(parse_number("12%", pl), Some(12.0));
assert_eq!(parse_number("\u{2013}2,5", pl), Some(-2.5));
assert_eq!(parse_number("%", pl), None);
assert_eq!(parse_number(".", pl), None);
assert_eq!(format_number(5.75, pl), "5,75");
assert_eq!(format_number(1350.0, pl), "1350");
}
#[test]
fn english_numbers() {
let en = Some("en-US");
assert_eq!(parse_number("1,250.50", en), Some(1250.5));
assert_eq!(parse_number("$1,250", en), Some(1250.0));
assert_eq!(parse_number("1.5", en), Some(1.5));
assert_eq!(parse_number("12", None), Some(12.0));
assert_eq!(parse_number(".5", None), Some(0.5));
assert_eq!(parse_number("1,5", en), None);
assert_eq!(parse_number("1.", en), Some(1.0));
assert_eq!(parse_number("0,125", en), None, "never 125");
assert_eq!(format_number(5.75, en), "5.75");
assert_eq!(decimal_separator(Some("de-CH")), '.');
assert_eq!(decimal_separator(Some("de-DE")), ',');
}
#[test]
fn hostile_numbers_never_panic() {
for s in ["", "-", "+", ",", ".", "1e400", "inf", "NaN", "--1", "₽", "1 ,", " zł", "\u{2212}", "9".repeat(500).as_str()] {
for l in [None, Some("pl"), Some("en"), Some("")] {
let _ = parse_number(s, l);
}
}
assert_eq!(format_number(f64::NAN, None), "0");
}
}
+33 -1
View File
@@ -300,7 +300,13 @@ impl Counters {
let k = (d as usize).saturating_sub(1).min(8);
let val = vals.get(k).copied().unwrap_or(0);
let val = if val == 0 { level_def(k).map(|l| l.start).unwrap_or(1) } else { val };
let fmt = if def.legal && k != lv { NumFormat::Decimal } else { level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) };
// Legal numbering (w:isLgl) shows every level in Arabic digits, whatever its own
// format (二 of a Chinese outline becomes 2 in "2.1").
let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) {
NumFormat::DecimalZero => NumFormat::DecimalZero,
_ if def.legal => NumFormat::Decimal,
f => f,
};
label.push_str(&fmt.format(val));
} else {
label.push(c);
@@ -371,6 +377,32 @@ mod tests {
assert!(c.next_label(&n, 999, 0).is_none());
}
#[test]
fn east_asian_labels_and_legal_numbering() {
// A Chinese outline: 第一章, then "一、" items, and a legal level showing 1.1.
let mut n = Numbering::default();
let id = n.add_list(ListKind::Numbered);
if let Some(a) = n.abstracts.first_mut() {
a.levels[0] = Level { format: NumFormat::ChineseCounting, text: "第%1章".into(), ..Level::default() };
a.levels[1] = Level { format: NumFormat::ChineseCounting, text: "%2、".into(), ..Level::default() };
a.levels[2] = Level { format: NumFormat::ChineseCounting, text: "%1.%2.%3".into(), legal: true, ..Level::default() };
a.levels[3] = Level { format: NumFormat::ChineseCounting, text: "(%4)".into(), ..Level::default() };
}
let mut c = Counters::default();
let mut l = |lv| c.next_label(&n, id, lv).unwrap().0;
assert_eq!(l(0), "第一章");
assert_eq!(l(1), "一、");
assert_eq!(l(1), "二、");
assert_eq!(l(1), "三、");
assert_eq!(l(2), "1.3.1", "legal numbering is decimal on every level");
assert_eq!(l(3), "(一)");
for _ in 0..9 {
l(3);
}
assert_eq!(l(3), "(十一)");
assert_eq!(l(0), "第二章");
}
#[test]
fn legal_labels() {
let mut n = Numbering::default();
+64 -12
View File
@@ -447,8 +447,8 @@ pub enum InlineObject {
code: bool,
},
/// The start of a field whose result is ordinary content — formatted, possibly spanning
/// paragraphs — up to the matching [`InlineObject::FieldEnd`]. Citation managers' `ADDIN`
/// fields (Zotero, Mendeley, EndNote) are kept this way; `instr` is the field code. See
/// paragraphs — up to the matching [`InlineObject::FieldEnd`]. Imported TOCs and citation
/// managers' `ADDIN` fields are kept this way; `instr` is the field code. See
/// [`crate::fields`].
FieldStart {
instr: String,
@@ -831,6 +831,30 @@ impl Paragraph {
}
out
}
/// [`Self::plain_text`] as it reads on screen: characters in runs set in a symbol font
/// (Symbol, Wingdings…) become their standard Unicode equivalents
/// ([`crate::symbol_fonts`]). For copying as text and plain-text export; the stored text is
/// unchanged.
pub fn shown_text(&self) -> String {
let mut out = String::with_capacity(self.text.len());
let mut k = 0;
let mut runs = self.run_ranges().peekable();
for (i, c) in self.text.char_indices() {
while runs.peek().is_some_and(|(r, _)| r.end <= i) {
runs.next();
}
if c == OBJ {
if let Some(o) = self.objects.get(k) {
out.push_str(o.plain_text());
}
k += 1;
continue;
}
let font = runs.peek().filter(|(r, _)| r.contains(&i)).and_then(|(_, p)| p.font.as_deref());
out.push(font.and_then(|f| crate::symbol_fonts::symbol_char(f, c)).unwrap_or(c));
}
out
}
/// [`Self::plain_text`] without tracked deletions: the text as it reads with every change accepted.
pub fn final_text(&self) -> String {
self.text_without(&self.deleted_ranges())
@@ -1040,7 +1064,12 @@ impl Paragraph {
if self.runs.is_empty() || a == 0 {
// Keep the formatting of what's deleted for the mark when emptying.
if b == self.text.len() {
self.mark = self.props_of_char(a).clone();
// Formatting only: the mark is a tracked change of its own or it is not, whatever
// revision the deleted text was part of.
let revisions = (self.mark.ins, self.mark.del, self.mark.fmt_change.clone());
let mut mark = self.props_of_char(a).clone();
(mark.ins, mark.del, mark.fmt_change) = revisions;
self.mark = mark;
}
}
let k0 = self.objects_before(a);
@@ -1176,19 +1205,24 @@ impl Paragraph {
self.touch();
}
/// Previous grapheme boundary before `off` (0 at start).
/// Previous caret stop before `off` (0 at start): a grapheme boundary that doesn't split a
/// complex-script syllable ([`crate::cluster`]).
pub fn prev_boundary(&self, off: usize) -> usize {
use unicode_segmentation::GraphemeCursor;
let off = self.clamp(off);
let mut c = GraphemeCursor::new(off, self.text.len(), true);
c.prev_boundary(&self.text, 0).ok().flatten().unwrap_or(0)
crate::cluster::prev_cluster(&self.text, self.clamp(off))
}
/// Next grapheme boundary after `off` (len at end).
/// Next caret stop after `off` (len at end), see [`Self::prev_boundary`].
pub fn next_boundary(&self, off: usize) -> usize {
use unicode_segmentation::GraphemeCursor;
crate::cluster::next_cluster(&self.text, self.clamp(off))
}
/// Where Backspace at `off` deletes from: the previous character in the Brahmic scripts
/// (Myanmar, Khmer, Devanagari, Thai…), where it removes one vowel sign or stacked consonant
/// at a time, otherwise the previous caret stop.
pub fn backspace_from(&self, off: usize) -> usize {
let off = self.clamp(off);
let mut c = GraphemeCursor::new(off, self.text.len(), true);
c.next_boundary(&self.text, 0).ok().flatten().unwrap_or(self.text.len())
match self.text.get(..off).and_then(|t| t.char_indices().next_back()) {
Some((i, c)) if crate::cluster::backspaces_by_char(c) => i,
_ => self.prev_boundary(off),
}
}
/// Start of the word at or before `off` (Ctrl+Left).
pub fn word_start(&self, off: usize) -> usize {
@@ -1407,6 +1441,24 @@ mod tests {
assert_eq!(t.mark.bold, Some(true));
}
#[test]
fn emptying_a_paragraph_keeps_the_formatting_but_not_the_revisions_of_its_text() {
let mut props = bold();
props.ins = Some(3);
props.del = Some(4);
let mut p = Paragraph::with_text("Hello", props);
p.mark = bold();
p.delete(0, 5).unwrap();
assert!(p.is_empty());
assert_eq!(p.mark.bold, Some(true));
assert_eq!((p.mark.ins, p.mark.del), (None, None));
// A mark that is itself a tracked change keeps its own revision.
let mut p = Paragraph::with_text("Hello", bold());
p.mark.del = Some(7);
p.delete(0, 5).unwrap();
assert_eq!((p.mark.ins, p.mark.del), (None, Some(7)));
}
#[test]
fn words() {
let p = Paragraph::with_text("The quick brown fox.", CharProps::default());
+130 -21
View File
@@ -31,13 +31,42 @@ impl Rgb {
}
/// A text colour: automatic (black on light backgrounds, white on dark) or a fixed colour.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Default)]
pub enum TextColor {
#[default]
Auto,
Rgb(Rgb),
}
impl TextColor {
/// `auto` or `automatic` (any case), or a colour [`Rgb::parse`] reads.
pub fn parse(s: &str) -> Option<TextColor> {
if s.eq_ignore_ascii_case("auto") || s.eq_ignore_ascii_case("automatic") { Some(TextColor::Auto) } else { Rgb::parse(s).map(TextColor::Rgb) }
}
}
impl<'de> Deserialize<'de> for TextColor {
/// Reads what it writes (`"Auto"`, `{"Rgb": [r, g, b]}`) and also a string [`TextColor::parse`]
/// reads, so `styles.modify` and `format.set` take the colours `format.color` takes.
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use serde::de::{Error, Unexpected};
#[derive(Deserialize)]
enum Written {
Auto,
Rgb(Rgb),
}
match serde_json::Value::deserialize(d)? {
serde_json::Value::String(s) => {
TextColor::parse(&s).ok_or_else(|| D::Error::invalid_value(Unexpected::Str(&s), &"`auto`, `RRGGBB` or `#RRGGBB`"))
}
v => Ok(match serde_json::from_value(v).map_err(D::Error::custom)? {
Written::Auto => TextColor::Auto,
Written::Rgb(c) => TextColor::Rgb(c),
}),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum Underline {
@@ -299,10 +328,14 @@ pub struct CharProps {
/// Complex-script font (Arabic, Persian, Hebrew…; OOXML `w:rFonts/@w:cs`). `None` = `font`.
#[serde(skip_serializing_if = "Option::is_none")]
pub font_cs: Option<String>,
/// Complex-script size, points (`w:szCs`). `None` = the same as `size` (see [`CharProps::overlay`]).
/// East Asian font (Chinese, Japanese and Korean text; OOXML `w:rFonts/@w:eastAsia`). `None` =
/// inherit, then `font`.
#[serde(skip_serializing_if = "Option::is_none")]
pub font_ea: Option<String>,
/// Complex-script size, points (`w:szCs`). `None` leaves the inherited value unchanged.
#[serde(skip_serializing_if = "Option::is_none")]
pub size_cs: Option<f32>,
/// Complex-script bold / italic (`w:bCs`, `w:iCs`). `None` = the same as `bold` / `italic`.
/// Complex-script bold / italic (`w:bCs`, `w:iCs`). `None` leaves the inherited value unchanged.
#[serde(skip_serializing_if = "Option::is_none")]
pub bold_cs: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -386,27 +419,15 @@ impl CharProps {
/// Apply every `Some` field of `patch` on top of `self`.
///
/// Size, bold and italic come in pairs with their complex-script values (Word's own buttons
/// set both): a patch that sets the plain value and leaves the complex-script one `None` sets
/// both, so a run's size or bold isn't overridden for its Persian/Arabic characters by an
/// inherited `w:szCs` / `w:bCs`. The complex-script font inherits on its own (Word often
/// sets only the Latin font and lets complex-script text keep the style's).
/// Plain and complex-script properties inherit independently. Commands that intend to
/// format both scripts must set both values explicitly.
pub fn overlay(&mut self, patch: &CharProps) {
overlay_fields!(self, patch; style, font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl,
cs, font_cs, size_cs, bold_cs, italic_cs, lang_bidi, link, ins, del, fmt_change);
cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, link, ins, del, fmt_change);
if let Some(p) = &patch.text_effects {
self.text_effects.get_or_insert_with(Default::default).overlay(p);
}
if patch.size.is_some() && patch.size_cs.is_none() {
self.size_cs = None;
}
if patch.bold.is_some() && patch.bold_cs.is_none() {
self.bold_cs = None;
}
if patch.italic.is_some() && patch.italic_cs.is_none() {
self.italic_cs = None;
}
}
pub fn overlaid(mut self, patch: &CharProps) -> CharProps {
self.overlay(patch);
@@ -634,6 +655,33 @@ pub struct NumRef {
pub level: u8,
}
/// A text frame's placement (`w:framePr`, ECMA-376 §17.3.1.11), in points. Offsets apply when
/// the matching alignment is unset; anchors and the wrap keep OOXML's names.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct TextFrame {
/// Width; `None` fits the text.
pub w: Option<f32>,
/// Height, with `h_exact` exact rather than at least; `None` fits the text.
pub h: Option<f32>,
pub h_exact: bool,
pub x: f32,
pub y: f32,
/// `left`, `center`, `right`, `inside`, `outside`.
pub x_align: Option<String>,
/// `top`, `center`, `bottom`, `inside`, `outside` (`inline` is no alignment).
pub y_align: Option<String>,
/// `text` (the column), `margin` or `page`.
pub h_anchor: String,
/// `text` (the paragraph), `margin` or `page`.
pub v_anchor: String,
/// `around`, `auto`, `notBeside`, `none`, `tight` or `through`.
pub wrap: String,
/// Distance from the text left and right, and above and below.
pub h_space: f32,
pub v_space: f32,
}
/// Paragraph formatting. `None` = inherit.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
@@ -651,15 +699,38 @@ pub struct ParaProps {
/// First-line indent, points; negative = hanging.
#[serde(skip_serializing_if = "Option::is_none")]
pub indent_first: Option<f32>,
/// Left (start) indent in characters (`w:leftChars` / `w:startChars`, stored by Word in
/// hundredths). A non-zero value wins over `indent_left`; one character is the size of the
/// paragraph mark's font. `Some(0.0)` cancels an inherited value. Common in East Asian documents.
#[serde(skip_serializing_if = "Option::is_none")]
pub indent_left_chars: Option<f32>,
/// Right (end) indent in characters (`w:rightChars` / `w:endChars`), like `indent_left_chars`.
#[serde(skip_serializing_if = "Option::is_none")]
pub indent_right_chars: Option<f32>,
/// First-line indent in characters (`w:firstLineChars`; negative = `w:hangingChars`), like
/// `indent_left_chars`.
#[serde(skip_serializing_if = "Option::is_none")]
pub indent_first_chars: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub space_before: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub space_after: Option<f32>,
/// HTML-style automatic space before (`w:beforeAutospacing`): 14 pt, none at the top of the
/// document or of a table cell, and collapsing with the space after the paragraph above.
#[serde(skip_serializing_if = "Option::is_none")]
pub space_before_auto: Option<bool>,
/// HTML-style automatic space after (`w:afterAutospacing`): 14 pt, none at the end of a cell.
#[serde(skip_serializing_if = "Option::is_none")]
pub space_after_auto: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub line_spacing: Option<LineSpacing>,
/// Ignore space before/after between paragraphs of the same style.
#[serde(skip_serializing_if = "Option::is_none")]
pub contextual_spacing: Option<bool>,
/// `w:mirrorIndents` (ECMA-376 §17.3.1.18): the left and right indents are the inside and
/// outside ones, so they swap sides on even (left-hand) pages.
#[serde(skip_serializing_if = "Option::is_none")]
pub mirror_indents: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub keep_next: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -689,6 +760,10 @@ pub struct ParaProps {
/// Drop cap: lines to drop (0 = none) — applies to the first character(s).
#[serde(skip_serializing_if = "Option::is_none")]
pub drop_cap: Option<u8>,
/// A text frame (`w:framePr` other than a drop cap): the paragraph is placed apart from the
/// text, which may wrap around it — a footer's page number in a frame, for instance.
#[serde(skip_serializing_if = "Option::is_none")]
pub frame: Option<TextFrame>,
/// Asian typography (`w:kinsoku`): East Asian line-breaking rules — no line starts with
/// closing punctuation such as 、。」 or ends with opening punctuation such as 「(.
/// Unset = on.
@@ -723,9 +798,9 @@ pub struct ParaProps {
impl ParaProps {
pub fn overlay(&mut self, patch: &ParaProps) {
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, space_before, space_after, line_spacing,
contextual_spacing, keep_next, keep_lines, page_break_before, widow_control, outline_level, numbering, tabs, shading, borders,
suppress_hyphens, suppress_line_numbers, bidi, drop_cap, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn,
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars, space_before, space_after, space_before_auto, space_after_auto, line_spacing,
contextual_spacing, mirror_indents, keep_next, keep_lines, page_break_before, widow_control, outline_level, numbering, tabs, shading, borders,
suppress_hyphens, suppress_line_numbers, bidi, drop_cap, frame, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn,
snap_to_grid);
}
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
@@ -735,6 +810,15 @@ impl ParaProps {
pub fn is_empty(&self) -> bool {
*self == ParaProps::default()
}
/// After setting indents in points: keep the indents in characters (left, right, first line)
/// an inherited style may set from winning over them, as Word does (`w:firstLineChars="0"`).
pub fn cancel_char_indents(&mut self, left: bool, right: bool, first: bool) {
for (on, chars) in [(left, &mut self.indent_left_chars), (right, &mut self.indent_right_chars), (first, &mut self.indent_first_chars)] {
if on {
*chars = Some(0.0);
}
}
}
/// Just the formatting: without the tracked changes.
pub fn formatting(&self) -> ParaProps {
ParaProps { num_change: None, fmt_change: None, ..self.clone() }
@@ -996,6 +1080,10 @@ pub struct CellProps {
/// Which way the cell's text runs.
pub text_direction: TextDirection,
pub no_wrap: bool,
/// The cell's last paragraph takes no room when it is empty (`w:hideMark`): a row of such
/// empty cells shrinks to its borders.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub hide_mark: bool,
/// Tracked formatting change (`w:tcPrChange`).
#[serde(skip_serializing_if = "Option::is_none")]
pub fmt_change: Option<Box<PropChange<CellProps>>>,
@@ -1066,6 +1154,27 @@ mod tests {
assert_eq!(TextDirection::from_ooxml("é?"), TextDirection::Horizontal);
}
/// #865: a colour reads as `format.color` takes it (`RRGGBB`, `#RRGGBB`, `auto`), while what
/// documents and settings already hold reads and writes exactly as before.
#[test]
fn text_color_reads_hex_and_keeps_its_serialized_forms() {
let read = |j: &str| serde_json::from_str::<TextColor>(j);
let ink = TextColor::Rgb(Rgb(0x1B, 0x16, 0x11));
assert_eq!(read(r#""1B1611""#).unwrap(), ink);
assert_eq!(read(r##""#1b1611""##).unwrap(), ink);
assert_eq!(read(r#""auto""#).unwrap(), TextColor::Auto);
assert_eq!(read(r#""Automatic""#).unwrap(), TextColor::Auto);
for (c, j) in [(TextColor::Auto, r#""Auto""#), (ink, r#"{"Rgb":[27,22,17]}"#)] {
assert_eq!(serde_json::to_string(&c).unwrap(), j, "written as before");
assert_eq!(read(j).unwrap(), c);
}
for bad in [r#""1B161""#, r#""zzzzzz""#, r#""""#, r#"{"Rgb":"1B1611"}"#, r#"{"Hex":[1,2,3]}"#, "7", "[27,22,17]"] {
assert!(read(bad).is_err(), "{bad}");
}
let p: CharProps = serde_json::from_value(serde_json::json!({"color": "#1B1611", "bold": true})).unwrap();
assert_eq!((p.color, p.bold), (Some(ink), Some(true)));
}
#[test]
fn props_serde_skip_none() {
let p = CharProps { bold: Some(true), ..Default::default() };
+64 -3
View File
@@ -8,6 +8,10 @@ use crate::props::{
};
use crate::styles::{StyleKind, StyleSheet};
/// Automatic (HTML-style) paragraph spacing, points: what Word for Windows gives `w:beforeAutospacing`
/// and `w:afterAutospacing` at any font size (measured).
pub const AUTO_SPACING: f32 = 14.0;
/// Concrete character formatting.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -32,6 +36,9 @@ pub struct ResolvedChar {
pub spacing: f32,
pub scale: f32,
pub position: f32,
/// Kerning: the smallest font size, in points, that is kerned (`w:kern`); 0 = never kerned.
#[serde(default)]
pub kern: f32,
pub outline: bool,
pub shadow: bool,
pub emboss: bool,
@@ -52,6 +59,9 @@ pub struct ResolvedChar {
pub cs: bool,
/// Complex-script font, size, bold and italic (Arabic, Persian, Hebrew… text).
pub font_cs: String,
/// East Asian font (Chinese, Japanese and Korean text), when one is set.
#[serde(skip_serializing_if = "Option::is_none")]
pub font_ea: Option<String>,
pub size_cs: f32,
pub bold_cs: bool,
pub italic_cs: bool,
@@ -79,6 +89,7 @@ impl ResolvedChar {
spacing: c.spacing.unwrap_or(0.0).clamp(-100.0, 100.0),
scale: c.scale.unwrap_or(100.0).clamp(1.0, 600.0),
position: c.position.unwrap_or(0.0).clamp(-800.0, 800.0),
kern: c.kern.filter(|k| k.is_finite()).unwrap_or(0.0).clamp(0.0, 1638.0),
outline: c.outline.unwrap_or(false),
shadow: c.shadow.unwrap_or(false),
emboss: c.emboss.unwrap_or(false),
@@ -93,6 +104,7 @@ impl ResolvedChar {
rtl: c.rtl.unwrap_or(false),
cs: c.cs.unwrap_or(false),
font_cs: c.font_cs.clone().or_else(|| c.font.clone()).unwrap_or_else(|| crate::styles::BODY_FONT.to_string()),
font_ea: c.font_ea.clone(),
size_cs: c.size_cs.or(c.size).unwrap_or(11.0).clamp(1.0, 1638.0),
bold_cs: c.bold_cs.or(c.bold).unwrap_or(false),
italic_cs: c.italic_cs.or(c.italic).unwrap_or(false),
@@ -104,6 +116,11 @@ impl ResolvedChar {
pub fn complex(&self) -> ResolvedChar {
ResolvedChar { font: self.font_cs.clone(), size: self.size_cs, bold: self.bold_cs, italic: self.italic_cs, ..self.clone() }
}
/// Whether the text is kerned: Word kerns only a run with Kerning for fonts set (`w:kern`) at
/// a size of at least that many points, never by default (measured in Word for Windows).
pub fn kerned(&self) -> bool {
self.kern > 0.0 && self.size >= self.kern
}
/// Does a character of this run use the complex-script properties?
pub fn uses_complex(&self, c: char) -> bool {
self.rtl || self.cs || crate::bidi::is_complex_script(c)
@@ -134,8 +151,16 @@ pub struct ResolvedPara {
pub indent_first: f32,
pub space_before: f32,
pub space_after: f32,
/// The space before or after is automatic (HTML-style, [`AUTO_SPACING`]): see
/// `ParaProps::space_before_auto`.
#[serde(default)]
pub before_auto: bool,
#[serde(default)]
pub after_auto: bool,
pub line_spacing: LineSpacing,
pub contextual_spacing: bool,
/// Left/right indents are inside/outside: they swap on even pages.
pub mirror_indents: bool,
pub keep_next: bool,
pub keep_lines: bool,
pub page_break_before: bool,
@@ -147,7 +172,11 @@ pub struct ResolvedPara {
pub borders: Option<Borders>,
pub bidi: bool,
pub drop_cap: u8,
/// A text frame the paragraph sits in (see `ParaProps::frame`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub frame: Option<crate::props::TextFrame>,
pub suppress_hyphens: bool,
pub suppress_line_numbers: bool,
/// Asian typography (see `ParaProps`): East Asian line-breaking rules.
pub kinsoku: bool,
/// Latin words wrap whole (`false`: they may break at any character).
@@ -158,6 +187,10 @@ pub struct ResolvedPara {
pub auto_space_dn: bool,
/// Lines snap to the document grid, when the section has one.
pub snap_to_grid: bool,
/// Indents in characters (left, right, first line; 0 = in points), which win over the
/// point values; see [`ResolvedPara::set_char_unit`].
#[serde(default)]
pub indent_chars: [f32; 3],
}
const MAX_INDENT: f32 = 1584.0; // 22"
@@ -169,6 +202,18 @@ impl ResolvedPara {
pub fn visual_indents(&self) -> (f32, f32) {
if self.bidi { (self.indent_right, self.indent_left) } else { (self.indent_left, self.indent_right) }
}
/// Turn the indents given in characters into points, one character being `size` points: the
/// size of the paragraph mark's font (as Word measures `w:firstLineChars` and friends).
pub fn set_char_unit(&mut self, size: f32) {
let size = if size.is_finite() { size.clamp(1.0, 1638.0) } else { 12.0 };
let [l, r, f] = self.indent_chars;
for (chars, pts) in [(l, &mut self.indent_left), (r, &mut self.indent_right), (f, &mut self.indent_first)] {
if chars != 0.0 {
*pts = (chars * size).clamp(-MAX_INDENT, MAX_INDENT);
}
}
}
}
impl ResolvedPara {
@@ -183,14 +228,17 @@ impl ResolvedPara {
indent_left: clampi(p.indent_left),
indent_right: clampi(p.indent_right),
indent_first: clampi(p.indent_first),
space_before: p.space_before.unwrap_or(0.0).clamp(0.0, 1584.0),
space_after: p.space_after.unwrap_or(0.0).clamp(0.0, 1584.0),
space_before: if p.space_before_auto == Some(true) { AUTO_SPACING } else { p.space_before.unwrap_or(0.0).clamp(0.0, 1584.0) },
space_after: if p.space_after_auto == Some(true) { AUTO_SPACING } else { p.space_after.unwrap_or(0.0).clamp(0.0, 1584.0) },
before_auto: p.space_before_auto == Some(true),
after_auto: p.space_after_auto == Some(true),
line_spacing: match p.line_spacing.unwrap_or_default() {
LineSpacing::Multiple(m) => LineSpacing::Multiple(if m.is_finite() { m.clamp(0.06, 132.0) } else { 1.0 }),
LineSpacing::AtLeast(v) => LineSpacing::AtLeast(if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 }),
LineSpacing::Exactly(v) => LineSpacing::Exactly(if v.is_finite() { v.clamp(0.7, 1584.0) } else { 12.0 }),
},
contextual_spacing: p.contextual_spacing.unwrap_or(false),
mirror_indents: p.mirror_indents.unwrap_or(false),
keep_next: p.keep_next.unwrap_or(false),
keep_lines: p.keep_lines.unwrap_or(false),
page_break_before: p.page_break_before.unwrap_or(false),
@@ -202,7 +250,9 @@ impl ResolvedPara {
borders: p.borders.filter(Borders::any_visible),
bidi: p.bidi.unwrap_or(false),
drop_cap: p.drop_cap.unwrap_or(0).min(10),
frame: p.frame.clone().filter(|_| p.drop_cap.unwrap_or(0) == 0),
suppress_hyphens: p.suppress_hyphens.unwrap_or(false),
suppress_line_numbers: p.suppress_line_numbers.unwrap_or(false),
kinsoku: p.kinsoku.unwrap_or(true),
word_wrap: p.word_wrap.unwrap_or(true),
overflow_punct: p.overflow_punct.unwrap_or(true),
@@ -210,10 +260,15 @@ impl ResolvedPara {
auto_space_de: p.auto_space_de.unwrap_or(true),
auto_space_dn: p.auto_space_dn.unwrap_or(true),
snap_to_grid: p.snap_to_grid.unwrap_or(true),
indent_chars: [p.indent_left_chars, p.indent_right_chars, p.indent_first_chars]
.map(|c| c.filter(|c| c.is_finite()).unwrap_or(0.0).clamp(-MAX_CHARS, MAX_CHARS)),
}
}
}
/// The largest indent in characters (Word allows indents up to 22").
const MAX_CHARS: f32 = 1584.0;
impl StyleSheet {
/// Paragraph properties after defaults and the style chain (without direct formatting).
pub fn para_style_props(&self, style: &str) -> (ParaProps, CharProps) {
@@ -263,7 +318,13 @@ impl StyleSheet {
direct.tabs = Some(merged);
}
base.overlay(&direct);
ResolvedPara::from(&base, &style)
let mut rp = ResolvedPara::from(&base, &style);
// Indents in characters: measured in the style's font size here; layout measures them in
// the paragraph mark's (`set_char_unit`), which direct formatting may change.
if rp.indent_chars.iter().any(|c| *c != 0.0) {
rp.set_char_unit(self.resolve_char(Some(&style), &CharProps::default()).size);
}
rp
}
/// Resolve character formatting for a run in a paragraph of style `para_style`.
+259 -18
View File
@@ -15,6 +15,9 @@ pub enum SectionStart {
NextColumn,
}
/// A number format (ECMA-376 §17.18.59 ST_NumberFormat). Every value the format defines is
/// kept, so a document saves back the format it was opened with even where WordCraft draws it as
/// plain decimal (see [`NumFormat::format`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum NumFormat {
@@ -30,9 +33,127 @@ pub enum NumFormat {
DecimalZero,
Bullet,
None,
Hex,
Chicago,
IdeographDigital,
JapaneseCounting,
Aiueo,
Iroha,
DecimalFullWidth,
DecimalHalfWidth,
JapaneseLegal,
JapaneseDigitalTenThousand,
DecimalEnclosedCircle,
DecimalFullWidth2,
AiueoFullWidth,
IrohaFullWidth,
Ganada,
Chosung,
DecimalEnclosedFullstop,
DecimalEnclosedParen,
DecimalEnclosedCircleChinese,
IdeographEnclosedCircle,
IdeographTraditional,
IdeographZodiac,
IdeographZodiacTraditional,
TaiwaneseCounting,
IdeographLegalTraditional,
TaiwaneseCountingThousand,
TaiwaneseDigital,
ChineseCounting,
ChineseLegalSimplified,
ChineseCountingThousand,
KoreanDigital,
KoreanCounting,
KoreanLegal,
KoreanDigital2,
VietnameseCounting,
RussianLower,
RussianUpper,
NumberInDash,
Hebrew1,
Hebrew2,
ArabicAlpha,
ArabicAbjad,
HindiVowels,
HindiConsonants,
HindiNumbers,
HindiCounting,
ThaiLetters,
ThaiNumbers,
ThaiCounting,
BahtText,
DollarText,
}
impl NumFormat {
/// Every format, in the order ST_NumberFormat lists them (which is also the order of the
/// binary .doc number format codes up to 0x27).
pub const ALL: [NumFormat; 62] = [
NumFormat::Decimal,
NumFormat::UpperRoman,
NumFormat::LowerRoman,
NumFormat::UpperLetter,
NumFormat::LowerLetter,
NumFormat::Ordinal,
NumFormat::CardinalText,
NumFormat::OrdinalText,
NumFormat::Hex,
NumFormat::Chicago,
NumFormat::IdeographDigital,
NumFormat::JapaneseCounting,
NumFormat::Aiueo,
NumFormat::Iroha,
NumFormat::DecimalFullWidth,
NumFormat::DecimalHalfWidth,
NumFormat::JapaneseLegal,
NumFormat::JapaneseDigitalTenThousand,
NumFormat::DecimalEnclosedCircle,
NumFormat::DecimalFullWidth2,
NumFormat::AiueoFullWidth,
NumFormat::IrohaFullWidth,
NumFormat::DecimalZero,
NumFormat::Bullet,
NumFormat::Ganada,
NumFormat::Chosung,
NumFormat::DecimalEnclosedFullstop,
NumFormat::DecimalEnclosedParen,
NumFormat::DecimalEnclosedCircleChinese,
NumFormat::IdeographEnclosedCircle,
NumFormat::IdeographTraditional,
NumFormat::IdeographZodiac,
NumFormat::IdeographZodiacTraditional,
NumFormat::TaiwaneseCounting,
NumFormat::IdeographLegalTraditional,
NumFormat::TaiwaneseCountingThousand,
NumFormat::TaiwaneseDigital,
NumFormat::ChineseCounting,
NumFormat::ChineseLegalSimplified,
NumFormat::ChineseCountingThousand,
NumFormat::KoreanDigital,
NumFormat::KoreanCounting,
NumFormat::KoreanLegal,
NumFormat::KoreanDigital2,
NumFormat::VietnameseCounting,
NumFormat::RussianLower,
NumFormat::RussianUpper,
NumFormat::None,
NumFormat::NumberInDash,
NumFormat::Hebrew1,
NumFormat::Hebrew2,
NumFormat::ArabicAlpha,
NumFormat::ArabicAbjad,
NumFormat::HindiVowels,
NumFormat::HindiConsonants,
NumFormat::HindiNumbers,
NumFormat::HindiCounting,
NumFormat::ThaiLetters,
NumFormat::ThaiNumbers,
NumFormat::ThaiCounting,
NumFormat::BahtText,
NumFormat::DollarText,
];
pub fn ooxml(self) -> &'static str {
match self {
NumFormat::Decimal => "decimal",
@@ -46,30 +167,71 @@ impl NumFormat {
NumFormat::DecimalZero => "decimalZero",
NumFormat::Bullet => "bullet",
NumFormat::None => "none",
NumFormat::Hex => "hex",
NumFormat::Chicago => "chicago",
NumFormat::IdeographDigital => "ideographDigital",
NumFormat::JapaneseCounting => "japaneseCounting",
NumFormat::Aiueo => "aiueo",
NumFormat::Iroha => "iroha",
NumFormat::DecimalFullWidth => "decimalFullWidth",
NumFormat::DecimalHalfWidth => "decimalHalfWidth",
NumFormat::JapaneseLegal => "japaneseLegal",
NumFormat::JapaneseDigitalTenThousand => "japaneseDigitalTenThousand",
NumFormat::DecimalEnclosedCircle => "decimalEnclosedCircle",
NumFormat::DecimalFullWidth2 => "decimalFullWidth2",
NumFormat::AiueoFullWidth => "aiueoFullWidth",
NumFormat::IrohaFullWidth => "irohaFullWidth",
NumFormat::Ganada => "ganada",
NumFormat::Chosung => "chosung",
NumFormat::DecimalEnclosedFullstop => "decimalEnclosedFullstop",
NumFormat::DecimalEnclosedParen => "decimalEnclosedParen",
NumFormat::DecimalEnclosedCircleChinese => "decimalEnclosedCircleChinese",
NumFormat::IdeographEnclosedCircle => "ideographEnclosedCircle",
NumFormat::IdeographTraditional => "ideographTraditional",
NumFormat::IdeographZodiac => "ideographZodiac",
NumFormat::IdeographZodiacTraditional => "ideographZodiacTraditional",
NumFormat::TaiwaneseCounting => "taiwaneseCounting",
NumFormat::IdeographLegalTraditional => "ideographLegalTraditional",
NumFormat::TaiwaneseCountingThousand => "taiwaneseCountingThousand",
NumFormat::TaiwaneseDigital => "taiwaneseDigital",
NumFormat::ChineseCounting => "chineseCounting",
NumFormat::ChineseLegalSimplified => "chineseLegalSimplified",
NumFormat::ChineseCountingThousand => "chineseCountingThousand",
NumFormat::KoreanDigital => "koreanDigital",
NumFormat::KoreanCounting => "koreanCounting",
NumFormat::KoreanLegal => "koreanLegal",
NumFormat::KoreanDigital2 => "koreanDigital2",
NumFormat::VietnameseCounting => "vietnameseCounting",
NumFormat::RussianLower => "russianLower",
NumFormat::RussianUpper => "russianUpper",
NumFormat::NumberInDash => "numberInDash",
NumFormat::Hebrew1 => "hebrew1",
NumFormat::Hebrew2 => "hebrew2",
NumFormat::ArabicAlpha => "arabicAlpha",
NumFormat::ArabicAbjad => "arabicAbjad",
NumFormat::HindiVowels => "hindiVowels",
NumFormat::HindiConsonants => "hindiConsonants",
NumFormat::HindiNumbers => "hindiNumbers",
NumFormat::HindiCounting => "hindiCounting",
NumFormat::ThaiLetters => "thaiLetters",
NumFormat::ThaiNumbers => "thaiNumbers",
NumFormat::ThaiCounting => "thaiCounting",
NumFormat::BahtText => "bahtText",
NumFormat::DollarText => "dollarText",
}
}
/// The format an ST_NumberFormat value names; a value the format doesn't define is decimal
/// (as Word shows it). So is `custom` (a Word 2010 format string, always written with a
/// decimal fallback).
pub fn from_ooxml(s: &str) -> NumFormat {
[
NumFormat::Decimal,
NumFormat::UpperRoman,
NumFormat::LowerRoman,
NumFormat::UpperLetter,
NumFormat::LowerLetter,
NumFormat::Ordinal,
NumFormat::CardinalText,
NumFormat::OrdinalText,
NumFormat::DecimalZero,
NumFormat::Bullet,
NumFormat::None,
]
.into_iter()
.find(|f| f.ooxml() == s)
.unwrap_or(NumFormat::Decimal)
NumFormat::ALL.into_iter().find(|f| f.ooxml() == s).unwrap_or(NumFormat::Decimal)
}
/// Format `n` (1-based).
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Arabic, Hindi, Thai,
/// Vietnamese, Russian, text amounts) show decimal, as do values past a sequence's end
/// (the 21st item of ①…⑳).
pub fn format(self, n: u32) -> String {
use crate::cjk_numbers as cjk;
match self {
NumFormat::Decimal => n.to_string(),
NumFormat::DecimalZero => format!("{n:02}"),
NumFormat::UpperRoman => roman(n).to_uppercase(),
NumFormat::LowerRoman => roman(n),
@@ -79,10 +241,70 @@ impl NumFormat {
NumFormat::CardinalText => cardinal_text(n),
NumFormat::OrdinalText => ordinal_text(n),
NumFormat::Bullet | NumFormat::None => String::new(),
NumFormat::Chicago => chicago(n),
NumFormat::NumberInDash => format!("- {n} -"),
NumFormat::IdeographDigital | NumFormat::KoreanDigital2 | NumFormat::TaiwaneseDigital => cjk::ideograph_digital(n),
NumFormat::JapaneseCounting => cjk::japanese_counting(n),
NumFormat::JapaneseLegal => cjk::japanese_legal(n),
NumFormat::JapaneseDigitalTenThousand => cjk::japanese_digital_ten_thousand(n),
NumFormat::Aiueo => cjk::aiueo(n),
NumFormat::AiueoFullWidth => cjk::aiueo_full_width(n),
NumFormat::Iroha => cjk::iroha(n),
NumFormat::IrohaFullWidth => cjk::iroha_full_width(n),
NumFormat::DecimalFullWidth | NumFormat::DecimalFullWidth2 => cjk::decimal_full_width(n),
NumFormat::DecimalEnclosedCircle => cjk::decimal_enclosed_circle(n),
NumFormat::DecimalEnclosedCircleChinese => cjk::decimal_enclosed_circle_chinese(n),
NumFormat::DecimalEnclosedFullstop => cjk::decimal_enclosed_fullstop(n),
NumFormat::DecimalEnclosedParen => cjk::decimal_enclosed_paren(n),
NumFormat::IdeographEnclosedCircle => cjk::ideograph_enclosed_circle(n),
NumFormat::IdeographTraditional => cjk::ideograph_traditional(n),
NumFormat::IdeographZodiac => cjk::ideograph_zodiac(n),
NumFormat::IdeographZodiacTraditional => cjk::ideograph_zodiac_traditional(n),
NumFormat::IdeographLegalTraditional => cjk::ideograph_legal_traditional(n),
NumFormat::TaiwaneseCounting => cjk::taiwanese_counting(n),
NumFormat::TaiwaneseCountingThousand => cjk::taiwanese_counting_thousand(n),
NumFormat::ChineseCounting => cjk::chinese_counting(n),
NumFormat::ChineseCountingThousand => cjk::chinese_counting_thousand(n),
NumFormat::ChineseLegalSimplified => cjk::chinese_legal_simplified(n),
NumFormat::KoreanCounting => cjk::korean_counting(n),
NumFormat::KoreanDigital => cjk::korean_digital(n),
NumFormat::KoreanLegal => cjk::korean_legal(n),
NumFormat::Ganada => cjk::ganada(n),
NumFormat::Chosung => cjk::chosung(n),
NumFormat::Decimal
| NumFormat::DecimalHalfWidth
| NumFormat::Hex
| NumFormat::VietnameseCounting
| NumFormat::RussianLower
| NumFormat::RussianUpper
| NumFormat::Hebrew1
| NumFormat::Hebrew2
| NumFormat::ArabicAlpha
| NumFormat::ArabicAbjad
| NumFormat::HindiVowels
| NumFormat::HindiConsonants
| NumFormat::HindiNumbers
| NumFormat::HindiCounting
| NumFormat::ThaiLetters
| NumFormat::ThaiNumbers
| NumFormat::ThaiCounting
| NumFormat::BahtText
| NumFormat::DollarText => n.to_string(),
}
}
}
/// Chicago Manual of Style note marks: *, †, ‡, §, then doubled (**, ††, …) and so on.
fn chicago(n: u32) -> String {
const MARKS: [char; 4] = ['*', '\u{2020}', '\u{2021}', '\u{a7}'];
if n == 0 {
return String::new();
}
let i = (n - 1) as usize;
let k = (i / 4 + 1).min(10);
std::iter::repeat_n(MARKS.get(i % 4).copied().unwrap_or('*'), k).collect()
}
fn ordinal_suffix(n: u32) -> &'static str {
match (n % 10, n % 100) {
(_, 11..=13) => "th",
@@ -459,6 +681,10 @@ pub struct SectionProps {
pub page_borders: Option<Borders>,
/// Text direction / bidi section.
pub rtl: bool,
/// Which way the section's text runs (`w:textDirection`, ECMA-376 §17.6.20): vertical
/// sections turn the page's text area (headers and footers stay horizontal).
#[serde(skip_serializing_if = "is_horizontal")]
pub text_direction: crate::props::TextDirection,
/// Tracked change of the section's properties (`w:sectPrChange`).
#[serde(skip_serializing_if = "Option::is_none")]
pub fmt_change: Option<Box<crate::props::PropChange<SectionProps>>>,
@@ -496,6 +722,7 @@ impl Default for SectionProps {
valign: VAlign::Top,
page_borders: None,
rtl: false,
text_direction: crate::props::TextDirection::Horizontal,
fmt_change: None,
doc_grid: None,
footnote_pr: NoteProps::default(),
@@ -504,6 +731,10 @@ impl Default for SectionProps {
}
}
fn is_horizontal(d: &crate::props::TextDirection) -> bool {
!d.is_turned()
}
impl SectionProps {
/// Width of the text area, points.
pub fn text_width(&self) -> f32 {
@@ -595,6 +826,16 @@ mod tests {
assert_eq!(NumFormat::DecimalZero.format(3), "03");
assert!(!NumFormat::UpperRoman.format(u32::MAX).is_empty());
assert_eq!(NumFormat::LowerLetter.format(u32::MAX).len(), 30);
assert_eq!([NumFormat::Chicago.format(1), NumFormat::Chicago.format(6)], ["*", "\u{2020}\u{2020}"]);
assert_eq!(NumFormat::ChineseCounting.format(11), "十一");
assert_eq!(NumFormat::ChineseCountingThousand.format(101), "一百零一");
assert_eq!(NumFormat::DecimalEnclosedCircle.format(3), "③");
assert_eq!(NumFormat::Hebrew1.format(3), "3", "formats not drawn yet show decimal");
for f in NumFormat::ALL {
assert_eq!(NumFormat::from_ooxml(f.ooxml()), f, "{f:?} round-trips");
assert!(f == NumFormat::Bullet || f == NumFormat::None || !f.format(u32::MAX).is_empty(), "{f:?}");
}
assert_eq!(NumFormat::from_ooxml("klingon"), NumFormat::Decimal, "unknown formats are decimal");
}
#[test]
+324
View File
@@ -0,0 +1,324 @@
//! SmartArt graphics WordCraft makes and edits (Insert › SmartArt): a layout, an outline of items
//! (text and level) and a colour variant.
//!
//! A SmartArt object ([`crate::graphic::Graphic`] with `smart_art` set) keeps this model next to
//! the items it is drawn with. WordCraft lays it out with its own algorithms; the DOCX writer
//! saves it as diagram parts (data, layout, style, colours and the drawing of the shapes) and the
//! reader turns a diagram back into it only when its data part is exactly what WordCraft writes.
//! SmartArt from other programs stays as it was read.
//!
//! Every value from outside (commands, files) goes through [`SmartArtSpec::sanitize`]: item
//! count, text length and levels are capped.
use serde::{Deserialize, Serialize};
/// Most items in a SmartArt graphic.
pub const MAX_ITEMS: usize = 100;
/// Longest item text, characters.
pub const MAX_TEXT: usize = 255;
/// Deepest item level (0 is the top).
pub const MAX_LEVEL: u8 = 7;
/// How a SmartArt graphic arranges its items.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SmartArtLayout {
/// Blocks in rows.
#[default]
BasicList,
/// Steps left to right with arrows between.
Process,
/// Steps around a circle with arrows between.
Cycle,
/// A tree: each item under the item one level up before it.
Hierarchy,
/// Stacked bands narrowing to the top.
Pyramid,
/// The first item in the middle, the items under it around it.
Radial,
/// Quadrants of a square.
Matrix,
/// Overlapping circles.
Venn,
}
impl SmartArtLayout {
pub const ALL: [SmartArtLayout; 8] = [
SmartArtLayout::BasicList,
SmartArtLayout::Process,
SmartArtLayout::Cycle,
SmartArtLayout::Hierarchy,
SmartArtLayout::Pyramid,
SmartArtLayout::Radial,
SmartArtLayout::Matrix,
SmartArtLayout::Venn,
];
/// The id commands use (`basicList`, `process`…).
pub fn id(self) -> &'static str {
match self {
SmartArtLayout::BasicList => "basicList",
SmartArtLayout::Process => "process",
SmartArtLayout::Cycle => "cycle",
SmartArtLayout::Hierarchy => "hierarchy",
SmartArtLayout::Pyramid => "pyramid",
SmartArtLayout::Radial => "radial",
SmartArtLayout::Matrix => "matrix",
SmartArtLayout::Venn => "venn",
}
}
/// The layout an id names (any ASCII case; `list` is the basic list).
pub fn from_id(id: &str) -> Option<SmartArtLayout> {
let id = id.trim();
if id.eq_ignore_ascii_case("list") {
return Some(SmartArtLayout::BasicList);
}
SmartArtLayout::ALL.into_iter().find(|l| l.id().eq_ignore_ascii_case(id))
}
/// The name shown for it.
pub fn label(self) -> &'static str {
match self {
SmartArtLayout::BasicList => "Basic List",
SmartArtLayout::Process => "Basic Process",
SmartArtLayout::Cycle => "Basic Cycle",
SmartArtLayout::Hierarchy => "Hierarchy",
SmartArtLayout::Pyramid => "Basic Pyramid",
SmartArtLayout::Radial => "Basic Radial",
SmartArtLayout::Matrix => "Basic Matrix",
SmartArtLayout::Venn => "Basic Venn",
}
}
/// The gallery category it's shown under.
pub fn category(self) -> &'static str {
match self {
SmartArtLayout::BasicList => "List",
SmartArtLayout::Process => "Process",
SmartArtLayout::Cycle => "Cycle",
SmartArtLayout::Hierarchy => "Hierarchy",
SmartArtLayout::Pyramid => "Pyramid",
SmartArtLayout::Radial | SmartArtLayout::Venn => "Relationship",
SmartArtLayout::Matrix => "Matrix",
}
}
/// What it's for, in a sentence (the picker shows it).
pub fn description(self) -> &'static str {
match self {
SmartArtLayout::BasicList => "Shows items of equal weight as blocks in rows.",
SmartArtLayout::Process => "Shows steps in order, left to right, with arrows between them.",
SmartArtLayout::Cycle => "Shows steps that repeat, around a circle.",
SmartArtLayout::Hierarchy => "Shows a tree: each item below the item it belongs to.",
SmartArtLayout::Pyramid => "Shows parts that build on each other, largest at the bottom.",
SmartArtLayout::Radial => "Shows items around a central idea: the first item is the centre.",
SmartArtLayout::Matrix => "Shows parts of a whole as quadrants.",
SmartArtLayout::Venn => "Shows overlapping ideas as overlapping circles.",
}
}
}
/// The colours a SmartArt graphic takes from the document's theme.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SmartArtColors {
/// Every shape in accent `n` (1–6).
#[default]
Accent1,
Accent2,
Accent3,
Accent4,
Accent5,
Accent6,
/// Shapes take the accents in turn.
Colorful,
}
impl SmartArtColors {
pub const ALL: [SmartArtColors; 7] = [
SmartArtColors::Accent1,
SmartArtColors::Accent2,
SmartArtColors::Accent3,
SmartArtColors::Accent4,
SmartArtColors::Accent5,
SmartArtColors::Accent6,
SmartArtColors::Colorful,
];
pub fn id(self) -> &'static str {
match self {
SmartArtColors::Accent1 => "accent1",
SmartArtColors::Accent2 => "accent2",
SmartArtColors::Accent3 => "accent3",
SmartArtColors::Accent4 => "accent4",
SmartArtColors::Accent5 => "accent5",
SmartArtColors::Accent6 => "accent6",
SmartArtColors::Colorful => "colorful",
}
}
pub fn from_id(id: &str) -> Option<SmartArtColors> {
SmartArtColors::ALL.into_iter().find(|c| c.id().eq_ignore_ascii_case(id.trim()))
}
/// The name shown for it.
pub fn label(self) -> &'static str {
match self {
SmartArtColors::Accent1 => "Accent 1",
SmartArtColors::Accent2 => "Accent 2",
SmartArtColors::Accent3 => "Accent 3",
SmartArtColors::Accent4 => "Accent 4",
SmartArtColors::Accent5 => "Accent 5",
SmartArtColors::Accent6 => "Accent 6",
SmartArtColors::Colorful => "Colorful",
}
}
/// The theme accent (1–6) of shape `i`.
pub fn accent(self, i: usize) -> usize {
match self {
SmartArtColors::Accent1 => 1,
SmartArtColors::Accent2 => 2,
SmartArtColors::Accent3 => 3,
SmartArtColors::Accent4 => 4,
SmartArtColors::Accent5 => 5,
SmartArtColors::Accent6 => 6,
SmartArtColors::Colorful => i % 6 + 1,
}
}
}
/// One item of the outline: its text and level (0 is the top).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SmartArtItem {
pub text: String,
#[serde(default)]
pub level: u8,
}
impl SmartArtItem {
pub fn new(text: impl Into<String>, level: u8) -> SmartArtItem {
SmartArtItem { text: text.into(), level }
}
}
/// A SmartArt graphic WordCraft can edit.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SmartArtSpec {
pub layout: SmartArtLayout,
#[serde(default)]
pub items: Vec<SmartArtItem>,
#[serde(default)]
pub colors: SmartArtColors,
}
impl SmartArtSpec {
/// A new graphic of `layout` with WordCraft's sample items.
pub fn sample(layout: SmartArtLayout) -> SmartArtSpec {
let items: Vec<SmartArtItem> = match layout {
SmartArtLayout::Hierarchy => vec![
SmartArtItem::new("Lead", 0),
SmartArtItem::new("Design", 1),
SmartArtItem::new("Sketches", 2),
SmartArtItem::new("Build", 1),
SmartArtItem::new("Code", 2),
SmartArtItem::new("Tests", 2),
],
SmartArtLayout::Radial => {
let mut v = vec![SmartArtItem::new("Goal", 0)];
v.extend(["Plan", "Build", "Test", "Ship"].map(|t| SmartArtItem::new(t, 1)));
v
}
SmartArtLayout::Process | SmartArtLayout::Cycle => ["Plan", "Build", "Test", "Ship"].map(|t| SmartArtItem::new(t, 0)).to_vec(),
SmartArtLayout::Pyramid | SmartArtLayout::Venn => ["People", "Process", "Tools"].map(|t| SmartArtItem::new(t, 0)).to_vec(),
SmartArtLayout::Matrix => ["Strengths", "Weaknesses", "Chances", "Risks"].map(|t| SmartArtItem::new(t, 0)).to_vec(),
SmartArtLayout::BasicList => ["Ideas", "Notes", "Drafts", "Edits", "Final"].map(|t| SmartArtItem::new(t, 0)).to_vec(),
};
SmartArtSpec { layout, items, colors: SmartArtColors::default() }
}
/// Make the graphic safe to store and draw: at most [`MAX_ITEMS`] items, text cut to
/// [`MAX_TEXT`] characters with control characters as spaces and the ends trimmed, and levels
/// that follow on (the first item at level 0, each item at most one level below the one
/// before it, never below [`MAX_LEVEL`]).
pub fn sanitize(&mut self) {
self.items.truncate(MAX_ITEMS);
let mut prev: Option<u8> = None;
for it in &mut self.items {
it.text = clean_text(&it.text);
let most = prev.map_or(0, |p| p.saturating_add(1)).min(MAX_LEVEL);
it.level = it.level.min(most);
prev = Some(it.level);
}
}
/// The graphic after `sanitize`.
pub fn sanitized(mut self) -> SmartArtSpec {
self.sanitize();
self
}
/// The index of the item each item belongs to (the nearest item before it one level up);
/// `None` for the top-level items. Expects a sanitized graphic.
pub fn parents(&self) -> Vec<Option<usize>> {
let mut out = Vec::with_capacity(self.items.len());
// The last item seen at each level.
let mut last: Vec<usize> = Vec::new();
for (i, it) in self.items.iter().enumerate() {
let l = usize::from(it.level);
last.truncate(l);
out.push(if l == 0 { None } else { last.last().copied() });
last.push(i);
}
out
}
/// The items that belong to item `i` (directly or deeper): the run after it at deeper levels.
pub fn descendants(&self, i: usize) -> std::ops::Range<usize> {
let Some(level) = self.items.get(i).map(|it| it.level) else { return 0..0 };
let start = i + 1;
let end = self.items.iter().skip(start).position(|it| it.level <= level).map_or(self.items.len(), |p| start + p);
start..end
}
}
/// `s` as item text: control characters as spaces, ends trimmed, at most [`MAX_TEXT`] characters.
pub fn clean_text(s: &str) -> String {
let s: String = s.chars().take(MAX_TEXT * 4).map(|c| if c.is_control() { ' ' } else { c }).collect();
s.trim().chars().take(MAX_TEXT).collect::<String>().trim_end().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_caps_items_text_and_levels() {
let mut s = SmartArtSpec {
items: (0..MAX_ITEMS + 20).map(|i| SmartArtItem::new(format!("{}\n{i}", "x".repeat(400)), if i == 0 { 5 } else { 200 })).collect(),
..Default::default()
};
s.sanitize();
assert_eq!(s.items.len(), MAX_ITEMS);
assert_eq!(s.items[0].level, 0, "the first item is at the top");
assert_eq!(s.items[1].level, 1, "one level below the item before");
assert!(s.items.iter().all(|it| it.level <= MAX_LEVEL));
assert_eq!(s.items[0].text.chars().count(), MAX_TEXT);
assert!(!s.items[0].text.contains('\n'));
}
#[test]
fn parents_and_descendants_follow_levels() {
let s = SmartArtSpec::sample(SmartArtLayout::Hierarchy).sanitized();
assert_eq!(s.parents(), vec![None, Some(0), Some(1), Some(0), Some(3), Some(3)]);
assert_eq!(s.descendants(0), 1..6);
assert_eq!(s.descendants(1), 2..3);
assert_eq!(s.descendants(5), 6..6);
assert_eq!(s.descendants(99), 0..0);
assert_eq!(SmartArtLayout::from_id("VENN"), Some(SmartArtLayout::Venn));
assert_eq!(SmartArtColors::from_id("colorful"), Some(SmartArtColors::Colorful));
}
}
+31 -2
View File
@@ -138,6 +138,9 @@ pub struct StyleSheet {
/// Document defaults (`w:docDefaults`).
pub default_chr: CharProps,
pub default_para: ParaProps,
/// Preserve an explicit `w:pPrDefault`, even when empty. Omission leaves defaults to
/// the application; nonempty `default_para` is always written after spacing edits.
pub default_para_present: bool,
pub styles: Vec<Style>,
}
@@ -165,13 +168,13 @@ fn color(c: Rgb) -> Option<TextColor> {
impl StyleSheet {
pub fn empty() -> Self {
StyleSheet { default_chr: CharProps::default(), default_para: ParaProps::default(), styles: Vec::new() }
StyleSheet { default_chr: CharProps::default(), default_para: ParaProps::default(), default_para_present: true, styles: Vec::new() }
}
/// The built-in style set used for new documents.
pub fn builtin() -> Self {
let mut s = StyleSheet {
default_chr: CharProps { font: Some(BODY_FONT.into()), size: Some(12.0), kern: Some(0.0), lang: Some("en-US".into()), ..chr() },
default_chr: CharProps { font: Some(BODY_FONT.into()), size: Some(12.0), kern: Some(1.0), lang: Some("en-US".into()), ..chr() },
default_para: ParaProps {
space_after: Some(8.0),
space_before: Some(0.0),
@@ -179,6 +182,7 @@ impl StyleSheet {
widow_control: Some(true),
..para()
},
default_para_present: true,
styles: Vec::new(),
};
let mut add = |st: Style| s.styles.push(st);
@@ -440,6 +444,19 @@ impl StyleSheet {
out.reverse();
out
}
/// Which indents (left, first line) of a paragraph that gets its list from style `id` the style
/// chain sets over the list level's own. Word applies the level's indents on top of the most
/// derived style that gives a list, so that style and the ones based on it win over the level,
/// and the styles it is based on don't (Heading 3 with its own left indent on Heading 2's
/// list). `(false, false)` when no style in the chain gives a list.
pub fn indents_over_list(&self, id: &str) -> (bool, bool) {
let chain = self.chain(id);
let Some(k) = chain.iter().rposition(|s| s.para.numbering.is_some()) else { return (false, false) };
let set = |v: Option<f32>, chars: Option<f32>| v.is_some() || chars.is_some_and(|c| c != 0.0);
chain.get(k..).unwrap_or_default().iter().fold((false, false), |(l, f), s| {
(l || set(s.para.indent_left, s.para.indent_left_chars), f || set(s.para.indent_first, s.para.indent_first_chars))
})
}
/// Table style `id` with its based-on chain merged, base styles first (`None` when there is no
/// such table style). Styles of other kinds in the chain are skipped.
pub fn table_style(&self, id: &str) -> Option<TableStyleProps> {
@@ -588,6 +605,18 @@ mod tests {
assert!(g.iter().any(|s| s.id == "Heading1"));
}
#[test]
fn older_serialized_styles_keep_explicit_defaults() {
let mut value = serde_json::to_value(StyleSheet::empty()).unwrap();
value.as_object_mut().unwrap().remove("defaultParaPresent");
let sheet: StyleSheet = serde_json::from_value(value).unwrap();
assert!(sheet.default_para_present);
let mut omitted = sheet;
omitted.default_para_present = false;
let again: StyleSheet = serde_json::from_str(&serde_json::to_string(&omitted).unwrap()).unwrap();
assert_eq!(again, omitted);
}
#[test]
fn chain_is_cycle_safe() {
let mut s = StyleSheet::builtin();
+296
View File
@@ -0,0 +1,296 @@
//! Word's symbol fonts — Symbol, Wingdings, Wingdings 2, Wingdings 3 and Webdings — mapped to
//! standard Unicode characters, so text set in them shows the intended symbols when the font
//! isn't installed (they are proprietary and never bundled) and exports as real characters.
//!
//! Word stores a symbol-font character either as the private-use code U+F000 + the font's own
//! 8-bit code (U+F020–U+F0FF, what Insert › Symbol and `w:sym` write) or as the plain 8-bit code
//! (U+0020–U+00FF) in a run whose font is the symbol font. Both map the same way. The stored text
//! never changes: the mapping is for display, layout and plain-text output only.
//!
//! Sources (Unicode's own published data; no font file was read):
//! - Symbol: the Adobe Symbol encoding table published by Unicode,
//! <https://www.unicode.org/Public/MAPPINGS/VENDORS/ADOBE/symbol.txt> (codes it maps to the
//! private use area are left out; 0x6D maps to GREEK SMALL LETTER MU).
//! - Wingdings, Wingdings 2, Wingdings 3, Webdings: the correspondence tables in "Status of
//! encoding of Wingdings and Webdings Symbols", Unicode L2/12-368 (WG2 N4384, M. Suignard,
//! 2012), the frozen repertoire of the Unicode 7.0 encoding (earlier drafts L2/11-052,
//! L2/11-344), checked against the Unicode Character Database names. The one character not
//! encoded (Wingdings 0xFF) has no entry.
//!
//! Tables are indexed by code − 0x20; 0 = no Unicode equivalent.
/// The symbol fonts this module knows, by lowercase family name.
fn table(family: &str) -> Option<&'static [u32; 224]> {
let f = family.trim();
if f.eq_ignore_ascii_case("symbol") {
Some(&SYMBOL)
} else if f.eq_ignore_ascii_case("wingdings") {
Some(&WINGDINGS)
} else if f.eq_ignore_ascii_case("wingdings 2") {
Some(&WINGDINGS_2)
} else if f.eq_ignore_ascii_case("wingdings 3") {
Some(&WINGDINGS_3)
} else if f.eq_ignore_ascii_case("webdings") {
Some(&WEBDINGS)
} else {
None
}
}
/// Whether `family` is one of Word's symbol fonts (Symbol, Wingdings, Wingdings 2, Wingdings 3,
/// Webdings), whose characters [`symbol_char`] maps.
pub fn is_symbol_font(family: &str) -> bool {
table(family).is_some()
}
/// The standard Unicode character for `c` in a run set in the symbol font `family`: `c` is the
/// font's code stored as U+F020–U+F0FF or as U+0020–U+00FF (except the soft hyphen U+00AD).
/// `None` for other fonts, other characters and codes with no Unicode equivalent.
pub fn symbol_char(family: &str, c: char) -> Option<char> {
let t = table(family)?;
let u = u32::from(c);
let code = match u {
0xF020..=0xF0FF => u - 0xF000,
// A soft hyphen stays a soft hyphen (Word stores `w:softHyphen` as U+00AD).
0xAD => return None,
0x20..=0xFF => u,
_ => return None,
};
let i = usize::try_from(code.checked_sub(0x20)?).ok()?;
t.get(i).copied().filter(|v| *v != 0).and_then(char::from_u32)
}
/// `text` (in a run set in `family`) with every symbol-font character replaced by its Unicode
/// equivalent; unchanged for other fonts.
pub fn map_text(family: &str, text: &str) -> String {
if !is_symbol_font(family) {
return text.to_string();
}
text.chars().map(|c| symbol_char(family, c).unwrap_or(c)).collect()
}
const SYMBOL: [u32; 224] = [
0x0020, 0x0021, 0x2200, 0x0023, 0x2203, 0x0025, 0x0026, 0x220B, // 0x20
0x0028, 0x0029, 0x2217, 0x002B, 0x002C, 0x2212, 0x002E, 0x002F, // 0x28
0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, // 0x30
0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, // 0x38
0x2245, 0x0391, 0x0392, 0x03A7, 0x0394, 0x0395, 0x03A6, 0x0393, // 0x40
0x0397, 0x0399, 0x03D1, 0x039A, 0x039B, 0x039C, 0x039D, 0x039F, // 0x48
0x03A0, 0x0398, 0x03A1, 0x03A3, 0x03A4, 0x03A5, 0x03C2, 0x03A9, // 0x50
0x039E, 0x03A8, 0x0396, 0x005B, 0x2234, 0x005D, 0x22A5, 0x005F, // 0x58
0x0000, 0x03B1, 0x03B2, 0x03C7, 0x03B4, 0x03B5, 0x03C6, 0x03B3, // 0x60
0x03B7, 0x03B9, 0x03D5, 0x03BA, 0x03BB, 0x03BC, 0x03BD, 0x03BF, // 0x68
0x03C0, 0x03B8, 0x03C1, 0x03C3, 0x03C4, 0x03C5, 0x03D6, 0x03C9, // 0x70
0x03BE, 0x03C8, 0x03B6, 0x007B, 0x007C, 0x007D, 0x223C, 0x0000, // 0x78
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0x80
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0x88
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0x90
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0x98
0x20AC, 0x03D2, 0x2032, 0x2264, 0x2044, 0x221E, 0x0192, 0x2663, // 0xA0
0x2666, 0x2665, 0x2660, 0x2194, 0x2190, 0x2191, 0x2192, 0x2193, // 0xA8
0x00B0, 0x00B1, 0x2033, 0x2265, 0x00D7, 0x221D, 0x2202, 0x2022, // 0xB0
0x00F7, 0x2260, 0x2261, 0x2248, 0x2026, 0x0000, 0x0000, 0x21B5, // 0xB8
0x2135, 0x2111, 0x211C, 0x2118, 0x2297, 0x2295, 0x2205, 0x2229, // 0xC0
0x222A, 0x2283, 0x2287, 0x2284, 0x2282, 0x2286, 0x2208, 0x2209, // 0xC8
0x2220, 0x2207, 0x0000, 0x0000, 0x0000, 0x220F, 0x221A, 0x22C5, // 0xD0
0x00AC, 0x2227, 0x2228, 0x21D4, 0x21D0, 0x21D1, 0x21D2, 0x21D3, // 0xD8
0x25CA, 0x2329, 0x0000, 0x0000, 0x0000, 0x2211, 0x0000, 0x0000, // 0xE0
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xE8
0x0000, 0x232A, 0x222B, 0x2320, 0x0000, 0x2321, 0x0000, 0x0000, // 0xF0
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xF8
];
const WEBDINGS: [u32; 224] = [
0x0020, 0x1F577, 0x1F578, 0x1F572, 0x1F576, 0x1F3C6, 0x1F396, 0x1F587, // 0x20
0x1F5E8, 0x1F5E9, 0x1F5F0, 0x1F5F1, 0x1F336, 0x1F397, 0x1F67E, 0x1F67C, // 0x28
0x1F5D5, 0x1F5D6, 0x1F5D7, 0x23F4, 0x23F5, 0x23F6, 0x23F7, 0x23EA, // 0x30
0x23E9, 0x23EE, 0x23ED, 0x23F8, 0x23F9, 0x23FA, 0x1F5DA, 0x1F5F3, // 0x38
0x1F6E0, 0x1F3D7, 0x1F3D8, 0x1F3D9, 0x1F3DA, 0x1F3DC, 0x1F3ED, 0x1F3DB, // 0x40
0x1F3E0, 0x1F3D6, 0x1F3DD, 0x1F6E3, 0x1F50D, 0x1F3D4, 0x1F441, 0x1F442, // 0x48
0x1F3DE, 0x1F3D5, 0x1F6E4, 0x1F3DF, 0x1F6F3, 0x1F56C, 0x1F56B, 0x1F568, // 0x50
0x1F508, 0x1F394, 0x1F395, 0x1F5EC, 0x1F67D, 0x1F5ED, 0x1F5EA, 0x1F5EB, // 0x58
0x2B94, 0x2714, 0x1F6B2, 0x2B1C, 0x1F6E1, 0x1F4E6, 0x1F6F1, 0x2B1B, // 0x60
0x1F691, 0x1F6C8, 0x1F6E9, 0x1F6F0, 0x1F7C8, 0x1F574, 0x2B24, 0x1F6E5, // 0x68
0x1F694, 0x1F5D8, 0x1F5D9, 0x2753, 0x1F6F2, 0x1F687, 0x1F68D, 0x26F3, // 0x70
0x29B8, 0x2296, 0x1F6AD, 0x1F5EE, 0x23D0, 0x1F5EF, 0x1F5F2, 0x0000, // 0x78
0x1F6B9, 0x1F6BA, 0x1F6C9, 0x1F6CA, 0x1F6BC, 0x1F47D, 0x1F3CB, 0x26F7, // 0x80
0x1F3C2, 0x1F3CC, 0x1F3CA, 0x1F3C4, 0x1F3CD, 0x1F3CE, 0x1F698, 0x1F5E0, // 0x88
0x1F6E2, 0x1F4B0, 0x1F3F7, 0x1F4B3, 0x1F46A, 0x1F5E1, 0x1F5E2, 0x1F5E3, // 0x90
0x272F, 0x1F584, 0x1F585, 0x1F583, 0x1F586, 0x1F5B9, 0x1F5BA, 0x1F5BB, // 0x98
0x1F575, 0x1F570, 0x1F5BD, 0x1F5BE, 0x1F4CB, 0x1F5D2, 0x1F5D3, 0x1F56E, // 0xA0
0x1F4DA, 0x1F5DE, 0x1F5DF, 0x1F5C3, 0x1F5C2, 0x1F5BC, 0x1F3AD, 0x1F39C, // 0xA8
0x1F398, 0x1F399, 0x1F3A7, 0x1F4BF, 0x1F39E, 0x1F4F7, 0x1F39F, 0x1F3AC, // 0xB0
0x1F4FD, 0x1F4F9, 0x1F4FE, 0x1F4FB, 0x1F39A, 0x1F39B, 0x1F4FA, 0x1F4BB, // 0xB8
0x1F5A5, 0x1F5A6, 0x1F5A7, 0x1F579, 0x1F3AE, 0x1F57B, 0x1F57C, 0x1F4DF, // 0xC0
0x1F581, 0x1F580, 0x1F5A8, 0x1F5A9, 0x1F5BF, 0x1F5AA, 0x1F5DC, 0x1F512, // 0xC8
0x1F513, 0x1F5DD, 0x1F4E5, 0x1F4E4, 0x1F573, 0x1F323, 0x1F324, 0x1F325, // 0xD0
0x1F326, 0x2601, 0x1F328, 0x1F327, 0x1F329, 0x1F32A, 0x1F32C, 0x1F32B, // 0xD8
0x1F31C, 0x1F321, 0x1F6CB, 0x1F6CF, 0x1F37D, 0x1F378, 0x1F6CE, 0x1F6CD, // 0xE0
0x24C5, 0x267F, 0x1F6C6, 0x1F588, 0x1F393, 0x1F5E4, 0x1F5E5, 0x1F5E6, // 0xE8
0x1F5E7, 0x1F6EA, 0x1F43F, 0x1F426, 0x1F41F, 0x1F415, 0x1F408, 0x1F66C, // 0xF0
0x1F66E, 0x1F66D, 0x1F66F, 0x1F5FA, 0x1F30D, 0x1F30F, 0x1F30E, 0x1F54A, // 0xF8
];
const WINGDINGS: [u32; 224] = [
0x0020, 0x1F589, 0x2702, 0x2701, 0x1F453, 0x1F56D, 0x1F56E, 0x1F56F, // 0x20
0x1F57F, 0x2706, 0x1F582, 0x1F583, 0x1F4EA, 0x1F4EB, 0x1F4EC, 0x1F4ED, // 0x28
0x1F5C0, 0x1F5C1, 0x1F5CE, 0x1F5CF, 0x1F5D0, 0x1F5C4, 0x23F3, 0x1F5AE, // 0x30
0x1F5B0, 0x1F5B2, 0x1F5B3, 0x1F5B4, 0x1F5AB, 0x1F5AC, 0x2707, 0x270D, // 0x38
0x1F58E, 0x270C, 0x1F58F, 0x1F44D, 0x1F44E, 0x261C, 0x261E, 0x261D, // 0x40
0x1F597, 0x1F590, 0x263A, 0x1F610, 0x2639, 0x1F4A3, 0x1F571, 0x1F3F3, // 0x48
0x1F3F1, 0x2708, 0x263C, 0x1F322, 0x2744, 0x1F546, 0x271E, 0x1F548, // 0x50
0x2720, 0x2721, 0x262A, 0x262F, 0x1F549, 0x2638, 0x2648, 0x2649, // 0x58
0x264A, 0x264B, 0x264C, 0x264D, 0x264E, 0x264F, 0x2650, 0x2651, // 0x60
0x2652, 0x2653, 0x1F670, 0x1F675, 0x26AB, 0x1F53E, 0x25FC, 0x1F78F, // 0x68
0x1F790, 0x2751, 0x2752, 0x1F79F, 0x29EB, 0x25C6, 0x2756, 0x1F799, // 0x70
0x2327, 0x2BB9, 0x2318, 0x1F3F5, 0x1F3F6, 0x1F676, 0x1F677, 0x0000, // 0x78
0x1F10B, 0x2780, 0x2781, 0x2782, 0x2783, 0x2784, 0x2785, 0x2786, // 0x80
0x2787, 0x2788, 0x2789, 0x1F10C, 0x278A, 0x278B, 0x278C, 0x278D, // 0x88
0x278E, 0x278F, 0x2790, 0x2791, 0x2792, 0x2793, 0x1F662, 0x1F660, // 0x90
0x1F661, 0x1F663, 0x1F65E, 0x1F65C, 0x1F65D, 0x1F65F, 0x2219, 0x2022, // 0x98
0x2B1D, 0x2B58, 0x1F786, 0x1F788, 0x1F78A, 0x1F78B, 0x1F53F, 0x25AA, // 0xA0
0x1F78E, 0x1F7C1, 0x1F7C5, 0x2605, 0x1F7CB, 0x1F7CF, 0x1F7D3, 0x1F7D1, // 0xA8
0x2BD0, 0x2316, 0x2BCE, 0x2BCF, 0x2BD1, 0x272A, 0x2730, 0x1F550, // 0xB0
0x1F551, 0x1F552, 0x1F553, 0x1F554, 0x1F555, 0x1F556, 0x1F557, 0x1F558, // 0xB8
0x1F559, 0x1F55A, 0x1F55B, 0x2BB0, 0x2BB1, 0x2BB2, 0x2BB3, 0x2BB4, // 0xC0
0x2BB5, 0x2BB6, 0x2BB7, 0x1F66A, 0x1F66B, 0x1F655, 0x1F654, 0x1F657, // 0xC8
0x1F656, 0x1F650, 0x1F651, 0x1F652, 0x1F653, 0x232B, 0x2326, 0x2B98, // 0xD0
0x2B9A, 0x2B99, 0x2B9B, 0x2B88, 0x2B8A, 0x2B89, 0x2B8B, 0x1F868, // 0xD8
0x1F86A, 0x1F869, 0x1F86B, 0x1F86C, 0x1F86D, 0x1F86F, 0x1F86E, 0x1F878, // 0xE0
0x1F87A, 0x1F879, 0x1F87B, 0x1F87C, 0x1F87D, 0x1F87F, 0x1F87E, 0x21E6, // 0xE8
0x21E8, 0x21E7, 0x21E9, 0x2B04, 0x21F3, 0x2B01, 0x2B00, 0x2B03, // 0xF0
0x2B02, 0x1F8AC, 0x1F8AD, 0x1F5F6, 0x2713, 0x1F5F7, 0x1F5F9, 0x0000, // 0xF8
];
const WINGDINGS_2: [u32; 224] = [
0x0020, 0x1F58A, 0x1F58B, 0x1F58C, 0x1F58D, 0x2704, 0x2700, 0x1F57E, // 0x20
0x1F57D, 0x1F5C5, 0x1F5C6, 0x1F5C7, 0x1F5C8, 0x1F5C9, 0x1F5CA, 0x1F5CB, // 0x28
0x1F5CC, 0x1F5CD, 0x1F4CB, 0x1F5D1, 0x1F5D4, 0x1F5B5, 0x1F5B6, 0x1F5B7, // 0x30
0x1F5B8, 0x1F5AD, 0x1F5AF, 0x1F5B1, 0x1F592, 0x1F593, 0x1F598, 0x1F599, // 0x38
0x1F59A, 0x1F59B, 0x1F448, 0x1F449, 0x1F59C, 0x1F59D, 0x1F59E, 0x1F59F, // 0x40
0x1F5A0, 0x1F5A1, 0x1F446, 0x1F447, 0x1F5A2, 0x1F5A3, 0x1F591, 0x1F5F4, // 0x48
0x1F5F8, 0x1F5F5, 0x2611, 0x2BBD, 0x2612, 0x2BBE, 0x2BBF, 0x1F6C7, // 0x50
0x29B8, 0x1F671, 0x1F674, 0x1F672, 0x1F673, 0x203D, 0x1F679, 0x1F67A, // 0x58
0x1F67B, 0x1F666, 0x1F664, 0x1F665, 0x1F667, 0x1F65A, 0x1F658, 0x1F659, // 0x60
0x1F65B, 0x24EA, 0x2460, 0x2461, 0x2462, 0x2463, 0x2464, 0x2465, // 0x68
0x2466, 0x2467, 0x2468, 0x2469, 0x24FF, 0x2776, 0x2777, 0x2778, // 0x70
0x2779, 0x277A, 0x277B, 0x277C, 0x277D, 0x277E, 0x277F, 0x0000, // 0x78
0x2609, 0x1F315, 0x263D, 0x263E, 0x2E3F, 0x271D, 0x1F547, 0x1F55C, // 0x80
0x1F55D, 0x1F55E, 0x1F55F, 0x1F560, 0x1F561, 0x1F562, 0x1F563, 0x1F564, // 0x88
0x1F565, 0x1F566, 0x1F567, 0x1F668, 0x1F669, 0x22C5, 0x1F784, 0x2981, // 0x90
0x25CF, 0x25CB, 0x1F785, 0x1F787, 0x1F789, 0x2299, 0x29BF, 0x1F78C, // 0x98
0x1F78D, 0x25FE, 0x25A0, 0x25A1, 0x1F791, 0x1F792, 0x1F793, 0x1F794, // 0xA0
0x25A3, 0x1F795, 0x1F796, 0x1F797, 0x1F798, 0x2B29, 0x2B25, 0x25C7, // 0xA8
0x1F79A, 0x25C8, 0x1F79B, 0x1F79C, 0x1F79D, 0x1F79E, 0x2B2A, 0x2B27, // 0xB0
0x25CA, 0x1F7A0, 0x25D6, 0x25D7, 0x2BCA, 0x2BCB, 0x2BC0, 0x2BC1, // 0xB8
0x2B1F, 0x2BC2, 0x2B23, 0x2B22, 0x2BC3, 0x2BC4, 0x1F7A1, 0x1F7A2, // 0xC0
0x1F7A3, 0x1F7A4, 0x1F7A5, 0x1F7A6, 0x1F7A7, 0x1F7A8, 0x1F7A9, 0x1F7AA, // 0xC8
0x1F7AB, 0x1F7AC, 0x1F7AD, 0x1F7AE, 0x1F7AF, 0x1F7B0, 0x1F7B1, 0x1F7B2, // 0xD0
0x1F7B3, 0x1F7B4, 0x1F7B5, 0x1F7B6, 0x1F7B7, 0x1F7B8, 0x1F7B9, 0x1F7BA, // 0xD8
0x1F7BB, 0x1F7BC, 0x1F7BD, 0x1F7BE, 0x1F7BF, 0x1F7C0, 0x1F7C2, 0x1F7C4, // 0xE0
0x1F7C6, 0x1F7C9, 0x1F7CA, 0x2736, 0x1F7CC, 0x1F7CE, 0x1F7D0, 0x1F7D2, // 0xE8
0x2739, 0x1F7C3, 0x1F7C7, 0x272F, 0x1F7CD, 0x1F7D4, 0x2BCC, 0x2BCD, // 0xF0
0x203B, 0x2042, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xF8
];
const WINGDINGS_3: [u32; 224] = [
0x0020, 0x2B60, 0x2B62, 0x2B61, 0x2B63, 0x2B66, 0x2B67, 0x2B69, // 0x20
0x2B68, 0x2B70, 0x2B72, 0x2B71, 0x2B73, 0x2B76, 0x2B78, 0x2B7B, // 0x28
0x2B7D, 0x2B64, 0x2B65, 0x2B6A, 0x2B6C, 0x2B6B, 0x2B6D, 0x2B4D, // 0x30
0x2BA0, 0x2BA1, 0x2BA2, 0x2BA3, 0x2BA4, 0x2BA5, 0x2BA6, 0x2BA7, // 0x38
0x2B90, 0x2B91, 0x2B92, 0x2B93, 0x2B80, 0x2B83, 0x2B7E, 0x2B7F, // 0x40
0x2B84, 0x2B86, 0x2B85, 0x2B87, 0x2B8F, 0x2B8D, 0x2B8E, 0x2B8C, // 0x48
0x2B6E, 0x2B6F, 0x238B, 0x2324, 0x2303, 0x2325, 0x2423, 0x237D, // 0x50
0x21EA, 0x2BB8, 0x1F8A0, 0x1F8A1, 0x1F8A2, 0x1F8A3, 0x1F8A4, 0x1F8A5, // 0x58
0x1F8A6, 0x1F8A7, 0x1F8A8, 0x1F8A9, 0x1F8AA, 0x1F8AB, 0x1F850, 0x1F852, // 0x60
0x1F851, 0x1F853, 0x1F854, 0x1F855, 0x1F857, 0x1F856, 0x1F858, 0x1F859, // 0x68
0x25B2, 0x25BC, 0x25B3, 0x25BD, 0x25C0, 0x25B6, 0x25C1, 0x25B7, // 0x70
0x25E3, 0x25E2, 0x25E4, 0x25E5, 0x1F780, 0x1F782, 0x1F781, 0x0000, // 0x78
0x1F783, 0x2BC5, 0x2BC6, 0x2BC7, 0x2BC8, 0x2B9C, 0x2B9E, 0x2B9D, // 0x80
0x2B9F, 0x1F810, 0x1F812, 0x1F811, 0x1F813, 0x1F814, 0x1F816, 0x1F815, // 0x88
0x1F817, 0x1F818, 0x1F81A, 0x1F819, 0x1F81B, 0x1F81C, 0x1F81E, 0x1F81D, // 0x90
0x1F81F, 0x1F800, 0x1F802, 0x1F801, 0x1F803, 0x1F804, 0x1F806, 0x1F805, // 0x98
0x1F807, 0x1F808, 0x1F80A, 0x1F809, 0x1F80B, 0x1F820, 0x1F822, 0x1F824, // 0xA0
0x1F826, 0x1F828, 0x1F82A, 0x1F82C, 0x1F89C, 0x1F89D, 0x1F89E, 0x1F89F, // 0xA8
0x1F82E, 0x1F830, 0x1F832, 0x1F834, 0x1F836, 0x1F838, 0x1F83A, 0x1F839, // 0xB0
0x1F83B, 0x1F898, 0x1F89A, 0x1F899, 0x1F89B, 0x1F83C, 0x1F83E, 0x1F83D, // 0xB8
0x1F83F, 0x1F840, 0x1F842, 0x1F841, 0x1F843, 0x1F844, 0x1F846, 0x1F845, // 0xC0
0x1F847, 0x2BA8, 0x2BA9, 0x2BAA, 0x2BAB, 0x2BAC, 0x2BAD, 0x2BAE, // 0xC8
0x2BAF, 0x1F860, 0x1F862, 0x1F861, 0x1F863, 0x1F864, 0x1F865, 0x1F867, // 0xD0
0x1F866, 0x1F870, 0x1F872, 0x1F871, 0x1F873, 0x1F874, 0x1F875, 0x1F877, // 0xD8
0x1F876, 0x1F880, 0x1F882, 0x1F881, 0x1F883, 0x1F884, 0x1F885, 0x1F887, // 0xE0
0x1F886, 0x1F890, 0x1F892, 0x1F891, 0x1F893, 0x1F894, 0x1F896, 0x1F895, // 0xE8
0x1F897, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xF0
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xF8
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn both_storage_forms_map_alike() {
for f in ["Symbol", "Wingdings", "Wingdings 2", "Wingdings 3", "Webdings"] {
assert!(is_symbol_font(f));
for code in 0x20u32..=0xFF {
let (Some(pua), Some(plain)) = (char::from_u32(0xF000 + code), char::from_u32(code)) else { continue };
if code == 0xAD {
assert_eq!(symbol_char(f, plain), None);
continue;
}
assert_eq!(symbol_char(f, pua), symbol_char(f, plain), "{f} {code:#X}");
}
}
}
#[test]
fn symbol_uses_the_adobe_symbol_encoding() {
assert_eq!(symbol_char("Symbol", '\u{F061}'), Some('α'));
assert_eq!(symbol_char("symbol", 'a'), Some('α'));
assert_eq!(symbol_char("Symbol", 'm'), Some('μ'));
assert_eq!(symbol_char("Symbol", 'W'), Some('Ω'));
assert_eq!(symbol_char("Symbol", '\u{F0B7}'), Some('•'));
assert_eq!(symbol_char("Symbol", '\u{F0A5}'), Some('∞'));
assert_eq!(symbol_char("Symbol", '\u{F0D6}'), Some('√'));
// The radical extender is private use in the Adobe table: no equivalent.
assert_eq!(symbol_char("Symbol", '\u{F060}'), None);
}
#[test]
fn wingdings_codes() {
assert_eq!(symbol_char("Wingdings", '\u{F04A}'), Some('☺'));
assert_eq!(symbol_char("Wingdings", 'J'), Some('☺'));
assert_eq!(symbol_char("Wingdings", 'L'), Some('☹'));
assert_eq!(symbol_char("Wingdings", '\u{F0FC}'), Some('✓'));
assert_eq!(symbol_char("Wingdings", '\u{F0FE}'), Some('\u{1F5F9}'));
assert_eq!(symbol_char("Wingdings", '\u{F06E}'), Some('◼'));
assert_eq!(symbol_char("Wingdings", '\u{F0A7}'), Some('▪'));
assert_eq!(symbol_char("Wingdings", '\u{F076}'), Some('❖'));
assert_eq!(symbol_char("Wingdings", '\u{F028}'), Some('\u{1F57F}'));
// The one character Unicode didn't encode.
assert_eq!(symbol_char("Wingdings", '\u{F0FF}'), None);
}
#[test]
fn wingdings_2_3_and_webdings_codes() {
assert_eq!(symbol_char("Wingdings 2", '\u{F050}'), Some('\u{1F5F8}'));
assert_eq!(symbol_char("Wingdings 2", 'O'), Some('\u{1F5F4}'));
assert_eq!(symbol_char("Wingdings 2", '\u{F0FA}'), None);
assert_eq!(symbol_char("Wingdings 3", '\u{F021}'), Some('⭠'));
assert_eq!(symbol_char("Wingdings 3", '\u{F0F1}'), None);
assert_eq!(symbol_char("Webdings", '\u{F059}'), Some('\u{1F394}'));
assert_eq!(symbol_char("Webdings", 'a'), Some('\u{2714}'));
assert_eq!(symbol_char("Webdings", '\u{F0FF}'), Some('\u{1F54A}'));
}
#[test]
fn other_fonts_and_characters_are_left_alone() {
assert!(!is_symbol_font("Arial"));
assert_eq!(symbol_char("Arial", '\u{F04A}'), None);
assert_eq!(symbol_char("Wingdings", '\u{1F600}'), None);
assert_eq!(symbol_char("Wingdings", '\u{10FFFF}'), None);
assert_eq!(symbol_char("Wingdings", '\u{1F}'), None);
assert_eq!(map_text("Wingdings", "J\u{F04C}é"), "☺☹\u{1F879}");
assert_eq!(map_text("Arial", "J\u{F04C}"), "J\u{F04C}");
}
}
+45
View File
@@ -16,6 +16,51 @@ fn word_count() {
assert_eq!(count_words(" -- "), 0);
}
#[test]
fn word_count_includes_numbered_list_labels_but_not_bullets() {
let mut d = Document::new();
let num_id = d.numbering.add_list(ListKind::Numbered);
let bul_id = d.numbering.add_list(ListKind::Bullet);
let mut p1 = Paragraph::with_text("Apple", CharProps::default());
p1.props.numbering = Some(props::NumRef { num: num_id, level: 0 });
let mut p2 = Paragraph::with_text("Banana", CharProps::default());
p2.props.numbering = Some(props::NumRef { num: num_id, level: 0 });
let mut p3 = Paragraph::with_text("Cherry", CharProps::default());
p3.props.numbering = Some(props::NumRef { num: bul_id, level: 0 });
let mut p4 = Paragraph::with_text("Date", CharProps::default());
p4.props.numbering = Some(props::NumRef { num: bul_id, level: 0 });
d.body = vec![Arc::new(Block::Para(p1)), Arc::new(Block::Para(p2)), Arc::new(Block::Para(p3)), Arc::new(Block::Para(p4))];
// Numbered items contribute 2 words each (label + text) = 4 words.
// Bullet items contribute 1 word each (bullet marker is not a word + text) = 2 words.
assert_eq!(d.word_count(), 6);
let plain = d.plain_text(StoryRef::Body);
assert_eq!(plain, "Apple\nBanana\nCherry\nDate");
}
#[test]
fn word_count_includes_numbered_labels_with_nothing_suffix() {
let mut d = Document::new();
let num_id = d.numbering.add_list(ListKind::Numbered);
let aid = d.numbering.num(num_id).expect("list num exists").abstract_id;
let a = d.numbering.abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists");
a.levels[0].suffix = numbering::LevelSuffix::Nothing;
let mut p1 = Paragraph::with_text("Apple", CharProps::default());
p1.props.numbering = Some(props::NumRef { num: num_id, level: 0 });
let mut p2 = Paragraph::with_text("Banana Cherry", CharProps::default());
p2.props.numbering = Some(props::NumRef { num: num_id, level: 0 });
d.body = vec![Arc::new(Block::Para(p1)), Arc::new(Block::Para(p2))];
// Label "1." and text "Apple" must each be counted as a separate word (2 words),
// and label "2." and text "Banana Cherry" as 3 words (total: 5 words),
// even though LevelSuffix::Nothing produces no visible separator between label and text.
assert_eq!(d.word_count(), 5);
}
#[test]
fn sections_and_section_mut() {
let mut d = Document::from_text("a\nb\nc");
+2
View File
@@ -12,6 +12,8 @@ workspace = true
wordcraft-doc = { workspace = true }
wordcraft-geom = { workspace = true }
cfb = { workspace = true }
image = { workspace = true }
miniz_oxide = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
+262
View File
@@ -0,0 +1,262 @@
//! Floating shapes and pictures: the `PlcfSpa` anchors of the main document and the headers
//! ([MS-DOC] §2.8.27 PlcfSpa, §2.9.253 Spa) joined with the OfficeArt shapes they name, and the
//! text box stories (`PlcftxbxTxt`, §2.8.30) that fill text boxes.
use std::collections::HashMap;
use wordcraft_doc::para::{Anchor, Float, FloatAlign, GroupChild, InlineObject, ShapeKind, Wrap};
use wordcraft_doc::props::Rgb;
use crate::media::Pictures;
use crate::officeart::{Node, Sp};
/// Most anchors (and text box stories) read per PLC.
const MAX_ANCHORS: usize = 10_000;
/// `Spa` and `FTXBXS` record sizes.
const CB_SPA: usize = 26;
const CB_FTXBXS: usize = 22;
/// Largest frame or offset kept (points; 22 inches, Word's own limit for sizes).
const MAX_PT: f32 = 1584.0;
/// Deepest group nesting flattened.
const MAX_GROUP_DEPTH: usize = 8;
/// The (CP, data) entries of a PLC with `cb`-byte data elements, the trailing CP dropped.
fn plc(table: &[u8], (fc, lcb): (u32, u32), cb: usize) -> Vec<(u32, &[u8])> {
let Some(blob) = table.get(fc as usize..(fc as usize).saturating_add(lcb as usize)) else { return Vec::new() };
let n = blob.len().saturating_sub(4) / (4 + cb);
let n = n.min(MAX_ANCHORS);
let data_at = (n + 1) * 4;
(0..n)
.filter_map(|i| {
let cp = u32::from_le_bytes(blob.get(i * 4..i * 4 + 4)?.try_into().ok()?);
let at = data_at + i * cb;
Some((cp, blob.get(at..at + cb)?))
})
.collect()
}
/// The text box stories of a `PlcftxbxTxt` (or `PlcfHdrtxbxTxt`): (shape spid, story CP range
/// relative to the text box subdocument).
pub(crate) fn textbox_stories(table: &[u8], pair: Option<(u32, u32)>) -> Vec<(u32, (u32, u32))> {
let Some(pair) = pair else { return Vec::new() };
let Some(blob) = table.get(pair.0 as usize..(pair.0 as usize).saturating_add(pair.1 as usize)) else { return Vec::new() };
let mut out = Vec::new();
for (i, (cp, ftxbxs)) in plc(table, pair, CB_FTXBXS).iter().enumerate() {
// The story runs to the next entry's CP (the PLC's last CP for the last entry).
let Some(end) = blob.get((i + 1) * 4..(i + 1) * 4 + 4).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes) else { continue };
let Some(lid) = ftxbxs.get(14..18).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes) else { continue };
if end > *cp && lid != 0 {
out.push((lid, (*cp, end)));
}
}
out
}
/// What building the floating objects needs.
pub(crate) struct Build<'a> {
/// The BLIP store and the stream holding BLIPs not embedded in it (WordDocument).
pub(crate) bstore: &'a [&'a [u8]],
pub(crate) delay: &'a [u8],
/// Text box part ids by shape spid.
pub(crate) stories: &'a HashMap<u32, u32>,
}
/// The floating objects anchored by `PlcfSpa` (at `pair`), as (absolute anchor CP, object),
/// sorted by CP. `cp_base` turns the PLC's CPs into absolute ones (the header subdocument's
/// start for `PlcSpaHdr`).
pub(crate) fn anchors(
table: &[u8],
pair: Option<(u32, u32)>,
cp_base: u32,
shapes: &HashMap<u32, Node>,
b: &Build,
pics: &mut Pictures,
) -> Vec<(u32, InlineObject)> {
let Some(pair) = pair else { return Vec::new() };
let mut out = Vec::new();
for (cp, spa) in plc(table, pair, CB_SPA) {
let i32_at = |o: usize| spa.get(o..o + 4).and_then(|b| b.try_into().ok()).map(i32::from_le_bytes).unwrap_or(0);
let spid = i32_at(0) as u32;
let [xl, yt, xr, yb] = [4, 8, 12, 16].map(i32_at);
let flags = spa.get(20..22).map(|b| u16::from_le_bytes([b[0], b[1]])).unwrap_or(0);
let Some(node) = shapes.get(&spid) else { continue };
let tw = |v: i64| (v as f32 / 20.0).clamp(-2.0 * MAX_PT, 2.0 * MAX_PT);
let (mut x, mut y) = (tw(xl as i64), tw(yt as i64));
let (mut w, mut h) = (tw(xr as i64 - xl as i64).clamp(0.0, MAX_PT), tw(yb as i64 - yt as i64).clamp(0.0, MAX_PT));
let sp = node.sp();
let rot = wordcraft_geom::normalize_degrees(if sp.props.rot.is_finite() { sp.props.rot } else { 0.0 });
// A shape turned by 45°–135° (or 225°–315°) is anchored by its turned bounds: the
// unrotated frame has the other aspect, about the same centre.
if (45.0..135.0).contains(&rot) || (225.0..315.0).contains(&rot) {
x += (w - h) / 2.0;
y += (h - w) / 2.0;
std::mem::swap(&mut w, &mut h);
}
let mut float = spa_float(flags, &sp.props);
float.x = x;
float.y = y;
float.rot = rot;
float.flip_h = sp.flip_h();
float.flip_v = sp.flip_v();
let obj = match node {
Node::Shape(sp) => leaf(sp, w, h, float, b, pics),
Node::Group(sp, members) => group(sp, members, w, h, float, b, pics),
};
match obj {
Some(obj) => out.push((cp_base.saturating_add(cp), obj)),
None => pics.dropped(cp_base.saturating_add(cp)),
}
}
out.sort_by_key(|(cp, _)| *cp);
out
}
/// Placement from a `Spa`'s flags (fHdr, bx, by, wr, wrk, fRcaSimple, fBelowText, fAnchorLock)
/// and the shape's position properties, which take precedence when present.
fn spa_float(flags: u16, p: &crate::officeart::Props) -> Float {
let bx = (flags >> 1) & 3;
let by = (flags >> 3) & 3;
let wr = (flags >> 5) & 0xF;
let below = flags & (1 << 14) != 0;
let wrap = match wr {
1 => Wrap::TopAndBottom,
3 => {
if below {
Wrap::BehindText
} else {
Wrap::InFrontOfText
}
}
4 => Wrap::Tight,
5 => Wrap::Through,
_ => Wrap::Square,
};
let h_rel = match p.posrelh.unwrap_or(bx as u32) {
0 => Anchor::Margin,
1 => Anchor::Page,
3 => Anchor::Character,
_ => Anchor::Column,
};
let v_rel = match p.posrelv.unwrap_or(by as u32) {
0 => Anchor::Margin,
1 => Anchor::Page,
3 => Anchor::Line,
_ => Anchor::Paragraph,
};
let align = |v: Option<u32>| match v {
Some(1) => Some(FloatAlign::Start),
Some(2) => Some(FloatAlign::Center),
Some(3) => Some(FloatAlign::End),
Some(4) => Some(FloatAlign::Inside),
Some(5) => Some(FloatAlign::Outside),
_ => None,
};
// Distances from text, EMUs; Word's defaults are 0.125" at the sides, none above and below.
let emu = |v: Option<u32>, def: f32| v.map_or(def, |e| (e as f32 / 12_700.0).clamp(0.0, MAX_PT));
let [dl, dt, dr, db] = p.wrap_dist;
Float {
wrap,
h_rel,
v_rel,
h_align: align(p.posh),
v_align: align(p.posv),
dist: emu(dl, 9.0).max(emu(dr, 9.0)),
dist_top: emu(dt, 0.0),
dist_bottom: emu(db, 0.0),
..Float::default()
}
}
/// One shape as a picture or a drawn shape, `w` × `h` points.
fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -> Option<InlineObject> {
let p = &sp.props;
if let Some(pib) = p.pib {
let media = pics.bse(pib, b.bstore, b.delay)?;
return Some(InlineObject::Image { media, w, h, alt: p.alt.clone(), float, crop: p.crop, ole: None });
}
let story = b.stories.get(&sp.spid).copied().filter(|_| sp.textbox || sp.spt == 202);
let mut kind = match sp.spt {
1 => ShapeKind::Rectangle,
2 => ShapeKind::RoundedRectangle,
3 => ShapeKind::Ellipse,
4 => ShapeKind::Diamond,
5 | 6 => ShapeKind::Triangle,
12 | 187 => ShapeKind::Star,
13 | 66 | 67 | 68 | 69 => ShapeKind::Arrow,
20 | 32 => ShapeKind::Line,
74 => ShapeKind::Heart,
202 => ShapeKind::TextBox,
// A freeform's path is not read: better left out than drawn as a box.
0 => return None,
_ => ShapeKind::Rectangle,
};
if story.is_some() && kind == ShapeKind::Rectangle {
kind = ShapeKind::TextBox;
}
let line = kind == ShapeKind::Line;
let fill = if line || p.filled == Some(false) { None } else { Some(p.fill_color.unwrap_or(Rgb::WHITE)) };
let stroke = if p.line == Some(false) { None } else { Some(p.line_color.unwrap_or(Rgb::BLACK)) };
// Line widths are EMUs; Word's default is 0.75 pt.
let stroke_width = p.line_width.map_or(0.75, |e| (e as f32 / 12_700.0).clamp(0.0, 100.0));
Some(InlineObject::Shape {
kind,
w,
h,
fill,
stroke,
stroke_width,
float,
story,
freeform: None,
effects: Default::default(),
extra: Default::default(),
})
}
/// A group: its members flattened into the group's own coordinate space (nested groups mapped
/// through their child anchors).
fn group(sp: &Sp, members: &[Node], w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -> Option<InlineObject> {
let [l, t, r, btm] = sp.group_space.unwrap_or([0, 0, 0, 0]);
let (cw, ch) = ((r as f64 - l as f64) as f32, (btm as f64 - t as f64) as f32);
let (ch_w, ch_h) = if cw > 0.0 && ch > 0.0 { (cw, ch) } else { (w.max(1.0), h.max(1.0)) };
let mut children = Vec::new();
// Map: outer = x0 + inner × s; the top group's space starts at its FSPGR origin.
members_into(members, (-(l as f32), -(t as f32), 1.0, 1.0), 0, b, pics, &mut children);
if children.is_empty() {
return None;
}
Some(InlineObject::Group { w, h, float, ch_w, ch_h, children, canvas: None })
}
fn members_into(nodes: &[Node], t: (f32, f32, f32, f32), depth: usize, b: &Build, pics: &mut Pictures, out: &mut Vec<GroupChild>) {
let (x0, y0, sx, sy) = t;
let fit = |v: f32| if v.is_finite() { v.clamp(-1e7, 1e7) } else { 0.0 };
for n in nodes {
if out.len() >= wordcraft_doc::para::MAX_GROUP_CHILDREN {
return;
}
let sp = n.sp();
let Some([l, t, r, btm]) = sp.child else { continue };
let (ox, oy) = (l as f32, t as f32);
let (ew, eh) = ((r as f64 - l as f64) as f32, (btm as f64 - t as f64) as f32);
match n {
Node::Group(_, members) => {
if depth >= MAX_GROUP_DEPTH {
continue;
}
let [gl, gt, gr, gb] = sp.group_space.unwrap_or([l, t, r, btm]);
let (gw, gh) = ((gr as f64 - gl as f64) as f32, (gb as f64 - gt as f64) as f32);
let (kx, ky) = if gw > 0.0 && gh > 0.0 { (ew / gw, eh / gh) } else { (1.0, 1.0) };
let nt = (fit(x0 + (ox - gl as f32 * kx) * sx), fit(y0 + (oy - gt as f32 * ky) * sy), fit(sx * kx), fit(sy * ky));
members_into(members, nt, depth + 1, b, pics, out);
}
Node::Shape(sp) => {
let float = Float { rot: sp.props.rot, flip_h: sp.flip_h(), flip_v: sp.flip_v(), ..Float::default() };
let (w, h) = (fit(ew * sx).max(0.0), fit(eh * sy).max(0.0));
if let Some(obj) = leaf(sp, w, h, float, b, pics) {
out.push(GroupChild { x: fit(x0 + ox * sx), y: fit(y0 + oy * sy), obj });
}
}
}
}
}
+8
View File
@@ -33,8 +33,16 @@ pub(crate) mod pair {
pub(crate) const PLCF_BKF: usize = 22;
pub(crate) const PLCF_BKL: usize = 23;
pub(crate) const CLX: usize = 33;
/// `fcPlcSpaMom` / `fcPlcSpaHdr`: floating shape anchors of the main document / headers.
pub(crate) const PLC_SPA_MOM: usize = 40;
pub(crate) const PLC_SPA_HDR: usize = 41;
pub(crate) const PLCF_END_REF: usize = 46;
pub(crate) const PLCF_END_TXT: usize = 47;
/// `fcDggInfo`: the OfficeArt drawing content (BLIP store, shapes).
pub(crate) const DGG_INFO: usize = 50;
/// `fcPlcftxbxTxt` / `fcPlcfHdrtxbxTxt`: text box stories of the main document / headers.
pub(crate) const PLCF_TXBX_TXT: usize = 56;
pub(crate) const PLCF_HDR_TXBX_TXT: usize = 58;
/// `fcPlfLst`: the list definitions (LSTF array, with the LVLs appended after the blob).
pub(crate) const PLF_LST: usize = 73;
/// `fcPlfLfo`: the list format overrides (LFO array with LFOData appended).
+83 -16
View File
@@ -1,7 +1,7 @@
//! Sprms → model properties, and style-chain resolution.
use wordcraft_doc::Rgb;
use wordcraft_doc::props::{Align, CharProps, Highlight, LineSpacing, ParaProps, TextColor, Underline, VertAlign};
use wordcraft_doc::props::{Align, CharProps, Highlight, LineSpacing, ParaProps, TabAlign, TabLeader, TabStop, TextColor, Underline, VertAlign};
use wordcraft_doc::styles::{Style, StyleKind, StyleSheet};
use crate::sprm::{self, Prl, Toggle};
@@ -30,6 +30,11 @@ const P_DXA_LEFT80: u16 = 0x840F;
const P_DXA_RIGHT: u16 = 0x845D;
const P_DXA_RIGHT80: u16 = 0x840E;
const P_DXA_FIRST: u16 = 0x8460; // sprmPDxaLeft1
const P_DXA_FIRST80: u16 = 0x8411; // sprmPDxaLeft1_80
// Indents in hundredths of a character (sprmPDxcRight, sprmPDxcLeft, sprmPDxcLeft1).
const P_DXC_RIGHT: u16 = 0x4455;
const P_DXC_LEFT: u16 = 0x4456;
const P_DXC_FIRST: u16 = 0x4457;
const P_DYA_BEFORE: u16 = 0xA413;
const P_DYA_AFTER: u16 = 0xA414;
const P_DYA_LINE: u16 = 0x6412;
@@ -182,17 +187,63 @@ pub(crate) fn apply_char(p: &mut CharProps, prl: &Prl, base: &CharProps, fonts:
}
}
/// Custom tab stops from `sprmPChgTabsPapx` / `sprmPChgTabs` ([MS-DOC] PChgTabsPapxOperand,
/// PChgTabsOperand): the stops removed become `Clear` stops (they hide inherited ones) and the
/// stops added carry their alignment and leader (TBD: jc in bits 0–2, tlc in bits 3–5).
fn change_tabs(p: &mut ParaProps, prl: &Prl) {
let op = prl.operand;
let i16_at = |i: usize| op.get(i..i + 2).map(|b| f32::from(i16::from_le_bytes([b[0], b[1]])) / 20.0);
// Both operands start with cb; sprmPChgTabs's removals also carry a "close" distance each.
let close = prl.op == sprm::P_CHG_TABS;
let mut at = 1;
let Some(&del) = op.get(at) else { return };
let del = usize::from(del.min(64));
at += 1;
let mut removed = Vec::with_capacity(del);
for k in 0..del {
if let Some(x) = i16_at(at + 2 * k) {
removed.push(x);
}
}
at += 2 * del * if close { 2 } else { 1 };
let Some(&add) = op.get(at) else { return };
let add = usize::from(add.min(64));
at += 1;
let mut tabs = p.tabs.take().unwrap_or_default();
// Removed within 25 twips (1.25 pt), as the format says.
tabs.retain(|t| !removed.iter().any(|x| (t.pos - x).abs() <= 1.25));
for x in removed {
tabs.push(TabStop { pos: x, align: TabAlign::Clear, leader: TabLeader::None });
}
for k in 0..add {
let (Some(pos), Some(&tbd)) = (i16_at(at + 2 * k), op.get(at + 2 * add + k)) else { break };
let align = match tbd & 7 {
1 => TabAlign::Center,
2 => TabAlign::Right,
3 => TabAlign::Decimal,
4 => TabAlign::Bar,
_ => TabAlign::Left,
};
let leader = match (tbd >> 3) & 7 {
1 => TabLeader::Dot,
2 => TabLeader::Hyphen,
3 | 4 => TabLeader::Underscore,
5 => TabLeader::MiddleDot,
_ => TabLeader::None,
};
tabs.retain(|t| (t.pos - pos).abs() > 0.05);
tabs.push(TabStop { pos, align, leader });
}
tabs.sort_by(|a, b| a.pos.total_cmp(&b.pos));
p.tabs = Some(tabs);
}
/// Apply a paragraph-property sprm.
pub(crate) fn apply_para(p: &mut ParaProps, prl: &Prl) {
let tw = |v: i32| (v as f32 / 20.0).clamp(-1000.0, 1000.0);
let s16 = |prl: &Prl| -> Option<i16> { u16_of(prl).map(|v| v as i16) };
let i32of = |prl: &Prl| -> Option<i32> {
match prl.operand {
[b0, b1, b2, b3, ..] => Some(i32::from_le_bytes([*b0, *b1, *b2, *b3])),
_ => None,
}
};
match prl.op {
sprm::P_CHG_TABS | sprm::P_CHG_TABS_PAPX => change_tabs(p, prl),
P_JC | P_JC80 => {
p.align = match prl.operand.first().copied().unwrap_or(0) {
1 => Some(Align::Center),
@@ -212,9 +263,20 @@ pub(crate) fn apply_para(p: &mut ParaProps, prl: &Prl) {
p.indent_right = Some(tw(v as i32));
}
}
P_DXA_FIRST => {
if let Some(v) = i32of(prl) {
p.indent_first = Some(tw(v));
// A two-byte operand, like the other indents (spra 4).
P_DXA_FIRST | P_DXA_FIRST80 => {
if let Some(v) = s16(prl) {
p.indent_first = Some(tw(v as i32));
}
}
P_DXC_RIGHT | P_DXC_LEFT | P_DXC_FIRST => {
if let Some(v) = s16(prl) {
let chars = Some(f32::from(v) / 100.0);
match prl.op {
P_DXC_RIGHT => p.indent_right_chars = chars,
P_DXC_LEFT => p.indent_left_chars = chars,
_ => p.indent_first_chars = chars,
}
}
}
P_DYA_BEFORE => {
@@ -227,13 +289,18 @@ pub(crate) fn apply_para(p: &mut ParaProps, prl: &Prl) {
p.space_after = Some((v as f32 / 20.0).clamp(0.0, 3168.0));
}
}
// LSPD ([MS-DOC] §2.9.150): a signed dyaLine, then fMultLinespace. In lines (240ths) when
// fMultLinespace is 1; else twips, at least when positive and exactly when negative.
P_DYA_LINE => {
if let Some(v) = u16_of(prl) {
let mult = (v & 0x3FFF) as f32;
p.line_spacing = Some(match v & 0xC000 {
0x0000 => LineSpacing::Multiple((mult / 240.0).clamp(0.1, 20.0)),
0x8000 => LineSpacing::AtLeast((mult / 20.0).clamp(0.0, 1584.0)),
_ => LineSpacing::Exactly((mult / 20.0).clamp(0.0, 1584.0)),
if let Some(v) = s16(prl) {
let mult = prl.operand.get(2..4).is_some_and(|b| b == [1, 0]);
let v = f32::from(v);
p.line_spacing = Some(if mult {
LineSpacing::Multiple((v.abs() / 240.0).clamp(0.06, 132.0))
} else if v < 0.0 {
LineSpacing::Exactly((-v / 20.0).clamp(0.7, 1584.0))
} else {
LineSpacing::AtLeast((v / 20.0).clamp(0.0, 1584.0))
});
}
}
+55 -8
View File
@@ -8,12 +8,14 @@
//! capped), so hostile input yields an error or a best-effort document, never a panic.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod drawing;
mod fib;
mod fkp;
mod fmt;
mod list;
mod media;
mod notes;
mod officeart;
mod piece;
mod sections;
mod sprm;
@@ -139,8 +141,36 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
sheet: &sheet,
notes: &[],
marks: &[],
drawn: &[],
sect_ends: &[],
};
// Drawings: text box stories become parts first, then the floating shapes and pictures
// anchored in the main document and the headers, found by their anchor CP while walking.
let txbx_base = edn_base.saturating_add(fib.ccp.edn);
let hdr_txbx_base = txbx_base.saturating_add(fib.ccp.txbx);
let art = fib
.pair(fib::pair::DGG_INFO)
.and_then(|(f, l)| table.get(f as usize..(f as usize).saturating_add(l as usize)))
.map(officeart::content)
.unwrap_or_default();
let mut tb_stories = std::collections::HashMap::new();
for (pair, base, len) in [(fib::pair::PLCF_TXBX_TXT, txbx_base, fib.ccp.txbx), (fib::pair::PLCF_HDR_TXBX_TXT, hdr_txbx_base, fib.ccp.hdr_txbx)] {
for (spid, ab) in drawing::textbox_stories(&table, fib.pair(pair)) {
let Some((a, b)) = story(base, len, ab) else { continue };
if doc.parts.len() >= MAX_PARTS || tb_stories.contains_key(&spid) {
continue;
}
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
tb_stories.insert(spid, doc.add_part(PartKind::TextBox, blocks));
}
}
let build = drawing::Build { bstore: &art.bstore, delay: &word, stories: &tb_stories };
let mut drawn = drawing::anchors(&table, fib.pair(fib::pair::PLC_SPA_MOM), 0, &art.main, &build, &mut pics);
drawn.extend(drawing::anchors(&table, fib.pair(fib::pair::PLC_SPA_HDR), hdd_base, &art.header, &build, &mut pics));
drawn.sort_by_key(|(cp, _)| *cp);
let ctx = WalkCtx { drawn: &drawn, ..ctx };
// Header and footer parts: skip the six separator stories, then six stories per section
// in the order even header, odd (default) header, even footer, odd (default) footer,
// first header, first footer. Empty stories inherit from the previous section.
@@ -186,7 +216,9 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
// Sorted so the main walk finds each reference by binary search.
note_links.sort_by_key(|(cp, _, _)| *cp);
let marks = notes::parse_bookmarks(&table, &fib);
let ctx = WalkCtx { notes: &note_links, marks: &marks, ..ctx };
let mut sect_ends: Vec<u32> = sects.iter().map(|s| s.end_cp).collect();
sect_ends.sort_unstable();
let ctx = WalkCtx { notes: &note_links, marks: &marks, sect_ends: &sect_ends, ..ctx };
// Sections: each section's properties attach to the paragraph that ends it (its story of
// header/footer parts included); the section reaching the end of the text is the final one.
@@ -246,6 +278,11 @@ struct WalkCtx<'a> {
notes: &'a [(u32, NoteKind, u32)],
/// Bookmark boundaries (main-document CPs), sorted for a sequential walk.
marks: &'a [(u32, notes::Mark)],
/// Floating shapes and pictures by anchor CP (absolute), sorted.
drawn: &'a [(u32, InlineObject)],
/// Where sections end (main-document CPs, sorted): the section mark (0x0C) just before
/// each ends the section's last paragraph, it isn't a page break inside it.
sect_ends: &'a [u32],
}
/// State while assembling one paragraph from per-CP formatting.
@@ -319,8 +356,10 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -
continue;
}
};
// A section mark (0x0C) at a section's end is that section's last paragraph mark.
let section_mark = c == '\u{C}' && ctx.sect_ends.binary_search(&cp.saturating_add(1)).is_ok();
match c {
'\r' | '\u{7}' => {
'\r' | '\u{7}' | '\u{C}' if c != '\u{C}' || section_mark => {
// Paragraph mark: its PAPX covers the FC range of the paragraph it ends, so
// querying at the mark's own FC finds that paragraph's properties.
let fc_mark = ctx.pieces.fc_of_cp(cp).unwrap_or(0);
@@ -405,21 +444,29 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -
let fc = ctx.pieces.fc_of_cp(cp).unwrap_or(0);
let fc_pic = pic_location(ctx.chpx_bins.chpx(ctx.word, fc));
match fc_pic.and_then(|at| pics.get(ctx.data, at)) {
Some((key, w, h)) => {
Some(p) => {
pb.push('\u{FFFC}', CharProps::default());
pb.objects.push(InlineObject::Image {
media: key,
w,
h,
alt: String::new(),
media: p.key,
w: p.w,
h: p.h,
alt: p.alt,
float: Default::default(),
crop: [0.0; 4],
crop: p.crop,
ole: None,
});
}
None => pics.dropped(cp),
}
}
'\u{8}' => {
// Drawn object anchor: the floating shape or picture anchored at this CP.
let at = ctx.drawn.partition_point(|(c, _)| *c < cp);
for (_, obj) in ctx.drawn.get(at..).unwrap_or_default().iter().take_while(|(c, _)| *c == cp) {
pb.push('\u{FFFC}', CharProps::default());
pb.objects.push(obj.clone());
}
}
'\u{2}' => {
// Footnote or endnote reference; custom symbols share the same character.
let at = ctx.notes.partition_point(|(c, _, _)| *c < cp);
+16 -10
View File
@@ -47,23 +47,29 @@ fn bullet_char(c: u16) -> char {
}
}
/// MSONFC → model number format ([MS-OSHARED] §2.2.1.3; 0x17 = bullets, 0xFF = none).
/// MSONFC → model number format ([MS-OSHARED] §2.2.1.3; 0x17 = bullets, 0xFF = none). Codes
/// 0x00 to 0x27 run in the order of ST_NumberFormat (decimal, upperRoman … 0x16 decimalZero, 0x17
/// bullet … 0x25 chineseCounting, 0x26 chineseLegalSimplified, 0x27 chineseCountingThousand);
/// later codes are drawn as decimal.
fn num_format(nfc: u8) -> NumFormat {
match nfc {
0x01 => NumFormat::UpperRoman,
0x02 => NumFormat::LowerRoman,
0x03 => NumFormat::UpperLetter,
0x04 => NumFormat::LowerLetter,
0x05 => NumFormat::Ordinal,
0x06 => NumFormat::CardinalText,
0x07 => NumFormat::OrdinalText,
0x0A | 0x0B => NumFormat::DecimalZero,
0x17 => NumFormat::Bullet,
0xFF => NumFormat::None,
0x00..=0x27 => NumFormat::ALL.get(nfc as usize).copied().unwrap_or(NumFormat::Decimal),
_ => NumFormat::Decimal,
}
}
#[cfg(test)]
#[test]
fn number_format_codes() {
assert_eq!(num_format(0x04), NumFormat::LowerLetter);
assert_eq!(num_format(0x16), NumFormat::DecimalZero);
assert_eq!(num_format(0x17), NumFormat::Bullet);
assert_eq!(num_format(0x25), NumFormat::ChineseCounting);
assert_eq!(num_format(0x27), NumFormat::ChineseCountingThousand);
assert_eq!(num_format(0x60), NumFormat::Decimal);
}
/// Read the lists of a document; empty when the FIB carries none (or they are unreadable).
pub(crate) fn parse(table: &[u8], fib: &Fib, fonts: &[String]) -> Numbering {
let defs = match fib.pair(pair::PLF_LST) {
+119 -20
View File
@@ -1,10 +1,13 @@
//! Inline pictures: `sprmCPicLocation` → a `PICF` in the Data stream → embedded image bytes
//! ([MS-DOC] §2.9.190 PICF, §1.3.5 Pictures).
//! ([MS-DOC] §2.9.190 PICF, §1.3.5 Pictures), and the shared store of every picture the
//! document reads (inline and floating).
use std::collections::{BTreeMap, HashMap};
use std::ops::Range;
use std::sync::Arc;
use crate::officeart;
/// `PICF.cbHeader` — the fixed size of the PICF structure.
const CB_HEADER: usize = 0x44;
/// Offsets inside PICF: `PICMID.dxaGoal/dyaGoal/mx/my` start at 28.
@@ -17,10 +20,13 @@ pub(crate) const MAX_MEDIA_BYTES: usize = 256 << 20;
/// The document's pictures, keyed by their location in the Data stream: anchors that share a
/// `fc_pic` share one media entry, and the total bytes copied are capped, so a file with
/// thousands of anchors on one huge picture costs one copy, not thousands.
/// thousands of anchors on one huge picture costs one copy, not thousands. Pictures of the
/// drawing layer's BLIP store are shared the same way, by store index.
pub(crate) struct Pictures {
/// fc_pic → (media key, width, height) of pictures already materialised.
by_fc: HashMap<u32, (String, f32, f32)>,
/// fc_pic → the inline picture already materialised.
by_fc: HashMap<u32, Inline>,
/// BLIP store index → media key (`None`: unreadable, not retried).
by_bse: HashMap<u32, Option<String>>,
/// The media entries, merged into the document at the end.
pub(crate) media: BTreeMap<String, Arc<Vec<u8>>>,
total: usize,
@@ -28,33 +34,89 @@ pub(crate) struct Pictures {
pub(crate) limit: usize,
/// Anchors whose picture was dropped (only the first few are logged).
dropped: usize,
/// Pictures kept as placeholders (formats we can't draw).
placeholders: usize,
}
impl Default for Pictures {
fn default() -> Self {
Pictures { by_fc: HashMap::new(), media: BTreeMap::new(), total: 0, limit: MAX_MEDIA_BYTES, dropped: 0 }
Pictures {
by_fc: HashMap::new(),
by_bse: HashMap::new(),
media: BTreeMap::new(),
total: 0,
limit: MAX_MEDIA_BYTES,
dropped: 0,
placeholders: 0,
}
}
}
/// An inline picture: its media key, display size (points), crop fractions and alt text.
#[derive(Clone, Debug)]
pub(crate) struct Inline {
pub(crate) key: String,
pub(crate) w: f32,
pub(crate) h: f32,
pub(crate) crop: [f32; 4],
pub(crate) alt: String,
}
impl Pictures {
/// The media key and display size (points) of the picture at `fc_pic`, copying its
/// bytes the first time. `None` when the header is malformed, the payload is a format
/// we do not embed (metafiles), or a picture count/byte cap is reached.
pub(crate) fn get(&mut self, data: &[u8], fc_pic: u32) -> Option<(String, f32, f32)> {
/// The inline picture at `fc_pic` in the Data stream, copying its bytes the first time.
/// `None` when the header is malformed, the picture can't be read, or a picture
/// count/byte cap is reached.
pub(crate) fn get(&mut self, data: &[u8], fc_pic: u32) -> Option<Inline> {
if let Some(hit) = self.by_fc.get(&fc_pic) {
return Some(hit.clone());
}
if self.by_fc.len() >= MAX_PICTURES {
if self.by_fc.len().saturating_add(self.by_bse.len()) >= MAX_PICTURES {
return None;
}
let pic = locate(data, fc_pic)?;
let bytes = data.get(pic.range)?;
let pic = match locate(data, fc_pic)? {
Found::Range(pic) => {
let bytes = data.get(pic.range.clone())?.to_vec();
let key = self.insert(bytes, pic.ext, false)?;
Inline { key, w: pic.w, h: pic.h, crop: [0.0; 4], alt: String::new() }
}
Found::Shape { w, h, blip, props } => {
let key = self.insert(blip.bytes, blip.ext, blip.placeholder)?;
Inline { key, w, h, crop: props.crop, alt: props.alt }
}
};
self.by_fc.insert(fc_pic, pic.clone());
Some(pic)
}
/// The media key of BLIP store entry `pib` (1-based), decoding it the first time; `delay`
/// is the stream that holds BLIPs not embedded in the store (the WordDocument stream).
pub(crate) fn bse(&mut self, pib: u32, bstore: &[&[u8]], delay: &[u8]) -> Option<String> {
if let Some(hit) = self.by_bse.get(&pib) {
return hit.clone();
}
if self.by_fc.len().saturating_add(self.by_bse.len()) >= MAX_PICTURES {
return None;
}
let fbse = bstore.get((pib as usize).checked_sub(1)?)?;
let room = self.limit.saturating_sub(self.total);
let key = officeart::fbse_blip(fbse, delay, room).and_then(|b| self.insert(b.bytes, b.ext, b.placeholder));
self.by_bse.insert(pib, key.clone());
key
}
/// Add picture bytes as a new media entry, within the byte cap.
fn insert(&mut self, bytes: Vec<u8>, ext: &str, placeholder: bool) -> Option<String> {
let total = self.total.checked_add(bytes.len()).filter(|&t| t <= self.limit)?;
let key = format!("image{}.{}", self.by_fc.len() + 1, pic.ext);
self.media.insert(key.clone(), Arc::new(bytes.to_vec()));
let key = format!("image{}.{ext}", self.media.len() + 1);
if placeholder {
if self.placeholders < 10 {
log::warn!("docbin: picture `{key}` is in a format WordCraft can't draw ({ext}); it shows as a placeholder");
}
self.placeholders = self.placeholders.saturating_add(1);
}
self.media.insert(key.clone(), Arc::new(bytes));
self.total = total;
self.by_fc.insert(fc_pic, (key.clone(), pic.w, pic.h));
Some((key, pic.w, pic.h))
Some(key)
}
/// Note a picture anchor that could not be materialised.
@@ -74,27 +136,64 @@ struct Located {
ext: &'static str,
}
/// Locate the picture at `fc_pic` in the Data stream without copying it.
fn locate(data: &[u8], fc_pic: u32) -> Option<Located> {
/// What a `PICFAndOfficeArtData` holds: raw image bytes (older writers), or an
/// `OfficeArtInlineSpContainer` (Word 97 and later) whose BLIP is decoded.
enum Found {
Range(Located),
Shape { w: f32, h: f32, blip: officeart::Blip, props: officeart::Props },
}
/// `PICF.mfpf.mm` values of pictures stored as a shape (`MM_SHAPE`) or a shape with a file
/// name (`MM_SHAPEFILE`).
const MM_SHAPE: u16 = 0x0064;
const MM_SHAPEFILE: u16 = 0x0066;
/// Locate the picture at `fc_pic` in the Data stream ([MS-DOC] §2.9.192 PICFAndOfficeArtData).
fn locate(data: &[u8], fc_pic: u32) -> Option<Found> {
let base = fc_pic as usize;
let pic = data.get(base..)?;
let lcb = u32::from_le_bytes(pic.get(0..4)?.try_into().ok()?) as usize;
let cb_header = u16::from_le_bytes(pic.get(4..6)?.try_into().ok()?) as usize;
let cb_header = if (0x20..=0x100).contains(&cb_header) { cb_header } else { CB_HEADER };
let mm = u16::from_le_bytes(pic.get(6..8)?.try_into().ok()?);
let m = pic.get(OFF_PICMID..OFF_PICMID + 8)?;
let dxa_goal = i16::from_le_bytes([m[0], m[1]]) as f32;
let dya_goal = i16::from_le_bytes([m[2], m[3]]) as f32;
let mx = u16::from_le_bytes([m[4], m[5]]) as f32;
let my = u16::from_le_bytes([m[6], m[7]]) as f32;
let scale = |r: f32| if r > 0.0 { (r / 1000.0).clamp(0.01, 100.0) } else { 1.0 };
let end = lcb.min(pic.len());
if mm == MM_SHAPE || mm == MM_SHAPEFILE {
let mut at = cb_header;
if mm == MM_SHAPEFILE {
at = at.checked_add(1 + *pic.get(at)? as usize)?;
}
let (blip, props) = inline_shape(pic.get(at..end)?, data)?;
// The goal size is before cropping; the crop fractions take their share off it.
let [cl, ct, cr, cb] = props.crop;
let w = (dxa_goal / 20.0 * (1.0 - cl - cr).max(0.01) * scale(mx)).clamp(1.0, 1584.0);
let h = (dya_goal / 20.0 * (1.0 - ct - cb).max(0.01) * scale(my)).clamp(1.0, 1584.0);
return Some(Found::Shape { w, h, blip, props });
}
let w = (dxa_goal / 20.0 * scale(mx)).clamp(1.0, 1584.0);
let h = (dya_goal / 20.0 * scale(my)).clamp(1.0, 1584.0);
let end = lcb.min(pic.len());
let body = pic.get(cb_header..end)?;
let at = sniff(body)?;
let image = body.get(at..)?;
let start = base.checked_add(cb_header)?.checked_add(at)?;
Some(Located { w, h, range: start..start.checked_add(image.len())?, ext: ext_of(image) })
Some(Found::Range(Located { w, h, range: start..start.checked_add(image.len())?, ext: ext_of(image) }))
}
/// An `OfficeArtInlineSpContainer`: the picture shape, then the `OfficeArtFBSE` records its
/// `pib` indexes (BLIPs embedded, or at `foDelay` in the Data stream).
fn inline_shape(buf: &[u8], data: &[u8]) -> Option<(officeart::Blip, officeart::Props)> {
let mut budget = 10_000usize;
let recs = officeart::children(buf, &mut budget);
let sp = recs.iter().find(|r| r.typ == officeart::rt::SP_CONTAINER).map(|r| officeart::shape(r.body, &mut budget))?;
let fbses: Vec<&[u8]> = recs.iter().filter(|r| r.typ == officeart::rt::FBSE).map(|r| r.body).collect();
let fbse = sp.props.pib.and_then(|p| fbses.get((p as usize).checked_sub(1)?)).or(fbses.first())?;
let blip = officeart::fbse_blip(fbse, data, MAX_MEDIA_BYTES)?;
Some((blip, sp.props))
}
/// Offset of the first embedded raster image inside the payload, skipping the small
+463
View File
@@ -0,0 +1,463 @@
//! OfficeArt (Escher) drawing records, as Word 97-2003 stores them ([MS-ODRAW], public Microsoft
//! Open Specifications): the record header, the BLIP store and its pictures, shape containers with
//! their property tables, and the `OfficeArtContent` of the Table stream ([MS-DOC] §2.9.172).
//!
//! Everything here walks hostile bytes: record lengths are clamped to their parent, nesting depth
//! and record counts are capped, and decompressed metafiles are size-limited.
use std::collections::HashMap;
use wordcraft_doc::props::Rgb;
/// Deepest container nesting walked (groups inside groups…).
const MAX_DEPTH: usize = 12;
/// Most records walked in one drawing tree or BLIP store.
const MAX_RECORDS: usize = 200_000;
/// Most BLIP store entries kept.
const MAX_BSE: usize = 10_000;
/// Most drawings (main document, headers) read from `OfficeArtContent`.
const MAX_DRAWINGS: usize = 8;
/// Largest decompressed metafile (bytes).
const MAX_METAFILE: usize = 64 << 20;
/// Largest DIB converted to PNG, per side (pixels).
const MAX_DIB_SIDE: u32 = 16_384;
/// Record types ([MS-ODRAW] §2.2, §2.3).
pub(crate) mod rt {
pub(crate) const DGG_CONTAINER: u16 = 0xF000;
pub(crate) const BSTORE_CONTAINER: u16 = 0xF001;
pub(crate) const DG_CONTAINER: u16 = 0xF002;
pub(crate) const SPGR_CONTAINER: u16 = 0xF003;
pub(crate) const SP_CONTAINER: u16 = 0xF004;
pub(crate) const FBSE: u16 = 0xF007;
pub(crate) const FSPGR: u16 = 0xF009;
pub(crate) const FSP: u16 = 0xF00A;
pub(crate) const FOPT: u16 = 0xF00B;
pub(crate) const CLIENT_TEXTBOX: u16 = 0xF00D;
pub(crate) const CHILD_ANCHOR: u16 = 0xF00F;
pub(crate) const SECONDARY_FOPT: u16 = 0xF121;
pub(crate) const TERTIARY_FOPT: u16 = 0xF122;
pub(crate) const BLIP_EMF: u16 = 0xF01A;
pub(crate) const BLIP_WMF: u16 = 0xF01B;
pub(crate) const BLIP_PICT: u16 = 0xF01C;
pub(crate) const BLIP_JPEG: u16 = 0xF01D;
pub(crate) const BLIP_PNG: u16 = 0xF01E;
pub(crate) const BLIP_DIB: u16 = 0xF01F;
pub(crate) const BLIP_TIFF: u16 = 0xF029;
pub(crate) const BLIP_JPEG_CMYK: u16 = 0xF02A;
}
/// One record: header fields and its body (clamped to what the parent holds).
#[derive(Clone, Copy, Debug)]
pub(crate) struct Rec<'a> {
pub(crate) inst: u16,
pub(crate) typ: u16,
pub(crate) body: &'a [u8],
}
/// The record at the start of `buf` and the bytes it spans (header included). A body longer
/// than `buf` is cut to what is there.
pub(crate) fn record(buf: &[u8]) -> Option<(Rec<'_>, usize)> {
let h = buf.get(0..8)?;
let ver_inst = u16::from_le_bytes([h[0], h[1]]);
let typ = u16::from_le_bytes([h[2], h[3]]);
let len = u32::from_le_bytes([h[4], h[5], h[6], h[7]]) as usize;
let rest = buf.get(8..)?;
let len = len.min(rest.len());
let body = rest.get(..len)?;
Some((Rec { inst: ver_inst >> 4, typ, body }, 8 + len))
}
/// The sibling records of a container body, at most `budget` of them (the budget is shared
/// across a whole walk, so a deep or wide tree cannot multiply the work).
pub(crate) fn children<'a>(body: &'a [u8], budget: &mut usize) -> Vec<Rec<'a>> {
let mut out = Vec::new();
let mut at = 0usize;
while *budget > 0 {
let Some((rec, used)) = body.get(at..).and_then(record) else { break };
*budget -= 1;
out.push(rec);
at = at.saturating_add(used);
}
out
}
fn u16_at(b: &[u8], at: usize) -> Option<u16> {
Some(u16::from_le_bytes(b.get(at..at.checked_add(2)?)?.try_into().ok()?))
}
fn u32_at(b: &[u8], at: usize) -> Option<u32> {
Some(u32::from_le_bytes(b.get(at..at.checked_add(4)?)?.try_into().ok()?))
}
fn i32_at(b: &[u8], at: usize) -> Option<i32> {
u32_at(b, at).map(|v| v as i32)
}
// ---------------------------------------------------------------------------------------------
// Pictures (BLIPs).
/// A decoded picture: bytes ready for `Document::media` and their file extension.
pub(crate) struct Blip {
pub(crate) bytes: Vec<u8>,
pub(crate) ext: &'static str,
/// The format cannot be drawn (Macintosh PICT): the picture is kept as a placeholder of
/// the right size.
pub(crate) placeholder: bool,
}
/// The picture of an `OfficeArtFBSE` body: its embedded BLIP, or the BLIP stored at `foDelay`
/// in `delay` (the WordDocument stream for the document's BLIP store, the Data stream for
/// inline pictures). `limit` caps the bytes produced.
pub(crate) fn fbse_blip(fbse: &[u8], delay: &[u8], limit: usize) -> Option<Blip> {
// btWin32, btMacOS, rgbUid[16], tag, size, cRef, foDelay, unused, cbName, unused×2.
let size = u32_at(fbse, 20)? as usize;
let fo_delay = u32_at(fbse, 28)? as usize;
let cb_name = *fbse.get(33)? as usize;
let at = 36usize.checked_add(cb_name)?;
if let Some((rec, _)) = fbse.get(at..).and_then(record) {
return blip(&rec, limit);
}
// Not embedded: the record sits in the delay stream.
let end = fo_delay.checked_add(size.max(8))?.min(delay.len());
let (rec, _) = record(delay.get(fo_delay..end)?)?;
blip(&rec, limit)
}
/// Decode one `OfficeArtBlip*` record ([MS-ODRAW] §2.2.23-§2.2.31).
pub(crate) fn blip(rec: &Rec, limit: usize) -> Option<Blip> {
// Each type has two instance values; the odd one carries a second 16-byte UID.
let uids = if rec.inst & 1 == 1 { 32 } else { 16 };
match rec.typ {
rt::BLIP_PNG | rt::BLIP_JPEG | rt::BLIP_JPEG_CMYK | rt::BLIP_DIB | rt::BLIP_TIFF => {
let data = rec.body.get(uids + 1..)?; // + tag byte
if data.is_empty() || data.len() > limit {
return None;
}
match rec.typ {
rt::BLIP_PNG => Some(Blip { bytes: data.to_vec(), ext: "png", placeholder: false }),
rt::BLIP_JPEG | rt::BLIP_JPEG_CMYK => Some(Blip { bytes: data.to_vec(), ext: "jpeg", placeholder: false }),
rt::BLIP_TIFF => Some(Blip { bytes: data.to_vec(), ext: "tiff", placeholder: false }),
_ => dib_to_png(data),
}
}
rt::BLIP_EMF | rt::BLIP_WMF | rt::BLIP_PICT => {
// OfficeArtMetafileHeader: cbSize, rcBounds, ptSize, cbSave, compression, filter.
let hdr = rec.body.get(uids..uids + 34)?;
let cb_size = u32_at(hdr, 0)? as usize;
let cb_save = u32_at(hdr, 28)? as usize;
let compression = *hdr.get(32)?;
let raw = rec.body.get(uids + 34..)?;
let raw = raw.get(..cb_save.min(raw.len()))?;
let cap = limit.min(MAX_METAFILE);
let bytes = if compression == 0 {
let want = if cb_size > 0 { cb_size.min(cap) } else { cap };
miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(raw, want).ok()?
} else {
if raw.len() > cap {
return None;
}
raw.to_vec()
};
if bytes.is_empty() {
return None;
}
match rec.typ {
rt::BLIP_EMF => Some(Blip { bytes, ext: "emf", placeholder: false }),
rt::BLIP_WMF => Some(Blip { bytes, ext: "wmf", placeholder: false }),
_ => Some(Blip { bytes, ext: "pict", placeholder: true }),
}
}
_ => None,
}
}
/// A device-independent bitmap (BITMAPINFOHEADER + palette + pixels) as a PNG: the DIB gets
/// the 14-byte BMP file header it lacks, is decoded with size limits and re-encoded. When the
/// bitmap can't be decoded the BMP bytes are kept (and render as a grey placeholder).
fn dib_to_png(dib: &[u8]) -> Option<Blip> {
let header = u32_at(dib, 0)? as usize;
if !(12..=124).contains(&header) {
return None;
}
let (bits, used, compression) =
if header == 12 { (u16_at(dib, 10)? as usize, 0, 0) } else { (u16_at(dib, 14)? as usize, u32_at(dib, 32)? as usize, u32_at(dib, 16)?) };
let entry = if header == 12 { 3 } else { 4 };
let colors = if used > 0 {
used.min(256)
} else if bits <= 8 {
1usize << bits
} else {
0
};
// BI_BITFIELDS masks follow a 40-byte header.
let masks = if header == 40 && (compression == 3 || compression == 6) { if compression == 3 { 12 } else { 16 } } else { 0 };
let off_bits = 14usize.checked_add(header)?.checked_add(colors * entry)?.checked_add(masks)?;
let file_size = 14usize.checked_add(dib.len())?;
let mut bmp = Vec::with_capacity(file_size);
bmp.extend_from_slice(b"BM");
bmp.extend_from_slice(&u32::try_from(file_size).ok()?.to_le_bytes());
bmp.extend_from_slice(&[0, 0, 0, 0]);
bmp.extend_from_slice(&u32::try_from(off_bits).ok()?.to_le_bytes());
bmp.extend_from_slice(dib);
let mut reader = image::ImageReader::new(std::io::Cursor::new(&bmp));
reader.set_format(image::ImageFormat::Bmp);
let mut limits = image::Limits::default();
limits.max_image_width = Some(MAX_DIB_SIDE);
limits.max_image_height = Some(MAX_DIB_SIDE);
limits.max_alloc = Some(256 << 20);
reader.limits(limits);
let mut png = Vec::new();
match reader.decode() {
Ok(img) if img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_ok() => {
Some(Blip { bytes: png, ext: "png", placeholder: false })
}
_ => Some(Blip { bytes: bmp, ext: "bmp", placeholder: true }),
}
}
// ---------------------------------------------------------------------------------------------
// Shapes.
/// The shape properties we use, from `OfficeArtFOPT` and its secondary/tertiary tables
/// ([MS-ODRAW] §2.3).
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct Props {
/// `pib`: 1-based index of the picture in the BLIP store.
pub(crate) pib: Option<u32>,
/// Rotation, degrees clockwise.
pub(crate) rot: f32,
/// Crop fractions (left, top, right, bottom).
pub(crate) crop: [f32; 4],
pub(crate) fill_color: Option<Rgb>,
pub(crate) filled: Option<bool>,
pub(crate) line_color: Option<Rgb>,
/// Line width in EMUs.
pub(crate) line_width: Option<u32>,
pub(crate) line: Option<bool>,
/// An arrowhead at either end of a line.
pub(crate) arrowhead: bool,
/// `posh`/`posrelh`/`posv`/`posrelv`: alignment and reference frame (Word 2000 and later).
pub(crate) posh: Option<u32>,
pub(crate) posrelh: Option<u32>,
pub(crate) posv: Option<u32>,
pub(crate) posrelv: Option<u32>,
/// Distance from wrapped text (left, top, right, bottom), EMUs.
pub(crate) wrap_dist: [Option<u32>; 4],
/// `wzDescription`: the alternative text.
pub(crate) alt: String,
}
impl Props {
/// Apply one property table (`OfficeArtFOPT` or a secondary/tertiary one): `count` 6-byte
/// entries, then the complex data of the entries flagged complex, in order.
pub(crate) fn apply(&mut self, body: &[u8], count: u16) {
let n = (count as usize).min(body.len() / 6);
let mut complex_at = n * 6;
for i in 0..n {
let Some(opid) = u16_at(body, i * 6) else { break };
let Some(op) = u32_at(body, i * 6 + 2) else { break };
let pid = opid & 0x3FFF;
if opid & 0x8000 != 0 {
let start = complex_at;
complex_at = complex_at.saturating_add(op as usize);
if pid == 0x0381 {
let end = complex_at.min(body.len());
if let Some(data) = body.get(start..end) {
let units: Vec<u16> =
data.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).take_while(|u| *u != 0).take(4096).collect();
self.alt = String::from_utf16_lossy(&units);
}
}
continue;
}
let fixed = |v: u32| (v as i32) as f32 / 65_536.0;
match pid {
0x0004 => self.rot = fixed(op),
0x0100..=0x0103 => {
// cropFromTop, cropFromBottom, cropFromLeft, cropFromRight.
let slot = [1, 3, 0, 2][(pid - 0x0100) as usize];
let v = fixed(op);
self.crop[slot] = if v.is_finite() { v.clamp(0.0, 0.99) } else { 0.0 };
}
0x0104 => self.pib = Some(op),
0x0181 => self.fill_color = color(op),
0x01BF => {
// fFilled (bit 4) is valid when fUsefFilled (bit 20) is set.
if op & (1 << 20) != 0 {
self.filled = Some(op & (1 << 4) != 0);
}
}
0x01C0 => self.line_color = color(op),
0x01CB => self.line_width = Some(op),
0x01D0 | 0x01D1 => self.arrowhead |= op != 0,
0x01FF => {
// fLine (bit 3) is valid when fUsefLine (bit 19) is set.
if op & (1 << 19) != 0 {
self.line = Some(op & (1 << 3) != 0);
}
}
0x0384..=0x0387 => self.wrap_dist[(pid - 0x0384) as usize] = Some(op),
0x038F => self.posh = Some(op),
0x0390 => self.posrelh = Some(op),
0x0391 => self.posv = Some(op),
0x0392 => self.posrelv = Some(op),
_ => {}
}
}
}
}
/// An `OfficeArtCOLORREF` holding a literal RGB value (palette, scheme and system colour
/// references are not resolved).
fn color(v: u32) -> Option<Rgb> {
let [r, g, b, flags] = v.to_le_bytes();
// fPaletteIndex, fSchemeIndex, fSysIndex.
if flags & 0x19 != 0 {
return None;
}
Some(Rgb(r, g, b))
}
/// One shape (`OfficeArtSpContainer`).
#[derive(Clone, Debug, Default)]
pub(crate) struct Sp {
pub(crate) spid: u32,
/// Shape type (`MSOSPT`), from the FSP's instance.
pub(crate) spt: u16,
/// FSP flags: fGroup, fChild, fPatriarch, fDeleted, fOleShape, fHaveMaster, fFlipH, fFlipV…
pub(crate) flags: u32,
pub(crate) props: Props,
/// `OfficeArtChildAnchor` (left, top, right, bottom) in the parent group's space.
pub(crate) child: Option<[i32; 4]>,
/// `OfficeArtFSPGR`: a group's own coordinate space.
pub(crate) group_space: Option<[i32; 4]>,
/// Has an `OfficeArtClientTextbox` (its text is in the text box story).
pub(crate) textbox: bool,
}
impl Sp {
pub(crate) fn flip_h(&self) -> bool {
self.flags & (1 << 6) != 0
}
pub(crate) fn flip_v(&self) -> bool {
self.flags & (1 << 7) != 0
}
fn deleted(&self) -> bool {
self.flags & (1 << 3) != 0
}
}
/// A shape or a group of shapes.
#[derive(Clone, Debug)]
pub(crate) enum Node {
Shape(Sp),
/// A group: its own shape record (FSPGR, anchor) and members.
Group(Sp, Vec<Node>),
}
impl Node {
pub(crate) fn sp(&self) -> &Sp {
match self {
Node::Shape(sp) | Node::Group(sp, _) => sp,
}
}
}
/// Parse one `OfficeArtSpContainer`.
pub(crate) fn shape(body: &[u8], budget: &mut usize) -> Sp {
let mut sp = Sp::default();
for rec in children(body, budget) {
match rec.typ {
rt::FSP => {
sp.spt = rec.inst;
sp.spid = u32_at(rec.body, 0).unwrap_or(0);
sp.flags = u32_at(rec.body, 4).unwrap_or(0);
}
rt::FOPT | rt::SECONDARY_FOPT | rt::TERTIARY_FOPT => sp.props.apply(rec.body, rec.inst),
rt::FSPGR | rt::CHILD_ANCHOR => {
let r = [0, 4, 8, 12].map(|o| i32_at(rec.body, o));
if let [Some(l), Some(t), Some(r), Some(b)] = r {
if rec.typ == rt::FSPGR {
sp.group_space = Some([l, t, r, b]);
} else {
sp.child = Some([l, t, r, b]);
}
}
}
rt::CLIENT_TEXTBOX => sp.textbox = true,
_ => {}
}
}
sp
}
/// Parse an `OfficeArtSpgrContainer`: the first shape is the group itself, the rest its members.
fn group(body: &[u8], depth: usize, budget: &mut usize) -> Option<Node> {
let recs = children(body, budget);
let mut it = recs.iter();
let head = it.find(|r| r.typ == rt::SP_CONTAINER).map(|r| shape(r.body, budget))?;
let mut members = Vec::new();
for rec in it {
if let Some(n) = node(rec, depth + 1, budget) {
members.push(n);
}
}
Some(Node::Group(head, members))
}
/// A shape or group record as a node (`None` for other records, deleted shapes or past the
/// depth cap).
fn node(rec: &Rec, depth: usize, budget: &mut usize) -> Option<Node> {
match rec.typ {
rt::SP_CONTAINER => Some(shape(rec.body, budget)).filter(|s| !s.deleted()).map(Node::Shape),
rt::SPGR_CONTAINER if depth < MAX_DEPTH => group(rec.body, depth, budget),
_ => None,
}
}
/// The document's drawings: the BLIP store and the top-level shapes of each drawing by spid.
#[derive(Default)]
pub(crate) struct Content<'a> {
/// `OfficeArtFBSE` bodies, in BLIP store order (`pib` 1 = the first).
pub(crate) bstore: Vec<&'a [u8]>,
/// The main document's shapes (`dgglbl` 0) and the headers' (`dgglbl` 1), by spid.
pub(crate) main: HashMap<u32, Node>,
pub(crate) header: HashMap<u32, Node>,
}
/// Parse `OfficeArtContent` (the `fcDggInfo` blob of the Table stream): an
/// `OfficeArtDggContainer` followed by (`dgglbl`, `OfficeArtDgContainer`) drawings.
pub(crate) fn content(blob: &[u8]) -> Content<'_> {
let mut out = Content::default();
let mut budget = MAX_RECORDS;
let Some((dgg, mut at)) = record(blob) else { return out };
if dgg.typ == rt::DGG_CONTAINER {
for rec in children(dgg.body, &mut budget) {
if rec.typ == rt::BSTORE_CONTAINER {
out.bstore = children(rec.body, &mut budget).into_iter().filter(|r| r.typ == rt::FBSE).map(|r| r.body).take(MAX_BSE).collect();
}
}
}
for _ in 0..MAX_DRAWINGS {
let Some(&label) = blob.get(at) else { break };
let Some((dg, used)) = blob.get(at + 1..).and_then(record) else { break };
at = at.saturating_add(1 + used);
if dg.typ != rt::DG_CONTAINER {
continue;
}
let into = if label == 0 { &mut out.main } else { &mut out.header };
for rec in children(dg.body, &mut budget) {
if rec.typ != rt::SPGR_CONTAINER {
continue;
}
// The top group: its first shape is the patriarch; the members are the drawing's shapes.
if let Some(Node::Group(_, members)) = group(rec.body, 0, &mut budget) {
for n in members {
into.entry(n.sp().spid).or_insert(n);
}
}
}
}
out
}
+14 -3
View File
@@ -1,8 +1,10 @@
//! Sprm decoding ([MS-DOC] §2.6). A Prl is a 2-byte opcode followed by an operand whose
//! size comes from the opcode's `spra` field (with two variable-length exceptions).
/// `sprmPChgTabs`: custom tab stops ([MS-DOC] §2.6.29 — handled in a later pass).
const P_CHG_TABS: u16 = 0xC615;
/// `sprmPChgTabs`: custom tab stops added and removed ([MS-DOC] §2.6.3, PChgTabsOperand).
pub(crate) const P_CHG_TABS: u16 = 0xC615;
/// `sprmPChgTabsPapx`: the same for paragraph and style formatting (PChgTabsPapxOperand).
pub(crate) const P_CHG_TABS_PAPX: u16 = 0xC60D;
/// `sprmTDefTable`: table cell borders and widths.
const T_DEF_TABLE: u16 = 0xD608;
@@ -32,7 +34,16 @@ pub(crate) fn iter(grpprl: &[u8]) -> impl Iterator<Item = Prl<'_>> {
let after = &rest[2..];
// Operand size per spra, with the exceptions whose size byte lies.
let len = match (op >> 13, op) {
(_, P_CHG_TABS) => after.get(..2).map(|b| 2 + u16::from_le_bytes([b[0], b[1]]) as usize),
// PChgTabsOperand.cb is one byte, the size after it; 255 means "work it out" from the
// two counts: cb, cTabs, 4 bytes per removed stop, cTabs, 3 bytes per added stop.
(_, P_CHG_TABS) => match after.first().copied() {
Some(255) => after.get(1).and_then(|&del| {
let add = after.get(2 + 4 * del as usize).copied()?;
Some(1 + 1 + 4 * del as usize + 1 + 3 * add as usize)
}),
Some(cb) => Some(1 + cb as usize),
None => None,
},
// TDefTableOperand.cb counts the bytes after cb, incremented by 1, so the
// operand is one byte longer than cb.
(_, T_DEF_TABLE) => after.get(..2).map(|b| 1 + u16::from_le_bytes([b[0], b[1]]) as usize),
+134 -1
View File
@@ -7,6 +7,8 @@ use proptest::prelude::*;
use super::read;
mod drawing;
/// Offset in the WordDocument stream where test text pieces are placed (past the FIB).
const TEXT_FC: u32 = 0x600;
/// Offset in the Table stream where the test Clx is placed.
@@ -673,6 +675,34 @@ fn paragraph_props_and_heading_style() {
assert_eq!(p1.props.style.as_deref(), Some("Normal"));
}
/// #534: Chinese theses set a first-line indent of two characters (`sprmPDxaLeft1`, whose operand
/// is two bytes, plus `sprmPDxcLeft1` in hundredths of a character), and exact line spacing as a
/// negative LSPD `dyaLine`. Both were lost: the indent read as four bytes, and exact 20pt as
/// 799pt, a page per empty paragraph.
#[test]
fn first_line_indent_chars_and_exact_line_spacing() {
let lspd = |dya: i16, mult: u16| [dya.to_le_bytes(), mult.to_le_bytes()].concat();
let (exact, multiple) = (lspd(-400, 0), lspd(360, 1));
let spec = RichSpec {
text: "indented\rexact\rold\r",
styles: vec![(1, 0, "Normal", Vec::new(), Vec::new())],
fonts: vec!["Times New Roman"],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![
(0, 0, grpprl(&[(0x8460, &480i16.to_le_bytes()), (0x4457, &200i16.to_le_bytes()), (0x6412, &multiple)])),
(9, 0, grpprl(&[(0x6412, &exact)])),
(15, 0, grpprl(&[(0x8411, &(-360i16).to_le_bytes())])),
],
};
let doc = read(&rich_doc(&spec)).expect("opens");
let para = |i: usize| doc.body.get(i).and_then(|b| b.as_para()).expect("para").props.clone();
let p0 = para(0);
assert_eq!((p0.indent_first, p0.indent_first_chars), (Some(24.0), Some(2.0)));
assert_eq!(p0.line_spacing, Some(wordcraft_doc::props::LineSpacing::Multiple(1.5)));
assert_eq!(para(1).line_spacing, Some(wordcraft_doc::props::LineSpacing::Exactly(20.0)));
assert_eq!(para(2).indent_first, Some(-18.0)); // sprmPDxaLeft1_80: a hanging indent
}
/// Local corpus (gitignored `plan/word/fixtures`): real files must open with text and styles.
#[test]
fn corpus_files_open() {
@@ -1600,7 +1630,7 @@ fn picture_bytes_are_capped_per_document() {
pics.limit = 1000;
let first = pics.get(&data, 0).expect("first fits");
// Same location again: no new bytes.
assert_eq!(pics.get(&data, 0).map(|p| p.0), Some(first.0.clone()));
assert_eq!(pics.get(&data, 0).map(|p| p.key), Some(first.key.clone()));
// A second distinct picture would exceed the cap.
assert!(pics.get(&data, a.len() as u32).is_none());
assert_eq!(pics.media.len(), 1);
@@ -1773,3 +1803,106 @@ fn alternate_font_name_keeps_later_entries() {
assert_eq!(fonts, [Some("Arial".to_string()), Some("Courier New".to_string())], "alt {alt:?}");
}
}
/// PChgTabsPapxOperand bytes: cb, removed stops, added stops with their TBDs.
fn tabs_papx(del: &[i16], add: &[(i16, u8)]) -> Vec<u8> {
let mut v = vec![0u8, del.len() as u8];
for d in del {
v.extend_from_slice(&d.to_le_bytes());
}
v.push(add.len() as u8);
for (x, _) in add {
v.extend_from_slice(&x.to_le_bytes());
}
v.extend(add.iter().map(|(_, tbd)| *tbd));
v[0] = (v.len() - 1) as u8;
v
}
/// Custom tab stops ([MS-DOC] sprmPChgTabsPapx, sprmPChgTabs) were skipped, so tab-aligned text
/// (centred titles, right-aligned dates, dot leaders in forms) lost its alignment. Stops added by
/// a style and by the paragraph, stops it removes, and the TBD's alignment and leader are read.
#[test]
fn tab_stops_read_from_styles_and_paragraphs() {
use wordcraft_doc::props::{TabAlign, TabLeader};
// Normal: centre at 3.25" (4680 tw), right at 6.5" (9360 tw) with a dot leader (tlc 1).
let normal = grpprl(&[(0xC60D, &tabs_papx(&[], &[(4680, 1), (9360, 2 | 1 << 3)]))]);
// The paragraph drops the centre stop and adds a left stop at 0.5" and a bar at 1".
let direct = grpprl(&[(0xC60D, &tabs_papx(&[4680], &[(720, 0), (1440, 4)]))]);
// The second paragraph uses the older sprmPChgTabs: removals carry a "close" distance.
let mut old = vec![0u8, 1];
old.extend_from_slice(&9360i16.to_le_bytes());
old.extend_from_slice(&25i16.to_le_bytes());
old.push(1);
old.extend_from_slice(&2880i16.to_le_bytes());
old.push(3 | 5 << 3); // decimal, middle dot
old[0] = (old.len() - 1) as u8;
let spec = RichSpec {
text: "\tA\tB\r\tC\r",
styles: vec![(1, 0x0FFF, "Normal", normal, Vec::new())],
fonts: vec!["Calibri"],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![(0, 0, direct), (5, 0, grpprl(&[(0xC615, &old)]))],
};
let doc = read(&rich_doc(&spec)).expect("opens");
let tabs = |i: usize| {
let p = doc.body.get(i).and_then(|b| b.as_para()).expect("para");
doc.styles.resolve_para(&p.props).tabs.iter().map(|t| (t.pos, t.align, t.leader)).collect::<Vec<_>>()
};
assert_eq!(
tabs(0),
[(36.0, TabAlign::Left, TabLeader::None), (72.0, TabAlign::Bar, TabLeader::None), (468.0, TabAlign::Right, TabLeader::Dot)],
"the style's stops, less the removed one, plus the paragraph's"
);
assert_eq!(tabs(1), [(144.0, TabAlign::Decimal, TabLeader::MiddleDot), (234.0, TabAlign::Center, TabLeader::None)], "sprmPChgTabs");
assert_eq!(doc.styles.styles.iter().find(|s| s.name == "Normal").and_then(|s| s.para.tabs.as_ref()).map(Vec::len), Some(2));
}
/// Hostile tab operands (counts past the end, more than 64 stops, cb 255) never panic and
/// never swallow the sprms after them.
#[test]
fn hostile_tab_operands_are_safe() {
for operand in [vec![2u8, 200, 9], vec![0], vec![255, 1, 0x10, 0x00], vec![255, 3], vec![5, 0, 70, 0, 1, 2]] {
let papx = grpprl(&[(0xC615, &operand), (0x2403, &[1])]);
let spec = RichSpec {
text: "x\r",
styles: vec![(1, 0x0FFF, "Normal", Vec::new(), Vec::new())],
fonts: vec!["Calibri"],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![(0, 0, papx)],
};
let _ = read(&rich_doc(&spec));
}
let mut iter = crate::sprm::iter(&[0x15, 0xC6, 3, 0, 0, 0, 0x03, 0x24, 1]);
assert_eq!(iter.next().map(|p| (p.op, p.operand.len())), Some((0xC615, 4)), "cb is one byte: the operand is cb + 1 bytes");
assert_eq!(iter.next().map(|p| p.op), Some(0x2403), "the next sprm is still read");
}
/// A section mark (0x0C) at a section's end is the section's last paragraph mark, not a page
/// break inside a paragraph: the section's properties (here continuous) land on that paragraph.
/// Before, every section of such a file was lost and each section mark became a page break.
#[test]
fn section_marks_end_paragraphs() {
use wordcraft_doc::section::SectionStart;
let spec = StructSpec {
text: "first\u{C}second\r",
header_stories: vec![],
sections: vec![(6, grpprl(&[(0x3009, &[0x00])])), (13, Vec::new())],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![(0, 0, Vec::new()), (6, 0, Vec::new())],
plf_lst: Vec::new(),
plf_lfo: Vec::new(),
note_stories: vec![],
endnote_stories: vec![],
extra_pairs: Vec::new(),
data_stream: Vec::new(),
};
let doc = read(&struct_doc(&spec)).expect("opens");
assert_eq!(body_texts(&doc), ["first", "second"]);
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
assert_eq!(p0.section.as_ref().map(|s| s.start), Some(SectionStart::Continuous), "the section ends at its mark");
// A page break inside a section stays a page break.
let spec = StructSpec { text: "a\u{C}b\r", sections: vec![(4, Vec::new())], papx_entries: vec![(0, 0, Vec::new())], ..spec };
let doc = read(&struct_doc(&spec)).expect("opens");
assert_eq!(body_texts(&doc), ["a\u{C}b"]);
}
+276
View File
@@ -0,0 +1,276 @@
//! Pictures and drawings: OfficeArt records ([MS-ODRAW]) hand-built for these tests from the
//! specification, with tiny pictures generated in code. No file produced by Word is used.
use proptest::prelude::*;
use wordcraft_doc::para::{Anchor, InlineObject, ShapeKind, Wrap};
use wordcraft_doc::props::Rgb;
use super::{StructSpec, grpprl, plc, read, struct_doc, structured_spec};
use crate::officeart;
/// One OfficeArt record: header (recVer, recInstance, recType, recLen) + body.
fn rec(ver: u16, inst: u16, typ: u16, body: &[u8]) -> Vec<u8> {
let vi = (inst << 4) | (ver & 0xF);
[vi.to_le_bytes().as_slice(), &typ.to_le_bytes(), &(body.len() as u32).to_le_bytes(), body].concat()
}
fn container(typ: u16, kids: &[Vec<u8>]) -> Vec<u8> {
rec(0xF, 0, typ, &kids.concat())
}
/// `OfficeArtFSP`: shape type, spid, flags.
fn fsp(spt: u16, spid: u32, flags: u32) -> Vec<u8> {
rec(2, spt, 0xF00A, &[spid.to_le_bytes(), flags.to_le_bytes()].concat())
}
/// `OfficeArtFOPT` with simple properties and complex ones (data appended in order).
fn fopt(simple: &[(u16, u32)], complex: &[(u16, Vec<u8>)]) -> Vec<u8> {
let mut body = Vec::new();
for (pid, op) in simple {
body.extend_from_slice(&pid.to_le_bytes());
body.extend_from_slice(&op.to_le_bytes());
}
for (pid, data) in complex {
body.extend_from_slice(&(pid | 0x8000).to_le_bytes());
body.extend_from_slice(&(data.len() as u32).to_le_bytes());
}
for (_, data) in complex {
body.extend_from_slice(data);
}
rec(3, (simple.len() + complex.len()) as u16, 0xF00B, &body)
}
/// A shape container with the given records.
fn sp(kids: &[Vec<u8>]) -> Vec<u8> {
container(0xF004, kids)
}
/// A real 2×2 PNG.
fn tiny_png() -> Vec<u8> {
let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([10, 20, 30, 255]));
let mut out = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
/// `OfficeArtBlipPNG` (one UID, tag) and the `OfficeArtFBSE` embedding it.
fn fbse_png(png: &[u8]) -> Vec<u8> {
let blip = rec(0, 0x6E0, 0xF01E, &[[0u8; 16].as_slice(), &[0xFF], png].concat());
let mut body = vec![6u8, 6];
body.extend_from_slice(&[0u8; 16]); // rgbUid
body.extend_from_slice(&0u16.to_le_bytes()); // tag
body.extend_from_slice(&(blip.len() as u32).to_le_bytes()); // size
body.extend_from_slice(&1u32.to_le_bytes()); // cRef
body.extend_from_slice(&0u32.to_le_bytes()); // foDelay
body.extend_from_slice(&[0, 0, 0, 0]); // unused1, cbName, unused2, unused3
body.extend_from_slice(&blip);
rec(2, 6, 0xF007, &body)
}
/// UTF-16LE, NUL-terminated (a complex string property).
fn wz(s: &str) -> Vec<u8> {
s.encode_utf16().chain([0]).flat_map(|u| u.to_le_bytes()).collect()
}
/// A `Spa` (26 bytes): spid, bounds in twips, flags, cTxbx.
fn spa(spid: u32, [l, t, r, b]: [i32; 4], flags: u16) -> Vec<u8> {
[spid.to_le_bytes().as_slice(), &l.to_le_bytes(), &t.to_le_bytes(), &r.to_le_bytes(), &b.to_le_bytes(), &flags.to_le_bytes(), &0u32.to_le_bytes()]
.concat()
}
/// `Spa` flags from bx, by, wr and fBelowText.
fn spa_flags(bx: u16, by: u16, wr: u16, below: bool) -> u16 {
(bx << 1) | (by << 3) | (wr << 5) | (u16::from(below) << 14)
}
/// `OfficeArtContent`: a DggContainer holding a BLIP store, then one main-document drawing.
fn art_content(fbses: &[Vec<u8>], shapes: &[Vec<u8>]) -> Vec<u8> {
let bstore = rec(0xF, fbses.len() as u16, 0xF001, &fbses.concat());
let dgg = container(0xF000, &[bstore]);
let patriarch = sp(&[fsp(0, 1024, 0b101)]);
let spgr = container(0xF003, &[[patriarch].as_slice(), shapes].concat());
let dg = container(0xF002, &[rec(0, 1, 0xF008, &[0u8; 8]), spgr]);
[dgg, vec![0u8], dg].concat()
}
/// A PICF for an inline OfficeArt picture: `MM_SHAPE`, 1 × 0.5 inch goal size at 100 %.
fn picf_shape(rest: &[u8]) -> Vec<u8> {
let mut picf = vec![0u8; 0x44];
picf[0..4].copy_from_slice(&((0x44 + rest.len()) as u32).to_le_bytes());
picf[4..6].copy_from_slice(&0x44u16.to_le_bytes());
picf[6..8].copy_from_slice(&0x0064u16.to_le_bytes()); // mfpf.mm = MM_SHAPE
picf[28..30].copy_from_slice(&1440i16.to_le_bytes());
picf[30..32].copy_from_slice(&720i16.to_le_bytes());
picf[32..34].copy_from_slice(&1000u16.to_le_bytes());
picf[34..36].copy_from_slice(&1000u16.to_le_bytes());
[picf.as_slice(), rest].concat()
}
#[test]
fn inline_officeart_picture_with_crop_and_alt_text() {
let png = tiny_png();
// A picture frame whose pib names the first FBSE that follows it, a quarter cropped
// off the left, with alt text.
let shape = sp(&[fsp(75, 1025, 0xA00), fopt(&[(0x0102, 0x4000), (0x0104, 1)], &[(0x0381, wz("Logo"))])]);
let data = picf_shape(&[shape, fbse_png(&png)].concat());
let text = "Pic\u{1}!\r";
let ccp = text.chars().count() as u32;
let spec = StructSpec { text, data_stream: data, chpx_runs: vec![(0, grpprl(&[(0x6A03, &0u32.to_le_bytes())]))], ..structured_spec(text, ccp) };
let doc = read(&struct_doc(&spec)).expect("opens");
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
assert_eq!(p0.text, "Pic\u{FFFC}!");
let Some(InlineObject::Image { media, w, h, alt, crop, float, .. }) = p0.objects.first() else { panic!("no image: {:?}", p0.objects) };
assert_eq!((*w, *h), (54.0, 36.0)); // 72 pt less a quarter, × 36 pt
assert_eq!(*crop, [0.25, 0.0, 0.0, 0.0]);
assert_eq!(alt, "Logo");
assert_eq!(float.wrap, Wrap::Inline);
assert_eq!(doc.media.get(media).map(|b| b.as_slice()), Some(png.as_slice()));
}
#[test]
fn floating_picture_from_the_blip_store_keeps_position_and_wrap() {
let png = tiny_png();
let pic = sp(&[fsp(75, 1025, 0xA00), fopt(&[(0x0104, 1)], &[])]);
let art = art_content(&[fbse_png(&png)], &[pic]);
// Two inches by one, 1" from the page's left edge and 0.5" below the top margin; square wrap.
let spas = plc(&[1, 3], &spa(1025, [1440, 720, 4320, 2160], spa_flags(1, 0, 2, false)));
let text = "A\u{8}B\r";
let ccp = text.chars().count() as u32;
let spec = StructSpec { text, extra_pairs: vec![(40, spas), (50, art)], ..structured_spec(text, ccp) };
let doc = read(&struct_doc(&spec)).expect("opens");
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
assert_eq!(p0.text, "A\u{FFFC}B");
let Some(InlineObject::Image { media, w, h, float, .. }) = p0.objects.first() else { panic!("no image: {:?}", p0.objects) };
assert_eq!((*w, *h), (144.0, 72.0));
assert_eq!((float.wrap, float.h_rel, float.v_rel, float.x, float.y), (Wrap::Square, Anchor::Page, Anchor::Margin, 72.0, 36.0));
assert_eq!(doc.media.get(media).map(|b| b.as_slice()), Some(png.as_slice()));
}
#[test]
fn shapes_keep_kind_colours_wrap_and_text_box_text() {
// A red ellipse with a 2 pt blue outline behind the text, and an unfilled text box.
let ellipse = sp(&[fsp(3, 1026, 0xA00), fopt(&[(0x0181, 0x0000_00FF), (0x01C0, 0x00FF_0000), (0x01CB, 25_400)], &[])]);
let tb = sp(&[fsp(202, 1027, 0xA00), fopt(&[(0x01BF, 1 << 20)], &[]), rec(0, 0, 0xF00D, &0x0001_0000u32.to_le_bytes())]);
let art = art_content(&[], &[ellipse, tb]);
let spas =
plc(&[1, 2, 4], &[spa(1026, [0, 0, 1440, 1440], spa_flags(2, 2, 3, true)), spa(1027, [0, 0, 2880, 720], spa_flags(0, 0, 1, false))].concat());
// The text box story: an FTXBXS whose lid is the shape's spid (cTxbx, cReusable, fReusable,
// reserved, lid, txidUndo).
let ftxbxs =
[1i32.to_le_bytes().as_slice(), &0i32.to_le_bytes(), &0i16.to_le_bytes(), &0i32.to_le_bytes(), &1027u32.to_le_bytes(), &0i32.to_le_bytes()]
.concat();
let txbx = plc(&[0, 7], &ftxbxs);
let text = "A\u{8}\u{8}B\r";
let ccp = text.chars().count() as u32;
// The text box story is stored as the subdocument right after the main text; the endnote
// slot of the fixture writes it there and the FIB is then patched to call it the text box
// subdocument (ccpEdn = 0, ccpTxbx = 7).
let spec =
StructSpec { text, endnote_stories: vec!["Inside\r"], extra_pairs: vec![(40, spas), (50, art), (56, txbx)], ..structured_spec(text, ccp) };
let file = patch_word(&struct_doc(&spec), &[(0x60, 0), (0x64, 7)]);
let doc = read(&file).expect("opens");
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
assert_eq!(p0.objects.len(), 2, "{:?}", p0.objects);
let Some(InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, .. }) = p0.objects.first() else { panic!("{:?}", p0.objects) };
assert_eq!((*kind, *w, *h), (ShapeKind::Ellipse, 72.0, 72.0));
assert_eq!((*fill, *stroke, *stroke_width), (Some(Rgb(255, 0, 0)), Some(Rgb(0, 0, 255)), 2.0));
assert_eq!((float.wrap, float.h_rel, float.v_rel), (Wrap::BehindText, Anchor::Column, Anchor::Paragraph));
let Some(InlineObject::Shape { kind, fill, float, story, .. }) = p0.objects.get(1) else { panic!("{:?}", p0.objects) };
assert_eq!((*kind, *fill, float.wrap), (ShapeKind::TextBox, None, Wrap::TopAndBottom));
let part = story.and_then(|id| doc.parts.get(&id)).expect("text box story part");
assert_eq!(part.kind, wordcraft_doc::PartKind::TextBox);
assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside"));
}
/// Overwrite little-endian u32s in the WordDocument stream of a compound file.
fn patch_word(file: &[u8], at: &[(usize, u32)]) -> Vec<u8> {
use std::io::Read;
let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(file.to_vec())).unwrap();
let mut streams = Vec::new();
for name in ["WordDocument", "0Table", "Data"] {
if let Ok(mut s) = comp.open_stream(format!("/{name}")) {
let mut v = Vec::new();
s.read_to_end(&mut v).unwrap();
if name == "WordDocument" {
for (o, x) in at {
v[*o..*o + 4].copy_from_slice(&x.to_le_bytes());
}
}
streams.push((name, v));
}
}
let refs: Vec<(&str, &[u8])> = streams.iter().map(|(n, v)| (*n, v.as_slice())).collect();
super::cfb_file(&refs)
}
#[test]
fn dib_becomes_png_and_compressed_metafiles_inflate() {
// A 2×1 24-bit DIB (BITMAPINFOHEADER, rows padded to 4 bytes).
let mut dib = Vec::new();
for v in [40u32, 2, 1] {
dib.extend_from_slice(&v.to_le_bytes());
}
dib.extend_from_slice(&1u16.to_le_bytes());
dib.extend_from_slice(&24u16.to_le_bytes());
dib.extend_from_slice(&[0u8; 24]);
dib.extend_from_slice(&[0, 0, 255, 0, 255, 0, 0, 0]);
let r = rec(0, 0x7A8, 0xF01F, &[[0u8; 16].as_slice(), &[0xFF], &dib].concat());
let (dib_rec, _) = officeart::record(&r).unwrap();
let blip = officeart::blip(&dib_rec, 1 << 20).expect("DIB decodes");
assert_eq!(blip.ext, "png");
let img = image::load_from_memory(&blip.bytes).unwrap().to_rgb8();
assert_eq!((img.width(), img.height(), img.get_pixel(0, 0).0), (2, 1, [255, 0, 0]));
// A deflated WMF stays a WMF (the metafile renderer draws it).
let wmf: Vec<u8> = [1u16, 9, 0x300].iter().flat_map(|v| v.to_le_bytes()).chain([0u8; 12]).collect();
let packed = miniz_oxide::deflate::compress_to_vec_zlib(&wmf, 6);
let mut hdr = Vec::new();
hdr.extend_from_slice(&(wmf.len() as u32).to_le_bytes());
hdr.extend_from_slice(&[0u8; 24]); // rcBounds, ptSize
hdr.extend_from_slice(&(packed.len() as u32).to_le_bytes());
hdr.extend_from_slice(&[0x00, 0xFE]); // compression = deflate, filter
let r = rec(0, 0x216, 0xF01B, &[[0u8; 16].as_slice(), &hdr, &packed].concat());
let (wmf_rec, _) = officeart::record(&r).unwrap();
let blip = officeart::blip(&wmf_rec, 1 << 20).expect("WMF inflates");
assert_eq!((blip.ext, blip.bytes), ("wmf", wmf));
}
#[test]
fn deeply_nested_groups_and_lying_lengths_are_bounded() {
// 2000 nested group containers: the walk stops at its depth cap (no stack overflow).
let mut g = sp(&[fsp(1, 2000, 0)]);
for _ in 0..2_000 {
g = container(0xF003, &[sp(&[fsp(0, 2001, 1)]), g]);
}
let _ = officeart::content(&art_content(&[], &[g]));
// A metafile claiming 4 GB uncompressed with garbage data, and an FBSE pointing far out.
let mut hdr = vec![0xFFu8; 4];
hdr.extend_from_slice(&[0u8; 24]);
hdr.extend_from_slice(&u32::MAX.to_le_bytes());
hdr.extend_from_slice(&[0, 0]);
let r = rec(0, 0x3D4, 0xF01A, &[[0u8; 16].as_slice(), &hdr, &[0x78, 0x9C, 1, 2, 3]].concat());
let (emf, _) = officeart::record(&r).unwrap();
assert!(officeart::blip(&emf, 1 << 20).is_none());
let mut fbse = vec![0u8; 36];
fbse[28..32].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(officeart::fbse_blip(&fbse, &[0u8; 64], 1 << 20).is_none());
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(128))]
#[test]
fn junk_drawings_never_panic(art in proptest::collection::vec(any::<u8>(), 0..2048), spas in proptest::collection::vec(any::<u8>(), 0..512), data in proptest::collection::vec(any::<u8>(), 0..1024)) {
let text = "A\u{8}\u{1}\u{8}B\r";
let ccp = text.chars().count() as u32;
let picf = picf_shape(&data);
let spec = StructSpec {
text,
data_stream: picf,
chpx_runs: vec![(0, grpprl(&[(0x6A03, &0u32.to_le_bytes())]))],
extra_pairs: vec![(40, spas), (50, art.clone()), (56, art)],
..structured_spec(text, ccp)
};
let _ = read(&struct_doc(&spec));
}
}
+411
View File
@@ -0,0 +1,411 @@
//! Word XML Document: a whole WordprocessingML package as one XML file (the Flat OPC format Word
//! saves as "Word XML Document (*.xml)"): a `pkg:package` root holding one `pkg:part` per package
//! part, each with its name and content type, and the part's XML inline (`pkg:xmlData`) or its
//! bytes in base64 (`pkg:binaryData`).
//!
//! Reading unpacks the parts into an in-memory [`Package`] and hands it to the DOCX reader;
//! writing runs the DOCX writer and lays its parts out in one file. Bounded like the zip reader:
//! part count, part size, total size and base64 length are capped.
use std::collections::BTreeMap;
use base64::Engine as _;
use quick_xml::events::{BytesStart, Event};
use quick_xml::name::ResolveResult;
use quick_xml::reader::NsReader;
use wordcraft_doc::Document;
use crate::package::{ContentTypes, MAX_ENTRIES, MAX_PART, MAX_TOTAL, Package};
use crate::xml::{self, W, esc};
use crate::{DocxError, Flavor};
/// The Flat OPC package namespace.
pub const PKG_NS: &str = "http://schemas.microsoft.com/office/2006/xmlPackage";
/// The WordprocessingML 2003 namespace (Word 2003 XML Document).
pub const W2003_NS: &str = "http://schemas.microsoft.com/office/word/2003/wordml";
/// What kind of XML file a `.xml` holds.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum XmlKind {
/// A Flat OPC package (Word XML Document, Word 2007 and later).
FlatOpc,
/// A Word 2003 XML Document (WordprocessingML 2003).
WordMl2003,
/// Some other XML, or not XML at all.
Other,
}
/// Elements and declarations looked at before the root element (comments, processing
/// instructions) before giving up.
const MAX_PROLOG_EVENTS: usize = 10_000;
/// The text of `bytes` as UTF-8: UTF-16 with a byte-order mark is transcoded, a UTF-8 one dropped.
fn utf8_text(bytes: &[u8]) -> std::borrow::Cow<'_, [u8]> {
match xml::utf16_to_utf8(bytes) {
Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
None => std::borrow::Cow::Borrowed(bytes.strip_prefix(&[0xEF, 0xBB, 0xBF][..]).unwrap_or(bytes)),
}
}
/// Tell a Word XML file by its root element: `pkg:package` (Flat OPC) or `w:wordDocument`
/// (Word 2003), each in its namespace.
pub fn sniff_xml(bytes: &[u8]) -> XmlKind {
let text = utf8_text(bytes);
let mut r = NsReader::from_reader(text.as_ref());
for _ in 0..MAX_PROLOG_EVENTS {
match r.read_resolved_event() {
Ok((res, Event::Start(e) | Event::Empty(e))) => {
let ns = match res {
ResolveResult::Bound(ns) => ns.as_ref().to_vec(),
_ => Vec::new(),
};
return match (e.local_name().as_ref(), ns.as_slice()) {
(b"package", ns) if ns == PKG_NS.as_bytes() => XmlKind::FlatOpc,
(b"wordDocument", ns) if ns == W2003_NS.as_bytes() => XmlKind::WordMl2003,
_ => XmlKind::Other,
};
}
Ok((_, Event::Eof)) | Err(_) => return XmlKind::Other,
Ok(_) => {}
}
}
XmlKind::Other
}
/// Read a Flat OPC file.
pub fn read(bytes: &[u8]) -> Result<Document, DocxError> {
crate::read::read_opened(&unpack(bytes)?)
}
/// The data element of the part being read.
enum Data {
/// `pkg:xmlData`: its content starts at this byte offset.
Xml(usize),
/// `pkg:binaryData`: the base64 text so far.
Binary(String),
}
/// The part being read: name, content type, data.
struct Part {
name: String,
content_type: String,
data: Option<Data>,
}
/// Longest base64 text of one part (the size limit, encoded; whitespace isn't kept).
const MAX_BASE64: u64 = (MAX_PART / 3 + 1) * 4;
/// Unpack a Flat OPC file into its parts, with a `[Content_Types].xml` made from the parts'
/// content types (unless the file carries one).
pub(crate) fn unpack(bytes: &[u8]) -> Result<Package, DocxError> {
let text = utf8_text(bytes);
let src: &[u8] = text.as_ref();
let mut r = NsReader::from_reader(src);
r.config_mut().check_end_names = true;
let mut depth = 0usize;
let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
let mut types: Vec<(String, String)> = Vec::new();
let mut total: u64 = 0;
let mut part: Option<Part> = None;
// `xmlns` declarations on the package, part and data elements, in scope for the inline XML.
let mut scope: Vec<Vec<(String, String)>> = Vec::new();
let bad = |e: quick_xml::Error| DocxError::Xml(e.to_string());
loop {
let before = usize::try_from(r.buffer_position()).unwrap_or(usize::MAX);
let (res, ev) = r.read_resolved_event().map_err(bad)?;
let in_pkg = matches!(&res, ResolveResult::Bound(ns) if ns.as_ref() == PKG_NS.as_bytes());
match ev {
Event::Start(ref e) | Event::Empty(ref e) => {
let empty = matches!(ev, Event::Empty(_));
depth += 1;
let local = e.local_name().as_ref().to_vec();
match depth {
1 if !(in_pkg && local == b"package") => {
return Err(DocxError::NotWord("the root element isn't a Flat OPC pkg:package".into()));
}
2 if in_pkg && local == b"part" => {
if files.len() + usize::from(part.is_some()) >= MAX_ENTRIES {
return Err(DocxError::Limit(format!("more than {MAX_ENTRIES} parts")));
}
let name = attr(e, b"name").unwrap_or_default();
let content_type = attr(e, b"contentType").unwrap_or_default();
part = Some(Part { name, content_type, data: None });
}
3 if in_pkg => {
if let Some(p) = part.as_mut().filter(|p| p.data.is_none()) {
let pos = usize::try_from(r.buffer_position()).unwrap_or(usize::MAX);
p.data = match local.as_slice() {
// `<pkg:xmlData/>`: an empty part.
b"xmlData" if empty => Some(Data::Binary(String::new())),
b"xmlData" => Some(Data::Xml(pos)),
b"binaryData" => Some(Data::Binary(String::new())),
_ => None,
};
}
}
_ => {}
}
if depth <= 3 {
scope.push(ns_decls(e));
}
if empty {
if depth == 2
&& let Some(p) = part.take()
{
finish_part(p, &mut files, &mut types, &mut total)?;
}
if depth <= 3 {
scope.pop();
}
depth -= 1;
}
}
Event::End(_) => {
// The inline XML ends where its data element's end tag starts.
if depth == 3
&& let Some(p) = part.as_mut()
&& let Some(Data::Xml(start)) = p.data
{
p.data = None;
let xml = with_scope(src.get(start..before).unwrap_or_default(), &scope);
store(&p.name, &p.content_type, xml, &mut files, &mut types, &mut total)?;
}
if depth == 2
&& let Some(p) = part.take()
{
finish_part(p, &mut files, &mut types, &mut total)?;
}
if depth <= 3 {
scope.pop();
}
depth = depth.saturating_sub(1);
if depth == 0 {
break;
}
}
Event::Text(ref t) if depth == 3 => {
if let Some(Part { data: Some(Data::Binary(b64)), .. }) = part.as_mut() {
let s = std::str::from_utf8(t.as_ref()).map_err(|e| DocxError::Xml(e.to_string()))?;
b64.extend(s.chars().filter(|c| !c.is_ascii_whitespace()));
if b64.len() as u64 > MAX_BASE64 {
return Err(DocxError::Limit("a binary part is larger than the size limit".into()));
}
}
}
Event::Eof => break,
_ => {}
}
}
if files.is_empty() {
return Err(DocxError::NotWord("the Flat OPC package has no parts".into()));
}
if !files.contains_key("[Content_Types].xml") {
let mut w = W::new();
w.open("Types", &[("xmlns", "http://schemas.openxmlformats.org/package/2006/content-types")]);
for (name, ct) in &types {
w.empty("Override", &[("PartName", &format!("/{name}")), ("ContentType", ct)]);
}
w.close("Types");
files.insert("[Content_Types].xml".into(), w.into_bytes());
}
Ok(Package::from_parts(files))
}
/// End of a `pkg:part`: store its binary data (inline XML was stored when it ended).
fn finish_part(p: Part, files: &mut BTreeMap<String, Vec<u8>>, types: &mut Vec<(String, String)>, total: &mut u64) -> Result<(), DocxError> {
match p.data {
Some(Data::Binary(b64)) => {
let bytes = base64::engine::general_purpose::STANDARD_NO_PAD
.decode(b64.trim_end_matches('='))
.map_err(|_| DocxError::Xml(format!("part {} has invalid base64 data", p.name)))?;
store(&p.name, &p.content_type, bytes, files, types, total)
}
// A part without data, or whose inline XML was already stored.
_ => Ok(()),
}
}
/// Add a part, within the size limits; a repeated or empty name is ignored.
fn store(
name: &str,
content_type: &str,
bytes: Vec<u8>,
files: &mut BTreeMap<String, Vec<u8>>,
types: &mut Vec<(String, String)>,
total: &mut u64,
) -> Result<(), DocxError> {
let name = name.trim_start_matches('/');
if name.is_empty() || files.contains_key(name) {
return Ok(());
}
let len = bytes.len() as u64;
if len > MAX_PART {
return Err(DocxError::Limit(format!("part {name} is larger than the size limit")));
}
*total = total.saturating_add(len);
if *total > MAX_TOTAL {
return Err(DocxError::Limit("the package is larger than the size limit".into()));
}
if !content_type.is_empty() && !content_type.contains(['\r', '\n']) {
types.push((name.to_string(), content_type.to_string()));
}
files.insert(name.to_string(), bytes);
Ok(())
}
/// An attribute's unescaped value, by local name (any prefix).
fn attr(e: &BytesStart, local: &[u8]) -> Option<String> {
e.attributes().with_checks(false).flatten().find(|a| a.key.local_name().as_ref() == local).map(|a| {
match a.normalized_value(quick_xml::XmlVersion::Explicit1_0) {
Ok(v) => v.into_owned(),
Err(_) => String::from_utf8_lossy(&a.value).into_owned(),
}
})
}
/// The `xmlns` / `xmlns:p` declarations of a start tag, as written (value still escaped).
fn ns_decls(e: &BytesStart) -> Vec<(String, String)> {
e.attributes()
.with_checks(false)
.flatten()
.filter(|a| a.key.as_ref() == b"xmlns" || a.key.as_ref().starts_with(b"xmlns:"))
.filter(|a| !a.value.contains(&b'"'))
.map(|a| (String::from_utf8_lossy(a.key.as_ref()).into_owned(), String::from_utf8_lossy(&a.value).into_owned()))
.take(256)
.collect()
}
/// The inline XML of a part as a document of its own: namespaces it inherits from the package
/// elements are declared on its root element (unless it declares them itself).
fn with_scope(frag: &[u8], scope: &[Vec<(String, String)>]) -> Vec<u8> {
let decls: Vec<&(String, String)> = scope.iter().flatten().filter(|(k, _)| k != "xmlns:pkg").collect();
let Some(open) = root_tag_start(frag) else { return frag.to_vec() };
if decls.is_empty() {
return frag.get(open..).unwrap_or_default().to_vec();
}
let rest = frag.get(open..).unwrap_or_default();
let name_end = rest.iter().position(|b| b.is_ascii_whitespace() || *b == b'>' || *b == b'/').unwrap_or(rest.len());
let tag_end = rest.iter().position(|b| *b == b'>').unwrap_or(rest.len());
let tag = rest.get(..tag_end).unwrap_or_default();
let mut out = Vec::with_capacity(rest.len() + 256);
out.extend_from_slice(rest.get(..name_end).unwrap_or_default());
// Innermost declarations win: walk from the data element out, skipping prefixes seen.
let mut seen: Vec<&str> = Vec::new();
for (k, v) in decls.iter().rev() {
let declared = tag.windows(k.len() + 1).any(|w| w.get(..k.len()) == Some(k.as_bytes()) && matches!(w.last(), Some(b'=' | b' ')));
if declared || seen.contains(&k.as_str()) {
continue;
}
seen.push(k);
out.extend_from_slice(format!(" {k}=\"{v}\"").as_bytes());
}
out.extend_from_slice(rest.get(name_end..).unwrap_or_default());
out
}
/// Offset of the `<` of the first element in `frag` (after whitespace, comments and processing
/// instructions).
fn root_tag_start(frag: &[u8]) -> Option<usize> {
let mut i = 0usize;
loop {
while frag.get(i).is_some_and(u8::is_ascii_whitespace) {
i += 1;
}
let rest = frag.get(i..)?;
if rest.starts_with(b"<!--") {
i += rest.windows(3).position(|w| w == b"-->")? + 3;
} else if rest.starts_with(b"<?") {
i += rest.windows(2).position(|w| w == b"?>")? + 2;
} else if rest.first() == Some(&b'<') {
return Some(i);
} else {
return None;
}
}
}
/// Write `doc` as a Word XML Document (Flat OPC).
pub fn write(doc: &Document) -> Result<Vec<u8>, DocxError> {
let parts = crate::write::write_parts(doc, Flavor::Document)?;
let types = parts
.iter()
.find(|(n, _)| n == "[Content_Types].xml")
.map(|(n, b)| ContentTypes::read(&Package::from_parts(BTreeMap::from([(n.clone(), b.clone())]))))
.unwrap_or_default();
let mut s = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<?mso-application progid=\"Word.Document\"?>\n");
s.push_str(&format!("<pkg:package xmlns:pkg=\"{PKG_NS}\">"));
for (name, bytes) in &parts {
if name == "[Content_Types].xml" {
continue;
}
let ct = types.of(name).unwrap_or("application/octet-stream");
s.push_str(&format!("<pkg:part pkg:name=\"/{}\" pkg:contentType=\"{}\"", esc(name), esc(ct)));
match xml_fragment(ct, bytes) {
Some(x) => {
s.push_str("><pkg:xmlData>");
s.push_str(x);
s.push_str("</pkg:xmlData></pkg:part>");
}
None => {
s.push_str(" pkg:compression=\"store\"><pkg:binaryData>");
let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
for (i, chunk) in b64.as_bytes().chunks(76).enumerate() {
if i > 0 {
s.push('\n');
}
s.push_str(std::str::from_utf8(chunk).unwrap_or_default());
}
s.push_str("</pkg:binaryData></pkg:part>");
}
}
}
s.push_str("</pkg:package>");
Ok(s.into_bytes())
}
/// An XML part's markup without its XML declaration, to inline as `pkg:xmlData`; `None` for
/// a part that isn't UTF-8 XML (or has a DOCTYPE), which goes in base64.
fn xml_fragment<'a>(ct: &str, bytes: &'a [u8]) -> Option<&'a str> {
let ct = ct.to_ascii_lowercase();
if !(ct.ends_with("+xml") || ct.ends_with("/xml")) {
return None;
}
let s = std::str::from_utf8(bytes).ok()?;
let s = s.strip_prefix('\u{FEFF}').unwrap_or(s).trim_start();
let s = match s.strip_prefix("<?xml") {
Some(rest) => rest.split_once("?>").map(|(_, r)| r)?.trim_start(),
None => s,
};
(s.starts_with('<') && !s.contains("<!DOCTYPE")).then_some(s)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sniffs_root_elements() {
assert_eq!(
sniff_xml(
format!("<?xml version=\"1.0\"?><?mso-application progid=\"Word.Document\"?><!-- x --><p:package xmlns:p=\"{PKG_NS}\"/>").as_bytes()
),
XmlKind::FlatOpc
);
assert_eq!(sniff_xml(format!("\u{FEFF}<w:wordDocument xmlns:w=\"{W2003_NS}\"/>").as_bytes()), XmlKind::WordMl2003);
assert_eq!(sniff_xml(b"<package/>"), XmlKind::Other);
assert_eq!(sniff_xml(b"<html><body/></html>"), XmlKind::Other);
assert_eq!(sniff_xml(b"\x00\xff garbage"), XmlKind::Other);
assert_eq!(sniff_xml(b""), XmlKind::Other);
}
#[test]
fn inline_xml_inherits_namespaces_from_the_package() {
let frag = b"\n <!-- c --> <w:document><w:body/></w:document>";
let scope = vec![vec![("xmlns:pkg".to_string(), PKG_NS.to_string()), ("xmlns:w".to_string(), "urn:w".to_string())]];
let out = String::from_utf8(with_scope(frag, &scope)).unwrap();
assert_eq!(out, "<w:document xmlns:w=\"urn:w\"><w:body/></w:document>");
let own = b"<w:document xmlns:w=\"urn:own\"/>";
assert_eq!(with_scope(own, &scope), own.to_vec());
assert_eq!(with_scope(b"no element", &scope), b"no element".to_vec());
}
}
+25
View File
@@ -10,9 +10,12 @@
mod chart_spec;
mod crypt;
mod custom;
mod flat;
mod package;
mod read;
mod smart_art;
mod units;
mod wordml2003;
mod write;
mod xml;
@@ -21,9 +24,28 @@ pub use crypt::{
DEFAULT_SPIN_COUNT, MAX_PASSWORD_CHARS, PasswordCheck, check_password, check_protection_password, decrypt, encrypt, encrypt_with_spin_count,
is_encrypted, protection_hash,
};
pub use flat::{XmlKind, sniff_xml, write as write_flat};
pub use read::read;
pub use smart_art::smart_art_items;
pub use write::{write, write_as};
/// Largest `.xml` file read as a Word XML document.
pub const MAX_XML_FILE: usize = 1 << 30;
/// Read a Word XML file (`.xml`): a Word XML Document (Flat OPC, the whole package in one XML
/// file) or a Word 2003 XML Document (WordprocessingML 2003), told apart by the root element.
/// Any other XML gives [`DocxError::NotWordXml`].
pub fn read_xml(bytes: &[u8]) -> Result<wordcraft_doc::Document, DocxError> {
if bytes.len() > MAX_XML_FILE {
return Err(DocxError::Limit(format!("XML file of {} bytes", bytes.len())));
}
match sniff_xml(bytes) {
XmlKind::FlatOpc => flat::read(bytes),
XmlKind::WordMl2003 => wordml2003::read(bytes),
XmlKind::Other => Err(DocxError::NotWordXml),
}
}
/// What [`DocxError::PasswordRequired`] says.
pub const PASSWORD_REQUIRED: &str = "this document is protected with a password";
/// What [`DocxError::WrongPassword`] says.
@@ -106,6 +128,9 @@ pub enum DocxError {
/// The package is readable but isn't a WordprocessingML document.
#[error("not a Word document: {0}")]
NotWord(String),
/// An `.xml` file that is neither a Word XML Document (Flat OPC) nor Word 2003 XML.
#[error("not a Word XML document")]
NotWordXml,
/// The package is encrypted and no password was given.
#[error("{}", PASSWORD_REQUIRED)]
PasswordRequired,
+16
View File
@@ -19,9 +19,12 @@ pub mod rt {
pub const CORE: &str = "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties";
pub const APP: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties";
pub const CUSTOM: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties";
pub const CUSTOM_XML_PROPS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXmlProps";
pub const STYLES: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles";
pub const NUMBERING: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering";
pub const SETTINGS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings";
pub const ATTACHED_TEMPLATE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/attachedTemplate";
pub const FONT_TABLE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable";
pub const THEME: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme";
pub const FOOTNOTES: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes";
pub const ENDNOTES: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes";
@@ -35,6 +38,10 @@ pub mod rt {
pub const CHART: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart";
pub const DIAGRAM_DATA: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramData";
pub const DIAGRAM_DRAWING: &str = "http://schemas.microsoft.com/office/2007/relationships/diagramDrawing";
pub const DIAGRAM_LAYOUT: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramLayout";
pub const DIAGRAM_QUICK_STYLE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramQuickStyle";
pub const DIAGRAM_COLORS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramColors";
pub const CUSTOM_XML: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml";
}
/// `Document::passthrough` key for a macro project, kept as opaque bytes (never parsed or run).
@@ -51,6 +58,9 @@ pub const MAX_VBA_RELATED: usize = 64;
/// types and own relationships, as [`EmbeddedManifest`] lines. Each part's bytes are kept under its
/// part name. Not a package part: WordCraft's own bookkeeping.
pub const EMBEDDED_PARTS: &str = "wordcraft:embedded.parts";
/// Custom XML roots kept by the package (P) or main document (D): `owner\ttype\tpart`.
/// Their opaque bytes and related parts use the same bounded manifest as embedded objects.
pub const CUSTOM_XML_ROOTS: &str = "wordcraft:customXml.roots";
/// Most parts we carry along for a file's embedded objects.
pub const MAX_EMBEDDED_PARTS: usize = 4096;
/// Most relationships we keep for one such part.
@@ -174,6 +184,12 @@ impl Package {
Ok(Package { files })
}
/// A package from parts already in memory (a Flat OPC file, a converted document), by part
/// name without the leading `/`.
pub fn from_parts(files: BTreeMap<String, Vec<u8>>) -> Package {
Package { files }
}
/// Part bytes by name (leading `/` ignored; case-insensitive fallback).
pub fn get(&self, name: &str) -> Option<&[u8]> {
let n = name.trim_start_matches('/');
+17 -2
View File
@@ -234,7 +234,9 @@ fn kind_of(prst: &str) -> Option<ShapeKind> {
"ellipse" => ShapeKind::Ellipse,
"triangle" => ShapeKind::Triangle,
"diamond" => ShapeKind::Diamond,
"line" | "straightConnector1" => ShapeKind::Line,
// A line runs from its box's top-left to its bottom-right corner (a path; the Line shape
// kind draws the other diagonal).
"line" | "straightConnector1" => return None,
"rightArrow" => ShapeKind::Arrow,
"star5" | "star4" | "star6" => ShapeKind::Star,
"heart" => ShapeKind::Heart,
@@ -466,9 +468,14 @@ fn text(sp: &El, xf: Xf, b: [f64; 4], theme: &[Rgb], filled: bool, fit: f32, out
}
}
/// How wide `s` is at `size` points by the estimate chart and diagram text uses.
pub(crate) fn text_width(s: &str, size: f32) -> f32 {
text_w(s, size)
}
/// Splits `text` into at most `max` lines of whole words that fit `width` points (by the shared
/// text width estimate); a word wider than that keeps its own line.
fn wrap(text: &str, size: f32, width: f32, max: usize) -> Vec<String> {
pub(crate) fn wrap(text: &str, size: f32, width: f32, max: usize) -> Vec<String> {
let limit = if width > 0.0 && size > 0.0 { width } else { f32::INFINITY };
let space = text_w(" ", size);
let mut lines = Vec::new();
@@ -521,6 +528,14 @@ impl Reader<'_> {
rels.by_id(&id).is_some_and(|r| !r.external && rel_is(&r.kind, rt::DIAGRAM_DRAWING)).then_some(id)
}
/// The SmartArt model of a diagram WordCraft wrote (its data part exactly as WordCraft writes
/// it); `None` for any other.
pub(super) fn smart_art_spec(&mut self, gd: &El, rels: &Rels) -> Option<wordcraft_doc::smart_art::SmartArtSpec> {
let dm = gd.child("dgm:relIds")?.attr("r:dm")?;
let data = super::part_of(rels, dm, rt::DIAGRAM_DATA)?;
crate::smart_art::spec_of(self.graphic_part(&data)?.as_ref())
}
/// The items of the SmartArt drawing part at `path`, fitted to `w` × `h` points.
pub(super) fn diagram_drawing(&mut self, path: &str, w: f32, h: f32) -> Vec<GraphicItem> {
let Some(root) = self.graphic_part(path) else { return Vec::new() };
+63 -3
View File
@@ -31,7 +31,7 @@ pub(crate) fn fill_colour(e: &El, theme: &[Rgb]) -> Option<Rgb> {
}
fn is_color(e: &El) -> bool {
matches!(e.name.as_str(), "a:srgbClr" | "a:schemeClr" | "a:sysClr")
matches!(e.name.as_str(), "a:srgbClr" | "a:schemeClr" | "a:sysClr" | "a:prstClr" | "a:scrgbClr" | "a:hslClr")
}
/// A colour element (`a:srgbClr`, `a:schemeClr`, `a:sysClr`), or the one inside `e`, with its
@@ -41,7 +41,25 @@ pub(crate) fn color(e: &El, theme: &[Rgb]) -> Option<Rgb> {
let base = match c.name.as_str() {
"a:srgbClr" => Rgb::parse(c.attr("val")?)?,
"a:schemeClr" => *theme.get(scheme_slot(c.attr("val")?)?)?,
"a:sysClr" => Rgb::parse(c.attr("lastClr")?)?,
"a:sysClr" => match c.attr("lastClr").and_then(Rgb::parse) {
Some(rgb) => rgb,
None => sys_color(c.attr("val")?)?,
},
"a:prstClr" => preset_color(c.attr("val")?)?,
"a:scrgbClr" => {
// Linear 0–100 000 per channel, to sRGB.
let ch = |n: &str| -> u8 {
let l = c.attr(n).and_then(int).map_or(0.0, |v| (v as f64 / 100_000.0).clamp(0.0, 1.0));
let s = if l <= 0.003_130_8 { l * 12.92 } else { 1.055 * l.powf(1.0 / 2.4) - 0.055 };
(s * 255.0).round().clamp(0.0, 255.0) as u8
};
Rgb(ch("r"), ch("g"), ch("b"))
}
"a:hslClr" => {
let v = |n: &str| c.attr(n).and_then(int).unwrap_or(0) as f64;
let hue = (v("hue") / 60_000.0).rem_euclid(360.0) / 360.0;
from_hsl(hue, (v("sat") / 100_000.0).clamp(0.0, 1.0), (v("lum") / 100_000.0).clamp(0.0, 1.0))
}
_ => return None,
};
let val = |m: &El| m.attr("val").and_then(int).map_or(0.0, |v| (v as f64 / 100_000.0).clamp(-10.0, 10.0));
@@ -58,6 +76,47 @@ pub(crate) fn color(e: &El, theme: &[Rgb]) -> Option<Rgb> {
Some(rgb)
}
/// A preset colour (`a:prstClr/@val`, ECMA-376 Part 1 §20.1.10.48): the common ones; others are
/// unknown (`None`).
pub(crate) fn preset_color(name: &str) -> Option<Rgb> {
Some(match name {
"black" => Rgb(0, 0, 0),
"white" => Rgb(0xFF, 0xFF, 0xFF),
"red" => Rgb(0xFF, 0, 0),
"green" => Rgb(0, 0x80, 0),
"lime" => Rgb(0, 0xFF, 0),
"blue" => Rgb(0, 0, 0xFF),
"yellow" => Rgb(0xFF, 0xFF, 0),
"cyan" | "aqua" => Rgb(0, 0xFF, 0xFF),
"magenta" | "fuchsia" => Rgb(0xFF, 0, 0xFF),
"gray" | "grey" => Rgb(0x80, 0x80, 0x80),
"silver" => Rgb(0xC0, 0xC0, 0xC0),
"ltGray" | "ltGrey" => Rgb(0xD3, 0xD3, 0xD3),
"dkGray" | "dkGrey" => Rgb(0xA9, 0xA9, 0xA9),
"maroon" => Rgb(0x80, 0, 0),
"navy" => Rgb(0, 0, 0x80),
"olive" => Rgb(0x80, 0x80, 0),
"purple" => Rgb(0x80, 0, 0x80),
"teal" => Rgb(0, 0x80, 0x80),
"orange" => Rgb(0xFF, 0xA5, 0),
"brown" => Rgb(0xA5, 0x2A, 0x2A),
"pink" => Rgb(0xFF, 0xC0, 0xCB),
_ => return None,
})
}
/// A system colour without its `lastClr`: the usual light-theme values of the common ones.
fn sys_color(name: &str) -> Option<Rgb> {
Some(match name {
"windowText" | "btnText" | "menuText" | "captionText" | "infoText" => Rgb(0, 0, 0),
"window" | "menu" | "highlightText" => Rgb(0xFF, 0xFF, 0xFF),
"btnFace" | "3dLight" => Rgb(0xF0, 0xF0, 0xF0),
"grayText" | "btnShadow" => Rgb(0xA0, 0xA0, 0xA0),
"highlight" => Rgb(0x00, 0x78, 0xD7),
_ => return None,
})
}
/// Index into `Document::settings.theme_colors` (dk1, lt1, dk2, lt2, accent1..6, hlink, folHlink).
fn scheme_slot(name: &str) -> Option<usize> {
Some(match name {
@@ -93,7 +152,8 @@ fn mix(c: Rgb, to: f64, v: f64) -> Rgb {
Rgb(f(c.0), f(c.1), f(c.2))
}
fn adjust_lum(c: Rgb, f: impl Fn(f64) -> f64) -> Rgb {
/// `c` with its HSL luminance changed by `f`.
pub(crate) fn adjust_lum(c: Rgb, f: impl Fn(f64) -> f64) -> Rgb {
let (h, s, l) = to_hsl(c);
from_hsl(h, s, f(l).clamp(0.0, 1.0))
}
+28 -3
View File
@@ -6,20 +6,45 @@
//! reaches through relationships (chart, embedded workbook, colours and style; diagram data, layout,
//! quick style, colours and drawing; OLE embedding and its picture…) as opaque bytes in
//! `Document::passthrough`, with their content types and relationships in an
//! [`EmbeddedManifest`](crate::package::EmbeddedManifest).
//! [`EmbeddedManifest`](crate::package::EmbeddedManifest). The font table and the fonts it embeds
//! are kept the same way.
use std::sync::Arc;
use wordcraft_doc::graphic::{Embedded, EmbeddedRef};
use super::Reader;
use crate::package::{ContentTypes, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, MAX_EMBEDDED_XML, Rel, Rels, manifest_field_ok};
use crate::package::{
CUSTOM_XML_ROOTS, ContentTypes, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, MAX_EMBEDDED_XML, Rel, Rels, manifest_field_ok, rel_is, rt,
};
use crate::xml::{El, MAX_DEPTH, NAMESPACES, esc};
/// Most relationship ids one object's markup may use.
const MAX_IDS: usize = 64;
impl Reader<'_> {
/// Keep custom XML stores reached from the main document or package, without evaluating
/// bindings. A bibliography already loaded into the source model belongs to its writer.
pub(super) fn keep_custom_xml(&mut self, package: &Rels, document: &Rels) {
let mut roots = String::new();
for (owner, rels) in [("P", package), ("D", document)] {
for rel in rels.list.iter().filter(|r| !r.external && rel_is(&r.kind, rt::CUSTOM_XML)).take(MAX_EMBEDDED_RELS) {
if ![&rel.kind, &rel.target].iter().all(|s| manifest_field_ok(s)) {
continue;
}
if !self.doc.sources.is_empty() && self.xml(&rel.target).ok().flatten().is_some_and(|r| r.local() == "Sources") {
continue;
}
if self.keep_part_tree(&rel.target) {
roots.push_str(&format!("{owner}\t{}\t{}\n", rel.kind, rel.target));
}
}
}
if !roots.is_empty() {
self.doc.passthrough.insert(CUSTOM_XML_ROOTS.into(), Arc::new(roots.into_bytes()));
}
}
/// Keep `el` (an object's `a:graphic` or `w:object`, read with `rels`) and the parts it refers
/// to, so the object can be written back. `extra` names relationships the object's parts use
/// by id although its markup doesn't (a SmartArt drawing, named in the diagram's data part).
@@ -49,7 +74,7 @@ impl Reader<'_> {
/// Keep part `root` and every part it reaches through its relationships. False when `root`
/// itself can't be kept.
fn keep_part_tree(&mut self, root: &str) -> bool {
pub(super) fn keep_part_tree(&mut self, root: &str) -> bool {
if self.embedded.parts.contains_key(root) {
return true;
}
+3 -5
View File
@@ -219,11 +219,9 @@ fn node(k: &El, depth: usize, out: &mut Arg) {
});
}
// Tracked insertions show; deletions don't.
"w:ins" | "w:moveTo" | "w:smartTag" | "w:customXml" | "m:oMath" => {
if depth <= MAX_DEPTH {
for c in k.els() {
node(c, d, out);
}
"w:ins" | "w:moveTo" | "w:smartTag" | "w:customXml" | "m:oMath" if depth <= MAX_DEPTH => {
for c in k.els() {
node(c, d, out);
}
}
"w:sdt" => {
+322 -14
View File
@@ -1,13 +1,14 @@
//! DOCX → [`Document`].
pub(crate) mod chart;
mod diagram;
pub(crate) mod diagram;
mod drawing_color;
mod embed;
mod freeform;
mod math;
mod props;
mod sdt;
mod shape_style;
mod story;
mod wordart;
@@ -35,6 +36,22 @@ const MAX_PARTS: usize = 50_000;
/// Most graphic work (items plus path segments, see `story::graphic_work`) built for one file's
/// charts and diagrams; later ones are left empty.
const MAX_GRAPHIC_WORK: usize = 2_000_000;
/// Bibliography sources kept from a file (Word's own source manager holds far fewer).
const MAX_SOURCES: usize = 10_000;
/// Custom XML parts looked at for bibliography sources.
const MAX_BIBLIOGRAPHY_PARTS: usize = 64;
/// Characters kept of a bibliography's citation style name.
const MAX_STYLE_NAME: usize = 64;
/// Bibliography sources already read: tags, and (title, author) of untagged ones.
#[derive(Default)]
struct BibSeen {
tags: HashSet<String>,
untagged: HashSet<(String, String)>,
}
/// Longest attached-template path we keep (bytes); a longer one is not a real path.
const MAX_TEMPLATE_PATH: usize = 32 * 1024;
pub(crate) struct Reader<'p> {
pkg: &'p Package,
@@ -97,7 +114,12 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocxError> {
/// [`read`] for bytes known not to be an encrypted package.
pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
let pkg = Package::open(bytes)?;
read_opened(&Package::open(bytes)?)
}
/// Read a package whose parts are already unpacked (from a zip, a Flat OPC file or a converted
/// Word 2003 XML document).
pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
let root_rels = pkg.rels("");
let main = root_rels.by_type(rt::OFFICE_DOC).filter(|r| !r.external).map(|r| r.target.clone()).unwrap_or_else(|| "word/document.xml".to_string());
let Some(root) = pkg.xml(&main)? else {
@@ -110,7 +132,7 @@ pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
let mut doc = Document::new();
doc.body.clear();
let mut r = Reader {
pkg: &pkg,
pkg,
doc,
pc: PropCtx::default(),
revs: HashMap::new(),
@@ -139,6 +161,10 @@ pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
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());
// The font table and the fonts embedded through it ride along as opaque parts.
if let Some(p) = part(rt::FONT_TABLE) {
r.keep_part_tree(&p);
}
if let Some(p) = part(rt::THEME) {
r.read_theme(&p);
}
@@ -148,6 +174,10 @@ pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
log::warn!("docx: ignoring unreadable {what}: {e}");
}
};
// Styles come before settings, but their theme colours already follow its colour mapping.
if let Some(p) = part(rt::SETTINGS) {
r.read_color_mapping(&p);
}
if let Some(p) = part(rt::STYLES) {
lenient("styles", r.read_styles(&p));
}
@@ -169,6 +199,31 @@ pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
let ex = part(rt::COMMENTS_EX);
lenient("comments", r.read_comments(&p, ex.as_deref()));
}
// 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.
let mut bib_parts: Vec<String> = Vec::new();
for rel in root_rels.list.iter().chain(rels.list.iter()) {
if rel_is(&rel.kind, rt::CUSTOM_XML) && !rel.external && !bib_parts.contains(&rel.target) {
bib_parts.push(rel.target.clone());
if bib_parts.len() >= MAX_BIBLIOGRAPHY_PARTS {
break;
}
}
}
for i in 1..=16 {
let path = format!("customXml/item{i}.xml");
if bib_parts.len() < MAX_BIBLIOGRAPHY_PARTS && pkg.get(&path).is_some() && !bib_parts.contains(&path) {
bib_parts.push(path);
}
}
let mut seen_sources = BibSeen::default();
for path in &bib_parts {
if r.doc.sources.len() >= MAX_SOURCES {
break;
}
lenient("customXml bibliography", r.read_bibliography(path, &mut seen_sources));
}
if let Some(bg) = root.child("w:background").and_then(|b| b.attr("w:color")).and_then(Rgb::parse) {
r.doc.settings.page_color = Some(bg);
@@ -178,8 +233,9 @@ pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
let mut blocks = Blocks::new();
r.read_blocks(&mut sc, body, &rels, &mut blocks, 0);
r.flush_pending(&mut sc, &mut blocks);
toc_field_to_heading(&mut blocks);
r.doc.body = blocks;
unstyle_toc_links(&mut r.doc);
toc_field_to_heading(&mut r.doc.body);
if let Some(s) = body.child("w:sectPr") {
r.doc.last_section = r.read_section(s, &rels);
}
@@ -201,6 +257,7 @@ pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
r.doc.passthrough.insert(VBA_PROJECT_PART.into(), Arc::new(b.to_vec()));
r.read_vba_related(&v);
}
r.keep_custom_xml(&root_rels, &rels);
if !r.embedded.parts.is_empty() {
let manifest = r.embedded.to_bytes();
r.doc.passthrough.insert(EMBEDDED_PARTS.into(), Arc::new(manifest));
@@ -333,8 +390,25 @@ fn keep_glued_ids(doc: &mut Document, glued: &HashSet<u32>) {
});
}
/// Word starts a TOC field in the first entry; the engine keeps it at the end of the TOC heading
/// (the paragraph holding the field marks where the TOC is). Move it there when one precedes it.
/// Word draws a table of contents' entry links without the Hyperlink character style (no blue,
/// no underline) though the entries carry it (measured in Word for Windows: a Hyperlink-styled run
/// in a hyperlink inside a TOC field's result is plain; one outside a hyperlink, another style,
/// direct formatting, or a hyperlink in another field keep their look). The style is dropped from
/// those runs so they look the same here; the links stay.
fn unstyle_toc_links(doc: &mut Document) {
for f in doc.field_ranges(wordcraft_doc::StoryRef::Body) {
if is_toc(&f.instr) {
let _ = doc.format_range(&f.start, &f.end, &|c| {
if c.link.is_some() && c.style.as_deref() == Some("Hyperlink") {
c.style = None;
}
});
}
}
}
/// Normalize only a complete TOC range that the engine's heading/entry representation can
/// reproduce exactly. Other imported TOCs keep their explicit boundaries, independent of styles.
fn toc_field_to_heading(body: &mut Blocks) {
for i in 1..body.len() {
let heading = body[i - 1].as_para().filter(|p| p.props.style.as_deref() == Some("TOCHeading"));
@@ -342,12 +416,23 @@ fn toc_field_to_heading(body: &mut Blocks) {
if heading.is_none_or(|h| h.objects.iter().any(has_toc)) {
continue;
}
let Some((field, props)) = body[i].as_para().and_then(|p| match p.object_at(0) {
Some(f @ InlineObject::Field { result, .. }) if result.is_empty() && has_toc(f) => Some((f.clone(), p.props_of_char(0).clone())),
_ => None,
let Some((field, props, end)) = body[i].as_para().and_then(|p| {
let (field, end) = match p.object_at(0) {
Some(InlineObject::FieldStart { instr, locked }) if is_toc(instr) => {
(InlineObject::Field { instr: instr.clone(), result: String::new(), locked: *locked, code: false }, Some(toc_range_end(body, i)?))
}
Some(f @ InlineObject::Field { result, .. }) if result.is_empty() && has_toc(f) => (f.clone(), None),
_ => return None,
};
Some((field, p.props_of_char(0).clone(), end))
}) else {
continue;
};
// Delete the end first: the two markers may occupy the same paragraph.
if let Some(Block::Para(p)) = end.and_then(|end| body.get_mut(end)).map(Arc::make_mut) {
let off = p.len().saturating_sub(OBJ.len_utf8());
let _ = p.delete(off, p.len());
}
if let Some(Block::Para(p)) = body.get_mut(i).map(Arc::make_mut) {
let _ = p.delete(0, OBJ.len_utf8());
}
@@ -359,7 +444,32 @@ fn toc_field_to_heading(body: &mut Blocks) {
}
fn is_toc(instr: &str) -> bool {
instr.trim_start().get(..3).is_some_and(|k| k.eq_ignore_ascii_case("TOC"))
instr.split_whitespace().next().is_some_and(|k| k.eq_ignore_ascii_case("TOC"))
}
/// The actual matching end must also be the end the styled writer would choose: at the end
/// of the last contiguous TOC entry, with no section break or trailing content outside it.
fn toc_range_end(body: &Blocks, start: usize) -> Option<usize> {
let entry =
|p: &wordcraft_doc::Paragraph| p.section.is_none() && p.props.style.as_deref().is_some_and(|s| s.starts_with("TOC") && s != "TOCHeading");
let mut depth = 0_usize;
for (i, b) in body.iter().enumerate().skip(start) {
let p = b.as_para().filter(|p| entry(p))?;
for (off, o) in p.object_offsets().into_iter().zip(&p.objects) {
match o {
InlineObject::FieldStart { .. } => depth = depth.checked_add(1)?,
InlineObject::FieldEnd => {
depth = depth.checked_sub(1)?;
if depth == 0 {
let following_entry = body.get(i + 1).and_then(|b| b.as_para()).is_some_and(entry);
return (off.checked_add(OBJ.len_utf8()) == Some(p.len()) && !following_entry).then_some(i);
}
}
_ => {}
}
}
}
None
}
/// Built-in style names Word stores in lower case, and how the UI shows them.
@@ -558,6 +668,7 @@ impl Reader<'_> {
*slot = rgb;
}
}
self.pc.theme_colors = colors.clone();
self.doc.settings.theme_colors = colors;
}
if let Some(fs) = el.child("a:fontScheme") {
@@ -575,6 +686,47 @@ impl Reader<'_> {
self.pc.theme = self.doc.settings.theme_colors.clone();
}
/// `w:settings/w:clrSchemeMapping`: which scheme colour text1, background1… stand for.
fn read_color_mapping(&mut self, path: &str) {
let Ok(Some(root)) = self.xml(path) else { return };
let Some(m) = root.child("w:clrSchemeMapping") else { return };
let names = [
"dark1",
"light1",
"dark2",
"light2",
"accent1",
"accent2",
"accent3",
"accent4",
"accent5",
"accent6",
"hyperlink",
"followedHyperlink",
];
let attrs = [
"w:t1",
"w:bg1",
"w:t2",
"w:bg2",
"w:accent1",
"w:accent2",
"w:accent3",
"w:accent4",
"w:accent5",
"w:accent6",
"w:hyperlink",
"w:followedHyperlink",
];
let mut map = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
for (slot, a) in map.iter_mut().zip(attrs) {
if let Some(i) = m.attr(a).and_then(|v| names.iter().position(|n| *n == v)) {
*slot = i;
}
}
self.pc.color_map = Some(map);
}
fn read_styles(&mut self, path: &str) -> Result<(), DocxError> {
let Some(root) = self.xml(path)? else { return Ok(()) };
// The default paragraph style must be called `Normal` in our model.
@@ -588,6 +740,7 @@ impl Reader<'_> {
self.pc.alias.insert(id.to_string(), "Normal".into());
}
let mut sheet = StyleSheet::empty();
sheet.default_para_present = root.child("w:docDefaults").and_then(|dd| dd.child("w:pPrDefault")).is_some();
if let Some(dd) = root.child("w:docDefaults") {
if let Some(r) = dd.child("w:rPrDefault").and_then(|d| d.child("w:rPr")) {
sheet.default_chr = self.pc.rpr(r);
@@ -639,6 +792,25 @@ impl Reader<'_> {
}
sheet.styles.push(st);
}
// Styles that give only a list level take the list from the style they are based on
// (Heading 3: level 3 of Heading 2's outline list).
let fixed: Vec<Option<wordcraft_doc::props::NumRef>> = sheet
.styles
.iter()
.map(|s| match s.para.numbering {
Some(n) if n.num == props::LEVEL_ONLY => {
let base = s.based_on.as_deref().and_then(|b| props::chain_list(&sheet, b));
base.map(|num| wordcraft_doc::props::NumRef { num, level: n.level })
}
other => other,
})
.collect();
for (s, n) in sheet.styles.iter_mut().zip(fixed) {
s.para.numbering = n;
}
if sheet.default_para.numbering.is_some_and(|n| n.num == props::LEVEL_ONLY) {
sheet.default_para.numbering = None;
}
self.doc.styles = sheet;
Ok(())
}
@@ -651,12 +823,12 @@ impl Reader<'_> {
if let Some(b) = tp.child("w:tblBorders") {
t.borders = Some(props::borders(b));
}
t.fill = tp.child("w:shd").and_then(props::shd_fill);
t.fill = tp.child("w:shd").and_then(|e| self.pc.shd_fill(e));
t.cell_margins = tp.child("w:tblCellMar").map(props::margins);
t.band_size = tp.child_val("w:tblStyleRowBandSize").and_then(u32_of).filter(|n| *n > 0).map(|n| n.min(1000));
}
for c in s.children("w:tblStylePr").take(64) {
let fill = c.child("w:tcPr").and_then(|p| p.child("w:shd")).and_then(props::shd_fill);
let fill = c.child("w:tcPr").and_then(|p| p.child("w:shd")).and_then(|e| self.pc.shd_fill(e));
let cell_borders = c.child("w:tcPr").and_then(|p| p.child("w:tcBorders")).map(props::borders);
let chr = c.child("w:rPr").map(|r| self.pc.rpr(r)).unwrap_or_default();
match c.attr("w:type") {
@@ -809,9 +981,19 @@ impl Reader<'_> {
fn read_settings(&mut self, path: &str) -> Result<(), DocxError> {
let Some(root) = self.xml(path)? else { return Ok(()) };
let rels = self.pkg.rels(path);
let s = &mut self.doc.settings;
for k in root.els() {
match k.name.as_str() {
// §17.15.1.6: the template, by a relationship (usually external) of the settings part.
"w:attachedTemplate" => {
s.attached_template = k
.attr("r:id")
.and_then(|id| rels.by_id(id))
.map(|r| r.target.trim().to_string())
.filter(|t| !t.is_empty() && t.len() <= MAX_TEMPLATE_PATH);
}
"w:linkStyles" => s.link_styles = on_off(k),
"w:compat" => {
// Only Word's own setting: another `w:uri` names a different application's.
let mode = k
@@ -1002,6 +1184,130 @@ impl Reader<'_> {
Ok(())
}
/// Sources from a `b:Sources` custom XML part (other custom XML is left alone); at most
/// [`MAX_SOURCES`] in all, and the document's citation style from `StyleName`.
fn read_bibliography(&mut self, path: &str, seen: &mut BibSeen) -> Result<(), DocxError> {
let Some(root) = self.xml(path)? else { return Ok(()) };
if root.local() != "Sources" {
return Ok(());
}
if self.doc.settings.bib_style.is_none()
&& let Some(name) = root.attrs.iter().find(|(k, _)| k.rsplit(':').next() == Some("StyleName")).map(|(_, v)| v.trim())
&& !name.is_empty()
{
self.doc.settings.bib_style = Some(name.chars().take(MAX_STYLE_NAME).collect());
}
let child_text = |el: &El, local: &str| el.els().find(|e| e.local() == local).map(|e| e.text()).unwrap_or_default();
let parse_persons = |parent: &El| -> String {
let mut names = Vec::new();
let person_els: Vec<&El> = {
let in_lists: Vec<&El> =
parent.els().filter(|e| e.local() == "NameList").flat_map(|n| n.els().filter(|e| e.local() == "Person")).collect();
if in_lists.is_empty() { parent.els().filter(|e| e.local() == "Person").collect() } else { in_lists }
};
for name_el in person_els {
let last = child_text(name_el, "Last");
let first = child_text(name_el, "First");
let middle = child_text(name_el, "Middle");
let name = if !first.is_empty() && !middle.is_empty() {
format!("{last}, {first} {middle}")
} else if !first.is_empty() {
format!("{last}, {first}")
} else {
last
};
if !name.is_empty() {
names.push(name);
}
}
names.join("; ")
};
for src_el in root.els().filter(|e| e.local() == "Source") {
if self.doc.sources.len() >= MAX_SOURCES {
log::warn!("docx: more than {MAX_SOURCES} bibliography sources; the rest are dropped");
break;
}
let tag = child_text(src_el, "Tag");
let raw_kind = child_text(src_el, "SourceType");
let kind = crate::write::from_source_type(&raw_kind);
let (author, editor) = match src_el.els().find(|e| e.local() == "Author") {
Some(a) => {
let auth =
a.els().find(|e| e.local() == "Author").map(parse_persons).filter(|s| !s.is_empty()).unwrap_or_else(|| parse_persons(a));
let ed = a.els().find(|e| e.local() == "Editor").map(parse_persons).unwrap_or_default();
(auth, ed)
}
None => (String::new(), String::new()),
};
let editor = if editor.is_empty() { src_el.els().find(|e| e.local() == "Editor").map(parse_persons).unwrap_or_default() } else { editor };
let title = child_text(src_el, "Title");
let year = child_text(src_el, "Year");
let month = child_text(src_el, "Month");
let day = child_text(src_el, "Day");
let city = child_text(src_el, "City");
let publisher = child_text(src_el, "Publisher");
let journal = child_text(src_el, "JournalName");
let edition = child_text(src_el, "Edition");
let volume = child_text(src_el, "Volume");
let issue = child_text(src_el, "Issue");
let pages = child_text(src_el, "Pages");
let url = child_text(src_el, "URL");
let accessed = match child_text(src_el, "Accessed") {
a if !a.is_empty() => a,
_ => {
let y = child_text(src_el, "YearAccessed");
let m = child_text(src_el, "MonthAccessed");
let d = child_text(src_el, "DayAccessed");
if !y.is_empty() && !m.is_empty() && !d.is_empty() {
format!("{y}-{m}-{d}")
} else if !y.is_empty() && !m.is_empty() {
format!("{y}-{m}")
} else {
y
}
}
};
let doi = match child_text(src_el, "DOI") {
d if !d.is_empty() => d,
_ => child_text(src_el, "StandardNumber"),
};
// Keep sources with some content, once each (by tag, or title and author untagged).
if tag.is_empty() && title.is_empty() && author.is_empty() && editor.is_empty() {
continue;
}
let fresh = if tag.is_empty() {
seen.untagged.insert((title.clone(), if author.is_empty() { editor.clone() } else { author.clone() }))
} else {
seen.tags.insert(tag.clone())
};
if fresh {
self.doc.sources.push(wordcraft_doc::Source {
tag,
kind,
author,
title,
year,
city,
publisher,
journal,
volume,
pages,
url,
editor,
edition,
issue,
month,
day,
accessed,
doi,
});
}
}
Ok(())
}
fn read_core(&mut self, path: &str) {
let Ok(Some(root)) = self.xml(path) else { return };
let c = &mut self.doc.core;
@@ -1024,8 +1330,10 @@ impl Reader<'_> {
}
}
fn default_level(i: usize) -> Level {
Level { indent: 36.0 * (i as f32 + 1.0), text: String::new(), ..Level::default() }
/// A level as the file defines it before its own elements: no indents (a level without `w:ind`
/// leaves the label at the paragraph's left edge, as Word shows it), no text.
fn default_level(_i: usize) -> Level {
Level { indent: 0.0, hanging: 0.0, text: String::new(), ..Level::default() }
}
/// The password hash attributes of `w:documentProtection` that are kept (ECMA-376 §17.15.1.29:
+174 -46
View File
@@ -12,20 +12,70 @@ use wordcraft_doc::section::{
SectionProps, SectionStart,
};
use super::drawing_color::adjust_lum;
use crate::units::{int, measure, on_off, tw, u32_of};
/// A text frame (`w:framePr` without a drop cap, ECMA-376 §17.3.1.11), sizes and offsets in
/// points (clamped), names kept as OOXML writes them.
fn text_frame(k: &El) -> wordcraft_doc::props::TextFrame {
let len = |name: &str| tw(k, name).filter(|v| v.is_finite()).map(|v| v.clamp(-1584.0, 1584.0));
let word = |name: &str, allowed: &[&str], default: &str| k.attr(name).filter(|v| allowed.contains(v)).unwrap_or(default).to_string();
let align = |name: &str, allowed: &[&str]| k.attr(name).filter(|v| allowed.contains(v)).map(str::to_string);
wordcraft_doc::props::TextFrame {
w: len("w:w").filter(|v| *v > 0.0),
h: len("w:h").map(f32::abs).filter(|v| *v > 0.0),
h_exact: k.attr("w:hRule") == Some("exact"),
x: len("w:x").unwrap_or(0.0),
y: len("w:y").unwrap_or(0.0),
x_align: align("w:xAlign", &["left", "center", "right", "inside", "outside"]),
y_align: align("w:yAlign", &["top", "center", "bottom", "inside", "outside"]),
h_anchor: word("w:hAnchor", &["text", "margin", "page"], "page"),
v_anchor: word("w:vAnchor", &["text", "margin", "page"], "page"),
wrap: word("w:wrap", &["around", "auto", "notBeside", "none", "tight", "through"], "auto"),
h_space: len("w:hSpace").unwrap_or(0.0).max(0.0),
v_space: len("w:vSpace").unwrap_or(0.0).max(0.0),
}
}
use crate::xml::El;
/// Context for property reading: theme fonts and style id aliases.
/// Context for property reading: theme fonts and colours, and style id aliases.
#[derive(Default)]
pub struct PropCtx {
pub major_font: String,
pub minor_font: String,
/// The theme's colour scheme (dk1, lt1, dk2, lt2, accent1..6, hlink, folHlink); empty when the
/// package has none, and then `w:themeColor` falls back to the fixed colour.
pub theme_colors: Vec<Rgb>,
/// `w:clrSchemeMapping`: the scheme slot each of text1, background1, text2, background2,
/// accent1..6, hyperlink and followedHyperlink uses. `None` = the default (same order).
pub color_map: Option<[usize; 12]>,
/// Style id renames (e.g. a localized default paragraph style id → `Normal`).
pub alias: HashMap<String, String>,
/// The theme's colours (for WordArt effects in theme colours).
pub theme: Vec<Rgb>,
}
/// A `w:numPr` that gives a level (`w:ilvl`) and no list (`w:numId`): the list is the one the
/// paragraph's style chain uses (ECMA-376 §17.3.1.19). A placeholder while reading, never left in
/// a document (`resolve_list_levels`).
pub(crate) const LEVEL_ONLY: u32 = u32::MAX;
/// The list a level-only `numPr` refers to: the nearest numbering in style `style`'s based-on chain
/// (its own included) that names a list. `None` when no style in the chain has one.
pub(crate) fn chain_list(sheet: &wordcraft_doc::styles::StyleSheet, style: &str) -> Option<u32> {
sheet.chain(style).iter().rev().filter_map(|s| s.para.numbering).map(|n| n.num).find(|n| *n != LEVEL_ONLY)
}
/// Resolve level-only numbering in `p` against its style chain (`style` when it has none).
pub(crate) fn resolve_level_only(sheet: &wordcraft_doc::styles::StyleSheet, p: &mut ParaProps, style: &str) {
if let Some(n) = p.numbering.as_mut().filter(|n| n.num == LEVEL_ONLY) {
match chain_list(sheet, p.style.as_deref().unwrap_or(style)) {
Some(num) => n.num = num,
None => p.numbering = None,
}
}
}
impl PropCtx {
pub fn style_id(&self, id: &str) -> String {
self.alias.get(id).cloned().unwrap_or_else(|| id.to_string())
@@ -36,34 +86,88 @@ impl PropCtx {
(!f.is_empty()).then(|| f.clone())
}
/// The theme colour `e` names in `attr` (`w:themeColor`, `w:themeFill`), through the colour
/// scheme mapping, with its `tint` and `shade` (hex bytes) applied to the HSL luminance as
/// Word does. When given it supersedes the fixed colour; `None` leaves the fixed one.
fn theme_color(&self, e: &El, attr: &str, tint: &str, shade: &str) -> Option<Rgb> {
let logical = |i: usize| self.color_map.and_then(|m| m.get(i).copied()).unwrap_or(i);
let slot = match e.attr(attr)? {
"dark1" => 0,
"light1" => 1,
"dark2" => 2,
"light2" => 3,
"text1" => logical(0),
"background1" => logical(1),
"text2" => logical(2),
"background2" => logical(3),
"accent1" => logical(4),
"accent2" => logical(5),
"accent3" => logical(6),
"accent4" => logical(7),
"accent5" => logical(8),
"accent6" => logical(9),
"hyperlink" => logical(10),
"followedHyperlink" => logical(11),
_ => return None,
};
let mut c = *self.theme_colors.get(slot)?;
let byte = |a: &str| e.attr(a).and_then(|v| u8::from_str_radix(v.trim(), 16).ok()).map(|v| f64::from(v) / 255.0);
if let Some(t) = byte(tint) {
c = adjust_lum(c, |l| l * t + (1.0 - t));
}
if let Some(s) = byte(shade) {
c = adjust_lum(c, |l| l * s);
}
Some(c)
}
/// `w:shd`: its fill colour (a theme fill first), or a solid pattern's colour.
pub fn shd_fill(&self, e: &El) -> Option<Rgb> {
if let Some(c) = self.theme_color(e, "w:themeFill", "w:themeFillTint", "w:themeFillShade") {
return Some(c);
}
// A solid pattern without a fill is painted in its pattern colour, a theme one first.
if e.attr("w:val") == Some("solid")
&& e.attr("w:fill").and_then(Rgb::parse).is_none()
&& let Some(c) = self.theme_color(e, "w:themeColor", "w:themeTint", "w:themeShade")
{
return Some(c);
}
shd_fill(e)
}
pub fn rpr(&self, e: &El) -> CharProps {
let mut c = CharProps::default();
for k in e.els() {
match k.name.as_str() {
"w:rStyle" => c.style = k.attr("w:val").filter(|v| !v.is_empty()).map(|v| self.style_id(v)),
"w:rFonts" => {
c.font = k
.attr("w:ascii")
.or_else(|| k.attr("w:hAnsi"))
.filter(|f| !f.is_empty())
.map(str::to_string)
.or_else(|| k.attr("w:asciiTheme").or_else(|| k.attr("w:hAnsiTheme")).and_then(|t| self.theme_font(t)))
.or_else(|| {
// `w:eastAsia` and `w:cs` name fonts for other scripts; they stand in for the
// Latin font only when the run is hinted as that script. Otherwise the Latin
// font is inherited (a Normal style with only `w:eastAsia`/`w:cs` keeps the
// `docDefaults` font, as in Word).
match k.attr("w:hint") {
Some("eastAsia") => k.attr("w:eastAsia"),
Some("cs") => k.attr("w:cs"),
_ => None,
}
.filter(|f| !f.is_empty())
.map(str::to_string)
});
// A theme font supersedes the fixed font beside it (`w:asciiTheme` over
// `w:ascii`, `w:hAnsiTheme` over `w:hAnsi`).
let named = |a: &str| k.attr(a).filter(|f| !f.is_empty()).map(str::to_string);
let themed = |a: &str| k.attr(a).and_then(|t| self.theme_font(t));
c.font =
themed("w:asciiTheme").or_else(|| named("w:ascii")).or_else(|| themed("w:hAnsiTheme")).or_else(|| named("w:hAnsi")).or_else(
|| {
// `w:eastAsia` and `w:cs` name fonts for other scripts; they stand in for the
// Latin font only when the run is hinted as that script. Otherwise the Latin
// font is inherited (a Normal style with only `w:eastAsia`/`w:cs` keeps the
// `docDefaults` font, as in Word).
match k.attr("w:hint") {
Some("eastAsia") => k.attr("w:eastAsia"),
Some("cs") => k.attr("w:cs"),
_ => None,
}
.filter(|f| !f.is_empty())
.map(str::to_string)
},
);
// The complex-script font (Persian, Arabic, Hebrew text). A `w:cstheme` font is
// left to inherit: the theme's per-script fonts aren't read.
c.font_cs = k.attr("w:cs").filter(|f| !f.is_empty() && f.len() < 256).map(str::to_string);
// The East Asian font (Chinese, Japanese, Korean text). A `w:eastAsiaTheme` font
// is left to inherit, like `w:cstheme`.
c.font_ea = k.attr("w:eastAsia").filter(|f| !f.is_empty() && f.len() < 256).map(str::to_string);
}
"w:b" => c.bold = Some(on_off(k)),
"w:bCs" => c.bold_cs = Some(on_off(k)),
@@ -82,10 +186,11 @@ impl PropCtx {
"w:vanish" => c.hidden = Some(on_off(k)),
"w:rtl" => c.rtl = Some(on_off(k)),
"w:color" => {
c.color = match k.attr("w:val") {
Some(v) if v.eq_ignore_ascii_case("auto") => Some(TextColor::Auto),
Some(v) => Rgb::parse(v).map(TextColor::Rgb),
None => None,
c.color = match (self.theme_color(k, "w:themeColor", "w:themeTint", "w:themeShade"), k.attr("w:val")) {
(Some(t), _) => Some(TextColor::Rgb(t)),
(None, Some(v)) if v.eq_ignore_ascii_case("auto") => Some(TextColor::Auto),
(None, Some(v)) => Rgb::parse(v).map(TextColor::Rgb),
(None, None) => None,
}
}
"w:spacing" => c.spacing = tw(k, "w:val").map(|v| v.clamp(-1584.0, 1584.0)),
@@ -101,10 +206,11 @@ impl PropCtx {
} else {
c.underline = Some(Underline::Single);
}
c.underline_color = k.attr("w:color").and_then(Rgb::parse);
c.underline_color =
self.theme_color(k, "w:themeColor", "w:themeTint", "w:themeShade").or_else(|| k.attr("w:color").and_then(Rgb::parse));
}
"w:bdr" => c.border = Some(border(k)),
"w:shd" => c.shading = shd_fill(k),
"w:shd" => c.shading = self.shd_fill(k),
"w:vertAlign" => {
c.vert_align = k.attr("w:val").map(|v| match v {
"superscript" => VertAlign::Superscript,
@@ -126,22 +232,12 @@ impl PropCtx {
_ => {}
}
}
// A complex-script value equal to its plain one is the same as none (see
// `CharProps::overlay`), which keeps documents round-tripping exactly.
if c.size_cs.is_some() && c.size_cs == c.size {
c.size_cs = None;
}
if c.bold_cs.is_some() && c.bold_cs == c.bold {
c.bold_cs = None;
}
if c.italic_cs.is_some() && c.italic_cs == c.italic {
c.italic_cs = None;
}
c
}
/// `w:pPr` (without `w:sectPr`; `w:rPr` is the paragraph mark, returned separately).
pub fn ppr(&self, e: &El) -> (ParaProps, CharProps) {
// (`w:numPr` with a level and no list sets `LEVEL_ONLY`, resolved by the caller.)
let mut p = ParaProps::default();
let mut mark = CharProps::default();
for k in e.els() {
@@ -152,6 +248,7 @@ impl PropCtx {
"w:pageBreakBefore" => p.page_break_before = Some(on_off(k)),
"w:widowControl" => p.widow_control = Some(on_off(k)),
"w:contextualSpacing" => p.contextual_spacing = Some(on_off(k)),
"w:mirrorIndents" => p.mirror_indents = Some(on_off(k)),
"w:suppressAutoHyphens" => p.suppress_hyphens = Some(on_off(k)),
"w:suppressLineNumbers" => p.suppress_line_numbers = Some(on_off(k)),
"w:bidi" => p.bidi = Some(on_off(k)),
@@ -165,17 +262,25 @@ impl PropCtx {
"w:framePr" => {
if matches!(k.attr("w:dropCap"), Some("drop") | Some("margin")) {
p.drop_cap = Some(k.attr("w:lines").and_then(u32_of).unwrap_or(3).clamp(1, 10) as u8);
} else {
p.frame = Some(text_frame(k));
}
}
"w:numPr" => {
let num = k.child_val("w:numId").and_then(u32_of);
let level = k.child_val("w:ilvl").and_then(u32_of).unwrap_or(0).min(8) as u8;
if let Some(num) = num {
p.numbering = Some(NumRef { num, level });
let ilvl = k.child_val("w:ilvl").and_then(u32_of);
let level = ilvl.unwrap_or(0).min(8) as u8;
match (num, ilvl) {
(Some(num), _) => p.numbering = Some(NumRef { num, level }),
// A level without a list: the list comes from the based-on style, as in
// a Heading 3 that only says "level 3" of Heading 2's list. Resolved once
// the styles are read (`resolve_list_levels`).
(None, Some(_)) => p.numbering = Some(NumRef { num: LEVEL_ONLY, level }),
(None, None) => {}
}
}
"w:pBdr" => p.borders = Some(borders(k)),
"w:shd" => p.shading = shd_fill(k),
"w:shd" => p.shading = self.shd_fill(k),
"w:tabs" => {
let tabs: Vec<TabStop> = k.children("w:tab").take(64).filter_map(tab_stop).collect();
p.tabs = Some(tabs);
@@ -187,6 +292,14 @@ impl PropCtx {
if let Some(v) = tw(k, "w:after") {
p.space_after = Some(v.max(0.0));
}
// HTML-style automatic spacing; "0" switches an inherited one off.
let auto = |name| k.attr(name).map(|v| matches!(v.trim(), "1" | "true" | "on"));
if let Some(a) = auto("w:beforeAutospacing") {
p.space_before_auto = Some(a);
}
if let Some(a) = auto("w:afterAutospacing") {
p.space_after_auto = Some(a);
}
if let Some(line) = k.attr("w:line") {
// Word reads the rule case-insensitively (`atleast` is seen in the wild).
let rule = k.attr("w:lineRule").unwrap_or("auto").to_ascii_lowercase();
@@ -209,6 +322,19 @@ impl PropCtx {
} else if let Some(v) = tw(k, "w:firstLine") {
p.indent_first = Some(v);
}
// Character units (hundredths of a character), which win when non-zero.
let chars = |a: &str| k.attr(a).and_then(int).map(|v| v.clamp(-158_400, 158_400) as f32 / 100.0);
if let Some(v) = chars("w:leftChars").or_else(|| chars("w:startChars")) {
p.indent_left_chars = Some(v);
}
if let Some(v) = chars("w:rightChars").or_else(|| chars("w:endChars")) {
p.indent_right_chars = Some(v);
}
match (chars("w:hangingChars"), chars("w:firstLineChars")) {
(Some(h), f) if h != 0.0 || f.is_none() => p.indent_first_chars = Some(-h),
(_, Some(f)) => p.indent_first_chars = Some(f),
_ => {}
}
}
"w:jc" => p.align = k.attr("w:val").map(align),
"w:outlineLvl" => p.outline_level = k.attr("w:val").and_then(u32_of).map(|v| v.min(9) as u8),
@@ -250,8 +376,8 @@ fn tab_stop(t: &El) -> Option<TabStop> {
Some(TabStop { pos, align, leader })
}
/// `w:shd/@w:fill` (a solid fill colour; `auto` = none).
pub fn shd_fill(e: &El) -> Option<Rgb> {
/// `w:shd/@w:fill` (a solid fill colour; `auto` = none), ignoring theme fills ([`PropCtx::shd_fill`]).
fn shd_fill(e: &El) -> Option<Rgb> {
let fill = e.attr("w:fill").and_then(Rgb::parse);
if fill.is_some() {
return fill;
@@ -316,7 +442,7 @@ impl PropCtx {
"w:jc" => t.align = k.attr("w:val").map(align),
"w:tblInd" => t.indent = tw(k, "w:w"),
"w:tblBorders" => t.borders = Some(borders(k)),
"w:shd" => t.shading = shd_fill(k),
"w:shd" => t.shading = self.shd_fill(k),
"w:tblLayout" => t.fixed = k.attr("w:type") == Some("fixed"),
"w:bidiVisual" => t.bidi_visual = on_off(k),
"w:tblCellMar" => t.cell_margins = Some(margins(k)),
@@ -391,7 +517,7 @@ pub fn trpr(e: &El) -> RowProps {
}
/// `w:tcPr`; returns the props and whether the cell is a legacy `hMerge` continuation.
pub fn tcpr(e: &El) -> (CellProps, bool) {
pub fn tcpr(pc: &PropCtx, e: &El) -> (CellProps, bool) {
let mut c = CellProps { span: 1, ..Default::default() };
let mut hcont = false;
for k in e.els() {
@@ -410,8 +536,9 @@ pub fn tcpr(e: &El) -> (CellProps, bool) {
"w:hMerge" => hcont = k.attr("w:val") != Some("restart"),
"w:vMerge" => c.vmerge = if k.attr("w:val") == Some("restart") { VMerge::Restart } else { VMerge::Continue },
"w:tcBorders" => c.borders = Some(borders(k)),
"w:shd" => c.shading = shd_fill(k),
"w:shd" => c.shading = pc.shd_fill(k),
"w:noWrap" => c.no_wrap = on_off(k),
"w:hideMark" => c.hide_mark = on_off(k),
"w:tcMar" => c.margins = Some(margins(k)),
"w:textDirection" => c.text_direction = TextDirection::from_ooxml(k.attr("w:val").unwrap_or("")),
"w:vAlign" => {
@@ -504,6 +631,7 @@ pub fn sectpr(e: &El) -> (SectionProps, Vec<HfRef>) {
"w:endnotePr" => s.endnote_pr = note_pr(k, true),
"w:titlePg" => s.title_page = on_off(k),
"w:bidi" => s.rtl = on_off(k),
"w:textDirection" => s.text_direction = wordcraft_doc::props::TextDirection::from_ooxml(k.attr("w:val").unwrap_or("")),
"w:pgNumType" => {
s.page_num_start = k.attr("w:start").and_then(u32_of);
if let Some(f) = k.attr("w:fmt") {
+177
View File
@@ -0,0 +1,177 @@
//! How a Word shape (`wps:wsp`) looks: its preset geometry, and the fill and outline it gets from its own properties (`wps:spPr`) or, failing those, from the theme through its
//! style references (`wps:style`: `a:fillRef`, `a:lnRef`, ECMA-376 Part 1 §20.1.4.2).
use wordcraft_doc::para::ShapeKind;
use wordcraft_doc::props::Rgb;
use super::drawing_color::{color, fill_colour};
use crate::units::{int, measure};
use crate::xml::El;
/// Outline widths of the theme's three line styles (`a:lnStyleLst`), points: what Word's
/// default themes give (6 350, 12 700 and 19 050 EMU).
const THEME_LINE_WIDTHS: [f32; 3] = [0.5, 1.0, 1.5];
/// The shape kind drawn for DrawingML preset geometry `prst` (ECMA-376 Part 1 §20.1.10.56).
/// Presets WordCraft has no shape for are drawn as the nearest one it has: callouts as the box,
/// round or cloud they point from, flowchart boxes as rectangles.
pub(crate) fn preset_kind(prst: &str) -> ShapeKind {
match prst {
"roundRect"
| "round1Rect"
| "round2SameRect"
| "round2DiagRect"
| "snipRoundRect"
| "wedgeRoundRectCallout"
| "flowChartAlternateProcess"
| "flowChartTerminator"
| "plaque" => ShapeKind::RoundedRectangle,
"ellipse" | "wedgeEllipseCallout" | "cloudCallout" | "cloud" | "flowChartConnector" | "donut" | "flowChartOnlineStorage" => {
ShapeKind::Ellipse
}
"triangle" | "rtTriangle" | "flowChartExtract" | "flowChartMerge" => ShapeKind::Triangle,
"diamond" | "flowChartDecision" => ShapeKind::Diamond,
"line" | "lineInv" => ShapeKind::Line,
"straightConnector1" => ShapeKind::StraightConnector,
"bentConnector2" | "bentConnector3" | "bentConnector4" | "bentConnector5" => ShapeKind::ElbowConnector,
"curvedConnector2" | "curvedConnector3" | "curvedConnector4" | "curvedConnector5" => ShapeKind::CurvedConnector,
"rightArrow" | "leftArrow" | "upArrow" | "downArrow" | "leftRightArrow" | "upDownArrow" | "notchedRightArrow" | "stripedRightArrow" => {
ShapeKind::Arrow
}
"star4" | "star5" | "star6" | "star7" | "star8" | "star10" | "star12" | "star16" | "star24" | "star32" => ShapeKind::Star,
"heart" => ShapeKind::Heart,
_ => ShapeKind::Rectangle,
}
}
/// A shape's fill and outline.
#[derive(Debug, Default, PartialEq)]
pub(crate) struct ShapePaint {
pub fill: Option<Rgb>,
pub stroke: Option<Rgb>,
/// Points.
pub stroke_width: f32,
}
/// The paint of a shape from its properties `sppr`, falling back on its style `style` (theme
/// colours `theme`) for what they leave out: an explicit `a:noFill` is no fill or outline, an
/// explicit colour wins, and a style reference with index 0 is none.
pub(crate) fn shape_paint(sppr: Option<&El>, style: Option<&El>, theme: &[Rgb]) -> ShapePaint {
let style_ref = |name: &str| -> Option<(i64, &El)> {
let r = style?.child(name)?;
Some((r.attr("idx").and_then(int).unwrap_or(0), r))
};
let fill = match sppr {
Some(s) if s.child("a:noFill").is_some() || s.child("a:blipFill").is_some() => None,
Some(s) if s.els().any(|c| matches!(c.name.as_str(), "a:solidFill" | "a:gradFill" | "a:pattFill")) => fill_colour(s, theme),
_ => style_ref("a:fillRef").filter(|(idx, _)| *idx > 0).and_then(|(_, r)| color(r, theme)),
};
let ln = sppr.and_then(|s| s.child("a:ln"));
let line_ref = style_ref("a:lnRef").filter(|(idx, _)| *idx > 0);
let stroke = match ln {
Some(l) if l.child("a:noFill").is_some() => None,
Some(l) if l.els().any(|c| matches!(c.name.as_str(), "a:solidFill" | "a:gradFill" | "a:pattFill")) => fill_colour(l, theme),
_ => line_ref.and_then(|(_, r)| color(r, theme)),
};
let themed_width = line_ref.and_then(|(idx, _)| THEME_LINE_WIDTHS.get(usize::try_from(idx.clamp(1, 3) - 1).unwrap_or(0)).copied());
let stroke_width = ln
.and_then(|l| l.attr("w"))
.and_then(|v| measure(v, 12_700.0))
.or(themed_width)
// Word's default outline when the file names none (no outline: no width).
.unwrap_or(if stroke.is_some() { 0.75 } else { 0.0 })
.clamp(0.0, 100.0);
ShapePaint { fill, stroke, stroke_width }
}
/// A VML colour (`strokecolor`, `fillcolor`): `#rrggbb` or `#rgb` (Word follows it with the
/// theme slot, as in `#4472c4 [3204]`), or a colour name.
pub(crate) fn vml_color(v: &str) -> Option<Rgb> {
let v = v.split_whitespace().next()?;
if let Some(hex) = v.strip_prefix('#') {
if hex.len() == 3 && hex.is_ascii() {
let long: String = hex.chars().flat_map(|c| [c, c]).collect();
return Rgb::parse(&long);
}
return Rgb::parse(hex);
}
super::drawing_color::preset_color(v)
}
#[cfg(test)]
mod tests {
use super::*;
use wordcraft_doc::THEME_COLORS;
fn el(xml: &str) -> El {
let ns = r#"xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main""#;
crate::xml::parse(xml.replace(r#"xmlns:wps="w" xmlns:a="a""#, ns).as_bytes()).unwrap()
}
#[test]
fn theme_styled_connector_gets_its_line_from_the_style() {
// A connector with no colour of its own (what Word writes for Insert › Shapes › Line):
// its outline comes from `a:lnRef`, its width from the theme's first line style.
let sp = el(r#"<wps:spPr xmlns:wps="w" xmlns:a="a"><a:prstGeom prst="line"/><a:ln><a:tailEnd type="triangle"/></a:ln></wps:spPr>"#);
let st = el(
r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="1"><a:schemeClr val="accent4"/></a:lnRef><a:fillRef idx="0"><a:schemeClr val="accent4"/></a:fillRef></wps:style>"#,
);
let p = shape_paint(Some(&sp), Some(&st), &THEME_COLORS);
assert_eq!(p.stroke, Some(THEME_COLORS[7]));
assert_eq!(p.stroke_width, 0.5);
assert_eq!(p.fill, None);
}
#[test]
fn explicit_paint_wins_over_the_style() {
let sp = el(r#"<wps:spPr xmlns:wps="w" xmlns:a="a"><a:noFill/><a:ln><a:solidFill><a:srgbClr val="EE0000"/></a:solidFill></a:ln></wps:spPr>"#);
let st = el(
r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="2"><a:schemeClr val="accent1"><a:shade val="15000"/></a:schemeClr></a:lnRef><a:fillRef idx="1"><a:schemeClr val="accent1"/></a:fillRef></wps:style>"#,
);
let p = shape_paint(Some(&sp), Some(&st), &THEME_COLORS);
assert_eq!((p.fill, p.stroke, p.stroke_width), (None, Some(Rgb(0xEE, 0, 0)), 1.0));
// Preset and scheme colours as the box's own fill and outline.
let sp = el(
r#"<wps:spPr xmlns:wps="w" xmlns:a="a"><a:solidFill><a:schemeClr val="lt1"/></a:solidFill><a:ln w="6350"><a:solidFill><a:prstClr val="black"/></a:solidFill></a:ln></wps:spPr>"#,
);
let p = shape_paint(Some(&sp), None, &THEME_COLORS);
assert_eq!((p.fill, p.stroke, p.stroke_width), (Some(THEME_COLORS[1]), Some(Rgb(0, 0, 0)), 0.5));
// A style fill with index 0 is none; an outline set to no fill is none.
let sp = el(r#"<wps:spPr xmlns:wps="w" xmlns:a="a"><a:ln><a:noFill/></a:ln></wps:spPr>"#);
let st = el(
r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="2"><a:schemeClr val="accent1"/></a:lnRef><a:fillRef idx="0"><a:schemeClr val="accent1"/></a:fillRef></wps:style>"#,
);
let p = shape_paint(Some(&sp), Some(&st), &THEME_COLORS);
assert_eq!((p.fill, p.stroke), (None, None));
}
#[test]
fn hostile_style_references_do_not_panic() {
let st = el(
r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="-99999999999"><a:schemeClr val="nope"/></a:lnRef><a:fillRef idx="99999999999"/></wps:style>"#,
);
let p = shape_paint(None, Some(&st), &[]);
assert_eq!((p.fill, p.stroke), (None, None));
let st = el(r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="77"><a:srgbClr val="010203"/></a:lnRef></wps:style>"#);
assert_eq!(shape_paint(None, Some(&st), &[]).stroke_width, 1.5);
}
#[test]
fn presets_map_to_the_nearest_shape() {
assert_eq!(preset_kind("straightConnector1"), ShapeKind::StraightConnector);
assert_eq!(preset_kind("bentConnector3"), ShapeKind::ElbowConnector);
assert_eq!(preset_kind("wedgeRoundRectCallout"), ShapeKind::RoundedRectangle);
assert_eq!(preset_kind("wedgeEllipseCallout"), ShapeKind::Ellipse);
assert_eq!(preset_kind("wedgeRectCallout"), ShapeKind::Rectangle);
assert_eq!(preset_kind("flowChartDecision"), ShapeKind::Diamond);
}
#[test]
fn vml_colours() {
assert_eq!(vml_color("#ffc000 [3207]"), Some(Rgb(0xFF, 0xC0, 0)));
assert_eq!(vml_color("#f00"), Some(Rgb(0xFF, 0, 0)));
assert_eq!(vml_color("black"), Some(Rgb(0, 0, 0)));
assert_eq!(vml_color("#é"), None);
assert_eq!(vml_color(""), None);
}
}
+206 -62
View File
@@ -13,11 +13,15 @@ use wordcraft_doc::{Block, Blocks, InlineObject, Paragraph, PartKind, RevisionKi
use super::Reader;
use super::props::{sectpr, tcpr, trpr};
use crate::package::{Rels, rt};
use super::shape_style::{preset_kind, shape_paint, vml_color};
use crate::package::{ContentTypes, Rels, rt};
use crate::units::{int, measure, u32_of};
use crate::xml::El;
use crate::xml::{El, Node};
use wordcraft_doc::wordart::TextWarp;
const INK_URI: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingInk";
const INK_REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml";
/// Deepest table nesting we build (deeper tables become plain paragraphs).
const MAX_TABLE_DEPTH: usize = 24;
/// Deepest field nesting we track.
@@ -41,9 +45,11 @@ enum Phase {
Result,
}
/// A buffered piece of a field's result. Objects are boxed: an inline object is much larger than
/// a string.
enum Item {
Text(String),
Obj(InlineObject),
Obj(Box<InlineObject>),
}
struct FieldState {
@@ -80,9 +86,19 @@ fn enclosure(f: &FieldState) -> Option<wordcraft_doc::eq::Enclosure> {
}
/// Fields kept as ranges: those whose result another program writes, with formatting and often
/// several paragraphs (citation managers' `ADDIN` fields: Zotero, Mendeley, EndNote).
/// several paragraphs (TOCs and citation managers' `ADDIN` fields).
fn is_range_instr(instr: &str) -> bool {
instr.trim_start().get(..5).is_some_and(|k| k.eq_ignore_ascii_case("ADDIN"))
super::is_toc(instr) || instr.trim_start().get(..5).is_some_and(|k| k.eq_ignore_ascii_case("ADDIN"))
}
/// The last paragraph of a block list: its last block, or inside a final table the last
/// paragraph of its last cell (nested tables to [`MAX_TABLE_DEPTH`]).
fn last_para_mut(out: &mut Blocks, depth: usize) -> Option<&mut Paragraph> {
match Arc::make_mut(out.last_mut()?) {
Block::Para(p) => Some(p),
Block::Table(t) if depth < MAX_TABLE_DEPTH => last_para_mut(&mut t.rows.last_mut()?.cells.last_mut()?.blocks, depth + 1),
Block::Table(_) => None,
}
}
/// The field that buffers content now: the innermost one that isn't a range field, unless it
@@ -150,7 +166,7 @@ fn clean_text(s: &str) -> String {
fn children_of_choice(ac: &El) -> Option<&El> {
// Prefer a Choice whose requirements we understand; else the Fallback; else any Choice.
const KNOWN: &[&str] = &["wps", "wpg", "wpc", "w14", "w15", "wp14", "a14", "w16se", "w16cid", "w16", "w16cex", "w16sdtdh", "v"];
const KNOWN: &[&str] = &["wps", "wpg", "wpc", "wpi", "w14", "w15", "wp14", "a14", "w16se", "w16cid", "w16", "w16cex", "w16sdtdh", "v"];
let ok = |c: &El| c.attr("Requires").unwrap_or("").split_whitespace().all(|r| KNOWN.contains(&r));
ac.children("mc:Choice").find(|c| ok(c)).or_else(|| ac.child("mc:Fallback")).or_else(|| ac.child("mc:Choice"))
}
@@ -250,12 +266,13 @@ impl Reader<'_> {
out.extend(inner);
}
/// Markers left over at the end of a story go to its last paragraph.
/// Markers left over at the end of a story go to its last paragraph (after a final table:
/// the last paragraph of its last cell, so a bookmark around the table keeps its end).
pub fn flush_pending(&mut self, sc: &mut StoryCtx, out: &mut Blocks) {
if sc.pending.is_empty() {
return;
}
if let Some(Block::Para(p)) = out.last_mut().map(Arc::make_mut) {
if let Some(p) = last_para_mut(out, 0) {
for o in sc.pending.drain(..) {
let props = p.runs.last().map(|r| r.props.clone()).unwrap_or_default();
p.text.push(wordcraft_doc::para::OBJ);
@@ -317,7 +334,8 @@ impl Reader<'_> {
pb.push_obj(o, &CharProps::default());
}
if let Some(ppr) = e.child("w:pPr") {
let (props, mark) = self.pc.ppr(ppr);
let (mut props, mark) = self.pc.ppr(ppr);
super::props::resolve_level_only(&self.doc.styles, &mut props, "Normal");
para.props = props;
para.mark = mark;
// A tracked paragraph mark (ECMA-376 §17.13.5.16 / §17.13.5.15): `w:ins` / `w:del`
@@ -339,7 +357,14 @@ impl Reader<'_> {
// Tracked formatting changes (§17.13.5.29, §17.13.5.19): the paragraph properties
// before the change, and a change of the list numbering.
if let Some(ch) = ppr.child("w:pPrChange") {
let old = ch.child("w:pPr").map(|o| self.pc.ppr(o).0.formatting()).unwrap_or_default();
let old = ch
.child("w:pPr")
.map(|o| {
let mut old = self.pc.ppr(o).0;
super::props::resolve_level_only(&self.doc.styles, &mut old, "Normal");
old.formatting()
})
.unwrap_or_default();
para.props.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old);
}
if para.props.numbering.is_some()
@@ -486,7 +511,10 @@ impl Reader<'_> {
}
}
"mc:AlternateContent" => {
if let Some(c) = children_of_choice(k) {
if let Some(o) = self.read_ink_alternate(sc, k, rels) {
let props = self.run_props_none(ctx);
self.emit_obj(sc, pb, o, &props);
} else if let Some(c) = children_of_choice(k) {
self.read_inline_children(sc, pb, c, rels, ctx, depth + 1);
}
}
@@ -647,7 +675,9 @@ impl Reader<'_> {
}
"w:ruby" => self.read_ruby(sc, pb, k, props),
"mc:AlternateContent" => {
if let Some(c) = children_of_choice(k) {
if let Some(o) = self.read_ink_alternate(sc, k, rels) {
self.emit_obj(sc, pb, o, props);
} else if let Some(c) = children_of_choice(k) {
for x in c.els() {
self.read_run_child(sc, pb, x, props, rels, note, depth + 1);
}
@@ -768,6 +798,20 @@ impl Reader<'_> {
self.emit_obj(sc, pb, InlineObject::FieldEnd, &f.props);
return;
}
// A cached result holding a picture, chart or shape (`INCLUDEPICTURE`'s embedded
// picture) can't be a text result: keep the field as a range around it so the picture
// stays in the document and is saved again. The linked source is never read.
if !nested_in_instr && f.buf.iter().any(|(it, _)| matches!(it, Item::Obj(o) if o.frame().is_some())) {
self.emit_obj(sc, pb, InlineObject::FieldStart { instr: f.instr.trim().to_string(), locked: f.locked }, &f.props);
for (it, p) in f.buf {
match it {
Item::Text(t) => self.emit_text(sc, pb, &t, &p),
Item::Obj(o) => self.emit_obj(sc, pb, *o, &p),
}
}
self.emit_obj(sc, pb, InlineObject::FieldEnd, &f.props);
return;
}
let mut result = String::new();
for (it, _) in &f.buf {
match it {
@@ -803,7 +847,7 @@ impl Reader<'_> {
for (it, p) in f.buf.drain(..) {
match it {
Item::Text(t) => pb.push_text(&t, &p),
Item::Obj(o) => pb.push_obj(o, &p),
Item::Obj(o) => pb.push_obj(*o, &p),
}
}
}
@@ -824,7 +868,7 @@ impl Reader<'_> {
match sink(sc) {
Some(f) => {
if f.phase == Phase::Result && f.buf.len() < 100_000 {
f.buf.push((Item::Obj(o), props.clone()));
f.buf.push((Item::Obj(Box::new(o)), props.clone()));
}
}
_ => pb.push_obj(o, props),
@@ -853,6 +897,25 @@ impl Reader<'_> {
let gd = c.child("a:graphic").and_then(|g| g.child("a:graphicData"))?;
// Checked by URI before the blip search: a diagram's data can hold a blip further down.
let uri = gd.attr("uri").unwrap_or("");
if uri == INK_URI {
let content = gd.child("w14:contentPart")?;
let part = super::part_of(rels, content.attr("r:id")?, INK_REL)?;
let pkg = self.pkg;
if self.content_types.get_or_insert_with(|| ContentTypes::read(pkg)).of(&part) != Some("application/inkml+xml") {
return None;
}
let source = self.keep_embedded(c.child("a:graphic")?, rels, &[])?;
float.set_spin(xfrm_spin(content.child("w14:xfrm")));
let (px, py) = float.spin_pad(w, h);
for (e, p) in float.effect.iter_mut().zip([px, py, px, py]) {
*e = (*e - p).clamp(0.0, 1584.0);
}
// The renderer's existing missing-picture box marks ink without a usable preview.
// InkML stays opaque: no approximation of its strokes or conversion to freeforms.
let items = vec![GraphicItem::Image { rect: [0.0, 0.0, w, h], media: String::new() }];
let graphic = Arc::new(Graphic { kind: GraphicKind::Ink, items, w, h, source: Some(source), ..Default::default() });
return Some(InlineObject::Graphic { w, h, alt, float, graphic });
}
let graphic_kind = if uri.ends_with("/chart") {
Some(GraphicKind::Chart)
} else if uri.ends_with("/diagram") {
@@ -864,7 +927,7 @@ impl Reader<'_> {
// Kept so saving writes the chart or diagram back: the frame around it is written
// from the object's (possibly moved or resized) size and position.
let extra: Vec<String> = match kind {
GraphicKind::Chart => Vec::new(),
GraphicKind::Chart | GraphicKind::Ink => Vec::new(),
GraphicKind::Diagram => self.diagram_drawing_rel(gd, rels).into_iter().collect(),
};
let source = c.child("a:graphic").and_then(|g| self.keep_embedded(g, rels, &extra));
@@ -881,17 +944,14 @@ impl Reader<'_> {
} else {
let wsp = gd.find("wps:wsp")?;
let mut obj = self.read_wsp(sc, wsp, rels, w, h, float);
// A freeform: its custom geometry, and whether it's ink WordCraft wrote.
if let InlineObject::Shape { kind, freeform, .. } = &mut obj
&& let Some(sppr) = wsp.child("wps:spPr")
&& let Some(mut f) = super::freeform::cust_geom(sppr, w, h)
// A freeform: whether it's ink WordCraft wrote.
if let InlineObject::Shape { freeform: Some(f), .. } = &mut obj
&& let Some(name) = c.child("wp:docPr").and_then(|p| p.attr("name"))
&& let Some(tool) = super::freeform::ink_tool(name)
{
f.alpha = super::freeform::line_alpha(sppr.child("a:ln"));
let name = c.child("wp:docPr").and_then(|p| p.attr("name"));
f.ink = name.and_then(super::freeform::ink_tool);
f.order = name.filter(|_| f.ink.is_some()).and_then(super::freeform::ink_order);
*kind = ShapeKind::Freeform;
*freeform = Some(Arc::new(f));
let f = Arc::make_mut(f);
f.ink = Some(tool);
f.order = super::freeform::ink_order(name);
}
obj
};
@@ -910,6 +970,34 @@ impl Reader<'_> {
Some(obj)
}
/// Keep the selected native-ink branch and its picture fallback as one object. Reading only
/// the fallback flattens the ink; reading only the choice loses its preview on the next save.
fn read_ink_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
let choice = children_of_choice(ac).filter(|e| e.name == "mc:Choice")?;
let drawing = choice.child("w:drawing")?;
if drawing.find("a:graphicData")?.attr("uri") != Some(INK_URI) {
return None;
}
let fallback = ac.child("mc:Fallback");
let preview = fallback.and_then(|f| f.child("w:pict")).and_then(|p| self.read_vml(sc, p, rels));
let Some(mut object) = self.read_drawing(sc, drawing, rels) else { return preview };
if let InlineObject::Graphic { w, h, graphic, .. } = &mut object {
let graphic = Arc::make_mut(graphic);
if let Some(InlineObject::Image { media, .. }) = preview {
graphic.items = vec![GraphicItem::Image { rect: [0.0, 0.0, *w, *h], media }];
}
if let Some(fallback) = fallback {
let kept =
El { name: "mc:AlternateContent".into(), attrs: Vec::new(), kids: vec![Node::El(choice.clone()), Node::El(fallback.clone())] };
if let Some(source) = self.keep_embedded(&kept, rels, &[]) {
graphic.source = Some(source);
}
// A malformed or over-budget fallback must not discard a valid native part.
}
}
Some(object)
}
/// The chart or SmartArt diagram in an `a:graphicData`, as items in points from its top-left
/// corner. Charts are drawn from their cached data, SmartArt diagrams from the drawing Word
/// stores beside them. Built once per part and size; once the file's graphic budget is spent,
@@ -918,22 +1006,27 @@ impl Reader<'_> {
let part = match kind {
GraphicKind::Chart => gd.child("c:chart").and_then(|c| c.attr("r:id")).and_then(|id| super::part_of(rels, id, rt::CHART)),
GraphicKind::Diagram => self.diagram_part(gd, rels),
GraphicKind::Ink => None,
};
let Some(path) = part else { return Arc::new(Graphic { kind, items: Vec::new(), w, h, source, chart: None }) };
let Some(path) = part else { return Arc::new(Graphic { kind, items: Vec::new(), w, h, source, chart: None, smart_art: None }) };
let key = (kind, path, w.to_bits(), h.to_bits());
if let Some(g) = self.graphics.get(&key) {
return g.clone();
}
let items = if self.graphic_budget == 0 { Vec::new() } else { self.graphic_items(kind, &key.1, w, h) };
self.graphic_budget = self.graphic_budget.saturating_sub(graphic_work(&items));
// A chart that is exactly what WordCraft writes for its model can be edited (and is
// written from the model); any other stays as it was read.
// A chart or SmartArt that is exactly what WordCraft writes for its model can be edited
// (and is written from the model); any other stays as it was read.
let chart = match kind {
GraphicKind::Chart if !items.is_empty() => self.graphic_part(&key.1).and_then(|space| crate::chart_spec::spec_of(&space)).map(Arc::new),
_ => None,
};
let source = if chart.is_some() { None } else { source };
let g = Arc::new(Graphic { kind, items, w, h, source, chart });
let smart_art = match kind {
GraphicKind::Diagram if !items.is_empty() => self.smart_art_spec(gd, rels).map(Arc::new),
_ => None,
};
let source = if chart.is_some() || smart_art.is_some() { None } else { source };
let g = Arc::new(Graphic { kind, items, w, h, source, chart, smart_art });
self.graphics.insert(key, g.clone());
g
}
@@ -946,6 +1039,7 @@ impl Reader<'_> {
None => Vec::new(),
},
GraphicKind::Diagram => self.diagram_drawing(path, w, h),
GraphicKind::Ink => Vec::new(),
}
}
@@ -1079,31 +1173,16 @@ impl Reader<'_> {
float.set_spin(xfrm_spin(sppr));
let prst = sppr.and_then(|s| s.child("a:prstGeom")).and_then(|g| g.attr("prst")).unwrap_or("rect");
let txbx = wsp.child("wps:txbx").and_then(|t| t.child("w:txbxContent"));
let mut kind = match prst {
"roundRect" => ShapeKind::RoundedRectangle,
"ellipse" => ShapeKind::Ellipse,
"triangle" | "rtTriangle" => ShapeKind::Triangle,
"diamond" => ShapeKind::Diamond,
"line" => ShapeKind::Line,
"straightConnector1" => ShapeKind::StraightConnector,
"bentConnector2" | "bentConnector3" | "bentConnector4" | "bentConnector5" => ShapeKind::ElbowConnector,
"curvedConnector2" | "curvedConnector3" | "curvedConnector4" | "curvedConnector5" => ShapeKind::CurvedConnector,
"rightArrow" | "leftArrow" | "upArrow" | "downArrow" => ShapeKind::Arrow,
"star5" | "star4" | "star6" => ShapeKind::Star,
"heart" => ShapeKind::Heart,
_ => ShapeKind::Rectangle,
};
let mut kind = preset_kind(prst);
let is_tb = wsp.child("wps:cNvSpPr").and_then(|c| c.attr("txBox")).is_some_and(|v| v == "1" || v == "true");
if is_tb || (txbx.is_some() && kind == ShapeKind::Rectangle) {
kind = ShapeKind::TextBox;
}
let solid = |e: Option<&El>| {
e.and_then(|f| f.child("a:solidFill")).and_then(|f| f.child("a:srgbClr")).and_then(|c| c.attr("val")).and_then(Rgb::parse)
};
let fill = solid(sppr);
// Explicit paint, else the theme's through the style references (#611).
let paint = shape_paint(sppr, wsp.child("wps:style"), &self.doc.settings.theme_colors);
let (stroke, stroke_width) = (paint.stroke, paint.stroke_width);
let fill = if kind.is_open() { None } else { paint.fill };
let ln = sppr.and_then(|s| s.child("a:ln"));
let stroke = solid(ln);
let stroke_width = ln.and_then(|l| l.attr("w")).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.0).clamp(0.0, 100.0);
let effects = sppr.and_then(|s| s.child("a:effectLst")).map(effect_list).unwrap_or_default();
let story = match txbx {
Some(t) if sc.story_depth < MAX_STORY_DEPTH => Some(self.read_textbox(sc, t, rels)),
@@ -1143,6 +1222,15 @@ impl Reader<'_> {
if let Some(a) = bp.attr("anchor") {
part.body.anchor = wordcraft_doc::props::valign_from_anchor(a);
}
// Internal margins, each defaulting to Word's (#743).
let mut inset = wordcraft_doc::TEXT_BOX_INSET;
for (slot, n) in inset.iter_mut().zip(["lIns", "tIns", "rIns", "bIns"]) {
if let Some(v) = bp.attr(n).and_then(int) {
*slot = u32::try_from(v.clamp(0, i64::from(wordcraft_doc::MAX_TEXT_BOX_INSET))).unwrap_or(0);
}
}
part.body.inset = (inset != wordcraft_doc::TEXT_BOX_INSET).then_some(inset);
part.body.auto_fit = bp.child("a:spAutoFit").is_some();
}
let mut extra = ShapeExtra::default();
// Arrowheads: any end type but `none` (ECMA-376 §20.1.10.33) is drawn as a triangle.
@@ -1162,7 +1250,13 @@ impl Reader<'_> {
self.glued.extend(extra.start.iter().chain(extra.end.iter()).map(|e| e.id));
}
extra.warp = wsp.child("wps:bodyPr").and_then(|b| b.child("a:prstTxWarp")).and_then(text_warp);
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, freeform: None, effects, extra }
// A freeform: its custom geometry (#743: in a group too).
let freeform = sppr.and_then(|s| super::freeform::cust_geom(s, w, h)).map(|mut f| {
f.alpha = super::freeform::line_alpha(ln);
kind = ShapeKind::Freeform;
Arc::new(f)
});
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, freeform, effects, extra }
}
/// Keep drawing id `id` on a shape read from a file (see [`Reader::glued`]).
@@ -1183,32 +1277,34 @@ impl Reader<'_> {
self.doc.add_part(PartKind::TextBox, blocks)
}
/// Legacy VML pictures and text boxes (best effort).
/// Legacy VML pictures, text boxes, lines, rectangles and ovals (best effort).
fn read_vml(&mut self, sc: &mut StoryCtx, pict: &El, rels: &Rels) -> Option<InlineObject> {
let shape = pict.els().find(|e| e.name.starts_with("v:") && e.local() != "shapetype")?;
let style = shape.attr("style").unwrap_or("");
let len = |k: &str| vml_style(style, k).and_then(|v| measure(v, 1.0)).unwrap_or(0.0).clamp(0.0, crate::units::MAX_LEN_PT);
let (w, h) = (len("width"), len("height"));
let float = vml_float(shape, style);
let (mut w, mut h) = (len("width"), len("height"));
let mut float = vml_float(shape, style);
if let Some(img) = shape.find("v:imagedata") {
let id = img.attr("r:id").or_else(|| img.attr("r:pict"))?;
let media = self.media_for(rels, id)?;
let alt = shape.attr("alt").or_else(|| img.attr("o:title")).unwrap_or("").to_string();
return Some(InlineObject::Image { media, w, h, alt, float, crop: [0.0; 4], ole: None });
}
let on = |name: &str| shape.attr(name).is_none_or(|v| !matches!(v, "f" | "false" | "0"));
let stroke = on("stroked").then(|| shape.attr("strokecolor").map_or(Some(Rgb::BLACK), vml_color)).flatten();
let stroke_width = shape.attr("strokeweight").and_then(|v| measure(v, 1.0)).unwrap_or(0.75).clamp(0.0, 100.0);
let fill = on("filled").then(|| shape.attr("fillcolor").map_or(Some(Rgb::WHITE), vml_color)).flatten();
if let Some(t) = shape.find("w:txbxContent")
&& sc.story_depth < MAX_STORY_DEPTH
{
let story = Some(self.read_textbox(sc, t, rels));
let fill = shape.attr("fillcolor").and_then(Rgb::parse);
let stroke = shape.attr("strokecolor").and_then(Rgb::parse);
return Some(InlineObject::Shape {
kind: ShapeKind::TextBox,
w,
h,
fill,
stroke,
stroke_width: 0.75,
stroke_width,
float,
story,
freeform: None,
@@ -1216,7 +1312,51 @@ impl Reader<'_> {
extra: Default::default(),
});
}
None
let kind = match shape.local() {
"line" => ShapeKind::Line,
"rect" => ShapeKind::Rectangle,
"roundrect" => ShapeKind::RoundedRectangle,
"oval" => ShapeKind::Ellipse,
_ => return None,
};
let mut extra = ShapeExtra::default();
if kind == ShapeKind::Line {
// A line runs `from` → `to` (points, offsets from where its style places it).
let pt = |name: &str| -> (f32, f32) {
let v = shape.attr(name).unwrap_or("0,0");
let (x, y) = v.split_once(',').unwrap_or((v, "0"));
let m = |s: &str| measure(s, 1.0).map_or(0.0, wordcraft_geom::finite).clamp(-crate::units::MAX_LEN_PT, crate::units::MAX_LEN_PT);
(m(x), m(y))
};
let ((x0, y0), (x1, y1)) = (pt("from"), pt("to"));
let max = crate::units::MAX_LEN_PT;
(w, h) = ((x1 - x0).abs().min(max), (y1 - y0).abs().min(max));
float.x = (float.x + x0.min(x1)).clamp(-max, max);
float.y = (float.y + y0.min(y1)).clamp(-max, max);
// Drawn top-left to bottom-right; flipped for the other directions.
float.flip_h ^= x1 < x0;
float.flip_v ^= y1 < y0;
// Arrowheads (`v:stroke` startarrow / endarrow): any but `none` is drawn.
let arrow = |name: &str| shape.child("v:stroke").and_then(|s| s.attr(name)).is_some_and(|v| v != "none");
extra.arrow_start = arrow("startarrow");
extra.arrow_end = arrow("endarrow");
}
if stroke.is_none() && (fill.is_none() || kind == ShapeKind::Line) {
return None;
}
Some(InlineObject::Shape {
kind,
w,
h,
fill: fill.filter(|_| kind != ShapeKind::Line),
stroke,
stroke_width,
float,
story: None,
freeform: None,
effects: Default::default(),
extra,
})
}
/// An OLE object (`w:object`, ECMA-376 §17.3.3.19): shown as its picture (VML, or a DrawingML
@@ -1264,10 +1404,12 @@ impl Reader<'_> {
let mut tcs = Vec::new();
collect(tr, "w:tc", &mut tcs, &mut Vec::new(), 0);
for (tc, cell_controls) in tcs.into_iter().take(MAX_COLS) {
let (mut props, hcont) =
tc.child("w:tcPr").map(tcpr).unwrap_or_else(|| (wordcraft_doc::props::CellProps { span: 1, ..Default::default() }, false));
let (mut props, hcont) = tc
.child("w:tcPr")
.map(|p| tcpr(&self.pc, p))
.unwrap_or_else(|| (wordcraft_doc::props::CellProps { span: 1, ..Default::default() }, false));
if let Some(ch) = tc.child("w:tcPr").and_then(|p| p.child("w:tcPrChange")) {
let old = ch.child("w:tcPr").map(|o| tcpr(o).0).unwrap_or_default();
let old = ch.child("w:tcPr").map(|o| tcpr(&self.pc, o).0).unwrap_or_default();
props.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old);
}
if hcont && let Some(prev) = row.cells.last_mut() {
@@ -1584,7 +1726,9 @@ struct Xfrm {
/// The rotation and flips of the `a:xfrm` in shape properties `sppr` (ECMA-376 §20.1.7.6:
/// `rot` in 60000ths of a degree, clockwise; `flipH`, `flipV`). None when missing or junk.
fn xfrm_spin(sppr: Option<&El>) -> wordcraft_geom::Spin {
let Some(x) = sppr.and_then(|s| s.child("a:xfrm")) else { return wordcraft_geom::Spin::default() };
let Some(x) = sppr.and_then(|s| if matches!(s.name.as_str(), "a:xfrm" | "w14:xfrm") { Some(s) } else { s.child("a:xfrm") }) else {
return wordcraft_geom::Spin::default();
};
let rot = x.attr("rot").and_then(int).map(|v| v.rem_euclid(21_600_000) as f32 / 60_000.0).unwrap_or(0.0);
wordcraft_geom::Spin::new(rot, on_off_attr(x, "flipH"), on_off_attr(x, "flipV"))
}
File diff suppressed because it is too large Load Diff
+576
View File
@@ -0,0 +1,576 @@
//! Word 2003 XML Document (WordprocessingML 2003, root `w:wordDocument`, from the public Office
//! 2003 XML Reference Schemas). Import only.
//!
//! ECMA-376 kept most of the 2003 vocabulary (`w:p`, `w:r`, `w:pPr`, `w:rPr`, `w:tbl`,
//! `w:sectPr`, `w:styles`…), so the file is opened by rewriting it as the package it corresponds
//! to and reading that with the DOCX reader: one reader, one set of property rules. The rewrite
//! renames what changed (hyphenated names, `w:hlink`, `w:listPr`, `w:nfc`, `w:vmerge`…), moves
//! what 2003 keeps inline into parts (lists into numbering, headers and footers, footnotes,
//! endnotes, comments, `w:binData` pictures into media) and turns `aml:annotation` bookmarks,
//! comments and tracked insertions/deletions into their ECMA-376 elements. Bounded: the parser
//! caps depth and element count, pictures are capped in number and size.
use std::collections::{BTreeMap, HashMap};
use base64::Engine as _;
use wordcraft_doc::Document;
use crate::DocxError;
use crate::flat::W2003_NS;
use crate::package::{MAX_PART, MAX_TOTAL, Package, rt};
use crate::write::PartRels;
use crate::xml::{self, El, MAX_DEPTH, NAMESPACES, Node, W};
/// Auxiliary hints (`wx:`): display hints Word writes alongside the real markup.
const AUX_NS: &str = "http://schemas.microsoft.com/office/word/2003/auxHint";
/// Annotation markup (`aml:`): bookmarks, comments, tracked changes.
const AML_NS: &str = "http://schemas.microsoft.com/aml/2001/core";
/// Most pictures (`w:binData`) taken from one file.
const MAX_MEDIA: usize = 4096;
/// Most header, footer, footnote, endnote and comment stories.
const MAX_STORIES: usize = 50_000;
/// Read a Word 2003 XML Document.
pub fn read(bytes: &[u8]) -> Result<Document, DocxError> {
crate::read::read_opened(&convert(bytes)?)
}
/// Parts the converted package gets relationships for.
const DOC: usize = 0;
const FOOTNOTES: usize = 1;
const ENDNOTES: usize = 2;
const COMMENTS: usize = 3;
struct Conv {
/// `w:binData` name (`wordml://…`) → media target relative to `word/`.
media: HashMap<String, String>,
files: BTreeMap<String, Vec<u8>>,
/// Relationships per part: document, footnotes, endnotes, comments, then one per header or
/// footer (index in `stories` + 4).
rels: Vec<PartRels>,
footnotes: Vec<Node>,
endnotes: Vec<Node>,
comments: Vec<Node>,
/// Header and footer parts: (part name under `word/`, root element).
stories: Vec<(String, El)>,
next_note: i64,
story_count: usize,
}
/// Rewrite a Word 2003 XML Document as a WordprocessingML package.
pub(crate) fn convert(bytes: &[u8]) -> Result<Package, DocxError> {
let root = xml::parse_ns(bytes, &[("w", W2003_NS), ("wx", AUX_NS), ("aml", AML_NS)])?;
if root.name != "w:wordDocument" {
return Err(DocxError::NotWord(format!("root element is {}", root.name)));
}
let mut c = Conv {
media: HashMap::new(),
files: BTreeMap::new(),
rels: (0..4).map(|_| PartRels::default()).collect(),
footnotes: Vec::new(),
endnotes: Vec::new(),
comments: Vec::new(),
stories: Vec::new(),
next_note: 1,
story_count: 0,
};
let mut total = 0u64;
c.collect_media(&root, 0, &mut total)?;
// Body: `wx:sect` / `wx:sub-section` wrappers unwrapped; the last section's properties last.
let mut body = El { name: "w:body".into(), ..El::default() };
if let Some(b) = root.child("w:body") {
body.kids = c.kids(b, DOC, 1);
}
let last_sect = body.kids.iter().rposition(|n| matches!(n, Node::El(e) if e.name == "w:sectPr"));
if let Some(i) = last_sect {
let s = body.kids.remove(i);
body.kids.retain(|n| !matches!(n, Node::El(e) if e.name == "w:sectPr"));
body.kids.push(s);
}
let mut document = El { name: "w:document".into(), attrs: xml::body_ns(), kids: Vec::new() };
document.kids.push(Node::El(body));
let mut overrides: Vec<(String, &str)> = Vec::new();
let wml = |s: &str| format!("application/vnd.openxmlformats-officedocument.wordprocessingml.{s}+xml");
let styles = c.styles(&root);
c.put("word/styles.xml", &styles);
c.rels[DOC].add(rt::STYLES, "styles.xml", false);
overrides.push(("/word/styles.xml".into(), "styles"));
if let Some(n) = root.child("w:lists").map(|l| c.numbering(l)) {
c.put("word/numbering.xml", &n);
c.rels[DOC].add(rt::NUMBERING, "numbering.xml", false);
overrides.push(("/word/numbering.xml".into(), "numbering"));
}
for (part, root_name, kids, rel, ct) in [
(FOOTNOTES, "w:footnotes", std::mem::take(&mut c.footnotes), rt::FOOTNOTES, "footnotes"),
(ENDNOTES, "w:endnotes", std::mem::take(&mut c.endnotes), rt::ENDNOTES, "endnotes"),
(COMMENTS, "w:comments", std::mem::take(&mut c.comments), rt::COMMENTS, "comments"),
] {
if kids.is_empty() {
continue;
}
let file = format!("{ct}.xml");
c.put(&format!("word/{file}"), &El { name: root_name.into(), attrs: xml::body_ns(), kids });
c.put_rels(&file, part);
c.rels[DOC].add(rel, &file, false);
overrides.push((format!("/word/{file}"), ct));
}
for (i, (file, story)) in std::mem::take(&mut c.stories).into_iter().enumerate() {
c.put(&format!("word/{file}"), &story);
c.put_rels(&file, i + 4);
overrides.push((format!("/word/{file}"), if story.name == "w:ftr" { "footer" } else { "header" }));
}
c.put("word/document.xml", &document);
c.put_rels("document.xml", DOC);
let mut root_rels = PartRels::default();
root_rels.add(rt::OFFICE_DOC, "word/document.xml", false);
if let Some(props) = root.child("o:DocumentProperties") {
c.files.insert("docProps/core.xml".into(), core_xml(props));
root_rels.add(rt::CORE, "docProps/core.xml", false);
}
c.files.insert("_rels/.rels".into(), root_rels.xml());
let mut w = W::new();
w.open("Types", &[("xmlns", "http://schemas.openxmlformats.org/package/2006/content-types")]);
w.empty("Default", &[("Extension", "rels"), ("ContentType", "application/vnd.openxmlformats-package.relationships+xml")]);
w.empty("Default", &[("Extension", "xml"), ("ContentType", "application/xml")]);
let mut exts: Vec<&str> = c.media.values().filter_map(|t| t.rsplit_once('.').map(|(_, e)| e)).collect();
exts.sort_unstable();
exts.dedup();
for ext in exts {
w.empty("Default", &[("Extension", ext), ("ContentType", media_type(ext))]);
}
w.empty("Override", &[("PartName", "/word/document.xml"), ("ContentType", &wml("document.main"))]);
for (part, kind) in &overrides {
w.empty("Override", &[("PartName", part), ("ContentType", &wml(kind))]);
}
if c.files.contains_key("docProps/core.xml") {
w.empty("Override", &[("PartName", "/docProps/core.xml"), ("ContentType", "application/vnd.openxmlformats-package.core-properties+xml")]);
}
w.close("Types");
c.files.insert("[Content_Types].xml".into(), w.into_bytes());
Ok(Package::from_parts(c.files))
}
impl Conv {
/// Store an element as a part.
fn put(&mut self, path: &str, root: &El) {
let mut s = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
s.push_str(&root.to_xml());
self.files.insert(path.into(), s.into_bytes());
}
/// Store the relationships of `word/{file}` (if it has any).
fn put_rels(&mut self, file: &str, part: usize) {
if let Some(r) = self.rels.get(part).filter(|r| !r.is_empty()) {
let x = r.xml();
self.files.insert(format!("word/_rels/{file}.rels"), x);
}
}
/// Every `w:binData` in the file, decoded into a media part.
fn collect_media(&mut self, e: &El, depth: usize, total: &mut u64) -> Result<(), DocxError> {
if depth > MAX_DEPTH {
return Ok(());
}
for k in e.els() {
if k.name == "w:binData" {
let Some(name) = k.attr("w:name").filter(|n| !self.media.contains_key(*n)) else { continue };
if self.media.len() >= MAX_MEDIA {
continue;
}
let text = k.text();
let b64: String = text.chars().filter(|c| !c.is_ascii_whitespace()).collect();
if (b64.len() / 4 * 3) as u64 > MAX_PART {
return Err(DocxError::Limit(format!("picture {name} is larger than the size limit")));
}
let Ok(bytes) = base64::engine::general_purpose::STANDARD_NO_PAD.decode(b64.trim_end_matches('=')) else {
log::warn!("wordml2003: skipping picture {name}: invalid base64");
continue;
};
*total = total.saturating_add(bytes.len() as u64);
if *total > MAX_TOTAL {
return Err(DocxError::Limit("pictures larger than the size limit".into()));
}
let ext = picture_ext(&bytes, name);
let target = format!("media/image{}.{ext}", self.media.len() + 1);
self.files.insert(format!("word/{target}"), bytes);
self.media.insert(name.to_string(), target);
} else {
self.collect_media(k, depth + 1, total)?;
}
}
Ok(())
}
/// The converted children of `e`.
fn kids(&mut self, e: &El, part: usize, depth: usize) -> Vec<Node> {
let mut out = Vec::with_capacity(e.kids.len());
for n in &e.kids {
match n {
Node::Text(t) => out.push(Node::Text(t.clone())),
Node::El(k) => out.extend(self.node(k, part, depth + 1)),
}
}
out
}
/// `e` converted: nothing (dropped), its children (unwrapped) or one element.
fn node(&mut self, e: &El, part: usize, depth: usize) -> Vec<Node> {
if depth > MAX_DEPTH {
return Vec::new();
}
match e.name.as_str() {
"wx:sect" | "wx:sub-section" | "wx:pBdrGroup" => return self.kids(e, part, depth),
"w:binData" | "w:fonts" | "w:docPr" | "w:lists" | "w:styles" | "w:latentStyles" | "w:versionOfBuiltInStyleNames" => return Vec::new(),
"w:hlink" => return vec![Node::El(self.hyperlink(e, part, depth))],
"w:listPr" => return vec![Node::El(list_pr(e))],
"w:footnote" | "w:endnote" => return self.note(e, depth).into_iter().map(Node::El).collect(),
"aml:annotation" => return self.annotation(e, part, depth),
"w:sectPr" => return vec![Node::El(self.section(e, part, depth))],
"w:nfc" => return vec![Node::El(num_fmt(e))],
_ => {}
}
if !keep(&e.name) {
return Vec::new();
}
let mut el = El { name: rename(&e.name), attrs: attrs(e), kids: self.kids(e, part, depth) };
if el.name == "v:imagedata"
&& let Some(target) = e.attr("src").and_then(|s| self.media.get(s)).cloned()
&& let Some(r) = self.rels.get_mut(part)
{
let id = r.add(rt::IMAGE, &target, false);
el.attrs.retain(|(k, _)| k != "r:id");
el.attrs.push(("r:id".into(), id));
}
if el.name == "w:style" {
for (k, v) in &mut el.attrs {
if k == "w:default" && v == "on" {
*v = "1".into();
}
}
}
vec![Node::El(el)]
}
/// `w:hlink` → `w:hyperlink` (an external relationship for `w:dest`).
fn hyperlink(&mut self, e: &El, part: usize, depth: usize) -> El {
let mut attrs = Vec::new();
if let Some(dest) = e.attr("w:dest").filter(|d| !d.is_empty())
&& let Some(r) = self.rels.get_mut(part)
{
attrs.push(("r:id".into(), r.add(rt::HYPERLINK, dest, true)));
}
for (from, to) in [("w:bookmark", "w:anchor"), ("w:screenTip", "w:tooltip"), ("w:target", "w:tgtFrame")] {
if let Some(v) = e.attr(from) {
attrs.push((to.into(), v.to_string()));
}
}
El { name: "w:hyperlink".into(), attrs, kids: self.kids(e, part, depth) }
}
/// A footnote or endnote written inline in its run → a reference, the note in its part.
fn note(&mut self, e: &El, depth: usize) -> Option<El> {
// Separator definitions (`w:type`) live in document settings, not in the text.
if e.attr("w:type").is_some() || self.story_count >= MAX_STORIES {
return None;
}
self.story_count += 1;
let foot = e.name == "w:footnote";
let id = self.next_note.to_string();
self.next_note = self.next_note.saturating_add(1);
let part = if foot { FOOTNOTES } else { ENDNOTES };
let kids = self.kids(e, part, depth);
let note = El { name: e.name.clone(), attrs: vec![("w:id".into(), id.clone())], kids };
if foot {
self.footnotes.push(Node::El(note));
} else {
self.endnotes.push(Node::El(note));
}
let name = if foot { "w:footnoteReference" } else { "w:endnoteReference" };
Some(El { name: name.into(), attrs: vec![("w:id".into(), id)], kids: Vec::new() })
}
/// `aml:annotation`: bookmarks, comments and tracked insertions/deletions.
fn annotation(&mut self, e: &El, part: usize, depth: usize) -> Vec<Node> {
let id = e.attr("aml:id").filter(|v| v.parse::<i64>().is_ok()).unwrap_or("0").to_string();
let el = |name: &str, attrs: Vec<(String, String)>| vec![Node::El(El { name: name.into(), attrs, kids: Vec::new() })];
let author_date = |e: &El| {
let mut a = vec![("w:id".to_string(), id.clone())];
if let Some(v) = e.attr("aml:author") {
a.push(("w:author".into(), v.to_string()));
}
if let Some(v) = e.attr("aml:createdate") {
a.push(("w:date".into(), v.to_string()));
}
a
};
let content = |c: &mut Conv, part: usize| e.child("aml:content").map(|k| c.kids(k, part, depth + 1)).unwrap_or_default();
match e.attr("w:type").unwrap_or("") {
"Word.Bookmark.Start" => {
el("w:bookmarkStart", vec![("w:id".into(), id.clone()), ("w:name".into(), e.attr("w:name").unwrap_or("").to_string())])
}
"Word.Bookmark.End" => el("w:bookmarkEnd", vec![("w:id".into(), id)]),
"Word.Comment.Start" => el("w:commentRangeStart", vec![("w:id".into(), id)]),
"Word.Comment.End" => el("w:commentRangeEnd", vec![("w:id".into(), id)]),
"Word.Comment" => {
if self.story_count >= MAX_STORIES {
return Vec::new();
}
self.story_count += 1;
let mut attrs = author_date(e);
if let Some(v) = e.attr("w:initials") {
attrs.push(("w:initials".into(), v.to_string()));
}
let kids = content(self, COMMENTS);
self.comments.push(Node::El(El { name: "w:comment".into(), attrs, kids }));
el("w:commentReference", vec![("w:id".into(), id)])
}
t @ ("Word.Insertion" | "Word.Deletion") => {
let name = if t == "Word.Insertion" { "w:ins" } else { "w:del" };
vec![Node::El(El { name: name.into(), attrs: author_date(e), kids: content(self, part) })]
}
_ => Vec::new(),
}
}
/// `w:sectPr`: inline `w:hdr` / `w:ftr` become header and footer parts.
fn section(&mut self, e: &El, part: usize, depth: usize) -> El {
let mut refs = Vec::new();
let mut rest = Vec::new();
for n in &e.kids {
let Node::El(k) = n else { continue };
if (k.name == "w:hdr" || k.name == "w:ftr") && self.story_count < MAX_STORIES {
self.story_count += 1;
let footer = k.name == "w:ftr";
let kind = match k.attr("w:type") {
Some("even") => "even",
Some("first") => "first",
_ => "default",
};
let idx = self.stories.len() + 4;
self.rels.push(PartRels::default());
let kids = self.kids(k, idx, depth + 1);
let file = format!("{}{}.xml", if footer { "footer" } else { "header" }, self.stories.len() + 1);
self.stories.push((file.clone(), El { name: k.name.clone(), attrs: xml::body_ns(), kids }));
let id = self.rels.get_mut(part).map(|r| r.add(if footer { rt::FOOTER } else { rt::HEADER }, &file, false)).unwrap_or_default();
let name = if footer { "w:footerReference" } else { "w:headerReference" };
refs.push(Node::El(El { name: name.into(), attrs: vec![("w:type".into(), kind.into()), ("r:id".into(), id)], kids: Vec::new() }));
} else if k.name != "w:hdr" && k.name != "w:ftr" {
rest.extend(self.node(k, part, depth + 1));
}
}
refs.extend(rest);
El { name: "w:sectPr".into(), attrs: attrs(e), kids: refs }
}
/// `w:styles` → `styles.xml`, with the document's default fonts (`w:fonts/w:defaultFonts`).
fn styles(&mut self, root: &El) -> El {
let mut kids = Vec::new();
if let Some(f) = root.child("w:fonts").and_then(|f| f.child("w:defaultFonts")) {
let fonts = El { name: "w:rFonts".into(), attrs: attrs(f), kids: Vec::new() };
let rpr = El { name: "w:rPr".into(), attrs: Vec::new(), kids: vec![Node::El(fonts)] };
let rpr_default = El { name: "w:rPrDefault".into(), attrs: Vec::new(), kids: vec![Node::El(rpr)] };
kids.push(Node::El(El { name: "w:docDefaults".into(), attrs: Vec::new(), kids: vec![Node::El(rpr_default)] }));
}
if let Some(s) = root.child("w:styles") {
for st in s.children("w:style") {
kids.extend(self.node(st, DOC, 2));
}
}
El { name: "w:styles".into(), attrs: xml::body_ns(), kids }
}
/// `w:lists` → `numbering.xml`: `w:listDef` → `w:abstractNum`, `w:list` → `w:num`.
fn numbering(&mut self, lists: &El) -> El {
let mut kids = Vec::new();
for def in lists.children("w:listDef") {
let Some(id) = def.attr("w:listDefId") else { continue };
let mut a = El { name: "w:abstractNum".into(), attrs: vec![("w:abstractNumId".into(), id.to_string())], kids: Vec::new() };
for k in def.els() {
let converted = match k.name.as_str() {
"w:plt" => {
let v = match k.attr("w:val").unwrap_or("") {
"SingleLevel" => "singleLevel",
"HybridMultilevel" => "hybridMultilevel",
_ => "multilevel",
};
vec![Node::El(val_el("w:multiLevelType", v))]
}
"w:lsid" => vec![Node::El(val_el("w:nsid", k.attr("w:val").unwrap_or("")))],
"w:listStyleLink" => vec![Node::El(val_el("w:numStyleLink", k.attr("w:val").unwrap_or("")))],
_ => self.node(k, DOC, 2),
};
a.kids.extend(converted);
}
kids.push(Node::El(a));
}
for list in lists.children("w:list") {
let Some(id) = list.attr("w:ilfo") else { continue };
let Some(abs) = list.child_val("w:ilst") else { continue };
let mut n =
El { name: "w:num".into(), attrs: vec![("w:numId".into(), id.to_string())], kids: vec![Node::El(val_el("w:abstractNumId", abs))] };
for o in list.children("w:lvlOverride") {
n.kids.extend(self.node(o, DOC, 2));
}
kids.push(Node::El(n));
}
El { name: "w:numbering".into(), attrs: xml::body_ns(), kids }
}
}
/// `<name w:val="v"/>`.
fn val_el(name: &str, v: &str) -> El {
El { name: name.into(), attrs: vec![("w:val".into(), v.into())], kids: Vec::new() }
}
/// `w:listPr` (`w:ilvl`, `w:ilfo`) → `w:numPr` (`w:ilvl`, `w:numId`).
fn list_pr(e: &El) -> El {
let mut kids = Vec::new();
if let Some(v) = e.child_val("w:ilvl") {
kids.push(Node::El(val_el("w:ilvl", v)));
}
if let Some(v) = e.child_val("w:ilfo") {
kids.push(Node::El(val_el("w:numId", v)));
}
El { name: "w:numPr".into(), attrs: Vec::new(), kids }
}
/// `w:nfc` (a number format code) → `w:numFmt`.
fn num_fmt(e: &El) -> El {
let f = match e.attr("w:val").and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(0) {
1 => "upperRoman",
2 => "lowerRoman",
3 => "upperLetter",
4 => "lowerLetter",
5 => "ordinal",
6 => "cardinalText",
7 => "ordinalText",
9 => "chicago",
14 | 19 => "decimalFullWidth",
15 => "decimalHalfWidth",
18 => "decimalEnclosedCircle",
22 => "decimalZero",
23 => "bullet",
24 => "ganada",
25 => "chosung",
26 => "decimalEnclosedFullstop",
27 => "decimalEnclosedParen",
255 => "none",
_ => "decimal",
};
val_el("w:numFmt", f)
}
/// Is an element or attribute with this name kept (a namespace the package knows, or none)?
fn keep(name: &str) -> bool {
match name.split_once(':') {
None => true,
Some(("xml", _)) => true,
Some((p, _)) => NAMESPACES.iter().any(|(q, _)| *q == p),
}
}
/// A WordprocessingML 2003 name in its ECMA-376 spelling: `w:sz-cs` → `w:szCs`, `w:first-line`
/// → `w:firstLine`, `w:fareast` → `w:eastAsia`, `w:vmerge` → `w:vMerge`.
fn rename(name: &str) -> String {
let Some(local) = name.strip_prefix("w:") else { return name.to_string() };
match local {
"fareast" => return "w:eastAsia".into(),
"vmerge" => return "w:vMerge".into(),
"hmerge" => return "w:hMerge".into(),
_ => {}
}
let mut out = String::from("w:");
let mut up = false;
for c in local.chars() {
if c == '-' {
up = true;
} else if up {
out.extend(c.to_uppercase());
up = false;
} else {
out.push(c);
}
}
out
}
/// The attributes of `e` that the package keeps, renamed.
fn attrs(e: &El) -> Vec<(String, String)> {
e.attrs.iter().filter(|(k, _)| keep(k)).map(|(k, v)| (rename(k), v.clone())).collect()
}
/// A picture's file extension: from its bytes, else from its `wordml://` name.
fn picture_ext(b: &[u8], name: &str) -> String {
let sniffed = if b.starts_with(b"\x89PNG") {
"png"
} else if b.starts_with(&[0xFF, 0xD8]) {
"jpeg"
} else if b.starts_with(b"GIF8") {
"gif"
} else if b.starts_with(b"BM") {
"bmp"
} else if b.get(40..44) == Some(b" EMF") {
"emf"
} else if b.starts_with(&[0xD7, 0xCD, 0xC6, 0x9A]) || b.starts_with(&[0x01, 0x00, 0x09, 0x00]) {
"wmf"
} else {
""
};
if !sniffed.is_empty() {
return sniffed.into();
}
let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default();
if !ext.is_empty() && ext.len() <= 5 && ext.chars().all(|c| c.is_ascii_alphanumeric()) && ext != "xml" && ext != "rels" {
ext
} else {
"bin".into()
}
}
fn media_type(ext: &str) -> &'static str {
match ext {
"png" => "image/png",
"jpeg" | "jpg" => "image/jpeg",
"gif" => "image/gif",
"bmp" => "image/bmp",
"emf" => "image/x-emf",
"wmf" => "image/x-wmf",
_ => "application/octet-stream",
}
}
/// `o:DocumentProperties` → `docProps/core.xml`.
fn core_xml(props: &El) -> Vec<u8> {
let mut w = W::new();
w.open(
"cp:coreProperties",
&[
("xmlns:cp", "http://schemas.openxmlformats.org/package/2006/metadata/core-properties"),
("xmlns:dc", "http://purl.org/dc/elements/1.1/"),
("xmlns:dcterms", "http://purl.org/dc/terms/"),
],
);
for (from, to) in [
("o:Title", "dc:title"),
("o:Subject", "dc:subject"),
("o:Author", "dc:creator"),
("o:Keywords", "cp:keywords"),
("o:Description", "dc:description"),
("o:LastAuthor", "cp:lastModifiedBy"),
("o:Revision", "cp:revision"),
("o:Category", "cp:category"),
("o:Created", "dcterms:created"),
("o:LastSaved", "dcterms:modified"),
] {
if let Some(t) = props.child(from).map(El::text).filter(|t| !t.trim().is_empty()) {
w.leaf(to, &[], t.trim());
}
}
w.close("cp:coreProperties");
w.into_bytes()
}
+160 -7
View File
@@ -1,4 +1,4 @@
//! Writing back charts, SmartArt diagrams and OLE objects read from a file (see `read::embed`).
//! Writing back charts, SmartArt diagrams, native ink and OLE objects read from a file (see `read::embed`).
//!
//! Each written object gets its kept markup with fresh relationship ids, pointing at copies of the
//! parts it needs. The first object to use a part writes it under its original name (unless that
@@ -13,8 +13,10 @@ use wordcraft_doc::graphic::Embedded;
use wordcraft_doc::para::Float;
use super::PartRels;
use crate::package::{EMBEDDED_PARTS, EmbeddedManifest, MAX_EMBEDDED_PARTS, VBA_PROJECT_PART, VBA_RELATED, relative};
use crate::units::emu;
use crate::package::{
CUSTOM_XML_ROOTS, EMBEDDED_PARTS, EmbeddedManifest, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, VBA_PROJECT_PART, VBA_RELATED, rel_is, relative, rt,
};
use crate::units::{MAX_LEN_PT, emu, int};
use crate::xml::{self, El, MAX_DEPTH};
/// Most bytes written for copies of parts (objects pasted more than once).
@@ -27,6 +29,9 @@ const PART_COST: u64 = 4096;
/// Where the stories' parts live: every story this writer makes is a part in `word/`.
const STORY_PART: &str = "word/document.xml";
/// The font table kept from a file (read by `Reader::keep_part_tree`).
const FONT_TABLE_PART: &str = "word/fontTable.xml";
/// The embedded-object parts of a package being written.
pub(crate) struct EmbedWriter<'d> {
manifest: EmbeddedManifest,
@@ -64,6 +69,10 @@ impl<'d> EmbedWriter<'d> {
.map(|s| s.to_string())
.collect();
taken.insert(VBA_PROJECT_PART.to_ascii_lowercase());
// The bibliography writer uses these names when sources are present (#532).
if !doc.sources.is_empty() {
taken.extend(["customxml/item1.xml".into(), "customxml/itemprops1.xml".into()]);
}
// The macro project's parts keep their names (see `write_as`).
if let Some(m) = doc.passthrough.get(VBA_RELATED) {
for line in String::from_utf8_lossy(m).lines() {
@@ -84,6 +93,14 @@ impl<'d> EmbedWriter<'d> {
}
}
/// Write the font table kept from a file, with the fonts it embeds, relating it from `rels`.
pub fn font_table(&mut self, rels: &mut PartRels) {
let mut written = Vec::new();
if let Some(name) = self.copy_tree(FONT_TABLE_PART, &mut HashMap::new(), &mut written) {
rels.add(rt::FONT_TABLE, &relative(STORY_PART, &name), false);
}
}
/// Reserve the names of the media files this writer writes.
pub fn reserve_media<'a>(&mut self, files: impl Iterator<Item = &'a String>) {
for f in files {
@@ -101,6 +118,9 @@ impl<'d> EmbedWriter<'d> {
/// A free part name for (a copy of) `orig`: `orig` itself if free, else `stem_N.ext`.
fn alloc(&mut self, orig: &str) -> String {
// Suffixes cannot escape reserved directories or a .rels extension. Such a kept
// part needs a different base name, otherwise every candidate would be reserved.
let orig = if Self::reserved(&orig.to_ascii_lowercase()) { "word/embeddings/part.xml" } else { orig };
let lower = orig.to_ascii_lowercase();
if !Self::reserved(&lower) && self.taken.insert(lower) {
return orig.to_string();
@@ -121,6 +141,24 @@ impl<'d> EmbedWriter<'d> {
}
}
/// A number `n` for which every `{stem}{n}{ext}` is a free part name, with those names taken.
fn fresh_set(&mut self, stems: &[&str], ext: &str) -> Vec<String> {
let mut n: u32 = 1;
loop {
let names: Vec<String> = stems.iter().map(|s| format!("{s}{n}{ext}")).collect();
if names.iter().all(|name| {
let lower = name.to_ascii_lowercase();
!Self::reserved(&lower) && !self.taken.contains(&lower)
}) {
for name in &names {
self.taken.insert(name.to_ascii_lowercase());
}
return names;
}
n = n.saturating_add(1);
}
}
/// A free part name `{stem}{n}{ext}` for a new part, from n = 1 up.
fn fresh(&mut self, stem: &str, ext: &str) -> String {
let mut n: u32 = 1;
@@ -139,6 +177,25 @@ impl<'d> EmbedWriter<'d> {
self.manifest.parts.contains_key(name) && self.passthrough.contains_key(name)
}
/// Reattach implicit custom XML roots, sharing their kept parts across both owners.
pub fn custom_xml_rels(&mut self, owner: &str, rels: &mut PartRels) {
let Some(roots) = self.passthrough.get(CUSTOM_XML_ROOTS).cloned() else { return };
let mut memo = std::mem::take(&mut self.first);
let mut written = Vec::new();
for line in String::from_utf8_lossy(&roots).lines().take(MAX_EMBEDDED_RELS * 2) {
let fields: Vec<&str> = line.split('\t').collect();
let [source, kind, target] = fields.as_slice() else { continue };
if *source != owner || !rel_is(kind, rt::CUSTOM_XML) {
continue;
}
if let Some(name) = self.copy_tree(target, &mut memo, &mut written) {
let source_part = if owner == "P" { "" } else { STORY_PART };
rels.add(kind, &relative(source_part, &name), false);
}
}
self.first = memo;
}
/// What copying the parts reached from `roots` costs against the copy budget.
fn tree_cost(&self, roots: &[&str]) -> u64 {
let mut seen: HashSet<&str> = HashSet::new();
@@ -205,6 +262,12 @@ impl super::Writer<'_> {
/// the object's current size and position into its own markup (a chart or diagram's frame is
/// written by the caller). `None` when it can't be written back (its parts are gone).
pub(super) fn embedded_xml(&mut self, src: &Embedded, rels: &mut PartRels, resize: Option<(f32, f32, &Float, &str)>) -> Option<String> {
self.embedded_tree(src, rels, resize).map(|root| root.to_xml())
}
/// The same bounded preservation operation, retaining the parsed tree for a caller that
/// must rebuild an ink AlternateContent drawing frame without reparsing unbound prefixes.
pub(super) fn embedded_tree(&mut self, src: &Embedded, rels: &mut PartRels, resize: Option<(f32, f32, &Float, &str)>) -> Option<El> {
let Some(left) = self.embeds.markup_bytes.checked_sub(src.xml.len() as u64) else {
log::warn!("docx: too much embedded-object markup; the rest is left out");
return None;
@@ -259,7 +322,52 @@ impl super::Writer<'_> {
if let Some((w, h, float, alt)) = resize {
self.resize_object(&mut root, w, h, float, alt, 0);
}
Some(root.to_xml())
Some(root)
}
/// The `a:graphic` of a SmartArt graphic written from its model, laid out `w` × `h` points:
/// new diagram parts (`word/diagrams/{data,layout,quickStyle,colors,drawing}N.xml`) related
/// from the story part through `rels`.
pub(super) fn smart_art_graphic(&mut self, spec: &wordcraft_doc::smart_art::SmartArtSpec, w: f32, h: f32, rels: &mut PartRels) -> String {
use crate::package::rt;
use crate::smart_art as sa;
let names = self.embeds.fresh_set(
&["word/diagrams/data", "word/diagrams/layout", "word/diagrams/quickStyle", "word/diagrams/colors", "word/diagrams/drawing"],
".xml",
);
let [data, layout, style, colors, drawing] = names.as_slice() else { return String::new() };
// The drawing first: the data part names it by the story part's relationship id.
let drawing_id = rels.add(rt::DIAGRAM_DRAWING, &relative(STORY_PART, drawing), false);
let parts = [
(data, sa::data_xml(spec, &drawing_id), sa::DATA_CT, rt::DIAGRAM_DATA),
(layout, sa::layout_xml(spec.layout), sa::LAYOUT_CT, rt::DIAGRAM_LAYOUT),
(style, sa::quick_style_xml(), sa::STYLE_CT, rt::DIAGRAM_QUICK_STYLE),
(colors, sa::colors_xml(spec.colors), sa::COLORS_CT, rt::DIAGRAM_COLORS),
(drawing, sa::drawing_xml(spec, w, h), sa::DRAWING_CT, rt::DIAGRAM_DRAWING),
];
let mut ids = Vec::new();
for (name, xml, ct, kind) in parts {
self.embeds.parts.push((name.clone(), xml.into_bytes(), ct.to_string(), PartRels::default()));
ids.push(rels.add(kind, &relative(STORY_PART, name), false));
}
let id = |i: usize| ids.get(i).cloned().unwrap_or_default();
let mut x = xml::W::default();
x.open("a:graphic", &[("xmlns:a", "http://schemas.openxmlformats.org/drawingml/2006/main")]);
x.open("a:graphicData", &[("uri", sa::DIAGRAM_URI)]);
x.empty(
"dgm:relIds",
&[
("xmlns:dgm", "http://schemas.openxmlformats.org/drawingml/2006/diagram"),
("xmlns:r", "http://schemas.openxmlformats.org/officeDocument/2006/relationships"),
("r:dm", &id(0)),
("r:lo", &id(1)),
("r:qs", &id(2)),
("r:cs", &id(3)),
],
);
x.close("a:graphicData");
x.close("a:graphic");
x.s
}
/// The `a:graphic` of a chart written from its model: a new chart part (`word/charts/chartN.xml`)
@@ -346,6 +454,51 @@ impl super::Writer<'_> {
}
}
}
/// Ink's own transform need not fill its outer drawing frame. Scale its original offsets
/// and extents proportionally; an unrelated edit must not stretch the preserved strokes.
pub(super) fn ink_transform(e: &mut El, from: (f32, f32), to: (f32, f32), float: &Float, depth: usize) {
if depth > MAX_DEPTH {
return;
}
if e.name == "w14:xfrm" {
e.attrs.retain(|(k, _)| !matches!(k.as_str(), "rot" | "flipH" | "flipV"));
for (k, v) in super::story::spin_attrs(float.spin()) {
e.attrs.push((k.to_string(), v));
}
let scale = |old: f32, new: f32| if old.is_finite() && old > 0.0 && new.is_finite() { (new.max(0.0) / old) as f64 } else { 1.0 };
let (sx, sy) = (scale(from.0, to.0), scale(from.1, to.1));
let max = MAX_LEN_PT as f64 * 12_700.0;
for c in e.kids.iter_mut().filter_map(|k| match k {
xml::Node::El(c) => Some(c),
_ => None,
}) {
if !matches!(c.name.as_str(), "a:off" | "a:ext") {
continue;
}
for (key, value) in &mut c.attrs {
let factor = match key.as_str() {
"x" | "cx" => sx,
"y" | "cy" => sy,
_ => continue,
};
if factor != 1.0
&& let Some(n) = int(value)
{
let min = if c.name == "a:ext" { 0.0 } else { -max };
*value = ((n as f64 * factor).clamp(min, max).round() as i64).to_string();
}
}
}
return;
}
for c in e.kids.iter_mut().filter_map(|k| match k {
xml::Node::El(c) => Some(c),
_ => None,
}) {
Self::ink_transform(c, from, to, float, depth + 1);
}
}
}
/// Set an extent's `cx` and `cy` (those it has) to `w` × `h` points.
@@ -436,9 +589,9 @@ fn vml_style_with(style: &str, w: f32, h: f32, float: &Float) -> String {
.split(';')
.filter(|d| {
let key = d.split_once(':').map_or("", |(k, _)| k).trim().to_ascii_lowercase();
!d.trim().is_empty()
&& !matches!(key.as_str(), "width" | "height" | "rotation" | "flip")
&& !(absolute && matches!(key.as_str(), "left" | "top" | "margin-left" | "margin-top"))
!(d.trim().is_empty()
|| matches!(key.as_str(), "width" | "height" | "rotation" | "flip")
|| (absolute && matches!(key.as_str(), "left" | "top" | "margin-left" | "margin-top")))
})
.map(|d| d.trim().to_string())
.collect();
+266 -14
View File
@@ -44,10 +44,10 @@ impl PartRels {
}
self.list.push((id.to_string(), kind.to_string(), target.to_string(), external));
}
fn is_empty(&self) -> bool {
pub(crate) fn is_empty(&self) -> bool {
self.list.is_empty()
}
fn xml(&self) -> Vec<u8> {
pub(crate) fn xml(&self) -> Vec<u8> {
let mut w = W::new();
w.open("Relationships", &[("xmlns", "http://schemas.openxmlformats.org/package/2006/relationships")]);
for (id, kind, target, external) in &self.list {
@@ -117,6 +117,12 @@ pub fn write(doc: &Document) -> Result<Vec<u8>, DocxError> {
/// Write a package of the given flavour (`.docx`, `.docm`, `.dotx`, `.dotm`).
pub fn write_as(doc: &Document, flavor: Flavor) -> Result<Vec<u8>, DocxError> {
zip_entries(&write_parts(doc, flavor)?)
}
/// The parts of the package [`write_as`] zips, in order, `[Content_Types].xml` first: (part name
/// without the leading `/`, bytes).
pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String, Vec<u8>)>, DocxError> {
// A range field must be whole in the file: drop markers that lost their partner in editing.
let balanced;
let doc = if doc.has_unbalanced_field_ranges() {
@@ -164,6 +170,7 @@ pub fn write_as(doc: &Document, flavor: Flavor) -> Result<Vec<u8>, DocxError> {
let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
let mut overrides: Vec<(String, String)> = Vec::new();
let mut rels = PartRels::default();
let mut root = PartRels::default();
// Comments get paragraph ids before any story is written.
let mut comment_paras: Vec<(u32, String)> = Vec::new();
@@ -339,13 +346,17 @@ pub fn write_as(doc: &Document, flavor: Flavor) -> Result<Vec<u8>, DocxError> {
push_part(&mut entries, &mut overrides, "word/numbering.xml", numbering_xml(doc), &format!("{CT_WML}numbering+xml"), PartRels::default());
}
rels.add(rt::SETTINGS, "settings.xml", false);
// The attached template is an external relationship of the settings part (§17.15.1.6).
let mut settings_rels = PartRels::default();
let template = doc.settings.attached_template.as_deref().map(str::trim).filter(|t| !t.is_empty());
let template_rel = template.map(|t| settings_rels.add(rt::ATTACHED_TEMPLATE, t, true));
push_part(
&mut entries,
&mut overrides,
"word/settings.xml",
settings_xml(doc, notes_part(doc, &wr, true), notes_part(doc, &wr, false)),
settings_xml(doc, notes_part(doc, &wr, true), notes_part(doc, &wr, false), template_rel.as_deref()),
&format!("{CT_WML}settings+xml"),
PartRels::default(),
settings_rels,
);
rels.add(rt::THEME, "theme/theme1.xml", false);
push_part(
@@ -357,6 +368,8 @@ pub fn write_as(doc: &Document, flavor: Flavor) -> Result<Vec<u8>, DocxError> {
PartRels::default(),
);
wr.embeds.font_table(&mut rels);
// Media referenced by some drawing (orphan parts would make the package suspect).
let mut media_types: BTreeMap<String, &str> = BTreeMap::new();
for (key, file) in &wr.media_files {
@@ -370,7 +383,9 @@ pub fn write_as(doc: &Document, flavor: Flavor) -> Result<Vec<u8>, DocxError> {
}
}
// Parts of charts, diagrams and OLE objects kept from a file, as the objects written need them.
wr.embeds.custom_xml_rels("D", &mut rels);
wr.embeds.custom_xml_rels("P", &mut root);
// Parts of embedded objects and custom XML stores kept from the file.
for (name, bytes, ct, prels) in std::mem::take(&mut wr.embeds.parts) {
push_part(&mut entries, &mut overrides, &name, bytes, &ct, prels);
}
@@ -417,7 +432,6 @@ pub fn write_as(doc: &Document, flavor: Flavor) -> Result<Vec<u8>, DocxError> {
entries.insert(1, ("word/_rels/document.xml.rels".into(), rels.xml()));
// Package-level parts.
let mut root = PartRels::default();
root.add(rt::OFFICE_DOC, "word/document.xml", false);
root.add(rt::CORE, "docProps/core.xml", false);
root.add(rt::APP, "docProps/app.xml", false);
@@ -430,6 +444,27 @@ pub fn write_as(doc: &Document, flavor: Flavor) -> Result<Vec<u8>, DocxError> {
entries.push(("docProps/custom.xml".into(), crate::custom::write(&doc.custom_props)));
overrides.push(("/docProps/custom.xml".into(), "application/vnd.openxmlformats-officedocument.custom-properties+xml".into()));
}
if !doc.sources.is_empty() {
root.add(rt::CUSTOM_XML, "customXml/item1.xml", false);
let mut item_rels = PartRels::default();
item_rels.add(rt::CUSTOM_XML_PROPS, "itemProps1.xml", false);
push_part(
&mut entries,
&mut overrides,
"customXml/item1.xml",
bibliography_xml(doc),
"application/vnd.openxmlformats-officedocument.customXml+xml",
item_rels,
);
push_part(
&mut entries,
&mut overrides,
"customXml/itemProps1.xml",
bibliography_props_xml(),
"application/vnd.openxmlformats-officedocument.customXmlProperties+xml",
PartRels::default(),
);
}
entries.insert(0, ("_rels/.rels".into(), root.xml()));
// Content types.
@@ -447,8 +482,7 @@ pub fn write_as(doc: &Document, flavor: Flavor) -> Result<Vec<u8>, DocxError> {
}
w.close("Types");
entries.insert(0, ("[Content_Types].xml".into(), w.into_bytes()));
zip_entries(&entries)
Ok(entries)
}
fn push_part(entries: &mut Vec<(String, Vec<u8>)>, overrides: &mut Vec<(String, String)>, path: &str, bytes: Vec<u8>, ct: &str, rels: PartRels) {
@@ -622,11 +656,13 @@ fn styles_xml(doc: &Document) -> Vec<u8> {
props::rpr_inner(&mut w, &s.default_chr);
w.close("w:rPr");
w.close("w:rPrDefault");
w.open("w:pPrDefault", &[]);
w.open("w:pPr", &[]);
props::ppr_inner(&mut w, &s.default_para, false, None);
w.close("w:pPr");
w.close("w:pPrDefault");
if s.default_para_present || s.default_para != wordcraft_doc::props::ParaProps::default() {
w.open("w:pPrDefault", &[]);
w.open("w:pPr", &[]);
props::ppr_inner(&mut w, &s.default_para, false, None);
w.close("w:pPr");
w.close("w:pPrDefault");
}
w.close("w:docDefaults");
for st in &s.styles {
style_xml(&mut w, st);
@@ -845,7 +881,9 @@ fn notes_part(doc: &Document, wr: &Writer<'_>, foot: bool) -> bool {
!ids.is_empty() || !seps.is_empty()
}
fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool) -> Vec<u8> {
/// `w:settings`, its children in the schema's order (CT_Settings is a sequence). `template_rel`
/// is the id of the attached template's relationship, when there is one.
fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, template_rel: Option<&str>) -> Vec<u8> {
let s = &doc.settings;
let s_notes = (&doc.footnote_separators, &doc.endnote_separators);
let mut w = W::new();
@@ -859,6 +897,12 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool) -> Vec<u8> {
if s.mirror_margins {
w.empty("w:mirrorMargins", &[]);
}
if let Some(id) = template_rel {
w.empty("w:attachedTemplate", &[("r:id", id)]);
}
if s.link_styles {
w.empty("w:linkStyles", &[]);
}
if s.track_changes {
w.empty("w:trackRevisions", &[]);
}
@@ -1039,3 +1083,211 @@ fn app_xml(doc: &Document) -> Vec<u8> {
w.close("Properties");
w.into_bytes()
}
fn to_source_type(kind: &str) -> &str {
match kind.to_ascii_lowercase().as_str() {
"book" => "Book",
"booksection" | "chapter" => "BookSection",
"article" | "journal" | "journalarticle" => "JournalArticle",
"periodical" | "articleinaperiodical" => "ArticleInAPeriodical",
"website" | "site" | "internetsite" => "InternetSite",
"documentfrominternetsite" => "DocumentFromInternetSite",
"electronicsource" => "ElectronicSource",
"report" => "Report",
"conference" | "proceedings" | "conferenceproceedings" => "ConferenceProceedings",
"case" => "Case",
"art" => "Art",
"film" => "Film",
"interview" => "Interview",
"patent" => "Patent",
"performance" => "Performance",
"soundrecording" => "SoundRecording",
"standard" => "Standard",
"thesis" => "Thesis",
"other" | "misc" => "Misc",
_ => "Book",
}
}
pub(crate) fn from_source_type(st: &str) -> String {
match st.trim() {
"Book" | "book" => "book".into(),
"BookSection" | "booksection" => "bookSection".into(),
"JournalArticle" | "journalarticle" | "article" => "article".into(),
"ArticleInAPeriodical" | "articleinaperiodical" => "periodical".into(),
"InternetSite" | "internetsite" | "website" => "website".into(),
"DocumentFromInternetSite" | "documentfrominternetsite" => "website".into(),
"ElectronicSource" | "electronicsource" => "electronicsource".into(),
"Report" | "report" => "report".into(),
"ConferenceProceedings" | "conferenceproceedings" => "conference".into(),
"Case" | "case" => "case".into(),
"SoundRecording" | "soundrecording" => "soundrecording".into(),
"Film" | "film" => "film".into(),
"Interview" | "interview" => "interview".into(),
"Patent" | "patent" => "patent".into(),
"Performance" | "performance" => "performance".into(),
"Standard" | "standard" => "standard".into(),
"Thesis" | "thesis" => "thesis".into(),
"Misc" | "misc" => "other".into(),
"" => "book".into(),
other => other.to_ascii_lowercase(),
}
}
/// The document's citation style, APA when it has none, and a style sheet file name made from
/// it (`\APA.XSL`, `\MLA.XSL`).
fn bib_style(doc: &Document) -> (&str, String) {
let name = doc.settings.bib_style.as_deref().map(str::trim).filter(|n| !n.is_empty()).unwrap_or("APA");
let file: String = name.chars().filter(char::is_ascii_alphanumeric).collect();
(name, format!("\\{}.XSL", if file.is_empty() { "APA" } else { &file }))
}
fn write_name_list(w: &mut W, names: &str) {
w.open("b:NameList", &[]);
let persons: Vec<_> = names.split(';').map(str::trim).filter(|s| !s.is_empty()).collect();
for a in persons {
w.open("b:Person", &[]);
// Parse "Last, First" or "Last, First Middle"
let (last, first_middle) = match a.split_once(',') {
Some((l, f)) => (l.trim(), f.trim()),
None => (a, ""),
};
w.leaf("b:Last", &[], last);
if !first_middle.is_empty() {
let parts: Vec<&str> = first_middle.split_whitespace().collect();
if let Some(first) = parts.first() {
w.leaf("b:First", &[], first);
}
if let Some(slice) = parts.get(1..).filter(|s| !s.is_empty()) {
let mid = slice.join(" ");
w.leaf("b:Middle", &[], &mid);
}
}
w.close("b:Person");
}
w.close("b:NameList");
}
fn split_accessed_date(s: &str) -> (Option<&str>, Option<&str>, Option<&str>) {
let s = s.trim();
if s.is_empty() {
return (None, None, None);
}
let parts: Vec<&str> = if s.contains('-') {
s.split('-').collect()
} else if s.contains('/') {
s.split('/').collect()
} else {
Vec::new()
};
if let [y, m, d] = parts.as_slice() {
(Some(y.trim()), Some(m.trim()), Some(d.trim()))
} else if let [y, m] = parts.as_slice() {
(Some(y.trim()), Some(m.trim()), None)
} else {
(Some(s), None, None)
}
}
fn bibliography_xml(doc: &Document) -> Vec<u8> {
let (style_name, selected) = bib_style(doc);
let mut w = W::new();
w.open(
"b:Sources",
&[
("SelectedStyle", selected.as_str()),
("StyleName", style_name),
("xmlns:b", "http://schemas.openxmlformats.org/officeDocument/2006/bibliography"),
("xmlns", "http://schemas.openxmlformats.org/officeDocument/2006/bibliography"),
],
);
for src in &doc.sources {
w.open("b:Source", &[]);
w.leaf("b:Tag", &[], &src.tag);
w.leaf("b:SourceType", &[], to_source_type(&src.kind));
// Author & Editor
if !src.author.is_empty() || !src.editor.is_empty() {
w.open("b:Author", &[]);
if !src.author.is_empty() {
w.open("b:Author", &[]);
write_name_list(&mut w, &src.author);
w.close("b:Author");
}
if !src.editor.is_empty() {
w.open("b:Editor", &[]);
write_name_list(&mut w, &src.editor);
w.close("b:Editor");
}
w.close("b:Author");
}
if !src.title.is_empty() {
w.leaf("b:Title", &[], &src.title);
}
if !src.year.is_empty() {
w.leaf("b:Year", &[], &src.year);
}
if !src.month.is_empty() {
w.leaf("b:Month", &[], &src.month);
}
if !src.day.is_empty() {
w.leaf("b:Day", &[], &src.day);
}
if !src.city.is_empty() {
w.leaf("b:City", &[], &src.city);
}
if !src.publisher.is_empty() {
w.leaf("b:Publisher", &[], &src.publisher);
}
if !src.journal.is_empty() {
w.leaf("b:JournalName", &[], &src.journal);
}
if !src.edition.is_empty() {
w.leaf("b:Edition", &[], &src.edition);
}
if !src.volume.is_empty() {
w.leaf("b:Volume", &[], &src.volume);
}
if !src.issue.is_empty() {
w.leaf("b:Issue", &[], &src.issue);
}
if !src.pages.is_empty() {
w.leaf("b:Pages", &[], &src.pages);
}
if !src.url.is_empty() {
w.leaf("b:URL", &[], &src.url);
}
if !src.accessed.is_empty() {
let (y, m, d) = split_accessed_date(&src.accessed);
if let Some(y) = y.filter(|s| !s.is_empty()) {
w.leaf("b:YearAccessed", &[], y);
}
if let Some(m) = m.filter(|s| !s.is_empty()) {
w.leaf("b:MonthAccessed", &[], m);
}
if let Some(d) = d.filter(|s| !s.is_empty()) {
w.leaf("b:DayAccessed", &[], d);
}
w.leaf("b:Accessed", &[], &src.accessed);
}
if !src.doi.is_empty() {
w.leaf("b:DOI", &[], &src.doi);
w.leaf("b:StandardNumber", &[], &src.doi);
}
w.close("b:Source");
}
w.close("b:Sources");
w.into_bytes()
}
fn bibliography_props_xml() -> Vec<u8> {
let mut w = W::new();
w.open(
"ds:datastoreItem",
&[("xmlns:ds", "http://schemas.openxmlformats.org/officeDocument/2006/customXml"), ("ds:itemID", "{3315B9A8-2323-4554-B8B2-1234567890AB}")],
);
w.open("ds:schemaRefs", &[]);
w.empty("ds:schemaRef", &[("ds:uri", "http://schemas.openxmlformats.org/officeDocument/2006/bibliography")]);
w.close("ds:schemaRefs");
w.close("ds:datastoreItem");
w.into_bytes()
}
+75 -13
View File
@@ -26,19 +26,24 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) {
if let Some(s) = &c.style {
w.val("w:rStyle", s);
}
// The complex-script font is written only when set: without it, Persian/Arabic text keeps
// the style's. Complex-script bold, italic and size default to the plain ones (they come in
// pairs, see `CharProps::overlay`), so text formatted here looks the same in Word.
match (&c.font, &c.font_cs) {
(Some(f), Some(cs)) => w.empty("w:rFonts", &[("w:ascii", f), ("w:hAnsi", f), ("w:cs", cs)]),
(Some(f), None) => w.empty("w:rFonts", &[("w:ascii", f), ("w:hAnsi", f)]),
(None, Some(cs)) => w.empty("w:rFonts", &[("w:cs", cs)]),
(None, None) => {}
// Emit only explicit properties so absent values keep inheriting from the style chain.
let mut fonts: Vec<(&str, &str)> = Vec::new();
if let Some(f) = &c.font {
fonts.extend([("w:ascii", f.as_str()), ("w:hAnsi", f.as_str())]);
}
if let Some(ea) = &c.font_ea {
fonts.push(("w:eastAsia", ea));
}
if let Some(cs) = &c.font_cs {
fonts.push(("w:cs", cs));
}
if !fonts.is_empty() {
w.empty("w:rFonts", &fonts);
}
toggle(w, "w:b", c.bold);
toggle(w, "w:bCs", c.bold_cs.or(c.bold));
toggle(w, "w:bCs", c.bold_cs);
toggle(w, "w:i", c.italic);
toggle(w, "w:iCs", c.italic_cs.or(c.italic));
toggle(w, "w:iCs", c.italic_cs);
toggle(w, "w:caps", c.caps);
toggle(w, "w:smallCaps", c.small_caps);
toggle(w, "w:strike", c.strike);
@@ -70,7 +75,7 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) {
if let Some(s) = c.size {
w.val("w:sz", &half(s));
}
if let Some(s) = c.size_cs.or(c.size) {
if let Some(s) = c.size_cs {
w.val("w:szCs", &half(s));
}
if let Some(h) = c.highlight {
@@ -204,6 +209,35 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
"w:framePr",
&[("w:dropCap", "drop"), ("w:lines", &n(lines.min(10) as i64)), ("w:wrap", "around"), ("w:vAnchor", "text"), ("w:hAnchor", "text")],
);
} else if let Some(f) = &p.frame {
let (fw, fh, fx, fy) = (f.w.map(twips), f.h.map(twips), twips(f.x), twips(f.y));
let (hs, vs) = (twips(f.h_space), twips(f.v_space));
let mut a: Vec<(&str, &str)> = Vec::new();
if let Some(v) = &fw {
a.push(("w:w", v));
}
if let Some(v) = &fh {
a.push(("w:h", v));
a.push(("w:hRule", if f.h_exact { "exact" } else { "atLeast" }));
}
if f.h_space > 0.0 {
a.push(("w:hSpace", &hs));
}
if f.v_space > 0.0 {
a.push(("w:vSpace", &vs));
}
a.push(("w:wrap", &f.wrap));
a.push(("w:vAnchor", &f.v_anchor));
a.push(("w:hAnchor", &f.h_anchor));
match &f.x_align {
Some(x) => a.push(("w:xAlign", x)),
None => a.push(("w:x", &fx)),
}
match &f.y_align {
Some(y) => a.push(("w:yAlign", y)),
None => a.push(("w:y", &fy)),
}
w.empty("w:framePr", &a);
}
toggle(w, "w:widowControl", p.widow_control);
if let Some(nr) = p.numbering {
@@ -261,7 +295,12 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
toggle(w, "w:autoSpaceDN", p.auto_space_dn);
toggle(w, "w:bidi", p.bidi);
toggle(w, "w:snapToGrid", p.snap_to_grid);
if p.space_before.is_some() || p.space_after.is_some() || p.line_spacing.is_some() {
if p.space_before.is_some()
|| p.space_after.is_some()
|| p.line_spacing.is_some()
|| p.space_before_auto.is_some()
|| p.space_after_auto.is_some()
{
let before = p.space_before.map(|v| twips(v.max(0.0)));
let after = p.space_after.map(|v| twips(v.max(0.0)));
let line = p.line_spacing.map(|l| match l {
@@ -273,32 +312,52 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
if let Some(b) = &before {
a.push(("w:before", b));
}
if let Some(on) = p.space_before_auto {
a.push(("w:beforeAutospacing", if on { "1" } else { "0" }));
}
if let Some(x) = &after {
a.push(("w:after", x));
}
if let Some(on) = p.space_after_auto {
a.push(("w:afterAutospacing", if on { "1" } else { "0" }));
}
if let Some((l, r)) = &line {
a.push(("w:line", l));
a.push(("w:lineRule", r));
}
w.empty("w:spacing", &a);
}
if p.indent_left.is_some() || p.indent_right.is_some() || p.indent_first.is_some() {
let chars = [p.indent_left_chars, p.indent_right_chars, p.indent_first_chars];
if p.indent_left.is_some() || p.indent_right.is_some() || p.indent_first.is_some() || chars.iter().any(Option::is_some) {
let l = p.indent_left.map(twips);
let r = p.indent_right.map(twips);
let f = p.indent_first.map(|v| (v < 0.0, twips(v.abs())));
// Character units in hundredths of a character.
let hundredths = |v: f32| ((v.clamp(-1584.0, 1584.0) * 100.0).round() as i64).to_string();
let [lc, rc, fc] = chars.map(|c| c.map(|v| (v < 0.0, hundredths(v.abs()), hundredths(v))));
let mut a: Vec<(&str, &str)> = Vec::new();
if let Some((_, _, v)) = &lc {
a.push(("w:leftChars", v));
}
if let Some(l) = &l {
a.push(("w:left", l));
}
if let Some((_, _, v)) = &rc {
a.push(("w:rightChars", v));
}
if let Some(r) = &r {
a.push(("w:right", r));
}
if let Some((hang, v, _)) = &fc {
a.push((if *hang { "w:hangingChars" } else { "w:firstLineChars" }, v));
}
if let Some((hang, v)) = &f {
a.push((if *hang { "w:hanging" } else { "w:firstLine" }, v));
}
w.empty("w:ind", &a);
}
toggle(w, "w:contextualSpacing", p.contextual_spacing);
toggle(w, "w:mirrorIndents", p.mirror_indents);
if let Some(a) = p.align {
w.val("w:jc", align_val(a));
}
@@ -478,6 +537,9 @@ fn tcpr_inner(w: &mut W, c: &CellProps) {
VAlign::Center => w.val("w:vAlign", "center"),
VAlign::Bottom => w.val("w:vAlign", "bottom"),
}
if c.hide_mark {
w.empty("w:hideMark", &[]);
}
}
/// `w:tblpPr` and `w:tblOverlap` for a floating table.
+51 -19
View File
@@ -524,33 +524,50 @@ impl Writer<'_> {
w.close("w:r");
self.rev_close(w, props);
}
// A chart or diagram read from a file: its frame from the object's size and position,
// the graphic inside as read. Ones made some other way have nothing to write, except
// charts with a model (made or edited in WordCraft).
// Editable charts and SmartArt keep main's model writer; opaque graphics and native
// ink keep their original source and related parts.
InlineObject::Graphic { w: gw, h: gh, alt, float, graphic } => {
// A chart WordCraft can edit: a chart part written from its model.
let inner = match (graphic.chart.as_deref(), graphic.source.as_deref()) {
(Some(spec), _) => self.chart_graphic(spec, rels),
(None, Some(src)) => {
let Some(inner) = self.embedded_xml(src, rels, None) else { return };
inner
}
(None, None) => return,
let ink = graphic.kind == wordcraft_doc::graphic::GraphicKind::Ink;
let root = match (graphic.chart.as_deref(), graphic.smart_art.as_deref(), graphic.source.as_deref()) {
(Some(spec), _, _) => crate::xml::parse(self.chart_graphic(spec, rels).as_bytes()).ok(),
(None, Some(spec), _) => crate::xml::parse(self.smart_art_graphic(spec, *gw, *gh, rels).as_bytes()).ok(),
(None, None, Some(src)) => self.embedded_tree(src, rels, ink.then_some((*gw, *gh, float, alt.as_str()))),
(None, None, None) => None,
};
let Some(mut root) = root else { return };
if ink {
Self::ink_transform(&mut root, (graphic.w, graphic.h), (*gw, *gh), float, 0);
}
let alternate = (ink && root.name == "mc:AlternateContent").then_some(&root);
let fallback = alternate.and_then(|e| e.child("mc:Fallback"));
let native = alternate.and_then(|e| e.child("mc:Choice")).and_then(|e| e.find("a:graphic"));
if alternate.is_some() && native.is_none() {
return;
}
self.rev_open(w, props);
w.open("w:r", &[]);
self.rpr(w, props);
if fallback.is_some() {
w.open("mc:AlternateContent", &[]);
w.open("mc:Choice", &[("Requires", "wpi")]);
}
w.open("w:drawing", &[]);
let docpr = self.next_docpr();
let name = match graphic.kind {
wordcraft_doc::graphic::GraphicKind::Chart => format!("Chart {docpr}"),
wordcraft_doc::graphic::GraphicKind::Diagram => format!("Diagram {docpr}"),
wordcraft_doc::graphic::GraphicKind::Ink => format!("Ink {docpr}"),
};
self.drawing_open(w, float, *gw, *gh, &docpr, &name, alt);
w.empty("wp:cNvGraphicFramePr", &[]);
w.raw(&inner);
w.raw(&native.unwrap_or(&root).to_xml());
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
w.close("w:drawing");
if let Some(fallback) = fallback {
w.close("mc:Choice");
w.raw(&fallback.to_xml());
w.close("mc:AlternateContent");
}
w.close("w:r");
self.rev_close(w, props);
}
@@ -1014,13 +1031,25 @@ impl Writer<'_> {
if body.anchor != wordcraft_doc::props::VAlign::Top {
attrs.push(("anchor", wordcraft_doc::props::anchor_ooxml(body.anchor)));
}
match extra.warp.as_ref().and_then(|t| t.sanitized()) {
Some(warp) => {
w.open("wps:bodyPr", &attrs);
text_warp(w, &warp);
w.close("wps:bodyPr");
// Internal margins, when the box has its own.
let inset = body.inset.map(|i| i.map(|v| v.min(wordcraft_doc::MAX_TEXT_BOX_INSET).to_string()));
if let Some(inset) = &inset {
for (n, v) in ["lIns", "tIns", "rIns", "bIns"].into_iter().zip(inset) {
attrs.push((n, v));
}
None => w.empty("wps:bodyPr", &attrs),
}
let warp = extra.warp.as_ref().and_then(|t| t.sanitized());
if warp.is_none() && !body.auto_fit {
w.empty("wps:bodyPr", &attrs);
} else {
w.open("wps:bodyPr", &attrs);
if let Some(warp) = &warp {
text_warp(w, warp);
}
if body.auto_fit {
w.empty("a:spAutoFit", &[]);
}
w.close("wps:bodyPr");
}
w.close("wps:wsp");
}
@@ -1283,6 +1312,9 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
if s.title_page {
w.empty("w:titlePg", &[]);
}
if s.text_direction.is_turned() {
w.val("w:textDirection", s.text_direction.ooxml());
}
if s.rtl {
w.empty("w:bidi", &[]);
}
@@ -1480,7 +1512,7 @@ const TOC_SDT_OPEN: &str =
r#"<w:sdt><w:sdtPr><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj></w:sdtPr><w:sdtContent>"#;
fn is_toc(instr: &str) -> bool {
instr.trim_start().get(..3).is_some_and(|k| k.eq_ignore_ascii_case("TOC"))
instr.split_whitespace().next().is_some_and(|k| k.eq_ignore_ascii_case("TOC"))
}
/// The engine's TOC: a paragraph holding an empty `TOC` field, then its TOC-styled entries.
+65 -6
View File
@@ -6,7 +6,7 @@
use quick_xml::XmlVersion;
use quick_xml::events::Event;
use quick_xml::name::ResolveResult;
use quick_xml::name::{QName, ResolveResult};
use quick_xml::reader::NsReader;
use crate::DocxError;
@@ -26,6 +26,7 @@ pub const NAMESPACES: &[(&str, &str)] = &[
("pic", "http://schemas.openxmlformats.org/drawingml/2006/picture"),
("mc", "http://schemas.openxmlformats.org/markup-compatibility/2006"),
("w14", "http://schemas.microsoft.com/office/word/2010/wordml"),
("wpi", "http://schemas.microsoft.com/office/word/2010/wordprocessingInk"),
("w15", "http://schemas.microsoft.com/office/word/2012/wordml"),
("wp14", "http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing"),
("wps", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape"),
@@ -46,6 +47,7 @@ pub const DECORATIVE_NS: &str = "http://schemas.microsoft.com/office/drawing/201
/// Namespaces that only appear in non-body parts.
const OTHER_NAMESPACES: &[(&str, &str)] = &[
("b", "http://schemas.openxmlformats.org/officeDocument/2006/bibliography"),
("rel", "http://schemas.openxmlformats.org/package/2006/relationships"),
("ct", "http://schemas.openxmlformats.org/package/2006/content-types"),
("cp", "http://schemas.openxmlformats.org/package/2006/metadata/core-properties"),
@@ -68,7 +70,10 @@ const STRICT: &[(&str, &str)] = &[
("m", "http://purl.oclc.org/ooxml/officeDocument/math"),
];
fn prefix_for(uri: &[u8]) -> &'static str {
fn prefix_for(uri: &[u8], extra: &[(&'static str, &str)]) -> &'static str {
if let Some((p, _)) = extra.iter().find(|(_, u)| u.as_bytes() == uri) {
return p;
}
for (p, u) in NAMESPACES.iter().chain(OTHER_NAMESPACES).chain(STRICT) {
if u.as_bytes() == uri {
return p;
@@ -77,6 +82,24 @@ fn prefix_for(uri: &[u8]) -> &'static str {
"?"
}
/// Compatibility feature namespaces whose elements are not otherwise handled by this tree.
/// Keep the reader's existing feature set when canonicalizing `mc:Choice/@Requires` aliases.
fn requirement_prefix(uri: &[u8]) -> &'static str {
let prefix = prefix_for(uri, &[]);
if prefix != "?" {
return prefix;
}
match uri {
b"http://schemas.microsoft.com/office/drawing/2010/main" => "a14",
b"http://schemas.microsoft.com/office/word/2015/wordml/symex" => "w16se",
b"http://schemas.microsoft.com/office/word/2016/wordml/cid" => "w16cid",
b"http://schemas.microsoft.com/office/word/2018/wordml" => "w16",
b"http://schemas.microsoft.com/office/word/2018/wordml/cex" => "w16cex",
b"http://schemas.microsoft.com/office/word/2020/wordml/sdtdatahash" => "w16sdtdh",
_ => "?",
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum Node {
El(El),
@@ -210,10 +233,9 @@ impl El {
}
}
/// Parse XML bytes into the root element.
/// UTF-16 LE/BE text with a byte-order mark as UTF-8 (`None` without one). Lossy: an unpaired
/// surrogate becomes U+FFFD and a trailing odd byte is dropped. The result is at most 1.5× the input.
fn utf16_to_utf8(bytes: &[u8]) -> Option<String> {
pub(crate) fn utf16_to_utf8(bytes: &[u8]) -> Option<String> {
let (rest, le) = match bytes.get(..2) {
Some([0xFF, 0xFE]) => (bytes.get(2..)?, true),
Some([0xFE, 0xFF]) => (bytes.get(2..)?, false),
@@ -223,7 +245,14 @@ fn utf16_to_utf8(bytes: &[u8]) -> Option<String> {
Some(char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect())
}
/// Parse XML bytes into the root element.
pub fn parse(bytes: &[u8]) -> Result<El, DocxError> {
parse_ns(bytes, &[])
}
/// [`parse`] with `extra` namespaces (prefix, URI) mapped to canonical prefixes first, so a
/// related vocabulary (WordprocessingML 2003) reads with the prefixes the reader knows.
pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result<El, DocxError> {
// UTF-16 (with a BOM) is transcoded to UTF-8; a UTF-8 BOM is stripped.
let transcoded = utf16_to_utf8(bytes);
let bytes = match &transcoded {
@@ -246,7 +275,7 @@ pub fn parse(bytes: &[u8]) -> Result<El, DocxError> {
buf.clear();
let (res, ev) = r.read_resolved_event_into(&mut buf).map_err(|e| DocxError::Xml(e.to_string()))?;
let el_prefix = match &res {
ResolveResult::Bound(ns) => Some(prefix_for(ns.as_ref())),
ResolveResult::Bound(ns) => Some(prefix_for(ns.as_ref(), extra)),
_ => None,
};
match ev {
@@ -271,13 +300,35 @@ pub fn parse(bytes: &[u8]) -> Result<El, DocxError> {
let (ares, alocal) = r.resolver_mut().resolve_attribute(key);
let alocal = String::from_utf8_lossy(alocal.as_ref()).into_owned();
let aname = match ares {
ResolveResult::Bound(ns) => format!("{}:{alocal}", prefix_for(ns.as_ref())),
ResolveResult::Bound(ns) => format!("{}:{alocal}", prefix_for(ns.as_ref(), extra)),
_ => alocal,
};
let val = match a.normalized_value(XmlVersion::Explicit1_0) {
Ok(v) => v.into_owned(),
Err(_) => String::from_utf8_lossy(&a.value).into_owned(),
};
// Requires contains namespace prefixes, not feature names. Resolve them while
// the namespace scope still exists; declarations are omitted from this tree.
let val = if el.name == "mc:Choice" && aname == "Requires" {
let requirements: Vec<_> = val.split_whitespace().take(65).collect();
if requirements.len() > 64 || requirements.iter().any(|prefix| prefix.len() > 128) {
"?".to_string()
} else {
requirements
.into_iter()
.map(|prefix| {
let qualified = format!("{prefix}:requirement");
match r.resolver_mut().resolve_attribute(QName(qualified.as_bytes())).0 {
ResolveResult::Bound(ns) => requirement_prefix(ns.as_ref()),
_ => "?",
}
})
.collect::<Vec<_>>()
.join(" ")
}
} else {
val
};
el.attrs.push((aname, val));
}
if matches!(ev, Event::Start(_)) {
@@ -479,6 +530,14 @@ mod tests {
assert_eq!(t.attr("xml:space"), Some("preserve"));
}
#[test]
fn choice_requirements_resolve_namespace_aliases_and_rebindings() {
let x = br#"<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:pen="http://schemas.microsoft.com/office/word/2010/wordprocessingInk"><mc:Choice Requires="pen"/><mc:Choice xmlns:pen="urn:future" Requires="pen"/><mc:Choice Requires="undeclared"/></mc:AlternateContent>"#;
let e = parse(x).unwrap();
let values: Vec<_> = e.children("mc:Choice").map(|c| c.attr("Requires")).collect();
assert_eq!(values, [Some("wpi"), Some("?"), Some("?")]);
}
#[test]
fn deep_nesting_is_error() {
let mut s = String::new();
+2 -2
View File
@@ -114,8 +114,8 @@ fn reads_word_style_bidi_markup() {
assert_eq!(fa.font.as_deref(), Some("Times New Roman"));
assert_eq!(fa.font_cs.as_deref(), Some("B Lotus"));
assert_eq!((fa.size, fa.size_cs), (Some(12.0), Some(16.0)));
// b + bCs alike is stored once: an unset complex-script bold is the same as `bold`.
assert_eq!((fa.bold, fa.bold_cs, fa.rtl), (Some(true), None, Some(true)));
// Both explicit values survive, even when they happen to be equal.
assert_eq!((fa.bold, fa.bold_cs, fa.rtl), (Some(true), Some(true), Some(true)));
assert_eq!((fa.lang.as_deref(), fa.lang_bidi.as_deref()), (Some("en-US"), Some("fa-IR")));
assert_eq!(ps[1].props.bidi, Some(false));
let x = &ps[1].runs[0].props;
+82
View File
@@ -0,0 +1,82 @@
//! Indents in character units (#534), common in Chinese documents: ECMA-376 Part 1 §17.3.1.12
//! `w:ind` `w:firstLineChars`, `w:hangingChars`, `w:leftChars` and `w:rightChars`, in hundredths
//! of a character. Fixtures are hand-written.
use std::io::{Read, Write};
use wordcraft_doc::Document;
use wordcraft_doc::props::ParaProps;
fn zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in entries {
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
/// A package whose body is `body`.
fn docx(body: &str) -> Vec<u8> {
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
);
zip(&[("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())])
}
fn props(d: &Document) -> Vec<ParaProps> {
d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.clone()).collect()
}
#[test]
fn character_indents_read_and_round_trip() {
let body = r#"<w:p><w:pPr><w:ind w:firstLineChars="200" w:firstLine="420"/></w:pPr><w:r><w:t>一</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:leftChars="150" w:left="315" w:rightChars="50" w:right="105" w:hangingChars="100" w:hanging="210"/></w:pPr><w:r><w:t>二</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:firstLineChars="0" w:firstLine="240"/></w:pPr><w:r><w:t>三</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:firstLineChars="99999999" w:leftChars="junk"/></w:pPr><w:r><w:t>四</w:t></w:r></w:p>"#;
let d = wordcraft_docx::read(&docx(body)).unwrap();
let p = props(&d);
assert_eq!((p[0].indent_first_chars, p[0].indent_first), (Some(2.0), Some(21.0)));
assert_eq!((p[1].indent_left_chars, p[1].indent_right_chars, p[1].indent_first_chars), (Some(1.5), Some(0.5), Some(-1.0)));
// Zero cancels an inherited value; the points apply.
assert_eq!((p[2].indent_first_chars, p[2].indent_first), (Some(0.0), Some(12.0)));
// Hostile values are clamped or dropped.
assert_eq!((p[3].indent_first_chars, p[3].indent_left_chars), (Some(1584.0), None));
let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_docx::write(&d).unwrap())).unwrap();
let mut xml = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
for attr in [r#"w:firstLineChars="200""#, r#"w:leftChars="150""#, r#"w:rightChars="50""#, r#"w:hangingChars="100""#, r#"w:firstLineChars="0""#] {
assert!(xml.contains(attr), "{attr} missing: {xml}");
}
let again = props(&wordcraft_docx::read(&docx(&xml[xml.find("<w:body>").unwrap() + 8..xml.find("</w:body>").unwrap()])).unwrap());
assert_eq!(again[..3], p[..3]);
}
/// HTML-style automatic paragraph spacing (`w:beforeAutospacing`, `w:afterAutospacing`, ECMA-376
/// §17.3.1.33), common in text pasted from web pages: read, "0" switching an inherited one off,
/// and written back.
#[test]
fn automatic_spacing_reads_and_round_trips() {
let body = r#"<w:p><w:pPr><w:spacing w:before="100" w:beforeAutospacing="1" w:after="100" w:afterAutospacing="1"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>
<w:p><w:pPr><w:spacing w:before="0" w:beforeAutospacing="0" w:after="100" w:afterAutospacing="true"/></w:pPr><w:r><w:t>b</w:t></w:r></w:p>
<w:p><w:pPr><w:spacing w:after="100"/></w:pPr><w:r><w:t>c</w:t></w:r></w:p>"#;
let d = wordcraft_docx::read(&docx(body)).unwrap();
let p = props(&d);
assert_eq!((p[0].space_before_auto, p[0].space_after_auto, p[0].space_before), (Some(true), Some(true), Some(5.0)));
assert_eq!((p[1].space_before_auto, p[1].space_after_auto), (Some(false), Some(true)));
assert_eq!((p[2].space_before_auto, p[2].space_after_auto), (None, None));
let rp = d.styles.resolve_para(&p[0]);
assert_eq!((rp.space_before, rp.space_after, rp.before_auto), (14.0, 14.0, true), "automatic spacing is 14 pt");
assert_eq!(d.styles.resolve_para(&p[2]).space_after, 5.0);
let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_docx::write(&d).unwrap())).unwrap();
let mut xml = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
for attr in [r#"w:beforeAutospacing="1""#, r#"w:afterAutospacing="1""#, r#"w:beforeAutospacing="0""#] {
assert!(xml.contains(attr), "{attr} missing");
}
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(props(&again)[..3], p[..3]);
}
@@ -0,0 +1,135 @@
//! Independent complex-script formatting through style inheritance and DOCX round trips.
//! Synthetic packages, written here; no external documents or installed fonts are needed.
use std::io::Write;
use wordcraft_doc::{Document, Paragraph};
fn package(run: &str, defaults: &str, styles: &str, paragraph_style: &str) -> Vec<u8> {
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#;
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="s1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
let document = format!(
r#"<w:document {ns}><w:body><w:p><w:pPr>{paragraph_style}<w:bidi/></w:pPr><w:r><w:rPr>{run}</w:rPr><w:t>שָׁלוֹם ABC 123</w:t></w:r></w:p></w:body></w:document>"#
);
let styles = format!("<w:styles {ns}><w:docDefaults><w:rPrDefault><w:rPr>{defaults}</w:rPr></w:rPrDefault></w:docDefaults>{styles}</w:styles>");
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, data) in [
("_rels/.rels", root),
("word/_rels/document.xml.rels", rels),
("word/document.xml", document.as_str()),
("word/styles.xml", styles.as_str()),
] {
z.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
z.write_all(data.as_bytes()).unwrap();
}
z.finish().unwrap().into_inner()
}
fn paragraph(d: &Document) -> &Paragraph {
d.body.iter().find_map(|b| b.as_para()).unwrap()
}
fn check_generations(bytes: &[u8], check: impl Fn(&Document)) {
let mut d = wordcraft_docx::read(bytes).unwrap();
for _ in 0..3 {
check(&d);
let next = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(paragraph(&next).text, paragraph(&d).text);
assert_eq!(paragraph(&next).runs, paragraph(&d).runs);
assert_eq!(paragraph(&next).props.bidi, Some(true));
d = next;
}
}
#[test]
fn separate_hebrew_font_size_language_and_flags_survive() {
let run = r#"<w:rFonts w:ascii="Source Serif 4" w:hAnsi="Source Serif 4" w:cs="Arial Hebrew"/><w:b w:val="0"/><w:bCs/><w:i w:val="0"/><w:iCs/><w:sz w:val="24"/><w:szCs w:val="48"/><w:rtl/><w:cs/><w:lang w:val="en-US" w:bidi="he-IL"/>"#;
check_generations(&package(run, "", "", ""), |d| {
let c = &paragraph(d).runs[0].props;
assert_eq!((c.font.as_deref(), c.font_cs.as_deref()), (Some("Source Serif 4"), Some("Arial Hebrew")));
assert_eq!((c.size, c.size_cs), (Some(12.0), Some(24.0)));
assert_eq!((c.bold, c.bold_cs, c.italic, c.italic_cs), (Some(false), Some(true), Some(false), Some(true)));
assert_eq!((c.rtl, c.cs), (Some(true), Some(true)));
assert_eq!((c.lang.as_deref(), c.lang_bidi.as_deref()), (Some("en-US"), Some("he-IL")));
});
}
#[test]
fn latin_direct_properties_leave_complex_script_defaults_inherited() {
let defaults = r#"<w:szCs w:val="36"/><w:bCs/><w:iCs/>"#;
let run = r#"<w:sz w:val="20"/><w:b w:val="0"/><w:i w:val="0"/>"#;
check_generations(&package(run, defaults, "", ""), |d| {
let p = paragraph(d);
let c = &p.runs[0].props;
assert_eq!((c.size_cs, c.bold_cs, c.italic_cs), (None, None, None), "Absent direct values must remain absent when saved");
let resolved = d.styles.resolve_char(p.props.style.as_deref(), c);
assert_eq!((resolved.size, resolved.bold, resolved.italic), (10.0, false, false));
let cs = resolved.complex();
assert_eq!((cs.size, cs.bold, cs.italic), (18.0, true, true));
});
}
#[test]
fn latin_style_properties_leave_complex_script_style_chain_inherited() {
let styles = r#"
<w:style w:type="paragraph" w:styleId="HebrewBase"><w:name w:val="HebrewBase"/><w:rPr><w:rFonts w:cs="Arial Hebrew"/><w:szCs w:val="36"/><w:bCs/><w:iCs/><w:lang w:bidi="he-IL"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="MixedChild"><w:name w:val="MixedChild"/><w:basedOn w:val="HebrewBase"/><w:rPr><w:sz w:val="24"/><w:b w:val="0"/><w:i w:val="0"/></w:rPr></w:style>
<w:style w:type="character" w:styleId="LatinOnly"><w:name w:val="LatinOnly"/><w:rPr><w:sz w:val="20"/><w:b w:val="0"/><w:i w:val="0"/></w:rPr></w:style>"#;
let run = r#"<w:rStyle w:val="LatinOnly"/><w:rFonts w:ascii="Source Serif 4" w:hAnsi="Source Serif 4"/>"#;
check_generations(&package(run, "", styles, r#"<w:pStyle w:val="MixedChild"/>"#), |d| {
let p = paragraph(d);
let c = d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props);
assert_eq!((c.font.as_str(), c.size, c.bold, c.italic), ("Source Serif 4", 10.0, false, false));
let cs = c.complex();
assert_eq!((cs.font.as_str(), cs.size, cs.bold, cs.italic), ("Arial Hebrew", 18.0, true, true));
assert_eq!(d.styles.get("HebrewBase").unwrap().chr.lang_bidi.as_deref(), Some("he-IL"));
});
}
#[test]
fn equal_explicit_complex_script_values_are_not_discarded() {
let run = r#"<w:sz w:val="20"/><w:szCs w:val="20"/><w:b w:val="0"/><w:bCs w:val="0"/><w:i w:val="0"/><w:iCs w:val="0"/>"#;
let defaults = r#"<w:szCs w:val="36"/><w:bCs/><w:iCs/>"#;
check_generations(&package(run, defaults, "", ""), |d| {
let p = paragraph(d);
let c = &p.runs[0].props;
assert_eq!((c.size_cs, c.bold_cs, c.italic_cs), (Some(10.0), Some(false), Some(false)));
let cs = d.styles.resolve_char(p.props.style.as_deref(), c).complex();
assert_eq!((cs.size, cs.bold, cs.italic), (10.0, false, false));
});
}
#[test]
fn complex_script_direct_properties_leave_latin_defaults_inherited() {
let run = r#"<w:szCs w:val="20"/><w:bCs w:val="0"/><w:iCs w:val="0"/><w:rtl w:val="0"/><w:cs w:val="0"/>"#;
let defaults = r#"<w:sz w:val="36"/><w:b/><w:i/>"#;
check_generations(&package(run, defaults, "", ""), |d| {
let p = paragraph(d);
let c = &p.runs[0].props;
assert_eq!((c.size, c.bold, c.italic), (None, None, None));
assert_eq!((c.rtl, c.cs), (Some(false), Some(false)));
let resolved = d.styles.resolve_char(p.props.style.as_deref(), c);
assert_eq!((resolved.size, resolved.bold, resolved.italic), (18.0, true, true));
let cs = resolved.complex();
assert_eq!((cs.size, cs.bold, cs.italic), (10.0, false, false));
});
}
#[test]
fn tracked_formatting_keeps_independent_complex_script_values() {
let defaults = r#"<w:szCs w:val="36"/><w:bCs/><w:iCs/>"#;
let run = r#"<w:sz w:val="24"/><w:b w:val="0"/><w:i w:val="0"/><w:rPrChange w:id="7" w:author="Synthetic Author" w:date="2026-10-11T00:00:00Z"><w:rPr><w:sz w:val="20"/><w:szCs w:val="20"/><w:b w:val="0"/><w:bCs w:val="0"/><w:i w:val="0"/><w:iCs w:val="0"/></w:rPr></w:rPrChange>"#;
check_generations(&package(run, defaults, "", ""), |d| {
let p = paragraph(d);
let c = &p.runs[0].props;
let current = d.styles.resolve_char(None, c).complex();
assert_eq!((current.size, current.bold, current.italic), (18.0, true, true));
let change = c.fmt_change.as_ref().unwrap();
assert_eq!((change.old.size_cs, change.old.bold_cs, change.old.italic_cs), (Some(10.0), Some(false), Some(false)));
let old = d.styles.resolve_char(None, &change.old).complex();
assert_eq!((old.size, old.bold, old.italic), (10.0, false, false));
let mut overlaid = wordcraft_doc::props::CharProps::default();
overlaid.overlay(c);
assert_eq!(overlaid.fmt_change, c.fmt_change);
});
}
+179
View File
@@ -0,0 +1,179 @@
//! Original synthetic custom XML stores, written from the public OOXML package contract.
use std::collections::BTreeMap;
use std::io::{Cursor, Read, Write};
use wordcraft_doc::{Path, StoryRef};
const REL_NS: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
const RT: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/";
const DATA: &str =
"<?xml version=\"1.0\"?><c:customer xmlns:c=\"urn:original:customer\"><c:name>Atlas Studios</c:name><c:ref>CASE-17</c:ref></c:customer>";
const PROPS: &str = r#"<ds:datastoreItem xmlns:ds="http://schemas.openxmlformats.org/officeDocument/2006/customXml" ds:itemID="{22222222-2222-2222-2222-222222222222}"><ds:schemaRefs><ds:schemaRef ds:uri="urn:original:customer"/></ds:schemaRefs></ds:datastoreItem>"#;
fn rels(rows: &[(&str, &str, &str, bool)]) -> String {
let mut out = format!(r#"<Relationships xmlns="{REL_NS}">"#);
for (id, kind, target, external) in rows {
let mode = if *external { r#" TargetMode="External""# } else { "" };
out.push_str(&format!(r#"<Relationship Id="{id}" Type="{RT}{kind}" Target="{target}"{mode}/>"#));
}
out + "</Relationships>"
}
fn fixture() -> BTreeMap<String, Vec<u8>> {
let doc = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Independent edit.</w:t></w:r></w:p><w:p><w:sdt><w:sdtPr><w:id w:val="42"/><w:dataBinding w:xpath="/c:customer/c:name" w:prefixMappings="xmlns:c='urn:original:customer'" w:storeItemID="{22222222-2222-2222-2222-222222222222}"/><w:text/></w:sdtPr><w:sdtContent><w:r><w:t>Atlas Studios</w:t></w:r></w:sdtContent></w:sdt></w:p></w:body></w:document>"#;
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/customXml/itemProps1.xml" ContentType="application/vnd.openxmlformats-officedocument.customXmlProperties+xml"/></Types>"#;
[
("[Content_Types].xml", types.to_string()),
("_rels/.rels", rels(&[("office", "officeDocument", "word/document.xml", false)])),
("word/document.xml", doc.to_string()),
("word/_rels/document.xml.rels", rels(&[("data", "customXml", "../customXml/item1.xml", false)])),
("customXml/item1.xml", DATA.to_string()),
("customXml/itemProps1.xml", PROPS.to_string()),
("customXml/_rels/item1.xml.rels", rels(&[("properties", "customXmlProps", "itemProps1.xml", false)])),
]
.into_iter()
.map(|(p, s)| (p.into(), s.into_bytes()))
.collect()
}
fn zip(parts: &BTreeMap<String, Vec<u8>>) -> Vec<u8> {
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
for (name, bytes) in parts {
z.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
z.write_all(bytes).unwrap();
}
z.finish().unwrap().into_inner()
}
fn unzip(bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
let mut parts = BTreeMap::new();
for i in 0..z.len() {
let mut f = z.by_index(i).unwrap();
let name = f.name().to_string();
let mut bytes = Vec::new();
f.read_to_end(&mut bytes).unwrap();
assert!(parts.insert(name, bytes).is_none(), "duplicate ZIP entry");
}
parts
}
fn text(parts: &BTreeMap<String, Vec<u8>>, name: &str) -> String {
String::from_utf8(parts[name].clone()).unwrap()
}
fn edited_cycles(input: &BTreeMap<String, Vec<u8>>, check: impl Fn(&BTreeMap<String, Vec<u8>>)) {
let mut doc = wordcraft_docx::read(&zip(input)).unwrap();
for _ in 0..2 {
doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap().insert_text(0, "Unrelated edit. ", &Default::default()).unwrap();
let bytes = wordcraft_docx::write(&doc).unwrap();
check(&unzip(&bytes));
doc = wordcraft_docx::read(&bytes).unwrap();
}
}
#[test]
fn bound_store_bytes_properties_and_relationships_survive_two_edited_saves() {
edited_cycles(&fixture(), |p| {
let document = text(p, "word/document.xml");
assert!(document.contains("<w:dataBinding"), "the control keeps its store binding");
assert!(document.contains(r#"w:storeItemID="{22222222-2222-2222-2222-222222222222}""#));
assert!(document.contains(r#"w:xpath="/c:customer/c:name""#));
assert_eq!(p.get("customXml/item1.xml").map(Vec::as_slice), Some(DATA.as_bytes()));
assert_eq!(p.get("customXml/itemProps1.xml").map(Vec::as_slice), Some(PROPS.as_bytes()));
assert!(text(p, "word/_rels/document.xml.rels").contains(r#"Target="../customXml/item1.xml""#));
assert!(text(p, "customXml/_rels/item1.xml.rels").contains(r#"Id="properties""#));
assert!(text(p, "customXml/_rels/item1.xml.rels").contains(r#"Target="itemProps1.xml""#));
assert!(text(p, "[Content_Types].xml").contains("application/vnd.openxmlformats-officedocument.customXmlProperties+xml"));
});
}
#[test]
fn package_and_document_roots_share_parts_and_keep_multiple_stores() {
let mut p = fixture();
p.insert(
"_rels/.rels".into(),
rels(&[("office", "officeDocument", "word/document.xml", false), ("data", "customXml", "customXml/item1.xml", false)]).into_bytes(),
);
p.insert(
"word/_rels/document.xml.rels".into(),
rels(&[("one", "customXml", "../customXml/item1.xml", false), ("two", "customXml", "../customXml/other.xml", false)]).into_bytes(),
);
p.insert("customXml/other.xml".into(), b"<other>Independent store</other>".to_vec());
p.insert("customXml/_rels/other.xml.rels".into(), rels(&[("shared", "customXmlProps", "itemProps1.xml", false)]).into_bytes());
// A cycle must not cause repeated parts, and an external schema stays external.
p.insert(
"customXml/_rels/itemProps1.xml.rels".into(),
rels(&[("cycle", "customXml", "item1.xml", false), ("schema", "schema", "https://example.invalid/schema", true)]).into_bytes(),
);
edited_cycles(&p, |out| {
assert_eq!(out.get("customXml/item1.xml").map(Vec::as_slice), Some(DATA.as_bytes()));
assert_eq!(out.get("customXml/other.xml"), p.get("customXml/other.xml"));
assert!(text(out, "_rels/.rels").contains(r#"Target="customXml/item1.xml""#));
assert!(text(out, "word/_rels/document.xml.rels").contains(r#"Target="../customXml/other.xml""#));
assert!(text(out, "customXml/_rels/itemProps1.xml.rels").contains(r#"TargetMode="External""#));
assert!(text(out, "customXml/_rels/other.xml.rels").contains(r#"Target="itemProps1.xml""#));
});
}
#[test]
fn absent_and_external_stores_are_not_fetched_or_written() {
let mut p = fixture();
p.insert(
"word/_rels/document.xml.rels".into(),
rels(&[
("missing", "customXml", "../customXml/missing.xml", false),
("external", "customXml", "https://example.invalid/store.xml", true),
("valid", "customXml", "../customXml/item1.xml", false),
])
.into_bytes(),
);
p.insert("customXml/_rels/item1.xml.rels".into(), rels(&[("missing", "customXmlProps", "missing.xml", false)]).into_bytes());
edited_cycles(&p, |out| {
assert_eq!(out.get("customXml/item1.xml").map(Vec::as_slice), Some(DATA.as_bytes()));
let roots = text(out, "word/_rels/document.xml.rels");
assert!(!roots.contains("missing.xml"));
assert!(!roots.contains("example.invalid"));
assert!(!out.contains_key("customXml/_rels/item1.xml.rels"));
});
}
#[test]
fn custom_stores_do_not_shadow_reserved_bibliography_names() {
let mut doc = wordcraft_docx::read(&zip(&fixture())).unwrap();
doc.sources.push(wordcraft_doc::Source { tag: "OriginalSource".into(), title: "Original synthetic reference".into(), ..Default::default() });
let out = unzip(&wordcraft_docx::write(&doc).unwrap());
assert_eq!(out.get("customXml/item1_2.xml").map(Vec::as_slice), Some(DATA.as_bytes()));
assert_eq!(out.get("customXml/itemProps1_2.xml").map(Vec::as_slice), Some(PROPS.as_bytes()));
assert!(text(&out, "word/_rels/document.xml.rels").contains(r#"Target="../customXml/item1_2.xml""#));
assert!(text(&out, "customXml/_rels/item1_2.xml.rels").contains(r#"Target="itemProps1_2.xml""#));
}
#[test]
fn opaque_non_utf8_data_and_strict_relationship_types_are_preserved() {
let mut p = fixture();
let data: Vec<u8> =
"<?xml version=\"1.0\" encoding=\"UTF-16\"?><customer>Atlas Studios</customer>".encode_utf16().flat_map(u16::to_le_bytes).collect();
let mut bytes = vec![0xff, 0xfe];
bytes.extend(data);
p.insert("customXml/item1.xml".into(), bytes.clone());
let strict =
rels(&[("strict", "customXml", "../customXml/item1.xml", false)]).replace(RT, "http://purl.oclc.org/ooxml/officeDocument/relationships/");
p.insert("word/_rels/document.xml.rels".into(), strict.into_bytes());
edited_cycles(&p, |out| {
assert_eq!(out.get("customXml/item1.xml"), Some(&bytes));
assert!(text(out, "word/_rels/document.xml.rels").contains("http://purl.oclc.org/ooxml/officeDocument/relationships/customXml"));
});
}
#[test]
fn reserved_part_targets_are_renamed_instead_of_shadowing_or_looping() {
let mut p = fixture();
p.insert("word/_rels/document.xml.rels".into(), rels(&[("hostile", "customXml", "../docProps/foreign.xml", false)]).into_bytes());
p.insert("docProps/foreign.xml".into(), DATA.as_bytes().to_vec());
edited_cycles(&p, |out| {
assert!(out.values().any(|b| b == DATA.as_bytes()));
assert!(!out.contains_key("docProps/foreign.xml"));
assert!(text(out, "word/_rels/document.xml.rels").contains(r#"Target="embeddings/part.xml""#));
assert!(text(out, "docProps/core.xml").contains("coreProperties"));
});
}
+785 -3
View File
@@ -47,10 +47,79 @@ fn read_body(body: &str) -> Document {
wordcraft_docx::read(&docx(body, &[], &[])).unwrap()
}
#[test]
fn paragraph_default_presence_survives_repeated_saves() {
// Omission leaves paragraph defaults application-defined (ISO 29500 pPrDefault).
// An explicitly empty container must therefore remain distinct from omission.
for (defaults, present, after) in [
("", false, None),
("<w:docDefaults/>", false, None),
("<w:docDefaults><w:rPrDefault><w:rPr><w:sz w:val=\"22\"/></w:rPr></w:rPrDefault></w:docDefaults>", false, None),
("<w:docDefaults><w:pPrDefault/></w:docDefaults>", true, None),
("<w:docDefaults><w:pPrDefault><w:pPr/></w:pPrDefault></w:docDefaults>", true, None),
("<w:docDefaults><w:pPrDefault><w:pPr><w:spacing w:after=\"160\"/></w:pPr></w:pPrDefault></w:docDefaults>", true, Some(8.0)),
] {
let styles = format!(
r#"<w:styles {W_NS}>{defaults}<w:style w:type="paragraph" w:styleId="Normal" w:default="1"><w:name w:val="Normal"/></w:style></w:styles>"#
);
let mut d = wordcraft_docx::read(&docx(
"<w:p><w:r><w:t>First</w:t></w:r></w:p><w:p><w:r><w:t>Second</w:t></w:r></w:p>",
&[("rId1", "styles", "styles.xml")],
&[("word/styles.xml", &styles)],
))
.unwrap();
for _ in 0..2 {
let bytes = wordcraft_docx::write(&d).unwrap();
let xml = text(&unzip(&bytes), "word/styles.xml");
assert_eq!(xml.contains("<w:pPrDefault>"), present, "{defaults}");
d = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(d.styles.default_para.space_after, after);
assert_eq!(paras(&d).iter().map(|p| p.text.as_str()).collect::<Vec<_>>(), ["First", "Second"]);
}
}
}
#[test]
fn omitted_paragraph_defaults_can_be_edited() {
let styles =
format!(r#"<w:styles {W_NS}><w:style w:type="paragraph" w:styleId="Normal" w:default="1"><w:name w:val="Normal"/></w:style></w:styles>"#);
let mut d = wordcraft_docx::read(&docx("<w:p/>", &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
assert!(!d.styles.default_para_present);
d.styles.default_para.space_after = Some(12.0);
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert!(again.styles.default_para_present);
assert_eq!(again.styles.default_para.space_after, Some(12.0));
let new = Document::new();
assert_eq!(wordcraft_docx::read(&wordcraft_docx::write(&new).unwrap()).unwrap().styles.default_para, new.styles.default_para);
}
fn paras(d: &Document) -> Vec<&Paragraph> {
d.body.iter().filter_map(|b| b.as_para()).collect()
}
#[test]
fn single_entry_toc_keeps_formatted_result_and_nested_page_field() {
let d = read_body(
r#"
<w:p><w:pPr><w:pStyle w:val="TOCHeading"/></w:pPr><w:r><w:t>Contents</w:t></w:r></w:p>
<w:p><w:pPr><w:pStyle w:val="TOC1"/></w:pPr>
<w:r><w:fldChar w:fldCharType="begin" w:fldLock="true"/></w:r>
<w:r><w:instrText> TOC \o "1-1" \h </w:instrText></w:r>
<w:r><w:fldChar w:fldCharType="separate"/></w:r>
<w:hyperlink w:anchor="_Toc1"><w:r><w:rPr><w:b/></w:rPr><w:t>Intro</w:t></w:r>
<w:r><w:tab/></w:r><w:fldSimple w:instr=" PAGEREF _Toc1 \h "><w:r><w:t>1</w:t></w:r></w:fldSimple></w:hyperlink>
<w:r><w:fldChar w:fldCharType="end"/></w:r>
</w:p>"#,
);
let p = paras(&d);
assert_eq!(p[0].plain_text(), "Contents");
assert_eq!(p[0].objects, vec![InlineObject::Field { instr: r#"TOC \o "1-1" \h"#.into(), result: String::new(), locked: true, code: false }]);
assert_eq!(p[1].plain_text(), "Intro\t1");
assert_eq!(p[1].props_of_char(0).bold, Some(true));
assert_eq!(p[1].props_of_char(0).link.as_deref(), Some("#_Toc1"));
assert_eq!(p[1].objects, vec![InlineObject::Field { instr: r#"PAGEREF _Toc1 \h"#.into(), result: "1".into(), locked: false, code: false }]);
}
#[test]
fn toc_complex_field_spanning_paragraphs() {
let body = r#"
@@ -66,13 +135,13 @@ fn toc_complex_field_spanning_paragraphs() {
let p = paras(&d);
assert_eq!(p.len(), 5);
assert_eq!(p[0].text, "\u{FFFC}Intro\t\u{FFFC}");
assert_eq!(p[0].objects[0], InlineObject::Field { instr: r#"TOC \o "1-3" \h \z \u"#.into(), result: String::new(), locked: false, code: false });
assert_eq!(p[0].objects[0], InlineObject::FieldStart { instr: r#"TOC \o "1-3" \h \z \u"#.into(), locked: false });
assert_eq!(p[0].objects[1], InlineObject::Field { instr: r#"PAGEREF _Toc1 \h"#.into(), result: "1".into(), locked: false, code: false });
assert_eq!(p[0].props_of_char(3).link.as_deref(), Some("#_Toc1"));
assert_eq!(p[0].props.style.as_deref(), Some("TOC1"));
assert_eq!(p[1].text, "Body\t2");
assert_eq!(p[1].props_of_char(0).link.as_deref(), Some("#_Toc2"));
assert_eq!(p[2].text, "");
assert_eq!(p[2].objects, vec![InlineObject::FieldEnd]);
assert_eq!(p[3].objects, vec![InlineObject::BookmarkStart { name: "_Toc1".into() }, InlineObject::BookmarkEnd { name: "_Toc1".into() }]);
assert_eq!(p[4].plain_text(), "Page 7");
assert_eq!(p[4].objects[0], InlineObject::Field { instr: "PAGE".into(), result: "7".into(), locked: false, code: false });
@@ -109,6 +178,31 @@ fn hyperlink_runs_and_wrappers() {
assert_eq!(p[1].plain_text(), "in block sdt");
}
#[test]
fn symbol_font_text_reads_as_unicode_and_round_trips_unchanged() {
// Wingdings stored both ways Word writes it: `w:sym` (private-use code) and a plain code in a
// run whose font is Wingdings; plus Symbol and Webdings.
let body = r#"
<w:p>
<w:r><w:t xml:space="preserve">Done </w:t></w:r>
<w:r><w:sym w:font="Wingdings" w:char="F0FC"/></w:r>
<w:r><w:rPr><w:rFonts w:ascii="Wingdings" w:hAnsi="Wingdings"/></w:rPr><w:t>J</w:t></w:r>
<w:r><w:sym w:font="Symbol" w:char="F061"/><w:sym w:font="Webdings" w:char="F061"/></w:r>
</w:p>"#;
let d = wordcraft_docx::read(&docx(body, &[], &[])).unwrap();
let p = paras(&d);
// Stored as Word stores it…
assert_eq!(p[0].plain_text(), "Done \u{F0FC}J\u{F061}\u{F061}");
// …read (copied, exported as text) as the symbols shown.
assert_eq!(p[0].shown_text(), "Done \u{2713}\u{263A}\u{3B1}\u{2714}");
assert_eq!(d.shown_text(wordcraft_doc::StoryRef::Body), "Done \u{2713}\u{263A}\u{3B1}\u{2714}");
// A round trip keeps the original characters and fonts.
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
let q = paras(&again);
assert_eq!(q[0].plain_text(), p[0].plain_text());
assert_eq!(q[0].shown_text(), p[0].shown_text());
}
#[test]
fn alternate_content_choice_and_fallback() {
let body = r#"
@@ -1077,13 +1171,95 @@ fn formatting_revisions_round_trip() {
assert!(xml.contains(parent), "{tag} last: {xml}");
}
assert!(xml.contains(r#"<w:numberingChange w:id="#) && xml.contains(r#"w:original="1."/></w:numPr>"#), "{xml}");
assert!(xml.contains(r#"<w:b/><w:bCs/><w:rPrChange w:id="#), "{xml}");
assert!(xml.contains(r#"<w:b/><w:rPrChange w:id="#), "{xml}");
assert!(!xml.contains("<w:bCs"), "Saving must not add an absent complex-script bold: {xml}");
let again = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(again.body, d.body);
assert_eq!(again.last_section, d.last_section);
assert_eq!(again.revisions, d.revisions);
}
/// A package with `styles`, a theme (accent1 4472C4, accent2 ED7D31, major font "Major Face") and,
/// when given, `settings`.
fn themed_docx(body: &str, styles: &str, settings: Option<&str>) -> Document {
let theme = r#"<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="T"><a:themeElements><a:clrScheme name="T"><a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1><a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1><a:dk2><a:srgbClr val="44546A"/></a:dk2><a:lt2><a:srgbClr val="E7E6E6"/></a:lt2><a:accent1><a:srgbClr val="4472C4"/></a:accent1><a:accent2><a:srgbClr val="ED7D31"/></a:accent2><a:accent3><a:srgbClr val="A5A5A5"/></a:accent3><a:accent4><a:srgbClr val="FFC000"/></a:accent4><a:accent5><a:srgbClr val="5B9BD5"/></a:accent5><a:accent6><a:srgbClr val="70AD47"/></a:accent6><a:hlink><a:srgbClr val="0563C1"/></a:hlink><a:folHlink><a:srgbClr val="954F72"/></a:folHlink></a:clrScheme><a:fontScheme name="T"><a:majorFont><a:latin typeface="Major Face"/></a:majorFont><a:minorFont><a:latin typeface="Minor Face"/></a:minorFont></a:fontScheme></a:themeElements></a:theme>"#;
let styles = format!(
r#"<w:styles {W_NS}><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>{styles}</w:styles>"#
);
let mut doc_rels = vec![("rId1", "styles", "styles.xml"), ("rId2", "theme", "theme/theme1.xml")];
let mut extra = vec![("word/styles.xml", styles.as_str()), ("word/theme/theme1.xml", theme)];
let settings = settings.map(|s| format!(r#"<w:settings {W_NS}>{s}</w:settings>"#));
if let Some(s) = &settings {
doc_rels.push(("rId3", "settings", "settings.xml"));
extra.push(("word/settings.xml", s.as_str()));
}
wordcraft_docx::read(&docx(body, &doc_rels, &extra)).unwrap()
}
/// Within one step per channel: Word's tint and shade round differently from ours in the last bit.
fn near(c: Option<TextColor>, want: Rgb) -> bool {
matches!(c, Some(TextColor::Rgb(Rgb(r, g, b))) if r.abs_diff(want.0) <= 1 && g.abs_diff(want.1) <= 1 && b.abs_diff(want.2) <= 1)
}
#[test]
fn theme_colour_wins_over_the_fixed_colour() {
// Heading 1 as Word writes it: the stale w:val is ignored, accent1 darkened 25% is 2F5496.
let styles = r#"<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:rPr><w:color w:val="FF0000" w:themeColor="accent1" w:themeShade="BF"/><w:u w:val="single" w:color="FF0000" w:themeColor="accent2"/></w:rPr></w:style>"#;
let body = r#"<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>H</w:t></w:r></w:p>
<w:p><w:r><w:rPr><w:color w:val="000000" w:themeColor="accent1" w:themeTint="99"/></w:rPr><w:t>a</w:t></w:r><w:r><w:rPr><w:color w:val="000000" w:themeColor="accent1" w:themeTint="66"/></w:rPr><w:t>b</w:t></w:r><w:r><w:rPr><w:color w:val="000000" w:themeColor="accent1" w:themeTint="33"/></w:rPr><w:t>c</w:t></w:r><w:r><w:rPr><w:color w:val="000000" w:themeColor="accent1" w:themeShade="7F"/></w:rPr><w:t>d</w:t></w:r><w:r><w:rPr><w:color w:val="000000" w:themeColor="accent2" w:themeShade="BF"/></w:rPr><w:t>e</w:t></w:r><w:r><w:rPr><w:color w:val="000000" w:themeColor="text2"/></w:rPr><w:t>f</w:t></w:r><w:r><w:rPr><w:color w:val="auto" w:themeColor="hyperlink"/></w:rPr><w:t>g</w:t></w:r><w:r><w:rPr><w:shd w:val="clear" w:color="auto" w:fill="000000" w:themeFill="accent2" w:themeFillTint="33"/></w:rPr><w:t>h</w:t></w:r></w:p>"#;
let d = themed_docx(body, styles, None);
let h = d.styles.get("Heading1").unwrap();
assert_eq!(h.chr.color, Some(TextColor::Rgb(Rgb(0x2F, 0x54, 0x96))));
assert_eq!(h.chr.underline_color, Some(Rgb(0xED, 0x7D, 0x31)));
let p = paras(&d);
let at = |i: usize| p[1].props_of_char(i);
// Colours Word writes into w:val next to these theme colours (Grid Table and heading styles).
assert!(near(at(0).color, Rgb(0x8E, 0xAA, 0xDB)), "{:?}", at(0).color);
assert!(near(at(1).color, Rgb(0xB4, 0xC6, 0xE7)), "{:?}", at(1).color);
assert!(near(at(2).color, Rgb(0xD9, 0xE2, 0xF3)), "{:?}", at(2).color);
assert!(near(at(3).color, Rgb(0x1F, 0x37, 0x63)), "{:?}", at(3).color);
assert!(near(at(4).color, Rgb(0xC4, 0x59, 0x11)), "{:?}", at(4).color);
// text2 maps to dk2 without a w:clrSchemeMapping; hyperlink to hlink.
assert_eq!(at(5).color, Some(TextColor::Rgb(Rgb(0x44, 0x54, 0x6A))));
assert_eq!(at(6).color, Some(TextColor::Rgb(Rgb(0x05, 0x63, 0xC1))));
let fill = at(7).shading.unwrap();
assert!(near(Some(TextColor::Rgb(fill)), Rgb(0xFB, 0xE4, 0xD5)), "{fill:?}");
}
#[test]
fn theme_colours_follow_the_colour_scheme_mapping() {
// Styles are read before settings; the mapping still applies to them.
let styles =
r#"<w:style w:type="character" w:styleId="T1"><w:name w:val="T1"/><w:rPr><w:color w:val="000000" w:themeColor="text1"/></w:rPr></w:style>"#;
let body = r#"<w:p><w:r><w:rPr><w:rStyle w:val="T1"/></w:rPr><w:t>a</w:t></w:r><w:r><w:rPr><w:color w:val="000000" w:themeColor="background1"/></w:rPr><w:t>b</w:t></w:r><w:r><w:rPr><w:color w:val="000000" w:themeColor="dark1"/></w:rPr><w:t>c</w:t></w:r></w:p>"#;
let mapping = r#"<w:clrSchemeMapping w:bg1="dark1" w:t1="accent2" w:bg2="light2" w:t2="dark2" w:accent1="accent1" w:accent2="accent2" w:accent3="accent3" w:accent4="accent4" w:accent5="accent5" w:accent6="accent6" w:hyperlink="hyperlink" w:followedHyperlink="followedHyperlink"/>"#;
let d = themed_docx(body, styles, Some(mapping));
assert_eq!(d.styles.get("T1").unwrap().chr.color, Some(TextColor::Rgb(Rgb(0xED, 0x7D, 0x31))));
let p = paras(&d);
assert_eq!(p[0].props_of_char(1).color, Some(TextColor::Rgb(Rgb(0, 0, 0))), "background1 → dark1");
assert_eq!(p[0].props_of_char(2).color, Some(TextColor::Rgb(Rgb(0, 0, 0))), "dark1 isn't remapped");
}
#[test]
fn theme_colour_without_a_theme_keeps_the_fixed_colour() {
let d = read_body(r#"<w:p><w:r><w:rPr><w:color w:val="00FF00" w:themeColor="accent1" w:themeShade="BF"/></w:rPr><w:t>a</w:t></w:r></w:p>"#);
assert_eq!(paras(&d)[0].props_of_char(0).color, Some(TextColor::Rgb(Rgb(0, 0xFF, 0))));
}
#[test]
fn theme_font_wins_over_the_fixed_font() {
let styles = r#"<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:rPr><w:rFonts w:ascii="Calibri Light" w:hAnsi="Calibri Light" w:asciiTheme="majorHAnsi" w:hAnsiTheme="majorHAnsi"/></w:rPr></w:style>"#;
let body = r#"<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>H</w:t></w:r></w:p>
<w:p><w:r><w:rPr><w:rFonts w:hAnsi="Arial" w:hAnsiTheme="minorHAnsi"/></w:rPr><w:t>a</w:t></w:r><w:r><w:rPr><w:rFonts w:ascii="Arial" w:asciiTheme="minorHAnsi"/></w:rPr><w:t>b</w:t></w:r><w:r><w:rPr><w:rFonts w:ascii="Arial" w:hAnsiTheme="majorHAnsi"/></w:rPr><w:t>c</w:t></w:r></w:p>"#;
let d = themed_docx(body, styles, None);
assert_eq!(d.styles.get("Heading1").unwrap().chr.font.as_deref(), Some("Major Face"));
let p = paras(&d);
assert_eq!(p[1].props_of_char(0).font.as_deref(), Some("Minor Face"));
assert_eq!(p[1].props_of_char(1).font.as_deref(), Some("Minor Face"));
// An explicit ascii font still beats a theme font given only for hAnsi.
assert_eq!(p[1].props_of_char(2).font.as_deref(), Some("Arial"));
}
/// Issue #400: moved text (ECMA-376 §17.13.5.21–28). `w:moveFrom` / `w:moveTo` inside named
/// move ranges are the two ends of one move, paragraph marks included; the ranges are written
/// back around them. A move missing an end, or whose range never ends, is a plain deletion and
@@ -1174,3 +1350,609 @@ fn note_separators_and_continuation_notice_round_trip() {
assert!(settings.contains(r#"<w:footnote w:id="-1"/><w:footnote w:id="0"/><w:footnote w:id="1"/>"#), "{settings}");
check(&wordcraft_docx::read(&out).unwrap());
}
/// Issue #534: East Asian number formats are read, labelled and written back as they were; a
/// value the format doesn't define is decimal, and a level without `w:ind` has no indent.
#[test]
fn east_asian_number_formats_read_label_and_round_trip() {
use wordcraft_doc::numbering::Counters;
use wordcraft_doc::section::NumFormat;
let lvl = |i: u8, fmt: &str, text: &str| {
format!(r#"<w:lvl w:ilvl="{i}"><w:start w:val="1"/><w:numFmt w:val="{fmt}"/><w:suff w:val="nothing"/><w:lvlText w:val="{text}"/></w:lvl>"#)
};
let levels = [
lvl(0, "chineseCounting", "%1、"),
lvl(1, "chineseCountingThousand", "第%2章"),
lvl(2, "japaneseLegal", "(%3)"),
lvl(3, "decimalEnclosedCircle", "%4"),
lvl(4, "hebrew1", "%5."),
lvl(5, "notAFormat", "%6."),
]
.concat();
let numbering = format!(
r#"<w:numbering {W_NS}><w:abstractNum w:abstractNumId="0">{levels}</w:abstractNum><w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num></w:numbering>"#
);
let item = |lv: u8| format!(r#"<w:p><w:pPr><w:numPr><w:ilvl w:val="{lv}"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>x</w:t></w:r></w:p>"#);
let body: String = [0, 0, 0, 1, 2, 3, 4, 5].map(item).concat();
let bytes = docx(&body, &[("rId1", "numbering", "numbering.xml")], &[("word/numbering.xml", &numbering)]);
let d = wordcraft_docx::read(&bytes).unwrap();
let formats = |d: &Document| (0..6).map(|i| d.numbering.level(1, i).map(|l| l.format)).collect::<Vec<_>>();
let want = [
NumFormat::ChineseCounting,
NumFormat::ChineseCountingThousand,
NumFormat::JapaneseLegal,
NumFormat::DecimalEnclosedCircle,
NumFormat::Hebrew1,
NumFormat::Decimal,
]
.map(Some);
assert_eq!(formats(&d), want);
assert_eq!(d.numbering.level(1, 0).map(|l| (l.indent, l.hanging)), Some((0.0, 0.0)));
let mut c = Counters::default();
let labels: Vec<String> =
paras(&d).iter().filter_map(|p| p.props.numbering).filter_map(|n| c.next_label(&d.numbering, n.num, n.level)).map(|l| l.0).collect();
assert_eq!(labels, ["一、", "二、", "三、", "第一章", "(壱)", "①", "1.", "1."]);
// Saved and read again, every format is the one the file had (hebrew1 too, which shows as
// decimal for now).
let out = wordcraft_docx::write(&d).unwrap();
let back = wordcraft_docx::read(&out).unwrap();
assert_eq!(formats(&back), want);
}
/// #464: a bookmark whose end follows the document's final table keeps its end (it goes into
/// the table's last cell) and is saved as a matched pair.
#[test]
fn bookmark_around_final_table_keeps_its_end() {
let body = r#"<w:bookmarkStart w:id="7" w:name="TableRange"/><w:tbl><w:tblPr/><w:tblGrid><w:gridCol w:w="1440"/></w:tblGrid><w:tr><w:tc><w:tcPr/><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:bookmarkEnd w:id="7"/><w:sectPr/>"#;
let d = read_body(body);
let marks = |d: &Document| {
let Block::Table(t) = &*d.body[0] else { panic!("{:?}", d.body[0]) };
t.rows[0].cells[0].blocks[0].as_para().unwrap().objects.clone()
};
let want = vec![InlineObject::BookmarkStart { name: "TableRange".into() }, InlineObject::BookmarkEnd { name: "TableRange".into() }];
assert_eq!(marks(&d), want);
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(marks(&again), want);
assert_eq!(again.bookmarks().len(), 1);
}
/// #465: a field whose cached result is an embedded picture (`INCLUDEPICTURE`, simple, complex or
/// locked) keeps the picture through reading and saving; the linked file is never read.
#[test]
fn includepicture_keeps_its_embedded_picture() {
let png: &[u8] = b"\x89PNG\r\n\x1a\nstand-in";
let pic = r#"<w:r><w:drawing><wp:inline><wp:extent cx="914400" cy="914400"/><wp:docPr id="1" name="P"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rIdPic"/></pic:blipFill></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#;
let cases = [
(format!(r#"<w:fldSimple w:instr="INCLUDEPICTURE &quot;picture.png&quot;">{pic}</w:fldSimple>"#), false),
(format!(r#"<w:fldSimple w:instr="INCLUDEPICTURE &quot;picture.png&quot;" w:fldLock="1">{pic}</w:fldSimple>"#), true),
(
format!(
r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> INCLUDEPICTURE "picture.png" </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>{pic}<w:r><w:fldChar w:fldCharType="end"/></w:r>"#
),
false,
),
];
for (field, locked) in cases {
let bytes = docx(&format!("<w:p>{field}</w:p><w:sectPr/>"), &[("rIdPic", "image", "media/picture.png")], &[]);
let bytes = {
// `docx` takes text parts; add the picture bytes as a binary part.
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
for i in 0..z.len() {
let mut f = z.by_index(i).unwrap();
let mut v = Vec::new();
std::io::Read::read_to_end(&mut f, &mut v).unwrap();
entries.push((f.name().to_string(), v));
}
entries.push(("word/media/picture.png".into(), png.to_vec()));
zip(&entries.iter().map(|(n, v)| (n.as_str(), v.as_slice())).collect::<Vec<_>>())
};
let check = |d: &Document| {
let p = paras(d);
let objs = &p[0].objects;
assert_eq!(objs.len(), 3, "{objs:?}");
assert_eq!(objs[0], InlineObject::FieldStart { instr: "INCLUDEPICTURE \"picture.png\"".into(), locked });
let InlineObject::Image { media, .. } = &objs[1] else { panic!("{objs:?}") };
assert_eq!(d.media.get(media).unwrap().as_slice(), png);
assert_eq!(objs[2], InlineObject::FieldEnd);
};
let d = wordcraft_docx::read(&bytes).unwrap();
check(&d);
check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap());
}
}
/// One anchored Word shape (`wps:wsp` inside `mc:AlternateContent`, as Word writes them) with
/// shape properties `sppr`, style `style` and extra `wsp` content.
fn anchored_wsp(name: &str, cx: u32, cy: u32, sppr: &str, style: &str, extra: &str) -> String {
format!(
r#"<w:r><mc:AlternateContent><mc:Choice Requires="wps"><w:drawing><wp:anchor distT="0" distB="0" distL="114300" distR="114300" simplePos="0" relativeHeight="1" behindDoc="0" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="column"><wp:posOffset>12700</wp:posOffset></wp:positionH><wp:positionV relativeFrom="paragraph"><wp:posOffset>25400</wp:posOffset></wp:positionV><wp:extent cx="{cx}" cy="{cy}"/><wp:effectExtent l="0" t="0" r="0" b="0"/><wp:wrapNone/><wp:docPr id="1" name="{name}"/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><wps:wsp>{extra}<wps:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm>{sppr}</wps:spPr>{style}<wps:bodyPr/></wps:wsp></a:graphicData></a:graphic></wp:anchor></w:drawing></mc:Choice><mc:Fallback><w:pict><v:line from="0,0" to="1pt,2pt"/></w:pict></mc:Fallback></mc:AlternateContent></w:r>"#
)
}
/// #611: diagrams drawn with Word's shapes were mostly missing. Connectors and boxes take their
/// outline and fill from the theme through their style references, or name preset and scheme
/// colours; all of these are read, with line widths and arrowheads.
#[test]
fn theme_styled_connectors_and_callout_boxes_keep_their_paint() {
let style = |ln: u8, fill: u8, clr: &str| {
format!(
r#"<wps:style><a:lnRef idx="{ln}"><a:schemeClr val="{clr}"/></a:lnRef><a:fillRef idx="{fill}"><a:schemeClr val="{clr}"/></a:fillRef><a:effectRef idx="0"><a:schemeClr val="{clr}"/></a:effectRef><a:fontRef idx="minor"><a:schemeClr val="tx1"/></a:fontRef></wps:style>"#
)
};
let body = [
// A connector with only a style: accent4, the theme's thinnest line, an arrowhead.
anchored_wsp("Arrow", 673100, 889000, r#"<a:prstGeom prst="straightConnector1"><a:avLst/></a:prstGeom><a:ln><a:tailEnd type="triangle"/></a:ln>"#, &style(1, 0, "accent4"), "<wps:cNvCnPr/>"),
// A callout text box: scheme fill, preset black outline.
anchored_wsp(
"Text Box",
3073400,
673100,
r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:solidFill><a:schemeClr val="lt1"/></a:solidFill><a:ln w="6350"><a:solidFill><a:prstClr val="black"/></a:solidFill></a:ln>"#,
"",
r#"<wps:cNvSpPr txBox="1"/><wps:txbx><w:txbxContent><w:p><w:r><w:t>Callout</w:t></w:r></w:p></w:txbxContent></wps:txbx>"#,
),
// A filled rectangle from its style alone.
anchored_wsp("Rect", 292100, 647700, r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#, &style(2, 1, "accent1"), "<wps:cNvSpPr/>"),
// An elbow connector.
anchored_wsp("Elbow", 1270000, 635000, r#"<a:prstGeom prst="bentConnector3"><a:avLst/></a:prstGeom>"#, &style(1, 0, "accent2"), "<wps:cNvCnPr/>"),
// A rounded-rectangle callout.
anchored_wsp("Callout", 1270000, 635000, r#"<a:prstGeom prst="wedgeRoundRectCallout"><a:avLst/></a:prstGeom>"#, &style(2, 1, "accent1"), "<wps:cNvSpPr/>"),
]
.concat();
let d = read_body(&format!("<w:p>{body}</w:p>"));
let theme = &d.settings.theme_colors;
let objs = &paras(&d)[0].objects;
assert_eq!(objs.len(), 5);
let shape = |i: usize| match &objs[i] {
InlineObject::Shape { kind, fill, stroke, stroke_width, extra, story, .. } => {
(*kind, *fill, *stroke, *stroke_width, extra.arrow_end, story.is_some())
}
o => panic!("{o:?}"),
};
assert_eq!(shape(0), (ShapeKind::StraightConnector, None, Some(theme[7]), 0.5, true, false));
assert_eq!(shape(1), (ShapeKind::TextBox, Some(theme[1]), Some(Rgb(0, 0, 0)), 0.5, false, true));
assert_eq!(shape(2), (ShapeKind::Rectangle, Some(theme[4]), Some(theme[4]), 1.0, false, false));
assert_eq!(shape(3), (ShapeKind::ElbowConnector, None, Some(theme[5]), 0.5, false, false));
assert_eq!(shape(4).0, ShapeKind::RoundedRectangle);
// The themed outline and the arrowhead survive saving.
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert!(matches!(&paras(&again)[0].objects[0], InlineObject::Shape { stroke, extra, .. } if *stroke == Some(theme[7]) && extra.arrow_end));
}
/// #611: legacy VML lines and rectangles (documents without DrawingML).
#[test]
fn vml_lines_and_rectangles() {
let body = r##"<w:p><w:r><w:pict><v:line style="position:absolute;flip:y;z-index:1" from="10pt,20pt" to="40pt,60pt" strokecolor="#4472c4 [3204]" strokeweight=".5pt"><v:stroke endarrow="block"/></v:line></w:pict></w:r>
<w:r><w:pict><v:rect style="position:absolute;margin-left:5pt;margin-top:6pt;width:30pt;height:20pt" fillcolor="#ff0000" stroked="f"/></w:pict></w:r></w:p>"##;
let d = read_body(body);
let objs = &paras(&d)[0].objects;
assert_eq!(objs.len(), 2, "{objs:?}");
match &objs[0] {
InlineObject::Shape { kind: ShapeKind::Line, w, h, stroke, stroke_width, float, extra, .. } => {
assert_eq!((*w, *h, *stroke, *stroke_width), (30.0, 40.0, Some(Rgb(0x44, 0x72, 0xC4)), 0.5));
assert_eq!((float.x, float.y, float.flip_h, float.flip_v), (10.0, 20.0, false, true));
assert!(extra.arrow_end && !extra.arrow_start);
}
o => panic!("{o:?}"),
}
assert!(matches!(&objs[1], InlineObject::Shape { kind: ShapeKind::Rectangle, fill: Some(Rgb(255, 0, 0)), stroke: None, w: 30.0, h: 20.0, .. }));
}
/// #743: a cover page from Word: a group holding a rectangle filled through its style, a
/// freeform band and a title box, beside a box that resizes to fit its two lines and has no
/// internal margins. The fills, the band's outline, the margins and the resizing are all read
/// and survive saving.
#[test]
fn cover_page_group_fills_freeforms_and_text_box_margins() {
let style = r#"<wps:style><a:lnRef idx="2"><a:schemeClr val="accent1"><a:shade val="50000"/></a:schemeClr></a:lnRef><a:fillRef idx="1"><a:schemeClr val="accent1"/></a:fillRef><a:effectRef idx="0"><a:schemeClr val="accent1"/></a:effectRef><a:fontRef idx="minor"><a:schemeClr val="lt1"/></a:fontRef></wps:style>"#;
let member = |id: u32, txbox: bool, off: (u32, u32), ext: (u32, u32), geom: &str, paint: &str, inner: &str| {
let nv = if txbox { r#"<wps:cNvSpPr txBox="1"/>"# } else { "<wps:cNvSpPr/>" };
format!(
r#"<wps:wsp><wps:cNvPr id="{id}" name="Member {id}"/>{nv}<wps:spPr><a:xfrm><a:off x="{}" y="{}"/><a:ext cx="{}" cy="{}"/></a:xfrm>{geom}{paint}</wps:spPr>{style}{inner}</wps:wsp>"#,
off.0, off.1, ext.0, ext.1
)
};
let rect = r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#;
let band = r#"<a:custGeom><a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/><a:rect l="0" t="0" r="r" b="b"/><a:pathLst><a:path w="100" h="100"><a:moveTo><a:pt x="0" y="100"/></a:moveTo><a:lnTo><a:pt x="100" y="0"/></a:lnTo><a:lnTo><a:pt x="100" y="100"/></a:lnTo><a:close/></a:path></a:pathLst></a:custGeom>"#;
let title = r#"<wps:txbx><w:txbxContent><w:p><w:r><w:t>Cover Title</w:t></w:r></w:p></w:txbxContent></wps:txbx><wps:bodyPr rot="0" vert="horz" wrap="square" lIns="0" tIns="0" rIns="0" bIns="0" anchor="t"><a:noAutofit/></wps:bodyPr>"#;
let members = [
member(11, false, (0, 0), (5486400, 5486400), rect, r#"<a:ln><a:noFill/></a:ln>"#, "<wps:bodyPr/>"),
member(
12,
false,
(914400, 4572000),
(4572000, 914400),
band,
r#"<a:solidFill><a:schemeClr val="bg1"/></a:solidFill><a:ln><a:noFill/></a:ln>"#,
"<wps:bodyPr/>",
),
member(13, true, (457200, 457200), (4572000, 1828800), rect, r#"<a:noFill/><a:ln><a:noFill/></a:ln>"#, title),
]
.concat();
let group = format!(
r##"<w:r><mc:AlternateContent><mc:Choice Requires="wpg"><w:drawing><wp:anchor distT="0" distB="0" distL="0" distR="0" simplePos="0" relativeHeight="1" behindDoc="1" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="margin"><wp:align>center</wp:align></wp:positionH><wp:positionV relativeFrom="margin"><wp:align>top</wp:align></wp:positionV><wp:extent cx="5486400" cy="5486400"/><wp:effectExtent l="0" t="0" r="0" b="0"/><wp:wrapNone/><wp:docPr id="1" name="Group 1"/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"><wpg:wgp><wpg:cNvGrpSpPr/><wpg:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="5486400" cy="5486400"/><a:chOff x="0" y="0"/><a:chExt cx="5486400" cy="5486400"/></a:xfrm></wpg:grpSpPr>{members}</wpg:wgp></a:graphicData></a:graphic></wp:anchor></w:drawing></mc:Choice><mc:Fallback><w:pict><v:rect style="width:10pt;height:10pt" fillcolor="#156082 [3204]" stroked="f"/></w:pict></mc:Fallback></mc:AlternateContent></w:r>"##
);
let subtitle = anchored_wsp(
"Subtitle",
5753100,
420000,
r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/><a:ln><a:noFill/></a:ln>"#,
"",
r#"<wps:cNvSpPr txBox="1"/><wps:txbx><w:txbxContent><w:p><w:r><w:t>Plan</w:t></w:r></w:p><w:p><w:r><w:t>Author</w:t></w:r></w:p></w:txbxContent></wps:txbx>"#,
)
// `anchored_wsp` writes an empty `wps:bodyPr`: give this one margins and Resize to fit.
.replace("<wps:bodyPr/>", r#"<wps:bodyPr lIns="0" tIns="12700" rIns="0" bIns="0"><a:spAutoFit/></wps:bodyPr>"#);
let check = |d: &Document| {
let theme = &d.settings.theme_colors;
let objs = &paras(d)[0].objects;
let InlineObject::Group { children, .. } = &objs[0] else { panic!("{:?}", objs[0]) };
assert_eq!(children.len(), 3);
let shape = |i: usize| match &children[i].obj {
InlineObject::Shape { kind, fill, freeform, story, .. } => (*kind, *fill, freeform.is_some(), *story),
o => panic!("{o:?}"),
};
let (kind, fill, free, _) = shape(0);
assert_eq!((kind, fill, free), (ShapeKind::Rectangle, Some(theme[4]), false), "the style's accent fill");
let (kind, fill, free, _) = shape(1);
assert_eq!((kind, fill, free), (ShapeKind::Freeform, Some(theme[1]), true), "a freeform in a group keeps its outline");
let (kind, _, _, story) = shape(2);
assert_eq!(kind, ShapeKind::TextBox);
let body = d.parts[&story.unwrap()].body;
assert_eq!((body.inset, body.auto_fit), (Some([0; 4]), false), "the title box has no margins");
let InlineObject::Shape { story: Some(sub), .. } = &objs[1] else { panic!("{:?}", objs[1]) };
let body = d.parts[sub].body;
assert_eq!((body.inset, body.auto_fit), (Some([0, 12700, 0, 0]), true), "the subtitle resizes to fit its text");
};
let d = read_body(&format!("<w:p>{group}{subtitle}</w:p>"));
check(&d);
check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap());
}
#[test]
fn embedded_fonts_survive_a_round_trip() {
let table = r#"<w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:font w:name="Roboto"><w:embedRegular r:id="rId1"/><w:embedBold r:id="rId2"/></w:font></w:fonts>"#;
let table_rels = rels(&[("rId1", "font", "fonts/Roboto-regular.ttf"), ("rId2", "font", "fonts/Roboto-bold.ttf")]);
let bytes = docx(
"<w:p><w:r><w:t>Hi</w:t></w:r></w:p>",
&[("rId9", "fontTable", "fontTable.xml")],
&[
("word/fontTable.xml", table),
("word/_rels/fontTable.xml.rels", &table_rels),
("word/fonts/Roboto-regular.ttf", "regular font bytes"),
("word/fonts/Roboto-bold.ttf", "bold font bytes"),
],
);
let out = wordcraft_docx::write(&wordcraft_docx::read(&bytes).unwrap()).unwrap();
let files = unzip(&out);
assert_eq!(text(&files, "word/fontTable.xml"), table);
assert_eq!(text(&files, "word/fonts/Roboto-regular.ttf"), "regular font bytes");
assert_eq!(text(&files, "word/fonts/Roboto-bold.ttf"), "bold font bytes");
let table_rels = text(&files, "word/_rels/fontTable.xml.rels");
assert!(table_rels.contains(r#"Id="rId1""#) && table_rels.contains("fonts/Roboto-regular.ttf"), "{table_rels}");
assert!(text(&files, "word/_rels/document.xml.rels").contains("fontTable.xml"));
// A second save keeps them too.
let again = unzip(&wordcraft_docx::write(&wordcraft_docx::read(&out).unwrap()).unwrap());
assert_eq!(text(&again, "word/fonts/Roboto-bold.ttf"), "bold font bytes");
}
// ---- native Word ink: opaque preservation, never re-encoded as WordCraft freeforms ----
const INK_NS: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingInk";
const INK_XML: &str = r##"<ink xmlns="http://www.w3.org/2003/InkML"><definitions><context xml:id="ctx"><inkSource xml:id="src"><traceFormat><channel name="X" type="integer" units="cm"/><channel name="Y" type="integer" units="cm"/></traceFormat></inkSource></context><brush xml:id="blue"><brushProperty name="color" value="#2563EB"/><brushProperty name="width" value="0.08" units="cm"/></brush></definitions><trace contextRef="#ctx" brushRef="#blue">0 0, 1 1, 2 0</trace></ink>"##;
const INK_TYPES: &str = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/word/ink/ink1.xml" ContentType="application/inkml+xml"/><Default Extension="png" ContentType="image/png"/></Types>"#;
fn ink_docx(fallback: bool, alias: bool) -> Vec<u8> {
let content = r#"<q:contentPart xmlns:q="http://schemas.microsoft.com/office/word/2010/wordml" r:id="rInk"><q:xfrm><a:off x="0" y="0"/><a:ext cx="1828800" cy="914400"/></q:xfrm></q:contentPart>"#;
let drawing = format!(
r#"<w:drawing><wp:inline><wp:extent cx="1828800" cy="914400"/><wp:docPr id="1" name="Study ink"/><a:graphic><a:graphicData uri="{INK_NS}">{content}</a:graphicData></a:graphic></wp:inline></w:drawing>"#
);
let prefix = if alias { "pen" } else { "wpi" };
let markup = if fallback {
format!(
r##"<mc:AlternateContent><mc:Choice xmlns:{prefix}="{INK_NS}" Requires="{prefix}">{drawing}</mc:Choice><mc:Fallback><w:pict><v:shape id="preview" style="width:144pt;height:72pt"><v:imagedata r:id="rPreview"/></v:shape></w:pict></mc:Fallback></mc:AlternateContent>"##
)
} else {
drawing
};
let body = format!("<w:p><w:r><w:t>Original</w:t></w:r></w:p><w:p><w:r>{markup}</w:r></w:p>");
let mut relationships = vec![("rInk", "customXml", "ink/ink1.xml")];
let mut extra = vec![("[Content_Types].xml", INK_TYPES), ("word/ink/ink1.xml", INK_XML)];
if fallback {
relationships.push(("rPreview", "image", "media/preview.png"));
extra.push(("word/media/preview.png", "original preview bytes"));
}
docx(&body, &relationships, &extra)
}
fn check_ink(files: &std::collections::BTreeMap<String, Vec<u8>>) -> String {
let body = text(files, "word/document.xml");
let [id] = attr_values(&body, "w14:contentPart", "r:id").try_into().unwrap_or_else(|_| panic!("ink reference lost: {body}"));
let (kind, target) = rel(&text(files, "word/_rels/document.xml.rels"), &id);
assert!(kind.ends_with("/customXml"));
let part = part_at("word", &target);
assert_eq!(text(files, &part), INK_XML);
assert!(text(files, "[Content_Types].xml").contains(&format!(r#"PartName="/{part}" ContentType="application/inkml+xml""#)));
part
}
#[test]
fn native_ink_survives_unrelated_edit_and_two_saves() {
let mut d = wordcraft_docx::read(&ink_docx(false, false)).unwrap();
let g = graphics(&d);
assert_eq!(g.len(), 1, "native ink must remain a model object");
let InlineObject::Graphic { graphic, .. } = &g[0] else { panic!() };
assert_eq!(graphic.kind.noun(), "ink");
assert!(graphic.source.is_some());
assert!(!graphic.items.is_empty(), "a missing preview must not silently leave a blank frame");
let p = std::sync::Arc::make_mut(&mut d.body[0]);
let Block::Para(p) = p else { panic!() };
p.delete(0, p.len()).unwrap();
p.insert_text(0, "Edited", &Default::default()).unwrap();
for _ in 0..2 {
let out = wordcraft_docx::write(&d).unwrap();
let files = unzip(&out);
check_ink(&files);
assert!(text(&files, "word/document.xml").contains("Edited"));
d = wordcraft_docx::read(&out).unwrap();
assert_eq!(graphics(&d).len(), 1);
}
}
#[test]
fn native_ink_keeps_alternate_content_and_preview_with_renamed_prefixes() {
for alias in [false, true] {
let mut d = wordcraft_docx::read(&ink_docx(true, alias)).unwrap();
let g = graphics(&d);
assert_eq!(g.len(), 1, "fallback must not replace native ink");
let InlineObject::Graphic { graphic, .. } = &g[0] else { panic!() };
assert!(
graphic.items.iter().any(|item| matches!(item, wordcraft_doc::graphic::GraphicItem::Image { media, .. } if d.media.contains_key(media)))
);
for _ in 0..2 {
let out = wordcraft_docx::write(&d).unwrap();
let files = unzip(&out);
check_ink(&files);
let body = text(&files, "word/document.xml");
assert!(body.contains("<mc:AlternateContent>") && body.contains("<mc:Fallback>"));
let [id] = attr_values(&body, "v:imagedata", "r:id").try_into().unwrap();
let (kind, target) = rel(&text(&files, "word/_rels/document.xml.rels"), &id);
assert!(kind.ends_with("/image"));
assert_eq!(text(&files, &part_at("word", &target)), "original preview bytes");
d = wordcraft_docx::read(&out).unwrap();
assert_eq!(graphics(&d).len(), 1);
}
}
}
#[test]
fn native_ink_missing_part_uses_available_fallback() {
let bytes = ink_docx(true, true);
let mut files = unzip(&bytes);
files.remove("word/ink/ink1.xml");
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
let d = wordcraft_docx::read(&zip(&entries)).unwrap();
assert!(graphics(&d).is_empty());
assert!(paras(&d).iter().flat_map(|p| &p.objects).any(|o| matches!(o, InlineObject::Image { .. })));
let files = unzip(&wordcraft_docx::write(&d).unwrap());
assert!(!text(&files, "word/document.xml").contains("w14:contentPart"));
assert!(files.values().any(|bytes| bytes == b"original preview bytes"));
}
#[test]
fn native_ink_missing_optional_preview_keeps_native_part() {
let mut files = unzip(&ink_docx(true, false));
files.remove("word/media/preview.png");
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
let d = wordcraft_docx::read(&zip(&entries)).unwrap();
assert_eq!(graphics(&d).len(), 1);
let files = unzip(&wordcraft_docx::write(&d).unwrap());
check_ink(&files);
assert!(!text(&files, "word/document.xml").contains("<mc:Fallback>"));
}
#[test]
fn native_ink_requires_the_bound_namespace_and_ink_content_type() {
for wrong_namespace in [true, false] {
let mut files = unzip(&ink_docx(true, false));
let name = if wrong_namespace { "word/document.xml" } else { "[Content_Types].xml" };
let old = text(&files, name);
let changed = if wrong_namespace {
old.replace(&format!(r#"xmlns:wpi="{INK_NS}""#), r#"xmlns:wpi="urn:future:unsupported""#)
} else {
old.replace("application/inkml+xml", "application/xml")
};
files.insert(name.into(), changed.into_bytes());
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
let d = wordcraft_docx::read(&zip(&entries)).unwrap();
assert!(graphics(&d).is_empty());
assert!(paras(&d).iter().flat_map(|p| &p.objects).any(|o| matches!(o, InlineObject::Image { .. })));
}
}
#[test]
fn native_ink_copies_have_independent_parts_and_current_frames() {
let mut d = wordcraft_docx::read(&ink_docx(true, false)).unwrap();
let [mut object] = graphics(&d).try_into().unwrap();
let InlineObject::Graphic { w, h, float, .. } = &mut object else { panic!() };
(*w, *h) = (288.0, 144.0);
*float = wordcraft_doc::para::Float { wrap: Wrap::Square, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 72.0, y: 36.0, ..Default::default() };
let mut p = Paragraph::new();
p.insert_object(0, object.clone(), &Default::default()).unwrap();
p.insert_object(p.len(), object, &Default::default()).unwrap();
d.body = vec![wordcraft_doc::para_block(p)];
let out = wordcraft_docx::write(&d).unwrap();
let files = unzip(&out);
let body = text(&files, "word/document.xml");
let ids = attr_values(&body, "w14:contentPart", "r:id");
assert_eq!(ids.len(), 2);
let parts: Vec<_> = ids.iter().map(|id| part_at("word", &rel(&text(&files, "word/_rels/document.xml.rels"), id).1)).collect();
assert_ne!(parts[0], parts[1]);
for part in parts {
assert_eq!(text(&files, &part), INK_XML);
}
assert_eq!(attr_values(&body, "wp:extent", "cx"), ["3657600", "3657600"]);
assert_eq!(attr_values(&body, "a:ext", "cx"), ["3657600", "3657600"]);
assert_eq!(attr_values(&body, "v:shape", "style"), ["width:288pt;height:144pt", "width:288pt;height:144pt"]);
assert_eq!(body.matches("<wp:posOffset>914400</wp:posOffset>").count(), 2);
let back = wordcraft_docx::read(&out).unwrap();
let g = graphics(&back);
assert_eq!(g.len(), 2);
for object in g {
let InlineObject::Graphic { w, h, float, .. } = object else { panic!() };
assert_eq!((w, h, float.x, float.y), (288.0, 144.0, 72.0, 36.0));
}
}
#[test]
fn native_ink_markup_limit_does_not_write_a_dangling_reference() {
let mut files = unzip(&ink_docx(false, false));
let body = text(&files, "word/document.xml").replace("<a:graphicData ", &format!(r#"<a:graphicData padding="{}" "#, "x".repeat(1024 * 1024 + 1)));
files.insert("word/document.xml".into(), body.into_bytes());
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
let d = wordcraft_docx::read(&zip(&entries)).unwrap();
let files = unzip(&wordcraft_docx::write(&d).unwrap());
assert!(!text(&files, "word/document.xml").contains("w14:contentPart"));
}
#[test]
fn native_ink_preserves_inner_transform_and_rotation_padding_on_unrelated_saves() {
let mut files = unzip(&ink_docx(false, false));
let body = text(&files, "word/document.xml")
.replace("<wp:docPr", r#"<wp:effectExtent l="55468" t="512668" r="55468" b="512668"/><wp:docPr"#)
.replace("<q:xfrm>", r#"<q:xfrm rot="2700000">"#)
.replace(r#"<a:off x="0" y="0"/><a:ext cx="1828800" cy="914400"/>"#, r#"<a:off x="127000" y="254000"/><a:ext cx="914400" cy="457200"/>"#);
files.insert("word/document.xml".into(), body.into_bytes());
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
let mut d = wordcraft_docx::read(&zip(&entries)).unwrap();
for _ in 0..2 {
let [object] = graphics(&d).try_into().unwrap();
let InlineObject::Graphic { float, .. } = object else { panic!() };
assert_eq!(float.spin().deg, 45.0);
assert!(float.effect.iter().all(|v| v.abs() < 0.001), "rotation padding is not counted twice: {:?}", float.effect);
let out = wordcraft_docx::write(&d).unwrap();
let files = unzip(&out);
check_ink(&files);
let body = text(&files, "word/document.xml");
assert_eq!(attr_values(&body, "a:off", "x"), ["127000"]);
assert_eq!(attr_values(&body, "a:ext", "cx"), ["914400"]);
assert_eq!(attr_values(&body, "w14:xfrm", "rot"), ["2700000"]);
d = wordcraft_docx::read(&out).unwrap();
}
}
/// Word draws a table of contents' entry links without the Hyperlink character style (measured in
/// Word for Windows); the import drops the style from them, keeping the link. A Hyperlink-styled run
/// outside a hyperlink, and a hyperlink in another field, keep it.
#[test]
fn toc_entry_links_lose_the_hyperlink_style() {
let styles = format!(
r#"<w:styles {W_NS}><w:style w:type="paragraph" w:styleId="Normal" w:default="1"><w:name w:val="Normal"/></w:style>
<w:style w:type="character" w:styleId="Hyperlink"><w:name w:val="Hyperlink"/><w:rPr><w:color w:val="0066FF"/><w:u w:val="single"/></w:rPr></w:style></w:styles>"#
);
let field = |instr: &str, inner: &str| {
format!(
r#"<w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve">{instr}</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>{inner}</w:p><w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#
)
};
let link =
|t: &str| format!(r#"<w:hyperlink w:anchor="_Toc1"><w:r><w:rPr><w:rStyle w:val="Hyperlink"/></w:rPr><w:t>{t}</w:t></w:r></w:hyperlink>"#);
let body = [
field(r#" TOC \o "1-3" \h \z \u "#, &link("entry")),
field(" TOC ", r#"<w:r><w:rPr><w:rStyle w:val="Hyperlink"/></w:rPr><w:t>styled</w:t></w:r>"#),
field(" QUOTE x ", &link("quoted")),
format!("<w:p>{}</w:p>", link("plain")),
r#"<w:p><w:bookmarkStart w:id="1" w:name="_Toc1"/><w:r><w:t>Target</w:t></w:r><w:bookmarkEnd w:id="1"/></w:p>"#.to_string(),
]
.concat();
let d = wordcraft_docx::read(&docx(&body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
let run = |word: &str| {
paras(&d).into_iter().find_map(|p| p.text.find(word).map(|at| p.props_of_char(at).clone())).unwrap_or_else(|| panic!("no run with {word}"))
};
let entry = run("entry");
assert_eq!((entry.style.as_deref(), entry.link.as_deref()), (None, Some("#_Toc1")), "a TOC entry: plain, still a link");
assert_eq!(run("styled").style.as_deref(), Some("Hyperlink"), "not in a hyperlink: kept");
assert_eq!(run("quoted").style.as_deref(), Some("Hyperlink"), "another field: kept");
assert_eq!(run("plain").style.as_deref(), Some("Hyperlink"), "outside fields: kept");
}
/// A style or paragraph whose `w:numPr` gives only a level (`w:ilvl`) takes the list from its
/// style chain (ECMA-376 §17.3.1.19): a Heading 3 based on a numbered Heading 2 is level 3 of the
/// same list. WordCraft dropped the level, so Heading 3s numbered as Heading 2s (6.15 where Word
/// shows 6.4.5 in NIH's monitoring-plan template).
#[test]
fn a_list_level_without_a_list_takes_the_style_s_list() {
let styles = format!(
r#"<w:styles {W_NS}>
<w:style w:type="paragraph" w:styleId="Normal" w:default="1"><w:name w:val="Normal"/></w:style>
<w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:pPr><w:numPr><w:ilvl w:val="1"/><w:numId w:val="15"/></w:numPr></w:pPr></w:style>
<w:style w:type="paragraph" w:styleId="Heading3"><w:name w:val="heading 3"/><w:basedOn w:val="Heading2"/><w:pPr><w:numPr><w:ilvl w:val="2"/></w:numPr></w:pPr></w:style>
<w:style w:type="paragraph" w:styleId="Heading4"><w:name w:val="heading 4"/><w:basedOn w:val="Heading3"/><w:pPr><w:numPr><w:ilvl w:val="3"/></w:numPr></w:pPr></w:style>
<w:style w:type="paragraph" w:styleId="Loose"><w:name w:val="Loose"/><w:basedOn w:val="Normal"/><w:pPr><w:numPr><w:ilvl w:val="4"/></w:numPr></w:pPr></w:style>
</w:styles>"#
);
let body = r#"<w:p><w:pPr><w:pStyle w:val="Heading3"/></w:pPr><w:r><w:t>three</w:t></w:r></w:p>
<w:p><w:pPr><w:pStyle w:val="Heading2"/><w:numPr><w:ilvl w:val="2"/></w:numPr></w:pPr><w:r><w:t>direct level</w:t></w:r></w:p>
<w:p><w:pPr><w:numPr><w:ilvl w:val="1"/></w:numPr></w:pPr><w:r><w:t>no list anywhere</w:t></w:r></w:p>"#;
let d = wordcraft_docx::read(&docx(body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
let num = |id: &str| d.styles.get(id).and_then(|s| s.para.numbering).map(|n| (n.num, n.level));
assert_eq!(num("Heading3"), Some((15, 2)), "Heading 3 is level 3 of Heading 2's list");
assert_eq!(num("Heading4"), Some((15, 3)), "through two level-only styles");
assert_eq!(num("Loose"), None, "no list in the chain: no numbering");
let p = paras(&d);
assert_eq!(d.styles.resolve_para(&p[0].props).numbering.map(|n| (n.num, n.level)), Some((15, 2)));
assert_eq!(p[1].props.numbering.map(|n| (n.num, n.level)), Some((15, 2)), "a paragraph's own level, its style's list");
assert_eq!(p[2].props.numbering, None);
// Saved and read again it is an ordinary list reference.
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(again.styles.get("Heading3").and_then(|s| s.para.numbering).map(|n| (n.num, n.level)), Some((15, 2)));
}
/// `w:hideMark` on a cell is read and written back (it was dropped, and the empty rows NIH's
/// tables collapse with it showed as blank lines).
#[test]
fn cell_hide_mark_reads_and_round_trips() {
let body = r#"<w:tbl><w:tblGrid><w:gridCol w:w="2000"/><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:hideMark/></w:tcPr><w:p/></w:tc><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr><w:p/></w:tc></w:tr></w:tbl><w:p/>"#;
let hides = |d: &Document| -> Vec<bool> {
d.body
.iter()
.find_map(|b| {
if let wordcraft_doc::Block::Table(t) = &**b { Some(t.rows[0].cells.iter().map(|c| c.props.hide_mark).collect()) } else { None }
})
.unwrap()
};
let d = read_body(body);
assert_eq!(hides(&d), [true, false]);
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(hides(&again), [true, false]);
}
/// Text frames (`w:framePr` without a drop cap), as NIH's templates put a footer's page number in
/// one: read with their size, anchors, alignment and wrap, and written back (they were dropped on
/// save).
#[test]
fn text_frames_read_and_round_trip() {
let body = r#"<w:p><w:pPr><w:framePr w:wrap="around" w:vAnchor="text" w:hAnchor="margin" w:xAlign="center" w:y="1"/></w:pPr><w:r><w:t>3</w:t></w:r></w:p>
<w:p><w:pPr><w:framePr w:w="2880" w:h="720" w:hRule="exact" w:hSpace="180" w:wrap="notBeside" w:hAnchor="page" w:vAnchor="page" w:x="1440" w:y="2880"/></w:pPr><w:r><w:t>boxed</w:t></w:r></w:p>
<w:p><w:pPr><w:framePr w:wrap="sideways" w:hAnchor="nowhere" w:w="-5" w:x="NaN"/></w:pPr><w:r><w:t>junk</w:t></w:r></w:p>
<w:p><w:pPr><w:framePr w:dropCap="drop" w:lines="2" w:wrap="around" w:vAnchor="text" w:hAnchor="text"/></w:pPr><w:r><w:t>D</w:t></w:r></w:p>
<w:p><w:r><w:t>rop cap text</w:t></w:r></w:p>"#;
let d = read_body(body);
let p = paras(&d);
let f = p[0].props.frame.clone().expect("a frame");
assert_eq!(
(f.wrap.as_str(), f.v_anchor.as_str(), f.h_anchor.as_str(), f.x_align.as_deref(), f.y),
("around", "text", "margin", Some("center"), 0.05)
);
let g = p[1].props.frame.clone().expect("a frame");
assert_eq!((g.w, g.h, g.h_exact, g.h_space, g.wrap.as_str(), g.x, g.y), (Some(144.0), Some(36.0), true, 9.0, "notBeside", 72.0, 144.0));
let cap = p.iter().find(|q| q.props.drop_cap.is_some()).expect("the drop cap");
assert!(cap.props.frame.is_none(), "a drop cap stays a drop cap");
let junk = p.iter().find(|q| q.text.contains("junk")).and_then(|q| q.props.frame.clone()).expect("a frame");
assert_eq!((junk.wrap.as_str(), junk.h_anchor.as_str(), junk.w, junk.x), ("auto", "page", None, 0.0), "unknown values fall back");
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
let q = paras(&again);
assert_eq!(q[0].props.frame, p[0].props.frame);
assert_eq!(q[1].props.frame, p[1].props.frame);
assert!(q.iter().any(|x| x.props.drop_cap == Some(2) && x.props.frame.is_none()));
}
+45
View File
@@ -163,6 +163,51 @@ const SNIPPETS: &[&str] = &[
"<w:sectPr/>",
];
#[test]
fn malformed_or_hostile_bibliography_never_panics() {
let doc = format!("<w:document {W_NS}><w:body><w:p><w:r><w:t>body</w:t></w:r></w:p></w:body></w:document>");
let cases: &[&[u8]] = &[
b"<not_sources/>",
b"<b:Sources xmlns:b=\"http://schemas.openxmlformats.org/officeDocument/2006/bibliography\"><unclosed",
b"<b:Sources xmlns:b=\"http://schemas.openxmlformats.org/officeDocument/2006/bibliography\"><b:Source/></b:Sources>",
b"<b:Sources xmlns:b=\"http://schemas.openxmlformats.org/officeDocument/2006/bibliography\"><b:Source><b:Author><b:NameList><b:Person/></b:NameList></b:Author></b:Source></b:Sources>",
b"<b:Sources xmlns:b=\"http://schemas.openxmlformats.org/officeDocument/2006/bibliography\"><b:Source><b:Tag></b:Tag><b:SourceType></b:SourceType><b:Author><b:NameList><b:Person><b:Last>,,,,</b:Last><b:First></b:First></b:Person></b:NameList></b:Author></b:Source></b:Sources>",
b"<b:Sources xmlns:b=\"http://schemas.openxmlformats.org/officeDocument/2006/bibliography\"><b:Source><b:Author><b:Editor><b:NameList><b:Person><b:Last>Ed</b:Last></b:Person></b:NameList></b:Editor></b:Author><b:Edition>1st</b:Edition><b:Issue>99</b:Issue><b:Month>13</b:Month><b:Day>32</b:Day><b:YearAccessed>2025</b:YearAccessed><b:MonthAccessed>02</b:MonthAccessed><b:DayAccessed>30</b:DayAccessed><b:StandardNumber>doi:10.0/0</b:StandardNumber></b:Source></b:Sources>",
b"<b:Sources xmlns:b=\"http://schemas.openxmlformats.org/officeDocument/2006/bibliography\"><b:Source><b:Tag>Hostile1</b:Tag><b:Editor><b:Person/></b:Editor><b:Accessed>---///---</b:Accessed></b:Source></b:Sources>",
b"\xff\xfe<\0b\0:\0S\0",
];
let root_rels_bib = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml" Target="customXml/item1.xml"/></Relationships>"#;
for case in cases {
let z = zip(&[("_rels/.rels", root_rels_bib.as_bytes()), ("word/document.xml", doc.as_bytes()), ("customXml/item1.xml", case)]);
let res = read(&z);
if let Ok(d) = res {
let _ = write(&d);
}
}
}
/// A file with a huge number of bibliography sources (duplicates and distinct ones) reads in
/// linear time and keeps at most 10,000 of them.
#[test]
fn hostile_bibliography_source_count_is_capped() {
let doc = format!("<w:document {W_NS}><w:body><w:p><w:r><w:t>body</w:t></w:r></w:p></w:body></w:document>");
let mut sources = String::from(r#"<b:Sources xmlns:b="http://schemas.openxmlformats.org/officeDocument/2006/bibliography" StyleName="MLA">"#);
for i in 0..60_000 {
sources.push_str(&format!("<b:Source><b:Tag>T{i}</b:Tag><b:Title>Title {i}</b:Title></b:Source>"));
// Untagged duplicates all collapse into one source.
sources.push_str("<b:Source><b:Title>Same</b:Title></b:Source>");
}
sources.push_str("</b:Sources>");
let root_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml" Target="customXml/item1.xml"/></Relationships>"#;
let z = zip(&[("_rels/.rels", root_rels.as_bytes()), ("word/document.xml", doc.as_bytes()), ("customXml/item1.xml", sources.as_bytes())]);
let start = std::time::Instant::now();
let d = read(&z).unwrap();
assert_eq!(d.sources.len(), 10_000);
assert_eq!(d.sources.iter().filter(|s| s.tag.is_empty()).count(), 1, "untagged duplicates are kept once");
assert_eq!(d.settings.bib_style.as_deref(), Some("MLA"));
assert!(start.elapsed() < std::time::Duration::from_secs(30), "took {:?}", start.elapsed());
}
proptest! {
#![proptest_config(ProptestConfig { cases: 300, ..ProptestConfig::default() })]
+271 -34
View File
@@ -11,7 +11,7 @@ use wordcraft_doc::props::{
use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, SectionProps, SectionStart};
use wordcraft_doc::styles::{Style, StyleKind};
use wordcraft_doc::table::{Cell, Table};
use wordcraft_doc::{Block, Blocks, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, Watermark, para_block};
use wordcraft_doc::{Block, Blocks, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, Source, Watermark, para_block};
fn rt(doc: &Document) -> Document {
let bytes = wordcraft_docx::write(doc).expect("write");
@@ -105,6 +105,7 @@ fn every_char_prop_round_trips() {
rtl: Some(false),
cs: Some(true),
font_cs: Some("B Nazanin".into()),
font_ea: Some("宋体".into()),
size_cs: Some(13.0),
bold_cs: Some(false),
italic_cs: Some(true),
@@ -135,10 +136,16 @@ fn every_para_prop_round_trips() {
indent_left: Some(36.0),
indent_right: Some(18.0),
indent_first: Some(-18.0),
indent_left_chars: Some(1.5),
indent_right_chars: Some(0.0),
indent_first_chars: Some(-1.0),
space_before: Some(6.0),
space_after: Some(12.0),
space_before_auto: Some(true),
space_after_auto: Some(false),
line_spacing: Some(LineSpacing::Multiple(1.5)),
contextual_spacing: Some(true),
mirror_indents: Some(true),
keep_next: Some(true),
keep_lines: Some(false),
page_break_before: Some(true),
@@ -158,6 +165,20 @@ fn every_para_prop_round_trips() {
suppress_line_numbers: Some(true),
bidi: Some(false),
drop_cap: None,
frame: Some(wordcraft_doc::props::TextFrame {
w: Some(144.0),
h: Some(36.0),
h_exact: true,
x: 0.0,
y: 12.0,
x_align: Some("right".into()),
y_align: None,
h_anchor: "margin".into(),
v_anchor: "text".into(),
wrap: "around".into(),
h_space: 9.0,
v_space: 0.0,
}),
kinsoku: Some(false),
word_wrap: Some(false),
overflow_punct: Some(false),
@@ -680,41 +701,46 @@ fn word_note_mark_round_trips_once() {
/// or Word shows them as plain text with no Update Table.
#[test]
fn toc_field_wraps_its_entries() {
let mut head = Paragraph::with_text("Contents", CharProps::default()).styled("TOCHeading");
let end = head.len();
head.insert_object(
end,
InlineObject::Field { instr: "TOC \\o \"1-2\" \\h \\z \\u".into(), result: String::new(), locked: false, code: false },
&CharProps::default(),
)
.unwrap();
let entries = [("Intro\t1", "TOC1"), ("Detail\t2", "TOC2")];
let mut blocks = vec![head];
blocks.extend(entries.iter().map(|(t, st)| Paragraph::with_text(t, CharProps::default()).styled(st)));
blocks.push(Paragraph::with_text("Body", CharProps::default()));
let d = doc_with(blocks);
for entries in [&[("Intro\t1", "TOC1")][..], &[("Intro\t1", "TOC1"), ("Detail\t2", "TOC2")][..]] {
let mut head = Paragraph::with_text("Contents", CharProps::default()).styled("TOCHeading");
let end = head.len();
head.insert_object(
end,
InlineObject::Field { instr: "TOC \\o \"1-2\" \\h \\z \\u".into(), result: String::new(), locked: false, code: false },
&CharProps::default(),
)
.unwrap();
let mut blocks = vec![head];
blocks.extend(entries.iter().map(|(t, st)| Paragraph::with_text(t, CharProps::default()).styled(st)));
blocks.push(Paragraph::with_text("Body", CharProps::default()));
let d = doc_with(blocks);
let bytes = wordcraft_docx::write(&d).expect("write");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
let sdt = xml.find("<w:sdt>").expect("TOC content control");
assert!(xml.contains(r#"<w:docPartGallery w:val="Table of Contents"/>"#), "{xml}");
let (begin, fend) = (xml.find(r#"fldCharType="begin""#).unwrap(), xml.find(r#"fldCharType="end""#).unwrap());
let last_entry = xml.find("Detail").unwrap();
assert!(sdt < xml.find("Contents").unwrap() && xml.find("</w:sdt>").unwrap() < xml.find(">Body<").unwrap(), "{xml}");
assert!(begin < xml.find("Intro").unwrap() && fend > last_entry && fend < xml.find("</w:sdt>").unwrap(), "field must span the entries: {xml}");
// Word starts the field in the first entry; started in the heading, LibreOffice splits the heading.
let heading_end = xml[..xml.find("Intro").unwrap()].rfind("</w:p>").unwrap();
assert!(begin > heading_end, "field must start in the first entry, not the heading: {xml}");
let bytes = wordcraft_docx::write(&d).expect("write");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
let sdt = xml.find("<w:sdt>").expect("TOC content control");
assert!(xml.contains(r#"<w:docPartGallery w:val="Table of Contents"/>"#), "{xml}");
let (begin, fend) = (xml.find(r#"fldCharType="begin""#).unwrap(), xml.find(r#"fldCharType="end""#).unwrap());
let last_entry = xml.find(entries.last().unwrap().0.split('\t').next().unwrap()).unwrap();
assert!(sdt < xml.find("Contents").unwrap() && xml.find("</w:sdt>").unwrap() < xml.find(">Body<").unwrap(), "{xml}");
assert!(
begin < xml.find("Intro").unwrap() && fend > last_entry && fend < xml.find("</w:sdt>").unwrap(),
"field must span the entries: {xml}"
);
// Word starts the field in the first entry; started in the heading, LibreOffice splits the heading.
let heading_end = xml[..xml.find("Intro").unwrap()].rfind("</w:p>").unwrap();
assert!(begin > heading_end, "field must start in the first entry, not the heading: {xml}");
// WordCraft reads its own TOC back unchanged.
let r = wordcraft_docx::read(&bytes).expect("read");
let ps = paras(&r);
assert_eq!(ps.iter().map(|p| p.plain_text()).collect::<Vec<_>>(), ["Contents", "Intro\t1", "Detail\t2", "Body"]);
assert!(matches!(ps[0].objects.first(), Some(InlineObject::Field { instr, .. }) if instr.starts_with("TOC")), "{:?}", ps[0].objects);
assert!(ps[1].objects.is_empty(), "the field goes back on the heading: {:?}", ps[1].objects);
assert_eq!(ps[1].props.style.as_deref(), Some("TOC1"));
// WordCraft reads its own TOC back unchanged.
let r = wordcraft_docx::read(&bytes).expect("read");
let ps = paras(&r);
let expected: Vec<_> = std::iter::once("Contents").chain(entries.iter().map(|(t, _)| *t)).chain(std::iter::once("Body")).collect();
assert_eq!(ps.iter().map(|p| p.plain_text()).collect::<Vec<_>>(), expected);
assert!(matches!(ps[0].objects.first(), Some(InlineObject::Field { instr, .. }) if instr.starts_with("TOC")), "{:?}", ps[0].objects);
assert!(ps[1].objects.is_empty(), "the field goes back on the heading: {:?}", ps[1].objects);
assert_eq!(ps[1].props.style.as_deref(), Some("TOC1"));
}
}
/// Table Layout › Text Direction (#226): each direction is written as Word's `w:textDirection`
@@ -1137,6 +1163,38 @@ fn compatibility_mode_round_trips() {
}
}
/// Tools › Templates and Add-ins: the attached template (an external relationship of the settings
/// part) and "Automatically update document styles" (`w:linkStyles`) round-trip, written in the
/// schema's order (after `w:mirrorMargins`, before `w:trackRevisions`).
#[test]
fn attached_template_and_link_styles_round_trip() {
let mut d = Document::new();
d.settings.attached_template = Some("file:///C:/Templates/Report & Co.dotx".into());
d.settings.link_styles = true;
d.settings.mirror_margins = true;
d.settings.track_changes = true;
let bytes = wordcraft_docx::write(&d).expect("write");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("word/settings.xml").unwrap(), &mut xml).unwrap();
let at = |tag: &str| xml.find(tag).unwrap_or_else(|| panic!("{tag} missing: {xml}"));
assert!(at("<w:mirrorMargins") < at("<w:attachedTemplate"));
assert!(at("<w:attachedTemplate") < at("<w:linkStyles"));
assert!(at("<w:linkStyles") < at("<w:trackRevisions"));
let mut rels = String::new();
std::io::Read::read_to_string(&mut zip.by_name("word/_rels/settings.xml.rels").unwrap(), &mut rels).unwrap();
assert!(rels.contains("relationships/attachedTemplate") && rels.contains("TargetMode=\"External\""), "{rels}");
let r = wordcraft_docx::read(&bytes).expect("read");
assert_eq!(r.settings.attached_template.as_deref(), Some("file:///C:/Templates/Report & Co.dotx"));
assert!(r.settings.link_styles);
// Detached: neither element nor relationship is written.
let plain = rt(&Document::new());
assert_eq!(plain.settings.attached_template, None);
assert!(!plain.settings.link_styles);
}
#[test]
fn settings_core_theme_round_trip() {
let mut d = Document::new();
@@ -1223,6 +1281,21 @@ fn kitchen_sink() -> Document {
blocks.push(Arc::new(Block::Table(t)));
blocks.push(para_block(Paragraph::new()));
d.body = blocks;
d.sources.push(Source {
tag: "Sink2026".into(),
kind: "book".into(),
author: "Author, First".into(),
title: "Title".into(),
year: "2026".into(),
editor: "Editor, Chief".into(),
edition: "3rd".into(),
issue: "2".into(),
month: "08".into(),
day: "24".into(),
accessed: "2026-08-25".into(),
doi: "10.1234/sink.2026".into(),
..Default::default()
});
d
}
@@ -1238,9 +1311,25 @@ fn second_generation_is_stable() {
assert_eq!(d2.revisions, d1.revisions);
assert_eq!(d2.settings, d1.settings);
assert_eq!(d2.media, d1.media);
assert_eq!(d2.sources, d1.sources);
assert_eq!(d2, d1);
}
/// A Word XML Document (Flat OPC) carries the same package as a .docx: reading it back gives
/// what the .docx round trip gives, pictures included (#548).
#[test]
fn flat_opc_round_trip_equals_docx() {
let d = kitchen_sink();
let xml = wordcraft_docx::write_flat(&d).expect("write flat");
let text = std::str::from_utf8(&xml).expect("utf-8");
assert!(text.starts_with("<?xml") && text.contains("<?mso-application progid=\"Word.Document\"?>"));
assert!(text.contains("pkg:name=\"/word/document.xml\"") && text.contains("<pkg:binaryData>"));
assert_eq!(wordcraft_docx::sniff_xml(&xml), wordcraft_docx::XmlKind::FlatOpc);
let back = wordcraft_docx::read_xml(&xml).expect("read flat");
assert_eq!(back, rt(&d));
assert_eq!(back.media.len(), 1);
}
#[test]
fn builtin_stylesheet_survives() {
let d = Document::new();
@@ -1651,6 +1740,154 @@ fn rotated_group_and_shadow_rotation_round_trip() {
assert_eq!(got, [(20.0, Some(true)), (20.0, Some(false))]);
}
#[test]
fn bibliography_sources_round_trip() {
let mut d = Document::new();
d.sources.push(Source {
tag: "Rivera2021".into(),
kind: "book".into(),
author: "Rivera, Alex; Chen, Mei".into(),
title: "Shared Spaces".into(),
year: "2021".into(),
publisher: "Harbor Books".into(),
city: "Portland".into(),
editor: "Johnson, Robert".into(),
edition: "2nd".into(),
month: "10".into(),
day: "15".into(),
..Default::default()
});
d.sources.push(Source {
tag: "Smith2023".into(),
kind: "article".into(),
author: "Smith, John David".into(),
title: "Modern Typography".into(),
year: "2023".into(),
journal: "Design Quarterly".into(),
volume: "12".into(),
pages: "45-60".into(),
url: "https://example.com/typo".into(),
issue: "4".into(),
doi: "10.1000/182".into(),
..Default::default()
});
d.sources.push(Source {
tag: "WebSite2024".into(),
kind: "website".into(),
author: "Solo".into(),
title: "Open Fonts".into(),
year: "2024".into(),
url: "https://example.org".into(),
accessed: "2024-05-15".into(),
..Default::default()
});
d.sources.push(Source {
tag: "Chapter2025".into(),
kind: "bookSection".into(),
author: "Lee, Harper".into(),
title: "Chapter One".into(),
year: "2025".into(),
journal: "Anthology of Prose".into(),
pages: "1-20".into(),
..Default::default()
});
d.sources.push(Source {
tag: "Misc2026".into(),
kind: "other".into(),
title: "Archive Manuscript".into(),
year: "2026".into(),
city: "Oxford".into(),
..Default::default()
});
let bytes = wordcraft_docx::write(&d).expect("write");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
// Verify package-root customXml parts exist
assert!(zip.by_name("customXml/item1.xml").is_ok());
assert!(zip.by_name("customXml/itemProps1.xml").is_ok());
assert!(zip.by_name("customXml/_rels/item1.xml.rels").is_ok());
let mut rels_xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("_rels/.rels").unwrap(), &mut rels_xml).unwrap();
assert!(rels_xml.contains("relationships/customXml"), "{rels_xml}");
assert!(rels_xml.contains("Target=\"customXml/item1.xml\""), "{rels_xml}");
let mut ct_xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("[Content_Types].xml").unwrap(), &mut ct_xml).unwrap();
assert!(ct_xml.contains("PartName=\"/customXml/item1.xml\""), "{ct_xml}");
assert!(ct_xml.contains("PartName=\"/customXml/itemProps1.xml\""), "{ct_xml}");
let mut item_xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("customXml/item1.xml").unwrap(), &mut item_xml).unwrap();
assert!(item_xml.contains("SelectedStyle=\"\\APA.XSL\""), "{item_xml}");
assert!(item_xml.contains("StyleName=\"APA\""), "{item_xml}");
assert!(item_xml.contains("<b:SourceType>Book</b:SourceType>"), "{item_xml}");
assert!(item_xml.contains("<b:SourceType>JournalArticle</b:SourceType>"), "{item_xml}");
assert!(item_xml.contains("<b:SourceType>InternetSite</b:SourceType>"), "{item_xml}");
assert!(item_xml.contains("<b:SourceType>BookSection</b:SourceType>"), "{item_xml}");
assert!(item_xml.contains("<b:SourceType>Misc</b:SourceType>"), "{item_xml}");
assert!(item_xml.contains("<b:Author><b:Author><b:NameList><b:Person>"), "{item_xml}");
assert!(item_xml.contains("<b:Last>Rivera</b:Last><b:First>Alex</b:First>"), "{item_xml}");
assert!(item_xml.contains("<b:Last>Smith</b:Last><b:First>John</b:First><b:Middle>David</b:Middle>"), "{item_xml}");
assert!(item_xml.contains("<b:Last>Solo</b:Last>"), "{item_xml}");
assert!(item_xml.contains("<b:Editor>"), "{item_xml}");
assert!(item_xml.contains("<b:Last>Johnson</b:Last><b:First>Robert</b:First>"), "{item_xml}");
assert!(item_xml.contains("<b:Edition>2nd</b:Edition>"), "{item_xml}");
assert!(item_xml.contains("<b:Month>10</b:Month>"), "{item_xml}");
assert!(item_xml.contains("<b:Day>15</b:Day>"), "{item_xml}");
assert!(item_xml.contains("<b:Issue>4</b:Issue>"), "{item_xml}");
assert!(item_xml.contains("<b:DOI>10.1000/182</b:DOI>"), "{item_xml}");
assert!(item_xml.contains("<b:YearAccessed>2024</b:YearAccessed>"), "{item_xml}");
assert!(item_xml.contains("<b:MonthAccessed>05</b:MonthAccessed>"), "{item_xml}");
assert!(item_xml.contains("<b:DayAccessed>15</b:DayAccessed>"), "{item_xml}");
assert!(item_xml.contains("<b:Accessed>2024-05-15</b:Accessed>"), "{item_xml}");
let back = wordcraft_docx::read(&bytes).expect("read");
assert_eq!(back.sources.len(), 5);
assert_eq!(back.sources[0].tag, "Rivera2021");
assert_eq!(back.sources[0].kind, "book");
assert_eq!(back.sources[0].author, "Rivera, Alex; Chen, Mei");
assert_eq!(back.sources[0].title, "Shared Spaces");
assert_eq!(back.sources[0].year, "2021");
assert_eq!(back.sources[0].publisher, "Harbor Books");
assert_eq!(back.sources[0].city, "Portland");
assert_eq!(back.sources[0].editor, "Johnson, Robert");
assert_eq!(back.sources[0].edition, "2nd");
assert_eq!(back.sources[0].month, "10");
assert_eq!(back.sources[0].day, "15");
assert_eq!(back.sources[1].tag, "Smith2023");
assert_eq!(back.sources[1].kind, "article");
assert_eq!(back.sources[1].author, "Smith, John David");
assert_eq!(back.sources[1].title, "Modern Typography");
assert_eq!(back.sources[1].journal, "Design Quarterly");
assert_eq!(back.sources[1].volume, "12");
assert_eq!(back.sources[1].pages, "45-60");
assert_eq!(back.sources[1].url, "https://example.com/typo");
assert_eq!(back.sources[1].issue, "4");
assert_eq!(back.sources[1].doi, "10.1000/182");
assert_eq!(back.sources[2].tag, "WebSite2024");
assert_eq!(back.sources[2].kind, "website");
assert_eq!(back.sources[2].author, "Solo");
assert_eq!(back.sources[2].title, "Open Fonts");
assert_eq!(back.sources[2].url, "https://example.org");
assert_eq!(back.sources[2].accessed, "2024-05-15");
assert_eq!(back.sources[3].tag, "Chapter2025");
assert_eq!(back.sources[3].kind, "bookSection");
assert_eq!(back.sources[3].author, "Lee, Harper");
assert_eq!(back.sources[3].title, "Chapter One");
assert_eq!(back.sources[3].journal, "Anthology of Prose");
assert_eq!(back.sources[3].pages, "1-20");
assert_eq!(back.sources[4].tag, "Misc2026");
assert_eq!(back.sources[4].kind, "other");
assert_eq!(back.sources[4].title, "Archive Manuscript");
assert_eq!(back.sources[4].city, "Oxford");
}
#[test]
fn wrap_polygons_round_trip() {
use wordcraft_doc::wrap::{WrapPolygon, shape_outline};
@@ -0,0 +1,50 @@
//! Section text direction (`w:textDirection` in `w:sectPr`, ECMA-376 Part 1 §17.6.20). Fixtures
//! are hand-written from the spec.
use std::io::{Read, Write};
use wordcraft_doc::props::TextDirection;
fn docx(body: &str) -> Vec<u8> {
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
);
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn document_xml(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
s
}
#[test]
fn section_text_direction_round_trips_in_schema_order() {
// Three sections: vertical East Asian (tbRl), bottom-to-top (btLr) and a value no one knows.
let sect = |dir: &str| format!(r#"<w:sectPr><w:titlePg/><w:textDirection w:val="{dir}"/><w:bidi/></w:sectPr>"#);
let body = format!(
r#"<w:p><w:pPr>{}</w:pPr><w:r><w:t>縦書き</w:t></w:r></w:p><w:p><w:pPr>{}</w:pPr><w:r><w:t>Up</w:t></w:r></w:p><w:p><w:r><w:t>Odd</w:t></w:r></w:p>{}"#,
sect("tbRl"),
sect("btLr"),
sect("diagonal")
);
let d = wordcraft_docx::read(&docx(&body)).unwrap();
let dirs: Vec<TextDirection> = d.sections().iter().map(|(_, s)| s.text_direction).collect();
assert_eq!(dirs, vec![TextDirection::Down, TextDirection::Up, TextDirection::Horizontal]);
let out = wordcraft_docx::write(&d).unwrap();
let xml = document_xml(&out);
// After w:titlePg and before w:bidi (CT_SectPr's sequence); a horizontal section writes none.
assert!(xml.contains(r#"<w:titlePg/><w:textDirection w:val="tbRl"/><w:bidi/>"#), "{xml}");
assert!(xml.contains(r#"<w:textDirection w:val="btLr"/>"#));
assert_eq!(xml.matches("w:textDirection").count(), 2);
let back = wordcraft_docx::read(&out).unwrap();
let again: Vec<TextDirection> = back.sections().iter().map(|(_, s)| s.text_direction).collect();
assert_eq!(again, dirs);
}
+2 -2
View File
@@ -53,9 +53,9 @@ fn text_box(d: &mut Document, text: &str, body: TextBody) -> InlineObject {
#[test]
fn direction_alignment_and_links_round_trip() {
let mut d = Document::from_text("a\nb\nc");
let a = text_box(&mut d, "Flows on", TextBody { vert: TextVert::Vert, anchor: VAlign::Center, next: None });
let a = text_box(&mut d, "Flows on", TextBody { vert: TextVert::Vert, anchor: VAlign::Center, ..Default::default() });
let b = text_box(&mut d, "", TextBody::default());
let c = text_box(&mut d, "", TextBody { vert: TextVert::Stacked, anchor: VAlign::Bottom, next: None });
let c = text_box(&mut d, "", TextBody { vert: TextVert::Stacked, anchor: VAlign::Bottom, ..Default::default() });
for (i, o) in [a, b, c].into_iter().enumerate() {
d.insert_object(&wordcraft_doc::Pos::body(i, 1), o, &Default::default()).unwrap();
}
+177
View File
@@ -0,0 +1,177 @@
//! Original field-boundary fixtures from the public OOXML complex-field contract.
use std::io::Write;
use wordcraft_doc::{InlineObject, Path, StoryRef};
fn package(body: &str) -> Vec<u8> {
let xml = format!(r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#);
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, value) in [
("word/document.xml", xml.as_str()),
(
"_rels/.rels",
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="main" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#,
),
] {
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(value.as_bytes()).unwrap();
}
zip.finish().unwrap().into_inner()
}
fn start(code: &str) -> String {
format!(
r#"<w:r><w:fldChar w:fldCharType="begin" w:fldLock="true"/></w:r><w:r><w:instrText>{code}</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#
)
}
const END: &str = r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#;
const PAGE: &str = r#"<w:fldSimple w:instr="PAGEREF Original"><w:r><w:t>7</w:t></w:r></w:fldSimple>"#;
const ENTRY: &str = r#"<w:hyperlink w:anchor="Original"><w:r><w:rPr><w:b/><w:i/></w:rPr><w:t>Scope</w:t></w:r><w:r><w:tab/></w:r>"#;
#[test]
fn unstyled_toc_keeps_range_nested_field_and_formatting_through_two_edited_saves() {
for split in [false, true] {
let content = if split {
format!("{ENTRY}{PAGE}</w:hyperlink></w:p><w:p><w:r><w:t>Delivery</w:t></w:r>")
} else {
format!("{ENTRY}{PAGE}</w:hyperlink>")
};
let body = format!(
r#"<w:p><w:r><w:t>Before.</w:t></w:r></w:p><w:p>{}{content}{END}</w:p><w:p><w:r><w:t>After.</w:t></w:r></w:p>"#,
start(r#"TOC \o "1-2" \h"#)
);
let mut doc = wordcraft_docx::read(&package(&body)).unwrap();
for cycle in 0..=2 {
let ranges = doc.field_ranges(StoryRef::Body);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].instr, r#"TOC \o "1-2" \h"#);
assert!(ranges[0].locked);
assert_eq!(ranges[0].start.path, Path::top(1));
assert_eq!(ranges[0].start.off, 0);
assert_eq!(ranges[0].end.path, Path::top(if split { 2 } else { 1 }));
let entry = doc.body[1].as_para().unwrap();
assert_eq!(entry.plain_text(), "Scope\t7");
let props = entry.props_of_char(wordcraft_doc::para::OBJ.len_utf8());
assert_eq!(props.bold, Some(true));
assert_eq!(props.italic, Some(true));
assert_eq!(props.link.as_deref(), Some("#Original"));
assert!(
entry.objects.iter().any(|o| matches!(o, InlineObject::Field { instr, result, .. } if instr == "PAGEREF Original" && result == "7"))
);
assert_eq!(doc.body.last().unwrap().as_para().unwrap().plain_text(), "After.");
if cycle == 2 {
break;
}
doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap().insert_text(0, "Independent edit. ", &Default::default()).unwrap();
doc = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
}
}
}
#[test]
fn styled_heading_does_not_expand_partial_custom_or_short_ranges() {
let heading = r#"<w:p><w:pPr><w:pStyle w:val="TOCHeading"/></w:pPr><w:r><w:t>Contents</w:t></w:r></w:p>"#;
let extra = r#"<w:p><w:pPr><w:pStyle w:val="TOC1"/></w:pPr><w:r><w:t>Outside entry.</w:t></w:r></w:p>"#;
for (style, prefix, suffix, following) in
[("TOC1", "Prefix.", "", ""), ("TOC1", "", "Suffix.", ""), ("TOC1", "", "", extra), ("CompanyContents", "", "", "")]
{
let body = format!(
r#"{heading}<w:p><w:pPr><w:pStyle w:val="{style}"/></w:pPr><w:r><w:t>{prefix}</w:t></w:r>{}<w:r><w:t>Scope</w:t></w:r>{END}<w:r><w:t>{suffix}</w:t></w:r></w:p>{following}<w:p><w:r><w:t>Body.</w:t></w:r></w:p>"#,
start("TOC")
);
let mut doc = wordcraft_docx::read(&package(&body)).unwrap();
let text = doc.plain_text(StoryRef::Body);
for cycle in 0..=2 {
let ranges = doc.field_ranges(StoryRef::Body);
assert_eq!(ranges.len(), 1, "{style} {prefix} {suffix} {following}");
assert_eq!(ranges[0].start.path, Path::top(1));
assert_eq!(ranges[0].end.path, Path::top(1));
assert_eq!(ranges[0].start.off, prefix.len());
assert_eq!(ranges[0].end.off, prefix.len() + wordcraft_doc::para::OBJ.len_utf8() + "Scope".len());
assert_eq!(doc.plain_text(StoryRef::Body), text);
if cycle == 2 {
break;
}
doc = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
}
}
}
#[test]
fn simple_lowercase_toc_and_multiple_tocs_keep_their_own_ranges() {
let body = format!(
r#"<w:p><w:fldSimple w:instr="toc \h" w:fldLock="true">{ENTRY}{PAGE}</w:hyperlink></w:fldSimple><w:r><w:t>Between.</w:t></w:r>{}<w:r><w:t>Other result.</w:t></w:r>{END}<w:r><w:t>After.</w:t></w:r></w:p>"#,
start(r#"TOC \o "1-1""#)
);
let mut doc = wordcraft_docx::read(&package(&body)).unwrap();
for cycle in 0..=2 {
let r = doc.field_ranges(StoryRef::Body);
assert_eq!(r.len(), 2);
assert_eq!(r[0].instr, r#"toc \h"#);
assert_eq!(r[1].instr, r#"TOC \o "1-1""#);
assert!(r.iter().all(|r| r.locked));
assert!(r[0].end.off < r[1].start.off);
assert_eq!(doc.plain_text(StoryRef::Body), "Scope\t7Between.Other result.After.");
if cycle == 2 {
break;
}
doc = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
}
}
#[test]
fn toc_in_table_cell_does_not_capture_the_next_cell() {
let body = format!(
r#"<w:tbl><w:tr><w:tc><w:p>{}{ENTRY}{PAGE}</w:hyperlink>{END}</w:p></w:tc><w:tc><w:p><w:r><w:t>Other cell.</w:t></w:r></w:p></w:tc></w:tr></w:tbl>"#,
start("TOC")
);
let mut doc = wordcraft_docx::read(&package(&body)).unwrap();
for cycle in 0..=2 {
let ranges = doc.field_ranges(StoryRef::Body);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start.path, wordcraft_doc::Path(vec![0, 0, 0, 0]));
assert_eq!(ranges[0].end.path, ranges[0].start.path);
assert_eq!(doc.body[0].as_table().unwrap().rows[0].cells[1].blocks[0].as_para().unwrap().plain_text(), "Other cell.");
if cycle == 2 {
break;
}
doc = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
}
}
#[test]
fn missing_end_and_excessive_nesting_stay_bounded_and_save_balanced() {
for (starts, ends) in [(1, 0), (40, 40)] {
let body = format!("<w:p>{}<w:r><w:t>Visible result.</w:t></w:r>{}</w:p>", start("TOC").repeat(starts), END.repeat(ends));
let doc = wordcraft_docx::read(&package(&body)).unwrap();
let saved = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
assert!(!saved.has_unbalanced_field_ranges());
assert_eq!(saved.plain_text(StoryRef::Body), "Visible result.");
assert!(saved.field_ranges(StoryRef::Body).len() <= 32);
}
}
#[test]
fn section_break_keeps_an_explicit_range_and_toc_prefix_is_not_a_toc() {
let body = format!(
r#"<w:p><w:pPr><w:pStyle w:val="TOCHeading"/></w:pPr><w:r><w:t>Contents</w:t></w:r></w:p><w:p><w:pPr><w:pStyle w:val="TOC1"/><w:sectPr/></w:pPr>{}<w:r><w:t>Scope</w:t></w:r>{END}</w:p><w:p>{}<w:r><w:t>Other.</w:t></w:r>{END}</w:p>"#,
start("TOC"),
start("TOC_OTHER")
);
let doc = wordcraft_docx::read(&package(&body)).unwrap();
for d in [&doc, &wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap()] {
let ranges = d.field_ranges(StoryRef::Body);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start.path, Path::top(1));
assert!(d.body[1].as_para().unwrap().section.is_some());
assert!(
d.body[2]
.as_para()
.unwrap()
.objects
.iter()
.any(|o| matches!(o, InlineObject::Field { instr, result, .. } if instr == "TOC_OTHER" && result == "Other."))
);
}
}
+215
View File
@@ -0,0 +1,215 @@
//! Word XML files (#548): Flat OPC (Word XML Document) and Word 2003 XML, from hand-written
//! samples (never files produced by Word).
use base64::Engine as _;
use wordcraft_doc::props::Align;
use wordcraft_doc::section::NumFormat;
use wordcraft_doc::{Block, Document, InlineObject, Paragraph};
use wordcraft_docx::{DocxError, XmlKind, read_xml, sniff_xml};
fn paras(doc: &Document) -> Vec<&Paragraph> {
doc.body.iter().filter_map(|b| b.as_para()).collect()
}
fn part_text(doc: &Document, id: Option<u32>) -> String {
id.and_then(|i| doc.parts.get(&i))
.map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::<Vec<_>>().join("\n"))
.unwrap_or_default()
}
fn tiny_png_b64() -> String {
let img = image::RgbaImage::from_fn(2, 2, |x, y| image::Rgba([x as u8 * 100, y as u8 * 100, 50, 255]));
let mut out = std::io::Cursor::new(Vec::new());
img.write_to(&mut out, image::ImageFormat::Png).unwrap();
let b64 = base64::engine::general_purpose::STANDARD.encode(out.into_inner());
// Line breaks inside base64, as the formats write it.
b64.as_bytes().chunks(20).map(|c| std::str::from_utf8(c).unwrap()).collect::<Vec<_>>().join("\n")
}
/// A minimal Flat OPC package written by hand: namespaces declared on the package element only,
/// a hyperlink relationship, a picture in base64 and an empty part.
#[test]
fn reads_a_hand_written_flat_opc_package() {
let png = tiny_png_b64();
let xml = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<?mso-application progid="Word.Document"?>
<pkg:package xmlns:pkg="http://schemas.microsoft.com/office/2006/xmlPackage" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<pkg:part pkg:name="/_rels/.rels" pkg:contentType="application/vnd.openxmlformats-package.relationships+xml">
<pkg:xmlData>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>
</pkg:xmlData>
</pkg:part>
<pkg:part pkg:name="/word/_rels/document.xml.rels" pkg:contentType="application/vnd.openxmlformats-package.relationships+xml">
<pkg:xmlData><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.org/flat" TargetMode="External"/><Relationship Id="rId6" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/dot.png"/></Relationships></pkg:xmlData>
</pkg:part>
<pkg:part pkg:name="/word/document.xml" pkg:contentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml">
<pkg:xmlData>
<w:document xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:v="urn:schemas-microsoft-com:vml"><w:body>
<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>Seed &amp; soil</w:t></w:r></w:p>
<w:p><w:hyperlink r:id="rId5"><w:r><w:t>link</w:t></w:r></w:hyperlink></w:p>
<w:p><w:r><w:pict><v:shape style="width:24pt;height:12pt"><v:imagedata r:id="rId6"/></v:shape></w:pict></w:r></w:p>
</w:body></w:document>
</pkg:xmlData>
</pkg:part>
<pkg:part pkg:name="/word/media/dot.png" pkg:contentType="image/png" pkg:compression="store">
<pkg:binaryData>{png}</pkg:binaryData>
</pkg:part>
<pkg:part pkg:name="/word/empty.xml" pkg:contentType="application/xml"><pkg:xmlData/></pkg:part>
</pkg:package>"#
);
assert_eq!(sniff_xml(xml.as_bytes()), XmlKind::FlatOpc);
let doc = read_xml(xml.as_bytes()).unwrap();
let ps = paras(&doc);
assert_eq!(ps[0].plain_text(), "Seed & soil");
assert_eq!(ps[0].props_at(0).bold, Some(true));
assert_eq!(ps[1].props_at(0).link.as_deref(), Some("https://example.org/flat"));
assert!(matches!(ps[2].objects.first(), Some(InlineObject::Image { w, h, .. }) if *w == 24.0 && *h == 12.0));
assert_eq!(doc.media.len(), 1);
}
/// A Word 2003 XML Document, hand-written from the Office 2003 XML Reference Schemas.
fn wordml_2003_sample() -> String {
let png = tiny_png_b64();
format!(
r##"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<?mso-application progid="Word.Document"?>
<w:wordDocument xmlns:w="http://schemas.microsoft.com/office/word/2003/wordml" xmlns:wx="http://schemas.microsoft.com/office/word/2003/auxHint" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:aml="http://schemas.microsoft.com/aml/2001/core" w:macrosPresent="no">
<o:DocumentProperties><o:Title>Garden notes</o:Title><o:Author>Ada Gardener</o:Author><o:Keywords>beans</o:Keywords></o:DocumentProperties>
<w:fonts><w:defaultFonts w:ascii="Georgia" w:fareast="Georgia" w:h-ansi="Georgia" w:cs="Georgia"/></w:fonts>
<w:lists>
<w:listDef w:listDefId="3"><w:plt w:val="HybridMultilevel"/>
<w:lvl w:ilvl="0"><w:start w:val="1"/><w:nfc w:val="4"/><w:lvlText w:val="%1)"/><w:lvlJc w:val="left"/><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl>
</w:listDef>
<w:list w:ilfo="2"><w:ilst w:val="3"/></w:list>
</w:lists>
<w:styles>
<w:versionOfBuiltInStyleNames w:val="4"/>
<w:style w:type="paragraph" w:default="on" w:styleId="Normal"><w:name w:val="Normal"/><w:rPr><wx:font wx:val="Georgia"/><w:sz w:val="24"/><w:sz-cs w:val="24"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><wx:uiName wx:val="Heading 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:pPr><w:keepNext/><w:spacing w:before="240" w:after="60"/><w:outlineLvl w:val="0"/></w:pPr><w:rPr><w:b/><w:sz w:val="32"/></w:rPr></w:style>
</w:styles>
<w:docPr><w:view w:val="print"/><w:zoom w:percent="100"/><w:footnotePr><w:footnote w:type="separator"><w:p><w:r><w:separator/></w:r></w:p></w:footnote></w:footnotePr></w:docPr>
<w:body>
<wx:sect>
<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><aml:annotation aml:id="0" w:type="Word.Bookmark.Start" w:name="plan"/><aml:annotation aml:id="1" w:type="Word.Comment.Start"/><w:r><w:t>Plan</w:t></w:r><aml:annotation aml:id="1" w:type="Word.Comment.End"/><w:r><aml:annotation aml:id="1" aml:author="Bo" w:initials="B" w:type="Word.Comment"><aml:content><w:p><w:r><w:t>Too short?</w:t></w:r></w:p></aml:content></aml:annotation></w:r><aml:annotation aml:id="0" w:type="Word.Bookmark.End"/></w:p>
<w:p><w:pPr><w:jc w:val="center"/><w:ind w:first-line="360"/><w:spacing w:line="360" w:line-rule="auto"/></w:pPr>
<w:r><w:t>Plain </w:t></w:r>
<w:r><w:rPr><w:rFonts w:ascii="Courier New" w:h-ansi="Courier New"/><w:b/><w:i/><w:u w:val="single"/><w:sz w:val="28"/><w:color w:val="FF0000"/></w:rPr><w:t>loud</w:t></w:r>
<w:r><w:tab/><w:t>then</w:t><w:br/><w:t>more</w:t></w:r>
<w:r><w:footnote><w:p><w:r><w:footnoteRef/></w:r><w:r><w:t>Sown in May.</w:t></w:r></w:p></w:footnote></w:r>
</w:p>
<w:p><w:hlink w:dest="https://example.org/beans" w:screenTip="Beans"><w:r><w:rPr><w:rStyle w:val="Hyperlink"/></w:rPr><w:t>bean guide</w:t></w:r></w:hlink></w:p>
<w:p><w:pPr><w:listPr><w:ilvl w:val="0"/><w:ilfo w:val="2"/><wx:t wx:val="a)"/></w:listPr></w:pPr><w:r><w:t>Water the beans</w:t></w:r><aml:annotation aml:id="7" aml:author="Ada" aml:createdate="2004-05-01T10:00:00Z" w:type="Word.Insertion"><aml:content><w:r><w:t> daily</w:t></w:r></aml:content></aml:annotation></w:p>
<w:tbl>
<w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr>
<w:tblGrid><w:gridCol w:w="2000"/><w:gridCol w:w="2000"/></w:tblGrid>
<w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:vmerge w:val="restart"/></w:tcPr><w:p><w:r><w:t>A1</w:t></w:r></w:p></w:tc><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr><w:p><w:r><w:t>B1</w:t></w:r></w:p></w:tc></w:tr>
<w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:vmerge/></w:tcPr><w:p/></w:tc><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr><w:p><w:r><w:t>B2</w:t></w:r></w:p></w:tc></w:tr>
</w:tbl>
<w:p><w:r><w:pict><w:binData w:name="wordml://02000001.png">{png}</w:binData><v:shape id="_x0000_i1025" type="#_x0000_t75" style="width:36pt;height:18pt"><v:imagedata src="wordml://02000001.png" o:title="dot"/></v:shape></w:pict></w:r></w:p>
<w:sectPr>
<w:hdr w:type="odd"><w:p><w:r><w:t>Garden header</w:t></w:r></w:p></w:hdr>
<w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1800" w:bottom="1440" w:left="1800" w:header="720" w:footer="720" w:gutter="0"/>
</w:sectPr>
</wx:sect>
</w:body>
</w:wordDocument>"##
)
}
#[test]
fn imports_word_2003_xml() {
let xml = wordml_2003_sample();
assert_eq!(sniff_xml(xml.as_bytes()), XmlKind::WordMl2003);
let doc = read_xml(xml.as_bytes()).unwrap();
assert_eq!(doc.core.title, "Garden notes");
assert_eq!(doc.core.creator, "Ada Gardener");
let h1 = doc.styles.get("Heading1").expect("Heading1 style");
assert_eq!(h1.chr.bold, Some(true));
assert_eq!(h1.chr.size, Some(16.0));
let ps = paras(&doc);
assert_eq!(ps[0].plain_text(), "Plan");
assert_eq!(ps[0].props.style.as_deref(), Some("Heading1"));
assert!(ps[0].objects.iter().any(|o| matches!(o, InlineObject::BookmarkStart { name, .. } if name == "plan")));
let p = ps[1];
assert_eq!(p.props.align, Some(Align::Center));
assert_eq!(p.props.indent_first, Some(18.0));
let loud = p.text.find("loud").unwrap();
let c = p.props_of_char(loud);
assert_eq!((c.bold, c.italic, c.size, c.font.as_deref()), (Some(true), Some(true), Some(14.0), Some("Courier New")));
assert!(c.underline.is_some() && c.color.is_some());
assert!(p.plain_text().contains("Plain loud\tthen"));
let note = p.objects.iter().find_map(|o| match o {
InlineObject::NoteRef { id, .. } => Some(*id),
_ => None,
});
assert_eq!(part_text(&doc, note), "Sown in May.");
assert_eq!(ps[2].props_at(0).link.as_deref(), Some("https://example.org/beans"));
let listed = ps[3];
let num = listed.props.numbering.expect("list paragraph");
let lvl = doc.numbering.level(num.num, num.level).expect("list level");
assert_eq!((lvl.format, lvl.text.as_str(), lvl.indent), (NumFormat::LowerLetter, "%1)", 36.0));
let ins = listed.props_of_char(listed.text.find("daily").unwrap()).ins.and_then(|i| doc.revisions.get(i as usize));
assert_eq!(ins.map(|r| r.author.as_str()), Some("Ada"));
let comment = doc.comments.values().next().expect("comment");
assert_eq!((comment.author.as_str(), part_text(&doc, Some(comment.part)).as_str()), ("Bo", "Too short?"));
let table = doc.body.iter().find_map(|b| match b.as_ref() {
Block::Table(t) => Some(t),
_ => None,
});
let table = table.expect("table");
assert_eq!(table.rows.len(), 2);
assert_eq!(table.rows[0].cells[1].blocks.first().and_then(|b| b.as_para()).map(|p| p.plain_text()).as_deref(), Some("B1"));
let pic = ps.iter().flat_map(|p| p.objects.iter()).find(|o| matches!(o, InlineObject::Image { .. }));
assert!(matches!(pic, Some(InlineObject::Image { w, h, .. }) if *w == 36.0 && *h == 18.0));
assert_eq!(doc.media.len(), 1);
assert_eq!(doc.last_section.page_w, 612.0);
assert_eq!(doc.last_section.margin_left, 90.0);
assert_eq!(part_text(&doc, doc.last_section.headers.default), "Garden header");
}
#[test]
fn hostile_word_xml_is_an_error_not_a_panic() {
let flat = r#"<pkg:package xmlns:pkg="http://schemas.microsoft.com/office/2006/xmlPackage">"#;
let w2003 = r#"<w:wordDocument xmlns:w="http://schemas.microsoft.com/office/word/2003/wordml">"#;
// Other XML (and non-XML) is refused with the Word XML error.
for other in ["<html><body>hi</body></html>", "", "not xml at all", "<package/>"] {
assert_eq!(read_xml(other.as_bytes()), Err(DocxError::NotWordXml), "{other:?}");
}
// Broken packages: errors.
for bad in [
flat.to_string(),
format!("{flat}</pkg:package>"),
format!("{flat}<pkg:part pkg:name=\"/word/document.xml\"><pkg:binaryData>!!!not base64</pkg:binaryData></pkg:part></pkg:package>"),
format!("{flat}<pkg:part pkg:name=\"/word/document.xml\"><pkg:xmlData><w:document></pkg:xmlData></pkg:part></pkg:package>"),
] {
assert!(read_xml(bad.as_bytes()).is_err(), "{bad}");
}
// Lenient leftovers never panic.
for odd in [
format!("{flat}<pkg:part/><pkg:part pkg:name=\"\"><pkg:xmlData/></pkg:part><x/></pkg:package>"),
format!(
"{w2003}<w:body><w:p><w:r><w:footnote w:type=\"separator\"/><w:pict><w:binData w:name=\"a\">%%%</w:binData><v:imagedata src=\"a\"/></w:pict></w:r></w:p></w:body></w:wordDocument>"
),
format!(
"{w2003}<w:lists><w:list/><w:listDef/></w:lists><w:body><aml:annotation xmlns:aml=\"http://schemas.microsoft.com/aml/2001/core\" aml:id=\"x\" w:type=\"Word.Comment\"/><w:sectPr><w:hdr/><w:ftr w:type=\"first\"/></w:sectPr></w:body></w:wordDocument>"
),
format!("{w2003}{}", "<w:p>".repeat(5000)),
] {
let _ = read_xml(odd.as_bytes());
}
// Too many parts.
let mut many = String::from(flat);
for i in 0..20_001 {
many.push_str(&format!("<pkg:part pkg:name=\"/p{i}.bin\"><pkg:binaryData>AA==</pkg:binaryData></pkg:part>"));
}
many.push_str("</pkg:package>");
assert!(matches!(read_xml(many.as_bytes()), Err(DocxError::Limit(_))));
}
+27
View File
@@ -13,12 +13,14 @@ File|Backstage|Open|file.open
File|Backstage|Save|file.save
File|Backstage|Save As|file.saveAs
File|Backstage|Print|file.print
File|Backstage|Print Markup|file.printMarkup
File|Backstage|Close|file.close
File|Backstage|Info|file.info
File|Backstage|Properties|file.properties
File|Backstage|Export PDF|file.exportPdf
File|Backstage|Export Image|file.exportPng
File|Backstage|Options|file.options
File|Options|Numerals|view.numeral
File|Backstage|Share|file.share
File|Backstage|Protect Document|file.protect
File|Backstage|Inspect Document|file.inspect
@@ -29,9 +31,11 @@ File|Backstage|Recover Unsaved|file.recover
File|Backstage|New from Template|file.newFromTemplate
File|Backstage|Save as Template|file.saveTemplate
Quick Access|Toolbar|Undo|edit.undo
Quick Access|Toolbar|Undo List|edit.undoTo
Quick Access|Toolbar|Redo|edit.redo
Quick Access|Toolbar|Repeat|edit.repeat
Quick Access|Toolbar|AutoSave|file.autosave
Title Bar|Search|Search (Tell Me)|tools.search
Home|Clipboard|Paste|edit.paste
Home|Clipboard|Paste Keep Text Only|edit.pasteText
Home|Clipboard|Paste Merge Formatting|edit.pasteMerge
@@ -220,6 +224,7 @@ Layout|Page Setup|Page Setup Dialog|layout.pageSetup
Layout|Page Setup|Vertical Alignment|layout.verticalAlign
Layout|Page Setup|Document Grid|layout.documentGrid
Layout|Page Setup|Section Direction|layout.sectionRtl
Layout|Page Setup|Text Direction|layout.textDirection
Layout|Paragraph|Indent|para.indents
Layout|Paragraph|Spacing|para.spacing
Layout|Arrange|Position|arrange.position
@@ -253,6 +258,7 @@ References|Index|Insert Index|references.index
References|Index|Update Index|references.updateIndex
References|Table of Authorities|Mark Citation|references.markCitation
References|Table of Authorities|Insert Table of Authorities|references.tableOfAuthorities
References|Table of Authorities|Update Table|references.updateAuthorities
References|Fields|Update Fields|references.updateFields
Mailings|Create|Envelopes|mailings.envelopes
Mailings|Create|Labels|mailings.labels
@@ -352,6 +358,7 @@ Table Design|Borders|Borders|table.borders
Table Design|Borders|Border Painter|table.borderPainter
Table Layout|Table|Select Table|table.selectTable
Table Layout|Table|Select Row|table.selectRow
Table Layout|Table|Select Column|table.selectColumn
Table Layout|Table|Select Cell|table.selectCell
Table Layout|Table|View Gridlines|table.viewGridlines
Table Layout|Table|Properties|table.properties
@@ -404,6 +411,19 @@ Equation|Equation Options|Change to Inline / Display|equation.display
Equation|Equation Options|Justification|equation.justify
Equation|Equation Options|Equation Number (#)|equation.number
Equation|Equation Options|Structure Commands|equation.structure
Outlining|Outline Tools|Promote to Heading 1|outline.promoteToHeading1
Outlining|Outline Tools|Promote|outline.promote
Outlining|Outline Tools|Outline Level|outline.level
Outlining|Outline Tools|Demote|outline.demote
Outlining|Outline Tools|Demote to Body Text|outline.demoteToBody
Outlining|Outline Tools|Move Up|outline.moveUp
Outlining|Outline Tools|Move Down|outline.moveDown
Outlining|Outline Tools|Expand|outline.expand
Outlining|Outline Tools|Collapse|outline.collapse
Outlining|Outline Tools|Show Level|outline.showLevel
Outlining|Outline Tools|Show Text Formatting|outline.showFormatting
Outlining|Outline Tools|Show First Line Only|outline.firstLineOnly
Outlining|Close|Close Outline View|outline.close
Picture Format|Adjust|Remove Background|picture.removeBackground
Picture Format|Adjust|Corrections|picture.corrections
Picture Format|Adjust|Color|picture.color
@@ -419,6 +439,13 @@ Shape Format|Accessibility|Alt Text|object.altText
Picture Format|Size|Crop|picture.crop
Picture Format|Size|Size|picture.size
Picture Format|Size|Rotation|arrange.rotation
SmartArt Design|Create Graphic|Add Shape|smartArt.addShape
SmartArt Design|Create Graphic|Promote|smartArt.promote
SmartArt Design|Create Graphic|Demote|smartArt.demote
SmartArt Design|Create Graphic|Text Pane|smartArt.textPane
SmartArt Design|Layouts|Layouts|smartArt.layout
SmartArt Design|SmartArt Styles|Change Colors|smartArt.colors
SmartArt Design|Reset|Reset Graphic|smartArt.reset
Chart Design|Type|Change Chart Type|chart.type
Chart Design|Data|Edit Data|chart.editData
Chart Design|Chart Layouts|Chart Title|chart.title
+99 -24
View File
@@ -48,7 +48,10 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("select.word", "Select Word", "Navigation", select_word).pure(),
CommandSpec::new("select.sentence", "Select Sentence", "Navigation", select_sentence).pure(),
CommandSpec::new("select.paragraph", "Select Paragraph", "Navigation", select_paragraph).pure(),
CommandSpec::new("select.line", "Select Line", "Navigation", select_line).pure(),
CommandSpec::new("select.dragUnit", "Extend Selection by Units", "Navigation", drag_unit)
.params(r#"{"unit": "word|sentence|line|paragraph|document", "start": Pos, "end": Pos, "pos": Pos}"#)
.pure(),
CommandSpec::new("select.line", "Select Line", "Navigation", select_line).params(r#"{"pos"?: Pos}"#).pure(),
CommandSpec::new("select.text", "Select Text", "Navigation", select_text).params(r#"{"text": string, "occurrence"?: n}"#).pure(),
CommandSpec::new("select.collapse", "Collapse Selection", "Navigation", |s, v| {
let to_end = p::bool(v, "end").unwrap_or(false);
@@ -287,41 +290,113 @@ fn select_range(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s)
}
fn select_word(s: &mut Session, _: &Value) -> CmdResult {
/// A unit a selection grows by: what a double-click (word), Ctrl/⌘+click (sentence) or
/// triple-click (paragraph) selects, and what dragging after it extends by. A click in the
/// selection bar (the left margin) selects a line; triple-click there the whole document.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Unit {
Word,
Sentence,
Line,
Paragraph,
Document,
}
impl Unit {
pub fn parse(s: &str) -> Option<Unit> {
match s {
"word" => Some(Unit::Word),
"sentence" => Some(Unit::Sentence),
"line" => Some(Unit::Line),
"paragraph" => Some(Unit::Paragraph),
"document" => Some(Unit::Document),
_ => None,
}
}
}
/// The word, sentence, line, paragraph or story around `at`, as (start, end). A paragraph
/// includes its mark (it ends at the start of the next paragraph in the same container, when
/// there is one), and so does its last line.
pub fn unit_range(s: &mut Session, at: &Pos, unit: Unit) -> (Pos, Pos) {
let Some(p) = s.doc.para_at(at) else { return (at.clone(), at.clone()) };
let len = p.len();
let mark_end = |s: &Session| match s.doc.next_para(at.story, &at.path) {
Some(q) if q.parent() == at.path.parent() => Pos { story: at.story, path: q, off: 0 },
_ => Pos { off: len, ..at.clone() },
};
let (a, b) = match unit {
Unit::Word => p.word_at(at.off),
Unit::Sentence => p.sentence_at(at.off),
Unit::Paragraph => return (Pos { off: 0, ..at.clone() }, mark_end(s)),
Unit::Document => return (s.doc.start_of(at.story), s.doc.end_of(at.story)),
Unit::Line => {
let hint = s.page_hint;
return match s.layout().line_span(at, hint) {
Some((a, _, true)) => (a, mark_end(s)),
Some((a, b, false)) => (a, b),
None => (at.clone(), at.clone()),
};
}
};
(Pos { off: a, ..at.clone() }, Pos { off: b, ..at.clone() })
}
fn select_unit(s: &mut Session, unit: Unit) -> CmdResult {
let f = s.sel.focus.clone();
if let Some(p) = s.doc.para_at(&f) {
let (a, b) = p.word_at(f.off);
s.sel = Selection { anchor: Pos { off: a, ..f.clone() }, focus: Pos { off: b, ..f } };
if s.doc.para_at(&f).is_some() {
let (a, b) = unit_range(s, &f, unit);
s.sel = Selection { anchor: a, focus: b };
}
sel_result(s)
}
fn select_word(s: &mut Session, _: &Value) -> CmdResult {
select_unit(s, Unit::Word)
}
fn select_sentence(s: &mut Session, _: &Value) -> CmdResult {
let f = s.sel.focus.clone();
if let Some(p) = s.doc.para_at(&f) {
let (a, b) = p.sentence_at(f.off);
s.sel = Selection { anchor: Pos { off: a, ..f.clone() }, focus: Pos { off: b, ..f } };
}
sel_result(s)
select_unit(s, Unit::Sentence)
}
fn select_paragraph(s: &mut Session, _: &Value) -> CmdResult {
let f = s.sel.focus.clone();
let len = s.doc.para_at(&f).map(|p| p.len()).unwrap_or(0);
// Word includes the paragraph mark: extend to the start of the next paragraph when there is one.
let end = match s.doc.next_para(f.story, &f.path) {
Some(q) if q.parent() == f.path.parent() => Pos { story: f.story, path: q, off: 0 },
_ => Pos { off: len, ..f.clone() },
};
s.sel = Selection { anchor: Pos { off: 0, ..f }, focus: end };
sel_result(s)
select_unit(s, Unit::Paragraph)
}
fn select_line(s: &mut Session, _: &Value) -> CmdResult {
let l = s.layout();
if let Some((a, b)) = l.line_bounds(&s.sel.focus, s.page_hint) {
s.sel = Selection { anchor: a, focus: b };
/// A click in the selection bar (#856): the whole line at `pos` (default: the caret's), its
/// trailing spaces and, on a paragraph's last line, the paragraph mark.
fn select_line(s: &mut Session, v: &Value) -> CmdResult {
if let Some(pos) = v.get("pos").and_then(parse_pos) {
s.sel = Selection::caret(s.doc.clamp(&pos));
}
select_unit(s, Unit::Line)
}
/// Drag after a double-click (words), triple-click (paragraphs) or Ctrl/⌘+click (sentences):
/// the selection keeps the unit first selected (`start`..`end`) and grows by whole units to
/// the one under `pos`. Dragging forward anchors at the unit's start; dragging back before it
/// anchors at its end, as in Word.
fn drag_unit(s: &mut Session, v: &Value) -> CmdResult {
let unit = p::str(v, "unit")
.and_then(Unit::parse)
.ok_or_else(|| CmdError::Params("`unit` must be word, sentence, line, paragraph or document".into()))?;
let get = |k: &str| v.get(k).and_then(parse_pos).ok_or_else(|| CmdError::Params(format!("bad `{k}`")));
let (at, start, end) = (s.doc.clamp(&get("pos")?), s.doc.clamp(&get("start")?), s.doc.clamp(&get("end")?));
let (start, end) = if start <= end { (start, end) } else { (end, start) };
// Only within one story (the pointer over another story keeps the selection).
if at.story != start.story || end.story != start.story {
return sel_result(s);
}
let (us, ue) = unit_range(s, &at, unit);
s.sel = if unit == Unit::Document {
Selection { anchor: us, focus: ue }
} else if at >= start {
Selection { focus: ue.max(end.clone()), anchor: start }
} else {
Selection { focus: us.min(start), anchor: end }
};
s.goal_x = None;
s.pending = None;
sel_result(s)
}
+1 -1
View File
@@ -129,7 +129,7 @@ fn has_chart(s: &Session) -> Option<&'static str> {
pub fn chart_graphic(spec: ChartSpec, theme: &[Rgb], w: f32, h: f32) -> Graphic {
let spec = spec.sanitized();
let items = wordcraft_docx::chart_items(&spec, theme, w, h);
Graphic { kind: GraphicKind::Chart, items, w, h, source: None, chart: Some(Arc::new(spec)) }
Graphic { kind: GraphicKind::Chart, items, w, h, source: None, chart: Some(Arc::new(spec)), smart_art: None }
}
/// `type` of the params, if given.
+105 -12
View File
@@ -3,6 +3,7 @@
//! of authorities, footnote options.
use serde_json::{Value, json};
use wordcraft_doc::collate::{SortKey, heading_letter, sort_key};
use wordcraft_doc::para::InlineObject;
use wordcraft_doc::props::{CharProps, TabAlign, TabLeader, TabStop};
use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, Source, StoryRef};
@@ -297,6 +298,8 @@ pub fn specs() -> Vec<CommandSpec> {
CmdError::Params(format!("style must be one of {:?}", STYLES.iter().map(|x| x.name).collect::<Vec<_>>()))
})?;
s.bib_style = st.name.to_string();
// Recorded in the document too, so a save keeps it.
s.doc.settings.bib_style = Some(st.name.to_string());
update_citations(s)?;
Ok(json!({"style": s.bib_style}))
})
@@ -334,6 +337,10 @@ pub fn specs() -> Vec<CommandSpec> {
generated_list(s, "TOA", "Table of Authorities")?;
sel_result(s)
}),
CommandSpec::new("references.updateAuthorities", "Update Table", "References › Table of Authorities", |s, _| {
update_generated(s)?;
sel_result(s)
}),
CommandSpec::new("insert.crossReference", "Cross-reference", "Insert › Links", cross_ref)
.params(r#"{"to": "heading|bookmark|figure|table", "target": string (text / name / number), "show"?: "text|page|number|aboveBelow"} or {} to list targets"#),
CommandSpec::new("references.noteOptions", "Footnote and Endnote", "References › Footnotes", super::references::note_options).params(
@@ -557,7 +564,7 @@ fn citation(s: &mut Session, v: &Value) -> CmdResult {
let src = s.doc.sources.iter().find(|x| x.tag == tag).cloned().ok_or_else(|| CmdError::Params(format!("no source `{tag}`")))?;
let pages = p::str(v, "pages").unwrap_or("").to_string();
let st = style(&s.bib_style);
let n = number_of(&s.doc, &bib_order(&s.doc, st), &tag);
let n = number_of(&s.doc, &bib_order(&s.doc, st, super::default_lang(s)), &tag);
let props = s.typing_props();
let at = delete_selection(s)?;
let instr = if pages.is_empty() { format!("CITATION {tag}") } else { format!("CITATION {tag} \\p {pages}") };
@@ -605,7 +612,7 @@ fn author_key(src: &Source) -> String {
/// Indices into `doc.sources` in bibliography order: the cited sources (every source while
/// nothing is cited), sorted or in order of first citation as the style asks.
fn bib_order(doc: &Document, st: &CiteStyle) -> Vec<usize> {
fn bib_order(doc: &Document, st: &CiteStyle, lang: Option<String>) -> Vec<usize> {
let cited = cited_tags(doc);
let mut idx: Vec<usize> = if cited.is_empty() {
(0..doc.sources.len()).collect()
@@ -615,8 +622,10 @@ fn bib_order(doc: &Document, st: &CiteStyle) -> Vec<usize> {
let cited: std::collections::HashSet<&str> = cited.iter().map(String::as_str).collect();
doc.sources.iter().enumerate().filter(|(_, x)| cited.contains(x.tag.as_str())).map(|(i, _)| i).collect()
};
let key =
|i: &usize, by_title: bool| doc.sources.get(*i).map(|x| if by_title { x.title.to_lowercase() } else { author_key(x) }).unwrap_or_default();
// Sorted in the document's language (Polish authors `Łukasiewicz` before `Mazur`).
let key = |i: &usize, by_title: bool| {
sort_key(&doc.sources.get(*i).map(|x| if by_title { x.title.clone() } else { author_key(x) }).unwrap_or_default(), lang.as_deref())
};
match st.order {
Order::Author => idx.sort_by_cached_key(|i| key(i, false)),
Order::Title => idx.sort_by_cached_key(|i| key(i, true)),
@@ -639,7 +648,7 @@ pub fn update_citations(s: &mut Session) -> Result<(), CmdError> {
/// Refresh the results of the CITATION fields in the body.
fn refresh_citations(s: &mut Session) -> Result<(), CmdError> {
let st = style(&s.bib_style);
let order = bib_order(&s.doc, st);
let order = bib_order(&s.doc, st, super::default_lang(s));
for path in s.doc.para_paths(StoryRef::Body) {
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
let updates: Vec<(usize, String)> = p
@@ -757,7 +766,7 @@ fn page_of(s: &mut Session, pos: &Pos) -> u32 {
fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
if kind.starts_with("BIBLIOGRAPHY") {
let st = style(&s.bib_style);
return bib_order(&s.doc, st)
return bib_order(&s.doc, st, super::default_lang(s))
.into_iter()
.enumerate()
.filter_map(|(k, i)| s.doc.sources.get(i).map(|x| (entry(x, st.name, k.saturating_add(1)), !st.numbered())))
@@ -818,21 +827,22 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
return with_pages.into_iter().map(|(t, p)| (format!("{t}\t{p}"), false)).collect();
}
// Index / TOA: group by entry, sort, list pages.
let mut grouped: std::collections::BTreeMap<String, Vec<u32>> = Default::default();
let lang = super::default_lang(s);
let mut grouped: std::collections::BTreeMap<(SortKey, String), Vec<u32>> = Default::default();
for (e, p) in with_pages {
let pages = grouped.entry(e).or_default();
let pages = grouped.entry((sort_key(&e, lang.as_deref()), e)).or_default();
if !pages.contains(&p) {
pages.push(p);
}
}
let mut out = Vec::new();
let mut letter = None;
for (e, pages) in grouped {
let first = e.chars().next().map(|c| c.to_ascii_uppercase());
for ((_, e), pages) in grouped {
let first = heading_letter(&e, lang.as_deref());
if kind.starts_with("INDEX") && first != letter {
letter = first;
letter = first.clone();
if let Some(l) = first {
out.push((l.to_string(), false));
out.push((l, false));
}
}
let list = pages.iter().map(|p| p.to_string()).collect::<Vec<_>>().join(", ");
@@ -972,6 +982,71 @@ mod tests {
assert!(s.doc.plain_text(StoryRef::Body).contains("[1]"));
}
#[test]
fn bibliography_round_trip_docx() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("text.insert", &json!({"text": "Studios matter "})).unwrap();
s.run(
"references.citation",
&json!({
"source": {
"author": "Rivera, Alex; Chen, Mei",
"title": "Shared Spaces",
"year": "2021",
"publisher": "Harbor Books",
"city": "Portland",
"editor": "Johnson, Robert",
"edition": "2nd",
"issue": "4",
"month": "10",
"day": "15",
"accessed": "2024-05-15",
"doi": "10.1000/182"
}
}),
)
.unwrap();
s.run("text.newParagraph", &json!({})).unwrap();
s.run("references.bibliography", &json!({})).unwrap();
// Serialize to DOCX and back — sources must survive.
let bytes = wordcraft_docx::write(&s.doc).expect("docx write");
let doc = wordcraft_docx::read(&bytes).expect("docx read");
assert_eq!(doc.sources.len(), 1, "round-tripped sources: {:?}", doc.sources);
assert_eq!(doc.sources[0].author, "Rivera, Alex; Chen, Mei");
assert_eq!(doc.sources[0].title, "Shared Spaces");
assert_eq!(doc.sources[0].editor, "Johnson, Robert");
assert_eq!(doc.sources[0].edition, "2nd");
assert_eq!(doc.sources[0].issue, "4");
assert_eq!(doc.sources[0].month, "10");
assert_eq!(doc.sources[0].day, "15");
assert_eq!(doc.sources[0].accessed, "2024-05-15");
assert_eq!(doc.sources[0].doi, "10.1000/182");
// Round-tripped document in a new session works with citation styles.
let mut s2 = Session::new(doc);
s2.run("references.citationStyle", &json!({"style": "Harvard (Anglia)"})).unwrap();
let t = s2.doc.plain_text(StoryRef::Body);
assert!(t.contains("(Rivera and Chen, 2021)"), "{t}");
assert!(t.contains("Rivera, A. and Chen, M., 2021. Shared Spaces. Portland: Harbor Books."), "{t}");
}
#[test]
fn citation_style_survives_docx_save_and_reopen() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("references.citation", &json!({"source": {"author": "Rivera, Alex", "title": "Shared Spaces", "year": "2021"}})).unwrap();
s.run("references.citationStyle", &json!({"style": "Harvard (Anglia)"})).unwrap();
let bytes = wordcraft_docx::write(&s.doc).expect("docx write");
let doc = wordcraft_docx::read(&bytes).expect("docx read");
assert_eq!(doc.settings.bib_style.as_deref(), Some("Harvard (Anglia)"));
assert_eq!(Session::new(doc.clone()).bib_style, "Harvard (Anglia)");
let mut other = Session::new(wordcraft_doc::Document::new());
other.set_document(doc);
assert_eq!(other.bib_style, "Harvard (Anglia)");
// A document without a recorded style is APA.
assert_eq!(Session::new(wordcraft_doc::Document::new()).bib_style, "APA");
}
#[test]
fn index_and_figures() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Kilns are hot.\nPresses are heavy.\nMore kilns."));
@@ -999,6 +1074,24 @@ mod tests {
assert!(fields(&s).contains(&(format!("REF {name} \\h"), "Figure 1".to_string())), "{:?}", fields(&s));
}
/// #661: References › Table of Authorities › Update Table picks up citations marked after the table.
#[test]
fn table_of_authorities_updates() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Smith v. Jones held it.\nDoe v. Roe agreed."));
s.run("select.text", &json!({"text": "Smith v. Jones"})).unwrap();
s.run("references.markCitation", &json!({})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("text.newParagraph", &json!({})).unwrap();
s.run("references.tableOfAuthorities", &json!({})).unwrap();
let t = s.doc.plain_text(StoryRef::Body);
assert!(t.contains("Smith v. Jones") && !t.contains("Doe v. Roe, "), "{t}");
s.run("select.text", &json!({"text": "Doe v. Roe"})).unwrap();
s.run("references.markCitation", &json!({})).unwrap();
s.run("references.updateAuthorities", &json!({})).unwrap();
let after = s.doc.plain_text(StoryRef::Body);
assert!(after.matches("Doe v. Roe").count() > t.matches("Doe v. Roe").count(), "{after}");
}
/// Field instructions and results in the body, in order.
fn fields(s: &Session) -> Vec<(String, String)> {
let mut out = Vec::new();
+29 -2
View File
@@ -3,6 +3,10 @@
//! Backspace remove just the block, character formatting applies to each row's piece, paragraph
//! formatting to the paragraphs it touches. Typing or pasting first deletes the block; any other
//! edit collapses it to its top-left corner. Any ordinary caret move or selection drops it.
//! Whole table cells that aren't one run of the text (part of a column, `CellRange`) are a block
//! of their paragraphs: edits and formatting go cell by cell; table commands take the cells.
//! Extending the selection from one cell into another (Shift+arrows, Shift+click, a drag) makes
//! such cells, as in Word (`table::extend_selection`).
use serde_json::{Value, json};
use wordcraft_doc::edit::Fragment;
@@ -94,7 +98,7 @@ fn make(s: &mut Session, a: Pos, b: Pos, xa: f32, xb: f32) {
let l = s.layout();
let segments = l.column_segments(&s.doc, &a, &b, left, right, s.page_hint, MAX_LINES);
s.sel = Selection { anchor: a, focus: b };
s.column = Some(ColumnBlock { sel: s.sel.clone(), anchor_x: xa, focus_x: xb, segments });
s.column = Some(ColumnBlock { sel: s.sel.clone(), anchor_x: xa, focus_x: xb, segments, cells: None });
s.pending = None;
}
@@ -116,6 +120,11 @@ pub(crate) fn dispatch(s: &mut Session, id: &str, mutates: bool, run: fn(&mut Se
s.column = None;
s.column_mode = false;
}
// Placing the caret ends a block even where its corners are (a selected empty cell's are
// the same place).
if matches!(id, "caret.set" | "select.collapse") {
s.column = None;
}
if s.column_mode && id.starts_with("caret.") && id != "caret.set" {
let mut params = v.clone();
match params.as_object_mut() {
@@ -132,8 +141,25 @@ pub(crate) fn dispatch(s: &mut Session, id: &str, mutates: bool, run: fn(&mut Se
if matches!(id, "caret.set" | "select.collapse") || (mutates && !id.starts_with("format.") && !id.starts_with("para.")) {
s.column_mode = false;
}
// Extending the selection (Shift+arrows, Shift+click, a drag) goes on from where it really
// runs, then takes whole cells or rows the way Word does around tables.
if id.starts_with("caret.") && p::bool(v, "extend") == Some(true) {
let shown = s.sel.clone();
s.sel = s.sel_ends().clone();
if let Err(e) = run(s, v) {
s.sel = shown;
return Err(e);
}
let to = s.sel.focus.clone();
super::table::extend_selection(s, to);
return super::sel_result(s);
}
let Some(segs) = s.column_segments().map(<[(Pos, Pos)]>::to_vec) else { return run(s, v) };
let cells = s.cell_range().is_some();
match id {
// Whole cells: table commands take the rectangle between the corners, and Tab moves on
// from it without clearing it, as in Word.
_ if cells && (id.starts_with("table.") || matches!(id, "text.tab" | "text.backTab")) => run(s, v),
"edit.copy" => copy(s, &segs),
"edit.cut" => {
let r = copy(s, &segs)?;
@@ -146,7 +172,8 @@ pub(crate) fn dispatch(s: &mut Session, id: &str, mutates: bool, run: fn(&mut Se
}
"format.changeCase" | "format.clear" | "format.resetChar" => per_row(s, run, v, &segs),
_ if id.starts_with("format.") || id.starts_with("para.") || id == "select.column" => run(s, v),
_ if mutates && (id.starts_with("text.") || id.starts_with("edit.paste")) => {
// Pasting formatting (the format painter) leaves the text alone.
_ if mutates && (id.starts_with("text.") || (id.starts_with("edit.paste") && id != "edit.pasteFormat")) => {
delete(s, &segs)?;
run(s, v)
}
+6 -4
View File
@@ -470,11 +470,13 @@ fn stroke(s: &mut Session, v: &Value) -> CmdResult {
/// under `anchor_y` (positioned from its column and top), or, where no paragraph starts on the
/// page, to the nearest line and positioned on the page. Returns where its character went.
fn place(s: &mut Session, layout: &DocLayout, obj: InlineObject, page: usize, (x, y): (f32, f32), anchor_y: f32) -> Result<Pos, CmdError> {
let (at, on_page) = match super::objects::anchor_paragraph(layout, page, anchor_y) {
let (at, on_page) = match super::objects::anchor_paragraph(layout, page, anchor_y, StoryRef::Body) {
Some(path) => (Pos { story: StoryRef::Body, path, off: 0 }, false),
None => {
(super::objects::nearest_line(layout, page, anchor_y).ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?, true)
}
None => (
super::objects::nearest_line(layout, page, anchor_y, StoryRef::Body)
.ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?,
true,
),
};
s.doc.insert_object(&at, obj, &CharProps::default())?;
let len = OBJ.len_utf8();
+248 -58
View File
@@ -4,9 +4,29 @@ use serde_json::{Value, json};
use wordcraft_doc::edit::Fragment;
use wordcraft_doc::{Pos, StoryRef};
use super::{delete_selection, pos_json, sel_result};
use super::{delete_selection, find_pattern, pos_json, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
/// Undo several steps at once: the Undo button's list (#701). `count` is validated here and
/// clamped to the undo depth, so hostile numbers never reach the stack.
fn undo_to(s: &mut Session, v: &Value) -> CmdResult {
let n = p::u64(v, "count").ok_or_else(|| CmdError::Params("`count` (whole number, 1 or more) is required".into()))?;
if n == 0 {
return Err(CmdError::Params("`count` must be 1 or more".into()));
}
let n = usize::try_from(n).unwrap_or(usize::MAX).min(s.undo_depth());
if s.step_lifts_protection(false) {
return Err(CmdError::Disabled("edit.undoTo: use Stop Protection to lift the document's protection".into()));
}
let undone = s.undo_steps(n);
let mut r = sel_result(s)?;
if let Some(m) = r.as_object_mut() {
m.insert("undone".into(), json!(undone));
m.insert("depth".into(), json!(s.undo_depth()));
}
Ok(r)
}
fn has_sel(s: &Session) -> Option<&'static str> {
if s.sel.is_collapsed() { Some("nothing is selected") } else { None }
}
@@ -22,6 +42,14 @@ pub fn specs() -> Vec<CommandSpec> {
})
.key("Mod+Z")
.pure(),
CommandSpec::new("edit.undoTo", "Undo Actions", "Quick Access Toolbar", undo_to)
.params(r#"{"count": n (1 = the newest step, like edit.undo; n undoes the newest n steps at once; at most the undo depth)}"#)
.when(|s| if s.can_undo() { None } else { Some("there is nothing to undo") })
.pure(),
CommandSpec::new("edit.undoHistory", "Undo History", "Quick Access Toolbar", |s, _| {
Ok(json!({"undo": s.undo_history(), "depth": s.undo_depth(), "redo": s.redo_label()}))
})
.pure(),
CommandSpec::new("edit.redo", "Redo", "Quick Access Toolbar", |s, _| {
if s.step_lifts_protection(true) {
return Err(CmdError::Disabled("edit.redo: use Stop Protection to lift the document's protection".into()));
@@ -33,11 +61,12 @@ pub fn specs() -> Vec<CommandSpec> {
.pure(),
CommandSpec::new("edit.cut", "Cut", "Home › Clipboard", cut).key("Mod+X").when(has_sel),
CommandSpec::new("edit.copy", "Copy", "Home › Clipboard", copy).key("Mod+C").pure(),
CommandSpec::new("edit.paste", "Paste", "Home › Clipboard", paste).key("Mod+V").params(r#"{"text"?: string}"#),
CommandSpec::new("edit.paste", "Paste", "Home › Clipboard", paste).key("Mod+V").params(r#"{"text"?: string, "html"?: string, "rtf"?: string}"#),
CommandSpec::new("edit.pasteText", "Paste: Keep Text Only", "Home › Clipboard › Paste", paste_text)
.params(r#"{"text"?: string}"#)
.key("Mod+Shift+Alt+V"),
CommandSpec::new("edit.pasteMerge", "Paste: Merge Formatting", "Home › Clipboard › Paste", paste_text).params(r#"{"text"?: string}"#),
CommandSpec::new("edit.pasteMerge", "Paste: Merge Formatting", "Home › Clipboard › Paste", paste_merge)
.params(r#"{"text"?: string, "html"?: string, "rtf"?: string}"#),
CommandSpec::new("edit.clipboardPane", "Clipboard Pane", "Home › Clipboard", |s, v| {
let on = p::bool(v, "value").unwrap_or(!s.view.clipboard_pane);
s.view.clipboard_pane = on;
@@ -65,22 +94,45 @@ pub fn specs() -> Vec<CommandSpec> {
.pure(),
CommandSpec::new("edit.find", "Find", "Home › Editing", find)
.key("Mod+F")
.params(r#"{"text": string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool}"#)
.params(r#"{"text": string, "matchCase"?: bool, "wholeWord"?: bool, "matchPrefix"?: bool, "matchSuffix"?: bool, "ignorePunct"?: bool, "ignoreSpace"?: bool, "wildcards"?: bool, "special"?: bool (^p ^t ^# … codes, default true), "regex"?: bool}"#)
.pure(),
CommandSpec::new("edit.advancedFind", "Advanced Find", "Home › Editing › Find", advanced_find)
.params(r#"{"text"?: string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool, "in"?: "main|selection", "highlight"?: bool}"#)
.params(r#"{"text"?: string, …options of edit.find, "in"?: "main|selection", "highlight"?: bool}"#)
.pure(),
CommandSpec::new("edit.findNext", "Find Next", "Home › Editing › Find", |s, _| step(s, 1)).key("Mod+G / F3 / Mod+PageDown").pure(),
CommandSpec::new("edit.findPrevious", "Find Previous", "Home › Editing › Find", |s, _| step(s, -1))
// Elsewhere Ctrl+Shift+G is Word Count; Ctrl+Page Up/Down browse the find results.
.key(super::mac_or("Mod+Shift+G / Mod+PageUp", "Mod+PageUp"))
.pure(),
CommandSpec::new("edit.replace", "Replace", "Home › Editing", replace).key("Mod+H").params(r#"{"text": string, "with": string}"#),
CommandSpec::new("edit.findNext", "Find Next", "Home › Editing › Find", |s, v| {
read_opts(s, v);
step(s, 1)
})
.key("Mod+G / F3 / Mod+PageDown")
.params(r#"{"text"?: string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool}"#)
.pure(),
CommandSpec::new("edit.findPrevious", "Find Previous", "Home › Editing › Find", |s, v| {
read_opts(s, v);
step(s, -1)
})
// Elsewhere Ctrl+Shift+G is Word Count; Ctrl+Page Up/Down browse the find results.
.key(super::mac_or("Mod+Shift+G / Mod+PageUp", "Mod+PageUp"))
.params(r#"{"text"?: string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool}"#)
.pure(),
CommandSpec::new("edit.replace", "Replace", "Home › Editing", replace).params(r#"{"text": string, "with": string, …options of edit.replaceAll}"#),
// Ctrl+H, Home › Replace: the Find and Replace dialog on its Replace tab (#712). Opening
// it changes nothing, so it is its own command and never marks the document edited.
CommandSpec::new("edit.findReplace", "Find and Replace", "Home › Editing", |s, v| {
let tab = match p::str(v, "tab").unwrap_or("replace") {
t @ ("find" | "replace" | "goto") => t,
other => return Err(CmdError::Params(format!("`tab` is find, replace or goto, not `{other}`"))),
};
s.ui_requests.push(json!({"open": tab}));
Ok(json!({"open": tab}))
})
.key("Mod+H")
.params(r#"{"tab"?: "find|replace|goto" (default replace)}"#)
.pure(),
CommandSpec::new("edit.replaceAll", "Replace All", "Home › Editing › Replace", replace_all)
.params(r#"{"text": string, "with": string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool}"#),
.params(r#"{"text": string, "with": string (^p ^t ^& ^c …; \\1 with wildcards), …options of edit.find}"#),
CommandSpec::new("edit.goto", "Go To", "Home › Editing › Find", goto)
.key("Mod+Alt+G / F5")
.params(r#"{"page"?: n, "bookmark"?: string, "paragraph"?: n}"#)
.params(r#"{"page"?: n, "pageBy"?: int (pages forward, negative back, from the caret's page), "bookmark"?: string, "paragraph"?: n}; none opens Find and Replace on its Go To tab"#)
.pure(),
CommandSpec::new("edit.goBack", "Go Back", "Navigation", |s, _| {
let p = s.go_back().ok_or_else(|| CmdError::Failed("no edits to go back to yet".into()))?;
@@ -107,7 +159,7 @@ fn copy(s: &mut Session, _: &Value) -> CmdResult {
return Ok(json!({"text": ""}));
}
let f = s.doc.copy_range(&a, &b);
s.clipboard_text = f.plain_text();
s.clipboard_text = f.clipboard_text();
s.clip_history.push(&f, &s.clipboard_text);
s.clipboard = Some(f);
Ok(json!({"text": s.clipboard_text}))
@@ -123,14 +175,18 @@ fn cut(s: &mut Session, v: &Value) -> CmdResult {
Ok(r)
}
/// Paste keeps the source formatting: our own copy while the system clipboard still holds it,
/// else another program's HTML or RTF (#687), else the system clipboard's text.
fn paste(s: &mut Session, v: &Value) -> CmdResult {
// An explicit text param (from the system clipboard) wins unless it matches our own copy.
let frag = match (p::str(v, "text"), &s.clipboard) {
(Some(t), Some(f)) if f.plain_text() == t.replace("\r\n", "\n") => f.clone(),
(Some(t), _) => Fragment::from_text(&t.replace("\r\n", "\n")),
(None, Some(f)) => f.clone(),
(None, None) => return Err(CmdError::Failed("the clipboard is empty".into())),
};
let frag = super::paste::paste_fragment(s, v)?;
super::paste::insert(s, frag)?;
sel_result(s)
}
/// Merge Formatting: the pasted text takes the destination's formatting, keeping its emphasis.
fn paste_merge(s: &mut Session, v: &Value) -> CmdResult {
let mut frag = super::paste::paste_fragment(s, v)?;
super::paste::merge_formatting(s, &mut frag);
super::paste::insert(s, frag)?;
sel_result(s)
}
@@ -274,50 +330,160 @@ fn paste_text(s: &mut Session, v: &Value) -> CmdResult {
Some(t) => t.to_string(),
None => s.clipboard.as_ref().map(Fragment::plain_text).unwrap_or_default(),
};
if text.is_empty() {
return Err(CmdError::Failed("the clipboard is empty".into()));
}
super::paste_text(s, &text.replace("\r\n", "\r").replace('\n', "\r"))?;
sel_result(s)
}
/// One match: its range and, when asked for, the Replace with text filled in for it.
pub(crate) struct Found {
pub a: Pos,
pub b: Pos,
pub rep: Option<String>,
}
/// A stretch of searched text: `len` bytes from `hay` are paragraph `para`'s bytes from `raw`.
/// A paragraph mark is a one-byte stretch at the paragraph's end (`mark`).
struct Seg {
hay: usize,
para: usize,
raw: usize,
len: usize,
mark: bool,
}
/// All matches of the find state in the current story. Text marked deleted by
/// Track Changes is no longer part of the document, so the search reads each
/// paragraph without it: a match lies within one stretch of live text, and word
/// boundaries and anchors see the neighbouring live text, not the deleted text.
/// Otherwise Replace would find its own deleted text again.
pub(crate) fn search(s: &Session, story: StoryRef) -> Result<Vec<(Pos, Pos)>, CmdError> {
Ok(search_full(s, story, false)?.into_iter().map(|f| (f.a, f.b)).collect())
}
fn find_opts(f: &crate::session::FindState) -> find_pattern::Opts {
find_pattern::Opts {
match_case: f.match_case,
whole_word: f.whole_word,
prefix: f.match_prefix,
suffix: f.match_suffix,
ignore_punct: f.ignore_punct,
ignore_space: f.ignore_space,
wildcards: f.wildcards,
regex: f.regex,
literal: f.literal,
}
}
/// [`search`], with the Replace with text of each match when `with_rep` (special codes, `^&`,
/// wildcard groups). A pattern that can match a paragraph mark (`^p`) reads runs of sibling
/// paragraphs as one text with a mark between them; otherwise each paragraph is read alone.
pub(crate) fn search_full(s: &Session, story: StoryRef, with_rep: bool) -> Result<Vec<Found>, CmdError> {
use regex_automata::{Input, meta, util::syntax};
let f = &s.find;
if f.query.is_empty() {
return Ok(Vec::new());
}
let pat = if f.regex { f.query.clone() } else { regex::escape(&f.query) };
let pat = if f.whole_word { format!(r"\b{pat}\b") } else { pat };
let opts = find_opts(f);
let c = find_pattern::compile(&f.query, &opts).map_err(CmdError::Params)?;
let re = meta::Regex::builder()
.syntax(syntax::Config::new().case_insensitive(!f.match_case))
.syntax(syntax::Config::new().case_insensitive(c.case_insensitive))
.configure(meta::Config::new().nfa_size_limit(Some(1 << 20)))
.build(&pat)
.build(&c.pattern)
.map_err(|e| CmdError::Params(format!("bad pattern: {e}")))?;
let mut out = Vec::new();
for path in s.doc.para_paths(story) {
let Some(p) = s.doc.para(story, &path) else { continue };
// The paragraph as it reads, and its stretches of live text as
// (start in `live`, start in the paragraph, length).
let mut live = String::new();
let mut spans: Vec<(usize, usize, usize)> = Vec::new();
for (r, c) in p.run_ranges() {
let Some(t) = p.text.get(r.clone()).filter(|_| c.del.is_none()) else { continue };
match spans.last_mut() {
Some((_, raw, n)) if *raw + *n == r.start => *n += t.len(),
_ => spans.push((live.len(), r.start, t.len())),
}
live.push_str(t);
let pieces = with_rep.then(|| find_pattern::parse_replace(&f.replace, &opts));
let paths = s.doc.para_paths(story);
// Paragraphs read as one text: consecutive siblings when the pattern spans paragraphs.
let mut chunks: Vec<std::ops::Range<usize>> = Vec::new();
for i in 0..paths.len() {
let joins =
c.spans_paras && i > 0 && paths.get(i - 1).zip(paths.get(i)).is_some_and(|(x, y)| x.parent() == y.parent() && x.last() + 1 == y.last());
match chunks.last_mut() {
Some(r) if joins => r.end = i + 1,
_ => chunks.push(i..i + 1),
}
for (at, raw, n) in spans {
for m in re.find_iter(Input::new(&live).range(at..at + n)) {
}
let mut out = Vec::new();
let mut caps = re.create_captures();
for chunk in chunks {
let mut hay = String::new();
let mut segs: Vec<Seg> = Vec::new();
// Where the live text breaks (tracked-deleted text, or a paragraph edge when not spanning).
let mut cuts: Vec<usize> = Vec::new();
let last = chunk.end.saturating_sub(1);
for i in chunk {
let Some(p) = paths.get(i).and_then(|path| s.doc.para(story, path)) else {
cuts.push(hay.len());
continue;
};
let mut expect = 0;
for (r, ch) in p.run_ranges() {
let Some(t) = p.text.get(r.clone()).filter(|_| ch.del.is_none()) else { continue };
if t.is_empty() {
continue;
}
if r.start != expect {
cuts.push(hay.len());
}
match segs.last_mut() {
Some(sg) if !sg.mark && sg.para == i && sg.raw + sg.len == r.start => sg.len += t.len(),
_ => segs.push(Seg { hay: hay.len(), para: i, raw: r.start, len: t.len(), mark: false }),
}
hay.push_str(t);
expect = r.end;
}
if i < last {
if expect != p.text.len() {
cuts.push(hay.len());
}
segs.push(Seg { hay: hay.len(), para: i, raw: p.text.len(), len: 1, mark: true });
hay.push(if p.section.is_some() { find_pattern::SECT_MARK } else { find_pattern::PARA_MARK });
}
}
let pos_of = |x: usize, end: bool| -> Option<Pos> {
let k = segs.partition_point(|sg| if end { sg.hay + sg.len < x } else { sg.hay + sg.len <= x });
let sg = segs.get(k)?;
if (end && sg.hay >= x) || (!end && sg.hay > x) {
return None;
}
if sg.mark && x > sg.hay {
let path = paths.get(sg.para)?;
return Some(Pos { story, path: path.with_last(path.last() + 1), off: 0 });
}
Some(Pos { story, path: paths.get(sg.para)?.clone(), off: sg.raw + (x - sg.hay) })
};
cuts.push(hay.len());
let mut from = 0;
for cut in cuts {
if cut <= from {
continue;
}
let mut input = Input::new(&hay).range(from..cut);
from = cut;
loop {
re.search_captures(&input, &mut caps);
let Some(m) = caps.get_match() else { break };
// Step past an empty match so the search moves on.
let next = if m.is_empty() {
hay.get(m.end()..).and_then(|t| t.chars().next()).map_or(m.end() + 1, |c| m.end() + c.len_utf8())
} else {
m.end()
};
if next > input.end() {
break;
}
input.set_start(next);
if m.is_empty() {
continue;
}
let (a, b) = (raw + m.start().saturating_sub(at), raw + m.end().saturating_sub(at));
out.push((Pos { story, path: path.clone(), off: a }, Pos { story, path: path.clone(), off: b }));
let (Some(a), Some(b)) = (pos_of(m.start(), false), pos_of(m.end(), true)) else { continue };
let rep = pieces.as_ref().map(|pc| {
let group = |n: usize| caps.get_group(n).and_then(|g| hay.get(g.range())).map(str::to_string);
find_pattern::expand(pc, hay.get(m.range()).unwrap_or(""), &group, &s.clipboard_text)
});
out.push(Found { a, b, rep });
if out.len() >= 100_000 {
return Ok(out);
}
@@ -342,6 +508,24 @@ fn read_opts(s: &mut Session, v: &Value) {
if let Some(b) = p::bool(v, "regex") {
s.find.regex = b;
}
if let Some(b) = p::bool(v, "wildcards") {
s.find.wildcards = b;
}
if let Some(b) = p::bool(v, "special") {
s.find.literal = !b;
}
if let Some(b) = p::bool(v, "matchPrefix") {
s.find.match_prefix = b;
}
if let Some(b) = p::bool(v, "matchSuffix") {
s.find.match_suffix = b;
}
if let Some(b) = p::bool(v, "ignorePunct") {
s.find.ignore_punct = b;
}
if let Some(b) = p::bool(v, "ignoreSpace") {
s.find.ignore_space = b;
}
if let Some(t) = p::str(v, "with") {
s.find.replace = t.to_string();
}
@@ -350,8 +534,10 @@ fn read_opts(s: &mut Session, v: &Value) {
fn find(s: &mut Session, v: &Value) -> CmdResult {
read_opts(s, v);
if p::str(v, "text").is_none() {
// Word's Ctrl+F: the Navigation pane with its search box ready to type in (#712); the
// Find and Replace dialog is Advanced Find.
s.view.nav_pane = true;
s.ui_requests.push(json!({"open": "find"}));
s.ui_requests.push(json!({"focus": "navSearch"}));
return Ok(json!({"count": s.find.results.len()}));
}
let story = s.sel.focus.story;
@@ -421,25 +607,25 @@ fn step(s: &mut Session, dir: i64) -> CmdResult {
fn replace(s: &mut Session, v: &Value) -> CmdResult {
read_opts(s, v);
let story = s.sel.focus.story;
let results = search(s, story)?;
let results = search_full(s, story, true)?;
let (a, b) = s.sel.ordered();
// Replace the current match if selected, then move to the next.
if results.iter().any(|(x, y)| *x == a && *y == b) {
let with = s.find.replace.clone();
super::paste_text(s, &with)?;
if let Some(f) = results.into_iter().find(|f| f.a == a && f.b == b) {
super::paste_text(s, &f.rep.unwrap_or_default())?;
}
let _ = step(s, 1);
Ok(json!({"remaining": search(s, story)?.len()}))
}
/// Every match replaced, last first so earlier positions stay valid (one undo step). A `\r` in
/// the replacement (`^p`) splits the paragraph.
fn replace_all(s: &mut Session, v: &Value) -> CmdResult {
read_opts(s, v);
let story = s.sel.focus.story;
let results = search(s, story)?;
let with = s.find.replace.clone();
let results = search_full(s, story, true)?;
let n = results.len();
// Replace from the end so earlier positions stay valid.
for (a, b) in results.into_iter().rev() {
for Found { a, b, rep } in results.into_iter().rev() {
let with = rep.unwrap_or_default();
if s.doc.settings.track_changes {
// Same path as a single replace, so the change is a reviewable revision.
s.sel = Selection { anchor: a, focus: b };
@@ -447,11 +633,9 @@ fn replace_all(s: &mut Session, v: &Value) -> CmdResult {
continue;
}
let props = s.doc.para_at(&a).map(|p| p.props_of_char(a.off).clone()).unwrap_or_default();
s.doc.delete_range(&a, &b)?;
// A paragraph mark in the replacement starts a new paragraph, as in a single replace.
let mut at = a;
for (i, part) in with.split(['\r', '\u{2029}']).enumerate() {
if i > 0 {
let mut at = s.doc.delete_range(&a, &b)?;
for (k, part) in with.split(['\r', '\u{2029}']).enumerate() {
if k > 0 {
at = s.doc.split_paragraph(&at)?;
}
if !part.is_empty() {
@@ -464,7 +648,13 @@ fn replace_all(s: &mut Session, v: &Value) -> CmdResult {
}
fn goto(s: &mut Session, v: &Value) -> CmdResult {
if let Some(pg) = p::u64(v, "page") {
// Relative pages, Go To's "+2" / "-1": from the page the caret is on.
let by = v.get("pageBy").and_then(Value::as_i64);
let rel = by.map(|by| {
let here = s.layout().caret_on(&s.sel.focus, s.page_hint).map_or(0, |c| c.page) as i64;
(here + 1).saturating_add(by).max(1) as u64
});
if let Some(pg) = rel.or_else(|| p::u64(v, "page")) {
let l = s.layout();
let idx = (pg.max(1) as usize - 1).min(l.pages.len().saturating_sub(1));
let target = l.pages.get(idx).and_then(|page| {
+48 -19
View File
@@ -12,7 +12,7 @@ pub const MAX_AUTHOR_CHARS: usize = 255;
/// Sets the user name that new tracked changes and comments are recorded under. The name is
/// trimmed, stripped of control characters (they can't go into the saved XML) and cut to
/// [`MAX_AUTHOR_CHARS`]; a blank name is refused so revisions always carry an author.
fn set_author(s: &mut Session, v: &Value) -> CmdResult {
pub(crate) fn set_author(s: &mut Session, v: &Value) -> CmdResult {
let name: String = p::req_str(v, "name")?.trim().chars().filter(|c| !c.is_control()).take(MAX_AUTHOR_CHARS).collect();
let name = name.trim_end();
if name.is_empty() {
@@ -35,9 +35,9 @@ pub fn specs() -> Vec<CommandSpec> {
.pure(),
CommandSpec::new("file.save", "Save", "File", save)
.key("Mod+S / Shift+F12")
.params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does)}"#)
.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)}"#)
.pure(),
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key("F12").params(r#"{"path": string, "password"?: string}"#).pure(),
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key("F12").params(r#"{"path": string, "password"?: string, "markup"?: 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)
@@ -46,13 +46,26 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("file.exportPdf", "Export PDF", "File › Export", |s, v| {
let path = p::req_str(v, "path")?;
let path = if path.to_ascii_lowercase().ends_with(".pdf") { path.to_string() } else { format!("{path}.pdf") };
crate::io::save_path(std::path::Path::new(&path), &s.doc).map_err(CmdError::Failed)?;
Ok(json!({"path": path}))
let opts = export_options(s, v);
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}"#)
.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)}"#)
.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}"#)
.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)}"#)
.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| {
s.prefs.print_markup = p::bool(v, "value").unwrap_or(!s.prefs.print_markup);
Ok(json!({"value": s.prefs.print_markup}))
})
.params(r#"{"value"?: bool (omit to toggle)} — PDFs, prints and copies show comments (balloons beside each page; a list at the end of HTML, Markdown and text) and tracked changes"#)
.pure(),
CommandSpec::new("file.printPdf", "Print to PDF", "File › Print", crate::print::print_pdf_to_file)
.params(
r#"{"path": string, "range"?: "all" | "current" | "selection" (the pages the selection touches) | "custom", "pages"?: "1-3,5" (implies custom), "parity"?: "all" | "odd" | "even", "copies"?: 1-999, "collate"?: bool (default true)}"#,
)
.pure(),
CommandSpec::new("file.print", "Print", "File", |s, _| {
s.ui_requests.push(json!({"open": "print"}));
@@ -69,11 +82,6 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("file.properties", "Properties", "File › Info", properties)
.params(r#"{"title"?, "subject"?, "author"?, "keywords"?, "comments"?, "category"?, "custom"?: {name: string | null (removes)}}"#),
CommandSpec::new("file.info", "Info", "File", info).pure(),
CommandSpec::new("file.options", "Options", "File", |s, _| {
s.ui_requests.push(json!({"open": "options"}));
sel_result(s)
})
.pure(),
CommandSpec::new("file.setAuthor", "User Name", "File › Options › General", set_author)
.params(r#"{"name": string (author of new tracked changes and comments)}"#)
.pure(),
@@ -111,7 +119,15 @@ fn new(s: &mut Session, v: &Value) -> CmdResult {
"letter" => crate::sample::letter(),
"resume" => crate::sample::resume(),
"report" => crate::sample::report(),
_ => Document::new(),
// The Normal template: the region's paper (#838), written in the editing language (the
// templates' text is English).
_ => {
let mut d = s.blank_document();
if let Some(lang) = &s.editing_lang {
d.styles.default_chr.lang = Some(lang.clone());
}
d
}
};
s.set_document(doc);
s.path = None;
@@ -124,12 +140,19 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
return sel_result(s);
};
let password = p::str(v, "password");
let (doc, encrypted) = if let Some(data) = p::str(v, "data") {
let (mut doc, encrypted) = if let Some(data) = p::str(v, "data") {
let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?;
crate::io::open_bytes_with(path, &bytes, password).map_err(CmdError::Failed)?
} else {
crate::io::open_path_with(std::path::Path::new(path), password).map_err(CmdError::Failed)?
};
// Plain text and Markdown carry no language: they are in the editing language.
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
if let Some(lang) = &s.editing_lang
&& matches!(ext.as_str(), "txt" | "text" | "md" | "markdown")
{
doc.styles.default_chr.lang = Some(lang.clone());
}
s.set_document(doc);
s.path = Some(path.into());
// Like Word, a document opened with a password is saved with it again.
@@ -178,10 +201,10 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
}
s.stamp_save();
let password = s.password.as_ref().map(crate::io::Password::as_str);
crate::io::save_path_with(&path, &s.doc, password).map_err(CmdError::Failed)?;
crate::io::save_path_with(&path, &s.doc, password, &export_options(s, v)).map_err(CmdError::Failed)?;
let encrypted = password.is_some() && crate::io::can_encrypt(&path.to_string_lossy());
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
if ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json"].contains(&ext.as_str()) {
if ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json", "xml"].contains(&ext.as_str()) {
s.path = Some(path.clone());
s.dirty = false;
}
@@ -196,16 +219,22 @@ fn save_as(s: &mut Session, v: &Value) -> CmdResult {
save(s, v)
}
/// How a copy is written: Print Markup from the command's `markup`, else the File › Print
/// setting (`file.printMarkup`); comments as Track Changes Options show them.
pub fn export_options(s: &Session, v: &Value) -> crate::io::ExportOptions {
let markup = p::bool(v, "markup").unwrap_or(s.prefs.print_markup);
crate::io::ExportOptions { markup, comments: markup && s.prefs.markup.comments }
}
fn export_png(s: &mut Session, v: &Value) -> CmdResult {
let path = p::req_str(v, "path")?;
let page = p::u64(v, "page").unwrap_or(1).max(1) as usize - 1;
let scale = p::f32(v, "scale").unwrap_or(2.0).clamp(0.1, 8.0);
let l = s.export_layout();
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)))?;
let img = wordcraft_render::render_page(&s.doc, pg, scale, &Default::default());
let png = img.to_png();
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(path, &png).map_err(|e| CmdError::Failed(format!("{path}: {e}")))?;
crate::io::write_file(path, &png).map_err(CmdError::Failed)?;
Ok(json!({"path": path, "width": img.width, "height": img.height, "bytes": png.len()}))
}
+417
View File
@@ -0,0 +1,417 @@
//! Word's Find and Replace patterns (#543): special characters (`^p`, `^t`, `^#` …) and the
//! "Use wildcards" language (`?`, `*`, `[a-z]`, `{n,m}`, `@`, `<`, `>`, `( )`), translated to a
//! regular expression for the linear-time `regex` engine, so no pattern can backtrack
//! catastrophically. Replace text understands the same codes plus `^&` (the found text), `^c`
//! (the clipboard) and, with wildcards, `\1`–`\9` (the groups).
//!
//! The search reads a story as text with a paragraph mark (`\r`, [`PARA_MARK`]) between
//! paragraphs when the pattern can match one, so `^p` finds paragraph ends; a paragraph that ends
//! a section ends with [`SECT_MARK`] instead (`^b`).
use regex::escape;
/// A paragraph mark in the searched text.
pub const PARA_MARK: char = '\r';
/// The mark of a paragraph that ends a section (`^b`), in the searched text only.
pub const SECT_MARK: char = '\u{1D}';
/// Longest Find what text, in characters.
pub const MAX_PATTERN: usize = 1024;
/// Largest count in `{n,m}`.
const MAX_COUNT: u32 = 1000;
/// Any character except a paragraph mark (`?`, `^?`, `*`).
const ANY: &str = r"[^\r\x{1D}]";
/// How the Find what text is read.
#[derive(Clone, Copy, Debug, Default)]
pub struct Opts {
pub match_case: bool,
pub whole_word: bool,
pub prefix: bool,
pub suffix: bool,
pub ignore_punct: bool,
pub ignore_space: bool,
/// Word's "Use wildcards".
pub wildcards: bool,
/// A regular expression (WordCraft's own option; wins over wildcards).
pub regex: bool,
/// Take the text literally: no `^` codes.
pub literal: bool,
}
/// A pattern ready for the regex engine.
#[derive(Debug)]
pub struct Compiled {
pub pattern: String,
pub case_insensitive: bool,
/// The pattern can match a paragraph mark, so the search must read across paragraphs.
pub spans_paras: bool,
}
/// What a `^` code stands for in Find what.
fn find_code(c: char) -> Option<&'static str> {
Some(match c {
'p' => r"\r",
'b' => r"\x{1D}",
't' => r"\t",
'?' => ANY,
'#' => "[0-9]",
'$' => r"\p{L}",
'^' => r"\^",
'l' => r"\n",
'm' => r"\x{0C}",
'n' => r"\x{0E}",
'+' => r"\x{2014}",
'=' => r"\x{2013}",
's' => r"\x{A0}",
'~' => r"\x{2011}",
'-' => r"\x{AD}",
'w' => r"[ \t\x{A0}]+",
'g' => r"\x{FFFC}",
'%' => "§",
'v' => "",
_ => return None,
})
}
/// What a `^` code stands for in Replace with.
fn replace_code(c: char) -> Option<char> {
Some(match c {
'p' => PARA_MARK,
't' => '\t',
'^' => '^',
'l' => '\n',
'm' => '\u{0C}',
'n' => '\u{0E}',
'+' => '\u{2014}',
'=' => '\u{2013}',
's' => '\u{A0}',
'~' => '\u{2011}',
'-' => '\u{AD}',
'%' => '§',
'v' => '¶',
_ => return None,
})
}
/// A character code (`^13`, `^0151`, `^u8212`) as the character it stands for here.
fn code_char(n: u32) -> Option<char> {
Some(match n {
13 => PARA_MARK,
11 => '\n',
12 => '\u{0C}',
14 => '\u{0E}',
30 => '\u{2011}',
31 => '\u{AD}',
n => char::from_u32(n).filter(|c| !c.is_control() || *c == '\t')?,
})
}
/// Read a numeric code after `^` (`13`, `0151`, `u8212`) starting at `chars[i]`; returns the
/// character and how many characters it took.
fn numeric_code(chars: &[char], i: usize) -> Option<(char, usize)> {
let (start, max) = if chars.get(i) == Some(&'u') { (i + 1, 5) } else { (i, 4) };
let digits: String = chars.get(start..).unwrap_or(&[]).iter().take(max).take_while(|c| c.is_ascii_digit()).collect();
if digits.is_empty() {
return None;
}
let n: u32 = digits.parse().ok()?;
Some((code_char(n)?, start - i + digits.chars().count()))
}
/// A literal character as a regex atom.
fn lit(c: char) -> String {
if c == PARA_MARK {
return r"\r".into();
}
escape(c.encode_utf8(&mut [0; 4]))
}
/// Translate Word's Find what text.
pub fn compile(query: &str, o: &Opts) -> Result<Compiled, String> {
if query.chars().count() > MAX_PATTERN {
return Err(format!("the Find what text is longer than {MAX_PATTERN} characters"));
}
let (pat, spans) = if o.regex {
(query.to_string(), false)
} else if o.literal {
(escape(query), false)
} else if o.wildcards {
wildcards(query)?
} else {
special(query, o)
};
let wrap = |p: String| format!("(?:{p})");
let pat = if o.wildcards && !o.regex {
pat
} else if o.whole_word {
format!(r"\b{}\b", wrap(pat))
} else {
match (o.prefix, o.suffix) {
(true, true) => format!(r"\b{}\b", wrap(pat)),
(true, false) => format!(r"\b{}", wrap(pat)),
(false, true) => format!(r"{}\b", wrap(pat)),
(false, false) => pat,
}
};
// Word's wildcard search always matches case.
let case_insensitive = !o.match_case && !(o.wildcards && !o.regex);
Ok(Compiled { pattern: pat, case_insensitive, spans_paras: spans })
}
/// Normal search: literal text with `^` codes, optionally skipping punctuation and white space.
fn special(query: &str, o: &Opts) -> (String, bool) {
let chars: Vec<char> = query.chars().collect();
let mut atoms: Vec<String> = Vec::new();
let mut spans = false;
let mut i = 0;
while let Some(&c) = chars.get(i) {
i += 1;
if c == '^'
&& let Some(&n) = chars.get(i)
{
if let Some(code) = find_code(n) {
spans |= matches!(n, 'p' | 'b');
atoms.push(code.to_string());
i += 1;
continue;
}
if let Some((ch, used)) = numeric_code(&chars, i) {
spans |= ch == PARA_MARK;
atoms.push(lit(ch));
i += used;
continue;
}
// Not a code: the caret itself.
}
if (o.ignore_punct && is_punct(c)) || (o.ignore_space && is_space(c)) {
continue;
}
atoms.push(lit(c));
}
let filler = match (o.ignore_punct, o.ignore_space) {
(true, true) => r"[\p{P} \t\x{A0}]*",
(true, false) => r"\p{P}*",
(false, true) => r"[ \t\x{A0}]*",
(false, false) => "",
};
(atoms.join(filler), spans)
}
fn is_punct(c: char) -> bool {
c.is_ascii_punctuation() || matches!(c, '\u{2010}'..='\u{2027}' | '\u{00A1}' | '\u{00BF}' | '\u{00AB}' | '\u{00BB}')
}
fn is_space(c: char) -> bool {
matches!(c, ' ' | '\t' | '\u{A0}')
}
/// "Use wildcards" search.
fn wildcards(query: &str) -> Result<(String, bool), String> {
let chars: Vec<char> = query.chars().collect();
let mut out = String::new();
let mut spans = false;
let mut i = 0;
while let Some(&c) = chars.get(i) {
i += 1;
match c {
'?' => out.push_str(ANY),
'*' => {
out.push_str(ANY);
out.push_str("*?");
}
'@' => out.push('+'),
'<' => out.push_str(r"\b{start}"),
'>' => out.push_str(r"\b{end}"),
'(' | ')' => out.push(c),
'{' => {
let end = chars.get(i..).and_then(|r| r.iter().position(|c| *c == '}')).ok_or("a { needs a closing }")?;
let body: String = chars.get(i..i + end).unwrap_or(&[]).iter().collect();
out.push_str(&count(&body)?);
i += end + 1;
}
'[' => {
let end = chars.get(i..).and_then(|r| r.iter().position(|c| *c == ']')).ok_or("a [ needs a closing ]")?;
out.push_str(&class(chars.get(i..i + end).unwrap_or(&[]))?);
i += end + 1;
}
'\\' => match chars.get(i) {
Some(d) if d.is_ascii_digit() => return Err("back-references (\\1) work in Replace with, not in Find what".into()),
Some(&n) => {
out.push_str(&lit(n));
i += 1;
}
None => out.push_str(r"\\"),
},
'^' => {
let Some(&n) = chars.get(i) else {
out.push_str(r"\^");
continue;
};
if let Some(code) = find_code(n) {
spans |= matches!(n, 'p' | 'b');
out.push_str(code);
i += 1;
} else if let Some((ch, used)) = numeric_code(&chars, i) {
spans |= ch == PARA_MARK;
out.push_str(&lit(ch));
i += used;
} else {
out.push_str(r"\^");
}
}
c => out.push_str(&lit(c)),
}
}
Ok((out, spans))
}
/// `{n}`, `{n,}`, `{n,m}` (and `{,m}`).
fn count(body: &str) -> Result<String, String> {
let bad = || format!("{{{body}}} is not a valid count");
let num = |s: &str| -> Result<Option<u32>, String> {
let s = s.trim();
if s.is_empty() {
return Ok(None);
}
let n: u32 = s.parse().map_err(|_| bad())?;
if n > MAX_COUNT {
return Err(format!("counts go up to {MAX_COUNT}"));
}
Ok(Some(n))
};
match body.split_once([',', ';']) {
None => Ok(format!("{{{}}}", num(body)?.ok_or_else(bad)?)),
Some((a, b)) => {
let a = num(a)?.unwrap_or(0);
match num(b)? {
Some(b) if b < a => Err(bad()),
Some(b) => Ok(format!("{{{a},{b}}}")),
None => Ok(format!("{{{a},}}")),
}
}
}
}
/// `[abc]`, `[a-z]`, `[!x]`: the characters inside are literal.
fn class(inner: &[char]) -> Result<String, String> {
let (neg, inner) = match inner.split_first() {
Some(('!', rest)) => (true, rest),
_ => (false, inner),
};
if inner.is_empty() {
return Err("[ ] needs at least one character".into());
}
let mut out = String::from(if neg { r"[^\r\x{1D}" } else { "[" });
let mut i = 0;
while let Some(&c) = inner.get(i) {
let c = if c == '\\' && i + 1 < inner.len() {
i += 1;
inner.get(i).copied().unwrap_or(c)
} else {
c
};
i += 1;
if inner.get(i) == Some(&'-')
&& let Some(&hi) = inner.get(i + 1)
{
if hi < c {
return Err(format!("[{c}-{hi}] is not in ascending order"));
}
out.push_str(&format!("{}-{}", lit(c), lit(hi)));
i += 2;
} else {
out.push_str(&lit(c));
}
}
out.push(']');
Ok(out)
}
/// A piece of Replace with text.
#[derive(Clone, Debug, PartialEq)]
pub enum Piece {
Lit(String),
/// `^&`: the text found.
Found,
/// `\n` with wildcards: group `n`.
Group(usize),
/// `^c`: the clipboard.
Clip,
}
/// Read Replace with text: `^` codes (unless `literal`), and `\1`–`\9` with wildcards.
pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
if o.literal || o.regex {
return vec![Piece::Lit(with.to_string())];
}
let chars: Vec<char> = with.chars().collect();
let mut out = Vec::new();
let mut cur = String::new();
let flush = |cur: &mut String, out: &mut Vec<Piece>| {
if !cur.is_empty() {
out.push(Piece::Lit(std::mem::take(cur)));
}
};
let mut i = 0;
while let Some(&c) = chars.get(i) {
i += 1;
if c == '\\' && o.wildcards {
match chars.get(i) {
Some(d) if d.is_ascii_digit() && *d != '0' => {
flush(&mut cur, &mut out);
out.push(Piece::Group(d.to_digit(10).unwrap_or(0) as usize));
i += 1;
continue;
}
Some(&n) => {
cur.push(n);
i += 1;
continue;
}
None => {}
}
}
if c == '^'
&& let Some(&n) = chars.get(i)
{
if n == '&' || n == 'c' {
flush(&mut cur, &mut out);
out.push(if n == '&' { Piece::Found } else { Piece::Clip });
i += 1;
continue;
}
if let Some(ch) = replace_code(n) {
cur.push(ch);
i += 1;
continue;
}
if let Some((ch, used)) = numeric_code(&chars, i) {
cur.push(ch);
i += used;
continue;
}
}
cur.push(c);
}
flush(&mut cur, &mut out);
out
}
/// Searched text as it is inserted again: marks become paragraph breaks.
fn as_inserted(s: &str) -> String {
s.replace(SECT_MARK, "\r")
}
/// Fill in Replace with text for one match: `found` is the matched text, `group(n)` a group's.
pub fn expand(pieces: &[Piece], found: &str, group: &dyn Fn(usize) -> Option<String>, clip: &str) -> String {
let mut out = String::new();
for p in pieces {
match p {
Piece::Lit(t) => out.push_str(t),
Piece::Found => out.push_str(&as_inserted(found)),
Piece::Group(n) => out.push_str(&as_inserted(&group(*n).unwrap_or_default())),
Piece::Clip => out.push_str(&clip.replace("\r\n", "\r").replace('\n', "\r")),
}
}
out
}

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