Compare commits

..
16 Commits
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 a1caf22a0b Bump version to 0.4.0 (from 0.3.0) for release; refresh contributors
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:02:32 -04:00
d7d9702bda Interface languages: follow the system, or pick one in Options (#8) (#12)
WordCraft's interface can now be shown in Simplified Chinese, Traditional
Chinese or Japanese as well as English. The new `language` setting is
`auto` by default: the UI follows the operating system's preferred
languages (Windows display language, macOS preferred languages,
LANG/LC_* on Unix, via sys-locale; WORDCRAFT_LANGUAGE overrides) when
there is a catalog for one of them, and English otherwise. File > Options
> Interface language picks one by hand, and the `ui.language` command does
the same for scripts and agents. The setting persists with the other UI
preferences.

The i18n module is PdfCraft's (catalog format, parser, locale matching),
trimmed to plain entries. English strings in the code are the keys; the
ribbon helpers (group, big, small, split, menu_button, menu items,
tooltips and ribbon locations), tabs, Backstage, Options, dialogs, panes,
the status bar, the context menu and command search translate at draw
time. Command ids, documents, the CLI, MCP and the control channel stay
English. Catalogs hold ~780 entries per language, written clean-room from
the English meaning.

Chinese interfaces put the craft-fonts Chinese face before the Japanese
one so a line never mixes glyph styles; fonts are reinstalled when the
language changes.

Fixes #8.


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <shared2@storyteller.ai>
2026-10-09 09:22:42 -04:00
Gert Pellin - GPEandBrandon Thomas 9b6f42dd5f Desktop: write log records to standard error and a rotating log file (#17)
* Desktop: write log records to standard error and a rotating log file

The desktop app installed no logger, so every log::warn!/log::error! in
the workspace was discarded: an unreadable part of a .docx, a picture or
font a PDF export had to leave out, a PNG that would not encode. A launch
from a desktop menu has no terminal either, and a panic's message went to
that missing terminal, so there was nothing to attach to a bug report.

apps/wordcraft/src/logging.rs (no new dependency):
- standard error plus logs/wordcraft.log next to the preferences
  (~/.config/wordcraft/logs, or under $XDG_CONFIG_HOME); each launch
  rotates the previous log to wordcraft.1.log and .2.log, so the log of
  a run that crashed survives the next start; the file is capped at 16 MiB
- records logged before the settings directory is known are buffered
  (bounded) and written once the file is attached
- default levels: info for wordcraft*, warn for everything else;
  RUST_LOG replaces them with env_logger-style directives
- never panics: an unusable directory or a failed write leaves standard
  error as the only sink
- runs with WORDCRAFT_NO_PREFS (agents' test runs) log to standard error
  only, so they don't rotate away the user's own logs
- a panic hook reports every panic, also those Session::run's guard
  recovers from, through the logger; text the message quotes between
  backticks (std quotes the string it failed to slice, which can be the
  user's writing) is left out

The logger is installed first in main, before the panic hook, and the
file is opened after argument parsing (--version leaves no file). The
control server's and file-open messages go through the logger and drop
their "wordcraft: " prefix, which the record's target now shows.
Documented in README.md (Logs).

* Desktop: no eprintln! on the log and panic paths

eprintln! panics when standard error is a broken pipe (a GUI launch on
Windows), and a panic inside the panic hook aborts. The "no log file"
message goes through log::warn! (standard error only by then); the panic
hook's fallback writes to standard error with write_fmt and ignores the
error. Same fix as storytold/vectorcraft#465.

---------

Co-authored-by: Brandon Thomas <shared2@storyteller.ai>
2026-10-09 09:22:04 -04:00
Xus BadiaandClaude Opus 5.5 09f6b37761 macOS DMG: branded window background and icon layout (#21)
The DMG opened as a plain Finder window. It now shows the app icon's figure on
the WordCraft colour, with WordCraft.app and the Applications link side by side.
Same approach as PhotoCraft's and VectorCraft's (storytold/photocraft#1201,
storytold/vectorcraft#493).

- packaging/macos/dmg/: background.svg (source; text outlined, no paper grain,
  the figure an <image> of assets/app-icon), background.tiff (1x + 2x HiDPI,
  16-colour palette, Deflate, sRGB; ~23 KB)
  and dmg-layout.DS_Store (Finder view settings: window, icon size 128,
  positions, background).
- generate.py writes the TIFF and the layout on any OS, byte-for-byte
  reproducible, with nothing from the machine that ran it (no paths, users,
  disk names or volume UUIDs).
- package.sh copies them into the staging folder; the image is still built with
  hdiutil makehybrid. Nothing extra runs in the signing job.
- The volume name drops the version: the layout finds the background through
  an alias that includes the volume name. The DMG file name keeps the version.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:13:31 -04:00
MarekSpodBiedryandBrandon Thomas 78369fd2e9 Desktop: remember the window size and position (#23) (#38)
Co-authored-by: Brandon Thomas <shared2@storyteller.ai>
2026-10-09 09:13:23 -04:00
d646d38921 M6: TOC page numbers, and save the TOC as a field Word can update (#52)
* M6: Update Table puts each heading on its real page

The second pass removed the entries and laid the document out again before
looking headings up at `path + entries.len()`, so every lookup landed past
its heading: later entries showed the next heading's page and the last fell
back to page 1. Lay out with the entries still in place, then replace them.

A heading typed after a manual page break also starts with the break, so its
page is now read after any leading breaks rather than before them.

The existing test only had a heading on page 1; the new one puts headings on
pages 2, 3 and 4 (before: 4, 1, 1).

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

* M6: save the table of contents as a TOC field Word can update

The writer saved the TOC heading's field empty (begin, instruction, separate
and end all at the end of "Contents"), with the entries as plain paragraphs
after it. Word showed them as ordinary text: no TOC frame, and no Update
Table or Update Field on the entries.

Write the field so its result spans the entries (closed at the end of the
last entry) and wrap heading and entries in the Table of Contents content
control (w:sdt with docPartGallery "Table of Contents", ECMA-376 17.5.2).

The reader already keeps a multi-paragraph field's start as an empty field
in its first paragraph and the rest as text, so WordCraft reads the TOC back
exactly as before; the new round-trip test checks both the file and that.

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 <shared2@storyteller.ai>
2026-10-09 09:02:22 -04:00
MarekSpodBiedryandBrandon Thomas 8588fb908a Options: remember the user name between runs (#32) (#35)
Co-authored-by: Brandon Thomas <shared2@storyteller.ai>
2026-10-09 09:02:16 -04:00
DWAA1660andClaude Opus 5.5 f2a8420679 Default Windows startup to DirectX 12 (#50)
eframe's default wgpu instance enables PRIMARY | GL on Windows. Creating
the OpenGL backend can crash inside AMD's atio6axx.dll before the window
appears, so the app exits silently at launch. When WGPU_BACKEND is not
set, the Windows instance now enables only DirectX 12; an explicit
WGPU_BACKEND is still honoured. macOS, Linux and web are unchanged.

Same fix as storytold/designcraft#168 and storytold/filmcraft#179.


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:49:04 -04:00
MarekSpodBiedry 0edb87e3da draw Symbol and Wingdings bullets as Unicode when the font is missing (#29) (#37) 2026-10-09 08:48:57 -04:00
AiBizzAppsandClaude Opus 5.5 5a94aa77d9 M6: footnotes no longer cite themselves in saved .docx files (#53)
references.footnote starts the note's text with a NoteRef to the note itself,
so the editor shows its number. The writer already puts the w:footnoteRef
mark at the start of every note, then also wrote that NoteRef as a
w:footnoteReference, so each note referenced itself. LibreOffice refuses to
open such a file ("source file could not be loaded").

Skip a note's reference to itself while writing that note; the mark stands
in for it. Notes read from Word files never contain one, so they are
unaffected.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:48:51 -04:00
DWAA1660andClaude Opus 5.5 3f166a94f5 Windows MSI: own UpgradeCode, not DesignCraft's (#4) (#10)
wordcraft.wxs shipped with DesignCraft's UpgradeCode and app component
GUID. Windows Installer treats packages that share an UpgradeCode as one
product, so with DesignCraft 0.2.1 installed the WordCraft 0.1.0 MSI was a
"downgrade" and refused to install ("A newer version of WordCraft is
already installed"), and installing DesignCraft after WordCraft would have
removed WordCraft.

Give WordCraft fresh GUIDs, drop the header comment copied from PhotoCraft,
and make packaging-lint fail if the .wxs reuses a sibling Craft app's
UpgradeCode/component GUID or repeats a GUID.

Fixes #4.


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:48:44 -04:00
MarekSpodBiedry 61e2152250 Ruler: dragging an indent marker is one undo step (#57) (#62) 2026-10-09 08:48:37 -04:00
MarekSpodBiedry eec54c635b Ctrl+-, Ctrl+= and Ctrl+0 no longer zoom the whole window (#14) (#36)
egui's built-in keyboard zoom fired on the same keys as Word's shortcuts (optional hyphen, subscript, space before), so Ctrl+- looked like zoom out while it also typed an optional hyphen over the selection. Turn it off once at startup; document zoom stays under View > Zoom and the status bar.
2026-10-09 08:48:30 -04:00
DWAA1660 7ab2bdb1f8 Issue #5: prevent font dropdown preview deadlock (#9) 2026-10-09 08:48:22 -04:00
daf9a99eb2 Layout: first-page header test no longer depends on installed fonts (#65)
tall_first_page_header_pushes_body_down compared page 1's first line with
the last paragraph on the last page. Where Aptos falls back to Liberation
Sans (Linux without Carlito), the last page is nearly full and that
paragraph sits below page 1's first line, so the test failed although
layout was correct. Compare with the first paragraph on page 2 instead,
which is what the test means: later pages don't reserve the tall
first-page header's space.

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:48:15 -04:00
810e176303 Apply heading/Title clears a paragraph's list numbering (#13)
apply_style's guard ended with `&& id == "Normal"`, making the
`id.starts_with("Heading") || id == "Title"` terms dead: only Normal
cleared a numbered paragraph's direct numbering, so applying Heading 1/2/3
or Title to a list item left the number behind — contradicting the
comment right above it. Drop the redundant term and cover it with a
regression test.

Also clears the clippy overly_complex_bool_expr error this expression
raised, which was failing `cargo xtask ci`.

Co-authored-by: justinjohn0306 <babyjjbabyjj549@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-09 08:48:07 -04:00
49 changed files with 5084 additions and 294 deletions
+19
View File
@@ -51,6 +51,25 @@ jobs:
if grep -oE '<Icon Id="[^"]*"' packaging/windows/wordcraft.wxs | grep -vE '\.(ico|exe)"$'; then
echo "::error::Icon Id must end in .ico or .exe"; exit 1
fi
# Each Craft app needs its own MSI UpgradeCode and component GUIDs. Sharing one with a
# sibling makes Windows Installer treat the two apps as versions of one product (#4).
# Codes the sibling apps' installers use:
siblings='D9660F32-2D0A-4ABE-B73D-17BE25F1542C A6DE41B7-A80F-4C59-A17E-BB68065BF67E
601C6437-08AE-4645-ADCC-27F036C2EA2F 0E8C6C5E-DE4A-4E1D-9DB0-44E8E38C2103
31E6FB14-9DBC-41DD-B24B-26B71FB31B62 15933017-925B-4963-8992-52000EB26AAA
229BB552-5EFD-479A-BA86-8CB6D926390C 8A1D3A39-F7C1-47B1-97AE-40C80BF82F4A
EA00C367-7270-4C50-9D8B-A7A5C88AD27F E724C1C6-091F-4E21-A685-80D089FDBE21
DC028192-C233-4F99-9A77-ED458C60DF26 F6948627-32AB-4D78-8865-0FDF417CE442
AACA8593-C6ED-40A8-BA6C-CB325D8529F9 BA719860-95FE-4154-B265-47006B8E9083
49E87D30-BB68-4896-AA1D-F45C833CFC3C DE018EF3-EE87-4E36-9F76-0B7B060A195E'
for code in $siblings; do
if grep -qi "$code" packaging/windows/wordcraft.wxs; then
echo "::error file=packaging/windows/wordcraft.wxs::$code belongs to a sibling Craft app's MSI; generate a new GUID"; exit 1
fi
done
if [ "$(grep -oiE '(UpgradeCode|Guid)="[0-9A-F-]{36}"' packaging/windows/wordcraft.wxs | cut -d'"' -f2 | sort -f | uniq -di)" ]; then
echo "::error file=packaging/windows/wordcraft.wxs::duplicate GUID"; exit 1
fi
desktop-file-validate packaging/linux/ai.storyteller.wordcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.wordcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.wordcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.wordcraft.metainfo.xml"
+2
View File
@@ -11,6 +11,8 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `assets/hyphenation/en-us.pat` | DesignCraft contributors (generated) | trained from the Moby list by DesignCraft's patgen | Public Domain (CC0-1.0) | Our own Liang hyphenation patterns (no TeX patterns) |
| `assets/hyphenation/en-us.dic` | Grady Ward (Moby Hyphenator II word list); converted by DesignCraft contributors | https://www.gutenberg.org/ebooks/3204 (file `mhyph.txt`, SHA-256 `eeb30474c86b8af3469035ec1a0913e35905325ca885290db9dfed9881e230ac`), via DesignCraft | Public Domain | ~165k words with break points; drives spelling and hyphenation |
| `assets/app-icon/wordcraft-1024.png` | WordCraft contributors | original artwork drawn in SVG (`assets/app-icon/wordcraft.svg`), rendered by `packaging/icons.sh` | MIT OR Apache-2.0 | Placeholder app icon until the WordCraft mascot drawing lands |
| `packaging/macos/dmg/background.svg` | @XusBadia | original work for this repository, derived from the WordCraft app icon (`assets/app-icon/wordcraft.svg`, referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0, like the code ([`LICENSE-MIT`](LICENSE-MIT), [`LICENSE-APACHE`](LICENSE-APACHE)) | WordCraft macOS DMG window background (2026-10-08); used for the Finder window of the macOS DMG, rendered to `background.tiff` by `packaging/macos/dmg/generate.py` |
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0, like the code ([`LICENSE-MIT`](LICENSE-MIT), [`LICENSE-APACHE`](LICENSE-APACHE)) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
| `assets/app-icon/wordcraft-macos-512.png` | WordCraft contributors | original artwork drawn in SVG (`assets/app-icon/wordcraft.svg`), rendered by `packaging/icons.sh` | MIT OR Apache-2.0 | Placeholder app icon until the WordCraft mascot drawing lands |
| `assets/app-icon/wordcraft.icns` | WordCraft contributors | original artwork drawn in SVG (`assets/app-icon/wordcraft.svg`), rendered by `packaging/icons.sh` | MIT OR Apache-2.0 | Placeholder app icon until the WordCraft mascot drawing lands |
| `assets/app-icon/wordcraft.ico` | WordCraft contributors | original artwork drawn in SVG (`assets/app-icon/wordcraft.svg`), rendered by `packaging/icons.sh` | MIT OR Apache-2.0 | Placeholder app icon until the WordCraft mascot drawing lands |
Generated
+26 -16
View File
@@ -3638,6 +3638,15 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "sys-locale"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
"libc",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -4832,7 +4841,7 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wordcraft"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"eframe",
"egui",
@@ -4847,7 +4856,7 @@ dependencies = [
[[package]]
name = "wordcraft-cli"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"serde_json",
"wordcraft-doc",
@@ -4858,7 +4867,7 @@ dependencies = [
[[package]]
name = "wordcraft-doc"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"proptest",
"serde",
@@ -4870,7 +4879,7 @@ dependencies = [
[[package]]
name = "wordcraft-docx"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"image",
"log",
@@ -4884,7 +4893,7 @@ dependencies = [
[[package]]
name = "wordcraft-engine"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"image",
"log",
@@ -4905,7 +4914,7 @@ dependencies = [
[[package]]
name = "wordcraft-fonts"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"harfrust 0.13.3",
"kurbo",
@@ -4916,7 +4925,7 @@ dependencies = [
[[package]]
name = "wordcraft-formats"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"image",
"log",
@@ -4929,14 +4938,14 @@ dependencies = [
[[package]]
name = "wordcraft-geom"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"serde",
]
[[package]]
name = "wordcraft-layout"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"log",
"serde",
@@ -4951,7 +4960,7 @@ dependencies = [
[[package]]
name = "wordcraft-mcp"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"log",
"serde_json",
@@ -4962,7 +4971,7 @@ dependencies = [
[[package]]
name = "wordcraft-pdf"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"hayro-cmap",
"hayro-interpret",
@@ -4980,7 +4989,7 @@ dependencies = [
[[package]]
name = "wordcraft-proof"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"log",
"miniz_oxide 0.8.9",
@@ -4989,7 +4998,7 @@ dependencies = [
[[package]]
name = "wordcraft-render"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"image",
"log",
@@ -5002,7 +5011,7 @@ dependencies = [
[[package]]
name = "wordcraft-ui-egui"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"egui",
"egui_kittest",
@@ -5010,6 +5019,7 @@ dependencies = [
"log",
"serde",
"serde_json",
"sys-locale",
"toml",
"wordcraft-doc",
"wordcraft-engine",
@@ -5021,7 +5031,7 @@ dependencies = [
[[package]]
name = "wordcraft-web"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"eframe",
"egui",
@@ -5127,7 +5137,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
[workspace.package]
version = "0.3.0"
version = "0.4.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
+35 -1
View File
@@ -117,7 +117,7 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes
| **References** | Table of contents, footnotes and endnotes, citations and bibliography (APA, MLA, Chicago, IEEE), captions, table of figures, cross-references, index, table of authorities |
| **Review** | Spelling and grammar with suggestions, thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector |
| **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels |
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode |
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文 or 日本語 (follows the system language by default) |
| **Files** | .docx read/write (opens in Word), PDF export, .odt, .rtf, .html, .md, .txt import/export, page images |
The honest picture, area by area, is in [ROADMAP.md](ROADMAP.md) and the generated
@@ -160,6 +160,25 @@ can check their work through `inspect_document` without screenshots. See [docs/m
and the [control protocol](docs/control-protocol.md). Macros record any sequence of commands and
play it back (`tools.recordMacro`, `tools.macros`).
## Logs
The desktop app writes its log records to standard error and to `logs/wordcraft.log` next to its
preferences: on Linux `$XDG_CONFIG_HOME/wordcraft/logs/` (by default `~/.config/wordcraft/logs/`),
on macOS `~/Library/Application Support/WordCraft/logs/`, on Windows `%APPDATA%\WordCraft\logs\`.
A start from a desktop menu or the Dock has no terminal, so this file is what to attach to a bug
report: unreadable parts of a .docx, pictures or fonts a PDF export had to leave out and panics the
command guard recovered from land there. Each launch moves the previous log to `wordcraft.1.log`
(and that one to `wordcraft.2.log`), so the log of a run that crashed survives the next start; a
log stops growing at 16 MiB. `--version` writes no file, and runs with `WORDCRAFT_NO_PREFS` (agents'
test runs) log to standard error only. Document text is never logged; a panic message leaves out
the text it quotes.
By default WordCraft's own crates log at `info` and everything else at `warn`. `RUST_LOG` replaces
that with env_logger-style directives, for example `RUST_LOG=debug`,
`RUST_LOG=warn,wordcraft_docx=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*`
covers every target starting with it (`wordcraft*=debug`). The logger is
`apps/wordcraft/src/logging.rs`.
## Architecture
| Layer | Crate | Job |
@@ -198,6 +217,21 @@ Details and estimates: [ROADMAP.md](ROADMAP.md).
Installers and executables are code-signed.
**If the app doesn't open on Windows:** the desktop app initializes only DirectX 12 by default.
Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's
`atio6axx.dll`) before the window appears, so the app would flash in Task Manager and quit.
`WGPU_BACKEND` overrides the default for troubleshooting (for example `dx12` or `vulkan`). In
PowerShell, from the folder containing the executable:
```powershell
$env:WGPU_BACKEND = "vulkan"
& .\wordcraft.exe
Remove-Item Env:WGPU_BACKEND # restore the default for later launches
```
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
and web backend defaults are unchanged.
### macOS
| Build | File | Notes |
+2 -2
View File
@@ -14,11 +14,11 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
eprintln!("wordcraft: control server failed to bind 127.0.0.1:{port}: {e}");
log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
return rx;
}
};
eprintln!("wordcraft: control server listening on 127.0.0.1:{port}");
log::info!("control server listening on 127.0.0.1:{port}");
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
+47
View File
@@ -0,0 +1,47 @@
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends};
/// Restrict the wgpu instance to DirectX 12 unless `backend_override` (normally
/// `Backends::from_env()`, i.e. `WGPU_BACKEND`) names other backends.
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>) {
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
// eframe's default includes GL. Creating that backend can crash inside AMD's
// atio6axx.dll before adapter selection or any Rust error handling runs, so the
// window never appears. Choosing an adapter afterwards is too late: only leaving GL
// out of the instance avoids it. This matches the verified WGPU_BACKEND=dx12
// workaround without mutating process environment variables.
create.instance_descriptor.backends = backend_override.unwrap_or(Backends::DX12);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn configured_backends(backend_override: Option<Backends>) -> Backends {
let mut options = NativeOptions::default();
configure(&mut options, backend_override);
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
create.instance_descriptor.backends
}
#[test]
fn windows_default_initializes_only_dx12() {
// eframe's own default (PRIMARY | GL) would initialize OpenGL as well.
let WgpuSetup::CreateNew(eframe_default) = NativeOptions::default().wgpu_options.wgpu_setup else {
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);
}
#[test]
fn explicit_backend_override_is_preserved() {
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
assert_eq!(configured_backends(Some(backends)), backends);
}
}
}
+663
View File
@@ -0,0 +1,663 @@
//! The desktop app's logger: `log::` records go to standard error and to a log file.
//!
//! Without a logger every `log::warn!`/`log::error!` in the workspace (an unreadable part of a
//! .docx, a picture or font a PDF export had to leave out, a PNG that would not encode) vanished. A
//! launch from a desktop menu has no terminal, so the file is what a bug report can attach:
//! `<settings dir>/logs/wordcraft.log`, next to the preferences (`prefs_path` in `main.rs`). Each
//! start moves the previous log to `wordcraft.1.log` (and that one to `.2`), so the log of a run
//! that crashed survives the next launch. Runs without preferences (`WORDCRAFT_NO_PREFS`) log to
//! standard error only.
//!
//! Levels: `info` for WordCraft's own crates, `warn` for everything else (wgpu and naga are
//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`,
//! `warn,wordcraft_docx=trace`, `wgpu_core=info`. A directive ending in `*` matches every target
//! that starts with it (`wordcraft*=debug`).
//!
//! Records logged before the settings directory is known are kept (up to [`MAX_PENDING`]) and
//! written once the file is attached. Writing never panics: a file that can't be created or
//! written leaves standard error as the only sink.
//!
//! The panic hook ([`install_panic_hook`]) reports every panic, caught by `Session::run`'s guard or
//! not, through this logger. Text a panic message quotes between backticks (a slice of a string,
//! which can be the user's writing) is left out.
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock, PoisonError};
use std::time::SystemTime;
use log::LevelFilter;
/// The current log file's name inside the log directory.
pub const LOG_FILE: &str = "wordcraft.log";
/// How many previous logs are kept (`wordcraft.1.log` … `wordcraft.<KEEP>.log`).
pub const KEEP: usize = 2;
/// The log file stops growing past this size (a runaway warning can't fill the disk).
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
/// Records kept in memory until the log file is attached.
pub const MAX_PENDING: usize = 512;
/// The built-in filter when `RUST_LOG` is unset or empty.
pub const DEFAULT_FILTER: &str = "warn,wordcraft*=info";
/// Per-target level filter parsed from env_logger-style directives.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Filter {
default: LevelFilter,
directives: Vec<(String, LevelFilter)>,
}
impl Filter {
/// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no
/// directive names log errors only.
pub fn parse(spec: &str) -> Filter {
let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() };
for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) {
match part.split_once('=') {
Some((name, level)) => {
let name = name.trim();
if let (false, Some(level)) = (name.is_empty(), parse_level(level)) {
f.directives.push((name.to_owned(), level));
}
}
None => match parse_level(part) {
Some(level) => f.default = level,
// A bare target name: everything from it (env_logger does the same).
None => f.directives.push((part.to_owned(), LevelFilter::Trace)),
},
}
}
f
}
/// The level that applies to `target` (the most specific matching directive wins).
pub fn level_for(&self, target: &str) -> LevelFilter {
self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level)
}
/// The most verbose level any target can reach (for `log::set_max_level`).
pub fn max(&self) -> LevelFilter {
self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max)
}
}
fn parse_level(s: &str) -> Option<LevelFilter> {
s.trim().parse().ok()
}
/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`);
/// `name*` matches every target starting with `name`.
fn matches(name: &str, target: &str) -> bool {
match name.strip_suffix('*') {
Some(prefix) => target.starts_with(prefix),
None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")),
}
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock
/// value can overflow it.
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days.saturating_add(719_468);
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
(y, m, d)
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("wordcraft.{n}.log")) }
}
/// Shift the previous logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest dropped)
/// and create a fresh, empty log file in `dir` (created if missing).
pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
std::fs::create_dir_all(dir)?;
for n in (1..=KEEP).rev() {
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
_ => {}
}
}
let path = numbered(dir, 0);
let file = File::create(&path)?;
Ok((path, file))
}
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
let message = message.strip_suffix('\n').unwrap_or(message);
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
}
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
/// then appended to it until it reaches its size cap.
pub struct Sink {
file: Option<File>,
pending: Vec<String>,
dropped: usize,
written: u64,
max_bytes: u64,
capped: bool,
}
impl Sink {
pub fn new(max_bytes: u64) -> Sink {
Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
}
/// Append `line` to the file, or keep it until [`Sink::attach`].
pub fn write(&mut self, line: &str) {
if self.file.is_none() {
if self.capped {
// No file is coming ([`Sink::no_file`]): standard error had the line.
} else if self.pending.len() < MAX_PENDING {
self.pending.push(line.to_owned());
} else {
self.dropped = self.dropped.saturating_add(1);
}
return;
}
self.append(line);
}
/// Start writing to `file` (a fresh one: the size cap starts over), first the lines kept so far.
pub fn attach(&mut self, file: File) {
self.file = Some(file);
self.written = 0;
self.capped = false;
for line in std::mem::take(&mut self.pending) {
self.append(&line);
}
if self.dropped > 0 {
let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
self.dropped = 0;
self.append(&note);
}
}
/// There will be no log file: forget the lines kept for it and keep no more.
pub fn no_file(&mut self) {
self.file = None;
self.pending = Vec::new();
self.dropped = 0;
self.capped = true;
}
fn append(&mut self, line: &str) {
if self.capped {
return;
}
let Some(file) = self.file.as_mut() else { return };
let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
let text = if self.written.saturating_add(len) > self.max_bytes {
self.capped = true;
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
} else {
self.written = self.written.saturating_add(len);
line.to_owned()
};
// A full disk or a vanished file must not take the app down: drop the file sink.
if file.write_all(text.as_bytes()).is_err() {
self.file = None;
self.capped = true;
}
}
}
/// The installed logger: a [`Filter`], standard error and a [`Sink`].
pub struct AppLogger {
filter: Filter,
stderr: bool,
sink: Mutex<Sink>,
}
impl AppLogger {
pub fn new(filter: Filter, stderr: bool) -> AppLogger {
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) }
}
/// Rotate the logs in `dir` and send records to the fresh file; returns its path. On an error
/// standard error stays the only sink.
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
// A thread that panicked while logging leaves the sink usable: take it back.
let mut sink = self.sink.lock().unwrap_or_else(PoisonError::into_inner);
match rotate(dir) {
Ok((path, file)) => {
sink.attach(file);
Ok(path)
}
Err(e) => {
sink.no_file();
Err(format!("{}: {e}", dir.display()))
}
}
}
/// No log file for this run (runs without preferences): log to standard error only.
pub fn no_file(&self) {
self.sink.lock().unwrap_or_else(PoisonError::into_inner).no_file();
}
}
impl log::Log for AppLogger {
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
metadata.level() <= self.filter.level_for(metadata.target())
}
fn log(&self, record: &log::Record<'_>) {
if !self.enabled(record.metadata()) {
return;
}
// Formatted before the sink is locked, so a message that logs while it is formatted can't deadlock.
let thread = std::thread::current();
let line =
format_line(&timestamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
if self.stderr {
// No terminal (a Windows GUI build, a closed pipe) is not an error worth reporting.
let _ = std::io::stderr().write_all(line.as_bytes());
}
self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line);
}
fn flush(&self) {}
}
/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another
/// logger was installed first. Call [`AppLogger::attach_dir`] once the settings directory is known.
pub fn install() -> Option<&'static AppLogger> {
static LOGGER: OnceLock<AppLogger> = OnceLock::new();
let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER));
let max = filter.max();
let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true));
log::set_logger(logger).ok()?;
log::set_max_level(max);
Some(logger)
}
/// Replace text a panic message quotes between backticks with `…`: std quotes the string it was
/// slicing (`byte index 5 is not a char boundary; … of `<the string>``), and that string can be
/// the user's writing. An unpaired backtick leaves the rest of the message out too.
pub fn redact_quoted(message: &str) -> String {
let mut out = String::with_capacity(message.len());
for (i, part) in message.split('`').enumerate() {
if i % 2 == 1 {
out.push_str("`…");
} else {
if i > 0 {
out.push('`');
}
out.push_str(part);
}
}
out
}
/// The text the panic hook logs: where it happened, the redacted message and, when captured
/// (`RUST_BACKTRACE`), the backtrace.
pub fn panic_report(location: Option<&str>, message: Option<&str>, backtrace: Option<&str>) -> String {
let message = message.map_or_else(|| "(no message)".to_owned(), redact_quoted);
let mut report = format!("panic at {}: {message}", location.unwrap_or("an unknown place"));
if let Some(trace) = backtrace {
report.push('\n');
report.push_str(trace.trim_end());
}
report
}
/// Report every panic (caught by `Session::run`'s guard or not) through the logger, so it reaches
/// the log file too; standard error alone when `RUST_LOG` turned errors off.
pub fn install_panic_hook() {
std::panic::set_hook(Box::new(|info| {
let location = info.location().map(ToString::to_string);
let trace = std::backtrace::Backtrace::capture();
let trace = (trace.status() == std::backtrace::BacktraceStatus::Captured).then(|| trace.to_string());
let report = panic_report(location.as_deref(), info.payload_as_str(), trace.as_deref());
if log::log_enabled!(log::Level::Error) {
log::error!("{report}");
} else {
// `eprintln!` panics on a broken stderr pipe, and a panic inside the panic hook aborts.
let _ = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("wordcraft: {report}\n"));
}
}));
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, UNIX_EPOCH};
fn temp_dir(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("wordcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
d
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).expect("read log")
}
#[test]
fn the_default_filter_shows_wordcraft_info_and_other_crates_warnings() {
let f = Filter::parse(DEFAULT_FILTER);
assert_eq!(f.level_for("wordcraft"), LevelFilter::Info);
assert_eq!(f.level_for("wordcraft::control_server"), LevelFilter::Info);
assert_eq!(f.level_for("wordcraft_docx::package"), LevelFilter::Info);
assert_eq!(f.level_for("wordcraft_ui_egui"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
assert_eq!(f.level_for("naga"), LevelFilter::Warn);
assert_eq!(f.max(), LevelFilter::Info);
}
#[test]
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
let f = Filter::parse("info,wgpu_core=error,wordcraft_pdf=trace,wordcraft_pdf::fonts=off");
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
assert_eq!(f.level_for("wordcraft_pdf::images"), LevelFilter::Trace);
assert_eq!(f.level_for("wordcraft_pdf::fonts"), LevelFilter::Off);
assert_eq!(f.max(), LevelFilter::Trace);
// A module name is matched at `::` boundaries, not as a bare prefix.
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
// A trailing `*` is a prefix.
assert_eq!(Filter::parse("wordcraft*=debug").level_for("wordcraft_layout::lines"), LevelFilter::Debug);
assert_eq!(Filter::parse("wordcraft*=debug").level_for("eframe"), LevelFilter::Error);
// A bare target name sets that target to the most verbose level, as env_logger does.
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
// Levels are case-insensitive and surrounding blanks are ignored.
assert_eq!(Filter::parse(" WARN , wordcraft = Debug ").level_for("wordcraft"), LevelFilter::Debug);
assert_eq!(Filter::parse(" WARN , wordcraft = Debug ").level_for("eframe"), LevelFilter::Warn);
}
#[test]
fn hostile_specs_never_panic_and_fall_back_sensibly() {
let long = "x".repeat(100_000);
let many = "a=info,".repeat(10_000);
for spec in [
"",
",,,",
"=",
"==",
"=debug",
"wordcraft=",
"nonsense=loud",
"🦀=info",
"*",
"*=",
"=*",
"a=b=c",
"warn,,wordcraft=DEBUG",
"\0",
"é::ü=trace",
&long,
&many,
] {
let f = Filter::parse(spec);
for target in ["", "wordcraft", "🦀", "a::b", "*"] {
let _ = f.level_for(target);
}
let _ = f.max();
}
assert_eq!(Filter::parse("warn,,wordcraft=DEBUG").level_for("wordcraft"), LevelFilter::Debug);
// An unknown level is skipped: that target keeps the default.
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
assert_eq!(Filter::parse("wordcraft=").level_for("wordcraft"), LevelFilter::Error);
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error);
assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info);
}
#[test]
fn timestamps_are_utc_with_milliseconds() {
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
// Leap day, and the day after it.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
// 2000 is a leap year (divisible by 400), 2100 is not.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
}
#[test]
fn a_line_carries_time_level_thread_target_and_message() {
assert_eq!(
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "wordcraft_docx::package", "skipping unreadable zip entry 3"),
"2026-10-08T07:59:17.728Z WARN [main] wordcraft_docx::package: skipping unreadable zip entry 3\n"
);
// A multi-line message keeps its lines; the record still ends in one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
}
#[test]
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
let dir = temp_dir("rotate");
for run in 1..=4 {
let (path, _file) = rotate(&dir).expect("rotate");
assert_eq!(path, dir.join(LOG_FILE));
assert_eq!(read(&path), "");
std::fs::write(&path, format!("run {run}")).expect("write");
}
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
assert_eq!(read(&dir.join("wordcraft.1.log")), "run 3");
assert_eq!(read(&dir.join("wordcraft.2.log")), "run 2");
assert!(!dir.join("wordcraft.3.log").exists(), "only {KEEP} old logs are kept");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
let dir = temp_dir("blocked");
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
std::fs::write(&dir, "a file where the directory should be").expect("block");
assert!(rotate(&dir).is_err());
let _ = std::fs::remove_file(&dir);
}
#[test]
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
let dir = temp_dir("pending");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early 1\n");
sink.write("early 2\n");
sink.attach(file);
sink.write("late\n");
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
let dir = temp_dir("bounded");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING + 5 {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
assert!(text.contains("5 earlier log lines were dropped"), "{text}");
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn exactly_a_full_pending_buffer_drops_nothing() {
let dir = temp_dir("full");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().count(), MAX_PENDING);
assert!(!text.contains("dropped"), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn without_a_file_nothing_is_kept_in_memory() {
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early\n");
sink.no_file();
sink.write("late\n");
assert!(sink.pending.is_empty() && sink.dropped == 0);
}
#[test]
fn the_file_stops_at_its_size_cap_with_one_note() {
let dir = temp_dir("cap");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(100);
sink.attach(file);
for i in 0..50 {
sink.write(&format!("line {i:02} with some padding\n"));
}
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
assert_eq!(text.matches("log file reached").count(), 1, "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
use log::Log;
let dir = temp_dir("logger");
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
let record = |level, target: &'static str, msg: &'static str| {
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
};
record(log::Level::Info, "wordcraft", "before the settings directory");
record(log::Level::Info, "wgpu_core::device", "too chatty");
let path = logger.attach_dir(&dir).expect("attach");
assert_eq!(path, dir.join(LOG_FILE));
record(log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
record(log::Level::Error, "wordcraft_render", "PNG encode: bad size 0×0");
record(log::Level::Debug, "wordcraft", "below info");
let text = read(&path);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 3, "{text}");
assert!(lines[0].contains(" INFO [") && lines[0].ends_with("wordcraft: before the settings directory"), "{text}");
assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}");
assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("wordcraft_render: PNG encode: bad size 0×0"), "{text}");
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("wordcraft_ui_egui").build()));
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
// Attaching again rotates: the first log becomes `.1`.
logger.attach_dir(&dir).expect("attach again");
assert_eq!(read(&dir.join("wordcraft.1.log")), text);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn records_from_many_threads_are_whole_lines() {
use log::Log;
let dir = temp_dir("threads");
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
let path = logger.attach_dir(&dir).expect("attach");
let workers: Vec<_> = (0..8)
.map(|t| {
let l = std::sync::Arc::clone(&logger);
std::thread::spawn(move || {
for i in 0..100 {
l.log(
&log::Record::builder().level(log::Level::Info).target("wordcraft").args(format_args!("thread {t} record {i}")).build(),
);
}
})
})
.collect();
for w in workers {
w.join().expect("worker");
}
let text = read(&path);
assert_eq!(text.lines().count(), 800);
assert!(text.lines().all(|l| l.contains(" wordcraft: thread ")), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
use log::Log;
let dir = temp_dir("unusable");
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
std::fs::write(&dir, "not a directory").expect("block");
let logger = AppLogger::new(Filter::parse("info"), false);
logger.log(&log::Record::builder().level(log::Level::Error).target("x").args(format_args!("early")).build());
assert!(logger.attach_dir(&dir).is_err());
logger.log(&log::Record::builder().level(log::Level::Error).target("x").args(format_args!("still fine")).build());
// Nothing waits in memory for a file that won't come.
let sink = logger.sink.lock().expect("sink");
assert!(sink.pending.is_empty() && sink.file.is_none());
drop(sink);
let _ = std::fs::remove_file(&dir);
}
#[test]
fn a_poisoned_sink_still_logs() {
use log::Log;
let dir = temp_dir("poison");
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
let l = std::sync::Arc::clone(&logger);
let _ = std::thread::spawn(move || {
let _guard = l.sink.lock().expect("lock");
panic!("poison the sink");
})
.join();
assert!(logger.sink.is_poisoned());
let path = logger.attach_dir(&dir).expect("attach");
logger.log(&log::Record::builder().level(log::Level::Warn).target("wordcraft").args(format_args!("after the poison")).build());
assert!(read(&path).contains("wordcraft: after the poison"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn panic_messages_leave_out_quoted_text() {
// What std says when a string is sliced inside a character: the string is the user's text.
let msg = "byte index 5 is not a char boundary; it is inside 'é' (bytes 4..6) of `Dear Ms Smith, my secret`";
assert_eq!(redact_quoted(msg), "byte index 5 is not a char boundary; it is inside 'é' (bytes 4..6) of `…`");
assert_eq!(redact_quoted("byte index 99 is out of range of `my diary`[...]"), "byte index 99 is out of range of `…`[...]");
assert_eq!(redact_quoted("a `b` c `d` e"), "a `…` c `…` e");
// An unpaired backtick hides the rest rather than risk leaking it.
assert_eq!(redact_quoted("of `half a sentence"), "of `…");
assert_eq!(
redact_quoted("no quotes: index out of bounds: the len is 3 but the index is 5"),
"no quotes: index out of bounds: the len is 3 but the index is 5"
);
assert_eq!(redact_quoted(""), "");
assert_eq!(redact_quoted("``"), "`…`");
assert_eq!(redact_quoted("🦀`🦀`🦀"), "🦀`…`🦀");
}
#[test]
fn a_panic_report_names_the_place_and_the_redacted_message() {
assert_eq!(
panic_report(Some("crates/engine/src/cmd/edit.rs:12:5"), Some("of `secret`"), None),
"panic at crates/engine/src/cmd/edit.rs:12:5: of `…`"
);
assert_eq!(panic_report(None, None, None), "panic at an unknown place: (no message)");
assert_eq!(panic_report(Some("a.rs:1:1"), Some("boom"), Some(" 0: main\n")), "panic at a.rs:1:1: boom\n 0: main");
}
}
+67 -14
View File
@@ -9,15 +9,24 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod control_server;
#[cfg(any(target_os = "windows", test))]
mod graphics;
mod logging;
use wordcraft_engine::Session;
use wordcraft_ui_egui::{Services, UiState, WordApp};
use wordcraft_ui_egui::{Services, UiState, WordApp, window_geometry::WindowGeometry};
struct App(WordApp);
/// The app, and the restored window geometry until the first frame has checked it.
struct App(WordApp, Option<WindowGeometry>);
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.0.logic(ctx);
let prev = self.0.ui.window;
self.0.ui.window = ctx.input(|i| WindowGeometry::track(prev, i.viewport(), i.viewport_rect().size()));
if let Some(pos) = self.1.take().and_then(|g| ctx.input(|i| g.rescue_position(i.viewport()))) {
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
}
if self.0.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
@@ -47,28 +56,46 @@ fn prefs_path() -> Option<std::path::PathBuf> {
base.map(|b| b.join("ui.json"))
}
/// Where the log files live: `logs` in the preferences folder (see `logging`).
fn log_dir() -> Option<std::path::PathBuf> {
Some(prefs_path()?.parent()?.join("logs"))
}
/// Runs without preferences (`WORDCRAFT_NO_PREFS`, agents' test runs) neither read nor write them.
fn prefs_enabled() -> bool {
std::env::var_os("WORDCRAFT_NO_PREFS").is_none()
}
fn load_prefs(app: &mut WordApp) {
if std::env::var_os("WORDCRAFT_NO_PREFS").is_some() {
if !prefs_enabled() {
return;
}
if let Some(p) = prefs_path()
&& let Ok(bytes) = std::fs::read(&p)
&& let Ok(ui) = serde_json::from_slice::<UiState>(&bytes)
{
app.ui = ui;
app.ui.backstage = false;
app.apply_prefs(ui);
}
}
/// The saved window geometry, read before the window opens (`load_prefs` runs after).
fn saved_window() -> Option<WindowGeometry> {
if !prefs_enabled() {
return None;
}
let bytes = std::fs::read(prefs_path()?).ok()?;
serde_json::from_slice::<UiState>(&bytes).ok()?.window?.sanitized()
}
fn save_prefs(app: &WordApp) {
if std::env::var_os("WORDCRAFT_NO_PREFS").is_some() {
if !prefs_enabled() {
return;
}
if let Some(p) = prefs_path() {
if let Some(dir) = p.parent() {
let _ = std::fs::create_dir_all(dir);
}
if let Ok(bytes) = serde_json::to_vec_pretty(&app.ui) {
if let Ok(bytes) = serde_json::to_vec_pretty(&app.prefs()) {
let _ = std::fs::write(&p, bytes);
}
}
@@ -101,7 +128,15 @@ fn app_icon() -> Option<egui::IconData> {
eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok()
}
/// The commit the build came from (short), or `dev`.
fn build_sha() -> &'static str {
option_env!("WORDCRAFT_BUILD_SHA").map(|s| s.get(..8).unwrap_or(s)).unwrap_or("dev")
}
fn main() -> eframe::Result {
// First, so the panic hook and every start-up warning are recorded (`logging`).
let logger = logging::install();
logging::install_panic_hook();
let mut control_port: Option<u16> = std::env::var("WORDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut sample = false;
@@ -111,16 +146,26 @@ fn main() -> eframe::Result {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--sample" => sample = true,
"--version" => {
println!(
"wordcraft {} ({})",
env!("CARGO_PKG_VERSION"),
option_env!("WORDCRAFT_BUILD_SHA").map(|s| s.get(..8).unwrap_or(s)).unwrap_or("dev")
);
println!("wordcraft {} ({})", env!("CARGO_PKG_VERSION"), build_sha());
return Ok(());
}
_ => files.push(a),
}
}
// The log file lives in the settings directory, next to the preferences; opened after the
// arguments, so `--version` leaves no file behind. Records logged until now are written to it
// first. Runs without preferences (agents' test runs) log to standard error only, so they
// don't rotate away the user's own logs.
if let Some(logger) = logger {
match log_dir().filter(|_| prefs_enabled()) {
Some(dir) => match logger.attach_dir(&dir) {
Ok(path) => log::info!("WordCraft {} ({}), log file {}", env!("CARGO_PKG_VERSION"), build_sha(), path.display()),
// Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics.
Err(e) => log::warn!("no log file: {e}"),
},
None => logger.no_file(),
}
}
let mut options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("WordCraft")
@@ -136,6 +181,13 @@ 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).
#[cfg(target_os = "windows")]
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
let restored = saved_window();
if let Some(window) = restored {
options.viewport = window.apply(options.viewport);
}
eframe::run_native(
"WordCraft",
options,
@@ -143,6 +195,7 @@ fn main() -> eframe::Result {
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_doc::Document::new() };
let mut app = WordApp::new(Session::new(doc), services());
load_prefs(&mut app);
app.ui.window = restored;
app.integrated_titlebar = cfg!(target_os = "macos");
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
@@ -150,10 +203,10 @@ fn main() -> eframe::Result {
}
for f in files {
if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) {
eprintln!("wordcraft: {f}: {e}");
log::warn!("{f}: {e}");
}
}
Ok(Box::new(App(app)))
Ok(Box::new(App(app, restored)))
}),
)
}
+124 -11
View File
@@ -2,23 +2,23 @@
"schema": 1,
"repo": "storytold/wordcraft",
"branch": "main",
"head": "05d0b41b7e52dcd3cdade9c38e3d475bd7644b04",
"head_date": "2026-10-07T21:25:33Z",
"commits": 23,
"head": "d7d9702bda1fe626952024dc37fd2624323dad0f",
"head_date": "2026-10-09T13:22:42Z",
"commits": 45,
"unattributed_commits": 0,
"contributors": [
{
"login": "echelon",
"people_entry": true,
"github_name": null,
"prs": 1,
"commits": 21,
"lines_added": 56356,
"lines_deleted": 1728,
"prs": 4,
"commits": 26,
"lines_added": 57353,
"lines_deleted": 1782,
"binary_added": 37,
"binary_deleted": 0,
"first_commit": "2026-10-06T00:23:39Z",
"last_commit": "2026-10-07T18:46:02Z"
"last_commit": "2026-10-08T13:51:15Z"
},
{
"login": "Sirsyorrz",
@@ -32,6 +32,110 @@
"binary_deleted": 0,
"first_commit": "2026-10-07T21:25:27Z",
"last_commit": "2026-10-07T21:25:33Z"
},
{
"login": "h-a-n-a-s-h-i",
"people_entry": false,
"github_name": null,
"prs": 0,
"commits": 2,
"lines_added": 34,
"lines_deleted": 34,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T12:13:16Z",
"last_commit": "2026-10-08T14:02:16Z"
},
{
"login": "justinjohn0306",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 22,
"lines_deleted": 1,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T12:48:07Z",
"last_commit": "2026-10-09T12:48:07Z"
},
{
"login": "dkhlapov",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 3,
"lines_deleted": 1,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T12:48:15Z",
"last_commit": "2026-10-09T12:48:15Z"
},
{
"login": "DWAA1660",
"people_entry": false,
"github_name": null,
"prs": 4,
"commits": 4,
"lines_added": 3374,
"lines_deleted": 235,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T12:48:22Z",
"last_commit": "2026-10-09T13:22:42Z"
},
{
"login": "MarekSpodBiedry",
"people_entry": false,
"github_name": null,
"prs": 5,
"commits": 5,
"lines_added": 416,
"lines_deleted": 10,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T12:48:30Z",
"last_commit": "2026-10-09T13:13:23Z"
},
{
"login": "goldsbd",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 166,
"lines_deleted": 8,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T12:48:51Z",
"last_commit": "2026-10-09T13:02:22Z"
},
{
"login": "XusBadia",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 241,
"lines_deleted": 1,
"binary_added": 2,
"binary_deleted": 0,
"first_commit": "2026-10-09T13:13:31Z",
"last_commit": "2026-10-09T13:13:31Z"
},
{
"login": "switch87",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 722,
"lines_deleted": 11,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T13:22:04Z",
"last_commit": "2026-10-09T13:22:04Z"
}
],
"models": [
@@ -40,9 +144,18 @@
"company": "Anthropic",
"model": "Claude Opus",
"version": "5.5",
"commits": 20,
"lines_added": 56349,
"lines_deleted": 1728
"commits": 34,
"lines_added": 61120,
"lines_deleted": 2060
},
{
"id": "anthropic/claude-opus/4.8",
"company": "Anthropic",
"model": "Claude Opus",
"version": "4.8",
"commits": 1,
"lines_added": 22,
"lines_deleted": 1
}
]
}
+13 -2
View File
@@ -67,6 +67,12 @@ pub(crate) struct Writer<'d> {
para_ids: HashMap<usize, String>,
/// A note reference mark to put at the start of the next paragraph.
pending_mark: Option<&'static str>,
/// The note being written (is footnote, part id): its reference to itself is the mark above.
current_note: Option<(bool, u32)>,
/// Writing a TOC heading: its TOC field stays open so the entries become the field's result.
toc_hold_end: bool,
/// Close the open TOC field at the end of the paragraph being written.
toc_end_here: bool,
/// Media keys actually referenced by a written drawing.
used_media: std::collections::BTreeSet<String>,
}
@@ -88,6 +94,9 @@ pub fn write(doc: &Document) -> Result<Vec<u8>, DocxError> {
hf: Vec::new(),
para_ids: HashMap::new(),
pending_mark: None,
current_note: None,
toc_hold_end: false,
toc_end_here: false,
used_media: Default::default(),
};
wr.assign_media();
@@ -172,7 +181,7 @@ pub fn write(doc: &Document) -> Result<Vec<u8>, DocxError> {
let nid = k.to_string();
w.open(tag, &[("w:id", &nid)]);
let blocks: Blocks = doc.parts.get(&pid).map(|p| p.blocks.clone()).unwrap_or_default();
wr.note_blocks(&mut w, &blocks, &mut prels, foot);
wr.note_blocks(&mut w, &blocks, &mut prels, foot, pid);
w.close(tag);
}
w.close(root);
@@ -440,9 +449,11 @@ impl Writer<'_> {
}
/// Note stories: the first paragraph starts with the note's own reference mark.
fn note_blocks(&mut self, w: &mut W, blocks: &Blocks, rels: &mut PartRels, foot: bool) {
fn note_blocks(&mut self, w: &mut W, blocks: &Blocks, rels: &mut PartRels, foot: bool, part: u32) {
self.pending_mark = Some(if foot { "w:footnoteRef" } else { "w:endnoteRef" });
self.current_note = Some((foot, part));
self.blocks(w, blocks, rels, false, 0);
self.current_note = None;
self.pending_mark = None;
}
}
+60 -2
View File
@@ -30,11 +30,30 @@ impl Writer<'_> {
w.raw("<w:p/>");
return;
}
for b in bl {
let toc = if top { toc_span(bl) } else { None };
// Whether the heading's TOC field was written and still needs its end.
let mut toc_field_open = false;
for (i, b) in bl.iter().enumerate() {
if let Some((start, end)) = toc {
if i == start {
w.raw(TOC_SDT_OPEN);
self.toc_hold_end = true;
}
self.toc_end_here = toc_field_open && i == end;
}
match &**b {
Block::Para(p) => self.para(w, p, rels, top, depth),
Block::Table(t) => self.table(w, t, rels, depth),
}
if let Some((start, end)) = toc {
if i == start {
// `field` clears the flag when it leaves the TOC field open.
toc_field_open = !std::mem::take(&mut self.toc_hold_end);
}
if i == end {
w.raw("</w:sdtContent></w:sdt>");
}
}
}
if bl.last().is_none_or(|b| matches!(**b, Block::Table(_))) {
w.raw("<w:p/>");
@@ -93,6 +112,9 @@ impl Writer<'_> {
w.raw("</w:r>");
}
self.para_content(w, p, rels, depth);
if std::mem::take(&mut self.toc_end_here) {
w.raw(r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#);
}
w.close("w:p");
}
@@ -279,6 +301,10 @@ impl Writer<'_> {
InlineObject::Field { instr, result, locked } => self.field(w, instr, result, *locked, props),
InlineObject::NoteRef { kind, id, custom } => {
let foot = *kind == NoteKind::Footnote;
if self.current_note == Some((foot, *id)) {
// The note's own number: already written as the w:footnoteRef / w:endnoteRef mark.
return;
}
let nid = self.note_id(*id, foot);
let mut p = props.clone();
if p.style.is_none() && p.vert_align.is_none() {
@@ -477,7 +503,13 @@ impl Writer<'_> {
flush(w, &mut buf);
});
}
run(w, &|w| w.empty("w:fldChar", &[("w:fldCharType", "end")]));
// A TOC heading's field is closed after its last entry (see `toc_span`).
let toc_open = self.toc_hold_end && is_toc(instr);
if toc_open {
self.toc_hold_end = false;
} else {
run(w, &|w| w.empty("w:fldChar", &[("w:fldCharType", "end")]));
}
self.rev_close(w, props);
}
@@ -685,3 +717,29 @@ fn xfrm(w: &mut W, cw: f32, ch: f32) {
w.empty("a:ext", &[("cx", &emu(cw.max(0.0))), ("cy", &emu(ch.max(0.0)))]);
w.close("a:xfrm");
}
/// Word's Table of Contents content control, which gives the TOC its frame and Update Table.
const TOC_SDT_OPEN: &str =
r#"<w:sdt><w:sdtPr><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj></w:sdtPr><w:sdtContent>"#;
fn is_toc(instr: &str) -> bool {
instr.trim_start().get(..3).is_some_and(|k| k.eq_ignore_ascii_case("TOC"))
}
/// The engine's TOC: a paragraph holding an empty `TOC` field, then its TOC-styled entries.
/// Returns (heading, last entry) so the field can be written spanning the entries, as Word does.
fn toc_span(bl: &Blocks) -> Option<(usize, usize)> {
let start = bl.iter().position(|b| {
b.as_para().is_some_and(|p| {
p.section.is_none()
&& p.objects.iter().any(|o| matches!(o, InlineObject::Field { instr, result, .. } if is_toc(instr) && result.is_empty()))
})
})?;
let entries = bl[start + 1..]
.iter()
.take_while(|b| {
b.as_para().is_some_and(|p| p.section.is_none() && p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading"))
})
.count();
(entries > 0).then_some((start, start + entries))
}
+65
View File
@@ -448,6 +448,71 @@ fn notes_round_trip() {
assert_eq!(r.settings.endnote_format, NumFormat::UpperRoman);
}
/// `references.footnote` starts the note's own text with a reference to the note, so the editor shows
/// its number. In the file that is the `w:footnoteRef` mark; a `w:footnoteReference` there would make
/// the note cite itself, which LibreOffice refuses to open.
#[test]
fn note_never_references_itself() {
let mut d = Document::new();
let f = d.add_part(PartKind::Footnote, Vec::new());
let mut note = Paragraph::with_text(" The note.", CharProps::default());
note.insert_object(0, InlineObject::NoteRef { kind: NoteKind::Footnote, id: f, custom: String::new() }, &CharProps::default()).unwrap();
d.parts.get_mut(&f).unwrap().blocks = vec![para_block(note)];
let mut p = Paragraph::with_text("Text", CharProps::default());
p.insert_object(4, InlineObject::NoteRef { kind: NoteKind::Footnote, id: f, custom: String::new() }, &CharProps::default()).unwrap();
d.body = vec![para_block(p)];
let bytes = wordcraft_docx::write(&d).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/footnotes.xml").unwrap(), &mut xml).unwrap();
assert!(!xml.contains("<w:footnoteReference"), "a footnote cites itself: {xml}");
assert_eq!(xml.matches("<w:footnoteRef/>").count(), 1, "{xml}");
let r = wordcraft_docx::read(&bytes).expect("read");
let got = paras(&r)[0];
let Some(InlineObject::NoteRef { id, .. }) = got.objects.first() else { panic!("{:?}", got.objects) };
assert_eq!(part_text(&r, Some(*id)).trim(), "The note.");
}
/// The TOC is a `Contents` paragraph ending in an empty `TOC` field, followed by TOC-styled entries.
/// In the file the field must contain the entries, inside Word's Table of Contents content control,
/// or Word shows them as plain text with no Update Table.
#[test]
fn toc_field_wraps_its_entries() {
let mut head = Paragraph::with_text("Contents", CharProps::default()).styled("TOCHeading");
let end = head.len();
head.insert_object(
end,
InlineObject::Field { instr: "TOC \\o \"1-2\" \\h \\z \\u".into(), result: String::new(), locked: false },
&CharProps::default(),
)
.unwrap();
let entries = [("Intro\t1", "TOC1"), ("Detail\t2", "TOC2")];
let mut blocks = vec![head];
blocks.extend(entries.iter().map(|(t, st)| Paragraph::with_text(t, CharProps::default()).styled(st)));
blocks.push(Paragraph::with_text("Body", CharProps::default()));
let d = doc_with(blocks);
let bytes = wordcraft_docx::write(&d).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();
let sdt = xml.find("<w:sdt>").expect("TOC content control");
assert!(xml.contains(r#"<w:docPartGallery w:val="Table of Contents"/>"#), "{xml}");
let (begin, fend) = (xml.find(r#"fldCharType="begin""#).unwrap(), xml.find(r#"fldCharType="end""#).unwrap());
let last_entry = xml.find("Detail").unwrap();
assert!(sdt < xml.find("Contents").unwrap() && xml.find("</w:sdt>").unwrap() < xml.find(">Body<").unwrap(), "{xml}");
assert!(begin < xml.find("Intro").unwrap() && fend > last_entry && fend < xml.find("</w:sdt>").unwrap(), "field must span the entries: {xml}");
// WordCraft reads its own TOC back unchanged.
let r = wordcraft_docx::read(&bytes).expect("read");
let ps = paras(&r);
assert_eq!(ps.iter().map(|p| p.plain_text()).collect::<Vec<_>>(), ["Contents", "Intro\t1", "Detail\t2", "Body"]);
assert!(matches!(ps[0].objects.first(), Some(InlineObject::Field { instr, .. }) if instr.starts_with("TOC")), "{:?}", ps[0].objects);
assert_eq!(ps[1].props.style.as_deref(), Some("TOC1"));
}
#[test]
fn tracked_changes_round_trip() {
let mut d = Document::new();
+1 -1
View File
@@ -274,7 +274,7 @@ fn apply_style(s: &mut Session, v: &Value) -> CmdResult {
s.doc.format_paragraphs(&a, &b, &|p| {
p.style = Some(id.clone());
// Applying a heading or Normal removes direct list numbering (except list styles).
if (id.starts_with("Heading") || id == "Title" || id == "Normal") && p.numbering.is_some_and(|n| n.num != 0) && id == "Normal" {
if (id.starts_with("Heading") || id == "Title" || id == "Normal") && p.numbering.is_some_and(|n| n.num != 0) {
p.numbering = None;
}
})?;
+10 -4
View File
@@ -160,20 +160,26 @@ pub fn update_toc(s: &mut Session) -> Result<(), CmdError> {
}
let width = super::page::sect(s).text_width();
let entries = headings(s, levels);
// A heading typed after a manual page break starts with that break; its page is where the
// text after the break lands, so look the page up past any leading breaks.
let leads: Vec<usize> =
entries.iter().map(|(path, ..)| s.doc.para(StoryRef::Body, path).map_or(0, |p| p.text.bytes().take_while(|b| *b == 0x0C).count())).collect();
for pass in 0..2 {
// Pass 1 lays out with the entries in place, so headings sit `entries.len()` blocks
// further down and their pages account for the TOC's own length.
s.touch();
let l = s.layout();
if pass == 1 {
// Replace the page numbers now that the entries exist.
for _ in 0..entries.len() {
s.doc.remove_block(StoryRef::Body, &Path::top(start + 1))?;
}
}
s.touch();
let l = s.layout();
let shift = if pass == 0 { 0 } else { entries.len() };
for (k, (path, level, text)) in entries.iter().enumerate() {
let target = Path::top(path.last() + shift);
let page =
l.caret(&Pos { story: StoryRef::Body, path: target, off: 0 }).and_then(|c| l.pages.get(c.page)).map(|pg| pg.number).unwrap_or(1);
let off = leads.get(k).copied().unwrap_or(0);
let page = l.caret(&Pos { story: StoryRef::Body, path: target, off }).and_then(|c| l.pages.get(c.page)).map(|pg| pg.number).unwrap_or(1);
let mut para = Paragraph::with_text(&format!("{text}\t{page}"), CharProps::default()).styled(&format!("TOC{}", level + 1));
para.props.tabs = Some(vec![TabStop { pos: width - 0.5, align: TabAlign::Right, leader: TabLeader::Dot }]);
s.doc.insert_block(StoryRef::Body, &Path::top(start + 1 + k), Block::Para(para))?;
+13 -1
View File
@@ -129,6 +129,8 @@ pub struct Session {
redo: Vec<Undo>,
/// Typing is coalesced into one undo step until something else happens.
typing_open: bool,
/// The next mutating command joins the previous undo step (later frames of a drag).
join_next: bool,
rev: u64,
cache: LayoutCache,
layout: Option<(u64, f32, ViewMode, Arc<DocLayout>, bool)>,
@@ -179,6 +181,7 @@ impl Session {
history: Vec::new(),
redo: Vec::new(),
typing_open: false,
join_next: false,
rev: 1,
cache: LayoutCache::new(),
layout: None,
@@ -246,6 +249,12 @@ impl Session {
}
self.redo.clear();
}
/// Make the next mutating command part of the previous undo step instead of a new one, so a
/// drag that runs a command every frame is a single Undo. Call it on every frame of the drag
/// except the first; it is consumed by the next `run`.
pub fn join_next_undo(&mut self) {
self.join_next = true;
}
/// Close an open typing group (caret moved, other command).
pub fn close_typing(&mut self) {
self.typing_open = false;
@@ -310,6 +319,7 @@ impl Session {
/// control channel). Mutating commands are undoable; a failed command leaves the document as
/// it was; a panic inside a command becomes an error.
pub fn run(&mut self, id: &str, params: &Value) -> Result<Value, CmdError> {
let join = std::mem::take(&mut self.join_next);
let reg = self.registry.clone();
let Some(spec) = reg.get(id) else { return Err(CmdError::Unknown(id.to_string())) };
if let Some(why) = (spec.enabled)(self) {
@@ -343,7 +353,9 @@ impl Session {
}
if spec.mutates {
let label = if spec.id == "text.insert" { "Typing" } else { spec.label };
self.checkpoint(label);
if !join || self.history.is_empty() {
self.checkpoint(label);
}
} else if !spec.id.starts_with("caret.") && !spec.id.starts_with("view.") {
// Non-mutating commands other than caret movement keep the typing group.
} else if spec.id.starts_with("caret.") {
+62
View File
@@ -44,6 +44,29 @@ fn typing_enter_undo() {
assert_eq!(text(&s), "Hello world");
}
#[test]
fn joined_commands_are_one_undo_step() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
run(&mut s, "para.indents", json!({"left": 9.0}));
run(&mut s, "para.indents", json!({"left": 18.0}));
for x in [27.0, 36.0] {
s.join_next_undo();
run(&mut s, "para.indents", json!({"left": x}));
}
let indent = |s: &Session| s.doc.para_at(&s.sel.focus).and_then(|p| p.props.indent_left);
assert_eq!(indent(&s), Some(36.0));
// One undo reverts the whole second drag, not just its last frame.
run(&mut s, "edit.undo", json!({}));
assert_eq!(indent(&s), Some(9.0));
run(&mut s, "edit.undo", json!({}));
assert_eq!(indent(&s), None);
assert_eq!(text(&s), "Hello");
run(&mut s, "edit.redo", json!({}));
run(&mut s, "edit.redo", json!({}));
assert_eq!(indent(&s), Some(36.0));
}
#[test]
fn backspace_and_delete() {
let mut s = s();
@@ -118,6 +141,27 @@ fn styles_and_lists() {
assert!(s.doc.para_at(&s.sel.focus).unwrap().props.numbering.is_some_and(|n| n.num != 0));
}
#[test]
fn applying_a_heading_clears_list_numbering() {
// Applying a heading (or Title/Normal) to a numbered paragraph removes its direct list
// numbering. A dead `&& id == "Normal"` term used to let only Normal clear it, so headings
// kept the numbering.
for style_cmd in ["para.heading1", "para.heading2", "para.normal"] {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "item"}));
run(&mut s, "para.bullets", json!({}));
assert!(
s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.is_some_and(|n| n.num != 0),
"{style_cmd}: expected list numbering before applying the style"
);
run(&mut s, style_cmd, json!({}));
assert!(
!s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.is_some_and(|n| n.num != 0),
"{style_cmd}: should clear direct list numbering"
);
}
}
#[test]
fn find_replace() {
let mut s = s();
@@ -227,6 +271,24 @@ fn toc_and_fields() {
assert_eq!(s.doc.plain_text(StoryRef::Body).matches("Summary\t").count(), 1, "one TOC entry after update");
}
#[test]
fn toc_page_numbers_follow_headings() {
let mut s = s();
run(&mut s, "references.toc", json!({}));
run(&mut s, "caret.docEnd", json!({}));
for name in ["Alpha", "Bravo", "Charlie"] {
run(&mut s, "text.pageBreak", json!({}));
run(&mut s, "text.insert", json!({"text": name}));
run(&mut s, "para.heading1", json!({}));
run(&mut s, "text.newParagraph", json!({}));
}
run(&mut s, "references.updateToc", json!({}));
let t = text(&s);
for (name, page) in [("Alpha", 2), ("Bravo", 3), ("Charlie", 4)] {
assert!(t.contains(&format!("{name}\t{page}")), "{name} should be on page {page}: {t}");
}
}
#[test]
fn failed_command_leaves_document_unchanged() {
let mut s = s();
+17
View File
@@ -50,6 +50,23 @@ pub fn japanese_ui_fonts(bold: bool) -> Vec<&'static CraftFont> {
v
}
/// The craft-fonts faces for Simplified Chinese (`"Hans"`), in manifest order. Empty without
/// craft-fonts.
pub fn chinese_fonts() -> impl Iterator<Item = &'static CraftFont> {
CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Hans"))
}
/// The CJK interface faces, Regular styles only (the UI fakes no bold): the Japanese UI face and
/// the Chinese face, Chinese first when `prefer_hans` (a Chinese interface), Japanese first
/// otherwise. Mixing the two within a line would mix glyph styles, so the first face should
/// cover the interface language. Empty without craft-fonts.
pub fn ui_cjk_fonts(prefer_hans: bool) -> Vec<&'static CraftFont> {
let regular = |f: &&CraftFont| f.style == "Regular";
let ja = japanese_ui_fonts(false).into_iter().filter(regular).take(1);
let zh = chinese_fonts().filter(regular).take(1);
if prefer_hans { zh.chain(ja).collect() } else { ja.chain(zh).collect() }
}
/// The bundled serif used when a document asks for nothing better.
pub const DEFAULT_FAMILY: &str = "Source Serif 4";
+41
View File
@@ -87,6 +87,34 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved {
r
}
/// The standard Unicode character for a Symbol or Wingdings character stored the way Word stores
/// it, as the private-use code U+F000 + the font's own code (U+F0B7 is the Symbol bullet). Used
/// when that font isn't installed: a substitute draws something else, or nothing, at the
/// private-use code. `None` for other fonts and for codes not in the table.
pub fn symbol_font_char(family: &str, c: char) -> Option<char> {
let code = u32::from(c).checked_sub(0xF000)?;
let u = match (family.to_ascii_lowercase().as_str(), code) {
// Adobe Symbol encoding.
("symbol", 0xA7) => '\u{2663}',
("symbol", 0xA8) => '\u{2666}',
("symbol", 0xA9) => '\u{2665}',
("symbol", 0xAA) => '\u{2660}',
("symbol", 0xAE) => '\u{2192}',
("symbol", 0xB7) => '\u{2022}',
("symbol", 0xD7) => '\u{22C5}',
("symbol", 0xE0) => '\u{25CA}',
// Wingdings characters common as list bullets.
("wingdings", 0x6C) => '\u{25CF}',
("wingdings", 0x6E) => '\u{25A0}',
("wingdings", 0x76) => '\u{2756}',
("wingdings", 0xA7) => '\u{25AA}',
("wingdings", 0xD8) => '\u{27A2}',
("wingdings", 0xFC) => '\u{2714}',
_ => return None,
};
Some(u)
}
/// Line metrics for a face in font units: (ascent, descent), both positive, the way word
/// processors measure single line spacing (OS/2 usWinAscent/usWinDescent, falling back to the
/// hhea values plus line gap).
@@ -131,6 +159,19 @@ mod tests {
assert!(a > 0.0 && d >= 0.0);
}
#[test]
fn symbol_font_chars_map_to_unicode() {
assert_eq!(symbol_font_char("Symbol", '\u{F0B7}'), Some('\u{2022}'));
assert_eq!(symbol_font_char("symbol", '\u{F0B7}'), Some('\u{2022}'));
assert_eq!(symbol_font_char("Wingdings", '\u{F0A7}'), Some('\u{25AA}'));
assert_eq!(symbol_font_char("Wingdings", '\u{F0D8}'), Some('\u{27A2}'));
// Same code, other font; unknown codes; ordinary characters.
assert_eq!(symbol_font_char("Arial", '\u{F0B7}'), None);
assert_eq!(symbol_font_char("Symbol", '\u{F041}'), None);
assert_eq!(symbol_font_char("Symbol", 'a'), None);
assert_eq!(symbol_font_char("Symbol", '\u{10FFFF}'), None);
}
#[test]
fn synthesizes_missing_styles() {
// JetBrains Mono ships only Regular here.
+13 -2
View File
@@ -499,9 +499,20 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
let label = env.label.as_ref().map(|(text, lvl)| {
let lc = p.mark.clone().overlaid(&lvl.chr);
let rc = resolve(&lc);
// A Symbol/Wingdings bullet (U+F0B7…) without that font installed, or in a face that has
// no glyph at the private-use code: draw the standard character instead, since a
// substitute has another glyph, or none, there.
let r = wordcraft_fonts::word::resolve(&rc.font, rc.bold, rc.italic);
let text: String = text
.chars()
.map(|c| match wordcraft_fonts::word::symbol_font_char(&rc.font, c) {
Some(u) if r.substituted || !r.face.covers(c) => u,
_ => c,
})
.collect();
let before_c = b.clusters.len();
let before_g = b.glyphs.len();
b.shape(text, 0, &rc, None);
b.shape(&text, 0, &rc, None);
let cl: Vec<Cluster> = b.clusters.drain(before_c..).collect();
let mut glyphs = Vec::new();
let mut x = 0.0;
@@ -514,7 +525,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
x += c.adv;
}
b.glyphs.truncate(before_g);
Label { text: text.clone(), style, glyphs, x: 0.0, width: x }
Label { text, style, glyphs, x: 0.0, width: x }
});
let mut pl = ParaLayout {
+23 -1
View File
@@ -120,6 +120,26 @@ fn lists_get_labels_and_indent() {
assert!((x - 108.0).abs() < 0.5, "{x}");
}
#[test]
fn symbol_font_bullet_without_the_font_draws_a_bullet() {
// Word's default bullet: U+F0B7 in Symbol.
let mut d = Document::from_text("one");
let id = d.numbering.add_list(wordcraft_doc::ListKind::BulletChar('\u{F0B7}'));
for a in &mut d.numbering.abstracts {
if let Some(l) = a.levels.first_mut() {
l.chr.font = Some("Symbol".into());
}
}
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 })).unwrap();
let l = lay(&d);
let label = l.pages[0].items.iter().find_map(|it| if let Placed::Lines { para, .. } = it { para.label.clone() } else { None }).unwrap();
// Where a real Symbol font with the private-use code is installed (Windows), the code stays.
let symbol = wordcraft_fonts::word::resolve("Symbol", false, false);
let expected = if symbol.substituted || !symbol.face.covers('\u{F0B7}') { "\u{2022}" } else { "\u{F0B7}" };
assert_eq!(label.text, expected);
assert_eq!(label.glyphs.len(), 1);
}
#[test]
fn tables_lay_out_cells() {
let mut d = Document::from_text("before\nafter");
@@ -166,7 +186,9 @@ fn tall_first_page_header_pushes_body_down() {
let l = lay(&d);
let first = l.caret(&Pos::body(0, 0)).unwrap();
assert!(first.top > d.last_section.margin_top + 50.0, "{first:?}");
let later = l.caret_on(&Pos::body(119, 0), l.pages.len() - 1).unwrap();
// The first paragraph on page 2 (which has no header) sits higher than page 1's first line,
// whatever the installed fonts do to pagination.
let later = (0..120).filter_map(|i| l.caret(&Pos::body(i, 0))).find(|c| c.page == 1).unwrap();
assert!(later.top < first.top, "{later:?}");
}
+2
View File
@@ -19,6 +19,8 @@ egui = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
# The system's preferred interface languages (#8). Native: OS APIs; wasm: none (English).
sys-locale = "0.3"
[dev-dependencies]
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
+49 -25
View File
@@ -48,7 +48,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
ui.painter().text(
pos2(r.min.x + 22.0, r.center().y),
Align2::LEFT_CENTER,
label,
tl!(label),
if active { semibold(14.0) } else { medium(14.0) },
egui::Color32::WHITE,
);
@@ -71,7 +71,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
if resp.hovered() {
ui.painter().rect_filled(r, 0.0, egui::Color32::from_white_alpha(26));
}
ui.painter().text(pos2(r.min.x + 22.0, r.center().y), Align2::LEFT_CENTER, label, regular(13.0), egui::Color32::WHITE);
ui.painter().text(pos2(r.min.x + 22.0, r.center().y), Align2::LEFT_CENTER, tl!(label), regular(13.0), egui::Color32::WHITE);
if resp.clicked() {
if id == "discord" {
let _ = app.run("ui.discord", json!({}));
@@ -95,7 +95,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
}
fn heading(ui: &mut Ui, s: &str) {
ui.label(egui::RichText::new(s).font(semibold(26.0)));
ui.label(egui::RichText::new(tl!(s)).font(semibold(26.0)));
ui.add_space(16.0);
}
@@ -130,7 +130,7 @@ fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) {
if let Some(h) = tex {
ui.painter().image(h.id(), r.shrink(1.0), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), egui::Color32::WHITE);
}
ui.label(egui::RichText::new(label).font(regular(12.5)));
ui.label(egui::RichText::new(tl!(label)).font(regular(12.5)));
if resp.clicked() {
let _ = app.run("file.new", json!({"template": template}));
app.ui.backstage = false;
@@ -165,10 +165,10 @@ fn new_page(app: &mut WordApp, ui: &mut Ui) {
fn open_list(app: &mut WordApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new("Recent").font(semibold(16.0)));
ui.label(egui::RichText::new(tl!("Recent")).font(semibold(16.0)));
ui.add_space(6.0);
if app.ui.recent.is_empty() {
ui.label(egui::RichText::new("Documents you open will show up here.").color(t.text_dim));
ui.label(egui::RichText::new(tl!("Documents you open will show up here.")).color(t.text_dim));
}
for p in app.ui.recent.clone() {
let name = std::path::Path::new(&p).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(p.clone());
@@ -189,7 +189,7 @@ fn open_list(app: &mut WordApp, ui: &mut Ui) {
fn open_page(app: &mut WordApp, ui: &mut Ui) {
heading(ui, "Open");
if ui.button(egui::RichText::new("📂 Browse…").font(medium(14.0))).clicked() {
if ui.button(egui::RichText::new(tl!("📂 Browse…")).font(medium(14.0))).clicked() {
let _ = app.run("ui.openFileDialog", json!({}));
}
ui.add_space(18.0);
@@ -201,12 +201,12 @@ fn info_page(app: &mut WordApp, ui: &mut Ui) {
let info = app.session.run("file.info", &json!({})).unwrap_or_default();
ui.columns(2, |cols| {
let ui = &mut cols[0];
ui.label(egui::RichText::new("Properties").font(semibold(15.0)));
ui.label(egui::RichText::new(tl!("Properties")).font(semibold(15.0)));
let mut props = app.session.doc.core.clone();
let mut changed = false;
egui::Grid::new("props").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| {
for (l, v) in [("Title", &mut props.title), ("Subject", &mut props.subject), ("Author", &mut props.creator), ("Keywords", &mut props.keywords), ("Category", &mut props.category)] {
ui.label(l);
ui.label(tl!(l));
changed |= ui.text_edit_singleline(v).lost_focus();
ui.end_row();
}
@@ -215,17 +215,18 @@ fn info_page(app: &mut WordApp, ui: &mut Ui) {
let _ = app.run("file.properties", json!({"title": props.title, "subject": props.subject, "author": props.creator, "keywords": props.keywords, "category": props.category}));
}
let ui = &mut cols[1];
ui.label(egui::RichText::new("Statistics").font(semibold(15.0)));
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")] {
ui.label(format!("{l}: {}", info.get(k).map(|v| v.to_string()).unwrap_or_default()));
ui.label(format!("{}: {}", tl!(l), info.get(k).map(|v| v.to_string()).unwrap_or_default()));
}
ui.label(format!("Location: {}", info.get("path").and_then(|v| v.as_str()).unwrap_or("Not saved yet")));
let path = info.get("path").and_then(|v| v.as_str()).unwrap_or(tl!("Not saved yet"));
ui.label(crate::i18n::fmt(tl!("Location: {path}"), &[("path", path)]));
});
}
fn export_page(app: &mut WordApp, ui: &mut Ui) {
heading(ui, if app.ui.backstage_page == "print" { "Print" } else { "Export" });
ui.label("Save a copy in another format. Printing goes through a PDF you can print from any viewer.");
ui.label(tl!("Save a copy in another format. Printing goes through a PDF you can print from any viewer."));
ui.add_space(12.0);
for (label, ext) in [
("PDF document (*.pdf)", "pdf"),
@@ -237,13 +238,13 @@ fn export_page(app: &mut WordApp, ui: &mut Ui) {
("Plain text (*.txt)", "txt"),
("Page image (*.png)", "png"),
] {
if ui.add(egui::Button::new(egui::RichText::new(label).font(medium(13.5))).min_size(vec2(320.0, 34.0))).clicked() {
if ui.add(egui::Button::new(egui::RichText::new(tl!(label)).font(medium(13.5))).min_size(vec2(320.0, 34.0))).clicked() {
let name = format!("{}.{ext}", app.title_stem());
let picked = app.services.pick_save.as_ref().and_then(|f| f(&name));
if let Some(path) = picked {
let r = if ext == "png" { app.run("file.exportPng", json!({"path": path})) } else { app.run("file.saveAs", json!({"path": path})) };
if r.is_ok() {
app.status(format!("Exported {path}"));
app.status(crate::i18n::fmt(tl!("Exported {path}"), &[("path", &path)]));
}
}
}
@@ -253,31 +254,54 @@ fn export_page(app: &mut WordApp, ui: &mut Ui) {
fn options_page(app: &mut WordApp, ui: &mut Ui) {
heading(ui, "Options");
ui.label(egui::RichText::new("General").font(semibold(15.0)));
ui.label(egui::RichText::new(tl!("General")).font(semibold(15.0)));
language_picker(app, ui);
ui.horizontal(|ui| {
ui.label("User name:");
ui.label(tl!("User name:"));
let mut n = app.session.author.clone();
if ui.text_edit_singleline(&mut n).changed() {
app.session.author = n;
}
});
let mut dark = app.ui.dark;
if ui.checkbox(&mut dark, "Dark mode").changed() {
if ui.checkbox(&mut dark, tl!("Dark mode")).changed() {
let _ = app.run("ui.dark", json!({"value": dark}));
}
ui.checkbox(&mut app.autosave, "AutoSave documents that have been saved");
ui.checkbox(&mut app.ui.show_discord, "Show the community button in the title bar");
ui.checkbox(&mut app.autosave, tl!("AutoSave documents that have been saved"));
ui.checkbox(&mut app.ui.show_discord, tl!("Show the community button in the title bar"));
ui.add_space(10.0);
ui.label(egui::RichText::new("Display").font(semibold(15.0)));
ui.label(egui::RichText::new(tl!("Display")).font(semibold(15.0)));
let mut marks = app.session.view.marks;
if ui.checkbox(&mut marks, "Show all formatting marks").changed() {
if ui.checkbox(&mut marks, tl!("Show all formatting marks")).changed() {
let _ = app.run("view.marks", json!({"value": marks}));
}
let mut ruler = app.session.view.ruler;
if ui.checkbox(&mut ruler, "Show rulers").changed() {
if ui.checkbox(&mut ruler, tl!("Show rulers")).changed() {
let _ = app.run("view.ruler", json!({"value": ruler}));
}
ui.add_space(10.0);
ui.label(egui::RichText::new("Agents").font(semibold(15.0)));
ui.label("Every command is available to scripts and AI agents: run `wordcraft-cli mcp` for an MCP server, or start the app with `--control <port>` for the JSON control channel.");
ui.label(egui::RichText::new(tl!("Agents")).font(semibold(15.0)));
ui.label(tl!("Every command is available to scripts and AI agents: run `wordcraft-cli mcp` for an MCP server, or start the app with `--control <port>` for the JSON control channel."));
}
/// File ▸ Options ▸ Interface language: follow the system (the default) or pick one (#8).
fn language_picker(app: &mut WordApp, ui: &mut Ui) {
use crate::i18n::{AUTO, Lang};
let system = crate::i18n::system_lang();
let auto_label = crate::i18n::fmt(tl!("Automatic ({language})"), &[("language", system.name())]);
let current = if app.ui.language == AUTO { auto_label.clone() } else { Lang::from_pref(&app.ui.language).name().to_string() };
ui.horizontal(|ui| {
ui.label(tl!("Interface language:"));
egui::ComboBox::from_id_salt("interface_language").selected_text(current).width(220.0).show_ui(ui, |ui| {
if ui.selectable_label(app.ui.language == AUTO, auto_label.as_str()).clicked() {
let _ = app.run("ui.language", json!({"value": AUTO}));
}
for lang in Lang::all() {
if ui.selectable_label(app.ui.language == lang.code(), lang.name()).clicked() {
let _ = app.run("ui.language", json!({"value": lang.code()}));
}
}
});
});
ui.label(egui::RichText::new(tl!("Automatic follows your system's language. Menus and commands change; your documents don't.")).small().weak());
}
+18 -8
View File
@@ -245,7 +245,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
dashed(&painter, pos2(sr.min.x, y), pos2(sr.max.x, y), Stroke::new(1.0, t.accent));
let tr = Rect::from_min_size(pos2(sr.min.x + 2.0, if label == "Header" { y } else { y - 18.0 }), vec2(52.0, 18.0));
painter.rect_filled(tr, 2.0, t.checked);
painter.text(tr.center(), egui::Align2::CENTER_CENTER, label, regular(11.0), t.accent_text);
painter.text(tr.center(), egui::Align2::CENTER_CENTER, tl!(label), regular(11.0), t.accent_text);
}
}
}
@@ -389,7 +389,7 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re
for (ax, ay, id, pos) in list.iter() {
let Some(c) = app.session.doc.comments.get(id) else { continue };
let text = app.session.doc.plain_text(StoryRef::Part(c.part));
let author = if c.author.is_empty() { "Author".to_string() } else { c.author.clone() };
let author = if c.author.is_empty() { tl!("Author").to_string() } else { c.author.clone() };
let ai = authors.iter().position(|a| *a == author).unwrap_or_else(|| {
authors.push(author.clone());
authors.len() - 1
@@ -548,7 +548,8 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
&& let Some(pos) = layout.hit(page, x, y, story)
&& let Some(link) = app.session.doc.para_at(&pos).and_then(|pp| pp.props_of_char(pos.off).link.clone())
{
let tip = format!("{link}\n{}+Click to follow link", if cfg!(target_os = "macos") { "⌘" } else { "Ctrl" });
let key = if cfg!(target_os = "macos") { "⌘" } else { "Ctrl" };
let tip = format!("{link}\n{}", crate::i18n::fmt(tl!("{key}+Click to follow link"), &[("key", key)]));
egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("link_tip"), egui::PopupAnchor::Pointer).show(|ui| {
ui.label(tip);
});
@@ -652,6 +653,9 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo
{
let pt = ((pp.x - x0) / scale - col_x).clamp(-col_x, page.body.w - 18.0);
let snapped = (pt / 4.5).round() * 4.5;
if !r.drag_started() {
app.session.join_next_undo();
}
let _ = app.run("para.indents", json!({"left": snapped}));
}
let fr = Rect::from_center_size(pos2(first, bar.min.y + 3.0), vec2(12.0, 10.0));
@@ -660,6 +664,9 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo
&& let Some(pp) = r.interact_pointer_pos()
{
let pt = (pp.x - x0) / scale - col_x - rp.indent_left;
if !r.drag_started() {
app.session.join_next_undo();
}
let _ = app.run("para.indents", json!({"firstLine": (pt / 4.5).round() * 4.5}));
}
let rr = Rect::from_center_size(pos2(right, bar.max.y - 3.0), vec2(12.0, 12.0));
@@ -668,6 +675,9 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo
&& let Some(pp) = r.interact_pointer_pos()
{
let pt = page.body.w - ((pp.x - x0) / scale - col_x);
if !r.drag_started() {
app.session.join_next_undo();
}
let _ = app.run("para.indents", json!({"right": (pt / 4.5).round() * 4.5}));
}
}
@@ -736,7 +746,7 @@ fn context_menu(app: &mut WordApp, ui: &mut Ui) {
.unwrap_or_default();
ui.label(egui::RichText::new(issue.get("message").and_then(|m| m.as_str()).unwrap_or("")).small().weak());
if sugg.is_empty() {
ui.label(egui::RichText::new("(no suggestions)").italics());
ui.label(egui::RichText::new(tl!("(no suggestions)")).italics());
}
for sgt in sugg {
if ui.button(egui::RichText::new(&sgt).strong()).clicked() {
@@ -751,7 +761,7 @@ fn context_menu(app: &mut WordApp, ui: &mut Ui) {
ui.separator();
}
item(ui, app, "Cut", "edit.cut", json!({}));
if ui.add(egui::Button::new("Copy").shortcut_text(crate::widgets::shortcut_text(app, "edit.copy"))).clicked() {
if ui.add(egui::Button::new(tl!("Copy")).shortcut_text(crate::widgets::shortcut_text(app, "edit.copy"))).clicked() {
if let Ok(r) = app.run("edit.copy", json!({}))
&& let Some(t) = r.get("text").and_then(|t| t.as_str())
{
@@ -762,7 +772,7 @@ fn context_menu(app: &mut WordApp, ui: &mut Ui) {
item(ui, app, "Paste", "edit.paste", json!({}));
ui.separator();
if let Some(syn) = app.canvas.context_synonyms.clone().and_then(|v| v.as_array().cloned()).filter(|a| !a.is_empty()) {
ui.menu_button("Synonyms", |ui| {
ui.menu_button(tl!("Synonyms"), |ui| {
for w in syn.iter().filter_map(|x| x.as_str()) {
if ui.button(w).clicked() {
let _ = app.run("select.word", json!({}));
@@ -782,13 +792,13 @@ fn context_menu(app: &mut WordApp, ui: &mut Ui) {
item(ui, app, "New Comment", "review.newComment", json!({}));
if app.session.sel.focus.path.cell().is_some() {
ui.separator();
ui.menu_button("Insert", |ui| {
ui.menu_button(tl!("Insert"), |ui| {
item(ui, app, "Insert Rows Above", "table.insertRowAbove", json!({}));
item(ui, app, "Insert Rows Below", "table.insertRowBelow", json!({}));
item(ui, app, "Insert Columns to the Left", "table.insertColumnLeft", json!({}));
item(ui, app, "Insert Columns to the Right", "table.insertColumnRight", json!({}));
});
ui.menu_button("Delete", |ui| {
ui.menu_button(tl!("Delete"), |ui| {
item(ui, app, "Delete Rows", "table.deleteRow", json!({}));
item(ui, app, "Delete Columns", "table.deleteColumn", json!({}));
item(ui, app, "Delete Table", "table.deleteTable", json!({}));
+39 -22
View File
@@ -45,7 +45,7 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
ui.spacing_mut().item_spacing = vec2(2.0, 0.0);
// AutoSave toggle (on when the document has a path).
let has_path = app.session.path.is_some();
ui.label(egui::RichText::new("AutoSave").font(regular(11.5)).color(t.text_dim));
ui.label(egui::RichText::new(tl!("AutoSave")).font(regular(11.5)).color(t.text_dim));
let (r, resp) = ui.allocate_exact_size(vec2(30.0, 16.0), Sense::click());
let on = has_path && app.canvas_autosave();
ui.painter().rect(
@@ -57,7 +57,7 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
);
let knob = if on { r.max.x - 8.0 } else { r.min.x + 8.0 };
ui.painter().circle_filled(pos2(knob, r.center().y), 5.0, if on { egui::Color32::WHITE } else { t.text_dim });
if resp.on_hover_text("AutoSave saves every change to the file (needs a saved document)").clicked() {
if resp.on_hover_text(tl!("AutoSave saves every change to the file (needs a saved document)")).clicked() {
if has_path {
app.toggle_autosave();
} else {
@@ -65,12 +65,12 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
}
}
ui.add_space(8.0);
qat_button(ui, app, "save", "Save", "file.save", true);
qat_button(ui, app, "save", tl!("Save"), "file.save", true);
let can_undo = app.session.can_undo();
let can_redo = app.session.can_redo();
qat_button(ui, app, "undo", &format!("Undo {}", app.session.undo_label().unwrap_or("")), "edit.undo", can_undo);
qat_button(ui, app, "redo", "Redo", "edit.redo", can_redo);
qat_button(ui, app, "more", "Customize Quick Access Toolbar", "ui.dialog", true);
qat_button(ui, app, "undo", &crate::i18n::prefixed("Undo", app.session.undo_label().unwrap_or("")), "edit.undo", can_undo);
qat_button(ui, app, "redo", tl!("Redo"), "edit.redo", can_redo);
qat_button(ui, app, "more", tl!("Customize Quick Access Toolbar"), "ui.dialog", true);
qat_end = ui.min_rect().max.x;
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// Account / community.
@@ -94,8 +94,14 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
egui::Color32::WHITE,
egui::Color32::WHITE,
);
ui.painter().text(pos2(r.min.x + 24.0, r.center().y), Align2::LEFT_CENTER, "Discord", medium(11.5), egui::Color32::WHITE);
if resp.on_hover_text("Join the ArtCraft community on Discord").clicked() {
ui.painter().text(
pos2(r.min.x + 24.0, r.center().y),
Align2::LEFT_CENTER,
tl!("Discord"),
medium(11.5),
egui::Color32::WHITE,
);
if resp.on_hover_text(tl!("Join the ArtCraft community on Discord")).clicked() {
let _ = app.run("ui.discord", json!({}));
}
ui.add_space(8.0);
@@ -104,7 +110,13 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
let (r, resp) = ui.allocate_exact_size(vec2(260.0, 26.0), Sense::click());
ui.painter().rect(r, 6.0, if resp.hovered() { t.input } else { t.ribbon }, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
icons::paint(ui.painter(), Rect::from_min_size(r.min + vec2(8.0, 5.0), vec2(16.0, 16.0)), "search", t.text_dim, t.accent);
ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, "Search commands and help", regular(12.0), t.text_dim);
ui.painter().text(
pos2(r.min.x + 30.0, r.center().y),
Align2::LEFT_CENTER,
tl!("Search commands and help"),
regular(12.0),
t.text_dim,
);
if resp.clicked() {
let _ = app.run("ui.dialog", json!({"name": "commands"}));
}
@@ -116,11 +128,11 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
"{} — {}",
app.title_stem(),
if app.session.dirty {
"Edited"
tl!("Edited")
} else if app.session.path.is_some() {
"Saved"
tl!("Saved")
} else {
"Not saved"
tl!("Not saved")
}
);
let g = ui.ctx().fonts_mut(|f| f.layout_no_wrap(title.clone(), semibold(12.5), t.text));
@@ -150,25 +162,28 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
let st = |ui: &mut Ui, s: &str| {
ui.add(egui::Label::new(egui::RichText::new(s).font(regular(11.5)).color(t.text_dim)).sense(Sense::click()))
};
if st(ui, &format!("Page {page} of {}", l.pages.len())).on_hover_text("Go To (⌘⌥G)").clicked() {
if st(ui, &crate::i18n::fmt(tl!("Page {page} of {pages}"), &[("page", &page.to_string()), ("pages", &l.pages.len().to_string())]))
.on_hover_text(tl!("Go To (⌘⌥G)"))
.clicked()
{
let _ = app.run("ui.dialog", json!({"name": "goto"}));
}
let words = app.cached_word_count();
let wtxt = if app.session.sel.is_collapsed() {
format!("{words} words")
crate::i18n::fmt(tl!("{words} words"), &[("words", &words.to_string())])
} else {
let sw = wordcraft_doc::count_words(&app.session.selected_text());
format!("{sw} of {words} words")
crate::i18n::fmt(tl!("{selected} of {words} words"), &[("selected", &sw.to_string()), ("words", &words.to_string())])
};
if st(ui, &wtxt).clicked() {
let _ = app.run("ui.dialog", json!({"name": "wordCount"}));
}
if app.session.sel.focus.story != StoryRef::Body {
st(ui, "Editing header/footer");
st(ui, tl!("Editing header/footer"));
}
st(ui, "English (United States)");
st(ui, tl!("English (United States)"));
if app.session.doc.settings.track_changes {
st(ui, "Track Changes: On");
st(ui, tl!("Track Changes: On"));
}
if let Some((msg, at)) = &app.status_msg
&& crate::now_ms() - at < 6000.0
@@ -184,7 +199,7 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
{
let _ = app.run("ui.dialog", json!({"name": "zoom"}));
}
if small_icon(ui, "plus", "Zoom In") {
if small_icon(ui, "plus", tl!("Zoom In")) {
let _ = app.run("view.zoomIn", json!({}));
}
// Zoom slider (10%–500%, 100% in the middle).
@@ -194,7 +209,7 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
if ui.add(slider).changed() {
let _ = app.run("view.zoom", json!({"value": (v * 100.0).round()}));
}
if small_icon(ui, "minus", "Zoom Out") {
if small_icon(ui, "minus", tl!("Zoom Out")) {
let _ = app.run("view.zoomOut", json!({}));
}
ui.add_space(10.0);
@@ -217,12 +232,14 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
}
}
ui.add_space(8.0);
let (r, resp) = ui.allocate_exact_size(vec2(56.0, 20.0), Sense::click());
let focus = tl!("Focus");
let focus_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(focus.to_string(), regular(11.5), t.text_dim).size().x);
let (r, resp) = ui.allocate_exact_size(vec2((focus_w + 26.0).max(56.0), 20.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
icons::paint(ui.painter(), Rect::from_min_size(r.min + vec2(2.0, 2.0), vec2(16.0, 16.0)), "focus", t.icon, t.accent);
ui.painter().text(pos2(r.min.x + 22.0, r.center().y), Align2::LEFT_CENTER, "Focus", regular(11.5), t.text_dim);
ui.painter().text(pos2(r.min.x + 22.0, r.center().y), Align2::LEFT_CENTER, focus, regular(11.5), t.text_dim);
if resp.clicked() {
let _ = app.run("view.focus", json!({}));
}
+1 -1
View File
@@ -46,7 +46,7 @@ pub fn inspect(app: &mut WordApp, ctx: &egui::Context) -> Value {
let r = ctx.content_rect();
let caret = crate::canvas::caret_screen(app).map(|(p, h)| json!({"x": p.x, "y": p.y, "h": h}));
json!({
"ui": app.ui,
"ui": app.prefs(),
"view": app.session.view,
"dialog": app.dialog.as_ref().map(|d| serde_json::to_value(d).unwrap_or_default()),
"window": [r.width(), r.height()],
+7 -7
View File
@@ -215,14 +215,14 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
let id = egui::Id::new("credits_view");
let mut v = ui.data_mut(|d| d.get_temp::<View>(id)).unwrap_or_default();
ui.horizontal_wrapped(|ui| {
ui.label("Show");
ui.label(tl!("Show"));
for m in NameMode::ALL {
if ui.selectable_label(v.names == m, m.label()).clicked() {
v.names = m;
}
}
ui.separator();
ui.label("Sort");
ui.label(tl!("Sort"));
egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| {
for k in SortKey::ALL {
if ui.selectable_label(v.key == k, k.label().0).clicked() {
@@ -231,14 +231,14 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
}
}
});
if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text("Reverse the order").clicked() {
if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text(tl!("Reverse the order")).clicked() {
v.ascending = !v.ascending;
}
ui.separator();
if ui.selectable_label(!v.table, "Grab bag").clicked() {
if ui.selectable_label(!v.table, tl!("Grab bag")).clicked() {
v.table = false;
}
if ui.selectable_label(v.table, "Table").clicked() {
if ui.selectable_label(v.table, tl!("Table")).clicked() {
v.table = true;
}
});
@@ -247,7 +247,7 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
ui.separator();
egui::ScrollArea::both().auto_shrink([false, true]).max_height(360.0).show(ui, |ui| {
if list.is_empty() {
ui.label("No contributor data was built into this copy.");
ui.label(tl!("No contributor data was built into this copy."));
} else if v.table {
table(ui, &list, &mut v);
} else {
@@ -297,7 +297,7 @@ fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
/// About ▸ Models: AI models credited in Co-Authored-By trailers.
pub fn models_ui(ui: &mut egui::Ui) {
if MODELS.is_empty() {
ui.label("No model credits were built into this copy.");
ui.label(tl!("No model credits were built into this copy."));
return;
}
let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
+112 -104
View File
@@ -288,13 +288,16 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
Dialog::Commands { .. } => "Search Commands",
Dialog::About { .. } => "About WordCraft",
};
egui::Window::new(title).collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, vec2(0.0, -40.0)).open(&mut open).show(
ctx,
|ui| {
egui::Window::new(tl!(title))
.id(egui::Id::new(("dialog", title)))
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, vec2(0.0, -40.0))
.open(&mut open)
.show(ctx, |ui| {
ui.set_min_width(340.0);
close = body(app, ui, &mut d);
},
);
});
if open && !close {
app.dialog = Some(d);
} else {
@@ -307,7 +310,7 @@ fn buttons(ui: &mut Ui, ok: &str) -> (bool, bool) {
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
if ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
r.1 = true;
}
let okb = ui.add(egui::Button::new(egui::RichText::new(ok).color(egui::Color32::WHITE)).fill(crate::theme::APP_COLOR));
@@ -324,38 +327,38 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
match d {
Dialog::Font { font, size, bold, italic, underline, strike, sup, sub, small_caps, caps, hidden, color, spacing } => {
egui::Grid::new("fontdlg").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label("Font:");
ui.label(tl!("Font:"));
ui.text_edit_singleline(font);
ui.end_row();
ui.label("Size:");
ui.label(tl!("Size:"));
ui.text_edit_singleline(size);
ui.end_row();
ui.label("Font color:");
ui.label(tl!("Font color:"));
ui.text_edit_singleline(color);
ui.end_row();
ui.label("Spacing (pt):");
ui.label(tl!("Spacing (pt):"));
ui.add(egui::DragValue::new(spacing).speed(0.1).range(-20.0..=20.0));
ui.end_row();
});
ui.separator();
ui.label(egui::RichText::new("Effects").font(semibold(12.5)));
ui.label(egui::RichText::new(tl!("Effects")).font(semibold(12.5)));
egui::Grid::new("fx").num_columns(2).show(ui, |ui| {
ui.checkbox(bold, "Bold");
ui.checkbox(italic, "Italic");
ui.checkbox(bold, tl!("Bold"));
ui.checkbox(italic, tl!("Italic"));
ui.end_row();
ui.checkbox(underline, "Underline");
ui.checkbox(strike, "Strikethrough");
ui.checkbox(underline, tl!("Underline"));
ui.checkbox(strike, tl!("Strikethrough"));
ui.end_row();
ui.checkbox(sup, "Superscript");
ui.checkbox(sub, "Subscript");
ui.checkbox(sup, tl!("Superscript"));
ui.checkbox(sub, tl!("Subscript"));
ui.end_row();
ui.checkbox(small_caps, "Small caps");
ui.checkbox(caps, "All caps");
ui.checkbox(small_caps, tl!("Small caps"));
ui.checkbox(caps, tl!("All caps"));
ui.end_row();
ui.checkbox(hidden, "Hidden");
ui.checkbox(hidden, tl!("Hidden"));
ui.end_row();
});
let (ok, cancel) = buttons(ui, "OK");
let (ok, cancel) = buttons(ui, tl!("OK"));
if ok {
let mut props = json!({
"bold": *bold, "italic": *italic, "strike": *strike,
@@ -377,40 +380,40 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
ok || cancel
}
Dialog::Paragraph { align, left, right, first, before, after, line, keep_next, keep_lines, page_break, widow } => {
ui.label(egui::RichText::new("General").font(semibold(12.5)));
egui::ComboBox::from_label("Alignment").selected_text(align.clone()).show_ui(ui, |ui| {
ui.label(egui::RichText::new(tl!("General")).font(semibold(12.5)));
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("Indentation").font(semibold(12.5)));
ui.label(egui::RichText::new(tl!("Indentation")).font(semibold(12.5)));
egui::Grid::new("ind").num_columns(4).show(ui, |ui| {
ui.label("Left:");
ui.label(tl!("Left:"));
ui.add(egui::DragValue::new(left).speed(0.05).suffix("\"").max_decimals(2));
ui.label("Right:");
ui.label(tl!("Right:"));
ui.add(egui::DragValue::new(right).speed(0.05).suffix("\"").max_decimals(2));
ui.end_row();
ui.label("First line:");
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("Spacing").font(semibold(12.5)));
ui.label(egui::RichText::new(tl!("Spacing")).font(semibold(12.5)));
egui::Grid::new("sp").num_columns(4).show(ui, |ui| {
ui.label("Before:");
ui.label(tl!("Before:"));
ui.add(egui::DragValue::new(before).speed(1.0).range(0.0..=1584.0).suffix(" pt"));
ui.label("Line spacing:");
ui.label(tl!("Line spacing:"));
ui.add(egui::DragValue::new(line).speed(0.05).range(0.5..=5.0));
ui.end_row();
ui.label("After:");
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("Line and Page Breaks").font(semibold(12.5)));
ui.checkbox(widow, "Widow/Orphan control");
ui.checkbox(keep_next, "Keep with next");
ui.checkbox(keep_lines, "Keep lines together");
ui.checkbox(page_break, "Page break before");
let (ok, cancel) = buttons(ui, "OK");
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 (ok, cancel) = buttons(ui, tl!("OK"));
if ok {
let _ = app.run("para.align", json!({"value": align}));
let _ = app.run(
@@ -426,27 +429,27 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
Dialog::Find { query, replace, match_case, whole_word, regex, replace_mode, message } => {
ui.horizontal(|ui| {
if ui.selectable_label(!*replace_mode, "Find").clicked() {
if ui.selectable_label(!*replace_mode, tl!("Find")).clicked() {
*replace_mode = false;
}
if ui.selectable_label(*replace_mode, "Replace").clicked() {
if ui.selectable_label(*replace_mode, tl!("Replace")).clicked() {
*replace_mode = true;
}
});
ui.horizontal(|ui| {
ui.label("Find what:");
ui.label(tl!("Find what:"));
ui.add(egui::TextEdit::singleline(query).desired_width(240.0));
});
if *replace_mode {
ui.horizontal(|ui| {
ui.label("Replace with:");
ui.label(tl!("Replace with:"));
ui.add(egui::TextEdit::singleline(replace).desired_width(228.0));
});
}
ui.horizontal(|ui| {
ui.checkbox(match_case, "Match case");
ui.checkbox(whole_word, "Whole words");
ui.checkbox(regex, "Wildcards (regex)");
ui.checkbox(match_case, tl!("Match case"));
ui.checkbox(whole_word, tl!("Whole words"));
ui.checkbox(regex, tl!("Wildcards (regex)"));
});
if !message.is_empty() {
ui.label(egui::RichText::new(message.as_str()).weak());
@@ -455,24 +458,24 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
let mut close = false;
ui.horizontal(|ui| {
if *replace_mode {
if ui.button("Replace All").clicked() {
if ui.button(tl!("Replace All")).clicked() {
match app.run("edit.replaceAll", opts.clone()) {
Ok(r) => *message = format!("All done. We made {} replacements.", r["replaced"]),
Err(e) => *message = e,
}
}
if ui.button("Replace").clicked() {
if ui.button(tl!("Replace")).clicked() {
let _ = app.run("edit.replace", opts.clone());
}
}
if ui.button("Find Next").clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
if ui.button(tl!("Find Next")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
let _ = app.session.run("edit.find", &opts);
match app.run("edit.findNext", json!({})) {
Ok(r) => *message = format!("Match {} of {}", r["index"].as_u64().unwrap_or(0) + 1, r["count"]),
Err(e) => *message = e,
}
}
if ui.button("Close").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
if ui.button(tl!("Close")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
close = true;
}
});
@@ -480,10 +483,10 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
Dialog::Goto { page } => {
ui.horizontal(|ui| {
ui.label("Enter page number:");
ui.label(tl!("Enter page number:"));
ui.text_edit_singleline(page);
});
let (ok, cancel) = buttons(ui, "Go To");
let (ok, cancel) = buttons(ui, tl!("Go To"));
if ok && let Ok(n) = page.trim().parse::<u64>() {
let _ = app.run("edit.goto", json!({"page": n}));
}
@@ -491,39 +494,39 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
Dialog::InsertTable { rows, cols } => {
egui::Grid::new("it").num_columns(2).show(ui, |ui| {
ui.label("Number of columns:");
ui.label(tl!("Number of columns:"));
ui.add(egui::DragValue::new(cols).range(1..=63));
ui.end_row();
ui.label("Number of rows:");
ui.label(tl!("Number of rows:"));
ui.add(egui::DragValue::new(rows).range(1..=1000));
ui.end_row();
});
let (ok, cancel) = buttons(ui, "OK");
let (ok, cancel) = buttons(ui, tl!("OK"));
if ok {
let _ = app.run("insert.table", json!({"rows": *rows, "cols": *cols}));
}
ok || cancel
}
Dialog::PageSetup { top, bottom, left, right, landscape } => {
ui.label(egui::RichText::new("Margins").font(semibold(12.5)));
ui.label(egui::RichText::new(tl!("Margins")).font(semibold(12.5)));
egui::Grid::new("ps").num_columns(4).show(ui, |ui| {
ui.label("Top:");
ui.label(tl!("Top:"));
ui.add(egui::DragValue::new(top).speed(0.05).suffix("\"").max_decimals(2));
ui.label("Bottom:");
ui.label(tl!("Bottom:"));
ui.add(egui::DragValue::new(bottom).speed(0.05).suffix("\"").max_decimals(2));
ui.end_row();
ui.label("Left:");
ui.label(tl!("Left:"));
ui.add(egui::DragValue::new(left).speed(0.05).suffix("\"").max_decimals(2));
ui.label("Right:");
ui.label(tl!("Right:"));
ui.add(egui::DragValue::new(right).speed(0.05).suffix("\"").max_decimals(2));
ui.end_row();
});
ui.label(egui::RichText::new("Orientation").font(semibold(12.5)));
ui.label(egui::RichText::new(tl!("Orientation")).font(semibold(12.5)));
ui.horizontal(|ui| {
ui.radio_value(landscape, false, "Portrait");
ui.radio_value(landscape, true, "Landscape");
ui.radio_value(landscape, false, tl!("Portrait"));
ui.radio_value(landscape, true, tl!("Landscape"));
});
let (ok, cancel) = buttons(ui, "OK");
let (ok, cancel) = buttons(ui, tl!("OK"));
if ok {
let _ = app.run("layout.orientation", json!({"value": if *landscape { "landscape" } else { "portrait" }}));
let _ =
@@ -533,21 +536,21 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
Dialog::Link { url, text } => {
egui::Grid::new("lnk").num_columns(2).show(ui, |ui| {
ui.label("Text to display:");
ui.label(tl!("Text to display:"));
ui.text_edit_singleline(text);
ui.end_row();
ui.label("Address:");
ui.label(tl!("Address:"));
ui.text_edit_singleline(url);
ui.end_row();
});
let (ok, cancel) = buttons(ui, "OK");
let (ok, cancel) = buttons(ui, tl!("OK"));
if ok {
let _ = app.run("insert.link", json!({"url": url, "text": text}));
}
ok || cancel
}
Dialog::Bookmark { name } => {
ui.label("Bookmark name:");
ui.label(tl!("Bookmark name:"));
ui.text_edit_singleline(name);
let marks = app.session.doc.bookmarks();
for (n, _) in &marks {
@@ -555,7 +558,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
let _ = app.run("edit.goto", json!({"bookmark": n}));
}
}
let (ok, cancel) = buttons(ui, "Add");
let (ok, cancel) = buttons(ui, tl!("Add"));
if ok {
let _ = app.run("insert.bookmark", json!({"name": name}));
}
@@ -571,12 +574,12 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
("Paragraphs", "paragraphs"),
("Lines", "lines"),
] {
ui.label(l);
ui.label(tl!(l));
ui.label(stats.get(k).map(|v| v.to_string()).unwrap_or_default());
ui.end_row();
}
});
let (ok, cancel) = buttons(ui, "Close");
let (ok, cancel) = buttons(ui, tl!("Close"));
ok || cancel
}
Dialog::Zoom { percent } => {
@@ -588,13 +591,13 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
ui.add(egui::Slider::new(percent, 10.0..=500.0).suffix("%"));
let mut fit = None;
ui.horizontal(|ui| {
if ui.button("Page width").clicked() {
if ui.button(tl!("Page width")).clicked() {
fit = Some("pageWidth");
}
if ui.button("Whole page").clicked() {
if ui.button(tl!("Whole page")).clicked() {
fit = Some("onePage");
}
if ui.button("Many pages").clicked() {
if ui.button(tl!("Many pages")).clicked() {
fit = Some("multiplePages");
}
});
@@ -602,7 +605,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
let _ = app.run("view.zoom", json!({"value": f}));
return true;
}
let (ok, cancel) = buttons(ui, "OK");
let (ok, cancel) = buttons(ui, tl!("OK"));
if ok {
let _ = app.run("view.zoom", json!({"value": *percent}));
}
@@ -610,14 +613,14 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
Dialog::Watermark { text, diagonal } => {
ui.horizontal(|ui| {
ui.label("Text:");
ui.label(tl!("Text:"));
ui.text_edit_singleline(text);
});
ui.horizontal(|ui| {
ui.radio_value(diagonal, true, "Diagonal");
ui.radio_value(diagonal, false, "Horizontal");
ui.radio_value(diagonal, true, tl!("Diagonal"));
ui.radio_value(diagonal, false, tl!("Horizontal"));
});
let (ok, cancel) = buttons(ui, "OK");
let (ok, cancel) = buttons(ui, tl!("OK"));
if ok {
let _ = app.run("design.watermark", json!({"text": text, "diagonal": *diagonal}));
}
@@ -625,15 +628,15 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
Dialog::NewStyle { name, based_on } => {
egui::Grid::new("ns").num_columns(2).show(ui, |ui| {
ui.label("Name:");
ui.label(tl!("Name:"));
ui.text_edit_singleline(name);
ui.end_row();
ui.label("Style based on:");
ui.label(tl!("Style based on:"));
ui.text_edit_singleline(based_on);
ui.end_row();
});
ui.label(egui::RichText::new("The new style takes the formatting of the current paragraph.").small().weak());
let (ok, cancel) = buttons(ui, "OK");
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"));
if ok {
let _ = app.run("styles.create", json!({"name": name, "basedOn": based_on}));
}
@@ -641,19 +644,19 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
Dialog::ModifyStyle { id, name, font, size, bold, italic, color, before, after } => {
egui::Grid::new("ms").num_columns(2).show(ui, |ui| {
ui.label("Name:");
ui.label(tl!("Name:"));
ui.text_edit_singleline(name);
ui.end_row();
ui.label("Font:");
ui.label(tl!("Font:"));
ui.text_edit_singleline(font);
ui.end_row();
ui.label("Size:");
ui.label(tl!("Size:"));
ui.add(egui::DragValue::new(size).range(1.0..=1638.0).speed(0.5));
ui.end_row();
ui.label("Color:");
ui.label(tl!("Color:"));
ui.text_edit_singleline(color);
ui.end_row();
ui.label("Space before/after:");
ui.label(tl!("Space before/after:"));
ui.horizontal(|ui| {
ui.add(egui::DragValue::new(before).range(0.0..=1584.0));
ui.add(egui::DragValue::new(after).range(0.0..=1584.0));
@@ -661,10 +664,10 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
ui.end_row();
});
ui.horizontal(|ui| {
ui.checkbox(bold, "Bold");
ui.checkbox(italic, "Italic");
ui.checkbox(bold, tl!("Bold"));
ui.checkbox(italic, tl!("Italic"));
});
let (ok, cancel) = buttons(ui, "OK");
let (ok, cancel) = buttons(ui, tl!("OK"));
if ok {
let mut chr = json!({"font": font, "size": *size, "bold": *bold, "italic": *italic});
if let Some(c) = wordcraft_doc::Rgb::parse(color) {
@@ -676,7 +679,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
ok || cancel
}
Dialog::Commands { query } => {
let r = ui.add(egui::TextEdit::singleline(query).hint_text("Type a command, e.g. \"insert table\"").desired_width(380.0));
let r = ui.add(egui::TextEdit::singleline(query).hint_text(tl!("Type a command, e.g. \"insert table\"")).desired_width(380.0));
r.request_focus();
let q = query.to_lowercase();
let reg = app.session.registry.clone();
@@ -685,14 +688,18 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
.iter()
.filter(|c| {
!q.is_empty()
&& (c.label.to_lowercase().contains(&q) || c.id.to_lowercase().contains(&q) || c.location.to_lowercase().contains(&q))
&& (c.label.to_lowercase().contains(&q)
|| tl!(c.label).to_lowercase().contains(&q)
|| c.id.to_lowercase().contains(&q)
|| c.location.to_lowercase().contains(&q)
|| crate::i18n::location(c.location).to_lowercase().contains(&q))
})
.collect();
hits.truncate(14);
let mut close = false;
for c in hits {
let sc = crate::widgets::shortcut_text(app, c.id);
let label = format!("{} — {}", c.label, c.location);
let label = format!("{} — {}", tl!(c.label), crate::i18n::location(c.location));
if ui.add(egui::Button::new(label).shortcut_text(sc).min_size(vec2(380.0, 0.0))).clicked() {
let _ = app.run(c.id, json!({}));
close = true;
@@ -705,7 +712,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
ui.horizontal(|ui| {
for (i, l) in ["About", "Contributors", "Models"].into_iter().enumerate() {
let i = i as u8;
if ui.selectable_label(*tab == i, l).clicked() {
if ui.selectable_label(*tab == i, tl!(l)).clicked() {
*tab = i;
}
}
@@ -715,24 +722,25 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
1 => crate::credits::contributors_ui(ui),
2 => crate::credits::models_ui(ui),
_ => {
ui.label(egui::RichText::new("WordCraft").font(semibold(22.0)));
ui.label(format!(
"Version {} ({})",
env!("CARGO_PKG_VERSION"),
option_env!("WORDCRAFT_BUILD_SHA").map(|s| s.get(..8).unwrap_or(s)).unwrap_or("development build")
ui.label(egui::RichText::new(tl!("WordCraft")).font(semibold(22.0)));
let build = option_env!("WORDCRAFT_BUILD_SHA").map(|s| s.get(..8).unwrap_or(s)).unwrap_or(tl!("development build"));
ui.label(crate::i18n::fmt(tl!("Version {version} ({build})"), &[("version", env!("CARGO_PKG_VERSION")), ("build", build)]));
ui.label(tl!(
"A free, open-source word processor written from scratch in Rust.\nPart of the Crafting Apps from the ArtCraft team."
));
ui.label("A free, open-source word processor written from scratch in Rust.\nPart of the Crafting Apps from the ArtCraft team.");
ui.add_space(6.0);
ui.hyperlink_to("getartcraft.com/apps/wordcraft", "https://getartcraft.com/apps/wordcraft");
ui.hyperlink_to("Join us on Discord: discord.gg/artcraft", "https://discord.gg/artcraft");
ui.hyperlink_to("Source code: github.com/storytold/wordcraft", "https://github.com/storytold/wordcraft");
ui.hyperlink_to(tl!("getartcraft.com/apps/wordcraft"), "https://getartcraft.com/apps/wordcraft");
ui.hyperlink_to(tl!("Join us on Discord: discord.gg/artcraft"), "https://discord.gg/artcraft");
ui.hyperlink_to(tl!("Source code: github.com/storytold/wordcraft"), "https://github.com/storytold/wordcraft");
ui.add_space(6.0);
ui.label(
egui::RichText::new("MIT OR Apache-2.0. Copyright (c) 2026 ArtCraft Team and the WordCraft contributors.").small().weak(),
egui::RichText::new(tl!("MIT OR Apache-2.0. Copyright (c) 2026 ArtCraft Team and the WordCraft contributors."))
.small()
.weak(),
);
}
}
let (ok, cancel) = buttons(ui, "Close");
let (ok, cancel) = buttons(ui, tl!("Close"));
ok || cancel
}
}
+119
View File
@@ -0,0 +1,119 @@
//! Parser and lookup table for translation catalogs (`*.tsv`, format documented in `zh-hans.tsv`).
//! The format is PdfCraft's; WordCraft uses its plain (context-free) entries.
//!
//! Validation is strict (unknown escapes, unknown `@` contexts, placeholder, ellipsis and plural
//! form mismatches, duplicates), but a bad line is only skipped and reported: the rest of the
//! catalog still loads and the skipped string shows in English. The tests insist the bundled
//! catalogs have no errors at all.
use std::collections::{HashMap, HashSet};
/// A catalog entry as read from the file.
#[derive(Debug, PartialEq, Eq)]
pub struct Entry {
pub context: String,
pub source: String,
pub translation: String,
}
/// A parsed catalog. Values are owned for the life of the process (catalogs are built once).
#[derive(Debug, Default)]
pub struct Catalog {
/// English source → translation (the hot path, looked up every frame).
plain: HashMap<String, String>,
}
fn unescape(text: &str) -> Result<String, String> {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('n') => out.push('\n'),
Some('t') => out.push('\t'),
Some('\\') => out.push('\\'),
Some(other) => return Err(format!("unknown escape \\{other}; use \\n, \\t or \\\\")),
None => return Err("trailing backslash; use \\\\ for a literal backslash".into()),
}
}
Ok(out)
}
/// `{name}` placeholders of a template, sorted (translators may reorder them).
pub fn placeholders(text: &str) -> Vec<&str> {
let mut names = Vec::new();
let mut rest = text;
while let Some((_, after_open)) = rest.split_once('{') {
let Some((name, after_close)) = after_open.split_once('}') else { break };
names.push(name);
rest = after_close;
}
names.sort_unstable();
names
}
/// Check one entry against its English source.
fn validate(entry: &Entry) -> Result<(), String> {
let Entry { context, source, translation } = entry;
if !context.is_empty() {
return Err(format!("contexts aren't used in WordCraft catalogs (found {context:?}); leave the first column empty"));
}
if placeholders(source) != placeholders(translation) {
return Err("placeholders differ from the English source".into());
}
if source.ends_with('…') != translation.ends_with('…') {
return Err("trailing ellipsis differs from the English source".into());
}
Ok(())
}
fn parse_line(line: &str) -> Result<Entry, String> {
let mut columns = line.split('\t');
let (Some(context), Some(source), Some(translation), None) = (columns.next(), columns.next(), columns.next(), columns.next()) else {
return Err("expected `context<TAB>source<TAB>translation`".into());
};
let entry = Entry { context: unescape(context)?, source: unescape(source)?, translation: unescape(translation)? };
if entry.source.is_empty() || entry.translation.is_empty() {
return Err("source and translation must be nonempty".into());
}
validate(&entry)?;
Ok(entry)
}
/// Read the entries of a catalog file. Malformed lines and duplicates (the first entry wins) are
/// returned as errors and skipped, so a bad translation never breaks the UI.
pub fn parse_entries(text: &str) -> (Vec<Entry>, Vec<String>) {
let mut entries = Vec::new();
let mut errors = Vec::new();
let mut seen = HashSet::new();
for (index, line) in text.lines().enumerate() {
if line.trim().is_empty() || line.starts_with('#') {
continue;
}
let line_no = index.saturating_add(1);
match parse_line(line) {
Ok(entry) if !seen.insert((entry.context.clone(), entry.source.clone())) => {
errors.push(format!("line {line_no}: duplicate key {:?} / {:?}", entry.context, entry.source));
}
Ok(entry) => entries.push(entry),
Err(error) => errors.push(format!("line {line_no}: {error}")),
}
}
(entries, errors)
}
impl Catalog {
/// Build a catalog from its valid entries; the errors of the skipped lines come back too.
pub fn parse(text: &str) -> (Catalog, Vec<String>) {
let (entries, errors) = parse_entries(text);
let plain = entries.into_iter().map(|e| (e.source, e.translation)).collect();
(Catalog { plain }, errors)
}
pub fn plain(&self, s: &str) -> Option<&str> {
self.plain.get(s).map(String::as_str)
}
}
+773
View File
@@ -0,0 +1,773 @@
# Japanese (ja) interface catalog. Same format and clean-room rules as zh-hans.tsv.
(no suggestions) (候補なし)
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 でゼロから書かれた、無料のオープンソース ワープロです。\nArtCraft チームの Crafting Apps のひとつです。
About 情報
About WordCraft WordCraft について
Accept 承諾
Accept All Changes すべての変更を承諾
Accept This Change この変更を承諾
Accessibility アクセシビリティ
Add 追加
Add a comment コメントを追加
Add a Table 表を追加
Add Space Before Paragraph 段落前に間隔を追加
Add Text テキストの追加
Add to Dictionary 辞書に追加
Add to Style Gallery スタイル ギャラリーに追加
Address Block 住所ブロック
Address: アドレス:
Address\nBlock 住所ブロック
Adjust 調整
Advanced 詳細
After: 段落後:
Agents エージェント
Align 配置
Align Left 左揃え
Align Right 右揃え
Alignment 配置
All Borders 格子
All Caps すべて大文字
All caps すべて大文字
All Markup すべての変更履歴
Alt Text 代替テキスト
Apply Character Style 文字スタイルを適用
Apply Style スタイルを適用
Arrange 配置
Arrange\nAll 整列
Arrow 矢印
Artistic Effects アート効果
Author 作成者
AutoCorrect Options オートコレクトのオプション
AutoFit 自動調整
AutoFit Contents 文字列の幅に合わせる
AutoFit Window ウィンドウ幅に合わせる
Automatic 自動
Automatic ({language}) 自動({language})
Automatic follows your system's language. Menus and commands change; your documents don't. 「自動」はシステムの言語に従います。メニューとコマンドの表示が変わり、文書の内容は変わりません。
Automatic Table 1 (3 levels) 自動目次 1(3 レベル)
Automatic Table 2 (2 levels) 自動目次 2(2 レベル)
AutoSave 自動保存
AutoSave documents that have been saved 保存済みの文書を自動保存する
AutoSave saves every change to the file (needs a saved document) 自動保存は変更のたびにファイルへ保存します(保存済みの文書が必要です)
AutoText 定型句
Backspace Backspace
Banded Columns 縞模様(列)
Banded Rows 縞模様(行)
Before: 段落前:
Bibliography 参考文献
Binary assets added 追加したバイナリ アセット
Binary assets removed 削除したバイナリ アセット
Blank 空白
Blank (Three Columns) 空白(3 列)
Blank document 白紙の文書
Blank Page 空白のページ
Block\nAuthors 作成者のブロック
Bold 太字
Bookmark ブックマーク
Bookmark name: ブックマーク名:
Border Painter 罫線の書式設定
Border\nPainter 罫線の書式設定
Borders 罫線
Bottom Border 下罫線
Bottom of Page ページの下部
Bottom: 下:
Box 囲み
Breaks 区切り
Bring Forward 前面へ移動
Bullet Library 行頭文字ライブラリ
Bullets 箇条書き
Calendar カレンダー
Cancel キャンセル
Capitalize Each Word 各単語の先頭を大文字にする
Captions 図表番号
Cell Margins セルの余白
Cell Size セルのサイズ
Cell\nMargins セルの余白
Center 中央揃え
Change 変更
Change Case 大文字/小文字の変換
Change List Level リスト レベルの変更
Change Picture 図の変更
Change Shape 図形の変更
Changes 変更箇所
Character Position 文字位置
Character Scale 文字の拡大/縮小
Character Shading 文字の網かけ
Character Spacing 文字間隔
Characters (no spaces) 文字数(スペースを含めない)
Characters (with spaces) 文字数(スペースを含める)
Chart グラフ
Check Accessibility アクセシビリティ チェック
Check Compatibility 互換性チェック
Check for Errors エラーのチェック
Check Spelling as You Type 入力時にスペル チェックを行う
Check\nAccessibility アクセシビリティ チェック
Citation Style 引用文献のスタイル
Citations & Bibliography 引用文献と文献目録
Clear クリア
Clear All Formatting すべての書式をクリア
Clipboard クリップボード
Close 閉じる
Close Header and Footer ヘッダーとフッターを閉じる
Collapse Selection 選択範囲を縮小
Color 色
Color: 色:
Colors 配色
Column 段
Column Break 段区切り
Columns 段組み
Combine 組み込み
Commands コマンド
Comment コメント
Comments コメント
Comments Pane コメント ウィンドウ
Community\nDiscord コミュニティ\nDiscord
Compact コンパクト
Compare 比較
Compress Pictures 図の圧縮
Continuous 現在の位置から開始
Convert 変換
Convert Text to Table 文字列を表にする
Convert Text to Table… 文字列を表にする…
Convert to Text 表の解除
Copy コピー
Copy Formatting 書式のコピー
Corrections 修整
Cover Page 表紙
Create 作成
Create a Style スタイルの作成
Create an interactive outline of your document.\n\nIt's a great way to keep track of where you are or quickly move your content around.\n\nTo get started, go to the Home tab and apply Heading styles to the headings in your document. 文書の対話型アウトラインを作成します。\n\n今どこを編集しているかを把握したり、内容をすばやく移動したりするのに便利です。\n\nまず [ホーム] タブで、文書の見出しに見出しスタイルを適用してください。
Create New Style 新しいスタイルの作成
Crop トリミング
Cross-reference 相互参照
Current Position 現在の位置
Current style: {style} 現在のスタイル: {style}
Custom Margins… ユーザー設定の余白…
Custom Watermark ユーザー設定の透かし
Custom Watermark… ユーザー設定の透かし…
Customize Quick Access Toolbar クイック アクセス ツール バーのユーザー設定
Cut 切り取り
Dark Mode ダーク モード
Dark mode ダーク モード
Dashed 破線
Data データ
Date & Time 日付と時刻
Decrease Font Size フォント サイズの縮小
Decrease Indent インデントを減らす
Default 既定
Define New Bullet 新しい行頭文字の定義
Define New Number Format 新しい番号書式の定義
Delete 削除
Delete Cells セルの削除
Delete Columns 列の削除
Delete Comment コメントの削除
Delete Next Word 次の単語を削除
Delete Previous Word 前の単語を削除
Delete Rows 行の削除
Delete Style スタイルの削除
Delete Table 表の削除
Design デザイン
development build 開発ビルド
Diagonal 対角線
Diamond ひし形
Dictate ディクテーション
Different First Page 先頭ページのみ別指定
Different Odd & Even Pages 奇数/偶数ページ別指定
Discord Discord
Discord community Discord コミュニティ
Display 表示
Display for Review 変更内容の表示
Distribute Columns 列の幅を揃える
Distribute Rows 行の高さを揃える
Distributed 均等割り付け
Do Not Show in TOC 目次に表示しない
Document End 文書の末尾
Document Formatting ドキュメントの書式設定
Document Property 文書のプロパティ
Document Start 文書の先頭
Document Text 文書のテキスト
Document Title 文書のタイトル
Documents you open will show up here. 開いた文書がここに表示されます。
Dot-dash 一点鎖線
Dotted 点線
Double 二重線
Double Spacing 2 行
Double Strikethrough 二重取り消し線
Double Underline 二重下線
Down 下
Draft 下書き
Draw 描画
Drawing Tools 描画ツール
Drawing\nCanvas 描画キャンバス
Drop Cap ドロップ キャップ
Edit Footer フッターの編集
Edit Header ヘッダーの編集
Edit Recipient List アドレス帳の編集
Edit\nRecipient List アドレス帳の編集
Edited 編集済み
Editing 編集
Editing header/footer ヘッダー/フッターを編集中
Editing — edit the document directly 編集 — 文書を直接編集します
Editor エディター
Effects 文字飾り
Emboss 浮き出し
Encrypt with Password パスワードを使用して暗号化
English (United States) 英語(米国)
Engrave 浮き彫り
Enter page number: ページ番号を入力:
Envelopes 封筒
Equation 数式
Eraser 消しゴム
Even Page 偶数ページから開始
Every command is available to scripts and AI agents: run `wordcraft-cli mcp` for an MCP server, or start the app with `--control <port>` for the JSON control channel. すべてのコマンドはスクリプトと AI エージェントから利用できます。`wordcraft-cli mcp` で MCP サーバーを起動するか、`--control <port>` を付けてアプリを起動すると JSON 制御チャネルが使えます。
Export エクスポート
Export a copy to share (PDF, Word document) 共有用にコピーをエクスポート(PDF、Word 文書)
Export Page as PNG ページを PNG としてエクスポート
Export PDF PDF にエクスポート
Exported {path} {path} にエクスポートしました
Extend Selection 選択範囲の拡張
Field フィールド
File ファイル
Find 検索
Find and Replace 検索と置換
Find Next 次を検索
Find Previous 前を検索
Find Recipient 宛先の検索
Find what: 検索する文字列:
Finish 完了
Finish & Merge 完了と差し込み
Finish &\nMerge 完了と差し込み
First Column 最初の列
First commit 最初のコミット
First line: 最初の行:
Fixed Column Width 列の幅を固定する
Focus フォーカス
Font フォント
Font Color フォントの色
Font color: フォントの色:
Font Dialog フォント ダイアログ
Font Size フォント サイズ
Font: フォント:
Fonts フォント
Footer フッター
Footnote and Endnote 脚注と文末脚注
Footnotes 脚注
Format 書式
Format Page Numbers ページ番号の書式設定
Format Page Numbers… ページ番号の書式設定…
Format Painter 書式のコピー/貼り付け
Formatting at Selection 選択範囲の書式
Formula 計算式
General 全般
Go To ジャンプ
Go to 移動
Go To (⌘⌥G) ジャンプ (⌘⌥G)
Grab bag ランダム表示
Greeting Line あいさつ文
Greeting\nLine あいさつ文
Gridlines グリッド線
Group グループ化
Grow Font 1 Point フォントを 1 ポイント拡大
Hanging Indent ぶら下げインデント
Header ヘッダー
Header & Footer ヘッダーとフッター
Header Row タイトル行
Header/Footer Position ヘッダー/フッターの位置
Heading 1 見出し 1
Heading 2 見出し 2
Heading 3 見出し 3
Headings 見出し
Heart ハート
Help ヘルプ
Hidden 隠し文字
Highlight Merge Fields 差し込みフィールドの強調表示
Highlight\nMerge Fields 差し込みフィールドの強調表示
Highlighter 蛍光ペン
Home ホーム
Horizontal 横
Horizontal Line 横線
Hyphenation ハイフネーション
Icons アイコン
Ignore All すべて無視
Illustrations 図
Immersive イマーシブ
Immersive Reader イマーシブ リーダー
Immersive\nReader イマーシブ リーダー
Increase Font Size フォント サイズの拡大
Increase Indent インデントを増やす
Indent インデント
Indentation インデント
Indents インデント
Index 索引
Info 情報
Ink to\nMath インクを数式に変換
Ink to\nShape インクを図形に変換
Ink\nReplay インクの再生
Insert 挿入
Insert Above 上に行を挿入
Insert Below 下に行を挿入
Insert Caption 図表番号の挿入
Insert Citation 引用文献の挿入
Insert Endnote 文末脚注の挿入
Insert Footnote 脚注の挿入
Insert Hyperlink ハイパーリンクの挿入
Insert Index 索引の挿入
Insert Left 左に列を挿入
Insert Merge Field 差し込みフィールドの挿入
Insert Merge\nField 差し込みフィールドの挿入
Insert Right 右に列を挿入
Insert Shapes 図形の挿入
Insert Table 表の挿入
Insert Table of Authorities 引用文献一覧の挿入
Insert Table of Figures 図表目次の挿入
Insert Table… 表の挿入…
Insert\nAbove 上に行を挿入
Insert\nCaption 図表番号の挿入
Insert\nCitation 引用文献の挿入
Insert\nFootnote 脚注の挿入
Inside Borders 内側の罫線
Inspect Document ドキュメント検査
Interface language: 表示言語:
Italic 斜体
Join the ArtCraft community on Discord Discord の ArtCraft コミュニティに参加
Join us on Discord: discord.gg/artcraft Discord で参加: discord.gg/artcraft
Justify 両端揃え
Keep Lines Together 段落を分割しない
Keep lines together 段落を分割しない
Keep Text Only テキストのみ保持
Keep with Next 次の段落と分離しない
Keep with next 次の段落と分離しない
Labels ラベル
Landscape 横
Language 言語
Lasso なげなわ
Last Column 最後の列
Last commit 最後のコミット
Layout レイアウト
Layout Summary レイアウトの概要
Left 左
Left Border 左罫線
Left: 左:
Letter レター
Line 直線
Line and Page Breaks 改ページと改行
Line and Paragraph Spacing 行と段落の間隔
Line Break 改行
Line delta 行数の増減
Line End 行末
Line Numbers 行番号
Line Spacing 行間
Line Spacing Options… 行間のオプション…
Line spacing: 行間:
Line Start 行頭
Lines 行数
Lines added 追加行数
Lines deleted 削除行数
Link リンク
Link to Previous 前と同じヘッダー/フッター
Links リンク
Location: {path} 場所: {path}
lowercase 小文字
Macros マクロ
Mailings 差し込み文書
Manage Sources 資料文献の管理
Many pages 複数ページ
Margins 余白
Mark Citation 引用文のマーク
Mark Entry 索引登録
Mark\nCitation 引用文のマーク
Mark\nEntry 索引登録
Markdown (*.md) Markdown (*.md)
Match case 大文字と小文字を区別する
Match Fields フィールドの対応
Matrix 行列
Merge 結合
Merge Cells セルの結合
Merge Formatting 書式を結合
Merged PRs マージ済みの PR
Mirrored 見開きページ
MIT OR Apache-2.0. Copyright (c) 2026 ArtCraft Team and the WordCraft contributors. MIT OR Apache-2.0。Copyright (c) 2026 ArtCraft チームおよび WordCraft の貢献者。
Moderate やや狭い
Modify Style スタイルの変更
Modify… 変更…
Multilevel List アウトライン
Multiple Pages 複数ページ
Name (A–Z) 名前(A–Z)
Name: 名前:
Narrow 0.5" all 狭い 上下左右 0.5"
Navigation ナビゲーション
Navigation Pane ナビゲーション ウィンドウ
New 新規
New Comment 新しいコメント
New from Template テンプレートから新規作成
New Paragraph 新しい段落
New Style… 新しいスタイル…
New Window 新しいウィンドウ
New\nComment 新しいコメント
New\nWindow 新しいウィンドウ
Next 次へ
Next Change 次の変更箇所
Next Comment 次のコメント
Next Footnote 次の脚注
Next Page 次のページから開始
Next Record 次のレコード
Next Section 次へ
No Border 枠なし
No Color 色なし
No contributor data was built into this copy. このビルドには貢献者のデータが含まれていません。
No Markup 変更履歴/コメントなし
No model credits were built into this copy. このビルドにはモデルのクレジットが含まれていません。
No Paragraph Space 段落間隔なし
Nonbreaking Hyphen 改行なしハイフン
Nonbreaking Space 改行なしスペース
None なし
Normal 1" all 標準 上下左右 1"
Normal Style 標準スタイル
Not available right now 現在は使用できません
Not saved 未保存
Not saved yet まだ保存されていません
Number of columns: 列数:
Number of rows: 行数:
Numbering 段落番号
Object オブジェクト
Odd Page 奇数ページから開始
Office 2003 Default Office 2003 の既定値
OK OK
One 1 段
One Page 1 ページ
Open 開く
OpenDocument Text (*.odt) OpenDocument テキスト (*.odt)
Optional Hyphen 任意指定のハイフン
Options オプション
Orientation 印刷の向き
Outline アウトライン
Outline Level アウトライン レベル
Outside Borders 外枠
Oval 楕円
Page ページ
Page Background ページの背景
Page Borders ページ罫線
Page Break 改ページ
Page Break Before 段落前で改ページする
Page break before 段落前で改ページする
Page Breaks 改ページ
Page Color ページの色
Page Down 下へスクロール
Page image (*.png) ページ画像 (*.png)
Page Movement ページの移動
Page Number ページ番号
Page Setup ページ設定
Page Up 上へスクロール
Page Width ページ幅を基準に表示
Page width ページ幅
Page X of Y X / Y ページ
Page {page} {page} ページ
Page {page} of {pages} {page} / {pages} ページ
Page\nBorders ページ罫線
Page\nColor ページの色
Page\nNumber ページ番号
Pages ページ
Paragraph 段落
Paragraph Details 段落の詳細
Paragraph Down 次の段落へ
Paragraph Settings 段落の設定
Paragraph Spacing 段落の間隔
Paragraph Up 前の段落へ
Paragraphs 段落数
Paste 貼り付け
Paste (⌘V) 貼り付け (⌘V)
Paste Formatting 書式の貼り付け
Paste: Keep Text Only 貼り付け: テキストのみ保持
Paste: Merge Formatting 貼り付け: 書式を結合
PDF document (*.pdf) PDF 文書 (*.pdf)
Pen ペン
Pencil 鉛筆
Picture Border 図の枠線
Picture Format 図の形式
Picture Styles 図のスタイル
Pictures 画像
Plain text (*.txt) テキスト (*.txt)
Portrait 縦
Position 位置
Preview Results 結果のプレビュー
Preview\nResults 結果のプレビュー
Previous 前へ
Previous Change 前の変更箇所
Previous Comment 前のコメント
Previous Record 前のレコード
Previous Section 前へ
Print 印刷
Print Layout 印刷レイアウト
Print\nLayout 印刷レイアウト
Proofing 文章校正
Proofing Issues 文章校正の問題
Properties プロパティ
Protect 保護
Protect Document 文書の保護
Quick Access Toolbar クイック アクセス ツール バー
Quick Parts クイック パーツ
Quick Tables クイック表作成
Read Aloud 音声読み上げ
Read Mode 閲覧モード
Read\nAloud 音声読み上げ
Read\nMode 閲覧モード
Recent 最近使ったファイル
Record Macro マクロの記録
Recover Unsaved Documents 保存されていない文書の回復
Rectangle 四角形
Redo やり直し
Reduce Hanging Indent ぶら下げインデントを減らす
References 参考資料
Reject 元に戻す
Reject All Changes すべての変更を元に戻す
Reject Change 変更を元に戻す
Relaxed ゆったり
Remove Background 背景の削除
Remove Footer フッターの削除
Remove Header ヘッダーの削除
Remove Hyperlink ハイパーリンクの削除
Remove Space After Paragraph 段落後の間隔を削除
Remove Table of Contents 目次の削除
Remove Watermark 透かしの削除
Repeat 繰り返し
Repeat Header Rows タイトル行の繰り返し
Replace 置換
Replace All すべて置換
Replace Document Text 文書のテキストを置換
Replace with: 置換後の文字列:
Replay 再生
Reply 返信
Report レポート
Research 調査
Researcher リサーチツール
Reset Picture 図のリセット
Resolve 解決
Resolved 解決済み
Restart at 1 1 から振り直す
Restart Each Page ページごとに振り直し
Restart Each Section セクションごとに振り直し
Restrict Editing 編集の制限
Restrict\nEditing 編集の制限
Results 結果
Reverse the order 順序を逆にする
Review 校閲
Reviewing 校閲
Reviewing Pane [変更履歴] ウィンドウ
Reviewing — edits become suggestions 校閲 — 編集内容は提案として記録されます
Rich Text Format (*.rtf) リッチ テキスト形式 (*.rtf)
Right 右
Right Border 右罫線
Right-to-Left Text Direction 右から左への文字方向
Right: 右:
Rotate 回転
Rounded Rectangle 角丸四角形
Rows & Columns 行と列
Ruler ルーラー
Rules ルール
Résumé 履歴書
Save 保存
Save a copy in another format. Printing goes through a PDF you can print from any viewer. コピーを別の形式で保存します。印刷は PDF を経由し、任意のビューアーから印刷できます。
Save As 名前を付けて保存
Save as Template テンプレートとして保存
Saved 保存済み
Search Commands コマンドの検索
Search commands and help コマンドとヘルプを検索
Search document 文書内を検索
Section Breaks セクション区切り
Section Properties セクションのプロパティ
Sections セクション数
Select 選択
Select All すべて選択
Select Cell セルの選択
Select Line 行の選択
Select Objects オブジェクトの選択
Select Paragraph 段落の選択
Select Range 範囲の選択
Select Recipients 宛先の選択
Select Row 行の選択
Select Sentence 文の選択
Select Table 表の選択
Select Text テキストの選択
Select Text with Similar Formatting 同じ書式のテキストを選択
Select Word 単語の選択
Select\nRecipients 宛先の選択
Selection 選択範囲
Selection Pane オブジェクトの選択と表示
Send Backward 背面へ移動
Sentence case. 文の先頭文字を大文字にする。
Set as Default 既定に設定
Set Caret カーソル位置の設定
Set Character Formatting 文字書式の設定
Set Numbering Value 番号の設定
Set Paragraph Formatting 段落書式の設定
Shading 網かけ
Shadow 影
Shape Fill 図形の塗りつぶし
Shape Format 図形の書式
Shape Outline 図形の枠線
Shape Styles 図形のスタイル
Shapes 図形
Share 共有
Shift+Tab Shift+Tab
Show 表示
Show all formatting marks すべての編集記号を表示する
Show Comments コメントの表示
Show Markup 変更履歴とコメントの表示
Show Notes 注の表示
Show rulers ルーラーを表示する
Show the community button in the title bar タイトル バーにコミュニティ ボタンを表示する
Show/Hide ¶ 編集記号の表示/非表示 ¶
Show\nComments コメントの表示
Shrink Font 1 Point フォントを 1 ポイント縮小
Side to Side 左右に並べる
Side\nto Side 左右に並べる
Signature Line 署名欄
Single 一重線
Single Spacing 1 行
Size サイズ
Size: サイズ:
Small Caps 小型英大文字
Small caps 小型英大文字
SmartArt SmartArt
Sort 並べ替え
Source code: github.com/storytold/wordcraft ソース コード: github.com/storytold/wordcraft
Space before/after: 段落前/後の間隔:
Spacing 間隔
Spacing (pt): 間隔 (pt):
Speech 音声
Spelling & Grammar スペル チェックと文章校正
Spelling &\nGrammar スペル チェックと文章校正
Spelling Suggestions スペルの候補
Split 分割
Split Cells セルの分割
Split Table 表の分割
Spreadsheet Table スプレッドシート
Standard Colors 標準の色
Star 星
Start Mail Merge 差し込み印刷の開始
Start Mail\nMerge 差し込み印刷の開始
Statistics 統計
Status Bar ステータス バー
Strikethrough 取り消し線
Studio cover スタジオ表紙
Studio handbook (sample) スタジオ ハンドブック(サンプル)
Style スタイル
Style based on: 基準にするスタイル:
Style Set スタイル セット
Style: APA スタイル: APA
Styles スタイル
Styles Pane スタイル ウィンドウ
Subscript 下付き
Superscript 上付き
Switch Modes モードの切り替え
Switch\nModes モードの切り替え
Symbol 記号と特殊文字
Symbols 記号と特殊文字
Synonyms 類義語
Tab タブ
Table 表
Table Column Width 表の列の幅
Table Design テーブル デザイン
Table Layout 表のレイアウト
Table of Authorities 引用文献一覧
Table of Contents 目次
Table of\nContents 目次
Table Properties 表のプロパティ
Table Row Height 表の行の高さ
Table Style Options 表スタイルのオプション
Table Styles 表のスタイル
Tables 表
Tabs タブ
Tabular List 表形式の一覧
Text テキスト
Text Box テキスト ボックス
Text Direction 文字列の方向
Text Effects 文字の効果
Text Effects and Typography 文字の効果と体裁
Text from File ファイルからテキストを挿入
Text Highlight Color 蛍光ペンの色
Text to display: 表示文字列:
Text Wrapping 文字列の折り返し
Text: テキスト:
Text\nBox テキスト ボックス
Text\nDirection 文字列の方向
The new style takes the formatting of the current paragraph. 新しいスタイルには現在の段落の書式が使われます。
Theme Colors テーマの色
Theme Fonts テーマのフォント
Theme List テーマの一覧
Themes テーマ
Thesaurus 類義語辞典
Thick 太線
Three 3 段
Tight 狭い
Top Border 上罫線
Top of Page ページの上部
Top: 上:
Total Row 集計行
Track Changes 変更履歴の記録
Track Changes: On 変更履歴の記録: オン
Tracking 変更履歴
Translate 翻訳
Transparency 透明度
Triangle 三角形
Two 2 段
Type a command, e.g. "insert table" コマンドを入力(例: "insert table")
Type Text テキストを入力
Underline 下線
Underline Words Only 単語のみ下線
Undo 元に戻す
Undo {action} 元に戻す: {action}
Up 上
Update Field フィールドの更新
Update Index 索引の更新
Update Style to Match Selection 選択箇所と一致するようにスタイルを更新
Update Table 目次の更新
Update Table of Figures 図表目次の更新
Update to Match 一致するように更新
Update to Match Selection 選択箇所と一致するように更新
UPPERCASE 大文字
User Name ユーザー名
User name: ユーザー名:
Version History バージョン履歴
Version {version} ({build}) バージョン {version}({build})
Vertical 縦
Vertical Alignment 垂直方向の配置
View 表示
View Gridlines グリッド線の表示
View State 表示の状態
Views 表示
Voice 音声
Watermark 透かし
Wave 波線
Web Layout Web レイアウト
Web page (*.html) Web ページ (*.html)
Web\nLayout Web レイアウト
Whole page ページ全体
Whole words 単語単位で探す
Wide 広い
Widow/Orphan Control 改ページ時 1 行残して段落を区切らない
Widow/Orphan control 改ページ時 1 行残して段落を区切らない
Wildcards (regex) ワイルドカード(正規表現)
Window ウィンドウ
Word Count 文字カウント
Word document (*.docx) Word 文書 (*.docx)
Word Left 前の単語へ
Word Right 次の単語へ
WordArt ワードアート
WordCraft WordCraft
Words 単語数
Words only 単語のみ
Wrap Text 文字列の折り返し
Wrap\nText 文字列の折り返し
Write & Insert Fields 文章入力とフィールドの挿入
Zoom ズーム
Zoom In 拡大
Zoom Out 縮小
{group} settings {group} の設定
{key}+Click to follow link {key} キーを押しながらクリックしてリンク先を表示
➕ New comment ➕ 新しいコメント
📂 Browse… 📂 参照…
{words} words {words} 語
{selected} of {words} words {selected}/{words} 語
Can't render this file because it contains an unexpected character in line 399 and column 13.
+258
View File
@@ -0,0 +1,258 @@
//! Interface languages (#8). Strings in the code stay English and are the lookup keys; a catalog
//! per language (`*.tsv`, format in `zh-hans.tsv`) maps them to display text when the UI is drawn.
//! Command ids, document text, file names, the control channel, the CLI and MCP never see
//! translated text, so scripts and agents are unaffected. A string without a translation shows in
//! English, so coverage can grow one entry at a time.
//!
//! The `language` setting is `auto` by default: the UI follows the operating system's language
//! (Windows display language, macOS preferred languages, `LANG`/`LC_*` elsewhere) when there is a
//! catalog for it, and English otherwise. File ▸ Options ▸ Interface language picks one by hand.
//!
//! This is the system PdfCraft and PhotoCraft use (`pdfcraft/crates/ui-egui/src/i18n`), so the
//! catalog format and habits carry over between the Crafting Apps.
//!
//! # Adding a language
//! 1. Add `xx.tsv` next to `zh-hans.tsv` (copy its header; translate from the *meaning* of the
//! English text, clean-room: never from Microsoft Word's or another product's localisation).
//! 2. Add one row to [`LANGUAGES`].
//!
//! The Options dropdown, the system-language match and the catalog tests pick it up from there.
//!
//! # Looking strings up
//! - [`tl!`](crate::tl) / [`t`]: a string in the current language. [`tr`]: in a given one.
//! - [`location`]: a ribbon location such as `Home › Font`, segment by segment.
//! - [`fmt`]: fill `{name}` placeholders after a lookup; translators may reorder them.
mod catalog;
use std::cell::Cell;
use std::sync::OnceLock;
use catalog::Catalog;
/// The `language` setting that follows the system.
pub const AUTO: &str = "auto";
/// One supported interface language.
pub struct LangInfo {
/// BCP 47 code, lowercase (`ja`, `zh-hans`). Also the `language` setting's value.
pub code: &'static str,
/// The language's name in itself, shown in the Options dropdown.
pub name: &'static str,
/// Catalog file contents (empty for the built-in English).
pub source: &'static str,
/// Which CJK interface face comes first (Chinese text wants the Chinese face).
pub prefer_hans: bool,
catalog: OnceLock<Catalog>,
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 4] = [
LangInfo { code: "en", name: "English", source: "", prefer_hans: false, catalog: OnceLock::new() },
// Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` resolve here (see `candidates`).
LangInfo { code: "zh-hans", name: "简体中文", source: include_str!("zh-hans.tsv"), prefer_hans: true, catalog: OnceLock::new() },
// Traditional Chinese (Taiwan vocabulary); `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant-*` resolve here.
LangInfo { code: "zh-hant", name: "繁體中文", source: include_str!("zh-hant.tsv"), prefer_hans: true, catalog: OnceLock::new() },
LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), prefer_hans: false, catalog: OnceLock::new() },
];
impl LangInfo {
fn catalog(&self) -> &Catalog {
self.catalog.get_or_init(|| {
let (catalog, errors) = Catalog::parse(self.source);
for error in errors {
log::warn!("{} interface catalog: {error}; that entry shows in English", self.code);
}
catalog
})
}
}
/// A language the UI can be shown in (a handle into [`LANGUAGES`]).
#[derive(Clone, Copy)]
pub struct Lang(&'static LangInfo);
impl std::fmt::Debug for Lang {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Lang({})", self.0.code)
}
}
impl PartialEq for Lang {
fn eq(&self, other: &Self) -> bool {
self.0.code == other.0.code
}
}
impl Eq for Lang {}
impl Lang {
pub const EN: Lang = Lang(&LANGUAGES[0]);
pub fn code(self) -> &'static str {
self.0.code
}
pub fn name(self) -> &'static str {
self.0.name
}
pub fn prefers_hans(self) -> bool {
self.0.prefer_hans
}
/// A language by its exact code (any case).
pub fn from_code(code: &str) -> Option<Lang> {
LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code)).map(Lang)
}
/// Resolve the `language` setting: a language code, or [`AUTO`] (and anything unknown, e.g. a
/// code from a newer version) to follow the system.
pub fn from_pref(pref: &str) -> Lang {
Lang::from_code(pref).unwrap_or_else(system_lang)
}
/// Every registered language.
pub fn all() -> impl Iterator<Item = Lang> {
LANGUAGES.iter().map(Lang)
}
fn catalog(self) -> &'static Catalog {
self.0.catalog()
}
}
/// The canonical `language` setting for user input: [`AUTO`] or a registered code (any case).
/// `None` for anything else, so callers keep the current setting.
pub fn normalize_pref(pref: &str) -> Option<&'static str> {
if pref.eq_ignore_ascii_case(AUTO) {
return Some(AUTO);
}
Lang::from_code(pref).map(Lang::code)
}
/// Candidate codes for a locale tag, most specific first: `zh_TW.UTF-8` → `zh-tw`, `zh-hant`, `zh`.
fn candidates(tag: &str) -> Vec<String> {
let base = tag.split(['.', '@']).next().unwrap_or("").replace('_', "-").to_ascii_lowercase();
let parts: Vec<&str> = base.split('-').filter(|p| !p.is_empty()).collect();
let Some(&primary) = parts.first() else { return Vec::new() };
let mut out = Vec::new();
for n in (1..=parts.len()).rev() {
out.push(parts.get(..n).unwrap_or_default().join("-"));
}
if primary == "zh" && !parts.iter().any(|p| matches!(*p, "hans" | "hant")) {
// Chinese by region when no script is given.
let script = if parts.iter().any(|p| matches!(*p, "tw" | "hk" | "mo")) { "zh-hant" } else { "zh-hans" };
out.insert(out.len().saturating_sub(1), script.to_string());
}
out
}
/// The registered language for a locale tag such as `ja_JP.UTF-8` or `zh-Hant-TW`; `None` when
/// there is no catalog for it. `C`/`POSIX` mean English.
pub fn lang_from_tag(tag: &str) -> Option<Lang> {
let cands = candidates(tag);
if matches!(cands.first().map(String::as_str), Some("c" | "posix")) {
return Some(Lang::EN);
}
cands.iter().find_map(|c| Lang::from_code(c))
}
/// The first of the system's preferred languages WordCraft has, in the user's order: someone
/// who prefers French, then Japanese, gets Japanese rather than English.
pub fn first_supported<'a>(tags: impl IntoIterator<Item = &'a str>) -> Option<Lang> {
tags.into_iter().find_map(lang_from_tag)
}
/// The system language (looked up once). English when it can't be determined or isn't supported.
pub fn system_lang() -> Lang {
// Tests drive the UI by its English labels whatever the machine's language is.
if cfg!(test) {
return Lang::EN;
}
static SYSTEM: OnceLock<Lang> = OnceLock::new();
*SYSTEM.get_or_init(detect_system_lang)
}
fn detect_system_lang() -> Lang {
// An explicit override, then the platform's preferred UI languages (Windows display language,
// macOS preferred languages, `LC_ALL`/`LC_MESSAGES`/`LANG` on Unix, the browser on the web).
if let Some(l) = std::env::var("WORDCRAFT_LANGUAGE").ok().and_then(|v| lang_from_tag(&v)) {
return l;
}
let tags: Vec<String> = sys_locale::get_locales().take(16).collect();
first_supported(tags.iter().map(String::as_str)).unwrap_or(Lang::EN)
}
thread_local! {
// Per thread: parallel test harnesses must not change each other's language.
static CURRENT: Cell<Lang> = const { Cell::new(Lang::EN) };
}
/// Set the language the UI is drawn in (the app does this every frame from its setting).
pub fn set_current(lang: Lang) {
CURRENT.set(lang);
}
/// The language the UI is drawn in.
pub fn current() -> Lang {
CURRENT.get()
}
/// Does `lang` have a catalog entry for this string? (English never does: it is the source.)
pub fn has(lang: Lang, s: &str) -> bool {
lang.catalog().plain(s).is_some()
}
/// `s` in the current language ([`tr`] with [`current`]).
pub fn t(s: &str) -> &str {
tr(current(), s)
}
/// `s` in `lang`; strings without a translation come back unchanged.
pub fn tr(lang: Lang, s: &str) -> &str {
lang.catalog().plain(s).unwrap_or(s)
}
/// A ribbon location (`Home › Font`) in the current language, segment by segment.
pub fn location(loc: &str) -> String {
loc.split(" › ").map(t).collect::<Vec<_>>().join(" › ")
}
/// A label with an action after a fixed English prefix, e.g. `Undo Typing`: both parts translated
/// where the catalog has them.
pub fn prefixed(prefix: &str, rest: &str) -> String {
if rest.is_empty() {
return t(prefix).to_string();
}
fmt(t(&format!("{prefix} {{action}}")), &[("action", t(rest))])
}
/// Fill `{name}` placeholders in one pass. Unknown placeholders stay as written, and inserted
/// values are never read as templates again, so a file name containing `{n}` stays intact.
pub fn fmt(template: &str, args: &[(&str, &str)]) -> String {
let mut out = String::with_capacity(template.len());
let mut rest = template;
while let Some((before, after_open)) = rest.split_once('{') {
out.push_str(before);
let Some((name, after_close)) = after_open.split_once('}') else {
out.push('{');
out.push_str(after_open);
return out;
};
match args.iter().find(|(key, _)| *key == name) {
Some((_, value)) => out.push_str(value),
None => {
out.push('{');
out.push_str(name);
out.push('}');
}
}
rest = after_close;
}
out.push_str(rest);
out
}
#[cfg(test)]
mod tests;
+155
View File
@@ -0,0 +1,155 @@
//! Interface language tests (#8).
use super::catalog::{Catalog, parse_entries, placeholders};
use super::*;
fn lang(code: &str) -> Lang {
Lang::from_code(code).unwrap_or_else(|| panic!("{code} registered"))
}
#[test]
fn system_locales_map_to_languages() {
assert_eq!(lang_from_tag("zh_CN.UTF-8"), Some(lang("zh-hans")));
assert_eq!(lang_from_tag("zh-CN"), Some(lang("zh-hans")));
assert_eq!(lang_from_tag("zh"), Some(lang("zh-hans")));
assert_eq!(lang_from_tag("zh-SG"), Some(lang("zh-hans")));
assert_eq!(lang_from_tag("zh-Hans-HK"), Some(lang("zh-hans")), "a script beats the region");
assert_eq!(lang_from_tag("zh_TW.UTF-8"), Some(lang("zh-hant")));
assert_eq!(lang_from_tag("zh-HK"), Some(lang("zh-hant")));
assert_eq!(lang_from_tag("zh-Hant"), Some(lang("zh-hant")));
assert_eq!(lang_from_tag("ja-JP"), Some(lang("ja")));
assert_eq!(lang_from_tag("ja_JP.eucJP@euro"), Some(lang("ja")));
assert_eq!(lang_from_tag("en-GB"), Some(Lang::EN));
assert_eq!(lang_from_tag("C"), Some(Lang::EN));
assert_eq!(lang_from_tag("POSIX"), Some(Lang::EN));
assert_eq!(lang_from_tag("fr-FR"), None);
assert_eq!(lang_from_tag(""), None);
assert_eq!(lang_from_tag("_"), None);
}
#[test]
fn the_first_supported_preferred_language_wins() {
// Windows and macOS list several preferred languages in order.
assert_eq!(first_supported(["fr-FR", "zh-Hant-TW", "en-US"]), Some(lang("zh-hant")));
assert_eq!(first_supported(["en-US", "ja-JP"]), Some(Lang::EN));
assert_eq!(first_supported(["fr-FR", "de-DE"]), None);
assert_eq!(first_supported([]), None);
}
#[test]
fn settings_resolve_with_fallback() {
assert_eq!(Lang::from_pref("ja"), lang("ja"));
assert_eq!(Lang::from_pref("ZH-HANS"), lang("zh-hans"));
assert_eq!(Lang::from_pref("en"), Lang::EN);
// `auto` and unknown codes follow the system (English under test).
assert_eq!(Lang::from_pref(AUTO), Lang::EN);
assert_eq!(Lang::from_pref("xx-unknown"), Lang::EN);
assert_eq!(normalize_pref("Zh-Hant"), Some("zh-hant"));
assert_eq!(normalize_pref("Auto"), Some(AUTO));
assert_eq!(normalize_pref("zh"), None, "only exact codes are settings");
assert_eq!(normalize_pref("xx"), None);
}
#[test]
fn lookups_fall_back_to_english() {
let zh = lang("zh-hans");
assert_eq!(tr(zh, "File"), "文件");
assert_eq!(tr(lang("zh-hant"), "File"), "檔案");
assert_eq!(tr(lang("ja"), "File"), "ファイル");
assert_eq!(tr(zh, "no such label"), "no such label");
assert_eq!(tr(Lang::EN, "File"), "File");
assert!(has(zh, "Save") && !has(Lang::EN, "Save"));
}
#[test]
fn current_language_is_per_thread() {
set_current(lang("ja"));
assert_eq!(t("Insert"), "挿入");
assert_eq!(location("Home › Font"), "ホーム › フォント");
assert_eq!(prefixed("Undo", "Bold"), "元に戻す: 太字");
assert_eq!(prefixed("Undo", ""), "元に戻す");
std::thread::spawn(|| assert_eq!(current(), Lang::EN)).join().unwrap();
set_current(Lang::EN);
assert_eq!(t("Insert"), "Insert");
assert_eq!(prefixed("Undo", "Typing"), "Undo Typing");
}
#[test]
fn placeholders_fill_without_reinterpreting_values() {
let args = [("path", "a-{pages}.docx"), ("pages", "2")];
assert_eq!(fmt("{pages}: {path}; {unknown}", &args), "2: a-{pages}.docx; {unknown}");
assert_eq!(fmt("text {unfinished", &args), "text {unfinished");
assert_eq!(fmt(tr(lang("zh-hans"), "Page {page} of {pages}"), &[("page", "3"), ("pages", "9")]), "第 3 页,共 9 页");
}
#[test]
fn malformed_catalog_lines_are_skipped_not_fatal() {
let (c, errors) =
Catalog::parse("# c\n\tHello\t你好\nctx\tA\tB\n\tOpen…\t打开\n\tPage {n}\t第 {m} 页\nno tabs\n\tHello\t重复\n\tLine\\nTwo\t一\\n二\n");
assert_eq!(errors.len(), 5, "{errors:?}");
assert_eq!(c.plain("Hello"), Some("你好"), "the first entry wins");
assert_eq!(c.plain("Line\nTwo"), Some("一\n二"));
assert_eq!(c.plain("Open…"), None);
}
/// Every bundled catalog parses without errors, and its entries keep the English placeholders.
#[test]
fn bundled_catalogs_are_well_formed() {
let mut codes = std::collections::HashSet::new();
for l in &LANGUAGES {
assert!(codes.insert(l.code), "duplicate code {}", l.code);
assert_eq!(l.code, l.code.to_ascii_lowercase());
let (entries, errors) = parse_entries(l.source);
assert!(errors.is_empty(), "{}: {errors:?}", l.code);
for e in &entries {
assert_eq!(placeholders(&e.source), placeholders(&e.translation), "{}: {:?}", l.code, e.source);
assert_eq!(e.translation.trim(), e.translation, "{}: stray whitespace in {:?}", l.code, e.translation);
assert!(!e.translation.contains("..."), "{}: use … rather than three dots: {:?}", l.code, e.translation);
}
assert!(l.code == "en" || entries.len() > 700, "{} has {} entries", l.code, entries.len());
}
}
/// The ribbon tabs and every registered command's label and ribbon location are translated, in
/// every language, so menus, tooltips and command search never fall back to English.
#[test]
fn every_tab_and_command_is_translated() {
let session = wordcraft_engine::Session::new(wordcraft_doc::Document::new());
// Labels that read the same in every language.
let universal = |s: &str| !s.chars().any(char::is_alphabetic);
let mut missing = Vec::new();
for l in Lang::all().filter(|l| *l != Lang::EN) {
let tabs = crate::ribbon::TABS.iter().chain(&["Table Design", "Table Layout"]).copied();
let commands = session.registry.all().iter().flat_map(|spec| std::iter::once(spec.label).chain(spec.location.split(" › ")));
for s in tabs.chain(commands).filter(|s| !s.is_empty() && !universal(s)) {
if !has(l, s) {
missing.push(format!("{}: {s:?}", l.code()));
}
}
}
missing.sort();
missing.dedup();
assert!(missing.is_empty(), "untranslated: {missing:#?}");
}
#[test]
fn the_language_setting_is_a_ui_command_and_persists() {
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
assert_eq!(app.ui.language, AUTO);
let r = app.run("ui.language", serde_json::json!({"value": "ZH-HANT"})).unwrap();
assert_eq!(app.ui.language, "zh-hant");
assert_eq!(r["effective"], "zh-hant");
assert!(r["available"].as_array().is_some_and(|a| a.len() == LANGUAGES.len()));
assert!(app.run("ui.language", serde_json::json!({"value": "xx"})).is_err());
assert_eq!(app.ui.language, "zh-hant", "an unknown code keeps the setting");
let saved = serde_json::to_string(&app.ui).unwrap();
let restored: crate::UiState = serde_json::from_str(&saved).unwrap();
assert_eq!(restored.language, "zh-hant");
// Settings saved before this option existed follow the system.
let legacy: crate::UiState = serde_json::from_str(r#"{"tab":"Home"}"#).unwrap();
assert_eq!(legacy.language, AUTO);
app.run("ui.language", serde_json::json!({"value": "auto"})).unwrap();
assert_eq!(app.ui.language, AUTO);
// Reading without a value reports the current setting.
assert_eq!(app.run("ui.language", serde_json::json!({})).unwrap()["language"], AUTO);
}
+784
View File
@@ -0,0 +1,784 @@
# Simplified Chinese (zh-hans) interface catalog. Columns, separated by a TAB:
# context <TAB> English source <TAB> translation
# context empty (WordCraft uses no contexts yet)
# source the English text exactly as in the code (tl!(...), ribbon labels, command labels)
# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns, and a
# trailing "…" in one needs one in the other.
# Lines starting with # and blank lines are ignored. A missing entry shows in English.
#
# Clean room: every entry was written from the plain meaning of the English label in ordinary
# computing vocabulary. No Microsoft Word (or other product) localisation, string table or
# translated UI was consulted or copied; add entries the same way.
# Product, format and technology names (WordCraft, PDF, Markdown, MCP …) stay in Latin letters.
(no suggestions) (无建议)
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属于 ArtCraft 团队的 Crafting Apps 系列。
About 关于
About WordCraft 关于 WordCraft
Accept 接受
Accept All Changes 接受所有修订
Accept This Change 接受此修订
Accessibility 辅助功能
Add 添加
Add a comment 添加批注
Add a Table 添加表格
Add Space Before Paragraph 在段前添加间距
Add Text 添加文字
Add to Dictionary 添加到词典
Add to Style Gallery 添加到样式库
Address Block 地址块
Address: 地址:
Address\nBlock 地址块
Adjust 调整
Advanced 高级
After: 段后:
Agents 智能体
Align 对齐
Align Left 左对齐
Align Right 右对齐
Alignment 对齐方式
All Borders 所有框线
All Caps 全部大写
All caps 全部大写
All Markup 所有标记
Alt Text 替代文字
Apply Character Style 应用字符样式
Apply Style 应用样式
Arrange 排列
Arrange\nAll 全部重排
Arrow 箭头
Artistic Effects 艺术效果
Author 作者
AutoCorrect Options 自动更正选项
AutoFit 自动调整
AutoFit Contents 根据内容自动调整
AutoFit Window 根据窗口自动调整
Automatic 自动
Automatic ({language}) 自动({language})
Automatic follows your system's language. Menus and commands change; your documents don't. “自动”跟随系统语言。菜单和命令会改变,文档内容不会改变。
Automatic Table 1 (3 levels) 自动目录 1(3 级)
Automatic Table 2 (2 levels) 自动目录 2(2 级)
AutoSave 自动保存
AutoSave documents that have been saved 自动保存已保存过的文档
AutoSave saves every change to the file (needs a saved document) 自动保存会把每次更改写入文件(文档需已保存)
AutoText 自动图文集
Backspace 退格
Banded Columns 镶边列
Banded Rows 镶边行
Before: 段前:
Bibliography 参考文献
Binary assets added 新增二进制资源
Binary assets removed 删除二进制资源
Blank 空白
Blank (Three Columns) 空白(三栏)
Blank document 空白文档
Blank Page 空白页
Block\nAuthors 阻止作者
Bold 加粗
Bookmark 书签
Bookmark name: 书签名:
Border Painter 边框刷
Border\nPainter 边框刷
Borders 边框
Bottom Border 下框线
Bottom of Page 页面底端
Bottom: 下:
Box 方框
Breaks 分隔符
Bring Forward 上移一层
Bullet Library 项目符号库
Bullets 项目符号
Calendar 日历
Cancel 取消
Capitalize Each Word 每个单词首字母大写
Captions 题注
Cell Margins 单元格边距
Cell Size 单元格大小
Cell\nMargins 单元格边距
Center 居中
Change 更改
Change Case 更改大小写
Change List Level 更改列表级别
Change Picture 更改图片
Change Shape 更改形状
Changes 修订
Character Position 字符位置
Character Scale 字符缩放
Character Shading 字符底纹
Character Spacing 字符间距
Characters (no spaces) 字符数(不计空格)
Characters (with spaces) 字符数(计空格)
Chart 图表
Check Accessibility 检查辅助功能
Check Compatibility 检查兼容性
Check for Errors 检查错误
Check Spelling as You Type 键入时检查拼写
Check\nAccessibility 检查辅助功能
Citation Style 引文样式
Citations & Bibliography 引文与参考文献
Clear 清除
Clear All Formatting 清除所有格式
Clipboard 剪贴板
Close 关闭
Close Header and Footer 关闭页眉和页脚
Collapse Selection 折叠选定内容
Color 颜色
Color: 颜色:
Colors 颜色
Column 栏
Column Break 分栏符
Columns 栏
Combine 合并
Commands 命令
Comment 批注
Comments 批注
Comments Pane 批注窗格
Community\nDiscord 社区\nDiscord
Compact 紧凑
Compare 比较
Compress Pictures 压缩图片
Continuous 连续
Convert 转换
Convert Text to Table 文本转换成表格
Convert Text to Table… 文本转换成表格…
Convert to Text 转换为文本
Copy 复制
Copy Formatting 复制格式
Corrections 更正
Cover Page 封面
Create 创建
Create a Style 创建样式
Create an interactive outline of your document.\n\nIt's a great way to keep track of where you are or quickly move your content around.\n\nTo get started, go to the Home tab and apply Heading styles to the headings in your document. 为文档创建交互式大纲。\n\n用它可以随时知道自己在文档中的位置,或快速移动内容。\n\n要开始使用,请转到“开始”选项卡,为文档中的标题应用标题样式。
Create New Style 创建新样式
Crop 裁剪
Cross-reference 交叉引用
Current Position 当前位置
Current style: {style} 当前样式:{style}
Custom Margins… 自定义页边距…
Custom Watermark 自定义水印
Custom Watermark… 自定义水印…
Customize Quick Access Toolbar 自定义快速访问工具栏
Cut 剪切
Dark Mode 深色模式
Dark mode 深色模式
Dashed 虚线
Data 数据
Date & Time 日期和时间
Decrease Font Size 减小字号
Decrease Indent 减少缩进量
Default 默认
Define New Bullet 定义新项目符号
Define New Number Format 定义新编号格式
Delete 删除
Delete Cells 删除单元格
Delete Columns 删除列
Delete Comment 删除批注
Delete Next Word 删除后一个单词
Delete Previous Word 删除前一个单词
Delete Rows 删除行
Delete Style 删除样式
Delete Table 删除表格
Design 设计
development build 开发版本
Diagonal 斜式
Diamond 菱形
Dictate 听写
Different First Page 首页不同
Different Odd & Even Pages 奇偶页不同
Discord Discord
Discord community Discord 社区
Display 显示
Display for Review 显示以供审阅
Distribute Columns 平均分布各列
Distribute Rows 平均分布各行
Distributed 分散对齐
Do Not Show in TOC 不在目录中显示
Document End 文档结尾
Document Formatting 文档格式
Document Property 文档属性
Document Start 文档开头
Document Text 文档文本
Document Title 文档标题
Documents you open will show up here. 打开过的文档会显示在这里。
Dot-dash 点划线
Dotted 点线
Double 双线
Double Spacing 双倍行距
Double Strikethrough 双删除线
Double Underline 双下划线
Down 下
Draft 草稿
Draw 绘图
Drawing Tools 绘图工具
Drawing\nCanvas 绘图画布
Drop Cap 首字下沉
Edit Footer 编辑页脚
Edit Header 编辑页眉
Edit Recipient List 编辑收件人列表
Edit\nRecipient List 编辑收件人列表
Edited 已编辑
Editing 编辑
Editing header/footer 正在编辑页眉/页脚
Editing — edit the document directly 编辑 — 直接编辑文档
Editor 编辑器
Effects 效果
Emboss 阳文
Encrypt with Password 用密码进行加密
English (United States) 英语(美国)
Engrave 阴文
Enter page number: 输入页码:
Envelopes 信封
Equation 公式
Eraser 橡皮擦
Even Page 偶数页
Every command is available to scripts and AI agents: run `wordcraft-cli mcp` for an MCP server, or start the app with `--control <port>` for the JSON control channel. 每个命令都可供脚本和 AI 智能体使用:运行 `wordcraft-cli mcp` 启动 MCP 服务器,或使用 `--control <port>` 启动应用以打开 JSON 控制通道。
Export 导出
Export a copy to share (PDF, Word document) 导出副本以共享(PDF、Word 文档)
Export Page as PNG 将页面导出为 PNG
Export PDF 导出 PDF
Exported {path} 已导出 {path}
Extend Selection 扩展选定范围
Field 域
File 文件
Find 查找
Find and Replace 查找和替换
Find Next 查找下一处
Find Previous 查找上一处
Find Recipient 查找收件人
Find what: 查找内容:
Finish 完成
Finish & Merge 完成并合并
Finish &\nMerge 完成并合并
First Column 第一列
First commit 首次提交
First line: 首行:
Fixed Column Width 固定列宽
Focus 专注
Font 字体
Font Color 字体颜色
Font color: 字体颜色:
Font Dialog 字体对话框
Font Size 字号
Font: 字体:
Fonts 字体
Footer 页脚
Footnote and Endnote 脚注和尾注
Footnotes 脚注
Format 格式
Format Page Numbers 设置页码格式
Format Page Numbers… 设置页码格式…
Format Painter 格式刷
Formatting at Selection 选定内容的格式
Formula 公式
General 常规
Go To 定位
Go to 转到
Go To (⌘⌥G) 定位 (⌘⌥G)
Grab bag 随机展示
Greeting Line 问候语
Greeting\nLine 问候语
Gridlines 网格线
Group 组合
Grow Font 1 Point 字号增大 1 磅
Hanging Indent 悬挂缩进
Header 页眉
Header & Footer 页眉和页脚
Header Row 标题行
Header/Footer Position 页眉/页脚位置
Heading 1 标题 1
Heading 2 标题 2
Heading 3 标题 3
Headings 标题
Heart 心形
Help 帮助
Hidden 隐藏
Highlight Merge Fields 突出显示合并域
Highlight\nMerge Fields 突出显示合并域
Highlighter 荧光笔
Home 开始
Horizontal 水平
Horizontal Line 横线
Hyphenation 断字
Icons 图标
Ignore All 全部忽略
Illustrations 插图
Immersive 沉浸式
Immersive Reader 沉浸式阅读器
Immersive\nReader 沉浸式阅读器
Increase Font Size 增大字号
Increase Indent 增加缩进量
Indent 缩进
Indentation 缩进
Indents 缩进
Index 索引
Info 信息
Ink to\nMath 墨迹转公式
Ink to\nShape 墨迹转形状
Ink\nReplay 墨迹重播
Insert 插入
Insert Above 在上方插入
Insert Below 在下方插入
Insert Caption 插入题注
Insert Citation 插入引文
Insert Endnote 插入尾注
Insert Footnote 插入脚注
Insert Hyperlink 插入超链接
Insert Index 插入索引
Insert Left 在左侧插入
Insert Merge Field 插入合并域
Insert Merge\nField 插入合并域
Insert Right 在右侧插入
Insert Shapes 插入形状
Insert Table 插入表格
Insert Table of Authorities 插入引文目录
Insert Table of Figures 插入表目录
Insert Table… 插入表格…
Insert\nAbove 在上方插入
Insert\nCaption 插入题注
Insert\nCitation 插入引文
Insert\nFootnote 插入脚注
Inside Borders 内部框线
Inspect Document 检查文档
Interface language: 界面语言:
Italic 倾斜
Join the ArtCraft community on Discord 加入 Discord 上的 ArtCraft 社区
Join us on Discord: discord.gg/artcraft 在 Discord 上加入我们:discord.gg/artcraft
Justify 两端对齐
Keep Lines Together 段中不分页
Keep lines together 段中不分页
Keep Text Only 只保留文本
Keep with Next 与下段同页
Keep with next 与下段同页
Labels 标签
Landscape 横向
Language 语言
Lasso 套索
Last Column 最后一列
Last commit 最近提交
Layout 布局
Layout Summary 布局摘要
Left 左
Left Border 左框线
Left: 左:
Letter 信函
Line 直线
Line and Page Breaks 换行和分页
Line and Paragraph Spacing 行和段落间距
Line Break 换行符
Line delta 行数变化
Line End 行尾
Line Numbers 行号
Line Spacing 行距
Line Spacing Options… 行距选项…
Line spacing: 行距:
Line Start 行首
Lines 行数
Lines added 新增行数
Lines deleted 删除行数
Link 链接
Link to Previous 链接到前一节
Links 链接
Location: {path} 位置:{path}
lowercase 全部小写
Macros 宏
Mailings 邮件
Manage Sources 管理源
Many pages 多页
Margins 页边距
Mark Citation 标记引文
Mark Entry 标记条目
Mark\nCitation 标记引文
Mark\nEntry 标记条目
Markdown (*.md) Markdown (*.md)
Match case 区分大小写
Match Fields 匹配域
Matrix 矩阵
Merge 合并
Merge Cells 合并单元格
Merge Formatting 合并格式
Merged PRs 已合并的 PR
Mirrored 对称
MIT OR Apache-2.0. Copyright (c) 2026 ArtCraft Team and the WordCraft contributors. MIT OR Apache-2.0。版权所有 (c) 2026 ArtCraft 团队及 WordCraft 贡献者。
Moderate 适中
Modify Style 修改样式
Modify… 修改…
Multilevel List 多级列表
Multiple Pages 多页
Name (A–Z) 名称(A–Z)
Name: 名称:
Narrow 0.5" all 窄 四周 0.5"
Navigation 导航
Navigation Pane 导航窗格
New 新建
New Comment 新建批注
New from Template 从模板新建
New Paragraph 新段落
New Style… 新建样式…
New Window 新建窗口
New\nComment 新建批注
New\nWindow 新建窗口
Next 下一条
Next Change 下一处修订
Next Comment 下一条批注
Next Footnote 下一条脚注
Next Page 下一页
Next Record 下一记录
Next Section 下一节
No Border 无框线
No Color 无颜色
No contributor data was built into this copy. 此版本未内置贡献者数据。
No Markup 无标记
No model credits were built into this copy. 此版本未内置模型致谢信息。
No Paragraph Space 无段落间距
Nonbreaking Hyphen 不间断连字符
Nonbreaking Space 不间断空格
None 无
Normal 1" all 常规 四周 1"
Normal Style 正文样式
Not available right now 当前不可用
Not saved 未保存
Not saved yet 尚未保存
Number of columns: 列数:
Number of rows: 行数:
Numbering 编号
Object 对象
Odd Page 奇数页
Office 2003 Default Office 2003 默认设置
OK 确定
One 一栏
One Page 单页
Open 打开
OpenDocument Text (*.odt) OpenDocument 文本 (*.odt)
Optional Hyphen 可选连字符
Options 选项
Orientation 纸张方向
Outline 大纲
Outline Level 大纲级别
Outside Borders 外侧框线
Oval 椭圆
Page 页面
Page Background 页面背景
Page Borders 页面边框
Page Break 分页符
Page Break Before 段前分页
Page break before 段前分页
Page Breaks 分页符
Page Color 页面颜色
Page Down 下一屏
Page image (*.png) 页面图像 (*.png)
Page Movement 翻页方式
Page Number 页码
Page Setup 页面设置
Page Up 上一屏
Page Width 页宽
Page width 页宽
Page X of Y 第 X 页,共 Y 页
Page {page} 第 {page} 页
Page {page} of {pages} 第 {page} 页,共 {pages} 页
Page\nBorders 页面边框
Page\nColor 页面颜色
Page\nNumber 页码
Pages 页面
Paragraph 段落
Paragraph Details 段落详细信息
Paragraph Down 下移一段
Paragraph Settings 段落设置
Paragraph Spacing 段落间距
Paragraph Up 上移一段
Paragraphs 段落数
Paste 粘贴
Paste (⌘V) 粘贴 (⌘V)
Paste Formatting 粘贴格式
Paste: Keep Text Only 粘贴:只保留文本
Paste: Merge Formatting 粘贴:合并格式
PDF document (*.pdf) PDF 文档 (*.pdf)
Pen 笔
Pencil 铅笔
Picture Border 图片边框
Picture Format 图片格式
Picture Styles 图片样式
Pictures 图片
Plain text (*.txt) 纯文本 (*.txt)
Portrait 纵向
Position 位置
Preview Results 预览结果
Preview\nResults 预览结果
Previous 上一条
Previous Change 上一处修订
Previous Comment 上一条批注
Previous Record 上一记录
Previous Section 上一节
Print 打印
Print Layout 页面视图
Print\nLayout 页面视图
Proofing 校对
Proofing Issues 校对问题
Properties 属性
Protect 保护
Protect Document 保护文档
Quick Access Toolbar 快速访问工具栏
Quick Parts 文档部件
Quick Tables 快速表格
Read Aloud 朗读
Read Mode 阅读视图
Read\nAloud 朗读
Read\nMode 阅读视图
Recent 最近
Record Macro 录制宏
Recover Unsaved Documents 恢复未保存的文档
Rectangle 矩形
Redo 恢复
Reduce Hanging Indent 减少悬挂缩进
References 引用
Reject 拒绝
Reject All Changes 拒绝所有修订
Reject Change 拒绝修订
Relaxed 宽松
Remove Background 删除背景
Remove Footer 删除页脚
Remove Header 删除页眉
Remove Hyperlink 取消超链接
Remove Space After Paragraph 删除段后间距
Remove Table of Contents 删除目录
Remove Watermark 删除水印
Repeat 重复
Repeat Header Rows 重复标题行
Replace 替换
Replace All 全部替换
Replace Document Text 替换文档文本
Replace with: 替换为:
Replay 重播
Reply 答复
Report 报告
Research 研究
Researcher 研究工具
Reset Picture 重设图片
Resolve 解决
Resolved 已解决
Restart at 1 从 1 重新开始
Restart Each Page 每页重新编号
Restart Each Section 每节重新编号
Restrict Editing 限制编辑
Restrict\nEditing 限制编辑
Results 结果
Reverse the order 反转顺序
Review 审阅
Reviewing 审阅
Reviewing Pane 审阅窗格
Reviewing — edits become suggestions 审阅 — 编辑内容将成为修订建议
Rich Text Format (*.rtf) RTF 格式 (*.rtf)
Right 右
Right Border 右框线
Right-to-Left Text Direction 从右向左文字方向
Right: 右:
Rotate 旋转
Rounded Rectangle 圆角矩形
Rows & Columns 行和列
Ruler 标尺
Rules 规则
Résumé 简历
Save 保存
Save a copy in another format. Printing goes through a PDF you can print from any viewer. 以其他格式保存副本。打印时先生成 PDF,可在任意查看器中打印。
Save As 另存为
Save as Template 另存为模板
Saved 已保存
Search Commands 搜索命令
Search commands and help 搜索命令和帮助
Search document 搜索文档
Section Breaks 分节符
Section Properties 节属性
Sections 节数
Select 选择
Select All 全选
Select Cell 选择单元格
Select Line 选择行
Select Objects 选择对象
Select Paragraph 选择段落
Select Range 选择范围
Select Recipients 选择收件人
Select Row 选择行
Select Sentence 选择句子
Select Table 选择表格
Select Text 选择文本
Select Text with Similar Formatting 选择格式相似的文本
Select Word 选择单词
Select\nRecipients 选择收件人
Selection 选择
Selection Pane 选择窗格
Send Backward 下移一层
Sentence case. 句首字母大写。
Set as Default 设为默认值
Set Caret 设置光标
Set Character Formatting 设置字符格式
Set Numbering Value 设置编号值
Set Paragraph Formatting 设置段落格式
Shading 底纹
Shadow 阴影
Shape Fill 形状填充
Shape Format 形状格式
Shape Outline 形状轮廓
Shape Styles 形状样式
Shapes 形状
Share 共享
Shift+Tab Shift+Tab
Show 显示
Show all formatting marks 显示所有格式标记
Show Comments 显示批注
Show Markup 显示标记
Show Notes 显示备注
Show rulers 显示标尺
Show the community button in the title bar 在标题栏中显示社区按钮
Show/Hide ¶ 显示/隐藏 ¶
Show\nComments 显示批注
Shrink Font 1 Point 字号减小 1 磅
Side to Side 并排
Side\nto Side 并排
Signature Line 签名行
Single 单线
Single Spacing 单倍行距
Size 纸张大小
Size: 字号:
Small Caps 小型大写字母
Small caps 小型大写字母
SmartArt SmartArt
Sort 排序
Source code: github.com/storytold/wordcraft 源代码:github.com/storytold/wordcraft
Space before/after: 段前/段后间距:
Spacing 间距
Spacing (pt): 间距(磅):
Speech 语音
Spelling & Grammar 拼写和语法
Spelling &\nGrammar 拼写和语法
Spelling Suggestions 拼写建议
Split 拆分
Split Cells 拆分单元格
Split Table 拆分表格
Spreadsheet Table 电子表格
Standard Colors 标准色
Star 星形
Start Mail Merge 开始邮件合并
Start Mail\nMerge 开始邮件合并
Statistics 统计信息
Status Bar 状态栏
Strikethrough 删除线
Studio cover 工作室封面
Studio handbook (sample) 工作室手册(示例)
Style 样式
Style based on: 样式基于:
Style Set 样式集
Style: APA 样式:APA
Styles 样式
Styles Pane 样式窗格
Subscript 下标
Superscript 上标
Switch Modes 切换模式
Switch\nModes 切换模式
Symbol 符号
Symbols 符号
Synonyms 同义词
Tab 制表符
Table 表格
Table Column Width 表格列宽
Table Design 表设计
Table Layout 表布局
Table of Authorities 引文目录
Table of Contents 目录
Table of\nContents 目录
Table Properties 表格属性
Table Row Height 表格行高
Table Style Options 表格样式选项
Table Styles 表格样式
Tables 表格
Tabs 制表位
Tabular List 表格列表
Text 文本
Text Box 文本框
Text Direction 文字方向
Text Effects 文本效果
Text Effects and Typography 文本效果和版式
Text from File 文件中的文字
Text Highlight Color 文本突出显示颜色
Text to display: 要显示的文字:
Text Wrapping 文字环绕
Text: 文字:
Text\nBox 文本框
Text\nDirection 文字方向
The new style takes the formatting of the current paragraph. 新样式将采用当前段落的格式。
Theme Colors 主题颜色
Theme Fonts 主题字体
Theme List 主题列表
Themes 主题
Thesaurus 同义词库
Thick 粗线
Three 三栏
Tight 紧密
Top Border 上框线
Top of Page 页面顶端
Top: 上:
Total Row 汇总行
Track Changes 修订
Track Changes: On 修订:打开
Tracking 修订
Translate 翻译
Transparency 透明度
Triangle 三角形
Two 两栏
Type a command, e.g. "insert table" 键入命令,例如 "insert table"
Type Text 键入文本
Underline 下划线
Underline Words Only 仅给字词加下划线
Undo 撤消
Undo {action} 撤消{action}
Up 上
Update Field 更新域
Update Index 更新索引
Update Style to Match Selection 更新样式以匹配所选内容
Update Table 更新目录
Update Table of Figures 更新表目录
Update to Match 更新以匹配
Update to Match Selection 更新以匹配所选内容
UPPERCASE 全部大写
User Name 用户名
User name: 用户名:
Version History 版本历史记录
Version {version} ({build}) 版本 {version}({build})
Vertical 垂直
Vertical Alignment 垂直对齐方式
View 视图
View Gridlines 查看网格线
View State 视图状态
Views 视图
Voice 语音
Watermark 水印
Wave 波浪线
Web Layout Web 版式视图
Web page (*.html) 网页 (*.html)
Web\nLayout Web 版式视图
Whole page 整页
Whole words 全字匹配
Wide 宽
Widow/Orphan Control 孤行控制
Widow/Orphan control 孤行控制
Wildcards (regex) 通配符(正则表达式)
Window 窗口
Word Count 字数统计
Word document (*.docx) Word 文档 (*.docx)
Word Left 左移一个单词
Word Right 右移一个单词
WordArt 艺术字
WordCraft WordCraft
Words 字数
Words only 仅字词
Wrap Text 环绕文字
Wrap\nText 环绕文字
Write & Insert Fields 编写和插入域
Zoom 缩放
Zoom In 放大
Zoom Out 缩小
{group} settings {group}设置
{key}+Click to follow link 按住 {key} 并单击可访问链接
➕ New comment ➕ 新建批注
📂 Browse… 📂 浏览…
{words} words {words} 个字
{selected} of {words} words {selected}/{words} 个字
Can't render this file because it contains an unexpected character in line 6 and column 25.
+774
View File
@@ -0,0 +1,774 @@
# Traditional Chinese (zh-hant, Taiwan vocabulary) interface catalog. Same format and clean-room
# rules as zh-hans.tsv.
(no suggestions) (無建議)
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屬於 ArtCraft 團隊的 Crafting Apps 系列。
About 關於
About WordCraft 關於 WordCraft
Accept 接受
Accept All Changes 接受所有修訂
Accept This Change 接受此修訂
Accessibility 協助工具
Add 新增
Add a comment 新增註解
Add a Table 新增表格
Add Space Before Paragraph 在段落前新增間距
Add Text 新增文字
Add to Dictionary 新增至字典
Add to Style Gallery 新增至樣式庫
Address Block 地址區塊
Address: 地址:
Address\nBlock 地址區塊
Adjust 調整
Advanced 進階
After: 段落後:
Agents 代理程式
Align 對齊
Align Left 靠左對齊
Align Right 靠右對齊
Alignment 對齊方式
All Borders 所有框線
All Caps 全部大寫
All caps 全部大寫
All Markup 所有標記
Alt Text 替代文字
Apply Character Style 套用字元樣式
Apply Style 套用樣式
Arrange 排列
Arrange\nAll 全部排列
Arrow 箭號
Artistic Effects 美術效果
Author 作者
AutoCorrect Options 自動校正選項
AutoFit 自動調整
AutoFit Contents 依內容自動調整
AutoFit Window 依視窗自動調整
Automatic 自動
Automatic ({language}) 自動({language})
Automatic follows your system's language. Menus and commands change; your documents don't. 「自動」會跟隨系統語言。功能表與命令會改變,文件內容不會改變。
Automatic Table 1 (3 levels) 自動目錄 1(3 階層)
Automatic Table 2 (2 levels) 自動目錄 2(2 階層)
AutoSave 自動儲存
AutoSave documents that have been saved 自動儲存已儲存過的文件
AutoSave saves every change to the file (needs a saved document) 自動儲存會將每次變更寫入檔案(文件需已儲存)
AutoText 自動圖文集
Backspace 退格
Banded Columns 帶狀欄
Banded Rows 帶狀列
Before: 段落前:
Bibliography 參考書目
Binary assets added 新增二進位資源
Binary assets removed 移除二進位資源
Blank 空白
Blank (Three Columns) 空白(三欄)
Blank document 空白文件
Blank Page 空白頁
Block\nAuthors 封鎖作者
Bold 粗體
Bookmark 書籤
Bookmark name: 書籤名稱:
Border Painter 框線複製
Border\nPainter 框線複製
Borders 框線
Bottom Border 下框線
Bottom of Page 頁面底端
Bottom: 下:
Box 方框
Breaks 分隔設定
Bring Forward 上移一層
Bullet Library 項目符號庫
Bullets 項目符號
Calendar 行事曆
Cancel 取消
Capitalize Each Word 每個單字字首大寫
Captions 標號
Cell Margins 儲存格邊界
Cell Size 儲存格大小
Cell\nMargins 儲存格邊界
Center 置中
Change 變更
Change Case 變更大小寫
Change List Level 變更清單階層
Change Picture 變更圖片
Change Shape 變更圖案
Changes 變更
Character Position 字元位置
Character Scale 字元縮放
Character Shading 字元網底
Character Spacing 字元間距
Characters (no spaces) 字元數(不含空格)
Characters (with spaces) 字元數(含空格)
Chart 圖表
Check Accessibility 檢查協助工具
Check Compatibility 檢查相容性
Check for Errors 檢查錯誤
Check Spelling as You Type 輸入時檢查拼字
Check\nAccessibility 檢查協助工具
Citation Style 引文樣式
Citations & Bibliography 引文與參考書目
Clear 清除
Clear All Formatting 清除所有格式設定
Clipboard 剪貼簿
Close 關閉
Close Header and Footer 關閉頁首及頁尾
Collapse Selection 摺疊選取範圍
Color 色彩
Color: 色彩:
Colors 色彩
Column 欄
Column Break 分欄符號
Columns 欄
Combine 合併
Commands 命令
Comment 註解
Comments 註解
Comments Pane 註解窗格
Community\nDiscord 社群\nDiscord
Compact 緊密
Compare 比較
Compress Pictures 壓縮圖片
Continuous 接續本頁
Convert 轉換
Convert Text to Table 文字轉換為表格
Convert Text to Table… 文字轉換為表格…
Convert to Text 轉換為文字
Copy 複製
Copy Formatting 複製格式
Corrections 校正
Cover Page 封面頁
Create 建立
Create a Style 建立樣式
Create an interactive outline of your document.\n\nIt's a great way to keep track of where you are or quickly move your content around.\n\nTo get started, go to the Home tab and apply Heading styles to the headings in your document. 為文件建立互動式大綱。\n\n用它可以隨時掌握目前位置,或快速移動內容。\n\n若要開始使用,請前往「常用」索引標籤,為文件中的標題套用標題樣式。
Create New Style 建立新樣式
Crop 裁剪
Cross-reference 交互參照
Current Position 目前位置
Current style: {style} 目前樣式:{style}
Custom Margins… 自訂邊界…
Custom Watermark 自訂浮水印
Custom Watermark… 自訂浮水印…
Customize Quick Access Toolbar 自訂快速存取工具列
Cut 剪下
Dark Mode 深色模式
Dark mode 深色模式
Dashed 虛線
Data 資料
Date & Time 日期及時間
Decrease Font Size 縮小字型大小
Decrease Indent 減少縮排
Default 預設
Define New Bullet 定義新的項目符號
Define New Number Format 定義新的編號格式
Delete 刪除
Delete Cells 刪除儲存格
Delete Columns 刪除欄
Delete Comment 刪除註解
Delete Next Word 刪除下一個單字
Delete Previous Word 刪除上一個單字
Delete Rows 刪除列
Delete Style 刪除樣式
Delete Table 刪除表格
Design 設計
development build 開發版本
Diagonal 斜向
Diamond 菱形
Dictate 聽寫
Different First Page 首頁不同
Different Odd & Even Pages 奇偶頁不同
Discord Discord
Discord community Discord 社群
Display 顯示
Display for Review 顯示以供檢閱
Distribute Columns 平均分配欄寬
Distribute Rows 平均分配列高
Distributed 分散對齊
Do Not Show in TOC 不在目錄中顯示
Document End 文件結尾
Document Formatting 文件格式設定
Document Property 文件摘要資訊
Document Start 文件開頭
Document Text 文件文字
Document Title 文件標題
Documents you open will show up here. 開啟過的文件會顯示在這裡。
Dot-dash 點虛線
Dotted 點線
Double 雙線
Double Spacing 兩倍行高
Double Strikethrough 雙刪除線
Double Underline 雙底線
Down 下
Draft 草稿
Draw 繪圖
Drawing Tools 繪圖工具
Drawing\nCanvas 繪圖畫布
Drop Cap 首字放大
Edit Footer 編輯頁尾
Edit Header 編輯頁首
Edit Recipient List 編輯收件者清單
Edit\nRecipient List 編輯收件者清單
Edited 已編輯
Editing 編輯
Editing header/footer 正在編輯頁首/頁尾
Editing — edit the document directly 編輯 — 直接編輯文件
Editor 編輯器
Effects 效果
Emboss 浮凸
Encrypt with Password 以密碼加密
English (United States) 英文(美國)
Engrave 陰刻
Enter page number: 輸入頁碼:
Envelopes 信封
Equation 方程式
Eraser 橡皮擦
Even Page 偶數頁
Every command is available to scripts and AI agents: run `wordcraft-cli mcp` for an MCP server, or start the app with `--control <port>` for the JSON control channel. 每個命令都可供指令碼和 AI 代理程式使用:執行 `wordcraft-cli mcp` 啟動 MCP 伺服器,或以 `--control <port>` 啟動應用程式以開啟 JSON 控制通道。
Export 匯出
Export a copy to share (PDF, Word document) 匯出副本以共用(PDF、Word 文件)
Export Page as PNG 將頁面匯出為 PNG
Export PDF 匯出 PDF
Exported {path} 已匯出 {path}
Extend Selection 擴大選取範圍
Field 功能變數
File 檔案
Find 尋找
Find and Replace 尋找及取代
Find Next 尋找下一個
Find Previous 尋找上一個
Find Recipient 尋找收件者
Find what: 尋找目標:
Finish 完成
Finish & Merge 完成與合併
Finish &\nMerge 完成與合併
First Column 第一欄
First commit 首次提交
First line: 第一行:
Fixed Column Width 固定欄寬
Focus 專注
Font 字型
Font Color 字型色彩
Font color: 字型色彩:
Font Dialog 字型對話方塊
Font Size 字型大小
Font: 字型:
Fonts 字型
Footer 頁尾
Footnote and Endnote 註腳及章節附註
Footnotes 註腳
Format 格式
Format Page Numbers 頁碼格式
Format Page Numbers… 頁碼格式…
Format Painter 複製格式
Formatting at Selection 選取範圍的格式
Formula 公式
General 一般
Go To 移至
Go to 移至
Go To (⌘⌥G) 移至 (⌘⌥G)
Grab bag 隨機展示
Greeting Line 問候語
Greeting\nLine 問候語
Gridlines 格線
Group 群組
Grow Font 1 Point 字型放大 1 點
Hanging Indent 首行凸排
Header 頁首
Header & Footer 頁首及頁尾
Header Row 標題列
Header/Footer Position 頁首/頁尾位置
Heading 1 標題 1
Heading 2 標題 2
Heading 3 標題 3
Headings 標題
Heart 心形
Help 說明
Hidden 隱藏
Highlight Merge Fields 醒目提示合併欄位
Highlight\nMerge Fields 醒目提示合併欄位
Highlighter 螢光筆
Home 常用
Horizontal 水平
Horizontal Line 水平線
Hyphenation 斷字
Icons 圖示
Ignore All 全部略過
Illustrations 圖例
Immersive 沉浸式
Immersive Reader 沉浸式閱讀程式
Immersive\nReader 沉浸式閱讀程式
Increase Font Size 放大字型
Increase Indent 增加縮排
Indent 縮排
Indentation 縮排
Indents 縮排
Index 索引
Info 資訊
Ink to\nMath 筆跡轉數學公式
Ink to\nShape 筆跡轉圖形
Ink\nReplay 筆跡重播
Insert 插入
Insert Above 插入上方列
Insert Below 插入下方列
Insert Caption 插入標號
Insert Citation 插入引文
Insert Endnote 插入章節附註
Insert Footnote 插入註腳
Insert Hyperlink 插入超連結
Insert Index 插入索引
Insert Left 插入左方欄
Insert Merge Field 插入合併欄位
Insert Merge\nField 插入合併欄位
Insert Right 插入右方欄
Insert Shapes 插入圖案
Insert Table 插入表格
Insert Table of Authorities 插入引文目錄
Insert Table of Figures 插入圖表目錄
Insert Table… 插入表格…
Insert\nAbove 插入上方列
Insert\nCaption 插入標號
Insert\nCitation 插入引文
Insert\nFootnote 插入註腳
Inside Borders 內框線
Inspect Document 檢查文件
Interface language: 介面語言:
Italic 斜體
Join the ArtCraft community on Discord 加入 Discord 上的 ArtCraft 社群
Join us on Discord: discord.gg/artcraft 在 Discord 上加入我們:discord.gg/artcraft
Justify 左右對齊
Keep Lines Together 段落中不分頁
Keep lines together 段落中不分頁
Keep Text Only 只保留文字
Keep with Next 與下段同頁
Keep with next 與下段同頁
Labels 標籤
Landscape 橫向
Language 語言
Lasso 套索
Last Column 最後一欄
Last commit 最近提交
Layout 版面配置
Layout Summary 版面配置摘要
Left 左
Left Border 左框線
Left: 左:
Letter 信函
Line 直線
Line and Page Breaks 分行與分頁設定
Line and Paragraph Spacing 行距與段落間距
Line Break 換行符號
Line delta 行數變化
Line End 行尾
Line Numbers 行號
Line Spacing 行距
Line Spacing Options… 行距選項…
Line spacing: 行距:
Line Start 行首
Lines 行數
Lines added 新增行數
Lines deleted 刪除行數
Link 連結
Link to Previous 連結到前一節
Links 連結
Location: {path} 位置:{path}
lowercase 全部小寫
Macros 巨集
Mailings 郵件
Manage Sources 管理來源
Many pages 多頁
Margins 邊界
Mark Citation 標記引文
Mark Entry 標記項目
Mark\nCitation 標記引文
Mark\nEntry 標記項目
Markdown (*.md) Markdown (*.md)
Match case 大小寫須相符
Match Fields 對應欄位
Matrix 矩陣
Merge 合併
Merge Cells 合併儲存格
Merge Formatting 合併格式設定
Merged PRs 已合併的 PR
Mirrored 左右對稱
MIT OR Apache-2.0. Copyright (c) 2026 ArtCraft Team and the WordCraft contributors. MIT OR Apache-2.0。著作權所有 (c) 2026 ArtCraft 團隊及 WordCraft 貢獻者。
Moderate 中等
Modify Style 修改樣式
Modify… 修改…
Multilevel List 多層次清單
Multiple Pages 多頁
Name (A–Z) 名稱(A–Z)
Name: 名稱:
Narrow 0.5" all 窄 四周 0.5"
Navigation 導覽
Navigation Pane 功能窗格
New 新增
New Comment 新增註解
New from Template 從範本新增
New Paragraph 新段落
New Style… 新增樣式…
New Window 開新視窗
New\nComment 新增註解
New\nWindow 開新視窗
Next 下一個
Next Change 下一個變更
Next Comment 下一個註解
Next Footnote 下一個註腳
Next Page 下一頁
Next Record 下一筆記錄
Next Section 下一節
No Border 無框線
No Color 無色彩
No contributor data was built into this copy. 此版本未內建貢獻者資料。
No Markup 無標記
No model credits were built into this copy. 此版本未內建模型致謝資訊。
No Paragraph Space 無段落間距
Nonbreaking Hyphen 不分行連字號
Nonbreaking Space 不分行空格
None 無
Normal 1" all 一般 四周 1"
Normal Style 內文樣式
Not available right now 目前無法使用
Not saved 未儲存
Not saved yet 尚未儲存
Number of columns: 欄數:
Number of rows: 列數:
Numbering 編號
Object 物件
Odd Page 奇數頁
Office 2003 Default Office 2003 預設值
OK 確定
One 一欄
One Page 單頁
Open 開啟
OpenDocument Text (*.odt) OpenDocument 文字 (*.odt)
Optional Hyphen 選擇性連字號
Options 選項
Orientation 方向
Outline 大綱
Outline Level 大綱階層
Outside Borders 外框線
Oval 橢圓
Page 頁面
Page Background 頁面背景
Page Borders 頁面框線
Page Break 分頁符號
Page Break Before 段落前分頁
Page break before 段落前分頁
Page Breaks 分頁符號
Page Color 頁面色彩
Page Down 下一頁
Page image (*.png) 頁面影像 (*.png)
Page Movement 翻頁方式
Page Number 頁碼
Page Setup 版面設定
Page Up 上一頁
Page Width 頁寬
Page width 頁寬
Page X of Y 第 X 頁,共 Y 頁
Page {page} 第 {page} 頁
Page {page} of {pages} 第 {page} 頁,共 {pages} 頁
Page\nBorders 頁面框線
Page\nColor 頁面色彩
Page\nNumber 頁碼
Pages 頁面
Paragraph 段落
Paragraph Details 段落詳細資料
Paragraph Down 下移一段
Paragraph Settings 段落設定
Paragraph Spacing 段落間距
Paragraph Up 上移一段
Paragraphs 段落數
Paste 貼上
Paste (⌘V) 貼上 (⌘V)
Paste Formatting 貼上格式
Paste: Keep Text Only 貼上:只保留文字
Paste: Merge Formatting 貼上:合併格式設定
PDF document (*.pdf) PDF 文件 (*.pdf)
Pen 畫筆
Pencil 鉛筆
Picture Border 圖片框線
Picture Format 圖片格式
Picture Styles 圖片樣式
Pictures 圖片
Plain text (*.txt) 純文字 (*.txt)
Portrait 直向
Position 位置
Preview Results 預覽結果
Preview\nResults 預覽結果
Previous 上一個
Previous Change 上一個變更
Previous Comment 上一個註解
Previous Record 上一筆記錄
Previous Section 上一節
Print 列印
Print Layout 整頁模式
Print\nLayout 整頁模式
Proofing 校訂
Proofing Issues 校訂問題
Properties 內容
Protect 保護
Protect Document 保護文件
Quick Access Toolbar 快速存取工具列
Quick Parts 快速組件
Quick Tables 快速表格
Read Aloud 大聲朗讀
Read Mode 閱讀模式
Read\nAloud 大聲朗讀
Read\nMode 閱讀模式
Recent 最近
Record Macro 錄製巨集
Recover Unsaved Documents 復原未儲存的文件
Rectangle 矩形
Redo 取消復原
Reduce Hanging Indent 減少首行凸排
References 參考資料
Reject 拒絕
Reject All Changes 拒絕所有變更
Reject Change 拒絕變更
Relaxed 寬鬆
Remove Background 移除背景
Remove Footer 移除頁尾
Remove Header 移除頁首
Remove Hyperlink 移除超連結
Remove Space After Paragraph 移除段落後的間距
Remove Table of Contents 移除目錄
Remove Watermark 移除浮水印
Repeat 重複
Repeat Header Rows 重複標題列
Replace 取代
Replace All 全部取代
Replace Document Text 取代文件文字
Replace with: 取代為:
Replay 重播
Reply 回覆
Report 報告
Research 研究
Researcher 研究工具
Reset Picture 重設圖片
Resolve 解決
Resolved 已解決
Restart at 1 從 1 重新開始
Restart Each Page 每頁重新編號
Restart Each Section 每節重新編號
Restrict Editing 限制編輯
Restrict\nEditing 限制編輯
Results 結果
Reverse the order 反轉順序
Review 校閱
Reviewing 校閱
Reviewing Pane 校閱窗格
Reviewing — edits become suggestions 校閱 — 編輯內容會成為修訂建議
Rich Text Format (*.rtf) RTF 格式 (*.rtf)
Right 右
Right Border 右框線
Right-to-Left Text Direction 從右至左文字方向
Right: 右:
Rotate 旋轉
Rounded Rectangle 圓角矩形
Rows & Columns 列與欄
Ruler 尺規
Rules 規則
Résumé 履歷
Save 儲存
Save a copy in another format. Printing goes through a PDF you can print from any viewer. 以其他格式儲存副本。列印時會先產生 PDF,可在任何檢視器中列印。
Save As 另存新檔
Save as Template 另存為範本
Saved 已儲存
Search Commands 搜尋命令
Search commands and help 搜尋命令和說明
Search document 搜尋文件
Section Breaks 分節符號
Section Properties 節屬性
Sections 節數
Select 選取
Select All 全選
Select Cell 選取儲存格
Select Line 選取行
Select Objects 選取物件
Select Paragraph 選取段落
Select Range 選取範圍
Select Recipients 選取收件者
Select Row 選取列
Select Sentence 選取句子
Select Table 選取表格
Select Text 選取文字
Select Text with Similar Formatting 選取格式相似的文字
Select Word 選取單字
Select\nRecipients 選取收件者
Selection 選取範圍
Selection Pane 選取窗格
Send Backward 下移一層
Sentence case. 句首字母大寫。
Set as Default 設定為預設值
Set Caret 設定游標
Set Character Formatting 設定字元格式
Set Numbering Value 設定編號值
Set Paragraph Formatting 設定段落格式
Shading 網底
Shadow 陰影
Shape Fill 圖案填滿
Shape Format 圖形格式
Shape Outline 圖案外框
Shape Styles 圖案樣式
Shapes 圖案
Share 共用
Shift+Tab Shift+Tab
Show 顯示
Show all formatting marks 顯示所有格式化標記
Show Comments 顯示註解
Show Markup 顯示標記
Show Notes 顯示附註
Show rulers 顯示尺規
Show the community button in the title bar 在標題列中顯示社群按鈕
Show/Hide ¶ 顯示/隱藏 ¶
Show\nComments 顯示註解
Shrink Font 1 Point 字型縮小 1 點
Side to Side 並排
Side\nto Side 並排
Signature Line 簽名欄
Single 單線
Single Spacing 單行間距
Size 大小
Size: 大小:
Small Caps 小型大寫字
Small caps 小型大寫字
SmartArt SmartArt
Sort 排序
Source code: github.com/storytold/wordcraft 原始碼:github.com/storytold/wordcraft
Space before/after: 段落前/後間距:
Spacing 間距
Spacing (pt): 間距(點):
Speech 語音
Spelling & Grammar 拼字及文法
Spelling &\nGrammar 拼字及文法
Spelling Suggestions 拼字建議
Split 分割
Split Cells 分割儲存格
Split Table 分割表格
Spreadsheet Table 試算表
Standard Colors 標準色彩
Star 星形
Start Mail Merge 啟動合併列印
Start Mail\nMerge 啟動合併列印
Statistics 統計資料
Status Bar 狀態列
Strikethrough 刪除線
Studio cover 工作室封面
Studio handbook (sample) 工作室手冊(範例)
Style 樣式
Style based on: 樣式依據:
Style Set 樣式集
Style: APA 樣式:APA
Styles 樣式
Styles Pane 樣式窗格
Subscript 下標
Superscript 上標
Switch Modes 切換模式
Switch\nModes 切換模式
Symbol 符號
Symbols 符號
Synonyms 同義字
Tab 定位字元
Table 表格
Table Column Width 表格欄寬
Table Design 表格設計
Table Layout 表格版面配置
Table of Authorities 引文目錄
Table of Contents 目錄
Table of\nContents 目錄
Table Properties 表格內容
Table Row Height 表格列高
Table Style Options 表格樣式選項
Table Styles 表格樣式
Tables 表格
Tabs 定位點
Tabular List 表格式清單
Text 文字
Text Box 文字方塊
Text Direction 文字方向
Text Effects 文字效果
Text Effects and Typography 文字效果與印刷樣式
Text from File 檔案中的文字
Text Highlight Color 文字醒目提示色彩
Text to display: 要顯示的文字:
Text Wrapping 文繞圖
Text: 文字:
Text\nBox 文字方塊
Text\nDirection 文字方向
The new style takes the formatting of the current paragraph. 新樣式會採用目前段落的格式。
Theme Colors 佈景主題色彩
Theme Fonts 佈景主題字型
Theme List 佈景主題清單
Themes 佈景主題
Thesaurus 同義字典
Thick 粗線
Three 三欄
Tight 緊密
Top Border 上框線
Top of Page 頁面頂端
Top: 上:
Total Row 合計列
Track Changes 追蹤修訂
Track Changes: On 追蹤修訂:開啟
Tracking 追蹤
Translate 翻譯
Transparency 透明度
Triangle 三角形
Two 二欄
Type a command, e.g. "insert table" 輸入命令,例如 "insert table"
Type Text 輸入文字
Underline 底線
Underline Words Only 只在文字下加底線
Undo 復原
Undo {action} 復原{action}
Up 上
Update Field 更新功能變數
Update Index 更新索引
Update Style to Match Selection 更新樣式以符合選取範圍
Update Table 更新目錄
Update Table of Figures 更新圖表目錄
Update to Match 更新以符合
Update to Match Selection 更新以符合選取範圍
UPPERCASE 全部大寫
User Name 使用者名稱
User name: 使用者名稱:
Version History 版本歷程記錄
Version {version} ({build}) 版本 {version}({build})
Vertical 垂直
Vertical Alignment 垂直對齊方式
View 檢視
View Gridlines 檢視格線
View State 檢視狀態
Views 檢視
Voice 語音
Watermark 浮水印
Wave 波浪線
Web Layout Web 版面配置
Web page (*.html) 網頁 (*.html)
Web\nLayout Web 版面配置
Whole page 整頁
Whole words 全字拼寫須相符
Wide 寬
Widow/Orphan Control 遺留字串控制
Widow/Orphan control 遺留字串控制
Wildcards (regex) 萬用字元(規則運算式)
Window 視窗
Word Count 字數統計
Word document (*.docx) Word 文件 (*.docx)
Word Left 左移一個單字
Word Right 右移一個單字
WordArt 文字藝術師
WordCraft WordCraft
Words 字數
Words only 僅文字
Wrap Text 文繞圖
Wrap\nText 文繞圖
Write & Insert Fields 書寫與插入欄位
Zoom 縮放
Zoom In 放大
Zoom Out 縮小
{group} settings {group}設定
{key}+Click to follow link 按住 {key} 並按一下以追蹤連結
➕ New comment ➕ 新增註解
📂 Browse… 📂 瀏覽…
{words} words {words} 個字
{selected} of {words} words {selected}/{words} 個字
Can't render this file because it contains an unexpected character in line 400 and column 13.
+119 -2
View File
@@ -5,12 +5,21 @@
//! the menus, ribbon, shortcuts, control channel and MCP all reach the same behaviour.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
/// An English UI string in the current interface language ([`i18n::t`]).
#[macro_export]
macro_rules! tl {
($s:expr) => {
$crate::i18n::t($s)
};
}
pub mod backstage;
pub mod canvas;
pub mod chrome;
pub mod control;
pub mod credits;
pub mod dialogs;
pub mod i18n;
pub mod icons;
pub mod keys;
pub mod panes;
@@ -18,6 +27,7 @@ pub mod previews;
pub mod ribbon;
pub mod theme;
pub mod widgets;
pub mod window_geometry;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
@@ -55,6 +65,12 @@ pub struct UiState {
pub dark: bool,
pub nav_tab: String,
pub show_discord: bool,
/// User name (File › Options) for comments and tracked changes; empty keeps the default.
pub author: String,
/// Desktop: the main window's size and position, restored at the next launch.
pub window: Option<window_geometry::WindowGeometry>,
/// Interface language: `auto` (follow the system) or a code from [`i18n::LANGUAGES`].
pub language: String,
}
impl Default for UiState {
@@ -68,6 +84,9 @@ impl Default for UiState {
dark: false,
nav_tab: "headings".into(),
show_discord: true,
author: String::new(),
window: None,
language: i18n::AUTO.into(),
}
}
}
@@ -89,6 +108,8 @@ pub struct WordApp {
pending_shots: Vec<(u64, Option<String>, std::sync::mpsc::Sender<Value>, f64)>,
pub(crate) synthetic: Vec<egui::Event>,
styled: bool,
/// The CJK face order the installed UI fonts use (Chinese first, or Japanese first).
fonts_hans: bool,
fonts_frames: u32,
applied_dark: Option<bool>,
pub frame_ms: f64,
@@ -115,6 +136,7 @@ impl WordApp {
pending_shots: Vec::new(),
synthetic: Vec::new(),
styled: false,
fonts_hans: false,
fonts_frames: 0,
applied_dark: None,
frame_ms: 0.0,
@@ -130,6 +152,24 @@ impl WordApp {
self
}
/// Preferences to save between runs: the UI state plus the current user name.
pub fn prefs(&self) -> UiState {
let mut ui = self.ui.clone();
ui.author = self.session.author.clone();
ui
}
/// Restore preferences saved by [`WordApp::prefs`].
pub fn apply_prefs(&mut self, ui: UiState) {
self.ui = ui;
self.ui.backstage = false;
// The session owns the name from here on; `prefs` copies it back when saving.
let author = std::mem::take(&mut self.ui.author);
if !author.trim().is_empty() {
self.session.author = author;
}
}
/// Run a command; UI-level commands (`ui.*`) are handled here, the rest by the engine.
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
if let Some(r) = self.ui_command(id, &params) {
@@ -235,6 +275,20 @@ impl WordApp {
self.ui.dark = p.get("value").and_then(Value::as_bool).unwrap_or(!self.ui.dark);
json!({"dark": self.ui.dark})
}
"ui.language" => {
// `auto` (follow the system) or a language code; anything else is an error.
if let Some(v) = s("value") {
match i18n::normalize_pref(v) {
Some(code) => self.ui.language = code.to_string(),
None => {
let codes: Vec<&str> = i18n::Lang::all().map(i18n::Lang::code).collect();
return Some(Err(format!("unknown language `{v}`; use auto or one of {}", codes.join(", "))));
}
}
}
let lang = i18n::Lang::from_pref(&self.ui.language);
json!({"language": self.ui.language, "effective": lang.code(), "available": i18n::Lang::all().map(|l| json!({"code": l.code(), "name": l.name()})).collect::<Vec<_>>()})
}
"ui.openFileDialog" => {
self.open_dialog();
json!({})
@@ -298,8 +352,15 @@ impl WordApp {
/// Per-frame logic before layout: control requests, screenshots, shortcuts, file drops.
pub fn logic(&mut self, ctx: &egui::Context) {
if !self.styled {
theme::install_fonts(ctx);
let lang = i18n::Lang::from_pref(&self.ui.language);
i18n::set_current(lang);
// Chinese text wants the Chinese face before the Japanese one (one glyph style per line).
if !self.styled || lang.prefers_hans() != self.fonts_hans {
theme::install_fonts_for(ctx, lang.prefers_hans());
self.fonts_hans = lang.prefers_hans();
// Mod with -, = and 0 are Word shortcuts (optional hyphen, subscript, paragraph spacing);
// egui's keyboard zoom would also scale the whole window on them. Zoom is View › Zoom.
ctx.options_mut(|o| o.zoom_with_keyboard = false);
self.styled = true;
}
let dark = self.ui.dark || self.session.view.dark_mode;
@@ -362,6 +423,7 @@ impl WordApp {
pub fn ui(&mut self, ui: &mut egui::Ui) {
let t0 = now_ms();
let ctx = ui.ctx().clone();
i18n::set_current(i18n::Lang::from_pref(&self.ui.language));
// Fonts installed by `logic` take effect on the next frame.
if self.fonts_frames < 2 {
self.fonts_frames += 1;
@@ -485,3 +547,58 @@ pub fn now_ms() -> f64 {
0.0
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mod_key(key: egui::Key) -> egui::Event {
egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }
}
/// Issue #14: Mod+- (optional hyphen) also zoomed the whole window out, so it read as "zoom out".
#[test]
fn word_shortcuts_do_not_zoom_the_window() {
let ctx = egui::Context::default();
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
let mut frame = |events: Vec<egui::Event>| {
let input = egui::RawInput { events, ..Default::default() };
// No painter here: dropping the font atlas delta unapplied trips an epaint debug assertion.
ctx.run_ui(input, |ui| app.logic(ui.ctx())).drop_without_applying_deltas();
};
frame(Vec::new());
frame(vec![mod_key(egui::Key::Minus)]);
frame(vec![mod_key(egui::Key::Minus)]);
frame(Vec::new());
assert_eq!(ctx.zoom_factor(), 1.0);
}
fn app() -> WordApp {
WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default())
}
#[test]
fn user_name_survives_restart() {
let mut first = app();
first.run("file.setAuthor", json!({"name": "Ada Lovelace"})).unwrap();
let saved = serde_json::to_vec(&first.prefs()).unwrap();
let mut second = app();
second.apply_prefs(serde_json::from_slice(&saved).unwrap());
assert_eq!(second.session.author, "Ada Lovelace");
// A later rename is what gets saved next, not the name loaded at startup.
second.run("file.setAuthor", json!({"name": "Grace Hopper"})).unwrap();
assert_eq!(second.prefs().author, "Grace Hopper");
}
#[test]
fn prefs_without_user_name_keep_default() {
let mut a = app();
let default = a.session.author.clone();
a.apply_prefs(serde_json::from_str(r#"{"dark": true, "backstage": true}"#).unwrap());
assert_eq!(a.session.author, default);
assert!(a.ui.dark);
assert!(!a.ui.backstage);
}
}
+21 -18
View File
@@ -35,7 +35,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
fn header(ui: &mut Ui, title: &str) -> bool {
let mut close = false;
ui.horizontal(|ui| {
ui.label(egui::RichText::new(title).font(semibold(15.0)));
ui.label(egui::RichText::new(tl!(title)).font(semibold(15.0)));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.small_button("✕").clicked() {
close = true;
@@ -53,7 +53,7 @@ fn nav(app: &mut WordApp, ui: &mut Ui) {
}
let qid = egui::Id::new("nav_query");
let mut q = ui.data(|d| d.get_temp::<String>(qid)).unwrap_or_default();
let r = ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search document").desired_width(f32::INFINITY));
let r = ui.add(egui::TextEdit::singleline(&mut q).hint_text(tl!("Search document")).desired_width(f32::INFINITY));
if r.changed() {
ui.data_mut(|d| d.insert_temp(qid, q.clone()));
if !q.is_empty() {
@@ -65,7 +65,7 @@ fn nav(app: &mut WordApp, ui: &mut Ui) {
ui.add_space(4.0);
ui.horizontal(|ui| {
for (k, l) in [("headings", "Headings"), ("pages", "Pages"), ("results", "Results")] {
if ui.selectable_label(app.ui.nav_tab == k, l).clicked() {
if ui.selectable_label(app.ui.nav_tab == k, tl!(l)).clicked() {
app.ui.nav_tab = k.into();
}
}
@@ -75,7 +75,7 @@ fn nav(app: &mut WordApp, ui: &mut Ui) {
"pages" => {
let l = app.session.layout();
for (i, p) in l.pages.iter().enumerate() {
let label = format!("Page {}", p.number);
let label = crate::i18n::fmt(tl!("Page {page}"), &[("page", &p.number.to_string())]);
if ui.selectable_label(app.session.page_hint == i, label).clicked() {
let _ = app.run("edit.goto", json!({"page": i + 1}));
}
@@ -122,7 +122,7 @@ fn nav(app: &mut WordApp, ui: &mut Ui) {
}
}
if items.is_empty() {
ui.label(egui::RichText::new("Create an interactive outline of your document.\n\nIt's a great way to keep track of where you are or quickly move your content around.\n\nTo get started, go to the Home tab and apply Heading styles to the headings in your document.").weak());
ui.label(egui::RichText::new(tl!("Create an interactive outline of your document.\n\nIt's a great way to keep track of where you are or quickly move your content around.\n\nTo get started, go to the Home tab and apply Heading styles to the headings in your document.")).weak());
}
for (i, (path, lv, txt)) in items.into_iter().enumerate() {
ui.horizontal(|ui| {
@@ -145,15 +145,18 @@ fn styles(app: &mut WordApp, ui: &mut Ui) {
}
let st = app.session.run("format.state", &json!({})).unwrap_or_default();
let cur = st.get("style").and_then(Value::as_str).unwrap_or("Normal").to_string();
ui.label(egui::RichText::new(format!("Current style: {}", st.get("styleName").and_then(Value::as_str).unwrap_or(""))).small());
ui.label(
egui::RichText::new(crate::i18n::fmt(tl!("Current style: {style}"), &[("style", st.get("styleName").and_then(Value::as_str).unwrap_or(""))]))
.small(),
);
ui.horizontal(|ui| {
if ui.button("New Style…").clicked() {
if ui.button(tl!("New Style…")).clicked() {
app.dialog = crate::dialogs::Dialog::open("newStyle", app);
}
if ui.button("Update to Match").clicked() {
if ui.button(tl!("Update to Match")).clicked() {
let _ = app.run("styles.updateToMatch", json!({}));
}
if ui.button("Clear").clicked() {
if ui.button(tl!("Clear")).clicked() {
let _ = app.run("format.clear", json!({}));
}
});
@@ -180,15 +183,15 @@ fn styles(app: &mut WordApp, ui: &mut Ui) {
app.canvas.want_focus = true;
}
r.context_menu(|ui| {
if ui.button("Modify…").clicked() {
if ui.button(tl!("Modify…")).clicked() {
app.dialog = crate::dialogs::Dialog::modify_style(app, &id);
ui.close();
}
if ui.button("Update to Match Selection").clicked() {
if ui.button(tl!("Update to Match Selection")).clicked() {
let _ = app.run("styles.updateToMatch", json!({"style": id}));
ui.close();
}
if ui.button("Delete").clicked() {
if ui.button(tl!("Delete")).clicked() {
let _ = app.run("styles.delete", json!({"style": id}));
ui.close();
}
@@ -203,7 +206,7 @@ fn comments(app: &mut WordApp, ui: &mut Ui) {
let _ = app.run("view.commentsPane", json!({"value": false}));
return;
}
if ui.button("➕ New comment").clicked() {
if ui.button(tl!("➕ New comment")).clicked() {
let _ = app.run("review.newComment", json!({"text": ""}));
}
ui.separator();
@@ -222,7 +225,7 @@ fn comments(app: &mut WordApp, ui: &mut Ui) {
ui.label(egui::RichText::new(c.get("author").and_then(Value::as_str).unwrap_or("")).font(semibold(12.0)));
ui.label(egui::RichText::new(c.get("date").and_then(Value::as_str).unwrap_or("").get(..10).unwrap_or("")).small().weak());
if c.get("resolved").and_then(Value::as_bool).unwrap_or(false) {
ui.label(egui::RichText::new("Resolved").small().color(t.green));
ui.label(egui::RichText::new(tl!("Resolved")).small().color(t.green));
}
});
// Editable comment text (writes back to the comment's story).
@@ -230,7 +233,7 @@ fn comments(app: &mut WordApp, ui: &mut Ui) {
if let Some(part) = part {
let key = egui::Id::new(("comment_edit", id));
let mut text = ui.data(|d| d.get_temp::<String>(key)).unwrap_or_else(|| app.session.doc.plain_text(StoryRef::Part(part)));
let r = ui.add(egui::TextEdit::multiline(&mut text).desired_rows(1).desired_width(f32::INFINITY).hint_text("Add a comment"));
let r = ui.add(egui::TextEdit::multiline(&mut text).desired_rows(1).desired_width(f32::INFINITY).hint_text(tl!("Add a comment")));
if r.changed() {
ui.data_mut(|d| d.insert_temp(key, text.clone()));
}
@@ -243,15 +246,15 @@ fn comments(app: &mut WordApp, ui: &mut Ui) {
}
}
ui.horizontal(|ui| {
if ui.small_button("Go to").clicked()
if ui.small_button(tl!("Go to")).clicked()
&& let Some(a) = c.get("anchor").filter(|a| !a.is_null())
{
let _ = app.run("caret.set", json!({"pos": a}));
}
if ui.small_button("Resolve").clicked() {
if ui.small_button(tl!("Resolve")).clicked() {
let _ = app.run("review.resolveComment", json!({"id": id}));
}
if ui.small_button("Delete").clicked() {
if ui.small_button(tl!("Delete")).clicked() {
let _ = app.run("review.deleteComment", json!({"id": id}));
}
});
+44 -1
View File
@@ -49,9 +49,10 @@ impl Previews {
ui.painter().rect_filled(r, 3.0, t.hover);
}
let tex = tex.or_else(|| {
// Context accessors share a lock: read input before taking the data write lock.
let now = ui.input(|i| i.time);
let n = ui.ctx().data_mut(|d| {
let c = d.get_temp_mut_or_default::<(f64, u32)>(egui::Id::new("font_preview_budget"));
let now = ui.input(|i| i.time);
if c.0 != now {
*c = (now, 0);
}
@@ -295,3 +296,45 @@ pub fn table_style_tile(ui: &mut Ui, app: &mut WordApp, style: &str) -> Response
}
resp
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn font_menu_previews_render_defer_and_reuse_without_deadlocking() {
// A nested Context lock used to hang the first uncached menu entry. Bound the wait so
// a regression fails the test instead of hanging the entire test suite.
let (tx, rx) = std::sync::mpsc::channel();
let worker = std::thread::spawn(move || {
let ctx = egui::Context::default();
let preview = Previews::default().font_preview_fn();
let budget_id = egui::Id::new("font_preview_budget");
let sans_id = egui::Id::new("font:Source Sans 3");
let inter_id = egui::Id::new("font:Inter");
ctx.data_mut(|d| d.insert_temp(budget_id, (1.0_f64, 11_u32)));
for time in [1.0, 2.0, 3.0] {
let input = egui::RawInput { time: Some(time), ..Default::default() };
ctx.run_ui(input, |ui| {
preview(ui, "Source Sans 3");
preview(ui, "Inter");
})
.drop_without_applying_deltas();
ctx.data(|d| {
assert!(d.get_temp::<TextureHandle>(sans_id).is_some());
if time == 1.0 {
assert!(d.get_temp::<TextureHandle>(inter_id).is_none(), "over-budget entry should defer");
assert_eq!(d.get_temp::<(f64, u32)>(budget_id), Some((1.0, 13)));
} else {
assert!(d.get_temp::<TextureHandle>(inter_id).is_some(), "deferred entry should render next frame");
assert_eq!(d.get_temp::<(f64, u32)>(budget_id), Some((2.0, 1)), "cached entries should not consume budget");
}
});
}
tx.send(()).unwrap();
});
rx.recv_timeout(std::time::Duration::from_secs(30)).expect("font menu preview rendering hung or failed");
worker.join().unwrap();
}
}
+36 -30
View File
@@ -29,14 +29,15 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
}
for tab in tabs {
let contextual = tab.starts_with("Table ");
let w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(tab.to_string(), medium(12.5), t.text).size().x) + 18.0;
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());
let active = app.ui.tab == tab && !app.ui.backstage;
if resp.hovered() && !active {
ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover);
}
let color = if contextual || active { t.accent_text } else { t.text };
ui.painter().text(r.center(), Align2::CENTER_CENTER, tab, if active { semibold(12.5) } else { medium(12.5) }, color);
ui.painter().text(r.center(), Align2::CENTER_CENTER, shown, if active { semibold(12.5) } else { medium(12.5) }, color);
if active {
let u = Rect::from_center_size(pos2(r.center().x, r.max.y - 2.0), vec2(w - 16.0, 3.0));
ui.painter().rect_filled(u, 2.0, t.accent);
@@ -50,7 +51,9 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// Share, Editing mode, Comments.
let (r, resp) = ui.allocate_exact_size(vec2(74.0, 24.0), Sense::click());
let share = tl!("Share");
let share_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(share.to_string(), medium(12.0), t.text).size().x);
let (r, resp) = ui.allocate_exact_size(vec2((share_w + 36.0).max(74.0), 24.0), Sense::click());
ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.accent_text } else { t.accent });
icons::paint(
ui.painter(),
@@ -59,24 +62,27 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
egui::Color32::WHITE,
egui::Color32::WHITE,
);
ui.painter().text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, "Share", medium(12.0), egui::Color32::WHITE);
if resp.on_hover_text("Export a copy to share (PDF, Word document)").clicked() {
ui.painter().text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, share, medium(12.0), egui::Color32::WHITE);
if resp.on_hover_text(tl!("Export a copy to share (PDF, Word document)")).clicked() {
let _ = app.run("ui.backstage", json!({"value": true, "page": "export"}));
}
ui.add_space(6.0);
let track = app.session.doc.settings.track_changes;
ui.menu_button(egui::RichText::new(if track { "✎ Reviewing ▾" } else { "✎ Editing ▾" }).font(regular(12.0)), |ui| {
if ui.selectable_label(!track, "Editing — edit the document directly").clicked() {
let _ = app.run("review.trackChanges", json!({"value": false}));
ui.close();
}
if ui.selectable_label(track, "Reviewing — edits become suggestions").clicked() {
let _ = app.run("review.trackChanges", json!({"value": true}));
ui.close();
}
});
ui.menu_button(
egui::RichText::new(format!("✎ {} ▾", if track { tl!("Reviewing") } else { tl!("Editing") })).font(regular(12.0)),
|ui| {
if ui.selectable_label(!track, tl!(tl!("Editing — edit the document directly"))).clicked() {
let _ = app.run("review.trackChanges", json!({"value": false}));
ui.close();
}
if ui.selectable_label(track, tl!(tl!("Reviewing — edits become suggestions"))).clicked() {
let _ = app.run("review.trackChanges", json!({"value": true}));
ui.close();
}
},
);
ui.add_space(4.0);
if ui.button(egui::RichText::new("💬 Comments").font(regular(12.0))).clicked() {
if ui.button(egui::RichText::new(format!("💬 {}", tl!("Comments"))).font(regular(12.0))).clicked() {
let _ = app.run("view.commentsPane", json!({}));
}
});
@@ -124,7 +130,7 @@ fn stack(ui: &mut Ui, add: impl FnOnce(&mut Ui)) {
fn mi(ui: &mut Ui, app: &mut WordApp, label: &str, id: &str, params: Value) {
let sc = crate::widgets::shortcut_text(app, id);
let enabled = crate::widgets::enabled(app, id);
let resp = ui.add_enabled(enabled, egui::Button::new(label).shortcut_text(sc).min_size(vec2(200.0, 0.0)));
let resp = ui.add_enabled(enabled, egui::Button::new(tl!(label)).shortcut_text(sc).min_size(vec2(200.0, 0.0)));
if resp.clicked() {
let _ = app.run(id, params);
ui.close();
@@ -252,7 +258,7 @@ fn home(app: &mut WordApp, ui: &mut Ui) {
stack(ui, |ui| {
crate::widgets::row(ui, |ui| {
split(ui, app, "bullets", "Bullets", "para.bullets", json!({}), false, None, |ui, app| {
ui.label(egui::RichText::new("Bullet Library").small().weak());
ui.label(egui::RichText::new(tl!("Bullet Library")).small().weak());
ui.horizontal(|ui| {
for c in ["•", "○", "▪", "◆", "➢", "✓", "–"] {
if ui.button(egui::RichText::new(c).size(16.0)).clicked() {
@@ -377,7 +383,7 @@ fn insert(app: &mut WordApp, ui: &mut Ui) {
ui.separator();
mi(ui, app, "Insert Table…", "ui.dialog", json!({"name": "insertTable"}));
mi(ui, app, "Convert Text to Table…", "table.fromText", json!({}));
ui.menu_button("Quick Tables", |ui| {
ui.menu_button(tl!("Quick Tables"), |ui| {
mi(ui, app, "Tabular List", "table.quick", json!({"kind": "tabular"}));
mi(ui, app, "Matrix", "table.quick", json!({"kind": "matrix"}));
mi(ui, app, "Calendar", "table.quick", json!({"kind": "calendar"}));
@@ -597,11 +603,11 @@ fn layout(app: &mut WordApp, ui: &mut Ui) {
});
stack(ui, |ui| {
menu_button(ui, app, "breaks", Some("Breaks"), "Breaks", false, |ui, app| {
ui.label(egui::RichText::new("Page Breaks").strong());
ui.label(egui::RichText::new(tl!("Page Breaks")).strong());
mi(ui, app, "Page", "layout.break", json!({"kind": "page"}));
mi(ui, app, "Column", "layout.break", json!({"kind": "column"}));
mi(ui, app, "Text Wrapping", "layout.break", json!({"kind": "textWrapping"}));
ui.label(egui::RichText::new("Section Breaks").strong());
ui.label(egui::RichText::new(tl!("Section Breaks")).strong());
mi(ui, app, "Next Page", "layout.break", json!({"kind": "nextPage"}));
mi(ui, app, "Continuous", "layout.break", json!({"kind": "continuous"}));
mi(ui, app, "Even Page", "layout.break", json!({"kind": "evenPage"}));
@@ -625,21 +631,21 @@ fn layout(app: &mut WordApp, ui: &mut Ui) {
let rp = app.session.doc.para_at(&app.session.sel.focus).map(|p| app.session.doc.styles.resolve_para(&p.props));
let Some(rp) = rp else { return };
egui::Grid::new("layout_para").spacing(vec2(6.0, 4.0)).show(ui, |ui| {
ui.label(egui::RichText::new("Indent").small().strong());
ui.label(egui::RichText::new(tl!("Indent")).small().strong());
ui.label("");
ui.label(egui::RichText::new("Spacing").small().strong());
ui.label(egui::RichText::new(tl!("Spacing")).small().strong());
ui.end_row();
let mut l = rp.indent_left / 72.0;
let mut b = rp.space_before;
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Left:").small());
ui.label(egui::RichText::new(tl!("Left:")).small());
if ui.add(egui::DragValue::new(&mut l).speed(0.05).range(-11.0..=22.0).suffix("\"").max_decimals(2)).changed() {
let _ = app.run("para.indents", json!({"left": l * 72.0}));
}
});
ui.label("");
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Before:").small());
ui.label(egui::RichText::new(tl!("Before:")).small());
if ui.add(egui::DragValue::new(&mut b).speed(1.0).range(0.0..=1584.0).suffix(" pt")).changed() {
let _ = app.run("para.spacing", json!({"before": b}));
}
@@ -648,14 +654,14 @@ fn layout(app: &mut WordApp, ui: &mut Ui) {
let mut r = rp.indent_right / 72.0;
let mut a = rp.space_after;
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Right:").small());
ui.label(egui::RichText::new(tl!("Right:")).small());
if ui.add(egui::DragValue::new(&mut r).speed(0.05).range(-11.0..=22.0).suffix("\"").max_decimals(2)).changed() {
let _ = app.run("para.indents", json!({"right": r * 72.0}));
}
});
ui.label("");
ui.horizontal(|ui| {
ui.label(egui::RichText::new("After:").small());
ui.label(egui::RichText::new(tl!("After:")).small());
if ui.add(egui::DragValue::new(&mut a).speed(1.0).range(0.0..=1584.0).suffix(" pt")).changed() {
let _ = app.run("para.spacing", json!({"after": a}));
}
@@ -874,15 +880,15 @@ fn view(app: &mut WordApp, ui: &mut Ui) {
group(ui, "Show", None, app, |ui, app| {
stack(ui, |ui| {
let mut r = v.ruler;
if ui.checkbox(&mut r, "Ruler").changed() {
if ui.checkbox(&mut r, tl!("Ruler")).changed() {
let _ = app.run("view.ruler", json!({"value": r}));
}
let mut g = v.gridlines;
if ui.checkbox(&mut g, "Gridlines").changed() {
if ui.checkbox(&mut g, tl!("Gridlines")).changed() {
let _ = app.run("view.gridlines", json!({"value": g}));
}
let mut n = v.nav_pane;
if ui.checkbox(&mut n, "Navigation Pane").changed() {
if ui.checkbox(&mut n, tl!("Navigation Pane")).changed() {
let _ = app.run("view.navigationPane", json!({"value": n}));
}
});
+19 -5
View File
@@ -123,8 +123,20 @@ impl Tokens {
}
}
/// Install the UI fonts (Inter, JetBrains Mono) and the craft-fonts Japanese UI face if built in.
/// Install the UI fonts (Inter, JetBrains Mono) and the craft-fonts CJK interface faces if built
/// in, Japanese first.
pub fn install_fonts(ctx: &egui::Context) {
install_fonts_for(ctx, false);
}
/// [`install_fonts`] with the CJK fallback order for the interface language: the Chinese face
/// first for Chinese (#8), the Japanese one otherwise. The new fonts apply from the next frame.
pub fn install_fonts_for(ctx: &egui::Context, prefer_hans: bool) {
ctx.set_fonts(font_definitions(prefer_hans));
}
/// The interface fonts; see [`install_fonts_for`].
pub fn font_definitions(prefer_hans: bool) -> FontDefinitions {
let mut fonts = FontDefinitions::default();
let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| {
fonts.font_data.insert(name.into(), Arc::new(FontData::from_static(bytes)));
@@ -143,16 +155,18 @@ pub fn install_fonts(ctx: &egui::Context) {
}
fonts.families.insert(FontFamily::Name("medium".into()), vec!["InterMedium".into(), "Inter".into(), "SourceSans".into()]);
fonts.families.insert(FontFamily::Name("semibold".into()), vec!["InterSemiBold".into(), "Inter".into(), "SourceSans".into()]);
for f in wordcraft_fonts::japanese_ui_fonts(false).into_iter().take(1) {
fonts.font_data.insert("JpUi".into(), Arc::new(FontData::from_static(f.bytes)));
for f in wordcraft_fonts::ui_cjk_fonts(prefer_hans) {
// The same static bytes the document fonts use: one copy in the binary.
let name = format!("{} {}", f.family, f.style);
fonts.font_data.insert(name.clone(), Arc::new(FontData::from_static(f.bytes)));
for fam in [FontFamily::Proportional, FontFamily::Name("medium".into()), FontFamily::Name("semibold".into())] {
if let Some(v) = fonts.families.get_mut(&fam) {
v.push("JpUi".into());
v.push(name.clone());
}
}
}
// Symbols and emoji fall back to egui's defaults (kept in the families).
ctx.set_fonts(fonts);
fonts
}
pub fn medium(size: f32) -> FontId {
+14 -6
View File
@@ -24,9 +24,10 @@ pub fn shortcut_text(app: &WordApp, id: &str) -> String {
}
}
/// `label` is already in the interface language.
fn tooltip(app: &WordApp, resp: Response, label: &str, id: &str) -> Response {
let sc = shortcut_text(app, id);
let desc = app.session.registry.get(id).map(|s| s.location).unwrap_or("");
let desc = app.session.registry.get(id).map(|s| crate::i18n::location(s.location)).unwrap_or_default();
let enabled = app.session.registry.get(id).map(|s| (s.enabled)(&app.session).is_none()).unwrap_or(true);
resp.on_hover_ui(|ui| {
ui.set_max_width(260.0);
@@ -39,7 +40,7 @@ fn tooltip(app: &WordApp, resp: Response, label: &str, id: &str) -> Response {
ui.label(egui::RichText::new(desc).small().weak());
}
if !enabled {
ui.label(egui::RichText::new("Not available right now").small().weak());
ui.label(egui::RichText::new(tl!("Not available right now")).small().weak());
}
})
}
@@ -70,6 +71,7 @@ fn bg(ui: &Ui, r: Rect, resp: &Response, checked: bool, t: &Tokens) {
/// A large ribbon button: 32 px icon over a (possibly two-line) label.
pub fn big(ui: &mut Ui, app: &mut WordApp, icon: &str, label: &str, id: &str, params: Value, menu: bool) -> Response {
let label = tl!(label);
let t = Tokens::get(ui.ctx());
let galley_w =
label.split('\n').map(|l| ui.ctx().fonts_mut(|f| f.layout_no_wrap(l.to_string(), regular(11.5), t.text).size().x)).fold(0.0, f32::max);
@@ -96,6 +98,7 @@ pub fn big(ui: &mut Ui, app: &mut WordApp, icon: &str, label: &str, id: &str, pa
/// A small button: 16 px icon with an optional label; `checked` draws the toggled state.
pub fn small(ui: &mut Ui, app: &mut WordApp, icon: &str, label: Option<&str>, tip: &str, id: &str, params: Value, checked: bool) -> Response {
let (label, tip) = (label.map(|l| tl!(l)), tl!(tip));
let t = Tokens::get(ui.ctx());
let text_w = label.map(|l| ui.ctx().fonts_mut(|f| f.layout_no_wrap(l.to_string(), regular(11.5), t.text).size().x) + 6.0).unwrap_or(0.0);
let (r, resp) = ui.allocate_exact_size(vec2(24.0 + text_w, 22.0), Sense::click());
@@ -126,6 +129,7 @@ pub fn split(
swatch: Option<Color32>,
menu: impl FnOnce(&mut Ui, &mut WordApp),
) {
let tip = tl!(tip);
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(24.0, 22.0), Sense::click());
let (ar, aresp) = ui.allocate_exact_size(vec2(11.0, 22.0), Sense::click());
@@ -157,6 +161,7 @@ pub fn menu_button(
big_btn: bool,
menu: impl FnOnce(&mut Ui, &mut WordApp),
) {
let (label, tip) = (label.map(|l| tl!(l)), tl!(tip));
let t = Tokens::get(ui.ctx());
let resp = if big_btn {
let galley_w = label
@@ -196,6 +201,9 @@ pub fn menu_button(
/// A ribbon group: content, a centred label below and a divider on the right.
pub fn group(ui: &mut Ui, title: &str, launcher: Option<&str>, app: &mut WordApp, add: impl FnOnce(&mut Ui, &mut WordApp)) {
// The English title keys the launcher's id; the drawn title is translated.
let id_title = title;
let title = tl!(title);
let t = Tokens::get(ui.ctx());
let label_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(title.to_string(), regular(11.0), t.group_label).size().x);
let start = ui.cursor().min;
@@ -211,12 +219,12 @@ pub fn group(ui: &mut Ui, title: &str, launcher: Option<&str>, app: &mut WordApp
ui.painter().text(pos2(r.center().x, r.max.y - 8.0), Align2::CENTER_CENTER, title, regular(11.0), t.group_label);
if let Some(cmd) = launcher {
let lr = Rect::from_center_size(pos2(r.max.x - 6.0, r.max.y - 8.0), vec2(11.0, 11.0));
let resp = ui.interact(lr, ui.id().with(("launch", title)), Sense::click());
let resp = ui.interact(lr, ui.id().with(("launch", id_title)), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(lr.expand(2.0), 2.0, t.hover);
}
icons::paint(ui.painter(), lr, "launcher", t.text_dim, t.accent);
if resp.on_hover_text(format!("{title} settings")).clicked() {
if resp.on_hover_text(crate::i18n::fmt(tl!("{group} settings"), &[("group", title)])).clicked() {
let _ = app.run(cmd, serde_json::json!({}));
}
}
@@ -237,7 +245,7 @@ pub fn row(ui: &mut Ui, add: impl FnOnce(&mut Ui)) {
/// Word's standard colour grid (theme colours + tints, standard colours). Returns a picked hex.
pub fn color_grid(ui: &mut Ui, theme: &[wordcraft_doc::Rgb]) -> Option<String> {
let mut picked = None;
ui.label(egui::RichText::new("Theme Colors").small().weak());
ui.label(egui::RichText::new(tl!("Theme Colors")).small().weak());
let base: Vec<wordcraft_doc::Rgb> = theme.iter().take(10).copied().collect();
let tint = |c: wordcraft_doc::Rgb, k: f32| {
let f = |v: u8| if k >= 0.0 { (v as f32 + (255.0 - v as f32) * k) as u8 } else { (v as f32 * (1.0 + k)) as u8 };
@@ -257,7 +265,7 @@ pub fn color_grid(ui: &mut Ui, theme: &[wordcraft_doc::Rgb]) -> Option<String> {
});
}
ui.add_space(4.0);
ui.label(egui::RichText::new("Standard Colors").small().weak());
ui.label(egui::RichText::new(tl!("Standard Colors")).small().weak());
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing = vec2(3.0, 0.0);
for hex in ["C00000", "FF0000", "FFC000", "FFFF00", "92D050", "00B050", "00B0F0", "0070C0", "002060", "7030A0"] {
+188
View File
@@ -0,0 +1,188 @@
//! The main window's size, position and maximized state, saved with the UI preferences so the
//! next launch opens the window where the user left it (#23).
//!
//! Sizes and positions are in egui points: what [`egui::ViewportInfo`] reports and what
//! [`egui::ViewportBuilder`] takes.
use serde::{Deserialize, Serialize};
/// The smallest window restored: the desktop app's minimum inner size.
pub const MIN_SIZE: egui::Vec2 = egui::vec2(760.0, 480.0);
/// Larger saved sizes are corrupt. eframe also shrinks the start size to the largest monitor.
const MAX_SIZE: egui::Vec2 = egui::vec2(16384.0, 16384.0);
/// Saved positions farther from the origin than this are corrupt.
const MAX_COORD: f32 = 32768.0;
/// The window as it was when the app last closed.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct WindowGeometry {
/// Top-left corner: of the frame, or of the content on macOS (where winit places a new
/// window by it). `None` where the platform doesn't report it (Wayland).
pub position: Option<[f32; 2]>,
/// Content size of the un-maximized window.
pub width: f32,
pub height: f32,
pub maximized: bool,
/// Size of the monitor the window was on, to notice when that monitor is gone.
pub monitor: Option<[f32; 2]>,
}
impl WindowGeometry {
/// The geometry to save, given the last saved one and the window as it is now. While the
/// window is minimized, full screen or maximized, the last un-maximized size and position are
/// kept, so un-maximizing after the next launch returns to them.
pub fn track(prev: Option<Self>, info: &egui::ViewportInfo, content_size: egui::Vec2) -> Option<Self> {
if info.minimized == Some(true) || info.fullscreen == Some(true) {
return prev;
}
// egui reads the maximized state on macOS only when the window opens (egui#3494), so a
// window that opened zoomed would stay "maximized" for good. There the zoomed frame is
// saved like any other size instead.
let maximized = info.maximized == Some(true) && !cfg!(target_os = "macos");
if maximized && let Some(prev) = prev {
return Some(Self { maximized, ..prev });
}
let rect = if cfg!(target_os = "macos") { info.inner_rect } else { info.outer_rect };
let size = info.inner_rect.map_or(content_size, |r| r.size());
let position = rect.map(|r| [r.min.x, r.min.y]);
let monitor = info.monitor_size.map(|s| [s.x, s.y]);
Self { position, width: size.x, height: size.y, maximized, monitor }.sanitized().or(prev)
}
/// `None` for a size that isn't positive and finite. Otherwise the size is clamped to
/// [`MIN_SIZE`]..=16384, and a position or monitor size that can't be real is dropped.
pub fn sanitized(self) -> Option<Self> {
let size = egui::vec2(self.width, self.height);
if !size.is_finite() || size.x <= 0.0 || size.y <= 0.0 {
return None;
}
let size = size.clamp(MIN_SIZE, MAX_SIZE);
let position = self.position.filter(|p| p.iter().all(|c| c.is_finite() && c.abs() <= MAX_COORD));
let monitor = self.monitor.filter(|m| m.iter().all(|c| c.is_finite() && *c > 0.0 && *c <= MAX_SIZE.x));
Some(Self { position, width: size.x, height: size.y, maximized: self.maximized, monitor })
}
/// Open the window at this size, position and maximized state.
pub fn apply(self, builder: egui::ViewportBuilder) -> egui::ViewportBuilder {
let builder = builder.with_inner_size([self.width, self.height]).with_maximized(self.maximized);
match self.position {
Some(p) => builder.with_position(p),
None => builder,
}
}
/// After a restore: where to move the window when the monitor it now reports differs from the
/// one it was saved on (an external monitor was unplugged, say), so a window restored to a
/// position no monitor covers any more doesn't open off screen. Centred on the primary
/// monitor, whose top-left corner is the origin. A maximized window fills whichever monitor
/// the system picks, so it stays put.
pub fn rescue_position(&self, info: &egui::ViewportInfo) -> Option<egui::Pos2> {
if self.maximized || self.position.is_none() {
return None;
}
let saved = egui::Vec2::from(self.monitor?);
let now = info.monitor_size.filter(|s| s.is_finite() && s.x > 0.0 && s.y > 0.0)?;
if (now - saved).length() <= 1.0 {
return None;
}
let free = now - egui::vec2(self.width, self.height);
Some(egui::pos2((free.x / 2.0).max(0.0), (free.y / 2.0).max(0.0)))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn info(rect: egui::Rect, monitor: egui::Vec2) -> egui::ViewportInfo {
egui::ViewportInfo { inner_rect: Some(rect), outer_rect: Some(rect), monitor_size: Some(monitor), ..Default::default() }
}
fn saved() -> WindowGeometry {
WindowGeometry { position: Some([100.0, 50.0]), width: 1200.0, height: 800.0, maximized: false, monitor: Some([1920.0, 1080.0]) }
}
#[test]
fn records_the_window_and_restores_it() {
let rect = egui::Rect::from_min_size(egui::pos2(100.0, 50.0), egui::vec2(1200.0, 800.0));
let g = WindowGeometry::track(None, &info(rect, egui::vec2(1920.0, 1080.0)), egui::Vec2::ZERO);
assert_eq!(g, Some(saved()));
let b = saved().apply(egui::ViewportBuilder::default());
assert_eq!(b.inner_size, Some(egui::vec2(1200.0, 800.0)));
assert_eq!(b.position, Some(egui::pos2(100.0, 50.0)));
assert_eq!(b.maximized, Some(false));
}
#[test]
fn maximized_keeps_the_normal_size_and_position() {
let full = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1920.0, 1040.0));
let mut i = info(full, egui::vec2(1920.0, 1080.0));
i.maximized = Some(true);
let g = WindowGeometry::track(Some(saved()), &i, egui::Vec2::ZERO);
if cfg!(target_os = "macos") {
// The zoomed frame is saved as the size; the window never reopens "maximized".
assert_eq!(g.map(|g| (g.position, g.width, g.height, g.maximized)), Some((Some([0.0, 0.0]), 1920.0, 1040.0, false)));
} else {
assert_eq!(g, Some(WindowGeometry { maximized: true, ..saved() }));
}
// Minimized or full screen: nothing changes.
i.maximized = Some(false);
i.minimized = Some(true);
assert_eq!(WindowGeometry::track(Some(saved()), &i, egui::Vec2::ZERO), Some(saved()));
i.minimized = None;
i.fullscreen = Some(true);
assert_eq!(WindowGeometry::track(Some(saved()), &i, egui::Vec2::ZERO), Some(saved()));
}
#[test]
fn without_a_position_the_content_size_is_kept() {
// Wayland reports neither rect.
let i = egui::ViewportInfo::default();
let g = WindowGeometry::track(None, &i, egui::vec2(1000.0, 700.0));
assert_eq!(g, Some(WindowGeometry { position: None, width: 1000.0, height: 700.0, maximized: false, monitor: None }));
assert_eq!(g.map(|g| g.apply(egui::ViewportBuilder::default()).position), Some(None));
}
#[test]
fn insane_values_are_clamped_or_dropped() {
let zero = WindowGeometry { width: 0.0, height: 800.0, ..saved() };
assert_eq!(zero.sanitized(), None);
assert_eq!(WindowGeometry::default().sanitized(), None);
let nan = WindowGeometry { width: f32::NAN, ..saved() };
assert_eq!(nan.sanitized(), None);
let tiny = WindowGeometry { width: 10.0, height: 1.0e9, ..saved() }.sanitized();
assert_eq!(tiny.map(|g| (g.width, g.height)), Some((MIN_SIZE.x, 16384.0)));
let far = WindowGeometry { position: Some([1.0e7, 0.0]), monitor: Some([f32::INFINITY, 1080.0]), ..saved() }.sanitized();
assert_eq!(far.map(|g| (g.position, g.monitor)), Some((None, None)));
assert_eq!(saved().sanitized(), Some(saved()));
}
#[test]
fn a_window_whose_monitor_is_gone_is_moved_on_screen() {
let on = egui::Rect::from_min_size(egui::pos2(2000.0, 50.0), egui::vec2(1200.0, 800.0));
// Same monitor: stays where it was.
assert_eq!(saved().rescue_position(&info(on, egui::vec2(1920.0, 1080.0))), None);
// A different (smaller) monitor now: centred on it.
assert_eq!(saved().rescue_position(&info(on, egui::vec2(1600.0, 900.0))), Some(egui::pos2(200.0, 50.0)));
// Larger than that monitor: its top-left corner.
assert_eq!(saved().rescue_position(&info(on, egui::vec2(1024.0, 768.0))), Some(egui::Pos2::ZERO));
// Nothing to compare, or no saved position: no move.
assert_eq!(WindowGeometry { monitor: None, ..saved() }.rescue_position(&info(on, egui::vec2(1600.0, 900.0))), None);
assert_eq!(WindowGeometry { position: None, ..saved() }.rescue_position(&info(on, egui::vec2(1600.0, 900.0))), None);
assert_eq!(WindowGeometry { maximized: true, ..saved() }.rescue_position(&info(on, egui::vec2(1600.0, 900.0))), None);
assert_eq!(saved().rescue_position(&egui::ViewportInfo::default()), None);
}
#[test]
fn survives_the_prefs_round_trip() {
let ui = crate::UiState { window: Some(saved()), ..Default::default() };
let json = serde_json::to_string(&ui).unwrap();
let back: crate::UiState = serde_json::from_str(&json).unwrap();
assert_eq!(back.window, Some(saved()));
// Prefs written before #23 have no window.
let old: crate::UiState = serde_json::from_str(r#"{"tab":"Insert"}"#).unwrap();
assert_eq!(old.window, None);
assert_eq!(old.tab, "Insert");
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ document unchanged.
| Method | Params | Result |
|---|---|---|
| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`…) |
| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"}`)…) |
| `engine.commands` | — | every command: id, label, location, shortcut, params, enabled |
| `document.inspect` | `text?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages |
| `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, perf |
+44
View File
@@ -0,0 +1,44 @@
# macOS DMG window
What Finder shows when the DMG opens: a 660 × 400 pt background with the app icon and the
`Applications` link side by side. `package.sh` copies these files into the image; nothing here is
generated at build time, so the DMG still builds with `hdiutil makehybrid` (no mounted device, no
Finder scripting on CI, nothing extra installed in the signing job).
| File | What |
|---|---|
| `background.svg` | Source of the background: the app icon (`assets/app-icon/wordcraft.svg`, linked, not copied) cropped as a cover on the WordCraft colour field (`#3b5bdb`), Ink and Paper. Its text (Inter, JetBrains Mono) is outlined, so rendering it needs no fonts. No paper grain: noise doesn't compress, and this keeps `background.tiff` small. |
| `background.tiff` | The background at 1x (660 × 400 px, 72 dpi) and 2x (1320 × 800 px, 144 dpi) in one HiDPI TIFF (16-colour palette, Deflate, sRGB). Goes to `.background/background.tiff`. |
| `dmg-layout.DS_Store` | Finder's view settings for the volume: window size, icon size 128, WordCraft.app at (326, 205), `Applications` at (574, 205), and the background. Goes to `.DS_Store` in the image; named so it isn't mistaken for (or ignored like) a Finder-generated `.DS_Store`. |
| `generate.py` | Writes `background.tiff` and `dmg-layout.DS_Store` from the SVG and the layout above. |
## The volume name has no version
The mounted volume is called `WordCraft`, not `WordCraft <version>`. The layout points at the background
through an alias that includes the volume name, and Finder resolves it by that name: with a
versioned name the window keeps its size and icon positions but shows no background (tested).
The DMG file name (`wordcraft-<version>-macos-<arch>.dmg`) still carries the version.
## Rules
- **Finder draws the icon labels in black in light and dark mode** when a window has a background,
so the area under both icons stays light (Paper).
- **Nothing goes inside the icon boxes:** artwork keeps 10 pt clear of each 128 pt icon box and of
the label strip under it.
## Regenerate
Edit `background.svg` (or the layout constants in `generate.py`), then run, on any OS:
```sh
pip install 'pillow>=12' ds_store==1.3.3 mac_alias==2.2.3
python3 packaging/macos/dmg/generate.py # needs resvg on PATH
```
Both outputs are byte-for-byte reproducible and carry nothing from the machine that ran it: the
SVG renders without any fonts, and `dmg-layout.DS_Store` is written from scratch, its background
alias holding only the volume name and `/.background/background.tiff`. If you add text to the SVG,
outline it (`usvg` from resvg converts text to paths). The window is 660 × 432 with Finder's 32 pt
title bar; the content area is 660 × 400.
This follows VectorCraft's `packaging/macos/dmg/` (storytold/vectorcraft#493).
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.
Binary file not shown.
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""Regenerate background.tiff and dmg-layout.DS_Store for the macOS DMG window (see README.md here).
Needs resvg on PATH and:
pip install 'pillow>=12' ds_store==1.3.3 mac_alias==2.2.3
Runs on any OS, no Mac needed. Both outputs come from this script alone, so they carry nothing
from the machine that made them: no local paths, user or disk names, dates or volume UUIDs.
python3 packaging/macos/dmg/generate.py
"""
import datetime
import os
import shutil
import subprocess
import sys
import tempfile
from ds_store import DSStore
from mac_alias import Alias, TargetInfo, VolumeInfo
from PIL import Image, ImageCms
HERE = os.path.dirname(os.path.abspath(__file__))
# Keep in sync with package.sh (volume name) and README.md (layout).
VOLUME = "WordCraft"
WIDTH, HEIGHT = 660, 400 # window content, pt; the background's 1x size
TITLE_BAR = 32
ICON_SIZE = 128
PALETTE = 16 # colours in background.tiff
ICONS = {"WordCraft.app": (326, 205), "Applications": (574, 205)}
# A fixed date for the alias and the colour profile. Finder never matches it against the image
# (each build is a new volume): it finds the background by volume name and path.
FIXED_DATE = datetime.datetime(2026, 10, 8, tzinfo=datetime.timezone.utc)
def render(svg, width, out):
# The SVG's text is already outlined and no fonts are loaded, so every machine draws the
# same pixels.
subprocess.run(
["resvg", "--skip-system-fonts", "-w", str(width), svg, out],
check=True,
)
with Image.open(out) as im:
return im.convert("RGB")
def quantize(im):
# No dithering: dither noise compresses badly and the artwork has flat fills.
return im.quantize(PALETTE, method=Image.Quantize.FASTOCTREE, dither=Image.Dither.NONE)
def srgb_profile():
# LittleCMS's built-in sRGB ("No copyright, use freely"), with its creation date fixed.
icc = bytearray(ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes())
d = FIXED_DATE
icc[24:36] = b"".join(v.to_bytes(2, "big") for v in (d.year, d.month, d.day, 0, 0, 0))
if icc[84:100] != bytes(16):
sys.exit("error: the sRGB profile has an ID; recompute it after fixing the date")
return bytes(icc)
def write_tiff(svg, out):
"""1x (72 dpi) and 2x (144 dpi) in one TIFF, as `tiffutil -cathidpicheck` writes them."""
with tempfile.TemporaryDirectory() as tmp:
one, two = (
quantize(render(svg, WIDTH * s, os.path.join(tmp, f"{s}x.png"))) for s in (1, 2)
)
two.encoderinfo = {"dpi": (144, 144)} # the rest as the first page
# Opaque 16-colour palette, Deflate: the artwork is three inks plus antialiasing, and a palette
# keeps the file under ~100 KB (repository assets must stay small). As PhotoCraft's.
one.save(
out,
"TIFF",
save_all=True,
append_images=[two],
compression="tiff_adobe_deflate", # brand-ok: Pillow's name for TIFF compression 8 (Deflate)
dpi=(72, 72),
icc_profile=srgb_profile(),
)
with Image.open(out) as im:
im.seek(1)
if im.info.get("dpi") != (144, 144):
sys.exit("error: the 2x page isn't 144 dpi (needs Pillow 12 or later)")
strips = []
for page in (0, 1):
im.seek(page)
strips += zip(im.tag_v2[273], im.tag_v2[279]) # StripOffsets, StripByteCounts
# TIFF keeps directories and tag data on even offsets. When a strip ends on an odd one, libtiff
# leaves the padding byte after it uninitialised, so the file changed from run to run with
# identical pixels. Zero it: nothing refers to that byte.
starts = {o for o, _ in strips}
with open(out, "r+b") as f:
for o, n in strips:
if (o + n) % 2 and o + n not in starts:
f.seek(o + n)
f.write(bytes(1))
def background_alias():
# What Finder stores for /.background/background.tiff on the volume, minus anything about the
# machine (dmgbuild writes the same kind of alias from a mounted image). Disk type 5 (ejectable),
# the volume flags and the folder/file ids (25, 26) are what Finder wrote on a test image; the ids
# are only hints, as the volume's creation date.
volume = VolumeInfo(VOLUME, FIXED_DATE, b"H+", 5, 0x0D02, b"\0\0")
volume.posix_path = f"/Volumes/{VOLUME}"
target = TargetInfo(0, "background.tiff", 25, 26, FIXED_DATE, b"\0\0\0\0", b"\0\0\0\0")
target.folder_name = ".background"
target.cnid_path = [25]
target.carbon_path = f"{VOLUME}:.background:\0background.tiff"
target.posix_path = "/.background/background.tiff"
return Alias(volume=volume, target=target).to_bytes()
def write_ds_store(out):
if os.path.exists(out):
os.remove(out)
with DSStore.open(out, "w+") as d:
d["."]["bwsp"] = {
"ShowStatusBar": False,
"ShowToolbar": False,
"ShowTabView": False,
"ContainerShowSidebar": False,
"ShowSidebar": False,
"WindowBounds": f"{{{{200, 528}}, {{{WIDTH}, {HEIGHT + TITLE_BAR}}}}}",
}
d["."]["icvp"] = {
"viewOptionsVersion": 1,
"backgroundType": 2,
"backgroundImageAlias": background_alias(),
"backgroundColorRed": 1.0,
"backgroundColorGreen": 1.0,
"backgroundColorBlue": 1.0,
"gridOffsetX": 0.0,
"gridOffsetY": 0.0,
"gridSpacing": 100.0,
"arrangeBy": "none",
"showIconPreview": True,
"showItemInfo": False,
"labelOnBottom": True,
"textSize": 16.0,
"iconSize": float(ICON_SIZE),
}
d["."]["vSrn"] = ("long", 1)
for name, pos in ICONS.items():
d[name]["Iloc"] = pos
def main():
if not shutil.which("resvg"):
sys.exit("error: resvg not found (brew install resvg / cargo install resvg --locked)")
write_tiff(os.path.join(HERE, "background.svg"), os.path.join(HERE, "background.tiff"))
write_ds_store(os.path.join(HERE, "dmg-layout.DS_Store"))
if __name__ == "__main__":
main()
+6 -1
View File
@@ -131,10 +131,15 @@ STAGE="$WORK/dmg"
mkdir -p "$STAGE"
ditto "$APP" "$STAGE/WordCraft.app"
ln -s /Applications "$STAGE/Applications"
# Finder window layout: background, icon size and positions (packaging/macos/dmg/README.md).
mkdir -p "$STAGE/.background"
cp "$HERE/dmg/background.tiff" "$STAGE/.background/background.tiff"
cp "$HERE/dmg/dmg-layout.DS_Store" "$STAGE/.DS_Store"
rm -f "$DMG" "$WORK/raw.dmg"
# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`,
# which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions.
hdiutil makehybrid -hfs -hfs-volume-name "WordCraft $VERSION" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
# The volume name has no version: .DS_Store finds the background through an alias that includes it.
hdiutil makehybrid -hfs -hfs-volume-name "WordCraft" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG"
rm -f "$WORK/raw.dmg"
sign "$DMG"
+8 -5
View File
@@ -4,16 +4,19 @@
wix build wordcraft.wxs -arch x64 -d Version=1.2.3 -d BinDir=... -d IconPath=... -o out.msi
Per-machine install into Program Files, a Start Menu shortcut, `.pcraft` owned by WordCraft,
and WordCraft offered under "Open with" for PSD/PSB and the image formats it reads (without
taking over the user's default app for them).
Per-machine install into Program Files with a Start Menu shortcut.
UpgradeCode and the component GUIDs below belong to WordCraft alone. Never copy them from (or
into) a sibling Craft app: Windows Installer treats every package sharing an UpgradeCode as one
product, so a shared code makes one app's installer refuse to install ("a newer version is
already installed") or silently uninstall the other. packaging-lint.yml checks this.
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Package
Name="WordCraft"
Manufacturer="Learning Machines LLC"
Version="$(var.Version)"
UpgradeCode="49E87D30-BB68-4896-AA1D-F45C833CFC3C"
UpgradeCode="075497F9-E861-45D0-9C7F-A74F8C34E6BA"
Scope="perMachine"
Compressed="yes"
Language="1033">
@@ -42,7 +45,7 @@
<Fragment>
<ComponentGroup Id="WordcraftFiles" Directory="INSTALLFOLDER">
<Component Id="WordcraftApp" Guid="DE018EF3-EE87-4E36-9F76-0B7B060A195E">
<Component Id="WordcraftApp" Guid="855DCEC6-9285-444F-9382-9654B83B34F6">
<File Id="WordcraftExe" Source="$(var.BinDir)\wordcraft.exe" KeyPath="yes">
<Shortcut Id="StartMenuShortcut" Directory="ProgramMenuFolder" Name="WordCraft"
Description="WordCraft word processor" WorkingDirectory="INSTALLFOLDER" Advertise="yes"