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
Brandon ThomasandClaude Opus 5.5 3de6ff95af Release 0.12.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:52:06 -04:00
Brandon ThomasandClaude Opus 5.5 65bb8b3709 macOS packaging: retry stapler when Apple's CloudKit drops the connection
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:52:00 -04:00
Dylan M. Taylor c4fafc2154 Enforce Rust coding standards via Clippy (#4501)
* Refactor doc for strict Clippy checks

* Refactor docx for strict Clippy checks

* Refactor engine for strict Clippy checks

* Refactor fonts for strict Clippy checks

* Refactor formats for strict Clippy checks

* Refactor layout for strict Clippy checks

* Refactor mcp for strict Clippy checks

* Refactor pdf for strict Clippy checks

* Refactor proof for strict Clippy checks

* Refactor render for strict Clippy checks

* Refactor ui canvas for strict Clippy checks

* Refactor ui dialogs for strict Clippy checks

* Refactor ui ribbon for strict Clippy checks

* Refactor ui support for strict Clippy checks

* Refactor wordcraft-cli for strict Clippy checks

* Enforce workspace Clippy standards and document lint policy

* Deflake the IME epoch test

* Fix platform-gated imports for wasm builds
2026-10-27 22:00:30 -04:00
Dylan M. Taylor df5a59275d Fix platform-gated imports for wasm builds 2026-10-28 01:24:26 +00:00
Dylan M. Taylor ab91e9f96c Deflake the IME epoch test 2026-10-28 01:06:49 +00:00
Dylan M. Taylor 1c8f6e9257 Enforce workspace Clippy standards and document lint policy 2026-10-28 01:06:48 +00:00
Dylan M. Taylor bc05149bd9 Refactor wordcraft-cli for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor 51c70e1606 Refactor ui support for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor ac2cc0c4ef Refactor ui ribbon for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor 9c0f61356c Refactor ui dialogs for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor 260a629e9e Refactor ui canvas for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor 1ecb22c67d Refactor render for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor 36f6244cd4 Refactor proof for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor ffaeda9316 Refactor pdf for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor 815d3f0032 Refactor mcp for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor 937c7e532d Refactor layout for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor 7976368de7 Refactor formats for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor 0436139c57 Refactor fonts for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor a2435388e5 Refactor engine for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor 75d30d69b4 Refactor docx for strict Clippy checks 2026-10-28 01:06:47 +00:00
Dylan M. Taylor 1d3e6d9ca8 Refactor doc for strict Clippy checks 2026-10-28 01:06:47 +00:00
aiongg d522bfd8b1 Merge pull request #4492 from Jeremy8776/feat/install-scope-policy
Windows installation policy: WordCraft
2026-10-27 17:08:58 -04:00
aiongg 0bd3420553 ci: use storytold/craft-common-actions at the merged installer-policy commit 2026-10-27 20:51:47 +00:00
Jeremy 54e1b1e7f3 fix(installer): check the exact architecture-specific machine folder
The pre-signing check matched any folder named Program Files, so it rejected
a valid MSI when Windows resolves Program Files elsewhere, accepted the 64-bit
root for an x86 package and accepted a different app name. Build the expected
folder from the package architecture's resolved root and the policy name.
2026-10-27 19:13:02 +00:00
aiongg 4b8b06fe03 Merge pull request #4602 from Silvertree2010/perf/export-speed
Speed up PDF export by about 24 %
2026-10-27 13:41:24 -04:00
NetterSilverandClaude Opus 5.5 de3bfaf049 Keep harfrust shape plans per face
Each shaping call let harfrust compile a new shape plan (the face's
lookups for the run's script, direction, language and features), which
took about as long as shaping a short run. Each thread now keeps up to 64
plans (the oldest dropped to make room), found by face and harfrust's
ShapePlanKey::matches, and passes the plan to the shaper. A kept plan is
only used for the face it was made for: the face id (from one process-wide
counter, so no two faces share it) together with the font bytes' address,
length and face index. The plan is the one the shaper would have made for
the buffer, so the glyphs are the same; tests shape with kept and fresh
plans and compare, and check the per-thread bound.

PDF export of the export_bench document (259 pages), interleaved with main
(A/A within 1 %): median 421 ms -> 319 ms (-24.2 %), measured on a loaded
machine; on a quieter run earlier 294 ms -> 224 ms (-23.8 %). PNG export of
page 1 (which lays out the whole document): about -34 %. The PDF and PNG
bytes are identical to main's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:39:37 +01:00
aiongg f8e327b068 Merge pull request #3936 from benandez/fix/3907-section-marks
#3907: continuous sections running off a page go onto the next; empty section marks in a row stack to the page bottom
2026-10-27 12:37:46 -04:00
aiongg 50a8ebe307 Merge pull request #4387 from benandez/fix/3872-followup
#3872 follow-up: font table found by its stored spelling; privacy scrub keeps attribute values well-formed
2026-10-27 12:37:26 -04:00
aiongg f270dbd3b0 Merge pull request #4060 from benandez/fix/3995-thai-test-guard
#3995: Thai one-face fallback tests skip unless a Thai font with Latin is installed
2026-10-27 12:37:05 -04:00
Jeremy ffa35d62db fix(installer): resolve the wizard scope and folder correctly
Real msiexec runs (per-user and all-users, silent and wizard) found two defects
that the metadata fixtures cannot see:

- "Only for me" published ALLUSERS=2, so FindRelatedProducts treated the run as
  per-machine and skipped the existing per-user copy. An interactive upgrade
  left two registered products and uninstall left files behind. The page now
  leaves ALLUSERS unset for a per-user choice.
- Windows Installer resolves Program Files once, for the context it started in,
  so choosing "Everyone" in the wizard installed into the user profile with a
  machine-wide registration. The wizard now sets the new-install folder itself
  after the choice (Program Files, Program Files (x86) for 32-bit, or the
  per-user Programs folder). Silent installs are unchanged.

check-install-policy.ps1 now fails if either regresses.
2026-10-27 15:32:42 +00:00
aiongg 436d444ef0 fix(ui): dropdown menus grow back after a search with no matches (#4577)
* ui: craft_ui::ComboBox everywhere and the combo arrow popup on craft_ui::combo::Menu

Repin craft-libs to aiongg/craft-libs 62bb0e1d (dropdown regrow). Ban egui::ComboBox in clippy.toml.

* chore(deps): craft-libs 60d60d48 (dropdown fixes)

* chore(deps): craft-libs 6af63481 (dropdown fixes)

* chore(deps): craft-libs 4d7b82f7 (dropdown fixes)

* chore(deps): craft-libs 92d392cf (#387 merged)
2026-10-27 10:27:21 -04:00
NetterSilverandClaude Opus 5.5 ffa127c615 Skip per-character table lookups for plain text in layout and PDF export
Shortcuts that give the same answers as the lookups they skip:

- is_rtl: nothing before U+0590 is right to left, so Latin, Greek and
  Cyrillic text skips the bidi class search.
- strong_rtl and is_mark: ASCII letters are the only strong ASCII
  characters, and no ASCII character is a mark.
- cluster_starts: in ASCII text every character starts a grapheme except LF
  after CR, and no ASCII character joins a cluster.
- display lines: a glyph run's vectors and text are sized up front, and the
  symbol-font check is made once per run instead of once per cluster.
- PDF glyph runs: the baseline (the y most glyphs share) is only worked
  out for italic runs, the only ones that use it, and a run on one baseline
  returns it without counting.

Tests check each shortcut against the full lookup (every code point for the
bidi classes, every ASCII pair for the clusters).

PDF export of the export_bench document, interleaved with main: median
293 ms -> 241 ms (-18 % with the previous commit). The PDF bytes are
identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:39:09 +01:00
NetterSilverandClaude Opus 5.5 8cdc172171 Lay out the paragraph after a kept heading once
A paragraph with keep with next measures the paragraph after it before it is
placed (next_first_line). That measurement called layout_para directly, so
every paragraph following a heading was laid out twice in each layout pass:
once to measure it and once, through the cache, when it was placed.

The measurement now goes through the layout cache under the key
para_labelled uses (Ctx::para_ahead) when its inputs are the same: no
proofing, no typed word, not inside a text box, no inline text boxes that
resize, the body's tracked-deletion view. Otherwise it is laid out on its
own as before. It counts nothing (list labels, equation numbers and the
page-total flag are restored), so pagination is unchanged.

Adds crates/engine/examples/export_bench.rs, which saves a long generated
document (the sample document 200 times, 259 pages) through io::save_bytes
and prints wall times:

  cargo run --release -p wordcraft-engine --example export_bench -- pdf 200 9

PDF export on that document, interleaved with main (Ryzen, 16 threads):
median 286 ms -> 256 ms (-10.6 %). The PDF bytes are identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:39:09 +01:00
Jeremy d603983a61 docs(installer): note that the 32-bit all-users folder is Program Files (x86); tidy wording 2026-10-27 12:25:21 +00:00
Jeremy 72b8818ea6 packaging(windows): ship one dual-scope MSI
The package is dual-scope, so the separate -windows-user MSI was the same package under a
second name. Build and policy-check the single plain-named MSI before signing, which is the
name every sibling app, the launcher and the shared windows-arm64 workflow use. Replace the
loop test with one that checks the MSI is policy-checked once, before it is signed.
2026-10-27 12:17:18 +00:00
Jeremy 3031870ce3 docs(installer): describe the per-user default install location and scope 2026-10-27 12:09:04 +00:00
Jeremy 1b0a59d4d2 fix(installer): harden install-policy checks and packaging-lint workflow 2026-10-27 12:02:00 +00:00
Jeremy 8edd6cb8eb fix(installer): address scope and ARM64 review feedback 2026-10-27 12:01:59 +00:00
Jeremy 89a059fb2d feat(installer): default to a per-user ArtCraft folder 2026-10-27 12:01:59 +00:00
Martin BallandClaude Opus 5.5 1b485e1f90 #3907: CodeRabbit — a kept paragraph is measured against the next page's body (a different first-page header)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:24:50 +00:00
Martin BallandClaude Opus 5.5 751eb4b204 #3907: CodeRabbit — a stacked mark past the page bottom draws no line number either
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:39:26 +00:00
Martin BallandClaude Opus 5.5 5225569d44 #3907: review — an empty mark taking no room, or stacked past the page bottom, draws no list label; an empty mark after a text paragraph closing a section stays quiet
dpkruse's Genuine Steps Certificate: a bulleted text paragraph closes a
two-column continuous section, then an empty bulleted one closes another.
Word draws no bullet for the empty one; it is now quiet (takes no room) and
draws no label, still counted. A stacked mark that doesn't fit keeps its
line for the caret but draws nothing below the margin (CodeRabbit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:04:05 +00:00
Martin BallandClaude Opus 5.5 f2fe7654e9 #3907: review — a kept paragraph low in a continuous section moves to the next page; a page break before a stacked mark keeps the new page's bottom
CodeRabbit (local): keep-lines compared its height with the room below the
mid-page section start, so a paragraph that fits on a new page split instead.
It now compares with the next column, or the next page's body when this is the
last column. A stacked mark whose page break before starts a page no longer
gets the previous page's bottom back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-27 09:56:44 +00:00
Martin BallandClaude Opus 5.5 7757828295 #3907: continuous sections running off a page go onto the next; empty marks in a row stack to the page bottom
A continuous section moves the column top down below the text above. at_top()
then held at every new section start, so a paragraph that did not fit was
placed anyway, as on an empty page. A run of one-line continuous sections ran
off the bottom of page 1 (100 of them reached 2,480 pt down a 792 pt page).
The overflow checks for paragraphs now use fresh(): at the top with nothing
above on this page. at_top() still drives the spacing rules, unchanged.

Empty paragraphs that close a continuous section right after another
section's mark took no room. Measured in Word on synthetic files ("Start", N
such marks, "End"): 10 put "End" about 9 cm down page 1; 50, 100 and 200 all
put it on the first line of page 2, the same with 24 pt marks or no paragraph
spacing. So each takes its line while it fits, then stays at the page's
bottom. A mark right after text stays quiet, as measured before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-27 09:56:35 +00:00
Martin BallandClaude Opus 5.5 04ebc4ed42 #3995: Thai one-face fallback tests skip unless a Thai font with Latin is installed
Noto Sans Thai and its siblings have no Latin letters or digits, so on a
machine whose only Thai fonts are Noto no face covers the test run and
fallback_for_run rightly returns None. The guard now asks whether the Thai
substitute covers the whole run, which doesn't go through fallback_for_run.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-27 09:52:32 +00:00
Martin BallandClaude Opus 5.5 cd683a9fd1 #3872: review — privacy rewrite keeps attribute values as written, escaping only quotes
Unescaping and escaping again turned &#10; and &#9; into raw characters that read
back as spaces. The raw value of well-formed markup holds no `<` and every `&` starts
a reference, so only a `"` from a single-quoted value is replaced with &quot;.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2
2026-10-27 09:51:52 +00:00
Martin BallandClaude Opus 5.5 932a2e8955 #3872: review — font table found by its stored spelling; privacy rewrite escapes attribute values
- A package storing WORD/FONTTABLE.XML (part names are case-insensitive) loaded no
  embedded fonts and a save dropped the table and every font: lookups used the exact
  constant spelling. They now find the key case-insensitively.
- scrub_private re-pushed attribute values raw (quick-xml push_attribute does not
  escape), so a value read from single quotes became malformed XML. The value is now
  unescaped and escaped again.
- rel_targets matches _rels/ case-insensitively.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2
2026-10-27 09:51:52 +00:00
421 changed files with 18324 additions and 9045 deletions
+24
View File
@@ -21,3 +21,27 @@ jobs:
lint:
# 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/packaging-lint.yml@main
windows-install-policy:
timeout-minutes: 10
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install the pinned packaging toolchain
shell: pwsh
run: |
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
dotnet tool install --tool-path $tools wix --version 5.0.2
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
$tools >> $env:GITHUB_PATH
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
foreach ($extension in @($policy.extensions)) {
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
}
- name: Build and check non-executable installer fixtures
shell: pwsh
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
+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
View File
@@ -31,3 +31,5 @@ jobs:
with:
app-name: wordcraft
app-dir: WordCraft
install-relative-path: Programs/ArtCraft/WordCraft
install-scope: perUser
+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.
+6 -1
View File
@@ -13,7 +13,7 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
- **Input-derived numbers are hostile** (files, commands, MCP/control params): `get()` instead of `[i]`, slice strings only at char boundaries, checked/saturating arithmetic, clamp sizes, cap allocations, bound recursion.
- **Last-resort guard:** `Session::run` turns an escaped panic into an error and restores the document. It's a safety net, not a licence.
- **Prove it:** every crash fix lands with a regression test. `crates/engine/src/tests.rs::hostile_params_never_panic` runs every command with junk params — keep it green.
- Production crate roots carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; new crates start with it. Tests may `unwrap` (`clippy.toml`).
- Production crate roots carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; new crates start with it and add `[lints]` with `workspace = true` to their `Cargo.toml`. Tests may `unwrap` (`clippy.toml`).
## Assets: absolutely no Microsoft (or Adobe/Avid/Autodesk) iconography or images
**This rule is non-negotiable.** Breaking it is the worst mistake you can make in this repo.
@@ -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
+34 -32
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",
]
@@ -1014,12 +1014,12 @@ dependencies = [
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
[[package]]
name = "craft-i18n"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"fixed_decimal",
"icu_decimal",
@@ -1042,7 +1042,7 @@ dependencies = [
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"krilla",
"thiserror 2.0.21",
@@ -1051,7 +1051,7 @@ dependencies = [
[[package]]
name = "craft-print"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"craft-winprint-gdi",
"miniz_oxide 0.9.1",
@@ -1064,7 +1064,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"craft-fonts-build",
"craft-i18n",
@@ -1084,13 +1084,14 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116"
dependencies = [
"chrono",
"dunce",
"egui",
"flate2",
"getrandom 0.3.4",
"image",
"log",
"plist",
"semver",
@@ -1109,7 +1110,7 @@ dependencies = [
[[package]]
name = "craft-window"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"egui",
"objc2 0.6.5",
@@ -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",
]
@@ -2854,7 +2855,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -6647,7 +6648,7 @@ dependencies = [
[[package]]
name = "wordcraft"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"arboard",
"chrono",
@@ -6682,7 +6683,7 @@ dependencies = [
[[package]]
name = "wordcraft-cli"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"serde_json",
"sys-locale",
@@ -6695,11 +6696,11 @@ dependencies = [
[[package]]
name = "wordcraft-control-key"
version = "0.11.0"
version = "0.12.0"
[[package]]
name = "wordcraft-doc"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"base64 0.23.1",
"log",
@@ -6713,7 +6714,7 @@ dependencies = [
[[package]]
name = "wordcraft-docbin"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"cfb",
"chrono",
@@ -6730,7 +6731,7 @@ dependencies = [
[[package]]
name = "wordcraft-docx"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"aes",
"base64 0.23.1",
@@ -6754,7 +6755,7 @@ dependencies = [
[[package]]
name = "wordcraft-engine"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"cfb",
"chrono",
@@ -6785,7 +6786,7 @@ dependencies = [
[[package]]
name = "wordcraft-fonts"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"brotli-decompressor",
"harfrust 0.13.3",
@@ -6799,7 +6800,7 @@ dependencies = [
[[package]]
name = "wordcraft-formats"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"cfb",
"chardetng",
@@ -6812,12 +6813,13 @@ dependencies = [
"quick-xml 0.41.0",
"wordcraft-doc",
"wordcraft-geom",
"wordcraft-metafile",
"zip 3.0.0",
]
[[package]]
name = "wordcraft-geom"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"image",
"serde",
@@ -6825,7 +6827,7 @@ dependencies = [
[[package]]
name = "wordcraft-layout"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"image",
"js-sys",
@@ -6845,7 +6847,7 @@ dependencies = [
[[package]]
name = "wordcraft-mcp"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"log",
"serde_json",
@@ -6857,7 +6859,7 @@ dependencies = [
[[package]]
name = "wordcraft-metafile"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"kurbo",
"thiserror 2.0.21",
@@ -6865,7 +6867,7 @@ dependencies = [
[[package]]
name = "wordcraft-pdf"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"chrono",
"craft-pdf",
@@ -6886,7 +6888,7 @@ dependencies = [
[[package]]
name = "wordcraft-proof"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"log",
"miniz_oxide 0.9.1",
@@ -6896,7 +6898,7 @@ dependencies = [
[[package]]
name = "wordcraft-render"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"image",
"log",
@@ -6910,7 +6912,7 @@ dependencies = [
[[package]]
name = "wordcraft-ui-egui"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"cfb",
"chrono",
@@ -6941,7 +6943,7 @@ dependencies = [
[[package]]
name = "wordcraft-web"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"eframe",
"egui",
@@ -6960,7 +6962,7 @@ dependencies = [
[[package]]
name = "wordcraft-zotero"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"log",
"proptest",
@@ -7123,7 +7125,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"serde_json",
]
+8 -5
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
[workspace.package]
version = "0.11.0"
version = "0.12.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -58,8 +58,9 @@ wuff = "0.2.9"
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 = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.1.0", default-features = false, features = ["write"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", features = ["egui"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["write"] }
# 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.
@@ -96,9 +97,11 @@ arboard = { version = "3.7", default-features = false, features = ["wayland-data
unsafe_code = "forbid"
[workspace.lints.clippy]
cognitive_complexity = "deny"
wildcard_imports = "deny"
dbg_macro = "warn"
todo = "warn"
too_many_arguments = "allow"
too_many_arguments = "deny"
type_complexity = "allow"
# Line tables keep file and line in panics and backtraces; full debug info
@@ -120,7 +123,7 @@ debug = 0
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+10 -9
View File
@@ -221,16 +221,17 @@ closer pagination fidelity, a stability sweep and native printing. Details and e
### Windows
| Build | Installer (all users) | Installer (just me) | Portable |
|---|---|---|---|
| x64 (64-bit Intel/AMD) | `wordcraft-<ver>-windows-x64.msi` | `wordcraft-<ver>-windows-user-x64.msi` | `wordcraft-<ver>-windows-x64-portable.zip` |
| arm64 (Snapdragon and other ARM PCs) | `wordcraft-<ver>-windows-arm64.msi` | `wordcraft-<ver>-windows-user-arm64.msi` | `wordcraft-<ver>-windows-arm64-portable.zip` |
| x86 (32-bit) | `wordcraft-<ver>-windows-x86.msi` | `wordcraft-<ver>-windows-user-x86.msi` | `wordcraft-<ver>-windows-x86-portable.zip` |
| Build | Installer | Portable |
|---|---|---|
| x64 (64-bit Intel/AMD) | `wordcraft-<ver>-windows-x64.msi` | `wordcraft-<ver>-windows-x64-portable.zip` |
| arm64 (Snapdragon and other ARM PCs) | `wordcraft-<ver>-windows-arm64.msi` | `wordcraft-<ver>-windows-arm64-portable.zip` |
| x86 (32-bit) | `wordcraft-<ver>-windows-x86.msi` | `wordcraft-<ver>-windows-x86-portable.zip` |
The all-users installer puts WordCraft in Program Files and needs administrator rights. The
just-me installer needs none: it installs into `%LOCALAPPDATA%\Programs\WordCraft` for your
account only. Choose one: installing both gives two WordCraft entries in Settings › Apps and two
in the Start menu. Installers and executables are code-signed.
The installer is one dual-scope package. By default it installs for your account only,
into `%LOCALAPPDATA%\Programs\ArtCraft\WordCraft`, and needs no administrator rights; choosing
"Everyone on this computer" on the first page installs into `C:\Program Files\ArtCraft\WordCraft` (`Program Files (x86)` for the 32-bit build)
and asks for an administrator. Install only one copy: a copy for another scope is refused, so
uninstall first to change who WordCraft is installed for. Installers and executables are code-signed.
**If the app doesn't open on Windows:** the desktop app initializes only DirectX 12 by default.
Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's
+58 -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)).
+23 -11
View File
@@ -391,6 +391,27 @@ fn valid_connect_addr(addr: &str) -> bool {
!host.is_empty() && port.parse::<u16>().is_ok()
}
fn load_run_script(rest: &[String], script_path: Option<&Path>) -> Result<Option<script::Script>, Failure> {
match arg_value(rest, "--script") {
Some(path) if !path.starts_with("--") => {
let source = script_path.unwrap_or(Path::new(&path));
Ok(Some(script::load(source)?))
}
_ if rest.iter().any(|arg| arg == "--script") => Err("run: --script requires a value".into()),
_ => Ok(None),
}
}
fn run_source_paths(rest: &[String], script: Option<&script::Script>) -> (Option<String>, Option<String>) {
// A source named on the command line wins over both of the script's.
let (mut from_file, mut from_template) = (arg_value(rest, "--file"), arg_value(rest, "--template"));
if from_file.is_none() && from_template.is_none() {
from_file = script.and_then(|loaded| loaded.file.clone());
from_template = script.and_then(|loaded| loaded.template.clone());
}
(from_file, from_template)
}
/// Run the subcommand named by `args` (the command line after the program name). Files named
/// there are opened by their real names, which may not be valid Unicode.
fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
@@ -474,17 +495,8 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
let page = parse_page_arg("run", &rest)?;
let scale = parse_scale_arg("run", &rest)?;
let password = password_from(&rest)?;
let script = match arg_value(&rest, "--script") {
Some(path) if !path.starts_with("--") => Some(script::load(script_path.as_deref().unwrap_or(Path::new(&path)))?),
_ if rest.iter().any(|a| a == "--script") => return Err("run: --script requires a value".into()),
_ => None,
};
// A source named on the command line wins over both of the script's.
let (mut from_file, mut from_template) = (arg_value(&rest, "--file"), arg_value(&rest, "--template"));
if from_file.is_none() && from_template.is_none() {
from_file = script.as_ref().and_then(|c| c.file.clone());
from_template = script.as_ref().and_then(|c| c.template.clone());
}
let script = load_run_script(&rest, script_path.as_deref())?;
let (from_file, from_template) = run_source_paths(&rest, script.as_ref());
let mut s = match (from_file, from_template) {
(Some(f), _) => open(&f, password.as_deref())?,
// Both go through `file.new`, which puts a blank document on the region's paper (#3152).
+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,
+1 -1
View File
@@ -73,7 +73,7 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi
clipboard-win = { version = "5.4", features = ["std"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" }
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
rfd = { workspace = true }
# Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94);
# rfd itself already depends on it.
+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;
+4
View File
@@ -2,3 +2,7 @@ allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true
allow-indexing-slicing-in-tests = true
disallowed-types = [
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
]
cognitive-complexity-threshold = 30
+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
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Arc, Deserialize, Paragraph, Serialize, Table};
/// A block in a story. (Blocks are always behind an `Arc`, so the size difference is fine.)
#[allow(clippy::large_enum_variant)]
+38 -5
View File
@@ -64,6 +64,11 @@ pub fn is_cluster_boundary(text: &str, off: usize) -> bool {
/// Byte offsets where clusters start (graphemes, joined per [`joins_cluster`]), in order; the
/// first is 0 for non-empty text.
pub fn cluster_starts(text: &str) -> Vec<usize> {
// ASCII: every character starts a grapheme except LF after CR, and none joins (UAX #29).
if text.is_ascii() {
let b = text.as_bytes();
return (0..b.len()).filter(|&i| !(i > 0 && b.get(i - 1) == Some(&b'\r') && b.get(i) == Some(&b'\n'))).collect();
}
let mut out: Vec<usize> = Vec::new();
let mut prev: Option<char> = None;
for (i, g) in text.grapheme_indices(true) {
@@ -110,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
@@ -188,6 +203,22 @@ mod tests {
assert_eq!(split(kinzi, &cluster_starts(kinzi)), vec!["အ", "င်္ဂ", "လိ", "ပ်"]);
}
/// The ASCII shortcut gives the grapheme rules' answer.
#[test]
fn ascii_cluster_starts_match_the_graphemes() {
let graphemes = |t: &str| -> Vec<usize> { t.grapheme_indices(true).map(|(i, _)| i).collect() };
let all: String = (0u8..128).map(char::from).collect();
for t in ["", "a", "Hello, world.", "\r\n", "a\r\nb\n\rc\r\r\n\n", "\t \u{7f}x\u{0}", all.as_str()] {
assert_eq!(cluster_starts(t), graphemes(t), "{t:?}");
}
for a in 0u8..128 {
for b in 0u8..128 {
let t = format!("{}{}", char::from(a), char::from(b));
assert_eq!(cluster_starts(&t), graphemes(&t), "{t:?}");
}
}
}
#[test]
fn khmer_subscripts_and_indic_conjuncts_are_one_cluster() {
// ខ្មែរ: ខ + coeng + ម + ែ, then រ.
@@ -229,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}'));
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
use super::block::{Block, Blocks};
use super::parts::Part;
use super::*;
use super::{Arc, BTreeMap, BuildingBlock, Cell, Paragraph, Row, Table};
pub fn para_block(p: Paragraph) -> Arc<Block> {
Arc::new(Block::Para(p))
+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() {
+6 -2
View File
@@ -5,7 +5,9 @@ use super::parts::{MAX_LINKED_BOXES, NoteSeparators, Part, PartKind, PermRange,
use super::position::{Path, Pos, StoryRef};
use super::settings::{CoreProps, CustomProp, Settings};
use super::text_walk::{TextMode, notes_and_boxes, story_word_count_of, text_of, walk};
use super::*;
use std::sync::Arc;
use super::{BTreeMap, CharProps, Document, InlineObject, Numbering, Paragraph, SectionProps, StyleSheet, Table, edit, numbering, para, section};
/// [`Document::media`] by content, so adding media is linear in the bytes added rather than a
/// scan of every earlier image (CWE-407). Rebuilt whenever `media` changed size behind its back;
@@ -793,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();
+62 -57
View File
@@ -1061,63 +1061,8 @@ pub fn apply_structure(root: &mut Arg, pos: &MathPos, level: usize, action: &str
*kind = ScriptKind::SubSup;
here(if action == "script.addSup" { 2 } else { 1 })
}
(MNode::Matrix { rows, .. }, a) if a.starts_with("matrix.") => {
let ncols = rows.iter().map(|r| r.len()).max().unwrap_or(1).max(1);
// Row and column of the caret's cell.
let mut k = 0;
let mut rc = (0, 0);
for (r, row) in rows.iter().enumerate() {
if c < k + row.len() {
rc = (r, c - k);
break;
}
k += row.len();
}
let (r, col) = rc;
let (mut tr, mut tc) = (r, col);
match a {
"matrix.rowAbove" | "matrix.rowBelow" if rows.len() < 1000 => {
let at = if a == "matrix.rowAbove" { r } else { r + 1 };
rows.insert(at.min(rows.len()), vec![Vec::new(); ncols]);
tr = at;
}
"matrix.colBefore" | "matrix.colAfter" if ncols < 64 => {
let at = if a == "matrix.colBefore" { col } else { col + 1 };
for row in rows.iter_mut() {
row.insert(at.min(row.len()), Vec::new());
}
tc = at;
}
"matrix.deleteRow" if rows.len() > 1 => {
rows.remove(r.min(rows.len() - 1));
tr = r.min(rows.len() - 1);
}
"matrix.deleteCol" if ncols > 1 => {
for row in rows.iter_mut() {
if col < row.len() {
row.remove(col);
}
}
tc = col.min(ncols - 2);
}
_ => {}
}
let flat: usize = rows.iter().take(tr).map(|x| x.len()).sum::<usize>() + tc;
here(flat)
}
(MNode::EqArr { rows }, a) if a.starts_with("eqarr.") => match a {
"eqarr.rowAbove" | "eqarr.rowBelow" if rows.len() < 1000 => {
let at = if a == "eqarr.rowAbove" { c } else { c + 1 }.min(rows.len());
rows.insert(at, Vec::new());
here(at)
}
"eqarr.deleteRow" if rows.len() > 1 => {
let k = c.min(rows.len() - 1);
rows.remove(k);
here(k.min(rows.len() - 1))
}
_ => inner,
},
(MNode::Matrix { rows, .. }, a) if a.starts_with("matrix.") => apply_matrix_action(rows, c, a, &here),
(MNode::EqArr { rows }, a) if a.starts_with("eqarr.") => apply_equation_array_action(rows, c, a, &here, inner),
(MNode::GroupChr { top, chr, .. }, "group.flip") => {
*top = !*top;
*chr = match *chr {
@@ -1141,6 +1086,66 @@ pub fn apply_structure(root: &mut Arg, pos: &MathPos, level: usize, action: &str
Some(clamp(root, &out))
}
fn apply_matrix_action(rows: &mut Vec<Vec<Arg>>, caret: usize, action: &str, here: &impl Fn(usize) -> MathPos) -> MathPos {
let columns = rows.iter().map(Vec::len).max().unwrap_or(1).max(1);
let (mut row_index, mut column) = (0, 0);
let mut offset = 0;
for (index, row) in rows.iter().enumerate() {
if caret < offset + row.len() {
row_index = index;
column = caret - offset;
break;
}
offset += row.len();
}
let (mut target_row, mut target_column) = (row_index, column);
match action {
"matrix.rowAbove" | "matrix.rowBelow" if rows.len() < 1000 => {
let at = if action == "matrix.rowAbove" { row_index } else { row_index + 1 };
rows.insert(at.min(rows.len()), vec![Vec::new(); columns]);
target_row = at;
}
"matrix.colBefore" | "matrix.colAfter" if columns < 64 => {
let at = if action == "matrix.colBefore" { column } else { column + 1 };
for row in rows.iter_mut() {
row.insert(at.min(row.len()), Vec::new());
}
target_column = at;
}
"matrix.deleteRow" if rows.len() > 1 => {
rows.remove(row_index.min(rows.len() - 1));
target_row = row_index.min(rows.len() - 1);
}
"matrix.deleteCol" if columns > 1 => {
for row in rows.iter_mut() {
if column < row.len() {
row.remove(column);
}
}
target_column = column.min(columns - 2);
}
_ => {}
}
let flat = rows.iter().take(target_row).map(Vec::len).sum::<usize>() + target_column;
here(flat)
}
fn apply_equation_array_action(rows: &mut Vec<Arg>, caret: usize, action: &str, here: &impl Fn(usize) -> MathPos, inner: MathPos) -> MathPos {
match action {
"eqarr.rowAbove" | "eqarr.rowBelow" if rows.len() < 1000 => {
let at = (if action == "eqarr.rowAbove" { caret } else { caret + 1 }).min(rows.len());
rows.insert(at, Vec::new());
here(at)
}
"eqarr.deleteRow" if rows.len() > 1 => {
let index = caret.min(rows.len() - 1);
rows.remove(index);
here(index.min(rows.len() - 1))
}
_ => inner,
}
}
/// Set the limit placement of n-ary operators (`None`: Word's default by operator).
pub fn set_limits(nodes: &mut Arg, loc: Option<LimLoc>) {
for n in nodes.iter_mut() {
+233 -214
View File
@@ -863,233 +863,252 @@ fn group_chr_tex(chr: char, top: bool) -> &'static str {
}
}
fn node(n: &MNode, s: &mut String, depth: usize) {
match n {
MNode::Run(r) => {
let (open, close) = if r.nor {
("\\text{", "}")
} else {
match (r.sty, r.scr) {
(_, MScr::DoubleStruck) => ("\\mathbb{", "}"),
(_, MScr::Script) => ("\\mathcal{", "}"),
(_, MScr::Fraktur) => ("\\mathfrak{", "}"),
(_, MScr::SansSerif) => ("\\mathsf{", "}"),
(_, MScr::Monospace) => ("\\mathtt{", "}"),
(Some(MSty::Bold), _) => ("\\mathbf{", "}"),
(Some(MSty::BoldItalic), _) => ("\\boldsymbol{", "}"),
(Some(MSty::Plain), _) if r.text.chars().any(|c| c.is_alphabetic()) => {
if FUNCTION_NAMES.contains(&r.text.as_str()) {
s.push('\\');
s.push_str(&r.text);
s.push(' ');
return;
}
("\\mathrm{", "}")
}
_ => ("", ""),
}
};
s.push_str(open);
if r.nor {
text_chars(&r.text, s);
} else {
chars(&r.text, s);
}
s.push_str(close);
}
MNode::Frac { kind, num, den } => match kind {
FracKind::NoBar => {
s.push_str("\\genfrac{}{}{0pt}{}");
braced(num, s, depth);
braced(den, s, depth);
}
FracKind::Linear | FracKind::Skewed => {
braced(num, s, depth);
s.push('/');
braced(den, s, depth);
}
FracKind::Bar => {
s.push_str("\\frac");
braced(num, s, depth);
braced(den, s, depth);
}
},
MNode::Script { kind, base, sub, sup } => {
if *kind == ScriptKind::Pre {
s.push_str("{}_");
braced(sub, s, depth);
s.push('^');
braced(sup, s, depth);
braced(base, s, depth);
return;
}
braced(base, s, depth);
if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) {
s.push('_');
braced(sub, s, depth);
}
if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) {
s.push('^');
braced(sup, s, depth);
}
}
MNode::Rad { deg, deg_hide, e } => {
s.push_str("\\sqrt");
if !deg_hide && !deg.is_empty() {
s.push('[');
arg(deg, s, depth);
s.push(']');
}
braced(e, s, depth);
}
MNode::Nary { chr, lim_loc, sub_hide, sup_hide, sub, sup, e, .. } => {
match name_of(*chr) {
Some(n) => {
s.push('\\');
s.push_str(n);
}
None => s.push(*chr),
}
match lim_loc {
Some(crate::math::LimLoc::UndOvr) => s.push_str("\\limits"),
Some(crate::math::LimLoc::SubSup) if !crate::math::is_integral(*chr) => s.push_str("\\nolimits"),
_ => {}
}
if !sub_hide && !sub.is_empty() {
s.push('_');
braced(sub, s, depth);
}
if !sup_hide && !sup.is_empty() {
s.push('^');
braced(sup, s, depth);
}
s.push(' ');
braced(e, s, depth);
}
MNode::Delim { beg, end, sep, elems, .. } => {
s.push_str("\\left");
s.push_str(&delim_tex(*beg));
let mid = match sep {
Some(c) => format!("\\middle{}", delim_tex(Some(*c))),
None => "\\middle|".to_string(),
};
for (i, e) in elems.iter().enumerate() {
if i > 0 {
s.push_str(&mid);
}
arg(e, s, depth);
}
s.push_str("\\right");
s.push_str(&delim_tex(*end));
}
fn node(node: &MNode, output: &mut String, depth: usize) {
match node {
MNode::Run(run) => run_node(run, output),
MNode::Frac { kind, num, den } => fraction_node(*kind, num, den, output, depth),
MNode::Script { kind, base, sub, sup } => script_node(*kind, base, sub, sup, output, depth),
MNode::Rad { deg, deg_hide, e } => radical_node(deg, *deg_hide, e, output, depth),
MNode::Nary { .. } => nary_node(node, output, depth),
MNode::Delim { beg, end, sep, elems, .. } => delimiter_node(*beg, *end, *sep, elems, output, depth),
MNode::Func { name, e } => {
arg(name, s, depth);
braced(e, s, depth);
}
MNode::Lim { upper, e, lim } => {
// `\lim_{…}` keeps its name; other bases stack.
let simple = matches!(e.as_slice(), [MNode::Run(r)] if r.sty == Some(MSty::Plain) && FUNCTION_NAMES.contains(&r.text.as_str()));
if simple && !upper {
arg(e, s, depth);
s.push('_');
braced(lim, s, depth);
} else if let [MNode::GroupChr { chr, top, e: inner }] = e.as_slice() {
s.push_str(group_chr_tex(*chr, *top));
braced(inner, s, depth);
s.push(if *upper { '^' } else { '_' });
braced(lim, s, depth);
} else {
s.push_str(if *upper { "\\overset" } else { "\\underset" });
braced(lim, s, depth);
braced(e, s, depth);
}
}
MNode::Acc { chr, e } => {
let cmd = match chr {
'\u{302}' => "hat",
'\u{303}' => "tilde",
'\u{307}' => "dot",
'\u{308}' => "ddot",
'\u{20DB}' => "dddot",
'\u{30C}' => "check",
'\u{306}' => "breve",
'\u{301}' => "acute",
'\u{300}' => "grave",
'\u{305}' | '\u{AF}' => "bar",
'\u{20D6}' => "overleftarrow",
'\u{20E1}' => "overleftrightarrow",
_ => "vec",
};
s.push('\\');
s.push_str(cmd);
braced(e, s, depth);
arg(name, output, depth);
braced(e, output, depth);
}
MNode::Lim { upper, e, lim } => limit_node(*upper, e, lim, output, depth),
MNode::Acc { chr, e } => accent_node(*chr, e, output, depth),
MNode::Bar { top, e } => {
s.push_str(if *top { "\\overline" } else { "\\underline" });
braced(e, s, depth);
output.push_str(if *top { "\\overline" } else { "\\underline" });
braced(e, output, depth);
}
MNode::BorderBox { e, .. } => {
s.push_str("\\boxed");
braced(e, s, depth);
output.push_str("\\boxed");
braced(e, output, depth);
}
MNode::Boxed { e } => braced(e, s, depth),
MNode::Boxed { e } => braced(e, output, depth),
MNode::GroupChr { chr, top, e } => {
s.push_str(group_chr_tex(*chr, *top));
braced(e, s, depth);
}
MNode::EqArr { rows } => {
if rows.len() == 1
&& let Some(r) = rows.first()
&& crate::math::has_number_mark(r)
{
arg(r, s, depth);
return;
}
s.push_str("\\begin{aligned}");
for (i, r) in rows.iter().enumerate() {
if i > 0 {
s.push_str("\\\\");
}
for n in r {
match n {
MNode::Run(run) if !run.lit && run.text.contains('&') => {
for (k, part) in run.text.split('&').enumerate() {
if k > 0 {
s.push('&');
}
let mut p = run.clone();
p.text = part.to_string();
node(&MNode::Run(p), s, depth + 1);
}
}
other => node(other, s, depth + 1),
}
}
}
s.push_str("\\end{aligned}");
}
MNode::Matrix { rows, .. } => {
s.push_str("\\begin{matrix}");
for (i, r) in rows.iter().enumerate() {
if i > 0 {
s.push_str("\\\\");
}
for (j, c) in r.iter().enumerate() {
if j > 0 {
s.push('&');
}
arg(c, s, depth);
}
}
s.push_str("\\end{matrix}");
output.push_str(group_chr_tex(*chr, *top));
braced(e, output, depth);
}
MNode::EqArr { rows } => equation_array_node(rows, output, depth),
MNode::Matrix { rows, .. } => matrix_node(rows, output, depth),
MNode::Phant { e, .. } => {
s.push_str("\\phantom");
braced(e, s, depth);
output.push_str("\\phantom");
braced(e, output, depth);
}
}
}
fn run_node(run: &MRun, output: &mut String) {
let (open, close) = if run.nor {
("\\text{", "}")
} else {
match (run.sty, run.scr) {
(_, MScr::DoubleStruck) => ("\\mathbb{", "}"),
(_, MScr::Script) => ("\\mathcal{", "}"),
(_, MScr::Fraktur) => ("\\mathfrak{", "}"),
(_, MScr::SansSerif) => ("\\mathsf{", "}"),
(_, MScr::Monospace) => ("\\mathtt{", "}"),
(Some(MSty::Bold), _) => ("\\mathbf{", "}"),
(Some(MSty::BoldItalic), _) => ("\\boldsymbol{", "}"),
(Some(MSty::Plain), _) if run.text.chars().any(char::is_alphabetic) => {
if FUNCTION_NAMES.contains(&run.text.as_str()) {
output.push('\\');
output.push_str(&run.text);
output.push(' ');
return;
}
("\\mathrm{", "}")
}
_ => ("", ""),
}
};
output.push_str(open);
if run.nor {
text_chars(&run.text, output)
} else {
chars(&run.text, output)
}
output.push_str(close);
}
fn fraction_node(kind: FracKind, numerator: &[MNode], denominator: &[MNode], output: &mut String, depth: usize) {
match kind {
FracKind::NoBar => {
output.push_str("\\genfrac{}{}{0pt}{}");
braced(numerator, output, depth);
braced(denominator, output, depth);
}
FracKind::Linear | FracKind::Skewed => {
braced(numerator, output, depth);
output.push('/');
braced(denominator, output, depth);
}
FracKind::Bar => {
output.push_str("\\frac");
braced(numerator, output, depth);
braced(denominator, output, depth);
}
}
}
fn script_node(kind: ScriptKind, base: &[MNode], sub: &[MNode], sup: &[MNode], output: &mut String, depth: usize) {
if kind == ScriptKind::Pre {
output.push_str("{}_");
braced(sub, output, depth);
output.push('^');
braced(sup, output, depth);
braced(base, output, depth);
return;
}
braced(base, output, depth);
if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) {
output.push('_');
braced(sub, output, depth);
}
if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) {
output.push('^');
braced(sup, output, depth);
}
}
fn radical_node(degree: &[MNode], degree_hidden: bool, expression: &[MNode], output: &mut String, depth: usize) {
output.push_str("\\sqrt");
if !degree_hidden && !degree.is_empty() {
output.push('[');
arg(degree, output, depth);
output.push(']');
}
braced(expression, output, depth);
}
fn nary_node(node: &MNode, output: &mut String, depth: usize) {
let MNode::Nary { chr, lim_loc, sub_hide, sup_hide, sub, sup, e, .. } = node else { return };
match name_of(*chr) {
Some(name) => {
output.push('\\');
output.push_str(name);
}
None => output.push(*chr),
}
match lim_loc {
Some(crate::math::LimLoc::UndOvr) => output.push_str("\\limits"),
Some(crate::math::LimLoc::SubSup) if !crate::math::is_integral(*chr) => output.push_str("\\nolimits"),
_ => {}
}
if !sub_hide && !sub.is_empty() {
output.push('_');
braced(sub, output, depth);
}
if !sup_hide && !sup.is_empty() {
output.push('^');
braced(sup, output, depth);
}
output.push(' ');
braced(e, output, depth);
}
fn delimiter_node(begin: Option<char>, end: Option<char>, separator: Option<char>, elements: &[Arg], output: &mut String, depth: usize) {
output.push_str("\\left");
output.push_str(&delim_tex(begin));
let middle = separator.map(|c| format!("\\middle{}", delim_tex(Some(c)))).unwrap_or_else(|| "\\middle|".to_string());
for (index, element) in elements.iter().enumerate() {
if index > 0 {
output.push_str(&middle);
}
arg(element, output, depth);
}
output.push_str("\\right");
output.push_str(&delim_tex(end));
}
fn limit_node(upper: bool, expression: &[MNode], limit: &[MNode], output: &mut String, depth: usize) {
let simple = matches!(expression, [MNode::Run(run)] if run.sty == Some(MSty::Plain) && FUNCTION_NAMES.contains(&run.text.as_str()));
if simple && !upper {
arg(expression, output, depth);
output.push('_');
braced(limit, output, depth);
} else if let [MNode::GroupChr { chr, top, e: inner }] = expression {
output.push_str(group_chr_tex(*chr, *top));
braced(inner, output, depth);
output.push(if upper { '^' } else { '_' });
braced(limit, output, depth);
} else {
output.push_str(if upper { "\\overset" } else { "\\underset" });
braced(limit, output, depth);
braced(expression, output, depth);
}
}
fn accent_node(character: char, expression: &[MNode], output: &mut String, depth: usize) {
let command = match character {
'\u{302}' => "hat",
'\u{303}' => "tilde",
'\u{307}' => "dot",
'\u{308}' => "ddot",
'\u{20DB}' => "dddot",
'\u{30C}' => "check",
'\u{306}' => "breve",
'\u{301}' => "acute",
'\u{300}' => "grave",
'\u{305}' | '\u{AF}' => "bar",
'\u{20D6}' => "overleftarrow",
'\u{20E1}' => "overleftrightarrow",
_ => "vec",
};
output.push('\\');
output.push_str(command);
braced(expression, output, depth);
}
fn equation_array_node(rows: &[Arg], output: &mut String, depth: usize) {
if rows.len() == 1
&& let Some(row) = rows.first()
&& crate::math::has_number_mark(row)
{
arg(row, output, depth);
return;
}
output.push_str("\\begin{aligned}");
for (index, row) in rows.iter().enumerate() {
if index > 0 {
output.push_str("\\\\");
}
for child_node in row {
match child_node {
MNode::Run(run) if !run.lit && run.text.contains('&') => {
for (index, part) in run.text.split('&').enumerate() {
if index > 0 {
output.push('&');
}
let mut piece = run.clone();
piece.text = part.to_string();
run_node(&piece, output);
}
}
other => node(other, output, depth + 1),
}
}
}
output.push_str("\\end{aligned}");
}
fn matrix_node(rows: &[Vec<Arg>], output: &mut String, depth: usize) {
output.push_str("\\begin{matrix}");
for (row_index, row) in rows.iter().enumerate() {
if row_index > 0 {
output.push_str("\\\\");
}
for (column, cell) in row.iter().enumerate() {
if column > 0 {
output.push('&');
}
arg(cell, output, depth);
}
}
output.push_str("\\end{matrix}");
}
#[cfg(test)]
mod tests {
use super::*;
+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()) {
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Deserialize, Serialize};
/// How a floating object is placed.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
+1 -1
View File
@@ -1,6 +1,6 @@
use super::float::{Float, Wrap, cap_alt};
use super::inline_kinds::{NoteKind, PTabAlign, PTabBase, RubyAlign, ShapeKind};
use super::*;
use super::{CharProps, Deserialize, Serialize};
/// An inline object anchored at a U+FFFC in the paragraph text.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{AtomicU64, CharProps, Deserialize, Ordering, Serialize};
/// Object replacement character: marks an inline object in paragraph text.
pub const OBJ: char = '\u{FFFC}';
+1 -1
View File
@@ -1,6 +1,6 @@
use super::block::Blocks;
use super::position::Pos;
use super::*;
use super::{Deserialize, Serialize, props};
/// A free id, `min` or more, for something new (a part, a list, a comment): one past `largest`, the
/// largest id in use, so ids keep growing; once that would pass `u32::MAX` (a file can use any id),
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Deserialize, Serialize};
/// Which story a position is in.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
+49 -7
View File
@@ -1,6 +1,6 @@
use super::color::{Rgb, TextColor};
use super::para_props::Border;
use super::*;
use super::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
@@ -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>,
@@ -447,24 +453,60 @@ impl CharProps {
/// The fields of `self` that differ from `base` (equal ones unset): what `self` adds when
/// overlaid on `base`.
pub fn minus(mut self, base: &CharProps) -> CharProps {
drop_same_fields!(self, base; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl,
cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout, link, ins, del, ligatures);
self.minus_text_style(base);
self.minus_run_visibility(base);
self.minus_script_style(base);
self.minus_document_marks(base);
self
}
fn minus_text_style(&mut self, base: &CharProps) {
drop_same_fields!(self, base; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl);
}
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);
}
fn minus_document_marks(&mut self, base: &CharProps) {
drop_same_fields!(self, base; link, ins, del, ligatures);
}
/// Apply every `Some` field of `patch` on top of `self`.
///
/// Plain and complex-script properties inherit independently. Commands that intend to
/// format both scripts must set both values explicitly.
pub fn overlay(&mut self, patch: &CharProps) {
overlay_fields!(self, patch; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl,
cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout, link, ins, del, fmt_change, ligatures);
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 {
self.text_effects.get_or_insert_with(Default::default).overlay(p);
}
}
fn overlay_text_style(&mut self, patch: &CharProps) {
overlay_fields!(self, patch; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl);
}
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);
}
fn overlay_document_marks(&mut self, patch: &CharProps) {
overlay_fields!(self, patch; link, ins, del, fmt_change, ligatures);
}
pub fn overlaid(mut self, patch: &CharProps) -> CharProps {
self.overlay(patch);
self
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Deserialize, Serialize};
/// An sRGB colour.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Default)]
+18 -5
View File
@@ -1,6 +1,6 @@
use super::char_props::{NumChange, PropChange};
use super::color::Rgb;
use super::*;
use super::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
@@ -367,10 +367,23 @@ pub struct ParaProps {
impl ParaProps {
pub fn overlay(&mut self, patch: &ParaProps) {
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars, space_before, space_after, space_before_auto, space_after_auto, line_spacing,
contextual_spacing, mirror_indents, keep_next, keep_lines, page_break_before, widow_control, outline_level, numbering, tabs, shading, borders,
suppress_hyphens, suppress_line_numbers, bidi, drop_cap, drop_cap_margin, drop_cap_distance, drop_cap_font, frame, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn,
snap_to_grid);
self.overlay_layout(patch);
self.overlay_pagination(patch);
self.overlay_script_layout(patch);
}
fn overlay_layout(&mut self, patch: &ParaProps) {
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars,
space_before, space_after, space_before_auto, space_after_auto, line_spacing, contextual_spacing, mirror_indents, tabs, shading, borders);
}
fn overlay_pagination(&mut self, patch: &ParaProps) {
overlay_fields!(self, patch; keep_next, keep_lines, page_break_before, widow_control, outline_level, numbering, suppress_hyphens, suppress_line_numbers,
drop_cap, drop_cap_margin, drop_cap_distance, drop_cap_font, frame, snap_to_grid);
}
fn overlay_script_layout(&mut self, patch: &ParaProps) {
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn);
}
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
self.overlay(patch);
+1 -1
View File
@@ -1,7 +1,7 @@
use super::char_props::PropChange;
use super::color::Rgb;
use super::para_props::{Align, Borders};
use super::*;
use super::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
+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),
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Blocks, Deserialize, Document, Serialize};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
+8 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Deserialize, Rgb, Serialize, math, section, styles};
/// A bibliography source.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
@@ -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");
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
use super::block::{Block, Blocks};
use super::parts::PartKind;
use super::position::Path;
use super::*;
use super::{InlineObject, Numbering, numbering, para, section, styles};
/// Words in `s` (tokens separated by whitespace that contain at least one alphanumeric char).
pub fn count_words(s: &str) -> usize {
+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 {
+9 -4
View File
@@ -8,7 +8,7 @@ use std::collections::HashSet;
use wordcraft_doc::Document;
use crate::package::{EMBEDDED_PARTS, EmbeddedManifest, FONT_TABLE_PART};
use crate::package::{EMBEDDED_PARTS, EmbeddedManifest};
/// Most font files taken from one document.
const MAX_FONTS: usize = 64;
@@ -41,11 +41,15 @@ pub fn embedded_fonts(doc: &Document) -> Vec<EmbeddedFont> {
/// [`embedded_fonts`], copying at most `budget` bytes of font files.
fn fonts_within(doc: &Document, mut budget: u64) -> Vec<EmbeddedFont> {
let budget_total = budget;
let Some(table) = doc.passthrough.get(FONT_TABLE_PART).and_then(|b| crate::xml::parse(b).ok()) else {
let manifest = doc.passthrough.get(EMBEDDED_PARTS).map(|m| EmbeddedManifest::parse(m)).unwrap_or_default();
// The table is kept under the spelling the package stores it as (#3872).
let Some(key) = crate::package::font_table_key(&doc.passthrough) else {
return Vec::new();
};
let manifest = doc.passthrough.get(EMBEDDED_PARTS).map(|m| EmbeddedManifest::parse(m)).unwrap_or_default();
let Some(part) = manifest.parts.get(FONT_TABLE_PART) else {
let Some(table) = doc.passthrough.get(key).and_then(|b| crate::xml::parse(b).ok()) else {
return Vec::new();
};
let Some(part) = manifest.parts.get(key) else {
return Vec::new();
};
let mut out = Vec::new();
@@ -118,6 +122,7 @@ mod tests {
use std::sync::Arc;
use super::*;
use crate::package::FONT_TABLE_PART;
/// A document whose font table makes `refs` references (`(font name, part)`) to font parts
/// `parts` (`(name, size)`).
+11 -1
View File
@@ -96,6 +96,15 @@ pub const MAX_VBA_RELATED: usize = 64;
pub const EMBEDDED_PARTS: &str = "wordcraft:embedded.parts";
/// The font table kept from a file (read by `Reader::keep_part_tree`), with the fonts it embeds.
pub const FONT_TABLE_PART: &str = "word/fontTable.xml";
/// The spelling the font table is stored under in `parts` (part names are case-insensitive, so a
/// package may spell it `WORD/FONTTABLE.XML`), preferring the exact [`FONT_TABLE_PART`].
pub fn font_table_key<V>(parts: &BTreeMap<String, V>) -> Option<&str> {
if let Some((k, _)) = parts.get_key_value(FONT_TABLE_PART) {
return Some(k.as_str());
}
parts.keys().find(|k| k.eq_ignore_ascii_case(FONT_TABLE_PART)).map(String::as_str)
}
/// Custom XML roots kept by the package (P) or main document (D): `owner\ttype\tpart`.
/// Their opaque bytes and related parts use the same bounded manifest as embedded objects.
pub const CUSTOM_XML_ROOTS: &str = "wordcraft:customXml.roots";
@@ -412,7 +421,8 @@ fn rel_targets(files: &BTreeMap<String, Arc<Vec<u8>>>) -> HashSet<String> {
let mut named = HashSet::new();
for (name, bytes) in files {
// `dir/_rels/file.rels` relates `dir/file`, so its targets resolve against `dir`.
let Some(at) = name.rfind("_rels/").filter(|_| name.to_ascii_lowercase().ends_with(".rels")) else { continue };
let lower = name.to_ascii_lowercase();
let Some(at) = lower.rfind("_rels/").filter(|_| lower.ends_with(".rels")) else { continue };
let dir = name.get(..at).unwrap_or("").trim_end_matches('/');
let Ok(root) = xml::parse(bytes) else { continue };
for r in root.els().filter(|e| e.local() == "Relationship") {
+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.
+1 -1
View File
@@ -1,5 +1,5 @@
use super::model::Pos;
use super::*;
use super::{CHAR_W, GAP, GraphicItem, LEGEND_SHARE, LINE, MAX_LEGEND_ROWS, PAD, PathSeg, Rgb, ShapeKind, TEXT, TextAlign};
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct Rect {
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::Rgb;
/// A data label to draw once the chart is: the value at (`x`, `y`), centred there (`inside`, in
/// white on the fill) or just beside it (above, or right of a horizontal bar's end).
+1 -1
View File
@@ -1,7 +1,7 @@
use super::format::{date_text, excel_stamp, format_value, is_date_code};
use super::model::{Axis, Grouping, Kind, Pos, Series};
use super::plot::label;
use super::*;
use super::{Chart, El, FONT, LINE_W, MAX_CELLS, MAX_POINTS, MAX_RADAR_SERIES, MAX_SERIES, MAX_SPOKES, Rgb, fill_colour, int, measure, slot};
impl Chart {
pub(super) fn parse(space: &El, theme: &[Rgb]) -> Option<Chart> {
+1 -1
View File
@@ -3,7 +3,7 @@ use super::format::format_value;
use super::model::{DataLabel, Grouping, Kind, Series};
use super::parse::category_text;
use super::polar::{draw_data_labels, label_of};
use super::*;
use super::{AXIS, Chart, El, GAP, GRID, GraphicItem, LINE, MAX_LABEL, MAX_TICKS, PathSeg, Rgb, ShapeKind, TextAlign, fill_colour, measure};
pub(super) fn label(s: &str) -> String {
s.chars().take(MAX_LABEL).collect()
+1 -1
View File
@@ -2,7 +2,7 @@ use super::canvas::{Rect, clip_text, fill, stroke, text, text_w, widest};
use super::format::format_value;
use super::model::{DataLabel, Kind, Series};
use super::plot::{Axes, has_values};
use super::*;
use super::{Chart, FRAC_PI_2, GAP, GRID, GraphicItem, LINE, PathSeg, Rgb, ShapeKind, TAU, TEXT, TextAlign};
pub(super) fn pie(out: &mut Vec<GraphicItem>, c: &Chart, area: Rect) {
let Some(s) = c.series.first() else { return };
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Arc, BTreeMap, Blocks, Comment, DocxError, El, HashMap, HashSet, MAX_PARTS, MAX_SOURCES, MAX_TEMPLATE_PATH, NoteKind, NumFormat, Paragraph,
PartKind, Reader, Rels, Seen, StoryCtx, Zoom, ZoomFit, int, math, on_off, para_block, props, tw, u32_of,
};
impl Reader<'_> {
pub(super) fn read_settings(&mut self, path: &str) -> Result<(), DocxError> {
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Arc, Blocks, ContentTypes, DocxError, El, MAX_FONT_NAME, MAX_HF_DEPTH, MAX_PARTS, MAX_TABLE_FONTS, MAX_VBA_RELATED, PartKind, Reader, Rels,
Revision, RevisionKind, Rgb, StoryCtx, VBA_PROJECT_PART, VBA_RELATED, props, shape_style,
};
impl Reader<'_> {
/// Keep the parts the VBA project at `project` relates to, with their relationship types and
+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")) {
+5 -1
View File
@@ -1,4 +1,8 @@
use super::*;
use super::{
Arc, Block, Blocks, Cell, CharProps, El, InlineObject, MAX_COLS, MAX_ROWS, MAX_TABLE_DEPTH, Node, NumChange, PB, Paragraph, Phase, PropChange,
Reader, Rels, RevisionKind, Row, Run, RunCtx, StoryCtx, Table, children_of_choice, clean_text, collect, last_para_mut, para_block,
placeholder_of, sectpr, tcpr, trpr,
};
impl Reader<'_> {
/// Read a story's blocks from a container element (`w:body`, `w:hdr`, `w:tc`, `w:txbxContent`…).
+5 -1
View File
@@ -1,4 +1,8 @@
use super::*;
use super::{
AltText, Anchor, Arc, Blocks, CHARTEX_URI, ConnEnd, ContentTypes, El, Embedded, Float, FloatAlign, Glow, Graphic, GraphicItem, GraphicKind,
INK_REL, INK_URI, InlineObject, MAX_STORY_DEPTH, Node, PartKind, Reader, Rels, Rgb, Shadow, ShapeEffects, ShapeExtra, ShapeKind, StoryCtx,
TextWarp, Wrap, WrapText, cap_alt, children_of_choice, int, measure, on_off_attr, own_fill, preset_kind, rt, shape_paint, u32_of,
};
impl Reader<'_> {
// ---- drawings ----
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{CharProps, FieldState, InlineObject, Item, MAX_FIELD_DEPTH, PB, Phase, Reader, StoryCtx};
/// Enclosed characters (`eq \o\ac(○,字)`), with their style from the code's font sizes: Word
/// enlarges the symbol (bigger than the text, which keeps the field's size) or shrinks the text
+8 -7
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
CharProps, El, InlineObject, Item, MAX_FORM, MAX_INSTR, NoteKind, PB, PTabAlign, PTabBase, Phase, Reader, Rels, RevisionKind, RunCtx, StoryCtx,
TextColor, children_of_choice, clean_text, int, measure, on_off_attr, placeholder_of, sink,
};
impl Reader<'_> {
pub(super) fn read_inline_children(&mut self, sc: &mut StoryCtx, pb: &mut PB, e: &El, rels: &Rels, ctx: &RunCtx, depth: usize) {
@@ -105,7 +108,7 @@ impl Reader<'_> {
// Some generators write a break directly under the paragraph instead of inside a run.
"w:br" | "w:cr" => {
let props = self.run_props_none(ctx);
self.read_run_child(sc, pb, k, &props, rels, &mut None, depth + 1);
self.read_run_child(sc, pb, k, (&props, rels), &mut None, depth + 1);
}
_ => {}
}
@@ -131,21 +134,19 @@ impl Reader<'_> {
};
let mut note: Option<(NoteKind, u32, Option<String>)> = None;
for k in r.els() {
self.read_run_child(sc, pb, k, &props, rels, &mut note, depth + 1);
self.read_run_child(sc, pb, k, (&props, rels), &mut note, depth + 1);
}
if let Some((kind, id, custom)) = note.take() {
self.emit_obj(sc, pb, InlineObject::NoteRef { kind, id, custom: custom.unwrap_or_default() }, &props);
}
}
#[allow(clippy::too_many_arguments)]
fn read_run_child(
&mut self,
sc: &mut StoryCtx,
pb: &mut PB,
k: &El,
props: &CharProps,
rels: &Rels,
(props, rels): (&CharProps, &Rels),
note: &mut Option<(NoteKind, u32, Option<String>)>,
depth: usize,
) {
@@ -301,7 +302,7 @@ impl Reader<'_> {
self.emit_obj(sc, pb, o, props);
} else if let Some(c) = children_of_choice(k) {
for x in c.els() {
self.read_run_child(sc, pb, x, props, rels, note, depth + 1);
self.read_run_child(sc, pb, x, (props, rels), note, depth + 1);
}
}
}
+17 -7
View File
@@ -1,4 +1,14 @@
use super::*;
use super::{
Anchor, Arc, CharProps, El, Float, FloatAlign, InlineObject, MAX_STORY_DEPTH, MAX_VML_GROUP_DEPTH, Paragraph, PartKind, Reader, Rels, Rgb,
ShapeExtra, ShapeKind, StoryCtx, TextColor, TextWarp, Wrap, int, measure, para_block, vml_color, vml_fill, vml_fraction, vml_preset_kind,
};
#[derive(Clone, Copy)]
struct VmlGroupSpace {
origin: (f32, f32),
scale: (f32, f32),
depth: usize,
}
impl Reader<'_> {
/// Every legacy VML drawing in one `w:pict`, in document order: a `w:pict` may hold any
@@ -43,7 +53,7 @@ impl Reader<'_> {
let (ch_w, ch_h) = vml_coordpair(grp, "coordsize").filter(|(cw, ch)| *cw > 0.0 && *ch > 0.0).unwrap_or((w.max(1.0), h.max(1.0)));
let (cx, cy) = vml_coordpair(grp, "coordorigin").unwrap_or((0.0, 0.0));
let mut children = Vec::new();
self.vml_group_members(sc, pict, grp, rels, (-cx, -cy, 1.0, 1.0), 0, &mut children);
self.vml_group_members(sc, pict, grp, rels, VmlGroupSpace { origin: (-cx, -cy), scale: (1.0, 1.0), depth: 0 }, &mut children);
if children.is_empty() {
return None;
}
@@ -59,11 +69,11 @@ impl Reader<'_> {
pict: &El,
g: &El,
rels: &Rels,
t: (f32, f32, f32, f32),
depth: usize,
space: VmlGroupSpace,
out: &mut Vec<wordcraft_doc::para::GroupChild>,
) {
let (x0, y0, sx, sy) = t;
let (x0, y0) = space.origin;
let (sx, sy) = space.scale;
let max = crate::units::MAX_LEN_PT;
let fit = |v: f32, lo: f32| wordcraft_geom::finite(v).clamp(lo, max);
for e in g.els() {
@@ -77,7 +87,7 @@ impl Reader<'_> {
let num = |k: &str| vml_style(style, k).and_then(|v| measure(v, 1.0)).map_or(0.0, wordcraft_geom::finite);
let ((ox, oy), (ew, eh)) = ((num("left"), num("top")), (num("width"), num("height")));
if e.local() == "group" {
if depth >= MAX_VML_GROUP_DEPTH {
if space.depth >= MAX_VML_GROUP_DEPTH {
continue;
}
// Its members' space maps onto its own box.
@@ -85,7 +95,7 @@ impl Reader<'_> {
let (cox, coy) = vml_coordpair(e, "coordorigin").unwrap_or((0.0, 0.0));
let (kx, ky) = (ew / cw, eh / ch);
let nt = (x0 + (ox - cox * kx) * sx, y0 + (oy - coy * ky) * sy, sx * kx, sy * ky);
self.vml_group_members(sc, pict, e, rels, nt, depth + 1, out);
self.vml_group_members(sc, pict, e, rels, VmlGroupSpace { origin: (nt.0, nt.1), scale: (nt.2, nt.3), depth: space.depth + 1 }, out);
continue;
}
let Some(mut obj) = self.read_vml_shape(sc, pict, e, rels, Some((sx, sy))) else { continue };
+22 -2
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
AbstractNum, DocxError, El, Level, LevelSuffix, Num, NumFormat, PartFormat, Reader, Style, StyleKind, StyleSheet, TablePart, TableStyleParts,
VAlign, flag, int, props, tw, u32_of,
};
/// Built-in style names Word stores in lower case, and how the UI shows them.
pub(crate) const LOWER_NAMES: &[(&str, &str)] = &[
@@ -39,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).
@@ -112,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")
@@ -128,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);
}
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Arc, Block, Blocks, ControlEvent, ControlKind, Document, HashSet, InlineObject, OBJ, StyleKind, control_events, pair_events};
/// Word draws a table of contents' entry links without the Hyperlink character style (no blue,
/// no underline) though the entries carry it (measured in Word for Windows: a Hyperlink-styled run
+65 -35
View File
@@ -805,16 +805,17 @@ pub fn data_xml(spec: &SmartArtSpec, drawing_rel: &str) -> String {
/// A layout node: `alg` with its parameters, a shape (`None`: no geometry), what text it shows
/// (`presOf` attributes), then `body`.
fn layout_node(
w: &mut W,
name: &str,
style: Option<&str>,
alg: &str,
params: &[(&str, &str)],
shape: Option<&str>,
pres_of: &[(&str, &str)],
body: impl FnOnce(&mut W),
) {
struct LayoutNode<'a> {
name: &'a str,
style: Option<&'a str>,
alg: &'a str,
params: &'a [(&'a str, &'a str)],
shape: Option<&'a str>,
pres_of: &'a [(&'a str, &'a str)],
}
fn layout_node(w: &mut W, node: LayoutNode<'_>, body: impl FnOnce(&mut W)) {
let LayoutNode { name, style, alg, params, shape, pres_of } = node;
let mut attrs = vec![("name", name)];
if let Some(s) = style {
attrs.push(("styleLbl", s));
@@ -870,45 +871,74 @@ pub fn layout_xml(l: SmartArtLayout) -> String {
SmartArtLayout::Cycle | SmartArtLayout::Radial | SmartArtLayout::Venn => "ellipse",
SmartArtLayout::Pyramid => "trapezoid",
};
layout_node(&mut w, "diagram", None, alg, params, None, &[], |w| match l {
layout_node(&mut w, LayoutNode { name: "diagram", style: None, alg, params, shape: None, pres_of: &[] }, |w| match l {
SmartArtLayout::Hierarchy => {
w.open("dgm:forEach", &[("name", "branches"), ("axis", "ch"), ("ptType", "node")]);
layout_node(w, "branch", None, "hierRoot", &[], None, &[], |w| {
layout_node(w, "item", Some("node1"), "tx", &[], Some(shape), &[("axis", "self")], |_| {});
layout_node(w, "below", None, "hierChild", &[("chAlign", "t")], None, &[], |w| {
w.open("dgm:forEach", &[("name", "joins"), ("axis", "ch"), ("ptType", "parTrans")]);
layout_node(
w,
"join",
Some("connector"),
"conn",
&[("dim", "1D"), ("connRout", "bend"), ("begPts", "bCtr"), ("endPts", "tCtr")],
Some("conn"),
&[("axis", "self")],
|_| {},
);
w.close("dgm:forEach");
w.empty("dgm:forEach", &[("name", "deeper"), ("ref", "branches")]);
});
layout_node(w, LayoutNode { name: "branch", style: None, alg: "hierRoot", params: &[], shape: None, pres_of: &[] }, |w| {
layout_node(
w,
LayoutNode { name: "item", style: Some("node1"), alg: "tx", params: &[], shape: Some(shape), pres_of: &[("axis", "self")] },
|_| {},
);
layout_node(
w,
LayoutNode { name: "below", style: None, alg: "hierChild", params: &[("chAlign", "t")], shape: None, pres_of: &[] },
|w| {
w.open("dgm:forEach", &[("name", "joins"), ("axis", "ch"), ("ptType", "parTrans")]);
layout_node(
w,
LayoutNode {
name: "join",
style: Some("connector"),
alg: "conn",
params: &[("dim", "1D"), ("connRout", "bend"), ("begPts", "bCtr"), ("endPts", "tCtr")],
shape: Some("conn"),
pres_of: &[("axis", "self")],
},
|_| {},
);
w.close("dgm:forEach");
w.empty("dgm:forEach", &[("name", "deeper"), ("ref", "branches")]);
},
);
});
w.close("dgm:forEach");
}
SmartArtLayout::Radial => {
w.open("dgm:forEach", &[("name", "middle"), ("axis", "ch"), ("ptType", "node"), ("cnt", "1")]);
layout_node(w, "centre", Some("node1"), "tx", &[], Some(shape), &[("axis", "self")], |w| {
w.open("dgm:forEach", &[("name", "around"), ("axis", "ch"), ("ptType", "node")]);
layout_node(w, "spoke", Some("node1"), "tx", &[], Some(shape), ITEM_TEXT, |_| {});
w.close("dgm:forEach");
});
layout_node(
w,
LayoutNode { name: "centre", style: Some("node1"), alg: "tx", params: &[], shape: Some(shape), pres_of: &[("axis", "self")] },
|w| {
w.open("dgm:forEach", &[("name", "around"), ("axis", "ch"), ("ptType", "node")]);
layout_node(
w,
LayoutNode { name: "spoke", style: Some("node1"), alg: "tx", params: &[], shape: Some(shape), pres_of: ITEM_TEXT },
|_| {},
);
w.close("dgm:forEach");
},
);
w.close("dgm:forEach");
}
_ => {
w.open("dgm:forEach", &[("name", "items"), ("axis", "ch"), ("ptType", "node")]);
let style = if l == SmartArtLayout::Venn { "venn" } else { "node1" };
layout_node(w, "item", Some(style), "tx", &[], Some(shape), ITEM_TEXT, |_| {});
layout_node(w, LayoutNode { name: "item", style: Some(style), alg: "tx", params: &[], shape: Some(shape), pres_of: ITEM_TEXT }, |_| {});
if matches!(l, SmartArtLayout::Process | SmartArtLayout::Cycle) {
w.open("dgm:forEach", &[("name", "arrows"), ("axis", "followSib"), ("ptType", "sibTrans"), ("cnt", "1")]);
layout_node(w, "arrow", Some("connector"), "conn", &[("dim", "2D")], Some("rightArrow"), &[("axis", "self")], |_| {});
layout_node(
w,
LayoutNode {
name: "arrow",
style: Some("connector"),
alg: "conn",
params: &[("dim", "2D")],
shape: Some("rightArrow"),
pres_of: &[("axis", "self")],
},
|_| {},
);
w.close("dgm:forEach");
}
w.close("dgm:forEach");
+10 -4
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Document, W};
fn to_source_type(kind: &str) -> &str {
match kind.to_ascii_lowercase().as_str() {
@@ -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");
+22 -4
View File
@@ -14,8 +14,7 @@ use wordcraft_doc::para::Float;
use super::PartRels;
use crate::package::{
CUSTOM_XML_ROOTS, EMBEDDED_PARTS, EmbeddedManifest, FONT_TABLE_PART, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, VBA_PROJECT_PART, VBA_RELATED,
rel_is, relative, rt,
CUSTOM_XML_ROOTS, EMBEDDED_PARTS, EmbeddedManifest, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, VBA_PROJECT_PART, VBA_RELATED, rel_is, relative, rt,
};
use crate::units::{MAX_LEN_PT, emu, int};
use crate::xml::{self, El, MAX_DEPTH};
@@ -105,7 +104,8 @@ impl<'d> EmbedWriter<'d> {
/// Write the font table kept from a file, with the fonts it embeds, relating it from `rels`.
pub fn font_table(&mut self, rels: &mut PartRels) {
let mut written = Vec::new();
if let Some(name) = self.copy_tree(FONT_TABLE_PART, &mut HashMap::new(), &mut written) {
let Some(root) = crate::package::font_table_key(self.passthrough).map(str::to_string) else { return };
if let Some(name) = self.copy_tree(&root, &mut HashMap::new(), &mut written) {
rels.add(rt::FONT_TABLE, &relative(STORY_PART, &name), false);
}
}
@@ -747,7 +747,25 @@ fn scrub_private(bytes: &[u8], names: bool, dates: bool) -> Option<Vec<u8>> {
b"date" if dates && wml_attr => None,
b"dateUtc" if dates => None,
_ => {
el.push_attribute(a);
// `push_attribute` writes the value as given, inside `"`. In well-formed
// markup the raw value holds no `<` and every `&` starts a valid
// reference, so only a `"` from a single-quoted value needs escaping;
// references such as `&#10;` stay as written (#3872).
let raw = a.value.as_ref();
let value = if raw.contains(&b'"') {
let mut v = Vec::with_capacity(raw.len() + 8);
for &c in raw {
if c == b'"' {
v.extend_from_slice(b"&quot;");
} else {
v.push(c);
}
}
Cow::Owned(v)
} else {
Cow::Borrowed(raw)
};
el.push_attribute(Attribute { key: a.key, value });
continue;
}
};
+63 -57
View File
@@ -179,6 +179,67 @@ fn char_prop(w: &mut W, name: &str, c: Option<char>) {
val(w, name, &s);
}
fn write_matrix(w: &mut W, n: &MNode, depth: usize) {
let MNode::Matrix { rows, col_jc } = n else { return };
let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).max(1);
w.open("m:m", &[]);
w.open("m:mPr", &[]);
w.open("m:mcs", &[]);
for c in 0..cols {
let jc = match col_jc.get(c).copied().unwrap_or_default() {
ColJc::Center => "center",
ColJc::Left => "left",
ColJc::Right => "right",
};
w.open("m:mc", &[]);
w.open("m:mcPr", &[]);
val(w, "m:count", "1");
val(w, "m:mcJc", jc);
w.close("m:mcPr");
w.close("m:mc");
}
w.close("m:mcs");
ctrl(w);
w.close("m:mPr");
for row in rows {
w.open("m:mr", &[]);
for c in 0..cols {
arg(w, "m:e", row.get(c).map(|v| v.as_slice()).unwrap_or(&[]), depth);
}
w.close("m:mr");
}
w.close("m:m");
}
fn write_nary(w: &mut W, n: &MNode, depth: usize) {
let MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } = n else { return };
w.open("m:nary", &[]);
w.open("m:naryPr", &[]);
if *chr != '∫' {
char_prop(w, "m:chr", Some(*chr));
}
match lim_loc {
Some(LimLoc::UndOvr) => val(w, "m:limLoc", "undOvr"),
Some(LimLoc::SubSup) => val(w, "m:limLoc", "subSup"),
None => {}
}
if *grow {
val(w, "m:grow", "1");
}
if *sub_hide {
val(w, "m:subHide", "1");
}
if *sup_hide {
val(w, "m:supHide", "1");
}
ctrl(w);
w.close("m:naryPr");
arg(w, "m:sub", sub, depth);
arg(w, "m:sup", sup, depth);
arg(w, "m:e", e, depth);
w.close("m:nary");
}
fn node(w: &mut W, n: &MNode, depth: usize) {
match n {
MNode::Run(r) => run(w, r),
@@ -235,33 +296,7 @@ fn node(w: &mut W, n: &MNode, depth: usize) {
arg(w, "m:e", e, depth);
w.close("m:rad");
}
MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } => {
w.open("m:nary", &[]);
w.open("m:naryPr", &[]);
if *chr != '∫' {
char_prop(w, "m:chr", Some(*chr));
}
match lim_loc {
Some(LimLoc::UndOvr) => val(w, "m:limLoc", "undOvr"),
Some(LimLoc::SubSup) => val(w, "m:limLoc", "subSup"),
None => {}
}
if *grow {
val(w, "m:grow", "1");
}
if *sub_hide {
val(w, "m:subHide", "1");
}
if *sup_hide {
val(w, "m:supHide", "1");
}
ctrl(w);
w.close("m:naryPr");
arg(w, "m:sub", sub, depth);
arg(w, "m:sup", sup, depth);
arg(w, "m:e", e, depth);
w.close("m:nary");
}
MNode::Nary { .. } => write_nary(w, n, depth),
MNode::Delim { beg, end, sep, grow, shp_match, elems } => {
w.open("m:d", &[]);
w.open("m:dPr", &[]);
@@ -375,36 +410,7 @@ fn node(w: &mut W, n: &MNode, depth: usize) {
}
w.close("m:eqArr");
}
MNode::Matrix { rows, col_jc } => {
let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).max(1);
w.open("m:m", &[]);
w.open("m:mPr", &[]);
w.open("m:mcs", &[]);
for c in 0..cols {
let jc = match col_jc.get(c).copied().unwrap_or_default() {
ColJc::Center => "center",
ColJc::Left => "left",
ColJc::Right => "right",
};
w.open("m:mc", &[]);
w.open("m:mcPr", &[]);
val(w, "m:count", "1");
val(w, "m:mcJc", jc);
w.close("m:mcPr");
w.close("m:mc");
}
w.close("m:mcs");
ctrl(w);
w.close("m:mPr");
for r in rows {
w.open("m:mr", &[]);
for c in 0..cols {
arg(w, "m:e", r.get(c).map(|v| v.as_slice()).unwrap_or(&[]), depth);
}
w.close("m:mr");
}
w.close("m:m");
}
MNode::Matrix { .. } => write_matrix(w, n, depth),
MNode::Phant { show, zero_wid, zero_asc, zero_desc, e } => {
w.open("m:phant", &[]);
w.open("m:phantPr", &[]);
+177 -155
View File
@@ -3,7 +3,10 @@ use super::settings::{
ANONYMOUS_INITIALS, annotation_author, app_xml, core_xml, note_id_base, notes_part, settings_xml, theme_xml, write_comment_ids,
};
use super::styles::{numbering_xml, styles_xml};
use super::*;
use super::{
BTreeMap, Blocks, CT_WML, Document, DocxError, Flavor, HashMap, MAX_VBA_RELATED, PartRels, VBA_PROJECT_PART, VBA_RELATED, W, Writer, embed,
glossary, rt, settings_merge, watermark, xml,
};
/// The parts of the package [`write_as`] zips, in order, `[Content_Types].xml` first: (part name
/// without the leading `/`, bytes).
@@ -60,6 +63,8 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
let mut overrides: Vec<(String, String)> = Vec::new();
let mut rels = PartRels::default();
let mut root = PartRels::default();
let ns = xml::body_ns();
let ns_refs: Vec<(&str, &str)> = ns.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
// Comments get paragraph ids before any story is written.
let mut comment_paras: Vec<(u32, String)> = Vec::new();
@@ -73,41 +78,7 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
}
}
// Body.
let mut w = W::new();
let ns = xml::body_ns();
let ns_refs: Vec<(&str, &str)> = ns.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
w.open("w:document", &ns_refs);
let pic = doc.settings.page_picture.as_ref().filter(|p| wr.media_files.contains_key(&p.media) && doc.media.contains_key(&p.media));
if pic.is_some() || doc.settings.page_color.is_some() {
let color = doc.settings.page_color.map(|c| c.hex());
let attrs: Vec<(&str, &str)> = color.iter().map(|c| ("w:color", c.as_str())).collect();
w.open("w:background", &attrs);
if let Some(p) = pic
&& let Some(file) = wr.media_files.get(&p.media)
{
let rid = rels.add(rt::IMAGE, &format!("media/{file}"), false);
wr.used_media.insert(p.media.clone());
w.open("v:background", &[("id", "_x0000_s1025"), ("o:bwmode", "white")]);
let mut fa: Vec<(&str, &str)> = vec![("r:id", &rid)];
if !p.fill_type.is_empty() {
fa.push(("type", &p.fill_type));
}
if !p.title.is_empty() {
fa.push(("o:title", &p.title));
}
w.empty("v:fill", &fa);
w.close("v:background");
}
w.close("w:background");
}
w.open("w:body", &[]);
wr.blocks(&mut w, &doc.body, &mut rels, true, 0);
let last = doc.last_section.clone();
wr.sectpr(&mut w, &last, &mut rels);
w.close("w:body");
w.close("w:document");
let body = w.into_bytes();
let body = write_document_body(doc, &mut wr, &mut rels, &ns_refs);
// Headers and footers (discovered while writing sections).
let mut done = 0;
@@ -150,126 +121,9 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
wr.glossary(&mut entries, &mut overrides, &mut rels);
}
// Notes (references were collected from the body and headers).
for foot in [true, false] {
let seps = if foot { &doc.footnote_separators } else { &doc.endnote_separators };
if !notes_part(doc, &wr, foot) {
continue;
}
let (root, tag, file) = if foot { ("w:footnotes", "w:footnote", "footnotes.xml") } else { ("w:endnotes", "w:endnote", "endnotes.xml") };
let mut prels = PartRels::default();
let mut w = W::new();
w.open(root, &ns_refs);
// The separators and continuation notice: the document's own, else the defaults (no
// notice). Word numbers them -1, 0 and 1.
for (id, kind, own) in [
("-1", "separator", &seps.separator),
("0", "continuationSeparator", &seps.continuation_separator),
("1", "continuationNotice", &seps.continuation_notice),
] {
match own {
Some(blocks) => {
w.open(tag, &[("w:type", kind), ("w:id", id)]);
if blocks.is_empty() {
w.empty("w:p", &[]);
} else {
wr.blocks(&mut w, blocks, &mut prels, false, 0);
}
w.close(tag);
}
None if kind != "continuationNotice" => {
w.open(tag, &[("w:type", kind), ("w:id", id)]);
w.open("w:p", &[]);
w.open("w:pPr", &[]);
w.empty("w:spacing", &[("w:after", "0"), ("w:line", "240"), ("w:lineRule", "auto")]);
w.close("w:pPr");
w.open("w:r", &[]);
w.empty(if kind == "separator" { "w:separator" } else { "w:continuationSeparator" }, &[]);
w.close("w:r");
w.close("w:p");
w.close(tag);
}
None => {}
}
}
let base = note_id_base(doc, foot);
let mut k = 0;
while let Some(pid) = (if foot { &wr.footnotes } else { &wr.endnotes }).get(k).copied() {
let nid = (k + base).to_string();
k += 1;
w.open(tag, &[("w:id", &nid)]);
let blocks: Blocks = doc.parts.get(&pid).map(|p| p.blocks.clone()).unwrap_or_default();
wr.note_blocks(&mut w, &blocks, &mut prels, foot, pid);
w.close(tag);
}
w.close(root);
let kind = if foot { rt::FOOTNOTES } else { rt::ENDNOTES };
rels.add(kind, file, false);
push_part(
&mut entries,
&mut overrides,
&format!("word/{file}"),
w.into_bytes(),
&format!("{CT_WML}{}+xml", if foot { "footnotes" } else { "endnotes" }),
prels,
);
}
write_notes(doc, &ns_refs, &mut wr, &mut entries, &mut overrides, &mut rels);
// Comments.
if !doc.comments.is_empty() {
let mut prels = PartRels::default();
let mut w = W::new();
w.open("w:comments", &ns_refs);
for (cid, c) in &doc.comments {
let id = cid.to_string();
let author = annotation_author(doc, &c.author);
let mut a = vec![("w:id", id.as_str()), ("w:author", author)];
if !c.date.is_empty() && !doc.settings.remove_date_time {
a.push(("w:date", c.date.as_str()));
}
if doc.settings.remove_personal_info {
a.push(("w:initials", ANONYMOUS_INITIALS));
} else if !c.initials.is_empty() {
a.push(("w:initials", c.initials.as_str()));
}
w.open("w:comment", &a);
let blocks: Blocks = doc.parts.get(&c.part).map(|p| p.blocks.clone()).unwrap_or_default();
wr.blocks(&mut w, &blocks, &mut prels, false, 0);
w.close("w:comment");
}
w.close("w:comments");
rels.add(rt::COMMENTS, "comments.xml", false);
push_part(&mut entries, &mut overrides, "word/comments.xml", w.into_bytes(), &format!("{CT_WML}comments+xml"), prels);
let mut w = W::new();
w.open(
"w15:commentsEx",
&[
("xmlns:w15", "http://schemas.microsoft.com/office/word/2012/wordml"),
("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());
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)]),
}
}
w.close("w15:commentsEx");
rels.add(rt::COMMENTS_EX, "commentsExtended.xml", false);
push_part(
&mut entries,
&mut overrides,
"word/commentsExtended.xml",
w.into_bytes(),
&format!("{CT_WML}commentsExtended+xml"),
PartRels::default(),
);
write_comment_ids(doc, &comment_paras, &mut entries, &mut overrides, &mut rels);
}
write_comments(doc, &comment_paras, &ns_refs, &mut wr, &mut entries, &mut overrides, &mut rels);
rels.add(rt::STYLES, "styles.xml", false);
push_part(&mut entries, &mut overrides, "word/styles.xml", styles_xml(doc), &format!("{CT_WML}styles+xml"), PartRels::default());
@@ -425,6 +279,174 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
Ok(entries)
}
fn write_document_body(doc: &Document, wr: &mut Writer<'_>, rels: &mut PartRels, ns_refs: &[(&str, &str)]) -> Vec<u8> {
let mut w = W::new();
w.open("w:document", ns_refs);
let pic = doc.settings.page_picture.as_ref().filter(|p| wr.media_files.contains_key(&p.media) && doc.media.contains_key(&p.media));
if pic.is_some() || doc.settings.page_color.is_some() {
let color = doc.settings.page_color.map(|c| c.hex());
let attrs: Vec<(&str, &str)> = color.iter().map(|c| ("w:color", c.as_str())).collect();
w.open("w:background", &attrs);
if let Some(p) = pic
&& let Some(file) = wr.media_files.get(&p.media)
{
let rid = rels.add(rt::IMAGE, &format!("media/{file}"), false);
wr.used_media.insert(p.media.clone());
w.open("v:background", &[("id", "_x0000_s1025"), ("o:bwmode", "white")]);
let mut fa: Vec<(&str, &str)> = vec![("r:id", &rid)];
if !p.fill_type.is_empty() {
fa.push(("type", &p.fill_type));
}
if !p.title.is_empty() {
fa.push(("o:title", &p.title));
}
w.empty("v:fill", &fa);
w.close("v:background");
}
w.close("w:background");
}
w.open("w:body", &[]);
wr.blocks(&mut w, &doc.body, rels, true, 0);
let last = doc.last_section.clone();
wr.sectpr(&mut w, &last, rels);
w.close("w:body");
w.close("w:document");
w.into_bytes()
}
fn write_notes(
doc: &Document,
ns_refs: &[(&str, &str)],
wr: &mut Writer<'_>,
entries: &mut Vec<(String, Vec<u8>)>,
overrides: &mut Vec<(String, String)>,
rels: &mut PartRels,
) {
for foot in [true, false] {
let seps = if foot { &doc.footnote_separators } else { &doc.endnote_separators };
if !notes_part(doc, wr, foot) {
continue;
}
let (root, tag, file) = if foot { ("w:footnotes", "w:footnote", "footnotes.xml") } else { ("w:endnotes", "w:endnote", "endnotes.xml") };
let mut prels = PartRels::default();
let mut w = W::new();
w.open(root, ns_refs);
// The separators and continuation notice: the document's own, else the defaults (no
// notice). Word numbers them -1, 0 and 1.
for (id, kind, own) in [
("-1", "separator", &seps.separator),
("0", "continuationSeparator", &seps.continuation_separator),
("1", "continuationNotice", &seps.continuation_notice),
] {
match own {
Some(blocks) => {
w.open(tag, &[("w:type", kind), ("w:id", id)]);
if blocks.is_empty() {
w.empty("w:p", &[]);
} else {
wr.blocks(&mut w, blocks, &mut prels, false, 0);
}
w.close(tag);
}
None if kind != "continuationNotice" => {
w.open(tag, &[("w:type", kind), ("w:id", id)]);
w.open("w:p", &[]);
w.open("w:pPr", &[]);
w.empty("w:spacing", &[("w:after", "0"), ("w:line", "240"), ("w:lineRule", "auto")]);
w.close("w:pPr");
w.open("w:r", &[]);
w.empty(if kind == "separator" { "w:separator" } else { "w:continuationSeparator" }, &[]);
w.close("w:r");
w.close("w:p");
w.close(tag);
}
None => {}
}
}
let base = note_id_base(doc, foot);
let mut k = 0;
while let Some(pid) = (if foot { &wr.footnotes } else { &wr.endnotes }).get(k).copied() {
let nid = (k + base).to_string();
k += 1;
w.open(tag, &[("w:id", &nid)]);
let blocks: Blocks = doc.parts.get(&pid).map(|p| p.blocks.clone()).unwrap_or_default();
wr.note_blocks(&mut w, &blocks, &mut prels, foot, pid);
w.close(tag);
}
w.close(root);
let kind = if foot { rt::FOOTNOTES } else { rt::ENDNOTES };
rels.add(kind, file, false);
push_part(
entries,
overrides,
&format!("word/{file}"),
w.into_bytes(),
&format!("{CT_WML}{}+xml", if foot { "footnotes" } else { "endnotes" }),
prels,
);
}
}
fn write_comments(
doc: &Document,
comment_paras: &[(u32, String)],
ns_refs: &[(&str, &str)],
wr: &mut Writer<'_>,
entries: &mut Vec<(String, Vec<u8>)>,
overrides: &mut Vec<(String, String)>,
rels: &mut PartRels,
) {
if !doc.comments.is_empty() {
let mut prels = PartRels::default();
let mut w = W::new();
w.open("w:comments", ns_refs);
for (cid, c) in &doc.comments {
let id = cid.to_string();
let author = annotation_author(doc, &c.author);
let mut a = vec![("w:id", id.as_str()), ("w:author", author)];
if !c.date.is_empty() && !doc.settings.remove_date_time {
a.push(("w:date", c.date.as_str()));
}
if doc.settings.remove_personal_info {
a.push(("w:initials", ANONYMOUS_INITIALS));
} else if !c.initials.is_empty() {
a.push(("w:initials", c.initials.as_str()));
}
w.open("w:comment", &a);
let blocks: Blocks = doc.parts.get(&c.part).map(|p| p.blocks.clone()).unwrap_or_default();
wr.blocks(&mut w, &blocks, &mut prels, false, 0);
w.close("w:comment");
}
w.close("w:comments");
rels.add(rt::COMMENTS, "comments.xml", false);
push_part(entries, overrides, "word/comments.xml", w.into_bytes(), &format!("{CT_WML}comments+xml"), prels);
let mut w = W::new();
w.open(
"w15:commentsEx",
&[
("xmlns:w15", "http://schemas.microsoft.com/office/word/2012/wordml"),
("xmlns:mc", "http://schemas.openxmlformats.org/markup-compatibility/2006"),
],
);
// 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)]),
}
}
w.close("w15:commentsEx");
rels.add(rt::COMMENTS_EX, "commentsExtended.xml", false);
push_part(entries, overrides, "word/commentsExtended.xml", w.into_bytes(), &format!("{CT_WML}commentsExtended+xml"), PartRels::default());
write_comment_ids(doc, comment_paras, entries, overrides, rels);
}
}
pub(super) fn push_part(
entries: &mut Vec<(String, Vec<u8>)>,
overrides: &mut Vec<(String, String)>,
+48 -26
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()),
@@ -253,6 +255,40 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
toggle(w, "w:keepNext", p.keep_next);
toggle(w, "w:keepLines", p.keep_lines);
toggle(w, "w:pageBreakBefore", p.page_break_before);
write_frame(w, p, framed);
toggle(w, "w:widowControl", p.widow_control);
if let Some(nr) = p.numbering {
w.open("w:numPr", &[]);
w.val("w:ilvl", &n(nr.level.min(8) as i64));
w.val("w:numId", &n(nr.num as i64));
if let Some(a) = num_change {
let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect();
w.empty("w:numberingChange", &refs);
}
w.close("w:numPr");
}
toggle(w, "w:suppressLineNumbers", p.suppress_line_numbers);
if let Some(b) = &p.borders {
borders(w, "w:pBdr", b, Some("w:between"), &[]);
}
if let Some(s) = p.shading {
w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &s.hex())]);
}
write_tabs(w, p);
write_text_flow(w, p);
write_spacing(w, p);
write_indentation(w, p);
toggle(w, "w:contextualSpacing", p.contextual_spacing);
toggle(w, "w:mirrorIndents", p.mirror_indents);
if let Some(a) = p.align {
w.val("w:jc", align_val(a));
}
if let Some(l) = p.outline_level {
w.val("w:outlineLvl", &n(l.min(9) as i64));
}
}
fn write_frame(w: &mut W, p: &ParaProps, framed: bool) {
if framed && let Some(lines) = p.drop_cap.filter(|l| *l > 0) {
// ECMA-376 §17.3.1.11: the drop cap's frame — dropped or in the margin, the lines it
// spans and its distance from the text (`w:hSpace`, twips).
@@ -295,24 +331,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
}
w.empty("w:framePr", &a);
}
toggle(w, "w:widowControl", p.widow_control);
if let Some(nr) = p.numbering {
w.open("w:numPr", &[]);
w.val("w:ilvl", &n(nr.level.min(8) as i64));
w.val("w:numId", &n(nr.num as i64));
if let Some(a) = num_change {
let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect();
w.empty("w:numberingChange", &refs);
}
w.close("w:numPr");
}
toggle(w, "w:suppressLineNumbers", p.suppress_line_numbers);
if let Some(b) = &p.borders {
borders(w, "w:pBdr", b, Some("w:between"), &[]);
}
if let Some(s) = p.shading {
w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &s.hex())]);
}
}
fn write_tabs(w: &mut W, p: &ParaProps) {
if let Some(tabs) = &p.tabs
&& !tabs.is_empty()
{
@@ -334,6 +355,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
}
w.close("w:tabs");
}
}
fn write_text_flow(w: &mut W, p: &ParaProps) {
toggle(w, "w:suppressAutoHyphens", p.suppress_hyphens);
toggle(w, "w:kinsoku", p.kinsoku);
toggle(w, "w:wordWrap", p.word_wrap);
@@ -343,6 +367,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
toggle(w, "w:autoSpaceDN", p.auto_space_dn);
toggle(w, "w:bidi", p.bidi);
toggle(w, "w:snapToGrid", p.snap_to_grid);
}
fn write_spacing(w: &mut W, p: &ParaProps) {
if p.space_before.is_some()
|| p.space_after.is_some()
|| p.line_spacing.is_some()
@@ -375,6 +402,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
}
w.empty("w:spacing", &a);
}
}
fn write_indentation(w: &mut W, p: &ParaProps) {
let chars = [p.indent_left_chars, p.indent_right_chars, p.indent_first_chars];
if p.indent_left.is_some() || p.indent_right.is_some() || p.indent_first.is_some() || chars.iter().any(Option::is_some) {
let l = p.indent_left.map(twips);
@@ -404,14 +434,6 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
}
w.empty("w:ind", &a);
}
toggle(w, "w:contextualSpacing", p.contextual_spacing);
toggle(w, "w:mirrorIndents", p.mirror_indents);
if let Some(a) = p.align {
w.val("w:jc", align_val(a));
}
if let Some(l) = p.outline_level {
w.val("w:outlineLvl", &n(l.min(9) as i64));
}
}
pub fn margins(w: &mut W, tag: &str, m: &[f32; 4]) {
+28 -12
View File
@@ -1,5 +1,5 @@
use super::parts::push_part;
use super::*;
use super::{CT_WML, Document, PartRels, W, Writer, math, rt, story, xml};
/// The file id of the first footnote or endnote: after the separators (-1, 0) and the
/// continuation notice (1), when the document has one.
@@ -102,22 +102,41 @@ pub(super) fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, temp
}
}
w.val("w:characterSpacingControl", "doNotCompress");
write_note_settings(&mut w, s, s_notes, footnotes, endnotes);
write_compatibility_settings(&mut w, doc);
if let Some(m) = &s.math {
math::math_pr(&mut w, m);
}
w.close("w:settings");
w.into_bytes()
}
fn write_note_settings(
w: &mut W,
settings: &wordcraft_doc::Settings,
separators: (&wordcraft_doc::NoteSeparators, &wordcraft_doc::NoteSeparators),
footnotes: bool,
endnotes: bool,
) {
for (tag, fmt, pr, used, el) in [
("w:footnotePr", s.footnote_format, &s.footnote_pr, footnotes, "w:footnote"),
("w:endnotePr", s.endnote_format, &s.endnote_pr, endnotes, "w:endnote"),
("w:footnotePr", settings.footnote_format, &settings.footnote_pr, footnotes, "w:footnote"),
("w:endnotePr", settings.endnote_format, &settings.endnote_pr, endnotes, "w:endnote"),
] {
w.open(tag, &[]);
story::note_pr_inner(&mut w, &wordcraft_doc::section::NoteProps { num_fmt: Some(fmt), ..*pr });
story::note_pr_inner(w, &wordcraft_doc::section::NoteProps { num_fmt: Some(fmt), ..*pr });
if used {
w.empty(el, &[("w:id", "-1")]);
w.empty(el, &[("w:id", "0")]);
let seps = if el == "w:footnote" { &s_notes.0 } else { &s_notes.1 };
let seps = if el == "w:footnote" { separators.0 } else { separators.1 };
if seps.continuation_notice.is_some() {
w.empty(el, &[("w:id", "1")]);
}
}
w.close(tag);
}
}
fn write_compatibility_settings(w: &mut W, doc: &Document) {
w.open("w:compat", &[]);
// CT_Compat: the older switches come before the `w:compatSetting`s.
if doc.settings.do_not_expand_shift_return {
@@ -126,17 +145,12 @@ pub(super) fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, temp
if doc.settings.sum_paragraph_spacing {
w.empty("w:doNotUseHTMLParagraphAutoSpacing", &[]);
}
if s.allow_space_of_same_style_in_table {
if doc.settings.allow_space_of_same_style_in_table {
w.empty("w:allowSpaceOfSameStyleInTable", &[]);
}
let mode = doc.settings.compat_mode.clamp(11, 15).to_string();
w.empty("w:compatSetting", &[("w:name", "compatibilityMode"), ("w:uri", "http://schemas.microsoft.com/office/word"), ("w:val", &mode)]);
w.close("w:compat");
if let Some(m) = &s.math {
math::math_pr(&mut w, m);
}
w.close("w:settings");
w.into_bytes()
}
/// Is `d` a usable `w16cid:durableId`: 1–8 hex digits, below 0x7FFFFFFF, not 0?
@@ -158,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();
@@ -169,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;
}
},
+1 -1
View File
@@ -1,5 +1,5 @@
use super::toc::written_toc_span;
use super::*;
use super::{Block, Blocks, CharProps, MAX_DEPTH, PartRels, Table, W, Writer, tblpr, tcpr, trpr, twips};
impl Writer<'_> {
fn write_trailing_para(&mut self, w: &mut W, depth: usize) {
+18 -16
View File
@@ -1,5 +1,16 @@
use super::object::ink_name;
use super::*;
use super::{
AltText, Anchor, Fill, Float, GradientFlip, GradientKind, InlineObject, PartRels, PathShape, Rgb, ShapeEffects, ShapeKind, W, Wrap, Writer, emu,
n, rt,
};
pub(super) struct DrawingInfo<'a> {
pub float: &'a Float,
pub size: (f32, f32),
pub docpr: &'a str,
pub name: &'a str,
pub alt: &'a str,
}
impl Writer<'_> {
/// A picture's `pic:pic` (the image in `media/{file}`), at `off` in its group (or 0, 0).
@@ -198,28 +209,19 @@ impl Writer<'_> {
w.close("wps:wsp");
}
#[allow(clippy::too_many_arguments)]
/// A `w:drawing` holding `graphic` (an `a:graphic`) in a frame of the object's size and place.
#[allow(clippy::too_many_arguments)]
pub(super) fn graphic_frame(
&mut self,
w: &mut W,
float: &Float,
(gw, gh): (f32, f32),
docpr: &str,
name: &str,
alt: &str,
graphic: &crate::xml::El,
) {
pub(super) fn graphic_frame(&mut self, w: &mut W, info: DrawingInfo<'_>, graphic: &crate::xml::El) {
let inline = info.float.wrap == Wrap::Inline;
w.open("w:drawing", &[]);
self.drawing_open(w, float, gw, gh, docpr, name, alt);
self.drawing_open(w, info);
w.empty("wp:cNvGraphicFramePr", &[]);
w.raw(&graphic.to_xml());
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
w.close(if inline { "wp:inline" } else { "wp:anchor" });
w.close("w:drawing");
}
pub(super) fn drawing_open(&mut self, w: &mut W, float: &Float, iw: f32, ih: f32, docpr: &str, name: &str, alt: &str) {
pub(super) fn drawing_open(&mut self, w: &mut W, info: DrawingInfo<'_>) {
let DrawingInfo { float, size: (iw, ih), docpr, name, alt } = info;
let (cx, cy) = (emu(iw.max(0.0)), emu(ih.max(0.0)));
if float.wrap == Wrap::Inline {
w.open("wp:inline", &[("distT", "0"), ("distB", "0"), ("distL", "0"), ("distR", "0")]);
+11 -2
View File
@@ -1,8 +1,17 @@
use super::toc::is_toc;
use super::*;
use super::{CharProps, W, Writer, rpr};
pub(super) struct FieldData<'a> {
pub instr: &'a str,
pub result: &'a str,
pub locked: bool,
pub dirty: bool,
pub form: &'a str,
}
impl Writer<'_> {
pub(super) fn field(&mut self, w: &mut W, instr: &str, result: &str, locked: bool, dirty: bool, form: &str, props: &CharProps) {
pub(super) fn field(&mut self, w: &mut W, field: FieldData<'_>, props: &CharProps) {
let FieldData { instr, result, locked, dirty, form } = field;
if self.toc_hold_end && is_toc(instr) {
// A TOC heading's field opens in the first entry and closes after the last (see `toc_span`).
self.toc_hold_end = false;
+235 -221
View File
@@ -1,7 +1,7 @@
use super::drawing::{shape_name, spin_attrs, write_fill, xfrm_spin_only};
use super::field::field_run;
use super::drawing::{DrawingInfo, shape_name, spin_attrs, write_fill, xfrm_spin_only};
use super::field::{FieldData, field_run};
use super::section::comment_reference_style;
use super::*;
use super::{CharProps, Fill, InlineObject, NoteKind, PartRels, TextColor, W, Wrap, Writer, emu, n, rpr};
impl Writer<'_> {
pub(super) fn object(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, depth: usize) {
@@ -56,7 +56,9 @@ impl Writer<'_> {
None => crate::write::math::write_equation(w, linear, *display, math, color),
}
}
InlineObject::Field { instr, result, locked, dirty, form, .. } => self.field(w, instr, result, *locked, *dirty, form, props),
InlineObject::Field { instr, result, locked, dirty, form, .. } => {
self.field(w, FieldData { instr, result, locked: *locked, dirty: *dirty, form }, props)
}
InlineObject::FieldStart { instr, locked, dirty } => {
self.rev_open(w, props);
self.field_start(w, instr, *locked, *dirty, "", props);
@@ -111,223 +113,10 @@ impl Writer<'_> {
}
// Editable charts and SmartArt keep main's model writer; opaque graphics and native
// ink keep their original source and related parts.
InlineObject::Graphic { w: gw, h: gh, alt, float, graphic } => {
let ink = graphic.kind == wordcraft_doc::graphic::GraphicKind::Ink;
let root = match (graphic.chart.as_deref(), graphic.smart_art.as_deref(), graphic.source.as_deref()) {
(Some(spec), _, _) => crate::xml::parse(self.chart_graphic(spec, rels).as_bytes()).ok(),
(None, Some(spec), _) => crate::xml::parse(self.smart_art_graphic(spec, *gw, *gh, rels).as_bytes()).ok(),
// Ink in a group member's form (no drawing of its own) can't stand alone: left
// out before its parts are queued, so no orphan part or relationship is written.
(None, None, Some(src)) if ink && !standalone_ink(src) => {
log::warn!("docx: group-member ink outside its group can't be written; it is left out");
None
}
(None, None, Some(src)) => self.embedded_tree(src, rels, ink.then_some((*gw, *gh, float, alt.as_str()))),
(None, None, None) => None,
};
let Some(mut root) = root else { return };
let direct_ink = ink && root.name == "w:contentPart";
if ink && !direct_ink {
Self::ink_transform(&mut root, (graphic.w, graphic.h), (*gw, *gh), float, 0);
}
// Native ink, or an Office 2016 chart (`cx:chart`) with its fallback picture.
let alternate = (root.name == "mc:AlternateContent").then_some(&root);
let fallback = alternate.and_then(|e| e.child("mc:Fallback"));
let choice = alternate.and_then(|e| e.child("mc:Choice"));
let native = choice.and_then(|e| e.find("a:graphic"));
if alternate.is_some() && native.is_none() {
return;
}
// A chart's picture gets the chart's current frame too; ink's VML preview is resized in place.
let picture = if ink { None } else { fallback.and_then(|e| e.find("a:graphic")) };
self.rev_open(w, props);
w.open("w:r", &[]);
self.rpr(w, props);
if direct_ink {
w.raw(&root.to_xml());
} else {
if fallback.is_some() {
let requires = if ink { "wpi" } else { choice.and_then(|c| c.attr("Requires")).unwrap_or_default() };
w.open("mc:AlternateContent", &[]);
w.open("mc:Choice", &[("Requires", requires)]);
}
let docpr = self.next_docpr();
let name = match graphic.kind {
wordcraft_doc::graphic::GraphicKind::Chart => format!("Chart {docpr}"),
wordcraft_doc::graphic::GraphicKind::Diagram => format!("Diagram {docpr}"),
wordcraft_doc::graphic::GraphicKind::Ink => format!("Ink {docpr}"),
};
let z = self.z;
self.graphic_frame(w, float, (*gw, *gh), &docpr, &name, alt, native.unwrap_or(&root));
if let Some(fallback) = fallback {
w.close("mc:Choice");
match picture {
Some(picture) => {
// Both branches are one object: same id and stacking order.
self.z = z;
w.open("mc:Fallback", &[]);
self.graphic_frame(w, float, (*gw, *gh), &docpr, &name, alt, picture);
w.close("mc:Fallback");
}
None => w.raw(&fallback.to_xml()),
}
w.close("mc:AlternateContent");
}
}
w.close("w:r");
self.rev_close(w, props);
}
InlineObject::Image { media, w: iw, h: ih, alt, float, ole, .. } => {
// An OLE object read from a file: the object itself, at its current size.
if let Some(src) = ole.as_deref()
&& let Some(obj) = self.embedded_xml(src, rels, Some((*iw, *ih, float, alt.as_str())))
{
self.rev_open(w, props);
w.open("w:r", &[]);
self.rpr(w, props);
w.raw(&obj);
w.close("w:r");
self.rev_close(w, props);
return;
}
let Some(file) = self.media_files.get(media).cloned() else { return };
self.rev_open(w, props);
w.open("w:r", &[]);
self.rpr(w, props);
w.open("w:drawing", &[]);
let docpr = self.next_docpr();
let name = format!("Picture {docpr}");
self.drawing_open(w, float, *iw, *ih, &docpr, &name, alt);
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
w.open("a:graphicData", &[("uri", "http://schemas.openxmlformats.org/drawingml/2006/picture")]);
self.pic(w, o, &file, (0.0, 0.0), rels);
w.close("a:graphicData");
w.close("a:graphic");
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
w.close("w:drawing");
w.close("w:r");
self.rev_close(w, props);
}
InlineObject::Shape { kind, w: sw, h: sh, float, effects, freeform, extra, .. } => {
self.rev_open(w, props);
w.open("w:r", &[]);
self.rpr(w, props);
w.open("w:drawing", &[]);
let docpr = self.shape_docpr(extra.id);
// Ink is told apart by its name (and its pen), which reading looks for.
let name = shape_name(*kind, freeform.as_deref(), &docpr);
// The effect extent leaves room for the shadow and glow.
let mut float = float.clone();
for (e, fx) in float.effect.iter_mut().zip(effects.extent()) {
*e = wordcraft_geom::finite(*e).max(fx);
}
// Tight and Through wrapping need a polygon: the shape's outline unless it has one.
if matches!(float.wrap, Wrap::Tight | Wrap::Through) && float.wrap_polygon.is_none() {
float.wrap_polygon = wordcraft_doc::wrap::shape_outline(*kind, *sw, *sh, extra.corner)
.and_then(|pts| wordcraft_doc::wrap::WrapPolygon::from_unit(false, &pts))
.map(std::sync::Arc::new);
}
let float = &float;
self.drawing_open(w, float, *sw, *sh, &docpr, &name, o.alt_text());
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape")]);
self.wsp(w, o, None, (0.0, 0.0), rels, depth);
w.close("a:graphicData");
w.close("a:graphic");
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
w.close("w:drawing");
w.close("w:r");
self.rev_close(w, props);
}
InlineObject::Group { w: gw, h: gh, float, ch_w, ch_h, children, canvas } => {
self.rev_open(w, props);
w.open("w:r", &[]);
self.rpr(w, props);
w.open("w:drawing", &[]);
let docpr = self.next_docpr();
let name = format!("{} {docpr}", if canvas.is_some() { "Canvas" } else { "Group" });
self.drawing_open(w, float, *gw, *gh, &docpr, &name, o.alt_text());
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
let outer = match canvas {
Some(c) => {
// A Drawing Canvas: its background (`wpc:bg`) and outline (`wpc:whole`), then
// its members, at offsets in the canvas's own (unscaled) space.
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas")]);
w.open("wpc:wpc", &[]);
w.open("wpc:bg", &[]);
write_fill(w, c.fill.as_ref());
w.close("wpc:bg");
w.open("wpc:whole", &[]);
match c.stroke {
Some(_) => w.open("a:ln", &[("w", &emu(c.stroke_width.clamp(0.0, 100.0)))]),
None => w.open("a:ln", &[]),
}
write_fill(w, c.stroke.map(Fill::solid).as_ref());
w.close("a:ln");
w.close("wpc:whole");
"wpc:wpc"
}
None => {
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup")]);
// ECMA-376 §20.1.7.6 (`a:xfrm` of a group): the members' space `a:chOff`/`a:chExt`
// maps onto the group's `a:off`/`a:ext`.
w.open("wpg:wgp", &[]);
w.empty("wpg:cNvGrpSpPr", &[]);
w.open("wpg:grpSpPr", &[]);
w.open("a:xfrm", &spin_attrs(float.spin()).iter().map(|(k, v)| (*k, v.as_str())).collect::<Vec<_>>());
w.empty("a:off", &[("x", "0"), ("y", "0")]);
w.empty("a:ext", &[("cx", &emu(gw.max(0.0))), ("cy", &emu(gh.max(0.0)))]);
w.empty("a:chOff", &[("x", "0"), ("y", "0")]);
w.empty("a:chExt", &[("cx", &emu(ch_w.max(0.0))), ("cy", &emu(ch_h.max(0.0)))]);
w.close("a:xfrm");
w.close("wpg:grpSpPr");
"wpg:wgp"
}
};
for c in children.iter().take(wordcraft_doc::para::MAX_GROUP_CHILDREN) {
let at = (c.x, c.y);
match &c.obj {
InlineObject::Image { media, .. } => {
if let Some(file) = self.media_files.get(media).cloned() {
self.pic(w, &c.obj, &file, at, rels);
}
}
InlineObject::Shape { extra, .. } => {
let id = self.shape_docpr(extra.id);
self.wsp(w, &c.obj, Some(&id), at, rels, depth);
}
// Native ink kept from the file, with its fallback, at the member's place.
InlineObject::Graphic { w: iw, h: ih, float, graphic, .. } if graphic.kind == wordcraft_doc::graphic::GraphicKind::Ink => {
if let Some(src) = graphic.source.as_deref()
&& let Some(mut root) = self.embedded_tree(src, rels, None)
{
Self::place_member(&mut root, [c.x, c.y, *iw, *ih], float, 0);
w.raw(&root.to_xml());
}
}
_ => {}
}
}
if canvas.is_some() && float.spin() != wordcraft_geom::Spin::default() {
// The canvas schema has no transform of its own: keep its turn in our
// extension (Word draws it unturned, as it can't turn a canvas).
w.open("wpc:extLst", &[]);
w.open("a:ext", &[("uri", crate::CANVAS_SPIN_EXT)]);
xfrm_spin_only(w, float.spin());
w.close("a:ext");
w.close("wpc:extLst");
}
w.close(outer);
w.close("a:graphicData");
w.close("a:graphic");
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
w.close("w:drawing");
w.close("w:r");
self.rev_close(w, props);
}
InlineObject::Graphic { .. } => self.write_graphic(w, o, props, rels, depth),
InlineObject::Image { .. } => self.write_image(w, o, props, rels, depth),
InlineObject::Shape { .. } => self.write_shape(w, o, props, rels, depth),
InlineObject::Group { .. } => self.write_group(w, o, props, rels, depth),
InlineObject::Ruby { base, ruby, align, size, raise, base_size, lang, props: ruby_props } => {
// ECMA-376 §17.3.3.25: a run holding w:ruby (properties, ruby text, base text).
let t = if self.is_del(props) { "w:delText" } else { "w:t" };
@@ -385,6 +174,231 @@ impl Writer<'_> {
}
}
}
fn write_graphic(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, _depth: usize) {
let InlineObject::Graphic { w: gw, h: gh, alt, float, graphic } = o else { return };
let ink = graphic.kind == wordcraft_doc::graphic::GraphicKind::Ink;
let root = match (graphic.chart.as_deref(), graphic.smart_art.as_deref(), graphic.source.as_deref()) {
(Some(spec), _, _) => crate::xml::parse(self.chart_graphic(spec, rels).as_bytes()).ok(),
(None, Some(spec), _) => crate::xml::parse(self.smart_art_graphic(spec, *gw, *gh, rels).as_bytes()).ok(),
// Ink in a group member's form (no drawing of its own) can't stand alone: left
// out before its parts are queued, so no orphan part or relationship is written.
(None, None, Some(src)) if ink && !standalone_ink(src) => {
log::warn!("docx: group-member ink outside its group can't be written; it is left out");
None
}
(None, None, Some(src)) => self.embedded_tree(src, rels, ink.then_some((*gw, *gh, float, alt.as_str()))),
(None, None, None) => None,
};
let Some(mut root) = root else { return };
let direct_ink = ink && root.name == "w:contentPart";
if ink && !direct_ink {
Self::ink_transform(&mut root, (graphic.w, graphic.h), (*gw, *gh), float, 0);
}
// Native ink, or an Office 2016 chart (`cx:chart`) with its fallback picture.
let alternate = (root.name == "mc:AlternateContent").then_some(&root);
let fallback = alternate.and_then(|e| e.child("mc:Fallback"));
let choice = alternate.and_then(|e| e.child("mc:Choice"));
let native = choice.and_then(|e| e.find("a:graphic"));
if alternate.is_some() && native.is_none() {
return;
}
// A chart's picture gets the chart's current frame too; ink's VML preview is resized in place.
let picture = if ink { None } else { fallback.and_then(|e| e.find("a:graphic")) };
self.rev_open(w, props);
w.open("w:r", &[]);
self.rpr(w, props);
if direct_ink {
w.raw(&root.to_xml());
} else {
if fallback.is_some() {
let requires = if ink { "wpi" } else { choice.and_then(|c| c.attr("Requires")).unwrap_or_default() };
w.open("mc:AlternateContent", &[]);
w.open("mc:Choice", &[("Requires", requires)]);
}
let docpr = self.next_docpr();
let name = match graphic.kind {
wordcraft_doc::graphic::GraphicKind::Chart => format!("Chart {docpr}"),
wordcraft_doc::graphic::GraphicKind::Diagram => format!("Diagram {docpr}"),
wordcraft_doc::graphic::GraphicKind::Ink => format!("Ink {docpr}"),
};
let z = self.z;
self.graphic_frame(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt }, native.unwrap_or(&root));
if let Some(fallback) = fallback {
w.close("mc:Choice");
match picture {
Some(picture) => {
// Both branches are one object: same id and stacking order.
self.z = z;
w.open("mc:Fallback", &[]);
self.graphic_frame(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt }, picture);
w.close("mc:Fallback");
}
None => w.raw(&fallback.to_xml()),
}
w.close("mc:AlternateContent");
}
}
w.close("w:r");
self.rev_close(w, props);
}
fn write_image(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, _depth: usize) {
let InlineObject::Image { media, w: iw, h: ih, alt, float, ole, .. } = o else { return };
// An OLE object read from a file: the object itself, at its current size.
if let Some(src) = ole.as_deref()
&& let Some(obj) = self.embedded_xml(src, rels, Some((*iw, *ih, float, alt.as_str())))
{
self.rev_open(w, props);
w.open("w:r", &[]);
self.rpr(w, props);
w.raw(&obj);
w.close("w:r");
self.rev_close(w, props);
return;
}
let Some(file) = self.media_files.get(media).cloned() else { return };
self.rev_open(w, props);
w.open("w:r", &[]);
self.rpr(w, props);
w.open("w:drawing", &[]);
let docpr = self.next_docpr();
let name = format!("Picture {docpr}");
self.drawing_open(w, DrawingInfo { float, size: (*iw, *ih), docpr: &docpr, name: &name, alt });
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
w.open("a:graphicData", &[("uri", "http://schemas.openxmlformats.org/drawingml/2006/picture")]);
self.pic(w, o, &file, (0.0, 0.0), rels);
w.close("a:graphicData");
w.close("a:graphic");
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
w.close("w:drawing");
w.close("w:r");
self.rev_close(w, props);
}
fn write_shape(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, depth: usize) {
let InlineObject::Shape { kind, w: sw, h: sh, float, effects, freeform, extra, .. } = o else { return };
self.rev_open(w, props);
w.open("w:r", &[]);
self.rpr(w, props);
w.open("w:drawing", &[]);
let docpr = self.shape_docpr(extra.id);
// Ink is told apart by its name (and its pen), which reading looks for.
let name = shape_name(*kind, freeform.as_deref(), &docpr);
// The effect extent leaves room for the shadow and glow.
let mut float = float.clone();
for (e, fx) in float.effect.iter_mut().zip(effects.extent()) {
*e = wordcraft_geom::finite(*e).max(fx);
}
// Tight and Through wrapping need a polygon: the shape's outline unless it has one.
if matches!(float.wrap, Wrap::Tight | Wrap::Through) && float.wrap_polygon.is_none() {
float.wrap_polygon = wordcraft_doc::wrap::shape_outline(*kind, *sw, *sh, extra.corner)
.and_then(|pts| wordcraft_doc::wrap::WrapPolygon::from_unit(false, &pts))
.map(std::sync::Arc::new);
}
let float = &float;
self.drawing_open(w, DrawingInfo { float, size: (*sw, *sh), docpr: &docpr, name: &name, alt: o.alt_text() });
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape")]);
self.wsp(w, o, None, (0.0, 0.0), rels, depth);
w.close("a:graphicData");
w.close("a:graphic");
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
w.close("w:drawing");
w.close("w:r");
self.rev_close(w, props);
}
fn write_group(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, depth: usize) {
let InlineObject::Group { w: gw, h: gh, float, ch_w, ch_h, children, canvas } = o else { return };
self.rev_open(w, props);
w.open("w:r", &[]);
self.rpr(w, props);
w.open("w:drawing", &[]);
let docpr = self.next_docpr();
let name = format!("{} {docpr}", if canvas.is_some() { "Canvas" } else { "Group" });
self.drawing_open(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt: o.alt_text() });
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
let outer = match canvas {
Some(c) => {
// A Drawing Canvas: its background (`wpc:bg`) and outline (`wpc:whole`), then
// its members, at offsets in the canvas's own (unscaled) space.
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas")]);
w.open("wpc:wpc", &[]);
w.open("wpc:bg", &[]);
write_fill(w, c.fill.as_ref());
w.close("wpc:bg");
w.open("wpc:whole", &[]);
match c.stroke {
Some(_) => w.open("a:ln", &[("w", &emu(c.stroke_width.clamp(0.0, 100.0)))]),
None => w.open("a:ln", &[]),
}
write_fill(w, c.stroke.map(Fill::solid).as_ref());
w.close("a:ln");
w.close("wpc:whole");
"wpc:wpc"
}
None => {
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup")]);
// ECMA-376 §20.1.7.6 (`a:xfrm` of a group): the members' space `a:chOff`/`a:chExt`
// maps onto the group's `a:off`/`a:ext`.
w.open("wpg:wgp", &[]);
w.empty("wpg:cNvGrpSpPr", &[]);
w.open("wpg:grpSpPr", &[]);
w.open("a:xfrm", &spin_attrs(float.spin()).iter().map(|(k, v)| (*k, v.as_str())).collect::<Vec<_>>());
w.empty("a:off", &[("x", "0"), ("y", "0")]);
w.empty("a:ext", &[("cx", &emu(gw.max(0.0))), ("cy", &emu(gh.max(0.0)))]);
w.empty("a:chOff", &[("x", "0"), ("y", "0")]);
w.empty("a:chExt", &[("cx", &emu(ch_w.max(0.0))), ("cy", &emu(ch_h.max(0.0)))]);
w.close("a:xfrm");
w.close("wpg:grpSpPr");
"wpg:wgp"
}
};
for c in children.iter().take(wordcraft_doc::para::MAX_GROUP_CHILDREN) {
let at = (c.x, c.y);
match &c.obj {
InlineObject::Image { media, .. } => {
if let Some(file) = self.media_files.get(media).cloned() {
self.pic(w, &c.obj, &file, at, rels);
}
}
InlineObject::Shape { extra, .. } => {
let id = self.shape_docpr(extra.id);
self.wsp(w, &c.obj, Some(&id), at, rels, depth);
}
// Native ink kept from the file, with its fallback, at the member's place.
InlineObject::Graphic { w: iw, h: ih, float, graphic, .. } if graphic.kind == wordcraft_doc::graphic::GraphicKind::Ink => {
if let Some(src) = graphic.source.as_deref()
&& let Some(mut root) = self.embedded_tree(src, rels, None)
{
Self::place_member(&mut root, [c.x, c.y, *iw, *ih], float, 0);
w.raw(&root.to_xml());
}
}
_ => {}
}
}
if canvas.is_some() && float.spin() != wordcraft_geom::Spin::default() {
// The canvas schema has no transform of its own: keep its turn in our
// extension (Word draws it unturned, as it can't turn a canvas).
w.open("wpc:extLst", &[]);
w.open("a:ext", &[("uri", crate::CANVAS_SPIN_EXT)]);
xfrm_spin_only(w, float.spin());
w.close("a:ext");
w.close("wpc:extLst");
}
w.close(outer);
w.close("a:graphicData");
w.close("a:graphic");
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
w.close("w:drawing");
w.close("w:r");
self.rev_close(w, props);
}
}
pub(super) fn ink_name(tool: wordcraft_doc::freeform::InkTool) -> &'static str {
+3 -1
View File
@@ -1,4 +1,6 @@
use super::*;
use super::{
ChangeAttrs, CharProps, InlineObject, Paragraph, PartRels, Piece, PropChange, RevisionKind, W, Writer, open_change, ppr_inner, rpr, rpr_inner, rt,
};
impl Writer<'_> {
pub(super) fn para(&mut self, w: &mut W, p: &Paragraph, rels: &mut PartRels, top: bool, depth: usize) {
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{LineNumberRestart, NoteProps, PartRels, SectionProps, SectionStart, W, Writer, borders, n, open_change, twips};
impl Writer<'_> {
/// The section's headers, plus the header holding only the watermark in each slot the
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Blocks, InlineObject, W, Writer};
impl Writer<'_> {
pub(super) fn update_toc_before_block(&mut self, i: usize, toc: Option<(usize, usize)>, toc_field_open: bool, w: &mut W) {
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Document, Level, LevelSuffix, ParaProps, Style, StyleKind, TablePart, VAlign, W, props, xml};
/// Built-in names Word keeps in lower case in styles.xml.
fn file_style_name(name: &str) -> String {
+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");
}
}
+23 -12
View File
@@ -109,7 +109,13 @@ fn by_id(d: &Document, id: i64) -> ContentControl {
#[test]
fn every_kind_reads_with_its_properties() {
let d = read(&form()).unwrap();
let intro = controls(&d).into_iter().find(|c| c.title == "Intro").unwrap();
assert_intro_control(&d);
assert_control_kinds(&d);
assert_table_controls(&d);
}
fn assert_intro_control(d: &Document) {
let intro = controls(d).into_iter().find(|c| c.title == "Intro").unwrap();
assert!(intro.block && intro.showing_placeholder);
assert_eq!((intro.tag.as_str(), intro.id, intro.lock), ("intro", Some(-1201), ControlLock::SdtLocked));
assert_eq!(intro.placeholder, "DefaultPlaceholder_1");
@@ -119,13 +125,15 @@ fn every_kind_reads_with_its_properties() {
let ranges = d.control_ranges(StoryRef::Body);
let r = ranges.iter().find(|r| r.control.title == "Intro").unwrap();
assert_eq!(d.control_text(r), "Write the introduction.\nSecond paragraph");
}
assert_eq!(by_id(&d, 7).kind, ControlKind::Text { multi_line: true });
assert!(!by_id(&d, 7).block);
fn assert_control_kinds(d: &Document) {
assert_eq!(by_id(d, 7).kind, ControlKind::Text { multi_line: true });
assert!(!by_id(d, 7).block);
assert!(
matches!(by_id(&d, 8).kind, ControlKind::CheckBox { checked: true, checked_char: '\u{2612}', ref checked_font, .. } if checked_font == "Symbola")
matches!(by_id(d, 8).kind, ControlKind::CheckBox { checked: true, checked_char: '\u{2612}', ref checked_font, .. } if checked_font == "Symbola")
);
let dd = by_id(&d, 9);
let dd = by_id(d, 9);
assert_eq!(dd.lock, ControlLock::ContentLocked);
assert_eq!(
dd.kind,
@@ -135,14 +143,17 @@ fn every_kind_reads_with_its_properties() {
}
);
assert!(
matches!(by_id(&d, 10).kind, ControlKind::Date { ref full_date, ref format, .. } if full_date == "2026-10-11T00:00:00Z" && format == "d.M.yyyy")
matches!(by_id(d, 10).kind, ControlKind::Date { ref full_date, ref format, .. } if full_date == "2026-10-11T00:00:00Z" && format == "d.M.yyyy")
);
assert_eq!(by_id(&d, 10).extra.len(), 1, "data binding kept");
assert_eq!(by_id(&d, 12).kind, ControlKind::RichText);
assert!(matches!(by_id(&d, 13).kind, ControlKind::Gallery { list: false, ref gallery, unique: true, .. } if gallery == "Cover Pages"));
assert!(matches!(by_id(&d, 20).kind, ControlKind::RepeatingSection { ref title, .. } if title == "Items"));
assert_eq!(by_id(&d, 21).kind, ControlKind::RepeatingSectionItem);
assert_eq!(by_id(&d, 23).kind, ControlKind::Picture);
assert_eq!(by_id(d, 10).extra.len(), 1, "data binding kept");
assert_eq!(by_id(d, 12).kind, ControlKind::RichText);
assert!(matches!(by_id(d, 13).kind, ControlKind::Gallery { list: false, ref gallery, unique: true, .. } if gallery == "Cover Pages"));
assert!(matches!(by_id(d, 20).kind, ControlKind::RepeatingSection { ref title, .. } if title == "Items"));
assert_eq!(by_id(d, 21).kind, ControlKind::RepeatingSectionItem);
assert_eq!(by_id(d, 23).kind, ControlKind::Picture);
}
fn assert_table_controls(d: &Document) {
// Row-level controls sit on the rows, the cell-level one on its cell, the table's on the table.
let tables: Vec<_> = d.body.iter().filter_map(|b| b.as_table()).collect();
assert_eq!(tables.len(), 2);
+75 -1
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 =
@@ -495,6 +557,12 @@ fn styles_numbering_settings_notes_comments_from_parts() {
("docProps/core.xml", core.as_bytes()),
]);
let d = wordcraft_docx::read(&bytes).unwrap();
assert_imported_styles(&d);
assert_imported_numbering_and_settings(&d);
assert_imported_body_and_metadata(&d);
}
fn assert_imported_styles(d: &Document) {
// Styles: localized default id renamed to Normal; display names mapped.
let h = d.styles.get("1").unwrap();
assert_eq!(h.name, "Heading 1");
@@ -511,6 +579,9 @@ fn styles_numbering_settings_notes_comments_from_parts() {
assert_eq!(g.header_chr.bold, Some(true));
assert_eq!(d.settings.theme_colors[4], wordcraft_doc::Rgb(0x44, 0x72, 0xC4));
assert_eq!(d.settings.major_font, "Major Face");
}
fn assert_imported_numbering_and_settings(d: &Document) {
// Numbering.
let lv = d.numbering.level(2, 0).unwrap();
assert_eq!((lv.start, lv.text.as_str(), lv.indent, lv.hanging, lv.align), (3, "%1)", 36.0, 18.0, Align::Right));
@@ -525,8 +596,11 @@ fn styles_numbering_settings_notes_comments_from_parts() {
// A hostile start is clamped, an unknown position left out.
let fp = d.settings.footnote_pr;
assert_eq!((fp.pos, fp.num_start, fp.num_restart), (None, Some(1), Some(wordcraft_doc::section::NoteRestart::EachPage)));
}
fn assert_imported_body_and_metadata(d: &Document) {
// Body.
let p = paras(&d);
let p = paras(d);
assert_eq!(p[0].props.style.as_deref(), Some("1"));
assert_eq!(p[0].props.numbering, Some(wordcraft_doc::props::NumRef { num: 2, level: 1 }));
assert_eq!(p[0].props_of_char(4).font.as_deref(), Some("Major Face"));
+50
View File
@@ -509,3 +509,53 @@ fn hostile_glossaries_never_panic() {
let doc = read(two);
assert_eq!(doc.glossary.len(), 1);
}
#[test]
fn a_private_save_escapes_the_attribute_values_it_rewrites() {
// A value read from single quotes holds a bare `"`; the privacy rewrite wrote it back inside
// double quotes unescaped, which is not XML (review of #3872).
let quoted = r#"<w:docPart><w:docPartPr><w:name w:val='Say "hi" &amp; &lt;go&gt;'/><w:category><w:name w:val="General"/><w:gallery w:val="docParts"/></w:category></w:docPartPr><w:docPartBody><w:p><w:ins w:id="1" w:author="Quill Ramsey" w:date="2026-03-04T05:06:07Z"><w:r><w:t>Added clause</w:t></w:r></w:ins></w:p></w:docPartBody></w:docPart>"#;
let bytes = package(
&[("rId7", GLOSSARY_REL, "glossary/document.xml")],
&[
("[Content_Types].xml", content_types(&[("/word/glossary/document.xml", GLOSSARY_CT)])),
("word/glossary/document.xml", glossary_xml(quoted)),
],
);
let doc = wordcraft_docx::read(&bytes).unwrap();
let name = doc.glossary.first().map(|e| e.name.clone()).expect("an entry");
assert_eq!(name, r#"Say "hi" & <go>"#);
let mut private = doc.clone();
private.settings.remove_personal_info = true;
let saved = wordcraft_docx::write(&private).unwrap();
let files = unzip(&saved);
let g = text(&files, &glossary_target(&files).expect("a glossary"));
assert!(!g.contains("Quill Ramsey"), "{g}");
let again = wordcraft_docx::read(&saved).unwrap();
assert_eq!(again.glossary.first().map(|e| e.name.as_str()), Some(name.as_str()), "{g}");
}
#[test]
fn a_private_save_keeps_character_references_in_attribute_values_as_written() {
// `&#10;` and `&#9;` are a newline and a tab only as references; written as the raw
// characters they read back as spaces (review of #3872).
let entry = r#"<w:docPart><w:docPartPr><w:name w:val="Clause"/><w:category><w:name w:val="General"/><w:gallery w:val="docParts"/></w:category><w:description w:val="line1&#10;line2&#9;end"/></w:docPartPr><w:docPartBody><w:p><w:ins w:id="1" w:author="Quill Ramsey" w:date="2026-03-04T05:06:07Z"><w:r><w:t>Added clause</w:t></w:r></w:ins></w:p></w:docPartBody></w:docPart>"#;
let bytes = package(
&[("rId7", GLOSSARY_REL, "glossary/document.xml")],
&[
("[Content_Types].xml", content_types(&[("/word/glossary/document.xml", GLOSSARY_CT)])),
("word/glossary/document.xml", glossary_xml(entry)),
],
);
let doc = wordcraft_docx::read(&bytes).unwrap();
let before = doc.glossary.first().map(|e| e.description.clone()).expect("an entry");
assert_eq!(before, "line1\nline2\tend");
let mut private = doc.clone();
private.settings.remove_personal_info = true;
let saved = wordcraft_docx::write(&private).unwrap();
let files = unzip(&saved);
let g = text(&files, &glossary_target(&files).expect("a glossary"));
assert!(!g.contains("Quill Ramsey") && g.contains(r#"w:val="line1&#10;line2&#9;end""#), "{g}");
let again = wordcraft_docx::read(&saved).unwrap();
assert_eq!(again.glossary.first().map(|e| e.description.as_str()), Some(before.as_str()));
}
+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);
}
}
+88
View File
@@ -403,3 +403,91 @@ fn a_part_reached_under_many_spellings_is_kept_and_written_once() {
let written: usize = files.values().map(Vec::len).sum();
assert!(written < 4 * 64 * 1024, "{written} bytes written");
}
#[test]
fn embedded_fonts_load_and_save_when_the_package_spells_the_font_table_in_capitals() {
// Part names are case-insensitive: a package storing `WORD/FONTTABLE.XML` and its fonts in
// capitals, related in lower case, loaded no fonts, and a save dropped the table and every
// font (review of #3872).
let table = format!(
r#"<?xml version="1.0" encoding="UTF-8"?><w:fonts xmlns:w="{W}" xmlns:r="{R_NS}"><w:font w:name="Quill Sans"><w:embedRegular r:id="rId1"/></w:font></w:fonts>"#
);
let table_rels = rels(&[("rId1", &format!("{REL}/font"), "fonts/font1.odttf")]);
let font_bytes = vec![9u8; 2048];
let ct = CT.replace(
"<Default Extension=\"jpeg\"",
"<Default Extension=\"odttf\" ContentType=\"application/vnd.openxmlformats-officedocument.obfuscatedFont\"/><Default Extension=\"jpeg\"",
);
let mut entries: Vec<(&str, Vec<u8>)> = vec![
("_rels/.rels", rels(&[("rIdMain", &format!("{REL}/officeDocument"), "word/document.xml")])),
("[Content_Types].xml", ct.into_bytes()),
(
"word/document.xml",
format!(r#"<w:document xmlns:w="{W}"><w:body><w:p><w:r><w:t>Body</w:t></w:r></w:p></w:body></w:document>"#).into_bytes(),
),
("word/_rels/document.xml.rels", rels(&[("rId1", &format!("{REL}/fontTable"), "fontTable.xml")])),
];
entries.push(("WORD/FONTTABLE.XML", table.into_bytes()));
entries.push(("WORD/_RELS/FONTTABLE.XML.RELS", table_rels));
entries.push(("WORD/FONTS/FONT1.ODTTF", font_bytes.clone()));
let doc = wordcraft_docx::read(&zip(&entries)).unwrap();
let fonts = wordcraft_docx::embedded_fonts(&doc);
assert_eq!(fonts.len(), 1, "the font loads");
assert_eq!(fonts[0].data, font_bytes);
// And through a save and a reopen.
let saved = wordcraft_docx::write(&doc).unwrap();
let files = unzip(&saved);
assert!(files.keys().any(|k| k.to_ascii_lowercase().ends_with("fonttable.xml")), "{:?}", files.keys());
let again = wordcraft_docx::read(&saved).unwrap();
let fonts = wordcraft_docx::embedded_fonts(&again);
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));
}
+109 -40
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),
@@ -2262,9 +2264,46 @@ fn bibliography_sources_round_trip() {
});
let bytes = wordcraft_docx::write(&d).expect("write");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
// Verify package-root customXml parts exist
assert_bibliography_parts(&bytes);
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());
assert!(zip.by_name("customXml/itemProps1.xml").is_ok());
assert!(zip.by_name("customXml/_rels/item1.xml.rels").is_ok());
@@ -2281,6 +2320,12 @@ fn bibliography_sources_round_trip() {
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_bibliography_types(&item_xml);
assert_bibliography_people(&item_xml);
assert_bibliography_details(&item_xml);
}
fn assert_bibliography_types(item_xml: &str) {
assert!(item_xml.contains("SelectedStyle=\"\\APA.XSL\""), "{item_xml}");
assert!(item_xml.contains("StyleName=\"APA\""), "{item_xml}");
assert!(item_xml.contains("<b:SourceType>Book</b:SourceType>"), "{item_xml}");
@@ -2288,12 +2333,18 @@ fn bibliography_sources_round_trip() {
assert!(item_xml.contains("<b:SourceType>InternetSite</b:SourceType>"), "{item_xml}");
assert!(item_xml.contains("<b:SourceType>BookSection</b:SourceType>"), "{item_xml}");
assert!(item_xml.contains("<b:SourceType>Misc</b:SourceType>"), "{item_xml}");
}
fn assert_bibliography_people(item_xml: &str) {
assert!(item_xml.contains("<b:Author><b:Author><b:NameList><b:Person>"), "{item_xml}");
assert!(item_xml.contains("<b:Last>Rivera</b:Last><b:First>Alex</b:First>"), "{item_xml}");
assert!(item_xml.contains("<b:Last>Smith</b:Last><b:First>John</b:First><b:Middle>David</b:Middle>"), "{item_xml}");
assert!(item_xml.contains("<b:Last>Solo</b:Last>"), "{item_xml}");
assert!(item_xml.contains("<b:Editor>"), "{item_xml}");
assert!(item_xml.contains("<b:Last>Johnson</b:Last><b:First>Robert</b:First>"), "{item_xml}");
}
fn assert_bibliography_details(item_xml: &str) {
assert!(item_xml.contains("<b:Edition>2nd</b:Edition>"), "{item_xml}");
assert!(item_xml.contains("<b:Month>10</b:Month>"), "{item_xml}");
assert!(item_xml.contains("<b:Day>15</b:Day>"), "{item_xml}");
@@ -2303,50 +2354,68 @@ fn bibliography_sources_round_trip() {
assert!(item_xml.contains("<b:MonthAccessed>05</b:MonthAccessed>"), "{item_xml}");
assert!(item_xml.contains("<b:DayAccessed>15</b:DayAccessed>"), "{item_xml}");
assert!(item_xml.contains("<b:Accessed>2024-05-15</b:Accessed>"), "{item_xml}");
}
let back = wordcraft_docx::read(&bytes).expect("read");
fn assert_bibliography_sources_round_trip(bytes: &[u8]) {
let back = wordcraft_docx::read(bytes).expect("read");
assert_eq!(back.sources.len(), 5);
assert_eq!(back.sources[0].tag, "Rivera2021");
assert_eq!(back.sources[0].kind, "book");
assert_eq!(back.sources[0].author, "Rivera, Alex; Chen, Mei");
assert_eq!(back.sources[0].title, "Shared Spaces");
assert_eq!(back.sources[0].year, "2021");
assert_eq!(back.sources[0].publisher, "Harbor Books");
assert_eq!(back.sources[0].city, "Portland");
assert_eq!(back.sources[0].editor, "Johnson, Robert");
assert_eq!(back.sources[0].edition, "2nd");
assert_eq!(back.sources[0].month, "10");
assert_eq!(back.sources[0].day, "15");
assert_bibliography_source_0(&back.sources[0]);
assert_bibliography_source_1(&back.sources[1]);
assert_bibliography_source_2(&back.sources[2]);
assert_bibliography_source_3(&back.sources[3]);
assert_bibliography_source_4(&back.sources[4]);
}
assert_eq!(back.sources[1].tag, "Smith2023");
assert_eq!(back.sources[1].kind, "article");
assert_eq!(back.sources[1].author, "Smith, John David");
assert_eq!(back.sources[1].title, "Modern Typography");
assert_eq!(back.sources[1].journal, "Design Quarterly");
assert_eq!(back.sources[1].volume, "12");
assert_eq!(back.sources[1].pages, "45-60");
assert_eq!(back.sources[1].url, "https://example.com/typo");
assert_eq!(back.sources[1].issue, "4");
assert_eq!(back.sources[1].doi, "10.1000/182");
fn assert_bibliography_source_0(source: &Source) {
assert_eq!(source.tag, "Rivera2021");
assert_eq!(source.kind, "book");
assert_eq!(source.author, "Rivera, Alex; Chen, Mei");
assert_eq!(source.title, "Shared Spaces");
assert_eq!(source.year, "2021");
assert_eq!(source.publisher, "Harbor Books");
assert_eq!(source.city, "Portland");
assert_eq!(source.editor, "Johnson, Robert");
assert_eq!(source.edition, "2nd");
assert_eq!(source.month, "10");
assert_eq!(source.day, "15");
}
assert_eq!(back.sources[2].tag, "WebSite2024");
assert_eq!(back.sources[2].kind, "website");
assert_eq!(back.sources[2].author, "Solo");
assert_eq!(back.sources[2].title, "Open Fonts");
assert_eq!(back.sources[2].url, "https://example.org");
assert_eq!(back.sources[2].accessed, "2024-05-15");
fn assert_bibliography_source_1(source: &Source) {
assert_eq!(source.tag, "Smith2023");
assert_eq!(source.kind, "article");
assert_eq!(source.author, "Smith, John David");
assert_eq!(source.title, "Modern Typography");
assert_eq!(source.journal, "Design Quarterly");
assert_eq!(source.volume, "12");
assert_eq!(source.pages, "45-60");
assert_eq!(source.url, "https://example.com/typo");
assert_eq!(source.issue, "4");
assert_eq!(source.doi, "10.1000/182");
}
assert_eq!(back.sources[3].tag, "Chapter2025");
assert_eq!(back.sources[3].kind, "bookSection");
assert_eq!(back.sources[3].author, "Lee, Harper");
assert_eq!(back.sources[3].title, "Chapter One");
assert_eq!(back.sources[3].journal, "Anthology of Prose");
assert_eq!(back.sources[3].pages, "1-20");
fn assert_bibliography_source_2(source: &Source) {
assert_eq!(source.tag, "WebSite2024");
assert_eq!(source.kind, "website");
assert_eq!(source.author, "Solo");
assert_eq!(source.title, "Open Fonts");
assert_eq!(source.url, "https://example.org");
assert_eq!(source.accessed, "2024-05-15");
}
assert_eq!(back.sources[4].tag, "Misc2026");
assert_eq!(back.sources[4].kind, "other");
assert_eq!(back.sources[4].title, "Archive Manuscript");
assert_eq!(back.sources[4].city, "Oxford");
fn assert_bibliography_source_3(source: &Source) {
assert_eq!(source.tag, "Chapter2025");
assert_eq!(source.kind, "bookSection");
assert_eq!(source.author, "Lee, Harper");
assert_eq!(source.title, "Chapter One");
assert_eq!(source.journal, "Anthology of Prose");
assert_eq!(source.pages, "1-20");
}
fn assert_bibliography_source_4(source: &Source) {
assert_eq!(source.tag, "Misc2026");
assert_eq!(source.kind, "other");
assert_eq!(source.title, "Archive Manuscript");
assert_eq!(source.city, "Oxford");
}
#[test]
+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();
}
}
+64
View File
@@ -0,0 +1,64 @@
//! Export timing: saves a long generated document (the built-in sample document repeated) through
//! the real save path, the way `file.save` / `file.export` write it, and prints wall times.
//!
//! `cargo run --release -p wordcraft-engine --example export_bench -- [ext] [copies] [runs] [out]`
//!
//! Defaults: `pdf`, 40 copies (about 100 pages), 7 runs. With `out` the last run's bytes are
//! written there, to compare the output of two builds.
use std::sync::Arc;
use std::time::Instant;
use wordcraft_engine::doc::props::CharProps;
use wordcraft_engine::doc::{Block, Document};
use wordcraft_engine::{io, sample};
/// The sample document's body `copies` times, each paragraph ending in its copy number so no two
/// copies share text (as the chapters of a real long document don't).
fn long_document(copies: usize) -> Document {
let mut d = sample::sample_document();
// A fixed creation date: the PDF's dates (and the ID derived from them) stay the same from run to run.
d.core.created = "2026-01-01T00:00:00Z".into();
let one: Vec<Block> = d.body.iter().map(|b| (**b).clone()).collect();
d.body.clear();
for i in 0..copies {
for b in &one {
let mut b = b.clone();
if let Block::Para(p) = &mut b {
let at = p.len();
let _ = p.insert_text(at, &format!(" {i}"), &CharProps::default());
}
d.body.push(Arc::new(b));
}
}
d
}
fn main() -> Result<(), String> {
let args: Vec<String> = std::env::args().skip(1).collect();
let ext = args.first().map_or("pdf", String::as_str);
let copies = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(40);
let runs = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(7usize).max(1);
let doc = long_document(copies);
let name = format!("bench.{ext}");
let mut ms = Vec::with_capacity(runs);
let mut bytes = Vec::new();
for _ in 0..runs {
let t = Instant::now();
bytes = io::save_bytes(&name, &doc)?;
ms.push(t.elapsed().as_secs_f64() * 1000.0);
}
let mut sorted = ms.clone();
sorted.sort_by(f64::total_cmp);
let median = sorted.get(sorted.len() / 2).copied().unwrap_or_default();
let min = sorted.first().copied().unwrap_or_default();
println!(
"{ext}: {copies} copies, {} bytes, runs {:?} ms, median {median:.1} ms, min {min:.1} ms",
bytes.len(),
ms.iter().map(|m| m.round()).collect::<Vec<_>>()
);
if let Some(out) = args.get(3) {
std::fs::write(out, &bytes).map_err(|e| e.to_string())?;
}
Ok(())
}
@@ -1,5 +1,5 @@
use super::generated::page_of;
use super::*;
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 }));
+9 -6
View File
@@ -1,8 +1,11 @@
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::*;
use super::{
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
/// captions that label's SEQ field numbers, `\a "<label>"` lists them without their label and
@@ -83,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 {
@@ -253,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 }));
}
@@ -277,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;
+15 -8
View File
@@ -1,5 +1,11 @@
use super::Source;
use super::styles::{Cite, Name, Names, style};
use super::*;
/// 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(';')
@@ -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(),
+15 -9
View File
@@ -1,7 +1,7 @@
use super::generated::update_generated;
use super::render::{authors, cite};
use super::render::{authors, cite, corporate};
use super::styles::{CiteStyle, Order, style};
use super::*;
use super::{CmdError, CmdResult, Document, InlineObject, Selection, Session, Source, StoryRef, Value, delete_selection, json, p, sort_key};
/// The tag a new source gets, as Word makes it: the first three letters of the first author's
/// name and the last two digits of the year (`Smi20`), with `1`, `2`… added while it is taken.
@@ -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
+4 -1
View File
@@ -12,6 +12,7 @@ use serde_json::{Value, json};
use wordcraft_doc::edit::Fragment;
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Block, Paragraph, Pos};
use wordcraft_layout::hit::ColumnSelection;
use super::{parse_pos, pos_json};
use crate::{CmdError, CmdResult, ColumnBlock, CommandSpec, Selection, Session, p};
@@ -96,7 +97,7 @@ fn make(s: &mut Session, a: Pos, b: Pos, xa: f32, xb: f32) {
let (xa, xb) = (if xa.is_finite() { xa } else { 0.0 }, if xb.is_finite() { xb } else { 0.0 });
let (left, right) = (xa.min(xb), xa.max(xb));
let l = s.layout();
let segments = l.column_segments(&s.doc, &a, &b, left, right, s.page_hint, MAX_LINES);
let segments = l.column_segments(&s.doc, ColumnSelection { start: &a, end: &b, left, right, page_hint: s.page_hint, max_lines: MAX_LINES });
s.sel = Selection { anchor: a, focus: b };
s.column = Some(ColumnBlock { sel: s.sel.clone(), anchor_x: xa, focus_x: xb, segments, cells: None });
s.pending = None;
@@ -179,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)?;

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