M0.9–M0.12: CLI, MCP server, xtask (layers/assets/parity/ci), icon placeholder, AGENTS/ATTRIBUTION/NOTICE, protocol docs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-05 23:21:35 -04:00
co-authored by Claude Opus 5.5
parent 98bbfbed4e
commit 435194f7c2
40 changed files with 1612 additions and 151 deletions
+62
View File
@@ -0,0 +1,62 @@
# WordCraft — instructions for agents
WordCraft is a clean-room, open-source, Rust-native word processor targeting Microsoft Word parity — and going further on speed, openness and agent control. It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../printcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign). Shared rules and learnings: [`storytold/craftrules`](https://github.com/storytold/craftrules) (`../../craftrules`) — read its `AGENTS.md`.
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. `plan/` is gitignored (local only); if it's missing, start from `ROADMAP.md` and `docs/parity.md`.
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §5). Don't stop to ask unless a decision is genuinely the owner's (licensing, publishing, pushing to new remotes, secrets).
## Never crash
People trust WordCraft with their writing; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it. Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature returns an error or is disabled; it never panics.
- **Input-derived numbers are hostile** (files, commands, MCP/control params): `get()` instead of `[i]`, slice strings only at char boundaries, checked/saturating arithmetic, clamp sizes, cap allocations, bound recursion.
- **Last-resort guard:** `Session::run` turns an escaped panic into an error and restores the document. It's a safety net, not a licence.
- **Prove it:** every crash fix lands with a regression test. `crates/engine/src/tests.rs::hostile_params_never_panic` runs every command with junk params — keep it green.
- Production crate roots carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; new crates start with it. Tests may `unwrap` (`clippy.toml`).
## Assets: absolutely no Microsoft (or Adobe/Avid/Autodesk) iconography or images
**This rule is non-negotiable.** Breaking it is the worst mistake you can make in this repo.
- Never copy, trace, screenshot-crop or recreate icons, images, templates, themes, clip art, fonts, cursors, sounds or any other asset from Microsoft Word/Office or any Microsoft, Adobe, Avid or Autodesk product. Not even "temporarily".
- Every asset must be **original** (drawn in code — prefer this: `crates/ui-egui/src/icons.rs`), **public domain / CC0**, **OSI open source**, or **Creative Commons that allows redistribution**, or licensed open source by a contributor who made it.
- **Every asset file has a row in [`ATTRIBUTION.md`](ATTRIBUTION.md)** (author, source, licence). `cargo xtask assets` (part of `cargo xtask ci`) fails otherwise. Add the row in the same commit as the file.
- Fonts: new font files go to [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), never this repo; it's an optional build input (`CRAFT_FONTS_DIR`). Documents asking for proprietary fonts (Calibri, Aptos, Cambria…) are rendered with installed system fonts or metric-compatible open substitutes (`crates/fonts/src/word.rs`) — never bundle proprietary fonts.
- Templates, sample documents and themes are our own text and colours (`crates/engine/src/sample.rs`, `cmd/design.rs`). Demo images are public domain, with sources recorded.
## Clean room
- Microsoft Word is installed on the dev machine and may be **observed** black-box: run it, use its UI with synthetic documents, take screenshots **of its window only** (by window id — the machine runs other work; never capture the whole desktop) stored only under `plan/word/screenshots/` (gitignored, never committed).
- Never read, disassemble or copy anything inside the Word bundle (file names/listings only). Never commit files produced by Word. Never copy Microsoft wording beyond feature names.
- File formats come from public specs: ECMA-376 / ISO 29500 (OOXML), the published RTF spec, OASIS ODF 1.3. Never copy GPL/LGPL/AGPL code (LibreOffice, AbiWord, pandoc…).
## Architecture (see `plan/architecture.md`)
| Layer | Crate | Job |
|---|---|---|
| L0 | `geom` | units, rects, measurement parsing |
| L1 | `doc`, `fonts` | document model + edit primitives; font DB, shaping, Word font substitution |
| L2 | `layout`, `docx`, `formats` | line breaking, pagination, tables, headers/footers, hit testing; OOXML I/O; TXT/MD/HTML/RTF/ODT |
| L3 | `render`, `pdf`, `proof` | vello_cpu rasteriser; krilla PDF; proofing |
| L4 | `engine` | Session, undo, **command registry**, Word feature catalog, samples, I/O dispatch |
| L5 | `mcp` | MCP server (headless or bridged to the app) |
| L6 | `ui-egui` | Word-style egui front end (swappable) |
| apps | `apps/wordcraft`, `apps/wordcraft-cli`, `apps/wordcraft-web` | binaries |
- **Everything is a command.** User-visible behaviour = a `CommandSpec` in `crates/engine/src/cmd/*.rs` (id, label, ribbon location, shortcut, params doc, `enabled`, `run`) + tests. UI-only commands (`ui.*`) live in `crates/ui-egui/src/lib.rs`. The ribbon, shortcuts, command search, CLI, control channel and MCP all dispatch by id. Programmatic calls never open dialogs.
- **Parity is measured:** `crates/engine/src/catalog.rs` lists Word's ribbon/menu features with command ids. `cargo xtask parity` writes `docs/parity.md`; a test enforces a floor that only rises. Implement missing ids to raise it.
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. The UI is thin and reads `Session` state; colours come from `theme::Tokens`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
## Quality gates
Before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every arc of work that builds, with a task id in the message (`M2.1: line spacing dialog`).
## Running and looking at the app
- `cargo run --release -p wordcraft -- --sample --control 7981` (sample document + control channel).
- Drive it with JSON lines on `127.0.0.1:7981`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"text.insert","params":{"text":"Hello"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`; docs: `docs/control-protocol.md`.
- **Headless window screenshots** (no focus stealing, works with a locked screen): `cargo run --release -p wordcraft-ui-egui --example ui_shot -- script.jsonl` (see the example's header).
- **For UI work, look at the result** (screenshot, read the PNG) and compare with Word side by side.
- CLI: `wordcraft-cli convert in.docx out.pdf`, `wordcraft-cli run --template sample --cmd 'select.text={"text":"Studio"}' --cmd format.bold --save out.docx`, `wordcraft-cli commands`, `wordcraft-cli parity`.
- MCP: `wordcraft-cli mcp` (headless) or `wordcraft-cli mcp --connect 127.0.0.1:7981` (drives the running app). See `docs/mcp.md`.
- Shell gotcha: `mv`/`cp` may be aliased interactive — use `/bin/mv -f` / `/bin/cp -f`.
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
## Roadmap
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a milestone task lands.
+51
View File
@@ -0,0 +1,51 @@
# Asset attribution
Every non-code asset in this repository (fonts, icons, images, example documents) is listed here with its author, source and licence. `cargo xtask assets` (part of `cargo xtask ci`) fails if an asset file is missing from this table.
**Policy (mandatory):** WordCraft contains **no Microsoft, Adobe, Avid or Autodesk iconography, images, templates, themes, clip art, fonts or other assets**. Every asset is original work by WordCraft contributors or third-party material under an open licence (OSI open source, public domain / CC0, or a Creative Commons licence that allows redistribution). Screenshots of other vendors' software are never committed. New font files go to [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not this repo. The one exception is the ArtCraft brand files in `docs/brand/`: ArtCraft trademarks, not open source, used under `docs/brand/LICENSE-brand.txt`.
Generated-in-code art is original and has no file to list: the UI icon set (`crates/ui-egui/src/icons.rs`), the sample documents and templates (`crates/engine/src/sample.rs`), the table and style previews, and theme colours (`crates/engine/src/cmd/design.rs`).
| Asset | Author | Source | Licence | Notes |
|---|---|---|---|---|
| `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 |
| `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 |
| `assets/app-icon/wordcraft.svg` | 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/hicolor/16x16/apps/ai.storyteller.wordcraft.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/hicolor/64x64/apps/ai.storyteller.wordcraft.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/hicolor/32x32/apps/ai.storyteller.wordcraft.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/hicolor/24x24/apps/ai.storyteller.wordcraft.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/hicolor/128x128/apps/ai.storyteller.wordcraft.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/hicolor/256x256/apps/ai.storyteller.wordcraft.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/hicolor/scalable/apps/ai.storyteller.wordcraft.svg` | 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/hicolor/48x48/apps/ai.storyteller.wordcraft.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/hicolor/512x512/apps/ai.storyteller.wordcraft.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/fonts/Inter-Medium.ttf` | Rasmus Andersson / The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | Open-source typeface (Latin UI/document font, carried over from DesignCraft) |
| `assets/fonts/Inter-Regular.ttf` | Rasmus Andersson / The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | Open-source typeface (Latin UI/document font, carried over from DesignCraft) |
| `assets/fonts/Inter-SemiBold.ttf` | Rasmus Andersson / The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | Open-source typeface (Latin UI/document font, carried over from DesignCraft) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | The JetBrains Mono Project Authors | https://github.com/JetBrains/JetBrainsMono | OFL-1.1 (`assets/fonts/OFL-JetBrainsMono.txt`) | Open-source typeface (Latin UI/document font, carried over from DesignCraft) |
| `assets/fonts/OFL-Inter.txt` | (licence text) | upstream project | — | |
| `assets/fonts/OFL-JetBrainsMono.txt` | (licence text) | upstream project | — | |
| `assets/fonts/OFL-SourceSans3.txt` | (licence text) | upstream project | — | |
| `assets/fonts/OFL-SourceSerif4.txt` | (licence text) | upstream project | — | |
| `assets/fonts/SourceSans3-Bold.ttf` | Paul D. Hunt / Frank Grießhammer, Adobe (released as open source) | https://github.com/adobe-fonts/source-sans · https://github.com/adobe-fonts/source-serif | OFL-1.1 (`assets/fonts/OFL-SourceSans3.txt`, `OFL-SourceSerif4.txt`) | Open-source typeface (Latin UI/document font, carried over from DesignCraft) |
| `assets/fonts/SourceSans3-It.ttf` | Paul D. Hunt / Frank Grießhammer, Adobe (released as open source) | https://github.com/adobe-fonts/source-sans · https://github.com/adobe-fonts/source-serif | OFL-1.1 (`assets/fonts/OFL-SourceSans3.txt`, `OFL-SourceSerif4.txt`) | Open-source typeface (Latin UI/document font, carried over from DesignCraft) |
| `assets/fonts/SourceSans3-Regular.ttf` | Paul D. Hunt / Frank Grießhammer, Adobe (released as open source) | https://github.com/adobe-fonts/source-sans · https://github.com/adobe-fonts/source-serif | OFL-1.1 (`assets/fonts/OFL-SourceSans3.txt`, `OFL-SourceSerif4.txt`) | Open-source typeface (Latin UI/document font, carried over from DesignCraft) |
| `assets/fonts/SourceSans3-Semibold.ttf` | Paul D. Hunt / Frank Grießhammer, Adobe (released as open source) | https://github.com/adobe-fonts/source-sans · https://github.com/adobe-fonts/source-serif | OFL-1.1 (`assets/fonts/OFL-SourceSans3.txt`, `OFL-SourceSerif4.txt`) | Open-source typeface (Latin UI/document font, carried over from DesignCraft) |
| `assets/fonts/SourceSerif4-Bold.ttf` | Paul D. Hunt / Frank Grießhammer, Adobe (released as open source) | https://github.com/adobe-fonts/source-sans · https://github.com/adobe-fonts/source-serif | OFL-1.1 (`assets/fonts/OFL-SourceSans3.txt`, `OFL-SourceSerif4.txt`) | Open-source typeface (Latin UI/document font, carried over from DesignCraft) |
| `assets/fonts/SourceSerif4-BoldIt.ttf` | Paul D. Hunt / Frank Grießhammer, Adobe (released as open source) | https://github.com/adobe-fonts/source-sans · https://github.com/adobe-fonts/source-serif | OFL-1.1 (`assets/fonts/OFL-SourceSans3.txt`, `OFL-SourceSerif4.txt`) | Open-source typeface (Latin UI/document font, carried over from DesignCraft) |
| `assets/fonts/SourceSerif4-It.ttf` | Paul D. Hunt / Frank Grießhammer, Adobe (released as open source) | https://github.com/adobe-fonts/source-sans · https://github.com/adobe-fonts/source-serif | OFL-1.1 (`assets/fonts/OFL-SourceSans3.txt`, `OFL-SourceSerif4.txt`) | Open-source typeface (Latin UI/document font, carried over from DesignCraft) |
| `assets/fonts/SourceSerif4-Regular.ttf` | Paul D. Hunt / Frank Grießhammer, Adobe (released as open source) | https://github.com/adobe-fonts/source-sans · https://github.com/adobe-fonts/source-serif | OFL-1.1 (`assets/fonts/OFL-SourceSans3.txt`, `OFL-SourceSerif4.txt`) | Open-source typeface (Latin UI/document font, carried over from DesignCraft) |
| `assets/fonts/SourceSerif4-Semibold.ttf` | Paul D. Hunt / Frank Grießhammer, Adobe (released as open source) | https://github.com/adobe-fonts/source-sans · https://github.com/adobe-fonts/source-serif | OFL-1.1 (`assets/fonts/OFL-SourceSans3.txt`, `OFL-SourceSerif4.txt`) | Open-source typeface (Latin UI/document font, carried over from DesignCraft) |
| `docs/brand/LICENSE-brand.txt` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| craft-fonts (optional build input, not files in this repo): BIZ UDPGothic, BIZ UDMincho, Shippori Mincho | see https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | embedded only when built with `CRAFT_FONTS_DIR` (all official releases) | OFL-1.1 | Japanese UI and document fallback |
+62
View File
@@ -0,0 +1,62 @@
# WordCraft — instructions for agents
WordCraft is a clean-room, open-source, Rust-native word processor targeting Microsoft Word parity — and going further on speed, openness and agent control. It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../printcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign). Shared rules and learnings: [`storytold/craftrules`](https://github.com/storytold/craftrules) (`../../craftrules`) — read its `AGENTS.md`.
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. `plan/` is gitignored (local only); if it's missing, start from `ROADMAP.md` and `docs/parity.md`.
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §5). Don't stop to ask unless a decision is genuinely the owner's (licensing, publishing, pushing to new remotes, secrets).
## Never crash
People trust WordCraft with their writing; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it. Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature returns an error or is disabled; it never panics.
- **Input-derived numbers are hostile** (files, commands, MCP/control params): `get()` instead of `[i]`, slice strings only at char boundaries, checked/saturating arithmetic, clamp sizes, cap allocations, bound recursion.
- **Last-resort guard:** `Session::run` turns an escaped panic into an error and restores the document. It's a safety net, not a licence.
- **Prove it:** every crash fix lands with a regression test. `crates/engine/src/tests.rs::hostile_params_never_panic` runs every command with junk params — keep it green.
- Production crate roots carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; new crates start with it. Tests may `unwrap` (`clippy.toml`).
## Assets: absolutely no Microsoft (or Adobe/Avid/Autodesk) iconography or images
**This rule is non-negotiable.** Breaking it is the worst mistake you can make in this repo.
- Never copy, trace, screenshot-crop or recreate icons, images, templates, themes, clip art, fonts, cursors, sounds or any other asset from Microsoft Word/Office or any Microsoft, Adobe, Avid or Autodesk product. Not even "temporarily".
- Every asset must be **original** (drawn in code — prefer this: `crates/ui-egui/src/icons.rs`), **public domain / CC0**, **OSI open source**, or **Creative Commons that allows redistribution**, or licensed open source by a contributor who made it.
- **Every asset file has a row in [`ATTRIBUTION.md`](ATTRIBUTION.md)** (author, source, licence). `cargo xtask assets` (part of `cargo xtask ci`) fails otherwise. Add the row in the same commit as the file.
- Fonts: new font files go to [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), never this repo; it's an optional build input (`CRAFT_FONTS_DIR`). Documents asking for proprietary fonts (Calibri, Aptos, Cambria…) are rendered with installed system fonts or metric-compatible open substitutes (`crates/fonts/src/word.rs`) — never bundle proprietary fonts.
- Templates, sample documents and themes are our own text and colours (`crates/engine/src/sample.rs`, `cmd/design.rs`). Demo images are public domain, with sources recorded.
## Clean room
- Microsoft Word is installed on the dev machine and may be **observed** black-box: run it, use its UI with synthetic documents, take screenshots **of its window only** (by window id — the machine runs other work; never capture the whole desktop) stored only under `plan/word/screenshots/` (gitignored, never committed).
- Never read, disassemble or copy anything inside the Word bundle (file names/listings only). Never commit files produced by Word. Never copy Microsoft wording beyond feature names.
- File formats come from public specs: ECMA-376 / ISO 29500 (OOXML), the published RTF spec, OASIS ODF 1.3. Never copy GPL/LGPL/AGPL code (LibreOffice, AbiWord, pandoc…).
## Architecture (see `plan/architecture.md`)
| Layer | Crate | Job |
|---|---|---|
| L0 | `geom` | units, rects, measurement parsing |
| L1 | `doc`, `fonts` | document model + edit primitives; font DB, shaping, Word font substitution |
| L2 | `layout`, `docx`, `formats` | line breaking, pagination, tables, headers/footers, hit testing; OOXML I/O; TXT/MD/HTML/RTF/ODT |
| L3 | `render`, `pdf`, `proof` | vello_cpu rasteriser; krilla PDF; proofing |
| L4 | `engine` | Session, undo, **command registry**, Word feature catalog, samples, I/O dispatch |
| L5 | `mcp` | MCP server (headless or bridged to the app) |
| L6 | `ui-egui` | Word-style egui front end (swappable) |
| apps | `apps/wordcraft`, `apps/wordcraft-cli`, `apps/wordcraft-web` | binaries |
- **Everything is a command.** User-visible behaviour = a `CommandSpec` in `crates/engine/src/cmd/*.rs` (id, label, ribbon location, shortcut, params doc, `enabled`, `run`) + tests. UI-only commands (`ui.*`) live in `crates/ui-egui/src/lib.rs`. The ribbon, shortcuts, command search, CLI, control channel and MCP all dispatch by id. Programmatic calls never open dialogs.
- **Parity is measured:** `crates/engine/src/catalog.rs` lists Word's ribbon/menu features with command ids. `cargo xtask parity` writes `docs/parity.md`; a test enforces a floor that only rises. Implement missing ids to raise it.
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. The UI is thin and reads `Session` state; colours come from `theme::Tokens`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
## Quality gates
Before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every arc of work that builds, with a task id in the message (`M2.1: line spacing dialog`).
## Running and looking at the app
- `cargo run --release -p wordcraft -- --sample --control 7981` (sample document + control channel).
- Drive it with JSON lines on `127.0.0.1:7981`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"text.insert","params":{"text":"Hello"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`; docs: `docs/control-protocol.md`.
- **Headless window screenshots** (no focus stealing, works with a locked screen): `cargo run --release -p wordcraft-ui-egui --example ui_shot -- script.jsonl` (see the example's header).
- **For UI work, look at the result** (screenshot, read the PNG) and compare with Word side by side.
- CLI: `wordcraft-cli convert in.docx out.pdf`, `wordcraft-cli run --template sample --cmd 'select.text={"text":"Studio"}' --cmd format.bold --save out.docx`, `wordcraft-cli commands`, `wordcraft-cli parity`.
- MCP: `wordcraft-cli mcp` (headless) or `wordcraft-cli mcp --connect 127.0.0.1:7981` (drives the running app). See `docs/mcp.md`.
- Shell gotcha: `mv`/`cp` may be aliased interactive — use `/bin/mv -f` / `/bin/cp -f`.
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
## Roadmap
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a milestone task lands.
Generated
+92 -1
View File
@@ -189,6 +189,15 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]] [[package]]
name = "arboard" name = "arboard"
version = "3.6.1" version = "3.6.1"
@@ -763,6 +772,17 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "dify" name = "dify"
version = "0.8.0" version = "0.8.0"
@@ -2717,6 +2737,15 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.38.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.41.0" version = "0.41.0"
@@ -3865,7 +3894,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quick-xml", "quick-xml 0.41.0",
"quote", "quote",
] ]
@@ -4470,6 +4499,17 @@ dependencies = [
"wordcraft-ui-egui", "wordcraft-ui-egui",
] ]
[[package]]
name = "wordcraft-cli"
version = "0.1.0"
dependencies = [
"serde_json",
"wordcraft-doc",
"wordcraft-engine",
"wordcraft-mcp",
"wordcraft-render",
]
[[package]] [[package]]
name = "wordcraft-doc" name = "wordcraft-doc"
version = "0.1.0" version = "0.1.0"
@@ -4482,6 +4522,20 @@ dependencies = [
"wordcraft-geom", "wordcraft-geom",
] ]
[[package]]
name = "wordcraft-docx"
version = "0.1.0"
dependencies = [
"image",
"log",
"proptest",
"quick-xml 0.38.4",
"thiserror 2.0.21",
"wordcraft-doc",
"wordcraft-geom",
"zip",
]
[[package]] [[package]]
name = "wordcraft-engine" name = "wordcraft-engine"
version = "0.1.0" version = "0.1.0"
@@ -4530,6 +4584,17 @@ dependencies = [
"wordcraft-geom", "wordcraft-geom",
] ]
[[package]]
name = "wordcraft-mcp"
version = "0.1.0"
dependencies = [
"log",
"serde_json",
"wordcraft-doc",
"wordcraft-engine",
"wordcraft-render",
]
[[package]] [[package]]
name = "wordcraft-render" name = "wordcraft-render"
version = "0.1.0" version = "0.1.0"
@@ -4840,6 +4905,20 @@ dependencies = [
"syn 3.0.6", "syn 3.0.6",
] ]
[[package]]
name = "zip"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308"
dependencies = [
"arbitrary",
"crc32fast",
"flate2",
"indexmap",
"memchr",
"zopfli",
]
[[package]] [[package]]
name = "zlib-rs" name = "zlib-rs"
version = "0.6.8" version = "0.6.8"
@@ -4852,6 +4931,18 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]] [[package]]
name = "zune-core" name = "zune-core"
version = "0.5.3" version = "0.5.3"
+1 -1
View File
@@ -1,7 +1,7 @@
[workspace] [workspace]
resolver = "3" resolver = "3"
members = ["crates/*", "apps/*", "xtask"] members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/wordcraft", "xtask"] default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
[workspace.package] [workspace.package]
version = "0.1.0" version = "0.1.0"
+28
View File
@@ -0,0 +1,28 @@
WordCraft
Copyright (c) 2026 ArtCraft Team and the WordCraft contributors.
Licensed under the MIT License or the Apache License, Version 2.0, at your option
(LICENSE-MIT, LICENSE-APACHE).
The ArtCraft name, wordmark and logos in docs/brand/ are trademarks of the ArtCraft Team. They are
not open source and are not covered by the license above; their terms are in
docs/brand/LICENSE-brand.txt.
Third-party material
This product bundles third-party material under its own licenses, listed below. The per-file
list (author, source, license) is in ATTRIBUTION.md.
- Source Sans 3 and Source Serif 4 (assets/fonts/Source*) — SIL Open Font License 1.1. See assets/fonts/OFL-SourceSans3.txt, OFL-SourceSerif4.txt.
- Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
- JetBrains Mono (assets/fonts/JetBrainsMono-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt.
- Japanese fonts (craft-fonts): builds made with craft-fonts (CRAFT_FONTS_DIR; all official
releases) embed the fonts listed in
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, BIZ UDMincho,
Shippori Mincho), under the SIL Open Font License 1.1. Their licence texts ship with the
packages as OFL-<family>.txt. Builds without CRAFT_FONTS_DIR do not include them.
- All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0.
Rust dependencies (egui, eframe, vello_cpu, skrifa, harfrust, krilla, quick-xml, zip, image, …)
are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io.
No Microsoft, Adobe, Avid or Autodesk icons, images, templates or artwork are included.
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "wordcraft-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
description = "WordCraft command line: convert, inspect, render, run commands, MCP server"
[[bin]]
name = "wordcraft-cli"
path = "src/main.rs"
[lints]
workspace = true
[dependencies]
wordcraft-doc = { workspace = true }
wordcraft-engine = { workspace = true }
wordcraft-render = { workspace = true }
wordcraft-mcp = { workspace = true }
serde_json = { workspace = true }
+184
View File
@@ -0,0 +1,184 @@
//! `wordcraft-cli`: WordCraft from the command line.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::process::ExitCode;
use serde_json::{Value, json};
use wordcraft_engine::Session;
const USAGE: &str = "\
wordcraft-cli — WordCraft from the command line
USAGE:
wordcraft-cli convert <in> <out> convert between formats (docx, pdf, odt, rtf, html, md, txt, json, png)
wordcraft-cli info <file> pages, words, paragraphs, properties (JSON)
wordcraft-cli text <file> plain text
wordcraft-cli inspect <file> document structure (JSON)
wordcraft-cli render <file> <out.png> [--page N] [--scale S]
wordcraft-cli run [--file F | --template T] --cmd 'id={json}' [--cmd …] [--save OUT] [--print]
run commands headlessly, then save
wordcraft-cli commands [--json] list every command
wordcraft-cli parity [--markdown] feature-catalog parity
wordcraft-cli mcp [--connect HOST:PORT] MCP server on stdio (headless, or bridged to the app)
wordcraft-cli --version
";
fn open(path: &str) -> Result<Session, String> {
let doc = wordcraft_engine::io::open_path(std::path::Path::new(path))?;
let mut s = Session::new(doc);
s.path = Some(path.into());
Ok(s)
}
fn arg_value(args: &[String], name: &str) -> Option<String> {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned()
}
fn run(args: &[String]) -> Result<(), String> {
let cmd = args.first().map(String::as_str).unwrap_or("");
let rest: Vec<String> = args.iter().skip(1).cloned().collect();
let pos = |i: usize| rest.iter().filter(|a| !a.starts_with("--")).nth(i).cloned().ok_or_else(|| format!("missing argument\n\n{USAGE}"));
match cmd {
"convert" => {
let (i, o) = (pos(0)?, pos(1)?);
let mut s = open(&i)?;
let r = if o.to_ascii_lowercase().ends_with(".png") {
s.run("file.exportPng", &json!({"path": o, "page": 1}))
} else {
s.run("file.save", &json!({"path": o}))
};
r.map_err(|e| e.to_string())?;
eprintln!("wrote {o}");
Ok(())
}
"info" => {
let mut s = open(&pos(0)?)?;
println!("{}", serde_json::to_string_pretty(&s.run("file.info", &json!({})).map_err(|e| e.to_string())?).unwrap_or_default());
Ok(())
}
"text" => {
let s = open(&pos(0)?)?;
println!("{}", s.doc.plain_text(wordcraft_doc::StoryRef::Body));
Ok(())
}
"inspect" => {
let mut s = open(&pos(0)?)?;
println!("{}", serde_json::to_string_pretty(&s.run("document.inspect", &json!({})).map_err(|e| e.to_string())?).unwrap_or_default());
Ok(())
}
"render" => {
let (i, o) = (pos(0)?, pos(1)?);
let mut s = open(&i)?;
let page = arg_value(&rest, "--page").and_then(|p| p.parse::<u64>().ok()).unwrap_or(1);
let scale = arg_value(&rest, "--scale").and_then(|p| p.parse::<f64>().ok()).unwrap_or(2.0);
let r = s.run("file.exportPng", &json!({"path": o, "page": page, "scale": scale})).map_err(|e| e.to_string())?;
println!("{r}");
Ok(())
}
"run" => {
let mut s = match (arg_value(&rest, "--file"), arg_value(&rest, "--template")) {
(Some(f), _) => open(&f)?,
(None, Some(t)) => {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("file.new", &json!({"template": t})).map_err(|e| e.to_string())?;
s
}
_ => Session::new(wordcraft_doc::Document::new()),
};
let mut i = 0;
while i < rest.len() {
if rest.get(i).map(String::as_str) == Some("--cmd") {
let spec = rest.get(i + 1).ok_or("--cmd needs a value")?;
let (id, params) = match spec.split_once('=') {
Some((id, p)) => (id.to_string(), serde_json::from_str::<Value>(p).map_err(|e| format!("{id}: bad JSON params: {e}"))?),
None => (spec.clone(), json!({})),
};
let r = s.run(&id, &params).map_err(|e| format!("{id}: {e}"))?;
if rest.iter().any(|a| a == "--print") {
println!("{id}: {r}");
}
i += 2;
} else {
i += 1;
}
}
if let Some(out) = arg_value(&rest, "--save") {
let r = if out.to_ascii_lowercase().ends_with(".png") { s.run("file.exportPng", &json!({"path": out})) } else { s.run("file.save", &json!({"path": out})) };
r.map_err(|e| e.to_string())?;
eprintln!("wrote {out}");
}
Ok(())
}
"commands" => {
let s = Session::new(wordcraft_doc::Document::new());
if rest.iter().any(|a| a == "--json") {
println!("{}", serde_json::to_string_pretty(&s.registry.describe()).unwrap_or_default());
} else {
for c in s.registry.all() {
println!("{:<32} {:<36} {:<24} {}", c.id, c.label, c.shortcut, c.location);
}
}
Ok(())
}
"parity" => {
let s = Session::new(wordcraft_doc::Document::new());
let p = wordcraft_engine::catalog::parity(&s.registry);
if rest.iter().any(|a| a == "--markdown") {
print!("{}", parity_markdown(&p));
} else {
println!("{}", serde_json::to_string_pretty(&p).unwrap_or_default());
}
Ok(())
}
"mcp" => {
let backend: Box<dyn wordcraft_mcp::Backend> = match arg_value(&rest, "--connect") {
Some(addr) => Box::new(wordcraft_mcp::Remote::connect(&addr).map_err(|e| format!("can't reach the WordCraft app at {addr}: {e}"))?),
None => Box::new(wordcraft_mcp::Headless::default()),
};
let mut server = wordcraft_mcp::Server::new(backend);
let stdin = std::io::stdin();
server.serve(stdin.lock(), std::io::stdout()).map_err(|e| e.to_string())
}
"--version" | "-V" => {
println!("wordcraft-cli {}", env!("CARGO_PKG_VERSION"));
Ok(())
}
"" | "help" | "--help" | "-h" => {
print!("{USAGE}");
Ok(())
}
other => Err(format!("unknown command `{other}`\n\n{USAGE}")),
}
}
fn parity_markdown(p: &Value) -> String {
let mut s = String::from("# WordCraft feature parity\n\nGenerated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.\n\n");
s += &format!("**{} of {} catalog features have commands ({}%).**\n\n| Tab | Live | Total |\n|---|---|---|\n", p["live"], p["total"], p["percent"]);
for t in p["tabs"].as_array().cloned().unwrap_or_default() {
s += &format!("| {} | {} | {} |\n", t["tab"].as_str().unwrap_or(""), t["live"], t["total"]);
}
s += "\n## Missing\n\n";
for t in p["tabs"].as_array().cloned().unwrap_or_default() {
let m = t["missing"].as_array().cloned().unwrap_or_default();
if m.is_empty() {
continue;
}
s += &format!("### {}\n\n", t["tab"].as_str().unwrap_or(""));
for x in m {
s += &format!("- {}\n", x.as_str().unwrap_or(""));
}
s += "\n";
}
s
}
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
match run(&args) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("wordcraft-cli: {e}");
ExitCode::FAILURE
}
}
}
+13 -1
View File
@@ -91,6 +91,15 @@ fn services() -> Services {
} }
} }
/// Window, Dock and taskbar icon.
fn app_icon() -> Option<egui::IconData> {
#[cfg(target_os = "macos")]
let png: &[u8] = include_bytes!("../../../assets/app-icon/wordcraft-macos-512.png");
#[cfg(not(target_os = "macos"))]
let png: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.wordcraft.png");
eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok()
}
fn main() -> eframe::Result { fn main() -> eframe::Result {
let mut control_port: Option<u16> = std::env::var("WORDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); 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 files = Vec::new();
@@ -107,7 +116,7 @@ fn main() -> eframe::Result {
_ => files.push(a), _ => files.push(a),
} }
} }
let options = eframe::NativeOptions { let mut options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default() viewport: egui::ViewportBuilder::default()
.with_title("WordCraft") .with_title("WordCraft")
.with_app_id("ai.storyteller.wordcraft") .with_app_id("ai.storyteller.wordcraft")
@@ -119,6 +128,9 @@ fn main() -> eframe::Result {
.with_title_shown(false), .with_title_shown(false),
..Default::default() ..Default::default()
}; };
if let Some(icon) = app_icon() {
options.viewport = options.viewport.with_icon(icon);
}
eframe::run_native( eframe::run_native(
"WordCraft", "WordCraft",
options, options,
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 604 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1001 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!-- WordCraft placeholder icon (original; MIT OR Apache-2.0). To be replaced by the mascot drawing. -->
<rect width="512" height="512" rx="112" fill="#3B5BDB"/>
<g transform="rotate(-6 256 256)">
<path d="M150 92 H322 L384 154 V420 H150 Z" fill="#efe9dc" stroke="#0b0b0c" stroke-width="14" stroke-linejoin="round"/>
<path d="M322 92 V154 H384" fill="none" stroke="#0b0b0c" stroke-width="14" stroke-linejoin="round"/>
<g stroke="#0b0b0c" stroke-width="14" stroke-linecap="round">
<line x1="190" y1="200" x2="344" y2="200"/>
<line x1="190" y1="246" x2="344" y2="246"/>
<line x1="190" y1="292" x2="344" y2="292"/>
<line x1="190" y1="338" x2="290" y2="338"/>
</g>
</g>
<path d="M300 410 L418 292 L446 320 L328 438 L292 446 Z" fill="#0b0b0c"/>
<path d="M418 292 L436 274 L464 302 L446 320 Z" fill="#efe9dc" stroke="#0b0b0c" stroke-width="8"/>
</svg>

After

Width:  |  Height:  |  Size: 961 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!-- WordCraft placeholder icon (original; MIT OR Apache-2.0). To be replaced by the mascot drawing. -->
<rect width="512" height="512" rx="112" fill="#3B5BDB"/>
<g transform="rotate(-6 256 256)">
<path d="M150 92 H322 L384 154 V420 H150 Z" fill="#efe9dc" stroke="#0b0b0c" stroke-width="14" stroke-linejoin="round"/>
<path d="M322 92 V154 H384" fill="none" stroke="#0b0b0c" stroke-width="14" stroke-linejoin="round"/>
<g stroke="#0b0b0c" stroke-width="14" stroke-linecap="round">
<line x1="190" y1="200" x2="344" y2="200"/>
<line x1="190" y1="246" x2="344" y2="246"/>
<line x1="190" y1="292" x2="344" y2="292"/>
<line x1="190" y1="338" x2="290" y2="338"/>
</g>
</g>
<path d="M300 410 L418 292 L446 320 L328 438 L292 446 Z" fill="#0b0b0c"/>
<path d="M418 292 L436 274 L464 302 L446 320 Z" fill="#efe9dc" stroke="#0b0b0c" stroke-width="8"/>
</svg>

After

Width:  |  Height:  |  Size: 961 B

+21
View File
@@ -0,0 +1,21 @@
[package]
name = "wordcraft-docx"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "WordCraft DOCX (OOXML WordprocessingML) reader and writer"
[lints]
workspace = true
[dependencies]
wordcraft-doc = { workspace = true }
wordcraft-geom = { workspace = true }
quick-xml = { workspace = true }
zip = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
image = { workspace = true }
+1
View File
@@ -0,0 +1 @@
#![deny(clippy::unwrap_used)]
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "wordcraft-mcp"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "WordCraft MCP server: every command as a tool, headless or bridged to the running app"
[lints]
workspace = true
[dependencies]
wordcraft-doc = { workspace = true }
wordcraft-engine = { workspace = true }
wordcraft-render = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
+107
View File
@@ -0,0 +1,107 @@
//! Where MCP tool calls end up: a control-channel method call.
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::time::Duration;
use serde_json::{Value, json};
/// Something that answers control-channel methods (`engine.execute`, `document.inspect`,
/// `ui.pointer`, `ui.render`, …). See `wordcraft_ui_egui::control` for the full list.
pub trait Backend {
/// Call one method. `Ok` carries the `result`, `Err` the error message.
fn call(&mut self, method: &str, params: Value) -> Result<Value, String>;
/// True when a real UI is attached (`ui.screenshot`, `ui.click`, dialogs… work).
fn has_ui(&self) -> bool;
/// Short human description ("headless", "connected to 127.0.0.1:7981").
fn describe(&self) -> String;
}
/// A running WordCraft app, reached through its loopback control port.
pub struct Remote {
addr: String,
conn: Option<(BufReader<TcpStream>, TcpStream)>,
next_id: u64,
}
impl Remote {
/// Connect to `addr` (`127.0.0.1:7981`), failing fast when nothing is listening.
pub fn connect(addr: &str) -> std::io::Result<Self> {
let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1 };
r.reconnect()?;
Ok(r)
}
pub fn addr(&self) -> &str {
&self.addr
}
fn reconnect(&mut self) -> std::io::Result<()> {
self.conn = None;
let mut last = std::io::Error::new(std::io::ErrorKind::NotFound, format!("cannot resolve {}", self.addr));
for sa in self.addr.to_socket_addrs()? {
match TcpStream::connect_timeout(&sa, Duration::from_millis(800)) {
Ok(s) => {
s.set_nodelay(true).ok();
// The app answers within 60 s (its own timeout); leave headroom.
s.set_read_timeout(Some(Duration::from_secs(90))).ok();
let read = s.try_clone()?;
self.conn = Some((BufReader::new(read), s));
return Ok(());
}
Err(e) => last = e,
}
}
Err(last)
}
fn roundtrip(&mut self, line: &str) -> std::io::Result<String> {
if self.conn.is_none() {
self.reconnect()?;
}
let Some((reader, writer)) = self.conn.as_mut() else {
return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected"));
};
writer.write_all(line.as_bytes())?;
writer.write_all(b"\n")?;
writer.flush()?;
let mut reply = String::new();
if reader.read_line(&mut reply)? == 0 {
return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed"));
}
Ok(reply)
}
}
impl Backend for Remote {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
let id = self.next_id;
self.next_id += 1;
let line = json!({"id": id, "method": method, "params": params}).to_string();
// One retry with a fresh connection (the app may have restarted).
let reply = match self.roundtrip(&line) {
Ok(r) => r,
Err(_) => {
self.conn = None;
self.roundtrip(&line).map_err(|e| {
self.conn = None;
format!("WordCraft app at {} is not reachable: {e}", self.addr)
})?
}
};
let v: Value = serde_json::from_str(reply.trim()).map_err(|e| format!("bad reply from app: {e}"))?;
if v.get("ok").and_then(Value::as_bool) == Some(true) {
Ok(v.get("result").cloned().unwrap_or(Value::Null))
} else {
Err(v.get("error").and_then(Value::as_str).unwrap_or("unknown error").to_string())
}
}
fn has_ui(&self) -> bool {
true
}
fn describe(&self) -> String {
format!("connected to the WordCraft app at {}", self.addr)
}
}
+65
View File
@@ -0,0 +1,65 @@
//! WordCraft's MCP server (Model Context Protocol, JSON-RPC 2.0 over stdio).
//!
//! Two backends: [`Headless`] runs an in-process editing session (no window); [`backend::Remote`]
//! talks to a running WordCraft app through its control channel (`wordcraft --control PORT`), so
//! agents can also click, type and screenshot the real UI.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod backend;
pub mod server;
pub mod tools;
pub use backend::{Backend, Remote};
pub use server::Server;
use serde_json::{Value, json};
use wordcraft_engine::Session;
/// Methods that need the desktop app.
pub const NEEDS_APP: &str = "needs the desktop app: start `wordcraft --control 7981` and run `wordcraft-cli mcp --connect 127.0.0.1:7981`";
/// An in-process session.
pub struct Headless {
pub session: Session,
}
impl Default for Headless {
fn default() -> Self {
Headless { session: Session::new(wordcraft_doc::Document::new()) }
}
}
impl Backend for Headless {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
match method {
"engine.execute" | "command" => {
let id = params.get("command").and_then(Value::as_str).ok_or("missing `command`")?.to_string();
let p = params.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}));
self.session.run(&id, &p).map_err(|e| e.to_string())
}
"engine.commands" => Ok(self.session.registry.describe()),
"document.inspect" => self.session.run("document.inspect", &params).map_err(|e| e.to_string()),
"ui.parity" => Ok(wordcraft_engine::catalog::parity(&self.session.registry)),
"ui.render" | "render.page" => {
let page = params.get("page").and_then(Value::as_u64).unwrap_or(1).max(1) as usize - 1;
let scale = params.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.1, 4.0) as f32;
let l = self.session.layout();
let pg = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?;
let img = wordcraft_render::render_page(&self.session.doc, pg, scale, &Default::default());
let png = img.to_png();
Ok(json!({"png": wordcraft_engine::cmd::insert::base64_encode(&png), "width": img.width, "height": img.height, "pages": l.pages.len()}))
}
m if m.starts_with("ui.") => Err(format!("`{m}` {NEEDS_APP}")),
other => self.session.run(other, &params).map_err(|e| e.to_string()),
}
}
fn has_ui(&self) -> bool {
false
}
fn describe(&self) -> String {
"headless session (no window)".into()
}
}
#[cfg(test)]
mod tests;
+168
View File
@@ -0,0 +1,168 @@
//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources methods.
use std::io::{BufRead, Write};
use serde_json::{Value, json};
use crate::backend::Backend;
use crate::tools::{call_tool, tool_definitions};
/// The MCP revision we implement.
pub const PROTOCOL_VERSION: &str = "2025-06-18";
const SUPPORTED_VERSIONS: &[&str] = &[PROTOCOL_VERSION, "2025-03-26", "2024-11-05"];
const PARSE_ERROR: i64 = -32700;
const INVALID_REQUEST: i64 = -32600;
const METHOD_NOT_FOUND: i64 = -32601;
const INVALID_PARAMS: i64 = -32602;
const INTERNAL_ERROR: i64 = -32603;
const RESOURCE_NOT_FOUND: i64 = -32002;
const INSTRUCTIONS: &str = "WordCraft is a word processor (a Microsoft Word clone). Every action is a command: find ids and \
parameters with list_commands and run them with execute (or several with batch). Positions are \
{story:\"body\", path:[block,(row,cell,block)...], off:byteOffset}. Typical flow: new_document → type_text \
\"Hello\" → execute text.newParagraph → execute para.style {style:\"Heading 1\"} → select_text {text:\"Hello\"} → \
execute format.bold → inspect_document to verify → render_page to look at the result → save {path:\"out.docx\"}.";
/// Resource URIs.
pub const DOC_URI: &str = "wordcraft://document";
pub const COMMANDS_URI: &str = "wordcraft://commands";
/// An MCP server bound to one backend.
pub struct Server {
backend: Box<dyn Backend>,
initialized: bool,
}
fn response(id: Value, result: Value) -> Value {
json!({"jsonrpc": "2.0", "id": id, "result": result})
}
fn error(id: Value, code: i64, message: impl Into<String>) -> Value {
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into()}})
}
impl Server {
pub fn new(backend: Box<dyn Backend>) -> Self {
Self { backend, initialized: false }
}
pub fn backend(&mut self) -> &mut dyn Backend {
self.backend.as_mut()
}
/// Whether the client has sent `notifications/initialized`.
pub fn is_initialized(&self) -> bool {
self.initialized
}
/// Serve newline-delimited JSON-RPC until `input` closes. Logs go to stderr only (stdout is
/// the protocol stream).
pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
for line in input.lines() {
let line = line?;
if let Some(reply) = self.handle_line(&line) {
output.write_all(reply.as_bytes())?;
output.write_all(b"\n")?;
output.flush()?;
}
}
Ok(())
}
/// Handle one line; returns the reply line (None for notifications and blank lines).
pub fn handle_line(&mut self, line: &str) -> Option<String> {
let line = line.trim();
if line.is_empty() {
return None;
}
let reply = match serde_json::from_str::<Value>(line) {
Ok(Value::Array(batch)) => {
// Batches were removed in 2025-06-18; still answer older clients sensibly.
if batch.is_empty() {
Some(error(Value::Null, INVALID_REQUEST, "empty batch"))
} else {
let replies: Vec<Value> = batch.into_iter().filter_map(|m| self.handle(m)).collect();
(!replies.is_empty()).then_some(Value::Array(replies))
}
}
Ok(msg) => self.handle(msg),
Err(e) => Some(error(Value::Null, PARSE_ERROR, format!("parse error: {e}"))),
};
reply.map(|r| r.to_string())
}
/// Handle one JSON-RPC message; `None` for notifications and responses.
pub fn handle(&mut self, msg: Value) -> Option<Value> {
let Value::Object(o) = &msg else { return Some(error(Value::Null, INVALID_REQUEST, "message must be an object")) };
let id = o.get("id").cloned();
let Some(method) = o.get("method").and_then(Value::as_str) else {
// A response to a server→client request (we send none): ignore. Anything else is invalid.
if o.contains_key("result") || o.contains_key("error") {
return None;
}
return Some(error(id.unwrap_or(Value::Null), INVALID_REQUEST, "missing `method`"));
};
let params = o.get("params").cloned().unwrap_or(Value::Null);
let Some(id) = id else {
self.notification(method);
return None;
};
if !(id.is_string() || id.is_number()) {
return Some(error(Value::Null, INVALID_REQUEST, "`id` must be a string or number"));
}
Some(match self.request(method, &params) {
Ok(r) => response(id, r),
Err((code, m)) => error(id, code, m),
})
}
fn notification(&mut self, method: &str) {
match method {
"notifications/initialized" => self.initialized = true,
"notifications/cancelled" | "notifications/progress" | "notifications/roots/list_changed" => {}
other => log::debug!("ignoring notification {other}"),
}
}
fn request(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
match method {
"initialize" => {
let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL_VERSION);
let version = if SUPPORTED_VERSIONS.contains(&asked) { asked } else { PROTOCOL_VERSION };
Ok(json!({
"protocolVersion": version,
"capabilities": {"tools": {}, "resources": {}},
"serverInfo": {"name": "wordcraft", "title": "WordCraft", "version": env!("CARGO_PKG_VERSION"), "websiteUrl": "https://getartcraft.com/apps/wordcraft"},
"instructions": format!(
"{INSTRUCTIONS} Backend: {}. Community: https://discord.gg/artcraft · https://getartcraft.com/apps/wordcraft · https://github.com/storytold/wordcraft",
self.backend.describe()
),
}))
}
"ping" => Ok(json!({})),
"tools/list" => Ok(json!({"tools": tool_definitions()})),
"tools/call" => {
let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?;
let args = params.get("arguments").cloned().unwrap_or(Value::Null);
Ok(call_tool(self.backend.as_mut(), name, &args).to_value())
}
"resources/list" => Ok(json!({"resources": [
{"uri": DOC_URI, "name": "document", "title": "Active document", "description": "Blocks, text, formatting, sections, parts and selection of the document (document.inspect)", "mimeType": "application/json"},
{"uri": COMMANDS_URI, "name": "commands", "title": "Commands", "description": "Every command: id, label, ribbon location, shortcut, params, enabled (engine.commands)", "mimeType": "application/json"},
]})),
"resources/templates/list" => Ok(json!({"resourceTemplates": []})),
"resources/read" => {
let uri = params.get("uri").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing `uri`".to_string()))?;
let v = match uri {
DOC_URI => self.backend.call("document.inspect", json!({})),
COMMANDS_URI => self.backend.call("engine.commands", json!({})),
_ => return Err((RESOURCE_NOT_FOUND, format!("resource not found: {uri}"))),
}
.map_err(|e| (INTERNAL_ERROR, e))?;
Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": serde_json::to_string_pretty(&v).unwrap_or_default()}]}))
}
other => Err((METHOD_NOT_FOUND, format!("method not found: {other}"))),
}
}
}
+69
View File
@@ -0,0 +1,69 @@
//! An MCP client doing realistic tasks through the protocol only, checking results via MCP.
use serde_json::{Value, json};
use crate::{Headless, Server};
fn call(s: &mut Server, id: u64, method: &str, params: Value) -> Value {
let line = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
let r = s.handle_line(&line).expect("reply");
serde_json::from_str(&r).expect("json")
}
fn tool(s: &mut Server, name: &str, args: Value) -> Value {
let r = call(s, 9, "tools/call", json!({"name": name, "arguments": args}));
let res = &r["result"];
assert_eq!(res["isError"], false, "{name}: {res}");
let text = res["content"][0]["text"].as_str().unwrap_or("null");
serde_json::from_str(text).unwrap_or(Value::String(text.to_string()))
}
#[test]
fn lifecycle_and_tools() {
let mut s = Server::new(Box::new(Headless::default()));
let init = call(&mut s, 1, "initialize", json!({"protocolVersion": "2025-06-18"}));
assert_eq!(init["result"]["serverInfo"]["name"], "wordcraft");
assert!(s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#).is_none());
let tools = call(&mut s, 2, "tools/list", json!({}));
assert!(tools["result"]["tools"].as_array().unwrap().len() >= 12);
let bad = call(&mut s, 3, "nope", json!({}));
assert!(bad["error"].is_object());
assert!(s.handle_line("{not json").unwrap().contains("parse error"));
}
#[test]
fn agent_writes_a_formatted_document() {
let mut s = Server::new(Box::new(Headless::default()));
tool(&mut s, "new_document", json!({}));
tool(&mut s, "type_text", json!({"text": "Quarterly Report"}));
tool(&mut s, "execute", json!({"command": "para.style", "params": {"style": "Heading 1"}}));
tool(&mut s, "execute", json!({"command": "text.newParagraph"}));
tool(&mut s, "type_text", json!({"text": "Sales grew strongly this quarter.\nCosts fell.", "paragraphs": true}));
tool(&mut s, "select_text", json!({"text": "strongly"}));
tool(&mut s, "execute", json!({"command": "format.bold"}));
tool(&mut s, "batch", json!({"commands": [{"command": "caret.docEnd"}, {"command": "insert.table", "params": {"rows": 2, "cols": 2}}]}));
let doc = tool(&mut s, "inspect_document", json!({}));
let blocks = doc["blocks"].as_array().unwrap();
assert_eq!(blocks[0]["style"], "Heading1");
assert_eq!(blocks[0]["text"], "Quarterly Report");
let runs = blocks[1]["runs"].as_array().unwrap();
assert!(runs.iter().any(|r| r["props"]["bold"] == true));
assert!(blocks.iter().any(|b| b["type"] == "table"));
let text = tool(&mut s, "get_text", json!({}));
assert!(text["text"].as_str().unwrap().contains("Costs fell."));
let r = call(&mut s, 10, "tools/call", json!({"name": "render_page", "arguments": {"page": 1, "scale": 0.5}}));
assert_eq!(r["result"]["content"][0]["type"], "image");
let r = call(&mut s, 11, "tools/call", json!({"name": "screenshot", "arguments": {}}));
assert_eq!(r["result"]["isError"], true);
let p = tool(&mut s, "parity", json!({}));
assert!(p["percent"].as_f64().unwrap() > 50.0);
}
#[test]
fn resources() {
let mut s = Server::new(Box::new(Headless::default()));
let l = call(&mut s, 1, "resources/list", json!({}));
assert_eq!(l["result"]["resources"].as_array().unwrap().len(), 2);
let r = call(&mut s, 2, "resources/read", json!({"uri": "wordcraft://document"}));
assert!(r["result"]["contents"][0]["text"].as_str().unwrap().contains("blocks"));
}
+188
View File
@@ -0,0 +1,188 @@
//! MCP tools: names, schemas and implementations on top of a [`Backend`].
use serde_json::{Value, json};
use crate::backend::Backend;
#[derive(Clone, Debug, PartialEq)]
pub struct ToolResult {
pub content: Vec<Value>,
pub is_error: bool,
}
impl ToolResult {
pub fn text(t: impl Into<String>) -> Self {
Self { content: vec![json!({"type": "text", "text": t.into()})], is_error: false }
}
pub fn json(v: &Value) -> Self {
Self::text(serde_json::to_string_pretty(v).unwrap_or_default())
}
pub fn error(t: impl Into<String>) -> Self {
Self { content: vec![json!({"type": "text", "text": t.into()})], is_error: true }
}
pub fn image(png_base64: String, info: &Value) -> Self {
Self { content: vec![json!({"type": "image", "data": png_base64, "mimeType": "image/png"}), json!({"type": "text", "text": info.to_string()})], is_error: false }
}
pub fn to_value(&self) -> Value {
json!({"content": self.content, "isError": self.is_error})
}
}
fn s(desc: &str) -> Value {
json!({"type": "string", "description": desc})
}
fn n(desc: &str) -> Value {
json!({"type": "number", "description": desc})
}
fn obj(props: Value, req: &[&str]) -> Value {
json!({"type": "object", "properties": props, "required": req})
}
fn tool(name: &str, title: &str, desc: &str, schema: Value, ro: bool) -> Value {
json!({"name": name, "title": title, "description": desc, "inputSchema": schema, "annotations": {"title": title, "readOnlyHint": ro, "openWorldHint": false}})
}
pub fn tool_definitions() -> Value {
json!([
tool("list_commands", "List commands", "Every WordCraft command: id, label, ribbon location, shortcut and params. Filter with `query`.", obj(json!({"query": s("Case-insensitive filter on id/label/location")}), &[]), true),
tool("execute", "Run a command", "Run one command by id with params, e.g. {command:\"format.bold\"} or {command:\"insert.table\", params:{rows:3, cols:4}}.", obj(json!({"command": s("Command id"), "params": {"type": "object", "description": "Command parameters"}}), &["command"]), false),
tool("batch", "Run several commands", "Run commands in order; stops at the first error unless keepGoing.", obj(json!({"commands": {"type": "array", "items": {"type": "object", "properties": {"command": {"type": "string"}, "params": {"type": "object"}}, "required": ["command"]}}, "keepGoing": {"type": "boolean"}}), &["commands"]), false),
tool("new_document", "New document", "Start a document: blank, sample, letter, resume or report.", obj(json!({"template": s("blank|sample|letter|resume|report")}), &[]), false),
tool("open_document", "Open", "Open a .docx, .odt, .rtf, .md, .html, .txt or .json file.", obj(json!({"path": s("File path")}), &["path"]), false),
tool("save_document", "Save", "Save (format from the extension: docx, pdf, odt, rtf, html, md, txt, png).", obj(json!({"path": s("File path; omit to save in place")}), &[]), false),
tool("type_text", "Type text", "Type at the caret (replacing the selection). Use \\n... for line breaks inside text; use execute text.newParagraph for new paragraphs, or `paragraphs: true` to split lines into paragraphs.", obj(json!({"text": s("Text"), "paragraphs": {"type": "boolean"}}), &["text"]), false),
tool("select_text", "Select text", "Select the n-th occurrence of some text.", obj(json!({"text": s("Text to find"), "occurrence": n("1-based")}), &["text"]), false),
tool("get_text", "Document text", "Plain text of the document body.", obj(json!({}), &[]), true),
tool("inspect_document", "Inspect document", "Structure: blocks with text, styles, formatting runs, lists, tables, sections, parts, selection, pages.", obj(json!({}), &[]), true),
tool("render_page", "Render page", "Render a page to PNG to look at the result.", obj(json!({"page": n("1-based page"), "scale": n("Pixels per point (default 1)")}), &[]), true),
tool("parity", "Feature parity", "WordCraft's command coverage of the word-processor feature catalog.", obj(json!({}), &[]), true),
tool("screenshot", "Screenshot app", "Screenshot of the whole WordCraft window (desktop app only).", obj(json!({}), &[]), true),
tool("click", "Click", "Click at window coordinates (desktop app only).", obj(json!({"x": n("x"), "y": n("y"), "count": n("1-3 clicks"), "shift": {"type": "boolean"}, "cmd": {"type": "boolean"}}), &["x", "y"]), false),
tool("key", "Press key", "Press a key with modifiers (desktop app only), e.g. {key:\"B\", cmd:true}.", obj(json!({"key": s("Key name"), "shift": {"type": "boolean"}, "alt": {"type": "boolean"}, "cmd": {"type": "boolean"}}), &["key"]), false),
tool("ui_inspect", "Inspect UI", "UI state: tab, panes, dialog, page rectangles on screen, caret (desktop app only).", obj(json!({}), &[]), true),
])
}
fn exec(b: &mut dyn Backend, id: &str, params: Value) -> Result<Value, String> {
b.call("engine.execute", json!({"command": id, "params": params}))
}
fn wrap(r: Result<Value, String>) -> ToolResult {
match r {
Ok(v) => ToolResult::json(&v),
Err(e) => ToolResult::error(e),
}
}
pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
let st = |k: &str| a.get(k).and_then(Value::as_str);
match name {
"list_commands" => {
let q = st("query").unwrap_or("").to_lowercase();
match b.call("engine.commands", json!({})) {
Ok(Value::Array(v)) => {
let f: Vec<Value> = v
.into_iter()
.filter(|c| q.is_empty() || ["id", "label", "location"].iter().any(|k| c.get(*k).and_then(Value::as_str).is_some_and(|x| x.to_lowercase().contains(&q))))
.collect();
ToolResult::json(&Value::Array(f))
}
Ok(v) => ToolResult::json(&v),
Err(e) => ToolResult::error(e),
}
}
"execute" => match st("command") {
Some(c) => wrap(exec(b, c, a.get("params").cloned().unwrap_or(json!({})))),
None => ToolResult::error("missing `command`"),
},
"batch" => {
let keep = a.get("keepGoing").and_then(Value::as_bool).unwrap_or(false);
let mut out = Vec::new();
for c in a.get("commands").and_then(Value::as_array).cloned().unwrap_or_default() {
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
let r = exec(b, id, c.get("params").cloned().unwrap_or(json!({})));
let failed = r.is_err();
out.push(match r {
Ok(v) => json!({"command": id, "ok": true, "result": v}),
Err(e) => json!({"command": id, "ok": false, "error": e}),
});
if failed && !keep {
return ToolResult { content: vec![json!({"type": "text", "text": Value::Array(out).to_string()})], is_error: true };
}
}
ToolResult::json(&Value::Array(out))
}
"new_document" => wrap(exec(b, "file.new", json!({"template": st("template").unwrap_or("blank")}))),
"open_document" => wrap(exec(b, "file.open", json!({"path": st("path").unwrap_or("")}))),
"save_document" => {
let p = st("path");
let ext = p.and_then(|x| x.rsplit('.').next()).unwrap_or("").to_ascii_lowercase();
if ext == "png" {
wrap(exec(b, "file.exportPng", json!({"path": p})))
} else {
wrap(exec(b, "file.save", match p {
Some(p) => json!({"path": p}),
None => json!({}),
}))
}
}
"type_text" => {
let t = st("text").unwrap_or("");
if a.get("paragraphs").and_then(Value::as_bool).unwrap_or(false) {
let mut last = Ok(Value::Null);
for (i, line) in t.split('\n').enumerate() {
if i > 0 {
last = exec(b, "text.newParagraph", json!({}));
}
if !line.is_empty() {
last = exec(b, "text.insert", json!({"text": line, "raw": true}));
}
if last.is_err() {
break;
}
}
wrap(last)
} else {
wrap(exec(b, "text.insert", json!({"text": t, "raw": true})))
}
}
"select_text" => wrap(exec(b, "select.text", json!({"text": st("text").unwrap_or(""), "occurrence": a.get("occurrence").cloned().unwrap_or(json!(1))}))),
"get_text" => wrap(exec(b, "document.text", json!({}))),
"inspect_document" => wrap(b.call("document.inspect", json!({}))),
"parity" => wrap(b.call("ui.parity", json!({}))),
"render_page" => {
if b.has_ui() {
let path = std::env::temp_dir().join("wordcraft-mcp-page.png");
let path_s = path.to_string_lossy().to_string();
match b.call("ui.render", json!({"path": path_s, "page": a.get("page").cloned().unwrap_or(json!(1)), "scale": a.get("scale").cloned().unwrap_or(json!(1.0))})) {
Ok(info) => match std::fs::read(&path) {
Ok(bytes) => ToolResult::image(wordcraft_engine::cmd::insert::base64_encode(&bytes), &info),
Err(e) => ToolResult::error(e.to_string()),
},
Err(e) => ToolResult::error(e),
}
} else {
match b.call("ui.render", a.clone()) {
Ok(v) => {
let png = v.get("png").and_then(Value::as_str).unwrap_or("").to_string();
ToolResult::image(png, &json!({"width": v["width"], "height": v["height"], "pages": v["pages"]}))
}
Err(e) => ToolResult::error(e),
}
}
}
"screenshot" => {
let path = std::env::temp_dir().join("wordcraft-mcp-shot.png");
match b.call("ui.screenshot", json!({"path": path.to_string_lossy()})) {
Ok(info) => match std::fs::read(&path) {
Ok(bytes) => ToolResult::image(wordcraft_engine::cmd::insert::base64_encode(&bytes), &info),
Err(e) => ToolResult::error(e.to_string()),
},
Err(e) => ToolResult::error(e),
}
}
"click" => wrap(b.call("ui.click", a.clone())),
"key" => wrap(b.call("ui.key", a.clone())),
"ui_inspect" => wrap(b.call("ui.inspect", json!({}))),
other => ToolResult::error(format!("unknown tool `{other}`")),
}
}
+34
View File
@@ -0,0 +1,34 @@
# Control protocol
Start the app with a control port: `wordcraft --control 7981` (or `WORDCRAFT_CONTROL_PORT=7981`).
It listens on `127.0.0.1` only. Send one JSON object per line; each gets one reply line:
```json
{"id": 1, "method": "engine.execute", "params": {"command": "text.insert", "params": {"text": "Hello"}}}
{"id": 1, "ok": true, "result": {"anchor": {...}, "focus": {...}}}
```
Errors come back as `{"id": …, "ok": false, "error": "message"}`. A failed command leaves the
document unchanged.
## Methods
| 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.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 |
| `ui.click` | `x`, `y`, `button?`, `count?`, `shift?`, `cmd?`, `alt?` | real pointer input at window coordinates |
| `ui.clickText` | `page` (0-based), `x`, `y` (points from the page's top-left), `count?` | click inside a page |
| `ui.move` / `ui.drag` | `x`,`y` / `x`,`y`,`toX`,`toY`,`steps?` | pointer move / drag |
| `ui.key` | `key` (`B`, `Enter`, `Left`…), `shift?`, `alt?`, `cmd?` | key press through egui (shortcuts apply) |
| `ui.text` | `text` | typed text through egui |
| `ui.screenshot` | `path?` | PNG of the window |
| `ui.render` | `path`, `page?` (1-based), `scale?` | PNG of a page, rendered by the engine |
| `ui.parity` | — | feature-catalog parity |
| `ui.resize` / `ui.focus` / `app.quit` | | window control |
Any other method name is treated as a command id: `{"method": "format.bold"}` works.
Positions are `{"story": "body" | {"part": n}, "path": [block, row, cell, block, …], "off": byteOffset}`.
+32
View File
@@ -0,0 +1,32 @@
# MCP server
WordCraft speaks the [Model Context Protocol](https://modelcontextprotocol.io) over stdio, so
Claude and other agents can write and edit documents.
```sh
wordcraft-cli mcp # headless session (no window)
wordcraft --control 7981 & # or: drive the running app…
wordcraft-cli mcp --connect 127.0.0.1:7981 # …including clicks, keys and screenshots
```
Claude Code: `claude mcp add wordcraft -- wordcraft-cli mcp`.
## Tools
| Tool | What it does |
|---|---|
| `list_commands` | every command (filter with `query`) — over 270 of them |
| `execute` | run one command: `{command: "insert.table", params: {rows: 3, cols: 4}}` |
| `batch` | run several commands in order |
| `new_document` | blank, sample, letter, resume, report |
| `open_document` / `save_document` | docx, odt, rtf, html, md, txt, json; save also pdf, png |
| `type_text` | type at the caret (`paragraphs: true` splits lines into paragraphs) |
| `select_text` | select the n-th occurrence of some text |
| `get_text` / `inspect_document` | read the document back (verify without screenshots) |
| `render_page` | a page as PNG |
| `parity` | feature coverage |
| `screenshot`, `click`, `key`, `ui_inspect` | app only (`--connect`) |
Resources: `wordcraft://document` (inspect) and `wordcraft://commands`.
The acceptance test `crates/mcp/src/tests.rs` writes a formatted document using MCP only.
+238
View File
@@ -0,0 +1,238 @@
# WordCraft feature parity
Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.
**250 of 405 catalog features have commands (61.7%).**
| Tab | Live | Total |
|---|---|---|
| File | 11 | 20 |
| Quick Access | 2 | 4 |
| Home | 82 | 100 |
| Insert | 34 | 53 |
| Draw | 0 | 11 |
| Design | 9 | 10 |
| Layout | 11 | 19 |
| References | 10 | 24 |
| Mailings | 0 | 17 |
| Review | 17 | 29 |
| View | 20 | 29 |
| Table Design | 4 | 5 |
| Table Layout | 26 | 30 |
| Picture Format | 0 | 13 |
| Shape Format | 0 | 6 |
| Header & Footer | 2 | 5 |
| Editing | 22 | 24 |
| Tools | 0 | 6 |
## Missing
### File
- Backstage › Share (`file.share`)
- Backstage › Protect Document (`file.protect`)
- Backstage › Inspect Document (`file.inspect`)
- Backstage › Check Accessibility (`file.accessibility`)
- Backstage › Check Compatibility (`file.compatibility`)
- Backstage › Version History (`file.versions`)
- Backstage › Recover Unsaved (`file.recover`)
- Backstage › New from Template (`file.newFromTemplate`)
- Backstage › Save as Template (`file.saveTemplate`)
### Quick Access
- Toolbar › Repeat (`edit.repeat`)
- Toolbar › AutoSave (`file.autosave`)
### Home
- Clipboard › Paste Special (`edit.pasteSpecial`)
- Clipboard › Clipboard Pane (`edit.clipboardPane`)
- Font › Character Border (`format.border`)
- Font › Phonetic Guide (`format.phonetic`)
- Font › Enclose Characters (`format.enclose`)
- Paragraph › Define New Bullet (`para.defineBullet`)
- Paragraph › Define New Number Format (`para.defineNumber`)
- Paragraph › Set Numbering Value (`para.setNumberingValue`)
- Paragraph › Text Direction RTL (`para.rtl`)
- Paragraph › Asian Typography (`para.asianTypography`)
- Styles › Style Inspector (`styles.inspector`)
- Styles › Manage Styles (`styles.manage`)
- Styles › Apply Styles (`styles.apply`)
- Editing › Advanced Find (`edit.advancedFind`)
- Editing › Select Objects (`select.objects`)
- Editing › Select Similar Formatting (`select.similar`)
- Voice › Dictate (`tools.dictate`)
- Editor › Editor (`review.editor`)
### Insert
- Tables › Draw Table (`table.draw`)
- Tables › Excel Spreadsheet (`insert.spreadsheet`)
- Illustrations › Online Pictures (`insert.onlinePicture`)
- Illustrations › Icons (`insert.icon`)
- Illustrations › 3D Models (`insert.model3d`)
- Illustrations › SmartArt (`insert.smartArt`)
- Illustrations › Chart (`insert.chart`)
- Illustrations › Screenshot (`insert.screenshot`)
- Illustrations › Drawing Canvas (`insert.canvas`)
- Add-ins › Get Add-ins (`addins.get`)
- Media › Online Video (`insert.video`)
- Links › Cross-reference (`insert.crossReference`)
- Header & Footer › Link to Previous (`layout.linkToPrevious`)
- Text › Quick Parts (`insert.quickParts`)
- Text › AutoText (`insert.autoText`)
- Text › Document Property (`insert.docProperty`)
- Text › Signature Line (`insert.signatureLine`)
- Text › Object (`insert.object`)
- Symbols › Ink Equation (`insert.inkEquation`)
### Draw
- Tools › Select (`draw.select`)
- Tools › Lasso Select (`draw.lasso`)
- Tools › Eraser (`draw.eraser`)
- Pens › Pen (`draw.pen`)
- Pens › Pencil (`draw.pencil`)
- Pens › Highlighter (`draw.highlighter`)
- Pens › Add Pen (`draw.addPen`)
- Convert › Ink to Shape (`draw.inkToShape`)
- Convert › Ink to Math (`draw.inkToMath`)
- Insert › Drawing Canvas (`insert.canvas`)
- Replay › Ink Replay (`draw.replay`)
### Design
- Document Formatting › Effects (`design.effects`)
### Layout
- Arrange › Position (`arrange.position`)
- Arrange › Wrap Text (`arrange.wrap`)
- Arrange › Bring Forward (`arrange.bringForward`)
- Arrange › Send Backward (`arrange.sendBackward`)
- Arrange › Selection Pane (`arrange.selectionPane`)
- Arrange › Align (`arrange.align`)
- Arrange › Group (`arrange.group`)
- Arrange › Rotate (`arrange.rotate`)
### References
- Footnotes › Footnote Options (`references.noteOptions`)
- Research › Researcher (`references.researcher`)
- Citations › Insert Citation (`references.citation`)
- Citations › Manage Sources (`references.sources`)
- Citations › Style (`references.citationStyle`)
- Citations › Bibliography (`references.bibliography`)
- Captions › Insert Table of Figures (`references.tableOfFigures`)
- Captions › Update Table of Figures (`references.updateFigures`)
- Captions › Cross-reference (`insert.crossReference`)
- Index › Mark Entry (`references.markEntry`)
- Index › Insert Index (`references.index`)
- Index › Update Index (`references.updateIndex`)
- Table of Authorities › Mark Citation (`references.markCitation`)
- Table of Authorities › Insert Table of Authorities (`references.tableOfAuthorities`)
### Mailings
- Create › Envelopes (`mailings.envelopes`)
- Create › Labels (`mailings.labels`)
- Start Mail Merge › Start Mail Merge (`mailings.start`)
- Start Mail Merge › Select Recipients (`mailings.recipients`)
- Start Mail Merge › Edit Recipient List (`mailings.editRecipients`)
- Write & Insert Fields › Highlight Merge Fields (`mailings.highlightFields`)
- Write & Insert Fields › Address Block (`mailings.addressBlock`)
- Write & Insert Fields › Greeting Line (`mailings.greetingLine`)
- Write & Insert Fields › Insert Merge Field (`mailings.insertField`)
- Write & Insert Fields › Rules (`mailings.rules`)
- Write & Insert Fields › Match Fields (`mailings.matchFields`)
- Preview Results › Preview Results (`mailings.preview`)
- Preview Results › Next Record (`mailings.next`)
- Preview Results › Previous Record (`mailings.previous`)
- Preview Results › Find Recipient (`mailings.findRecipient`)
- Preview Results › Check for Errors (`mailings.checkErrors`)
- Finish › Finish & Merge (`mailings.finish`)
### Review
- Proofing › Spelling & Grammar (`review.spelling`)
- Proofing › Thesaurus (`review.thesaurus`)
- Speech › Read Aloud (`review.readAloud`)
- Accessibility › Check Accessibility (`file.accessibility`)
- Language › Translate (`review.translate`)
- Language › Language (`review.language`)
- Tracking › Show Markup (`review.showMarkup`)
- Compare › Compare (`review.compare`)
- Compare › Combine (`review.combine`)
- Protect › Block Authors (`review.blockAuthors`)
- Protect › Restrict Editing (`review.restrict`)
- Ink › Hide Ink (`review.hideInk`)
### View
- Immersive › Immersive Reader (`view.immersive`)
- Page Movement › Vertical (`view.vertical`)
- Page Movement › Side to Side (`view.sideToSide`)
- Window › Arrange All (`view.arrangeAll`)
- Window › View Side by Side (`view.sideBySide`)
- Window › Synchronous Scrolling (`view.syncScroll`)
- Window › Switch Windows (`view.switchWindows`)
- Macros › Macros (`tools.macros`)
- Macros › Record Macro (`tools.recordMacro`)
### Table Design
- Borders › Border Painter (`table.borderPainter`)
### Table Layout
- Draw › Draw Table (`table.draw`)
- Draw › Eraser (`table.eraser`)
- Alignment › Text Direction (`table.textDirection`)
- Alignment › Cell Margins (`table.cellMargins`)
### Picture Format
- Adjust › Remove Background (`picture.removeBackground`)
- Adjust › Corrections (`picture.corrections`)
- Adjust › Color (`picture.color`)
- Adjust › Artistic Effects (`picture.effects`)
- Adjust › Transparency (`picture.transparency`)
- Adjust › Compress Pictures (`picture.compress`)
- Adjust › Change Picture (`picture.change`)
- Adjust › Reset Picture (`picture.reset`)
- Picture Styles › Picture Styles (`picture.style`)
- Picture Styles › Picture Border (`picture.border`)
- Accessibility › Alt Text (`picture.altText`)
- Size › Crop (`picture.crop`)
- Size › Size (`picture.size`)
### Shape Format
- Shape Styles › Shape Fill (`shape.fill`)
- Shape Styles › Shape Outline (`shape.outline`)
- Shape Styles › Shape Effects (`shape.effects`)
- Text › Text Direction (`shape.textDirection`)
- Text › Align Text (`shape.alignText`)
- Text › Create Link (`shape.link`)
### Header & Footer
- Navigation › Previous Section (`hf.previous`)
- Navigation › Next Section (`hf.next`)
- Position › Header from Top (`hf.position`)
### Editing
- Selection › Extend Selection (F8) (`select.extend`)
- Selection › Column Selection (`select.column`)
### Tools
- Proofing › AutoCorrect Options (`tools.autocorrect`)
- Proofing › Set Proofing Language (`review.language`)
- Customize › Customize Ribbon (`tools.customizeRibbon`)
- Customize › Customize Keyboard (`tools.customizeKeyboard`)
- Protection › Encrypt with Password (`file.encrypt`)
- Templates › Templates and Add-ins (`tools.templates`)
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Regenerate every app icon from assets/app-icon/wordcraft.svg.
#
# Needs: resvg (brew install resvg / cargo install resvg). On macOS, iconutil also writes the
# .icns. The outputs are committed, so builds and packaging never need these tools.
#
# packaging/icons.sh
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DIR="$ROOT/assets/app-icon"
SVG="$DIR/wordcraft.svg"
ID="ai.storyteller.wordcraft"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
command -v resvg >/dev/null || { echo "error: resvg not found (brew install resvg)" >&2; exit 1; }
# The artwork is a full-bleed 512 tile (rx=112). Windows and Linux use it as is. macOS icons sit on
# Apple's grid: an 824/1024 body with a transparent margin, so widen the viewBox to 512*1024/824.
MAC="$TMP/mac.svg"
sed 's/viewBox="0 0 512 512"/viewBox="-62.136 -62.136 636.272 636.272"/' "$SVG" >"$MAC"
grep -q 'viewBox="-62.136' "$MAC" || { echo "error: expected viewBox=\"0 0 512 512\" in $SVG" >&2; exit 1; }
render() { resvg -w "$2" -h "$2" "$1" "$3" </dev/null; }
render "$SVG" 1024 "$DIR/wordcraft-1024.png"
# Runtime window/Dock icon on macOS (embedded by apps/wordcraft); other platforms use hicolor 256.
render "$MAC" 512 "$DIR/wordcraft-macos-512.png"
# Linux hicolor theme.
for s in 16 24 32 48 64 128 256 512; do
mkdir -p "$DIR/hicolor/${s}x${s}/apps"
render "$SVG" "$s" "$DIR/hicolor/${s}x${s}/apps/$ID.png"
done
mkdir -p "$DIR/hicolor/scalable/apps"
cp "$SVG" "$DIR/hicolor/scalable/apps/$ID.svg"
# Windows .ico.
ICO_PNGS=()
for s in 16 20 24 32 40 48 64 128 256; do
render "$SVG" "$s" "$TMP/ico-$s.png"
ICO_PNGS+=("$TMP/ico-$s.png")
done
(cd "$ROOT" && cargo run -q -p xtask -- ico "$DIR/wordcraft.ico" "${ICO_PNGS[@]}")
# macOS .icns.
if command -v iconutil >/dev/null; then
SET="$TMP/wordcraft.iconset"
mkdir -p "$SET"
for s in 16 32 128 256 512; do
render "$MAC" "$s" "$SET/icon_${s}x${s}.png"
render "$MAC" $((s * 2)) "$SET/icon_${s}x${s}@2x.png"
done
iconutil -c icns -o "$DIR/wordcraft.icns" "$SET"
else
echo "warning: iconutil not found (macOS only); wordcraft.icns not regenerated" >&2
fi
echo "icons written to $DIR"
+9 -7
View File
@@ -1,8 +1,8 @@
//! `cargo xtask assets`: every non-code asset must be attributed in `ASSETS.md`. //! `cargo xtask assets`: every non-code asset must be attributed in `ATTRIBUTION.md`.
//! //!
//! Scans all git-tracked (and untracked, not ignored) files with an asset extension, plus //! Scans all git-tracked (and untracked, not ignored) files with an asset extension, plus
//! everything under `assets/`, `docs/images/` and `examples/`, and fails if a path is not listed //! everything under `assets/`, `docs/images/` and `examples/`, and fails if a path is not listed
//! as `` `path` `` in ASSETS.md. See the asset policy in AGENTS.md. //! as `` `path` `` in ATTRIBUTION.md. See the asset policy in AGENTS.md.
use std::path::Path; use std::path::Path;
use std::process::Command; use std::process::Command;
@@ -34,7 +34,9 @@ const ASSET_EXT: &[&str] = &[
"idml", "idml",
"aco", "aco",
"abr", "abr",
"designcraft", "docx",
"odt",
"rtf",
"mp4", "mp4",
"wav", "wav",
"mp3", "mp3",
@@ -46,7 +48,7 @@ pub fn is_asset(path: &str) -> bool {
ASSET_DIRS.iter().any(|d| path.starts_with(d)) || ASSET_EXT.contains(&ext.as_str()) ASSET_DIRS.iter().any(|d| path.starts_with(d)) || ASSET_EXT.contains(&ext.as_str())
} }
/// Paths that need an ASSETS.md entry but lack one. /// Paths that need an ATTRIBUTION.md entry but lack one.
pub fn missing(files: &[String], assets_md: &str) -> Vec<String> { pub fn missing(files: &[String], assets_md: &str) -> Vec<String> {
files.iter().filter(|f| is_asset(f) && !assets_md.contains(&format!("`{f}`"))).cloned().collect() files.iter().filter(|f| is_asset(f) && !assets_md.contains(&format!("`{f}`"))).cloned().collect()
} }
@@ -58,13 +60,13 @@ pub fn run(root: &Path) -> Result<(), String> {
.output() .output()
.map_err(|e| format!("git ls-files: {e}"))?; .map_err(|e| format!("git ls-files: {e}"))?;
let files: Vec<String> = String::from_utf8_lossy(&out.stdout).lines().filter(|l| root.join(l).exists()).map(str::to_owned).collect(); let files: Vec<String> = String::from_utf8_lossy(&out.stdout).lines().filter(|l| root.join(l).exists()).map(str::to_owned).collect();
let md = std::fs::read_to_string(root.join("ASSETS.md")).map_err(|e| format!("ASSETS.md: {e}"))?; let md = std::fs::read_to_string(root.join("ATTRIBUTION.md")).map_err(|e| format!("ATTRIBUTION.md: {e}"))?;
let miss = missing(&files, &md); let miss = missing(&files, &md);
if miss.is_empty() { if miss.is_empty() {
println!("assets: all {} asset files attributed in ASSETS.md", files.iter().filter(|f| is_asset(f)).count()); println!("assets: all {} asset files attributed in ATTRIBUTION.md", files.iter().filter(|f| is_asset(f)).count());
Ok(()) Ok(())
} else { } else {
Err(format!("{} asset file(s) lack an ASSETS.md entry (author, source, licence):\n {}", miss.len(), miss.join("\n "))) Err(format!("{} asset file(s) lack an ATTRIBUTION.md entry (author, source, licence):\n {}", miss.len(), miss.join("\n ")))
} }
} }
+1 -1
View File
@@ -95,7 +95,7 @@ mod tests {
#[test] #[test]
fn committed_icon_is_valid() { fn committed_icon_is_valid() {
let path = crate::root().join("assets/app-icon/designcraft.ico"); let path = crate::root().join("assets/app-icon/wordcraft.ico");
let Ok(ico) = std::fs::read(&path) else { return }; let Ok(ico) = std::fs::read(&path) else { return };
assert_eq!(&ico[..4], &[0, 0, 1, 0]); assert_eq!(&ico[..4], &[0, 0, 1, 0]);
let n = u16::from_le_bytes([ico[4], ico[5]]) as usize; let n = u16::from_le_bytes([ico[4], ico[5]]) as usize;
+41 -45
View File
@@ -30,28 +30,24 @@ impl Class {
} }
} }
/// The layering table. Names are package names without the `designcraft-` /// The layering table. Names are package names without the `wordcraft-`
/// prefix. /// prefix.
pub const TABLE: &[(&str, Class)] = &[ pub const TABLE: &[(&str, Class)] = &[
("geom", Class::Layer(0)), ("geom", Class::Layer(0)),
("color", Class::Layer(0)),
("doc", Class::Layer(1)), ("doc", Class::Layer(1)),
("fonts", Class::Layer(1)), ("fonts", Class::Layer(1)),
("compose", Class::Layer(2)), ("layout", Class::Layer(2)),
("images", Class::Layer(2)), ("docx", Class::Layer(2)),
("formats", Class::Layer(2)),
("render", Class::Layer(3)), ("render", Class::Layer(3)),
("pdf", Class::Layer(3)), ("pdf", Class::Layer(3)),
("idml", Class::Layer(3)), ("proof", Class::Layer(3)),
("epub", Class::Layer(3)), ("engine", Class::Layer(4)),
("textimport", Class::Layer(3)), ("mcp", Class::Layer(5)),
("format", Class::Layer(3)),
("tools", Class::Layer(4)),
("engine", Class::Layer(5)),
("ui-egui", Class::Layer(6)), ("ui-egui", Class::Layer(6)),
("mcp", Class::Layer(6)),
("testkit", Class::Testkit), ("testkit", Class::Testkit),
// L7 apps and tooling // apps and tooling
("designcraft", Class::Exempt), ("wordcraft", Class::Exempt),
("cli", Class::Exempt), ("cli", Class::Exempt),
("web", Class::Exempt), ("web", Class::Exempt),
("xtask", Class::Exempt), ("xtask", Class::Exempt),
@@ -61,7 +57,7 @@ pub const TABLE: &[(&str, Class)] = &[
/// The L0 foundation is a small chain: `raster` builds on `color` and /// The L0 foundation is a small chain: `raster` builds on `color` and
/// `geom`, which the §3 diagram draws on one line. The GPU backend (`gpu`) /// `geom`, which the §3 diagram draws on one line. The GPU backend (`gpu`)
/// reuses the CPU reference (`compose`) for LUTs and parity tests. /// reuses the CPU reference (`compose`) for LUTs and parity tests.
pub const INTRA_LAYER_ORDER: &[&[&str]] = &[&["geom", "color"], &["doc", "fonts"]]; pub const INTRA_LAYER_ORDER: &[&[&str]] = &[&["doc", "fonts"]];
fn intra_layer_allowed(from: &str, to: &str) -> bool { fn intra_layer_allowed(from: &str, to: &str) -> bool {
let (from, to) = (short_name(from), short_name(to)); let (from, to) = (short_name(from), short_name(to));
@@ -79,7 +75,7 @@ pub const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "rfd
pub const UI_MIN_LAYER: u8 = 6; pub const UI_MIN_LAYER: u8 = 6;
pub fn short_name(pkg: &str) -> &str { pub fn short_name(pkg: &str) -> &str {
pkg.strip_prefix("designcraft-").unwrap_or(pkg) pkg.strip_prefix("wordcraft-").unwrap_or(pkg)
} }
pub fn classify(pkg: &str) -> Option<Class> { pub fn classify(pkg: &str) -> Option<Class> {
@@ -137,7 +133,7 @@ impl std::fmt::Display for Violation {
write!(f, "{krate}: standalone crate must not depend on workspace crate {dep}") write!(f, "{krate}: standalone crate must not depend on workspace crate {dep}")
} }
Violation::TestkitAsNormalDep { krate } => { Violation::TestkitAsNormalDep { krate } => {
write!(f, "{krate}: designcraft-testkit may only be a dev-dependency") write!(f, "{krate}: wordcraft-testkit may only be a dev-dependency")
} }
Violation::UiBelowL6 { krate, dep, layer } => { Violation::UiBelowL6 { krate, dep, layer } => {
write!(f, "{krate} (L{layer}) depends on UI crate `{dep}`; UI toolkits are only allowed in L6+") write!(f, "{krate} (L{layer}) depends on UI crate `{dep}`; UI toolkits are only allowed in L6+")
@@ -245,76 +241,76 @@ mod tests {
#[test] #[test]
fn clean_downward_graph_passes() { fn clean_downward_graph_passes() {
let g = [ let g = [
c("designcraft-geom", &[("kurbo", Normal, false)]), c("wordcraft-geom", &[("kurbo", Normal, false)]),
c("designcraft-doc", &[("designcraft-geom", Normal, true)]), c("wordcraft-doc", &[("wordcraft-geom", Normal, true)]),
c("designcraft-engine", &[("designcraft-doc", Normal, true), ("designcraft-testkit", Dev, true)]), c("wordcraft-engine", &[("wordcraft-doc", Normal, true), ("wordcraft-testkit", Dev, true)]),
c("designcraft-ui-egui", &[("designcraft-engine", Normal, true), ("egui", Normal, false)]), c("wordcraft-ui-egui", &[("wordcraft-engine", Normal, true), ("egui", Normal, false)]),
c("designcraft-cli", &[("designcraft-ui-egui", Normal, true)]), c("wordcraft-cli", &[("wordcraft-ui-egui", Normal, true)]),
]; ];
assert!(check(&g).is_empty(), "{:?}", check(&g)); assert!(check(&g).is_empty(), "{:?}", check(&g));
} }
#[test] #[test]
fn upward_dependency_flagged() { fn upward_dependency_flagged() {
let v = check(&[c("designcraft-doc", &[("designcraft-engine", Normal, true)])]); let v = check(&[c("wordcraft-doc", &[("wordcraft-engine", Normal, true)])]);
assert!(matches!(v[..], [Violation::Upward { from: 1, to: 5, .. }])); assert!(matches!(v[..], [Violation::Upward { from: 1, to: 4, .. }]));
} }
#[test] #[test]
fn sideways_dependency_flagged() { fn sideways_dependency_flagged() {
let v = check(&[c("designcraft-compose", &[("designcraft-images", Normal, true)])]); let v = check(&[c("wordcraft-layout", &[("wordcraft-docx", Normal, true)])]);
assert!(matches!(v[..], [Violation::Upward { from: 2, to: 2, .. }])); assert!(matches!(v[..], [Violation::Upward { from: 2, to: 2, .. }]));
} }
#[test] #[test]
fn l0_foundation_chain_allowed_one_way() { fn l0_foundation_chain_allowed_one_way() {
assert!(check(&[c("designcraft-color", &[("designcraft-geom", Normal, true)])]).is_empty()); assert!(check(&[c("wordcraft-doc", &[("wordcraft-geom", Normal, true)])]).is_empty());
let v = check(&[c("designcraft-geom", &[("designcraft-color", Normal, true)])]); let v = check(&[c("wordcraft-geom", &[("wordcraft-doc", Normal, true)])]);
assert!(matches!(v[..], [Violation::Upward { from: 0, to: 0, .. }])); assert!(matches!(v[..], [Violation::Upward { from: 0, to: 1, .. }]));
} }
#[test] #[test]
fn self_dev_dependency_ignored() { fn self_dev_dependency_ignored() {
assert!(check(&[c("designcraft-doc", &[("designcraft-doc", Dev, true)])]).is_empty()); assert!(check(&[c("wordcraft-doc", &[("wordcraft-doc", Dev, true)])]).is_empty());
} }
#[test] #[test]
fn upward_dev_dependency_flagged() { fn upward_dev_dependency_flagged() {
let v = check(&[c("designcraft-geom", &[("designcraft-doc", Dev, true)])]); let v = check(&[c("wordcraft-geom", &[("wordcraft-doc", Dev, true)])]);
assert!(matches!(v[..], [Violation::Upward { kind: Dev, .. }])); assert!(matches!(v[..], [Violation::Upward { kind: Dev, .. }]));
} }
#[test] #[test]
fn ui_crates_below_l6_flagged() { fn ui_crates_below_l6_flagged() {
for dep in ["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy_ecs", "bevy"] { for dep in ["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy_ecs", "bevy"] {
let v = check(&[c("designcraft-engine", &[(dep, Normal, false)])]); let v = check(&[c("wordcraft-engine", &[(dep, Normal, false)])]);
assert!(matches!(v[..], [Violation::UiBelowL6 { layer: 5, .. }]), "{dep}"); assert!(matches!(v[..], [Violation::UiBelowL6 { layer: 4, .. }]), "{dep}");
} }
assert!(check(&[c("designcraft-engine", &[("egui_extras_not", Normal, false)])]).is_empty()); assert!(check(&[c("wordcraft-engine", &[("egui_extras_not", Normal, false)])]).is_empty());
assert!(check(&[c("designcraft-mcp", &[("winit", Normal, false)])]).is_empty()); assert!(!check(&[c("wordcraft-mcp", &[("winit", Normal, false)])]).is_empty());
} }
#[test] #[test]
fn unregistered_crate_is_error() { fn unregistered_crate_is_error() {
let v = check(&[c("designcraft-mystery", &[])]); let v = check(&[c("wordcraft-mystery", &[])]);
assert!(matches!(&v[..], [Violation::Unregistered { krate }] if krate == "designcraft-mystery")); assert!(matches!(&v[..], [Violation::Unregistered { krate }] if krate == "wordcraft-mystery"));
assert!(v[0].to_string().contains("register")); assert!(v[0].to_string().contains("register"));
} }
#[test] #[test]
fn testkit_only_as_dev_dependency() { fn testkit_only_as_dev_dependency() {
let v = check(&[c("designcraft-render", &[("designcraft-testkit", Normal, true)])]); let v = check(&[c("wordcraft-render", &[("wordcraft-testkit", Normal, true)])]);
assert!(matches!(v[..], [Violation::TestkitAsNormalDep { .. }])); assert!(matches!(v[..], [Violation::TestkitAsNormalDep { .. }]));
assert!(check(&[c("designcraft-render", &[("designcraft-testkit", Dev, true)])]).is_empty()); assert!(check(&[c("wordcraft-render", &[("wordcraft-testkit", Dev, true)])]).is_empty());
// testkit itself may use anything up to L5 but not L6 crates. // testkit itself may use anything up to L5 but not L6 crates.
assert!(check(&[c("designcraft-testkit", &[("designcraft-engine", Normal, true)])]).is_empty()); assert!(check(&[c("wordcraft-testkit", &[("wordcraft-engine", Normal, true)])]).is_empty());
assert!(!check(&[c("designcraft-testkit", &[("designcraft-ui-egui", Normal, true)])]).is_empty()); assert!(!check(&[c("wordcraft-testkit", &[("wordcraft-ui-egui", Normal, true)])]).is_empty());
} }
#[test] #[test]
fn apps_and_xtask_exempt() { fn apps_and_xtask_exempt() {
for app in ["designcraft", "designcraft-cli", "designcraft-web", "xtask"] { for app in ["wordcraft", "wordcraft-cli", "wordcraft-web", "xtask"] {
assert!(check(&[c(app, &[("egui", Normal, false), ("designcraft-ui-egui", Normal, true)])]).is_empty()); assert!(check(&[c(app, &[("egui", Normal, false), ("wordcraft-ui-egui", Normal, true)])]).is_empty());
} }
} }
@@ -322,17 +318,17 @@ mod tests {
fn metadata_parsing() { fn metadata_parsing() {
let meta: Value = serde_json::from_str( let meta: Value = serde_json::from_str(
r#"{"packages":[ r#"{"packages":[
{"name":"designcraft-doc","dependencies":[ {"name":"wordcraft-doc","dependencies":[
{"name":"designcraft-geom","kind":null,"path":"/x/crates/geom"}, {"name":"wordcraft-geom","kind":null,"path":"/x/crates/geom"},
{"name":"serde","kind":null}, {"name":"serde","kind":null},
{"name":"proptest","kind":"dev"}]}, {"name":"proptest","kind":"dev"}]},
{"name":"designcraft-geom","dependencies":[]} {"name":"wordcraft-geom","dependencies":[]}
]}"#, ]}"#,
) )
.unwrap(); .unwrap();
let g = from_metadata(&meta).unwrap(); let g = from_metadata(&meta).unwrap();
assert_eq!(g.len(), 2); assert_eq!(g.len(), 2);
let doc = g.iter().find(|c| c.name == "designcraft-doc").unwrap(); let doc = g.iter().find(|c| c.name == "wordcraft-doc").unwrap();
assert!(doc.deps[0].workspace && !doc.deps[1].workspace); assert!(doc.deps[0].workspace && !doc.deps[1].workspace);
assert_eq!(doc.deps[2].kind, Dev); assert_eq!(doc.deps[2].kind, Dev);
assert!(check(&g).is_empty()); assert!(check(&g).is_empty());
+3 -3
View File
@@ -17,14 +17,14 @@ const USAGE: &str = "\
usage: cargo xtask <command> usage: cargo xtask <command>
commands: commands:
assets check that every icon/image/font/asset is attributed in ASSETS.md assets check that every icon/image/font/asset is attributed in ATTRIBUTION.md
layers enforce the crate dependency layering (plan/architecture.md §3) layers enforce the crate dependency layering (plan/architecture.md §3)
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates
ci fmt --check, clippy -D warnings, test, assets, layers, wasm (stops at first failure) ci fmt --check, clippy -D warnings, test, assets, layers, wasm (stops at first failure)
corpus [--download] corpus [--download]
show where test corpora live; --download fetches PngSuite into corpus/pngsuite show where test corpora live; --download fetches PngSuite into corpus/pngsuite
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
parity recompute the InDesign feature-parity summary in docs/parity.md parity recompute the Word feature-parity summary in docs/parity.md
ico <out.ico> <in.png>... ico <out.ico> <in.png>...
pack square PNGs (<= 256 px) into a Windows .ico (see packaging/icons.sh) pack square PNGs (<= 256 px) into a Windows .ico (see packaging/icons.sh)
"; ";
@@ -207,7 +207,7 @@ fn cmd_corpus(download: bool) -> Result<(), String> {
"Test corpora live under {} (git-ignored, never committed). "Test corpora live under {} (git-ignored, never committed).
Tests that use a corpus skip cleanly when it is absent. Tests that use a corpus skip cleanly when it is absent.
corpus/pngsuite/ PngSuite (public domain) — designcraft-codecs compares every file corpus/pngsuite/ PngSuite (public domain) — wordcraft-codecs compares every file
against the `image` crate. Fetch: cargo xtask corpus --download against the `image` crate. Fetch: cargo xtask corpus --download
corpus/psd/ PSD samples from MIT/BSD projects (ag-psd, psd-tools test data). corpus/psd/ PSD samples from MIT/BSD projects (ag-psd, psd-tools test data).
Copy files in manually; licences must be MIT/BSD/CC0. Copy files in manually; licences must be MIT/BSD/CC0.
+15 -92
View File
@@ -1,99 +1,22 @@
//! `cargo xtask parity`: recompute the feature-parity summary in docs/parity.md from its row //! `cargo xtask parity`: write docs/parity.md from the live command registry compared with the
//! table (each InDesign feature scored D / P / M, weighted by priority). //! word-processor feature catalog (via `wordcraft-cli parity --markdown`).
use std::path::Path; use std::path::Path;
const START: &str = "<!-- SUMMARY -->";
const ROWS: &str = "\n## Rows";
/// (area, feature, priority 0–2, value 0 / 0.5 / 1) for each table row.
pub fn rows(text: &str) -> Vec<(String, String, u8, f64)> {
let mut out = Vec::new();
for l in text.lines() {
let cells: Vec<&str> = l.trim().trim_matches('|').split('|').map(str::trim).collect();
if cells.len() != 4 {
continue;
}
let pri = match cells[2] {
"P0" => 0,
"P1" => 1,
"P2" => 2,
_ => continue,
};
let v = match cells[3] {
"D" => 1.0,
"P" => 0.5,
"M" => 0.0,
_ => continue,
};
out.push((cells[0].to_string(), cells[1].to_string(), pri, v));
}
out
}
fn weight(p: u8) -> f64 {
[3.0, 2.0, 1.0][p as usize]
}
pub fn summary(rows: &[(String, String, u8, f64)]) -> String {
let pct = |rs: &[&(String, String, u8, f64)]| {
let tot: f64 = rs.iter().map(|r| weight(r.2)).sum();
if tot == 0.0 { 0.0 } else { 100.0 * rs.iter().map(|r| weight(r.2) * r.3).sum::<f64>() / tot }
};
let all: Vec<_> = rows.iter().collect();
let mut s = format!(
"**Weighted breadth parity: {:.0}%** over {} features.\n\n| Priority | Features | Done | Partial | Missing | Parity |\n|---|---|---|---|---|---|\n",
pct(&all),
rows.len()
);
for p in 0..3u8 {
let rr: Vec<_> = rows.iter().filter(|r| r.2 == p).collect();
let n = |v: f64| rr.iter().filter(|r| r.3 == v).count();
let unweighted = if rr.is_empty() { 0.0 } else { 100.0 * rr.iter().map(|r| r.3).sum::<f64>() / rr.len() as f64 };
s += &format!("| P{p} | {} | {} | {} | {} | {unweighted:.0}% |\n", rr.len(), n(1.0), n(0.5), n(0.0));
}
s += "\n| Area | Parity |\n|---|---|\n";
let mut areas: Vec<&str> = Vec::new();
for r in rows {
if !areas.contains(&r.0.as_str()) {
areas.push(&r.0);
}
}
for a in areas {
let rr: Vec<_> = rows.iter().filter(|r| r.0 == a).collect();
s += &format!("| {a} | {:.0}% |\n", pct(&rr));
}
let open: Vec<&str> = rows.iter().filter(|r| r.2 == 0 && r.3 < 1.0).map(|r| r.1.as_str()).collect();
s += &format!("\nOpen P0 items: {}.\n", open.join(", "));
s
}
pub fn run(root: &Path) -> Result<(), String> { pub fn run(root: &Path) -> Result<(), String> {
let out = crate::cargo()
.args(["run", "-q", "-p", "wordcraft-cli", "--", "parity", "--markdown"])
.output()
.map_err(|e| format!("cargo run wordcraft-cli: {e}"))?;
if !out.status.success() {
return Err(format!("wordcraft-cli parity failed:\n{}", String::from_utf8_lossy(&out.stderr)));
}
let md = String::from_utf8_lossy(&out.stdout).to_string();
let path = root.join("docs/parity.md"); let path = root.join("docs/parity.md");
let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?; std::fs::write(&path, &md).map_err(|e| format!("{}: {e}", path.display()))?;
let (Some(a), Some(b)) = (text.find(START), text.find(ROWS)) else { if let Some(line) = md.lines().find(|l| l.starts_with("**")) {
return Err("docs/parity.md needs a `<!-- SUMMARY -->` marker before `## Rows`".into()); println!("{line}");
}; }
let rows = rows(&text[b..]); println!("wrote {}", path.display());
let s = summary(&rows);
let new = format!("{}{START}\n{s}{}", &text[..a], &text[b..]);
std::fs::write(&path, new).map_err(|e| e.to_string())?;
print!("{s}");
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scores_rows() {
let t = "| Area | Feature | Priority | Status |\n|---|---|---|---|\n| A | x | P0 | D |\n| A | y | P2 | M |\n| B | z | P1 | P |\n";
let r = rows(t);
assert_eq!(r.len(), 3);
let s = summary(&r);
// (3·1 + 1·0 + 2·0.5) / 6 = 67%
assert!(s.contains("**Weighted breadth parity: 67%**"), "{s}");
assert!(s.contains("Open P0 items: ."));
}
}