Files
persona/scripts/persona-icon.mjs
T
jiantw83andClaude Opus 5 cd1f4754ab fix: Wiki 圖片其實是破圖(HTML img 不被改寫);形象圖加高解析度與五官細節
1. Wiki 形象圖「沒有同步」的真正原因
   內容其實一直是一致的(md5 相同),但**頁面上看不到圖**:
   Icon.md/Home.md 用了 HTML `<img src="icon.svg">`,而 Gitea **只改寫
   Markdown 圖片語法**(`![](icon.png)` → `/wiki/raw/icon.png`)。HTML 的 src
   被瀏覽器解析成 `/wiki/icon.svg` → 303 轉址 → 破圖。
   用公開的臨時庫實測四種寫法後確認,全部改成 Markdown 語法。

   同時發現攤平規則過頭了:原本所有子目錄檔案都被壓成 `icon-portrait.svg`。
   實測 `/wiki/raw/<資料夾>/<圖>` 是取得到的,所以**只有 .md 需要攤平**
   (頁面必須在根層),圖片與附件保留資料夾結構。

2. icon/ 資料夾 + 高解析度
   每次 `icon generate` 都會輸出並同步:
     icon.svg / icon.png            512,Wiki 頁面與存取庫頭像
     icon/portrait.svg              向量原稿
     icon/portrait-512.png          高解析度
     icon/portrait-1024.png         高解析度
   柵格器加上每個圖形的 bounding box 裁剪,1024×1024(3× 超取樣)約 1.8 秒;
   沒有裁剪的話要跑上億次點測試。

3. 更貼近原圖、更可愛
   眼睛放大約 15%,虹膜改成四層(暗環→本色→下緣提亮→瞳孔)+雙高光,
   眼線壓扁成細弧、外眼角加睫毛;頭髮加高光弧、髮絲與側髮反光;
   臉型再圓一點、加下巴陰影與鼻子。
   眉色改成隨髮色明暗調整:深髮往膚色提亮、淺髮比頭髮更深——否則不是和瀏海
   連成黑帶,就是變成兩條灰槓。

selftest 168 項全綠(新增第 ⑱ 節:icon/ 多解析度、PNG 實際尺寸、只攤平 .md、
Wiki 頁面不得用 HTML img、bounding box 裁剪效能)。

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

