🎃

ブラウザで Typst が動く時代——WASM で実現した完全ローカルのレジュメエディタ

に公開

はじめに

履歴書の作成、どうしてますか?

Word で地道にレイアウト調整する人、オンラインサービスを使う人、Typst や LaTeX で組版する人——選択肢は色々ありますが、それぞれトレードオフがあります。

  • Word → レイアウトが崩れがち
  • オンラインサービス → 個人情報をサーバーに送るのが不安
  • Typst/LaTeX → 美しい仕上がりだが CLI 必須

「ブラウザで Typst を動かせば全部解決するのでは」

その仮説を検証したのが SmartResume です。

概要

Kakuti-Resume は完全ブラウザ完結のレジュメエディタです。

  • Typst 組版エンジンを WASM 化してブラウザ内で実行
  • Notion 風のブロックエディタで直感的な編集
  • 既存の PDF 履歴書をアップロードするだけで解析 → 編集可能に
  • 5種類のテンプレート(Classic / Modern / Art / 履歴書 / 職務経歴書)
  • データは IndexedDB に保存、サーバー通信なし

ソースコード:GitHub

SmartResume スクリーンショット

技術的な面白さ

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