Developer console

Seal specification (v0)

How to draw a seal from a hash. Anyone following these rules draws the same seal.

Input

The input is a 32-byte hash. For a signature, it is the intent's message_digest (base64url) decoded back into 32 bytes. The bytes are called d0, d1, … from the start.

message_digest (base64url)
TxO-68UEwD6K9RXDXelOszsebQO044wzHSUfwyTSQQY

bytes (hex)
4f13beebc504c03e8af515c35de94eb33b1e6d03b4e38c331d251fc324d24106

d0 = 0x4f, d1 = 0x13, d2 = 0xbe, … d31 = 0x06

A seal helps people notice a difference. Someone who searches hard enough can find a seal that looks similar, so decide whether a signature is valid by verifying the signature.

Bands

The viewBox is -520 -520 1040 1040 with the centre at (0, 0). Bands are always drawn in the same order, from the outside in.

  • Rimr 500 · 481 · 464 · 444–458
    The outer rule, microtext of the hash (full only), the inner rule and the ticks.
  • Netcentred on r 384
    A bundle of phase-shifted sine waves.
  • Ruler 318
    Separates the net from the field.
  • Fieldr 196–304
    Stacked wavy lines at shifting radii.
  • Flowerup to r 176
    A hypotrochoid or an epitrochoid.
  • Markr 26 · ±44
    The centre circle and cross.

Values

From bytes to values
pick(list, b) = list[b mod length]        u(b) = b / 255        clamp(v, lo, hi)

net (d0–d7)
  lobes    = pick([12, 14, 15, 16, 18, 20, 21, 24], d0)
  amp      = clamp((1 + u(d2) × 1.2) × 384 / lobes, 18, 46)
  strands  = round(clamp(pick([14, 16, 18, 20, 24], d1) × 30 / amp, 10, 28))
  mod      = pick([0, 2, 3, 4, 5, 6, 8, 9], d3)
  modDepth = 0.12 + u(d4) × 0.3
  twist    = u(d5) × 2π
  beat     = pick([2, 3, 4, 5], d6)
  beatAmp  = u(d7) × 6

field (d8–d14)
  lines    = pick([16, 20, 24, 28], d8)
  lobes    = pick([3, 4, 5, 6, 7, 8, 9, 10, 12], d9)
  amp      = (304 − 196) / (lines − 1) × (0.9 + u(d10) × 2.4)
  drift    = (u(d11) − 0.5) × 0.7
  beat     = pick([2, 3, 4, 5], d12)
  beatAmp  = u(d13) × 5
  twist    = u(d14) × 2π

flower (d15–d19)
  petals   = pick([5, 6, 7, 8, 9, 10, 11, 12, 13], d15)
  strands  = pick([2, 3, 4, 5, 6], d16)
  ratio    = 0.36 + u(d17) × 0.34
  twist    = u(d18) × 2π
  epi      = d19 is odd

ticks (d20–d21)
  count    = pick([96, 120, 144, 160, 180], d20)
  twist    = u(d21) × 2π

Curves

Curves
net    j = 0 … strands − 1, θ splits 2π into lobes × 28 equal steps
       φ = 2π j / strands + twist
       E(θ) = 1 − modDepth + modDepth × cos(mod θ)        1 when mod = 0
       r(θ) = 384 + amp × sin(lobes θ + φ) × E(θ) + beatAmp × sin((lobes − beat) θ − φ)

field  j = 0 … lines − 1, θ splits 2π into max(lobes, beat) × 36 equal steps
       r0 = 196 + amp, r1 = 304 − amp
       base = r0 + (r1 − r0) × j / (lines − 1)
       φ = twist + j × drift
       r(θ) = base + amp × sin(lobes θ + φ) + beatAmp × sin(beat θ − φ / 2)

flower j = 0 … strands − 1, t splits 2π into petals × 48 equal steps
       a = 176 / (1 + ratio), b = 176 − a
       k = petals + 1 (epi), otherwise −(petals − 1)
       z(t) = a e^{it} + b e^{ikt}, rotated by twist + 2π j / (strands × petals)

