Skip to content

feat: Typst ファイルを入力として受け付ける (#12)#21

Merged
miyaji255 merged 15 commits into
mainfrom
feat/typst-input
May 3, 2026
Merged

feat: Typst ファイルを入力として受け付ける (#12)#21
miyaji255 merged 15 commits into
mainfrom
feat/typst-input

Conversation

@miyaji255
Copy link
Copy Markdown
Collaborator

Summary

issue #12 の実装。.typ ファイルをブラウザ内で PDF にコンパイルし、既存の PDF プレゼンパイプラインに合流させる。

  • typst.ts (WASM) を Web Worker 上で実行、メイン UI をブロックしない
  • 公式パッケージレジストリ (packages.typst.org) から自動 fetch、touying / polylux 等の外部スライドフレームワークがそのまま動作
  • D&D 経路は webkitGetAsEntry でフォルダごと再帰展開、サブディレクトリ込みプロジェクトに対応 (ピッカー経由はフラットのみ)
  • 構文エラー時は HeroSection 直下に診断リスト (file:line:col — message) を展開
  • Recent files に Typst ソースを保存、再オープンで毎回再コンパイル → FSA mode で外部エディタとの相互運用が成立
  • 動的 import / Vite チャンク分割により、.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 に既存)。

アーキテクチャ

[D&D / file input / FSA picker]
            │
   index.tsx#handleFiles
            │
  ┌─────────┴──────────┐
  │ .typ を含む?         │── No ──▶ 既存 PDF フロー
  └─────────┬──────────┘
            │ Yes
            ▼
  compileTypst (動的 import → Web Worker)
    1. WASM 読込
    2. パッケージ取得 (packages.typst.org)
    3. コンパイル → PDF Uint8Array  or  diagnostics[]
            │
       成功: synthetic File("compiled.pdf", "application/pdf")
       失敗: <TypstDiagnosticList items={...} /> を status に表示
            │
            ▼
  proceedWithPdf (recent 保存 / navigate / window.open)

主な変更ファイル

新規 役割
src/lib/typst.ts compileTypst({ sources, mainPath }, { onProgress }) 公開 API + Worker クライアント
src/lib/typst-source-detect.ts containsTypst / pickMainTypst / filesToTypstSources / entriesToTypstSources
src/workers/typst-worker.ts typst.ts (WASM) を呼ぶ Worker、診断パース、進捗 postMessage
src/components/TypstDiagnosticList.tsx severity アイコン付き診断リスト
改修 内容
src/routes/$locale/(main)/index.tsx handleFiles の Typst 分岐、proceedWithPdf を抽出して PDF 経路と共有、Typst 用 recent 保存 + 再オープン再コンパイル
src/routes/$locale/(main)/-index/HeroSection.tsx .typ を accept に追加、D&D の webkitGetAsEntry 再帰展開、status: ReactNode 受領
src/lib/recent-store.ts RecentFilekind: "pdf" | "typst" の判別共用体へ。読み出し時 kind 未設定 → "pdf" で互換維持
vite.config.ts worker.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 ケース): 行レンダリング検証
  • 既存全テスト 89/89 PASS、Chromium で全 92 PASS

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 を開いた時のみフェッチ
  • PWA precache manifest からは WASM を除外 (globIgnores)

Test plan

  • pnpm devhttp://localhost:6123 を開く
  • 最小 .typ (= Hello\n\nWorld\n) を D&D → presenter 起動
  • touying サンプル (#import \"@preview/touying:0.5.5\": *) を D&D → パッケージ取得 → presenter 起動
  • 構文エラー .typ (#let x = (\n) を D&D → HeroSection 下に診断リスト表示
  • フォルダ D&D (main.typ + logo.png) → 画像が埋め込まれた PDF 表示
  • 「最近開いたファイル」から Typst エントリをクリック → 再コンパイル → 再 presenter 起動
  • pnpm test を TTY 経由で実行し chromium + firefox 両方で全テスト PASS
  • pnpm build で WASM が 21 MB の独立アセットとして出力され、main bundle に typst.ts シンボルが含まれないことを確認

Closes

Closes #12

🤖 Generated with Claude Code

miyaji255 and others added 14 commits May 3, 2026 22:34
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>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 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".

Comment thread src/routes/$locale/(main)/index.tsx Outdated
(mainSourceFile?.name ?? mainPath.split("/").pop()),
);

await proceedWithPdf(compiledPdf, pdfpc, undefined, {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread src/routes/$locale/(main)/index.tsx Outdated
};

// Typst branch
const sources = await filesToTypstSources(files);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread src/components/TypstDiagnosticList.tsx Outdated
{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" />
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 9a8ada0. --color-warning (same #F59E0B hex) is already defined in src/styles.css; switched to text-warning.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 handleFiles flow.
  • 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.

Comment on lines +269 to +338

// 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;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread src/routes/$locale/(main)/index.tsx Outdated
Comment on lines +331 to +337
await proceedWithPdf(compiledPdf, pdfpc, undefined, {
mainPath,
sourceFile: mainSourceFile,
assetFiles,
sourceHandle,
assetHandles,
});
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 9a8ada0. handles is now forwarded to proceedWithPdf so the pdfpc handle resolves for FSA Typst recents.

Comment thread src/routes/$locale/(main)/index.tsx Outdated
Comment on lines +305 to +306
const pdfArrayBuffer = new Uint8Array(result.pdf).buffer;
const compiledPdf = new File([pdfArrayBuffer], `${stem}.pdf`, {
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread src/lib/typst.ts Outdated
});

const inbound: WorkerInbound = { kind: "compile", req };
worker.postMessage(inbound);
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread src/lib/typst.ts Outdated
Comment on lines +5 to +83
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);
});
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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>
@miyaji255 miyaji255 merged commit 28c72e6 into main May 3, 2026
2 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.

feat: Typst ファイルを入力として受け付ける (ブラウザ内コンパイル、エディタなし)

2 participants