diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..c4125c1c7 --- /dev/null +++ b/AGENTS.md @@ -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`** 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. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md new file mode 100644 index 000000000..8e1e8a398 --- /dev/null +++ b/ATTRIBUTION.md @@ -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 | diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..c4125c1c7 --- /dev/null +++ b/CLAUDE.md @@ -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`** 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. diff --git a/Cargo.lock b/Cargo.lock index 053950cf5..a95959dea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -189,6 +189,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "arboard" version = "3.6.1" @@ -763,6 +772,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "dify" version = "0.8.0" @@ -2717,6 +2737,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "quick-xml" version = "0.41.0" @@ -3865,7 +3894,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.41.0", "quote", ] @@ -4470,6 +4499,17 @@ dependencies = [ "wordcraft-ui-egui", ] +[[package]] +name = "wordcraft-cli" +version = "0.1.0" +dependencies = [ + "serde_json", + "wordcraft-doc", + "wordcraft-engine", + "wordcraft-mcp", + "wordcraft-render", +] + [[package]] name = "wordcraft-doc" version = "0.1.0" @@ -4482,6 +4522,20 @@ dependencies = [ "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]] name = "wordcraft-engine" version = "0.1.0" @@ -4530,6 +4584,17 @@ dependencies = [ "wordcraft-geom", ] +[[package]] +name = "wordcraft-mcp" +version = "0.1.0" +dependencies = [ + "log", + "serde_json", + "wordcraft-doc", + "wordcraft-engine", + "wordcraft-render", +] + [[package]] name = "wordcraft-render" version = "0.1.0" @@ -4840,6 +4905,20 @@ dependencies = [ "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]] name = "zlib-rs" version = "0.6.8" @@ -4852,6 +4931,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "zune-core" version = "0.5.3" diff --git a/Cargo.toml b/Cargo.toml index c681dba6a..c43cd3521 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "3" members = ["crates/*", "apps/*", "xtask"] -default-members = ["crates/*", "apps/wordcraft", "xtask"] +default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"] [workspace.package] version = "0.1.0" diff --git a/NOTICE b/NOTICE new file mode 100644 index 000000000..dcc9ef435 --- /dev/null +++ b/NOTICE @@ -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-.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. diff --git a/apps/wordcraft-cli/Cargo.toml b/apps/wordcraft-cli/Cargo.toml new file mode 100644 index 000000000..782aaf129 --- /dev/null +++ b/apps/wordcraft-cli/Cargo.toml @@ -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 } diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs new file mode 100644 index 000000000..bde3c42e2 --- /dev/null +++ b/apps/wordcraft-cli/src/main.rs @@ -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 convert between formats (docx, pdf, odt, rtf, html, md, txt, json, png) + wordcraft-cli info pages, words, paragraphs, properties (JSON) + wordcraft-cli text plain text + wordcraft-cli inspect document structure (JSON) + wordcraft-cli render [--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 { + 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 { + 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 = 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::().ok()).unwrap_or(1); + let scale = arg_value(&rest, "--scale").and_then(|p| p.parse::().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::(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 = 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 = std::env::args().skip(1).collect(); + match run(&args) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("wordcraft-cli: {e}"); + ExitCode::FAILURE + } + } +} diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index a14270e1b..052eb6e24 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -91,6 +91,15 @@ fn services() -> Services { } } +/// Window, Dock and taskbar icon. +fn app_icon() -> Option { + #[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 { let mut control_port: Option = std::env::var("WORDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); let mut files = Vec::new(); @@ -107,7 +116,7 @@ fn main() -> eframe::Result { _ => files.push(a), } } - let options = eframe::NativeOptions { + let mut options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("WordCraft") .with_app_id("ai.storyteller.wordcraft") @@ -119,6 +128,9 @@ fn main() -> eframe::Result { .with_title_shown(false), ..Default::default() }; + if let Some(icon) = app_icon() { + options.viewport = options.viewport.with_icon(icon); + } eframe::run_native( "WordCraft", options, diff --git a/assets/app-icon/hicolor/128x128/apps/ai.storyteller.wordcraft.png b/assets/app-icon/hicolor/128x128/apps/ai.storyteller.wordcraft.png new file mode 100644 index 000000000..35bb5e03b Binary files /dev/null and b/assets/app-icon/hicolor/128x128/apps/ai.storyteller.wordcraft.png differ diff --git a/assets/app-icon/hicolor/16x16/apps/ai.storyteller.wordcraft.png b/assets/app-icon/hicolor/16x16/apps/ai.storyteller.wordcraft.png new file mode 100644 index 000000000..c7b1794e7 Binary files /dev/null and b/assets/app-icon/hicolor/16x16/apps/ai.storyteller.wordcraft.png differ diff --git a/assets/app-icon/hicolor/24x24/apps/ai.storyteller.wordcraft.png b/assets/app-icon/hicolor/24x24/apps/ai.storyteller.wordcraft.png new file mode 100644 index 000000000..604f1374c Binary files /dev/null and b/assets/app-icon/hicolor/24x24/apps/ai.storyteller.wordcraft.png differ diff --git a/assets/app-icon/hicolor/256x256/apps/ai.storyteller.wordcraft.png b/assets/app-icon/hicolor/256x256/apps/ai.storyteller.wordcraft.png new file mode 100644 index 000000000..fe4bc13e5 Binary files /dev/null and b/assets/app-icon/hicolor/256x256/apps/ai.storyteller.wordcraft.png differ diff --git a/assets/app-icon/hicolor/32x32/apps/ai.storyteller.wordcraft.png b/assets/app-icon/hicolor/32x32/apps/ai.storyteller.wordcraft.png new file mode 100644 index 000000000..09faf103c Binary files /dev/null and b/assets/app-icon/hicolor/32x32/apps/ai.storyteller.wordcraft.png differ diff --git a/assets/app-icon/hicolor/48x48/apps/ai.storyteller.wordcraft.png b/assets/app-icon/hicolor/48x48/apps/ai.storyteller.wordcraft.png new file mode 100644 index 000000000..576730e9b Binary files /dev/null and b/assets/app-icon/hicolor/48x48/apps/ai.storyteller.wordcraft.png differ diff --git a/assets/app-icon/hicolor/512x512/apps/ai.storyteller.wordcraft.png b/assets/app-icon/hicolor/512x512/apps/ai.storyteller.wordcraft.png new file mode 100644 index 000000000..ba85ab6b0 Binary files /dev/null and b/assets/app-icon/hicolor/512x512/apps/ai.storyteller.wordcraft.png differ diff --git a/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png b/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png new file mode 100644 index 000000000..9c92c68f2 Binary files /dev/null and b/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png differ diff --git a/assets/app-icon/hicolor/scalable/apps/ai.storyteller.wordcraft.svg b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.wordcraft.svg new file mode 100644 index 000000000..e46f26f61 --- /dev/null +++ b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.wordcraft.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/assets/app-icon/wordcraft-1024.png b/assets/app-icon/wordcraft-1024.png new file mode 100644 index 000000000..f458131ac Binary files /dev/null and b/assets/app-icon/wordcraft-1024.png differ diff --git a/assets/app-icon/wordcraft-macos-512.png b/assets/app-icon/wordcraft-macos-512.png new file mode 100644 index 000000000..ee9d67004 Binary files /dev/null and b/assets/app-icon/wordcraft-macos-512.png differ diff --git a/assets/app-icon/wordcraft.icns b/assets/app-icon/wordcraft.icns new file mode 100644 index 000000000..2dbe3f468 Binary files /dev/null and b/assets/app-icon/wordcraft.icns differ diff --git a/assets/app-icon/wordcraft.ico b/assets/app-icon/wordcraft.ico new file mode 100644 index 000000000..1f55672a1 Binary files /dev/null and b/assets/app-icon/wordcraft.ico differ diff --git a/assets/app-icon/wordcraft.svg b/assets/app-icon/wordcraft.svg new file mode 100644 index 000000000..e46f26f61 --- /dev/null +++ b/assets/app-icon/wordcraft.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/crates/docx/Cargo.toml b/crates/docx/Cargo.toml new file mode 100644 index 000000000..2fa1787ad --- /dev/null +++ b/crates/docx/Cargo.toml @@ -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 } diff --git a/crates/docx/src/lib.rs b/crates/docx/src/lib.rs new file mode 100644 index 000000000..55601f9ee --- /dev/null +++ b/crates/docx/src/lib.rs @@ -0,0 +1 @@ +#![deny(clippy::unwrap_used)] diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml new file mode 100644 index 000000000..8044e7624 --- /dev/null +++ b/crates/mcp/Cargo.toml @@ -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 } diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs new file mode 100644 index 000000000..b458727b2 --- /dev/null +++ b/crates/mcp/src/backend.rs @@ -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; + /// 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)>, + 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 { + 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 { + 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 { + 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) + } +} diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs new file mode 100644 index 000000000..796927b2f --- /dev/null +++ b/crates/mcp/src/lib.rs @@ -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 { + 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; diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs new file mode 100644 index 000000000..63f6764c6 --- /dev/null +++ b/crates/mcp/src/server.rs @@ -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, + 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) -> Value { + json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into()}}) +} + +impl Server { + pub fn new(backend: Box) -> 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 { + let line = line.trim(); + if line.is_empty() { + return None; + } + let reply = match serde_json::from_str::(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 = 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 { + 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 { + 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}"))), + } + } +} diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs new file mode 100644 index 000000000..52756749c --- /dev/null +++ b/crates/mcp/src/tests.rs @@ -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")); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs new file mode 100644 index 000000000..ab4b86967 --- /dev/null +++ b/crates/mcp/src/tools.rs @@ -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, + pub is_error: bool, +} + +impl ToolResult { + pub fn text(t: impl Into) -> 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) -> 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 { + b.call("engine.execute", json!({"command": id, "params": params})) +} + +fn wrap(r: Result) -> 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 = 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}`")), + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md new file mode 100644 index 000000000..3fbf7647e --- /dev/null +++ b/docs/control-protocol.md @@ -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}`. diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 000000000..cf039b495 --- /dev/null +++ b/docs/mcp.md @@ -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. diff --git a/docs/parity.md b/docs/parity.md new file mode 100644 index 000000000..9eba661e9 --- /dev/null +++ b/docs/parity.md @@ -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`) + diff --git a/packaging/icons.sh b/packaging/icons.sh new file mode 100755 index 000000000..368f7c5f1 --- /dev/null +++ b/packaging/icons.sh @@ -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; 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" diff --git a/xtask/src/assets.rs b/xtask/src/assets.rs index 1aef2dbac..1f9d5c309 100644 --- a/xtask/src/assets.rs +++ b/xtask/src/assets.rs @@ -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 //! 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::process::Command; @@ -34,7 +34,9 @@ const ASSET_EXT: &[&str] = &[ "idml", "aco", "abr", - "designcraft", + "docx", + "odt", + "rtf", "mp4", "wav", "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()) } -/// 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 { 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() .map_err(|e| format!("git ls-files: {e}"))?; let files: Vec = 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); 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(()) } 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 "))) } } diff --git a/xtask/src/ico.rs b/xtask/src/ico.rs index 6d99ebfbc..42413b129 100644 --- a/xtask/src/ico.rs +++ b/xtask/src/ico.rs @@ -95,7 +95,7 @@ mod tests { #[test] 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 }; assert_eq!(&ico[..4], &[0, 0, 1, 0]); let n = u16::from_le_bytes([ico[4], ico[5]]) as usize; diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 3e9e28c0a..1e1a8c7d1 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -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. pub const TABLE: &[(&str, Class)] = &[ ("geom", Class::Layer(0)), - ("color", Class::Layer(0)), ("doc", Class::Layer(1)), ("fonts", Class::Layer(1)), - ("compose", Class::Layer(2)), - ("images", Class::Layer(2)), + ("layout", Class::Layer(2)), + ("docx", Class::Layer(2)), + ("formats", Class::Layer(2)), ("render", Class::Layer(3)), ("pdf", Class::Layer(3)), - ("idml", Class::Layer(3)), - ("epub", Class::Layer(3)), - ("textimport", Class::Layer(3)), - ("format", Class::Layer(3)), - ("tools", Class::Layer(4)), - ("engine", Class::Layer(5)), + ("proof", Class::Layer(3)), + ("engine", Class::Layer(4)), + ("mcp", Class::Layer(5)), ("ui-egui", Class::Layer(6)), - ("mcp", Class::Layer(6)), ("testkit", Class::Testkit), - // L7 apps and tooling - ("designcraft", Class::Exempt), + // apps and tooling + ("wordcraft", Class::Exempt), ("cli", Class::Exempt), ("web", 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 /// `geom`, which the §3 diagram draws on one line. The GPU backend (`gpu`) /// 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 { 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 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 { @@ -137,7 +133,7 @@ impl std::fmt::Display for Violation { write!(f, "{krate}: standalone crate must not depend on workspace crate {dep}") } 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 } => { write!(f, "{krate} (L{layer}) depends on UI crate `{dep}`; UI toolkits are only allowed in L6+") @@ -245,76 +241,76 @@ mod tests { #[test] fn clean_downward_graph_passes() { let g = [ - c("designcraft-geom", &[("kurbo", Normal, false)]), - c("designcraft-doc", &[("designcraft-geom", Normal, true)]), - c("designcraft-engine", &[("designcraft-doc", Normal, true), ("designcraft-testkit", Dev, true)]), - c("designcraft-ui-egui", &[("designcraft-engine", Normal, true), ("egui", Normal, false)]), - c("designcraft-cli", &[("designcraft-ui-egui", Normal, true)]), + c("wordcraft-geom", &[("kurbo", Normal, false)]), + c("wordcraft-doc", &[("wordcraft-geom", Normal, true)]), + c("wordcraft-engine", &[("wordcraft-doc", Normal, true), ("wordcraft-testkit", Dev, true)]), + c("wordcraft-ui-egui", &[("wordcraft-engine", Normal, true), ("egui", Normal, false)]), + c("wordcraft-cli", &[("wordcraft-ui-egui", Normal, true)]), ]; assert!(check(&g).is_empty(), "{:?}", check(&g)); } #[test] fn upward_dependency_flagged() { - let v = check(&[c("designcraft-doc", &[("designcraft-engine", Normal, true)])]); - assert!(matches!(v[..], [Violation::Upward { from: 1, to: 5, .. }])); + let v = check(&[c("wordcraft-doc", &[("wordcraft-engine", Normal, true)])]); + assert!(matches!(v[..], [Violation::Upward { from: 1, to: 4, .. }])); } #[test] 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, .. }])); } #[test] fn l0_foundation_chain_allowed_one_way() { - assert!(check(&[c("designcraft-color", &[("designcraft-geom", Normal, true)])]).is_empty()); - let v = check(&[c("designcraft-geom", &[("designcraft-color", Normal, true)])]); - assert!(matches!(v[..], [Violation::Upward { from: 0, to: 0, .. }])); + assert!(check(&[c("wordcraft-doc", &[("wordcraft-geom", Normal, true)])]).is_empty()); + let v = check(&[c("wordcraft-geom", &[("wordcraft-doc", Normal, true)])]); + assert!(matches!(v[..], [Violation::Upward { from: 0, to: 1, .. }])); } #[test] 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] 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, .. }])); } #[test] fn ui_crates_below_l6_flagged() { for dep in ["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy_ecs", "bevy"] { - let v = check(&[c("designcraft-engine", &[(dep, Normal, false)])]); - assert!(matches!(v[..], [Violation::UiBelowL6 { layer: 5, .. }]), "{dep}"); + let v = check(&[c("wordcraft-engine", &[(dep, Normal, false)])]); + assert!(matches!(v[..], [Violation::UiBelowL6 { layer: 4, .. }]), "{dep}"); } - assert!(check(&[c("designcraft-engine", &[("egui_extras_not", Normal, false)])]).is_empty()); - assert!(check(&[c("designcraft-mcp", &[("winit", Normal, false)])]).is_empty()); + assert!(check(&[c("wordcraft-engine", &[("egui_extras_not", Normal, false)])]).is_empty()); + assert!(!check(&[c("wordcraft-mcp", &[("winit", Normal, false)])]).is_empty()); } #[test] fn unregistered_crate_is_error() { - let v = check(&[c("designcraft-mystery", &[])]); - assert!(matches!(&v[..], [Violation::Unregistered { krate }] if krate == "designcraft-mystery")); + let v = check(&[c("wordcraft-mystery", &[])]); + assert!(matches!(&v[..], [Violation::Unregistered { krate }] if krate == "wordcraft-mystery")); assert!(v[0].to_string().contains("register")); } #[test] 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!(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. - assert!(check(&[c("designcraft-testkit", &[("designcraft-engine", Normal, true)])]).is_empty()); - assert!(!check(&[c("designcraft-testkit", &[("designcraft-ui-egui", Normal, true)])]).is_empty()); + assert!(check(&[c("wordcraft-testkit", &[("wordcraft-engine", Normal, true)])]).is_empty()); + assert!(!check(&[c("wordcraft-testkit", &[("wordcraft-ui-egui", Normal, true)])]).is_empty()); } #[test] fn apps_and_xtask_exempt() { - for app in ["designcraft", "designcraft-cli", "designcraft-web", "xtask"] { - assert!(check(&[c(app, &[("egui", Normal, false), ("designcraft-ui-egui", Normal, true)])]).is_empty()); + for app in ["wordcraft", "wordcraft-cli", "wordcraft-web", "xtask"] { + assert!(check(&[c(app, &[("egui", Normal, false), ("wordcraft-ui-egui", Normal, true)])]).is_empty()); } } @@ -322,17 +318,17 @@ mod tests { fn metadata_parsing() { let meta: Value = serde_json::from_str( r#"{"packages":[ - {"name":"designcraft-doc","dependencies":[ - {"name":"designcraft-geom","kind":null,"path":"/x/crates/geom"}, + {"name":"wordcraft-doc","dependencies":[ + {"name":"wordcraft-geom","kind":null,"path":"/x/crates/geom"}, {"name":"serde","kind":null}, {"name":"proptest","kind":"dev"}]}, - {"name":"designcraft-geom","dependencies":[]} + {"name":"wordcraft-geom","dependencies":[]} ]}"#, ) .unwrap(); let g = from_metadata(&meta).unwrap(); 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_eq!(doc.deps[2].kind, Dev); assert!(check(&g).is_empty()); diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 6715e9153..38a575918 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -17,14 +17,14 @@ const USAGE: &str = "\ usage: cargo xtask 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) 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) corpus [--download] 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`) - parity recompute the InDesign feature-parity summary in docs/parity.md + parity recompute the Word feature-parity summary in docs/parity.md ico ... 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). 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 corpus/psd/ PSD samples from MIT/BSD projects (ag-psd, psd-tools test data). Copy files in manually; licences must be MIT/BSD/CC0. diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs index d0dfcf61a..86952e75a 100644 --- a/xtask/src/parity.rs +++ b/xtask/src/parity.rs @@ -1,99 +1,22 @@ -//! `cargo xtask parity`: recompute the feature-parity summary in docs/parity.md from its row -//! table (each InDesign feature scored D / P / M, weighted by priority). +//! `cargo xtask parity`: write docs/parity.md from the live command registry compared with the +//! word-processor feature catalog (via `wordcraft-cli parity --markdown`). use std::path::Path; -const START: &str = ""; -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::() / 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::() / 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> { + 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 text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?; - let (Some(a), Some(b)) = (text.find(START), text.find(ROWS)) else { - return Err("docs/parity.md needs a `` marker before `## Rows`".into()); - }; - let rows = rows(&text[b..]); - 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}"); + std::fs::write(&path, &md).map_err(|e| format!("{}: {e}", path.display()))?; + if let Some(line) = md.lines().find(|l| l.starts_with("**")) { + println!("{line}"); + } + println!("wrote {}", path.display()); 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: .")); - } -}