ticks  count segments from radius 444 to 458, evenly spaced from twist
mark   a circle of radius 26, plus vertical and horizontal lines from −44 to 44

Points are (r cos θ, r sin θ). Round coordinates to one decimal place and close each line as a polyline.

Carve start

Each line reads one byte of the hash as an angle and starts carving there. The shape does not change.

Carve start
i = 0, 1, … is the carving order (outside → in): outer rules, ticks, net, rule, field, flower, mark
s(i) = (d[i mod 32] + 97 × ⌊i / 32⌋) mod 256

polyline  start at point ⌊s × points / 256⌋ of the equal steps
circle    from point P at angle 2π s / 256, two half arcs P → −P → P
          round P's coordinates to one decimal place away from 0 (if P falls inside the circle, the arc centre shifts)
ticks     start at segment ⌊s × count / 256⌋
mark      the circle as above; the cross is unchanged

Every line runs in the direction of increasing θ (t for the flower), clockwise on screen.

Display size and levels

The display size in CSS px picks a level; smaller sizes thin out the lines. Line width in viewBox units is px × 1040 ÷ the display size.

  • full240px and up
    Every band and the microtext. Hairlines 0.7px, rules 1px.
  • mid120–239px
    Drops the microtext and halves the ticks and the lines in the net, field and flower. Hairlines 0.6px.
  • small48–119px
    Drops the ticks and keeps a quarter of the lines (at least 4 in the net and 5 in the field).
  • pip21–47px
    The outer rule and a single flower line with its ratio capped at 0.3.
  • dot20px and below
    Only the outer rule and the centre circle.

Ink states

  • Wet — waiting for a signature. Drawn in the blue of freshly written ink.
  • Fixed — signed. Drawn in black with lines 1.12 times thicker.
  • Rust — the contents do not match. Drawn in rust, with every line except the rules broken (dashes of 0.011 and 0.007 against a pathLength of 1).

Test vectors

Values to check an implementation against, rounded to four decimal places. The first input is the promise sentence behind the Clavis mark, in Japanese.

Test vectors (JSON)
[
  {
    "input": "Clavis は Passkey の署名で、誰が・何に・いつ同意したかを再検証できる記録を、プロダクトに埋め込みます。",
    "digest": "R09cAcXyJdHJY7r8H8dGVxAfultyVlwNS_6eq9qsv08",
    "net": { "lobes": 24, "strands": 28, "amp": 22.9271, "mod": 2, "modDepth": 0.3518, "beat": 3, "beatAmp": 4.9176 },
    "field": { "lines": 20, "lobes": 3, "amp": 15.0665, "drift": 0.3418, "beat": 5, "beatAmp": 3.902 },
    "flower": { "petals": 11, "strands": 3, "ratio": 0.4013, "epi": true },
    "ticks": { "count": 180 },
    "full_640px": { "paths": 56, "first_net_index": 3, "first_net_start": 1, "first_net_point": "M390.1 7.3" }
  },
  {
    "input": "",
    "digest": "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU",
    "net": { "lobes": 16, "strands": 10, "amp": 46, "mod": 3, "modDepth": 0.2988, "beat": 2, "beatAmp": 0.4706 },
    "field": { "lines": 24, "lobes": 12, "amp": 15.0095, "drift": 0.199, "beat": 3, "beatAmp": 2.1765 },
    "flower": { "petals": 5, "strands": 6, "ratio": 0.592, "epi": false },
    "ticks": { "count": 96 },
    "full_640px": { "paths": 45, "first_net_index": 3, "first_net_start": 66, "first_net_point": "M-16.9 402.5" }
  }
]

Draw and check

