刻印の規則(v0)
ハッシュから刻印を描く規則です。同じ規則で、誰でも同じ刻印を描けます。
入力
32 バイトのハッシュを入力にします。署名では、Intent の message_digest(base64url)を戻した 32 バイトです。バイトを先頭から d0, d1, … と呼びます。
message_digest (base64url)
TxO-68UEwD6K9RXDXelOszsebQO044wzHSUfwyTSQQY
bytes (hex)
4f13beebc504c03e8af515c35de94eb33b1e6d03b4e38c331d251fc324d24106
d0 = 0x4f, d1 = 0x13, d2 = 0xbe, … d31 = 0x06刻印は、人が違いに気づくための補助です。見た目の似た刻印は探せば作れるので、署名が正しいかどうかは署名の検証で判定してください。
帯の並び
viewBox は -520 -520 1040 1040、中心は (0, 0) です。外から内へ、いつも同じ並びで描きます。
縁r 500・481・464・444–458外周の罫、ハッシュの微小文字(full のみ)、内側の罫、刻みです。網中心 r 384位相をずらした正弦の束です。罫r 318網と地紋を分けます。地紋r 196–304半径をずらした波線を重ねます。花最大 r 176内トロコイド、または外トロコイドです。見当r 26・±44中心の円と十字です。
値の決め方
バイトから値
pick(list, b) = list[b mod 長さ] u(b) = b / 255 clamp(v, lo, hi)
網(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
地紋(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π
花(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 が奇数
刻み(d20–d21)
count = pick([96, 120, 144, 160, 180], d20)
twist = u(d21) × 2π曲線
曲線
網 j = 0 … strands − 1、θ は 2π を lobes × 28 等分
φ = 2π j / strands + twist
E(θ) = 1 − modDepth + modDepth × cos(mod θ) mod = 0 なら 1
r(θ) = 384 + amp × sin(lobes θ + φ) × E(θ) + beatAmp × sin((lobes − beat) θ − φ)
地紋 j = 0 … lines − 1、θ は 2π を max(lobes, beat) × 36 等分
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)
花 j = 0 … strands − 1、t は 2π を petals × 48 等分
a = 176 / (1 + ratio)、b = 176 − a
k = petals + 1(epi)、または −(petals − 1)
z(t) = a e^{it} + b e^{ikt} を、twist + 2π j / (strands × petals) だけ回す
刻み 半径 444 から 458 への線分を count 本。角度は twist から等間隔
見当 半径 26 の円と、−44 から 44 の縦横の線
点は (r cos θ, r sin θ)。座標は小数第 1 位で丸め、各線を閉じた折れ線にします。彫り始め
線ごとにハッシュの 1 バイトを角度に読み、そこから彫り始めます(形は変わりません)。
彫り始め
i = 0, 1, … は彫る順(外 → 内)。縁の罫・刻み・網・罫・地紋・花・見当の順に数えます
s(i) = (d[i mod 32] + 97 × ⌊i / 32⌋) mod 256
折れ線 等分点のうち ⌊s × 点の数 / 256⌋ 番目から書き始める
円 角度 2π s / 256 の点 P から、P → −P → P の半円の弧を二つ
P の座標は 0 から遠い側へ小数第 1 位に切り上げる(P が円の内側に入ると弧の中心がずれるため)
刻み ⌊s × count / 256⌋ 本目から書き始める
見当 円は上と同じ。十字は変えない
どの線も θ(花は t)の増える向き、画面では時計回りに進みます。表示サイズと段
表示サイズ(CSS px)で段を選び、小さいほど線を間引きます。線の太さは viewBox 単位で「px × 1040 ÷ 表示サイズ」です。
full240px 以上すべての帯と微小文字を描きます。細線 0.7px、罫 1px です。mid120–239px微小文字を外し、刻みを半分、網・地紋・花の本数を半分にします。細線 0.6px です。small48–119px刻みを外し、本数を 4 分の 1 にします(網は 4 本以上、地紋は 5 本以上)。pip21–47px外周の罫と、比を 0.3 までにした花を 1 本だけ描きます。dot20px 以下外周の罫と、中心の円だけです。
インクの状態
- 未定着 — 署名を待っているもの。書いた直後のインクの青で描きます。
- 定着 — 署名が済んだもの。黒で描き、線を 1.12 倍にします。
- 腐食 — 内容が合わないもの。錆の色で描き、罫以外の線を途切れさせます(pathLength 1 に対して 0.011 と 0.007 の破線)。
試験値
実装を確かめるための値です。小数は 4 桁で丸めています。一つめは Clavis のマークの元になる約束文です。
試験値(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" }
}
]描いて確かめる
net lobes 24 · strands 28 · mod 2
field lines 20 · lobes 3
flower petals 11 · strands 3 · epi
ticks 180参照実装
この規則の TypeScript 実装です。DOM に依存せず、SVG の文字列か、線ごとの path を返します。
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, "&").replace(/</g, "<").replace(/>/g, ">");
/**
* 単体の 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>`;
}