From 594ab311040f25ed1ffc932222ba4ccf610c82c8 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 1 Nov 2026 10:30:47 -0800 Subject: [PATCH] feat(slidedeck): native slide deck writer and a shared zip package @corbits-builder/zip writes and safely reads zip archives in the hub and the browser with no dependencies. @corbits-builder/pptx validates a deck model and writes a .pptx: theme from tokens, master logo, seven layouts, text and image blocks, notes, footer and slide numbers. --- bun.lock | 16 + package.json | 2 +- packages/slidedeck/README.md | 73 +++ packages/slidedeck/package.json | 15 + packages/slidedeck/src/deck.ts | 135 +++++ packages/slidedeck/src/image.ts | 43 ++ packages/slidedeck/src/index.ts | 18 + packages/slidedeck/src/layouts.ts | 314 ++++++++++++ packages/slidedeck/src/theme.ts | 34 ++ packages/slidedeck/src/write.ts | 577 ++++++++++++++++++++++ packages/slidedeck/src/xml.ts | 43 ++ packages/slidedeck/test/fixture.ts | 120 +++++ packages/slidedeck/test/slidedeck.test.ts | 95 ++++ packages/zip/README.md | 54 ++ packages/zip/package.json | 11 + packages/zip/src/crc32.ts | 16 + packages/zip/src/format.ts | 34 ++ packages/zip/src/index.ts | 3 + packages/zip/src/streams.ts | 27 + packages/zip/src/unzip.ts | 172 +++++++ packages/zip/src/zip.ts | 102 ++++ packages/zip/test/zip.test.ts | 48 ++ packages/zip/tsconfig.json | 19 + 23 files changed, 1970 insertions(+), 1 deletion(-) create mode 100644 packages/slidedeck/README.md create mode 100644 packages/slidedeck/package.json create mode 100644 packages/slidedeck/src/deck.ts create mode 100644 packages/slidedeck/src/image.ts create mode 100644 packages/slidedeck/src/index.ts create mode 100644 packages/slidedeck/src/layouts.ts create mode 100644 packages/slidedeck/src/theme.ts create mode 100644 packages/slidedeck/src/write.ts create mode 100644 packages/slidedeck/src/xml.ts create mode 100644 packages/slidedeck/test/fixture.ts create mode 100644 packages/slidedeck/test/slidedeck.test.ts create mode 100644 packages/zip/README.md create mode 100644 packages/zip/package.json create mode 100644 packages/zip/src/crc32.ts create mode 100644 packages/zip/src/format.ts create mode 100644 packages/zip/src/index.ts create mode 100644 packages/zip/src/streams.ts create mode 100644 packages/zip/src/unzip.ts create mode 100644 packages/zip/src/zip.ts create mode 100644 packages/zip/test/zip.test.ts create mode 100644 packages/zip/tsconfig.json diff --git a/bun.lock b/bun.lock index 1aa84442..8a77b232 100644 --- a/bun.lock +++ b/bun.lock @@ -158,6 +158,18 @@ "arktype": "catalog:", }, }, + "packages/slidedeck": { + "name": "@corbits/slidedeck", + "version": "0.1.0-rc.2", + "dependencies": { + "@corbits/zip": "workspace:*", + "arktype": "catalog:", + }, + }, + "packages/zip": { + "name": "@corbits/zip", + "version": "0.1.0-rc.2", + }, "vendor/interchange/apps/sidecar": { "name": "@intx/sidecar-app", "version": "0.5.0", @@ -750,8 +762,12 @@ "@corbits/react-ui": ["@corbits/react-ui@0.5.0", "", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@radix-ui/react-dialog", "@radix-ui/react-dropdown-menu", "@radix-ui/react-tooltip", "sonner"] }, "sha512-tFTkT7sqYuVavKOVEyP9CrDbXomuOWY/AX1oFjLgsgFc/DdUAMcf6cRMHILdfRI/6HV3W+82hCX+efKlD1JD8g=="], + "@corbits/slidedeck": ["@corbits/slidedeck@workspace:packages/slidedeck"], + "@corbits/xai-provider": ["@corbits/xai-provider@0.1.6", "", { "dependencies": { "arktype": "^2.2.3" }, "peerDependencies": { "@corbits/oauth-core": "^0.3.4", "@corbits/openai-responses": "^0.2.3", "@intx/inference": "^0.5.0", "@intx/types": "^0.5.0" } }, "sha512-tXWyloKzGicQZV9M/u/XCvVHHEqWkeKCBmc15PxJ9MyG3j5xnzKyagMCuvv81r/CaEG/7yeFcjDOHdJHljzwSw=="], + "@corbits/zip": ["@corbits/zip@workspace:packages/zip"], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], "@emulators/core": ["@emulators/core@0.12.3", "", { "dependencies": { "jose": "^6.2.2" } }, "sha512-xbKgJKyk8inuyVDv/um8/M5FowgA7xI1UJVZYbIcRL1JxRWyPBnbo5n+EEQcypVDoZTLrXdJZ/STGBthbek2YA=="], diff --git a/package.json b/package.json index 8d33302f..ef242e5a 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "lint": "bun --bun run oxlint --deny-warnings", "format": "bun fmt --no-script", "format:check": "bun fmt --no-script --check", - "typecheck": "bun check --no-script && bun check --no-script -p packages/client && bun check --no-script -p apps/web", + "typecheck": "bun check --no-script && bun check --no-script -p packages/client && bun check --no-script -p packages/zip && bun check --no-script -p apps/web", "test": "bun --conditions=intx-src test ./apps/hub ./apps/web/src ./packages ./workflows ./test/integration", "test:e2e": "bun --env-file=.env.hub --conditions=intx-src test ./test/e2e", "vendor:verify": "bun scripts/verify-vendor.ts", diff --git a/packages/slidedeck/README.md b/packages/slidedeck/README.md new file mode 100644 index 00000000..de12e6f2 --- /dev/null +++ b/packages/slidedeck/README.md @@ -0,0 +1,73 @@ +# Slide deck + +`@corbits/slidedeck`: writes a slide deck as a standard `.pptx` file that opens +in PowerPoint, Keynote, Google Slides and LibreOffice Impress. An agent +describes the deck as data matching the `Deck` schema, and +`writePptx(deck, assets)` returns the file's bytes. + +```ts +import { assetKey, writePptx } from "@corbits/slidedeck"; + +const logo = { path: "brand/logo.png" }; +const bytes = await writePptx( + { + theme: { + colors: { + primary: "#6D28D9", + secondary: "#0EA5E9", + accent: "#F59E0B", + background: "#FFFFFF", + text: "#1B1B1F", + muted: "#6B7280", + }, + fonts: { heading: "Georgia", body: "Inter" }, + logo, + }, + slides: [{ layout: "title", title: "Builder overview" }], + }, + new Map([[assetKey(logo), logoPngBytes]]), +); +``` + +## Where it fits + +A pure library: it imports arktype and [`packages/zip`][zip], and nothing +from Builder or Interchange. Images arrive as bytes in `assets`, keyed by +`assetKey(ref)`; the writer never fetches them. + +## API + +- `Deck`, `Slide`, `Theme`, `Block`, `TextBlock`, `Paragraph`, `Element`, + `ImageElement`, `AssetRef`: the arktype schemas, each also its type. + `DeckInput` is the shape before defaults such as `fit: "contain"`. +- `validateDeck(input)`: `{ ok: true, deck }` or `{ ok: false, issues }`; + each `DeckIssue` names its path and, inside a slide, the slide's index. +- `writePptx(deck, assets?)`: the `.pptx` bytes. Throws `DeckError`, which + carries `issues`, for an invalid deck or a missing or unreadable image. +- `assetKey(ref)`: the `Assets` key for an `AssetRef`. + +## Limits + +- Seven layouts on a 16:9 slide: `title`, `section`, `content`, + `two-column`, `comparison`, `image` and `blank`. +- Blocks are plain paragraphs and images; no lists, rich text, tables, + charts or shapes. A region's blocks stack in equal-height bands. +- Images must be PNG or JPEG. Text is not measured; overflow is left to + PowerPoint's autofit. +- A deck written twice gives the same bytes under one deflate + implementation. + +## Layout + +- `src/deck.ts`: the deck model and its validation. +- `src/layouts.ts`: the slide size, safe areas and the seven layouts with + their placeholders, plus the slide master. +- `src/theme.ts`: the theme from the deck's colour and font tokens. +- `src/write.ts`: slides, notes, media and the package parts. + +## Working on it + +`bun run test` writes the fixture in `test/fixture.ts`, which uses every +feature, and reads it back; extend the fixture when you add one. + +[zip]: https://github.com/corbitsdev/corbits-builder/tree/main/packages/zip diff --git a/packages/slidedeck/package.json b/packages/slidedeck/package.json new file mode 100644 index 00000000..820c02ef --- /dev/null +++ b/packages/slidedeck/package.json @@ -0,0 +1,15 @@ +{ + "name": "@corbits/slidedeck", + "version": "0.1.0-rc.2", + "private": true, + "license": "SEE LICENSE IN ../../LICENSE.md", + "author": "Sawyer Cutler ", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@corbits/zip": "workspace:*", + "arktype": "catalog:" + } +} diff --git a/packages/slidedeck/src/deck.ts b/packages/slidedeck/src/deck.ts new file mode 100644 index 00000000..18a8d8f3 --- /dev/null +++ b/packages/slidedeck/src/deck.ts @@ -0,0 +1,135 @@ +import { type } from "arktype"; + +const Color = /^#?[0-9A-Fa-f]{6}$/; +const Font = "0 < string <= 100"; +const Text = "string > 0"; + +export const AssetRef = type({ + "+": "reject", + artifact: Text, + version: "number.integer > 0", +}).or({ "+": "reject", path: Text }); +export type AssetRef = typeof AssetRef.infer; + +export const Theme = type({ + "+": "reject", + colors: { + "+": "reject", + primary: Color, + secondary: Color, + accent: Color, + background: Color, + text: Color, + muted: Color, + }, + fonts: { "+": "reject", heading: Font, body: Font }, + "logo?": AssetRef, +}); +export type Theme = typeof Theme.infer; + +const Fit = "'contain' | 'cover' = 'contain'"; + +export const Paragraph = type({ + "+": "reject", + type: "'paragraph'", + text: "string", +}); + +/** Blocks that flow as paragraphs in one text box; lists and rich runs join this union. */ +export const TextBlock = Paragraph; +export type TextBlock = typeof TextBlock.infer; + +export const ImageElement = type({ + "+": "reject", + type: "'image'", + image: AssetRef, + fit: Fit, +}); + +/** Blocks that are shapes of their own; tables, charts and shapes join this union. */ +export const Element = ImageElement; +export type Element = typeof Element.infer; + +/** A region holds an ordered mix of blocks, which the writer stacks top to bottom. */ +export const Block = TextBlock.or(Element); +export type Block = typeof Block.infer; +const Blocks = Block.array(); + +const slide = { "+": "reject", "notes?": "string" } as const; + +const Column = type({ "+": "reject", heading: Text, body: Blocks }); + +export const Slide = type({ + ...slide, + layout: "'title'", + title: Text, + "subtitle?": "string", +}) + .or({ ...slide, layout: "'section'", title: Text, "subtitle?": "string" }) + .or({ ...slide, layout: "'content'", title: Text, body: Blocks }) + .or({ + ...slide, + layout: "'two-column'", + title: Text, + left: Blocks, + right: Blocks, + }) + .or({ + ...slide, + layout: "'comparison'", + title: Text, + left: Column, + right: Column, + }) + .or({ + ...slide, + layout: "'image'", + "title?": Text, + image: AssetRef, + "caption?": "string", + fit: Fit, + }) + .or({ ...slide, layout: "'blank'", body: Blocks }); +export type Slide = typeof Slide.infer; +export type Layout = Slide["layout"]; + +export const Deck = type({ + "+": "reject", + theme: Theme, + "footer?": { "+": "reject", "text?": "string", "slideNumbers?": "boolean" }, + slides: Slide.array().atLeastLength(1), +}); +export type Deck = typeof Deck.infer; +export type DeckInput = typeof Deck.inferIn; + +export type DeckIssue = { + /** Zero-based index into `slides`, when the issue is inside one slide. */ + slide?: number; + /** Where the issue is, such as `slides[2].title`. */ + path: string; + message: string; +}; + +export type DeckValidation = + | { ok: true; deck: Deck } + | { ok: false; issues: DeckIssue[] }; + +export function validateDeck(input: unknown): DeckValidation { + const deck = Deck(input); + if (!(deck instanceof type.errors)) return { ok: true, deck }; + return { + ok: false, + issues: deck.map((error) => { + const [field, index] = error.path; + const path = error.propString || "deck"; + return field === "slides" && typeof index === "number" + ? { slide: index, path, message: error.message } + : { path, message: error.message }; + }), + }; +} + +export const assetKey = (ref: AssetRef) => + "path" in ref + ? `path:${ref.path}` + : `artifact:${ref.artifact}@${ref.version}`; diff --git a/packages/slidedeck/src/image.ts b/packages/slidedeck/src/image.ts new file mode 100644 index 00000000..b24ae912 --- /dev/null +++ b/packages/slidedeck/src/image.ts @@ -0,0 +1,43 @@ +export type ImageFormat = "png" | "jpeg"; +export type ImageInfo = { format: ImageFormat; width: number; height: number }; + +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + +function jpegSize(view: DataView) { + for (let at = 2; at + 9 < view.byteLength;) { + const marker = view.getUint8(at + 1); + // Start-of-frame markers carry the size; C4, C8 and CC share the range but are not frames. + if ( + marker >= 0xc0 && + marker <= 0xcf && + ![0xc4, 0xc8, 0xcc].includes(marker) + ) + return { height: view.getUint16(at + 5), width: view.getUint16(at + 7) }; + at += 2 + view.getUint16(at + 2); + } + return undefined; +} + +function readHeader(bytes: Uint8Array): ImageInfo | undefined { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if ( + bytes.length >= 24 && + PNG_SIGNATURE.every((byte, index) => bytes[index] === byte) + ) + return { + format: "png", + width: view.getUint32(16), + height: view.getUint32(20), + }; + if (bytes.length >= 4 && bytes[0] === 0xff && bytes[1] === 0xd8) { + const size = jpegSize(view); + return size && { format: "jpeg", ...size }; + } + return undefined; +} + +/** The format and pixel size from a PNG or JPEG header, or undefined when it is neither or has no area. */ +export function readImage(bytes: Uint8Array): ImageInfo | undefined { + const info = readHeader(bytes); + return info && info.width > 0 && info.height > 0 ? info : undefined; +} diff --git a/packages/slidedeck/src/index.ts b/packages/slidedeck/src/index.ts new file mode 100644 index 00000000..ffefad5d --- /dev/null +++ b/packages/slidedeck/src/index.ts @@ -0,0 +1,18 @@ +export { + AssetRef, + Block, + Deck, + Element, + ImageElement, + Paragraph, + Slide, + TextBlock, + Theme, + assetKey, + validateDeck, + type DeckInput, + type DeckIssue, + type DeckValidation, + type Layout, +} from "./deck.ts"; +export { DeckError, writePptx, type Assets } from "./write.ts"; diff --git a/packages/slidedeck/src/layouts.ts b/packages/slidedeck/src/layouts.ts new file mode 100644 index 00000000..bec7c0e6 --- /dev/null +++ b/packages/slidedeck/src/layouts.ts @@ -0,0 +1,314 @@ +import type { Layout } from "./deck.ts"; +import { COLOR_MAP } from "./theme.ts"; +import { NAMESPACES, XML_DECLARATION, escapeXml } from "./xml.ts"; + +export type Box = { x: number; y: number; w: number; h: number }; + +const EMU_PER_INCH = 914400; +const inch = (value: number) => Math.round(value * EMU_PER_INCH); +const inches = (x: number, y: number, w: number, h: number): Box => ({ + x: inch(x), + y: inch(y), + w: inch(w), + h: inch(h), +}); + +const SLIDE_WIDTH = 40 / 3; +export const SLIDE_SIZE = { w: inch(SLIDE_WIDTH), h: inch(7.5) }; + +const MARGIN = 0.6; +const WIDTH = SLIDE_WIDTH - 2 * MARGIN; +const GAP = 0.4; +const COLUMN = (WIDTH - GAP) / 2; +const RIGHT_COLUMN = MARGIN + COLUMN + GAP; +const BODY_TOP = 1.45; +const BODY_HEIGHT = 4.95; +const LOGO_TOP = 0.35; +const LOGO_WIDTH = 1.2; +const LOGO_HEIGHT = 0.6; + +export const LOGO_BOX = inches( + SLIDE_WIDTH - MARGIN - LOGO_WIDTH, + LOGO_TOP, + LOGO_WIDTH, + LOGO_HEIGHT, +); +const FREE_TOP = LOGO_TOP + LOGO_HEIGHT + 0.2; + +/** Free content on a blank slide sits below the logo area and above the footer. */ +export const FREE_AREA = inches(MARGIN, FREE_TOP, WIDTH, 6.4 - FREE_TOP); + +type Kind = + | "title" + | "ctrTitle" + | "subTitle" + | "body" + | "obj" + | "pic" + | "ftr" + | "sldNum"; + +export type Role = + | "title" + | "subtitle" + | "body" + | "left" + | "right" + | "leftHeading" + | "rightHeading" + | "image" + | "caption" + | "footer" + | "slideNumber"; + +type TextStyle = { + anchor?: "t" | "ctr" | "b"; + align?: "l" | "ctr" | "r"; + size?: number; + bold?: boolean; + color?: string; +}; + +export type Placeholder = { + role: Role; + kind: Kind; + idx: number; + name: string; + box: Box; +} & TextStyle; + +const FOOTER: Placeholder = { + role: "footer", + kind: "ftr", + idx: 11, + name: "Footer", + box: inches(MARGIN, 6.75, 8, 0.4), + anchor: "ctr", + size: 1200, + color: "tx2", +}; +const SLIDE_NUMBER: Placeholder = { + role: "slideNumber", + kind: "sldNum", + idx: 12, + name: "Slide Number", + box: inches(SLIDE_WIDTH - MARGIN - 1.5, 6.75, 1.5, 0.4), + anchor: "ctr", + align: "r", + size: 1200, + color: "tx2", +}; + +const subtitleStyle = { size: 2000, color: "tx2" } as const; +const headingStyle = { + anchor: "b", + size: 2000, + bold: true, + color: "accent1", +} as const; + +function placeholders() { + // The title always stops short of the logo area, so adding a logo never moves a title. + const titleWidth = WIDTH - LOGO_WIDTH - GAP; + const title: Placeholder = { + role: "title", + kind: "title", + idx: 0, + name: "Title", + box: inches(MARGIN, 0.35, titleWidth, 0.9), + anchor: "ctr", + }; + const body = (role: Role, idx: number, box: Box): Placeholder => ({ + role, + kind: "obj", + idx, + name: "Content", + box, + }); + const text = ( + role: Role, + idx: number, + box: Box, + style: TextStyle, + ): Placeholder => ({ + role, + kind: "body", + idx, + name: "Text", + box, + ...style, + }); + const headingHeight = 0.6; + const comparisonTop = BODY_TOP + headingHeight + 0.1; + const comparisonHeight = BODY_HEIGHT - headingHeight - 0.1; + return { + title: [ + { + role: "title", + kind: "ctrTitle", + idx: 0, + name: "Title", + box: inches(MARGIN, 2.2, WIDTH, 1.5), + anchor: "b", + align: "ctr", + size: 4400, + bold: true, + }, + { + role: "subtitle", + kind: "subTitle", + idx: 1, + name: "Subtitle", + box: inches(MARGIN, 3.85, WIDTH, 1.0), + anchor: "t", + align: "ctr", + ...subtitleStyle, + }, + ], + section: [ + { + ...title, + box: inches(MARGIN, 2.4, WIDTH, 1.4), + anchor: "b", + size: 4000, + }, + text("subtitle", 1, inches(MARGIN, 3.9, WIDTH, 0.9), { + anchor: "t", + ...subtitleStyle, + }), + ], + content: [ + title, + body("body", 1, inches(MARGIN, BODY_TOP, WIDTH, BODY_HEIGHT)), + ], + "two-column": [ + title, + body("left", 1, inches(MARGIN, BODY_TOP, COLUMN, BODY_HEIGHT)), + body("right", 2, inches(RIGHT_COLUMN, BODY_TOP, COLUMN, BODY_HEIGHT)), + ], + comparison: [ + title, + text( + "leftHeading", + 1, + inches(MARGIN, BODY_TOP, COLUMN, headingHeight), + headingStyle, + ), + body("left", 2, inches(MARGIN, comparisonTop, COLUMN, comparisonHeight)), + text( + "rightHeading", + 3, + inches(RIGHT_COLUMN, BODY_TOP, COLUMN, headingHeight), + headingStyle, + ), + body( + "right", + 4, + inches(RIGHT_COLUMN, comparisonTop, COLUMN, comparisonHeight), + ), + ], + image: [ + title, + { + role: "image", + kind: "pic", + idx: 1, + name: "Picture", + box: inches(MARGIN, BODY_TOP, WIDTH, 4.25), + }, + text("caption", 2, inches(MARGIN, BODY_TOP + 4.35, WIDTH, 0.6), { + anchor: "t", + align: "ctr", + size: 1400, + color: "tx2", + }), + ], + blank: [], + } satisfies Record; +} + +const LAYOUTS: Record = { + title: { type: "title", name: "Title Slide" }, + section: { type: "secHead", name: "Section Header" }, + content: { type: "obj", name: "Title and Content" }, + "two-column": { type: "twoObj", name: "Two Content" }, + comparison: { type: "twoTxTwoObj", name: "Comparison" }, + image: { type: "picTx", name: "Picture with Caption" }, + blank: { type: "blank", name: "Blank" }, +}; + +export const LAYOUT_ORDER = Object.keys(LAYOUTS) as Layout[]; + +export function layoutPlaceholders(layout: Layout): Placeholder[] { + return [...placeholders()[layout], FOOTER, SLIDE_NUMBER]; +} + +export const xfrm = ({ x, y, w, h }: Box) => + ``; + +export const GROUP_HEADER = + ''; + +export function placeholderTag({ kind, idx }: Placeholder) { + switch (kind) { + case "title": + case "ctrTitle": + return ``; + case "obj": + return ``; + case "ftr": + case "sldNum": + return ``; + default: + return ``; + } +} + +export const slideNumberField = (text: string) => + `${text}`; + +function styleXml({ size, bold, color, align }: Placeholder) { + const run = [size && `sz="${size}"`, bold && 'b="1"'] + .filter(Boolean) + .join(" "); + const fill = color + ? `` + : ""; + return `${fill}`; +} + +function templateShape(id: number, placeholder: Placeholder) { + const prompt = + placeholder.kind === "sldNum" + ? slideNumberField("‹#›") + : `${escapeXml(placeholder.name)}`; + const anchor = placeholder.anchor ? ` anchor="${placeholder.anchor}"` : ""; + return `${placeholderTag(placeholder)}${xfrm(placeholder.box)}${styleXml(placeholder)}${prompt}`; +} + +export function layoutXml(layout: Layout) { + const { type, name } = LAYOUTS[layout]; + const shapes = layoutPlaceholders(layout) + .map((placeholder, index) => templateShape(index + 2, placeholder)) + .join(""); + return `${XML_DECLARATION}${GROUP_HEADER}${shapes}`; +} + +export function masterXml(logo: string) { + // Layouts' untyped content placeholders inherit from the master's body; PowerPoint rejects an untyped one on the master itself. + const content = placeholders().content.map((placeholder) => + placeholder.kind === "obj" + ? { ...placeholder, kind: "body" as const } + : placeholder, + ); + const shapes = [...content, FOOTER, SLIDE_NUMBER] + .map((placeholder, index) => templateShape(index + 2, placeholder)) + .join(""); + const layoutIds = LAYOUT_ORDER.map( + (_, index) => + ``, + ).join(""); + const level = (size: number, extra = "") => + ``; + return `${XML_DECLARATION}${GROUP_HEADER}${shapes}${logo}${COLOR_MAP}${layoutIds}${level(2000, ' marL="0" indent="0"')}${level(1800)}`; +} diff --git a/packages/slidedeck/src/theme.ts b/packages/slidedeck/src/theme.ts new file mode 100644 index 00000000..c89fc47c --- /dev/null +++ b/packages/slidedeck/src/theme.ts @@ -0,0 +1,34 @@ +import type { Theme } from "./deck.ts"; +import { XML_DECLARATION, escapeXml } from "./xml.ts"; + +const hex = (color: string) => color.replace("#", "").toUpperCase(); +const srgb = (color: string) => ``; + +export const COLOR_MAP = + ''; + +const placeholderStyles = (tag: string, count: number) => + `${Array.from({ length: count }, () => '').join("")}`; + +/** A theme part; scheme colours map dk1 to text, lt1 to background and the accents in token order. */ +export function themeXml({ colors, fonts }: Theme, name: string) { + const accents = [colors.primary, colors.secondary, colors.accent]; + const scheme = [ + `${srgb(colors.text)}`, + `${srgb(colors.background)}`, + `${srgb(colors.muted)}`, + `${srgb(colors.background)}`, + ...[...accents, ...accents].map( + (color, index) => + `${srgb(color)}`, + ), + `${srgb(colors.primary)}`, + `${srgb(colors.muted)}`, + ].join(""); + const font = (typeface: string) => + ``; + const line = + ''; + const effect = ""; + return `${XML_DECLARATION}${scheme}${font(fonts.heading)}${font(fonts.body)}${placeholderStyles("fillStyleLst", 3)}${line}${line}${line}${effect}${effect}${effect}${placeholderStyles("bgFillStyleLst", 3)}`; +} diff --git a/packages/slidedeck/src/write.ts b/packages/slidedeck/src/write.ts new file mode 100644 index 00000000..0465fccf --- /dev/null +++ b/packages/slidedeck/src/write.ts @@ -0,0 +1,577 @@ +import { zip, type ZipEntry } from "@corbits/zip"; + +import { + assetKey, + validateDeck, + type AssetRef, + type Block, + type Deck, + type DeckInput, + type DeckIssue, + type Element, + type Slide, + type TextBlock, +} from "./deck.ts"; +import { readImage, type ImageInfo } from "./image.ts"; +import { + FREE_AREA, + GROUP_HEADER, + LAYOUT_ORDER, + LOGO_BOX, + SLIDE_SIZE, + layoutPlaceholders, + layoutXml, + masterXml, + placeholderTag, + slideNumberField, + xfrm, + type Box, + type Placeholder, + type Role, +} from "./layouts.ts"; +import { COLOR_MAP, themeXml } from "./theme.ts"; +import { + NAMESPACES, + Relationships, + XML_DECLARATION, + escapeXml, +} from "./xml.ts"; + +/** Image bytes by `assetKey(ref)`; the writer never fetches an asset itself. */ +export type Assets = ReadonlyMap>; + +export class DeckError extends Error { + override name = "DeckError"; + + constructor(readonly issues: DeckIssue[]) { + super(issues.map((issue) => issue.message).join("\n")); + } +} + +type Media = { name: string; data: Uint8Array; info: ImageInfo }; +type AssetUse = { ref: AssetRef; path: string; slide?: number }; +type Region = { role: Role; path: string; blocks: readonly Block[] }; + +const CONTENT_TYPE = "application/vnd.openxmlformats-officedocument"; +const PRESENTATION = `${CONTENT_TYPE}.presentationml`; +const STACK_GAP = 137160; + +const lines = (text: string) => text.split(/\r?\n/); + +const run = (text: string) => + `${escapeXml(text)}`; + +function paragraph(text: string) { + if (text === "") return ''; + return `${lines(text).map(run).join('')}`; +} + +const isText = (block: Block): block is TextBlock => block.type === "paragraph"; + +function regions(slide: Slide): Region[] { + switch (slide.layout) { + case "content": + case "blank": + return [{ role: "body", path: "body", blocks: slide.body }]; + case "two-column": + return [ + { role: "left", path: "left", blocks: slide.left }, + { role: "right", path: "right", blocks: slide.right }, + ]; + case "comparison": + return [ + { role: "left", path: "left.body", blocks: slide.left.body }, + { role: "right", path: "right.body", blocks: slide.right.body }, + ]; + default: + return []; + } +} + +function assetUses(deck: Deck): AssetUse[] { + const uses: AssetUse[] = deck.theme.logo + ? [{ ref: deck.theme.logo, path: "theme.logo" }] + : []; + for (const [slide, content] of deck.slides.entries()) { + if (content.layout === "image") + uses.push({ ref: content.image, path: `slides[${slide}].image`, slide }); + for (const { path, blocks } of regions(content)) + for (const [index, block] of blocks.entries()) + if (block.type === "image") + uses.push({ + ref: block.image, + path: `slides[${slide}].${path}[${index}].image`, + slide, + }); + } + return uses; +} + +const issueAt = ({ path, slide }: AssetUse, message: string): DeckIssue => + slide === undefined ? { path, message } : { slide, path, message }; + +/** Reads every image the deck names, reporting all missing or unreadable ones at once. */ +function resolveMedia(deck: Deck, assets: Assets) { + const byKey = new Map(); + const issues: DeckIssue[] = []; + for (const use of assetUses(deck)) { + const key = assetKey(use.ref); + if (byKey.has(key)) continue; + const data = assets.get(key); + if (!data) { + issues.push( + issueAt( + use, + `${use.path} names asset "${key}", which was not provided`, + ), + ); + continue; + } + const info = readImage(data); + if (!info) { + issues.push( + issueAt(use, `${use.path} is not a PNG or JPEG image with a size`), + ); + continue; + } + byKey.set(key, { + name: `image${byKey.size + 1}.${info.format}`, + data, + info, + }); + } + if (issues.length > 0) throw new DeckError(issues); + return { + items: [...byKey.values()], + get(ref: AssetRef) { + const media = byKey.get(assetKey(ref)); + if (!media) throw new Error(`asset ${assetKey(ref)} was not resolved`); + return media; + }, + }; +} +type MediaStore = ReturnType; + +function contain({ width, height }: ImageInfo, box: Box): Box { + const scale = Math.min(box.w / width, box.h / height); + const w = Math.round(width * scale); + const h = Math.round(height * scale); + return { + x: box.x + Math.round((box.w - w) / 2), + y: box.y + Math.round((box.h - h) / 2), + w, + h, + }; +} + +// Cover fills the box and crops the overflow evenly; srcRect is in thousandths of a percent per side. +function coverCrop({ width, height }: ImageInfo, box: Box) { + const visible = box.w / box.h / (width / height); + const side = (fraction: number) => Math.round(((1 - fraction) / 2) * 100000); + return visible < 1 + ? `` + : ``; +} + +type Picture = { + id: number; + relId: string; + box: Box; + name?: string; + nvPr?: string; + crop?: string; +}; + +const picture = ({ + id, + relId, + box, + name = `Picture ${id}`, + nvPr = "", + crop = "", +}: Picture) => + `${nvPr}${crop}${xfrm(box)}`; + +const textBox = (id: number, box: Box, paragraphs: string) => + `${xfrm(box)}${paragraphs}`; + +const placeholderShape = ( + id: number, + placeholder: Placeholder, + paragraphs: string, + box?: Box, +) => + `${placeholderTag(placeholder)}${box ? xfrm(box) : ""}${paragraphs}`; + +class SlidePart { + readonly rels = new Relationships(); + readonly shapes: string[] = []; + #lastId = 1; + + constructor(readonly media: MediaStore) {} + + nextId() { + return ++this.#lastId; + } + + image(ref: AssetRef) { + const media = this.media.get(ref); + return { + info: media.info, + relId: this.rels.add("image", `../media/${media.name}`), + }; + } + + element(element: Element, box: Box) { + const { info, relId } = this.image(element.image); + this.shapes.push( + element.fit === "contain" + ? picture({ id: this.nextId(), relId, box: contain(info, box) }) + : picture({ + id: this.nextId(), + relId, + box, + crop: coverCrop(info, box), + }), + ); + } +} + +type Segment = { text: TextBlock[] } | { element: Element }; + +function segments(blocks: readonly Block[]) { + const out: Segment[] = []; + for (const block of blocks) { + const last = out.at(-1); + if (!isText(block)) out.push({ element: block }); + else if (last && "text" in last) last.text.push(block); + else out.push({ text: [block] }); + } + return out; +} + +/** Stacks a region's blocks top to bottom in equal bands; the first text band fills the placeholder. */ +function stack( + part: SlidePart, + blocks: readonly Block[], + area: Box, + placeholder?: Placeholder, +) { + const bands = segments(blocks); + const height = Math.round( + (area.h - STACK_GAP * (bands.length - 1)) / bands.length, + ); + let target = placeholder; + for (const [index, band] of bands.entries()) { + const box = { + ...area, + y: area.y + index * (height + STACK_GAP), + h: height, + }; + if ("element" in band) { + part.element(band.element, box); + continue; + } + const paragraphs = band.text.map((block) => paragraph(block.text)).join(""); + if (!target) { + part.shapes.push(textBox(part.nextId(), box, paragraphs)); + continue; + } + const fills = bands.length === 1 ? undefined : box; + part.shapes.push( + placeholderShape(part.nextId(), target, paragraphs, fills), + ); + target = undefined; + } +} + +function textContent(slide: Slide): Partial> { + switch (slide.layout) { + case "title": + case "section": + return { + title: paragraph(slide.title), + ...(slide.subtitle && { subtitle: paragraph(slide.subtitle) }), + }; + case "comparison": + return { + title: paragraph(slide.title), + leftHeading: paragraph(slide.left.heading), + rightHeading: paragraph(slide.right.heading), + }; + case "image": + return { + ...(slide.title && { title: paragraph(slide.title) }), + ...(slide.caption && { caption: paragraph(slide.caption) }), + }; + case "blank": + return {}; + default: + return { title: paragraph(slide.title) }; + } +} + +function imagePlaceholder( + part: SlidePart, + slide: Slide & { layout: "image" }, + placeholder: Placeholder, +) { + const { info, relId } = part.image(slide.image); + const id = part.nextId(); + const nvPr = placeholderTag(placeholder); + if (slide.fit === "contain") { + part.shapes.push( + picture({ id, relId, box: contain(info, placeholder.box), nvPr }), + ); + return; + } + // An untitled cover image is the whole slide, not a band inside the safe area. + const box = + slide.title === undefined ? { x: 0, y: 0, ...SLIDE_SIZE } : placeholder.box; + part.shapes.push( + picture({ id, relId, box, nvPr, crop: coverCrop(info, box) }), + ); +} + +function slideXml(deck: Deck, slide: Slide, index: number, media: MediaStore) { + const part = new SlidePart(media); + part.rels.add( + "slideLayout", + `../slideLayouts/slideLayout${LAYOUT_ORDER.indexOf(slide.layout) + 1}.xml`, + ); + const content: Partial> = { + ...textContent(slide), + ...(deck.footer?.text && { footer: paragraph(deck.footer.text) }), + ...(deck.footer?.slideNumbers && { + slideNumber: `${slideNumberField(String(index + 1))}`, + }), + }; + const byRole = new Map( + regions(slide).map((region) => [region.role, region.blocks]), + ); + if (slide.layout === "blank") stack(part, slide.body, FREE_AREA); + for (const placeholder of layoutPlaceholders(slide.layout)) { + const blocks = byRole.get(placeholder.role); + const text = content[placeholder.role]; + if (placeholder.role === "image" && slide.layout === "image") + imagePlaceholder(part, slide, placeholder); + else if (blocks) stack(part, blocks, placeholder.box, placeholder); + else if (text !== undefined) + part.shapes.push(placeholderShape(part.nextId(), placeholder, text)); + } + if (slide.notes !== undefined) + part.rels.add("notesSlide", `../notesSlides/notesSlide${index + 1}.xml`); + return { + notes: slide.notes, + rels: part.rels, + xml: `${XML_DECLARATION}${GROUP_HEADER}${part.shapes.join("")}`, + }; +} + +function notesXml(notes: string) { + return `${XML_DECLARATION}${GROUP_HEADER}${lines(notes).map(paragraph).join("")}`; +} + +const NOTES_MASTER = `${XML_DECLARATION}${GROUP_HEADER}${xfrm({ x: 685800, y: 1143000, w: 5486400, h: 3086100 })}${xfrm({ x: 685800, y: 4400550, w: 5486400, h: 3600450 })}${COLOR_MAP}`; + +function contentTypes(deck: Deck) { + const override = (part: string, type: string) => + ``; + return `${XML_DECLARATION}${[ + override("ppt/presentation.xml", `${PRESENTATION}.presentation.main+xml`), + override( + "ppt/slideMasters/slideMaster1.xml", + `${PRESENTATION}.slideMaster+xml`, + ), + ...LAYOUT_ORDER.map((_, index) => + override( + `ppt/slideLayouts/slideLayout${index + 1}.xml`, + `${PRESENTATION}.slideLayout+xml`, + ), + ), + override( + "ppt/notesMasters/notesMaster1.xml", + `${PRESENTATION}.notesMaster+xml`, + ), + override("ppt/theme/theme1.xml", `${CONTENT_TYPE}.theme+xml`), + override("ppt/theme/theme2.xml", `${CONTENT_TYPE}.theme+xml`), + override("ppt/presProps.xml", `${PRESENTATION}.presProps+xml`), + override("ppt/viewProps.xml", `${PRESENTATION}.viewProps+xml`), + override("ppt/tableStyles.xml", `${PRESENTATION}.tableStyles+xml`), + override( + "docProps/core.xml", + "application/vnd.openxmlformats-package.core-properties+xml", + ), + override("docProps/app.xml", `${CONTENT_TYPE}.extended-properties+xml`), + ...deck.slides.flatMap((slide, index) => [ + override(`ppt/slides/slide${index + 1}.xml`, `${PRESENTATION}.slide+xml`), + ...(slide.notes === undefined + ? [] + : [ + override( + `ppt/notesSlides/notesSlide${index + 1}.xml`, + `${PRESENTATION}.notesSlide+xml`, + ), + ]), + ]), + ].join("")}`; +} + +function titleXml(deck: Deck) { + const title = deck.slides + .map((slide) => ("title" in slide ? slide.title : undefined)) + .find(Boolean); + return title ? `${escapeXml(title)}` : ""; +} + +function packageParts(deck: Deck) { + const root = new Relationships(); + root.add("officeDocument", "ppt/presentation.xml"); + root.add( + "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties", + "docProps/core.xml", + ); + root.add("extended-properties", "docProps/app.xml"); + + const presentation = new Relationships(); + const masterId = presentation.add( + "slideMaster", + "slideMasters/slideMaster1.xml", + ); + const notesMasterId = presentation.add( + "notesMaster", + "notesMasters/notesMaster1.xml", + ); + presentation.add("presProps", "presProps.xml"); + presentation.add("viewProps", "viewProps.xml"); + presentation.add("theme", "theme/theme1.xml"); + presentation.add("tableStyles", "tableStyles.xml"); + const slideIds = deck.slides.map((_, index) => + presentation.add("slide", `slides/slide${index + 1}.xml`), + ); + + return [ + { path: "[Content_Types].xml", data: contentTypes(deck) }, + { path: "_rels/.rels", data: root.xml() }, + { + path: "docProps/core.xml", + data: `${XML_DECLARATION}${titleXml(deck)}`, + }, + { + path: "docProps/app.xml", + data: `${XML_DECLARATION}Builder${deck.slides.length}${deck.slides.filter((slide) => slide.notes !== undefined).length}`, + }, + { + path: "ppt/presentation.xml", + data: `${XML_DECLARATION}${slideIds.map((id, index) => ``).join("")}`, + }, + { path: "ppt/_rels/presentation.xml.rels", data: presentation.xml() }, + { + path: "ppt/presProps.xml", + data: `${XML_DECLARATION}`, + }, + { + path: "ppt/viewProps.xml", + data: `${XML_DECLARATION}`, + }, + { + path: "ppt/tableStyles.xml", + data: `${XML_DECLARATION}`, + }, + { path: "ppt/theme/theme1.xml", data: themeXml(deck.theme, "Builder") }, + { + path: "ppt/theme/theme2.xml", + data: themeXml(deck.theme, "Builder Notes"), + }, + ]; +} + +function masterParts(deck: Deck, media: MediaStore) { + const rels = new Relationships(); + for (const index of LAYOUT_ORDER.keys()) + rels.add("slideLayout", `../slideLayouts/slideLayout${index + 1}.xml`); + rels.add("theme", "../theme/theme1.xml"); + const logo = deck.theme.logo && media.get(deck.theme.logo); + const logoXml = logo + ? picture({ + id: 90, + name: "Logo", + relId: rels.add("image", `../media/${logo.name}`), + box: contain(logo.info, LOGO_BOX), + }) + : ""; + const layoutRels = new Relationships(); + layoutRels.add("slideMaster", "../slideMasters/slideMaster1.xml"); + const notesMasterRels = new Relationships(); + notesMasterRels.add("theme", "../theme/theme2.xml"); + return [ + { path: "ppt/slideMasters/slideMaster1.xml", data: masterXml(logoXml) }, + { path: "ppt/slideMasters/_rels/slideMaster1.xml.rels", data: rels.xml() }, + ...LAYOUT_ORDER.flatMap((layout, index) => [ + { + path: `ppt/slideLayouts/slideLayout${index + 1}.xml`, + data: layoutXml(layout), + }, + { + path: `ppt/slideLayouts/_rels/slideLayout${index + 1}.xml.rels`, + data: layoutRels.xml(), + }, + ]), + { path: "ppt/notesMasters/notesMaster1.xml", data: NOTES_MASTER }, + { + path: "ppt/notesMasters/_rels/notesMaster1.xml.rels", + data: notesMasterRels.xml(), + }, + ]; +} + +function slideParts(deck: Deck, media: MediaStore) { + return deck.slides.flatMap((slide, index) => { + const number = index + 1; + const { xml, rels, notes } = slideXml(deck, slide, index, media); + if (notes === undefined) + return [ + { path: `ppt/slides/slide${number}.xml`, data: xml }, + { path: `ppt/slides/_rels/slide${number}.xml.rels`, data: rels.xml() }, + ]; + const notesRels = new Relationships(); + notesRels.add("notesMaster", "../notesMasters/notesMaster1.xml"); + notesRels.add("slide", `../slides/slide${number}.xml`); + return [ + { path: `ppt/slides/slide${number}.xml`, data: xml }, + { path: `ppt/slides/_rels/slide${number}.xml.rels`, data: rels.xml() }, + { + path: `ppt/notesSlides/notesSlide${number}.xml`, + data: notesXml(notes), + }, + { + path: `ppt/notesSlides/_rels/notesSlide${number}.xml.rels`, + data: notesRels.xml(), + }, + ]; + }); +} + +/** + * Writes a deck as a .pptx. The same deck and assets give the same bytes under one deflate + * implementation; Bun and browsers can compress differently. + */ +export async function writePptx( + input: DeckInput, + assets: Assets = new Map(), +): Promise> { + const checked = validateDeck(input); + if (!checked.ok) throw new DeckError(checked.issues); + const { deck } = checked; + const media = resolveMedia(deck, assets); + const entries: ZipEntry[] = [ + ...packageParts(deck), + ...masterParts(deck, media), + ...slideParts(deck, media), + ...media.items.map(({ name, data }) => ({ + path: `ppt/media/${name}`, + data, + })), + ]; + return zip(entries); +} diff --git a/packages/slidedeck/src/xml.ts b/packages/slidedeck/src/xml.ts new file mode 100644 index 00000000..e7cec646 --- /dev/null +++ b/packages/slidedeck/src/xml.ts @@ -0,0 +1,43 @@ +export const XML_DECLARATION = + '\n'; +export const NAMESPACES = + 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"'; +const RELATIONSHIP_TYPES = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + +// XML 1.0 has no escape for these control characters; one would make the whole part unreadable. +const FORBIDDEN = /[^\P{Cc}\t\n\r]|\p{Noncharacter_Code_Point}/gu; + +export const escapeXml = (text: string) => + text + .replace(FORBIDDEN, "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + +type Relationship = { id: string; type: string; target: string }; + +const relationshipsXml = (items: readonly Relationship[]) => + `${XML_DECLARATION}${items + .map( + ({ id, type, target }) => + ``, + ) + .join("")}`; + +/** One part's relationships, numbered in the order they are added. */ +export class Relationships { + readonly #items: Relationship[] = []; + + /** `type` is a full URI, or a name under the officeDocument relationships namespace. */ + add(type: string, target: string) { + const id = `rId${this.#items.length + 1}`; + this.#items.push({ id, type, target }); + return id; + } + + xml() { + return relationshipsXml(this.#items); + } +} diff --git a/packages/slidedeck/test/fixture.ts b/packages/slidedeck/test/fixture.ts new file mode 100644 index 00000000..b7bec66b --- /dev/null +++ b/packages/slidedeck/test/fixture.ts @@ -0,0 +1,120 @@ +import { crc32, deflateSync } from "node:zlib"; + +import { assetKey, type DeckInput } from "../src/index.ts"; + +function chunk(kind: string, data: Uint8Array) { + const body = Buffer.concat([Buffer.from(kind), data]); + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body)); + return Buffer.concat([length, body, crc]); +} + +/** A solid-colour RGB PNG, so the fixture needs no binary files. */ +export function solidPng( + width: number, + height: number, + rgb: [number, number, number], +) { + const row = Buffer.alloc(1 + width * 3); + for (let x = 0; x < width; x++) row.set(rgb, 1 + x * 3); + const header = Buffer.alloc(13); + header.writeUInt32BE(width, 0); + header.writeUInt32BE(height, 4); + header[8] = 8; + header[9] = 2; + return new Uint8Array( + Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk("IHDR", header), + chunk( + "IDAT", + deflateSync(Buffer.concat(Array.from({ length: height }, () => row))), + ), + chunk("IEND", new Uint8Array()), + ]), + ); +} + +const logo = { path: "brand/logo.png" }; +const mockup = { artifact: "mockup", version: 3 }; + +export const assets = new Map([ + [assetKey(logo), solidPng(240, 80, [109, 40, 217])], + [assetKey(mockup), solidPng(1600, 900, [200, 220, 255])], +]); + +const paragraph = (text: string) => ({ type: "paragraph" as const, text }); + +export const deck: DeckInput = { + theme: { + colors: { + primary: "#6D28D9", + secondary: "#0EA5E9", + accent: "#F59E0B", + background: "#FFFFFF", + text: "#1B1B1F", + muted: "#6B7280", + }, + fonts: { heading: "Georgia", body: "Helvetica Neue" }, + logo, + }, + footer: { text: "Builder · Confidential", slideNumbers: true }, + slides: [ + { + layout: "title", + title: "Builder overview", + subtitle: "From idea to working app", + notes: "Open with the problem.", + }, + { layout: "section", title: "Why now", subtitle: "Three shifts" }, + { + layout: "content", + title: "What Builder does", + body: [ + paragraph("Turns an idea into approved documents."), + paragraph("Designs from your tokens & ."), + ], + notes: "Keep it short.\r\nTwo lines of notes.", + }, + { + layout: "two-column", + title: "Inputs and outputs", + left: [paragraph("Idea, constraints")], + right: [ + paragraph("Docs, design, app"), + { type: "image", image: mockup }, + paragraph("Live preview"), + ], + }, + { + layout: "comparison", + title: "Before and after", + left: { + heading: "Before", + body: [paragraph("Manual specs"), paragraph("Slow handoffs")], + }, + right: { + heading: "After", + body: [paragraph("Agent-written docs"), paragraph("Approvals in chat")], + }, + }, + { + layout: "image", + title: "The mockup", + image: mockup, + caption: "Home screen", + }, + { + layout: "image", + image: mockup, + fit: "cover", + }, + { + layout: "blank", + body: [paragraph("Questions?")], + notes: "Leave time for questions.", + }, + ], +}; diff --git a/packages/slidedeck/test/slidedeck.test.ts b/packages/slidedeck/test/slidedeck.test.ts new file mode 100644 index 00000000..c929ebba --- /dev/null +++ b/packages/slidedeck/test/slidedeck.test.ts @@ -0,0 +1,95 @@ +import { expect, test } from "bun:test"; + +import { unzip } from "@corbits/zip"; + +import { validateDeck, writePptx } from "../src/index.ts"; +import { assets, deck } from "./fixture.ts"; + +const XML_TOKEN = + /<\?xml[^?]*\?>|<(\/?)([A-Za-z_][\w.:-]*)((?:\s+[\w.:-]+="[^"<]*")*)\s*(\/?)>|[^<]+/gy; +const BAD_ENTITY = /&(?!(?:amp|lt|gt|quot|apos|#\d+|#x[\dA-Fa-f]+);)/; + +/** The first well-formedness problem in a part, or undefined; enough to catch a writer slip. */ +function xmlProblem(xml: string) { + const open: string[] = []; + let roots = 0; + let at = 0; + for (const [token, closing, name, attributes, selfClosing] of xml.matchAll( + XML_TOKEN, + )) { + at += token.length; + if (BAD_ENTITY.test(token)) return `bad entity before ${at}`; + if (!name) { + if (open.length === 0 && !token.startsWith("`; + continue; + } + if (open.length === 0) roots++; + if (!selfClosing) open.push(name); + } + if (at !== xml.length) return `unreadable markup at ${at}`; + if (open.length > 0) return `unclosed <${open.at(-1)}>`; + return roots === 1 ? undefined : `${roots} root elements`; +} + +test("the fixture deck writes the same bytes twice and every part reads back", async () => { + const first = await writePptx(deck, assets); + expect(await writePptx(deck, assets)).toEqual(first); + + const parts = await unzip(first); + const paths = parts.map((part) => part.path); + expect(paths[0]).toBe("[Content_Types].xml"); + expect(paths).toContain("ppt/media/image1.png"); + for (const index of [1, 3, 8]) + expect(paths).toContain(`ppt/notesSlides/notesSlide${index}.xml`); + expect( + paths.filter((path) => /^ppt\/slides\/slide\d+\.xml$/.test(path)), + ).toHaveLength(8); + const decoder = new TextDecoder(); + const contentTypes = decoder.decode(parts[0]?.data); + for (const path of paths.filter( + (path) => path.endsWith(".xml") && !path.startsWith("["), + )) + expect(contentTypes).toContain(`PartName="/${path}"`); + + for (const { path, data } of parts) { + if (!/\.(xml|rels)$/.test(path)) continue; + const xml = decoder.decode(data); + expect({ path, problem: xmlProblem(xml) }).toEqual({ + path, + problem: undefined, + }); + if (!path.endsWith(".rels")) continue; + const base = `file:///${path.replace(/_rels\/[^/]+$/, "")}`; + for (const [, target = ""] of xml.matchAll(/Target="([^"]+)"/g)) + expect(paths).toContain(new URL(target, base).pathname.slice(1)); + } +}); + +test("an invalid deck names the slide and field", () => { + const result = validateDeck({ + ...deck, + slides: [deck.slides[0], { layout: "content", body: [] }], + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.issues).toEqual([ + expect.objectContaining({ slide: 1, path: "slides[1].title" }), + ]); +}); + +test("the slide master's content placeholder is a typed body, which PowerPoint needs to open the deck", async () => { + const parts = await unzip(await writePptx(deck, assets)); + const master = parts.find( + (part) => part.path === "ppt/slideMasters/slideMaster1.xml", + ); + const placeholders = new TextDecoder() + .decode(master?.data) + .match(/]*>/g); + expect(placeholders).toContain(''); +}); diff --git a/packages/zip/README.md b/packages/zip/README.md new file mode 100644 index 00000000..87382535 --- /dev/null +++ b/packages/zip/README.md @@ -0,0 +1,54 @@ +# Zip + +`@corbits/zip`: Builder's ZIP writer and reader. `zip(entries)` writes +entries to archive bytes and `unzip(bytes)` reads them back, checking each +entry's name, bounds, size and CRC. + +```ts +import { unzip, zip } from "@corbits/zip"; + +const archive = await zip([{ path: "hello.txt", data: "hello" }]); +const entries = await unzip(archive, { maxTotalBytes: 1024 * 1024 }); +``` + +## Where it fits + +A plain shared library with no dependencies and no `node:` imports. It +compresses with the platform's `CompressionStream("deflate-raw")`, so the +same code runs in the [hub][hub], in agents and in the [interface][web]. +The [slide deck writer][slidedeck] packages its parts with it. + +## API + +- `zip(entries)`: archive bytes. A `ZipEntry` is `{ path, data }`, where + `data` is bytes or a string written as UTF-8. Entries keep the order given + and carry a fixed 1980-01-01 timestamp; each is deflated unless that would + make it larger. +- `unzip(bytes, { maxTotalBytes }?)`: every `UnzippedEntry` (`{ path, data }`) + in central directory order. `maxTotalBytes` defaults to 256 MiB. +- `ZipError`: thrown for anything either side refuses. + +## Limits + +- No ZIP64: at most 65,535 entries, and entries and archives under 4 GiB. +- No encryption; only stored and deflated entries are read. +- Names must be relative and unique: absolute paths, drive letters, + backslashes, NUL and `..` segments are refused on both sides. +- Equal input gives equal bytes under one deflate implementation; Bun and + browsers can compress differently. + +## Layout + +- `src/zip.ts`: the writer. +- `src/unzip.ts`: the reader, from the central directory. +- `src/format.ts`: record signatures and sizes, and the name check. +- `src/crc32.ts`, `src/streams.ts`: the checksum and the deflate streams. + +## Working on it + +`bun run test` feeds hostile archives to the reader here, and round-trips a +full archive through the [slide deck writer's][slidedeck] fixture. + +[hub]: https://github.com/corbitsdev/corbits-builder/tree/main/apps/hub +[web]: https://github.com/corbitsdev/corbits-builder/tree/main/apps/web +[slidedeck]: https://github.com/corbitsdev/corbits-builder/tree/main/packages/slidedeck diff --git a/packages/zip/package.json b/packages/zip/package.json new file mode 100644 index 00000000..f0faef7d --- /dev/null +++ b/packages/zip/package.json @@ -0,0 +1,11 @@ +{ + "name": "@corbits/zip", + "version": "0.1.0-rc.2", + "private": true, + "license": "SEE LICENSE IN ../../LICENSE.md", + "author": "Sawyer Cutler ", + "type": "module", + "exports": { + ".": "./src/index.ts" + } +} diff --git a/packages/zip/src/crc32.ts b/packages/zip/src/crc32.ts new file mode 100644 index 00000000..af7db6d7 --- /dev/null +++ b/packages/zip/src/crc32.ts @@ -0,0 +1,16 @@ +// A DataView read is always a number, where indexing the array is typed as possibly undefined. +const TABLE = new DataView( + Uint32Array.from({ length: 256 }, (_, index) => { + let value = index; + for (let bit = 0; bit < 8; bit++) + value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + return value; + }).buffer, +); + +export function crc32(data: Uint8Array) { + let crc = 0xffffffff; + for (const byte of data) + crc = TABLE.getUint32(((crc ^ byte) & 0xff) * 4, true) ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} diff --git a/packages/zip/src/format.ts b/packages/zip/src/format.ts new file mode 100644 index 00000000..a3edca9a --- /dev/null +++ b/packages/zip/src/format.ts @@ -0,0 +1,34 @@ +export const LOCAL_HEADER = 0x04034b50; +export const CENTRAL_HEADER = 0x02014b50; +export const END_OF_CENTRAL = 0x06054b50; +export const LOCAL_HEADER_SIZE = 30; +export const CENTRAL_HEADER_SIZE = 46; +export const END_RECORD_SIZE = 22; +export const STORED = 0; +export const DEFLATED = 8; +export const UTF8_NAMES = 0x0800; +export const MAX_UINT32 = 0xffffffff; +export const MAX_ENTRIES = 0xffff; + +export class ZipError extends Error { + override name = "ZipError"; +} + +const UNSAFE_NAME = /^$|^\/|^[A-Za-z]:|\\|\0|(^|\/)\.\.(\/|$)/; + +/** Throws on a name that could escape the folder it is extracted into, or that repeats. */ +export function checkName(path: string, seen: Set) { + if (UNSAFE_NAME.test(path)) throw new ZipError(`unsafe entry name "${path}"`); + if (seen.has(path)) throw new ZipError(`duplicate entry name "${path}"`); + seen.add(path); +} + +export function concat(parts: readonly Uint8Array[]): Uint8Array { + const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)); + let at = 0; + for (const part of parts) { + out.set(part, at); + at += part.length; + } + return out; +} diff --git a/packages/zip/src/index.ts b/packages/zip/src/index.ts new file mode 100644 index 00000000..7d77d8cc --- /dev/null +++ b/packages/zip/src/index.ts @@ -0,0 +1,3 @@ +export { ZipError } from "./format.ts"; +export { unzip, type UnzipOptions, type UnzippedEntry } from "./unzip.ts"; +export { zip, type ZipEntry } from "./zip.ts"; diff --git a/packages/zip/src/streams.ts b/packages/zip/src/streams.ts new file mode 100644 index 00000000..e6c6f012 --- /dev/null +++ b/packages/zip/src/streams.ts @@ -0,0 +1,27 @@ +import { concat } from "./format.ts"; + +export async function deflateRaw(data: Uint8Array) { + const stream = new Blob([data]) + .stream() + .pipeThrough(new CompressionStream("deflate-raw")); + return new Uint8Array(await new Response(stream).arrayBuffer()); +} + +/** Inflates up to `limit` bytes, or returns undefined as soon as the output would pass it. */ +export async function inflateRaw(data: Uint8Array, limit: number) { + const reader = new Blob([data]) + .stream() + .pipeThrough(new DecompressionStream("deflate-raw")) + .getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (let read = await reader.read(); !read.done; read = await reader.read()) { + total += read.value.length; + if (total > limit) { + await reader.cancel(); + return undefined; + } + chunks.push(read.value); + } + return concat(chunks); +} diff --git a/packages/zip/src/unzip.ts b/packages/zip/src/unzip.ts new file mode 100644 index 00000000..aa1fb45c --- /dev/null +++ b/packages/zip/src/unzip.ts @@ -0,0 +1,172 @@ +import { crc32 } from "./crc32.ts"; +import { + CENTRAL_HEADER, + CENTRAL_HEADER_SIZE, + DEFLATED, + END_OF_CENTRAL, + END_RECORD_SIZE, + LOCAL_HEADER, + LOCAL_HEADER_SIZE, + MAX_UINT32, + STORED, + UTF8_NAMES, + ZipError, + checkName, +} from "./format.ts"; +import { inflateRaw } from "./streams.ts"; + +export type UnzippedEntry = { path: string; data: Uint8Array }; +export type UnzipOptions = { maxTotalBytes?: number }; + +const MAX_COMMENT = 0xffff; +const ENCRYPTED = 0x1; +const DEFAULT_MAX_TOTAL_BYTES = 256 * 1024 * 1024; + +type Header = { + path: string; + method: number; + crc: number; + packedSize: number; + size: number; + start: number; +}; + +const utf8 = new TextDecoder("utf-8", { fatal: true }); + +function decodeName(bytes: Uint8Array, flags: number) { + if (!(flags & UTF8_NAMES)) return String.fromCharCode(...bytes); + try { + return utf8.decode(bytes); + } catch (cause) { + throw new ZipError("entry name is not valid UTF-8", { cause }); + } +} + +function endRecordOffset(view: DataView) { + const last = view.byteLength - END_RECORD_SIZE; + for (let at = last; at >= Math.max(0, last - MAX_COMMENT); at--) + if (view.getUint32(at, true) === END_OF_CENTRAL) return at; + throw new ZipError("not a zip: no end of central directory record"); +} + +function readHeaders(bytes: Uint8Array, view: DataView): Header[] { + const end = endRecordOffset(view); + const count = view.getUint16(end + 10, true); + const directorySize = view.getUint32(end + 12, true); + const directoryAt = view.getUint32(end + 16, true); + if (directoryAt === MAX_UINT32) + throw new ZipError("ZIP64 archives are not read"); + const directoryEnd = directoryAt + directorySize; + if (directoryEnd > end) + throw new ZipError("central directory runs past its end record"); + if (count * CENTRAL_HEADER_SIZE > directorySize) + throw new ZipError(`central directory is too small for ${count} entries`); + + const seen = new Set(); + const headers: Header[] = []; + let at = directoryAt; + for (let index = 0; index < count; index++) { + if ( + at + CENTRAL_HEADER_SIZE > directoryEnd || + view.getUint32(at, true) !== CENTRAL_HEADER + ) + throw new ZipError(`central directory entry ${index} is corrupt`); + const flags = view.getUint16(at + 8, true); + const nameEnd = at + CENTRAL_HEADER_SIZE + view.getUint16(at + 28, true); + const next = + nameEnd + view.getUint16(at + 30, true) + view.getUint16(at + 32, true); + if (next > directoryEnd) + throw new ZipError(`central directory entry ${index} is truncated`); + const path = decodeName( + bytes.subarray(at + CENTRAL_HEADER_SIZE, nameEnd), + flags, + ); + checkName(path, seen); + if (flags & ENCRYPTED) throw new ZipError(`${path} is encrypted`); + + const packedSize = view.getUint32(at + 20, true); + const size = view.getUint32(at + 24, true); + const localAt = view.getUint32(at + 42, true); + if ([packedSize, size, localAt].includes(MAX_UINT32)) + throw new ZipError(`${path} needs ZIP64, which is not read`); + if ( + localAt + LOCAL_HEADER_SIZE > directoryAt || + view.getUint32(localAt, true) !== LOCAL_HEADER + ) + throw new ZipError(`${path} has no local header`); + // The local header's own name and extra lengths can differ from the central directory's. + const start = + localAt + + LOCAL_HEADER_SIZE + + view.getUint16(localAt + 26, true) + + view.getUint16(localAt + 28, true); + if (start + packedSize > directoryAt) + throw new ZipError(`${path} runs into the central directory`); + headers.push({ + path, + method: view.getUint16(at + 10, true), + crc: view.getUint32(at + 16, true), + packedSize, + size, + start, + }); + at = next; + } + return headers; +} + +function checkNoOverlap(headers: readonly Header[]) { + const ranges = headers + .map(({ start, packedSize }) => ({ start, end: start + packedSize })) + .sort((a, b) => a.start - b.start); + let previousEnd = 0; + for (const { start, end } of ranges) { + if (start < previousEnd) + throw new ZipError("entries overlap in the archive"); + previousEnd = end; + } +} + +async function inflate( + { path, method, size }: Header, + body: Uint8Array, +) { + if (method === STORED) return body; + if (method !== DEFLATED) + throw new ZipError(`${path} uses unsupported compression method ${method}`); + try { + return await inflateRaw(body, size); + } catch (cause) { + throw new ZipError(`${path} is not valid deflate data`, { cause }); + } +} + +/** Reads every entry in central directory order, checking names, bounds, sizes and CRCs. */ +export async function unzip( + bytes: Uint8Array, + { maxTotalBytes = DEFAULT_MAX_TOTAL_BYTES }: UnzipOptions = {}, +): Promise { + if (bytes.length < END_RECORD_SIZE) + throw new ZipError("not a zip: too short"); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const headers = readHeaders(bytes, view); + checkNoOverlap(headers); + const total = headers.reduce((sum, { size }) => sum + size, 0); + if (total > maxTotalBytes) + throw new ZipError( + `archive expands to ${total} bytes, over the ${maxTotalBytes} limit`, + ); + + const entries: UnzippedEntry[] = []; + for (const header of headers) { + const { path, crc, packedSize, size, start } = header; + const data = await inflate( + header, + bytes.subarray(start, start + packedSize), + ); + if (data === undefined || data.length !== size || crc32(data) !== crc) + throw new ZipError(`${path} fails its size or CRC check`); + entries.push({ path, data }); + } + return entries; +} diff --git a/packages/zip/src/zip.ts b/packages/zip/src/zip.ts new file mode 100644 index 00000000..d555c395 --- /dev/null +++ b/packages/zip/src/zip.ts @@ -0,0 +1,102 @@ +import { crc32 } from "./crc32.ts"; +import { + CENTRAL_HEADER, + CENTRAL_HEADER_SIZE, + DEFLATED, + END_OF_CENTRAL, + END_RECORD_SIZE, + LOCAL_HEADER, + LOCAL_HEADER_SIZE, + MAX_ENTRIES, + MAX_UINT32, + STORED, + UTF8_NAMES, + ZipError, + checkName, + concat, +} from "./format.ts"; +import { deflateRaw } from "./streams.ts"; + +export type ZipEntry = { path: string; data: Uint8Array | string }; + +const VERSION = 20; +// 1980-01-01 00:00, the earliest DOS date. +const DOS_TIME = 0; +const DOS_DATE = (1 << 5) | 1; + +const encoder = new TextEncoder(); + +async function compress(raw: Uint8Array) { + const packed = await deflateRaw(raw); + return packed.length < raw.length + ? { method: DEFLATED, body: packed } + : { method: STORED, body: raw }; +} + +/** + * Writes entries in the order given with fixed timestamps. Equal input gives equal bytes under one + * deflate implementation; Bun and browsers can compress differently. + */ +export async function zip( + entries: readonly ZipEntry[], +): Promise> { + if (entries.length > MAX_ENTRIES) + throw new ZipError(`zip holds at most ${MAX_ENTRIES} entries`); + const seen = new Set(); + for (const { path } of entries) checkName(path, seen); + const parts: Uint8Array[] = []; + const central: Uint8Array[] = []; + let offset = 0; + const prepared = await Promise.all( + entries.map(async ({ path, data }) => { + const raw = typeof data === "string" ? encoder.encode(data) : data; + return { path, raw, ...(await compress(raw)) }; + }), + ); + for (const { path, raw, method, body } of prepared) { + const name = encoder.encode(path); + if (raw.length >= MAX_UINT32 || offset >= MAX_UINT32) + throw new ZipError(`${path} needs ZIP64, which is not written`); + const crc = crc32(raw); + + const local = new DataView(new ArrayBuffer(LOCAL_HEADER_SIZE)); + local.setUint32(0, LOCAL_HEADER, true); + local.setUint16(4, VERSION, true); + local.setUint16(6, UTF8_NAMES, true); + local.setUint16(8, method, true); + local.setUint16(10, DOS_TIME, true); + local.setUint16(12, DOS_DATE, true); + local.setUint32(14, crc, true); + local.setUint32(18, body.length, true); + local.setUint32(22, raw.length, true); + local.setUint16(26, name.length, true); + parts.push(new Uint8Array(local.buffer), name, body); + + const dir = new DataView(new ArrayBuffer(CENTRAL_HEADER_SIZE)); + dir.setUint32(0, CENTRAL_HEADER, true); + dir.setUint16(4, VERSION, true); + dir.setUint16(6, VERSION, true); + dir.setUint16(8, UTF8_NAMES, true); + dir.setUint16(10, method, true); + dir.setUint16(12, DOS_TIME, true); + dir.setUint16(14, DOS_DATE, true); + dir.setUint32(16, crc, true); + dir.setUint32(20, body.length, true); + dir.setUint32(24, raw.length, true); + dir.setUint16(28, name.length, true); + dir.setUint32(42, offset, true); + central.push(new Uint8Array(dir.buffer), name); + + offset += LOCAL_HEADER_SIZE + name.length + body.length; + } + const centralSize = central.reduce((sum, part) => sum + part.length, 0); + if (offset >= MAX_UINT32 || centralSize >= MAX_UINT32) + throw new ZipError("archive needs ZIP64, which is not written"); + const end = new DataView(new ArrayBuffer(END_RECORD_SIZE)); + end.setUint32(0, END_OF_CENTRAL, true); + end.setUint16(8, entries.length, true); + end.setUint16(10, entries.length, true); + end.setUint32(12, centralSize, true); + end.setUint32(16, offset, true); + return concat([...parts, ...central, new Uint8Array(end.buffer)]); +} diff --git a/packages/zip/test/zip.test.ts b/packages/zip/test/zip.test.ts new file mode 100644 index 00000000..88000191 --- /dev/null +++ b/packages/zip/test/zip.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from "bun:test"; + +import { ZipError, unzip, zip } from "../src/index.ts"; + +const centralDirectoryOffset = (archive: Uint8Array) => + new DataView(archive.buffer).getUint32(archive.length - 6, true); + +async function patched( + path: string, + data: string, + patch: (view: DataView, at: number) => void, +) { + const archive = await zip([{ path, data }]); + patch(new DataView(archive.buffer), centralDirectoryOffset(archive)); + return archive; +} + +test("hostile archives fail with a ZipError", async () => { + const bomb = await patched("bomb", "\0".repeat(1 << 20), (view, at) => + view.setUint32(at + 24, 16, true), + ); + const outOfRange = await patched("a", "hello", (view, at) => + view.setUint32(at + 42, 0x7fffffff, true), + ); + const traversal = await patched("xx/evil", "hello", (view, at) => { + for (const offset of [30, at + 46]) { + view.setUint8(offset, 0x2e); + view.setUint8(offset + 1, 0x2e); + } + }); + const whole = await zip([{ path: "a", data: "hello" }]); + + for (const archive of [ + bomb, + outOfRange, + traversal, + whole.slice(0, whole.length - 8), + ]) + await expect(unzip(archive)).rejects.toBeInstanceOf(ZipError); + for (const path of ["../evil", "/etc/passwd", "C:/x", "a\\b"]) + await expect(zip([{ path, data: "" }])).rejects.toBeInstanceOf(ZipError); + await expect( + zip([ + { path: "a", data: "" }, + { path: "a", data: "" }, + ]), + ).rejects.toBeInstanceOf(ZipError); +}); diff --git a/packages/zip/tsconfig.json b/packages/zip/tsconfig.json new file mode 100644 index 00000000..dc034197 --- /dev/null +++ b/packages/zip/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": [], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "exactOptionalPropertyTypes": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src"] +}