abelien-harness 0.6.2 → 0.7.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 +1 -1
- package/package.json +1 -1
- package/skills/abelien-appearance/SKILL.md +1 -0
- package/skills/abelien-clarify/SKILL.md +11 -9
- package/skills/abelien-clarify/references/examples.md +3 -3
- package/skills/abelien-clarify/scripts/check_result.py +3 -2
- package/skills/abelien-clarify/scripts/test_check_result.py +13 -0
- package/skills/abelien-guide/SKILL.md +1 -1
- package/skills/abelien-hardware/SKILL.md +2 -2
- package/skills/abelien-hardware/domain.json +1 -1
- package/skills/abelien-hardware/references/hardware-selection/SKILL.md +1 -1
- package/skills/abelien-hardware/references/hardware-selection/references/query-contract.md +6 -4
- package/skills/abelien-hardware/references/planning-modes.md +1 -1
- package/skills/abelien-hardware/references/search-index-format.md +2 -2
- package/skills/abelien-hardware/scripts/dimensions.mjs +13 -8
- package/skills/abelien-hardware-selection/references/hardware-selection-v2/SKILL.md +1 -1
- package/skills/abelien-hardware-selection/references/hardware-selection-v2/references/query-contract.md +6 -4
- package/skills/abelien-hardware-selection/references/selection-service-contract.md +2 -2
- package/skills/abelien-harness/references/adapter-contract.md +1 -0
- package/skills/abelien-harness/scripts/ab.mjs +6 -0
- package/skills/abelien-harness/scripts/web/public-read.mjs +41 -10
- package/skills/abelien-reference/SKILL.md +9 -6
- package/skills/abelien-reference/domain.json +4 -3
- package/skills/abelien-reference/fixtures/create.final.json +22 -2
- package/skills/abelien-reference/fixtures/restore.collected.json +26 -3
- package/skills/abelien-reference/fixtures/restore.final.json +32 -7
- package/skills/abelien-reference/references/research-contract.md +32 -15
- package/skills/abelien-reference/schemas/reference-evidence.v1.json +26 -7
- package/skills/abelien-reference/schemas/reference-judgement.v1.json +6 -6
- package/skills/abelien-reference/scripts/evidence.mjs +28 -7
- package/skills/abelien-reference/scripts/finalize.mjs +60 -14
- package/skills/abelien-reference/scripts/image-format.mjs +87 -0
- package/skills/abelien-reference/scripts/measure.mjs +4 -2
- package/skills/abelien-reference/scripts/png.mjs +20 -4
- package/skills/abelien-reference/scripts/research.mjs +169 -50
- package/skills/abelien-structure/SKILL.md +2 -2
- package/skills/abelien-structure/domain.json +1 -1
- package/skills/abelien-structure/references/build123d-execution.md +2 -2
- package/skills/abelien-structure/references/handoff.md +1 -1
- package/skills/abelien-structure/worker/cli-adapter.mjs +56 -13
- package/skills/abelien-structure/worker/main.mjs +15 -4
- package/skills/abelien-structure/worker/supervisor.mjs +6 -2
- package/skills/abelien-workspace/SKILL.md +1 -1
- package/skills/abelien-workspace/assets/journey-stage-plan.json +21 -15
- package/skills/abelien-workspace/domain.json +1 -1
- package/skills/abelien-workspace/references/harness-binding.md +4 -1
- package/skills/abelien-workspace/references/user-journey.md +13 -10
- package/skills/abelien-workspace/scripts/journey-next.mjs +17 -5
- package/skills/abelien-workspace/workflows/definitions/748a70482fb2f775f6e5fd942c7873534cf3e59614c77381f812a9710d479743.json +1 -0
- package/skills/abelien-workspace/workflows/definitions/a1021c217f2894da423f8591842ea9f349bc526ac3119352b4bea02e8dbd42f9.json +1 -0
- package/skills/abelien-workspace/workflows/definitions/df147960a3f7c4d631f66a6b91a7891f8a37d25a50322cf09bdc9304195963b0.json +1 -0
- package/skills/abelien-workspace/workflows/user-journey-hardware-candidates.json +1 -1
- package/skills/abelien-workspace/workflows/user-journey.json +3 -3
|
@@ -1,12 +1,32 @@
|
|
|
1
1
|
// reference.finalize(P2-13.04):核验 Agent 对已收集证据的判断,生成最终的 reference-evidence。
|
|
2
|
-
// Agent
|
|
2
|
+
// Agent 只给判断与原文引用;引用必须逐字出现在已保存的页面正文里(规范化空白与全半角后比较,至少 8 字),
|
|
3
|
+
// 候选只能引用自己的页面,名称只有出现在引用里才替换标题;尺寸必须以带长度单位的数值出现在同一引用里;
|
|
4
|
+
// 规格值必须能在引用中定位(数值类要求数字与单位同在引用里),否则拒绝。
|
|
3
5
|
// 比例判定由测量值与规格尺寸确定性计算,不接受 Agent 自填的判定。
|
|
4
6
|
import { text, record, digest } from "../../abelien-harness/scripts/web/common.mjs";
|
|
5
7
|
import { compareRatio } from "./measure.mjs";
|
|
6
8
|
import { validateEvidence, invalid } from "./evidence.mjs";
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
+
// NFKC folds full-width digits, letters and punctuation to their half-width forms before whitespace is collapsed.
|
|
11
|
+
const normalize = value => value.normalize("NFKC").replace(/\s+/g, " ").trim();
|
|
12
|
+
export const QUOTE_MIN_CHARS = 8;
|
|
13
|
+
const LENGTH_MM = { mm: 1, "毫米": 1, cm: 10, "厘米": 10, m: 1000, "米": 1000, in: 25.4, inch: 25.4, inches: 25.4, "英寸": 25.4, '"': 25.4, "″": 25.4 };
|
|
14
|
+
const UNIT = /^\s?(毫米|厘米|英寸|毫安时|千克|公斤|毫升|赫兹|分钟|小时|米|克|瓦|伏|升|元|天|["″]|[A-Za-z%°µμ℃]+)/;
|
|
15
|
+
const CHAIN = /^\s*(?:[×xX*✕✖]|by)\s*\d/;
|
|
16
|
+
/**
|
|
17
|
+
* Numbers in a (normalised) text with the unit written right after each one. In a chain such as
|
|
18
|
+
* "118 × 118 × 215 mm" every number takes the unit written after the last one.
|
|
19
|
+
*/
|
|
20
|
+
function quantities(value) {
|
|
21
|
+
const out = [...value.matchAll(/\d+(?:\.\d+)?/g)].map(m => {
|
|
22
|
+
const rest = value.slice(m.index + m[0].length), chained = CHAIN.test(rest);
|
|
23
|
+
return { value: Number(m[0]), chained, unit: chained ? null : (UNIT.exec(rest)?.[1].toLowerCase() ?? null) };
|
|
24
|
+
});
|
|
25
|
+
for (let i = out.length - 2; i >= 0; i--) if (out[i].chained) out[i].unit = out[i + 1].unit;
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
const lengthsMm = value => quantities(value).filter(q => q.unit in LENGTH_MM).map(q => q.value * LENGTH_MM[q.unit]);
|
|
29
|
+
const sameLength = (a, b) => Math.abs(a - b) <= Math.max(0.05, b * 0.002);
|
|
10
30
|
const percent = value => `${Math.round(value * 1000) / 10}%`;
|
|
11
31
|
|
|
12
32
|
/** Allowed fields of the judgement, level by level; schemas/reference-judgement.v1.json states the same and a test keeps them equal. */
|
|
@@ -16,6 +36,24 @@ export const JUDGEMENT_FIELDS = Object.freeze({
|
|
|
16
36
|
similar: ["item_id", "decision", "borrowed", "why", "not_copied"], excluded: ["item_id", "reason"], spec: ["title", "value", "quote"],
|
|
17
37
|
});
|
|
18
38
|
|
|
39
|
+
/**
|
|
40
|
+
* A spec value is a source fact only if its quote shows it: the normalised value is a substring of the quote, or
|
|
41
|
+
* every number in it appears in the quote with the same unit (lengths compare after conversion to millimetres).
|
|
42
|
+
* Otherwise the whole judgement is refused; an unverified value is never written as quoted.
|
|
43
|
+
*/
|
|
44
|
+
function verifyValue(value, quoteText, label) {
|
|
45
|
+
const v = normalize(value), q = normalize(quoteText);
|
|
46
|
+
if (q.includes(v)) return;
|
|
47
|
+
const wanted = quantities(v);
|
|
48
|
+
if (!wanted.length) invalid(`${label}.value must appear in its quote`);
|
|
49
|
+
const found = quantities(q);
|
|
50
|
+
for (const item of wanted) {
|
|
51
|
+
const hit = found.some(f => item.unit in LENGTH_MM && f.unit in LENGTH_MM ? sameLength(f.value * LENGTH_MM[f.unit], item.value * LENGTH_MM[item.unit])
|
|
52
|
+
: f.value === item.value && (item.unit === null || f.unit === item.unit));
|
|
53
|
+
if (!hit) invalid(`${label}.value ${item.value}${item.unit ? ` ${item.unit}` : ""} must appear with its unit in its quote`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
19
57
|
function checkKeys(value, keys, label) { if (!record(value) || Object.keys(value).some(key => !keys.includes(key))) invalid(`${label} has unsupported fields`); }
|
|
20
58
|
function words(value, label, max = 300) { if (!text(value) || value.length > max) invalid(`${label} must be 1–${max} chars`); return value.trim(); }
|
|
21
59
|
|
|
@@ -35,6 +73,7 @@ export async function finalizeReference({ input, readResource, readBlob }) {
|
|
|
35
73
|
if (!page || page.status !== "read" || !page.text_blob_ref) invalid(`${label}.page_id must name a page that was read`);
|
|
36
74
|
if (!pageText.has(page.id)) pageText.set(page.id, normalize(String(readBlob(page.text_blob_ref)?.text ?? "")));
|
|
37
75
|
const quoted = words(quote.text, `${label}.text`, 500);
|
|
76
|
+
if ([...normalize(quoted)].length < QUOTE_MIN_CHARS) invalid(`${label}.text must be a quote of at least ${QUOTE_MIN_CHARS} characters`);
|
|
38
77
|
if (!pageText.get(page.id).includes(normalize(quoted))) invalid(`${label}.text must appear verbatim in page ${page.id}`);
|
|
39
78
|
return { page_id: page.id, text: quoted };
|
|
40
79
|
};
|
|
@@ -59,17 +98,22 @@ export async function finalizeReference({ input, readResource, readBlob }) {
|
|
|
59
98
|
const candidates = list.map((c, i) => {
|
|
60
99
|
const label = `identity.candidates[${i}]`;
|
|
61
100
|
checkKeys(c, JUDGEMENT_FIELDS.candidate, label);
|
|
62
|
-
itemOf(c.item_id, ["candidate"], `${label}.item_id`);
|
|
101
|
+
const item = itemOf(c.item_id, ["candidate"], `${label}.item_id`);
|
|
102
|
+
// A candidate stands only on its own page: a quote from another page cannot promote it, and an
|
|
103
|
+
// unread page (too_large, unavailable) has no quote and therefore cannot become a candidate.
|
|
104
|
+
if (record(c.quote) && c.quote.page_id !== c.item_id) invalid(`${label}.quote must come from the candidate's own page ${c.item_id}`);
|
|
105
|
+
if (item.status !== "ready") invalid(`${label}.item_id must name a candidate page that was read`);
|
|
63
106
|
// Dimensions are optional: a page may name the model without giving its size. The quote is always required.
|
|
64
107
|
const dims = c.dimensions_mm === undefined || c.dimensions_mm === null ? null : c.dimensions_mm;
|
|
65
108
|
const quote = quoteOn(c.quote, `${label}.quote`);
|
|
66
109
|
if (dims) {
|
|
67
110
|
checkKeys(dims, JUDGEMENT_FIELDS.dimensions, `${label}.dimensions_mm`);
|
|
68
|
-
const keys = ["width", "height", ...(dims.depth === undefined ? [] : ["depth"])], found =
|
|
111
|
+
const keys = ["width", "height", ...(dims.depth === undefined ? [] : ["depth"])], found = lengthsMm(normalize(quote.text));
|
|
69
112
|
for (const key of keys) if (!Number.isFinite(dims[key]) || dims[key] <= 0 || dims[key] > 5000) invalid(`${label}.dimensions_mm.${key} must be 0–5000 mm`);
|
|
70
|
-
for (const key of keys) if (!found.
|
|
113
|
+
for (const key of keys) if (!found.some(length => sameLength(length, dims[key]))) invalid(`${label}.quote must contain the ${key} value ${dims[key]} mm as a length with its unit (mm, cm, m, in)`);
|
|
71
114
|
}
|
|
72
|
-
|
|
115
|
+
const name = words(c.name, `${label}.name`, 120), nameInQuote = normalize(quote.text).toLowerCase().includes(normalize(name).toLowerCase());
|
|
116
|
+
return { item_id: c.item_id, name, name_in_quote: nameInQuote, display: nameInQuote ? name : item.title, dimensions_mm: dims ? { ...dims } : null, quote, ratio_check: compareRatio(measurement, dims) };
|
|
73
117
|
});
|
|
74
118
|
const chosen = input.identity.chosen_item_id;
|
|
75
119
|
if (chosen !== null && !candidates.some(c => c.item_id === chosen)) invalid("identity.chosen_item_id must name one of the candidates, or be null");
|
|
@@ -78,20 +122,21 @@ export async function finalizeReference({ input, readResource, readBlob }) {
|
|
|
78
122
|
else if (input.identity.unidentified_reason !== undefined) invalid("identity.unidentified_reason is only for chosen_item_id null");
|
|
79
123
|
const pick = candidates.find(c => c.item_id === chosen) ?? null;
|
|
80
124
|
let conflict = null;
|
|
81
|
-
if (pick?.ratio_check.status === "mismatch") conflict = `所选型号「${pick.
|
|
125
|
+
if (pick?.ratio_check.status === "mismatch") conflict = `所选型号「${pick.display}」与参考图比例不符(实测 ${pick.ratio_check.measured},规格 ${pick.ratio_check.spec})`;
|
|
82
126
|
else {
|
|
83
127
|
const others = candidates.filter(c => c !== pick && c.ratio_check.status === "consistent" && c.name !== pick?.name);
|
|
84
|
-
if (others.length && pick?.ratio_check.status !== "consistent") conflict = `另有比例一致的候选:${others.map(c => c.
|
|
128
|
+
if (others.length && pick?.ratio_check.status !== "consistent") conflict = `另有比例一致的候选:${others.map(c => c.display).join("、")}`;
|
|
85
129
|
}
|
|
86
130
|
if (pick?.ratio_check.status === "unknown") unknowns.push(`型号比例未核对(${pick.ratio_check.reason})`);
|
|
87
131
|
for (const c of candidates) {
|
|
88
132
|
const item = items.get(c.item_id), r = c.ratio_check;
|
|
89
133
|
const ratio = r.status === "unknown" ? `比例未核对(${r.reason})` : `实测高宽比 ${r.measured},规格 ${r.spec},相差 ${percent(r.relative_error)}`;
|
|
90
134
|
if (r.status === "mismatch") item.verdict = { code: "ratio_mismatch", basis: ratio };
|
|
91
|
-
else if (c.item_id === chosen) item.verdict = { code: "closest", basis: `选定为「${c.
|
|
92
|
-
|
|
135
|
+
else if (c.item_id === chosen) item.verdict = { code: "closest", basis: `选定为「${c.display}」;${ratio}` };
|
|
136
|
+
// Only a name the quote itself shows may replace the page title shown on the canvas.
|
|
137
|
+
if (c.name_in_quote) item.title = c.name;
|
|
93
138
|
}
|
|
94
|
-
identity = { candidates, chosen_item_id: chosen, conflict };
|
|
139
|
+
identity = { candidates: candidates.map(({ display, ...c }) => c), chosen_item_id: chosen, conflict };
|
|
95
140
|
}
|
|
96
141
|
|
|
97
142
|
const similar = (input.similar ?? []).map((s, i) => {
|
|
@@ -118,8 +163,9 @@ export async function finalizeReference({ input, readResource, readBlob }) {
|
|
|
118
163
|
if (input.specs !== undefined && (!Array.isArray(input.specs) || input.specs.length > 12)) invalid("specs must be up to 12 entries");
|
|
119
164
|
const specs = (input.specs ?? []).map((s, i) => {
|
|
120
165
|
checkKeys(s, JUDGEMENT_FIELDS.spec, `specs[${i}]`);
|
|
121
|
-
const quote = quoteOn(s.quote, `specs[${i}].quote`), page = pages.get(quote.page_id);
|
|
122
|
-
|
|
166
|
+
const quote = quoteOn(s.quote, `specs[${i}].quote`), page = pages.get(quote.page_id), value = words(s.value, `specs[${i}].value`, 200);
|
|
167
|
+
verifyValue(value, quote.text, `specs[${i}]`);
|
|
168
|
+
return { id: `sp${i + 1}`, kind: "spec", title: words(s.title, `specs[${i}].title`, 80), role: value,
|
|
123
169
|
source: { url: page.final_url ?? page.url, site: items.get(page.id)?.source?.site ?? "" }, image: null, verdict: null, status: "ready", query: null, quote };
|
|
124
170
|
});
|
|
125
171
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// 参考图片的格式与统一转换(P2-13.06):格式只按字节识别,不信响应头(E 新创验收:CSDN 的 JPEG 被标成 image/png,
|
|
2
|
+
// 宿主私有存储按格式门禁拒收)。宿主可提供转换器,把任何能解码的图片统一转成 PNG;保存的字节即图片 id(sha256)的来源。
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { isAbsolute } from "node:path";
|
|
6
|
+
import { Buffer } from "node:buffer";
|
|
7
|
+
import { failure } from "../../abelien-harness/scripts/web/common.mjs";
|
|
8
|
+
import { isPng, imageSize } from "./png.mjs";
|
|
9
|
+
|
|
10
|
+
/** Formats a host can store and a browser can show as they are; any other format needs the host's converter. */
|
|
11
|
+
export const PASS_THROUGH = Object.freeze(["image/png", "image/jpeg", "image/webp"]);
|
|
12
|
+
/** Absolute path of the host's converter: an executable, or a Node script (.mjs/.cjs/.js) run with this Node. */
|
|
13
|
+
export const CONVERTER_ENV = "ABELIEN_REFERENCE_IMAGE_CONVERTER";
|
|
14
|
+
|
|
15
|
+
const AVIF_BRANDS = new Set(["avif", "avis"]);
|
|
16
|
+
const HEIF_BRANDS = new Set(["heic", "heix", "hevc", "hevx", "heim", "heis", "hevm", "hevs", "mif1", "msf1"]);
|
|
17
|
+
const SVG_HEAD = /^(?:<\?xml[^>]*>\s*)?(?:<!--[\s\S]*?-->\s*)*(?:<!DOCTYPE[^>]*>\s*)?(?:<!--[\s\S]*?-->\s*)*<svg[\s>]/i;
|
|
18
|
+
|
|
19
|
+
/** The image format the bytes actually are, or null when they are not an image this module knows. */
|
|
20
|
+
export function sniffImage(bytes) {
|
|
21
|
+
if (!Buffer.isBuffer(bytes) || bytes.length < 12) return null;
|
|
22
|
+
if (isPng(bytes)) return "image/png";
|
|
23
|
+
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
|
|
24
|
+
const head = bytes.toString("latin1", 0, 12);
|
|
25
|
+
if (head.startsWith("GIF87a") || head.startsWith("GIF89a")) return "image/gif";
|
|
26
|
+
if (head.startsWith("RIFF") && head.slice(8, 12) === "WEBP") return "image/webp";
|
|
27
|
+
if (head.slice(4, 8) === "ftyp") {
|
|
28
|
+
const size = Math.min(bytes.readUInt32BE(0), bytes.length), brands = [head.slice(8, 12)];
|
|
29
|
+
for (let at = 16; at + 4 <= size; at += 4) brands.push(bytes.toString("latin1", at, at + 4));
|
|
30
|
+
if (brands.some(brand => AVIF_BRANDS.has(brand))) return "image/avif";
|
|
31
|
+
if (brands.some(brand => HEIF_BRANDS.has(brand))) return "image/heic";
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
if (head.startsWith("BM") && bytes.readUInt32LE(2) <= bytes.length + 1024 && bytes.length >= 26) return "image/bmp";
|
|
35
|
+
if (head.startsWith("II*\0") || head.startsWith("MM\0*")) return "image/tiff";
|
|
36
|
+
if (bytes.readUInt32BE(0) === 0x00000100 && bytes.readUInt16LE(4) > 0) return "image/x-icon";
|
|
37
|
+
const textHead = bytes.toString("utf8", 0, Math.min(bytes.length, 4096)).replace(/^/, "").trimStart();
|
|
38
|
+
return SVG_HEAD.test(textHead) ? "image/svg+xml" : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The host's converter as a function, or null when the host provides none. Protocol: the source bytes on stdin;
|
|
43
|
+
* arguments `--max-side <px> --max-bytes <n>`; a PNG on stdout no larger than either limit (auto-rotated, first frame);
|
|
44
|
+
* a non-zero exit means the image cannot be converted. A configured path that is not absolute or does not exist fails
|
|
45
|
+
* the run before any request, like a missing search key.
|
|
46
|
+
*/
|
|
47
|
+
export function hostConverter(env = {}) {
|
|
48
|
+
const path = env[CONVERTER_ENV];
|
|
49
|
+
if (path === undefined || path === "") return null;
|
|
50
|
+
if (typeof path !== "string" || !isAbsolute(path) || !existsSync(path)) throw failure("REFERENCE_WEB_CONFIG", `${CONVERTER_ENV} must be the absolute path of an existing converter`);
|
|
51
|
+
const script = /\.(?:mjs|cjs|js)$/.test(path);
|
|
52
|
+
return (bytes, { maxSide, maxBytes, timeoutMs }) => new Promise(resolve => {
|
|
53
|
+
const child = spawn(script ? process.execPath : path, [...(script ? [path] : []), "--max-side", String(maxSide), "--max-bytes", String(maxBytes)],
|
|
54
|
+
{ stdio: ["pipe", "pipe", "ignore"], env: { PATH: env.PATH ?? process.env.PATH ?? "" } });
|
|
55
|
+
const chunks = []; let size = 0, settled = false;
|
|
56
|
+
const finish = result => { if (settled) return; settled = true; clearTimeout(timer); child.kill("SIGKILL"); resolve(result); };
|
|
57
|
+
const timer = setTimeout(() => finish({ ok: false, reason: "convert_timeout" }), timeoutMs);
|
|
58
|
+
child.stdout.on("data", chunk => {
|
|
59
|
+
size += chunk.length;
|
|
60
|
+
if (size > maxBytes) finish({ ok: false, reason: "convert_too_large" }); else chunks.push(chunk);
|
|
61
|
+
});
|
|
62
|
+
child.on("error", () => finish({ ok: false, reason: "convert_failed" }));
|
|
63
|
+
child.on("close", code => finish(code === 0 ? { ok: true, bytes: Buffer.concat(chunks) } : { ok: false, reason: "convert_failed" }));
|
|
64
|
+
child.stdin.on("error", () => { /* the converter may exit before reading everything; its exit code decides */ });
|
|
65
|
+
child.stdin.end(bytes);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One fetched image made ready to save. With a converter every format becomes a PNG within the limits; without one,
|
|
71
|
+
* only PNG, JPEG and WebP pass through unchanged. Returns {bytes, media_type, size} or {reason}.
|
|
72
|
+
*/
|
|
73
|
+
export async function prepareImage(raw, { convert, maxSide, maxBytes, timeoutMs }) {
|
|
74
|
+
const source = sniffImage(raw);
|
|
75
|
+
if (!source) return { reason: "not_image" };
|
|
76
|
+
let bytes = raw, mediaType = source;
|
|
77
|
+
if (convert) {
|
|
78
|
+
const converted = await convert(raw, { maxSide, maxBytes, timeoutMs });
|
|
79
|
+
if (!converted.ok) return { reason: converted.reason, source };
|
|
80
|
+
if (!isPng(converted.bytes)) return { reason: "convert_invalid", source };
|
|
81
|
+
bytes = converted.bytes; mediaType = "image/png";
|
|
82
|
+
} else if (!PASS_THROUGH.includes(source)) return { reason: "needs_converter", source };
|
|
83
|
+
const size = imageSize(bytes);
|
|
84
|
+
if (!size || !size.width || !size.height) return { reason: "unreadable", source };
|
|
85
|
+
if (bytes.length > maxBytes || (convert && Math.max(size.width, size.height) > maxSide)) return { reason: convert ? "convert_invalid" : "too_large", source };
|
|
86
|
+
return { bytes, media_type: mediaType, source, size };
|
|
87
|
+
}
|
|
@@ -41,9 +41,11 @@ export function measureSilhouette({ width, height, rgba }) {
|
|
|
41
41
|
aspect_h_over_w: Math.round(h / w * 10000) / 10000, background };
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
/** options.maxPixels lowers the pixel cap (never above PNG_MAX_PIXELS); a larger image is "png_too_large", not decoded. */
|
|
45
|
+
export function measurePng(bytes, options = {}) {
|
|
45
46
|
let image;
|
|
46
|
-
|
|
47
|
+
// decodePng reports every failure (including zlib limits) as PNG_UNSUPPORTED; that image is simply not measurable.
|
|
48
|
+
try { image = decodePng(bytes, options); } catch (error) { return { status: "unknown", reason: `png_${error?.code === "PNG_UNSUPPORTED" ? error.reason : "corrupt"}`, method: MEASURE_METHOD }; }
|
|
47
49
|
return measureSilhouette(image);
|
|
48
50
|
}
|
|
49
51
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// 零依赖 PNG 解码(P2-13.04):只用 Node 自带 zlib。支持非隔行的灰度、RGB、调色板、灰度+透明、RGBA,
|
|
2
2
|
// 位深按 PNG 规范(16 位取高字节);含 tRNS 透明。隔行(Adam7)与超大图明确拒绝,由调用方记为未测量,不编造。
|
|
3
|
+
// 解压按 IHDR 算出的确切长度设上限(防解压炸弹),解压结果长度必须恰好等于该值;任何异常都归入 PngError。
|
|
3
4
|
import { inflateSync } from "node:zlib";
|
|
4
5
|
|
|
5
6
|
const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
@@ -13,7 +14,15 @@ const DEPTHS = { 0: [1, 2, 4, 8, 16], 2: [8, 16], 3: [1, 2, 4, 8], 4: [8, 16], 6
|
|
|
13
14
|
function paeth(a, b, c) { const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c); return pa <= pb && pa <= pc ? a : pb <= pc ? b : c; }
|
|
14
15
|
|
|
15
16
|
/** Returns { width, height, rgba } with 8-bit RGBA samples. Throws PngError with a stable reason. */
|
|
16
|
-
export function decodePng(bytes) {
|
|
17
|
+
export function decodePng(bytes, { maxPixels = PNG_MAX_PIXELS } = {}) {
|
|
18
|
+
try { return decode(bytes, maxPixels); } catch (error) {
|
|
19
|
+
if (error instanceof PngError) throw error;
|
|
20
|
+
// zlib, RangeError or a malformed chunk read: the image is not measurable; never let it escape.
|
|
21
|
+
throw new PngError(error?.code === "ERR_BUFFER_TOO_LARGE" || error instanceof RangeError ? "too_large" : "corrupt", `PNG cannot be decoded: ${error?.message ?? error}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function decode(bytes, maxPixels) {
|
|
17
26
|
if (!isPng(bytes)) throw new PngError("not_png", "not a PNG file");
|
|
18
27
|
let offset = 8, header = null; const idat = []; let palette = null, trns = null;
|
|
19
28
|
while (offset + 8 <= bytes.length) {
|
|
@@ -33,11 +42,18 @@ export function decodePng(bytes) {
|
|
|
33
42
|
const { width, height, depth, color, interlace } = header;
|
|
34
43
|
if (!CHANNELS[color] || !DEPTHS[color].includes(depth)) throw new PngError("unsupported_format", `PNG color type ${color} depth ${depth} is not supported`);
|
|
35
44
|
if (interlace !== 0) throw new PngError("interlaced", "interlaced PNG is not supported");
|
|
36
|
-
if (!width || !height || width * height > PNG_MAX_PIXELS) throw new PngError("too_large", "PNG dimensions are out of range");
|
|
45
|
+
if (!width || !height || width * height > Math.min(maxPixels, PNG_MAX_PIXELS)) throw new PngError("too_large", "PNG dimensions are out of range");
|
|
37
46
|
if (color === 3 && !palette) throw new PngError("truncated", "palette PNG has no PLTE");
|
|
38
|
-
const raw = inflateSync(Buffer.concat(idat));
|
|
39
47
|
const bitsPerPixel = CHANNELS[color] * depth, stride = Math.ceil(width * bitsPerPixel / 8), bpp = Math.max(1, bitsPerPixel >> 3);
|
|
40
|
-
|
|
48
|
+
const expected = height * (stride + 1);
|
|
49
|
+
let raw;
|
|
50
|
+
try { raw = inflateSync(Buffer.concat(idat), { maxOutputLength: expected }); }
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error?.code === "ERR_BUFFER_TOO_LARGE" || error instanceof RangeError) throw new PngError("too_large", "PNG image data inflates beyond its declared size");
|
|
53
|
+
throw new PngError(error?.code === "Z_BUF_ERROR" ? "truncated" : "corrupt", "PNG image data is not a valid zlib stream");
|
|
54
|
+
}
|
|
55
|
+
if (raw.length < expected) throw new PngError("truncated", "PNG image data is truncated");
|
|
56
|
+
if (raw.length !== expected) throw new PngError("too_large", "PNG image data is longer than its declared size");
|
|
41
57
|
const lines = Buffer.alloc(height * stride);
|
|
42
58
|
for (let y = 0; y < height; y++) {
|
|
43
59
|
const filter = raw[y * (stride + 1)], src = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)), row = lines.subarray(y * stride, (y + 1) * stride);
|