Files
persona/scripts/persona-icon.mjs
T
jiantw83andClaude Opus 5 1884a9c3d7 feat: 圖示配色取自人格「最新一次登場」的官方視覺
新增 skill persona-icon:上網查出該人格最新的官方視覺 → 下載 →
**用 Read 親眼看過** → 取髮色/瞳色/服裝色 → 用那組配色繪製圖示,
來源網址與造型描述一併寫進 state/config.json 備查。

CLI:
  icon generate --palette "hair=#..,eye=#..,accent=#..,secondary=#..,light=#.."
                --source-url <網址> [--source-note <說明> --source-date <日期>]
  * --palette 必須配 --source-url(配色要有出處,CLI 強制)。
  * 配色與來源存進 config.icon,之後不帶 --palette 重畫會沿用,不會變回雜湊色。
  * icon show 會列出調色盤與來源網址。

繪製:漸層=髮色→服裝主色、外框=瞳色、點陣紋=亮色、中央仍是編號前兩字。
一深一淺的極端配色(藍黑髮+淡粉洋裝)會把較亮端往較暗端壓到對比 ≥ 3.2,
確保字讀得到——顏色仍是照片來的,只是收斂色階。

為什麼不是把照片本身當圖示:
  1) 環境裡沒有任何影像解碼器(rsvg/imagemagick/Pillow/ffmpeg 都沒有),
     JPEG/WebP 讀不進來,無法轉成 PNG;
  2) 把他人的美術作品原樣放進存取庫是散布,不是引用。
取配色是有依據又不搬運原圖的做法,這個取捨寫進了 README 與 skill。

已套用到兩個真實人格:
  ASUNA-01  取自《Unanswered//butterfly》(2026) 官方主視覺(金栗髮/紅褐瞳/紅衣粉裙)
  YUI-01    取自 AniList 官方角色圖(藍黑髮/暖棕瞳)+ Unital Ring 導航妖精造型

selftest 136 項全綠(新增第 ⑮ 節:調色盤解析、缺來源會被擋、格式錯誤會被擋、
來源寫入 config、重畫沿用配色、極端配色的對比保證)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:17:35 +00:00

