`icon generate|show`:由**編號、Name、Emoji** 雜湊出配色與圖案,產出 512×512 的幾何徽章 icon.svg + icon.png,設為 Gitea 存取庫頭像並同步到 Wiki 區。 圖案(SVG 與 PNG 共用同一組單位座標與同一份 5×7 點陣字,**輸出的是同一張圖**): * 雙色對角漸層底 * 5×5 左右對稱點陣紋(identicon 式) * 中央兩個字母=編號前兩字(ASUNA-01 → AS),依背景亮度自動選黑/白 同一個人格永遠得到同一張圖(純函數,無隨機);ASUNA-01 與 ASUNA-02 明顯不同。 為什麼自己畫:這台機器(及多數伺服器)沒有 rsvg/inkscape/imagemagick, 沒有影像函式庫,也沒有 emoji 字型,而本專案禁止 npm 依賴。所以 scripts/persona-icon.mjs 自己柵格化(3× 超取樣 + 盒式縮減當反鋸齒), 再用內建 zlib 手工組出 IHDR/IDAT/IEND 與 CRC32。 emoji 無法柵格化,因此不作為圖形,但仍參與配色雜湊。 時機:**資料補齊之後才產生**——配色與字母綁在最終身分上,太早跑會對不上。 persona-create 第 7 步、persona-anime 第 8 步都補了這個步驟;改過身分用 --force 重畫。 selftest 128 項全綠(新增第 ⑭ 節:PNG chunk 合法性、決定性、不同人格不同圖、 圖示屬 Wiki 區、匯出用 base64 帶走二進位、guest 不得重畫)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
404 lines
15 KiB
JavaScript
404 lines
15 KiB
JavaScript
// 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 人格資料 → 同一個人格永遠同一張圖
|
||
// --------------------------------------------------------------------------- //
|
||
|
||
export function iconSpec(slug, { code = null, identity = null } = {}) {
|
||
const ident = identity || pl.identityFields(slug);
|
||
const theCode = code || pl.loadConfig(slug).code || slug;
|
||
const seedText = `${theCode}|${ident.Name || slug}|${ident.Emoji || ""}`;
|
||
const h = crypto.createHash("sha256").update(seedText).digest();
|
||
|
||
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
|
||
const c1 = hslToRgb(hue, sat, light);
|
||
const c2 = hslToRgb(hue2, sat * 0.9, Math.min(0.72, light + 0.18));
|
||
|
||
// 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 onDark = luminance(c1) < 0.32 || luminance(c2) < 0.32;
|
||
const ink = onDark ? [255, 255, 255] : [16, 18, 24];
|
||
|
||
return {
|
||
persona: slug,
|
||
code: theCode,
|
||
name: ident.Name || slug,
|
||
emoji: ident.Emoji || "",
|
||
letters,
|
||
c1,
|
||
c2,
|
||
ink,
|
||
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.ink)}" opacity="0.14">`,
|
||
...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.ink)}" stroke-opacity="0.18" 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 → 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;
|
||
|
||
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;
|
||
// 紋:圓點(14% 不透明度)
|
||
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 += (i1 - r) * 0.14;
|
||
g += (i2 - g) * 0.14;
|
||
b += (i3 - b) * 0.14;
|
||
break;
|
||
}
|
||
}
|
||
// 邊框:與 SVG 的 stroke 完全相同的環帶
|
||
//(SVG 的描邊以 0.012 為中心、寬 0.012 → 涵蓋 0.006~0.018)
|
||
if (insideInset(u, v, 0.006) && !insideInset(u, v, 0.018)) {
|
||
r += (i1 - r) * 0.18;
|
||
g += (i2 - g) * 0.18;
|
||
b += (i3 - b) * 0.18;
|
||
}
|
||
// 字:實心方塊
|
||
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 } = {}) {
|
||
const spec = iconSpec(slug, { code });
|
||
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);
|
||
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));
|