@hajimism/dek 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.ja.md +1 -1
  3. package/README.md +1 -1
  4. package/package.json +2 -1
  5. package/skills/dek/SKILL.md +10 -3
  6. package/skills/dek-design/SKILL.md +42 -0
  7. package/src/cli/address.ts +84 -14
  8. package/src/cli/build.ts +2 -2
  9. package/src/cli/check.ts +2 -1
  10. package/src/cli/commands.ts +54 -13
  11. package/src/cli/contract.ts +18 -1
  12. package/src/cli/files.ts +9 -6
  13. package/src/cli/flags.ts +1 -1
  14. package/src/cli/goto.ts +3 -1
  15. package/src/cli/init.ts +10 -5
  16. package/src/cli/lint.ts +4 -3
  17. package/src/cli/main.ts +7 -5
  18. package/src/cli/new.ts +41 -10
  19. package/src/cli/pack.ts +21 -0
  20. package/src/cli/pptx.ts +9 -4
  21. package/src/cli/ref.ts +243 -9
  22. package/src/cli/result.ts +5 -2
  23. package/src/cli/scope.ts +25 -27
  24. package/src/cli/shot.ts +2 -1
  25. package/src/cli/text.ts +45 -4
  26. package/src/cli/theme.ts +16 -2
  27. package/src/cli/voice.ts +4 -3
  28. package/src/core/alignment.ts +320 -0
  29. package/src/core/annotation-rows.ts +16 -4
  30. package/src/core/annotations.ts +19 -1
  31. package/src/core/config-keys.ts +6 -2
  32. package/src/core/config.ts +88 -12
  33. package/src/core/diagnostic.ts +5 -1
  34. package/src/core/escape.ts +43 -0
  35. package/src/core/licenses/CC-BY-4.0.txt +396 -0
  36. package/src/core/licenses/CC0-1.0.txt +121 -0
  37. package/src/core/licenses.ts +20 -0
  38. package/src/core/line-break.ts +275 -0
  39. package/src/core/lint/asset-refs.ts +6 -2
  40. package/src/core/lint/files.ts +30 -5
  41. package/src/core/lint/keys.ts +7 -3
  42. package/src/core/lint/slide-html.ts +3 -1
  43. package/src/core/lint/slide-script.ts +7 -5
  44. package/src/core/mv.ts +30 -10
  45. package/src/core/pack.ts +137 -0
  46. package/src/core/playwright-module.d.ts +1 -0
  47. package/src/core/playwright-visual.ts +180 -20
  48. package/src/core/playwright.ts +202 -25
  49. package/src/core/pptx-package.ts +461 -33
  50. package/src/core/pptx-paint.ts +1168 -0
  51. package/src/core/pptx-plan.ts +879 -0
  52. package/src/core/pptx-text.ts +204 -47
  53. package/src/core/pptx-theme.ts +222 -0
  54. package/src/core/pptx.ts +55 -8
  55. package/src/core/prose.ts +23 -0
  56. package/src/core/pseudo-text.ts +32 -10
  57. package/src/core/ref-name.ts +100 -17
  58. package/src/core/ref.ts +318 -34
  59. package/src/core/resolve.ts +20 -9
  60. package/src/core/shot/morph.ts +1 -1
  61. package/src/core/shot/motion.ts +2 -3
  62. package/src/core/shot/still.ts +2 -2
  63. package/src/core/shot-cache.ts +25 -17
  64. package/src/core/slide-measure.ts +51 -1
  65. package/src/core/slide-script-eval.ts +10 -8
  66. package/src/core/slide-script.ts +114 -20
  67. package/src/core/sync.ts +3 -3
  68. package/src/core/visual-cache.ts +2 -0
  69. package/src/core/visual.ts +154 -15
  70. package/src/core/voice.ts +3 -5
  71. package/src/core/zod.ts +9 -1
  72. package/src/runtime/annotate-view.ts +4 -2
  73. package/src/server/dev.ts +15 -4
  74. package/src/server/lock.ts +48 -5
  75. package/src/server/routes.ts +7 -2
  76. package/src/theme/default.css +4 -0
  77. package/src/video/bake.ts +6 -2
@@ -1,8 +1,9 @@
1
1
  import { zipStored } from "./zip.ts";
2
2
 
3
3
  // A PowerPoint file as dek writes it: the fewest OOXML parts PowerPoint opens without a repair,
4
- // as strings. Each slide is a picture of what the browser drew, with the text taken out of it,
5
- // and that text laid back over it in text boxes, line by line, where the browser set it.
4
+ // as strings. Each slide is a picture of what the browser drew, with what is laid over it taken
5
+ // out of it: boxes, pictures, and the text in them as PowerPoint's own shapes, other text in text
6
+ // boxes line by line where the browser set it, in the deck's theme.
6
7
 
7
8
  /** One run of text on one line, as the browser drew it. Sizes are in the slide's own pixels. */