520 lines
19 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// persona-icon.mjs — 由人格資料產生圖示(SVG + PNG),零外部依賴
//
// 為什麼是「幾何圖形 + 字母」而不是 emoji:
// 這台機器(以及大部分伺服器)沒有 rsvg/inkscape/imagemagick,也沒有 emoji 字型,
// 而本專案的規則是只用 Node 內建模組。把 emoji 畫進 PNG 需要字型柵格化,做不到。
// 所以圖案只用「我能在 SVG 與自寫柵格器裡畫出完全相同結果」的元素:
// 圓角矩形、線性漸層、圓點、以及 5×7 點陣字母。**兩種格式輸出的是同一張圖。**
// emoji 仍然參與雜湊,所以它會影響配色。
//
// 圖案(512×512 圓角方形徽章):
// 底:由人格編號雜湊出的雙色對角漸層
// 紋:5×5 左右對稱的圓點(identicon 式,每個人格都不一樣)
// 字:編號英文名的前兩個字母(ASUNA-01 → AS),5×7 點陣,自動選黑或白以確保對比
//
// 同一個人格永遠得到同一張圖(純函數 of 編號/名字/emoji)。
import fs from "node:fs";
import path from "node:path";
import zlib from "node:zlib";
import crypto from "node:crypto";
import * as pl from "./persona-lib.mjs";
export const ICON_SVG = "icon.svg";
export const ICON_PNG = "icon.png";
export const DEFAULT_SIZE = 512;
export const iconSvgPath = (slug) => path.join(pl.personaDir(slug), ICON_SVG);
export const iconPngPath = (slug) => path.join(pl.personaDir(slug), ICON_PNG);
// --------------------------------------------------------------------------- //
// 5×7 點陣字(SVG 與 PNG 共用同一份資料,兩邊才會長得一模一樣)
// --------------------------------------------------------------------------- //
const FONT = {
A: "01110,10001,10001,11111,10001,10001,10001",
B: "11110,10001,10001,11110,10001,10001,11110",
C: "01111,10000,10000,10000,10000,10000,01111",
D: "11110,10001,10001,10001,10001,10001,11110",
E: "11111,10000,10000,11110,10000,10000,11111",
F: "11111,10000,10000,11110,10000,10000,10000",
G: "01110,10001,10000,10111,10001,10001,01111",
H: "10001,10001,10001,11111,10001,10001,10001",
I: "11111,00100,00100,00100,00100,00100,11111",
J: "00111,00010,00010,00010,00010,10010,01100",
K: "10001,10010,10100,11000,10100,10010,10001",
L: "10000,10000,10000,10000,10000,10000,11111",
M: "10001,11011,10101,10101,10001,10001,10001",
N: "10001,11001,10101,10011,10001,10001,10001",
O: "01110,10001,10001,10001,10001,10001,01110",
P: "11110,10001,10001,11110,10000,10000,10000",
Q: "01110,10001,10001,10001,10101,10010,01101",
R: "11110,10001,10001,11110,10100,10010,10001",
S: "01111,10000,10000,01110,00001,00001,11110",
T: "11111,00100,00100,00100,00100,00100,00100",
U: "10001,10001,10001,10001,10001,10001,01110",
V: "10001,10001,10001,10001,10001,01010,00100",
W: "10001,10001,10001,10101,10101,11011,10001",
X: "10001,10001,01010,00100,01010,10001,10001",
Y: "10001,10001,01010,00100,00100,00100,00100",
Z: "11111,00001,00010,00100,01000,10000,11111",
0: "01110,10001,10011,10101,11001,10001,01110",
1: "00100,01100,00100,00100,00100,00100,01110",
2: "01110,10001,00001,00010,00100,01000,11111",
3: "11111,00010,00100,00010,00001,10001,01110",
4: "00010,00110,01010,10010,11111,00010,00010",
5: "11111,10000,11110,00001,00001,10001,01110",
6: "00110,01000,10000,11110,10001,10001,01110",
7: "11111,00001,00010,00100,01000,01000,01000",
8: "01110,10001,10001,01110,10001,10001,01110",
9: "01110,10001,10001,01111,00001,00010,01100",
};
const glyph = (ch) => (FONT[ch] || FONT.O).split(",").map((row) => row.split("").map(Number));
// --------------------------------------------------------------------------- //
// 顏色
// --------------------------------------------------------------------------- //
/** h 0360, s 01, l 01 → [r,g,b] 0255 */
export function hslToRgb(h, s, l) {
const c = (1 - Math.abs(2 * l - 1)) * s;
const hp = (((h % 360) + 360) % 360) / 60;
const x = c * (1 - Math.abs((hp % 2) - 1));
const [r1, g1, b1] =
hp < 1 ? [c, x, 0] : hp < 2 ? [x, c, 0] : hp < 3 ? [0, c, x]
: hp < 4 ? [0, x, c] : hp < 5 ? [x, 0, c] : [c, 0, x];
const m = l - c / 2;
return [r1 + m, g1 + m, b1 + m].map((v) => Math.round(Math.max(0, Math.min(1, v)) * 255));
}
const hex = ([r, g, b]) => `#${[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
/** 相對亮度(sRGB → 線性),用來決定字要黑還是白。 */
function luminance([r, g, b]) {
const lin = [r, g, b].map((v) => {
const c = v / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
}
// --------------------------------------------------------------------------- //
// 圖案規格:純函數 of 人格資料 → 同一個人格永遠同一張圖
// --------------------------------------------------------------------------- //
const WHITE = [255, 255, 255];
const BLACK = [16, 18, 24];
/** WCAG 對比度(121)。 */
function contrast(a, b) {
const la = luminance(a);
const lb = luminance(b);
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}
const mix = (a, b, t) => a.map((v, i) => Math.round(v + (b[i] - v) * t));
/** 字要黑還是白:取「對兩端漸層色的最差對比」最好的那個。 */
function pickInk(c1, c2) {
const worst = (ink) => Math.min(contrast(ink, c1), contrast(ink, c2));
return worst(WHITE) >= worst(BLACK) ? WHITE : BLACK;
}
/**
* 照片取來的兩個顏色可能一深一淺(例:藍黑髮 + 淡粉洋裝),
* 那樣不論字用黑或白,都會有一端糊掉。這裡把較亮的一端往較暗的一端壓,
* 直到最差對比達標——顏色仍然是照片來的,只是收斂色階。
*/
function harmonize(c1, c2, target = 3.2) {
let a = c1;
let b = c2;
for (let step = 0; step < 6; step += 1) {
const ink = pickInk(a, b);
if (Math.min(contrast(ink, a), contrast(ink, b)) >= target) break;
if (luminance(a) > luminance(b)) a = mix(a, b, 0.18);
else b = mix(b, a, 0.18);
}
return [a, b];
}
// --------------------------------------------------------------------------- //
// 取自照片的配色
// --------------------------------------------------------------------------- //
//
// 「依照片產生圖示」在這裡的做法:**由 AI 上網找到該人格最新的官方視覺、看過那張圖、
// 萃取出五個代表色**,再交給這支程式繪製。不是把原圖裁進圖示裡——
// 1) 沒有任何影像解碼器可用(見檔頭),JPEG/WebP 根本讀不進來;
// 2) 把他人的美術作品原樣放進存取庫是散布,不是引用。
// 取配色是有依據又不搬運原圖的做法,來源網址與描述會一起記進 config.json 備查。
export const PALETTE_KEYS = ["hair", "eye", "accent", "secondary", "light"];
export function parseHexColor(value) {
const m = String(value ?? "").trim().match(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i);
if (!m) return null;
const hexStr = m[1].length === 3 ? m[1].split("").map((c) => c + c).join("") : m[1];
return [0, 2, 4].map((i) => parseInt(hexStr.slice(i, i + 2), 16));
}
/** `hair=#d9a45b,eye=#9e5b3e,...` → { hair:[r,g,b], ... };缺 hair/accent 視為無效。 */
export function parsePalette(raw) {
if (!raw) return null;
const out = {};
for (const chunk of String(raw).split(",")) {
const idx = chunk.indexOf("=");
if (idx < 0) continue;
const key = chunk.slice(0, idx).trim().toLowerCase();
const rgb = parseHexColor(chunk.slice(idx + 1));
if (PALETTE_KEYS.includes(key) && rgb) out[key] = rgb;
}
if (!out.hair || !out.accent) return null;
out.eye ??= out.accent;
out.secondary ??= out.accent;
out.light ??= [246, 240, 236];
return out;
}
export const paletteToString = (p) =>
PALETTE_KEYS.filter((k) => p?.[k]).map((k) => `${k}=${hex(p[k])}`).join(",");
export function iconSpec(slug, { code = null, identity = null, palette = null } = {}) {
const ident = identity || pl.identityFields(slug);
const config = pl.loadConfig(slug);
const theCode = code || config.code || slug;
const seedText = `${theCode}|${ident.Name || slug}|${ident.Emoji || ""}`;
const h = crypto.createHash("sha256").update(seedText).digest();
// 5×5 左右對稱的點陣(只決定左邊三行,鏡射過去)——不論哪種配色都保留這個專屬紋路
const pattern = [];
for (let y = 0; y < 5; y += 1) {
const row = [];
for (let x = 0; x < 3; x += 1) row.push((h[8 + y * 3 + x] & 1) === 1);
pattern.push([...row, row[1], row[0]]);
}
const letters = String(theCode).replace(/[^A-Za-z0-9]/g, "").toUpperCase().slice(0, 2) || "P";
const pal = palette || (config.icon?.palette ? parsePalette(config.icon.palette) : null);
let c1;
let c2;
let ring;
let dot;
let ringAlpha;
let dotAlpha;
if (pal) {
// 照片配色:髮色 → 主服裝色的漸層,瞳色當外框,亮色當紋路
c1 = pal.hair;
c2 = pal.accent;
ring = pal.eye;
dot = pal.light;
ringAlpha = 0.55;
dotAlpha = 0.2;
} else {
// 雜湊配色:沒有參考照片時的預設
const hue = (h[0] * 360) / 256;
const hue2 = (hue + 40 + (h[1] % 80)) % 360;
const sat = 0.52 + (h[2] % 30) / 100; // 0.520.81
const light = 0.36 + (h[3] % 18) / 100; // 0.360.53
c1 = hslToRgb(hue, sat, light);
c2 = hslToRgb(hue2, sat * 0.9, Math.min(0.72, light + 0.18));
ring = null;
dot = null;
ringAlpha = 0.18;
dotAlpha = 0.14;
}
[c1, c2] = harmonize(c1, c2);
const ink = pickInk(c1, c2);
return {
persona: slug,
code: theCode,
name: ident.Name || slug,
emoji: ident.Emoji || "",
letters,
c1,
c2,
ink,
ring: ring || ink,
dot: dot || ink,
ringAlpha,
dotAlpha,
palette: pal,
source: pal ? config.icon?.source || null : null,
pattern,
seed: h.subarray(0, 8).toString("hex"),
};
}
// --------------------------------------------------------------------------- //
// 幾何:SVG 與柵格器共用同一組座標(單位為 0–1,最後乘上 size)
// --------------------------------------------------------------------------- //
const GEO = {
radius: 0.22, // 圓角半徑
patternInset: 0.12,
patternCell: 0.152,
dotRadius: 0.038,
glyphCell: 1 / 16,
};
/** 回傳圖案中所有圓點(單位座標)。 */
function dots(spec) {
const out = [];
for (let y = 0; y < 5; y += 1) {
for (let x = 0; x < 5; x += 1) {
if (!spec.pattern[y][x]) continue;
out.push({
cx: GEO.patternInset + GEO.patternCell * (x + 0.5),
cy: GEO.patternInset + GEO.patternCell * (y + 0.5),
r: GEO.dotRadius,
});
}
}
return out;
}
/** 回傳字母的所有方塊(單位座標)。 */
function glyphRects(spec) {
const cell = GEO.glyphCell;
const chars = [...spec.letters];
const width = chars.length * 5 * cell + (chars.length - 1) * cell;
const left = 0.5 - width / 2;
const top = 0.5 - (7 * cell) / 2;
const out = [];
chars.forEach((ch, i) => {
const g = glyph(ch);
const ox = left + i * 6 * cell;
for (let y = 0; y < 7; y += 1) {
for (let x = 0; x < 5; x += 1) {
if (g[y][x]) out.push({ x: ox + x * cell, y: top + y * cell, w: cell, h: cell });
}
}
});
return out;
}
// --------------------------------------------------------------------------- //
// SVG
// --------------------------------------------------------------------------- //
export function renderSvg(spec, size = DEFAULT_SIZE) {
const S = size;
const u = (v) => Math.round(v * S * 1000) / 1000;
const lines = [
`<svg xmlns="http://www.w3.org/2000/svg" width="${S}" height="${S}" viewBox="0 0 ${S} ${S}" role="img"`,
` aria-label="人格 ${spec.code}${spec.name ? `${spec.name}` : ""}">`,
` <title>${spec.code}${spec.name ? ` ${spec.name}` : ""}</title>`,
" <defs>",
` <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">`,
` <stop offset="0" stop-color="${hex(spec.c1)}"/>`,
` <stop offset="1" stop-color="${hex(spec.c2)}"/>`,
" </linearGradient>",
" </defs>",
` <rect width="${S}" height="${S}" rx="${u(GEO.radius)}" ry="${u(GEO.radius)}" fill="url(#bg)"/>`,
` <g fill="${hex(spec.dot)}" opacity="${spec.dotAlpha}">`,
...dots(spec).map((d) => ` <circle cx="${u(d.cx)}" cy="${u(d.cy)}" r="${u(d.r)}"/>`),
" </g>",
` <g fill="${hex(spec.ink)}">`,
...glyphRects(spec).map((r) => ` <rect x="${u(r.x)}" y="${u(r.y)}" width="${u(r.w)}" height="${u(r.h)}"/>`),
" </g>",
` <rect x="${u(0.012)}" y="${u(0.012)}" width="${u(0.976)}" height="${u(0.976)}"`,
` rx="${u(GEO.radius - 0.012)}" ry="${u(GEO.radius - 0.012)}"`,
` fill="none" stroke="${hex(spec.ring)}" stroke-opacity="${spec.ringAlpha}" stroke-width="${u(0.012)}"/>`,
"</svg>",
"",
];
return lines.join("\n");
}
// --------------------------------------------------------------------------- //
// PNG:自己柵格化 + 自己編碼(zlib 是內建的,不需要任何影像函式庫)
// --------------------------------------------------------------------------- //
const CRC_TABLE = (() => {
const table = new Int32Array(256);
for (let n = 0; n < 256; n += 1) {
let c = n;
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c;
}
return table;
})();
function crc32(buf) {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i += 1) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function pngChunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body), 0);
return Buffer.concat([len, body, crc]);
}
/** RGBA buffer → PNG8-bit RGBA、無交錯、filter 0)。 */
export function encodePng(rgba, width, height) {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // color type: RGBA
ihdr[10] = 0; // compression
ihdr[11] = 0; // filter
ihdr[12] = 0; // interlace
const stride = width * 4;
const raw = Buffer.alloc((stride + 1) * height);
for (let y = 0; y < height; y += 1) {
raw[y * (stride + 1)] = 0; // filter type: None
rgba.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
}
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
pngChunk("IHDR", ihdr),
pngChunk("IDAT", zlib.deflateSync(raw, { level: 9 })),
pngChunk("IEND", Buffer.alloc(0)),
]);
}
/** 內縮 inset 之後的圓角矩形內外判定(用來畫出與 SVG 描邊相同的環帶)。 */
function insideInset(x, y, inset) {
const span = 1 - 2 * inset;
return insideRounded((x - inset) / span, (y - inset) / span, (GEO.radius - inset) / span);
}
/** 圓角矩形的內外判定(單位座標)。 */
function insideRounded(x, y, r) {
if (x < 0 || y < 0 || x > 1 || y > 1) return false;
const cx = Math.min(Math.max(x, r), 1 - r);
const cy = Math.min(Math.max(y, r), 1 - r);
const dx = x - cx;
const dy = y - cy;
return dx * dx + dy * dy <= r * r;
}
/**
* 畫出與 SVG 完全相同的圖案。
* 用 3× 超取樣再做盒式縮減當作反鋸齒——沒有第三方繪圖庫,這是最省事又夠好的做法。
*/
export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) {
const SS = Math.max(1, Math.min(4, supersample));
const big = size * SS;
const acc = Buffer.alloc(big * big * 4);
const circles = dots(spec);
const rects = glyphRects(spec);
const [i1, i2, i3] = spec.ink;
const [d1, d2, d3] = spec.dot;
const [g1, g2, g3] = spec.ring;
for (let py = 0; py < big; py += 1) {
const v = (py + 0.5) / big;
for (let px = 0; px < big; px += 1) {
const u = (px + 0.5) / big;
const o = (py * big + px) * 4;
if (!insideRounded(u, v, GEO.radius)) continue; // 圓角外=透明
// 底:對角線性漸層(與 SVG 的 x1,y1=0,0 → x2,y2=1,1 相同)
const t = Math.min(1, Math.max(0, (u + v) / 2));
let r = spec.c1[0] + (spec.c2[0] - spec.c1[0]) * t;
let g = spec.c1[1] + (spec.c2[1] - spec.c1[1]) * t;
let b = spec.c1[2] + (spec.c2[2] - spec.c1[2]) * t;
// 紋:圓點
for (const d of circles) {
const dx = u - d.cx;
const dy = v - d.cy;
if (dx * dx + dy * dy <= d.r * d.r) {
r += (d1 - r) * spec.dotAlpha;
g += (d2 - g) * spec.dotAlpha;
b += (d3 - b) * spec.dotAlpha;
break;
}
}
// 邊框:與 SVG 的 stroke 完全相同的環帶
//SVG 的描邊以 0.012 為中心、寬 0.012 → 涵蓋 0.0060.018
if (insideInset(u, v, 0.006) && !insideInset(u, v, 0.018)) {
r += (g1 - r) * spec.ringAlpha;
g += (g2 - g) * spec.ringAlpha;
b += (g3 - b) * spec.ringAlpha;
}
// 字:實心方塊
for (const rc of rects) {
if (u >= rc.x && u < rc.x + rc.w && v >= rc.y && v < rc.y + rc.h) {
r = i1;
g = i2;
b = i3;
break;
}
}
acc[o] = Math.round(r);
acc[o + 1] = Math.round(g);
acc[o + 2] = Math.round(b);
acc[o + 3] = 255;
}
}
if (SS === 1) return encodePng(acc, size, size);
// 盒式縮減(連 alpha 一起平均,圓角邊緣才會平滑)
const out = Buffer.alloc(size * size * 4);
const n = SS * SS;
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
let r = 0;
let g = 0;
let b = 0;
let a = 0;
for (let sy = 0; sy < SS; sy += 1) {
const row = (y * SS + sy) * big;
for (let sx = 0; sx < SS; sx += 1) {
const o = (row + x * SS + sx) * 4;
const av = acc[o + 3];
r += acc[o] * av;
g += acc[o + 1] * av;
b += acc[o + 2] * av;
a += av;
}
}
const o = (y * size + x) * 4;
out[o] = a ? Math.round(r / a) : 0;
out[o + 1] = a ? Math.round(g / a) : 0;
out[o + 2] = a ? Math.round(b / a) : 0;
out[o + 3] = Math.round(a / n);
}
}
return encodePng(out, size, size);
}
// --------------------------------------------------------------------------- //
// 產生並寫檔
// --------------------------------------------------------------------------- //
export function generateIcon(slug, { size = DEFAULT_SIZE, code = null, palette = null, source = null } = {}) {
const spec = iconSpec(slug, { code, palette });
const svg = renderSvg(spec, size);
const png = renderPng(spec, size);
pl.writeText(iconSvgPath(slug), svg);
fs.mkdirSync(path.dirname(iconPngPath(slug)), { recursive: true });
fs.writeFileSync(iconPngPath(slug), png);
// 把配色與來源記進 config,之後重畫才會一致,也才查得到「這個顏色是哪來的」
const config = pl.loadConfig(slug);
config.icon = {
...(config.icon || {}),
letters: spec.letters,
size,
generated_at: pl.nowIso(),
palette: spec.palette ? paletteToString(spec.palette) : null,
source: source || spec.source || null,
};
pl.writeJson(pl.configPath(slug), config);
return {
spec,
svg: iconSvgPath(slug),
png: iconPngPath(slug),
size,
bytes: { svg: Buffer.byteLength(svg, "utf8"), png: png.length },
};
}
export const hasIcon = (slug) => fs.existsSync(iconSvgPath(slug)) && fs.existsSync(iconPngPath(slug));