Skip to content

feat(app): preview Word, Excel and PowerPoint files - #53305

Merged
Hona merged 7 commits into
anomalyco:v2from
Hona:office-preview
Oct 7, 2026
Merged

Hona merged 7 commits into
anomalyco:v2from
Hona:office-preview

Conversation

@Hona

@Hona Hona commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Read-only previews for .docx, .xlsx and .pptx in the file view. They are a new built-in GUI extension, microsoft-office, on BetterOffice (Apache-2.0), whose Rust engines are compiled to WASM.

  • Off the main thread: every engine runs in its own worker for each open file.
  • Local only: the file never leaves the machine. Bytes come from the server's file API, and engines and fonts load from the app's own origin.
Before After
Mobile, scrolled to a chart About → Third-party notices
flowchart LR
  F[file view<br/>artifact-view.tsx] -->|kind not built in| R{{file.viewer registry}}
  R -->|"problem(bytes)?"| B[binary placeholder + reason]
  R --> V[microsoft-office viewer<br/>lazy chunk per kind]
  V <-->|worker-rpc: keyed calls,<br/>transferred bytes / ImageBitmaps| W[module worker per open file<br/>engine wasm + OffscreenCanvas]
  V --> D[DOM: text layer, headers,<br/>selection, notes, a11y]
Loading

Registry

file/contract.ts adds file.viewer, so any extension can render file kinds the file view does not render itself. The file view handles the rest:

  • Rejected bytes: bytes the viewer's problem() rejects show the binary placeholder with the reason.
  • Errors: a viewer that throws, or a worker that dies, also shows the binary placeholder, and the file panel keeps working.
export interface FileViewer {
  readonly kinds: readonly ArtifactKind[]
  problem?(bytes: Uint8Array): string | undefined // checked before the viewer or its engine loads
  readonly View: Component<FileViewerProps> // { bytes, onDetails(details), onError(reason?) }
}
export const FileViewer = Registry.define<FileViewer>("file.viewer")

Viewers

  • Word:
    • Pages appear progressively: the first pages show while the rest lay out in steps.
    • A text layer handles selection, copy, Ctrl+F and screen readers.
    • Links work: external ones open through the host only for http(s) and mailto; table-of-contents and bookmark links scroll, even to pages not built yet.
    • Resolved comments are not tinted as active.
    • Fonts come from the bundled metric-compatible faces, with Word's substitutions and Hebrew and Arabic fallbacks.
  • Excel:
    • Row and column headers, and frozen panes.
    • Selection by mouse and keyboard; Ctrl+C copies the displayed text as TSV.
    • Links: external, internal, and Enter on the active cell.
    • Sheet tabs with roving focus; hidden sheets are filtered out.
    • The scroll area works like Excel's and includes charts; a viewport over the engine's cell cap is drawn in tiles.
    • A screen-reader grid with aria-activedescendant.
  • PowerPoint:
    • Each deck loads only the fonts it uses, registered in the worker under the deck's own family names.
    • Speaker notes, and hidden slides dimmed and labeled.
    • A slide that fails to paint shows a message; a slide over its shadow budget paints again without shadows.
  • Paper colours: pages and sheets keep their paper colour in every theme.

Performance

Workers:

  • worker-rpc.ts follows the markdown worker's model:
    • typed calls;
    • a newer call with the same key replaces a queued one;
    • aborting a call cancels it in the worker;
    • the file's bytes move to the worker instead of being copied.
  • Workers paint into an OffscreenCanvas and send ImageBitmaps back, which the main thread shows with bitmaprenderer.
  • Closing a file terminates its worker, which frees its engine memory. WASM memory never shrinks otherwise.

Main thread, before workers → after:

Word, 149 pages Word, ~600 pages Excel, 20k rows Excel, 100k rows PowerPoint, 120 slides
Long tasks, open + scroll 15 (max 560 ms) → 0 70 (max 8 s) → 0 4 (max 5.7 s) → 0 64 (max 100 s) → 0 1 → 0
Scroll fps 54 → 60 55 → 60 46 → 60 13 → 60 58 → 60
Main-thread WASM 305 MB → 0 939 MB → 0 349 MB → 0 1.7 GB → 0 104 MB → 0

Other numbers:

  • Word: binary display frames, built only for pages about to show, instead of one JSON display list. A 149-page document shows its first pages in 1.2 s instead of 93 s; a ~600-page document opens instead of failing on V8's string limit.
  • Word memory: the worker releases built pages more than 10 pages away from the page it paints (releaseDisplayPagesFrame, docx 0.4.3). Scrolling through 149 pages peaks at 369 MB of WASM instead of 1,135 MB. Through 594 pages it peaks at 946 MB, where it used to reach the 4 GB WASM limit and crash the engine.
  • PowerPoint text inspection skips media: its JSON drops from 27.7 MB to 0.4 MB. Decoded pictures are capped at 2,560 px and kept in a 128 MB least-recently-used cache.
  • Excel scrolling sends half as many frame requests, and half the bitmap traffic.
  • Base64: decoding uses Uint8Array.fromBase64/toBase64, with a fallback for older browsers. A 25 MiB file takes 30 ms instead of 3.7 s.
  • Page column: pages mount in slices with one shared observer. Paints are cancellable, and a page repaints only when its pixel size changes.