SHA-256 R09cAcXyJdHJY7r8H8dGVxAfultyVlwNS_6eq9qsv08 · SHA-256 R09cAcXyJdHJY7r8H8dGVxAfultyVlwNS_6eq9qsv08 · SHA-256 R09cAcXyJdHJY7r8H8dGVxAfultyVlwNS_6eq9qsv08 · SHA-256 R09cAcXyJdHJY7r8H8dGVxAfultyVlwNS_6eq9qsv08 · SHA-256 R09cAcXyJdHJY7r8H8dGVxAfultyVlwNS_6eq9qsv08 · SHA-256 R09cAcXyJdHJY7r8H8dGVxAfultyVlwNS_6eq9qsv08 · SHA-256 R09cAcXyJdHJY7r8H8dGVxAfultyVlwNS_6eq9qsv08 ·
240px
96px
32px
net    lobes 24 · strands 28 · mod 2
field  lines 20 · lobes 3
flower petals 11 · strands 3 · epi
ticks  180

Reference implementation

A TypeScript implementation of these rules. It does not depend on the DOM and returns either an SVG string or one path per line.

Open seal.ts
/**
 * Clavis 刻印(Seal)v0 — 照合値(SHA-256 の 32 バイト)から印を決定的に描く。
 *
 * 同じ照合値からは同じ刻印、1 ビット違えば別の刻印になる。刻印は人が違いに気づくための補助で、
 * 照合は暗号の検証が決める(見た目の似た刻印は探せば作れる)。規則は公開仕様
 * (docs.clavis.nexus/spec/seal)と同じで、第三者が同じ刻印を描けることを前提にしている。
 *
 * 文法(外 → 内。viewBox は -520 -520 1040 1040、中心 0,0、外周 500)
 *   縁    外周の罫、照合値そのものの微小文字(full のみ)、硬貨の縁の刻み(d20–d21)
 *   網    位相をずらした正弦の束(d0–d7)
 *   地紋  半径をずらした波線を、位相を流しながら重ねる(d8–d14)
 *   花    内・外トロコイド(d15–d19)
 *   見当  中心のトンボ。押す場所
 * 帯の並びは常に同じで、値だけが照合値で変わる。
 *
 * 彫り始めも照合値で決まる。線ごとに照合値の 1 バイトを角度に読み、そこから時計回りに彫る
 * (`sealStart`)。形は変わらず、刻む動きだけが刻印ごと・線ごとに違う。
 *
 * DOM に依存しない。React では `sealGeometry` の `paths` を `<path>` に写す
 * (Trusted Types のため innerHTML は使わない)。書き出しは `sealSvg`。
 */

export const CLAVIS_SEAL_VERSION = "v0" as const;

/** Clavis のマークの元。North Star の約束文(docs/product/clavis-sign-north-star.md §1)。 */
export const CLAVIS_MARK_SOURCE =
  "Clavis は Passkey の署名で、誰が・何に・いつ同意したかを再検証できる記録を、プロダクトに埋め込みます。";

/** `CLAVIS_MARK_SOURCE` の UTF-8 を SHA-256 にかけた値(base64url)。マークはこの刻印。 */
export const CLAVIS_MARK_DIGEST = "R09cAcXyJdHJY7r8H8dGVxAfultyVlwNS_6eq9qsv08";

export type SealLod = "full" | "mid" | "small" | "pip" | "dot";

/** 刻印のインク。色は tokens(clavis.css)の `--wet` / `--ink` / `--rust` が持つ。`current` は置き場の文字色(ボタンの中など)。 */
export type SealInk = "wet" | "fixed" | "rust" | "blank" | "current";

export type SealParams = {
  net: {
    lobes: number;
    strands: number;
    amp: number;
    mod: number;
    modDepth: number;
    twist: number;
    beat: number;
    beatAmp: number;
  };
  field: {
    lines: number;
    lobes: number;
    amp: number;
    drift: number;
    beat: number;
    beatAmp: number;
    twist: number;
  };
  flower: { petals: number; strands: number; ratio: number; twist: number; epi: boolean };
  ticks: { count: number; twist: number };
};

export type SealPathKind = "ring" | "tick" | "line" | "core";

export type SealPath = {
  /** 彫る順(外 → 内)。CSS の `--i` に写す。 */
  index: number;
  /** 彫り始め(0–255。256 で一周)。`d` はこの角度から始まる。 */
  start: number;
  kind: SealPathKind;
  d: string;
  /** 線の太さ(viewBox 単位)。CSS の `--w` に写す。 */
  width: number;
};

export type SealMicrotext = {
  /** 微小文字を載せる円の path(12 時から時計回り)。 */
  ringPath: string;
  text: string;
  textLength: number;
};

export type SealGeometry = {
  version: typeof CLAVIS_SEAL_VERSION;
  lod: SealLod;
  params: SealParams;
  paths: SealPath[];
  microtext: SealMicrotext | null;
};

export const SEAL_VIEWBOX = "-520 -520 1040 1040";

/** 帯の位置(viewBox 単位)。 */
export const SEAL_BANDS = { net: 384, field: [196, 304] as const, flower: 176 };

const TAU = Math.PI * 2;
const pick = <T>(list: readonly T[], byte: number): T => list[byte % list.length]!;
const unit = (byte: number) => byte / 255;
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const fmt = (n: number) => Math.round(n * 10) / 10;

/* ---------- 照合値 ---------- */

const B64U = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";

export function bytesToBase64Url(bytes: Uint8Array): string {
  let out = "";
  let i = 0;
  for (; i + 2 < bytes.length; i += 3) {
    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!;
    out += B64U[(n >> 18) & 63]! + B64U[(n >> 12) & 63]! + B64U[(n >> 6) & 63]! + B64U[n & 63]!;
  }
  const rest = bytes.length - i;
  if (rest === 1) {
    const n = bytes[i]! << 16;
    out += B64U[(n >> 18) & 63]! + B64U[(n >> 12) & 63]!;
  } else if (rest === 2) {
    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8);
    out += B64U[(n >> 18) & 63]! + B64U[(n >> 12) & 63]! + B64U[(n >> 6) & 63]!;
  }
  return out;
}

/**
 * 照合値を 32 バイトにする。base64url(SignProof の `message_digest` と同じ形)か、32 バイト以上の配列。
 * 形が合わなければ null(刻印を描かない)。
 */
export function sealDigestBytes(input: string | Uint8Array | null | undefined): Uint8Array | null {
  if (!input) return null;
  if (input instanceof Uint8Array) return input.length >= 32 ? input.slice(0, 32) : null;
  const raw = input.trim();
  if (!/^[A-Za-z0-9_-]{43,}$/.test(raw)) return null;
  const bytes: number[] = [];
  let acc = 0;
  let bits = 0;
  for (const ch of raw) {
    acc = (acc << 6) | B64U.indexOf(ch);
    bits += 6;
    if (bits >= 8) {
      bits -= 8;
      bytes.push((acc >> bits) & 0xff);
    }
  }
  return bytes.length >= 32 ? Uint8Array.from(bytes.slice(0, 32)) : null;
}

/* ---------- 刻印の値 ---------- */

