Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
73 changes: 73 additions & 0 deletions packages/slidedeck/README.md
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions packages/slidedeck/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "@corbits/slidedeck",
"version": "0.1.0-rc.2",
"private": true,
"license": "SEE LICENSE IN ../../LICENSE.md",
"author": "Sawyer Cutler <sawyer@dirtroad.dev>",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@corbits/zip": "workspace:*",
"arktype": "catalog:"
}
}
135 changes: 135 additions & 0 deletions packages/slidedeck/src/deck.ts
Original file line number Diff line number Diff line change
@@ -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}`;
43 changes: 43 additions & 0 deletions packages/slidedeck/src/image.ts
Original file line number Diff line number Diff line change
@@ -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;
}
18 changes: 18 additions & 0 deletions packages/slidedeck/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading