@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/README.md +4 -4
- package/dist/deck.bundle.mjs +100 -94
- package/dist/deck.bundle.mjs.map +4 -4
- package/dist/deck.css +111 -0
- package/dist/deck.js +208 -91
- package/dist/deck.js.map +4 -4
- package/dist/deck.mjs +42 -36
- package/dist/deck.mjs.map +4 -4
- package/dist/template.mjs +3 -3
- package/dist/template.mjs.map +3 -3
- package/dist/types/blocks.d.ts +6 -4
- package/dist/types/figures.d.ts +68 -0
- package/dist/types/kit.d.ts +16 -0
- package/dist/types/mindmap.d.ts +69 -0
- package/dist/types/pptx.d.ts +3 -1
- package/dist/types/render.d.ts +21 -3
- package/dist/types/version.d.ts +1 -1
- package/docs/format.md +85 -4
- package/docs/host.md +3 -3
- package/docs/kits.md +10 -2
- package/package.json +1 -1
- package/src/blocks.js +26 -9
- package/src/deck.css +111 -0
- package/src/deck.js +11 -1
- package/src/figures.js +255 -0
- package/src/kit.js +35 -6
- package/src/mindmap.js +208 -0
- package/src/pptx.js +132 -17
- package/src/render.js +122 -19
- package/src/version.js +1 -1
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;
|
|
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-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|