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
195 changes: 190 additions & 5 deletions packages/slidedeck/src/deck.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type } from "arktype";
import { scope, type } from "arktype";

const Color = /^#?[0-9A-Fa-f]{6}$/;
const Font = "0 < string <= 100";
Expand Down Expand Up @@ -29,14 +29,76 @@ export type Theme = typeof Theme.infer;

const Fit = "'contain' | 'cover' = 'contain'";

export const ThemeColor = type(
"'primary' | 'secondary' | 'accent' | 'background' | 'text' | 'muted'",
);
export type ThemeColor = typeof ThemeColor.infer;

const LINK_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);

const Url = type("string").narrow(
(text, ctx) =>
(URL.canParse(text) && LINK_PROTOCOLS.has(new URL(text).protocol)) ||
ctx.mustBe("an http, https or mailto URL"),
);

/** A URL, or a slide as a zero-based index into `slides` like the paths in validation issues. */
export const Link = Url.or("number.integer >= 0");
export type Link = typeof Link.infer;

export const Run = type({
"+": "reject",
text: "string",
"bold?": "boolean",
"italic?": "boolean",
"underline?": "boolean",
"strike?": "boolean",
"color?": ThemeColor.or(Color),
/** Points. */
"size?": "1 <= number <= 400",
"link?": Link,
});
export type Run = typeof Run.infer;

/** A plain string, or runs that may each be a plain string. */
export const RichText = type("string").or(type("string").or(Run).array());
export type RichText = typeof RichText.infer;

export const Paragraph = type({
"+": "reject",
type: "'paragraph'",
text: "string",
text: RichText,
});

/** A list item is text, or text with nested items, which are numbered like their parent unless `ordered` says otherwise. */
export const { ListItem } = scope({
RichText,
ListItem: [
"RichText",
"|",
{
"+": "reject",
text: "RichText",
"ordered?": "boolean",
items: "ListItem[] >= 1",
},
],
}).export();
export type ListItem = typeof ListItem.infer;

/** How deep lists nest; the writer styles each level from the master. */
export const LIST_DEPTH = 5;

export const List = type({
"+": "reject",
type: "'list'",
ordered: "boolean = false",
items: ListItem.array().atLeastLength(1),
});
export type List = typeof List.infer;

/** Blocks that flow as paragraphs in one text box; lists and rich runs join this union. */
export const TextBlock = Paragraph;
/** Blocks that flow as paragraphs in one text box. */
export const TextBlock = Paragraph.or(List);
export type TextBlock = typeof TextBlock.infer;

export const ImageElement = type({
Expand Down Expand Up @@ -114,9 +176,132 @@ export type DeckValidation =
| { ok: true; deck: Deck }
| { ok: false; issues: DeckIssue[] };

/** The block lists on a slide, with their path under the slide. */
export function slideRegions(slide: Slide) {
switch (slide.layout) {
case "content":
case "blank":
return [{ role: "body", path: "body", blocks: slide.body }] as const;
case "two-column":
return [
{ role: "left", path: "left", blocks: slide.left },
{ role: "right", path: "right", blocks: slide.right },
] as const;
case "comparison":
return [
{ role: "left", path: "left.body", blocks: slide.left.body },
{ role: "right", path: "right.body", blocks: slide.right.body },
] as const;
default:
return [];
}
}

type Path = (string | number)[];

function pathKey(key: string | number, index: number) {
if (typeof key === "number") return `[${key}]`;
if (index === 0) return key;
return `.${key}`;
}

const pathString = (path: Path) => path.map(pathKey).join("");

function issueAt(path: Path, message: string): DeckIssue {
const [, slide] = path;
const where = pathString(path);
return typeof slide === "number"
? { slide, path: where, message: `${where} ${message}` }
: { path: where, message: `${where} ${message}` };
}

const isObject = (value: unknown): value is Record<string, unknown> =>
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 issues: DeckIssue[] = [];
const pending = [
{
value: isObject(input) ? input.slides : undefined,
path: ["slides"] as Path,
depth: 0,
},
];
for (let next = pending.pop(); next; next = pending.pop()) {
const { value, path, depth } = 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 });
else
issues.push(
issueAt(childPath, `nests lists deeper than ${LIST_DEPTH} levels`),
);
}
}
return issues;
}

