@cyber-dash-tech/revela 0.17.14 → 0.17.16
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/bin/revela.ts +1 -1
- package/lib/browser/chrome.ts +48 -0
- package/lib/commands/pdf.ts +1 -3
- package/lib/commands/pptx.ts +0 -2
- package/lib/html-export/deck-detect.ts +78 -0
- package/lib/html-export/index.ts +231 -0
- package/lib/html-export/pdf.ts +15 -0
- package/lib/html-export/selectors.ts +12 -0
- package/lib/html-export/trim.ts +60 -0
- package/lib/html-export/validate.ts +44 -0
- package/lib/pdf/export.ts +35 -34
- package/lib/pptx/export.ts +5 -33
- package/lib/qa/measure.ts +2 -35
- package/lib/runtime/index.ts +2 -4
- package/package.json +1 -1
- package/plugins/revela/.mcp.json +1 -1
- package/plugins/revela/mcp/revela-server.ts +2 -2
- package/plugins/revela/skills/revela-export/SKILL.md +6 -6
- package/tools/pdf.ts +1 -4
- package/tools/pptx.ts +0 -2
- package/tools/qa.ts +2 -2
- package/lib/qa/export-gate.ts +0 -18
package/bin/revela.ts
CHANGED
|
@@ -86,7 +86,7 @@ Usage:
|
|
|
86
86
|
revela deck-foundation --outputPath <path> --title <title> --language <tag> [--workspaceRoot <path>] [--designName <name>] [--mode create|repair] [--overwrite true]
|
|
87
87
|
revela qa --file <path> [--workspaceRoot <path>]
|
|
88
88
|
revela review-read --file <path> [--workspaceRoot <path>] [--format json|markdown]
|
|
89
|
-
revela export-pdf --file <path> [--workspaceRoot <path>]
|
|
89
|
+
revela export-pdf --file <path> [--workspaceRoot <path>] # deck PDF, or single-page PDF fallback for non-deck HTML
|
|
90
90
|
revela export-pptx --file <path> [--workspaceRoot <path>]
|
|
91
91
|
revela design-list
|
|
92
92
|
revela design-read [--name <design>]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import puppeteer, { type Browser } from "puppeteer-core"
|
|
2
|
+
import { existsSync } from "fs"
|
|
3
|
+
|
|
4
|
+
export const CHROME_PATHS = [
|
|
5
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
6
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
7
|
+
"/usr/bin/google-chrome-stable",
|
|
8
|
+
"/usr/bin/google-chrome",
|
|
9
|
+
"/usr/bin/chromium-browser",
|
|
10
|
+
"/usr/bin/chromium",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
export function findChromePath(): string {
|
|
14
|
+
for (const p of CHROME_PATHS) {
|
|
15
|
+
if (existsSync(p)) return p
|
|
16
|
+
}
|
|
17
|
+
throw new Error(
|
|
18
|
+
"Could not find a Chrome/Chromium installation.\n" +
|
|
19
|
+
"Tried:\n" + CHROME_PATHS.map((p) => ` ${p}`).join("\n")
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface LaunchChromeOptions {
|
|
24
|
+
width?: number
|
|
25
|
+
height?: number
|
|
26
|
+
allowFileAccess?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function launchChrome(options: LaunchChromeOptions = {}): Promise<Browser> {
|
|
30
|
+
const width = options.width ?? 1920
|
|
31
|
+
const height = options.height ?? 1080
|
|
32
|
+
const args = [
|
|
33
|
+
"--no-sandbox",
|
|
34
|
+
"--disable-setuid-sandbox",
|
|
35
|
+
"--disable-dev-shm-usage",
|
|
36
|
+
`--window-size=${width},${height}`,
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
if (options.allowFileAccess) {
|
|
40
|
+
args.splice(3, 0, "--allow-file-access-from-files")
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return await puppeteer.launch({
|
|
44
|
+
executablePath: findChromePath(),
|
|
45
|
+
headless: true,
|
|
46
|
+
args,
|
|
47
|
+
})
|
|
48
|
+
}
|
package/lib/commands/pdf.ts
CHANGED
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
import { existsSync, readdirSync } from "fs"
|
|
11
11
|
import { resolve } from "path"
|
|
12
12
|
import { exportToPdf } from "../pdf/export"
|
|
13
|
-
import { assertExportQAPassed } from "../qa/export-gate"
|
|
14
13
|
import { recordRenderedArtifact, workspaceRelative } from "../workspace-state/rendered-artifacts"
|
|
15
14
|
|
|
16
15
|
export async function handlePdf(
|
|
@@ -30,10 +29,8 @@ export async function handlePdf(
|
|
|
30
29
|
}
|
|
31
30
|
|
|
32
31
|
const abs = resolvedFile.deck.absoluteFile
|
|
33
|
-
await send(`Running pre-export QA for \`${abs}\`...`)
|
|
34
32
|
|
|
35
33
|
try {
|
|
36
|
-
await assertExportQAPassed(abs, { workspaceRoot: root })
|
|
37
34
|
await send(`Exporting \`${abs}\` to PDF...`)
|
|
38
35
|
const result = await exportToPdf(abs)
|
|
39
36
|
recordRenderedArtifact(root, {
|
|
@@ -46,6 +43,7 @@ export async function handlePdf(
|
|
|
46
43
|
await send(
|
|
47
44
|
`**PDF exported successfully**\n\n` +
|
|
48
45
|
`- Output: \`${result.outputPath}\`\n` +
|
|
46
|
+
`- Mode: ${result.exportMode}\n` +
|
|
49
47
|
`- Slides: ${result.slideCount}\n` +
|
|
50
48
|
`- Time: ${secs}s`
|
|
51
49
|
)
|
package/lib/commands/pptx.ts
CHANGED
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
|
|
10
10
|
import { existsSync, readdirSync } from "fs"
|
|
11
11
|
import { relative, resolve, sep } from "path"
|
|
12
|
-
import { assertDeckHtmlContractValid } from "../deck-html/contract"
|
|
13
12
|
import { exportToPptx } from "../pptx/export"
|
|
14
13
|
import { recordRenderedArtifact } from "../workspace-state/rendered-artifacts"
|
|
15
14
|
|
|
@@ -103,7 +102,6 @@ export async function handlePptx(
|
|
|
103
102
|
const deck = resolvePptxDeck(workspaceRoot, args.filePath)
|
|
104
103
|
const abs = deck.absoluteFile
|
|
105
104
|
|
|
106
|
-
assertDeckHtmlContractValid(workspaceRoot, abs)
|
|
107
105
|
await send(`Exporting \`${abs}\` to PPTX...`)
|
|
108
106
|
let lastSlideUpdate = 0
|
|
109
107
|
let longDeckThreshold: number | null = null
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Browser } from "puppeteer-core"
|
|
2
|
+
import { pathToFileURL } from "url"
|
|
3
|
+
import { launchChrome } from "../browser/chrome"
|
|
4
|
+
|
|
5
|
+
export interface DeckDetectionResult {
|
|
6
|
+
isDeck: boolean
|
|
7
|
+
slideCount: number
|
|
8
|
+
reason: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function detectDeckHtml(htmlFilePath: string): Promise<DeckDetectionResult> {
|
|
12
|
+
const browser = await launchChrome({ allowFileAccess: true })
|
|
13
|
+
try {
|
|
14
|
+
return await detectDeckHtmlWithBrowser(browser, htmlFilePath)
|
|
15
|
+
} finally {
|
|
16
|
+
await browser.close().catch(() => undefined)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function detectDeckHtmlWithBrowser(browser: Browser, htmlFilePath: string): Promise<DeckDetectionResult> {
|
|
21
|
+
const page = await browser.newPage()
|
|
22
|
+
try {
|
|
23
|
+
await page.goto(pathToFileURL(htmlFilePath).href, { waitUntil: "domcontentloaded", timeout: 15000 })
|
|
24
|
+
return await page.evaluate(() => {
|
|
25
|
+
const slides = Array.from(document.querySelectorAll(".slide")) as HTMLElement[]
|
|
26
|
+
if (slides.length === 0) {
|
|
27
|
+
return { isDeck: false, slideCount: 0, reason: "no .slide elements found" }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const missingCanvas = slides.findIndex((slide) => !slide.querySelector(".slide-canvas"))
|
|
31
|
+
if (missingCanvas >= 0) {
|
|
32
|
+
return {
|
|
33
|
+
isDeck: false,
|
|
34
|
+
slideCount: slides.length,
|
|
35
|
+
reason: `.slide ${missingCanvas + 1} has no .slide-canvas`,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const indexValues = slides
|
|
40
|
+
.map((slide) => slide.getAttribute("data-slide-index"))
|
|
41
|
+
.filter((value): value is string => value !== null && value.trim() !== "")
|
|
42
|
+
|
|
43
|
+
if (indexValues.length > 0) {
|
|
44
|
+
if (indexValues.length !== slides.length) {
|
|
45
|
+
return {
|
|
46
|
+
isDeck: false,
|
|
47
|
+
slideCount: slides.length,
|
|
48
|
+
reason: "some slides have data-slide-index and some do not",
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const seen = new Set<number>()
|
|
53
|
+
for (let i = 0; i < indexValues.length; i++) {
|
|
54
|
+
const parsed = Number(indexValues[i])
|
|
55
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
56
|
+
return {
|
|
57
|
+
isDeck: false,
|
|
58
|
+
slideCount: slides.length,
|
|
59
|
+
reason: `slide ${i + 1} has invalid data-slide-index "${indexValues[i]}"`,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (seen.has(parsed)) {
|
|
63
|
+
return {
|
|
64
|
+
isDeck: false,
|
|
65
|
+
slideCount: slides.length,
|
|
66
|
+
reason: `duplicate data-slide-index "${parsed}"`,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
seen.add(parsed)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { isDeck: true, slideCount: slides.length, reason: "valid deck contract" }
|
|
74
|
+
})
|
|
75
|
+
} finally {
|
|
76
|
+
await page.close().catch(() => undefined)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from "fs"
|
|
2
|
+
import { dirname, join, resolve } from "path"
|
|
3
|
+
import { pathToFileURL } from "url"
|
|
4
|
+
import { randomBytes } from "crypto"
|
|
5
|
+
import { Jimp } from "jimp"
|
|
6
|
+
import type { Page } from "puppeteer-core"
|
|
7
|
+
import { launchChrome } from "../browser/chrome"
|
|
8
|
+
import { chooseSelector } from "./selectors"
|
|
9
|
+
import { findTrimmedHeight } from "./trim"
|
|
10
|
+
import { validatePngOutput } from "./validate"
|
|
11
|
+
import { writeSinglePagePdfFromPng } from "./pdf"
|
|
12
|
+
|
|
13
|
+
export interface ExportSinglePageHtmlPdfOptions {
|
|
14
|
+
selector?: string
|
|
15
|
+
outputPath?: string
|
|
16
|
+
scale?: number
|
|
17
|
+
trim?: boolean
|
|
18
|
+
trimColor?: string
|
|
19
|
+
trimThreshold?: number
|
|
20
|
+
trimPadding?: number
|
|
21
|
+
timeout?: number
|
|
22
|
+
wait?: number
|
|
23
|
+
background?: boolean
|
|
24
|
+
maxSegmentHeight?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ExportSinglePageHtmlPdfResult {
|
|
28
|
+
outputPath: string
|
|
29
|
+
pngPath: string
|
|
30
|
+
width: number
|
|
31
|
+
height: number
|
|
32
|
+
segmentCount: number
|
|
33
|
+
selector: string
|
|
34
|
+
attemptedSelectors: string[]
|
|
35
|
+
warnings: string[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type Stage = "load" | "selector" | "screenshot" | "stitch" | "trim" | "validate" | "pdf"
|
|
39
|
+
|
|
40
|
+
export async function exportSinglePageHtmlPdf(
|
|
41
|
+
htmlFilePath: string,
|
|
42
|
+
options: ExportSinglePageHtmlPdfOptions = {},
|
|
43
|
+
): Promise<ExportSinglePageHtmlPdfResult> {
|
|
44
|
+
const abs = resolve(htmlFilePath)
|
|
45
|
+
if (!existsSync(abs)) throw new Error(`File not found: ${abs}`)
|
|
46
|
+
if (!/\.html?$/i.test(abs)) throw new Error(`Not an HTML file: ${abs}`)
|
|
47
|
+
|
|
48
|
+
const outputPath = options.outputPath ?? abs.replace(/\.html?$/i, ".pdf")
|
|
49
|
+
const scale = normalizedNumber(options.scale, 2, 0.25, 4)
|
|
50
|
+
const timeout = normalizedNumber(options.timeout, 60000, 1000, 300000)
|
|
51
|
+
const wait = normalizedNumber(options.wait, 500, 0, 30000)
|
|
52
|
+
const maxSegmentHeight = Math.floor(normalizedNumber(options.maxSegmentHeight, 1600, 256, 4096))
|
|
53
|
+
const background = options.background ?? true
|
|
54
|
+
const selectorChoice = chooseSelector(options.selector)
|
|
55
|
+
const tmpDir = join(dirname(outputPath), `.revela-html-export-${randomBytes(6).toString("hex")}`)
|
|
56
|
+
mkdirSync(tmpDir, { recursive: true })
|
|
57
|
+
const tmpPng = join(tmpDir, "artifact.png")
|
|
58
|
+
const finalPng = join(tmpDir, "artifact-final.png")
|
|
59
|
+
const promotedPng = outputPath.replace(/\.pdf$/i, ".png")
|
|
60
|
+
const warnings: string[] = []
|
|
61
|
+
let stage: Stage = "load"
|
|
62
|
+
|
|
63
|
+
const browser = await launchChrome({ allowFileAccess: true, width: 1200, height: maxSegmentHeight })
|
|
64
|
+
try {
|
|
65
|
+
const page = await browser.newPage()
|
|
66
|
+
try {
|
|
67
|
+
await page.setViewport({ width: 1200, height: maxSegmentHeight, deviceScaleFactor: scale })
|
|
68
|
+
await page.goto(pathToFileURL(abs).href, { waitUntil: "domcontentloaded", timeout })
|
|
69
|
+
await waitForStablePage(page, timeout, wait, warnings)
|
|
70
|
+
|
|
71
|
+
stage = "selector"
|
|
72
|
+
const target = await resolveTarget(page, selectorChoice.attempted)
|
|
73
|
+
if (!target) {
|
|
74
|
+
throw new Error(`selector was not found. Attempted: ${selectorChoice.attempted.join(", ")}`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const viewportWidth = Math.max(1, Math.ceil(target.box.width + target.box.x))
|
|
78
|
+
await page.setViewport({ width: viewportWidth, height: maxSegmentHeight, deviceScaleFactor: scale })
|
|
79
|
+
await waitForAnimationFrames(page)
|
|
80
|
+
|
|
81
|
+
const segments: Array<{ buffer: Buffer; top: number; height: number }> = []
|
|
82
|
+
const totalHeight = Math.ceil(target.box.height)
|
|
83
|
+
const width = Math.ceil(target.box.width)
|
|
84
|
+
const x = Math.max(0, Math.floor(target.box.x))
|
|
85
|
+
const y = Math.max(0, Math.floor(target.box.y))
|
|
86
|
+
|
|
87
|
+
stage = "screenshot"
|
|
88
|
+
for (let top = 0; top < totalHeight; top += maxSegmentHeight) {
|
|
89
|
+
const height = Math.min(maxSegmentHeight, totalHeight - top)
|
|
90
|
+
const clip = { x, y: y + top, width, height }
|
|
91
|
+
try {
|
|
92
|
+
const buffer = await page.screenshot({ type: "png", clip, omitBackground: !background })
|
|
93
|
+
segments.push({ buffer: Buffer.from(buffer), top, height })
|
|
94
|
+
} catch {
|
|
95
|
+
const buffer = await page.screenshot({ type: "png", clip, omitBackground: !background })
|
|
96
|
+
segments.push({ buffer: Buffer.from(buffer), top, height })
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
stage = "stitch"
|
|
101
|
+
const stitched = new Jimp({
|
|
102
|
+
width: Math.round(width * scale),
|
|
103
|
+
height: Math.round(totalHeight * scale),
|
|
104
|
+
color: background ? 0x000000ff : 0x00000000,
|
|
105
|
+
})
|
|
106
|
+
for (const segment of segments) {
|
|
107
|
+
const image = await Jimp.read(segment.buffer)
|
|
108
|
+
stitched.composite(image, 0, Math.round(segment.top * scale))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
stage = "trim"
|
|
112
|
+
const trimmedHeight = findTrimmedHeight(stitched, {
|
|
113
|
+
enabled: options.trim ?? true,
|
|
114
|
+
color: options.trimColor,
|
|
115
|
+
threshold: options.trimThreshold,
|
|
116
|
+
padding: options.trimPadding,
|
|
117
|
+
})
|
|
118
|
+
const finalImage = trimmedHeight < stitched.bitmap.height
|
|
119
|
+
? stitched.crop({ x: 0, y: 0, w: stitched.bitmap.width, h: trimmedHeight })
|
|
120
|
+
: stitched
|
|
121
|
+
|
|
122
|
+
const finalBuffer = await (finalImage.getBuffer as any)("image/png")
|
|
123
|
+
writeFileSync(tmpPng, new Uint8Array(finalBuffer))
|
|
124
|
+
renameSync(tmpPng, finalPng)
|
|
125
|
+
|
|
126
|
+
stage = "validate"
|
|
127
|
+
const validation = await validatePngOutput(finalPng)
|
|
128
|
+
|
|
129
|
+
stage = "pdf"
|
|
130
|
+
await writeSinglePagePdfFromPng(finalPng, outputPath, validation.width, validation.height)
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
outputPath,
|
|
134
|
+
pngPath: promotedPng,
|
|
135
|
+
width: validation.width,
|
|
136
|
+
height: validation.height,
|
|
137
|
+
segmentCount: segments.length,
|
|
138
|
+
selector: target.selector,
|
|
139
|
+
attemptedSelectors: selectorChoice.attempted,
|
|
140
|
+
warnings,
|
|
141
|
+
}
|
|
142
|
+
} finally {
|
|
143
|
+
await page.close().catch(() => undefined)
|
|
144
|
+
}
|
|
145
|
+
} catch (error) {
|
|
146
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
147
|
+
throw new Error(
|
|
148
|
+
`Single-page HTML PDF export failed during ${stage}.\n` +
|
|
149
|
+
`File: ${abs}\n` +
|
|
150
|
+
`Attempted selector(s): ${selectorChoice.attempted.join(", ")}\n` +
|
|
151
|
+
message
|
|
152
|
+
)
|
|
153
|
+
} finally {
|
|
154
|
+
await browser.close().catch(() => undefined)
|
|
155
|
+
if (existsSync(finalPng)) {
|
|
156
|
+
try {
|
|
157
|
+
if (statSync(finalPng).size > 0) renameSync(finalPng, promotedPng)
|
|
158
|
+
} catch {
|
|
159
|
+
// Non-fatal; the PDF is the requested artifact.
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
rmSync(tmpDir, { recursive: true, force: true })
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function normalizedNumber(value: unknown, fallback: number, min: number, max: number): number {
|
|
167
|
+
const parsed = typeof value === "number" ? value : Number(value)
|
|
168
|
+
if (!Number.isFinite(parsed)) return fallback
|
|
169
|
+
return Math.min(max, Math.max(min, parsed))
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function waitForStablePage(page: Page, timeout: number, wait: number, warnings: string[]) {
|
|
173
|
+
await page.evaluate(async (timeoutMs) => {
|
|
174
|
+
const deadline = Date.now() + timeoutMs
|
|
175
|
+
const fonts = (document as any).fonts
|
|
176
|
+
if (fonts?.ready) {
|
|
177
|
+
await Promise.race([
|
|
178
|
+
fonts.ready,
|
|
179
|
+
new Promise((resolve) => setTimeout(resolve, Math.max(0, deadline - Date.now()))),
|
|
180
|
+
])
|
|
181
|
+
}
|
|
182
|
+
}, Math.min(timeout, 10000)).catch(() => warnings.push("Timed out waiting for fonts."))
|
|
183
|
+
|
|
184
|
+
await page.evaluate(async (timeoutMs) => {
|
|
185
|
+
const deadline = Date.now() + timeoutMs
|
|
186
|
+
const images = Array.from(document.images)
|
|
187
|
+
await Promise.all(images.map((img) => {
|
|
188
|
+
if (img.complete) return undefined
|
|
189
|
+
return new Promise<void>((resolve) => {
|
|
190
|
+
const done = () => resolve()
|
|
191
|
+
const remaining = Math.max(0, deadline - Date.now())
|
|
192
|
+
const timer = setTimeout(done, remaining)
|
|
193
|
+
img.addEventListener("load", () => { clearTimeout(timer); done() }, { once: true })
|
|
194
|
+
img.addEventListener("error", () => { clearTimeout(timer); done() }, { once: true })
|
|
195
|
+
})
|
|
196
|
+
}))
|
|
197
|
+
}, Math.min(timeout, 20000)).catch(() => warnings.push("Timed out waiting for images."))
|
|
198
|
+
|
|
199
|
+
await waitForAnimationFrames(page)
|
|
200
|
+
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait))
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function waitForAnimationFrames(page: Page) {
|
|
204
|
+
await page.evaluate(() => new Promise<void>((resolve) => {
|
|
205
|
+
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
|
206
|
+
}))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function resolveTarget(
|
|
210
|
+
page: Page,
|
|
211
|
+
selectors: string[],
|
|
212
|
+
): Promise<{ selector: string; box: { x: number; y: number; width: number; height: number } } | null> {
|
|
213
|
+
return await page.evaluate((candidates) => {
|
|
214
|
+
for (const selector of candidates) {
|
|
215
|
+
const el = document.querySelector(selector) as HTMLElement | null
|
|
216
|
+
if (!el) continue
|
|
217
|
+
const rect = el.getBoundingClientRect()
|
|
218
|
+
if (rect.width <= 0 || rect.height <= 0) continue
|
|
219
|
+
return {
|
|
220
|
+
selector,
|
|
221
|
+
box: {
|
|
222
|
+
x: rect.left + window.scrollX,
|
|
223
|
+
y: rect.top + window.scrollY,
|
|
224
|
+
width: rect.width,
|
|
225
|
+
height: rect.height,
|
|
226
|
+
},
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return null
|
|
230
|
+
}, selectors)
|
|
231
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { PDFDocument } from "pdf-lib"
|
|
2
|
+
import { readFileSync, renameSync, writeFileSync } from "fs"
|
|
3
|
+
import { dirname, join } from "path"
|
|
4
|
+
import { randomBytes } from "crypto"
|
|
5
|
+
|
|
6
|
+
export async function writeSinglePagePdfFromPng(pngPath: string, outputPath: string, width: number, height: number): Promise<void> {
|
|
7
|
+
const pdf = await PDFDocument.create()
|
|
8
|
+
const image = await pdf.embedPng(new Uint8Array(readFileSync(pngPath)))
|
|
9
|
+
const page = pdf.addPage([width, height])
|
|
10
|
+
page.drawImage(image, { x: 0, y: 0, width, height })
|
|
11
|
+
|
|
12
|
+
const tmpPath = join(dirname(outputPath), `.revela-${randomBytes(6).toString("hex")}.pdf`)
|
|
13
|
+
writeFileSync(tmpPath, await pdf.save())
|
|
14
|
+
renameSync(tmpPath, outputPath)
|
|
15
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const DEFAULT_SINGLE_PAGE_SELECTORS = [".poster", ".artifact", "main", "body"]
|
|
2
|
+
|
|
3
|
+
export interface SelectorChoice {
|
|
4
|
+
selector: string
|
|
5
|
+
attempted: string[]
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function chooseSelector(input?: string): SelectorChoice {
|
|
9
|
+
const trimmed = input?.trim()
|
|
10
|
+
if (trimmed) return { selector: trimmed, attempted: [trimmed] }
|
|
11
|
+
return { selector: DEFAULT_SINGLE_PAGE_SELECTORS[0], attempted: [...DEFAULT_SINGLE_PAGE_SELECTORS] }
|
|
12
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Jimp } from "jimp"
|
|
2
|
+
|
|
3
|
+
export interface TrimOptions {
|
|
4
|
+
enabled: boolean
|
|
5
|
+
color?: string
|
|
6
|
+
threshold?: number
|
|
7
|
+
padding?: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function parseHexColor(value = "#020615"): { r: number; g: number; b: number } {
|
|
11
|
+
const normalized = value.trim().replace(/^#/, "")
|
|
12
|
+
if (!/^[0-9a-fA-F]{6}$/.test(normalized)) return { r: 2, g: 6, b: 21 }
|
|
13
|
+
return {
|
|
14
|
+
r: Number.parseInt(normalized.slice(0, 2), 16),
|
|
15
|
+
g: Number.parseInt(normalized.slice(2, 4), 16),
|
|
16
|
+
b: Number.parseInt(normalized.slice(4, 6), 16),
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function findTrimmedHeight(image: InstanceType<typeof Jimp>, options: TrimOptions): number {
|
|
21
|
+
if (!options.enabled) return image.bitmap.height
|
|
22
|
+
|
|
23
|
+
const { width, height, data } = image.bitmap
|
|
24
|
+
const color = parseHexColor(options.color)
|
|
25
|
+
const threshold = options.threshold ?? 8
|
|
26
|
+
const padding = Math.max(0, Math.floor(options.padding ?? 0))
|
|
27
|
+
const requiredBlankRows = Math.min(24, Math.max(4, Math.floor(height * 0.003)))
|
|
28
|
+
let blankRun = 0
|
|
29
|
+
let trimStart = height
|
|
30
|
+
|
|
31
|
+
for (let y = height - 1; y >= 0; y--) {
|
|
32
|
+
let rowBlank = true
|
|
33
|
+
for (let x = 0; x < width; x++) {
|
|
34
|
+
const idx = (y * width + x) * 4
|
|
35
|
+
const a = data[idx + 3] ?? 255
|
|
36
|
+
const r = data[idx] ?? 0
|
|
37
|
+
const g = data[idx + 1] ?? 0
|
|
38
|
+
const b = data[idx + 2] ?? 0
|
|
39
|
+
const close =
|
|
40
|
+
Math.abs(r - color.r) <= threshold &&
|
|
41
|
+
Math.abs(g - color.g) <= threshold &&
|
|
42
|
+
Math.abs(b - color.b) <= threshold
|
|
43
|
+
if (a > 8 && !close) {
|
|
44
|
+
rowBlank = false
|
|
45
|
+
break
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (rowBlank) {
|
|
50
|
+
blankRun += 1
|
|
51
|
+
if (blankRun >= requiredBlankRows) trimStart = y
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
break
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (trimStart >= height) return height
|
|
59
|
+
return Math.min(height, Math.max(1, trimStart + padding))
|
|
60
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { statSync } from "fs"
|
|
2
|
+
import { Jimp } from "jimp"
|
|
3
|
+
|
|
4
|
+
export interface PngValidationResult {
|
|
5
|
+
width: number
|
|
6
|
+
height: number
|
|
7
|
+
fileSize: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function validatePngOutput(path: string): Promise<PngValidationResult> {
|
|
11
|
+
const fileSize = statSync(path).size
|
|
12
|
+
const image = await Jimp.read(path)
|
|
13
|
+
const { width, height, data } = image.bitmap
|
|
14
|
+
if (width <= 0 || height <= 0) throw new Error("PNG output has invalid dimensions")
|
|
15
|
+
if (fileSize <= 0) throw new Error("PNG output is empty")
|
|
16
|
+
|
|
17
|
+
const samples = samplePixels(width, height)
|
|
18
|
+
let varied = false
|
|
19
|
+
let visible = false
|
|
20
|
+
let first: string | undefined
|
|
21
|
+
|
|
22
|
+
for (const [x, y] of samples) {
|
|
23
|
+
const idx = (y * width + x) * 4
|
|
24
|
+
const rgba = `${data[idx]},${data[idx + 1]},${data[idx + 2]},${data[idx + 3]}`
|
|
25
|
+
if ((data[idx + 3] ?? 0) > 8) visible = true
|
|
26
|
+
first ??= rgba
|
|
27
|
+
if (rgba !== first) varied = true
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!visible) throw new Error("PNG output appears fully transparent")
|
|
31
|
+
if (!varied && width * height > 1) throw new Error("PNG output appears flat")
|
|
32
|
+
|
|
33
|
+
return { width, height, fileSize }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function samplePixels(width: number, height: number): Array<[number, number]> {
|
|
37
|
+
const xs = [0.1, 0.25, 0.5, 0.75, 0.9].map((v) => Math.min(width - 1, Math.max(0, Math.floor(width * v))))
|
|
38
|
+
const ys = [0.1, 0.25, 0.5, 0.75, 0.9].map((v) => Math.min(height - 1, Math.max(0, Math.floor(height * v))))
|
|
39
|
+
const out: Array<[number, number]> = []
|
|
40
|
+
for (const y of ys) {
|
|
41
|
+
for (const x of xs) out.push([x, y])
|
|
42
|
+
}
|
|
43
|
+
return out
|
|
44
|
+
}
|
package/lib/pdf/export.ts
CHANGED
|
@@ -17,7 +17,6 @@
|
|
|
17
17
|
* Output path: replaces the .html extension with .pdf, same directory as input.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import puppeteer from "puppeteer-core"
|
|
21
20
|
import { PDFDocument } from "pdf-lib"
|
|
22
21
|
import {
|
|
23
22
|
existsSync,
|
|
@@ -29,6 +28,9 @@ import {
|
|
|
29
28
|
import { resolve, dirname, basename, join, extname } from "path"
|
|
30
29
|
import { pathToFileURL } from "url"
|
|
31
30
|
import { randomBytes } from "crypto"
|
|
31
|
+
import { launchChrome } from "../browser/chrome"
|
|
32
|
+
import { detectDeckHtml } from "../html-export/deck-detect"
|
|
33
|
+
import { exportSinglePageHtmlPdf } from "../html-export"
|
|
32
34
|
|
|
33
35
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
34
36
|
|
|
@@ -36,16 +38,6 @@ import { randomBytes } from "crypto"
|
|
|
36
38
|
const CANVAS_W = 1920
|
|
37
39
|
const CANVAS_H = 1080
|
|
38
40
|
|
|
39
|
-
/** Path to system Chrome on macOS and Linux — same as measure.ts */
|
|
40
|
-
const CHROME_PATHS = [
|
|
41
|
-
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
42
|
-
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
43
|
-
"/usr/bin/google-chrome-stable",
|
|
44
|
-
"/usr/bin/google-chrome",
|
|
45
|
-
"/usr/bin/chromium-browser",
|
|
46
|
-
"/usr/bin/chromium",
|
|
47
|
-
]
|
|
48
|
-
|
|
49
41
|
/** Mime type → file extension mapping for downloaded images */
|
|
50
42
|
const MIME_TO_EXT: Record<string, string> = {
|
|
51
43
|
"image/jpeg": ".jpg",
|
|
@@ -71,16 +63,6 @@ const EXT_TO_MIME: Record<string, string> = {
|
|
|
71
63
|
|
|
72
64
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
73
65
|
|
|
74
|
-
function findChromePath(): string {
|
|
75
|
-
for (const p of CHROME_PATHS) {
|
|
76
|
-
if (existsSync(p)) return p
|
|
77
|
-
}
|
|
78
|
-
throw new Error(
|
|
79
|
-
"Could not find a Chrome/Chromium installation.\n" +
|
|
80
|
-
"Tried:\n" + CHROME_PATHS.map((p) => ` ${p}`).join("\n")
|
|
81
|
-
)
|
|
82
|
-
}
|
|
83
|
-
|
|
84
66
|
/** Derive output PDF path from input HTML path (same dir, .html → .pdf) */
|
|
85
67
|
export function derivePdfPath(htmlFilePath: string): string {
|
|
86
68
|
const abs = resolve(htmlFilePath)
|
|
@@ -233,6 +215,15 @@ export interface ExportResult {
|
|
|
233
215
|
outputPath: string
|
|
234
216
|
slideCount: number
|
|
235
217
|
durationMs: number
|
|
218
|
+
exportMode: "deck" | "single-page-html"
|
|
219
|
+
deckDetection?: {
|
|
220
|
+
isDeck: boolean
|
|
221
|
+
slideCount: number
|
|
222
|
+
reason: string
|
|
223
|
+
}
|
|
224
|
+
selector?: string
|
|
225
|
+
pngPath?: string
|
|
226
|
+
warnings?: string[]
|
|
236
227
|
}
|
|
237
228
|
|
|
238
229
|
/**
|
|
@@ -244,6 +235,28 @@ export interface ExportResult {
|
|
|
244
235
|
export async function exportToPdf(htmlFilePath: string): Promise<ExportResult> {
|
|
245
236
|
const startMs = Date.now()
|
|
246
237
|
const abs = resolve(htmlFilePath)
|
|
238
|
+
const detection = await detectDeckHtml(abs)
|
|
239
|
+
if (detection.isDeck) {
|
|
240
|
+
const result = await exportDeckToPdf(abs)
|
|
241
|
+
return { ...result, exportMode: "deck", deckDetection: detection, durationMs: Date.now() - startMs }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const result = await exportSinglePageHtmlPdf(abs, { outputPath: derivePdfPath(abs) })
|
|
245
|
+
return {
|
|
246
|
+
outputPath: result.outputPath,
|
|
247
|
+
slideCount: 1,
|
|
248
|
+
durationMs: Date.now() - startMs,
|
|
249
|
+
exportMode: "single-page-html",
|
|
250
|
+
deckDetection: detection,
|
|
251
|
+
selector: result.selector,
|
|
252
|
+
pngPath: result.pngPath,
|
|
253
|
+
warnings: result.warnings,
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function exportDeckToPdf(htmlFilePath: string): Promise<Omit<ExportResult, "exportMode">> {
|
|
258
|
+
const startMs = Date.now()
|
|
259
|
+
const abs = resolve(htmlFilePath)
|
|
247
260
|
|
|
248
261
|
if (!existsSync(abs)) {
|
|
249
262
|
throw new Error(`File not found: ${abs}`)
|
|
@@ -254,7 +267,6 @@ export async function exportToPdf(htmlFilePath: string): Promise<ExportResult> {
|
|
|
254
267
|
}
|
|
255
268
|
|
|
256
269
|
const outputPath = derivePdfPath(abs)
|
|
257
|
-
const executablePath = findChromePath()
|
|
258
270
|
|
|
259
271
|
// ── Step 1: Download external images and rewrite HTML ─────────────────────
|
|
260
272
|
const tmpDir = join("/tmp", `revela-pdf-${randomBytes(6).toString("hex")}`)
|
|
@@ -275,18 +287,7 @@ export async function exportToPdf(htmlFilePath: string): Promise<ExportResult> {
|
|
|
275
287
|
const fileUrl = pathToFileURL(tmpHtmlPath).href
|
|
276
288
|
|
|
277
289
|
// ── Step 2: Launch Puppeteer and screenshot each slide ────────────────────
|
|
278
|
-
const browser = await
|
|
279
|
-
executablePath,
|
|
280
|
-
headless: true,
|
|
281
|
-
args: [
|
|
282
|
-
"--no-sandbox",
|
|
283
|
-
"--disable-setuid-sandbox",
|
|
284
|
-
"--disable-dev-shm-usage",
|
|
285
|
-
// Allow file:// pages to load other local file:// resources (downloaded images)
|
|
286
|
-
"--allow-file-access-from-files",
|
|
287
|
-
`--window-size=${CANVAS_W},${CANVAS_H}`,
|
|
288
|
-
],
|
|
289
|
-
})
|
|
290
|
+
const browser = await launchChrome({ width: CANVAS_W, height: CANVAS_H, allowFileAccess: true })
|
|
290
291
|
|
|
291
292
|
let screenshots: Buffer[] = []
|
|
292
293
|
|
package/lib/pptx/export.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* editable whenever dom-to-pptx can represent them.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import
|
|
16
|
+
import { type Browser, type Page } from "puppeteer-core"
|
|
17
17
|
import { DOMParser, XMLSerializer } from "@xmldom/xmldom"
|
|
18
18
|
import { unzipSync, zipSync, strFromU8, strToU8 } from "fflate"
|
|
19
19
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"
|
|
@@ -21,6 +21,7 @@ import { createRequire } from "module"
|
|
|
21
21
|
import { basename, dirname, extname, join, posix as pathPosix, resolve } from "path"
|
|
22
22
|
import { randomBytes } from "crypto"
|
|
23
23
|
import { pathToFileURL } from "url"
|
|
24
|
+
import { findChromePath, launchChrome } from "../browser/chrome"
|
|
24
25
|
|
|
25
26
|
const CANVAS_W = 1920
|
|
26
27
|
const CANVAS_H = 1080
|
|
@@ -28,15 +29,6 @@ const MIN_PPTX_FONT_SIZE_PT = 6
|
|
|
28
29
|
const PPT_REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
|
29
30
|
const requireFromExportModule = createRequire(import.meta.url)
|
|
30
31
|
|
|
31
|
-
const CHROME_PATHS = [
|
|
32
|
-
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
33
|
-
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
34
|
-
"/usr/bin/google-chrome-stable",
|
|
35
|
-
"/usr/bin/google-chrome",
|
|
36
|
-
"/usr/bin/chromium-browser",
|
|
37
|
-
"/usr/bin/chromium",
|
|
38
|
-
]
|
|
39
|
-
|
|
40
32
|
const IMAGE_EXTS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".avif", ".bmp"])
|
|
41
33
|
const MIME_TO_EXT: Record<string, string> = {
|
|
42
34
|
"image/jpeg": ".jpg",
|
|
@@ -125,28 +117,8 @@ interface LocalizeExternalImagesResult {
|
|
|
125
117
|
localizedCount: number
|
|
126
118
|
}
|
|
127
119
|
|
|
128
|
-
function
|
|
129
|
-
|
|
130
|
-
if (existsSync(p)) return p
|
|
131
|
-
}
|
|
132
|
-
throw new Error(
|
|
133
|
-
"Could not find a Chrome/Chromium installation.\n" +
|
|
134
|
-
"Tried:\n" + CHROME_PATHS.map((p) => ` ${p}`).join("\n")
|
|
135
|
-
)
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
async function launchBrowser(executablePath: string): Promise<Browser> {
|
|
139
|
-
return await puppeteer.launch({
|
|
140
|
-
executablePath,
|
|
141
|
-
headless: true,
|
|
142
|
-
args: [
|
|
143
|
-
"--no-sandbox",
|
|
144
|
-
"--disable-setuid-sandbox",
|
|
145
|
-
"--disable-dev-shm-usage",
|
|
146
|
-
"--allow-file-access-from-files",
|
|
147
|
-
`--window-size=${CANVAS_W},${CANVAS_H}`,
|
|
148
|
-
],
|
|
149
|
-
})
|
|
120
|
+
async function launchBrowser(): Promise<Browser> {
|
|
121
|
+
return await launchChrome({ width: CANVAS_W, height: CANVAS_H, allowFileAccess: true })
|
|
150
122
|
}
|
|
151
123
|
|
|
152
124
|
export function derivePptxPath(htmlFilePath: string): string {
|
|
@@ -984,7 +956,7 @@ export async function exportToPptx(
|
|
|
984
956
|
kind: "stage",
|
|
985
957
|
message: "Launching Chrome and preparing slide DOM...",
|
|
986
958
|
})
|
|
987
|
-
const browser = await launchBrowser(
|
|
959
|
+
const browser = await launchBrowser()
|
|
988
960
|
|
|
989
961
|
try {
|
|
990
962
|
const pageSetupStart = Date.now()
|
package/lib/qa/measure.ts
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* Returns raw per-slide geometry data consumed by checks.ts.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import puppeteer from "puppeteer-core"
|
|
13
12
|
import { pathToFileURL } from "url"
|
|
13
|
+
import { launchChrome } from "../browser/chrome"
|
|
14
14
|
|
|
15
15
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
16
16
|
|
|
@@ -18,16 +18,6 @@ import { pathToFileURL } from "url"
|
|
|
18
18
|
export const CANVAS_W = 1920
|
|
19
19
|
export const CANVAS_H = 1080
|
|
20
20
|
|
|
21
|
-
/** Path to system Chrome on macOS. Falls back to common Linux paths. */
|
|
22
|
-
const CHROME_PATHS = [
|
|
23
|
-
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
24
|
-
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
25
|
-
"/usr/bin/google-chrome-stable",
|
|
26
|
-
"/usr/bin/google-chrome",
|
|
27
|
-
"/usr/bin/chromium-browser",
|
|
28
|
-
"/usr/bin/chromium",
|
|
29
|
-
]
|
|
30
|
-
|
|
31
21
|
// ── Types ────────────────────────────────────────────────────────────────────
|
|
32
22
|
|
|
33
23
|
export interface Rect {
|
|
@@ -127,19 +117,6 @@ export interface MeasurementResult {
|
|
|
127
117
|
cssDefinedClasses: string[]
|
|
128
118
|
}
|
|
129
119
|
|
|
130
|
-
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
131
|
-
|
|
132
|
-
function findChromePath(): string {
|
|
133
|
-
const { existsSync } = require("fs") as typeof import("fs")
|
|
134
|
-
for (const p of CHROME_PATHS) {
|
|
135
|
-
if (existsSync(p)) return p
|
|
136
|
-
}
|
|
137
|
-
throw new Error(
|
|
138
|
-
"Could not find a Chrome/Chromium installation. " +
|
|
139
|
-
"Tried: " + CHROME_PATHS.join(", ")
|
|
140
|
-
)
|
|
141
|
-
}
|
|
142
|
-
|
|
143
120
|
// ── Main export ──────────────────────────────────────────────────────────────
|
|
144
121
|
|
|
145
122
|
/**
|
|
@@ -147,19 +124,9 @@ function findChromePath(): string {
|
|
|
147
124
|
* and return slide geometry + CSS class names defined in <style> blocks.
|
|
148
125
|
*/
|
|
149
126
|
export async function measureSlides(htmlFilePath: string): Promise<MeasurementResult> {
|
|
150
|
-
const executablePath = findChromePath()
|
|
151
127
|
const fileUrl = pathToFileURL(htmlFilePath).href
|
|
152
128
|
|
|
153
|
-
const browser = await
|
|
154
|
-
executablePath,
|
|
155
|
-
headless: true,
|
|
156
|
-
args: [
|
|
157
|
-
"--no-sandbox",
|
|
158
|
-
"--disable-setuid-sandbox",
|
|
159
|
-
"--disable-dev-shm-usage",
|
|
160
|
-
"--window-size=1920,1080",
|
|
161
|
-
],
|
|
162
|
-
})
|
|
129
|
+
const browser = await launchChrome({ width: CANVAS_W, height: CANVAS_H })
|
|
163
130
|
|
|
164
131
|
try {
|
|
165
132
|
const page = await browser.newPage()
|
package/lib/runtime/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { readDeckPlanArtifact } from "../narrative-state/deck-plan-artifact"
|
|
|
10
10
|
import { extractDesignClasses } from "../design/designs"
|
|
11
11
|
import { recordRenderedArtifact, workspaceRelative } from "../workspace-state/rendered-artifacts"
|
|
12
12
|
import type { ReviewDeckOpenInput, ReviewDeckReadInput } from "./review"
|
|
13
|
+
import pkg from "../../package.json"
|
|
13
14
|
export { bindResearchFindings, evaluateResearchFindings, researchSave, researchTargets } from "./research"
|
|
14
15
|
export { storyRead } from "./story"
|
|
15
16
|
|
|
@@ -42,6 +43,7 @@ export function doctor(input: RuntimeWorkspaceInput = {}) {
|
|
|
42
43
|
const workspaceRoot = root(input.workspaceRoot)
|
|
43
44
|
return {
|
|
44
45
|
ok: true,
|
|
46
|
+
version: pkg.version,
|
|
45
47
|
workspaceRoot,
|
|
46
48
|
hasNarrativeVault: existsSync(resolve(workspaceRoot, "revela-narrative")),
|
|
47
49
|
hasDeckPlan: existsSync(resolve(workspaceRoot, "deck-plan")),
|
|
@@ -111,10 +113,8 @@ export async function runDeckQa(input: RuntimeFileInput) {
|
|
|
111
113
|
|
|
112
114
|
export async function exportPdf(input: RuntimeFileInput) {
|
|
113
115
|
const { exportToPdf } = await import("../pdf/export")
|
|
114
|
-
const { assertExportQAPassed } = await import("../qa/export-gate")
|
|
115
116
|
const workspaceRoot = root(input.workspaceRoot)
|
|
116
117
|
const filePath = resolve(workspaceRoot, input.file)
|
|
117
|
-
await assertExportQAPassed(filePath, { workspaceRoot })
|
|
118
118
|
const result = await exportToPdf(filePath)
|
|
119
119
|
recordRenderedArtifact(workspaceRoot, {
|
|
120
120
|
sourceHtmlPath: workspaceRelative(resolve(workspaceRoot), filePath),
|
|
@@ -127,10 +127,8 @@ export async function exportPdf(input: RuntimeFileInput) {
|
|
|
127
127
|
|
|
128
128
|
export async function exportPptx(input: RuntimeFileInput & { speakerNotes?: Array<string | null | undefined> }) {
|
|
129
129
|
const { exportToPptx } = await import("../pptx/export")
|
|
130
|
-
const { assertExportQAPassed } = await import("../qa/export-gate")
|
|
131
130
|
const workspaceRoot = root(input.workspaceRoot)
|
|
132
131
|
const filePath = resolve(workspaceRoot, input.file)
|
|
133
|
-
await assertExportQAPassed(filePath, { workspaceRoot })
|
|
134
132
|
const result = await exportToPptx(filePath, { speakerNotes: input.speakerNotes })
|
|
135
133
|
recordRenderedArtifact(workspaceRoot, {
|
|
136
134
|
sourceHtmlPath: workspaceRelative(resolve(workspaceRoot), filePath),
|
package/package.json
CHANGED
package/plugins/revela/.mcp.json
CHANGED
|
@@ -83,7 +83,7 @@ const tools = [
|
|
|
83
83
|
},
|
|
84
84
|
{
|
|
85
85
|
name: "revela_export_pdf",
|
|
86
|
-
description: "
|
|
86
|
+
description: "Export HTML to PDF. Deck HTML uses multi-page deck export; non-deck HTML falls back to a single-page artifact PDF.",
|
|
87
87
|
inputSchema: objectSchema({
|
|
88
88
|
workspaceRoot: stringProp("Optional workspace root."),
|
|
89
89
|
file: requiredStringProp("Workspace-relative or absolute HTML deck path."),
|
|
@@ -91,7 +91,7 @@ const tools = [
|
|
|
91
91
|
},
|
|
92
92
|
{
|
|
93
93
|
name: "revela_export_pptx",
|
|
94
|
-
description: "
|
|
94
|
+
description: "Export a Revela HTML deck to PPTX.",
|
|
95
95
|
inputSchema: objectSchema({
|
|
96
96
|
workspaceRoot: stringProp("Optional workspace root."),
|
|
97
97
|
file: requiredStringProp("Workspace-relative or absolute HTML deck path."),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: revela-export
|
|
3
|
-
description: Export Revela deck artifacts from Codex to PDF or PPTX
|
|
3
|
+
description: Export Revela deck artifacts from Codex to PDF or PPTX.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Revela Export
|
|
@@ -10,12 +10,12 @@ Use this skill when the user asks to export a Revela deck.
|
|
|
10
10
|
## Workflow
|
|
11
11
|
|
|
12
12
|
1. Resolve the target HTML deck path.
|
|
13
|
-
2.
|
|
14
|
-
3.
|
|
15
|
-
4.
|
|
16
|
-
5. For PPTX, call `revela_export_pptx`.
|
|
17
|
-
6. Report output path and any export diagnostics.
|
|
13
|
+
2. For PDF, call `revela_export_pdf`.
|
|
14
|
+
3. For PPTX, call `revela_export_pptx`.
|
|
15
|
+
4. Report output path or export errors.
|
|
18
16
|
|
|
19
17
|
`revela_run_deck_qa`, `revela_export_pdf`, and `revela_export_pptx` may launch a browser. In sandboxed Codex sessions, request user-approved command escalation when the browser cannot start inside the default sandbox.
|
|
20
18
|
|
|
19
|
+
Deck writes run post-write QA automatically. Do not run artifact QA as a pre-export blocker unless the user explicitly asks for diagnostics.
|
|
20
|
+
|
|
21
21
|
Do not treat narrative gaps as export blockers unless they affect technical artifact validity or data safety.
|
package/tools/pdf.ts
CHANGED
|
@@ -8,13 +8,11 @@ import { tool } from "@opencode-ai/plugin"
|
|
|
8
8
|
import { existsSync } from "fs"
|
|
9
9
|
import { resolve } from "path"
|
|
10
10
|
import { exportToPdf } from "../lib/pdf/export"
|
|
11
|
-
import { assertExportQAPassed } from "../lib/qa/export-gate"
|
|
12
11
|
import { recordRenderedArtifact, workspaceRelative } from "../lib/workspace-state/rendered-artifacts"
|
|
13
12
|
|
|
14
13
|
export default tool({
|
|
15
14
|
description:
|
|
16
|
-
"Export
|
|
17
|
-
"Runs pre-export QA before writing the PDF. " +
|
|
15
|
+
"Export HTML to PDF. Revela deck HTML exports as a multi-page deck PDF; non-deck HTML falls back to a single-page artifact PDF. " +
|
|
18
16
|
"Output is written beside the input file with the same basename and a .pdf extension.",
|
|
19
17
|
args: {
|
|
20
18
|
file: tool.schema
|
|
@@ -37,7 +35,6 @@ export default tool({
|
|
|
37
35
|
|
|
38
36
|
try {
|
|
39
37
|
const root = directory || process.cwd()
|
|
40
|
-
await assertExportQAPassed(filePath, { workspaceRoot: root })
|
|
41
38
|
const result = await exportToPdf(filePath)
|
|
42
39
|
recordRenderedArtifact(root, {
|
|
43
40
|
sourceHtmlPath: workspaceRelative(resolve(root), filePath),
|
package/tools/pptx.ts
CHANGED
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
import { tool } from "@opencode-ai/plugin"
|
|
8
8
|
import { existsSync } from "fs"
|
|
9
9
|
import { resolve } from "path"
|
|
10
|
-
import { assertDeckHtmlContractValid } from "../lib/deck-html/contract"
|
|
11
10
|
import { exportToPptx } from "../lib/pptx/export"
|
|
12
11
|
import { recordRenderedArtifact, workspaceRelative } from "../lib/workspace-state/rendered-artifacts"
|
|
13
12
|
|
|
@@ -45,7 +44,6 @@ export default tool({
|
|
|
45
44
|
|
|
46
45
|
try {
|
|
47
46
|
const root = directory || process.cwd()
|
|
48
|
-
assertDeckHtmlContractValid(root, filePath)
|
|
49
47
|
const result = await exportToPptx(filePath, {
|
|
50
48
|
speakerNotes: normalizeSpeakerNotes(speakerNotes),
|
|
51
49
|
onProgress: (event) => {
|
package/tools/qa.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* revela-qa — Artifact quality assurance for generated slide HTML files.
|
|
5
5
|
*
|
|
6
|
-
* Exposed as a manual diagnostic tool.
|
|
6
|
+
* Exposed as a manual diagnostic tool. Deck writes run post-write QA automatically.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { tool } from "@opencode-ai/plugin"
|
|
@@ -18,7 +18,7 @@ export default tool({
|
|
|
18
18
|
"Opens the file in a headless browser and measures actual rendered geometry. " +
|
|
19
19
|
"Checks deck contract, component compliance, exact 1920x1080 canvas, scrollbars, element overflow, text overflow, and content/evidence density warnings. " +
|
|
20
20
|
"Returns a structured report with specific issues and fix instructions. " +
|
|
21
|
-
"Deck writes
|
|
21
|
+
"Deck writes run QA automatically; call it directly for explicit diagnostics before review or export.",
|
|
22
22
|
args: {
|
|
23
23
|
file: tool.schema
|
|
24
24
|
.string()
|
package/lib/qa/export-gate.ts
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { formatReport, runQA } from "./index"
|
|
2
|
-
import { assertDeckHtmlContractValid } from "../deck-html/contract"
|
|
3
|
-
|
|
4
|
-
export interface ExportQAGateOptions {
|
|
5
|
-
workspaceRoot?: string
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export async function assertExportQAPassed(filePath: string, options: ExportQAGateOptions = {}): Promise<void> {
|
|
9
|
-
if (options.workspaceRoot) assertDeckHtmlContractValid(options.workspaceRoot, filePath)
|
|
10
|
-
|
|
11
|
-
const report = await runQA(filePath)
|
|
12
|
-
if (report.totalIssues === 0) return
|
|
13
|
-
|
|
14
|
-
throw new Error(
|
|
15
|
-
"Export blocked because pre-export QA found issues. Fix them and export again.\n\n" +
|
|
16
|
-
formatReport(report)
|
|
17
|
-
)
|
|
18
|
-
}
|