ブラウザで Typst が動く時代——WASM で実現した完全ローカルのレジュメエディタ
はじめに
履歴書の作成、どうしてますか?
Word で地道にレイアウト調整する人、オンラインサービスを使う人、Typst や LaTeX で組版する人——選択肢は色々ありますが、それぞれトレードオフがあります。
- Word → レイアウトが崩れがち
- オンラインサービス → 個人情報をサーバーに送るのが不安
- Typst/LaTeX → 美しい仕上がりだが CLI 必須
「ブラウザで Typst を動かせば全部解決するのでは」
その仮説を検証したのが SmartResume です。
概要
Kakuti-Resume は完全ブラウザ完結のレジュメエディタです。
- Typst 組版エンジンを WASM 化してブラウザ内で実行
- Notion 風のブロックエディタで直感的な編集
- 既存の PDF 履歴書をアップロードするだけで解析 → 編集可能に
- 5種類のテンプレート(Classic / Modern / Art / 履歴書 / 職務経歴書)
- データは IndexedDB に保存、サーバー通信なし
ソースコード:GitHub
技術的な面白さ
Typst WASM のパイプライン
組版の流れはこうです:
@myriaddreamin/typst.ts が提供する2つの WASM バイナリ:
| バイナリ | 機能 | サイズ |
|---|---|---|
| Compiler |
.typ の構文解析 → AST |
~8 MB |
| Renderer | AST → PDF / SVG | ~5 MB |
これらを Web Worker 内で動かすことで、コンパイル中も UI がブロックされません。
コンパイルリクエストの制御
ユーザーがタイプするたびにコンパイルが走ると無駄が多いので、単調増加 ID による結果破棄を実装しました:
let nextId = 0;
// リクエスト送信
function compile(source: string) {
const id = ++nextId;
worker.postMessage({ type: 'compile', id, source });
}
// レスポンス受信
worker.addEventListener('message', (e) => {
const { type, id } = e.data;
if (type === 'compile_done' && id !== nextId) return; // stale
applyResult(e.data);
});
AbortController を使わず、シンプルな ID 比較だけで不要な結果を弾いています。
テンプレートエンジンとモック注入
Typst のテンプレートは #import で外部ファイルを参照しますが、WASM 環境には仮想ファイルシステムしかありません。この問題は Worker 側で import を剥がし、必要な関数のモックを注入する ことで解決しました:
// テンプレートに注入されるモック群
#let fa-icon(name, fill: black) = {
let icons = (
"github": "\u{f09b}",
"linkedin": "\u{f08c}",
"envelope": "\u{f0e0}",
// ... その他 Font Awesome アイコン
)
text(fill: fill, raw(icons.at(name, default: "")))
}
#let linguify(key, default: none, ..args) = { default }
エディタの設計
Notion ライクなブロックエディタを contentEditable で実装しています。
デュアル編集モード
| モード | 説明 |
|---|---|
| WYSIWYG |
contentEditable によるリッチテキスト編集(太字、色、フォントサイズ) |
| Markdown | ブロック横の空き領域で発動する生 Markdown 入力 |
Markdown モードでの独自拡張:
**太字テキスト**
[青いテキスト]{#0075de}
[大きいテキスト]{size:14pt}
- 箇条書き
# 見出し
カーソル位置の維持
React の再レンダリングをまたいで contentEditable のカーソル位置を維持するのは地味に大変でした。DOM TreeWalker でカーソルパスをウォークし、再レンダリング後に同じ位置を復元する仕組みで対応しています。
PDF インポート
「既存の PDF をアップロードするだけで編集できる」——これが意外と好評です。
PDF File
↓
pdfjs-dist(テキスト抽出)
↓
行グループ化(y座標の近接度で判定)
↓
ヘッダー/フッター除去
↓
行分類(フォントサイズ → h1/h2/h3、インデント → 箇条書き)
↓
構造解析(セクション境界、エントリのグルーピング)
↓
Markdown 再構築
↓
エディタブロックに変換
AI や OCR は一切使わず、レイアウトヒューリスティックのみで実現しています。履歴書はレイアウトが比較的定型なので、このアプローチが十分機能します。
デザインについて
Notion のデザイン哲学を参考に、編集に集中できる UI を目指しました:
- 暖かみのあるグレー(
#f6f5f4)を基調に、コールドな青グレーを避ける - テキストは
rgba(0,0,0,0.95)で#000より柔らかく - 境界線はすべて
1px solid rgba(0,0,0,0.1)——構造はあるが見えない - 影は多層で opacity を 0.05 以下に抑え、感じるけど主張しない奥行き
制約とこれから
現状の課題
- WASM バイナリの合計 ~13MB(初回ロードが重い → HTTP キャッシュで軽減)
- フォントの CDN 読み込み遅延(Noto Sans CJK はサイズが大きい)
-
contentEditableのエッジケース対応がまだ不完全 - 共同編集なし(プライバシーとのトレードオフ)
今後やりたいこと
- もっとテンプレートを増やす
- カバーレター対応
- PWA 化によるオフライン完結
- 英語圏向けの ATS 最適化テンプレート
おわりに
「WASM はもうプロダクションで使える」——これがこのプロジェクト最大の学びです。
組版エンジンのような重い処理も、Web Worker + WASM の組み合わせでブラウザ内で実用的に動かせます。アイデアがあればぜひ試してみてください。
フィードバックは resume.kakuti.site のフィードバックフォームか、GitHub Issues までお願いします。
Discussion