function* textRuns(text: RichText, path: Path): Generator<[Run, Path]> {
if (typeof text === "string") return;
for (const [index, run] of text.entries())
if (typeof run !== "string") yield [run, [...path, index]];
}

function* itemRuns(
items: readonly ListItem[],
path: Path,
): Generator<[Run, Path]> {
for (const [index, item] of items.entries()) {
if (typeof item === "string" || Array.isArray(item)) {
yield* textRuns(item, [...path, index]);
continue;
}
yield* textRuns(item.text, [...path, index, "text"]);
yield* itemRuns(item.items, [...path, index, "items"]);
}
}

function* slideRuns(slide: Slide, index: number): Generator<[Run, Path]> {
for (const region of slideRegions(slide))
for (const [position, block] of region.blocks.entries()) {
const path = ["slides", index, region.path, position];
if (block.type === "paragraph")
yield* textRuns(block.text, [...path, "text"]);
else if (block.type === "list")
yield* itemRuns(block.items, [...path, "items"]);
}
}

function missingSlideLinks({ slides }: Deck): DeckIssue[] {
return slides
.flatMap((slide, index) => [...slideRuns(slide, index)])
.filter(
([run]) => typeof run.link === "number" && run.link >= slides.length,
)
.map(([run, path]) =>
issueAt(
[...path, "link"],
`links to slide ${run.link}, but the deck has ${slides.length} slides`,
),
);
}

