promptfigure 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -0
- package/adapters/claude-code/SKILL.md +381 -0
- package/adapters/claude-code/install.mjs +9 -0
- package/adapters/codex/marketplace.json +14 -0
- package/adapters/codex/promptfigure/.codex-plugin/plugin.json +6 -0
- package/adapters/codex/promptfigure/skills/promptfigure-local/SKILL.md +381 -0
- package/bin/pf.mjs +1953 -0
- package/package.json +44 -0
- package/scripts/build-adapters.mjs +80 -0
- package/scripts/test-e2e.mjs +117 -0
- package/skill/promptfigure-local/SKILL.md +381 -0
- package/src/anchor.mjs +63 -0
- package/src/config.mjs +75 -0
- package/src/craft-rules.mjs +158 -0
- package/src/craft.mjs +600 -0
- package/src/doc/docx.mjs +69 -0
- package/src/doc/index.mjs +35 -0
- package/src/doc/para.mjs +54 -0
- package/src/doc/tex.mjs +161 -0
- package/src/doc/texbuild.mjs +158 -0
- package/src/docsearch.mjs +96 -0
- package/src/entity-pair.mjs +17 -0
- package/src/events.mjs +52 -0
- package/src/extract.mjs +124 -0
- package/src/figure-catalog.mjs +326 -0
- package/src/journal.mjs +67 -0
- package/src/ledger.mjs +43 -0
- package/src/next.mjs +125 -0
- package/src/plan.mjs +112 -0
- package/src/png-trim.mjs +217 -0
- package/src/quality.mjs +325 -0
- package/src/ratio.mjs +87 -0
- package/src/render.mjs +247 -0
- package/src/review.mjs +48 -0
- package/src/server.mjs +218 -0
- package/src/store.mjs +91 -0
- package/src/vectorize.mjs +54 -0
- package/tray/pf-tray.py +265 -0
- package/web/app.js +613 -0
- package/web/index.html +73 -0
- package/web/probe.html +36 -0
- package/web/style.css +208 -0
package/src/next.mjs
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// next.mjs — 🔴 状态路由器(2026-09-21 用户反馈"别人的 AI 根本不会用"):
|
|
2
|
+
// 冷启动 AI 面对几十条命令不知道下一步。本模块只做一件事:
|
|
3
|
+
// 读项目当前状态 → 输出**唯一**一条可直接复制执行的下一步命令 + 为什么。
|
|
4
|
+
// 所有歧义在这里消解:AI 不需要理解工作流,只要反复 `pf next` → 执行 → 再 `pf next`。
|
|
5
|
+
//
|
|
6
|
+
// 设计原则:
|
|
7
|
+
// - 命令必须是完整可执行的(占位符用 <尖括号> 明确标出要 AI 自己填的内容)
|
|
8
|
+
// - 优先级:精修 > 待审批 > 驳回 > 定稿缺失 > 新图 > 读文选位
|
|
9
|
+
// - 纯函数:CLI 与 cold-start-sim 共用同一份判断,插件改状态这里必须跟着改
|
|
10
|
+
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
|
|
13
|
+
// 审批状态取本地图(daemon 挂了导航也不能挂)
|
|
14
|
+
function reviewMap(docId, readReview) {
|
|
15
|
+
try { return readReview(docId) || {}; } catch { return {}; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// 🔴 活跃修正 vs 最新 sidecar 对账(2026-09-21 子智能体实测发现的死路出口):
|
|
19
|
+
// AI 按 refine 重拼命令跑过 craft --fixes 后,若在等授权/沙盒(没有真渲染推进状态),
|
|
20
|
+
// pf next 必须认出"修正已全部进入最新提示词",指去 render 而不是永远卡在 refine。
|
|
21
|
+
function sidecarCoversFixes(fig, fixes = []) {
|
|
22
|
+
try {
|
|
23
|
+
const scPath = fig?.versions?.at(-1)?.sidecar;
|
|
24
|
+
if (!scPath || !fs.existsSync(scPath)) return false;
|
|
25
|
+
const sc = JSON.parse(fs.readFileSync(scPath, "utf8"));
|
|
26
|
+
const norm = (s) => String(s || "").toLowerCase().replace(/\s+/g, " ").trim();
|
|
27
|
+
const scSet = String(sc.fixes || "").split(/[;;]/).map(norm).filter(Boolean);
|
|
28
|
+
if (!scSet.length) return false;
|
|
29
|
+
return (fixes || []).every((f) => {
|
|
30
|
+
const n = norm(f);
|
|
31
|
+
return scSet.includes(n) || scSet.some((s) => s && (s.includes(n) || n.includes(s)));
|
|
32
|
+
});
|
|
33
|
+
} catch { return false; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 单图阶段判定(2026-09-21 多图路由重构):返回 {stage,cmd,why} 或 null(已定稿)。
|
|
37
|
+
// 顺序:精修 > ready(对账出口)> 待核验 > 驳回 > 定稿缺失。
|
|
38
|
+
export function figureNext(fig, rev = {}) {
|
|
39
|
+
const fid = fig.figureId;
|
|
40
|
+
const st = (rev[fid] || {}).status || "pending";
|
|
41
|
+
|
|
42
|
+
if ((fig.activeFixes || []).length > 0) {
|
|
43
|
+
if (sidecarCoversFixes(fig, fig.activeFixes)) {
|
|
44
|
+
return {
|
|
45
|
+
stage: "ready",
|
|
46
|
+
cmd: `pf render --at "${fig.at || "<原文位置>"}" --model standard --prompt-file <刚 craft --out 的文件名>.txt`,
|
|
47
|
+
why: `修正已全部写进最新提示词(对账 ${fig.activeFixes.length} 条 ✓)——直接渲染;真实渲染后新版本会自动清核验记录并重置审批(沙盒/排练用 --dry-run,但闭环要真图才能走完)`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
stage: "refine",
|
|
52
|
+
cmd: `pf refine ${fid}`,
|
|
53
|
+
why: `图 ${fid} 有 ${fig.activeFixes.length} 条未消化修正——refine 会列出它们并自动重拼下一轮 craft 命令`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
if (st === "pending") {
|
|
57
|
+
return {
|
|
58
|
+
stage: "review",
|
|
59
|
+
cmd: `pf qa ${fid}`,
|
|
60
|
+
why: `图 ${fid} 等待核验——pf qa 输出任务包(体检数字+实体清单+核验清单),亲眼读图逐条核验后写回:pf qa ${fid} --pass --note "依据"(通过并写回审批)或 pf qa ${fid} --fail --note "缺陷1; 缺陷2"(驳回+缺陷自动进精修状态机)`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (st === "rejected") {
|
|
64
|
+
return {
|
|
65
|
+
stage: "fix",
|
|
66
|
+
cmd: `pf inspect ${fid} && pf qa-list ${fid}`,
|
|
67
|
+
why: `图 ${fid} 被驳回——先看自动体检数字(留白/白度/彩色占比),再用你的看图能力过核验清单;发现的缺陷用 pf refine ${fid} --fix "缺陷" 记入状态机`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const last = fig.versions?.at(-1);
|
|
71
|
+
if (st === "approved" && last && last.model !== "premium") {
|
|
72
|
+
return {
|
|
73
|
+
stage: "finalize",
|
|
74
|
+
cmd: `pf prompt ${fid} --out <名字>.txt`,
|
|
75
|
+
why: `图 ${fid} 已通过还是草稿——pf prompt 把定稿提示词导出成文件,然后 pf render --at "<原文位置>" --model premium --prompt-file <名字>.txt 出定稿`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return null; // approved + premium = 完工
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function computeNext({ docId = null, meta = null, cfg = {} } = {}, io = {}) {
|
|
82
|
+
const readReview = io.readReview;
|
|
83
|
+
const rev = docId && readReview ? reviewMap(docId, readReview) : {};
|
|
84
|
+
|
|
85
|
+
// ---- 0) 没配 key:一切免谈 ----
|
|
86
|
+
if (!cfg.key) {
|
|
87
|
+
return { stage: "login", cmd: "pf login", why: "还没配置 API key——先去控制台创建 pf_ 开头的 key,再跑 pf login <key>" };
|
|
88
|
+
}
|
|
89
|
+
// ---- 1) 没打开文档 ----
|
|
90
|
+
if (!docId || !meta) {
|
|
91
|
+
return { stage: "open", cmd: "pf open <论文路径.docx|.tex|.pdf>", why: "还没打开论文。open 会打印 docId 并自动打开预览窗口,之后的命令都要用到 docId" };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const figures = Object.values(meta.figures || {});
|
|
95
|
+
// 🔴 多图路由(2026-09-21 子智能体 E 实测:批量配图时 next 只盯"最新一张",其他图做完
|
|
96
|
+
// craft/dry-run 它毫无感知)。改为按创建序轮询:第一张没完工的图就是下一步。
|
|
97
|
+
const ordered = figures.slice().sort((a, b) => (a.versions?.[0]?.at || "").localeCompare(b.versions?.[0]?.at || ""));
|
|
98
|
+
|
|
99
|
+
for (const fig of ordered) {
|
|
100
|
+
const r = figureNext(fig, rev);
|
|
101
|
+
if (r) return r;
|
|
102
|
+
}
|
|
103
|
+
if (ordered.length > 0) {
|
|
104
|
+
return {
|
|
105
|
+
stage: "done",
|
|
106
|
+
cmd: `pf doc figures`,
|
|
107
|
+
why: `全部 ${ordered.length} 张图已定稿 ✅——对照论文原图清单挑下一张要重画的;全部完成则收工`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---- 3) 没图:读文 → 选位 → craft ----
|
|
112
|
+
const anchors = io.readAnchors ? (io.readAnchors(docId) || []) : [];
|
|
113
|
+
if (anchors.length === 0) {
|
|
114
|
+
return {
|
|
115
|
+
stage: "context",
|
|
116
|
+
cmd: `pf doc outline ${docId} → pf distill --at "<章节 段号>"`,
|
|
117
|
+
why: "还没选定配图位置——先 outline 看章节树(找特定内容用 pf doc search <关键词>;找数值用 pf doc data),再 distill 从原文抽实体/阶段素材(禁止跳过读原文直接编内容)",
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
stage: "craft",
|
|
122
|
+
cmd: `pf craft --at "<章节 段号>" --intent "<图意,来自刚才读的原文>" --entities "<实体逗号分隔>" --stages "<标题 | show 画法 || …>" --figure-type <类型> --out <名字>.txt`,
|
|
123
|
+
why: "位置已定——craft 必带 --at(实体溯源会拿回原文对账,缺了直接拒绝);质量分 <60 也会被拒绝;--out 会写伴随 JSON,render 自动继承画布比",
|
|
124
|
+
};
|
|
125
|
+
}
|
package/src/plan.mjs
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// plan.mjs — 🔴 小白对齐器(2026-09-22 用户反馈"数模小白在 Codex/Z Code 里用不出好效果"):
|
|
2
|
+
// 前几轮把"门"焊死了(质量门/溯源/QA/审批),但小白用户的另一半问题在"门"之前——
|
|
3
|
+
// 用户自己说不清要什么图,弱 AI 不问就闷头编:编实体(被溯源拒绝)或编完直接 render(钱白烧)。
|
|
4
|
+
// plan 做一件事:把插件当前状态翻译成【发给用户的大白话问题卡】+【AI 拿到答复后的执行步骤】,
|
|
5
|
+
// 问题卡里的选项全部来自真实文档(章节编号、候选图型),小白回个数字就能推进。
|
|
6
|
+
// 纯函数:CLI 与测试共用。
|
|
7
|
+
import { computeNext } from "./next.mjs";
|
|
8
|
+
|
|
9
|
+
// 图型中文名(给小白看的人话,CLI 与 distill 确认卡共用)
|
|
10
|
+
export function figureTypeBrief(ft = "") {
|
|
11
|
+
const M = {
|
|
12
|
+
pipeline: "流程图", flowchart: "判断流程图", architecture: "结构/架构图", mechanism: "机理/因果图",
|
|
13
|
+
comparison: "对比图", dataflow: "数据流图", hierarchy: "层级/组成图", timeline: "时间线",
|
|
14
|
+
teaser: "核心一图流", zoomin: "局部放大图", scene: "场景示意图", "result-style": "结果风格图",
|
|
15
|
+
"multi-panel": "多面板组合图",
|
|
16
|
+
};
|
|
17
|
+
return M[String(ft || "").trim()] || "示意图";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// goal 关键词 → 图型建议(确定性路由,弱 AI 不用自己猜"画什么图")
|
|
21
|
+
export function suggestFigureType(goal = "") {
|
|
22
|
+
const g = String(goal || "").toLowerCase();
|
|
23
|
+
if (/数据|结果|准确率|精度|提升|误差|灵敏度|收敛|对比实验|numerical|result/.test(g)) return { type: "result-style", note: "data" };
|
|
24
|
+
if (/机理|因果|促进|抑制|影响|作用|为什么|mechanism/.test(g)) return { type: "mechanism" };
|
|
25
|
+
if (/对比|比较|优缺点|before|after|改进|vs/.test(g)) return { type: "comparison" };
|
|
26
|
+
if (/组合|多面板|panel|分图|消融/.test(g)) return { type: "multi-panel" };
|
|
27
|
+
if (/组成|包含|分类|层级|体系|hierarchy/.test(g)) return { type: "hierarchy" };
|
|
28
|
+
if (/发展|历史|时间|演进|timeline/.test(g)) return { type: "timeline" };
|
|
29
|
+
if (/结构|架构|组成模块|architecture|framework/.test(g)) return { type: "architecture" };
|
|
30
|
+
if (/流程|步骤|pipeline|过程|算法流程/.test(g)) return { type: "pipeline" };
|
|
31
|
+
if (/核心|摘要|一图|teaser|概述/.test(g)) return { type: "teaser" };
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 真实章节选项(heading 块 → 编号清单;小白回数字,AI 按编号映射回 §路径)
|
|
36
|
+
export function sectionOptions(blocks = [], { limit = 10 } = {}) {
|
|
37
|
+
const heads = blocks.filter((b) => b.type === "heading" && String(b.secPath || "") !== "0");
|
|
38
|
+
return heads.slice(0, limit).map((b, i) => ({ n: i + 1, at: `§${b.secPath}`, title: String(b.text || "").slice(0, 30) }));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function buildPlan({ docId = null, meta = null, cfg = {}, goal = "" } = {}, io = {}) {
|
|
42
|
+
const userLines = [];
|
|
43
|
+
const steps = [];
|
|
44
|
+
|
|
45
|
+
// ---- 0) 没配 key ----
|
|
46
|
+
if (!cfg.key) {
|
|
47
|
+
userLines.push("开始出图前需要配置一次 promptFigure 的 API key(控制台 promptfigure.top 注册后创建,pf_ 开头)。");
|
|
48
|
+
userLines.push("把 key 发给我,或者自己去控制台建好告诉我一声。");
|
|
49
|
+
steps.push({ cmd: "pf login <pf_ 开头的 key>", why: "配置 key——之后所有出图计费都走它" });
|
|
50
|
+
steps.push({ cmd: "pf plan", why: "配好后重新跑计划,进入下一步" });
|
|
51
|
+
return { stage: "login", userLines, steps };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---- 1) 没打开文档 ----
|
|
55
|
+
if (!docId || !meta) {
|
|
56
|
+
userLines.push("请把论文文件发给我(支持 Word .docx / PDF / LaTeX .tex)。");
|
|
57
|
+
userLines.push("如果你手上是 .doc/.wps 老格式,先在 Word/WPS 里另存为 .docx。");
|
|
58
|
+
steps.push({ cmd: "pf open <论文文件路径>", why: "打开论文并打印 docId,之后的操作都自动作用于它" });
|
|
59
|
+
steps.push({ cmd: "pf plan", why: "打开后重新跑计划:我会列出真实章节让你挑配图位置" });
|
|
60
|
+
return { stage: "open", userLines, steps };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const blocks = meta.blocks || [];
|
|
64
|
+
const figures = Object.values(meta.figures || {});
|
|
65
|
+
|
|
66
|
+
// ---- 2) 已有图:报进度 + 路由下一步 ----
|
|
67
|
+
if (figures.length > 0) {
|
|
68
|
+
const r = computeNext({ docId, meta, cfg }, io);
|
|
69
|
+
const doneN = figures.filter((f) => {
|
|
70
|
+
const st = ((io.readReview?.(docId) || {})[f.figureId] || {}).status;
|
|
71
|
+
return st === "approved" && f.versions?.at(-1)?.model === "premium";
|
|
72
|
+
}).length;
|
|
73
|
+
userLines.push(`你的论文我正在配图:目前共 ${figures.length} 张,定稿 ${doneN} 张。`);
|
|
74
|
+
if (goal) {
|
|
75
|
+
const s = suggestFigureType(goal);
|
|
76
|
+
userLines.push(`你说的"${goal}"我记下了,会按${s ? figureTypeBrief(s.type) : "示意图"}来处理。`);
|
|
77
|
+
}
|
|
78
|
+
userLines.push("接下来这一步不需要你操作,我处理完会把图给你看。");
|
|
79
|
+
steps.push({ cmd: r.cmd, why: r.why });
|
|
80
|
+
steps.push({ cmd: "pf plan", why: "每做完一步重新跑计划,进度和下一步会跟着更新" });
|
|
81
|
+
return { stage: r.stage, userLines, steps };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---- 3) 还没图:真实章节选项 + 需求三问 ----
|
|
85
|
+
// 🔴 判断层归 AI(2026-09-22 去写死):下面的问题卡与图型建议只是底稿——
|
|
86
|
+
// 关键词路由覆盖不到的语言/说法,AI 必须自己用 pf types 的选型决策来判断图型;
|
|
87
|
+
// 问题卡要用用户的语言转述(多语言用户的用户卡不是中文)。
|
|
88
|
+
const opts = sectionOptions(blocks);
|
|
89
|
+
const s = suggestFigureType(goal);
|
|
90
|
+
userLines.push("[向用户转述时用用户的语言,下面只是底稿]");
|
|
91
|
+
userLines.push("给论文配图前,先确认三件事(直接回复,不用专业术语):");
|
|
92
|
+
userLines.push("① 图配在哪个部分?回复编号即可(0=说不清/整篇都行,我读全文后挑最值得画的部分给你确认):" + (opts.length
|
|
93
|
+
? opts.map((o) => `${o.n}=${o.title}`).join(";") + "。"
|
|
94
|
+
: "(没读到章节标题——把大概位置告诉我,比如\"第三章那一段\")"));
|
|
95
|
+
userLines.push("② 这张图最想让读者记住什么?一句话就行。");
|
|
96
|
+
userLines.push("③ 图里必须出现的名字/术语有哪些?说不清就跳过,我会从原文里提取给你确认。");
|
|
97
|
+
if (s) {
|
|
98
|
+
userLines.push(`(插件按关键词猜了「${figureTypeBrief(s.type)}」——只是参考,你读原文后用 pf types 的选型决策自己定图型,猜测可以推翻)`);
|
|
99
|
+
}
|
|
100
|
+
if (s?.note === "data") {
|
|
101
|
+
userLines.push("⚠️ 你要的是数据结果图:精确数值的柱/线/热图我不会用 AI 重画(数字必错),我会先看你的绘图脚本,引导改样式;纯示意图才走 AI 出图。");
|
|
102
|
+
}
|
|
103
|
+
// 价格口径唯一来源(2026-09-22 子智能体实测:AI 自己报价报错——草稿说成 0.15):
|
|
104
|
+
// 问题卡直接带上真实价格,AI 照抄即可
|
|
105
|
+
userLines.push("费用参考:草稿约 $0.02/张(可反复调),定稿约 $0.15/张(确认满意后才出);先出草稿给你看。");
|
|
106
|
+
steps.push({ cmd: `pf distill --at "<用户选中的章节 §路径>"`, why: "从原文抽候选实体/阶段句/数据句(任何语言都支持),产出用户确认卡;候选是统计性的,逐个核对原文出处;用户回 0 = 你用 pf doc outline + pf doc data 自己挑最值得画的章节,挑完仍要走确认卡" });
|
|
107
|
+
steps.push({ cmd: "(把确认卡发给用户,等确认)", why: "渲染要花钱,先让用户确认要素和顺序" });
|
|
108
|
+
steps.push({ cmd: `pf craft --at "<章节>" --figure-type <你定的图型,pf types 查选型> … --out <名字>.txt`, why: "按确认过的素材拼合规提示词;质量门会挡空泛输入" });
|
|
109
|
+
steps.push({ cmd: `pf render --at "<章节>" --model standard --prompt-file <名字>.txt`, why: "standard 草稿先出图(~$0.02),premium 定稿要等审批通过" });
|
|
110
|
+
steps.push({ cmd: "pf plan", why: "图出来后计划会路由到核验(pf qa)环节" });
|
|
111
|
+
return { stage: "interview", userLines, steps };
|
|
112
|
+
}
|
package/src/png-trim.mjs
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// png-trim.mjs — 白边自动裁剪(零依赖,Node 内置 zlib)
|
|
2
|
+
//
|
|
3
|
+
// 🔴 2026-09-21 V14 复盘:画布句命令"内容带撑满画布高度"premium 也不听(惯性构图,
|
|
4
|
+
// 内容带仍只占 ~46% 高度,上下空白 >50%)。措辞路线到头了 —— 留白改由**确定性后处理保证**:
|
|
5
|
+
// 解码 PNG → 扫描非白像素求内容包围盒 → 留白面积超阈值就裁到 bbox+窄边距 → 重编码回写。
|
|
6
|
+
// 任何一步异常都保留原图跳过(宁可多留白,不可损坏图)。
|
|
7
|
+
//
|
|
8
|
+
// 适用:color type 2 (RGB) / 6 (RGBA),bit depth 8,非隔行 —— premium/standard 产出均如此。
|
|
9
|
+
|
|
10
|
+
import zlib from "node:zlib";
|
|
11
|
+
|
|
12
|
+
const CHUNK_TYPE = (buf, off) => buf.toString("ascii", off, off + 4);
|
|
13
|
+
|
|
14
|
+
// ---------- 解码(8-bit RGB/RGBA 非隔行)----------
|
|
15
|
+
export function decodePng(buf) {
|
|
16
|
+
if (buf.readUInt32BE(0) !== 0x89504e47) throw new Error("not a PNG");
|
|
17
|
+
let off = 8;
|
|
18
|
+
let width = 0, height = 0, bitDepth = 0, colorType = 0, interlace = 0;
|
|
19
|
+
const idat = [];
|
|
20
|
+
while (off < buf.length) {
|
|
21
|
+
const len = buf.readUInt32BE(off);
|
|
22
|
+
const type = CHUNK_TYPE(buf, off + 4);
|
|
23
|
+
const data = buf.subarray(off + 8, off + 8 + len);
|
|
24
|
+
if (type === "IHDR") {
|
|
25
|
+
width = data.readUInt32BE(0);
|
|
26
|
+
height = data.readUInt32BE(4);
|
|
27
|
+
bitDepth = data[8];
|
|
28
|
+
colorType = data[9];
|
|
29
|
+
interlace = data[12];
|
|
30
|
+
} else if (type === "IDAT") {
|
|
31
|
+
idat.push(data);
|
|
32
|
+
} else if (type === "IEND") break;
|
|
33
|
+
off += 12 + len;
|
|
34
|
+
}
|
|
35
|
+
if (bitDepth !== 8 || (colorType !== 2 && colorType !== 6) || interlace !== 0) {
|
|
36
|
+
throw new Error(`unsupported PNG: depth=${bitDepth} color=${colorType} interlace=${interlace}`);
|
|
37
|
+
}
|
|
38
|
+
const ch = colorType === 6 ? 4 : 3;
|
|
39
|
+
const raw = zlib.inflateSync(Buffer.concat(idat));
|
|
40
|
+
const stride = width * ch;
|
|
41
|
+
const out = Buffer.alloc(width * height * ch);
|
|
42
|
+
// 反滤波(None/Sub/Up/Average/Paeth)
|
|
43
|
+
let prev = Buffer.alloc(stride);
|
|
44
|
+
for (let y = 0; y < height; y++) {
|
|
45
|
+
const f = raw[y * (stride + 1)];
|
|
46
|
+
const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
|
|
47
|
+
const cur = out.subarray(y * stride, (y + 1) * stride);
|
|
48
|
+
for (let x = 0; x < stride; x++) {
|
|
49
|
+
const a = x >= ch ? cur[x - ch] : 0;
|
|
50
|
+
const b = prev[x];
|
|
51
|
+
const c = x >= ch ? prev[x - ch] : 0;
|
|
52
|
+
let v = line[x];
|
|
53
|
+
if (f === 1) v += a;
|
|
54
|
+
else if (f === 2) v += b;
|
|
55
|
+
else if (f === 3) v += (a + b) >> 1;
|
|
56
|
+
else if (f === 4) {
|
|
57
|
+
const p = a + b - c;
|
|
58
|
+
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
|
59
|
+
v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
60
|
+
}
|
|
61
|
+
cur[x] = v & 0xff;
|
|
62
|
+
}
|
|
63
|
+
prev = cur;
|
|
64
|
+
}
|
|
65
|
+
return { width, height, ch, pixels: out };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------- 内容包围盒(非白 = 任一色通道 <250;透明也算内容外?不——全白背景不透明)----------
|
|
69
|
+
export function contentBbox({ width, height, ch, pixels }) {
|
|
70
|
+
let top = height, bottom = -1, left = width, right = -1;
|
|
71
|
+
for (let y = 0; y < height; y++) {
|
|
72
|
+
for (let x = 0; x < width; x++) {
|
|
73
|
+
const i = (y * width + x) * ch;
|
|
74
|
+
const nonWhite = pixels[i] < 250 || pixels[i + 1] < 250 || pixels[i + 2] < 250;
|
|
75
|
+
if (nonWhite) {
|
|
76
|
+
if (y < top) top = y;
|
|
77
|
+
if (y > bottom) bottom = y;
|
|
78
|
+
if (x < left) left = x;
|
|
79
|
+
if (x > right) right = x;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (bottom < 0) return null;
|
|
84
|
+
return { top, bottom, left, right };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------- 编码(filter 0 每行,RGB 输出)----------
|
|
88
|
+
export function encodePng({ width, height, ch, pixels }) {
|
|
89
|
+
const stride = width * 3;
|
|
90
|
+
const raw = Buffer.alloc((stride + 1) * height);
|
|
91
|
+
for (let y = 0; y < height; y++) {
|
|
92
|
+
raw[y * (stride + 1)] = 0; // filter None
|
|
93
|
+
for (let x = 0; x < width; x++) {
|
|
94
|
+
const si = (y * width + x) * ch;
|
|
95
|
+
const di = y * (stride + 1) + 1 + x * 3;
|
|
96
|
+
raw[di] = pixels[si];
|
|
97
|
+
raw[di + 1] = pixels[si + 1];
|
|
98
|
+
raw[di + 2] = pixels[si + 2];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const crcTable = (() => {
|
|
102
|
+
const t = new Int32Array(256);
|
|
103
|
+
for (let n = 0; n < 256; n++) {
|
|
104
|
+
let c = n;
|
|
105
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
106
|
+
t[n] = c;
|
|
107
|
+
}
|
|
108
|
+
return t;
|
|
109
|
+
})();
|
|
110
|
+
const crc32 = (b) => {
|
|
111
|
+
let c = 0xffffffff;
|
|
112
|
+
for (const v of b) c = crcTable[(c ^ v) & 0xff] ^ (c >>> 8);
|
|
113
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
114
|
+
};
|
|
115
|
+
const chunk = (type, data) => {
|
|
116
|
+
const head = Buffer.alloc(8);
|
|
117
|
+
head.writeUInt32BE(data.length, 0);
|
|
118
|
+
head.write(type, 4, "ascii");
|
|
119
|
+
const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
|
|
120
|
+
const crc = Buffer.alloc(4);
|
|
121
|
+
crc.writeUInt32BE(crc32(body), 0);
|
|
122
|
+
return Buffer.concat([head, data, crc]);
|
|
123
|
+
};
|
|
124
|
+
const ihdr = Buffer.alloc(13);
|
|
125
|
+
ihdr.writeUInt32BE(width, 0);
|
|
126
|
+
ihdr.writeUInt32BE(height, 4);
|
|
127
|
+
ihdr[8] = 8; ihdr[9] = 2; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
|
|
128
|
+
return Buffer.concat([
|
|
129
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
130
|
+
chunk("IHDR", ihdr),
|
|
131
|
+
chunk("IDAT", zlib.deflateSync(raw, { level: 6 })),
|
|
132
|
+
chunk("IEND", Buffer.alloc(0)),
|
|
133
|
+
]);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------- 量化体检(2026-09-21 定规:核验数字由插件算,AI 只做语义判断)----------
|
|
137
|
+
// 留白占比 / 边缘带白度(纸张斑纹高发区)/ 彩色像素占比(黑白线稿风回归检测)
|
|
138
|
+
export function pngStats(buf, { step = 4 } = {}) {
|
|
139
|
+
try {
|
|
140
|
+
const img = decodePng(buf);
|
|
141
|
+
const bbox = contentBbox(img);
|
|
142
|
+
if (!bbox) return null;
|
|
143
|
+
const cw = bbox.right - bbox.left + 1;
|
|
144
|
+
const chh = bbox.bottom - bbox.top + 1;
|
|
145
|
+
const wastePct = Number((100 * (1 - (cw * chh) / (img.width * img.height))).toFixed(1));
|
|
146
|
+
|
|
147
|
+
// 边缘带(外圈 1.5%)白度:背景不是纯白(纸张纹理/斑驳底)在这里最明显
|
|
148
|
+
const band = Math.max(4, Math.round(Math.min(img.width, img.height) * 0.015));
|
|
149
|
+
let edgeTotal = 0, edgeWhite = 0;
|
|
150
|
+
const isWhite = (i) => img.pixels[i] >= 248 && img.pixels[i + 1] >= 248 && img.pixels[i + 2] >= 248;
|
|
151
|
+
for (let y = 0; y < img.height; y += 2) {
|
|
152
|
+
for (let x = 0; x < img.width; x += 2) {
|
|
153
|
+
const inBand = x < band || x >= img.width - band || y < band || y >= img.height - band;
|
|
154
|
+
if (!inBand) continue;
|
|
155
|
+
edgeTotal++;
|
|
156
|
+
if (isWhite((y * img.width + x) * img.ch)) edgeWhite++;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 彩色像素占比(下采样):饱和度 max-min>32 视为彩色。黑白线稿风的图显著偏低
|
|
161
|
+
let total = 0, sat = 0;
|
|
162
|
+
for (let y = 0; y < img.height; y += step) {
|
|
163
|
+
for (let x = 0; x < img.width; x += step) {
|
|
164
|
+
const i = (y * img.width + x) * img.ch;
|
|
165
|
+
total++;
|
|
166
|
+
const mx = Math.max(img.pixels[i], img.pixels[i + 1], img.pixels[i + 2]);
|
|
167
|
+
const mn = Math.min(img.pixels[i], img.pixels[i + 1], img.pixels[i + 2]);
|
|
168
|
+
if (mx - mn > 32) sat++;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
w: img.width, h: img.height,
|
|
173
|
+
wastePct,
|
|
174
|
+
edgeWhitePct: Number((100 * edgeWhite / Math.max(1, edgeTotal)).toFixed(1)),
|
|
175
|
+
satPct: Number((100 * sat / Math.max(1, total)).toFixed(1)),
|
|
176
|
+
};
|
|
177
|
+
} catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// ---------- 裁剪主入口:留白面积占比 >maxWaste 才裁,边距留 ratio(占裁后短边比)----------
|
|
182
|
+
// 返回 { buf, from:{w,h}, to:{w,h}, savedPct } 或 null(不值得裁/出错)
|
|
183
|
+
export function trimWhitespace(pngBuf, { maxWaste = 0.3, marginRatio = 0.03 } = {}) {
|
|
184
|
+
try {
|
|
185
|
+
const img = decodePng(pngBuf);
|
|
186
|
+
const bbox = contentBbox(img);
|
|
187
|
+
if (!bbox) return null;
|
|
188
|
+
const cw = bbox.right - bbox.left + 1;
|
|
189
|
+
const chh = bbox.bottom - bbox.top + 1;
|
|
190
|
+
const waste = 1 - (cw * chh) / (img.width * img.height);
|
|
191
|
+
if (waste <= maxWaste) return null;
|
|
192
|
+
const margin = Math.max(8, Math.round(Math.min(cw, chh) * marginRatio));
|
|
193
|
+
const left = Math.max(0, bbox.left - margin);
|
|
194
|
+
const top = Math.max(0, bbox.top - margin);
|
|
195
|
+
const right = Math.min(img.width - 1, bbox.right + margin);
|
|
196
|
+
const bottom = Math.min(img.height - 1, bbox.bottom + margin);
|
|
197
|
+
const w2 = right - left + 1;
|
|
198
|
+
const h2 = bottom - top + 1;
|
|
199
|
+
const cropped = Buffer.alloc(w2 * h2 * img.ch);
|
|
200
|
+
for (let y = 0; y < h2; y++) {
|
|
201
|
+
img.pixels.copy(
|
|
202
|
+
cropped,
|
|
203
|
+
y * w2 * img.ch,
|
|
204
|
+
((top + y) * img.width + left) * img.ch,
|
|
205
|
+
((top + y) * img.width + right + 1) * img.ch
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
buf: encodePng({ width: w2, height: h2, ch: img.ch, pixels: cropped }),
|
|
210
|
+
from: { w: img.width, h: img.height },
|
|
211
|
+
to: { w: w2, h: h2 },
|
|
212
|
+
savedPct: Number((100 * (1 - (w2 * h2) / (img.width * img.height))).toFixed(1)),
|
|
213
|
+
};
|
|
214
|
+
} catch {
|
|
215
|
+
return null; // 任何异常:保留原图
|
|
216
|
+
}
|
|
217
|
+
}
|