Skip to content

feat(export): export a page to Word (DOCX) and OpenDocument (ODT) - #46

Merged
christianhuening merged 33 commits into
mainfrom
feat/page-export
Oct 24, 2026
Merged

christianhuening merged 33 commits into
mainfrom
feat/page-export

Conversation

@christianhuening

Copy link
Copy Markdown
Contributor

Summary

Export the open page as a Word (.docx) or OpenDocument (.odt) file from the page header, ready to send around internally. Mermaid diagrams and Excalidraw boards are included as pictures. Design: docs/superpowers/specs/2026-10-23-page-export-docx-odt-design.md (with the approved mockup next to it). Plan: docs/superpowers/plans/2026-10-23-page-export-docx-odt.md.

sequenceDiagram
    participant UI as Browser (ExportMenu → useExport)
    participant S as knot-server
    participant X as knot-export (pure crate)
    UI->>S: GET /api/docs/{id}/export/diagrams
    S->>X: walk(live Y.Doc snapshot)
    S-->>UI: {limits, diagrams: mermaid source / board ids}
    UI->>UI: render each diagram → PNG at 2×
    UI->>S: POST /api/docs/{id}/export/{docx|odt} (multipart PNGs)
    S->>S: resolve images (per-blob ACL, 40 MiB budget, WebP→PNG), mentions, links
    S->>X: write_docx / write_odt (spawn_blocking, 2 permits)
    S-->>UI: file + X-Knot-Export-Fallbacks
    UI->>UI: save "<title>.docx", toast what couldn't be included
Loading
crates/knot-export/          NEW pure crate (yrs, zip, image-webp, png, imagesize)
├── walk.rs, tables.rs       Y.Doc → export model; depth cap, table and page limits
├── docx/, odt/              hand-written OOXML / ODF writers (named styles, template-ready)
└── examples/kitchen_sink.rs sample files for manual acceptance
crates/knot-server/
├── export.rs                live snapshot (shared with markdown), asset resolution
├── routes/api/export.rs     GET diagrams, POST export/{format}, limits, metrics
└── export_check.rs          `knot-server export-check`: offline export of every page, no migrations
web/src/features/export/     ExportMenu, useExport, renderDiagrams, API client, file helpers
e2e/flows/export.spec.ts     DOCX + ODT through the UI
  • The download icon is now a menu with four items: Word, OpenDocument, Markdown (zip), and Markdown with subpages (zip). The Markdown items are hidden on template pages, because the zip export refuses them. The shift-click shortcut is gone.
  • Anything that can't be exported falls back to a placeholder instead of failing the export: a diagram without a picture, an image the user can't read or that is over budget, an unknown element. The user gets a toast saying what was left out.
  • Abuse limits:
    • nesting depth is capped at 64;
    • tables are capped at 64 columns and 64-cell spans, with a page-wide grid budget;
    • the image budget is 40 MiB, and WebP images above 4096×4096 are refused;
    • uploads are capped at 10 MiB in total and 2 MiB per part;
    • at most two exports run at once per process;
    • external images are never fetched.

Evidence

  • Before: the page could only be exported as a Markdown zip.
    After:

    • cargo nextest run --workspace --all-features: 626 passed, 2 skipped. That includes 137 knot-export tests, which unzip and parse every part (relationships, content types, styles, numbering ids, child order, manifest, covered cells).
    • pnpm test: 474 passed. tsc, eslint --max-warnings 0, cargo fmt --check and clippy -D warnings are all clean.
    • e2e flows/export.spec.ts + flows/mobile.spec.ts: 4 passed in Chromium. The tests download real DOCX and ODT files and assert the headings, the picture parts, and a correctly sized diagram PNG.
    • A sample ODT validates against the OASIS ODF 1.3 RelaxNG schemas (dev-time check).
  • cargo deny: licenses, bans and sources pass. Two advisories fail, RUSTSEC-2026-0386 (tokio) and RUSTSEC-2026-0385 (ctutils), and both are already on main.

  • A final whole-branch review looked at the plan, security and file formats. It found:

    • Critical, fixed: Mermaid diagrams rasterized into a 300×150 box.
    • Important, fixed: picture memory doubling, per-table-only output caps, tall pictures running off the page, and the menu off-screen on phones.
  • Manual acceptance before merge. Open the kitchen-sink files and one real export in Word (365, 2019, web) and LibreOffice 7.6+, and check:

    • no repair prompt;
    • the navigation pane and an inserted TOC list the headings;
    • "Page X of Y";
    • tables with their merges;
    • pictures;
    • checklist boxes.

    Do one diagram export in Firefox and in Safari.

  • Not measured: binary size. Local release builds fail on macOS 27, main's included, so CI's Linux image shows the real number.

Merge Danger

Door: two-way

No migration and no change to stored data. Reverting removes the new routes, the crate and the menu.

Blast Radius: moderate

  • Page header: the download control changes for every page. The Markdown zip now sits behind a menu item instead of a direct click and shift-click.
  • Proxy limit: a reverse proxy has to allow 10 MiB request bodies for exports, as it already does for image uploads. This is documented in the Helm README.
  • Shared code: the live-snapshot step used by Markdown export moved into a shared helper. Its behaviour is unchanged and covered by the existing tests.
  • Known, parked:
    • an export slot is released when the file is built, before the download finishes;
    • a few edge cases only trigger near the size caps;
    • contributors_integration::revoked_editor_… is a pre-existing flaky test, also flaky on main.

🤖 Generated with Claude Code

christianhuening added a commit that referenced this pull request Oct 24, 2026
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
christianhuening and others added 28 commits October 24, 2026 08:56
Draft design for exporting one page to Word and OpenDocument: a new
pure knot-export crate walks the live Y.Doc into an export model with
hand-written OOXML/ODF writers, diagrams rendered to PNG in the browser
and sent with the request, per-element fallbacks reported to the user,
memory and upload limits, and a neutral style set ready for company
templates later. Includes the approved brainstorm mockup.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Twenty TDD tasks: the knot-export crate (walker, tables, media, DOCX
and ODT writers, kitchen-sink sample), the server service, routes and
export-check CLI, the web diagram rendering, API client, export flow
and menu, an e2e flow, and whole-branch verification. Adds the
planning clarifications to the design.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
nextest's positional filter matches test names, not binaries, so the per-task commands selected zero tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
christianhuening and others added 5 commits October 24, 2026 08:56
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@christianhuening
christianhuening merged commit 11f41e6 into main Oct 24, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant