// 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 os from "node:os"; import path from "node:path"; import zlib from "node:zlib"; import crypto from "node:crypto"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; 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", "skin"]; 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]; out.skin ??= mix(out.light, [242, 201, 168], 0.65); // 沒指定就用偏暖的膚色 return out; } export const paletteToString = (p) => PALETTE_KEYS.filter((k) => p?.[k]).map((k) => `${k}=${hex(p[k])}`).join(","); export const STYLES = ["portrait", "badge"]; export function iconSpec(slug, { code = null, identity = null, palette = null, style = null, features = 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, // 有調色盤(=看過大頭照)就重繪人物形象;沒有的話只能畫徽章 // 只認得 STYLES 裡的樣式:config 可能殘留舊版本寫進去的值(例如已移除的 photo) style: [style, config.icon?.style].find((v) => STYLES.includes(v)) || (pal ? "portrait" : "badge"), palette: pal, features: features || parseFeatures(config.icon?.features || null), 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; } // --------------------------------------------------------------------------- // // 圖形清單:SVG 與柵格器都從這份清單畫,兩邊不可能長得不一樣 // --------------------------------------------------------------------------- // const shade = (c, t) => mix(c, [0, 0, 0], t); const tint = (c, t) => mix(c, [255, 255, 255], t); const rect = (x, y, w, h, fill, alpha = 1) => ({ type: "rect", x, y, w, h, fill, alpha }); const ellipse = (cx, cy, rx, ry, fill, alpha = 1) => ({ type: "ellipse", cx, cy, rx, ry, fill, alpha }); const circle = (cx, cy, r, fill, alpha = 1) => ellipse(cx, cy, r, r, fill, alpha); /** 多邊形:畫髮尾、緞帶、V 領、呆毛這些橢圓做不出來的形狀。 */ const poly = (points, fill, alpha = 1) => ({ type: "poly", points, fill, alpha }); // --------------------------------------------------------------------------- // // 五官與造型的特徵:這些決定「重新繪製」出來的人長什麼樣 // --------------------------------------------------------------------------- // export const FEATURE_SPEC = { hairstyle: ["straight", "twintails", "ponytail", "bob", "braid"], length: ["short", "medium", "long", "very-long"], fringe: ["blunt", "parted", "swept", "curtain"], eyes: ["round", "almond", "sharp", "droopy"], expression: ["gentle", "bright", "calm", "neutral"], accessory: ["none", "ribbon", "clip", "flower", "hairband"], side: ["left", "right"], collar: ["round", "v", "high", "sailor"], ahoge: ["no", "yes"], }; export const DEFAULT_FEATURES = { hairstyle: "straight", length: "long", fringe: "parted", eyes: "almond", expression: "gentle", accessory: "none", side: "right", collar: "round", ahoge: "no", }; /** `hairstyle=twintails,fringe=blunt,...` → 正規化過的特徵物件(不認得的值一律回退預設)。 */ export function parseFeatures(raw) { const out = { ...DEFAULT_FEATURES }; if (!raw) return 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 value = chunk.slice(idx + 1).trim().toLowerCase(); if (FEATURE_SPEC[key] && FEATURE_SPEC[key].includes(value)) out[key] = value; } return out; } export const featuresToString = (f) => Object.keys(DEFAULT_FEATURES).map((k) => `${k}=${f[k]}`).join(","); /** * 人物形象(有臉)。全部用橢圓與矩形拼出來,所以 SVG 與自寫柵格器畫得出一模一樣的結果。 * 顏色一律來自參考照片萃取的調色盤:髮色、瞳色、服裝色、膚色。 */ function portraitShapes(spec) { const p = spec.palette || {}; const hair = p.hair || spec.c1; const eye = p.eye || spec.ring; const cloth = p.accent || spec.c2; const cloth2 = p.secondary || cloth; const skin = p.skin || [244, 214, 187]; const hairDark = shade(hair, 0.32); const skinShade = shade(skin, 0.12); const mouth = shade(cloth, 0.25); const f = spec.features || DEFAULT_FEATURES; const out = []; const mirror = f.side === "left" ? -1 : 1; const sideX = (base) => 0.5 + (base - 0.5) * mirror; // ── 頭髮長度:側髮鋪到哪裡 const bottom = { short: 0.66, medium: 0.78, long: 0.90, "very-long": 1.02 }[f.length] || 0.90; const sideCy = (0.46 + bottom) / 2; const sideRy = (bottom - 0.46) / 2; // ── 肩膀與衣服 out.push(ellipse(0.5, 1.18, 0.47, 0.36, cloth)); if (f.collar === "round") out.push(ellipse(0.5, 1.14, 0.175, 0.22, cloth2)); else if (f.collar === "high") out.push(rect(0.30, 0.90, 0.40, 0.10, cloth2)); else if (f.collar === "sailor") { out.push(poly([[0.30, 0.92], [0.50, 1.06], [0.70, 0.92], [0.70, 1.10], [0.30, 1.10]], cloth2)); } else if (f.collar === "v") { out.push(poly([[0.428, 0.900], [0.572, 0.900], [0.500, 1.030]], skinShade)); } // 脖子 out.push(rect(0.442, 0.64, 0.116, 0.15, skinShade)); // ── 後髮 out.push(ellipse(0.5, 0.50, 0.315, 0.395, hairDark)); if (f.hairstyle === "twintails") { out.push(ellipse(sideX(0.185), sideCy + 0.03, 0.085, sideRy, hairDark)); out.push(ellipse(sideX(0.815), sideCy + 0.03, 0.085, sideRy, hairDark)); out.push(ellipse(sideX(0.255), 0.415, 0.040, 0.030, cloth2)); // 髮束 out.push(ellipse(sideX(0.745), 0.415, 0.040, 0.030, cloth2)); } else if (f.hairstyle === "ponytail") { out.push(ellipse(sideX(0.815), sideCy, 0.075, sideRy * 1.05, hairDark)); out.push(ellipse(sideX(0.735), 0.40, 0.042, 0.032, cloth2)); out.push(ellipse(0.245, 0.62, 0.062, 0.14, hairDark)); } else if (f.hairstyle === "bob") { out.push(ellipse(0.215, 0.60, 0.090, 0.175, hairDark)); out.push(ellipse(0.785, 0.60, 0.090, 0.175, hairDark)); } else if (f.hairstyle === "braid") { out.push(ellipse(0.235, sideCy, 0.072, sideRy, hairDark)); out.push(ellipse(0.765, sideCy, 0.072, sideRy, hairDark)); for (let i = 0; i < 3; i += 1) { out.push(ellipse(sideX(0.775), 0.60 + i * 0.11, 0.055, 0.048, shade(hair, 0.18))); } } else { out.push(ellipse(0.235, sideCy, 0.078, sideRy, hairDark)); out.push(ellipse(0.765, sideCy, 0.078, sideRy, hairDark)); } // 呆毛 if (f.ahoge === "yes") out.push(poly([[0.482, 0.180], [0.548, 0.070], [0.540, 0.190]], hair)); // ── 臉 out.push(ellipse(0.5, 0.500, 0.226, 0.256, skin)); // ── 瀏海 if (f.fringe === "blunt") { out.push(ellipse(0.5, 0.300, 0.248, 0.150, hair)); out.push(rect(0.252, 0.300, 0.496, 0.088, hair)); } else if (f.fringe === "swept") { out.push(ellipse(sideX(0.560), 0.320, 0.235, 0.160, hair)); out.push(poly([[sideX(0.30), 0.30], [sideX(0.78), 0.30], [sideX(0.30), 0.47]], hair)); } else if (f.fringe === "curtain") { out.push(ellipse(0.5, 0.290, 0.235, 0.140, hair)); out.push(ellipse(0.360, 0.400, 0.090, 0.155, hair)); out.push(ellipse(0.640, 0.400, 0.090, 0.155, hair)); } else { out.push(ellipse(0.5, 0.320, 0.244, 0.145, hair)); out.push(ellipse(0.318, 0.410, 0.068, 0.140, hair)); out.push(ellipse(0.682, 0.410, 0.068, 0.140, hair)); } // 鬢髮(貼著臉頰的兩束) out.push(ellipse(0.268, 0.545, 0.038, 0.150, hair)); out.push(ellipse(0.732, 0.545, 0.038, 0.150, hair)); // ── 眉毛(表情會影響傾斜) const browY = f.expression === "bright" ? 0.430 : 0.436; const brow = mix(hairDark, skin, 0.28); // 深髮角色的眉毛要和瀏海分得開 const browTilt = { gentle: 0.006, bright: 0.010, calm: 0, neutral: 0 }[f.expression] ?? 0; out.push(poly([[0.372, browY + browTilt], [0.462, browY], [0.462, browY + 0.021], [0.372, browY + browTilt + 0.021]], brow)); out.push(poly([[0.538, browY], [0.628, browY + browTilt], [0.628, browY + browTilt + 0.021], [0.538, browY + 0.021]], brow)); // ── 眼睛 const eyeGeo = { round: { rx: 0.058, ry: 0.070 }, almond: { rx: 0.063, ry: 0.060 }, sharp: { rx: 0.066, ry: 0.049 }, droopy: { rx: 0.060, ry: 0.065 }, }[f.eyes] || { rx: 0.063, ry: 0.064 }; const eyeY = f.eyes === "droopy" ? 0.558 : 0.552; for (const [cx, hl] of [[0.415, 0.399], [0.585, 0.569]]) { out.push(ellipse(cx, eyeY, eyeGeo.rx, eyeGeo.ry, [252, 252, 255])); out.push(ellipse(cx, eyeY + 0.005, eyeGeo.rx * 0.76, eyeGeo.ry * 0.80, eye)); out.push(ellipse(cx, eyeY + 0.011, eyeGeo.rx * 0.34, eyeGeo.ry * 0.42, shade(eye, 0.7))); out.push(circle(hl, eyeY - 0.018, 0.015, [255, 255, 255])); // 上眼線:眼型的關鍵 out.push(rect(cx - eyeGeo.rx, eyeY - eyeGeo.ry, eyeGeo.rx * 2, 0.014, hairDark)); if (f.eyes === "sharp") { out.push(poly([[cx + eyeGeo.rx * 0.6, eyeY - eyeGeo.ry], [cx + eyeGeo.rx * 1.45, eyeY - eyeGeo.ry - 0.022], [cx + eyeGeo.rx * 1.05, eyeY - eyeGeo.ry * 0.35]], hairDark)); } } // ── 鼻子(很小一點,但少了它嘴巴會被看成鼻子) out.push(ellipse(0.5, 0.638, 0.010, 0.007, shade(skin, 0.22))); // ── 腮紅與嘴(表情) if (f.expression !== "calm") { out.push(ellipse(0.318, 0.646, 0.042, 0.019, cloth, f.expression === "bright" ? 0.28 : 0.20)); out.push(ellipse(0.682, 0.646, 0.042, 0.019, cloth, f.expression === "bright" ? 0.28 : 0.20)); } if (f.expression === "bright") { out.push(ellipse(0.5, 0.690, 0.046, 0.022, mouth)); out.push(rect(0.454, 0.676, 0.092, 0.009, shade(mouth, 0.35))); out.push(ellipse(0.5, 0.683, 0.034, 0.008, tint(mouth, 0.6))); } else if (f.expression === "calm" || f.expression === "neutral") { out.push(rect(0.480, 0.686, 0.040, 0.009, mouth)); } else { out.push(ellipse(0.5, 0.688, 0.024, 0.013, mouth)); } // ── 髮飾 const ax = sideX(0.775); if (f.accessory === "ribbon") { out.push(poly([[ax, 0.330], [ax + 0.085 * mirror, 0.288], [ax + 0.085 * mirror, 0.376]], cloth2)); out.push(poly([[ax, 0.330], [ax - 0.075 * mirror, 0.292], [ax - 0.075 * mirror, 0.372]], cloth2)); out.push(circle(ax, 0.332, 0.026, shade(cloth2, 0.18))); } else if (f.accessory === "clip") { out.push(rect(ax - 0.055, 0.318, 0.110, 0.026, cloth2)); out.push(rect(ax - 0.055, 0.352, 0.075, 0.022, tint(cloth2, 0.25))); } else if (f.accessory === "flower") { for (let i = 0; i < 5; i += 1) { const a = (i / 5) * Math.PI * 2; out.push(circle(ax + Math.cos(a) * 0.042, 0.335 + Math.sin(a) * 0.042, 0.030, cloth2)); } out.push(circle(ax, 0.335, 0.024, tint(cloth2, 0.45))); } else if (f.accessory === "hairband") { out.push(ellipse(0.5, 0.268, 0.246, 0.052, cloth2)); out.push(ellipse(0.5, 0.300, 0.238, 0.052, hair)); } return out; } /** 徽章樣式(沒有參考照片時):點陣紋 + 編號字母。 */ function badgeShapes(spec) { return [ ...dots(spec).map((d) => circle(d.cx, d.cy, d.r, spec.dot, spec.dotAlpha)), ...glyphRects(spec).map((r) => rect(r.x, r.y, r.w, r.h, spec.ink)), ]; } export function iconShapes(spec) { return spec.style === "portrait" ? portraitShapes(spec) : badgeShapes(spec); } /** 回傳字母的所有方塊(單位座標)。 */ 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}` : ""}`, " ", ` `, ` `, ` `, " ", ` `, ` `, " ", " ", ` `, ` `, ...iconShapes(spec).map((sh) => { const paint = ` fill="${hex(sh.fill)}"${sh.alpha < 1 ? ` opacity="${sh.alpha}"` : ""}/>`; if (sh.type === "rect") { return ` ", ` `, "", "", ]; 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)), ]); } /** 點是否在多邊形內(crossing number;與 SVG 的 nonzero 對簡單多邊形結果一致)。 */ function pointInPoly(x, y, pts) { let inside = false; for (let i = 0, j = pts.length - 1; i < pts.length; j = i, i += 1) { const [xi, yi] = pts[i]; const [xj, yj] = pts[j]; if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside; } return inside; } /** 內縮 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 shapes = iconShapes(spec); 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; // 圖形清單:由後往前疊,與 SVG 的繪製順序相同 for (const sh of shapes) { let hit; if (sh.type === "rect") { hit = u >= sh.x && u < sh.x + sh.w && v >= sh.y && v < sh.y + sh.h; } else if (sh.type === "poly") { hit = pointInPoly(u, v, sh.points); } else { const dx = (u - sh.cx) / sh.rx; const dy = (v - sh.cy) / sh.ry; hit = dx * dx + dy * dy <= 1; } if (!hit) continue; const a = sh.alpha; r += (sh.fill[0] - r) * a; g += (sh.fill[1] - g) * a; b += (sh.fill[2] - b) * a; } // 邊框:與 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; } 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); } // --------------------------------------------------------------------------- // // 產生並寫檔 // --------------------------------------------------------------------------- // /** * 產生圖示。有 `photo` 且工具齊全 → 用真實照片裁臉;否則畫向量人物形象(一樣有臉)。 * 回傳裡的 `photo` 說明走了哪條路、缺什麼工具。 */ export function generateIcon(slug, { size = DEFAULT_SIZE, code = null, palette = null, source = null, style = null, features = null, } = {}) { const spec = iconSpec(slug, { code, palette, style, features }); 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, style: spec.style, size, generated_at: pl.nowIso(), palette: spec.palette ? paletteToString(spec.palette) : null, features: featuresToString(spec.features), 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)); // --------------------------------------------------------------------------- // // 照片裁臉(選用的加值路徑) // --------------------------------------------------------------------------- // // // 本體零依賴,但如果環境裡有 Pillow(+可選的 OpenCV 動漫臉偵測), // 圖示就能直接用真實照片裁出的臉。缺工具時 `toolReport()` 會給出安裝指令。 const HERE = path.dirname(fileURLToPath(import.meta.url)); export const PORTRAIT_PY = path.join(HERE, "portrait.py"); export const CASCADE_URL = "https://raw.githubusercontent.com/nagadomi/lbpcascade_animeface/master/lbpcascade_animeface.xml"; export function cascadePath() { if (process.env.PERSONA_ANIME_CASCADE) return process.env.PERSONA_ANIME_CASCADE; return path.join(os.homedir(), ".cache", "jsc-persona", "lbpcascade_animeface.xml"); } /** 找一個能用的 python(優先吃 PERSONA_PYTHON,其次 venv,最後系統 python3)。 */ export function pythonPath() { const candidates = [ process.env.PERSONA_PYTHON, path.join(os.homedir(), ".cache", "jsc-persona", "venv", "bin", "python3"), "python3", ].filter(Boolean); for (const bin of candidates) { const probe = spawnSync(bin, ["-c", "import sys;print(sys.version_info[0])"], { encoding: "utf8" }); if (probe.status === 0) return bin; } return null; } const pyHas = (bin, mod) => spawnSync(bin, ["-c", `import ${mod}`], { encoding: "utf8" }).status === 0; /** 目前有哪些工具、缺什麼、怎麼補。 */ export function toolReport() { const python = pythonPath(); const pillow = Boolean(python) && pyHas(python, "PIL"); const cv2 = Boolean(python) && pyHas(python, "cv2"); const cascade = fs.existsSync(cascadePath()); const venv = path.join(os.homedir(), ".cache", "jsc-persona", "venv"); const missing = []; if (!python) { missing.push({ what: "python3", why: "解碼照片(JPEG/WebP/PNG)與裁切都靠它", how: "sudo apt-get install -y python3 python3-venv", }); } if (!pillow) { missing.push({ what: "Pillow", why: "沒有它就無法把照片解碼成像素,也就無法裁臉", how: `python3 -m venv ${venv} && ${venv}/bin/pip install -q pillow`, }); } if (!cv2) { missing.push({ what: "OpenCV(opencv-python-headless)", why: "自動找出臉的位置;沒有它就只能用「上方中央」的經驗法則裁切", how: `${venv}/bin/pip install -q opencv-python-headless`, }); } if (!cascade) { missing.push({ what: "動漫臉偵測模型 lbpcascade_animeface.xml", why: "OpenCV 內建的模型認不出動漫臉,要這個才準", how: `mkdir -p ${path.dirname(cascadePath())} && curl -sL -o ${cascadePath()} ${CASCADE_URL}`, }); } return { python, pillow, cv2, cascade, ready: Boolean(python && pillow), faceDetection: Boolean(python && pillow && cv2), animeFaceDetection: Boolean(python && pillow && cv2 && cascade), missing, venv, }; } /** 缺工具時要印給使用者看的提示(一行 what/why,一行指令)。 */ export function installHintLines(report) { if (!report.missing.length) return []; const lines = [" ⚠ 少了這些工具,暫時無法用真實照片裁臉(先用向量人物形象代替):"]; for (const m of report.missing) { lines.push(` • ${m.what} — ${m.why}`); lines.push(` ${m.how}`); } lines.push(" 裝好之後重跑 `icon generate --photo <圖片> --force` 就會換成照片裁臉版。"); return lines; } /** 取得參考照片:URL 就抓下來,本機路徑就直接用。 */ export async function fetchPhoto(source, destDir) { if (/^https?:\/\//i.test(source)) { const res = await fetch(source, { headers: { "User-Agent": "jsc-persona/icon" } }); if (!res.ok) throw new Error(`下載參考照片失敗(HTTP ${res.status}):${source}`); const buf = Buffer.from(await res.arrayBuffer()); fs.mkdirSync(destDir, { recursive: true }); const ext = (source.split("?")[0].match(/\.(png|jpe?g|webp|gif)$/i)?.[1] || "img").toLowerCase(); const file = path.join(destDir, `reference.${ext}`); fs.writeFileSync(file, buf); return file; } const abs = path.resolve(source); if (!fs.existsSync(abs)) throw new Error(`找不到參考照片:${abs}`); return abs; } /** 列出參考照片裡偵測到的所有臉(多角色的圖要先看這個再挑)。 */ export function listFaces(imagePath) { const report = toolReport(); if (!report.ready) return { ok: false, report }; const args = [PORTRAIT_PY, "--input", imagePath, "--list"]; if (report.cascade) args.push("--cascade", cascadePath()); const proc = spawnSync(report.python, args, { encoding: "utf8" }); if (proc.status !== 0) return { ok: false, report, reason: String(proc.stderr).slice(0, 300) }; try { return { ...JSON.parse(String(proc.stdout).trim().split("\n").pop()), report }; } catch { return { ok: false, report, reason: "portrait.py 的輸出不是 JSON" }; } } /** * 從參考圖裁出**大頭照**。這張是給 AI 看的**參考**,不是圖示本身—— * 圖示一律由 renderSvg/renderPng 依人格資料重新繪製。 */ export function cropHeadshot(imagePath, outPath, size = DEFAULT_SIZE, { pick = null, face = null } = {}) { const report = toolReport(); if (!report.ready) return { ok: false, report }; const args = [PORTRAIT_PY, "--input", imagePath, "--output", outPath, "--size", String(size), "--radius", String(GEO.radius)]; if (report.cascade) args.push("--cascade", cascadePath()); if (face) args.push("--face", face); else if (pick) args.push("--pick", pick); const proc = spawnSync(report.python, args, { encoding: "utf8" }); if (proc.status !== 0) { return { ok: false, report, reason: (proc.stderr || proc.stdout || "").trim().slice(0, 300) }; } let info = null; try { info = JSON.parse(String(proc.stdout).trim().split("\n").pop()); } catch { info = null; } if (!info?.ok) return { ok: false, report, reason: info?.reason || "portrait.py 沒有回報成功" }; return { ok: true, report, info }; }