@hajimism/dek 0.3.3 → 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 +63 -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 +11 -6
- package/src/cli/contract.ts +9 -1
- package/src/cli/files.ts +1 -5
- 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 +14 -7
- package/src/cli/pptx.ts +9 -4
- package/src/cli/ref.ts +91 -9
- package/src/cli/result.ts +5 -2
- package/src/cli/scope.ts +17 -28
- package/src/cli/shot.ts +2 -1
- package/src/cli/text.ts +27 -2
- 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.ts +61 -12
- package/src/core/diagnostic.ts +5 -1
- package/src/core/escape.ts +43 -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 +2 -2
- 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 +47 -8
- package/src/core/ref.ts +172 -16
- 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 +2 -2
- 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/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-text.ts
CHANGED
|
@@ -1,40 +1,65 @@
|
|
|
1
1
|
/// <reference lib="dom" />
|
|
2
2
|
// What a PPTX takes from a slide as the browser drew it: each line of the text in its HTML flow,
|
|
3
|
-
// where it was laid out and how it was set, so PowerPoint can set it there again;
|
|
4
|
-
//
|
|
3
|
+
// where it was laid out and how it was set, so PowerPoint can set it there again; each block's
|
|
4
|
+
// text as it is written, for a shape to set as paragraphs; and the slide drawn with that text
|
|
5
|
+
// taken out, as the picture under it.
|
|
5
6
|
|
|
6
|
-
import type {
|
|
7
|
-
|
|
8
|
-
/** A run as the page measures it: the element that owns its text, for its font to be looked up. */
|
|
9
|
-
type MeasuredRun = Omit<PptxRun, "font"> & { owner: number };
|
|
10
|
-
|
|
11
|
-
type MeasuredBox = Omit<PptxTextBox, "runs"> & { runs: MeasuredRun[] };
|
|
7
|
+
import type { PptxBlockText, PptxMeasuredBox, PptxMeasuredRun } from "./pptx-plan.ts";
|
|
8
|
+
import type { Box } from "./slide-measure.ts";
|
|
12
9
|
|
|
13
10
|
export type PptxPageTexts = {
|
|
14
|
-
boxes:
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
|
|
11
|
+
boxes: PptxMeasuredBox[];
|
|
12
|
+
/** The text of each block a box is in, when it is set in plain lines left to right. */
|
|
13
|
+
blocks: PptxBlockText[];
|
|
14
|
+
/** Text the picture keeps, and the shadows of text it does not, by the element they are of. */
|
|
15
|
+
drawn: Array<{ node: number; extent: Box }>;
|
|
19
16
|
};
|
|
20
17
|
|
|
21
|
-
/** The attribute that marks each element owning measured text, by its
|
|
18
|
+
/** The attribute that marks each element owning measured text, by its node. */
|
|
22
19
|
export const PPTX_OWNER_ATTR = "data-dek-pptx-font";
|
|
23
20
|
|
|
24
21
|
/**
|
|
25
22
|
* Measures every text in the slide's HTML flow, line by line, then hides it from the drawing with
|
|
26
23
|
* a highlight, which changes no layout, so a shot taken next is the picture under the text. What
|
|
27
24
|
* is not measured stays drawn: SVG, decoration under `aria-hidden`, what a pseudo-element draws,
|
|
28
|
-
* text a transform rotates or skews, text with no fill of its own, and every text's shadow
|
|
25
|
+
* text a transform rotates or skews, text with no fill of its own, and every text's shadow; the
|
|
26
|
+
* text and the shadows among these are reported as drawn. Each element is known by the number
|
|
27
|
+
* `measurePptxPaintInPage` marked it with, which runs first. Each run says the `font-family` it
|
|
28
|
+
* was set in, for the theme's fonts to be found.
|
|
29
29
|
*
|
|
30
30
|
* Runs in the page, shipped by `page.evaluate`, so it references nothing outside itself.
|
|
31
31
|
*/
|
|
32
32
|
export function measurePptxTextsInPage(): PptxPageTexts {
|
|
33
33
|
const slide = document.querySelector("#deck > .slide");
|
|
34
34
|
if (!(slide instanceof HTMLElement)) {
|
|
35
|
-
return { boxes: [],
|
|
35
|
+
return { boxes: [], blocks: [], drawn: [] };
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* The element's number, as `measurePptxPaintInPage` marked it; -1 for the slide; and for one it
|
|
39
|
+
* did not mark, which it did not reach or did not run for, a number below -1 of its own, so
|
|
40
|
+
* each owner of text still has its own font.
|
|
41
|
+
*/
|
|
42
|
+
const unmarked = new Map<Element, number>();
|
|
43
|
+
const nodeOf = (el: Element): number => {
|
|
44
|
+
const marked = el.getAttribute("data-dek-pptx-node");
|
|
45
|
+
if (marked !== null) {
|
|
46
|
+
return Number(marked);
|
|
47
|
+
}
|
|
48
|
+
if (el === slide) {
|
|
49
|
+
return -1;
|
|
50
|
+
}
|
|
51
|
+
const known = unmarked.get(el) ?? -2 - unmarked.size;
|
|
52
|
+
unmarked.set(el, known);
|
|
53
|
+
return known;
|
|
54
|
+
};
|
|
37
55
|
const origin = slide.getBoundingClientRect();
|
|
56
|
+
const local = (rect: Box): Box => ({
|
|
57
|
+
left: rect.left - origin.left,
|
|
58
|
+
top: rect.top - origin.top,
|
|
59
|
+
right: rect.right - origin.left,
|
|
60
|
+
bottom: rect.bottom - origin.top,
|
|
61
|
+
});
|
|
62
|
+
const drawn: PptxPageTexts["drawn"] = [];
|
|
38
63
|
|
|
39
64
|
const canvas = document.createElement("canvas");
|
|
40
65
|
canvas.width = 1;
|
|
@@ -50,6 +75,8 @@ export function measurePptxTextsInPage(): PptxPageTexts {
|
|
|
50
75
|
let found = { rgb: [0, 0, 0] as [number, number, number], alpha: 0 };
|
|
51
76
|
if (paint) {
|
|
52
77
|
paint.clearRect(0, 0, 1, 1);
|
|
78
|
+
// A value the canvas cannot read leaves its fill as it was: nothing, not the last color.
|
|
79
|
+
paint.fillStyle = "rgba(0, 0, 0, 0)";
|
|
53
80
|
paint.fillStyle = value;
|
|
54
81
|
paint.fillRect(0, 0, 1, 1);
|
|
55
82
|
const [r = 0, g = 0, b = 0, a = 0] = paint.getImageData(0, 0, 1, 1).data;
|
|
@@ -60,17 +87,30 @@ export function measurePptxTextsInPage(): PptxPageTexts {
|
|
|
60
87
|
};
|
|
61
88
|
|
|
62
89
|
/**
|
|
63
|
-
* How much the element and what it is in, up to the slide, scale it
|
|
64
|
-
* rotates or skews it,
|
|
90
|
+
* How much the element and what it is in, up to the slide, scale it, by `transform` and
|
|
91
|
+
* `scale`; none when one of them rotates or skews it, scales it unevenly, or `zoom` sets it at
|
|
92
|
+
* another size than its computed lengths say, which a text box cannot follow.
|
|
65
93
|
*/
|
|
66
94
|
const scaleOf = (el: Element): number | undefined => {
|
|
95
|
+
const set = (value: string) => value !== "none" && value !== "";
|
|
67
96
|
let scale = 1;
|
|
68
97
|
for (
|
|
69
98
|
let node: Element | null = el;
|
|
70
99
|
node && node !== slide.parentElement;
|
|
71
100
|
node = node.parentElement
|
|
72
101
|
) {
|
|
73
|
-
const
|
|
102
|
+
const style = getComputedStyle(node);
|
|
103
|
+
if (set(style.rotate) || (style.zoom !== "" && Number(style.zoom) !== 1)) {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
if (set(style.scale)) {
|
|
107
|
+
const [x = Number.NaN, y = x, z = 1] = style.scale.split(/\s+/).map(Number);
|
|
108
|
+
if (!(x > 0) || Math.abs(x - y) > 1e-6 || z !== 1) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
scale *= x;
|
|
112
|
+
}
|
|
113
|
+
const transform = style.transform;
|
|
74
114
|
if (transform === "none" || transform === "") {
|
|
75
115
|
continue;
|
|
76
116
|
}
|
|
@@ -98,6 +138,15 @@ export function measurePptxTextsInPage(): PptxPageTexts {
|
|
|
98
138
|
return opacity;
|
|
99
139
|
};
|
|
100
140
|
|
|
141
|
+
/** How far a shadow list reaches past the text: every length in it, added up, at most. */
|
|
142
|
+
const reachOf = (value: string): number =>
|
|
143
|
+
value === "none"
|
|
144
|
+
? 0
|
|
145
|
+
: (value.match(/-?\d*\.?\d+px/g) ?? []).reduce(
|
|
146
|
+
(sum, length) => sum + Math.abs(Number.parseFloat(length) || 0),
|
|
147
|
+
0,
|
|
148
|
+
);
|
|
149
|
+
|
|
101
150
|
/** The nearest element around `el` laid out as a block of its own, `el` included. */
|
|
102
151
|
const blockOf = (el: Element): Element => {
|
|
103
152
|
for (let node: Element | null = el; node; node = node.parentElement) {
|
|
@@ -150,11 +199,21 @@ export function measurePptxTextsInPage(): PptxPageTexts {
|
|
|
150
199
|
right: number;
|
|
151
200
|
bottom: number;
|
|
152
201
|
block: Element;
|
|
153
|
-
run:
|
|
202
|
+
run: PptxMeasuredRun;
|
|
154
203
|
};
|
|
155
204
|
const fragments: Fragment[] = [];
|
|
156
205
|
const ranges: Range[] = [];
|
|
157
|
-
const owners = new
|
|
206
|
+
const owners = new Set<Element>();
|
|
207
|
+
/** Each measured text's run, with its white space as written, for its block's paragraph. */
|
|
208
|
+
const written = new Map<
|
|
209
|
+
Text,
|
|
210
|
+
{
|
|
211
|
+
run: PptxMeasuredRun;
|
|
212
|
+
spaces: "collapsed" | "lines" | "kept";
|
|
213
|
+
transform: string;
|
|
214
|
+
lang: string;
|
|
215
|
+
}
|
|
216
|
+
>();
|
|
158
217
|
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
159
218
|
|
|
160
219
|
const walker = document.createTreeWalker(slide, NodeFilter.SHOW_TEXT);
|
|
@@ -164,7 +223,7 @@ export function measurePptxTextsInPage(): PptxPageTexts {
|
|
|
164
223
|
if (!parent || !/[^ \t\n\r\f]/.test(text.data)) {
|
|
165
224
|
continue;
|
|
166
225
|
}
|
|
167
|
-
if (parent.closest(
|
|
226
|
+
if (parent.closest("svg, script, style, template")) {
|
|
168
227
|
continue;
|
|
169
228
|
}
|
|
170
229
|
const style = getComputedStyle(parent);
|
|
@@ -174,16 +233,40 @@ export function measurePptxTextsInPage(): PptxPageTexts {
|
|
|
174
233
|
const fill = rgba(style.webkitTextFillColor || style.color);
|
|
175
234
|
const opacity = opacityOf(parent);
|
|
176
235
|
const scale = scaleOf(parent);
|
|
177
|
-
if (
|
|
236
|
+
if (opacity === 0) {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
// What is not set again stays drawn, and so does every shadow of what is.
|
|
240
|
+
const shadow = reachOf(style.textShadow);
|
|
241
|
+
if (
|
|
242
|
+
shadow > 0 ||
|
|
243
|
+
fill.alpha === 0 ||
|
|
244
|
+
scale === undefined ||
|
|
245
|
+
parent.closest('[aria-hidden="true"]')
|
|
246
|
+
) {
|
|
247
|
+
const whole = document.createRange();
|
|
248
|
+
whole.selectNodeContents(text);
|
|
249
|
+
const bounds = whole.getBoundingClientRect();
|
|
250
|
+
drawn.push({
|
|
251
|
+
node: nodeOf(parent),
|
|
252
|
+
extent: local({
|
|
253
|
+
left: bounds.left - shadow,
|
|
254
|
+
top: bounds.top - shadow,
|
|
255
|
+
right: bounds.right + shadow,
|
|
256
|
+
bottom: bounds.bottom + shadow,
|
|
257
|
+
}),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
if (fill.alpha === 0 || scale === undefined || parent.closest('[aria-hidden="true"]')) {
|
|
178
261
|
continue;
|
|
179
262
|
}
|
|
180
263
|
const block = blockOf(parent);
|
|
181
|
-
const owner =
|
|
182
|
-
owners.
|
|
264
|
+
const owner = nodeOf(parent);
|
|
265
|
+
owners.add(parent);
|
|
183
266
|
const weight = Number(style.fontWeight);
|
|
184
267
|
const letterSpacing =
|
|
185
268
|
style.letterSpacing === "normal" ? 0 : Number.parseFloat(style.letterSpacing);
|
|
186
|
-
const run:
|
|
269
|
+
const run: PptxMeasuredRun = {
|
|
187
270
|
text: "",
|
|
188
271
|
size: Number.parseFloat(style.fontSize) * scale,
|
|
189
272
|
bold: weight >= 600,
|
|
@@ -193,9 +276,13 @@ export function measurePptxTextsInPage(): PptxPageTexts {
|
|
|
193
276
|
alpha: Math.round(fill.alpha * opacity * 1000) / 1000,
|
|
194
277
|
letterSpacing: (Number.isFinite(letterSpacing) ? letterSpacing : 0) * scale,
|
|
195
278
|
owner,
|
|
279
|
+
family: style.fontFamily,
|
|
196
280
|
};
|
|
197
281
|
const collapse = !/^(pre|pre-wrap|break-spaces)$/.test(style.whiteSpace);
|
|
198
282
|
const lang = parent.closest("[lang]")?.getAttribute("lang") ?? "";
|
|
283
|
+
// `pre-line` collapses spaces as `normal` does, and keeps each newline as a break.
|
|
284
|
+
const spaces = !collapse ? "kept" : style.whiteSpace === "pre-line" ? "lines" : "collapsed";
|
|
285
|
+
written.set(text, { run, spaces, transform: style.textTransform, lang });
|
|
199
286
|
|
|
200
287
|
// The lines the text is drawn on, each the graphemes on it and the box they cover.
|
|
201
288
|
const range = document.createRange();
|
|
@@ -262,8 +349,8 @@ export function measurePptxTextsInPage(): PptxPageTexts {
|
|
|
262
349
|
|
|
263
350
|
// Pieces of one line in one block that sit together are one box, a run each; a gap wider than
|
|
264
351
|
// a space, such as the padding of a chip, starts another.
|
|
265
|
-
const boxes:
|
|
266
|
-
let open: { box:
|
|
352
|
+
const boxes: PptxMeasuredBox[] = [];
|
|
353
|
+
let open: { box: PptxMeasuredBox; block: Element; right: number } | undefined;
|
|
267
354
|
for (const fragment of fragments) {
|
|
268
355
|
const height = fragment.bottom - fragment.top;
|
|
269
356
|
const gap = open ? fragment.left - open.right : Number.POSITIVE_INFINITY;
|
|
@@ -297,7 +384,8 @@ export function measurePptxTextsInPage(): PptxPageTexts {
|
|
|
297
384
|
}
|
|
298
385
|
// A box starts at its first glyph: a space it would start with is not set.
|
|
299
386
|
const start = Number.isFinite(fragment.ink) ? fragment.ink : fragment.left;
|
|
300
|
-
const box:
|
|
387
|
+
const box: PptxMeasuredBox = {
|
|
388
|
+
block: nodeOf(fragment.block),
|
|
301
389
|
x: start - origin.left,
|
|
302
390
|
y: fragment.top - origin.top,
|
|
303
391
|
width: Math.max(0, fragment.right - start),
|
|
@@ -320,8 +408,8 @@ export function measurePptxTextsInPage(): PptxPageTexts {
|
|
|
320
408
|
box.runs = box.runs.filter((run) => run.text !== "");
|
|
321
409
|
}
|
|
322
410
|
|
|
323
|
-
for (const
|
|
324
|
-
el.setAttribute("data-dek-pptx-font", String(
|
|
411
|
+
for (const el of owners) {
|
|
412
|
+
el.setAttribute("data-dek-pptx-font", String(nodeOf(el)));
|
|
325
413
|
}
|
|
326
414
|
const style = document.createElement("style");
|
|
327
415
|
style.textContent =
|
|
@@ -329,25 +417,94 @@ export function measurePptxTextsInPage(): PptxPageTexts {
|
|
|
329
417
|
document.head.append(style);
|
|
330
418
|
CSS.highlights.set("dek-pptx", new Highlight(...ranges));
|
|
331
419
|
|
|
332
|
-
const
|
|
333
|
-
(
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
}
|
|
337
|
-
const said =
|
|
338
|
-
(el.tagName.toLowerCase() === "title"
|
|
339
|
-
? el.textContent
|
|
340
|
-
: (el.getAttribute("alt") ?? el.getAttribute("aria-label"))) ?? "";
|
|
341
|
-
return said.trim() === "" ? [] : [said.trim()];
|
|
420
|
+
const blocks = [...new Set(fragments.map((fragment) => fragment.block))].flatMap(
|
|
421
|
+
(block): PptxBlockText[] => {
|
|
422
|
+
const text = paragraphOf(block);
|
|
423
|
+
return text ? [text] : [];
|
|
342
424
|
},
|
|
343
425
|
);
|
|
344
|
-
return {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
426
|
+
return { boxes: boxes.filter((box) => box.runs.length > 0), blocks, drawn };
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* A block's text as it is written, in runs, a `<br>` as a line break; none when it is not set
|
|
430
|
+
* in plain lines left to right, which a paragraph could not set again.
|
|
431
|
+
*/
|
|
432
|
+
function paragraphOf(block: Element): PptxBlockText | undefined {
|
|
433
|
+
const style = getComputedStyle(block);
|
|
434
|
+
if (style.writingMode !== "horizontal-tb" || style.direction !== "ltr") {
|
|
435
|
+
return undefined;
|
|
436
|
+
}
|
|
437
|
+
const runs: PptxMeasuredRun[] = [];
|
|
438
|
+
const walk = document.createTreeWalker(block, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
|
|
439
|
+
for (let node = walk.nextNode(); node; node = walk.nextNode()) {
|
|
440
|
+
if (node instanceof HTMLBRElement && blockOf(node) === block) {
|
|
441
|
+
const last = runs.at(-1);
|
|
442
|
+
if (last) {
|
|
443
|
+
last.text = `${last.text.replace(/ +$/, "")}\n`;
|
|
444
|
+
}
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
const facts = node instanceof Text ? written.get(node) : undefined;
|
|
448
|
+
if (
|
|
449
|
+
!facts ||
|
|
450
|
+
!(node instanceof Text) ||
|
|
451
|
+
node.parentElement === null ||
|
|
452
|
+
blockOf(node.parentElement) !== block
|
|
453
|
+
) {
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
let piece =
|
|
457
|
+
facts.spaces === "collapsed"
|
|
458
|
+
? node.data.replace(/[ \t\n\r\f]+/g, " ")
|
|
459
|
+
: facts.spaces === "lines"
|
|
460
|
+
? node.data.replace(/[ \t\r\f]*\n[ \t\r\f]*/g, "\n").replace(/[ \t\r\f]+/g, " ")
|
|
461
|
+
: node.data;
|
|
462
|
+
const before = runs.at(-1)?.text ?? "";
|
|
463
|
+
if (facts.spaces !== "kept" && (before === "" || / $|\n$/.test(before))) {
|
|
464
|
+
piece = piece.replace(/^ /, "");
|
|
465
|
+
}
|
|
466
|
+
piece = transformText(piece, facts.transform, facts.lang);
|
|
467
|
+
const last = runs.at(-1);
|
|
468
|
+
if (last && sameStyle(last, facts.run)) {
|
|
469
|
+
last.text += piece;
|
|
470
|
+
} else if (piece !== "") {
|
|
471
|
+
runs.push({ ...facts.run, text: piece });
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
const last = runs.at(-1);
|
|
475
|
+
if (last) {
|
|
476
|
+
last.text = last.text.replace(/[ \t\n\r\f]+$/, "");
|
|
477
|
+
}
|
|
478
|
+
const kept = runs.filter((run) => run.text !== "");
|
|
479
|
+
if (kept.length === 0) {
|
|
480
|
+
return undefined;
|
|
481
|
+
}
|
|
482
|
+
const rect = block.getBoundingClientRect();
|
|
483
|
+
const scale = scaleOf(block) ?? 1;
|
|
484
|
+
const px = (value: string) => (Number.parseFloat(value) || 0) * scale;
|
|
485
|
+
const align = style.textAlign;
|
|
486
|
+
return {
|
|
487
|
+
node: nodeOf(block),
|
|
488
|
+
runs: kept,
|
|
489
|
+
align: /center/.test(align)
|
|
490
|
+
? "center"
|
|
491
|
+
: align === "right" || align === "end"
|
|
492
|
+
? "right"
|
|
493
|
+
: align === "justify"
|
|
494
|
+
? "justify"
|
|
495
|
+
: "left",
|
|
496
|
+
lineHeight: Number.parseFloat(style.lineHeight) * scale,
|
|
497
|
+
content: local({
|
|
498
|
+
left: rect.left + px(style.borderLeftWidth) + px(style.paddingLeft),
|
|
499
|
+
top: rect.top + px(style.borderTopWidth) + px(style.paddingTop),
|
|
500
|
+
right: rect.right - px(style.borderRightWidth) - px(style.paddingRight),
|
|
501
|
+
bottom: rect.bottom - px(style.borderBottomWidth) - px(style.paddingBottom),
|
|
502
|
+
}),
|
|
503
|
+
textIndent: px(style.textIndent),
|
|
504
|
+
};
|
|
505
|
+
}
|
|
349
506
|
|
|
350
|
-
function sameStyle(a:
|
|
507
|
+
function sameStyle(a: PptxMeasuredRun, b: PptxMeasuredRun): boolean {
|
|
351
508
|
return (
|
|
352
509
|
a.owner === b.owner &&
|
|
353
510
|
a.size === b.size &&
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import type { PptxFill, PptxPaint, PptxTheme, PptxThemeFont } from "./pptx-package.ts";
|
|
2
|
+
|
|
3
|
+
// What of a slide's look becomes PowerPoint's own rather than its picture's, from the computed
|
|
4
|
+
// values a page reports: the deck's theme, from the tokens most slides have, and a slide's
|
|
5
|
+
// background, when PowerPoint paints it as the browser did. Nothing here touches a page.
|
|
6
|
+
|
|
7
|
+
/** What one slide says of the deck's theme: its color tokens, and the fonts its font tokens are. */
|
|
8
|
+
export type PptxPageTheme = {
|
|
9
|
+
colors: PptxTheme["colors"];
|
|
10
|
+
fonts: Partial<Record<"title" | "body", PptxThemeFont>>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The deck's theme: each token as most slides have it, the first slide's on a tie, so a slide
|
|
15
|
+
* that sets a token of its own keeps it to itself. Fonts only when both font tokens are known.
|
|
16
|
+
*/
|
|
17
|
+
export function deckTheme(pages: PptxPageTheme[]): PptxTheme {
|
|
18
|
+
const colors: PptxTheme["colors"] = {};
|
|
19
|
+
for (const token of ["fg", "bg", "muted", "accent"] as const) {
|
|
20
|
+
const color = mostOf(
|
|
21
|
+
pages.map((page) => page.colors[token]),
|
|
22
|
+
(color) => color.join(),
|
|
23
|
+
);
|
|
24
|
+
if (color) {
|
|
25
|
+
colors[token] = color;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const title = mostOf(
|
|
29
|
+
pages.map((page) => page.fonts.title),
|
|
30
|
+
(font) => font.family,
|
|
31
|
+
);
|
|
32
|
+
const body = mostOf(
|
|
33
|
+
pages.map((page) => page.fonts.body),
|
|
34
|
+
(font) => font.family,
|
|
35
|
+
);
|
|
36
|
+
return title && body ? { colors, fonts: { title, body } } : { colors };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The value most of `values` are, by `key`; the first of them on a tie. */
|
|
40
|
+
function mostOf<T>(values: Array<T | undefined>, key: (value: T) => string): T | undefined {
|
|
41
|
+
const counts = new Map<string, { value: T; count: number }>();
|
|
42
|
+
for (const value of values) {
|
|
43
|
+
if (value !== undefined) {
|
|
44
|
+
const seen = counts.get(key(value)) ?? { value, count: 0 };
|
|
45
|
+
seen.count += 1;
|
|
46
|
+
counts.set(key(value), seen);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
let most: { value: T; count: number } | undefined;
|
|
50
|
+
for (const seen of counts.values()) {
|
|
51
|
+
if (!most || seen.count > most.count) {
|
|
52
|
+
most = seen;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return most?.value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A slide's background as PowerPoint paints it, from its computed `background-color` and
|
|
60
|
+
* `background-image`: one opaque color, or one linear gradient along an axis whose stops are
|
|
61
|
+
* opaque sRGB colors at lengths CSS and DrawingML place alike. None for anything else, which
|
|
62
|
+
* stays in the slide's picture: an image, a radial or repeating gradient, a diagonal, a hint,
|
|
63
|
+
* a color space other than sRGB, a stop the page shows through, a second layer.
|
|
64
|
+
*/
|
|
65
|
+
export function slideBackground(
|
|
66
|
+
background: { paint: PptxPaint; image: string },
|
|
67
|
+
size: { width: number; height: number },
|
|
68
|
+
): PptxFill | undefined {
|
|
69
|
+
const image = background.image.trim();
|
|
70
|
+
if (image === "none") {
|
|
71
|
+
return background.paint.alpha === 1 ? { kind: "solid", paint: background.paint } : undefined;
|
|
72
|
+
}
|
|
73
|
+
const layers = splitTopLevel(image);
|
|
74
|
+
const gradient = layers.length === 1 ? /^linear-gradient\((.*)\)$/s.exec(image)?.[1] : undefined;
|
|
75
|
+
if (gradient === undefined) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
const args = splitTopLevel(gradient);
|
|
79
|
+
const first = args[0] ?? "";
|
|
80
|
+
const direction = cssAngle(first);
|
|
81
|
+
if (/\bin\s/.test(first) || (direction === undefined && !colorOf(first))) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
// CSS's 0deg points up and turns clockwise; DrawingML's 0 points right, and turns so too.
|
|
85
|
+
const css = direction ?? 180;
|
|
86
|
+
const turned = ((css % 360) + 360) % 360;
|
|
87
|
+
if (Math.abs(turned / 90 - Math.round(turned / 90)) > 1e-6) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
const along = Math.round(turned / 90) % 2 === 0 ? size.height : size.width;
|
|
91
|
+
const stops = placeStops(direction === undefined ? args : args.slice(1), along);
|
|
92
|
+
if (!stops || stops.length < 2 || stops.some((stop) => stop.at < 0 || stop.at > 1)) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
return { kind: "linear", angle: (Math.round(turned) + 270) % 360, stops };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A gradient's direction in CSS degrees, or none when the argument is not one. */
|
|
99
|
+
function cssAngle(arg: string): number | undefined {
|
|
100
|
+
const sides: Record<string, number> = { top: 0, right: 90, bottom: 180, left: 270 };
|
|
101
|
+
const side = /^to\s+(top|right|bottom|left)$/.exec(arg)?.[1];
|
|
102
|
+
if (side !== undefined) {
|
|
103
|
+
return sides[side];
|
|
104
|
+
}
|
|
105
|
+
const angle = /^(-?[\d.]+)(deg|turn|rad|grad)$/.exec(arg);
|
|
106
|
+
if (!angle) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
const per: Record<string, number> = { deg: 1, turn: 360, rad: 180 / Math.PI, grad: 0.9 };
|
|
110
|
+
return Number(angle[1]) * (per[angle[2] ?? "deg"] ?? 1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The stops at 0..1 along the gradient, as CSS places them: the first at the start and the
|
|
115
|
+
* last at the end unless placed, one behind another moved up to it, and the unplaced spread
|
|
116
|
+
* evenly between their neighbors. None when a stop is not an opaque color, or a hint or a
|
|
117
|
+
* length is there that DrawingML has no place for.
|
|
118
|
+
*/
|
|
119
|
+
function placeStops(
|
|
120
|
+
args: string[],
|
|
121
|
+
along: number,
|
|
122
|
+
): Array<{ at: number; paint: PptxPaint }> | undefined {
|
|
123
|
+
const stops: Array<{ at: number | undefined; paint: PptxPaint }> = [];
|
|
124
|
+
for (const arg of args) {
|
|
125
|
+
const color = colorOf(arg);
|
|
126
|
+
if (!color) {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
const lengths = arg.slice(color.written.length).trim().split(/\s+/).filter(Boolean);
|
|
130
|
+
if (lengths.length > 2 || color.paint.alpha !== 1) {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
const at = lengths.map((length) => lengthAt(length, along));
|
|
134
|
+
if (at.some((each) => each === undefined)) {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
if (at.length === 0) {
|
|
138
|
+
stops.push({ at: undefined, paint: color.paint });
|
|
139
|
+
}
|
|
140
|
+
for (const each of at) {
|
|
141
|
+
stops.push({ at: each, paint: color.paint });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const [head, tail] = [stops[0], stops.at(-1)];
|
|
145
|
+
if (head && head.at === undefined) {
|
|
146
|
+
head.at = 0;
|
|
147
|
+
}
|
|
148
|
+
if (tail && tail.at === undefined) {
|
|
149
|
+
tail.at = 1;
|
|
150
|
+
}
|
|
151
|
+
let furthest = Number.NEGATIVE_INFINITY;
|
|
152
|
+
for (const stop of stops) {
|
|
153
|
+
if (stop.at !== undefined) {
|
|
154
|
+
stop.at = Math.max(stop.at, furthest);
|
|
155
|
+
furthest = stop.at;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
for (let index = 0; index < stops.length; index++) {
|
|
159
|
+
if (stops[index]?.at !== undefined) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const from = index - 1;
|
|
163
|
+
let to = index;
|
|
164
|
+
while (stops[to]?.at === undefined) {
|
|
165
|
+
to++;
|
|
166
|
+
}
|
|
167
|
+
const start = stops[from]?.at ?? 0;
|
|
168
|
+
const end = stops[to]?.at ?? 1;
|
|
169
|
+
for (let gap = index; gap < to; gap++) {
|
|
170
|
+
const stop = stops[gap];
|
|
171
|
+
if (stop) {
|
|
172
|
+
stop.at = start + ((end - start) * (gap - from)) / (to - from);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return stops.map((stop) => ({ at: stop.at ?? 0, paint: stop.paint }));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function lengthAt(length: string, along: number): number | undefined {
|
|
180
|
+
const value = /^(-?[\d.]+)(%|px)$/.exec(length);
|
|
181
|
+
if (!value) {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
const number = Number(value[1]);
|
|
185
|
+
return value[2] === "%" ? number / 100 : along > 0 ? number / along : undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** A stop's color as Chromium writes an sRGB one, `rgb()` or `rgba()`, and what it took. */
|
|
189
|
+
function colorOf(arg: string): { paint: PptxPaint; written: string } | undefined {
|
|
190
|
+
const match =
|
|
191
|
+
/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)/.exec(arg);
|
|
192
|
+
if (!match) {
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
const [written, r = "0", g = "0", b = "0", a = "1"] = match;
|
|
196
|
+
const alpha = a.endsWith("%") ? Number(a.slice(0, -1)) / 100 : Number(a);
|
|
197
|
+
const channel = (value: string) => Math.max(0, Math.min(255, Math.round(Number(value))));
|
|
198
|
+
return {
|
|
199
|
+
paint: { color: [channel(r), channel(g), channel(b)], alpha },
|
|
200
|
+
written,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The arguments of a function, split at the commas outside any parentheses. */
|
|
205
|
+
function splitTopLevel(text: string): string[] {
|
|
206
|
+
const args: string[] = [];
|
|
207
|
+
let depth = 0;
|
|
208
|
+
let start = 0;
|
|
209
|
+
for (let at = 0; at < text.length; at++) {
|
|
210
|
+
const char = text[at];
|
|
211
|
+
if (char === "(") {
|
|
212
|
+
depth++;
|
|
213
|
+
} else if (char === ")") {
|
|
214
|
+
depth--;
|
|
215
|
+
} else if (char === "," && depth === 0) {
|
|
216
|
+
args.push(text.slice(start, at).trim());
|
|
217
|
+
start = at + 1;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
args.push(text.slice(start).trim());
|
|
221
|
+
return args;
|
|
222
|
+
}
|