Compare commits

..
Author SHA1 Message Date
Brandon Thomas cd9faa677b Merge remote-tracking branch 'origin/main' into reland-c 2026-10-10 16:07:08 -04:00
Brandon ThomasandClaude Opus 5.5 6b0eafd4be Manage Styles: mark table styles with ⊞, which the interface fonts have (▦ has no glyph, #305's check)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:56:17 -04:00
Brandon ThomasandClaude Opus 5.5 5a73089f10 Reland: catalog coverage 390/431 (90.5%) in ROADMAP and target-app parity
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:44:45 -04:00
Brandon Thomas 53c6dd960c Merge remote-tracking branch 'origin/main' into reland-c
# Conflicts:
#	crates/ui-egui/src/i18n/sr-latn.tsv
#	crates/ui-egui/src/i18n/sr.tsv
2026-10-10 15:39:49 -04:00
Brandon ThomasandClaude Opus 5.5 a87eadc5a4 Reland #264 #263 #270 #271 #267 #280: merge the integrated stack (fix-280) into main
The stack's squash merges on GitHub came out empty; this brings their code back. Conflicts
resolved keeping both sides: table-style regions extend main's region_of/region_text (#287)
with last column and column bands; charts/diagrams (Graphic, #292) count as drawings in the
stack's frame/float_mut/set_size helpers but stay frozen and can't be grouped; group reading
sits after main's chart/diagram URI check; i18n catalogs unioned (duplicate Preview dropped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:39:35 -04:00
Brandon ThomasandClaude Opus 5.5 f1b9079911 Merge #267 stack (main, clipboard pane, table styles, Manage Styles, paste pictures, Asian typography, Group) into #280 (Shape Effects)
#280 now builds on #267's object model: selected_all() uses InlineObject::is_drawing (groups
included); group members carry effects everywhere #267 handles them (docx writer's shared
wsp() writes a:effectLst for standalone shapes and group members alike, the reader's read_wsp
reads it for both, layout's float_items/display draw members with their effects); the
groups_round_trip test gives a member a shadow and soft edges and checks they come back.
shape.effects also applies to shapes added to the selection with Shift+click (#267's
also_selected). Serbian rows for the Shape Effects strings; parity regenerated (390/431) and
floor raised to 90.4%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:26:31 -04:00
Brandon ThomasandClaude Opus 5.5 70cfcfcc91 Merge #271 stack (main, clipboard pane, table styles, Manage Styles, paste pictures, Asian typography) into #267 (Group)
Session: also_selected kept next to main's column/column_mode (constructor, open, snapshot);
Serbian rows for Add Object to Selection and Ungroup; parity regenerated (389/431) and floor
raised to 90.2%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:23:20 -04:00
Brandon ThomasandClaude Opus 5.5 8ba1beb1ff Asian typography: test that kinsoku keeps a manual line break before a closing bracket
Manual line breaks end the line by cluster kind, not by break opportunity, so kinsoku's filter
can't drop one before 」; pin that with a layout test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:21:35 -04:00
Brandon ThomasandClaude Opus 5.5 e6402acf33 Merge #272 stack (main, clipboard pane, table styles, Manage Styles, paste pictures) into #271 (Asian Typography)
dialogs.rs: both apply_asian and Manage Styles' helpers kept; Serbian rows for the Asian
Typography strings; parity regenerated (388/431) and floor raised to 90.0%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:21:35 -04:00
Brandon ThomasandClaude Opus 5.5 306534ea01 Paste pictures: Mod+Shift+V (Paste Formatting) no longer pastes a clipboard picture
The key-release fallback that pastes the system clipboard's picture treated every Mod+V release as
Paste. It now requires plain Mod+V or the Paste key (no Shift, no Alt), with a unit test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:19:16 -04:00
Brandon ThomasandClaude Opus 5.5 7e83021859 Merge #270 stack (main, clipboard pane, table styles, Manage Styles) into #272 (paste pictures)
CanvasState: both #272's pasted flag and main's wheel/scroll fields kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:13:33 -04:00
Brandon ThomasandClaude Opus 5.5 ff5045450f Manage Styles: deleting a table style runs Table Design's table style deletion
styles.delete on a table style now calls table_style::delete (#263): its tables take Table Grid,
styles based on it keep their look, and Word's built-in table styles are refused even when a
.docx marks them custom (the Manage Styles list reports them built-in too). Document::restyle no
longer retargets tables' own styles, so there is one place that does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:13:07 -04:00
Brandon ThomasandClaude Opus 5.5 cf6b9f549c Merge #263 stack (main, clipboard pane, table styles) into #270 (Manage Styles)
Dialog enum/name/title/body: both Manage Styles and New/Modify Table Style kept (NewStyle keeps
#270's back field); module doc and Styles pane buttons unioned; Serbian rows for the Manage
Styles strings; parity regenerated (387/431) and floor raised to 89.7%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:09:46 -04:00
Brandon ThomasandClaude Opus 5.5 d925423d66 Table styles: Table Grid and Normal Table can't be deleted even when a .docx marks them custom
A .docx can set w:customStyle on any style, which made Word's built-in table styles look custom
and deletable. table.deleteStyle and the ribbon's Delete Table Style now also recognise them by
id and name, so no table is left on a style that's gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:07:48 -04:00
Brandon ThomasandClaude Opus 5.5 d3c881cb8f Merge #264 stack (main + clipboard pane) into #263 (table styles)
#263 carried #256's commit, already on main as d8f7195; main's version is kept and only #263's
own change (2fefdeb) is applied on top. Six-region TableStyle tuple in layout, union of catalog
rows (duplicates dropped), Serbian rows for Delete Table Style and Preview, parity regenerated
(386/431).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:06:03 -04:00
Brandon ThomasandClaude Opus 5.5 836011eb02 Merge origin/main into #264 (clipboard pane)
Regenerate docs/parity.md (385/430), raise the parity floor to 89.5%, add Serbian (sr, sr-latn)
rows for the clipboard pane strings and drop the now-duplicate "Clear All" catalog rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:03:42 -04:00
vangel76andClaude Opus 5.5 2f8f304fc9 Shape Format: Shape Effects — shadow, glow and soft edges (#275)
Shapes and text boxes get an effects model (outer shadow with colour,
transparency, blur, distance and angle; glow with colour, size and
transparency; soft-edge radius), the shape.effects command (presets,
custom values or null per effect, applied to every selected shape,
undoable, hostile numbers clamped), screen and PDF rendering with the
same banded approximation of a blur, DOCX a:effectLst read/write
(outerShdw, glow, softEdge) with a matching wp:effectExtent, and a
contextual Shape Format tab with Shape Fill, Shape Outline and Shape
Effects (Shadow, Glow, Soft Edges submenus) plus Arrange.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:35:05 +02:00
vangel76andClaude Opus 5.5 26e2b313f1 Paste pictures from the system clipboard (screenshots, Copy Image, copied files) (#45)
egui's paste event only carries text, so a clipboard holding just a picture
sent no paste at all on Ctrl+V, and the ribbon/context-menu Paste only knew
WordCraft's own copy ("the clipboard is empty").

Desktop: the app reads the clipboard's picture through a new
Services::clipboard_picture (arboard, already in the build via egui-winit):
copied files first, else image pixels. Mod+V whose release arrives without a
paste event, and Paste from the ribbon or menus, insert that picture through
insert.picture (one undo step); text naming copied picture files inserts the
pictures. Pixel sizes are hostile: at most 16,000 px a side and 50 Mpx, the
buffer must match, files at most 200 MB.

Web: a document paste listener reads image files from the paste event when
there's no text and delivers them through the inbox like dropped pictures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:30:06 +02:00
vangel76andClaude Opus 5.5 2fefdebcb1 Table styles: every region, a live preview, delete, custom styles first (#146)
Follow-up to the custom table styles work: the New/Modify Table Style
dialog now covers every region a table style formats, shows what the
style will look like, and table styles can be deleted.

- engine: table.newStyle / table.modifyStyle take lastRow (total row),
  firstColumn, lastColumn and bandedColumns regions with the same props
  as the others. New table.deleteStyle removes a custom table style:
  built-in ones are refused, styles based on it are re-based on its own
  base with its formatting folded in (so they look the same), and tables
  using it fall back to Table Grid. Undoable like any command.
- doc/layout: table styles gain fill and cell borders for the first and
  last column and total row, and fill, text and borders for column
  bands; layout applies them in the standard order (column bands, row
  bands, first/last column, header/total row).
- docx: firstCol/lastCol/lastRow/band1Vert shading and cell borders are
  read and written.
- ui: the dialog's "Apply formatting to" lists all seven regions and a
  small live preview table shows the pending changes; Styles menu gets
  Delete Table Style (disabled for built-ins); the gallery shows the
  document's own table styles first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:26:13 +02:00
vangel76andClaude Opus 5.5 8aa4a25245 Create and modify your own table styles, saved in the .docx (#146)
New Table Style and Modify Table Style (Table Design > Table Styles) make
real table styles in the document's style sheet: name, based on, and
formatting for the whole table, the header row and banded rows (borders,
fill, bold, font colour). The table at the caret takes the new style, it
shows in the gallery, and other tools that read table styles by name
(pandoc's custom-style, for one) see it.

- engine: table.newStyle / table.modifyStyle with documented params
  (regions take borders, fill, bold, italic, colour, size, font; bandSize;
  para); table.style only accepts table styles now.
- doc/layout: table styles gain header-row and row-band cell borders,
  row-band character formatting and rows per band, applied by layout.
- docx: tblStyleRowBandSize and firstRow/band1Horz tcBorders and rPr are
  read and written, so custom styles round-trip.
- ui: a compact dialog from the Styles menu next to the gallery; table
  style tiles redraw when a style changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:22:23 +02:00
vangel76andClaude Opus 5.5 cad7808d0a Manage Styles: list, preview, delete and show or hide styles
Home › Styles › Manage Styles (styles.manage) opens a dialog from the
Styles pane listing the document's paragraph, character, linked and table
styles, sorted as recommended or alphabetically, with a preview line and a
description (type, formatting it sets, what it is based on). From there:
Modify… and New Style… reuse the existing dialogs and come back, Delete
removes custom styles, and two checkboxes show the style in the Styles
gallery (w:qFormat) or hide it from style lists (w:semiHidden).

styles.manage returns the list for scripts (name, type, builtIn,
inGallery, hidden, basedOn, priority, own formatting). styles.delete now
moves paragraphs, runs and tables that used the style to its base (in
every story), rebases styles built on it and takes a custom linked
character half with it; built-in styles are refused and it is undoable.
New styles.setVisibility {style, gallery?, hidden?} sets the flags, which
round-trip through .docx.

Fixes #266

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:18:25 +02:00
vangel76andClaude Opus 5.5 147a055468 Paragraph: Asian Typography settings, with kinsoku and mid-word Latin wrapping in layout (#269)
Paragraphs gain the Asian Typography flags (line-breaking rules, wrapping
Latin text in the middle of a word, hanging punctuation, compressing
punctuation at the start of a line, and automatic space between Asian and
Latin text and between Asian text and numbers). They inherit through styles,
round-trip through .docx pPr, are set by the new para.asianTypography
command and appear on a new Asian Typography tab of the Paragraph dialog.

Layout applies the kinsoku lists (no line starts with closing East Asian
punctuation or ends with opening punctuation), breaks between any East Asian
characters when kinsoku is off, and breaks Latin words anywhere when word
wrap is off. Text without East Asian characters breaks as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:07:29 +02:00
vangel76andClaude Opus 5.5 eed5d75b40 Layout > Arrange > Group: group pictures, shapes and text boxes into one object
Shift+click (or Ctrl+click) adds pictures, shapes and text boxes to the
selection; Group (arrange.group) combines the selected floating objects
into one group object that moves, wraps and resizes as one, with its
members keeping their places and scaling with the group. Ungroup
(arrange.ungroup) splits it back, where the group showed them, and
leaves them selected so Group puts them back together.

- Model: InlineObject::Group with the members' coordinate space (like
  DrawingML a:chExt), so resizing the group scales its members.
- Layout draws the members inside the group's frame; hit testing
  selects the group; text boxes in a group show their text.
- DOCX writes wpg:wgp with a:chOff/a:chExt and each member's a:xfrm,
  and reads Word's groups (including inside mc:AlternateContent and
  nested groups, flattened).
- select.addObject makes multi-selection scriptable (pos or Selection
  Pane index).

Parity 378 -> 379 of 428 (88.6%); floor raised to 88.5%.

Fixes #265

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:01:04 +02:00
vangel76andClaude Opus 5.5 7a44b18072 Clipboard pane: collect the last 24 copies and cuts and paste any of them again
Copy and Cut now also keep each item (rich fragment plus plain text) in a
session list of up to 24, newest first. Items over ~4 MB (estimated) are
skipped and the list stays under ~24 MB in total; copying the same thing
twice in a row adds it once. The system clipboard is untouched.

New commands: edit.clipboardPane (toggle the pane, also the Clipboard
group's dialog launcher), edit.clipboardItems (list with previews),
edit.pasteClipboardItem {index}, edit.pasteAllClipboard,
edit.deleteClipboardItem {index} and edit.clearClipboard. Pasting goes
through the normal paste path, so it is one undo step and respects Track
Changes. The pane lists the items with a short preview; clicking one pastes
it at the caret, and each has Delete, plus Paste All and Clear All.

Parity: 378 -> 379 of 428 features.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:21:41 +02:00
44 changed files with 101 additions and 2212 deletions
Generated
-159
View File
@@ -121,17 +121,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aes"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32"
dependencies = [
"cipher",
"cpubits",
"cpufeatures",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -499,15 +488,6 @@ version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "block2"
version = "0.5.1"
@@ -692,16 +672,6 @@ dependencies = [
"libc",
]
[[package]]
name = "cipher"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "clipboard-win"
version = "5.4.1"
@@ -711,12 +681,6 @@ dependencies = [
"error-code",
]
[[package]]
name = "cmov"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "codespan-reporting"
version = "0.13.1"
@@ -772,12 +736,6 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -827,21 +785,6 @@ dependencies = [
"libm",
]
[[package]]
name = "cpubits"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -891,24 +834,6 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "ctutils"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574"
dependencies = [
"cmov",
]
[[package]]
name = "cursor-icon"
version = "1.2.0"
@@ -939,18 +864,6 @@ dependencies = [
"rayon",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
"ctutils",
]
[[package]]
name = "dispatch"
version = "0.2.0"
@@ -1493,11 +1406,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -1828,24 +1739,6 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
dependencies = [
"digest",
]
[[package]]
name = "hybrid-array"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [
"typenum",
]
[[package]]
name = "icu_collections"
version = "2.3.0"
@@ -1995,15 +1888,6 @@ dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "inout"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
dependencies = [
"hybrid-array",
]
[[package]]
name = "is-docker"
version = "0.2.0"
@@ -3534,28 +3418,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "sha1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "2.0.1"
@@ -4023,12 +3885,6 @@ dependencies = [
"rustc-hash 2.1.3",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uds_windows"
version = "1.2.1"
@@ -5071,21 +4927,13 @@ dependencies = [
name = "wordcraft-docx"
version = "0.4.0"
dependencies = [
"aes",
"base64",
"cfb",
"getrandom 0.3.4",
"hmac",
"image",
"log",
"proptest",
"quick-xml 0.38.4",
"sha1",
"sha2",
"thiserror 2.0.21",
"wordcraft-doc",
"wordcraft-geom",
"zeroize",
"zip",
]
@@ -5111,7 +4959,6 @@ dependencies = [
"wordcraft-pdf",
"wordcraft-proof",
"wordcraft-render",
"zeroize",
"zip",
]
@@ -5545,12 +5392,6 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
[[package]]
name = "zerotrie"
version = "0.2.5"
-8
View File
@@ -52,14 +52,6 @@ krilla = { version = "0.8", features = ["pdf"] }
quick-xml = "0.38"
zip = { version = "3", default-features = false, features = ["deflate"] }
cfb = "0.15"
# Password-protected (agile-encrypted) Word documents: pure-Rust RustCrypto primitives.
aes = "0.9"
sha1 = "0.11"
sha2 = "0.11"
hmac = "0.13"
base64 = "0.22"
getrandom = "0.3"
zeroize = "1"
# ui
egui = "0.36"
-6
View File
@@ -234,12 +234,6 @@ Remove-Item Env:WGPU_BACKEND # restore the default for later
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
and web backend defaults are unchanged.
**If the app is slow on an older Windows PC:** when DirectX 12 finds no graphics card with a
driver for it (for example Intel 4th-generation graphics on Windows 11), it only offers Microsoft's
software renderer, which draws every frame on the processor. In that case WordCraft uses OpenGL
instead, without setting anything. The log file records the choice (lines starting with
`graphics:`). `WGPU_BACKEND` still overrides it, e.g. `$env:WGPU_BACKEND = "gl"` or `"dx12"`.
### macOS
| Build | File | Notes |
+1 -4
View File
@@ -130,8 +130,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| Date | What landed |
|---|---|
| 2026-10-10 | Full re-measure against Word for Mac 16.113.4 and progress docs to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, format/layout/UI/hardware/localization parity; `docs/parity.md` → `docs/parity-checklist.md`). Landed the same day (~80 PRs): equations with OMML and an Equation tab (#191); Word 97-2003 `.doc` import (#145); right-to-left and Persian text (#207); Zotero integration (#189); Read Aloud player (#190); text boxes and floating objects editable (#46) and placed like Word (#136); floating tables (#137); track-changes fixes (#125, #244); typing parity with Word (#204); hidden text (#173); save prompts and AutoSave rules (#151); LaTeX import/export (#11); keytips and mini-toolbar (#43); Spanish, Ukrainian and Brazilian Portuguese interfaces; system theme (#249); Linux file dialogs no longer freeze (#246); Chinese UI font on Windows (#248); every installed font weight (#239); cell text direction (#245); Paste Special (#235), Advanced Find (#234), Column Selection (#237), Style Inspector (#236), custom table styles (#256), View gridlines (#243), zoom buttons (#232), inertial touchpad scrolling (#252), File Info fields kept (#268), ¶ in mixed-direction paragraphs (#278), Serbian interface (#250), table Height/Width boxes, AutoFit Contents and Table Properties (#44), AutoSave switch greyed out with the reason where it can't save (.md/.txt/.html, unsaved, browser) (#196, #176); System theme keeps following the OS and Dark page leaves the interface alone (#311, #312); v0.4.0 released |
| 2026-10-10 | Full re-measure against Word for Mac 16.113.4 and progress docs to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, format/layout/UI/hardware/localization parity; `docs/parity.md` → `docs/parity-checklist.md`). Landed the same day (~80 PRs): equations with OMML and an Equation tab (#191); Word 97-2003 `.doc` import (#145); right-to-left and Persian text (#207); Zotero integration (#189); Read Aloud player (#190); text boxes and floating objects editable (#46) and placed like Word (#136); floating tables (#137); track-changes fixes (#125, #244); typing parity with Word (#204); hidden text (#173); save prompts and AutoSave rules (#151); LaTeX import/export (#11); keytips and mini-toolbar (#43); Spanish, Ukrainian and Brazilian Portuguese interfaces; system theme (#249); Linux file dialogs no longer freeze (#246); Chinese UI font on Windows (#248); every installed font weight (#239); cell text direction (#245); Paste Special (#235), Advanced Find (#234), Column Selection (#237), Style Inspector (#236), custom table styles (#256), View gridlines (#243), zoom buttons (#232), inertial touchpad scrolling (#252), File Info fields kept (#268), ¶ in mixed-direction paragraphs (#278), Serbian interface (#250), table Height/Width boxes, AutoFit Contents and Table Properties (#44), AutoSave switch greyed out with the reason where it can't save (.md/.txt/.html, unsaved, browser) (#196, #176), OpenGL fallback on Windows PCs without a DirectX 12 driver (#316); v0.4.0 released |
| 2026-10-10 | Full re-measure against Word for Mac 16.113.4 and progress docs to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, format/layout/UI/hardware/localization parity; `docs/parity.md` → `docs/parity-checklist.md`). Landed the same day (~80 PRs): equations with OMML and an Equation tab (#191); Word 97-2003 `.doc` import (#145); right-to-left and Persian text (#207); Zotero integration (#189); Read Aloud player (#190); text boxes and floating objects editable (#46) and placed like Word (#136); floating tables (#137); track-changes fixes (#125, #244); typing parity with Word (#204); hidden text (#173); save prompts and AutoSave rules (#151); LaTeX import/export (#11); keytips and mini-toolbar (#43); Spanish, Ukrainian and Brazilian Portuguese interfaces; system theme (#249); Linux file dialogs no longer freeze (#246); Chinese UI font on Windows (#248); every installed font weight (#239); cell text direction (#245); Paste Special (#235), Advanced Find (#234), Column Selection (#237), Style Inspector (#236), custom table styles (#256), View gridlines (#243), zoom buttons (#232), inertial touchpad scrolling (#252), File Info fields kept (#268), ¶ in mixed-direction paragraphs (#278), Serbian interface (#250), table Height/Width boxes, AutoFit Contents and Table Properties (#44), AutoSave switch greyed out with the reason where it can't save (.md/.txt/.html, unsaved, browser) (#196, #176); v0.4.0 released |
| 2026-10-09 | Interface languages follow the system or Options (#12; zh-hans, zh-hant, ja); TOC page numbers as an updatable field (#52); rotating log file (#17); window geometry remembered (#38) |
| 2026-10-08 | v0.2.0 and v0.3.0 released; Flatpak and AppImage auto-update (#22) |
| 2026-10-07 | v0.1.0 released; contributor credits in About (#6) |
@@ -142,8 +141,6 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | trivial | Progress log: Windows OpenGL fallback without a DirectX 12 driver (#316) |
| 2026-10-10 | minor | Password-protected .docx (#281): files encrypted with a password (agile encryption) open and save; File › Info › Encrypt with Password |
| 2026-10-10 | minor | Relanded clipboard pane, table styles editor, Manage Styles, Asian typography (kinsoku), Group/Ungroup and Shape Effects (#264 #263 #270 #271 #267 #280): catalog 390/431 (90.5%) |
| 2026-10-10 | minor | Merged main: catalog 384/430 (89.3%), 431 commands; Paste Special, Advanced Find, Column Selection, Style Inspector, custom table styles, gridlines, zoom buttons, touchpad scrolling, File Info, Serbian landed; mainstream 54→55% |
| 2026-10-10 | minor | Readiness table with hours per audience; full number restored to the additive weighted sum (~47% → ~60%): method aligned with the standard, no new evidence; beta distance back to ~15 points and ~150–230 h |
+9 -117
View File
@@ -1,122 +1,33 @@
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
use eframe::{
NativeOptions,
egui_wgpu::WgpuSetup,
wgpu::{Backends, DeviceType},
};
use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends};
/// What the start-up probe learned about one DirectX 12 adapter.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Dx12Adapter {
/// `Cpu` is WARP (Microsoft's software rasteriser): it works, but every frame is drawn on the
/// processor, which makes the app lag on older machines (#316).
pub device_type: DeviceType,
/// Whether the adapter offers the limits eframe will ask for when it creates the device.
pub meets_limits: bool,
}
/// The backends to initialize on Windows.
///
/// An explicit `backend_override` (normally `Backends::from_env()`, i.e. `WGPU_BACKEND`) always
/// wins. Otherwise DirectX 12 only, as long as `dx12_adapters` (what a DirectX 12-only probe found)
/// contains a usable hardware GPU. Without one, OpenGL is the way to the GPU: graphics chips whose
/// driver has no DirectX 12 support (Intel 4th generation on Windows 11, #316) still have an
/// OpenGL driver, and GL can't reach the AMD driver crash described in [`configure_backends`],
/// because an AMD GPU with a driver shows up as a DirectX 12 adapter and keeps us on DirectX 12.
/// When DirectX 12 offers only WARP, it stays enabled next to GL as a last resort for machines
/// without an OpenGL driver either (virtual machines): wgpu prefers any GPU over a software
/// adapter, so it's only picked when GL has nothing better.
pub fn choose_backends(backend_override: Option<Backends>, dx12_adapters: &[Dx12Adapter]) -> Backends {
if let Some(backends) = backend_override {
return backends;
}
let hardware = |a: &&Dx12Adapter| a.device_type != DeviceType::Cpu;
if dx12_adapters.iter().filter(hardware).any(|a| a.meets_limits) {
Backends::DX12
} else if !dx12_adapters.is_empty() && !dx12_adapters.iter().any(|a| hardware(&a)) {
Backends::DX12 | Backends::GL
} else {
// No DirectX 12 adapter at all, or only GPUs that can't run the app: GL alone, so a
// DirectX 12 GPU that would fail device creation isn't picked over the OpenGL one.
Backends::GL
}
}
/// Restricts the wgpu instance eframe creates to `backends`.
pub fn configure_backends(options: &mut NativeOptions, backends: Backends) {
/// Restrict the wgpu instance to DirectX 12 unless `backend_override` (normally
/// `Backends::from_env()`, i.e. `WGPU_BACKEND`) names other backends.
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>) {
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
// eframe's default includes GL. Creating that backend can crash inside AMD's
// atio6axx.dll before adapter selection or any Rust error handling runs, so the
// window never appears. Choosing an adapter afterwards is too late: only leaving GL
// out of the instance avoids it. This matches the verified WGPU_BACKEND=dx12
// workaround without mutating process environment variables.
create.instance_descriptor.backends = backends;
create.instance_descriptor.backends = backend_override.unwrap_or(Backends::DX12);
}
}
/// Chooses the backends ([`choose_backends`]) and applies them to `options`. Without a
/// `backend_override` this first probes DirectX 12 with a short-lived instance of its own, which
/// is dropped before eframe creates the real one.
#[cfg(target_os = "windows")]
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>) {
let backends = match backend_override {
Some(backends) => {
log::info!("graphics: WGPU_BACKEND override, backends {backends:?}");
backends
}
None => {
let adapters = probe_dx12(options);
let backends = choose_backends(None, &adapters);
log::info!("graphics: {} DirectX 12 adapter(s) {adapters:?}, backends {backends:?}", adapters.len());
backends
}
};
configure_backends(options, backends);
}
/// Lists the DirectX 12 adapters, checking each against the limits eframe's device descriptor
/// will require. Empty when this build has no DirectX 12 backend.
#[cfg(target_os = "windows")]
fn probe_dx12(options: &NativeOptions) -> Vec<Dx12Adapter> {
use eframe::wgpu::{Instance, InstanceDescriptor};
// `Instance::new` panics when the build has no backend for the platform.
if !Instance::enabled_backend_features().contains(Backends::DX12) {
return Vec::new();
}
let WgpuSetup::CreateNew(create) = &options.wgpu_options.wgpu_setup else {
return Vec::new();
};
let instance = Instance::new(InstanceDescriptor { backends: Backends::DX12, ..InstanceDescriptor::new_without_display_handle() });
pollster::block_on(instance.enumerate_adapters(Backends::DX12))
.iter()
.map(|adapter| {
let info = adapter.get_info();
let required = (create.device_descriptor)(adapter).required_limits;
let meets_limits = required.check_limits(&adapter.limits());
log::info!("graphics: DirectX 12 adapter {:?} ({:?}, driver {:?}), limits ok: {meets_limits}", info.name, info.device_type, info.driver);
Dx12Adapter { device_type: info.device_type, meets_limits }
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn configured_backends(backends: Backends) -> Backends {
fn configured_backends(backend_override: Option<Backends>) -> Backends {
let mut options = NativeOptions::default();
configure_backends(&mut options, backends);
configure(&mut options, backend_override);
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
create.instance_descriptor.backends
}
const fn adapter(device_type: DeviceType, meets_limits: bool) -> Dx12Adapter {
Dx12Adapter { device_type, meets_limits }
}
#[test]
fn windows_default_initializes_only_dx12() {
// eframe's own default (PRIMARY | GL) would initialize OpenGL as well.
@@ -124,32 +35,13 @@ mod tests {
panic!("default native options must create a wgpu instance");
};
assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL));
let gpu = [adapter(DeviceType::IntegratedGpu, true)];
assert_eq!(configured_backends(choose_backends(None, &gpu)), Backends::DX12);
assert_eq!(configured_backends(None), Backends::DX12);
}
#[test]
fn explicit_backend_override_is_preserved() {
let warp_only = [adapter(DeviceType::Cpu, true)];
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
// Whatever the probe would have found, the override wins (the probe doesn't run then).
assert_eq!(choose_backends(Some(backends), &[]), backends);
assert_eq!(choose_backends(Some(backends), &warp_only), backends);
assert_eq!(configured_backends(backends), backends);
assert_eq!(configured_backends(Some(backends)), backends);
}
}
#[test]
fn gl_when_dx12_has_no_usable_gpu() {
// #316: no DirectX 12 driver, so DirectX 12 offers only WARP (or nothing) and lags.
let warp_only = [adapter(DeviceType::Cpu, true)];
assert_eq!(choose_backends(None, &warp_only), Backends::GL | Backends::DX12);
assert_eq!(choose_backends(None, &[]), Backends::GL);
// A GPU that can't create eframe's device: GL alone, so it isn't picked over the GL one.
let weak_gpu = [adapter(DeviceType::IntegratedGpu, false), adapter(DeviceType::Cpu, true)];
assert_eq!(choose_backends(None, &weak_gpu), Backends::GL);
// Any usable GPU keeps DirectX 12 only, next to WARP or a weaker GPU.
let mixed = [adapter(DeviceType::Cpu, true), adapter(DeviceType::IntegratedGpu, false), adapter(DeviceType::DiscreteGpu, true)];
assert_eq!(choose_backends(None, &mixed), Backends::DX12);
}
}
+1 -2
View File
@@ -206,8 +206,7 @@ fn main() -> eframe::Result {
if let Some(icon) = app_icon() {
options.viewport = options.viewport.with_icon(icon);
}
// Before eframe creates the wgpu instance: default Windows to DirectX 12 only, or OpenGL when
// DirectX 12 has no GPU driver (see graphics.rs).
// Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs).
#[cfg(target_os = "windows")]
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
let restored = saved_window();
-4
View File
@@ -331,9 +331,6 @@ pub struct Settings {
/// Drawing grid spacing in points, across and down (View › Gridlines); ECMA-376's default is 1/8 inch.
pub grid_h: f32,
pub grid_v: f32,
/// Equation options (`m:mathPr`), when the document has them.
#[serde(skip_serializing_if = "Option::is_none")]
pub math: Option<math::MathProps>,
}
/// Word's compatibility mode for documents that don't state one.
@@ -397,7 +394,6 @@ impl Default for Settings {
compat_mode: COMPAT_MODE_CURRENT,
grid_h: DEFAULT_GRID,
grid_v: DEFAULT_GRID,
math: None,
}
}
}
-54
View File
@@ -54,55 +54,6 @@ pub enum MathJc {
Right,
}
/// Where a display equation too wide for its line breaks at a binary operator (`m:brkBin`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum BrkBin {
/// The operator starts the next line.
#[default]
Before,
/// The operator ends the line.
After,
/// The operator ends the line and starts the next one again.
Repeat,
}
/// How a minus repeats across a break when [`BrkBin::Repeat`] (`m:brkBinSub`): the operator
/// ending the line, then the one starting the next.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum BrkBinSub {
/// `--`
#[default]
MinusMinus,
/// `-+`
MinusPlus,
/// `+-`
PlusMinus,
}
/// Document-wide equation options (`m:mathPr` in the settings part) that layout uses: how long
/// display equations wrap.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct MathProps {
pub brk_bin: BrkBin,
pub brk_bin_sub: BrkBinSub,
/// Indent of a wrapped equation's continuation lines, points (`m:wrapIndent`).
pub wrap_indent: f32,
/// Continuation lines align right instead (`m:wrapRight`).
pub wrap_right: bool,
}
/// `m:wrapIndent`'s default: 1440 twips, one inch.
pub const DEFAULT_WRAP_INDENT: f32 = 72.0;
impl Default for MathProps {
fn default() -> Self {
MathProps { brk_bin: BrkBin::Before, brk_bin_sub: BrkBinSub::MinusMinus, wrap_indent: DEFAULT_WRAP_INDENT, wrap_right: false }
}
}
/// Math run style (`m:sty`): plain, bold, italic, bold italic.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -149,10 +100,6 @@ pub struct MRun {
/// Font for normal text.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font: Option<String>,
/// A manual line break before this run in a display equation (`m:brk`): `Some(0)` wraps as
/// usual, `Some(n)` aligns the new line at the `n`th operator of the first line (`m:alnAt`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub brk: Option<u8>,
}
impl MRun {
@@ -715,7 +662,6 @@ pub fn merge_runs(a: Arg) -> Arg {
&& prev.size == r.size
&& prev.color == r.color
&& prev.font == r.font
&& r.brk.is_none()
{
prev.text.push_str(&r.text);
continue;
-12
View File
@@ -15,18 +15,6 @@ quick-xml = { workspace = true }
zip = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
cfb = { workspace = true }
aes = { workspace = true }
sha1 = { workspace = true }
sha2 = { workspace = true }
hmac = { workspace = true }
base64 = { workspace = true }
getrandom = { workspace = true }
zeroize = { workspace = true }
# The browser's `crypto.getRandomValues` for the salts and keys of encrypted saves.
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
getrandom = { workspace = true, features = ["wasm_js"] }
[dev-dependencies]
proptest = { workspace = true }
-741
View File
@@ -1,741 +0,0 @@
//! Password-protected Word documents: ECMA-376 document encryption with the *agile* method
//! (Word 2010 and later), written from Microsoft's public Open Specification [MS-OFFCRYPTO]
//! §2.3.4.4 and §2.3.4.10–§2.3.4.15, with the data-spaces streams of §2.1 / §2.3.4.1–§2.3.4.3.
//!
//! An encrypted package is not a zip but an OLE compound file ([MS-CFB]) holding
//! `EncryptionInfo` (how the key is derived from the password, as XML) and `EncryptedPackage`
//! (the original zip, AES-CBC encrypted in 4096-byte segments). A random *intermediate key*
//! encrypts the package; the password, hashed `spinCount` times with a salt, encrypts that key and
//! a verifier that tells a wrong password apart; an HMAC over the encrypted stream checks that it
//! hasn't been damaged or tampered with.
//!
//! Files are hostile: every stream read is capped, every length is checked before slicing, and the
//! spin count is bounded by the specification's maximum. Passwords and keys are wiped from memory
//! when dropped ([`Zeroizing`]) and never logged.
use std::io::{Cursor, Read, Write};
use aes::cipher::{BlockCipherDecrypt, BlockCipherEncrypt, KeyInit};
use base64::Engine as _;
use hmac::Mac;
use sha2::Digest;
use zeroize::Zeroizing;
use crate::DocxError;
use crate::xml;
/// The spin count Word uses, and WordCraft writes.
pub const DEFAULT_SPIN_COUNT: u32 = 100_000;
/// The largest spin count the specification allows (§2.3.4.10 `ST_SpinCount`): it bounds the
/// work a hostile file can ask for.
const MAX_SPIN_COUNT: u32 = 10_000_000;
/// Word's limit on password length, in characters.
pub const MAX_PASSWORD_CHARS: usize = 255;
/// `EncryptedPackage` is encrypted in segments of this many bytes (§2.3.4.15).
const SEGMENT: usize = 4096;
/// Largest `EncryptionInfo` stream read (the XML is a few kilobytes).
const MAX_INFO: u64 = 1 << 20;
/// AES's block size.
const BLOCK: usize = 16;
/// Salt length written.
const SALT: usize = 16;
/// Block keys (§2.3.4.13, §2.3.4.14).
const BK_VERIFIER_INPUT: [u8; 8] = [0xfe, 0xa7, 0xd2, 0x76, 0x3b, 0x4b, 0x9e, 0x79];
const BK_VERIFIER_VALUE: [u8; 8] = [0xd7, 0xaa, 0x0f, 0x6d, 0x30, 0x61, 0x34, 0x4e];
const BK_KEY_VALUE: [u8; 8] = [0x14, 0x6e, 0x0b, 0xe7, 0xab, 0xac, 0xd0, 0xd6];
const BK_HMAC_KEY: [u8; 8] = [0x5f, 0xb2, 0xad, 0x01, 0x0c, 0xb9, 0xe1, 0xf6];
const BK_HMAC_VALUE: [u8; 8] = [0xa0, 0x67, 0x7f, 0x02, 0xb2, 0x2c, 0x84, 0x33];
/// An OLE compound file starts with these bytes ([MS-CFB] §2.2).
const CFB_MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
const NS_ENCRYPTION: &str = "http://schemas.microsoft.com/office/2006/encryption";
const NS_PASSWORD: &str = "http://schemas.microsoft.com/office/2006/keyEncryptor/password";
/// Is this an encrypted Office package (a compound file with `EncryptionInfo` and
/// `EncryptedPackage` streams) rather than a zip?
pub fn is_encrypted(bytes: &[u8]) -> bool {
if bytes.get(..8) != Some(&CFB_MAGIC[..]) {
return false;
}
match cfb::CompoundFile::open(Cursor::new(bytes)) {
Ok(comp) => comp.is_stream("/EncryptionInfo") && comp.is_stream("/EncryptedPackage"),
Err(_) => false,
}
}
/// The zip package inside an encrypted Office file. Without a password (`None`) this says
/// [`DocxError::PasswordRequired`] once it knows the file is one it can decrypt; a password that
/// doesn't fit gives [`DocxError::WrongPassword`].
pub fn decrypt(bytes: &[u8], password: Option<&str>) -> Result<Vec<u8>, DocxError> {
let mut comp = cfb::CompoundFile::open(Cursor::new(bytes)).map_err(|e| bad(format!("not a readable compound file ({e})")))?;
let info = stream(&mut comp, "/EncryptionInfo", MAX_INFO)?;
let package = stream(&mut comp, "/EncryptedPackage", bytes.len() as u64)?;
let info = parse_info(&info)?;
let Some(password) = password else { return Err(DocxError::PasswordRequired) };
let pe = &info.password;
let hn = iterate(pe.hash, &pe.salt, password, pe.spin_count);
// The verifier (§2.3.4.13): a random value and its hash, both encrypted with keys derived
// from the password. They match only for the right password.
let key_input = derive_key(pe.hash, &hn, &BK_VERIFIER_INPUT, pe.key_bytes);
let key_value = derive_key(pe.hash, &hn, &BK_VERIFIER_VALUE, pe.key_bytes);
let iv = make_iv(pe.hash, &pe.salt, None, pe.block_size);
let input = Zeroizing::new(cbc_decrypt(&key_input, &iv, &pe.verifier_input)?);
let input = input.get(..pe.salt_size).ok_or_else(|| bad("the password verifier is too short"))?;
let value = Zeroizing::new(cbc_decrypt(&key_value, &iv, &pe.verifier_hash)?);
let value = value.get(..pe.hash.size()).ok_or_else(|| bad("the password verifier hash is too short"))?;
if !same(&pe.hash.hash(&[input]), value) {
return Err(DocxError::WrongPassword);
}
// The intermediate key that encrypts the package.
let key_key = derive_key(pe.hash, &hn, &BK_KEY_VALUE, pe.key_bytes);
let key = Zeroizing::new(cbc_decrypt(&key_key, &iv, &pe.key_value)?);
let key = Zeroizing::new(key.get(..info.key_bytes).ok_or_else(|| bad("the encrypted key is too short"))?.to_vec());
// Data integrity (§2.3.4.14): an HMAC of the whole EncryptedPackage stream.
if let Some((enc_key, enc_value)) = &info.integrity {
let hmac_key = Zeroizing::new(cbc_decrypt(&key, &make_iv(info.hash, &info.salt, Some(&BK_HMAC_KEY), info.block_size), enc_key)?);
// The key is the salt, zero-padded to the block size; HMAC pads short keys with zeros
// anyway, so the padded key gives the same HMAC.
let hmac_key = hmac_key.get(..hmac_key.len().min(info.hash.size())).unwrap_or(&[]);
let expected = cbc_decrypt(&key, &make_iv(info.hash, &info.salt, Some(&BK_HMAC_VALUE), info.block_size), enc_value)?;
let expected = expected.get(..info.hash.size()).ok_or_else(|| bad("the integrity check value is too short"))?;
if !same(&info.hash.hmac(hmac_key, &package)?, expected) {
return Err(bad("the document is damaged (its integrity check failed)"));
}
}
// The package itself (§2.3.4.15): StreamSize, then 4096-byte segments, each with its own IV.
let size = package
.get(..8)
.and_then(|b| <[u8; 8]>::try_from(b).ok())
.map(u64::from_le_bytes)
.ok_or_else(|| bad("the encrypted package is truncated"))?;
let data = package.get(8..).unwrap_or(&[]);
let size = usize::try_from(size).ok().filter(|s| *s <= data.len()).ok_or_else(|| bad("the encrypted package is truncated"))?;
let needed = size.div_ceil(BLOCK).saturating_mul(BLOCK);
let data = data.get(..needed).ok_or_else(|| bad("the encrypted package is truncated"))?;
let mut out = Vec::with_capacity(needed);
for (i, seg) in data.chunks(SEGMENT).enumerate() {
let i = u32::try_from(i).map_err(|_| bad("the encrypted package is too large"))?;
let iv = make_iv(info.hash, &info.salt, Some(&i.to_le_bytes()), info.block_size);
out.extend_from_slice(&cbc_decrypt(&key, &iv, seg)?);
}
out.truncate(size);
Ok(out)
}
/// Encrypt a zip package with `password` (agile encryption: AES-256-CBC, SHA-512, Word's spin
/// count), as Word's "Encrypt with Password" does.
pub fn encrypt(package: &[u8], password: &str) -> Result<Vec<u8>, DocxError> {
encrypt_with_spin_count(package, password, DEFAULT_SPIN_COUNT)
}
/// [`encrypt`] with a chosen spin count (tests use a small one; files should use the default).
pub fn encrypt_with_spin_count(package: &[u8], password: &str, spin_count: u32) -> Result<Vec<u8>, DocxError> {
check_password(password)?;
let spin_count = spin_count.min(MAX_SPIN_COUNT);
let hash = Hash::Sha512;
let key_bytes = 32;
let key_data_salt = random(SALT)?;
let password_salt = random(SALT)?;
let key = random(key_bytes)?;
let verifier = random(SALT)?;
// The HMAC key is a salt as long as the hash (MS-OFFCRYPTO §2.3.4.14).
let hmac_salt = random(hash.size())?;
// The package, segment by segment; the last segment is zero-padded to the block size.
let mut stream = Vec::with_capacity(package.len().saturating_add(8 + BLOCK));
stream.extend_from_slice(&(package.len() as u64).to_le_bytes());
for (i, seg) in package.chunks(SEGMENT).enumerate() {
let i = u32::try_from(i).map_err(|_| bad("the document is too large to encrypt"))?;
let iv = make_iv(hash, &key_data_salt, Some(&i.to_le_bytes()), BLOCK);
stream.extend_from_slice(&cbc_encrypt(&key, &iv, &padded(seg))?);
}
// The password key encryptor (§2.3.4.13).
let hn = iterate(hash, &password_salt, password, spin_count);
let iv = make_iv(hash, &password_salt, None, BLOCK);
let enc_verifier_input = cbc_encrypt(&derive_key(hash, &hn, &BK_VERIFIER_INPUT, key_bytes), &iv, &padded(&verifier))?;
let enc_verifier_hash = cbc_encrypt(&derive_key(hash, &hn, &BK_VERIFIER_VALUE, key_bytes), &iv, &padded(&hash.hash(&[&verifier])))?;
let enc_key_value = cbc_encrypt(&derive_key(hash, &hn, &BK_KEY_VALUE, key_bytes), &iv, &padded(&key))?;
// Data integrity (§2.3.4.14).
let enc_hmac_key = cbc_encrypt(&key, &make_iv(hash, &key_data_salt, Some(&BK_HMAC_KEY), BLOCK), &padded(&hmac_salt))?;
let hmac = hash.hmac(&hmac_salt, &stream)?;
let enc_hmac_value = cbc_encrypt(&key, &make_iv(hash, &key_data_salt, Some(&BK_HMAC_VALUE), BLOCK), &padded(&hmac))?;
let b64 = |b: &[u8]| base64::engine::general_purpose::STANDARD.encode(b);
let params = format!(
r#"saltSize="{SALT}" blockSize="{BLOCK}" keyBits="{}" hashSize="{}" cipherAlgorithm="AES" cipherChaining="ChainingModeCBC" hashAlgorithm="{}""#,
key_bytes * 8,
hash.size(),
hash.name(),
);
let xml = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n\
<encryption xmlns=\"{NS_ENCRYPTION}\" xmlns:p=\"{NS_PASSWORD}\">\
<keyData {params} saltValue=\"{}\"/>\
<dataIntegrity encryptedHmacKey=\"{}\" encryptedHmacValue=\"{}\"/>\
<keyEncryptors><keyEncryptor uri=\"{NS_PASSWORD}\">\
<p:encryptedKey spinCount=\"{spin_count}\" {params} saltValue=\"{}\" encryptedVerifierHashInput=\"{}\" encryptedVerifierHashValue=\"{}\" encryptedKeyValue=\"{}\"/>\
</keyEncryptor></keyEncryptors></encryption>",
b64(&key_data_salt),
b64(&enc_hmac_key),
b64(&enc_hmac_value),
b64(&password_salt),
b64(&enc_verifier_input),
b64(&enc_verifier_hash),
b64(&enc_key_value),
);
// EncryptionInfo (§2.3.4.10): version 4.4, reserved 0x40, the XML.
let mut info = vec![4, 0, 4, 0, 0x40, 0, 0, 0];
info.extend_from_slice(xml.as_bytes());
let io = |e: std::io::Error| bad(format!("couldn't write the compound file ({e})"));
let mut comp = cfb::CompoundFile::create_with_version(cfb::Version::V3, Cursor::new(Vec::new())).map_err(io)?;
for (path, data) in [
("/\u{6}DataSpaces/Version", data_space_version()),
("/\u{6}DataSpaces/DataSpaceMap", data_space_map()),
("/\u{6}DataSpaces/DataSpaceInfo/StrongEncryptionDataSpace", data_space_definition()),
("/\u{6}DataSpaces/TransformInfo/StrongEncryptionTransform/\u{6}Primary", primary_transform()),
("/EncryptionInfo", info),
("/EncryptedPackage", stream),
] {
if let Some((parent, _)) = path.rsplit_once('/')
&& !parent.is_empty()
{
comp.create_storage_all(parent).map_err(io)?;
}
comp.create_stream(path).map_err(io)?.write_all(&data).map_err(io)?;
}
comp.flush().map_err(io)?;
Ok(comp.into_inner().into_inner())
}
/// Refuse a password Word wouldn't accept: empty, or longer than [`MAX_PASSWORD_CHARS`].
pub fn check_password(password: &str) -> Result<(), DocxError> {
if password.is_empty() {
return Err(bad("the password is empty"));
}
if password.chars().count() > MAX_PASSWORD_CHARS {
return Err(bad(format!("passwords are at most {MAX_PASSWORD_CHARS} characters")));
}
Ok(())
}
fn bad(msg: impl Into<String>) -> DocxError {
DocxError::Encryption(msg.into())
}
/// A root-level stream, at most `cap` bytes.
fn stream<F: Read + std::io::Seek>(comp: &mut cfb::CompoundFile<F>, path: &str, cap: u64) -> Result<Vec<u8>, DocxError> {
let s = comp.open_stream(path).map_err(|e| bad(format!("can't read {} ({e})", path.trim_start_matches('/'))))?;
let mut v = Vec::new();
s.take(cap.saturating_add(1)).read_to_end(&mut v).map_err(|e| bad(format!("can't read {} ({e})", path.trim_start_matches('/'))))?;
if v.len() as u64 > cap {
return Err(DocxError::Limit(format!("{} is too large", path.trim_start_matches('/'))));
}
Ok(v)
}
/// The hash algorithms agile encryption names that WordCraft supports.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Hash {
Sha1,
Sha256,
Sha384,
Sha512,
}
impl Hash {
fn parse(s: &str) -> Option<Hash> {
match s.to_ascii_uppercase().replace('-', "").as_str() {
"SHA1" => Some(Hash::Sha1),
"SHA256" => Some(Hash::Sha256),
"SHA384" => Some(Hash::Sha384),
"SHA512" => Some(Hash::Sha512),
_ => None,
}
}
fn name(self) -> &'static str {
match self {
Hash::Sha1 => "SHA-1",
Hash::Sha256 => "SHA256",
Hash::Sha384 => "SHA384",
Hash::Sha512 => "SHA512",
}
}
fn size(self) -> usize {
match self {
Hash::Sha1 => 20,
Hash::Sha256 => 32,
Hash::Sha384 => 48,
Hash::Sha512 => 64,
}
}
/// The hash of the concatenated `parts`.
fn hash(self, parts: &[&[u8]]) -> Vec<u8> {
fn go<D: Digest>(parts: &[&[u8]]) -> Vec<u8> {
let mut d = D::new();
for p in parts {
d.update(p);
}
d.finalize().to_vec()
}
match self {
Hash::Sha1 => go::<sha1::Sha1>(parts),
Hash::Sha256 => go::<sha2::Sha256>(parts),
Hash::Sha384 => go::<sha2::Sha384>(parts),
Hash::Sha512 => go::<sha2::Sha512>(parts),
}
}
fn hmac(self, key: &[u8], msg: &[u8]) -> Result<Vec<u8>, DocxError> {
fn go<M: Mac + KeyInit>(key: &[u8], msg: &[u8]) -> Result<Vec<u8>, DocxError> {
let mut m = <M as KeyInit>::new_from_slice(key).map_err(|_| bad("bad integrity key"))?;
m.update(msg);
Ok(m.finalize().into_bytes().to_vec())
}
match self {
Hash::Sha1 => go::<hmac::Hmac<sha1::Sha1>>(key, msg),
Hash::Sha256 => go::<hmac::Hmac<sha2::Sha256>>(key, msg),
Hash::Sha384 => go::<hmac::Hmac<sha2::Sha384>>(key, msg),
Hash::Sha512 => go::<hmac::Hmac<sha2::Sha512>>(key, msg),
}
}
}
/// The password hash after `spin_count` iterations (§2.3.4.11): H0 = H(salt + password as
/// UTF-16LE), Hn = H(iterator + Hn-1).
fn iterate(hash: Hash, salt: &[u8], password: &str, spin_count: u32) -> Zeroizing<Vec<u8>> {
let pw: Zeroizing<Vec<u8>> = Zeroizing::new(password.encode_utf16().flat_map(u16::to_le_bytes).collect());
let mut h = Zeroizing::new(hash.hash(&[salt, &pw]));
for i in 0..spin_count.min(MAX_SPIN_COUNT) {
*h = hash.hash(&[&i.to_le_bytes(), &h]);
}
h
}
/// A key from the iterated password hash and a block key (§2.3.4.11): H(Hn + blockKey),
/// truncated to `len`, or padded with 0x36.
fn derive_key(hash: Hash, hn: &[u8], block_key: &[u8], len: usize) -> Zeroizing<Vec<u8>> {
let mut k = Zeroizing::new(hash.hash(&[hn, block_key]));
k.resize(len, 0x36);
k
}
/// An initialization vector (§2.3.4.12): H(salt + blockKey), or the salt itself, truncated to
/// the block size or padded with 0x36.
fn make_iv(hash: Hash, salt: &[u8], block_key: Option<&[u8]>, block_size: usize) -> Vec<u8> {
let mut iv = match block_key {
Some(b) => hash.hash(&[salt, b]),
None => salt.to_vec(),
};
iv.resize(block_size, 0x36);
iv
}
/// `data` zero-padded to a whole number of blocks.
fn padded(data: &[u8]) -> Zeroizing<Vec<u8>> {
let mut v = Zeroizing::new(data.to_vec());
v.resize(data.len().div_ceil(BLOCK).saturating_mul(BLOCK), 0);
v
}
fn random(n: usize) -> Result<Zeroizing<Vec<u8>>, DocxError> {
let mut v = Zeroizing::new(vec![0u8; n]);
getrandom::fill(&mut v).map_err(|e| bad(format!("no secure random numbers available ({e})")))?;
Ok(v)
}
/// Compare without stopping at the first difference.
fn same(a: &[u8], b: &[u8]) -> bool {
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
/// AES with a 128-, 192- or 256-bit key.
enum Aes {
A128(aes::Aes128),
A192(aes::Aes192),
A256(aes::Aes256),
}
impl Aes {
fn new(key: &[u8]) -> Result<Aes, DocxError> {
let err = |_| bad("bad AES key");
Ok(match key.len() {
16 => Aes::A128(aes::Aes128::new_from_slice(key).map_err(err)?),
24 => Aes::A192(aes::Aes192::new_from_slice(key).map_err(err)?),
32 => Aes::A256(aes::Aes256::new_from_slice(key).map_err(err)?),
n => return Err(bad(format!("unsupported AES key length {} bits", n * 8))),
})
}
fn encrypt(&self, b: &mut aes::Block) {
match self {
Aes::A128(c) => c.encrypt_block(b),
Aes::A192(c) => c.encrypt_block(b),
Aes::A256(c) => c.encrypt_block(b),
}
}
fn decrypt(&self, b: &mut aes::Block) {
match self {
Aes::A128(c) => c.decrypt_block(b),
Aes::A192(c) => c.decrypt_block(b),
Aes::A256(c) => c.decrypt_block(b),
}
}
}
/// AES-CBC without padding: `data` must be whole blocks.
fn cbc_encrypt(key: &[u8], iv: &[u8], data: &[u8]) -> Result<Vec<u8>, DocxError> {
let aes = Aes::new(key)?;
let (blocks, rest) = data.as_chunks::<BLOCK>();
let mut prev: [u8; BLOCK] = iv.try_into().map_err(|_| bad("bad initialization vector"))?;
if !rest.is_empty() {
return Err(bad("encrypted data isn't a whole number of blocks"));
}
let mut out = Vec::with_capacity(data.len());
for b in blocks {
let mut x = aes::Block::default();
for ((o, p), v) in x.iter_mut().zip(b).zip(&prev) {
*o = p ^ v;
}
aes.encrypt(&mut x);
prev.copy_from_slice(&x);
out.extend_from_slice(&x);
}
Ok(out)
}
/// AES-CBC decryption without padding.
fn cbc_decrypt(key: &[u8], iv: &[u8], data: &[u8]) -> Result<Vec<u8>, DocxError> {
let aes = Aes::new(key)?;
let (blocks, rest) = data.as_chunks::<BLOCK>();
if !rest.is_empty() {
return Err(bad("encrypted data isn't a whole number of blocks"));
}
let mut prev: [u8; BLOCK] = iv.try_into().map_err(|_| bad("bad initialization vector"))?;
let mut out = Vec::with_capacity(data.len());
for b in blocks {
let mut x = aes::Block::default();
x.copy_from_slice(b);
aes.decrypt(&mut x);
for (o, v) in x.iter_mut().zip(&prev) {
*o ^= v;
}
prev = *b;
out.extend_from_slice(&x);
}
Ok(out)
}
/// What `EncryptionInfo` says.
struct Info {
hash: Hash,
salt: Vec<u8>,
block_size: usize,
key_bytes: usize,
/// encryptedHmacKey, encryptedHmacValue.
integrity: Option<(Vec<u8>, Vec<u8>)>,
password: PasswordEncryptor,
}
struct PasswordEncryptor {
hash: Hash,
salt: Vec<u8>,
salt_size: usize,
block_size: usize,
key_bytes: usize,
spin_count: u32,
verifier_input: Vec<u8>,
verifier_hash: Vec<u8>,
key_value: Vec<u8>,
}
/// Cipher parameters shared by `keyData` and `encryptedKey`.
struct Params {
hash: Hash,
salt: Vec<u8>,
salt_size: usize,
block_size: usize,
key_bytes: usize,
}
fn parse_info(info: &[u8]) -> Result<Info, DocxError> {
let (major, minor) = match info.get(..4) {
Some([a, b, c, d]) => (u16::from_le_bytes([*a, *b]), u16::from_le_bytes([*c, *d])),
_ => return Err(bad("EncryptionInfo is truncated")),
};
match (major, minor) {
(4, 4) => {}
(2..=4, 2) => return Err(bad("this file uses Standard encryption (Office 2007), which WordCraft can't open yet")),
(3 | 4, 3) => return Err(bad("this file uses extensible encryption, which WordCraft can't open")),
_ => return Err(bad(format!("unknown encryption version {major}.{minor}"))),
}
let root = xml::parse(info.get(8..).unwrap_or(&[]))?;
if root.local() != "encryption" {
return Err(bad("EncryptionInfo has no <encryption> element"));
}
let key_data = root.els().find(|e| e.local() == "keyData").ok_or_else(|| bad("EncryptionInfo has no keyData"))?;
let kd = params(key_data)?;
let integrity = match root.els().find(|e| e.local() == "dataIntegrity") {
Some(di) => Some((b64_attr(di, "encryptedHmacKey")?, b64_attr(di, "encryptedHmacValue")?)),
None => None,
};
// The password key encryptor is the `encryptedKey` with a spin count (a certificate key
// encryptor has none).
let encryptors = root.els().find(|e| e.local() == "keyEncryptors").ok_or_else(|| bad("EncryptionInfo has no keyEncryptors"))?;
let ek = encryptors
.els()
.filter(|e| e.local() == "keyEncryptor")
.flat_map(|e| e.els())
.find(|e| e.local() == "encryptedKey" && e.attr("spinCount").is_some())
.ok_or_else(|| bad("this file isn't protected with a password (only certificate encryption is listed)"))?;
let pe = params(ek)?;
let spin_count: u32 = ek.attr("spinCount").and_then(|s| s.trim().parse().ok()).ok_or_else(|| bad("bad spinCount"))?;
if spin_count > MAX_SPIN_COUNT {
return Err(bad(format!("spinCount {spin_count} is over the allowed maximum")));
}
Ok(Info {
hash: kd.hash,
salt: kd.salt,
block_size: kd.block_size,
key_bytes: kd.key_bytes,
integrity,
password: PasswordEncryptor {
hash: pe.hash,
salt: pe.salt,
salt_size: pe.salt_size,
block_size: pe.block_size,
key_bytes: pe.key_bytes,
spin_count,
verifier_input: b64_attr(ek, "encryptedVerifierHashInput")?,
verifier_hash: b64_attr(ek, "encryptedVerifierHashValue")?,
key_value: b64_attr(ek, "encryptedKeyValue")?,
},
})
}
fn params(e: &xml::El) -> Result<Params, DocxError> {
let num = |k: &str| -> Result<usize, DocxError> { e.attr(k).and_then(|s| s.trim().parse::<usize>().ok()).ok_or_else(|| bad(format!("bad {k}"))) };
let cipher = e.attr("cipherAlgorithm").unwrap_or("");
if !cipher.eq_ignore_ascii_case("AES") {
return Err(bad(format!("the {cipher:?} cipher isn't supported")));
}
let chaining = e.attr("cipherChaining").unwrap_or("");
if chaining != "ChainingModeCBC" {
return Err(bad(format!("the {chaining:?} chaining mode isn't supported")));
}
let name = e.attr("hashAlgorithm").unwrap_or("");
let hash = Hash::parse(name).ok_or_else(|| bad(format!("the {name:?} hash algorithm isn't supported")))?;
if num("hashSize")? != hash.size() {
return Err(bad("hashSize doesn't match the hash algorithm"));
}
let block_size = num("blockSize")?;
if block_size != BLOCK {
return Err(bad(format!("AES needs a block size of {BLOCK}, not {block_size}")));
}
let key_bits = num("keyBits")?;
if !matches!(key_bits, 128 | 192 | 256) {
return Err(bad(format!("unsupported AES key size {key_bits}")));
}
let salt = b64_attr(e, "saltValue")?;
let salt_size = num("saltSize")?;
if salt.is_empty() || salt.len() != salt_size {
return Err(bad("the salt doesn't match saltSize"));
}
Ok(Params { hash, salt, salt_size, block_size, key_bytes: key_bits / 8 })
}
fn b64_attr(e: &xml::El, k: &str) -> Result<Vec<u8>, DocxError> {
let s = e.attr(k).ok_or_else(|| bad(format!("{k} is missing")))?;
let s: String = s.chars().filter(|c| !c.is_ascii_whitespace()).collect();
base64::engine::general_purpose::STANDARD.decode(s).map_err(|_| bad(format!("{k} isn't valid base64")))
}
/// A UNICODE-LP-P4 string (§2.1.2): byte length, UTF-16LE, zero-padded to 4 bytes.
fn lp_p4(out: &mut Vec<u8>, s: &str) {
let units: Vec<u8> = s.encode_utf16().flat_map(u16::to_le_bytes).collect();
out.extend_from_slice(&(units.len() as u32).to_le_bytes());
out.extend_from_slice(&units);
while !out.len().is_multiple_of(4) {
out.push(0);
}
}
/// `\x06DataSpaces\Version` (§2.1.5).
fn data_space_version() -> Vec<u8> {
let mut v = Vec::new();
lp_p4(&mut v, "Microsoft.Container.DataSpaces");
for _ in 0..3 {
v.extend_from_slice(&[1, 0, 0, 0]);
}
v
}
/// `\x06DataSpaces\DataSpaceMap` (§2.1.6, §2.3.4.1): EncryptedPackage → StrongEncryptionDataSpace.
fn data_space_map() -> Vec<u8> {
let mut entry = Vec::new();
entry.extend_from_slice(&1u32.to_le_bytes());
entry.extend_from_slice(&0u32.to_le_bytes());
lp_p4(&mut entry, "EncryptedPackage");
lp_p4(&mut entry, "StrongEncryptionDataSpace");
let mut v = Vec::new();
v.extend_from_slice(&8u32.to_le_bytes());
v.extend_from_slice(&1u32.to_le_bytes());
v.extend_from_slice(&((entry.len() + 4) as u32).to_le_bytes());
v.extend_from_slice(&entry);
v
}
/// `\x06DataSpaces\DataSpaceInfo\StrongEncryptionDataSpace` (§2.1.7, §2.3.4.2).
fn data_space_definition() -> Vec<u8> {
let mut v = Vec::new();
v.extend_from_slice(&8u32.to_le_bytes());
v.extend_from_slice(&1u32.to_le_bytes());
lp_p4(&mut v, "StrongEncryptionTransform");
v
}
/// `\x06DataSpaces\TransformInfo\StrongEncryptionTransform\x06Primary` (§2.1.8, §2.1.9,
/// §2.3.4.3): the encryption transform, with a null EncryptionName as agile encryption requires.
fn primary_transform() -> Vec<u8> {
let mut id = Vec::new();
lp_p4(&mut id, "{FF9A3F03-56EF-4613-BDD5-5A41C1D07246}");
let mut v = Vec::new();
// TransformLength: the bytes before TransformName.
v.extend_from_slice(&((8 + id.len()) as u32).to_le_bytes());
v.extend_from_slice(&1u32.to_le_bytes());
v.extend_from_slice(&id);
lp_p4(&mut v, "Microsoft.Container.EncryptionTransform");
for _ in 0..3 {
v.extend_from_slice(&[1, 0, 0, 0]);
}
// EncryptionTransformInfo: null name, block size, cipher mode 0, reserved 4.
v.extend_from_slice(&0u32.to_le_bytes());
v.extend_from_slice(&(BLOCK as u32).to_le_bytes());
v.extend_from_slice(&0u32.to_le_bytes());
v.extend_from_slice(&4u32.to_le_bytes());
v
}
#[cfg(test)]
mod tests {
use super::*;
fn zip_package() -> Vec<u8> {
let mut doc = wordcraft_doc::Document::from_text("Top secret plans\nSecond paragraph");
doc.core.title = "Secret".into();
crate::write(&doc).unwrap()
}
#[test]
fn round_trip_with_a_password() {
let pkg = zip_package();
let enc = encrypt_with_spin_count(&pkg, "pässwörd 🔑", 16).unwrap();
assert!(is_encrypted(&enc));
assert!(!is_encrypted(&pkg));
// The compound file carries the data spaces Word expects.
let comp = cfb::CompoundFile::open(Cursor::new(&enc)).unwrap();
assert!(comp.is_stream("/\u{6}DataSpaces/TransformInfo/StrongEncryptionTransform/\u{6}Primary"));
assert_eq!(decrypt(&enc, Some("pässwörd 🔑")).unwrap(), pkg);
let doc = crate::read_with_password(&enc, Some("pässwörd 🔑")).unwrap();
assert!(doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Top secret plans"));
assert_eq!(doc.core.title, "Secret");
// Without a password, or with the wrong one, it says so instead of opening blank.
assert_eq!(crate::read(&enc), Err(DocxError::PasswordRequired));
assert_eq!(crate::read_with_password(&enc, None), Err(DocxError::PasswordRequired));
assert_eq!(decrypt(&enc, Some("Pässwörd 🔑")), Err(DocxError::WrongPassword));
// A package that is a whole number of segments, and an empty one.
for n in [0usize, SEGMENT, SEGMENT * 2 + 1] {
let data: Vec<u8> = (0..n).map(|i| (i * 7) as u8).collect();
let enc = encrypt_with_spin_count(&data, "x", 1).unwrap();
assert_eq!(decrypt(&enc, Some("x")).unwrap(), data);
}
assert!(encrypt(&pkg, "").is_err());
assert!(encrypt(&pkg, &"x".repeat(MAX_PASSWORD_CHARS + 1)).is_err());
}
#[test]
fn tampered_package_fails_the_integrity_check() {
let pkg = zip_package();
let enc = encrypt_with_spin_count(&pkg, "pw", 4).unwrap();
let mut comp = cfb::CompoundFile::open(Cursor::new(enc)).unwrap();
let mut data = Vec::new();
comp.open_stream("/EncryptedPackage").unwrap().read_to_end(&mut data).unwrap();
let last = data.len() - 1;
data[last] ^= 1;
comp.create_stream("/EncryptedPackage").unwrap().write_all(&data).unwrap();
comp.flush().unwrap();
let enc = comp.into_inner().into_inner();
assert!(matches!(decrypt(&enc, Some("pw")), Err(DocxError::Encryption(m)) if m.contains("integrity")));
}
/// A compound file with the given EncryptionInfo and EncryptedPackage streams.
fn cfb_with(info: &[u8], package: &[u8]) -> Vec<u8> {
let mut comp = cfb::CompoundFile::create(Cursor::new(Vec::new())).unwrap();
comp.create_stream("/EncryptionInfo").unwrap().write_all(info).unwrap();
comp.create_stream("/EncryptedPackage").unwrap().write_all(package).unwrap();
comp.flush().unwrap();
comp.into_inner().into_inner()
}
#[test]
fn hostile_encryption_info_errors_without_panicking() {
let good = encrypt_with_spin_count(&zip_package(), "pw", 2).unwrap();
let mut comp = cfb::CompoundFile::open(Cursor::new(&good)).unwrap();
let mut info = Vec::new();
comp.open_stream("/EncryptionInfo").unwrap().read_to_end(&mut info).unwrap();
let mut package = Vec::new();
comp.open_stream("/EncryptedPackage").unwrap().read_to_end(&mut package).unwrap();
let xml = String::from_utf8(info[8..].to_vec()).unwrap();
// (EncryptionInfo, EncryptedPackage, must fail). Truncations only must not panic: the
// XML reader is lenient, so cutting the closing tags still reads.
let mut cases: Vec<(Vec<u8>, Vec<u8>, bool)> = Vec::new();
for n in (0..info.len()).step_by(7) {
cases.push((info[..n].to_vec(), package.clone(), n < info.len() / 2));
}
for n in [0, 4, 8, 9, 100, package.len() - 1] {
cases.push((info.clone(), package[..n].to_vec(), true));
}
// Standard and unknown versions, absurd numbers, bad base64, a huge StreamSize.
let with = |from: &str, to: &str| {
let mut v = info[..8].to_vec();
v.extend_from_slice(xml.replacen(from, to, 2).as_bytes());
v
};
cases.push(([3u8, 0, 2, 0, 0x24, 0, 0, 0].to_vec(), package.clone(), true));
cases.push(([9u8, 0, 9, 0].to_vec(), package.clone(), true));
cases.push((with("spinCount=\"2\"", "spinCount=\"4294967295\""), package.clone(), true));
cases.push((with("keyBits=\"256\"", "keyBits=\"8\""), package.clone(), true));
cases.push((with("blockSize=\"16\"", "blockSize=\"4096\""), package.clone(), true));
cases.push((with("hashSize=\"64\"", "hashSize=\"0\""), package.clone(), true));
cases.push((with("saltSize=\"16\"", "saltSize=\"99999999999999999999\""), package.clone(), true));
cases.push((with("SHA512", "MD5"), package.clone(), true));
cases.push((with("ChainingModeCBC", "ChainingModeCFB"), package.clone(), true));
cases.push((with("saltValue=\"", "saltValue=\"!!"), package.clone(), true));
cases.push((with("encryptedKeyValue=\"", "encryptedKeyValue=\"AAAA"), package.clone(), true));
// Data integrity is optional; without it a good file still opens.
cases.push((with("<dataIntegrity", "<nothing"), package.clone(), false));
let mut huge = package.clone();
huge[..8].copy_from_slice(&u64::MAX.to_le_bytes());
cases.push((with("<dataIntegrity", "<nothing"), huge, true));
for (i, p, must_fail) in cases {
let file = cfb_with(&i, &p);
let r = decrypt(&file, Some("pw"));
assert!(!must_fail || r.is_err());
let _ = crate::read_with_password(&file, Some("pw"));
assert!(crate::read(&file).is_err());
}
// A file that only looks like a compound file.
let mut fake = CFB_MAGIC.to_vec();
fake.extend_from_slice(&[0u8; 600]);
assert!(!is_encrypted(&fake));
assert!(decrypt(&fake, Some("pw")).is_err());
}
}
-28
View File
@@ -7,7 +7,6 @@
//! error or a best-effort document, never a panic.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod crypt;
mod custom;
mod package;
mod read;
@@ -15,26 +14,9 @@ mod units;
mod write;
mod xml;
pub use crypt::{DEFAULT_SPIN_COUNT, MAX_PASSWORD_CHARS, check_password, decrypt, encrypt, encrypt_with_spin_count, is_encrypted};
pub use read::read;
pub use write::{write, write_as};
/// What [`DocxError::PasswordRequired`] says.
pub const PASSWORD_REQUIRED: &str = "this document is protected with a password";
/// What [`DocxError::WrongPassword`] says.
pub const WRONG_PASSWORD: &str = "the password is incorrect";
/// Read a package that may be password-protected ([`is_encrypted`]): it is decrypted with
/// `password` first. An encrypted package without a password gives
/// [`DocxError::PasswordRequired`]; a wrong one, [`DocxError::WrongPassword`].
pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<wordcraft_doc::Document, DocxError> {
if is_encrypted(bytes) {
let package = zeroize::Zeroizing::new(decrypt(bytes, password)?);
return read::read_package(&package);
}
read(bytes)
}
/// Which kind of WordprocessingML package to write. The main part's content type differs per
/// kind, and Word refuses a file whose content type doesn't match its extension.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@@ -96,14 +78,4 @@ pub enum DocxError {
/// The package is readable but isn't a WordprocessingML document.
#[error("not a Word document: {0}")]
NotWord(String),
/// The package is encrypted and no password was given.
#[error("{}", PASSWORD_REQUIRED)]
PasswordRequired,
/// The password doesn't open the package.
#[error("{}", WRONG_PASSWORD)]
WrongPassword,
/// The package is encrypted in a way WordCraft can't read, or its encryption data is damaged
/// (or encrypting failed).
#[error("encrypted document: {0}")]
Encryption(String),
}
+1 -28
View File
@@ -1,8 +1,6 @@
//! Office Math (OMML, ECMA-376 Part 1 §22.1) → [`wordcraft_doc::math`] tree.
use wordcraft_doc::math::{
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, MathProps, ScriptKind, merge_runs,
};
use wordcraft_doc::math::{Arg, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, ScriptKind, merge_runs};
use wordcraft_doc::props::Rgb;
use crate::xml::El;
@@ -22,27 +20,6 @@ pub fn para_jc(para: &El) -> MathJc {
}
}
/// The settings part's `m:mathPr`: how display equations wrap.
pub fn read_math_pr(e: &El) -> MathProps {
let mut m = MathProps::default();
let pr = Some(e);
m.brk_bin = match val(pr, "m:brkBin") {
Some("after") => BrkBin::After,
Some("repeat") => BrkBin::Repeat,
_ => BrkBin::Before,
};
m.brk_bin_sub = match val(pr, "m:brkBinSub") {
Some("-+") => BrkBinSub::MinusPlus,
Some("+-") => BrkBinSub::PlusMinus,
_ => BrkBinSub::MinusMinus,
};
if let Some(v) = e.child("m:wrapIndent").and_then(|c| crate::units::tw(c, "m:val")).filter(|v| v.is_finite() && *v >= 0.0) {
m.wrap_indent = v.min(1584.0);
}
m.wrap_right = prop(pr, "m:wrapRight", false);
m
}
/// OMML on/off property: absent `m:val` = on.
fn on(e: &El) -> bool {
match e.attr("m:val") {
@@ -274,10 +251,6 @@ fn run(r: &El) -> Option<MRun> {
};
out.nor = pr.child("m:nor").is_some_and(on);
out.lit = pr.child("m:lit").is_some_and(on);
if let Some(b) = pr.child("m:brk") {
let aln = b.attr("m:alnAt").and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(0);
out.brk = Some(aln.min(255) as u8);
}
}
wrpr(r.child("w:rPr"), &mut out);
Some(out)
-11
View File
@@ -63,16 +63,6 @@ fn part_of(rels: &Rels, id: &str, kind: &str) -> Option<String> {
/// Read a `.docx` package.
pub fn read(bytes: &[u8]) -> Result<Document, DocxError> {
// A password-protected document is a compound file, not a zip: say so rather than failing
// as a broken zip (`read_with_password` opens it).
if crate::is_encrypted(bytes) {
return crate::read_with_password(bytes, None);
}
read_package(bytes)
}
/// [`read`] for bytes known not to be an encrypted package.
pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
let pkg = Package::open(bytes)?;
let root_rels = pkg.rels("");
let main = root_rels.by_type(rt::OFFICE_DOC).filter(|r| !r.external).map(|r| r.target.clone()).unwrap_or_else(|| "word/document.xml".to_string());
@@ -661,7 +651,6 @@ impl Reader<'_> {
if k.name == "w:drawingGridHorizontalSpacing" { s.grid_h = v } else { s.grid_v = v }
}
}
"m:mathPr" => s.math = Some(math::read_math_pr(k)),
"w:mirrorMargins" => s.mirror_margins = on_off(k),
"w:autoHyphenation" => s.auto_hyphenation = on_off(k),
"w:footnotePr" => {
+2 -33
View File
@@ -1,8 +1,6 @@
//! [`wordcraft_doc::math`] tree → Office Math (OMML).
use wordcraft_doc::math::{
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, MathProps, ScriptKind, parse_linear,
};
use wordcraft_doc::math::{Arg, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, ScriptKind, parse_linear};
use crate::xml::W;
@@ -88,30 +86,6 @@ fn wrap_ns(xml: &str) -> String {
format!("<w:x{decl}>{xml}</w:x>")
}
/// The settings part's `m:mathPr` (CT_MathPr order; `m:wrapIndent` and `m:wrapRight` are a choice).
pub fn math_pr(w: &mut W, m: &MathProps) {
w.open("m:mathPr", &[]);
let bin = match m.brk_bin {
BrkBin::Before => "before",
BrkBin::After => "after",
BrkBin::Repeat => "repeat",
};
val(w, "m:brkBin", bin);
let sub = match m.brk_bin_sub {
BrkBinSub::MinusMinus => "--",
BrkBinSub::MinusPlus => "-+",
BrkBinSub::PlusMinus => "+-",
};
val(w, "m:brkBinSub", sub);
if m.wrap_right {
w.empty("m:wrapRight", &[]);
} else {
let indent = if m.wrap_indent.is_finite() { m.wrap_indent.clamp(0.0, 1584.0) } else { wordcraft_doc::math::DEFAULT_WRAP_INDENT };
val(w, "m:wrapIndent", &crate::units::twips(indent));
}
w.close("m:mathPr");
}
fn val(w: &mut W, name: &str, v: &str) {
w.empty(name, &[("m:val", v)]);
}
@@ -408,7 +382,7 @@ fn run(w: &mut W, r: &MRun) {
MScr::SansSerif => Some("sans-serif"),
MScr::Monospace => Some("monospace"),
};
if sty.is_some() || scr.is_some() || r.nor || r.lit || r.brk.is_some() {
if sty.is_some() || scr.is_some() || r.nor || r.lit {
w.open("m:rPr", &[]);
if r.lit {
w.empty("m:lit", &[]);
@@ -422,11 +396,6 @@ fn run(w: &mut W, r: &MRun) {
if let Some(s) = sty {
val(w, "m:sty", s);
}
match r.brk {
Some(0) => w.empty("m:brk", &[]),
Some(n) => w.empty("m:brk", &[("m:alnAt", &n.to_string())]),
None => {}
}
w.close("m:rPr");
}
w.open("w:rPr", &[]);
-3
View File
@@ -802,9 +802,6 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool) -> Vec<u8> {
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()
}
-29
View File
@@ -962,12 +962,6 @@ fn settings_core_theme_round_trip() {
d.settings.protection = Some("readOnly".into());
d.settings.grid_h = 5.5;
d.settings.grid_v = 18.0;
d.settings.math = Some(wordcraft_doc::math::MathProps {
brk_bin: wordcraft_doc::math::BrkBin::Repeat,
brk_bin_sub: wordcraft_doc::math::BrkBinSub::PlusMinus,
wrap_indent: 36.0,
wrap_right: false,
});
d.core.title = "Title & <stuff>".into();
d.core.subject = "Subj".into();
d.core.creator = "Me".into();
@@ -1245,26 +1239,3 @@ fn shape_effects_round_trip() {
o => panic!("{o:?}"),
}
}
#[test]
fn equation_manual_breaks_round_trip() {
use wordcraft_doc::math::{MNode, MRun, Math};
let nodes = vec![
MNode::Run(MRun::new("a=b")),
MNode::Run(MRun { brk: Some(1), ..MRun::new("+c") }),
MNode::Run(MRun { brk: Some(0), ..MRun::new("+d") }),
];
let mut d = Document::new();
let math = Math { nodes: nodes.clone(), ..Default::default() };
let mut p = Paragraph::with_text("", CharProps::default());
p.insert_object(0, InlineObject::Equation { linear: "a=b+c+d".into(), display: true, math }, &CharProps::default()).unwrap();
d.body = vec![para_block(p)];
let r = rt(&d);
let got = paras(&r).iter().flat_map(|p| p.objects.iter()).find_map(|o| match o {
InlineObject::Equation { math, .. } => Some(math.nodes.clone()),
_ => None,
});
assert_eq!(got, Some(nodes));
// Word's own settings round-trip; a document without them gets none.
assert_eq!(r.settings.math, None);
}
-1
View File
@@ -26,7 +26,6 @@ log = { workspace = true }
regex = { workspace = true }
regex-automata = { workspace = true }
image = { workspace = true }
zeroize = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
# The browser clock for `now_iso` (`SystemTime::now()` panics on wasm32).
+9 -57
View File
@@ -29,20 +29,9 @@ fn set_author(s: &mut Session, v: &Value) -> CmdResult {
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("file.new", "New", "File", new).key("Mod+N").params(r#"{"template"?: "blank|sample|letter|resume|report"}"#).pure(),
CommandSpec::new("file.open", "Open", "File", open)
.key("Mod+O")
.params(r#"{"path": string, "data"?: base64 (the file's bytes; `path` then only names it), "password"?: string (for a password-protected Word document)}"#)
.pure(),
CommandSpec::new("file.save", "Save", "File", save)
.key("Mod+S")
.params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does)}"#)
.pure(),
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key("F12").params(r#"{"path": string, "password"?: string}"#).pure(),
// Pure: the password must not land in Repeat or a recorded macro, and it isn't an edit
// to undo; it marks the document as changed itself.
CommandSpec::new("file.encrypt", "Encrypt with Password", "File › Info › Protect Document", encrypt)
.params(r#"{"password": string (Word documents are saved encrypted with it) | null or "" (saved unencrypted)}"#)
.pure(),
CommandSpec::new("file.open", "Open", "File", open).key("Mod+O").params(r#"{"path": string}"#).pure(),
CommandSpec::new("file.save", "Save", "File", save).key("Mod+S").params(r#"{"path"?: string}"#).pure(),
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key("F12").params(r#"{"path": string}"#).pure(),
CommandSpec::new("file.exportPdf", "Export PDF", "File › Export", |s, v| {
let path = p::req_str(v, "path")?;
let path = if path.to_ascii_lowercase().ends_with(".pdf") { path.to_string() } else { format!("{path}.pdf") };
@@ -123,39 +112,15 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
s.ui_requests.push(json!({"open": "openFile"}));
return sel_result(s);
};
let password = p::str(v, "password");
let (doc, encrypted) = if let Some(data) = p::str(v, "data") {
let doc = if let Some(data) = p::str(v, "data") {
let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?;
crate::io::open_bytes_with(path, &bytes, password).map_err(CmdError::Failed)?
crate::io::open_bytes(path, &bytes).map_err(CmdError::Failed)?
} else {
crate::io::open_path_with(std::path::Path::new(path), password).map_err(CmdError::Failed)?
crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?
};
s.set_document(doc);
s.path = Some(path.into());
// Like Word, a document opened with a password is saved with it again.
if encrypted {
s.password = password.map(crate::io::Password::new);
}
Ok(json!({"path": path, "paragraphs": s.doc.paragraph_count(), "words": s.doc.word_count(), "encrypted": encrypted}))
}
/// File › Info › Protect Document › Encrypt with Password: the password Word documents are saved
/// with from now on, or none.
fn encrypt(s: &mut Session, v: &Value) -> CmdResult {
let password = match v.get("password") {
Some(Value::Null) => None,
Some(Value::String(pw)) if pw.is_empty() => None,
Some(Value::String(pw)) => {
wordcraft_docx::check_password(pw).map_err(|e| CmdError::Params(e.to_string()))?;
Some(crate::io::Password::new(pw))
}
_ => return Err(CmdError::Params("`password` must be a string, or null to remove the password".into())),
};
if password != s.password {
s.password = password;
s.dirty = true;
}
Ok(json!({"encrypted": s.password.is_some()}))
Ok(json!({"path": path, "paragraphs": s.doc.paragraph_count(), "words": s.doc.word_count()}))
}
fn save(s: &mut Session, v: &Value) -> CmdResult {
@@ -166,26 +131,14 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
return Ok(json!({"saved": false}));
}
};
if let Some(pw) = v.get("password").filter(|pw| !pw.is_null()) {
let Some(pw) = pw.as_str().filter(|pw| !pw.is_empty()) else {
return Err(CmdError::Params("`password` must be a non-empty string (file.encrypt removes a password)".into()));
};
if !crate::io::can_encrypt(&path.to_string_lossy()) {
return Err(CmdError::Params("only Word documents (.docx, .docm, .dotx, .dotm) can be saved with a password".into()));
}
wordcraft_docx::check_password(pw).map_err(|e| CmdError::Params(e.to_string()))?;
s.password = Some(crate::io::Password::new(pw));
}
s.stamp_save();
let password = s.password.as_ref().map(crate::io::Password::as_str);
crate::io::save_path_with(&path, &s.doc, password).map_err(CmdError::Failed)?;
let encrypted = password.is_some() && crate::io::can_encrypt(&path.to_string_lossy());
crate::io::save_path(&path, &s.doc).map_err(CmdError::Failed)?;
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
if ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json"].contains(&ext.as_str()) {
s.path = Some(path.clone());
s.dirty = false;
}
Ok(json!({"saved": true, "path": path.to_string_lossy(), "encrypted": encrypted}))
Ok(json!({"saved": true, "path": path.to_string_lossy()}))
}
fn save_as(s: &mut Session, v: &Value) -> CmdResult {
@@ -263,7 +216,6 @@ fn info(s: &mut Session, _: &Value) -> CmdResult {
"trackChanges": s.doc.settings.track_changes,
"comments": s.doc.comments.len(),
"sections": s.doc.sections().len(),
"encrypted": s.password.is_some(),
"layoutMs": l.ms,
}))
}
+5
View File
@@ -173,6 +173,11 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"hint": "Save or export a copy (PDF, Word document) to share it."}))
})
.pure(),
CommandSpec::new("file.encrypt", "Encrypt with Password", "File › Info › Protect Document", |s, _| {
s.status = "Password encryption isn't available yet; use Restrict Editing to prevent changes.".into();
Err(CmdError::Failed("password encryption isn't available yet".into()))
})
.pure(),
CommandSpec::new("insert.quickParts", "Quick Parts", "Insert › Text", quick_parts)
.params(r#"{"save"?: name (from the selection), "insert"?: name, "delete"?: name} → list"#),
CommandSpec::new("insert.autoText", "AutoText", "Insert › Text › Quick Parts", quick_parts).params(r#"{"save"?: name, "insert"?: name}"#),
+8 -82
View File
@@ -36,63 +36,9 @@ pub fn is_word_package(ext: &str) -> bool {
wordcraft_docx::Flavor::from_ext(ext).is_some()
}
/// A document password: wiped from memory when dropped, never printed (`Debug` shows `***`).
#[derive(Clone, Default, PartialEq, Eq)]
pub struct Password(zeroize::Zeroizing<String>);
impl Password {
pub fn new(s: &str) -> Password {
Password(zeroize::Zeroizing::new(s.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
/// The text, for a password box to edit in place.
pub fn as_mut_string(&mut self) -> &mut String {
&mut self.0
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl std::fmt::Debug for Password {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Password(***)")
}
}
/// Does this open error mean the file is password-protected and no password was given?
pub fn needs_password(err: &str) -> bool {
err.contains(wordcraft_docx::PASSWORD_REQUIRED)
}
/// Does this open error mean the password given was wrong?
pub fn wrong_password(err: &str) -> bool {
err.contains(wordcraft_docx::WRONG_PASSWORD)
}
/// Can files with this extension be saved with a password (Word's packages)?
pub fn can_encrypt(name: &str) -> bool {
is_word_package(&ext_of(name))
}
/// Parse a document from bytes; `name` gives the format by extension.
pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<Document, String> {
open_bytes_with(name, bytes, None).map(|(doc, _)| doc)
}
/// [`open_bytes`] for a file that may be password-protected: a Word document encrypted with a
/// password ([MS-OFFCRYPTO] agile encryption) is decrypted with `password` first. Also says
/// whether the file was encrypted. Without the password needed the error satisfies
/// [`needs_password`]; with a wrong one, [`wrong_password`].
pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>) -> Result<(Document, bool), String> {
let ext = ext_of(name);
if matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "doc" | "dot") && wordcraft_docx::is_encrypted(bytes) {
let mut doc = wordcraft_docx::read_with_password(bytes, password).map_err(|e| e.to_string())?;
doc.ensure_nonempty();
return Ok((doc, true));
}
let mut doc = match ext.as_str() {
"txt" | "text" | "" => Document::from_text(&decode_text(bytes)),
"json" => serde_json::from_slice::<Document>(bytes).map_err(|e| format!("{name}: {e}"))?,
@@ -102,7 +48,7 @@ pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>) -> Resu
},
};
doc.ensure_nonempty();
Ok((doc, false))
Ok(doc)
}
/// Serialise a document; `name` gives the format by extension.
@@ -118,23 +64,8 @@ pub fn save_bytes(name: &str, doc: &Document) -> Result<Vec<u8>, String> {
}
}
/// [`save_bytes`], encrypted with `password` when there is one and the format is a Word package
/// (other formats are copies and are written as they are).
pub fn save_bytes_with(name: &str, doc: &Document, password: Option<&str>) -> Result<Vec<u8>, String> {
let bytes = save_bytes(name, doc)?;
match password {
Some(pw) if can_encrypt(name) => wordcraft_docx::encrypt(&bytes, pw).map_err(|e| e.to_string()),
_ => Ok(bytes),
}
}
pub fn open_path(path: &std::path::Path) -> Result<Document, String> {
open_path_with(path, None).map(|(doc, _)| doc)
}
/// [`open_path`] for a file that may be password-protected (see [`open_bytes_with`]).
#[cfg(not(target_arch = "wasm32"))]
pub fn open_path_with(path: &std::path::Path, password: Option<&str>) -> Result<(Document, bool), String> {
pub fn open_path(path: &std::path::Path) -> Result<Document, String> {
let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?;
if meta.len() > 2 << 30 {
return Err(format!("{}: file is larger than 2 GB", path.display()));
@@ -146,9 +77,9 @@ pub fn open_path_with(path: &std::path::Path, password: Option<&str>) -> Result<
let images = LocalImages::new(path.parent().unwrap_or(std::path::Path::new("")));
let mut doc = wordcraft_formats::html::import_with(&wordcraft_formats::html::decode(&bytes), &|src| images.load(src));
doc.ensure_nonempty();
return Ok((doc, false));
return Ok(doc);
}
open_bytes_with(&name, &bytes, password)
open_bytes(&name, &bytes)
}
/// The pictures an HTML file names by a path relative to its folder.
@@ -255,18 +186,13 @@ fn read_regular_file(path: &std::path::Path, limit: u64) -> Option<Vec<u8>> {
}
#[cfg(target_arch = "wasm32")]
pub fn open_path_with(path: &std::path::Path, _password: Option<&str>) -> Result<(Document, bool), String> {
pub fn open_path(path: &std::path::Path) -> Result<Document, String> {
Err(format!("{}: files are opened through the browser on the web", path.display()))
}
pub fn save_path(path: &std::path::Path, doc: &Document) -> Result<(), String> {
save_path_with(path, doc, None)
}
/// [`save_path`], encrypted with `password` as [`save_bytes_with`] does.
#[cfg(not(target_arch = "wasm32"))]
pub fn save_path_with(path: &std::path::Path, doc: &Document, password: Option<&str>) -> Result<(), String> {
let bytes = save_bytes_with(&path.to_string_lossy(), doc, password)?;
pub fn save_path(path: &std::path::Path, doc: &Document) -> Result<(), String> {
let bytes = save_bytes(&path.to_string_lossy(), doc)?;
// Write atomically: temp file next to the target, then rename.
let tmp = path.with_extension(format!("{}.tmp", ext_of(&path.to_string_lossy())));
std::fs::write(&tmp, &bytes).map_err(|e| format!("{}: {e}", tmp.display()))?;
@@ -274,7 +200,7 @@ pub fn save_path_with(path: &std::path::Path, doc: &Document, password: Option<&
}
#[cfg(target_arch = "wasm32")]
pub fn save_path_with(path: &std::path::Path, _doc: &Document, _password: Option<&str>) -> Result<(), String> {
pub fn save_path(path: &std::path::Path, _doc: &Document) -> Result<(), String> {
Err(format!("{}: files are saved through the browser on the web", path.display()))
}
-1
View File
@@ -19,7 +19,6 @@ use std::collections::HashMap;
use serde_json::Value;
pub use io::Password;
pub use session::{ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, ViewState};
pub use wordcraft_doc as doc;
pub use wordcraft_layout as layout;
-8
View File
@@ -247,10 +247,6 @@ pub struct Session {
pub math_latex: bool,
/// Text typed into equations is normal (non-math) text.
pub math_normal_text: bool,
/// The password Word documents are saved with (File › Info › Protect Document › Encrypt with
/// Password, `file.encrypt`); `None` saves them unencrypted. Kept from a password-protected
/// file that was opened, cleared when the document is replaced.
pub password: Option<crate::io::Password>,
/// More pictures and shapes selected along with the one the selection holds (Shift+click),
/// for Group. Cleared by any edit or selection change other than adding to it.
pub also_selected: Vec<Pos>,
@@ -333,7 +329,6 @@ impl Session {
also_selected: Vec::new(),
prefs: Prefs::default(),
read_aloud: Default::default(),
password: None,
column: None,
column_mode: false,
}
@@ -496,7 +491,6 @@ impl Session {
self.document_id = self.document_id.wrapping_add(1);
self.doc = doc;
self.doc.ensure_nonempty();
self.password = None;
self.sel = Selection::caret(self.doc.start_of(StoryRef::Body));
self.pending = None;
self.also_selected.clear();
@@ -569,8 +563,6 @@ impl Session {
math: _,
math_latex: _,
math_normal_text: _,
// The save password, like the file path: not an edit to the document.
password: _,
also_selected: _,
// Column selection, like `sel`'s shape: valid only while `sel` matches it.
column: _,
-46
View File
@@ -2353,52 +2353,6 @@ fn accessibility_reports_a_chart_without_alt_text() {
assert!(issues.iter().any(|i| i["issue"] == "Chart or diagram has no alternative text"), "{issues:?}");
}
/// Encrypt with Password (#55): a document saved with a password opens only with it, keeps it
/// for the next save, and saves unencrypted once the password is removed.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn password_protected_documents_round_trip() {
let dir = scratch_dir("password");
let path = dir.join("secret.docx");
let path_str = path.to_string_lossy().to_string();
let mut a = s();
run(&mut a, "document.setText", json!({"text": "Quarterly numbers"}));
let r = run(&mut a, "file.save", json!({"path": path_str, "password": "hunter2"}));
assert_eq!(r["encrypted"], true);
assert!(wordcraft_docx::is_encrypted(&std::fs::read(&path).unwrap()));
assert!(a.run("file.save", &json!({"path": dir.join("x.txt").to_string_lossy(), "password": "pw"})).is_err(), "only Word packages encrypt");
// No password, then a wrong one: clear errors, and the open document stays.
let mut t = s();
let e = t.run("file.open", &json!({"path": path_str})).unwrap_err().to_string();
assert!(crate::io::needs_password(&e), "{e}");
let e = t.run("file.open", &json!({"path": path_str, "password": "hunter3"})).unwrap_err().to_string();
assert!(crate::io::wrong_password(&e), "{e}");
assert!(t.path.is_none());
let r = run(&mut t, "file.open", json!({"path": path_str, "password": "hunter2"}));
assert_eq!(r["encrypted"], true);
assert_eq!(text(&t), "Quarterly numbers");
assert_eq!(run(&mut t, "file.info", json!({}))["encrypted"], true);
// Saving again keeps the password, as Word does.
run(&mut t, "text.insert", json!({"text": "Q3 "}));
run(&mut t, "file.save", json!({}));
let (doc, encrypted) = crate::io::open_path_with(&path, Some("hunter2")).unwrap();
assert!(encrypted && doc.plain_text(StoryRef::Body).contains("Q3"));
// Removing the password saves the document unencrypted.
assert!(t.run("file.encrypt", &json!({})).is_err());
assert!(t.run("file.encrypt", &json!({"password": "x".repeat(300)})).is_err());
t.dirty = false;
assert_eq!(run(&mut t, "file.encrypt", json!({"password": null}))["encrypted"], false);
assert!(t.dirty, "removing the password is a change to save");
run(&mut t, "file.save", json!({}));
assert!(crate::io::open_path(&path).unwrap().plain_text(StoryRef::Body).contains("Quarterly"));
// A password never shows in debug output.
assert_eq!(format!("{:?}", crate::io::Password::new("hunter2")), "Password(***)");
let _ = std::fs::remove_dir_all(&dir);
}
/// Shape Format › Shape Effects (#275): presets and custom values apply to the selected shape,
/// an omitted key is kept, hostile numbers are clamped and each change is one undo step.
#[test]
+21 -341
View File
@@ -11,8 +11,7 @@
use std::sync::Arc;
use wordcraft_doc::math::{
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MClass, MNode, MRun, MScr, MSty, MathProps, ScriptKind, is_integral, math_alnum,
parse_linear,
Arg, ColJc, FracKind, LimLoc, MAX_DEPTH, MClass, MNode, MRun, MScr, MSty, ScriptKind, is_integral, math_alnum, parse_linear,
};
use wordcraft_doc::props::{Rgb, TextColor};
use wordcraft_doc::resolve::ResolvedChar;
@@ -175,16 +174,14 @@ fn split_number(row: &[MNode]) -> Option<(Arg, Arg, usize)> {
/// Lay out an equation set at the size and colour of `rc`. `display`: an equation on a line of
/// its own (bigger operators, limits above and below, full-size fractions).
/// `avail` is the width of the line (numbered display equations fill it, wider display equations
/// wrap to it as `props` says); `counter` counts the document's automatic equation numbers so far
/// and advances past this equation's.
/// `avail` is the width of the line (numbered display equations fill it); `counter` counts the
/// document's automatic equation numbers so far and advances past this equation's.
pub fn layout_equation(
math: &wordcraft_doc::math::Math,
linear: &str,
rc: &ResolvedChar,
display: bool,
avail: f32,
props: &MathProps,
counter: &mut u32,
) -> MathLayout {
let parsed;
@@ -216,7 +213,6 @@ pub fn layout_equation(
let b = match nodes.as_slice() {
[] => cx.hint(st),
[MNode::EqArr { rows }] if display && rows.iter().any(|r| split_number(r).is_some()) => cx.numbered(rows, st, avail, counter),
_ if display => cx.wrapped(nodes, st, avail, props),
_ => cx.arg(nodes, st, 0),
};
let mut slots = b.slots;
@@ -311,8 +307,6 @@ struct Bx {
slots: Vec<Slot>,
/// Text atoms: x of each character boundary (one more than the characters).
cuts: Vec<f32>,
/// A manual line break before this atom (`m:brk`; the value is its `m:alnAt`).
brk: Option<u8>,
}
impl Bx {
@@ -553,7 +547,6 @@ impl Ctx {
let path = self.path.clone();
match n {
MNode::Run(r) => {
let first = out.len();
for part in self.run_atoms(r, st, u, &path) {
match part {
RunPart::Atom(b) => out.push((b, seg)),
@@ -563,9 +556,6 @@ impl Ctx {
}
}
}
if let Some((b, _)) = out.get_mut(first) {
b.brk = r.brk;
}
u += r.text.chars().count();
}
MNode::Nary { .. } | MNode::Func { .. } => {
@@ -1602,198 +1592,6 @@ impl Ctx {
out
}
/// A display equation, broken into lines where it is wider than `avail` or a run asks for a
/// manual break (`m:brk`). Breaks fall only at top-level binary operators and relations (never
/// inside a fraction, radical, script or matrix), before or after the operator as `m:brkBin`
/// says; lines are filled greedily and a term wider than the line overflows it. The first line
/// starts at the left; the rest are indented by `m:wrapIndent`, set flush right with
/// `m:wrapRight`, or aligned at an operator of the first line (`m:alnAt`).
fn wrapped(&mut self, a: &[MNode], st: St, avail: f32, mp: &MathProps) -> Bx {
let em = self.em(st);
let atoms: Vec<Bx> = self.row(a, st, 0, 0).into_iter().map(|(b, _)| b).collect();
let gaps = gaps_between(&atoms, st, em);
let total: f32 = atoms.iter().map(|b| b.w).sum::<f32>() + gaps.iter().sum::<f32>();
let manual = atoms.iter().skip(1).any(|b| b.brk.is_some());
if atoms.len() < 2 || (!manual && total <= avail + 0.01) {
let mut b = compose(atoms, st, em);
finalize(&mut b, &self.path, em);
return b;
}
let cls = classes(&atoms);
let n = atoms.len();
let is_op = |i: usize| -> bool {
atoms.get(i).is_some_and(|b| b.glyph.is_some() && !b.transparent)
&& cls.get(i).is_some_and(|c| matches!(c.0, Cls::Bin | Cls::Rel) && c.0 == c.1)
};
// Chunk starts (after the first): the atom index → forced (`Some(alnAt)`) and the
// operator repeated at the start of the line it begins.
let mut starts: std::collections::BTreeMap<usize, (Option<u8>, Option<usize>)> = std::collections::BTreeMap::new();
for i in 1..n {
let op = is_op(i);
let at = if op && mp.brk_bin != BrkBin::Before { i + 1 } else { i };
if at == 0 || at >= n {
continue;
}
let repeat = (op && mp.brk_bin == BrkBin::Repeat).then_some(i);
let forced = atoms.get(i).and_then(|b| b.brk);
if op || forced.is_some() {
let e = starts.entry(at).or_insert((None, None));
if forced.is_some() {
e.0 = forced;
}
if repeat.is_some() {
e.1 = repeat;
}
}
}
// Chunks: [s, e) with their width inside and the gap before them.
let mut bounds: Vec<usize> = vec![0];
bounds.extend(starts.keys().copied());
bounds.push(n);
let width_of = |s: usize, e: usize| -> f32 {
(s..e).map(|i| atoms.get(i).map_or(0.0, |b| b.w) + if i > s { gaps.get(i).copied().unwrap_or(0.0) } else { 0.0 }).sum()
};
// The operator glyphs a repeated operator shows at the end of one line and the start of
// the next.
let repeats: std::collections::HashMap<usize, (Option<Bx>, Bx)> = starts
.values()
.filter_map(|v| v.1)
.filter_map(|i| {
let b = atoms.get(i)?;
let (end, start) = match op_char(b) {
Some('−' | '-') => match mp.brk_bin_sub {
BrkBinSub::MinusMinus => ('−', '−'),
BrkBinSub::MinusPlus => ('−', '+'),
BrkBinSub::PlusMinus => ('+', '−'),
},
Some(c) => (c, c),
None => return None,
};
let end = (Some(end) != op_char(b)).then(|| self.op_glyph(b, end, st)).flatten();
Some((i, (end, self.op_glyph(b, start, st)?)))
})
.collect();
let indent = if mp.wrap_indent.is_finite() { mp.wrap_indent.clamp(0.0, 1584.0) } else { 0.0 }.min(avail * 0.5);
// Greedy fill: lines as lists of chunks (index into `bounds`).
let mut lines: Vec<(usize, usize)> = Vec::new();
let mut cur_w = 0.0f32;
let mut auto = false;
for c in 0..bounds.len().saturating_sub(1) {
let (s, e) = (bounds.get(c).copied().unwrap_or(0), bounds.get(c + 1).copied().unwrap_or(n));
let w = width_of(s, e);
let lead = gaps.get(s).copied().unwrap_or(0.0);
let info = starts.get(&s).copied();
let copy_w = info.and_then(|v| v.1).and_then(|i| repeats.get(&i)).map_or(0.0, |r| r.1.w + lead);
let line_avail = if lines.len() <= 1 || mp.wrap_right { avail } else { avail - indent };
match lines.last_mut() {
Some(l) if info.is_some_and(|v| v.0.is_none()) && cur_w + lead + w <= line_avail + 0.01 => {
l.1 = c + 1;
cur_w += lead + w;
}
Some(_) if info.is_some_and(|v| v.0.is_none()) => {
auto = true;
lines.push((c, c + 1));
cur_w = copy_w + w;
}
_ => {
lines.push((c, c + 1));
cur_w = copy_w + w;
}
}
}
// Build each line's box.
let ops: Vec<bool> = (0..n).map(is_op).collect();
let path = self.path.clone();
let mut atoms: Vec<Option<Bx>> = atoms.into_iter().map(Some).collect();
let mut boxes: Vec<(Bx, Option<u8>)> = Vec::with_capacity(lines.len());
let mut ops_x: Vec<f32> = Vec::new();
for (li, &(c0, c1)) in lines.iter().enumerate() {
let (s, e) = (bounds.get(c0).copied().unwrap_or(0), bounds.get(c1).copied().unwrap_or(n));
let info = starts.get(&s).copied();
let next = lines.get(li + 1).and_then(|l| bounds.get(l.0)).and_then(|s| starts.get(s)).and_then(|v| v.1);
let mut row = Bx::default();
let mut x = 0.0f32;
let copy = info.and_then(|v| v.1).and_then(|i| repeats.get(&i)).map(|r| r.1.clone());
let has_copy = copy.is_some();
if let Some(cb) = copy {
let w = cb.w;
row.put(cb, 0.0, 0.0);
x = w;
}
for i in s..e {
let Some(mut b) = atoms.get_mut(i).and_then(Option::take) else { continue };
if i > s || has_copy {
x += gaps.get(i).copied().unwrap_or(0.0);
}
if next == Some(i)
&& let Some((Some(end), _)) = repeats.get(&i)
{
let mut r = end.clone();
let k = if b.w > 0.0 { r.w / b.w } else { 1.0 };
r.slots = std::mem::take(&mut b.slots)
.into_iter()
.map(|mut sl| {
sl.x *= k;
sl
})
.collect();
b = r;
}
if li == 0 && ops.get(i).copied().unwrap_or(false) {
ops_x.push(x);
}
let w = b.w;
row.put(b, x, 0.0);
x += w;
}
row.w = x;
finalize(&mut row, &path, em);
boxes.push((row, info.and_then(|v| v.0)));
}
let widest = boxes.iter().map(|(b, _)| b.w).fold(0.0f32, f32::max);
let right = if auto { avail.max(widest) } else { widest };
let mut placed = Vec::with_capacity(boxes.len());
for (li, (b, aln)) in boxes.into_iter().enumerate() {
let x = if li == 0 {
0.0
} else if let Some(ax) = aln.filter(|a| *a > 0).and_then(|a| ops_x.get(a as usize - 1)) {
*ax
} else if mp.wrap_right {
(right - b.w).max(0.0)
} else {
indent
};
placed.push((b, x));
}
// Stack the lines down from the first one's baseline.
let (gap, skip) = (em * 0.2, em * 1.25);
let mut out = Bx::default();
let mut y = 0.0f32;
let mut prev_d: Option<f32> = None;
for (b, x) in placed {
if let Some(pd) = prev_d {
y -= (pd + gap + b.a).max(skip);
}
prev_d = Some(b.d);
out.w = out.w.max(x + b.w);
out.put(b, x, y);
}
out.w = out.w.max(right);
out
}
/// Operator `c` drawn like the operator atom `like` (its face, size and colour), without
/// caret slots.
fn op_glyph(&self, like: &Bx, c: char, st: St) -> Option<Bx> {
let MItem::Glyphs { face, size, color, synth_bold, synth_italic, .. } = like.items.iter().find(|i| matches!(i, MItem::Glyphs { .. }))? else {
return None;
};
let face = if face.covers(c) { *face } else { self.face };
let mut b = self.glyphs(face, &c.to_string(), *size, st.level, *color, (*synth_bold, *synth_italic));
b.cuts.clear();
Some(b.cls(like.first))
}
fn matrix(&mut self, rows: &[Vec<Arg>], col_jc: &[ColJc], st: St, d: usize) -> Bx {
let em = self.em(st);
let mut cells: Vec<Vec<Bx>> = Vec::new();
@@ -1842,18 +1640,6 @@ impl Ctx {
}
}
/// The character an operator atom draws.
fn op_char(b: &Bx) -> Option<char> {
b.items.iter().find_map(|i| match i {
MItem::Glyphs { text, .. } => {
let mut cs = text.chars();
let c = cs.next()?;
cs.next().is_none().then_some(c)
}
_ => None,
})
}
enum RunPart {
Atom(Bx),
/// An `&` alignment mark (a zero-width atom holding its caret slots).
@@ -1875,22 +1661,6 @@ fn finalize(b: &mut Bx, path: &[(usize, usize)], em: f32) {
/// relation, an opening bracket or punctuation is ordinary, as is one before a relation,
/// closing bracket or punctuation).
fn gaps_between(atoms: &[Bx], st: St, em: f32) -> Vec<f32> {
let cls = classes(atoms);
let solid: Vec<usize> = (0..atoms.len()).filter(|i| atoms.get(*i).is_some_and(|b| !b.transparent)).collect();
let mut gaps = vec![0.0f32; atoms.len()];
for k in 1..solid.len() {
let (Some(&p), Some(&i)) = (solid.get(k - 1), solid.get(k)) else { continue };
let (Some(l), Some(r)) = (cls.get(p), cls.get(i)) else { continue };
if let Some(g) = gaps.get_mut(i) {
*g = spacing_mu(l.1, r.0, st) * em / 18.0;
}
}
gaps
}
/// Each atom's classes (first, last) in context: a binary operator where TeX makes it ordinary
/// (a unary minus) becomes ordinary.
fn classes(atoms: &[Bx]) -> Vec<(Cls, Cls)> {
let mut cls: Vec<(Cls, Cls)> = atoms.iter().map(|b| (b.first, b.last)).collect();
let solid: Vec<usize> = (0..atoms.len()).filter(|i| atoms.get(*i).is_some_and(|b| !b.transparent)).collect();
for (k, &i) in solid.iter().enumerate() {
@@ -1911,7 +1681,15 @@ fn classes(atoms: &[Bx]) -> Vec<(Cls, Cls)> {
}
}
}
cls
let mut gaps = vec![0.0f32; atoms.len()];
for k in 1..solid.len() {
let (Some(&p), Some(&i)) = (solid.get(k - 1), solid.get(k)) else { continue };
let (Some(l), Some(r)) = (cls.get(p), cls.get(i)) else { continue };
if let Some(g) = gaps.get_mut(i) {
*g = spacing_mu(l.1, r.0, st) * em / 18.0;
}
}
gaps
}
/// Atoms side by side with inter-atom spacing.
@@ -1967,96 +1745,7 @@ mod tests {
}
fn lay(lin: &str, display: bool) -> MathLayout {
layout_equation(&Math::default(), lin, &rc(), display, 400.0, &MathProps::default(), &mut 0)
}
/// The glyphs of a laid-out equation by line: (baseline, [(x, text)]), top line first.
fn lines(m: &MathLayout) -> Vec<(f32, Vec<(f32, String)>)> {
let mut out: Vec<(f32, Vec<(f32, String)>)> = Vec::new();
for it in &m.items {
if let MItem::Glyphs { glyphs, text, .. } = it
&& let Some(&(_, x, y)) = glyphs.first()
{
match out.iter_mut().find(|l| (l.0 - y).abs() < 0.5) {
Some(l) => l.1.push((x, text.clone())),
None => out.push((y, vec![(x, text.clone())])),
}
}
}
out.sort_by(|a, b| b.0.total_cmp(&a.0));
for l in &mut out {
l.1.sort_by(|a, b| a.0.total_cmp(&b.0));
}
out
}
fn lay_in(math: &Math, lin: &str, display: bool, avail: f32, mp: &MathProps) -> MathLayout {
layout_equation(math, lin, &rc(), display, avail, mp, &mut 0)
}
#[test]
fn long_display_equations_wrap_before_operators_and_indent() {
let sum = "y=a+b+c+d+e+f+g+h+i+j+k+l+m+n+o+p+q+r+s+t+u+v+w";
let mp = MathProps::default();
let full = lay_in(&Math::default(), sum, true, 1e4, &mp);
assert_eq!(lines(&full).len(), 1, "fits on one line when there is room");
let avail = full.width * 0.45;
assert!(avail > 2.0 * mp.wrap_indent, "room for the indent: {avail}");
let m = lay_in(&Math::default(), sum, true, avail, &mp);
let ls = lines(&m);
assert!(ls.len() >= 2, "{ls:?}");
assert!((m.width - avail).abs() < 0.01, "the wrapped equation fills the line: {} vs {avail}", m.width);
for (k, (_, gs)) in ls.iter().enumerate() {
let (x0, first) = gs.first().cloned().unwrap();
if k == 0 {
// Math italic 𝑦 when a font has it; plain y where none does (FreeBSD CI has no fonts).
assert!(x0.abs() < 0.01 && (first == "𝑦" || first == "y"), "{gs:?}");
} else {
// Continuation lines start with the operator, one wrap indent in.
assert!((x0 - mp.wrap_indent).abs() < 0.01 && first == "+", "line {k}: {gs:?}");
}
for (x, t) in gs {
assert!(*x < avail, "line {k}: {t} at {x} past {avail}");
}
}
// Breaks after the operator, and the operator repeated, as the settings say.
let after = MathProps { brk_bin: BrkBin::After, ..Default::default() };
let ls = lines(&lay_in(&Math::default(), sum, true, avail, &after));
assert!(ls.len() >= 2 && ls[0].1.last().is_some_and(|g| g.1 == "+") && ls[1].1[0].1 != "+", "{ls:?}");
let repeat = MathProps { brk_bin: BrkBin::Repeat, wrap_right: true, ..Default::default() };
let m = lay_in(&Math::default(), sum, true, avail, &repeat);
let ls = lines(&m);
assert!(ls.len() >= 2 && ls[0].1.last().is_some_and(|g| g.1 == "+") && ls[1].1[0].1 == "+", "{ls:?}");
// Inline equations never wrap.
assert_eq!(lines(&lay_in(&Math::default(), sum, false, avail, &mp)).len(), 1);
// A term wider than the line overflows it rather than breaking inside.
let frac = lay_in(&Math::default(), "y=(a+b+c+d+e+f+g+h+i+j+k+l+m+n+o+p+q+r+s+t+u+v+w)/2", true, avail, &mp);
assert!(frac.width > avail && frac.items.iter().any(|i| matches!(i, MItem::Rect { .. })), "{}", frac.width);
}
#[test]
fn manual_breaks_start_new_lines() {
let mp = MathProps::default();
let nodes = vec![MNode::Run(MRun::new("a=b")), MNode::Run(MRun { brk: Some(0), ..MRun::new("+c") })];
let m = lay_in(&Math { nodes, ..Default::default() }, "", true, 400.0, &mp);
let ls = lines(&m);
assert_eq!(ls.len(), 2, "{ls:?}");
assert!((ls[1].1[0].0 - mp.wrap_indent).abs() < 0.01 && ls[1].1[0].1 == "+", "{ls:?}");
assert!(m.width < 400.0, "a manually broken equation is as wide as its lines");
// `m:alnAt`: the new line's operator sits under the first line's first operator.
let nodes = vec![MNode::Run(MRun::new("a=b")), MNode::Run(MRun { brk: Some(1), ..MRun::new("+c") })];
let ls = lines(&lay_in(&Math { nodes, ..Default::default() }, "", true, 400.0, &mp));
let eq_x = ls[0].1.iter().find(|g| g.1 == "=").unwrap().0;
assert!((ls[1].1[0].0 - eq_x).abs() < 0.01, "{ls:?}");
}
#[test]
fn short_display_equations_stay_on_one_line() {
let mp = MathProps::default();
let one = lay_in(&Math::default(), "a+b=c", true, 400.0, &mp);
assert_eq!(lines(&one).len(), 1);
let plain = lay_in(&Math::default(), "a+b=c", false, 400.0, &mp);
assert!(one.width < 400.0 && (one.width - plain.width).abs() < 0.01, "{} vs {}", one.width, plain.width);
layout_equation(&Math::default(), lin, &rc(), display, 400.0, &mut 0)
}
#[test]
@@ -2134,7 +1823,7 @@ mod tests {
MNode::Run(MRun { text: "R".into(), scr: MScr::DoubleStruck, ..Default::default() }),
];
for n in nodes {
let m = layout_equation(&Math { nodes: vec![n.clone()], ..Default::default() }, "", &rc(), true, 400.0, &MathProps::default(), &mut 0);
let m = layout_equation(&Math { nodes: vec![n.clone()], ..Default::default() }, "", &rc(), true, 400.0, &mut 0);
assert!(m.width > 0.0 && m.ascent + m.descent > 0.0, "{n:?} → {m:?}");
for it in &m.items {
if let MItem::Glyphs { glyphs, .. } = it {
@@ -2148,7 +1837,7 @@ mod tests {
fn caret_slots_cover_every_position() {
use wordcraft_doc::math_edit::{MathPos, move_right, units};
let math = Math { nodes: wordcraft_doc::math::parse_linear("x=(-b±√(b^2-4ac))/2a+■(a&b@c&d)"), ..Default::default() };
let m = layout_equation(&math, "", &rc(), true, 400.0, &MathProps::default(), &mut 0);
let m = layout_equation(&math, "", &rc(), true, 400.0, &mut 0);
// Walking the caret through the equation, every position has a slot.
let mut p = MathPos::default();
let mut seen = 0;
@@ -2172,17 +1861,17 @@ mod tests {
#[test]
fn empty_slots_show_placeholders_on_screen_only() {
let math = Math { nodes: wordcraft_doc::math::parse_linear("⬚/⬚"), ..Default::default() };
let m = layout_equation(&math, "", &rc(), true, 400.0, &MathProps::default(), &mut 0);
let m = layout_equation(&math, "", &rc(), true, 400.0, &mut 0);
assert!(m.items.iter().all(|i| matches!(i, MItem::ScreenOnly(_) | MItem::Rect { .. })), "{:?}", m.items);
assert!(m.slots.iter().any(|s| s.path == vec![(0, 0)] && s.off == 0));
let empty = layout_equation(&Math::default(), "", &rc(), true, 400.0, &MathProps::default(), &mut 0);
let empty = layout_equation(&Math::default(), "", &rc(), true, 400.0, &mut 0);
assert!(empty.width > 10.0 && empty.items.iter().all(|i| matches!(i, MItem::ScreenOnly(_))));
}
#[test]
fn numbered_equations_fill_the_line_with_the_number_at_the_right() {
let math = Math { nodes: wordcraft_doc::math::parse_linear("E=mc^2#(1)"), ..Default::default() };
let m = layout_equation(&math, "", &rc(), true, 400.0, &MathProps::default(), &mut 0);
let m = layout_equation(&math, "", &rc(), true, 400.0, &mut 0);
assert!((m.width - 400.0).abs() < 0.01, "{}", m.width);
let xs: Vec<f32> =
m.items.iter().filter_map(|i| if let MItem::Glyphs { glyphs, .. } = i { glyphs.first().map(|g| g.1) } else { None }).collect();
@@ -2193,7 +1882,7 @@ mod tests {
// Automatic numbers count.
let auto = Math { nodes: wordcraft_doc::math::parse_linear("a=b#"), ..Default::default() };
let mut n = 4;
let _ = layout_equation(&auto, "", &rc(), true, 400.0, &MathProps::default(), &mut n);
let _ = layout_equation(&auto, "", &rc(), true, 400.0, &mut n);
assert_eq!(n, 5);
assert_eq!(auto_numbers(&auto, true), 1);
}
@@ -2216,7 +1905,7 @@ mod tests {
for _ in 0..500 {
n = vec![MNode::Frac { kind: FracKind::Bar, num: n.clone(), den: Vec::new() }];
}
let _ = layout_equation(&Math { nodes: n, ..Default::default() }, "", &rc(), true, 400.0, &MathProps::default(), &mut 0);
let _ = layout_equation(&Math { nodes: n, ..Default::default() }, "", &rc(), true, 400.0, &mut 0);
// Empty everything.
let empty = vec![
MNode::Nary { chr: '\0', lim_loc: None, grow: true, sub_hide: false, sup_hide: false, sub: vec![], sup: vec![], e: vec![] },
@@ -2225,16 +1914,7 @@ mod tests {
MNode::EqArr { rows: vec![] },
MNode::Run(MRun { text: String::new(), size: Some(f32::NAN), ..Default::default() }),
];
let m = layout_equation(&Math { nodes: empty, ..Default::default() }, "", &rc(), true, 400.0, &MathProps::default(), &mut 0);
let m = layout_equation(&Math { nodes: empty, ..Default::default() }, "", &rc(), true, 400.0, &mut 0);
assert!(m.width.is_finite() && m.ascent.is_finite());
// Breaks everywhere, odd wrap settings, no room at all.
let runs: Vec<MNode> = (0..2000).map(|i| MNode::Run(MRun { brk: (i % 3 == 0).then_some((i % 256) as u8), ..MRun::new("−+=") })).collect();
for avail in [0.0, f32::NAN, 1e9] {
for brk_bin in [BrkBin::Before, BrkBin::After, BrkBin::Repeat] {
let mp = MathProps { brk_bin, brk_bin_sub: BrkBinSub::PlusMinus, wrap_indent: f32::INFINITY, wrap_right: avail == 0.0 };
let m = layout_equation(&Math { nodes: runs.clone(), ..Default::default() }, "", &rc(), true, avail, &mp, &mut 0);
assert!(m.width.is_finite() && m.descent.is_finite());
}
}
}
}
+1 -2
View File
@@ -640,7 +640,6 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
let mut maths = Vec::new();
let mut displays = Vec::new();
let mut eq_counter = env.eq_number;
let math_props = doc.settings.math.clone().unwrap_or_default();
// The byte ranges of runs left out of the layout, in order, adjacent runs merged.
let mut left: Vec<std::ops::Range<usize>> = Vec::new();
for (range, props) in p.run_ranges() {
@@ -769,7 +768,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
}
Some(InlineObject::Equation { linear, display, math }) => {
let avail = (env.width - rp.indent_left.max(0.0) - rp.indent_right.max(0.0)).max(12.0);
let ml = crate::math::layout_equation(math, linear, &rc, *display, avail, &math_props, &mut eq_counter);
let ml = crate::math::layout_equation(math, linear, &rc, *display, avail, &mut eq_counter);
let st = b.styles.get(si as usize);
// The line is at least as tall as the text around it.
let (a, d) = st.map(|s| (s.ascent, s.descent)).unwrap_or((0.0, 0.0));
-18
View File
@@ -300,24 +300,6 @@ fn info_page(app: &mut WordApp, ui: &mut Ui) {
if focused != app.info_editing.map(|(f, _)| f) {
app.info_editing = None;
}
// Protect Document › Encrypt with Password (#55).
ui.add_space(18.0);
ui.label(egui::RichText::new(tl!("Protect Document")).font(semibold(15.0)));
let encrypted = info.get("encrypted").and_then(|v| v.as_bool()).unwrap_or(false);
ui.label(if encrypted {
tl!("A password is required to open this document.")
} else {
tl!("Anyone can open this document. Encrypt it with a password to keep it private.")
});
ui.horizontal(|ui| {
let label = if encrypted { tl!("Change Password…") } else { tl!("Encrypt with Password…") };
if ui.button(egui::RichText::new(label).font(medium(13.5))).clicked() {
let _ = app.run("file.encrypt", json!({}));
}
if encrypted && ui.button(egui::RichText::new(tl!("Remove Password")).font(medium(13.5))).clicked() {
let _ = app.run("file.encrypt", json!({"password": null}));
}
});
let ui = &mut cols[1];
ui.label(egui::RichText::new(tl!("Statistics")).font(semibold(15.0)));
for (l, k) in [("Pages", "pages"), ("Words", "words"), ("Paragraphs", "paragraphs"), ("Sections", "sections"), ("Comments", "comments")] {
+4 -110
View File
@@ -1,16 +1,14 @@
//! Dialogs: Font, Paragraph, Find & Replace, Go To, Insert Table, Page Setup, Link, Bookmark,
//! Word Count, Zoom, Watermark, New/Modify Style, Manage Styles, New/Modify Table Style, Table
//! Properties, Command search, Paste Special, About, Save Changes, the password to open a
//! document and Encrypt with Password, and the mail-merge Recipient List, Insert Merge Field, Find
//! Recipient, merge rules, Match Fields and Check for Errors. Every dialog ends by running a
//! command (or shows one's result), so agents get the same result without the dialog.
//! Properties, Command search, Paste Special, About, Save Changes, and the mail-merge Recipient
//! List, Insert Merge Field, Find Recipient, merge rules, Match Fields and Check for Errors. Every
//! dialog ends by running a command (or shows one's result), so agents get the same result without
//! the dialog.
use egui::{Sense, Ui, vec2};
use serde::Serialize;
use serde_json::{Value, json};
use wordcraft_engine::Password;
use crate::WordApp;
use crate::theme::{Tokens, semibold};
@@ -210,25 +208,6 @@ pub enum Dialog {
#[serde(skip)]
basis: Box<TableForm>,
},
/// Opening a password-protected document (#55): the password, then `file.open` with
/// `params` again. The password is never serialised or printed.
Password {
name: String,
#[serde(skip)]
params: Value,
#[serde(skip)]
password: Password,
message: String,
},
/// File › Info › Protect Document › Encrypt with Password: a password typed twice
/// (`file.encrypt`); left empty, it removes the password.
EncryptPassword {
#[serde(skip)]
password: Password,
#[serde(skip)]
confirm: Password,
message: String,
},
}
/// The Table Properties dialog's fields. Lengths are in the interface unit
@@ -625,8 +604,6 @@ impl Dialog {
Dialog::MatchFields { .. } => "matchFields",
Dialog::CheckErrors { .. } => "checkErrors",
Dialog::TableProperties { .. } => "tableProperties",
Dialog::Password { .. } => "password",
Dialog::EncryptPassword { .. } => "encryptPassword",
}
}
@@ -759,7 +736,6 @@ impl Dialog {
Dialog::InsertMergeField { field: fields.first().cloned().unwrap_or_default(), fields }
}
"findRecipient" => Dialog::FindRecipient { text: String::new(), message: String::new() },
"encryptPassword" => Dialog::EncryptPassword { password: Password::default(), confirm: Password::default(), message: String::new() },
"ruleIf" | "ruleSkipIf" => {
let fields = app.session.merge.headers.clone();
Dialog::MergeRule {
@@ -890,8 +866,6 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
Dialog::MatchFields { .. } => "Match Fields",
Dialog::CheckErrors { .. } => "Check for Errors",
Dialog::TableProperties { .. } => "Table Properties",
Dialog::Password { .. } => "Password",
Dialog::EncryptPassword { .. } => "Encrypt with Password",
};
egui::Window::new(tl!(title))
.id(egui::Id::new(("dialog", title)))
@@ -1637,86 +1611,6 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
cancel
}
Dialog::Password { name, params, password, message } => {
ui.label(crate::i18n::fmt(tl!("{name} is protected with a password."), &[("name", name)]));
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label(tl!("Password:"));
let r = ui.add(egui::TextEdit::singleline(password.as_mut_string()).password(true).desired_width(220.0));
if password.is_empty() && !r.has_focus() {
r.request_focus();
}
});
if !message.is_empty() {
ui.label(egui::RichText::new(message.as_str()).small().color(ui.visuals().error_fg_color));
}
let (ok, cancel) = buttons(ui, tl!("OK"));
if ok && !password.is_empty() {
let mut p = params.clone();
if let Some(o) = p.as_object_mut() {
o.insert("password".into(), json!(password.as_str()));
}
// `execute`, not `run`: Save Changes was asked when the open began.
match app.execute("file.open", p) {
Ok(_) => {
app.ui.backstage = false;
return true;
}
Err(e) if wordcraft_engine::io::wrong_password(&e) => {
*message = tl!("That password isn't right. Check it (passwords are case-sensitive) and try again.").to_string();
password.as_mut_string().clear();
}
Err(e) => *message = e,
}
}
cancel
}
Dialog::EncryptPassword { password, confirm, message } => {
ui.label(tl!("Anyone who opens the document will need this password."));
ui.add_space(4.0);
egui::Grid::new("encrypt_password").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label(tl!("Password:"));
let r = ui.add(egui::TextEdit::singleline(password.as_mut_string()).password(true).desired_width(220.0));
if password.is_empty() && confirm.is_empty() && !r.has_focus() {
r.request_focus();
}
ui.end_row();
ui.label(tl!("Confirm password:"));
ui.add(egui::TextEdit::singleline(confirm.as_mut_string()).password(true).desired_width(220.0));
ui.end_row();
});
ui.label(
egui::RichText::new(tl!(
"Keep the password somewhere safe: without it nobody, WordCraft included, can open the document. Leave both boxes empty to remove the password."
))
.small()
.weak(),
);
if !message.is_empty() {
ui.label(egui::RichText::new(message.as_str()).small().color(ui.visuals().error_fg_color));
}
let (ok, cancel) = buttons(ui, tl!("OK"));
if ok {
if password != confirm {
*message = tl!("The passwords don't match.").to_string();
confirm.as_mut_string().clear();
return false;
}
let pw = if password.is_empty() { Value::Null } else { json!(password.as_str()) };
match app.run("file.encrypt", json!({"password": pw})) {
Ok(_) => {
app.status(if password.is_empty() {
tl!("The password is removed. Save the document to keep the change.")
} else {
tl!("The document will be saved with a password. Save it to protect it.")
});
return true;
}
Err(e) => *message = e,
}
}
cancel
}
Dialog::FindRecipient { text, message } => {
ui.horizontal(|ui| {
ui.label(tl!("Find:"));
+1 -1
View File
@@ -151,7 +151,7 @@ impl WordApp {
if !self.safely_saved(saved) || document != self.session.document_id() {
return Ok(json!({"done": false}));
}
self.execute_user(&then, params).map(|r| json!({"done": true, "result": r}))
self.execute(&then, params).map(|r| json!({"done": true, "result": r}))
}
}
}
-15
View File
@@ -1103,18 +1103,3 @@
Hide from style lists Ocultar de las listas de estilos
Built-in styles can't be deleted. Los estilos integrados no se pueden eliminar.
Delete Table Style Eliminar estilo de tabla
Password Contraseña
Password: Contraseña:
Confirm password: Confirmar contraseña:
Encrypt with Password… Cifrar con contraseña…
Change Password… Cambiar contraseña…
Remove Password Quitar contraseña
A password is required to open this document. Se necesita una contraseña para abrir este documento.
Anyone can open this document. Encrypt it with a password to keep it private. Cualquiera puede abrir este documento. Cífralo con una contraseña para mantenerlo privado.
Anyone who opens the document will need this password. Quien abra el documento necesitará esta contraseña.
{name} is protected with a password. {name} está protegido con contraseña.
That password isn't right. Check it (passwords are case-sensitive) and try again. La contraseña no es correcta. Revísala (distingue mayúsculas de minúsculas) e inténtalo de nuevo.
The document will be saved with a password. Save it to protect it. El documento se guardará con contraseña. Guárdalo para protegerlo.
The password is removed. Save the document to keep the change. Se quitó la contraseña. Guarda el documento para conservar el cambio.
The passwords don't match. Las contraseñas no coinciden.
Keep the password somewhere safe: without it nobody, WordCraft included, can open the document. Leave both boxes empty to remove the password. Guarda la contraseña en un lugar seguro: sin ella nadie, ni siquiera WordCraft, puede abrir el documento. Deja ambos cuadros vacíos para quitar la contraseña.
Can't render this file because it contains an unexpected character in line 6 and column 25.
-15
View File
@@ -1087,18 +1087,3 @@
Hide from style lists スタイルの一覧に表示しない
Built-in styles can't be deleted. 組み込みスタイルは削除できません。
Delete Table Style 表スタイルの削除
Password パスワード
Password: パスワード:
Confirm password: パスワードの確認:
Encrypt with Password… パスワードを使用して暗号化…
Change Password… パスワードの変更…
Remove Password パスワードの削除
A password is required to open this document. この文書を開くにはパスワードが必要です。
Anyone can open this document. Encrypt it with a password to keep it private. この文書は誰でも開けます。パスワードで暗号化すると内容を保護できます。
Anyone who opens the document will need this password. この文書を開くすべての人にこのパスワードが必要になります。
{name} is protected with a password. {name} はパスワードで保護されています。
That password isn't right. Check it (passwords are case-sensitive) and try again. パスワードが正しくありません。確認して(大文字と小文字は区別されます)もう一度お試しください。
The document will be saved with a password. Save it to protect it. 文書はパスワード付きで保存されます。保護するには文書を保存してください。
The password is removed. Save the document to keep the change. パスワードを削除しました。変更を保持するには文書を保存してください。
The passwords don't match. パスワードが一致しません。
Keep the password somewhere safe: without it nobody, WordCraft included, can open the document. Leave both boxes empty to remove the password. パスワードは安全な場所に保管してください。パスワードがないと、WordCraft を含め誰も文書を開けません。パスワードを削除するには両方のボックスを空にします。
Can't render this file because it contains an unexpected character in line 439 and column 13.
-15
View File
@@ -1099,18 +1099,3 @@
Hide from style lists Ocultar das listas de estilos
Built-in styles can't be deleted. Estilos integrados não podem ser excluídos.
Delete Table Style Excluir estilo de tabela
Password Senha
Password: Senha:
Confirm password: Confirmar senha:
Encrypt with Password… Criptografar com senha…
Change Password… Alterar senha…
Remove Password Remover senha
A password is required to open this document. É necessária uma senha para abrir este documento.
Anyone can open this document. Encrypt it with a password to keep it private. Qualquer pessoa pode abrir este documento. Criptografe-o com uma senha para mantê-lo privado.
Anyone who opens the document will need this password. Quem abrir o documento precisará desta senha.
{name} is protected with a password. {name} está protegido por senha.
That password isn't right. Check it (passwords are case-sensitive) and try again. A senha está incorreta. Verifique-a (as senhas diferenciam maiúsculas de minúsculas) e tente novamente.
The document will be saved with a password. Save it to protect it. O documento será salvo com senha. Salve-o para protegê-lo.
The password is removed. Save the document to keep the change. A senha foi removida. Salve o documento para manter a alteração.
The passwords don't match. As senhas não coincidem.
Keep the password somewhere safe: without it nobody, WordCraft included, can open the document. Leave both boxes empty to remove the password. Guarde a senha em um lugar seguro: sem ela, ninguém, nem o WordCraft, consegue abrir o documento. Deixe as duas caixas vazias para remover a senha.
Can't render this file because it contains an unexpected character in line 6 and column 25.
-15
View File
@@ -15,7 +15,6 @@
(picture or object) (slika ili objekat)
1.5 Line Spacing Proređenost 1,5
A free, open-source word processor written from scratch in Rust.\nPart of the Crafting Apps from the ArtCraft team. Besplatan, otvoreni obrađivač teksta napisan od nule u Rust-u.\nDeo Crafting Apps paketa tima ArtCraft.
A password is required to open this document. Za otvaranje ovog dokumenta potrebna je lozinka.
About O programu
About WordCraft O WordCraft-u
Accept Prihvati
@@ -51,8 +50,6 @@
Allow Latin text to wrap in the middle of a word Dozvoli prelom latiničnog teksta usred reči
Alphabetical Abecedno
Alt Text Alternativni tekst
Anyone can open this document. Encrypt it with a password to keep it private. Svako može da otvori ovaj dokument. Šifrujte ga lozinkom da bi ostao privatan.
Anyone who opens the document will need this password. Svakome ko otvori dokument biće potrebna ova lozinka.
Apply Asian line-breaking rules (kinsoku) Primeni azijska pravila preloma redova (kinsoku)
Apply Character Style Primeni stil znakova
Apply Style Primeni stil
@@ -80,14 +77,12 @@
Based on: {style} Zasnovan na: {style}
Built-in style Ugrađeni stil
Built-in styles can't be deleted. Ugrađeni stilovi ne mogu da se obrišu.
Change Password… Promeni lozinku…
Character style Stil znakova
Click an item to paste it. Kliknite na stavku da biste je nalepili.
Clipboard Items Stavke ostave
Clipboard Pane Okno ostave
Color: {color} Boja: {color}
Compress punctuation at the start of a line Sažmi interpunkciju na početku reda
Confirm password: Potvrdite lozinku:
Dark page (white text on black) Tamna stranica (beo tekst na crnom)
Edit Comment Uredi komentar
Left Margin Leva margina
@@ -95,13 +90,11 @@
Right Margin Desna margina
Delete Clipboard Item Obriši stavku iz ostave
Delete Table Style Obriši stil tabele
Encrypt with Password… Šifruj lozinkom…
Font: {font} Font: {font}
Glow Sjaj
Glow Colors Boje sjaja
Hide from style lists Sakrij sa lista stilova
Indents and Spacing Uvlačenja i razmaci
Keep the password somewhere safe: without it nobody, WordCraft included, can open the document. Leave both boxes empty to remove the password. Čuvajte lozinku na sigurnom mestu: bez nje niko, pa ni WordCraft, ne može da otvori dokument. Ostavite oba polja prazna da biste uklonili lozinku.
Line breaking Prelom redova
Linked style (paragraph and character) Povezani stil (pasus i znakovi)
Manage Styles Upravljanje stilovima
@@ -120,12 +113,9 @@
Offset: Top Left Pomak: gore levo
Offset: Top Right Pomak: gore desno
Paragraph style Stil pasusa
Password Lozinka
Password: Lozinka:
Paste All Nalepi sve
Paste Clipboard Item Nalepi stavku iz ostave
Preview Pregled
Remove Password Ukloni lozinku
Shadow, glow and soft edges Senka, sjaj i meke ivice
Shape Effects Efekti oblika
Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. Prikaži dokumente sa invertovanim bojama, kao kod Prikaz › Promeni režim. Čuvanje, štampanje i PDF nisu pogođeni.
@@ -792,11 +782,7 @@
Text\nBox Okvir\nza tekst
Text\nDirection Smer\nteksta
That format doesn't keep everything, so the document is still open. Save it as a Word document (.docx) to go on. Taj format ne čuva sve, pa je dokument i dalje otvoren. Sačuvajte ga kao Word dokument (.docx) da biste nastavili.
That password isn't right. Check it (passwords are case-sensitive) and try again. Lozinka nije ispravna. Proverite je (lozinke razlikuju velika i mala slova) i pokušajte ponovo.
The document will be saved with a password. Save it to protect it. Dokument će biti sačuvan sa lozinkom. Sačuvajte ga da biste ga zaštitili.
The new style takes the formatting of the current paragraph. Novi stil preuzima formatiranje trenutnog pasusa.
The password is removed. Save the document to keep the change. Lozinka je uklonjena. Sačuvajte dokument da biste zadržali izmenu.
The passwords don't match. Lozinke se ne podudaraju.
Theme Colors Boje teme
Theme Fonts Fontovi teme
Theme List Lista tema
@@ -874,7 +860,6 @@
Zoom Out Umanji
{group} settings Podešavanja za {group}
{key}+Click to follow link {key}+klik za otvaranje veze
{name} is protected with a password. {name} je zaštićen lozinkom.
{size} pt {size} pt
Browse… Potraži…
{words} words {words} reči
Can't render this file because it contains an unexpected character in line 6 and column 25.
-15
View File
@@ -19,7 +19,6 @@
(picture or object) (слика или објекат)
1.5 Line Spacing Проређеност 1,5
A free, open-source word processor written from scratch in Rust.\nPart of the Crafting Apps from the ArtCraft team. Бесплатан, отворени обрађивач текста написан од нуле у Rust-у.\nДео Crafting Apps пакета тима ArtCraft.
A password is required to open this document. За отварање овог документа потребна је лозинка.
About О програму
About WordCraft О WordCraft-у
Accept Прихвати
@@ -55,8 +54,6 @@
Allow Latin text to wrap in the middle of a word Дозволи прелом латиничног текста усред речи
Alphabetical Азбучно
Alt Text Алтернативни текст
Anyone can open this document. Encrypt it with a password to keep it private. Свако може да отвори овај документ. Шифрујте га лозинком да би остао приватан.
Anyone who opens the document will need this password. Свакоме ко отвори документ биће потребна ова лозинка.
Apply Asian line-breaking rules (kinsoku) Примени азијска правила прелома редова (кинсоку)
Apply Character Style Примени стил знакова
Apply Style Примени стил
@@ -84,14 +81,12 @@
Based on: {style} Заснован на: {style}
Built-in style Уграђени стил
Built-in styles can't be deleted. Уграђени стилови не могу да се обришу.
Change Password… Промени лозинку…
Character style Стил знакова
Click an item to paste it. Кликните на ставку да бисте је налепили.
Clipboard Items Ставке оставе
Clipboard Pane Окно оставе
Color: {color} Боја: {color}
Compress punctuation at the start of a line Сажми интерпункцију на почетку реда
Confirm password: Потврдите лозинку:
Dark page (white text on black) Тамна страница (бео текст на црном)
Edit Comment Уреди коментар
Left Margin Лева маргина
@@ -99,13 +94,11 @@
Right Margin Десна маргина
Delete Clipboard Item Обриши ставку из оставе
Delete Table Style Обриши стил табеле
Encrypt with Password… Шифруј лозинком…
Font: {font} Фонт: {font}
Glow Сјај
Glow Colors Боје сјаја
Hide from style lists Сакриј са листа стилова
Indents and Spacing Увлачења и размаци
Keep the password somewhere safe: without it nobody, WordCraft included, can open the document. Leave both boxes empty to remove the password. Чувајте лозинку на сигурном месту: без ње нико, па ни WordCraft, не може да отвори документ. Оставите оба поља празна да бисте уклонили лозинку.
Line breaking Прелом редова
Linked style (paragraph and character) Повезани стил (пасус и знакови)
Manage Styles Управљање стиловима
@@ -124,12 +117,9 @@
Offset: Top Left Помак: горе лево
Offset: Top Right Помак: горе десно
Paragraph style Стил пасуса
Password Лозинка
Password: Лозинка:
Paste All Налепи све
Paste Clipboard Item Налепи ставку из оставе
Preview Преглед
Remove Password Уклони лозинку
Shadow, glow and soft edges Сенка, сјај и меке ивице
Shape Effects Ефекти облика
Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. Прикажи документе са инвертованим бојама, као код Приказ › Промени режим. Чување, штампање и PDF нису погођени.
@@ -796,11 +786,7 @@
Text\nBox Оквир\nза текст
Text\nDirection Смер\nтекста
That format doesn't keep everything, so the document is still open. Save it as a Word document (.docx) to go on. Тај формат не чува све, па је документ и даље отворен. Сачувајте га као Word документ (.docx) да бисте наставили.
That password isn't right. Check it (passwords are case-sensitive) and try again. Лозинка није исправна. Проверите је (лозинке разликују велика и мала слова) и покушајте поново.
The document will be saved with a password. Save it to protect it. Документ ће бити сачуван са лозинком. Сачувајте га да бисте га заштитили.
The new style takes the formatting of the current paragraph. Нови стил преузима форматирање тренутног пасуса.
The password is removed. Save the document to keep the change. Лозинка је уклоњена. Сачувајте документ да бисте задржали измену.
The passwords don't match. Лозинке се не подударају.
Theme Colors Боје теме
Theme Fonts Фонтови теме
Theme List Листа тема
@@ -878,7 +864,6 @@
Zoom Out Умањи
{group} settings Подешавања за {group}
{key}+Click to follow link {key}+клик за отварање везе
{name} is protected with a password. {name} је заштићен лозинком.
{size} pt {size} пт
Browse… Потражи…
{words} words {words} речи
Can't render this file because it contains an unexpected character in line 11 and column 25.
-15
View File
@@ -1099,18 +1099,3 @@
Hide from style lists Сховати зі списків стилів
Built-in styles can't be deleted. Вбудовані стилі не можна видалити.
Delete Table Style Видалити стиль таблиці
Password Пароль
Password: Пароль:
Confirm password: Підтвердження пароля:
Encrypt with Password… Зашифрувати паролем…
Change Password… Змінити пароль…
Remove Password Видалити пароль
A password is required to open this document. Щоб відкрити цей документ, потрібен пароль.
Anyone can open this document. Encrypt it with a password to keep it private. Цей документ може відкрити будь-хто. Зашифруйте його паролем, щоб захистити.
Anyone who opens the document will need this password. Кожному, хто відкриватиме документ, знадобиться цей пароль.
{name} is protected with a password. {name} захищено паролем.
That password isn't right. Check it (passwords are case-sensitive) and try again. Пароль неправильний. Перевірте його (паролі враховують регістр) і спробуйте ще раз.
The document will be saved with a password. Save it to protect it. Документ буде збережено з паролем. Збережіть його, щоб захистити.
The password is removed. Save the document to keep the change. Пароль видалено. Збережіть документ, щоб зберегти зміну.
The passwords don't match. Паролі не збігаються.
Keep the password somewhere safe: without it nobody, WordCraft included, can open the document. Leave both boxes empty to remove the password. Зберігайте пароль у надійному місці: без нього ніхто, зокрема WordCraft, не зможе відкрити документ. Залиште обидва поля порожніми, щоб видалити пароль.
Can't render this file because it contains an unexpected character in line 6 and column 31.
-15
View File
@@ -1098,18 +1098,3 @@
Hide from style lists 在样式列表中隐藏
Built-in styles can't be deleted. 内置样式不能删除。
Delete Table Style 删除表格样式
Password 密码
Password: 密码:
Confirm password: 确认密码:
Encrypt with Password… 用密码进行加密…
Change Password… 更改密码…
Remove Password 删除密码
A password is required to open this document. 打开此文档需要密码。
Anyone can open this document. Encrypt it with a password to keep it private. 任何人都可以打开此文档。用密码加密可保护其隐私。
Anyone who opens the document will need this password. 打开文档的任何人都需要此密码。
{name} is protected with a password. {name} 受密码保护。
That password isn't right. Check it (passwords are case-sensitive) and try again. 密码不正确。请检查密码(区分大小写)后重试。
The document will be saved with a password. Save it to protect it. 文档将使用密码保存。保存文档以保护它。
The password is removed. Save the document to keep the change. 密码已删除。保存文档以保留此更改。
The passwords don't match. 两次输入的密码不一致。
Keep the password somewhere safe: without it nobody, WordCraft included, can open the document. Leave both boxes empty to remove the password. 请妥善保管密码:没有密码,任何人(包括 WordCraft)都无法打开文档。两个框都留空可删除密码。
Can't render this file because it contains an unexpected character in line 6 and column 25.
-15
View File
@@ -1088,18 +1088,3 @@
Hide from style lists 從樣式清單中隱藏
Built-in styles can't be deleted. 無法刪除內建樣式。
Delete Table Style 刪除表格樣式
Password 密碼
Password: 密碼:
Confirm password: 確認密碼:
Encrypt with Password… 以密碼加密…
Change Password… 變更密碼…
Remove Password 移除密碼
A password is required to open this document. 開啟此文件需要密碼。
Anyone can open this document. Encrypt it with a password to keep it private. 任何人都可以開啟此文件。以密碼加密可保護其隱私。
Anyone who opens the document will need this password. 開啟文件的任何人都需要此密碼。
{name} is protected with a password. {name} 受密碼保護。
That password isn't right. Check it (passwords are case-sensitive) and try again. 密碼不正確。請檢查密碼(區分大小寫)後再試一次。
The document will be saved with a password. Save it to protect it. 文件將以密碼儲存。儲存文件以保護它。
The password is removed. Save the document to keep the change. 密碼已移除。儲存文件以保留此變更。
The passwords don't match. 兩次輸入的密碼不一致。
Keep the password somewhere safe: without it nobody, WordCraft included, can open the document. Leave both boxes empty to remove the password. 請妥善保管密碼:沒有密碼,任何人(包括 WordCraft)都無法開啟文件。兩個方塊都留空可移除密碼。
Can't render this file because it contains an unexpected character in line 440 and column 13.
+12 -131
View File
@@ -114,8 +114,7 @@ pub struct UiState {
/// that follows ends a chord (Alt+click, Alt+drag column selection), not a keytip tap.
#[serde(skip)]
pub alt_chord_used: bool,
/// View › Switch Modes: show pages dark (white text on black), kept between runs. Only the
/// pages: the interface follows [`UiState::theme`] (#312).
/// View › Switch Modes: show pages dark (white text on black), kept between runs.
pub dark_page: bool,
}
@@ -176,8 +175,7 @@ pub struct WordApp {
/// File ▸ Options: load the installed CJK fallback font if no embedded face covers it.
pub(crate) want_system_cjk: bool,
fonts_frames: u32,
/// The interface theme setting last installed in egui ([`theme::apply`]).
applied_theme: Option<theme::Appearance>,
applied_dark: Option<bool>,
pub frame_ms: f64,
/// The window title last sent; a viewport command schedules a repaint, so only send changes.
sent_title: String,
@@ -247,7 +245,7 @@ impl WordApp {
fonts_system_cjk: false,
want_system_cjk: false,
fonts_frames: 0,
applied_theme: None,
applied_dark: None,
frame_ms: 0.0,
sent_title: String::new(),
quit_requested: false,
@@ -328,7 +326,7 @@ impl WordApp {
{
return r;
}
let r = self.execute_user(id, params);
let r = self.execute(id, params);
// Match Fields and Check for Errors show what they found.
if let Ok(v) = &r
&& let Some(d) = dialogs::Dialog::report(id, v)
@@ -365,23 +363,11 @@ impl WordApp {
if self.services.download.is_some() && matches!(id, "file.save" | "file.saveAs" | "file.exportPdf" | "file.exportPng") {
let name = params.get("path").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| self.default_save_name());
let name = if id == "file.exportPdf" && !name.ends_with(".pdf") { format!("{name}.pdf") } else { name };
// A save with a password encrypts this and later saves (`file.save`'s `password`).
if matches!(id, "file.save" | "file.saveAs")
&& let Some(pw) = params.get("password").filter(|v| !v.is_null())
{
if !wordcraft_engine::io::can_encrypt(&name) || pw.as_str().is_none_or(str::is_empty) {
return Err("only Word documents (.docx, .docm, .dotx, .dotm) can be saved with a password, and it can't be empty".into());
}
self.session.run("file.encrypt", &json!({"password": pw})).map_err(|e| e.to_string())?;
}
// A save is a save, download or not: advance the revision and modified stamp (#262).
if matches!(id, "file.save" | "file.saveAs") {
self.session.stamp_save();
}
// Word documents are encrypted with the document's password, if it has one.
let password =
if matches!(id, "file.save" | "file.saveAs") { self.session.password.as_ref().map(wordcraft_engine::Password::as_str) } else { None };
let bytes = wordcraft_engine::io::save_bytes_with(&name, &self.session.doc, password)?;
let bytes = wordcraft_engine::io::save_bytes(&name, &self.session.doc)?;
if let Some(d) = &self.services.download
&& let Err(e) = d(&name, &bytes)
{
@@ -448,23 +434,7 @@ impl WordApp {
if !go {
return Ok(json!({"done": false}));
}
self.execute_user(&then, params).map(|r| json!({"done": true, "result": r}))
}
/// [`WordApp::execute`] for something the user did: a document that turns out to be
/// password-protected asks for the password (and opens with it) instead of failing (#55).
pub(crate) fn execute_user(&mut self, id: &str, params: Value) -> Result<Value, String> {
let retry = (id == "file.open").then(|| params.clone());
let r = self.execute(id, params);
if let (Err(e), Some(params)) = (&r, retry)
&& wordcraft_engine::io::needs_password(e)
{
let path = params.get("path").and_then(Value::as_str).unwrap_or("");
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
self.dialog = Some(dialogs::Dialog::Password { name, params, password: Default::default(), message: String::new() });
return Ok(json!({"pending": "password"}));
}
r
self.execute(&then, params).map(|r| json!({"done": true, "result": r}))
}
/// Save before New/Open/Close (and the mailings that replace the document): true once the
@@ -766,12 +736,12 @@ impl WordApp {
ctx.options_mut(|o| o.zoom_with_keyboard = false);
self.styled = true;
}
// Both palettes are installed and egui picks one: with `System` it follows an OS appearance
// change live (it reports it and repaints, #311). The interface follows the Interface theme
// setting alone; Dark page only changes how pages are drawn (#312).
if self.applied_theme != Some(self.ui.theme) {
theme::apply(ctx, self.ui.theme);
self.applied_theme = Some(self.ui.theme);
// Re-checked every frame, so `System` follows an OS appearance change live (egui reports it
// and repaints).
let dark = self.ui.theme.is_dark(ctx.system_theme()) || self.session.view.dark_mode;
if self.applied_dark != Some(dark) {
theme::apply(ctx, &if dark { theme::Tokens::dark() } else { theme::Tokens::light() });
self.applied_dark = Some(dark);
}
if self.ctx.is_none() {
self.ctx = Some(ctx.clone());
@@ -1082,8 +1052,6 @@ fn input_dialog(id: &str, params: &Value) -> Option<&'static str> {
"mailings.findRecipient" if !has("text") => Some("findRecipient"),
// Table Properties without settings shows the dialog (with settings it applies them).
"table.properties" if params.as_object().is_none_or(|m| m.is_empty()) => Some("tableProperties"),
// `null` is an answer here: it removes the password.
"file.encrypt" if params.get("password").is_none() => Some("encryptPassword"),
_ => None,
}
}
@@ -1443,34 +1411,6 @@ mod tests {
assert!(!body_text(&a).trim().is_empty());
}
/// #55: a password-protected document asks for its password (after Save Changes) instead of
/// opening blank; scripts get the error instead of a dialog; Encrypt with Password asks for one.
#[test]
fn password_protected_documents_ask_for_the_password() {
let dir = scratch("password");
let path = dir.join("locked.docx");
let mut s = Session::new(wordcraft_doc::Document::from_text("Locked away"));
s.run("file.save", &json!({"path": path.to_string_lossy(), "password": "sesame"})).unwrap();
let mut a = typed();
assert!(a.execute("file.open", json!({"path": path.to_string_lossy()})).is_err(), "a script gets the error");
assert_eq!(prompt(&a), None);
a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap();
a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap();
assert_eq!(prompt(&a), Some("password"));
assert!(body_text(&a).contains(UNSAVED), "nothing is replaced until the password fits");
let state = serde_json::to_string(&a.dialog).unwrap();
assert!(state.contains("locked.docx") && !state.contains("sesame"));
let mut b = app();
b.run("file.encrypt", json!({})).unwrap();
assert_eq!(prompt(&b), Some("encryptPassword"));
b.dialog = None;
b.run("file.encrypt", json!({"password": null})).unwrap();
assert_eq!(prompt(&b), None, "null removes the password without asking");
let _ = std::fs::remove_dir_all(&dir);
}
/// Open (after the file is picked), recent files and dropped files all go through `file.open`.
#[test]
fn open_asks_before_discarding_unsaved_work() {
@@ -2224,65 +2164,6 @@ mod tests {
assert_eq!(a.run("ui.theme", json!({})).unwrap()["theme"], "light");
}
/// One app frame with the OS appearance egui reports; returns the window commands it sent.
fn theme_frame(ctx: &egui::Context, a: &mut WordApp, os: Option<egui::Theme>) -> Vec<egui::ViewportCommand> {
let input = egui::RawInput { system_theme: os, ..Default::default() };
let mut out = ctx.run_ui(input, |ui| a.logic(ui.ctx()));
let commands = out.viewport_output.remove(&egui::ViewportId::ROOT).map(|v| v.commands).unwrap_or_default();
out.drop_without_applying_deltas();
commands
}
/// #311: with System, egui's theme preference stays System, so the native window is never
/// pinned to a concrete appearance (macOS would stop reporting OS changes), and our palette
/// follows each OS appearance egui reports.
#[test]
fn system_theme_follows_the_os_without_pinning_the_window() {
use egui::{SystemTheme, Theme, ThemePreference, ViewportCommand};
let ctx = egui::Context::default();
let mut a = app();
a.run("ui.theme", json!({"value": "system"})).unwrap();
let mut sent = Vec::new();
for (os, dark) in [(Some(Theme::Light), false), (Some(Theme::Dark), true), (Some(Theme::Light), false), (None, false)] {
sent.extend(theme_frame(&ctx, &mut a, os));
assert_eq!(ctx.options(|o| o.theme_preference), ThemePreference::System, "OS at {os:?}");
let want = if dark { theme::Tokens::dark() } else { theme::Tokens::light() };
assert_eq!(theme::Tokens::get(&ctx).dark, dark, "OS at {os:?}");
// Our palette, not egui's default style for that theme.
assert_eq!(ctx.global_style().visuals.panel_fill, want.ribbon, "OS at {os:?}");
assert_eq!(a.ui_is_dark(), dark, "OS at {os:?}");
}
assert!(sent.contains(&ViewportCommand::SetTheme(SystemTheme::SystemDefault)), "{sent:?}");
assert!(!sent.iter().any(|c| matches!(c, ViewportCommand::SetTheme(SystemTheme::Light | SystemTheme::Dark))), "{sent:?}");
// A manual choice pins the window, whatever the OS says.
a.run("ui.theme", json!({"value": "dark"})).unwrap();
let sent = theme_frame(&ctx, &mut a, Some(Theme::Light));
assert!(sent.contains(&ViewportCommand::SetTheme(SystemTheme::Dark)), "{sent:?}");
assert!(theme::Tokens::get(&ctx).dark);
}
/// #312: Dark page darkens only the pages; the interface keeps following its own setting.
#[test]
fn dark_page_leaves_the_interface_theme_alone() {
use egui::Theme::{Dark, Light};
for (setting, os, dark) in [
("system", Some(Light), false),
("system", None, false),
("light", Some(Dark), false),
("system", Some(Dark), true),
("dark", Some(Light), true),
] {
let ctx = egui::Context::default();
let mut a = app();
a.run("ui.theme", json!({"value": setting})).unwrap();
a.run("view.darkMode", json!({"value": true})).unwrap();
theme_frame(&ctx, &mut a, os);
assert!(a.session.view.dark_mode, "{setting} with the OS at {os:?}");
assert_eq!(theme::Tokens::get(&ctx).dark, dark, "{setting} with the OS at {os:?}");
assert_eq!(a.run("ui.theme", json!({})).unwrap()["dark"], dark, "{setting} with the OS at {os:?}");
}
}
#[test]
fn word_count_setting_survives_a_restart() {
let mut a = app();
+19 -42
View File
@@ -8,9 +8,8 @@ use egui::{Align2, Color32, CornerRadius, FontData, FontDefinitions, FontFamily,
pub const APP_COLOR: Color32 = Color32::from_rgb(0x3B, 0x5B, 0xDB);
pub const APP_INK: Color32 = Color32::from_rgb(0x2B, 0x47, 0xB5);
/// The interface theme setting (File › Options › General, View › Interface Theme): a fixed light or
/// dark palette, or `System`, which follows the OS light/dark appearance live (#115). Dark page
/// (View › Switch Modes) is separate: it changes how pages are drawn, never the interface (#312).
/// The interface theme setting (File › Options › General, View › Dark Mode): a fixed light or
/// dark palette, or `System`, which follows the OS light/dark appearance live (#115).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(from = "String", into = "String")]
pub enum Appearance {
@@ -55,15 +54,6 @@ impl Appearance {
Appearance::System => system == Some(egui::Theme::Dark),
}
}
/// The egui theme preference: `System` stays automatic rather than resolving to a fixed theme.
pub fn preference(self) -> egui::ThemePreference {
match self {
Appearance::Light => egui::ThemePreference::Light,
Appearance::Dark => egui::ThemePreference::Dark,
Appearance::System => egui::ThemePreference::System,
}
}
}
/// An unknown saved value reads as the default rather than discarding the whole `ui.json`.
@@ -375,35 +365,8 @@ fn tracked_job(text: &str, font: &FontId, tracking: f32, color: Color32) -> egui
egui::text::LayoutJob::single_section(text.to_string(), format)
}
/// Apply the interface theme setting to egui. Both palettes go in, our light one as egui's light
/// style and our dark one as its dark style, and egui picks between them each frame: `System`
/// leaves egui's theme preference on `System`, so it follows the OS appearance it reports (light
/// when the OS reports none, like [`Appearance::is_dark`]) and the native window keeps its own
/// appearance; a manual Light or Dark pins egui (and the window decorations) to that theme.
/// Setting a concrete theme for `System` would pin the native window too, and macOS then stops
/// reporting appearance changes (#311).
pub fn apply(ctx: &egui::Context, appearance: Appearance) {
ctx.set_visuals_of(egui::Theme::Light, visuals(&Tokens::light()));
ctx.set_visuals_of(egui::Theme::Dark, visuals(&Tokens::dark()));
ctx.all_styles_mut(|s| {
s.spacing.item_spacing = egui::vec2(6.0, 4.0);
s.spacing.button_padding = egui::vec2(8.0, 3.0);
s.spacing.interact_size = egui::vec2(24.0, 22.0);
s.spacing.menu_margin = egui::Margin::same(6);
s.text_styles.insert(egui::TextStyle::Body, regular(12.5));
s.text_styles.insert(egui::TextStyle::Button, regular(12.5));
s.text_styles.insert(egui::TextStyle::Small, regular(10.5));
s.text_styles.insert(egui::TextStyle::Heading, semibold(17.0));
s.interaction.tooltip_delay = 0.45;
});
ctx.options_mut(|o| {
o.fallback_theme = egui::Theme::Light;
o.theme_preference = appearance.preference();
});
}
/// egui's visuals for one of our palettes.
fn visuals(t: &Tokens) -> Visuals {
/// Apply tokens to egui's style.
pub fn apply(ctx: &egui::Context, t: &Tokens) {
let mut v = if t.dark { Visuals::dark() } else { Visuals::light() };
v.panel_fill = t.ribbon;
v.window_fill = t.menu;
@@ -435,7 +398,21 @@ fn visuals(t: &Tokens) -> Visuals {
v.widgets.open.weak_bg_fill = t.pressed;
v.popup_shadow = egui::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(if t.dark { 120 } else { 40 }) };
v.window_shadow = egui::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(if t.dark { 140 } else { 50 }) };
v
// Pin egui's own theme to ours: left on its default (follow the OS), egui would swap to its
// other, unstyled light/dark style the moment the OS appearance changed.
ctx.set_theme(if t.dark { egui::Theme::Dark } else { egui::Theme::Light });
ctx.set_visuals(v);
ctx.global_style_mut(|s| {
s.spacing.item_spacing = egui::vec2(6.0, 4.0);
s.spacing.button_padding = egui::vec2(8.0, 3.0);
s.spacing.interact_size = egui::vec2(24.0, 22.0);
s.spacing.menu_margin = egui::Margin::same(6);
s.text_styles.insert(egui::TextStyle::Body, regular(12.5));
s.text_styles.insert(egui::TextStyle::Button, regular(12.5));
s.text_styles.insert(egui::TextStyle::Small, regular(10.5));
s.text_styles.insert(egui::TextStyle::Heading, semibold(17.0));
s.interaction.tooltip_delay = 0.45;
});
}
pub fn c32(c: wordcraft_doc::Rgb) -> Color32 {
+1 -1
View File
@@ -471,7 +471,7 @@ mod tests {
fn drawn_buttons_fade_in_on_hover_and_darken_while_pressed() {
// #305's drawn replacements for ✕, 💬, ✎ and ➕ must react like egui's buttons.
let ctx = egui::Context::default();
crate::theme::apply(&ctx, crate::theme::Appearance::Light);
crate::theme::apply(&ctx, &Tokens::light());
let clock = std::cell::Cell::new(0u32);
let seen = std::cell::Cell::new((Rect::NOTHING, Rect::NOTHING, Color32::PLACEHOLDER, Color32::PLACEHOLDER));
// One 60 fps frame; returns both buttons' rects and fills (framed, frameless).
+1 -2
View File
@@ -60,7 +60,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| Group shapes (`wpg:`), drawing canvas | ❌ | ❌ | Dropped |
| VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | ❌ | Pictures and text boxes, best effort (#242) |
| Charts (`c:chart`), SmartArt (`dgm`), ink, 3D models, OLE objects (`w:object`) | ❌ | ❌ | Dropped on read, not preserved on save. A document with a chart loses it silently |
| Equations (OMML `m:oMath`) | ✅ | ✅ | #191; manual breaks (`m:brk`) and the settings' `m:mathPr` wrapping options (#326) |
| Equations (OMML `m:oMath`) | ✅ | ✅ | #191 |
| Themes, font table, settings, compatibility mode | ✅ | ✅ | Embedded fonts (`w:embedRegular`) not read |
| Custom XML parts, document properties, bibliography sources | 🟡 | 🟡 | Custom properties round-trip; bibliography sources pending (#169) |
| Ruby, `w:eastAsianLayout`, `w:fitText`, document grid (`w:docGrid`) | ❌ | ❌ | East Asian layout |
@@ -80,6 +80,5 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | trivial | Equation breaks and `m:mathPr` wrapping options round-trip (#326) |
| 2026-10-10 | trivial | Custom table styles round-trip (#256 merged) |
| 2026-10-10 | major | First version: Word's full format list, read/write status, DOCX element coverage from the source, fidelity and hours |
+2 -4
View File
@@ -13,7 +13,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes.
|---|---|---|---|---|---|---|
| 1 | **DOCX never tested on real-world files.** No corpus of files from Word, Google Docs, LibreOffice and Pages has been opened, rendered and round-tripped and compared with Word page by page | B | No corpus in the repo; `crates/docx/tests` are synthetic fixtures; "DOCX fidelity corpus: not started" since 2026-10-06 | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) |
| 2 | **Charts, SmartArt, OLE objects, ink and group shapes are dropped on open and lost on save, silently** | B | No `c:chart`, `dgm`, `wpg`, `w:object` handling in `crates/docx` | Business and academic documents lose content without warning | 10–15 to preserve and show fallback pictures; 45–70 to render and edit charts and SmartArt | [file-format-parity.md](file-format-parity.md) |
| 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170) (lag on GPUs without a DirectX 12 driver fixed, #316), freezes (#77, #59 Thai fonts, #31 font selector), no writes without admin on Windows 11 (#218), broken shape tool (#142), images can't be moved (#82) | B | 62 open issues, ~15 of them crashes, hangs or data-path failures | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) |
| 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170), freezes (#77, #59 Thai fonts, #31 font selector), no writes without admin on Windows 11 (#218), broken shape tool (#142), images can't be moved (#82) | B | 62 open issues, ~15 of them crashes, hangs or data-path failures | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) |
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; columns don't balance; long footnotes don't continue; legacy compatibility options ignored; no page-by-page comparison harness | B | [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
| 5 | **Formatting revisions, move tracking and content controls** are lost (`w:rPrChange`/`w:pPrChange` absent; `w:sdt` unwrapped) | B | Issue #41; source grep | Legal and editorial review workflows; templates with form controls | 12–18 | [file-format-parity.md](file-format-parity.md) |
| 6 | **No native printing** on desktop: File › Print opens the PDF in the system viewer, which prints (#286); no print dialog of our own (printer, copies, ranges); web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) |
@@ -39,7 +39,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes.
| 26 | **Minor formats**: Flat OPC XML, Word 2003 XML, MHT, Works/WordPerfect | | | Rare | 20–35 | [file-format-parity.md](file-format-parity.md) |
| 27 | **View windows**: Side by Side, Synchronous Scrolling, Arrange All, Switch Windows | | [parity-checklist.md](parity-checklist.md) (View 25/29) | Comparing documents | 3–5 | [ui-parity.md](ui-parity.md) |
| 28 | **Dictate** | | `tools.dictate` missing | Dictation users | 8–15 + owner (speech model) | [hardware-parity.md](hardware-parity.md) |
| 29 | **Equations**: ink equations | | #191 notes; long display equations wrap at operators (#326) | Maths-heavy documents | 2–3 | [layout-parity.md](layout-parity.md) |
| 29 | **Equations**: long display equations don't break across lines; ink equations | | #191 notes | Maths-heavy documents | 3–4 | [layout-parity.md](layout-parity.md) |
**Beta needs #1–#7** (~150–230 h with the stability and layout work they imply): they are the
blocking gaps, and closing them lifts file formats, layout, stability and UI enough to take the
@@ -64,8 +64,6 @@ The same gaps grouped the way the parity documents are, for agents working in on
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | trivial | #29: long display equations wrap across lines (#326) |
| 2026-10-10 | trivial | #3: Windows lag on GPUs without a DirectX 12 driver fixed with an OpenGL fallback (#316) |
| 2026-10-10 | trivial | #6: desktop File › Print opens the system PDF viewer (#286) |
| 2026-10-10 | trivial | Table Properties dialog landed (#44); removed from the dialog-depth list |
| 2026-10-10 | trivial | Merged main: landed features removed from the feature-gap list |
+1 -2
View File
@@ -11,7 +11,7 @@ pens, microphones (Dictate) and speakers (Read Aloud), and displays.
| Hardware | Word | WordCraft macOS | Windows | Linux / BSD | Web | Parity | Hours |
|---|---|---|---|---|---|---|---|
| Printers: native print dialog, printer choice, copies, duplex, page ranges, tray, scaling | ✅ (`WordPDE.plugin` print-dialog extension on Mac) | 🟡 PDF opens in Preview to print (#286) | 🟡 PDF opens in the default viewer (#286) | 🟡 PDF opens in the default viewer (#286) | ✅ browser print dialog (#209) | 35% | 5–8 |
| GPU rendering, HiDPI/Retina, pixel-aligned text | ✅ | ✅ wgpu + vello_cpu rasteriser, whole-pixel text (#110, #165) | ✅ DirectX 12 default (#50), OpenGL when DirectX 12 has no GPU driver (#316); startup crash on Intel UHD (#170) | ✅ (soft text on KDE at some zooms, #140) | ✅ | 80% | 3–5 (bugs) |
| GPU rendering, HiDPI/Retina, pixel-aligned text | ✅ | ✅ wgpu + vello_cpu rasteriser, whole-pixel text (#110, #165) | ✅ DirectX 12 default (#50); startup crash on Intel UHD (#170) | ✅ (soft text on KDE at some zooms, #140) | ✅ | 80% | 3–5 (bugs) |
| Multiple monitors, per-monitor DPI, remembered window position | ✅ | ✅ (#38, #76) | ✅ (winit DPI fix pending, #155) | ✅ | — | 85% | 1 |
| Pen / stylus: ink, pressure, eraser, ink to shape/math | ✅ (Draw tab, `InkRender.bundle`) | ❌ | ❌ | ❌ | ❌ | 0% | (Draw tab, 15–25) |
| Microphone: Dictate | ✅ (cloud speech service) | ❌ | ❌ | ❌ | ❌ | 0% | 8–15 + owner (speech model choice) |
@@ -23,7 +23,6 @@ pens, microphones (Dictate) and speakers (Read Aloud), and displays.
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | trivial | Windows falls back to OpenGL when DirectX 12 offers only the software adapter (#316) |
| 2026-10-10 | trivial | Desktop File › Print opens the PDF in the system viewer (#286) |
| 2026-10-10 | trivial | Inertial touchpad scrolling landed (#252) |
| 2026-10-10 | major | First version |
+1 -2
View File
@@ -49,7 +49,7 @@ font can't match Word's metrics.
| Vertical text (`tbRl`) in sections and text boxes; cell text direction | 🟡 | cell text direction landed (#245); page-level and text-box vertical text missing | 8–12 |
| Ruby / phonetic guide, enclose characters, combined characters | ❌ | | 4–6 |
| Bidirectional paragraphs, mixed-direction lines | 🟡 | #207; RTL sections and tables (`w:bidiVisual`) missing | 4–6 |
| Equations: display layout, numbering, wrapping long display equations | ✅ | #191; long display equations break at top-level operators (`m:brkBin`, `m:brkBinSub`), manual breaks and `m:alnAt`, continuation lines indented by `m:wrapIndent` or set right (`m:wrapRight`) (#326) | — |
| Equations: display layout, numbering | 🟡 | #191; long display equations don't break across lines | 3–4 |
| Hidden text excluded from breaking and hyphenation | ✅ | #173 | — |
| Fields: PAGE, NUMPAGES, SECTIONPAGES, TOC page numbers | ✅ | #52 | — |
| Compatibility modes (Word 2003/2007/2010 layout for old files: `w:compat` options) | 🟡 | mode 15 behaviour; the ~60 legacy compat options are mostly ignored | 10–20 |
@@ -68,6 +68,5 @@ font can't match Word's metrics.
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | trivial | Long display equations wrap across lines (#326) |
| 2026-10-10 | trivial | AutoFit Contents measures the text (#44) |
| 2026-10-10 | major | First version: layout and pagination checklist with evidence from `crates/layout`, PRs and issues |
+1 -2
View File
@@ -27,7 +27,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
| Zoom: Ctrl+scroll, pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251) | ✅ | ✅ | 85% | — |
| Touchpad smooth/inertial scroll | ✅ (#252) | ✅ | 90% | — |
| Status bar (page x of y, words, language, view buttons, zoom slider) | ✅ | ✅ | 80% | — |
| Interface themes (light, dark, system #249, follows OS changes live #311), dark page separate from the interface theme (#194, #312) | ✅ | ✅ | 90% | — |
| Interface themes (light, dark, system #249), dark page (#194) | ✅ | ✅ | 90% | — |
| Platform conventions (macOS menus/traffic lights #255, Windows title bar, Linux CSD on Wayland #78) | partial | native | 70% | 3–5 |
| Screen readers (VoiceOver, Narrator, Orca) | AccessKit on, document canvas exposure untested | full | 25% | 10–15 |
| Right-to-left (mirrored) interface | ❌ | ✅ (Arabic, Hebrew Word) | 0% | in localization |
@@ -36,7 +36,6 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | trivial | System interface theme keeps following OS appearance changes (no longer pins the macOS window); Dark page no longer darkens the interface (#311, #312) |
| 2026-10-10 | trivial | AutoSave switch greyed out with a tooltip saying why where AutoSave can't save; Save As from it (#196, #176) |
| 2026-10-10 | trivial | Table Properties dialog and Table Layout Height/Width boxes (#44) |
| 2026-10-10 | minor | Merged main: Style Inspector, Column Selection, zoom buttons, inertial scrolling, File Info landed |