// 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 0–360, s 0–1, l 0–1 → [r,g,b] 0–255 */ 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 對比度(1–21)。 */ 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.52–0.81 const light = 0.36 + (h[3] % 18) / 100; // 0.36–0.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 = [ ``, ` ${spec.code}${spec.name ? ` ${spec.name}` : ""}`, " ", ` `, ` `, ` `, " ", " ", ` `, ` `, ...dots(spec).map((d) => ` `), " ", ` `, ...glyphRects(spec).map((r) => ` `), " ", ` `, "", "", ]; 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 → PNG(8-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.006~0.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));