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>
This commit is contained in:
2026-07-30 03:20:29 +00:00
co-authored by Claude Opus 5
parent d0c42f0c15
commit cd1f4754ab
10 changed files with 166 additions and 42 deletions
+72 -24
View File
@@ -26,6 +26,8 @@ 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);
@@ -404,11 +406,14 @@ function portraitShapes(spec) {
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.226, 0.256, skin));
out.push(ellipse(0.5, 0.500, 0.234, 0.252, skin));
// ── 瀏海
if (f.fringe === "blunt") {
@@ -426,41 +431,63 @@ function portraitShapes(spec) {
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.268, 0.545, 0.038, 0.150, hair));
out.push(ellipse(0.732, 0.545, 0.038, 0.150, hair));
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.430 : 0.436;
const brow = mix(hairDark, skin, 0.28); // 深髮角色的眉毛要和瀏海分得開
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.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));
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.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 },
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;
for (const [cx, hl] of [[0.415, 0.399], [0.585, 0.569]]) {
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.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(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)));
@@ -664,7 +691,16 @@ 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);
// 先算每個圖形的外接矩形:不加這個,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) {
@@ -680,6 +716,7 @@ export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) {
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;
@@ -758,6 +795,16 @@ export function generateIcon(slug, {
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 = {
@@ -776,6 +823,7 @@ export function generateIcon(slug, {
svg: iconSvgPath(slug),
png: iconPngPath(slug),
size,
renders,
bytes: { svg: Buffer.byteLength(svg, "utf8"), png: png.length },
};
}