diff --git a/.gitignore b/.gitignore index d76899a..280aeff 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ VALIDATION_REPORT.md .superpowers/* .planning/* .cline/ +.serena/* # examples examples/01-example/node_modules/ diff --git a/AGENTS.md b/AGENTS.md index 4266b9f..5112f8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,14 @@ This repository contains Agent Skills and reference scaffolds for reliable **editable, browser-native slide applications**. +## Code intelligence + +Prefer Serena semantic tools for source-code navigation, symbol lookup, +references, implementations, dependency tracing, and symbol-level refactoring. + +Use built-in read/grep tools for configuration files, documentation, logs, +plain-text searches, or when Serena cannot resolve the required code context. + ## Read order 1. `rules/README.md` diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..c3f101d --- /dev/null +++ b/QUICKSTART.md @@ -0,0 +1,128 @@ +# DeckForge Quick Start + +Get from zero to a working browser-native presentation in 5 minutes. + +## Install + +```bash +# Add the primary skill (recommended starting point) +npx skills@latest add tph-kds/deckforge --skill deckforge + +# Or install everything +npx skills@latest add tph-kds/deckforge --skill '*' +``` + +Your coding agent now has DeckForge loaded. The skill activates automatically when you ask to create, edit, or audit a presentation. + +## Create your first deck + +Tell your agent what you want. Natural language works: + +``` +Create a 10-slide product launch deck for a SaaS analytics platform. +Use a dark theme with blue accents. Include speaker notes on every slide. +``` + +``` +Build an investor pitch deck based on this outline: +1. Problem, 2. Solution, 3. Market size, 4. Traction, 5. Team, 6. Ask +Target: $2M seed round. Audience: early-stage VCs. +``` + +``` +Make a technical architecture presentation for our microservices migration. +Include a process flow, comparison chart, and timeline. +``` + +The agent loads the `deckforge` skill, selects templates and layouts, and builds a complete `DeckProject` with an editor, presenter, and export capability. + +## Common workflows + +### Build a new presentation + +``` +Create an editable web presentation about climate tech innovations. +Include charts, diagrams, and a call-to-action closing slide. +``` + +Agent activates: `deckforge` + +### Redesign an existing deck + +``` +Redesign this presentation — the layout feels cluttered and the typography is inconsistent. +``` + +Agent loads your `deck.json`, audits it, and rebuilds with consistent themes and spacing. + +### Add PPTX export + +``` +Add PowerPoint download to this deck. Make sure diagrams survive as visuals, not text summaries. +``` + +Agent activates: `deckforge-export` + +### Audit for quality + +``` +Review this presentation for accessibility, performance, and design consistency. +``` + +Agent activates: `deckforge-audit` + +### Publish to the web + +``` +Deploy this deck as a standalone web page with a public URL and embed support. +``` + +Agent activates: `deckforge-publish` + +## What you get + +A complete project with: + +- **`deck.json`** — the structured slide model (themes, layouts, blocks, motion, metadata) +- **`src/`** — React app with editor toolbar, slide rail, inspector, presenter view +- **PPTX export** — hybrid editable PowerPoint with fidelity scoring and preflight checks +- **Validation** — schema, layout, collision, and quality gates run before completion + +## Run it + +```bash +cd your-project +npm install +npm run dev +``` + +Open `http://localhost:5173` — you have a full slide editor with undo/redo, themes, presenter mode, and export. + +## Skill reference + +| Skill | When to use | +|---|---| +| `deckforge` | Create, redesign, extend, or migrate a presentation | +| `deckforge-audit` | Review existing slides for quality issues | +| `deckforge-export` | Add or fix PPTX export with fidelity checks | +| `deckforge-runtime-planner` | Plan architecture without implementing | +| `deckforge-publish` | Web delivery, embeds, and release | +| `deckforge-visual-evidence` | Browser verification of generated apps | +| `deckforge-skill-evaluator` | Compare skill outcomes | + +Worker skills (`visual-evidence`, `skill-evaluator`) are invoked by CI, not by user prompts. + +## Validate your project + +```bash +npm run validate # full quality gate +npm run package-skills # build skill ZIP bundles +``` + +## Learn more + +- [`README.md`](./README.md) — full project overview +- [`skills/README.md`](./skills/README.md) — skill catalog +- [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md) — recommended architecture +- [`docs/QUALITY_MODEL.md`](./docs/QUALITY_MODEL.md) — quality criteria +- [`docs/TEMPLATE_AUTHORING.md`](./docs/TEMPLATE_AUTHORING.md) — add templates diff --git a/README.md b/README.md index 5ff7a43..9eadabe 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,10 @@ deckforge-web-slides-skills/ └── tests/ ``` +## Quick start + +New to DeckForge? See [`QUICKSTART.md`](./QUICKSTART.md) — get from zero to a working presentation in 5 minutes. + ## Quick orientation for contributors ```bash @@ -310,6 +314,7 @@ DeckForge contributions should preserve these principles: | Document | Purpose | |---|---| +| [`QUICKSTART.md`](./QUICKSTART.md) | 5-minute guide for new users. | | [`AGENTS.md`](./AGENTS.md) | Entry point and routing guidance for coding agents. | | [`INSTALL_WITH_SKILLS_CLI.md`](./INSTALL_WITH_SKILLS_CLI.md) | Installation and discovery instructions. | | [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md) | Recommended product and runtime architecture. | diff --git a/docs/plans/pptx-fidelity-upgrade.md b/docs/plans/pptx-fidelity-upgrade.md new file mode 100644 index 0000000..d6d9cac --- /dev/null +++ b/docs/plans/pptx-fidelity-upgrade.md @@ -0,0 +1,1623 @@ +# PPTX Fidelity-First Export Upgrade (02-example) + +Status: **Draft — corrected after self-review, not yet executed** +Target: `examples/02-example` +Planner: DeckForge runtime planner + superpowers writing-plans +Created: 2026-08-09 + +## Purpose + +The current PPTX exporter (`02-example/src/export/**`) produces a working +`.pptx` but gives the user no feedback about **what was actually preserved**: +charts, diagrams, and video chapters are silently replaced, fonts are +substituted, and the deck-level status is a coarse `"complete" | "partial" | +"failed"`. This plan upgrades the export pipeline to a **fidelity-first** +contract: + +- Every block export reports its *representation* (native / svg / raster / + expanded-build / unsupported). +- The final deck status becomes fidelity-aware: + `"complete" | "complete-with-fallbacks" | "partial" | "failed"` (extended + `ExportStatus`). +- Charts, diagrams, and video chapter content export as **inline SVG** (data + URIs) instead of being dropped, so PowerPoint/Keynote/LibreOffice can still + open and render them as images, while the HTML5 runtime keeps true + editability. +- The generated `.pptx` is verified against a short **archive contract** + (slide count, speaker notes, hyperlinks, text survival) after generation. +- The export dialog shows a **fidelity summary** (content recall, missing + blocks, fallback count) instead of only a file path. +- A `docs`-visible **fidelity showcase deck** (`03-example-fidelity`) is added + to make degradation behavior visible and reviewable. + +This is an **editable-deck** delivery profile: the editor surface stays the +single source of truth, the exported artifact is a fidelity-preserving +derivative, and any loss is surfaced, not hidden. + +## Non-goals + +- No actual `.pptx` round-trip *rendering* (no slide-preview raster). Fidelity + here means: content/data preserved in a form PowerPoint can open, with any + loss explicitly reported. +- No WYSIWYG export UI changes beyond the summary card and shortcut hint. +- No changes to the web presenter/editor surfaces themselves. +- No native text/image/shape table-of-rows conversion work (already native). +- No new runtime dependency: SVG export uses only `pptxgenjs` image support. +- No attempt to make PowerPoint edit the SVG (documented as "image-only"). + +## Delivery contract (what must be true when this is done) + +1. `src/export/fidelity/fidelity-report.ts` exports `buildFidelityReport`, + `countRepresentation`, and `fidelityStatus`. `src/export/fidelity/ + representation-planner.ts` exports `planBlockRepresentation` and + `FIDELITY_POLICY`. `src/export/fidelity/content-parity.ts` exports + `calculateContentParity`. `src/export/fidelity/fidelity-policy.ts` exports + `FIDELITY_POLICY`. +2. `buildFidelityReport` returns `{ status, contentRecall, missingVisibleBlocks, + blocks }` where `status` derives from parity and the per-block statuses per + the hard rules below, and `blocks[i].representation` is one of + `"native" | "svg" | "raster" | "expanded-build" | "unsupported"`. +3. `export.ts` extends `PptxExportResult` with `fidelity?: FidelityReport` + and populates it from the built `ExportReport` (`blocks` + parity + archive + verification). +4. `ExportStatus` gains `"complete-with-fallbacks"`. The four-value order is + `"complete" | "complete-with-fallbacks" | "partial" | "failed"` — never + "good, then bad" within one deck. +5. The seed deck exports with **no** `error`-severity issues and no silent + losses: every seed block reports `"native"` or `"svg"` (charts/diagram), + and the deck status is `"complete"` or `"complete-with-fallbacks"`. +6. The export dialog replaces the status badge with a fidelity summary card + (recall %, missing-block count, fallback count) plus a hint to press + `?` for export shortcuts. +7. Preflight exposes `estimatedFallbacks`, `missingBlockCount`, + `unsupportedBlockCount`, and `chartBlockCount` (zero-satisfying defaults for + null configs). +8. A dedicated exportable showcase deck lives at + `examples/03-example-fidelity` (decks/theme/components mirror the 02 pattern + minimally), demonstrating: chart SVG export, diagram SVG export, video + chapter fallback, and a raster fallback block. + +## Global constraints (read every step against these) + +- **One-way dependency rule.** `export-types.ts` must NOT import from + `fidelity/*`. All fidelity schema types live in `export-types.ts`; + `fidelity/*` imports *from* `export-types.ts`. This keeps the type layer + acyclic and avoids a runtime cycle. +- **No runtime cycle.** Every fidelity `import type` in `export/*.ts` (the + framework `export.ts`, `export-preflight.ts`, `pptx/*`) is converted to a + full `import` only if the imported module has no side effects — all our + modules are side-effect-free, so full imports are fine. Do not add modules + that run side effects at import time. +- **Keep the editor and presenter as separate surfaces.** No changes to + editor/presenter routing. +- **Visible shortcut guidance.** The dialog already shows `?` for shortcuts; + the fidelity card must not remove it. +- **Always fidelity-aware.** Never claim "complete" when a non-hidden block is + `raster`, `svg` (only when an error occurred), or `unsupported`. +- **SVG is emitted with `data:` URI and `alt`.** Charts/diagrams export an SVG + image with `alt` text derived from block content; the `alt` is what the + verifier and PowerPoint screen-reader see. +- **Keep tests green as you go.** Run `npm.cmd run typecheck` then + `npm.cmd run test -- src/export` (watch mode for the current file) after + every task. The export test suite's existing tests must stay passing + (update only where the plan explicitly says). + +## Verification loop + +```bash +# in examples/02-example (use npm.cmd on Windows) +npm.cmd run typecheck +npm.cmd run test -- src/export/fidelity +npm.cmd run test -- src/export +npm.cmd run build +# then, from repo root: +python ../../scripts/audits/validate_output_contract.py . --profile editable-deck +npm.cmd run validate +``` + +Run the relevant slice after every task, the full loop at the end. + +--- + +# Task 1 — Schema: fidelity types + `svg` slide element + +Adds the type vocabulary the whole pipeline uses, and extends the slide-element +model with an SVG variant. Tests are added first and must pass against the new +types without changing any behavior. + +> Execution note (2026-08-09): two facts differ from the original draft. +> (a) Vitest only includes `tests/**/*.test.ts(x)` (see `vite.config.ts`), so +> every test lives under `tests/` with `../src/...` imports. (b) +> `PptxSlideElement` is already a **discriminated union** with a typed `data` +> per variant — so the SVG variant is added as a new union member, not by +> widening a single interface. + +## Steps + +### 1. Test-first: add `tests/export-types.test.ts` + +Create `examples/02-example/tests/export-types.test.ts` (mirrors the +`tests/export.test.ts` convention): + +```ts +import { describe, expect, it } from "vitest"; +import type { FidelityReport } from "../src/export/fidelity/fidelity-types"; +import type { PptxExportResult, PptxSlideElementType } from "../src/export/export-types"; + +describe("export schema", () => { + it("accepts the four-value fidelity status order", () => { + const order: Array<"complete" | "complete-with-fallbacks" | "partial" | "failed"> = [ + "complete", + "complete-with-fallbacks", + "partial", + "failed", + ]; + expect(order).toHaveLength(4); + }); + + it("defines PptxExportResult with an optional fidelity report", () => { + const result: PptxExportResult = { + report: { status: "complete", slides: [], issues: [] }, + blob: new Blob(), + archiveVerified: true, + fidelity: { status: "complete", contentRecall: 1, missingVisibleBlocks: 0, blocks: [] }, + }; + expect(result.fidelity?.status).toBe("complete"); + }); + + it("allows an svg slide element type", () => { + const t: PptxSlideElementType = "svg"; + expect(t).toBe("svg"); + }); +}); +``` + +Because vitest strips types without checking, the red phase for this test is +`npm.cmd run typecheck` (tsc errors: missing `fidelity/fidelity-types`, +missing `PptxSlideElementType`, no `fidelity` on `PptxExportResult`). The +vitest run itself passes once the files compile. + +Run: `npm.cmd run typecheck` (red), then `npm.cmd run test -- tests/export-types.test.ts` (green after implementation). + +### 2. Add an `svg` variant to the slide-element union + +`src/export/export-types.ts` already models `PptxSlideElement` as a +discriminated union of element interfaces with typed `data`. Add a new member: + +```ts +interface PptxSvgElement { + type: "svg"; + x: number; + y: number; + w: number; + h: number; + data: { svg: string; alt?: string; options?: Record }; +} +``` + +and append it to the union (`... | PptxSvgElement`). Also export a type alias +for the discriminant: + +```ts +export type PptxSlideElementType = + | "text" + | "image" + | "shape" + | "table" + | "chart" + | "fallback" + | "svg"; +``` + +SVG elements are constructed as +`{ type: "svg", x, y, w, h, data: { svg: "", alt?: string } }`. Because +the union already types `data` per variant, `element.data.svg` is typed +`string` in `writeElementToSlide` — no cast needed at the access site. + +### 3. Extend `ExportBlockReport` (line ~36) + +```ts +export interface ExportBlockReport { + blockId: string; + status: BlockExportStatus; + issues: ExportIssue[]; + representation?: BlockRepresentation; + contentPreserved?: boolean; + editable?: boolean; + visualParity?: number; +} +``` + +`BlockRepresentation` is defined in step 4 below; it lives in this same file. + +### 4. Add the fidelity schema types (in `export-types.ts`, near `ExportStatus` line ~50) + +Add **in this file** so the one-way dependency rule holds (`fidelity/*` imports +from here, never the reverse): + +```ts +export type PptxExportMode = "fidelity-first" | "editability-first"; + +export type BlockRepresentation = "native" | "svg" | "raster" | "expanded-build" | "unsupported"; + +export type FidelityStatus = ExportStatus; + +export interface PptxVerificationCheck { + name: string; + passed: boolean; + detail?: string; +} + +export interface PptxVerificationReport { + checks: PptxVerificationCheck[]; + passed: boolean; +} + +export interface FidelityReport { + status: FidelityStatus; + contentRecall: number; + missingVisibleBlocks: number; + blocks: ExportBlockReport[]; + verification?: PptxVerificationReport; +} +``` + +Also extend the status union (line ~50): + +```ts +export type ExportStatus = "complete" | "complete-with-fallbacks" | "partial" | "failed"; +``` + +### 5. Extend `ExportPreflightResult` (line ~121) + +```ts +export interface ExportPreflightResult { + issues: ExportIssue[]; + score: number; + blockCoverage: number; + estimatedFallbacks: number; + missingBlockCount: number; + unsupportedBlockCount: number; + chartBlockCount: number; +} +``` + +### 6. Extend `PptxExportResult` (line ~59) + +```ts +export interface PptxExportResult { + report: ExportReport; + blob: Blob; + archiveVerified: boolean; + fidelity?: FidelityReport; +} +``` + +### 6a. Create `fidelity/fidelity-types.ts` (re-export layer) + +The Task 1 test imports `FidelityReport` from `./fidelity/fidelity-types`, so +the re-export layer is created here (it was originally placed in Task 2). Its +content is exactly the file described in Task 2 step 3 below; Task 2 skips +that step. + +### 6b. Keep `export-preflight.ts` compiling + +Because `ExportPreflightResult` gained required fields, make +`runExportPreflight` return zero-default estimates now (real estimates land in +Task 9): + +```ts +return { + issues, + score, + blockCoverage, + estimatedFallbacks: 0, + missingBlockCount: 0, + unsupportedBlockCount: 0, + chartBlockCount: 0, +}; +``` + +### 7. Mirror ALL type changes into the starter-components copy + +Repeat steps 2–6 in +`skills/deckforge/starter-components/export/export-types.ts` (same edits, +same order). Keep the two files in sync — the starter copy is what future +generated apps get. + +### 8. Re-run `export-types.test.ts` — green. + +--- + +# Task 2 — Fidelity policy and representation planner + +Implements the decision function that maps a block + export result to a +representation, and a hand-written `FIDELITY_POLICY` (hard rules + priorities) +used by the parity/report layers and (later) the showcase deck. + +## Steps + +### 1. Add `fidelity/fidelity-policy.ts` (new) + +```ts +import type { PptxFidelityPolicy } from "./fidelity-types"; + +export const FIDELITY_POLICY: PptxFidelityPolicy = { + version: "1.0.0", + defaultMode: "fidelity-first", + priorities: ["content", "visual", "geometry", "editability", "file-size"], + hardRules: { + meaningfulContentRecall: 0.9, + maxMissingVisibleBlocks: 0, + silentOmissionAllowed: false, + diagramSummaryFallbackAllowed: true, + }, + representations: ["native", "svg", "raster", "expanded-build"], +}; +``` + +### 2. Add `fidelity/representation-planner.ts` (new) + +```ts +import type { BlockExportStatus, ExportIssue, PptxSlideElement } from "../export-types"; +import type { BlockRepresentation, FidelityBlockReport, PptxFidelityPolicy } from "./fidelity-types"; + +export interface PlannerInput { + blockId: string; + hidden: boolean; + status: BlockExportStatus; + element?: PptxSlideElement; + issues: ExportIssue[]; +} + +function repr( + blockId: string, + status: BlockExportStatus, + issues: ExportIssue[], + representation: BlockRepresentation, + extras: Partial> = {}, +): FidelityBlockReport { + return { + blockId, + status, + issues, + representation, + contentPreserved: representation !== "unsupported", + editable: representation === "native", + visualParity: representation === "native" ? 1 : representation === "svg" ? 0.9 : 0.8, + ...extras, + }; +} + +export function planBlockRepresentation( + input: PlannerInput, + policy: PptxFidelityPolicy = FIDELITY_POLICY, +): FidelityBlockReport { + const { blockId, hidden, status, element, issues } = input; + if (hidden) { + return repr(blockId, "skipped", issues, "unsupported", { contentPreserved: false, editable: false, visualParity: 0 }); + } + const hasError = issues.some((issue) => issue.severity === "error"); + if (hasError || status === "unsupported") { + return repr(blockId, "unsupported", issues, "unsupported", { contentPreserved: false, editable: false, visualParity: 0 }); + } + const r: BlockRepresentation = + element?.type === "svg" + ? "svg" + : status === "native" + ? "native" + : status === "rasterized" + ? "raster" + : status === "substituted" + ? "expanded-build" + : "unsupported"; + return repr(blockId, status, issues, r); +} + +export function countRepresentation( + blocks: FidelityBlockReport[], + rep: BlockRepresentation, +): number { + return blocks.filter((b) => b.representation === rep).length; +} +``` + +### 3. Add `fidelity/fidelity-types.ts` — DONE in Task 1 (step 6a) + +Already created in Task 1; its content is exactly the file below. If for any +reason it does not exist yet, create it now: + +This file is the **re-export layer** so consumers keep importing from +`./fidelity-types` while the schema itself lives in `export-types.ts` (one-way +dependency): + +```ts +import type { + BlockRepresentation, + ExportBlockReport, + ExportStatus, + FidelityReport, + PptxExportMode, + PptxVerificationReport, +} from "../export-types"; + +// Re-exported so consumers can import the shared schema types from one place. +export type { BlockRepresentation, FidelityReport, PptxExportMode, PptxVerificationReport }; + +export type FidelityStatus = ExportStatus; + +/** A per-block fidelity report entry; same shape as the extended ExportBlockReport. */ +export type FidelityBlockReport = ExportBlockReport; + +export interface FidelityHardRules { + meaningfulContentRecall: number; + maxMissingVisibleBlocks: number; + silentOmissionAllowed: boolean; + diagramSummaryFallbackAllowed: boolean; +} + +export interface PptxFidelityPolicy { + version: string; + defaultMode: PptxExportMode; + priorities: Array<"content" | "visual" | "geometry" | "editability" | "file-size">; + hardRules: FidelityHardRules; + representations: Array<"native" | "svg" | "raster" | "expanded-build">; +} +``` + +### 4. Add `fidelity/representation-planner.test.ts` (new) + +```ts +import { describe, expect, it } from "vitest"; +import { FIDELITY_POLICY } from "./fidelity-policy"; +import { countRepresentation, planBlockRepresentation, type PlannerInput } from "./representation-planner"; +import type { FidelityBlockReport, PptxFidelityPolicy } from "./fidelity-types"; + +function rep(input: Partial): FidelityBlockReport { + return planBlockRepresentation( + { + blockId: "b1", + hidden: false, + status: "native", + issues: [], + ...input, + }, + FIDELITY_POLICY, + ); +} + +describe("planBlockRepresentation", () => { + it("maps native blocks to native representation", () => { + const r = rep({ status: "native" }); + expect(r.representation).toBe("native"); + expect(r.editable).toBe(true); + expect(r.contentPreserved).toBe(true); + }); + + it("maps svg elements to svg representation", () => { + const r = rep({ status: "rasterized", element: { type: "svg", x: 0, y: 0, w: 100, h: 100, data: { svg: "", alt: "A chart" } } }); + expect(r.representation).toBe("svg"); + expect(r.editable).toBe(false); + }); + + it("maps rasterized non-svg elements to raster", () => { + const r = rep({ status: "rasterized" }); + expect(r.representation).toBe("raster"); + }); + + it("maps substituted blocks to expanded-build", () => { + const r = rep({ status: "substituted" }); + expect(r.representation).toBe("expanded-build"); + }); + + it("is unsupported when the exporter errored", () => { + const r = rep({ status: "native", issues: [{ code: "block-export-failed", severity: "error", message: "boom", automaticFixAvailable: false }] }); + expect(r.representation).toBe("unsupported"); + expect(r.contentPreserved).toBe(false); + expect(r.editable).toBe(false); + }); + + it("skips hidden blocks without counting them as missing", () => { + const r = rep({ hidden: true }); + expect(r.representation).toBe("unsupported"); + expect(r.status).toBe("skipped"); + }); + + it("counts representations per category", () => { + const blocks = [rep({ status: "native" }), rep({ status: "rasterized", element: { type: "svg", x: 0, y: 0, w: 1, h: 1, data: { svg: "" } } }), rep({ hidden: true })]; + expect(countRepresentation(blocks, "svg")).toBe(1); + expect(countRepresentation(blocks, "native")).toBe(1); + }); + + it("respects a custom policy's allowed representations", () => { + const custom: PptxFidelityPolicy = { ...FIDELITY_POLICY, representations: ["native"] }; + const r = planBlockRepresentation({ blockId: "b", hidden: false, status: "native", issues: [] }, custom); + expect(r.representation).toBe("native"); + }); +}); +``` + +Run: `npm.cmd run typecheck`, then `npm.cmd run test -- src/export/fidelity/representation-planner.test.ts`. + +--- + +# Task 3 — Content parity + +Computes a single `contentRecall` number from a deck and its fidelity block +reports. This is the input to `buildFidelityReport`'s status. + +## Steps + +### 1. Add `fidelity/content-parity.ts` (new) + +> Execution note: in this codebase `Block.content` is typed `unknown`, so the +> module casts through a `Record` helper (`asRecord`) before +> reading `.text` / `.alt` / `.chart.title`. The original draft's placeholder +> `blockRepresentation` (which always returned `"native"`) is dropped — it was +> dead weight. The module exports `rawText` and `calculateContentParity`. + +```ts +import type { Block, DeckProject } from "../../deck/types"; +import type { FidelityBlockReport, PptxFidelityPolicy } from "./fidelity-types"; +import { FIDELITY_POLICY } from "./fidelity-policy"; + +const VISIBLE_TEXT = /[A-Za-z0-9]{2,}/g; + +type ContentRecord = Record; + +function asRecord(value: unknown): ContentRecord { + return (value ?? {}) as ContentRecord; +} + +export function rawText(block: Block): string { + return String(asRecord(block.content).text ?? ""); +} + +function meaningfulText(block: Block): number { + return (rawText(block).match(VISIBLE_TEXT) ?? []).length; +} + +export function calculateContentParity( + deck: DeckProject, + blocks: FidelityBlockReport[], + policy: PptxFidelityPolicy = FIDELITY_POLICY, +): number { + const visible = deck.slides.flatMap((slide) => slide.blocks.filter((block) => !block.hidden)); + if (visible.length === 0) return 1; + + const byId = new Map(blocks.map((b) => [b.blockId, b])); + const expected = visible.map((block) => meaningfulText(block)); + const present = visible.map((block) => { + const rep = byId.get(block.id); + if (!rep || rep.representation === "unsupported") return 0; + if (rep.representation === "native" || rep.representation === "expanded-build") return meaningfulText(block); + if (rep.representation === "raster") return Math.round(meaningfulText(block) * 0.8); + if (rep.representation === "svg") { + const content = asRecord(block.content); + const alt = String(content.alt ?? asRecord(content.chart).title ?? content.text ?? ""); + return Math.max(1, (alt.match(VISIBLE_TEXT) ?? []).length); + } + return 0; + }); + + const expectedTotal = expected.reduce((a, b) => a + b, 0); + const presentTotal = present.reduce((a, b) => a + b, 0); + return expectedTotal === 0 ? 1 : Math.min(1, presentTotal / expectedTotal); +} +``` + +### 2. Add `fidelity/content-parity.test.ts` (new) + +```ts +import { describe, expect, it } from "vitest"; +import { calculateContentParity } from "./content-parity"; +import { FIDELITY_POLICY } from "./fidelity-policy"; +import type { FidelityBlockReport } from "./fidelity-types"; +import type { DeckProject } from "../../deck/types"; + +function textBlock(id: string, text: string, hidden = false): any { + return { id, type: "text", layout: { x: 0, y: 0, w: 200, h: 40 }, content: { text }, hidden }; +} + +function deck(blocks: any[], slideId = "s1"): DeckProject { + return { slides: [{ id: slideId, blocks }] } as unknown as DeckProject; +} + +function native(id: string): FidelityBlockReport { + return { blockId: id, status: "native", issues: [], representation: "native" }; +} + +function unsupported(id: string): FidelityBlockReport { + return { blockId: id, status: "unsupported", issues: [], representation: "unsupported", contentPreserved: false }; +} + +describe("calculateContentParity", () => { + it("is 1 for an all-native deck", () => { + const d = deck([textBlock("a", "Hello world")]); + const parity = calculateContentParity(d, [native("a")], FIDELITY_POLICY); + expect(parity).toBe(1); + }); + + it("is 0 when every visible block is missing", () => { + const d = deck([textBlock("a", "Hello world")]); + const parity = calculateContentParity(d, [unsupported("a")], FIDELITY_POLICY); + expect(parity).toBe(0); + }); + + it("is 0.5 when one of two blocks is missing", () => { + const d = deck([textBlock("a", "Hello world"), textBlock("b", "Goodbye world")]); + const parity = calculateContentParity(d, [native("a"), unsupported("b")], FIDELITY_POLICY); + expect(parity).toBe(0.5); + }); + + it("excludes hidden blocks from the denominator", () => { + const d = deck([textBlock("a", "Hello world", true), textBlock("b", "Goodbye world")]); + const parity = calculateContentParity(d, [unsupported("a"), native("b")], FIDELITY_POLICY); + expect(parity).toBe(1); + }); + + it("is 1 for an empty visible deck", () => { + expect(calculateContentParity(deck([]), [], FIDELITY_POLICY)).toBe(1); + }); +}); +``` + +Run: `npm.cmd run typecheck`, then `npm.cmd run test -- src/export/fidelity/content-parity.test.ts`. + +--- + +# Task 4 — PPTX archive verifier + +Adds `pptx-verifier.ts`: a JSZip-based structural check of the produced +`.pptx`. It verifies slide count, speaker notes, text survival, and slide +relationship integrity. +The current `pptx-exporter.ts` already stubs `verifyArchive` returning +`{ passed: true }`; replace it here. The exporter test suite must keep passing +(its `vi.mock` of pptxgenjs still works because the verifier needs no +pptxgenjs). + +## Steps + +### 1. Add `pptx/pptx-verifier.ts` (new) + +```ts +import JSZip from "jszip"; +import type { ExportReport, PptxVerificationCheck, PptxVerificationReport } from "../export-types"; + +export interface VerificationInput { + report: ExportReport; + blob: Blob; + /** slideId -> expected text fragments that must appear in that slide's runs */ + expectedTexts?: Record; +} + +function decode(entryText: string): string { + return entryText.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'"); +} + +function normalizeText(text: string): string { + return decode(text).replace(/<[^>]*>/g, " ").replace(/ /g, " ").replace(/\s+/g, " ").trim().toLowerCase(); +} + +function slideNumber(slideId: string): number { + const match = /slide(\d+)/.exec(slideId); + return match ? parseInt(match[1], 10) : 0; +} + +export async function verifyPptxArchive(input: VerificationInput): Promise<{ + passed: boolean; + report: PptxVerificationReport; +}> { + const { report, blob, expectedTexts = {} } = input; + const checks: PptxVerificationCheck[] = []; + const slideIds = report.slides.map((s) => s.slideId); + + try { + const zip = await JSZip.loadAsync(blob); + + const archiveSlides = Object.keys(zip.files) + .filter((name) => name.startsWith("ppt/slides/slide") && name.endsWith(".xml") && !name.includes("_rels")) + .map((name) => name.replace("ppt/slides/", "").replace(".xml", "")) + .filter((name) => name !== "slideMast"); + checks.push({ + name: "slide-count", + passed: archiveSlides.length === slideIds.length, + detail: `expected ${slideIds.length} slides, found ${archiveSlides.length}`, + }); + + const notesCount = slideIds.reduce((count, slideId) => { + const entry = zip.file(`ppt/notesSlides/notesSlide${slideNumber(slideId)}.xml`); + return entry ? count + 1 : count; + }, 0); + checks.push({ + name: "speaker-notes", + passed: notesCount === slideIds.length, + detail: `found notes for ${notesCount} of ${slideIds.length} slides`, + }); + + const slideText = new Map(); + for (const slideId of slideIds) { + const entry = zip.file(`ppt/slides/${slideId}.xml`); + if (!entry) continue; + const raw = await entry.async("string"); + const texts = raw.match(/([^<]*)<\/a:t>/g) ?? []; + slideText.set(slideId, texts.map((t) => t.replace(/<\/?a:t>/g, "")).join(" ")); + } + + const missing: string[] = []; + for (const slideId of slideIds) { + const normalized = normalizeText(slideText.get(slideId) ?? ""); + for (const expected of expectedTexts[slideId] ?? []) { + if (!normalized.includes(normalizeText(expected))) { + missing.push(`slide ${slideId} missing text: "${expected}"`); + } + } + } + checks.push({ + name: "text-survival", + passed: missing.length === 0, + detail: missing.length === 0 ? "all expected text found" : missing.join("; "), + }); + + const missingRels: string[] = []; + for (const slideId of slideIds) { + const rels = zip.file(`ppt/slides/_rels/${slideId}.xml.rels`); + if (!rels) continue; + const relsText = await rels.async("string"); + if (!/^\s*]*\bTarget=/m.test(relsText)) { + missingRels.push(slideId); + } + } + checks.push({ + name: "relationship-integrity", + passed: missingRels.length === 0, + detail: missingRels.length === 0 ? "every slide has a target-bearing relationship" : `slides missing rels targets: ${missingRels.join(", ")}`, + }); + + return { + passed: checks.every((c) => c.passed), + report: { checks, passed: checks.every((c) => c.passed) }, + }; + } catch (error) { + checks.push({ + name: "archive-open", + passed: false, + detail: error instanceof Error ? error.message : "failed to open archive", + }); + return { passed: false, report: { checks, passed: false } }; + } +} +``` + +### 2. Add `pptx/pptx-verifier.test.ts` (new) + +The verifier needs a real blob, so it cannot use the existing pptxgenjs mock. +It builds a minimal valid PPTX zip with `jszip` directly. + +```ts +import { describe, expect, it } from "vitest"; +import JSZip from "jszip"; +import { verifyPptxArchive } from "./pptx-verifier"; +import type { ExportReport } from "../export-types"; + +const SLIDE_XML = ` + + + Hello world + +`; + +const SLIDE_RELS = ` + + +`; + +const NOTES_XML = ` + + Speaker note +`; + +async function buildZip(overrides: Record = {}): Promise { + const zip = new JSZip(); + const base: Record = { + "[Content_Types].xml": "", + "ppt/slides/slide1.xml": SLIDE_XML, + "ppt/slides/_rels/slide1.xml.rels": SLIDE_RELS, + "ppt/notesSlides/notesSlide1.xml": NOTES_XML, + }; + const files = { ...base, ...overrides }; + for (const [name, content] of Object.entries(files)) { + zip.file(name, content as string); + } + return zip.generateAsync({ type: "blob" }); +} + +const BASE_REPORT: ExportReport = { + status: "complete", + slides: [ + { + slideId: "slide1", + blocks: [ + { blockId: "text-a", status: "native", issues: [], representation: "native", contentPreserved: true }, + ], + }, + ], + issues: [], +}; + +describe("verifyPptxArchive", () => { + it("passes a well-formed archive with all expected content", async () => { + const blob = await buildZip(); + const result = await verifyPptxArchive({ + report: BASE_REPORT, + blob, + expectedTexts: { slide1: ["Hello world"] }, + }); + expect(result.passed).toBe(true); + expect(result.report.checks.map((c) => c.name)).toContain("text-survival"); + }); + + it("fails when a slide part is missing", async () => { + const blob = await buildZip(); + const result = await verifyPptxArchive({ + report: { ...BASE_REPORT, slides: [BASE_REPORT.slides[0], { slideId: "slide2", blocks: [] }] }, + blob, + }); + expect(result.passed).toBe(false); + expect(result.report.checks.find((c) => c.name === "slide-count")?.passed).toBe(false); + }); + + it("reports missing expected text", async () => { + const blob = await buildZip({ + "ppt/slides/slide1.xml": SLIDE_XML.replace("Hello world", "Goodbye world"), + }); + const result = await verifyPptxArchive({ + report: BASE_REPORT, + blob, + expectedTexts: { slide1: ["Hello world"] }, + }); + expect(result.passed).toBe(false); + const textCheck = result.report.checks.find((c) => c.name === "text-survival"); + expect(textCheck?.passed).toBe(false); + }); + + it("fails to open a non-archive blob", async () => { + const result = await verifyPptxArchive({ report: BASE_REPORT, blob: new Blob(["not a zip"]) }); + expect(result.passed).toBe(false); + expect(result.report.checks.find((c) => c.name === "archive-open")?.passed).toBe(false); + }); +}); +``` + +### 3. Replace the `verifyArchive` stub in `pptx-exporter.ts` + +Delete this block (around lines 108–126 in the current file): + +```ts +export async function verifyArchive(_blob: Blob): Promise<{ passed: boolean }> { + return { passed: true }; +} +``` + +And replace it with a thin wrapper: + +```ts +import type { ExportReport } from "../export-types"; +import { verifyPptxArchive } from "./pptx-verifier"; + +export async function verifyArchive( + blob: Blob, + report?: ExportReport, + expectedTexts?: Record, +): Promise<{ passed: boolean }> { + if (!report) return { passed: true }; + const result = await verifyPptxArchive({ report, blob, expectedTexts }); + return { passed: result.passed }; +} +``` + +Run: `npm.cmd run typecheck`, `npm.cmd run test -- src/export/pptx/pptx-verifier.test.ts`, then `npm.cmd run test -- src/export`. + +--- + +# Task 5 — Wire the verifier into the exporter and keep the suite green + +The exporter currently stubs `verifyArchive`. This task replaces that stub with +the real `verifyPptxArchive` and updates the existing export test to cover the +real verification path, while keeping every pre-existing test passing. + +## Steps + +### 1. Update the existing `export.test.ts` mock and fixtures + +Current `export.test.ts` mocks `pptxgenjs` with a fake `Slide` whose +`writeFile` returns `{ generateAsync: ... }` and its zip fixture has `slide1.xml`, +`slide2.xml`, and notes. Because the exporter now calls the real verifier, the +fixture must satisfy the archive contract. Keep the mock (it still works — the +verifier needs no pptxgenjs), but fix two things: + +**a. `ArchiveMock.writeFile` — keep returning a blob, but make sure it encodes +all three slides** (it already pushes one part per call to `generate`; when the +exporter adds notes via `addNotes` the mock already records them). Verify the +fixture's slide count matches the report (2 slides) so `slide-count` passes. + +**b. Fix the slide-count test that passed for the wrong reason.** The existing +test "reports complete with every block native for an all-native deck" and the +archive verification tests currently rely on the fake returning `passed: true` +unconditionally. Replace the unconditional pass with the real verifier by +giving the fixture exactly the two slides the report expects. If the current +test drops `slide2` from the fake zip, the verifier now correctly fails +`slide-count` — so update the test to keep BOTH slides in the fixture: + +```ts +"ppt/slides/slide2.xml": fakeSlideXml("slide2"), +"ppt/slides/_rels/slide2.xml.rels": fakeSlideRels("slide2"), +``` + +(Adjust `fakeSlideXml`/`fakeSlideRels` helpers as needed — they already exist in +the mock.) + +### 2. Confirm `export.test.ts` "verifies the archive" test now runs the real verifier + +The existing test asserting `result.archiveVerified === true` for a valid deck +must still pass. If it fails, it means the fixture's text/slide-count no longer +matches the report — fix the fixture, not the assertion. + +Run: `npm.cmd run typecheck`, then `npm.cmd run test -- src/export/export.test.ts`. + +--- + +# Task 6 — Fidelity report builder + +Combines per-block fidelity entries + content parity into the deck-level +`FidelityReport` returned by export. + +## Steps + +### 1. Add `fidelity/fidelity-report.ts` (new) + +```ts +import type { DeckProject } from "../../deck/types"; +import type { FidelityReport } from "../export-types"; +import { calculateContentParity } from "./content-parity"; +import { FIDELITY_POLICY } from "./fidelity-policy"; +import type { FidelityBlockReport, PptxFidelityPolicy } from "./fidelity-types"; + +export interface BuildFidelityReportInput { + deck: DeckProject; + blocks: FidelityBlockReport[]; + policy?: PptxFidelityPolicy; +} + +export function countRepresentation( + blocks: FidelityBlockReport[], + rep: FidelityBlockReport["representation"], +): number { + return blocks.filter((b) => b.representation === rep).length; +} + +export function fidelityStatus( + parity: number, + blocks: FidelityBlockReport[], + policy: PptxFidelityPolicy, +): FidelityReport["status"] { + const hardRules = policy.hardRules; + const visible = blocks.filter((b) => b.status !== "skipped"); + const hasError = blocks.some((b) => b.issues.some((i) => i.severity === "error")); + if (hasError) return "failed"; + if (parity < hardRules.meaningfulContentRecall) return "partial"; + if (visible.some((b) => b.representation === "unsupported")) return "partial"; + const fallbackCount = countRepresentation(blocks, "raster") + countRepresentation(blocks, "svg") + countRepresentation(blocks, "expanded-build"); + if (fallbackCount > 0) return "complete-with-fallbacks"; + return "complete"; +} + +export function buildFidelityReport(input: BuildFidelityReportInput): FidelityReport { + const { deck, blocks, policy = FIDELITY_POLICY } = input; + const parity = calculateContentParity(deck, blocks, policy); + const missingVisibleBlocks = blocks.filter( + (b) => b.status !== "skipped" && b.representation === "unsupported", + ).length; + return { + status: fidelityStatus(parity, blocks, policy), + contentRecall: parity, + missingVisibleBlocks, + blocks, + }; +} +``` + +(Note: `fidelity-report.ts` imports `FidelityReport` from `../export-types` +directly, and the re-exported `FidelityReport` in `fidelity-types.ts` is +identical, so either import site compiles. Keep the direct one here and use the +`fidelity-types` one in consumers that already import it — both are the same +type.) + +### 2. Add `fidelity/fidelity-report.test.ts` (new) + +```ts +import { describe, expect, it } from "vitest"; +import { FIDELITY_POLICY } from "./fidelity-policy"; +import { buildFidelityReport, countRepresentation, fidelityStatus } from "./fidelity-report"; +import { planBlockRepresentation, type PlannerInput } from "./representation-planner"; +import type { DeckProject } from "../../deck/types"; +import type { FidelityBlockReport } from "./fidelity-types"; + +function textBlock(id: string, text: string, hidden = false): any { + return { id, type: "text", layout: { x: 0, y: 0, w: 200, h: 40 }, content: { text }, hidden }; +} + +function deck(blocks: any[]): DeckProject { + return { slides: [{ id: "s1", blocks }] } as unknown as DeckProject; +} + +function textEl(text: string, x = 0, y = 0): PlannerInput["element"] { + return { type: "text", x, y, w: 100, h: 40, data: { text, options: {} } }; +} + +function plan(input: Partial): FidelityBlockReport { + return planBlockRepresentation( + { blockId: "b1", hidden: false, status: "native", issues: [], element: textEl("Hello world"), ...input }, + FIDELITY_POLICY, + ); +} + +describe("buildFidelityReport", () => { + it("is complete for an all-native deck", () => { + const blocks = [plan({ blockId: "a" })]; + const report = buildFidelityReport({ deck: deck([textBlock("a", "Hello world")]), blocks }); + expect(report.status).toBe("complete"); + expect(report.contentRecall).toBe(1); + expect(report.missingVisibleBlocks).toBe(0); + }); + + it("is complete-with-fallbacks when a chart is svg", () => { + const svg = plan({ blockId: "a", status: "rasterized", element: { type: "svg", x: 0, y: 0, w: 100, h: 100, data: { svg: "", alt: "Quarterly revenue" } } }); + const report = buildFidelityReport({ deck: deck([textBlock("a", "Quarterly revenue")]), blocks: [svg] }); + expect(report.status).toBe("complete-with-fallbacks"); + }); + + it("is partial when a visible block is unsupported", () => { + const blocks = [ + plan({ blockId: "a" }), + plan({ blockId: "b", issues: [{ code: "block-export-failed", severity: "error", message: "boom", automaticFixAvailable: false }] }), + ]; + const report = buildFidelityReport({ deck: deck([textBlock("a", "Hello world"), textBlock("b", "Goodbye world")]), blocks }); + expect(report.status).toBe("failed"); + expect(report.missingVisibleBlocks).toBe(1); + }); + + it("counts representations", () => { + const blocks = [plan({ blockId: "a" }), plan({ blockId: "b", status: "rasterized" })]; + expect(countRepresentation(blocks, "native")).toBe(1); + expect(countRepresentation(blocks, "raster")).toBe(1); + }); + + it("derives failed from parity below the hard rule", () => { + const blocks = [ + plan({ blockId: "a" }), + plan({ blockId: "b", issues: [{ code: "block-export-failed", severity: "error", message: "boom", automaticFixAvailable: false }] }), + ]; + expect(fidelityStatus(0.5, blocks, FIDELITY_POLICY)).toBe("failed"); + }); +}); +``` + +Run: `npm.cmd run typecheck`, then `npm.cmd run test -- src/export/fidelity/fidelity-report.test.ts`. + +--- + +# Task 7 — Rework `export.ts` + `pptx-exporter.ts` to be fidelity-first + +The core: the exporter now produces fidelity block reports, computes parity, +calls the real verifier, and returns `PptxExportResult` with `fidelity`. The +big existing `export.test.ts` suite is reworked to fixture the new pipeline. + +## Steps + +### 1. Rework `pptx-exporter.ts` + +**a. Add an SVG case to `writeElementToSlide`** (after the `"fallback"` case, +around line 82): + +```ts + case "svg": { + pptxSlide.addImage({ + data: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(element.data.svg as string)}`, + ...opts, + ...(element.data.options as PptxGenJS.ImageProps), + } as unknown as PptxGenJS.ImageProps); + break; + } +``` + +**b. Extend `ExportBuildResult`** (line ~86) so the fidelity blocks flow out: + +```ts +export interface ExportBuildResult { + slides: Array<{ slide: DeckSlide; elements: PptxSlideElement[] }>; + report: ExportReport; + parity: number; + fidelityBlocks: FidelityBlockReport[]; +} +``` + +Import `FidelityBlockReport` (from `./fidelity-types`). + +**c. Rework `deriveExportStatus`** (lines ~95-97). Current: + +```ts +export function deriveExportStatus( + issues: ExportIssue[], + slideReports: ExportSlideReport[], + params?: unknown +): ExportStatus { + const hasError = issues.some((issue) => issue.severity === "error"); + return hasError ? "failed" : "partial"; +} +``` + +Replace with: + +```ts +export function deriveExportStatus( + issues: ExportIssue[], + slideReports: ExportSlideReport[], + parity: number, + archiveVerified: boolean = true, +): ExportStatus { + const hasError = issues.some((issue) => issue.severity === "error"); + if (hasError || !archiveVerified) return "failed"; + const allNative = slideReports.every((slide) => slide.blocks.every((block) => block.status === "native")); + if (allNative) return "complete"; + if (parity >= 1) return "complete-with-fallbacks"; + return "partial"; +} +``` + +**d. Rework `exportBlocksToSlide`** (lines ~99-108) to thread a per-slide +`fidelityBlocks: FidelityBlockReport[]` array: + +- Start with `const fidelityBlocks: FidelityBlockReport[] = [];` +- For each block, call `blockExporter.export(block, ctx)` to get `result` + (`PptxBlockExport` with `status`, `issues`, optional `element`). +- Build a `PlannerInput` and push `planBlockRepresentation(...)` into + `fidelityBlocks`. Import `planBlockRepresentation` and `PlannerInput` from + `./fidelity/representation-planner`. +- If `result.element` exists, `elements.push(result.element)`. +- For hidden blocks, skip export and push + `planBlockRepresentation({ blockId: block.id, hidden: true, status: "skipped", issues: [] }, FIDELITY_POLICY)`. +- After the loop, if any block had an error issue, keep the block's element out + of the deck and record a `"block-export-failed"` issue (severity `"error"`) so + `deriveExportStatus` can fail the deck. The block report still gets + `representation: "unsupported"`. + +**e. Rework `export()`** (the exported function) to return `PptxExportResult`: + +- After building `{ slides, report }`, compute: + `const parity = calculateContentParity(deck, reportBlocks, FIDELITY_POLICY)`. +- Build the final fidelity report: + `const fidelityReport = buildFidelityReport({ deck, blocks: reportBlocks, policy: FIDELITY_POLICY })`. +- Build `expectedTexts` from the deck so the archive verifier checks real + content (visible blocks only; reuse `rawText` from `fidelity/content-parity`): + `const expectedTexts = Object.fromEntries(deck.slides.map((slide) => [slide.slideId, slide.blocks.filter((b) => !b.hidden).map((b) => rawText(b))]));` + (Adjust `slide.slideId` to the actual field name used by the deck model.) +- Replace the `verifyArchive(blob)` stub call with the real one: + `const archiveVerified = (await verifyArchive(blob, report, expectedTexts)).passed;` +- Set `report.status = deriveExportStatus(allIssues, report.slides, parity, archiveVerified)`. +- Return `{ report, blob, archiveVerified, fidelity: fidelityReport }`. + +**f. Keep the module pure and side-effect free** — no top-level imports of +modules that run side effects. + +### 2. Update `export.ts` (framework facade) + +- `import type { FidelityReport } from "./export-types";` (the schema lives + there now; do not import it from `./fidelity/fidelity-types`). +- In `PptxExporter.export()`, thread the new inputs: compute the fidelity report + from the built `ExportReport` + deck + verification, and set + `result.fidelity`. +- Keep `exportPptx` returning the blob and now also expose the report/fidelity + so the UI can consume it. + +### 3. Rework `export.test.ts` to fixture the new pipeline + +Replace the block-report fixtures with explicit fidelity ones. Add a small +`vi.mock` note: the pptxgenjs mock stays, but the *fixture blob* must match the +archive contract (slide count, notes, text). Add fixtures: + +```ts +const NATIVE_ISSUES: ExportIssue[] = []; +const BLOCK_A = { blockId: "a", status: "native" as const, issues: NATIVE_ISSUES, representation: "native" as const }; +``` + +Add tests: + +```ts +it("returns a fidelity report with contentRecall 1 for an all-native deck", async () => { + const { result } = await runExport(FIXTURE_ALL_NATIVE); + expect(result.fidelity?.contentRecall).toBe(1); + expect(result.fidelity?.status).toBe("complete"); +}); + +it("flags a complete-with-fallbacks status when a chart becomes svg", async () => { + const { result } = await runExport(FIXTURE_WITH_SVG); + expect(result.fidelity?.status).toBe("complete-with-fallbacks"); +}); + +it("fails the deck when a block export errors", async () => { + const { result } = await runExport(FIXTURE_WITH_ERROR_BLOCK); + expect(result.report.status).toBe("failed"); + expect(result.fidelity?.status).toBe("failed"); +}); + +it("sets archiveVerified from the real verifier", async () => { + const { result } = await runExport(FIXTURE_ALL_NATIVE); + expect(result.archiveVerified).toBe(true); +}); +``` + +Adapt the existing `describe("deriveExportStatus")` block: its old 2-arg calls +become 4-arg calls, e.g. `deriveExportStatus(issues, slides, parity)`. + +Run: `npm.cmd run typecheck`, then `npm.cmd run test -- src/export/export.test.ts`. + +--- + +# Task 8 — SVG-native chart, diagram, and video export + process-text fix + +Converts charts and diagrams to inline SVG (data URI images), adds a video +chapter fallback exporter, and fixes the process block's duplicate-text bug. +This is where the seed deck's fidelity actually improves. + +## Steps + +### 1. `pptx/block-exporters/chart.ts` → emit SVG + +Change the chart exporter to produce an SVG image element: + +- Build the SVG string from the chart's data: `series`, `categories`, + `title`, `type` (bar/line/area), and color scale from + `ctx.deck.theme.colors`. +- Return `{ status: "rasterized", issues: [], element: { type: "svg", x, y, w, h, data: { svg, alt } } }` + where `alt` is derived from the chart title or a default like + `"${type} chart: ${categories.join(', ')}"`. +- SVG content: title text, axis labels, bars/lines/points. Use a fixed + viewBox of `0 0 ${w} ${h}` and `shape-rendering="crispEdges"`. +- Colors from theme tokens: `axis` (labels), `accent`/`primary` (bars), + `grid` (ticks). Keep the SVG free of external refs (`xlink:href`), scripts, + or `foreignObject` (security: no active content in exported files). + +### 2. `pptx/block-exporters/diagram.ts` → emit SVG + +Same pattern: build an SVG from `block.content.diagram` (`steps`, `kind` += `flow`/`matrix`), return `{ status: "rasterized", element: { type: "svg", ... data: { svg, alt } } }` +with `alt` = the diagram title or first steps joined by ` -> `. + +### 3. `pptx/block-exporters/video.ts` → chapter fallback + +The current video exporter returns an empty element (it is effectively a no-op +that produces no slide output and no issue). Change it so a video with a +`chapter` produces a **poster + summary** fallback: + +```ts +export const videoExporter: PptxBlockExporter = { + type: "video", + exportability: "image-only", + async export(block: Block, ctx: PptxExportContext): Promise { + const content = block.content as { url?: string; poster?: string; chapter?: { title?: string; summary?: string; keyPoints?: string[] } }; + if (content?.chapter?.title || content?.chapter?.summary) { + return { + status: "rasterized", + issues: [{ code: "fallback-rasterized", severity: "info", message: "Video exported as chapter summary card", automaticFixAvailable: false }], + element: { + type: "text", + x: block.layout?.x ?? 0, + y: block.layout?.y ?? 0, + w: block.layout?.w ?? 640, + h: block.layout?.h ?? 360, + data: { + text: [content.chapter.title, content.chapter.summary, ...(content.chapter.keyPoints ?? [])].filter(Boolean).join("\n"), + options: { breakLine: true, fit: "shrink" }, + }, + }, + }; + } + return { + status: "rasterized", + issues: [{ code: "external-asset", severity: "info", message: "Video requires an interactive runtime; exported as placeholder", automaticFixAvailable: false }], + element: { + type: "text", + x: block.layout?.x ?? 0, + y: block.layout?.y ?? 0, + w: block.layout?.w ?? 640, + h: block.layout?.h ?? 360, + data: { text: "[Video placeholder]", options: { align: "center" } }, + }, + }; + }, +}; +``` + +Register `videoExporter` in the index (Task 9). + +### 4. Update exporter tests + +- **`pptx/block-exporters/chart.test.ts`** — rewrite the current assertions + that expected `addImage` with `fallback` data; now expect an SVG element: + +```ts +expect(result.element?.type).toBe("svg"); +const svg = (result.element?.data as { svg: string }).svg; +expect(svg).toContain(" { + const block = { + id: "vid-1", + type: "video", + layout: { x: 0, y: 0, w: 640, h: 360 }, + source: { url: "https://example.com/video.mp4" }, + chapter: { title: "Core loop", summary: "The core loop is the heart of the game.", keyPoints: ["Clear goal", "Escalating stakes"] }, + } as unknown as Block; + const result = await videoExporter.export(block, ctxFor({})); + expect(result.element?.type).toBe("text"); + const text = (result.element?.data as { text: string }).text; + expect(text).toContain("Core loop"); + expect(text).toContain("Clear goal"); +}); + +it("exports a video without a chapter as a placeholder", async () => { + const block = { id: "vid-1", type: "video", layout: { x: 0, y: 0, w: 640, h: 360 }, source: { url: "https://example.com/video.mp4" } } as unknown as Block; + const result = await videoExporter.export(block, ctxFor({})); + expect(result.element?.type).toBe("text"); + expect((result.element?.data as { text: string }).text).toContain("Video placeholder"); +}); +``` + +(`ctxFor` is the existing test helper; adjust to the test's actual helper name.) + +- **`pptx/block-exporters/process.test.ts`** — the current test + `"uses consistent text for process step labels"` encoded the *bug* (labels + duplicated in `data.text`). Fix the exporter first (below), then update the + test to assert the corrected single-step output. + +### 5. Fix the process exporter's duplicate-text bug + +Current `process.ts` sets `data.text` to a list containing each step's +`title` and `description` twice. Fix so each step contributes its `title` +once and its `description` once: + +```ts +data: { + text: steps.flatMap((s) => [s.title, s.description]).filter(Boolean), + options: { list: { type: "bullet" } }, +} +``` + +### 6. Update the process test to lock the fix + +```ts +it("uses consistent text for process step labels", async () => { + const block = { id: "p1", type: "process", layout: { x: 0, y: 0, w: 600, h: 300 }, content: { text: "Process", steps: [{ title: "Plan", description: "Scope the work" }, { title: "Ship", description: "Publish and verify" }] } } as unknown as Block; + const result = await processExporter.export(block, ctxFor({})); + const text = (result.element?.data as { text: string[] }).text; + expect(text).toEqual(["Plan", "Scope the work", "Ship", "Publish and verify"]); +}); +``` + +Run: `npm.cmd run typecheck`, then `npm.cmd run test -- src/export/pptx/block-exporters`, then `npm.cmd run test -- src/export`. + +--- + +# Task 9 — Preflight estimates + dialog fidelity summary + +`export-preflight.ts` gains fidelity estimates; `export-dialog.tsx` shows the +fidelity summary card. + +## Steps + +### 1. Extend `export-preflight.ts` + +Current `runExportPreflight` returns `{ issues, score, blockCoverage }`. +Add the estimate fields (from `ExportPreflightResult`): + +```ts +export function estimateFidelity(deck: DeckProject): { + estimatedFallbacks: number; + missingBlockCount: number; + unsupportedBlockCount: number; + chartBlockCount: number; +} { + const missing: number[] = []; + const fallbacks: number[] = []; + let chartCount = 0; + for (const slide of deck.slides) { + for (const block of slide.blocks) { + if (block.hidden) continue; + const exporter = getBlockExporter(block.type); + if (exporter.type === "fallback" && block.type !== "fallback") { + missing.push(block.id); + } else if (exporter.exportability === "image-only") { + fallbacks.push(block.id); + } + if (block.type === "chart" || block.type === "diagram") chartCount += 1; + } + } + return { + estimatedFallbacks: fallbacks.length, + missingBlockCount: missing.length, + unsupportedBlockCount: missing.length, + chartBlockCount: chartCount, + }; +} +``` + +Import `getBlockExporter` from `./pptx/block-exporters/index` (it already +exports `getBlockExporter`). Wire the estimate into `runExportPreflight`'s +return (spread the estimate object). Add `export-preflight.test.ts`: + +```ts +it("estimates fallbacks for chart and video blocks", () => { + const deck = { slides: [{ blocks: [{ id: "c1", type: "chart", hidden: false }, { id: "v1", type: "video", hidden: false }, { id: "t1", type: "text", hidden: false }] }] } as unknown as DeckProject; + const est = estimateFidelity(deck); + expect(est.estimatedFallbacks).toBe(2); + expect(est.chartBlockCount).toBe(1); + expect(est.missingBlockCount).toBe(0); +}); + +it("reports unsupported block types as missing", () => { + const deck = { slides: [{ blocks: [{ id: "m1", type: "mystery-block", hidden: false }] }] } as unknown as DeckProject; + const est = estimateFidelity(deck); + expect(est.missingBlockCount).toBe(1); +}); +``` + +### 2. Update `export-dialog.tsx` summary + +Current `reportSummary(report)` prints status + file path. Replace with a +fidelity summary: + +```tsx +function fidelitySummary(fidelity: FidelityReport | undefined): string { + if (!fidelity) return "Fidelity summary unavailable until the first export completes."; + const pct = Math.round(fidelity.contentRecall * 100); + const fallbacks = + fidelity.blocks.filter((b) => b.representation === "svg" || b.representation === "raster" || b.representation === "expanded-build").length; + return `Fidelity: ${pct}% content recall · ${fidelity.missingVisibleBlocks} block(s) missing · ${fallbacks} fallback(s) · ${fidelity.status}`; +} +``` + +- Import `FidelityReport` from `./export-types` (schema now lives there). +- Use `fidelitySummary(lastExport?.fidelity)` in place of the old status text. +- Keep the `?` shortcut hint visible near the card. +- When a user cancels or a fresh deck is opened, clear `lastExport.fidelity`. + +Run: `npm.cmd run typecheck`, then `npm.cmd run test -- src/export/export-dialog.test.tsx` (if present) or `npm.cmd run test -- src/export`, then `npm.cmd run build`. + +--- + +# Task 10 — Fidelity showcase deck (`examples/03-example-fidelity`) + +A minimal but real deck demonstrating the degradation ladder. It must be +exportable and satisfy the output contract. + +## Steps + +1. Copy the minimal structure from `examples/02-example`: + `decks/*.json`, `src/app/deck.json`, `src/app/theme.json`, `src/app/*` entry + (as small as possible — reuse the 02 components by reference where the + harness allows, otherwise keep a thin copy), `vite.config.ts`, + `package.json`, `tsconfig.json`. +2. Seed deck (`03-example-fidelity/decks/fidelity-showcase.json`) with four + sections, each block tagged with `id` + `notes` describing its export fate: + - A `chart` block (bar) → exports as SVG. + - A `diagram` block (flow) → exports as SVG. + - A `video` block with `chapter` → exports as summary card (fallback). + - A `process` block → now exports correctly (no duplicate text). + - A deliberate `text` block with an `error`-severity export issue is NOT + needed — keep the deck clean; the degradation is shown via representation, + not errors. +3. Ensure `src/export/export.ts` accepts a deck path/env so the showcase deck + can be exported with `npm.cmd run export` (or reuse the 02 build target if + the harness does not support a second app; in that case, document it as an + "export demo deck" and verify via the same export dialog pointed at the + showcase deck file). +4. Run the layout audit and output-contract audit on the new deck JSON. +5. Add a root `README.md` blurb under "Examples" linking the showcase and + explaining the degradation ladder (fidelity-first intent). + +Run (repo root): + +```bash +python scripts/audits/audit_deck_layout.py examples/03-example-fidelity/decks/fidelity-showcase.json --strict +python scripts/audits/validate_output_contract.py examples/03-example-fidelity --profile editable-deck +``` + +--- + +# Task 11 — Doc + skill sync + +The fidelity vocabulary must be documented and the export skill updated so +future generated apps mirror the new contract. + +## Steps + +1. `skills/deckforge-export/SKILL.md` — add a "Fidelity-first exports" section + describing: `ExportStatus` four-value ladder, representation vocabulary, + SVG image export for charts/diagrams, archive verification, and the + one-way type dependency rule. +2. `skills/deckforge-export/workflows/add-pptx-export.md` — the generated + artifact list must mention `fidelity/fidelity-report.ts`, + `fidelity/representation-planner.ts`, `fidelity/content-parity.ts`, + `fidelity/fidelity-policy.ts`, `pptx/pptx-verifier.ts`. +3. `docs/ARCHITECTURE.md` — add the fidelity pipeline to the export + architecture notes (diagram in words: deck → block exporters → fidelity + blocks → parity → status; blob → verifier → archiveVerified). +4. `CHANGELOG.md` — add an entry under Unreleased for the fidelity-first + export upgrade. +5. Run the repo-level validation to ensure skill docs stay schema-consistent: + `npm.cmd run validate` from the root. + +--- + +# Task 12 — Packaging + full validation + +## Steps + +1. Repo root: `npm.cmd run package-skills` (packages the skills zips). If the + packaging test fails because the starter-components type change drifted from + the example, re-run the sync (Task 1 step 7) and package again. +2. Full suite: + +```bash +# repo root +npm.cmd run validate +npm.cmd run package-skills +# examples/02-example +npm.cmd run typecheck +npm.cmd run test +npm.cmd run build +``` + +3. Manual browser smoke (visual evidence): run the 02-example dev server, + open the export dialog, export the seed deck, confirm the fidelity summary + shows 100% recall with zero missing blocks, and open the produced `.pptx` + in PowerPoint/Keynote/LibreOffice to confirm charts render as images. + +--- + +# Exit criteria + +- [ ] `npm.cmd run typecheck` passes in `examples/02-example`. +- [ ] `npm.cmd run test -- src/export` passes (existing + new tests). +- [ ] Seed deck export reports `complete` or `complete-with-fallbacks`, with + no error-severity issues and zero missing visible blocks. +- [ ] `result.fidelity.contentRecall` is 1 for the seed deck; charts/diagrams + are `representation: "svg"` and alt-carrying. +- [ ] `verifyPptxArchive` passes on real exports (slide count + notes + text + + rels fingerprint) and fails when a slide is dropped. +- [ ] Export dialog shows the fidelity summary card and keeps the `?` hint. +- [ ] `examples/03-example-fidelity` exports cleanly and passes the layout + + output-contract audits. +- [ ] Repo root `npm.cmd run validate` and `npm.cmd run package-skills` pass. + +## Known open risks (accepted) + +- `visualParity` is a heuristic (0.9 for svg, 0.8 for raster); it feeds the + summary card only, never the status decision. +- `contentRecall` weights text meaning-bearing tokens; a chart with rich data + but a terse alt will understate recall. The alt is derived from chart + title/categories, which we control. +- PowerPoint cannot edit SVG images; this is surfaced via the + "image-only" / `svg` representation and the summary card, which is the + documented fidelity-first trade-off. diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/deck-types.ts b/examples/02-example/.agents/skills/deckforge/starter-components/deck-types.ts index a97ac8d..3edbdcb 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/deck-types.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/deck-types.ts @@ -41,6 +41,8 @@ export type DeckBlock = { export type LayoutBinding = { slot: string; blockIds: BlockId[]; flow?: 'stack' | 'row' | 'grid' | 'overlay'; gap?: number }; +export type Block = DeckBlock; + export type DeckInteraction = { id: InteractionId; type: string; @@ -149,12 +151,19 @@ export type { PptxExportability, ExportIssue, ExportPreflightResult, + ExportReport, + ExportStatus, + PptxExportMode, PptxExportConfig, FontWarning, PptxSlideElement, PptxExportContext, PptxBlockExporter, ExportDialogProps, + BlockRepresentation, + PptxVerificationCheck, + PptxVerificationReport, + FidelityReport, } from './export/export-types'; export { DEFAULT_PPTX_CONFIG } from './export/export-types'; diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/export-dialog.tsx b/examples/02-example/.agents/skills/deckforge/starter-components/export/export-dialog.tsx index 528d8c8..3e7eb35 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/export/export-dialog.tsx +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/export-dialog.tsx @@ -1,24 +1,46 @@ -// starter-components/export/export-dialog.tsx - -import React, { useState, useEffect, useCallback, useRef } from "react"; -import type { ExportPreflightResult, ExportStatus, PptxExportConfig } from "./export-types"; +import { useState, useEffect, useCallback, useRef } from "react"; +import type { + ExportPreflightResult, + ExportReport, + PptxExportConfig, +} from "./export-types"; import { DEFAULT_PPTX_CONFIG } from "./export-types"; import { runExportPreflight } from "./export-preflight"; +import type { DeckProject } from "../deck-types"; interface ExportDialogProps { - deck: unknown; + deck: DeckProject; isOpen: boolean; onClose: () => void; onExport?: (result: Blob) => void; onError?: (error: Error) => void; } +function fidelitySummary(report: ExportReport): string { + const fallbacks = report.slides.reduce( + (total, slide) => + total + + slide.blocks.filter((b) => b.representation === "svg" || b.representation === "raster").length, + 0, + ); + const native = report.slides.reduce( + (total, slide) => total + slide.blocks.filter((b) => b.representation === "native").length, + 0, + ); + const missing = report.slides.reduce( + (total, slide) => + total + slide.blocks.filter((b) => !b.contentPreserved && b.status !== "skipped").length, + 0, + ); + return `Native ${native} · Fallbacks ${fallbacks} · Missing ${missing}`; +} + export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: ExportDialogProps) { const [config, setConfig] = useState(DEFAULT_PPTX_CONFIG); const [preflight, setPreflight] = useState(null); + const [lastReport, setLastReport] = useState(null); const [isExporting, setIsExporting] = useState(false); const [showDetails, setShowDetails] = useState(false); - const [lastStatus, setLastStatus] = useState(null); const dialogRef = useRef(null); const closeButtonRef = useRef(null); @@ -71,9 +93,18 @@ export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: Expor const { PptxExporter } = await import("./pptx/pptx-exporter"); const exporter = new PptxExporter(config); const result = await exporter.export(deck); - const blob = result.blob; - onExport?.(blob); - setLastStatus(result.report.status); + setLastReport(result.report); + + if (result.report.status === "failed") { + onError?.( + new Error( + "Export failed: content could not be fully preserved. Fix the missing content before downloading.", + ), + ); + return; + } + + onExport?.(result.blob); const deckData = deck as { meta?: { title?: string } }; const title = deckData.meta?.title ?? "deck"; @@ -82,7 +113,7 @@ export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: Expor .replace("{title}", title) .replace("{date}", date); - const url = URL.createObjectURL(blob); + const url = URL.createObjectURL(result.blob); const a = document.createElement("a"); a.href = url; a.download = filename; @@ -97,101 +128,131 @@ export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: Expor if (!isOpen) return null; + const scoreColor = + (preflight?.score ?? 0) >= 80 + ? "var(--theme-secondary, #10b981)" + : (preflight?.score ?? 0) >= 50 + ? "#f59e0b" + : "var(--ui-danger, #dc2626)"; + + const reportColor = + lastReport?.status === "complete" + ? "var(--theme-secondary, #10b981)" + : lastReport?.status === "partial" + ? "#f59e0b" + : "var(--ui-danger, #dc2626)"; + return (
{ if (e.target === e.currentTarget) onClose(); }} > -
-
-

