Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor ac2fa7bbb7 fix(flatpak): allow reading launcher version pins 2026-10-30 18:42:00 -04:00
Dylan M. Taylor 087d330a24 fix(flatpak): allow reading launcher version pins 2026-10-30 18:40:59 -04: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
OPUZZLES 01a1bf3921 M9: assert unused recipient labels are empty (#4880) 2026-10-29 18:14:46 +01: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
147 changed files with 5085 additions and 468 deletions
+4 -1
View File
@@ -11,7 +11,7 @@ on:
default: ""
permissions:
contents: write
contents: read
concurrency:
group: release-${{ github.ref }}
@@ -19,6 +19,9 @@ concurrency:
jobs:
release:
# Only this job publishes the GitHub release, so only it gets write access.
permissions:
contents: 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
+5
View File
@@ -82,3 +82,8 @@ Progress docs follow [`craftrules/standards/progress-docs.md`](https://github.co
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.
+5
View File
@@ -63,3 +63,8 @@ Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's o
## 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.
## 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
+7 -5
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=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116"
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",
]
@@ -6812,6 +6813,7 @@ dependencies = [
"quick-xml 0.41.0",
"wordcraft-doc",
"wordcraft-geom",
"wordcraft-metafile",
"zip 3.0.0",
]
+2 -1
View File
@@ -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 = "04aa1703b32d299e54f78fcde4182f2b393a9116", 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.
+56 -2
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;
+9
View File
@@ -181,3 +181,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() {
+3 -1
View File
@@ -795,7 +795,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);
}
+6 -1
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),
+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);
+65
View File
@@ -1,5 +1,7 @@
use super::*;
mod note_conversion;
#[test]
fn new_document_is_valid() {
let d = Document::new();
@@ -651,3 +653,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 -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);
}
+4
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;
+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();
}
}
@@ -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,
+47 -9
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)?;
+37 -13
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)}"#)
@@ -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}))
}
+69
View File
@@ -375,6 +375,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)
}
+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);
}
+390 -1
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]
@@ -4594,7 +4953,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 +4990,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]
+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")));
}
}
+151
View File
@@ -1414,6 +1414,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 +3196,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 +3489,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() {
+40
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,23 @@ 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));
}
+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).
+73
View File
@@ -74,6 +74,20 @@ pub struct ObjectHit {
pub origin: Point,
}
/// Object positions in one layout, retaining each page occurrence for page-hinted lookups.
/// Build once for a batch of lookups; rebuild when the layout changes.
pub struct ObjectIndex {
by_pos: HashMap<Pos, Vec<ObjectHit>>,
}
impl ObjectIndex {
/// Like [`DocLayout::object`]: prefer `page_hint`, otherwise the first matching page.
pub fn object(&self, pos: &Pos, page_hint: usize) -> Option<&ObjectHit> {
let hits = self.by_pos.get(pos)?;
hits.binary_search_by_key(&page_hint, |h| h.page).ok().and_then(|i| hits.get(i)).or_else(|| hits.first())
}
}
/// The inputs for a column selection across laid-out lines.
pub struct ColumnSelection<'a> {
/// The selection's first corner.
@@ -456,6 +470,22 @@ impl DocLayout {
self.find_object(page_hint, |o| o.story == pos.story && o.path == pos.path && o.off == pos.off)
}
/// Index objects once for repeated [`Self::object`] lookups, preserving their selection
/// order, including objects repeated on several pages (headers or repeated table rows).
pub fn object_index(&self) -> ObjectIndex {
let mut by_pos: HashMap<Pos, Vec<ObjectHit>> = HashMap::new();
for (i, page) in self.pages.iter().enumerate() {
for hit in all_objects(page, i) {
let hits = by_pos.entry(hit.pos()).or_default();
// all_objects has the same within-page order as find_object; first wins.
if hits.last().is_none_or(|prev| prev.page != i) {
hits.push(hit);
}
}
}
ObjectIndex { by_pos }
}
/// Where the text box showing story `part` is laid out.
pub fn text_box(&self, part: u32, page_hint: usize) -> Option<ObjectHit> {
self.find_object(page_hint, |o| o.text_box == Some(part))
@@ -939,3 +969,46 @@ fn score(l: &LineHit, x: f32, y: f32) -> f32 {
};
dy * 4.0 + dx
}
#[cfg(test)]
mod object_index_tests {
use super::*;
/// Compare with the ordinary lookup, including repeated rows/parts and within-page ties.
#[test]
fn indexed_objects_keep_page_hints_and_first_match_order() {
let body = Pos::body(0, 3);
let header = Pos::new(StoryRef::Part(7), Path(vec![0, 0, 0, 1]), 6);
let footer = Pos::new(StoryRef::Part(8), Path(vec![0]), 0);
let object = |pos: &Pos, x| Placed::Object {
rect: Rect::new(x, 20.0, 40.0, 30.0),
spin: wordcraft_geom::Spin::default(),
story: pos.story,
path: pos.path.clone(),
off: pos.off,
text_box: None,
wrap: Wrap::InFrontOfText,
origin: Point::new(5.0, 6.0),
};
let layout = DocLayout {
pages: vec![
Page { items: vec![object(&body, 10.0), object(&body, 11.0)], header: vec![object(&header, 12.0)], ..Default::default() },
Page::default(),
Page {
items: vec![object(&body, 30.0)],
header: vec![object(&header, 31.0), object(&header, 32.0)],
footer: vec![object(&footer, 33.0)],
..Default::default()
},
],
..Default::default()
};
let index = layout.object_index();
for pos in [body, header, footer, Pos::body(99, 0), Pos::new(StoryRef::Part(7), Path(vec![0]), 6)] {
for hint in [0, 1, 2, 3, usize::MAX] {
assert_eq!(index.object(&pos, hint).cloned(), layout.object(&pos, hint), "{pos:?}, page hint {hint}");
}
}
assert!(DocLayout::default().object_index().object(&Pos::body(0, 0), 0).is_none());
}
}
+35 -7
View File
@@ -391,9 +391,9 @@ struct TableFrame {
x: f32,
}
fn table_frame(ctx: &mut Ctx, t: &Table, avail: f32, depth: usize, style: Option<&TableStyleProps>) -> TableFrame {
// Nested measurement needs the same preferred weights as layout before sharing span minima.
fn preferred_grid(t: &Table, avail: f32) -> Vec<f32> {
let ncols = grid_cols(t).max(1);
// Column widths.
let mut grid: Vec<f32> = if t.grid.len() == ncols {
// Grid units can be narrower than a text box, including omitted row-end columns.
t.grid
@@ -408,6 +408,12 @@ fn table_frame(ctx: &mut Ctx, t: &Table, avail: f32, depth: usize, style: Option
vec![avail / ncols as f32; ncols]
};
apply_cell_widths(t, &mut grid);
grid
}
fn table_frame(ctx: &mut Ctx, t: &Table, avail: f32, depth: usize, style: Option<&TableStyleProps>) -> TableFrame {
let ncols = grid_cols(t).max(1);
let mut grid = preferred_grid(t, avail);
let mut total: f32 = grid.iter().sum();
// A top-level table in an inherited header or footer takes its share of the defining
// section's width (`Ctx::pct_base`).
@@ -437,7 +443,7 @@ fn table_frame(ctx: &mut Ctx, t: &Table, avail: f32, depth: usize, style: Option
if !t.props.fixed {
// Measuring lays paragraphs out once more; it mustn't count their equations twice.
let eq_count = ctx.eq_count;
let content = column_content(ctx, t, style, depth);
let content = column_content(ctx, t, style, depth, &grid);
ctx.eq_count = eq_count;
if let Some(content) = content {
autofit(&mut grid, &content, t, avail);
@@ -1167,7 +1173,7 @@ fn region_text(st: &TableStyleProps, region: Region) -> CellText {
/// Each column's narrowest and widest content, cell margins included: its longest word and its
/// longest line. A cell spanning columns shares what they lack between them; nested tables count
/// with their own. `None` when every cell is empty.
fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, depth: usize) -> Option<Vec<(f32, f32)>> {
fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, depth: usize, grid: &[f32]) -> Option<Vec<(f32, f32)>> {
let margins_def = default_margins(t, style);
// The grid's width once: counting it walks every row.
let ncols = grid_cols(t);
@@ -1196,15 +1202,36 @@ fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, dep
for (g, span, need) in spanning {
let Some(cs) = cols.get_mut(g..g.saturating_add(span).min(ncols)) else { continue };
let n = cs.len().max(1) as f32;
let (have_lo, have_hi) = cs.iter().fold((0.0, 0.0), |(l, h), c| (l + c.0, h + c.1));
let have_hi: f32 = cs.iter().map(|c| c.1).sum();
let preferred = (!is_automatic(t)).then(|| grid.get(g..g + cs.len())).flatten();
share_span_min(cs, preferred, need.0);
for c in cs {
c.0 += (need.0 - have_lo).max(0.0) / n;
c.1 += (need.1 - have_hi).max(0.0) / n;
}
}
any.then_some(cols)
}
// Share a span's extra minimum in preferred-column proportions. Each column keeps
// its own text minimum; automatic tables share the extra equally.
fn share_span_min(cols: &mut [(f32, f32)], grid: Option<&[f32]>, need: f32) {
let extra = (need - cols.iter().map(|c| c.0).sum::<f32>()).max(0.0);
let weight = |w: &f32| if w.is_finite() { w.clamp(0.0, UNBOUNDED) } else { 0.0 };
let total = grid.map_or(0.0, |grid| grid.iter().take(cols.len()).map(weight).sum::<f32>());
if total > 0.0 {
if let Some(grid) = grid {
for (c, w) in cols.iter_mut().zip(grid) {
c.0 += extra * weight(w) / total;
}
}
return;
}
let share = extra / cols.len().max(1) as f32;
for c in cols {
c.0 += share;
}
}
/// The grid columns row `row` leaves empty before its first cell and after its last one (which
/// ends at grid column `end`) when it gives their width (`w:wBefore`, `w:wAfter`): (first column,
/// columns, width in points). Column widths make room for them like for an empty cell.
@@ -1234,7 +1261,8 @@ fn blocks_content(ctx: &mut Ctx, blocks: &Blocks, text: Option<&CellText>, depth
(width, width)
} else {
let style = table_style(ctx, nt);
let cols = column_content(ctx, nt, style.as_ref(), depth + 1).unwrap_or_default();
let weights = preferred_grid(nt, width);
let cols = column_content(ctx, nt, style.as_ref(), depth + 1, &weights).unwrap_or_default();
let (l, h) = cols.iter().fold((0.0, 0.0), |(l, h), c| (l + c.0, h + c.1));
(width.max(l), width.max(h))
}
+152
View File
@@ -6226,6 +6226,50 @@ fn graphic_text_in_a_degenerate_rectangle_is_not_drawn() {
assert!(!draws.iter().any(|x| matches!(x, display::Draw::Glyphs { text, .. } if text == "Q1")));
}
#[test]
fn chart_text_the_font_lacks_is_drawn_in_a_fallback_face() {
// #4720: a Chinese legend in a chart drawn in the body font (which has no Chinese) showed boxes.
use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, TextAlign};
use wordcraft_doc::props::Rgb;
if wordcraft_fonts::FontDb::global().fallback_for('销', 0).is_none() {
eprintln!("skipped: no Chinese font installed");
return;
}
let label = "Sales 销售额 2024";
let items = vec![GraphicItem::Text {
rect: [0.0, 100.0, 216.0, 20.0],
text: label.into(),
size: 10.0,
color: Rgb::BLACK,
bold: false,
align: TextAlign::Center,
font: None,
}];
let graphic = Graphic { kind: GraphicKind::Chart, items, ..Default::default() };
let obj = InlineObject::Graphic { w: 216.0, h: 144.0, alt: String::new(), float: Default::default(), graphic: Arc::new(graphic) };
let mut d = Document::from_text("Chart");
d.insert_object(&Pos::body(0, 0), obj, &Default::default()).unwrap();
let draws = flat(display::page_display(&d, &lay(&d).pages[0], &Default::default()));
let runs: Vec<_> = draws
.iter()
.filter_map(|x| if let display::Draw::Glyphs { face, text, glyphs, .. } = x { Some((*face, text.as_str(), glyphs)) } else { None })
.filter(|(_, text, _)| label.contains(*text) && text.trim() != "")
.collect();
// The label is drawn whole, in reading order, each character in a face that has it.
assert_eq!(runs.iter().map(|(_, t, _)| *t).collect::<String>(), label);
for (face, text, glyphs) in &runs {
for c in text.chars().filter(|c| !c.is_whitespace()) {
assert!(face.covers(c), "{c} in {}", face.family);
}
assert!(glyphs.iter().all(|g| g.0 != 0), "no missing glyphs in {text:?}");
}
// The Chinese takes one face, and the runs follow each other along the line.
assert_eq!(runs.iter().filter(|(_, t, _)| t.contains('销')).count(), 1);
assert!(runs.iter().any(|(_, t, _)| t.contains("销售额")), "{:?}", runs.iter().map(|r| r.1).collect::<Vec<_>>());
let xs: Vec<f32> = runs.iter().flat_map(|(_, _, g)| g.iter().map(|g| g.1)).collect();
assert!(xs.windows(2).all(|w| w[0] <= w[1]), "{xs:?}");
}
/// The draws with figures opened up.
fn flat(draws: Vec<display::Draw>) -> Vec<display::Draw> {
draws.into_iter().flat_map(|x| if let display::Draw::Figure { draws, .. } = x { draws } else { vec![x] }).collect()
@@ -11232,6 +11276,114 @@ fn caret_height_ignores_line_spacing() {
}
}
enum SpanWidth {
Preferred,
Automatic,
TextOnly,
}
fn banner_layout(width: SpanWidth) -> DocLayout {
let widths = [150.0, 100.0, 25.0, 25.0];
let mut d = Document::new();
let mut t = Table::new(2, widths.len(), 300.0);
t.grid = widths.to_vec();
t.props.width = match width {
SpanWidth::Preferred => Some(300.0),
SpanWidth::Automatic | SpanWidth::TextOnly => None,
};
t.props.cell_margins = Some([0.0; 4]);
t.rows[0].cells.truncate(1);
t.rows[0].cells[0].props.span = 4;
t.rows[0].cells[0].props.width = t.props.width;
let mut banner = wordcraft_doc::Paragraph::new();
if !matches!(width, SpanWidth::TextOnly) {
banner.insert_object(0, picture(300.0, 30.0, Default::default()), &Default::default()).unwrap();
}
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(banner)];
for (i, cell) in t.rows[1].cells.iter_mut().enumerate() {
cell.props.width = match width {
SpanWidth::Preferred => Some(widths[i]),
SpanWidth::Automatic | SpanWidth::TextOnly => None,
};
cell.blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("A", Default::default()))];
}
d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(t)).unwrap();
lay(&d)
}
#[test]
fn spanning_banner_uses_weights() {
let text = banner_layout(SpanWidth::TextOnly);
let minimum = text.pages[0]
.items
.iter()
.find_map(|item| match item {
Placed::Cell { rect, row: 1, .. } => Some(rect.w),
_ => None,
})
.unwrap();
let widths = [150.0 - minimum, 100.0 - minimum / 3.0, 25.0 + 2.0 * minimum / 3.0, 25.0 + 2.0 * minimum / 3.0];
let l = banner_layout(SpanWidth::Preferred);
let actual: Vec<_> =
l.pages[0].items.iter().filter_map(|it| if let Placed::Cell { rect, row: 1, .. } = it { Some(rect.w) } else { None }).collect();
assert_eq!(actual.len(), widths.len());
for (got, want) in actual.iter().zip(widths) {
assert!((got - want).abs() < 0.01, "spanning banner changed columns: {actual:?}");
}
}
#[test]
fn automatic_banner_shares_width() {
let l = banner_layout(SpanWidth::Automatic);
let widths: Vec<_> =
l.pages[0].items.iter().filter_map(|it| if let Placed::Cell { rect, row: 1, .. } = it { Some(rect.w) } else { None }).collect();
assert_eq!(widths.len(), 4);
assert!(widths.iter().all(|w| (w - 75.0).abs() < 0.01), "automatic widths: {widths:?}");
}
#[test]
fn nested_spans_use_grid_weights() {
const NESTED_WIDTH: f32 = 40.0;
const IMAGE_WIDTH: f32 = 50.0;
const OUTER_WIDTH: f32 = 51.0;
let widths: Vec<_> = [0.0, 4.0]
.into_iter()
.map(|first| {
let mut nested = Table::new(2, 3, NESTED_WIDTH);
nested.props.style = None;
nested.props.width = Some(NESTED_WIDTH);
nested.props.cell_margins = Some([0.0; 4]);
nested.grid = vec![first, 100.0, 100.0];
for (index, row) in nested.rows.iter_mut().enumerate() {
row.cells.truncate(1);
row.props.grid_before = index as u32;
row.cells[0].props.span = 2;
row.cells[0].props.width = None;
let mut p = Paragraph::new();
p.insert_object(0, picture(IMAGE_WIDTH, 10.0, Default::default()), &Default::default()).unwrap();
row.cells[0].blocks = vec![wordcraft_doc::para_block(p)];
}
let mut outer = Table::new(1, 1, OUTER_WIDTH);
outer.props.style = None;
outer.props.width = Some(OUTER_WIDTH);
outer.props.cell_margins = Some([0.0; 4]);
outer.rows[0].cells[0].blocks = vec![Arc::new(Block::Table(nested))];
let mut d = Document::new();
d.body = vec![Arc::new(Block::Table(outer))];
lay(&d).pages[0]
.items
.iter()
.find_map(|item| match item {
Placed::Cell { rect, table, .. } if table.0 == [0] => Some(rect.w),
_ => None,
})
.unwrap()
})
.collect();
assert!(widths[1] > OUTER_WIDTH + 0.5, "overlapping spans must expand the outer table: {widths:?}");
assert!((widths[0] - widths[1]).abs() < 0.01, "equivalent nested grids changed outer fitting: {widths:?}");
}
/// #2048: a picture anchored in a later paragraph but placed higher up the page (fixed on the
/// page, or moved up from its paragraph) keeps the text of the paragraphs above its anchor clear
/// too, as in Word, not just its own paragraph's.
+5 -5
View File
@@ -1,14 +1,14 @@
use super::export::Fit;
use super::glyphs::{baseline, cluster_ends, glyph_outlines, left_to_right, split_clusters, visual_order};
use super::glyphs::{baseline, cluster_ends, glyph_id, glyph_outlines, left_to_right, split_clusters, visual_order};
use super::options::{PdfError, PdfOptions};
use super::paint::{append_path, fill, grown, norm, ok, paint_fill, pdf_line_cap, rect_path, shape_effects, shape_path};
use super::picture::{Vector, build_vector, load_image, paint_of, vector_picture};
use super::tags::{Role, TagEntry, figure_alt, graphic_alt, holds_picture};
use super::{
Annotation, Arc, Artifact, ArtifactType, ClKind, ContentTag, DisplayOptions, DocLayout, DocPath, Document, Draw, FaceRef, Fill, FillRule, Font,
GlyphId, HashMap, HashSet, Identifier, Image, InlineObject, ItemDisplay, KrillaGlyph, LineCap, LineEnd, LineJoin, LineStyle, LinkAnnotation,
Node, NonZeroU16, Outline, OutlineNode, Page, PathBuilder, Placed, Point, Rect, Rgb, ShapeKind, Size, SpanTag, StoryRef, Stroke, StrokeDash,
Surface, Tag, TagGroup, TagKind, TagTree, Target, Transform, WriteError, XyzDestination, krilla, rgb, write,
HashMap, HashSet, Identifier, Image, InlineObject, ItemDisplay, KrillaGlyph, LineCap, LineEnd, LineJoin, LineStyle, LinkAnnotation, Node,
NonZeroU16, Outline, OutlineNode, Page, PathBuilder, Placed, Point, Rect, Rgb, ShapeKind, Size, SpanTag, StoryRef, Stroke, StrokeDash, Surface,
Tag, TagGroup, TagKind, TagTree, Target, Transform, WriteError, XyzDestination, krilla, rgb, write,
};
mod draw;
@@ -346,7 +346,7 @@ impl Exporter<'_> {
let end = ends.get(i).copied().unwrap_or(text.len());
let start = g.cluster.min(end);
KrillaGlyph::new(
GlyphId::new(g.gid),
glyph_id(g.gid),
g.x_advance as f32 / upem,
g.x_offset as f32 / upem,
g.y_offset as f32 / upem,
+3 -3
View File
@@ -1,5 +1,5 @@
use super::{
Exporter, FaceRef, Fill, FillRule, GlyphId, KrillaGlyph, Point, Rgb, Stroke, Surface, Transform, baseline, cluster_ends, fill, glyph_outlines,
Exporter, FaceRef, Fill, FillRule, KrillaGlyph, Point, Rgb, Stroke, Surface, Transform, baseline, cluster_ends, fill, glyph_id, glyph_outlines,
left_to_right, norm, ok, paint_of, rgb, split_clusters, visual_order,
};
@@ -126,7 +126,7 @@ impl Exporter<'_> {
Some((_, nx, _, _)) => (nx - x) / size,
None => face.advance(*g) as f32 / upem,
};
KrillaGlyph::new(GlyphId::new(*g), adv, 0.0, (y0 - y) / size, 0.0, r.clone(), None)
KrillaGlyph::new(glyph_id(*g), adv, 0.0, (y0 - y) / size, 0.0, r.clone(), None)
})
.collect();
let pushed = italic;
@@ -216,7 +216,7 @@ impl Exporter<'_> {
None => g.x_advance as f32 / upem,
};
let dy = lay.glyphs.get(i).map_or(0.0, |p| ((lay.baseline - p.1) / lay.em) as f32);
KrillaGlyph::new(GlyphId::new(g.gid), adv, 0.0, dy, 0.0, start..end, None)
KrillaGlyph::new(glyph_id(g.gid), adv, 0.0, dy, 0.0, start..end, None)
})
.collect();
let x0 = lay.glyphs.first().map_or(0.0, |p| p.0) as f32;
+9 -1
View File
@@ -1,5 +1,13 @@
use super::paint::append_path;
use super::{FaceRef, HashMap, Path, PathBuilder};
use super::{FaceRef, GlyphId, HashMap, Path, PathBuilder};
/// A shaped glyph id as krilla takes it. Shaping gives 32-bit ids (a format-12 cmap can map to
/// any), but krilla's font subsetting takes 16-bit ones and panics on a larger id (#3674): one
/// that doesn't fit is written as .notdef (glyph 0), keeping its place. Its own character doesn't
/// extract, since krilla maps no text to .notdef.
pub(crate) fn glyph_id(g: u32) -> GlyphId {
GlyphId::new(u16::try_from(g).map_or(0, u32::from))
}
/// The outlines of `glyphs` (glyph id, pen position) at `size`, as one path in page coordinates.
pub(crate) fn glyph_outlines(face: &FaceRef, size: f32, glyphs: &[(u32, f32, f32)]) -> Option<Path> {
+26
View File
@@ -773,6 +773,32 @@ fn unsubsettable_font_is_drawn_as_outlines() {
assert!(paths > 0, "its glyphs are drawn as outlines");
}
/// #3674: shaping gives 32-bit glyph ids (a format-12 cmap can map a character to any of them),
/// but krilla takes 16-bit ones and panics on a larger id. That failed the export, and on wasm
/// (`panic = "abort"`) lost the tab. A glyph id above 65535 is now written as .notdef.
#[test]
fn glyph_id_above_u16_exports() {
let mut d = Document::new();
d.body = vec![para_block(Paragraph::with_text("Wide glyph", CharProps { font: Some("Source Sans 3".into()), ..Default::default() }))];
let opts = PdfOptions { tagged: false, ..Default::default() };
let mut lay = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
let (want, _) = count_glyphs_and_paths(&export_layout(&d, &lay, &opts).unwrap());
assert!(want > 0);
let mut wide = 0;
for item in lay.pages.iter_mut().flat_map(|p| p.items.iter_mut()) {
if let Placed::Lines { para, .. } = item
&& let Some(g) = Arc::make_mut(para).glyphs.first_mut()
{
g.gid = 70_000;
wide += 1;
}
}
assert_eq!(wide, 1, "one glyph id is out of range");
let bytes = export_layout(&d, &lay, &opts).expect("a glyph id above 65535 must not fail the export");
let (glyphs, _) = count_glyphs_and_paths(&bytes);
assert_eq!(glyphs, want, "every glyph is still drawn, the wide one as .notdef");
}
#[test]
fn flat_lines_in_a_group_are_drawn() {
// A horizontal divider inside a group has no height (`a:ext cy="0"`): it used to be left out
+1
View File
@@ -112,6 +112,7 @@ impl WordApp {
styled: false,
fonts_hans: false,
fonts_korean: false,
fonts_hant: false,
fonts_system_cjk: false,
want_system_cjk: false,
recover_fonts_for: None,
+20 -1
View File
@@ -316,6 +316,7 @@ impl WordApp {
};
}
let retry = (id == "file.open").then(|| params.clone());
let retry_path = retry.clone();
let r = self.execute(id, params);
if let (Err(e), Some(params)) = (&r, retry)
&& wordcraft_engine::io::needs_password(e)
@@ -325,6 +326,22 @@ impl WordApp {
self.dialog = Some(dialogs::Dialog::Password { name, params, password: Default::default(), message: String::new() });
return Ok(json!({"pending": "password"}));
}
// Whoever asked, a failed open names the file and the cause (#1103).
if let (Err(e), Some(params)) = (&r, retry_path) {
let path = params.get("path").and_then(Value::as_str).unwrap_or("");
self.report_open_failure(path, e);
}
r
}
/// [`WordApp::execute_user`] for a command a prompt or a finished save held back. A failed
/// open also leaves Backstage, whose list isn't where that failure gets shown (#1103); a
/// direct open from Recent keeps File open and reports there instead (#2938).
pub(crate) fn execute_held_back(&mut self, id: &str, params: Value) -> Result<Value, String> {
let r = self.execute_user(id, params);
if r.is_err() && id == "file.open" {
self.ui.backstage = false;
}
r
}
@@ -975,7 +992,8 @@ fn input_dialog_other(id: &str, params: &Value) -> Option<&'static str> {
/// Commands that work while no document is open (#1129): opening or creating one, closing the
/// window, Help, adding fonts (a font file dropped on the empty window), and the settings (File ›
/// Options, the view and interface preferences it sets, Customize Ribbon and Keyboard).
/// Options, the view and interface preferences it sets, AutoCorrect Options, Customize Ribbon and
/// Keyboard).
/// Everything else needs a document, `ui.*` included: only the interface commands listed here
/// (tabs, Backstage, dialogs, appearance, opening files) work without one.
pub(crate) fn works_without_document(id: &str) -> bool {
@@ -993,6 +1011,7 @@ pub(crate) fn works_without_document(id: &str) -> bool {
| "font.remove"
| "readAloud.speed"
| "readAloud.skipCitations"
| "tools.autocorrect"
| "tools.customizeKeyboard"
| "tools.customizeRibbon"
| "view.darkMode"
+12 -3
View File
@@ -51,7 +51,7 @@ impl WordApp {
if !go {
return Ok(json!({"done": false}));
}
self.execute_user(&then, params).map(|r| json!({"done": true, "result": r}))
self.execute_held_back(&then, params).map(|r| json!({"done": true, "result": r}))
}
/// Save before New/Open/Close (and the mailings that replace the document): true once the
@@ -144,7 +144,11 @@ impl WordApp {
match outcome {
// Only when nothing changed since and no later save of it is still unconfirmed.
async_saves::SaveOutcome::Saved if !self.session.dirty && self.session.rev() == wait.rev && !self.save_unconfirmed() => {
if let Err(e) = self.execute_user(&wait.then, wait.params) {
// A failed open was already reported by name in `execute_user`.
let is_open = wait.then == "file.open";
if let Err(e) = self.execute_held_back(&wait.then, wait.params)
&& !is_open
{
self.status(e);
}
}
@@ -328,7 +332,7 @@ impl WordApp {
let lp = path.to_ascii_lowercase();
let img = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".wmf", ".emf"].iter().any(|e| lp.ends_with(e));
let sheet = wordcraft_engine::cmd::mailings::is_spreadsheet_name(&lp);
let _ = if img {
let opened = if img {
self.caret_after_placed_picture(placed_picture);
let r = self.run("insert.picture", json!({"path": path}));
placed_picture = Self::placed_in_text(&r);
@@ -341,6 +345,11 @@ impl WordApp {
} else {
self.open_file(&path, None)
};
if let Err(e) = opened {
self.report_open_failure(&path, &e);
// Backstage hides the status bar; leave it so the error shows.
self.ui.backstage = false;
}
}
if self.session.document_id() != before {
self.autosave_path = None;

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