feat: Typst ファイルを入力として受け付ける (#12)#21
Conversation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- worker.format=es so Vite's secondary iife pass works with the
module worker created via new Worker(new URL(...), {type:'module'}).
- globIgnores excludes the 21 MB typst-ts WASM from the workbox
precache manifest. The WASM is loaded on demand when a user opens
a .typ file; precaching it would bloat the SW for users who never
use Typst.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Code review findings: - compileTypst rejection (worker fatal / abort / runtime error) was swallowed silently, leaving the user stuck on the loading status. Wrap in try/catch and surface typst_error_runtime_init() (or clear the status on AbortError). - TypstDiagnosticList used array index as React key. Switch to a composite key based on path/line/column/message + index. - Replace `any` test fixtures with a local MockEntry type. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 989a4f6399
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| (mainSourceFile?.name ?? mainPath.split("/").pop()), | ||
| ); | ||
|
|
||
| await proceedWithPdf(compiledPdf, pdfpc, undefined, { |
There was a problem hiding this comment.
Preserve pdfpc handles when saving Typst recents
The Typst path passes undefined as the handles argument, so proceedWithPdf cannot resolve pdfpcHandle and stores configHandle/configName as empty for FSA-based Typst entries. In the main.typ + matching .pdfpc flow opened via File System Access, the initial open works from in-memory files, but reopening from Recent drops the presenter config because the handle was never persisted. Pass through picker handles (or the specific config handle) when calling proceedWithPdf in this branch.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 9a8ada0. The Typst branch now forwards handles to proceedWithPdf, so pdfpcHandle resolves and is persisted for FSA-mode Typst recents. The synthetic compiled PDF still has no on-disk handle, so the pdfHandle search is a harmless miss.
| }; | ||
|
|
||
| // Typst branch | ||
| const sources = await filesToTypstSources(files); |
There was a problem hiding this comment.
Avoid eagerly loading all file bytes on every open
This now always calls filesToTypstSources(files) before deciding between Typst and PDF paths, and that helper reads every selected file with arrayBuffer(). As a result, normal PDF opens now incur a full read of large PDFs up front, adding noticeable delay and memory pressure on the main path (including potential OOM for very large decks). Detect Typst by filename first and only materialize file bytes when entering the Typst compile branch.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 9a8ada0. Added filesToTypstMeta(files) that returns just paths, and gated filesToTypstSources (which calls arrayBuffer() on every file) inside the Typst branch. PDF opens no longer eagerly read bytes.
| {d.severity === "error" ? ( | ||
| <AlertCircleIcon className="mt-[2px] size-3.5 shrink-0 text-destructive" /> | ||
| ) : ( | ||
| <AlertTriangleIcon className="mt-[2px] size-3.5 shrink-0 text-amber-500" /> |
There was a problem hiding this comment.
Use semantic color token for warning diagnostics
This introduces a fixed palette class (text-amber-500) instead of semantic tokens, which violates the UI rule in /workspace/pdfpw.github.io/AGENTS.md (use tokens like text-primary/text-secondary/text-destructive rather than hardcoded colors). Keeping hardcoded amber here will break theme consistency and token-based styling; switch to a semantic warning token (or define one) instead.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 9a8ada0. --color-warning (same #F59E0B hex) is already defined in src/styles.css; switched to text-warning.
There was a problem hiding this comment.
Pull request overview
Adds Typst (.typ) as a first-class input format by compiling Typst to PDF in-browser (via typst.ts WASM in a Web Worker) and then reusing the existing PDF presenter pipeline, including Recent Files support and user-facing diagnostics.
Changes:
- Introduces a Typst compilation worker + client API and integrates a Typst branch into the main
handleFilesflow. - Adds Typst source detection utilities (including folder D&D expansion support) and a diagnostic list UI component.
- Extends recent-store to persist Typst entries alongside PDFs, and updates build/test setup and dependencies for Typst WASM.
Reviewed changes
Copilot reviewed 14 out of 15 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| vite.config.ts | Excludes Typst WASM from PWA precache; configures module workers and dep optimization for Typst. |
| src/workers/typst-worker.ts | New worker that initializes typst.ts compiler, loads WASM, compiles to PDF, and returns diagnostics/progress. |
| src/routes/$locale/(main)/index.tsx | Adds Typst detection/compile branch, diagnostics rendering, and Typst recent re-open flow; refactors PDF proceed logic. |
| src/routes/$locale/(main)/-index/HeroSection.tsx | Accepts .typ in file input and expands folder drops via webkitGetAsEntry. |
| src/lib/typst.ts | New public compileTypst API that runs compilation in a module worker with progress/abort support. |
| src/lib/typst.test.ts | New unit tests validating Typst→PDF output, diagnostics, and progress ordering. |
| src/lib/typst-source-detect.ts | New helpers to detect/pick main Typst file and convert files/entries to Typst sources. |
| src/lib/typst-source-detect.test.ts | Unit tests for Typst source detection and entry recursion behavior. |
| src/lib/recent-store.ts | Migrates RecentFile to a discriminated union supporting `kind: "pdf" |
| src/components/TypstDiagnosticList.tsx | New UI component to render Typst diagnostics with severity icons. |
| src/components/TypstDiagnosticList.test.tsx | Basic rendering test for diagnostics list rows. |
| package.json | Adds Typst compiler dependencies. |
| pnpm-lock.yaml | Lockfile updates for Typst dependencies. |
| messages/en.json | Adds Typst status/error i18n strings. |
| messages/ja.json | Adds Typst status/error i18n strings. |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| // Typst branch | ||
| const sources = await filesToTypstSources(files); | ||
| if (containsTypst(sources)) { | ||
| const mainPath = pickMainTypst(sources); | ||
| if (!mainPath) { | ||
| setStatus(m.typst_error_no_main()); | ||
| return; | ||
| } | ||
| setStatus(m.typst_status_loading_wasm()); | ||
| let result: Awaited<ReturnType<typeof compileTypst>>; | ||
| try { | ||
| result = await compileTypst( | ||
| { sources, mainPath }, | ||
| { | ||
| onProgress: (p) => { | ||
| if (p.stage === "loading-wasm") setStatus(m.typst_status_loading_wasm()); | ||
| else if (p.stage === "fetching-packages") | ||
| setStatus(m.typst_status_fetching_packages({ package: p.current ?? "" })); | ||
| else if (p.stage === "compiling") setStatus(m.typst_status_compiling()); | ||
| }, | ||
| }, | ||
| ); | ||
| } catch (err) { | ||
| if ((err as Error)?.name === "AbortError") { | ||
| setStatus(null); | ||
| return; | ||
| } | ||
| setStatus(m.typst_error_runtime_init()); | ||
| return; | ||
| } | ||
| if (!result.ok) { | ||
| setStatus(<TypstDiagnosticList items={result.diagnostics} />); | ||
| return; | ||
| } | ||
| const stem = mainPath.replace(/\.typ$/i, "").split("/").pop() ?? "main"; | ||
| const pdfArrayBuffer = new Uint8Array(result.pdf).buffer; | ||
| const compiledPdf = new File([pdfArrayBuffer], `${stem}.pdf`, { | ||
| type: "application/pdf", | ||
| }); | ||
| const pdfpc = files.find( | ||
| (f) => /\.pdfpc$/i.test(f.name) && sameBase(`${stem}.pdf`, f.name), | ||
| ); | ||
|
|
||
| const mainSourceFile = files.find( | ||
| (f) => | ||
| ((f as File & { webkitRelativePath?: string }).webkitRelativePath || | ||
| f.name) === mainPath, | ||
| ); | ||
| const assetFiles = files.filter( | ||
| (f) => f !== mainSourceFile && !/\.pdfpc$/i.test(f.name), | ||
| ); | ||
| const assetHandles = handles?.filter( | ||
| (h) => | ||
| h.name !== mainSourceFile?.name && !/\.pdfpc$/i.test(h.name), | ||
| ); | ||
| const sourceHandle = handles?.find( | ||
| (h) => | ||
| h.name === | ||
| (mainSourceFile?.name ?? mainPath.split("/").pop()), | ||
| ); | ||
|
|
||
| await proceedWithPdf(compiledPdf, pdfpc, undefined, { | ||
| mainPath, | ||
| sourceFile: mainSourceFile, | ||
| assetFiles, | ||
| sourceHandle, | ||
| assetHandles, | ||
| }); | ||
| return; |
There was a problem hiding this comment.
Fixed in 9a8ada0. filesToTypstMeta now returns path-only metadata for the gating check; filesToTypstSources (the byte-reading one) only runs after the Typst branch is taken.
| await proceedWithPdf(compiledPdf, pdfpc, undefined, { | ||
| mainPath, | ||
| sourceFile: mainSourceFile, | ||
| assetFiles, | ||
| sourceHandle, | ||
| assetHandles, | ||
| }); |
There was a problem hiding this comment.
Fixed in 9a8ada0. handles is now forwarded to proceedWithPdf so the pdfpc handle resolves for FSA Typst recents.
| const pdfArrayBuffer = new Uint8Array(result.pdf).buffer; | ||
| const compiledPdf = new File([pdfArrayBuffer], `${stem}.pdf`, { |
There was a problem hiding this comment.
Fixed in 9a8ada0. Pass result.pdf.buffer (cast to ArrayBuffer) directly to the File constructor — the original round-trip was a workaround for TS ArrayBufferLike strictness; worker-cloned Uint8Arrays always land with a plain ArrayBuffer (byteOffset 0, full length), so the cast is safe and avoids the copy.
| }); | ||
|
|
||
| const inbound: WorkerInbound = { kind: "compile", req }; | ||
| worker.postMessage(inbound); |
There was a problem hiding this comment.
Holding off on this one. Real Typst project sources are KB-low MB (.typ files plus a few images); structured-clone for that size is microseconds, while the compile itself is seconds. Adding a transfer list also complicates the worker contract (callers can't reuse the source data after compileTypst, which they currently can). Deferring until there's a concrete project size where the clone shows up in profiling.
| severity: "error" | "warning"; | ||
| path: string; | ||
| line: number; // 1-origin | ||
| column: number; // 1-origin | ||
| message: string; | ||
| package?: string; | ||
| } | ||
|
|
||
| export type TypstProgress = | ||
| | { stage: "loading-wasm" } | ||
| | { stage: "fetching-packages"; current?: string } | ||
| | { stage: "compiling" }; | ||
|
|
||
| export interface CompileRequest { | ||
| sources: TypstSource[]; | ||
| mainPath: string; | ||
| } | ||
|
|
||
| export type CompileResult = | ||
| | { ok: true; pdf: Uint8Array } | ||
| | { ok: false; diagnostics: TypstDiagnostic[] }; | ||
|
|
||
| export interface CompileOptions { | ||
| signal?: AbortSignal; | ||
| onProgress?: (p: TypstProgress) => void; | ||
| } | ||
|
|
||
| export type WorkerInbound = { kind: "compile"; req: CompileRequest }; | ||
| export type WorkerOutbound = | ||
| | { kind: "progress"; payload: TypstProgress } | ||
| | { kind: "result"; payload: CompileResult } | ||
| | { kind: "fatal"; message: string }; | ||
|
|
||
| export function compileTypst( | ||
| req: CompileRequest, | ||
| opts: CompileOptions = {}, | ||
| ): Promise<CompileResult> { | ||
| return new Promise((resolve, reject) => { | ||
| const worker = new Worker( | ||
| new URL("../workers/typst-worker.ts", import.meta.url), | ||
| { type: "module" }, | ||
| ); | ||
| const cleanup = () => { | ||
| worker.terminate(); | ||
| opts.signal?.removeEventListener("abort", onAbort); | ||
| }; | ||
| const onAbort = () => { | ||
| cleanup(); | ||
| reject(new DOMException("Aborted", "AbortError")); | ||
| }; | ||
| if (opts.signal) { | ||
| if (opts.signal.aborted) return onAbort(); | ||
| opts.signal.addEventListener("abort", onAbort); | ||
| } | ||
|
|
||
| worker.addEventListener("message", (ev: MessageEvent<WorkerOutbound>) => { | ||
| const msg = ev.data; | ||
| switch (msg.kind) { | ||
| case "progress": | ||
| opts.onProgress?.(msg.payload); | ||
| break; | ||
| case "result": | ||
| cleanup(); | ||
| resolve(msg.payload); | ||
| break; | ||
| case "fatal": | ||
| cleanup(); | ||
| reject(new Error(msg.message)); | ||
| break; | ||
| } | ||
| }); | ||
| worker.addEventListener("error", (ev) => { | ||
| cleanup(); | ||
| reject(new Error(ev.message || "worker error")); | ||
| }); | ||
|
|
||
| const inbound: WorkerInbound = { kind: "compile", req }; | ||
| worker.postMessage(inbound); | ||
| }); |
There was a problem hiding this comment.
Fixed in 9a8ada0 by running pnpm format over the new Typst files.
- Stop reading every File's bytes on the PDF path. Add filesToTypstMeta() that returns just paths; gate filesToTypstSources() behind the Typst branch so opening a large PDF deck no longer eagerly arrayBuffer()s the entire file. (Codex P1, Copilot) - Forward `handles` from handleFiles into proceedWithPdf in the Typst branch so the pdfpc handle resolves for FSA-based Typst recents (synthetic compiled PDF has no handle, so the pdfHandle search is a harmless miss). (Codex P1, Copilot) - Use semantic `text-warning` token instead of hardcoded `text-amber-500` in TypstDiagnosticList per AGENTS.md UI rule. The CSS already defines --color-warning at the same hex. (Codex P1) - Drop the redundant `new Uint8Array(result.pdf).buffer` round-trip; pass result.pdf.buffer directly to the File constructor with an ArrayBuffer cast (worker-cloned Uint8Array always lands with a plain ArrayBuffer). (Copilot) - Run `pnpm format` to switch new Typst files from 2-space indent to tabs to match biome.json indentStyle. (Copilot) Pushed back on the postMessage transferable suggestion (Copilot): real Typst projects pass KB-MB of sources, structured-clone cost is negligible at that scale, and adding transfer ownership complicates the worker contract for no measurable gain. YAGNI until evidence. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
issue #12 の実装。
.typファイルをブラウザ内で PDF にコンパイルし、既存の PDF プレゼンパイプラインに合流させる。packages.typst.org) から自動 fetch、touying / polylux 等の外部スライドフレームワークがそのまま動作webkitGetAsEntryでフォルダごと再帰展開、サブディレクトリ込みプロジェクトに対応 (ピッカー経由はフラットのみ)file:line:col — message) を展開.typを開かないユーザーには 21 MB WASM を一切ダウンロードさせない (PWA precache からも除外)設計とプランは
docs/superpowers/specs/2026-05-03-typst-input-design.mdおよびdocs/superpowers/plans/2026-05-03-typst-input.md(main に既存)。アーキテクチャ
主な変更ファイル
src/lib/typst.tscompileTypst({ sources, mainPath }, { onProgress })公開 API + Worker クライアントsrc/lib/typst-source-detect.tscontainsTypst/pickMainTypst/filesToTypstSources/entriesToTypstSourcessrc/workers/typst-worker.tssrc/components/TypstDiagnosticList.tsxsrc/routes/$locale/(main)/index.tsxhandleFilesの Typst 分岐、proceedWithPdfを抽出して PDF 経路と共有、Typst 用 recent 保存 + 再オープン再コンパイルsrc/routes/$locale/(main)/-index/HeroSection.tsx.typを accept に追加、D&D のwebkitGetAsEntry再帰展開、status: ReactNode受領src/lib/recent-store.tsRecentFileをkind: "pdf" | "typst"の判別共用体へ。読み出し時kind未設定 →"pdf"で互換維持vite.config.tsworker.format = "es"(module Worker サポート) +globIgnoresで 21 MB WASM を PWA precache から除外テスト
typst-source-detect.test.ts(8 ケース): containsTypst / pickMainTypst の主ファイル判定ルール / filesToTypstSources / D&D エントリ再帰展開typst.test.ts(3 ケース): 最小 .typ → PDF 生成 (%PDF-確認) / 構文エラーで診断 / 進捗ステージ順序TypstDiagnosticList.test.tsx(1 ケース): 行レンダリング検証index.tsxの Typst 分岐の結合テストは見送り (現コードベースは UI クリックテスト = 1 件のみで、ロジックはユニット網羅 + 手動 smoke の方針のため)。ビルド / バンドル
pnpm tsc -b✅pnpm build✅main-*.js(745 KB) に typst.ts コードゼロtypst-worker-*.js(37 KB) とtypst_ts_web_compiler_bg-*.wasm(21 MB) は分離チャンク、.typを開いた時のみフェッチglobIgnores)Test plan
pnpm devで http://localhost:6123 を開く.typ(= Hello\n\nWorld\n) を D&D → presenter 起動#import \"@preview/touying:0.5.5\": *) を D&D → パッケージ取得 → presenter 起動#let x = (\n) を D&D → HeroSection 下に診断リスト表示main.typ+logo.png) → 画像が埋め込まれた PDF 表示pnpm testを TTY 経由で実行し chromium + firefox 両方で全テスト PASSpnpm buildで WASM が 21 MB の独立アセットとして出力され、main bundle に typst.ts シンボルが含まれないことを確認Closes
Closes #12
🤖 Generated with Claude Code