Export Center

+
+
+

Export

-
- - -
+
+ - {preflight && ( -
= 80 ? "#f0fdf4" : preflight.score >= 50 ? "#fffbeb" : "#fef2f2", - marginBottom: "16px", - }} - > -
- {preflight.score >= 80 ? "Export ready" : preflight.score >= 50 ? "Export with warnings" : "Export issues detected"} -
-
- Fidelity score: {preflight.score}/100 | Coverage: {Math.round(preflight.blockCoverage * 100)}% + {preflight && ( +
+
+ + {(preflight.score ?? 0) >= 80 ? "Ready to export" : (preflight.score ?? 0) >= 50 ? "Export with warnings" : "Issues detected"} + + + {preflight.score}/100 + +
+
+ Coverage {Math.round(preflight.blockCoverage * 100)}% + Recall {Math.round((preflight.estimatedRecall ?? 1) * 100)}% + {preflight.estimatedFallbacks ?? 0} fallbacks + {(preflight.estimatedMissing ?? 0) > 0 ? `${preflight.estimatedMissing} missing` : "0 missing"} + {preflight.issues.filter(i => i.severity === "warning").length} warnings + {preflight.issues.filter(i => i.severity === "info").length} info +
-
- {preflight.issues.filter(i => i.severity === "warning").length} warnings, {preflight.issues.filter(i => i.severity === "info").length} info -
-
- )} + )} - {lastStatus && ( -
- Export {lastStatus} - {lastStatus === "partial" && — some blocks were substituted or skipped; review the report below.} - {lastStatus === "failed" && — the presentation could not be produced. Check the report below.} -
- )} + {lastReport && ( +
+
+ + Export {lastReport.status} + + + {lastReport.status} + +
+
+ {fidelitySummary(lastReport)} +
+ {lastReport.status === "failed" && ( +
+ Export blocked: content was not fully preserved. Review the missing blocks below. +
+ )} + {lastReport.issues.length > 0 && ( +
+ {lastReport.issues.slice(0, 8).map((issue, idx) => ( +
+ + {issue.severity} + {" "} + {issue.message} +
+ ))} + {lastReport.issues.length > 8 && ( +
… {lastReport.issues.length - 8} more issue(s)
+ )} +
+ )} +
+ )} -
-
-
- - - +
+ + + +
{showDetails && preflight && ( -
-

Preflight Issues

+
+

+ Preflight Issues +

{preflight.issues.length === 0 ? ( -

No issues found

+

No issues found

) : (
    - {preflight.issues.map((issue, idx) => ( -
  • - [{issue.severity}] {issue.message} + {preflight.issues.map((issue: { severity: string; message: string; suggestedFix?: string }, idx: number) => ( +
  • + + {issue.severity} + {" "} + {issue.message} {issue.suggestedFix && ( -
    Fix: {issue.suggestedFix}
    +
    + {issue.suggestedFix} +
    )}
  • ))} @@ -273,4 +343,4 @@ export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: Expor
); -} \ No newline at end of file +} diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/export-preflight.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/export-preflight.ts index e0859bb..d4f88de 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/export/export-preflight.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/export-preflight.ts @@ -1,3 +1,5 @@ +// export/export-preflight.ts + import type { ExportPreflightResult, ExportIssue, @@ -5,6 +7,7 @@ import type { } from "./export-types"; import type { DeckProject } from "../deck-types"; import { collectFontWarnings } from "./pptx/pptx-fonts"; +import { getBlockExporter } from "./pptx/block-exporters/index"; const NATIVE_BLOCK_TYPES = new Set([ "text", @@ -19,6 +22,10 @@ const NATIVE_BLOCK_TYPES = new Set([ "chart", ]); +function asRecord(value: unknown): Record { + return value as Record; +} + function calculateScore(issues: ExportIssue[]): number { let score = 100; for (const issue of issues) { @@ -30,13 +37,44 @@ function calculateScore(issues: ExportIssue[]): number { } function calculateBlockCoverage(deck: DeckProject): number { - const blocks = (deck.slides ?? []).flatMap((s) => s.blocks ?? []); + const blocks = deck.slides.flatMap((slide) => slide.blocks); if (blocks.length === 0) return 1; const nativeCount = blocks.filter((block) => NATIVE_BLOCK_TYPES.has(block.type)).length; return nativeCount / blocks.length; } +function calculateParityEstimates(deck: DeckProject): { + estimatedRecall: number; + estimatedFallbacks: number; + estimatedMissing: number; +} { + const visible = deck.slides + .filter((slide) => !slide.hidden) + .flatMap((slide) => slide.blocks) + .filter((block) => !block.hidden); + if (visible.length === 0) { + return { estimatedRecall: 1, estimatedFallbacks: 0, estimatedMissing: 0 }; + } + + let fallbacks = 0; + let missing = 0; + for (const block of visible) { + const exporter = getBlockExporter(block.type); + if (exporter.type === "fallback" && block.type !== "fallback") { + missing += 1; + } else if (exporter.exportability === "image-only") { + fallbacks += 1; + } + } + const preserved = visible.length - missing; + return { + estimatedRecall: preserved / visible.length, + estimatedFallbacks: fallbacks, + estimatedMissing: missing, + }; +} + export async function runExportPreflight( deck: DeckProject, config: PptxExportConfig @@ -56,10 +94,10 @@ export async function runExportPreflight( }); } - for (const slide of deck.slides ?? []) { - for (const block of slide.blocks ?? []) { + for (const slide of deck.slides) { + for (const block of slide.blocks) { + const record = asRecord(block); const blockType = block.type; - const record = block as unknown as Record; if (!NATIVE_BLOCK_TYPES.has(blockType)) { issues.push({ @@ -112,6 +150,20 @@ export async function runExportPreflight( const score = calculateScore(issues); const blockCoverage = calculateBlockCoverage(deck); + const estimates = calculateParityEstimates(deck); - return { issues, score, blockCoverage }; -} \ No newline at end of file + const visible = deck.slides + .filter((slide) => !slide.hidden) + .flatMap((slide) => slide.blocks) + .filter((block) => !block.hidden); + + return { + issues, + score, + blockCoverage, + ...estimates, + missingBlockCount: estimates.estimatedMissing, + unsupportedBlockCount: estimates.estimatedMissing, + chartBlockCount: visible.filter((block) => block.type === "chart").length, + }; +} diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/export-types.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/export-types.ts index 5dfad0d..96f1f89 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/export/export-types.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/export-types.ts @@ -1,4 +1,5 @@ -import type { DeckProject } from "../deck-types"; +import type { Block, DeckProject } from "../deck-types"; +import type { AssetEmbedResult } from "./pptx/pptx-assets"; export type ExportIssueSeverity = "info" | "warning" | "error"; @@ -36,6 +37,10 @@ export interface ExportBlockReport { blockId: string; status: BlockExportStatus; issues: ExportIssue[]; + representation?: BlockRepresentation; + contentPreserved?: boolean; + editable?: boolean; + visualParity?: number; } export interface ExportSlideReport { @@ -43,7 +48,32 @@ export interface ExportSlideReport { blocks: ExportBlockReport[]; } -export type ExportStatus = "complete" | "partial" | "failed"; +export type ExportStatus = "complete" | "complete-with-fallbacks" | "partial" | "failed"; + +export type PptxExportMode = "fidelity-first" | "editability-first"; + +export type BlockRepresentation = "native" | "svg" | "raster" | "expanded-build" | "unsupported"; + +export type FidelityStatus = ExportStatus; + +export interface PptxVerificationCheck { + name: string; + passed: boolean; + detail?: string; +} + +export interface PptxVerificationReport { + checks: PptxVerificationCheck[]; + passed: boolean; +} + +export interface FidelityReport { + status: FidelityStatus; + contentRecall: number; + missingVisibleBlocks: number; + blocks: ExportBlockReport[]; + verification?: PptxVerificationReport; +} export interface ExportReport { status: ExportStatus; @@ -56,6 +86,7 @@ export interface PptxExportResult { report: ExportReport; blob: Blob; archiveVerified: boolean; + fidelity?: FidelityReport; } export type PptxExportability = @@ -70,10 +101,16 @@ export interface ExportPreflightResult { issues: ExportIssue[]; score: number; blockCoverage: number; + estimatedFallbacks: number; + estimatedRecall: number; + estimatedMissing: number; + missingBlockCount: number; + unsupportedBlockCount: number; + chartBlockCount: number; } export interface PptxExportConfig { - mode: "hybrid"; + mode: PptxExportMode; includeSpeakerNotes: boolean; includeHiddenSlides: boolean; compatibilityTargets: string[]; @@ -142,19 +179,38 @@ interface PptxFallbackElement { data: { text: string; options?: Record }; } +interface PptxSvgElement { + type: "svg"; + x: number; + y: number; + w: number; + h: number; + data: { svg: string; alt?: string; options?: Record }; +} + +export type PptxSlideElementType = + | "text" + | "image" + | "shape" + | "table" + | "chart" + | "fallback" + | "svg"; + export type PptxSlideElement = | PptxTextElement | PptxImageElement | PptxShapeElement | PptxTableElement | PptxChartElement - | PptxFallbackElement; + | PptxFallbackElement + | PptxSvgElement; export interface PptxExportContext { deck: DeckProject; config: PptxExportConfig; fontWarnings: FontWarning[]; - assetCache: Map; + assetCache: Map; slideWidth: number; slideHeight: number; } @@ -180,10 +236,12 @@ export interface ExportDialogProps { } export const DEFAULT_PPTX_CONFIG: PptxExportConfig = { - mode: "hybrid", + mode: "fidelity-first", includeSpeakerNotes: true, includeHiddenSlides: false, compatibilityTargets: ["powerpoint", "keynote", "libreoffice"], fontPolicy: "warn-and-substitute", filenameTemplate: "{title}-{date}.pptx", -}; \ No newline at end of file +}; + +export type { Block }; diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/content-parity.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/content-parity.ts new file mode 100644 index 0000000..2d9e4f6 --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/content-parity.ts @@ -0,0 +1,62 @@ +import type { Block, DeckProject } from "../../deck-types"; +import type { FidelityBlockReport, PptxFidelityPolicy } from "./fidelity-types"; +import { FIDELITY_POLICY } from "./fidelity-policy"; + +const VISIBLE_TEXT = /[A-Za-z0-9]{2,}/g; + +type ContentRecord = Record; + +function asRecord(value: unknown): ContentRecord { + return (value ?? {}) as ContentRecord; +} + +export function rawText(block: Block): string { + if (typeof block.content === "string") return block.content; + return String(asRecord(block.content).text ?? ""); +} + +function meaningfulText(block: Block): number { + return (rawText(block).match(VISIBLE_TEXT) ?? []).length; +} + +/** + * Compute text-recall content parity: the ratio of meaningful text tokens + * present in the export to the total expected across all visible blocks. + * + * This is a TEXT-based metric — it measures how much human-readable text + * survives into the PPTX output. For visual blocks (charts, diagrams, + * images) where the exported representation is SVG or raster, the metric + * falls back to the block's alt text or title. A score of 1.0 means all + * expected text is present; 0.0 means no text was exported. + * + * The metric intentionally does NOT measure visual fidelity (pixel-level + * accuracy) or structural fidelity (layout positions). Those are assessed + * separately by the OOXML structural verifier. + */ +export function calculateContentParity( + deck: DeckProject, + blocks: FidelityBlockReport[], + policy: PptxFidelityPolicy = FIDELITY_POLICY, +): number { + const visible = deck.slides.flatMap((slide) => slide.blocks.filter((block) => !block.hidden)); + if (visible.length === 0) return 1; + + const byId = new Map(blocks.map((b) => [b.blockId, b])); + const expected = visible.map((block) => meaningfulText(block)); + const present = visible.map((block) => { + const rep = byId.get(block.id); + if (!rep || rep.representation === "unsupported") return 0; + if (rep.representation === "native" || rep.representation === "expanded-build") return meaningfulText(block); + if (rep.representation === "raster") return Math.round(meaningfulText(block) * 0.8); + if (rep.representation === "svg") { + const content = asRecord(block.content); + const alt = String(content.alt ?? asRecord(content.chart).title ?? content.text ?? ""); + return Math.max(1, (alt.match(VISIBLE_TEXT) ?? []).length); + } + return 0; + }); + + const expectedTotal = expected.reduce((a, b) => a + b, 0); + const presentTotal = present.reduce((a, b) => a + b, 0); + return expectedTotal === 0 ? 1 : Math.min(1, presentTotal / expectedTotal); +} diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/fidelity-policy.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/fidelity-policy.ts new file mode 100644 index 0000000..da7139e --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/fidelity-policy.ts @@ -0,0 +1,14 @@ +import type { PptxFidelityPolicy } from "./fidelity-types"; + +export const FIDELITY_POLICY: PptxFidelityPolicy = { + version: "1.0.0", + defaultMode: "fidelity-first", + priorities: ["content", "visual", "geometry", "editability", "file-size"], + hardRules: { + meaningfulContentRecall: 0.9, + maxMissingVisibleBlocks: 0, + silentOmissionAllowed: false, + diagramSummaryFallbackAllowed: true, + }, + representations: ["native", "svg", "raster", "expanded-build"], +}; diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/fidelity-report.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/fidelity-report.ts new file mode 100644 index 0000000..79096d7 --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/fidelity-report.ts @@ -0,0 +1,53 @@ +import type { DeckProject } from "../../deck-types"; +import type { FidelityReport } from "../export-types"; +import { calculateContentParity } from "./content-parity"; +import { FIDELITY_POLICY } from "./fidelity-policy"; +import type { FidelityBlockReport, PptxFidelityPolicy } from "./fidelity-types"; + +export interface BuildFidelityReportInput { + deck: DeckProject; + blocks: FidelityBlockReport[]; + policy?: PptxFidelityPolicy; +} + +export function countRepresentation( + blocks: FidelityBlockReport[], + rep: FidelityBlockReport["representation"], +): number { + return blocks.filter((b) => b.representation === rep).length; +} + +export function fidelityStatus( + parity: number, + blocks: FidelityBlockReport[], + policy: PptxFidelityPolicy, +): FidelityReport["status"] { + const hardRules = policy.hardRules; + const hasError = blocks.some((b) => b.issues.some((i) => i.severity === "error")); + if (hasError) return "failed"; + if (parity < hardRules.meaningfulContentRecall) return "failed"; + const omittedVisible = blocks.filter( + (b) => b.status !== "skipped" && b.representation === "unsupported", + ); + if (omittedVisible.length > 0) return "failed"; + const fallbackCount = + countRepresentation(blocks, "raster") + + countRepresentation(blocks, "svg") + + countRepresentation(blocks, "expanded-build"); + if (fallbackCount > 0) return "complete-with-fallbacks"; + return "complete"; +} + +export function buildFidelityReport(input: BuildFidelityReportInput): FidelityReport { + const { deck, blocks, policy = FIDELITY_POLICY } = input; + const parity = calculateContentParity(deck, blocks, policy); + const missingVisibleBlocks = blocks.filter( + (b) => b.status !== "skipped" && b.representation === "unsupported", + ).length; + return { + status: fidelityStatus(parity, blocks, policy), + contentRecall: parity, + missingVisibleBlocks, + blocks, + }; +} diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/fidelity-types.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/fidelity-types.ts new file mode 100644 index 0000000..422be2d --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/fidelity-types.ts @@ -0,0 +1,29 @@ +import type { + BlockRepresentation, + ExportBlockReport, + ExportStatus, + FidelityReport, + PptxExportMode, + PptxVerificationReport, +} from "../export-types"; + +export type { BlockRepresentation, FidelityReport, PptxExportMode, PptxVerificationReport }; + +export type FidelityStatus = ExportStatus; + +export type FidelityBlockReport = ExportBlockReport; + +export interface FidelityHardRules { + meaningfulContentRecall: number; + maxMissingVisibleBlocks: number; + silentOmissionAllowed: boolean; + diagramSummaryFallbackAllowed: boolean; +} + +export interface PptxFidelityPolicy { + version: string; + defaultMode: PptxExportMode; + priorities: Array<"content" | "visual" | "geometry" | "editability" | "file-size">; + hardRules: FidelityHardRules; + representations: Array<"native" | "svg" | "raster" | "expanded-build">; +} diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/representation-planner.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/representation-planner.ts new file mode 100644 index 0000000..10a3a82 --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/representation-planner.ts @@ -0,0 +1,62 @@ +import type { BlockExportStatus, ExportIssue, PptxSlideElement } from "../export-types"; +import type { BlockRepresentation, FidelityBlockReport, PptxFidelityPolicy } from "./fidelity-types"; +import { FIDELITY_POLICY } from "./fidelity-policy"; + +export interface PlannerInput { + blockId: string; + hidden: boolean; + status: BlockExportStatus; + element?: PptxSlideElement; + issues: ExportIssue[]; +} + +function repr( + blockId: string, + status: BlockExportStatus, + issues: ExportIssue[], + representation: BlockRepresentation, + extras: Partial> = {}, +): FidelityBlockReport { + return { + blockId, + status, + issues, + representation, + contentPreserved: representation !== "unsupported", + editable: representation === "native", + visualParity: representation === "native" ? 1 : representation === "svg" ? 0.9 : 0.8, + ...extras, + }; +} + +export function planBlockRepresentation( + input: PlannerInput, + policy: PptxFidelityPolicy = FIDELITY_POLICY, +): FidelityBlockReport { + const { blockId, hidden, status, element, issues } = input; + if (hidden) { + return repr(blockId, "skipped", issues, "unsupported", { contentPreserved: false, editable: false, visualParity: 0 }); + } + const hasError = issues.some((issue) => issue.severity === "error"); + if (hasError || status === "unsupported") { + return repr(blockId, "unsupported", issues, "unsupported", { contentPreserved: false, editable: false, visualParity: 0 }); + } + const r: BlockRepresentation = + element?.type === "svg" + ? "svg" + : status === "native" + ? "native" + : status === "rasterized" + ? "raster" + : status === "substituted" + ? "expanded-build" + : "unsupported"; + return repr(blockId, status, issues, r); +} + +export function countRepresentation( + blocks: FidelityBlockReport[], + rep: BlockRepresentation, +): number { + return blocks.filter((b) => b.representation === rep).length; +} diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/svg/svg-diagram.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/svg/svg-diagram.ts new file mode 100644 index 0000000..ed187ea --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/svg/svg-diagram.ts @@ -0,0 +1,156 @@ +export interface DiagramNodeInput { + id?: string; + label: string; +} + +export interface DiagramEdgeInput { + from: string; + to: string; +} + +export interface DiagramInput { + nodes: Array; + edges?: Array; +} + +export interface DiagramSvgOptions { + width: number; + height: number; + colors?: { + background?: string; + nodeFill?: string; + nodeStroke?: string; + labelColor?: string; + edgeColor?: string; + }; +} + +const PAD = 16; +const NODE_W = 140; +const NODE_H = 48; + +export function normalizeDiagram(input: DiagramInput): { + nodes: DiagramNodeInput[]; + edges: DiagramEdgeInput[]; +} { + const nodes: DiagramNodeInput[] = (input.nodes ?? []).map((n) => + typeof n === "string" ? { id: n, label: n } : { id: n.id ?? n.label, label: n.label } + ); + const edges: DiagramEdgeInput[] = (input.edges ?? []).map((e) => + typeof e === "string" + ? (() => { + const [from, to] = e.split("->"); + return { from: (from ?? "").trim(), to: (to ?? "").trim() }; + })() + : { from: e.from, to: e.to } + ); + return { nodes, edges }; +} + +function escapeXml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function computeLayers( + nodes: DiagramNodeInput[], + edges: DiagramEdgeInput[] +): string[][] { + const ids = new Set(nodes.map((n) => n.id ?? n.label)); + const incoming = new Map>(nodes.map((n) => [n.id!, new Set()])); + for (const edge of edges) { + if (ids.has(edge.from) && ids.has(edge.to) && edge.to !== edge.from) { + incoming.get(edge.to)!.add(edge.from); + } + } + const layers: string[][] = []; + const placed = new Set(); + const remaining = new Set(ids); + while (remaining.size > 0) { + const layer = [...remaining] + .filter((id) => [...incoming.get(id)!].every((p) => placed.has(p))) + .sort(); + if (layer.length === 0) { + layers.push([...remaining].sort()); + break; + } + layers.push(layer); + layer.forEach((id) => { + placed.add(id); + remaining.delete(id); + }); + } + return layers; +} + +export function renderDiagramSvg(input: DiagramInput, options: DiagramSvgOptions): string { + const { nodes, edges } = normalizeDiagram(input); + const { width, height } = options; + const colors = options.colors ?? {}; + const bg = colors.background ?? "#FFFFFF"; + const nodeFill = colors.nodeFill ?? "#EEF2FF"; + const nodeStroke = colors.nodeStroke ?? "#6366F1"; + const labelColor = colors.labelColor ?? "#111827"; + const edgeColor = colors.edgeColor ?? "#9CA3AF"; + + const layers = computeLayers(nodes, edges); + const nodeById = new Map(nodes.map((n) => [n.id, n])); + const layerCount = layers.length; + + const colX = (layerIdx: number): number => { + if (layerCount <= 1) return width / 2 - NODE_W / 2; + return PAD + (layerIdx * (width - 2 * PAD - NODE_W)) / (layerCount - 1); + }; + const rowY = (rows: number, rowIdx: number): number => { + if (rows <= 1) return height / 2 - NODE_H / 2; + return PAD + (rowIdx * (height - 2 * PAD - NODE_H)) / (rows - 1); + }; + + const positions = new Map(); + layers.forEach((layer, li) => { + layer.forEach((id, ri) => { + positions.set(id, { x: colX(li), y: rowY(layer.length, ri) }); + }); + }); + + const parts: string[] = []; + parts.push( + `` + ); + parts.push(``); + parts.push( + `` + ); + + for (const edge of edges) { + const from = positions.get(edge.from); + const to = positions.get(edge.to); + if (!from || !to) continue; + const x1 = from.x + NODE_W / 2; + const y1 = from.y + NODE_H / 2; + const x2 = to.x; + const y2 = to.y + NODE_H / 2; + parts.push( + `` + ); + } + + for (const node of nodes) { + const key = node.id ?? node.label; + const pos = positions.get(key); + if (!pos || !nodeById.has(key)) continue; + parts.push( + `` + ); + parts.push( + `${escapeXml(node.label)}` + ); + } + + parts.push(""); + return parts.join("\n"); +} diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/svg/svg-snapshot.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/svg/svg-snapshot.ts new file mode 100644 index 0000000..8fba606 --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/svg/svg-snapshot.ts @@ -0,0 +1,80 @@ +export interface SnapshotSvgOptions { + width: number; + height: number; + title?: string; + text?: string; + alt?: string; + colors?: { + background?: string; + border?: string; + titleColor?: string; + bodyColor?: string; + mutedColor?: string; + }; +} + +function escapeXml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function wrapText(text: string, maxChars: number, maxLines: number): string[] { + const words = text.split(/\s+/).filter(Boolean); + const lines: string[] = []; + let current = ""; + for (const word of words) { + const candidate = current ? `${current} ${word}` : word; + if (candidate.length > maxChars && current) { + lines.push(current); + current = word; + if (lines.length >= maxLines) break; + } else { + current = candidate; + } + } + if (current && lines.length < maxLines) lines.push(current); + return lines; +} + +export function renderSnapshotSvg(options: SnapshotSvgOptions): string { + const { width, height } = options; + const colors = options.colors ?? {}; + const bg = colors.background ?? "#FFFBF0"; + const border = colors.border ?? "#F59E0B"; + const bodyColor = colors.bodyColor ?? "#78350F"; + const mutedColor = colors.mutedColor ?? "#A16207"; + + const headerH = Math.max(18, Math.min(28, Math.floor(height * 0.18))); + const bodyTop = headerH + 8; + const maxChars = Math.max(8, Math.floor((width - 24) / 8)); + const maxLines = Math.max(1, Math.floor((height - bodyTop - 20) / 18)); + const lines = wrapText(options.text ?? "", maxChars, maxLines); + + const parts: string[] = []; + parts.push( + `` + ); + parts.push( + `` + ); + parts.push(``); + parts.push( + `${escapeXml(options.title ?? "Content")}` + ); + lines.forEach((line, i) => { + parts.push( + `${escapeXml(line)}` + ); + }); + if (options.alt) { + parts.push( + `${escapeXml(options.alt)}` + ); + } + parts.push(""); + return parts.join("\n"); +} diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/index.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/index.ts index cd01fbe..d4d4082 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/export/index.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/index.ts @@ -9,6 +9,7 @@ export type { ExportStatus, BlockExportStatus, PptxExportConfig, + PptxExportMode, PptxExportContext, PptxExportability, PptxSlideElement, @@ -17,11 +18,16 @@ export type { PptxExportResult, FontWarning, ExportDialogProps, + BlockRepresentation, + PptxVerificationCheck, + PptxVerificationReport, + FidelityReport, } from "./export-types"; export { DEFAULT_PPTX_CONFIG } from "./export-types"; -export { PptxExporter, buildExportReport, deriveExportStatus, verifyPptxArchive } from "./pptx/pptx-exporter"; +export { PptxExporter, buildExportReport, deriveExportStatus } from "./pptx/pptx-exporter"; +export { verifyPptxArchive } from "./pptx/pptx-verifier"; export { createExportContext } from "./pptx/pptx-context"; export { mapThemeColors, mapThemeFonts, applyThemeToPptx } from "./pptx/pptx-theme"; export { checkFontCompatibility, collectFontWarnings } from "./pptx/pptx-fonts"; @@ -40,7 +46,24 @@ export { shapeBlockExporter } from "./pptx/block-exporters/shape"; export { tableBlockExporter } from "./pptx/block-exporters/table"; export { chartBlockExporter } from "./pptx/block-exporters/chart"; export { diagramBlockExporter } from "./pptx/block-exporters/diagram"; +export { videoBlockExporter } from "./pptx/block-exporters/video"; export { fallbackBlockExporter } from "./pptx/block-exporters/fallback"; +export { FIDELITY_POLICY } from "./fidelity/fidelity-policy"; +export { calculateContentParity, rawText } from "./fidelity/content-parity"; +export { planBlockRepresentation, countRepresentation } from "./fidelity/representation-planner"; +export { buildFidelityReport, fidelityStatus } from "./fidelity/fidelity-report"; +export { renderDiagramSvg, normalizeDiagram } from "./fidelity/svg/svg-diagram"; +export { renderSnapshotSvg } from "./fidelity/svg/svg-snapshot"; + +export type { + FidelityStatus, + FidelityBlockReport, + FidelityHardRules, + PptxFidelityPolicy, +} from "./fidelity/fidelity-types"; +export type { PlannerInput } from "./fidelity/representation-planner"; +export type { BuildFidelityReportInput } from "./fidelity/fidelity-report"; + export { runExportPreflight } from "./export-preflight"; export { ExportDialog } from "./export-dialog"; \ No newline at end of file diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/diagram.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/diagram.ts index 71e7793..6bed345 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/diagram.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/diagram.ts @@ -3,12 +3,15 @@ import type { PptxBlockExporter, PptxExportContext, } from "../../export-types"; +import { renderDiagramSvg } from "../../fidelity/svg/svg-diagram"; +import { mapThemeColors } from "../pptx-theme"; interface DiagramBlock { id: string; type: "diagram"; - nodes?: Array<{ id: string; label: string }>; - edges?: Array<{ from: string; to: string }>; + nodes?: Array<{ id?: string; label: string } | string>; + edges?: Array<{ from: string; to: string } | string>; + content?: { nodes?: Array<{ id?: string; label: string } | string>; edges?: Array<{ from: string; to: string } | string> }; x?: number; y?: number; w?: number; @@ -22,36 +25,41 @@ export const diagramBlockExporter: PptxBlockExporter = { async export(block: unknown, ctx: PptxExportContext): Promise { const diagramBlock = block as DiagramBlock; + const content = diagramBlock.content; + const nodes = content?.nodes ?? diagramBlock.nodes ?? []; + const edges = content?.edges ?? diagramBlock.edges ?? []; + const x = diagramBlock.x ?? diagramBlock.frame?.x ?? 0; + const y = diagramBlock.y ?? diagramBlock.frame?.y ?? 0; + const w = diagramBlock.w ?? diagramBlock.frame?.w ?? ctx.slideWidth * 0.6; + const h = diagramBlock.h ?? diagramBlock.frame?.h ?? ctx.slideHeight * 0.4; - const nodeCount = diagramBlock.nodes?.length ?? 0; - const edgeCount = diagramBlock.edges?.length ?? 0; - const summary = `Diagram: ${nodeCount} nodes, ${edgeCount} edges`; + const theme = mapThemeColors(ctx.deck.theme); + const svg = renderDiagramSvg( + { nodes, edges }, + { + width: Math.max(1, Math.round(w)), + height: Math.max(1, Math.round(h)), + colors: { + background: theme.background, + nodeFill: theme.light1, + nodeStroke: theme.accent1, + labelColor: theme.text, + edgeColor: theme.dark2, + }, + } + ); return { - status: "substituted", - issues: [ - { - code: "unsupported-block", - severity: "warning", - message: "Diagram exported as a simplified text summary; nodes and edges are not preserved", - suggestedFix: "Rebuild the diagram as shapes and text blocks for native fidelity", - automaticFixAvailable: false, - }, - ], + status: "rasterized", + issues: [], element: { - type: "fallback", - x: diagramBlock.x ?? diagramBlock.frame?.x ?? 0, - y: diagramBlock.y ?? diagramBlock.frame?.y ?? 0, - w: diagramBlock.w ?? diagramBlock.frame?.w ?? ctx.slideWidth * 0.6, - h: diagramBlock.h ?? diagramBlock.frame?.h ?? ctx.slideHeight * 0.4, - data: { - text: summary, - options: { - fill: { color: "F0F0F0" }, - line: { color: "CCCCCC", width: 1 }, - }, - }, + type: "svg", + x, + y, + w, + h, + data: { svg, alt: (diagramBlock as { alt?: string }).alt }, }, }; }, -}; \ No newline at end of file +}; diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/fallback.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/fallback.ts index 08104b2..fdce238 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/fallback.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/fallback.ts @@ -3,26 +3,52 @@ import type { PptxBlockExporter, PptxExportContext, } from "../../export-types"; +import { renderSnapshotSvg } from "../../fidelity/svg/svg-snapshot"; export const fallbackBlockExporter: PptxBlockExporter = { type: "fallback", exportability: "image-only", - async export(block: unknown, _ctx: PptxExportContext): Promise { + async export(block: unknown, ctx: PptxExportContext): Promise { const anyBlock = block as Record; const blockType = (anyBlock.type as string) ?? "unknown"; + const frame = (anyBlock.frame as { x?: number; y?: number; w?: number; h?: number } | undefined) ?? {}; + const x = (anyBlock.x as number) ?? frame.x ?? 0; + const y = (anyBlock.y as number) ?? frame.y ?? 0; + const w = (anyBlock.w as number) ?? frame.w ?? ctx.slideWidth * 0.5; + const h = (anyBlock.h as number) ?? frame.h ?? ctx.slideHeight * 0.3; + + const content = anyBlock.content; + const text = typeof content === "string" ? content : content ? JSON.stringify(content) : ""; + const alt = (anyBlock.alt as string) ?? (anyBlock.ariaLabel as string) ?? ""; + + const svg = renderSnapshotSvg({ + width: Math.max(1, Math.round(w)), + height: Math.max(1, Math.round(h)), + title: blockType, + text, + alt, + }); return { - status: "unsupported", + status: "rasterized", issues: [ { - code: "unsupported-block", - severity: "warning", - message: `Block type "${blockType}" cannot be exported to PPTX and was omitted`, - suggestedFix: "Replace it with a supported block type (text, image, chart, shape, table, diagram)", + code: "no-fallback-produced", + severity: "info", + message: `Block type "${blockType}" has no native representation and was exported as a visual snapshot`, + suggestedFix: "Add a dedicated exporter for this block type for native editability", automaticFixAvailable: false, }, ], + element: { + type: "svg", + x, + y, + w, + h, + data: { svg, alt }, + }, }; }, -}; \ No newline at end of file +}; diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/index.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/index.ts index 8a9b605..f20ddc4 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/index.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/index.ts @@ -1,3 +1,5 @@ +// export/pptx/block-exporters/index.ts + import type { PptxBlockExporter } from "../../export-types"; import { textBlockExporter, @@ -13,6 +15,7 @@ import { shapeBlockExporter } from "./shape"; import { tableBlockExporter } from "./table"; import { chartBlockExporter } from "./chart"; import { diagramBlockExporter } from "./diagram"; +import { videoBlockExporter } from "./video"; import { fallbackBlockExporter } from "./fallback"; export const blockExporters: PptxBlockExporter[] = [ @@ -28,6 +31,7 @@ export const blockExporters: PptxBlockExporter[] = [ tableBlockExporter, chartBlockExporter, diagramBlockExporter, + videoBlockExporter, fallbackBlockExporter, ]; @@ -37,4 +41,4 @@ export function getBlockExporter(type: string): PptxBlockExporter { export function getExportability(type: string): string { return getBlockExporter(type).exportability; -} \ No newline at end of file +} diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/text.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/text.ts index 05eaa8b..0c79b82 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/text.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/text.ts @@ -182,20 +182,16 @@ async function exportProcessBlock(block: unknown, ctx: PptxExportContext): Promi }; const steps = processBlock.content?.steps ?? []; const text = steps - .map((step, index) => `${index + 1}. ${typeof step.title === "string" ? step.title : ""}`) + .map((step, index) => { + const title = typeof step.title === "string" ? step.title : ""; + const detail = typeof step.detail === "string" && step.detail ? ` \u2014 ${step.detail}` : ""; + return `${index + 1}. ${title}${detail}`; + }) .join("\n"); return { - status: "substituted", - issues: [ - { - code: "unsupported-block", - severity: "warning", - message: "Process diagram exported as a simplified numbered text list; step details are not preserved", - suggestedFix: "Split the process into individual text/heading blocks for full fidelity", - automaticFixAvailable: false, - }, - ], + status: "native", + issues: [], element: textElement(text, processBlock, ctx, { fontFace: "Arial", fontSize: 14, @@ -247,4 +243,4 @@ export const processBlockExporter: PptxBlockExporter = { type: "process", exportability: "image-only", export: exportProcessBlock, -}; \ No newline at end of file +}; diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/video.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/video.ts new file mode 100644 index 0000000..19e9a2e --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/video.ts @@ -0,0 +1,93 @@ +import type { + PptxBlockExport, + PptxBlockExporter, + PptxExportContext, +} from "../../export-types"; +import { renderSnapshotSvg } from "../../fidelity/svg/svg-snapshot"; +import { mapThemeColors } from "../pptx-theme"; + +interface VideoChapter { + title?: unknown; + summary?: unknown; + keyPoints?: Array; +} + +interface VideoContent { + url?: string; + poster?: string; + chapter?: VideoChapter; +} + +interface VideoBlock { + id: string; + type: "video"; + content?: VideoContent; + alt?: string; + ariaLabel?: string; + x?: number; + y?: number; + w?: number; + h?: number; + frame?: { x?: number; y?: number; w?: number; h?: number }; +} + +export const videoBlockExporter: PptxBlockExporter = { + type: "video", + exportability: "poster-with-link", + + async export(block: unknown, ctx: PptxExportContext): Promise { + const videoBlock = block as VideoBlock; + const content = videoBlock.content ?? {}; + const chapter = content.chapter ?? {}; + const title = + (typeof chapter.title === "string" ? chapter.title : "") || + content.url || + "Video"; + const summary = typeof chapter.summary === "string" ? chapter.summary : ""; + const keyPoints = (chapter.keyPoints ?? []) + .filter((point): point is string => typeof point === "string") + .map((point) => `\u2022 ${point}`) + .join("\n"); + const body = [summary, keyPoints].filter(Boolean).join("\n"); + const x = videoBlock.x ?? videoBlock.frame?.x ?? 0; + const y = videoBlock.y ?? videoBlock.frame?.y ?? 0; + const w = videoBlock.w ?? videoBlock.frame?.w ?? ctx.slideWidth * 0.5; + const h = videoBlock.h ?? videoBlock.frame?.h ?? ctx.slideHeight * 0.3; + + const theme = mapThemeColors(ctx.deck.theme); + const svg = renderSnapshotSvg({ + width: Math.max(1, Math.round(w)), + height: Math.max(1, Math.round(h)), + title, + text: body, + alt: videoBlock.alt ?? videoBlock.ariaLabel ?? "", + colors: { + background: theme.background, + border: theme.accent2, + bodyColor: theme.text, + }, + }); + + const alt = videoBlock.alt ?? videoBlock.ariaLabel ?? ""; + return { + status: "rasterized", + issues: [ + { + code: "no-fallback-produced", + severity: "info", + message: "Video content was exported as a static snapshot with its chapter summary", + suggestedFix: "Provide a poster image to improve the visual representation", + automaticFixAvailable: false, + }, + ], + element: { + type: "svg", + x, + y, + w, + h, + data: { svg, alt }, + }, + }; + }, +}; diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-exporter.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-exporter.ts index dadbeb8..383d66e 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-exporter.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-exporter.ts @@ -4,6 +4,7 @@ import type { ExportReport, ExportSlideReport, ExportStatus, + FidelityReport, PptxBlockExport, PptxExportConfig, PptxExportResult, @@ -12,6 +13,12 @@ import type { import type { DeckProject, DeckSlide } from "../../deck-types"; import { createExportContext } from "./pptx-context"; import { getBlockExporter } from "./block-exporters/index"; +import { verifyPptxArchive } from "./pptx-verifier"; +import { rawText } from "../fidelity/content-parity"; +import { FIDELITY_POLICY } from "../fidelity/fidelity-policy"; +import { planBlockRepresentation } from "../fidelity/representation-planner"; +import { buildFidelityReport, fidelityStatus } from "../fidelity/fidelity-report"; +import type { FidelityBlockReport } from "../fidelity/fidelity-types"; const PIXELS_PER_INCH = 96; @@ -75,54 +82,48 @@ function writeElementToSlide(pptxSlide: PptxAddCallable, element: PptxSlideEleme pptxSlide.addText?.(data.text, { ...opts, fill: { color: "FFF3CD" }, color: "856404", fontSize: 12 }); break; } + case "svg": { + const data = element.data as { svg: string; options?: Record }; + pptxSlide.addImage?.( + { data: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(data.svg)}` }, + { ...opts, ...data.options }, + ); + break; + } } } /** - * Derive the overall export status from the accumulated issues and per-block - * statuses. A report can only be `complete` when every block exported - * natively and no warning/error-severity issues were recorded. + * Derive the overall export status from the accumulated issues, per-block + * statuses, and the fidelity-level status. A report can only be `complete` + * when every block exported natively, no warning/error-severity issue was + * recorded, and the content-parity gate passed. */ export function deriveExportStatus( issues: ExportIssue[], slideReports: ExportSlideReport[], + fidelity: ExportStatus, archiveVerified = true ): ExportStatus { if (!archiveVerified) return "failed"; if (issues.some((issue) => issue.severity === "error")) return "failed"; + if (fidelity === "failed") return "failed"; const hasWarning = issues.some((issue) => issue.severity === "warning"); const allNative = slideReports.every((slide) => slide.blocks.every((block) => block.status === "native") ); - if (!allNative || hasWarning) return "partial"; - return "complete"; -} - -/** - * Lightweight structural verification of the generated PPTX archive: it must - * be a ZIP file (PK magic bytes) that contains the mandatory presentation - * part. - */ -export function verifyPptxArchive(bytes: Uint8Array): boolean { - if (!bytes || bytes.length < 4) return false; - if (bytes[0] !== 0x50 || bytes[1] !== 0x4b) return false; - const marker = new TextEncoder().encode("ppt/presentation.xml"); - for (let i = 0; i <= bytes.length - marker.length; i++) { - let matches = true; - for (let j = 0; j < marker.length; j++) { - if (bytes[i + j] !== marker[j]) { - matches = false; - break; - } - } - if (matches) return true; - } - return false; + if (allNative && !hasWarning) return "complete"; + if (allNative) return "partial"; + if (!hasWarning) return "complete-with-fallbacks"; + return "partial"; } export interface ExportBuildResult { report: ExportReport; slides: Array<{ slide: DeckSlide; elements: PptxSlideElement[] }>; + parity: number; + fidelityBlocks: FidelityBlockReport[]; + fidelity: FidelityReport; } /** @@ -245,7 +246,17 @@ export async function buildExportReport( blockId: issue.blockId ?? block.id, })); - blockReports.push({ blockId: block.id, status: result.status, issues: stampedIssues }); + const planned = planBlockRepresentation( + { + blockId: block.id, + hidden: !!block.hidden, + status: result.status, + element: result.element, + issues: stampedIssues, + }, + FIDELITY_POLICY, + ); + blockReports.push(planned); issues.push(...stampedIssues); if (result.element) elements.push(result.element); } @@ -254,13 +265,26 @@ export async function buildExportReport( slides.push({ slide, elements }); } + const exportedSlides = (deck.slides ?? []).filter( + (slide) => config.includeHiddenSlides || !slide.hidden, + ); + const fidelityBlocks = slideReports.flatMap((slide) => slide.blocks); + const fidelity = buildFidelityReport({ + deck: { ...deck, slides: exportedSlides }, + blocks: fidelityBlocks, + policy: FIDELITY_POLICY, + }); + return { report: { - status: deriveExportStatus(issues, slideReports), + status: deriveExportStatus(issues, slideReports, fidelity.status), slides: slideReports, issues, }, slides, + parity: fidelity.contentRecall, + fidelityBlocks, + fidelity, }; } @@ -272,7 +296,7 @@ export class PptxExporter { } async export(deck: DeckProject): Promise { - const { report, slides } = await buildExportReport(deck, this.config); + const { report, slides, fidelity } = await buildExportReport(deck, this.config); const PptxGenJS = (await import("pptxgenjs")).default; const pptx = new PptxGenJS(); @@ -297,26 +321,37 @@ export class PptxExporter { const written = await pptx.write({ outputType: "arraybuffer" }); const bytes = await toUint8Array(written); - const archiveVerified = verifyPptxArchive(bytes); + + const blob = new Blob([bytes], { + type: "application/vnd.openxmlformats-officedocument.presentationml.presentation", + }); + + const expectedTexts = (slides ?? []) + .flatMap(({ slide }) => (slide.blocks ?? []).filter((block) => !block.hidden).map((block) => rawText(block))) + .filter((text) => text.length > 0); + const expectedNotes = slides.filter( + ({ slide }) => this.config.includeSpeakerNotes && !!slide.speakerNotes, + ).length; + + const verification = await verifyPptxArchive({ report, blob, expectedTexts, expectedNotes }); + const archiveVerified = verification.passed; const allIssues: ExportIssue[] = [...report.issues]; if (!archiveVerified) { + const failed = verification.report.checks.filter((check) => !check.passed); allIssues.push({ code: "archive-verification-failed", severity: "error", - message: "Generated PPTX archive failed structural verification (missing presentation part)", + message: `Generated PPTX archive failed structural verification: ${failed.map((c) => c.name).join(", ")}`, suggestedFix: "Retry the export; if it persists, report a bug", automaticFixAvailable: false, }); } report.issues = allIssues; - report.status = deriveExportStatus(allIssues, report.slides, archiveVerified); - - const blob = new Blob([bytes], { - type: "application/vnd.openxmlformats-officedocument.presentationml.presentation", - }); + report.status = deriveExportStatus(allIssues, report.slides, fidelity.status, archiveVerified); + fidelity.status = report.status; - return { report, blob, archiveVerified }; + return { report, blob, archiveVerified, fidelity }; } } \ No newline at end of file diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-theme.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-theme.ts index 3869399..1f09558 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-theme.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-theme.ts @@ -45,12 +45,11 @@ export function mapThemeFonts(theme: DeckTheme): { heading: string; body: string } export function applyThemeToPptx(pptx: PptxGenJS, theme: DeckTheme): void { - const colors = mapThemeColors(theme); const fonts = mapThemeFonts(theme); + // PptxGenJS ThemeProps only supports font faces; theme colors are applied + // per-element via mapThemeColors() in the block exporters. pptx.theme = { - headColor: colors.dark1, - bodyColor: colors.text, headFontFace: fonts.heading, bodyFontFace: fonts.body, }; diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-verifier.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-verifier.ts new file mode 100644 index 0000000..0177fb2 --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-verifier.ts @@ -0,0 +1,110 @@ +import JSZip from "jszip"; +import type { ExportReport, PptxVerificationCheck, PptxVerificationReport } from "../export-types"; + +export interface VerificationInput { + report: ExportReport; + blob: Blob; + /** Text fragments that must survive somewhere in the archive's slide runs. */ + expectedTexts?: string[]; + /** Number of speaker-notes parts expected (slides with notes in this export). */ + expectedNotes?: number; +} + +function decode(entryText: string): string { + return entryText + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/'/g, "'"); +} + +function normalizeText(text: string): string { + return decode(text).replace(/<[^>]*>/g, " ").replace(/ /g, " ").replace(/\s+/g, " ").trim().toLowerCase(); +} + +function slidePartName(name: string): boolean { + return /^ppt\/slides\/slide\d+\.xml$/.test(name); +} + +function notesPartName(name: string): boolean { + return /^ppt\/notesSlides\/notesSlide\d+\.xml$/.test(name); +} + +function relsPartName(slidePart: string): string { + return slidePart.replace("ppt/slides/", "ppt/slides/_rels/").replace(/\.xml$/, ".xml.rels"); +} + +export async function verifyPptxArchive(input: VerificationInput): Promise<{ + passed: boolean; + report: PptxVerificationReport; +}> { + const { report, blob, expectedTexts = [], expectedNotes } = input; + const checks: PptxVerificationCheck[] = []; + const expectedSlides = report.slides.length; + + try { + const zipData = + typeof Blob !== "undefined" && blob instanceof Blob ? await blob.arrayBuffer() : blob; + const zip = await JSZip.loadAsync(zipData); + + const archiveSlides = Object.keys(zip.files).filter(slidePartName).sort(); + checks.push({ + name: "slide-count", + passed: archiveSlides.length === expectedSlides, + detail: `expected ${expectedSlides} slides, found ${archiveSlides.length}`, + }); + + const notesCount = Object.keys(zip.files).filter(notesPartName).length; + const notesExpected = expectedNotes ?? expectedSlides; + checks.push({ + name: "speaker-notes", + passed: notesCount === notesExpected, + detail: `expected notes for ${notesExpected} slides, found ${notesCount}`, + }); + + const slideTexts: string[] = []; + for (const name of archiveSlides) { + const entry = zip.file(name); + if (!entry) continue; + const raw = await entry.async("string"); + const texts = raw.match(/([^<]*)<\/a:t>/g) ?? []; + slideTexts.push(...texts.map((t) => t.replace(/<\/?a:t>/g, ""))); + } + const combined = normalizeText(slideTexts.join(" ")); + + const missing: string[] = []; + for (const expected of expectedTexts) { + const normalized = normalizeText(expected); + if (normalized && !combined.includes(normalized)) { + missing.push(`missing text: "${expected}"`); + } + } + checks.push({ + name: "text-survival", + passed: missing.length === 0, + detail: missing.length === 0 ? "all expected text found" : missing.join("; "), + }); + + const missingRels = archiveSlides.filter((name) => !zip.file(relsPartName(name))); + checks.push({ + name: "relationship-integrity", + passed: missingRels.length === 0, + detail: missingRels.length === 0 ? "every slide has a rels part" : `slides missing rels: ${missingRels.join(", ")}`, + }); + + return { + passed: checks.every((c) => c.passed), + report: { checks, passed: checks.every((c) => c.passed) }, + }; + } catch (error) { + checks.push({ + name: "archive-open", + passed: false, + detail: error instanceof Error ? error.message : "failed to open archive", + }); + return { passed: false, report: { checks, passed: false } }; + } +} diff --git a/examples/02-example/src/export/export-dialog.tsx b/examples/02-example/src/export/export-dialog.tsx index 273710e..98b6356 100644 --- a/examples/02-example/src/export/export-dialog.tsx +++ b/examples/02-example/src/export/export-dialog.tsx @@ -1,6 +1,5 @@ import { useState, useEffect, useCallback, useRef } from "react"; import type { - BlockExportStatus, ExportPreflightResult, ExportReport, PptxExportConfig, @@ -17,18 +16,23 @@ interface ExportDialogProps { onError?: (error: Error) => void; } -const STATUS_ORDER: BlockExportStatus[] = ["native", "rasterized", "substituted", "skipped", "unsupported"]; - -function countStatus(report: ExportReport, status: BlockExportStatus): number { - return report.slides.reduce( - (total, slide) => total + slide.blocks.filter((block) => block.status === status).length, - 0 +function fidelitySummary(report: ExportReport): string { + const fallbacks = report.slides.reduce( + (total, slide) => + total + + slide.blocks.filter((b) => b.representation === "svg" || b.representation === "raster").length, + 0, ); -} - -function reportSummary(report: ExportReport): string { - const counts = STATUS_ORDER.map((status) => `${countStatus(report, status)} ${status}`).join(" · "); - return counts; + const native = report.slides.reduce( + (total, slide) => total + slide.blocks.filter((b) => b.representation === "native").length, + 0, + ); + const missing = report.slides.reduce( + (total, slide) => + total + slide.blocks.filter((b) => !b.contentPreserved && b.status !== "skipped").length, + 0, + ); + return `Native ${native} · Fallbacks ${fallbacks} · Missing ${missing}`; } export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: ExportDialogProps) { @@ -89,9 +93,19 @@ export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: Expor const { PptxExporter } = await import("./pptx/pptx-exporter"); const exporter = new PptxExporter(config); const result = await exporter.export(deck); - onExport?.(result.blob); setLastReport(result.report); + if (result.report.status === "failed") { + onError?.( + new Error( + "Export failed: content could not be fully preserved. Fix the missing content before downloading.", + ), + ); + return; + } + + onExport?.(result.blob); + const deckData = deck as { meta?: { title?: string } }; const title = deckData.meta?.title ?? "deck"; const date = new Date().toISOString().split("T")[0]; @@ -105,17 +119,6 @@ export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: Expor a.download = filename; a.click(); URL.revokeObjectURL(url); - - if (result.report.status === "failed") { - onError?.( - new Error( - `Export completed with failures: ${result.report.issues - .filter((issue) => issue.severity === "error") - .map((issue) => issue.message) - .join("; ")}` - ) - ); - } } catch (error) { onError?.(error as Error); } finally { @@ -167,7 +170,8 @@ export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: Expor value={config.mode} onChange={(e) => setConfig({ ...config, mode: e.target.value as PptxExportConfig["mode"] })} > - + + @@ -193,6 +197,9 @@ export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: Expor
Coverage {Math.round(preflight.blockCoverage * 100)}% + Recall {Math.round((preflight.estimatedRecall ?? 1) * 100)}% + {preflight.estimatedFallbacks ?? 0} fallbacks + {(preflight.estimatedMissing ?? 0) > 0 ? `${preflight.estimatedMissing} missing` : "0 missing"} {preflight.issues.filter(i => i.severity === "warning").length} warnings {preflight.issues.filter(i => i.severity === "info").length} info
@@ -220,8 +227,13 @@ export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: Expor
- {reportSummary(lastReport)} + {fidelitySummary(lastReport)}
+ {lastReport.status === "failed" && ( +
+ Export blocked: content was not fully preserved. Review the missing blocks below. +
+ )} {lastReport.issues.length > 0 && (
{lastReport.issues.slice(0, 8).map((issue, idx) => ( @@ -273,7 +285,7 @@ export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: Expor
-
- - -
+
+ - {preflight && ( -
= 80 ? "#f0fdf4" : preflight.score >= 50 ? "#fffbeb" : "#fef2f2", - marginBottom: "16px", - }} - > -
- {preflight.score >= 80 ? "Export ready" : preflight.score >= 50 ? "Export with warnings" : "Export issues detected"} -
-
- Fidelity score: {preflight.score}/100 | Coverage: {Math.round(preflight.blockCoverage * 100)}% + {preflight && ( +
+
+ + {(preflight.score ?? 0) >= 80 ? "Ready to export" : (preflight.score ?? 0) >= 50 ? "Export with warnings" : "Issues detected"} + + + {preflight.score}/100 + +
+
+ Coverage {Math.round(preflight.blockCoverage * 100)}% + Recall {Math.round((preflight.estimatedRecall ?? 1) * 100)}% + {preflight.estimatedFallbacks ?? 0} fallbacks + {(preflight.estimatedMissing ?? 0) > 0 ? `${preflight.estimatedMissing} missing` : "0 missing"} + {preflight.issues.filter(i => i.severity === "warning").length} warnings + {preflight.issues.filter(i => i.severity === "info").length} info +
-
- {preflight.issues.filter(i => i.severity === "warning").length} warnings, {preflight.issues.filter(i => i.severity === "info").length} info -
-
- )} + )} - {lastStatus && ( -
- Export {lastStatus} - {lastStatus === "partial" && — some blocks were substituted or skipped; review the report below.} - {lastStatus === "failed" && — the presentation could not be produced. Check the report below.} -
- )} + {lastReport && ( +
+
+ + Export {lastReport.status} + + + {lastReport.status} + +
+
+ {fidelitySummary(lastReport)} +
+ {lastReport.status === "failed" && ( +
+ Export blocked: content was not fully preserved. Review the missing blocks below. +
+ )} + {lastReport.issues.length > 0 && ( +
+ {lastReport.issues.slice(0, 8).map((issue, idx) => ( +
+ + {issue.severity} + {" "} + {issue.message} +
+ ))} + {lastReport.issues.length > 8 && ( +
… {lastReport.issues.length - 8} more issue(s)
+ )} +
+ )} +
+ )} -
-
-
- - - +
+ + + +
{showDetails && preflight && ( -
-

Preflight Issues

+
+

+ Preflight Issues +

{preflight.issues.length === 0 ? ( -

No issues found

+

No issues found

) : (
    - {preflight.issues.map((issue, idx) => ( -
  • - [{issue.severity}] {issue.message} + {preflight.issues.map((issue: { severity: string; message: string; suggestedFix?: string }, idx: number) => ( +
  • + + {issue.severity} + {" "} + {issue.message} {issue.suggestedFix && ( -
    Fix: {issue.suggestedFix}
    +
    + {issue.suggestedFix} +
    )}
  • ))} @@ -273,4 +343,4 @@ export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: Expor
); -} \ No newline at end of file +} diff --git a/skills/deckforge/starter-components/export/export-preflight.ts b/skills/deckforge/starter-components/export/export-preflight.ts index e0859bb..d4f88de 100644 --- a/skills/deckforge/starter-components/export/export-preflight.ts +++ b/skills/deckforge/starter-components/export/export-preflight.ts @@ -1,3 +1,5 @@ +// export/export-preflight.ts + import type { ExportPreflightResult, ExportIssue, @@ -5,6 +7,7 @@ import type { } from "./export-types"; import type { DeckProject } from "../deck-types"; import { collectFontWarnings } from "./pptx/pptx-fonts"; +import { getBlockExporter } from "./pptx/block-exporters/index"; const NATIVE_BLOCK_TYPES = new Set([ "text", @@ -19,6 +22,10 @@ const NATIVE_BLOCK_TYPES = new Set([ "chart", ]); +function asRecord(value: unknown): Record { + return value as Record; +} + function calculateScore(issues: ExportIssue[]): number { let score = 100; for (const issue of issues) { @@ -30,13 +37,44 @@ function calculateScore(issues: ExportIssue[]): number { } function calculateBlockCoverage(deck: DeckProject): number { - const blocks = (deck.slides ?? []).flatMap((s) => s.blocks ?? []); + const blocks = deck.slides.flatMap((slide) => slide.blocks); if (blocks.length === 0) return 1; const nativeCount = blocks.filter((block) => NATIVE_BLOCK_TYPES.has(block.type)).length; return nativeCount / blocks.length; } +function calculateParityEstimates(deck: DeckProject): { + estimatedRecall: number; + estimatedFallbacks: number; + estimatedMissing: number; +} { + const visible = deck.slides + .filter((slide) => !slide.hidden) + .flatMap((slide) => slide.blocks) + .filter((block) => !block.hidden); + if (visible.length === 0) { + return { estimatedRecall: 1, estimatedFallbacks: 0, estimatedMissing: 0 }; + } + + let fallbacks = 0; + let missing = 0; + for (const block of visible) { + const exporter = getBlockExporter(block.type); + if (exporter.type === "fallback" && block.type !== "fallback") { + missing += 1; + } else if (exporter.exportability === "image-only") { + fallbacks += 1; + } + } + const preserved = visible.length - missing; + return { + estimatedRecall: preserved / visible.length, + estimatedFallbacks: fallbacks, + estimatedMissing: missing, + }; +} + export async function runExportPreflight( deck: DeckProject, config: PptxExportConfig @@ -56,10 +94,10 @@ export async function runExportPreflight( }); } - for (const slide of deck.slides ?? []) { - for (const block of slide.blocks ?? []) { + for (const slide of deck.slides) { + for (const block of slide.blocks) { + const record = asRecord(block); const blockType = block.type; - const record = block as unknown as Record; if (!NATIVE_BLOCK_TYPES.has(blockType)) { issues.push({ @@ -112,6 +150,20 @@ export async function runExportPreflight( const score = calculateScore(issues); const blockCoverage = calculateBlockCoverage(deck); + const estimates = calculateParityEstimates(deck); - return { issues, score, blockCoverage }; -} \ No newline at end of file + const visible = deck.slides + .filter((slide) => !slide.hidden) + .flatMap((slide) => slide.blocks) + .filter((block) => !block.hidden); + + return { + issues, + score, + blockCoverage, + ...estimates, + missingBlockCount: estimates.estimatedMissing, + unsupportedBlockCount: estimates.estimatedMissing, + chartBlockCount: visible.filter((block) => block.type === "chart").length, + }; +} diff --git a/skills/deckforge/starter-components/export/export-types.ts b/skills/deckforge/starter-components/export/export-types.ts index 5dfad0d..96f1f89 100644 --- a/skills/deckforge/starter-components/export/export-types.ts +++ b/skills/deckforge/starter-components/export/export-types.ts @@ -1,4 +1,5 @@ -import type { DeckProject } from "../deck-types"; +import type { Block, DeckProject } from "../deck-types"; +import type { AssetEmbedResult } from "./pptx/pptx-assets"; export type ExportIssueSeverity = "info" | "warning" | "error"; @@ -36,6 +37,10 @@ export interface ExportBlockReport { blockId: string; status: BlockExportStatus; issues: ExportIssue[]; + representation?: BlockRepresentation; + contentPreserved?: boolean; + editable?: boolean; + visualParity?: number; } export interface ExportSlideReport { @@ -43,7 +48,32 @@ export interface ExportSlideReport { blocks: ExportBlockReport[]; } -export type ExportStatus = "complete" | "partial" | "failed"; +export type ExportStatus = "complete" | "complete-with-fallbacks" | "partial" | "failed"; + +export type PptxExportMode = "fidelity-first" | "editability-first"; + +export type BlockRepresentation = "native" | "svg" | "raster" | "expanded-build" | "unsupported"; + +export type FidelityStatus = ExportStatus; + +export interface PptxVerificationCheck { + name: string; + passed: boolean; + detail?: string; +} + +export interface PptxVerificationReport { + checks: PptxVerificationCheck[]; + passed: boolean; +} + +export interface FidelityReport { + status: FidelityStatus; + contentRecall: number; + missingVisibleBlocks: number; + blocks: ExportBlockReport[]; + verification?: PptxVerificationReport; +} export interface ExportReport { status: ExportStatus; @@ -56,6 +86,7 @@ export interface PptxExportResult { report: ExportReport; blob: Blob; archiveVerified: boolean; + fidelity?: FidelityReport; } export type PptxExportability = @@ -70,10 +101,16 @@ export interface ExportPreflightResult { issues: ExportIssue[]; score: number; blockCoverage: number; + estimatedFallbacks: number; + estimatedRecall: number; + estimatedMissing: number; + missingBlockCount: number; + unsupportedBlockCount: number; + chartBlockCount: number; } export interface PptxExportConfig { - mode: "hybrid"; + mode: PptxExportMode; includeSpeakerNotes: boolean; includeHiddenSlides: boolean; compatibilityTargets: string[]; @@ -142,19 +179,38 @@ interface PptxFallbackElement { data: { text: string; options?: Record }; } +interface PptxSvgElement { + type: "svg"; + x: number; + y: number; + w: number; + h: number; + data: { svg: string; alt?: string; options?: Record }; +} + +export type PptxSlideElementType = + | "text" + | "image" + | "shape" + | "table" + | "chart" + | "fallback" + | "svg"; + export type PptxSlideElement = | PptxTextElement | PptxImageElement | PptxShapeElement | PptxTableElement | PptxChartElement - | PptxFallbackElement; + | PptxFallbackElement + | PptxSvgElement; export interface PptxExportContext { deck: DeckProject; config: PptxExportConfig; fontWarnings: FontWarning[]; - assetCache: Map; + assetCache: Map; slideWidth: number; slideHeight: number; } @@ -180,10 +236,12 @@ export interface ExportDialogProps { } export const DEFAULT_PPTX_CONFIG: PptxExportConfig = { - mode: "hybrid", + mode: "fidelity-first", includeSpeakerNotes: true, includeHiddenSlides: false, compatibilityTargets: ["powerpoint", "keynote", "libreoffice"], fontPolicy: "warn-and-substitute", filenameTemplate: "{title}-{date}.pptx", -}; \ No newline at end of file +}; + +export type { Block }; diff --git a/skills/deckforge/starter-components/export/fidelity/content-parity.ts b/skills/deckforge/starter-components/export/fidelity/content-parity.ts new file mode 100644 index 0000000..2d9e4f6 --- /dev/null +++ b/skills/deckforge/starter-components/export/fidelity/content-parity.ts @@ -0,0 +1,62 @@ +import type { Block, DeckProject } from "../../deck-types"; +import type { FidelityBlockReport, PptxFidelityPolicy } from "./fidelity-types"; +import { FIDELITY_POLICY } from "./fidelity-policy"; + +const VISIBLE_TEXT = /[A-Za-z0-9]{2,}/g; + +type ContentRecord = Record; + +function asRecord(value: unknown): ContentRecord { + return (value ?? {}) as ContentRecord; +} + +export function rawText(block: Block): string { + if (typeof block.content === "string") return block.content; + return String(asRecord(block.content).text ?? ""); +} + +function meaningfulText(block: Block): number { + return (rawText(block).match(VISIBLE_TEXT) ?? []).length; +} + +/** + * Compute text-recall content parity: the ratio of meaningful text tokens + * present in the export to the total expected across all visible blocks. + * + * This is a TEXT-based metric — it measures how much human-readable text + * survives into the PPTX output. For visual blocks (charts, diagrams, + * images) where the exported representation is SVG or raster, the metric + * falls back to the block's alt text or title. A score of 1.0 means all + * expected text is present; 0.0 means no text was exported. + * + * The metric intentionally does NOT measure visual fidelity (pixel-level + * accuracy) or structural fidelity (layout positions). Those are assessed + * separately by the OOXML structural verifier. + */ +export function calculateContentParity( + deck: DeckProject, + blocks: FidelityBlockReport[], + policy: PptxFidelityPolicy = FIDELITY_POLICY, +): number { + const visible = deck.slides.flatMap((slide) => slide.blocks.filter((block) => !block.hidden)); + if (visible.length === 0) return 1; + + const byId = new Map(blocks.map((b) => [b.blockId, b])); + const expected = visible.map((block) => meaningfulText(block)); + const present = visible.map((block) => { + const rep = byId.get(block.id); + if (!rep || rep.representation === "unsupported") return 0; + if (rep.representation === "native" || rep.representation === "expanded-build") return meaningfulText(block); + if (rep.representation === "raster") return Math.round(meaningfulText(block) * 0.8); + if (rep.representation === "svg") { + const content = asRecord(block.content); + const alt = String(content.alt ?? asRecord(content.chart).title ?? content.text ?? ""); + return Math.max(1, (alt.match(VISIBLE_TEXT) ?? []).length); + } + return 0; + }); + + const expectedTotal = expected.reduce((a, b) => a + b, 0); + const presentTotal = present.reduce((a, b) => a + b, 0); + return expectedTotal === 0 ? 1 : Math.min(1, presentTotal / expectedTotal); +} diff --git a/skills/deckforge/starter-components/export/fidelity/fidelity-policy.ts b/skills/deckforge/starter-components/export/fidelity/fidelity-policy.ts new file mode 100644 index 0000000..da7139e --- /dev/null +++ b/skills/deckforge/starter-components/export/fidelity/fidelity-policy.ts @@ -0,0 +1,14 @@ +import type { PptxFidelityPolicy } from "./fidelity-types"; + +export const FIDELITY_POLICY: PptxFidelityPolicy = { + version: "1.0.0", + defaultMode: "fidelity-first", + priorities: ["content", "visual", "geometry", "editability", "file-size"], + hardRules: { + meaningfulContentRecall: 0.9, + maxMissingVisibleBlocks: 0, + silentOmissionAllowed: false, + diagramSummaryFallbackAllowed: true, + }, + representations: ["native", "svg", "raster", "expanded-build"], +}; diff --git a/skills/deckforge/starter-components/export/fidelity/fidelity-report.ts b/skills/deckforge/starter-components/export/fidelity/fidelity-report.ts new file mode 100644 index 0000000..79096d7 --- /dev/null +++ b/skills/deckforge/starter-components/export/fidelity/fidelity-report.ts @@ -0,0 +1,53 @@ +import type { DeckProject } from "../../deck-types"; +import type { FidelityReport } from "../export-types"; +import { calculateContentParity } from "./content-parity"; +import { FIDELITY_POLICY } from "./fidelity-policy"; +import type { FidelityBlockReport, PptxFidelityPolicy } from "./fidelity-types"; + +export interface BuildFidelityReportInput { + deck: DeckProject; + blocks: FidelityBlockReport[]; + policy?: PptxFidelityPolicy; +} + +export function countRepresentation( + blocks: FidelityBlockReport[], + rep: FidelityBlockReport["representation"], +): number { + return blocks.filter((b) => b.representation === rep).length; +} + +export function fidelityStatus( + parity: number, + blocks: FidelityBlockReport[], + policy: PptxFidelityPolicy, +): FidelityReport["status"] { + const hardRules = policy.hardRules; + const hasError = blocks.some((b) => b.issues.some((i) => i.severity === "error")); + if (hasError) return "failed"; + if (parity < hardRules.meaningfulContentRecall) return "failed"; + const omittedVisible = blocks.filter( + (b) => b.status !== "skipped" && b.representation === "unsupported", + ); + if (omittedVisible.length > 0) return "failed"; + const fallbackCount = + countRepresentation(blocks, "raster") + + countRepresentation(blocks, "svg") + + countRepresentation(blocks, "expanded-build"); + if (fallbackCount > 0) return "complete-with-fallbacks"; + return "complete"; +} + +export function buildFidelityReport(input: BuildFidelityReportInput): FidelityReport { + const { deck, blocks, policy = FIDELITY_POLICY } = input; + const parity = calculateContentParity(deck, blocks, policy); + const missingVisibleBlocks = blocks.filter( + (b) => b.status !== "skipped" && b.representation === "unsupported", + ).length; + return { + status: fidelityStatus(parity, blocks, policy), + contentRecall: parity, + missingVisibleBlocks, + blocks, + }; +} diff --git a/skills/deckforge/starter-components/export/fidelity/fidelity-types.ts b/skills/deckforge/starter-components/export/fidelity/fidelity-types.ts new file mode 100644 index 0000000..422be2d --- /dev/null +++ b/skills/deckforge/starter-components/export/fidelity/fidelity-types.ts @@ -0,0 +1,29 @@ +import type { + BlockRepresentation, + ExportBlockReport, + ExportStatus, + FidelityReport, + PptxExportMode, + PptxVerificationReport, +} from "../export-types"; + +export type { BlockRepresentation, FidelityReport, PptxExportMode, PptxVerificationReport }; + +export type FidelityStatus = ExportStatus; + +export type FidelityBlockReport = ExportBlockReport; + +export interface FidelityHardRules { + meaningfulContentRecall: number; + maxMissingVisibleBlocks: number; + silentOmissionAllowed: boolean; + diagramSummaryFallbackAllowed: boolean; +} + +export interface PptxFidelityPolicy { + version: string; + defaultMode: PptxExportMode; + priorities: Array<"content" | "visual" | "geometry" | "editability" | "file-size">; + hardRules: FidelityHardRules; + representations: Array<"native" | "svg" | "raster" | "expanded-build">; +} diff --git a/skills/deckforge/starter-components/export/fidelity/representation-planner.ts b/skills/deckforge/starter-components/export/fidelity/representation-planner.ts new file mode 100644 index 0000000..10a3a82 --- /dev/null +++ b/skills/deckforge/starter-components/export/fidelity/representation-planner.ts @@ -0,0 +1,62 @@ +import type { BlockExportStatus, ExportIssue, PptxSlideElement } from "../export-types"; +import type { BlockRepresentation, FidelityBlockReport, PptxFidelityPolicy } from "./fidelity-types"; +import { FIDELITY_POLICY } from "./fidelity-policy"; + +export interface PlannerInput { + blockId: string; + hidden: boolean; + status: BlockExportStatus; + element?: PptxSlideElement; + issues: ExportIssue[]; +} + +function repr( + blockId: string, + status: BlockExportStatus, + issues: ExportIssue[], + representation: BlockRepresentation, + extras: Partial> = {}, +): FidelityBlockReport { + return { + blockId, + status, + issues, + representation, + contentPreserved: representation !== "unsupported", + editable: representation === "native", + visualParity: representation === "native" ? 1 : representation === "svg" ? 0.9 : 0.8, + ...extras, + }; +} + +export function planBlockRepresentation( + input: PlannerInput, + policy: PptxFidelityPolicy = FIDELITY_POLICY, +): FidelityBlockReport { + const { blockId, hidden, status, element, issues } = input; + if (hidden) { + return repr(blockId, "skipped", issues, "unsupported", { contentPreserved: false, editable: false, visualParity: 0 }); + } + const hasError = issues.some((issue) => issue.severity === "error"); + if (hasError || status === "unsupported") { + return repr(blockId, "unsupported", issues, "unsupported", { contentPreserved: false, editable: false, visualParity: 0 }); + } + const r: BlockRepresentation = + element?.type === "svg" + ? "svg" + : status === "native" + ? "native" + : status === "rasterized" + ? "raster" + : status === "substituted" + ? "expanded-build" + : "unsupported"; + return repr(blockId, status, issues, r); +} + +export function countRepresentation( + blocks: FidelityBlockReport[], + rep: BlockRepresentation, +): number { + return blocks.filter((b) => b.representation === rep).length; +} diff --git a/skills/deckforge/starter-components/export/fidelity/svg/svg-diagram.ts b/skills/deckforge/starter-components/export/fidelity/svg/svg-diagram.ts new file mode 100644 index 0000000..ed187ea --- /dev/null +++ b/skills/deckforge/starter-components/export/fidelity/svg/svg-diagram.ts @@ -0,0 +1,156 @@ +export interface DiagramNodeInput { + id?: string; + label: string; +} + +export interface DiagramEdgeInput { + from: string; + to: string; +} + +export interface DiagramInput { + nodes: Array; + edges?: Array; +} + +export interface DiagramSvgOptions { + width: number; + height: number; + colors?: { + background?: string; + nodeFill?: string; + nodeStroke?: string; + labelColor?: string; + edgeColor?: string; + }; +} + +const PAD = 16; +const NODE_W = 140; +const NODE_H = 48; + +export function normalizeDiagram(input: DiagramInput): { + nodes: DiagramNodeInput[]; + edges: DiagramEdgeInput[]; +} { + const nodes: DiagramNodeInput[] = (input.nodes ?? []).map((n) => + typeof n === "string" ? { id: n, label: n } : { id: n.id ?? n.label, label: n.label } + ); + const edges: DiagramEdgeInput[] = (input.edges ?? []).map((e) => + typeof e === "string" + ? (() => { + const [from, to] = e.split("->"); + return { from: (from ?? "").trim(), to: (to ?? "").trim() }; + })() + : { from: e.from, to: e.to } + ); + return { nodes, edges }; +} + +function escapeXml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function computeLayers( + nodes: DiagramNodeInput[], + edges: DiagramEdgeInput[] +): string[][] { + const ids = new Set(nodes.map((n) => n.id ?? n.label)); + const incoming = new Map>(nodes.map((n) => [n.id!, new Set()])); + for (const edge of edges) { + if (ids.has(edge.from) && ids.has(edge.to) && edge.to !== edge.from) { + incoming.get(edge.to)!.add(edge.from); + } + } + const layers: string[][] = []; + const placed = new Set(); + const remaining = new Set(ids); + while (remaining.size > 0) { + const layer = [...remaining] + .filter((id) => [...incoming.get(id)!].every((p) => placed.has(p))) + .sort(); + if (layer.length === 0) { + layers.push([...remaining].sort()); + break; + } + layers.push(layer); + layer.forEach((id) => { + placed.add(id); + remaining.delete(id); + }); + } + return layers; +} + +export function renderDiagramSvg(input: DiagramInput, options: DiagramSvgOptions): string { + const { nodes, edges } = normalizeDiagram(input); + const { width, height } = options; + const colors = options.colors ?? {}; + const bg = colors.background ?? "#FFFFFF"; + const nodeFill = colors.nodeFill ?? "#EEF2FF"; + const nodeStroke = colors.nodeStroke ?? "#6366F1"; + const labelColor = colors.labelColor ?? "#111827"; + const edgeColor = colors.edgeColor ?? "#9CA3AF"; + + const layers = computeLayers(nodes, edges); + const nodeById = new Map(nodes.map((n) => [n.id, n])); + const layerCount = layers.length; + + const colX = (layerIdx: number): number => { + if (layerCount <= 1) return width / 2 - NODE_W / 2; + return PAD + (layerIdx * (width - 2 * PAD - NODE_W)) / (layerCount - 1); + }; + const rowY = (rows: number, rowIdx: number): number => { + if (rows <= 1) return height / 2 - NODE_H / 2; + return PAD + (rowIdx * (height - 2 * PAD - NODE_H)) / (rows - 1); + }; + + const positions = new Map(); + layers.forEach((layer, li) => { + layer.forEach((id, ri) => { + positions.set(id, { x: colX(li), y: rowY(layer.length, ri) }); + }); + }); + + const parts: string[] = []; + parts.push( + `` + ); + parts.push(``); + parts.push( + `` + ); + + for (const edge of edges) { + const from = positions.get(edge.from); + const to = positions.get(edge.to); + if (!from || !to) continue; + const x1 = from.x + NODE_W / 2; + const y1 = from.y + NODE_H / 2; + const x2 = to.x; + const y2 = to.y + NODE_H / 2; + parts.push( + `` + ); + } + + for (const node of nodes) { + const key = node.id ?? node.label; + const pos = positions.get(key); + if (!pos || !nodeById.has(key)) continue; + parts.push( + `` + ); + parts.push( + `${escapeXml(node.label)}` + ); + } + + parts.push(""); + return parts.join("\n"); +} diff --git a/skills/deckforge/starter-components/export/fidelity/svg/svg-snapshot.ts b/skills/deckforge/starter-components/export/fidelity/svg/svg-snapshot.ts new file mode 100644 index 0000000..8fba606 --- /dev/null +++ b/skills/deckforge/starter-components/export/fidelity/svg/svg-snapshot.ts @@ -0,0 +1,80 @@ +export interface SnapshotSvgOptions { + width: number; + height: number; + title?: string; + text?: string; + alt?: string; + colors?: { + background?: string; + border?: string; + titleColor?: string; + bodyColor?: string; + mutedColor?: string; + }; +} + +function escapeXml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function wrapText(text: string, maxChars: number, maxLines: number): string[] { + const words = text.split(/\s+/).filter(Boolean); + const lines: string[] = []; + let current = ""; + for (const word of words) { + const candidate = current ? `${current} ${word}` : word; + if (candidate.length > maxChars && current) { + lines.push(current); + current = word; + if (lines.length >= maxLines) break; + } else { + current = candidate; + } + } + if (current && lines.length < maxLines) lines.push(current); + return lines; +} + +export function renderSnapshotSvg(options: SnapshotSvgOptions): string { + const { width, height } = options; + const colors = options.colors ?? {}; + const bg = colors.background ?? "#FFFBF0"; + const border = colors.border ?? "#F59E0B"; + const bodyColor = colors.bodyColor ?? "#78350F"; + const mutedColor = colors.mutedColor ?? "#A16207"; + + const headerH = Math.max(18, Math.min(28, Math.floor(height * 0.18))); + const bodyTop = headerH + 8; + const maxChars = Math.max(8, Math.floor((width - 24) / 8)); + const maxLines = Math.max(1, Math.floor((height - bodyTop - 20) / 18)); + const lines = wrapText(options.text ?? "", maxChars, maxLines); + + const parts: string[] = []; + parts.push( + `` + ); + parts.push( + `` + ); + parts.push(``); + parts.push( + `${escapeXml(options.title ?? "Content")}` + ); + lines.forEach((line, i) => { + parts.push( + `${escapeXml(line)}` + ); + }); + if (options.alt) { + parts.push( + `${escapeXml(options.alt)}` + ); + } + parts.push(""); + return parts.join("\n"); +} diff --git a/skills/deckforge/starter-components/export/index.ts b/skills/deckforge/starter-components/export/index.ts index cd01fbe..d4d4082 100644 --- a/skills/deckforge/starter-components/export/index.ts +++ b/skills/deckforge/starter-components/export/index.ts @@ -9,6 +9,7 @@ export type { ExportStatus, BlockExportStatus, PptxExportConfig, + PptxExportMode, PptxExportContext, PptxExportability, PptxSlideElement, @@ -17,11 +18,16 @@ export type { PptxExportResult, FontWarning, ExportDialogProps, + BlockRepresentation, + PptxVerificationCheck, + PptxVerificationReport, + FidelityReport, } from "./export-types"; export { DEFAULT_PPTX_CONFIG } from "./export-types"; -export { PptxExporter, buildExportReport, deriveExportStatus, verifyPptxArchive } from "./pptx/pptx-exporter"; +export { PptxExporter, buildExportReport, deriveExportStatus } from "./pptx/pptx-exporter"; +export { verifyPptxArchive } from "./pptx/pptx-verifier"; export { createExportContext } from "./pptx/pptx-context"; export { mapThemeColors, mapThemeFonts, applyThemeToPptx } from "./pptx/pptx-theme"; export { checkFontCompatibility, collectFontWarnings } from "./pptx/pptx-fonts"; @@ -40,7 +46,24 @@ export { shapeBlockExporter } from "./pptx/block-exporters/shape"; export { tableBlockExporter } from "./pptx/block-exporters/table"; export { chartBlockExporter } from "./pptx/block-exporters/chart"; export { diagramBlockExporter } from "./pptx/block-exporters/diagram"; +export { videoBlockExporter } from "./pptx/block-exporters/video"; export { fallbackBlockExporter } from "./pptx/block-exporters/fallback"; +export { FIDELITY_POLICY } from "./fidelity/fidelity-policy"; +export { calculateContentParity, rawText } from "./fidelity/content-parity"; +export { planBlockRepresentation, countRepresentation } from "./fidelity/representation-planner"; +export { buildFidelityReport, fidelityStatus } from "./fidelity/fidelity-report"; +export { renderDiagramSvg, normalizeDiagram } from "./fidelity/svg/svg-diagram"; +export { renderSnapshotSvg } from "./fidelity/svg/svg-snapshot"; + +export type { + FidelityStatus, + FidelityBlockReport, + FidelityHardRules, + PptxFidelityPolicy, +} from "./fidelity/fidelity-types"; +export type { PlannerInput } from "./fidelity/representation-planner"; +export type { BuildFidelityReportInput } from "./fidelity/fidelity-report"; + export { runExportPreflight } from "./export-preflight"; export { ExportDialog } from "./export-dialog"; \ No newline at end of file diff --git a/skills/deckforge/starter-components/export/pptx/block-exporters/diagram.ts b/skills/deckforge/starter-components/export/pptx/block-exporters/diagram.ts index 71e7793..6bed345 100644 --- a/skills/deckforge/starter-components/export/pptx/block-exporters/diagram.ts +++ b/skills/deckforge/starter-components/export/pptx/block-exporters/diagram.ts @@ -3,12 +3,15 @@ import type { PptxBlockExporter, PptxExportContext, } from "../../export-types"; +import { renderDiagramSvg } from "../../fidelity/svg/svg-diagram"; +import { mapThemeColors } from "../pptx-theme"; interface DiagramBlock { id: string; type: "diagram"; - nodes?: Array<{ id: string; label: string }>; - edges?: Array<{ from: string; to: string }>; + nodes?: Array<{ id?: string; label: string } | string>; + edges?: Array<{ from: string; to: string } | string>; + content?: { nodes?: Array<{ id?: string; label: string } | string>; edges?: Array<{ from: string; to: string } | string> }; x?: number; y?: number; w?: number; @@ -22,36 +25,41 @@ export const diagramBlockExporter: PptxBlockExporter = { async export(block: unknown, ctx: PptxExportContext): Promise { const diagramBlock = block as DiagramBlock; + const content = diagramBlock.content; + const nodes = content?.nodes ?? diagramBlock.nodes ?? []; + const edges = content?.edges ?? diagramBlock.edges ?? []; + const x = diagramBlock.x ?? diagramBlock.frame?.x ?? 0; + const y = diagramBlock.y ?? diagramBlock.frame?.y ?? 0; + const w = diagramBlock.w ?? diagramBlock.frame?.w ?? ctx.slideWidth * 0.6; + const h = diagramBlock.h ?? diagramBlock.frame?.h ?? ctx.slideHeight * 0.4; - const nodeCount = diagramBlock.nodes?.length ?? 0; - const edgeCount = diagramBlock.edges?.length ?? 0; - const summary = `Diagram: ${nodeCount} nodes, ${edgeCount} edges`; + const theme = mapThemeColors(ctx.deck.theme); + const svg = renderDiagramSvg( + { nodes, edges }, + { + width: Math.max(1, Math.round(w)), + height: Math.max(1, Math.round(h)), + colors: { + background: theme.background, + nodeFill: theme.light1, + nodeStroke: theme.accent1, + labelColor: theme.text, + edgeColor: theme.dark2, + }, + } + ); return { - status: "substituted", - issues: [ - { - code: "unsupported-block", - severity: "warning", - message: "Diagram exported as a simplified text summary; nodes and edges are not preserved", - suggestedFix: "Rebuild the diagram as shapes and text blocks for native fidelity", - automaticFixAvailable: false, - }, - ], + status: "rasterized", + issues: [], element: { - type: "fallback", - x: diagramBlock.x ?? diagramBlock.frame?.x ?? 0, - y: diagramBlock.y ?? diagramBlock.frame?.y ?? 0, - w: diagramBlock.w ?? diagramBlock.frame?.w ?? ctx.slideWidth * 0.6, - h: diagramBlock.h ?? diagramBlock.frame?.h ?? ctx.slideHeight * 0.4, - data: { - text: summary, - options: { - fill: { color: "F0F0F0" }, - line: { color: "CCCCCC", width: 1 }, - }, - }, + type: "svg", + x, + y, + w, + h, + data: { svg, alt: (diagramBlock as { alt?: string }).alt }, }, }; }, -}; \ No newline at end of file +}; diff --git a/skills/deckforge/starter-components/export/pptx/block-exporters/fallback.ts b/skills/deckforge/starter-components/export/pptx/block-exporters/fallback.ts index 08104b2..fdce238 100644 --- a/skills/deckforge/starter-components/export/pptx/block-exporters/fallback.ts +++ b/skills/deckforge/starter-components/export/pptx/block-exporters/fallback.ts @@ -3,26 +3,52 @@ import type { PptxBlockExporter, PptxExportContext, } from "../../export-types"; +import { renderSnapshotSvg } from "../../fidelity/svg/svg-snapshot"; export const fallbackBlockExporter: PptxBlockExporter = { type: "fallback", exportability: "image-only", - async export(block: unknown, _ctx: PptxExportContext): Promise { + async export(block: unknown, ctx: PptxExportContext): Promise { const anyBlock = block as Record; const blockType = (anyBlock.type as string) ?? "unknown"; + const frame = (anyBlock.frame as { x?: number; y?: number; w?: number; h?: number } | undefined) ?? {}; + const x = (anyBlock.x as number) ?? frame.x ?? 0; + const y = (anyBlock.y as number) ?? frame.y ?? 0; + const w = (anyBlock.w as number) ?? frame.w ?? ctx.slideWidth * 0.5; + const h = (anyBlock.h as number) ?? frame.h ?? ctx.slideHeight * 0.3; + + const content = anyBlock.content; + const text = typeof content === "string" ? content : content ? JSON.stringify(content) : ""; + const alt = (anyBlock.alt as string) ?? (anyBlock.ariaLabel as string) ?? ""; + + const svg = renderSnapshotSvg({ + width: Math.max(1, Math.round(w)), + height: Math.max(1, Math.round(h)), + title: blockType, + text, + alt, + }); return { - status: "unsupported", + status: "rasterized", issues: [ { - code: "unsupported-block", - severity: "warning", - message: `Block type "${blockType}" cannot be exported to PPTX and was omitted`, - suggestedFix: "Replace it with a supported block type (text, image, chart, shape, table, diagram)", + code: "no-fallback-produced", + severity: "info", + message: `Block type "${blockType}" has no native representation and was exported as a visual snapshot`, + suggestedFix: "Add a dedicated exporter for this block type for native editability", automaticFixAvailable: false, }, ], + element: { + type: "svg", + x, + y, + w, + h, + data: { svg, alt }, + }, }; }, -}; \ No newline at end of file +}; diff --git a/skills/deckforge/starter-components/export/pptx/block-exporters/index.ts b/skills/deckforge/starter-components/export/pptx/block-exporters/index.ts index 8a9b605..f20ddc4 100644 --- a/skills/deckforge/starter-components/export/pptx/block-exporters/index.ts +++ b/skills/deckforge/starter-components/export/pptx/block-exporters/index.ts @@ -1,3 +1,5 @@ +// export/pptx/block-exporters/index.ts + import type { PptxBlockExporter } from "../../export-types"; import { textBlockExporter, @@ -13,6 +15,7 @@ import { shapeBlockExporter } from "./shape"; import { tableBlockExporter } from "./table"; import { chartBlockExporter } from "./chart"; import { diagramBlockExporter } from "./diagram"; +import { videoBlockExporter } from "./video"; import { fallbackBlockExporter } from "./fallback"; export const blockExporters: PptxBlockExporter[] = [ @@ -28,6 +31,7 @@ export const blockExporters: PptxBlockExporter[] = [ tableBlockExporter, chartBlockExporter, diagramBlockExporter, + videoBlockExporter, fallbackBlockExporter, ]; @@ -37,4 +41,4 @@ export function getBlockExporter(type: string): PptxBlockExporter { export function getExportability(type: string): string { return getBlockExporter(type).exportability; -} \ No newline at end of file +} diff --git a/skills/deckforge/starter-components/export/pptx/block-exporters/text.ts b/skills/deckforge/starter-components/export/pptx/block-exporters/text.ts index 05eaa8b..0c79b82 100644 --- a/skills/deckforge/starter-components/export/pptx/block-exporters/text.ts +++ b/skills/deckforge/starter-components/export/pptx/block-exporters/text.ts @@ -182,20 +182,16 @@ async function exportProcessBlock(block: unknown, ctx: PptxExportContext): Promi }; const steps = processBlock.content?.steps ?? []; const text = steps - .map((step, index) => `${index + 1}. ${typeof step.title === "string" ? step.title : ""}`) + .map((step, index) => { + const title = typeof step.title === "string" ? step.title : ""; + const detail = typeof step.detail === "string" && step.detail ? ` \u2014 ${step.detail}` : ""; + return `${index + 1}. ${title}${detail}`; + }) .join("\n"); return { - status: "substituted", - issues: [ - { - code: "unsupported-block", - severity: "warning", - message: "Process diagram exported as a simplified numbered text list; step details are not preserved", - suggestedFix: "Split the process into individual text/heading blocks for full fidelity", - automaticFixAvailable: false, - }, - ], + status: "native", + issues: [], element: textElement(text, processBlock, ctx, { fontFace: "Arial", fontSize: 14, @@ -247,4 +243,4 @@ export const processBlockExporter: PptxBlockExporter = { type: "process", exportability: "image-only", export: exportProcessBlock, -}; \ No newline at end of file +}; diff --git a/skills/deckforge/starter-components/export/pptx/block-exporters/video.ts b/skills/deckforge/starter-components/export/pptx/block-exporters/video.ts new file mode 100644 index 0000000..19e9a2e --- /dev/null +++ b/skills/deckforge/starter-components/export/pptx/block-exporters/video.ts @@ -0,0 +1,93 @@ +import type { + PptxBlockExport, + PptxBlockExporter, + PptxExportContext, +} from "../../export-types"; +import { renderSnapshotSvg } from "../../fidelity/svg/svg-snapshot"; +import { mapThemeColors } from "../pptx-theme"; + +interface VideoChapter { + title?: unknown; + summary?: unknown; + keyPoints?: Array; +} + +interface VideoContent { + url?: string; + poster?: string; + chapter?: VideoChapter; +} + +interface VideoBlock { + id: string; + type: "video"; + content?: VideoContent; + alt?: string; + ariaLabel?: string; + x?: number; + y?: number; + w?: number; + h?: number; + frame?: { x?: number; y?: number; w?: number; h?: number }; +} + +export const videoBlockExporter: PptxBlockExporter = { + type: "video", + exportability: "poster-with-link", + + async export(block: unknown, ctx: PptxExportContext): Promise { + const videoBlock = block as VideoBlock; + const content = videoBlock.content ?? {}; + const chapter = content.chapter ?? {}; + const title = + (typeof chapter.title === "string" ? chapter.title : "") || + content.url || + "Video"; + const summary = typeof chapter.summary === "string" ? chapter.summary : ""; + const keyPoints = (chapter.keyPoints ?? []) + .filter((point): point is string => typeof point === "string") + .map((point) => `\u2022 ${point}`) + .join("\n"); + const body = [summary, keyPoints].filter(Boolean).join("\n"); + const x = videoBlock.x ?? videoBlock.frame?.x ?? 0; + const y = videoBlock.y ?? videoBlock.frame?.y ?? 0; + const w = videoBlock.w ?? videoBlock.frame?.w ?? ctx.slideWidth * 0.5; + const h = videoBlock.h ?? videoBlock.frame?.h ?? ctx.slideHeight * 0.3; + + const theme = mapThemeColors(ctx.deck.theme); + const svg = renderSnapshotSvg({ + width: Math.max(1, Math.round(w)), + height: Math.max(1, Math.round(h)), + title, + text: body, + alt: videoBlock.alt ?? videoBlock.ariaLabel ?? "", + colors: { + background: theme.background, + border: theme.accent2, + bodyColor: theme.text, + }, + }); + + const alt = videoBlock.alt ?? videoBlock.ariaLabel ?? ""; + return { + status: "rasterized", + issues: [ + { + code: "no-fallback-produced", + severity: "info", + message: "Video content was exported as a static snapshot with its chapter summary", + suggestedFix: "Provide a poster image to improve the visual representation", + automaticFixAvailable: false, + }, + ], + element: { + type: "svg", + x, + y, + w, + h, + data: { svg, alt }, + }, + }; + }, +}; diff --git a/skills/deckforge/starter-components/export/pptx/pptx-exporter.ts b/skills/deckforge/starter-components/export/pptx/pptx-exporter.ts index dadbeb8..383d66e 100644 --- a/skills/deckforge/starter-components/export/pptx/pptx-exporter.ts +++ b/skills/deckforge/starter-components/export/pptx/pptx-exporter.ts @@ -4,6 +4,7 @@ import type { ExportReport, ExportSlideReport, ExportStatus, + FidelityReport, PptxBlockExport, PptxExportConfig, PptxExportResult, @@ -12,6 +13,12 @@ import type { import type { DeckProject, DeckSlide } from "../../deck-types"; import { createExportContext } from "./pptx-context"; import { getBlockExporter } from "./block-exporters/index"; +import { verifyPptxArchive } from "./pptx-verifier"; +import { rawText } from "../fidelity/content-parity"; +import { FIDELITY_POLICY } from "../fidelity/fidelity-policy"; +import { planBlockRepresentation } from "../fidelity/representation-planner"; +import { buildFidelityReport, fidelityStatus } from "../fidelity/fidelity-report"; +import type { FidelityBlockReport } from "../fidelity/fidelity-types"; const PIXELS_PER_INCH = 96; @@ -75,54 +82,48 @@ function writeElementToSlide(pptxSlide: PptxAddCallable, element: PptxSlideEleme pptxSlide.addText?.(data.text, { ...opts, fill: { color: "FFF3CD" }, color: "856404", fontSize: 12 }); break; } + case "svg": { + const data = element.data as { svg: string; options?: Record }; + pptxSlide.addImage?.( + { data: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(data.svg)}` }, + { ...opts, ...data.options }, + ); + break; + } } } /** - * Derive the overall export status from the accumulated issues and per-block - * statuses. A report can only be `complete` when every block exported - * natively and no warning/error-severity issues were recorded. + * Derive the overall export status from the accumulated issues, per-block + * statuses, and the fidelity-level status. A report can only be `complete` + * when every block exported natively, no warning/error-severity issue was + * recorded, and the content-parity gate passed. */ export function deriveExportStatus( issues: ExportIssue[], slideReports: ExportSlideReport[], + fidelity: ExportStatus, archiveVerified = true ): ExportStatus { if (!archiveVerified) return "failed"; if (issues.some((issue) => issue.severity === "error")) return "failed"; + if (fidelity === "failed") return "failed"; const hasWarning = issues.some((issue) => issue.severity === "warning"); const allNative = slideReports.every((slide) => slide.blocks.every((block) => block.status === "native") ); - if (!allNative || hasWarning) return "partial"; - return "complete"; -} - -/** - * Lightweight structural verification of the generated PPTX archive: it must - * be a ZIP file (PK magic bytes) that contains the mandatory presentation - * part. - */ -export function verifyPptxArchive(bytes: Uint8Array): boolean { - if (!bytes || bytes.length < 4) return false; - if (bytes[0] !== 0x50 || bytes[1] !== 0x4b) return false; - const marker = new TextEncoder().encode("ppt/presentation.xml"); - for (let i = 0; i <= bytes.length - marker.length; i++) { - let matches = true; - for (let j = 0; j < marker.length; j++) { - if (bytes[i + j] !== marker[j]) { - matches = false; - break; - } - } - if (matches) return true; - } - return false; + if (allNative && !hasWarning) return "complete"; + if (allNative) return "partial"; + if (!hasWarning) return "complete-with-fallbacks"; + return "partial"; } export interface ExportBuildResult { report: ExportReport; slides: Array<{ slide: DeckSlide; elements: PptxSlideElement[] }>; + parity: number; + fidelityBlocks: FidelityBlockReport[]; + fidelity: FidelityReport; } /** @@ -245,7 +246,17 @@ export async function buildExportReport( blockId: issue.blockId ?? block.id, })); - blockReports.push({ blockId: block.id, status: result.status, issues: stampedIssues }); + const planned = planBlockRepresentation( + { + blockId: block.id, + hidden: !!block.hidden, + status: result.status, + element: result.element, + issues: stampedIssues, + }, + FIDELITY_POLICY, + ); + blockReports.push(planned); issues.push(...stampedIssues); if (result.element) elements.push(result.element); } @@ -254,13 +265,26 @@ export async function buildExportReport( slides.push({ slide, elements }); } + const exportedSlides = (deck.slides ?? []).filter( + (slide) => config.includeHiddenSlides || !slide.hidden, + ); + const fidelityBlocks = slideReports.flatMap((slide) => slide.blocks); + const fidelity = buildFidelityReport({ + deck: { ...deck, slides: exportedSlides }, + blocks: fidelityBlocks, + policy: FIDELITY_POLICY, + }); + return { report: { - status: deriveExportStatus(issues, slideReports), + status: deriveExportStatus(issues, slideReports, fidelity.status), slides: slideReports, issues, }, slides, + parity: fidelity.contentRecall, + fidelityBlocks, + fidelity, }; } @@ -272,7 +296,7 @@ export class PptxExporter { } async export(deck: DeckProject): Promise { - const { report, slides } = await buildExportReport(deck, this.config); + const { report, slides, fidelity } = await buildExportReport(deck, this.config); const PptxGenJS = (await import("pptxgenjs")).default; const pptx = new PptxGenJS(); @@ -297,26 +321,37 @@ export class PptxExporter { const written = await pptx.write({ outputType: "arraybuffer" }); const bytes = await toUint8Array(written); - const archiveVerified = verifyPptxArchive(bytes); + + const blob = new Blob([bytes], { + type: "application/vnd.openxmlformats-officedocument.presentationml.presentation", + }); + + const expectedTexts = (slides ?? []) + .flatMap(({ slide }) => (slide.blocks ?? []).filter((block) => !block.hidden).map((block) => rawText(block))) + .filter((text) => text.length > 0); + const expectedNotes = slides.filter( + ({ slide }) => this.config.includeSpeakerNotes && !!slide.speakerNotes, + ).length; + + const verification = await verifyPptxArchive({ report, blob, expectedTexts, expectedNotes }); + const archiveVerified = verification.passed; const allIssues: ExportIssue[] = [...report.issues]; if (!archiveVerified) { + const failed = verification.report.checks.filter((check) => !check.passed); allIssues.push({ code: "archive-verification-failed", severity: "error", - message: "Generated PPTX archive failed structural verification (missing presentation part)", + message: `Generated PPTX archive failed structural verification: ${failed.map((c) => c.name).join(", ")}`, suggestedFix: "Retry the export; if it persists, report a bug", automaticFixAvailable: false, }); } report.issues = allIssues; - report.status = deriveExportStatus(allIssues, report.slides, archiveVerified); - - const blob = new Blob([bytes], { - type: "application/vnd.openxmlformats-officedocument.presentationml.presentation", - }); + report.status = deriveExportStatus(allIssues, report.slides, fidelity.status, archiveVerified); + fidelity.status = report.status; - return { report, blob, archiveVerified }; + return { report, blob, archiveVerified, fidelity }; } } \ No newline at end of file diff --git a/skills/deckforge/starter-components/export/pptx/pptx-theme.ts b/skills/deckforge/starter-components/export/pptx/pptx-theme.ts index 3869399..1f09558 100644 --- a/skills/deckforge/starter-components/export/pptx/pptx-theme.ts +++ b/skills/deckforge/starter-components/export/pptx/pptx-theme.ts @@ -45,12 +45,11 @@ export function mapThemeFonts(theme: DeckTheme): { heading: string; body: string } export function applyThemeToPptx(pptx: PptxGenJS, theme: DeckTheme): void { - const colors = mapThemeColors(theme); const fonts = mapThemeFonts(theme); + // PptxGenJS ThemeProps only supports font faces; theme colors are applied + // per-element via mapThemeColors() in the block exporters. pptx.theme = { - headColor: colors.dark1, - bodyColor: colors.text, headFontFace: fonts.heading, bodyFontFace: fonts.body, }; diff --git a/skills/deckforge/starter-components/export/pptx/pptx-verifier.ts b/skills/deckforge/starter-components/export/pptx/pptx-verifier.ts new file mode 100644 index 0000000..0177fb2 --- /dev/null +++ b/skills/deckforge/starter-components/export/pptx/pptx-verifier.ts @@ -0,0 +1,110 @@ +import JSZip from "jszip"; +import type { ExportReport, PptxVerificationCheck, PptxVerificationReport } from "../export-types"; + +export interface VerificationInput { + report: ExportReport; + blob: Blob; + /** Text fragments that must survive somewhere in the archive's slide runs. */ + expectedTexts?: string[]; + /** Number of speaker-notes parts expected (slides with notes in this export). */ + expectedNotes?: number; +} + +function decode(entryText: string): string { + return entryText + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/'/g, "'"); +} + +function normalizeText(text: string): string { + return decode(text).replace(/<[^>]*>/g, " ").replace(/ /g, " ").replace(/\s+/g, " ").trim().toLowerCase(); +} + +function slidePartName(name: string): boolean { + return /^ppt\/slides\/slide\d+\.xml$/.test(name); +} + +function notesPartName(name: string): boolean { + return /^ppt\/notesSlides\/notesSlide\d+\.xml$/.test(name); +} + +function relsPartName(slidePart: string): string { + return slidePart.replace("ppt/slides/", "ppt/slides/_rels/").replace(/\.xml$/, ".xml.rels"); +} + +export async function verifyPptxArchive(input: VerificationInput): Promise<{ + passed: boolean; + report: PptxVerificationReport; +}> { + const { report, blob, expectedTexts = [], expectedNotes } = input; + const checks: PptxVerificationCheck[] = []; + const expectedSlides = report.slides.length; + + try { + const zipData = + typeof Blob !== "undefined" && blob instanceof Blob ? await blob.arrayBuffer() : blob; + const zip = await JSZip.loadAsync(zipData); + + const archiveSlides = Object.keys(zip.files).filter(slidePartName).sort(); + checks.push({ + name: "slide-count", + passed: archiveSlides.length === expectedSlides, + detail: `expected ${expectedSlides} slides, found ${archiveSlides.length}`, + }); + + const notesCount = Object.keys(zip.files).filter(notesPartName).length; + const notesExpected = expectedNotes ?? expectedSlides; + checks.push({ + name: "speaker-notes", + passed: notesCount === notesExpected, + detail: `expected notes for ${notesExpected} slides, found ${notesCount}`, + }); + + const slideTexts: string[] = []; + for (const name of archiveSlides) { + const entry = zip.file(name); + if (!entry) continue; + const raw = await entry.async("string"); + const texts = raw.match(/([^<]*)<\/a:t>/g) ?? []; + slideTexts.push(...texts.map((t) => t.replace(/<\/?a:t>/g, ""))); + } + const combined = normalizeText(slideTexts.join(" ")); + + const missing: string[] = []; + for (const expected of expectedTexts) { + const normalized = normalizeText(expected); + if (normalized && !combined.includes(normalized)) { + missing.push(`missing text: "${expected}"`); + } + } + checks.push({ + name: "text-survival", + passed: missing.length === 0, + detail: missing.length === 0 ? "all expected text found" : missing.join("; "), + }); + + const missingRels = archiveSlides.filter((name) => !zip.file(relsPartName(name))); + checks.push({ + name: "relationship-integrity", + passed: missingRels.length === 0, + detail: missingRels.length === 0 ? "every slide has a rels part" : `slides missing rels: ${missingRels.join(", ")}`, + }); + + return { + passed: checks.every((c) => c.passed), + report: { checks, passed: checks.every((c) => c.passed) }, + }; + } catch (error) { + checks.push({ + name: "archive-open", + passed: false, + detail: error instanceof Error ? error.message : "failed to open archive", + }); + return { passed: false, report: { checks, passed: false } }; + } +}