@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.
Files changed (97) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +114 -38
  3. package/package.json +1 -1
  4. package/plugins/generations/daily-article-gen/dist/index.js +45 -5
  5. package/plugins/generations/daily-article-gen/src/index.ts +59 -4
  6. package/plugins/generations/dailynews-gen/dist/index.js +46 -9
  7. package/plugins/generations/dailynews-gen/src/index.ts +36 -1
  8. package/plugins/generations/dailynews-gen/src/script-builder.ts +15 -12
  9. package/plugins/generations/dailynews-gen/src/script-plan.ts +21 -3
  10. package/plugins/generations/episode-plan-gen/dist/index.js +55 -4
  11. package/plugins/generations/episode-plan-gen/src/index.ts +80 -2
  12. package/plugins/generations/image-gen/assets/fonts/NotoSansSC-Bold.otf +0 -0
  13. package/plugins/generations/image-gen/assets/fonts/NotoSansSC-Regular.otf +0 -0
  14. package/plugins/generations/image-gen/assets/fonts/OFL.txt +92 -0
  15. package/plugins/generations/image-gen/assets/fonts/SOURCE.md +15 -0
  16. package/plugins/generations/image-gen/dist/index.js +700 -22
  17. package/plugins/generations/image-gen/package.json +1 -0
  18. package/plugins/generations/image-gen/presets/formats/daily-news-cover.yaml +2 -2
  19. package/plugins/generations/image-gen/presets/formats/xhs-card.yaml +19 -11
  20. package/plugins/generations/image-gen/presets/styles/xhs-knowledge.yaml +2 -1
  21. package/plugins/generations/image-gen/scripts/compose-e2e-artifact.ts +135 -0
  22. package/plugins/generations/image-gen/src/bytes.ts +116 -0
  23. package/plugins/generations/image-gen/src/compose/card.ts +294 -0
  24. package/plugins/generations/image-gen/src/compose/chromium.ts +147 -0
  25. package/plugins/generations/image-gen/src/compose/cmap.ts +177 -0
  26. package/plugins/generations/image-gen/src/index.ts +211 -13
  27. package/plugins/generations/image-gen/src/job.ts +12 -0
  28. package/plugins/generations/image-gen/src/presets.ts +53 -13
  29. package/plugins/generations/image-gen/src/providers/minimax.ts +12 -4
  30. package/plugins/generations/image-gen/src/providers/types.ts +12 -1
  31. package/plugins/kk/plugin.json +1 -1
  32. package/plugins/kk/scripts/daily-voice-style.zh.md +27 -14
  33. package/plugins/kk/scripts/itc-stages.ts +21 -3
  34. package/plugins/kk/scripts/kk-workflow-stages.ts +461 -10
  35. package/plugins/kk/scripts/topic-boundary.json +96 -0
  36. package/plugins/kk/scripts/validate-voicescript.ts +15 -0
  37. package/plugins/kk/skills/content-judge/SKILL.md +30 -0
  38. package/plugins/kk/skills/content-judge/references/workflow-integration.md +9 -2
  39. package/plugins/kk/skills/content-safety/SKILL.md +163 -0
  40. package/plugins/kk/skills/content-safety/references/platforms/apple-podcasts.md +36 -0
  41. package/plugins/kk/skills/content-safety/references/platforms/netease-music.md +31 -0
  42. package/plugins/kk/skills/content-safety/references/platforms/wechatmp.md +32 -0
  43. package/plugins/kk/skills/content-safety/references/platforms/xiaoyuzhou.md +35 -0
  44. package/plugins/kk/skills/publish/SKILL.md +1 -1
  45. package/plugins/kk/skills/xhs-images/SKILL.md +11 -7
  46. package/plugins/kk/skills/xhs-images/references/card-layouts.md +4 -4
  47. package/plugins/kk/workflows/judge-gated-publish-example.yaml +18 -10
  48. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +139 -24
  49. package/plugins/kk/workflows/kk-itc.yaml +21 -8
  50. package/plugins/publishings/podcast-pub/dist/index.js +4 -0
  51. package/plugins/publishings/podcast-pub/src/index.ts +2 -0
  52. package/plugins/publishings/podcast-pub/src/show-notes.ts +6 -0
  53. package/plugins/publishings/xhs-pub/dist/index.js +1 -1
  54. package/plugins/publishings/xhs-pub/src/automation-guard.ts +2 -2
  55. package/plugins/publishings/xhs-pub/src/index.ts +1 -1
  56. package/plugins/publishings/xhs-pub/src/profile-check.ts +2 -2
  57. package/plugins/sp/lib/env.ts +0 -46
  58. package/plugins/sp/lib/inline-run.generated.mjs +0 -1489
  59. package/plugins/sp/scripts/batch-preflight.mjs +0 -346
  60. package/plugins/sp/scripts/batch-preflight.ts +0 -459
  61. package/plugins/sp/scripts/daily-summary/daily-summary.mjs +0 -615
  62. package/plugins/sp/scripts/daily-summary/daily-summary.ts +0 -846
  63. package/plugins/sp/scripts/daily-summary/logger.ts +0 -28
  64. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +0 -223
  65. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +0 -367
  66. package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +0 -132
  67. package/plugins/sp/scripts/dogfood-testing/validate-report.ts +0 -169
  68. package/plugins/sp/scripts/feature-dev-precheck.mjs +0 -171
  69. package/plugins/sp/scripts/feature-dev-precheck.ts +0 -238
  70. package/plugins/sp/scripts/feature-sync-bounded.mjs +0 -285
  71. package/plugins/sp/scripts/feature-sync-bounded.ts +0 -478
  72. package/plugins/sp/scripts/feature-verification-steps.ts +0 -275
  73. package/plugins/sp/scripts/history-anatomy-cache.mjs +0 -902
  74. package/plugins/sp/scripts/history-anatomy-cache.ts +0 -1028
  75. package/plugins/sp/scripts/idea-handoff.mjs +0 -22
  76. package/plugins/sp/scripts/idea-handoff.ts +0 -44
  77. package/plugins/sp/scripts/inline-pipeline-parity-check.ts +0 -185
  78. package/plugins/sp/scripts/inline-run-setup.ts +0 -722
  79. package/plugins/sp/scripts/pr-reviewing.mjs +0 -769
  80. package/plugins/sp/scripts/pr-reviewing.ts +0 -925
  81. package/plugins/sp/scripts/quality-gate.mjs +0 -179
  82. package/plugins/sp/scripts/quality-gate.ts +0 -217
  83. package/plugins/sp/scripts/script-contract-check.ts +0 -319
  84. package/plugins/sp/scripts/stage-registry-adapter.ts +0 -1533
  85. package/plugins/sp/scripts/surface-drift-inventory.ts +0 -929
  86. package/plugins/sp/scripts/task-evidence-precheck.ts +0 -181
  87. package/plugins/sp/scripts/task-size-precheck.ts +0 -175
  88. package/plugins/sp/scripts/transition-shim-check.ts +0 -238
  89. package/plugins/sp/scripts/validate-commands.ts +0 -689
  90. package/plugins/sp/scripts/validate-flag-contracts.ts +0 -878
  91. package/plugins/sp/scripts/verify-answer-lint.ts +0 -530
  92. package/plugins/sp/scripts/workflow-step-profile.mjs +0 -316
  93. package/plugins/sp/scripts/workflow-step-profile.ts +0 -456
  94. package/plugins/sp/scripts/wrapup-drift-probe.mjs +0 -190
  95. package/plugins/sp/scripts/wrapup-drift-probe.ts +0 -258
  96. package/plugins/sp/scripts/wrapup-steps.mjs +0 -444
  97. package/plugins/sp/scripts/wrapup-steps.ts +0 -552
@@ -9,6 +9,7 @@
9
9
  "@gobing-ai/kk-core": "workspace:*",
10
10
  "@gobing-ai/ts-runtime": "catalog:",
11
11
  "@gobing-ai/ts-utils": "catalog:",
12
+ "playwright": "catalog:",
12
13
  "zod": "4.4.3"
13
14
  },
14
15
  "devDependencies": {
@@ -1,5 +1,5 @@
1
1
  name: daily-news-cover
2
- description: Daily AI news episode cover — fixed composition and palette; subject is the only per-run input
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 news podcast episode.
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
- Fresh clean design, strong text hierarchy, comfortable margins, no watermark,
54
- no extra UI chrome. 8K quality, crisp details, professional color grading.
55
- {{content}}
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, large legible Chinese typography, generous whitespace, flat geometric decorations, subtle drop shadows, fresh and professional
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
294
+ }