@akairos/pipeline 0.20.1 → 0.21.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/cover-snapshot.ts +2 -0
- package/package.json +5 -4
- package/src/commands/render.ts +3 -0
- package/src/cover-snapshot.test.ts +43 -0
- package/src/cover-snapshot.ts +70 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akairos/pipeline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"files": [
|
|
14
14
|
"src/",
|
|
15
15
|
"article-style.ts",
|
|
16
|
+
"cover-snapshot.ts",
|
|
16
17
|
"ARTICLE-THEMES.md",
|
|
17
18
|
"THIRD-PARTY-NOTICES.md"
|
|
18
19
|
],
|
|
@@ -29,12 +30,12 @@
|
|
|
29
30
|
},
|
|
30
31
|
"dependencies": {
|
|
31
32
|
"@akairos/milkdown-article-plugins": "^0.1.6",
|
|
32
|
-
"@akairos/
|
|
33
|
+
"@akairos/myno": "0.3.2",
|
|
34
|
+
"@akairos/typeset-studio": "0.3.0",
|
|
33
35
|
"@chenglou/pretext": "0.0.8",
|
|
34
36
|
"image-size": "^2.0.2",
|
|
35
37
|
"pngjs": "^7.0.0",
|
|
36
|
-
"zod": "^4.4.2"
|
|
37
|
-
"@akairos/myno": "0.3.1"
|
|
38
|
+
"zod": "^4.4.2"
|
|
38
39
|
},
|
|
39
40
|
"optionalDependencies": {
|
|
40
41
|
"@napi-rs/canvas": "^0.1.97",
|
package/src/commands/render.ts
CHANGED
|
@@ -256,6 +256,9 @@ Options:
|
|
|
256
256
|
|
|
257
257
|
try {
|
|
258
258
|
const imageRenderer = await resolveImageRenderer(config);
|
|
259
|
+
if (process.env.ZZHUB_PIPELINE_COVER_SNAPSHOT === "1" && imageRenderer.name !== "typeset-studio") {
|
|
260
|
+
throw new Error("Cover snapshot requires the Typeset Studio image renderer");
|
|
261
|
+
}
|
|
259
262
|
reportProgress({ stage: "render.adapter", message: "开始生成图片" });
|
|
260
263
|
|
|
261
264
|
const bodyImages = (
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { createCanvas } from "@napi-rs/canvas";
|
|
4
|
+
import { ARTICLE_COVER_FONT, DEFAULT_ARTICLE_COVER_CONTROLS } from "@akairos/typeset-studio/cover";
|
|
5
|
+
import { createCoverSnapshot, validateCoverSnapshot } from "../cover-snapshot";
|
|
6
|
+
|
|
7
|
+
function input() {
|
|
8
|
+
const canvas = createCanvas(1000, 1000);
|
|
9
|
+
canvas.getContext("2d").fillRect(0, 0, 1000, 1000);
|
|
10
|
+
const bytes = canvas.toBuffer("image/jpeg");
|
|
11
|
+
return {
|
|
12
|
+
id: "adca5060-0ef7-49fa-bfc5-567208a70494", revision: 1, name: "封面",
|
|
13
|
+
controls: { ...DEFAULT_ARTICLE_COVER_CONTROLS, backgroundColor: "#AABBCC" },
|
|
14
|
+
asset: { id: "3aebf96a-455f-4543-825a-ab425c1b53a3", sha256: createHash("sha256").update(bytes).digest("hex"), contentType: "image/jpeg", dataBase64: bytes.toString("base64") },
|
|
15
|
+
font: { id: ARTICLE_COVER_FONT.id, sha256: ARTICLE_COVER_FONT.sha256 },
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test("封面快照复用 Typeset 控件与工厂,规范化并绑定修订/素材/字体身份", () => {
|
|
20
|
+
const fields = input();
|
|
21
|
+
const snapshot = createCoverSnapshot(fields);
|
|
22
|
+
expect(snapshot.controls.backgroundColor).toBe("#aabbcc");
|
|
23
|
+
expect(snapshot.config.images?.[0].src).toBe(`asset:${fields.asset.id}`);
|
|
24
|
+
expect(validateCoverSnapshot(JSON.parse(JSON.stringify(snapshot)))).toEqual(snapshot);
|
|
25
|
+
expect(createCoverSnapshot({ ...fields, revision: 2 }).hash).not.toBe(snapshot.hash);
|
|
26
|
+
expect(createCoverSnapshot(Object.fromEntries(Object.entries(fields).reverse())).hash).toBe(snapshot.hash);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("封面快照拒绝任意配置、路径、字体、字节篡改和超范围输入", () => {
|
|
30
|
+
const fields = input();
|
|
31
|
+
const snapshot = createCoverSnapshot(fields);
|
|
32
|
+
for (const patch of [{ schemaVersion: 2 }, { revision: 0 }, { hash: "0".repeat(64) }, { path: "/private/image.jpg" }, { config: { ...snapshot.config, name: "改变配置" } }, { controls: { ...snapshot.controls, fontSize: 201 } }])
|
|
33
|
+
expect(() => validateCoverSnapshot({ ...snapshot, ...patch })).toThrow();
|
|
34
|
+
for (const patch of [
|
|
35
|
+
{ id: "../template" }, { name: "x".repeat(81) }, { controls: { ...fields.controls, font: "serif" } },
|
|
36
|
+
{ font: { ...fields.font, path: "/tmp/font.ttc" } }, { font: { ...fields.font, sha256: "0".repeat(64) } },
|
|
37
|
+
{ asset: { ...fields.asset, sha256: "0".repeat(64) } }, { asset: { ...fields.asset, dataBase64: `${fields.asset.dataBase64}\n` } },
|
|
38
|
+
{ asset: { ...fields.asset, dataBase64: Buffer.alloc(1_048_577).toString("base64") } },
|
|
39
|
+
{ asset: { ...fields.asset, url: "https://example.test/image.jpg" } },
|
|
40
|
+
]) expect(() => createCoverSnapshot({ ...fields, ...patch })).toThrow();
|
|
41
|
+
const bytes = createCanvas(10, 10).toBuffer("image/jpeg");
|
|
42
|
+
expect(() => createCoverSnapshot({ ...fields, asset: { ...fields.asset, sha256: createHash("sha256").update(bytes).digest("hex"), dataBase64: bytes.toString("base64") } })).toThrow("1000x1000");
|
|
43
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { isDeepStrictEqual } from "node:util";
|
|
3
|
+
import { imageSize } from "image-size";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { ARTICLE_COVER_FONT, createArticleCoverConfig, validateArticleCoverControls, type ArticleCoverControls } from "@akairos/typeset-studio/cover";
|
|
6
|
+
|
|
7
|
+
const identity = z.string().uuid().transform(value => value.toLowerCase());
|
|
8
|
+
const sha256 = z.string().regex(/^[a-f\d]{64}$/);
|
|
9
|
+
const SnapshotInputSchema = z.object({
|
|
10
|
+
id: identity,
|
|
11
|
+
revision: z.number().int().positive(),
|
|
12
|
+
name: z.string().trim().min(1).max(80),
|
|
13
|
+
controls: z.unknown(),
|
|
14
|
+
asset: z.object({
|
|
15
|
+
id: identity,
|
|
16
|
+
sha256,
|
|
17
|
+
contentType: z.literal("image/jpeg"),
|
|
18
|
+
dataBase64: z.string().min(4).max(1_398_104),
|
|
19
|
+
}).strict(),
|
|
20
|
+
font: z.object({ id: z.literal(ARTICLE_COVER_FONT.id), sha256: z.literal(ARTICLE_COVER_FONT.sha256) }).strict(),
|
|
21
|
+
}).strict();
|
|
22
|
+
|
|
23
|
+
export interface CoverSnapshot {
|
|
24
|
+
schemaVersion: 1;
|
|
25
|
+
id: string;
|
|
26
|
+
revision: number;
|
|
27
|
+
name: string;
|
|
28
|
+
controls: ArticleCoverControls;
|
|
29
|
+
config: ReturnType<typeof createArticleCoverConfig>;
|
|
30
|
+
asset: { id: string; sha256: string; contentType: "image/jpeg"; dataBase64: string };
|
|
31
|
+
font: { id: string; sha256: string };
|
|
32
|
+
hash: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CoverSnapshotInput {
|
|
36
|
+
id: string;
|
|
37
|
+
revision: number;
|
|
38
|
+
name: string;
|
|
39
|
+
controls: ArticleCoverControls;
|
|
40
|
+
asset: CoverSnapshot["asset"];
|
|
41
|
+
font: CoverSnapshot["font"];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 单图只接收已规范化的不可变 JPEG 字节,不解析远程地址或调用方路径。 */
|
|
45
|
+
export function createCoverSnapshot(value: unknown): CoverSnapshot {
|
|
46
|
+
const input = SnapshotInputSchema.parse(value);
|
|
47
|
+
const controls = validateArticleCoverControls(input.controls);
|
|
48
|
+
const bytes = Buffer.from(input.asset.dataBase64, "base64");
|
|
49
|
+
if (!bytes.length || bytes.length > 1_048_576 || bytes.toString("base64") !== input.asset.dataBase64)
|
|
50
|
+
throw new Error("Cover asset must be canonical base64 JPEG <= 1 MiB");
|
|
51
|
+
const image = imageSize(bytes);
|
|
52
|
+
if (image.type !== "jpg" || image.width !== 1000 || image.height !== 1000)
|
|
53
|
+
throw new Error("Cover asset must be a normalized 1000x1000 JPEG");
|
|
54
|
+
if (createHash("sha256").update(bytes).digest("hex") !== input.asset.sha256)
|
|
55
|
+
throw new Error("Cover asset hash mismatch");
|
|
56
|
+
const config = createArticleCoverConfig({ id: input.id, name: input.name, controls, assetId: input.asset.id });
|
|
57
|
+
const content = { schemaVersion: 1 as const, id: input.id, revision: input.revision, name: input.name, controls, config, asset: input.asset, font: input.font };
|
|
58
|
+
const hash = createHash("sha256").update(JSON.stringify(content)).digest("hex");
|
|
59
|
+
return { ...content, hash };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 配置只能由 Typeset 有限工厂生成;同 hash 不能夹带其他布局、素材或字体。 */
|
|
63
|
+
export function validateCoverSnapshot(value: unknown): CoverSnapshot {
|
|
64
|
+
const input = SnapshotInputSchema.extend({ schemaVersion: z.literal(1), config: z.unknown(), hash: sha256 }).strict().parse(value);
|
|
65
|
+
const { schemaVersion: _, config, hash, ...fields } = input;
|
|
66
|
+
const snapshot = createCoverSnapshot(fields);
|
|
67
|
+
if (!isDeepStrictEqual(config, snapshot.config)) throw new Error("Cover config does not match canonical template");
|
|
68
|
+
if (hash !== snapshot.hash) throw new Error("Cover snapshot hash mismatch");
|
|
69
|
+
return snapshot;
|
|
70
|
+
}
|