@gobing-ai/knowledge-kit 0.0.28 → 0.0.29

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 (40) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/package.json +1 -1
  3. package/plugins/generations/daily-article-gen/dist/index.js +27 -4
  4. package/plugins/generations/daily-article-gen/src/index.ts +34 -3
  5. package/plugins/generations/dailynews-gen/dist/index.js +27 -7
  6. package/plugins/generations/dailynews-gen/src/index.ts +29 -1
  7. package/plugins/generations/dailynews-gen/src/script-builder.ts +15 -12
  8. package/plugins/generations/image-gen/assets/fonts/NotoSansSC-Bold.otf +0 -0
  9. package/plugins/generations/image-gen/assets/fonts/NotoSansSC-Regular.otf +0 -0
  10. package/plugins/generations/image-gen/assets/fonts/OFL.txt +92 -0
  11. package/plugins/generations/image-gen/assets/fonts/SOURCE.md +15 -0
  12. package/plugins/generations/image-gen/dist/index.js +591 -15
  13. package/plugins/generations/image-gen/package.json +1 -0
  14. package/plugins/generations/image-gen/presets/formats/daily-news-cover.yaml +2 -2
  15. package/plugins/generations/image-gen/presets/formats/xhs-card.yaml +19 -11
  16. package/plugins/generations/image-gen/presets/styles/xhs-knowledge.yaml +2 -1
  17. package/plugins/generations/image-gen/scripts/compose-e2e-artifact.ts +135 -0
  18. package/plugins/generations/image-gen/src/bytes.ts +28 -0
  19. package/plugins/generations/image-gen/src/compose/card.ts +294 -0
  20. package/plugins/generations/image-gen/src/compose/chromium.ts +147 -0
  21. package/plugins/generations/image-gen/src/compose/cmap.ts +177 -0
  22. package/plugins/generations/image-gen/src/index.ts +167 -9
  23. package/plugins/generations/image-gen/src/job.ts +6 -0
  24. package/plugins/generations/image-gen/src/presets.ts +53 -13
  25. package/plugins/kk/plugin.json +1 -1
  26. package/plugins/kk/scripts/daily-voice-style.zh.md +25 -12
  27. package/plugins/kk/scripts/kk-workflow-stages.ts +268 -9
  28. package/plugins/kk/scripts/topic-boundary.json +105 -0
  29. package/plugins/kk/scripts/validate-voicescript.ts +15 -0
  30. package/plugins/kk/skills/content-safety/SKILL.md +163 -0
  31. package/plugins/kk/skills/content-safety/references/platforms/apple-podcasts.md +36 -0
  32. package/plugins/kk/skills/content-safety/references/platforms/netease-music.md +31 -0
  33. package/plugins/kk/skills/content-safety/references/platforms/wechatmp.md +32 -0
  34. package/plugins/kk/skills/content-safety/references/platforms/xiaoyuzhou.md +35 -0
  35. package/plugins/kk/skills/xhs-images/SKILL.md +11 -7
  36. package/plugins/kk/skills/xhs-images/references/card-layouts.md +4 -4
  37. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +103 -13
  38. package/plugins/publishings/podcast-pub/dist/index.js +4 -0
  39. package/plugins/publishings/podcast-pub/src/index.ts +2 -0
  40. package/plugins/publishings/podcast-pub/src/show-notes.ts +6 -0
@@ -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,31 @@ 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 — pure PNG-only dimension reader (IHDR chunk).
23
+ * Reads `width` and `height` from the PNG IHDR chunk. Non-PNG bytes and
24
+ * truncated headers throw. Task 0182 will extend this to JPEG/WebP (escalation
25
+ * decision A — do not preempt that task's scope here).
26
+ */
27
+ export function imageDimensions(bytes: Uint8Array): { width: number; height: number } {
28
+ // PNG signature (8 bytes) + IHDR chunk length (4) + 'IHDR' tag (4) + width (4) + height (4) = 24
29
+ const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
30
+ if (bytes.byteLength < 24) {
31
+ throw new Error('imageDimensions: not a PNG — buffer too small');
32
+ }
33
+ for (let i = 0; i < 8; i++) {
34
+ if (bytes[i] !== PNG_SIG[i]) {
35
+ throw new Error('imageDimensions: not a PNG — invalid signature');
36
+ }
37
+ }
38
+ // IHDR chunk: offset 8 = chunk data length (must be 13), offset 12 = 'IHDR' tag.
39
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
40
+ const ihdrLength = view.getUint32(8, false);
41
+ if (ihdrLength !== 13 || bytes[12] !== 0x49 || bytes[13] !== 0x48 || bytes[14] !== 0x44 || bytes[15] !== 0x52) {
42
+ throw new Error('imageDimensions: not a PNG — IHDR chunk not found at offset 8');
43
+ }
44
+ const width = view.getUint32(16, false);
45
+ const height = view.getUint32(20, false);
46
+ return { width, height };
47
+ }
@@ -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
+ }