/** 照合値から刻印の値を決める。帯ごとに別のバイトを読む。 */
export function sealParams(d: Uint8Array): SealParams {
  // 網: 振幅は「傾き」から決め、葉数が多いほど浅くする(網目の角度を保つ)。
  // 振幅が大きい帯ほど本数を減らし、網目の明るさを揃える。
  const nLobes = pick([12, 14, 15, 16, 18, 20, 21, 24], d[0]!);
  const nAmp = clamp(((1 + unit(d[2]!) * 1.2) * SEAL_BANDS.net) / nLobes, 18, 46);
  const net = {
    lobes: nLobes,
    strands: Math.round(clamp(pick([14, 16, 18, 20, 24], d[1]!) * (30 / nAmp), 10, 28)),
    amp: nAmp,
    mod: pick([0, 2, 3, 4, 5, 6, 8, 9], d[3]!),
    modDepth: 0.12 + unit(d[4]!) * 0.3,
    twist: unit(d[5]!) * TAU,
    beat: pick([2, 3, 4, 5], d[6]!),
    beatAmp: unit(d[7]!) * 6,
  };
  // 地紋: 波線の間隔の何倍揺らすかで、線が交わる深さが決まる。
  const fLines = pick([16, 20, 24, 28], d[8]!);
  const field = {
    lines: fLines,
    lobes: pick([3, 4, 5, 6, 7, 8, 9, 10, 12], d[9]!),
    amp: ((SEAL_BANDS.field[1] - SEAL_BANDS.field[0]) / (fLines - 1)) * (0.9 + unit(d[10]!) * 2.4),
    drift: (unit(d[11]!) - 0.5) * 0.7,
    beat: pick([2, 3, 4, 5], d[12]!),
    beatAmp: unit(d[13]!) * 5,
    twist: unit(d[14]!) * TAU,
  };
  const flower = {
    petals: pick([5, 6, 7, 8, 9, 10, 11, 12, 13], d[15]!),
    strands: pick([2, 3, 4, 5, 6], d[16]!),
    ratio: 0.36 + unit(d[17]!) * 0.34,
    twist: unit(d[18]!) * TAU,
    epi: (d[19]! & 1) === 1,
  };
  const ticks = { count: pick([96, 120, 144, 160, 180], d[20]!), twist: unit(d[21]!) * TAU };
  return { net, field, flower, ticks };
}

/* ---------- 曲線 ---------- */

/** 閉じた折れ線。等分点 `⌊start × 点の数 / 256⌋` から書き始める(点の並びを回すだけで、形は同じ)。 */
function polyline(points: number[], start: number): string {
  const n = points.length / 2;
  const k = Math.floor((start * n) / 256);
  const at = (i: number) => {
    const j = ((k + i) % n) * 2;
    return `${fmt(points[j]!)} ${fmt(points[j + 1]!)}`;
  };
  let s = `M${at(0)}`;
  for (let i = 1; i < n; i++) s += ` ${at(i)}`;
  return `${s}Z`;
}

/** 網。r(θ) = rc + A·sin(nθ + φ)·(1 − μ + μ·cos(kθ)) + B·sin((n − m)θ − φ) を、φ をずらして束ねる。 */
function netPaths(rc: number, p: SealParams["net"], strandCount: number, perLobe: number): number[][] {
  const paths: number[][] = [];
  const steps = p.lobes * perLobe;
  for (let j = 0; j < strandCount; j++) {
    const phase = (TAU * j) / strandCount + p.twist;
    const pts = new Array<number>(steps * 2);
    for (let i = 0; i < steps; i++) {
      const t = (TAU * i) / steps;
      const envelope = p.mod ? 1 - p.modDepth + p.modDepth * Math.cos(p.mod * t) : 1;
      let r = rc + p.amp * Math.sin(p.lobes * t + phase) * envelope;
      if (p.beatAmp) r += p.beatAmp * Math.sin((p.lobes - p.beat) * t - phase);
      pts[i * 2] = r * Math.cos(t);
      pts[i * 2 + 1] = r * Math.sin(t);
    }
    paths.push(pts);
  }
  return paths;
}

/** 地紋。半径をずらした波線を、位相を少しずつ流しながら重ねる(紙幣の細線地紋)。 */
function fieldPaths(r0: number, r1: number, p: SealParams["field"], perLobe: number): number[][] {
  const paths: number[][] = [];
  const steps = Math.max(p.lobes, p.beat) * perLobe;
  for (let j = 0; j < p.lines; j++) {
    const base = r0 + ((r1 - r0) * j) / (p.lines - 1);
    const phase = p.twist + j * p.drift;
    const pts = new Array<number>(steps * 2);
    for (let i = 0; i < steps; i++) {
      const t = (TAU * i) / steps;
      const r = base + p.amp * Math.sin(p.lobes * t + phase) + p.beatAmp * Math.sin(p.beat * t - phase * 0.5);
      pts[i * 2] = r * Math.cos(t);
      pts[i * 2 + 1] = r * Math.sin(t);
    }
    paths.push(pts);
  }
  return paths;
}

/** 花。z(t) = a·e^{it} + b·e^{i·k·t}。k = −(P−1) で内トロコイド、P+1 で外トロコイド。 */
function flowerPaths(maxR: number, p: SealParams["flower"], strandCount: number, steps: number): number[][] {
  const paths: number[][] = [];
  const k = p.epi ? p.petals + 1 : -(p.petals - 1);
  const a = maxR / (1 + p.ratio);
  const b = maxR - a;
  for (let j = 0; j < strandCount; j++) {
    const rot = p.twist + (TAU * j) / (strandCount * p.petals);
    const pts = new Array<number>(steps * 2);
    for (let i = 0; i < steps; i++) {
      const t = (TAU * i) / steps;
      const x = a * Math.cos(t) + b * Math.cos(k * t);
      const y = a * Math.sin(t) + b * Math.sin(k * t);
      pts[i * 2] = x * Math.cos(rot) - y * Math.sin(rot);
      pts[i * 2 + 1] = x * Math.sin(rot) + y * Math.cos(rot);
    }
    paths.push(pts);
  }
  return paths;
}

/** 0 から遠い側へ、小数第 1 位に切り上げる。 */
const fmtOut = (n: number) => (n < 0 ? -1 : 1) * (Math.ceil(Math.abs(n) * 10 - 1e-9) / 10);

/**
 * 円。角度 `2π × start / 256` の点から、半円の弧を二つ。
 * 点が円の内側に丸まると弧の中心がずれるので、外側へ丸める(半径は SVG の規則で点に合わせて広がる)。
 */
function circle(r: number, start: number): string {
  const a = (TAU * start) / 256;
  const x = fmtOut(r * Math.cos(a));
  const y = fmtOut(r * Math.sin(a));
  return `M${x} ${y}A${r} ${r} 0 1 1 ${fmt(-x)} ${fmt(-y)}A${r} ${r} 0 1 1 ${x} ${y}Z`;
}

/** 縁の刻み(硬貨のギザ)。`⌊start × count / 256⌋` 本目から書き始める。 */
function reeding(r0: number, r1: number, count: number, twist: number, start: number): string {
  const k = Math.floor((start * count) / 256);
  let s = "";
  for (let j = 0; j < count; j++) {
    const i = (k + j) % count;
    const t = twist + (TAU * i) / count;
    const c = Math.cos(t);
    const n = Math.sin(t);
    s += `M${fmt(r0 * c)} ${fmt(r0 * n)}L${fmt(r1 * c)} ${fmt(r1 * n)}`;
  }
  return s;
}

/** 中心の見当(トンボ)。押す場所であり、版の芯。 */
const register = (r: number, arm: number, start: number) => `${circle(r, start)}M${-arm} 0H${arm}M0 ${-arm}V${arm}`;

/**
 * 線 `index`(彫る順)の彫り始め(0–255)。照合値のバイトをそのまま角度に読む。
 * 32 本を超えたら一周ごとに 97 ずらし、同じバイトを読む線どうしが同じ角度から始まらないようにする。
 */
export function sealStart(digest: Uint8Array, index: number): number {
  return (digest[index % 32]! + 97 * Math.floor(index / 32)) % 256;
}

/* ---------- 段と幾何 ---------- */

/** 表示サイズ(CSS px)から段を選ぶ。小さくなるほど線を間引き、形だけを残す。 */
export function sealLodFor(px: number): SealLod {
  if (px >= 240) return "full";
  if (px >= 120) return "mid";
  if (px >= 48) return "small";
  if (px > 20) return "pip";
  return "dot";
}

const HAIR_PX: Record<SealLod, number> = { full: 0.7, mid: 0.6, small: 0.55, pip: 1.1, dot: 1.5 };
const RULE_PX: Record<SealLod, number> = { full: 1, mid: 0.9, small: 0.9, pip: 1.4, dot: 1.6 };

