世界に一つの印影をブラウザで無料で作れるサービスを作った — inkan.teraren.com
概要
ブラウザで動く印鑑ジェネレーター inkan.teraren.com を作りました。
- 丸印・角印 / 17 種のフォント / 7 種のアレンジ(回転・ジッター・サイズ変動・かすれ・枠欠け・にじみ・字体歪み)
- URL がそのまま permalink:状態を base64url バイナリにパックして
?v=1&p=...に乗せる - 文字ごとに個別アレンジを振れるので、同じ「佐藤」でも設定空間がきわめて大きく、現実的に他人と被ることのない印影になる
- 生成 URL を保存しておけば「自分が最初に作った印影である」ことの実用上十分な証明になる(100 円ショップの三文判では絶対に出せない性質)
- React 19 + Vite + Tailwind v4、Coolify + Docker + nginx でデプロイ
- 苗字ランキング TOP 100 × 3 バリアントの静的 SEO ランディングを prebuild で生成

動機 — 印鑑から失われた「ユニークネス」を取り戻す
そもそも印鑑というのは「世界に一つだけの印影」であることに本質的な意味があります。実印・銀行印・認印いずれも、印影が個人や組織に一意に紐づいているからこそ、押された文書が「私が承認した」ことの証明になる。ユニークネスこそが印鑑のコア要件であり、それが担保されないなら印鑑である意味がない。
ところが現実はどうか。100 円ショップで「佐藤」「鈴木」「田中」の三文判が大量に並ぶようになった頃から、印鑑のユニークネス要件は形骸化しています。同じ苗字なら同じ印影が量産され、書類に押されている「佐藤」のハンコと、別の佐藤さんが押したハンコの区別がつかない。本来の意味で言えば、これはもう「印鑑」ではなく「装飾されたサイン記号」です。
無料 Web サービスもまったく同じ構造で、入力した文字が同じなら全員が同じ印影になります。テンプレートにフォントを当てているだけなので当然なのですが、これでは三文判をデジタル化しただけで、印鑑が本来持つべきユニークネス要件は満たせない。
一方、ハンコ屋でオリジナル印影を彫ってもらおうとすると 4,000 円〜 かかります。「無料 = 形骸化した印鑑」「本来の印鑑 = 有料」という二択しか選択肢がない状態。
このギャップを、
- 文字ごとの個別アレンジ(位置・回転・サイズ・字体歪みを 1 文字単位で振る)
- 決定的なノイズ(同じ URL なら完全再現、別 URL なら世界に一つ)
の組合せで埋めて、ブラウザだけで・無料で・かつユニークな印影を作れるようにする、というのがこのプロジェクトの動機です。
模倣可能性と「証明可能性」の話
正直に言うと、デジタルで生成した印影が「世界で唯一」かと言われると、模倣は技術的には可能です。SVG をコピーして PNG に書き出せば見た目は完全に同じものが作れる。これは紙のハンコでも同じで、印影をスキャンして 3D プリンタで彫れば物理的にも複製はできてしまう。完全な模倣不可能性を達成できる印鑑は、現実にはどこにも存在しない。
このサービスが提供できるのは「完全な模倣不可能性」ではなく、「生成元の証明可能性」のほうです。
具体的には:
- 設定の組合せ空間がきわめて大きい:17 フォント × 形状パラメータ × 効果 7 種 × 文字数ぶんの perChar(6 軸 × N 文字、各 i8 量子化で 255 階調)という多次元空間で、現実的に他人が偶然同じ印影を生成する確率はほぼゼロ
- 生成 URL が事実上の証明書:ある印影の URL を最初に保有していたという事実が、自分が生成元であることの根拠になる
- 生成日時を残せる:URL を生成直後にどこかにタイムスタンプ付きで保存しておけば(ブログ・GitHub Gist・自分のメール下書きなど)、後から「これは自分が ◯ 月 ◯ 日に生成した印影だ」と主張できる
要は「タイムスタンプ + 巨大な名前空間」で、ブロックチェーンや TSA を持ち出さなくても、印鑑として実用上十分な「私が生成したものであるという主張のしやすさ」が確保できる、という設計です。
100 円ショップの三文判には絶対に出せない性質で、ここがこのサービスの本質的な価値だと考えています。
裏動機として、Claude Code に巨大な仕様書を投げて一気にプロトタイピングしたかった、というのもあります。実際 100 コミットちょいで MVP からデプロイまで持っていけました。
アーキテクチャ全体像
3 層の純粋関数パイプラインで構成しています。
URL ──decode──▶ StampConfig ──layout──▶ LayoutResult ──render──▶ SVG + Canvas ▲ │ │ │ └──── encode ─────────┘ │ │ │ │ UI Controls ─────────┴── onChange(cfg) ───────┘- State 層 (
src/state/):StampConfig ⇄ URLの可逆変換。Zod で厳密バリデーション - Layout 層 (
src/layout/):StampConfig → LayoutResult(各文字の基準座標・サイズ・向き) - Render 層 (
src/render/):LayoutResult + config → SVG、Canvas でかすれ・にじみを後処理 - UI 層 (
src/ui/):状態は URL のみに持つ(useUrlState)
設計の原則:
- State / Layout / Render は 副作用なし・純粋関数
StampConfigが 3 層間の唯一のコントラクト型Math.random()は使わない(永続可能性・決定性のため lint で禁止)
最後の「Math.random() 禁止」は地味ですが効きます。permalink 再現性を担保するには、レンダリング全体を「同じ入力 → 同じ出力」にしなければならない。その入口を 1 箇所閉じておくだけで、後の議論がだいぶ楽になりました。
State 層 — URL を Single Source of Truth にする
コントラクト型 StampConfig
設定値は全部この型に集約しています。Zod で書いて型は z.infer で抜く方針。
const zeroToOne = z.number().min(0).max(1)const negOneToPlusOne = z.number().min(-1).max(1)const hexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/)
export const StampConfigSchema = z.object({ version: z.literal(1),
text: z.string(), textLayout: z.enum(['auto', 'vertical', 'horizontal', 'grid', 'perimeter']),
shape: z.object({ kind: z.enum(['circle', 'rect']), width: z.number().int().min(100).max(1000), height: z.number().int().min(100).max(1000), cornerRadius: zeroToOne, borderWidth: zeroToOne, }),
ink: z.object({ style: z.enum(['shu', 'haku']), // 朱文 / 白文 color: hexColor, }),
font: z.object({ family: z.string().min(1), weight: z.number().int().min(400).max(900), }),
// 全体強度(0..1) effects: z.object({ rotate: zeroToOne, jitter: zeroToOne, sizeVar: zeroToOne, strokeNoise: zeroToOne, edgeChip: zeroToOne, inkBleed: zeroToOne, stretch: zeroToOne, }),
// 文字ごとの個別値(-1..+1) perChar: z.array( z.object({ rotate: negOneToPlusOne, jitterX: negOneToPlusOne, jitterY: negOneToPlusOne, sizeVar: negOneToPlusOne, stretchX: negOneToPlusOne, stretchY: negOneToPlusOne, }), ),})
export type StampConfig = z.infer<typeof StampConfigSchema>effects は「全体強度(ノブ)」、perChar は「文字ごとの個別係数」。最終値はこの 2 つの掛け算で決めます。
finalRotation(i) = perChar[i].rotate * effects.rotate * MAX_ROTATION // 15°finalJitterX(i) = perChar[i].jitterX * effects.jitter * cellWidth * 0.15// ...こうすることで「強度ノブを 0 にすると全アレンジが消える」「強度を上げると perChar の個性がそのままスケールする」という挙動が統一的に書けます。「アレンジを完全に切る」「文字ごとのキャラを保ったまま強度だけ調整する」という UX 操作が、構造的に簡単になる。
URL に詰め込む — base64url バイナリ
クエリパラメータを 30 個並べると見た目が破滅するので、StampConfig 全体を 1 つのバイナリにパックして base64url で乗せます。
https://inkan.teraren.com/?v=1&p=AQAsASwBAA0ALSfIAAEAkAGyRUBJMxBABgDkuK3mnZGF-so7mu8L3QPHjWUv はバージョン番号で別パラメータに切り出してあります(パーサが最初に読んでバージョン別コードパスへ流す)。p が StampConfig 全体のパック表現。
エンコーダはこんな感じで素朴に書いています。
export function encodeConfig(cfg: StampConfig): Uint8Array { const w = new ByteWriter()
// header w.u8(cfg.version) w.u8(SHAPE_KIND[cfg.shape.kind]) w.u16(cfg.shape.width) w.u16(cfg.shape.height) w.u8(quantizeU8(cfg.shape.cornerRadius)) w.u8(quantizeU8(cfg.shape.borderWidth)) w.u8(INK_STYLE[cfg.ink.style]) w.u24(Number.parseInt(cfg.ink.color.slice(1), 16)) w.u8(TEXT_LAYOUT[cfg.textLayout]) w.u16(FONT_FAMILY_TO_ID[cfg.font.family] ?? 1) w.u16(cfg.font.weight)
// effects (7 × u8) w.u8(quantizeU8(cfg.effects.rotate)) w.u8(quantizeU8(cfg.effects.jitter)) // ... 残り 5 つ
// text (UTF-8) const textBytes = new TextEncoder().encode(cfg.text) w.u16(textBytes.length) w.bytes(textBytes)
// perChar entries (6 × i8 each) for (const c of cfg.perChar) { w.i8(quantizeI8(c.rotate)) w.i8(quantizeI8(c.jitterX)) w.i8(quantizeI8(c.jitterY)) w.i8(quantizeI8(c.sizeVar)) w.i8(quantizeI8(c.stretchX)) w.i8(quantizeI8(c.stretchY)) }
return w.toBytes()}量子化は quantizeU8(0..1 を 0..255 に)と quantizeI8(-1..+1 を -127..+127 に)の 2 種類だけ。誤差は最大 0.5% で、印影の見た目には何も影響しないレベルです。
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
export function quantizeU8(v: number): number { return Math.round(clamp(v, 0, 1) * 255)}
export function quantizeI8(v: number): number { return Math.round(clamp(v, -1, 1) * 127)}これで「7 文字の 佐藤太郎山田花子」みたいな込み入った設定でも URL は 100 文字以内に収まります。
useUrlState — UI 状態は URL からだけ取る
URL を Single Source of Truth にすると、React の state は URL のラッパに過ぎなくなります。
function parseCurrentUrl(): { config: StampConfig; error: UrlError | null } { const result = searchParamsToConfig(new URLSearchParams(window.location.search)) if (result.ok) return { config: result.config, error: null } // 失敗時はデフォルトに戻してバナーを出す(サイレントフォールバック禁止) return { config: DEFAULT_CONFIG, error: { reason: result.reason } }}
export function useUrlState() { const [state, setState] = useState(parseCurrentUrl)
useEffect(() => { const onPop = () => setState(parseCurrentUrl()) window.addEventListener('popstate', onPop) return () => window.removeEventListener('popstate', onPop) }, [])
const setConfig = useCallback((cfg, opts) => { const params = configToSearchParams(cfg) const url = `${window.location.pathname}?${params.toString()}` if (opts?.pushHistory) window.history.pushState(null, '', url) else window.history.replaceState(null, '', url) setState({ config: cfg, error: null }) }, [])
return { config: state.config, error: state.error, setConfig }}ポイントは 2 つ。
- スライダーをぐりぐり動かす操作は
replaceState(履歴を汚さない) - 「ランダム」「リセット」みたいな大きい変更は
pushHistory: trueでpushState(戻るで戻れる)
これだけで「シェアした URL が完全再現される」「戻る/進むがちゃんと動く」「リロードしても状態が残る」が全部タダで手に入ります。Redux も React Query もいらない。
永続可能性の契約
「既存の URL は永久にパース可能でなければならない」というのが本サービスの最重要契約です。だってシェアされた URL が将来動かなくなったら印影が失われるので。
これを担保するために:
v=1を絶対に変えない。仕様が変わるならv=2を切る- フォント ID は不変。削除するなら
deprecated: true+fallback: IDを残す - デフォルト値を変えても URL 内の値が優先されるので既存リンクは無傷
// 永続契約:id は一度採番したら変えない。削除時は deprecated + fallback で残す。export const FONT_REGISTRY: readonly FontEntry[] = [ { id: 1, key: 'shunju-tsu', /* ... */ }, { id: 2, key: 'noto-serif-jp', /* ... */ }, { id: 3, key: 'shippori-mincho', /* ... */ }, // ... 17 種]そして契約をテストで縛ります。src/state/__tests__/fixtures/v1/*.json に「v=1 の代表 URL × 期待 config」のフィクスチャを置いておいて、
describe('v1 fixtures', () => { for (const f of files) { it(`${f} decodes to expected config`, () => { const { v, p, expectedConfig } = JSON.parse(readFileSync(...)) const params = new URLSearchParams() params.set('v', v) params.set('p', p) const r = searchParamsToConfig(params) expect(r.ok).toBe(true) if (r.ok) expect(r.config).toEqual(expectedConfig) }) }})将来うっかりバイナリレイアウトを変えたら、このテストが落ちる。「永続契約」を機械が守ってくれる状態です。
Layout 層 — 丸印と角印のレイアウト計算
Layout 層の責務は「StampConfig を受け取って、各文字の基準座標・サイズ・向きを返す」こと。それ以上は何もしない。
export function layout(cfg: StampConfig): LayoutResult { return cfg.shape.kind === 'circle' ? circleLayout(cfg) : rectLayout(cfg)}丸印:5 種類のストラテジ
文字数によって自動で最適なレイアウトを選ぶようにしています。
function decideStrategy(cfg: StampConfig, n: number): Strategy { if (cfg.textLayout === 'auto') { if (n === 1) return 'center' if (n === 2) return 'vertical' if (n <= 4) return 'grid' return 'perimeter' // 5 文字以上は円周配置 } // ... 明示指定もある}perimeter は文字を円周に沿って並べるやつ(落款印でよく見るやつ)。grid は 2x2 で 4 文字を綺麗に並べるパターンで、苗字 + 名前で 4 文字の人にちょうど良い。
角印:multi-column
角印は center / vertical / multi-column の 3 種類。multi-column は縦書きの右から左へ複数段で流すレイアウトで、長い名前や会社名向け。

Grapheme 単位の分割
地味に大事なのが、文字数を text.length で数えないこと。サロゲートペアや絵文字(実際使う人は少ないですが)、合字を 1 文字として扱うために Intl.Segmenter を使っています。
const segmenter = new Intl.Segmenter('ja', { granularity: 'grapheme' })
export function splitGraphemes(text: string): string[] { return Array.from(segmenter.segment(text), (s) => s.segment)}これがあると perChar 配列の長さも grapheme 数と一致させられて、文字ごとの個別アレンジ UI も「人間が見て 1 文字」と一致する。
Render 層 — SVG + Canvas の二段構え
レンダリングは「SVG で構造を組んで、Canvas でピクセル後処理」という二段構えにしています。
- SVG = 文字配置・枠線・パス操作(
edgeChipの枠歪み) - Canvas = 後処理(
strokeNoiseのかすれ、inkBleedのにじみ)
なぜ分けたか。SVG は構造が透明でデバッグしやすく、文字とフレームを純粋関数で組み立てられる。一方、ピクセル単位の質感(かすれ・にじみ)は SVG の <filter> でやると挙動が浏覧器ごとにバラついて再現性が壊れるので、Canvas に降ろして自分で書くのが結局一番確実でした。
決定的ハッシュ + PRNG + Perlin ノイズ
「同じ URL なら同じ印影」を担保するために、ノイズの種を毎回 StampConfig から決定的に導出します。
// FNV-1a 32-bitexport function hashString(s: string): number { let h = 0x811c9dc5 for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i) h = Math.imul(h, 0x01000193) } return h >>> 0}
// mulberry32: 決定的 32-bit PRNGexport function mulberry32(seed: number): () => number { let a = seed >>> 0 return () => { a = (a + 0x6d2b79f5) >>> 0 let t = a t = Math.imul(t ^ (t >>> 15), t | 1) t ^= t + Math.imul(t ^ (t >>> 7), t | 61) return ((t ^ (t >>> 14)) >>> 0) / 4294967296 }}シードの導出は意図的に テキスト + perChar だけ から計算します。フォント・色・形状・effect 強度は除外。
export function deriveSeed(cfg: StampConfig): number { let h = 0x811c9dc5 const mix = (b: number) => { h ^= b & 0xff h = Math.imul(h, 0x01000193) }
const textBytes = new TextEncoder().encode(cfg.text) for (const b of textBytes) mix(b) mix(0) // separator
for (const c of cfg.perChar) { const values = [c.rotate, c.jitterX, c.jitterY, c.sizeVar, c.stretchX, c.stretchY] for (const v of values) { const q = quantizeI8(v) mix(q < 0 ? q + 256 : q) } } return h >>> 0}なぜこの設計にしたか:フォントを切り替えても、かすれや欠けのパターンは変わってほしくないから。文字ごとのキャラクターは「文字 + perChar」で一意に決まり、フォント・色・サイズはあとから着せ替えられる、という直感的な分離をデータモデル側で守らせています。
そして大事なのが、perChar の値を i8 量子化済みの値 で混ぜていること。URL に乗せる前と後で 0.5% ぐらい誤差が出るので、その誤差ぶんだけシードがズレて、ノイズ模様が変わってしまう。これを防ぐために「URL 形式に正規化してから seed を導出」しています。地味ですが、これを直すまで「シェアした URL を開くと微妙に印影が違う」というバグに 1 時間溶かしました。
Perlin ノイズで枠の欠けを path 生成段階で
枠の edgeChip(欠け)は SVG の path 生成段階で実装。Perlin ノイズで円周をサンプリングして、ノイズ値ぶん半径方向に押し引きする。
const SAMPLES = 64
function perturbCircle(cx, cy, r, strokeWidth, opts): string { const pts: [number, number][] = [] const amp = strokeWidth * 0.4 * opts.edgeChipStrength for (let i = 0; i < SAMPLES; i++) { const t = (i / SAMPLES) * Math.PI * 2 const nx = Math.cos(t) const ny = Math.sin(t) const d = opts.noise(Math.cos(t) * 2, Math.sin(t) * 2) * amp pts.push([cx + nx * (r + d), cy + ny * (r + d)]) } // M ... L ... Z で閉じる return /* ... */}最初は振幅を strokeWidth × 2 にしていて、効果が強すぎて枠がボロボロになっていました。× 0.4 まで落として、ようやく「使い込んだ印鑑の枠」っぽい質感に。
Canvas でのかすれ後処理
strokeNoise(かすれ)は SVG をラスタライズした後、Canvas のピクセル配列を直接舐めてアルファを下げます。
export function applyStrokeNoise(ctx, width, height, strength, seed): void { if (strength <= 0) return const noise = makePerlin2D(seed) const img = ctx.getImageData(0, 0, width, height) const data = img.data const threshold = -0.6 + strength * 1.2 for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const i = (y * width + x) * 4 if (data[i + 3]! === 0) continue // 透明ピクセルはスキップ const n = noise(x * 0.04, y * 0.04) if (n < threshold) { data[i + 3] = Math.max(0, Math.floor( data[i + 3]! * Math.max(0, (n - threshold) * 3 + 1) )) } } } ctx.putImageData(img, 0, 0)}inkBleed(にじみ)は逆に、Canvas の filter: blur(...) で半透明レイヤを重ねる方式。素朴なんですが、軽くて実用上これで十分でした。
export function applyInkBleed(ctx, width, height, strength, ...) { if (strength <= 0) return const radius = 0.5 + strength * 3 const off = offscreenFactory(width, height) // ... offscreen に現在のピクセルをコピー ctx.save() ctx.filter = `blur(${radius.toFixed(1)}px)` ctx.globalAlpha = Math.min(1, strength * 1.2) ctx.drawImage(off, 0, 0) ctx.restore()}buildSvg のオーケストレーション
3 つの層をつなぐエントリポイント:
export function buildSvg(l: LayoutResult, cfg: StampConfig): string { // URL 形式に正規化(i8/u8 に量子化)してから seed を作る const normalized = normalizeConfig(cfg) const seed = deriveSeed(normalized) const noise = makePerlin2D(seed) const effects = computeCharEffects(l, normalized) const font = getFontByKey(normalized.font.family)
const frameSvg = buildFrame(l.frame, normalized.ink.color, normalized.ink.style, { edgeChipStrength: normalized.effects.edgeChip, noise, }) const charsSvg = buildChars(l.chars, fontSpec, normalized.ink.color, normalized.ink.style, { rotations: effects.rotations, jittersX: effects.jittersX, /* ... */ })
return serializeSvg({ width: l.canvas.width, height: l.canvas.height, body: frameSvg + charsSvg })}
export async function renderToCanvas(l, cfg, canvas, scale = 2) { const normalized = normalizeConfig(cfg) const svg = buildSvg(l, normalized) await rasterizeSvgToCanvas(svg, canvas, l.canvas.width, l.canvas.height, scale) const ctx = canvas.getContext('2d', { willReadFrequently: true })! const seed = deriveSeed(normalized) applyStrokeNoise(ctx, canvas.width, canvas.height, normalized.effects.strokeNoise, seed) applyInkBleed(ctx, canvas.width, canvas.height, normalized.effects.inkBleed)}これで「SVG で組む → ラスタライズ → ピクセル後処理」が 1 関数で閉じます。
UI / UX
2 カラム → モバイルは積み重ね
デスクトップは「左にプレビュー / 右にコントロール」、モバイルは縦に積み重ねの 1 カラムにしてあります。

最初はモバイルで「プレビューは固定で、コントロールはボトムシートで上にスライドしてくる」設計にしていたのですが、
- ボトムシートが半透明でプレビューが透けて操作中に印影が見えない
- スライダーをいじりたいのに高さ確保が常に足りない
- iOS Safari の
100vh問題が地味に効いてくる
で、結局「常時 1 カラム積み重ね」にしました。スクロールはユーザーが慣れているので、変な仕掛けより素直なほうが速い。

プレビュークリックで押印アニメ + 効果音
地味に楽しいギミック。プレビューをクリックすると、印影が「ポン」と押印されるアニメと音が再生されます。ミュートトグル付き。
// useStampSound.ts (抜粋)const audio = new Audio('/sounds/pon.mp3')audio.volume = 0.4audio.play().catch(() => {}) // 自動再生ブロックは握り潰すこれがあるだけで、ジェネレーターというより「印影を作って押す体験」になる。意味は薄いんだけどあると嬉しい類のやつです。

Safari でフリーズした不具合
開発中、Safari だけプレビューが固まる現象に悩まされました。原因は Canvas の getImageData を毎フレーム呼んでいたこと。
// before: 毎フレーム getImageData → setImageDatauseEffect(() => { renderToCanvas(layout, config, canvasRef.current!) // 毎回 effects 後処理}, [config]) // config が変わるたびに走るスライダーをぐりぐり動かしている最中、Safari の Canvas 実装は getImageData で毎回ピクセル配列をコピーする際の Garbage Collection に弱くて、すぐ固まる。
修正は「Canvas 操作は debounce + requestAnimationFrame でまとめる」+「willReadFrequently: true を getContext に渡す」の 2 段構え。
const ctx = canvas.getContext('2d', { willReadFrequently: true })このフラグを付けると、ブラウザが getImageData の頻繁な呼び出しを前提にした最適化(CPU 側にバッファを置くなど)をしてくれて、Safari でも実用域に入りました。Chrome は元々速かったので気づかなかった。
フォント切替で描画が変わらない問題
これも面白いバグでした。フォントセレクタを操作してもプレビューに反映されない。
原因はフォント未ロード時のフォールバックで描画してしまっていたこと。Web フォントは初回ロードに時間がかかるので、document.fonts.ready を待たずに Canvas に流すと、ブラウザがそのフォントを「まだ持っていない」状態で描画してしまう。
async function renderWithFont(font: FontSpec, ...) { await document.fonts.ready const ok = await document.fonts.check(`16px ${font.cssFamily}`) if (!ok) { // ロード失敗 → fallback で描画 } // ここまで来てから Canvas 描画}document.fonts.check() で「本当にこのフォントが利用可能か」を確認してから初めて Canvas に渡す。これでフォント切替が即時に反映されるようになりました。
SEO 戦略 — 苗字ランディングページの自動生成
SPA は SEO に弱い、というのが定説です。でも「丸印を作るやつでしょ」と検索されると、たぶん「佐藤の印鑑」「鈴木の印鑑」みたいな具体的な苗字込みクエリで来る人が多いはず。
そこで、日本の苗字ランキング TOP 100 × 3 バリアント(丸印朱文・丸印白文・角印)の静的ランディングページを prebuild で生成することにしました。

prebuild で全部静的に吐く
package.json:
{ "scripts": { "prebuild": "bun run scripts/build-myoji-pages.ts", "build": "tsc -b && vite build" }}prebuild で苗字ページ + OG 画像 + sitemap.xml を public/myoji/ に吐いて、それを Vite が dist/ にコピーする。
async function main() { const entries = loadMyojiData('data/myoji.json')
// 一覧ページ writeFileSync(join(OUT_ROOT, 'index.html'), renderIndex(entries), 'utf8')
// 詳細ページ × 100 for (const entry of entries) { const variants = renderVariants(entry.surname) // 3 種類の SVG + 編集 URL const related = findRelated(entry, entries, 5) // rank ±5 の関連苗字 const dir = join(OUT_ROOT, entry.surname) mkdirSync(dir, { recursive: true }) writeFileSync(join(dir, 'index.html'), renderDetail(entry, variants, related), 'utf8') }
// OG 画像 × 100(Playwright で生成) const browser = await chromium.launch() for (const entry of entries) { const out = join(OUT_ROOT, entry.surname, 'og.png') await generateOgImage(browser, entry, out) } await browser.close()
// サイトマップ writeFileSync('public/sitemap.xml', renderSitemap(entries), 'utf8')}OG 画像は Playwright の Headless Chromium で 1 枚ずつ生成。これがあるおかげで、/myoji/佐藤/ を Twitter にシェアすると「佐藤の印鑑」専用 OG カードが出ます。

詳細ページには 3 つのバリアント SVG をそのまま埋め込んで、各バリアントには「この印影を編集する」リンクを付けています。クリックすると、その苗字の URL(?v=1&p=...)でジェネレーター本体が立ち上がる。SEO 用の静的ページから本体 SPA への入口として機能する設計です。
nginx で静的ファイルを優先
SPA fallback よりも先に /myoji/* の静的 HTML を返すよう、nginx 側でロケーションを切ってあります。
# 末尾スラッシュなしは 301 リダイレクトlocation ~ ^/myoji/([^/]+)$ { return 301 /myoji/$1/;}
# 一覧ページlocation = /myoji/ { try_files $uri $uri/index.html =404; expires 1h;}
# 苗字別ページ(末尾スラッシュ必須)location ~ ^/myoji/[^/]+/$ { try_files $uri $uri/index.html =404; expires 1h;}
# 苗字別 OG 画像location ~ ^/myoji/[^/]+/og\.png$ { try_files $uri =404; expires 7d;}正規表現でロケーションを切らないと、SPA の index.html が /myoji/佐藤/ でも返されてしまうので注意。
JSON-LD / canonical / breadcrumb
各詳細ページには WebApplication の JSON-LD と breadcrumb の構造化データを埋め込んでいます。canonical は当然苗字 URL を指す。トップの SPA 側にも <link rel="canonical"> と JSON-LD を入れて、Google に「これは Web アプリだよ」と伝えています。
noscript h1 を入れて JS 切ったときも内容が読めるようにしてあるのも地味なポイント。robots.txt と sitemap.xml も静的に生成して public/ 直下に置く。
関連苗字ナビ
詳細ページ下部に「rank ±5 の関連苗字」へのリンクを並べて、内部リンクを張っています。
export function findRelated(entry, entries, count): MyojiEntry[] { const sorted = [...entries].sort((a, b) => Math.abs(a.rank - entry.rank) - Math.abs(b.rank - entry.rank)) return sorted.filter((e) => e.surname !== entry.surname).slice(0, count)}「佐藤」の隣に「鈴木」「高橋」「田中」が並ぶ。これで Google から見たときの内部リンク構造が一気に整います。
デプロイ — Coolify + Docker + nginx
最初は Cloudflare Pages にしようと思っていたんですが、OG 画像生成に Playwright Chromium が要るので断念。Pages のビルド環境にブラウザを入れるのは現実的じゃない。
最終的に Coolify(セルフホスト PaaS)に Docker でデプロイする構成に。Dockerfile はこんな感じ。
FROM oven/bun:1 AS builderWORKDIR /app
COPY bun.lock package.json ./RUN bun install --frozen-lockfile
# Install Playwright chromium browser (required for OG image generation in prebuild)RUN bunx playwright install --with-deps chromium
COPY . .
ARG VITE_GTM_IDENV VITE_GTM_ID=${VITE_GTM_ID}
RUN bun run build
FROM nginx:alpineCOPY --from=builder /app/dist /usr/share/nginx/htmlCOPY nginx.conf /etc/nginx/conf.d/default.confCOPY docker-entrypoint.sh /docker-entrypoint.shRUN chmod +x /docker-entrypoint.shEXPOSE 80CMD ["/docker-entrypoint.sh"]bunx playwright install --with-deps chromium がポイント。これを入れ忘れて prebuild が落ちて 30 分溶かしました。
CDN キャッシュ汚染問題
途中、SPA fallback の index.html に Cache-Control を付けていなかったせいで、CDN が古い index.html を握ってしまい、新しいデプロイの JS バンドル URL を含む HTML が配信されない、という事故をやりました。
# SPA fallback には no-cache を付けるlocation / { try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache, no-store, must-revalidate";}
# 一方で /assets/ 以下のハッシュ付きバンドルは 1 年キャッシュlocation /assets/ { expires 1y; add_header Cache-Control "public, immutable";}「index.html は no-cache、ハッシュ付きアセットは immutable」が SPA キャッシュ戦略の鉄則です。
テスト戦略
Math.random() 禁止 lint
冒頭で「Math.random() 使わない」と書きましたが、これを README に書くだけだと絶対に守られない。テストで縛ります。
describe('no Math.random in pure layers', () => { const PURE_DIRS = ['src/state', 'src/layout', 'src/render'] for (const dir of PURE_DIRS) { it(`${dir} has no Math.random`, () => { const offenders: string[] = [] for (const file of walk(dir)) { const contents = readFileSync(file, 'utf8') if (/Math\.random\s*\(/.test(contents)) offenders.push(file) } expect(offenders).toEqual([]) }) }})愚直ですが効きます。grep をテストに昇格させただけ、とも言える。レイヤを跨ぐ「決定性」というアーキテクチャ制約は、こういう「全部見る系」のテストが一番強い。
パイプライン決定性テスト
レンダリングパイプライン全体に「同じ config なら同じ SVG」を担保するテスト:
it('produces byte-identical SVG for the same config', () => { const cfg = withAllEffects('佐藤太郎') const l = layout(cfg) const a = buildSvg(l, cfg) const b = buildSvg(l, cfg) expect(a).toBe(b)})
it('survives URL round-trip', () => { const cfg = withAllEffects('佐藤太郎') const decoded = decodeConfig(encodeConfig(cfg)) const l = layout(decoded) expect(buildSvg(l, decoded)).toBe(buildSvg(layout(cfg), cfg))})「URL を経由してももう一度同じ SVG が出る」を 1 行で担保できる。これがあると seed 導出のバグみたいなのが回帰テストで一発で捕まります。
fast-check による property-based テスト
StampConfig のフィールドが 0..1 や -1..+1 みたいな範囲制約だらけなので、ランダムな config を吐いて「encode → decode が原値に戻ること(量子化誤差以内)」みたいな性質を fast-check で検証しています。
fc.assert( fc.property(arbitraryConfig(), (cfg) => { const back = decodeConfig(encodeConfig(cfg)) expect(back.text).toBe(cfg.text) expect(back.shape.width).toBe(cfg.shape.width) expect(back.effects.rotate).toBeCloseTo(cfg.effects.rotate, 2) // 量子化誤差込み // ... }),)例ベースのテストだと「7 文字の苗字が」「絵文字混じりだと」みたいなコーナーケースを書き漏らすので、property-based のほうが向いてます。
アナリティクス
GTM 連携を入れていますが、VITE_GTM_ID を環境変数で注入する方式にしてあって、未設定なら何も読み込まれない。
const gtmId = import.meta.env.VITE_GTM_IDif (gtmId) { // GTM スクリプトを動的注入 // 主要アクション(生成・ランダム・保存・シェア)を dataLayer に push}ローカル開発と stg / prod でログ汚染を分離できる。「GA タグを直接書く → 開発時にもイベントが飛ぶ」という事故が起きないので、推奨パターンです。
Claude Code との協働メモ
このプロジェクトは大半を Claude Code に書いてもらっています。100 コミット強の内訳的には:
- 設計書(spec)→ 実装計画(plan)→ 実装 の 3 段階で進める
- 各 plan ごとにブランチを切って TDD で実装してもらう
- 人間は spec 書きと「動かして触ってヘンなところを指摘する」レビュー役
docs/superpowers/specs/ に設計書、docs/superpowers/plans/ に実装 plan。
docs/superpowers/├── plans/│ ├── 2026-04-22-plan1-core-engine.md│ ├── 2026-04-23-plan2-full-ui-fonts.md│ ├── 2026-04-23-plan3-e2e-deploy.md│ └── 2026-04-25-myoji-seo-landing-pages.md└── specs/ ├── 2026-04-22-inkan-generator-design.md (878 行) └── 2026-04-25-myoji-seo-landing-pages-design.md (376 行)このサイズの spec を最初にちゃんと書くと、あとは plan → 実装が機械的に進む。AI 協働で効くのは、
- Zod / TypeScript / Biome / fast-check のような「契約を機械に強制させる」道具
Math.random()禁止テスト のような「アーキテクチャ制約をテストにする」考え方StampConfigのような 境界面の型を 1 つに絞る 設計
の 3 つだと感じました。AI が書いた局所コードがどれだけ怪しくても、契約が機械で強制されていれば全体は壊れない。「型と境界を人間が決めて、実装は AI に振る」のはコスパが極端に高い。
クレジットページを作って、フォントと素材の出典をちゃんと並べておきました。

これからやりたいこと
- 印影ユニーク度の表示:「あなたの印影は ◯◯ 通り中の 1 つです」を動的に計算して表示。フォント数 × 形状 × 効果値 × perChar の組合せから。
- フォント追加:朱文専用フォント、楷書、隷書。永続契約があるので新規 ID を末尾に追加するだけで安全に増やせる
- 苗字ランディング 100 → 1,000 件へ拡張。データソースは決まっているので prebuild の処理時間がボトルネック
- 印鑑証明書風 PDF エクスポート(やるかは未定)
まとめ
- 100 円ショップの三文判以降、印鑑の本来要件であるユニークネスは形骸化している。無料サービスもその形骸化を引き継いでいて、本来の意味の印鑑になっていない
- 完全な模倣不可能性は無理でも、巨大な設定空間 + URL を残しておくことによる生成元の証明可能性で、印鑑として実用上十分なユニークネスは取り戻せる
- 設計の核は「
StampConfig1 つに集約」「3 層は純粋関数」「決定性を lint と test で強制」「URL を Single Source of Truth に」 - React 19 + Vite + Tailwind v4 / Coolify + Docker + nginx / 苗字 SEO ランディング
- 100 コミット強で MVP からデプロイまで。Claude Code 協働では「契約を機械に強制させる道具」が効く
inkan.teraren.com で触れます。生成した URL は必ず保存しておいてください。その URL を最初に保有していたという事実が、その印影が自分のものであることの証明になります。
フィードバック・要望・「このフォント入れて」等あれば @matsubokkuri まで。


