@codename/deck 0.3.0 → 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/src/mindmap.js ADDED
@@ -0,0 +1,208 @@
1
+ // A mind map, from lists as a slide holds them: each item a node, its indentation its branching,
2
+ // every list on the slide merged into one tree, as items of the same words at the same place are
3
+ // one node, under one root (the item the lists share, else the slide's heading). Laid out as maps
4
+ // are drawn by hand: the root in the middle, its branches shared out to either side, the first on
5
+ // the side a line ends, clockwise, each branch a tidy tree going outwards. Pure, but for
6
+ // `watchMap`, which measures a map's words on its slide and places them, again whenever their size
7
+ // or its box's changes, in em of the map's own type, so it holds at any size.
8
+
9
+ /** @typedef {{ text: string, start: number, end: number, children: Item[], blocks?: { lang: string, text: string }[], step?: boolean, element?: HTMLElement }} Item */
10
+ /** @typedef {{ w: number, h: number, children: Box[], x?: number, y?: number, side?: number, span?: number, inner?: number }} Box */
11
+ /** @typedef {{ branch: number, depth: number, x1: number, y1: number, x2: number, y2: number }} Line */
12
+
13
+ const ITEM = /^([ \t]*)(?:[-*+]|\d{1,9}[.)])(?:[ \t]+(?:\[[ xX]\][ \t]+)?|$)/
14
+
15
+ /**
16
+ * A list's items as a tree, by their indentation, as written: each item's words where they are in the source, from
17
+ * `start` (where the list's own text starts), a line that is no item going on with the item above it, a fenced block
18
+ * under an item (a chart, a diagram) that item's to draw.
19
+ * @param {string} raw @param {number} start @returns {Item[]}
20
+ */
21
+ export function itemsOf(raw, start) {
22
+ /** @type {Item[]} */
23
+ const top = []
24
+ /** @type {{ indent: number, item: Item }[]} */
25
+ const open = []
26
+ let at = start
27
+ /** @type {{ fence: string, indent: number, lang: string, lines: string[] } | null} */
28
+ let fenced = null
29
+ for (const line of raw.split('\n')) {
30
+ const from = at
31
+ at += line.length + 1
32
+ if (fenced) {
33
+ if (line.trimStart().startsWith(fenced.fence) && !line.trim().slice(fenced.fence.length).trim()) {
34
+ const item = /** @type {{ item: Item }} */ (open.at(-1)).item
35
+ ;(item.blocks ??= []).push({ lang: fenced.lang, text: fenced.lines.join('\n') })
36
+ fenced = null
37
+ } else fenced.lines.push(line.slice(Math.min(fenced.indent, /^[ \t]*/.exec(line)?.[0].length ?? 0)))
38
+ continue
39
+ }
40
+ const fence = /^([ \t]*)(`{3,}|~{3,})(.*)$/.exec(line)
41
+ if (fence && open.length) { fenced = { fence: fence[2], indent: fence[1].length, lang: fence[3].trim(), lines: [] }; continue }
42
+ const marker = ITEM.exec(line)
43
+ if (marker) {
44
+ const indent = marker[1].replace(/\t/g, ' ').length
45
+ const item = { text: line.slice(marker[0].length).trim(), start: from + marker[0].length, end: from + line.trimEnd().length, children: [] }
46
+ while (open.length && /** @type {{ indent: number }} */ (open.at(-1)).indent >= indent) open.pop()
47
+ ;(open.at(-1)?.item.children ?? top).push(item)
48
+ open.push({ indent, item })
49
+ } else if (line.trim() && open.length) {
50
+ const item = /** @type {{ item: Item }} */ (open.at(-1)).item
51
+ item.text = `${item.text} ${line.trim()}`.trim()
52
+ item.end = from + line.trimEnd().length
53
+ }
54
+ }
55
+ return top
56
+ }
57
+
58
+ /** An item's words as they are compared: no emphasis, no case, one space. @param {string} text */
59
+ export const keyOf = (text) => text.normalize('NFC').replace(/[*_`~]/g, '').replace(/\s+/g, ' ').trim().toLowerCase()
60
+
61
+ /**
62
+ * Items merged: of siblings with the same words, the first keeps the others' children, at every level; a step if any
63
+ * was one. Items without words stay apart. @param {Item[]} items @returns {Item[]}
64
+ */
65
+ export function mergeItems(items) {
66
+ /** @type {Item[]} */
67
+ const out = []
68
+ /** @type {Map<string, Item>} */
69
+ const seen = new Map()
70
+ for (const item of items) {
71
+ const key = keyOf(item.text), same = key ? seen.get(key) : undefined
72
+ if (same) { same.children.push(...item.children); same.step = same.step || item.step; continue }
73
+ const copy = { ...item, children: [...item.children] }
74
+ if (key) seen.set(key, copy)
75
+ out.push(copy)
76
+ }
77
+ for (const item of out) item.children = mergeItems(item.children)
78
+ return out
79
+ }
80
+
81
+ /**
82
+ * A map laid out, its boxes' sizes given (in em): the root's centre at the middle, its branches shared out to the end
83
+ * side (top to bottom) and the start side (bottom to top) so the two are as tall as they can be alike, each child a gap
84
+ * beyond its parent, siblings stacked and centred on it, spaced out further (up to four times) where the map would be
85
+ * flatter than its box (`aspect`, its width over its height). Each box gets its `x` and `y` (its top start corner, from the
86
+ * map's top start corner, mirrored if `rtl`) and its `side` (1 right of the root, -1 left); gives the map's size and its
87
+ * lines, each from a parent's side to its child's.
88
+ * @param {Box} root @param {{ rtl?: boolean, aspect?: number }} [how] @returns {{ width: number, height: number, lines: Line[] }}
89
+ */
90
+ export function layoutMap(root, { rtl = false, aspect = 0 } = {}) {
91
+ let laid = layoutAt(root, rtl, 1)
92
+ if (aspect > 0 && laid.width / laid.height > aspect && layoutAt(root, rtl, 4).height < laid.width / aspect) laid = layoutAt(root, rtl, 4)
93
+ else if (aspect > 0 && laid.width / laid.height > aspect) {
94
+ let low = 1, high = 4
95
+ for (let n = 0; n < 12; n++) { const mid = (low + high) / 2; if (layoutAt(root, rtl, mid).height < laid.width / aspect) low = mid; else high = mid }
96
+ laid = layoutAt(root, rtl, low)
97
+ }
98
+ return laid
99
+ }
100
+
101
+ /** The map laid out with its siblings' spaces so many times the usual. @param {Box} root @param {boolean} rtl @param {number} spread */
102
+ function layoutAt(root, rtl, spread) {
103
+ const gap = (/** @type {number} */ depth) => (depth <= 1 ? 2.4 : 1.5)
104
+ const space = (/** @type {number} */ depth) => (depth <= 1 ? 0.8 * spread : 0.3 * Math.sqrt(spread))
105
+ const measure = (/** @type {Box} */ box, /** @type {number} */ depth) => {
106
+ box.inner = box.children.reduce((sum, child) => sum + measure(child, depth + 1), 0) + space(depth + 1) * Math.max(0, box.children.length - 1)
107
+ box.span = Math.max(box.h, box.inner)
108
+ return box.span
109
+ }
110
+ /** @type {Line[]} */
111
+ const lines = []
112
+ // A box at its near edge `x`, in a band from `top`, going out to `side` (1 the end, -1 the start).
113
+ const place = (/** @type {Box} */ box, /** @type {number} */ x, /** @type {number} */ top, /** @type {number} */ side, /** @type {number} */ depth, /** @type {number} */ branch) => {
114
+ const middle = top + /** @type {number} */ (box.span) / 2
115
+ box.x = side > 0 ? x : x - box.w
116
+ box.side = rtl ? -side : side
117
+ box.y = middle - box.h / 2
118
+ let y = middle - /** @type {number} */ (box.inner) / 2
119
+ const far = x + side * box.w
120
+ for (const child of box.children) {
121
+ const near = far + side * gap(depth + 1)
122
+ place(child, near, y, side, depth + 1, branch)
123
+ lines.push({ branch, depth: depth + 1, x1: far, y1: middle, x2: near, y2: y + /** @type {number} */ (child.span) / 2 })
124
+ y += /** @type {number} */ (child.span) + space(depth + 1)
125
+ }
126
+ }
127
+ for (const child of root.children) measure(child, 1)
128
+ root.span = root.h
129
+ root.side = 0
130
+ root.x = -root.w / 2
131
+ root.y = -root.h / 2
132
+ // The branches to the end side, then the rest to the start: the split that makes the two sides nearest in height.
133
+ const tall = (/** @type {Box[]} */ some) => some.reduce((sum, b) => sum + /** @type {number} */ (b.span), 0) + space(1) * Math.max(0, some.length - 1)
134
+ const count = root.children.length
135
+ let split = count
136
+ for (let k = Math.ceil(count / 2); k <= count; k++) if (Math.max(tall(root.children.slice(0, k)), tall(root.children.slice(k))) < Math.max(tall(root.children.slice(0, split)), tall(root.children.slice(split)))) split = k
137
+ for (const [side, some] of /** @type {[number, Box[]][]} */ ([[1, root.children.slice(0, split)], [-1, root.children.slice(split).reverse()]])) {
138
+ let y = -tall(some) / 2
139
+ for (const child of some) {
140
+ const near = side * (root.w / 2 + gap(1))
141
+ place(child, near, y, side, 1, root.children.indexOf(child))
142
+ lines.push({ branch: root.children.indexOf(child), depth: 1, x1: side * root.w / 2, y1: 0, x2: near, y2: y + /** @type {number} */ (child.span) / 2 })
143
+ y += /** @type {number} */ (child.span) + space(1)
144
+ }
145
+ }
146
+ // From the map's top start corner.
147
+ /** @type {Box[]} */
148
+ const all = []
149
+ const walk = (/** @type {Box} */ box) => { all.push(box); box.children.forEach(walk) }
150
+ walk(root)
151
+ const left = Math.min(...all.map((b) => /** @type {number} */ (b.x))), right = Math.max(...all.map((b) => /** @type {number} */ (b.x) + b.w))
152
+ const top = Math.min(...all.map((b) => /** @type {number} */ (b.y))), bottom = Math.max(...all.map((b) => /** @type {number} */ (b.y) + b.h))
153
+ const width = right - left
154
+ const x = (/** @type {number} */ v) => (rtl ? width - (v - left) : v - left)
155
+ for (const box of all) { box.x = rtl ? width - (/** @type {number} */ (box.x) - left) - box.w : /** @type {number} */ (box.x) - left; box.y = /** @type {number} */ (box.y) - top }
156
+ for (const line of lines) Object.assign(line, { x1: x(line.x1), x2: x(line.x2), y1: line.y1 - top, y2: line.y2 - top })
157
+ return { width, height: bottom - top, lines }
158
+ }
159
+
160
+ /** A line as a curve leaving its parent and reaching its child level. @param {Line} line */
161
+ export const curveOf = ({ x1, y1, x2, y2 }) => `M${x1.toFixed(3)} ${y1.toFixed(3)}C${((x1 + x2) / 2).toFixed(3)} ${y1.toFixed(3)} ${((x1 + x2) / 2).toFixed(3)} ${y2.toFixed(3)} ${x2.toFixed(3)} ${y2.toFixed(3)}`
162
+
163
+ /**
164
+ * A map drawn by render.js (`.deck-mindmap`, its plane, its lines' SVG with a group a branch, its tree as lists, each
165
+ * item's words a `.deck-map-node`) placed once it is on a page, and again as its words or its box change size: its
166
+ * words where the layout puts them, its lines, the whole shrunk to its box. @param {HTMLElement} map
167
+ */
168
+ export function watchMap(map) {
169
+ const plane = /** @type {HTMLElement} */ (map.querySelector('.deck-map-plane'))
170
+ const svg = /** @type {SVGSVGElement} */ (plane.querySelector('.deck-map-lines'))
171
+ const root = /** @type {HTMLElement} */ (plane.querySelector('.deck-map-tree > li'))
172
+ const place = () => {
173
+ if (!map.clientWidth || !map.clientHeight) return
174
+ const em = parseFloat(getComputedStyle(plane).fontSize) || 16
175
+ /** @type {[HTMLElement, Box][]} */
176
+ const placed = []
177
+ const boxOf = (/** @type {Element} */ li) => {
178
+ const node = /** @type {HTMLElement} */ (li.firstElementChild)
179
+ const list = li.lastElementChild?.tagName === 'UL' ? [...li.lastElementChild.children] : []
180
+ /** @type {Box} */
181
+ const box = { w: node.offsetWidth / em, h: node.offsetHeight / em, children: list.map(boxOf) }
182
+ placed.push([node, box])
183
+ return box
184
+ }
185
+ const { width, height, lines } = layoutMap(boxOf(root), { rtl: getComputedStyle(map).direction === 'rtl', aspect: map.clientWidth / map.clientHeight })
186
+ // As shares of the plane: a node's own em is its own type's.
187
+ for (const [node, box] of placed) {
188
+ node.style.setProperty('left', `${(/** @type {number} */ (box.x) / width) * 100}%`)
189
+ node.style.setProperty('top', `${(/** @type {number} */ (box.y) / height) * 100}%`)
190
+ node.setAttribute('data-side', box.side === 1 ? 'right' : box.side === -1 ? 'left' : 'middle')
191
+ }
192
+ plane.style.setProperty('width', `${width}em`)
193
+ plane.style.setProperty('height', `${height}em`)
194
+ svg.setAttribute('viewBox', `0 0 ${width.toFixed(3)} ${height.toFixed(3)}`)
195
+ const groups = [...svg.children]
196
+ for (const group of groups) group.replaceChildren()
197
+ for (const line of lines) {
198
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
199
+ path.setAttribute('d', curveOf(line))
200
+ path.setAttribute('data-depth', String(line.depth))
201
+ groups[line.branch]?.append(path)
202
+ }
203
+ plane.style.setProperty('scale', String(Math.min(1, map.clientWidth / (width * em), map.clientHeight / (height * em))))
204
+ }
205
+ const watcher = new ResizeObserver(place)
206
+ watcher.observe(map)
207
+ for (const node of plane.querySelectorAll('.deck-map-node')) watcher.observe(node)
208
+ }
package/src/pptx.js CHANGED
@@ -2,7 +2,10 @@
2
2
  // slide layout of its own (a master, in PptxGenJS's words) with a placeholder for its title and
3
3
  // each of its slots, so a slide added there looks like the deck's; words as PowerPoint's own text,
4
4
  // a list's levels as its levels; a table as a table; a chart of bars, lines, areas, pies, rings,
5
- // points or a radar as PowerPoint's own chart, its numbers in the file to change; anything else (a
5
+ // points or a radar as PowerPoint's own chart, its numbers in the file to change; a mind map as
6
+ // PowerPoint's own shapes, its words in boxes joined by curves; a figure (cards, key figures, a
7
+ // timeline, chevrons, a programme, a pyramid, a bento, parts face to face or in a matrix, the
8
+ // deck's contents) as PowerPoint's own shapes and boxes of words, where the slide has them; anything else (a
6
9
  // diagram, another chart, a picture) as a picture, a layout's art too, on its slide layout; the speaker's notes as notes; each slide's
7
10
  // transition as PowerPoint's (Morph where it has one, a fade where it hasn't); the look's colours
8
11
  // as the file's theme. Read from the deck as drawn, each box where the browser put it, so the file
@@ -14,19 +17,23 @@ const W = 13.333, H = 7.5
14
17
  // Words: a paragraph that is a picture alone on its line is the picture.
15
18
  const TEXT = 'h1, h2, h3, h4, h5, h6, .deck-h, p:not(.deck-picture), ul, ol, blockquote'
16
19
 
17
- /** @param {string} value rgb(…) or #… @returns {string} six hex digits */
20
+ /** @param {string} value rgb(…), color(srgb …) or #… @returns {string} six hex digits */
18
21
  function hexOf(value) {
19
22
  const m = /rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/.exec(value)
20
23
  if (m) return [m[1], m[2], m[3]].map((n) => Math.round(Number(n)).toString(16).padStart(2, '0')).join('').toUpperCase()
24
+ const srgb = /color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/.exec(value)
25
+ if (srgb) return [srgb[1], srgb[2], srgb[3]].map((n) => Math.round(Number(n) * 255).toString(16).padStart(2, '0')).join('').toUpperCase()
21
26
  return value.replace('#', '').toUpperCase().slice(0, 6) || '000000'
22
27
  }
23
28
  /** @param {string} value */
24
29
  const opaque = (value) => !/rgba\([^)]*,\s*0\)|transparent/.test(value)
