mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-25 02:47:33 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1caf22a0b | ||
|
|
d7d9702bda | ||
|
|
9b6f42dd5f | ||
|
|
09f6b37761 | ||
|
|
78369fd2e9 | ||
|
|
d646d38921 | ||
|
|
8588fb908a | ||
|
|
f2a8420679 | ||
|
|
0edb87e3da | ||
|
|
5a94aa77d9 | ||
|
|
3f166a94f5 | ||
|
|
61e2152250 | ||
|
|
eec54c635b | ||
|
|
7ab2bdb1f8 | ||
|
|
daf9a99eb2 | ||
|
|
810e176303 |
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(¬e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(×tamp(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
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
})?;
|
||||
|
||||
@@ -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))?;
|
||||
|
||||
@@ -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.") {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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!({}));
|
||||
|
||||
@@ -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!({}));
|
||||
}
|
||||
|
||||
@@ -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()],
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
@@ -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
@@ -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, ¶ms) {
|
||||
@@ -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
@@ -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}));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user