Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 cc2dc9e99c Tests: the equation-wrap test accepts plain y where no math font is installed (FreeBSD CI)
The first glyph is math italic 𝑦 only when a font has it; FreeBSD CI has none, so
it shapes as plain y. The test is about line breaks and indents, not the glyph.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:04:00 -04:00
81bf9187f2 Wrap long display equations across lines like Word (#326) (#329)
A display equation wider than its column ran off the page. It now breaks
at top-level binary operators and relations (never inside a fraction,
radical, script or matrix), filling each line greedily; a term wider
than the line overflows instead of breaking inside.

- Break before the operator by default, or after it, or repeated on both
  lines, as the document's m:brkBin says; m:brkBinSub picks how a minus
  repeats.
- Continuation lines are indented by m:wrapIndent (one inch by default)
  or set flush right with m:wrapRight.
- Manual breaks (m:brk on a run) start a new line; m:alnAt aligns it at
  an operator of the first line.
- DOCX: m:mathPr in settings.xml and m:brk in math runs are read and
  written back. Documents without them save as before.
- Inline equations, numbered equations and m:jc alignment are unchanged.

Tests: layout (a long sum wraps within the column with indented
continuation lines; after/repeat modes; manual breaks and alnAt; short
equations stay on one line; hostile break input) and DOCX round-trip.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 16:37:25 -04:00
9bfdbee0b7 System interface theme follows OS changes again, and Dark page no longer darkens the interface (#317)
* System interface theme keeps following the OS; Dark page leaves the interface alone (#311, #312)

With Interface theme set to System, every frame resolved the OS appearance
to Light or Dark and passed that to egui's set_theme. egui then sent
SetTheme(Light/Dark) to the window, which on macOS sets an explicit
NSAppearance; winit ignores appearance changes while one is set, so later
OS switches never reached the app.

Both palettes are now installed as egui's light and dark styles (with the
same spacing and text styles), and System leaves egui's preference on
System with a light fallback, so egui picks the matching style from the
OS appearance it reports and the window keeps following the OS. Manual
Light and Dark still pin the window.

Dark page (View > Switch Modes, Options > Dark page) also forced the
interface dark; it now only changes how pages are drawn and the interface
follows the Interface theme setting alone.

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

* Theme: update #314's test to the new theme::apply(ctx, Appearance) signature

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 16:27:23 -04:00
1800007463 Windows: use OpenGL when DirectX 12 has no GPU driver (#316) (#325)
Since Windows defaults to DirectX 12 only (#50), PCs whose graphics chip has
no DirectX 12 driver (Intel 4th generation on Windows 11) fall back to WARP,
Microsoft's software renderer, and the app lags. Before eframe creates its
instance, WordCraft now probes DirectX 12 with a short-lived instance: a
hardware adapter that meets eframe's device limits keeps DirectX 12 only;
WARP only enables OpenGL next to DirectX 12 (GPU preferred, WARP as last
resort); no adapter, or only GPUs below the limits, uses OpenGL alone.
WGPU_BACKEND still overrides everything. The choice is logged, and the
decision is a pure function with unit tests.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 16:24:16 -04:00
4b450f1b00 Open and save password-protected Word documents (#55) (#281)
* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* Open and save password-protected Word documents (#55)

Opening a password-protected .docx used to fail as a broken zip and showed
a blank document. Encrypted packages (an OLE compound file with
EncryptionInfo + EncryptedPackage) are now detected and decrypted with
ECMA-376 agile encryption, written from the public [MS-OFFCRYPTO] spec:
password key derivation with spinCount, verifier check, data-integrity
HMAC, AES-CBC in 4096-byte segments (AES-128/192/256 with SHA-1/256/384/512
on read; AES-256 + SHA-512, spinCount 100000 on save). Standard (Office
2007) and certificate encryption give a clear "not supported" error.

- docx: crypt module (decrypt, encrypt, is_encrypted, read_with_password),
  PasswordRequired / WrongPassword / Encryption errors; hostile files are
  bounded (stream caps, spinCount <= 10,000,000, checked lengths).
- engine: file.open/file.save take "password"; file.encrypt
  {"password": string|null} sets or removes the save password (pure, so it
  never lands in Repeat or recorded macros); a document opened with a
  password is saved with it again; file.info reports "encrypted".
  Passwords are held in a zeroizing type whose Debug prints ***.
- ui: a password prompt when a user-opened file needs one (scripts get the
  error), File > Info > Protect Document with Encrypt with Password (typed
  twice) and Remove Password; web downloads are encrypted too.
- deps: aes, sha1, sha2, hmac, base64, getrandom (wasm_js in the browser),
  zeroize.

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

* 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>

* 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>

* 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>

* 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>

* Encrypted saves: the data-integrity HMAC key is hashSize bytes

MS-OFFCRYPTO §2.3.4.14 makes the HMAC salt as long as the hash (64 bytes for SHA-512); it was
16. Reading already accepted both.

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

* i18n: password dialog and File › Info strings in every catalog

Rows for the Password and Encrypt with Password dialogs, the Protect Document section of
File › Info and the password status messages in zh-hans, zh-hant, ja, pt-br, es, uk, sr and
sr-latn.

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

* Open and save password-protected Word documents (#55)

Opening a password-protected .docx used to fail as a broken zip and showed
a blank document. Encrypted packages (an OLE compound file with
EncryptionInfo + EncryptedPackage) are now detected and decrypted with
ECMA-376 agile encryption, written from the public [MS-OFFCRYPTO] spec:
password key derivation with spinCount, verifier check, data-integrity
HMAC, AES-CBC in 4096-byte segments (AES-128/192/256 with SHA-1/256/384/512
on read; AES-256 + SHA-512, spinCount 100000 on save). Standard (Office
2007) and certificate encryption give a clear "not supported" error.

- docx: crypt module (decrypt, encrypt, is_encrypted, read_with_password),
  PasswordRequired / WrongPassword / Encryption errors; hostile files are
  bounded (stream caps, spinCount <= 10,000,000, checked lengths).
- engine: file.open/file.save take "password"; file.encrypt
  {"password": string|null} sets or removes the save password (pure, so it
  never lands in Repeat or recorded macros); a document opened with a
  password is saved with it again; file.info reports "encrypted".
  Passwords are held in a zeroizing type whose Debug prints ***.
- ui: a password prompt when a user-opened file needs one (scripts get the
  error), File > Info > Protect Document with Encrypt with Password (typed
  twice) and Remove Password; web downloads are encrypted too.
- deps: aes, sha1, sha2, hmac, base64, getrandom (wasm_js in the browser),
  zeroize.

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

* Reland: catalog coverage 390/431 (90.5%) in ROADMAP and target-app parity

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

* 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>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 16:16:35 -04:00
29e978d6d8 Re-land #264, #263, #270, #271, #267, #280: clipboard pane, table styles, Manage Styles, Asian Typography, Group, Shape Effects (#323)
The original squash merges of these six PRs were empty. Integrated and tested together on current main.

Co-authored-by: Valentin Klein <vangel1976@googlemail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:09:04 -04:00
76 changed files with 6255 additions and 501 deletions
Generated
+159
View File
@@ -121,6 +121,17 @@ 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"
@@ -488,6 +499,15 @@ 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"
@@ -672,6 +692,16 @@ 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"
@@ -681,6 +711,12 @@ 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"
@@ -736,6 +772,12 @@ 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"
@@ -785,6 +827,21 @@ 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"
@@ -834,6 +891,24 @@ 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"
@@ -864,6 +939,18 @@ 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"
@@ -1406,9 +1493,11 @@ 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]]
@@ -1739,6 +1828,24 @@ 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"
@@ -1888,6 +1995,15 @@ 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"
@@ -3418,6 +3534,28 @@ 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"
@@ -3885,6 +4023,12 @@ 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"
@@ -4927,13 +5071,21 @@ 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",
]
@@ -4959,6 +5111,7 @@ dependencies = [
"wordcraft-pdf",
"wordcraft-proof",
"wordcraft-render",
"zeroize",
"zip",
]
@@ -5392,6 +5545,12 @@ 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,6 +52,14 @@ 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,6 +234,12 @@ 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 |
+6 -2
View File
@@ -14,7 +14,7 @@ agent control. This page is the summary; the assessment is
| Number | Value | Kind |
|---|---|---|
| Ribbon/menu catalog coverage | **384 / 430 (89.3%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
| Ribbon/menu catalog coverage | **390 / 431 (90.5%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
| **Feature breadth** (weighted, incl. dialog options, styles, languages beyond the ribbon) | **~80%** | estimated |
| **Ready for real work** (full target) | **~60%** (55–63%) | estimated, additive weighted sum over the dimensions |
| **Mainstream practitioner** | **~55%** | estimated: weekly-work depth × discounts for interaction (×0.93), stability (×0.90), file exchange (×0.90) |
@@ -130,7 +130,8 @@ 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); 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); 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-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) |
@@ -141,6 +142,9 @@ 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 |
| 2026-10-10 | minor | Mainstream practitioner (~54%) and essentials user (~63%) numbers added; full number recomputed 60% → ~47% with the standard's discounts; beta distance now ~28 points and ~450–700 h; stage stays alpha |
+117 -9
View File
@@ -1,33 +1,122 @@
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends};
use eframe::{
NativeOptions,
egui_wgpu::WgpuSetup,
wgpu::{Backends, DeviceType},
};
/// 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>) {
/// 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) {
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 = backend_override.unwrap_or(Backends::DX12);
create.instance_descriptor.backends = backends;
}
}
/// 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(backend_override: Option<Backends>) -> Backends {
fn configured_backends(backends: Backends) -> Backends {
let mut options = NativeOptions::default();
configure(&mut options, backend_override);
configure_backends(&mut options, backends);
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.
@@ -35,13 +124,32 @@ mod tests {
panic!("default native options must create a wgpu instance");
};
assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL));
assert_eq!(configured_backends(None), Backends::DX12);
let gpu = [adapter(DeviceType::IntegratedGpu, true)];
assert_eq!(configured_backends(choose_backends(None, &gpu)), 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()] {
assert_eq!(configured_backends(Some(backends)), backends);
// 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);
}
}
#[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);
}
}
+2 -1
View File
@@ -206,7 +206,8 @@ 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 (see graphics.rs).
// Before eframe creates the wgpu instance: default Windows to DirectX 12 only, or OpenGL when
// DirectX 12 has no GPU driver (see graphics.rs).
#[cfg(target_os = "windows")]
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
let restored = saved_window();
+78 -6
View File
@@ -74,12 +74,83 @@ fn each_para_mut(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Paragraph))
}
}
/// Whether a paragraph anywhere in `b` (also inside tables) uses paragraph or character style
/// `id` (depth-limited like `each_para`). Table styles are the engine's `table.deleteStyle`'s job.
fn uses_style(b: &Block, id: &str, depth: usize) -> bool {
let is = |s: &Option<String>| s.as_deref() == Some(id);
match b {
Block::Para(p) => is(&p.props.style) || is(&p.mark.style) || p.runs.iter().any(|r| is(&r.props.style)),
Block::Table(t) if depth < 16 => {
t.rows.iter().flat_map(|r| r.cells.iter()).flat_map(|c| c.blocks.iter()).any(|cb| uses_style(cb, id, depth + 1))
}
Block::Table(_) => false,
}
}
/// Point every paragraph and run in `b` that uses style `from` at `to`; returns how many
/// paragraphs changed.
fn restyle_block(b: &mut Block, from: &str, to: &Option<String>, depth: usize) -> usize {
let fix = |s: &mut Option<String>| {
if s.as_deref() == Some(from) {
s.clone_from(to);
true
} else {
false
}
};
match b {
Block::Para(p) => {
let mut changed = fix(&mut p.props.style);
changed |= fix(&mut p.mark.style);
for r in &mut p.runs {
changed |= fix(&mut r.props.style);
}
if changed {
p.normalize();
p.touch();
}
usize::from(changed)
}
Block::Table(t) => {
let mut n = 0;
if depth < 16 {
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
for cb in &mut cell.blocks {
if uses_style(cb, from, depth + 1) {
n += restyle_block(Arc::make_mut(cb), from, to, depth + 1);
}
}
}
}
n
}
}
}
/// Text box story ids of a paragraph's shapes.
fn box_ids(p: &Paragraph) -> impl Iterator<Item = u32> + '_ {
p.objects.iter().filter_map(|o| if let InlineObject::Shape { story: Some(id), .. } = o { Some(*id) } else { None })
p.objects.iter().flat_map(InlineObject::text_boxes)
}
impl Document {
/// Point every paragraph and run that uses paragraph or character style `from` at `to`
/// (`None`: the default style), in every story, tables included. Returns how many paragraphs
/// changed. (Tables' own table styles are retargeted by the engine's table style deletion.)
pub fn restyle(&mut self, from: &str, to: Option<String>) -> usize {
let stories: Vec<crate::StoryRef> =
std::iter::once(crate::StoryRef::Body).chain(self.parts.keys().map(|k| crate::StoryRef::Part(*k))).collect();
let mut n = 0;
for s in stories {
let Ok(bl) = self.story_mut(s) else { continue };
for b in bl.iter_mut() {
if uses_style(b, from, 0) {
n += restyle_block(Arc::make_mut(b), from, &to, 0);
}
}
}
n
}
/// Insert plain text at `pos` (`\n` inside `s` is a manual line break; use
/// [`Document::split_paragraph`] for paragraph breaks). Returns the position after it.
pub fn insert_text(&mut self, pos: &Pos, s: &str, props: &CharProps) -> Result<Pos> {
@@ -320,11 +391,11 @@ impl Document {
each_para_mut(b, 0, &mut |p| {
let mut changed = false;
for o in p.objects.iter_mut() {
if let InlineObject::Shape { story, .. } = o
&& let Some(old) = *story
{
*story = self.adopt_text_box(old, parts, depth, budget);
changed = true;
for story in o.text_box_slots() {
if let Some(old) = *story {
*story = self.adopt_text_box(old, parts, depth, budget);
changed = true;
}
}
}
if changed {
@@ -501,6 +572,7 @@ mod tests {
stroke_width: 0.75,
float: Default::default(),
story,
effects: Default::default(),
}
}
+224
View File
@@ -0,0 +1,224 @@
//! Shape effects (DrawingML `a:effectLst`, ECMA-376 Part 1 §20.1.8): an outer shadow, a glow and
//! soft edges. Lengths are points, angles degrees, transparencies percent (0 = opaque).
//!
//! Renderers approximate the blurs with [`bands`]: a few copies of the shape's silhouette, grown
//! or shrunk by steps and overlaid with partial opacity, so the screen and PDF output (which has
//! no blur filter) match.
use serde::{Deserialize, Serialize};
use crate::props::Rgb;
/// Largest blur radius, points.
pub const MAX_BLUR: f32 = 100.0;
/// Largest shadow distance, points.
pub const MAX_DISTANCE: f32 = 200.0;
/// Largest glow size, points.
pub const MAX_GLOW: f32 = 150.0;
/// Largest soft-edge radius, points.
pub const MAX_SOFT_EDGE: f32 = 100.0;
/// The effects on a shape. Empty (the default) draws the shape plainly.
#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ShapeEffects {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shadow: Option<Shadow>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glow: Option<Glow>,
/// Soft-edge radius, points: the shape fades out over this distance inside its outline.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub soft_edge: Option<f32>,
}
/// An outer shadow: a blurred copy of the shape's silhouette cast `distance` points away in
/// direction `angle` (degrees clockwise from the positive x axis, y pointing down).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Shadow {
pub color: Rgb,
pub transparency: f32,
pub blur: f32,
pub distance: f32,
pub angle: f32,
}
impl Default for Shadow {
/// "Offset: Bottom Right".
fn default() -> Self {
Shadow { color: Rgb::BLACK, transparency: 60.0, blur: 4.0, distance: 3.0, angle: 45.0 }
}
}
/// A glow: a soft band of colour `size` points wide around the shape's outline.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Glow {
pub color: Rgb,
pub size: f32,
pub transparency: f32,
}
impl Default for Glow {
fn default() -> Self {
Glow { color: Rgb(0x15, 0x60, 0x82), size: 5.0, transparency: 60.0 }
}
}
fn clamp(v: f32, lo: f32, hi: f32, dflt: f32) -> f32 {
if v.is_finite() { v.clamp(lo, hi) } else { dflt }
}
/// The outer-shadow presets (no perspective shadows): id, label.
pub const SHADOW_PRESETS: [(&str, &str); 9] = [
("offsetBottomRight", "Offset: Bottom Right"),
("offsetBottom", "Offset: Bottom"),
("offsetBottomLeft", "Offset: Bottom Left"),
("offsetRight", "Offset: Right"),
("offsetCenter", "Offset: Center"),
("offsetLeft", "Offset: Left"),
("offsetTopRight", "Offset: Top Right"),
("offsetTop", "Offset: Top"),
("offsetTopLeft", "Offset: Top Left"),
];
impl Shadow {
/// A preset by id (see [`SHADOW_PRESETS`]).
pub fn preset(id: &str) -> Option<Shadow> {
let d = Shadow::default();
let angle = match id {
"offsetBottomRight" => 45.0,
"offsetBottom" => 90.0,
"offsetBottomLeft" => 135.0,
"offsetLeft" => 180.0,
"offsetTopLeft" => 225.0,
"offsetTop" => 270.0,
"offsetTopRight" => 315.0,
"offsetRight" => 0.0,
"offsetCenter" => return Some(Shadow { distance: 0.0, blur: 5.0, ..d }),
_ => return None,
};
Some(Shadow { angle, ..d })
}
/// Finite, in range.
pub fn sanitized(self) -> Shadow {
let angle = clamp(self.angle, -1e6, 1e6, 45.0).rem_euclid(360.0);
Shadow {
color: self.color,
transparency: clamp(self.transparency, 0.0, 100.0, 60.0),
blur: clamp(self.blur, 0.0, MAX_BLUR, 0.0),
distance: clamp(self.distance, 0.0, MAX_DISTANCE, 0.0),
angle: if angle.is_finite() && angle < 360.0 { angle } else { 0.0 },
}
}
/// The shadow's offset from the shape, points (x right, y down).
pub fn offset(&self) -> (f32, f32) {
let s = self.sanitized();
let a = s.angle.to_radians();
(s.distance * a.cos(), s.distance * a.sin())
}
/// Opacity 0..1.
pub fn opacity(&self) -> f32 {
1.0 - self.sanitized().transparency / 100.0
}
}
impl Glow {
/// Finite, in range.
pub fn sanitized(self) -> Glow {
Glow { color: self.color, size: clamp(self.size, 0.0, MAX_GLOW, 0.0), transparency: clamp(self.transparency, 0.0, 100.0, 60.0) }
}
/// Opacity 0..1.
pub fn opacity(&self) -> f32 {
1.0 - self.sanitized().transparency / 100.0
}
}
impl ShapeEffects {
pub fn is_empty(&self) -> bool {
self.shadow.is_none() && self.glow.is_none() && self.soft_edge.is_none()
}
/// Finite, in range; zero-size glows and soft edges dropped.
pub fn sanitized(self) -> ShapeEffects {
ShapeEffects {
shadow: self.shadow.map(Shadow::sanitized),
glow: self.glow.map(Glow::sanitized).filter(|g| g.size > 0.0),
soft_edge: self.soft_edge.map(|r| clamp(r, 0.0, MAX_SOFT_EDGE, 0.0)).filter(|r| *r > 0.0),
}
}
/// How far the effects reach beyond the shape's frame on each side (left, top, right,
/// bottom), points — what `wp:effectExtent` records.
pub fn extent(&self) -> [f32; 4] {
let e = self.sanitized();
let mut out = [0.0f32; 4];
if let Some(g) = e.glow {
out = [g.size; 4];
}
if let Some(s) = e.shadow {
let (dx, dy) = s.offset();
let r = s.blur / 2.0;
let reach = [r - dx, r - dy, r + dx, r + dy];
for (o, v) in out.iter_mut().zip(reach) {
*o = o.max(v.max(0.0));
}
}
out.map(|v| (v * 100.0).round() / 100.0)
}
}
/// Bands approximating a blurred edge: `(grow, alpha)` pairs. Each is a copy of the silhouette
/// grown outward by `grow` points (negative shrinks it), filled with opacity `alpha`; overlaid in
/// any order, in one colour, they ramp linearly from `opacity` at `from` to nothing at `to`.
pub fn bands(from: f32, to: f32, opacity: f32) -> Vec<(f32, f32)> {
let (from, to) = (clamp(from, -1000.0, 1000.0, 0.0), clamp(to, -1000.0, 1000.0, 0.0));
let opacity = clamp(opacity, 0.0, 1.0, 0.0);
if opacity <= 0.0 {
return Vec::new();
}
let span = (to - from).max(0.0);
if span < 0.25 {
return vec![((from + to) / 2.0, opacity)];
}
let n = ((span / 1.5).ceil() as usize).clamp(3, 12);
let nf = n as f32;
(1..=n)
.map(|m| {
// The m-th band from the outside: after it, coverage is opacity·m/n.
let grow = to - span * (m as f32 - 0.5) / nf;
let before = 1.0 - opacity * (m as f32 - 1.0) / nf;
let after = 1.0 - opacity * m as f32 / nf;
let alpha = if before > 0.0 { 1.0 - after / before } else { 1.0 };
(grow, alpha.clamp(0.0, 1.0))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bands_ramp_to_the_requested_opacity() {
for (from, to, o) in [(-2.0, 2.0, 0.4), (-5.0, 0.0, 1.0), (0.0, 8.0, 0.6)] {
let b = bands(from, to, o);
assert!(b.iter().all(|(g, a)| *g > from && *g < to && (0.0..=1.0).contains(a)));
let covered = 1.0 - b.iter().map(|(_, a)| 1.0 - a).product::<f32>();
assert!((covered - o).abs() < 1e-4, "{covered} vs {o}");
}
assert_eq!(bands(0.0, 0.0, 0.5), vec![(0.0, 0.5)]);
assert!(bands(f32::NAN, f32::INFINITY, f32::NAN).is_empty());
}
#[test]
fn hostile_numbers_are_clamped() {
let s = Shadow { color: Rgb::BLACK, transparency: f32::NAN, blur: 1e9, distance: -4.0, angle: -90.0 }.sanitized();
assert_eq!((s.transparency, s.blur, s.distance, s.angle), (60.0, MAX_BLUR, 0.0, 270.0));
let e = ShapeEffects { shadow: None, glow: Some(Glow { size: 1e9, ..Glow::default() }), soft_edge: Some(-1.0) }.sanitized();
assert_eq!(e.glow.map(|g| g.size), Some(MAX_GLOW));
assert_eq!(e.soft_edge, None);
let e = ShapeEffects { glow: Some(Glow { size: f32::INFINITY, ..Glow::default() }), ..Default::default() }.sanitized();
assert!(e.is_empty(), "a non-finite size is no glow");
let (dx, dy) = Shadow::preset("offsetBottomRight").unwrap().offset();
assert!(dx > 2.0 && dy > 2.0);
}
}
+21 -17
View File
@@ -12,6 +12,7 @@
pub mod bidi;
pub mod edit;
pub mod effects;
pub mod encoding;
pub mod fields;
pub mod graphic;
@@ -330,6 +331,9 @@ 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.
@@ -393,6 +397,7 @@ impl Default for Settings {
compat_mode: COMPAT_MODE_CURRENT,
grid_h: DEFAULT_GRID,
grid_v: DEFAULT_GRID,
math: None,
}
}
}
@@ -596,7 +601,7 @@ impl Document {
for path in self.para_paths(story) {
let Some(p) = self.para(story, &path) else { continue };
for off in p.object_offsets() {
if matches!(p.object_at(off), Some(InlineObject::Shape { story: Some(id), .. }) if *id == part) {
if p.object_at(off).is_some_and(|o| o.text_boxes().contains(&part)) {
return Some(Pos { story, path, off: off + para::OBJ.len_utf8() });
}
}
@@ -700,9 +705,8 @@ impl Document {
/// document order. Headers, footers, comments and stories nothing points at aren't included.
pub fn counted_stories(&self) -> Vec<StoryRef> {
self.reachable(vec![StoryRef::Body], &|o| match o {
InlineObject::Shape { story: Some(id), .. } => Some((*id, &[PartKind::TextBox])),
InlineObject::NoteRef { id, .. } => Some((*id, &[PartKind::Footnote, PartKind::Endnote])),
_ => None,
InlineObject::NoteRef { id, .. } => vec![(*id, &[PartKind::Footnote, PartKind::Endnote][..])],
o => o.text_boxes().into_iter().map(|id| (id, &[PartKind::TextBox][..])).collect(),
})
}
@@ -716,10 +720,7 @@ impl Document {
let roots = std::iter::once(StoryRef::Body)
.chain(self.parts.iter().filter(|(_, p)| p.kind != PartKind::TextBox).map(|(id, _)| StoryRef::Part(*id)))
.collect();
let live = self.reachable(roots, &|o| match o {
InlineObject::Shape { story: Some(id), .. } => Some((*id, &[PartKind::TextBox])),
_ => None,
});
let live = self.reachable(roots, &|o| o.text_boxes().into_iter().map(|id| (id, &[PartKind::TextBox][..])).collect());
let before = self.parts.len();
self.parts.retain(|id, p| p.kind != PartKind::TextBox || live.contains(&StoryRef::Part(*id)));
before - self.parts.len()
@@ -765,13 +766,16 @@ impl Document {
for b in blocks {
edit::each_para(b, 0, &mut |p| {
for (k, o) in p.objects.iter().enumerate() {
if f(p, k)
&& let InlineObject::Shape { story: Some(id), .. } = o
&& let Some(part) = self.parts.get(id).filter(|p| p.kind == PartKind::TextBox)
&& budget.enter(*id)
{
self.walk_objects(&part.blocks, budget, f);
budget.leave();
if !f(p, k) {
continue;
}
for id in o.text_boxes() {
if let Some(part) = self.parts.get(&id).filter(|p| p.kind == PartKind::TextBox)
&& budget.enter(id)
{
self.walk_objects(&part.blocks, budget, f);
budget.leave();
}
}
}
});
@@ -780,7 +784,7 @@ impl Document {
/// `roots`, then every story their objects lead to (`follow`: an object's story id and the
/// part kinds that count), transitively, in the order found.
fn reachable(&self, roots: Vec<StoryRef>, follow: &dyn Fn(&InlineObject) -> Option<(u32, &'static [PartKind])>) -> Vec<StoryRef> {
fn reachable(&self, roots: Vec<StoryRef>, follow: &dyn Fn(&InlineObject) -> Vec<(u32, &'static [PartKind])>) -> Vec<StoryRef> {
let mut out = roots;
let mut seen: std::collections::BTreeSet<StoryRef> = out.iter().copied().collect();
let mut i = 0;
@@ -789,7 +793,7 @@ impl Document {
let mut found = Vec::new();
for b in self.story(s).into_iter().flatten() {
edit::each_para(b, 0, &mut |p| {
found.extend(p.objects.iter().filter_map(follow));
found.extend(p.objects.iter().flat_map(follow));
});
}
for (id, kinds) in found {
+54
View File
@@ -54,6 +54,55 @@ 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")]
@@ -100,6 +149,10 @@ 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 {
@@ -662,6 +715,7 @@ 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;
+115 -1
View File
@@ -223,6 +223,23 @@ pub enum InlineObject {
/// Text box content: `Document::parts` id.
#[serde(default)]
story: Option<u32>,
/// Shadow, glow and soft edges.
#[serde(default, skip_serializing_if = "crate::effects::ShapeEffects::is_empty")]
effects: crate::effects::ShapeEffects,
},
/// Pictures, shapes and text boxes grouped into one object (Layout › Arrange › Group): it
/// moves, wraps and resizes as one. Its members are laid out in the group's own coordinate
/// space, `ch_w` × `ch_h`, which is stretched over the group's `w` × `h` (DrawingML's
/// `a:chExt` and `a:ext`), so resizing the group scales them.
Group {
w: f32,
h: f32,
#[serde(default)]
float: Float,
/// Size of the members' coordinate space (their offsets and sizes are in it).
ch_w: f32,
ch_h: f32,
children: Vec<GroupChild>,
},
/// A field: `instr` is the field code (`PAGE`, `NUMPAGES`, `DATE \@ "M/d/yyyy"`, `TOC \o "1-3"`…);
/// `result` the cached display text.
@@ -283,7 +300,101 @@ pub enum InlineObject {
},
}
/// One member of an [`InlineObject::Group`]: a picture or shape (its own `w`, `h` and story), at
/// `x`, `y` in the group's coordinate space. Its `float` is unused.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GroupChild {
pub x: f32,
pub y: f32,
pub obj: InlineObject,
}
/// Most members a group keeps (hostile files).
pub const MAX_GROUP_CHILDREN: usize = 1000;
impl InlineObject {
/// A picture, chart or diagram, shape, text box or group: something drawn with a frame, sized and wrapped.
pub fn is_drawing(&self) -> bool {
matches!(self, InlineObject::Image { .. } | InlineObject::Graphic { .. } | InlineObject::Shape { .. } | InlineObject::Group { .. })
}
/// A drawing's size and placement.
pub fn frame(&self) -> Option<(f32, f32, &Float)> {
match self {
InlineObject::Image { w, h, float, .. }
| InlineObject::Graphic { w, h, float, .. }
| InlineObject::Shape { w, h, float, .. }
| InlineObject::Group { w, h, float, .. } => Some((*w, *h, float)),
_ => None,
}
}
/// A drawing's placement, to change.
pub fn float_mut(&mut self) -> Option<&mut Float> {
match self {
InlineObject::Image { float, .. }
| InlineObject::Graphic { float, .. }
| InlineObject::Shape { float, .. }
| InlineObject::Group { float, .. } => Some(float),
_ => None,
}
}
/// Resize a drawing (a group's members scale with it).
pub fn set_size(&mut self, nw: f32, nh: f32) {
if let InlineObject::Image { w, h, .. }
| InlineObject::Graphic { w, h, .. }
| InlineObject::Shape { w, h, .. }
| InlineObject::Group { w, h, .. } = self
{
*w = nw;
*h = nh;
}
}
/// The text box story this object shows, if it is a text box.
pub fn text_box(&self) -> Option<u32> {
match self {
InlineObject::Shape { story, .. } => *story,
_ => None,
}
}
/// The text box stories this object shows: a text box's, or its group members'.
pub fn text_boxes(&self) -> Vec<u32> {
match self {
InlineObject::Shape { story: Some(id), .. } => vec![*id],
InlineObject::Group { children, .. } => children.iter().filter_map(|c| c.obj.text_box()).collect(),
_ => Vec::new(),
}
}
/// The text box story slots of this object and its group members, to repoint.
pub fn text_box_slots(&mut self) -> Vec<&mut Option<u32>> {
match self {
InlineObject::Shape { story, .. } => vec![story],
InlineObject::Group { children, .. } => children
.iter_mut()
.filter_map(|c| match &mut c.obj {
InlineObject::Shape { story, .. } => Some(story),
_ => None,
})
.collect(),
_ => Vec::new(),
}
}
/// A group's members placed in a `w` × `h` box at (`x`, `y`): each member's rectangle
/// (x, y, w, h) and the member. Empty for anything else.
pub fn group_rects(&self, x: f32, y: f32, w: f32, h: f32) -> Vec<([f32; 4], &InlineObject)> {
let InlineObject::Group { ch_w, ch_h, children, .. } = self else { return Vec::new() };
let fin = |v: f32| if v.is_finite() { v } else { 0.0 };
let sx = if fin(*ch_w) > 0.0 { fin(w) / ch_w } else { 1.0 };
let sy = if fin(*ch_h) > 0.0 { fin(h) / ch_h } else { 1.0 };
children
.iter()
.take(MAX_GROUP_CHILDREN)
.filter_map(|c| {
let (cw, chh, _) = c.obj.frame()?;
let r = [x + fin(c.x) * sx, y + fin(c.y) * sy, (fin(cw) * sx).clamp(0.0, 4000.0), (fin(chh) * sy).clamp(0.0, 4000.0)];
(!matches!(c.obj, InlineObject::Group { .. })).then_some((r, &c.obj))
})
.collect()
}
/// Zero-width markers don't take part in layout.
pub fn is_marker(&self) -> bool {
matches!(
@@ -298,7 +409,10 @@ impl InlineObject {
}
pub fn is_floating(&self) -> bool {
match self {
InlineObject::Image { float, .. } | InlineObject::Graphic { float, .. } | InlineObject::Shape { float, .. } => float.wrap != Wrap::Inline,
InlineObject::Image { float, .. }
| InlineObject::Graphic { float, .. }
| InlineObject::Shape { float, .. }
| InlineObject::Group { float, .. } => float.wrap != Wrap::Inline,
_ => false,
}
}
+22 -1
View File
@@ -622,13 +622,34 @@ pub struct ParaProps {
/// Drop cap: lines to drop (0 = none) — applies to the first character(s).
#[serde(skip_serializing_if = "Option::is_none")]
pub drop_cap: Option<u8>,
/// Asian typography (`w:kinsoku`): East Asian line-breaking rules — no line starts with
/// closing punctuation such as 、。」 or ends with opening punctuation such as 「(.
/// Unset = on.
#[serde(skip_serializing_if = "Option::is_none")]
pub kinsoku: Option<bool>,
/// `w:wordWrap`: Latin words wrap whole; `false` lets them break at any character (Word's
/// "Allow Latin text to wrap in the middle of a word"). Unset = on.
#[serde(skip_serializing_if = "Option::is_none")]
pub word_wrap: Option<bool>,
/// `w:overflowPunct`: punctuation may hang past the line end. Unset = on.
#[serde(skip_serializing_if = "Option::is_none")]
pub overflow_punct: Option<bool>,
/// `w:topLinePunct`: compress punctuation at the start of a line. Unset = off.
#[serde(skip_serializing_if = "Option::is_none")]
pub top_line_punct: Option<bool>,
/// `w:autoSpaceDE`: automatic space between Asian and Latin text. Unset = on.
#[serde(rename = "autoSpaceDE", skip_serializing_if = "Option::is_none")]
pub auto_space_de: Option<bool>,
/// `w:autoSpaceDN`: automatic space between Asian text and numbers. Unset = on.
#[serde(rename = "autoSpaceDN", skip_serializing_if = "Option::is_none")]
pub auto_space_dn: Option<bool>,
}
impl ParaProps {
pub fn overlay(&mut self, patch: &ParaProps) {
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, space_before, space_after, line_spacing,
contextual_spacing, keep_next, keep_lines, page_break_before, widow_control, outline_level, numbering, tabs, shading, borders,
suppress_hyphens, suppress_line_numbers, bidi, drop_cap);
suppress_hyphens, suppress_line_numbers, bidi, drop_cap, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn);
}
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
self.overlay(patch);
+14
View File
@@ -141,6 +141,14 @@ pub struct ResolvedPara {
pub bidi: bool,
pub drop_cap: u8,
pub suppress_hyphens: bool,
/// Asian typography (see `ParaProps`): East Asian line-breaking rules.
pub kinsoku: bool,
/// Latin words wrap whole (`false`: they may break at any character).
pub word_wrap: bool,
pub overflow_punct: bool,
pub top_line_punct: bool,
pub auto_space_de: bool,
pub auto_space_dn: bool,
}
const MAX_INDENT: f32 = 1584.0; // 22"
@@ -186,6 +194,12 @@ impl ResolvedPara {
bidi: p.bidi.unwrap_or(false),
drop_cap: p.drop_cap.unwrap_or(0).min(10),
suppress_hyphens: p.suppress_hyphens.unwrap_or(false),
kinsoku: p.kinsoku.unwrap_or(true),
word_wrap: p.word_wrap.unwrap_or(true),
overflow_punct: p.overflow_punct.unwrap_or(true),
top_line_punct: p.top_line_punct.unwrap_or(false),
auto_space_de: p.auto_space_de.unwrap_or(true),
auto_space_dn: p.auto_space_dn.unwrap_or(true),
}
}
}
+44
View File
@@ -65,6 +65,21 @@ pub struct TableStyleParts {
pub first_col_chr: CharProps,
pub total_chr: CharProps,
pub total_border_top: Option<Border>,
/// Shading and cell borders of the first column (`firstCol` region).
pub first_col_fill: Option<Rgb>,
pub first_col_borders: Option<Borders>,
/// The last column (`lastCol` region).
pub last_col_fill: Option<Rgb>,
pub last_col_chr: CharProps,
pub last_col_borders: Option<Borders>,
/// Shading and cell borders of the total (last) row (`lastRow` region); a top edge left
/// unset falls back to `total_border_top`.
pub total_fill: Option<Rgb>,
pub total_borders: Option<Borders>,
/// The odd column bands (`band1Vert` region).
pub col_band_fill: Option<Rgb>,
pub col_band_chr: CharProps,
pub col_band_borders: Option<Borders>,
}
impl TableStyleParts {
@@ -91,6 +106,16 @@ impl TableStyleParts {
self.first_col_chr.overlay(&patch.first_col_chr);
self.total_chr.overlay(&patch.total_chr);
self.total_border_top = patch.total_border_top.or(self.total_border_top);
self.first_col_fill = patch.first_col_fill.or(self.first_col_fill);
self.first_col_borders = merge(self.first_col_borders, patch.first_col_borders);
self.last_col_fill = patch.last_col_fill.or(self.last_col_fill);
self.last_col_chr.overlay(&patch.last_col_chr);
self.last_col_borders = merge(self.last_col_borders, patch.last_col_borders);
self.total_fill = patch.total_fill.or(self.total_fill);
self.total_borders = merge(self.total_borders, patch.total_borders);
self.col_band_fill = patch.col_band_fill.or(self.col_band_fill);
self.col_band_chr.overlay(&patch.col_band_chr);
self.col_band_borders = merge(self.col_band_borders, patch.col_band_borders);
}
}
@@ -437,6 +462,25 @@ impl StyleSheet {
None => self.styles.push(st),
}
}
/// Remove style `id` and return it. Styles based on it are rebased onto its own base, and
/// `next` / `linked` references to it are dropped. Text that uses it is the document's to
/// retarget ([`crate::Document::restyle`]).
pub fn remove(&mut self, id: &str) -> Option<Style> {
let i = self.styles.iter().position(|s| s.id == id)?;
let gone = self.styles.remove(i);
for s in &mut self.styles {
if s.based_on.as_deref() == Some(id) {
s.based_on = gone.based_on.clone().filter(|b| *b != s.id);
}
if s.next.as_deref() == Some(id) {
s.next = None;
}
if s.linked.as_deref() == Some(id) {
s.linked = None;
}
}
Some(gone)
}
/// An id not used yet, derived from a display name.
pub fn new_id(&self, name: &str) -> String {
let base: String = name.chars().filter(|c| c.is_alphanumeric()).collect();
+5
View File
@@ -88,6 +88,7 @@ fn text_box_anchor_finds_the_owning_shape() {
stroke_width: 0.75,
float: Default::default(),
story,
effects: Default::default(),
};
// A plain shape before it doesn't count.
d.insert_object(&Pos::body(0, 0), shape(None), &CharProps::default()).unwrap();
@@ -108,6 +109,7 @@ fn word_count_including_notes_counts_used_boxes_and_notes_only() {
stroke_width: 0.75,
float: Default::default(),
story: Some(story),
effects: Default::default(),
};
let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))];
let boxed = d.add_part(PartKind::TextBox, para_of("three four"));
@@ -146,6 +148,7 @@ fn counted_stories_survive_self_nested_boxes() {
stroke_width: 0.0,
float: Default::default(),
story: Some(id),
effects: Default::default(),
};
d.insert_object(&Pos::body(0, 0), shape.clone(), &CharProps::default()).unwrap();
d.insert_object(&Pos { story: StoryRef::Part(id), path: Path::top(0), off: 0 }, shape, &CharProps::default()).unwrap();
@@ -163,6 +166,7 @@ fn prune_text_boxes_drops_only_unshown_ones() {
stroke_width: 0.0,
float: Default::default(),
story: Some(story),
effects: Default::default(),
};
let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))];
let mut d = Document::from_text("body");
@@ -195,6 +199,7 @@ fn notes_in_text_box_follow_nested_boxes_once() {
stroke_width: 0.0,
float: Default::default(),
story: Some(story),
effects: Default::default(),
};
let note = |id| InlineObject::NoteRef { kind: para::NoteKind::Footnote, id, custom: String::new() };
let mut d = Document::new();
+12
View File
@@ -15,6 +15,18 @@ 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
@@ -0,0 +1,741 @@
//! 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,6 +7,7 @@
//! 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;
@@ -14,9 +15,26 @@ 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)]
@@ -78,4 +96,14 @@ 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),
}
+28 -1
View File
@@ -1,6 +1,8 @@
//! Office Math (OMML, ECMA-376 Part 1 §22.1) → [`wordcraft_doc::math`] tree.
use wordcraft_doc::math::{Arg, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, ScriptKind, merge_runs};
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::props::Rgb;
use crate::xml::El;
@@ -20,6 +22,27 @@ 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") {
@@ -251,6 +274,10 @@ 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)
+31 -2
View File
@@ -63,6 +63,16 @@ 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());
@@ -505,10 +515,28 @@ impl Reader<'_> {
t.band_chr = chr;
t.band_borders = cell_borders;
}
Some("firstCol") => t.first_col_chr = chr,
Some("firstCol") => {
t.first_col_chr = chr;
t.first_col_fill = fill;
t.first_col_borders = cell_borders;
}
Some("lastCol") => {
t.last_col_chr = chr;
t.last_col_fill = fill;
t.last_col_borders = cell_borders;
}
Some("lastRow") => {
t.total_chr = chr;
t.total_border_top = c.child("w:tcPr").and_then(|p| p.child("w:tcBorders")).and_then(|b| b.child("w:top")).map(props::border);
t.total_fill = fill;
// The top rule is kept on its own (as built-in styles have it); the other edges
// are the region's borders.
t.total_border_top = cell_borders.and_then(|b| b.top);
t.total_borders = cell_borders.map(|b| wordcraft_doc::props::Borders { top: None, ..b }).filter(|b| *b != Default::default());
}
Some("band1Vert") => {
t.col_band_fill = fill;
t.col_band_chr = chr;
t.col_band_borders = cell_borders;
}
_ => {}
}
@@ -633,6 +661,7 @@ 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" => {
+6
View File
@@ -142,6 +142,12 @@ impl PropCtx {
"w:suppressAutoHyphens" => p.suppress_hyphens = Some(on_off(k)),
"w:suppressLineNumbers" => p.suppress_line_numbers = Some(on_off(k)),
"w:bidi" => p.bidi = Some(on_off(k)),
"w:kinsoku" => p.kinsoku = Some(on_off(k)),
"w:wordWrap" => p.word_wrap = Some(on_off(k)),
"w:overflowPunct" => p.overflow_punct = Some(on_off(k)),
"w:topLinePunct" => p.top_line_punct = Some(on_off(k)),
"w:autoSpaceDE" => p.auto_space_de = Some(on_off(k)),
"w:autoSpaceDN" => p.auto_space_dn = Some(on_off(k)),
"w:framePr" => {
if matches!(k.attr("w:dropCap"), Some("drop") | Some("margin")) {
p.drop_cap = Some(k.attr("w:lines").and_then(u32_of).unwrap_or(3).clamp(1, 10) as u8);
+181 -16
View File
@@ -2,6 +2,7 @@
use std::sync::Arc;
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind};
use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap};
use wordcraft_doc::props::{CharProps, Rgb};
@@ -125,7 +126,7 @@ fn clean_text(s: &str) -> String {
fn children_of_choice(ac: &El) -> Option<&El> {
// Prefer a Choice whose requirements we understand; else the Fallback; else any Choice.
const KNOWN: &[&str] = &["wps", "w14", "w15", "wp14", "a14", "w16se", "w16cid", "w16", "w16cex", "w16sdtdh", "v"];
const KNOWN: &[&str] = &["wps", "wpg", "w14", "w15", "wp14", "a14", "w16se", "w16cid", "w16", "w16cex", "w16sdtdh", "v"];
let ok = |c: &El| c.attr("Requires").unwrap_or("").split_whitespace().all(|r| KNOWN.contains(&r));
ac.children("mc:Choice").find(|c| ok(c)).or_else(|| ac.child("mc:Fallback")).or_else(|| ac.child("mc:Choice"))
}
@@ -663,19 +664,11 @@ impl Reader<'_> {
let graphic = self.graphic(kind, gd, rels, w, h);
return Some(InlineObject::Graphic { w, h, alt, float, graphic });
}
if let Some(blip) = gd.find("a:blip") {
let media = blip.attr("r:embed").and_then(|id| self.media_for(rels, id))?;
let mut crop = [0.0f32; 4];
if let Some(sr) = gd.find("a:srcRect") {
for (i, n) in ["l", "t", "r", "b"].iter().enumerate() {
if let Some(v) = sr.attr(n).and_then(int)
&& let Some(slot) = crop.get_mut(i)
{
*slot = (v.clamp(0, 100_000) as f32 / 100_000.0).clamp(0.0, 1.0);
}
}
}
return Some(InlineObject::Image { media, w, h, alt, float, crop });
if let Some(g) = gd.child("wpg:wgp") {
return self.read_group(sc, g, rels, w, h, float);
}
if gd.find("a:blip").is_some() {
return self.read_pic(gd, rels, w, h, alt, float);
}
if let Some(wsp) = gd.find("wps:wsp") {
return Some(self.read_wsp(sc, wsp, rels, w, h, float));
@@ -715,6 +708,99 @@ impl Reader<'_> {
}
}
/// A picture: the first `a:blip` in `pic` and its crop.
fn read_pic(&mut self, pic: &El, rels: &Rels, w: f32, h: f32, alt: String, float: Float) -> Option<InlineObject> {
let blip = pic.find("a:blip")?;
let media = blip.attr("r:embed").and_then(|id| self.media_for(rels, id))?;
let mut crop = [0.0f32; 4];
if let Some(sr) = pic.find("a:srcRect") {
for (i, n) in ["l", "t", "r", "b"].iter().enumerate() {
if let Some(v) = sr.attr(n).and_then(int)
&& let Some(slot) = crop.get_mut(i)
{
*slot = (v.clamp(0, 100_000) as f32 / 100_000.0).clamp(0.0, 1.0);
}
}
}
Some(InlineObject::Image { media, w, h, alt, float, crop })
}
/// A `wpg:wgp` group (`w` × `h`): its pictures, shapes and text boxes, nested groups
/// flattened into it. `None` when it has no member we can show.
fn read_group(&mut self, sc: &mut StoryCtx, g: &El, rels: &Rels, w: f32, h: f32, float: Float) -> Option<InlineObject> {
let x = group_xfrm(g.child("wpg:grpSpPr"));
// The members' space; without `a:chExt` it is the group's own size.
let (ch_w, ch_h) = match x.ch_ext {
Some((cw, ch)) if cw > 0.0 && ch > 0.0 => (cw, ch),
_ => (w.max(1.0), h.max(1.0)),
};
let (cx, cy) = x.ch_off.unwrap_or((0.0, 0.0));
let mut children = Vec::new();
self.group_members(sc, g, rels, (-cx, -cy, 1.0, 1.0), 0, &mut children);
if children.is_empty() {
return None;
}
Some(InlineObject::Group { w, h, float, ch_w, ch_h, children })
}
/// The members of group `g` into `out`, mapped into the outermost group's space by `t`
/// (x0, y0, sx, sy: outer = x0 + inner × sx).
fn group_members(
&mut self,
sc: &mut StoryCtx,
g: &El,
rels: &Rels,
t: (f32, f32, f32, f32),
depth: usize,
out: &mut Vec<wordcraft_doc::para::GroupChild>,
) {
let (x0, y0, sx, sy) = t;
for e in g.els() {
if out.len() >= wordcraft_doc::para::MAX_GROUP_CHILDREN {
return;
}
let e = match e.name.as_str() {
"mc:AlternateContent" => match children_of_choice(e).and_then(|c| c.els().next()) {
Some(c) => c,
None => continue,
},
_ => e,
};
let sppr = match e.name.as_str() {
"wps:wsp" => e.child("wps:spPr"),
"pic:pic" => e.child("pic:spPr"),
"wpg:grpSp" => e.child("wpg:grpSpPr"),
_ => continue,
};
let x = group_xfrm(sppr);
let ((ox, oy), (ew, eh)) = (x.off, x.ext);
if e.name == "wpg:grpSp" {
if depth >= 8 {
continue;
}
// Its members' space maps onto its own box.
let (cox, coy) = x.ch_off.unwrap_or((0.0, 0.0));
let (cw, ch) = x.ch_ext.filter(|(cw, ch)| *cw > 0.0 && *ch > 0.0).unwrap_or((ew.max(1.0), eh.max(1.0)));
let (kx, ky) = (ew / cw, eh / ch);
let nt = (x0 + (ox - cox * kx) * sx, y0 + (oy - coy * ky) * sy, sx * kx, sy * ky);
self.group_members(sc, e, rels, nt, depth + 1, out);
continue;
}
let max = crate::units::MAX_LEN_PT;
let fit = |v: f32, lo: f32| wordcraft_geom::finite(v).clamp(lo, max);
let (w, h) = (fit(ew * sx, 0.0), fit(eh * sy, 0.0));
let obj = if e.name == "pic:pic" {
let alt = e.find("pic:cNvPr").and_then(|p| p.attr("descr")).unwrap_or("").to_string();
self.read_pic(e, rels, w, h, alt, Float::default())
} else {
Some(self.read_wsp(sc, e, rels, w, h, Float::default()))
};
if let Some(obj) = obj {
out.push(wordcraft_doc::para::GroupChild { x: fit(x0 + ox * sx, -max), y: fit(y0 + oy * sy, -max), obj });
}
}
}
fn read_wsp(&mut self, sc: &mut StoryCtx, wsp: &El, rels: &Rels, w: f32, h: f32, float: Float) -> InlineObject {
let sppr = wsp.child("wps:spPr");
let prst = sppr.and_then(|s| s.child("a:prstGeom")).and_then(|g| g.attr("prst")).unwrap_or("rect");
@@ -741,11 +827,12 @@ impl Reader<'_> {
let ln = sppr.and_then(|s| s.child("a:ln"));
let stroke = solid(ln);
let stroke_width = ln.and_then(|l| l.attr("w")).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.0).clamp(0.0, 100.0);
let effects = sppr.and_then(|s| s.child("a:effectLst")).map(effect_list).unwrap_or_default();
let story = match txbx {
Some(t) if sc.story_depth < MAX_STORY_DEPTH => Some(self.read_textbox(sc, t, rels)),
_ => None,
};
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story }
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, effects }
}
fn read_textbox(&mut self, sc: &StoryCtx, content: &El, rels: &Rels) -> u32 {
@@ -775,7 +862,17 @@ impl Reader<'_> {
let story = Some(self.read_textbox(sc, t, rels));
let fill = shape.attr("fillcolor").and_then(Rgb::parse);
let stroke = shape.attr("strokecolor").and_then(Rgb::parse);
return Some(InlineObject::Shape { kind: ShapeKind::TextBox, w, h, fill, stroke, stroke_width: 0.75, float, story });
return Some(InlineObject::Shape {
kind: ShapeKind::TextBox,
w,
h,
fill,
stroke,
stroke_width: 0.75,
float,
story,
effects: Default::default(),
});
}
None
}
@@ -965,3 +1062,71 @@ fn vml_float(shape: &El, style: &str) -> Float {
fn graphic_work(items: &[GraphicItem]) -> usize {
items.iter().map(|it| if let GraphicItem::Path { segs, .. } = it { segs.len() + 1 } else { 1 }).sum()
}
/// A shape's `a:effectLst` (ECMA-376 Part 1 §20.1.8.26): its outer shadow, glow and soft edges.
/// Other effects are dropped.
fn effect_list(l: &El) -> ShapeEffects {
let pt = |e: &El, n: &str| e.attr(n).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.0);
let shadow = l.child("a:outerShdw").map(|e| {
let (color, transparency) = effect_color(e);
// `dir`: 60000ths of a degree, clockwise.
let angle = e.attr("dir").and_then(int).map(|v| (v.rem_euclid(21_600_000) as f32) / 60_000.0).unwrap_or(0.0);
Shadow { color: color.unwrap_or(Rgb::BLACK), transparency, blur: pt(e, "blurRad"), distance: pt(e, "dist"), angle }
});
let glow = l.child("a:glow").map(|e| {
let (color, transparency) = effect_color(e);
Glow { color: color.unwrap_or(Glow::default().color), size: pt(e, "rad"), transparency }
});
let soft_edge = l.child("a:softEdge").map(|e| pt(e, "rad"));
ShapeEffects { shadow, glow, soft_edge }.sanitized()
}
/// The colour inside an effect (`a:srgbClr`, `a:prstClr`, `a:sysClr`; theme colours aren't
/// resolved here) and its transparency, percent (from `a:alpha`, opaque when absent).
fn effect_color(e: &El) -> (Option<Rgb>, f32) {
let Some(c) = e.els().find(|c| matches!(c.name.as_str(), "a:srgbClr" | "a:prstClr" | "a:sysClr" | "a:schemeClr" | "a:scrgbClr" | "a:hslClr"))
else {
return (None, 0.0);
};
let rgb = match c.name.as_str() {
"a:srgbClr" => c.attr("val").and_then(Rgb::parse),
"a:sysClr" => c.attr("lastClr").and_then(Rgb::parse),
"a:prstClr" => match c.attr("val").unwrap_or("") {
"black" => Some(Rgb::BLACK),
"white" => Some(Rgb::WHITE),
"gray" | "grey" => Some(Rgb(0x80, 0x80, 0x80)),
_ => None,
},
_ => None,
};
// `a:alpha`: opacity in 1000ths of a percent.
let opacity = c.child("a:alpha").and_then(|a| a.attr("val")).and_then(int).map(|v| v.clamp(0, 100_000) as f32 / 1000.0).unwrap_or(100.0);
(rgb, 100.0 - opacity)
}
/// A DrawingML `a:xfrm` (ECMA-376 §20.1.7.5/6) in points: offset, extent and, for a group, its
/// members' offset and extent.
struct Xfrm {
off: (f32, f32),
ext: (f32, f32),
ch_off: Option<(f32, f32)>,
ch_ext: Option<(f32, f32)>,
}
/// The `a:xfrm` in shape properties `sppr` (zeros when missing).
fn group_xfrm(sppr: Option<&El>) -> Xfrm {
let x = sppr.and_then(|s| s.child("a:xfrm"));
let pair = |name: &str, a: &str, b: &str, lim: f32| {
let e = x.and_then(|x| x.child(name))?;
let v = |n: &str| e.attr(n).and_then(|v| measure(v, 12_700.0)).map(|v| v.clamp(-lim, lim)).unwrap_or(0.0);
Some((v(a), v(b)))
};
let max = crate::units::MAX_LEN_PT;
let pos = |p: Option<(f32, f32)>| p.map(|(a, b)| (a.max(0.0), b.max(0.0)));
Xfrm {
off: pair("a:off", "x", "y", max).unwrap_or((0.0, 0.0)),
ext: pos(pair("a:ext", "cx", "cy", max)).unwrap_or((0.0, 0.0)),
ch_off: pair("a:chOff", "x", "y", max),
ch_ext: pos(pair("a:chExt", "cx", "cy", max)),
}
}
+33 -2
View File
@@ -1,6 +1,8 @@
//! [`wordcraft_doc::math`] tree → Office Math (OMML).
use wordcraft_doc::math::{Arg, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, ScriptKind, parse_linear};
use wordcraft_doc::math::{
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, MathProps, ScriptKind, parse_linear,
};
use crate::xml::W;
@@ -86,6 +88,30 @@ 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)]);
}
@@ -382,7 +408,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 {
if sty.is_some() || scr.is_some() || r.nor || r.lit || r.brk.is_some() {
w.open("m:rPr", &[]);
if r.lit {
w.empty("m:lit", &[]);
@@ -396,6 +422,11 @@ 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", &[]);
+15 -3
View File
@@ -665,9 +665,18 @@ fn style_xml(w: &mut W, st: &Style) {
};
cond(w, "wholeTable", &wordcraft_doc::CharProps::default(), t.fill, None);
cond(w, "firstRow", &t.header_chr, t.header_fill, t.header_borders);
let total_top = t.total_border_top.map(|b| wordcraft_doc::props::Borders { top: Some(b), ..Default::default() });
cond(w, "lastRow", &t.total_chr, None, total_top);
cond(w, "firstCol", &t.first_col_chr, None, None);
// The total row's borders, its top edge falling back to the built-in top rule.
let total_borders = match (t.total_borders, t.total_border_top) {
(Some(mut b), top) => {
b.top = b.top.or(top);
Some(b)
}
(None, top) => top.map(|b| wordcraft_doc::props::Borders { top: Some(b), ..Default::default() }),
};
cond(w, "lastRow", &t.total_chr, t.total_fill, total_borders);
cond(w, "firstCol", &t.first_col_chr, t.first_col_fill, t.first_col_borders);
cond(w, "lastCol", &t.last_col_chr, t.last_col_fill, t.last_col_borders);
cond(w, "band1Vert", &t.col_band_chr, t.col_band_fill, t.col_band_borders);
cond(w, "band1Horz", &t.band_chr, t.band_fill, t.band_borders);
}
w.close("w:style");
@@ -793,6 +802,9 @@ 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()
}
+6
View File
@@ -230,6 +230,12 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool) {
w.close("w:tabs");
}
toggle(w, "w:suppressAutoHyphens", p.suppress_hyphens);
toggle(w, "w:kinsoku", p.kinsoku);
toggle(w, "w:wordWrap", p.word_wrap);
toggle(w, "w:overflowPunct", p.overflow_punct);
toggle(w, "w:topLinePunct", p.top_line_punct);
toggle(w, "w:autoSpaceDE", p.auto_space_de);
toggle(w, "w:autoSpaceDN", p.auto_space_dn);
toggle(w, "w:bidi", p.bidi);
if p.space_before.is_some() || p.space_after.is_some() || p.line_spacing.is_some() {
let before = p.space_before.map(|v| twips(v.max(0.0)));
+204 -89
View File
@@ -1,7 +1,8 @@
//! Blocks, paragraphs, runs, objects and tables → WordprocessingML.
use wordcraft_doc::effects::ShapeEffects;
use wordcraft_doc::para::{Anchor, Float, NoteKind, ShapeKind, Wrap};
use wordcraft_doc::props::CharProps;
use wordcraft_doc::props::{CharProps, Rgb};
use wordcraft_doc::section::{LineNumberRestart, SectionProps, SectionStart};
use wordcraft_doc::table::Table;
use wordcraft_doc::{Block, Blocks, InlineObject, Paragraph, RevisionKind};
@@ -371,10 +372,8 @@ impl Writer<'_> {
}
// Charts and diagrams aren't written back yet.
InlineObject::Graphic { .. } => {}
InlineObject::Image { media, w: iw, h: ih, alt, float, crop } => {
InlineObject::Image { media, w: iw, h: ih, alt, float, .. } => {
let Some(file) = self.media_files.get(media).cloned() else { return };
let rid = rels.add(rt::IMAGE, &format!("media/{file}"), false);
self.used_media.insert(media.clone());
self.rev_open(w, props);
w.open("w:r", &[]);
rpr(w, props);
@@ -385,30 +384,7 @@ impl Writer<'_> {
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
w.open("a:graphicData", &[("uri", "http://schemas.openxmlformats.org/drawingml/2006/picture")]);
w.open("pic:pic", &[]);
w.open("pic:nvPicPr", &[]);
w.empty("pic:cNvPr", &[("id", "0"), ("name", &file), ("descr", alt)]);
w.open("pic:cNvPicPr", &[]);
w.empty("a:picLocks", &[("noChangeAspect", "1"), ("noChangeArrowheads", "1")]);
w.close("pic:cNvPicPr");
w.close("pic:nvPicPr");
w.open("pic:blipFill", &[]);
w.empty("a:blip", &[("r:embed", &rid)]);
if crop.iter().any(|c| *c > 0.0) {
let c = |v: f32| n((wordcraft_geom::finite(v).clamp(0.0, 1.0) * 100_000.0).round() as i64);
w.empty("a:srcRect", &[("l", &c(crop[0])), ("t", &c(crop[1])), ("r", &c(crop[2])), ("b", &c(crop[3]))]);
}
w.open("a:stretch", &[]);
w.empty("a:fillRect", &[]);
w.close("a:stretch");
w.close("pic:blipFill");
w.open("pic:spPr", &[("bwMode", "auto")]);
xfrm(w, *iw, *ih);
w.open("a:prstGeom", &[("prst", "rect")]);
w.empty("a:avLst", &[]);
w.close("a:prstGeom");
w.close("pic:spPr");
w.close("pic:pic");
self.pic(w, o, &file, (0.0, 0.0), rels);
w.close("a:graphicData");
w.close("a:graphic");
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
@@ -416,77 +392,70 @@ impl Writer<'_> {
w.close("w:r");
self.rev_close(w, props);
}
InlineObject::Shape { kind, w: sw, h: sh, fill, stroke, stroke_width, float, story } => {
InlineObject::Shape { w: sw, h: sh, float, effects, .. } => {
self.rev_open(w, props);
w.open("w:r", &[]);
rpr(w, props);
w.open("w:drawing", &[]);
let docpr = self.next_docpr();
let name = format!("Shape {docpr}");
// The effect extent leaves room for the shadow and glow.
let mut float = *float;
for (e, fx) in float.effect.iter_mut().zip(effects.extent()) {
*e = wordcraft_geom::finite(*e).max(fx);
}
let float = &float;
self.drawing_open(w, float, *sw, *sh, &docpr, &name, "");
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape")]);
w.open("wps:wsp", &[]);
if *kind == ShapeKind::TextBox {
w.empty("wps:cNvSpPr", &[("txBox", "1")]);
} else {
w.empty("wps:cNvSpPr", &[]);
}
w.open("wps:spPr", &[]);
xfrm(w, *sw, *sh);
let prst = match kind {
ShapeKind::Rectangle | ShapeKind::TextBox => "rect",
ShapeKind::RoundedRectangle => "roundRect",
ShapeKind::Ellipse => "ellipse",
ShapeKind::Triangle => "triangle",
ShapeKind::Diamond => "diamond",
ShapeKind::Line => "line",
ShapeKind::Arrow => "rightArrow",
ShapeKind::Star => "star5",
ShapeKind::Heart => "heart",
};
w.open("a:prstGeom", &[("prst", prst)]);
w.empty("a:avLst", &[]);
w.close("a:prstGeom");
match fill {
Some(c) => {
w.open("a:solidFill", &[]);
w.empty("a:srgbClr", &[("val", &c.hex())]);
w.close("a:solidFill");
}
None => w.empty("a:noFill", &[]),
}
match stroke {
Some(c) => {
w.open("a:ln", &[("w", &emu(stroke_width.clamp(0.0, 100.0)))]);
w.open("a:solidFill", &[]);
w.empty("a:srgbClr", &[("val", &c.hex())]);
w.close("a:solidFill");
w.close("a:ln");
}
None => {
w.open("a:ln", &[]);
w.empty("a:noFill", &[]);
w.close("a:ln");
self.wsp(w, o, None, (0.0, 0.0), rels, depth);
w.close("a:graphicData");
w.close("a:graphic");
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
w.close("w:drawing");
w.close("w:r");
self.rev_close(w, props);
}
InlineObject::Group { w: gw, h: gh, float, ch_w, ch_h, children } => {
self.rev_open(w, props);
w.open("w:r", &[]);
rpr(w, props);
w.open("w:drawing", &[]);
let docpr = self.next_docpr();
let name = format!("Group {docpr}");
self.drawing_open(w, float, *gw, *gh, &docpr, &name, "");
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup")]);
// ECMA-376 §20.1.7.6 (`a:xfrm` of a group): the members' space `a:chOff`/`a:chExt`
// maps onto the group's `a:off`/`a:ext`.
w.open("wpg:wgp", &[]);
w.empty("wpg:cNvGrpSpPr", &[]);
w.open("wpg:grpSpPr", &[]);
w.open("a:xfrm", &[]);
w.empty("a:off", &[("x", "0"), ("y", "0")]);
w.empty("a:ext", &[("cx", &emu(gw.max(0.0))), ("cy", &emu(gh.max(0.0)))]);
w.empty("a:chOff", &[("x", "0"), ("y", "0")]);
w.empty("a:chExt", &[("cx", &emu(ch_w.max(0.0))), ("cy", &emu(ch_h.max(0.0)))]);
w.close("a:xfrm");
w.close("wpg:grpSpPr");
for c in children.iter().take(wordcraft_doc::para::MAX_GROUP_CHILDREN) {
let at = (c.x, c.y);
match &c.obj {
InlineObject::Image { media, .. } => {
if let Some(file) = self.media_files.get(media).cloned() {
self.pic(w, &c.obj, &file, at, rels);
}
}
InlineObject::Shape { .. } => {
let id = self.next_docpr();
self.wsp(w, &c.obj, Some(&id), at, rels, depth);
}
_ => {}
}
}
w.close("wps:spPr");
// Its text, within the same bounds layout shows boxes inside boxes with.
if let Some(id) = *story
&& let Some(part) = self.doc.parts.get(&id).filter(|p| p.kind == wordcraft_doc::PartKind::TextBox)
&& self.boxes.enter(id)
{
w.open("wps:txbx", &[]);
w.open("w:txbxContent", &[]);
let blocks = part.blocks.clone();
self.blocks(w, &blocks, rels, false, depth + 1);
w.close("w:txbxContent");
w.close("wps:txbx");
self.boxes.leave();
}
w.empty("wps:bodyPr", &[]);
w.close("wps:wsp");
w.close("wpg:wgp");
w.close("a:graphicData");
w.close("a:graphic");
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
@@ -576,6 +545,108 @@ impl Writer<'_> {
run(w, &|w| w.empty("w:fldChar", &[("w:fldCharType", "separate")]));
}
/// A picture's `pic:pic` (the image in `media/{file}`), at `off` in its group (or 0, 0).
fn pic(&mut self, w: &mut W, o: &InlineObject, file: &str, off: (f32, f32), rels: &mut PartRels) {
let InlineObject::Image { media, w: iw, h: ih, alt, crop, .. } = o else { return };
let rid = rels.add(rt::IMAGE, &format!("media/{file}"), false);
self.used_media.insert(media.clone());
w.open("pic:pic", &[]);
w.open("pic:nvPicPr", &[]);
w.empty("pic:cNvPr", &[("id", "0"), ("name", file), ("descr", alt)]);
w.open("pic:cNvPicPr", &[]);
w.empty("a:picLocks", &[("noChangeAspect", "1"), ("noChangeArrowheads", "1")]);
w.close("pic:cNvPicPr");
w.close("pic:nvPicPr");
w.open("pic:blipFill", &[]);
w.empty("a:blip", &[("r:embed", &rid)]);
if crop.iter().any(|c| *c > 0.0) {
let c = |v: f32| n((wordcraft_geom::finite(v).clamp(0.0, 1.0) * 100_000.0).round() as i64);
w.empty("a:srcRect", &[("l", &c(crop[0])), ("t", &c(crop[1])), ("r", &c(crop[2])), ("b", &c(crop[3]))]);
}
w.open("a:stretch", &[]);
w.empty("a:fillRect", &[]);
w.close("a:stretch");
w.close("pic:blipFill");
w.open("pic:spPr", &[("bwMode", "auto")]);
xfrm(w, off, *iw, *ih);
w.open("a:prstGeom", &[("prst", "rect")]);
w.empty("a:avLst", &[]);
w.close("a:prstGeom");
w.close("pic:spPr");
w.close("pic:pic");
}
/// A shape's or text box's `wps:wsp`, at `off` in its group (or 0, 0). In a group it carries
/// its own `wps:cNvPr` with drawing id `id`.
fn wsp(&mut self, w: &mut W, o: &InlineObject, id: Option<&str>, off: (f32, f32), rels: &mut PartRels, depth: usize) {
let InlineObject::Shape { kind, w: sw, h: sh, fill, stroke, stroke_width, story, effects, .. } = o else { return };
w.open("wps:wsp", &[]);
if let Some(id) = id {
let name = format!("Shape {id}");
w.empty("wps:cNvPr", &[("id", id), ("name", &name)]);
}
if *kind == ShapeKind::TextBox {
w.empty("wps:cNvSpPr", &[("txBox", "1")]);
} else {
w.empty("wps:cNvSpPr", &[]);
}
w.open("wps:spPr", &[]);
xfrm(w, off, *sw, *sh);
let prst = match kind {
ShapeKind::Rectangle | ShapeKind::TextBox => "rect",
ShapeKind::RoundedRectangle => "roundRect",
ShapeKind::Ellipse => "ellipse",
ShapeKind::Triangle => "triangle",
ShapeKind::Diamond => "diamond",
ShapeKind::Line => "line",
ShapeKind::Arrow => "rightArrow",
ShapeKind::Star => "star5",
ShapeKind::Heart => "heart",
};
w.open("a:prstGeom", &[("prst", prst)]);
w.empty("a:avLst", &[]);
w.close("a:prstGeom");
match fill {
Some(c) => {
w.open("a:solidFill", &[]);
w.empty("a:srgbClr", &[("val", &c.hex())]);
w.close("a:solidFill");
}
None => w.empty("a:noFill", &[]),
}
match stroke {
Some(c) => {
w.open("a:ln", &[("w", &emu(stroke_width.clamp(0.0, 100.0)))]);
w.open("a:solidFill", &[]);
w.empty("a:srgbClr", &[("val", &c.hex())]);
w.close("a:solidFill");
w.close("a:ln");
}
None => {
w.open("a:ln", &[]);
w.empty("a:noFill", &[]);
w.close("a:ln");
}
}
effect_list(w, effects);
w.close("wps:spPr");
// Its text, within the same bounds layout shows boxes inside boxes with.
if let Some(id) = *story
&& let Some(part) = self.doc.parts.get(&id).filter(|p| p.kind == wordcraft_doc::PartKind::TextBox)
&& self.boxes.enter(id)
{
w.open("wps:txbx", &[]);
w.open("w:txbxContent", &[]);
let blocks = part.blocks.clone();
self.blocks(w, &blocks, rels, false, depth + 1);
w.close("w:txbxContent");
w.close("wps:txbx");
self.boxes.leave();
}
w.empty("wps:bodyPr", &[]);
w.close("wps:wsp");
}
#[allow(clippy::too_many_arguments)]
fn drawing_open(&mut self, w: &mut W, float: &Float, iw: f32, ih: f32, docpr: &str, name: &str, alt: &str) {
let (cx, cy) = (emu(iw.max(0.0)), emu(ih.max(0.0)));
@@ -776,9 +847,53 @@ impl Writer<'_> {
}
}
fn xfrm(w: &mut W, cw: f32, ch: f32) {
/// A shape's `a:effectLst` (ECMA-376 Part 1 §20.1.8.26), children in schema order.
fn effect_list(w: &mut W, effects: &ShapeEffects) {
let fx = effects.sanitized();
if fx.is_empty() {
return;
}
let color = |w: &mut W, c: Rgb, transparency: f32| {
let alpha = n(((100.0 - transparency.clamp(0.0, 100.0)) * 1000.0).round() as i64);
w.open("a:srgbClr", &[("val", &c.hex())]);
w.empty("a:alpha", &[("val", &alpha)]);
w.close("a:srgbClr");
};
w.open("a:effectLst", &[]);
if let Some(g) = fx.glow {
w.open("a:glow", &[("rad", &emu(g.size))]);
color(w, g.color, g.transparency);
w.close("a:glow");
}
if let Some(s) = fx.shadow {
let dir = n((s.angle * 60_000.0).round() as i64);
// Anchor scaling at the corner the shadow falls away from (no scaling is written, so
// this only matters to editors that resize it).
let (dx, dy) = s.offset();
let algn = match (dy > 0.01, dy < -0.01, dx > 0.01, dx < -0.01) {
(true, _, true, _) => "tl",
(true, _, _, true) => "tr",
(true, _, _, _) => "t",
(_, true, true, _) => "bl",
(_, true, _, true) => "br",
(_, true, _, _) => "b",
(_, _, true, _) => "l",
(_, _, _, true) => "r",
_ => "ctr",
};
w.open("a:outerShdw", &[("blurRad", &emu(s.blur)), ("dist", &emu(s.distance)), ("dir", &dir), ("algn", algn), ("rotWithShape", "0")]);
color(w, s.color, s.transparency);
w.close("a:outerShdw");
}
if let Some(r) = fx.soft_edge {
w.empty("a:softEdge", &[("rad", &emu(r))]);
}
w.close("a:effectLst");
}
fn xfrm(w: &mut W, (x, y): (f32, f32), cw: f32, ch: f32) {
w.open("a:xfrm", &[]);
w.empty("a:off", &[("x", "0"), ("y", "0")]);
w.empty("a:off", &[("x", &emu(x)), ("y", &emu(y))]);
w.empty("a:ext", &[("cx", &emu(cw.max(0.0))), ("cy", &emu(ch.max(0.0)))]);
w.close("a:xfrm");
}
+26 -1
View File
@@ -6,7 +6,7 @@ use wordcraft_doc::para::{Anchor, FloatAlign, NoteKind, ShapeKind, Wrap};
use wordcraft_doc::props::{Align, Border, BorderStyle, Rgb, TextColor, VMerge};
use wordcraft_doc::{Block, Document, InlineObject, Paragraph};
const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office""#;
const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office""#;
const ROOT_RELS: &str = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
@@ -699,3 +699,28 @@ fn hostile_vml_style_values_stay_finite() {
assert!([*w, *h, float.x, float.y, float.dist, float.dist_top].iter().all(|v| v.is_finite()), "{w} {h} {float:?}");
assert_eq!((float.h_align, float.dist, float.dist_top), (None, 0.0, 1584.0));
}
/// A group as Word saves one (in `mc:AlternateContent`, needing `wpg`): its members' space
/// starts at `a:chOff`, and a nested group is flattened into it through its own transform.
#[test]
fn word_group_with_nested_group() {
let body = r#"<w:p><w:r><mc:AlternateContent><mc:Choice Requires="wpg"><w:drawing><wp:anchor behindDoc="0" distT="0" distB="0" distL="114300" distR="114300" simplePos="0" relativeHeight="1" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="column"><wp:posOffset>0</wp:posOffset></wp:positionH><wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV><wp:extent cx="2540000" cy="1270000"/><wp:effectExtent l="0" t="0" r="0" b="0"/><wp:wrapSquare wrapText="bothSides"/><wp:docPr id="1" name="Group 1"/><wp:cNvGraphicFramePr/>
<a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"><wpg:wgp><wpg:cNvGrpSpPr/><wpg:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="2540000" cy="1270000"/><a:chOff x="127000" y="127000"/><a:chExt cx="2540000" cy="1270000"/></a:xfrm></wpg:grpSpPr>
<wps:wsp><wps:cNvPr id="2" name="Rectangle 2"/><wps:cNvSpPr/><wps:spPr><a:xfrm><a:off x="127000" y="127000"/><a:ext cx="1270000" cy="635000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></wps:spPr><wps:bodyPr/></wps:wsp>
<wpg:grpSp><wpg:cNvGrpSpPr/><wpg:grpSpPr><a:xfrm><a:off x="1397000" y="762000"/><a:ext cx="1270000" cy="635000"/><a:chOff x="0" y="0"/><a:chExt cx="2540000" cy="1270000"/></a:xfrm></wpg:grpSpPr>
<wps:wsp><wps:cNvPr id="3" name="Oval 3"/><wps:cNvSpPr/><wps:spPr><a:xfrm><a:off x="254000" y="254000"/><a:ext cx="508000" cy="254000"/></a:xfrm><a:prstGeom prst="ellipse"><a:avLst/></a:prstGeom></wps:spPr><wps:bodyPr/></wps:wsp>
</wpg:grpSp></wpg:wgp></a:graphicData></a:graphic></wp:anchor></w:drawing></mc:Choice><mc:Fallback><w:pict/></mc:Fallback></mc:AlternateContent></w:r></w:p>"#;
let d = read_body(body);
let p = paras(&d);
let InlineObject::Group { w, h, float, ch_w, ch_h, children } = &p[0].objects[0] else { panic!("{:?}", p[0].objects) };
assert_eq!((*w, *h, *ch_w, *ch_h), (200.0, 100.0, 200.0, 100.0));
assert_eq!(float.wrap, Wrap::Square);
let got: Vec<(f32, f32, ShapeKind, f32, f32)> = children
.iter()
.map(|c| match &c.obj {
InlineObject::Shape { kind, w, h, .. } => (c.x, c.y, *kind, *w, *h),
o => panic!("{o:?}"),
})
.collect();
assert_eq!(got, [(0.0, 0.0, ShapeKind::Rectangle, 100.0, 50.0), (110.0, 60.0, ShapeKind::Ellipse, 20.0, 10.0)]);
}
+162 -1
View File
@@ -136,11 +136,18 @@ fn every_para_prop_round_trips() {
suppress_line_numbers: Some(true),
bidi: Some(false),
drop_cap: None,
kinsoku: Some(false),
word_wrap: Some(false),
overflow_punct: Some(false),
top_line_punct: Some(true),
auto_space_de: Some(false),
auto_space_dn: Some(true),
};
let variants = [
ParaProps { line_spacing: Some(LineSpacing::AtLeast(14.0)), indent_first: Some(24.0), align: Some(Align::Center), ..Default::default() },
ParaProps { line_spacing: Some(LineSpacing::Exactly(20.0)), align: Some(Align::Right), ..Default::default() },
ParaProps { align: Some(Align::Distribute), ..Default::default() },
ParaProps { kinsoku: Some(true), word_wrap: Some(true), auto_space_dn: Some(false), ..Default::default() },
ParaProps::default(),
];
let mut ps = vec![Paragraph::with_text("main", CharProps::default())];
@@ -678,6 +685,7 @@ fn toc_field_skips_nested_stories() {
stroke_width: 0.0,
float: Float::default(),
story: Some(story),
effects: Default::default(),
}
};
let mut head = Paragraph::with_text("Contents", CharProps::default()).styled("TOCHeading");
@@ -799,6 +807,7 @@ fn shapes_textboxes_equations_dropcaps_round_trip() {
stroke_width: 1.0,
float: Float { wrap: Wrap::Square, h_rel: Anchor::Margin, v_rel: Anchor::Paragraph, x: 10.0, y: 20.0, dist: 0.0, ..Default::default() },
story: Some(story),
effects: Default::default(),
};
let star = InlineObject::Shape {
kind: ShapeKind::Star,
@@ -809,6 +818,7 @@ fn shapes_textboxes_equations_dropcaps_round_trip() {
stroke_width: 0.0,
float: Float::default(),
story: None,
effects: Default::default(),
};
let eq = InlineObject::Equation { linear: "x=(-b±√(b^2-4ac))/2a".into(), display: false, math: Default::default() };
let mut p = Paragraph::with_text("shapes ", CharProps::default());
@@ -824,7 +834,7 @@ fn shapes_textboxes_equations_dropcaps_round_trip() {
let got = paras(&r);
assert_eq!(got[0].objects.len(), 3);
match &got[0].objects[0] {
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story } => {
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, .. } => {
assert_eq!(
(*kind, *w, *h, *fill, *stroke, *stroke_width),
(ShapeKind::TextBox, 144.0, 72.0, Some(Rgb(255, 255, 200)), Some(Rgb(0, 0, 0)), 1.0)
@@ -848,6 +858,82 @@ fn shapes_textboxes_equations_dropcaps_round_trip() {
assert_eq!(got[1].props, dc.props);
}
/// A group of a picture, a shape and a text box (`wpg:wgp`) comes back as it was saved: its
/// size, placement and members' space, and each member's offset, size and content.
#[test]
fn groups_round_trip() {
use wordcraft_doc::para::GroupChild;
let mut d = Document::new();
let key = d.add_media(tiny_png(), "png");
let story = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("In the group", CharProps::default()))]);
let pic = InlineObject::Image { media: key, w: 60.0, h: 40.0, alt: "A picture".into(), float: Float::default(), crop: [0.0; 4] };
let oval = InlineObject::Shape {
kind: ShapeKind::Ellipse,
w: 50.0,
h: 30.0,
fill: Some(Rgb(200, 0, 0)),
stroke: None,
stroke_width: 0.0,
float: Float::default(),
story: None,
// A member's shape effects (#275) come back too.
effects: wordcraft_doc::effects::ShapeEffects {
shadow: Some(wordcraft_doc::effects::Shadow { color: Rgb(0x20, 0x30, 0x40), transparency: 60.0, blur: 4.0, distance: 3.0, angle: 135.0 }),
glow: None,
soft_edge: Some(2.5),
},
};
let tb = InlineObject::Shape {
kind: ShapeKind::TextBox,
w: 100.0,
h: 40.0,
fill: Some(Rgb(255, 255, 255)),
stroke: Some(Rgb(0, 0, 0)),
stroke_width: 0.75,
float: Float::default(),
story: Some(story),
effects: Default::default(),
};
let float = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 20.0, y: 10.0, dist: 9.0, ..Default::default() };
let group = InlineObject::Group {
w: 240.0,
h: 120.0,
float,
ch_w: 200.0,
ch_h: 100.0,
children: vec![
GroupChild { x: 0.0, y: 0.0, obj: pic },
GroupChild { x: 150.0, y: 10.0, obj: oval.clone() },
GroupChild { x: 50.0, y: 60.0, obj: tb },
],
};
let mut p = Paragraph::with_text("grouped ", CharProps::default());
let end = p.len();
p.insert_object(end, group, &CharProps::default()).unwrap();
d.body = vec![para_block(p)];
let r = rt(&d);
let got = paras(&r);
let InlineObject::Group { w, h, float: f, ch_w, ch_h, children } = &got[0].objects[0] else { panic!("{:?}", got[0].objects) };
assert_eq!((*w, *h, *ch_w, *ch_h), (240.0, 120.0, 200.0, 100.0));
assert_eq!(*f, float);
assert_eq!(children.iter().map(|c| (c.x, c.y)).collect::<Vec<_>>(), [(0.0, 0.0), (150.0, 10.0), (50.0, 60.0)]);
match &children[0].obj {
InlineObject::Image { media, w, h, alt, .. } => {
assert_eq!((*w, *h, alt.as_str()), (60.0, 40.0, "A picture"));
assert_eq!(r.media.get(media).map(|m| m.as_slice()), Some(tiny_png().as_slice()));
}
o => panic!("{o:?}"),
}
assert_eq!(children[1].obj, oval);
match &children[2].obj {
InlineObject::Shape { kind, w, h, story, .. } => {
assert_eq!((*kind, *w, *h), (ShapeKind::TextBox, 100.0, 40.0));
assert_eq!(part_text(&r, *story), "In the group");
}
o => panic!("{o:?}"),
}
}
#[test]
fn compatibility_mode_round_trips() {
// New documents are Word 2013+ documents; an older file keeps its mode, so saving it doesn't
@@ -876,6 +962,12 @@ 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();
@@ -1027,6 +1119,7 @@ fn self_showing_text_box_saves_bounded() {
stroke_width: 0.0,
float: Float::default(),
story: Some(id),
effects: Default::default(),
};
for _ in 0..30 {
d.insert_object(
@@ -1107,3 +1200,71 @@ fn list_level_overrides_round_trip() {
assert_eq!(c.next_label(&back.numbering, restart, 0).unwrap().0, "1.");
assert_eq!(c.next_label(&back.numbering, restart, 1).unwrap().0, "1.01");
}
/// Shape effects (#275): `a:effectLst` with an outer shadow, a glow and soft edges round-trips,
/// and the effect extent leaves room for them.
#[test]
fn shape_effects_round_trip() {
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
let effects = ShapeEffects {
shadow: Some(Shadow { color: Rgb(0x20, 0x30, 0x40), transparency: 60.0, blur: 4.0, distance: 3.0, angle: 135.0 }),
glow: Some(Glow { color: Rgb(0xC0, 0x50, 0x10), size: 8.0, transparency: 40.0 }),
soft_edge: Some(2.5),
};
let shape = InlineObject::Shape {
kind: ShapeKind::RoundedRectangle,
w: 100.0,
h: 50.0,
fill: Some(Rgb(0x15, 0x60, 0x82)),
stroke: None,
stroke_width: 0.0,
float: Float::default(),
story: None,
effects,
};
let mut p = Paragraph::with_text("x", CharProps::default());
p.insert_object(1, shape, &CharProps::default()).unwrap();
let bytes = wordcraft_docx::write(&doc_with(vec![p])).expect("write");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
assert!(
xml.contains(
r#"<a:outerShdw blurRad="50800" dist="38100" dir="8100000" algn="tr" rotWithShape="0"><a:srgbClr val="203040"><a:alpha val="40000"/>"#
),
"{xml}"
);
assert!(xml.contains(r#"<a:glow rad="101600"><a:srgbClr val="C05010"><a:alpha val="60000"/>"#), "{xml}");
assert!(xml.contains(r#"<a:softEdge rad="31750"/>"#), "{xml}");
let r = wordcraft_docx::read(&bytes).expect("read");
match paras(&r)[0].objects.first() {
Some(InlineObject::Shape { effects: got, float, .. }) => {
assert_eq!(*got, effects);
assert_eq!(float.effect_extent(), effects.extent());
}
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,6 +26,7 @@ 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).
+2 -1
View File
@@ -329,6 +329,7 @@ Table Design|Table Style Options|Table Style Options|table.look
Table Design|Table Styles|Table Styles|table.style
Table Design|Table Styles|New Table Style|table.newStyle
Table Design|Table Styles|Modify Table Style|table.modifyStyle
Table Design|Table Styles|Delete Table Style|table.deleteStyle
Table Design|Table Styles|Shading|table.shading
Table Design|Borders|Borders|table.borders
Table Design|Borders|Border Painter|table.borderPainter
@@ -502,6 +503,6 @@ mod tests {
let p = parity(&reg);
let pct = p["percent"].as_f64().unwrap();
// The floor only ever rises.
assert!(pct >= 89.0, "parity {pct}%");
assert!(pct >= 90.4, "parity {pct}%");
}
}
+160
View File
@@ -32,6 +32,31 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"text"?: string}"#)
.key("Mod+Shift+Alt+V"),
CommandSpec::new("edit.pasteMerge", "Paste: Merge Formatting", "Home › Clipboard › Paste", paste_text).params(r#"{"text"?: string}"#),
CommandSpec::new("edit.clipboardPane", "Clipboard Pane", "Home › Clipboard", |s, v| {
let on = p::bool(v, "value").unwrap_or(!s.view.clipboard_pane);
s.view.clipboard_pane = on;
Ok(json!({"value": on, "count": s.clip_history.len()}))
})
.params(r#"{"value"?: bool}"#)
.pure(),
CommandSpec::new("edit.clipboardItems", "Clipboard Items", "Home › Clipboard › Clipboard Pane", |s, _| Ok(s.clip_history.describe())).pure(),
CommandSpec::new("edit.pasteClipboardItem", "Paste Clipboard Item", "Home › Clipboard › Clipboard Pane", paste_clip_item)
.params(r#"{"index": n (0 = newest)}"#)
.when(has_clips),
CommandSpec::new("edit.pasteAllClipboard", "Paste All", "Home › Clipboard › Clipboard Pane", paste_all_clips).when(has_clips),
CommandSpec::new("edit.deleteClipboardItem", "Delete Clipboard Item", "Home › Clipboard › Clipboard Pane", |s, v| {
let i = clip_index(s, v)?;
s.clip_history.items.remove(i);
Ok(s.clip_history.describe())
})
.params(r#"{"index": n (0 = newest)}"#)
.when(has_clips)
.pure(),
CommandSpec::new("edit.clearClipboard", "Clear All", "Home › Clipboard › Clipboard Pane", |s, _| {
s.clip_history.items.clear();
Ok(s.clip_history.describe())
})
.pure(),
CommandSpec::new("edit.find", "Find", "Home › Editing", find)
.key("Mod+F")
.params(r#"{"text": string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool}"#)
@@ -66,6 +91,7 @@ fn copy(s: &mut Session, _: &Value) -> CmdResult {
}
let f = s.doc.copy_range(&a, &b);
s.clipboard_text = f.plain_text();
s.clip_history.push(&f, &s.clipboard_text);
s.clipboard = Some(f);
Ok(json!({"text": s.clipboard_text}))
}
@@ -88,6 +114,140 @@ fn paste(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s)
}
/// Most items the Clipboard pane keeps.
pub const CLIP_MAX_ITEMS: usize = 24;
/// A copy bigger than this (estimated) is not collected (it still reaches the clipboard).
const CLIP_ITEM_BYTES: usize = 4 << 20;
/// All collected items together stay under this; the oldest go first.
const CLIP_TOTAL_BYTES: usize = 24 << 20;
/// Characters of preview per item.
const CLIP_PREVIEW_CHARS: usize = 120;
/// One item collected by Copy or Cut.
#[derive(Clone, Debug)]
pub struct ClipItem {
pub fragment: Fragment,
/// Its plain text.
pub text: String,
/// Rough memory use.
bytes: usize,
}
impl ClipItem {
/// One line of the item's text for a list: whitespace runs become one space, objects drop out.
pub fn preview(&self) -> String {
let words = self.text.split(|c: char| c.is_whitespace() || c == wordcraft_doc::para::OBJ).filter(|w| !w.is_empty());
let mut out = String::new();
let mut n = 0;
for w in words {
if !out.is_empty() {
out.push(' ');
n += 1;
}
for c in w.chars() {
if n >= CLIP_PREVIEW_CHARS {
out.push('…');
return out;
}
out.push(c);
n += 1;
}
}
out
}
}
/// Items collected by Copy and Cut during the session, newest first: the Clipboard pane
/// (Home › Clipboard). At most [`CLIP_MAX_ITEMS`]; oversized copies are skipped.
#[derive(Clone, Debug, Default)]
pub struct ClipHistory {
items: Vec<ClipItem>,
}
impl ClipHistory {
pub fn items(&self) -> &[ClipItem] {
&self.items
}
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
fn push(&mut self, f: &Fragment, text: &str) {
// Text plus per-paragraph overhead; pictures are references into the document.
let paras = text.matches('\n').count().saturating_add(f.blocks.len()).saturating_add(f.parts.len()).saturating_add(1);
let bytes = text.len().saturating_mul(2).saturating_add(paras.saturating_mul(256));
if bytes > CLIP_ITEM_BYTES || f.blocks.is_empty() {
return;
}
// Copying the same thing again doesn't add a second item.
if self.items.first().is_some_and(|i| i.text == text && i.fragment == *f) {
return;
}
self.items.insert(0, ClipItem { fragment: f.clone(), text: text.to_string(), bytes });
self.items.truncate(CLIP_MAX_ITEMS);
while self.items.len() > 1 && self.items.iter().map(|i| i.bytes).sum::<usize>() > CLIP_TOTAL_BYTES {
self.items.pop();
}
}
/// The items for agents and the pane, newest first.
fn describe(&self) -> Value {
Value::Array(
self.items
.iter()
.enumerate()
.map(|(i, it)| {
json!({
"index": i,
"preview": it.preview(),
"chars": it.text.chars().count(),
"objects": it.text.matches(wordcraft_doc::para::OBJ).count(),
})
})
.collect(),
)
}
}
fn has_clips(s: &Session) -> Option<&'static str> {
if s.clip_history.is_empty() { Some("the Clipboard pane is empty") } else { None }
}
fn clip_index(s: &Session, v: &Value) -> Result<usize, CmdError> {
let i = p::u64(v, "index").ok_or_else(|| CmdError::Params("`index` (number, 0 = newest) is required".into()))?;
usize::try_from(i)
.ok()
.filter(|i| *i < s.clip_history.len())
.ok_or_else(|| CmdError::Params(format!("no clipboard item {i} (there are {})", s.clip_history.len())))
}
/// Paste a collected item through the normal paste path. The clipboard itself stays as it was.
fn paste_clip(s: &mut Session, f: Fragment) -> CmdResult {
let saved = s.clipboard.replace(f);
let r = paste(s, &json!({}));
s.clipboard = saved;
r
}
fn paste_clip_item(s: &mut Session, v: &Value) -> CmdResult {
let i = clip_index(s, v)?;
let f = s.clip_history.items.get(i).map(|x| x.fragment.clone()).ok_or_else(|| CmdError::Params("no such clipboard item".into()))?;
paste_clip(s, f)
}
/// Every collected item, in the order they were copied (one undo step).
fn paste_all_clips(s: &mut Session, _: &Value) -> CmdResult {
let all: Vec<Fragment> = s.clip_history.items.iter().rev().map(|x| x.fragment.clone()).collect();
let mut r = sel_result(s)?;
for f in all {
r = paste_clip(s, f)?;
}
Ok(r)
}
fn paste_text(s: &mut Session, v: &Value) -> CmdResult {
let text = match p::str(v, "text") {
Some(t) => t.to_string(),
+57 -9
View File
@@ -29,9 +29,20 @@ 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}"#).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.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.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") };
@@ -112,15 +123,39 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
s.ui_requests.push(json!({"open": "openFile"}));
return sel_result(s);
};
let doc = if let Some(data) = p::str(v, "data") {
let password = p::str(v, "password");
let (doc, encrypted) = 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(path, &bytes).map_err(CmdError::Failed)?
crate::io::open_bytes_with(path, &bytes, password).map_err(CmdError::Failed)?
} else {
crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?
crate::io::open_path_with(std::path::Path::new(path), password).map_err(CmdError::Failed)?
};
s.set_document(doc);
s.path = Some(path.into());
Ok(json!({"path": path, "paragraphs": s.doc.paragraph_count(), "words": s.doc.word_count()}))
// 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()}))
}
fn save(s: &mut Session, v: &Value) -> CmdResult {
@@ -131,14 +166,26 @@ 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();
crate::io::save_path(&path, &s.doc).map_err(CmdError::Failed)?;
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());
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()}))
Ok(json!({"saved": true, "path": path.to_string_lossy(), "encrypted": encrypted}))
}
fn save_as(s: &mut Session, v: &Value) -> CmdResult {
@@ -216,6 +263,7 @@ 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,
}))
}
+12 -2
View File
@@ -252,8 +252,17 @@ fn shape(s: &mut Session, v: &Value) -> CmdResult {
let stroke = p::str(v, "stroke").and_then(Rgb::parse).or(Some(Rgb(0x0E, 0x40, 0x5A)));
let props = s.typing_props();
let at = delete_selection(s)?;
let obj =
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width: 1.0, float: Float { wrap: Wrap::Inline, ..Default::default() }, story: None };
let obj = InlineObject::Shape {
kind,
w,
h,
fill,
stroke,
stroke_width: 1.0,
float: Float { wrap: Wrap::Inline, ..Default::default() },
story: None,
effects: Default::default(),
};
let end = s.doc.insert_object(&at, obj, &props)?;
s.sel = Selection { anchor: at, focus: end };
sel_result(s)
@@ -275,6 +284,7 @@ fn text_box(s: &mut Session, v: &Value) -> CmdResult {
stroke_width: 0.75,
float: Float::default(),
story: Some(id),
effects: Default::default(),
};
s.doc.insert_object(&at, obj, &props)?;
// Like Word, type straight into the new box.
+373 -23
View File
@@ -23,7 +23,7 @@ fn has_object(s: &Session) -> Option<&'static str> {
fn has_floating(s: &Session) -> Option<&'static str> {
match selected(s) {
Some((_, InlineObject::Graphic { .. })) => Some(FROZEN),
Some((_, InlineObject::Image { float, .. } | InlineObject::Shape { float, .. })) if float.wrap != Wrap::Inline => None,
Some((_, o)) if o.is_floating() => None,
_ => Some("select a floating picture or shape first"),
}
}
@@ -37,6 +37,15 @@ fn has_movable(s: &Session) -> Option<&'static str> {
_ => has_object(s),
}
}
fn has_group(s: &Session) -> Option<&'static str> {
match selected(s) {
Some((_, InlineObject::Group { .. })) => None,
_ => Some("select a group first"),
}
}
fn can_group(s: &Session) -> Option<&'static str> {
if picked_objects(s).len() >= 2 { None } else { Some("select two or more pictures or shapes first (Shift+click)") }
}
fn has_shape(s: &Session) -> Option<&'static str> {
match selected(s) {
Some((_, InlineObject::Shape { .. })) => None,
@@ -271,6 +280,13 @@ pub fn specs() -> Vec<CommandSpec> {
})
.params(r#"{"dx"?: pt, "dy"?: pt}"#)
.when(has_floating),
CommandSpec::new("arrange.group", "Group", "Layout › Arrange", group)
.params(r#"{} (groups the selected pictures, shapes and text boxes: the selected one plus those added with select.addObject)"#)
.when(can_group),
CommandSpec::new("arrange.ungroup", "Ungroup", "Layout › Arrange", ungroup).when(has_group),
CommandSpec::new("select.addObject", "Add Object to Selection", "Home › Editing › Select", add_object)
.params(r#"{"pos"?: Pos, "index"?: n, "objects"?: [Pos | n]} (n: its Selection Pane index; with no object selected yet, the first one becomes the selection)"#)
.pure(),
CommandSpec::new("select.objects", "Select Objects", "Home › Editing › Select", |s, v| {
let n = p::u64(v, "index").unwrap_or(0) as usize;
let list = all_objects(s);
@@ -305,6 +321,11 @@ pub fn specs() -> Vec<CommandSpec> {
})
.params(r#"{"color": "RRGGBB" | null, "width"?: pt}"#)
.when(has_shape),
CommandSpec::new("shape.effects", "Shape Effects", "Shape Format › Shape Styles", shape_effects)
.params(
r#"{"shadow"?: preset|{"preset"?, "color"?: "RRGGBB", "transparency"?: %, "blur"?: pt, "distance"?: pt, "angle"?: deg}|null, "glow"?: pt|{"color"?: "RRGGBB", "size"?: pt, "transparency"?: %}|null, "softEdge"?: pt|null} (shadow presets: offsetBottomRight, offsetBottom, offsetBottomLeft, offsetRight, offsetCenter, offsetLeft, offsetTopRight, offsetTop, offsetTopLeft; an omitted key is left as it is; applies to every shape in the selection)"#,
)
.when(has_shape),
CommandSpec::new("shape.change", "Change Shape", "Shape Format › Insert Shapes", |s, v| {
let kind: wordcraft_doc::para::ShapeKind = serde_json::from_value(v.get("kind").cloned().unwrap_or(json!("rectangle"))).map_err(|e| CmdError::Params(e.to_string()))?;
with_obj(s, |o| {
@@ -326,12 +347,20 @@ pub fn object_selection(s: &Session) -> Option<(Pos, &InlineObject)> {
return None;
}
let o = s.doc.para_at(&a)?.object_at(a.off)?;
matches!(o, InlineObject::Image { .. } | InlineObject::Graphic { .. } | InlineObject::Shape { .. }).then_some((a, o))
o.is_drawing().then_some((a, o))
}
/// The first picture/shape in the selection, or just before a collapsed caret.
pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> {
selected_all(s, 1).into_iter().next()
}
/// The pictures and shapes in the selection (at most `max`), or the one just before a collapsed
/// caret.
pub fn selected_all(s: &Session, max: usize) -> Vec<(Pos, InlineObject)> {
let mut out = Vec::new();
let (a, b) = s.sel.ordered();
let max = if a == b { 1 } else { max };
let story = a.story;
let paths = if a == b { vec![a.path.clone()] } else { s.doc.paths_between(&a, &b) };
for path in paths {
@@ -345,12 +374,104 @@ pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> {
if !inside {
continue;
}
if let Some(o @ (InlineObject::Image { .. } | InlineObject::Graphic { .. } | InlineObject::Shape { .. })) = p.object_at(off) {
return Some((Pos { story, path: path.clone(), off }, o.clone()));
if let Some(o) = p.object_at(off).filter(|o| o.is_drawing()) {
out.push((Pos { story, path: path.clone(), off }, o.clone()));
if out.len() >= max {
return out;
}
}
}
}
None
out
}
/// Most shapes one `shape.effects` changes.
const MAX_EFFECT_SHAPES: usize = 10_000;
/// `shape.effects`: set or clear the shadow, glow and soft edges of the selected shapes.
fn shape_effects(s: &mut Session, v: &Value) -> CmdResult {
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
let color = |o: &Value| p::str(o, "color").and_then(Rgb::parse);
// Each change is `None` (leave as it is) or `Some(new value)`.
let shadow: Option<Option<Shadow>> = match v.get("shadow") {
None => None,
Some(Value::Null) => Some(None),
Some(Value::Bool(b)) => Some(b.then(Shadow::default)),
Some(Value::String(id)) if id == "none" => Some(None),
Some(Value::String(id)) => Some(Some(Shadow::preset(id).ok_or_else(|| CmdError::Params(format!("unknown shadow preset `{id}`")))?)),
Some(o @ Value::Object(_)) => {
let mut sh = match p::str(o, "preset") {
Some(id) => Shadow::preset(id).ok_or_else(|| CmdError::Params(format!("unknown shadow preset `{id}`")))?,
None => Shadow::default(),
};
sh.color = color(o).unwrap_or(sh.color);
sh.transparency = p::f32(o, "transparency").unwrap_or(sh.transparency);
sh.blur = p::f32(o, "blur").unwrap_or(sh.blur);
sh.distance = p::f32(o, "distance").unwrap_or(sh.distance);
sh.angle = p::f32(o, "angle").unwrap_or(sh.angle);
Some(Some(sh.sanitized()))
}
Some(_) => return Err(CmdError::Params("`shadow`: a preset name, an object or null".into())),
};
let glow: Option<Option<Glow>> = match v.get("glow") {
None => None,
Some(Value::Null) => Some(None),
Some(Value::String(id)) if id == "none" => Some(None),
Some(n @ Value::Number(_)) => Some(Some(Glow { size: n.as_f64().unwrap_or(0.0) as f32, ..Glow::default() }.sanitized())),
Some(o @ Value::Object(_)) => {
let d = Glow::default();
Some(Some(
Glow {
color: color(o).unwrap_or(d.color),
size: p::f32(o, "size").unwrap_or(d.size),
transparency: p::f32(o, "transparency").unwrap_or(d.transparency),
}
.sanitized(),
))
}
Some(_) => return Err(CmdError::Params("`glow`: a size in points, an object or null".into())),
};
let soft: Option<Option<f32>> = match v.get("softEdge") {
None => None,
Some(Value::Null) => Some(None),
Some(n @ Value::Number(_)) => Some(n.as_f64().map(|x| x as f32)),
Some(_) => return Err(CmdError::Params("`softEdge`: a radius in points or null".into())),
};
// The shapes in the selection and those added to it (Shift+click).
let mut targets: Vec<Pos> =
selected_all(s, MAX_EFFECT_SHAPES).into_iter().filter(|(_, o)| matches!(o, InlineObject::Shape { .. })).map(|(pos, _)| pos).collect();
for pos in picked_objects(s) {
if !targets.contains(&pos) && matches!(s.doc.para_at(&pos).and_then(|q| q.object_at(pos.off)), Some(InlineObject::Shape { .. })) {
targets.push(pos);
}
}
if targets.is_empty() {
return Err(CmdError::Disabled("no shape selected".into()));
}
let mut last = ShapeEffects::default();
for pos in &targets {
let o = edit_obj(s, pos, |o| {
if let InlineObject::Shape { effects, float, .. } = o {
let mut e = *effects;
if let Some(x) = shadow {
e.shadow = x;
}
if let Some(x) = glow {
e.glow = x;
}
if let Some(x) = soft {
e.soft_edge = x;
}
*effects = e.sanitized();
// Room around the shape for its shadow and glow, as Word's effect extent.
float.effect = effects.extent();
}
})?;
if let InlineObject::Shape { effects, .. } = o {
last = effects;
}
}
Ok(json!({"shapes": targets.len(), "effects": serde_json::to_value(last).unwrap_or(Value::Null)}))
}
/// Smallest width/height an object can be resized to, points (a text box keeps room for a line).
@@ -374,14 +495,7 @@ fn bounds(s: &mut Session, v: &Value) -> CmdResult {
let w = p::f32(v, "width").unwrap_or(w0).clamp(min, MAX_OFFSET);
let h = p::f32(v, "height").unwrap_or(h0).clamp(min, MAX_OFFSET);
if (w, h) != (w0, h0) {
with_obj(s, |o| {
if let InlineObject::Image { w: ow, h: oh, .. } | InlineObject::Graphic { w: ow, h: oh, .. } | InlineObject::Shape { w: ow, h: oh, .. } =
o
{
*ow = w;
*oh = h;
}
})?;
with_obj(s, |o| o.set_size(w, h))?;
}
let pos = match to {
Some((x, y)) => move_object(s, pos, p::u64(v, "page"), x, y, (w, h))?,
@@ -461,7 +575,7 @@ fn move_object(s: &mut Session, pos: Pos, page: Option<u64>, x: f32, y: f32, (w,
fn unalign(s: &mut Session) -> Result<(), CmdError> {
use wordcraft_doc::para::Anchor;
let (pos, obj) = selected(s).ok_or_else(|| CmdError::Disabled("no picture or shape selected".into()))?;
let (InlineObject::Image { float, .. } | InlineObject::Graphic { float, .. } | InlineObject::Shape { float, .. }) = &obj else { return Ok(()) };
let Some((_, _, float)) = obj.frame() else { return Ok(()) };
if float.h_align.is_none() && float.v_align.is_none() {
return Ok(());
}
@@ -510,10 +624,7 @@ fn nearest_line(layout: &wordcraft_layout::DocLayout, page: usize, y: f32) -> Op
}
fn obj_size(o: &InlineObject) -> (f32, f32) {
match o {
InlineObject::Image { w, h, .. } | InlineObject::Graphic { w, h, .. } | InlineObject::Shape { w, h, .. } => (*w, *h),
_ => (0.0, 0.0),
}
o.frame().map_or((0.0, 0.0), |(w, h, _)| (w, h))
}
fn with_obj(s: &mut Session, f: impl Fn(&mut InlineObject)) -> CmdResult {
@@ -534,15 +645,15 @@ fn edit_obj(s: &mut Session, pos: &Pos, f: impl Fn(&mut InlineObject)) -> Result
fn edit_float(s: &mut Session, pos: &Pos, f: impl Fn(&mut Float)) -> Result<InlineObject, CmdError> {
edit_obj(s, pos, |o| {
if let InlineObject::Image { float, .. } | InlineObject::Graphic { float, .. } | InlineObject::Shape { float, .. } = o {
if let Some(float) = o.float_mut() {
f(float);
}
})
}
fn with_float(s: &mut Session, f: impl Fn(&mut Float)) -> CmdResult {
with_obj(s, |o| match o {
InlineObject::Image { float, .. } | InlineObject::Graphic { float, .. } | InlineObject::Shape { float, .. } => {
with_obj(s, |o| {
if let Some(float) = o.float_mut() {
let was_inline = float.wrap == Wrap::Inline;
f(float);
// Word's distance from text for a newly wrapped object: 0.125" at the sides.
@@ -550,7 +661,6 @@ fn with_float(s: &mut Session, f: impl Fn(&mut Float)) -> CmdResult {
float.dist = 9.0;
}
}
_ => {}
})
}
@@ -750,12 +860,191 @@ fn picture_style(img: image::DynamicImage, style: &str) -> image::DynamicImage {
}
}
/// The selected pictures, shapes, text boxes and groups: the one the selection holds, then
/// those added to it ([`Session::also_selected`]), each once, in the body.
pub fn picked_objects(s: &Session) -> Vec<Pos> {
let Some((first, _)) = object_selection(s) else { return Vec::new() };
let mut out = vec![first];
for p in &s.also_selected {
if !out.contains(p) && s.doc.para_at(p).and_then(|q| q.object_at(p.off)).is_some_and(InlineObject::is_drawing) {
out.push(p.clone());
}
}
out
}
/// The object a `select.addObject` item names: a position, or a Selection Pane index.
fn object_ref(s: &Session, v: &Value) -> Result<Pos, CmdError> {
let pos = match v.as_u64() {
Some(n) => all_objects(s).into_iter().nth(usize::try_from(n).unwrap_or(usize::MAX)).map(|(p, _)| p),
None => super::parse_pos(v),
};
pos.filter(|p| p.story == StoryRef::Body && s.doc.para_at(p).and_then(|q| q.object_at(p.off)).is_some_and(InlineObject::is_drawing))
.ok_or_else(|| CmdError::Params("no picture or shape there".into()))
}
/// Add objects to the selection (Shift+click): the first one is selected if no object is yet;
/// one already selected is taken out again.
fn add_object(s: &mut Session, v: &Value) -> CmdResult {
let mut items: Vec<Value> = match v.get("objects").and_then(Value::as_array) {
Some(a) => a.iter().take(1000).cloned().collect(),
None => Vec::new(),
};
if let Some(p) = v.get("pos") {
items.push(p.clone());
}
if let Some(i) = v.get("index") {
items.push(i.clone());
}
if items.is_empty() {
return Err(CmdError::Params("`pos`, `index` or `objects` required".into()));
}
for it in &items {
let pos = object_ref(s, it)?;
match object_selection(s).map(|(p, _)| p) {
None => {
s.also_selected.clear();
s.sel = Selection { anchor: pos.clone(), focus: Pos { off: pos.off + wordcraft_doc::para::OBJ.len_utf8(), ..pos } };
}
Some(first) if first == pos => {}
Some(_) => {
if let Some(i) = s.also_selected.iter().position(|p| *p == pos) {
s.also_selected.remove(i);
} else {
s.also_selected.push(pos);
}
}
}
}
Ok(json!({"selected": picked_objects(s).iter().map(super::pos_json).collect::<Vec<_>>()}))
}
/// Group the selected objects (Layout › Arrange › Group): one floating group object, anchored
/// where the first of them was, in the box around them all; they keep their places and sizes.
/// A group among them is merged in (its members join the new group).
fn group(s: &mut Session, _: &Value) -> CmdResult {
use wordcraft_doc::para::GroupChild;
let mut picks = picked_objects(s);
if picks.len() < 2 {
return Err(CmdError::Disabled("select two or more pictures or shapes first".into()));
}
picks.sort();
let layout = s.layout();
let mut found = Vec::new();
for pos in &picks {
if pos.story != StoryRef::Body || pos.path.depth() > 0 {
return Err(CmdError::Disabled("only objects in the body text (not in tables) can be grouped".into()));
}
let obj = s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("object vanished".into()))?;
if matches!(obj, InlineObject::Graphic { .. }) {
return Err(CmdError::Disabled(FROZEN.into()));
}
if !obj.is_floating() {
return Err(CmdError::Disabled("objects in line with text can't be grouped: choose a text wrapping for them first".into()));
}
let hit = layout.object(pos, s.page_hint).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?;
found.push((obj, hit));
}
let page = found.first().map(|(_, h)| h.page).unwrap_or(0);
if found.iter().any(|(_, h)| h.page != page) {
return Err(CmdError::Disabled("objects on different pages can't be grouped".into()));
}
let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
for (_, h) in &found {
(x0, y0) = (x0.min(h.rect.x), y0.min(h.rect.y));
(x1, y1) = (x1.max(h.rect.right()), y1.max(h.rect.bottom()));
}
let (w, h) = ((x1 - x0).clamp(1.0, MAX_OFFSET), (y1 - y0).clamp(1.0, MAX_OFFSET));
let mut children = Vec::new();
let member = |obj: &InlineObject, r: [f32; 4]| {
let mut obj = obj.clone();
obj.set_size(r[2], r[3]);
if let Some(f) = obj.float_mut() {
*f = Float::default();
}
GroupChild { x: r[0] - x0, y: r[1] - y0, obj }
};
for (obj, hit) in &found {
let r = hit.rect;
match obj {
InlineObject::Group { .. } => children.extend(obj.group_rects(r.x, r.y, r.w, r.h).into_iter().map(|(cr, c)| member(c, cr))),
_ => children.push(member(obj, [r.x, r.y, r.w, r.h])),
}
}
let float = found.first().and_then(|(o, _)| o.frame()).map(|(_, _, f)| *f).unwrap_or_default();
let grouped = InlineObject::Group { w, h, float, ch_w: w, ch_h: h, children };
// Take the members out, last first so the earlier positions hold, and put the group where
// the first one was.
let len = wordcraft_doc::para::OBJ.len_utf8();
for pos in picks.iter().rev() {
s.doc.delete_range(pos, &Pos { off: pos.off + len, ..pos.clone() })?;
}
let first = picks.first().cloned().ok_or_else(|| CmdError::Failed("nothing to group".into()))?;
let at = s.doc.clamp(&first);
s.doc.insert_object(&at, grouped, &wordcraft_doc::CharProps::default())?;
s.touch();
let pos = move_object(s, at, Some(page as u64), x0, y0, (w, h))?;
s.also_selected.clear();
s.sel = Selection { anchor: pos.clone(), focus: Pos { off: pos.off + len, ..pos.clone() } };
let o = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).cloned();
Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null), "pos": super::pos_json(&pos)}))
}
/// Split the selected group back into its pictures and shapes (Layout › Arrange › Ungroup), in
/// the places and sizes the group shows them, anchored where the group was. They stay
/// selected, so Group puts them back together.
fn ungroup(s: &mut Session, _: &Value) -> CmdResult {
let (pos, obj) = selected(s).ok_or_else(|| CmdError::Disabled("select a group first".into()))?;
let InlineObject::Group { w, h, float, .. } = &obj else { return Err(CmdError::Disabled("select a group first".into())) };
// Its offsets, explicit: the members are placed from the same anchor.
let pos = if float.wrap == Wrap::Inline {
if pos.story != StoryRef::Body || pos.path.depth() > 0 {
return Err(CmdError::Disabled("only groups in the body text can be ungrouped in line with text".into()));
}
let hit = s.layout().object(&pos, s.page_hint).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?;
move_object(s, pos, Some(hit.page as u64), hit.rect.x, hit.rect.y, (*w, *h))?
} else {
unalign(s)?;
pos
};
let obj = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("object vanished".into()))?;
let Some((w, h, float)) = obj.frame() else { return Err(CmdError::Failed("object vanished".into())) };
let float = *float;
let members: Vec<InlineObject> = obj
.group_rects(0.0, 0.0, w, h)
.into_iter()
.map(|([x, y, cw, ch], c)| {
let mut c = c.clone();
c.set_size(cw.max(min_size(&c)), ch.max(min_size(&c)));
if let Some(f) = c.float_mut() {
*f = Float { x: (float.x + x).clamp(-MAX_OFFSET, MAX_OFFSET), y: (float.y + y).clamp(-MAX_OFFSET, MAX_OFFSET), ..float };
}
c
})
.collect();
if members.is_empty() {
return Err(CmdError::Failed("the group is empty".into()));
}
let len = wordcraft_doc::para::OBJ.len_utf8();
s.doc.delete_range(&pos, &Pos { off: pos.off + len, ..pos.clone() })?;
let mut at = s.doc.clamp(&pos);
let mut placed = Vec::new();
for m in members {
placed.push(at.clone());
at = s.doc.insert_object(&at, m, &wordcraft_doc::CharProps::default())?;
}
let first = placed.first().cloned().unwrap_or(pos);
s.sel = Selection { anchor: first.clone(), focus: Pos { off: first.off + len, ..first } };
s.also_selected = placed.into_iter().skip(1).collect();
sel_result(s)
}
fn all_objects(s: &Session) -> Vec<(Pos, InlineObject)> {
let mut v = Vec::new();
for path in s.doc.para_paths(StoryRef::Body) {
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
for off in p.object_offsets() {
if let Some(o @ (InlineObject::Image { .. } | InlineObject::Graphic { .. } | InlineObject::Shape { .. })) = p.object_at(off) {
if let Some(o) = p.object_at(off).filter(|o| o.is_drawing()) {
v.push((Pos { story: StoryRef::Body, path: path.clone(), off }, o.clone()));
}
}
@@ -773,6 +1062,7 @@ fn objects_list(s: &mut Session, _: &Value) -> CmdResult {
InlineObject::Image { alt, .. } => ("picture", if alt.is_empty() { format!("Picture {}", i + 1) } else { alt.clone() }),
InlineObject::Shape { kind, .. } => ("shape", format!("{kind:?} {}", i + 1)),
InlineObject::Graphic { graphic, .. } => ("graphic", format!("{:?} {}", graphic.kind, i + 1)),
InlineObject::Group { .. } => ("group", format!("Group {}", i + 1)),
_ => ("object", format!("Object {}", i + 1)),
};
json!({"index": i, "kind": kind, "name": name, "pos": super::pos_json(&pos), "size": obj_size(&o)})
@@ -876,6 +1166,66 @@ mod tests {
assert!(s.run("picture.crop", &json!({"left": 0.1})).is_err());
}
/// The page rectangle of the object at `pos`.
fn rect_of(s: &mut Session, pos: &Pos) -> [f32; 4] {
let r = s.layout().object(pos, 0).unwrap().rect;
[r.x, r.y, r.w, r.h]
}
fn near(a: [f32; 4], b: [f32; 4]) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.5)
}
/// Two floating shapes, Shift+clicked together and grouped: one object that moves as one with
/// its members in place; ungrouping puts them back where the group showed them, still
/// selected, so Group regroups them.
#[test]
fn group_moves_as_one_and_ungroups() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("document.setText", &json!({"text": "one\ntwo\nthree\nfour"})).unwrap();
s.sel = Selection::caret(s.doc.start_of(StoryRef::Body));
s.run("insert.shape", &json!({"kind": "rectangle", "width": 100, "height": 50})).unwrap();
s.run("arrange.bounds", &json!({"page": 0, "x": 100, "y": 150})).unwrap();
s.run("select.collapse", &json!({"end": true})).unwrap();
s.run("insert.shape", &json!({"kind": "ellipse", "width": 60, "height": 40})).unwrap();
s.run("arrange.bounds", &json!({"page": 0, "x": 300, "y": 200})).unwrap();
// One shape selected: nothing to group yet.
assert!(s.run("arrange.group", &json!({})).is_err());
let r = s.run("select.addObject", &json!({"index": 0})).unwrap();
assert_eq!(r["selected"].as_array().unwrap().len(), 2);
let g = s.run("arrange.group", &json!({})).unwrap();
let pos: Pos = serde_json::from_value(g["pos"].clone()).unwrap();
let list = s.run("arrange.selectionPane", &json!({})).unwrap();
assert_eq!(list.as_array().unwrap().len(), 1, "{list}");
assert_eq!(list[0]["kind"], "group");
assert!(s.also_selected.is_empty());
assert!(near(rect_of(&mut s, &pos), [100.0, 150.0, 260.0, 90.0]), "{:?}", rect_of(&mut s, &pos));
// Moving the group moves its members with it.
s.run("arrange.nudge", &json!({"dx": 20, "dy": 10})).unwrap();
let (pos, obj) = selected(&s).unwrap();
let [x, y, w, h] = rect_of(&mut s, &pos);
let members: Vec<[f32; 4]> = obj.group_rects(x, y, w, h).into_iter().map(|(r, _)| r).collect();
assert!(near(members[0], [120.0, 160.0, 100.0, 50.0]) && near(members[1], [320.0, 210.0, 60.0, 40.0]), "{members:?}");
// Drawn there too: the page shows both shapes inside the group's frame.
let shapes = s.layout().pages[0].items.iter().filter(|i| matches!(i, wordcraft_layout::Placed::Shape { .. })).count();
assert_eq!(shapes, 2);
// Ungroup: two shapes again, where the group showed them, both still selected.
s.run("arrange.ungroup", &json!({})).unwrap();
let list = all_objects(&s);
assert_eq!(list.len(), 2);
let rects: Vec<[f32; 4]> = list.iter().map(|(p, _)| rect_of(&mut s, p)).collect();
assert!(rects.iter().any(|r| near(*r, [120.0, 160.0, 100.0, 50.0])), "{rects:?}");
assert!(rects.iter().any(|r| near(*r, [320.0, 210.0, 60.0, 40.0])), "{rects:?}");
assert_eq!(picked_objects(&s).len(), 2);
s.run("arrange.group", &json!({})).unwrap();
assert_eq!(all_objects(&s).len(), 1);
// Undo goes back step by step.
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(all_objects(&s).len(), 2);
}
#[test]
fn picture_alt_and_crop_round_trip() {
let mut s = Session::new(wordcraft_doc::Document::new());
+201 -7
View File
@@ -129,6 +129,9 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("para.widowControl", "Widow/Orphan Control", "Home › Paragraph › Line and Page Breaks", |s, v| {
tog(s, v, |p| p.widow_control, |p, b| p.widow_control = Some(b))
}),
CommandSpec::new("para.asianTypography", "Asian Typography", "Home › Paragraph › Asian Typography", asian_typography).params(
r#"{"kinsoku"?: bool (East Asian line-breaking rules), "wordWrap"?: bool (false: Latin words may break at any character), "overflowPunct"?: bool (hanging punctuation), "topLinePunct"?: bool (compress punctuation at the start of a line), "autoSpaceDE"?: bool (space between Asian and Latin text), "autoSpaceDN"?: bool (space between Asian text and numbers)} — flags left out stay as they are; returns the flags at the caret"#,
),
CommandSpec::new("para.tabs", "Tabs", "Home › Paragraph › Paragraph", |s, v| {
let tabs: Vec<TabStop> = serde_json::from_value(v.get("tabs").cloned().unwrap_or(Value::Null)).map_err(|e| CmdError::Params(e.to_string()))?;
if tabs.len() > 64 {
@@ -154,14 +157,20 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("styles.create", "Create a Style", "Home › Styles", create_style).params(r#"{"name": string, "basedOn"?: string, "fromSelection"?: bool}"#),
CommandSpec::new("styles.modify", "Modify Style", "Home › Styles", modify_style).params(r#"{"style": string, "chr"?: CharProps, "para"?: ParaProps, "name"?: string, "next"?: string}"#),
CommandSpec::new("styles.updateToMatch", "Update Style to Match Selection", "Home › Styles", update_to_match).params(r#"{"style"?: string}"#),
CommandSpec::new("styles.delete", "Delete Style", "Home › Styles", |s, v| {
let id = style_id(s, p::req_str(v, "style")?)?;
if s.doc.styles.get(&id).is_some_and(|st| st.builtin) {
return Err(CmdError::Failed("built-in styles can't be deleted".into()));
CommandSpec::new("styles.delete", "Delete Style", "Home › Styles", delete_style).params(r#"{"style": string (name or id; custom styles only)}"#),
CommandSpec::new("styles.manage", "Manage Styles", "Home › Styles", |s, v| {
let sort = p::str(v, "sort").unwrap_or("recommended");
if !matches!(sort, "recommended" | "alphabetical") {
return Err(CmdError::Params(format!("unknown sort `{sort}` (recommended|alphabetical)")));
}
s.doc.styles.styles.retain(|st| st.id != id);
sel_result(s)
}),
let list = manage_list(s, sort == "alphabetical");
s.ui_requests.push(json!({"open": "manageStyles"}));
Ok(list)
})
.params(r#"{"sort"?: "recommended|alphabetical"} → [{id, name, type: "paragraph|character|linked|table", builtIn, inGallery, hidden, basedOn, priority, format}]"#)
.pure(),
CommandSpec::new("styles.setVisibility", "Show or Hide Style", "Home › Styles", set_visibility)
.params(r#"{"style": string, "gallery"?: bool (Styles gallery, w:qFormat), "hidden"?: bool (hidden from style lists, w:semiHidden)}"#),
CommandSpec::new("styles.list", "Styles", "Home › Styles", |s, _| {
Ok(Value::Array(
s.doc.styles.styles.iter().map(|st| json!({"id": st.id, "name": st.name, "kind": st.kind, "quick": st.quick, "basedOn": st.based_on})).collect(),
@@ -240,6 +249,42 @@ fn tog(s: &mut Session, v: &Value, get: fn(&ParaProps) -> Option<bool>, set: fn(
fmt(s, &|p| set(p, on))
}
/// Asian Typography (the Paragraph dialog's tab): sets the given flags on every selected
/// paragraph and returns the resolved flags at the caret.
fn asian_typography(s: &mut Session, v: &Value) -> CmdResult {
let flag = |k: &str| p::bool(v, k);
let (kinsoku, word_wrap, overflow, top_line, de, dn) =
(flag("kinsoku"), flag("wordWrap"), flag("overflowPunct"), flag("topLinePunct"), flag("autoSpaceDE"), flag("autoSpaceDN"));
let mut out = if [kinsoku, word_wrap, overflow, top_line, de, dn].iter().any(Option::is_some) {
fmt(s, &|p| {
let set = |field: &mut Option<bool>, val: Option<bool>| {
if val.is_some() {
*field = val;
}
};
set(&mut p.kinsoku, kinsoku);
set(&mut p.word_wrap, word_wrap);
set(&mut p.overflow_punct, overflow);
set(&mut p.top_line_punct, top_line);
set(&mut p.auto_space_de, de);
set(&mut p.auto_space_dn, dn);
})?
} else {
sel_result(s)?
};
let r = cur(s);
if let Some(o) = out.as_object_mut() {
o.insert(
"asianTypography".into(),
json!({
"kinsoku": r.kinsoku, "wordWrap": r.word_wrap, "overflowPunct": r.overflow_punct,
"topLinePunct": r.top_line_punct, "autoSpaceDE": r.auto_space_de, "autoSpaceDN": r.auto_space_dn,
}),
);
}
Ok(out)
}
pub fn indent(s: &mut Session, _: &Value) -> CmdResult {
let (a, b) = s.sel.ordered();
let paths = s.doc.paths_between(&a, &b);
@@ -584,6 +629,155 @@ fn update_to_match(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s)
}
/// Delete a custom style. Text that uses it falls back to the style it was based on (or the
/// default style), styles based on it are rebased, and a custom character style linked to it
/// goes with it. Built-in styles are refused.
fn delete_style(s: &mut Session, v: &Value) -> CmdResult {
let id = style_id(s, p::req_str(v, "style")?)?;
let Some(st) = s.doc.styles.get(&id).cloned() else { return Err(CmdError::Params(format!("no style `{id}`"))) };
// Table styles: their tables take Table Grid and styles based on them keep their look.
if st.kind == StyleKind::Table {
let mut out = super::table_style::delete(s, &id)?;
if let Some(o) = out.as_object_mut() {
let tables = o.get("tables").cloned().unwrap_or(json!(0));
o.insert("deleted".into(), json!([id]));
o.insert("restyled".into(), tables);
}
return Ok(out);
}
if st.builtin {
return Err(CmdError::Failed(format!("`{}` is a built-in style and can't be deleted", st.name)));
}
let mut gone = vec![st.clone()];
if let Some(partner) = st.linked.as_deref().and_then(|l| s.doc.styles.get(l))
&& !partner.builtin
&& partner.linked.as_deref() == Some(id.as_str())
{
gone.push(partner.clone());
}
let mut changed = 0;
for g in &gone {
s.doc.styles.remove(&g.id);
// Fall back to the base only while it is a style of the same kind that still exists.
let base = g.based_on.clone().filter(|b| s.doc.styles.get(b).is_some_and(|x| x.kind == g.kind));
changed += s.doc.restyle(&g.id, base);
}
touch_all(s);
Ok(json!({"deleted": gone.iter().map(|g| g.id.clone()).collect::<Vec<_>>(), "restyled": changed}))
}
/// Styles gallery and style list visibility (`w:qFormat` / `w:semiHidden`).
fn set_visibility(s: &mut Session, v: &Value) -> CmdResult {
let id = style_id(s, p::req_str(v, "style")?)?;
let (gallery, hidden) = (p::bool(v, "gallery"), p::bool(v, "hidden"));
if gallery.is_none() && hidden.is_none() {
return Err(CmdError::Params("give `gallery` and/or `hidden`".into()));
}
let st = s.doc.styles.get_mut(&id).ok_or_else(|| CmdError::Params(format!("no style `{id}`")))?;
if let Some(h) = hidden {
st.hidden = h;
}
if let Some(g) = gallery {
st.quick = g;
// Showing a style in the gallery unhides it.
if g {
st.hidden = false;
}
}
let out = json!({"style": st.id, "inGallery": st.quick && !st.hidden && st.kind != StyleKind::Table, "hidden": st.hidden});
touch_all(s);
Ok(out)
}
/// Manage Styles' list: paragraph, character, linked and table styles (a linked pair once, as
/// its paragraph half), sorted as recommended (priority, then name) or alphabetically.
pub fn manage_list(s: &Session, alphabetical: bool) -> Value {
let sheet = &s.doc.styles;
let links_back = |a: &Style, b: &str| sheet.get(b).is_some_and(|x| x.linked.as_deref() == Some(a.id.as_str()));
let mut list: Vec<&Style> = sheet
.styles
.iter()
.filter(|st| match st.kind {
StyleKind::Numbering => false,
// The character half of a linked pair is listed as the linked style.
StyleKind::Character => {
!st.linked.as_deref().is_some_and(|l| sheet.get(l).is_some_and(|x| x.kind == StyleKind::Paragraph) && links_back(st, l))
}
_ => true,
})
.collect();
if alphabetical {
list.sort_by_cached_key(|st| st.name.to_lowercase());
} else {
list.sort_by_cached_key(|st| (st.priority.unwrap_or(99), st.name.to_lowercase()));
}
Value::Array(
list.into_iter()
.map(|st| {
let ty = match st.kind {
// Linked: its character half links back (or isn't in the document yet).
StyleKind::Paragraph
if st.linked.as_deref().is_some_and(|l| sheet.get(l).is_none_or(|x| x.kind == StyleKind::Character && links_back(st, l))) =>
{
"linked"
}
StyleKind::Paragraph => "paragraph",
StyleKind::Character => "character",
_ => "table",
};
json!({
"id": st.id,
"name": st.name,
"type": ty,
"builtIn": st.builtin || (st.kind == StyleKind::Table && super::table_style::is_builtin_table_style(st)),
"inGallery": st.quick && !st.hidden && st.kind != StyleKind::Table,
"hidden": st.hidden,
"basedOn": st.based_on.as_deref().and_then(|b| sheet.get(b)).map(|b| b.name.clone()),
"priority": st.priority,
"format": own_format(st),
})
})
.collect(),
)
}
/// The formatting a style sets itself (not what it inherits), for its description.
fn own_format(st: &Style) -> Value {
let mut f = serde_json::Map::new();
let c = &st.chr;
if let Some(x) = &c.font {
f.insert("font".into(), json!(x));
}
if let Some(x) = c.size {
f.insert("size".into(), json!(x));
}
for (k, v) in [("bold", c.bold), ("italic", c.italic), ("caps", c.caps), ("smallCaps", c.small_caps)] {
if let Some(b) = v {
f.insert(k.into(), json!(b));
}
}
if let Some(wordcraft_doc::TextColor::Rgb(rgb)) = c.color {
f.insert("color".into(), json!(rgb.hex()));
}
let pp = &st.para;
if let Some(a) = pp.align {
f.insert("align".into(), json!(a));
}
if let Some(x) = pp.space_before {
f.insert("spaceBefore".into(), json!(x));
}
if let Some(x) = pp.space_after {
f.insert("spaceAfter".into(), json!(x));
}
if let Some(x) = pp.indent_left {
f.insert("indentLeft".into(), json!(x));
}
if let Some(LineSpacing::Multiple(m)) = pp.line_spacing {
f.insert("lineSpacing".into(), json!(m));
}
Value::Object(f)
}
/// Style edits re-lay out every paragraph: the layout cache is keyed on the style sheet, so
/// nothing needs touching here (kept as the one place to hook style-change side effects).
pub fn touch_all(s: &mut Session) {
+170 -17
View File
@@ -1,20 +1,28 @@
//! Table Design › Table Styles: create and modify table styles (whole table, header row and
//! banded rows), stored as table styles in the document's style sheet (ECMA-376 §17.7.6).
//! Table Design › Table Styles: create, modify and delete table styles (whole table, header and
//! total rows, first and last columns, banded rows and columns), stored as table styles in the
//! document's style sheet (ECMA-376 §17.7.6).
use std::sync::Arc;
use serde_json::{Value, json};
use wordcraft_doc::props::{Border, BorderStyle, Borders, CharProps, ParaProps, Rgb, TextColor};
use wordcraft_doc::styles::{Style, StyleKind, TableStyleParts};
use wordcraft_doc::styles::{Style, StyleKind, TableStyleParts, TableStyleProps};
use wordcraft_doc::{Block, Table};
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
/// The formatting params both commands share; each region (`wholeTable`, `headerRow`,
/// `bandedRows`) takes the same shortcuts.
/// The regions a table style formats, by param name: the table style options they follow.
pub const REGIONS: [&str; 7] = ["wholeTable", "headerRow", "lastRow", "firstColumn", "lastColumn", "bandedRows", "bandedColumns"];
/// The formatting params both commands share; each region (`wholeTable`, `headerRow`, `lastRow`
/// (the total row), `firstColumn`, `lastColumn`, `bandedRows`, `bandedColumns`) takes the same
/// shortcuts.
macro_rules! style_params {
($head:literal) => {
concat!(
"{",
$head,
r#", "basedOn"?: string, "wholeTable"?: Region, "headerRow"?: Region, "bandedRows"?: Region, "bandSize"?: n, "para"?: ParaProps, "apply"?: bool}"#,
r#", "basedOn"?: string, "wholeTable"?: Region, "headerRow"?: Region, "lastRow"?: Region (total row), "firstColumn"?: Region, "lastColumn"?: Region, "bandedRows"?: Region, "bandedColumns"?: Region, "bandSize"?: n (rows per band), "para"?: ParaProps, "apply"?: bool}"#,
r#" where Region = {"borders"?: bool | null (true: single lines, false: none, null: inherit), "borderColor"?: "RRGGBB", "borderWidth"?: pt, "fill"?: "RRGGBB" | null, "bold"?: bool, "italic"?: bool, "color"?: "RRGGBB" | null, "size"?: pt, "font"?: string}"#
)
};
@@ -25,6 +33,8 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("table.newStyle", "New Table Style", "Table Design › Table Styles", new_style).params(style_params!(r#""name": string"#)),
CommandSpec::new("table.modifyStyle", "Modify Table Style", "Table Design › Table Styles", modify_style)
.params(style_params!(r#""style"?: string (default: the current table's), "name"?: string"#)),
CommandSpec::new("table.deleteStyle", "Delete Table Style", "Table Design › Table Styles", delete_style)
.params(r#"{"style"?: string (default: the current table's)}"#),
]
}
@@ -43,7 +53,8 @@ fn current_table_style(s: &Session) -> Option<String> {
s.doc.table(s.sel.focus.story, &tp)?.props.style.clone()
}
fn new_style(s: &mut Session, v: &Value) -> CmdResult {
/// The style `table.newStyle` would add for `v`, validated.
fn build_new(s: &Session, v: &Value) -> Result<Style, CmdError> {
let name = p::req_str(v, "name")?.trim().to_string();
if name.is_empty() {
return Err(CmdError::Params("name is empty".into()));
@@ -56,8 +67,14 @@ fn new_style(s: &mut Session, v: &Value) -> CmdResult {
None => table_style_id(s, "TableGrid").or_else(|| table_style_id(s, "TableNormal")),
};
let id = s.doc.styles.new_id(&name);
let mut st = Style { id: id.clone(), name, kind: StyleKind::Table, based_on, priority: Some(99), ..Default::default() };
let mut st = Style { id, name, kind: StyleKind::Table, based_on, priority: Some(99), ..Default::default() };
edit(&mut st, v)?;
Ok(st)
}
fn new_style(s: &mut Session, v: &Value) -> CmdResult {
let st = build_new(s, v)?;
let id = st.id.clone();
s.doc.styles.upsert(st);
s.dirty = true;
if p::bool(v, "apply").unwrap_or(true) {
@@ -66,11 +83,28 @@ fn new_style(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"id": id}))
}
/// The table style `v` names (`style`), else the current table's.
fn target(s: &Session, v: &Value) -> Result<String, CmdError> {
match p::str(v, "style") {
Some(n) => table_style_id(s, n).ok_or_else(|| CmdError::Params(format!("no table style `{n}`"))),
None => current_table_style(s).ok_or_else(|| CmdError::Disabled("the cursor isn't in a table with a style".into())),
}
}
fn modify_style(s: &mut Session, v: &Value) -> CmdResult {
let id = match p::str(v, "style") {
Some(n) => table_style_id(s, n).ok_or_else(|| CmdError::Params(format!("no table style `{n}`")))?,
None => current_table_style(s).ok_or_else(|| CmdError::Disabled("the cursor isn't in a table with a style".into()))?,
};
let st = build_modified(s, v)?;
let id = st.id.clone();
s.doc.styles.upsert(st);
s.dirty = true;
if p::bool(v, "apply").unwrap_or(false) {
apply(s, &id)?;
}
Ok(json!({"id": id}))
}
/// The style `table.modifyStyle` would store for `v`, validated.
fn build_modified(s: &Session, v: &Value) -> Result<Style, CmdError> {
let id = target(s, v)?;
let rename = p::str(v, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
if let Some(n) = &rename
&& s.doc.styles.find(n).is_some_and(|x| x.id != id)
@@ -96,12 +130,119 @@ fn modify_style(s: &mut Session, v: &Value) -> CmdResult {
st.based_on = Some(b);
}
edit(&mut st, v)?;
s.doc.styles.upsert(st);
s.dirty = true;
if p::bool(v, "apply").unwrap_or(false) {
apply(s, &id)?;
Ok(st)
}
/// How table style `v["style"]` (or, without it, a new style) would look with the formatting
/// params of `table.modifyStyle` / `table.newStyle` applied, for a dialog's live preview. Names
/// aren't checked; `None` when the params don't validate.
pub fn preview(s: &Session, v: &Value) -> Option<TableStyleProps> {
if !v.is_object() {
return None;
}
Ok(json!({"id": id}))
let mut v = v.clone();
let st = if p::str(&v, "style").is_some() {
if let Some(o) = v.as_object_mut() {
o.remove("name");
}
build_modified(s, &v).ok()?
} else {
// A name no style has: the look doesn't depend on it.
v["name"] = json!("\u{1}preview");
build_new(s, &v).ok()?
};
let id = st.id.clone();
let mut sheet = s.doc.styles.clone();
sheet.upsert(st);
sheet.table_style(&id)
}
/// Delete a custom table style. Styles based on it are re-based on its own base with its
/// formatting folded in, so they look the same; tables using it take Table Grid (the style new
/// tables get), or no style when the document has no Table Grid.
fn delete_style(s: &mut Session, v: &Value) -> CmdResult {
let id = target(s, v)?;
delete(s, &id)
}
/// Delete table style `id` (also what `styles.delete` runs for a table style): see
/// [`delete_style`].
pub fn delete(s: &mut Session, id: &str) -> CmdResult {
let id = id.to_string();
let st = s.doc.styles.get(&id).cloned().ok_or_else(|| CmdError::Params("no such style".into()))?;
if st.kind != StyleKind::Table {
return Err(CmdError::Params(format!("`{}` isn't a table style", st.name)));
}
if is_builtin_table_style(&st) {
return Err(CmdError::Failed(format!("`{}` is a built-in table style and can't be deleted", st.name)));
}
let mut rebased = Vec::new();
for child in s.doc.styles.styles.iter_mut().filter(|c| c.based_on.as_deref() == Some(id.as_str())) {
let mut para = st.para.clone();
para.overlay(&child.para);
child.para = para;
let mut chr = st.chr.clone();
chr.overlay(&child.chr);
child.chr = chr;
if child.kind == StyleKind::Table {
let mut parts = st.table.clone().unwrap_or_default();
if let Some(t) = &child.table {
parts.overlay(t);
}
child.table = (parts != TableStyleParts::default()).then_some(parts);
}
child.based_on = st.based_on.clone();
rebased.push(child.id.clone());
}
s.doc.styles.styles.retain(|x| x.id != id);
let fallback = table_style_id(s, "TableGrid");
let mut tables = restyle(&mut s.doc.body, &id, fallback.as_deref(), 0);
for part in s.doc.parts.values_mut() {
tables += restyle(&mut part.blocks, &id, fallback.as_deref(), 0);
}
s.dirty = true;
Ok(json!({"deleted": id, "tables": tables, "rebased": rebased}))
}
/// Whether `st` is one of Word's built-in table styles. A .docx can mark any style custom
/// (`w:customStyle`), so Table Grid and Normal Table are recognised by id and name too: deleting
/// them would leave new tables (and the fallback for deleted styles) without a style to take.
pub fn is_builtin_table_style(st: &Style) -> bool {
const IDS: [&str; 2] = ["TableGrid", "TableNormal"];
const NAMES: [&str; 3] = ["Table Grid", "Normal Table", "Table Normal"];
st.builtin || IDS.iter().any(|x| st.id.eq_ignore_ascii_case(x)) || NAMES.iter().any(|x| st.name.eq_ignore_ascii_case(x))
}
/// Tables nest at most this deep for restyling.
const MAX_DEPTH: usize = 32;
/// Whether table `t`, or one nested in it, uses style `id`.
fn uses_style(t: &Table, id: &str, depth: usize) -> bool {
t.props.style.as_deref() == Some(id)
|| (depth < MAX_DEPTH
&& t.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.blocks).any(|b| b.as_table().is_some_and(|t| uses_style(t, id, depth + 1))))
}
/// Give every table in `blocks` that uses style `from` style `to`; the number restyled.
fn restyle(blocks: &mut [Arc<Block>], from: &str, to: Option<&str>, depth: usize) -> usize {
let mut n = 0;
for b in blocks {
if !b.as_table().is_some_and(|t| uses_style(t, from, depth)) {
continue;
}
if let Block::Table(t) = Arc::make_mut(b) {
if t.props.style.as_deref() == Some(from) {
t.props.style = to.map(str::to_string);
n += 1;
}
if depth < MAX_DEPTH {
for c in t.rows.iter_mut().flat_map(|r| &mut r.cells) {
n += restyle(&mut c.blocks, from, to, depth + 1);
}
}
}
}
n
}
/// Apply style `id` to the table at the caret, if any.
@@ -123,6 +264,18 @@ fn edit(st: &mut Style, v: &Value) -> Result<(), CmdError> {
if let Some(r) = v.get("bandedRows") {
region(r, &mut parts.band_fill, &mut parts.band_chr, &mut parts.band_borders)?;
}
if let Some(r) = v.get("lastRow") {
region(r, &mut parts.total_fill, &mut parts.total_chr, &mut parts.total_borders)?;
}
if let Some(r) = v.get("firstColumn") {
region(r, &mut parts.first_col_fill, &mut parts.first_col_chr, &mut parts.first_col_borders)?;
}
if let Some(r) = v.get("lastColumn") {
region(r, &mut parts.last_col_fill, &mut parts.last_col_chr, &mut parts.last_col_borders)?;
}
if let Some(r) = v.get("bandedColumns") {
region(r, &mut parts.col_band_fill, &mut parts.col_band_chr, &mut parts.col_band_borders)?;
}
if let Some(n) = v.get("bandSize") {
parts.band_size = n.as_u64().map(|n| n.clamp(1, 1000) as u32);
}
-5
View File
@@ -173,11 +173,6 @@ 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}"#),
+82 -8
View File
@@ -36,9 +36,63 @@ 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}"))?,
@@ -48,7 +102,7 @@ pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<Document, String> {
},
};
doc.ensure_nonempty();
Ok(doc)
Ok((doc, false))
}
/// Serialise a document; `name` gives the format by extension.
@@ -64,8 +118,23 @@ pub fn save_bytes(name: &str, doc: &Document) -> Result<Vec<u8>, String> {
}
}
#[cfg(not(target_arch = "wasm32"))]
/// [`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> {
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()));
@@ -77,9 +146,9 @@ pub fn open_path(path: &std::path::Path) -> Result<Document, String> {
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);
return Ok((doc, false));
}
open_bytes(&name, &bytes)
open_bytes_with(&name, &bytes, password)
}
/// The pictures an HTML file names by a path relative to its folder.
@@ -186,13 +255,18 @@ fn read_regular_file(path: &std::path::Path, limit: u64) -> Option<Vec<u8>> {
}
#[cfg(target_arch = "wasm32")]
pub fn open_path(path: &std::path::Path) -> Result<Document, String> {
pub fn open_path_with(path: &std::path::Path, _password: Option<&str>) -> Result<(Document, bool), String> {
Err(format!("{}: files are opened through the browser on the web", path.display()))
}
#[cfg(not(target_arch = "wasm32"))]
pub fn save_path(path: &std::path::Path, doc: &Document) -> Result<(), String> {
let bytes = save_bytes(&path.to_string_lossy(), doc)?;
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)?;
// 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()))?;
@@ -200,7 +274,7 @@ pub fn save_path(path: &std::path::Path, doc: &Document) -> Result<(), String> {
}
#[cfg(target_arch = "wasm32")]
pub fn save_path(path: &std::path::Path, _doc: &Document) -> Result<(), String> {
pub fn save_path_with(path: &std::path::Path, _doc: &Document, _password: Option<&str>) -> Result<(), String> {
Err(format!("{}: files are saved through the browser on the web", path.display()))
}
+1
View File
@@ -19,6 +19,7 @@ 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;
+28
View File
@@ -65,6 +65,9 @@ pub struct ViewState {
#[serde(default)]
pub style_inspector: bool,
pub comments_pane: bool,
/// The Clipboard pane (Home › Clipboard): items collected by Copy and Cut.
#[serde(default)]
pub clipboard_pane: bool,
pub multi_page: bool,
/// Zoom to fit: "pageWidth", "onePage", "multiplePages", or empty.
pub fit: String,
@@ -95,6 +98,7 @@ impl Default for ViewState {
styles_pane: false,
style_inspector: false,
comments_pane: false,
clipboard_pane: false,
multi_page: false,
fit: String::new(),
web_width: 800.0,
@@ -183,6 +187,8 @@ pub struct Session {
pub clipboard: Option<Fragment>,
/// Plain text mirror of the clipboard (for the system clipboard).
pub clipboard_text: String,
/// Items collected by Copy and Cut this session, for the Clipboard pane.
pub clip_history: crate::cmd::edit::ClipHistory,
pub find: FindState,
/// Page x the caret tries to keep on Up/Down.
pub goal_x: Option<f32>,
@@ -241,6 +247,13 @@ 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>,
/// Column (block) selection, if one was made (see [`Session::column_segments`]).
pub column: Option<ColumnBlock>,
/// Column selection mode (Ctrl+Shift+F8): caret movement extends the block.
@@ -285,6 +298,7 @@ impl Session {
dirty: false,
clipboard: None,
clipboard_text: String::new(),
clip_history: Default::default(),
find: FindState::default(),
goal_x: None,
page_hint: 0,
@@ -316,8 +330,10 @@ impl Session {
math: None,
math_latex: false,
math_normal_text: false,
also_selected: Vec::new(),
prefs: Prefs::default(),
read_aloud: Default::default(),
password: None,
column: None,
column_mode: false,
}
@@ -480,8 +496,10 @@ 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();
self.column = None;
self.column_mode = false;
self.reset_history();
@@ -516,6 +534,7 @@ impl Session {
dirty,
clipboard: _,
clipboard_text: _,
clip_history: _,
find: _,
goal_x: _,
page_hint: _,
@@ -550,6 +569,9 @@ 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: _,
column_mode: _,
@@ -581,6 +603,7 @@ impl Session {
self.undo_evicted = evicted;
self.doc = doc;
self.sel = sel;
self.also_selected.clear();
self.history.truncate(history_len);
self.redo = redo;
self.typing_open = typing_open;
@@ -679,6 +702,7 @@ impl Session {
} else if spec.id.starts_with("caret.") {
self.typing_open = false;
}
let sel_before = self.sel.clone();
let run = spec.run;
let mutates = spec.mutates;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::cmd::column::dispatch(self, id, mutates, run, params)));
@@ -697,6 +721,10 @@ impl Session {
self.doc.prune_text_boxes();
}
self.clamp_selection();
// Extra selected objects last until something else is edited or selected.
if !matches!(id, "select.addObject" | "arrange.ungroup") && (spec.mutates || self.sel != sel_before) {
self.also_selected.clear();
}
}
Err(e) => {
if let Some((d, s, h, r, t, ev, c)) = before_doc {
+350
View File
@@ -601,6 +601,73 @@ fn paste_special_respects_track_changes_and_brings_lists() {
assert!(text(&s).starts_with("one\ntwo"));
}
#[test]
fn clipboard_pane_collects_copies_and_pastes_one_undoably() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "one two three"}));
for (a, b) in [(0, 3), (4, 7), (8, 13)] {
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": a}, "focus": {"block": 0, "off": b}}));
run(&mut s, "edit.copy", json!({}));
}
// Copying the same text again adds nothing.
run(&mut s, "edit.copy", json!({}));
let items = run(&mut s, "edit.clipboardItems", json!({}));
let previews: Vec<&str> = items.as_array().unwrap().iter().map(|i| i["preview"].as_str().unwrap()).collect();
assert_eq!(previews, ["three", "two", "one"], "newest first");
assert_eq!(run(&mut s, "edit.clipboardPane", json!({}))["value"], true);
assert!(s.view.clipboard_pane);
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "edit.pasteClipboardItem", json!({"index": 1}));
assert_eq!(text(&s), "one two threetwo");
assert_eq!(s.clipboard_text, "three", "the clipboard itself is unchanged");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "one two three");
run(&mut s, "edit.pasteAllClipboard", json!({}));
assert_eq!(text(&s), "one two threeonetwothree", "Paste All goes in copy order");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "one two three", "Paste All is one undo step");
let left = run(&mut s, "edit.deleteClipboardItem", json!({"index": 0}));
assert_eq!(left[0]["preview"], "two");
run(&mut s, "edit.clearClipboard", json!({}));
assert!(s.clip_history.is_empty());
assert!(s.run("edit.pasteClipboardItem", &json!({"index": 0})).is_err());
}
#[test]
fn clipboard_pane_is_capped_and_rejects_bad_indexes() {
let mut s = s();
let words: Vec<String> = (0..30).map(|i| format!("w{i}")).collect();
run(&mut s, "document.setText", json!({"text": words.join(" ")}));
// Cutting each word in turn leaves the spaces: word `off` starts at `off`.
for (off, w) in words.iter().enumerate() {
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": off}, "focus": {"block": 0, "off": off + w.len()}}));
run(&mut s, "edit.cut", json!({}));
}
let items = run(&mut s, "edit.clipboardItems", json!({}));
let items = items.as_array().unwrap();
assert_eq!(items.len(), crate::cmd::edit::CLIP_MAX_ITEMS);
assert_eq!(items[0]["preview"], "w29");
assert_eq!(items[23]["preview"], "w6", "the oldest drop out");
let before = text(&s);
for bad in [json!({"index": 24}), json!({"index": -1}), json!({"index": 1e300}), json!({"index": "x"}), json!({})] {
assert!(s.run("edit.pasteClipboardItem", &bad).is_err(), "{bad}");
assert!(s.run("edit.deleteClipboardItem", &bad).is_err(), "{bad}");
}
assert_eq!(text(&s), before);
assert_eq!(s.clip_history.len(), 24);
// A huge copy still reaches the clipboard but is not collected.
run(&mut s, "document.setText", json!({"text": "x".repeat(3 << 20)}));
run(&mut s, "select.all", json!({}));
run(&mut s, "edit.copy", json!({}));
assert_eq!(s.clipboard_text.len(), 3 << 20);
assert_eq!(s.clip_history.items()[0].text, "w29");
}
#[test]
fn tables_commands() {
let mut s = s();
@@ -2285,3 +2352,286 @@ fn accessibility_reports_a_chart_without_alt_text() {
let issues = v["issues"].as_array().expect("issues");
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]
fn shape_effects_apply_and_undo() {
use wordcraft_doc::effects::{MAX_BLUR, MAX_DISTANCE, Shadow};
let mut s = s();
run(&mut s, "insert.shape", json!({"kind": "ellipse"}));
let effects = |s: &Session| match cmd::objects::selected(s) {
Some((_, wordcraft_doc::InlineObject::Shape { effects, float, .. })) => (effects, float.effect),
o => panic!("{o:?}"),
};
assert!(effects(&s).0.is_empty());
run(&mut s, "shape.effects", json!({"shadow": "offsetBottomRight", "glow": {"color": "FF0000", "size": 8}, "softEdge": 5}));
let (e, extent) = effects(&s);
assert_eq!(e.shadow, Shadow::preset("offsetBottomRight"));
assert_eq!(e.glow.map(|g| (g.color, g.size)), Some((wordcraft_doc::props::Rgb(255, 0, 0), 8.0)));
assert_eq!(e.soft_edge, Some(5.0));
assert!(extent.iter().all(|v| *v >= 8.0), "room for the glow: {extent:?}");
// Clearing the glow leaves the shadow and soft edges.
run(&mut s, "shape.effects", json!({"glow": null}));
let (e, _) = effects(&s);
assert!(e.glow.is_none() && e.shadow.is_some() && e.soft_edge == Some(5.0));
run(&mut s, "edit.undo", json!({}));
assert_eq!(effects(&s).0.glow.map(|g| g.size), Some(8.0));
// Hostile numbers.
run(&mut s, "shape.effects", json!({"shadow": {"blur": 1e30, "distance": -5, "angle": 1e20, "transparency": 400}, "softEdge": -3}));
let (e, _) = effects(&s);
let sh = e.shadow.unwrap();
assert!(sh.blur == MAX_BLUR && sh.distance == 0.0 && (0.0..360.0).contains(&sh.angle) && sh.transparency == 100.0, "{sh:?}");
assert!(e.soft_edge.is_none());
run(&mut s, "shape.effects", json!({"shadow": {"distance": 1e9}}));
assert_eq!(effects(&s).0.shadow.map(|s| s.distance), Some(MAX_DISTANCE));
assert!(s.run("shape.effects", &json!({"shadow": "perspective"})).is_err());
// Off a shape it's disabled.
run(&mut s, "select.collapse", json!({"end": true}));
run(&mut s, "text.insert", json!({"text": "x"}));
assert!(s.run("shape.effects", &json!({"shadow": null})).is_err());
}
#[test]
fn asian_typography_sets_flags_on_selected_paragraphs_and_undoes() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "一\n二\n三"}));
run(&mut s, "select.all", json!({}));
let r = run(&mut s, "para.asianTypography", json!({"kinsoku": false, "wordWrap": false, "topLinePunct": true}));
assert_eq!(r["asianTypography"]["kinsoku"], false);
assert_eq!(r["asianTypography"]["autoSpaceDE"], true, "a flag left out keeps its default");
for p in s.doc.body.iter().filter_map(|b| b.as_para()) {
assert_eq!(
(p.props.kinsoku, p.props.word_wrap, p.props.top_line_punct, p.props.overflow_punct),
(Some(false), Some(false), Some(true), None)
);
}
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.kinsoku.is_none() && p.props.word_wrap.is_none()));
// Without flags it only reports them.
let r = run(&mut s, "para.asianTypography", json!({}));
assert_eq!(r["asianTypography"]["kinsoku"], true);
}
#[test]
fn manage_styles_lists_paragraph_character_linked_and_table_styles() {
let mut s = s();
let list = run(&mut s, "styles.manage", json!({"sort": "alphabetical"}));
let list = list.as_array().unwrap();
let find = |name: &str| list.iter().find(|x| x["name"] == name).unwrap_or_else(|| panic!("{name} listed"));
assert_eq!(find("Normal")["type"], "paragraph");
assert_eq!(find("Normal")["builtIn"], true);
assert_eq!(find("Normal")["inGallery"], true);
assert_eq!(find("Heading 1")["type"], "linked");
assert_eq!(find("Heading 1")["basedOn"], "Normal");
assert!(find("Heading 1")["format"]["size"].is_number(), "description formatting");
assert_eq!(find("Table Grid")["type"], "table");
assert!(list.iter().any(|x| x["type"] == "character"));
// The character half of a linked pair is listed once, as the linked style.
assert!(!list.iter().any(|x| x["id"] == "Heading1Char"));
let names: Vec<String> = list.iter().map(|x| x["name"].as_str().unwrap().to_lowercase()).collect();
assert!(names.windows(2).all(|w| w[0] <= w[1]), "alphabetical");
// Recommended order starts with Normal (priority 0); listing is not an edit.
let rec = run(&mut s, "styles.manage", json!({}));
assert_eq!(rec[0]["name"], "Normal");
assert!(s.run("styles.manage", &json!({"sort": "sideways"})).is_err());
assert!(!s.dirty);
}
#[test]
fn deleting_a_style_falls_back_to_its_base_and_undoes() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Styled"}));
run(&mut s, "styles.create", json!({"name": "Mine", "basedOn": "Heading 1"}));
run(&mut s, "styles.create", json!({"name": "Child", "basedOn": "Mine", "fromSelection": false}));
run(&mut s, "styles.apply", json!({"style": "Mine"}));
assert_eq!(para_style(&s, 0).as_deref(), Some("Mine"));
let r = run(&mut s, "styles.delete", json!({"style": "Mine"}));
assert_eq!(r["restyled"], 1);
assert!(s.doc.styles.get("Mine").is_none());
assert_eq!(para_style(&s, 0).as_deref(), Some("Heading1"), "falls back to its base");
assert_eq!(s.doc.styles.get("Child").and_then(|c| c.based_on.clone()).as_deref(), Some("Heading1"), "rebased");
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.styles.get("Mine").is_some());
assert_eq!(para_style(&s, 0).as_deref(), Some("Mine"));
// Built-in styles are refused, unknown ones are errors.
assert!(s.run("styles.delete", &json!({"style": "Heading 1"})).is_err());
assert!(s.run("styles.delete", &json!({"style": "No Such Style"})).is_err());
assert!(s.doc.styles.get("Heading1").is_some());
}
/// Manage Styles' Delete on a table style is Table Design's Delete Table Style: its tables take
/// Table Grid, styles based on it keep their look, and Word's built-in table styles are refused.
#[test]
fn deleting_a_table_style_from_manage_styles_uses_table_style_deletion() {
use wordcraft_doc::Rgb;
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
let (tp, _, _) = s.sel.focus.path.cell().unwrap();
run(&mut s, "table.newStyle", json!({"name": "Base", "headerRow": {"fill": "C00000"}}));
run(&mut s, "table.newStyle", json!({"name": "Child", "basedOn": "Base", "apply": false}));
let r = run(&mut s, "styles.delete", json!({"style": "Base"}));
assert_eq!((r["deleted"][0].as_str(), r["restyled"].as_u64()), (Some("Base"), Some(1)));
assert_eq!(s.doc.table(StoryRef::Body, &tp).unwrap().props.style.as_deref(), Some("TableGrid"));
assert_eq!(s.doc.styles.table_style("Child").unwrap().parts.header_fill, Some(Rgb(0xC0, 0, 0)), "the child keeps its look");
for st in s.doc.styles.styles.iter_mut() {
st.builtin = false;
}
assert!(s.run("styles.delete", &json!({"style": "Table Grid"})).is_err());
let list = run(&mut s, "styles.manage", json!({}));
let grid = list.as_array().unwrap().iter().find(|x| x["name"] == "Table Grid").unwrap();
assert_eq!(grid["builtIn"], true);
}
#[test]
fn style_visibility_round_trips_through_docx() {
let mut s = s();
run(&mut s, "styles.create", json!({"name": "Mine", "fromSelection": false}));
run(&mut s, "styles.setVisibility", json!({"style": "Heading 1", "gallery": false}));
run(&mut s, "styles.setVisibility", json!({"style": "Mine", "hidden": true}));
run(&mut s, "styles.setVisibility", json!({"style": "Heading 4", "gallery": true}));
assert!(s.run("styles.setVisibility", &json!({"style": "Mine"})).is_err());
let gallery: Vec<String> = s.doc.styles.gallery().iter().map(|x| x.id.clone()).collect();
assert!(!gallery.contains(&"Heading1".to_string()) && !gallery.contains(&"Mine".to_string()) && gallery.contains(&"Heading4".to_string()));
let bytes = crate::io::save_bytes("v.docx", &s.doc).unwrap();
let doc = crate::io::open_bytes("v.docx", &bytes).unwrap();
let st = |id: &str| doc.styles.get(id).cloned().unwrap_or_else(|| panic!("{id} kept"));
assert!(!st("Heading1").quick && !st("Heading1").hidden);
assert!(st("Mine").hidden && !st("Mine").builtin);
assert!(st("Heading4").quick);
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.styles.get("Heading4").is_some_and(|h| !h.quick), "undoable");
}
/// #146: the first/last column, total row and banded column regions apply in layout (later
/// regions win: the total row's fill beats the first column's) and round-trip through .docx.
#[test]
fn custom_table_style_column_and_total_regions() {
use wordcraft_doc::Rgb;
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 3, "cols": 4}));
run(
&mut s,
"table.look",
json!({"headerRow": false, "bandedRows": false, "firstColumn": true, "lastColumn": true, "totalRow": true, "bandedColumns": true}),
);
let r = run(
&mut s,
"table.newStyle",
json!({"name": "Ledger",
"firstColumn": {"fill": "112233", "bold": true},
"lastColumn": {"fill": "445566"},
"lastRow": {"fill": "778899", "borders": true},
"bandedColumns": {"fill": "ABCDEF"}}),
);
let id = r["id"].as_str().unwrap().to_string();
let fills = |s: &mut Session, c: Rgb| {
s.layout().pages[0].items.iter().filter(|i| matches!(i, crate::layout::Placed::Fill { color, .. } if *color == c)).count()
};
// 3 rows x 4 columns: the total row takes all 4 of its cells, the first and last columns the
// two cells above it, and the column band (column 2 of the inner columns 2 and 3) one each.
assert_eq!(fills(&mut s, Rgb(0x77, 0x88, 0x99)), 4, "total row");
assert_eq!(fills(&mut s, Rgb(0x11, 0x22, 0x33)), 2, "first column");
assert_eq!(fills(&mut s, Rgb(0x44, 0x55, 0x66)), 2, "last column");
assert_eq!(fills(&mut s, Rgb(0xAB, 0xCD, 0xEF)), 2, "first column band");
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
let p = back.styles.get(&id).unwrap().table.clone().unwrap();
assert_eq!(
(p.first_col_fill, p.first_col_chr.bold, p.last_col_fill, p.total_fill, p.col_band_fill),
(Some(Rgb(0x11, 0x22, 0x33)), Some(true), Some(Rgb(0x44, 0x55, 0x66)), Some(Rgb(0x77, 0x88, 0x99)), Some(Rgb(0xAB, 0xCD, 0xEF)))
);
assert!(p.total_borders.is_some_and(|b| b.any_visible()));
}
/// #146: deleting a custom table style re-bases the styles based on it (keeping their look),
/// puts its tables on Table Grid, refuses built-in styles, and undoes.
#[test]
fn delete_table_style_falls_back_and_undoes() {
use wordcraft_doc::Rgb;
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
let (tp, _, _) = s.sel.focus.path.cell().unwrap();
run(&mut s, "table.newStyle", json!({"name": "Base", "headerRow": {"fill": "C00000"}}));
run(&mut s, "table.newStyle", json!({"name": "Child", "basedOn": "Base", "apply": false, "wholeTable": {"bold": true}}));
let style = |s: &Session| s.doc.table(StoryRef::Body, &tp).unwrap().props.style.clone();
assert_eq!(style(&s).as_deref(), Some("Base"));
assert!(s.run("table.deleteStyle", &json!({"style": "Table Grid"})).is_err(), "built-in");
assert!(s.run("table.deleteStyle", &json!({"style": "Normal Table"})).is_err(), "built-in");
let r = run(&mut s, "table.deleteStyle", json!({}));
assert_eq!((r["deleted"].as_str(), r["tables"].as_u64()), (Some("Base"), Some(1)));
assert!(s.doc.styles.get("Base").is_none());
assert_eq!(style(&s).as_deref(), Some("TableGrid"), "tables fall back to Table Grid");
let child = s.doc.styles.table_style("Child").unwrap();
assert_eq!((child.parts.header_fill, child.chr.bold), (Some(Rgb(0xC0, 0, 0)), Some(true)), "the child keeps its look");
assert_eq!(s.doc.styles.get("Child").unwrap().based_on.as_deref(), Some("TableGrid"));
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.styles.get("Base").is_some());
assert_eq!(style(&s).as_deref(), Some("Base"));
assert_eq!(s.doc.styles.get("Child").unwrap().based_on.as_deref(), Some("Base"));
}
/// #146: a .docx may mark Word's built-in table styles as custom; they still can't be deleted, so
/// no table is left pointing at a style that's gone.
#[test]
fn builtin_table_styles_marked_custom_in_a_docx_cant_be_deleted() {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
for st in s.doc.styles.styles.iter_mut() {
st.builtin = false;
}
for name in ["Table Grid", "Normal Table", "TableGrid"] {
assert!(s.run("table.deleteStyle", &json!({"style": name})).is_err(), "{name}");
}
let (tp, _, _) = s.sel.focus.path.cell().unwrap();
let id = s.doc.table(StoryRef::Body, &tp).unwrap().props.style.clone().unwrap();
assert!(s.doc.styles.get(&id).is_some(), "the table's style still exists");
}
+32 -11
View File
@@ -66,6 +66,8 @@ pub enum Draw {
fill: Option<Rgb>,
stroke: Option<Rgb>,
stroke_width: f32,
/// Shadow, glow and soft edges (drawn with [`wordcraft_doc::effects::bands`]).
effects: wordcraft_doc::effects::ShapeEffects,
},
/// A vector path (a chart's or diagram's polygons, slices, lines): filled, then stroked.
Path {
@@ -211,8 +213,8 @@ fn item(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, out: &mu
Placed::Fill { rect, color } => out.push(Draw::Fill { rect: *rect, color: *color, alpha }),
Placed::Rule { x0, y0, x1, y1, border } => out.push(rule(*x0, *y0, *x1, *y1, border, alpha)),
Placed::Image { rect, media, crop, .. } => out.push(Draw::Image { rect: *rect, media: media.clone(), crop: *crop, alpha }),
Placed::Shape { rect, kind, fill, stroke, stroke_width } => {
out.push(Draw::Shape { rect: *rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width })
Placed::Shape { rect, kind, fill, stroke, stroke_width, effects } => {
out.push(Draw::Shape { rect: *rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width, effects: *effects })
}
Placed::Graphic { rect, graphic, .. } => out.extend(graphic_draws(doc, graphic, *rect, alpha)),
Placed::Cell { .. } | Placed::Object { .. } => {}
@@ -546,8 +548,25 @@ fn lines(
let rect = inline_rect(obj, cx, base, c.adv, c.obj_h);
match obj {
Some(InlineObject::Image { media, crop, .. }) => out.push(Draw::Image { rect, media: media.clone(), crop: *crop, alpha }),
Some(InlineObject::Shape { kind, fill, stroke, stroke_width, .. }) => {
out.push(Draw::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width })
Some(InlineObject::Shape { kind, fill, stroke, stroke_width, effects, .. }) => {
out.push(Draw::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width, effects: *effects })
}
Some(g @ InlineObject::Group { .. }) => {
for ([x, y, w, h], c) in g.group_rects(rect.x, rect.y, rect.w, rect.h) {
let rect = Rect::new(x, y, w, h);
match c {
InlineObject::Image { media, crop, .. } => out.push(Draw::Image { rect, media: media.clone(), crop: *crop, alpha }),
InlineObject::Shape { kind, fill, stroke, stroke_width, effects, .. } => out.push(Draw::Shape {
rect,
kind: *kind,
fill: *fill,
stroke: *stroke,
stroke_width: *stroke_width,
effects: *effects,
}),
_ => {}
}
}
}
Some(InlineObject::Graphic { graphic, alt, .. }) => {
out.push(Draw::Figure { alt: alt.clone(), kind: graphic.kind, draws: graphic_draws(doc, graphic, rect, alpha) })
@@ -722,9 +741,14 @@ fn graphic_draws(doc: &Document, g: &Graphic, rect: Rect, alpha: f32) -> Vec<Dra
let mut out = Vec::with_capacity(g.items.len());
for it in &g.items {
match it {
GraphicItem::Shape { rect: r, kind, fill, stroke, stroke_width } => {
out.push(Draw::Shape { rect: inside(r), kind: *kind, fill: *fill, stroke: *stroke, stroke_width: stroke_width * k })
}
GraphicItem::Shape { rect: r, kind, fill, stroke, stroke_width } => out.push(Draw::Shape {
rect: inside(r),
kind: *kind,
fill: *fill,
stroke: *stroke,
stroke_width: stroke_width * k,
effects: Default::default(),
}),
GraphicItem::Path { segs, fill, stroke, stroke_width } => {
let segs = page_segs(segs, |x, y| (rect.x + x * sx, rect.y + y * sy));
out.push(Draw::Path { segs, fill: *fill, stroke: *stroke, stroke_width: stroke_width * k })
@@ -867,9 +891,6 @@ pub fn text_color(c: &TextColor, background: Option<Rgb>) -> Rgb {
/// Where inline object `obj` is drawn, given its cluster's box (`adv` × `obj_h` standing on the
/// baseline at `cx`): inside the room kept for its effects.
pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f32, obj_h: f32) -> Rect {
let [l, t, r, b] = match obj {
Some(InlineObject::Image { float, .. } | InlineObject::Graphic { float, .. } | InlineObject::Shape { float, .. }) => float.effect_extent(),
_ => [0.0; 4],
};
let [l, t, r, b] = obj.and_then(InlineObject::frame).map_or([0.0; 4], |(_, _, float)| float.effect_extent());
Rect::new(cx + l, base - obj_h + t, (adv - l - r).max(0.0), (obj_h - t - b).max(0.0))
}
+103
View File
@@ -0,0 +1,103 @@
//! Asian typography line-breaking rules (the paragraph's `kinsoku` and `word_wrap` flags), applied
//! on top of the Unicode line-break opportunities (UAX #14).
//!
//! - Kinsoku on (the default): no line starts with closing East Asian punctuation (、。,)」…)
//! and none ends with opening punctuation ((「【…). UAX #14 already forbids most of these
//! breaks; the lists make sure of it for the standard Japanese/Chinese sets.
//! - Kinsoku off: a line may break between any two East Asian characters, punctuation included.
//! - Word wrap off: Latin words may break at any character.
//!
//! Only East Asian characters are in the lists, and the extra breaks need letters on both sides,
//! so text with no East Asian characters breaks exactly as before unless word wrap is off.
use unicode_segmentation::UnicodeSegmentation;
/// East Asian characters a line may not start with: closing brackets and quotes, the
/// ideographic comma and full stop, fullwidth and halfwidth punctuation, the middle dot,
/// iteration and sound marks.
const NO_START: &str = "、。,.・:;?!゛゜ヽヾゝゞ々〻〉》」』】〕〗〙〛〞〟)]}⦆。」、・゙゚%¢";
/// East Asian characters a line may not end with: opening brackets and quotes, currency signs.
const NO_END: &str = "〈《「『【〔〖〘〚〝([{⦅「$£¥";
/// Whether `c` is East Asian text: CJK ideographs, kana, bopomofo, CJK punctuation, and
/// fullwidth/halfwidth forms (not spaces, not combining sound marks).
pub fn is_east_asian(c: char) -> bool {
matches!(c as u32,
0x2E80..=0x2FFF // radicals, ideographic description
| 0x3001..=0x303F // CJK symbols and punctuation (not the ideographic space)
| 0x3040..=0x3098 // hiragana
| 0x309B..=0x30FF // sound marks, katakana
| 0x3100..=0x312F // bopomofo
| 0x3190..=0x31FF // kanbun, CJK strokes, katakana extensions
| 0x3400..=0x4DBF // CJK extension A
| 0x4E00..=0x9FFF // CJK unified ideographs
| 0xF900..=0xFAFF // compatibility ideographs
| 0xFE30..=0xFE4F // CJK compatibility forms
| 0xFF01..=0xFFEF // fullwidth and halfwidth forms
| 0x20000..=0x3FFFF) // supplementary ideographs
}
/// Adjust the break opportunities `opps` (byte offsets into `text`: a line may start there) for
/// the paragraph's kinsoku and word-wrap settings. The result is sorted and has no duplicates.
pub fn apply(text: &str, opps: &mut Vec<usize>, kinsoku: bool, word_wrap: bool) {
let next_char = |i: usize| text.get(i..).and_then(|t| t.chars().next());
let prev_char = |i: usize| text.get(..i).and_then(|t| t.chars().next_back());
if kinsoku {
opps.retain(|&i| !next_char(i).is_some_and(|c| NO_START.contains(c)) && !prev_char(i).is_some_and(|c| NO_END.contains(c)));
} else {
// Between any two East Asian characters.
for (i, c) in text.char_indices().skip(1) {
if is_east_asian(c) && prev_char(i).is_some_and(is_east_asian) {
opps.push(i);
}
}
}
if !word_wrap {
// Between any two letters or digits that are not East Asian (whole graphemes, so accents
// stay with their letters).
let latin = |g: &str| g.chars().next().is_some_and(|c| c.is_alphanumeric() && !is_east_asian(c));
let mut prev: Option<&str> = None;
for (i, g) in text.grapheme_indices(true) {
if prev.is_some_and(latin) && latin(g) {
opps.push(i);
}
prev = Some(g);
}
}
opps.sort_unstable();
opps.dedup();
}
#[cfg(test)]
mod tests {
use super::apply;
/// Where a line may start, as the text from there to the next opportunity.
fn breaks(text: &str, kinsoku: bool, word_wrap: bool) -> Vec<String> {
let mut opps: Vec<usize> = unicode_linebreak::linebreaks(text).map(|(i, _)| i).filter(|&i| i < text.len()).collect();
apply(text, &mut opps, kinsoku, word_wrap);
opps.iter().map(|&i| text.get(i..).and_then(|t| t.chars().next()).map(String::from).unwrap_or_default()).collect()
}
#[test]
fn kinsoku_and_word_wrap_break_opportunities() {
let ja = "日本語、「漢字」。テスト";
let on = breaks(ja, true, true);
for c in ["、", "」", "。", "漢"] {
assert!(!on.iter().any(|s| s == c), "kinsoku: no line starts with {c}: {on:?}");
}
assert!(on.iter().any(|s| s == "「"), "a line may start at an opening bracket: {on:?}");
assert!(on.iter().any(|s| s == "テ"), "{on:?}");
let off = breaks(ja, false, true);
for c in ["、", "」", "。", "漢"] {
assert!(off.iter().any(|s| s == c), "kinsoku off: a line may start with {c}: {off:?}");
}
// Latin text: unchanged by kinsoku, broken anywhere without word wrap.
let en = "Hello wonderful (world), 50%.";
assert_eq!(breaks(en, true, true), breaks(en, false, true));
assert_eq!(breaks(en, true, true), ["w", "(", "5"]);
let any = breaks("Hello café", true, false);
assert_eq!(any, ["e", "l", "l", "o", "c", "a", "f", "é"]);
}
}
+51 -41
View File
@@ -11,6 +11,7 @@
pub mod display;
pub mod fields;
pub mod hit;
pub mod kinsoku;
pub mod math;
pub mod para;
mod table;
@@ -96,6 +97,7 @@ pub enum Placed {
fill: Option<Rgb>,
stroke: Option<Rgb>,
stroke_width: f32,
effects: wordcraft_doc::effects::ShapeEffects,
},
/// A floating chart or diagram, drawn from its items inside `rect`. The object is the U+FFFC at
/// byte `off` of paragraph `path` (its alt text).
@@ -1143,14 +1145,7 @@ struct PageFrame<'a> {
/// A floating (not inline) picture, chart or shape: its size and placement.
fn floating(o: &InlineObject) -> Option<(f32, f32, &Float)> {
match o {
InlineObject::Image { w, h, float, .. } | InlineObject::Graphic { w, h, float, .. } | InlineObject::Shape { w, h, float, .. }
if float.wrap != Wrap::Inline =>
{
Some((*w, *h, float))
}
_ => None,
}
o.frame().filter(|(_, _, float)| float.wrap != Wrap::Inline)
}
/// Where a floating object goes, in the coordinates of the box `frame` places on the page.
@@ -1213,20 +1208,26 @@ fn rel_exclusions(excl: &[(Rect, bool)], x0: f32, y0: f32) -> Vec<para::Exclusio
.collect()
}
/// The item drawing floating object `o` at `rect`.
fn float_item(o: &InlineObject, rect: Rect, story: StoryRef, path: &[u32], off: usize) -> Option<Placed> {
/// The items drawing floating object `o` at `rect` (a group: its members).
fn float_items(o: &InlineObject, rect: Rect, story: StoryRef, path: &[u32], off: usize) -> Vec<Placed> {
match o {
InlineObject::Image { media, crop, .. } => {
Some(Placed::Image { rect, media: media.clone(), crop: *crop, story, path: Path(path.to_vec()), off })
vec![Placed::Image { rect, media: media.clone(), crop: *crop, story, path: Path(path.to_vec()), off }]
}
InlineObject::Shape { kind, fill, stroke, stroke_width, .. } => {
Some(Placed::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width })
InlineObject::Shape { kind, fill, stroke, stroke_width, effects, .. } => {
vec![Placed::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width, effects: *effects }]
}
InlineObject::Graphic { graphic, .. } => Some(Placed::Graphic { rect, graphic: graphic.clone(), story, path: Path(path.to_vec()), off }),
_ => None,
InlineObject::Graphic { graphic, .. } => vec![Placed::Graphic { rect, graphic: graphic.clone(), story, path: Path(path.to_vec()), off }],
InlineObject::Group { .. } => group_members(o, rect).into_iter().flat_map(|(r, c)| float_items(c, r, story, path, off)).collect(),
_ => Vec::new(),
}
}
/// A group's members, each with its rectangle when the group is at `rect`.
fn group_members(o: &InlineObject, rect: Rect) -> Vec<(Rect, &InlineObject)> {
o.group_rects(rect.x, rect.y, rect.w, rect.h).into_iter().map(|([x, y, w, h], c)| (Rect::new(x, y, w, h), c)).collect()
}
/// Position the floating objects anchored in `p` for a paragraph whose text starts at `y0` in
/// the current column, add their wrap areas to the page (replacing any from an earlier attempt,
/// from `excl_mark` on) and lay the paragraph out around them, with its list `label`.
@@ -1304,23 +1305,19 @@ fn place_objects(
if left_out(oi) {
continue;
}
let Some(
obj @ (InlineObject::Image { w, h, float, .. } | InlineObject::Graphic { w, h, float, .. } | InlineObject::Shape { w, h, float, .. }),
) = p.objects.get(oi)
else {
continue;
};
let Some(obj) = p.objects.get(oi) else { continue };
let Some((w, h, float)) = obj.frame() else { continue };
let floating = float.wrap != Wrap::Inline;
let rect = if floating {
floats.get(&oi).copied().unwrap_or_else(|| float_rect(at.page, at.col, at.para_y, *w, *h, float))
floats.get(&oi).copied().unwrap_or_else(|| float_rect(at.page, at.col, at.para_y, w, h, float))
} else {
let cx = x + line.cl_left(k).unwrap_or(0.0);
display::inline_rect(Some(obj), cx, y + (line.baseline - fl.top), c.adv, c.obj_h)
};
let layer = if float.wrap == Wrap::BehindText { &mut behind } else { &mut front };
// Floating ones are drawn here; inline ones with their line.
if floating && let Some(it) = float_item(obj, rect, story, path, c.start) {
layer.push(it);
if floating {
layer.extend(float_items(obj, rect, story, path, c.start));
}
let text_box = match obj {
InlineObject::Shape { story: Some(id), .. } if ctx.doc.parts.get(id).is_some_and(|p| p.kind == wordcraft_doc::PartKind::TextBox) => {
@@ -1337,16 +1334,30 @@ fn place_objects(
wrap: float.wrap,
origin: Point::new(at.col.0, at.para_y),
});
// Its text, unless the box budget says no (a box inside itself, too deep, too many).
let Some(id) = text_box.filter(|id| ctx.boxes.enter(*id)) else { continue };
let blocks = ctx.doc.parts.get(&id).map(|p| p.blocks.clone()).unwrap_or_default();
let inner = layout_box(ctx, StoryRef::Part(id), &blocks, &[], (rect.w - 2.0 * BOX_INSET_X).max(12.0), None, depth + 1, None).items;
ctx.boxes.leave();
let layer = if float.wrap == Wrap::BehindText { &mut behind } else { &mut front };
// Text that doesn't fit inside the margins is hidden, as in Word.
for mut it in fit_box(inner, rect.h - 2.0 * BOX_INSET_Y) {
it.translate(rect.x + BOX_INSET_X, rect.y + BOX_INSET_Y);
layer.push(it);
// Its text (a group: its text boxes'), unless the box budget says no (a box inside
// itself, too deep, too many).
let boxes: Vec<(u32, Rect)> = match obj {
InlineObject::Group { .. } => group_members(obj, rect)
.into_iter()
.filter_map(|(r, c)| {
c.text_box().filter(|id| ctx.doc.parts.get(id).is_some_and(|p| p.kind == wordcraft_doc::PartKind::TextBox)).map(|id| (id, r))
})
.collect(),
_ => text_box.map(|id| (id, rect)).into_iter().collect(),
};
for (id, rect) in boxes {
if !ctx.boxes.enter(id) {
continue;
}
let blocks = ctx.doc.parts.get(&id).map(|p| p.blocks.clone()).unwrap_or_default();
let inner = layout_box(ctx, StoryRef::Part(id), &blocks, &[], (rect.w - 2.0 * BOX_INSET_X).max(12.0), None, depth + 1, None).items;
ctx.boxes.leave();
let layer = if float.wrap == Wrap::BehindText { &mut behind } else { &mut front };
// Text that doesn't fit inside the margins is hidden, as in Word.
for mut it in fit_box(inner, rect.h - 2.0 * BOX_INSET_Y) {
it.translate(rect.x + BOX_INSET_X, rect.y + BOX_INSET_Y);
layer.push(it);
}
}
}
}
@@ -1357,12 +1368,14 @@ fn place_objects(
/// (on the box's cluster), as (cluster, note id).
fn para_notes(doc: &Document, p: &Paragraph, pl: &ParaLayout) -> Vec<(usize, u32)> {
let mut notes = pl.notes.clone();
if p.objects.iter().any(|o| matches!(o, InlineObject::Shape { story: Some(_), .. })) {
if p.objects.iter().any(|o| !o.text_boxes().is_empty()) {
for (ci, c) in pl.clusters.iter().enumerate() {
if let para::ClKind::Object(oi) = c.kind
&& let Some(InlineObject::Shape { story: Some(id), .. }) = p.objects.get(oi)
&& let Some(o) = p.objects.get(oi)
{
notes.extend(doc.notes_in_text_box(*id).into_iter().map(|n| (ci, n)));
for id in o.text_boxes() {
notes.extend(doc.notes_in_text_box(id).into_iter().map(|n| (ci, n)));
}
}
}
}
@@ -1803,10 +1816,7 @@ fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &Secti
/// Is an object floating (not laid out inline)?
pub fn is_floating(o: &InlineObject) -> bool {
match o {
InlineObject::Image { float, .. } | InlineObject::Graphic { float, .. } | InlineObject::Shape { float, .. } => float.wrap != Wrap::Inline,
_ => false,
}
o.is_floating()
}
/// Milliseconds since an arbitrary epoch (monotonic on native; the browser's clock on the web).
+341 -21
View File
@@ -11,7 +11,8 @@
use std::sync::Arc;
use wordcraft_doc::math::{
Arg, ColJc, FracKind, LimLoc, MAX_DEPTH, MClass, MNode, MRun, MScr, MSty, ScriptKind, is_integral, math_alnum, parse_linear,
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MClass, MNode, MRun, MScr, MSty, MathProps, ScriptKind, is_integral, math_alnum,
parse_linear,
};
use wordcraft_doc::props::{Rgb, TextColor};
use wordcraft_doc::resolve::ResolvedChar;
@@ -174,14 +175,16 @@ 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); `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, 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.
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;
@@ -213,6 +216,7 @@ 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;
@@ -307,6 +311,8 @@ 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 {
@@ -547,6 +553,7 @@ 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)),
@@ -556,6 +563,9 @@ impl Ctx {
}
}
}
if let Some((b, _)) = out.get_mut(first) {
b.brk = r.brk;
}
u += r.text.chars().count();
}
MNode::Nary { .. } | MNode::Func { .. } => {
@@ -1592,6 +1602,198 @@ 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();
@@ -1640,6 +1842,18 @@ 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).
@@ -1661,6 +1875,22 @@ 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() {
@@ -1681,15 +1911,7 @@ fn gaps_between(atoms: &[Bx], st: St, em: f32) -> Vec<f32> {
}
}
}
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
cls
}
/// Atoms side by side with inter-atom spacing.
@@ -1745,7 +1967,96 @@ mod tests {
}
fn lay(lin: &str, display: bool) -> MathLayout {
layout_equation(&Math::default(), lin, &rc(), display, 400.0, &mut 0)
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);
}
#[test]
@@ -1823,7 +2134,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, &mut 0);
let m = layout_equation(&Math { nodes: vec![n.clone()], ..Default::default() }, "", &rc(), true, 400.0, &MathProps::default(), &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 {
@@ -1837,7 +2148,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, &mut 0);
let m = layout_equation(&math, "", &rc(), true, 400.0, &MathProps::default(), &mut 0);
// Walking the caret through the equation, every position has a slot.
let mut p = MathPos::default();
let mut seen = 0;
@@ -1861,17 +2172,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, &mut 0);
let m = layout_equation(&math, "", &rc(), true, 400.0, &MathProps::default(), &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, &mut 0);
let empty = layout_equation(&Math::default(), "", &rc(), true, 400.0, &MathProps::default(), &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, &mut 0);
let m = layout_equation(&math, "", &rc(), true, 400.0, &MathProps::default(), &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();
@@ -1882,7 +2193,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, &mut n);
let _ = layout_equation(&auto, "", &rc(), true, 400.0, &MathProps::default(), &mut n);
assert_eq!(n, 5);
assert_eq!(auto_numbers(&auto, true), 1);
}
@@ -1905,7 +2216,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, &mut 0);
let _ = layout_equation(&Math { nodes: n, ..Default::default() }, "", &rc(), true, 400.0, &MathProps::default(), &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![] },
@@ -1914,7 +2225,16 @@ 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, &mut 0);
let m = layout_equation(&Math { nodes: empty, ..Default::default() }, "", &rc(), true, 400.0, &MathProps::default(), &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());
}
}
}
}
+12 -11
View File
@@ -640,6 +640,7 @@ 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() {
@@ -732,10 +733,10 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
let k = obj_index;
obj_index += 1;
match p.objects.get(k) {
Some(InlineObject::Image { w, h, float, .. })
| Some(InlineObject::Graphic { w, h, float, .. })
| Some(InlineObject::Shape { w, h, float, .. }) => {
if float.wrap == wordcraft_doc::para::Wrap::Inline {
Some(o) if o.is_drawing() => {
if let Some((w, h, float)) = o.frame()
&& float.wrap == wordcraft_doc::para::Wrap::Inline
{
let maxw = (env.width - rp.indent_left.max(0.0) - rp.indent_right.max(0.0)).max(18.0);
let (w, h) = (w.clamp(1.0, 4000.0), h.clamp(1.0, 4000.0));
let s = if w > maxw { maxw / w } else { 1.0 };
@@ -768,7 +769,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, &mut eq_counter);
let ml = crate::math::layout_equation(math, linear, &rc, *display, avail, &math_props, &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));
@@ -824,19 +825,19 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
let to_para = |i: usize| -> Option<usize> {
if left.is_empty() { Some(i) } else { ends.binary_search_by_key(&i, |e| e.0).ok().and_then(|k| ends.get(k)).map(|e| e.1) }
};
let mut opps = std::collections::HashSet::new();
let mut found = Vec::new();
for (i, o) in unicode_linebreak::linebreaks(&text) {
// Word keeps "and/or" and web addresses whole: no break right after a slash (a word
// too long for the line still breaks anywhere).
let after_slash =
text.get(..i).is_some_and(|t| t.ends_with('/')) && text.get(i..).and_then(|t| t.chars().next()).is_some_and(char::is_alphanumeric);
if (o == unicode_linebreak::BreakOpportunity::Allowed || i < text.len())
&& !after_slash
&& let Some(end) = to_para(i)
{
opps.insert(end);
if (o == unicode_linebreak::BreakOpportunity::Allowed || i < text.len()) && !after_slash {
found.push(i);
}
}
// Asian typography: kinsoku and breaking Latin words anywhere.
crate::kinsoku::apply(&text, &mut found, rp.kinsoku, rp.word_wrap);
let opps: std::collections::HashSet<usize> = found.into_iter().filter_map(to_para).collect();
for c in &mut b.clusters {
c.break_after = opps.contains(&c.end) || matches!(c.kind, ClKind::Object(_) | ClKind::Tab);
}
+97 -38
View File
@@ -4,7 +4,7 @@
use wordcraft_doc::numbering::Counters;
use wordcraft_doc::props::{Align, Border, Borders, HeightRule, Rgb, TextDirection, VAlign, VMerge};
use wordcraft_doc::styles::TableStyleProps;
use wordcraft_doc::styles::{TableStyleParts, TableStyleProps};
use wordcraft_doc::{Block, Blocks, Paragraph, StoryRef, Table};
use wordcraft_geom::Rect;
@@ -40,8 +40,9 @@ struct Turned {
across: f32,
/// List counters and equation count before the cell, to lay it out again the same way.
snap: (Counters, u32),
/// Its formatting region (header row, total row, first column, row band) and cell index in the row.
region: (bool, bool, bool, bool),
/// Its formatting region (header row, total row, first column, last column, row band, column
/// band) and cell index in the row.
region: Region,
ci: usize,
/// Top margin plus top border band.
top: f32,
@@ -143,27 +144,20 @@ fn measure_para(p: &wordcraft_doc::Paragraph, env: &crate::para::ParaEnv) -> (f3
pub fn measure_table_columns(doc: &wordcraft_doc::Document, t: &Table) -> Vec<(f32, f32)> {
use wordcraft_doc::Block;
let style = t.props.style.as_deref().and_then(|id| doc.styles.table_style(id));
let parts = style.as_ref().map(|s| &s.parts);
let margins_def = default_margins(t, style.as_ref());
let ncols = t.cols().clamp(1, wordcraft_doc::table::MAX_COLS);
let fields = crate::FieldCtx::default();
let header_rows = t.props.look.header_row;
let band_size = parts.and_then(|p| p.band_size).unwrap_or(1).clamp(1, 1000) as usize;
let nrows = t.rows.len();
let mut cols = vec![(0.0f32, 0.0f32); ncols];
// Cells spanning several columns: (first column, span, min, max), applied after the others.
let mut spanned: Vec<(usize, usize, f32, f32)> = Vec::new();
let mut budget = MEASURE_BUDGET;
'rows: for (ri, row) in t.rows.iter().enumerate() {
let is_header = header_rows && ri == 0;
let is_total = t.props.look.total_row && ri + 1 == nrows && nrows > 1;
let band = t.props.look.banded_rows && !is_header && (ri.saturating_sub(usize::from(header_rows)) / band_size).is_multiple_of(2);
let mut g = 0usize;
for cell in &row.cells {
let span = cell.span();
let margins = cell.props.margins.unwrap_or(margins_def);
let side = margins[1].max(0.0) + margins[3].max(0.0);
let text = style.as_ref().map(|st| region_text(st, (is_header, is_total, t.props.look.first_column && g == 0, band)));
let text = style.as_ref().map(|st| region_text(st, region_of(t, style.as_ref(), ri, g, span, ncols)));
let env = crate::para::ParaEnv {
doc,
width: MEASURE_WIDE,
@@ -261,6 +255,48 @@ fn first_cell_left_margin_in(t: &Table, def: [f32; 4]) -> f32 {
t.rows.first().and_then(|r| r.cells.first()).and_then(|c| c.props.margins).unwrap_or(def)[1]
}
/// A cell's borders from the table style's conditional regions it is in (`regions`: header row,
/// total row, first column, last column, row band, column band), later regions winning. Row
/// regions give their left/right edges at the table's sides and `inside_v` between cells; column
/// regions their top/bottom edges at the table's top and bottom and `between` between rows.
fn region_borders(p: &TableStyleParts, (ri, nrows): (usize, usize), (first_g, last_g): (bool, bool), regions: [bool; 6]) -> Borders {
let [header, total, first_col, last_col, band, col_band] = regions;
let row = |b: Option<Borders>| {
b.map(|b| Borders {
top: b.top,
bottom: b.bottom,
left: if first_g { b.left } else { b.inside_v },
right: if last_g { b.right } else { b.inside_v },
between: None,
inside_v: None,
})
};
let col = |b: Option<Borders>| {
b.map(|b| Borders {
top: if ri == 0 { b.top } else { b.between },
bottom: if ri + 1 >= nrows { b.bottom } else { b.between },
left: b.left,
right: b.right,
between: None,
inside_v: None,
})
};
let mut out = Borders::default();
for (on, b) in [
(col_band, col(p.col_band_borders)),
(band, row(p.band_borders)),
(first_col, col(p.first_col_borders)),
(last_col, col(p.last_col_borders)),
(header, row(p.header_borders)),
(total, row(p.total_borders)),
] {
if on && let Some(b) = b {
out.overlay(&b);
}
}
out
}
pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], avail: f32, depth: usize) -> TableLayout {
let style = table_style(ctx, t);
let parts = style.as_ref().map(|s| &s.parts);
@@ -362,15 +398,6 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
let is_header = header_rows && ri == 0;
let is_total = t.props.look.total_row && ri + 1 == nrows && nrows > 1;
let band = banded(t, style.as_ref(), ri);
// The header row's or the odd band's own cell borders (conditional formatting).
let region_borders = if is_header {
parts.and_then(|p| p.header_borders)
} else if band {
parts.and_then(|p| p.band_borders)
} else {
None
}
.unwrap_or_default();
for (ci, cell) in row.cells.iter().enumerate() {
let span = cell.span();
let x0 = colx.get(g).copied().unwrap_or(acc);
@@ -378,15 +405,31 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
let margins = cell.props.margins.unwrap_or(margins_def);
let cw = (x1 - x0 - margins[1] - margins[3]).max(4.0);
let mut fill = cell.props.shading;
let region = region_of(t, style.as_ref(), ri, g);
let region = region_of(t, style.as_ref(), ri, g, span, ncols);
let (_, _, first_col, last_col, _, col_band) = region;
// Conditional formatting, later regions winning (ECMA-376 §17.7.6): whole table,
// column bands, row bands, first/last column, header/total row.
if let Some(p) = parts {
if is_header {
fill = fill.or(p.header_fill);
}
if band && fill.is_none() {
fill = p.band_fill;
}
fill = fill.or(p.fill);
let row_fill = if is_header {
p.header_fill
} else if is_total {
p.total_fill
} else {
None
};
let col_fill = if first_col {
p.first_col_fill
} else if last_col {
p.last_col_fill
} else {
None
};
fill = fill
.or(row_fill)
.or(col_fill)
.or(if band { p.band_fill } else { None })
.or(if col_band { p.col_band_fill } else { None })
.or(p.fill);
}
if let Some(st) = &style
&& !cell_text.iter().any(|(r, _)| *r == region)
@@ -402,8 +445,10 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
let edge =
|own: Option<Border>, outer: bool, outer_b: Option<Border>, inner_b: Option<Border>| own.or(if outer { outer_b } else { inner_b });
let cb = cell.props.borders.unwrap_or_default();
let rb = region_borders;
let (first_g, last_g) = (g == 0, g + span >= ncols);
let rb = parts
.map(|p| region_borders(p, (ri, nrows), (first_g, last_g), [is_header, is_total, first_col, last_col, band, col_band]))
.unwrap_or_default();
let mut borders = Borders {
top: edge(cb.top.or(rb.top), ri == 0, tb.top, tb.between),
bottom: edge(cb.bottom.or(rb.bottom), ri + 1 == nrows, tb.bottom, tb.between),
@@ -412,7 +457,10 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
between: None,
inside_v: None,
};
if is_total && let Some(b) = parts.and_then(|p| p.total_border_top) {
if is_total
&& parts.is_none_or(|p| p.total_borders.is_none_or(|b| b.top.is_none()))
&& let Some(b) = parts.and_then(|p| p.total_border_top)
{
borders.top = Some(b);
}
// Word keeps the cell's text clear of its top border (and the last row's of its bottom
@@ -763,12 +811,16 @@ fn split_rect(r: Rect, cut: f32, shift: f32) -> (Option<Rect>, Option<Rect>) {
}
/// A cell's region for table-style conditional formatting: (header row, total row, first column,
/// banded row).
type Region = (bool, bool, bool, bool);
/// last column, banded row, banded column).
type Region = (bool, bool, bool, bool, bool, bool);
fn region_of(t: &Table, style: Option<&TableStyleProps>, ri: usize, g: usize) -> Region {
/// The region of the cell at grid column `g` spanning `span` of `ncols` columns in row `ri`.
fn region_of(t: &Table, style: Option<&TableStyleProps>, ri: usize, g: usize, span: usize, ncols: usize) -> Region {
let (look, nrows) = (&t.props.look, t.rows.len());
(look.header_row && ri == 0, look.total_row && ri + 1 == nrows && nrows > 1, look.first_column && g == 0, banded(t, style, ri))
let first_col = look.first_column && g == 0;
let last_col = look.last_column && g.saturating_add(span) >= ncols && ncols > 1;
let col_band = look.banded_columns && !first_col && g.saturating_sub(usize::from(look.first_column)).is_multiple_of(2);
(look.header_row && ri == 0, look.total_row && ri + 1 == nrows && nrows > 1, first_col, last_col, banded(t, style, ri), col_band)
}
/// Whether row `ri` is in an odd band of the table style's banded rows (bands of the style's
@@ -779,16 +831,23 @@ fn banded(t: &Table, style: Option<&TableStyleProps>, ri: usize) -> bool {
look.banded_rows && !(look.header_row && ri == 0) && (ri.saturating_sub(usize::from(look.header_row)) / size).is_multiple_of(2)
}
/// The table style's text formatting in `region`: the whole table's, then the band's, the
/// column's and the row's (later regions win, ECMA-376 §17.7.6).
fn region_text(st: &TableStyleProps, (header, total, first_col, band): Region) -> CellText {
/// The table style's text formatting in `region`: the whole table's, then the column band's, the
/// row band's, the first/last column's and the header/total row's (later regions win,
/// ECMA-376 §17.7.6).
fn region_text(st: &TableStyleProps, (header, total, first_col, last_col, band, col_band): Region) -> CellText {
let mut chr = st.chr.clone();
if col_band {
chr.overlay(&st.parts.col_band_chr);
}
if band {
chr.overlay(&st.parts.band_chr);
}
if first_col {
chr.overlay(&st.parts.first_col_chr);
}
if last_col {
chr.overlay(&st.parts.last_col_chr);
}
if header {
chr.overlay(&st.parts.header_chr);
} else if total {
@@ -810,7 +869,7 @@ fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, dep
for cell in &row.cells {
let span = cell.span();
if cell.props.vmerge != VMerge::Continue {
let text = style.map(|st| region_text(st, region_of(t, style, ri, g)));
let text = style.map(|st| region_text(st, region_of(t, style, ri, g, cell.span(), cols.len())));
let (lo, hi) = blocks_content(ctx, &cell.blocks, text.as_ref(), depth);
any |= hi > 0.0;
let m = cell.props.margins.unwrap_or(margins_def);
+53 -2
View File
@@ -231,6 +231,7 @@ fn many_hidden_float_anchors_lay_out_in_linear_time() {
stroke_width: 1.0,
float,
story: None,
effects: Default::default(),
};
let obj = wordcraft_doc::para::OBJ.to_string();
let mut p = wordcraft_doc::Paragraph::with_text("Text ", Default::default());
@@ -1046,6 +1047,7 @@ fn text_wraps_around_square_float() {
stroke_width: 1.0,
float,
story: None,
effects: Default::default(),
};
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
let l = lay(&d);
@@ -1083,6 +1085,7 @@ fn deleted_float_leaves_no_wrap_area_without_markup() {
stroke_width: 1.0,
float,
story: None,
effects: Default::default(),
};
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
let obj = wordcraft_doc::para::OBJ.len_utf8();
@@ -1134,6 +1137,7 @@ fn hidden_float_leaves_no_wrap_area_when_hidden_text_is_not_shown() {
stroke_width: 1.0,
float,
story: None,
effects: Default::default(),
};
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
let obj = wordcraft_doc::para::OBJ.len_utf8();
@@ -1170,6 +1174,7 @@ fn line_numbers_borders_text_boxes() {
stroke_width: 1.0,
float: Default::default(),
story: Some(id),
effects: Default::default(),
};
d.insert_object(&Pos::body(2, 5), tb, &Default::default()).unwrap();
let l = lay(&d);
@@ -1463,6 +1468,7 @@ fn text_box_at(d: &mut Document, pos: &Pos, text: &str, w: f32, h: f32, float: w
stroke_width: 0.75,
float,
story: Some(id),
effects: Default::default(),
};
d.insert_object(pos, shape, &Default::default()).unwrap();
id
@@ -1533,6 +1539,7 @@ fn presses_grab_pictures_anywhere_and_text_boxes_by_their_border() {
stroke_width: 1.0,
float: Default::default(),
story: None,
effects: Default::default(),
};
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
let l = lay(&d);
@@ -1752,6 +1759,7 @@ fn box_fan_out(levels: usize, fan: usize) -> Document {
stroke_width: 0.0,
float: Default::default(),
story: Some(story),
effects: Default::default(),
};
for (k, id) in ids.iter().enumerate() {
let next = *ids.get(k + 1).unwrap_or(id);
@@ -1815,7 +1823,17 @@ fn a_text_box_inside_a_text_box_still_shows_its_text() {
}
fn picture(w: f32, h: f32, float: wordcraft_doc::para::Float) -> InlineObject {
InlineObject::Shape { kind: wordcraft_doc::para::ShapeKind::Rectangle, w, h, fill: None, stroke: None, stroke_width: 1.0, float, story: None }
InlineObject::Shape {
kind: wordcraft_doc::para::ShapeKind::Rectangle,
w,
h,
fill: None,
stroke: None,
stroke_width: 1.0,
float,
story: None,
effects: Default::default(),
}
}
#[test]
@@ -1953,7 +1971,17 @@ fn lines_with_tabs_never_shrink() {
}
fn rect_shape(w: f32, h: f32, float: wordcraft_doc::para::Float) -> InlineObject {
InlineObject::Shape { kind: wordcraft_doc::para::ShapeKind::Rectangle, w, h, fill: None, stroke: None, stroke_width: 1.0, float, story: None }
InlineObject::Shape {
kind: wordcraft_doc::para::ShapeKind::Rectangle,
w,
h,
fill: None,
stroke: None,
stroke_width: 1.0,
float,
story: None,
effects: Default::default(),
}
}
fn shapes(items: &[Placed]) -> Vec<Rect> {
@@ -2121,6 +2149,7 @@ fn floating_header_text_box_leaves_the_body_at_the_top_margin() {
stroke_width: 0.75,
float,
story: Some(story),
effects: Default::default(),
};
anchor.insert_object(0, tb, &Default::default()).unwrap();
}
@@ -2680,3 +2709,25 @@ fn a_split_row_moves_shading_below_the_cut_with_it() {
let nested = moved.get(1).copied().unwrap_or_default();
assert!(nested.0 > 0.0 && (nested.1 - 50.0).abs() < 0.01, "the nested shading keeps its place and size: {moved:?}");
}
#[test]
fn kinsoku_keeps_a_manual_line_break_before_a_closing_bracket() {
// #269: kinsoku forbids a line starting with 」, but a manual line break (Shift+Enter) right
// before one still ends the line there.
let text = "日本語\n」テスト";
let mut d = Document::from_text("");
d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))];
let l = lay(&d);
let mut lines = Vec::new();
for p in &l.pages {
for it in &p.items {
if let Placed::Lines { para, l0, l1, .. } = it {
lines.extend(para.lines[*l0..*l1].iter().map(|line| (line.start, line.end)));
}
}
}
let bracket = text.find('」').unwrap();
assert_eq!(lines.len(), 2, "{lines:?}");
assert_eq!(lines[0].1, LineEnd::LineBreak, "{lines:?}");
assert_eq!(lines[1].0, bracket, "the second line starts at the bracket: {lines:?}");
}
+58 -1
View File
@@ -321,6 +321,46 @@ fn rect_path(r: &Rect) -> Option<Path> {
pb.finish()
}
/// `r` grown by `g` points on every side and moved by (`dx`, `dy`).
fn grown(r: &Rect, g: f32, dx: f32, dy: f32) -> Rect {
Rect { x: r.x - g + dx, y: r.y - g + dy, w: r.w + 2.0 * g, h: r.h + 2.0 * g }
}
/// A shape's shadow and glow, behind it: bands of its silhouette (the same approximation of a
/// blur as the raster renderer, see [`wordcraft_doc::effects::bands`]).
fn shape_effects(s: &mut Surface, kind: ShapeKind, rect: &Rect, fx: &wordcraft_doc::effects::ShapeEffects, filled: bool, stroked: bool, sw: f32) {
let sw = if sw.is_finite() { sw.clamp(0.25, 200.0) } else { 0.75 };
let silhouette = |s: &mut Surface, c: Rgb, a: f32, g: f32, dx: f32, dy: f32| {
if filled {
let grow = g + if stroked { sw / 2.0 } else { 0.0 };
if let Some(p) = shape_path(kind, &grown(rect, grow, dx, dy)) {
s.set_stroke(None);
s.set_fill(Some(fill(c, a)));
s.draw_path(&p);
}
} else if sw + 2.0 * g > 0.05
&& let Some(p) = shape_path(kind, &grown(rect, 0.0, dx, dy))
{
s.set_fill(None);
s.set_stroke(Some(Stroke { paint: rgb::Color::new(c.0, c.1, c.2).into(), width: sw + 2.0 * g, opacity: norm(a), ..Default::default() }));
s.draw_path(&p);
}
};
if let Some(sh) = fx.shadow {
let (dx, dy) = sh.offset();
for (g, a) in wordcraft_doc::effects::bands(-sh.blur / 2.0, sh.blur / 2.0, sh.opacity()) {
silhouette(s, sh.color, a, g, dx, dy);
}
}
if let Some(gl) = fx.glow {
for (g, a) in wordcraft_doc::effects::bands(0.0, gl.size, gl.opacity()) {
silhouette(s, gl.color, a, g, 0.0, 0.0);
}
}
s.set_fill(None);
s.set_stroke(None);
}
/// Outline of a basic shape in a rectangle (same geometry as the raster renderer).
fn shape_path(kind: ShapeKind, r: &Rect) -> Option<Path> {
if !(ok(r.x) && ok(r.y) && ok(r.w) && ok(r.h)) || r.w <= 0.0 || r.h <= 0.0 {
@@ -704,9 +744,26 @@ impl Exporter<'_> {
s.set_fill(None);
s.set_stroke(None);
}
Draw::Shape { rect, kind, fill: f, stroke, stroke_width } => {
Draw::Shape { rect, kind, fill: f, stroke, stroke_width, effects } => {
let Some(p) = shape_path(*kind, rect) else { return };
let can_fill = *kind != ShapeKind::Line;
let fx = effects.sanitized();
if !fx.is_empty() && (f.is_some() && can_fill || stroke.is_some()) {
shape_effects(s, *kind, rect, &fx, f.is_some() && can_fill, stroke.is_some(), *stroke_width);
}
// Soft edges: the fill fades out toward the outline (which fades with it).
if let (Some(rad), Some(c), true) = (fx.soft_edge, f, can_fill) {
let rad = rad.min(rect.w.min(rect.h) / 2.0).max(0.0);
s.set_stroke(None);
for (g, a) in wordcraft_doc::effects::bands(-rad, 0.0, 1.0) {
if let Some(p) = shape_path(*kind, &grown(rect, g, 0.0, 0.0)) {
s.set_fill(Some(fill(*c, a)));
s.draw_path(&p);
}
}
s.set_fill(None);
return;
}
s.set_fill(f.filter(|_| can_fill).map(|c| fill(c, 1.0)));
s.set_stroke(stroke.map(|c| Stroke {
paint: rgb::Color::new(c.0, c.1, c.2).into(),
+100 -1
View File
@@ -639,10 +639,54 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl
ctx.stroke_path(&path);
}
}
Draw::Shape { rect, kind, fill, stroke, stroke_width } => {
Draw::Shape { rect, kind, fill, stroke, stroke_width, effects } => {
let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64);
let path = shape_path(*kind, r);
ctx.set_transform(view);
let fx = effects.sanitized();
let can_fill = *kind != ShapeKind::Line && fill.is_some();
if !fx.is_empty() && (can_fill || stroke.is_some()) {
let sw = if stroke_width.is_finite() { stroke_width.clamp(0.25, 200.0) as f64 } else { 0.75 };
// The shape's silhouette grown by `g` points and moved by `d`, in the current paint.
let silhouette = |ctx: &mut RenderContext, g: f32, d: kurbo::Vec2| {
let g = g as f64;
if can_fill {
let grow = g + if stroke.is_some() { sw / 2.0 } else { 0.0 };
let rr = r.inflate(grow, grow) + d;
if rr.width() > 0.0 && rr.height() > 0.0 {
ctx.fill_path(&shape_path(*kind, rr));
}
} else if sw + 2.0 * g > 0.05 {
ctx.set_stroke(kurbo::Stroke::new(sw + 2.0 * g));
ctx.stroke_path(&shape_path(*kind, r + d));
}
};
if let Some(sh) = fx.shadow {
let (dx, dy) = sh.offset();
for (g, a) in wordcraft_doc::effects::bands(-sh.blur / 2.0, sh.blur / 2.0, sh.opacity()) {
ctx.set_paint(color(opts.ink(sh.color), a));
silhouette(ctx, g, kurbo::Vec2::new(dx as f64, dy as f64));
}
}
if let Some(gl) = fx.glow {
for (g, a) in wordcraft_doc::effects::bands(0.0, gl.size, gl.opacity()) {
ctx.set_paint(color(opts.ink(gl.color), a));
silhouette(ctx, g, kurbo::Vec2::ZERO);
}
}
}
// Soft edges: the fill fades out toward the outline (which fades with it).
if let (Some(rad), Some(f), true) = (fx.soft_edge, fill, can_fill) {
let rad = rad.min(rect.w.min(rect.h) / 2.0).max(0.0);
for (g, a) in wordcraft_doc::effects::bands(-rad, 0.0, 1.0) {
let rr = r.inflate(g as f64, g as f64);
if rr.width() > 0.0 && rr.height() > 0.0 {
ctx.set_paint(color(opts.ink(*f), a));
ctx.fill_path(&shape_path(*kind, rr));
}
}
return;
}
if let Some(f) = fill {
ctx.set_paint(color(opts.ink(*f), 1.0));
ctx.fill_path(&path);
@@ -774,6 +818,61 @@ mod tests {
assert!(ink(TextDirection::Up) > 30, "up");
}
/// Shape Effects (#275): an outer shadow paints dark pixels beside the shape on the side it is
/// cast to, and none on the other; a glow surrounds it.
#[test]
fn shape_shadow_is_cast_offset_from_the_shape() {
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
use wordcraft_doc::para::InlineObject;
let draw = |effects: ShapeEffects| {
let mut d = Document::from_text("");
let shape = InlineObject::Shape {
kind: ShapeKind::Rectangle,
w: 100.0,
h: 60.0,
fill: Some(Rgb(0, 0, 255)),
stroke: None,
stroke_width: 0.0,
float: Default::default(),
story: None,
effects,
};
let at = wordcraft_doc::Pos { story: wordcraft_doc::StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 0 };
d.insert_object(&at, shape, &Default::default()).unwrap();
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
render_page(&d, &l.pages[0], 1.0, &RenderOptions::default())
};
let plain = draw(ShapeEffects::default());
let blue = |img: &Rendered| {
let px: Vec<(u32, u32)> = (0..612)
.flat_map(|x| (0..400).map(move |y| (x, y)))
.filter(|(x, y)| matches!(img.pixel(*x, *y), [r, _, b, _] if r < 40 && b > 200))
.collect();
let (x0, x1) = (px.iter().map(|p| p.0).min().unwrap(), px.iter().map(|p| p.0).max().unwrap());
let (y0, y1) = (px.iter().map(|p| p.1).min().unwrap(), px.iter().map(|p| p.1).max().unwrap());
(x0, y0, x1, y1)
};
let (x0, y0, x1, y1) = blue(&plain);
assert!(x1 - x0 > 90 && y1 - y0 > 50, "{:?}", (x0, y0, x1, y1));
// Grey (not white, not blue) pixels in a strip just outside an edge.
let grey = |img: &Rendered, xs: std::ops::Range<u32>, ys: std::ops::Range<u32>| {
xs.flat_map(|x| ys.clone().map(move |y| (x, y)))
.filter(|(x, y)| matches!(img.pixel(*x, *y), [r, g, b, _] if r < 200 && r == g && g == b))
.count()
};
let right = |img: &Rendered| grey(img, x1 + 2..x1 + 6, y0 + 10..y1);
let left = |img: &Rendered| grey(img, x0.saturating_sub(6)..x0.saturating_sub(2), y0 + 10..y1);
assert_eq!((right(&plain), left(&plain)), (0, 0));
let shadow = Shadow { color: Rgb::BLACK, transparency: 30.0, blur: 2.0, distance: 8.0, angle: 0.0 };
let img = draw(ShapeEffects { shadow: Some(shadow), ..Default::default() });
assert_eq!(blue(&img), (x0, y0, x1, y1), "the shape itself is unchanged");
assert!(right(&img) > 100, "shadow to the right: {}", right(&img));
assert_eq!(left(&img), 0, "nothing on the left");
let img = draw(ShapeEffects { glow: Some(Glow { color: Rgb(255, 0, 0), size: 6.0, transparency: 0.0 }), ..Default::default() });
let reddish = |x: u32, y: u32| matches!(img.pixel(x, y), [r, g, _, _] if r > 200 && g < 200);
assert!(reddish(x1 + 2, (y0 + y1) / 2) && reddish(x0 - 2, (y0 + y1) / 2), "glow on both sides");
}
#[test]
fn stem_darkening_follows_the_macos_curve() {
assert_eq!(stem_darkening(0.0), 0.0);
+18
View File
@@ -300,6 +300,24 @@ 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")] {
+563 -76
View File
@@ -1,13 +1,16 @@
//! Dialogs: Font, Paragraph, Find & Replace, Go To, Insert Table, Page Setup, Link, Bookmark,
//! Word Count, Zoom, Watermark, New/Modify Style, New/Modify Table Style, Table 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.
//! 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.
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};
@@ -45,6 +48,15 @@ pub enum Dialog {
keep_lines: bool,
page_break: bool,
widow: bool,
/// Page: 0 Indents and Spacing, 1 Line and Page Breaks, 2 Asian Typography.
tab: u8,
/// Asian Typography as the model has it (`para.asianTypography`): kinsoku, word wrap,
/// hanging punctuation, compress punctuation at line start, space between Asian and
/// Latin text, space between Asian text and numbers.
asian: [bool; 6],
/// `asian` when the dialog opened: OK sets only the flags that changed.
#[serde(skip)]
asian_was: [bool; 6],
},
Find {
query: String,
@@ -89,8 +101,13 @@ pub enum Dialog {
NewStyle {
name: String,
based_on: String,
/// Opened from Manage Styles, which comes back when this closes.
#[serde(skip)]
back: bool,
},
ModifyStyle {
#[serde(skip)]
back: bool,
id: String,
name: String,
font: String,
@@ -101,6 +118,12 @@ pub enum Dialog {
before: f32,
after: f32,
},
/// Manage Styles: every style with its preview and description; modify, create, delete and
/// show or hide them.
ManageStyles {
alphabetical: bool,
selected: String,
},
/// Table Design › New / Modify Table Style: one formatting region at a time.
TableStyle {
/// The style being modified; `None` creates one.
@@ -108,13 +131,13 @@ pub enum Dialog {
name: String,
/// Style id.
based_on: String,
/// Index into `regions`: whole table, header row, banded rows.
/// Index into `regions` (see [`REGION_LABELS`]).
region: usize,
regions: Box<[TableRegion; 3]>,
regions: Box<[TableRegion; 7]>,
/// What the regions showed when the dialog opened (or the base style changed): only
/// changes are sent, so everything else stays inherited.
#[serde(skip)]
basis: Box<[TableRegion; 3]>,
basis: Box<[TableRegion; 7]>,
},
Commands {
query: String,
@@ -187,6 +210,25 @@ 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
@@ -352,8 +394,11 @@ pub struct TableRegion {
pub color: String,
}
/// The Table Style dialog's regions, in the order of `table_style::REGIONS` (the param names).
const REGION_LABELS: [&str; 7] = ["Whole Table", "Header Row", "Total Row", "First Column", "Last Column", "Banded Rows", "Banded Columns"];
/// The regions of table style `id` as resolved through its based-on chain.
fn table_regions(app: &WordApp, id: &str) -> Box<[TableRegion; 3]> {
fn table_regions(app: &WordApp, id: &str) -> Box<[TableRegion; 7]> {
let Some(st) = app.session.doc.styles.table_style(id) else { return Default::default() };
let p = &st.parts;
let hex = |c: Option<wordcraft_doc::Rgb>| c.map(|c| c.hex()).unwrap_or_default();
@@ -362,17 +407,136 @@ fn table_regions(app: &WordApp, id: &str) -> Box<[TableRegion; 3]> {
_ => String::new(),
};
let lines = |b: Option<wordcraft_doc::props::Borders>| b.is_some_and(|b| b.any_visible());
let mut header_chr = st.chr.clone();
header_chr.overlay(&p.header_chr);
let mut band_chr = st.chr.clone();
band_chr.overlay(&p.band_chr);
// A region's text: the whole table's formatting with the region's over it.
let region = |borders: Option<wordcraft_doc::props::Borders>, fill: Option<wordcraft_doc::Rgb>, chr: &wordcraft_doc::CharProps| {
let mut c = st.chr.clone();
c.overlay(chr);
TableRegion { borders: lines(borders), fill: hex(fill), bold: c.bold.unwrap_or(false), color: text(&c) }
};
let none = wordcraft_doc::CharProps::default();
Box::new([
TableRegion { borders: lines(p.borders), fill: hex(p.fill), bold: st.chr.bold.unwrap_or(false), color: text(&st.chr) },
TableRegion { borders: lines(p.header_borders), fill: hex(p.header_fill), bold: header_chr.bold.unwrap_or(false), color: text(&header_chr) },
TableRegion { borders: lines(p.band_borders), fill: hex(p.band_fill), bold: band_chr.bold.unwrap_or(false), color: text(&band_chr) },
region(p.borders, p.fill, &none),
region(p.header_borders, p.header_fill, &p.header_chr),
region(p.total_borders, p.total_fill, &p.total_chr),
region(p.first_col_borders, p.first_col_fill, &p.first_col_chr),
region(p.last_col_borders, p.last_col_fill, &p.last_col_chr),
region(p.band_borders, p.band_fill, &p.band_chr),
region(p.col_band_borders, p.col_band_fill, &p.col_band_chr),
])
}
/// The params `table.newStyle` / `table.modifyStyle` take for the Table Style dialog's state:
/// only what changed from `basis`, so the rest stays inherited.
fn table_style_params(id: Option<&str>, name: &str, based_on: &str, regions: &[TableRegion; 7], basis: &[TableRegion; 7]) -> Value {
let mut v = json!({"name": name.trim(), "basedOn": based_on});
for ((key, r), b) in wordcraft_engine::cmd::table_style::REGIONS.into_iter().zip(regions.iter()).zip(basis.iter()) {
let ch = region_changes(r, b);
if ch.as_object().is_some_and(|o| !o.is_empty()) {
v[key] = ch;
}
}
if let Some(id) = id {
v["style"] = json!(id);
}
v
}
/// A small sample table drawn with `style` (5 columns, a header, three body rows and a total
/// row), showing the regions `look` turns on: the Table Style dialog's live preview.
fn table_style_preview(ui: &mut Ui, style: Option<&wordcraft_doc::styles::TableStyleProps>, look: wordcraft_doc::props::TableLook) {
use wordcraft_doc::props::{Border, Borders};
const COLS: usize = 5;
const ROWS: usize = 5;
const TEXT: [[&str; COLS]; ROWS] = [
["", "Mon", "Tue", "Wed", "Sum"],
["North", "4", "7", "2", "13"],
["South", "6", "1", "5", "12"],
["West", "3", "8", "4", "15"],
["Total", "13", "16", "11", "40"],
];
let (rect, _) = ui.allocate_exact_size(vec2(300.0, 120.0), Sense::hover());
let painter = ui.painter_at(rect);
// The page is white whatever the interface theme.
painter.rect_filled(rect, 2.0, egui::Color32::WHITE);
let t = Tokens::get(ui.ctx());
painter.rect_stroke(rect, 2.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
let Some(st) = style else { return };
let p = &st.parts;
let table = rect.shrink2(vec2(14.0, 10.0));
let (cw, rh) = (table.width() / COLS as f32, table.height() / ROWS as f32);
let rgb = |c: wordcraft_doc::Rgb| egui::Color32::from_rgb(c.0, c.1, c.2);
let tb = p.borders.unwrap_or_default();
for (ri, row) in TEXT.iter().enumerate() {
let header = look.header_row && ri == 0;
let total = look.total_row && ri + 1 == ROWS;
let band = look.banded_rows
&& !header
&& (ri.saturating_sub(usize::from(look.header_row)) / p.band_size.unwrap_or(1).clamp(1, 1000) as usize).is_multiple_of(2);
for (ci, txt) in row.iter().enumerate() {
let first = look.first_column && ci == 0;
let last = look.last_column && ci + 1 == COLS;
let col_band = look.banded_columns && !first && ci.saturating_sub(usize::from(look.first_column)).is_multiple_of(2);
let cell = egui::Rect::from_min_size(table.min + vec2(ci as f32 * cw, ri as f32 * rh), vec2(cw, rh));
// Regions from lowest to highest priority, as layout applies them.
let regions = [
(col_band, p.col_band_fill, &p.col_band_chr, p.col_band_borders),
(band, p.band_fill, &p.band_chr, p.band_borders),
(first, p.first_col_fill, &p.first_col_chr, p.first_col_borders),
(last, p.last_col_fill, &p.last_col_chr, p.last_col_borders),
(header, p.header_fill, &p.header_chr, p.header_borders),
(total, p.total_fill, &p.total_chr, p.total_borders),
];
let mut fill = p.fill;
let mut chr = st.chr.clone();
let mut b = Borders {
top: if ri == 0 { tb.top } else { tb.between },
bottom: if ri + 1 == ROWS { tb.bottom } else { tb.between },
left: if ci == 0 { tb.left } else { tb.inside_v },
right: if ci + 1 == COLS { tb.right } else { tb.inside_v },
between: None,
inside_v: None,
};
for (on, f, c, rb) in regions {
if !on {
continue;
}
fill = f.or(fill);
chr.overlay(c);
if let Some(rb) = rb {
b.overlay(&Borders { between: None, inside_v: None, ..rb });
}
}
if total && p.total_borders.is_none_or(|x| x.top.is_none()) {
b.top = p.total_border_top.or(b.top);
}
if let Some(f) = fill {
painter.rect_filled(cell, 0.0, rgb(f));
}
let edge = |from: egui::Pos2, to: egui::Pos2, e: Option<Border>| {
if let Some(e) = e.filter(Border::is_visible) {
let color = e.color.map_or(egui::Color32::BLACK, rgb);
painter.line_segment([from, to], egui::Stroke::new(e.width.clamp(0.5, 3.0) * 1.3, color));
}
};
edge(cell.left_top(), cell.right_top(), b.top);
edge(cell.left_bottom(), cell.right_bottom(), b.bottom);
edge(cell.left_top(), cell.left_bottom(), b.left);
edge(cell.right_top(), cell.right_bottom(), b.right);
let color = match chr.color {
Some(wordcraft_doc::TextColor::Rgb(c)) => rgb(c),
_ => egui::Color32::BLACK,
};
let font = egui::FontId::proportional(11.0);
let pos = cell.left_center() + vec2(4.0, 0.0);
painter.text(pos, egui::Align2::LEFT_CENTER, *txt, font.clone(), color);
if chr.bold.unwrap_or(false) {
// A second pass a hair to the right reads as bold at this size.
painter.text(pos + vec2(0.6, 0.0), egui::Align2::LEFT_CENTER, *txt, font, color);
}
}
}
}
/// The style of the table at the caret, when it is a table style.
fn current_table_style(app: &WordApp) -> Option<String> {
let s = &app.session;
@@ -446,6 +610,7 @@ impl Dialog {
Dialog::Watermark { .. } => "watermark",
Dialog::NewStyle { .. } => "newStyle",
Dialog::ModifyStyle { .. } => "modifyStyle",
Dialog::ManageStyles { .. } => "manageStyles",
Dialog::TableStyle { id: None, .. } => "newTableStyle",
Dialog::TableStyle { .. } => "modifyTableStyle",
Dialog::Commands { .. } => "commands",
@@ -460,6 +625,8 @@ impl Dialog {
Dialog::MatchFields { .. } => "matchFields",
Dialog::CheckErrors { .. } => "checkErrors",
Dialog::TableProperties { .. } => "tableProperties",
Dialog::Password { .. } => "password",
Dialog::EncryptPassword { .. } => "encryptPassword",
}
}
@@ -499,7 +666,7 @@ impl Dialog {
color: s("color"),
spacing: 0.0,
},
"paragraph" => {
"paragraph" | "asianTypography" => {
let rp = app.session.doc.para_at(&app.session.sel.focus).map(|p| app.session.doc.styles.resolve_para(&p.props));
let rp = rp?;
let line = match rp.line_spacing {
@@ -520,6 +687,9 @@ impl Dialog {
keep_lines: rp.keep_lines,
page_break: rp.page_break_before,
widow: rp.widow_control,
tab: if name == "asianTypography" { 2 } else { 0 },
asian: [rp.kinsoku, rp.word_wrap, rp.overflow_punct, rp.top_line_punct, rp.auto_space_de, rp.auto_space_dn],
asian_was: [rp.kinsoku, rp.word_wrap, rp.overflow_punct, rp.top_line_punct, rp.auto_space_de, rp.auto_space_dn],
}
}
"find" | "replace" => Dialog::Find {
@@ -548,7 +718,8 @@ impl Dialog {
"wordCount" => Dialog::WordCount { stats: app.session.run("review.wordCount", &json!({})).unwrap_or_default() },
"zoom" => Dialog::Zoom { percent: (app.session.view.zoom * 100.0).round() },
"watermark" => Dialog::Watermark { text: "CONFIDENTIAL".into(), diagonal: true },
"newStyle" => Dialog::NewStyle { name: "Style1".into(), based_on: "Normal".into() },
"newStyle" => Dialog::NewStyle { name: "Style1".into(), based_on: "Normal".into(), back: false },
"manageStyles" => Dialog::ManageStyles { alphabetical: false, selected: s("style") },
"newTableStyle" => {
let based_on = current_table_style(app).unwrap_or_else(|| "TableGrid".into());
let styles = &app.session.doc.styles;
@@ -588,6 +759,7 @@ 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 {
@@ -630,6 +802,7 @@ impl Dialog {
);
let rp = app.session.doc.styles.resolve_para(&wordcraft_doc::ParaProps { style: Some(id.into()), ..Default::default() });
Some(Dialog::ModifyStyle {
back: false,
id: id.into(),
name: st.name.clone(),
font: rc.font,
@@ -702,6 +875,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
Dialog::Watermark { .. } => "Custom Watermark",
Dialog::NewStyle { .. } => "Create New Style",
Dialog::ModifyStyle { .. } => "Modify Style",
Dialog::ManageStyles { .. } => "Manage Styles",
Dialog::TableStyle { id: None, .. } => "New Table Style",
Dialog::TableStyle { .. } => "Modify Table Style",
Dialog::Commands { .. } => "Search Commands",
@@ -716,6 +890,8 @@ 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)))
@@ -835,48 +1011,94 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
ok || cancel
}
Dialog::Paragraph { rtl, align, left, right, first, before, after, line, keep_next, keep_lines, page_break, widow } => {
ui.label(egui::RichText::new(tl!("General")).font(semibold(12.5)));
Dialog::Paragraph {
rtl,
align,
left,
right,
first,
before,
after,
line,
keep_next,
keep_lines,
page_break,
widow,
tab,
asian,
asian_was,
} => {
ui.horizontal(|ui| {
ui.label(tl!("Direction:"));
ui.radio_value(rtl, true, tl!("Right-to-left"));
ui.radio_value(rtl, false, tl!("Left-to-right"));
});
egui::ComboBox::from_label(tl!("Alignment")).selected_text(align.clone()).show_ui(ui, |ui| {
for a in ["left", "center", "right", "justify"] {
ui.selectable_value(align, a.to_string(), a);
for (k, name) in ["Indents and Spacing", "Line and Page Breaks", "Asian Typography"].into_iter().enumerate() {
if ui.selectable_label(*tab as usize == k, tl!(name)).clicked() {
*tab = k as u8;
}
}
});
ui.label(egui::RichText::new(tl!("Indentation")).font(semibold(12.5)));
egui::Grid::new("ind").num_columns(4).show(ui, |ui| {
ui.label(tl!("Left:"));
ui.add(egui::DragValue::new(left).speed(0.05).suffix("\"").max_decimals(2));
ui.label(tl!("Right:"));
ui.add(egui::DragValue::new(right).speed(0.05).suffix("\"").max_decimals(2));
ui.end_row();
ui.label(tl!("First line:"));
ui.add(egui::DragValue::new(first).speed(0.05).suffix("\"").max_decimals(2));
ui.end_row();
});
ui.label(egui::RichText::new(tl!("Spacing")).font(semibold(12.5)));
egui::Grid::new("sp").num_columns(4).show(ui, |ui| {
ui.label(tl!("Before:"));
ui.add(egui::DragValue::new(before).speed(1.0).range(0.0..=1584.0).suffix(" pt"));
ui.label(tl!("Line spacing:"));
ui.add(egui::DragValue::new(line).speed(0.05).range(0.5..=5.0));
ui.end_row();
ui.label(tl!("After:"));
ui.add(egui::DragValue::new(after).speed(1.0).range(0.0..=1584.0).suffix(" pt"));
ui.end_row();
});
ui.label(egui::RichText::new(tl!("Line and Page Breaks")).font(semibold(12.5)));
ui.checkbox(widow, tl!("Widow/Orphan control"));
ui.checkbox(keep_next, tl!("Keep with next"));
ui.checkbox(keep_lines, tl!("Keep lines together"));
ui.checkbox(page_break, tl!("Page break before"));
ui.separator();
match *tab {
0 => {
ui.label(egui::RichText::new(tl!("General")).font(semibold(12.5)));
ui.horizontal(|ui| {
ui.label(tl!("Direction:"));
ui.radio_value(rtl, true, tl!("Right-to-left"));
ui.radio_value(rtl, false, tl!("Left-to-right"));
});
egui::ComboBox::from_label(tl!("Alignment")).selected_text(align.clone()).show_ui(ui, |ui| {
for a in ["left", "center", "right", "justify"] {
ui.selectable_value(align, a.to_string(), a);
}
});
ui.label(egui::RichText::new(tl!("Indentation")).font(semibold(12.5)));
egui::Grid::new("ind").num_columns(4).show(ui, |ui| {
ui.label(tl!("Left:"));
ui.add(egui::DragValue::new(left).speed(0.05).suffix("\"").max_decimals(2));
ui.label(tl!("Right:"));
ui.add(egui::DragValue::new(right).speed(0.05).suffix("\"").max_decimals(2));
ui.end_row();
ui.label(tl!("First line:"));
ui.add(egui::DragValue::new(first).speed(0.05).suffix("\"").max_decimals(2));
ui.end_row();
});
ui.label(egui::RichText::new(tl!("Spacing")).font(semibold(12.5)));
egui::Grid::new("sp").num_columns(4).show(ui, |ui| {
ui.label(tl!("Before:"));
ui.add(egui::DragValue::new(before).speed(1.0).range(0.0..=1584.0).suffix(" pt"));
ui.label(tl!("Line spacing:"));
ui.add(egui::DragValue::new(line).speed(0.05).range(0.5..=5.0));
ui.end_row();
ui.label(tl!("After:"));
ui.add(egui::DragValue::new(after).speed(1.0).range(0.0..=1584.0).suffix(" pt"));
ui.end_row();
});
}
1 => {
ui.label(egui::RichText::new(tl!("Line and Page Breaks")).font(semibold(12.5)));
ui.checkbox(widow, tl!("Widow/Orphan control"));
ui.checkbox(keep_next, tl!("Keep with next"));
ui.checkbox(keep_lines, tl!("Keep lines together"));
ui.checkbox(page_break, tl!("Page break before"));
}
_ => {
let [kinsoku, word_wrap, overflow, top_line, de, dn] = asian;
ui.label(egui::RichText::new(tl!("Line breaking")).font(semibold(12.5)));
ui.checkbox(kinsoku, tl!("Apply Asian line-breaking rules (kinsoku)"));
// The model's flag is "wrap whole words"; the box offers the opposite.
let mut mid_word = !*word_wrap;
if ui.checkbox(&mut mid_word, tl!("Allow Latin text to wrap in the middle of a word")).changed() {
*word_wrap = !mid_word;
}
ui.checkbox(overflow, tl!("Allow hanging punctuation"));
ui.label(egui::RichText::new(tl!("Character Spacing")).font(semibold(12.5)));
ui.checkbox(top_line, tl!("Compress punctuation at the start of a line"));
ui.checkbox(de, tl!("Add space between Asian and Latin text"));
ui.checkbox(dn, tl!("Add space between Asian text and numbers"));
}
}
let (ok, cancel) = buttons(ui, tl!("OK"));
if ok {
apply_paragraph(app, *rtl, align, *left, *right, *first, *before, *after, *line, [*keep_next, *keep_lines, *page_break, *widow]);
apply_asian(app, *asian, *asian_was);
}
ok || cancel
}
@@ -1113,7 +1335,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
ok || cancel
}
Dialog::NewStyle { name, based_on } => {
Dialog::NewStyle { name, based_on, back } => {
egui::Grid::new("ns").num_columns(2).show(ui, |ui| {
ui.label(tl!("Name:"));
ui.text_edit_singleline(name);
@@ -1124,12 +1346,17 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
});
ui.label(egui::RichText::new(tl!("The new style takes the formatting of the current paragraph.")).small().weak());
let (ok, cancel) = buttons(ui, tl!("OK"));
let mut created = None;
if ok {
let _ = app.run("styles.create", json!({"name": name, "basedOn": based_on}));
created =
app.run("styles.create", json!({"name": name, "basedOn": based_on})).ok().and_then(|v| v.get("id")?.as_str().map(str::to_string));
}
if (ok || cancel) && *back {
app.dialog = Some(Dialog::ManageStyles { alphabetical: false, selected: created.unwrap_or_default() });
}
ok || cancel
}
Dialog::ModifyStyle { id, name, font, size, bold, italic, color, before, after } => {
Dialog::ModifyStyle { back, id, name, font, size, bold, italic, color, before, after } => {
egui::Grid::new("ms").num_columns(2).show(ui, |ui| {
ui.label(tl!("Name:"));
ui.text_edit_singleline(name);
@@ -1163,8 +1390,12 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
let _ =
app.run("styles.modify", json!({"style": id, "name": name, "chr": chr, "para": {"spaceBefore": *before, "spaceAfter": *after}}));
}
if (ok || cancel) && *back {
app.dialog = Some(Dialog::ManageStyles { alphabetical: false, selected: id.clone() });
}
ok || cancel
}
Dialog::ManageStyles { alphabetical, selected } => manage_styles(app, ui, alphabetical, selected),
Dialog::TableStyle { id, name, based_on, region, regions, basis } => {
let styles: Vec<(String, String)> = app
.session
@@ -1192,12 +1423,11 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
});
ui.end_row();
ui.label(tl!("Apply formatting to:"));
let names = ["Whole Table", "Header Row", "Banded Rows"];
egui::ComboBox::from_id_salt("tstyle_region")
.width(200.0)
.selected_text(tl!(names.get(*region).copied().unwrap_or("Whole Table")))
.selected_text(tl!(REGION_LABELS.get(*region).copied().unwrap_or("Whole Table")))
.show_ui(ui, |ui| {
for (i, n) in names.iter().enumerate() {
for (i, n) in REGION_LABELS.iter().enumerate() {
ui.selectable_value(region, i, tl!(n));
}
});
@@ -1224,24 +1454,32 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
ui.end_row();
});
}
// Live preview: the style as OK would leave it, on the current table's options with
// the region being edited turned on.
let v = table_style_params(id.as_deref(), name, based_on, regions, basis);
let mut look = app
.session
.sel
.focus
.path
.cell()
.and_then(|(tp, _, _)| app.session.doc.table(app.session.sel.focus.story, &tp).map(|t| t.props.look))
.unwrap_or_default();
match *region {
1 => look.header_row = true,
2 => look.total_row = true,
3 => look.first_column = true,
4 => look.last_column = true,
5 => look.banded_rows = true,
6 => look.banded_columns = true,
_ => {}
}
ui.add_space(4.0);
ui.label(egui::RichText::new(tl!("Preview")).small().weak());
table_style_preview(ui, wordcraft_engine::cmd::table_style::preview(&app.session, &v).as_ref(), look);
let (ok, cancel) = buttons(ui, tl!("OK"));
if ok {
let mut v = json!({"name": name.trim(), "basedOn": based_on});
for (i, key) in ["wholeTable", "headerRow", "bandedRows"].into_iter().enumerate() {
if let (Some(r), Some(b)) = (regions.get(i), basis.get(i)) {
let ch = region_changes(r, b);
if ch.as_object().is_some_and(|o| !o.is_empty()) {
v[key] = ch;
}
}
}
let cmd = match id {
Some(sid) => {
v["style"] = json!(sid);
"table.modifyStyle"
}
None => "table.newStyle",
};
let cmd = if id.is_some() { "table.modifyStyle" } else { "table.newStyle" };
if let Err(e) = app.run(cmd, v) {
app.status(e);
return false;
@@ -1399,6 +1637,86 @@ 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:"));
@@ -1677,6 +1995,175 @@ fn apply_paragraph(
);
}
/// The Paragraph dialog's Asian Typography: sets the flags that changed since it opened, so an
/// untouched tab adds no direct formatting.
fn apply_asian(app: &mut WordApp, asian: [bool; 6], was: [bool; 6]) {
let keys = ["kinsoku", "wordWrap", "overflowPunct", "topLinePunct", "autoSpaceDE", "autoSpaceDN"];
let changed: serde_json::Map<String, Value> =
keys.iter().zip(asian.iter().zip(was)).filter(|(_, (now, was))| **now != *was).map(|(k, (now, _))| (k.to_string(), json!(now))).collect();
if !changed.is_empty() {
let _ = app.run("para.asianTypography", Value::Object(changed));
}
}
/// Manage Styles' body. Reads the list from `styles.manage`'s data (never re-running the command,
/// which would ask to open this dialog again) and acts through commands.
fn manage_styles(app: &mut WordApp, ui: &mut Ui, alphabetical: &mut bool, selected: &mut String) -> bool {
let list = wordcraft_engine::cmd::para::manage_list(&app.session, *alphabetical);
let list = list.as_array().cloned().unwrap_or_default();
let str_of = |v: &Value, k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").to_string();
if !list.iter().any(|x| str_of(x, "id") == *selected) {
*selected = list.first().map(|x| str_of(x, "id")).unwrap_or_default();
}
ui.horizontal(|ui| {
ui.label(tl!("Sort order:"));
egui::ComboBox::from_id_salt("manage_styles_sort")
.selected_text(if *alphabetical { tl!("Alphabetical") } else { tl!("As Recommended") })
.show_ui(ui, |ui| {
ui.selectable_value(alphabetical, false, tl!("As Recommended"));
ui.selectable_value(alphabetical, true, tl!("Alphabetical"));
});
});
let t = Tokens::get(ui.ctx());
let shown = egui::Id::new("manage_styles_shown");
egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.border)).inner_margin(4).show(ui, |ui| {
egui::ScrollArea::vertical().id_salt("manage_styles_list").max_height(200.0).auto_shrink([false, false]).show(ui, |ui| {
ui.set_width(380.0);
for st in &list {
let id = str_of(st, "id");
let glyph = match st.get("type").and_then(Value::as_str) {
Some("character") => "a",
Some("linked") => "¶a",
Some("table") => "⊞",
_ => "",
};
let hidden = st.get("hidden").and_then(Value::as_bool).unwrap_or(false);
let mut name = egui::RichText::new(format!("{glyph} {}", str_of(st, "name")));
if hidden {
name = name.weak();
}
let r = ui.selectable_label(*selected == id, name);
// Bring the selection into view once, when it changes (opening, New Style…).
if *selected == id && ui.data(|d| d.get_temp::<String>(shown)).as_deref() != Some(id.as_str()) {
r.scroll_to_me(Some(egui::Align::Center));
ui.data_mut(|d| d.insert_temp(shown, id.clone()));
}
if r.clicked() {
ui.data_mut(|d| d.insert_temp(shown, id.clone()));
*selected = id.clone();
}
if r.double_clicked() {
*selected = id;
if let Some(Dialog::ModifyStyle { id, name, font, size, bold, italic, color, before, after, .. }) =
Dialog::modify_style(app, selected)
{
app.dialog = Some(Dialog::ModifyStyle { back: true, id, name, font, size, bold, italic, color, before, after });
}
}
}
});
});
let leave = |ui: &mut Ui| {
ui.data_mut(|d| d.remove::<String>(shown));
true
};
if app.dialog.is_some() {
return leave(ui);
}
let Some(cur) = list.iter().find(|x| str_of(x, "id") == *selected).cloned() else { return close_button(ui) && leave(ui) };
let ty = str_of(&cur, "type");
ui.add_space(6.0);
crate::previews::style_preview(app, ui, selected, &ty, vec2(388.0, if ty == "table" { 64.0 } else { 40.0 }));
ui.add_space(4.0);
ui.add(egui::Label::new(egui::RichText::new(describe(&cur)).small()).wrap());
ui.add_space(6.0);
let builtin = cur.get("builtIn").and_then(Value::as_bool).unwrap_or(true);
if ty != "table" {
let mut gallery = cur.get("inGallery").and_then(Value::as_bool).unwrap_or(false);
if ui.checkbox(&mut gallery, tl!("Show in the Styles gallery")).changed() {
let _ = app.run("styles.setVisibility", json!({"style": selected, "gallery": gallery}));
}
}
let mut hidden = cur.get("hidden").and_then(Value::as_bool).unwrap_or(false);
if ui.checkbox(&mut hidden, tl!("Hide from style lists")).changed() {
let _ = app.run("styles.setVisibility", json!({"style": selected, "hidden": hidden}));
}
ui.add_space(4.0);
ui.horizontal(|ui| {
// Table styles are edited from Table Design.
if ui.add_enabled(ty != "table", egui::Button::new(tl!("Modify…"))).clicked()
&& let Some(Dialog::ModifyStyle { id, name, font, size, bold, italic, color, before, after, .. }) = Dialog::modify_style(app, selected)
{
app.dialog = Some(Dialog::ModifyStyle { back: true, id, name, font, size, bold, italic, color, before, after });
}
if ui.button(tl!("New Style…")).clicked() {
app.dialog = Some(Dialog::NewStyle { name: "Style1".into(), based_on: str_of(&cur, "name"), back: true });
}
let del = ui.add_enabled(!builtin, egui::Button::new(tl!("Delete")));
let del = if builtin { del.on_disabled_hover_text(tl!("Built-in styles can't be deleted.")) } else { del };
if del.clicked() {
let _ = app.run("styles.delete", json!({"style": selected}));
selected.clear();
}
});
(app.dialog.is_some() || close_button(ui)) && leave(ui)
}
/// A dialog's single Close button (Escape too).
fn close_button(ui: &mut Ui) -> bool {
ui.add_space(8.0);
let mut close = ui.input(|i| i.key_pressed(egui::Key::Escape));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
close |= ui.add(egui::Button::new(egui::RichText::new(tl!("Close")).color(egui::Color32::WHITE)).fill(crate::theme::APP_COLOR)).clicked();
});
close
}
/// A style's description: its type, the formatting it sets and what it is based on.
fn describe(st: &Value) -> String {
let f = st.get("format").cloned().unwrap_or_default();
let fs = |k: &str| f.get(k).and_then(Value::as_str);
let fnum = |k: &str| f.get(k).and_then(Value::as_f64);
let pt = |x: f64| format!("{}", (x * 10.0).round() / 10.0);
let mut parts: Vec<String> = vec![
match st.get("type").and_then(Value::as_str) {
Some("character") => tl!("Character style"),
Some("linked") => tl!("Linked style (paragraph and character)"),
Some("table") => tl!("Table style"),
_ => tl!("Paragraph style"),
}
.to_string(),
];
if st.get("builtIn").and_then(Value::as_bool).unwrap_or(false) {
parts.push(tl!("Built-in style").to_string());
}
if let Some(x) = fs("font") {
parts.push(crate::i18n::fmt(tl!("Font: {font}"), &[("font", x)]));
}
if let Some(x) = fnum("size") {
parts.push(crate::i18n::fmt(tl!("{size} pt"), &[("size", &pt(x))]));
}
if f.get("bold").and_then(Value::as_bool) == Some(true) {
parts.push(tl!("Bold").to_string());
}
if f.get("italic").and_then(Value::as_bool) == Some(true) {
parts.push(tl!("Italic").to_string());
}
if let Some(x) = fs("color") {
parts.push(crate::i18n::fmt(tl!("Color: {color}"), &[("color", &format!("#{x}"))]));
}
if let Some(x) = fnum("spaceBefore") {
parts.push(crate::i18n::fmt(tl!("Space before: {pt} pt"), &[("pt", &pt(x))]));
}
if let Some(x) = fnum("spaceAfter") {
parts.push(crate::i18n::fmt(tl!("Space after: {pt} pt"), &[("pt", &pt(x))]));
}
if let Some(b) = st.get("basedOn").and_then(Value::as_str) {
parts.push(crate::i18n::fmt(tl!("Based on: {style}"), &[("style", b)]));
}
parts.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1695,7 +2182,7 @@ mod tests {
/// Open the Paragraph dialog and press OK without changing anything.
fn ok_unchanged(app: &mut WordApp) {
let Some(Dialog::Paragraph { rtl, align, left, right, first, before, after, line, keep_next, keep_lines, page_break, widow }) =
let Some(Dialog::Paragraph { rtl, align, left, right, first, before, after, line, keep_next, keep_lines, page_break, widow, .. }) =
Dialog::open("paragraph", app)
else {
panic!("no paragraph dialog")
+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(&then, params).map(|r| json!({"done": true, "result": r}))
self.execute_user(&then, params).map(|r| json!({"done": true, "result": r}))
}
}
}
+73
View File
@@ -23,7 +23,10 @@
Accept This Change Aceptar este cambio
Accessibility Accesibilidad
Add Añadir
Add Object to Selection Añadir objeto a la selección
Add Space Before Paragraph Añadir espacio antes del párrafo
Add space between Asian and Latin text Añadir espacio entre texto asiático y latino
Add space between Asian text and numbers Añadir espacio entre texto asiático y números
Add Text Añadir texto
Add a Table Añadir una tabla
Add a comment Añadir un comentario
@@ -46,13 +49,17 @@
All Caps Mayúsculas
All Markup Todas las marcas
All caps Mayúsculas
Allow hanging punctuation Permitir puntuación colgante
Allow Latin text to wrap in the middle of a word Permitir que el texto latino se divida en mitad de una palabra
Alt Text Texto alternativo
Apply Asian line-breaking rules (kinsoku) Aplicar las reglas asiáticas de división de líneas (kinsoku)
Apply Character Style Aplicar estilo de carácter
Apply Style Aplicar estilo
Arrange Organizar
Arrange\nAll Organizar\ntodo
Arrow Flecha
Artistic Effects Efectos artísticos
Asian Typography Tipografía asiática
Author Autor
AutoCorrect Options Opciones de autocorrección
AutoFit Ajustar automáticamente
@@ -134,6 +141,14 @@
Clear Formatting Level Borrar nivel de formato
Clear Paragraph Formatting Borrar formato de párrafo
Clipboard Portapapeles
Clipboard Pane Panel del portapapeles
Clipboard Items Elementos del portapapeles
Paste Clipboard Item Pegar elemento del portapapeles
Paste All Pegar todo
Delete Clipboard Item Eliminar elemento del portapapeles
Click an item to paste it. Haz clic en un elemento para pegarlo.
Nothing collected yet. Items you copy or cut appear here. Aún no hay nada. Lo que copies o cortes aparecerá aquí.
(picture or object) (imagen u objeto)
Close Cerrar
Close Header and Footer Cerrar encabezado y pie de página
Collapse Selection Contraer selección
@@ -152,6 +167,7 @@
Compact Compacto
Compare Comparar
Compress Pictures Comprimir imágenes
Compress punctuation at the start of a line Comprimir la puntuación al inicio de la línea
Continuous Continuo
Convert Convertir
Convert Text to Table Convertir texto en tabla
@@ -329,6 +345,7 @@
Indent Sangría
Indentation Sangría
Indents Sangrías
Indents and Spacing Sangría y espaciado
Index Índice
Info Información
Ink to\nMath Tinta a\nfórmula
@@ -385,6 +402,7 @@
Letter Carta
Line Línea
Line Break Salto de línea
Line breaking División de líneas
Line End Fin de línea
Line Numbers Números de línea
Line Spacing Interlineado
@@ -765,6 +783,7 @@
Two Dos
Type Text Escribir texto
Type a command, e.g. "insert table" Escribe un comando, p. ej. "insertar tabla"
Ungroup Desagrupar
UPPERCASE MAYÚSCULAS
Underline Subrayado
Underline Words Only Subrayar solo las palabras
@@ -1045,3 +1064,57 @@
Leave the equation (Esc) Salir de la ecuación (Esc)
Preview Vista previa
getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft
Shape Effects Efectos de forma
Glow Resplandor
Soft Edges Bordes suaves
No Shadow Sin sombra
No Glow Sin resplandor
No Soft Edges Sin bordes suaves
Glow Colors Colores de resplandor
Weight Grosor
Shadow, glow and soft edges Sombra, resplandor y bordes suaves
Offset: Bottom Right Desplazamiento: abajo a la derecha
Offset: Bottom Desplazamiento: abajo
Offset: Bottom Left Desplazamiento: abajo a la izquierda
Offset: Right Desplazamiento: derecha
Offset: Center Desplazamiento: centro
Offset: Left Desplazamiento: izquierda
Offset: Top Right Desplazamiento: arriba a la derecha
Offset: Top Desplazamiento: arriba
Offset: Top Left Desplazamiento: arriba a la izquierda
Manage Styles Administrar estilos
Show or Hide Style Mostrar u ocultar estilo
Manage Styles… Administrar estilos…
Sort order: Orden:
As Recommended Recomendado
Alphabetical Alfabético
Font: {font} Fuente: {font}
{size} pt {size} pt
Color: {color} Color: {color}
Space before: {pt} pt Espacio antes: {pt} pt
Space after: {pt} pt Espacio después: {pt} pt
Based on: {style} Basado en: {style}
Paragraph style Estilo de párrafo
Character style Estilo de carácter
Linked style (paragraph and character) Estilo vinculado (párrafo y carácter)
Table style Estilo de tabla
Built-in style Estilo integrado
Show in the Styles gallery Mostrar en la galería de estilos
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.
+74 -1
View File
@@ -12,7 +12,10 @@
Add 追加
Add a comment コメントを追加
Add a Table 表を追加
Add Object to Selection オブジェクトを選択範囲に追加
Add Space Before Paragraph 段落前に間隔を追加
Add space between Asian and Latin text アジア文字と欧文の間隔を空ける
Add space between Asian text and numbers アジア文字と数字の間隔を空ける
Add Text テキストの追加
Add to Dictionary 辞書に追加
Add to Style Gallery スタイル ギャラリーに追加
@@ -31,7 +34,10 @@
All Caps すべて大文字
All caps すべて大文字
All Markup すべての変更履歴
Allow hanging punctuation 句読点のぶら下げを行う
Allow Latin text to wrap in the middle of a word 欧文を単語の途中で折り返す
Alt Text 代替テキスト
Apply Asian line-breaking rules (kinsoku) アジア文字の禁則処理を行う
Apply Character Style 文字スタイルを適用
Apply Style スタイルを適用
Apply Styles スタイルを選んで適用
@@ -39,6 +45,7 @@
Arrange\nAll 整列
Arrow 矢印
Artistic Effects アート効果
Asian Typography アジア文字の組版
Author 作成者
AutoCorrect Options オートコレクトのオプション
AutoFit 自動調整
@@ -53,9 +60,12 @@
AutoSave documents you have saved in WordCraft WordCraft で保存した文書を自動保存する
AutoSave failed: {error}. Save the document to turn AutoSave back on. 自動保存できませんでした: {error}。文書を保存し直すと、自動保存が再開します。
Bottom Margin 下余白
Compress punctuation at the start of a line 行頭の約物を詰める
Dark page (white text on black) ダーク ページ (黒地に白い文字)
Edit Comment コメントを編集
Left Margin 左余白
Indents and Spacing インデントと間隔
Line breaking 行の分割
More templates → その他のテンプレート →
No templates match your search. 検索に一致するテンプレートはありません。
Reopen 再度開く
@@ -126,6 +136,15 @@
Clear クリア
Clear All Formatting すべての書式をクリア
Clipboard クリップボード
Clipboard Pane クリップボード ウィンドウ
Clipboard Items クリップボードの項目
Paste Clipboard Item クリップボードの項目を貼り付け
Paste All すべて貼り付け
Delete Clipboard Item クリップボードの項目を削除
Clear All すべてクリア
Click an item to paste it. 項目をクリックすると貼り付けます。
Nothing collected yet. Items you copy or cut appear here. まだ何もありません。コピーまたは切り取った項目がここに表示されます。
(picture or object) (画像またはオブジェクト)
Close 閉じる
Close Header and Footer ヘッダーとフッターを閉じる
Collapse Selection 選択範囲を縮小
@@ -745,6 +764,7 @@
Underline Words Only 単語のみ下線
Undo 元に戻す
Undo {action} 元に戻す: {action}
Ungroup グループ解除
Up 上
Update Field フィールドの更新
Update Index 索引の更新
@@ -898,7 +918,6 @@
Clear Paragraph Formatting 段落の書式をクリア
Clear Character Style 文字スタイルをクリア
Clear Character Formatting 文字の書式をクリア
Clear All すべてクリア
Paste Special 形式を選択して貼り付け
Paste Special… 形式を選択して貼り付け…
Paste as: 貼り付ける形式:
@@ -1029,3 +1048,57 @@
Leave the equation (Esc) 数式を終了 (Esc)
Preview プレビュー
getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft
Shape Effects 図形の効果
Glow 光彩
Soft Edges 輪郭のぼかし
No Shadow 影なし
No Glow 光彩なし
No Soft Edges ぼかしなし
Glow Colors 光彩の色
Weight 太さ
Shadow, glow and soft edges 影、光彩、輪郭のぼかし
Offset: Bottom Right オフセット: 右下
Offset: Bottom オフセット: 下
Offset: Bottom Left オフセット: 左下
Offset: Right オフセット: 右
Offset: Center オフセット: 中央
Offset: Left オフセット: 左
Offset: Top Right オフセット: 右上
Offset: Top オフセット: 上
Offset: Top Left オフセット: 左上
Manage Styles スタイルの管理
Show or Hide Style スタイルの表示/非表示
Manage Styles… スタイルの管理…
Sort order: 並べ替え順:
As Recommended 推奨順
Alphabetical アルファベット順
Font: {font} フォント: {font}
{size} pt {size} pt
Color: {color} 色: {color}
Space before: {pt} pt 前の間隔: {pt} pt
Space after: {pt} pt 後の間隔: {pt} pt
Based on: {style} 基準: {style}
Paragraph style 段落スタイル
Character style 文字スタイル
Linked style (paragraph and character) リンク スタイル (段落と文字)
Table style 表スタイル
Built-in style 組み込みスタイル
Show in the Styles gallery スタイル ギャラリーに表示
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.
+74 -1
View File
@@ -23,7 +23,10 @@
Add Adicionar
Add a comment Adicionar um comentário
Add a Table Adicionar uma tabela
Add Object to Selection Adicionar objeto à seleção
Add Space Before Paragraph Adicionar espaço antes do parágrafo
Add space between Asian and Latin text Adicionar espaço entre texto asiático e latino
Add space between Asian text and numbers Adicionar espaço entre texto asiático e números
Add Text Adicionar texto
Add to Dictionary Adicionar ao dicionário
Add to Style Gallery Adicionar à galeria de estilos
@@ -42,13 +45,17 @@
All Caps Tudo em maiúsculas
All caps Tudo em maiúsculas
All Markup Todas as marcações
Allow hanging punctuation Permitir pontuação pendente
Allow Latin text to wrap in the middle of a word Permitir que o texto latino quebre no meio de uma palavra
Alt Text Texto alternativo
Apply Asian line-breaking rules (kinsoku) Aplicar as regras asiáticas de quebra de linha (kinsoku)
Apply Character Style Aplicar estilo de caractere
Apply Style Aplicar estilo
Arrange Organizar
Arrange\nAll Organizar\ntudo
Arrow Seta
Artistic Effects Efeitos artísticos
Asian Typography Tipografia asiática
Author Autor
AutoCorrect Options Opções de autocorreção
AutoFit Autoajuste
@@ -124,6 +131,15 @@
Clear Limpar
Clear All Formatting Limpar toda a formatação
Clipboard Área de transferência
Clipboard Pane Painel da área de transferência
Clipboard Items Itens da área de transferência
Paste Clipboard Item Colar item da área de transferência
Paste All Colar tudo
Delete Clipboard Item Excluir item da área de transferência
Clear All Limpar tudo
Click an item to paste it. Clique em um item para colá-lo.
Nothing collected yet. Items you copy or cut appear here. Nada coletado ainda. Os itens que você copiar ou recortar aparecem aqui.
(picture or object) (imagem ou objeto)
Close Fechar
Close Header and Footer Fechar cabeçalho e rodapé
Collapse Selection Recolher seleção
@@ -142,6 +158,7 @@
Compact Compacto
Compare Comparar
Compress Pictures Compactar imagens
Compress punctuation at the start of a line Comprimir a pontuação no início da linha
Continuous Contínua
Convert Converter
Convert Text to Table Converter texto em tabela
@@ -186,6 +203,8 @@
Design Design
Edit Comment Editar comentário
Left Margin Margem esquerda
Indents and Spacing Recuos e espaçamento
Line breaking Quebra de linhas
More templates → Mais modelos →
No templates match your search. Nenhum modelo corresponde à pesquisa.
Reopen Reabrir
@@ -745,6 +764,7 @@
Underline Words Only Sublinhar somente palavras
Undo Desfazer
Undo {action} Desfazer {action}
Ungroup Desagrupar
Up Para cima
Update Field Atualizar campo
Update Index Atualizar índice
@@ -910,7 +930,6 @@
Clear Paragraph Formatting Limpar formatação do parágrafo
Clear Character Style Limpar estilo de caractere
Clear Character Formatting Limpar formatação de caractere
Clear All Limpar tudo
Paste Special Colar especial
Paste Special… Colar especial…
Paste as: Colar como:
@@ -1041,3 +1060,57 @@
Leave the equation (Esc) Sair da equação (Esc)
Preview Visualizar
getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft
Shape Effects Efeitos de forma
Glow Brilho
Soft Edges Bordas suaves
No Shadow Sem sombra
No Glow Sem brilho
No Soft Edges Sem bordas suaves
Glow Colors Cores do brilho
Weight Espessura
Shadow, glow and soft edges Sombra, brilho e bordas suaves
Offset: Bottom Right Deslocamento: inferior direito
Offset: Bottom Deslocamento: inferior
Offset: Bottom Left Deslocamento: inferior esquerdo
Offset: Right Deslocamento: direita
Offset: Center Deslocamento: centro
Offset: Left Deslocamento: esquerda
Offset: Top Right Deslocamento: superior direito
Offset: Top Deslocamento: superior
Offset: Top Left Deslocamento: superior esquerdo
Manage Styles Gerenciar estilos
Show or Hide Style Mostrar ou ocultar estilo
Manage Styles… Gerenciar estilos…
Sort order: Ordem:
As Recommended Recomendada
Alphabetical Alfabética
Font: {font} Fonte: {font}
{size} pt {size} pt
Color: {color} Cor: {color}
Space before: {pt} pt Espaço antes: {pt} pt
Space after: {pt} pt Espaço depois: {pt} pt
Based on: {style} Baseado em: {style}
Paragraph style Estilo de parágrafo
Character style Estilo de caractere
Linked style (paragraph and character) Estilo vinculado (parágrafo e caractere)
Table style Estilo de tabela
Built-in style Estilo integrado
Show in the Styles gallery Mostrar na galeria de estilos
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.
+74 -1
View File
@@ -12,8 +12,10 @@
# Product, format and technology names (WordCraft, PDF, Markdown, MCP …) stay in Latin letters.
(no suggestions) (nema predloga)
(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
@@ -23,7 +25,10 @@
Add Dodaj
Add a comment Dodaj komentar
Add a Table Dodaj tabelu
Add Object to Selection Dodaj objekat u izbor
Add Space Before Paragraph Dodaj razmak pre pasusa
Add space between Asian and Latin text Dodaj razmak između azijskog i latiničnog teksta
Add space between Asian text and numbers Dodaj razmak između azijskog teksta i brojeva
Add Text Dodaj tekst
Add to Dictionary Dodaj u rečnik
Add to Style Gallery Dodaj u galeriju stilova
@@ -42,7 +47,13 @@
All Caps Sva velika slova
All caps Sva velika slova
All Markup Sve izmene
Allow hanging punctuation Dozvoli viseću interpunkciju
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
Apply Styles Izaberi i primeni stilove
@@ -50,6 +61,8 @@
Arrange\nAll Rasporedi\nsve
Arrow Strelica
Artistic Effects Umetnički efekti
As Recommended Kao što je preporučeno
Asian Typography Azijska tipografija
Author Autor
AutoCorrect Options Opcije automatske ispravke
AutoFit Automatsko uklapanje
@@ -64,11 +77,57 @@
AutoSave documents you have saved in WordCraft Automatski čuvaj dokumente koje ste već sačuvali u WordCraft-u
AutoSave failed: {error}. Save the document to turn AutoSave back on. Automatsko čuvanje neuspelo: {error}. Sačuvajte dokument da biste ga ponovo uključili.
Bottom Margin Donja margina
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
Reopen Ponovo otvori
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
Manage Styles… Upravljanje stilovima…
No Glow Bez sjaja
No Shadow Bez senke
No Soft Edges Bez mekih ivica
Nothing collected yet. Items you copy or cut appear here. Još ništa nije prikupljeno. Ovde se pojavljuje ono što kopirate ili isečete.
Offset: Bottom Pomak: dole
Offset: Bottom Left Pomak: dole levo
Offset: Bottom Right Pomak: dole desno
Offset: Center Pomak: centar
Offset: Left Pomak: levo
Offset: Right Pomak: desno
Offset: Top Pomak: gore
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.
AutoSave saves every change to the file (needs a saved document) Automatsko čuvanje upisuje svaku izmenu u fajl (dokument mora biti već sačuvan)
AutoSave isn't available in the browser: it can't write to your files. Save downloads a copy. Automatsko čuvanje nije dostupno u pregledaču: ne može da upisuje u vaše fajlove. Čuvanje preuzima kopiju.
@@ -646,8 +705,10 @@
Show Prikaži
Show all formatting marks Prikaži sve znake formatiranja
Show Comments Prikaži komentare
Show in the Styles gallery Prikaži u galeriji stilova
Show Markup Prikaži izmene
Show Notes Prikaži napomene
Show or Hide Style Prikaži ili sakrij stil
Show rulers Prikaži lenjire
Show the community button in the title bar Prikaži dugme zajednice u traci naslova
Show/Hide ¶ Prikaži/sakrij ¶
@@ -663,9 +724,13 @@
Small Caps Kapitelke
Small caps kapitelke
SmartArt SmartArt
Soft Edges Meke ivice
Sort Sortiraj
Sort order: Redosled sortiranja:
Source code: github.com/storytold/wordcraft Izvorni kod: github.com/storytold/wordcraft
Space after: {pt} pt Razmak posle: {pt} pt
Space before/after: Razmak pre/posle:
Space before: {pt} pt Razmak pre: {pt} pt
Spacing Razmak
Spacing (pt): Razmak (pt):
Speech Govor
@@ -708,6 +773,7 @@
Table of\nContents Sadržaj
Table Properties Svojstva tabele
Table Row Height Visina reda tabele
Table style Stil tabele
Table Style Options Opcije stila tabele
Table Styles Stilovi tabele
Tables Tabele
@@ -726,7 +792,11 @@
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
@@ -753,6 +823,7 @@
Underline Words Only Podvuci samo reči
Undo Opozovi
Undo {action} Opozovi {action}
Ungroup Razgrupisanje
Up Gore
Update Field Ažuriraj polje
Update Index Ažuriraj indeks
@@ -778,6 +849,7 @@
Web Layout Veb raspored
Web page (*.html) Veb strana (*.html)
Web\nLayout Veb\nraspored
Weight Debljina
Whole page Cela strana
Whole words Samo cele reči
Wide Široko
@@ -802,6 +874,8 @@
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
{selected} of {words} words {selected} od {words} reči
@@ -1038,5 +1112,4 @@
Reset Crop Poništi isecanje
Insert a new equation (Alt+=) Umetni novu jednačinu (Alt+=)
Leave the equation (Esc) Napusti jednačinu (Esc)
Preview Pregled
getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft
Can't render this file because it contains an unexpected character in line 6 and column 25.
+74 -1
View File
@@ -16,8 +16,10 @@
# Product, format and technology names (WordCraft, PDF, Markdown, MCP …) stay in Latin letters.
(no suggestions) (нема предлога)
(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 Прихвати
@@ -27,7 +29,10 @@
Add Додај
Add a comment Додај коментар
Add a Table Додај табелу
Add Object to Selection Додај објекат у избор
Add Space Before Paragraph Додај размак пре пасуса
Add space between Asian and Latin text Додај размак између азијског и латиничног текста
Add space between Asian text and numbers Додај размак између азијског текста и бројева
Add Text Додај текст
Add to Dictionary Додај у речник
Add to Style Gallery Додај у галерију стилова
@@ -46,7 +51,13 @@
All Caps Сва велика слова
All caps Сва велика слова
All Markup Све измене
Allow hanging punctuation Дозволи висећу интерпункцију
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 Примени стил
Apply Styles Изабери и примени стилове
@@ -54,6 +65,8 @@
Arrange\nAll Распореди\nсве
Arrow Стрелица
Artistic Effects Уметнички ефекти
As Recommended Као што је препоручено
Asian Typography Азијска типографија
Author Аутор
AutoCorrect Options Опције аутоматске исправке
AutoFit Аутоматско уклапање
@@ -68,11 +81,57 @@
AutoSave documents you have saved in WordCraft Аутоматски чувај документе које сте већ сачували у WordCraft-у
AutoSave failed: {error}. Save the document to turn AutoSave back on. Аутоматско чување неуспело: {error}. Сачувајте документ да бисте га поново укључили.
Bottom Margin Доња маргина
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 Лева маргина
Reopen Поново отвори
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 Управљање стиловима
Manage Styles… Управљање стиловима…
No Glow Без сјаја
No Shadow Без сенке
No Soft Edges Без меких ивица
Nothing collected yet. Items you copy or cut appear here. Још ништа није прикупљено. Овде се појављује оно што копирате или исечете.
Offset: Bottom Помак: доле
Offset: Bottom Left Помак: доле лево
Offset: Bottom Right Помак: доле десно
Offset: Center Помак: центар
Offset: Left Помак: лево
Offset: Right Помак: десно
Offset: Top Помак: горе
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 нису погођени.
AutoSave saves every change to the file (needs a saved document) Аутоматско чување уписује сваку измену у фајл (документ мора бити већ сачуван)
AutoSave isn't available in the browser: it can't write to your files. Save downloads a copy. Аутоматско чување није доступно у прегледачу: не може да уписује у ваше фајлове. Чување преузима копију.
@@ -650,8 +709,10 @@
Show Прикажи
Show all formatting marks Прикажи све знаке форматирања
Show Comments Прикажи коментаре
Show in the Styles gallery Прикажи у галерији стилова
Show Markup Прикажи измене
Show Notes Прикажи напомене
Show or Hide Style Прикажи или сакриј стил
Show rulers Прикажи лењире
Show the community button in the title bar Прикажи дугме заједнице у траци наслова
Show/Hide ¶ Прикажи/сакриј ¶
@@ -667,9 +728,13 @@
Small Caps Капителке
Small caps капителке
SmartArt SmartArt
Soft Edges Меке ивице
Sort Сортирај
Sort order: Редослед сортирања:
Source code: github.com/storytold/wordcraft Изворни код: github.com/storytold/wordcraft
Space after: {pt} pt Размак после: {pt} пт
Space before/after: Размак пре/после:
Space before: {pt} pt Размак пре: {pt} пт
Spacing Размак
Spacing (pt): Размак (пт):
Speech Говор
@@ -712,6 +777,7 @@
Table of\nContents Садржај
Table Properties Својства табеле
Table Row Height Висина реда табеле
Table style Стил табеле
Table Style Options Опције стила табеле
Table Styles Стилови табеле
Tables Табеле
@@ -730,7 +796,11 @@
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 Листа тема
@@ -757,6 +827,7 @@
Underline Words Only Подвуци само речи
Undo Опозови
Undo {action} Опозови {action}
Ungroup Разгруписање
Up Горе
Update Field Ажурирај поље
Update Index Ажурирај индекс
@@ -782,6 +853,7 @@
Web Layout Веб распоред
Web page (*.html) Веб страна (*.html)
Web\nLayout Веб\nраспоред
Weight Дебљина
Whole page Цела страна
Whole words Само целе речи
Wide Широко
@@ -806,6 +878,8 @@
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} речи
{selected} of {words} words {selected} од {words} речи
@@ -1042,5 +1116,4 @@
Reset Crop Поништи исецање
Insert a new equation (Alt+=) Уметни нову једначину (Alt+=)
Leave the equation (Esc) Напусти једначину (Esc)
Preview Преглед
getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft
Can't render this file because it contains an unexpected character in line 11 and column 25.
+73
View File
@@ -23,7 +23,10 @@
Add Додати
Add a comment Додати коментар
Add a Table Додати таблицю
Add Object to Selection Додати об'єкт до виділення
Add Space Before Paragraph Додати інтервал перед абзацом
Add space between Asian and Latin text Додавати інтервал між азійським і латинським текстом
Add space between Asian text and numbers Додавати інтервал між азійським текстом і числами
Add Text Додати текст
Add to Dictionary Додати до словника
Add to Style Gallery Додати до колекції стилів
@@ -44,13 +47,17 @@
All Caps Усі великі літери
All caps Усі великі літери
All Markup Усі позначки
Allow hanging punctuation Дозволити висячу пунктуацію
Allow Latin text to wrap in the middle of a word Дозволити розрив латинського тексту посеред слова
Alt Text Альтернативний текст
Apply Asian line-breaking rules (kinsoku) Застосовувати азійські правила розриву рядків (кінсоку)
Apply Character Style Застосувати стиль символів
Apply Style Застосувати стиль
Arrange Упорядкувати
Arrange\nAll Упорядкувати\nвсі
Arrow Стрілка
Artistic Effects Художні ефекти
Asian Typography Азійська типографіка
Author Автор
AutoCorrect Options Параметри автовиправлення
AutoFit Добір розміру
@@ -132,6 +139,14 @@
Clear Formatting Level Очистити рівень форматування
Clear Paragraph Formatting Очистити форматування абзацу
Clipboard Буфер обміну
Clipboard Pane Панель буфера обміну
Clipboard Items Елементи буфера обміну
Paste Clipboard Item Вставити елемент буфера обміну
Paste All Вставити все
Delete Clipboard Item Видалити елемент буфера обміну
Click an item to paste it. Клацніть елемент, щоб вставити його.
Nothing collected yet. Items you copy or cut appear here. Поки що порожньо. Тут з'являться елементи, які ви скопіюєте або виріжете.
(picture or object) (зображення або об'єкт)
Close Закрити
Close Header and Footer Закрити колонтитули
Collapse Selection Зняти виділення
@@ -150,6 +165,7 @@
Compact Компактний
Compare Порівняти
Compress Pictures Стиснути зображення
Compress punctuation at the start of a line Стискати розділові знаки на початку рядка
Continuous Безперервний
Convert Перетворити
Convert Text to Table Перетворити текст на таблицю
@@ -194,6 +210,8 @@
Design Дизайн
Edit Comment Редагувати коментар
Left Margin Ліве поле
Indents and Spacing Відступи й інтервали
Line breaking Розрив рядків
More templates → Більше шаблонів →
No templates match your search. Немає шаблонів, що відповідають пошуку.
Reopen Відкрити знову
@@ -771,6 +789,7 @@
Underline Words Only Підкреслити лише слова
Undo Скасувати
Undo {action} Скасувати {action}
Ungroup Розгрупувати
Unformatted Text Неформатований текст
Up Угору
Update Field Оновити поле
@@ -1041,3 +1060,57 @@
Leave the equation (Esc) Вийти з рівняння (Esc)
Preview Попередній перегляд
getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft
Shape Effects Ефекти фігури
Glow Світіння
Soft Edges М'які краї
No Shadow Без тіні
No Glow Без світіння
No Soft Edges Без м'яких країв
Glow Colors Кольори світіння
Weight Товщина
Shadow, glow and soft edges Тінь, світіння та м'які краї
Offset: Bottom Right Зміщення: вниз праворуч
Offset: Bottom Зміщення: вниз
Offset: Bottom Left Зміщення: вниз ліворуч
Offset: Right Зміщення: праворуч
Offset: Center Зміщення: по центру
Offset: Left Зміщення: ліворуч
Offset: Top Right Зміщення: вгору праворуч
Offset: Top Зміщення: вгору
Offset: Top Left Зміщення: вгору ліворуч
Manage Styles Керування стилями
Show or Hide Style Показати або сховати стиль
Manage Styles… Керування стилями…
Sort order: Порядок сортування:
As Recommended Як рекомендовано
Alphabetical За абеткою
Font: {font} Шрифт: {font}
{size} pt {size} пт
Color: {color} Колір: {color}
Space before: {pt} pt Інтервал перед: {pt} пт
Space after: {pt} pt Інтервал після: {pt} пт
Based on: {style} На основі: {style}
Paragraph style Стиль абзацу
Character style Стиль символів
Linked style (paragraph and character) Зв’язаний стиль (абзац і символи)
Table style Стиль таблиці
Built-in style Вбудований стиль
Show in the Styles gallery Показувати в колекції стилів
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.
+74 -1
View File
@@ -23,7 +23,10 @@
Add 添加
Add a comment 添加批注
Add a Table 添加表格
Add Object to Selection 将对象添加到所选内容
Add Space Before Paragraph 在段前添加间距
Add space between Asian and Latin text 在亚洲文字与西文之间添加间距
Add space between Asian text and numbers 在亚洲文字与数字之间添加间距
Add Text 添加文字
Add to Dictionary 添加到词典
Add to Style Gallery 添加到样式库
@@ -42,7 +45,10 @@
All Caps 全部大写
All caps 全部大写
All Markup 所有标记
Allow hanging punctuation 允许标点溢出边界
Allow Latin text to wrap in the middle of a word 允许西文在单词中间换行
Alt Text 替代文字
Apply Asian line-breaking rules (kinsoku) 应用亚洲文字的断行规则(禁则)
Apply Character Style 应用字符样式
Apply Style 应用样式
Apply Styles 选择并应用样式
@@ -50,6 +56,7 @@
Arrange\nAll 全部重排
Arrow 箭头
Artistic Effects 艺术效果
Asian Typography 亚洲版式
Author 作者
AutoCorrect Options 自动更正选项
AutoFit 自动调整
@@ -64,9 +71,12 @@
AutoSave documents you have saved in WordCraft 自动保存在 WordCraft 中保存过的文档
AutoSave failed: {error}. Save the document to turn AutoSave back on. 自动保存失败:{error}。重新保存文档后将恢复自动保存。
Bottom Margin 下边距
Compress punctuation at the start of a line 压缩行首标点
Dark page (white text on black) 深色页面(黑底白字)
Edit Comment 编辑批注
Left Margin 左边距
Indents and Spacing 缩进和间距
Line breaking 断行
More templates → 更多模板 →
No templates match your search. 没有与搜索匹配的模板。
Reopen 重新打开
@@ -137,6 +147,15 @@
Clear 清除
Clear All Formatting 清除所有格式
Clipboard 剪贴板
Clipboard Pane 剪贴板窗格
Clipboard Items 剪贴板项目
Paste Clipboard Item 粘贴剪贴板项目
Paste All 全部粘贴
Delete Clipboard Item 删除剪贴板项目
Clear All 全部清除
Click an item to paste it. 单击项目即可粘贴。
Nothing collected yet. Items you copy or cut appear here. 尚无内容。复制或剪切的项目会显示在这里。
(picture or object) (图片或对象)
Close 关闭
Close Header and Footer 关闭页眉和页脚
Collapse Selection 折叠选定内容
@@ -756,6 +775,7 @@
Underline Words Only 仅给字词加下划线
Undo 撤消
Undo {action} 撤消{action}
Ungroup 取消组合
Up 上
Update Field 更新域
Update Index 更新索引
@@ -909,7 +929,6 @@
Clear Paragraph Formatting 清除段落格式
Clear Character Style 清除字符样式
Clear Character Formatting 清除字符格式
Clear All 全部清除
Paste Special 选择性粘贴
Paste Special… 选择性粘贴…
Paste as: 粘贴为:
@@ -1040,3 +1059,57 @@
Leave the equation (Esc) 退出公式 (Esc)
Preview 预览
getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft
Shape Effects 形状效果
Glow 发光
Soft Edges 柔化边缘
No Shadow 无阴影
No Glow 无发光
No Soft Edges 无柔化边缘
Glow Colors 发光颜色
Weight 粗细
Shadow, glow and soft edges 阴影、发光和柔化边缘
Offset: Bottom Right 偏移:右下
Offset: Bottom 偏移:下
Offset: Bottom Left 偏移:左下
Offset: Right 偏移:右
Offset: Center 偏移:居中
Offset: Left 偏移:左
Offset: Top Right 偏移:右上
Offset: Top 偏移:上
Offset: Top Left 偏移:左上
Manage Styles 管理样式
Show or Hide Style 显示或隐藏样式
Manage Styles… 管理样式…
Sort order: 排序方式:
As Recommended 按推荐
Alphabetical 按字母顺序
Font: {font} 字体:{font}
{size} pt {size} 磅
Color: {color} 颜色:{color}
Space before: {pt} pt 段前间距:{pt} 磅
Space after: {pt} pt 段后间距:{pt} 磅
Based on: {style} 基于:{style}
Paragraph style 段落样式
Character style 字符样式
Linked style (paragraph and character) 链接样式(段落和字符)
Table style 表格样式
Built-in style 内置样式
Show in the Styles gallery 在样式库中显示
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.
+74 -1
View File
@@ -13,7 +13,10 @@
Add 新增
Add a comment 新增註解
Add a Table 新增表格
Add Object to Selection 將物件加入選取範圍
Add Space Before Paragraph 在段落前新增間距
Add space between Asian and Latin text 在亞洲文字與西文之間加入間距
Add space between Asian text and numbers 在亞洲文字與數字之間加入間距
Add Text 新增文字
Add to Dictionary 新增至字典
Add to Style Gallery 新增至樣式庫
@@ -32,7 +35,10 @@
All Caps 全部大寫
All caps 全部大寫
All Markup 所有標記
Allow hanging punctuation 允許標點溢出邊界
Allow Latin text to wrap in the middle of a word 允許西文在單字中間換行
Alt Text 替代文字
Apply Asian line-breaking rules (kinsoku) 套用亞洲文字的斷行規則(禁則)
Apply Character Style 套用字元樣式
Apply Style 套用樣式
Apply Styles 選擇並套用樣式
@@ -40,6 +46,7 @@
Arrange\nAll 全部排列
Arrow 箭號
Artistic Effects 美術效果
Asian Typography 亞洲版式
Author 作者
AutoCorrect Options 自動校正選項
AutoFit 自動調整
@@ -54,9 +61,12 @@
AutoSave documents you have saved in WordCraft 自動儲存在 WordCraft 中儲存過的文件
AutoSave failed: {error}. Save the document to turn AutoSave back on. 自動儲存失敗:{error}。重新儲存文件後將恢復自動儲存。
Bottom Margin 下邊界
Compress punctuation at the start of a line 壓縮行首標點
Dark page (white text on black) 深色頁面(黑底白字)
Edit Comment 編輯註解
Left Margin 左邊界
Indents and Spacing 縮排和間距
Line breaking 斷行
More templates → 更多範本 →
No templates match your search. 沒有符合搜尋的範本。
Reopen 重新開啟
@@ -127,6 +137,15 @@
Clear 清除
Clear All Formatting 清除所有格式設定
Clipboard 剪貼簿
Clipboard Pane 剪貼簿窗格
Clipboard Items 剪貼簿項目
Paste Clipboard Item 貼上剪貼簿項目
Paste All 全部貼上
Delete Clipboard Item 刪除剪貼簿項目
Clear All 全部清除
Click an item to paste it. 按一下項目即可貼上。
Nothing collected yet. Items you copy or cut appear here. 尚無內容。複製或剪下的項目會顯示在這裡。
(picture or object) (圖片或物件)
Close 關閉
Close Header and Footer 關閉頁首及頁尾
Collapse Selection 摺疊選取範圍
@@ -746,6 +765,7 @@
Underline Words Only 只在文字下加底線
Undo 復原
Undo {action} 復原{action}
Ungroup 取消群組
Up 上
Update Field 更新功能變數
Update Index 更新索引
@@ -899,7 +919,6 @@
Clear Paragraph Formatting 清除段落格式設定
Clear Character Style 清除字元樣式
Clear Character Formatting 清除字元格式設定
Clear All 全部清除
Paste Special 選擇性貼上
Paste Special… 選擇性貼上…
Paste as: 貼上為:
@@ -1030,3 +1049,57 @@
Leave the equation (Esc) 離開方程式 (Esc)
Preview 預覽
getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft
Shape Effects 圖案效果
Glow 光暈
Soft Edges 柔化邊緣
No Shadow 無陰影
No Glow 無光暈
No Soft Edges 無柔化邊緣
Glow Colors 光暈色彩
Weight 粗細
Shadow, glow and soft edges 陰影、光暈和柔化邊緣
Offset: Bottom Right 位移:右下
Offset: Bottom 位移:下
Offset: Bottom Left 位移:左下
Offset: Right 位移:右
Offset: Center 位移:置中
Offset: Left 位移:左
Offset: Top Right 位移:右上
Offset: Top 位移:上
Offset: Top Left 位移:左上
Manage Styles 管理樣式
Show or Hide Style 顯示或隱藏樣式
Manage Styles… 管理樣式…
Sort order: 排序方式:
As Recommended 依建議
Alphabetical 依字母順序
Font: {font} 字型:{font}
{size} pt {size} 點
Color: {color} 色彩:{color}
Space before: {pt} pt 段落前間距:{pt} 點
Space after: {pt} pt 段落後間距:{pt} 點
Based on: {style} 根據:{style}
Paragraph style 段落樣式
Character style 字元樣式
Linked style (paragraph and character) 連結樣式(段落和字元)
Table style 表格樣式
Built-in style 內建樣式
Show in the Styles gallery 在樣式庫中顯示
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.
+8
View File
@@ -312,6 +312,14 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) {
pen.rect(3.0, 3.0, 11.0, 11.0, c);
pen.fcircle(13.0, 13.0, 4.5, a);
}
// Shape Effects: a square casting a soft shadow down and to the right.
"shapeEffects" => {
for (k, alpha) in [(2.0, 30), (1.0, 50), (0.0, 80)] {
pen.frect(7.0 - k, 7.0 - k, 18.0 + k, 18.0 + k, Color32::from_rgba_unmultiplied(0, 0, 0, alpha));
}
pen.frect(3.0, 3.0, 14.0, 14.0, a);
pen.rect(3.0, 3.0, 14.0, 14.0, c);
}
"icons" => {
pen.circle(10.0, 10.0, 7.5, c);
pen.fcircle(7.5, 8.0, 1.0, c);
+131 -12
View File
@@ -114,7 +114,8 @@ 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.
/// View › Switch Modes: show pages dark (white text on black), kept between runs. Only the
/// pages: the interface follows [`UiState::theme`] (#312).
pub dark_page: bool,
}
@@ -175,7 +176,8 @@ 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,
applied_dark: Option<bool>,
/// The interface theme setting last installed in egui ([`theme::apply`]).
applied_theme: Option<theme::Appearance>,
pub frame_ms: f64,
/// The window title last sent; a viewport command schedules a repaint, so only send changes.
sent_title: String,
@@ -245,7 +247,7 @@ impl WordApp {
fonts_system_cjk: false,
want_system_cjk: false,
fonts_frames: 0,
applied_dark: None,
applied_theme: None,
frame_ms: 0.0,
sent_title: String::new(),
quit_requested: false,
@@ -326,7 +328,7 @@ impl WordApp {
{
return r;
}
let r = self.execute(id, params);
let r = self.execute_user(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)
@@ -363,11 +365,23 @@ 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();
}
let bytes = wordcraft_engine::io::save_bytes(&name, &self.session.doc)?;
// 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)?;
if let Some(d) = &self.services.download
&& let Err(e) = d(&name, &bytes)
{
@@ -434,7 +448,23 @@ impl WordApp {
if !go {
return Ok(json!({"done": false}));
}
self.execute(&then, params).map(|r| json!({"done": true, "result": r}))
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
}
/// Save before New/Open/Close (and the mailings that replace the document): true once the
@@ -736,12 +766,12 @@ impl WordApp {
ctx.options_mut(|o| o.zoom_with_keyboard = false);
self.styled = true;
}
// 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);
// 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);
}
if self.ctx.is_none() {
self.ctx = Some(ctx.clone());
@@ -1052,6 +1082,8 @@ 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,
}
}
@@ -1411,6 +1443,34 @@ 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() {
@@ -2164,6 +2224,65 @@ 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();
+13
View File
@@ -94,6 +94,12 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect]
return false;
}
let Some((object, grab)) = grab_at(app, layout, pages, scale, at) else { return false };
// Shift/Ctrl+click adds an object to the selected ones (or takes it out), for Group.
let adding = ui.input(|i| i.modifiers.shift || i.modifiers.command);
if pressed && adding && grab == Grab::Move && selected(app).is_some() {
let _ = app.run("select.addObject", json!({"pos": object.pos()}));
return true;
}
if pressed {
let pos = object.pos();
let end = Pos { off: pos.off + OBJ.len_utf8(), ..pos.clone() };
@@ -161,6 +167,12 @@ pub fn paint(app: &WordApp, painter: &Painter, t: &Tokens, layout: &DocLayout, p
frame::paint(painter, t, to, false, false);
return;
}
// The others selected along with it (Shift+click): their outlines.
for p in &app.session.also_selected {
if let Some(f) = layout.object(p, app.session.page_hint).and_then(|o| screen(pages, scale, o.page, o.rect)) {
frame::paint(painter, t, f, false, false);
}
}
if let Some((o, editing)) = active(app, layout)
&& let Some(f) = screen(pages, scale, o.page, o.rect)
{
@@ -238,6 +250,7 @@ mod tests {
stroke_width: 1.0,
float: Default::default(),
story: None,
effects: Default::default(),
};
let (app, hit) = app_with(shape);
assert!(!fixed(&app, &hit));
+56 -1
View File
@@ -1,4 +1,4 @@
//! Side panes: Navigation (headings, pages, search results), Styles, Style Inspector, Comments.
//! Side panes: Navigation (headings, pages, search results), Clipboard, Styles, Style Inspector, Comments.
use egui::{Stroke, Ui, vec2};
use serde_json::{Value, json};
@@ -16,6 +16,13 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border)))
.show(ui, |ui| nav(app, ui));
}
if app.session.view.clipboard_pane {
egui::Panel::left("clipboard_pane")
.default_size(250.0)
.resizable(true)
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border)))
.show(ui, |ui| clipboard(app, ui));
}
if app.session.view.styles_pane {
egui::Panel::right("styles_pane")
.default_size(250.0)
@@ -146,6 +153,51 @@ fn nav(app: &mut WordApp, ui: &mut Ui) {
});
}
/// Items collected by Copy and Cut, newest first; clicking one pastes it at the caret.
fn clipboard(app: &mut WordApp, ui: &mut Ui) {
if header(ui, "Clipboard") {
let _ = app.run("edit.clipboardPane", json!({"value": false}));
return;
}
let items: Vec<String> = app.session.clip_history.items().iter().map(|i| i.preview()).collect();
ui.horizontal(|ui| {
if ui.add_enabled(!items.is_empty(), egui::Button::new(tl!("Paste All"))).clicked() {
let _ = app.run("edit.pasteAllClipboard", json!({}));
app.canvas.want_focus = true;
}
if ui.add_enabled(!items.is_empty(), egui::Button::new(tl!("Clear All"))).clicked() {
let _ = app.run("edit.clearClipboard", json!({}));
}
});
ui.add_space(4.0);
let hint = if items.is_empty() { "Nothing collected yet. Items you copy or cut appear here." } else { "Click an item to paste it." };
ui.label(egui::RichText::new(tl!(hint)).small().weak());
ui.separator();
let t = Tokens::get(ui.ctx());
egui::ScrollArea::vertical().show(ui, |ui| {
for (i, preview) in items.iter().enumerate() {
egui::Frame::NONE.fill(t.input).stroke(Stroke::new(1.0, t.border)).corner_radius(6).inner_margin(8).show(ui, |ui| {
ui.set_width(ui.available_width());
let text = if preview.is_empty() { tl!("(picture or object)").to_string() } else { preview.clone() };
let mut job =
egui::text::LayoutJob::single_section(text, egui::TextFormat { font_id: regular(12.5), color: t.text, ..Default::default() });
job.wrap.max_width = ui.available_width();
job.wrap.max_rows = 3;
if ui.add(egui::Button::new(job).frame(false)).on_hover_text(tl!("Paste")).clicked() {
let _ = app.run("edit.pasteClipboardItem", json!({"index": i}));
app.canvas.want_focus = true;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
if ui.small_button(tl!("Delete")).clicked() {
let _ = app.run("edit.deleteClipboardItem", json!({"index": i}));
}
});
});
ui.add_space(6.0);
}
});
}
fn styles(app: &mut WordApp, ui: &mut Ui) {
if header(ui, "Styles") {
let _ = app.run("view.stylesPane", json!({"value": false}));
@@ -168,6 +220,9 @@ fn styles(app: &mut WordApp, ui: &mut Ui) {
let _ = app.run("format.clear", json!({}));
}
});
if ui.button(tl!("Manage Styles…")).clicked() {
let _ = app.run("styles.manage", json!({}));
}
if ui.selectable_label(app.session.view.style_inspector, tl!("Style Inspector")).clicked() {
let _ = app.run("styles.inspector", json!({}));
}
+49
View File
@@ -218,6 +218,55 @@ fn snippet_blocks(
Some(egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &px))
}
/// Manage Styles' preview of one style (`ty`: the `styles.manage` type), `size` points wide and
/// tall: a line of sample text, or a small table for a table style.
pub fn style_preview(app: &mut WordApp, ui: &mut Ui, id: &str, ty: &str, size: egui::Vec2) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(size, Sense::hover());
ui.painter().rect(r, 3.0, egui::Color32::WHITE, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
let rev = app.session.rev();
let skey = app.previews.styles_key(&app.session.doc, rev);
let ppp = ui.ctx().pixels_per_point();
let (w, h) = (size.x - 12.0, size.y - 8.0);
let key = format!("manage:{id}:{ty}:{skey}:{ppp}:{w}x{h}");
let doc = &app.session.doc;
let tex = app.previews.get_or(ui.ctx(), &key, || {
let block = match ty {
"table" => {
let mut tb = Table::new(3, 4, w - 4.0);
tb.props.style = Some(id.to_string());
for (ri, row) in tb.rows.iter_mut().enumerate() {
for (ci, cell) in row.cells.iter_mut().enumerate() {
let txt = match (ri, ci) {
(0, c) => ["", "A", "B", "C"].get(c).copied().unwrap_or(""),
(_, 0) => "Row",
_ => "1",
};
cell.blocks = vec![para_block(Paragraph::with_text(txt, CharProps::default()))];
}
}
Block::Table(tb)
}
"character" => Block::Para(Paragraph::with_text("AaBbCcYyZz", CharProps { style: Some(id.to_string()), ..CharProps::default() })),
_ => {
let mut p = Paragraph::with_text("AaBbCcYyZz", CharProps::default()).styled(id);
p.props.space_before = Some(0.0);
p.props.indent_left = Some(0.0);
p.props.indent_first = Some(0.0);
p.props.numbering = None;
Block::Para(p)
}
};
snippet_blocks(doc, vec![block], w, h, ppp, 4.0, None)
});
if let Some(hd) = tex {
let sz = hd.size_vec2() / ppp;
let ir = Rect::from_min_size(pos2(r.min.x + 2.0, r.min.y + 2.0), sz.min(r.size() - vec2(4.0, 4.0)));
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2((ir.width() / sz.x).min(1.0), (ir.height() / sz.y).min(1.0)));
ui.painter().with_clip_rect(r).image(hd.id(), ir, uv, egui::Color32::WHITE);
}
}
/// The Home tab's Styles gallery.
pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) {
let t = Tokens::get(ui.ctx());
+133 -15
View File
@@ -14,6 +14,11 @@ pub fn has_picture_selected(s: &wordcraft_engine::Session) -> bool {
matches!(wordcraft_engine::cmd::objects::selected(s), Some((_, wordcraft_doc::para::InlineObject::Image { .. })))
}
/// Whether the selection is a shape or text box (so Shape Format is shown).
pub fn has_shape_selected(s: &wordcraft_engine::Session) -> bool {
matches!(wordcraft_engine::cmd::objects::selected(s), Some((_, wordcraft_doc::para::InlineObject::Shape { .. })))
}
/// Contextual tabs for the current selection (pure, tested).
pub fn contextual_tabs(s: &wordcraft_engine::Session) -> Vec<&'static str> {
let mut tabs = Vec::new();
@@ -24,6 +29,9 @@ pub fn contextual_tabs(s: &wordcraft_engine::Session) -> Vec<&'static str> {
if has_picture_selected(s) {
tabs.push("Picture Format");
}
if has_shape_selected(s) {
tabs.push("Shape Format");
}
// Editing an equation.
if s.math.is_some() {
tabs.push("Equation");
@@ -77,7 +85,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
}
}
for tab in tabs {
let contextual = tab.starts_with("Table ") || tab == "Picture Format" || tab == "Equation";
let contextual = tab.starts_with("Table ") || tab.ends_with(" Format") || tab == "Equation";
let shown = tl!(tab);
let w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(shown.to_string(), medium(12.5), t.text).size().x) + 18.0;
let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click());
@@ -171,6 +179,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
"Table Layout" => table_layout(app, ui),
"Equation" if app.session.math.is_some() => crate::equation_tab::show(app, ui),
"Picture Format" => picture_format(app, ui),
"Shape Format" => shape_format(app, ui),
_ => home(app, ui),
}
});
@@ -244,7 +253,7 @@ fn mi_check(ui: &mut Ui, app: &mut WordApp, label: &str, checked: bool, id: &str
fn home(app: &mut WordApp, ui: &mut Ui) {
let st = app.session.run("format.state", &json!({})).unwrap_or_default();
let flag = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false);
group(ui, "Clipboard", None, app, |ui, app| {
group(ui, "Clipboard", Some("edit.clipboardPane"), app, |ui, app| {
menu_button(ui, app, "paste", Some("Paste"), "Paste (⌘V)", true, |ui, app| {
mi(ui, app, "Paste", "edit.paste", json!({}));
mi(ui, app, "Keep Text Only", "edit.pasteText", json!({}));
@@ -803,12 +812,20 @@ fn layout(app: &mut WordApp, ui: &mut Ui) {
});
stack(ui, |ui| {
small(ui, app, "align", None, "Align", "arrange.align", json!({}), false);
small(ui, app, "group", None, "Group", "arrange.group", json!({}), false);
group_menu(ui, app, None);
small(ui, app, "rotate", None, "Rotate", "arrange.rotate", json!({}), false);
});
});
}
/// Arrange › Group: Group (Shift+click objects to select several) and Ungroup.
fn group_menu(ui: &mut Ui, app: &mut WordApp, label: Option<&str>) {
menu_button(ui, app, "group", label, "Group", false, |ui, app| {
mi(ui, app, "Group", "arrange.group", json!({}));
mi(ui, app, "Ungroup", "arrange.ungroup", json!({}));
});
}
fn references(app: &mut WordApp, ui: &mut Ui) {
group(ui, "Table of Contents", None, app, |ui, app| {
menu_button(ui, app, "toc", Some("Table of\nContents"), "Table of Contents", true, |ui, app| {
@@ -1103,6 +1120,83 @@ fn help(app: &mut WordApp, ui: &mut Ui) {
});
}
/// Shape Format: fill, outline and effects of the selected shape, and its arrangement.
fn shape_format(app: &mut WordApp, ui: &mut Ui) {
let (stroke, glow_size) = match wordcraft_engine::cmd::objects::selected(&app.session) {
Some((_, wordcraft_doc::para::InlineObject::Shape { stroke, effects, .. })) => (stroke, effects.glow.map(|g| g.size)),
_ => (None, None),
};
let theme = app.session.doc.settings.theme_colors.clone();
group(ui, "Shape Styles", None, app, |ui, app| {
stack(ui, |ui| {
menu_button(ui, app, "shading", Some("Shape Fill"), "Shape Fill", false, |ui, app| {
mi(ui, app, "No Color", "shape.fill", json!({"color": null}));
if let Some(hex) = color_grid(ui, &theme) {
let _ = app.run("shape.fill", json!({"color": hex}));
ui.close();
}
});
menu_button(ui, app, "pen", Some("Shape Outline"), "Shape Outline", false, |ui, app| {
mi(ui, app, "No Color", "shape.outline", json!({"color": null}));
if let Some(hex) = color_grid(ui, &theme) {
let _ = app.run("shape.outline", json!({"color": hex}));
ui.close();
}
ui.separator();
ui.menu_button(tl!("Weight"), |ui| {
let color = stroke.unwrap_or(wordcraft_doc::Rgb::BLACK).hex();
for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] {
mi(ui, app, &format!("{w} pt"), "shape.outline", json!({"color": color, "width": w}));
}
});
});
menu_button(ui, app, "shapeEffects", Some("Shape Effects"), "Shadow, glow and soft edges", false, |ui, app| {
shape_effects_menu(ui, app, &theme, glow_size)
});
});
});
group(ui, "Arrange", None, app, |ui, app| {
big(ui, app, "position", "Position", "arrange.position", json!({}), false);
big(ui, app, "wrapText", "Wrap\nText", "arrange.wrap", json!({}), false);
stack(ui, |ui| {
small(ui, app, "rotate", Some("Rotate"), "Rotate", "arrange.rotate", json!({}), false);
small(ui, app, "align", Some("Align"), "Align", "arrange.align", json!({}), false);
});
});
}
/// Shape Format › Shape Effects: Shadow, Glow and Soft Edges, each with presets and a "No …" entry.
fn shape_effects_menu(ui: &mut Ui, app: &mut WordApp, theme: &[wordcraft_doc::Rgb], glow_size: Option<f32>) {
ui.menu_button(tl!("Shadow"), |ui| {
mi(ui, app, "No Shadow", "shape.effects", json!({"shadow": null}));
ui.separator();
for (id, label) in wordcraft_doc::effects::SHADOW_PRESETS {
mi(ui, app, label, "shape.effects", json!({"shadow": id}));
}
});
ui.menu_button(tl!("Glow"), |ui| {
mi(ui, app, "No Glow", "shape.effects", json!({"glow": null}));
ui.separator();
for size in [5.0, 8.0, 11.0, 18.0] {
mi(ui, app, &format!("{size} pt"), "shape.effects", json!({"glow": size}));
}
ui.separator();
ui.menu_button(tl!("Glow Colors"), |ui| {
if let Some(hex) = color_grid(ui, theme) {
let _ = app.run("shape.effects", json!({"glow": {"color": hex, "size": glow_size.unwrap_or(8.0)}}));
ui.close();
}
});
});
ui.menu_button(tl!("Soft Edges"), |ui| {
mi(ui, app, "No Soft Edges", "shape.effects", json!({"softEdge": null}));
ui.separator();
for r in [1.0, 2.5, 5.0, 10.0, 25.0, 50.0] {
mi(ui, app, &format!("{r} pt"), "shape.effects", json!({"softEdge": r}));
}
});
}
fn picture_format(app: &mut WordApp, ui: &mut Ui) {
group(ui, "Adjust", None, app, |ui, app| {
menu_button(ui, app, "picture", Some("Corrections"), "Brightness, contrast and sharpness", true, |ui, app| {
@@ -1158,6 +1252,7 @@ fn picture_format(app: &mut WordApp, ui: &mut Ui) {
stack(ui, |ui| {
small(ui, app, "rotate", Some("Rotate"), "Rotate", "arrange.rotate", json!({}), false);
small(ui, app, "align", Some("Align"), "Align", "arrange.align", json!({}), false);
group_menu(ui, app, Some("Group"));
});
});
group(ui, "Size", None, app, |ui, app| {
@@ -1255,18 +1350,20 @@ fn table_design(app: &mut WordApp, ui: &mut Ui) {
});
});
group(ui, "Table Styles", None, app, |ui, app| {
let styles: Vec<(String, String)> = app
// The document's own styles first, so a new one shows without scrolling.
let mut styles: Vec<(bool, String, String)> = app
.session
.doc
.styles
.styles
.iter()
.filter(|s| s.kind == wordcraft_doc::StyleKind::Table && !s.hidden)
.map(|s| (s.id.clone(), s.name.clone()))
.map(|s| (s.builtin, s.id.clone(), s.name.clone()))
.collect();
styles.sort_by_key(|(builtin, _, _)| *builtin);
egui::ScrollArea::horizontal().max_width(420.0).show(ui, |ui| {
ui.horizontal(|ui| {
for (id, name) in styles {
for (_, id, name) in styles {
if crate::previews::table_style_tile(ui, app, &id).on_hover_text(name).clicked() {
let _ = app.run("table.style", json!({"style": id}));
}
@@ -1274,21 +1371,31 @@ fn table_design(app: &mut WordApp, ui: &mut Ui) {
});
});
stack(ui, |ui| {
let can_modify = look.is_some()
&& app
.session
.sel
.focus
.path
.cell()
.and_then(|(tp, _, _)| app.session.doc.table(app.session.sel.focus.story, &tp)?.props.style.clone())
.is_some();
let current = look.and_then(|_| {
let (tp, _, _) = app.session.sel.focus.path.cell()?;
let id = app.session.doc.table(app.session.sel.focus.story, &tp)?.props.style.clone()?;
app.session
.doc
.styles
.get(&id)
.filter(|st| st.kind == wordcraft_doc::StyleKind::Table)
.map(wordcraft_engine::cmd::table_style::is_builtin_table_style)
});
let can_modify = current.is_some();
// Built-in table styles can't be deleted.
let can_delete = current == Some(false);
menu_button(ui, app, "styles", Some("Styles"), "Table Styles", false, |ui, app| {
mi(ui, app, "New Table Style…", "ui.dialog", json!({"name": "newTableStyle"}));
if ui.add_enabled(can_modify, egui::Button::new(tl!("Modify Table Style…")).min_size(vec2(200.0, 0.0))).clicked() {
let _ = app.run("ui.dialog", json!({"name": "modifyTableStyle"}));
ui.close();
}
if ui.add_enabled(can_delete, egui::Button::new(tl!("Delete Table Style")).min_size(vec2(200.0, 0.0))).clicked() {
if let Err(e) = app.run("table.deleteStyle", json!({})) {
app.status(e);
}
ui.close();
}
});
split(ui, app, "shading", "Shading", "table.shading", json!({"color": app.canvas.last_shading.clone()}), false, None, |ui, app| {
mi(ui, app, "No Color", "table.shading", json!({"color": null}));
@@ -1464,6 +1571,17 @@ mod tests {
assert!(!has_picture_selected(&s));
}
#[test]
fn shape_format_tab_appears_when_shape_selected() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
assert!(contextual_tabs(&s).contains(&"Shape Format"));
assert!(!contextual_tabs(&s).contains(&"Picture Format"));
s.run("select.collapse", &json!({"end": true})).unwrap();
s.run("text.insert", &json!({"text": "x"})).unwrap();
assert!(!contextual_tabs(&s).contains(&"Shape Format"));
}
#[test]
fn resolve_tab_falls_back_when_contextual_tab_expires() {
let all = ["Home", "Table Design", "Picture Format"];
+42 -19
View File
@@ -8,8 +8,9 @@ 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 › Dark Mode): a fixed light or
/// dark palette, or `System`, which follows the OS light/dark appearance live (#115).
/// 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).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(from = "String", into = "String")]
pub enum Appearance {
@@ -54,6 +55,15 @@ 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`.
@@ -365,8 +375,35 @@ fn tracked_job(text: &str, font: &FontId, tracking: f32, color: Color32) -> egui
egui::text::LayoutJob::single_section(text.to_string(), format)
}
/// Apply tokens to egui's style.
pub fn apply(ctx: &egui::Context, t: &Tokens) {
/// 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 {
let mut v = if t.dark { Visuals::dark() } else { Visuals::light() };
v.panel_fill = t.ribbon;
v.window_fill = t.menu;
@@ -398,21 +435,7 @@ pub fn apply(ctx: &egui::Context, t: &Tokens) {
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 }) };
// 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;
});
v
}
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, &Tokens::light());
crate::theme::apply(&ctx, crate::theme::Appearance::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).
+2 -1
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 |
| Equations (OMML `m:oMath`) | ✅ | ✅ | #191; manual breaks (`m:brk`) and the settings' `m:mathPr` wrapping options (#326) |
| 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,5 +80,6 @@ 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 |
+4 -2
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), freezes (#77, #59 Thai fonts, #31 font selector), no writes without admin on Windows 11 (#218), broken shape tool (#142), images can't be moved (#82) | B | 62 open issues, ~15 of them crashes, hangs or data-path failures | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) |
| 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170) (lag on GPUs without a DirectX 12 driver fixed, #316), 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**: long display equations don't break across lines; ink equations | | #191 notes | Maths-heavy documents | 3–4 | [layout-parity.md](layout-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) |
**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,6 +64,8 @@ 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 |
+2 -1
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); 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), 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) |
| 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,6 +23,7 @@ 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 |
+2 -1
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 | 🟡 | #191; long display equations don't break across lines | 3–4 |
| 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) | — |
| 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,5 +68,6 @@ 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 |
+5 -13
View File
@@ -2,26 +2,26 @@
Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.
**384 of 430 catalog features have commands (89.3%).**
**390 of 431 catalog features have commands (90.5%).**
| Tab | Live | Total |
|---|---|---|
| File | 20 | 20 |
| Quick Access | 4 | 4 |
| Home | 95 | 101 |
| Home | 98 | 101 |
| Insert | 42 | 53 |
| Draw | 0 | 11 |
| Design | 10 | 10 |
| Layout | 18 | 19 |
| Layout | 19 | 19 |
| References | 24 | 24 |
| Mailings | 17 | 17 |
| Review | 26 | 29 |
| View | 25 | 29 |
| Table Design | 7 | 7 |
| Table Design | 8 | 8 |
| Table Layout | 28 | 30 |
| Equation | 21 | 22 |
| Picture Format | 13 | 13 |
| Shape Format | 2 | 6 |
| Shape Format | 3 | 6 |
| Header & Footer | 5 | 5 |
| Editing | 24 | 24 |
| Tools | 3 | 6 |
@@ -30,11 +30,8 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
### Home
- Clipboard › Clipboard Pane (`edit.clipboardPane`)
- Font › Phonetic Guide (`format.phonetic`)
- Font › Enclose Characters (`format.enclose`)
- Paragraph › Asian Typography (`para.asianTypography`)
- Styles › Manage Styles (`styles.manage`)
- Voice › Dictate (`tools.dictate`)
### Insert
@@ -65,10 +62,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
- Insert › Drawing Canvas (`insert.canvas`)
- Replay › Ink Replay (`draw.replay`)
### Layout
- Arrange › Group (`arrange.group`)
### Review
- Language › Translate (`review.translate`)
@@ -93,7 +86,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
### Shape Format
- Shape Styles › Shape Effects (`shape.effects`)
- Text › Text Direction (`shape.textDirection`)
- Text › Align Text (`shape.alignText`)
- Text › Create Link (`shape.link`)
+1 -1
View File
@@ -13,7 +13,7 @@ The generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md).
| Number | Value | Kind |
|---|---|---|
| Ribbon/menu catalog coverage | **384 / 430 (89.3%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) |
| Ribbon/menu catalog coverage | **390 / 431 (90.5%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) |
| Feature breadth (weighted, beyond the ribbon: dialog options, citation styles, proofing languages, chart types, UI languages) | **~80%** | estimated |
| Feature depth (weighted by use, table below) | **~68%** | estimated |
| **Ready for real work** (full target) | **~60%** (range 55–63%) | estimated: weighted sum over the dimension table ([By dimension](#by-dimension)) |
+2 -1
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), dark page (#194) | ✅ | ✅ | 90% | — |
| Interface themes (light, dark, system #249, follows OS changes live #311), dark page separate from the interface theme (#194, #312) | ✅ | ✅ | 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,6 +36,7 @@ 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 |