@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
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Task 0180 (F616) — Chromium-based card composer (Playwright).
3
+ *
4
+ * Default `CardComposer` implementation. Launches Chromium headless, renders
5
+ * the HTML card, measures every [data-field] box against the safe area (R2),
6
+ * and screenshots the result. The browser is always closed in `finally`.
7
+ *
8
+ * Font loading: uses `page.route()` to serve fonts from disk by URL, keeping
9
+ * the HTML small. The HTML references fonts by a stable `kk-font://` URL
10
+ * scheme; the route intercept fulfils each request with the disk bytes.
11
+ *
12
+ * The transport is Playwright's default; under Bun 1.3.14 a Chromium launch
13
+ * cycle can occasionally hang (leaving the browser's process tree behind),
14
+ * so the E2E test skips under `bun test` and the evidence script runs each
15
+ * variant pair in its own subprocess with a bounded timeout + retry.
16
+ *
17
+ * Missing Playwright or Chromium surfaces as an actionable error naming the
18
+ * install command.
19
+ */
20
+
21
+ import { join } from 'node:path';
22
+ import { readBytes } from '../bytes';
23
+ import type { CardComposer, CardInput } from './card';
24
+ import { cardHtml } from './card';
25
+
26
+ const FONTS_DIR = join(import.meta.dir, '..', '..', 'assets', 'fonts');
27
+
28
+ /** Stable URL prefix for font routes; never hits the network. */
29
+ const FONT_ROUTE_BASE = 'http://kk-fonts.local/';
30
+
31
+ /**
32
+ * Build a map of fontName → route URL. The HTML references these URLs;
33
+ * `page.route()` intercepts and returns the disk bytes.
34
+ */
35
+ function fontRouteUrls(fontNames: string[]): Map<string, string> {
36
+ const map = new Map<string, string>();
37
+ for (const name of fontNames) {
38
+ map.set(name, `${FONT_ROUTE_BASE}${encodeURIComponent(name)}`);
39
+ }
40
+ return map;
41
+ }
42
+
43
+ /**
44
+ * Playwright-based card composer. Inject in tests; the default export is used
45
+ * in production via `makeChromiumComposer()`.
46
+ */
47
+ export class ChromiumComposer implements CardComposer {
48
+ async compose(input: CardInput): Promise<Uint8Array> {
49
+ // Dynamic import so missing Playwright gives a clear error at call time.
50
+ let chromium: import('playwright').BrowserType;
51
+ try {
52
+ const pw = await import('playwright');
53
+ chromium = pw.chromium;
54
+ } catch (err: unknown) {
55
+ throw new Error(
56
+ `compose: Playwright is not installed. Run: bunx playwright install chromium\n(${err instanceof Error ? err.message : String(err)})`,
57
+ );
58
+ }
59
+
60
+ const fontNames = [input.compose.fonts.headline, input.compose.fonts.body];
61
+ const fontUrls = fontRouteUrls(fontNames);
62
+
63
+ // Build HTML with font URLs (small — no base64 inline).
64
+ const html = cardHtml(input, fontUrls);
65
+
66
+ let browser: import('playwright').Browser | undefined;
67
+ try {
68
+ try {
69
+ browser = await chromium.launch({ headless: true });
70
+ } catch (err: unknown) {
71
+ const msg = err instanceof Error ? err.message : String(err);
72
+ throw new Error(`compose: Chromium browser not found. Run: bunx playwright install chromium\n(${msg})`);
73
+ }
74
+
75
+ const page = await browser.newPage({
76
+ viewport: { width: input.width, height: input.height },
77
+ deviceScaleFactor: 1,
78
+ });
79
+
80
+ // Serve fonts from disk via route interception; no network, no base64 blob.
81
+ await page.route(`${FONT_ROUTE_BASE}**`, async (route) => {
82
+ const url = new URL(route.request().url());
83
+ const fontName = decodeURIComponent(url.pathname.slice(1));
84
+ const fontPath = join(FONTS_DIR, fontName);
85
+ try {
86
+ const bytes = await readBytes(fontPath);
87
+ await route.fulfill({
88
+ status: 200,
89
+ contentType: 'font/otf',
90
+ body: Buffer.from(bytes),
91
+ });
92
+ } catch {
93
+ await route.abort();
94
+ }
95
+ });
96
+
97
+ await page.setContent(html, { waitUntil: 'domcontentloaded' });
98
+ // Wait for fonts to load; passed as a string expression to avoid DOM lib dependency.
99
+ await page.evaluate('document.fonts.ready');
100
+
101
+ // R2: measure every [data-field] box against the safe area.
102
+ const { safeArea, illustrationHeight } = input.compose;
103
+ const safeLeft = safeArea;
104
+ const safeTop = illustrationHeight + safeArea;
105
+ const safeRight = input.width - safeArea;
106
+ const safeBottom = input.height - safeArea;
107
+
108
+ // Passed as a string to avoid requiring DOM lib in tsconfig; args injected by value.
109
+ const overflowExpr = `
110
+ (function(safeLeft, safeTop, safeRight, safeBottom) {
111
+ var results = [];
112
+ var els = Array.from(document.querySelectorAll('[data-field]'));
113
+ for (var i = 0; i < els.length; i++) {
114
+ var el = els[i];
115
+ var field = el.dataset.field || '';
116
+ var rect = el.getBoundingClientRect();
117
+ if (rect.left < safeLeft || rect.top < safeTop || rect.right > safeRight || rect.bottom > safeBottom) {
118
+ results.push({ field: field, rect: { top: rect.top, left: rect.left, bottom: rect.bottom, right: rect.right } });
119
+ }
120
+ }
121
+ return results;
122
+ })(${safeLeft}, ${safeTop}, ${safeRight}, ${safeBottom})
123
+ `;
124
+ const overflows = (await page.evaluate(overflowExpr)) as Array<{
125
+ field: string;
126
+ rect: { top: number; left: number; bottom: number; right: number };
127
+ }>;
128
+
129
+ if (overflows.length > 0) {
130
+ const fields = overflows.map((o) => o.field).join(', ');
131
+ throw new Error(
132
+ `compose: text overflows the safe area for field(s): ${fields} (safe area: ${safeArea}px inset)`,
133
+ );
134
+ }
135
+
136
+ const screenshot = await page.screenshot({ type: 'png', animations: 'disabled' });
137
+ return new Uint8Array(screenshot);
138
+ } finally {
139
+ await browser?.close();
140
+ }
141
+ }
142
+ }
143
+
144
+ /** Factory function that returns the default Chromium composer singleton. */
145
+ export function makeChromiumComposer(): CardComposer {
146
+ return new ChromiumComposer();
147
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Task 0180 (F616 R4) — pure sfnt cmap reader, no IO.
3
+ *
4
+ * Parses the OpenType/TrueType table directory and the cmap table to build a
5
+ * code-point → boolean coverage function. Format 12 (full Unicode BMP + SMP,
6
+ * preferred for CJK) is selected when present; format 4 (BMP only) is the
7
+ * fallback. A truncated font or a missing cmap throws so callers cannot silently
8
+ * get a false-negative from a partial read.
9
+ */
10
+
11
+ function u16(view: DataView, offset: number): number {
12
+ return view.getUint16(offset, false);
13
+ }
14
+
15
+ function u32(view: DataView, offset: number): number {
16
+ return view.getUint32(offset, false);
17
+ }
18
+
19
+ function tag(view: DataView, offset: number): string {
20
+ return String.fromCharCode(
21
+ view.getUint8(offset),
22
+ view.getUint8(offset + 1),
23
+ view.getUint8(offset + 2),
24
+ view.getUint8(offset + 3),
25
+ );
26
+ }
27
+
28
+ function assertBytes(view: DataView, offset: number, needed: number, context: string): void {
29
+ if (offset + needed > view.byteLength) {
30
+ throw new Error(`compose: truncated font — expected ${needed} bytes at offset ${offset} (${context})`);
31
+ }
32
+ }
33
+
34
+ /** Locate the cmap table in the sfnt directory and return its absolute offset + length. */
35
+ function findCmapTable(view: DataView): { offset: number; length: number } {
36
+ assertBytes(view, 0, 12, 'sfnt header');
37
+ // sfnt magic: 0x00010000 (TrueType), 0x4F54544F ('OTTO' — CFF/OTF), or 0x74727565 ('true').
38
+ const magic = u32(view, 0);
39
+ if (magic !== 0x00010000 && magic !== 0x4f54544f && magic !== 0x74727565) {
40
+ throw new Error(`compose: not a valid sfnt font (magic 0x${magic.toString(16).padStart(8, '0')})`);
41
+ }
42
+ const numTables = u16(view, 4);
43
+ assertBytes(view, 12, numTables * 16, 'sfnt table directory');
44
+ for (let i = 0; i < numTables; i++) {
45
+ const base = 12 + i * 16;
46
+ if (tag(view, base) === 'cmap') {
47
+ const offset = u32(view, base + 8);
48
+ const length = u32(view, base + 12);
49
+ return { offset, length };
50
+ }
51
+ }
52
+ throw new Error('compose: truncated font — cmap table not found in sfnt directory');
53
+ }
54
+
55
+ /** Build a coverage function from cmap format 4 (BMP segmented). */
56
+ function coverageFormat4(view: DataView, subtableOffset: number): (cp: number) => boolean {
57
+ assertBytes(view, subtableOffset, 14, 'cmap format 4 header');
58
+ const length = u16(view, subtableOffset + 2);
59
+ assertBytes(view, subtableOffset, length, 'cmap format 4 body');
60
+ const segCount = u16(view, subtableOffset + 6) / 2;
61
+ const endCodesBase = subtableOffset + 14;
62
+ assertBytes(
63
+ view,
64
+ endCodesBase,
65
+ segCount * 2 + 2 + segCount * 2 + segCount * 2 + segCount * 2,
66
+ 'cmap format 4 segments',
67
+ );
68
+ const startCodesBase = endCodesBase + segCount * 2 + 2; // skip reservedPad
69
+ const idDeltaBase = startCodesBase + segCount * 2;
70
+ const idRangeOffsetBase = idDeltaBase + segCount * 2;
71
+
72
+ return (cp: number): boolean => {
73
+ if (cp > 0xffff) return false;
74
+ // Binary search over endCode array.
75
+ let lo = 0;
76
+ let hi = segCount - 1;
77
+ while (lo <= hi) {
78
+ const mid = (lo + hi) >>> 1;
79
+ const endCode = u16(view, endCodesBase + mid * 2);
80
+ if (endCode < cp) {
81
+ lo = mid + 1;
82
+ } else {
83
+ hi = mid - 1;
84
+ }
85
+ }
86
+ if (lo >= segCount) return false;
87
+ const startCode = u16(view, startCodesBase + lo * 2);
88
+ if (cp < startCode) return false;
89
+ const idRangeOffset = u16(view, idRangeOffsetBase + lo * 2);
90
+ if (idRangeOffset === 0) {
91
+ const idDelta = u16(view, idDeltaBase + lo * 2);
92
+ return ((cp + idDelta) & 0xffff) !== 0;
93
+ }
94
+ // Use the idRangeOffset to index into glyphIdArray.
95
+ const glyphIdAddr = idRangeOffsetBase + lo * 2 + idRangeOffset + (cp - startCode) * 2;
96
+ if (glyphIdAddr + 2 > view.byteLength) return false;
97
+ const glyphId = u16(view, glyphIdAddr);
98
+ return glyphId !== 0;
99
+ };
100
+ }
101
+
102
+ /** Build a coverage function from cmap format 12 (full Unicode, sequential groups). */
103
+ function coverageFormat12(view: DataView, subtableOffset: number): (cp: number) => boolean {
104
+ assertBytes(view, subtableOffset, 16, 'cmap format 12 header');
105
+ const numGroups = u32(view, subtableOffset + 12);
106
+ assertBytes(view, subtableOffset + 16, numGroups * 12, 'cmap format 12 groups');
107
+ const groupsBase = subtableOffset + 16;
108
+
109
+ return (cp: number): boolean => {
110
+ // Binary search over startCharCode.
111
+ let lo = 0;
112
+ let hi = numGroups - 1;
113
+ while (lo <= hi) {
114
+ const mid = (lo + hi) >>> 1;
115
+ const base = groupsBase + mid * 12;
116
+ const startChar = u32(view, base);
117
+ const endChar = u32(view, base + 4);
118
+ if (endChar < cp) {
119
+ lo = mid + 1;
120
+ } else if (startChar > cp) {
121
+ hi = mid - 1;
122
+ } else {
123
+ // cp is in [startChar, endChar]; glyph is startGlyph + (cp - startChar).
124
+ const startGlyph = u32(view, base + 8);
125
+ return startGlyph + (cp - startChar) !== 0;
126
+ }
127
+ }
128
+ return false;
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Parse the cmap table and return a `(cp: number) => boolean` coverage
134
+ * predicate. Format 12 is preferred (covers all Unicode); format 4 is the
135
+ * fallback (BMP only). A truncated font or a missing usable subtable throws.
136
+ */
137
+ export function glyphCoverage(fontBytes: Uint8Array): (cp: number) => boolean {
138
+ if (fontBytes.byteLength < 12) {
139
+ throw new Error('compose: truncated font — too small for sfnt header');
140
+ }
141
+ const view = new DataView(fontBytes.buffer, fontBytes.byteOffset, fontBytes.byteLength);
142
+ const { offset: cmapOffset } = findCmapTable(view);
143
+
144
+ assertBytes(view, cmapOffset, 4, 'cmap table header');
145
+ const numSubtables = u16(view, cmapOffset + 2);
146
+ assertBytes(view, cmapOffset + 4, numSubtables * 8, 'cmap encoding records');
147
+
148
+ let fmt4Offset: number | undefined;
149
+ let fmt12Offset: number | undefined;
150
+
151
+ for (let i = 0; i < numSubtables; i++) {
152
+ const recBase = cmapOffset + 4 + i * 8;
153
+ const platformId = u16(view, recBase);
154
+ const encodingId = u16(view, recBase + 2);
155
+ const subtableOffset = cmapOffset + u32(view, recBase + 4);
156
+
157
+ if (subtableOffset + 2 > view.byteLength) continue;
158
+ const fmt = u16(view, subtableOffset);
159
+
160
+ // Prefer platform 3 (Windows) or platform 0 (Unicode) for format 12.
161
+ // Accept any platform for format 4 as BMP fallback.
162
+ if (fmt === 12 && (platformId === 3 || platformId === 0)) {
163
+ fmt12Offset = subtableOffset;
164
+ }
165
+ if (fmt === 4 && (platformId === 3 || platformId === 0) && encodingId !== 10) {
166
+ if (fmt4Offset === undefined) fmt4Offset = subtableOffset;
167
+ }
168
+ }
169
+
170
+ if (fmt12Offset !== undefined) {
171
+ return coverageFormat12(view, fmt12Offset);
172
+ }
173
+ if (fmt4Offset !== undefined) {
174
+ return coverageFormat4(view, fmt4Offset);
175
+ }
176
+ throw new Error('compose: truncated font — no usable cmap subtable (format 4 or 12) found');
177
+ }
@@ -1,9 +1,12 @@
1
- import { dirname } from 'node:path';
1
+ import { dirname, join } from 'node:path';
2
2
  import { parseArgs } from 'node:util';
3
3
  import { type Content, ContentSchema, type Doc, DocListSchema } from '@gobing-ai/kk-core';
4
4
  import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
5
5
  import { echoError } from '@gobing-ai/ts-utils';
6
- import { writeBytes } from './bytes';
6
+ import { imageDimensions, readBytes, writeBytes } from './bytes';
7
+ import type { CardComposer } from './compose/card';
8
+ import { assertCoverage, assertTextFreePrompt, type TextFreeSource } from './compose/card';
9
+ import { glyphCoverage } from './compose/cmap';
7
10
  import {
8
11
  type CaptionEntry,
9
12
  type ImageArtifact,
@@ -26,6 +29,7 @@ import {
26
29
  selectProvider,
27
30
  } from './providers/types';
28
31
 
32
+ export type { CardComposer, CardInput } from './compose/card';
29
33
  export * from './job';
30
34
  export * from './paths';
31
35
  export * from './presets';
@@ -192,6 +196,12 @@ export async function processGeneratorIO(
192
196
  providerResolver?: ProviderResolver;
193
197
  /** D6 0166: builds fallback candidates by name (tests inject stubs); defaults to the registry. */
194
198
  createProvider?: (name: string) => ImageProvider;
199
+ /**
200
+ * Task 0180: injectable card composer. Tests supply a stub; the default is the Chromium
201
+ * composer (same seam style as `providerResolver`). Resolved lazily so missing Playwright
202
+ * surfaces only when a compose-format job is actually requested.
203
+ */
204
+ composer?: CardComposer;
195
205
  } = {},
196
206
  ): Promise<void> {
197
207
  const fs = createNodeFileSystem();
@@ -229,6 +239,85 @@ export async function processGeneratorIO(
229
239
  }
230
240
  if (spec.job === 'illustration') assertAnchorPlan(doc?.body ?? '', positions, prompts.length);
231
241
 
242
+ // Task 0180 (F616 R4/R5): when the format has a compose block, run pre-spend checks
243
+ // (coverage + a dry compose with no illustration). This runs on BOTH the dry-run and
244
+ // spend branches so a missing-glyph or overflow error never bills the provider.
245
+ let _chromiumComposer: CardComposer | undefined;
246
+ const resolveComposer = async (): Promise<CardComposer> => {
247
+ if (options.composer !== undefined) return options.composer;
248
+ if (_chromiumComposer !== undefined) return _chromiumComposer;
249
+ // Lazy import: Playwright is a runtime dep, not always needed.
250
+ const { makeChromiumComposer } = await import('./compose/chromium');
251
+ _chromiumComposer = makeChromiumComposer();
252
+ return _chromiumComposer;
253
+ };
254
+
255
+ if (format.compose !== undefined) {
256
+ const cfg = format.compose;
257
+ if (spec.job !== 'free') {
258
+ throw new Error(
259
+ `compose: format '${format.name}' has a compose block but the job is '${spec.job}' — compose formats require job: free`,
260
+ );
261
+ }
262
+ const vars = spec.vars ?? {};
263
+ const headline = vars.headline ?? format.variables.headline?.default ?? '';
264
+ if (!headline.trim()) {
265
+ throw new Error(`compose: format '${format.name}' requires a non-empty vars.headline`);
266
+ }
267
+ const pointsRaw = vars.points ?? format.variables.points?.default ?? '';
268
+ const points = pointsRaw.split('\n').filter((l: string) => l.trim() !== '');
269
+
270
+ // Task 0181 (F616 R1): the provider prompt must stay text-free — card copy is
271
+ // typeset by the composer. Refusal happens before any font IO, compose, or spend
272
+ // and names the offending segment (template, style preset, extra, or a named var).
273
+ const promptVars: Record<string, string> = { subject: doc?.body ?? '', ...(spec.vars ?? {}) };
274
+ const textFreeSources: TextFreeSource[] = [
275
+ { source: 'the format template', text: format.template },
276
+ {
277
+ source: `the style preset '${style.name}'`,
278
+ text: [style.modifiers, style.negative].filter((part) => part !== '').join(', '),
279
+ },
280
+ { source: 'spec.extra', text: spec.extra ?? '' },
281
+ ...Object.entries(promptVars)
282
+ .filter(([name]) => name !== 'headline' && name !== 'points' && name !== 'index')
283
+ .map(([name, value]) => ({ source: `vars.${name}`, text: String(value) })),
284
+ ];
285
+ assertTextFreePrompt(prompts[0] ?? '', { headline, points }, textFreeSources);
286
+
287
+ // Load font bytes once for coverage check.
288
+ const fontsDir = join(import.meta.dir, '..', 'assets', 'fonts');
289
+ const fontNames = [cfg.fonts.headline, cfg.fonts.body];
290
+ const fontBytesMap = new Map<string, Uint8Array>();
291
+ for (const name of fontNames) {
292
+ fontBytesMap.set(name, new Uint8Array(await readBytes(join(fontsDir, name))));
293
+ }
294
+ const coverageMap = new Map<string, (cp: number) => boolean>();
295
+ for (const [name, bytes] of fontBytesMap) {
296
+ coverageMap.set(name, glyphCoverage(bytes));
297
+ }
298
+
299
+ if (format.width == null || format.height == null) {
300
+ throw new Error(
301
+ `image-gen: format '${format.name}' declares no canvas size; compose requires width and height`,
302
+ );
303
+ }
304
+ const cardInput = {
305
+ headline,
306
+ points,
307
+ role: vars.role ?? format.variables.role?.default ?? 'content',
308
+ compose: cfg,
309
+ width: format.width,
310
+ height: format.height,
311
+ };
312
+
313
+ // assertCoverage: fails loud naming field + code point (R4).
314
+ assertCoverage(cardInput, coverageMap);
315
+
316
+ // Dry compose (no illustration): proves Chromium is available and layout fits (R5).
317
+ const composer = await resolveComposer();
318
+ await composer.compose(cardInput);
319
+ }
320
+
232
321
  // Selection must succeed (or fail loud naming the provider) even when nothing is spent.
233
322
  const resolveProvider: ProviderResolver = options.providerResolver ?? defaultProviderResolver;
234
323
  const primary = resolveProvider(spec);
@@ -367,17 +456,86 @@ export async function processGeneratorIO(
367
456
  );
368
457
  }
369
458
  }
370
- const path = artifactPath(outputPath, stem, format.name, index + 1, result.extension);
371
- await writeBytes(path, result.bytes);
372
- written.push(path);
459
+
460
+ let artifactPathValue: string;
461
+ let artifactBytes: number;
462
+ let artifactWidth: number | undefined;
463
+ let artifactHeight: number | undefined;
464
+ let illustrationMeta: ImageArtifact['illustration'] | undefined;
465
+
466
+ if (format.compose !== undefined) {
467
+ // Task 0180 (F616 R5): compose path.
468
+ // 1. Write the raw provider illustration.
469
+ const illustrationArtifactPath = artifactPath(
470
+ outputPath,
471
+ stem,
472
+ `${format.name}-illustration`,
473
+ index + 1,
474
+ result.extension,
475
+ );
476
+ await writeBytes(illustrationArtifactPath, result.bytes);
477
+ written.push(illustrationArtifactPath);
478
+
479
+ // 2. Compose the card over the illustration.
480
+ const vars = spec.vars ?? {};
481
+ const headline = vars.headline ?? format.variables.headline?.default ?? '';
482
+ const pointsRaw = vars.points ?? format.variables.points?.default ?? '';
483
+ const points = pointsRaw.split('\n').filter((l: string) => l.trim() !== '');
484
+ if (format.width == null || format.height == null) {
485
+ throw new Error(
486
+ `image-gen: format '${format.name}' declares no canvas size; compose requires width and height`,
487
+ );
488
+ }
489
+ const cardInput = {
490
+ headline,
491
+ points,
492
+ role: vars.role ?? format.variables.role?.default ?? 'content',
493
+ compose: format.compose,
494
+ width: format.width,
495
+ height: format.height,
496
+ illustration: { bytes: result.bytes, mime: `image/${result.extension}` },
497
+ };
498
+ const composer = await resolveComposer();
499
+ const cardBytes = await composer.compose(cardInput);
500
+
501
+ // 3. Write the composed card.
502
+ const cardPath = artifactPath(outputPath, stem, format.name, index + 1, 'png');
503
+ await writeBytes(cardPath, cardBytes);
504
+ written.push(cardPath);
505
+
506
+ // 4. Measure the card dimensions via imageDimensions and assert canvas.
507
+ const dims = imageDimensions(cardBytes);
508
+ if (dims.width !== format.width || dims.height !== format.height) {
509
+ throw new Error(
510
+ `compose: card dimensions ${dims.width}x${dims.height} do not match format canvas ${format.width}x${format.height}`,
511
+ );
512
+ }
513
+
514
+ artifactPathValue = cardPath;
515
+ artifactBytes = cardBytes.byteLength;
516
+ artifactWidth = dims.width;
517
+ artifactHeight = dims.height;
518
+ illustrationMeta = { path: illustrationArtifactPath, bytes: result.bytes.byteLength };
519
+ } else {
520
+ // Non-compose: existing path (R6).
521
+ const path = artifactPath(outputPath, stem, format.name, index + 1, result.extension);
522
+ await writeBytes(path, result.bytes);
523
+ written.push(path);
524
+ artifactPathValue = path;
525
+ artifactBytes = result.bytes.byteLength;
526
+ }
527
+
373
528
  const artifact: ImageArtifact = {
374
- path,
529
+ path: artifactPathValue,
375
530
  format: format.name,
376
531
  style: style.name,
377
532
  provider: result.provider,
378
533
  model: result.model,
379
534
  prompt,
380
- bytes: result.bytes.byteLength,
535
+ bytes: artifactBytes,
536
+ ...(artifactWidth !== undefined ? { width: artifactWidth } : {}),
537
+ ...(artifactHeight !== undefined ? { height: artifactHeight } : {}),
538
+ ...(illustrationMeta !== undefined ? { illustration: illustrationMeta } : {}),
381
539
  };
382
540
  if (spec.seed !== undefined) {
383
541
  artifact.seed = spec.seed;
@@ -386,10 +544,10 @@ export async function processGeneratorIO(
386
544
  if (anchor !== undefined) {
387
545
  const parts = body.split(anchor);
388
546
  const altText = `Illustration ${index + 1}`;
389
- const markdown = `${anchor}\n\n![${altText}](${path})`;
547
+ const markdown = `${anchor}\n\n![${altText}](${artifactPathValue})`;
390
548
  captions.push({
391
549
  index: index + 1,
392
- path,
550
+ path: artifactPathValue,
393
551
  caption: anchor,
394
552
  altText,
395
553
  anchor,
@@ -79,6 +79,12 @@ export interface ImageArtifact {
79
79
  width?: number;
80
80
  height?: number;
81
81
  bytes: number;
82
+ /**
83
+ * Task 0180 (F616 R5): when the format has a compose block, the raw provider
84
+ * illustration is recorded here. `path` on the parent artifact is the composed
85
+ * card. `xhs-pub` reads only `.path`, so the publish DTO is unchanged.
86
+ */
87
+ illustration?: { path: string; width?: number; height?: number; bytes: number };
82
88
  }
83
89
 
84
90
  /** One illustration caption, the wt captions.json payload reduced to what consumers read (R4). */
@@ -31,6 +31,28 @@ export interface FormatVariable {
31
31
  default: string;
32
32
  }
33
33
 
34
+ /**
35
+ * Optional compose block in a format preset (task 0180, F616).
36
+ * When present, image-gen typesets the on-card copy from Noto Sans SC via
37
+ * headless Chromium instead of generating text as pixels. Requires `width`
38
+ * and `height` on the format (enforced by FormatPresetSchema refine).
39
+ */
40
+ export interface ComposeConfig {
41
+ /** The only v1 engine value; zod literal enforces it. */
42
+ engine: 'text-card';
43
+ /** Safe-area inset in px on all sides. */
44
+ safeArea: number;
45
+ /** Headline minimum font size in px. */
46
+ headlineMinPx: number;
47
+ /** Body/bullets minimum font size in px. */
48
+ bodyMinPx: number;
49
+ /** Height in px of the illustration band (top); text panel takes the rest. */
50
+ illustrationHeight: number;
51
+ palette: { background: string; text: string; accent: string };
52
+ /** Font filenames under `assets/fonts/`. */
53
+ fonts: { headline: string; body: string };
54
+ }
55
+
34
56
  /** R1 — `formats/<name>.yaml`: the job skeleton (template, keywords, variables). */
35
57
  export interface FormatPreset {
36
58
  /** Filename stem must equal this. */
@@ -51,6 +73,8 @@ export interface FormatPreset {
51
73
  variables: Record<string, FormatVariable>;
52
74
  /** Prompt body; YAML block scalar, embedded newlines preserved. */
53
75
  template: string;
76
+ /** Optional compose block (0180): when present, image-gen typesets card copy from a real font. */
77
+ compose?: ComposeConfig;
54
78
  }
55
79
 
56
80
  /** R1 — `styles/<name>.yaml`; `modifiers` is the only required prompt field. */
@@ -76,21 +100,37 @@ const PresetSourceSchema = z.object({
76
100
  confidence: z.enum(['high', 'medium', 'low']).optional(),
77
101
  });
78
102
 
79
- /** Parse gate for `formats/<name>.yaml`. */
80
- export const FormatPresetSchema = z.object({
81
- name: z.string(),
82
- description: z.string(),
83
- job: z.enum(['free', 'cover', 'illustration']),
84
- aspectRatio: z.string(),
85
- width: z.number().optional(),
86
- height: z.number().optional(),
87
- defaultStyle: z.string(),
88
- keywords: z.array(z.string()),
89
- outputFilename: z.string(),
90
- variables: z.record(z.string(), z.object({ description: z.string(), default: z.string() })),
91
- template: z.string(),
103
+ /** The zod schema for the optional compose block (0180). */
104
+ const ComposeConfigSchema = z.object({
105
+ engine: z.literal('text-card'),
106
+ safeArea: z.number(),
107
+ headlineMinPx: z.number(),
108
+ bodyMinPx: z.number(),
109
+ illustrationHeight: z.number(),
110
+ palette: z.object({ background: z.string(), text: z.string(), accent: z.string() }),
111
+ fonts: z.object({ headline: z.string(), body: z.string() }),
92
112
  });
93
113
 
114
+ /** Parse gate for `formats/<name>.yaml`. */
115
+ export const FormatPresetSchema = z
116
+ .object({
117
+ name: z.string(),
118
+ description: z.string(),
119
+ job: z.enum(['free', 'cover', 'illustration']),
120
+ aspectRatio: z.string(),
121
+ width: z.number().optional(),
122
+ height: z.number().optional(),
123
+ defaultStyle: z.string(),
124
+ keywords: z.array(z.string()),
125
+ outputFilename: z.string(),
126
+ variables: z.record(z.string(), z.object({ description: z.string(), default: z.string() })),
127
+ template: z.string(),
128
+ compose: ComposeConfigSchema.optional(),
129
+ })
130
+ .refine((data) => data.compose === undefined || (data.width !== undefined && data.height !== undefined), {
131
+ message: 'A format preset with a compose block requires both width and height to be declared',
132
+ });
133
+
94
134
  /** Parse gate for `styles/<name>.yaml`. */
95
135
  export const StylePresetSchema = z.object({
96
136
  name: z.string(),
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "kk",
3
- "version": "0.0.28",
3
+ "version": "0.0.29",
4
4
  "description": "knowledge-kit Claude Code plugin: skills, commands, subagents, hooks, and rules"
5
5
  }