/**
 * 刻印の幾何。`px` は表示サイズで、線の太さを表示サイズから決める(拡大しても毛線のまま)。
 */
export function sealGeometry(digest: Uint8Array, options: { px: number; lod?: SealLod }): SealGeometry {
  const px = Math.max(1, options.px);
  const lod = options.lod ?? sealLodFor(px);
  const params = sealParams(digest);
  const unitPx = 1040 / px;
  const hair = HAIR_PX[lod] * unitPx;
  const rule = RULE_PX[lod] * unitPx;
  const paths: SealPath[] = [];
  /** 線を一本足す。`draw` は彫り始めを受け取って path を返す。 */
  const add = (draw: (start: number) => string, width: number, kind: SealPathKind) => {
    const index = paths.length;
    const start = sealStart(digest, index);
    paths.push({ index, start, kind, d: draw(start), width: Math.round(width * 100) / 100 });
  };
  const ring = (r: number) => (start: number) => circle(r, start);
  const curve = (pts: number[]) => (start: number) => polyline(pts, start);
  const done = (microtext: SealMicrotext | null = null): SealGeometry => ({
    version: CLAVIS_SEAL_VERSION,
    lod,
    params,
    paths,
    microtext,
  });

  if (lod === "dot") {
    add(ring(500 - rule / 2), rule, "ring");
    add(ring(150), 300, "core");
    return done();
  }
  if (lod === "pip") {
    add(ring(500 - rule / 2), rule, "ring");
    const petals = { ...params.flower, ratio: Math.min(params.flower.ratio, 0.3) };
    for (const pts of flowerPaths(330, petals, 1, petals.petals * 36)) add(curve(pts), hair, "line");
    return done();
  }

  const full = lod === "full";
  const thin = lod === "full" ? 1 : lod === "mid" ? 2 : 4;
  let microtext: SealMicrotext | null = null;
  add(ring(500), rule, "ring");
  if (full) {
    microtext = {
      ringPath: "M0 -481A481 481 0 1 1 -0.01 -481",
      text: `SHA-256 ${bytesToBase64Url(digest)} · `.repeat(7),
      textLength: Math.round(TAU * 481 - 6),
    };
    add(ring(464), hair, "ring");
    add((start) => reeding(444, 458, params.ticks.count, params.ticks.twist, start), hair, "tick");
  } else if (lod === "mid") {
    add(ring(464), hair, "ring");
    add((start) => reeding(440, 458, params.ticks.count / 2, params.ticks.twist, start), hair * 1.2, "tick");
  }

  const per = full ? 28 : lod === "mid" ? 18 : 14;
  for (const pts of netPaths(SEAL_BANDS.net, params.net, Math.max(4, Math.ceil(params.net.strands / thin)), per)) {
    add(curve(pts), hair, "line");
  }
  add(ring(318), hair, "ring");
  const f = { ...params.field, lines: Math.max(5, Math.ceil(params.field.lines / thin)) };
  for (const pts of fieldPaths(SEAL_BANDS.field[0] + f.amp, SEAL_BANDS.field[1] - f.amp, f, per + 8)) {
    add(curve(pts), hair, "line");
  }
  const petals = Math.max(1, Math.ceil(params.flower.strands / thin));
  for (const pts of flowerPaths(SEAL_BANDS.flower, params.flower, petals, params.flower.petals * (full ? 48 : 30))) {
    add(curve(pts), hair, "line");
  }
  add((start) => register(26, 44, start), hair * 1.2, "core");
  return done(microtext);
}

/* ---------- 画面に描く ---------- */

/** 直線をこの区間数より長く続けない(Safari の破線の都合。`sealDrawPath`) */
const LINE_RUN = 128;

