diff --git a/packages/slidedeck/README.md b/packages/slidedeck/README.md index de12e6f2..4bcd8ae9 100644 --- a/packages/slidedeck/README.md +++ b/packages/slidedeck/README.md @@ -63,6 +63,9 @@ from Builder or Interchange. Images arrive as bytes in `assets`, keyed by - `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/free.ts`: free-form slides, whose elements sit in CSS pixels on a + 1280 by 720 canvas: text, shapes, images and groups. +- `src/motion.ts`: slide transitions and the animation timing tree. - `src/write.ts`: slides, notes, media and the package parts. ## Working on it diff --git a/packages/slidedeck/src/deck.ts b/packages/slidedeck/src/deck.ts index 80f16c13..70368e64 100644 --- a/packages/slidedeck/src/deck.ts +++ b/packages/slidedeck/src/deck.ts @@ -112,12 +112,270 @@ export const ImageElement = type({ export const Element = ImageElement; export type Element = typeof Element.infer; +/** Free-form slides are laid out in CSS pixels on this canvas, which is the slide at 96 pixels per inch. */ +export const CANVAS = { width: 1280, height: 720 } as const; + +const Fraction = "0 <= number <= 1"; +const Pixels = "number >= 0"; +const Side = type("'left' | 'right' | 'top' | 'bottom'"); +export type Side = typeof Side.infer; +const Paintable = ThemeColor.or(Color); + +const GradientStop = type({ + "+": "reject", + color: Paintable, + /** Percent along the gradient. */ + at: "0 <= number <= 100", + "opacity?": Fraction, +}); + +/** A colour, or a gradient of two or more stops; a linear angle is in CSS degrees, where 180 runs top to bottom. */ +export const Paint = Paintable.or({ + "+": "reject", + type: "'linear'", + angle: "number = 180", + stops: GradientStop.array().atLeastLength(2), +}).or({ + "+": "reject", + type: "'radial'", + stops: GradientStop.array().atLeastLength(2), +}); +export type Paint = typeof Paint.infer; + +export const Border = type({ + "+": "reject", + width: "number > 0", + color: Paintable, + "opacity?": Fraction, + dash: "'solid' | 'dash' | 'dot' = 'solid'", +}); +export type Border = typeof Border.infer; + +/** An outer shadow, as CSS box-shadow describes one. */ +export const Shadow = type({ + "+": "reject", + x: "number", + y: "number", + blur: Pixels, + spread: "number = 0", + color: Paintable, + opacity: "0 <= number <= 1 = 1", +}); +export type Shadow = typeof Shadow.infer; + +/** One radius, or per corner in CSS order: top left, top right, bottom right, bottom left. */ +const Radius = type(Pixels).or([Pixels, Pixels, Pixels, Pixels]); +export type Radius = typeof Radius.infer; + +/** Position and size in canvas pixels; `id` names the element for animations and for Morph. */ +const placed = { + "+": "reject", + "id?": Text, + x: "number", + y: "number", + w: Pixels, + h: Pixels, + /** Clockwise degrees. */ + "rotation?": "number", + "opacity?": Fraction, + "link?": Link, +} as const; + +export const FreeRun = type({ + "+": "reject", + /** A newline is a line break. */ + text: "string", + "font?": Font, + /** Pixels; PowerPoint's smallest size is one point, which is 4/3 of a pixel. */ + "size?": "1.34 <= number <= 500", + "weight?": "100 <= number.integer <= 900", + "italic?": "boolean", + "underline?": "boolean", + "strike?": "boolean", + "color?": Paint, + /** Pixels between letters. */ + "letterSpacing?": "-100 <= number <= 100", + "link?": Link, +}); +export type FreeRun = typeof FreeRun.infer; + +export const FreeParagraph = type({ + "+": "reject", + runs: type("string").or(FreeRun).array(), + align: "'left' | 'center' | 'right' | 'justify' = 'left'", + /** Pixels from one baseline to the next. */ + "lineHeight?": "0 < number <= 2000", + spaceBefore: "0 <= number <= 2000 = 0", + spaceAfter: "0 <= number <= 2000 = 0", +}); +export type FreeParagraph = typeof FreeParagraph.infer; + +export const TextElement = type({ + ...placed, + type: "'text'", + paragraphs: FreeParagraph.array().atLeastLength(1), + anchor: "'top' | 'middle' | 'bottom' = 'top'", + /** Off, the text keeps the breaks it was given. */ + wrap: "boolean = true", + /** Pixels; none when left out. */ + "insets?": { + "+": "reject", + top: Pixels, + right: Pixels, + bottom: Pixels, + left: Pixels, + }, + "fill?": Paint, + "border?": Border, + "shadow?": Shadow, +}); +export type TextElement = typeof TextElement.infer; + +const shape = { + ...placed, + type: "'shape'", + "fill?": Paint, + "border?": Border, + "shadow?": Shadow, +} as const; + +/** A line runs from the top left to the bottom right of its box, mirrored by `flip`, and draws with its border. */ +export const ShapeElement = type({ ...shape, geometry: "'rect' | 'ellipse'" }) + .or({ ...shape, geometry: "'roundRect'", radius: Radius }) + .or({ + ...placed, + type: "'shape'", + geometry: "'line'", + "flip?": "'horizontal' | 'vertical'", + border: Border, + "shadow?": Shadow, + }); +export type ShapeElement = typeof ShapeElement.infer; + +/** `image` is a PNG or JPEG; with `svg`, viewers that read SVG draw it and the rest fall back to `image`. */ +export const PictureElement = type({ + ...placed, + w: "number > 0", + h: "number > 0", + type: "'image'", + image: AssetRef, + "svg?": AssetRef, + fit: "'fill' | 'contain' | 'cover' = 'fill'", + /** Fractions of the image cut from each side; used with fit 'fill'. */ + "crop?": { + "+": "reject", + top: Fraction, + right: Fraction, + bottom: Fraction, + left: Fraction, + }, + "radius?": Radius, + "border?": Border, + "shadow?": Shadow, +}); +export type PictureElement = typeof PictureElement.infer; + +/** How deep groups nest. */ +export const GROUP_DEPTH = 10; + +/** Children are in canvas pixels too; later elements, here and on the slide, draw on top. */ +export const { FreeElement } = scope({ + TextElement, + ShapeElement, + PictureElement, + FreeElement: [ + "TextElement | ShapeElement | PictureElement", + "|", + { + "+": "reject", + "id?": Text, + type: "'group'", + "opacity?": Fraction, + children: "FreeElement[] >= 1", + }, + ], +}).export(); +export type FreeElement = typeof FreeElement.infer; +export type GroupElement = Extract; + +const Milliseconds = "0 <= number.integer <= 3600000"; + +export const Transition = type({ + "+": "reject", + type: "'fade' | 'morph'", + duration: "100 <= number.integer <= 60000 = 700", +}).or({ + "+": "reject", + type: "'push' | 'wipe'", + from: Side.default("right"), + duration: "100 <= number.integer <= 60000 = 700", +}); +export type Transition = typeof Transition.infer; + +const PATH_ARGUMENTS: Record = { M: 2, L: 2, C: 6, Z: 0, E: 0 }; + +/** OOXML motion path syntax: M, L, C, Z and E with numbers as fractions of the slide, relative to where the element starts. */ +const MotionPath = type("string").narrow((path, ctx) => { + const tokens = path.trim().split(/[\s,]+/); + if (tokens[0] !== "M") return ctx.mustBe("a path starting with M"); + let at = 0; + for (let command = tokens[at]; command !== undefined; command = tokens[at]) { + const count = PATH_ARGUMENTS[command]; + if (count === undefined) + return ctx.mustBe("a path of M, L, C, Z and E commands with numbers"); + const numbers = tokens.slice(at + 1, at + 1 + count); + if ( + numbers.length < count || + numbers.some((n) => !Number.isFinite(Number(n))) + ) + return ctx.mustBe(`a path whose ${command} has ${count} numbers`); + at += 1 + count; + } + return true; +}); + +const animation = { + "+": "reject", + /** The `id` of an element directly on the same slide; a group animates as a whole. */ + target: Text, + trigger: "'onClick' | 'withPrevious' | 'afterPrevious' = 'onClick'", + duration: "1 <= number.integer <= 3600000 = 500", + delay: `${Milliseconds} = 0`, + /** Fractions of the duration spent speeding up and slowing down. */ + "easing?": { "+": "reject", accel: Fraction, decel: Fraction }, + /** Times to play, or 'indefinite'. */ + "repeat?": type("1 <= number <= 1000").or("'indefinite'"), +} as const; + +/** Played in array order; each trigger is relative to the animation before it. */ +export const Animation = type({ + ...animation, + effect: "'fade' | 'zoom'", + kind: "'entrance' | 'exit'", +}) + .or({ + ...animation, + effect: "'fly' | 'wipe'", + kind: "'entrance' | 'exit'", + /** The edge the element enters from or exits to. */ + side: Side, + }) + .or({ ...animation, effect: "'spin'", degrees: "number = 360" }) + .or({ ...animation, effect: "'scale'", scale: "0 < number <= 100 = 1.5" }) + .or({ ...animation, effect: "'pulse'" }) + .or({ ...animation, effect: "'path'", path: MotionPath }); +export type Animation = typeof Animation.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 slide = { + "+": "reject", + "notes?": "string", + "transition?": Transition, +} as const; const Column = type({ "+": "reject", heading: Text, body: Blocks }); @@ -151,9 +409,17 @@ export const Slide = type({ "caption?": "string", fit: Fit, }) - .or({ ...slide, layout: "'blank'", body: Blocks }); + .or({ ...slide, layout: "'blank'", body: Blocks }) + .or({ + ...slide, + layout: "'free'", + "background?": Paint, + elements: FreeElement.array(), + "animations?": Animation.array(), + }); export type Slide = typeof Slide.infer; -export type Layout = Slide["layout"]; +/** The layouts with placeholders; a free slide draws on the blank layout. */ +export type Layout = Exclude; export const Deck = type({ "+": "reject", @@ -218,37 +484,88 @@ function issueAt(path: Path, message: string): DeckIssue { const isObject = (value: unknown): value is Record => typeof value === "object" && value !== null; -/** Lists nested past LIST_DEPTH, found without recursion before the schema's own recursion can overflow. */ -function deepLists(input: unknown): DeckIssue[] { +const NESTING = [ + { + key: "items", + limit: LIST_DEPTH, + noun: "lists", + opens: (value: Record, depth: number) => + depth > 0 || value.type === "list", + }, + { + key: "children", + limit: GROUP_DEPTH, + noun: "groups", + opens: (value: Record) => value.type === "group", + }, +] as const; + +type Depths = Record<(typeof NESTING)[number]["key"], number>; + +/** Lists and groups nested past their limits, found without recursion before the schema's own recursion can overflow. */ +function deepNesting(input: unknown): DeckIssue[] { const issues: DeckIssue[] = []; const pending = [ { value: isObject(input) ? input.slides : undefined, path: ["slides"] as Path, - depth: 0, + depths: { items: 0, children: 0 } satisfies Depths, }, ]; for (let next = pending.pop(); next; next = pending.pop()) { - const { value, path, depth } = next; + const { value, path, depths } = next; if (!isObject(value)) continue; const entries = Object.entries(value).reverse(); - const opensList = - Array.isArray(value.items) && (depth > 0 || value.type === "list"); for (const [key, child] of entries) { const childPath = [...path, Array.isArray(value) ? Number(key) : key]; - if (key !== "items" || !opensList) - pending.push({ value: child, path: childPath, depth }); - else if (depth < LIST_DEPTH) - pending.push({ value: child, path: childPath, depth: depth + 1 }); + const nesting = NESTING.find( + (each) => + key === each.key && + Array.isArray(child) && + each.opens(value, depths[each.key]), + ); + if (!nesting) pending.push({ value: child, path: childPath, depths }); + else if (depths[nesting.key] < nesting.limit) + pending.push({ + value: child, + path: childPath, + depths: { ...depths, [nesting.key]: depths[nesting.key] + 1 }, + }); else issues.push( - issueAt(childPath, `nests lists deeper than ${LIST_DEPTH} levels`), + issueAt( + childPath, + `nests ${nesting.noun} deeper than ${nesting.limit} levels`, + ), ); } } return issues; } +/** Every element on a free slide with its path, groups before their children, without recursion. */ +export function* freeElements( + elements: readonly FreeElement[], + path: Path, +): Generator<[FreeElement, Path]> { + const pending = elements + .map((element, index): [FreeElement, Path] => [element, [...path, index]]) + .reverse(); + for (let next = pending.pop(); next; next = pending.pop()) { + yield next; + const [element, at] = next; + if (element.type === "group") + pending.push( + ...element.children + .map((child, index): [FreeElement, Path] => [ + child, + [...at, "children", index], + ]) + .reverse(), + ); + } +} + function* textRuns(text: RichText, path: Path): Generator<[Run, Path]> { if (typeof text === "string") return; for (const [index, run] of text.entries()) @@ -269,7 +586,22 @@ function* itemRuns( } } -function* slideRuns(slide: Slide, index: number): Generator<[Run, Path]> { +type Linked = { link?: Link }; + +function* slideRuns(slide: Slide, index: number): Generator<[Linked, Path]> { + if (slide.layout === "free") + for (const [element, path] of freeElements(slide.elements, [ + "slides", + index, + "elements", + ])) { + if (element.type !== "group") yield [element, path]; + if (element.type !== "text") continue; + for (const [number, paragraph] of element.paragraphs.entries()) + for (const [position, run] of paragraph.runs.entries()) + if (typeof run !== "string") + yield [run, [...path, "paragraphs", number, "runs", position]]; + } for (const region of slideRegions(slide)) for (const [position, block] of region.blocks.entries()) { const path = ["slides", index, region.path, position]; @@ -280,6 +612,82 @@ function* slideRuns(slide: Slide, index: number): Generator<[Run, Path]> { } } +/** Duplicate ids, animations PowerPoint cannot play, and crops and shadows it cannot draw. */ +function freeSlideIssues(slide: Slide, index: number): DeckIssue[] { + if (slide.layout !== "free") return []; + const issues: DeckIssue[] = []; + const ids = new Set(); + const grouped = new Set(); + const slidePath = ["slides", index, "elements"]; + for (const [element, path] of freeElements(slide.elements, slidePath)) { + if (element.id !== undefined && ids.has(element.id)) + issues.push( + issueAt( + [...path, "id"], + `repeats the id "${element.id}" on this slide`, + ), + ); + if (element.id !== undefined) ids.add(element.id); + if (element.id !== undefined && path.length > slidePath.length + 1) + grouped.add(element.id); + if (element.type === "group") continue; + if (element.type === "image" && element.crop) { + const { top, right, bottom, left } = element.crop; + if (element.fit !== "fill") + issues.push( + issueAt([...path, "crop"], `needs fit 'fill', not '${element.fit}'`), + ); + if (left + right >= 1 || top + bottom >= 1) + issues.push( + issueAt([...path, "crop"], "cuts away the whole image on one axis"), + ); + } + if ( + element.shadow && + element.shadow.spread < -Math.min(element.w, element.h) / 2 + ) + issues.push( + issueAt( + [...path, "shadow", "spread"], + "shrinks the shadow past nothing; it can be no less than minus half the shorter side", + ), + ); + } + if (!slide.animations) return issues; + let endless = false; + for (const [position, animation] of slide.animations.entries()) { + const path = ["slides", index, "animations", position]; + if (!ids.has(animation.target)) + issues.push( + issueAt( + [...path, "target"], + `names "${animation.target}", which no element on this slide has as its id`, + ), + ); + if (grouped.has(animation.target)) + issues.push( + issueAt( + [...path, "target"], + `names "${animation.target}", which is inside a group; animate the whole group or an element directly on the slide`, + ), + ); + if (animation.trigger === "afterPrevious" && endless) + issues.push( + issueAt( + [...path, "trigger"], + "waits for an animation that repeats indefinitely and never ends", + ), + ); + if (animation.trigger !== "withPrevious") endless = false; + if (animation.repeat === "indefinite") endless = true; + if (animation.easing && animation.easing.accel + animation.easing.decel > 1) + issues.push( + issueAt([...path, "easing"], "has accel and decel adding up past 1"), + ); + } + return issues; +} + function missingSlideLinks({ slides }: Deck): DeckIssue[] { return slides .flatMap((slide, index) => [...slideRuns(slide, index)]) @@ -295,11 +703,14 @@ function missingSlideLinks({ slides }: Deck): DeckIssue[] { } export function validateDeck(input: unknown): DeckValidation { - const tooDeep = deepLists(input); + const tooDeep = deepNesting(input); if (tooDeep.length > 0) return { ok: false, issues: tooDeep }; const deck = Deck(input); if (!(deck instanceof type.errors)) { - const issues = missingSlideLinks(deck); + const issues = [ + ...deck.slides.flatMap(freeSlideIssues), + ...missingSlideLinks(deck), + ]; return issues.length === 0 ? { ok: true, deck } : { ok: false, issues }; } return { diff --git a/packages/slidedeck/src/free.ts b/packages/slidedeck/src/free.ts new file mode 100644 index 00000000..2e5250a8 --- /dev/null +++ b/packages/slidedeck/src/free.ts @@ -0,0 +1,324 @@ +import { + freeElements, + type AssetRef, + type Border, + type FreeElement, + type FreeParagraph, + type FreeRun, + type GroupElement, + type Link, + type Paint, + type PictureElement, + type Radius, + type Shadow, + type ShapeElement, + type TextElement, +} from "./deck.ts"; +import { contain, coverCrop, type ImageInfo } from "./image.ts"; +import type { Box } from "./layouts.ts"; +import { colorFill, colorXml } from "./theme.ts"; +import { escapeXml } from "./xml.ts"; + +/** What drawing needs from the slide part it draws into. */ +export type DrawingPart = { + nextId(): number; + image(ref: AssetRef): { info: ImageInfo; relId: string }; + svg(ref: AssetRef): string; + hyperlink(link: Link, ownColor?: boolean): string; +}; + +/** A shape an animation can target; `build` marks the shapes PowerPoint lists in the build list. */ +export type Target = { spid: number; build: boolean }; + +const EMU_PER_PIXEL = 9525; +const emu = (pixels: number) => Math.round(pixels * EMU_PER_PIXEL); +// Font sizes and spacing are in hundredths of a point, and a CSS pixel is three quarters of a point. +const centipoints = (pixels: number) => Math.round(pixels * 75); +const DEGREE = 60000; +const angle = (degrees: number) => + Math.round((((degrees % 360) + 360) % 360) * DEGREE); +const percent = (fraction: number) => Math.round(fraction * 100000); + +const boxOf = ({ x, y, w, h }: Box): Box => ({ + x: emu(x), + y: emu(y), + w: emu(w), + h: emu(h), +}); + +const FLIPS = { horizontal: ' flipH="1"', vertical: ' flipV="1"' } as const; + +function xfrm({ x, y, w, h }: Box, rotation = 0, flip?: keyof typeof FLIPS) { + const rot = angle(rotation); + return ``; +} + +export function paintXml(paint: Paint, opacity = 1) { + if (typeof paint === "string") return colorFill(paint, opacity); + const stops = paint.stops + .map( + (stop) => + `${colorXml(stop.color, (stop.opacity ?? 1) * opacity)}`, + ) + .join(""); + // CSS measures from the top, clockwise; DrawingML from the left, so CSS 180 is DrawingML 90. + const shade = + paint.type === "linear" + ? `` + : ''; + return `${stops}${shade}`; +} + +const DASHES = { solid: "", dash: "dash", dot: "sysDot" } as const; + +function lineXml(border: Border | undefined, opacity: number) { + if (!border) return ""; + const dash = DASHES[border.dash]; + return `${colorFill(border.color, (border.opacity ?? 1) * opacity)}${dash ? `` : ""}`; +} + +function shadowXml(shadow: Shadow | undefined, box: Box, opacity: number) { + if (!shadow) return ""; + const { x, y, blur, spread, color } = shadow; + const direction = (Math.atan2(y, x) * 180) / Math.PI; + const scale = (size: number) => + size > 0 ? percent((size + 2 * emu(spread)) / size) : 100000; + return `${colorXml(color, shadow.opacity * opacity)}`; +} + +const RECT = ''; + +/** DrawingML rounds every corner, two same-side corners or two diagonal ones; other mixes take the larger radius per side. */ +function cornersXml(radius: Radius, { w, h }: Box) { + const [tl, tr, br, bl] = + typeof radius === "number" ? [radius, radius, radius, radius] : radius; + const shortSide = Math.min(w, h); + const adjust = (corner: number) => + shortSide === 0 ? 0 : percent(Math.min(emu(corner) / shortSide, 0.5)); + const preset = (name: string, ...corners: number[]) => + `${corners + .map( + (corner, index) => + ``, + ) + .join("")}`; + if (tl === tr && tr === br && br === bl) return preset("roundRect", tl); + if (tl === br && tr === bl) return preset("round2DiagRect", tl, tr); + return preset("round2SameRect", Math.max(tl, tr), Math.max(br, bl)); +} + +function geometryXml(shape: ShapeElement, box: Box) { + switch (shape.geometry) { + case "roundRect": + return cornersXml(shape.radius, box); + case "rect": + return RECT; + default: + return ``; + } +} + +const ALIGN = { + left: "l", + center: "ctr", + right: "r", + justify: "just", +} as const; +const ANCHOR = { top: "t", middle: "ctr", bottom: "b" } as const; + +function runXml(run: FreeRun, part: DrawingPart, opacity: number) { + const { text, font, size, weight, italic, underline, strike } = run; + const attributes = [ + size === undefined ? "" : ` sz="${centipoints(size)}"`, + weight === undefined ? "" : ` b="${weight >= 600 ? 1 : 0}"`, + italic === undefined ? "" : ` i="${italic ? 1 : 0}"`, + underline === undefined ? "" : ` u="${underline ? "sng" : "none"}"`, + strike === undefined + ? "" + : ` strike="${strike ? "sngStrike" : "noStrike"}"`, + run.letterSpacing === undefined + ? "" + : ` spc="${centipoints(run.letterSpacing)}"`, + ].join(""); + // Text without its own colour follows the theme's, which carries no alpha of its own. + const color = run.color ?? (opacity < 1 ? "text" : undefined); + const typeface = font && escapeXml(font); + const children = [ + color === undefined ? "" : paintXml(color, opacity), + typeface + ? `` + : "", + run.link === undefined + ? "" + : part.hyperlink(run.link, run.color !== undefined), + ].join(""); + const properties = children + ? `${children}` + : ``; + return text + .split(/\r?\n/) + .map((line) => `${properties}${escapeXml(line)}`) + .join(`${properties}`); +} + +function paragraphXml( + paragraph: FreeParagraph, + part: DrawingPart, + opacity: number, +) { + const spacing = (tag: string, pixels: number) => + ``; + const properties = `${paragraph.lineHeight === undefined ? "" : spacing("lnSpc", paragraph.lineHeight)}${spacing("spcBef", paragraph.spaceBefore)}${spacing("spcAft", paragraph.spaceAfter)}`; + const runs = paragraph.runs.map((run) => + typeof run === "string" ? { text: run } : run, + ); + if (runs.every((run) => run.text === "")) + return `${properties}`; + return `${properties}${runs.map((run) => runXml(run, part, opacity)).join("")}`; +} + +type Drawn = { xml: string; spid: number; build: boolean }; + +function nonVisual( + part: DrawingPart, + element: { id?: string; link?: Link }, + kind: string, +) { + const spid = part.nextId(); + const name = escapeXml(element.id ?? `${kind} ${spid}`); + const link = element.link === undefined ? "" : part.hyperlink(element.link); + return { + spid, + cNvPr: link + ? `${link}` + : ``, + }; +} + +function textXml(element: TextElement, part: DrawingPart, opacity: number) { + const { spid, cNvPr } = nonVisual(part, element, "Text"); + const box = boxOf(element); + const insets = element.insets ?? { top: 0, right: 0, bottom: 0, left: 0 }; + const body = ``; + const paragraphs = element.paragraphs + .map((paragraph) => paragraphXml(paragraph, part, opacity)) + .join(""); + const fill = element.fill ? paintXml(element.fill, opacity) : ""; + return { + spid, + build: true, + xml: `${cNvPr}${xfrm(box, element.rotation)}${RECT}${fill}${lineXml(element.border, opacity)}${shadowXml(element.shadow, box, opacity)}${body}${paragraphs}`, + }; +} + +function shapeXml(element: ShapeElement, part: DrawingPart, opacity: number) { + const { spid, cNvPr } = nonVisual(part, element, "Shape"); + const box = boxOf(element); + const fill = + "fill" in element && element.fill + ? paintXml(element.fill, opacity) + : ""; + return { + spid, + build: true, + xml: `${cNvPr}${xfrm(box, element.rotation, "flip" in element ? element.flip : undefined)}${geometryXml(element, box)}${fill}${lineXml(element.border, opacity)}${shadowXml(element.shadow, box, opacity)}`, + }; +} + +const SVG_BLIP = (relId: string) => + ``; + +function sourceRectXml(element: PictureElement, info: ImageInfo, area: Box) { + if (element.fit === "cover") return coverCrop(info, area); + const { crop } = element; + if (!crop) return ""; + return ``; +} + +function pictureXml( + element: PictureElement, + part: DrawingPart, + opacity: number, +) { + const { spid, cNvPr } = nonVisual(part, element, "Picture"); + const { info, relId } = part.image(element.image); + const area = boxOf(element); + const box = element.fit === "contain" ? contain(info, area) : area; + const blipChildren = `${opacity < 1 ? `` : ""}${element.svg ? SVG_BLIP(part.svg(element.svg)) : ""}`; + const blip = blipChildren + ? `${blipChildren}` + : ``; + const geometry = element.radius ? cornersXml(element.radius, box) : RECT; + const line = element.border ? lineXml(element.border, opacity) : ""; + return { + spid, + build: false, + xml: `${cNvPr}${blip}${sourceRectXml(element, info, area)}${xfrm(box, element.rotation)}${geometry}${line}${shadowXml(element.shadow, box, opacity)}`, + }; +} + +/** The box around a group's descendants, so the group's child space is the slide's own. */ +function groupBox(group: GroupElement): Box { + const boxes = [...freeElements(group.children, [])] + .map(([element]) => element) + .filter((element) => element.type !== "group") + .map(boxOf); + const left = Math.min(...boxes.map((box) => box.x)); + const top = Math.min(...boxes.map((box) => box.y)); + const right = Math.max(...boxes.map((box) => box.x + box.w)); + const bottom = Math.max(...boxes.map((box) => box.y + box.h)); + return { x: left, y: top, w: right - left, h: bottom - top }; +} + +function groupXml( + group: GroupElement, + part: DrawingPart, + targets: Map, + opacity: number, +) { + const { spid, cNvPr } = nonVisual(part, group, "Group"); + const { x, y, w, h } = groupBox(group); + const children = group.children + .map((child) => elementXml(child, part, targets, opacity)) + .join(""); + return { + spid, + build: false, + xml: `${cNvPr}${children}`, + }; +} + +function drawnXml( + element: FreeElement, + part: DrawingPart, + targets: Map, + opacity: number, +): Drawn { + switch (element.type) { + case "text": + return textXml(element, part, opacity); + case "shape": + return shapeXml(element, part, opacity); + case "image": + return pictureXml(element, part, opacity); + case "group": + return groupXml(element, part, targets, opacity); + } +} + +/** Draws one element, recording the shape id of each element with an `id` for animations. */ +export function elementXml( + element: FreeElement, + part: DrawingPart, + targets: Map, + inherited = 1, +): string { + const opacity = inherited * (element.opacity ?? 1); + const drawn = drawnXml(element, part, targets, opacity); + if (element.id !== undefined) + targets.set(element.id, { spid: drawn.spid, build: drawn.build }); + return drawn.xml; +} + +export const backgroundXml = (paint: Paint) => + `${paintXml(paint)}`; diff --git a/packages/slidedeck/src/image.ts b/packages/slidedeck/src/image.ts index b24ae912..199c5fa5 100644 --- a/packages/slidedeck/src/image.ts +++ b/packages/slidedeck/src/image.ts @@ -1,3 +1,5 @@ +import type { Box } from "./layouts.ts"; + export type ImageFormat = "png" | "jpeg"; export type ImageInfo = { format: ImageFormat; width: number; height: number }; @@ -41,3 +43,43 @@ export function readImage(bytes: Uint8Array): ImageInfo | undefined { const info = readHeader(bytes); return info && info.width > 0 && info.height > 0 ? info : undefined; } + +const PROLOG = + /^\uFEFF?(?:\s|<\?[\s\S]*?\?>||]*(?:\[[\s\S]*?\])?\s*>)*/; +const CHUNK = 4096; + +/** Whether the document's root element is , decoding only as far as the end of the prolog. */ +export function isSvg(bytes: Uint8Array) { + const decoder = new TextDecoder(); + let text = ""; + for (let at = 0; at < bytes.length; at += CHUNK) { + const end = at + CHUNK >= bytes.length; + text += decoder.decode(bytes.subarray(at, at + CHUNK), { stream: !end }); + const rest = text.replace(PROLOG, ""); + // An unfinished comment, declaration or tag name needs the next chunk to decide. + if (end || (rest.length > 4 && !/^<[?!]/.test(rest))) + return /^]/.test(rest); + } + return false; +} + +export 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. +export 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 + ? `` + : ``; +} diff --git a/packages/slidedeck/src/layouts.ts b/packages/slidedeck/src/layouts.ts index fb29a3bf..4ff2c420 100644 --- a/packages/slidedeck/src/layouts.ts +++ b/packages/slidedeck/src/layouts.ts @@ -317,7 +317,7 @@ export const LIST_LEVELS = LIST_LEVEL_STYLES.map( ``, ).join(""); -export function masterXml(logo: string) { +export function masterXml(logo: string, layouts: readonly Layout[]) { // 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" @@ -327,10 +327,12 @@ export function masterXml(logo: string) { const shapes = [...content, FOOTER, SLIDE_NUMBER] .map((placeholder, index) => templateShape(index + 2, placeholder)) .join(""); - const layoutIds = LAYOUT_ORDER.map( - (_, index) => - ``, - ).join(""); + const layoutIds = layouts + .map( + (_, index) => + ``, + ) + .join(""); const level = (size: number, extra = "") => ``; return `${XML_DECLARATION}${GROUP_HEADER}${shapes}${logo}${COLOR_MAP}${layoutIds}${level(2000, ' marL="0" indent="0"')}${LIST_LEVELS}${level(1800)}`; diff --git a/packages/slidedeck/src/motion.ts b/packages/slidedeck/src/motion.ts new file mode 100644 index 00000000..71d21d47 --- /dev/null +++ b/packages/slidedeck/src/motion.ts @@ -0,0 +1,304 @@ +import type { Animation, Side, Transition } from "./deck.ts"; +import type { Target } from "./free.ts"; +import { escapeXml } from "./xml.ts"; + +const MC = + 'xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"'; +const P14 = + 'xmlns:p14="http://schemas.microsoft.com/office/powerpoint/2010/main"'; +const P159 = + 'xmlns:p159="http://schemas.microsoft.com/office/powerpoint/2015/09/main"'; + +// The movement direction: content arriving from the right moves left; transitions take its initial. +const TOWARD: Record = { + left: "right", + right: "left", + top: "down", + bottom: "up", +}; + +function speed(duration: number) { + if (duration <= 500) return "fast"; + if (duration <= 750) return "med"; + return "slow"; +} + +/** An exact duration needs PowerPoint 2010 and Morph needs 2019; older viewers take the fallback, with Morph as a fade. */ +export function transitionXml(transition: Transition) { + const effect = + "from" in transition + ? `` + : ""; + const [requires, chosen] = + transition.type === "morph" + ? [`${P14} ${P159} Requires="p159"`, ''] + : [`${P14} Requires="p14"`, effect]; + const spd = speed(transition.duration); + return `${chosen}${effect}`; +} + +const PRESETS = { + fade: 10, + fly: 2, + wipe: 22, + zoom: 53, + scale: 6, + spin: 8, + pulse: 26, + path: 0, +} as const; + +const ZOOM_FROM_CENTER = 16; +const SUBTYPES: Record = { top: 1, right: 2, bottom: 4, left: 8 }; +const OFF_SLIDE: Record = { + left: ["ppt_x", "0-#ppt_w/2"], + right: ["ppt_x", "1+#ppt_w/2"], + top: ["ppt_y", "0-#ppt_h/2"], + bottom: ["ppt_y", "1+#ppt_h/2"], +}; +const NODE_TYPES = { + onClick: "clickEffect", + withPrevious: "withEffect", + afterPrevious: "afterEffect", +} as const; + +type Behavior = { + attributes?: string[]; + at?: number; + additive?: boolean; + autoReverse?: boolean; +}; + +class Timeline { + #lastId = 0; + readonly builds: { target: Target; grpId: number }[] = []; + + id() { + return ++this.#lastId; + } + + /** Each effect on a shape is its own build, numbered per shape. */ + grpId(target: Target) { + const grpId = this.builds.filter( + (build) => build.target.spid === target.spid, + ).length; + this.builds.push({ target, grpId }); + return grpId; + } + + behavior( + spid: number, + duration: number, + { + attributes = [], + at = 0, + additive = false, + autoReverse = false, + }: Behavior = {}, + ) { + const start = + at > 0 ? `` : ""; + const names = attributes.length + ? `${attributes.map((name) => `${name}`).join("")}` + : ""; + const node = `${start}` : `${node}/>`; + return `${timing}${names}`; + } + + visibility(spid: number, value: "visible" | "hidden", at = 0) { + return `${this.behavior(spid, 1, { attributes: ["style.visibility"], at })}`; + } + + move( + spid: number, + duration: number, + attribute: string, + from: string, + to: string, + ) { + const tav = (time: number, value: string) => + ``; + return `${this.behavior(spid, duration, { attributes: [attribute], additive: true })}${tav(0, from)}${tav(100000, to)}`; + } + + filter(spid: number, duration: number, filter: string, entering: boolean) { + return `${this.behavior(spid, duration)}`; + } + + scale(spid: number, duration: number, by: number, autoReverse = false) { + const size = Math.round(by * 100000); + return `${this.behavior(spid, duration, { autoReverse })}`; + } +} + +/** The behaviours that draw one effect; entrances show the shape first and exits hide it last. */ +function behaviors(timeline: Timeline, animation: Animation, spid: number) { + const { duration } = animation; + switch (animation.effect) { + case "spin": + return `${timeline.behavior(spid, duration, { attributes: ["r"] })}`; + case "scale": + return timeline.scale(spid, duration, animation.scale); + case "pulse": + return timeline.scale( + spid, + Math.max(1, Math.round(duration / 2)), + 1.05, + true, + ); + case "path": + return `${timeline.behavior(spid, duration, { attributes: ["ppt_x", "ppt_y"] })}`; + } + const entering = animation.kind === "entrance"; + const effect = changeEffect(timeline, animation, spid, entering); + return entering + ? `${timeline.visibility(spid, "visible")}${effect}` + : `${effect}${timeline.visibility(spid, "hidden", duration - 1)}`; +} + +type Change = Extract; + +function changeEffect( + timeline: Timeline, + animation: Change, + spid: number, + entering: boolean, +) { + const { duration } = animation; + switch (animation.effect) { + case "fade": + return timeline.filter(spid, duration, "fade", entering); + case "wipe": + return timeline.filter( + spid, + duration, + `wipe(${TOWARD[animation.side]})`, + entering, + ); + case "fly": { + const [attribute, away] = OFF_SLIDE[animation.side]; + const home = `#${attribute}`; + return timeline.move( + spid, + duration, + attribute, + entering ? away : home, + entering ? home : away, + ); + } + case "zoom": { + const grow = (attribute: string) => + timeline.move( + spid, + duration, + attribute, + entering ? "0" : `#${attribute}`, + entering ? `#${attribute}` : "0", + ); + return `${grow("ppt_w")}${grow("ppt_h")}${timeline.filter(spid, duration, "fade", entering)}`; + } + } +} + +function presetClass(animation: Animation) { + if ("kind" in animation) + return animation.kind === "entrance" ? "entr" : "exit"; + return animation.effect === "path" ? "path" : "emph"; +} + +function presetSubtype(animation: Animation) { + if ("side" in animation) return SUBTYPES[animation.side]; + return animation.effect === "zoom" ? ZOOM_FROM_CENTER : 0; +} + +function effectXml( + timeline: Timeline, + animation: Animation, + target: Target, + nodeType: string, +) { + const easing = animation.easing + ? `${animation.easing.accel > 0 ? ` accel="${Math.round(animation.easing.accel * 100000)}"` : ""}${animation.easing.decel > 0 ? ` decel="${Math.round(animation.easing.decel * 100000)}"` : ""}` + : ""; + const repeat = + animation.repeat === undefined + ? "" + : ` repeatCount="${animation.repeat === "indefinite" ? "indefinite" : Math.round(animation.repeat * 1000)}"`; + const id = timeline.id(); + const grpId = timeline.grpId(target); + return `${behaviors(timeline, animation, target.spid)}`; +} + +const playTime = (animation: Animation) => + animation.delay + + animation.duration * + (typeof animation.repeat === "number" ? animation.repeat : 1); + +/** Click groups of steps that play one after another, each step's effects together. */ +function clickGroups(animations: readonly Animation[]) { + const groups: Animation[][][] = []; + for (const animation of animations) { + const group = groups.at(-1); + if (!group || animation.trigger === "onClick") groups.push([[animation]]); + else if (animation.trigger === "withPrevious") + group.at(-1)?.push(animation); + else group.push([animation]); + } + return groups; +} + +/** The slide's timing tree; the first group starts with the slide when it does not wait for a click. */ +export function timingXml( + animations: readonly Animation[], + targets: ReadonlyMap, +) { + if (animations.length === 0) return ""; + const timeline = new Timeline(); + const root = timeline.id(); + const sequence = timeline.id(); + const target = (animation: Animation) => { + const found = targets.get(animation.target); + if (!found) + throw new Error(`animation target ${animation.target} was not drawn`); + return found; + }; + const groups = clickGroups(animations).map((steps, index) => { + const first = steps[0]?.[0]; + const start = + index === 0 && first?.trigger !== "onClick" + ? `` + : ''; + const groupId = timeline.id(); + let offset = 0; + const stepsXml = steps + .map((step) => { + const stepId = timeline.id(); + const effects = step + .map((animation) => + effectXml( + timeline, + animation, + target(animation), + NODE_TYPES[animation.trigger], + ), + ) + .join(""); + const xml = `${effects}`; + offset += Math.max(...step.map(playTime)); + return xml; + }) + .join(""); + return `${start}${stepsXml}`; + }); + const builds = timeline.builds + .filter(({ target }) => target.build) + .map( + ({ target, grpId }) => + ``, + ) + .join(""); + const slideTarget = (event: string) => + ``; + return `${groups.join("")}${slideTarget("onPrev")}${slideTarget("onNext")}${builds ? `${builds}` : ""}`; +} diff --git a/packages/slidedeck/src/theme.ts b/packages/slidedeck/src/theme.ts index 50f87d9f..b29c6ccd 100644 --- a/packages/slidedeck/src/theme.ts +++ b/packages/slidedeck/src/theme.ts @@ -16,9 +16,19 @@ const SCHEME_COLORS: Record = { const isThemeColor = (color: string): color is ThemeColor => Object.hasOwn(SCHEME_COLORS, color); -/** A fill that follows the theme for a token name, or a fixed hex colour. */ -export const colorFill = (color: string) => - `${isThemeColor(color) ? `` : srgb(color)}`; +/** A colour that follows the theme for a token name, or a fixed hex colour, with an opacity below 1 as alpha. */ +export function colorXml(color: string, opacity = 1) { + const alpha = Math.round(opacity * 100000); + const [tag, value] = isThemeColor(color) + ? ["schemeClr", SCHEME_COLORS[color]] + : ["srgbClr", hex(color)]; + return alpha < 100000 + ? `` + : ``; +} + +export const colorFill = (color: string, opacity = 1) => + `${colorXml(color, opacity)}`; export const COLOR_MAP = ''; diff --git a/packages/slidedeck/src/write.ts b/packages/slidedeck/src/write.ts index 6b9abad7..211a648f 100644 --- a/packages/slidedeck/src/write.ts +++ b/packages/slidedeck/src/write.ts @@ -2,6 +2,7 @@ import { zip, type ZipEntry } from "@corbits/zip"; import { assetKey, + freeElements, slideRegions, validateDeck, type AssetRef, @@ -10,6 +11,7 @@ import { type DeckInput, type DeckIssue, type Element, + type Layout, type Link, type ListItem, type RichText, @@ -17,7 +19,14 @@ import { type Slide, type TextBlock, } from "./deck.ts"; -import { readImage, type ImageInfo } from "./image.ts"; +import { backgroundXml, elementXml, type Target } from "./free.ts"; +import { + contain, + coverCrop, + isSvg, + readImage, + type ImageInfo, +} from "./image.ts"; import { FREE_AREA, GROUP_HEADER, @@ -36,6 +45,7 @@ import { type Placeholder, type Role, } from "./layouts.ts"; +import { timingXml, transitionXml } from "./motion.ts"; import { COLOR_MAP, colorFill, themeXml } from "./theme.ts"; import { NAMESPACES, @@ -55,8 +65,13 @@ export class DeckError extends Error { } } -type Media = { name: string; data: Uint8Array; info: ImageInfo }; -type AssetUse = { ref: AssetRef; path: string; slide?: number }; +/** SVG media has no info; its picture always carries a raster fallback. */ +type Media = { + name: string; + data: Uint8Array; + info: ImageInfo | undefined; +}; +type AssetUse = { ref: AssetRef; path: string; slide?: number; svg?: true }; const CONTENT_TYPE = "application/vnd.openxmlformats-officedocument"; const PRESENTATION = `${CONTENT_TYPE}.presentationml`; @@ -163,6 +178,14 @@ function assetUses(deck: Deck): AssetUse[] { path: `slides[${slide}].${path}[${index}].image`, slide, }); + if (content.layout !== "free") continue; + for (const [element, path] of freeElements(content.elements, [])) { + if (element.type !== "image") continue; + const at = `slides[${slide}].elements${path.map((key) => (typeof key === "number" ? `[${key}]` : `.${key}`)).join("")}`; + uses.push({ ref: element.image, path: `${at}.image`, slide }); + if (element.svg) + uses.push({ ref: element.svg, path: `${at}.svg`, slide, svg: true }); + } } return uses; } @@ -170,23 +193,36 @@ function assetUses(deck: Deck): AssetUse[] { const issueAt = ({ path, slide }: AssetUse, message: string): DeckIssue => slide === undefined ? { path, message } : { slide, path, message }; +const mediaKey = (ref: AssetRef, svg?: true) => + svg ? `svg:${assetKey(ref)}` : assetKey(ref); + /** 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); + const key = mediaKey(use.ref, use.svg); if (byKey.has(key)) continue; - const data = assets.get(key); + const data = assets.get(assetKey(use.ref)); if (!data) { issues.push( issueAt( use, - `${use.path} names asset "${key}", which was not provided`, + `${use.path} names asset "${assetKey(use.ref)}", which was not provided`, ), ); continue; } + if (use.svg) { + if (isSvg(data)) + byKey.set(key, { + name: `image${byKey.size + 1}.svg`, + data, + info: undefined, + }); + else issues.push(issueAt(use, `${use.path} is not an SVG image`)); + continue; + } const info = readImage(data); if (!info) { issues.push( @@ -203,36 +239,21 @@ function resolveMedia(deck: Deck, assets: Assets) { if (issues.length > 0) throw new DeckError(issues); return { items: [...byKey.values()], - get(ref: AssetRef) { - const media = byKey.get(assetKey(ref)); + get(ref: AssetRef, svg?: true) { + const media = byKey.get(mediaKey(ref, svg)); if (!media) throw new Error(`asset ${assetKey(ref)} was not resolved`); return media; }, + raster(ref: AssetRef) { + const { name, info } = this.get(ref); + if (!info) + throw new Error(`asset ${assetKey(ref)} is not a raster image`); + return { name, info }; + }, }; } 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; @@ -263,6 +284,9 @@ const placeholderShape = ( ) => `${placeholderTag(placeholder)}${box ? xfrm(box) : ""}${paragraphs}`; +const LINK_OWN_COLOR = + ''; + class SlidePart { readonly rels = new Relationships(); readonly shapes: string[] = []; @@ -275,18 +299,25 @@ class SlidePart { } image(ref: AssetRef) { - const media = this.media.get(ref); + const media = this.media.raster(ref); return { info: media.info, relId: this.rels.add("image", `../media/${media.name}`), }; } - hyperlink(link: Link) { + svg(ref: AssetRef) { + return this.rels.add("image", `../media/${this.media.get(ref, true).name}`); + } + + /** With `ownColor`, the linked text keeps its colour instead of the theme's link colour in PowerPoint 2019 and later. */ + hyperlink(link: Link, ownColor = false) { + const color = ownColor ? LINK_OWN_COLOR : ""; + const close = color ? `>${color}` : "/>"; if (typeof link === "string") - return ``; + return ``; + return `, part: SlidePart, ): Partial> { switch (slide.layout) { @@ -403,12 +434,24 @@ function imagePlaceholder( ); } -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`, - ); +/** A free slide draws on the blank layout without the master's logo, footer or slide number. */ +function freeSlide(slide: Slide & { layout: "free" }, part: SlidePart) { + const targets = new Map(); + for (const element of slide.elements) + part.shapes.push(elementXml(element, part, targets)); + return { + background: slide.background ? backgroundXml(slide.background) : "", + timing: slide.animations ? timingXml(slide.animations, targets) : "", + }; +} + +/** A placed slide takes its background from the master and has no animations. */ +function placedSlide( + deck: Deck, + slide: Exclude, + index: number, + part: SlidePart, +) { const content: Partial> = { ...textContent(slide, part), ...(deck.footer?.text && { footer: paragraph(deck.footer.text, part) }), @@ -429,12 +472,42 @@ function slideXml(deck: Deck, slide: Slide, index: number, media: MediaStore) { else if (text !== undefined) part.shapes.push(placeholderShape(part.nextId(), placeholder, text)); } + return { background: "", timing: "" }; +} + +// Keynote drops an unused Picture with Caption layout and warns about removed media on open. +const deckLayouts = (deck: Deck) => + LAYOUT_ORDER.filter( + (layout) => + layout !== "image" || + deck.slides.some((slide) => slide.layout === layout), + ); + +function slideXml( + deck: Deck, + slide: Slide, + index: number, + media: MediaStore, + layouts: readonly Layout[], +) { + const part = new SlidePart(media); + const layout = slide.layout === "free" ? "blank" : slide.layout; + part.rels.add( + "slideLayout", + `../slideLayouts/slideLayout${layouts.indexOf(layout) + 1}.xml`, + ); + const { background, timing } = + slide.layout === "free" + ? freeSlide(slide, part) + : placedSlide(deck, slide, index, part); if (slide.notes !== undefined) part.rels.add("notesSlide", `../notesSlides/notesSlide${index + 1}.xml`); + const transition = slide.transition ? transitionXml(slide.transition) : ""; + const masterShapes = slide.layout === "free" ? ' showMasterSp="0"' : ""; return { notes: slide.notes, rels: part.rels, - xml: `${XML_DECLARATION}${GROUP_HEADER}${part.shapes.join("")}`, + xml: `${XML_DECLARATION}${background}${GROUP_HEADER}${part.shapes.join("")}${transition}${timing}`, }; } @@ -445,16 +518,16 @@ function notesXml(notes: string) { 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) { +function contentTypes(deck: Deck, layouts: readonly Layout[]) { const override = (part: string, type: string) => ``; - return `${XML_DECLARATION}${[ + return `${XML_DECLARATION}${[ override("ppt/presentation.xml", `${PRESENTATION}.presentation.main+xml`), override( "ppt/slideMasters/slideMaster1.xml", `${PRESENTATION}.slideMaster+xml`, ), - ...LAYOUT_ORDER.map((_, index) => + ...layouts.map((_, index) => override( `ppt/slideLayouts/slideLayout${index + 1}.xml`, `${PRESENTATION}.slideLayout+xml`, @@ -495,7 +568,7 @@ function titleXml(deck: Deck) { return title ? `${escapeXml(title)}` : ""; } -function packageParts(deck: Deck) { +function packageParts(deck: Deck, layouts: readonly Layout[]) { const root = new Relationships(); root.add("officeDocument", "ppt/presentation.xml"); root.add( @@ -522,7 +595,7 @@ function packageParts(deck: Deck) { ); return [ - { path: "[Content_Types].xml", data: contentTypes(deck) }, + { path: "[Content_Types].xml", data: contentTypes(deck, layouts) }, { path: "_rels/.rels", data: root.xml() }, { path: "docProps/core.xml", @@ -557,12 +630,16 @@ function packageParts(deck: Deck) { ]; } -function masterParts(deck: Deck, media: MediaStore) { +function masterParts( + deck: Deck, + media: MediaStore, + layouts: readonly Layout[], +) { const rels = new Relationships(); - for (const index of LAYOUT_ORDER.keys()) + for (const index of layouts.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 logo = deck.theme.logo && media.raster(deck.theme.logo); const logoXml = logo ? picture({ id: 90, @@ -576,9 +653,12 @@ function masterParts(deck: Deck, media: MediaStore) { const notesMasterRels = new Relationships(); notesMasterRels.add("theme", "../theme/theme2.xml"); return [ - { path: "ppt/slideMasters/slideMaster1.xml", data: masterXml(logoXml) }, + { + path: "ppt/slideMasters/slideMaster1.xml", + data: masterXml(logoXml, layouts), + }, { path: "ppt/slideMasters/_rels/slideMaster1.xml.rels", data: rels.xml() }, - ...LAYOUT_ORDER.flatMap((layout, index) => [ + ...layouts.flatMap((layout, index) => [ { path: `ppt/slideLayouts/slideLayout${index + 1}.xml`, data: layoutXml(layout), @@ -596,10 +676,10 @@ function masterParts(deck: Deck, media: MediaStore) { ]; } -function slideParts(deck: Deck, media: MediaStore) { +function slideParts(deck: Deck, media: MediaStore, layouts: readonly Layout[]) { return deck.slides.flatMap((slide, index) => { const number = index + 1; - const { xml, rels, notes } = slideXml(deck, slide, index, media); + const { xml, rels, notes } = slideXml(deck, slide, index, media, layouts); if (notes === undefined) return [ { path: `ppt/slides/slide${number}.xml`, data: xml }, @@ -635,10 +715,11 @@ export async function writePptx( if (!checked.ok) throw new DeckError(checked.issues); const { deck } = checked; const media = resolveMedia(deck, assets); + const layouts = deckLayouts(deck); const entries: ZipEntry[] = [ - ...packageParts(deck), - ...masterParts(deck, media), - ...slideParts(deck, media), + ...packageParts(deck, layouts), + ...masterParts(deck, media, layouts), + ...slideParts(deck, media, layouts), ...media.items.map(({ name, data }) => ({ path: `ppt/media/${name}`, data, diff --git a/packages/slidedeck/test/fixture.ts b/packages/slidedeck/test/fixture.ts index b076d047..8ddddf03 100644 --- a/packages/slidedeck/test/fixture.ts +++ b/packages/slidedeck/test/fixture.ts @@ -39,10 +39,17 @@ export function solidPng( const logo = { path: "brand/logo.png" }; const mockup = { artifact: "mockup", version: 3 }; +const mark = { path: "brand/mark.svg" }; export const assets = new Map([ [assetKey(logo), solidPng(240, 80, [109, 40, 217])], [assetKey(mockup), solidPng(1600, 900, [200, 220, 255])], + [ + assetKey(mark), + new TextEncoder().encode( + '', + ), + ], ]); const paragraph = (text: string) => ({ type: "paragraph" as const, text }); @@ -67,8 +74,14 @@ export const deck: DeckInput = { title: "Builder overview", subtitle: "From idea to working app", notes: "Open with the problem.", + transition: { type: "fade", duration: 400 }, + }, + { + layout: "section", + title: "Why now", + subtitle: "Three shifts", + transition: { type: "push", from: "bottom" }, }, - { layout: "section", title: "Why now", subtitle: "Three shifts" }, { layout: "content", title: "What Builder does", @@ -165,6 +178,7 @@ export const deck: DeckInput = { }, { layout: "comparison", + transition: { type: "wipe", from: "left", duration: 1200 }, title: "Before and after", left: { heading: "Before", @@ -200,5 +214,242 @@ export const deck: DeckInput = { ], notes: "Leave time for questions.", }, + { + layout: "free", + transition: { type: "morph", duration: 1500 }, + background: { + type: "linear", + angle: 135, + stops: [ + { color: "#0F1020", at: 0 }, + { color: "primary", at: 100, opacity: 0.9 }, + ], + }, + elements: [ + { + id: "glow", + type: "shape", + geometry: "ellipse", + x: 860, + y: -120, + w: 520, + h: 520, + opacity: 0.6, + fill: { + type: "radial", + stops: [ + { color: "accent", at: 0 }, + { color: "#F59E0B", at: 60, opacity: 0.4 }, + { color: "#F59E0B", at: 100, opacity: 0 }, + ], + }, + }, + { + id: "card", + type: "group", + children: [ + { + id: "card-back", + type: "shape", + geometry: "roundRect", + x: 80, + y: 200, + w: 560, + h: 360, + radius: 24, + fill: "background", + border: { width: 1, color: "#E5E7EB", dash: "solid" }, + shadow: { + x: 0, + y: 12, + blur: 32, + color: "#000000", + opacity: 0.25, + }, + }, + { + type: "shape", + geometry: "roundRect", + x: 80, + y: 200, + w: 560, + h: 8, + radius: [24, 24, 0, 0], + fill: "primary", + }, + { + type: "group", + opacity: 0.8, + children: [ + { + type: "shape", + geometry: "line", + x: 120, + y: 440, + w: 480, + h: 0, + border: { width: 2, color: "muted", dash: "dash" }, + }, + ], + }, + ], + }, + { + id: "headline", + type: "text", + x: 120, + y: 240, + w: 480, + h: 160, + anchor: "middle", + insets: { top: 4, right: 8, bottom: 4, left: 8 }, + link: 0, + paragraphs: [ + { + lineHeight: 64, + runs: [ + { + text: "Designed\nslides", + font: "Georgia", + size: 56, + weight: 800, + letterSpacing: -1, + color: { + type: "linear", + angle: 90, + stops: [ + { color: "primary", at: 0 }, + { color: "secondary", at: 100 }, + ], + }, + }, + ], + }, + { + align: "center", + spaceBefore: 12, + runs: [ + "Text, ", + { text: "italic", italic: true, weight: 300 }, + " and a ", + { + text: "link", + link: "https://corbits.dev", + color: "#0EA5E9", + underline: true, + }, + ], + }, + ], + }, + { + id: "cover", + type: "image", + image: mockup, + x: 700, + y: 200, + w: 500, + h: 300, + fit: "cover", + radius: [16, 0, 16, 0], + border: { width: 2, color: "#FFFFFF", dash: "dot", opacity: 0.5 }, + shadow: { x: 4, y: 8, blur: 16, spread: 2, color: "text" }, + }, + { + type: "image", + image: mockup, + x: 700, + y: 530, + w: 240, + h: 120, + fit: "contain", + rotation: -4, + }, + { + id: "mark", + type: "image", + image: logo, + svg: mark, + x: 980, + y: 530, + w: 120, + h: 120, + crop: { top: 0, right: 0.5, bottom: 0, left: 0 }, + opacity: 0.9, + link: "mailto:hello@corbits.dev", + }, + { + id: "tag", + type: "shape", + geometry: "rect", + x: 1120, + y: 560, + w: 80, + h: 40, + fill: "#10B981", + }, + ], + animations: [ + { + target: "card", + effect: "fade", + kind: "entrance", + trigger: "afterPrevious", + }, + { + target: "headline", + effect: "fly", + kind: "entrance", + side: "left", + trigger: "withPrevious", + delay: 200, + easing: { accel: 0, decel: 0.6 }, + }, + { target: "cover", effect: "zoom", kind: "entrance", duration: 700 }, + { + target: "tag", + effect: "wipe", + kind: "entrance", + side: "left", + trigger: "afterPrevious", + }, + { + target: "mark", + effect: "spin", + degrees: 180, + trigger: "withPrevious", + }, + { target: "tag", effect: "pulse", repeat: 3 }, + { target: "card", effect: "scale", scale: 1.1 }, + { + target: "glow", + effect: "path", + path: "M 0 0 L -0.1 0.05 E", + repeat: "indefinite", + trigger: "withPrevious", + }, + { target: "mark", effect: "fly", kind: "exit", side: "bottom" }, + { + target: "tag", + effect: "wipe", + kind: "exit", + side: "right", + trigger: "afterPrevious", + }, + { + target: "cover", + effect: "zoom", + kind: "exit", + trigger: "withPrevious", + }, + { + target: "headline", + effect: "fade", + kind: "exit", + trigger: "afterPrevious", + }, + ], + notes: "Every free-form element, transition and animation.", + }, ], }; diff --git a/packages/slidedeck/test/slidedeck.test.ts b/packages/slidedeck/test/slidedeck.test.ts index f0bcb1c4..5de5e5f9 100644 --- a/packages/slidedeck/test/slidedeck.test.ts +++ b/packages/slidedeck/test/slidedeck.test.ts @@ -2,7 +2,7 @@ import { expect, test } from "bun:test"; import { unzip } from "@corbits/zip"; -import { validateDeck, writePptx } from "../src/index.ts"; +import { validateDeck, writePptx, type DeckInput } from "../src/index.ts"; import { assets, deck } from "./fixture.ts"; const XML_TOKEN = @@ -45,11 +45,11 @@ test("the fixture deck writes the same bytes twice and every part reads back", a 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]) + for (const index of [1, 3, 8, 9]) expect(paths).toContain(`ppt/notesSlides/notesSlide${index}.xml`); expect( paths.filter((path) => /^ppt\/slides\/slide\d+\.xml$/.test(path)), - ).toHaveLength(8); + ).toHaveLength(9); const decoder = new TextDecoder(); const contentTypes = decoder.decode(parts[0]?.data); for (const path of paths.filter( @@ -113,6 +113,48 @@ test("the slide master's content placeholder is a typed body, which PowerPoint n expect(placeholders).toContain(''); }); +test("only a deck with an image slide carries the Picture with Caption layout, which Keynote otherwise warns about", async () => { + const written = async (input: DeckInput) => { + const parts = new Map( + (await unzip(await writePptx(input, assets))).map(({ path, data }) => [ + path, + new TextDecoder().decode(data), + ]), + ); + const layoutType = (path: string) => + parts.get(path)?.match(/]* type="(\w+)"/)?.[1]; + const layouts = [...parts.keys()].filter((path) => + /^ppt\/slideLayouts\/slideLayout\d+\.xml$/.test(path), + ); + return { + layouts: layouts.map(layoutType), + listed: parts + .get("ppt/slideMasters/slideMaster1.xml") + ?.match(/ + layoutType( + `ppt/slideLayouts/${parts.get(`ppt/slides/_rels/slide${index + 1}.xml.rels`)?.match(/slideLayouts\/(slideLayout\d+\.xml)/)?.[1]}`, + ), + ), + }; + }; + const withImages = await written(deck); + const withoutImages = await written({ + ...deck, + slides: deck.slides.map((slide) => + slide.layout === "image" ? { layout: "blank", body: [] } : slide, + ), + }); + + expect(withImages.layouts).toContain("picTx"); + expect(withImages.listed).toBe(withImages.layouts.length); + expect(withoutImages.layouts).not.toContain("picTx"); + expect(withoutImages.listed).toBe(withoutImages.layouts.length); + expect(withoutImages.slides).toEqual( + withImages.slides.map((type) => (type === "picTx" ? "blank" : type)), + ); +}); + test("a missing slide link or a list past five levels names the slide and field", () => { const nested = (levels: number) => { let item: unknown = "Too deep"; @@ -156,3 +198,129 @@ test("a missing slide link or a list past five levels names the slide and field" ], }); }); + +test("a free slide names the element of every problem, and groups past ten levels", () => { + const box = { x: 0, y: 0, w: 10, h: 10 }; + const image = { type: "image", image: { path: "a.png" }, ...box }; + const free = (elements: unknown[], animations: unknown[] = []) => ({ + ...deck, + slides: [deck.slides[0], { layout: "free", elements, animations }], + }); + const at = (path: string, message: string) => ({ + slide: 1, + path, + message: `${path} ${message}`, + }); + expect( + validateDeck( + free( + [ + { id: "a", type: "shape", geometry: "rect", ...box, link: 5 }, + { + type: "group", + children: [ + { + ...image, + id: "a", + fit: "cover", + crop: { top: 0.1, right: 0, bottom: 0, left: 0 }, + }, + { + ...image, + id: "c", + crop: { top: 0.5, right: 0, bottom: 0.5, left: 0 }, + shadow: { x: 0, y: 0, blur: 0, spread: -6, color: "text" }, + }, + ], + }, + { id: "d", type: "shape", geometry: "rect", ...box }, + ], + [ + { target: "b", effect: "pulse", easing: { accel: 0.7, decel: 0.7 } }, + { target: "c", effect: "pulse", repeat: "indefinite" }, + { target: "d", effect: "pulse", trigger: "withPrevious" }, + { target: "d", effect: "pulse", trigger: "afterPrevious" }, + ], + ), + ), + ).toEqual({ + ok: false, + issues: [ + at( + "slides[1].elements[1].children[0].id", + 'repeats the id "a" on this slide', + ), + at( + "slides[1].elements[1].children[0].crop", + "needs fit 'fill', not 'cover'", + ), + at( + "slides[1].elements[1].children[1].crop", + "cuts away the whole image on one axis", + ), + at( + "slides[1].elements[1].children[1].shadow.spread", + "shrinks the shadow past nothing; it can be no less than minus half the shorter side", + ), + at( + "slides[1].animations[0].target", + 'names "b", which no element on this slide has as its id', + ), + at( + "slides[1].animations[0].easing", + "has accel and decel adding up past 1", + ), + at( + "slides[1].animations[1].target", + 'names "c", which is inside a group; animate the whole group or an element directly on the slide', + ), + at( + "slides[1].animations[3].trigger", + "waits for an animation that repeats indefinitely and never ends", + ), + at( + "slides[1].elements[0].link", + "links to slide 5, but the deck has 2 slides", + ), + ], + }); + + const nested = (levels: number) => { + let element: unknown = image; + for (let level = 0; level < levels; level++) + element = { type: "group", children: [element] }; + return free([element]); + }; + const tooDeep = `slides[1].elements[0]${".children[0]".repeat(10)}.children`; + for (const levels of [11, 10_000]) + expect(validateDeck(nested(levels))).toEqual({ + ok: false, + issues: [at(tooDeep, "nests groups deeper than 10 levels")], + }); + expect(validateDeck(nested(10)).ok).toBe(true); +}); + +test("each effect on a shape is its own build, and wipes travel away from their side", async () => { + const parts = await unzip(await writePptx(deck, assets)); + const timed = parts + .map(({ data }) => new TextDecoder().decode(data)) + .find((xml) => xml.includes("")); + if (timed === undefined) throw new Error("no slide has a timing tree"); + const effects = [ + ...timed.matchAll( + /grpId="(\d+)" nodeType="\w+">.*?/g, + ), + ].map(([, grpId, spid]) => `${spid}:${grpId}`); + expect(effects.length).toBeGreaterThan( + new Set(effects.map((each) => each.split(":")[0])).size, + ); + expect(new Set(effects).size).toBe(effects.length); + for (const [, spid, grpId] of timed.matchAll( + / filter, + ); + expect(wipes).toEqual(["wipe(right)", "wipe(left)"]); +});