Bundle

  • Startup: nothing loads at startup. Each format's chunk, worker and WASM load when a file of that kind opens.
  • WASM: docx_edit 19.7 MB, xlsx 5.2 MB, pptx 5.4 MB. A small generateBundle plugin drops the Office files that nothing references (layout and opc WASM, the resident worker: 4.7 MB).
  • Fonts: the 65 bundled faces (about 14 MB) load on demand. The optional 33 MB CJK add-on is stubbed out.
  • Offline cache: the web app's service worker excludes all of these.
  • CLI: the CLI embeds the web UI, so the assets add about 24 MB to each CLI binary. That is accepted for now; serving them from a CDN for opencode serve can come later.

Licensing

About → Third-party notices opens a lazily loaded dialog with:

  • the BetterOffice NOTICE and the EigenPal attribution;
  • the 17 font licenses (SIL OFL 1.1);
  • the 86 Rust crates compiled into the engines, with their copyright lines and license texts.

Known limits

  • CJK slides: Chinese, Japanese and Korean text overlaps, because the CJK fonts are not shipped.
  • Arabic in Word: letters paint disjointed. Hebrew is fine.
  • Aptos in PowerPoint: text shows small gaps between letters (the substitute face is narrower).
  • Merged cells: Excel draws grid lines inside merged cells. This is upstream and also happens in BetterOffice's own demo.
  • TODAY()/NOW(): they use UTC.
  • Tab switching: switching file tabs reopens the file.
  • Ctrl+F in Word: it only reaches pages near the viewport.
  • Legacy files: .doc, .xls, .ppt and encrypted files show the binary placeholder with a reason.
  • Internal APIs: the Word engine APIs for incremental frames and page release are marked @internal.
  • docx 0.4.3 layout: compared with 0.4.1, table rows lay out slightly taller, so later pages of long documents can shift.

@Hona
Hona force-pushed the office-preview branch 2 times, most recently from fa4b945 to ea1887e Compare October 6, 2026 08:38
Hona added 4 commits October 6, 2026 18:38
Render .docx, .xlsx and .pptx read-only in the file viewer with the BetterOffice engines (Apache-2.0). Each format loads its own engine only when a file of that format opens; Word uses the Yrs engine alone, and only the five metric-compatible font families ship. Nothing leaves the machine. Add third-party notices to the About page.
Each open Word, Excel or PowerPoint file gets its own module worker that owns the engine and paints into an OffscreenCanvas, so no layout, recalculation or painting runs on the main thread, and closing a file frees its engine memory. Adds the Word text layer and links, Excel headers, selection, copy and links, per-deck PowerPoint fonts, speaker notes and hidden slides, file checks before any engine loads, and notices for the bundled fonts and Rust crates.
Moves the file bytes to each worker instead of copying them, skips media when PowerPoint inspects its text, caps decoded slide pictures, mounts and observes pages incrementally with cancellable paints, halves Excel frame requests while scrolling, and drops Office engine files the build emits but nothing loads.
Upgrades @betteroffice/docx to 0.4.3 and releases built pages more than 10 pages from the one being painted, so scrolling a long document no longer grows the Word worker's memory with every page. A 594-page document now stays under 1 GB of wasm instead of reaching the 4 GB limit.
@Hona
Hona marked this pull request as ready for review October 6, 2026 09:18
@Hona
Hona requested a review from Brendonovich as a code owner October 6, 2026 09:18
Copilot AI balanced review requested due to automatic review settings October 6, 2026 09:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Hona added 2 commits October 6, 2026 19:29
The 17 font families shipped with the Office previews share the SIL Open Font License 1.1, so the notices list each family with its own copyright lines and the license text once, instead of 17 entries.
- worker-rpc: an aborted handler closes its bitmaps instead of replying, and a
  reply that crosses its cancel closes the bitmaps it carries. Word paints
  check their signal.
- slide-text: bound central directory records and local offsets, so a
  malformed .pptx stays as it is.
- Spreadsheet: only the newest sheet pick updates the view, and going back to
  the shown sheet withdraws a pending pick. External links allow only http,
  https and mailto, through one helper shared with Word.
- Artifact viewer: check a viewer's problem only on freshly decoded bytes, and
  fail the file from the error boundary.
- Word: queue the full layout after the first paints, keep page rows stable so
  a changed page repaints over its old bitmap, and listen for messageerror.
- PowerPoint: keep the finer SVG raster, drop rasters for a withdrawn paint,
  and read each picture's blob once.
- Unit tests for links, withoutMedia, sheet-cells and worker-rpc.
…views

The font package maps East Asian families such as Microsoft YaHei, DengXian and Yu Gothic Medium to its CJK add-on, which the app does not ship. A deck that named only such families registered no font, so every slide failed to lay out, and Word laid out weight variants such as Microsoft YaHei Light as one unwrapped line in a browser font. Every lookup now returns a face the app ships, and a weight variant falls back by its base name, so a serif family stays serif.

Also rename the base64 test, which runs the native codecs, so it no longer claims to cover the fallback.
@Hona
Hona enabled auto-merge (squash) October 7, 2026 00:16
@Hona
Hona merged commit 9c51d84 into anomalyco:v2 Oct 7, 2026
9 checks passed
@ntcho

ntcho commented Oct 8, 2026

Copy link
Copy Markdown

This is super cool! When will this ship to v2?

@Hona

Hona commented Oct 8, 2026

Copy link
Copy Markdown
Member Author

about to release v2 in the next hour or 2

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.

3 participants