@gobing-ai/knowledge-kit 0.0.28 → 0.0.30
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/.claude-plugin/marketplace.json +1 -1
- package/dist/index.js +114 -38
- package/package.json +1 -1
- package/plugins/generations/daily-article-gen/dist/index.js +45 -5
- package/plugins/generations/daily-article-gen/src/index.ts +59 -4
- package/plugins/generations/dailynews-gen/dist/index.js +46 -9
- package/plugins/generations/dailynews-gen/src/index.ts +36 -1
- package/plugins/generations/dailynews-gen/src/script-builder.ts +15 -12
- package/plugins/generations/dailynews-gen/src/script-plan.ts +21 -3
- package/plugins/generations/episode-plan-gen/dist/index.js +55 -4
- package/plugins/generations/episode-plan-gen/src/index.ts +80 -2
- package/plugins/generations/image-gen/assets/fonts/NotoSansSC-Bold.otf +0 -0
- package/plugins/generations/image-gen/assets/fonts/NotoSansSC-Regular.otf +0 -0
- package/plugins/generations/image-gen/assets/fonts/OFL.txt +92 -0
- package/plugins/generations/image-gen/assets/fonts/SOURCE.md +15 -0
- package/plugins/generations/image-gen/dist/index.js +700 -22
- package/plugins/generations/image-gen/package.json +1 -0
- package/plugins/generations/image-gen/presets/formats/daily-news-cover.yaml +2 -2
- package/plugins/generations/image-gen/presets/formats/xhs-card.yaml +19 -11
- package/plugins/generations/image-gen/presets/styles/xhs-knowledge.yaml +2 -1
- package/plugins/generations/image-gen/scripts/compose-e2e-artifact.ts +135 -0
- package/plugins/generations/image-gen/src/bytes.ts +116 -0
- package/plugins/generations/image-gen/src/compose/card.ts +294 -0
- package/plugins/generations/image-gen/src/compose/chromium.ts +147 -0
- package/plugins/generations/image-gen/src/compose/cmap.ts +177 -0
- package/plugins/generations/image-gen/src/index.ts +211 -13
- package/plugins/generations/image-gen/src/job.ts +12 -0
- package/plugins/generations/image-gen/src/presets.ts +53 -13
- package/plugins/generations/image-gen/src/providers/minimax.ts +12 -4
- package/plugins/generations/image-gen/src/providers/types.ts +12 -1
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/scripts/daily-voice-style.zh.md +27 -14
- package/plugins/kk/scripts/itc-stages.ts +21 -3
- package/plugins/kk/scripts/kk-workflow-stages.ts +461 -10
- package/plugins/kk/scripts/topic-boundary.json +96 -0
- package/plugins/kk/scripts/validate-voicescript.ts +15 -0
- package/plugins/kk/skills/content-judge/SKILL.md +30 -0
- package/plugins/kk/skills/content-judge/references/workflow-integration.md +9 -2
- package/plugins/kk/skills/content-safety/SKILL.md +163 -0
- package/plugins/kk/skills/content-safety/references/platforms/apple-podcasts.md +36 -0
- package/plugins/kk/skills/content-safety/references/platforms/netease-music.md +31 -0
- package/plugins/kk/skills/content-safety/references/platforms/wechatmp.md +32 -0
- package/plugins/kk/skills/content-safety/references/platforms/xiaoyuzhou.md +35 -0
- package/plugins/kk/skills/publish/SKILL.md +1 -1
- package/plugins/kk/skills/xhs-images/SKILL.md +11 -7
- package/plugins/kk/skills/xhs-images/references/card-layouts.md +4 -4
- package/plugins/kk/workflows/judge-gated-publish-example.yaml +18 -10
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +139 -24
- package/plugins/kk/workflows/kk-itc.yaml +21 -8
- package/plugins/publishings/podcast-pub/dist/index.js +4 -0
- package/plugins/publishings/podcast-pub/src/index.ts +2 -0
- package/plugins/publishings/podcast-pub/src/show-notes.ts +6 -0
- package/plugins/publishings/xhs-pub/dist/index.js +1 -1
- package/plugins/publishings/xhs-pub/src/automation-guard.ts +2 -2
- package/plugins/publishings/xhs-pub/src/index.ts +1 -1
- package/plugins/publishings/xhs-pub/src/profile-check.ts +2 -2
- package/plugins/sp/lib/env.ts +0 -46
- package/plugins/sp/lib/inline-run.generated.mjs +0 -1489
- package/plugins/sp/scripts/batch-preflight.mjs +0 -346
- package/plugins/sp/scripts/batch-preflight.ts +0 -459
- package/plugins/sp/scripts/daily-summary/daily-summary.mjs +0 -615
- package/plugins/sp/scripts/daily-summary/daily-summary.ts +0 -846
- package/plugins/sp/scripts/daily-summary/logger.ts +0 -28
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +0 -223
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +0 -367
- package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +0 -132
- package/plugins/sp/scripts/dogfood-testing/validate-report.ts +0 -169
- package/plugins/sp/scripts/feature-dev-precheck.mjs +0 -171
- package/plugins/sp/scripts/feature-dev-precheck.ts +0 -238
- package/plugins/sp/scripts/feature-sync-bounded.mjs +0 -285
- package/plugins/sp/scripts/feature-sync-bounded.ts +0 -478
- package/plugins/sp/scripts/feature-verification-steps.ts +0 -275
- package/plugins/sp/scripts/history-anatomy-cache.mjs +0 -902
- package/plugins/sp/scripts/history-anatomy-cache.ts +0 -1028
- package/plugins/sp/scripts/idea-handoff.mjs +0 -22
- package/plugins/sp/scripts/idea-handoff.ts +0 -44
- package/plugins/sp/scripts/inline-pipeline-parity-check.ts +0 -185
- package/plugins/sp/scripts/inline-run-setup.ts +0 -722
- package/plugins/sp/scripts/pr-reviewing.mjs +0 -769
- package/plugins/sp/scripts/pr-reviewing.ts +0 -925
- package/plugins/sp/scripts/quality-gate.mjs +0 -179
- package/plugins/sp/scripts/quality-gate.ts +0 -217
- package/plugins/sp/scripts/script-contract-check.ts +0 -319
- package/plugins/sp/scripts/stage-registry-adapter.ts +0 -1533
- package/plugins/sp/scripts/surface-drift-inventory.ts +0 -929
- package/plugins/sp/scripts/task-evidence-precheck.ts +0 -181
- package/plugins/sp/scripts/task-size-precheck.ts +0 -175
- package/plugins/sp/scripts/transition-shim-check.ts +0 -238
- package/plugins/sp/scripts/validate-commands.ts +0 -689
- package/plugins/sp/scripts/validate-flag-contracts.ts +0 -878
- package/plugins/sp/scripts/verify-answer-lint.ts +0 -530
- package/plugins/sp/scripts/workflow-step-profile.mjs +0 -316
- package/plugins/sp/scripts/workflow-step-profile.ts +0 -456
- package/plugins/sp/scripts/wrapup-drift-probe.mjs +0 -190
- package/plugins/sp/scripts/wrapup-drift-probe.ts +0 -258
- package/plugins/sp/scripts/wrapup-steps.mjs +0 -444
- package/plugins/sp/scripts/wrapup-steps.ts +0 -552
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
name: daily-news-cover
|
|
2
|
-
description: Daily AI
|
|
2
|
+
description: Daily AI tech-sharing episode cover — fixed composition and palette; subject is the only per-run input
|
|
3
3
|
job: cover
|
|
4
4
|
aspectRatio: '21:9'
|
|
5
5
|
width: 1344
|
|
@@ -12,7 +12,7 @@ variables:
|
|
|
12
12
|
description: One concrete visual scene for the day's lead story (objects, settings, symbols)
|
|
13
13
|
default: a glowing network of connected nodes rising above a quiet city at dawn
|
|
14
14
|
template: |-
|
|
15
|
-
Ultrawide editorial cover illustration for a daily AI
|
|
15
|
+
Ultrawide editorial cover illustration for a daily AI tech-sharing podcast episode.
|
|
16
16
|
Scene: {{subject | a glowing network of connected nodes rising above a quiet city at dawn}}.
|
|
17
17
|
Composition: one central focal subject, generous negative space in the upper third, uncluttered background.
|
|
18
18
|
Color palette: deep navy background with electric blue and warm amber accents.
|
|
@@ -3,6 +3,8 @@ description: >-
|
|
|
3
3
|
Xiaohongshu (XHS) knowledge card — 3:4 portrait image-text card with large
|
|
4
4
|
Chinese headline and short bullet points. Adapted from baoyu-xhs-images
|
|
5
5
|
(MIT © Jim Liu); see plugins/kk/skills/xhs-images/references/card-layouts.md.
|
|
6
|
+
Task 0180: card copy is typeset from Noto Sans SC via headless Chromium;
|
|
7
|
+
the provider prompt generates the illustration only.
|
|
6
8
|
job: free
|
|
7
9
|
aspectRatio: '3:4'
|
|
8
10
|
width: 1080
|
|
@@ -11,12 +13,23 @@ defaultStyle: xhs-knowledge
|
|
|
11
13
|
keywords:
|
|
12
14
|
- xiaohongshu-style
|
|
13
15
|
- image-text card layout
|
|
14
|
-
- large-font title (Chinese-friendly)
|
|
15
16
|
- fresh clean design
|
|
16
17
|
- flat illustration accents with geometric decorations
|
|
17
|
-
- crisp legible text hierarchy
|
|
18
18
|
- professional color grading
|
|
19
19
|
outputFilename: 'card-{{index | 01}}.png'
|
|
20
|
+
compose:
|
|
21
|
+
engine: text-card
|
|
22
|
+
safeArea: 72
|
|
23
|
+
headlineMinPx: 64
|
|
24
|
+
bodyMinPx: 36
|
|
25
|
+
illustrationHeight: 640
|
|
26
|
+
palette:
|
|
27
|
+
background: '#FFFFFF'
|
|
28
|
+
text: '#1F2328'
|
|
29
|
+
accent: '#FF2442'
|
|
30
|
+
fonts:
|
|
31
|
+
headline: NotoSansSC-Bold.otf
|
|
32
|
+
body: NotoSansSC-Regular.otf
|
|
20
33
|
variables:
|
|
21
34
|
role:
|
|
22
35
|
description: Card role in the series (cover, content, closing)
|
|
@@ -33,9 +46,6 @@ variables:
|
|
|
33
46
|
subject:
|
|
34
47
|
description: Visual scene or metaphor illustrated on the card
|
|
35
48
|
default: clean flat illustration of the core concept
|
|
36
|
-
language:
|
|
37
|
-
description: On-card text language
|
|
38
|
-
default: 简体中文
|
|
39
49
|
index:
|
|
40
50
|
description: Card number in the series (zero-padded)
|
|
41
51
|
default: '01'
|
|
@@ -45,11 +55,9 @@ variables:
|
|
|
45
55
|
template: |-
|
|
46
56
|
Xiaohongshu-style knowledge card, portrait 3:4, for a card series.
|
|
47
57
|
Card role: {{role | content}}. Layout: {{layout | definition}}.
|
|
48
|
-
On-card headline in {{language | 简体中文}}, extra-large bold type: "{{headline}}".
|
|
49
|
-
Bullet points (short lines, generous line spacing, keep on-card text minimal):
|
|
50
|
-
{{points}}
|
|
51
58
|
Visual scene: {{subject | clean flat illustration of the core concept}},
|
|
52
59
|
rendered as flat illustration accents with subtle geometric decorations.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
60
|
+
Pure illustration, no text, no letters, no numerals, no captions — the text
|
|
61
|
+
band is typeset separately. Fresh clean design, comfortable margins, no
|
|
62
|
+
watermark, no extra UI chrome. 8K quality, crisp details, professional color
|
|
63
|
+
grading. {{content}}
|
|
@@ -4,4 +4,5 @@ description: >-
|
|
|
4
4
|
background, clean information layout, soft accent colors, rounded card zones.
|
|
5
5
|
Adapted from baoyu-xhs-images "knowledge card" style guidance (MIT © Jim Liu);
|
|
6
6
|
see plugins/kk/skills/xhs-images/references/card-layouts.md.
|
|
7
|
-
modifiers: simple clean light background, rounded rectangle card zones, soft pastel accent colors,
|
|
7
|
+
modifiers: simple clean light background, rounded rectangle card zones, soft pastel accent colors, generous whitespace, flat geometric decorations, subtle drop shadows, fresh and professional
|
|
8
|
+
negative: text, letters, numbers, Chinese characters, captions, watermark, logo
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone E2E compose evidence (task 0180 Plan item 9).
|
|
3
|
+
*
|
|
4
|
+
* Composes the fixed card twice per variant, asserts byte-identical SHA-256,
|
|
5
|
+
* asserts 1080x1440, and writes the PNG artifacts to .spur/run/0180/.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* bun scripts/compose-e2e-artifact.ts # all variants
|
|
9
|
+
* bun scripts/compose-e2e-artifact.ts without # one variant (no illustration)
|
|
10
|
+
* bun scripts/compose-e2e-artifact.ts with # one variant (fixture illustration)
|
|
11
|
+
* bun scripts/compose-e2e-artifact.ts r2 # 0181 R2: good card + overflow refusal
|
|
12
|
+
*
|
|
13
|
+
* Each variant runs in its own subprocess (the wrapper spawns `bun <self>
|
|
14
|
+
* <variant>`): under Bun 1.3.14 a third Chromium compose cycle within one
|
|
15
|
+
* process can hang the Playwright driver, while a two-compose pair is stable.
|
|
16
|
+
* Production composes one card per image-gen invocation, so the per-process
|
|
17
|
+
* pair matches the real spend path.
|
|
18
|
+
*/
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { imageDimensions } from '../src/bytes';
|
|
21
|
+
import { makeChromiumComposer } from '../src/compose/chromium';
|
|
22
|
+
|
|
23
|
+
const OUT_DIR = '.spur/run/0180';
|
|
24
|
+
const OUT_DIR_0181 = '.spur/run/0181';
|
|
25
|
+
const FIXTURE_PNG = new Uint8Array([
|
|
26
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00,
|
|
27
|
+
0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49,
|
|
28
|
+
0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc, 0x33,
|
|
29
|
+
0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
|
30
|
+
]);
|
|
31
|
+
const COMPOSE_CFG = {
|
|
32
|
+
engine: 'text-card' as const,
|
|
33
|
+
safeArea: 72,
|
|
34
|
+
headlineMinPx: 64,
|
|
35
|
+
bodyMinPx: 36,
|
|
36
|
+
illustrationHeight: 640,
|
|
37
|
+
palette: { background: '#FFFFFF', text: '#1F2328', accent: '#FF2442' },
|
|
38
|
+
fonts: { headline: 'NotoSansSC-Bold.otf', body: 'NotoSansSC-Regular.otf' },
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
async function sha256(bytes: Uint8Array): Promise<string> {
|
|
42
|
+
const digest = await crypto.subtle.digest('SHA-256', bytes as unknown as ArrayBuffer);
|
|
43
|
+
return Array.from(new Uint8Array(digest))
|
|
44
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
45
|
+
.join('');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function input(variant: 'without' | 'with') {
|
|
49
|
+
const base = {
|
|
50
|
+
headline: '微服务拆分的三个原则',
|
|
51
|
+
points: ['先按业务边界划分服务', '再按团队规模调整粒度', '最后用网关收敛入口'],
|
|
52
|
+
role: 'content' as const,
|
|
53
|
+
compose: COMPOSE_CFG,
|
|
54
|
+
width: 1080,
|
|
55
|
+
height: 1440,
|
|
56
|
+
};
|
|
57
|
+
return variant === 'with' ? { ...base, illustration: { bytes: FIXTURE_PNG, mime: 'image/png' } } : base;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function runPair(variant: 'without' | 'with'): Promise<void> {
|
|
61
|
+
console.log(`[${variant}] composing (1/2)...`);
|
|
62
|
+
const composer = makeChromiumComposer();
|
|
63
|
+
const first = await composer.compose(input(variant));
|
|
64
|
+
console.log(`[${variant}] composing (2/2)...`);
|
|
65
|
+
const second = await composer.compose(input(variant));
|
|
66
|
+
|
|
67
|
+
const dims = imageDimensions(first);
|
|
68
|
+
if (dims?.width !== 1080 || dims?.height !== 1440) {
|
|
69
|
+
throw new Error(`[${variant}] composed PNG is not 1080x1440: ${JSON.stringify(dims)}`);
|
|
70
|
+
}
|
|
71
|
+
const sha1 = await sha256(first);
|
|
72
|
+
const sha2 = await sha256(second);
|
|
73
|
+
if (sha1 !== sha2) {
|
|
74
|
+
throw new Error(`[${variant}] SHA-256 mismatch across identical compose: ${sha1} vs ${sha2}`);
|
|
75
|
+
}
|
|
76
|
+
const outPath = join(OUT_DIR, `card-${variant}-illustration.png`);
|
|
77
|
+
await Bun.write(outPath, first);
|
|
78
|
+
console.log(`[${variant}] OK ${first.length}B ${dims.width}x${dims.height} sha256=${sha1} -> ${outPath}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function runR2(): Promise<void> {
|
|
82
|
+
// 0181 R2 evidence: the no-illustration compose is a real card (R2 makes the
|
|
83
|
+
// illustration decoration, not a legibility dependency), and an overflowing
|
|
84
|
+
// headline is refused by the live safe-area check before any PNG exists.
|
|
85
|
+
const composer = makeChromiumComposer();
|
|
86
|
+
console.log('[r2] composing the no-illustration card (1/2)...');
|
|
87
|
+
const good = await composer.compose(input('without'));
|
|
88
|
+
const dims = imageDimensions(good);
|
|
89
|
+
if (dims?.width !== 1080 || dims?.height !== 1440) {
|
|
90
|
+
throw new Error(`[r2] composed PNG is not 1080x1440: ${JSON.stringify(dims)}`);
|
|
91
|
+
}
|
|
92
|
+
console.log('[r2] composing an overflowing headline (2/2) — expecting a safe-area refusal...');
|
|
93
|
+
let refusal = '';
|
|
94
|
+
try {
|
|
95
|
+
await composer.compose({ ...input('without'), headline: '拆'.repeat(200) });
|
|
96
|
+
} catch (err: unknown) {
|
|
97
|
+
refusal = err instanceof Error ? err.message : String(err);
|
|
98
|
+
}
|
|
99
|
+
if (!refusal.includes('overflows the safe area')) {
|
|
100
|
+
throw new Error(`[r2] overflowing headline was not refused: ${refusal || 'composed without error'}`);
|
|
101
|
+
}
|
|
102
|
+
const outPath = join(OUT_DIR_0181, 'card-r2-no-illustration.png');
|
|
103
|
+
await Bun.write(outPath, good);
|
|
104
|
+
console.log(`[r2] OK ${good.length}B ${dims.width}x${dims.height}; overflow refused; -> ${outPath}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const variant = process.argv[2];
|
|
108
|
+
if (variant === 'without' || variant === 'with') {
|
|
109
|
+
await runPair(variant);
|
|
110
|
+
} else if (variant === 'r2') {
|
|
111
|
+
await runR2();
|
|
112
|
+
} else if (variant === undefined) {
|
|
113
|
+
// Bounded: a Chromium cycle can occasionally hang under Bun 1.3.14 (driver
|
|
114
|
+
// contention right after a sibling subprocess); timeout + one clean retry.
|
|
115
|
+
for (const v of ['without', 'with', 'r2'] as const) {
|
|
116
|
+
let proc = Bun.spawnSync(['bun', import.meta.path, v], { stdout: 'pipe', stderr: 'pipe', timeout: 120_000 });
|
|
117
|
+
if (proc.exitCode !== 0) {
|
|
118
|
+
console.log(`[${v}] attempt 1 failed (exit ${proc.exitCode}); retrying once...`);
|
|
119
|
+
proc = Bun.spawnSync(['bun', import.meta.path, v], { stdout: 'pipe', stderr: 'pipe', timeout: 120_000 });
|
|
120
|
+
}
|
|
121
|
+
const out = new TextDecoder().decode(proc.stdout);
|
|
122
|
+
const err = new TextDecoder().decode(proc.stderr);
|
|
123
|
+
if (out) process.stdout.write(out);
|
|
124
|
+
if (proc.exitCode !== 0) {
|
|
125
|
+
process.stderr.write(err);
|
|
126
|
+
process.exit(proc.exitCode ?? 1);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
console.log(
|
|
130
|
+
'E2E compose artifacts written (.spur/run/0180 both variants, .spur/run/0181 R2) — SHA-256 deterministic.',
|
|
131
|
+
);
|
|
132
|
+
} else {
|
|
133
|
+
console.error(`Unknown variant: ${variant} (expected without|with)`);
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
@@ -17,3 +17,119 @@ export async function writeBytes(path: string, bytes: Uint8Array): Promise<void>
|
|
|
17
17
|
export async function readBytes(path: string): Promise<Uint8Array> {
|
|
18
18
|
return new Uint8Array(await readFile(path));
|
|
19
19
|
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Task 0180 (PNG) + task 0182 (JPEG/WebP, `null` contract) — pure header-only dimension
|
|
23
|
+
* reader. Reads `width`/`height` from PNG (IHDR), JPEG (SOF0–SOF15 except DHT/DAC/JPG)
|
|
24
|
+
* and WebP (VP8 lossy, VP8L lossless, VP8X extended) bytes; no decode. Returns `null`
|
|
25
|
+
* when the bytes cannot be parsed — the caller decides whether that is fatal (the compose
|
|
26
|
+
* canvas assertion) or a WARN with dimensions omitted (artifact recording, R3: the bytes
|
|
27
|
+
* are already paid for).
|
|
28
|
+
*/
|
|
29
|
+
export function imageDimensions(bytes: Uint8Array): { width: number; height: number } | null {
|
|
30
|
+
if (isPng(bytes)) return pngDimensions(bytes);
|
|
31
|
+
if (isJpeg(bytes)) return jpegDimensions(bytes);
|
|
32
|
+
if (isWebp(bytes)) return webpDimensions(bytes);
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isPng(b: Uint8Array): boolean {
|
|
37
|
+
const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
38
|
+
if (b.byteLength < 24) return false;
|
|
39
|
+
for (let i = 0; i < 8; i++) {
|
|
40
|
+
if (b[i] !== PNG_SIG[i]) return false;
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function pngDimensions(b: Uint8Array): { width: number; height: number } | null {
|
|
46
|
+
// IHDR chunk: offset 8 = chunk data length (must be 13), offset 12 = 'IHDR' tag.
|
|
47
|
+
const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
48
|
+
const ihdrLength = view.getUint32(8, false);
|
|
49
|
+
if (ihdrLength !== 13 || view.getUint32(12, false) !== 0x49484452) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return { width: view.getUint32(16, false), height: view.getUint32(20, false) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isJpeg(b: Uint8Array): boolean {
|
|
56
|
+
const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
57
|
+
// SOI + the first marker's 0xFF prefix.
|
|
58
|
+
return b.byteLength >= 4 && view.getUint16(0, false) === 0xffd8 && view.getUint8(2) === 0xff;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function jpegDimensions(b: Uint8Array): { width: number; height: number } | null {
|
|
62
|
+
const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
63
|
+
let at = 2; // after SOI
|
|
64
|
+
while (at + 4 <= b.byteLength) {
|
|
65
|
+
if (view.getUint8(at) !== 0xff) return null; // marker desync — not a parseable JPEG stream
|
|
66
|
+
const marker = view.getUint8(at + 1);
|
|
67
|
+
at += 2;
|
|
68
|
+
// Standalone markers (TEM, RSTn) carry no length segment.
|
|
69
|
+
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
|
|
70
|
+
if (at + 2 > b.byteLength) return null;
|
|
71
|
+
const length = view.getUint16(at, false);
|
|
72
|
+
if (length < 2) return null;
|
|
73
|
+
// SOF0–SOF15 minus DHT (C4), JPG (C8) and DAC (CC) carry the canvas.
|
|
74
|
+
const isSof = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
|
|
75
|
+
if (isSof) {
|
|
76
|
+
// SOF segment: length(2) precision(1) height(2) width(2).
|
|
77
|
+
if (at + 7 > b.byteLength) return null;
|
|
78
|
+
return { height: view.getUint16(at + 3, false), width: view.getUint16(at + 5, false) };
|
|
79
|
+
}
|
|
80
|
+
at += length;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isWebp(b: Uint8Array): boolean {
|
|
86
|
+
if (b.byteLength < 20) return false;
|
|
87
|
+
const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
88
|
+
return view.getUint32(0, true) === 0x46464952 && view.getUint32(8, true) === 0x50424557; // 'RIFF' 'WEBP'
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function webpDimensions(b: Uint8Array): { width: number; height: number } | null {
|
|
92
|
+
const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
93
|
+
// Walk the RIFF chunks after the 12-byte file header until a size-bearing chunk appears.
|
|
94
|
+
let at = 12;
|
|
95
|
+
while (at + 8 <= b.byteLength) {
|
|
96
|
+
const id = String.fromCharCode(
|
|
97
|
+
view.getUint8(at),
|
|
98
|
+
view.getUint8(at + 1),
|
|
99
|
+
view.getUint8(at + 2),
|
|
100
|
+
view.getUint8(at + 3),
|
|
101
|
+
);
|
|
102
|
+
const size = view.getUint32(at + 4, true);
|
|
103
|
+
const body = at + 8;
|
|
104
|
+
if (id === 'VP8 ') {
|
|
105
|
+
// Lossy: frame tag (3) + sync code 0x9d 0x01 0x2a + 14-bit LE width/height.
|
|
106
|
+
if (size < 10 || body + 10 > b.byteLength) return null;
|
|
107
|
+
if (
|
|
108
|
+
view.getUint8(body + 3) !== 0x9d ||
|
|
109
|
+
view.getUint8(body + 4) !== 0x01 ||
|
|
110
|
+
view.getUint8(body + 5) !== 0x2a
|
|
111
|
+
) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
width: view.getUint16(body + 6, true) & 0x3fff,
|
|
116
|
+
height: view.getUint16(body + 8, true) & 0x3fff,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (id === 'VP8L') {
|
|
120
|
+
// Lossless: signature 0x2f, then 14-bit width-1 / height-1 packed little-endian.
|
|
121
|
+
if (size < 5 || body + 5 > b.byteLength || view.getUint8(body) !== 0x2f) return null;
|
|
122
|
+
const bits = view.getUint32(body + 1, true);
|
|
123
|
+
return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 };
|
|
124
|
+
}
|
|
125
|
+
if (id === 'VP8X') {
|
|
126
|
+
// Extended: 4 flag/reserved bytes, then 24-bit LE width-1 / height-1.
|
|
127
|
+
if (size < 10 || body + 10 > b.byteLength) return null;
|
|
128
|
+
const width = view.getUint8(body + 4) | (view.getUint8(body + 5) << 8) | (view.getUint8(body + 6) << 16);
|
|
129
|
+
const height = view.getUint8(body + 7) | (view.getUint8(body + 8) << 8) | (view.getUint8(body + 9) << 16);
|
|
130
|
+
return { width: width + 1, height: height + 1 };
|
|
131
|
+
}
|
|
132
|
+
at = body + size + (size % 2); // RIFF chunks pad to even sizes
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task 0180 (F616) — card HTML builder and coverage asserter.
|
|
3
|
+
*
|
|
4
|
+
* `CardComposer` is the seam: tests inject a stub; `chromium.ts` is the default.
|
|
5
|
+
* `cardHtml` builds a self-contained HTML document with @font-face data: URLs
|
|
6
|
+
* and [data-field] attributes on every text element so the Chromium composer can
|
|
7
|
+
* measure overflow. No external requests, no system fonts.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** The compose block declared in a format preset's YAML. */
|
|
11
|
+
export interface ComposeBlock {
|
|
12
|
+
engine: 'text-card';
|
|
13
|
+
safeArea: number;
|
|
14
|
+
headlineMinPx: number;
|
|
15
|
+
bodyMinPx: number;
|
|
16
|
+
illustrationHeight: number;
|
|
17
|
+
palette: { background: string; text: string; accent: string };
|
|
18
|
+
fonts: { headline: string; body: string };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The full input to one compose call. */
|
|
22
|
+
export interface CardInput {
|
|
23
|
+
headline: string;
|
|
24
|
+
points: string[];
|
|
25
|
+
role: string;
|
|
26
|
+
compose: ComposeBlock;
|
|
27
|
+
width: number;
|
|
28
|
+
height: number;
|
|
29
|
+
/** Raw provider illustration, if available; absent on the pre-spend probe. */
|
|
30
|
+
illustration?: { bytes: Uint8Array; mime: string };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Injectable seam: compose one card PNG from the input. */
|
|
34
|
+
export interface CardComposer {
|
|
35
|
+
compose(input: CardInput): Promise<Uint8Array>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* R4 — check every character of headline and points against the font's cmap.
|
|
40
|
+
* Whitespace and line breaks are exempt. Throws loud on the first uncovered
|
|
41
|
+
* character, naming the field, the code point (U+HHHH form), and the glyph char.
|
|
42
|
+
*
|
|
43
|
+
* @param input The card input whose text is checked.
|
|
44
|
+
* @param fonts Map from font filename → coverage predicate. Keys must include
|
|
45
|
+
* `compose.fonts.headline` and `compose.fonts.body`.
|
|
46
|
+
*/
|
|
47
|
+
export function assertCoverage(input: CardInput, fonts: Map<string, (cp: number) => boolean>): void {
|
|
48
|
+
const headlineCover = fonts.get(input.compose.fonts.headline);
|
|
49
|
+
const bodyCover = fonts.get(input.compose.fonts.body);
|
|
50
|
+
|
|
51
|
+
if (headlineCover === undefined) {
|
|
52
|
+
throw new Error(`compose: headline font '${input.compose.fonts.headline}' not loaded`);
|
|
53
|
+
}
|
|
54
|
+
if (bodyCover === undefined) {
|
|
55
|
+
throw new Error(`compose: body font '${input.compose.fonts.body}' not loaded`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const checkField = (text: string, fieldName: string, covers: (cp: number) => boolean): void => {
|
|
59
|
+
for (const ch of text) {
|
|
60
|
+
const cp = ch.codePointAt(0);
|
|
61
|
+
if (cp === undefined) continue;
|
|
62
|
+
// Exempt whitespace and newlines (U+0000–U+0020, U+00A0, U+2000–U+200F, etc.)
|
|
63
|
+
if (cp <= 0x20 || cp === 0xa0 || (cp >= 0x2000 && cp <= 0x200f) || cp === 0x3000) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (!covers(cp)) {
|
|
67
|
+
const hex = `U+${cp.toString(16).toUpperCase().padStart(4, '0')}`;
|
|
68
|
+
const fontName = fieldName.startsWith('points')
|
|
69
|
+
? input.compose.fonts.body
|
|
70
|
+
: input.compose.fonts.headline;
|
|
71
|
+
throw new Error(`compose: ${fieldName} has ${hex} '${ch}' not covered by ${fontName}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
checkField(input.headline, 'headline', headlineCover);
|
|
77
|
+
for (const [i, point] of input.points.entries()) {
|
|
78
|
+
checkField(point, `points[${i}]`, bodyCover);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One named segment that contributed to the composed prompt, for source-naming. */
|
|
83
|
+
export interface TextFreeSource {
|
|
84
|
+
source: string;
|
|
85
|
+
text: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Task 0181 (F616 R1) — a compose format's illustration prompt must stay
|
|
90
|
+
* text-free: no Han character anywhere, and never the headline or any point as
|
|
91
|
+
* a literal substring (trimmed, length >= 2, so Latin-script card copy is
|
|
92
|
+
* caught too). Numerals and ordinary Latin words are fine: the negative prompt
|
|
93
|
+
* tells the model what to draw, not what it may contain.
|
|
94
|
+
*
|
|
95
|
+
* `sources` are checked in call order so a violation is attributed to the
|
|
96
|
+
* offending contributor (template, style preset, extra, a named var); when no
|
|
97
|
+
* single segment explains the hit, the composed `prompt` itself is named.
|
|
98
|
+
* Call this BEFORE any font IO, compose, or provider spend.
|
|
99
|
+
*/
|
|
100
|
+
export function assertTextFreePrompt(
|
|
101
|
+
prompt: string,
|
|
102
|
+
copy: { headline: string; points: string[] },
|
|
103
|
+
sources: TextFreeSource[],
|
|
104
|
+
): void {
|
|
105
|
+
const hit = (text: string): string | null => {
|
|
106
|
+
const headline = copy.headline.trim();
|
|
107
|
+
if (headline.length >= 2 && text.includes(headline)) return `the headline '${headline}'`;
|
|
108
|
+
for (const point of copy.points) {
|
|
109
|
+
const trimmed = point.trim();
|
|
110
|
+
if (trimmed.length >= 2 && text.includes(trimmed)) return `point '${trimmed}'`;
|
|
111
|
+
}
|
|
112
|
+
const han = /\p{Script=Han}/u.exec(text);
|
|
113
|
+
if (han !== null) {
|
|
114
|
+
const cp = (han[0]?.codePointAt(0) ?? 0).toString(16).toUpperCase().padStart(4, '0');
|
|
115
|
+
return `Han character '${han[0]}' (U+${cp})`;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
};
|
|
119
|
+
const fail = (source: string, what: string): never => {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`compose: the illustration prompt must stay text-free — ${source} contributes ${what} (card copy is typeset, never prompted)`,
|
|
122
|
+
);
|
|
123
|
+
};
|
|
124
|
+
for (const { source, text } of sources) {
|
|
125
|
+
const what = hit(text);
|
|
126
|
+
if (what !== null) fail(source, what);
|
|
127
|
+
}
|
|
128
|
+
const what = hit(prompt);
|
|
129
|
+
if (what !== null) fail('the composed prompt', what);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Build the self-contained HTML card document.
|
|
134
|
+
*
|
|
135
|
+
* Layout: illustration band (top, `object-fit: cover`) + text panel (below).
|
|
136
|
+
* All text is in fixed px inside the safe-area inset. @font-face references fonts
|
|
137
|
+
* by the URLs in `fontUrls` (data: URLs, local file:// URLs, or route URLs).
|
|
138
|
+
*
|
|
139
|
+
* Fit rule for headline: starts at `headlineMinPx * 1.5`, steps down 4px until
|
|
140
|
+
* it fits the safe-area width, never below `headlineMinPx`. Bullets use `bodyMinPx`.
|
|
141
|
+
*
|
|
142
|
+
* @param input Card input.
|
|
143
|
+
* @param fontUrls Map from font filename → URL string (any scheme accepted by the browser).
|
|
144
|
+
*/
|
|
145
|
+
export function cardHtml(input: CardInput, fontUrls: Map<string, string>): string {
|
|
146
|
+
const { compose: cfg, width, height, illustration } = input;
|
|
147
|
+
const { safeArea, headlineMinPx, bodyMinPx, illustrationHeight, palette } = cfg;
|
|
148
|
+
|
|
149
|
+
const headlineFont = fontUrls.get(cfg.fonts.headline) ?? '';
|
|
150
|
+
const bodyFont = fontUrls.get(cfg.fonts.body) ?? '';
|
|
151
|
+
|
|
152
|
+
// Headline starts at 1.5x the minimum and steps down by 4px.
|
|
153
|
+
const headlineStartPx = headlineMinPx * 1.5;
|
|
154
|
+
|
|
155
|
+
const textPanelHeight = height - illustrationHeight;
|
|
156
|
+
const safeWidth = width - safeArea * 2;
|
|
157
|
+
|
|
158
|
+
const illustrationSrc = illustration
|
|
159
|
+
? `data:${illustration.mime};base64,${Buffer.from(illustration.bytes).toString('base64')}`
|
|
160
|
+
: '';
|
|
161
|
+
|
|
162
|
+
const pointsHtml = input.points
|
|
163
|
+
.map(
|
|
164
|
+
(pt, i) =>
|
|
165
|
+
`<div class="point" data-field="points[${i}]" style="font-size:${bodyMinPx}px;">${escHtml(pt)}</div>`,
|
|
166
|
+
)
|
|
167
|
+
.join('\n');
|
|
168
|
+
|
|
169
|
+
return `<!DOCTYPE html>
|
|
170
|
+
<html lang="zh-Hans">
|
|
171
|
+
<head>
|
|
172
|
+
<meta charset="utf-8">
|
|
173
|
+
<style>
|
|
174
|
+
@font-face {
|
|
175
|
+
font-family: 'NotoSansSC-Bold';
|
|
176
|
+
src: url('${headlineFont}') format('opentype');
|
|
177
|
+
font-weight: bold;
|
|
178
|
+
font-style: normal;
|
|
179
|
+
}
|
|
180
|
+
@font-face {
|
|
181
|
+
font-family: 'NotoSansSC-Regular';
|
|
182
|
+
src: url('${bodyFont}') format('opentype');
|
|
183
|
+
font-weight: normal;
|
|
184
|
+
font-style: normal;
|
|
185
|
+
}
|
|
186
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
187
|
+
html, body {
|
|
188
|
+
width: ${width}px;
|
|
189
|
+
height: ${height}px;
|
|
190
|
+
overflow: hidden;
|
|
191
|
+
background: ${palette.background};
|
|
192
|
+
}
|
|
193
|
+
.card {
|
|
194
|
+
width: ${width}px;
|
|
195
|
+
height: ${height}px;
|
|
196
|
+
display: flex;
|
|
197
|
+
flex-direction: column;
|
|
198
|
+
background: ${palette.background};
|
|
199
|
+
}
|
|
200
|
+
.illustration-band {
|
|
201
|
+
width: ${width}px;
|
|
202
|
+
height: ${illustrationHeight}px;
|
|
203
|
+
flex-shrink: 0;
|
|
204
|
+
overflow: hidden;
|
|
205
|
+
background: ${palette.background};
|
|
206
|
+
}
|
|
207
|
+
.illustration-band img {
|
|
208
|
+
width: 100%;
|
|
209
|
+
height: 100%;
|
|
210
|
+
object-fit: cover;
|
|
211
|
+
display: block;
|
|
212
|
+
}
|
|
213
|
+
.text-panel {
|
|
214
|
+
width: ${width}px;
|
|
215
|
+
height: ${textPanelHeight}px;
|
|
216
|
+
flex-shrink: 0;
|
|
217
|
+
padding: ${safeArea}px;
|
|
218
|
+
display: flex;
|
|
219
|
+
flex-direction: column;
|
|
220
|
+
justify-content: flex-start;
|
|
221
|
+
overflow: hidden;
|
|
222
|
+
background: ${palette.background};
|
|
223
|
+
}
|
|
224
|
+
.headline {
|
|
225
|
+
font-family: 'NotoSansSC-Bold', sans-serif;
|
|
226
|
+
font-weight: bold;
|
|
227
|
+
color: ${palette.text};
|
|
228
|
+
line-height: 1.3;
|
|
229
|
+
margin-bottom: ${Math.round(safeArea / 2)}px;
|
|
230
|
+
word-break: break-all;
|
|
231
|
+
white-space: normal;
|
|
232
|
+
/* font-size is set inline with JS fit-step */
|
|
233
|
+
font-size: ${headlineStartPx}px;
|
|
234
|
+
}
|
|
235
|
+
.points-container {
|
|
236
|
+
display: flex;
|
|
237
|
+
flex-direction: column;
|
|
238
|
+
gap: ${Math.round(bodyMinPx * 0.5)}px;
|
|
239
|
+
}
|
|
240
|
+
.point {
|
|
241
|
+
font-family: 'NotoSansSC-Regular', sans-serif;
|
|
242
|
+
font-weight: normal;
|
|
243
|
+
color: ${palette.text};
|
|
244
|
+
line-height: 1.5;
|
|
245
|
+
word-break: break-all;
|
|
246
|
+
white-space: normal;
|
|
247
|
+
}
|
|
248
|
+
.safe-area-sentinel {
|
|
249
|
+
position: absolute;
|
|
250
|
+
top: ${illustrationHeight + safeArea}px;
|
|
251
|
+
left: ${safeArea}px;
|
|
252
|
+
width: ${safeWidth}px;
|
|
253
|
+
height: ${textPanelHeight - safeArea * 2}px;
|
|
254
|
+
pointer-events: none;
|
|
255
|
+
}
|
|
256
|
+
</style>
|
|
257
|
+
</head>
|
|
258
|
+
<body>
|
|
259
|
+
<div class="card">
|
|
260
|
+
<div class="illustration-band">
|
|
261
|
+
${illustrationSrc ? `<img src="${illustrationSrc}" alt="">` : `<div style="width:100%;height:100%;background:${palette.background};"></div>`}
|
|
262
|
+
</div>
|
|
263
|
+
<div class="text-panel">
|
|
264
|
+
<div class="headline" data-field="headline" id="headline">${escHtml(input.headline)}</div>
|
|
265
|
+
<div class="points-container">
|
|
266
|
+
${pointsHtml}
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
<div class="safe-area-sentinel" id="safe-area-sentinel"></div>
|
|
271
|
+
<script>
|
|
272
|
+
(function () {
|
|
273
|
+
// Step headline font down from start until it fits, never below floor.
|
|
274
|
+
var headline = document.getElementById('headline');
|
|
275
|
+
var headlineMinPx = ${headlineMinPx};
|
|
276
|
+
var headlineStartPx = ${headlineStartPx};
|
|
277
|
+
var safeWidth = ${safeWidth};
|
|
278
|
+
var panelHeight = ${textPanelHeight - safeArea * 2};
|
|
279
|
+
var size = headlineStartPx;
|
|
280
|
+
while (size > headlineMinPx) {
|
|
281
|
+
headline.style.fontSize = size + 'px';
|
|
282
|
+
if (headline.scrollWidth <= safeWidth && headline.scrollHeight <= panelHeight) break;
|
|
283
|
+
size -= 4;
|
|
284
|
+
}
|
|
285
|
+
headline.style.fontSize = Math.max(size, headlineMinPx) + 'px';
|
|
286
|
+
})();
|
|
287
|
+
</script>
|
|
288
|
+
</body>
|
|
289
|
+
</html>`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function escHtml(s: string): string {
|
|
293
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
294
|
+
}
|