Compare commits

..

22 Commits

Author SHA1 Message Date
NetterSilver 159b84f54c
Track Changes: Accept All no longer joins an extra paragraph after a tracked delete from a paragraph end (#864) (#914)
* 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
skylar taylor-barrick 36af036233 PRINT: scope XDG absolute-path regression to Unix 2026-10-12 17:10:30 +02:00
skylar taylor-barrick f0d47b6090 PRINT: integrate current main and expose native print commands 2026-10-12 17:02:36 +02:00
skylar taylor-barrick a38caa4b15 Print: preserve native workers across upstream platform shutdown changes 2026-10-12 08:37:53 +02:00
skylar taylor-barrick 1d0dff03a4 Print: preserve page planning and pass native copy collation to spooler 2026-10-12 06:50:19 +02:00
skylar taylor-barrick 9644e646a6 Print: preserve main updates without progress-log conflicts 2026-10-12 04:22:41 +02:00
skylar taylor-barrick 8889e356d5 Print: synchronize main updates for native CI 2026-10-12 03:51:41 +02:00
skylar taylor-barrick 98c457a98d Print: adopt craft-print with native settings and CI 2026-10-12 03:35:23 +02:00
58 changed files with 3359 additions and 495 deletions

73
.github/workflows/printing.yml vendored Normal file
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

18
Cargo.lock generated
View File

@ -843,6 +843,22 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-print"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d#d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d"
dependencies = [
"tempfile",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d#d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d"
dependencies = [
"egui",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@ -5086,6 +5102,7 @@ version = "0.6.1"
dependencies = [
"arboard",
"clipboard-win",
"craft-print",
"dispatch2",
"eframe",
"egui",
@ -5321,6 +5338,7 @@ dependencies = [
name = "wordcraft-ui-egui"
version = "0.6.1"
dependencies = [
"craft-ui",
"egui",
"egui_kittest",
"image",

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 (selection bar in the left margin, #856; 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
> **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
@ -133,6 +135,7 @@ 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 |
@ -164,17 +167,21 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 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) |
@ -196,6 +203,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 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 |
@ -265,6 +273,7 @@ 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 |
@ -274,9 +283,12 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 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 |
@ -293,6 +305,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 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) |

View File

@ -9,7 +9,7 @@ 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", "xml"];
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"];
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";

View File

@ -54,6 +54,8 @@ zbus = { version = "5", default-features = false, features = ["async-io", "block
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
@ -65,6 +67,7 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi
clipboard-win = { version = "5.4", features = ["std"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
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.
@ -74,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"

View File

@ -61,7 +61,7 @@ fn xdg_mime_args(desktop_id: &str) -> Vec<String> {
/// 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(any(all(unix, not(target_os = "macos")), test))]
#[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();
@ -130,6 +130,7 @@ mod xdg {
#[cfg(test)]
mod tests {
#[cfg(unix)]
use std::path::PathBuf;
use super::*;
@ -163,6 +164,7 @@ mod tests {
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);

View File

@ -54,7 +54,7 @@ impl eframe::App for App {
let closing = ctx.input(|i| i.viewport().close_requested());
if closing && !self.0.close_requested() {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
} else if closing || self.0.quit_requested {
} 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.
@ -78,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();
@ -95,11 +96,16 @@ impl eframe::App for App {
#[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();
@ -193,13 +199,14 @@ 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), the system clipboard's text, formatted copies,
/// 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()),
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)),

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");
}
}

View File

@ -64,9 +64,11 @@ 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"
sync_github_name = false
[people.odedigo]

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 {

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);
}
}
}
}

View File

@ -38,6 +38,7 @@ 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;
@ -801,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();
@ -881,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
@ -1276,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) => {
@ -1291,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"));
@ -1302,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;

View File

@ -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);
@ -1412,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());

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 {
@ -1125,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() };

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}");
}
}

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");

View File

@ -178,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#"

View File

@ -159,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}))

View File

