@kamishibai/sdk 0.1.1
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/README.md +192 -0
- package/package.json +54 -0
- package/src/blocks/board.js +210 -0
- package/src/blocks/callout.js +63 -0
- package/src/blocks/code.js +28 -0
- package/src/blocks/deck.js +76 -0
- package/src/blocks/diagram.js +265 -0
- package/src/blocks/element.js +51 -0
- package/src/blocks/graph.js +264 -0
- package/src/blocks/grid.js +156 -0
- package/src/blocks/index.js +106 -0
- package/src/blocks/list.js +50 -0
- package/src/blocks/placement.js +119 -0
- package/src/blocks/prose.js +28 -0
- package/src/blocks/quote.js +25 -0
- package/src/blocks/raw.js +47 -0
- package/src/blocks/registry.js +158 -0
- package/src/blocks/schema-parts.js +19 -0
- package/src/blocks/section.js +53 -0
- package/src/blocks/slide.js +104 -0
- package/src/blocks/stat.js +83 -0
- package/src/blocks/table.js +50 -0
- package/src/blocks/timeline.js +80 -0
- package/src/cli/commands/close.js +51 -0
- package/src/cli/commands/comments.js +73 -0
- package/src/cli/commands/debug.js +34 -0
- package/src/cli/commands/example.js +22 -0
- package/src/cli/commands/export.js +10 -0
- package/src/cli/commands/init.js +53 -0
- package/src/cli/commands/lint.js +93 -0
- package/src/cli/commands/list.js +54 -0
- package/src/cli/commands/open.js +31 -0
- package/src/cli/commands/promote.js +38 -0
- package/src/cli/commands/render.js +31 -0
- package/src/cli/commands/replay.js +49 -0
- package/src/cli/commands/schema.js +10 -0
- package/src/cli/commands/serve.js +178 -0
- package/src/cli/commands/setup.js +64 -0
- package/src/cli/commands/snapshot.js +29 -0
- package/src/cli/commands/templates.js +75 -0
- package/src/cli/deliver.js +54 -0
- package/src/cli/emit.js +29 -0
- package/src/cli/format.js +153 -0
- package/src/cli/index.js +365 -0
- package/src/cli/registry.js +18 -0
- package/src/core/blocks.js +147 -0
- package/src/core/diagram.js +282 -0
- package/src/core/errors.js +156 -0
- package/src/core/example.js +109 -0
- package/src/core/ir.js +62 -0
- package/src/core/lint-gates.js +427 -0
- package/src/core/lint.js +281 -0
- package/src/core/scan.js +84 -0
- package/src/core/schema.js +88 -0
- package/src/core/spec-check.js +33 -0
- package/src/core/validate.js +44 -0
- package/src/core/version.js +18 -0
- package/src/core/vocabulary.js +140 -0
- package/src/delivery/atomic.js +71 -0
- package/src/delivery/comments.js +180 -0
- package/src/delivery/home.js +70 -0
- package/src/delivery/open.js +31 -0
- package/src/delivery/project.js +141 -0
- package/src/delivery/read.js +109 -0
- package/src/delivery/run.js +95 -0
- package/src/delivery/scaffold-blueprints.js +728 -0
- package/src/delivery/store.js +219 -0
- package/src/delivery/template-extensions.js +183 -0
- package/src/delivery/template-format.js +112 -0
- package/src/delivery/template-package.js +376 -0
- package/src/delivery/template-promote.js +240 -0
- package/src/delivery/template-scaffold.js +181 -0
- package/src/delivery/templates.js +192 -0
- package/src/delivery/toml.js +195 -0
- package/src/delivery/write.js +35 -0
- package/src/export/browser.js +130 -0
- package/src/export/index.js +96 -0
- package/src/export/pdf.js +25 -0
- package/src/export/png.js +40 -0
- package/src/export/pptx.js +48 -0
- package/src/export/slides.js +33 -0
- package/src/export/snapshot.js +33 -0
- package/src/layouts/article.js +103 -0
- package/src/layouts/canvas.js +144 -0
- package/src/layouts/card.js +128 -0
- package/src/layouts/deck.js +88 -0
- package/src/layouts/index.js +90 -0
- package/src/layouts/one-page.js +161 -0
- package/src/layouts/registry.js +251 -0
- package/src/layouts/resume.js +172 -0
- package/src/layouts/template-index.js +78 -0
- package/src/parser/artifact.js +38 -0
- package/src/parser/container.js +103 -0
- package/src/parser/index.js +223 -0
- package/src/parser/tokens.js +265 -0
- package/src/render/board-filter.client.js +80 -0
- package/src/render/compile.js +29 -0
- package/src/render/context.js +98 -0
- package/src/render/element.js +32 -0
- package/src/render/fonts.js +129 -0
- package/src/render/graph-hover.client.js +148 -0
- package/src/render/html.js +52 -0
- package/src/render/index.js +241 -0
- package/src/render/measure.js +60 -0
- package/src/render/placement.js +136 -0
- package/src/render/playback.client.js +74 -0
- package/src/render/scale-to-fit.client.js +136 -0
- package/src/render/scale.js +41 -0
- package/src/render/skeleton.js +131 -0
- package/src/render/ssr.js +24 -0
- package/src/render/styles.js +56 -0
- package/src/render/templates.js +191 -0
- package/src/serve/daemon.js +117 -0
- package/src/serve/overlay.js +213 -0
- package/src/serve/protocol.js +36 -0
- package/src/serve/server.js +264 -0
- package/templates/kami/cards/components.js +40 -0
- package/templates/kami/cards/index.js +25 -0
- package/templates/kami/cards/manifest.js +67 -0
- package/templates/kami/cards/styles.css +389 -0
- package/templates/kami/long-form/components.js +102 -0
- package/templates/kami/long-form/index.js +25 -0
- package/templates/kami/long-form/manifest.js +87 -0
- package/templates/kami/long-form/styles.css +481 -0
- package/templates/kami/one-page/components.js +48 -0
- package/templates/kami/one-page/index.js +27 -0
- package/templates/kami/one-page/manifest.js +65 -0
- package/templates/kami/one-page/styles.css +375 -0
- package/templates/kami/resume/components.js +51 -0
- package/templates/kami/resume/index.js +27 -0
- package/templates/kami/resume/manifest.js +65 -0
- package/templates/kami/resume/styles.css +424 -0
- package/templates/kami/slides/components.js +41 -0
- package/templates/kami/slides/index.js +26 -0
- package/templates/kami/slides/manifest.js +64 -0
- package/templates/kami/slides/styles.css +406 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url'
|
|
2
|
+
import { CODES, EXIT, KsbError } from '../core/errors.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The one place the SDK talks to a real browser.
|
|
6
|
+
*
|
|
7
|
+
* Export and snapshot cannot be faked: a PDF, a PPTX slide image and a PNG all
|
|
8
|
+
* require something that actually lays out CSS. So the browser is a hard
|
|
9
|
+
* dependency — and the *honest* way to carry a hard dependency is to name it
|
|
10
|
+
* when it is missing, with the exact command that fixes it (CONTRACT D4).
|
|
11
|
+
* Anything softer (a blank PDF, a stub PNG, a silent exit 0) would hand back a
|
|
12
|
+
* file that looks like a deliverable and is not one.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Verbatim CONTRACT constant — the exact setup command, quoted in the error. */
|
|
16
|
+
export const BROWSER_SETUP_COMMAND = 'npx playwright install chromium'
|
|
17
|
+
/** Verbatim CONTRACT constant — disposable cache, never a protected asset. */
|
|
18
|
+
export const BROWSER_CACHE_DIR = '~/.cache/ms-playwright'
|
|
19
|
+
/** WSL + nvm convention (SPEC §10.3): npm/npx must run through a login shell. */
|
|
20
|
+
export const BROWSER_SETUP_SHELL_HINT = `zsh -lic '${BROWSER_SETUP_COMMAND}'`
|
|
21
|
+
|
|
22
|
+
/** Playwright's own words when the binary is absent, in either channel. */
|
|
23
|
+
const MISSING_SIGNS = Object.freeze([
|
|
24
|
+
/Executable doesn't exist/i,
|
|
25
|
+
/Looks like Playwright was just installed/i,
|
|
26
|
+
/ENOENT/,
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
export const browserMissingError = (detail) =>
|
|
30
|
+
new KsbError({
|
|
31
|
+
code: CODES.BROWSER_MISSING,
|
|
32
|
+
message:
|
|
33
|
+
`找不到可用的瀏覽器:${detail}。` +
|
|
34
|
+
`export/snapshot 需要真的 Chromium 才能排版,請先執行 \`${BROWSER_SETUP_COMMAND}\`` +
|
|
35
|
+
`(WSL + nvm 請包成 ${BROWSER_SETUP_SHELL_HINT})。` +
|
|
36
|
+
`二進位檔落在 ${BROWSER_CACHE_DIR},是可丟棄的快取,隨時可刪掉重抓。`,
|
|
37
|
+
exitCode: EXIT.VALIDATION,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const exportFailedError = (detail) =>
|
|
41
|
+
new KsbError({
|
|
42
|
+
code: CODES.EXPORT_FAILED,
|
|
43
|
+
message: `瀏覽器操作失敗:${detail}`,
|
|
44
|
+
exitCode: EXIT.VALIDATION,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
/** Absence and breakage are different diagnoses; never report one as the other. */
|
|
48
|
+
const asBrowserError = (cause) => {
|
|
49
|
+
const detail = String(cause?.message ?? cause).split('\n')[0]
|
|
50
|
+
return MISSING_SIGNS.some((re) => re.test(String(cause?.message ?? cause)))
|
|
51
|
+
? browserMissingError(detail)
|
|
52
|
+
: exportFailedError(detail)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const loadChromium = async () => {
|
|
56
|
+
try {
|
|
57
|
+
const { chromium } = await import('playwright')
|
|
58
|
+
return chromium
|
|
59
|
+
} catch (cause) {
|
|
60
|
+
throw browserMissingError(`playwright 套件無法載入(${cause.message})`)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Viewport used for every export — and since F2f it is **exactly** the `deck`
|
|
66
|
+
* 文體's logical canvas (1280×720), so one slide exports at 1:1: no scaling to
|
|
67
|
+
* blur the type, and a per-slide screenshot that is the canvas itself.
|
|
68
|
+
*/
|
|
69
|
+
export const EXPORT_VIEWPORT = Object.freeze({ width: 1280, height: 720 })
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Open an artifact in a real browser and hand the page to `visit`.
|
|
73
|
+
*
|
|
74
|
+
* JavaScript is **off** on purpose. The deck template's playback script hides
|
|
75
|
+
* every slide but the current one, so a JS-enabled page would export a deck of
|
|
76
|
+
* blank pages. Without JS the artifact degrades to the scrolling document the
|
|
77
|
+
* template already promises is fully readable — which is exactly the form a
|
|
78
|
+
* PDF/PPTX should capture.
|
|
79
|
+
*/
|
|
80
|
+
export async function withArtifactPage(artifactPath, visit) {
|
|
81
|
+
const chromium = await loadChromium()
|
|
82
|
+
let browser
|
|
83
|
+
try {
|
|
84
|
+
browser = await chromium.launch()
|
|
85
|
+
} catch (cause) {
|
|
86
|
+
throw asBrowserError(cause)
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const context = await browser.newContext({
|
|
90
|
+
javaScriptEnabled: false,
|
|
91
|
+
viewport: { ...EXPORT_VIEWPORT },
|
|
92
|
+
})
|
|
93
|
+
const page = await context.newPage()
|
|
94
|
+
// Print media, chosen by the *template*, is the export form.
|
|
95
|
+
//
|
|
96
|
+
// kami/slides paints its playback chrome with `position: fixed`, so a
|
|
97
|
+
// per-slide element screenshot came back with a dark band across the bottom
|
|
98
|
+
// of every slide — chrome that belongs to the on-screen deck, not to the
|
|
99
|
+
// slide. The stylesheet already says what the printed form is (`@media
|
|
100
|
+
// print` hides the chrome and the shadows), so the exporter asks for that
|
|
101
|
+
// form instead of hard-coding a template's class names here.
|
|
102
|
+
await page.emulateMedia({ media: 'print' })
|
|
103
|
+
await page.goto(pathToFileURL(artifactPath).href, { waitUntil: 'load' })
|
|
104
|
+
return await visit(page)
|
|
105
|
+
} catch (cause) {
|
|
106
|
+
if (cause instanceof KsbError) throw cause
|
|
107
|
+
throw asBrowserError(cause)
|
|
108
|
+
} finally {
|
|
109
|
+
await browser.close().catch(() => {})
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Is a usable browser actually there? Answered by launching one, because that
|
|
115
|
+
* is the only question that matters — a present-but-unlaunchable binary is
|
|
116
|
+
* absent for every practical purpose.
|
|
117
|
+
*
|
|
118
|
+
* @returns {Promise<{available: boolean, reason: string, command: string}>}
|
|
119
|
+
*/
|
|
120
|
+
export async function probeBrowser() {
|
|
121
|
+
try {
|
|
122
|
+
const chromium = await loadChromium()
|
|
123
|
+
const browser = await chromium.launch()
|
|
124
|
+
await browser.close()
|
|
125
|
+
return { available: true, reason: '', command: BROWSER_SETUP_COMMAND }
|
|
126
|
+
} catch (cause) {
|
|
127
|
+
const error = cause instanceof KsbError ? cause : asBrowserError(cause)
|
|
128
|
+
return { available: false, reason: error.message, command: BROWSER_SETUP_COMMAND }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { basename, extname, join } from 'node:path'
|
|
2
|
+
import { findDeck } from '../core/blocks.js'
|
|
3
|
+
import { CODES, EXIT, KsbError, usageError } from '../core/errors.js'
|
|
4
|
+
import { readArtifact } from '../delivery/read.js'
|
|
5
|
+
import { writeBytes } from '../delivery/write.js'
|
|
6
|
+
import { readEmbeddedIr } from '../parser/artifact.js'
|
|
7
|
+
import { withArtifactPage } from './browser.js'
|
|
8
|
+
import { renderPdf } from './pdf.js'
|
|
9
|
+
import { renderPptx } from './pptx.js'
|
|
10
|
+
import { readPngSize } from './png.js'
|
|
11
|
+
import { renderSnapshot } from './snapshot.js'
|
|
12
|
+
|
|
13
|
+
export { BROWSER_SETUP_COMMAND, BROWSER_CACHE_DIR, probeBrowser } from './browser.js'
|
|
14
|
+
export { DEFAULT_SLIDE } from './snapshot.js'
|
|
15
|
+
|
|
16
|
+
/** Verbatim CONTRACT constants — the two附屬格式 and the root each one needs. */
|
|
17
|
+
export const EXPORT_FORMATS = Object.freeze(['pdf', 'pptx'])
|
|
18
|
+
export const FORMAT_ROOT = Object.freeze({ pdf: 'document', pptx: 'deck' })
|
|
19
|
+
export const SNAPSHOT_EXT = 'png'
|
|
20
|
+
|
|
21
|
+
/** Where a deliverable lands when the caller gave no `-o` (mirrors `render`). */
|
|
22
|
+
const defaultOut = (artifactPath, ext) =>
|
|
23
|
+
join('out', `${basename(artifactPath, extname(artifactPath))}.${ext}`)
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What an artifact *is*, read from its own embedded IR rather than its markup.
|
|
27
|
+
*
|
|
28
|
+
* The root form decides which export formats are even meaningful, so it must
|
|
29
|
+
* come from the record the artifact carries — grepping for slide markup would
|
|
30
|
+
* make a document that merely *mentions* a slide class exportable as a deck.
|
|
31
|
+
*
|
|
32
|
+
* @returns {{path: string, html: string, root: 'document'|'deck', slides: number|undefined}}
|
|
33
|
+
*/
|
|
34
|
+
export function artifactShape(target) {
|
|
35
|
+
const { html, path } = readArtifact(target)
|
|
36
|
+
const ir = readEmbeddedIr(html, path)
|
|
37
|
+
const deck = findDeck(ir.doc)
|
|
38
|
+
return {
|
|
39
|
+
path,
|
|
40
|
+
html,
|
|
41
|
+
root: deck === undefined ? 'document' : 'deck',
|
|
42
|
+
slides: deck === undefined ? undefined : deck.slides.length,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const formatMismatch = (format, root, path) =>
|
|
47
|
+
new KsbError({
|
|
48
|
+
code: CODES.EXPORT_FORMAT_MISMATCH,
|
|
49
|
+
message:
|
|
50
|
+
`--to ${format} 只適用於 ${FORMAT_ROOT[format]} 根形的產物,但 ${path} 是 ${root} 產物。` +
|
|
51
|
+
`文體轉換不是匯出:deck 請用 --to pptx,document 請用 --to pdf。`,
|
|
52
|
+
exitCode: EXIT.VALIDATION,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `export <artifact> --to pdf|pptx` (CONTRACT D3).
|
|
57
|
+
*
|
|
58
|
+
* The pairing is checked *before* the browser is launched: a wrong `--to` is an
|
|
59
|
+
* authoring mistake, and answering it with a 5-second browser start-up followed
|
|
60
|
+
* by the same rejection would only teach callers that export is slow.
|
|
61
|
+
*
|
|
62
|
+
* @returns {Promise<{result: {ok: true, artifact: string, format: string}, exitCode: number}>}
|
|
63
|
+
*/
|
|
64
|
+
export async function exportArtifact({ target, format, out }) {
|
|
65
|
+
if (!EXPORT_FORMATS.includes(format)) {
|
|
66
|
+
throw usageError(`--to 必須是 ${EXPORT_FORMATS.join(' 或 ')},收到 ${JSON.stringify(format)}`)
|
|
67
|
+
}
|
|
68
|
+
const shape = artifactShape(target)
|
|
69
|
+
if (shape.root !== FORMAT_ROOT[format]) throw formatMismatch(format, shape.root, shape.path)
|
|
70
|
+
|
|
71
|
+
const buffer = await withArtifactPage(shape.path, async (page) =>
|
|
72
|
+
format === 'pdf' ? await renderPdf(page) : (await renderPptx(page, shape.slides)).buffer,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
const { path } = writeBytes(out ?? defaultOut(shape.path, format), buffer)
|
|
76
|
+
return { result: { ok: true, artifact: path, format }, exitCode: EXIT.OK }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `snapshot <artifact> -o out.png [--slide N]` (CONTRACT D5).
|
|
81
|
+
*
|
|
82
|
+
* The reported dimensions are read back out of the PNG header, so the result
|
|
83
|
+
* describes the file on disk rather than the request that produced it.
|
|
84
|
+
*
|
|
85
|
+
* @returns {Promise<{result: {ok: true, artifact: string, width: number, height: number},
|
|
86
|
+
* exitCode: number}>}
|
|
87
|
+
*/
|
|
88
|
+
export async function snapshotArtifact({ target, out, slide }) {
|
|
89
|
+
const shape = artifactShape(target)
|
|
90
|
+
const buffer = await withArtifactPage(shape.path, async (page) =>
|
|
91
|
+
await renderSnapshot(page, { slide, deckSlides: shape.slides }),
|
|
92
|
+
)
|
|
93
|
+
const { width, height } = readPngSize(buffer)
|
|
94
|
+
const { path } = writeBytes(out ?? defaultOut(shape.path, SNAPSHOT_EXT), buffer)
|
|
95
|
+
return { result: { ok: true, artifact: path, width, height }, exitCode: EXIT.OK }
|
|
96
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { CODES, EXIT, KsbError } from '../core/errors.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Document artifact → PDF, printed by the browser that already knows how to lay
|
|
5
|
+
* the artifact out. Nothing is re-implemented here: the template's own
|
|
6
|
+
* `@media print` rules are what shape the page.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Verbatim PDF file signature. */
|
|
10
|
+
export const PDF_MAGIC = '%PDF-'
|
|
11
|
+
|
|
12
|
+
const PAGE_FORMAT = 'A4'
|
|
13
|
+
const MARGIN = Object.freeze({ top: '14mm', right: '14mm', bottom: '14mm', left: '14mm' })
|
|
14
|
+
|
|
15
|
+
export async function renderPdf(page) {
|
|
16
|
+
const buffer = await page.pdf({ format: PAGE_FORMAT, printBackground: true, margin: MARGIN })
|
|
17
|
+
if (buffer.subarray(0, PDF_MAGIC.length).toString('latin1') !== PDF_MAGIC) {
|
|
18
|
+
throw new KsbError({
|
|
19
|
+
code: CODES.EXPORT_FAILED,
|
|
20
|
+
message: `瀏覽器回傳的不是 PDF(開頭應為 ${PDF_MAGIC})`,
|
|
21
|
+
exitCode: EXIT.VALIDATION,
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
return buffer
|
|
25
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { CODES, EXIT, KsbError } from '../core/errors.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PNG header reading, so `snapshot` reports the dimensions of the file it
|
|
5
|
+
* actually wrote rather than the dimensions it asked for. A screenshot that
|
|
6
|
+
* came back clipped, scaled or empty is exactly the failure a caller cannot see
|
|
7
|
+
* in a JSON result — so the number is read out of the bytes.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Verbatim PNG signature (RFC 2083 §3.1). */
|
|
11
|
+
export const PNG_MAGIC = Object.freeze([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
|
12
|
+
|
|
13
|
+
const IHDR_OFFSET = 12
|
|
14
|
+
const WIDTH_OFFSET = 16
|
|
15
|
+
const HEIGHT_OFFSET = 20
|
|
16
|
+
const MIN_LENGTH = 24
|
|
17
|
+
|
|
18
|
+
const malformed = (detail) =>
|
|
19
|
+
new KsbError({
|
|
20
|
+
code: CODES.EXPORT_FAILED,
|
|
21
|
+
message: `截圖不是合法的 PNG:${detail}`,
|
|
22
|
+
exitCode: EXIT.VALIDATION,
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
/** @returns {{width: number, height: number}} */
|
|
26
|
+
export function readPngSize(buffer) {
|
|
27
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < MIN_LENGTH) {
|
|
28
|
+
throw malformed(`長度只有 ${buffer?.length ?? 0} bytes`)
|
|
29
|
+
}
|
|
30
|
+
for (const [index, byte] of PNG_MAGIC.entries()) {
|
|
31
|
+
if (buffer[index] !== byte) throw malformed(`第 ${index} 個位元組不符 PNG 簽章`)
|
|
32
|
+
}
|
|
33
|
+
if (buffer.subarray(IHDR_OFFSET, IHDR_OFFSET + 4).toString('latin1') !== 'IHDR') {
|
|
34
|
+
throw malformed('缺少 IHDR 區塊')
|
|
35
|
+
}
|
|
36
|
+
const width = buffer.readUInt32BE(WIDTH_OFFSET)
|
|
37
|
+
const height = buffer.readUInt32BE(HEIGHT_OFFSET)
|
|
38
|
+
if (width === 0 || height === 0) throw malformed(`尺寸為 ${width}×${height}`)
|
|
39
|
+
return { width, height }
|
|
40
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import PptxGenJS from 'pptxgenjs'
|
|
2
|
+
import { CODES, EXIT, KsbError } from '../core/errors.js'
|
|
3
|
+
import { slideHandles } from './slides.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Deck artifact → PPTX, one slide per `<section class="slide">`.
|
|
7
|
+
*
|
|
8
|
+
* v1 puts a **screenshot** on each slide. That is a deliberate floor, not an
|
|
9
|
+
* oversight: the alternative — re-deriving PowerPoint shapes from the block tree
|
|
10
|
+
* — is a second renderer, and a second renderer is a second answer to "what does
|
|
11
|
+
* this deck look like". A picture of the real artifact cannot disagree with the
|
|
12
|
+
* artifact. The price is honest and stated: the text is not selectable in
|
|
13
|
+
* PowerPoint. Structured shapes are later work, driven by the IR.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** 16:9, matching the `deck` 文體's logical canvas (1280×720, src/layouts/deck.js). */
|
|
17
|
+
const LAYOUT = 'LAYOUT_16x9'
|
|
18
|
+
const FULL_BLEED = Object.freeze({ x: 0, y: 0, w: '100%', h: '100%' })
|
|
19
|
+
|
|
20
|
+
export async function renderPptx(page, expectedSlides) {
|
|
21
|
+
const handles = await slideHandles(page, expectedSlides)
|
|
22
|
+
|
|
23
|
+
const shots = []
|
|
24
|
+
for (const handle of handles) {
|
|
25
|
+
shots.push(await handle.screenshot({ type: 'png' }))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const pptx = new PptxGenJS()
|
|
29
|
+
pptx.layout = LAYOUT
|
|
30
|
+
for (const shot of shots) {
|
|
31
|
+
pptx.addSlide().addImage({
|
|
32
|
+
...FULL_BLEED,
|
|
33
|
+
data: `data:image/png;base64,${shot.toString('base64')}`,
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const written = await pptx.write({ outputType: 'nodebuffer' })
|
|
38
|
+
const buffer = Buffer.isBuffer(written) ? written : Buffer.from(written)
|
|
39
|
+
// A PPTX is a ZIP container; `PK` is the local file header signature.
|
|
40
|
+
if (buffer.subarray(0, 2).toString('latin1') !== 'PK') {
|
|
41
|
+
throw new KsbError({
|
|
42
|
+
code: CODES.EXPORT_FAILED,
|
|
43
|
+
message: 'pptx 產出不是合法的 OOXML 容器(開頭應為 PK)',
|
|
44
|
+
exitCode: EXIT.VALIDATION,
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
return { buffer, slides: handles.length }
|
|
48
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { CODES, EXIT, KsbError } from '../core/errors.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Verbatim CONTRACT constant — a deck slide *is* `<section class="slide">`.
|
|
5
|
+
* The exporter counts the same containers the template emits, so "one slide per
|
|
6
|
+
* section" is checked against the rendered DOM rather than assumed.
|
|
7
|
+
*/
|
|
8
|
+
export const SLIDE_SELECTOR = 'section.slide'
|
|
9
|
+
|
|
10
|
+
const exportFailed = (message) =>
|
|
11
|
+
new KsbError({ code: CODES.EXPORT_FAILED, message, exitCode: EXIT.VALIDATION })
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Every slide container in a rendered deck, in document order.
|
|
15
|
+
*
|
|
16
|
+
* `expected` is the slide count the artifact's own embedded IR claims. The two
|
|
17
|
+
* must agree: a DOM that drew fewer sections than the IR carries means the
|
|
18
|
+
* export would silently lose pages, which is precisely the class of failure
|
|
19
|
+
* that motivated the template vocabulary guard in S3a.
|
|
20
|
+
*/
|
|
21
|
+
export async function slideHandles(page, expected) {
|
|
22
|
+
const handles = await page.locator(SLIDE_SELECTOR).all()
|
|
23
|
+
if (handles.length === 0) {
|
|
24
|
+
throw exportFailed(`產物內找不到任何 \`${SLIDE_SELECTOR}\`,無法逐張匯出`)
|
|
25
|
+
}
|
|
26
|
+
if (Number.isInteger(expected) && handles.length !== expected) {
|
|
27
|
+
throw exportFailed(
|
|
28
|
+
`產物的 ${SLIDE_SELECTOR} 數(${handles.length})與內嵌 IR 的張數(${expected})不符;` +
|
|
29
|
+
'匯出會漏頁,故不產出檔案',
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
return handles
|
|
33
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { usageError } from '../core/errors.js'
|
|
2
|
+
import { slideHandles, SLIDE_SELECTOR } from './slides.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Artifact → PNG, so an Agent can *see* what it rendered (SPEC §10.1).
|
|
6
|
+
*
|
|
7
|
+
* A deck is snapshotted one slide at a time (CONTRACT D5: slide 1 by default),
|
|
8
|
+
* because a full-page shot of a deck is a column of thumbnails — technically an
|
|
9
|
+
* image, useless as a look at the deck.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Verbatim CONTRACT constant — deck snapshots default to the first slide. */
|
|
13
|
+
export const DEFAULT_SLIDE = 1
|
|
14
|
+
|
|
15
|
+
export async function renderSnapshot(page, { slide, deckSlides } = {}) {
|
|
16
|
+
const isDeck = Number.isInteger(deckSlides)
|
|
17
|
+
|
|
18
|
+
if (!isDeck) {
|
|
19
|
+
if (slide !== undefined) {
|
|
20
|
+
throw usageError(
|
|
21
|
+
`--slide 只適用於 deck 產物;這份是 document 產物(沒有 ${SLIDE_SELECTOR})`,
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
return await page.screenshot({ type: 'png', fullPage: true })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const handles = await slideHandles(page, deckSlides)
|
|
28
|
+
const wanted = slide === undefined ? DEFAULT_SLIDE : slide
|
|
29
|
+
if (!Number.isInteger(wanted) || wanted < 1 || wanted > handles.length) {
|
|
30
|
+
throw usageError(`--slide 必須是 1…${handles.length} 之間的整數,收到 ${JSON.stringify(slide)}`)
|
|
31
|
+
}
|
|
32
|
+
return await handles[wanted - 1].screenshot({ type: 'png' })
|
|
33
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { el } from '../blocks/element.js'
|
|
2
|
+
import { CANVAS_FLOWING } from './registry.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `article` — the flowing long-form 文體: one column of text, read by scrolling.
|
|
6
|
+
*
|
|
7
|
+
* What this module supplies is the *cut*: an outer frame, a body region, and
|
|
8
|
+
* two named slots above and below it. What goes **into** those slots — a
|
|
9
|
+
* masthead, a colophon, a table of contents, nothing at all — is the template's
|
|
10
|
+
* business, and so is every word of it. That division is the whole point of
|
|
11
|
+
* lifting the root form out of `templates/kami/long-form/components.js`: the
|
|
12
|
+
* same layout has to be able to carry a report and a maintenance page.
|
|
13
|
+
*
|
|
14
|
+
* The canvas is flowing, so `measure` is a *maximum* the body respects rather
|
|
15
|
+
* than a fixed logical size, and the artifact simply gets taller with its
|
|
16
|
+
* content (issue 14 裁決 4「流動文體流式」).
|
|
17
|
+
*
|
|
18
|
+
* ## The rail is a knob, not a 文體 (CONTRACT F2b)
|
|
19
|
+
*
|
|
20
|
+
* 「上面掛 header、旁邊塞 side menu、中間內文、底下 footer」 is what a long-form
|
|
21
|
+
* page looks like when it is finished, and the side region is the piece this
|
|
22
|
+
* layout was missing. It arrives here as **geometry**: a switch, a width, a
|
|
23
|
+
* side — plus one more slot for whoever fills it. What goes in is not this
|
|
24
|
+
* module's business and never can be, which is exactly why a table of contents
|
|
25
|
+
* is *not* a sixth 文體: it is a template computing chrome out of the section
|
|
26
|
+
* tree, on the same layout, through the same door as the masthead (量測報告
|
|
27
|
+
* §5.4). A rail that knew it contained a table of contents would be a rail no
|
|
28
|
+
* maintenance page could ever use for anything else.
|
|
29
|
+
*
|
|
30
|
+
* The knob is off by factory default, which is what makes every article
|
|
31
|
+
* template written before this slice byte-identical after it: an existing
|
|
32
|
+
* package turns nothing, resolves `rail: false`, and gets exactly the tree it
|
|
33
|
+
* got before. `kami/long-form` turns it on, because a long-form skin with a
|
|
34
|
+
* table of contents is what that particular template *looks like*.
|
|
35
|
+
*/
|
|
36
|
+
export default Object.freeze({
|
|
37
|
+
name: 'article',
|
|
38
|
+
|
|
39
|
+
canvas: CANVAS_FLOWING,
|
|
40
|
+
|
|
41
|
+
/** No required root form: an article draws whatever sits under `doc`. */
|
|
42
|
+
rootForm: null,
|
|
43
|
+
|
|
44
|
+
/** The built-in generic template of this 文體 — `render` never wants for one. */
|
|
45
|
+
defaultTemplate: 'kami/long-form',
|
|
46
|
+
|
|
47
|
+
slots: Object.freeze(['doc-lead', 'doc-rail', 'doc-foot']),
|
|
48
|
+
|
|
49
|
+
styleHooks: Object.freeze(['paper', 'doc-rail', 'doc-body']),
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Factory geometry, in CSS pixels — the fallback when a template names no
|
|
53
|
+
* value of its own. Geometry only: a content-shaped default would make this
|
|
54
|
+
* layout mean something, and a layout that means something can only carry the
|
|
55
|
+
* documents it already imagined.
|
|
56
|
+
*/
|
|
57
|
+
defaults: Object.freeze({ maxWidth: 740, measure: 740 }),
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* This 文體's own geometry — the side region, in CSS pixels and plain words.
|
|
61
|
+
* Off by default (third-party compatibility, above); `start` and `end` are
|
|
62
|
+
* sides rather than left and right, so the same knob still means something
|
|
63
|
+
* when the page runs the other way.
|
|
64
|
+
*/
|
|
65
|
+
knobs: Object.freeze({ rail: false, railWidth: 240, railSide: 'start' }),
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The rail region is drawn whenever the **knob** says so, empty slot or not.
|
|
69
|
+
*
|
|
70
|
+
* Tying it to "the template actually filled the slot" would read as tidier
|
|
71
|
+
* and would quietly make the applied state depend on the document: the same
|
|
72
|
+
* package would be a two-column page for one file and a one-column page for
|
|
73
|
+
* the next, with nothing in its manifest saying either. A region that is
|
|
74
|
+
* switched on and left empty is the template's own doing, and its stylesheet
|
|
75
|
+
* is where that is answered (`.doc-rail:empty`) — one place, in the layer
|
|
76
|
+
* that owns appearance.
|
|
77
|
+
*
|
|
78
|
+
* `...(x ? [n] : [])` rather than `x ? n : null`, because a `null` child is a
|
|
79
|
+
* deliberate empty comment node in the artifact: spreading nothing is what
|
|
80
|
+
* keeps an unknobbed package byte-identical (CONTRACT F2b G7).
|
|
81
|
+
*/
|
|
82
|
+
root: (doc, ctx) => {
|
|
83
|
+
const railed = ctx.canvas.rail === true
|
|
84
|
+
return el(
|
|
85
|
+
'article',
|
|
86
|
+
{
|
|
87
|
+
class: 'paper',
|
|
88
|
+
'data-template': ctx.templateKey,
|
|
89
|
+
...(railed ? { 'data-rail': ctx.canvas.railSide } : {}),
|
|
90
|
+
},
|
|
91
|
+
[
|
|
92
|
+
...ctx.chrome('doc-lead', ctx.meta),
|
|
93
|
+
// The rail's payload is the document tree, because navigating a
|
|
94
|
+
// document is what a rail on *this* 文體 is for. What is made of it —
|
|
95
|
+
// a table of contents, a progress meter, nothing — stays the
|
|
96
|
+
// template's, exactly like the masthead's wording.
|
|
97
|
+
...(railed ? [el('aside', { class: 'doc-rail' }, ctx.chrome('doc-rail', doc))] : []),
|
|
98
|
+
el('main', { class: 'doc-body' }, ctx.renderChildren(doc.children)),
|
|
99
|
+
...ctx.chrome('doc-foot', ctx.meta),
|
|
100
|
+
],
|
|
101
|
+
)
|
|
102
|
+
},
|
|
103
|
+
})
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CANVAS_BOUNDED,
|
|
3
|
+
GRID_ADDRESSED_FIELD,
|
|
4
|
+
LAYOUT_DEFAULT_KEYS,
|
|
5
|
+
LAYOUT_KNOBS_FIELD,
|
|
6
|
+
} from './registry.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolving the canvas: **layout defines the knobs, the template turns them.**
|
|
10
|
+
*
|
|
11
|
+
* `maxWidth` and `measure` are two independent numbers, and keeping them apart
|
|
12
|
+
* is the whole of CONTRACT C3. They used to be one: `--measure: 740px` was
|
|
13
|
+
* simultaneously the width of the page and the width of a line of text, which
|
|
14
|
+
* is why a diagram in a narrow column had no width to be measured against
|
|
15
|
+
* (量測報告 §4.2). A page can be 1120px wide and still hold a 740px column of
|
|
16
|
+
* text — 「一般網站的玩法」 (issue 14 裁決 3), not a value derived from column
|
|
17
|
+
* arithmetic.
|
|
18
|
+
*
|
|
19
|
+
* The layout ships factory geometry; the template's manifest carries the values
|
|
20
|
+
* this particular skin applies. Nothing here reads a stylesheet: the numbers
|
|
21
|
+
* flow the other way, from config into CSS (see `src/render/styles.js`).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Verbatim — `KSB_LAYOUT_CANVAS_INVALID`, kept as data so this layer imports nothing. */
|
|
25
|
+
export const CANVAS_INVALID_CODE = 'KSB_LAYOUT_CANVAS_INVALID'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Verbatim — the attribute a bounded canvas element wears, value `<w>x<h>`.
|
|
29
|
+
*
|
|
30
|
+
* It lives *here*, with the resolved canvas, rather than beside the browser
|
|
31
|
+
* asset that reads it, because three parties have to spell it identically and
|
|
32
|
+
* none of them may own it: the skeleton script that scales every element
|
|
33
|
+
* wearing it (`src/render/scale-to-fit.client.js`), the render layer that
|
|
34
|
+
* re-exports it (`src/render/scale.js`), and whichever **block** draws the
|
|
35
|
+
* element itself. A block must not import the render layer to learn one string,
|
|
36
|
+
* and a second copy of the string is a scale factor that silently stays 1.
|
|
37
|
+
*/
|
|
38
|
+
export const CANVAS_ATTR = 'data-ksb-canvas'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The attributes a bounded canvas element carries.
|
|
42
|
+
*
|
|
43
|
+
* The size travels in the markup rather than in the script because the script
|
|
44
|
+
* is one shared skeleton asset: a third-party bounded layout that never heard
|
|
45
|
+
* of it still gets scaled, as long as it says how big its canvas is.
|
|
46
|
+
*/
|
|
47
|
+
export function canvasAttrs({ width, height }) {
|
|
48
|
+
return {
|
|
49
|
+
[CANVAS_ATTR]: `${width}x${height}`,
|
|
50
|
+
style: `width: ${width}px; height: ${height}px`,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The applied canvas of one artifact.
|
|
56
|
+
*
|
|
57
|
+
* @param {{name: string, canvas: string, defaults: object}} layout
|
|
58
|
+
* @param {{maxWidth?: number, measure?: number}} manifest the template's applied state
|
|
59
|
+
*/
|
|
60
|
+
export function resolveCanvas(layout, manifest) {
|
|
61
|
+
const defaults = layout.defaults
|
|
62
|
+
const applied = {}
|
|
63
|
+
for (const key of LAYOUT_DEFAULT_KEYS) {
|
|
64
|
+
const override = manifest?.[key]
|
|
65
|
+
applied[key] = typeof override === 'number' ? override : defaults[key]
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* This 文體's own knobs, turned by the template (CONTRACT F2b G1).
|
|
69
|
+
*
|
|
70
|
+
* An override of the wrong *type* is ignored here and reported by
|
|
71
|
+
* `canvasProblems` rather than coerced: `rail = "false"` is a truthy string,
|
|
72
|
+
* so coercion would draw a side region for a template that switched it off,
|
|
73
|
+
* and the artifact would look deliberate.
|
|
74
|
+
*/
|
|
75
|
+
const knobs = {}
|
|
76
|
+
for (const [name, fallback] of Object.entries(layout[LAYOUT_KNOBS_FIELD] ?? {})) {
|
|
77
|
+
const override = manifest?.[name]
|
|
78
|
+
knobs[name] = typeof override === typeof fallback ? override : fallback
|
|
79
|
+
}
|
|
80
|
+
const canvasSize = defaults.canvasSize ?? null
|
|
81
|
+
return Object.freeze({
|
|
82
|
+
layout: layout.name,
|
|
83
|
+
canvas: layout.canvas,
|
|
84
|
+
canvasSize,
|
|
85
|
+
/**
|
|
86
|
+
* Ready-made markup attributes for the element that *is* this canvas, or
|
|
87
|
+
* `null` when the artifact has no fixed logical canvas at all. Resolved
|
|
88
|
+
* once, here, so the block that draws the element neither recomputes the
|
|
89
|
+
* format nor has to reach up into the render layer for it.
|
|
90
|
+
*/
|
|
91
|
+
canvasAttrs: canvasSize === null ? null : Object.freeze(canvasAttrs(canvasSize)),
|
|
92
|
+
// Absent means false, and false is the safe direction: an unrecognised
|
|
93
|
+
// layout shape must not accidentally licence bare coordinates.
|
|
94
|
+
gridAddressed: layout[GRID_ADDRESSED_FIELD] === true,
|
|
95
|
+
...knobs,
|
|
96
|
+
...applied,
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Why a resolved canvas cannot be drawn — empty array when it can.
|
|
102
|
+
*
|
|
103
|
+
* A text measure wider than the page it sits on is **not** clamped. Clamping
|
|
104
|
+
* would produce an artifact that renders, looks nearly right, and disagrees
|
|
105
|
+
* with the config it came from: the type scale would be tuned against a width
|
|
106
|
+
* the page never gives it, and nobody would ever be told. Loud is the only
|
|
107
|
+
* honest answer, which is the same reasoning that refuses a half-drawn diagram.
|
|
108
|
+
*
|
|
109
|
+
* `layout` and `manifest` are optional and carry the third refusal: a knob
|
|
110
|
+
* turned to the wrong *kind* of value. It needs both sides, because a resolved
|
|
111
|
+
* canvas alone cannot tell "the template left this alone" from "the template
|
|
112
|
+
* set it to something this knob is not" — the fallback looks identical.
|
|
113
|
+
*
|
|
114
|
+
* @param {object} resolved the canvas `resolveCanvas` produced
|
|
115
|
+
* @param {object} [layout] the layout module it was resolved from
|
|
116
|
+
* @param {object} [manifest] the template's applied state
|
|
117
|
+
*/
|
|
118
|
+
export function canvasProblems(resolved, layout, manifest) {
|
|
119
|
+
const problems = []
|
|
120
|
+
for (const [name, fallback] of Object.entries(layout?.[LAYOUT_KNOBS_FIELD] ?? {})) {
|
|
121
|
+
const override = manifest?.[name]
|
|
122
|
+
if (override === undefined || typeof override === typeof fallback) continue
|
|
123
|
+
problems.push(
|
|
124
|
+
`layout \`${resolved.layout}\` 的旋鈕 \`${name}\` 要的是 ${typeof fallback},` +
|
|
125
|
+
`模板給的是 ${typeof override}(${JSON.stringify(override)})。` +
|
|
126
|
+
'這裡不轉型也不靜默落回底值:字串 "false" 是真值,' +
|
|
127
|
+
'於是「關著的旋鈕」會畫出一塊誰也沒要求過的版面,而且看起來像是刻意的。',
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
if (resolved.measure > resolved.maxWidth) {
|
|
131
|
+
problems.push(
|
|
132
|
+
`layout \`${resolved.layout}\` 的內文寬 measure ${resolved.measure}px ` +
|
|
133
|
+
`大於畫布上限 maxWidth ${resolved.maxWidth}px。` +
|
|
134
|
+
'內文不得寬過畫布——這裡不靜默夾持:夾小的字級節奏會與版面上限對不上,' +
|
|
135
|
+
'而產物仍舊畫得出來,於是沒有人會發現。',
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
if (resolved.canvas === CANVAS_BOUNDED && resolved.canvasSize === null) {
|
|
139
|
+
problems.push(
|
|
140
|
+
`layout \`${resolved.layout}\` 宣告 ${CANVAS_BOUNDED} 畫布卻沒有固定邏輯畫布尺寸`,
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
return problems
|
|
144
|
+
}
|