Compare commits

..
109 Commits
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 5cd53f184a Release 0.14.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:58:07 -05:00
Dylan M. Taylor 9e6fa432f2 docs: sync Craft App Suite list with ArtCraft Launcher 2026-11-01 12:30:02 -05:00
aiongg 9c72ddf56a Merge pull request #5000 from benandez/integration/batch-proof
Integration batch proof: 2 approved proofing PRs (merge commit, please)
2026-11-01 10:46:45 -05:00
.CooCoo e868e2a83f Tests: border lines, tab indents and wide spaces before a word (#4235)
Tests and notes for rules that #4129, #4113, #4109 and #4101 added and
left uncovered:

- A border line at the top of a story with formatting left out of the
  Track Changes markup is not a formatting change, and Reject All keeps
  it.
- The author's own earlier formatting change on that paragraph stays the
  one change.
- Reject All keeps the table of a "+---+" line.
- A border line that a content control's lock stops Backspace from
  deleting stays as it is, and Enter is refused, untracked too.
- With tab indents on, Tab in a table cell moves to the next cell; with
  them off, Backspace before an indented paragraph joins it.
- AutoCorrect and the spelling check's web and e-mail addresses after a
  narrow no-break space and a figure space.

docs/typing-parity.md describes each, and lists a no-break space typed
after a word, which does not finish the word for AutoCorrect, as a
known gap.
2026-11-01 10:46:25 -05:00
Martin BallandClaude Opus 5.5 78f3089b19 #5000: review — a KEEPCASE word is suggested in its dictionary case, never re-cased into a flagged form
`Kmm` with `km/K` suggested `Km`, which the checker flags; `Km` itself got no
suggestion. A suggestion whose dictionary form is only correct through a
KEEPCASE stem now keeps that case (`km`). Other stems are re-cased as before
(`MIXCASX` → `MIXCASE`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-01 10:46:06 +00:00
Dylan M. Taylor a88e005256 ci: grant packages write permission to release workflow 2026-10-31 23:43:06 -04:00
Dylan M. Taylor 6e0f7e79ed packaging(flatpak): add OCI image build and GHCR publishing helper (#5126) 2026-10-31 18:53:02 -04:00
e6a5cd05f0 DOCX: a paragraph naming an undefined style is formatted as Normal, as in Word (#5077) (#5104)
A pStyle id the style sheet doesn't define gave an empty style chain, so the
paragraph got the document defaults alone. Word formats it as Normal: a Pandoc
footer naming an undefined Footer style was taller than Word's and pushed the
last paragraph of the page over.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-31 12:03:07 -04:00
401c6c0dc2 References: a table of contents writes its page numbers as PAGEREF fields, as Word does (#5097)
A hyperlinked TOC wrote each entry's page number as plain text inside the
link. Word writes it as a PAGEREF _Toc… \h field, so Update Field in Word
moves the number with its heading; with plain text the numbers stay what
they were when the file was saved.

Entries that link to a bookmark now end with that field (result: the page
number). Entries without a link and \n levels are unchanged. The DOCX span
test looks for the TOC's own end, the last one, since PAGEREF fields now end
inside it.

Co-authored-by: dfgodvan <180286157+dfgodvan@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-31 11:53:56 -04:00
OskarandBrandon Thomas 8afe6bef6d fix: translate highlighting color tooltips (#5008)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-31 10:50:22 -04:00
6dae5a1d17 Detect CSV separator from the first non-blank line (#5042)
Found while testing crates/engine/src/cmd/mailings/recipients.rs:6-18 (csv_separator)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-31 10:50:10 -04:00
3d966e2418 Treat <!--> and <!---> as empty comments in HTML import (#5040)
* Treat <!--> and <!---> as empty comments in HTML import

Found while testing crates/formats/src/html/lexer.rs:70-73 (Lexer::next, `<!--` branch)

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

* Docs: file-format-parity HTML row for empty comments (#5040)

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-31 10:49:58 -04:00
a4bcf05e11 Layout: a kept heading goes over with a three-line paragraph that widow control moves whole (#4991)
Keep with next asked for two lines of the paragraph after a kept heading under widow/orphan
control. A three-line paragraph cannot split at all there (two lines and a widow, or an orphan
and two), so it moved over whole and left the heading alone at the foot of the page. Word moves
the heading with it: ask for all three lines in that case.

Co-authored-by: dfgodvan <180286157+dfgodvan@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-31 10:49:46 -04:00
c934977157 Keytips: Alt, F shows the Backstage pages' letters; Alt, F, P and Alt, F, V print (#4960) (#4981)
* Keytips: Alt, F shows the Backstage pages' letters; Alt, F, P and Alt, F, V print (#4960)

Keytips ended as soon as File opened the Backstage, so Alt, F, P and
Word's older Alt, F, V (Print Preview) did nothing. Alt, F now badges the
side menu with Word's letters (Home H, New N, Open O, Info I, Save S,
Save As A, Print P, Export E, Close C, Options T), and V opens the Print
page too, as it does in Word. A page picked this way acts as a click on
it does.

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

* Keytips: closing the Backstage ends its page letters (#4960)

The Backstage closed by its back chevron or a mouse Save while Alt, F's
badges showed left keytips waiting for a page letter. The next key was
eaten instead of typed, and picked a page with the Backstage gone: C
closed the document and S saved it. Keytips now end with the Backstage.

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

* Keytips: keep the doc comment on works_without_document; ui-parity and ROADMAP rows (#4960)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-31 10:32:05 -04:00
a2f387567d Printing: the print job's PDF leaves out link annotations, so documents with links print (#4734) (#4817)
* #4734: the print job's PDF leaves out link annotations

craft-print refuses to fit a page with annotations to the paper ("flatten page annotations
before using shrink-to-fit"), and the desktop app always prints with ShrinkToFit, so any
document with a hyperlink or a TOC (its entries are links) failed to print. Links print
nothing; `PdfOptions::links` (default on) lets the print job leave them out. Exported PDFs
keep them.

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

* #4734: review — the print note is in every desktop column; gaps and progress log

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

* #4734: doc wording: the print PDF omits link annotations; hardware still to validate

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

* #4734: only the printer's PDF drops links; Save these pages as PDF keeps them

Session::print_job_pdf is what ui.printSubmit sends to the spooler, without
link annotations; Session::print_pdf (file.printPdf) keeps them again.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-31 10:26:28 -04:00
54517ace14 Word count: each Chinese and Japanese character is a word (#4547) (#4629)
count_words split only on whitespace, so unspaced Chinese or Japanese
text counted as one word per run. Word counts each Han ideograph and
kana as a word; Hangul (spaced) still counts by tokens. Fixes the status
bar, the Word Count dialog and NUMWORDS, which all use count_words.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-31 10:19:24 -04:00
LucienandBrandon Thomas a2446aa330 View › Macros opens a dialog to run, delete and record macros (#4327) (#4426)
* View › Macros opens a dialog to run, delete and record macros (#4327)

* Docs: date the Macros dialog row and bump the ui-parity status line (#4327)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-31 10:15:51 -04:00
socrabs151andBrandon Thomas 028d413ad6 #3676: page parameters saturate instead of wrapping to page one (#4289)
* fix(engine): saturate page parameters on 32-bit and wasm (#3676)

The file.exportPng and edit.goto paths cast a u64 page to usize and subtracted one. On 32-bit and wasm, page 2^32 truncated to 0 and the subtraction wrapped to page 1 (a debug panic).

Add p::page_index, which saturates instead of wrapping, and use it in both engine paths. The MCP render path already has the same guard on main; the new tests also pin that behavior.

Regression tests cover the helper, Go To, PNG export and the MCP render path.

* fix(engine): guard caret.set page cast too (#3676)

caret.set takes a 0-based page; pg as usize truncated on 32-bit and wasm and could select page 0. Use the same checked conversion and add a regression test.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-31 10:08:13 -04:00
Dylan M. Taylor eecbb8fe57 Merge pull request #5035 from aiongg/docs-tidy
docs: tidy agent, contributor and asset docs
2026-10-31 01:06:36 -04:00
Dylan M. Taylor a20fd6216d Merge pull request #5037 from aiongg/update-result-dialog
Updates: shared check-result dialog from craft-update
2026-10-31 01:05:46 -04:00
aiongg 9baacc310a updates: show check results with craft-update's CheckResultDialog 2026-10-31 04:42:38 +00:00
aiongg e8ea394f72 docs: tidy wording 2026-10-31 04:25:41 +00:00
aiongg 55958eb0ec docs: tidy agent, contributor and asset docs 2026-10-31 04:17:23 +00:00
Brandon ThomasandClaude Opus 5.5 2be33e6dc9 Release 0.13.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:25:32 -04:00
Brandon ThomasandClaude Opus 5.5 5f551346a5 Flatpak bundle: skip the debuginfo pass manifest-wide (runner has no eu-strip)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:25:28 -04:00
Brandon ThomasandClaude Opus 5.5 f2dfa0d28d Release 0.13.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:15:41 -04:00
Brandon ThomasandClaude Opus 5.5 fc41416804 Flatpak bundle: skip the debuginfo pass for cups-client (runner has no eu-strip)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:15:38 -04:00
Brandon ThomasandClaude Opus 5.5 1fb801f6bb Release 0.13.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:59:22 -04:00
Dylan M. Taylor aeae018cfd fix(flatpak): allow reading launcher version pins (#5010)
* fix(flatpak): allow reading launcher version pins

* fix(flatpak): allow reading launcher version pins
2026-10-30 20:12:15 -04:00
Martin Ball 2424bf8caf Merge #4191 2026-10-30 21:16:12 +00:00
Martin Ball 05405d51a9 Merge #4575 2026-10-30 21:16:12 +00:00
Martin BallandClaude Opus 5.5 5b5810fe1f CI: Scorecard hardening — release token write only on the release job, SECURITY.md, patched ctutils (#4975)
OpenSSF Scorecard (v5.6.0, 4.0/10) flagged:
- Token-Permissions: release.yml granted contents: write at the top level.
  Now read at the top, write on the release job only (same effective access).
- Security-Policy: no SECURITY.md. Added one pointing to GitHub private
  vulnerability reporting (already enabled on the repo).
- Vulnerabilities: ctutils 0.4.3 -> 0.4.7 (RUSTSEC-2026-0385); clipboard-win
  5.4.1 -> 5.4.3 (yanked).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:00:27 -04:00
Dylan M. Taylor 3b7f7ed10b UPD.1: honor app pretend versions in update checks (#4930) 2026-10-30 02:37:34 -04:00
Dylan M. Taylor 5d063c5bc2 Update prompt shows the app icon and accent color instead of a letter (#4925)
UPDATE_APP (native builds; craft-update is a native-only dependency) carries our 128 px icon and accent (#3f5fe0, its colour in ArtCraft Launcher). The updater, the self-update
helper and craft-update's startup prompt all get them through crate::UPDATE_APP; the
prompt draws the icon, the accent and "by ArtCraft" (craft-update's default developer).

craft-update is pinned to storytold/craft-libs main at 1065653e7 (#434).
2026-10-30 01:28:23 -04:00
Harrison BoothandBrandon Thomas 1cbb027962 docs: refresh the architecture inventory (#4772)
* Docs: refresh architecture inventory

* wc-upkeep-6: document standalone control-key layer

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-30 00:14:53 -04:00
ReesandBrandon Thomas a57dd81dba Copy selected objects along an axis with Ctrl+Shift (#4764) (#4775)
* #4764: copy selected objects along an axis with Ctrl+Shift

* #4775: preserve aligned copies through scrolling and secondary selection

* docs: keep the gaps.md note in the status line and revision table

* docs: drop a stray blank line

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-30 00:14:26 -04:00
ReesandBrandon Thomas a4b1682b41 Index laid-out objects during connector rerouting (#3631) (#4622)
* #3631: index laid-out objects during connector rerouting

* docs: keep the gaps.md note in the status line and revision table

* docs: drop a stray blank line

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-30 00:13:32 -04:00
2f78615291 #4720: Chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes (#4726)
Chart and diagram text was shaped in one face, the document's body font,
so Chinese legends and axis labels showed boxes while body text was fine.
Characters the face lacks now go to a fallback face, one face per run
when one covers it all, as in body text; a bold fallback face without a
bold style is emboldened.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-30 00:12:55 -04:00
9632646c5e Bibliography: keep a source's corporate author (#4848) (#4898)
* #4848: corporate authors in bibliographies

A Word source whose author is an organisation (Create Source › Corporate Author)
stores it as `b:Corporate`, which the reader ignored: the source opened with no
author, citations fell back to the title and bibliography entries started with
the year. A bare name typed into Author was split into "Organization, W. H.".

- Source gains `corporate`, read from and written as `b:Corporate` (in place of a
  name list: a role holds one or the other, ECMA-376 CT_NameOrCorporateType).
- Citations and bibliography entries write it as it is in every style; it gives
  the source's tag and its place in an author-sorted list, and untagged sources
  are matched by it.
- APA puts its full stop after a group author ("World Health Organization.
  (2021).") instead of relying on the initials for it.
- Create Source has a Corporate Author box under Author; Edit Source opens with
  it checked when the source has one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvG6DxS4tKmWSLCiDPvb4G

* Match untagged sources by corporate author before personal author

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-30 00:12:17 -04:00
ReesandBrandon Thomas 18ae8b2150 DOCX: preserve custom-property bookmark links (#4767) (#4771)
* M12: preserve custom-property bookmark links (#4767)

* M12: preserve bookmark links across property renames (#4767)

* M12: align custom-property preservation status dates (#4767)

* docs: keep the gaps.md note in the status line and revision table

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-30 00:11:27 -04:00
9a172e2241 Table Properties: row, column and cell settings follow the selection (#4857) (#4896)
Row, column and cell settings applied to the caret's row, column and cell only, so
selecting the whole table and unticking "Allow row to break across pages" changed
one row and the others still split across pages.

- table.properties applies row height and rule, allow break and header row to every
  selected row, the column width to every selected column, and the cell width and
  vertical alignment to every selected cell (the caret's with nothing selected), in
  one undo step.
- The dialog shows "Allow row to break" and "Repeat as header row" as mixed when the
  selected rows differ; left alone they change nothing, clicked they set every row.
- The row-height rule box is named by "Specify height" for screen readers.


Claude-Session: https://claude.ai/code/session_01LvG6DxS4tKmWSLCiDPvb4G

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-30 00:10:33 -04:00
ReesandBrandon Thomas db2b3cab99 Home › Font: step each selected run from its own size (#4903) (#4910)
* M2 #4903: step each selected run from its own font size

* docs: keep the gaps.md note in the status line and revision table

* docs: drop a stray blank line

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-30 00:09:39 -04:00
665ded2800 Cross-reference: captions in a style based on Caption (#4411) (#4881)
* #4411: captions in a style based on Caption are cross-reference targets

Cross-reference and Table of Figures found captions only by the exact
Caption style, so a caption restyled with a style based on Caption (a
label's own "Caption Table") was left out: of five tableX captions,
only the two still in Caption were offered. A caption is now a
paragraph whose style is Caption or based on it.

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

* #4411: test captions two styles from Caption and the Table type

Covers a style based on a style based on Caption, a Table caption in
such a style offered under the built-in Table type, and a caption
label used only in a style not based on Caption staying out of the
cross-reference types.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-30 00:09:01 -04:00
Yasas SriandBrandon Thomas d40e0eeb47 #4086: highlighting at a caret leaves the word alone (#4701)
* #4086: highlighting at a caret leaves the word alone

* #4086: the highlight toggle reads pending caret formatting; word formatting updates it

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-30 00:08:31 -04:00
Harrison BoothandBrandon Thomas 2e790d549c Backspace after a floating shape's anchor keeps the shape (#4592) (#4641)
* Backspace after a floating shape's anchor joins paragraphs and keeps the shape (#4592)

* wc-upkeep-6: protect Backspace joins and markers

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-30 00:07:53 -04:00
ReesandBrandon Thomas 74b2cbb5c9 Backspace: keep Arabic base letters when removing marks (#4757) (#4761)
* 4757: keep Arabic base letters when removing marks

* docs: keep the gaps.md note in the status line and revision table

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:53:06 -04:00
ReesandBrandon Thomas d39be3bab8 Avoid repeated reference scans when converting notes (#4750) (#4752)
* Fix #4750: walk note references and formatting runs together

* docs: keep the gaps.md note in the status line and revision table

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:52:43 -04:00
vikoabastianandBrandon Thomas f2debdecff #3674: PDF export writes a glyph id above 65535 as .notdef instead of failing (#4554)
* #3674: PDF export writes a glyph id above 65535 as .notdef instead of failing

Shaping gives 32-bit glyph ids (a format-12 cmap can map a character to
any of them), and the PDF exporter passed them to krilla unchecked.
krilla's CID font takes 16-bit ids and unwraps the conversion
(vendor/krilla/src/text/cid.rs:161 at craft-libs b926b5c0), so one such
glyph failed the whole export natively and aborted the tab on wasm.

The three places that build a KrillaGlyph (body text, metafile text and
the text watermark) now go through glyph_id(), which writes an id that
doesn't fit as glyph 0. The glyph keeps its place and its text range, so
the rest of the run is unchanged.

Regression test: glyph_id_above_u16_exports lays out a paragraph, sets
one glyph's id to 70000 and exports the layout. It failed before with
"PDF export failed internally: ... TryFromIntError(PosOverflow)".

* #3674: the replaced glyph keeps its place, not its text

krilla writes no ToUnicode entry for glyph 0, so a glyph written as
.notdef doesn't extract: "Wide glyph" with the W's id set to 70000
extracts as "\u{FFFD}ide glyph". The rest of the run extracts as
before. The comment on glyph_id() said the glyph kept its text.

* Adapt glyph_id imports to main's exporter text module

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:52:04 -04:00
Harrison BoothandBrandon Thomas 48f5a905b1 DOCX: keep run-level snapToGrid and webHidden when saving (#4863)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:51:39 -04:00
ReesandBrandon Thomas 4abb77aa70 DOCX: avoid repeated searches when writing comment IDs (#4841)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:50:49 -04:00
e855daf279 Built-in heading styles have their outline level without w:outlineLvl, so the Navigation pane lists them (#4696) (#4706)
* #4696: built-in heading styles have their outline level without w:outlineLvl

Some producers write "heading 1"…"heading 9" in styles.xml without w:outlineLvl. Word
gives those built-in styles their outline level anyway (checked: Word for Windows reports
OutlineLevel 1 and 2, whatever the style id), so their headings show in the Navigation
pane. WordCraft read no level, and the Headings tab stayed empty.

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

* #4696: review — w:customStyle="on" marks a custom style too

A custom style named "heading 4" with customStyle="on" (ST_OnOff) was taken for Word's
built-in heading and got an outline level.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:50:27 -04:00
f774f6f5c0 #4548: comment ranges inside footnotes and endnotes are kept on read (#4550)
* #4548: comment ranges inside footnotes and endnotes are kept on read

read_opened read footnotes.xml and endnotes.xml before comments.xml, so
a comment range inside a note looked its id up in an empty comment map
and was dropped. Comments are now read first. read_comments uses nothing
the notes set up.

Regression test: tests/it/note_comments.rs (synthetic markup).

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

* docs: file-format-parity row for comment ranges in notes (#4548)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:49:56 -04:00
5b952b2463 RTF: keep Windows and Enhanced Metafile pictures (#4259) (#4648)
The RTF reader only recognised \pngblip and \jpegblip, so a \pict
holding \wmetafile8 (how Word writes pasted and floating pictures) or
\emfblip was dropped and the document opened without its pictures.
Keep them as wmf/emf media when the bytes are a metafile (the extension
comes from the bytes; raster bytes go through the usual image path,
anything else is dropped), sized from \picwgoal/\pichgoal and the scale,
else from \picw/\pich (0.01 mm, or pixels under \wmetafile1; zero or
negative falls back to the default). The metafile renderer already
draws them and the DOCX writer saves them as .wmf/.emf.

RTF export dropped WMF/EMF pictures as well (it re-encoded every other
format through the raster decoder): it now writes \emfblip with the raw
bytes and \wmetafile8 without the placeable header, \picw/\pich in
0.01 mm.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:43:55 -04:00
81833cfc33 Keep Markdown emphasis markers flanking next to punctuation (#4801)
Found while testing crates/formats/src/markdown/export.rs:167-182 (inlines_md_impl, emphasis marker emission)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:43:16 -04:00
ReesandBrandon Thomas 48545479b0 Markdown import: bound nested reference-label work (#4659)
* M1 #4658: bound Markdown reference-label work

* #4658: align Markdown progress status dates

* docs: keep ROADMAP's second status line as on main

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:42:49 -04:00
ReesandBrandon Thomas 990b57a1e8 HTML: preserve preformatted lines with CR input newlines (#4810) (#4815)
* Formats: preserve HTML input newlines (#4810)

* docs: fold the #4810 notes into the existing status lines

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:42:07 -04:00
0debb7afda Keep -- and '' apart across text runs in LaTeX export (#4822)
Found while testing crates/formats/src/latex/export.rs:402-444 (escape_text, `prev` resets per call) and :230-237 (inlines, runs concatenated)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:41:10 -04:00
bc437049fa Fix HTML import leaking <title> text into body outside <head> (#4725)
Found while testing crates/formats/src/html/builder.rs:338-344 (Builder::text)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:40:50 -04:00
jayzed80andBrandon Thomas 6e8a420d28 fix(formats): HTML <font size> offsets no longer overflow at the integer limits (#4755)
* fix(formats): saturate HTML <font size> offsets so extreme values cannot overflow

* test(formats): assert the saturated HTML font sizes, not only that the text survives

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:40:21 -04:00
64ebfda971 Reject '+' signs in 8-digit hex colours in parse_color (#4653)
Found while testing crates/formats/src/model/color.rs:45-47 (parse_color, 8-digit hex branch)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:40:01 -04:00
c99552c958 Carry rounded ISO duration seconds into minutes and hours (#4555)
Found while testing crates/formats/src/sheet/date.rs:136-152 (iso_time, PT duration branch)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:39:32 -04:00
Brandon ThomasandClaude Opus 5.5 250a9c5164 ci: Zotero peer check judges an unaccepted connection by its listening socket
The Linux same-user check read the uid of the listener's side of the
connection from /proc/net/tcp. Until the listener accepts it, that row has
no socket file (inode 0) and reads uid 0 whoever listens, so a Zotero that
hadn't accepted yet was refused as "another user (uid 0)". The ui-egui
Zotero tests failed about one run in three on a busy machine because of it.

Rows without a socket file (unaccepted connections, TIME_WAIT) are now
skipped, and the listening row on the listener's address stands in for the
connection's own; two users listening there is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:33:41 -04:00
dhruvLearner8andBrandon Thomas 1be606de03 Insert › Shapes shows the basic shapes as an icon grid (#4349) (#4739)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 22:41:10 -04:00
685b420104 Ctrl+V pastes a clipboard picture again with egui 0.37 (#4787)
* Ctrl+V pastes a clipboard picture again with egui 0.37

Since the egui 0.37 migration, egui-winit keeps both the Mod+V press and
its release, so the canvas never sees the V release it pasted a clipboard
picture on. Outside Wayland, egui-winit instead sends Event::PasteImage
when the clipboard holds a picture and no text, which the canvas ignored.
Ctrl+V pasted nothing while the ribbon and context-menu Paste still worked.

Answer PasteImage with edit.paste, which reads the picture through the host
like the ribbon's Paste. The web build sends no PasteImage, so nothing
pastes twice there.

Part of #4782

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

* Mark the clipboard picture as pasted on PasteImage so a later V release cannot paste twice

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 22:39:17 -04:00
4fcc409c8b AutoCorrect Options opens with no document open (#4585) (#4626)
File › Options › Proofing › AutoCorrect Options did nothing on the Start
screen: tools.autocorrect wasn't in works_without_document, so the click
was dropped. Its options are user preferences (session.prefs), like the
other File › Options settings, so it now works without a document.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 22:37:44 -04:00
bd85c72f39 Shading buttons show the last picked colour (#4412) (#4505)
* Shading buttons show the last picked colour as their bar (#4412)

The paragraph and table Shading buttons always drew the icon's yellow bar,
whatever colour a click applied. They now draw the remembered colour as the
bar, as Font Color and Text Highlight Color do (#4087), on an icon without a
bar of its own.

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

* docs: ui-parity row for the Shading button colour (#4412)

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 22:36:42 -04:00
8a964609c4 Format Painter: double-click a word to paint it (#4410) (#4502)
With the painter armed, only dragging across the text applied the copied
formatting: a double-click (a word) or triple-click (a paragraph) selected
the text and returned before the painter was used. Both paths now go through
one helper, so the click paints what it selected and the painter goes off.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 22:34:44 -04:00
LucienandBrandon Thomas 4a12ab9a19 Layout › Arrange › Group stays on while a group is selected, so Ungroup is reachable (#4229) (#4440)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 22:34:01 -04:00
LucienandBrandon Thomas 41d5284fa8 Options: a blank User Name stays blank after a restart (#4368) (#4421)
* Options: a blank User Name stays blank after a restart (#4368)

* docs: ui-parity row for the blank User Name (#4368)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 22:33:21 -04:00
931324e049 WordArt gallery: hand the keyboard back to the page after a pick (#4061) (#4358)
Inserting WordArt selects its placeholder text, so typing should replace it. From the Insert
gallery the first keystrokes went nowhere: the gallery's cells (and the Text Fill, Text Outline,
Glow and Transform galleries beside them) ran their command with `app.run` instead of
`run_from_ribbon`, so the canvas never asked for focus back and the page ignored the keys.
They now go through `run_from_ribbon` like every menu item.

Regression test renders the style gallery, picks a style and checks the page asks for the
keyboard; it fails without the change.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 22:32:41 -04:00
983f1e0267 docx: pin Standard-encryption key derivation to a real-world package (#4472)
Adds the key that Apache POI's Excel-written test file
protected_passtika.xlsx (password "tika") derives, computed from
[MS-OFFCRYPTO] §2.3.4.7 independently of the implementation, so real
Word 2007 files stay pinned.

Also adds the contributor's people.toml entry.

Co-authored-by: Bra1nZ <bra1nz@astraoffice.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 22:24:23 -04:00
.CooCooandBrandon Thomas 39ae5f6778 Layout parity: field results take \* DollarText, \* Hex and \* ArabicDash (#4361)
* Layout parity: field results take \* DollarText, \* Hex and \* ArabicDash

docs/layout-parity.md gets a row for the three field switches that #4226
added, a revision entry and a new status-line clause. The row describes
main as it is after 8c035fbb, where \* ArabicDash formats any number
field and table formulas, not PAGE alone.

* Layout parity: dollar text keeps its digits only past 999,999.99

Cardinal and ordinal text keep their digits past 999,999. `\* DollarText` writes
results up to 999,999.99 (crates/layout/src/fields.rs dollar_text).

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 22:22:55 -04:00
5714408dd8 M14: validate window fallback geometry and avoid duplicate symbol names (#4343)
* M14: validate window fallback geometry and avoid duplicate symbol names

Verified regression cases and full cargo xtask ci before publication.

* M14: document review helpers and regression contracts

Documentation-only follow-up; full cargo xtask ci passed.

---------

Co-authored-by: kalmarr <680152+kalmarr@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 22:20:19 -04:00
Dylan M. Taylor 964a7bc5a8 CI: workflows only call craft-common-actions at @main (#4919)
AGENTS.md (and CLAUDE.md where present) gets a CI workflows section: workflows only call
craft-common-actions at @main, with no other jobs; feature and fix PRs don't touch CI and
link or open a craft-common-actions issue (a PR only for a small fix that is safe for
every app and does not increase CI time); legacy jobs
aren't added to and move only as their own task, staying until a shared workflow runs them.

windows-arm64.yml moves from its SHA pin to @main. The pinned revision is on
craft-common-actions main and the workflow (and the actions it calls) is identical
there, so nothing changes in behaviour.
2026-10-29 22:17:20 -04:00
SimoneandLucenx9 3508cac3b0 Prevent X11 crashes from NUL in document titles (#3634) (#4301)
Ported onto current main (title_stem now lives in app/frame.rs; the window
title is built in windows.rs).

Co-authored-by: Lucenx9 <185146821+Lucenx9@users.noreply.github.com>
2026-10-29 22:15:58 -04:00
8148a239e6 Content controls: resolve lone table-open markers in linear time (#3826) (#4227)
Count table opens in a single linear pass instead of rescanning
earlier events for every lone table-open marker (O(events) instead of
O(lone * events)).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 22:04:29 -04:00
acccf913ab Traditional Chinese interface: draw hanzi to the Taiwan standard with an installed TC font (#3380)
* i18n: Traditional Chinese interface draws hanzi with an installed TC font

The zh-hant interface shared the Simplified Chinese font order, so its first
CJK face was the embedded Noto Sans CJK SC and traditional characters were
drawn with mainland glyph shapes (說, 資, 這, 為, 戶).

On the desktop, a Traditional Chinese interface now puts an installed
Traditional Chinese font before every CJK face: PingFang TC on macOS,
Microsoft JhengHei on Windows, Noto Sans CJK TC on Linux. Noto Sans CJK SC
stays after it as the fallback. Without an installed TC font, and on wasm,
the order is unchanged.

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

* Credits: add TobyWu666 to contributors/people.toml

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

* i18n: find Traditional Chinese fonts at the root of a font folder

Linux user font folders (~/.fonts, ~/.local/share/fonts) often hold font
files directly; the Traditional Chinese candidates only named files in
distribution subfolders, so those fonts were missed (CodeRabbit review).

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

* i18n: skip the Traditional Chinese font test without embedded catalogs

Since the catalogs come from craft-i18n (#4072), a build without
CRAFT_I18N_DIR is English only, so the test now skips like the other
catalog tests. Also rustfmt hant_font_chars.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 22:03:12 -04:00
dc48046057 #1103: ignore macOS launch arguments and name the real cause when a file won't open (#2622)
* #1103: ignore macOS launch arguments and name the real cause when a file won't open

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

* #1103: filter launch args on macOS only; leave Backstage after a failed Finder open

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

* #1103: leave Backstage after a failed drag-and-drop open

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

* #1103: name the file when an open fails after the Save changes prompt

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

* #1103: report failed opens from every deferred path in execute_user

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

* #1103: keep the named failed-open message after a browser save

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

* #1103: only opens held back by a prompt or save leave Backstage on failure

A direct open from Recent keeps File open and reports there (#2938).

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

* #1103: re-apply open-failure reporting on main's split ui-egui modules

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

* Qualify i18n paths in report_open_failure after merging main

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 21:58:32 -04:00
22e99fc2b7 Tables can be copied and cut: whole tables, rows and cells (#1744) (#1784)
* #1744: WIP

* #1744: Select Table copies and cuts the whole table

Copy of a whole-table selection used to keep only the cells' paragraphs
(copy_blocks flattened a cross-cell range), and Cut only cleared the text.
Now Copy puts the table on the clipboard and Cut removes the table.

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

* #1744: selected table rows and cells copy as a table; Cut clears their text

Copy of whole rows, cells of a row or a block of cells puts a table of just
those cells on the clipboard (Table::slice). Cut of rows or cells clears their
text and keeps the rows and cells, as in Word (Word check A).

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

* #1744: review fixes

Table::slice widens every row to a merged cell partly selected (no ragged
copy); an empty selected table or cell copies and cuts; a text selection over
exactly a nested table copies it; one selected_table per Cut; tests for
slice merges/widths/controls, tracked and locked cuts, and the text route.

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

* #1744: Cut clears every cell Copy takes; Spike stays off for empty cell selections

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

* #1744: a copied part of a percentage-width table keeps a point width

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

* ROADMAP: tidy merged header

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

* ROADMAP: keep only the #1744 rows over main's version

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

* ROADMAP: remove conflict markers; keep only the #1744 rows over main's version

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

* #1744: rename the range Cut helper clear_range_cells (main added clear_cells)

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

* Remove conflict markers from ROADMAP.md; leave main's header line alone

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

* cargo fmt

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

* Import Path in table structure after merging main

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 21:47:27 -04:00
Fin WarmanandBrandon Thomas f658c872a2 Tables: share spanning minima by preferred widths (#1883)
* Tables: share span minima by preferred widths (#1883)

* 1222: Match nested preferred span weights

* docs: word the #1883 layout-parity entries

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 21:40:41 -04:00
Fin WarmanandBrandon Thomas a066fe5f13 Keep zero-sized paragraph frames in normal flow (#2810)
* 411: Keep zero-sized paragraph frames in normal flow

* M2.1: keep frame controls consistent with document defaults (#2810)

* M2.1: check the final frame round trip (#2810)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 21:38:01 -04:00
Dylan M. Taylor ccb8ce8494 packaging: Flatpak finds and prints to CUPS printers (#4909)
* packaging: Flatpak finds and prints to CUPS printers

The Print dialog listed no printers in the Flatpak: the runtime has libcups but not
lpstat/lp, the sandbox couldn't reach the CUPS socket, and driverless network printers
(lpstat -e) need Avahi. Bundle lpstat/lp/lpoptions from CUPS 2.4.20 (Apache-2.0,
statically linked, licence installed) and grant --socket=cups and
--system-talk-name=org.freedesktop.Avahi in both manifests.

* packaging: Flatpak reads the user's CUPS settings (~/.cups, read-only)

Review (CodeRabbit): without it the sandbox's lp/lpstat ignore the user's default
printer and saved options (lpoptions) and a print server named in their client.conf.

* packaging: Flatpak CUPS tools reach the host socket when client.conf names a custom path

Review: Flatpak mounts the host's CUPS socket at /var/run/cups/cups.sock whatever
path ~/.cups/client.conf or CUPS_SERVER names, so lp/lpstat/lpoptions now run through
a wrapper that points a socket path missing in the sandbox there. Server names
(remote servers) and sockets that exist are left alone. Tested in the sandbox:
CUPS_SERVER=/run/custom/cups.sock lists the host's printers through the wrapper and
none without it; a host name is passed through unchanged.

* packaging: redirect a custom CUPS socket only when it is the one Flatpak mounted

Review (CodeRabbit): Flatpak picks the socket it mounts at /var/run/cups/cups.sock
with stricter rules than CUPS (flatpak-run-cups.c: a socket CUPS_SERVER, else the
first line spelled exactly "ServerName /path" in ~/.cups/client.conf, else the
default), while CUPS reads the keyword in any case and takes the last ServerName. The
wrapper now redirects only when CUPS' socket is the one Flatpak chose, so a job can't
reach a different server; otherwise CUPS fails on the missing path.

* packaging: never redirect a CUPS_SERVER the sandbox was given

Review (CodeRabbit): Flatpak picks the socket it mounts from the host's CUPS_SERVER,
so one set by flatpak run --env or an override may not be what it mounted. The
wrapper now leaves any CUPS_SERVER alone and redirects only a client.conf socket that
matches Flatpak's choice; otherwise CUPS fails on the missing path instead of
reaching a different server.

* packaging: point CUPS at the mounted socket only when it is the one Flatpak chose, else fail

Review (CodeRabbit): with a custom socket listed first and the default last in
~/.cups/client.conf, Flatpak mounts the custom one at /var/run/cups/cups.sock while
CUPS picks the last line, finds that path, and talks to the custom server. The
wrapper now compares CUPS' socket with Flatpak's choice even when the path exists:
equal, CUPS is pointed at the mount; different, the tool fails with an error instead
of reaching another server.
2026-10-29 18:48:31 -04:00
skylar taylor-barrick 165d5725dc Merge pull request #4894 from OPUZZLES/opuzzles/pilot-recipient-label-sheets
Labels: continue selected recipients across sheets (#4880)
2026-10-29 14:18:49 -06:00
VoiceLessQ 0d41b18301 Engine tests: the dictionaries-folder test checks each reported file again
The shared dictionary slot reports no file while a dictionary given
with install_dictionary is in use, so this test had accepted null
alongside the folder's file, for installs from tests running at the
same time. That also let it pass if file.options stopped reporting the
folder's file at all. The tests' install helpers and this test now take
turns on one lock, and each language must report the folder's file
again. An install from before the test doesn't matter: changing the
folder reloads every slot.
2026-10-29 15:38:01 -02:00
OPUZZLES 01a1bf3921 M9: assert unused recipient labels are empty (#4880) 2026-10-29 18:14:46 +01:00
VoiceLessQ 744addabf2 Merge origin/main
main split Czech's suggest_in into one helper per round for the
cognitive_complexity limit (#4501). On this branch that code is the
shared affix_suggest::suggest_in (36 of 30), so cs/suggest.rs stays the
thin wrapper and the shared function gets the same split. The candidates
are offered in the same order; a dump of 13,081 calls over every
language's test dictionary matches the old engine except one: the typo
round counts the lower-cased letters, so "İ" (two letters lower case)
now gets "I" in Polish, where the old count stopped one letter short.
2026-10-29 14:55:30 -02:00
OPUZZLES 5a9e414d1e M9: generate all recipient label sheets (#4880) 2026-10-29 17:49:08 +01:00
Dylan M. Taylor aadb077e07 Honor CARGO_BUILD_TARGET in FreeBSD packaging for aarch64 release builds (#4760)
Add packaging/freebsd/package.sh, matching the sibling craft apps. The release
workflow cross-compiles aarch64 FreeBSD in an x86_64 VM with CARGO_BUILD_TARGET
set; the script builds with --target, reads binaries from the target directory,
names the archive after the target arch, and checks ELF/FreeBSD/arch instead of
running binaries that cannot run on the host.
2026-10-28 15:53:55 -04:00
Dylan M. Taylor 9724f15c3d Grant network access so update checks can reach GitHub (#4741) 2026-10-28 13:36:48 -04:00
VoiceLessQ 62948a2c31 Merge remote-tracking branch 'upstream/main' into fix/proof-shared-language-engine
# Conflicts:
#	crates/proof/src/es/mod.rs
#	crates/proof/src/tr/mod.rs
2026-10-27 15:36:17 -02:00
Skaywalker fa657a1c41 proof: U+0085 and U+00A0 are flags in one-flag directives too (#735) 2026-10-27 14:21:39 +01:00
Skaywalker e4765c999d proof: numeric and long flags keep a UTF-8 .aff as text (#735) 2026-10-27 13:09:33 +01:00
Skaywalker a47412aef9 proof: an AF count below 1 rejects the table, flags stay literal (#735) 2026-10-27 13:09:33 +01:00
Skaywalker 3051fab792 proof: .dic lines and alias numbers read as Hunspell reads them (#735)
Checked against the Hunspell 1.7 command line:
- Without a tab or a morphological field the whole line is the entry, so
  a phrase with single-byte flags (tök jó/396 in hu_HU) no longer fails
  the load or loses its alias; leading blanks belong to the entry.
- With an AF table a flag field is the alias its leading digits number
  (fal/2x is alias 2, lab/b has none); an undefined alias, in a .dic
  entry or a continuation class, reads as no flags instead of failing
  the dictionary; a table without its count line is ignored.
- A REP count line may carry a comment, like AF and ICONV.

normalize copies the input only from its first change on, and SET and
FLAG are found in one pass over the .aff.
2026-10-27 13:09:33 +01:00
Skaywalker 74449b87fb proof: read count comments, phrase entries and undefined aliases as Hunspell does (#735)
Checked against the Hunspell 1.7 command line:
- The first AF and ICONV line is the count even with a trailing comment
  (AF 2 # aliases), so alias numbers no longer shift and ICONV 1 # no
  longer turns 1 into #.
- A .dic entry ends where Hunspell ends it (dic_entry_end), so ad hoc/B
  is one entry and ad alone is no word.
- An undefined alias in a .dic entry reads as no flags: the stem loads
  without affixes instead of failing the whole dictionary.
- IGNORE characters are removed from the appended text only; a stripped
  text that holds one never matches a stem, as in Hunspell.
- FLAG UTF-8 and SET are found anywhere in the .aff, not only in the
  first 8 KB.

Also: normalize borrows the input when no conversion or ignored character
applies, the UTF-8 .aff reader counts fields as it goes and builds its
error context only on error, and the single-byte flag reader is
decode_latin1.
2026-10-27 13:09:33 +01:00
Skaywalker bb023684f2 proof: SUBSTANDARD stems are correct but never suggested, as with NOSUGGEST 2026-10-27 13:09:33 +01:00
Skaywalker 9b91b64d60 proof: test that unsupported directives are refused and morphology-only flags load
COMPOUNDFIRST and COMPOUNDLAST, the older names of COMPOUNDBEGIN and
COMPOUNDEND, are refused like them until compounding is supported.
2026-10-27 13:09:33 +01:00
Skaywalker efa570a9be proof: a one-flag field keeps its first byte, a directive reads only its first argument
Without FLAG UTF-8, Hunspell reads a field that names one flag (PFX/SFX,
KEEPCASE, FORBIDDENWORD and the like) through decode_flag, which takes its
first byte: SFX é is the flag 0xC3. Such a field no longer fails the load.
SYLLABLENUM lists flags and is read byte by byte like AF. SET and FLAG
read only their first argument, so FLAG UTF-8 with a trailing comment
keeps Unicode flags.
2026-10-27 13:09:33 +01:00
Skaywalker 7ebfcf0ee2 proof: .aff comments start after a directive's fields, .dic entries end where Hunspell ends them 2026-10-27 13:09:33 +01:00
Skaywalker 46a3067e20 proof: flag aliases, single-byte flags, ICONV, IGNORE and KEEPCASE in affix dictionaries (#735)
The Hungarian Hunspell dictionary declares SET UTF-8 but writes its flags as single bytes, uses AF aliases and 158 distinct flags. Read flag fields byte by byte unless FLAG UTF-8 is set, expand AF aliases, skip AM, apply ICONV and IGNORE, honour KEEPCASE, and widen flag masks to 256 bits. Compounding and NEEDAFFIX stay unsupported for now.
2026-10-27 13:09:33 +01:00
VoiceLessQ 4c9ff5db35 Proofing: a dictionary load from before a folder change is not kept
A dictionary is read outside the lock. If the dictionaries folder changed
while it was being read (File › Options › Proofing), the reload emptied
the slot first and the late load then filled it with the old folder's
dictionary. It stayed until the next reload. Every language's copy of
the loader had this window; the shared slot now closes it in one place.

The slot counts reloads. A load that started before a reload still
answers the check that asked for it, but it isn't kept: the next check
reads the new folder.

Found by CodeRabbit on #4191. Tests: a_load_from_before_a_reload_is_not_kept;
not counting reloads fails it. Proof: 288 tests.
2026-10-24 20:57:37 -01:00
VoiceLessQ 4dfdecf3d7 Proofing: a candidate found again at a lower cost ranks at that cost
The suggestion rounds don't run in rising cost: a MAP diacritic drop (11)
comes before a REP confusion (10, 9 for Russian). The engine checked each
candidate once, at the first cost it was offered, so the same word found
again more cheaply kept the dearer cost and could rank one place too low.
Czech's engine always did this, and the merged engine took it over.

The candidate set is now a map to the cheapest cost offered so far, and
an offer that is no cheaper is skipped as before.

Found by CodeRabbit on #4191. Tests: a_cheaper_later_offer_wins; skipping
every repeat offer again fails it. Proof: 288 tests.
2026-10-24 20:57:35 -01:00
VoiceLessQ e0014ff1d9 Proofing: the spelling options are read in one place
Czech, Croatian, Indonesian, Italian, Polish, Portuguese and Spanish began
their spelling checks with the same thirteen lines, byte for byte: no
dictionary means no marks, single letters and words without letters pass,
words with digits and short words in capitals pass when the File › Options
› Proofing choices say so, and words in another script pass. Each also had
its own copy of the Latin-script test. These were the lines #3966 had to fix
in six copies one by one, and where the Russian and Turkish copies stayed
wrong until the first commit here.

spelling.rs has them once (`word_to_check`), and each language goes on
from there with its own rules (Polish apostrophe endings, Italian elisions,
Indonesian clitics, Uruguayan `vos` forms).

Tests: words_needing_no_lookup. Ignoring the uppercase option there fails
it and all seven languages' proofing-options tests. Proof: 286 tests.
2026-10-24 19:58:30 -01:00
VoiceLessQ 9a8f4669fa Proofing: one language table instead of nine lists
lib.rs named every dictionary language in nine places: reloading for a new
folder, the installed list, load errors, the missing-dictionary hint, the
loaded check, the spelling message, spelling, suggestions and grammar. The
lists had drifted. dictionary_load_error had no Russian or Czech entry, so a
Russian or Czech dictionary that failed to load showed no reason in File ›
Options › Proofing, though the status bar, which reads another list, said
why.

LANGUAGES holds one row per language: its dictionary slot, its spelling
message, and its spelling, suggestion and grammar functions. The nine
functions read the row, so a language now joins with its module, its
ProofLang and one row. The per-language reload_dictionary and
installed_path wrappers had no callers left, inside the crate or out, and
are gone.

Tests: every_dictionary_language_reports_its_load_error, which fails for
Russian on the old code, and the_language_table_covers_the_proofed_languages
(each ProofLang::PROOFED language but English has exactly one row). Proof
285, engine 927 and layout 501 (lib).
2026-10-24 19:55:43 -01:00
VoiceLessQ e7eda2aff0 Proofing: one suggestion engine with per-language tuning
Czech's dictionary-driven ranking (case, MAP, REP, missing spaces, KEY,
TRY edits, a stem scan) had five older copies. Polish, Portuguese,
Croatian, Italian and Russian each kept one: 300 to 400 lines of the same
rounds with a few lines of language knowledge mixed in. Fixes reached some
copies only. NOSUGGEST (#1048) and the first-difference tie-break (#797)
never got to all of them, and none had the KEY round or the bound on TRY
letters.

affix_suggest.rs is that ranking once. A language's `Tuning` holds what is
its own: the short words that run into their neighbours (Polish,
Portuguese, Croatian, Italian), Russian's split rule, the letter groups and
confusions its dictionaries lack (Croatian, Russian), letters to try for a
dictionary without a TRY line, and cost hooks for Polish final devoicing
and Russian е/ё. Czech, Indonesian and Spanish use the dictionary's tables
alone. Turkish keeps its own suggester, which reads another dictionary type
with rounds of its own.

The language tables add to the dictionary's instead of replacing them, and
TRY letters are tried in the dictionary's order, commonest first, as the
copies did (the Czech engine sorted them).

Old and new compared on the test dictionaries, with every one-letter typo
of each word plus the tests' misspellings, top eight suggestions: Czech,
Croatian, Italian and Portuguese give the same for all 7,903 inputs.
Polish differs for 44 of 3,244 and Russian for 33 of 2,346, all orderings
among candidates of equal cost, from the tie-break they now share (`ie` →
`nie` before `i`).

New tests pin the moved knowledge: the tuning mechanism, Croatian's groups
without MAP lines, and Russian's costs and split rule. Turning each knob
off fails one. Proof: 283 tests.
2026-10-24 19:49:22 -01:00
VoiceLessQ 577d5d61f8 Proof tests: the user-dictionary file test waits for the dictionary tests
user_dictionary_persists_through_a_file saves the whole user dictionary to
a file and loads it back. Run beside
user_dictionary_and_ignore_all_match_both_normalization_forms, it could
catch a word that test had added and bring it back after the test removed
it, so the other test's last check failed now and then (once in about two
dozen full runs here). It now holds DICTIONARY_TESTS, as the other test
does.
2026-10-24 19:48:47 -01:00
VoiceLessQ d2f45ddd96 Proofing: one dictionary slot for every language
Nine languages each kept their own copy of the same code: the installed
dictionary behind a lock, read on first use and logged, replaced by
install_dictionary, forgotten by reload_dictionary, and its file reported
by installed_path. The copies had drifted already. Every one documented that
a dictionary given with install_dictionary has no file and isn't reported,
but only Russian and Czech did that (#3252); the other seven reported the
disk file anyway.

`DictSlot` (proof/src/slot.rs) is that code once. It is generic over the
dictionary type, so Turkish's `TrDict` fits, and keeps the dictionary and
its in-memory origin under one lock as #3252 made it. Each language keeps
its functions as one-line wrappers over its slot, so no caller changes, and
the log lines are the same.

installed_path now follows the documented rule in every language. Nothing
outside tests installs a dictionary in memory, but tests do, concurrently,
so the dictionaries-folder tests in proof and engine could race on the
reported path of a language another test installs. They now accept the
folder's file or none there, and check that the folder is searched first
with hunspell::find, which no install affects.

Tests: proof 279, engine 927, layout 501 (lib); slot.rs has a test of its
own.
2026-10-24 19:33:17 -01:00
VoiceLessQ 281392ac15 Croatian, Italian and Russian suggestions skip NOSUGGEST words
#1048 (6e25d482c) taught affix.rs about NOSUGGEST, the dictionary flag for
words that are correct but never offered (vulgarisms, rare forms), and
patched the Polish and Portuguese copies of the suggestion engine; Czech's
started with it. The Croatian, Italian and Russian copies never got it: they
test candidates with `contains`, so a suggestion could be a word the
dictionary says not to offer. They now use `contains_suggestible` and drop a
suggestion with any part `can_suggest` refuses, as the others do.

Tests: Croatian and Italian run the shared assert_nosuggest_is_honoured.
Russian gets its own, since its MAP and REP tables are built in and the
shared Latin-script check doesn't reach them. Each fails on the old code.
2026-10-24 19:21:57 -01:00
VoiceLessQ 695b2688d8 Proofing options reach the Russian and Turkish checks too
#3966 made Ignore words in UPPERCASE and Ignore words that contain numbers
reach six languages' own checks, one copy at a time, and Russian and Turkish
were left out. lib.rs still called `ru::is_correct(word)` and
`tr::is_correct(word)`, which accept words with digits (both) and short words
in capitals (Russian) whatever the options say, so turning either option off
changed nothing in those languages.

Both now have `is_correct_with_options`, like the others. Russian's rule for
a part in capitals (`ИИ-консультант`) follows the option too, as Indonesian's
does, and its digits rule keeps counting superscripts (`м²`). Turkish had no
test dictionary, so its two dictionary tests returned early wherever none was
installed, CI included; it has a synthetic one now, also in the
dictionaries-folder test.

Tests: proofing_options_reach_the_russian_check and
proofing_options_reach_the_turkish_check. Calling the old checks from lib.rs
fails each, and so does ungating the Russian part rule or the Turkish hyphen
recursion.
2026-10-24 19:19:25 -01:00
204 changed files with 8415 additions and 3556 deletions
+5 -1
View File
@@ -11,7 +11,7 @@ on:
default: ""
permissions:
contents: write
contents: read
concurrency:
group: release-${{ github.ref }}
@@ -19,6 +19,10 @@ concurrency:
jobs:
release:
# Only this job publishes the GitHub release, so only it gets write access.
permissions:
contents: write
packages: write
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
+2 -2
View File
@@ -26,8 +26,8 @@ permissions:
jobs:
windows-arm64:
# Immutable supporting workflow revision until the new install contract lands upstream.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
with:
app-name: wordcraft
app-dir: WordCraft
+10 -8
View File
@@ -1,13 +1,13 @@
# WordCraft — instructions for agents
WordCraft is a clean-room, open-source, Rust-native word processor targeting Microsoft Word parity — and going further on speed, openness and agent control. It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign). Shared rules and learnings: [`storytold/craftrules`](https://github.com/storytold/craftrules) (`../../craftrules`) — read its `AGENTS.md`.
WordCraft is a clean-room, open-source, Rust-native word processor targeting Microsoft Word parity — and going further on speed, openness and agent control. It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. `plan/` is gitignored (local only); if it's missing, start from `ROADMAP.md`, `docs/gaps.md` and `docs/roadmap.md` (Current focus).
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §5). Don't stop to ask unless a decision is genuinely the owner's (licensing, publishing, pushing to new remotes, secrets).
## Never crash
People trust WordCraft with their writing; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it. Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
People trust WordCraft with their writing; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it.
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature returns an error or is disabled; it never panics.
- **Input-derived numbers are hostile** (files, commands, MCP/control params): `get()` instead of `[i]`, slice strings only at char boundaries, checked/saturating arithmetic, clamp sizes, cap allocations, bound recursion.
@@ -63,22 +63,24 @@ Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's o
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
## Roadmap
Progress docs follow [`craftrules/standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md): `ROADMAP.md` (stage, headline numbers, progress log), `docs/target-app-parity.md` (the assessment), `docs/gaps.md` (ranked work list), `docs/roadmap.md` (milestones, Current focus), `docs/architecture.md`, and the parity checklists `docs/file-format-parity.md`, `docs/layout-parity.md`, `docs/typing-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the gap, the parity doc and the progress log, and bump each touched doc's status line and revision history.
Progress docs: `ROADMAP.md` (stage, headline numbers, progress log), `docs/target-app-parity.md` (the assessment), `docs/gaps.md` (ranked work list), `docs/roadmap.md` (milestones, Current focus), `docs/architecture.md`, and the parity checklists `docs/file-format-parity.md`, `docs/layout-parity.md`, `docs/typing-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the gap, the parity doc and the progress log, and bump each touched doc's status line and revision history.
## Contributor credits (About window)
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
(commit stats; never hand-edit) and `contributors/people.toml` (names people chose for
themselves). See `docs/contributors.md`.
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
whether they want to be credited by more than their username: a real name, a display name, and/or
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
(copy the template at the top of the file, or run
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
(copy the template at the top of the file)
and include it in their PR, committed as them. If no, change nothing: they are credited as
`@username` anyway.
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
+9 -9
View File
@@ -59,14 +59,14 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `docs/images/dark.png` | WordCraft contributors | screenshot of WordCraft itself (sample document written for WordCraft, rendered by `ui_shot`) | MIT OR Apache-2.0 | Original; no Microsoft UI or assets |
| `docs/images/backstage.png` | WordCraft contributors | screenshot of WordCraft itself (sample document written for WordCraft, rendered by `ui_shot`) | MIT OR Apache-2.0 | Original; no Microsoft UI or assets |
| `docs/images/layout.png` | WordCraft contributors | screenshot of WordCraft itself (sample document written for WordCraft, rendered by `ui_shot`) | MIT OR Apache-2.0 | Original; no Microsoft UI or assets |
| `docs/brand/LICENSE-brand.txt` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/LICENSE-brand.txt` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-logo.png` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| craft-fonts (optional build input, not files in this repo): BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic | see https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | embedded only when built with `CRAFT_FONTS_DIR` (all official releases) | OFL-1.1 | Japanese and Chinese UI, Japanese document fallback |
| craft-i18n (optional build input, not files in this repo): the interface translations | see https://github.com/storytold/craft-i18n/blob/main/ATTRIBUTION.md, which credits every translator of WordCraft's former in-repo catalogs (`crates/ui-egui/src/i18n/*.tsv`, migrated at `wordcraft@806a945`) | embedded only when built with `CRAFT_I18N_DIR`, at the commit in `craft-i18n.rev` (all official releases) | MIT OR Apache-2.0 | Interface languages other than English; clean-room translations from the English interface labels |
+8 -3
View File
@@ -1,13 +1,13 @@
# WordCraft — instructions for agents
WordCraft is a clean-room, open-source, Rust-native word processor targeting Microsoft Word parity — and going further on speed, openness and agent control. It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign). Shared rules and learnings: [`storytold/craftrules`](https://github.com/storytold/craftrules) (`../../craftrules`) — read its `AGENTS.md`.
WordCraft is a clean-room, open-source, Rust-native word processor targeting Microsoft Word parity — and going further on speed, openness and agent control. It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. `plan/` is gitignored (local only); if it's missing, start from `ROADMAP.md`, `docs/gaps.md` and `docs/roadmap.md` (Current focus).
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §5). Don't stop to ask unless a decision is genuinely the owner's (licensing, publishing, pushing to new remotes, secrets).
## Never crash
People trust WordCraft with their writing; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it. Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
People trust WordCraft with their writing; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it.
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature returns an error or is disabled; it never panics.
- **Input-derived numbers are hostile** (files, commands, MCP/control params): `get()` instead of `[i]`, slice strings only at char boundaries, checked/saturating arithmetic, clamp sizes, cap allocations, bound recursion.
@@ -62,4 +62,9 @@ Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's o
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
## Roadmap
Progress docs follow [`craftrules/standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md): `ROADMAP.md` (stage, headline numbers, progress log), `docs/target-app-parity.md` (the assessment), `docs/gaps.md` (ranked work list), `docs/roadmap.md` (milestones, Current focus), `docs/architecture.md`, and the parity checklists `docs/file-format-parity.md`, `docs/layout-parity.md`, `docs/typing-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the gap, the parity doc and the progress log, and bump each touched doc's status line and revision history.
Progress docs: `ROADMAP.md` (stage, headline numbers, progress log), `docs/target-app-parity.md` (the assessment), `docs/gaps.md` (ranked work list), `docs/roadmap.md` (milestones, Current focus), `docs/architecture.md`, and the parity checklists `docs/file-format-parity.md`, `docs/layout-parity.md`, `docs/typing-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the gap, the parity doc and the progress log, and bump each touched doc's status line and revision history.
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
Generated
+27 -25
View File
@@ -809,9 +809,9 @@ dependencies = [
[[package]]
name = "clipboard-win"
version = "5.4.1"
version = "5.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a"
dependencies = [
"error-code",
]
@@ -1084,13 +1084,14 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"chrono",
"dunce",
"egui",
"flate2",
"getrandom 0.3.4",
"image",
"log",
"plist",
"semver",
@@ -1207,9 +1208,9 @@ dependencies = [
[[package]]
name = "ctutils"
version = "0.4.3"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574"
checksum = "a2376ffd6f96d8a9db59875b4b6b0237d6900b5baf64279714e05493ce64828a"
dependencies = [
"cmov",
]
@@ -6647,7 +6648,7 @@ dependencies = [
[[package]]
name = "wordcraft"
version = "0.12.0"
version = "0.14.0"
dependencies = [
"arboard",
"chrono",
@@ -6682,7 +6683,7 @@ dependencies = [
[[package]]
name = "wordcraft-cli"
version = "0.12.0"
version = "0.14.0"
dependencies = [
"serde_json",
"sys-locale",
@@ -6695,11 +6696,11 @@ dependencies = [
[[package]]
name = "wordcraft-control-key"
version = "0.12.0"
version = "0.14.0"
[[package]]
name = "wordcraft-doc"
version = "0.12.0"
version = "0.14.0"
dependencies = [
"base64 0.23.1",
"log",
@@ -6713,7 +6714,7 @@ dependencies = [
[[package]]
name = "wordcraft-docbin"
version = "0.12.0"
version = "0.14.0"
dependencies = [
"cfb",
"chrono",
@@ -6730,7 +6731,7 @@ dependencies = [
[[package]]
name = "wordcraft-docx"
version = "0.12.0"
version = "0.14.0"
dependencies = [
"aes",
"base64 0.23.1",
@@ -6754,7 +6755,7 @@ dependencies = [
[[package]]
name = "wordcraft-engine"
version = "0.12.0"
version = "0.14.0"
dependencies = [
"cfb",
"chrono",
@@ -6785,7 +6786,7 @@ dependencies = [
[[package]]
name = "wordcraft-fonts"
version = "0.12.0"
version = "0.14.0"
dependencies = [
"brotli-decompressor",
"harfrust 0.13.3",
@@ -6799,7 +6800,7 @@ dependencies = [
[[package]]
name = "wordcraft-formats"
version = "0.12.0"
version = "0.14.0"
dependencies = [
"cfb",
"chardetng",
@@ -6812,12 +6813,13 @@ dependencies = [
"quick-xml 0.41.0",
"wordcraft-doc",
"wordcraft-geom",
"wordcraft-metafile",
"zip 3.0.0",
]
[[package]]
name = "wordcraft-geom"
version = "0.12.0"
version = "0.14.0"
dependencies = [
"image",
"serde",
@@ -6825,7 +6827,7 @@ dependencies = [
[[package]]
name = "wordcraft-layout"
version = "0.12.0"
version = "0.14.0"
dependencies = [
"image",
"js-sys",
@@ -6845,7 +6847,7 @@ dependencies = [
[[package]]
name = "wordcraft-mcp"
version = "0.12.0"
version = "0.14.0"
dependencies = [
"log",
"serde_json",
@@ -6857,7 +6859,7 @@ dependencies = [
[[package]]
name = "wordcraft-metafile"
version = "0.12.0"
version = "0.14.0"
dependencies = [
"kurbo",
"thiserror 2.0.21",
@@ -6865,7 +6867,7 @@ dependencies = [
[[package]]
name = "wordcraft-pdf"
version = "0.12.0"
version = "0.14.0"
dependencies = [
"chrono",
"craft-pdf",
@@ -6886,7 +6888,7 @@ dependencies = [
[[package]]
name = "wordcraft-proof"
version = "0.12.0"
version = "0.14.0"
dependencies = [
"log",
"miniz_oxide 0.9.1",
@@ -6896,7 +6898,7 @@ dependencies = [
[[package]]
name = "wordcraft-render"
version = "0.12.0"
version = "0.14.0"
dependencies = [
"image",
"log",
@@ -6910,7 +6912,7 @@ dependencies = [
[[package]]
name = "wordcraft-ui-egui"
version = "0.12.0"
version = "0.14.0"
dependencies = [
"cfb",
"chrono",
@@ -6941,7 +6943,7 @@ dependencies = [
[[package]]
name = "wordcraft-web"
version = "0.12.0"
version = "0.14.0"
dependencies = [
"eframe",
"egui",
@@ -6960,7 +6962,7 @@ dependencies = [
[[package]]
name = "wordcraft-zotero"
version = "0.12.0"
version = "0.14.0"
dependencies = [
"log",
"proptest",
@@ -7123,7 +7125,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.12.0"
version = "0.14.0"
dependencies = [
"serde_json",
]
+3 -2
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
[workspace.package]
version = "0.12.0"
version = "0.14.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -59,7 +59,8 @@ brotli-decompressor = "5"
vello_cpu = "0.2"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["write"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates.
+9 -1
View File
@@ -345,7 +345,15 @@ stand on its own.
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | **Writing and document design · you are here** | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
+71 -6
View File
File diff suppressed because one or more lines are too long
+26
View File
@@ -0,0 +1,26 @@
# Security policy
## Reporting a vulnerability
Please report security problems privately through GitHub:
**[Report a vulnerability](https://github.com/storytold/wordcraft/security/advisories/new)**
(Security tab › Advisories › Report a vulnerability).
Don't open a public issue or pull request for a vulnerability until a fix is released.
Useful to include:
- the WordCraft version or commit, and the platform (macOS, Windows, Linux, BSD, web);
- what an attacker can do, and what they need (a crafted document, MCP or control-channel access, a local account…);
- steps or a sample file that reproduce it.
We'll acknowledge the report, keep you updated while we work on a fix, and credit you in the advisory unless you'd rather not be named.
## Supported versions
Fixes go into `main` and ship in the next release. Older releases don't get backported fixes.
## Scope
In scope: anything that lets a document, file, command, MCP or control-channel request, or network response read or write files it shouldn't, run code, leak data, or crash WordCraft and lose the user's work.
Crashes from hostile input count, because WordCraft treats every panic as a bug (see [Never crash](AGENTS.md)).
+2 -2
View File
@@ -49,9 +49,9 @@ impl PrefsSync {
pub fn changed(&mut self, app: &WordApp) -> Option<String> {
let mut prefs = app.prefs();
if let Some((host, own)) = &self.host_author
&& prefs.author == *host
&& prefs.author.as_deref() == Some(host.as_str())
{
prefs.author = own.clone();
prefs.author = Some(own.clone());
}
let json = match serde_json::to_string(&prefs) {
Ok(j) => j,
+3 -4
View File
@@ -47,10 +47,9 @@ pub fn poll(inbox: &Inbox, app: &mut WordApp, ctx: &egui::Context) {
// Leave Backstage, as opening from the Open dialog does.
Ok(_) => app.ui.backstage = false,
Err(e) => {
log::warn!("{p}: {e}");
let e = e.to_string();
let msg = wordcraft_ui_egui::tl!("Couldn't open {path}: {error}");
app.status(wordcraft_ui_egui::i18n::fmt(msg, &[("path", p), ("error", &e)]));
app.report_open_failure(p, &e);
// Backstage hides the status bar, so leave it to show the error.
app.ui.backstage = false;
}
}
}
+54 -3
View File
@@ -329,6 +329,26 @@ fn text_args() -> (Vec<String>, Vec<std::path::PathBuf>) {
(args, files)
}
/// The arguments without the ones macOS adds when it launches an app (#1103): `-psn_0_12345`, and
/// the `-NS…` / `-Apple…` options (`-AppleLanguages (en)`, `-NSDocumentRevisionsDebugMode YES`) with
/// the value after each. They are not files, and opening them as files fails.
fn without_launch_args(args: Vec<String>) -> Vec<String> {
let mut kept = Vec::with_capacity(args.len());
let mut args = args.into_iter().peekable();
while let Some(a) = args.next() {
if a.starts_with("-psn_") {
continue;
}
if a.starts_with("-NS") || a.starts_with("-Apple") {
// Its value, unless the next argument is another flag.
args.next_if(|v| !v.starts_with('-'));
continue;
}
kept.push(a);
}
kept
}
/// Open a file whose name isn't valid Unicode (#1440). `file.open` takes its path as text, so it
/// gets the file's bytes under the lossy name, and the document then keeps the real path to save to.
/// The bytes are read as `file.open` reads a path: only a regular file, and at most 2 GB.
@@ -340,7 +360,9 @@ fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
// Not when it waits for a password: that open names the document itself.
Ok(r) if r.get("pending").is_none() => app.session.path = Some(path.into()),
Ok(_) => {}
Err(e) => log::warn!("{name}: {e}"),
Err(e) => {
app.report_open_failure(&name, &e);
}
}
}
@@ -350,7 +372,7 @@ fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
fn main() -> eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::WORDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
if let Some(result) = craft_update::self_update::finish(&wordcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
// First, so the panic hook and every start-up warning are recorded (`logging`).
@@ -360,6 +382,8 @@ fn main() -> eframe::Result {
let mut files = Vec::new();
let mut sample = false;
let (args, other_files) = text_args();
// Only macOS (Finder / LaunchServices) adds these; elsewhere `-NS…` could be a real file name.
let args = if cfg!(target_os = "macos") { without_launch_args(args) } else { args };
let mut bad_control: Option<Option<String>> = None;
let mut args = args.into_iter().peekable();
while let Some(a) = args.next() {
@@ -504,7 +528,7 @@ fn main() -> eframe::Result {
}
for f in files {
if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) {
log::warn!("{f}: {e}");
app.report_open_failure(&f, &e);
}
}
for f in other_files {
@@ -590,6 +614,33 @@ mod tests {
}
}
#[cfg(test)]
mod launch_arg_tests {
use super::without_launch_args;
fn strip(args: &[&str]) -> Vec<String> {
without_launch_args(args.iter().map(|a| a.to_string()).collect())
}
#[test]
fn macos_launch_arguments_are_not_files() {
assert_eq!(strip(&["-psn_0_12345", "a.docx"]), ["a.docx"]);
assert_eq!(strip(&["-AppleLanguages", "(en)", "a.docx"]), ["a.docx"]);
assert_eq!(strip(&["a.docx", "-NSDocumentRevisionsDebugMode", "YES", "b.docx"]), ["a.docx", "b.docx"]);
// A flag with no value after it, or followed by another flag, takes nothing with it.
assert_eq!(strip(&["a.docx", "-AppleLocale"]), ["a.docx"]);
assert_eq!(strip(&["-NSFoo", "--sample", "a.docx"]), ["--sample", "a.docx"]);
}
#[test]
fn real_arguments_are_kept() {
assert_eq!(strip(&["--control", "7981", "--sample", "-h"]), ["--control", "7981", "--sample", "-h"]);
// Files whose names merely start like a launch argument keep their place when they are paths.
assert_eq!(strip(&["./-psn_x.docx", "Apple.docx", "-", "-nsfoo"]), ["./-psn_x.docx", "Apple.docx", "-", "-nsfoo"]);
assert!(strip(&[]).is_empty());
}
}
#[cfg(test)]
mod prefs_tests {
use super::write_prefs;
+11 -5
View File
@@ -1,13 +1,10 @@
# Contributor names for the About window (craftrules standards/contributors.md).
# Contributor names for the About window.
#
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
# maintainer may add a person only when that person asked, recording where in consent_source.
#
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
#
# Or copy this block, replacing your-github-username (every field is optional):
# Copy this block, replacing your-github-username (every field is optional):
#
# [people.your-github-username]
# real_name = "Your Name" # shown when the viewer picks "Real name"
@@ -181,3 +178,12 @@ sync_github_name = true
[people.pakiat]
display_name = "OtorO"
[people.TobyWu666]
display_name = "tobywu"
[people.Bra1nZ]
sync_github_name = true
[people.dylanmtaylor]
sync_github_name = true
+17 -5
View File
@@ -115,12 +115,22 @@ pub fn next_cluster(text: &str, off: usize) -> usize {
}
}
/// Scripts whose Backspace removes one character at a time (a vowel sign, a medial, a stacked
/// consonant) rather than the whole cluster, as on Windows and macOS: the Brahmic scripts of
/// South and South-East Asia.
/// Characters Backspace removes individually rather than with their whole cluster: Arabic
/// combining marks and characters of the Brahmic scripts of South and South-East Asia.
pub fn backspaces_by_char(c: char) -> bool {
matches!(u32::from(c),
0x0900..=0x0DFF // Devanagari … Sinhala
0x0610..=0x061A // Arabic nonspacing marks (Unicode 17.0)
| 0x064B..=0x065F
| 0x0670
| 0x06D6..=0x06DC
| 0x06DF..=0x06E4
| 0x06E7..=0x06E8
| 0x06EA..=0x06ED
| 0x0897..=0x089F
| 0x08CA..=0x08E1
| 0x08E3..=0x08FF
| 0x10EFA..=0x10EFF
| 0x0900..=0x0DFF // Devanagari … Sinhala
| 0x0E00..=0x0EFF // Thai, Lao
| 0x0F00..=0x0FFF // Tibetan
| 0x1000..=0x109F // Myanmar
@@ -250,8 +260,10 @@ mod tests {
}
#[test]
fn backspace_by_character_in_brahmic_scripts_only() {
fn backspace_by_character_in_brahmic_scripts_and_arabic_marks() {
assert!(backspaces_by_char('ေ') && backspaces_by_char('ि') && backspaces_by_char('្'));
assert!(backspaces_by_char('\u{064E}') && backspaces_by_char('\u{06E7}') && backspaces_by_char('\u{10EFF}'));
assert!(!backspaces_by_char('\u{301}') && !backspaces_by_char('a') && !backspaces_by_char('語'));
assert!(!backspaces_by_char('ب') && !backspaces_by_char('\u{06E5}') && !backspaces_by_char('\u{08E2}'));
}
}
+26 -13
View File
@@ -14,7 +14,7 @@
//! outside what's deleted (so deleting across a control's edge empties it rather than breaking
//! it); writers call [`Document::balance_controls`] so a saved file always has whole controls.
use std::collections::{BTreeMap, BTreeSet};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use serde::{Deserialize, Serialize};
@@ -422,24 +422,37 @@ fn scan(story: StoryRef, bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out:
}
/// Positions of the markers without a partner in a block list and the cells inside it.
fn orphans(bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out: &mut Vec<(Path, usize)>, tables: &mut Vec<(Path, usize, bool)>) {
pub(crate) fn orphans(bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out: &mut Vec<(Path, usize)>, tables: &mut Vec<(Path, usize, bool)>) {
if depth > MAX_DEPTH {
return;
}
let events = control_events(bl);
let (_, lone) = pair_events(&events);
for k in lone {
let Some(e) = events.get(k) else { continue };
let mut path = prefix.clone();
path.push(u32::try_from(e.block()).unwrap_or(u32::MAX));
match (e, e.off()) {
(_, Some(off)) => out.push((Path(path), off)),
(ControlEvent::Open { .. }, None) => {
// Which of the table's opens: count those before it on the same table.
let nth = events.iter().take(k).filter(|x| matches!(x, ControlEvent::Open { block, off: None, .. } if *block == e.block())).count();
tables.push((Path(path), nth, true));
if !lone.is_empty() {
let mut table_counts: HashMap<usize, usize> = HashMap::new();
let mut open_nth = Vec::with_capacity(events.len());
for e in &events {
if let ControlEvent::Open { block, off: None, .. } = e {
let count = table_counts.entry(*block).or_insert(0);
open_nth.push(*count);
*count += 1;
} else {
open_nth.push(0);
}
}
for k in lone {
let Some(e) = events.get(k) else { continue };
let mut path = prefix.clone();
path.push(u32::try_from(e.block()).unwrap_or(u32::MAX));
match (e, e.off()) {
(_, Some(off)) => out.push((Path(path), off)),
(ControlEvent::Open { .. }, None) => {
// Which of the table's opens: index among table opens on the same table.
let nth = open_nth.get(k).copied().unwrap_or(0);
tables.push((Path(path), nth, true));
}
(ControlEvent::Close { .. }, None) => tables.push((Path(path), 0, false)),
}
(ControlEvent::Close { .. }, None) => tables.push((Path(path), 0, false)),
}
}
for (i, b) in bl.iter().enumerate() {
+4 -3
View File
@@ -443,8 +443,7 @@ impl Document {
Ok(id)
}
/// Count words like Word's status bar (whitespace-separated tokens containing a letter or digit,
/// including generated numbered-list labels).
/// Count words like Word's status bar ([`crate::count_words`], plus generated numbered-list labels).
pub fn word_count(&self) -> usize {
self.story_word_count(StoryRef::Body)
}
@@ -795,7 +794,9 @@ impl Document {
p.kind = "lpwstr".into();
p.value = value.to_string();
}
None => self.custom_props.push(CustomProp { name: name.to_string(), kind: "lpwstr".into(), value: value.to_string() }),
None => {
self.custom_props.push(CustomProp { name: name.to_string(), kind: "lpwstr".into(), value: value.to_string(), ..Default::default() })
}
}
}
+71 -1
View File
@@ -434,10 +434,16 @@ impl Document {
at.extend(offs.get(k).copied());
}
}
// Reference offsets and run boundaries are ordered; do not restart at the first
// reference for every run, including runs after the last reference.
let mut at = at.into_iter().peekable();
let mut start = 0usize;
for r in &mut p.runs {
let end = start.saturating_add(r.len);
if at.iter().any(|&o| o >= start && o < end) {
while at.peek().is_some_and(|&o| o < start) {
at.next();
}
if at.peek().is_some_and(|&o| o < end) {
swap(&mut r.props.style);
}
start = end;
@@ -792,6 +798,63 @@ impl Document {
Fragment { blocks, parts }
}
/// A copy of the block at `path` (a whole table, say) as a fragment, with its text boxes.
pub fn copy_block(&self, story: crate::StoryRef, path: &Path) -> Option<Fragment> {
Some(self.fragment_of(vec![self.block(story, path)?.clone()]))
}
/// A copy of rows `rows` of the table at `tp`, with the cells of each that reach into grid
/// columns `cols` (see [`crate::Table::slice`]), as a fragment holding that table.
pub fn copy_table_cells(&self, story: crate::StoryRef, tp: &Path, rows: (usize, usize), cols: (usize, usize)) -> Option<Fragment> {
let t = self.table(story, tp)?.slice(rows, cols);
if t.rows.is_empty() {
return None;
}
Some(self.fragment_of(vec![Block::Table(t)]))
}
/// `blocks` as a fragment: half content controls dropped, text boxes copied along.
fn fragment_of(&self, mut blocks: Vec<Block>) -> Fragment {
crate::control::balance_blocks(&mut blocks);
let parts = self.text_box_parts(&blocks);
Fragment { blocks, parts }
}
/// The table whose whole text `a..b` covers when the range runs across its cells: from the
/// start of its first paragraph to the end of its last. Of the tables holding both ends, the
/// outermost one covered whole (a table nested in a cell when the range covers just it).
/// `None` for a range inside one cell, or one that doesn't cover a whole table.
pub fn whole_table(&self, a: &Pos, b: &Pos) -> Option<Path> {
let (a, b) = order(a, b);
if a.story != b.story || a.off != 0 || a.path.parent() == b.path.parent() {
return None;
}
let mut paths: Option<Vec<Path>> = None;
// Table block paths are the prefixes of a cell paragraph's path one block deep, then
// every three steps (block, row, cell) deeper.
let mut n = 1usize;
while n.saturating_add(3) <= a.path.0.len() {
let pre = a.path.0.get(..n)?;
if !b.path.0.starts_with(pre) {
return None;
}
let tp = Path(pre.to_vec());
if self.table(a.story, &tp).is_some() {
let paths = paths.get_or_insert_with(|| self.para_paths(a.story));
let mut inside = paths.iter().filter(|p| p.0.starts_with(pre));
if let Some(first) = inside.next() {
let last = inside.next_back().unwrap_or(first);
let end = self.para(a.story, last).map_or(0, Paragraph::len);
if *first == a.path && *last == b.path && b.off >= end {
return Some(tp);
}
}
}
n = n.saturating_add(3);
}
None
}
/// The whole body as a fragment (with its text boxes), to insert into another document.
pub fn body_fragment(&self) -> Fragment {
let blocks: Vec<Block> = self.body.iter().map(|b| (**b).clone()).collect();
@@ -846,6 +909,13 @@ impl Document {
}
return out;
}
// Across the cells of a whole table: the table itself, not its cells' paragraphs (#1744).
if let Some(tp) = self.whole_table(&a, &b)
&& let Some(blk) = self.block(a.story, &tp)
{
out.push(blk.clone());
return out;
}
for p in self.paths_between(&a, &b) {
if let Some(para) = self.para(a.story, &p) {
let mut q = para.clone();
+2 -3
View File
@@ -501,9 +501,8 @@ impl Paragraph {
pub fn next_boundary(&self, off: usize) -> usize {
crate::cluster::next_cluster(&self.text, self.clamp(off))
}
/// Where Backspace at `off` deletes from: the previous character in the Brahmic scripts
/// (Myanmar, Khmer, Devanagari, Thai…), where it removes one vowel sign or stacked consonant
/// at a time, otherwise the previous caret stop.
/// Where Backspace at `off` deletes from: one Arabic combining mark or one character in the
/// Brahmic scripts (Myanmar, Khmer, Devanagari, Thai…), otherwise the previous caret stop.
pub fn backspace_from(&self, off: usize) -> usize {
let off = self.clamp(off);
match self.text.get(..off).and_then(|t| t.char_indices().next_back()) {
+16
View File
@@ -273,6 +273,12 @@ pub struct CharProps {
pub small_caps: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hidden: Option<bool>,
/// Hidden in Web Layout (`w:webHidden`); distinct from `w:vanish` (`hidden`).
#[serde(skip_serializing_if = "Option::is_none")]
pub web_hidden: Option<bool>,
/// Snap characters to the document grid (`w:snapToGrid`). Unset inherits; Word's default is on.
#[serde(skip_serializing_if = "Option::is_none")]
pub snap_to_grid: Option<bool>,
/// Character spacing (expanded/condensed), points.
#[serde(skip_serializing_if = "Option::is_none")]
pub spacing: Option<f32>,
@@ -448,6 +454,7 @@ impl CharProps {
/// overlaid on `base`.
pub fn minus(mut self, base: &CharProps) -> CharProps {
self.minus_text_style(base);
self.minus_run_visibility(base);
self.minus_script_style(base);
self.minus_document_marks(base);
self
@@ -458,6 +465,10 @@ impl CharProps {
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl);
}
fn minus_run_visibility(&mut self, base: &CharProps) {
drop_same_fields!(self, base; web_hidden, snap_to_grid);
}
fn minus_script_style(&mut self, base: &CharProps) {
drop_same_fields!(self, base; cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout);
}
@@ -472,6 +483,7 @@ impl CharProps {
/// format both scripts must set both values explicitly.
pub fn overlay(&mut self, patch: &CharProps) {
self.overlay_text_style(patch);
self.overlay_run_visibility(patch);
self.overlay_script_style(patch);
self.overlay_document_marks(patch);
if let Some(p) = &patch.text_effects {
@@ -484,6 +496,10 @@ impl CharProps {
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl);
}
fn overlay_run_visibility(&mut self, patch: &CharProps) {
overlay_fields!(self, patch; web_hidden, snap_to_grid);
}
fn overlay_script_style(&mut self, patch: &CharProps) {
overlay_fields!(self, patch; cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout);
}
+36 -2
View File
@@ -270,7 +270,12 @@ impl ResolvedPara {
drop_cap_margin: p.drop_cap_margin.unwrap_or(false),
drop_cap_distance: p.drop_cap_distance.filter(|d| d.is_finite()).unwrap_or(0.0).clamp(0.0, MAX_DROP_CAP_DISTANCE),
drop_cap_font: p.drop_cap_font.clone().filter(|f| !f.trim().is_empty()),
frame: p.frame.clone().filter(|_| p.drop_cap.unwrap_or(0) == 0),
// Explicit zero dimensions cancel a frame; omitted dimensions still fit its text.
frame: p.frame.clone().filter(|f| p.drop_cap.unwrap_or(0) == 0 && (f.w != Some(0.0) || f.h != Some(0.0))).map(|mut f| {
f.w = f.w.filter(|v| *v != 0.0);
f.h = f.h.filter(|v| *v != 0.0);
f
}),
suppress_hyphens: p.suppress_hyphens.unwrap_or(false),
suppress_line_numbers: p.suppress_line_numbers.unwrap_or(false),
kinsoku: p.kinsoku.unwrap_or(true),
@@ -295,8 +300,12 @@ impl StyleSheet {
self.para_style_props_over(style, self.default_para.clone(), self.default_chr.clone())
}
/// The paragraph style chain of `style` applied over `pp` / `cp`.
/// The paragraph style chain of `style` applied over `pp` / `cp`. A style the document doesn't
/// define is the default paragraph style, Normal, as in Word: a footer naming a `Footer` style
/// that `styles.xml` leaves out is formatted as Normal, not with the document defaults alone
/// (#5077).
fn para_style_props_over(&self, style: &str, mut pp: ParaProps, mut cp: CharProps) -> (ParaProps, CharProps) {
let style = if self.get(style).is_some() { style } else { "Normal" };
for s in self.chain(style) {
if s.kind == StyleKind::Paragraph {
let mut sp = s.para.clone();
@@ -395,6 +404,31 @@ mod tests {
assert_eq!(s.resolve_char(None, &direct).font, "Wingdings");
}
/// #5077: a paragraph naming a style the document doesn't define is formatted as Normal,
/// paragraph and run alike, not with the document defaults alone.
#[test]
fn an_undefined_paragraph_style_is_normal() {
let mut s = StyleSheet::builtin();
s.default_para.space_after = Some(10.0);
if let Some(n) = s.get_mut("Normal") {
n.para.space_after = Some(2.0);
n.chr.size = Some(13.0);
}
let undefined = ParaProps { style: Some("NoSuchStyle".into()), ..Default::default() };
let normal = s.resolve_para(&ParaProps::default());
let p = s.resolve_para(&undefined);
assert_eq!(p.space_after, 2.0);
assert_eq!(p.space_after, normal.space_after);
assert_eq!(p.line_spacing, normal.line_spacing);
assert_eq!(s.resolve_char(Some("NoSuchStyle"), &CharProps::default()).size, 13.0);
// The paragraph keeps the style it names; direct formatting still wins.
assert_eq!(p.style, "NoSuchStyle");
let direct = ParaProps { space_after: Some(7.0), ..undefined };
assert_eq!(s.resolve_para(&direct).space_after, 7.0);
// A defined style is unchanged.
assert_eq!(s.resolve_para(&ParaProps { style: Some("Heading1".into()), ..Default::default() }).space_after, 4.0);
}
#[test]
fn heading_resolves() {
let s = StyleSheet::builtin();
+7
View File
@@ -10,6 +10,10 @@ pub struct Source {
pub kind: String,
/// "Last, First; Last, First"
pub author: String,
/// A corporate author ("World Health Organization"): written as it is, never split into
/// names, and in place of `author` when set (Create Source › Corporate Author, `b:Corporate`,
/// #4848).
pub corporate: String,
pub title: String,
pub year: String,
pub publisher: String,
@@ -39,6 +43,9 @@ pub struct CustomProp {
/// the property's XML content kept verbatim (vectors, blobs).
pub kind: String,
pub value: String,
/// Bookmark associated with this property (`linkTarget`), retained without evaluating it.
#[serde(skip_serializing_if = "Option::is_none")]
pub link_target: Option<String>,
}
/// Document properties (File › Info).
+137
View File
@@ -77,6 +77,80 @@ pub const MAX_COLS: usize = 63;
pub const MAX_TABLE_DEPTH: usize = 24;
impl Table {
/// Grid columns `lo..hi` that [`Table::slice`] copies for `rows`/`cols` (widened over merged cells),
/// and whether any cell touches them (Cut clears the same cells it copies).
pub fn slice_bounds(&self, rows: (usize, usize), cols: (usize, usize)) -> (usize, usize, bool) {
let ((r0, r1), (g0, g1)) = (rows, cols);
let kept = || self.rows.iter().take(r1.saturating_add(1)).skip(r0);
// Grid columns `lo..hi` the cells touching them cover: a cell merged across that is partly
// in them widens them, for every row, so the copy isn't ragged. Each round only widens
// (at most once per grid column), so a bound of the grid's width plus one round is enough.
let (mut lo, mut hi) = (g0, g1.saturating_add(1));
let mut any = false;
for _ in 0..=MAX_COLS.saturating_add(1) {
let (mut nlo, mut nhi, mut found) = (lo, hi, false);
for row in kept() {
let mut g = 0usize;
for cell in &row.cells {
let end = g.saturating_add(cell.span());
if g < hi && end > lo {
(nlo, nhi, found) = (nlo.min(g), nhi.max(end), true);
}
g = end;
}
}
any = found;
if (nlo, nhi) == (lo, hi) {
break;
}
(lo, hi) = (nlo, nhi);
}
(lo, hi, any)
}
/// A copy of rows `rows` of this table holding, of each row, the cells that reach into grid
/// columns `cols` (both inclusive): what Copy takes from selected rows or cells. Content
/// controls around rows and the table aren't copied (they'd lose their other end); a cell
/// that continues a vertical merge from a row left out starts the merge instead.
pub fn slice(&self, rows: (usize, usize), cols: (usize, usize)) -> Table {
let (r0, r1) = rows;
let kept = || self.rows.iter().take(r1.saturating_add(1)).skip(r0);
let (lo, hi, any) = self.slice_bounds(rows, cols);
let mut out_rows = Vec::new();
for row in kept() {
let mut g = 0usize;
let mut cells = Vec::new();
for cell in &row.cells {
let end = g.saturating_add(cell.span());
if any && g < hi && end > lo {
cells.push(cell.clone());
}
g = end;
}
if !cells.is_empty() {
out_rows.push(Row { props: row.props.clone(), cells, controls: ControlWrap::default() });
}
}
if let Some(first) = out_rows.first_mut() {
for c in &mut first.cells {
if c.props.vmerge == VMerge::Continue {
c.props.vmerge = VMerge::Restart;
}
}
}
let whole = lo == 0 && hi >= self.grid.len();
let grid = if whole { self.grid.clone() } else { self.grid.get(lo..hi.min(self.grid.len())).map(<[f32]>::to_vec).unwrap_or_default() };
let mut props = self.props.clone();
if !whole {
// Part of the columns: as wide as they are, not the whole table's width.
if props.width.is_some() || props.width_pct.is_some() {
props.width = Some(grid.iter().sum());
}
props.width_pct = None;
}
Table { props, grid, rows: out_rows, controls: ControlWrap::default() }
}
/// A `rows × cols` table spanning `width` points, "Table Grid" style.
pub fn new(rows: usize, cols: usize, width: f32) -> Table {
let rows = rows.clamp(1, MAX_ROWS);
@@ -566,6 +640,69 @@ impl Table {
mod tests {
use super::*;
/// A 3×3 table, 100 pt columns: (0,0)–(0,1) merged across, (1,2)–(2,2) merged down.
fn merged() -> Table {
let mut t = Table::new(3, 3, 300.0);
t.merge(0, 0, 0, 1);
t.merge(1, 2, 2, 2);
assert_eq!(t.rows[0].cells.iter().map(Cell::span).collect::<Vec<_>>(), [2, 1]);
assert_eq!((t.rows[1].cells[2].props.vmerge, t.rows[2].cells[2].props.vmerge), (VMerge::Restart, VMerge::Continue));
t
}
fn spans(t: &Table) -> Vec<Vec<usize>> {
t.rows.iter().map(|r| r.cells.iter().map(Cell::span).collect()).collect()
}
/// #1744 review: a cell merged across that is partly selected takes its whole width, and the
/// other rows widen with it, so the copy isn't ragged.
#[test]
fn slice_widens_to_a_merged_cell_in_every_row() {
let t = merged().slice((0, 1), (1, 2));
assert_eq!(t.grid, [100.0, 100.0, 100.0]);
assert_eq!(spans(&t), [vec![2, 1], vec![1, 1, 1]]);
// Rows without the merged cell: just the selected columns.
let t = merged().slice((1, 2), (1, 2));
assert_eq!(t.grid, [100.0, 100.0]);
assert_eq!(spans(&t), [vec![1, 1], vec![1, 1]]);
}
/// #1744 review: a cell continuing a vertical merge from a row left out starts it instead.
#[test]
fn slice_starts_a_vertical_merge_whose_first_row_is_left_out() {
let t = merged().slice((2, 2), (0, 2));
assert_eq!(t.rows.len(), 1);
assert_eq!(t.rows[0].cells[2].props.vmerge, VMerge::Restart);
// Both rows kept: the merge is kept as it is.
let t = merged().slice((1, 2), (2, 2));
assert_eq!((t.rows[0].cells[0].props.vmerge, t.rows[1].cells[0].props.vmerge), (VMerge::Restart, VMerge::Continue));
}
/// #1744 review: part of the columns is as wide as they are; content controls around rows
/// and the table aren't copied.
#[test]
fn slice_width_and_controls() {
let mut t = Table::new(3, 3, 300.0);
(t.props.width, t.props.width_pct) = (Some(300.0), Some(50.0));
let wrap = ControlWrap { open: vec![crate::control::ContentControl::default()], close: 1 };
t.controls = wrap.clone();
t.rows[1].controls = wrap;
let part = t.slice((0, 2), (2, 2));
assert_eq!((part.grid.len(), part.props.width, part.props.width_pct), (1, Some(100.0), None));
let whole = t.slice((0, 2), (0, 2));
assert_eq!((whole.props.width, whole.props.width_pct), (Some(300.0), Some(50.0)), "all columns keep the table's width");
let row = t.slice((1, 1), (0, 2));
assert!(row.controls.is_empty() && row.rows[0].controls.is_empty());
// A table sized only by a percentage keeps a width for the part.
let mut pct = Table::new(3, 3, 300.0);
(pct.props.width, pct.props.width_pct) = (None, Some(50.0));
let part = pct.slice((0, 2), (2, 2));
assert_eq!((part.props.width, part.props.width_pct), (Some(100.0), None));
// Nothing in range: no rows.
assert!(t.slice((5, 6), (0, 2)).rows.is_empty());
assert!(t.slice((0, 2), (9, 9)).rows.is_empty());
}
#[test]
fn grid_before_shifts_the_row() {
let mut t = Table::new(2, 3, 300.0);
+96
View File
@@ -1,5 +1,7 @@
use super::*;
mod note_conversion;
#[test]
fn new_document_is_valid() {
let d = Document::new();
@@ -16,6 +18,20 @@ fn word_count() {
assert_eq!(count_words(" -- "), 0);
}
/// #4547: each Han ideograph and kana counts as a word; punctuation and Hangul spacing don't change.
#[test]
fn word_count_counts_each_chinese_and_japanese_character() {
// #4547: unspaced Chinese counted as one word per run.
assert_eq!(count_words("我们今天去公园。"), 7);
assert_eq!(count_words("WordCraft是一个文字处理器,很好用!"), 1 + 11);
assert_eq!(count_words("ひらがなとカタカナ"), 9);
assert_eq!(count_words("カタカナ"), 4);
assert_eq!(count_words("ガギ"), 2, "voiced sound marks belong to their kana");
assert_eq!(count_words("。、・「」"), 0);
// Korean is spaced: Hangul counts by tokens.
assert_eq!(count_words("안녕하세요 세계"), 2);
}
#[test]
fn word_count_includes_numbered_list_labels_but_not_bullets() {
let mut d = Document::new();
@@ -61,6 +77,23 @@ fn word_count_includes_numbered_labels_with_nothing_suffix() {
assert_eq!(d.word_count(), 5);
}
/// #4547: a generated CJK list label such as 第十二章 still counts as one word.
#[test]
fn word_count_keeps_a_cjk_list_label_one_word() {
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].format = section::NumFormat::ChineseCounting;
a.levels[0].text = "第%1章".into();
a.levels[0].start = 12;
let mut p = Paragraph::with_text("总结", CharProps::default());
p.props.numbering = Some(props::NumRef { num: num_id, level: 0 });
d.body = vec![Arc::new(Block::Para(p))];
// The label 第十二章 stays one word (#4547 changed only document text); 总结 is two.
assert_eq!(d.word_count(), 1 + 2);
}
#[test]
fn sections_and_section_mut() {
let mut d = Document::from_text("a\nb\nc");
@@ -651,3 +684,66 @@ fn control_markers_kept_are_sorted() {
assert!(kept.windows(2).all(|p| p[0] < p[1]), "sorted, no repeats: {kept:?}");
}
}
#[test]
fn lone_table_open_markers_linear_time() {
let mut d = Document::new();
let mut t = Table::new(1, 1, 100.0);
const COUNT: usize = 2_000;
t.controls.open = (0..COUNT).map(|i| ContentControl { tag: format!("ctrl_{i}"), ..Default::default() }).collect();
d.body.insert(0, Arc::new(Block::Table(t)));
let mut paras = Vec::new();
let mut tables = Vec::new();
let timer = std::time::Instant::now();
crate::control::orphans(&d.body, &mut Vec::new(), 0, &mut paras, &mut tables);
assert_eq!(paras.len(), 0);
assert_eq!(tables.len(), COUNT);
for (i, (path, nth, is_open)) in tables.iter().enumerate() {
assert_eq!(*path, Path::top(0));
assert_eq!(*nth, i);
assert!(*is_open);
}
assert!(d.has_unbalanced_controls());
let removed = d.balance_controls();
assert_eq!(removed, COUNT);
assert!(!d.has_unbalanced_controls());
assert!(timer.elapsed().as_secs_f32() < 10.0, "{:?}", timer.elapsed());
let Block::Table(tbl) = &*d.body[0] else { panic!("expected table") };
assert!(tbl.controls.open.is_empty());
}
#[test]
fn lone_table_open_markers_mixed_tables_and_paired() {
let mut d = Document::new();
let mut t0 = Table::new(1, 1, 100.0);
t0.controls.open = (0..3).map(|i| ContentControl { tag: format!("t0_{i}"), ..Default::default() }).collect();
t0.controls.close = 1;
let mut t1 = Table::new(1, 1, 100.0);
t1.controls.open = (0..2).map(|i| ContentControl { tag: format!("t1_{i}"), ..Default::default() }).collect();
d.body = vec![Arc::new(Block::Table(t0)), Arc::new(Block::Table(t1))];
let mut paras = Vec::new();
let mut tables = Vec::new();
crate::control::orphans(&d.body, &mut Vec::new(), 0, &mut paras, &mut tables);
assert_eq!(paras.len(), 0);
assert_eq!(tables, vec![(Path::top(0), 0, true), (Path::top(0), 1, true), (Path::top(1), 0, true), (Path::top(1), 1, true),]);
let removed = d.balance_controls();
assert_eq!(removed, 4);
assert!(!d.has_unbalanced_controls());
let Block::Table(res0) = &*d.body[0] else { panic!("expected table") };
assert_eq!(res0.controls.open.len(), 1);
assert_eq!(res0.controls.open[0].tag, "t0_2");
assert_eq!(res0.controls.close, 1);
let Block::Table(res1) = &*d.body[1] else { panic!("expected table") };
assert!(res1.controls.open.is_empty());
assert_eq!(res1.controls.close, 0);
}
+145
View File
@@ -0,0 +1,145 @@
use super::*;
use crate::para::{InlineObject, NoteKind, OBJ};
fn reference(id: u32, kind: NoteKind) -> InlineObject {
InlineObject::NoteRef { kind, id, custom: "*".into() }
}
fn styled(len: usize, style: &str) -> Run {
Run { len, props: CharProps { style: Some(style.into()), italic: Some(true), ..Default::default() } }
}
#[test]
fn conversion_preserves_reference_run_boundaries_and_unmatched_storage() {
for (foot, end, expected) in [
(true, false, ["FootnoteReference", "EndnoteReference", "FootnoteReference", "EndnoteReference", "EndnoteReference", "FootnoteReference"]),
(false, true, ["FootnoteReference", "FootnoteReference", "FootnoteReference", "EndnoteReference", "FootnoteReference", "FootnoteReference"]),
(true, true, ["FootnoteReference", "EndnoteReference", "FootnoteReference", "EndnoteReference", "FootnoteReference", "FootnoteReference"]),
] {
let mut d = Document::new();
let note = |style: &str| {
let mut p = Paragraph::with_text("Original note 漢", CharProps::default());
p.props.style = Some(style.into());
p.mark.style = Some(style.into());
vec![para_block(p)]
};
let f = d.add_part(PartKind::Footnote, note("FootnoteText"));
let e = d.add_part(PartKind::Endnote, note("EndnoteText"));
let p = Paragraph {
text: format!("é{OBJ}{OBJ}漢{OBJ}{OBJ}"),
objects: vec![
reference(f, NoteKind::Footnote),
InlineObject::BookmarkStart { name: "kept".into() },
reference(e, NoteKind::Endnote),
reference(999, NoteKind::Footnote),
reference(f, NoteKind::Footnote),
],
runs: vec![
styled(2, "FootnoteReference"),
styled(3, "FootnoteReference"),
styled(3, "FootnoteReference"),
styled(3, "EndnoteReference"),
styled(3, "EndnoteReference"),
styled(3, "FootnoteReference"),
],
..Paragraph::new()
};
let mut table = Table::new(1, 1, 200.0);
table.rows[0].cells[0].blocks = vec![para_block(p.clone())];
d.body = vec![para_block(p.clone()), Arc::new(Block::Table(table))];
let h = d.add_part(PartKind::Header, vec![para_block(p.clone())]);
let t = d.add_part(PartKind::TextBox, vec![para_block(p.clone())]);
let before = d.clone();
assert_eq!(d.convert_notes(foot, end), usize::from(foot) + usize::from(end));
for (story, path) in [
(StoryRef::Body, Path::top(0)),
(StoryRef::Body, Path(vec![1, 0, 0, 0])),
(StoryRef::Part(h), Path::top(0)),
(StoryRef::Part(t), Path::top(0)),
] {
let got = d.para(story, &path).unwrap();
assert_eq!(got.text, p.text);
assert_eq!(got.runs.iter().map(|r| r.props.style.as_deref().unwrap()).collect::<Vec<_>>(), expected);
assert!(got.runs.iter().all(|r| r.props.italic == Some(true)));
assert_eq!(got.objects[1], p.objects[1]);
assert_eq!(got.objects[3], p.objects[3], "a missing note stays unchanged");
assert_eq!(got.objects[0], reference(f, if foot { NoteKind::Endnote } else { NoteKind::Footnote }));
assert_eq!(got.objects[2], reference(e, if end { NoteKind::Footnote } else { NoteKind::Endnote }));
assert_eq!(got.objects[4], got.objects[0], "surplus object storage retains the existing conversion");
}
for (id, flipped, from, to) in [(f, foot, "FootnoteText", "EndnoteText"), (e, end, "EndnoteText", "FootnoteText")] {
let got = d.para(StoryRef::Part(id), &Path::top(0)).unwrap();
assert_eq!(got.text, before.plain_text(StoryRef::Part(id)));
assert_eq!(got.props.style.as_deref(), Some(if flipped { to } else { from }));
assert_eq!(got.mark.style, got.props.style);
}
let mut missing = Paragraph {
text: format!("{OBJ}{OBJ}"),
objects: vec![reference(f, if foot { NoteKind::Endnote } else { NoteKind::Footnote })],
runs: vec![styled(0, "FootnoteReference"), styled(3, "FootnoteReference"), styled(3, "FootnoteReference")],
..Paragraph::new()
};
missing.runs.push(styled(usize::MAX, "FootnoteReference"));
d.body = vec![para_block(missing)];
d.convert_notes(true, true);
let got = d.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!(
got.runs.iter().map(|r| r.props.style.as_deref().unwrap()).collect::<Vec<_>>(),
["FootnoteReference", "EndnoteReference", "FootnoteReference", "FootnoteReference"]
);
}
}
fn dense(n: usize) -> Document {
let mut d = Document::new();
let id = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Shared original note", CharProps::default()))]);
d.body = vec![para_block(Paragraph {
text: format!("{}{}", OBJ.to_string().repeat(n), "x".repeat(n)),
objects: (0..n).map(|_| reference(id, NoteKind::Footnote)).collect(),
runs: (0..n).map(|_| styled(OBJ.len_utf8(), "FootnoteReference")).chain((0..n).map(|_| styled(1, "FootnoteReference"))).collect(),
..Paragraph::new()
})];
d
}
// Thread CPU excludes scheduler delays. Missing or nonadvancing counters provide no timing
// evidence; the semantic assertions still run on those platforms.
fn cpu_cost(f: impl FnOnce()) -> Option<f64> {
let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
let before = ran();
f();
match (before, ran()) {
(Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9),
_ => None,
}
}
#[test]
fn conversion_work_scales_with_reference_and_formatting_runs() {
let (small, big) = (dense(2_000), dense(16_000));
let convert = |input: &Document| {
let mut d = input.clone();
assert_eq!(d.convert_notes(true, false), 1);
let p = d.para(StoryRef::Body, &Path::top(0)).unwrap();
let n = p.objects.len();
assert!(p.runs[..n].iter().all(|r| r.props.style.as_deref() == Some("EndnoteReference")));
assert!(p.runs[n..].iter().all(|r| r.props.style.as_deref() == Some("FootnoteReference")));
std::hint::black_box(d);
};
convert(&small);
convert(&big);
let (mut t1, mut t8) = (f64::INFINITY, f64::INFINITY);
for _ in 0..3 {
let a = cpu_cost(|| (0..8).for_each(|_| convert(&small)));
let b = cpu_cost(|| convert(&big));
if let (Some(a), Some(b)) = (a, b) {
t1 = t1.min(a / 8.0);
t8 = t8.min(b);
}
}
if t1.is_finite() && t8.is_finite() {
let ratio = t8 / t1;
eprintln!("note conversion CPU: small={t1:.6}s big={t8:.6}s ratio={ratio:.2}");
assert!(ratio < 24.0, "8x the references and runs cost {ratio:.2}x CPU");
}
}
+30 -3
View File
@@ -3,9 +3,36 @@ use super::parts::PartKind;
use super::position::Path;
use super::{InlineObject, Numbering, numbering, para, section, styles};
/// Words in `s` (tokens separated by whitespace that contain at least one alphanumeric char).
/// Words in `s`, as Word counts them: tokens separated by whitespace that contain at least one
/// alphanumeric char, plus each Han ideograph and kana as a word of its own (Chinese and Japanese
/// are written without spaces, #4547; Korean is spaced, so Hangul counts by tokens).
pub fn count_words(s: &str) -> usize {
s.split(|c: char| c.is_whitespace() || c == para::OBJ).filter(|w| w.chars().any(char::is_alphanumeric)).count()
// The half-width voiced sound marks belong to the kana before them, not a word of their own.
let word_char = |c: char| c.is_alphanumeric() && !matches!(c, '\u{FF9E}' | '\u{FF9F}');
let tokens = s.split(|c: char| c.is_whitespace() || c == para::OBJ || is_cjk_word(c)).filter(|w| w.chars().any(word_char)).count();
tokens.saturating_add(s.chars().filter(|c| is_cjk_word(*c)).count())
}
/// Words in a generated list label: whitespace-separated tokens holding a letter or digit, so a
/// CJK label such as 第十二章 stays one word (how Word counts those is unverified).
fn count_label_words(s: &str) -> usize {
s.split(char::is_whitespace).filter(|w| w.chars().any(char::is_alphanumeric)).count()
}
/// A Han ideograph or kana letter (not CJK punctuation), which Word counts as one word.
fn is_cjk_word(c: char) -> bool {
matches!(c,
'\u{3005}'..='\u{3007}' // 々 〆 〇
| '\u{3031}'..='\u{3035}' | '\u{303B}' // vertical kana repeat marks, 〻
| '\u{3040}'..='\u{30FF}' // hiragana, katakana
| '\u{31F0}'..='\u{31FF}' // katakana phonetic extensions
| '\u{3400}'..='\u{4DBF}' // extension A
| '\u{4E00}'..='\u{9FFF}' // unified ideographs
| '\u{F900}'..='\u{FAFF}' // compatibility ideographs
| '\u{FF66}'..='\u{FF9D}' // half-width katakana
| '\u{1B000}'..='\u{1B16F}' // kana supplement and extended
| '\u{20000}'..='\u{3FFFF}' // extensions B and later
) && c.is_alphanumeric()
}
/// How deep in nested tables [`Document::para_paths`] goes: cells below this are skipped.
@@ -146,7 +173,7 @@ pub(crate) fn story_word_count_of(
&& 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_label_words(&label));
}
total = total.saturating_add(count_words(&p.plain_text()));
}
+30 -6
View File
@@ -90,13 +90,16 @@ pub(crate) fn parse(root: &El, seen: &mut Seen, room: usize) -> Sources {
let tag = child_text(src_el, "Tag");
let raw_kind = child_text(src_el, "SourceType");
let kind = crate::write::from_source_type(&raw_kind);
let (author, editor) = match src_el.els().find(|e| e.local() == "Author") {
let (author, corporate, editor) = match src_el.els().find(|e| e.local() == "Author") {
Some(a) => {
let auth = a.els().find(|e| e.local() == "Author").map(parse_persons).filter(|s| !s.is_empty()).unwrap_or_else(|| parse_persons(a));
let role = a.els().find(|e| e.local() == "Author");
let auth = role.map(parse_persons).filter(|s| !s.is_empty()).unwrap_or_else(|| parse_persons(a));
// Word's Corporate Author: one name, kept whole (`b:Corporate`, #4848).
let corporate = role.map(|r| child_text(r, "Corporate").trim().to_string()).unwrap_or_default();
let ed = a.els().find(|e| e.local() == "Editor").map(parse_persons).unwrap_or_default();
(auth, ed)
(auth, corporate, ed)
}
None => (String::new(), String::new()),
None => (String::new(), String::new(), String::new()),
};
let editor = if editor.is_empty() { src_el.els().find(|e| e.local() == "Editor").map(parse_persons).unwrap_or_default() } else { editor };
let title = child_text(src_el, "Title");
@@ -132,11 +135,12 @@ pub(crate) fn parse(root: &El, seen: &mut Seen, room: usize) -> Sources {
};
// Keep sources with some content, once each (by tag, or title and author untagged).
if tag.is_empty() && title.is_empty() && author.is_empty() && editor.is_empty() {
if tag.is_empty() && title.is_empty() && author.is_empty() && corporate.is_empty() && editor.is_empty() {
continue;
}
let fresh = if tag.is_empty() {
seen.untagged.insert((title.clone(), if author.is_empty() { editor.clone() } else { author.clone() }))
let who = [&author, &corporate, &editor].into_iter().find(|w| !w.is_empty()).cloned().unwrap_or_default();
seen.untagged.insert((title.clone(), who))
} else {
seen.tags.insert(tag.clone())
};
@@ -147,6 +151,7 @@ pub(crate) fn parse(root: &El, seen: &mut Seen, room: usize) -> Sources {
tag,
kind,
author,
corporate,
title,
year,
city,
@@ -265,6 +270,25 @@ mod tests {
assert!(read_sources(br#"<o:Sources xmlns:o="urn:example:other"><o:Source/></o:Sources>"#).is_err(), "an unrelated namespace");
}
/// #4848: a corporate author (Word's Corporate Author box, `b:Corporate`) is read whole, and
/// an untagged source with only a corporate author is still kept, once.
#[test]
fn a_corporate_author_is_read_whole() {
let xml = br#"<b:Sources xmlns:b="http://schemas.openxmlformats.org/officeDocument/2006/bibliography">
<b:Source><b:Tag>Wor20</b:Tag><b:SourceType>Report</b:SourceType>
<b:Author><b:Author><b:Corporate> World Health Organization </b:Corporate></b:Author></b:Author>
<b:Title>World report on hearing</b:Title><b:Year>2020</b:Year></b:Source>
<b:Source><b:SourceType>Book</b:SourceType>
<b:Author><b:Author><b:Corporate>Harbor Guild, Inc.</b:Corporate></b:Author></b:Author></b:Source>
<b:Source><b:SourceType>Book</b:SourceType>
<b:Author><b:Author><b:Corporate>Harbor Guild, Inc.</b:Corporate></b:Author></b:Author></b:Source>
</b:Sources>"#;
let f = read_sources(xml).unwrap();
assert_eq!(f.sources.len(), 2, "{:?}", f.sources);
assert_eq!((f.sources[0].corporate.as_str(), f.sources[0].author.as_str()), ("World Health Organization", ""));
assert_eq!(f.sources[1].corporate, "Harbor Guild, Inc.", "a comma doesn't split a corporate name");
}
/// Word's own 2004/10 bibliography namespace (`Sources.xml` written by scripts and older
/// builds, the VBA `Source.XML` format) reads as well as OOXML's.
#[test]
+19
View File
@@ -193,6 +193,25 @@ fn hostile_encryption_info_errors_without_panicking() {
assert!(decrypt(&fake, Some("pw")).is_err());
}
/// The key a real-world Standard-encrypted package derives: Apache POI's test file
/// `protected_passtika.xlsx` (Apache-2.0 test data), password "tika", written by Excel
/// itself. The value was computed from [MS-OFFCRYPTO] §2.3.4.7 independently of this code —
/// the file decrypts to a valid package under it and nothing else opens that file — so real
/// Word 2007 documents stay pinned, not just spec-derived ones.
#[test]
fn standard_key_matches_a_real_world_package() {
let salt: [u8; 16] = [0x19, 0xd0, 0xc7, 0xf3, 0x95, 0x28, 0x78, 0x43, 0x6a, 0x49, 0x49, 0xd7, 0xf0, 0xa2, 0xcf, 0x1e];
const KEY: &str = "8d4d00143a5f2e4918af7e6e7410d46c";
let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
assert_eq!(hex(&standard_key(&salt, "tika", 16)), KEY);
// A different password or salt gives a different key; the key is exactly the length
// asked for, for each AES width.
assert_ne!(hex(&standard_key(&salt, "tika2", 16)), KEY);
assert_ne!(hex(&standard_key(&[0; 16], "tika", 16)), KEY);
assert_eq!(standard_key(&salt, "tika", 24).len(), 24);
assert_eq!(standard_key(&salt, "tika", 32).len(), 32);
}
/// Standard encryption's key derivation (§2.3.4.7) for the password "Password1234_" and the
/// salt 00 01 … 0F. The expected keys were computed outside WordCraft (Python's hashlib)
/// following the specification's steps, so the encryptor and decryptor can't share a mistake.
+8 -3
View File
@@ -25,11 +25,11 @@ pub fn read(root: &El) -> Vec<CustomProp> {
let Some(v) = p.els().next() else { continue };
let kind = v.local();
let prop = if SIMPLE.contains(&kind) {
CustomProp { name: name.to_string(), kind: kind.to_string(), value: v.text() }
CustomProp { name: name.to_string(), kind: kind.to_string(), value: v.text(), link_target: p.attr("linkTarget").map(str::to_string) }
} else {
let mut xml = String::new();
el_xml(v, &mut xml, 0);
CustomProp { name: name.to_string(), kind: "raw".into(), value: xml }
CustomProp { name: name.to_string(), kind: "raw".into(), value: xml, link_target: p.attr("linkTarget").map(str::to_string) }
};
out.push(prop);
}
@@ -51,7 +51,12 @@ pub fn write(props: &[CustomProp]) -> Vec<u8> {
if p.kind == "raw" && !raw_is_wellformed(&p.value) {
continue;
}
w.open("property", &[("fmtid", FMTID), ("pid", &pid.to_string()), ("name", &p.name)]);
let pid = pid.to_string();
let mut attrs = vec![("fmtid", FMTID), ("pid", &pid), ("name", &p.name)];
if let Some(target) = &p.link_target {
attrs.push(("linkTarget", target));
}
w.open("property", &attrs);
if p.kind == "raw" {
w.raw(&p.value);
} else {
+8 -6
View File
@@ -210,18 +210,20 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
if let Some(p) = part(rt::SETTINGS) {
lenient("settings", r.read_settings(&p));
}
if let Some(p) = part(rt::FOOTNOTES) {
lenient("footnotes", r.read_notes(&p, true));
}
if let Some(p) = part(rt::ENDNOTES) {
lenient("endnotes", r.read_notes(&p, false));
}
// Comments before notes: a comment range inside a footnote or endnote resolves its id against
// the comments already read, and was dropped when the notes came first.
if let Some(p) = part(rt::COMMENTS) {
let ex = part(rt::COMMENTS_EX);
let ids = part(rt::COMMENTS_IDS);
let cex = part(rt::COMMENTS_EXTENSIBLE);
lenient("comments", r.read_comments(&p, ex.as_deref(), ids.as_deref(), cex.as_deref()));
}
if let Some(p) = part(rt::FOOTNOTES) {
lenient("footnotes", r.read_notes(&p, true));
}
if let Some(p) = part(rt::ENDNOTES) {
lenient("endnotes", r.read_notes(&p, false));
}
// Bibliography sources live in a custom XML data store (`b:Sources`), related from the
// package root or the main part; Word's own files sometimes leave the relationship out, so
// the conventional `customXml/itemN.xml` names are tried too. Each part is read at most once.
+4 -2
View File
@@ -23,8 +23,8 @@ fn text_frame(k: &El) -> wordcraft_doc::props::TextFrame {
let word = |name: &str, allowed: &[&str], default: &str| k.attr(name).filter(|v| allowed.contains(v)).unwrap_or(default).to_string();
let align = |name: &str, allowed: &[&str]| k.attr(name).filter(|v| allowed.contains(v)).map(str::to_string);
wordcraft_doc::props::TextFrame {
w: len("w:w").filter(|v| *v > 0.0),
h: len("w:h").map(f32::abs).filter(|v| *v > 0.0),
w: len("w:w").filter(|v| *v >= 0.0),
h: len("w:h").map(f32::abs),
h_exact: k.attr("w:hRule") == Some("exact"),
x: len("w:x").unwrap_or(0.0),
y: len("w:y").unwrap_or(0.0),
@@ -258,6 +258,8 @@ impl PropCtx {
"w:imprint" => c.engrave = Some(on_off(k)),
"w:noProof" => c.no_proof = Some(on_off(k)),
"w:vanish" => c.hidden = Some(on_off(k)),
"w:webHidden" => c.web_hidden = Some(on_off(k)),
"w:snapToGrid" => c.snap_to_grid = Some(on_off(k)),
"w:rtl" => c.rtl = Some(on_off(k)),
"w:color" => {
c.color = match (self.theme_color(k, "w:themeColor", "w:themeTint", "w:themeShade"), k.attr("w:val")) {
+18 -1
View File
@@ -42,6 +42,13 @@ pub(crate) fn ui_style_name(n: &str) -> String {
n.to_string()
}
/// The outline level (0 for Heading 1) of Word's built-in style named `heading 1` … `heading 9`.
fn builtin_heading_level(name: &str) -> Option<u8> {
let name = name.trim();
let digit = name.get(..8).filter(|p| p.eq_ignore_ascii_case("heading ")).and(name.get(8..))?;
digit.parse::<u8>().ok().filter(|d| digit.len() == 1 && (1..=9).contains(d)).map(|d| d - 1)
}
impl Reader<'_> {
pub(super) fn read_styles(&mut self, path: &str) -> Result<(), DocxError> {
/// Largest `w:latentStyles` kept (Word's has ~380 exceptions, ~40 KB).
@@ -115,7 +122,7 @@ impl Reader<'_> {
priority: s.child_val("w:uiPriority").and_then(u32_of),
quick: flag(s, "w:qFormat").unwrap_or(false),
hidden: flag(s, "w:semiHidden").unwrap_or(false) || flag(s, "w:hidden").unwrap_or(false),
builtin: !s.attr("w:customStyle").is_some_and(|v| v == "1" || v == "true"),
builtin: !s.attr("w:customStyle").is_some_and(|v| matches!(v.trim(), "1" | "true" | "on")),
locked: flag(s, "w:locked").unwrap_or(false),
// Only where the writer couldn't tell from the id (it flags the English ids).
default: s.attr("w:default").is_some_and(|v| v == "1" || v == "true")
@@ -131,6 +138,16 @@ impl Reader<'_> {
if let Some(p) = s.child("w:pPr") {
st.para = self.pc.ppr(p).0;
}
// Word gives its built-in heading styles their outline level whether or not the file
// writes `w:outlineLvl` (some producers leave it out): "heading 2" is level 2 in the
// Navigation pane and the table of contents, whatever its id (#4696).
if kind == StyleKind::Paragraph
&& st.builtin
&& st.para.outline_level.is_none()
&& let Some(level) = s.child_val("w:name").and_then(builtin_heading_level)
{
st.para.outline_level = Some(level);
}
if let Some(rp) = s.child("w:rPr") {
st.chr = self.pc.rpr(rp);
}
+9 -3
View File
@@ -121,10 +121,16 @@ pub(super) fn bibliography_xml(doc: &Document) -> Vec<u8> {
w.open("b:Source", &[]);
w.leaf("b:Tag", &[], &src.tag);
w.leaf("b:SourceType", &[], to_source_type(&src.kind));
// Author & Editor
if !src.author.is_empty() || !src.editor.is_empty() {
// Author & Editor. A role holds a name list or a corporate name, not both (ECMA-376
// `CT_NameOrCorporateType`), so a corporate author takes the author's place (#4848).
let corporate = src.corporate.trim();
if !src.author.is_empty() || !corporate.is_empty() || !src.editor.is_empty() {
w.open("b:Author", &[]);
if !src.author.is_empty() {
if !corporate.is_empty() {
w.open("b:Author", &[]);
w.leaf("b:Corporate", &[], corporate);
w.close("b:Author");
} else if !src.author.is_empty() {
w.open("b:Author", &[]);
write_name_list(&mut w, &src.author);
w.close("b:Author");
+5 -3
View File
@@ -429,13 +429,15 @@ fn write_comments(
("xmlns:mc", "http://schemas.openxmlformats.org/markup-compatibility/2006"),
],
);
let pid_of = |cid: u32| comment_paras.iter().find(|(c, _)| *c == cid).map(|(_, p)| p.clone());
// Assigned in BTreeMap comment-ID order above, including gaps for missing parts.
let pid_of =
|cid: u32| comment_paras.binary_search_by_key(&cid, |(c, _)| *c).ok().and_then(|i| comment_paras.get(i)).map(|(_, p)| p.as_str());
for (cid, c) in &doc.comments {
let Some(pid) = pid_of(*cid) else { continue };
let done = if c.resolved { "1" } else { "0" };
match c.parent.and_then(pid_of) {
Some(parent) => w.empty("w15:commentEx", &[("w15:paraId", &pid), ("w15:paraIdParent", &parent), ("w15:done", done)]),
None => w.empty("w15:commentEx", &[("w15:paraId", &pid), ("w15:done", done)]),
Some(parent) => w.empty("w15:commentEx", &[("w15:paraId", pid), ("w15:paraIdParent", parent), ("w15:done", done)]),
None => w.empty("w15:commentEx", &[("w15:paraId", pid), ("w15:done", done)]),
}
}
w.close("w15:commentsEx");
+2
View File
@@ -53,7 +53,9 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) {
toggle(w, "w:emboss", c.emboss);
toggle(w, "w:imprint", c.engrave);
toggle(w, "w:noProof", c.no_proof);
toggle(w, "w:snapToGrid", c.snap_to_grid);
toggle(w, "w:vanish", c.hidden);
toggle(w, "w:webHidden", c.web_hidden);
match c.color {
Some(TextColor::Auto) => w.val("w:color", "auto"),
Some(TextColor::Rgb(rgb)) => w.val("w:color", &rgb.hex()),
+3 -1
View File
@@ -172,6 +172,8 @@ pub(super) fn write_comment_ids(
if comment_paras.is_empty() {
return;
}
// Keep the existing exact-string reservation rule, including comments without a written part.
let reserved: std::collections::HashSet<&str> = doc.comments.values().filter_map(|c| c.durable_id.as_deref()).collect();
let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut next = 0x1000_0000u32;
let mut rows: Vec<(String, String, Option<String>)> = Vec::new();
@@ -183,7 +185,7 @@ pub(super) fn write_comment_ids(
None => loop {
next = next.saturating_add(1);
let d = format!("{next:08X}");
if !used.contains(&d) && !doc.comments.values().any(|o| o.durable_id.as_deref() == Some(d.as_str())) {
if !used.contains(&d) && !reserved.contains(d.as_str()) {
break d;
}
},
+32
View File
@@ -155,6 +155,38 @@ fn zotero_fields_and_prefs_round_trip() {
assert_eq!(read(&write(&d2).unwrap()).unwrap().custom_props, d.custom_props);
}
#[test]
fn custom_property_bookmark_links_survive_saves_and_value_edits() {
let props = r#"<c:Properties xmlns:c="http://schemas.openxmlformats.org/officeDocument/2006/custom-properties" xmlns:v="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">
<c:property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="2" name="Linked" linkTarget="OriginalBookmark"><v:lpwstr>Original value</v:lpwstr></c:property>
<c:property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="3" name="Raw" linkTarget="RawBookmark"><v:vector size="1" baseType="lpwstr"><v:lpwstr>Ω</v:lpwstr></v:vector></c:property>
<c:property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="4" name="Unlinked"><v:i4>17</v:i4></c:property>
</c:Properties>"#;
let doc = format!(
r#"<w:document {W_NS}><w:body><w:p><w:bookmarkStart w:id="1" w:name="OriginalBookmark"/>{}<w:bookmarkEnd w:id="1"/></w:p><w:p><w:bookmarkStart w:id="2" w:name="RawBookmark"/>{}<w:bookmarkEnd w:id="2"/></w:p></w:body></w:document>"#,
t("Original value"),
t("Ω")
);
let bytes = zip(&[("_rels/.rels", ROOT_RELS.as_bytes()), ("word/document.xml", doc.as_bytes()), ("docProps/custom.xml", props.as_bytes())]);
let mut d = read(&bytes).unwrap();
for value in ["Original value", "Edited value"] {
d.set_custom_prop("linked", value);
let before = d.custom_props.clone();
for _ in 0..2 {
let out = write(&d).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&out)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut z.by_name("docProps/custom.xml").unwrap(), &mut xml).unwrap();
assert!(xml.contains(r#"linkTarget="OriginalBookmark""#), "{xml}");
assert!(xml.contains(r#"linkTarget="RawBookmark""#), "{xml}");
assert_eq!(xml.matches("linkTarget=").count(), 2, "unlinked properties stay unlinked");
d = read(&out).unwrap();
assert_eq!(d.custom_props, before);
assert_eq!(d.custom_prop("Linked"), Some(value));
}
}
}
#[test]
fn unterminated_and_resultless_addin_fields() {
// A field left open at the end of the document, and one with no result at all.
+55
View File
@@ -0,0 +1,55 @@
//! Saving large comment threads must not search all comments for each emitted ID.
use wordcraft_doc::{Comment, Document, Paragraph, Part, PartKind, para_block};
fn comments(n: u32, imported: bool) -> Document {
let mut d = Document::from_text("Original comment save fixture.");
for i in 0..n {
// Sparse IDs and replies to a later comment also exercise paragraph-ID lookup order.
let id = i * 3 + 7;
d.parts.insert(id, Part { kind: PartKind::Comment, blocks: vec![para_block(Paragraph::new())], ..Default::default() });
d.comments.insert(
id,
Comment { part: id, parent: (i + 1 < n).then_some(id + 3), durable_id: imported.then(|| format!("{:08X}", i + 1)), ..Default::default() },
);
}
d
}
fn cpu_cost(mut f: impl FnMut()) -> f64 {
let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
let (wall, before) = (std::time::Instant::now(), ran());
f();
match (before, ran()) {
(Some(a), Some(b)) if b > a => (b - a) as f64 * 1e-9,
_ => wall.elapsed().as_secs_f64(),
}
}
#[test]
fn many_comments_save_without_quadratic_id_searches() {
for imported in [true, false] {
let small = comments(4_000, imported);
let big = comments(32_000, imported);
let save = |d: &Document| drop(std::hint::black_box(wordcraft_docx::write(d).unwrap()));
save(&small);
save(&big);
let mut ratios = Vec::new();
// Alternating inputs and thread CPU time leave scheduler delays out on Linux. Elsewhere
// use elapsed time; retry ratios far from linear before declaring a regression.
for _ in 0..3 {
let (mut s, mut b) = (f64::INFINITY, f64::INFINITY);
for _ in 0..3 {
s = s.min(cpu_cost(|| (0..8).for_each(|_| save(&small))) / 8.0);
b = b.min(cpu_cost(|| save(&big)));
}
let ratio = b / s.max(1e-9);
eprintln!("comments imported={imported}: {s:.4}s -> {b:.4}s, ratio {ratio:.2}");
if ratio < 24.0 {
break;
}
ratios.push(ratio);
}
assert!(ratios.len() < 3, "8x comments took {ratios:?}x CPU: repeated ID searches");
}
}
+62
View File
@@ -114,6 +114,68 @@ fn paragraph_default_presence_survives_repeated_saves() {
}
}
#[test]
fn zero_frame_keeps_normal_flow() {
const ROUND_TRIPS: usize = 2;
let zero = r#"<w:framePr w:w="0" w:h="0" w:xAlign="left" w:y="0" w:hRule="exact" w:vAnchor="margin"/>"#;
let sized = r#"<w:framePr w:w="2400" w:h="1200" w:x="1440" w:y="2880" w:hAnchor="page" w:vAnchor="page" w:hRule="exact"/>"#;
for frame in [zero, sized] {
let styles = format!(
r#"<w:styles {W_NS}><w:docDefaults><w:pPrDefault><w:pPr>{frame}<w:spacing w:after="160"/></w:pPr></w:pPrDefault></w:docDefaults>
<w:style w:type="paragraph" w:styleId="Framed"><w:name w:val="Framed"/><w:pPr>{sized}</w:pPr></w:style></w:styles>"#
);
let header = format!(r#"<w:hdr {W_NS}><w:p><w:r><w:t>Header alpha</w:t></w:r></w:p><w:p><w:r><w:t>Header beta</w:t></w:r></w:p></w:hdr>"#);
let footer = format!(r#"<w:ftr {W_NS}><w:p><w:r><w:t>Footer delta</w:t></w:r></w:p></w:ftr>"#);
let body = format!(
r#"<w:p><w:r><w:t>Body one</w:t></w:r></w:p>
<w:p><w:pPr>{sized}</w:pPr><w:r><w:t>Direct frame</w:t></w:r></w:p>
<w:p><w:pPr><w:pStyle w:val="Framed"/></w:pPr><w:r><w:t>Styled frame</w:t></w:r></w:p>
<w:p><w:pPr><w:pStyle w:val="Framed"/>{zero}</w:pPr><w:r><w:t>Frame reset</w:t></w:r></w:p>
<w:sectPr><w:headerReference w:type="default" r:id="header"/><w:footerReference w:type="default" r:id="footer"/></w:sectPr>"#
);
let mut document = wordcraft_docx::read(&docx(
&body,
&[("styles", "styles", "styles.xml"), ("header", "header", "header1.xml"), ("footer", "footer", "footer1.xml")],
&[("word/styles.xml", &styles), ("word/header1.xml", &header), ("word/footer1.xml", &footer)],
))
.unwrap();
for pass in 0..=ROUND_TRIPS {
let body = paras(&document);
let resolved = document.styles.resolve_para(&body[0].props);
assert!(resolved.frame.is_none(), "document defaults never create a frame");
assert!(document.styles.default_para.frame.is_none());
let reset = body[3].props.frame.as_ref().unwrap();
assert_eq!((reset.w, reset.h), (Some(0.0), Some(0.0)), "the direct reset survives saving");
assert_eq!(resolved.space_after, 8.0);
assert!(document.styles.resolve_para(&body[1].props).frame.is_some());
assert!(document.styles.resolve_para(&body[2].props).frame.is_some());
assert!(document.styles.resolve_para(&body[3].props).frame.is_none());
for id in [document.last_section.headers.default, document.last_section.footers.default] {
for block in &document.parts.get(&id.unwrap()).unwrap().blocks {
let paragraph = block.as_para().unwrap();
assert!(document.styles.resolve_para(&paragraph.props).frame.is_none(), "defaults do not frame headers or footers");
}
}
if pass == ROUND_TRIPS {
break;
}
document = wordcraft_docx::read(&wordcraft_docx::write(&document).unwrap()).unwrap();
}
}
}
#[test]
fn frame_zero_side_is_automatic() {
for (attrs, width, height) in
[(r#"w:w="0" w:h="1200""#, None, Some(60.0)), (r#"w:w="2400" w:h="0" w:hRule="exact""#, Some(120.0), None), ("", None, None)]
{
let document = read_body(&format!(r#"<w:p><w:pPr><w:framePr {attrs}/></w:pPr><w:r><w:t>Original automatic frame</w:t></w:r></w:p>"#));
let paragraph = paras(&document)[0];
let frame = document.styles.resolve_para(&paragraph.props).frame.unwrap();
assert_eq!((frame.w, frame.h), (width, height));
}
}
#[test]
fn omitted_paragraph_defaults_can_be_edited() {
let styles =
+57
View File
@@ -0,0 +1,57 @@
//! #4696: Word's built-in heading styles have their outline level even when the file's
//! `styles.xml` doesn't write `w:outlineLvl` (some producers leave it out). Word for Windows,
//! opening such a file, reports `OutlineLevel` 1 and 2 for "heading 1" and "heading 2" (whatever
//! their style ids) and body text for a custom style.
use std::io::Write;
use wordcraft_doc::Paragraph;
const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
fn package(styles: &str, body: &str) -> Vec<u8> {
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
let doc = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="{W}"><w:body>{body}</w:body></w:document>"#);
let styles = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="{W}">{styles}</w:styles>"#);
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, body) in
[("_rels/.rels", root), ("word/document.xml", doc.as_str()), ("word/_rels/document.xml.rels", rels), ("word/styles.xml", styles.as_str())]
{
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(body.as_bytes()).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn para(style: &str, text: &str) -> String {
format!(r#"<w:p><w:pPr><w:pStyle w:val="{style}"/></w:pPr><w:r><w:t>{text}</w:t></w:r></w:p>"#)
}
fn levels(d: &wordcraft_doc::Document) -> Vec<Option<u8>> {
d.body.iter().filter_map(|b| b.as_para()).map(|p: &Paragraph| d.styles.resolve_para(&p.props).outline_level).collect()
}
#[test]
fn built_in_heading_styles_have_their_outline_level_without_outline_lvl() {
let styles = r#"<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/></w:style>
<w:style w:type="paragraph" w:styleId="Titolo2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/></w:style>
<w:style w:type="paragraph" w:styleId="Heading9"><w:name w:val="Heading 9"/></w:style>
<w:style w:type="paragraph" w:styleId="Below"><w:name w:val="Below heading 2"/><w:basedOn w:val="Titolo2"/></w:style>
<w:style w:type="paragraph" w:customStyle="1" w:styleId="MyHead"><w:name w:val="My heading 3"/><w:basedOn w:val="Normal"/></w:style>
<w:style w:type="paragraph" w:styleId="Heading10"><w:name w:val="heading 10"/></w:style>
<w:style w:type="paragraph" w:customStyle="on" w:styleId="Fake4"><w:name w:val="heading 4"/></w:style>
<w:style w:type="paragraph" w:styleId="Heading3"><w:name w:val="heading 3"/><w:pPr><w:outlineLvl w:val="5"/></w:pPr></w:style>"#;
let body = ["Heading1", "Titolo2", "Heading9", "Below", "MyHead", "Heading10", "Fake4", "Heading3", "Normal"]
.iter()
.map(|s| para(s, "x"))
.collect::<String>();
let d = wordcraft_docx::read(&package(styles, &body)).unwrap();
// Heading 1, 2 and 9 by name; a style based on a heading inherits its level; a custom style,
// "heading 10", a custom style named "heading 4" (`w:customStyle="on"`) and Normal have none; a
// level the file gives is kept.
let want = vec![Some(0), Some(1), Some(8), Some(1), None, None, None, Some(5), None];
assert_eq!(levels(&d), want);
// The level survives a save and reopen.
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(levels(&again), want);
}
+5
View File
@@ -3,6 +3,7 @@ mod bidi_runs;
mod cell_margin_inheritance;
mod char_units;
mod citations;
mod comment_save_cost;
mod complex_script_inheritance;
mod content_control_save_cost;
mod content_controls;
@@ -13,14 +14,17 @@ mod east_asian_lang;
mod east_asian_layout;
mod fixtures;
mod glossary;
mod heading_levels;
mod legacy_forms;
mod ligatures;
mod malformed;
mod note_comments;
mod passthrough;
mod positional_tabs;
mod privacy;
mod roundtrip;
mod row_property_exceptions;
mod run_props;
mod section_form_protection;
mod section_page_number_type;
mod section_text_direction;
@@ -28,6 +32,7 @@ mod section_valign;
mod settings_merge;
mod text_boxes;
mod toc_ranges;
mod undefined_style;
mod vml_shapes;
mod word_xml;
mod wordml2003_write;
+68
View File
@@ -0,0 +1,68 @@
//! Comments anchored inside footnotes and endnotes keep their ranges on read. All markup here is
//! synthetic, written from ECMA-376 §17.13.4 and §17.11.
use wordcraft_doc::para::{NoteKind, OBJ};
use wordcraft_doc::{Document, InlineObject};
use crate::fixtures::zip;
const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#;
const ROOT_RELS: &str = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
const DOC_RELS: &str = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes" Target="footnotes.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes" Target="endnotes.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/></Relationships>"#;
fn note(tag: &str, mark: &str, comment: u32, text: &str) -> String {
format!(
r#"<w:{tag} w:id="1"><w:p><w:r><w:{mark}/></w:r><w:commentRangeStart w:id="{comment}"/><w:r><w:t xml:space="preserve"> {text}</w:t></w:r><w:commentRangeEnd w:id="{comment}"/><w:r><w:commentReference w:id="{comment}"/></w:r></w:p></w:{tag}>"#
)
}
fn read_doc() -> Document {
let doc = format!(
r#"<w:document {W_NS}><w:body><w:p><w:r><w:t>Body</w:t></w:r><w:r><w:footnoteReference w:id="1"/></w:r><w:r><w:endnoteReference w:id="1"/></w:r></w:p></w:body></w:document>"#
);
let footnotes = format!(r#"<w:footnotes {W_NS}>{}</w:footnotes>"#, note("footnote", "footnoteRef", 7, "Foot."));
let endnotes = format!(r#"<w:endnotes {W_NS}>{}</w:endnotes>"#, note("endnote", "endnoteRef", 8, "End."));
let comments = format!(
r#"<w:comments {W_NS}><w:comment w:id="7" w:author="Ann" w:initials="A"><w:p><w:r><w:t>On the footnote</w:t></w:r></w:p></w:comment><w:comment w:id="8" w:author="Bob" w:initials="B"><w:p><w:r><w:t>On the endnote</w:t></w:r></w:p></w:comment></w:comments>"#
);
let bytes = zip(&[
("_rels/.rels", ROOT_RELS.as_bytes()),
("word/document.xml", doc.as_bytes()),
("word/_rels/document.xml.rels", DOC_RELS.as_bytes()),
("word/footnotes.xml", footnotes.as_bytes()),
("word/endnotes.xml", endnotes.as_bytes()),
("word/comments.xml", comments.as_bytes()),
]);
wordcraft_docx::read(&bytes).unwrap()
}
/// comments.xml has to be read before the notes: a note's `w:commentRangeStart` resolves its id
/// against the comments already read, and was dropped when the notes came first.
#[test]
fn comment_ranges_inside_notes_are_kept() {
let d = read_doc();
let body = d.body[0].as_para().unwrap();
let refs: Vec<(NoteKind, u32)> = body
.objects
.iter()
.filter_map(|o| match o {
InlineObject::NoteRef { kind, id, .. } => Some((*kind, *id)),
_ => None,
})
.collect();
assert_eq!(refs.len(), 2, "{:?}", body.objects);
for ((kind, id), (author, text)) in refs.into_iter().zip([("Ann", " Foot."), ("Bob", " End.")]) {
let p = d.parts.get(&id).unwrap().blocks[0].as_para().unwrap();
let comment = match &p.objects[..] {
[InlineObject::NoteRef { kind: k, id: n, .. }, InlineObject::CommentStart { id: c }, InlineObject::CommentEnd { id: e }]
if *k == kind && *n == id && c == e =>
{
*c
}
o => panic!("{kind:?} {id}: {o:?}"),
};
assert_eq!(d.comments.get(&comment).unwrap().author, author);
assert_eq!(p.text.chars().filter(|c| *c == OBJ).count(), 3);
assert_eq!(p.plain_text(), text);
}
}
+48
View File
@@ -443,3 +443,51 @@ fn embedded_fonts_load_and_save_when_the_package_spells_the_font_table_in_capita
assert_eq!(fonts.len(), 1, "the font survives a save: {:?}", files.keys());
assert_eq!(fonts[0].data, font_bytes);
}
#[test]
fn comment_ids_keep_sparse_threads_reservations_and_missing_parts() {
use wordcraft_doc::{Comment, Document, Paragraph, Part, PartKind, para_block};
let mut doc = Document::from_text("Original thread fixture.");
// New IDs must skip the exact durable ID reserved by a later comment, even one whose
// missing part keeps it out of commentsIds.xml. Duplicate imported IDs are reassigned.
let rows = [
(3, None, Some(90), false, true),
(11, Some("5a1b2c3d"), Some(90), true, true),
(25, Some("5A1B2C3D"), Some(999), false, true),
(90, Some("10000001"), None, true, true),
(999, Some("10000002"), None, false, false),
(u32::MAX, Some("not-hex"), Some(3), false, true),
];
for (id, durable, parent, resolved, has_part) in rows {
let part = id;
if has_part {
doc.parts.insert(
part,
Part {
kind: PartKind::Comment,
blocks: vec![para_block(Paragraph::with_text(&format!("Comment {id} β"), Default::default()))],
..Default::default()
},
);
}
doc.comments.insert(id, Comment { part, parent, resolved, durable_id: durable.map(str::to_string), ..Default::default() });
}
let files = save(&doc);
let (ids, _) = written_ids(&files);
assert_eq!(ids.iter().map(|(_, id)| id.as_str()).collect::<Vec<_>>(), ["10000003", "5A1B2C3D", "10000004", "10000001", "10000005"]);
let ex = text(&files, "word/commentsExtended.xml");
assert_eq!(ex.matches("<w15:commentEx ").count(), 5);
assert!(ex.contains(r#"w15:paraId="10000001" w15:paraIdParent="10000004" w15:done="0""#));
assert!(ex.contains(r#"w15:paraId="10000002" w15:paraIdParent="10000004" w15:done="1""#));
assert!(ex.contains(r#"w15:paraId="10000003" w15:done="0""#), "a missing parent part has no paragraph ID");
assert!(ex.contains(r#"w15:paraId="10000006" w15:paraIdParent="10000001" w15:done="0""#));
let back = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
// Reader IDs need not match the sparse input IDs. Check the thread by authored text.
let comment = |text: &str| back.comments.iter().find(|(_, c)| back.plain_text(wordcraft_doc::StoryRef::Part(c.part)).trim() == text).unwrap();
let (root, _) = comment("Comment 90 β");
assert_eq!(comment("Comment 3 β").1.parent, Some(*root));
assert_eq!(comment("Comment 11 β").1.parent, Some(*root));
assert!(comment("Comment 11 β").1.resolved);
let (first, _) = comment("Comment 3 β");
assert_eq!(comment(&format!("Comment {} β", u32::MAX)).1.parent, Some(*first));
}
+35
View File
@@ -89,6 +89,8 @@ fn every_char_prop_round_trips() {
caps: Some(true),
small_caps: Some(false),
hidden: Some(true),
web_hidden: Some(true),
snap_to_grid: Some(false),
spacing: Some(1.5),
scale: Some(150.0),
position: Some(3.0),
@@ -2267,6 +2269,39 @@ fn bibliography_sources_round_trip() {
assert_bibliography_sources_round_trip(&bytes);
}
/// #4848: a corporate author saves as Word's `b:Corporate`, in place of a name list (a role holds
/// one or the other), and reads back whole.
#[test]
fn a_corporate_author_round_trips() {
let mut d = Document::new();
d.sources.push(Source {
tag: "Wor20".into(),
kind: "report".into(),
corporate: "World Health Organization".into(),
title: "World report on hearing".into(),
year: "2020".into(),
editor: "Chadha, Shelly".into(),
..Default::default()
});
d.sources.push(Source {
tag: "Both".into(),
author: "Rivera, Alex".into(),
corporate: "Harbor Guild, Inc.".into(),
title: "Shared Spaces".into(),
..Default::default()
});
let bytes = wordcraft_docx::write(&d).expect("write");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes[..])).unwrap();
let mut item_xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("customXml/item1.xml").unwrap(), &mut item_xml).unwrap();
assert!(item_xml.contains("<b:Author><b:Author><b:Corporate>World Health Organization</b:Corporate></b:Author><b:Editor>"), "{item_xml}");
assert!(item_xml.contains("<b:Corporate>Harbor Guild, Inc.</b:Corporate>"), "{item_xml}");
assert!(!item_xml.contains("<b:Last>Rivera</b:Last>"), "the corporate author takes the person authors' place: {item_xml}");
let back = wordcraft_docx::read(&bytes).expect("read");
let got: Vec<(&str, &str, &str)> = back.sources.iter().map(|s| (s.corporate.as_str(), s.author.as_str(), s.editor.as_str())).collect();
assert_eq!(got, [("World Health Organization", "", "Chadha, Shelly"), ("Harbor Guild, Inc.", "", "")]);
}
fn assert_bibliography_parts(bytes: &[u8]) {
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
assert!(zip.by_name("customXml/item1.xml").is_ok());
+74
View File
@@ -0,0 +1,74 @@
//! Run and style properties that must survive repeated DOCX saves.
use std::io::{Read, Write};
fn docx(body: &str, styles: &str) -> Vec<u8> {
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
let document = format!(
r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}<w:sectPr/></w:body></w:document>"#
);
let styles = format!(r#"<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">{styles}</w:styles>"#);
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, xml) in [
("_rels/.rels", root.to_string()),
("word/document.xml", document),
("word/_rels/document.xml.rels", rels.to_string()),
("word/styles.xml", styles),
] {
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(xml.as_bytes()).unwrap();
}
zip.finish().unwrap().into_inner()
}
fn part(bytes: &[u8], name: &str) -> String {
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut xml = String::new();
zip.by_name(name).unwrap().read_to_string(&mut xml).unwrap();
xml
}
#[test]
fn run_and_style_snap_to_grid_and_web_hidden_survive_two_saves() {
let styles = r#"
<w:style w:type="character" w:styleId="On"><w:rPr><w:snapToGrid/><w:webHidden/></w:rPr></w:style>
<w:style w:type="character" w:styleId="Off"><w:rPr><w:snapToGrid w:val="0"/><w:webHidden w:val="0"/></w:rPr></w:style>
"#;
let body = r#"
<w:p>
<w:r><w:rPr><w:rStyle w:val="On"/></w:rPr><w:t>inherit</w:t></w:r>
<w:r><w:rPr><w:snapToGrid/><w:webHidden/></w:rPr><w:t>direct true</w:t></w:r>
<w:r><w:rPr><w:rStyle w:val="On"/><w:snapToGrid w:val="0"/><w:webHidden w:val="0"/></w:rPr><w:t>override</w:t></w:r>
<w:r><w:rPr><w:rStyle w:val="Off"/><w:vanish/></w:rPr><w:t>visible in print</w:t></w:r>
<w:r><w:t>absent</w:t></w:r>
</w:p>
"#;
let mut doc = wordcraft_docx::read(&docx(body, styles)).unwrap();
let p = doc.body.iter().find_map(|b| b.as_para()).unwrap();
assert_eq!(p.runs[0].props.snap_to_grid, None);
assert_eq!(p.runs[0].props.web_hidden, None);
assert_eq!(p.runs[1].props.snap_to_grid, Some(true));
assert_eq!(p.runs[1].props.web_hidden, Some(true));
assert_eq!(p.runs[2].props.snap_to_grid, Some(false));
assert_eq!(p.runs[2].props.web_hidden, Some(false));
assert_eq!(p.runs[3].props.hidden, Some(true));
assert_eq!(p.runs[3].props.web_hidden, None);
assert_eq!(p.runs[4].props.snap_to_grid, None);
assert_eq!(p.runs[4].props.web_hidden, None);
assert!(!doc.styles.resolve_char(None, &p.runs[0].props).hidden, "w:webHidden does not hide Print Layout text");
assert!(doc.styles.resolve_char(None, &p.runs[3].props).hidden, "w:vanish still controls hidden text");
for pass in 0..2 {
let out = wordcraft_docx::write(&doc).unwrap();
let style_xml = part(&out, "word/styles.xml");
assert!(style_xml.contains(r#"<w:snapToGrid/><w:webHidden/>"#), "pass {pass}: {style_xml}");
assert!(style_xml.contains(r#"<w:snapToGrid w:val="0"/><w:webHidden w:val="0"/>"#), "pass {pass}: {style_xml}");
let document_xml = part(&out, "word/document.xml");
assert!(document_xml.contains(r#"<w:snapToGrid w:val="0"/>"#), "pass {pass}: {document_xml}");
assert!(document_xml.contains(r#"<w:webHidden w:val="0"/>"#), "pass {pass}: {document_xml}");
assert!(document_xml.contains("<w:snapToGrid/><w:webHidden/>"), "pass {pass}: {document_xml}");
assert!(document_xml.contains("<w:vanish/>"), "pass {pass}: {document_xml}");
assert!(document_xml.contains("visible in print"));
doc = wordcraft_docx::read(&out).unwrap();
}
}
+71
View File
@@ -0,0 +1,71 @@
//! A paragraph whose `w:pStyle` names a style `styles.xml` doesn't define is formatted as Normal,
//! as in Word, not with the document defaults alone (#5077). Pandoc writes footers as
//! `<w:pStyle w:val="Footer"/>` without defining Footer.
use std::io::Write;
use wordcraft_doc::props::LineSpacing;
use wordcraft_doc::{Block, Document, PartKind};
const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
const R: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
fn package(normal_id: &str) -> Vec<u8> {
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="{W}" xmlns:r="{R}"><w:body><w:p><w:pPr><w:pStyle w:val="Footer"/></w:pPr><w:r><w:t>body</w:t></w:r></w:p><w:p><w:r><w:t>normal</w:t></w:r></w:p><w:sectPr><w:footerReference w:type="default" r:id="rId2"/></w:sectPr></w:body></w:document>"#
);
let footer = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:ftr xmlns:w="{W}"><w:p><w:pPr><w:pStyle w:val="Footer"/><w:jc w:val="center"/></w:pPr><w:r><w:t>1</w:t></w:r></w:p></w:ftr>"#
);
let styles = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="{W}"><w:docDefaults><w:rPrDefault><w:rPr><w:sz w:val="24"/></w:rPr></w:rPrDefault><w:pPrDefault><w:pPr><w:spacing w:after="200"/></w:pPr></w:pPrDefault></w:docDefaults><w:style w:type="paragraph" w:default="1" w:styleId="{normal_id}"><w:name w:val="Normal"/><w:pPr><w:spacing w:before="20" w:after="40" w:line="259" w:lineRule="auto"/></w:pPr><w:rPr><w:sz w:val="26"/></w:rPr></w:style></w:styles>"#
);
let rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/></Relationships>"#;
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, body) in [
("_rels/.rels", root),
("word/document.xml", doc.as_str()),
("word/_rels/document.xml.rels", rels),
("word/styles.xml", styles.as_str()),
("word/footer1.xml", footer.as_str()),
] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(body.as_bytes()).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn footer_para(d: &Document) -> &wordcraft_doc::Paragraph {
let part = d.parts.values().find(|p| p.kind == PartKind::Footer).expect("a footer");
match part.blocks.first().map(|b| &**b) {
Some(Block::Para(p)) => p,
_ => panic!("the footer starts with a paragraph"),
}
}
#[test]
fn a_paragraph_naming_an_undefined_style_is_formatted_as_normal() {
// Also when the file's default paragraph style has another id (read as Normal).
for normal_id in ["Normal", "a"] {
let d = wordcraft_docx::read(&package(normal_id)).unwrap();
let Some(Block::Para(body)) = d.body.first().map(|b| &**b) else { panic!("a body paragraph") };
let Some(Block::Para(normal)) = d.body.get(1).map(|b| &**b) else { panic!("a second body paragraph") };
let normal_rp = d.styles.resolve_para(&normal.props);
assert_eq!((normal_rp.space_before, normal_rp.space_after), (1.0, 2.0), "{normal_id}");
for p in [body, footer_para(&d)] {
// The paragraph keeps the style it names, for the round trip.
assert_eq!(p.props.style.as_deref(), Some("Footer"));
let rp = d.styles.resolve_para(&p.props);
assert_eq!((rp.space_before, rp.space_after), (1.0, 2.0), "{normal_id}: Normal's spacing, not the defaults' 10 pt after");
assert_eq!(rp.line_spacing, LineSpacing::Multiple(259.0 / 240.0), "{normal_id}");
assert_eq!(d.styles.resolve_char(Some("Footer"), &Default::default()).size, 13.0, "{normal_id}");
}
}
}
#[test]
fn an_undefined_style_is_written_back_as_it_was_named() {
let d = wordcraft_docx::read(&package("Normal")).unwrap();
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(footer_para(&again).props.style.as_deref(), Some("Footer"));
assert_eq!(again.styles.resolve_para(&footer_para(&again).props).space_after, 2.0);
}
+3 -2
View File
@@ -273,8 +273,9 @@ fn set(s: &mut Session, v: &Value) -> CmdResult {
} else if let (Some(pg), Some(x), Some(y)) = (p::u64(v, "page"), p::f32(v, "x"), p::f32(v, "y")) {
let story = super::story_param(s, v);
let l = s.layout();
s.page_hint = pg as usize;
l.hit(pg as usize, x, y, story).ok_or_else(|| CmdError::Params("no text on that page".into()))?
let page = usize::try_from(pg).unwrap_or(usize::MAX);
s.page_hint = page;
l.hit(page, x, y, story).ok_or_else(|| CmdError::Params("no text on that page".into()))?
} else {
return Err(CmdError::Params("`pos` or `page`/`x`/`y` required".into()));
};
@@ -1,5 +1,5 @@
use super::generated::page_of;
use super::{CharProps, CmdError, CmdResult, InlineObject, Paragraph, Pos, Selection, Session, StoryRef, Value, delete_selection, json, p};
use super::{CharProps, CmdError, CmdResult, Document, InlineObject, Paragraph, Pos, Selection, Session, StoryRef, Value, delete_selection, json, p};
pub(super) fn cross_ref(s: &mut Session, v: &Value) -> CmdResult {
let to = p::str(v, "to").unwrap_or("heading");
@@ -77,13 +77,19 @@ pub(super) fn caption_label(p: &Paragraph, text: &str) -> Option<String> {
(!label.is_empty() && label.chars().count() <= 40).then_some(label)
}
/// Whether `p` is a caption: its style is Caption or one based on it, as a caption restyled with a
/// style of its own (a `Table Caption` based on Caption) still is (#4411).
pub(super) fn is_caption(doc: &Document, p: &Paragraph) -> bool {
p.props.style.as_deref().is_some_and(|id| id == "Caption" || doc.styles.chain(id).iter().any(|s| s.id == "Caption"))
}
/// The caption paragraphs of the body: (text, position, label).
fn captions(s: &Session) -> Vec<(String, Pos, Option<String>)> {
s.doc
.para_paths(StoryRef::Body)
.into_iter()
.filter_map(|path| {
let p = s.doc.para(StoryRef::Body, &path).filter(|p| p.props.style.as_deref() == Some("Caption"))?;
let p = s.doc.para(StoryRef::Body, &path).filter(|p| is_caption(&s.doc, p))?;
let text = p.plain_text();
let label = caption_label(p, &text);
Some((text, Pos { story: StoryRef::Body, path, off: 0 }, label))
@@ -125,7 +131,7 @@ pub fn ref_targets(s: &Session, to: &str) -> Vec<(String, Pos)> {
};
for path in s.doc.para_paths(StoryRef::Body) {
if let Some(p) = s.doc.para(StoryRef::Body, &path)
&& p.props.style.as_deref() == Some("Caption")
&& is_caption(&s.doc, p)
&& p.plain_text().starts_with(label)
{
targets.push((p.plain_text(), Pos { story: StoryRef::Body, path: path.clone(), off: 0 }));
+7 -7
View File
@@ -1,10 +1,10 @@
use super::cross_refs::caption_label;
use super::cross_refs::{caption_label, is_caption};
use super::render::entry;
use super::sources::bib_order;
use super::styles::style;
use super::{
Block, CharProps, CmdError, CmdResult, FieldCode, InlineObject, Paragraph, Path, Pos, Session, SortKey, StoryRef, TabAlign, TabLeader, TabStop,
Value, delete_selection, heading_letter, p, sel_result, sort_key,
Block, CharProps, CmdError, CmdResult, Document, FieldCode, InlineObject, Paragraph, Path, Pos, Session, SortKey, StoryRef, TabAlign, TabLeader,
TabStop, Value, delete_selection, heading_letter, p, sel_result, sort_key,
};
/// The TOC field for `references.tableOfFigures` (ECMA-376 §17.16.5.68): `\c "<label>"` lists the
@@ -86,8 +86,8 @@ fn figures_label(instr: &str) -> Option<String> {
/// Whether `p` is a caption numbered by SEQ identifier `ident` (any case, as Word matches it). A
/// caption typed by hand, without a SEQ field, goes by its label.
fn captioned_as(p: &Paragraph, ident: &str) -> bool {
if p.props.style.as_deref() != Some("Caption") {
fn captioned_as(doc: &Document, p: &Paragraph, ident: &str) -> bool {
if !is_caption(doc, p) {
return false;
}
let seq = p.objects.iter().find_map(|o| match o {
@@ -256,7 +256,7 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
for path in s.doc.para_paths(StoryRef::Body) {
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
if let Some(ident) = &figures {
if captioned_as(p, ident) {
if captioned_as(&s.doc, p, ident) {
let text = if bare { caption_body(p, &ident.replace('_', " ")) } else { p.plain_text() };
marks.push((text, Pos { story: StoryRef::Body, path: path.clone(), off: 0 }));
}
@@ -280,7 +280,7 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
for path in s.doc.para_paths(StoryRef::Body) {
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
if let Some(ident) = &figures {
if captioned_as(p, ident) {
if captioned_as(&s.doc, p, ident) {
v.push(Pos { story: StoryRef::Body, path: path.clone(), off: 0 });
}
continue;
+14 -7
View File
@@ -1,6 +1,12 @@
use super::Source;
use super::styles::{Cite, Name, Names, style};
/// A source's corporate author, when it has one: written as it is in place of the person names
/// (Word's Corporate Author, #4848).
pub(super) fn corporate(src: &Source) -> Option<&str> {
Some(src.corporate.trim()).filter(|c| !c.is_empty())
}
pub(super) fn authors(a: &str) -> Vec<(String, String)> {
a.split(';')
.map(str::trim)
@@ -110,12 +116,13 @@ pub fn cite(src: &Source, style_name: &str, n: usize, pages: &str) -> String {
let st = style(style_name);
let au = authors(&src.author);
let lasts: Vec<&str> = au.iter().map(|a| a.0.as_str()).collect();
let who = match lasts.as_slice() {
[] => src.title.clone(),
[one] => one.to_string(),
[first, ..] if lasts.len() >= st.et_al_from.max(2) => format!("{first} et al."),
[a, b] => format!("{a} {} {b}", st.and),
[init @ .., z] => format!("{} {} {z}", init.join(", "), st.and),
let who = match (corporate(src), lasts.as_slice()) {
(Some(c), _) => c.to_string(),
(None, []) => src.title.clone(),
(None, [one]) => one.to_string(),
(None, [first, ..]) if lasts.len() >= st.et_al_from.max(2) => format!("{first} et al."),
(None, [a, b]) => format!("{a} {} {b}", st.and),
(None, [init @ .., z]) => format!("{} {} {z}", init.join(", "), st.and),
};
match st.cite {
Cite::AuthorPage if pages.is_empty() => format!("({who})"),
@@ -148,7 +155,7 @@ pub fn entry(src: &Source, style_name: &str, n: usize) -> String {
let field = |key: &str| -> String {
match key {
"n" => n.to_string(),
"names" => name_list(&au, &st.names),
"names" => corporate(src).map_or_else(|| name_list(&au, &st.names), str::to_string),
"year" if src.year.is_empty() => "n.d.".into(),
"year" => src.year.clone(),
"title" => src.title.clone(),
+14 -8
View File
@@ -1,5 +1,5 @@
use super::generated::update_generated;
use super::render::{authors, cite};
use super::render::{authors, cite, corporate};
use super::styles::{CiteStyle, Order, style};
use super::{CmdError, CmdResult, Document, InlineObject, Selection, Session, Source, StoryRef, Value, delete_selection, json, p, sort_key};
@@ -14,18 +14,19 @@ fn auto_tag(doc: &Document, src: &Source) -> String {
(1u32..).map(|n| format!("{base}{n}")).find(|t| !taken(t)).unwrap_or(base)
}
/// The author of a source for matching untagged entries: the editor when there is no author.
/// The author of a source for matching untagged entries: the corporate author (which takes the
/// author's place when a source is saved), else the author, else the editor.
#[cfg(not(target_arch = "wasm32"))]
fn by_author(x: &Source) -> &str {
if x.author.is_empty() { x.editor.as_str() } else { x.author.as_str() }
[x.corporate.trim(), x.author.as_str(), x.editor.as_str()].into_iter().find(|w| !w.is_empty()).unwrap_or_default()
}
/// The stem of a new source's tag, before `1`, `2`… are added while it is taken: the first three
/// alphanumerics of the first author's name (nothing of the author's, `Src`) and the last two
/// digits of the year.
/// alphanumerics of the first author's name or the corporate author (nothing of the author's,
/// `Src`) and the last two digits of the year.
fn auto_tag_base(src: &Source) -> String {
let name: String =
authors(&src.author).first().map(|x| x.0.clone()).unwrap_or_default().chars().filter(|c| c.is_alphanumeric()).take(3).collect();
let first = corporate(src).map(str::to_string).or_else(|| authors(&src.author).first().map(|x| x.0.clone())).unwrap_or_default();
let name: String = first.chars().filter(|c| c.is_alphanumeric()).take(3).collect();
let name = if name.is_empty() { "Src".to_string() } else { name };
let digits: Vec<char> = src.year.chars().filter(char::is_ascii_digit).collect();
let year: String = digits.get(digits.len().saturating_sub(2)..).unwrap_or_default().iter().collect();
@@ -184,8 +185,13 @@ fn cited_tags(doc: &Document) -> Vec<String> {
out
}
/// A source's place in an author-sorted bibliography: the corporate author, else the first
/// author's surname, else the title.
fn author_key(src: &Source) -> String {
authors(&src.author).first().map(|a| a.0.to_lowercase()).unwrap_or_else(|| src.title.to_lowercase())
corporate(src)
.map(str::to_lowercase)
.or_else(|| authors(&src.author).first().map(|a| a.0.to_lowercase()))
.unwrap_or_else(|| src.title.to_lowercase())
}
/// Indices into `doc.sources` in bibliography order: the cited sources (every source while
+4 -2
View File
@@ -103,8 +103,10 @@ pub static STYLES: [CiteStyle; 12] = [
et_al_from: 3,
order: Order::Author,
names: names(Name::LastInitials, Name::LastInitials, ", ", ", & ", ", & "),
book: "⟦{names} ⟧({year}). {title}.⟦ {publisher}.⟧⟦ {url}⟧",
article: "⟦{names} ⟧({year}). {title}. {journal}⟦, {volume}⟧⟦, {pages}⟧.⟦ {url}⟧",
// The names end with a full stop: an initial's, or one added after a group author or a
// name without initials ("World Health Organization. (2021).", #4848).
book: "⟦{names}. ⟧({year}). {title}.⟦ {publisher}.⟧⟦ {url}⟧",
article: "⟦{names}. ⟧({year}). {title}. {journal}⟦, {volume}⟧⟦, {pages}⟧.⟦ {url}⟧",
bib_title: "References",
},
CiteStyle {
+86
View File
@@ -18,6 +18,49 @@ fn citations_and_bibliography() {
assert!(s.doc.plain_text(StoryRef::Body).contains("[1]"));
}
/// #4848: a corporate author is cited and listed as written, in every style: never turned into
/// a surname and initials ("Organization, W. H."), never left out for the title. Its tag and its
/// place in an author-sorted list come from the name too.
#[test]
fn a_corporate_author_is_cited_and_listed_as_written() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("text.insert", &json!({"text": "Hearing loss is common "})).unwrap();
let who = "World Health Organization";
s.run("references.citation", &json!({"source": {"kind": "report", "corporate": who, "title": "World report on hearing", "year": "2021", "publisher": "WHO Press", "city": "Geneva"}}))
.unwrap();
s.run("text.insert", &json!({"text": " and rising "})).unwrap();
s.run("references.citation", &json!({"source": {"author": "Adams, Kim", "title": "Quiet Rooms", "year": "2019"}})).unwrap();
s.run("text.newParagraph", &json!({})).unwrap();
s.run("references.bibliography", &json!({})).unwrap();
assert_eq!(s.doc.sources[0].tag, "Wor21", "the tag starts with the corporate name");
let t = s.doc.plain_text(StoryRef::Body);
assert!(t.contains(&format!("({who}, 2021)")), "{t}");
assert!(t.contains(&format!("{who}. (2021). World report on hearing. WHO Press.")), "{t}");
// Sorted by the corporate name: Adams before World.
let adams = t.find("Adams, K. (2019)").unwrap_or(usize::MAX);
let world = t.find(&format!("{who}. (2021)")).unwrap_or(0);
assert!(adams < world, "{t}");
for style in [
"MLA",
"Chicago",
"GB/T 7714",
"GOST (name sort)",
"GOST (title sort)",
"ISO 690 (author-date)",
"ISO 690 (numerical)",
"Harvard (Anglia)",
"Turabian",
"IEEE",
"SIST02",
] {
s.run("references.citationStyle", &json!({"style": style})).unwrap();
let t = s.doc.plain_text(StoryRef::Body);
let entry = t.lines().find(|l| l.contains("World report on hearing")).unwrap_or_default();
assert!(entry.contains(who), "{style}: {entry}");
assert!(!entry.contains("Organization, W") && !entry.contains("ORGANIZATION, W"), "{style}: {entry}");
}
}
/// Updating fields in a document without generated lists leaves its styles alone: a new
/// style would make the next layout start over from nothing.
#[test]
@@ -162,6 +205,49 @@ fn caption_labels_do_not_need_a_digit() {
assert!(s.doc.plain_text(StoryRef::Body).ends_with("See Photo Plate I"), "{}", s.doc.plain_text(StoryRef::Body));
}
/// #4411: captions restyled with a style based on Caption, directly (`Caption Table`) or through
/// another (`Caption Table Wide`), are still captions after a DOCX save and reopen: every `tableX`
/// caption is a cross-reference target and in the table of figures, and a Table caption is a
/// Table target. Captions in a style not based on Caption are not, nor are their labels.
#[test]
fn captions_in_a_style_based_on_caption() {
let mut s = Session::new(wordcraft_doc::Document::from_text("T1\nT2\nT3\nT4\nT5\nNote\nGrid\nStone\nSee "));
let caption = |s: &mut Session, text: &str, label: &str, title: &str| {
s.run("select.text", &json!({"text": text})).unwrap();
s.run("references.caption", &json!({"label": label, "text": title})).unwrap();
};
for k in 1..=5 {
caption(&mut s, &format!("T{k}"), "tableX", &format!("Row {k}"));
}
caption(&mut s, "Note", "tableX", "Aside");
caption(&mut s, "Grid", "Table", "Grid");
caption(&mut s, "Stone", "Plate", "Stone");
s.run("styles.create", &json!({"name": "Caption Table", "basedOn": "Caption", "fromSelection": false})).unwrap();
s.run("styles.create", &json!({"name": "Caption Table Wide", "basedOn": "Caption Table", "fromSelection": false})).unwrap();
s.run("styles.create", &json!({"name": "Aside", "fromSelection": false})).unwrap();
let style = |s: &Session, name: &str| s.doc.styles.find(name).map(|st| st.id.clone());
let (table, wide, aside) = (style(&s, "Caption Table"), style(&s, "Caption Table Wide"), style(&s, "Aside"));
for path in s.doc.para_paths(StoryRef::Body) {
let p = s.doc.para_mut(StoryRef::Body, &path).unwrap();
let text = p.plain_text();
p.props.style = match text.get(..8).unwrap_or_default() {
"tableX 1" | "tableX 2" => table.clone(),
"tableX 3" | "Table 1:" => wide.clone(),
"tableX 6" | "Plate 1:" => aside.clone(),
_ => continue,
};
}
let bytes = wordcraft_docx::write(&s.doc).expect("docx write");
let mut s = Session::new(wordcraft_docx::read(&bytes).expect("docx read"));
let want: Vec<String> = (1..=5).map(|k| format!("tableX {k}: Row {k}")).collect();
assert_eq!(s.run("insert.crossReference", &json!({"to": "label:tableX"})).unwrap(), json!(want));
assert_eq!(s.run("insert.crossReference", &json!({"to": "table"})).unwrap(), json!(["Table 1: Grid"]));
assert_eq!(caption_labels(&s), ["tableX", "Table"]);
s.run("caret.docEnd", &json!({})).unwrap();
s.run("references.tableOfFigures", &json!({"label": "tableX", "pageNumbers": false})).unwrap();
assert_eq!(list_entries(&s).into_iter().map(|(t, _)| t).collect::<Vec<_>>(), want);
}
/// The entries of every generated list, in order, with their last tab stop's leader.
fn list_entries(s: &Session) -> Vec<(String, Option<TabLeader>)> {
s.doc
+2
View File
@@ -180,6 +180,8 @@ pub(crate) fn dispatch(s: &mut Session, id: &str, mutates: bool, run: fn(&mut Se
// Whole cells: table commands take the rectangle between the corners, and Tab moves on
// from it without clearing it, as in Word.
_ if cells && (id.starts_with("table.") || matches!(id, "text.tab" | "text.backTab")) => run(s, v),
// Copy and Cut of whole cells take a table of them (#1744).
"edit.copy" | "edit.cut" if cells => run(s, v),
"edit.copy" => copy(s, &segs),
"edit.cut" => {
let r = copy(s, &segs)?;
+50 -6
View File
@@ -48,10 +48,10 @@ fn scan(s: &Session) -> (HashMap<u32, Pos>, Vec<Pos>, Vec<Pos>) {
let (mut ids, mut conns, mut groups) = (HashMap::new(), Vec::new(), Vec::new());
for path in s.doc.para_paths(StoryRef::Body) {
let Some(para) = s.doc.para(StoryRef::Body, &path) else { continue };
for off in para.object_offsets() {
for (off, obj) in para.objects_with_offsets() {
let pos = Pos { story: StoryRef::Body, path: path.clone(), off };
match para.object_at(off) {
Some(InlineObject::Shape { kind, extra, float, .. }) => {
match obj {
InlineObject::Shape { kind, extra, float, .. } => {
if extra.id != 0 {
ids.entry(extra.id).or_insert(pos.clone());
}
@@ -59,7 +59,7 @@ fn scan(s: &Session) -> (HashMap<u32, Pos>, Vec<Pos>, Vec<Pos>) {
conns.push(pos);
}
}
Some(InlineObject::Group { children, .. })
InlineObject::Group { children, .. }
if children.iter().any(|c| matches!(&c.obj, InlineObject::Shape { kind, extra, .. } if kind.is_connector() && (extra.start.is_some() || extra.end.is_some()))) =>
{
groups.push(pos)
@@ -91,10 +91,11 @@ pub fn reroute(s: &mut Session) -> bool {
s.touch();
}
let layout = s.layout();
let objects = layout.object_index();
let site = |e: Option<ConnEnd>, page: usize| -> Option<(f32, f32)> {
let e = e?;
let target = ids.get(&e.id)?;
let hit = layout.object(target, page)?;
let hit = objects.object(target, page)?;
let kind = match s.doc.para_at(target)?.object_at(target.off)? {
InlineObject::Shape { kind, .. } => *kind,
_ => return None,
@@ -103,7 +104,7 @@ pub fn reroute(s: &mut Session) -> bool {
};
let mut plans = Vec::new();
for pos in conns {
let Some(hit) = layout.object(&pos, s.page_hint) else { continue };
let Some(hit) = objects.object(&pos, s.page_hint) else { continue };
let Some(InlineObject::Shape { extra, float, .. }) = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)) else { continue };
let r = hit.rect;
let (s0, e0) = ends(r.x, r.y, r.w, r.h, float.flip_h, float.flip_v);
@@ -313,6 +314,49 @@ mod tests {
}
}
/// Drawing commands must not search every page again for each glued connector.
#[test]
fn rerouting_many_pages_does_not_rescan_all_objects_per_connector() {
use wordcraft_doc::{Document, Paragraph, para_block};
let mut doc = Document::new();
doc.body = (0..8000u32)
.map(|i| {
let mut p = Paragraph::default();
p.props.page_break_before = Some(i > 0);
let id = i * 2 + 1;
for (kind, x, extra) in [
(ShapeKind::Rectangle, 100.0, ShapeExtra { id, ..Default::default() }),
(ShapeKind::Ellipse, 300.0, ShapeExtra { id: id + 1, ..Default::default() }),
(
ShapeKind::ElbowConnector,
0.0,
ShapeExtra { start: Some(ConnEnd { id, site: 3 }), end: Some(ConnEnd { id: id + 1, site: 2 }), ..Default::default() },
),
] {
let float = Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x, y: 200.0, ..Default::default() };
p.insert_object(p.len(), shape(kind, extra, float), &Default::default()).unwrap();
}
para_block(p)
})
.collect();
let mut s = Session::new(doc);
s.view.proofing = false;
s.page_hint = 0;
assert_eq!(s.layout().pages.len(), 8000);
let started = std::time::Instant::now();
assert!(reroute(&mut s));
let elapsed = started.elapsed();
eprintln!("8000-page reroute: {elapsed:?}");
assert!(elapsed < std::time::Duration::from_secs(5), "reroute took {elapsed:?}");
for path in s.doc.para_paths(StoryRef::Body) {
let para = s.doc.para(StoryRef::Body, &path).unwrap();
let InlineObject::Shape { w, h, float, .. } = para.object_at(6).unwrap() else { panic!("connector") };
assert_eq!((*w, *h, float.x, float.y), (160.0, 0.0, 140.0, 210.0));
}
s.touch();
assert!(!reroute(&mut s), "rerouting must settle after refreshing the layout");
}
/// The per-case shape/geometry of one scaled-group reroute case.
struct ScaledGroupSpec {
w: f32,
+48 -10
View File
@@ -2,7 +2,7 @@
use serde_json::{Value, json};
use wordcraft_doc::edit::Fragment;
use wordcraft_doc::{Pos, StoryRef};
use wordcraft_doc::{Path, Pos, StoryRef};
use super::{delete_selection, find_pattern, pos_json, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
@@ -27,10 +27,16 @@ fn undo_to(s: &mut Session, v: &Value) -> CmdResult {
Ok(r)
}
fn has_sel(s: &Session) -> Option<&'static str> {
/// Something the Spike can collect: text (it doesn't take empty cells).
fn has_text_sel(s: &Session) -> Option<&'static str> {
if s.sel.is_collapsed() { Some("nothing is selected") } else { None }
}
/// Something to cut: text, or whole table cells (an empty cell's selection has no length, #1744).
fn has_sel(s: &Session) -> Option<&'static str> {
if s.sel.is_collapsed() && s.cell_range().is_none() { Some("nothing is selected") } else { None }
}
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("edit.undo", "Undo", "Quick Access Toolbar", |s, _| {
@@ -63,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("edit.copy", "Copy", "Home › Clipboard", copy).key("Mod+C / Mod+Insert").pure(),
// Word's Spike (Ctrl+F3, Ctrl+Shift+F3; ⌘ on the Mac): the AutoText entry "Spike", so Quick
// Parts and AutoText insert it without emptying it, as in Word.
CommandSpec::new("edit.spike", "Spike", "Editing", spike).key("Mod+F3").when(has_sel),
CommandSpec::new("edit.spike", "Spike", "Editing", spike).key("Mod+F3").when(has_text_sel),
CommandSpec::new("edit.insertSpike", "Insert Spike", "Editing", insert_spike).key("Mod+Shift+F3").when(|s| {
if s.math.is_some() {
Some("leave the equation first")
@@ -167,11 +173,25 @@ pub fn specs() -> Vec<CommandSpec> {
}
fn copy(s: &mut Session, _: &Value) -> CmdResult {
let whole = super::table::selected_table(s);
copy_with(s, whole.as_ref())
}
/// Copy, `whole` being the table the selection covers whole, if it does.
fn copy_with(s: &mut Session, whole: Option<&Path>) -> CmdResult {
let (a, b) = s.sel.ordered();
if a == b {
return Ok(json!({"text": ""}));
}
let f = s.doc.copy_range(&a, &b);
// Whole table cells selected copy as a table, even empty ones (their selection has no
// length): the whole table, or a table of just the selected rows or cells (#1744).
let table = match (whole, s.cell_range()) {
(Some(tp), _) => s.doc.copy_block(a.story, tp),
(None, Some(r)) => s.doc.copy_table_cells(r.story, &r.table, r.rows, r.cols),
(None, None) => None,
};
let f = match table {
Some(f) => f,
None if a == b => return Ok(json!({"text": ""})),
None => s.doc.copy_range(&a, &b),
};
s.clipboard_text = f.clipboard_text();
s.clip_history.push(&f, &s.clipboard_text);
s.clipboard = Some(f);
@@ -200,8 +220,26 @@ pub(crate) fn copy_result(s: &mut Session) -> Value {
r
}
fn cut(s: &mut Session, v: &Value) -> CmdResult {
let r = copy(s, v)?;
fn cut(s: &mut Session, _: &Value) -> CmdResult {
let whole = super::table::selected_table(s);
let r = copy_with(s, whole.as_ref())?;
// A whole table selected: Cut removes the table, as Delete Table does (#1744). With Track
// Changes on, the cut below marks its text deleted (a tracked cut may become a move).
if !s.doc.settings.track_changes
&& let Some(tp) = &whole
{
super::table::delete_table_at(s, tp)?;
super::controls::restore_placeholder(s)?;
return Ok(r);
}
// Whole rows or some cells: their text goes, the rows and cells stay, as in Word.
if whole.is_none()
&& let Some(range) = s.cell_range().cloned()
{
super::table::clear_range_cells(s, &range)?;
super::controls::restore_placeholder(s)?;
return Ok(r);
}
// A tracked cut of a paragraph or sentence may become a move when pasted.
if !super::moves::cut_tracked(s)? {
delete_selection(s)?;
@@ -738,7 +776,7 @@ fn goto(s: &mut Session, v: &Value) -> CmdResult {
});
if let Some(pg) = rel.or_else(|| p::u64(v, "page")) {
let l = s.layout();
let idx = (pg.max(1) as usize - 1).min(l.pages.len().saturating_sub(1));
let idx = crate::p::page_index(pg).min(l.pages.len().saturating_sub(1));
let target = l.pages.get(idx).and_then(|page| {
page.items.iter().find_map(|it| match it {
wordcraft_layout::Placed::Lines { story, path, para, l0, .. } if *story == StoryRef::Body => {
+38 -14
View File
@@ -147,7 +147,7 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"id"?: n (an open document, see view.switchWindows; default the active one), "force"?: bool (discard unsaved changes; without it a document with unsaved changes is an error and stays open)} → {"closed": id, "active": id, "documents": [{"id", "title", "path", "dirty", "active"}]}; closing the last document leaves a new blank one"#)
.pure(),
CommandSpec::new("file.properties", "Properties", "File › Info", properties)
.params(r#"{"title"?, "subject"?, "author"?, "keywords"?, "comments"?, "category"?, "custom"?: {name: string | null (removes)}}"#),
.params(r#"{"title"?, "subject"?, "author"?, "keywords"?, "comments"?, "category"?, "custom"?: {name: string | {"value": string, "linkTarget"?: string | null} | null (removes)}}; text values and bookmark targets are at most 1 MiB; a string keeps an existing target, an object replaces it (omitted/null target unlinks)"#),
CommandSpec::new("file.info", "Info", "File", info).pure(),
CommandSpec::new("file.setAuthor", "User Name", "File › Options › General", set_author)
.params(r#"{"name": string (author of new tracked changes and comments)}"#)
@@ -428,7 +428,7 @@ fn export_png(s: &mut Session, v: &Value) -> CmdResult {
s.push_ui_request(json!({"open": "exportPng"}));
return Ok(json!({"exported": false}));
};
let page = p::u64(v, "page").unwrap_or(1).max(1) as usize - 1;
let page = crate::p::page_index(p::u64(v, "page").unwrap_or(1));
let scale = p::f32(v, "scale").unwrap_or(2.0).clamp(0.1, 8.0);
let l = s.export_layout_with(export_options(s, v).markup);
let pg = l.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {}", page + 1)))?;
@@ -445,6 +445,15 @@ fn export_png(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"path": path, "width": img.width, "height": img.height, "bytes": png.len()}))
}
/// Text and its bookmark association cross the dialog/command boundary together on rename.
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct LinkedCustomText<'a> {
value: &'a str,
#[serde(borrow)]
link_target: Option<&'a str>,
}
fn properties(s: &mut Session, v: &Value) -> CmdResult {
let c = &mut s.doc.core;
let set = |dst: &mut String, k: &str| {
@@ -460,7 +469,7 @@ fn properties(s: &mut Session, v: &Value) -> CmdResult {
set(&mut c.category, "category");
if let Some(custom) = v.get("custom") {
let Some(m) = custom.as_object() else {
return Err(CmdError::Params("`custom` must be an object of name → string or null".into()));
return Err(CmdError::Params("`custom` must be an object of name → text, linked text value, or null".into()));
};
// Removals first: a case-only rename sends the old spelling as null and the new one
// with its value, and `set_custom_prop` matches names case-insensitively, so setting
@@ -471,17 +480,32 @@ fn properties(s: &mut Session, v: &Value) -> CmdResult {
}
}
for (name, val) in m {
match val {
// Removals took the name as stored above: a file opened from an older Word may
// hold one longer than today's limit, and it must still be deletable (#2982).
Value::Null => {}
Value::String(x) if x.len() <= MAX_CUSTOM_VALUE => {
if name.trim().is_empty() || name.chars().count() > MAX_CUSTOM_NAME {
return Err(CmdError::Params(format!("custom property names are 1–{MAX_CUSTOM_NAME} characters")));
}
s.doc.set_custom_prop(name, x);
let bad_value =
|| CmdError::Params(format!("custom property `{name}` must be text (≤ {MAX_CUSTOM_VALUE} bytes), a linked text value, or null"));
let (value, target) = match val {
// Removal also accepts names longer than today's limit (#2982).
Value::Null => continue,
Value::String(value) => (value.as_str(), None),
Value::Object(_) => {
let linked: LinkedCustomText<'_> = serde::Deserialize::deserialize(val).map_err(|_| bad_value())?;
(linked.value, Some(linked.link_target))
}
_ => return Err(CmdError::Params(format!("custom property `{name}` must be a string (≤ {MAX_CUSTOM_VALUE} bytes) or null"))),
_ => return Err(bad_value()),
};
if value.len() > MAX_CUSTOM_VALUE {
return Err(bad_value());
}
if name.trim().is_empty() || name.chars().count() > MAX_CUSTOM_NAME {
return Err(CmdError::Params(format!("custom property names are 1–{MAX_CUSTOM_NAME} characters")));
}
if target.flatten().is_some_and(|t| t.len() > MAX_CUSTOM_VALUE) {
return Err(CmdError::Params(format!("custom property bookmark targets are at most {MAX_CUSTOM_VALUE} bytes")));
}
s.doc.set_custom_prop(name, value);
if let Some(target) = target
&& let Some(prop) = s.doc.custom_props.iter_mut().find(|p| p.name.eq_ignore_ascii_case(name))
{
prop.link_target = target.map(str::to_string);
}
}
}
@@ -495,7 +519,7 @@ fn properties(s: &mut Session, v: &Value) -> CmdResult {
/// Longest custom property name (Word's limit). The Custom Properties dialog validates with it
/// too (#2982).
pub const MAX_CUSTOM_NAME: usize = 255;
/// Largest custom property value we accept from a command. The Custom Properties dialog
/// Largest custom property value or bookmark target we accept from a command. The Custom Properties dialog
/// validates with it too (#2982).
pub const MAX_CUSTOM_VALUE: usize = 1024 * 1024;
+75 -19
View File
@@ -197,6 +197,17 @@ fn column_rows(s: &Session) -> Option<Vec<(Pos, Pos)>> {
/// Apply a change to the selection (or the pending caret formatting).
pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult {
apply_to(s, target, f)
}
/// The selection only, not the word at a caret: what highlighting formats (#4086).
fn selection(s: &Session) -> Option<(Pos, Pos)> {
let (a, b) = s.sel.ordered();
(a != b).then_some((a, b))
}
/// [`apply`], with `target` choosing what a non-column selection formats.
fn apply_to(s: &mut Session, target: fn(&Session) -> Option<(Pos, Pos)>, f: &dyn Fn(&mut CharProps)) -> CmdResult {
// A column selection formats each row's piece.
if let Some(rows) = column_rows(s) {
for (a, b) in rows {
@@ -211,6 +222,12 @@ pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult {
match selected_text_box(s).or_else(|| target(s)) {
Some((a, b)) => {
s.doc.format_range(&a, &b, f)?;
// The word at a caret took the change: so does formatting waiting for typing (#4086).
if s.sel.is_collapsed()
&& let Some(pend) = s.pending.as_mut()
{
f(pend);
}
}
None => {
let mut pend = s.typing_props();
@@ -354,26 +371,59 @@ fn current_size(s: &Session) -> f32 {
}
fn step_size(s: &mut Session, dir: i32) -> CmdResult {
let cur = current_size(s);
let next = if dir > 0 {
SIZES.iter().copied().find(|x| *x > cur + 0.01).unwrap_or(((cur / 10.0).floor() + 1.0) * 10.0)
} else {
SIZES.iter().rev().copied().find(|x| *x < cur - 0.01).unwrap_or(1.0)
};
let next = next.clamp(1.0, 1638.0);
// Each run steps from its own size when the selection mixes sizes.
apply(s, &|c| {
c.size = Some(next);
c.size_cs = Some(next);
resize(s, &|cur| {
if dir > 0 {
SIZES.iter().copied().find(|x| *x > cur + 0.01).unwrap_or(((cur / 10.0).floor() + 1.0) * 10.0)
} else {
SIZES.iter().rev().copied().find(|x| *x < cur - 0.01).unwrap_or(1.0)
}
})
}
fn nudge_size(s: &mut Session, d: f32) -> CmdResult {
let next = (current_size(s) + d).clamp(1.0, 1638.0);
apply(s, &|c| {
c.size = Some(next);
c.size_cs = Some(next);
})
resize(s, &|cur| cur + d)
}
/// Relative commands transform each run's effective size, including inherited sizes. Keep
/// `apply`'s targets and the existing explicit override of both normal and complex-script size.
fn resize(s: &mut Session, next: &dyn Fn(f32) -> f32) -> CmdResult {
let set = |c: &mut CharProps, size: f32| {
let size = next(size).clamp(1.0, 1638.0);
c.size = Some(size);
c.size_cs = Some(size);
};
let ranges = column_rows(s).or_else(|| selected_text_box(s).or_else(|| target(s)).map(|range| vec![range]));
let Some(ranges) = ranges else {
// Pending typing props and an empty paragraph's mark get the same single change.
let size = current_size(s);
return apply(s, &|c| set(c, size));
};
// Resolve against one immutable snapshot while formatting splits and normalizes runs.
let styles = s.doc.styles.clone();
for (a, b) in ranges {
for path in s.doc.paths_between(&a, &b) {
let p = s.doc.para_mut(a.story, &path)?;
let style = p.props.style.clone();
let from = if path == a.path { a.off } else { 0 };
let to = if path == b.path { b.off } else { p.len() };
let format = |c: &mut CharProps| set(c, styles.resolve_char(style.as_deref(), c).size);
// Avoid format_range here: its path walk would repeat a paragraph-prefix scan.
p.format(from, to, &format)?;
// Keep format_range's paragraph-mark rule, including filtering linked marks.
if to == p.len() && (from == 0 || path != a.path) {
let style = p.mark.style.clone();
format(&mut p.mark);
if p.mark.link.take().is_some() {
p.mark.style = style.filter(|s| s != "Hyperlink");
}
}
}
// An empty selected cell has only its paragraph mark, which needs a new layout too.
if a == b {
s.doc.para_mut(a.story, &a.path)?.touch();
}
}
sel_result(s)
}
fn color(s: &mut Session, v: &Value) -> CmdResult {
@@ -410,7 +460,7 @@ fn target_has_highlight(s: &Session, h: Highlight) -> bool {
return true;
}
}
if let Some((a, b)) = selected_text_box(s).or_else(|| target(s)) {
if let Some((a, b)) = selected_text_box(s).or_else(|| selection(s)) {
for path in s.doc.paths_between(&a, &b) {
let Some(p) = s.doc.para(a.story, &path) else { continue };
let from = if path == a.path { a.off } else { 0 };
@@ -422,7 +472,12 @@ fn target_has_highlight(s: &Session, h: Highlight) -> bool {
}
}
}
// No runs: retain the caret/pending and empty text-box paragraph-mark rules.
// No runs: retain the caret/pending and empty text-box paragraph-mark rules. A caret checks
// what is typed next, not the word it's in (#4086).
if selected_text_box(s).or_else(|| selection(s)).is_none() {
let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.as_deref());
return seen || matches(style, &s.typing_props());
}
seen || resolved(s).iter().all(|r| r.highlight == h.rgb())
}
@@ -472,7 +527,8 @@ fn highlight(s: &mut Session, v: &Value) -> CmdResult {
};
let remove = color.is_none() && target_has_highlight(s, h);
let value = if remove { Highlight::None } else { h };
let result = apply(s, &|x| x.highlight = Some(value))?;
// A caret highlights what is typed next, not the word it's in, as in Word (#4086).
let result = apply_to(s, selection, &|x| x.highlight = Some(value))?;
s.last_highlight = h;
Ok(result)
}
+1 -1
View File
@@ -222,7 +222,7 @@ pub fn specs() -> Vec<CommandSpec> {
r#"{"delivery": string, "return"?: string, "size"?: "Envelope #10|#9|Monarch|DL|C4|C5|C6|B5", "width"?: pt, "height"?: pt, "add"?: bool (add to this document instead of a new one)}"#,
),
CommandSpec::new("mailings.labels", "Labels", "Mailings › Create", labels).params(
r#"{"text"?: string, "product"?: "letter-3x10|letter-2x10|letter-2x7|letter-2x5|letter-4x20|a4-3x7|a4-2x7|a4-3x8|a4-2x4", "rows"?: n, "cols"?: n, "fromRecipients"?: bool, "single"?: {"row": n, "col": n}}"#,
r#"{"text"?: string, "product"?: "letter-3x10|letter-2x10|letter-2x7|letter-2x5|letter-4x20|a4-3x7|a4-2x7|a4-3x8|a4-2x4", "rows"?: n, "cols"?: n, "fromRecipients"?: bool (one label per selected recipient, continuing across sheets), "single"?: {"row": n, "col": n}}"#,
),
]
}
+49 -36
View File
@@ -254,8 +254,9 @@ impl LabelProduct {
}
/// Labels: a new document with a table of labels laid out like the sheet (`product`; spacer
/// columns and rows stand for the gaps). A full page of the same `text` (or one recipient per
/// label), or (`single`: `{"row", "col"}`, from 1) one label at that place on the sheet.
/// columns and rows stand for the gaps). A full page of the same `text`, as many sheets as
/// needed for one label per recipient, or (`single`: `{"row", "col"}`, from 1) one label
/// at that place on the sheet.
pub(super) fn labels(s: &mut Session, v: &Value) -> CmdResult {
let prod = match p::str(v, "product") {
Some(id) => *LabelProduct::by_id(id).ok_or_else(|| CmdError::Params(format!("unknown label product `{id}`")))?,
@@ -285,46 +286,58 @@ pub(super) fn labels(s: &mut Session, v: &Value) -> CmdResult {
let grid_cols: Vec<Option<usize>> = (0..cols).flat_map(|c| [Some(c)].into_iter().chain((c + 1 < cols && h_gap > 0.5).then_some(None))).collect();
let grid_rows: Vec<Option<usize>> = (0..rows).flat_map(|r| [Some(r)].into_iter().chain((r + 1 < rows && v_gap > 0.5).then_some(None))).collect();
let width: f32 = grid_cols.iter().map(|c| if c.is_some() { prod.width } else { h_gap }).sum();
let mut t = Table::new(grid_rows.len(), grid_cols.len(), width);
t.props.style = None;
t.props.borders = Some(wordcraft_doc::props::Borders::default());
t.props.fixed = true;
t.grid = grid_cols.iter().map(|c| if c.is_some() { prod.width } else { h_gap }).collect();
let per_sheet = rows * cols;
let sheets = if from_rec { s.merge.rows.len().div_ceil(per_sheet).max(1) } else { 1 };
let mut k = 0usize;
let mut made = 0usize;
for (row, r) in t.rows.iter_mut().zip(&grid_rows) {
row.props.height = Some(if r.is_some() { prod.height } else { v_gap });
row.props.height_rule = wordcraft_doc::props::HeightRule::Exact;
for (cell, c) in row.cells.iter_mut().zip(&grid_cols) {
cell.props.width = Some(if c.is_some() { prod.width } else { h_gap });
let (Some(r), Some(c)) = (r, c) else { continue };
let content = match single {
Some((sr, sc)) if (sr, sc) != (r + 1, c + 1) => String::new(),
_ if from_rec => {
let rec = if k < s.merge.rows.len() { address_block(s, k) } else { String::new() };
k += 1;
rec
d.body.clear();
for sheet in 0..sheets {
let mut t = Table::new(grid_rows.len(), grid_cols.len(), width);
t.props.style = None;
t.props.borders = Some(wordcraft_doc::props::Borders::default());
t.props.fixed = true;
t.grid = grid_cols.iter().map(|c| if c.is_some() { prod.width } else { h_gap }).collect();
for (row, r) in t.rows.iter_mut().zip(&grid_rows) {
row.props.height = Some(if r.is_some() { prod.height } else { v_gap });
row.props.height_rule = wordcraft_doc::props::HeightRule::Exact;
for (cell, c) in row.cells.iter_mut().zip(&grid_cols) {
cell.props.width = Some(if c.is_some() { prod.width } else { h_gap });
let (Some(r), Some(c)) = (r, c) else { continue };
let content = match single {
Some((sr, sc)) if (sr, sc) != (r + 1, c + 1) => String::new(),
_ if from_rec => {
let rec = if k < s.merge.rows.len() { address_block(s, k) } else { String::new() };
k += 1;
rec
}
_ => text.clone(),
};
if !content.is_empty() {
made += 1;
}
_ => text.clone(),
};
if !content.is_empty() {
made += 1;
cell.props.valign = Some(wordcraft_doc::props::VAlign::Center);
cell.blocks = content
.split('\n')
.take(20)
.map(|l| {
let mut p = Paragraph::with_text(l, CharProps::default()).styled("NoSpacing");
p.props.align = Some(Align::Left);
p.props.indent_left = Some(9.0);
para_block(p)
})
.collect();
}
cell.props.valign = Some(wordcraft_doc::props::VAlign::Center);
cell.blocks = content
.split('\n')
.take(20)
.map(|l| {
let mut p = Paragraph::with_text(l, CharProps::default()).styled("NoSpacing");
p.props.align = Some(Align::Left);
p.props.indent_left = Some(9.0);
para_block(p)
})
.collect();
}
d.body.push(std::sync::Arc::new(Block::Table(t)));
let mut end = Paragraph::new();
if sheet + 1 < sheets {
// End each sheet with the same next-page section, so the next table
// starts at the product's top margin without a leading blank line.
end.section = Some(Box::new(d.last_section.clone()));
}
d.body.push(para_block(end));
}
d.body = vec![std::sync::Arc::new(Block::Table(t)), para_block(Paragraph::new())];
s.add_document(d);
let _ = Pos::body(0, 0);
Ok(json!({"labels": if single.is_some() { 1 } else { rows * cols }, "filled": made, "product": prod.id}))
Ok(json!({"labels": if single.is_some() { 1 } else { sheets * per_sheet }, "filled": made, "product": prod.id}))
}
+2 -2
View File
@@ -2,9 +2,9 @@ use super::filter::refresh_shown;
use super::{CmdError, CmdResult, Session, Value, json, p};
/// Parse CSV (RFC 4180-ish: quotes, doubled quotes, commas/semicolons/tabs).
/// The field separator: whichever of `,` `;` and tab the first line (outside quotes) uses most.
/// The field separator: whichever of `,` `;` and tab the first non-blank line (outside quotes) uses most.
fn csv_separator(text: &str) -> char {
let first = text.lines().next().unwrap_or("");
let first = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
let mut quoted = false;
let mut unquoted = String::new();
for c in first.chars() {
+76
View File
@@ -52,6 +52,13 @@ fn csv_delimiter_ignores_quoted_text() {
assert_eq!(r, vec![vec!["Name, first, last", "City"], vec!["Ann", "Paris"]]);
}
#[test]
fn csv_delimiter_skips_leading_blank_lines() {
let want = vec![vec!["Name", "Email"], vec!["Ann", "ann@x.org"]];
assert_eq!(parse_csv("\nName,Email\nAnn,ann@x.org\n"), want);
assert_eq!(parse_csv("\r\n\r\nName;Email\r\nAnn;ann@x.org"), want);
}
/// Field types ignore case (ECMA-376 Part 1, 17.16.1), and `MERGEFIELDX` is another field.
#[test]
fn merge_field_types_ignore_case() {
@@ -375,6 +382,75 @@ fn envelope_sizes_add_to_document_and_label_products() {
assert!(s.run("mailings.labels", &json!({"product": "nope"})).is_err());
}
/// #4880: every selected recipient gets a label, including those beyond the first sheet.
#[test]
fn recipient_labels_continue_on_product_sheets() {
for product in LABEL_PRODUCTS {
let per_sheet = product.rows * product.cols;
for count in [1, per_sheet, per_sheet + 1, per_sheet * 2, per_sheet * 2 + 5] {
let mut s = Session::new(Document::from_text("Original letter"));
let rows: Vec<Value> = (0..count).map(|i| json!({"FirstName": format!("Recipient{i:03}"), "Address": "Original Street"})).collect();
s.run("mailings.recipients", &json!({"rows": rows})).unwrap();
let r = s.run("mailings.labels", &json!({"product": product.id, "fromRecipients": true})).unwrap();
let sheets = count.div_ceil(per_sheet);
assert_eq!(r["filled"], count, "{}: {count} recipients", product.id);
assert_eq!(r["labels"], sheets * per_sheet);
assert_eq!(s.doc.sections().len(), sheets);
let tables: Vec<_> = s
.doc
.body
.iter()
.filter_map(|b| match &**b {
Block::Table(t) => Some(t),
_ => None,
})
.collect();
assert_eq!(tables.len(), sheets);
let label_text: Vec<_> = tables
.iter()
.flat_map(|t| t.rows.iter())
.flat_map(|r| &r.cells)
.flat_map(|c| &c.blocks)
.filter_map(|b| match &**b {
Block::Para(p) if !p.text.is_empty() => Some(p.text.clone()),
_ => None,
})
.collect();
let expected: Vec<_> = (0..count).flat_map(|i| [format!("Recipient{i:03}"), "Original Street".to_string()]).collect();
assert_eq!(label_text, expected);
assert!(tables.windows(2).all(|t| t[0].grid == t[1].grid && t[0].props == t[1].props));
}
}
}
#[test]
fn recipient_label_sheets_keep_the_source_and_selected_order() {
let mut s = Session::new(Document::from_text("Original letter"));
s.run("text.insert", &json!({"text": "Draft "})).unwrap();
let original = s.doc.clone();
let id = s.document_id();
let rows: Vec<Value> = (0..65).map(|i| json!({"FirstName": format!("Recipient{i:03}")})).collect();
s.run("mailings.recipients", &json!({"rows": rows})).unwrap();
s.run(
"mailings.filterRecipients",
&json!({"rules": [{"field": "FirstName", "op": "notEqual", "value": "Recipient003"}],
"sort": [{"field": "FirstName", "descending": true}]}),
)
.unwrap();
let selected = s.run("mailings.editRecipients", &json!({})).unwrap();
let r = s.run("mailings.labels", &json!({"fromRecipients": true})).unwrap();
assert_eq!(r["filled"], 64);
assert_eq!(r["labels"], 90);
assert_eq!(s.undo_depth(), 0, "labels are a new document");
let text = s.doc.plain_text(StoryRef::Body);
let names: Vec<_> = text.split_whitespace().filter(|l| l.starts_with("Recipient")).collect();
assert_eq!(names, (0..65).rev().filter(|i| *i != 3).map(|i| format!("Recipient{i:03}")).collect::<Vec<_>>());
s.run("view.switchWindows", &json!({"id": id})).unwrap();
assert_eq!(s.doc, original, "the original letter stays open and unchanged");
assert_eq!(s.run("mailings.editRecipients", &json!({})).unwrap(), selected);
assert_eq!(s.undo_depth(), 1, "the source keeps its edit history");
}
fn b64(b: &[u8]) -> String {
super::super::insert::base64_encode(b)
}
+15 -3
View File
@@ -508,9 +508,21 @@ fn regenerate_toc(s: &mut Session, leader: Option<TabLeader>, tabs: &[(String, V
let page =
l.caret(&Pos { story: StoryRef::Body, path: target, off: e.lead }).and_then(|c| l.pages.get(c.page)).map(|pg| pg.number).unwrap_or(1);
let show_page = no_pages.is_none_or(|(a, b)| !(a..=b).contains(&(e.level + 1)));
let text = if show_page { format!("{}{sep}{page}", e.text) } else { e.text.clone() };
let props = CharProps { link: links.get(k).cloned().flatten(), ..Default::default() };
let mut para = Paragraph::with_text(&text, props).styled(&format!("TOC{}", e.level + 1));
let link = links.get(k).cloned().flatten();
// With a bookmark to point at, the page number is a `PAGEREF` field, as Word writes
// it, so updating it in Word follows the heading to its new page.
let pageref = link.as_deref().and_then(|l| l.strip_prefix('#')).filter(|_| show_page).map(|b| format!("PAGEREF {b} \\h"));
let text = match (show_page, &pageref) {
(true, None) => format!("{}{sep}{page}", e.text),
(true, Some(_)) => format!("{}{sep}", e.text),
(false, _) => e.text.clone(),
};
let props = CharProps { link, ..Default::default() };
let mut para = Paragraph::with_text(&text, props.clone()).styled(&format!("TOC{}", e.level + 1));
if let Some(instr) = pageref {
let field = InlineObject::Field { instr, result: page.to_string(), locked: false, dirty: false, code: false, form: String::new() };
para.insert_object(para.len(), field, &props)?;
}
para.mark = CharProps::default();
if e.text.contains('\t') {
para.props.tabs = tabs.iter().find(|(style, _)| Some(style) == para.props.style.as_ref()).map(|(_, tabs)| tabs.clone());
+3 -1
View File
@@ -20,6 +20,7 @@ pub(super) use cells::grid_cols;
pub use cells::selected_cells;
use cells::{cell, with_cells, with_table};
pub(crate) use cells::{cell_text, row_ends};
pub(super) use clear::clear_range_cells;
pub(crate) use clear::{backspace_cells, clear_cells};
use clear::{cell_prefix, guard_delete, text_spans};
use convert::{formula, from_text, quick_table, sort_table, to_text};
@@ -31,6 +32,7 @@ use select::{select_cells, select_column};
pub use structure::split_targets;
pub(super) use structure::{col, row};
use structure::{del_col, del_row, del_table, merge, split, split_table};
pub(super) use structure::{delete_table_at, selected_table};
fn in_table(s: &Session) -> Option<&'static str> {
if s.sel.focus.path.cell().is_some() { None } else { Some("the cursor isn't in a table") }
@@ -223,7 +225,7 @@ pub fn specs() -> Vec<CommandSpec> {
})
.params(r#"{"value"?: bool} (true: the first column stands at the right; without a value it toggles)"#)),
t(CommandSpec::new("table.properties", "Properties", "Table Layout › Table", properties).params(
r#"{"align"?: "left|center|right", "rtl"?: bool, "width"?: pt|null, "widthPct"?: percent, "indent"?: pt, "rowHeight"?: pt|null, "rowHeightRule"?: "atLeast|exact", "allowBreak"?: bool, "headerRow"?: bool, "columnWidth"?: pt, "cellWidth"?: pt|null, "valign"?: "top|center|bottom"} (row, column and cell settings apply to the caret's; without settings it returns the current ones)"#,
r#"{"align"?: "left|center|right", "rtl"?: bool, "width"?: pt|null, "widthPct"?: percent, "indent"?: pt, "rowHeight"?: pt|null, "rowHeightRule"?: "atLeast|exact", "allowBreak"?: bool, "headerRow"?: bool, "columnWidth"?: pt, "cellWidth"?: pt|null, "valign"?: "top|center|bottom"} (row, column and cell settings apply to the selected rows, columns and cells, or the caret's; without settings it returns the caret's current ones)"#,
)),
CommandSpec::new("table.fromText", "Convert Text to Table", "Insert › Tables", from_text)
.params(r#"{"separator"?: "tab|comma|paragraph|other", "char"?: string (with other), "columns"?: n, "autofit"?: "initial|fixed|contents|window", "width"?: pt (initial column width, 0 = Auto, at most 1584)}"#),
+24
View File
@@ -30,6 +30,30 @@ pub(crate) fn backspace_cells(s: &mut Session) -> Option<CmdResult> {
Some(r)
}
/// Clear the text of the cells `range` selects, leaving the cells (Cut of rows or cells, #1744);
/// tracked when Track Changes is on. The caret goes to the first cell.
pub(in crate::cmd) fn clear_range_cells(s: &mut Session, range: &crate::CellRange) -> Result<(), CmdError> {
let Some(t) = s.doc.table(range.story, &range.table) else { return Ok(()) };
// The cells Copy takes: columns widened over merged cells, as `Table::slice` does.
let (lo, hi, any) = t.slice_bounds(range.rows, range.cols);
let cells = if any { crate::CellRange { cols: (lo, hi.saturating_sub(1)), ..range.clone() }.cells(t) } else { Vec::new() };
let prefixes: Vec<Vec<u32>> = cells.into_iter().map(|(r, c)| cell_prefix(&range.table, r, c)).collect();
let spans = text_spans(s, range.story, &prefixes);
let first = spans.first().map(|(a, _)| a.clone());
if guard_delete(s, &spans)?.is_none() {
for (a, b) in spans.iter().rev() {
if a != b {
s.doc.delete_range(a, b)?;
}
}
}
s.column = None;
if let Some(a) = first {
s.sel = Selection::caret(s.doc.clamp(&a));
}
Ok(())
}
/// Delete with whole cells selected (#2802): each cell is emptied to one empty paragraph, as in
/// Word, nested tables included. That paragraph keeps the formatting of the cell's last one (its
/// mark is the end-of-cell mark). `None` without whole cells, and with Track Changes on (the text
+19 -21
View File
@@ -1,4 +1,4 @@
use super::cells::{cell, with_cells, with_table};
use super::cells::{cell, selected_cells, with_cells, with_table};
use super::{
Align, Border, BorderStyle, Borders, CmdError, CmdResult, HeightRule, Path, Pos, Rgb, Selection, Session, Table, TextDirection, VAlign, Value,
json, p, sel_result,
@@ -74,18 +74,9 @@ pub(super) fn set_column_widths(t: &mut Table, cells: &[(usize, usize)], w: f32)
}
}
fn set_column_width(t: &mut Table, r: usize, c: usize, w: f32) {
let g = t.grid_col(r, c);
if let Some(x) = t.grid.get_mut(g) {
*x = w;
t.props.width = None;
t.props.width_pct = None;
sync_cell_widths(t);
}
}
/// Table Properties: table, row, column and cell settings in one undoable step. Row, column and
/// cell settings apply to the caret's. Without any setting it returns the current values.
/// cell settings apply to the selected rows, columns and cells, as in Word, and to the caret's
/// when nothing is selected (#4857). Without any setting it returns the caret's current values.
pub(super) fn properties(s: &mut Session, v: &Value) -> CmdResult {
let (tp, r, c) = cell(s)?;
let keys = [
@@ -150,6 +141,11 @@ pub(super) fn properties(s: &mut Session, v: &Value) -> CmdResult {
let column_width = p::f32(v, "columnWidth").map(|x| x.clamp(6.0, 1584.0));
let cell_width = size("cellWidth", 6.0, 1584.0);
let rtl = p::bool(v, "rtl");
// The selected cells (a whole table, rows, a column…), else the caret's.
let cells = Some(selected_cells(s)).filter(|c| !c.is_empty()).unwrap_or_else(|| vec![(r, c)]);
let mut rows: Vec<usize> = cells.iter().map(|&(r, _)| r).collect();
rows.sort_unstable();
rows.dedup();
with_table(s, |t| {
if let Some(a) = align {
t.props.align = Some(a);
@@ -161,7 +157,7 @@ pub(super) fn properties(s: &mut Session, v: &Value) -> CmdResult {
t.props.indent = Some(i);
}
if let Some(cw) = column_width {
set_column_width(t, r, c, cw);
set_column_widths(t, &cells, cw);
}
if let Some(w) = width {
t.props.width = w;
@@ -170,7 +166,8 @@ pub(super) fn properties(s: &mut Session, v: &Value) -> CmdResult {
if let Some(pct) = width_pct {
t.props.width_pct = Some(pct);
}
if let Some(row) = t.rows.get_mut(r) {
for &r in &rows {
let Some(row) = t.rows.get_mut(r) else { continue };
if let Some(h) = row_height {
row.props.height = h;
row.props.height_rule = if h.is_some() { rule.unwrap_or(HeightRule::AtLeast) } else { HeightRule::Auto };
@@ -185,13 +182,14 @@ pub(super) fn properties(s: &mut Session, v: &Value) -> CmdResult {
if let Some(h) = header {
row.props.header = h;
}
if let Some(cl) = row.cells.get_mut(c) {
if let Some(w) = cell_width {
cl.props.width = w;
}
if let Some(va) = valign {
cl.props.valign = Some(va);
}
}
for &(r, c) in &cells {
let Some(cl) = t.rows.get_mut(r).and_then(|row| row.cells.get_mut(c)) else { continue };
if let Some(w) = cell_width {
cl.props.width = w;
}
if let Some(va) = valign {
cl.props.valign = Some(va);
}
}
})
+20
View File
@@ -2,6 +2,7 @@ use super::cells::{cell, selected_cells};
use super::clear::{cell_prefix, guard_delete, text_spans};
use super::layout::{fix_caret, sync_cell_widths};
use super::{Block, CmdError, CmdResult, Paragraph, Pos, Selection, Session, Value, json, p, sel_result};
use wordcraft_doc::Path;
/// Insert Above / Below: rows like the caret's, as many as the selection spans; the caret goes into the first.
pub(in crate::cmd) fn row(s: &mut Session, below: bool) -> CmdResult {
@@ -104,6 +105,25 @@ pub(super) fn del_col(s: &mut Session, _: &Value) -> CmdResult {
pub(super) fn del_table(s: &mut Session, _: &Value) -> CmdResult {
let (tp, _, _) = cell(s)?;
delete_table_at(s, &tp)
}
/// The table the selection covers whole (Select Table, a drag over all of it), if it does (#1744).
pub(in crate::cmd) fn selected_table(s: &Session) -> Option<Path> {
if let Some(r) = s.cell_range()
&& let Some(t) = s.doc.table(r.story, &r.table)
&& r.rows == (0, t.rows.len().saturating_sub(1))
&& r.cols == (0, t.cols().saturating_sub(1))
{
return Some(r.table.clone());
}
let (a, b) = s.sel.ordered();
s.doc.whole_table(&a, &b)
}
/// Delete Table for the table at `tp` (in the selection's story): tracked when Track Changes is on.
pub(in crate::cmd) fn delete_table_at(s: &mut Session, tp: &Path) -> CmdResult {
let tp = tp.clone();
let story = s.sel.focus.story;
let spans = text_spans(s, story, std::slice::from_ref(&tp.0));
if let Some(done) = guard_delete(s, &spans)? {
+17 -2
View File
@@ -1,5 +1,5 @@
use super::enter_tab::tab_indent_on;
use super::{Block, CmdResult, NumRef, Pos, Selection, Session, StoryRef, Value, delete_selection, delete_selection_effect, sel_result};
use super::{Block, CmdResult, NumRef, OBJ, Pos, Selection, Session, StoryRef, Value, delete_selection, delete_selection_effect, sel_result};
pub(super) fn backspace(s: &mut Session, v: &Value) -> CmdResult {
let r = backspace_inner(s, v)?;
@@ -43,8 +43,17 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
});
return sel_result(s);
}
let f = s.sel.focus.clone();
let mut f = s.sel.focus.clone();
let Some(para) = s.doc.para_at(&f) else { return sel_result(s) };
// Only floating objects before the caret: they are anchors, not characters, so this is the
// paragraph start and the shape stays (Word).
let before = para.text.get(..f.off).unwrap_or_default();
if !before.is_empty()
&& before.chars().all(|c| c == OBJ)
&& para.objects_with_offsets().take_while(|(o, _)| *o < f.off).all(|(_, o)| o.is_marker() || o.is_floating())
{
f.off = 0;
}
if f.off > 0 {
let a = Pos { off: para.backspace_from(f.off), ..f.clone() };
if let Some(c) = s.autocap.as_mut() {
@@ -98,6 +107,12 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
delete_selection(s)?;
return sel_result(s);
}
// Joining moves a content control whose start is at the paragraph edge. Check its
// contents explicitly: the paragraph-mark range itself ends before those contents.
for control in s.doc.control_ranges(f.story).into_iter().filter(|r| r.start.path == f.path && r.start.off == f.off) {
crate::cmd::controls::check_edit(s, &control.content_start(), &control.end)?;
}
crate::cmd::controls::check_delete(s, &a, &f)?;
let at = s.doc.delete_range(&a, &f)?;
s.sel = Selection::caret(at);
}
+5
View File
@@ -192,6 +192,11 @@ pub mod p {
pub fn u64(v: &Value, k: &str) -> Option<u64> {
v.get(k).and_then(|x| x.as_u64().or_else(|| x.as_f64().filter(|f| *f >= 0.0 && f.is_finite()).map(|f| f as u64)))
}
/// A 1-based page parameter as a 0-based index. A value that cannot fit in
/// `usize` (32-bit and wasm) saturates instead of wrapping to page 1.
pub fn page_index(page: u64) -> usize {
usize::try_from(page.max(1)).unwrap_or(usize::MAX).saturating_sub(1)
}
pub fn bool(v: &Value, k: &str) -> Option<bool> {
v.get(k).and_then(Value::as_bool)
}
+44 -1
View File
@@ -156,8 +156,20 @@ impl Session {
}
/// The document as the PDF of a print job `v` describes (see [`plan`]), with the number of
/// distinct pages chosen and copies. Does not touch the document.
/// distinct pages chosen and copies, as File › Print › Save these pages as PDF keeps it:
/// links work. Does not touch the document.
pub fn print_pdf(&self, v: &Value) -> Result<(Vec<u8>, PrintPlan), CmdError> {
self.print_pdf_with(v, true)
}
/// The PDF sent to a printer for job `v`: [`Self::print_pdf`] without link annotations,
/// which print nothing, and which a printer driver can't fit to the paper ("flatten page
/// annotations before using shrink-to-fit", #4734).
pub fn print_job_pdf(&self, v: &Value) -> Result<(Vec<u8>, PrintPlan), CmdError> {
self.print_pdf_with(v, false)
}
fn print_pdf_with(&self, v: &Value, links: bool) -> Result<(Vec<u8>, PrintPlan), CmdError> {
// Print Markup (File › Print) decides whether tracked changes and comments print.
let markup = crate::cmd::file::export_options(self, v);
let lay = self.export_layout_with(markup.markup);
@@ -170,6 +182,7 @@ impl Session {
include_markup: markup.markup,
comments: markup.comments,
paper,
links,
..Default::default()
};
let bytes = wordcraft_pdf::export_layout(&self.doc, &lay, &opts).map_err(|e| CmdError::Failed(e.to_string()))?;
@@ -238,6 +251,36 @@ mod tests {
assert!(r, "printPdf needs a path");
}
/// #4734: the PDF sent to a printer has no link annotations, which a printer driver can't fit
/// to the paper ("flatten page annotations before using shrink-to-fit"). An exported PDF and
/// one saved with Save these pages as PDF (`file.printPdf`) keep them.
#[test]
fn only_the_print_job_leaves_out_link_annotations() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.link", &json!({"url": "https://example.com", "text": "a link"})).unwrap();
let lay = s.export_layout();
let exported = wordcraft_pdf::export_layout(&s.doc, &lay, &wordcraft_pdf::PdfOptions::default()).unwrap();
let links = |bytes: &[u8]| {
let raw = String::from_utf8_lossy(bytes);
raw.contains("/Annots") && raw.contains("/Link")
};
assert!(links(&exported), "an exported PDF keeps the link");
for params in [json!({}), json!({"paper": "A4"}), json!({"markup": true})] {
let (job, _) = s.print_job_pdf(&params).unwrap();
assert!(!links(&job) && !String::from_utf8_lossy(&job).contains("/Annots"), "{params}");
let (saved, _) = s.print_pdf(&params).unwrap();
assert!(links(&saved), "{params}");
}
#[cfg(not(target_arch = "wasm32"))]
{
let path = std::env::temp_dir().join(format!("wc-print-links-{}.pdf", std::process::id()));
s.run("file.printPdf", &json!({"path": path.to_string_lossy()})).unwrap();
let saved = std::fs::read(&path).unwrap();
let _ = std::fs::remove_file(&path);
assert!(links(&saved), "Save these pages as PDF keeps the link");
}
}
/// Scale to paper size (#2108): the PDF goes on the chosen paper; the document keeps its size.
#[test]
fn print_pdf_scales_to_the_chosen_paper() {
+520 -7
View File
@@ -11,6 +11,7 @@ use crate::{Session, cmd};
mod bidi;
mod documents;
mod font_sizes;
mod fonts;
mod ime_noop;
mod revision_checks;
@@ -1943,6 +1944,291 @@ fn tables_commands() {
assert!(s.run("table.merge", &json!({})).is_err());
}
/// A 2×2 table "A1 B1 / A2 B2" after an "Intro" paragraph, the caret in its first cell.
fn table_doc() -> Session {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Intro"}));
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
for (i, t) in ["A1", "B1", "A2", "B2"].iter().enumerate() {
if i > 0 {
run(&mut s, "text.tab", json!({}));
}
run(&mut s, "text.insert", json!({"text": t}));
}
s
}
fn body_table_cells(s: &Session) -> Vec<Vec<String>> {
s.doc
.body
.iter()
.filter_map(|b| b.as_table())
.map(|t| {
t.rows
.iter()
.flat_map(|r| r.cells.iter())
.map(|c| c.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::<String>())
.collect()
})
.collect()
}
/// #1744: Select Table → Copy → Paste outside the table pastes a copy of the whole table.
#[test]
fn copy_whole_table_pastes_a_table() {
let mut s = table_doc();
run(&mut s, "table.selectTable", json!({}));
let r = run(&mut s, "edit.copy", json!({}));
assert!(r["text"].as_str().unwrap().contains("A1\tB1"), "clipboard text is tab separated: {r}");
assert!(matches!(s.clipboard.as_ref().unwrap().blocks.as_slice(), [wordcraft_doc::Block::Table(_)]), "the copy is the table");
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "edit.paste", json!({}));
let want = vec!["A1".to_string(), "B1".into(), "A2".into(), "B2".into()];
assert_eq!(body_table_cells(&s), vec![want.clone(), want]);
}
/// #1744: Select Table → Cut removes the whole table, and one Undo brings it back.
#[test]
fn cut_whole_table_removes_it_and_undoes() {
let mut s = table_doc();
run(&mut s, "table.selectTable", json!({}));
run(&mut s, "edit.cut", json!({}));
assert!(body_table_cells(&s).is_empty(), "the table is gone");
assert!(s.sel.focus.path.cell().is_none(), "the caret left the table");
run(&mut s, "edit.undo", json!({}));
assert_eq!(body_table_cells(&s), vec![vec!["A1".to_string(), "B1".into(), "A2".into(), "B2".into()]]);
// Paste puts it back as a table.
run(&mut s, "table.selectTable", json!({}));
run(&mut s, "edit.cut", json!({}));
run(&mut s, "edit.paste", json!({}));
assert_eq!(body_table_cells(&s).len(), 1);
}
/// A 3×3 table "A1 B1 C1 / A2 B2 C2 / A3 B3 C3" after an "Intro" paragraph (#1744).
fn table3_doc() -> Session {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Intro"}));
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "insert.table", json!({"rows": 3, "cols": 3}));
for (i, t) in ["A1", "B1", "C1", "A2", "B2", "C2", "A3", "B3", "C3"].iter().enumerate() {
if i > 0 {
run(&mut s, "text.tab", json!({}));
}
run(&mut s, "text.insert", json!({"text": t}));
}
s
}
/// Select whole cells of the body's table: rows and grid columns, both inclusive.
fn select_table_cells(s: &mut Session, rows: (usize, usize), cols: (usize, usize)) {
let tp = wordcraft_doc::Path(vec![1]);
crate::cmd::table::select_range(s, StoryRef::Body, &tp, rows, cols).unwrap();
assert_eq!(s.cell_range().map(|r| (r.rows, r.cols)), Some((rows, cols)));
}
fn row_cells(texts: &[&str]) -> Vec<String> {
texts.iter().map(|t| t.to_string()).collect()
}
/// #1744 (Word check A): copying whole rows, two cells of a row, or a block of cells over
/// several rows, then pasting outside the table, pastes a table of just those cells.
#[test]
fn copy_rows_and_cells_pastes_a_table_of_them() {
let full = row_cells(&["A1", "B1", "C1", "A2", "B2", "C2", "A3", "B3", "C3"]);
for (rows, cols, want) in [
((1, 1), (0, 2), row_cells(&["A2", "B2", "C2"])),
((0, 0), (0, 1), row_cells(&["A1", "B1"])),
((1, 2), (1, 2), row_cells(&["B2", "C2", "B3", "C3"])),
] {
let mut s = table3_doc();
select_table_cells(&mut s, rows, cols);
run(&mut s, "edit.copy", json!({}));
let Some([wordcraft_doc::Block::Table(t)]) = s.clipboard.as_ref().map(|f| f.blocks.as_slice()) else {
panic!("{rows:?} {cols:?}: the copy is a table, not {:?}", s.clipboard_text);
};
assert_eq!(t.rows.len(), rows.1 - rows.0 + 1, "{rows:?} {cols:?}");
assert_eq!(t.grid.len(), cols.1 - cols.0 + 1, "{rows:?} {cols:?}: the grid has the copied columns");
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "edit.paste", json!({}));
assert_eq!(body_table_cells(&s), vec![want, full.clone()], "{rows:?} {cols:?}");
}
}
/// #1744 (Word check A): Cut of whole rows, or of some cells, clears their text and leaves
/// the rows and cells; the clipboard still gets a table of them. One Undo brings the text back.
#[test]
fn cut_rows_and_cells_clears_their_text() {
let full = row_cells(&["A1", "B1", "C1", "A2", "B2", "C2", "A3", "B3", "C3"]);
for (rows, cols, left, copied) in [
((1, 1), (0, 2), row_cells(&["A1", "B1", "C1", "", "", "", "A3", "B3", "C3"]), row_cells(&["A2", "B2", "C2"])),
((0, 0), (0, 1), row_cells(&["", "", "C1", "A2", "B2", "C2", "A3", "B3", "C3"]), row_cells(&["A1", "B1"])),
((1, 2), (1, 2), row_cells(&["A1", "B1", "C1", "A2", "", "", "A3", "", ""]), row_cells(&["B2", "C2", "B3", "C3"])),
] {
let mut s = table3_doc();
select_table_cells(&mut s, rows, cols);
run(&mut s, "edit.cut", json!({}));
assert_eq!(body_table_cells(&s), vec![left.clone()], "{rows:?} {cols:?}: rows and cells stay");
assert!(s.sel.is_collapsed(), "{rows:?} {cols:?}");
run(&mut s, "edit.undo", json!({}));
assert_eq!(body_table_cells(&s), vec![full.clone()], "{rows:?} {cols:?}: one undo step");
select_table_cells(&mut s, rows, cols);
run(&mut s, "edit.cut", json!({}));
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "edit.paste", json!({}));
assert_eq!(body_table_cells(&s), vec![copied, left], "{rows:?} {cols:?}: the cut pastes as a table");
}
}
/// #1744 review: Cut clears every cell Copy takes: a cell merged across the selected columns
/// widens them, so the cell below the merge's second column is copied and cleared too.
#[test]
fn cut_clears_the_cells_a_merged_cell_widens_the_copy_to() {
let mut s = table3_doc();
select_table_cells(&mut s, (0, 0), (0, 1));
run(&mut s, "table.merge", json!({}));
select_table_cells(&mut s, (0, 1), (0, 0));
run(&mut s, "edit.copy", json!({}));
run(&mut s, "edit.cut", json!({}));
let cells = body_table_cells(&s).remove(0);
assert_eq!(cells.len(), 8, "one merged cell and seven others: {cells:?}");
assert_eq!(cells[1], "C1");
assert!(cells[2..4].iter().all(String::is_empty), "A2 and B2 are cleared: {cells:?}");
assert_eq!(cells[4..], ["C2", "A3", "B3", "C3"], "the rest stays");
}
/// #1744 review: an empty cell's selection enables Cut but not the Spike, which takes text only.
#[test]
fn spike_stays_disabled_on_an_empty_cell_selection() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Intro"}));
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
select_table_cells(&mut s, (0, 0), (0, 0));
let r = s.run("edit.spike", &json!({}));
assert!(matches!(r, Err(crate::CmdError::Disabled(_))), "{r:?}");
}
fn clip_is_table(s: &Session) -> bool {
matches!(s.clipboard.as_ref().map(|f| f.blocks.as_slice()), Some([wordcraft_doc::Block::Table(_)]))
}
/// The body table's cells as they read with every change accepted.
fn body_table_final(s: &Session) -> Vec<String> {
let t = s.doc.body.iter().find_map(|b| b.as_table()).expect("a table");
t.rows.iter().flat_map(|r| r.cells.iter()).map(|c| c.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.final_text()).collect()).collect()
}
/// #1744 review: an empty table or cell selected whole still copies, and Cut of the empty
/// table removes it, as in Word.
#[test]
fn copy_and_cut_of_an_empty_table_or_cell() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Intro"}));
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
run(&mut s, "table.selectTable", json!({}));
run(&mut s, "edit.copy", json!({}));
assert!(clip_is_table(&s), "an empty 1×1 table copies: {:?}", s.clipboard);
run(&mut s, "edit.cut", json!({}));
assert!(body_table_cells(&s).is_empty(), "Cut removes the empty table");
run(&mut s, "edit.undo", json!({}));
assert_eq!(body_table_cells(&s), vec![vec![String::new()]]);
let mut s = Session::new(wordcraft_doc::Document::new());
run(&mut s, "document.setText", json!({"text": "Intro"}));
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
select_table_cells(&mut s, (0, 0), (0, 0));
run(&mut s, "edit.copy", json!({}));
assert!(clip_is_table(&s), "an empty cell copies as a one-cell table");
run(&mut s, "edit.cut", json!({}));
assert_eq!(body_table_cells(&s).len(), 1, "Cut of an empty cell leaves the table");
}
/// #1744 review: a text selection over exactly a whole table (no whole-cell record) copies the
/// table; so does one over exactly a table nested in a cell. Other users of the copied range
/// read it as a table too: the selected text is tab separated, a Quick Part saved from it
/// inserts a table.
#[test]
fn text_selection_of_a_whole_table_copies_the_table() {
let mut s = table_doc();
let at = |path: Vec<u32>, off: usize| json!({"story": "body", "path": path, "off": off});
run(&mut s, "select.range", json!({"anchor": at(vec![1, 0, 0, 0], 0), "focus": at(vec![1, 1, 1, 0], 2)}));
assert!(s.cell_range().is_none(), "a text selection");
assert_eq!(s.selected_text(), "A1\tB1\nA2\tB2");
run(&mut s, "edit.copy", json!({}));
assert!(clip_is_table(&s));
run(&mut s, "insert.quickParts", json!({"save": "grid"}));
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "insert.quickParts", json!({"insert": "grid"}));
assert_eq!(body_table_cells(&s).len(), 2, "the Quick Part is a table");
// A table nested in cell A1: selecting its text whole copies just it.
let mut s = table_doc();
run(&mut s, "select.range", json!({"anchor": at(vec![1, 0, 0, 0], 2), "focus": at(vec![1, 0, 0, 0], 2)}));
run(&mut s, "insert.table", json!({"rows": 2, "cols": 1}));
run(&mut s, "text.insert", json!({"text": "N1"}));
run(&mut s, "text.tab", json!({}));
run(&mut s, "text.insert", json!({"text": "N2"}));
let (inner, _, _) = s.sel.focus.path.cell().unwrap();
assert_eq!(inner.0.len(), 4, "nested in a cell");
let first = [inner.0.clone(), vec![0, 0, 0]].concat();
let last = [inner.0.clone(), vec![1, 0, 0]].concat();
run(&mut s, "select.range", json!({"anchor": at(first, 0), "focus": at(last, 2)}));
assert!(s.cell_range().is_none(), "a text selection");
run(&mut s, "edit.copy", json!({}));
let Some([wordcraft_doc::Block::Table(t)]) = s.clipboard.as_ref().map(|f| f.blocks.as_slice()) else { panic!("{:?}", s.clipboard_text) };
assert_eq!(t.rows.len(), 2, "the nested table, not the outer one");
}
/// #1744 review: with Track Changes on, Cut of the whole table or of a row marks the text
/// deleted and keeps the table; one Undo takes the marks back.
#[test]
fn tracked_cut_of_a_table_or_row_marks_text_deleted() {
let full = row_cells(&["A1", "B1", "C1", "A2", "B2", "C2", "A3", "B3", "C3"]);
let mut s = table3_doc();
s.doc.settings.track_changes = true;
run(&mut s, "table.selectTable", json!({}));
run(&mut s, "edit.cut", json!({}));
assert!(clip_is_table(&s));
assert_eq!(body_table_cells(&s), vec![full.clone()], "the table and its text stay, marked");
assert!(body_table_final(&s).iter().all(String::is_empty), "{:?}", body_table_final(&s));
run(&mut s, "edit.undo", json!({}));
assert_eq!(body_table_final(&s), full);
select_table_cells(&mut s, (1, 1), (0, 2));
run(&mut s, "edit.cut", json!({}));
assert!(clip_is_table(&s));
assert_eq!(body_table_cells(&s), vec![full.clone()]);
assert_eq!(body_table_final(&s), row_cells(&["A1", "B1", "C1", "", "", "", "A3", "B3", "C3"]));
run(&mut s, "edit.undo", json!({}));
assert_eq!(body_table_final(&s), full);
}
/// #1744 review: Cut of a table, or of cells, holding a content control locked against deletion
/// is refused and changes nothing in the document; the copy is made first, so the clipboard
/// holds the table, as for any refused Cut.
#[test]
fn cut_of_cells_with_a_locked_control_is_refused() {
for whole in [true, false] {
let mut s = table3_doc();
run(&mut s, "edit.find", json!({"text": "B2"}));
run(&mut s, "developer.richText", json!({"tag": "keep"}));
run(&mut s, "developer.properties", json!({"control": "keep", "lockDelete": true}));
if whole {
run(&mut s, "table.selectTable", json!({}));
} else {
select_table_cells(&mut s, (1, 1), (0, 2));
}
let before = s.doc.clone();
assert!(s.run("edit.cut", &json!({})).is_err(), "whole table: {whole}");
assert_eq!(s.doc, before, "whole table: {whole}");
assert!(s.status.contains("can't be deleted"), "{}", s.status);
assert!(clip_is_table(&s), "whole table: {whole}");
}
}
#[test]
fn inserting_a_column_keeps_cell_widths_in_step_with_the_grid() {
let mut s = s();
@@ -2432,6 +2718,79 @@ fn table_properties_apply_in_one_step() {
assert!(s.run("table.properties", &json!({"valign": "middle"})).is_err());
}
/// #4857: Table Properties' row settings apply to every selected row (the whole table, say), the
/// column width to every selected column and the cell settings to every selected cell, as in
/// Word, in one undo step; with nothing selected, to the caret's alone.
#[test]
fn table_properties_apply_to_the_selected_rows_columns_and_cells() {
use wordcraft_doc::props::VAlign;
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 3, "cols": 2}));
let table = |s: &Session| s.doc.body.iter().find_map(|b| b.as_table()).unwrap().clone();
let cant_split = |s: &Session| table(s).rows.iter().map(|r| r.props.cant_split).collect::<Vec<_>>();
run(&mut s, "table.properties", json!({"allowBreak": false}));
assert_eq!(cant_split(&s), [true, false, false], "the caret's row alone");
run(&mut s, "edit.undo", json!({}));
let before = table(&s);
run(&mut s, "table.selectTable", json!({}));
run(&mut s, "table.properties", json!({"allowBreak": false, "rowHeight": 30.0, "columnWidth": 100.0, "cellWidth": 90.0, "valign": "center"}));
let t = table(&s);
assert_eq!(cant_split(&s), [true, true, true], "every selected row");
assert!(t.rows.iter().all(|r| r.props.height == Some(30.0)));
assert_eq!(t.grid, [100.0, 100.0], "every selected column");
assert!(t.rows.iter().flat_map(|r| &r.cells).all(|c| c.props.width == Some(90.0) && c.props.valign == Some(VAlign::Center)));
run(&mut s, "edit.undo", json!({}));
assert_eq!(table(&s), before, "one undo step");
// A selected row: that row alone; a selected column: that column alone.
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "text.tab", json!({}));
run(&mut s, "text.tab", json!({}));
run(&mut s, "table.selectRow", json!({}));
run(&mut s, "table.properties", json!({"allowBreak": false}));
assert_eq!(cant_split(&s), [false, true, false]);
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "text.tab", json!({}));
run(&mut s, "table.selectColumn", json!({}));
run(&mut s, "table.properties", json!({"columnWidth": 150.0}));
assert_eq!(table(&s).grid.get(1), Some(&150.0));
assert_ne!(table(&s).grid.first(), Some(&150.0));
}
/// #4857: a tall row across a page break moves to the next page whole once the whole table is
/// selected and "Allow row to break across pages" is turned off.
#[test]
fn a_selected_table_kept_from_breaking_moves_its_row_whole() {
// Lines above the table until its 12-line second row starts across the page break.
let found = (0..80u32).find_map(|above| {
let mut s = s();
for k in 0..above {
run(&mut s, "text.insert", json!({"text": format!("Line {k}")}));
run(&mut s, "text.newParagraph", json!({}));
}
run(&mut s, "insert.table", json!({"rows": 3, "cols": 1}));
run(&mut s, "text.insert", json!({"text": "Kiln"}));
run(&mut s, "text.tab", json!({}));
for i in 0..12 {
run(&mut s, "text.insert", json!({"text": format!("Press note {i}")}));
run(&mut s, "text.newParagraph", json!({}));
}
(row_pages(&mut s, above).len() == 2).then_some((s, above))
});
let (mut s, table) = found.expect("the row starts across the page break");
run(&mut s, "table.selectTable", json!({}));
run(&mut s, "table.properties", json!({"allowBreak": false}));
assert_eq!(row_pages(&mut s, table), [1], "the row moves to the next page whole");
}
/// The pages the second row of the table at body block `table` has lines on.
fn row_pages(s: &mut Session, table: u32) -> Vec<usize> {
let row = [table, 1];
let on_page = |p: &wordcraft_layout::Page| {
p.items.iter().any(|it| matches!(it, wordcraft_layout::Placed::Lines { path, .. } if path.0.get(..2) == Some(&row[..])))
};
s.layout().pages.iter().enumerate().filter(|(_, p)| on_page(p)).map(|(i, _)| i).collect()
}
/// Table Layout › Text Direction (#226) cycles the selected cells' text through horizontal,
/// top-to-bottom and bottom-to-top, and the layout turns the text.
#[test]
@@ -3754,7 +4113,8 @@ fn update_toc_clears_stale_field_result_for_docx_span() {
s
};
let b = xml.find(r#"w:fldCharType="begin""#).expect("TOC begin marker present");
let e = xml.find(r#"w:fldCharType="end""#).expect("TOC end marker present");
// The TOC's end is the last one: the entries' page numbers are `PAGEREF` fields inside it.
let e = xml.rfind(r#"w:fldCharType="end""#).expect("TOC end marker present");
assert!(b < e, "begin marker precedes end marker");
let inside_field = &xml[b..e];
let after_field = &xml[e..];
@@ -3939,6 +4299,50 @@ fn toc_page_numbers_follow_headings() {
}
}
/// A hyperlinked TOC's page numbers are `PAGEREF` fields to the headings' bookmarks, as Word
/// writes them, so updating them in Word follows the headings; they survive a save and reopen.
#[test]
fn toc_page_numbers_are_pageref_fields() {
let mut s = s();
run(&mut s, "references.toc", json!({}));
run(&mut s, "caret.docEnd", json!({}));
for name in ["Alpha", "Bravo"] {
run(&mut s, "text.pageBreak", json!({}));
run(&mut s, "text.insert", json!({"text": name}));
run(&mut s, "para.heading1", json!({}));
run(&mut s, "text.newParagraph", json!({}));
}
run(&mut s, "references.updateToc", json!({}));
let fields: Vec<(String, String)> = s
.doc
.body
.iter()
.filter_map(|b| b.as_para())
.filter(|p| p.props.style.as_deref() == Some("TOC1"))
.flat_map(|p| p.objects.iter())
.filter_map(|o| match o {
wordcraft_doc::para::InlineObject::Field { instr, result, .. } => Some((instr.clone(), result.clone())),
_ => None,
})
.collect();
assert_eq!(fields.len(), 2, "{fields:?}");
for ((instr, result), page) in fields.iter().zip(["2", "3"]) {
let mark = instr.strip_prefix("PAGEREF ").and_then(|r| r.strip_suffix(" \\h")).unwrap_or_else(|| panic!("{instr}"));
assert!(mark.starts_with("_Toc") && s.doc.bookmarks().iter().any(|(n, _)| n == mark), "{instr}");
assert_eq!(result, page);
}
let expected = text(&s);
assert!(expected.contains("Alpha\t2") && expected.contains("Bravo\t3"), "{expected}");
let bytes = crate::io::save_bytes("toc.docx", &s.doc).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
assert_eq!(xml.matches("PAGEREF _Toc").count(), 2, "{xml}");
let mut s = Session::new(crate::io::open_bytes("toc.docx", &bytes).unwrap());
run(&mut s, "references.updateFields", json!({}));
assert_eq!(text(&s), expected);
}
/// Headings numbered through a list carry their numbers, and the level's separator, into the
/// TOC entries.
#[test]
@@ -4594,7 +4998,7 @@ fn word_count_with_an_object_selected_counts_the_document() {
fn custom_properties_delete_legacy_names_but_new_names_keep_the_limit() {
let mut s = s();
let long = "x".repeat(crate::cmd::file::MAX_CUSTOM_NAME + 1);
s.doc.custom_props.push(wordcraft_doc::CustomProp { name: long.clone(), kind: "raw".into(), value: "<data/>".into() });
s.doc.custom_props.push(wordcraft_doc::CustomProp { name: long.clone(), kind: "raw".into(), value: "<data/>".into(), ..Default::default() });
let mut custom = serde_json::Map::new();
custom.insert(long.clone(), serde_json::Value::Null);
run(&mut s, "file.properties", json!({"custom": custom}));
@@ -4631,6 +5035,36 @@ fn custom_properties_case_only_rename_keeps_the_new_spelling() {
assert_eq!(props, [("Status", "draft")], "the property keeps the new spelling and its value");
}
/// Linked text objects carry rename metadata explicitly and retain the existing input bounds.
#[test]
fn custom_properties_linked_values_are_typed_bounded_and_atomic() {
let mut s = s();
s.doc.set_custom_prop("Status", "draft");
s.doc.custom_props[0].link_target = Some("OriginalBookmark".into());
let before = s.doc.clone();
let depth = s.undo_depth();
let max = crate::cmd::file::MAX_CUSTOM_VALUE;
for value in [
json!({"value": "changed", "linkTarget": 7}),
json!({"value": 7, "linkTarget": "OriginalBookmark"}),
json!({"value": "changed", "linkTarget": "OriginalBookmark", "kind": "raw"}),
json!({"value": "x".repeat(max + 1), "linkTarget": "OriginalBookmark"}),
json!({"value": "changed", "linkTarget": "x".repeat(max + 1)}),
] {
assert!(s.run("file.properties", &json!({"title": "must roll back", "custom": {"Status": null, "Renamed": value}})).is_err());
assert_eq!(s.doc, before);
assert_eq!(s.undo_depth(), depth);
}
run(&mut s, "file.properties", json!({"custom": {"Status": {"value": "final", "linkTarget": "OriginalBookmark"}}}));
run(&mut s, "file.properties", json!({"custom": {"Status": "string edit"}}));
assert_eq!(s.doc.custom_props[0].link_target.as_deref(), Some("OriginalBookmark"));
for value in [json!({"value": "unlinked", "linkTarget": null}), json!({"value": "unlinked"})] {
s.doc.custom_props[0].link_target = Some("OriginalBookmark".into());
run(&mut s, "file.properties", json!({"custom": {"Status": value}}));
assert_eq!(s.doc.custom_props[0].link_target, None);
}
}
/// #2982: what the Custom Properties dialog saves (through `file.properties`) survives a .docx
/// save and reopen.
#[test]
@@ -8038,9 +8472,18 @@ fn next_footnote_skips_endnotes() {
assert_eq!(s.sel.focus.off, 9);
}
/// Tests that install a test dictionary wait for the dictionaries-folder test, which changes the
/// folder and then reads what each language reports: an install in between reports no file.
static DICTIONARIES: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn dictionaries_turn() -> std::sync::MutexGuard<'static, ()> {
DICTIONARIES.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// A tiny synthetic Polish Hunspell dictionary (written for these tests). Real dictionaries are
/// installed on the system, never bundled; every Polish test installs this same one.
fn polish_test_dictionary() {
let _turn = dictionaries_turn();
wordcraft_proof::pl::install_dictionary(POLISH_TEST_AFF, POLISH_TEST_DIC).unwrap();
}
@@ -8050,6 +8493,7 @@ const POLISH_TEST_DIC: &str = "12\npłynie\nrzeka\na\nto\nwiem\nże\ntak\nw\nog
/// A tiny synthetic Portuguese Hunspell dictionary (written for these tests). Real dictionaries
/// are installed on the system, never bundled; every Portuguese test installs this same one.
fn portuguese_test_dictionary() {
let _turn = dictionaries_turn();
wordcraft_proof::pt::install_dictionary(PORTUGUESE_TEST_AFF, PORTUGUESE_TEST_DIC).unwrap();
}
@@ -8063,6 +8507,7 @@ const PORTUGUESE_TEST_DIC: &str = "11\nsaudade\ncoração\ne\nele\ndisse\nnão\n
/// running meanwhile see the same words; it stays set for the rest of the run for that reason.
#[test]
fn dictionaries_folder_option_is_saved_searched_and_reported() {
let _turn = dictionaries_turn();
let dir = std::env::temp_dir().join(format!("wc-engine-dictionaries-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for (name, aff, dic) in [
@@ -8093,14 +8538,20 @@ fn dictionaries_folder_option_is_saved_searched_and_reported() {
assert_eq!(r["dictionariesFolder"], json!(dir.to_string_lossy()));
let dicts = r["dictionaries"].as_array().unwrap();
let file = |lang: &str| dicts.iter().find(|d| d["language"] == lang).map(|d| d["file"].clone());
assert_eq!(file("pl"), Some(json!(dir.join("pl_PL.dic").to_string_lossy())));
assert_eq!(file("pt"), Some(json!(dir.join("pt_BR.dic").to_string_lossy())));
assert_eq!(file("id"), Some(json!(dir.join("id_ID.dic").to_string_lossy())));
// Each language reports the folder's file, which is searched first.
let reported = |got: Option<&serde_json::Value>, name: &str| {
let want = dir.join(format!("{name}.dic"));
assert_eq!(got, Some(&json!(want.to_string_lossy())), "{name}: {dicts:?}");
assert_eq!(wordcraft_proof::hunspell::find(&[name]).map(|(_, dic)| dic), Some(want));
};
reported(file("pl").as_ref(), "pl_PL");
reported(file("pt").as_ref(), "pt_BR");
reported(file("id").as_ref(), "id_ID");
// Each Spanish variety reports its own file (Spain, Mexico, Latin America, Uruguay, in order).
let spanish: Vec<serde_json::Value> = dicts.iter().filter(|d| d["language"] == "es").map(|d| d["file"].clone()).collect();
assert_eq!(spanish.len(), 4);
assert_eq!(spanish.first(), Some(&json!(dir.join("es_ES.dic").to_string_lossy())));
assert_eq!(spanish.last(), Some(&json!(dir.join("es_UY.dic").to_string_lossy())));
reported(spanish.first(), "es_ES");
reported(spanish.last(), "es_UY");
// Polish text is checked against the folder's dictionary.
run(&mut s, "document.setText", json!({"text": "rzeka płynie żeka"}));
run(&mut s, "select.all", json!({}));
@@ -8150,6 +8601,7 @@ fn portuguese_proofing_through_the_review_commands() {
}
fn indonesian_test_dictionary() {
let _turn = dictionaries_turn();
wordcraft_proof::id::install_dictionary(INDONESIAN_TEST_AFF, INDONESIAN_TEST_DIC).unwrap();
}
@@ -8321,6 +8773,7 @@ const URUGUAYAN_TEST_DIC: &str = "10\nesto\nes\nuna\nprueba/S\ndel\ncorrector\nt
fn spanish_test_dictionaries() {
use wordcraft_proof::ProofLang;
let _turn = dictionaries_turn();
for (lang, dic) in [(ProofLang::Es, SPANISH_TEST_DIC), (ProofLang::EsUy, URUGUAYAN_TEST_DIC)] {
wordcraft_proof::es::install_dictionary(lang, SPANISH_TEST_AFF, dic).unwrap();
}
@@ -8449,6 +8902,7 @@ const ITALIAN_TEST_AFF: &str = "SET UTF-8\nTRY aeiortnsclcaffèéè\nMAP 2\nMAP
const ITALIAN_TEST_DIC: &str = "6\ncasa\ncaffè\nroma\ndisse\ne\nper\n";
fn italian_test_dictionary() {
let _turn = dictionaries_turn();
wordcraft_proof::it::install_dictionary(ITALIAN_TEST_AFF, ITALIAN_TEST_DIC).unwrap();
}
@@ -11086,3 +11540,62 @@ fn scale_font_sizes_reaches_char_table_and_cs_sizes_and_unset_default() {
assert_eq!(grid.header_chr.size, Some(16.0));
assert_eq!(grid.regions.get(&TablePart::Band2Horz).unwrap().chr.size, Some(10.0));
}
/// #3676: a 1-based page parameter becomes a 0-based index without wrapping.
/// On 32-bit and wasm the old cast truncated 2^32 to 0 and `0 - 1` panicked.
#[test]
fn page_index_saturates_instead_of_wrapping_to_page_one() {
assert_eq!(crate::p::page_index(0), 0);
assert_eq!(crate::p::page_index(1), 0);
assert_eq!(crate::p::page_index(2), 1);
assert_ne!(crate::p::page_index(1u64 << 32), 0, "2^32 must not wrap to page 1");
assert_eq!(crate::p::page_index(u64::MAX), usize::MAX - 1);
}
/// #3676: absolute page numbers that cannot be an index are rejected, not silently
/// turned into page 1. `edit.goto` clamps to the last page, as it does past the end.
#[test]
fn hostile_absolute_pages_never_land_on_page_one() {
let mut s = s();
for i in 0..5 {
if i > 0 {
run(&mut s, "insert.pageBreak", json!({}));
}
run(&mut s, "text.insert", json!({"text": format!("Page {}", i + 1)}));
}
let page = |s: &mut Session| s.layout().caret_on(&s.sel.focus, s.page_hint).unwrap().page + 1;
run(&mut s, "edit.goto", json!({"page": 1u64 << 32}));
assert_eq!(page(&mut s), 5, "an out-of-range page lands on the last page");
run(&mut s, "edit.goto", json!({"page": u64::MAX}));
assert_eq!(page(&mut s), 5, "u64::MAX lands on the last page");
run(&mut s, "edit.goto", json!({"page": 0}));
assert_eq!(page(&mut s), 1, "page 0 is the first page");
}
/// #3676: `caret.set` takes a 0-based page; an oversized page is rejected instead of
/// truncating to page 0 on 32-bit and wasm.
#[test]
fn caret_set_rejects_a_page_number_that_does_not_fit() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
let r = s.run("caret.set", &json!({"page": 1u64 << 32, "x": 100.0, "y": 100.0}));
assert!(matches!(r, Err(crate::CmdError::Params(_))), "page 2^32: {r:?}");
}
/// #3676: `file.exportPng` rejects a page number that cannot be an index instead of
/// exporting page 1 (the 32-bit truncation) and writing it to the requested path.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn export_png_rejects_a_page_number_that_does_not_fit() {
let dir = scratch_dir("export-png-hostile-page");
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
let target = dir.join("page.png");
let r = s.run("file.exportPng", &json!({"path": target.to_string_lossy(), "page": 1u64 << 32}));
assert!(matches!(r, Err(crate::CmdError::Params(_))), "page 2^32: {r:?}");
assert!(!target.exists(), "no file is written for an invalid page");
let r = s.run("file.exportPng", &json!({"path": target.to_string_lossy(), "page": u64::MAX}));
assert!(matches!(r, Err(crate::CmdError::Params(_))), "page u64::MAX: {r:?}");
assert!(!target.exists(), "no file is written for an invalid page");
let _ = std::fs::remove_dir_all(&dir);
}
+229
View File
@@ -0,0 +1,229 @@
//! Relative font sizes keep each selected run's size (#4903).
use serde_json::json;
use wordcraft_doc::{Document, Pos};
use crate::Session;
fn mixed() -> Session {
let mut doc = Document::from_text("Large heading\nSmall detail");
for (block, size) in [(0, 20.0), (1, 10.0)] {
let end = doc.para_at(&Pos::body(block, 0)).unwrap().len();
doc.format_range(&Pos::body(block, 0), &Pos::body(block, end), &|c| {
c.size = Some(size);
c.size_cs = Some(size);
})
.unwrap();
}
let mut s = Session::new(doc);
s.run("select.all", &json!({})).unwrap();
s
}
fn sizes(s: &Session) -> Vec<f32> {
(0..2)
.map(|block| {
let p = s.doc.para_at(&Pos::body(block, 0)).unwrap();
s.doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(0)).size
})
.collect()
}
#[test]
fn relative_font_sizes_preserve_mixed_runs_and_one_undo() {
for (command, expected) in [
("format.growFont", [22.0, 10.5]),
("format.shrinkFont", [18.0, 9.0]),
("format.growFont1", [21.0, 11.0]),
("format.shrinkFont1", [19.0, 9.0]),
] {
let mut s = mixed();
let before = s.doc.clone();
let selection = s.sel.clone();
let depth = s.undo_depth();
s.run(command, &json!({})).unwrap();
assert_eq!(sizes(&s), expected, "{command}");
assert_eq!(s.sel, selection);
assert_eq!(s.undo_depth(), depth + 1);
let after = s.doc.clone();
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc, before);
s.run("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc, after);
}
}
#[test]
fn relative_font_sizes_resolve_styles_and_preserve_other_properties() {
use wordcraft_doc::props::CharProps;
use wordcraft_doc::styles::{Style, StyleKind};
let mut s = mixed();
s.doc.styles.default_chr.size_cs = Some(30.0);
s.doc.styles.upsert(Style {
id: "Large".into(),
name: "Large".into(),
based_on: Some("Normal".into()),
chr: CharProps { size: Some(20.0), bold: Some(true), ..Default::default() },
..Default::default()
});
s.doc.styles.upsert(Style {
id: "Small".into(),
name: "Small".into(),
kind: StyleKind::Character,
chr: CharProps { size: Some(10.0), italic: Some(true), ..Default::default() },
..Default::default()
});
for block in 0..2 {
let p = s.doc.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(block)).unwrap();
p.props.style = Some(if block == 0 { "Large" } else { "Normal" }.into());
for c in std::iter::once(&mut p.mark).chain(p.runs.iter_mut().map(|r| &mut r.props)) {
c.size = None;
c.size_cs = None;
if block == 1 {
c.style = Some("Small".into());
}
}
}
s.run("format.growFont", &json!({})).unwrap();
assert_eq!(sizes(&s), [22.0, 10.5]);
for (block, size) in [(0, 22.0), (1, 10.5)] {
let p = s.doc.para_at(&Pos::body(block, 0)).unwrap();
assert_eq!(p.mark.size, Some(size));
for (_, c) in p.run_ranges() {
assert_eq!((c.size, c.size_cs), (Some(size), Some(size)), "both scripts still get the explicit override");
let resolved = s.doc.styles.resolve_char(p.props.style.as_deref(), c);
assert!(if block == 0 { resolved.bold } else { resolved.italic });
}
}
}
#[test]
fn relative_font_sizes_change_only_the_selected_unicode_pieces() {
let mut s = Session::new(Document::from_text("éA中Bz"));
s.doc.format_range(&Pos::body(0, 2), &Pos::body(0, 3), &|c| c.size = Some(20.0)).unwrap();
s.doc.format_range(&Pos::body(0, 3), &Pos::body(0, 6), &|c| c.size = Some(10.0)).unwrap();
let before = s.doc.clone();
s.run("select.range", &json!({"anchor": Pos::body(0, 2), "focus": Pos::body(0, 6)})).unwrap();
s.run("format.growFont1", &json!({})).unwrap();
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
let original = before.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.text, "éA中Bz");
assert_eq!(p.props_of_char(2).size, Some(21.0));
assert_eq!(p.props_of_char(3).size, Some(11.0));
assert_eq!(p.props_of_char(0), original.props_of_char(0));
assert_eq!(p.props_of_char(6), original.props_of_char(6));
assert_eq!(p.mark, original.mark, "a partial selection leaves the paragraph mark alone");
}
#[test]
fn relative_font_sizes_keep_the_caret_and_word_targets() {
let mut s = Session::new(Document::new());
s.run("format.size", &json!({"size": 10})).unwrap();
for size in [11.0, 12.0] {
s.run("format.growFont1", &json!({})).unwrap();
assert_eq!(s.pending.as_ref().unwrap().size, Some(size));
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().mark.size, Some(size));
}
s.run("text.insert", &json!({"text": "abcd ef"})).unwrap();
s.doc.format_range(&Pos::body(0, 0), &Pos::body(0, 2), &|c| c.size = Some(20.0)).unwrap();
s.doc.format_range(&Pos::body(0, 2), &Pos::body(0, 4), &|c| c.size = Some(10.0)).unwrap();
s.run("caret.set", &json!({"pos": Pos::body(0, 2)})).unwrap();
let selection = s.sel.clone();
s.run("format.growFont1", &json!({})).unwrap();
assert_eq!(s.sel, selection);
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!((p.props_of_char(0).size, p.props_of_char(2).size, p.props_of_char(5).size), (Some(21.0), Some(11.0), Some(12.0)));
}
#[test]
fn relative_font_sizes_keep_column_cell_and_text_box_targets() {
use wordcraft_doc::{Path, StoryRef};
let mut s = super::column_doc();
super::block(&mut s);
for (block, size) in [(0, 20.0), (1, 10.0), (2, 14.0)] {
s.doc.format_range(&Pos::body(block, 0), &Pos::body(block, 6), &|c| c.size = Some(size)).unwrap();
}
s.run("format.growFont1", &json!({})).unwrap();
assert!(s.column_segments().is_some());
for (block, size) in [(0, 20.0), (1, 10.0), (2, 14.0)] {
let p = s.doc.para_at(&Pos::body(block, 0)).unwrap();
assert_eq!((p.props_of_char(1).size, p.props_of_char(2).size, p.props_of_char(4).size), (Some(size), Some(size + 1.0), Some(size)));
assert_eq!(p.mark.size, Some(size));
}
let mut s = Session::new(Document::new());
s.run("insert.table", &json!({"rows": 1, "cols": 2})).unwrap();
let paths = [Path(vec![0, 0, 0, 0]), Path(vec![0, 0, 1, 0])];
for (path, size) in paths.iter().zip([20.0, 10.0]) {
s.doc.para_mut(StoryRef::Body, path).unwrap().mark.size = Some(size);
}
s.run("table.selectTable", &json!({})).unwrap();
let _ = s.layout();
s.run("format.growFont1", &json!({})).unwrap();
for (path, size) in paths.iter().zip([21.0, 11.0]) {
assert_eq!(s.doc.para(StoryRef::Body, path).unwrap().mark.size, Some(size), "empty cells retain distinct sizes");
}
let mut s = Session::new(Document::from_text("Body"));
let id = s.run("insert.textBox", &json!({"text": "AB"})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
let at = |off| Pos { story, path: Path::top(0), off };
for (a, b, size) in [(0, 1, 20.0), (1, 2, 10.0)] {
s.doc.format_range(&at(a), &at(b), &|c| c.size = Some(size)).unwrap();
}
s.run("select.range", &json!({"anchor": Pos::body(0, 0), "focus": Pos::body(0, "\u{fffc}".len())})).unwrap();
let before = s.doc.para_at(&Pos::body(0, 0)).unwrap().clone();
s.run("format.growFont1", &json!({})).unwrap();
assert_eq!(*s.doc.para_at(&Pos::body(0, 0)).unwrap(), before);
let p = s.doc.para(story, &Path::top(0)).unwrap();
assert_eq!((p.props_of_char(0).size, p.props_of_char(1).size), (Some(21.0), Some(11.0)));
}
#[test]
fn relative_font_sizes_keep_bounds_protection_and_saved_sizes() {
for (command, values, expected) in [
("format.growFont", [72.0, 1638.0], [80.0, 1638.0]),
("format.shrinkFont", [1.0, 8.0], [1.0, 1.0]),
("format.growFont1", [1.0, 1638.0], [2.0, 1638.0]),
("format.shrinkFont1", [1.0, 1638.0], [1.0, 1637.0]),
] {
let mut s = mixed();
for (block, size) in values.into_iter().enumerate() {
let end = s.doc.para_at(&Pos::body(block, 0)).unwrap().len();
s.doc.format_range(&Pos::body(block, 0), &Pos::body(block, end), &|c| c.size = Some(size)).unwrap();
}
s.run(command, &json!({})).unwrap();
assert_eq!(sizes(&s), expected, "{command}");
let bytes = crate::io::save_bytes("sizes.docx", &s.doc).unwrap();
let reopened = Session::new(crate::io::open_bytes("sizes.docx", &bytes).unwrap());
assert_eq!(sizes(&reopened), expected, "saved {command}");
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
let before = s.doc.clone();
let depth = s.undo_depth();
assert!(s.run(command, &json!({})).is_err());
assert_eq!(s.doc, before);
assert_eq!(s.undo_depth(), depth);
}
}
#[test]
fn relative_font_sizes_keep_paragraph_mark_link_filtering() {
let mut s = mixed();
for (block, style) in [(0, "Hyperlink"), (1, "CustomLink")] {
let p = s.doc.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(block)).unwrap();
p.mark.link = Some("https://example.test/mark".into());
p.mark.style = Some(style.into());
p.runs[0].props.link = Some("https://example.test/text".into());
p.runs[0].props.style = Some("Hyperlink".into());
}
s.run("format.growFont", &json!({})).unwrap();
assert_eq!(sizes(&s), [22.0, 10.5]);
for block in 0..2 {
let p = s.doc.para_at(&Pos::body(block, 0)).unwrap();
assert_eq!(p.mark.link, None);
assert_eq!(p.mark.style.as_deref(), if block == 0 { None } else { Some("CustomLink") });
assert!(p.run_ranges().all(|(_, c)| c.link.as_deref() == Some("https://example.test/text") && c.style.as_deref() == Some("Hyperlink")));
}
}
+245
View File
@@ -522,6 +522,9 @@ fn enter_finishes_a_word_for_autocorrect() {
fn autocorrect_finds_the_word_after_a_wide_space() {
let s = typed("a\u{a0}teh x\u{3000}teh ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "a\u{a0}the x\u{3000}the ");
// A narrow no-break space and a figure space too.
let s = typed("a\u{202f}teh x\u{2007}teh ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "a\u{202f}the x\u{2007}the ");
let s = typed("end.\u{a0}hello end.\u{3000}world ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "End.\u{a0}Hello end.\u{3000}World ");
let s = typed("the\u{a0}1st and\u{3000}22nd ");
@@ -845,6 +848,67 @@ fn a_tracked_table_line_keeps_its_characters_as_a_deletion() {
run(&mut s, "edit.undo", json!({}));
run(&mut s, "review.rejectAll", json!({}));
assert!(s.doc.body.get(1).and_then(|b| b.as_para()).is_some_and(|p| p.text == "+--+------+"));
// The table was inserted untracked, so Reject All keeps it before the characters.
assert_eq!(s.doc.body.len(), 2, "{:?}", paras(&s));
assert!(matches!(s.doc.body.first().map(|b| &**b), Some(wordcraft_doc::Block::Table(_))));
}
#[test]
fn a_border_line_at_the_top_with_formatting_markup_off_is_not_a_formatting_change() {
// Track Changes on with formatting left out of the markup: the characters are still marked
// deleted, the border is not a formatting change, and Reject All keeps it.
let mut s = typed("---");
run(&mut s, "review.trackChanges", json!({"value": true}));
s.prefs.markup.track_formatting = false;
typ(&mut s, "\n");
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
assert!(p.props.borders.and_then(|b| b.bottom).is_some());
assert!(p.props.fmt_change.is_none(), "{:?}", p.props.fmt_change);
assert!(all_deleted(&s, 0, "---"), "{:?}", paras(&s));
run(&mut s, "review.rejectAll", json!({}));
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
assert_eq!(p.text, "---");
assert!(p.props.borders.and_then(|b| b.bottom).is_some());
}
#[test]
fn a_border_line_at_the_top_keeps_the_authors_own_earlier_formatting_change() {
// Ana centers the characters' paragraph, then her Enter gives it the border: the paragraph
// keeps Ana's one formatting change, which holds the paragraph from before both.
let mut s = typed("---");
run(&mut s, "review.trackChanges", json!({"value": true}));
s.author = "Ana".into();
run(&mut s, "para.align", json!({"value": "center"}));
let rev = s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.fmt_change.as_deref()).map(|c| c.rev);
typ(&mut s, "\n");
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
assert!(p.props.borders.and_then(|b| b.bottom).is_some());
let ch = p.props.fmt_change.as_deref();
assert_eq!(ch.map(|c| c.rev), rev);
assert_eq!(ch.map(|c| (c.old.align, c.old.borders.and_then(|b| b.bottom).is_some())), Some((None, false)));
run(&mut s, "review.rejectAll", json!({}));
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
assert_eq!((p.text.as_str(), p.props.align, p.props.borders.and_then(|b| b.bottom).is_some()), ("---", None, false));
}
#[test]
fn a_border_line_the_content_control_locks_refuse_to_delete_stays_as_backspace_leaves_it() {
// "---" inside an editable rich text control that sits in one locked against editing:
// Backspace can't delete its characters, so Enter is refused too, untracked, and the line
// stays as it is.
let mut s = Session::new(wordcraft_doc::Document::from_text("A\nB\n---\nC\nD"));
s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(4, 1) };
run(&mut s, "developer.richText", json!({"tag": "outer"}));
s.sel = crate::Selection { anchor: Pos::body(1, 0), focus: Pos::body(3, 1) };
run(&mut s, "developer.richText", json!({"tag": "inner"}));
run(&mut s, "developer.properties", json!({"control": "outer", "lockEdit": true}));
let before = s.doc.clone();
for key in ["text.backspace", "text.newParagraph"] {
s.sel = crate::Selection::caret(Pos::body(2, 3));
assert!(s.run(key, &json!({})).is_err(), "{key}");
assert_eq!(s.doc, before, "{key}");
}
assert!(s.doc.para_at(&Pos::body(2, 0)).is_some_and(|p| p.text == "---" && p.props.borders.is_none()));
}
#[test]
@@ -1028,6 +1092,36 @@ fn tab_before_a_later_line_of_a_paragraph_indents_the_whole_paragraph() {
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.map(|n| n.level), Some(8));
}
#[test]
fn with_tab_indents_on_tab_in_a_table_cell_moves_to_the_next_cell() {
let mut s = typed("+--+--+\nA");
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
run(&mut s, "caret.home", json!({}));
typ(&mut s, "\t");
assert_eq!(s.sel.focus.path.cell().map(|(_, r, c)| (r, c)), Some((0, 1)));
let Some(wordcraft_doc::Block::Table(t)) = s.doc.body.first().map(|b| &**b) else { panic!("no table: {:?}", paras(&s)) };
let a = t.rows[0].cells[0].blocks[0].as_para().unwrap();
assert_eq!((a.text.as_str(), a.props.indent_left, a.props.indent_first), ("A", None, None));
}
#[test]
fn with_tab_indents_off_backspace_before_an_indented_paragraph_joins_it() {
// The paragraph's own left indent, in points or in characters, doesn't stop the join.
for chars in [false, true] {
let mut s = typed("One\nTwo");
let second = Pos::body(1, 0);
let p = s.doc.para_mut(StoryRef::Body, &second.path).unwrap();
if chars {
p.props.indent_left_chars = Some(6.0);
} else {
p.props.indent_left = Some(36.0);
}
s.sel = crate::Selection::caret(second);
typ(&mut s, "\u{8}");
assert_eq!(s.doc.plain_text(StoryRef::Body), "OneTwo", "indent in characters: {chars}");
}
}
#[test]
fn plus_and_hyphen_line_becomes_a_table() {
let mut s = typed("+--+------+\nA");
@@ -1414,6 +1508,78 @@ fn indic_and_khmer_backspace_removes_one_character() {
assert_eq!(paras(&s)[0].0, "a");
}
/// Arabic vowel and Quranic marks are individually editable without retyping their base (#4757).
#[test]
fn arabic_backspace_removes_one_mark_and_keeps_the_base() {
for mark in [
'\u{0610}',
'\u{064B}',
'\u{064C}',
'\u{064D}',
'\u{064E}',
'\u{064F}',
'\u{0650}',
'\u{0651}',
'\u{0652}',
'\u{065F}',
'\u{0670}',
'\u{06D6}',
'\u{06DF}',
'\u{06E7}',
'\u{06EA}',
'\u{0897}',
'\u{089F}',
'\u{08CA}',
'\u{08E1}',
'\u{08E3}',
'\u{08FF}',
'\u{10EFA}',
'\u{10EFF}',
] {
let original = format!("Λ ب{mark}");
let mut s = typed(&original);
typ(&mut s, "\u{8}");
assert_eq!(paras(&s)[0].0, "Λ ب", "mark U+{:04X}", u32::from(mark));
assert_eq!(s.sel.focus.off, "Λ ب".len());
run(&mut s, "edit.undo", json!({}));
assert_eq!(paras(&s)[0].0, original);
run(&mut s, "edit.redo", json!({}));
typ(&mut s, "\u{0650}");
assert_eq!(paras(&s)[0].0, "Λ ب\u{0650}");
}
}
#[test]
fn arabic_backspace_uses_stored_mark_order_but_delete_keeps_the_cluster() {
for marks in ["\u{0651}\u{064E}", "\u{064E}\u{0651}"] {
let mut s = typed(&format!("ب{marks}"));
typ(&mut s, "\u{8}");
assert_eq!(paras(&s)[0].0, format!("ب{}", marks.chars().next().unwrap()));
typ(&mut s, "\u{8}");
assert_eq!(paras(&s)[0].0, "ب");
typ(&mut s, "\u{8}");
assert!(paras(&s)[0].0.is_empty());
let original = format!("ب{marks}ت");
let mut s = typed(&original);
// Arabic arrows move visually. They still visit only whole-cluster boundaries.
let boundaries = [0, original.len() - "ت".len(), original.len()];
let mut visited = vec![s.sel.focus.off];
for command in ["caret.right", "caret.right", "caret.left", "caret.left"] {
run(&mut s, command, json!({}));
assert!(boundaries.contains(&s.sel.focus.off));
visited.push(s.sel.focus.off);
}
assert!(boundaries.iter().all(|off| visited.contains(off)));
s.sel = crate::Selection::caret(Pos::body(0, 0));
run(&mut s, "text.delete", json!({}));
assert_eq!(paras(&s)[0].0, "ت");
}
for cluster in ["e\u{0301}", "👩‍💻", "👍🏽", "🇵🇱", "ב\u{05B7}"] {
let s = typed(&format!("a{cluster}\u{8}"));
assert_eq!(paras(&s)[0].0, "a", "unchanged grapheme {cluster}");
}
}
/// Press a shortcut the way the keyboard does: run the command bound to it.
fn press(s: &mut Session, combo: &str) -> Result<serde_json::Value, crate::CmdError> {
let id = s.registry.by_shortcut(combo).map(|c| c.id).unwrap_or_else(|| panic!("nothing bound to {combo}"));
@@ -3124,6 +3290,37 @@ fn highlight_toggle_handles_mixed_inherited_and_pending_formatting() {
assert_eq!(serde_json::to_value(&s.doc).unwrap(), before);
}
/// #4086: a caret inside a word highlights what is typed next, not the word; Bold still
/// formats the word.
#[test]
fn highlight_at_a_caret_leaves_the_word_alone() {
let mut s = typed("Alpha Beta");
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "caret.right", json!({}));
run(&mut s, "format.highlight", json!({"color":"yellow"}));
assert!(s.doc.body[0].as_para().unwrap().runs.iter().all(|r| r.props.highlight.is_none()));
assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::Yellow));
run(&mut s, "text.insert", json!({"text":"X"}));
run(&mut s, "select.text", json!({"text":"X"}));
assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], json!("FFFF00"));
run(&mut s, "select.text", json!({"text":"Beta"}));
assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], serde_json::Value::Null);
// The toggle reads the pending colour, not the word's: a second press turns it off.
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "caret.right", json!({}));
run(&mut s, "format.highlight", json!({}));
assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::Yellow));
run(&mut s, "format.highlight", json!({}));
assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::None));
// Bold after Highlight formats the word and keeps both for what is typed next.
run(&mut s, "format.highlight", json!({}));
run(&mut s, "format.bold", json!({}));
run(&mut s, "text.insert", json!({"text":"Y"}));
run(&mut s, "select.text", json!({"text":"Y"}));
let state = s.run("format.state", &json!({})).unwrap();
assert_eq!((state["highlight"].clone(), state["bold"].clone()), (json!("FFFF00"), json!(true)));
}
/// A differently coloured final paragraph must remain visible to the shortcut beyond
/// the formatting-state query limit, including a style-inherited tail and Undo/Redo.
#[cfg(not(target_os = "macos"))]
@@ -3386,3 +3583,51 @@ fn double_space_adds_a_period_when_asked() {
typ(&mut t, "Hello world");
assert_eq!(text(&t), "Hello world");
}
#[test]
fn backspace_after_a_floating_shape_anchor_joins_paragraphs_and_keeps_the_shape() {
let mut s = typed("Line one\nLine two");
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 0}}));
run(&mut s, "insert.textBox", json!({"text": "Box"}));
let (a, f) = (Pos::body(1, 0), Pos::body(1, 3));
run(&mut s, "select.range", json!({"anchor": a, "focus": f}));
run(&mut s, "arrange.wrap", json!({"wrap": "inFrontOfText"}));
let objects = |s: &Session| s.doc.para_at(&Pos::body(1, 0)).map(|p| p.object_offsets().len());
assert_eq!(objects(&s), Some(1));
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 3}}));
run(&mut s, "text.backspace", json!({}));
assert_eq!(s.doc.body.iter().filter(|b| b.as_para().is_some()).count(), 1);
assert_eq!(objects(&s), None);
assert_eq!(
s.doc.para_at(&Pos::body(0, 0)).map(|p| (p.text.replace('\u{FFFC}', ""), p.object_offsets().len())),
Some(("Line oneLine two".to_string(), 1))
);
}
#[test]
fn backspace_after_marker_and_floating_anchors_still_joins_paragraphs() {
let mut s = typed("Line one\nLine two");
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 0}}));
run(&mut s, "insert.textBox", json!({"text": "Box"}));
let (a, f) = (Pos::body(1, 0), Pos::body(1, 3));
run(&mut s, "select.range", json!({"anchor": a, "focus": f}));
run(&mut s, "arrange.wrap", json!({"wrap": "inFrontOfText"}));
s.doc.insert_object(&Pos::body(1, 0), wordcraft_doc::InlineObject::BookmarkStart { name: "anchor".into() }, &Default::default()).unwrap();
s.doc.insert_object(&Pos::body(1, 0), wordcraft_doc::InlineObject::CommentStart { id: 1 }, &Default::default()).unwrap();
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 9}}));
run(&mut s, "text.backspace", json!({}));
assert_eq!(s.doc.body.iter().filter(|b| b.as_para().is_some()).count(), 1);
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).map(|p| p.text.replace('\u{FFFC}', "")), Some("Line oneLine two".to_string()));
}
#[test]
fn backspace_cannot_join_into_locked_content_control() {
let mut s = typed("Above\nInside");
run(&mut s, "select.range", json!({"anchor": {"story": "body", "path": [1], "off": 0}, "focus": {"story": "body", "path": [1], "off": 6}}));
run(&mut s, "developer.richText", json!({"tag": "locked"}));
run(&mut s, "developer.properties", json!({"control": "locked", "lockEdit": true}));
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 0}}));
let before = s.doc.clone();
assert!(s.run("text.backspace", &json!({})).is_err());
assert_eq!(s.doc, before);
}
+21
View File
@@ -76,6 +76,27 @@ fn moving_a_joined_shape_reroutes_its_connector() {
assert_eq!(connector_ends(&mut s), ((172.0, 248.0), (300.0, 218.0)));
}
/// A target on another page, or a missing target, leaves the connector's end free.
#[test]
fn rerouting_keeps_off_page_and_missing_targets_at_their_free_ends() {
let mut s = joined();
let mut connector = s.doc.para_at(&Pos::body(0, 11)).unwrap().object_at(11).unwrap().clone();
let InlineObject::Shape { extra, .. } = &mut connector else { panic!("connector") };
extra.end = Some(ConnEnd { id: 404, site: 2 });
let mut p = Paragraph::new();
p.props.page_break_before = Some(true);
p.insert_object(0, connector.clone(), &Default::default()).unwrap();
s.doc.body.push(para_block(p));
s.touch();
select(&mut s, 5);
run(&mut s, "arrange.nudge", json!({"dx": 10, "dy": 30}));
assert_eq!(s.layout().pages.len(), 2);
assert_eq!(s.doc.para_at(&Pos::body(1, 0)).unwrap().object_at(0), Some(&connector));
assert_eq!(connector_ends(&mut s), ((182.0, 248.0), (300.0, 218.0)));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.para_at(&Pos::body(1, 0)).unwrap().object_at(0), Some(&connector));
}
/// A command that changes nothing but reroutes a stale connector is still an edit: the
/// reroute must not be left without an undo step (#23).
#[test]
+1 -1
View File
@@ -161,4 +161,4 @@ impl FontDb {
}
#[cfg(test)]
mod tests;
pub(crate) mod tests;
+1 -1
View File
@@ -1,5 +1,5 @@
#[cfg(not(target_arch = "wasm32"))]
mod sysfonts;
pub(crate) mod sysfonts;
#[test]
#[cfg(not(target_arch = "wasm32"))]
+2 -2
View File
@@ -12,7 +12,7 @@ fn renamed(file: &str) -> Vec<u8> {
}
/// 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> {
pub(crate) 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>>();
@@ -27,7 +27,7 @@ fn renamed_to(file: &str, family: &str) -> Vec<u8> {
}
/// The fonts in `faces` as one collection (TTC) file.
fn collection(faces: &[Vec<u8>]) -> Vec<u8> {
pub(crate) fn collection(faces: &[Vec<u8>]) -> Vec<u8> {
let mut out = b"ttcf".to_vec();
out.extend_from_slice(&0x0001_0000_u32.to_be_bytes());
out.extend_from_slice(&(faces.len() as u32).to_be_bytes());
+1 -1
View File
@@ -24,7 +24,7 @@ pub use kurbo::BezPath;
pub use script::{fallback_rank, is_emoji, needs_layout_tables, script_fallbacks};
use skrifa::MetadataProvider;
use skrifa::instance::Size;
pub use ui_fallback::{SystemUiFont, is_cjk, system_cjk_ui_font, system_cjk_ui_font_for, ui_needs_system_cjk, ui_uncovered_cjk};
pub use ui_fallback::{SystemUiFont, is_cjk, system_cjk_ui_font, system_cjk_ui_font_for, system_hant_ui_font, ui_needs_system_cjk, ui_uncovered_cjk};
/// A font from the optional craft-fonts build input (https://github.com/storytold/craft-fonts;
/// empty unless built with `CRAFT_FONTS_DIR`, see `build.rs`).
+141 -22
View File
@@ -70,6 +70,31 @@ const MAX_NEEDED: usize = 4096;
/// as blank space.
pub(crate) const OUTLINE_TABLES: [&[u8; 4]; 4] = [b"glyf", b"CFF ", b"CFF2", b"VARC"];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Region {
Hans,
Hant,
Other,
}
impl Region {
fn from_hans(hans: bool) -> Region {
if hans { Region::Hans } else { Region::Other }
}
fn rank(self, family: &str) -> u8 {
match self {
Region::Hans => u8::from(family.ends_with(" SC") || family.ends_with(" GB") || family.contains("YaHei")),
Region::Hant => {
let taiwan =
[" TC", " TW"].iter().any(|s| family.ends_with(s)) || ["JhengHei", "CNS", "LiHei", "MingLiU"].iter().any(|s| family.contains(s));
if taiwan { 2 } else { u8::from(family.ends_with(" HK")) }
}
Region::Other => 1,
}
}
}
/// A character only a font for the language has: a simplified-only hanzi, kana, or Hangul.
fn probe(hans: bool, korean: bool) -> char {
if korean {
@@ -139,6 +164,46 @@ fn candidates(hans: bool, korean: bool) -> Vec<&'static str> {
}
}
fn hant_candidates() -> &'static [&'static str] {
if cfg!(windows) {
&["msjh.ttc", "msjh.ttf", "mingliu.ttc"]
} else if cfg!(target_os = "macos") {
&[
"PingFang.ttc",
"Hiragino Sans CNS.ttc",
"LiHeiPro.ttf",
"/System/Library/PrivateFrameworks/FontServices.framework/Resources/Reserved/PingFangUI.ttc",
]
} else {
&[
"noto-cjk/NotoSansCJK-Regular.ttc",
"opentype/noto/NotoSansCJK-Regular.ttc",
"google-noto-sans-cjk-fonts/NotoSansCJK-Regular.ttc",
"google-noto-cjk/NotoSansCJK-Regular.ttc",
"noto-cjk/NotoSansCJKtc-Regular.otf",
"opentype/noto/NotoSansCJKtc-Regular.otf",
"google-noto-cjk/NotoSansCJKtc-Regular.otf",
"adobe-source-han-sans/SourceHanSansTW-Regular.otf",
"adobe-source-han-sans/SourceHanSansTC-Regular.otf",
"opentype/adobe-source-han-sans/SourceHanSansTW-Regular.otf",
"NotoSansCJK-Regular.ttc",
"NotoSansCJKtc-Regular.otf",
"SourceHanSansTW-Regular.otf",
"SourceHanSansTC-Regular.otf",
]
}
}
pub fn system_hant_ui_font(needed: &[char]) -> Option<SystemUiFont> {
let probe = ['體'];
let needed = if needed.is_empty() { &probe[..] } else { needed.get(..MAX_NEEDED).unwrap_or(needed) };
hant_font_in(&with_asset_data(crate::system_font_dirs()), needed)
}
fn hant_font_in(dirs: &[std::path::PathBuf], needed: &[char]) -> Option<SystemUiFont> {
find_ui_font(dirs, hant_candidates(), needed, Region::Hant).filter(|f| Region::Hant.rank(&f.family) > 0)
}
/// The installed CJK font for a Chinese (`hans`) or other interface that best covers `needed`
/// (the characters the embedded faces lack; empty: a character typical of the language), if one
/// is found. Never panics on damaged files.
@@ -150,7 +215,7 @@ pub fn system_cjk_ui_font(hans: bool, needed: &[char]) -> Option<SystemUiFont> {
pub fn system_cjk_ui_font_for(hans: bool, korean: bool, needed: &[char]) -> Option<SystemUiFont> {
let probe = [probe(hans, korean)];
let needed = if needed.is_empty() { &probe[..] } else { needed.get(..MAX_NEEDED).unwrap_or(needed) };
find_ui_font(&with_asset_data(crate::system_font_dirs()), &candidates(hans, korean), needed, hans)
find_ui_font(&with_asset_data(crate::system_font_dirs()), &candidates(hans, korean), needed, Region::from_hans(hans))
}
/// `dirs` and, for each macOS font asset folder among them (`com_apple_MobileAsset_Font<N>`),
@@ -175,9 +240,9 @@ pub(crate) fn with_asset_data(dirs: Vec<std::path::PathBuf>) -> Vec<std::path::P
/// The font file among `names` (tried in order, each in every folder of `dirs`) whose best face
/// maps all of `needed`, or failing that the one mapping the most of them (the earliest on a
/// tie). A file that maps none of them is never chosen.
pub(crate) fn find_ui_font(dirs: &[std::path::PathBuf], names: &[&str], needed: &[char], hans: bool) -> Option<SystemUiFont> {
pub(crate) fn find_ui_font(dirs: &[std::path::PathBuf], names: &[&str], needed: &[char], region: Region) -> Option<SystemUiFont> {
let mut tried = std::collections::HashSet::new();
let mut best: Option<(usize, SystemUiFont)> = None;
let mut best: Option<((u8, usize), SystemUiFont)> = None;
for path in names.iter().flat_map(|n| dirs.iter().map(move |d| d.join(n))) {
if !tried.insert(path.clone()) {
continue;
@@ -187,13 +252,15 @@ pub(crate) fn find_ui_font(dirs: &[std::path::PathBuf], names: &[&str], needed:
continue;
}
let Ok(bytes) = std::fs::read(&path) else { continue };
let Some((index, family, covered)) = pick_face(&bytes, needed, hans) else { continue };
if best.as_ref().is_some_and(|(b, _)| *b >= covered) {
let Some((index, family, covered)) = pick_face(&bytes, needed, region) else { continue };
let fit = if region == Region::Hant { region.rank(&family) } else { 0 };
if best.as_ref().is_some_and(|(b, _)| *b >= (fit, covered)) {
continue;
}
log::debug!("interface CJK fallback: {family} ({}), {covered} of {} characters", path.display(), needed.len());
best = Some((covered, SystemUiFont { family, bytes, index }));
if covered >= needed.len() {
let done = covered >= needed.len() && (region != Region::Hant || fit == 2);
best = Some(((fit, covered), SystemUiFont { family, bytes, index }));
if done {
break;
}
}
@@ -205,7 +272,7 @@ pub(crate) fn find_ui_font(dirs: &[std::path::PathBuf], names: &[&str], needed:
/// family (`… SC`, `… GB`, as in a pan-CJK collection). Faces with none of the
/// [`OUTLINE_TABLES`] are skipped. `None` if the header isn't a font's or no face with outlines
/// maps any of `needed`.
pub(crate) fn pick_face(data: &[u8], needed: &[char], hans: bool) -> Option<(u32, String, usize)> {
pub(crate) fn pick_face(data: &[u8], needed: &[char], region: Region) -> Option<(u32, String, usize)> {
let magic = data.get(..4)?;
if ![&[0, 1, 0, 0][..], b"OTTO", b"ttcf"].contains(&magic) {
return None;
@@ -214,7 +281,7 @@ pub(crate) fn pick_face(data: &[u8], needed: &[char], hans: bool) -> Option<(u32
FileRef::Font(_) => 1,
FileRef::Collection(c) => c.len().min(MAX_FACES),
};
let mut best: Option<((usize, bool, bool), u32, String)> = None;
let mut best: Option<((u8, usize, bool, u8), u32, String)> = None;
for i in 0..count {
let Ok(f) = skrifa::FontRef::from_index(data, i) else { continue };
if !OUTLINE_TABLES.iter().any(|t| f.table_data(skrifa::Tag::new(t)).is_some()) {
@@ -226,13 +293,14 @@ pub(crate) fn pick_face(data: &[u8], needed: &[char], hans: bool) -> Option<(u32
continue;
}
let Some((family, style, _)) = crate::fontdb::face_names(&f) else { continue };
let regional = !hans || family.ends_with(" SC") || family.ends_with(" GB") || family.contains("YaHei");
let score = (covered, style.eq_ignore_ascii_case("Regular"), regional);
let rank = region.rank(&family);
let first = if region == Region::Hant { rank } else { 0 };
let score = (first, covered, style.eq_ignore_ascii_case("Regular"), rank);
if best.as_ref().is_none_or(|(s, ..)| score > *s) {
best = Some((score, i, family));
}
}
best.map(|((covered, ..), i, family)| (i, family, covered))
best.map(|((_, covered, ..), i, family)| (i, family, covered))
}
#[cfg(test)]
@@ -255,6 +323,57 @@ mod tests {
assert_eq!(probe(false, true), '한');
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn traditional_chinese_interface_picks_the_taiwan_face() {
use crate::fontdb::tests::sysfonts::{collection, renamed_to};
assert_eq!(
hant_candidates().first(),
Some(&if cfg!(windows) {
"msjh.ttc"
} else if cfg!(target_os = "macos") {
"PingFang.ttc"
} else {
"noto-cjk/NotoSansCJK-Regular.ttc"
})
);
let face = |family| renamed_to("SourceSans3-Regular.ttf", family);
let pan = collection(&[face("Probe Sans JP"), face("Probe Sans KR"), face("Probe Sans SC"), face("Probe Sans TC"), face("Probe Sans HK")]);
assert_eq!(pick_face(&pan, &['A'], Region::Hant), Some((3, "Probe Sans TC".to_string(), 1)));
assert_eq!(pick_face(&pan, &['A'], Region::Hans), Some((2, "Probe Sans SC".to_string(), 1)));
assert_eq!(pick_face(&pan, &['A'], Region::Other), Some((0, "Probe Sans JP".to_string(), 1)));
let hk = collection(&[face("Probe Sans SC"), face("Probe Sans HK")]);
assert_eq!(pick_face(&hk, &['A'], Region::Hant).map(|f| f.0), Some(1));
assert_eq!(Region::Hant.rank("Microsoft JhengHei"), 2);
assert_eq!(Region::Hant.rank("Microsoft YaHei"), 0);
let dir = std::env::temp_dir().join(format!("wc-uifallback-hant-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let names = ["missing.ttc", "damaged.ttc", "sc.ttc", "pan.ttc"];
std::fs::write(dir.join("damaged.ttc"), b"ttcf\0\x01\0\0\xff\xff\xff\xff").unwrap();
std::fs::write(dir.join("sc.ttc"), collection(&[face("Probe Sans SC"), face("Probe Sans JP")])).unwrap();
std::fs::write(dir.join("pan.ttc"), &pan).unwrap();
let found = find_ui_font(std::slice::from_ref(&dir), &names, &['A'], Region::Hant).unwrap();
assert_eq!((found.family.as_str(), found.index), ("Probe Sans TC", 3));
assert_eq!(find_ui_font(std::slice::from_ref(&dir), &names, &['A'], Region::Hans).unwrap().family, "Probe Sans SC");
let only_sc = dir.join("only-sc");
std::fs::create_dir_all(only_sc.join("noto-cjk")).unwrap();
std::fs::write(only_sc.join("noto-cjk/NotoSansCJK-Regular.ttc"), collection(&[face("Probe Sans SC")])).unwrap();
assert!(hant_font_in(std::slice::from_ref(&only_sc), &['A']).is_none());
std::fs::write(only_sc.join("noto-cjk/NotoSansCJK-Regular.ttc"), &pan).unwrap();
if !cfg!(windows) && !cfg!(target_os = "macos") {
assert_eq!(hant_font_in(std::slice::from_ref(&only_sc), &['A']).map(|f| f.index), Some(3));
}
assert!(hant_font_in(std::slice::from_ref(&only_sc), &['體']).is_none());
let user = dir.join("user-fonts");
std::fs::create_dir_all(&user).unwrap();
std::fs::write(user.join("NotoSansCJKtc-Regular.otf"), face("Probe Sans TC")).unwrap();
if !cfg!(windows) && !cfg!(target_os = "macos") {
assert_eq!(hant_font_in(std::slice::from_ref(&user), &['A']).map(|f| f.family), Some("Probe Sans TC".to_string()));
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn needs_system_cjk_when_an_embedded_face_lacks_an_interface_character() {
// A face without CJK glyphs stands in for a craft-fonts face that lacks some (#486).
@@ -289,21 +408,21 @@ mod tests {
std::fs::write(dir.join("msyh.ttc"), b"<html>not a font</html>").unwrap();
std::fs::write(dir.join("sub/good.ttf"), sans).unwrap();
let names = ["missing.ttf", "Deng.ttf", "msyh.ttc", "sub/good.ttf"];
let found = find_ui_font(std::slice::from_ref(&dir), &names, &['A'], true).unwrap();
let found = find_ui_font(std::slice::from_ref(&dir), &names, &['A'], Region::Hans).unwrap();
assert_eq!((found.family.as_str(), found.index, found.bytes.len()), ("Source Sans 3", 0, sans.len()));
// No candidate maps a CJK character: nothing is chosen.
assert!(find_ui_font(std::slice::from_ref(&dir), &names, &['页'], true).is_none());
assert!(pick_face(b"OTT", &['A'], false).is_none());
assert!(find_ui_font(std::slice::from_ref(&dir), &names, &['页'], Region::Hans).is_none());
assert!(pick_face(b"OTT", &['A'], Region::Other).is_none());
// macOS 12 keeps PingFang in `AssetsV2/com_apple_MobileAsset_Font7/<hash>.asset/AssetData`.
std::fs::write(assets.join("0a1b.asset/AssetData/PingFang.ttc"), sans).unwrap();
std::fs::write(assets.join("not-an-asset/AssetData/Hidden.ttc"), sans).unwrap();
let dirs = with_asset_data(vec![dir.clone(), assets.clone()]);
assert_eq!(dirs, [dir.clone(), assets.join("0a1b.asset/AssetData"), assets.clone()]);
let found = find_ui_font(&dirs, &["PingFang.ttc"], &['A'], true).unwrap();
let found = find_ui_font(&dirs, &["PingFang.ttc"], &['A'], Region::Hans).unwrap();
assert_eq!(found.family, "Source Sans 3");
assert!(find_ui_font(&dirs, &["Hidden.ttc"], &['A'], true).is_none());
assert!(find_ui_font(&dirs, &["Hidden.ttc"], &['A'], Region::Hans).is_none());
// No file maps every needed character: the one mapping the most is used.
let found = find_ui_font(&dirs, &["sub/good.ttf", "PingFang.ttc"], &['A', '页'], true).unwrap();
let found = find_ui_font(&dirs, &["sub/good.ttf", "PingFang.ttc"], &['A', '页'], Region::Hans).unwrap();
assert_eq!(found.family, "Source Sans 3");
let _ = std::fs::remove_dir_all(&dir);
}
@@ -318,17 +437,17 @@ mod tests {
let glyf = (0..tables).map(|r| 12 + r * 16).find(|&at| &hvgl[at..at + 4] == b"glyf").unwrap();
hvgl[glyf..glyf + 4].copy_from_slice(b"hvgl");
assert!(skrifa::FontRef::new(&hvgl).unwrap().charmap().map('A').is_some());
assert!(pick_face(&hvgl, &['A'], true).is_none());
assert_eq!(pick_face(sans, &['A'], true), Some((0, "Source Sans 3".to_string(), 1)));
assert!(pick_face(&hvgl, &['A'], Region::Hans).is_none());
assert_eq!(pick_face(sans, &['A'], Region::Hans), Some((0, "Source Sans 3".to_string(), 1)));
// `find_ui_font` uses the next file, and returns `None` when no other file is listed.
let dir = std::env::temp_dir().join(format!("wc-uifallback-outlines-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("hvgl.ttf"), &hvgl).unwrap();
std::fs::write(dir.join("glyf.ttf"), sans).unwrap();
let found = find_ui_font(std::slice::from_ref(&dir), &["hvgl.ttf", "glyf.ttf"], &['A'], true).unwrap();
let found = find_ui_font(std::slice::from_ref(&dir), &["hvgl.ttf", "glyf.ttf"], &['A'], Region::Hans).unwrap();
assert!(found.bytes == sans);
assert!(find_ui_font(std::slice::from_ref(&dir), &["hvgl.ttf"], &['A'], true).is_none());
assert!(find_ui_font(std::slice::from_ref(&dir), &["hvgl.ttf"], &['A'], Region::Hans).is_none());
let _ = std::fs::remove_dir_all(&dir);
}
}
+1
View File
@@ -11,6 +11,7 @@ workspace = true
[dependencies]
wordcraft-doc = { workspace = true }
wordcraft-geom = { workspace = true, features = ["image"] }
wordcraft-metafile = { workspace = true }
image = { workspace = true }
quick-xml = { workspace = true }
zip = { workspace = true }
+6 -5
View File
@@ -66,9 +66,9 @@ fn apply_font_attributes(attrs: &[(String, String)], fmt: &mut Fmt) {
let Some(size) = attr(attrs, "size") else { return };
let size = size.trim();
let points = if let Some(rest) = size.strip_prefix('+') {
rest.parse::<i32>().ok().map(|delta| 3 + delta)
rest.parse::<i32>().ok().map(|delta| 3i32.saturating_add(delta))
} else if let Some(rest) = size.strip_prefix('-') {
rest.parse::<i32>().ok().map(|delta| 3 - delta)
rest.parse::<i32>().ok().map(|delta| 3i32.saturating_sub(delta))
} else {
size.parse::<i32>().ok()
};
@@ -336,10 +336,11 @@ impl<'r> Builder<'r> {
}
pub(super) fn text(&mut self, t: &str) {
if let Some(title) = &mut self.title {
title.push_str(t);
return;
}
if self.in_head() {
if let Some(title) = &mut self.title {
title.push_str(t);
}
return;
}
if t.is_empty() {
+14 -1
View File
@@ -10,7 +10,20 @@ pub fn parse(s: &str) -> Flow {
/// [`parse`], loading pictures that aren't `data:` URIs through `images`.
pub fn parse_with(s: &str, images: ImageLoader) -> Flow {
let mut lx = Lexer { s, pos: 0 };
// HTML normalizes input newlines before tokenization, not after decoding character references.
let normalized = if s.contains('\r') {
let mut out = String::with_capacity(s.len());
let mut chunks = s.split('\r');
out.push_str(chunks.next().unwrap_or_default());
for chunk in chunks {
out.push('\n');
out.push_str(chunk.strip_prefix('\n').unwrap_or(chunk));
}
std::borrow::Cow::Owned(out)
} else {
std::borrow::Cow::Borrowed(s)
};
let mut lx = Lexer { s: &normalized, pos: 0 };
let mut b = Builder::new(images);
let mut guard = 0usize;
while let Some(t) = lx.next() {
+8 -2
View File
@@ -68,8 +68,14 @@ impl Lexer<'_> {
}
let b = rest.as_bytes();
if rest.starts_with("<!--") {
self.pos += 4;
self.skip_past("-->");
if rest.starts_with("<!-->") {
self.pos += 5;
} else if rest.starts_with("<!--->") {
self.pos += 6;
} else {
self.pos += 4;
self.skip_past("-->");
}
return Some(Tok::Text(String::new()));
}
if rest.starts_with("<!") || rest.starts_with("<?") {
+48
View File
@@ -50,6 +50,26 @@ fn borderless_tables_export_without_cell_borders() {
}
}
#[test]
fn input_newlines_preserve_preformatted_lines() {
for newline in ["\n", "\r\n", "\r"] {
let f = parse(&format!("<pre>{newline} Alpha{newline} <b>Beta</b>{newline}{newline} Gamma</pre>"));
assert_eq!(texts(&f), ["CodeNone: Alpha", "CodeNone: Beta", "CodeNone:", "CodeNone: Gamma"], "{newline:?}");
let FBlock::Para(p) = &f.blocks[1] else { panic!() };
assert!(matches!(p.inlines.last(), Some(Inline::Text(t, f)) if t == "Beta" && f.bold));
}
}
#[test]
fn input_newlines_are_normalized_before_character_references() {
let f = parse("<p style='white-space:pre'>A&#13;B\r\nC\rD</p><p>A\r\nB\rC</p>");
assert_eq!(texts(&f), ["NormalNone:A\rB\nC\nD", "NormalNone:A B C"]);
let f = parse("<p style='white-space:pre-wrap'>α\r\r\nβ&#13;γ</p><a href='https://example.test/a\r\nb\rc&#13;d'>link</a>");
assert_eq!(texts(&f), ["NormalNone:α\n\nβ\rγ", "NormalNone:link"]);
let FBlock::Para(p) = &f.blocks[1] else { panic!() };
assert!(matches!(p.inlines.first(), Some(Inline::Text(_, f)) if f.link.as_deref() == Some("https://example.test/a\nb\nc\rd")));
}
#[test]
fn css_preformatted_paragraphs_keep_edge_spaces() {
// #439: `white-space:pre` keeps leading and trailing spaces like `<pre>`; normal
@@ -474,3 +494,31 @@ fn many_formatted_leading_breaks_trim_quickly() {
let texts: Vec<String> = f.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p.text()) } else { None }).collect();
assert_eq!(texts, ["x"]);
}
#[test]
fn font_size_at_the_integer_limits_does_not_overflow() {
// `<font size>` offsets were added to 3 unchecked, so the extreme values panicked in debug builds.
for size in ["+2147483647", "--2147483648", "-2147483647", "+99999999999"] {
let f = parse(&format!("<font size=\"{size}\">x</font>"));
assert!(texts(&f).iter().any(|t| t.ends_with(":x")), "size={size}");
}
// The offsets saturate, then clamp to the 1..=7 scale: 7 is 36 pt, 1 is 7.5 pt.
let size = |html: &str| match &parse(html).blocks[0] {
FBlock::Para(p) => p.inlines.iter().find_map(|i| match i {
Inline::Text(_, fmt) => fmt.size,
_ => None,
}),
_ => None,
};
assert_eq!(size("<font size=\"+2147483647\">x</font>"), Some(36.0));
assert_eq!(size("<font size=\"--2147483648\">x</font>"), Some(36.0));
assert_eq!(size("<font size=\"-2147483647\">x</font>"), Some(7.5));
}
#[test]
fn empty_comments_do_not_swallow_the_rest() {
for html in ["<p>a<!-->b</p><p>c</p>", "<p>a<!--->b</p><p>c</p>"] {
let doc = crate::html::import(html);
assert_eq!(doc.plain_text(Default::default()), "ab\nc", "{html}");
}
}
+14 -2
View File
@@ -233,7 +233,7 @@ impl Writer {
match i {
Inline::Text(t, f) => {
let s = self.span(t, f);
o.push_str(&s);
push_apart(&mut o, &s);
}
Inline::Image(img) => {
let alt = if img.alt.trim().is_empty() { "Picture".to_string() } else { format!("Picture: {}", img.alt.trim()) };
@@ -244,7 +244,7 @@ impl Writer {
// LaTeX keeps no ruby (no package): its base text, in the run's formatting.
Inline::Ruby { base, fmt, .. } => {
let s = self.span(base, fmt);
o.push_str(&s);
push_apart(&mut o, &s);
}
Inline::Equation { linear, display } => {
self.uses.math = true;
@@ -398,6 +398,18 @@ fn num(v: f32) -> String {
if r.fract() == 0.0 { format!("{r:.0}") } else { format!("{r}") }
}
/// Appends a run, writing `{}` between a `-` or `'` ending `o` and the same character starting
/// `s`, so TeX does not join them across runs into a dash or quote.
fn push_apart(o: &mut String, s: &str) {
if let Some(c) = s.chars().next()
&& (c == '-' || c == '\'')
&& o.ends_with(c)
{
o.push_str("{}");
}
o.push_str(s);
}
/// Text with LaTeX's special characters escaped.
pub fn escape_text(s: &str) -> String {
let mut o = String::with_capacity(s.len() + 8);
+11
View File
@@ -449,3 +449,14 @@ fn nested_multicolumn_wrappers_are_bounded() {
assert_eq!((t.rows[0][0].colspan, t.rows[0][0].rowspan), (2, 2));
assert_eq!(t.rows[0][0].text(), "Both");
}
#[test]
fn ligature_characters_split_across_runs_stay_apart() {
let d = crate::html::import(
"<p><span style=\"font-family:Arial\">a-</span><span style=\"font-family:Georgia\">-b '</span><span style=\"font-family:Arial\">'x</span></p>",
);
let tex = export(&d);
assert!(tex.contains("a-{}-b '{}'x"), "{tex}");
let back = import(tex.as_bytes());
assert_eq!(back.plain_text(Default::default()).trim(), "a--b ''x", "{tex}");
}
+28 -12
View File
@@ -1,5 +1,5 @@
use super::blocks::thematic_break;
use super::inlines::within;
use super::inlines::{is_punct, within};
use super::{Align, Arc, Cell, Document, FBlock, FTable, Fmt, Inline, Kind, model};
// ---------------------------------------------------------------------------------------------
@@ -166,30 +166,43 @@ fn inlines_md_impl(inlines: &[Inline], in_table: bool) -> String {
}
let want: Vec<Mark> =
[(f.bold, Mark::Bold), (f.italic, Mark::Italic), (f.strike, Mark::Strike)].iter().filter(|x| x.0).map(|x| x.1).collect();
let body = if f.code {
if in_table { code_span(&text.replace('|', "\\|")) } else { code_span(&text) }
} else {
escape(&text).replace('\n', "\\\n").replace('\t', " ")
};
// Close until the stack is a prefix of what we want.
let mut close = String::new();
while let Some(pos) = stack.iter().position(|m| !want.contains(m)) {
while stack.len() > pos {
if let Some(m) = stack.pop() {
out.push_str(marker(m));
close.push_str(marker(m));
}
}
}
let mut open = String::new();
for m in want {
if !stack.contains(&m) {
out.push_str(marker(m));
open.push_str(marker(m));
stack.push(m);
}
}
if f.code {
if in_table {
let t = text.replace('|', "\\|");
out.push_str(&code_span(&t));
} else {
out.push_str(&code_span(&text));
}
} else {
out.push_str(&escape(&text).replace('\n', "\\\n").replace('\t', " "));
// CommonMark only reads a closing run after punctuation as closing when whitespace or
// punctuation follows it, and an opening run before punctuation as opening when whitespace
// or punctuation precedes it. Where a letter sits there instead, or an opening run would
// directly follow a closing run (the two would merge into one run), an empty HTML comment (which readers drop) supplies
// the punctuation.
let prev = out.chars().next_back();
let next = body.chars().next();
let word = |c: Option<char>| c.is_some_and(|c| !c.is_whitespace() && !is_punct(c));
out.push_str(&close);
let close_needs = !close.is_empty() && prev.is_some_and(is_punct) && (!open.is_empty() || word(next));
let open_needs = !open.is_empty() && next.is_some_and(is_punct) && (!close.is_empty() || word(prev));
if close_needs || open_needs {
out.push_str(EMPTY_COMMENT);
}
out.push_str(&open);
out.push_str(&body);
}
close_all(&mut out, &mut stack);
if let Some(l) = &link {
@@ -229,6 +242,9 @@ fn entity_like(rest: &str) -> bool {
!body.is_empty() && body.chars().all(|c| c.is_ascii_alphanumeric())
}
/// Inline HTML that Markdown readers drop, used to separate emphasis markers from letters.
const EMPTY_COMMENT: &str = "<!-- -->";
fn marker(m: Mark) -> &'static str {
match m {
Mark::Bold => "**",
+25 -12
View File
@@ -16,7 +16,7 @@ enum Node {
Delim { ch: char, orig: usize, left: usize, open: bool, close: bool, opens: Vec<Emph>, closes: Vec<Emph> },
}
fn is_punct(c: char) -> bool {
pub(super) fn is_punct(c: char) -> bool {
c.is_ascii_punctuation() || (!c.is_alphanumeric() && !c.is_whitespace() && !c.is_control())
}
@@ -116,8 +116,8 @@ struct LinkScan<'a> {
budget: usize,
}
impl LinkScan<'_> {
fn parse_at(&mut self, open: usize) -> Option<(String, Arc<str>, usize)> {
impl<'a> LinkScan<'a> {
fn parse_at(&mut self, open: usize) -> Option<(&'a str, Arc<str>, usize)> {
let bytes = self.text.as_bytes();
let close = match self.known.get(&open).copied() {
Some(found) => found,
@@ -136,19 +136,32 @@ impl LinkScan<'_> {
let destination = link_dest(self.text, close + 1);
self.budget = self.budget.saturating_sub(destination.as_ref().map_or(self.text.len(), |d| d.1).saturating_sub(close));
let (destination, end) = destination?;
return Some((label_text.to_string(), Arc::from(destination), end));
return Some((label_text, Arc::from(destination), end));
}
// Full and shortcut reference links use definitions collected before inline parsing.
if self.refs.is_empty() {
return None;
}
let (label, end) = match self.text.get(close + 1..).and_then(|rest| rest.strip_prefix('[')) {
Some(rest) => {
let shut = rest.find(']')?;
let ahead = within(rest, self.budget);
let shut = ahead.find(']');
self.budget = self.budget.saturating_sub(shut.map_or(ahead.len(), |n| n + 1));
let shut = shut?;
let inner = rest.get(..shut)?;
let label = if inner.trim().is_empty() { label_text } else { inner };
(label, close + 2 + shut + 1)
}
None => (label_text, close + 1),
};
Some((label_text.to_string(), self.refs.get(&ref_label(label))?.clone(), end))
// A cached bracket pair still costs a pass over its label. Without charging it,
// nested undefined references normalize every suffix again.
if label.len() > self.budget {
self.budget = 0;
return None;
}
self.budget -= label.len();
Some((label_text, self.refs.get(&ref_label(label))?.clone(), end))
}
}
@@ -257,10 +270,10 @@ fn tag_attr(tag: &str, name: &str) -> Option<String> {
fn tokenize(s: &str, base: &Fmt, depth: usize, refs: &Refs) -> Vec<Node> {
let mut nodes: Vec<Node> = Vec::new();
// Bytes the link and image scans below may look ahead in total. Each unmatched `[` or `![(`
// would otherwise rescan the rest of the text, which is quadratic on hostile input; once the
// budget is gone, further brackets are plain text. A scan pays for what it reads; brackets an
// earlier scan passed are looked up in `known` instead.
// Bytes the link/image scans and reference-label lookups may read in total. Unmatched
// brackets and nested undefined references would otherwise rescan the rest of the text,
// which is quadratic on hostile input. Once the budget is gone, further brackets are plain
// text. A scan pays for what it reads; earlier bracket matches are looked up in `known`.
let ticks = Backticks::new(s);
let mut links = LinkScan { text: s, refs, ticks: &ticks, known: HashMap::new(), budget: s.len().saturating_mul(8).saturating_add(8192) };
let b = s.as_bytes();
@@ -324,7 +337,7 @@ fn tokenize(s: &str, base: &Fmt, depth: usize, refs: &Refs) -> Vec<Node> {
match parsed {
Some((inner, dest, end)) if img => {
flush!();
let alt: String = parse_inlines(&inner, &Fmt::default(), depth + 1, refs)
let alt: String = parse_inlines(inner, &Fmt::default(), depth + 1, refs)
.iter()
.filter_map(|x| if let Inline::Text(t, _) = x { Some(t.as_str()) } else { None })
.collect();
@@ -343,7 +356,7 @@ fn tokenize(s: &str, base: &Fmt, depth: usize, refs: &Refs) -> Vec<Node> {
if f.link.is_none() {
model::dropped(model::Dropped::Link);
}
for x in parse_inlines(&inner, &f, depth + 1, refs) {
for x in parse_inlines(inner, &f, depth + 1, refs) {
nodes.push(Node::Inl(x));
}
i = end;
+110
View File
@@ -79,6 +79,53 @@ fn reference_links_resolve_and_their_definitions_leave_the_body() {
assert_eq!(links(fenced), vec![], "a fenced definition defines nothing");
}
/// Cached bracket pairs must not make undefined shortcut references normalize every nested
/// suffix again. One unrelated definition keeps this on the reference-lookup path.
#[test]
fn nested_undefined_reference_labels_have_bounded_work() {
let literal = format!("prefix {}x{} suffix", "[".repeat(128_000), "]".repeat(128_000));
let src = format!("{literal}\n\n[other]: https://example.com/other\n");
let started = std::time::Instant::now();
let doc = import(&src);
let elapsed = started.elapsed();
assert_eq!(doc.plain_text(wordcraft_doc::StoryRef::Body), literal);
assert!(elapsed < std::time::Duration::from_secs(10), "nested undefined references took {elapsed:?}");
}
#[test]
fn reference_lookup_keeps_long_unicode_labels_and_nested_text() {
// The work guard is not a label-length cap. Collapsed/full/shortcut references keep their
// case and whitespace matching, even when lowercasing changes the number of UTF-8 bytes.
let label = format!("{}İ END", "Écho ".repeat(1500));
let key = label.to_lowercase();
let src = format!("[{label}] [{label}][] [full][{label}]\n\n[{key}]: https://example.com/long\n");
let flow = parse(&src);
let FBlock::Para(p) = &flow.blocks[0] else { panic!("paragraph") };
let links: Vec<_> = p
.inlines
.iter()
.filter_map(|x| match x {
Inline::Text(t, f) if f.link.is_some() => Some((t.as_str(), f.link.as_deref())),
_ => None,
})
.collect();
assert_eq!(
links,
vec![
(label.as_str(), Some("https://example.com/long")),
(label.as_str(), Some("https://example.com/long")),
("full", Some("https://example.com/long"))
]
);
let refs = Refs::from([("known".into(), Arc::from("https://example.com/known"))]);
let out = parse_inlines("[outer [known]] tail [inline](https://example.com/inline)", &Fmt::default(), 0, &refs);
let text: String = out.iter().filter_map(|x| if let Inline::Text(t, _) = x { Some(t.as_str()) } else { None }).collect();
assert_eq!(text, "[outer known] tail inline");
assert!(out.iter().any(|x| matches!(x, Inline::Text(t, f) if t == "known" && f.link.as_deref() == Some("https://example.com/known"))));
assert!(out.iter().any(|x| matches!(x, Inline::Text(t, f) if t == "inline" && f.link.as_deref() == Some("https://example.com/inline"))));
}
/// A definition has to start a block (CommonMark §4.7), and a fence opens and closes by the
/// same rules the block parser uses. Reading a line without that context deleted text: a
/// definition-shaped line inside a paragraph, and every such line inside a code block whose
@@ -154,6 +201,69 @@ fn export_escapes_round_trip() {
assert!(q.list.is_none());
}
#[test]
fn export_emphasis_next_to_punctuation_round_trips() {
let marks: [(&str, fn(&mut Fmt)); 3] = [("bold", |f| f.bold = true), ("italic", |f| f.italic = true), ("strike", |f| f.strike = true)];
let cases: [&[(&str, bool)]; 4] = [
&[("注意:", true), ("请阅读", false)],
&[("a.", true), ("b", false)],
&[("a", false), ("!", true), ("b", false)],
&[("a", false), ("(x)", true), ("b", false)],
];
for (name, set) in marks {
for runs in cases {
let mut p = Para::default();
for (t, on) in runs {
let mut f = Fmt::default();
if *on {
set(&mut f);
}
p.push_text(t, &f);
}
let md = inlines_md(&p.inlines);
let back = parse(&md);
let FBlock::Para(q) = &back.blocks[0] else { panic!() };
let want: String = runs.iter().map(|r| r.0).collect();
assert_eq!(q.text(), want, "{name} exported as {md:?}");
let got: Vec<(String, bool)> = q
.inlines
.iter()
.filter_map(|i| if let Inline::Text(t, f) = i { Some((t.clone(), f.bold || f.italic || f.strike)) } else { None })
.collect();
let expect: Vec<(String, bool)> = runs.iter().map(|(t, on)| (t.to_string(), *on)).collect();
assert_eq!(got, expect, "{name} exported as {md:?}");
}
}
}
#[test]
fn export_adjacent_emphasis_next_to_punctuation_round_trips() {
let b = Fmt { bold: true, ..Fmt::default() };
let i = Fmt { italic: true, ..Fmt::default() };
let s = Fmt { strike: true, ..Fmt::default() };
let cases: [&[(&str, &Fmt)]; 6] = [
&[("ab", &b), ("!c", &i)],
&[("ab", &i), ("!c", &b)],
&[("ab", &b), ("!c", &s)],
&[("ab", &s), ("(c)", &b)],
&[("a.", &b), ("b", &i)],
&[("a.", &b), ("!b", &i)],
];
for runs in cases {
let mut p = Para::default();
for (t, f) in runs {
p.push_text(t, f);
}
let md = inlines_md(&p.inlines);
let back = parse(&md);
let FBlock::Para(q) = &back.blocks[0] else { panic!() };
let got: Vec<(String, bool, bool, bool)> =
q.inlines.iter().filter_map(|x| if let Inline::Text(t, f) = x { Some((t.clone(), f.bold, f.italic, f.strike)) } else { None }).collect();
let expect: Vec<(String, bool, bool, bool)> = runs.iter().map(|(t, f)| (t.to_string(), f.bold, f.italic, f.strike)).collect();
assert_eq!(got, expect, "exported as {md:?}");
}
}
#[test]
fn escape_line_start_preserves_indentation() {
assert_eq!(escape_line_start(" indented text".to_string()), " indented text");
+1 -1
View File
@@ -21,7 +21,7 @@ mod types;
pub use color::parse_color;
pub use from_doc::{ListCounter, flow_paras, from_doc, from_doc_tracked, is_mono, para_kind};
pub use media::{base64_decode, base64_encode, data_uri, decode_bounded, image_px, make_img, mime_of, percent_decode, sniff_image};
pub use media::{base64_decode, base64_encode, data_uri, decode_bounded, image_px, make_img, mime_of, percent_decode, sized_img, sniff_image};
pub use to_doc::{char_props, part_blocks, to_doc, to_doc_in};
pub use types::{
CODE_STYLE, Cell, FBlock, FTable, Flow, Fmt, FmtChange, Img, Inline, Kind, ListInfo, MAX_DEPTH, MAX_FONT_NAME, MAX_LINK, MONO_FONT, Meta, Para,
+12 -1
View File
@@ -42,7 +42,7 @@ pub fn parse_color(s: &str) -> Option<Rgb> {
let d = |i: usize| h.get(i..i + 1).and_then(|x| u8::from_str_radix(x, 16).ok()).map(|v| v * 17);
return Some(Rgb(d(0)?, d(1)?, d(2)?));
}
if h.len() == 8 && h.is_ascii() {
if h.len() == 8 && h.bytes().all(|b| b.is_ascii_hexdigit()) {
let p = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok());
return Some(Rgb(p(0)?, p(2)?, p(4)?));
}
@@ -119,3 +119,14 @@ pub fn parse_color(s: &str) -> Option<Rgb> {
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn eight_digit_hex_rejects_signs() {
assert_eq!(parse_color("#+1+2+3+4"), None);
assert_eq!(parse_color("#11223344"), Some(Rgb(0x11, 0x22, 0x33)));
}
}
+9 -2
View File
@@ -33,7 +33,14 @@ pub fn decode_bounded(data: &[u8]) -> Option<image::DynamicImage> {
pub fn make_img(data: impl Into<Arc<Vec<u8>>>, w: Option<f32>, h: Option<f32>, alt: &str) -> Option<Img> {
let data = data.into();
let ext = sniff_image(&data)?;
let (pw, ph) = image_px(&data).map(|(a, b)| (a as f32 * 0.75, b as f32 * 0.75)).unwrap_or((72.0, 72.0));
let natural = image_px(&data).map(|(a, b)| (a as f32 * 0.75, b as f32 * 0.75));
Some(sized_img(data, ext, natural, w, h, alt))
}
/// A picture of a known format `ext` whose natural size (points) the caller knows, if at all: a
/// metafile, which `image` can't measure. Sized like [`make_img`].
pub fn sized_img(data: Arc<Vec<u8>>, ext: &str, natural: Option<(f32, f32)>, w: Option<f32>, h: Option<f32>, alt: &str) -> Img {
let (pw, ph) = natural.filter(|(a, b)| a.is_finite() && b.is_finite()).unwrap_or((72.0, 72.0));
let (pw, ph) = (pw.max(1.0), ph.max(1.0));
let (mut w, mut h) = match (w.filter(|v| v.is_finite() && *v > 0.0), h.filter(|v| v.is_finite() && *v > 0.0)) {
(Some(w), Some(h)) => (w, h),
@@ -45,7 +52,7 @@ pub fn make_img(data: impl Into<Arc<Vec<u8>>>, w: Option<f32>, h: Option<f32>, a
h *= 468.0 / w;
w = 468.0;
}
Some(Img { data, ext: ext.to_string(), w: w.clamp(1.0, 1584.0), h: h.clamp(1.0, 1584.0), alt: alt.to_string() })
Img { data, ext: ext.to_string(), w: w.clamp(1.0, 1584.0), h: h.clamp(1.0, 1584.0), alt: alt.to_string() }
}
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+18 -1
View File
@@ -341,9 +341,19 @@ impl Writer {
}
fn picture(&mut self, img: &model::Img, out: &mut String) {
// A metafile's `\picw`/`\pich` are its size in 0.01 mm (#4259).
let hundredths = ((img.w * 2540.0 / 72.0).round() as i64, (img.h * 2540.0 / 72.0).round() as i64);
let (data, blip) = match img.ext.as_str() {
"png" => (img.data.to_vec(), "\\pngblip"),
"jpeg" | "jpg" => (img.data.to_vec(), "\\jpegblip"),
// The bytes decide, as on reading: an EMF starts with its header record, type 1 (a
// DOCX may store one under `.wmf`).
"emf" | "wmf" if img.data.starts_with(&[1, 0, 0, 0]) => (img.data.to_vec(), "\\emfblip"),
// RTF holds a WMF without its 22-byte placeable header.
"emf" | "wmf" => {
let data = if img.data.starts_with(&[0xD7, 0xCD, 0xC6, 0x9A]) { img.data.get(22..).unwrap_or_default() } else { img.data.as_slice() };
(data.to_vec(), "\\wmetafile8")
}
_ => {
// Re-encode other formats as PNG, bounding the decode so a tiny file declaring
// huge dimensions can't exhaust memory on export.
@@ -355,7 +365,14 @@ impl Writer {
(buf, "\\pngblip")
}
};
let (pw, ph) = model::image_px(&data).unwrap_or((1, 1));
// A placeable header with nothing after it leaves no picture to write.
if data.is_empty() {
return;
}
let (pw, ph) = match blip {
"\\emfblip" | "\\wmetafile8" => hundredths,
_ => model::image_px(&data).map(|(a, b)| (i64::from(a), i64::from(b))).unwrap_or((1, 1)),
};
out.push_str(&format!(
"{{\\pict{blip}\\picw{pw}\\pich{ph}\\picwgoal{}\\pichgoal{}\n",
(img.w * 20.0).round() as i64,
+4
View File
@@ -62,6 +62,10 @@ pub(super) struct Reader {
pub(super) pict_h: Option<f32>,
pub(super) pict_sx: f32,
pub(super) pict_sy: f32,
/// `\picw`/`\pich` as written, read for metafiles (0 when absent).
pub(super) pict_nat: (i32, i32),
/// The mapping mode `N` of `\wmetafileN`.
pub(super) pict_mm: i32,
pub(super) bkmk: Option<(usize, String)>,
/// Authors of tracked changes (`\revtbl`), and the entry being read.
rev_authors: Vec<String>,
+88
View File
@@ -457,6 +457,94 @@ fn shppict_imports_one_image_and_skips_legacy_duplicate() -> Result<(), String>
Ok(())
}
/// #4259: `\wmetafile8` and `\emfblip` pictures are kept as WMF/EMF media (they were dropped),
/// sized from `\picwgoal`/`\pichgoal` and `\picscalex`/`\picscaley`, else from `\picw`/`\pich`
/// (0.01 mm for a metafile). As Word writes a floating picture: the `\shprslt` copy is read.
#[test]
fn metafile_pictures_are_imported() -> Result<(), String> {
// A plain WMF: the 9-word header (type 1, version 0x300, 12 words, max record 3), then EOF.
let wmf: Vec<u8> = [1u16, 9, 0x300, 12, 0, 0, 3, 0, 0, 3, 0, 0].iter().flat_map(|w| w.to_le_bytes()).collect();
let hex: String = wmf.iter().map(|b| format!("{b:02x}")).collect();
let images = |rtf: String| -> Result<Vec<model::Img>, String> {
let f = parse(rtf.as_bytes())?;
Ok(f.blocks
.iter()
.filter_map(|b| match b {
FBlock::Para(p) => Some(p.inlines.clone()),
FBlock::Table(_) => None,
})
.flatten()
.filter_map(|i| match i {
Inline::Image(img) => Some(img),
_ => None,
})
.collect())
};
let pict = format!("{{\\pict\\wmetafile8\\picw38761\\pich12832\\picwgoal21972\\pichgoal7272\\picscalex45\\picscaley45 {hex}}}");
let shape = format!("{{\\shp{{\\*\\shpinst{{\\sp{{\\sn pib}}{{\\sv {pict}}}}}}}{{\\shprslt{pict}}}}}");
let got = images(format!("{{\\rtf1\\ansi Before{shape}After\\par}}"))?;
let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) };
assert_eq!((img.ext.as_str(), img.data.as_ref()), ("wmf", &wmf));
// 21972 twips at 45% is 494.4 pt, shrunk to the 468 pt column like every imported picture.
assert_eq!(img.w, 468.0);
assert!((img.h - 7272.0 / 20.0 * 0.45 * 468.0 / 494.37).abs() < 0.1, "{}", img.h);
// No goal size: the natural size, 2540 × 1270 hundredths of a millimetre = 72 × 36 pt; under
// MM_TEXT (`\wmetafile1`) the same numbers are pixels.
let hex_of = |b: &[u8]| -> String { b.iter().map(|b| format!("{b:02x}")).collect() };
let mut emf = vec![0u8; 88];
emf[0] = 1;
emf[4] = 88;
emf[40..44].copy_from_slice(b" EMF");
let emf_hex = hex_of(&emf);
let got = images(format!("{{\\rtf1\\ansi{{\\pict\\emfblip\\picw2540\\pich1270 {emf_hex}}}\\par}}"))?;
let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) };
assert_eq!((img.ext.as_str(), img.w, img.h, img.data.as_ref()), ("emf", 72.0, 36.0, &emf));
let got = images(format!("{{\\rtf1\\ansi{{\\pict\\wmetafile1\\picw96\\pich48 {hex}}}\\par}}"))?;
let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) };
assert_eq!((img.ext.as_str(), img.w, img.h), ("wmf", 72.0, 36.0));
// `\picscalex`/`\picscaley` scale the natural size when there is no goal size.
let got = images(format!("{{\\rtf1\\ansi{{\\pict\\wmetafile8\\picw2540\\pich1270\\picscalex50\\picscaley200 {hex}}}\\par}}"))?;
let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) };
assert_eq!((img.w, img.h), (36.0, 72.0));
// The other mapping modes use their own units: 0.1 mm, 0.01 in, 0.001 in and twips.
for (mode, w, h) in [(2, 254, 127), (4, 100, 50), (5, 1000, 500), (6, 1440, 720)] {
let got = images(format!("{{\\rtf1\\ansi{{\\pict\\wmetafile{mode}\\picw{w}\\pich{h} {hex}}}\\par}}"))?;
let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) };
assert!((img.w - 72.0).abs() < 0.01 && (img.h - 36.0).abs() < 0.01, "mode {mode}: {} x {}", img.w, img.h);
}
// Hostile: a zero or negative natural size falls back to the default 72 pt square, and bytes
// that are no picture at all are dropped, whatever the control word says.
let got = images(format!("{{\\rtf1\\ansi{{\\pict\\wmetafile8\\picw0\\pich-5 {hex}}}\\par}}"))?;
let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) };
assert_eq!((img.w, img.h), (72.0, 72.0));
for blip in ["wmetafile8", "emfblip"] {
assert!(images(format!("{{\\rtf1\\ansi{{\\pict\\{blip}\\picw2540\\pich1270 0123456789abcdef}}\\par}}"))?.is_empty(), "{blip}");
}
// Saved as RTF and read back: the same picture, the EMF byte for byte, the WMF without the
// placeable header RTF has no room for.
let placeable: Vec<u8> = [0xD7u8, 0xCD, 0xC6, 0x9A].into_iter().chain([0u8; 18]).chain(wmf.iter().copied()).collect();
for (blip, data, want, ext) in [("emfblip", &emf, &emf, "emf"), ("wmetafile8", &wmf, &wmf, "wmf"), ("wmetafile8", &placeable, &wmf, "wmf")] {
let rtf = format!("{{\\rtf1\\ansi{{\\pict\\{blip}\\picw2540\\pich1270 {}}}\\par}}", hex_of(data));
let got = images(export(&import(rtf.as_bytes())?))?;
let [img] = got.as_slice() else { return Err(format!("{blip}: {} pictures", got.len())) };
assert_eq!((img.ext.as_str(), img.data.as_ref(), img.w, img.h), (ext, want, 72.0, 36.0), "{blip}");
}
// An EMF stored under `.wmf` (a DOCX can) is written as the EMF its bytes are.
let mut doc = import(format!("{{\\rtf1\\ansi{{\\pict\\wmetafile8\\picw2540\\pich1270 {hex}}}\\par}}").as_bytes())?;
let (key, bytes) = doc.media.iter_mut().next().ok_or("no media")?;
assert!(key.ends_with(".wmf"), "{key}");
*bytes = std::sync::Arc::new(emf.clone());
let rtf = export(&doc);
assert!(rtf.contains("\\emfblip") && !rtf.contains("\\wmetafile"), "{rtf}");
let got = images(rtf)?;
let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) };
assert_eq!((img.ext.as_str(), img.data.as_ref()), ("emf", &emf));
// A placeable header with no records after it has nothing to write: no empty picture group.
*doc.media.values_mut().next().ok_or("no media")? = std::sync::Arc::new(placeable[..22].to_vec());
assert!(!export(&doc).contains("\\pict"), "an empty metafile is not written");
Ok(())
}
#[test]
fn unicode_in_info_bookmarks_and_field_instructions() {
// #435: `\uN` (with its `\ucN` fallback skipped) decodes in every text destination.
+23 -1
View File
@@ -1,5 +1,6 @@
use super::state::{Dest, MAX_PICT, St};
use super::{Fmt, Inline, Reader, Rgb, make_img, model};
use std::sync::Arc;
/// The encoding of an East Asian multibyte code page (932 Shift-JIS, 936 GBK, 949 UHC, 950 Big5),
/// which [`Reader::finish_multibyte`] decodes a run with; `None` for the single-byte code pages.
@@ -155,7 +156,28 @@ impl Reader {
let sy = if self.pict_sy > 0.0 { self.pict_sy / 100.0 } else { 1.0 };
let w = self.pict_w.map(|v| v * sx);
let h = self.pict_h.map(|v| v * sy);
if let Some(img) = make_img(data, w, h, "") {
// The control word only says "a metafile": the bytes decide what it is (#4259).
let img = if matches!(self.pict_kind, 3 | 4) && wordcraft_metafile::is_metafile(&data) {
// An EMF starts with its header record, type 1.
let ext = if data.get(..4) == Some(&[1, 0, 0, 0]) { "emf" } else { "wmf" };
// `\picw`/`\pich` are in the units of the `\wmetafileN` mapping mode; 0.01 mm for the
// isotropic and anisotropic modes and for EMF.
let unit = match (self.pict_kind, self.pict_mm) {
(3, 1) => 0.75, // MM_TEXT: pixels
(3, 2) => 72.0 / 254.0, // MM_LOMETRIC: 0.1 mm
(3, 4) => 0.72, // MM_LOENGLISH: 0.01 in
(3, 5) => 0.072, // MM_HIENGLISH: 0.001 in
(3, 6) => 0.05, // MM_TWIPS
_ => 72.0 / 2540.0, // MM_HIMETRIC, MM_ISOTROPIC, MM_ANISOTROPIC
};
let pt = |v: i32| (v > 0).then(|| v.min(1_000_000) as f32 * unit);
// `\picscalex`/`\picscaley` scale the natural size too, when no goal size is given.
let natural = pt(self.pict_nat.0).zip(pt(self.pict_nat.1)).map(|(a, b)| (a * sx, b * sy));
Some(model::sized_img(Arc::new(data), ext, natural, w, h, ""))
} else {
make_img(data, w, h, "")
};
if let Some(img) = img {
self.para.inlines.push(Inline::Image(img));
self.para_has = true;
}
+10
View File
@@ -30,6 +30,8 @@ impl Reader {
self.pict_h = None;
self.pict_sx = 0.0;
self.pict_sy = 0.0;
self.pict_nat = (0, 0);
self.pict_mm = 0;
}
Dest::FldInst if self.st.field.is_none() => self.start_field(),
// Old character formatting holds the properties that changed (Word) or, after a
@@ -453,6 +455,14 @@ impl Reader {
match word {
"pngblip" => self.pict_kind = 1,
"jpegblip" => self.pict_kind = 2,
// Windows and Enhanced Metafiles (#4259): drawn by the metafile renderer.
"wmetafile" => {
self.pict_kind = 3;
self.pict_mm = value;
}
"emfblip" => self.pict_kind = 4,
"picw" => self.pict_nat.0 = value,
"pich" => self.pict_nat.1 = value,
"picwgoal" => self.pict_w = Some(value.clamp(1, 31_680) as f32 / 20.0),
"pichgoal" => self.pict_h = Some(value.clamp(1, 31_680) as f32 / 20.0),
"picscalex" => self.pict_sx = value.clamp(1, 1000) as f32,
+18 -1
View File
@@ -147,7 +147,11 @@ pub(super) fn iso_time(v: &str) -> Option<String> {
_ => return None,
}
}
let s = s.round() as u64;
let mut s = s.round() as u64;
m = m.saturating_add(s / 60);
s %= 60;
h = h.saturating_add(m / 60);
m %= 60;
return Some(if s == 0 { format!("{h:02}:{m:02}") } else { format!("{h:02}:{m:02}:{s:02}") });
}
// xs:time: hh:mm[:ss[.sss]][Z|+...]
@@ -159,3 +163,16 @@ pub(super) fn iso_time(v: &str) -> Option<String> {
}
None
}
#[cfg(test)]
mod tests {
use super::iso_time;
#[test]
fn duration_seconds_round_up_carries() {
assert_eq!(iso_time("PT1H59M59.6S").as_deref(), Some("02:00"));
assert_eq!(iso_time("PT59M59.5S").as_deref(), Some("01:00"));
assert_eq!(iso_time("PT0H0M59.4S").as_deref(), Some("00:00:59"));
assert_eq!(iso_time("PT1H2M3S").as_deref(), Some("01:02:03"));
}
}
+9
View File
@@ -1002,3 +1002,12 @@ fn dropped_names_and_links_are_logged_once_per_document() {
let (_, warned) = crate::test_log::warnings(|| crate::latex::import(tex.as_bytes()));
assert_eq!(count(&warned, "links longer"), 1, "{warned:?}");
}
#[test]
fn html_title_outside_head_is_metadata_not_body() {
for html in ["<title>T</title><p>b</p>", "<html><title>T</title><body>b</body></html>"] {
let d = crate::html::import(html);
assert_eq!(para_texts(&d), vec!["b".to_string()], "{html}");
assert_eq!(d.core.title, "T", "{html}");
}
}
+96 -34
View File
@@ -1,6 +1,6 @@
use super::options::DisplayOptions;
use super::paint::{Paint, paint_for};
use super::{BezPath, Document, Draw, Graphic, GraphicItem, GraphicKind, InlineObject, PathSeg, Rect, Rgb, ShapeKind, Spin, TextAlign};
use super::{BezPath, Document, Draw, FaceRef, Graphic, GraphicItem, GraphicKind, InlineObject, PathSeg, Rect, Rgb, ShapeKind, Spin, TextAlign};
/// The draws of a chart or diagram's items inside `rect` (page coordinates). The items were built
/// for the size `g.w` × `g.h` (0: `rect`'s own size), so they are scaled to `rect`; strokes and text
@@ -271,51 +271,113 @@ pub fn seg_path(segs: &[PathSeg]) -> BezPath {
}
/// One line of a `GraphicItem::Text` in `r`, shaped in its font (the document's body font when
/// unset) and aligned horizontally, centred vertically, at its size times `k`. `None` when there is
/// nothing to draw, or the item's rectangle or size isn't a finite number (a degenerate scale:
/// `r` itself is always finite, as [`Rect::new`] makes it).
fn text_glyphs(doc: &Document, r: Rect, it: &GraphicItem, alpha: f32, k: f32) -> Option<Draw> {
let GraphicItem::Text { rect, text, size, color, bold, align, font } = it else { return None };
/// unset) and aligned horizontally, centred vertically, at its size times `k`. Characters the font
/// lacks (Chinese in a chart drawn in Calibri's substitute, #4720) are drawn in a fallback face, as
/// in body text: one draw per run of a face, in reading order. Empty when there is nothing to draw,
/// or the item's rectangle or size isn't a finite number (a degenerate scale: `r` itself is always
/// finite, as [`Rect::new`] makes it).
fn text_glyphs(doc: &Document, r: Rect, it: &GraphicItem, alpha: f32, k: f32) -> Vec<Draw> {
let GraphicItem::Text { rect, text, size, color, bold, align, font } = it else { return Vec::new() };
let size = *size * k;
if !rect.iter().chain([&size]).all(|v| v.is_finite()) {
return None;
return Vec::new();
}
let family = font.as_deref().filter(|f| !f.is_empty()).unwrap_or(&doc.settings.minor_font);
let size = size.clamp(1.0, 400.0);
let resolved = wordcraft_fonts::word::resolve(family, *bold, false);
let face = resolved.face.get();
let shaped = wordcraft_fonts::shape(face, text, &[], |c| c);
if shaped.is_empty() {
return None;
}
let k = size / face.upem.max(1.0) as f32;
let width: f32 = shaped.iter().map(|g| g.x_advance as f32 * k).sum();
let (ascent, descent) = wordcraft_fonts::word::line_metrics(face);
let baseline = r.y + r.h / 2.0 + (ascent - descent) as f32 * k / 2.0;
let primary = resolved.face;
let scale = |f: FaceRef| size / f.upem.max(1.0) as f32;
let runs: Vec<_> = face_runs(text, primary, *bold)
.into_iter()
.map(|(face, s)| (face, s, wordcraft_fonts::shape(face.get(), s, &[], |c| c)))
.filter(|(_, _, shaped)| !shaped.is_empty())
.collect();
let width: f32 = runs.iter().map(|(f, _, shaped)| shaped.iter().map(|g| g.x_advance as f32).sum::<f32>() * scale(*f)).sum();
// The font's own line metrics centre the line, whichever faces draw it.
let (ascent, descent) = wordcraft_fonts::word::line_metrics(primary.get());
let baseline = r.y + r.h / 2.0 + (ascent - descent) as f32 * scale(primary) / 2.0;
let mut pen = match align {
TextAlign::Left => r.x,
TextAlign::Center => r.x + (r.w - width) / 2.0,
TextAlign::Right => r.x + r.w - width,
};
let mut glyphs = Vec::with_capacity(shaped.len());
for g in &shaped {
glyphs.push((g.gid, pen + g.x_offset as f32 * k, baseline - g.y_offset as f32 * k));
pen += g.x_advance as f32 * k;
let mut out = Vec::with_capacity(runs.len());
for (face, s, shaped) in runs {
let k = scale(face);
let mut glyphs = Vec::with_capacity(shaped.len());
for g in &shaped {
glyphs.push((g.gid, pen + g.x_offset as f32 * k, baseline - g.y_offset as f32 * k));
pen += g.x_advance as f32 * k;
}
// A fallback face is emboldened when it has no bold of its own, as in body text.
let synth_bold = if face.id() == primary.id() { resolved.synth_bold } else { *bold && face.weight < 600.0 };
out.push(Draw::Glyphs {
face,
size,
glyphs,
color: *color,
alpha,
synth_bold,
synth_italic: false,
text: s.to_string(),
link: None,
ranges: Vec::new(),
text_bg: None,
x_scale: 1.0,
});
}
Some(Draw::Glyphs {
face: resolved.face,
size,
glyphs,
color: *color,
alpha,
synth_bold: resolved.synth_bold,
synth_italic: false,
text: text.clone(),
link: None,
ranges: Vec::new(),
text_bg: None,
x_scale: 1.0,
})
out
}
/// `text` split into runs of the face that draws them: `primary` where it has the character, else a
/// fallback face. A run the primary lacks takes one face covering all of it when one does (so a
/// Chinese label doesn't mix serif and sans glyphs), else each character its own fallback. Spaces
/// and combining marks stay in the run before them; a character no face has stays in `primary`.
fn face_runs(text: &str, primary: FaceRef, bold: bool) -> Vec<(FaceRef, &str)> {
let joins = |c: char| c.is_whitespace() || wordcraft_doc::math_linear::is_combining(c);
// (start, end, drawn by `primary`)
let mut spans: Vec<(usize, usize, bool)> = Vec::new();
for (i, c) in text.char_indices() {
let end = i + c.len_utf8();
let own = primary.covers(c);
match spans.last_mut() {
Some(last) if last.2 == own || joins(c) => last.1 = end,
_ => spans.push((i, end, own)),
}
}
let db = wordcraft_fonts::FontDb::global();
let mut out: Vec<(FaceRef, &str)> = Vec::with_capacity(spans.len());
for (start, end, own) in spans {
let Some(s) = text.get(start..end) else { continue };
if own {
out.push((primary, s));
continue;
}
let chars: Vec<char> = s.chars().filter(|c| !joins(*c)).collect();
if let Some(f) = db.fallback_for_run(&chars, primary.id(), bold, false) {
out.push((FaceRef::of(&f), s));
continue;
}
// No one face has them all: each character in its own fallback, neighbours sharing a face
// in one run.
let mut run: Option<(FaceRef, usize)> = None;
for (i, c) in s.char_indices() {
let face =
if joins(c) { None } else { Some(db.fallback_styled(c, primary.id(), bold, false).map(|f| FaceRef::of(&f)).unwrap_or(primary)) };
match (run, face) {
(Some((cur, from)), Some(f)) if f.id() != cur.id() => {
out.extend(s.get(from..i).map(|t| (cur, t)));
run = Some((f, i));
}
(None, f) => run = Some((f.unwrap_or(primary), i)),
_ => {}
}
}
if let Some((cur, from)) = run {
out.extend(s.get(from..).map(|t| (cur, t)));
}
}
out
}
/// `items` turned by `spin` about the centre of `rect` (as they are, unturned).

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