@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 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
+ }
@@ -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
  )
@@ -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 puppeteer.launch({
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
 
@@ -13,7 +13,7 @@
13
13
  * editable whenever dom-to-pptx can represent them.
14
14
  */
15
15
 
16
- import puppeteer, { type Browser, type Page } from "puppeteer-core"
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 findChromePath(): string {
129
- for (const p of CHROME_PATHS) {
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(executablePath)
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 puppeteer.launch({
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()
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyber-dash-tech/revela",
3
- "version": "0.17.14",
3
+ "version": "0.17.16",
4
4
  "description": "OpenCode plugin for trusted narrative artifacts from local sources, research, and evidence",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "revela": {
4
4
  "command": "npx",
5
- "args": ["-y", "@cyber-dash-tech/revela@0.17.14", "mcp"]
5
+ "args": ["-y", "@cyber-dash-tech/revela@0.17.16", "mcp"]
6
6
  }
7
7
  }
8
8
  }
@@ -83,7 +83,7 @@ const tools = [
83
83
  },
84
84
  {
85
85
  name: "revela_export_pdf",
86
- description: "Run export QA and export a Revela HTML deck to PDF.",
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: "Run export QA and export a Revela HTML deck to PPTX.",
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 after artifact QA.
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. Call `revela_run_deck_qa` before export.
14
- 3. If QA hard errors exist, repair the HTML before exporting.
15
- 4. For PDF, call `revela_export_pdf`.
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 a Revela-generated HTML slide deck to PDF. " +
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. Export commands run pre-export QA automatically.
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 and PDF/PPTX export commands run QA automatically; call it directly for explicit diagnostics.",
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()
@@ -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
- }