@ -361,11 +361,7 @@ fn nudge_size(s: &mut Session, d: f32) -> CmdResult {
fn color(s: &mut Session, v: &Value) -> CmdResult {
let c = p::req_str(v, "color")?;
let tc = if c.eq_ignore_ascii_case("auto") || c.eq_ignore_ascii_case("automatic") {
TextColor::Auto
} else {
TextColor::Rgb(Rgb::parse(c).ok_or_else(|| CmdError::Params("color must be RRGGBB or auto".into()))?)
};
let tc = TextColor::parse(c).ok_or_else(|| CmdError::Params("color must be RRGGBB or auto".into()))?;
apply(s, &|x| x.color = Some(tc))
}

View File

@ -201,20 +201,26 @@ fn place_picture(s: &mut Session, bytes: Vec<u8>, width: Option<f32>, alt: &str)
if bytes.len() > 200 << 20 {
return Err(CmdError::Failed("image is larger than 200 MB".into()));
}
let (pw, ph) =
wordcraft_render::image_size(&bytes).ok_or_else(|| CmdError::Failed("not a supported image (PNG, JPEG, GIF, WebP, BMP)".into()))?;
let ext = match bytes.get(..4) {
Some([0x89, b'P', b'N', b'G']) => "png",
Some([0xFF, 0xD8, ..]) => "jpeg",
Some([b'G', b'I', b'F', _]) => "gif",
Some([b'R', b'I', b'F', b'F']) => "webp",
Some([b'B', b'M', ..]) => "bmp",
_ => "png",
// Natural size: pixels at 96 ppi, or a metafile's own frame (WMF, EMF: #802).
let (natural_w, natural_h, ext) = if let Some((pw, ph)) = wordcraft_render::image_size(&bytes) {
let ext = match bytes.get(..4) {
Some([0x89, b'P', b'N', b'G']) => "png",
Some([0xFF, 0xD8, ..]) => "jpeg",
Some([b'G', b'I', b'F', _]) => "gif",
Some([b'R', b'I', b'F', b'F']) => "webp",
Some([b'B', b'M', ..]) => "bmp",
_ => "png",
};
(pw as f32 * 0.75, ph as f32 * 0.75, ext)
} else if let Some(size) = wordcraft_render::metafile_size(&bytes) {
size
} else {
return Err(CmdError::Failed("not a supported image (PNG, JPEG, GIF, WebP, BMP, WMF, EMF)".into()));
};
let key = s.doc.add_media(bytes, ext);
// Natural size at 96 ppi, fitted to the text width.
// Fitted to the text width.
let max_w = s.doc.sections().first().map(|(_, sp)| sp.text_width()).unwrap_or(468.0);
let (mut w, mut h) = (pw as f32 * 0.75, ph as f32 * 0.75);
let (mut w, mut h) = (natural_w, natural_h);
if let Some(want) = width {
let k = want.max(4.0) / w.max(1.0);
w *= k;

View File

@ -66,7 +66,7 @@ fn source(s: &Session, v: &Value, fmt: &str) -> Option<String> {
let f = s.clipboard.as_ref()?;
// System clipboard text that isn't our own copy means someone else copied since.
match p::str(v, "text") {
Some(t) if f.plain_text() != t.replace("\r\n", "\n") => None,
Some(t) if f.clipboard_text() != t.replace("\r\n", "\n") => None,
_ => Some(String::new()),
}
}
@ -194,7 +194,7 @@ pub fn rich_fragment(s: &mut Session, v: &Value) -> Option<Fragment> {
pub fn paste_fragment(s: &mut Session, v: &Value) -> Result<Fragment, CmdError> {
let text = p::str(v, "text").map(|t| t.replace("\r\n", "\n"));
if let Some(f) = &s.clipboard
&& text.as_ref().is_none_or(|t| f.plain_text() == *t)
&& text.as_ref().is_none_or(|t| f.clipboard_text() == *t)
&& (text.is_some() || (p::str(v, "html").is_none() && p::str(v, "rtf").is_none()))
{
return Ok(f.clone());

View File

@ -753,7 +753,11 @@ fn word_count(s: &mut Session, v: &Value) -> CmdResult {
} else {
s.selected_text()
};
let words = wordcraft_doc::count_words(&text);
let words = if s.sel.is_collapsed() || super::objects::object_selection(s).is_some() {
stories.iter().map(|st| s.doc.story_word_count(*st)).sum()
} else {
wordcraft_doc::count_words(&text)
};
let chars = text.chars().filter(|c| *c != '\n').count();
let chars_no_spaces = text.chars().filter(|c| !c.is_whitespace()).count();
let paragraphs = text.split('\n').filter(|l| !l.trim().is_empty()).count();

View File

@ -110,7 +110,7 @@ pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>) -> Resu
doc.ensure_nonempty();
return Ok((doc, true));
}
if ext.is_empty() {
if ext.is_empty() || signature_contradicts(&ext, bytes) {
return open_by_content(name, &ext, bytes, password);
}
let mut doc = match ext.as_str() {
@ -163,6 +163,31 @@ pub(crate) fn fit_pictures(doc: &mut Document) {
}
}
/// Does the file hold a different document format than its Word or RTF extension says? An RTF
/// file saved as `.doc` (as accounting systems and report generators often save their
/// documents), a `.docx` package renamed `.doc` or a Word 97-2003 file renamed `.docx`: Word opens
/// each by what it holds, so they go through [`open_by_content`] instead of failing.
fn signature_contradicts(ext: &str, bytes: &[u8]) -> bool {
const ZIP: &[u8] = b"PK\x03\x04";
const OLE: &[u8] = &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
let held = if bytes.starts_with(ZIP) {
"zip"
} else if bytes.starts_with(OLE) {
"ole"
} else if bytes.starts_with(b"{\\rtf") {
"rtf"
} else {
return false;
};
let expected = match ext {
"docx" | "docm" | "dotx" | "dotm" => "zip",
"doc" | "dot" => "ole",
"rtf" => "rtf",
_ => return false,
};
held != expected
}
/// A file whose extension names no format WordCraft knows (`.m3u`, `.log`, `.ini`, none…),
/// opened by what it holds, as Word does: a Word document or RTF file by its signature, any
/// other text through the plain-text import with encoding detection (#569). Anything else is

View File

@ -209,6 +209,7 @@ mod tests {
#[test]
fn samples_are_valid() {
assert_eq!(sample_document().word_count(), 361);
for d in [sample_document(), letter(), resume(), report()] {
assert!(d.word_count() > 5);
let l = wordcraft_layout::layout(&d, &mut wordcraft_layout::LayoutCache::new(), &Default::default());

View File

@ -813,6 +813,22 @@ fn clipboard_round_trip() {
assert_eq!(text(&s), "plain\ntext");
}
#[test]
fn copying_symbol_font_text_puts_the_unicode_symbols_on_the_clipboard() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "ok J\u{F0FC}"}));
s.doc.format_range(&Pos::body(0, 3), &Pos::body(0, 7), &|c| c.font = Some("Wingdings".into())).unwrap();
run(&mut s, "select.all", json!({}));
run(&mut s, "edit.copy", json!({}));
assert_eq!(s.clipboard_text, "ok \u{263A}\u{2713}");
// Pasting our own copy back (the system clipboard holds the mapped text) keeps the stored
// Wingdings characters, not the Unicode ones.
run(&mut s, "caret.docEnd", json!({}));
let clip = s.clipboard_text.clone();
run(&mut s, "edit.paste", json!({"text": clip}));
assert_eq!(text(&s), "ok J\u{F0FC}ok J\u{F0FC}");
}
fn bold_at(s: &Session, block: usize, off: usize) -> bool {
let pos = Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(block), off };
s.doc.para_at(&pos).and_then(|p| p.props_of_char(off).bold).unwrap_or(false)
@ -1433,6 +1449,55 @@ fn accept_all_keeps_a_tracked_paragraph_break() {
assert_eq!(run(&mut s, "review.changes", json!({})), json!([]));
}
/// Issue #864: five paragraphs, a tracked deletion from the end of the first to the end of the
/// third. The two breaks inside the range and the text of the second and third paragraphs are
/// deleted; the third paragraph's own break is not.
fn tracked_delete_from_paragraph_end() -> Session {
let mut s = s();
for (i, word) in ["Alpha", "Bravo", "Charlie", "Delta", "Echo"].iter().enumerate() {
if i > 0 {
run(&mut s, "text.newParagraph", json!({}));
}
run(&mut s, "text.insert", json!({"text": word}));
}
run(&mut s, "review.trackChanges", json!({"value": true}));
let range = json!({"anchor": {"path": [0], "off": 5, "story": "body"}, "focus": {"path": [2], "off": 7, "story": "body"}});
run(&mut s, "select.range", range);
run(&mut s, "text.delete", json!({}));
s
}
#[test]
fn accept_all_after_a_tracked_delete_from_a_paragraph_end_keeps_the_next_paragraph() {
let mut s = tracked_delete_from_paragraph_end();
assert_eq!(body_texts(&s), ["Alpha", "Bravo", "Charlie", "Delta", "Echo"], "tracked deletions stay in the text");
run(&mut s, "review.acceptAll", json!({}));
assert_eq!(body_texts(&s), ["Alpha", "Delta", "Echo"]);
assert!(s.doc.revisions.is_empty());
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.mark.ins.is_none() && p.mark.del.is_none()));
// Rejecting instead restores everything, as it already did.
let mut s = tracked_delete_from_paragraph_end();
run(&mut s, "review.rejectAll", json!({}));
assert_eq!(body_texts(&s), ["Alpha", "Bravo", "Charlie", "Delta", "Echo"]);
}
#[test]
fn reject_all_after_typing_into_an_empty_paragraph_keeps_the_paragraph() {
// Same cause as #864 seen from the other side: rejecting the typed text emptied the
// paragraph, and its mark picked up the text's insertion, so it was rejected as well.
let mut s = s();
run(&mut s, "text.insert", json!({"text": "One"}));
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.insert", json!({"text": "Three"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
run(&mut s, "text.insert", json!({"text": "Two"}));
assert_eq!(body_texts(&s), ["One", "Two", "Three"]);
run(&mut s, "review.rejectAll", json!({}));
assert_eq!(body_texts(&s), ["One", "", "Three"]);
}
#[test]
fn reject_one_paragraph_break_and_rejecting_a_typed_paragraph() {
// Reject with the caret on the break (Next Change selects it), or at the end of its paragraph.
@ -1941,6 +2006,25 @@ fn word_count_includes_text_boxes_by_default() {
assert_eq!(s.word_count(), 2);
}
#[test]
fn word_count_includes_numbered_list_labels_in_words_but_not_characters() {
let mut d = wordcraft_doc::Document::new();
let num_id = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
let mut p = wordcraft_doc::Paragraph::with_text("Apple", wordcraft_doc::CharProps::default());
p.props.numbering = Some(wordcraft_doc::props::NumRef { num: num_id, level: 0 });
d.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Para(p))];
let mut s = Session::new(d);
let v = run(&mut s, "review.wordCount", json!({}));
// Generated label "1." + "Apple" = 2 words.
assert_eq!(v["words"].as_u64(), Some(2));
assert_eq!(s.word_count(), 2);
// Generated list label "1." is formatting metadata and not run characters,
// so Word excludes it from character counts: "Apple" has 5 characters.
assert_eq!(v["characters"].as_u64(), Some(5));
assert_eq!(v["charactersWithSpaces"].as_u64(), Some(5));
}
/// The laid-out area of the only text box.
fn box_area(s: &mut Session) -> wordcraft_layout::hit::ObjectHit {
let l = s.layout();
@ -2455,6 +2539,35 @@ fn styles_apply_multi_paragraph_selection() {
}
}
/// #865: `styles.modify` takes a colour the way `format.color` does (`RRGGBB`, `#RRGGBB`, `auto`),
/// still reads the serialized forms, and rejects a bad colour without touching the style.
#[test]
fn modify_style_takes_hex_colours_like_format_color() {
use wordcraft_doc::{CharProps, Rgb, TextColor};
let mut s = s();
let color = |s: &Session| s.doc.styles.get("Heading1").and_then(|st| st.chr.color);
let modify = |s: &mut Session, c: serde_json::Value| s.run("styles.modify", &json!({"style": "Heading1", "chr": {"color": c}}));
modify(&mut s, json!("1B1611")).unwrap();
assert_eq!(color(&s), Some(TextColor::Rgb(Rgb(0x1B, 0x16, 0x11))));
modify(&mut s, json!("auto")).unwrap();
assert_eq!(color(&s), Some(TextColor::Auto));
modify(&mut s, json!("#1B1611")).unwrap();
assert_eq!(color(&s), Some(TextColor::Rgb(Rgb(0x1B, 0x16, 0x11))));
modify(&mut s, json!("Auto")).unwrap();
assert_eq!(color(&s), Some(TextColor::Auto));
modify(&mut s, json!({"Rgb": [192, 0, 0]})).unwrap();
let red = TextColor::Rgb(Rgb(0xC0, 0, 0));
assert_eq!(color(&s), Some(red));
assert_eq!(s.doc.styles.resolve_char(Some("Heading1"), &CharProps::default()).color, red);
for bad in [json!("1B16"), json!("not a colour"), json!({"Rgb": "1B1611"}), json!(7)] {
let r = modify(&mut s, bad.clone());
assert!(matches!(r, Err(crate::CmdError::Params(_))), "{bad}: {r:?}");
assert_eq!(color(&s), Some(red), "{bad} leaves the style alone");
}
}
/// #555: Update Heading 2 to Match Selection (the styles pane's context menu) makes Heading 2
/// look like the selected text — its paragraph style and direct formatting — so every Heading 2
/// paragraph changes; the selected paragraph takes Heading 2, and it is one undo step.
@ -3716,6 +3829,31 @@ fn colour_commands_take_any_custom_hex() {
assert_eq!(s.doc.settings.page_color, wordcraft_doc::Rgb::parse("123ABC"));
}
/// A document whose content is another format than its extension says opens by what it holds,
/// as in Word: RTF saved as `.doc` (accounting systems and report generators save their documents
/// that way), a `.docx` package renamed `.doc`, and RTF renamed `.docx`.
#[test]
fn documents_open_by_content_when_the_extension_is_wrong() {
let text = |doc: &wordcraft_doc::Document| doc.plain_text(StoryRef::Body);
let rtf = b"{\\rtf1\\ansi\\ansicpg1251\\deff0{\\fonttbl{\\f0 Times New Roman;}}\\f0 \\'d1\\'f7\\'e5\\'f2 \\'b9 12\\par Invoice line\\par}";
for name in ["Счет 12.doc", "report.DOC", "letter.dot", "renamed.docx"] {
let doc = crate::io::open_bytes(name, rtf).unwrap_or_else(|e| panic!("{name}: {e}"));
let t = text(&doc);
assert!(t.contains("Счет № 12") && t.contains("Invoice line"), "{name}: {t:?}");
}
// A Word package renamed `.doc` or `.rtf`.
let mut s = Session::new(wordcraft_doc::Document::new());
run(&mut s, "text.insert", json!({"text": "Packaged text"}));
let docx = crate::io::save_bytes("a.docx", &s.doc).unwrap();
for name in ["package.doc", "package.rtf"] {
let doc = crate::io::open_bytes(name, &docx).unwrap_or_else(|e| panic!("{name}: {e}"));
assert!(text(&doc).contains("Packaged text"), "{name}");
}
// Matching content still goes the usual way, and garbage stays an error.
assert!(text(&crate::io::open_bytes("plain.rtf", rtf).unwrap()).contains("Invoice line"));
assert!(crate::io::open_bytes("broken.doc", b"\x00\x01not a document").is_err());
}
/// #569: a playlist (.m3u / .m3u8) — or any file with an extension WordCraft doesn't know —
/// opens as plain text with its encoding detected, like Word's text import; a binary file is a
/// clean error, never garbage or a panic.
@ -4817,3 +4955,54 @@ fn find_replace_and_go_to_open_like_word() {
assert_eq!(page(&mut s), 1, "before the start: the first page");
assert!(s.run("edit.goto", &json!({"pageBy": i64::MIN})).is_ok(), "hostile numbers never panic");
}
/// #802: Insert › Picture takes Windows Metafiles (and EMF) at their own frame size, stored under
/// their own extension so a save writes them as WMF/EMF parts.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn insert_picture_takes_a_windows_metafile() {
use wordcraft_doc::para::InlineObject;
// One WMF record: size in words, function, parameters.
let rec = |func: u16, params: &[i16]| {
let mut v = ((3 + params.len()) as u32).to_le_bytes().to_vec();
v.extend(func.to_le_bytes());
params.iter().for_each(|p| v.extend(p.to_le_bytes()));
v
};
// A placeable WMF, 1440 × 720 units at 1440 per inch (72 × 36 points), drawing a rectangle.
let body = [rec(0x041B, &[720, 1440, 0, 0]), rec(0, &[])].concat();
let mut wmf = 0x9AC6_CDD7u32.to_le_bytes().to_vec();
for v in [0i16, 0, 0, 1440, 720] {
wmf.extend(v.to_le_bytes());
}
wmf.extend(1440u16.to_le_bytes());
wmf.extend([0u8; 6]);
let words = ((wmf.len() + 18 + body.len()) / 2) as u32;
for v in [1u16, 9, 0x300] {
wmf.extend(v.to_le_bytes());
}
wmf.extend(words.to_le_bytes());
wmf.extend(0u16.to_le_bytes());
wmf.extend(5u32.to_le_bytes());
wmf.extend(0u16.to_le_bytes());
wmf.extend(body);
let dir = scratch_dir("insert-wmf");
let path = dir.join("logo.wmf");
std::fs::write(&path, &wmf).unwrap();
let mut s = Session::new(wordcraft_doc::Document::new());
let r = run(&mut s, "insert.picture", json!({"path": path.to_string_lossy()}));
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(72.0), Some(36.0)), "{r}");
let key = r["media"].as_str().unwrap().to_string();
assert!(key.ends_with(".wmf"), "{key}");
assert_eq!(s.doc.media.get(&key).map(|b| b.len()), Some(wmf.len()));
let mut placed = false;
s.doc.objects_in_reading_order(&mut |o| placed |= matches!(o, InlineObject::Image { media, .. } if *media == key));
assert!(placed, "the picture is in the text");
// Neither a picture nor a metafile: the same error as before, naming the formats.
std::fs::write(&path, b"not a picture").unwrap();
let err = s.run("insert.picture", &json!({"path": path.to_string_lossy()})).unwrap_err();
assert!(err.to_string().contains("WMF, EMF"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}

View File

@ -168,6 +168,17 @@ impl FontFace {
pub fn id(&self) -> u32 {
self.id
}
/// Glyph `gid`'s outline in font units, y-down (flipped), uncached: for faces the database
/// doesn't keep (font list previews); others go through [`FontDb::outline`]'s cache.
pub fn glyph_outline(&self, gid: u32) -> BezPath {
let mut pen = FlipPen(BezPath::new());
if let Some(f) = self.skrifa()
&& let Some(g) = f.outline_glyphs().get(GlyphId::new(gid))
{
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), self.location()), &mut pen);
}
pen.0
}
/// Is `family` this face's typographic or legacy family name?
pub fn named(&self, family: &str) -> bool {
self.family.eq_ignore_ascii_case(family) || self.legacy_family.eq_ignore_ascii_case(family)
@ -1197,6 +1208,71 @@ impl FontDb {
.unwrap_or_else(last_resort_face)
}
/// The face a font list entry draws `family`'s name in: its Regular face (or the closest
/// style), without loading the family (#848). A loaded family's face is shared; an installed
/// one is read into a face of its own that the database doesn't keep, so its data is freed when
/// the caller drops it. Browsing the font list (hundreds of families) then costs no lasting
/// memory; only fonts actually used load for good. `None` when no font has that name, or its
/// file is too large to read just for a preview.
pub fn preview_face(&self, family: &str) -> Option<Arc<FontFace>> {
if let Some(f) = self.find(family, "Regular") {
return Some(f);
}
let english = self.english_family(family).filter(|en| !en.eq_ignore_ascii_case(family));
if let Some(f) = english.as_deref().and_then(|en| self.find(en, "Regular")) {
return Some(f);
}
#[cfg(not(target_arch = "wasm32"))]
if let Some(f) = self.unshared_face(family).or_else(|| english.as_deref().and_then(|en| self.unshared_face(en))) {
return Some(f);
}
None
}
/// The installed `family`'s face closest to Regular, read from its file into a face the
/// database doesn't keep (see [`FontDb::preview_face`]).
#[cfg(not(target_arch = "wasm32"))]
fn unshared_face(&self, family: &str) -> Option<Arc<FontFace>> {
let (path, fam, style) = {
let cat = self.read_catalog();
let mut cands: Vec<&CatalogEntry> = cat.iter().filter(|c| c.family.eq_ignore_ascii_case(family)).collect();
if cands.is_empty() {
cands = cat.iter().filter(|c| c.legacy.eq_ignore_ascii_case(family)).collect();
}
let score = |c: &CatalogEntry| {
if matches!(norm(&c.style).as_str(), "regular" | "normal" | "book" | "roman") {
-1.0
} else {
(style_weight(&c.style) - 400.0).abs() + if style_italic(&c.style) { 1000.0 } else { 0.0 }
}
};
let best = cands.into_iter().min_by(|a, b| score(a).total_cmp(&score(b)))?;
(best.path.clone(), best.family.clone(), best.style.clone())
};
let data = Arc::new(read_fallback_file(&path)?);
let found = enumerate_faces(&data).into_iter().find(|f| f.family.eq_ignore_ascii_case(&fam) && f.style.eq_ignore_ascii_case(&style))?;
let mut face = make_face(FontBytes::Owned(data), found.index, found.family, found.style, found.legacy, found.coords)?;
face.local_names = found.local;
Some(Arc::new(face))
}
/// Bytes of font data held by loaded faces (each file once; compiled-in fonts not counted).
/// It grows as families load for use; font list previews leave it as is (#848).
pub fn loaded_font_bytes(&self) -> usize {
let mut seen: Vec<*const Vec<u8>> = Vec::new();
let mut total = 0usize;
for f in self.read_faces().iter() {
if let FontBytes::Owned(d) = &f.bytes {
let p = Arc::as_ptr(d);
if !seen.contains(&p) {
seen.push(p);
total = total.saturating_add(d.len());
}
}
}
total
}
/// Is `family` available (loaded, or installed on the system)? English and localized family
/// names both count.
pub fn has_family(&self, family: &str) -> bool {
@ -1211,7 +1287,7 @@ impl FontDb {
}
/// Is a face named `family` (its typographic or legacy family) loaded?
fn is_loaded(&self, family: &str) -> bool {
pub fn is_loaded(&self, family: &str) -> bool {
self.read_faces().iter().any(|f| f.named(family))
}
@ -1569,13 +1645,7 @@ impl FontDb {
if let Some(p) = self.outlines.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
return p.clone();
}
let mut pen = FlipPen(BezPath::new());
if let Some(f) = face.skrifa()
&& let Some(g) = f.outline_glyphs().get(GlyphId::new(gid))
{
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen);
}
let p = Arc::new(pen.0);
let p = Arc::new(face.glyph_outline(gid));
let mut cache = self.outlines.lock().unwrap_or_else(|e| e.into_inner());
if cache.len() >= OUTLINE_CACHE_MAX {
cache.clear();

View File

@ -8,9 +8,15 @@ const FAMILY: &str = "Sysfont Sans3";
/// A bundled Source Sans 3 file renamed [`FAMILY`] (as long as the original name).
fn renamed(file: &str) -> Vec<u8> {
renamed_to(file, FAMILY)
}
/// A bundled Source Sans 3 file renamed `family` (13 characters, as long as the original name).
fn renamed_to(file: &str, family: &str) -> Vec<u8> {
assert_eq!(family.len(), "Source Sans 3".len());
let mut data = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap();
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
for (from, to) in [(utf16("Source Sans 3"), utf16(FAMILY)), (b"Source Sans 3".to_vec(), FAMILY.as_bytes().to_vec())] {
for (from, to) in [(utf16("Source Sans 3"), utf16(family)), (b"Source Sans 3".to_vec(), family.as_bytes().to_vec())] {
let mut i = 0;
while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) {
data[i + at..i + at + to.len()].copy_from_slice(&to);
@ -405,3 +411,48 @@ fn east_asian_fonts_are_listed_for_the_asian_text_font_box() {
assert!(db.face("Songti Test", "Regular").is_east_asian());
assert_eq!(listed(&db), 1);
}
/// #848: browsing the font list drew each family's name in its own face, loading every family
/// shown for good (tens of megabytes per trip through the Font box). Preview faces are read for
/// the preview alone: however many families are previewed, the database keeps no font data, and
/// each preview face is freed when dropped.
#[test]
fn font_list_previews_load_nothing_for_good() {
let dir = std::env::temp_dir().join(format!("dc-sysfonts-{}-previews", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let families: Vec<String> = (0..40).map(|i| format!("Preview Fam{i:02}")).collect();
for (i, f) in families.iter().enumerate() {
std::fs::write(dir.join(format!("p{i}-Regular.ttf")), renamed_to("SourceSans3-Regular.ttf", f)).unwrap();
std::fs::write(dir.join(format!("p{i}-Bold.ttf")), renamed_to("SourceSans3-Bold.ttf", f)).unwrap();
}
let db = FontDb::with_font_dirs(vec![dir.clone()]);
let (faces, bytes) = (db.read_faces().len(), db.loaded_font_bytes());
// Every family previewed many times over, as reopening the list does.
for _ in 0..5 {
for f in &families {
let face = db.preview_face(f).expect("installed family previews");
assert_eq!(face.family, *f);
assert_eq!(face.style, "Regular", "the preview shows the Regular face");
assert!(face.covers('P'));
assert_eq!(Arc::strong_count(&face), 1, "nothing else keeps the preview face (or its data)");
}
}
assert_eq!(db.read_faces().len(), faces, "previews load no faces");
assert_eq!(db.loaded_font_bytes(), bytes, "previews keep no font data");
assert!(db.preview_face("No Such Family").is_none());
// Using a font loads it once; previewing it again shares the loaded face.
assert_eq!(db.face(&families[0], "Regular").family, families[0]);
let used = db.loaded_font_bytes();
assert!(used > bytes, "a used family's data is counted");
let shared = db.preview_face(&families[0]).unwrap();
assert!(Arc::strong_count(&shared) > 1, "a loaded family's preview is the shared face");
for _ in 0..3 {
for f in &families[1..] {
drop(db.preview_face(f));
}
}
assert_eq!(db.loaded_font_bytes(), used, "memory stays bounded across previews");
let _ = std::fs::remove_dir_all(&dir);
}

View File

@ -30,7 +30,9 @@ fn substitutes(family: &str) -> &'static [&'static str] {
const MONO: &[&str] = &["Courier New", "Liberation Mono", "Menlo", "Consolas", "DejaVu Sans Mono", "JetBrains Mono"];
const ARIAL: &[&str] = &["Liberation Sans", "Helvetica", "Helvetica Neue", "Arimo", "Source Sans 3"];
const TIMES: &[&str] = &["Liberation Serif", "Times", "Tinos", "Source Serif 4"];
const SYMBOL: &[&str] = &["Apple Symbols", "Segoe UI Symbol", "DejaVu Sans", "Noto Sans Symbols", "Source Sans 3"];
// Word's symbol fonts (Symbol, Wingdings…) are proprietary: their characters are drawn as their
// Unicode equivalents (`wordcraft_doc::symbol_fonts`) from these faces or any face that has them.
const SYMBOL: &[&str] = &["Apple Symbols", "Segoe UI Symbol", "DejaVu Sans", "Noto Sans Symbols", "Noto Sans Symbols 2", "Source Sans 3"];
// Persian / Arabic document fonts. Naskh (book) faces such as B Nazanin, B Lotus, B Zar and
// Traditional Arabic, then the sans faces (B Yekan, B Titr, IRANSans) people use for
// headings and screens. Open fonts with full Persian coverage first.
@ -66,7 +68,7 @@ fn substitutes(family: &str) -> &'static [&'static str] {
"arial" | "helvetica" | "arial nova" => ARIAL,
"times new roman" | "times" => TIMES,
"courier new" | "courier" | "consolas" | "lucida console" | "cascadia code" | "cascadia mono" => MONO,
"symbol" | "wingdings" | "segoe ui symbol" | "webdings" => SYMBOL,
"symbol" | "wingdings" | "wingdings 2" | "wingdings 3" | "segoe ui symbol" | "webdings" => SYMBOL,
"cambria"
| "cambria math"
| "georgia"
@ -85,6 +87,20 @@ fn substitutes(family: &str) -> &'static [&'static str] {
}
}
/// The font a Windows 3.1-era charset name stands for: `Times New Roman Cyr`, `Arial CE`,
/// `Courier New Baltic`, `Arial (Hebrew)`… are aliases Windows keeps (its FontSubstitutes) for
/// the font itself, and old Russian and Central European documents name them. Other names are
/// returned unchanged.
pub fn charset_base_family(family: &str) -> &str {
const SUFFIXES: &[&str] = &[" cyr", " ce", " greek", " tur", " baltic", " (arabic)", " (hebrew)", " (vietnamese)"];
let lower = family.trim_end().to_ascii_lowercase();
SUFFIXES
.iter()
.find(|s| lower.ends_with(*s) && lower.len() > s.len())
.and_then(|s| family.trim_end().get(..lower.len() - s.len()))
.map_or(family, str::trim_end)
}
/// Naskh-style Persian/Arabic document fonts (the "B …" family names come from the common
/// Persian font packs; matched by name only).
fn is_persian_naskh(f: &str) -> bool {
@ -192,10 +208,14 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved {
return *r;
}
}
let base = charset_base_family(family);
let (fam, substituted) = if db.has_family(family) {
(family.to_string(), false)
} else if base != family && db.has_family(base) {
// Windows maps the charset names to the font itself: not a substitution.
(base.to_string(), false)
} else {
let s = substitutes(family).iter().find(|s| db.has_family(s)).copied().unwrap_or(crate::FALLBACK_FAMILY);
let s = substitutes(base).iter().find(|s| db.has_family(s)).copied().unwrap_or(crate::FALLBACK_FAMILY);
(s.to_string(), true)
};
let face = db.face(&fam, style_name(bold, italic));
@ -207,34 +227,6 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved {
r
}
/// The standard Unicode character for a Symbol or Wingdings character stored the way Word stores
/// it, as the private-use code U+F000 + the font's own code (U+F0B7 is the Symbol bullet). Used
/// when that font isn't installed: a substitute draws something else, or nothing, at the
/// private-use code. `None` for other fonts and for codes not in the table.
pub fn symbol_font_char(family: &str, c: char) -> Option<char> {
let code = u32::from(c).checked_sub(0xF000)?;
let u = match (family.to_ascii_lowercase().as_str(), code) {
// Adobe Symbol encoding.
("symbol", 0xA7) => '\u{2663}',
("symbol", 0xA8) => '\u{2666}',
("symbol", 0xA9) => '\u{2665}',
("symbol", 0xAA) => '\u{2660}',
("symbol", 0xAE) => '\u{2192}',
("symbol", 0xB7) => '\u{2022}',
("symbol", 0xD7) => '\u{22C5}',
("symbol", 0xE0) => '\u{25CA}',
// Wingdings characters common as list bullets.
("wingdings", 0x6C) => '\u{25CF}',
("wingdings", 0x6E) => '\u{25A0}',
("wingdings", 0x76) => '\u{2756}',
("wingdings", 0xA7) => '\u{25AA}',
("wingdings", 0xD8) => '\u{27A2}',
("wingdings", 0xFC) => '\u{2714}',
_ => return None,
};
Some(u)
}
/// A line's (ascent, descent) from hhea values, both positive: the line gap goes above the ascent.
fn hhea_line(ascender: i16, descender: i16, line_gap: i16) -> (f64, f64) {
(f64::from(ascender) + f64::from(line_gap.max(0)), f64::from(descender).abs())
@ -304,19 +296,21 @@ mod tests {
}
// Other serif families are unchanged.
assert_eq!(substitutes("Georgia").first(), Some(&"Caladea"));
// Charset aliases are the font itself, so they substitute like it.
assert_eq!(charset_base_family("Times New Roman Cyr"), "Times New Roman");
assert_eq!(charset_base_family("Arial CYR"), "Arial");
assert_eq!(charset_base_family("Courier New CE"), "Courier New");
assert_eq!(charset_base_family("Arial (Hebrew)"), "Arial");
assert_eq!(charset_base_family("Cyr"), "Cyr");
assert_eq!(charset_base_family("Calibri"), "Calibri");
assert_eq!(substitutes(charset_base_family("Times New Roman Cyr")), substitutes("Times New Roman"));
}
#[test]
fn symbol_font_chars_map_to_unicode() {
assert_eq!(symbol_font_char("Symbol", '\u{F0B7}'), Some('\u{2022}'));
assert_eq!(symbol_font_char("symbol", '\u{F0B7}'), Some('\u{2022}'));
assert_eq!(symbol_font_char("Wingdings", '\u{F0A7}'), Some('\u{25AA}'));
assert_eq!(symbol_font_char("Wingdings", '\u{F0D8}'), Some('\u{27A2}'));
// Same code, other font; unknown codes; ordinary characters.
assert_eq!(symbol_font_char("Arial", '\u{F0B7}'), None);
assert_eq!(symbol_font_char("Symbol", '\u{F041}'), None);
assert_eq!(symbol_font_char("Symbol", 'a'), None);
assert_eq!(symbol_font_char("Symbol", '\u{10FFFF}'), None);
fn symbol_fonts_substitute_symbol_faces() {
for f in ["Symbol", "Wingdings", "Wingdings 2", "Wingdings 3", "Webdings"] {
assert!(substitutes(f).contains(&"Noto Sans Symbols 2"), "{f}");
}
}
#[test]

View File

@ -1060,6 +1060,7 @@ impl<'r> Builder<'r> {
covered: false,
header: name == "th",
shading,
borders: None,
});
fmt.background = None;
self.containers.push(Vec::new());

View File

@ -89,6 +89,19 @@ pub struct ListInfo {
pub level: u8,
}
/// Paragraph spacing and indents (points) that a source states on the paragraph itself (RTF);
/// left out, they come from the style.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Spacing {
pub before: Option<f32>,
pub after: Option<f32>,
pub line: Option<wordcraft_doc::props::LineSpacing>,
pub left: Option<f32>,
pub right: Option<f32>,
/// First-line indent; negative = hanging.
pub first: Option<f32>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Para {
pub kind: Kind,
@ -96,6 +109,9 @@ pub struct Para {
pub align: Option<Align>,
pub page_break: bool,
pub inlines: Vec<Inline>,
pub spacing: Spacing,
/// Borders the source puts on the paragraph itself (RTF `\brdrb`…).
pub borders: Option<wordcraft_doc::props::Borders>,
}
impl Para {
@ -126,6 +142,22 @@ impl Para {
}
s
}
/// [`Self::text`] as it reads on screen: text set in a symbol font (Symbol, Wingdings…) as
/// its Unicode equivalents ([`wordcraft_doc::symbol_fonts`]), for plain-text export.
pub fn shown_text(&self) -> String {
let mut s = String::new();
for i in &self.inlines {
match i {
Inline::Text(t, f) => match f.font.as_deref() {
Some(font) if wordcraft_doc::symbol_fonts::is_symbol_font(font) => s.push_str(&wordcraft_doc::symbol_fonts::map_text(font, t)),
_ => s.push_str(t),
},
Inline::Equation { linear: t, .. } => s.push_str(t),
Inline::Image(_) | Inline::Anchor(_) | Inline::Figure(_) => {}
}
}
s
}
pub fn is_empty(&self) -> bool {
self.inlines.iter().all(|i| matches!(i, Inline::Text(t, _) if t.is_empty()))
}
@ -171,11 +203,13 @@ pub struct Cell {
pub covered: bool,
pub header: bool,
pub shading: Option<Rgb>,
/// The cell's own borders (RTF `\clbrdr…`), drawn whatever the table's style.
pub borders: Option<wordcraft_doc::props::Borders>,
}
impl Default for Cell {
fn default() -> Self {
Cell { blocks: Vec::new(), colspan: 1, rowspan: 1, covered: false, header: false, shading: None }
Cell { blocks: Vec::new(), colspan: 1, rowspan: 1, covered: false, header: false, shading: None, borders: None }
}
}
@ -365,6 +399,8 @@ impl Builder<'_> {
out.props.style = style;
if p.kind == Kind::Rule {
out.props.borders = Some(rule_borders());
} else if p.borders.is_some() {
out.props.borders = p.borders;
}
if let (Some(li), Some(num)) = (p.list, list_num) {
if out.props.style.is_none() {
@ -373,6 +409,13 @@ impl Builder<'_> {
out.props.numbering = Some(NumRef { num, level: li.level.min(8) });
}
out.props.align = p.align;
let sp = p.spacing;
out.props.space_before = sp.before.or(out.props.space_before);
out.props.space_after = sp.after.or(out.props.space_after);
out.props.line_spacing = sp.line.or(out.props.line_spacing);
out.props.indent_left = sp.left.or(out.props.indent_left);
out.props.indent_right = sp.right.or(out.props.indent_right);
out.props.indent_first = sp.first.or(out.props.indent_first);
if p.page_break {
out.props.page_break_before = Some(true);
}
@ -528,7 +571,7 @@ impl Builder<'_> {
VMerge::None
};
row.cells.push(wordcraft_doc::Cell {
props: CellProps { width: Some(w), span: span as u32, vmerge, shading: c.shading, ..Default::default() },
props: CellProps { width: Some(w), span: span as u32, vmerge, shading: c.shading, borders: c.borders, ..Default::default() },
blocks,
controls: Default::default(),
});
@ -548,7 +591,13 @@ fn finite_or(v: f32, d: f32) -> f32 {
/// Build a document from a flow.
pub fn to_doc(flow: &Flow) -> Document {
to_doc_in(flow, wordcraft_doc::SectionProps::default())
}
/// [`to_doc`] on a page of the source's own size and margins (`section`), which tables size to.
pub fn to_doc_in(flow: &Flow, section: wordcraft_doc::SectionProps) -> Document {
let mut doc = Document::new();
doc.last_section = section;
doc.numbering = Default::default();
let uses_code = uses_kind(&flow.blocks, Kind::Code, 0);
if uses_code {
@ -567,6 +616,16 @@ pub fn to_doc(flow: &Flow) -> Document {
doc
}
/// `blocks` as document blocks of `doc` (their pictures go into its media), for a header,
/// footer or other part an importer adds itself.
pub fn part_blocks(doc: &mut Document, blocks: &[FBlock]) -> Blocks {
let mut out = Builder { doc }.blocks(blocks, 0);
if out.is_empty() {
out.push(para_block(Paragraph::new()));
}
out
}
fn uses_kind(blocks: &[FBlock], k: Kind, depth: usize) -> bool {
depth <= MAX_DEPTH
&& blocks.iter().any(|b| match b {
@ -674,7 +733,15 @@ fn media_ext(key: &str, data: &[u8]) -> String {
pub fn flow_paras(doc: &Document, p: &Paragraph) -> Vec<Para> {
let (kind, list) = para_kind(doc, p);
let mut done: Vec<Para> = Vec::new();
let mut out = Para { kind, list, align: p.props.align, page_break: p.props.page_break_before.unwrap_or(false), inlines: Vec::new() };
let mut out = Para {
kind,
list,
align: p.props.align,
page_break: p.props.page_break_before.unwrap_or(false),
inlines: Vec::new(),
spacing: Spacing::default(),
borders: None,
};
let in_code = kind == Kind::Code;
let mut k = 0usize;
for (range, props) in p.run_ranges() {
@ -686,7 +753,15 @@ pub fn flow_paras(doc: &Document, p: &Paragraph) -> Vec<Para> {
if c == PAGE_BREAK {
if !hidden {
out.push_text(&std::mem::take(&mut buf), &f);
let next = Para { kind, list: None, align: p.props.align, page_break: true, inlines: Vec::new() };
let next = Para {
kind,
list: None,
align: p.props.align,
page_break: true,
inlines: Vec::new(),
spacing: Spacing::default(),
borders: None,
};
let prev = std::mem::replace(&mut out, next);
if prev.inlines.is_empty() {
out.page_break |= prev.page_break;
@ -796,6 +871,7 @@ fn flow_table(doc: &Document, t: &Table, depth: usize) -> FTable {
covered: c.props.vmerge == VMerge::Continue,
header,
shading: c.props.shading,
borders: c.props.borders,
});
}
rows.push(cells);

View File

@ -9,7 +9,9 @@
//!
//! Import: a tolerant reader for groups and destinations (unknown `\*` destinations, headers,
//! footers, notes and objects are skipped), `\par \line \tab \page`, `\uN` with `\ucN`
//! fallback skipping, `\'hh` (Windows-1252), the formatting words above, stylesheet-based
//! fallback skipping, `\'hh` and 8-bit text in the document's code page (`\ansicpg`) or the
//! current font's (`\fcharset`, `\cpg`: Cyrillic, Central European, Greek…; Windows-1252 when
//! none is named), the formatting words above, stylesheet-based
//! headings and `\outlinelevel`, lists through `\ls`/`\ilvl`, fields, bookmarks, pictures and
//! tables. Group nesting and picture sizes are capped.
@ -18,7 +20,6 @@ use std::collections::HashMap;
use wordcraft_doc::{Align, Document, Rgb};
use crate::model::{self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, make_img};
use crate::txt::cp1252;
/// Deepest group nesting we track; deeper groups share the innermost state.
const MAX_GROUPS: usize = 512;
@ -481,6 +482,37 @@ enum Dest {
Bookmark,
}
/// Which header or footer a `\header…`/`\footer…` group is: footer or header, and the pages
/// (`\headerf` first, `\headerl` left = even, `\header`/`\headerr` the others).
#[derive(Clone, Copy, Debug, PartialEq)]
struct HeaderFooter {
footer: bool,
pages: HfPages,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum HfPages {
Default,
First,
Even,
}
/// The reader's output so far, set aside while a header or footer is read.
#[derive(Default)]
struct Output {
body: Vec<FBlock>,
para: Para,
para_has: bool,
table: Vec<Vec<Cell>>,
row: Vec<Cell>,
cell: Vec<FBlock>,
defs: Vec<CellDef>,
def_cur: CellDef,
row_header: bool,
rule_pending: bool,
table_rights: Vec<Vec<i32>>,
}
#[derive(Clone, Debug)]
struct St {
dest: Dest,
@ -496,6 +528,21 @@ struct St {
outline: Option<u8>,
pagebb: bool,
field: Option<usize>,
/// The code page of the current font's charset (`\fcharset`), when it names one.
cp: Option<u16>,
/// Space before/after (`\sb`, `\sa`), line spacing (`\sl`, `\slmult`) and indents
/// (`\li`, `\ri`, `\fi`), twips.
/// The paragraph's borders (`\brdrt`, `\brdrb`, `\brdrl`, `\brdrr`) and the side being
/// described.
borders: wordcraft_doc::props::Borders,
border_side: Option<char>,
sb: Option<i32>,
sa: Option<i32>,
sl: i32,
slmult: bool,
li: Option<i32>,
ri: Option<i32>,
fi: Option<i32>,
}
impl Default for St {
@ -513,6 +560,16 @@ impl Default for St {
outline: None,
pagebb: false,
field: None,
cp: None,
borders: Default::default(),
border_side: None,
sb: None,
sa: None,
sl: 0,
slmult: false,
li: None,
ri: None,
fi: None,
}
}
}
@ -523,6 +580,63 @@ struct CellDef {
hmerge: u8,
shading: Option<Rgb>,
right: i32,
/// The borders the cell names (`\clbrdrt` … `\clbrdrr`, each with its line style, `\brdrw`
/// width and `\brdrcf` colour).
borders: wordcraft_doc::props::Borders,
}
/// Page size and margins in twips (`\paperw`, `\margl`… and their `…sxn` section forms).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct PageGeom {
w: Option<i32>,
h: Option<i32>,
left: Option<i32>,
right: Option<i32>,
top: Option<i32>,
bottom: Option<i32>,
header: Option<i32>,
footer: Option<i32>,
landscape: bool,
}
impl PageGeom {
/// The section properties these give, Word's defaults where the file names none.
fn section(&self) -> wordcraft_doc::SectionProps {
let mut s = wordcraft_doc::SectionProps::default();
// Twips to points, ignoring nonsense (zero, negative, or beyond Word's 22").
let pt = |v: Option<i32>, lo: f32, hi: f32| v.map(|t| t as f32 / 20.0).filter(|v| v.is_finite() && (lo..=hi).contains(v));
if let Some(w) = pt(self.w, 72.0, 1584.0) {
s.page_w = w;
}
if let Some(h) = pt(self.h, 72.0, 1584.0) {
s.page_h = h;
}
if self.landscape && s.page_w < s.page_h {
std::mem::swap(&mut s.page_w, &mut s.page_h);
}
s.landscape = s.page_w > s.page_h;
for (v, f) in [
(self.left, &mut s.margin_left),
(self.right, &mut s.margin_right),
(self.top, &mut s.margin_top),
(self.bottom, &mut s.margin_bottom),
(self.header, &mut s.header),
(self.footer, &mut s.footer),
] {
if let Some(v) = pt(v, 0.0, 720.0) {
*f = v;
}
}
// Margins that leave no room for text fall back to Word's.
let d = wordcraft_doc::SectionProps::default();
if s.page_w - s.margin_left - s.margin_right < 72.0 {
(s.margin_left, s.margin_right) = (d.margin_left, d.margin_right);
}
if s.page_h - s.margin_top - s.margin_bottom < 72.0 {
(s.margin_top, s.margin_bottom) = (d.margin_top, d.margin_bottom);
}
s
}
}
#[derive(Default)]
@ -533,6 +647,26 @@ struct Reader {
fonts: HashMap<i32, String>,
font_cur: i32,
font_name: String,
/// Code pages of the fonts whose charset names one (`\fcharset204` → 1251).
font_cps: HashMap<i32, u16>,
font_cp_cur: Option<u16>,
/// The document's code page (`\ansicpg`); 0 until it names one (Windows-1252 then).
ansicpg: u16,
/// The default font (`\deff`), whose charset applies before any `\f`.
deff: i32,
page: PageGeom,
/// The header or footer being read: the group depth it started at, which one, and the
/// body's output, put back when the group closes.
hf: Option<(usize, HeaderFooter, Box<Output>)>,
/// Headers and footers read, in order.
hf_parts: Vec<(HeaderFooter, Vec<FBlock>)>,
/// `\titlepg`: the first page has its own header and footer.
title_page: bool,
/// The cell border side being described (`\clbrdrt`, `l`, `b`, `r`): its line style, width
/// and colour follow.
cell_side: Option<char>,
/// Each table row's cell right edges (`\cellx`, twips), for the table's column grid.
table_rights: Vec<Vec<i32>>,
colors: Vec<Option<Rgb>>,
color_cur: (u8, u8, u8, bool),
styles: HashMap<i32, String>,
@ -581,6 +715,60 @@ impl Reader {
self.stack.len()
}
/// Set the output aside (a header or footer starts) or put it back (it ended), returning the
/// other one.
fn swap_output(&mut self, mut o: Output) -> Output {
std::mem::swap(&mut o.body, &mut self.body);
std::mem::swap(&mut o.para, &mut self.para);
std::mem::swap(&mut o.para_has, &mut self.para_has);
std::mem::swap(&mut o.table, &mut self.table);
std::mem::swap(&mut o.row, &mut self.row);
std::mem::swap(&mut o.cell, &mut self.cell);
std::mem::swap(&mut o.defs, &mut self.defs);
std::mem::swap(&mut o.def_cur, &mut self.def_cur);
std::mem::swap(&mut o.row_header, &mut self.row_header);
std::mem::swap(&mut o.rule_pending, &mut self.rule_pending);
std::mem::swap(&mut o.table_rights, &mut self.table_rights);
o
}
/// The border of the cell side being described, else of the paragraph side.
fn cell_border_slot(&mut self) -> Option<&mut Option<wordcraft_doc::props::Border>> {
let (b, side) = match self.cell_side {
Some(c) => (&mut self.def_cur.borders, c),
None => (&mut self.st.borders, self.st.border_side?),
};
match side {
't' => Some(&mut b.top),
'l' => Some(&mut b.left),
'b' => Some(&mut b.bottom),
'r' => Some(&mut b.right),
_ => None,
}
}
/// A header or footer group starts: read it into an output of its own.
fn start_header_footer(&mut self, which: HeaderFooter) {
let body = self.swap_output(Output::default());
self.hf = Some((self.depth(), which, Box::new(body)));
self.st.intbl = false;
}
/// The header or footer group closed: keep what it held and go back to the body.
fn end_header_footer(&mut self) {
let Some((_, which, body)) = self.hf.take() else { return };
if self.para_has || !self.para.inlines.is_empty() {
self.end_para(false);
}
self.close_table();
let part = self.swap_output(*body);
// A document names each one once; a repeat (another section's) replaces it.
self.hf_parts.retain(|(w, _)| *w != which);
if self.hf_parts.len() < 16 {
self.hf_parts.push((which, part.body));
}
}
fn open(&mut self) {
self.skip_chars = 0;
if self.stack.len() >= MAX_GROUPS {
@ -631,15 +819,28 @@ impl Reader {
}
}
self.st = prev;
if self.hf.as_ref().is_some_and(|(d, _, _)| self.depth() < *d) {
self.end_header_footer();
}
}
fn commit_font(&mut self) {
let name = std::mem::take(&mut self.font_name).trim().trim_end_matches(';').trim().to_string();
if !name.is_empty() && self.fonts.len() < 4096 {
self.fonts.insert(self.font_cur, name);
if let Some(cp) = self.font_cp_cur.take() {
self.font_cps.insert(self.font_cur, cp);
}
}
}
/// A byte of 8-bit text (`\'hh` or raw) as a char, in the current font's code page, else the
/// default font's, else the document's.
fn byte_char(&self, b: u8) -> char {
let cp = self.st.cp.or_else(|| self.font_cps.get(&self.deff).copied()).unwrap_or(if self.ansicpg == 0 { 1252 } else { self.ansicpg });
wordcraft_doc::encoding::windows_byte(cp, b)
}
fn commit_style(&mut self) {
let name = std::mem::take(&mut self.style_name).trim().trim_end_matches(';').trim().to_string();
if self.styles.len() < 4096 {
@ -779,6 +980,8 @@ impl Reader {
self.rule_pending = false;
p.align = self.st.align;
p.page_break = self.st.pagebb;
p.spacing = self.spacing();
p.borders = self.st.borders.any_visible().then_some(self.st.borders);
if let Some(ls) = self.st.ls {
let list = self.overrides.get(&ls).and_then(|id| self.lists.get(id));
let lv = self.st.ilvl.min(8);
@ -816,6 +1019,32 @@ impl Reader {
}
}
/// The current paragraph's spacing and indents in points.
fn spacing(&self) -> model::Spacing {
use wordcraft_doc::props::LineSpacing;
let pt = |v: i32| (v as f32 / 20.0).clamp(-1584.0, 1584.0);
let gap = |v: Option<i32>| v.map(|v| pt(v).max(0.0));
let sl = self.st.sl;
let line = if sl == 0 {
None
} else if sl < 0 {
Some(LineSpacing::Exactly(pt(sl.saturating_neg()).max(1.0)))
} else if self.st.slmult {
// \sl240\slmult1 is single spacing.
Some(LineSpacing::Multiple((sl as f32 / 240.0).clamp(0.06, 132.0)))
} else {
Some(LineSpacing::AtLeast(pt(sl)))
};
model::Spacing {
before: gap(self.st.sb),
after: gap(self.st.sa),
line,
left: self.st.li.map(pt),
right: self.st.ri.map(pt),
first: self.st.fi.map(pt),
}
}
fn end_cell(&mut self) {
self.end_para(true);
let blocks = std::mem::take(&mut self.cell);
@ -831,9 +1060,19 @@ impl Reader {
let mut cells = std::mem::take(&mut self.row);
let defs = self.defs.clone();
let mut out: Vec<Cell> = Vec::new();
let mut rights: Vec<i32> = Vec::new();
for (i, mut c) in cells.drain(..).enumerate() {
let d = defs.get(i).cloned().unwrap_or_default();
// A merged-in cell widens the cell it joins.
if d.hmerge == 2
&& let Some(last) = rights.last_mut()
{
*last = d.right;
} else {
rights.push(d.right);
}
c.shading = d.shading;
c.borders = d.borders.any_visible().then_some(d.borders);
c.header = self.row_header;
match d.vmerge {
1 => c.rowspan = 2,
@ -849,7 +1088,9 @@ impl Reader {
out.push(c);
}
if !out.is_empty() && self.table.len() < wordcraft_doc::table::MAX_ROWS {
rights.truncate(out.len());
self.table.push(out);
self.table_rights.push(rights);
}
}
@ -862,8 +1103,11 @@ impl Reader {
self.end_row();
}
if !self.table.is_empty() {
let rows = std::mem::take(&mut self.table);
self.body.push(FBlock::Table(FTable { rows, widths: Vec::new(), borderless: false }));
let mut rows = std::mem::take(&mut self.table);
let rights = std::mem::take(&mut self.table_rights);
let widths = column_grid(&mut rows, &rights);
// Word draws only the borders the cells name: no grid style, each cell its own.
self.body.push(FBlock::Table(FTable { rows, widths, borderless: true }));
}
}
@ -903,6 +1147,18 @@ impl Reader {
"shppict" => return,
"pict" => Some(Dest::Pict),
"bkmkstart" => Some(Dest::Bookmark),
"header" | "footer" | "headerl" | "headerr" | "headerf" | "footerl" | "footerr" | "footerf"
if self.hf.is_none() && self.st.dest == Dest::Body && !self.st.intbl =>
{
let footer = w.starts_with("footer");
let pages = match w.strip_prefix(if footer { "footer" } else { "header" }) {
Some("f") => HfPages::First,
Some("l") => HfPages::Even,
_ => HfPages::Default,
};
self.start_header_footer(HeaderFooter { footer, pages });
return;
}
"header" | "footer" | "headerl" | "headerr" | "headerf" | "footerl" | "footerr" | "footerf" | "footnote" | "annotation" | "pntext"
| "listtext" | "object" | "nonshppict" | "xe" | "tc" | "txe" | "private" | "bkmkend" | "atnid" | "atnauthor" | "comment" | "shpinst"
| "do" | "operator" | "company" | "manager" | "category" | "hlinkbase" | "revtime" | "printim" | "buptim" | "version" | "nofpages"
@ -962,9 +1218,16 @@ impl Reader {
return;
}
Dest::FontTbl => {
if w == "f" {
self.font_cur = pv;
self.font_name.clear();
match w {
"f" => {
self.font_cur = pv;
self.font_name.clear();
self.font_cp_cur = None;
}
"fcharset" => self.font_cp_cur = wordcraft_doc::encoding::charset_code_page(pv),
// A font's explicit code page (`\cpg1251`) wins over its charset.
"cpg" if pv > 0 && wordcraft_doc::encoding::SINGLE_BYTE_CODE_PAGES.contains(&(pv as u16)) => self.font_cp_cur = Some(pv as u16),
_ => {}
}
return;
}
@ -1115,7 +1378,14 @@ impl Reader {
self.st.fmt.sub = false;
}
"fs" => self.st.fmt.size = if pv > 0 { Some((pv as f32 / 2.0).clamp(1.0, 1638.0)) } else { None },
"ansicpg" => {
if pv > 0 && wordcraft_doc::encoding::SINGLE_BYTE_CODE_PAGES.contains(&(pv as u16)) {
self.ansicpg = pv as u16;
}
}
"deff" => self.deff = pv,
"f" => {
self.st.cp = self.font_cps.get(&pv).copied();
let name = self.fonts.get(&pv).cloned();
match name {
Some(n) if model::is_mono(&n) => {
@ -1142,7 +1412,12 @@ impl Reader {
"intbl" => self.st.intbl = true,
"ls" => self.st.ls = Some(pv),
"ilvl" => self.st.ilvl = pv.clamp(0, 8) as u8,
"brdrb" => self.rule_pending = true,
"brdrb" | "brdrt" | "brdrl" | "brdrr" if self.cell_side.is_none() => {
if w == "brdrb" {
self.rule_pending = true;
}
self.st.border_side = w.chars().last();
}
"trowd" => {
self.defs.clear();
self.def_cur = CellDef::default();
@ -1154,7 +1429,63 @@ impl Reader {
"clmgf" => self.def_cur.hmerge = 1,
"clmrg" => self.def_cur.hmerge = 2,
"clcbpat" => self.def_cur.shading = self.colors.get(pv.max(0) as usize).copied().flatten(),
"sb" => self.st.sb = Some(pv),
"sa" => self.st.sa = Some(pv),
"sl" => self.st.sl = pv,
"slmult" => self.st.slmult = on,
"li" | "lin" => self.st.li = Some(pv),
"ri" | "rin" => self.st.ri = Some(pv),
"fi" => self.st.fi = Some(pv),
"clbrdrt" => self.cell_side = Some('t'),
"clbrdrl" => self.cell_side = Some('l'),
"clbrdrb" => self.cell_side = Some('b'),
"clbrdrr" => self.cell_side = Some('r'),
"brdrs" | "brdrth" | "brdrsh" | "brdrdb" | "brdrdot" | "brdrdash" | "brdrdashsm" | "brdrdashd" | "brdrdashdd" | "brdrhair"
| "brdrtriple" | "brdrthtnsg" | "brdrtnthsg" | "brdrthtnmg" | "brdrtnthmg" | "brdrwavy" | "brdrwavydb" | "brdrnone" | "brdrnil"
| "brdrtbl"
if self.cell_side.is_some() || self.st.border_side.is_some() =>
{
use wordcraft_doc::props::{Border, BorderStyle};
let style = match w {
"brdrnone" | "brdrnil" | "brdrtbl" => BorderStyle::None,
"brdrth" => BorderStyle::Thick,
"brdrdb" | "brdrthtnsg" | "brdrtnthsg" | "brdrthtnmg" | "brdrtnthmg" | "brdrwavydb" => BorderStyle::Double,
"brdrdot" => BorderStyle::Dotted,
"brdrdash" | "brdrdashsm" => BorderStyle::Dashed,
"brdrdashd" | "brdrdashdd" => BorderStyle::DotDash,
"brdrtriple" => BorderStyle::Triple,
"brdrwavy" => BorderStyle::Wave,
_ => BorderStyle::Single,
};
let b = (style != BorderStyle::None).then_some(Border { style, ..Border::single(0.5) });
if let Some(slot) = self.cell_border_slot() {
*slot = b;
}
}
"brdrw" if self.cell_side.is_some() || self.st.border_side.is_some() => {
let width = (pv as f32 / 20.0).clamp(0.25, 6.0);
if let Some(Some(b)) = self.cell_border_slot() {
b.width = width;
}
}
"brdrcf" if self.cell_side.is_some() || self.st.border_side.is_some() => {
let color = self.colors.get(pv.max(0) as usize).copied().flatten();
if let Some(Some(b)) = self.cell_border_slot() {
b.color = color;
}
}
"titlepg" => self.title_page = on,
"paperw" | "pgwsxn" => self.page.w = Some(pv),
"paperh" | "pghsxn" => self.page.h = Some(pv),
"margl" | "marglsxn" => self.page.left = Some(pv),
"margr" | "margrsxn" => self.page.right = Some(pv),
"margt" | "margtsxn" => self.page.top = Some(pv),
"margb" | "margbsxn" => self.page.bottom = Some(pv),
"headery" => self.page.header = Some(pv),
"footery" => self.page.footer = Some(pv),
"landscape" | "lndscpsxn" => self.page.landscape = on,
"cellx" => {
self.cell_side = None;
let mut d = std::mem::take(&mut self.def_cur);
d.right = pv;
if self.defs.len() < 63 {
@ -1206,6 +1537,25 @@ fn hyperlink(instr: &str) -> Option<String> {
/// Parse RTF bytes into the flow model.
pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
parse_with_page(bytes).map(|(f, _, _)| f)
}
/// The flow, the page, and the default font (`\deff`, font 0 when the file names none), which
/// is the font of all text that doesn't select one.
fn parse_with_page(bytes: &[u8]) -> Result<(Flow, PageGeom, Option<String>), String> {
parse_full(bytes).map(|r| (r.flow, r.page, r.default_font))
}
/// Everything an import takes from an RTF file.
struct Parsed {
flow: Flow,
page: PageGeom,
default_font: Option<String>,
headers_footers: Vec<(HeaderFooter, Vec<FBlock>)>,
title_page: bool,
}
fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
let start = bytes.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(0);
if !bytes.get(start..).is_some_and(|b| b.starts_with(b"{\\rtf")) {
return Err("not an RTF document (it doesn't start with {\\rtf)".into());
@ -1300,7 +1650,8 @@ pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
if r.st.dest == Dest::Pict {
r.text(&format!("{v:02x}"));
} else {
r.ch(cp1252(v));
let c = r.byte_char(v);
r.ch(c);
}
i += 4;
}
@ -1350,7 +1701,8 @@ pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
}
} else {
for b in chunk {
r.ch(cp1252(*b));
let c = r.byte_char(*b);
r.ch(c);
}
}
i = j;
@ -1358,6 +1710,8 @@ pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
}
}
}
// A header or footer left open by a truncated file ends here.
r.end_header_footer();
if r.para_has || !r.para.inlines.is_empty() {
r.end_para(false);
}
@ -1371,12 +1725,72 @@ pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
if blocks.is_empty() {
blocks.push(FBlock::Para(Para::default()));
}
Ok(Flow { blocks, meta: r.meta })
let default_font = r.fonts.get(&r.deff).filter(|n| !model::is_mono(n)).cloned();
Ok(Parsed { flow: Flow { blocks, meta: r.meta }, page: r.page, default_font, headers_footers: r.hf_parts, title_page: r.title_page })
}
/// The column grid of a table from its rows' cell right edges: every distinct edge starts a grid
/// column, and each cell spans the grid columns between its left and right edges, as Word lays
/// out RTF rows whose cells don't line up. Returns the widths in points, or none (equal columns)
/// when the edges don't make a grid.
fn column_grid(rows: &mut [Vec<Cell>], rights: &[Vec<i32>]) -> Vec<f32> {
let mut edges: Vec<i32> = rights.iter().flatten().copied().filter(|&x| x > 0).collect();
edges.sort_unstable();
edges.dedup();
let ok = rights.len() == rows.len()
&& rows
.iter()
.zip(rights)
.all(|(r, x)| r.len() == x.len() && x.windows(2).all(|w| w.first() < w.get(1)) && x.first().is_some_and(|&v| v > 0))
&& !edges.is_empty()
&& edges.len() <= 63;
if !ok {
return Vec::new();
}
for (row, x) in rows.iter_mut().zip(rights) {
let mut left = 0;
for (c, &right) in row.iter_mut().zip(x) {
let span = edges.iter().filter(|&&e| e > left && e <= right).count().max(1);
c.colspan = span as u32;
left = right;
}
}
let mut prev = 0;
edges
.iter()
.map(|&e| {
let w = (e - prev) as f32 / 20.0;
prev = e;
w
})
.collect()
}
/// Parse RTF bytes into a document.
pub fn import(bytes: &[u8]) -> Result<Document, String> {
Ok(model::to_doc(&parse(bytes)?))
let Parsed { flow, page, default_font, headers_footers, title_page } = parse_full(bytes)?;
let mut doc = model::to_doc_in(&flow, page.section());
for (which, blocks) in &headers_footers {
let blocks = model::part_blocks(&mut doc, blocks);
let id = doc.add_part(if which.footer { wordcraft_doc::PartKind::Footer } else { wordcraft_doc::PartKind::Header }, blocks);
let set = if which.footer { &mut doc.last_section.footers } else { &mut doc.last_section.headers };
match which.pages {
HfPages::Default => set.default = Some(id),
HfPages::First => set.first = Some(id),
HfPages::Even => set.even = Some(id),
}
}
doc.last_section.title_page = title_page;
// RTF's paragraph defaults are single spacing and no space around paragraphs; what a file
// wants beyond that it states on each paragraph.
let dp = &mut doc.styles.default_para;
dp.space_before = Some(0.0);
dp.space_after = Some(0.0);
dp.line_spacing = Some(wordcraft_doc::props::LineSpacing::Multiple(1.0));
if default_font.is_some() {
doc.styles.default_chr.font = default_font;
}
Ok(doc)
}
#[cfg(test)]
@ -1420,6 +1834,193 @@ mod tests {
assert!(matches!(&l.inlines[0], Inline::Text(_, f) if f.link.as_deref() == Some("http://a.b")));
}
#[test]
fn paragraph_borders() {
// A heading with a grey rule under it, as hh.ru résumés write their section titles.
let rtf = br"{\rtf1\ansi{\colortbl;\red0\green0\blue0;\red216\green216\blue216;}
\pard\brdrb\brdrs\brdrw10\brdrcf2 Section\par
\pard Body\par
\pard\brdrt\brdrdb\brdrl\brdrs\brdrr\brdrs Boxed\par
\pard\brdrb\brdrs\par}";
let f = parse(rtf).unwrap();
let paras: Vec<&Para> = f.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None }).collect();
let b = paras[0].borders.unwrap();
let bottom = b.bottom.unwrap();
assert_eq!((bottom.width, bottom.color), (0.5, Some(Rgb(216, 216, 216))));
assert!(b.top.is_none());
assert_eq!(paras[0].kind, Kind::Normal);
// \pard resets them.
assert!(paras[1].borders.is_none());
let b = paras[2].borders.unwrap();
assert_eq!(b.top.map(|x| x.style), Some(wordcraft_doc::props::BorderStyle::Double));
assert!(b.left.is_some() && b.right.is_some() && b.bottom.is_none());
// An empty paragraph with only a bottom border is still a rule.
assert_eq!(paras[3].kind, Kind::Rule);
// The document's paragraph carries them.
let d = import(rtf).unwrap();
let first = d.body.iter().find_map(|b| if let wordcraft_doc::Block::Para(p) = &**b { Some(p) } else { None }).unwrap();
assert!(first.props.borders.and_then(|b| b.bottom).is_some());
}
#[test]
fn headers_and_footers() {
let rtf = br"{\rtf1\ansi\titlepg
{\header \pard\qr Running head\par}
{\headerf \pard First head {\b bold}\par}
{\footer \pard Page \chpgn\par}
\pard Body text\par
\trowd\cellx2000\pard\intbl cell\cell\row
\pard after\par}";
let d = import(rtf).unwrap();
let s = &d.last_section;
assert!(s.title_page);
let text = |id: Option<u32>| {
let p = id.and_then(|id| d.parts.get(&id)).unwrap();
p.blocks
.iter()
.filter_map(|b| if let wordcraft_doc::Block::Para(p) = &**b { Some(p.text.clone()) } else { None })
.collect::<Vec<_>>()
.join("|")
};
assert_eq!(text(s.headers.default), "Running head");
assert_eq!(text(s.headers.first), "First head bold");
assert!(text(s.footers.default).starts_with("Page"));
assert!(s.footers.first.is_none() && s.headers.even.is_none());
// The body keeps only its own text, and its table survives the header groups.
let f = parse(rtf).unwrap();
let t = texts(&f);
assert_eq!(t, ["Normal:Body text", "table1x1:cell", "Normal:after"]);
// Without \titlepg there is no separate first page.
let d = import(br"{\rtf1\ansi{\headerf x\par} y\par}").unwrap();
assert!(!d.last_section.title_page && d.last_section.headers.first.is_some());
// An unterminated header still ends, and the body text after it isn't lost.
assert!(import(br"{\rtf1\ansi{\header x").is_ok());
}
#[test]
fn paragraph_spacing_and_indents() {
use wordcraft_doc::props::LineSpacing;
let rtf = br"{\rtf1\ansi
\pard\sb120\sa150\sl360\slmult1\li720\ri360\fi-360 a\par
\pard\sl-280 b\par
\pard\sl300\slmult0 c\par
\pard d\par}";
let f = parse(rtf).unwrap();
let sp: Vec<model::Spacing> = f.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p.spacing) } else { None }).collect();
assert_eq!(sp[0].before, Some(6.0));
assert_eq!(sp[0].after, Some(7.5));
assert_eq!(sp[0].line, Some(LineSpacing::Multiple(1.5)));
assert_eq!((sp[0].left, sp[0].right, sp[0].first), (Some(36.0), Some(18.0), Some(-18.0)));
assert_eq!(sp[1].line, Some(LineSpacing::Exactly(14.0)));
assert_eq!(sp[2].line, Some(LineSpacing::AtLeast(15.0)));
// \pard resets them.
assert_eq!(sp[3], model::Spacing::default());
// The document's defaults are RTF's: single, no space after; the paragraph's own win.
let d = import(rtf).unwrap();
assert_eq!(d.styles.default_para.space_after, Some(0.0));
assert_eq!(d.styles.default_para.line_spacing, Some(LineSpacing::Multiple(1.0)));
let first = d.body.iter().find_map(|b| if let wordcraft_doc::Block::Para(p) = &**b { Some(p) } else { None }).unwrap();
assert_eq!(first.props.space_after, Some(7.5));
assert_eq!(first.props.indent_first, Some(-18.0));
}
#[test]
fn default_font_is_deff_or_font_zero() {
// Text that never selects a font is in the default font: \deff, else font 0.
let d = import(br"{\rtf1\ansi{\fonttbl{\f0\fnil Times New Roman Cyr;}{\f1 Arial;}}\plain x\par}").unwrap();
assert_eq!(d.styles.default_chr.font.as_deref(), Some("Times New Roman Cyr"));
let d = import(br"{\rtf1\ansi\deff1{\fonttbl{\f0 Times New Roman;}{\f1 Arial;}} x\par}").unwrap();
assert_eq!(d.styles.default_chr.font.as_deref(), Some("Arial"));
// No font table: our default stays.
let d = import(br"{\rtf1\ansi x\par}").unwrap();
assert_eq!(d.styles.default_chr.font, wordcraft_doc::StyleSheet::builtin().default_chr.font);
}
#[test]
fn page_size_and_margins() {
let d = import(br"{\rtf1\ansi\paperw11906\paperh16838\margl1134\margr850\margt1134\margb1134 x\par}").unwrap();
let s = &d.last_section;
assert!((s.page_w - 595.3).abs() < 0.1 && (s.page_h - 841.9).abs() < 0.1, "{} x {}", s.page_w, s.page_h);
assert!((s.margin_left - 56.7).abs() < 0.1 && (s.margin_right - 42.5).abs() < 0.1);
assert!(!s.landscape);
let d = import(br"{\rtf1\ansi\paperw11906\paperh16838\landscape x\par}").unwrap();
assert!(d.last_section.landscape && d.last_section.page_w > d.last_section.page_h);
// Nonsense keeps Word's defaults.
let d = import(br"{\rtf1\ansi\paperw0\margl99999 x\par}").unwrap();
assert_eq!(d.last_section, wordcraft_doc::SectionProps::default());
}
#[test]
fn table_grid_from_cell_edges_and_borders() {
// Rows whose cells don't line up share one grid: edges 2000, 3000, 9000.
let rtf = br"{\rtf1\ansi
\trowd\cellx2000\cellx9000 \pard\intbl a\cell b\cell\row
\trowd\cellx3000\cellx9000 \pard\intbl c\cell d\cell\row
\pard after\par}";
let f = parse(rtf).unwrap();
let FBlock::Table(t) = &f.blocks[0] else { panic!() };
assert_eq!(t.widths, [100.0, 50.0, 300.0]);
assert_eq!(t.rows[0].iter().map(|c| c.colspan).collect::<Vec<_>>(), [1, 2]);
assert_eq!(t.rows[1].iter().map(|c| c.colspan).collect::<Vec<_>>(), [2, 1]);
assert_eq!(t.cols(), 3);
// RTF tables have no grid style: each cell draws the borders it names, nothing else.
assert!(t.borderless);
assert!(t.rows.iter().flatten().all(|c| c.borders.is_none()));
// A rule under a cell (a letterhead's line): the bottom side only, its width and colour.
let f =
parse(br"{\rtf1\ansi{\colortbl;\red174\green174\blue174;}\trowd\clbrdrb\brdrs\brdrw15\brdrcf1\cellx2000\pard\intbl a\cell\row}").unwrap();
let FBlock::Table(t) = &f.blocks[0] else { panic!() };
let b = t.rows[0][0].borders.unwrap();
assert!(b.top.is_none() && b.left.is_none() && b.right.is_none());
let bottom = b.bottom.unwrap();
assert_eq!((bottom.style, bottom.width, bottom.color), (wordcraft_doc::props::BorderStyle::Single, 0.75, Some(Rgb(174, 174, 174))));
// Every side, in different styles, per cell.
let f = parse(br"{\rtf1\ansi\trowd\clbrdrt\brdrdb\clbrdrl\brdrs\brdrw10\clbrdrb\brdrdot\clbrdrr\brdrth\cellx2000\cellx4000\pard\intbl a\cell b\cell\row}").unwrap();
let FBlock::Table(t) = &f.blocks[0] else { panic!() };
let b = t.rows[0][0].borders.unwrap();
use wordcraft_doc::props::BorderStyle as S;
assert_eq!(
[b.top.map(|x| x.style), b.left.map(|x| x.style), b.bottom.map(|x| x.style), b.right.map(|x| x.style)],
[Some(S::Double), Some(S::Single), Some(S::Dotted), Some(S::Thick)]
);
assert_eq!(b.left.map(|x| x.width), Some(0.5));
assert!(t.rows[0][1].borders.is_none(), "the second cell names no borders");
// A border side set to none isn't a border.
let f = parse(br"{\rtf1\ansi\trowd\clbrdrl\brdrnone\cellx2000\pard\intbl a\cell\row}").unwrap();
let FBlock::Table(t) = &f.blocks[0] else { panic!() };
assert!(t.rows[0][0].borders.is_none());
// The document's cells carry them.
let d = import(br"{\rtf1\ansi\trowd\clbrdrb\brdrs\cellx2000\pard\intbl a\cell\row}").unwrap();
let t = d.body.iter().find_map(|b| if let wordcraft_doc::Block::Table(t) = &**b { Some(t) } else { None }).unwrap();
assert!(t.props.style.is_none());
assert!(t.rows[0].cells[0].props.borders.and_then(|b| b.bottom).is_some());
// The table sizes to the document's page, not Letter.
let d = import(br"{\rtf1\ansi\paperw11906\margl1134\margr1134\trowd\cellx4818\cellx9638\pard\intbl a\cell b\cell\row}").unwrap();
let t = d.body.iter().find_map(|b| if let wordcraft_doc::Block::Table(t) = &**b { Some(t) } else { None }).unwrap();
assert_eq!(t.grid, [240.9, 241.0]);
}
#[test]
fn code_pages_from_ansicpg_and_font_charsets() {
// The document's code page: Russian RTF as accounting systems write it.
let f = parse(br"{\rtf1\ansi\ansicpg1251\deff0{\fonttbl{\f0 Times New Roman;}}\f0 \'cf\'f0\'e8\'e2\'e5\'f2, \'b8\'eb\'ea\'e0 \'b9 5\par}")
.unwrap();
assert_eq!(texts(&f), ["Normal:Привет, ёлка № 5"]);
// Font charsets switch inside a Windows-1252 document: Western, Cyrillic, Central
// European, then back to the first font.
let f = parse(
br"{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0 Arial;}{\f1\fcharset204 Arial CYR;}{\f2\fcharset238 Arial CE;}}\f0 caf\'e9 {\f1 \'c4\'e0} \f2 \'a3\'f3d\'9f \f0 na\'efve\par}",
)
.unwrap();
assert_eq!(texts(&f), ["Normal:café Да Łódź naïve"]);
// The default font's charset applies before any \f; an explicit \cpg wins over \fcharset.
let f = parse(br"{\rtf1\ansi\deff1{\fonttbl{\f1\fcharset204 Times;}{\f2\fcharset0\cpg1253 Greek;}}\'cc\'e8\'f0 \f2 \'c1\'e2\par}").unwrap();
assert_eq!(texts(&f), ["Normal:Мир Αβ"]);
// Raw 8-bit text (not escaped) follows the same code page.
let f = parse(b"{\\rtf1\\ansi\\ansicpg1251 \xc4\xee\xec\\par}").unwrap();
assert_eq!(texts(&f), ["Normal:Дом"]);
}
#[test]
fn shppict_imports_one_image_and_skips_legacy_duplicate() -> Result<(), String> {
let pixels = image::RgbaImage::from_pixel(1, 1, image::Rgba([40, 80, 160, 255]));

View File

@ -280,6 +280,14 @@ fn txt_round_trip() {
assert_eq!(import("txt", &[0x63, 0x61, 0x66, 0xE9, 0x80]).unwrap().unwrap().plain_text(Default::default()), "café€");
}
#[test]
fn txt_export_writes_symbol_font_text_as_unicode() {
let mut d = Document::from_text("Done J\u{F0FC}");
d.format_range(&wordcraft_doc::Pos::body(0, 5), &wordcraft_doc::Pos::body(0, 9), &|c| c.font = Some("Wingdings".into())).unwrap();
let out = export("txt", &d).unwrap().unwrap();
assert_eq!(String::from_utf8(out).unwrap(), "Done \u{263A}\u{2713}");
}
/// #569: a file with an extension we don't know opens as text when it is text (a playlist), and
/// a binary file is told apart.
#[test]

View File

@ -102,7 +102,7 @@ fn blocks_text(blocks: &[FBlock], out: &mut Vec<String>, lists: &mut ListCounter
}
None => lists.reset(),
}
line.push_str(&p.text().replace('\n', "\r\n").replace(['\u{000C}', '\u{000E}'], ""));
line.push_str(&p.shown_text().replace('\n', "\r\n").replace(['\u{000C}', '\u{000E}'], ""));
out.push(line);
}
FBlock::Table(t) => {

View File

@ -720,7 +720,13 @@ fn lines(
text.push_str(s);
shown = Some(r);
} else if let Some(p) = para {
text.push_str(p.text.get(c.start..c.end).unwrap_or(""));
let t = p.text.get(c.start..c.end).unwrap_or("");
// Symbol-font characters (Wingdings…) read as the Unicode symbols drawn.
if wordcraft_doc::symbol_fonts::is_symbol_font(&st.rc.font) {
text.push_str(&wordcraft_doc::symbol_fonts::map_text(&st.rc.font, t));
} else {
text.push_str(t);
}
}
let b = text.len();
let cg = pl.glyphs.get(c.g0 as usize..c.g1 as usize).unwrap_or(&[]);

View File

@ -927,10 +927,9 @@ fn layout_box(
// space after the paragraph above (the larger one counts).
if pl.rp.before_auto && i == 0 {
before = 0.0;
} else if i > 0 && (pl.rp.before_auto || prev_after_auto) {
} else if i > 0 && before > 0.0 && prev_after > 0.0 {
before = (before - prev_after).max(0.0);
}
// Word: space between paragraphs is before + after (no collapsing).
y += if i == 0 { before } else { before.max(0.0) };
// Floating objects anchored here: place them, then wrap the text around them.
// One left out of the layout (hidden, or deleted in the final text) takes no room.
@ -964,7 +963,7 @@ fn layout_box(
// Automatic space after: none after the last paragraph of a cell or box.
let after = if pl.rp.after_auto && i + 1 == blocks.len() { 0.0 } else { pl.rp.space_after };
y += pl.height + after;
prev_after = if same && ctxl { 0.0 } else { after };
prev_after = after;
prev_after_auto = pl.rp.after_auto;
prev_listed = listed;
prev_style = Some((pl.rp.style.clone(), ctxl));
@ -1915,18 +1914,28 @@ fn layout_pass(
// one long page
} else if pb.pages.is_empty() || start != SectionStart::Continuous || turn != prev_turn {
// A page holds text running one way: a section turning the other way starts a page.
let s = &doc.settings;
let (filler, restart) = section_side(start, restart, pb.number, pb.pages.len(), s.even_odd_headers, s.mirror_margins);
// The blank page that puts the section on the right side ends the previous section.
if filler
&& !web
&& let Some((_, prev)) = si.checked_sub(1).and_then(|k| sections.get(k))
{
pb.sect = prev;
pb.sect_idx = si - 1;
let (ph, pf) = (linked(&sections, si - 1, |s| s.headers), linked(&sections, si - 1, |s| s.footers));
let top = body_top_for(&mut ctx, prev, ph.default);
pb.foot_top = foot_top_for(&mut ctx, prev, pf.default);
pb.new_page(block, top);
pb.sect = sect;
pb.sect_idx = si;
}
if let Some(n) = restart {
pb.number = n.saturating_sub(1);
}
pb.foot_top = first_foot;
pb.new_page(block, first_top);
pb.foot_top = foot_top;
if !web && matches!(start, SectionStart::EvenPage | SectionStart::OddPage) {
let want_even = start == SectionStart::EvenPage;
if pb.number.is_multiple_of(2) != want_even {
pb.new_page(block, body_top);
}
}
} else {
// Continuous: new column layout below the current text (its tallest column).
pb.cols = sect.column_boxes();
@ -2278,6 +2287,33 @@ fn item_bottom(y: f32, para: &ParaLayout, l0: usize, l1: usize) -> Option<f32> {
Some(y + l.top + l.height - f.top)
}
/// Whether a section that starts on a new page needs a blank page first so it lands on the right
/// side, and the page number it restarts at. `last` is the number of the page before it and
/// `pages` how many pages there are so far.
/// - An odd- or even-page break counts on from the previous section's numbers. A section that
/// restarts its numbering gets no blank page unless the document prints both sides (different
/// odd and even headers, or mirror margins); a restart at the wrong parity takes the next number.
/// - With different odd and even headers, a next-page break that restarts the numbers at an odd
/// (even) number starts on an odd (even) sheet.
///
/// (Word's output for docxide-pdf's fixtures: bush_fires_act_comparison has a blank page 2.)
fn section_side(start: SectionStart, restart: Option<u32>, last: u32, pages: usize, even_odd: bool, mirror: bool) -> (bool, Option<u32>) {
match start {
SectionStart::OddPage | SectionStart::EvenPage => {
let want_odd = start == SectionStart::OddPage;
let continuing_odd = last.saturating_add(1) % 2 == 1;
let filler = pages > 0 && continuing_odd != want_odd && (restart.is_none() || even_odd || mirror);
let restart = restart.map(|n| if (n % 2 == 1) == want_odd { n } else { n.saturating_add(1) });
(filler, restart)
}
SectionStart::NextPage => {
let filler = even_odd && pages > 0 && restart.is_some_and(|n| (n % 2 == 1) != ((pages + 1) % 2 == 1));
(filler, restart)
}
SectionStart::Continuous | SectionStart::NextColumn => (false, restart),
}
}
/// Where body text starts: the top margin, or below the header if it's taller.
fn body_top_for(ctx: &mut Ctx, sect: &SectionProps, header: Option<u32>) -> f32 {
let Some(id) = header else { return sect.margin_top };
@ -2662,7 +2698,6 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
if broke_before {
pb.new_page(block, body_top);
}
// Contextual spacing: no space between paragraphs of the same style when either asks for it.
let mut before = pl.rp.space_before;
let prev_para = pb.prev.clone();
// At the top of a page, measured in Word for Windows: no space before after a paragraph that
@ -2679,42 +2714,39 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
// off the space before here (Word: 24 pt before after 10 pt after leaves 14 pt).
before = (before - after.max(0.0)).max(0.0);
}
let mut prev = pb.prev.take();
if let Some((style, ctxl, after, _, _)) = &mut prev
&& *style == pl.rp.style
if let Some((style, ctxl, mut after, after_auto, listed)) = pb.prev.take()
&& !pb.at_top()
{
if *ctxl {
pb.y -= *after;
*after = 0.0;
// Contextual spacing: no space between paragraphs of the same style when either asks for it.
if style == pl.rp.style {
if ctxl {
pb.y -= after;
after = 0.0;
}
if pl.rp.contextual_spacing {
before = 0.0;
}
}
if pl.rp.contextual_spacing {
before = 0.0;
// Automatic spacing: none between two list items (as in an HTML list; measured in Word:
// any two items, of one list or two, at any level).
if listed && label.is_some() {
if after_auto {
pb.y -= after;
after = 0.0;
}
if pl.rp.before_auto {
before = 0.0;
}
}
// The space between two paragraphs is the larger of the first's space after and the
// second's space before, not their sum.
if before > 0.0 && after > 0.0 {
before = (before - after).max(0.0);
}
}
// Automatic spacing: none between two list items (as in an HTML list; measured in Word: any
// two items, of one list or two, at any level).
if let Some((_, _, after, after_auto, true)) = &mut prev
&& label.is_some()
&& !pb.at_top()
{
if *after_auto {
pb.y -= *after;
*after = 0.0;
}
if pl.rp.before_auto {
before = 0.0;
}
}
// Automatic spacing: none at the top of the document, and it collapses with the space after
// the paragraph above (the larger one counts).
// Automatic spacing: none at the top of the document.
if pl.rp.before_auto && block == 0 {
before = 0.0;
} else if let Some((_, _, after, after_auto, _)) = prev
&& (pl.rp.before_auto || after_auto)
&& !pb.at_top()
{
before = (before - after).max(0.0);
}
pb.y += before;
let mut applied_before = before;

View File

@ -15,6 +15,53 @@ use wordcraft_fonts::FaceRef;
use crate::NumeralMode;
use crate::fields::{FIELD_SHADING, FieldCtx, field_code_text, field_text};
/// How a run set in one of Word's symbol fonts (Symbol, Wingdings, Wingdings 2/3, Webdings)
/// shows its characters. Those fonts are proprietary and usually missing: a substitute has other
/// glyphs, or none, at their codes, so each character is drawn as its Unicode equivalent
/// ([`wordcraft_doc::symbol_fonts`]) from a face that has it. The text itself is unchanged.
#[derive(Clone, Copy)]
struct SymbolRun<'a> {
family: &'a str,
/// The symbol font itself is installed (`face` is it).
installed: bool,
face: FaceRef,
}
impl<'a> SymbolRun<'a> {
fn of(family: &'a str, r: &wordcraft_fonts::word::Resolved) -> Option<Self> {
wordcraft_doc::symbol_fonts::is_symbol_font(family).then_some(SymbolRun { family, installed: !r.substituted, face: r.face })
}
/// The character drawn for `c`.
fn show(&self, c: char) -> char {
if self.installed {
if self.face.covers(c) {
return c;
}
// A plain 8-bit code in the installed font: its glyph sits at the private-use code.
if let Some(p) = (0x20..=0xFF).contains(&u32::from(c)).then(|| char::from_u32(0xF000 + u32::from(c))).flatten()
&& self.face.covers(p)
{
return p;
}
}
let Some(u) = wordcraft_doc::symbol_fonts::symbol_char(self.family, c) else { return c };
// A few list bullets have common look-alikes in far more fonts: use one when no face
// has the exact character.
let alt = match u {
'\u{26AB}' => Some('\u{25CF}'),
'\u{25FC}' => Some('\u{25A0}'),
'\u{2B9A}' => Some('\u{27A2}'),
_ => None,
};
let has = |ch: char| self.face.covers(ch) || wordcraft_fonts::FontDb::global().fallback_for(ch, self.face.id()).is_some();
match alt {
Some(a) if !has(u) && has(a) => a,
_ => u,
}
}
}
/// A face at a size with the formatting needed to draw it.
#[derive(Clone, Debug)]
pub struct StyleRun {
@ -657,7 +704,11 @@ impl<'a> Builder<'a> {
// Complex-script characters (Persian, Arabic…) use the run's complex-script font, size,
// bold and italic.
let rc_cs: Option<Arc<ResolvedChar>> = text.chars().any(|c| rc.uses_complex(c)).then(|| Arc::new(rc.complex()));
let primary = wordcraft_fonts::word::resolve(&rc.font, rc.bold, rc.italic).face;
let resolved = wordcraft_fonts::word::resolve(&rc.font, rc.bold, rc.italic);
let primary = resolved.face;
// Symbol-font characters are laid out as their Unicode equivalents.
let sym = SymbolRun::of(&rc.font, &resolved);
let show = |c: char| sym.map_or(c, |m| m.show(c));
let primary_cs = rc_cs.as_ref().map(|r| wordcraft_fonts::word::resolve(&r.font, r.bold, r.italic).face);
// Chinese, Japanese and Korean characters use the run's East Asian font (`w:eastAsia`) when
// it is installed and has them.
@ -692,6 +743,8 @@ impl<'a> Builder<'a> {
let mut segs: Vec<(usize, usize, SegKey)> = Vec::new();
for (i, c) in text.char_indices() {
let rtl = use_levels && self.levels.get(base + i).is_some_and(|l| l % 2 == 1);
// A symbol-font character becomes its Unicode symbol (#776).
let c = show(c);
// Displayed form (Word › Numeral): one char in, one char out. Coverage and the
// fallback face are picked for the displayed char, so Hindi digits land in an
// Arabic-capable face and Western digits in a Latin one.
@ -759,7 +812,16 @@ impl<'a> Builder<'a> {
orig_of.push(sub.len());
}
let shape_text = if substituted { mapped.as_str() } else { sub };
let map = |c: char| if upper { c.to_uppercase().next().unwrap_or(c) } else { c };
let map = |c: char| {
let d = show(c);
if d != c {
d
} else if upper {
c.to_uppercase().next().unwrap_or(c)
} else {
c
}
};
// The shaper kerns by default; Word kerns only when Kerning for fonts covers the size.
let features: &[wordcraft_fonts::Feature] = if rc.kerned() { &[] } else { &no_kern };
let shaped = if use_levels {
@ -1379,24 +1441,16 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
let label = env.label.as_ref().map(|(text, lvl)| {
let lc = p.mark.clone().overlaid(&lvl.chr);
let rc = resolve(&lc);
// A Symbol/Wingdings bullet (U+F0B7…) without that font installed, or in a face that has
// no glyph at the private-use code: draw the standard character instead, since a
// substitute has another glyph, or none, there.
let r = wordcraft_fonts::word::resolve(&rc.font, rc.bold, rc.italic);
let text: String = text
.chars()
.map(|c| match wordcraft_fonts::word::symbol_font_char(&rc.font, c) {
Some(u) if r.substituted || !r.face.covers(c) => u,
_ => c,
})
.collect();
// A Symbol/Wingdings bullet (U+F0B7…) is drawn as its Unicode equivalent when the font
// isn't installed (shaping maps it); the label's text reads as that character too.
let shown = |t: &str| -> String { wordcraft_doc::symbol_fonts::map_text(&rc.font, t) };
let before_c = b.clusters.len();
let before_g = b.glyphs.len();
b.shape_with(&text, 0, &rc, None, false);
b.shape_with(text, 0, &rc, None, false);
let mut cl: Vec<Cluster> = b.clusters.drain(before_c..).collect();
// A right-to-left paragraph's label reads right to left too ("1." shows as ".1").
if rp.bidi {
let lv = bidi_levels(&text, true);
let lv = bidi_levels(text, true);
let cl_lv: Vec<u8> = cl.iter().map(|c| lv.get(c.start).copied().unwrap_or(1)).collect();
let order = visual_order(&cl_lv);
cl = order.iter().filter_map(|&i| cl.get(i).cloned()).collect();
@ -1411,18 +1465,18 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
for g in b.glyphs.get(c.g0 as usize..c.g1 as usize).into_iter().flatten() {
glyphs.push(Glyph { dx: g.dx + x, ..*g });
}
let chars = text.get(c.start..c.end).unwrap_or("");
let chars = shown(text.get(c.start..c.end).unwrap_or(""));
match runs.last_mut() {
Some(r) if r.style == c.style => {
r.glyphs.end = glyphs.len();
r.text.push_str(chars);
r.text.push_str(&chars);
}
_ => runs.push(LabelRun { style: c.style, glyphs: g0..glyphs.len(), text: chars.to_string() }),
_ => runs.push(LabelRun { style: c.style, glyphs: g0..glyphs.len(), text: chars }),
}
x += c.adv;
}
b.glyphs.truncate(before_g);
Label { text, style, glyphs, runs, x: 0.0, width: x }
Label { text: shown(text), style, glyphs, runs, x: 0.0, width: x }
});
let mut pl = ParaLayout {

View File

@ -559,13 +559,42 @@ fn symbol_font_bullet_without_the_font_draws_a_bullet() {
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 })).unwrap();
let l = lay(&d);
let label = l.pages[0].items.iter().find_map(|it| if let Placed::Lines { para, .. } = it { para.label.clone() } else { None }).unwrap();
// Where a real Symbol font with the private-use code is installed (Windows), the code stays.
let symbol = wordcraft_fonts::word::resolve("Symbol", false, false);
let expected = if symbol.substituted || !symbol.face.covers('\u{F0B7}') { "\u{2022}" } else { "\u{F0B7}" };
assert_eq!(label.text, expected);
// The label reads as the Unicode bullet (drawn from a real Symbol font where one is installed).
assert_eq!(label.text, "\u{2022}");
assert_eq!(label.glyphs.len(), 1);
}
#[test]
fn wingdings_text_draws_and_reads_as_its_unicode_symbols() {
// Word stores symbol-font text both ways: plain codes ("J" = smiley) and private-use codes
// (U+F0FC = check mark, what `w:sym` writes).
let mut d = Document::from_text("Hi J\u{F0FC} ok");
d.format_range(&Pos::body(0, 3), &Pos::body(0, 7), &|c| c.font = Some("Wingdings".into())).unwrap();
let l = lay(&d);
let items = display::page_display(&d, &l.pages[0], &display::DisplayOptions::default());
let text: String = items.iter().filter_map(|i| if let display::Draw::Glyphs { text, .. } = i { Some(text.as_str()) } else { None }).collect();
assert_eq!(text, "Hi \u{263A}\u{2713} ok");
// The stored text is unchanged.
assert_eq!(d.plain_text(StoryRef::Body), "Hi J\u{F0FC} ok");
// Without Wingdings, each symbol is drawn from a face that has its Unicode character (never
// the substitute's "J" or a missing glyph).
if wordcraft_fonts::word::resolve("Wingdings", false, false).substituted {
for want in ['\u{263A}', '\u{2713}'] {
let run = items.iter().find_map(|i| match i {
display::Draw::Glyphs { face, glyphs, text, .. } if text.contains(want) => Some((*face, glyphs.clone())),
_ => None,
});
let Some((face, glyphs)) = run else { panic!("no run for {want}") };
if face.covers(want) {
assert!(glyphs.iter().any(|g| g.0 == face.glyph_for(want)), "{want}");
} else {
// No installed face has it (a bare CI machine): never the letter J's glyph.
assert!(!glyphs.iter().any(|g| g.0 != 0 && g.0 == face.glyph_for('J')), "{want}");
}
}
}
}
#[test]
fn tables_lay_out_cells() {
let mut d = Document::from_text("before\nafter");
@ -898,6 +927,90 @@ fn section_breaks_show_with_formatting_marks() {
assert_eq!(labels(&Document::from_text("One\nTwo"), true), (vec![], 2));
}
#[test]
fn space_between_paragraphs_is_the_larger_of_after_and_before() {
// (space after the first, space before the second, contextual spacing on both) -> gap
let gap = |after: f32, before: f32, contextual: bool| -> f32 {
let mut d = Document::from_text("One\nTwo");
for (i, (a, b)) in [(after, 0.0), (0.0, before)].into_iter().enumerate() {
let p = d.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(i)).unwrap();
(p.props.space_after, p.props.space_before, p.props.contextual_spacing) = (Some(a), Some(b), Some(contextual));
}
let l = lay(&d);
let ys: Vec<(f32, f32)> =
l.pages[0].items.iter().filter_map(|i| if let Placed::Lines { y, para, .. } = i { Some((*y, para.height)) } else { None }).collect();
ys[1].0 - (ys[0].0 + ys[0].1)
};
// Word's output for docxide-pdf's clean_energy_regulator_guidelines: 6pt after and 6pt before
// leave 6pt, not 12pt.
assert!((gap(6.0, 6.0, false) - 6.0).abs() < 0.01);
assert!((gap(6.0, 12.0, false) - 12.0).abs() < 0.01);
assert!((gap(12.0, 6.0, false) - 12.0).abs() < 0.01);
assert!((gap(0.0, 6.0, false) - 6.0).abs() < 0.01);
// Contextual spacing drops both between paragraphs of one style.
assert!(gap(6.0, 12.0, true).abs() < 0.01);
}
#[test]
fn space_between_paragraphs_in_a_cell_follows_the_body_rule() {
// Three paragraphs in a cell, each 6pt before and 6pt after -> the gaps between them.
let gaps = |contextual: bool| -> Vec<f32> {
let mut d = Document::from_text("before\nafter");
let mut t = Table::new(1, 1, 468.0);
t.rows[0].cells[0].blocks = (0..3)
.map(|k| {
let mut p = wordcraft_doc::Paragraph::with_text(&format!("p{k}"), Default::default());
(p.props.space_after, p.props.space_before, p.props.contextual_spacing) = (Some(6.0), Some(6.0), Some(contextual));
wordcraft_doc::para_block(p)
})
.collect();
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
let l = lay(&d);
let mut ys: Vec<(u32, f32, f32)> = l.pages[0]
.items
.iter()
.filter_map(|it| match it {
Placed::Lines { path, y, para, .. } if path.0.len() == 4 => Some((path.0[3], *y, para.height)),
_ => None,
})
.collect();
ys.sort_by_key(|v| v.0);
ys.windows(2).map(|w| w[1].1 - (w[0].1 + w[0].2)).collect()
};
let near = |got: Vec<f32>, want: [f32; 2]| got.len() == 2 && got.iter().zip(want).all(|(g, w)| (g - w).abs() < 0.01);
// The larger of after and before, not their sum.
assert!(near(gaps(false), [6.0, 6.0]), "{:?}", gaps(false));
// Contextual spacing drops the space between every pair, not just the first.
assert!(near(gaps(true), [0.0, 0.0]), "{:?}", gaps(true));
}
#[test]
fn sections_land_on_the_side_their_break_and_numbering_ask_for() {
use wordcraft_doc::section::{SectionProps, SectionStart};
// "One" ends the first section on page 1; the second section starts as `start`.
let pages = |start: SectionStart, restart: Option<u32>, even_odd: bool| -> Vec<(usize, u32)> {
let mut d = Document::from_text("One\nTwo");
d.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().section = Some(Box::new(SectionProps::default()));
d.last_section.start = start;
d.last_section.page_num_start = restart;
d.settings.even_odd_headers = even_odd;
lay(&d).pages.iter().map(|p| (p.section, p.number)).collect()
};
// An odd-page break after page 1 leaves page 2 blank; the blank page ends the first section.
assert_eq!(pages(SectionStart::OddPage, None, false), [(0, 1), (0, 2), (1, 3)]);
assert_eq!(pages(SectionStart::EvenPage, None, false), [(0, 1), (1, 2)]);
// Restarting at 1 needs no blank page unless both sides print; at 2 it takes the next number.
assert_eq!(pages(SectionStart::OddPage, Some(1), false), [(0, 1), (1, 1)]);
assert_eq!(pages(SectionStart::OddPage, Some(2), false), [(0, 1), (1, 3)]);
assert_eq!(pages(SectionStart::OddPage, Some(1), true), [(0, 1), (0, 2), (1, 1)]);
// With different odd and even headers, a next-page break restarting at 1 starts on an odd
// sheet (Word's output for docxide-pdf's bush_fires_act_comparison).
assert_eq!(pages(SectionStart::NextPage, Some(1), true), [(0, 1), (0, 2), (1, 1)]);
assert_eq!(pages(SectionStart::NextPage, Some(2), true), [(0, 1), (1, 2)]);
assert_eq!(pages(SectionStart::NextPage, Some(1), false), [(0, 1), (1, 1)]);
assert_eq!(pages(SectionStart::NextPage, None, true), [(0, 1), (1, 2)]);
}
#[test]
fn paragraph_marks_sit_past_text_running_the_other_way() {
// A left-to-right paragraph ending in Hebrew, and a right-to-left one ending in Latin: the

View File

@ -200,6 +200,24 @@ pub fn image_size(bytes: &[u8]) -> Option<(u32, u32)> {
image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()
}
/// The natural size in points of a Windows Metafile or Enhanced Metafile picture, and the file
/// extension it is stored under (`"wmf"` or `"emf"`), or None for anything else (#802). Very large
/// frames are scaled down to 22 inches on the longer side, keeping the shape.
pub fn metafile_size(bytes: &[u8]) -> Option<(f32, f32, &'static str)> {
if !wordcraft_metafile::is_metafile(bytes) {
return None;
}
let pic = wordcraft_metafile::parse(bytes).ok()?;
let (w, h) = (pic.width, pic.height);
if !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) {
return None;
}
let k = (1584.0 / w.max(h)).min(1.0);
// An EMF starts with its header record, type 1.
let ext = if bytes.get(..4) == Some(&[1, 0, 0, 0]) { "emf" } else { "wmf" };
Some((w * k, h * k, ext))
}
/// A metafile (WMF/EMF) picture, parsed once. The geometry comes placed by the metafile crate; the
/// bitmaps are pixmaps, one per picture item (None for paths and undecodable bitmaps).
struct Vector {
@ -412,6 +430,44 @@ pub fn render_region(doc: &Document, page: &Page, w: u32, h: u32, view: Affine,
Rendered { width: w16 as u32, height: h16 as u32, pixels }
}
/// One line of `text` in `face` at `size` points, black on white, in a `w`×`h` point raster at
/// `scale` pixels per point, starting `margin` points in with the baseline `margin` + the face's
/// ascent down: a font list entry. Outlines come straight from the face, not the shared outline
/// cache, so a face read just for the preview leaves nothing behind once dropped (#848).
pub fn render_text_line(face: &wordcraft_fonts::FontFace, text: &str, size: f32, w: f32, h: f32, margin: f32, scale: f32) -> Rendered {
let fin = |v: f32, d: f32| if v.is_finite() && v >= 0.0 { v } else { d };
let (size, w, h, margin) = (fin(size, 12.0).min(1000.0) as f64, fin(w, 1.0), fin(h, 1.0), fin(margin, 0.0).min(1000.0) as f64);
let scale = if scale.is_finite() && scale > 0.0 { scale.min(16.0) } else { 1.0 };
let (w16, h16) = (((w * scale).ceil() as u32).clamp(1, MAX_SIDE) as u16, ((h * scale).ceil() as u32).clamp(1, MAX_SIDE) as u16);
let mut ctx = RenderContext::new_with(w16, h16, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
ctx.set_transform(Affine::IDENTITY);
ctx.set_paint(color(Rgb(255, 255, 255), 1.0));
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w16 as f64, h16 as f64));
ctx.set_paint(color(Rgb(0, 0, 0), 1.0));
let upem = if face.upem.is_finite() && face.upem > 0.0 { face.upem } else { 1000.0 };
let k = size / upem;
let view = Affine::scale(scale as f64);
let ascent = if face.ascent.is_finite() { face.ascent.clamp(0.0, upem * 4.0) } else { upem * 0.8 };
let baseline = margin + ascent * k;
let mut x = margin;
for g in wordcraft_fonts::shape(face, text, &[], |c| c) {
let o = face.glyph_outline(g.gid);
ctx.set_transform(view * Affine::translate((x + g.x_offset as f64 * k, baseline - g.y_offset as f64 * k)) * Affine::scale(k));
ctx.fill_path(&o);
x += g.x_advance as f64 * k;
if x > w as f64 {
break;
}
}
ctx.flush();
let mut pixels = vec![0u8; w16 as usize * h16 as usize * 4];
let mut res = Resources::new();
if let Some(pm) = vello_cpu::PixmapMut::new(w16, h16, &mut pixels) {
ctx.render(pm, &mut res);
}
Rendered { width: w16 as u32, height: h16 as u32, pixels }
}
fn draw_watermark(ctx: &mut RenderContext, view: Affine, page: &Page, wm: &wordcraft_doc::Watermark, opts: &RenderOptions) {
let r = wordcraft_fonts::word::resolve(&wm.font, false, false);
let face = r.face;

View File

@ -9,6 +9,7 @@ description = "WordCraft egui front end (Word-style ribbon UI); swappable: nothi
workspace = true
[dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
wordcraft-geom = { workspace = true }
wordcraft-doc = { workspace = true }
wordcraft-fonts = { workspace = true }

View File

@ -2,7 +2,7 @@
//!
//! Methods (JSON lines over the host's transport, see `docs/control-protocol.md`):
//! - `engine.execute {command, params}`: run any command (engine or `ui.*`)
//! - `engine.commands`: every command with its enablement
//! - `engine.commands {search?}`: engine and app print commands with enablement and parameters
//! - `document.inspect`: document structure; `ui.inspect`: UI state, canvas geometry, perf
//! - `ui.click {x, y, button?, count?, shift?, cmd?}`, `ui.move {x, y}`, `ui.drag {x, y, toX, toY}`:
//! real pointer input in screen points (reaches every widget)
@ -119,12 +119,19 @@ pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> O
}
"engine.commands" => {
let reg = app.session.registry.clone();
ok(Value::Array(
reg.all()
.iter()
.map(|c| json!({"id": c.id, "label": c.label, "location": c.location, "shortcut": c.shortcut, "params": c.params, "enabled": (c.enabled)(&app.session).is_none()}))
.collect(),
))
let mut commands: Vec<Value> = reg.all().iter()
.map(|c| json!({"id": c.id, "label": c.label, "location": c.location, "shortcut": c.shortcut, "params": c.params, "enabled": (c.enabled)(&app.session).is_none()}))
.collect();
commands.extend(crate::printing::commands(app));
let search = s("search").unwrap_or("").to_ascii_lowercase();
if !search.is_empty() {
commands.retain(|command| {
["id", "label", "location", "params"]
.iter()
.any(|field| command.get(field).and_then(Value::as_str).is_some_and(|text| text.to_ascii_lowercase().contains(&search)))
});
}
ok(Value::Array(commands))
}
"document.inspect" => match app.session.run("document.inspect", p) {
Ok(v) => ok(v),

View File

@ -31,7 +31,7 @@ pub enum FileDialogRequest {
/// cancelled, or an error when no system dialog could be shown (the app then shows its own).
pub type FileDialogAnswer = Result<Option<String>, String>;
const PICTURES: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"];
const PICTURES: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"];
const DOCUMENTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"];
/// The file types Open offers, as (label, extensions; `*` is any file): pictures for Insert/Change

View File

@ -101,7 +101,7 @@
Number format Format des numéros
Number style for this level: Style de numéros pour ce niveau :
Outside border Bordure extérieure
Overtype Remplacement
Overtype Refrappe
Outside: Extérieur :
Restart list after: Redémarrer la liste après :
Right border Bordure droite
@ -1627,8 +1627,8 @@
SmartArt Design Création SmartArt
SmartArt Styles Styles SmartArt
Text Pane Volet Texte
Promote to Heading 1 Promouvoir en Titre 1
Demote to Body Text Abaisser en corps de texte
Promote to Heading 1 Élever au niveau Titre 1
Demote to Body Text Abaisser au niveau Corps de texte
Move Up Monter
Move Down Descendre
Collapse Réduire
@ -1649,7 +1649,7 @@
Show Text Formatting Afficher la mise en forme du texte
Close Outline View Fermer le mode Plan
Outline State État du plan
Outlining Plan
Outlining Mode Plan
Outline Tools Outils Plan
A, B, C, … A, B, C, …
Above the item Au-dessus de l’élément
@ -1862,7 +1862,7 @@
# Interface strings added on main since this catalog was first written.
As you type Pendant la frappe
Asian text font: Police du texte asiatique :
AutoSave writes your changes to the file a moment after you stop typing. L’enregistrement automatique écrit vos modifications dans le fichier peu après que vous arrêtez de taper.
AutoSave writes your changes to the file a moment after you stop typing. L’enregistrement automatique écrit vos modifications dans le fichier peu après que vous avez cessé de taper.
Centimeters Centimètres
Check spelling as you type Vérifier l’orthographe pendant la frappe
Click to switch back to inserting Cliquez pour revenir au mode insertion
@ -1882,8 +1882,8 @@
Millimeters Millimètres
New comments and tracked changes are recorded under this name and these initials. Les nouveaux commentaires et les modifications suivies sont enregistrés sous ce nom et ces initiales.
New word Nouveau mot
No words yet. Add to Dictionary on a misspelled word puts it here. Aucun mot pour l’instant. Les mots ajoutés avec Ajouter au dictionnaire sur un mot mal orthographié apparaissent ici.
Overtype (typing replaces the characters after the caret) Remplacement (la frappe remplace les caractères situés après le curseur)
No words yet. Add to Dictionary on a misspelled word puts it here. Aucun mot pour l’instant. Utilisez Ajouter au dictionnaire sur un mot mal orthographié pour l’ajouter ici.
Overtype (typing replaces the characters after the caret) Refrappe (la frappe remplace les caractères situés après le curseur)
Page display Affichage de la page
Paragraph marks Marques de paragraphe
Personalize Personnaliser
@ -1902,7 +1902,7 @@
These marks show even when Show/Hide ¶ is off. Ces marques s’affichent même lorsque Afficher/Masquer ¶ est désactivé.
Typing replaces selected text La frappe remplace le texte sélectionné
Update fields before printing Mettre à jour les champs avant l’impression
Use the Insert key to switch overtype on and off Utiliser la touche Inser pour activer ou désactiver le mode Remplacement
Use the Insert key to switch overtype on and off Utiliser la touche Inser pour activer ou désactiver le mode Refrappe
Extend Selection by Units Étendre la sélection par unités
Animation Animation
Animate the caret (it glides and fades) Animer le curseur (il glisse et s’estompe)
@ -1929,7 +1929,7 @@
Lum: Lum. :
Hex: Hex :
Document template: Modèle de document :
Attach… Joindre…
Attach… Attacher…
Automatically update document styles Mettre à jour automatiquement les styles du document
Organizer… Organiseur…
Documents Documents
@ -1968,13 +1968,13 @@
Hierarchy Hiérarchie
Relationship Relation
Pyramid Pyramide
Basic List Liste simple
Basic Process Processus simple
Basic Cycle Cycle simple
Basic Pyramid Pyramide simple
Basic Radial Radial simple
Basic Matrix Matrice simple
Basic Venn Venn simple
Basic List Liste de base
Basic Process Processus de base
Basic Cycle Cycle de base
Basic Pyramid Pyramide de base
Basic Radial Radial de base
Basic Matrix Matrice de base
Basic Venn Venn de base
Shows items of equal weight as blocks in rows. Affiche des éléments de même importance sous forme de blocs en lignes.
Shows steps in order, left to right, with arrows between them. Affiche des étapes dans l’ordre, de gauche à droite, reliées par des flèches.
Shows steps that repeat, around a circle. Affiche des étapes qui se répètent, disposées en cercle.
@ -2021,14 +2021,14 @@
Expression Expression
Not Non
Num Occurrences Nombre d’occurrences
Previous 1 or More 1 ou plusieurs du précédent
Previous 1 or More 1 occurrence ou plus du précédent
0 or More Characters 0 caractère ou plus
Fix Position on Page Fixer la position sur la page
Visual effects Effets visuels
Animation effects: Effets d’animation :
On Activés
Off Désactivés
Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Défilement et zoom fluides, et menus et boutons qui apparaissent en fondu. Avec Désactivés, chaque changement s’affiche immédiatement.
Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Défilement et zoom fluides, et menus et boutons qui apparaissent en fondu. Désactivés : chaque changement s’affiche immédiatement.
Your system has animation effects turned on. Les effets d’animation sont activés sur votre système.
Your system has animation effects turned off. Les effets d’animation sont désactivés sur votre système.
Your system doesn't report an animation setting, so animation effects stay on. Votre système n’indique aucun réglage d’animation, les effets d’animation restent donc activés.

Can't render this file because it contains an unexpected character in line 7 and column 25.

View File

@ -2114,5 +2114,357 @@
Correct accidental usage of cAPS LOCK key Poprawiaj przypadkowe użycie klawisza cAPS LOCK
Region: Region:
Automatic ({region}) Automatycznie ({region})
# Style, table style, watermark, paper size and equation gallery names, from #750 by @olekkut
AaBbCcDd AaBbCcDd
Annotation Reference Odwołanie do adnotacji
Annotation Subject Temat adnotacji
Annotation Text Tekst adnotacji
Body text in a short paragraph to show spacing. Treść dokumentu w krótkim akapicie pokazującym odstępy.
Default Paragraph Font Domyślna czcionka akapitu
Envelope Address Adres na kopercie
Envelope Return Adres zwrotny na kopercie
Index Heading Nagłówek indeksu
Index {n} Indeks {n}
Line Number Numer wiersza
Normal Normalny
Normal Indent Normalny z wcięciem
Normal Table Normalna tabela
Table Grid Siatka tabeli
Table of Figures Spis ilustracji
preview Title Tytuł
TOA Heading Nagłówek spisu źródeł
Good evening Dobry wieczór
Good morning Dzień dobry
Good afternoon Dzień dobry
tOGGLE cASE oDWRÓĆ wIELKOŚĆ lITER
Change to Level 1 Zmień na poziom 1
Change to Level 2 Zmień na poziom 2
Change to Level 3 Zmień na poziom 3
Change to Level 4 Zmień na poziom 4
Change to Level 5 Zmień na poziom 5
Level 1 Poziom 1
Level 2 Poziom 2
Level 3 Poziom 3
ASAP JAK NAJSZYBCIEJ
CONFIDENTIAL POUFNE
DO NOT COPY NIE KOPIOWAĆ
DRAFT WERSJA ROBOCZA
SAMPLE WZÓR
URGENT PILNE
Letter 8.50" × 11.00" Letter 8,50" × 11,00"
Legal 8.50" × 14.00" Legal 8,50" × 14,00"
Executive 7.25" × 10.50" Executive 7,25" × 10,50"
Statement 5.50" × 8.50" Statement 5,50" × 8,50"
Tabloid 11.00" × 17.00" Tabloid 11,00" × 17,00"
B4 (JIS) 10.12" × 14.33" B4 (JIS) 10,12" × 14,33"
B5 (JIS) 7.17" × 10.12" B5 (JIS) 7,17" × 10,12"
Envelope #10 4.12" × 9.50" Koperta nr 10 4,12" × 9,50"
Envelope DL 4.33" × 8.66" Koperta DL 4,33" × 8,66"
Plain Table 1 Prosta tabela 1
Grid Table 1 Light Tabela z siatką 1 — jasna
Grid Table 4 – Blue Tabela z siatką 4 — niebieska
Grid Table 4 – Green Tabela z siatką 4 — zielona
Grid Table 4 – Orange Tabela z siatką 4 — pomarańczowa
Grid Table 4 – Purple Tabela z siatką 4 — fioletowa
List Table 3 – Blue Tabela listy 3 — niebieska
List Table 3 – Green Tabela listy 3 — zielona
List Table 3 – Orange Tabela listy 3 — pomarańczowa
List Table 3 – Purple Tabela listy 3 — fioletowa
Convert between built-up and linear form Konwertuj między postacią profesjonalną a liniową
Justification of a display equation Wyrównanie równania wyświetlanego w osobnym wierszu
Number the equation; the number sits at the right margin (or type # then the number) Numeruj równanie; numer jest umieszczany przy prawym marginesie (lub wpisz # i numer)
Show the equation in the line of text Wyświetl równanie w wierszu tekstu
Show the equation on a line of its own Wyświetl równanie w osobnym wierszu
Type equations in LaTeX Wpisuj równania w notacji LaTeX
Type equations in the linear format (UnicodeMath) Wpisuj równania w formacie liniowym (UnicodeMath)
Type normal (non-math) text in the equation Wpisz zwykły (niematematyczny) tekst w równaniu
Sides of a right triangle Boki trójkąta prostokątnego
Circle area from its radius Pole koła na podstawie promienia
Roots of ax²+bx+c Pierwiastki równania ax²+bx+c
Binomial expansion of (x+a)ⁿ Rozwinięcie dwumianu (x+a)ⁿ
Power series of (1+x)ⁿ Szereg potęgowy (1+x)ⁿ
Euler's identity Tożsamość Eulera
Derivative as a limit Pochodna jako granica
Exponential series Szereg wykładniczy
Integral of e^(−x²) Całka z e^(−x²)
Sine and cosine series of f(x) Szereg sinusów i cosinusów funkcji f(x)
Sum or difference of sines Suma lub różnica sinusów
Sum of cosines Suma cosinusów
Normal probability density Gęstość rozkładu normalnego
Bayes' rule Twierdzenie Bayesa
Accents Akcenty
Basic Math Podstawowa matematyka
Boxed Formulas Wzory w ramce
Cases and Stacks Układy klamrowe i piętrowe
Common Accent Objects Typowe obiekty z akcentem
Common Brackets Typowe nawiasy
Common Fractions Typowe ułamki
Common Functions Typowe funkcje
Common Large Operators Typowe duże operatory
Common Operator Structures Typowe struktury operatorów
Common Operators Typowe operatory
Common Radicals Typowe pierwiastki
Common Subscripts and Superscripts Typowe indeksy dolne i górne
Contour Integrals Całki okrężne
Differentials Różniczki
Dots Kropki
Empty Matrices Puste macierze
Functions Funkcje
Geometry Geometria
Greek Letters Litery greckie
Hyperbolic Functions Funkcje hiperboliczne
Identity Matrices Macierze jednostkowe
Integrals Całki
Inverse Functions Funkcje odwrotne
Inverse Hyperbolic Functions Odwrotne funkcje hiperboliczne
Letter-Like Symbols Symbole literopodobne
Matrices with Brackets Macierze w nawiasach
Negated Relations Relacje zanegowane
Operator Structures Struktury operatorów
Operators Operatory
Other Large Operators Inne duże operatory
Overbars and Underbars Kreski górne i dolne
Products and Coproducts Iloczyny i koprodukty
Radicals Pierwiastki
Scripts Indeksy
Single Brackets Pojedyncze nawiasy
Sparse Matrices Macierze rzadkie
Subscripts and Superscripts Indeksy dolne i górne
Summations Sumy
Trigonometric Functions Funkcje trygonometryczne
Unions and Intersections Sumy i iloczyny zbiorów
1×2 Empty Matrix Pusta macierz 1×2
1×3 Empty Matrix Pusta macierz 1×3
2×1 Empty Matrix Pusta macierz 2×1
2×2 Empty Matrix Pusta macierz 2×2
2×3 Empty Matrix Pusta macierz 2×3
3×1 Empty Matrix Pusta macierz 3×1
3×2 Empty Matrix Pusta macierz 3×2
3×3 Empty Matrix Pusta macierz 3×3
2×2 Determinant Wyznacznik 2×2
2×2 Identity Matrix Macierz jednostkowa 2×2
2×2 Identity Matrix (sparse) Macierz jednostkowa 2×2 (rzadka)
3×3 Identity Matrix Macierz jednostkowa 3×3
3×3 Identity Matrix (sparse) Macierz jednostkowa 3×3 (rzadka)
2×2 Matrix in Brackets Macierz 2×2 w nawiasach kwadratowych
2×2 Matrix in Double Bars Macierz 2×2 w podwójnych kreskach pionowych
2×2 Matrix in Parentheses Macierz 2×2 w nawiasach okrągłych
n×n Identity Matrix Macierz jednostkowa n×n
n×n Matrix Macierz n×n
A xor B A xor B
Absolute value cases Przypadki wartości bezwzględnej
Acute Akcent ostry
Angle Brackets Nawiasy kątowe
Angle Brackets with Separator Nawiasy kątowe z separatorem
Angle Brackets with Two Separators Nawiasy kątowe z dwoma separatorami
Baseline Dots Kropki na linii bazowej
Binomial Coefficient Współczynnik dwumianowy
Binomial Coefficient (angle) Współczynnik dwumianowy (nawiasy kątowe)
Binomial Coefficient with Angle Brackets Współczynnik dwumianowy w nawiasach kątowych
Boxed Formula Wzór w ramce
Boxed Formula (example) Wzór w ramce (przykład)
Braces Nawiasy klamrowe
Braces with Separator Nawiasy klamrowe z separatorem
Brackets Nawiasy kwadratowe
Brackets with Separators Nawiasy kwadratowe z separatorami
Breve Brewis
Cases (Three Conditions) Układ warunków (trzy warunki)
Cases (Two Conditions) Układ warunków (dwa warunki)
Ceiling Sufit
Change in y over change in x Przyrost y przez przyrost x
Check Daszek odwrócony
Circled Dot Kropka w okręgu
Circled Plus Plus w okręgu
Circled Times Znak mnożenia w okręgu
Colon Equal Dwukropek i znak równości
Contour Integral Całka okrężna
Contour Integral with Limits Całka okrężna z granicami
Contour Integral with Stacked Limits Całka okrężna z granicami u góry i u dołu
Coproduct Koprodukt
Coproduct with Limits Koprodukt z granicami
Coproduct with Lower Limit Koprodukt z dolną granicą
Coproduct with Subscript-Superscript Limits Koprodukt z granicami w indeksach
Cosecant Function Funkcja cosecans
Cosine 2x Cosinus 2x
Cosine Function Funkcja cosinus
Cotangent Function Funkcja cotangens
Cube Root Pierwiastek sześcienny
Delta Equal To Równe z deltą
Delta over arrow Delta nad strzałką
Diagonal Dots Kropki ukośne
Differential Różniczka
Differential theta Różniczka theta
Differential x Różniczka x
Differential y Różniczka y
Dot Kropka nad znakiem
Double Bar Podwójna kreska nad znakiem
Double Dot Dwie kropki nad znakiem
Double Integral Całka podwójna
Double Integral with Limits Całka podwójna z granicami
Double Integral with Stacked Limits Całka podwójna z granicami u góry i u dołu
Double Left Arrow with Text Above Podwójna strzałka w lewo z tekstem powyżej
Double Left Arrow with Text Below Podwójna strzałka w lewo z tekstem poniżej
Double Left Right Arrow with Text Above Podwójna strzałka w lewo i prawo z tekstem powyżej
Double Left Right Arrow with Text Below Podwójna strzałka w lewo i prawo z tekstem poniżej
Double Right Arrow with Text Above Podwójna strzałka w prawo z tekstem powyżej
Double Right Arrow with Text Below Podwójna strzałka w prawo z tekstem poniżej
Double Square Brackets Podwójne nawiasy kwadratowe
Double Vertical Bars Podwójne kreski pionowe
Double summation Suma podwójna
Equal Equal Podwójny znak równości
Equal by Definition Równe z definicji
Exponential Funkcja wykładnicza
Floor Podłoga
Grave Akcent ciężki
Grouping Character Above Znak grupujący powyżej
Grouping Character Below Znak grupujący poniżej
Hat Daszek
Hyperbolic Cosecant Function Funkcja cosecans hiperboliczny
Hyperbolic Cosine Function Funkcja cosinus hiperboliczny
Hyperbolic Cotangent Function Funkcja cotangens hiperboliczny
Hyperbolic Secant Function Funkcja secans hiperboliczny
Hyperbolic Sine Function Funkcja sinus hiperboliczny
Hyperbolic Tangent Function Funkcja tangens hiperboliczny
Hypotenuse Przeciwprostokątna
Integral with Limits Całka z granicami
Integral with Stacked Limits Całka z granicami u góry i u dołu
Intersection Iloczyn zbiorów
Intersection with Limits Iloczyn zbiorów z granicami
Intersection with Lower Limit Iloczyn zbiorów z dolną granicą
Intersection with Subscript-Superscript Limits Iloczyn zbiorów z granicami w indeksach
Inverse Cosecant Function Funkcja arcus cosecans
Inverse Cosine Function Funkcja arcus cosinus
Inverse Cotangent Function Funkcja arcus cotangens
Inverse Hyperbolic Cosecant Function Funkcja area cosecans hiperboliczny
Inverse Hyperbolic Cosine Function Funkcja area cosinus hiperboliczny
Inverse Hyperbolic Cotangent Function Funkcja area cotangens hiperboliczny
Inverse Hyperbolic Secant Function Funkcja area secans hiperboliczny
Inverse Hyperbolic Sine Function Funkcja area sinus hiperboliczny
Inverse Hyperbolic Tangent Function Funkcja area tangens hiperboliczny
Inverse Secant Function Funkcja arcus secans
Inverse Sine Function Funkcja arcus sinus
Inverse Tangent Function Funkcja arcus tangens
Left Arrow with Text Above Strzałka w lewo z tekstem powyżej
Left Arrow with Text Below Strzałka w lewo z tekstem poniżej
Left Bracket Twice Podwójny lewy nawias kwadratowy
Left Right Arrow Above Strzałka w lewo i prawo powyżej
Left Right Arrow with Text Above Strzałka w lewo i prawo z tekstem powyżej
Left Right Arrow with Text Below Strzałka w lewo i prawo z tekstem poniżej
Left Subscript-Superscript Lewy indeks dolny i górny
Leftwards Arrow Above Strzałka w lewo powyżej
Leftwards Harpoon Above Harpun w lewo powyżej
Limit Granica
Limit Example Przykład granicy
Linear Fraction Ułamek liniowy
Logarithm Logarytm
Logarithm with Base Logarytm o podanej podstawie
Logical AND Koniunkcja logiczna
Logical AND with Limits Koniunkcja logiczna z granicami
Logical OR Alternatywa logiczna
Logical OR with Limits Alternatywa logiczna z granicami
Maximum Maksimum
Maximum Example Przykład maksimum
Measured By Mierzone przez
Midline Dots Kropki środkowe
Minimum Minimum
Minus Equal Minus i znak równości
Natural Logarithm Logarytm naturalny
One half Jedna druga
Outward Brackets Nawiasy kwadratowe skierowane na zewnątrz
Overbar Kreska górna
Overbrace Klamra górna
Parentheses Nawiasy okrągłe
Parentheses with Separator Nawiasy okrągłe z separatorem
Partial Differential Różniczka cząstkowa
Pi Over 2 Pi przez 2
Plus Equal Plus i znak równości
Product Iloczyn
Product of A Iloczyn A
Product with Limits Iloczyn z granicami
Product with Lower Limit Iloczyn z dolną granicą
Product with Subscript-Superscript Limits Iloczyn z granicami w indeksach
Quadratic Root Pierwiastek równania kwadratowego
Radical with Degree Pierwiastek ze stopniem
Right Arrow with Text Above Strzałka w prawo z tekstem powyżej
Right Arrow with Text Below Strzałka w prawo z tekstem poniżej
Right Bracket Twice Podwójny prawy nawias kwadratowy
Rightwards Arrow Above Strzałka w prawo powyżej
Rightwards Harpoon Above Harpun w prawo powyżej
Secant Function Funkcja secans
Sine Function Funkcja sinus
Sine theta Sinus theta
Single Left Angle Pojedynczy lewy nawias kątowy
Single Left Bar Pojedyncza lewa kreska pionowa
Single Left Brace Pojedynczy lewy nawias klamrowy
Single Left Bracket Pojedynczy lewy nawias kwadratowy
Single Left Parenthesis Pojedynczy lewy nawias okrągły
Single Right Brace Pojedynczy prawy nawias klamrowy
Single Right Bracket Pojedynczy prawy nawias kwadratowy
Single Right Parenthesis Pojedynczy prawy nawias okrągły
Skewed Fraction Ułamek ukośny
Small change Mały przyrost
Square Root Pierwiastek kwadratowy
Square Root with Degree Pierwiastek dowolnego stopnia
Square Union Kwadratowa suma zbiorów
Stack Object Obiekt piętrowy
Stack Object (Three) Obiekt piętrowy (trzy poziomy)
Stacked Fraction Ułamek piętrowy
Stacked, no bar Układ piętrowy bez kreski
Subscript-Superscript Indeks dolny i górny
Summation Suma
Summation from 0 to n Suma od 0 do n
Summation over binomials Suma po dwumianach
Summation with Limits Suma z granicami
Summation with Lower Limit Suma z dolną granicą
Summation with Subscript Limit Suma z granicą w indeksie dolnym
Summation with Subscript-Superscript Limits Suma z granicami w indeksach
Surface Integral Całka powierzchniowa
Surface Integral with Limits Całka powierzchniowa z granicami
Surface Integral with Stacked Limits Całka powierzchniowa z granicami u góry i u dołu
Tangent Function Funkcja tangens
Tangent formula Wzór na tangens
Text over Colon Equal Tekst nad znakiem przypisania
Text over Equal Tekst nad znakiem równości
Tilde Tylda
Triple Dot Trzy kropki nad znakiem
Triple Integral Całka potrójna
Triple Integral with Limits Całka potrójna z granicami
Triple Integral with Stacked Limits Całka potrójna z granicami u góry i u dołu
Underbar Kreska dolna
Underbrace Klamra dolna
Union Suma zbiorów
Union of intersections Suma iloczynów zbiorów
Union with Limits Suma zbiorów z granicami
Union with Lower Limit Suma zbiorów z dolną granicą
Union with Plus Suma zbiorów z plusem
Union with Subscript-Superscript Limits Suma zbiorów z granicami w indeksach
Up Diagonal Dots Kropki ukośne w górę
Vector A Wektor A
Vector AB Wektor AB
Vertical Bars Kreski pionowe
Vertical Dots Kropki pionowe
Volume Integral Całka objętościowa
Volume Integral with Limits Całka objętościowa z granicami
Volume Integral with Stacked Limits Całka objętościowa z granicami u góry i u dołu
Yields Daje w wyniku
x bar x z kreską
All - Linear Wszystkie — liniowe
All - Professional Wszystkie — profesjonalne
Current - Linear Bieżące — liniowe
Current - Professional Bieżące — profesjonalne
Centered as Group Wyśrodkowane jako grupa
✓ None ✓ Brak
Change to Level 6 Zmień na poziom 6
Change to Level 7 Zmień na poziom 7
Change to Level 8 Zmień na poziom 8
Change to Level 9 Zmień na poziom 9
Level 4 Poziom 4
Level 5 Poziom 5
Level 6 Poziom 6
Level 7 Poziom 7
Level 8 Poziom 8
Level 9 Poziom 9
Typing Pisanie
Search Wyszukaj
Title Bar Pasek tytułu

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -922,10 +922,11 @@ fn polish_covers_every_key_of_every_other_catalog() {
use std::collections::HashSet;
let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::<HashSet<_>>();
let polish = keys(lang("pl").0.source);
// Russian, Italian, Hungarian, Turkish, Bulgarian and Spanish also translate the built-in
// style names and the equation gallery, which the others don't yet; Polish matches every
// other catalog.
for other in Lang::all().filter(|l| *l != Lang::EN && !matches!(l.code(), "pl" | "ru" | "it" | "hu" | "tr" | "bg" | "es")) {
// Italian and Hungarian still carry "Style: APA", which the interface no longer shows;
// Hungarian and Bulgarian also name the citation styles, and Bulgarian has a few one-line
// pieces of gallery labels. Polish matches every other catalog, including the style names and
// equation gallery Russian translates.
for other in Lang::all().filter(|l| *l != Lang::EN && !matches!(l.code(), "pl" | "it" | "hu" | "bg")) {
let mut missing: Vec<String> = keys(other.0.source).difference(&polish).cloned().collect();
missing.sort();
assert!(missing.is_empty(), "pl lacks {}'s {missing:?}", other.code());

View File

@ -63,6 +63,7 @@ pub mod panes;
pub mod paste_picture;
pub mod previews;
pub mod print_page;
pub mod printing;
pub mod read_aloud;
pub mod ribbon;
pub mod scroll;
@ -87,6 +88,10 @@ pub use file_dialogs::{FileDialogAnswer, FileDialogRequest, open_filters};
/// Platform services injected by the host (file dialogs, file I/O).
#[derive(Default)]
pub struct Services {
/// Desktop: discover configured printers on a worker.
pub printers: Option<printing::Discover>,
/// Desktop: submit the PDF with the chosen settings on a worker.
pub print_native: Option<printing::Submit>,
/// Pick a file to open; the argument says what for (`document`, `picture`).
pub pick_open: Option<Box<dyn Fn(&str) -> Option<String>>>,
/// Pick a path to save to, given a suggested name.
@ -107,9 +112,8 @@ pub struct Services {
/// Web: told whether the document has unsaved changes after each pass, for the browser's
/// leave-page guard (`beforeunload` runs between frames and can't ask the app).
pub on_dirty: Option<Box<dyn Fn(bool)>>,
/// Hand PDF bytes to the system's print flow. Web: the browser's print dialog on that PDF (no
/// download, no intermediate file). Desktop: the PDF opens in the system's viewer (a temporary
/// file), where the user prints (#15). An error means nothing was opened.
/// Web: hand PDF bytes to the browser's print dialog (no download or intermediate file).
/// Desktop uses `print_native` instead. An error means nothing was opened.
pub print: Option<Box<dyn Fn(&[u8]) -> Result<(), String>>>,
/// Desktop: the picture on the system clipboard, if any — egui's paste only carries text
/// (#45). Read when Paste finds no text; see [`paste_picture`].
@ -341,6 +345,8 @@ pub struct WordApp {
pub session: Session,
pub ui: UiState,
pub services: Services,
pub printing: printing::State,
print_feedback_seen: Option<printing::Feedback>,
pub canvas: canvas::CanvasState,
/// Content control frames, check boxes and lists on the page.
pub controls: content_controls::ControlUi,
@ -460,6 +466,8 @@ impl WordApp {
session,
ui: UiState::default(),
services,
printing: Default::default(),
print_feedback_seen: None,
canvas: canvas::CanvasState::default(),
controls: Default::default(),
dialog: None,
@ -828,6 +836,10 @@ impl WordApp {
/// The window's close button (or the system) asked to quit. Returns true to let the window
/// close; with unsaved changes it asks first and returns false.
pub fn close_requested(&mut self) -> bool {
if self.printing.busy() {
self.status(tl!("Wait for the print job to finish submitting before closing."));
return false;
}
if self.quit_requested || !self.session.dirty {
return true;
}
@ -847,7 +859,8 @@ impl WordApp {
if id == "view.readMode" {
self.canvas.read_first_visible = None;
}
if !id.starts_with("view.") && !id.starts_with("document.") && !id.starts_with("format.state") {
// Drawing leaves the caret where it was: following it would pull the view off the ink.
if !id.starts_with("view.") && !id.starts_with("document.") && !id.starts_with("format.state") && !id.starts_with("draw.") {
self.canvas.scroll_to_caret = true;
}
for req in std::mem::take(&mut self.session.ui_requests) {
@ -1088,10 +1101,9 @@ impl WordApp {
self.pick_recipient_list();
json!({"pending": self.recipient_list_pending})
}
// Send the document to the system print flow (web, desktop). `file.print` opens the Print
// page; this is the button on it, and the one programmatic call that opens system UI
// here, like `ui.openFileDialog` — only when the host can print.
"ui.print" => return Some(self.print_to_system(p)),
// Native printing submits the selected queue on a worker; the browser owns its dialog.
"ui.printSettings" => return Some(printing::settings(&mut self.printing, p, self.services.printers.as_ref())),
"ui.print" | "ui.printSubmit" => return Some(self.print_to_system(p)),
// File › Options › General › Make WordCraft the default… changes the user's system
// default apps (Linux) or opens system settings (Windows), so only a click on that
// button does it ([`WordApp::make_default_app`]); scripts, the control channel and
@ -1139,38 +1151,9 @@ impl WordApp {
}))
}
/// `ui.print`: export the document to PDF in memory and hand it to the host's print hook.
/// `p` holds the print settings (`range`, `pages`, `parity`, `copies`, `collate`; see
/// `file.printPdf`); without any, every page prints once.
/// An error when the host can't print (File › Print then only saves a PDF).
/// Submit the selected print job; the implementation lives beside its localized controls.
fn print_to_system(&mut self, p: &Value) -> Result<Value, String> {
if self.services.print.is_none() {
return Err("printing to the system print dialog isn't available here; export a PDF instead".into());
}
// File › Options › Display: fields first, and the page colour only when asked for.
if self.ui.update_fields_before_print
&& let Err(e) = self.session.run("references.updateFields", &json!({}))
{
log::warn!("updating fields before printing: {e}");
}
let colour = if self.ui.print_backgrounds { None } else { self.session.doc.settings.page_color.take() };
let pdf = self.session.print_pdf(p).map_err(|e| e.to_string());
if colour.is_some() {
self.session.doc.settings.page_color = colour;
}
let result = pdf.and_then(|(bytes, _)| self.services.print.as_ref().map_or(Ok(()), |print| print(&bytes)).map(|()| bytes.len()));
match result {
Ok(len) => {
self.status(tl!("Opening print dialog…"));
Ok(json!({"printing": true, "bytes": len}))
}
Err(e) => {
log::error!("print failed: {e}");
let msg = i18n::fmt(tl!("Print failed: {error}"), &[("error", &e)]);
self.status(msg.clone());
Err(msg)
}
}
printing::submit(self, p)
}
/// File › Options › General › Make WordCraft the default…: hand over to the host's
@ -1307,6 +1290,22 @@ impl WordApp {
}
}
/// Terminal feedback also reaches the normal window when printing starts through control.
pub fn poll_printing(&mut self, ctx: &egui::Context) {
self.printing.poll(ctx);
if self.printing.message == self.print_feedback_seen {
return;
}
self.print_feedback_seen = self.printing.message.clone();
let Some(message @ (printing::Feedback::Submitted(_) | printing::Feedback::Failed(_) | printing::Feedback::WorkerStopped)) =
self.printing.message.clone()
else {
return;
};
let text = printing::feedback(&message, |label| i18n::t(label).to_owned());
self.status(text);
}
/// Per-frame logic before layout: control requests, screenshots, shortcuts, file drops.
pub fn logic(&mut self, ctx: &egui::Context) {
let lang = i18n::Lang::from_pref(&self.ui.language);
@ -1361,6 +1360,7 @@ impl WordApp {
if self.ctx.is_none() {
self.ctx = Some(ctx.clone());
}
self.poll_printing(ctx);
self.drain_control(ctx);
zotero::poll(self, ctx);
read_aloud::poll(self, ctx);
@ -1382,7 +1382,7 @@ impl WordApp {
{
let path = f.path().to_string_lossy().to_string();
let lp = path.to_ascii_lowercase();
let img = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"].iter().any(|e| lp.ends_with(e));
let img = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".wmf", ".emf"].iter().any(|e| lp.ends_with(e));
let sheet = [".xlsx", ".xlsm", ".ods"].iter().any(|e| lp.ends_with(e));
let _ = if img {
self.run("insert.picture", json!({"path": path}))
@ -1554,7 +1554,7 @@ impl WordApp {
let files = std::mem::take(&mut *inbox.lock().unwrap_or_else(|e| e.into_inner()));
for (name, bytes) in files {
let lower = name.to_ascii_lowercase();
let img = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"].iter().any(|e| lower.ends_with(e));
let img = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".wmf", ".emf"].iter().any(|e| lower.ends_with(e));
// Any recipient file ends the pending picker request, so a later .txt drop opens normally.
let asked = std::mem::take(&mut self.recipient_list_pending);
let list = lower.ends_with(".csv") || lower.ends_with(".tsv") || (lower.ends_with(".txt") && asked);
@ -4176,6 +4176,27 @@ mod tests {
assert!(!a.recipient_list_pending);
}
/// Issue #859: lifting the pen jumped the view back to the caret, usually on page 1. Drawing
/// leaves the caret where it was, so the view stays on the ink; typing still follows the caret.
#[test]
fn drawing_ink_keeps_the_view_where_it_is() {
let mut a = typed();
let keeps_view = |a: &mut WordApp, id: &str, params: Value| {
a.canvas.scroll_to_caret = false;
let r = a.run(id, params);
assert!(r.is_ok(), "{id}: {r:?}");
assert!(!a.canvas.scroll_to_caret, "{id} scrolled to the caret");
};
keeps_view(&mut a, "draw.stroke", json!({"page": 0, "points": [[100, 300], [200, 320]], "tool": "pen"}));
keeps_view(&mut a, "draw.lasso", json!({"page": 0, "points": [[80, 280], [220, 280], [220, 340], [80, 340]]}));
keeps_view(&mut a, "draw.lassoMove", json!({"dx": 10, "dy": 10}));
keeps_view(&mut a, "draw.stroke", json!({"page": 0, "points": [[300, 500], [400, 500]], "tool": "pencil"}));
keeps_view(&mut a, "draw.erase", json!({"page": 0, "x": 350, "y": 500}));
a.canvas.scroll_to_caret = false;
a.run("text.insert", json!({"text": "x"})).unwrap();
assert!(a.canvas.scroll_to_caret, "typing still brings the caret into view");
}
/// `ui.render` and `ui.screenshot` without a `path` return the 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.

View File

@ -33,7 +33,7 @@ pub const MAX_PIXELS: usize = 50_000_000;
pub const MAX_FILE_BYTES: u64 = 200 << 20;
/// Most picture files one paste inserts.
const MAX_FILES: usize = 20;
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"];
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"];
/// PNG bytes for clipboard pixels, or why they can't be pasted.
pub fn png_from_rgba(width: usize, height: usize, rgba: Vec<u8>) -> Result<Vec<u8>, String> {

View File

@ -121,14 +121,7 @@ impl Previews {
return None;
}
let ppp = ui.ctx().pixels_per_point();
let mut img = snippet(
&Document::new(),
Paragraph::with_text(name, CharProps { font: Some(name.to_string()), size: Some(13.0), ..Default::default() }),
240.0,
20.0,
ppp,
2.0,
)?;
let mut img = font_entry_image(name, ppp);
ink_mask(&mut img);
let h = ui.ctx().load_texture(&key, img, egui::TextureOptions::LINEAR);
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(&key), h.clone()));
@ -257,6 +250,20 @@ pub fn ink_mask(img: &mut egui::ColorImage) {
}
}
/// A font list entry: `name` in its own face, 240×20 pt. The face is read for the preview alone
/// and dropped (#848): browsing the list used to load every family shown for good, so each trip
/// through the Font box grew memory by tens of megabytes. A name the font has no glyphs for (a
/// symbol font's), or a font that can't be read, shows in the default face.
pub fn font_entry_image(name: &str, ppp: f32) -> egui::ColorImage {
let db = wordcraft_fonts::FontDb::global();
let face = db
.preview_face(name)
.filter(|f| name.chars().filter(|c| !c.is_whitespace()).all(|c| f.covers(c)))
.unwrap_or_else(|| db.face(wordcraft_fonts::FALLBACK_FAMILY, "Regular"));
let img = wordcraft_render::render_text_line(&face, name, 13.0, 244.0, 22.0, 2.0, ppp);
egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_straight())
}
/// Lay out `para` (with `base`'s styles) in a `w`×`h` pt box and rasterise it.
pub fn snippet(base: &Document, para: Paragraph, w: f32, h: f32, ppp: f32, margin: f32) -> Option<egui::ColorImage> {
snippet_blocks(base, vec![Block::Para(para)], w, h, ppp, margin, None, false)
@ -513,6 +520,30 @@ pub fn table_style_tile(ui: &mut Ui, app: &mut WordApp, style: &str) -> Response
mod tests {
use super::*;
/// #848: each trip through the Font box loaded every family it showed, for good. Entries are
/// now drawn with a face read for the preview alone: installed families stay unloaded.
#[test]
fn font_menu_entries_leave_installed_families_unloaded() {
let db = wordcraft_fonts::FontDb::global();
let ink = |img: &egui::ColorImage| img.pixels.iter().filter(|p| p.r() < 128).count();
assert!(ink(&font_entry_image("Source Sans 3", 1.0)) > 20, "the bundled face draws");
assert!(ink(&font_entry_image("No Such Font 848", 1.0)) > 20, "an unknown font shows in the default face");
// Tests running alongside share this database and may load a fallback (a math font for
// an equation, a font for a script) at any time: leave the math fonts out, and allow a
// stray load or two. Before #848 every family previewed was loaded.
let shared = |f: &str| wordcraft_fonts::math::MATH_SUBSTITUTES.iter().any(|n| f.eq_ignore_ascii_case(n));
let unloaded: Vec<String> = db.families().into_iter().filter(|f| !db.is_loaded(f) && !shared(f)).take(25).collect();
let mut loaded = Vec::new();
for f in &unloaded {
let img = font_entry_image(f, 1.0);
assert_eq!(img.size, [244, 22]);
if db.is_loaded(f) {
loaded.push(f.clone());
}
}
assert!(loaded.len() <= 2, "previewing loaded {loaded:?}");
}
/// #663: gallery previews are dimmed in the dark theme, unless they show Switch Modes' dark
/// page; the light theme keeps a white page.
#[test]

View File

@ -1,9 +1,7 @@
//! File › Print (#641): the print settings and a preview of the pages.
//!
//! WordCraft prints through a PDF that the host opens in the system's print flow, so the settings
//! here are the ones that shape that PDF: which pages, odd/even, copies and collation. They go to
//! `ui.print` / `file.printPdf` as params (see `wordcraft_engine::print`). The printer, two-sided
//! printing and pages per sheet are chosen in the system print dialog that opens next.
//! Page range and parity shape the PDF. Native printer settings and copy collation go to
//! the shared spooler; browser printing and saved print PDFs retain copies in the PDF.
use std::sync::Arc;
@ -109,8 +107,14 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
ui.vertical(|ui| {
ui.set_width(380.0);
ui.spacing_mut().item_spacing.y = 8.0;
let hook = app.services.print.is_some();
if hook {
let native = app.services.print_native.is_some();
let hook = native || app.services.print.is_some();
if native {
if let Some(discover) = &app.services.printers {
crate::printing::show(&mut app.printing, ui, discover, |label| crate::i18n::t(label).to_owned());
}
app.ui.print.copies = app.printing.settings.copies;
} else if hook {
ui.label(tl!("Send the document straight to the system print dialog, or save a copy in another format below."));
ui.label(
egui::RichText::new(tl!("The printer, two-sided printing and pages per sheet are chosen in that dialog.")).color(t.text_dim),
@ -119,7 +123,14 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
ui.label(tl!("Save a copy in another format. Printing goes through a PDF you can print from any viewer."));
}
ui.add_space(4.0);
if hook && ui.add(egui::Button::new(egui::RichText::new(tl!("Print")).font(medium(13.5))).min_size(vec2(320.0, 34.0))).clicked() {
if hook
&& ui
.add_enabled(
!native || app.printing.ready(),
egui::Button::new(egui::RichText::new(tl!("Print")).font(medium(13.5))).min_size(vec2(320.0, 34.0)),
)
.clicked()
{
let params = app.ui.print.params();
let _ = app.run("ui.print", params);
}
@ -131,15 +142,21 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
}
ui.add_space(6.0);
ui.label(egui::RichText::new(tl!("Copies")).font(medium(13.0)));
ui.horizontal(|ui| {
let mut n = app.ui.print.copies;
ui.add(egui::DragValue::new(&mut n).range(1..=wordcraft_engine::print::MAX_COPIES).speed(0.2));
app.ui.print.copies = n.clamp(1, wordcraft_engine::print::MAX_COPIES);
if native {
if app.ui.print.copies > 1 {
ui.checkbox(&mut app.ui.print.collate, tl!("Collate copies"));
ui.add_enabled(!app.printing.busy(), egui::Checkbox::new(&mut app.ui.print.collate, tl!("Collate copies")));
}
});
} else {
ui.label(egui::RichText::new(tl!("Copies")).font(medium(13.0)));
ui.horizontal(|ui| {
let mut n = app.ui.print.copies;
ui.add(egui::DragValue::new(&mut n).range(1..=wordcraft_engine::print::MAX_COPIES).speed(0.2));
app.ui.print.copies = n.clamp(1, wordcraft_engine::print::MAX_COPIES);
if app.ui.print.copies > 1 {
ui.checkbox(&mut app.ui.print.collate, tl!("Collate copies"));
}
});
}
ui.label(egui::RichText::new(tl!("Pages")).font(medium(13.0)));
if choice(ui, "print_range", &mut app.ui.print.range, &RANGES) {

View File

@ -0,0 +1,174 @@
//! Localized print-page wording around the shared, backend-independent controls.
use craft_ui::printing::Labels;
pub use craft_ui::printing::{Discover, Duplex, Feedback, Printer, Settings, State};
/// Native submission adds WordCraft's PDF-page collation setting to the shared spooler settings.
pub type Submit = Box<dyn Fn(Vec<u8>, Settings, bool) -> std::sync::mpsc::Receiver<Result<String, String>>>;
/// Metadata for the app-owned commands. Inspecting it never discovers printers or submits.
pub fn commands(app: &crate::WordApp) -> Vec<serde_json::Value> {
use serde_json::json;
let enabled = if app.services.print_native.is_some() { app.printing.ready() } else { app.services.print.is_some() };
let submit_params = "range?: all|current|selection|custom; pages?: string (1-3,5); parity?: all|odd|even; copies?: integer 1..999; collate?: bool. Native uses the selected printer, duplex and grayscale from ui.printSettings; omitted copies/collation use current settings. Browser printing opens its dialog. Submits only when explicitly executed.";
vec![
json!({"id":"ui.printSettings", "label":"Print settings", "location":"File › Print", "shortcut":null,
"params":"printer?: discovered name; copies?: integer 1..999; duplex?: printerDefault|off|longEdge|shortEdge|null; grayscale?: bool; refresh?: bool. Empty params inspect printer/copies/duplex/grayscale, printers, busy/discovering/ready and status/detail. Updates are atomic; refresh discovers only on native hosts. Never submits or opens a dialog.",
"enabled":true}),
json!({"id":"ui.printSubmit", "label":"Submit print job", "location":"File › Print", "shortcut":null, "params":submit_params, "enabled":enabled}),
json!({"id":"ui.print", "label":"Print (alias of ui.printSubmit)", "location":"File › Print", "shortcut":null, "params":submit_params, "enabled":enabled}),
]
}
pub fn show(state: &mut State, ui: &mut egui::Ui, discover: &Discover, translate: impl Fn(&str) -> String) {
let labels = Labels {
printer: &translate("Printer"),
select_printer: &translate("Select a printer"),
refresh: &translate("Refresh printers"),
copies: &translate("Copies"),
duplex: &translate("Two-sided printing"),
printer_default: &translate("Printer default"),
one_sided: &translate("One-sided"),
long_edge: &translate("Two-sided: long edge"),
short_edge: &translate("Two-sided: short edge"),
grayscale: &translate("Grayscale"),
};
state.show(ui, egui::Id::new("wordcraft_print_settings"), discover, &labels);
if let Some(message) = &state.message {
ui.label(feedback(message, translate));
}
}
/// Read print settings/status, or validate an explicit update before changing anything.
/// `refresh: true` starts discovery only; this command never submits a job or opens a dialog.
pub fn settings(state: &mut State, params: &serde_json::Value, discover: Option<&Discover>) -> Result<serde_json::Value, String> {
use serde_json::{Value, json};
let object = params.as_object().ok_or("print settings must be an object")?;
if object.keys().any(|key| !["printer", "copies", "duplex", "grayscale", "refresh"].contains(&key.as_str())) {
return Err("unknown print setting; use printer, copies, duplex, grayscale or refresh".into());
}
if state.busy() && !object.is_empty() {
return Err("wait for the active print submission before changing settings".into());
}
let mut next = state.settings.clone();
if let Some(value) = object.get("printer") {
let printer = value.as_str().ok_or("printer must be a name from the discovered list")?;
if !state.printers.iter().any(|available| available.name == printer) {
return Err("printer must be a name from the discovered list".into());
}
next.printer = printer.to_owned();
}
if let Some(value) = object.get("copies") {
let copies = value.as_u64().filter(|copies| (1..=999).contains(copies)).ok_or("copies must be an integer from 1 to 999")?;
next.copies = copies as u32;
}
if let Some(value) = object.get("duplex") {
next.duplex = match value.as_str() {
Some("printerDefault") => None,
Some("off") => Some(Duplex::Off),
Some("longEdge") => Some(Duplex::LongEdge),
Some("shortEdge") => Some(Duplex::ShortEdge),
None if value.is_null() => None,
_ => return Err("duplex must be printerDefault, off, longEdge or shortEdge".into()),
};
}
if let Some(value) = object.get("grayscale") {
next.grayscale = value.as_bool().ok_or("grayscale must be a boolean")?;
}
let refresh = object.get("refresh").map(|value| value.as_bool().ok_or("refresh must be a boolean")).transpose()?.unwrap_or(false);
if refresh {
let discover = discover.ok_or("native printer discovery is unavailable here")?;
if !state.refresh(discover) {
return Err("printer discovery or submission is already active".into());
}
}
state.settings = next;
let (status, detail) = match state.message.as_ref() {
None => ("idle", ""),
Some(Feedback::FindingPrinters) => ("findingPrinters", ""),
Some(Feedback::NoPrinters) => ("noPrinters", ""),
Some(Feedback::DiscoveryFailed) => ("discoveryFailed", ""),
Some(Feedback::Submitting) => ("submitting", ""),
Some(Feedback::Submitted(receipt)) => ("submitted", receipt.as_str()),
Some(Feedback::Failed(error)) => ("failed", error.as_str()),
Some(Feedback::WorkerStopped) => ("workerStopped", ""),
};
Ok(json!({
"printer": state.settings.printer, "copies": state.settings.copies,
"duplex": match state.settings.duplex { None => "printerDefault", Some(Duplex::Off) => "off", Some(Duplex::LongEdge) => "longEdge", Some(Duplex::ShortEdge) => "shortEdge" },
"grayscale": state.settings.grayscale,
"printers": state.printers.iter().map(|printer| json!({"name": printer.name, "default": printer.default})).collect::<Vec<Value>>(),
"busy": state.busy(), "discovering": state.discovering(), "ready": state.ready(), "status": status, "detail": detail,
}))
}
pub fn feedback(message: &Feedback, translate: impl Fn(&str) -> String) -> String {
match message {
Feedback::FindingPrinters => translate("Finding printers…"),
Feedback::NoPrinters => translate("No printers found. Configure a system printer or export a PDF."),
Feedback::DiscoveryFailed => translate("Printer discovery failed."),
Feedback::Submitting => translate("Submitting print job…"),
Feedback::Submitted(receipt) if receipt.trim().is_empty() => translate("Print job submitted."),
Feedback::Submitted(receipt) => translate("Print job submitted: {receipt}").replace("{receipt}", receipt),
Feedback::Failed(error) => translate("Print failed: {error}").replace("{error}", error),
Feedback::WorkerStopped => translate("Print failed: the printing worker stopped."),
}
}
/// Build the upstream page-range/markup PDF and hand it to the chosen host print flow.
pub fn submit(app: &mut crate::WordApp, params: &serde_json::Value) -> Result<serde_json::Value, String> {
use serde_json::json;
if app.services.print.is_none() && app.services.print_native.is_none() {
return Err("printing to the system print dialog isn't available here; export a PDF instead".into());
}
let native = app.services.print_native.is_some();
if native && !app.printing.ready() {
return Err("Select an available printer in File > Print before submitting.".into());
}
let mut pdf_params = params.clone();
let mut settings = app.printing.settings.clone();
let collate = params.get("collate").map(|value| value.as_bool().ok_or("collate must be a boolean")).transpose()?.unwrap_or(app.ui.print.collate);
if native {
let object = pdf_params.as_object_mut().ok_or("print settings must be an object")?;
if let Some(copies) = object.get("copies") {
settings.copies = copies.as_u64().filter(|copies| (1..=999).contains(copies)).ok_or("copies must be an integer from 1 to 999")? as u32;
}
// Copies/collation belong to the spooler. Baking them into the PDF would multiply
// the copies twice, and duplex could put the end of one copy behind the next.
object.insert("copies".into(), json!(1));
}
if app.ui.update_fields_before_print
&& let Err(error) = app.session.run("references.updateFields", &json!({}))
{
log::warn!("updating fields before printing: {error}");
}
let colour = if app.ui.print_backgrounds { None } else { app.session.doc.settings.page_color.take() };
let pdf = app.session.print_pdf(&pdf_params).map_err(|error| error.to_string());
if colour.is_some() {
app.session.doc.settings.page_color = colour;
}
let result = pdf.and_then(|(bytes, plan)| {
if native && plan.chosen.checked_mul(settings.copies as usize).is_none_or(|pages| pages > wordcraft_engine::print::MAX_PRINT_PAGES) {
return Err("the selected pages and copies exceed the print-job page limit".into());
}
let len = bytes.len();
if let Some(submit) = &app.services.print_native {
app.printing.begin(submit(bytes, settings, collate)).map_err(|_| "A print job is already being submitted.".to_owned())?;
app.print_feedback_seen = None;
} else if let Some(print) = &app.services.print {
print(&bytes)?;
}
Ok(len)
});
match result {
Ok(len) => {
app.status(if native { tl!("Submitting print job…") } else { tl!("Opening print dialog…") });
Ok(json!({"printing": true, "bytes": len, "pending": native}))
}
Err(error) => {
log::error!("print failed: {error}");
let msg = crate::i18n::fmt(tl!("Print failed: {error}"), &[("error", &error)]);
app.status(msg.clone());
Err(msg)
}
}
}

View File

@ -0,0 +1,203 @@
//! Native print onboarding preserves engine PDF generation and submits the selected settings.
use serde_json::json;
use std::{cell::RefCell, rc::Rc};
use wordcraft_engine::Session;
use wordcraft_ui_egui::printing::{Duplex, Feedback, Printer};
use wordcraft_ui_egui::{Services, WordApp};
#[test]
fn control_print_discovery_reports_metadata_without_host_side_effects() {
use std::cell::Cell;
use wordcraft_ui_egui::control::{ControlRequest, Outcome, handle};
let discoveries = Rc::new(Cell::new(0));
let submissions = Rc::new(Cell::new(0));
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
let observed = discoveries.clone();
app.services.printers = Some(Box::new(move || {
observed.set(observed.get() + 1);
let (_, receiver) = std::sync::mpsc::channel();
receiver
}));
let observed = submissions.clone();
app.services.print_native = Some(Box::new(move |_, _, _| {
observed.set(observed.get() + 1);
let (_, receiver) = std::sync::mpsc::channel();
receiver
}));
let ctx = egui::Context::default();
let (request, _) = ControlRequest::new("engine.commands", json!({"search":"print"}));
let Outcome::Done(reply) = handle(&mut app, &ctx, &request) else { panic!("metadata replies immediately") };
assert_eq!(reply["ok"], true);
let commands = reply["result"].as_array().unwrap();
assert!(commands.iter().any(|command| command["id"] == "file.print"), "engine print commands remain visible");
let settings = commands.iter().find(|command| command["id"] == "ui.printSettings").unwrap();
assert_eq!(settings["enabled"], true, "settings/status inspection is available");
for field in ["printer", "copies", "duplex", "grayscale", "refresh", "status", "detail"] {
assert!(settings["params"].as_str().unwrap().contains(field), "documented setting: {field}");
}
for id in ["ui.printSubmit", "ui.print"] {
let command = commands.iter().find(|command| command["id"] == id).unwrap();
assert_eq!(command["enabled"], false, "a printer must be selected before submission");
for field in ["range", "pages", "parity", "copies", "collate"] {
assert!(command["params"].as_str().unwrap().contains(field), "{id}: {field}");
}
}
app.printing.printers.push(Printer { name: "Queue".into(), default: true });
app.run("ui.printSettings", json!({"printer":"Queue"})).unwrap();
let Outcome::Done(reply) = handle(&mut app, &ctx, &request) else { panic!("metadata replies immediately") };
for id in ["ui.printSubmit", "ui.print"] {
let command = reply["result"].as_array().unwrap().iter().find(|command| command["id"] == id).unwrap();
assert_eq!(command["enabled"], true, "the selected queue enables {id}");
}
assert_eq!(discoveries.get(), 0, "metadata never starts printer discovery");
assert_eq!(submissions.get(), 0, "metadata never submits");
assert!(app.dialog.is_none() && !app.ui.backstage, "metadata never opens a dialog or Print page");
assert!(!app.printing.busy() && !app.session.dirty);
}
#[test]
fn native_print_uses_selected_settings_and_reports_spool_errors() {
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
app.run("text.insert", json!({"text": "A document to print"})).unwrap();
let jobs = Rc::new(RefCell::new(Vec::new()));
let observed = jobs.clone();
app.services.print_native = Some(Box::new(move |bytes, settings, collate| {
observed.borrow_mut().push((bytes, settings, collate));
let (sender, receiver) = std::sync::mpsc::channel();
sender.send(Err("printer is offline".into())).unwrap();
receiver
}));
assert!(app.run("ui.print", json!({})).is_err());
assert!(jobs.borrow().is_empty(), "opening settings/exporting does not spool");
app.printing.printers.push(Printer { name: "Chosen queue".into(), default: false });
let configured = app.run("ui.printSettings", json!({"printer":"Chosen queue", "copies":3,"duplex":"shortEdge","grayscale":true})).unwrap();
assert_eq!(configured["ready"], true);
assert_eq!(configured["printers"][0]["name"], "Chosen queue");
app.run("ui.print", json!({})).unwrap();
assert!(app.printing.busy());
assert!(!app.close_requested(), "a pending print job cannot be interrupted by normal close");
let jobs = jobs.borrow();
assert_eq!(jobs.len(), 1);
assert!(jobs[0].0.starts_with(b"%PDF"));
assert_eq!(jobs[0].1.printer, "Chosen queue");
assert_eq!(jobs[0].1.copies, 3);
assert_eq!(jobs[0].1.duplex, Some(Duplex::ShortEdge));
assert!(jobs[0].1.grayscale);
assert!(jobs[0].2, "copies are collated by default");
app.poll_printing(&egui::Context::default());
assert_eq!(app.printing.message, Some(Feedback::Failed("printer is offline".into())));
assert!(app.status_msg.as_ref().unwrap().0.contains("printer is offline"));
}
#[test]
fn print_settings_are_visible_in_a_narrow_window() {
use egui_kittest::kittest::{NodeT, Queryable};
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
app.run("text.insert", json!({"text": "A document to print"})).unwrap();
app.services.printers = Some(Box::new(|| {
let (sender, receiver) = std::sync::mpsc::channel();
sender.send(vec![Printer { name: "Test queue".into(), default: true }]).unwrap();
receiver
}));
app.services.print_native = Some(Box::new(|_, _, _| {
let (sender, receiver) = std::sync::mpsc::channel();
sender.send(Ok("queued".into())).unwrap();
receiver
}));
app.ui.backstage = true;
app.ui.backstage_page = "print".into();
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(800.0, 700.0)).build_ui_state(
|ui, app: &mut WordApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
harness.run_steps(5);
assert!(!harness.get_by_label("Refresh printers").accesskit_node().is_disabled());
assert!(!harness.get_by_label("Grayscale").accesskit_node().is_disabled());
harness.get_by_label("Grayscale").click();
harness.run_steps(2);
assert!(harness.state().printing.settings.grayscale);
assert!(harness.state().printing.ready());
}
#[test]
fn invalid_print_settings_are_rejected_without_partial_changes() {
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
app.printing.printers.push(Printer { name: "Queue".into(), default: true });
let before = app.run("ui.printSettings", json!({})).unwrap();
for params in [
json!({"printer":"Missing"}),
json!({"copies":0}),
json!({"copies":1000}),
json!({"copies":1.5}),
json!({"copies":-1}),
json!({"copies":"3"}),
json!({"duplex":"maybe"}),
json!({"grayscale":"yes"}),
json!({"refresh":"yes"}),
json!({"copies":3,"duplex":"invalid"}),
json!([]),
] {
assert!(app.run("ui.printSettings", params.clone()).is_err(), "{params}");
assert_eq!(app.run("ui.printSettings", json!({})).unwrap(), before, "no partial update: {params}");
}
}
#[test]
fn repeated_fast_jobs_each_surface_an_identical_receipt() {
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
app.printing.printers.push(Printer { name: "Queue".into(), default: true });
app.run("ui.printSettings", json!({"printer":"Queue"})).unwrap();
app.services.print_native = Some(Box::new(|_, _, _| {
let (sender, receiver) = std::sync::mpsc::channel();
sender.send(Ok("same receipt".into())).unwrap();
receiver
}));
for _ in 0..2 {
app.status_msg = None;
app.run("ui.printSubmit", json!({})).unwrap();
// Completion may be consumed by the shared widget before the app's logic pass.
app.printing.poll(&egui::Context::default());
app.poll_printing(&egui::Context::default());
assert!(app.status_msg.as_ref().unwrap().0.contains("same receipt"));
app.status_msg = None;
app.poll_printing(&egui::Context::default());
assert!(app.status_msg.is_none());
}
}
#[test]
fn native_ranges_and_parity_do_not_duplicate_spooler_copies() {
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
for page in 1..=3 {
app.run("text.insert", json!({"text":format!("Distinct page {page}")})).unwrap();
if page < 3 {
app.run("insert.pageBreak", json!({})).unwrap();
}
}
assert_eq!(app.session.export_layout().pages.len(), 3);
app.printing.printers.push(Printer { name: "Queue".into(), default: true });
app.run("ui.printSettings", json!({"printer":"Queue","copies":3})).unwrap();
let jobs = Rc::new(RefCell::new(Vec::new()));
let observed = jobs.clone();
app.services.print_native = Some(Box::new(move |bytes, settings, collate| {
observed.borrow_mut().push((bytes, settings, collate));
let (sender, receiver) = std::sync::mpsc::channel();
sender.send(Ok("queued".into())).unwrap();
receiver
}));
for bad in [json!({"pages":"8"}), json!({"parity":"bad"}), json!({"copies":0}), json!({"collate":"yes"})] {
assert!(app.run("ui.printSubmit", bad).is_err());
}
assert!(jobs.borrow().is_empty(), "invalid settings do not send any job");
let params = json!({"pages":"1-3","parity":"odd","collate":false});
let (expected, plan) = app.session.print_pdf(&params).unwrap();
assert_eq!(plan.pages, vec![0, 2]);
app.run("ui.printSubmit", params).unwrap();
let jobs = jobs.borrow();
assert_eq!(jobs[0].0, expected, "the PDF contains only the two selected pages, once each");
assert_eq!(jobs[0].1.copies, 3, "copies go to the spooler, not the PDF");
assert!(!jobs[0].2, "uncollated copies reach the spooler");
}

View File

@ -68,4 +68,31 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key.
Any other method name is treated as a command id: `{"method": "format.bold"}` works.
The `CharProps` and `ParaProps` that `format.set`, `para.set` and `styles.modify` take are listed
in [MCP server](mcp.md#character-and-paragraph-properties).
Positions are `{"story": "body" | {"part": n}, "path": [block, row, cell, block, …], "off": byteOffset}`.
### Native printing
`engine.execute` can call `ui.printSettings` with `{}` to inspect the printer list, selected
printer, copies, duplex, grayscale, discovery/submission status and backend receipt/error.
`{"refresh":true}` starts background discovery; poll the same command until `discovering` is
false. Set `printer` to a discovered name, integer `copies` from 1 to 999, `duplex` to
`printerDefault`, `off`, `longEdge` or `shortEdge`, and boolean `grayscale`. Invalid updates are
rejected without changing earlier settings. `ui.printSubmit` explicitly submits the current
print-ready PDF without opening a dialog; inspect `ui.printSettings` until `busy` becomes false
and check `status`/`detail`. The browser keeps its own print hook. PDF export remains separate.
`ui.printSubmit` / `ui.print` also accept the upstream PDF page settings (`range`, `pages`,
`parity`, `copies`, `collate`). Native printing renders the selected pages once and passes
copies/collation to the spooler; the browser and `file.printPdf` keep copies in the PDF.
Omitting native `copies` uses the configured `ui.printSettings` value. `collate` defaults to
File › Print's setting; invalid ranges submit no job.
`engine.commands {"search":"print"}` lists the engine print commands and the app-owned
`ui.printSettings`, `ui.printSubmit`, and legacy `ui.print` alias, with current enablement
and their parameter documentation. Search is case-insensitive across IDs, labels, locations
and parameter descriptions. Listing commands never discovers printers, submits a job or opens
a dialog. Settings inspection is always available; submission becomes enabled when the native
printer is ready, or when the browser host provides its print flow.

View File

@ -1,6 +1,6 @@
# File-format parity with Microsoft Word
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the
share of a typical real-world file's content and formatting that survives (read: shows correctly;
@ -25,9 +25,9 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` (56) | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Freeform shapes, z-order and Word 6/95 or encrypted files are dropped or refused. No writer |
| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133), links, outline, tagging. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. No PDF/A option, no "best for printing/online" choice |
| PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser |
| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. Thin: comments, notes, revisions, sections, RTL. Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687) |
| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Thin: comments, notes, revisions, sections, RTL |
| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810). No footnotes, comments, tracked changes, fields or RTL |
| `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). Word's encoding choice dialog and line-ending options missing; such files save only through Save As |
| `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`. Word's encoding choice dialog and line-ending options missing; such files save only through Save As |
| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `</head>` tolerated (#154). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687) |
| `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | |
| `.xml` Word XML Document (Flat OPC) | <1% | R/W | ✅ | ✅ | ~65% | — | `crates/docx/tests/word_xml.rs`, `roundtrip.rs` (`flat_opc_round_trip_equals_docx`) | The DOCX package in one XML file, read and written by the DOCX code (#548); File › Export › Word XML Document. `.xml` files are told apart by their root element |
@ -37,6 +37,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
| `.xlsx`/`.xlsm`/`.ods` as a mail-merge recipient list | — | R (data source) | ✅ | — | ~90% | 1–3 | `crates/formats/src/sheet.rs`, `cmd/mailings.rs` | Read-only, one sheet's cell values (#334): shared/inline/rich strings, numbers, booleans, cached formula results, dates and times as ISO 8601 (`yyyy-mm-dd`) from the cell's number format, sparse cells; ODS value types, repeated rows/columns, covered cells. Select Table picks the sheet. Capped (64 MiB per part, 100,000 rows, 1,000 columns, 2 M cells, 32,767 characters a cell). Not shown: number formats other than dates (currency, percent, thousands separators) in `.xlsx`; no `.xls`, Access or Outlook sources |
| `.md` Markdown | — | (no) | ✅ | ✅ | — | — | `crates/formats/src/tests.rs` | Beyond Word |
| `.tex` LaTeX | — | — | ✅ | ✅ | — | — | `crates/formats/src/tests.rs` | Beyond Word (#11), incl. math |
| Pictures for Insert › Picture, drag-and-drop and paste | — | R | ✅ | — | — | — | `insert_picture_takes_a_windows_metafile` (engine), `paste_picture.rs` | PNG, JPEG, GIF, WebP, BMP, and Windows Metafile `.wmf` / Enhanced Metafile `.emf` at their own frame size (#802), drawn by `crates/metafile`. Not yet: SVG, TIFF, ICO, HEIC |
| `.png` page images | — | (Save as picture, Mac) | — | ✅ | — | — | `io_ext.rs` | |
| `.json` document model | — | — | ✅ | ✅ | — | — | engine tests | For agents |
@ -164,6 +165,9 @@ raise readiness estimates. No Word-derived files are fixtures.
|---|---|---|
| 2026-10-12 | trivial | Paste keeps the formatting of HTML and RTF on the system clipboard; Paste Special lists them and pictures (#687, #833) |
| 2026-10-12 | trivial | ODT page size, orientation and margins read back from `styles.xml` on open, so they survive a save and reopen (#810) |
| 2026-10-12 | minor | RTF reads 8-bit text in the document's or the font's code page (`\ansicpg`, `\fcharset`, `\cpg`) instead of always Windows-1252; a `.doc`/`.docx`/`.rtf` whose content is another of these formats opens by signature. Found on a private corpus of real Russian documents: 38 of 44 `.doc` files were RTF, all now open with their Cyrillic text |
| 2026-10-12 | trivial | `w:sym` and symbol-font runs keep their stored characters on save; TXT export and copy write their Unicode equivalents (#776) |
| 2026-10-12 | trivial | Insert › Picture, drag-and-drop and paste take Windows Metafiles and Enhanced Metafiles (#802); new row for picture formats |
| 2026-10-11 | trivial | Independent complex-script size, bold and italic inherit correctly through defaults and styles; explicit equal values survive; six synthetic multi-generation round-trip tests, including tracked formatting, added |
| 2026-10-11 | minor | Opening files from the system: file associations and the default-app button (#295) |
| 2026-10-12 | trivial | DOCX: a table of contents' entry links (Hyperlink-styled runs in hyperlinks inside a TOC field) import without the Hyperlink style, as Word draws them; measured in Word for Windows |

View File

@ -1,6 +1,8 @@
# Where WordCraft falls short of Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (#811: scrolling a very large table no longer re-fingerprints every page each frame; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
The work list. One entry per shortfall, ranked by how much it stops a Word user from switching
(**B** = blocks beta). Each says what's missing, the evidence, who it hurts, an Opus 5.5 agent
@ -14,13 +16,11 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr
| # | Gap | B | Evidence | Impact | Hours | Doc |
|---|---|---|---|---|---|---|
| 1 | **DOCX never tested on real-world files.** No corpus of files from Word, Google Docs, LibreOffice and Pages has been opened, rendered and round-tripped and compared with Word page by page | B | No corpus in the repo; `crates/docx/tests` are synthetic fixtures (including omitted/empty paragraph defaults and imported TOC range preservation); "DOCX fidelity corpus: not started" since 2026-10-06. Field reports fixed one by one: a cover page's theme-coloured shapes (#611), freeforms inside groups and text-box margins and Resize to fit (#743) | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) |
| 2 | **Ink and drawing canvases are dropped on open and lost on save, silently**; OLE objects show only their picture | B | No ink or drawing-canvas (`wpc`) handling in `crates/docx`; group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Documents with diagrams drawn in Word or pen notes lose content without warning | 4–8 to preserve ink and drawing canvases; 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) |
| 1 | **DOCX never tested on real-world files.** No corpus of files from Word, Google Docs, LibreOffice and Pages has been opened, rendered and round-tripped and compared with Word page by page | B | No corpus in the repo; `crates/docx/tests` are synthetic fixtures (including omitted/empty paragraph defaults and imported TOC range preservation); "DOCX fidelity corpus: not started" since 2026-10-06 | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) |
| 2 | **Remaining native ink forms are dropped on open and lost on save, silently**; OLE objects show only their picture | B | Drawing-level native ink is preserved with its optional picture fallback; direct run-level content parts and ink nested in groups/canvases remain unsupported. Drawing canvases are read and written (#344); group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Documents with diagrams drawn in Word or pen notes lose content without warning | 4–8 to preserve remaining native ink forms; 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) |
| 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170) (lag on GPUs without a DirectX 12 driver fixed, #316; 32-bit Windows exited at launch because DirectX 12 graphics pipeline creation failed, now OpenGL), freezes (#77, #59 Thai fonts, #31 font selector), no writes without admin on Windows 11 (#218), broken shape tool (#142), images can't be moved (#82) | B | 62 open issues, ~15 of them crashes, hangs or data-path failures | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) |
| 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170) (lag on GPUs without a DirectX 12 driver fixed, #316; 32-bit Windows exited at launch because DirectX 12 graphics pipeline creation failed, now OpenGL), freezes (#77, #59 Thai fonts, #31 font selector), no writes without admin on Windows 11 (#218), broken shape tool (#142), images can't be moved (#82); memory growth on every font change fixed (#848: the font list no longer loads each family it previews) | B | 62 open issues, ~15 of them crashes, hangs or data-path failures | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) |
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options ignored; no page-by-page comparison harness | B | [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
| 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; still missing: drag-and-drop moves (no text drag yet) and move options in Track Changes Options. Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes and table/section changes made in WordCraft. Opaque custom XML backing stores now survive saves; binding evaluation remains absent. | B | Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) |
| 6 | **No native printing** on desktop: File › Print opens the PDF in the system viewer, which prints (#286); File › Print has a preview, page range, odd/even, copies and collate (#641); printer choice, duplex and pages per sheet are still the system's; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) |
| 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641); driver tray/options and pages per sheet remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) |
| 7 | **Dialog depth**: 54 modal dialogs vs Word's ~100 | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) |
| 8 | **Proofing covers English, Polish and Portuguese only**; no dictionaries, grammar or hyphenation for the other ~47 languages Word proofs (text in them is left unmarked) | | `crates/proof`: English, Polish and Portuguese (pt-BR via the installed VERO dictionary, 2026-10-12), a language per run (2026-10-10); Word ships 120 proofing tools; issues #25, #40, #100 | Every writer outside English, Polish and Portuguese | 20–40 | [localization-parity.md](localization-parity.md) |
| 9 | **Password-protected files** can't be opened or saved | | #55; `docbin` detects and refuses RC4/XOR | Corporate and legal users | 6–10 | [file-format-parity.md](file-format-parity.md) |
@ -79,6 +79,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
| 2026-10-11 | trivial | Tables: selection areas, insert buttons, move handle and double-click AutoFit on the page landed; ordinary drags over cells of several rows (a run of text, not a rectangle) added to the UI/UX gaps |
| 2026-10-12 | trivial | #1: Word 365 cover page (#743): freeforms in groups keep their outline; text boxes keep their own margins and Resize shape to fit text, so no line is cut |
| 2026-10-12 | trivial | #3: 32-bit Windows no longer exits at launch; DirectX 12 graphics pipelines fail to create, so that build starts on OpenGL |
| 2026-10-12 | trivial | Gap #3: browsing the Font list no longer keeps every previewed family in memory (#848); each applied family still loads once |
| 2026-10-12 | trivial | Custom dictionary persists across sessions (`custom-dictionary.txt` in the settings folder; #40) |
| 2026-10-11 | trivial | #8 narrowed: runs in a proofing language without a dictionary are skipped instead of flagged against the English dictionary (#100); dictionaries still English-only |
| 2026-10-11 | trivial | #16: DOCX open/save preserves independent complex-script formatting inheritance; RTL sections, tables and typography remain open |
@ -87,6 +88,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
| 2026-10-11 | trivial | UI/UX gaps list: the system's animation setting is now read on macOS and the Linux/BSD desktops; trying it on real machines remains |
| 2026-10-11 | trivial | Animation effects setting landed (File › Options › Accessibility); UI/UX gaps list: the system's animation setting isn't read on macOS or Linux/BSD yet |
| 2026-10-11 | trivial | Word XML Document (Flat OPC) and Word 2003 XML opening landed (#548); gap #26 narrowed to 15–28 h |
| 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 | #13 narrowed: Insert › SmartArt with eight layouts, Text Pane and SmartArt Design landed (#497); 25–40 h left for charts |
| 2026-10-11 | trivial | File › Options panes landed (#485); removed from the dialog-depth list |
| 2026-10-11 | trivial | #18: the document canvas describes its text, structure and caret to screen readers through AccessKit (#488); real screen-reader testing remains |

View File

@ -2,6 +2,8 @@
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** trivial (32-bit Windows starts on OpenGL; previously major: first version) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 and Word for Windows
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
Word is not a hardware-heavy app, but a few devices matter to its users: printers above all, then
pens, microphones (Dictate) and speakers (Read Aloud), and displays.
@ -10,7 +12,7 @@ pens, microphones (Dictate) and speakers (Read Aloud), and displays.
| Hardware | Word | WordCraft macOS | Windows | Linux / BSD | Web | Parity | Hours |
|---|---|---|---|---|---|---|---|
| Printers: native print dialog, printer choice, copies, duplex, page ranges, tray, scaling | ✅ (`WordPDE.plugin` print-dialog extension on Mac) | 🟡 PDF opens in Preview to print (#286) | 🟡 PDF opens in the default viewer (#286) | 🟡 PDF opens in the default viewer (#286) | ✅ browser print dialog (#209) | 35% | 5–8 |
| Printers: native print dialog, printer choice, copies, duplex, page ranges, tray, scaling | ✅ (`WordPDE.plugin` print-dialog extension on Mac) | 🟡 Direct CUPS printing; printer, copies, collation, duplex, ranges and grayscale | 🟡 Direct spooler printing; printer, copies, collation, duplex, ranges and grayscale | 🟡 Direct spooler printing; printer, copies, collation, duplex, ranges and grayscale | ✅ browser print dialog (#209) | 35% | 5–8 |
| GPU rendering, HiDPI/Retina, pixel-aligned text | ✅ | ✅ wgpu + vello_cpu rasteriser, whole-pixel text (#110, #165) | ✅ DirectX 12 default (#50), OpenGL when DirectX 12 has no GPU driver (#316) or on 32-bit; startup crash on Intel UHD (#170) | ✅ (soft text on KDE at some zooms, #140) | ✅ | 80% | 3–5 (bugs) |
| Multiple monitors, per-monitor DPI, remembered window position | ✅ | ✅ (#38, #76) | ✅ (winit DPI fix pending, #155) | ✅ | — | 85% | 1 |
| Pen / stylus: ink, pressure, eraser, ink to shape/math | ✅ (Draw tab, `InkRender.bundle`) | ❌ | ❌ | ❌ | ❌ | 0% | (Draw tab, 15–25) |
@ -24,6 +26,7 @@ pens, microphones (Dictate) and speakers (Read Aloud), and displays.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | trivial | 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 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. The upstream preview, page ranges, parity and Print Markup remain available. Remaining print parity includes driver-specific tray/options and pages-per-sheet UI; physical printer fidelity needs hardware validation. |
| 2026-10-10 | trivial | Windows falls back to OpenGL when DirectX 12 offers only the software adapter (#316) |
| 2026-10-10 | trivial | Desktop File › Print opens the PDF in the system viewer (#286) |
| 2026-10-10 | trivial | Inertial touchpad scrolling landed (#252) |

View File

@ -1,6 +1,6 @@
# Layout and pagination parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (section break over a selection keeps the text, #852; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
A document only "opens correctly" if its pages break where Word breaks them: the same lines, the
same page count, footnotes and pictures on the same pages. This checklist covers line breaking,
@ -24,6 +24,7 @@ font can't match Word's metrics.
| Kashida justification (Arabic) | ❌ | no kashida in `crates/layout` | 3–5 |
| Distributed / Thai distributed alignment | 🟡 | read; layout untested | 1–2 |
| Default font metrics (Aptos, Calibri, Cambria…) | 🟡 | Carlito/Caladea/Liberation are metric-compatible for Calibri/Cambria/Arial/Times/Courier; **Aptos (Word's default since 2024) has no metric-compatible open substitute**, so new Word documents wrap differently ([typing-parity.md](typing-parity.md) Known gaps). Needs an owner decision (commission or license a metric-matched font) | 6–10 + owner |
| Symbol fonts (Symbol, Wingdings, Wingdings 2/3, Webdings) without the proprietary font: each character, stored as U+F020–U+F0FF or a plain 8-bit code, is drawn as its Unicode equivalent from a face that has it; PDF text and copies read as those symbols | ✅ | `doc::symbol_fonts` tables (Adobe Symbol table and Unicode L2/12-368), `layout::tests::wingdings_text_draws_and_reads_as_its_unicode_symbols` (#776). Shapes come from Noto Sans Symbols / Symbols 2, DejaVu Sans or Segoe UI Symbol, so they differ from Word's; Wingdings 0xFF (not in Unicode) still has no symbol | — |
| Indents in characters (`w:firstLineChars`, `w:leftChars`…), measured in the paragraph mark's font size | ✅ | #534, `layout::tests::character_indents_use_the_paragraph_mark_size`; spacing in lines (`w:beforeLines`/`w:afterLines`) uses the stored points (needs the document grid) | — |
| Line spacing (single, 1.5, double, at least, exactly, multiple), spacing before/after, contextual spacing | 🟡 | Word puts multiple-spacing extra below the text and collapses spacing differently (#135, #108 open) | 3–5 |
| Kerning only where Kerning for fonts asks for it (`w:kern`, at or above its size) | ✅ | Measured in Word for Windows: no `w:kern` or `w:kern="0"` is unkerned; WordCraft kerned every run, so its lines held more text than Word's. `layout::tests::kerning_follows_kerning_for_fonts_like_word` | — |
@ -92,6 +93,7 @@ font can't match Word's metrics.
| 2026-10-12 | trivial | Kerning only where the document asks for it, and automatic (HTML-style) paragraph spacing, both measured in Word for Windows against real .gov documents |
| 2026-10-12 | trivial | A section break inserted over a selection keeps the selected text: the first section no longer comes out empty (#852) |
| 2026-10-12 | trivial | Large-table scrolling (#811): per-frame page fingerprinting fixed; measurement added |
| 2026-10-12 | trivial | Symbol-font text (Wingdings, Wingdings 2/3, Webdings, Symbol) drawn as Unicode symbols (#776) |
| 2026-10-11 | trivial | Chinese documents (#534): indents in characters, a page-anchored floating table after a full page moves to the next page, no blank page from a page break at the bottom of a full page |
| 2026-10-11 | trivial | East Asian list number formats (一、二、三, 第一章, ①); list labels whose characters come from two fonts draw each part with its own font (the digits of "1、" no longer show as other letters); a list level without indents leaves the label at the paragraph edge (#534) |
| 2026-10-11 | trivial | Vertical sections: `w:textDirection` in `w:sectPr`, upright East Asian glyphs (#491) |

View File

@ -20,8 +20,8 @@ native-speaker review (human).
## What WordCraft ships (measured)
- **19 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, `uk`, `es`, `pt-br`,
Serbian `sr`/`sr-latn` (#250), Estonian `et`, Turkish `tr`, Russian `ru`, Italian `it` (#594), German `de` (#574), Polish `pl`, Czech `cs`, Hungarian `hu`, French `fr` and Bulgarian `bg`. Catalogs have 2,021–2,419 entries
each; Polish has every key of every other catalog except the style names and equation gallery labels only Russian, Italian, Hungarian, Turkish and Bulgarian translate so far (a test checks it). A test (`every_tab_and_command_is_translated`) enforces that every ribbon tab, command
Serbian `sr`/`sr-latn` (#250), Estonian `et`, Turkish `tr`, Russian `ru`, Italian `it` (#594), German `de` (#574), Polish `pl`, Czech `cs`, Hungarian `hu`, French `fr` and Bulgarian `bg`. Catalogs have 2,057–2,454 entries
each; Polish has every key of every other catalog, including the built-in style names and the equation gallery, except the citation style names only Hungarian and Bulgarian translate so far (a test checks it). A test (`every_tab_and_command_is_translated`) enforces that every ribbon tab, command
label and ribbon location is translated in every language.
`pl`, `pl-PL` and `pl_PL.UTF-8` system locales pick Polish automatically.
- Of the 675 English strings a scan extracts from `tl!(…)` calls and command labels/locations, each

View File

@ -97,3 +97,82 @@ files it was given rather than guarding against other software on the computer.
Resources: `wordcraft://document` (inspect) and `wordcraft://commands`.
The acceptance test `crates/mcp/src/tests.rs` writes a formatted document using MCP only.
## Character and paragraph properties
`format.set {"props": CharProps}`, `para.set {"props": ParaProps}`,
`styles.modify {"style": …, "chr"?: CharProps, "para"?: ParaProps}` and the `para` of
`table.newStyle` / `table.modifyStyle` take these two objects. Every field is optional: a field
left out keeps what the style chain gives. A field name the object doesn't know is ignored, not
refused, so check the spelling. Lengths are points; `[r, g, b]` is a colour as three numbers
0–255.
```json
{"command": "styles.modify", "params": {"style": "Heading1",
"chr": {"font": "Georgia", "size": 18, "bold": true, "color": {"Rgb": [27, 22, 17]}},
"para": {"spaceBefore": 12, "lineSpacing": {"rule": "multiple", "value": 1.15}, "keepNext": true}}}
```
### CharProps
| Field | Type | Values |
|---|---|---|
| `style` | string | character style id |
| `font` | string | font name |
| `size` | number | points |
| `bold`, `italic`, `strike`, `doubleStrike`, `caps`, `smallCaps`, `hidden`, `outline`, `shadow`, `emboss`, `engrave`, `noProof` | bool | |
| `underline` | string | `none`, `single`, `words`, `double`, `thick`, `dotted`, `dash`, `dotDash`, `dotDotDash`, `wave`, `doubleWave` |
| `underlineColor` | `[r, g, b]` | |
| `color` | `"Auto"`, a hex string `"RRGGBB"` or `"#RRGGBB"`, or `{"Rgb": [r, g, b]}` | `Auto` (also `"auto"`) is black on light backgrounds, white on dark; read back as `"Auto"` or `{"Rgb": …}` (#865) |
| `highlight` | string | `none`, `yellow`, `brightGreen`, `turquoise`, `pink`, `blue`, `red`, `darkBlue`, `teal`, `green`, `violet`, `darkRed`, `darkYellow`, `gray50`, `gray25`, `black`, `white` |
| `shading` | `[r, g, b]` | background fill |
| `border` | Border | character border; `"style": "none"` is explicitly no border |
| `vertAlign` | string | `baseline`, `superscript`, `subscript` |
| `spacing` | number | character spacing, points: positive expands, negative condenses |
| `scale` | number | horizontal scale, percent (100 = normal) |
| `position` | number | raised (positive) or lowered (negative), points |
| `kern` | number | kern fonts at or above this size, points; 0 = off |
| `textEffects` | object | WordArt `fill`, `outline`, `shadow`, `glow`, `reflection` (see `crates/doc/src/wordart.rs`) |
| `lang` | string | BCP 47 language tag (proofing) |
| `rtl` | bool | right-to-left run |
| `cs` | bool | format the whole run with the complex-script properties |
| `fontCs`, `fontEa` | string | complex-script font; East Asian font |
| `sizeCs` | number | complex-script size, points |
| `boldCs`, `italicCs` | bool | complex-script bold, italic |
| `langBidi` | string | BCP 47 language of complex-script text, e.g. `fa-IR` |
| `link` | string | hyperlink target: a URL, or `#bookmark` |
| `ins`, `del`, `fmtChange` | | Track Changes bookkeeping (revision indexes) |
`format.set` also applies `size`, `bold` and `italic` to `sizeCs`, `boldCs` and `italicCs` when
those aren't given; `styles.modify` sets only what it is given.
A **Border** is `{"style": …, "width": pt, "color": [r, g, b] | null, "space": pt}` (`space` is
the distance from the text). Missing fields are `none`, 0, no colour and 0, so a border needs a
`width` above 0 to show. `style` is one of `none`, `single`, `double`, `dotted`, `dashed`,
`thick`, `triple`, `dotDash`, `wave`.
### ParaProps
| Field | Type | Values |
|---|---|---|
| `style` | string | paragraph style id |
| `align` | string | `left`, `center`, `right`, `justify`, `distribute` (`left` is the start edge: the right in a right-to-left paragraph) |
| `indentLeft`, `indentRight` | number | points |
| `indentFirst` | number | first-line indent, points; negative = hanging |
| `indentLeftChars`, `indentRightChars`, `indentFirstChars` | number | the same indents in characters; a non-zero value wins over the one in points, 0 cancels an inherited one |
| `spaceBefore`, `spaceAfter` | number | points |
| `lineSpacing` | object | `{"rule": "multiple", "value": 1.15}` (1 = single, 2 = double), `{"rule": "atLeast", "value": pt}`, `{"rule": "exactly", "value": pt}` |
| `contextualSpacing` | bool | ignore space before/after between paragraphs of the same style |
| `mirrorIndents` | bool | left and right indents swap on even pages |
| `keepNext`, `keepLines`, `pageBreakBefore`, `widowControl` | bool | |
| `outlineLevel` | integer | 0–8 = Level 1–9, 9 = body text |
| `numbering` | object | `{"num": n, "level": n}`, both required; `level` counts from 0; `num` 0 removes numbering a style gives |
| `tabs` | array | `[{"pos": pt, "align"?: "left\|center\|right\|decimal\|bar\|clear", "leader"?: "none\|dot\|hyphen\|underscore\|middleDot"}]`; replaces the inherited stops, `clear` removes an inherited one at `pos` |
| `shading` | `[r, g, b]` | background fill |
| `borders` | object | `{"top", "left", "bottom", "right", "between"}`, each a Border (`between`: between paragraphs; `inside_v` is used by tables) |
| `suppressHyphens`, `suppressLineNumbers` | bool | |
| `bidi` | bool | right-to-left paragraph |
| `dropCap` | integer | lines to drop, 0 = none |
| `kinsoku`, `wordWrap`, `overflowPunct`, `autoSpaceDE`, `autoSpaceDN`, `snapToGrid` | bool | East Asian typography; unset = on |
| `topLinePunct` | bool | East Asian typography; unset = off |
| `numChange`, `fmtChange` | | Track Changes bookkeeping |

View File

@ -1,6 +1,6 @@
# Typing parity with Word
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously minor: right-to-left letters typed into an empty paragraph make it right-to-left, #713; previously trivial: grammar proofing: ellipses are not sentence boundaries; previously minor: AutoCorrect's English replacement list applies only to English text, #835; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343; previously trivial: outline keys in Outline view, #493) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
What Word does when you type, observed black-box (same keystrokes typed into Word and
WordCraft, results read back paragraph by paragraph) and pinned by tests in
@ -40,6 +40,7 @@ overall picture is in [`target-app-parity.md`](target-app-parity.md).
| Enter (tracking on) | the new paragraph mark is a tracked insertion; Reject All joins the paragraphs again, Accept All keeps them | `tracked_enter_and_backspace_track_the_paragraph_mark`, `tests::reject_all_removes_a_tracked_paragraph_break` |
| Paste of several paragraphs (tracking on) | every paragraph mark the paste adds is a tracked insertion; Reject All restores the one paragraph, Accept All keeps the pasted ones | `reject_all_takes_out_the_paragraph_breaks_a_tracked_paste_inserted` |
| Backspace at a paragraph start / Delete at a paragraph end (tracking on) | the paragraph mark is marked deleted (still shown); the caret moves before it (Backspace) or past it (Delete). Accept joins the paragraphs, Reject keeps them. A mark you inserted yourself is just removed | `tracked_enter_and_backspace_track_the_paragraph_mark`, `tests::tracked_backspace_and_delete_mark_a_paragraph_break_deleted` |
| Delete a range from the end of one paragraph into a later one (tracking on) | the paragraph marks and text in the range are tracked deletions. Accept All removes exactly those, so the paragraph after the range stays a paragraph of its own; Reject All restores everything. Typing into an empty paragraph and rejecting leaves that paragraph | `tests::accept_all_after_a_tracked_delete_from_a_paragraph_end_keeps_the_next_paragraph`, `tests::reject_all_after_typing_into_an_empty_paragraph_keeps_the_paragraph` |
## Editing keys
@ -167,6 +168,7 @@ Word.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | trivial | Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864) |
| 2026-10-12 | minor | AutoCorrect: Correct TWo INitial CApitals, accidental cAPS LOCK, capitalize first letter of table cells (#544) |
| 2026-10-11 | minor | Text direction: right-to-left letters typed into an empty paragraph make it right-to-left; existing paragraphs keep theirs (#713) |
| 2026-10-12 | trivial | AutoCorrect: untagged text follows the editing language; English decided as proofing does (#835 review) |