987 lines
40 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 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;
/** icon/ 資料夾裡要輸出的解析度(512 給頭像、1024 給高解析用途)。 */
export const RENDER_SIZES = [512, 1024];
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", "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.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,
// 有調色盤(=看過大頭照)就重繪人物形象;沒有的話只能畫徽章
// 只認得 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));
}
// 側髮的反光,讓長髮有圓柱感
out.push(ellipse(0.221, sideCy - sideRy * 0.38, 0.022, sideRy * 0.36, tint(hairDark, 0.20), 0.32));
out.push(ellipse(0.779, sideCy - sideRy * 0.38, 0.022, sideRy * 0.36, tint(hairDark, 0.20), 0.32));
// 呆毛
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.234, 0.252, 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.5, 0.230, 0.126, 0.022, tint(hair, 0.40),
luminance(hair) < 0.12 ? 0.13 : 0.24));
for (const [sx, sy, sw] of [[0.386, 0.330, 0.013], [0.596, 0.330, 0.013]]) {
out.push(poly([[sx, sy - 0.075], [sx + sw, sy - 0.075], [sx + sw * 0.4, sy + 0.055]],
shade(hair, 0.16), 0.35));
}
// 鬢髮(貼著臉頰的兩束)
out.push(ellipse(0.250, 0.560, 0.032, 0.185, hair));
out.push(ellipse(0.750, 0.560, 0.032, 0.185, hair));
// ── 眉毛(表情會影響傾斜)
const browY = f.expression === "bright" ? 0.408 : 0.414;
// 眉毛要「看得到但不搶戲」:深髮角色往膚色提亮才不會和瀏海連成一片,
// 淺髮角色則要比頭髮更深,否則會變成兩條灰槓。
const brow = luminance(hair) < 0.12 ? mix(hairDark, skin, 0.16) : shade(hair, 0.48);
const browTilt = { gentle: 0.006, bright: 0.010, calm: 0, neutral: 0 }[f.expression] ?? 0;
out.push(poly([[0.378, browY + browTilt], [0.458, browY], [0.458, browY + 0.014],
[0.376, browY + browTilt + 0.014]], brow));
out.push(poly([[0.542, browY], [0.622, browY + browTilt], [0.622, browY + browTilt + 0.014],
[0.540, browY + 0.014]], brow));
// ── 眼睛
const eyeGeo = {
round: { rx: 0.070, ry: 0.084 },
almond: { rx: 0.074, ry: 0.073 },
sharp: { rx: 0.076, ry: 0.060 },
droopy: { rx: 0.072, ry: 0.078 },
}[f.eyes] || { rx: 0.063, ry: 0.064 };
const eyeY = f.eyes === "droopy" ? 0.558 : 0.552;
const lash = shade(hairDark, 0.35);
for (const [cx, hl, dir] of [[0.404, 0.385, -1], [0.596, 0.577, 1]]) {
// 眼白 → 虹膜(上深下淺的漸層感)→ 瞳孔 → 高光 ×2 → 眼線與睫毛
out.push(ellipse(cx, eyeY, eyeGeo.rx, eyeGeo.ry, [252, 252, 255]));
out.push(ellipse(cx, eyeY + 0.004, eyeGeo.rx * 0.80, eyeGeo.ry * 0.84, shade(eye, 0.30)));
out.push(ellipse(cx, eyeY + 0.012, eyeGeo.rx * 0.76, eyeGeo.ry * 0.72, eye));
out.push(ellipse(cx, eyeY + 0.024, eyeGeo.rx * 0.62, eyeGeo.ry * 0.42, tint(eye, 0.28)));
out.push(ellipse(cx, eyeY + 0.010, eyeGeo.rx * 0.32, eyeGeo.ry * 0.44, shade(eye, 0.78)));
out.push(ellipse(hl, eyeY - 0.019, 0.019, 0.016, [255, 255, 255]));
out.push(circle(cx + dir * eyeGeo.rx * 0.38, eyeY + eyeGeo.ry * 0.42, 0.010, [255, 255, 255], 0.85));
// 上眼線 + 外眼角睫毛
out.push(ellipse(cx, eyeY - eyeGeo.ry * 0.94, eyeGeo.rx * 0.98, eyeGeo.ry * 0.13, lash));
out.push(poly([
[cx + dir * eyeGeo.rx * 0.80, eyeY - eyeGeo.ry * 1.00],
[cx + dir * eyeGeo.rx * 1.22, eyeY - eyeGeo.ry * (f.eyes === "sharp" ? 1.24 : 1.08)],
[cx + dir * eyeGeo.rx * 1.02, eyeY - eyeGeo.ry * 0.66],
], lash));
if (f.eyes === "droopy") {
out.push(ellipse(cx + dir * eyeGeo.rx * 0.7, eyeY + eyeGeo.ry * 0.85, eyeGeo.rx * 0.5,
eyeGeo.ry * 0.16, lash, 0.75));
}
}
// ── 下巴/脖子的陰影,臉才立體
out.push(ellipse(0.5, 0.706, 0.104, 0.032, shade(skin, 0.14), 0.32));
// ── 鼻子(很小一點,但少了它嘴巴會被看成鼻子)
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 = [
`<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>",
` <clipPath id="badge">`,
` <rect width="${S}" height="${S}" rx="${u(GEO.radius)}" ry="${u(GEO.radius)}"/>`,
" </clipPath>",
" </defs>",
` <rect width="${S}" height="${S}" rx="${u(GEO.radius)}" ry="${u(GEO.radius)}" fill="url(#bg)"/>`,
` <g clip-path="url(#badge)">`,
...iconShapes(spec).map((sh) => {
const paint = ` fill="${hex(sh.fill)}"${sh.alpha < 1 ? ` opacity="${sh.alpha}"` : ""}/>`;
if (sh.type === "rect") {
return ` <rect x="${u(sh.x)}" y="${u(sh.y)}" width="${u(sh.w)}" height="${u(sh.h)}"${paint}`;
}
if (sh.type === "poly") {
return ` <polygon points="${sh.points.map(([x, y]) => `${u(x)},${u(y)}`).join(" ")}"${paint}`;
}
return ` <ellipse cx="${u(sh.cx)}" cy="${u(sh.cy)}" rx="${u(sh.rx)}" ry="${u(sh.ry)}"${paint}`;
}),
" </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)),
]);
}
/** 點是否在多邊形內(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);
// 先算每個圖形的外接矩形:不加這個,1024×3 超取樣要跑上億次點測試
const shapes = iconShapes(spec).map((sh) => {
if (sh.type === "rect") return { ...sh, x0: sh.x, x1: sh.x + sh.w, y0: sh.y, y1: sh.y + sh.h };
if (sh.type === "poly") {
const xs = sh.points.map((q) => q[0]);
const ys = sh.points.map((q) => q[1]);
return { ...sh, x0: Math.min(...xs), x1: Math.max(...xs), y0: Math.min(...ys), y1: Math.max(...ys) };
}
return { ...sh, x0: sh.cx - sh.rx, x1: sh.cx + sh.rx, y0: sh.cy - sh.ry, y1: sh.cy + sh.ry };
});
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) {
if (u < sh.x0 || u > sh.x1 || v < sh.y0 || v > sh.y1) continue;
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.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;
}
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);
// icon/ 資料夾:向量原稿 + 多個解析度,Gitea 上點得開也拿得走
const dir = path.join(pl.personaDir(slug), "icon");
fs.mkdirSync(dir, { recursive: true });
const renders = [{ name: "portrait.svg", bytes: Buffer.byteLength(svg, "utf8") }];
pl.writeText(path.join(dir, "portrait.svg"), svg);
for (const px of RENDER_SIZES) {
const buf = px === size ? png : renderPng(spec, px);
fs.writeFileSync(path.join(dir, `portrait-${px}.png`), buf);
renders.push({ name: `portrait-${px}.png`, bytes: buf.length });
}
// 把配色與來源記進 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,
renders,
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: "解碼照片(JPEGWebPPNG)與裁切都靠它",
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: "OpenCVopencv-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 };
}