@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.
- package/CHANGELOG.md +90 -0
- package/README.ja.md +1 -1
- package/README.md +1 -1
- package/package.json +2 -1
- package/skills/dek/SKILL.md +10 -3
- package/skills/dek-design/SKILL.md +42 -0
- package/src/cli/address.ts +84 -14
- package/src/cli/build.ts +2 -2
- package/src/cli/check.ts +2 -1
- package/src/cli/commands.ts +54 -13
- package/src/cli/contract.ts +18 -1
- package/src/cli/files.ts +9 -6
- package/src/cli/flags.ts +1 -1
- package/src/cli/goto.ts +3 -1
- package/src/cli/init.ts +10 -5
- package/src/cli/lint.ts +4 -3
- package/src/cli/main.ts +7 -5
- package/src/cli/new.ts +41 -10
- package/src/cli/pack.ts +21 -0
- package/src/cli/pptx.ts +9 -4
- package/src/cli/ref.ts +243 -9
- package/src/cli/result.ts +5 -2
- package/src/cli/scope.ts +25 -27
- package/src/cli/shot.ts +2 -1
- package/src/cli/text.ts +45 -4
- package/src/cli/theme.ts +16 -2
- package/src/cli/voice.ts +4 -3
- package/src/core/alignment.ts +320 -0
- package/src/core/annotation-rows.ts +16 -4
- package/src/core/annotations.ts +19 -1
- package/src/core/config-keys.ts +6 -2
- package/src/core/config.ts +88 -12
- package/src/core/diagnostic.ts +5 -1
- package/src/core/escape.ts +43 -0
- package/src/core/licenses/CC-BY-4.0.txt +396 -0
- package/src/core/licenses/CC0-1.0.txt +121 -0
- package/src/core/licenses.ts +20 -0
- package/src/core/line-break.ts +275 -0
- package/src/core/lint/asset-refs.ts +6 -2
- package/src/core/lint/files.ts +30 -5
- package/src/core/lint/keys.ts +7 -3
- package/src/core/lint/slide-html.ts +3 -1
- package/src/core/lint/slide-script.ts +7 -5
- package/src/core/mv.ts +30 -10
- package/src/core/pack.ts +137 -0
- package/src/core/playwright-module.d.ts +1 -0
- package/src/core/playwright-visual.ts +180 -20
- package/src/core/playwright.ts +202 -25
- package/src/core/pptx-package.ts +461 -33
- package/src/core/pptx-paint.ts +1168 -0
- package/src/core/pptx-plan.ts +879 -0
- package/src/core/pptx-text.ts +204 -47
- package/src/core/pptx-theme.ts +222 -0
- package/src/core/pptx.ts +55 -8
- package/src/core/prose.ts +23 -0
- package/src/core/pseudo-text.ts +32 -10
- package/src/core/ref-name.ts +100 -17
- package/src/core/ref.ts +318 -34
- package/src/core/resolve.ts +20 -9
- package/src/core/shot/morph.ts +1 -1
- package/src/core/shot/motion.ts +2 -3
- package/src/core/shot/still.ts +2 -2
- package/src/core/shot-cache.ts +25 -17
- package/src/core/slide-measure.ts +51 -1
- package/src/core/slide-script-eval.ts +10 -8
- package/src/core/slide-script.ts +114 -20
- package/src/core/sync.ts +3 -3
- package/src/core/visual-cache.ts +2 -0
- package/src/core/visual.ts +154 -15
- package/src/core/voice.ts +3 -5
- package/src/core/zod.ts +9 -1
- package/src/runtime/annotate-view.ts +4 -2
- package/src/server/dev.ts +15 -4
- package/src/server/lock.ts +48 -5
- package/src/server/routes.ts +7 -2
- package/src/theme/default.css +4 -0
- package/src/video/bake.ts +6 -2
package/src/core/pptx-package.ts
CHANGED
|
@@ -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
|
|
5
|
-
//
|
|
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
|
|
36
|
-
picture
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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,
|
|
246
|
-
const runs = box.runs.map((run) => textRun(run,
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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, """);
|
|
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, "	").replace(/\n/g, " ").replace(/\r/g, " ");
|
|
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
|
-
|
|
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
|
}
|