export function validateDeck(input: unknown): DeckValidation {
const tooDeep = deepLists(input);
if (tooDeep.length > 0) return { ok: false, issues: tooDeep };
const deck = Deck(input);
if (!(deck instanceof type.errors)) return { ok: true, deck };
if (!(deck instanceof type.errors)) {
const issues = missingSlideLinks(deck);
return issues.length === 0 ? { ok: true, deck } : { ok: false, issues };
}
return {
ok: false,
issues: deck.map((error) => {
Expand Down
6 changes: 6 additions & 0 deletions packages/slidedeck/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,16 @@ export {
Deck,
Element,
ImageElement,
Link,
List,
ListItem,
Paragraph,
RichText,
Run,
Slide,
TextBlock,
Theme,
ThemeColor,
assetKey,
validateDeck,
type DeckInput,
Expand Down
25 changes: 24 additions & 1 deletion packages/slidedeck/src/layouts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,29 @@ export function layoutXml(layout: Layout) {
return `${XML_DECLARATION}<p:sldLayout ${NAMESPACES} type="${type}" preserve="1"><p:cSld name="${name}"><p:spTree>${GROUP_HEADER}${shapes}</p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sldLayout>`;
}

const LIST_INDENT = inch(0.35);
const BULLET_HANG = inch(0.25);
const LIST_LEVEL_STYLES = [
{ bullet: "•", numbering: "arabicPeriod", size: 2000 },
{ bullet: "–", numbering: "alphaLcPeriod", size: 1800 },
{ bullet: "•", numbering: "romanLcPeriod", size: 1600 },
{ bullet: "–", numbering: "arabicParenR", size: 1600 },
{ bullet: "•", numbering: "alphaLcParenR", size: 1600 },
];

/** The numbering scheme for a numbered list item; paragraph level 0 is plain text, so lists start at 1. */
export function numbering(level: number) {
const style = LIST_LEVEL_STYLES[level - 1];
if (!style) throw new Error(`lists have no level ${level}`);
return style.numbering;
}

/** Paragraph levels 2–6 carry the list indents, bullets and sizes, in the master and in free text boxes. */
export const LIST_LEVELS = LIST_LEVEL_STYLES.map(
({ bullet, size }, index) =>
`<a:lvl${index + 2}pPr marL="${LIST_INDENT * (index + 1)}" indent="${-BULLET_HANG}"><a:spcBef><a:spcPts val="400"/></a:spcBef><a:buClr><a:schemeClr val="accent1"/></a:buClr><a:buFont typeface="Arial"/><a:buChar char="${bullet}"/><a:defRPr sz="${size}"><a:solidFill><a:schemeClr val="tx1"/></a:solidFill><a:latin typeface="+mn-lt"/></a:defRPr></a:lvl${index + 2}pPr>`,
).join("");

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) =>
Expand All @@ -310,5 +333,5 @@ export function masterXml(logo: string) {
).join("");
const level = (size: number, extra = "") =>
`<a:lvl1pPr${extra}><a:spcBef><a:spcPts val="600"/></a:spcBef><a:buNone/><a:defRPr sz="${size}"><a:solidFill><a:schemeClr val="tx1"/></a:solidFill><a:latin typeface="+mn-lt"/></a:defRPr></a:lvl1pPr>`;
return `${XML_DECLARATION}<p:sldMaster ${NAMESPACES}><p:cSld><p:bg><p:bgPr><a:solidFill><a:schemeClr val="bg1"/></a:solidFill><a:effectLst/></p:bgPr></p:bg><p:spTree>${GROUP_HEADER}${shapes}${logo}</p:spTree></p:cSld>${COLOR_MAP}<p:sldLayoutIdLst>${layoutIds}</p:sldLayoutIdLst><p:hf hdr="0" dt="0"/><p:txStyles><p:titleStyle><a:lvl1pPr algn="l"><a:defRPr sz="3200" b="1"><a:solidFill><a:schemeClr val="tx1"/></a:solidFill><a:latin typeface="+mj-lt"/></a:defRPr></a:lvl1pPr></p:titleStyle><p:bodyStyle>${level(2000, ' marL="0" indent="0"')}</p:bodyStyle><p:otherStyle>${level(1800)}</p:otherStyle></p:txStyles></p:sldMaster>`;
return `${XML_DECLARATION}<p:sldMaster ${NAMESPACES}><p:cSld><p:bg><p:bgPr><a:solidFill><a:schemeClr val="bg1"/></a:solidFill><a:effectLst/></p:bgPr></p:bg><p:spTree>${GROUP_HEADER}${shapes}${logo}</p:spTree></p:cSld>${COLOR_MAP}<p:sldLayoutIdLst>${layoutIds}</p:sldLayoutIdLst><p:hf hdr="0" dt="0"/><p:txStyles><p:titleStyle><a:lvl1pPr algn="l"><a:defRPr sz="3200" b="1"><a:solidFill><a:schemeClr val="tx1"/></a:solidFill><a:latin typeface="+mj-lt"/></a:defRPr></a:lvl1pPr></p:titleStyle><p:bodyStyle>${level(2000, ' marL="0" indent="0"')}${LIST_LEVELS}</p:bodyStyle><p:otherStyle>${level(1800)}</p:otherStyle></p:txStyles></p:sldMaster>`;
}
18 changes: 17 additions & 1 deletion packages/slidedeck/src/theme.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,25 @@
import type { Theme } from "./deck.ts";
import type { Theme, ThemeColor } from "./deck.ts";
import { XML_DECLARATION, escapeXml } from "./xml.ts";

const hex = (color: string) => color.replace("#", "").toUpperCase();
const srgb = (color: string) => `<a:srgbClr val="${hex(color)}"/>`;

const SCHEME_COLORS: Record<ThemeColor, string> = {
primary: "accent1",
secondary: "accent2",
accent: "accent3",
background: "bg1",
text: "tx1",
muted: "tx2",
};

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) =>
`<a:solidFill>${isThemeColor(color) ? `<a:schemeClr val="${SCHEME_COLORS[color]}"/>` : srgb(color)}</a:solidFill>`;

export const COLOR_MAP =
'<p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/>';

Expand Down
Loading
Loading