30
+ /** A colour's alpha, from rgba(…, a) or color(srgb … / a), else 1. @param {string} value */
31
+ const alphaOf = (value) => { const m = /rgba\((?:[^,]*,){3}\s*([\d.]+)\s*\)|\/\s*([\d.]+)\s*\)/.exec(value); return value === 'transparent' ? 0 : Number(m?.[1] ?? m?.[2] ?? 1) }
25
32
 
26
33
  /**
27
34
  * @param {import('./deck.js').DeckView} view
28
35
  * @param {{ PptxGenJS: any, JSZip: any }} libraries
29
- * @returns {Promise<{ blob: Blob, report: { slides: number, charts: number, pictures: number, tables: number, placeholders: number } }>}
36
+ * @returns {Promise<{ blob: Blob, report: { slides: number, charts: number, pictures: number, tables: number, placeholders: number, maps: number, figures: number } }>}
30
37
  */
31
38
  export async function toPptx(view, { PptxGenJS, JSZip }) {
32
39
  const pres = new PptxGenJS()
@@ -36,7 +43,7 @@ export async function toPptx(view, { PptxGenJS, JSZip }) {
36
43
  const rootStyle = getComputedStyle(view.root)
37
44
  const firstFont = (/** @type {string} */ stack) => stack.split(',').map((f) => f.trim().replace(/^["']|["']$/g, '')).find((f) => !/^(ui-|system-ui|-apple|sans-serif|serif|monospace)/.test(f)) ?? 'Arial'
38
45
  pres.theme = { headFontFace: firstFont(rootStyle.getPropertyValue('--deck-heading')), bodyFontFace: firstFont(rootStyle.getPropertyValue('--deck-body')) }
39
- const report = { slides: 0, charts: 0, pictures: 0, tables: 0, placeholders: 0 }
46
+ const report = { slides: 0, charts: 0, pictures: 0, tables: 0, placeholders: 0, maps: 0, figures: 0 }
40
47
  const rtl = view.dir === 'rtl'
41
48
  const slides = view.slides()
42
49
  const masters = new Map()
@@ -57,6 +64,21 @@ export async function toPptx(view, { PptxGenJS, JSZip }) {
57
64
  const start = parseFloat(pad.paddingInlineStart) * sx, end = parseFloat(pad.paddingInlineEnd) * sx
58
65
  return { x: x + (rtl ? end : start), y, w: w - start - end, h: h * 1.08 }
59
66
  }
67
+ // A chart as PowerPoint's own, else as a picture; a diagram or a picture as a picture.
68
+ const figure = async (/** @type {Element} */ block, /** @type {{ x: number, y: number, w: number, h: number }} */ at) => {
69
+ if (block.matches('.deck-echarts') && /** @type {any} */ (block).chart) {
70
+ const chart = /** @type {any} */ (block).chart
71
+ if (nativeChart(pres, slide, chart, at, { pt, rtl, lang: view.lang, ink: hexOf(style.color) })) report.charts++
72
+ else { slide.addImage({ data: await pictureOf(`data:image/svg+xml;charset=utf-8,${encodeURIComponent(chart.renderToSVGString())}`, at.w / sx * 2, at.h / sx * 2), ...at }); report.pictures++ }
73
+ return
74
+ }
75
+ const img = block.querySelector('img')
76
+ if (!img) return
77
+ // A slide's pictures are read as they come near the screen: one not read yet is read now, so that it has its size.
78
+ if (!img.complete) { img.loading = 'eager'; await img.decode().catch(() => {}) }
79
+ const data = await pictureOf(img.src, img.clientWidth * 2, img.clientHeight * 2)
80
+ if (data) { slide.addImage({ data, ...place(img), altText: img.alt }); report.pictures++ }
81
+ }
60
82
  // Where a layout centres its words, PowerPoint's box centres them too.
61
83
  const anchor = (/** @type {Element} */ slot) => (getComputedStyle(slot).justifyContent === 'center' ? 'middle' : 'top')
62
84
  const pt = (/** @type {number} */ px) => Math.round(px * sx * 72 * 10) / 10
@@ -101,6 +123,9 @@ export async function toPptx(view, { PptxGenJS, JSZip }) {
101
123
  const url = /url\("?(.*?)"?\)/.exec(getComputedStyle(background).backgroundImage)?.[1]
102
124
  const data = url && await pictureOf(url, background.clientWidth * 2, background.clientHeight * 2, true)
103
125
  if (data) { slide.addImage({ data, ...place(background) }); report.pictures++ }
126
+ // The veil over it, as a quote has: the words read on it.
127
+ const veil = getComputedStyle(background, '::after')
128
+ if (veil.content !== 'none' && alphaOf(veil.backgroundColor) > 0) slide.addShape(pres.shapes.RECTANGLE, { ...place(background), fill: { color: hexOf(veil.backgroundColor), transparency: Math.round((1 - alphaOf(veil.backgroundColor)) * 100) }, line: { type: 'none' } })
104
129
  }
105
130
  if (title && names.has('title')) {
106
131
  slide.addText(runs(title, { pt, rtl, lang: view.lang }), { placeholder: 'title' })
@@ -122,19 +147,13 @@ export async function toPptx(view, { PptxGenJS, JSZip }) {
122
147
  } else if (block.matches('.deck-table')) {
123
148
  table(slide, block, at, { pt, rtl, lang: view.lang })
124
149
  report.tables++
125
- } else if (block.matches('.deck-echarts') && /** @type {any} */ (block).chart) {
126
- const chart = /** @type {any} */ (block).chart
127
- if (nativeChart(pres, slide, chart, at, { pt, rtl, lang: view.lang, ink: hexOf(style.color) })) report.charts++
128
- else { slide.addImage({ data: await pictureOf(`data:image/svg+xml;charset=utf-8,${encodeURIComponent(chart.renderToSVGString())}`, at.w / sx * 2, at.h / sx * 2), ...at }); report.pictures++ }
129
- } else {
130
- const img = block.querySelector('img')
131
- if (img) {
132
- // A slide's pictures are read as they come near the screen: one not read yet is read now, so that it has its size.
133
- if (!img.complete) { img.loading = 'eager'; await img.decode().catch(() => {}) }
134
- const data = await pictureOf(img.src, img.clientWidth * 2, img.clientHeight * 2)
135
- if (data) { slide.addImage({ data, ...place(img), altText: img.alt }); report.pictures++ }
136
- }
137
- }
150
+ } else if (block.matches('.deck-mindmap')) {
151
+ await mindmap(pres, slide, /** @type {HTMLElement} */ (block), place, { pt, rtl, lang: view.lang }, figure)
152
+ report.maps++
153
+ } else if (block.matches('.deck-figure')) {
154
+ await drawing(pres, slide, block, place, { pt, rtl, lang: view.lang }, figure)
155
+ report.figures++
156
+ } else await figure(block, at)
138
157
  }
139
158
  }
140
159
  const notes = view.deck.slides[i]?.notes
@@ -162,6 +181,102 @@ export async function toPptx(view, { PptxGenJS, JSZip }) {
162
181
  return { blob, report }
163
182
  }
164
183
 
184
+ /**
185
+ * A mind map as PowerPoint's own shapes, where the slide has them: each line a curve in its branch's colour, each of its
186
+ * words a box, filled and outlined as on the slide (the root, the first branches), its words as runs, a chart or a
187
+ * diagram in it as the slide's are.
188
+ * @param {any} pres @param {any} slide @param {HTMLElement} map @param {(el: Element) => { x: number, y: number, w: number, h: number }} place
189
+ * @param {{ pt: (px: number) => number, rtl: boolean, lang: string }} how
190
+ * @param {(block: Element, at: { x: number, y: number, w: number, h: number }) => Promise<void>} figure
191
+ */
192
+ async function mindmap(pres, slide, map, place, how, figure) {
193
+ const plane = /** @type {HTMLElement} */ (map.querySelector('.deck-map-plane')), svg = /** @type {SVGSVGElement} */ (map.querySelector('.deck-map-lines'))
194
+ // The plane is shrunk to its box: its type and lines are so much smaller on the slide.
195
+ const scale = plane.getBoundingClientRect().width / (plane.offsetWidth || 1)
196
+ const area = place(svg), units = Number(svg.getAttribute('viewBox')?.split(' ')[2]) || 1, inch = area.w / units
197
+ for (const path of svg.querySelectorAll('path')) {
198
+ const [x1, y1, , , , , x2, y2] = (path.getAttribute('d')?.match(/-?[\d.]+/g) ?? []).map((n, i) => (i % 2 ? area.y : area.x) + Number(n) * inch)
199
+ const x = Math.min(x1, x2), y = Math.min(y1, y2), middle = (x1 + x2) / 2 - x
200
+ const stroke = getComputedStyle(path)
201
+ slide.addShape(pres.shapes.CUSTOM_GEOMETRY, {
202
+ x, y, w: Math.max(0.01, Math.abs(x2 - x1)), h: Math.max(0.01, Math.abs(y2 - y1)), line: { color: hexOf(stroke.stroke), width: Math.round(parseFloat(stroke.strokeWidth) * inch * 72 * 10) / 10 },
203
+ points: [{ x: x1 - x, y: y1 - y }, { x: x2 - x, y: y2 - y, curve: { type: 'cubic', x1: middle, y1: y1 - y, x2: middle, y2: y2 - y } }],
204
+ })
205
+ }
206
+ const small = { ...how, pt: (/** @type {number} */ px) => how.pt(px * scale) }
207
+ for (const node of /** @type {NodeListOf<HTMLElement>} */ (map.querySelectorAll('.deck-map-node'))) {
208
+ const style = getComputedStyle(node), at = place(node)
209
+ const align = style.textAlign === 'left' || style.textAlign === 'right' ? style.textAlign : style.textAlign === 'center' ? 'center' : how.rtl ? 'right' : 'left'
210
+ // A node holding a chart: its words alone, over its box, the chart where it is.
211
+ const label = node.matches('.has-block') ? node.querySelector('.deck-map-words') : node
212
+ const words = label ? runs(label, small).map((run) => ({ ...run, options: { ...run.options, align } })) : []
213
+ const border = parseFloat(style.borderTopWidth) || 0, pad = small.pt(parseFloat(style.paddingInlineStart) || 0) / 72
214
+ const fill = alphaOf(style.backgroundColor) > 0 ? { color: hexOf(style.backgroundColor), transparency: Math.round((1 - alphaOf(style.backgroundColor)) * 100) } : undefined
215
+ // PowerPoint sets type a little wider than a browser: words with no box grow away from their line.
216
+ const wide = fill || border ? at.w * 0.04 : at.w * 0.08
217
+ const x = align === 'left' ? at.x : align === 'right' ? at.x - wide : at.x - wide / 2
218
+ slide.addText(label === node && words.length ? words : '', {
219
+ x, y: at.y, w: at.w + wide, h: at.h, margin: [pad * 72, pad * 72, 0, 0], valign: 'middle', align, wrap: true,
220
+ ...(fill || border ? { shape: pres.shapes.ROUNDED_RECTANGLE, rectRadius: Math.min(at.h / 2, small.pt(parseFloat(style.borderTopLeftRadius) || 0) / 72), fill } : {}),
221
+ ...(border ? { line: { color: hexOf(style.borderTopColor), width: Math.round(small.pt(border) * 10) / 10 } } : {}),
222
+ })
223
+ if (label === node) continue
224
+ if (label) slide.addText(words, { ...place(label), margin: 0, valign: 'middle', align: 'center', wrap: true })
225
+ for (const block of node.querySelectorAll('.deck-block')) await figure(block, place(block))
226
+ }
227
+ }
228
+
229
+ /**
230
+ * A figure as PowerPoint's own shapes, as the slide draws them: each shape (a card, a chevron, a level, a dot, a rule) its
231
+ * box, or its polygon, filled and outlined as on the slide, a side's rule a line; then each of its words a box of words
232
+ * where they are, a table as a table, a chart or a diagram as the slide's are.
233
+ * @param {any} pres @param {any} slide @param {Element} root @param {(el: Element) => { x: number, y: number, w: number, h: number }} place
234
+ * @param {{ pt: (px: number) => number, rtl: boolean, lang: string }} how
235
+ * @param {(block: Element, at: { x: number, y: number, w: number, h: number }) => Promise<void>} figure
236
+ */
237
+ async function drawing(pres, slide, root, place, how, figure) {
238
+ const inches = (/** @type {number} */ px) => how.pt(px) / 72
239
+ // In the order the slide stacks them: a medallion over the parts it sits between.
240
+ const layer = (/** @type {Element} */ el) => parseInt(getComputedStyle(el).zIndex, 10) || 0
241
+ for (const shape of [...root.querySelectorAll('.deck-shape')].sort((a, b) => layer(a) - layer(b))) {
242
+ const style = getComputedStyle(shape), at = place(shape), box = shape.getBoundingClientRect()
243
+ const fill = alphaOf(style.backgroundColor) > 0 ? { color: hexOf(style.backgroundColor), transparency: Math.round((1 - alphaOf(style.backgroundColor)) * 100) } : undefined
244
+ const sides = /** @type {const} */ (['Top', 'Right', 'Bottom', 'Left']).map((side) => ({ side, width: parseFloat(style[`border${side}Width`]) || 0, colour: style[`border${side}Color`] })).map((one) => ({ ...one, width: alphaOf(one.colour) > 0 ? one.width : 0 }))
245
+ const even = sides.every((one) => Math.abs(one.width - sides[0].width) < 0.1)
246
+ const line = even && sides[0].width > 0 ? { color: hexOf(sides[0].colour), width: Math.round(how.pt(sides[0].width) * 10) / 10 } : { type: 'none' }
247
+ const polygon = /^polygon\((.*)\)$/.exec(style.clipPath)?.[1]
248
+ if (polygon && (fill || even && sides[0].width)) {
249
+ // Its points as the slide clips it: lengths and shares of its box, calc() as CSS writes it.
250
+ const length = (/** @type {string} */ text, /** @type {number} */ size) => [...text.replace(/^calc\(|\)$/g, '').matchAll(/([+-]?)\s*([\d.]+)(%|px)?/g)].reduce((sum, [, sign, n, unit]) => sum + (sign === '-' ? -1 : 1) * (unit === '%' ? (Number(n) / 100) * size : inches(Number(n))), 0)
251
+ const points = polygon.split(/,(?![^(]*\))/).map((pair) => pair.trim().match(/calc\([^)]*\)|[-\d.]+(?:%|px)?/g) ?? []).map(([x = '0', y = '0']) => ({ x: length(x, at.w), y: length(y, at.h) }))
252
+ slide.addShape(pres.shapes.CUSTOM_GEOMETRY, { ...at, fill, line, points: [...points, { close: true }] })
253
+ } else if (fill || line.width) {
254
+ const radius = style.borderTopLeftRadius.endsWith('%') ? (Math.min(box.width, box.height) * parseFloat(style.borderTopLeftRadius)) / 100 : parseFloat(style.borderTopLeftRadius) || 0
255
+ const round = radius >= Math.min(box.width, box.height) / 2 - 0.5
256
+ const kind = round && Math.abs(box.width - box.height) < 1 ? pres.shapes.OVAL : radius > 0 ? pres.shapes.ROUNDED_RECTANGLE : pres.shapes.RECTANGLE
257
+ slide.addShape(kind, { ...at, fill, line, ...(kind === pres.shapes.ROUNDED_RECTANGLE ? { rectRadius: Math.min(at.h / 2, at.w / 2, inches(radius)) } : {}) })
258
+ }
259
+ // A rule on one side alone, as a line.
260
+ if (!even) for (const { side, width, colour } of sides) {
261
+ if (!width) continue
262
+ const thick = inches(width)
263
+ const edge = { Top: { x: at.x, y: at.y + thick / 2, w: at.w, h: 0 }, Bottom: { x: at.x, y: at.y + at.h - thick / 2, w: at.w, h: 0 }, Left: { x: at.x + thick / 2, y: at.y, w: 0, h: at.h }, Right: { x: at.x + at.w - thick / 2, y: at.y, w: 0, h: at.h } }[side]
264
+ slide.addShape(pres.shapes.LINE, { ...edge, line: { color: hexOf(colour), width: Math.round(how.pt(width) * 10) / 10 } })
265
+ }
266
+ }
267
+ for (const words of root.querySelectorAll('.deck-words')) {
268
+ if (words.parentElement?.closest('.deck-words') || !words.textContent?.trim()) continue
269
+ const style = getComputedStyle(words), at = place(words)
270
+ const align = style.textAlign === 'center' ? 'center' : style.textAlign === 'left' || style.textAlign === 'right' ? style.textAlign : (style.textAlign === 'end') !== how.rtl ? 'right' : 'left'
271
+ // PowerPoint sets type a little wider than a browser: words grow away from where they are aligned.
272
+ const wide = at.w * 0.08, x = align === 'left' ? at.x : align === 'right' ? at.x - wide : at.x - wide / 2
273
+ // Words in a shape of their own (a number in its circle) in its middle; others from their top, as their lines may be fewer.
274
+ slide.addText(runs(words, how).map((run) => ({ ...run, options: { ...run.options, align } })), { x, y: at.y, w: at.w + wide, h: at.h, margin: 0, valign: words.matches('.deck-shape') ? 'middle' : 'top', align, wrap: true })
275
+ }
276
+ for (const one of root.querySelectorAll('.deck-table')) table(slide, one, place(one), how)
277
+ for (const block of root.querySelectorAll('.deck-block')) await figure(block, place(block))
278
+ }
279
+
165
280
  /** A slot's paragraphs' spacing, from the space under its first block. @param {HTMLElement} slot */
166
281
  function spaced(slot) {
167
282
  const first = slot.firstElementChild