/**
 * 画面で刻むための `d`。形は `d` と同じで、直線が続くところへ `LINE_RUN` 区間ごとに一つ、
 * まっすぐな 3 次曲線(制御点を線分の 1/3 と 2/3 に置く)を挟む。
 * Safari は直線だけが 256 区間を超えて続く線で破線を数か所からやり直すので、刻む線が
 * 何方向からも同時に現れる(Safari 27 で確認)。曲線が一つ混ざると、線の頭から刻む。
 * 仕様の `d`(試験値・書き出し)はこれを通さない。
 */
export function sealDrawPath(d: string): string {
  const tokens = d.match(/[MLHVAZ]|-?\d*\.?\d+(?:e-?\d+)?/gi) ?? [];
  let i = 0;
  const num = () => Number(tokens[i++]);
  const isNum = () => i < tokens.length && !/^[A-Z]$/i.test(tokens[i]!);
  let out = "";
  let mode = "";
  let x = 0;
  let y = 0;
  let sx = 0;
  let sy = 0;
  let run = 0;
  const cmd = (c: string) => {
    if (mode !== c) out += c;
    else out += " ";
    mode = c;
  };
  const third = (a: number, b: number, k: number) => Math.round((a + ((b - a) * k) / 3) * 100) / 100;
  const lineTo = (nx: number, ny: number) => {
    run += 1;
    if (run >= LINE_RUN) {
      cmd("C");
      out += `${third(x, nx, 1)} ${third(y, ny, 1)} ${third(x, nx, 2)} ${third(y, ny, 2)} ${nx} ${ny}`;
      run = 0;
    } else {
      cmd("L");
      out += `${nx} ${ny}`;
    }
    x = nx;
    y = ny;
  };
  while (i < tokens.length) {
    const c = tokens[i++]!.toUpperCase();
    if (c === "M") {
      x = sx = num();
      y = sy = num();
      cmd("M");
      out += `${x} ${y}`;
      while (isNum()) lineTo(num(), num());
    } else if (c === "L") {
      while (isNum()) lineTo(num(), num());
    } else if (c === "H") {
      while (isNum()) lineTo(num(), y);
    } else if (c === "V") {
      while (isNum()) lineTo(x, num());
    } else if (c === "A") {
      while (isNum()) {
        const arc = [num(), num(), num(), num(), num(), num(), num()];
        cmd("A");
        out += arc.join(" ");
        x = arc[5]!;
        y = arc[6]!;
        run = 0;
      }
    } else if (c === "Z") {
      cmd("Z");
      x = sx;
      y = sy;
      run += 1;
    }
  }
  return out;
}

/* ---------- 書き出し ---------- */

const escapeXml = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");

/**
 * 単体の SVG 文書(favicon・OG・メール・印刷)。CSS に頼らず属性で塗る。
 * `background` を渡すと、その色の円盤の上に刻む(アイコン用)。
 */
export function sealSvg(
  digest: Uint8Array,
  options: { px: number; lod?: SealLod; color?: string; background?: string; title?: string },
): string {
  const g = sealGeometry(digest, options);
  const color = options.color ?? "#191512";
  const parts: string[] = [];
  if (options.title) parts.push(`<title>${escapeXml(options.title)}</title>`);
  if (options.background) parts.push(`<circle r="520" fill="${options.background}"/>`);
  if (g.microtext) {
    parts.push(
      `<path id="r" d="${g.microtext.ringPath}" fill="none"/>` +
        `<text fill="${color}" font-family="ui-monospace, Menlo, monospace" font-size="11" letter-spacing=".5">` +
        `<textPath href="#r" textLength="${g.microtext.textLength}" lengthAdjust="spacingAndGlyphs">${escapeXml(g.microtext.text)}</textPath></text>`,
    );
  }
  for (const p of g.paths) {
    parts.push(`<path d="${p.d}" fill="none" stroke="${color}" stroke-width="${p.width}" stroke-linecap="round"/>`);
  }
  const size = Math.round(options.px);
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${SEAL_VIEWBOX}" width="${size}" height="${size}">${parts.join("")}</svg>`;
}