8
9
  export type PptxRun = {
@@ -18,6 +19,8 @@ export type PptxRun = {
18
19
  letterSpacing: number;
19
20
  /** The font the browser drew it with; PowerPoint's default when none is known. */
20
21
  font?: string;
22
+ /** The `font-family` it was set in, to find it in the theme's fonts. */
23
+ family?: string;
21
24
  };
22
25
 
23
26
  /** One line of text, or the part of one that sits together, at its box on the slide. */
@@ -31,12 +34,122 @@ export type PptxTextBox = {
31
34
  runs: PptxRun[];
32
35
  };
33
36
 
37
+ type Rgb = [number, number, number];
38
+
39
+ /** A color and its alpha, 0..1, the opacity of everything it is in included. */
40
+ export type PptxPaint = { color: Rgb; alpha: number };
41
+
42
+ /**
43
+ * A slide's background as PowerPoint's own: a color, or a linear gradient, its `angle` in
44
+ * degrees as DrawingML has it, 0 from left to right and clockwise, and each stop `at` 0..1.
45
+ */
46
+ export type PptxFill =
47
+ | { kind: "solid"; paint: PptxPaint }
48
+ | { kind: "linear"; angle: number; stops: Array<{ at: number; paint: PptxPaint }> };
49
+
50
+ /** A font of the deck's theme: the `font-family` it is, and the faces it was drawn in. */
51
+ export type PptxThemeFont = { family: string; latin: string; ea: string };
52
+
53
+ /**
54
+ * The deck's theme, from its tokens: the colors of `--fg`, `--bg`, `--muted`, and `--accent`,
55
+ * and the fonts of `--font-title` and `--font-body`. What a slide draws in them refers to them.
56
+ */
57
+ export type PptxTheme = {
58
+ colors: Partial<Record<"fg" | "bg" | "muted" | "accent", Rgb>>;
59
+ fonts?: { title: PptxThemeFont; body: PptxThemeFont };
60
+ };
61
+
62
+ /** A box on the slide in its own pixels. */
63
+ type PptxRect = { x: number; y: number; width: number; height: number };
64
+
65
+ export type PptxTextLayer = PptxTextBox & { kind: "text" };
66
+
67
+ /** What a picture embeds: the PNG every renderer shows, and the SVG one that draws SVG prefers. */
68
+ type PptxImage = { png: Uint8Array; svg?: string };
69
+
70
+ /** A box the browser drew with no more than a color, a solid border, and round corners. */
71
+ export type PptxShape<I = PptxImage> = PptxRect & {
72
+ kind: "shape";
73
+ fill?: PptxPaint;
74
+ /** A border the same on every side, inside the box as CSS draws it. */
75
+ outline?: { width: number; paint: PptxPaint };
76
+ /** The border box's corner radius; PowerPoint draws half the shorter side at most. */
77
+ radius: number;
78
+ text?: PptxShapeText;
79
+ /** What the shape holds, in the order the browser painted it, grouped with it. */
80
+ layers: PptxLayer<I>[];
81
+ };
82
+
83
+ /** One side of a border that differs per side: a straight line on the border's midline. */
84
+ export type PptxRule = {
85
+ kind: "rule";
86
+ x1: number;
87
+ y1: number;
88
+ x2: number;
89
+ y2: number;
90
+ width: number;
91
+ paint: PptxPaint;
92
+ };
93
+
94
+ /**
95
+ * An image, a canvas, or an SVG, drawn alone. What it embeds is `I`: the files in a package,
96
+ * where to find them on the way there.
97
+ */
98
+ export type PptxPicture<I = PptxImage> = PptxRect & { kind: "picture"; description: string } & I;
99
+
100
+ export type PptxLayer<I = PptxImage> = PptxTextLayer | PptxShape<I> | PptxRule | PptxPicture<I>;
101
+
102
+ /** The same layers with each picture's embedding replaced. */
103
+ export function mapPictures<A, B>(
104
+ layers: PptxLayer<A>[],
105
+ replace: (picture: PptxPicture<A>) => PptxPicture<B>,
106
+ ): PptxLayer<B>[] {
107
+ return layers.map((layer): PptxLayer<B> => {
108
+ switch (layer.kind) {
109
+ case "picture":
110
+ return replace(layer);
111
+ case "shape":
112
+ return { ...layer, layers: mapPictures(layer.layers, replace) };
113
+ default:
114
+ return layer;
115
+ }
116
+ });
117
+ }
118
+
119
+ /** The text a shape holds, as paragraphs PowerPoint sets from the top of the shape's insets. */
120
+ export type PptxShapeText = {
121
+ /** From the border box to where the text is set. */
122
+ inset: { left: number; top: number; right: number; bottom: number };
123
+ /** Whether PowerPoint wraps it: a text the browser set on one line is kept on one. */
124
+ wrap: boolean;
125
+ paragraphs: PptxParagraph[];
126
+ };
127
+
128
+ export type PptxParagraph = {
129
+ align: "left" | "center" | "right" | "justify";
130
+ /** The distance from one line to the next. */
131
+ lineHeight: number;
132
+ spaceBefore: number;
133
+ /** From the text's insets to where its lines start, and to where they end. */
134
+ marginLeft: number;
135
+ marginRight: number;
136
+ /** Where the first line, or the bullet, starts from the margin: negative hangs it. */
137
+ indent: number;
138
+ /** A bullet, or a number counted from `startAt` along the paragraphs that share it. */
139
+ bullet?: { char: string } | { startAt: number };
140
+ /** Its runs; a line break inside one, as `\n`, is PowerPoint's line break. */
141
+ runs: PptxRun[];
142
+ };
143
+
34
144
  export type PptxSlide = {
35
- /** The slide drawn without the text the boxes carry, as a PNG. */
36
- picture: Uint8Array;
145
+ /** The slide drawn without what is laid over it, as a PNG; none when nothing is left in it. */
146
+ picture?: Uint8Array;
147
+ /** The slide's background as PowerPoint's own, when it is not in the picture. */
148
+ background?: PptxFill;
37
149
  /** What the picture shows, for a screen reader: the slide's own alternative texts. */
38
150
  description: string;
39
- texts: PptxTextBox[];
151
+ /** What is laid over the picture, in the order the browser painted it. */
152
+ layers: PptxLayer[];
40
153
  /** The script for this slide, a paragraph a line. */
41
154
  notes: string;
42
155
  };
@@ -47,6 +160,8 @@ export type PptxDeck = {
47
160
  /** The slide's logical size in pixels; one pixel is 1/96 inch, as CSS has it. */
48
161
  size: { width: number; height: number };
49
162
  slides: PptxSlide[];
163
+ /** The deck's theme as PowerPoint's; PowerPoint's own without it. */
164
+ theme?: PptxTheme;
50
165
  };
51
166
 
52
167
  /** English Metric Units in one CSS pixel: 914400 to the inch, 96 pixels to the inch. */
@@ -73,7 +188,16 @@ export function pptxPackage(deck: PptxDeck): Uint8Array {
73
188
  const count = deck.slides.length;
74
189
  const numbers = deck.slides.map((_, index) => index + 1);
75
190
 
76
- add("[Content_Types].xml", contentTypes(numbers));
191
+ // Each slide's own pictures are numbered after the slides' pictures, across the deck.
192
+ const media = { next: count + 1 };
193
+ const slides = deck.slides.map((slide) => slideParts(slide, deck, media));
194
+ add(
195
+ "[Content_Types].xml",
196
+ contentTypes(
197
+ numbers,
198
+ slides.some((slide) => slide.media.some((file) => file.path.endsWith(".svg"))),
199
+ ),
200
+ );
77
201
  add(
78
202
  "_rels/.rels",
79
203
  relationships([
@@ -106,7 +230,7 @@ export function pptxPackage(deck: PptxDeck): Uint8Array {
106
230
  "ppt/tableStyles.xml",
107
231
  `${DECL}<a:tblStyleLst xmlns:a="${NS.a}" def="{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"/>`,
108
232
  );
109
- add("ppt/theme/theme1.xml", theme("dek"));
233
+ add("ppt/theme/theme1.xml", theme("dek", deck.theme));
110
234
  add("ppt/theme/theme2.xml", theme("dek notes"));
111
235
  add("ppt/slideMasters/slideMaster1.xml", slideMaster());
112
236
  add(
@@ -128,16 +252,31 @@ export function pptxPackage(deck: PptxDeck): Uint8Array {
128
252
  );
129
253
  deck.slides.forEach((slide, index) => {
130
254
  const n = index + 1;
131
- add(`ppt/slides/slide${n}.xml`, slideXml(slide, deck));
255
+ const written = slides[index];
256
+ if (!written) {
257
+ return;
258
+ }
259
+ add(`ppt/slides/slide${n}.xml`, written.xml);
132
260
  add(
133
261
  `ppt/slides/_rels/slide${n}.xml.rels`,
134
262
  relationships([
135
263
  [`${NS.rel}/slideLayout`, "../slideLayouts/slideLayout1.xml"],
136
- [`${NS.rel}/image`, `../media/image${n}.png`],
264
+ ...(slide.picture
265
+ ? [[`${NS.rel}/image`, `../media/image${n}.png`] as [string, string]]
266
+ : []),
137
267
  [`${NS.rel}/notesSlide`, `../notesSlides/notesSlide${n}.xml`],
268
+ ...written.media.map((file): [string, string] => [
269
+ `${NS.rel}/image`,
270
+ `../media/${file.path}`,
271
+ ]),
138
272
  ]),
139
273
  );
140
- parts.push({ path: `ppt/media/image${n}.png`, data: slide.picture });
274
+ if (slide.picture) {
275
+ parts.push({ path: `ppt/media/image${n}.png`, data: slide.picture });
276
+ }
277
+ for (const file of written.media) {
278
+ parts.push({ path: `ppt/media/${file.path}`, data: file.data });
279
+ }
141
280
  add(`ppt/notesSlides/notesSlide${n}.xml`, notesSlide(slide.notes, deck.lang));
142
281
  add(
143
282
  `ppt/notesSlides/_rels/notesSlide${n}.xml.rels`,
@@ -150,10 +289,10 @@ export function pptxPackage(deck: PptxDeck): Uint8Array {
150
289
  return zipStored(parts);
151
290
  }
152
291
 
153
- function contentTypes(numbers: number[]): string {
292
+ function contentTypes(numbers: number[], svg: boolean): string {
154
293
  const override = (part: string, type: string) =>
155
294
  `<Override PartName="/${part}" ContentType="${type}"/>`;
156
- return `${DECL}<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="png" ContentType="image/png"/>${[
295
+ return `${DECL}<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="png" ContentType="image/png"/>${svg ? '<Default Extension="svg" ContentType="image/svg+xml"/>' : ""}${[
157
296
  override("ppt/presentation.xml", `${CT}.presentationml.presentation.main+xml`),
158
297
  override("ppt/presProps.xml", `${CT}.presentationml.presProps+xml`),
159
298
  override("ppt/viewProps.xml", `${CT}.presentationml.viewProps+xml`),
@@ -230,10 +369,265 @@ function notesSlide(notes: string, lang: string): string {
230
369
  return `${DECL}<p:notes ${PML}><p:cSld><p:spTree>${GROUP}<p:sp><p:nvSpPr><p:cNvPr id="2" name="Slide Image"/><p:cNvSpPr><a:spLocks noGrp="1" noRot="1" noChangeAspect="1"/></p:cNvSpPr><p:nvPr><p:ph type="sldImg"/></p:nvPr></p:nvSpPr><p:spPr/></p:sp><p:sp><p:nvSpPr><p:cNvPr id="3" name="Notes"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="body" idx="1"/></p:nvPr></p:nvSpPr><p:spPr/><p:txBody><a:bodyPr/><a:lstStyle/>${paragraphs || `<a:p><a:endParaRPr lang="${escapeXml(lang)}" dirty="0"/></a:p>`}</p:txBody></p:sp></p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:notes>`;
231
370
  }
232
371
 
233
- function slideXml(slide: PptxSlide, deck: PptxDeck): string {
234
- const picture = `<p:pic><p:nvPicPr><p:cNvPr id="2" name="Slide" descr="${escapeXml(slide.description)}"/><p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr><p:nvPr/></p:nvPicPr><p:blipFill><a:blip r:embed="rId2"/><a:stretch><a:fillRect/></a:stretch></p:blipFill><p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${emu(deck.size.width)}" cy="${emu(deck.size.height)}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic>`;
235
- const boxes = slide.texts.map((box, index) => textBox(box, index + 3, deck.lang)).join("");
236
- return `${DECL}<p:sld ${PML}><p:cSld><p:spTree>${GROUP}${picture}${boxes}</p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sld>`;
372
+ type MediaFile = { path: string; data: Uint8Array };
373
+
374
+ /** What a slide writes: its XML, and the files its own pictures embed, in its relationships' order. */
375
+ function slideParts(
376
+ slide: PptxSlide,
377
+ deck: PptxDeck,
378
+ media: { next: number },
379
+ ): { xml: string; media: MediaFile[] } {
380
+ const files: MediaFile[] = [];
381
+ const encoder = new TextEncoder();
382
+ // The relationships before the slide's own pictures': its layout, its picture, its notes.
383
+ const before = slide.picture ? 3 : 2;
384
+ let id = 2;
385
+ const writer: LayerWriter = {
386
+ style: { lang: deck.lang, theme: deck.theme },
387
+ id: () => ++id,
388
+ embed: (data, extension) => {
389
+ files.push({ path: `image${media.next++}.${extension}`, data });
390
+ return `rId${before + files.length}`;
391
+ },
392
+ encode: (text) => encoder.encode(text),
393
+ };
394
+ const picture = slide.picture
395
+ ? `<p:pic><p:nvPicPr><p:cNvPr id="2" name="Slide" descr="${escapeAttribute(slide.description)}"/><p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr><p:nvPr/></p:nvPicPr><p:blipFill><a:blip r:embed="rId2"/><a:stretch><a:fillRect/></a:stretch></p:blipFill><p:spPr>${transform({ x: 0, y: 0, ...deck.size })}<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic>`
396
+ : "";
397
+ const layers = slide.layers.map((layer) => layerXml(layer, writer)).join("");
398
+ return {
399
+ xml: `${DECL}<p:sld ${PML}><p:cSld>${backgroundXml(slide.background, deck.theme)}<p:spTree>${GROUP}${picture}${layers}</p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sld>`,
400
+ media: files,
401
+ };
402
+ }
403
+
404
+ /**
405
+ * A slide's own background: none when it has none of its own, or when it is the theme's
406
+ * background color, which the master paints on every slide.
407
+ */
408
+ function backgroundXml(fill: PptxFill | undefined, theme: PptxTheme | undefined): string {
409
+ if (
410
+ !fill ||
411
+ (fill.kind === "solid" && fill.paint.alpha === 1 && same(fill.paint.color, theme?.colors.bg))
412
+ ) {
413
+ return "";
414
+ }
415
+ const painted =
416
+ fill.kind === "solid"
417
+ ? solidFill(fill.paint, theme)
418
+ : `<a:gradFill rotWithShape="1"><a:gsLst>${fill.stops
419
+ .map(
420
+ ({ at, paint }) =>
421
+ `<a:gs pos="${Math.round(at * 100000)}">${colorXml(paint, theme)}</a:gs>`,
422
+ )
423
+ .join(
424
+ "",
425
+ )}</a:gsLst><a:lin ang="${Math.round(fill.angle * 60000)}" scaled="0"/></a:gradFill>`;
426
+ return `<p:bg><p:bgPr>${painted}<a:effectLst/></p:bgPr></p:bg>`;
427
+ }
428
+
429
+ /** How the deck writes text and color: in its language, in its theme's colors and fonts. */
430
+ type Style = { lang: string; theme?: PptxTheme };
431
+
432
+ type LayerWriter = {
433
+ style: Style;
434
+ /** The next shape id on the slide. */
435
+ id: () => number;
436
+ /** Adds a file the slide embeds, and answers its relationship's id. */
437
+ embed: (data: Uint8Array, extension: "png" | "svg") => string;
438
+ encode: (text: string) => Uint8Array;
439
+ };
440
+
441
+ function layerXml(layer: PptxLayer, writer: LayerWriter): string {
442
+ switch (layer.kind) {
443
+ case "text":
444
+ return textBox(layer, writer.id(), writer.style);
445
+ case "shape":
446
+ return shapeXml(layer, writer);
447
+ case "rule":
448
+ return ruleXml(layer, writer.id(), writer.style.theme);
449
+ case "picture":
450
+ return pictureXml(layer, writer);
451
+ default: {
452
+ const unknown: never = layer;
453
+ throw new Error(`unknown layer ${JSON.stringify(unknown)}`);
454
+ }
455
+ }
456
+ }
457
+
458
+ /** A shape alone, or, when it holds anything, a group of it and what it holds. */
459
+ function shapeXml(shape: PptxShape, writer: LayerWriter): string {
460
+ if (shape.layers.length === 0) {
461
+ return shapeSp(shape, writer.id(), writer.style);
462
+ }
463
+ const id = writer.id();
464
+ const own = shapeSp(shape, writer.id(), writer.style);
465
+ const held = shape.layers.map((layer) => layerXml(layer, writer)).join("");
466
+ const bounds = frameOf(shape);
467
+ const at = `<a:off x="${emu(bounds.x)}" y="${emu(bounds.y)}"/><a:ext cx="${emu(bounds.width)}" cy="${emu(bounds.height)}"/>`;
468
+ const child = `<a:chOff x="${emu(bounds.x)}" y="${emu(bounds.y)}"/><a:chExt cx="${emu(bounds.width)}" cy="${emu(bounds.height)}"/>`;
469
+ return `<p:grpSp><p:nvGrpSpPr><p:cNvPr id="${id}" name="Group ${id - 2}"/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm>${at}${child}</a:xfrm></p:grpSpPr>${own}${held}</p:grpSp>`;
470
+ }
471
+
472
+ /**
473
+ * The shape itself. PowerPoint strokes the middle of a path and CSS draws a border inside the
474
+ * box, so the path runs half the border's width inside the border box, and so do its corners.
475
+ */
476
+ function shapeSp(shape: PptxShape, id: number, style: Style): string {
477
+ const half = (shape.outline?.width ?? 0) / 2;
478
+ const path = pathOf(shape);
479
+ const radius = Math.max(0, shape.radius - half);
480
+ const shorter = Math.min(path.width, path.height);
481
+ const geometry =
482
+ radius > 0 && shorter > 0
483
+ ? `<a:prstGeom prst="roundRect"><a:avLst><a:gd name="adj" fmla="val ${Math.min(50000, Math.round((radius / shorter) * 100000))}"/></a:avLst></a:prstGeom>`
484
+ : '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>';
485
+ const fill = shape.fill ? solidFill(shape.fill, style.theme) : "<a:noFill/>";
486
+ const line = shape.outline
487
+ ? `<a:ln w="${emu(shape.outline.width)}">${solidFill(shape.outline.paint, style.theme)}</a:ln>`
488
+ : "<a:ln><a:noFill/></a:ln>";
489
+ const text = shape.text ? shapeText(shape.text, half, style) : "";
490
+ return `<p:sp><p:nvSpPr><p:cNvPr id="${id}" name="Shape ${id - 2}"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr>${transform(path)}${geometry}${fill}${line}</p:spPr>${text}</p:sp>`;
491
+ }
492
+
493
+ const ALIGN = { left: "l", center: "ctr", right: "r", justify: "just" } as const;
494
+
495
+ /** A shape's text, set inside its insets less what the path already took. */
496
+ function shapeText(text: PptxShapeText, half: number, style: Style): string {
497
+ const inset = (px: number) => emu(Math.max(0, px - half));
498
+ const paragraphs = text.paragraphs.map((paragraph) => paragraphXml(paragraph, style)).join("");
499
+ return `<p:txBody><a:bodyPr wrap="${text.wrap ? "square" : "none"}" lIns="${inset(text.inset.left)}" tIns="${inset(text.inset.top)}" rIns="${inset(text.inset.right)}" bIns="${inset(text.inset.bottom)}" rtlCol="0" anchor="t"><a:noAutofit/></a:bodyPr><a:lstStyle/>${paragraphs || "<a:p/>"}</p:txBody>`;
500
+ }
501
+
502
+ function paragraphXml(paragraph: PptxParagraph, style: Style): string {
503
+ const { bullet } = paragraph;
504
+ const marker =
505
+ bullet === undefined
506
+ ? "<a:buNone/>"
507
+ : "char" in bullet
508
+ ? `<a:buChar char="${escapeXml(bullet.char)}"/>`
509
+ : `<a:buAutoNum type="arabicPeriod" startAt="${bullet.startAt}"/>`;
510
+ // A margin is never negative in DrawingML; the plan makes it at most a pixel's noise below 0.
511
+ const margin = (px: number) => Math.max(0, emu(px));
512
+ const properties = `<a:pPr marL="${margin(paragraph.marginLeft)}" marR="${margin(paragraph.marginRight)}" indent="${emu(paragraph.indent)}" algn="${ALIGN[paragraph.align]}"><a:lnSpc><a:spcPts val="${points(paragraph.lineHeight)}"/></a:lnSpc><a:spcBef><a:spcPts val="${Math.max(0, points(paragraph.spaceBefore))}"/></a:spcBef>${marker}</a:pPr>`;
513
+ // A break inside a run is PowerPoint's line break, set in the run's style.
514
+ const runs = paragraph.runs
515
+ .map((run) =>
516
+ run.text
517
+ .split("\n")
518
+ .map((piece) => (piece === "" ? "" : textRun({ ...run, text: piece }, style)))
519
+ .join(`<a:br>${runProperties(run, style)}</a:br>`),
520
+ )
521
+ .join("");
522
+ return `<a:p>${properties}${runs}</a:p>`;
523
+ }
524
+
525
+ /** A rule from one end to the other, a straight connector, flipped where it runs up or left. */
526
+ function ruleXml(rule: PptxRule, id: number, theme: PptxTheme | undefined): string {
527
+ const frame = frameOf(rule);
528
+ const flips = `${rule.x2 < rule.x1 ? ' flipH="1"' : ""}${rule.y2 < rule.y1 ? ' flipV="1"' : ""}`;
529
+ return `<p:cxnSp><p:nvCxnSpPr><p:cNvPr id="${id}" name="Line ${id - 2}"/><p:cNvCxnSpPr/><p:nvPr/></p:nvCxnSpPr><p:spPr><a:xfrm${flips}><a:off x="${emu(frame.x)}" y="${emu(frame.y)}"/><a:ext cx="${emu(frame.width)}" cy="${emu(frame.height)}"/></a:xfrm><a:prstGeom prst="line"><a:avLst/></a:prstGeom><a:ln w="${emu(rule.width)}" cap="flat">${solidFill(rule.paint, theme)}</a:ln></p:spPr></p:cxnSp>`;
530
+ }
531
+
532
+ /** The extension PowerPoint 2016 and later read an SVG from; the PNG is for any other. */
533
+ const SVG_BLIP = "{96DAC541-7B7A-43D3-8B79-37D633B846F1}";
534
+
535
+ function pictureXml(picture: PptxPicture, writer: LayerWriter): string {
536
+ const id = writer.id();
537
+ const png = writer.embed(picture.png, "png");
538
+ const svg =
539
+ picture.svg === undefined
540
+ ? ""
541
+ : `<a:extLst><a:ext uri="${SVG_BLIP}"><asvg:svgBlip xmlns:asvg="http://schemas.microsoft.com/office/drawing/2016/SVG/main" r:embed="${writer.embed(writer.encode(picture.svg), "svg")}"/></a:ext></a:extLst>`;
542
+ return `<p:pic><p:nvPicPr><p:cNvPr id="${id}" name="Picture ${id - 2}" descr="${escapeAttribute(picture.description)}"/><p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr><p:nvPr/></p:nvPicPr><p:blipFill><a:blip r:embed="${png}">${svg}</a:blip><a:stretch><a:fillRect/></a:stretch></p:blipFill><p:spPr>${transform(picture)}<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic>`;
543
+ }
544
+
545
+ /**
546
+ * The frame a layer is written with: what PowerPoint reads its place from, and a group's extent
547
+ * from, which it takes as the frames of what it holds put together. A group written any larger
548
+ * or smaller is scaled to fit by a renderer that works its extent out again.
549
+ */
550
+ function frameOf(layer: PptxLayer): PptxRect {
551
+ switch (layer.kind) {
552
+ case "text":
553
+ return { x: layer.x, y: layer.y, width: layer.width + roomOf(layer), height: layer.height };
554
+ case "rule":
555
+ return {
556
+ x: Math.min(layer.x1, layer.x2),
557
+ y: Math.min(layer.y1, layer.y2),
558
+ width: Math.abs(layer.x2 - layer.x1),
559
+ height: Math.abs(layer.y2 - layer.y1),
560
+ };
561
+ case "picture":
562
+ return { x: layer.x, y: layer.y, width: layer.width, height: layer.height };
563
+ case "shape": {
564
+ const frames = [pathOf(layer), ...layer.layers.map(frameOf)];
565
+ const left = Math.min(...frames.map((frame) => frame.x));
566
+ const top = Math.min(...frames.map((frame) => frame.y));
567
+ const right = Math.max(...frames.map((frame) => frame.x + frame.width));
568
+ const bottom = Math.max(...frames.map((frame) => frame.y + frame.height));
569
+ return { x: left, y: top, width: right - left, height: bottom - top };
570
+ }
571
+ default: {
572
+ const unknown: never = layer;
573
+ throw new Error(`unknown layer ${JSON.stringify(unknown)}`);
574
+ }
575
+ }
576
+ }
577
+
578
+ /** A shape's path: half its outline's width inside its border box, where PowerPoint strokes it. */
579
+ function pathOf(shape: PptxShape): PptxRect {
580
+ const half = (shape.outline?.width ?? 0) / 2;
581
+ return {
582
+ x: shape.x + half,
583
+ y: shape.y + half,
584
+ width: Math.max(0, shape.width - half * 2),
585
+ height: Math.max(0, shape.height - half * 2),
586
+ };
587
+ }
588
+
589
+ function transform(box: PptxRect): string {
590
+ return `<a:xfrm><a:off x="${emu(box.x)}" y="${emu(box.y)}"/><a:ext cx="${emu(box.width)}" cy="${emu(box.height)}"/></a:xfrm>`;
591
+ }
592
+
593
+ function solidFill(paint: PptxPaint, theme: PptxTheme | undefined): string {
594
+ return `<a:solidFill>${colorXml(paint, theme)}</a:solidFill>`;
595
+ }
596
+
597
+ /**
598
+ * The theme's colors as a slide refers to them, through the master's map, in the order a color
599
+ * two tokens share is taken: the text's, the background's, the accent, the muted.
600
+ */
601
+ const SCHEME = [
602
+ ["fg", "tx1"],
603
+ ["bg", "bg1"],
604
+ ["accent", "accent1"],
605
+ ["muted", "tx2"],
606
+ ] as const;
607
+
608
+ /** A color as the theme's when it is one of the theme's, so it follows the theme; else as itself. */
609
+ function colorXml(paint: PptxPaint, theme: PptxTheme | undefined): string {
610
+ const opacity = paint.alpha < 1 ? `<a:alpha val="${Math.round(paint.alpha * 100000)}"/>` : "";
611
+ const scheme = SCHEME.find(([token]) => same(paint.color, theme?.colors[token]))?.[1];
612
+ return scheme
613
+ ? `<a:schemeClr val="${scheme}">${opacity}</a:schemeClr>`
614
+ : `<a:srgbClr val="${hex(paint.color)}">${opacity}</a:srgbClr>`;
615
+ }
616
+
617
+ function same(a: Rgb, b: Rgb | undefined): boolean {
618
+ return b !== undefined && a.every((channel, index) => channel === b[index]);
619
+ }
620
+
621
+ function hex(color: Rgb): string {
622
+ return color
623
+ .map((channel) => channel.toString(16).padStart(2, "0"))
624
+ .join("")
625
+ .toUpperCase();
626
+ }
627
+
628
+ /** Hundredths of a point, as DrawingML sets sizes and spacing. */
629
+ function points(px: number): number {
630
+ return Math.round(px * PT_PER_PX * 100);
237
631
  }
238
632
 
239
633
  /**
@@ -242,32 +636,52 @@ function slideXml(slide: PptxSlide, deck: PptxDeck): string {
242
636
  * line's end, since a renderer that wraps anyway, or a substitute font a little wider, would
243
637
  * otherwise break the line again; the text is set from the left, so the room shows nowhere.
244
638
  */
245
- function textBox(box: PptxTextBox, id: number, lang: string): string {
246
- const runs = box.runs.map((run) => textRun(run, lang)).join("");
639
+ function textBox(box: PptxTextBox, id: number, style: Style): string {
640
+ const runs = box.runs.map((run) => textRun(run, style)).join("");
247
641
  const spacing = Math.max(1, Math.round(box.lineHeight * PT_PER_PX * 100));
248
- const room = Math.max(box.width * 0.25, box.lineHeight * 2);
642
+ const room = roomOf(box);
249
643
  return `<p:sp><p:nvSpPr><p:cNvPr id="${id}" name="Text ${id - 2}"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="${emu(box.x)}" y="${emu(box.y)}"/><a:ext cx="${emu(box.width + room)}" cy="${emu(box.height)}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/></p:spPr><p:txBody><a:bodyPr wrap="none" lIns="0" tIns="0" rIns="0" bIns="0" rtlCol="0" anchor="t"><a:noAutofit/></a:bodyPr><a:lstStyle/><a:p><a:pPr><a:lnSpc><a:spcPts val="${spacing}"/></a:lnSpc></a:pPr>${runs}</a:p></p:txBody></p:sp>`;
250
644
  }
251
645
 
252
- function textRun(run: PptxRun, lang: string): string {
646
+ /** How far a text box runs on past its line's end. */
647
+ function roomOf(box: PptxTextBox): number {
648
+ return Math.max(box.width * 0.25, box.lineHeight * 2);
649
+ }
650
+
651
+ function textRun(run: PptxRun, style: Style): string {
652
+ return `<a:r>${runProperties(run, style)}<a:t>${escapeXml(run.text)}</a:t></a:r>`;
653
+ }
654
+
655
+ function runProperties(run: PptxRun, style: Style): string {
253
656
  const attributes = [
254
- `lang="${escapeXml(lang)}"`,
255
- `sz="${Math.max(100, Math.round(run.size * PT_PER_PX * 100))}"`,
657
+ `lang="${escapeXml(style.lang)}"`,
658
+ `sz="${Math.max(100, points(run.size))}"`,
256
659
  `b="${run.bold ? 1 : 0}"`,
257
660
  `i="${run.italic ? 1 : 0}"`,
258
661
  ...(run.underline ? ['u="sng"'] : []),
259
662
  ...(run.strike ? ['strike="sngStrike"'] : []),
260
- ...(run.letterSpacing !== 0
261
- ? [`spc="${Math.round(run.letterSpacing * PT_PER_PX * 100)}"`]
262
- : []),
663
+ ...(run.letterSpacing !== 0 ? [`spc="${points(run.letterSpacing)}"`] : []),
263
664
  'dirty="0"',
264
665
  ].join(" ");
265
- const alpha = run.alpha < 1 ? `<a:alpha val="${Math.round(run.alpha * 100000)}"/>` : "";
266
- const color = run.color.map((channel) => channel.toString(16).padStart(2, "0")).join("");
267
- const font = run.font
268
- ? `<a:latin typeface="${escapeXml(run.font)}"/><a:ea typeface="${escapeXml(run.font)}"/><a:cs typeface="${escapeXml(run.font)}"/>`
269
- : "";
270
- return `<a:r><a:rPr ${attributes}><a:solidFill><a:srgbClr val="${color.toUpperCase()}">${alpha}</a:srgbClr></a:solidFill>${font}</a:rPr><a:t>${escapeXml(run.text)}</a:t></a:r>`;
666
+ // Text set in a font token is set in the theme's font, each script in its own face, as the
667
+ // browser set it; the body's wins when both tokens name one font.
668
+ const fonts = style.theme?.fonts;
669
+ const themed =
670
+ run.family === undefined
671
+ ? undefined
672
+ : run.family === fonts?.body.family
673
+ ? "mn"
674
+ : run.family === fonts?.title.family
675
+ ? "mj"
676
+ : undefined;
677
+ const faces = (face: (script: "lt" | "ea" | "cs") => string) =>
678
+ `<a:latin typeface="${face("lt")}"/><a:ea typeface="${face("ea")}"/><a:cs typeface="${face("cs")}"/>`;
679
+ const font = themed
680
+ ? faces((script) => `+${themed}-${script}`)
681
+ : run.font
682
+ ? faces(() => escapeXml(run.font ?? ""))
683
+ : "";
684
+ return `<a:rPr ${attributes}>${solidFill(run, style.theme)}${font}</a:rPr>`;
271
685
  }
272
686
 
273
687
  function emu(px: number): number {
@@ -285,6 +699,11 @@ function escapeXml(text: string): string {
285
699
  .replace(/"/g, "&quot;");
286
700
  }
287
701
 
702
+ /** An attribute's value: as text, with its line breaks and tabs kept, which XML would make spaces. */
703
+ function escapeAttribute(text: string): string {
704
+ return escapeXml(text).replace(/\t/g, "&#9;").replace(/\n/g, "&#10;").replace(/\r/g, "&#13;");
705
+ }
706
+
288
707
  /** Whether XML 1.0 holds the code point: tab, newline, return, and the rest of Unicode but surrogates. */
289
708
  function xmlChar(code: number): boolean {
290
709
  return (
@@ -301,12 +720,21 @@ function xmlChar(code: number): boolean {
301
720
  * A complete theme: PowerPoint repairs a file whose theme leaves out the colors, the fonts, or
302
721
  * the three lists of fill, line, and effect styles. Nothing in a slide uses it but the notes.
303
722
  */
304
- function theme(name: string): string {
723
+ function theme(name: string, deck?: PptxTheme): string {
305
724
  const solid = (value: string) => `<a:solidFill><a:schemeClr val="${value}"/></a:solidFill>`;
306
725
  const fills = `${solid("phClr")}${solid("phClr")}${solid("phClr")}`;
307
726
  const line = (width: number) =>
308
727
  `<a:ln w="${width}" cap="flat" cmpd="sng" algn="ctr">${solid("phClr")}<a:prstDash val="solid"/><a:miter lim="800000"/></a:ln>`;
309
728
  const effect = "<a:effectStyle><a:effectLst/></a:effectStyle>";
310
729
  const clr = (tag: string, value: string) => `<a:${tag}><a:srgbClr val="${value}"/></a:${tag}>`;
311
- return `${DECL}<a:theme xmlns:a="${NS.a}" name="${escapeXml(name)}"><a:themeElements><a:clrScheme name="dek"><a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1><a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1>${clr("dk2", "44546A")}${clr("lt2", "E7E6E6")}${clr("accent1", "4472C4")}${clr("accent2", "ED7D31")}${clr("accent3", "A5A5A5")}${clr("accent4", "FFC000")}${clr("accent5", "5B9BD5")}${clr("accent6", "70AD47")}${clr("hlink", "0563C1")}${clr("folHlink", "954F72")}</a:clrScheme><a:fontScheme name="dek"><a:majorFont><a:latin typeface="Calibri Light"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont><a:minorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont></a:fontScheme><a:fmtScheme name="dek"><a:fillStyleLst>${fills}</a:fillStyleLst><a:lnStyleLst>${line(6350)}${line(12700)}${line(19050)}</a:lnStyleLst><a:effectStyleLst>${effect}${effect}${effect}</a:effectStyleLst><a:bgFillStyleLst>${fills}</a:bgFillStyleLst></a:fmtScheme></a:themeElements><a:objectDefaults/><a:extraClrSchemeLst/></a:theme>`;
730
+ // The tokens fill the slots the master maps text, background, and accent to; the rest are
731
+ // PowerPoint's own, and so is every slot of a deck without a theme.
732
+ const token = (tag: string, color: Rgb | undefined, otherwise: string) =>
733
+ color ? clr(tag, hex(color)) : otherwise;
734
+ const colors = deck?.colors ?? {};
735
+ const face = (font: PptxThemeFont | undefined, latin: string) =>
736
+ font
737
+ ? `<a:latin typeface="${escapeXml(font.latin)}"/><a:ea typeface="${escapeXml(font.ea)}"/><a:cs typeface="${escapeXml(font.latin)}"/>`
738
+ : `<a:latin typeface="${latin}"/><a:ea typeface=""/><a:cs typeface=""/>`;
739
+ return `${DECL}<a:theme xmlns:a="${NS.a}" name="${escapeXml(name)}"><a:themeElements><a:clrScheme name="dek">${token("dk1", colors.fg, '<a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1>')}${token("lt1", colors.bg, '<a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1>')}${token("dk2", colors.muted, clr("dk2", "44546A"))}${clr("lt2", "E7E6E6")}${token("accent1", colors.accent, clr("accent1", "4472C4"))}${clr("accent2", "ED7D31")}${clr("accent3", "A5A5A5")}${clr("accent4", "FFC000")}${clr("accent5", "5B9BD5")}${clr("accent6", "70AD47")}${clr("hlink", "0563C1")}${clr("folHlink", "954F72")}</a:clrScheme><a:fontScheme name="dek"><a:majorFont>${face(deck?.fonts?.title, "Calibri Light")}</a:majorFont><a:minorFont>${face(deck?.fonts?.body, "Calibri")}</a:minorFont></a:fontScheme><a:fmtScheme name="dek"><a:fillStyleLst>${fills}</a:fillStyleLst><a:lnStyleLst>${line(6350)}${line(12700)}${line(19050)}</a:lnStyleLst><a:effectStyleLst>${effect}${effect}${effect}</a:effectStyleLst><a:bgFillStyleLst>${fills}</a:bgFillStyleLst></a:fmtScheme></a:themeElements><a:objectDefaults/><a:extraClrSchemeLst/></a:theme>`;
312
740
  }