7571 文字
38 分

世界に一つの印影をブラウザで無料で作れるサービスを作った — 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 で生成

inkan.teraren.com のトップページ

動機 — 印鑑から失われた「ユニークネス」を取り戻す#

そもそも印鑑というのは「世界に一つだけの印影」であることに本質的な意味があります。実印・銀行印・認印いずれも、印影が個人や組織に一意に紐づいているからこそ、押された文書が「私が承認した」ことの証明になる。ユニークネスこそが印鑑のコア要件であり、それが担保されないなら印鑑である意味がない。

ところが現実はどうか。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 で抜く方針。

src/state/schema.ts
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 つの掛け算で決めます。

src/render/effects.ts
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-so7mu8L3QPHjWU

v はバージョン番号で別パラメータに切り出してあります(パーサが最初に読んでバージョン別コードパスへ流す)。pStampConfig 全体のパック表現。

エンコーダはこんな感じで素朴に書いています。

src/state/encode.ts
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% で、印影の見た目には何も影響しないレベルです。

src/state/binary.ts
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 のラッパに過ぎなくなります。

src/state/useUrlState.ts
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: truepushState(戻るで戻れる)

これだけで「シェアした URL が完全再現される」「戻る/進むがちゃんと動く」「リロードしても状態が残る」が全部タダで手に入ります。Redux も React Query もいらない。

永続可能性の契約#

既存の URL は永久にパース可能でなければならない」というのが本サービスの最重要契約です。だってシェアされた URL が将来動かなくなったら印影が失われるので。

これを担保するために:

  1. v=1 を絶対に変えない。仕様が変わるなら v=2 を切る
  2. フォント ID は不変。削除するなら deprecated: true + fallback: ID を残す
  3. デフォルト値を変えても URL 内の値が優先されるので既存リンクは無傷
src/fonts/registry.ts
// 永続契約: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」のフィクスチャを置いておいて、

src/state/__tests__/fixtures.test.ts
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 を受け取って、各文字の基準座標・サイズ・向きを返す」こと。それ以上は何もしない。

src/layout/index.ts
export function layout(cfg: StampConfig): LayoutResult {
return cfg.shape.kind === 'circle'
? circleLayout(cfg)
: rectLayout(cfg)
}

丸印:5 種類のストラテジ#

文字数によって自動で最適なレイアウトを選ぶようにしています。

src/layout/circleLayout.ts
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 を使っています。

src/layout/graphemes.ts
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 から決定的に導出します。

src/render/noise/hash.ts
// FNV-1a 32-bit
export 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 PRNG
export 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 強度は除外。

src/render/index.ts
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 ノイズで円周をサンプリングして、ノイズ値ぶん半径方向に押し引きする。

src/render/svg/buildFrame.ts
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 のピクセル配列を直接舐めてアルファを下げます。

src/render/canvas/strokeNoise.ts
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(...) で半透明レイヤを重ねる方式。素朴なんですが、軽くて実用上これで十分でした。

src/render/canvas/inkBleed.ts
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 つの層をつなぐエントリポイント:

src/render/index.ts
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 カラムにしてあります。

アレンジタブの UI

最初はモバイルで「プレビューは固定で、コントロールはボトムシートで上にスライドしてくる」設計にしていたのですが、

  • ボトムシートが半透明でプレビューが透けて操作中に印影が見えない
  • スライダーをいじりたいのに高さ確保が常に足りない
  • iOS Safari の 100vh 問題が地味に効いてくる

で、結局「常時 1 カラム積み重ね」にしました。スクロールはユーザーが慣れているので、変な仕掛けより素直なほうが速い。

モバイルレイアウト

プレビュークリックで押印アニメ + 効果音#

地味に楽しいギミック。プレビューをクリックすると、印影が「ポン」と押印されるアニメと音が再生されます。ミュートトグル付き。

// useStampSound.ts (抜粋)
const audio = new Audio('/sounds/pon.mp3')
audio.volume = 0.4
audio.play().catch(() => {}) // 自動再生ブロックは握り潰す

これがあるだけで、ジェネレーターというより「印影を作って押す体験」になる。意味は薄いんだけどあると嬉しい類のやつです。

プレビューカード

Safari でフリーズした不具合#

開発中、Safari だけプレビューが固まる現象に悩まされました。原因は Canvas の getImageData を毎フレーム呼んでいたこと。

// before: 毎フレーム getImageData → setImageData
useEffect(() => {
renderToCanvas(layout, config, canvasRef.current!) // 毎回 effects 後処理
}, [config]) // config が変わるたびに走る

スライダーをぐりぐり動かしている最中、Safari の Canvas 実装は getImageData で毎回ピクセル配列をコピーする際の Garbage Collection に弱くて、すぐ固まる。

修正は「Canvas 操作は debounce + requestAnimationFrame でまとめる」+「willReadFrequently: truegetContext に渡す」の 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/ にコピーする。

scripts/build-myoji-pages.ts
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.txtsitemap.xml も静的に生成して public/ 直下に置く。

関連苗字ナビ#

詳細ページ下部に「rank ±5 の関連苗字」へのリンクを並べて、内部リンクを張っています。

scripts/myoji/related.ts
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 builder
WORKDIR /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_ID
ENV VITE_GTM_ID=${VITE_GTM_ID}
RUN bun run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
EXPOSE 80
CMD ["/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 に書くだけだと絶対に守られない。テストで縛ります。

src/rules/no-random.test.ts
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」を担保するテスト:

src/render/__tests__/determinism.test.ts
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 を環境変数で注入する方式にしてあって、未設定なら何も読み込まれない。

src/analytics.ts
const gtmId = import.meta.env.VITE_GTM_ID
if (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 に振る」のはコスパが極端に高い。

クレジットページを作って、フォントと素材の出典をちゃんと並べておきました。

Credits ページ

これからやりたいこと#

  • 印影ユニーク度の表示:「あなたの印影は ◯◯ 通り中の 1 つです」を動的に計算して表示。フォント数 × 形状 × 効果値 × perChar の組合せから。
  • フォント追加:朱文専用フォント、楷書、隷書。永続契約があるので新規 ID を末尾に追加するだけで安全に増やせる
  • 苗字ランディング 100 → 1,000 件へ拡張。データソースは決まっているので prebuild の処理時間がボトルネック
  • 印鑑証明書風 PDF エクスポート(やるかは未定)

まとめ#

  • 100 円ショップの三文判以降、印鑑の本来要件であるユニークネスは形骸化している。無料サービスもその形骸化を引き継いでいて、本来の意味の印鑑になっていない
  • 完全な模倣不可能性は無理でも、巨大な設定空間 + URL を残しておくことによる生成元の証明可能性で、印鑑として実用上十分なユニークネスは取り戻せる
  • 設計の核は「StampConfig 1 つに集約」「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 まで。

この記事が役に立ったら
GitHub Sponsorsで応援できます

コメント