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>
@@ -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.
|
||||||
@@ -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 |
|
||||||
@@ -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.
|
||||||
@@ -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,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"
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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, ¶ms).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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 604 B |
|
After Width: | Height: | Size: 1001 B |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
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 |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 18 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 |
@@ -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 }
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
#![deny(clippy::unwrap_used)]
|
||||||
@@ -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 }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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", ¶ms).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, ¶ms).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;
|
||||||
@@ -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, ¶ms) {
|
||||||
|
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}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
@@ -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}`")),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`.
|
||||||
@@ -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.
|
||||||
@@ -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`)
|
||||||
|
|
||||||
@@ -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"
|
||||||
@@ -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 ")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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: ."));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||