@codename/deck 0.2.0 → 0.3.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/template.js CHANGED
@@ -3,15 +3,23 @@
3
3
  // its theme's colours (the first light its paper, the first dark its ink, the first accent its primary, the six accents a
4
4
  // chart's colours) and faces (headings', words'); the first picture on its slide master, its logo, in the corner it sits
5
5
  // in; and each slide layout, a layout of the kit by its name, its placeholders a grid of slots, on the paper its
6
- // background is. The file is read with the platform's own DecompressionStream and its XML with regular expressions, as a
7
- // page under Trusted Types parses no markup. What can't be read is left out, with a reason.
6
+ // background is, with its art: what a slide on it shows besides its placeholders (its background, the master's shapes and
7
+ // pictures, its own, their text), drawn as SVG layers behind the words, each kept once among the kit's pictures. The file is read with the platform's own
8
+ // DecompressionStream and its XML with regular expressions, as a page under Trusted Types parses no markup. What can't be
9
+ // read is left out, with a reason.
8
10
 
9
- import { STANDARD, mergeKits, logoOf } from './kit.js'
11
+ import { STANDARD, mergeKits, logoOf, artOf } from './kit.js'
12
+ import { contrast, rgbOf } from './look.js'
10
13
 
11
14
  /** The slide layouts PowerPoint names as deck's own are: a template's take their place. */
12
15
  const OWN = /** @type {Record<string, string>} */ ({ 'Title Slide': 'cover', 'Title and Content': 'default', 'Section Header': 'section', 'Two Content': 'two-cols-header', 'Title Only': 'center', Blank: 'none' })
13
16
  const PPTX = ['Title Slide', 'Title and Content', 'Section Header', 'Two Content', 'Comparison', 'Title Only', 'Blank', 'Content with Caption', 'Picture with Caption']
14
17
  const PICTURES = /** @type {Record<string, string>} */ ({ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', svg: 'image/svg+xml' })
18
+ /** The pictures a browser draws in a layout's art, and how large one may be. */
19
+ const DRAWN = /** @type {Record<string, string>} */ ({ ...PICTURES, webp: 'image/webp', bmp: 'image/bmp' })
20
+ const PICTURE_MB = 2, ART_MB = 3
21
+ /** A point is 12,700 of PowerPoint's units: art is drawn in points. */
22
+ const EMU = 12700
15
23
 
16
24
  /** A zip's files by name, each read when asked. @param {Uint8Array} bytes */
17
25
  function unzip(bytes) {
@@ -50,17 +58,20 @@ export async function kitFromTemplate(bytes, { name } = {}) {
50
58
  const themePath = [...masterRels.values()].find((path) => /theme\/theme\d+\.xml$/.test(path)) ?? `${root}/theme/theme1.xml`, theme = await text(themePath)
51
59
  const [cx, cy] = ((size) => [Number(attr(size, 'cx') ?? 12192000), Number(attr(size, 'cy') ?? 6858000)])(/<p:sldSz\b[^>]*>/.exec(await text(`${root}/presentation.xml`))?.[0] ?? '')
52
60
 
53
- // Its theme: colours by their role, faces for headings and words.
54
- const scheme = Object.fromEntries([...theme.matchAll(/<a:(dk1|lt1|dk2|lt2|accent[1-6])>\s*<a:(?:srgbClr\b[^>]*\bval|sysClr\b[^>]*\blastClr)="([0-9A-Fa-f]{6})"/g)].map(([, role, value]) => [role, `#${value.toLowerCase()}`]))
61
+ // Its theme: colours by their role, as its master maps them to the paper and the words (bg1, tx1…), faces for headings and words.
62
+ const mapOf = (/** @type {string} */ xml) => Object.fromEntries([...(/<(?:p:clrMap|a:overrideClrMapping)\b[^>]*>/.exec(xml)?.[0] ?? '').matchAll(/(\w+)="(\w+)"/g)].map(([, key, value]) => [key, value]))
63
+ const masterMap = { bg1: 'lt1', tx1: 'dk1', bg2: 'lt2', tx2: 'dk2', ...mapOf(masterXml) }
64
+ const scheme = Object.fromEntries([...theme.matchAll(/<a:(dk1|lt1|dk2|lt2|accent[1-6]|hlink|folHlink)>\s*<a:(?:srgbClr\b[^>]*\bval|sysClr\b[^>]*\blastClr)="([0-9A-Fa-f]{6})"/g)].map(([, role, value]) => [role, `#${value.toLowerCase()}`]))
55
65
  const face = (/** @type {string} */ part) => attr(new RegExp(`<a:${part}>[\\s\\S]*?<a:latin\\b[^>]*>`).exec(theme)?.[0].replace(/^[\s\S]*<a:latin/, '') ?? '', 'typeface')
56
66
  const [heading, body] = [face('majorFont'), face('minorFont')].map((one) => (one && !one.startsWith('+') ? [one, 'ui-sans-serif', 'system-ui', 'sans-serif'] : null))
57
67
  /** @type {Record<string, any>} */
58
- const tokens = { ...(scheme.lt1 && { paper: scheme.lt1 }), ...(scheme.dk1 && { ink: scheme.dk1 }), ...(scheme.accent1 && { primary: scheme.accent1 }), ...(scheme.dk2 && { muted: scheme.dk2 }), ...(scheme.lt2 && { soft: scheme.lt2 }) }
68
+ const tokens = Object.fromEntries(/** @type {[string, string][]} */ ([['paper', masterMap.bg1], ['ink', masterMap.tx1], ['primary', 'accent1'], ['muted', masterMap.tx2], ['soft', masterMap.bg2]]).filter(([, role]) => scheme[role]).map(([key, role]) => [key, scheme[role]]))
59
69
  const accents = [1, 2, 3, 4, 5, 6].map((n) => scheme[`accent${n}`]).filter(Boolean)
60
70
  if (accents.length) tokens.series = accents
61
71
  if (heading || body) tokens.fonts = { heading: heading ?? body, body: body ?? heading }
62
72
 
63
73
  // Its logo: the first picture on its master that a browser draws, in the corner it sits in.
74
+ let logoPath = ''
64
75
  for (const [pic] of masterXml.matchAll(/<p:pic>[\s\S]*?<\/p:pic>/g)) {
65
76
  const path = masterRels.get(String(attr(/<a:blip\b[^>]*>/.exec(pic)?.[0] ?? '', 'r:embed'))), type = PICTURES[/\.(\w+)$/.exec(path ?? '')?.[1].toLowerCase() ?? '']
66
77
  if (!path || !type || !files.has(path)) continue
@@ -68,10 +79,26 @@ export async function kitFromTemplate(bytes, { name } = {}) {
68
79
  if (!logoOf(url)) { problems.push(`the logo ${path.split('/').pop()} is larger than 512 KB`); break }
69
80
  const [x, y] = ((off) => [Number(attr(off, 'x') ?? 0), Number(attr(off, 'y') ?? 0)])(/<a:off\b[^>]*>/.exec(pic)?.[0] ?? '')
70
81
  Object.assign(tokens, { logo: url, logoAt: `${y < cy / 2 ? 'top' : 'bottom'}-${x < cx / 2 ? 'start' : 'end'}` })
82
+ logoPath = path
71
83
  break
72
84
  }
73
85
 
74
- // Its layouts: each placeholder where it sits, else where the master's of its kind sits.
86
+ // What its art is drawn with: its colours by role, its colour map, its styles' fills and lines, its faces, its pictures.
87
+ const lists = (/** @type {string} */ list) => children(new RegExp(`<a:${list}>([\\s\\S]*?)</a:${list}>`).exec(theme)?.[1] ?? '').map((one) => one.xml)
88
+ /** @type {Art} */
89
+ const art = { scheme: Object.fromEntries(Object.entries(scheme).map(([role, hex]) => [role, rgbOf(hex.slice(1))])), map: masterMap, rels: masterRels, files, size: [cx / EMU, cy / EMU],
90
+ themeRels: targets(await text(themePath.replace(/([^/]+)$/, '_rels/$1.rels')), themePath.replace(/\/[^/]+$/, '')),
91
+ styles: { fill: lists('fillStyleLst'), line: lists('lnStyleLst'), bg: lists('bgFillStyleLst') }, faces: { major: face('majorFont') ?? '', minor: face('minorFont') ?? '' },
92
+ pictures: new Map(), used: new Set(), noted: new Set(), ids: { n: 0 }, defs: [] }
93
+ // The master's shapes, drawn once for each colour map its layouts read them with.
94
+ const underneath = new Map()
95
+ const masterArt = (/** @type {Record<string, string>} */ map) => underneath.get(JSON.stringify(map)) ?? underneath.set(JSON.stringify(map), ((used) => drawTree(treeOf(masterXml), { ...art, map, used, ids: { n: 0 } })
96
+ .then((svg) => ({ svg, used })))(new Set())).get(JSON.stringify(map))
97
+ // A colour as its role is, by the paper it is for: the background's its paper, the words' its ink, the first accent its fill, the second background's soft.
98
+ const roles = /** @type {Record<string, string>} */ (Object.fromEntries([[masterMap.bg2, 'soft'], ['accent1', 'fill'], [masterMap.tx1, 'ink'], [masterMap.bg1, 'paper']].filter(([role]) => scheme[role]).map(([role, paper]) => [scheme[role], paper])))
99
+ const [ink, paper] = [art.scheme[masterMap.tx1] ?? [0, 0, 0], art.scheme[masterMap.bg1] ?? [255, 255, 255]]
100
+
101
+ // Its layouts: each placeholder where it sits, else where the master's of its kind sits; its art behind them.
75
102
  const placed = (/** @type {string} */ xml) => [...xml.matchAll(/<p:sp>[\s\S]*?<\/p:sp>/g)].flatMap(([sp]) => {
76
103
  const ph = /<p:ph\b[^>]*>/.exec(sp)?.[0]
77
104
  if (!ph) return []
@@ -79,42 +106,439 @@ export async function kitFromTemplate(bytes, { name } = {}) {
79
106
  return [{ type: attr(ph, 'type') ?? 'body', name: attr(/<p:cNvPr\b[^>]*>/.exec(sp)?.[0] ?? '', 'name') ?? '', box: off && ext ? [Number(attr(off, 'x')), Number(attr(off, 'y')), Number(attr(ext, 'cx')), Number(attr(ext, 'cy'))] : null }]
80
107
  })
81
108
  const masterBoxes = placed(masterXml)
82
- const paperOf = (/** @type {string} */ xml) => {
83
- const fill = /<p:bg>[\s\S]*?<\/p:bg>/.exec(xml)?.[0] ?? '', role = attr(/<a:schemeClr\b[^>]*>/.exec(fill)?.[0] ?? '', 'val'), rgb = attr(/<a:srgbClr\b[^>]*>/.exec(fill)?.[0] ?? '', 'val')?.toLowerCase()
84
- return role === 'accent1' || (rgb && `#${rgb}` === scheme.accent1) ? 'fill' : role === 'tx1' || role === 'dk1' || (rgb && `#${rgb}` === scheme.dk1) ? 'ink' : role === 'bg2' || role === 'lt2' ? 'soft' : 'paper'
85
- }
86
109
  /** @type {Record<string, any>} */
87
- const layouts = {}
110
+ const layouts = {}, pictures = /** @type {Record<string, string>} */ ({})
111
+ // A layer of art, kept once among the kit's pictures by what it holds: its name, or none where it is too large.
112
+ const layer = (/** @type {string} */ drawn, /** @type {string} */ where) => {
113
+ const url = drawn ? artOf(svgOf(drawn, art.size)) : null
114
+ if (drawn && !url) problems.push(`layout ${where}: a layer of its art is larger than ${ART_MB} MB`)
115
+ if (!url) return []
116
+ let hash = 2166136261
117
+ for (let i = 0; i < url.length; i++) hash = Math.imul(hash ^ url.charCodeAt(i), 16777619) >>> 0
118
+ pictures[`art-${hash.toString(36)}`] = url
119
+ return [`art-${hash.toString(36)}`]
120
+ }
88
121
  const paths = [...masterRels.values()].filter((path) => /slideLayouts\/slideLayout\d+\.xml$/.test(path)).sort((a, b) => Number(/(\d+)\.xml$/.exec(a)?.[1]) - Number(/(\d+)\.xml$/.exec(b)?.[1]))
89
122
  for (const path of paths) {
90
123
  const xml = await text(path), title = (attr(/<p:cSld\b[^>]*>/.exec(xml)?.[0] ?? '', 'name') ?? '').replace(/&amp;/g, '&').trim()
91
124
  const boxes = placed(xml).filter((one) => !['dt', 'ftr', 'sldNum'].includes(one.type)).map((one) => ({ ...one, box: one.box ?? masterBoxes.find((m) => m.type === one.type || (one.type === 'ctrTitle' && m.type === 'title'))?.box ?? null }))
92
125
  .filter((one) => one.box)
93
126
  const key = OWN[title] ?? (title.toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').replace(/^(?=\d)/, 'l-').slice(0, 32) || `layout-${paths.indexOf(path) + 1}`)
94
- const layout = gridOf(boxes, cx, cy)
127
+ // Its art: its background, the master's shapes unless it hides them, its own; the paper its words read on.
128
+ // Each layer its own SVG, its gradients and patterns numbered from one, so that layers alike are kept once.
129
+ const here = { ...art, map: { ...masterMap, ...mapOf(xml) }, rels: targets(await text(path.replace(/([^/]+)$/, '_rels/$1.rels')), path.replace(/\/[^/]+$/, '')), used: new Set(), ids: { n: 0 } }
130
+ const own = bgOf(xml), back = await backOf(own ?? bgOf(masterXml), { ...here, ids: { n: 0 }, ...(!own && { rels: masterRels }) })
131
+ const under = /<p:sldLayout\b[^>]*\bshowMasterSp="(0|false)"/.test(xml) ? { svg: '', used: new Set() } : await masterArt(here.map)
132
+ const mine = await drawTree(treeOf(xml), here), words = wordsOf(xml, masterXml, here), role = back.rgb ? roles[hexOf(back.rgb)] : undefined
133
+ // Words the template sets light over its art read on ink; else on the paper their background is, a colour of the theme by its role.
134
+ const light = words && contrast(words, ink) > contrast(words, paper)
135
+ const sheet = role === 'fill' ? 'fill' : light && (under.svg || mine || !role) ? 'ink' : role ?? (back.rgb && contrast(back.rgb, ink) < contrast(back.rgb, paper) ? 'ink' : 'paper')
136
+ const layers = [...layer(sheet === role ? '' : back.svg, title || key), ...layer(under.svg, title || key), ...layer(mine, title || key)]
137
+ const layout = gridOf(boxes, cx, cy, layers.length > 0)
95
138
  if (!layout) { problems.push(`layout ${title || key}: its placeholders make no grid deck lays out`); continue }
96
- layouts[key] = { label: title || key, ...layout, paper: paperOf(xml), title: boxes.some((one) => one.type === 'ctrTitle') ? 'huge' : 'normal', logo: STANDARD.layouts[key]?.logo ?? 'small',
139
+ const logo = logoPath && (here.used.has(logoPath) || under.used.has(logoPath)) ? 'none' : STANDARD.layouts[key]?.logo ?? 'small'
140
+ // Its words aligned as its title is.
141
+ const align = /** @type {Record<string, string>} */ ({ ctr: 'center', r: 'end' })[titleSays(xml, masterXml, /<a:(?:lvl1pPr|pPr)\b[^>]*\balgn="(\w+)"/) ?? ''] ?? 'start'
142
+ layouts[key] = { label: title || key, ...layout, paper: sheet, title: boxes.some((one) => one.type === 'ctrTitle') ? 'huge' : 'normal', logo, align,
97
143
  ...(boxes.some((one) => one.type === 'ctrTitle') && { justify: 'center' }), pptx: PPTX.includes(title) ? title : layout.slots.length > 1 ? 'Comparison' : 'Title and Content',
98
- numbered: /<p:ph\b[^>]*type="sldNum"/.test(xml) }
144
+ numbered: /<p:ph\b[^>]*type="sldNum"/.test(xml), ...(layers.length && { art: layers }) }
145
+ }
146
+ // A template has one look: drawn with art, its paper and words by night are its own by day (its main colour is so already).
147
+ if (Object.values(layouts).some((layout) => layout.art)) tokens.dark = Object.fromEntries(['paper', 'ink', 'muted', 'soft'].filter((key) => tokens[key]).map((key) => [key, tokens[key]]))
148
+ const merged = mergeKits({ name: name ?? 'template', tokens, layouts, pictures })
149
+ return { kit: { ...merged.kit, name: name ?? 'template' }, problems: [...problems, ...art.noted, ...merged.problems] }
150
+ }
151
+
152
+ /**
153
+ * What a layout's art is drawn with: the theme's colours by role and the part's colour map, its relationships and the theme's, the theme's
154
+ * fills, lines and faces, the pictures read, those this part drew, what couldn't be drawn, the gradients and patterns a
155
+ * shape paints with, and the group's fill.
156
+ * @typedef {{ scheme: Record<string, number[] | null>, map: Record<string, string>, rels: Map<string, string>, themeRels: Map<string, string>, files: Map<string, () => Promise<Uint8Array>>, size: number[],
157
+ * styles: { fill: string[], line: string[], bg: string[] }, faces: { major: string, minor: string }, pictures: Map<string, Promise<string | null>>, used: Set<string>,
158
+ * noted: Set<string>, ids: { n: number }, defs: string[], group?: string }} Art
159
+ */
160
+ /** @typedef {{ name: string, tag: string, inner: string, xml: string }} Part */
161
+ /** @typedef {{ rgb: number[], alpha: number }} Colour */
162
+
163
+ /** The elements directly inside a piece of XML, in order: each its name, its opening tag, what it holds, and itself. @param {string | undefined} xml @returns {Part[]} */
164
+ function children(xml = '') {
165
+ const out = /** @type {Part[]} */ ([])
166
+ let depth = 0, tag = '', from = 0, at = 0
167
+ for (const m of xml.matchAll(/<(\/?)([\w:]+)[^>]*?(\/?)>/g)) {
168
+ const index = /** @type {number} */ (m.index)
169
+ if (m[1]) { if (--depth === 0) out.push({ name: m[2], tag, inner: xml.slice(from, index), xml: xml.slice(at, index + m[0].length) }) }
170
+ else if (m[3]) { if (!depth) out.push({ name: m[2], tag: m[0], inner: '', xml: m[0] }) }
171
+ else if (!depth++) { tag = m[0]; at = index; from = index + m[0].length }
172
+ }
173
+ return out
174
+ }
175
+ /** The first element of a name directly inside. @param {string | undefined} xml @param {string} name */
176
+ const child = (xml, name) => children(xml).find((one) => one.name === name)
177
+ /** A part's tree of shapes. @param {string} xml */
178
+ const treeOf = (xml) => /<p:spTree>([\s\S]*)<\/p:spTree>/.exec(xml)?.[1] ?? ''
179
+ /** A part's background, if it has one. @param {string} xml */
180
+ const bgOf = (xml) => /<p:bg>([\s\S]*?)<\/p:bg>/.exec(xml)?.[1]
181
+ /** A number as art writes it. @param {number} value */
182
+ const n = (value) => String(Math.round(value * 100) / 100)
183
+
184
+ /** @param {number[]} rgb */
185
+ const hexOf = (rgb) => `#${rgb.map((c) => Math.round(Math.max(0, Math.min(255, c))).toString(16).padStart(2, '0')).join('')}`
186
+ /** @param {number} c */
187
+ const toLinear = (c) => ((c /= 255) <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
188
+ /** @param {number} v */
189
+ const fromLinear = (v) => 255 * (v <= 0.0031308 ? v * 12.92 : 1.055 * Math.max(0, v) ** (1 / 2.4) - 0.055)
190
+ /** @param {number[]} rgb @returns {number[]} hue in degrees, saturation and lightness */
191
+ function hslOf([r, g, b]) {
192
+ const [x, y, z] = [r / 255, g / 255, b / 255], max = Math.max(x, y, z), min = Math.min(x, y, z), l = (max + min) / 2, d = max - min
193
+ if (!d) return [0, 0, l]
194
+ const s = d / (1 - Math.abs(2 * l - 1)), h = max === x ? ((y - z) / d) % 6 : max === y ? (z - x) / d + 2 : (x - y) / d + 4
195
+ return [(h * 60 + 360) % 360, s, l]
196
+ }
197
+ /** @param {number} h @param {number} s @param {number} l */
198
+ function rgbFrom(h, s, l) {
199
+ const c = (1 - Math.abs(2 * Math.min(1, Math.max(0, l)) - 1)) * Math.min(1, s), x = c * (1 - Math.abs(((h / 60) % 2) - 1)), m = Math.min(1, Math.max(0, l)) - c / 2
200
+ const [p, q, u] = h < 60 ? [c, x, 0] : h < 120 ? [x, c, 0] : h < 180 ? [0, c, x] : h < 240 ? [0, x, c] : h < 300 ? [x, 0, c] : [c, 0, x]
201
+ return [p + m, q + m, u + m].map((v) => v * 255)
202
+ }
203
+ const NAMED = /** @type {Record<string, string>} */ ({ black: '000000', white: 'ffffff', gray: '808080', grey: '808080', red: 'ff0000', green: '008000', blue: '0000ff', yellow: 'ffff00' })
204
+
205
+ /**
206
+ * The colour an element holds, its own, its role's through the part's map, or (phClr) its style's, with the changes
207
+ * PowerPoint makes to it: lighter or darker, a tint, a shade, see-through. @param {string} xml @param {Art} art @param {Colour | null} [ph]
208
+ * @returns {Colour | null}
209
+ */
210
+ function colourOf(xml, art, ph = null) {
211
+ const one = children(xml).find((part) => /^a:(srgbClr|schemeClr|sysClr|prstClr|scrgbClr|hslClr)$/.test(part.name))
212
+ if (!one) return null
213
+ const val = attr(one.tag, 'val') ?? '', number = (/** @type {string} */ key) => Number(attr(one.tag, key) ?? 0) / 100000
214
+ let rgb = one.name === 'a:srgbClr' ? rgbOf(val) : one.name === 'a:sysClr' ? rgbOf(attr(one.tag, 'lastClr') ?? (val === 'window' ? 'ffffff' : '000000'))
215
+ : one.name === 'a:schemeClr' ? (val === 'phClr' ? ph?.rgb ?? null : art.scheme[art.map[val] ?? val] ?? null) : one.name === 'a:prstClr' ? rgbOf(NAMED[val] ?? '808080')
216
+ : one.name === 'a:scrgbClr' ? ['r', 'g', 'b'].map((key) => fromLinear(number(key))) : rgbFrom(Number(attr(one.tag, 'hue') ?? 0) / 60000, number('sat'), number('lum'))
217
+ if (!rgb) return null
218
+ let alpha = val === 'phClr' ? ph?.alpha ?? 1 : 1
219
+ for (const change of children(one.inner)) {
220
+ const by = Number(attr(change.tag, 'val') ?? 100000) / 100000, [h, s, l] = hslOf(rgb)
221
+ if (change.name === 'a:alpha') alpha = by
222
+ else if (change.name === 'a:alphaMod') alpha *= by
223
+ else if (change.name === 'a:lumMod') rgb = rgbFrom(h, s, l * by)
224
+ else if (change.name === 'a:lumOff') rgb = rgbFrom(h, s, l + by)
225
+ else if (change.name === 'a:satMod') rgb = rgbFrom(h, s * by, l)
226
+ else if (change.name === 'a:hueMod') rgb = rgbFrom((h * by) % 360, s, l)
227
+ else if (change.name === 'a:hueOff') rgb = rgbFrom((h + by * 100000 / 60000 + 360) % 360, s, l)
228
+ else if (change.name === 'a:shade') rgb = rgb.map((c) => fromLinear(toLinear(c) * by))
229
+ else if (change.name === 'a:tint') rgb = rgb.map((c) => fromLinear(1 - (1 - toLinear(c)) * by))
230
+ }
231
+ return { rgb, alpha }
232
+ }
233
+
234
+ /** A picture of the part's, as a data: address to draw, once read; null where browsers draw none, or it is too large. @param {string | undefined} id @param {Art} art */
235
+ async function hrefOf(id, art) {
236
+ const path = id ? art.rels.get(id) : undefined, read = path && art.files.get(path)
237
+ if (!path || !read) return null
238
+ const type = DRAWN[/\.(\w+)$/.exec(path)?.[1].toLowerCase() ?? ''], file = path.split('/').pop()
239
+ if (!type) { art.noted.add(`the picture ${file} is in a format browsers don't draw`); return null }
240
+ if (!art.pictures.has(path)) art.pictures.set(path, read().then((data) => (data.length > PICTURE_MB * 1024 * 1024 ? (art.noted.add(`the picture ${file} is larger than ${PICTURE_MB} MB`), null) : `data:${type};base64,${base64(data)}`)))
241
+ const href = await art.pictures.get(path)
242
+ if (href) art.used.add(path)
243
+ return href ?? null
244
+ }
245
+
246
+ /**
247
+ * How PowerPoint recolours a picture, as an SVG filter among the part's defs: its greys between two colours (duotone), its
248
+ * brightness and contrast. @param {string} blip @param {Art} art @param {Colour | null} [ph]
249
+ */
250
+ function filterOf(blip, art, ph = null) {
251
+ const duotone = child(blip, 'a:duotone'), lum = child(blip, 'a:lum')
252
+ if (!duotone && !lum) return ''
253
+ const id = `a${++art.ids.n}`, [dark, light] = children(duotone?.inner).map((one) => colourOf(one.xml, art, ph)?.rgb ?? [0, 0, 0])
254
+ // Each colour from the grey a pixel is, as sRGB weighs it.
255
+ const row = (/** @type {number} */ i) => ((d) => `${n(d * 0.2126)} ${n(d * 0.7152)} ${n(d * 0.0722)} 0 ${n(dark[i] / 255)}`)((light[i] - dark[i]) / 255)
256
+ const [bright, contrast] = ['bright', 'contrast'].map((key) => Number(attr(lum?.tag ?? '', key) ?? 0) / 100000), slope = contrast < 0 ? 1 + contrast : 1 / Math.max(0.01, 1 - contrast)
257
+ const funcs = ['R', 'G', 'B'].map((c) => `<feFunc${c} type="linear" slope="${n(slope)}" intercept="${n(bright + 0.5 * (1 - slope))}"/>`).join('')
258
+ art.defs.push(`<filter id="${id}" color-interpolation-filters="sRGB">${lum ? `<feComponentTransfer>${funcs}</feComponentTransfer>` : ''}${duotone && light ? `<feColorMatrix type="matrix" values="${row(0)} ${row(1)} ${row(2)} 0 0 0 1 0"/>` : ''}</filter>`)
259
+ return ` filter="url(#${id})"`
260
+ }
261
+
262
+ /**
263
+ * How a fill or a line paints, as SVG's attributes: a colour, a gradient, a picture (the last two among the part's defs), or
264
+ * none; undefined where the part gives none, so that its style's does. @param {string} xml what holds the fill @param {'fill' | 'stroke'} as @param {Art} art @param {Colour | null} [ph]
265
+ * @returns {Promise<string | undefined>}
266
+ */
267
+ async function paintOf(xml, as, art, ph = null) {
268
+ for (const one of children(xml)) {
269
+ if (one.name === 'a:noFill') return `${as}="none"`
270
+ if (one.name === 'a:solidFill' || one.name === 'a:pattFill') {
271
+ const colour = colourOf(one.name === 'a:pattFill' ? child(one.inner, 'a:fgClr')?.inner ?? '' : one.inner, art, ph)
272
+ return colour ? `${as}="${hexOf(colour.rgb)}"${colour.alpha < 1 ? ` ${as}-opacity="${n(colour.alpha)}"` : ''}` : `${as}="none"`
273
+ }
274
+ if (one.name === 'a:gradFill') {
275
+ const stops = children(child(one.inner, 'a:gsLst')?.inner).map((gs) => /** @type {const} */ ([Number(attr(gs.tag, 'pos') ?? 0) / 100000, colourOf(gs.inner, art, ph)])).sort((a, b) => a[0] - b[0])
276
+ const id = `a${++art.ids.n}`, path = child(one.inner, 'a:path'), stop = stops.map(([at, colour]) => (colour ? `<stop offset="${n(at)}" stop-color="${hexOf(colour.rgb)}"${colour.alpha < 1 ? ` stop-opacity="${n(colour.alpha)}"` : ''}/>` : '')).join('')
277
+ if (!stop) return `${as}="none"`
278
+ if (path) {
279
+ // From the middle of its rectangle out to the shape's corners.
280
+ const rect = child(path.inner, 'a:fillToRect')?.tag ?? '', [l, t, r, b] = ['l', 't', 'r', 'b'].map((key) => Number(attr(rect, key) ?? 0) / 100000), x = (l + 1 - r) / 2, y = (t + 1 - b) / 2
281
+ art.defs.push(`<radialGradient id="${id}" cx="${n(x)}" cy="${n(y)}" r="${n(Math.hypot(Math.max(x, 1 - x), Math.max(y, 1 - y)))}">${stop}</radialGradient>`)
282
+ } else {
283
+ const angle = (Number(attr(child(one.inner, 'a:lin')?.tag ?? '', 'ang') ?? 0) / 60000) * (Math.PI / 180), dx = Math.cos(angle) / 2, dy = Math.sin(angle) / 2
284
+ art.defs.push(`<linearGradient id="${id}" x1="${n(0.5 - dx)}" y1="${n(0.5 - dy)}" x2="${n(0.5 + dx)}" y2="${n(0.5 + dy)}">${stop}</linearGradient>`)
285
+ }
286
+ return `${as}="url(#${id})"`
287
+ }
288
+ if (one.name === 'a:blipFill') {
289
+ const blip = child(one.inner, 'a:blip'), href = await hrefOf(attr(blip?.tag ?? '', 'r:embed'), art), id = `a${++art.ids.n}`
290
+ if (!href || as !== 'fill') return `${as}="none"`
291
+ const amount = attr(child(blip?.inner, 'a:alphaModFix')?.tag ?? '', 'amt')
292
+ art.defs.push(`<pattern id="${id}" width="1" height="1" patternContentUnits="objectBoundingBox"><image width="1" height="1" preserveAspectRatio="none"${filterOf(blip?.inner ?? '', art, ph)}${
293
+ amount ? ` opacity="${n(Number(amount) / 100000)}"` : ''} xlink:href="${href}"/></pattern>`)
294
+ return `fill="url(#${id})"`
295
+ }
296
+ if (one.name === 'a:grpFill') return art.group ?? `${as}="none"`
297
+ }
298
+ return undefined
299
+ }
300
+
301
+ /** A closed outline through points. @param {...number[]} points */
302
+ const poly = (...points) => `M${points.map(([x, y]) => `${n(x)} ${n(y)}`).join('L')}Z`
303
+ /** A rectangle w by h with its corners rounded, from the top start: top-left, top-right, bottom-right, bottom-left. @param {number} w @param {number} h @param {number[]} r */
304
+ const rounded = (w, h, [a, b, c, d]) => `M${n(a)} 0H${n(w - b)}${b ? `A${n(b)} ${n(b)} 0 0 1 ${n(w)} ${n(b)}` : ''}V${n(h - c)}${c ? `A${n(c)} ${n(c)} 0 0 1 ${n(w - c)} ${n(h)}` : ''}H${n(d)}${
305
+ d ? `A${n(d)} ${n(d)} 0 0 1 0 ${n(h - d)}` : ''}V${n(a)}${a ? `A${n(a)} ${n(a)} 0 0 1 ${n(a)} 0` : ''}Z`
306
+ /**
307
+ * PowerPoint's own shapes that templates draw with, as outlines in a box w by h; `a(name, fallback)` an adjustment as a share,
308
+ * `m` the box's shorter side. @type {Record<string, (w: number, h: number, a: (name: string, fallback: number) => number, m: number) => string>}
309
+ */
310
+ const SHAPES = {
311
+ rect: (w, h) => poly([0, 0], [w, 0], [w, h], [0, h]),
312
+ roundRect: (w, h, a, m) => rounded(w, h, Array(4).fill(m * a('adj', 16667))),
313
+ round1Rect: (w, h, a, m) => rounded(w, h, [0, m * a('adj', 16667), 0, 0]),
314
+ round2SameRect: (w, h, a, m) => rounded(w, h, [m * a('adj1', 16667), m * a('adj1', 16667), m * a('adj2', 0), m * a('adj2', 0)]),
315
+ round2DiagRect: (w, h, a, m) => rounded(w, h, [m * a('adj1', 16667), m * a('adj2', 0), m * a('adj1', 16667), m * a('adj2', 0)]),
316
+ snip1Rect: (w, h, a, m) => ((s) => poly([0, 0], [w - s, 0], [w, s], [w, h], [0, h]))(m * a('adj', 16667)),
317
+ snip2SameRect: (w, h, a, m) => ((s) => poly([s, 0], [w - s, 0], [w, s], [w, h], [0, h], [0, s]))(m * a('adj1', 16667)),
318
+ ellipse: (w, h) => `M0 ${n(h / 2)}A${n(w / 2)} ${n(h / 2)} 0 1 1 ${n(w)} ${n(h / 2)}A${n(w / 2)} ${n(h / 2)} 0 1 1 0 ${n(h / 2)}Z`,
319
+ triangle: (w, h, a) => poly([w * a('adj', 50000), 0], [w, h], [0, h]),
320
+ rtTriangle: (w, h) => poly([0, 0], [w, h], [0, h]),
321
+ diamond: (w, h) => poly([w / 2, 0], [w, h / 2], [w / 2, h], [0, h / 2]),
322
+ parallelogram: (w, h, a, m) => ((x) => poly([x, 0], [w, 0], [w - x, h], [0, h]))(m * a('adj', 25000)),
323
+ trapezoid: (w, h, a, m) => ((x) => poly([x, 0], [w - x, 0], [w, h], [0, h]))(m * a('adj', 25000)),
324
+ homePlate: (w, h, a, m) => ((x) => poly([0, 0], [x, 0], [w, h / 2], [x, h], [0, h]))(w - m * a('adj', 50000)),
325
+ chevron: (w, h, a, m) => ((x) => poly([0, 0], [w - x, 0], [w, h / 2], [w - x, h], [0, h], [x, h / 2]))(m * a('adj', 50000)),
326
+ pentagon: (w, h) => poly([w / 2, 0], [w, h * 0.382], [w * 0.809, h], [w * 0.191, h], [0, h * 0.382]),
327
+ hexagon: (w, h, a, m) => ((x) => poly([x, 0], [w - x, 0], [w, h / 2], [w - x, h], [x, h], [0, h / 2]))(m * a('adj', 25000)),
328
+ octagon: (w, h, a, m) => ((x) => poly([x, 0], [w - x, 0], [w, x], [w, h - x], [w - x, h], [x, h], [0, h - x], [0, x]))(m * a('adj', 29289)),
329
+ star5: (w, h) => poly(...Array.from({ length: 10 }, (_, i) => ((r, t) => [w / 2 + (w / 2) * r * Math.cos(t), h / 2 + (h / 2) * r * Math.sin(t)])(i % 2 ? 0.382 : 1, -Math.PI / 2 + (i * Math.PI) / 5))),
330
+ line: (w, h) => `M0 0L${n(w)} ${n(h)}`,
331
+ bentConnector2: (w, h) => `M0 0H${n(w)}V${n(h)}`,
332
+ bentConnector3: (w, h, a) => `M0 0H${n(w * a('adj1', 50000))}V${n(h)}H${n(w)}`,
333
+ curvedConnector3: (w, h, a) => ((x) => `M0 0C${n(x)} 0 ${n(x)} ${n(h)} ${n(w)} ${n(h)}`)(w * a('adj1', 50000)),
334
+ }
335
+ Object.assign(SHAPES, { straightConnector1: SHAPES.line, flowChartProcess: SHAPES.rect, flowChartAlternateProcess: SHAPES.roundRect, flowChartDecision: SHAPES.diamond, flowChartConnector: SHAPES.ellipse })
336
+ /** The shapes that are lines alone. */
337
+ const LINES = new Set(['line', 'straightConnector1', 'bentConnector2', 'bentConnector3', 'curvedConnector3'])
338
+ /** A line's dashes, as a share of its width. */
339
+ const DASHES = /** @type {Record<string, number[]>} */ ({ dash: [4, 3], sysDash: [3, 1], dot: [1, 3], sysDot: [1, 1], lgDash: [8, 3], dashDot: [4, 3, 1, 3], lgDashDot: [8, 3, 1, 3], lgDashDotDot: [8, 3, 1, 3, 1, 3], sysDashDot: [3, 1, 1, 1], sysDashDotDot: [3, 1, 1, 1, 1, 1] })
340
+
341
+ /**
342
+ * A shape's outline in its own box, w by h, as the paths it is drawn with, each filled or stroked or both: one of
343
+ * PowerPoint's own, or its own geometry; null where it has none, undefined where it is one deck doesn't know.
344
+ * @param {string} spPr @param {number} w @param {number} h @returns {{ d: string, fill: boolean, stroke: boolean }[] | null | undefined}
345
+ */
346
+ function outlineOf(spPr, w, h) {
347
+ const custom = child(spPr, 'a:custGeom')
348
+ if (custom) {
349
+ return children(child(custom.inner, 'a:pathLst')?.inner).filter((path) => path.name === 'a:path').flatMap((path) => {
350
+ const pw = Number(attr(path.tag, 'w') ?? 0), ph = Number(attr(path.tag, 'h') ?? 0), sx = pw ? w / pw : 1 / EMU, sy = ph ? h / ph : 1 / EMU
351
+ let d = '', at = [0, 0]
352
+ for (const step of children(path.inner)) {
353
+ const points = children(step.inner).filter((pt) => pt.name === 'a:pt').map((pt) => [Number(attr(pt.tag, 'x')) * sx, Number(attr(pt.tag, 'y')) * sy])
354
+ const xy = points.map(([x, y]) => `${n(x)} ${n(y)}`).join(' ')
355
+ if (step.name === 'a:moveTo') d += `M${xy}`
356
+ else if (step.name === 'a:lnTo') d += `L${xy}`
357
+ else if (step.name === 'a:cubicBezTo') d += `C${xy}`
358
+ else if (step.name === 'a:quadBezTo') d += `Q${xy}`
359
+ else if (step.name === 'a:close') d += 'Z'
360
+ else if (step.name === 'a:arcTo') {
361
+ // An arc on the ellipse its radii make, from where the path is, by the angles PowerPoint gives in 60,000ths of a degree.
362
+ const [rx, ry] = [Number(attr(step.tag, 'wR') ?? 0) * sx, Number(attr(step.tag, 'hR') ?? 0) * sy], [start, sweep] = ['stAng', 'swAng'].map((key) => (Number(attr(step.tag, key) ?? 0) / 60000) * (Math.PI / 180))
363
+ const end = [at[0] - rx * Math.cos(start) + rx * Math.cos(start + sweep), at[1] - ry * Math.sin(start) + ry * Math.sin(start + sweep)]
364
+ d += `A${n(rx)} ${n(ry)} 0 ${Math.abs(sweep) > Math.PI ? 1 : 0} ${sweep > 0 ? 1 : 0} ${n(end[0])} ${n(end[1])}`
365
+ at = end
366
+ }
367
+ if (points.length) at = points[points.length - 1]
368
+ }
369
+ return d && !d.includes('NaN') ? [{ d, fill: attr(path.tag, 'fill') !== 'none', stroke: !/^(0|false)$/.test(attr(path.tag, 'stroke') ?? '') }] : []
370
+ })
371
+ }
372
+ const preset = child(spPr, 'a:prstGeom')
373
+ if (!preset) return null
374
+ const kind = attr(preset.tag, 'prst') ?? '', given = Object.fromEntries(children(child(preset.inner, 'a:avLst')?.inner).map((gd) => [attr(gd.tag, 'name'), Number(/^val\s+(-?\d+)$/.exec(attr(gd.tag, 'fmla') ?? '')?.[1])]))
375
+ const d = SHAPES[kind]?.(w, h, (name, fallback) => (Number.isFinite(given[name]) ? given[name] : fallback) / 100000, Math.min(w, h))
376
+ return d ? [{ d, fill: !LINES.has(kind), stroke: true }] : undefined
377
+ }
378
+
379
+ /**
380
+ * Where a part sits, in points: its box, how SVG moves its own box there (its place and its turn about its middle), and
381
+ * its flips. @param {Part | undefined} xfrm
382
+ */
383
+ function frameOf(xfrm) {
384
+ const off = child(xfrm?.inner, 'a:off'), ext = child(xfrm?.inner, 'a:ext')
385
+ if (!xfrm || !off || !ext) return null
386
+ const [x, y, w, h] = [attr(off.tag, 'x'), attr(off.tag, 'y'), attr(ext.tag, 'cx'), attr(ext.tag, 'cy')].map((v) => Number(v ?? 0) / EMU)
387
+ const turn = Number(attr(xfrm.tag, 'rot') ?? 0) / 60000, [fh, fv] = ['flipH', 'flipV'].map((key) => /^(1|true)$/.test(attr(xfrm.tag, key) ?? ''))
388
+ return { x, y, w, h, move: `translate(${n(x)} ${n(y)})${turn ? ` rotate(${n(turn)} ${n(w / 2)} ${n(h / 2)})` : ''}`, flip: fh || fv ? ` matrix(${fh ? -1 : 1} 0 0 ${fv ? -1 : 1} ${fh ? n(w) : 0} ${fv ? n(h) : 0})` : '' }
389
+ }
390
+
391
+ /** A shape's fill: its own, else its style's, the theme's fill (or background fill) in the style's colour; else none. @param {string} spPr @param {string} style @param {Art} art */
392
+ async function fillOf(spPr, style, art) {
393
+ const own = await paintOf(spPr, 'fill', art), ref = child(style, 'a:fillRef'), idx = Number(attr(ref?.tag ?? '', 'idx') ?? 0)
394
+ const themed = idx > 1000 ? art.styles.bg[idx - 1001] : idx ? art.styles.fill[idx - 1] : undefined
395
+ return own ?? (themed && ref && await paintOf(themed, 'fill', { ...art, rels: art.themeRels }, colourOf(ref.inner, art))) ?? 'fill="none"'
396
+ }
397
+
398
+ /** A shape's line: its own, else its style's, the theme's line in the style's colour; its width and dashes; else none. @param {string} spPr @param {string} style @param {Art} art */
399
+ async function strokeOf(spPr, style, art) {
400
+ const ln = child(spPr, 'a:ln'), ref = child(style, 'a:lnRef'), idx = Number(attr(ref?.tag ?? '', 'idx') ?? 0), themed = idx ? children(art.styles.line[idx - 1] ?? '')[0] : undefined
401
+ const paint = (ln && await paintOf(ln.inner, 'stroke', art)) ?? (themed && ref && await paintOf(themed.inner, 'stroke', art, colourOf(ref.inner, art)))
402
+ if (!paint || paint === 'stroke="none"') return ''
403
+ const width = Number(attr(ln?.tag ?? '', 'w') ?? attr(themed?.tag ?? '', 'w') ?? 9525) / EMU, dash = DASHES[attr(child(ln?.inner, 'a:prstDash')?.tag ?? child(themed?.inner, 'a:prstDash')?.tag ?? '', 'val') ?? '']
404
+ return ` ${paint} stroke-width="${n(width)}"${dash ? ` stroke-dasharray="${dash.map((one) => n(one * width)).join(' ')}"` : ''}`
405
+ }
406
+
407
+ /**
408
+ * A shape's words, as PowerPoint sets them in its box: each paragraph in its first run's size, weight, colour and face (the
409
+ * theme's where it names one), aligned as it says, wrapped at the box's width as near as a face's average letter goes, the
410
+ * whole at the top, middle or bottom; fields (a slide's number, its date) are left to deck.
411
+ * @param {string} txBody @param {number} w @param {number} h @param {string} style @param {Art} art
412
+ */
413
+ function wordsIn(txBody, w, h, style, art) {
414
+ const bodyPr = child(txBody, 'a:bodyPr'), inset = (/** @type {string} */ key, /** @type {number} */ fallback) => Number(attr(bodyPr?.tag ?? '', key) ?? fallback) / EMU
415
+ const [l, t, r, b] = [inset('lIns', 91440), inset('tIns', 45720), inset('rIns', 91440), inset('bIns', 45720)], wraps = attr(bodyPr?.tag ?? '', 'wrap') !== 'none'
416
+ const scale = Number(attr(child(bodyPr?.inner, 'a:normAutofit')?.tag ?? '', 'fontScale') ?? 100000) / 100000, level = child(child(txBody, 'a:lstStyle')?.inner, 'a:lvl1pPr'), given = child(level?.inner, 'a:defRPr')
417
+ const fontRef = child(style, 'a:fontRef'), ink = (fontRef && colourOf(fontRef.inner, art)) ?? { rgb: art.scheme[art.map.tx1 ?? 'dk1'] ?? [0, 0, 0], alpha: 1 }
418
+ const lines = /** @type {{ words: string, size: number, align: string, colour: Colour, face: string, bold: boolean, italic: boolean }[]} */ ([])
419
+ for (const p of children(txBody).filter((one) => one.name === 'a:p')) {
420
+ const runs = children(p.inner).filter((one) => one.name === 'a:r' || one.name === 'a:br'), rPr = child(runs.find((one) => one.name === 'a:r')?.inner, 'a:rPr') ?? child(p.inner, 'a:endParaRPr')
421
+ const own = (/** @type {string} */ key) => attr(rPr?.tag ?? '', key) ?? attr(given?.tag ?? '', key)
422
+ const size = (Number(own('sz') ?? 1800) / 100) * scale, align = attr(child(p.inner, 'a:pPr')?.tag ?? '', 'algn') ?? attr(level?.tag ?? '', 'algn') ?? 'l'
423
+ const colour = colourOf(child(rPr?.inner, 'a:solidFill')?.inner ?? '', art) ?? colourOf(child(given?.inner, 'a:solidFill')?.inner ?? '', art) ?? ink
424
+ const typeface = attr(child(rPr?.inner, 'a:latin')?.tag ?? '', 'typeface') ?? '+mn-lt', face = typeface.startsWith('+mj') ? art.faces.major : typeface.startsWith('+') ? art.faces.minor : typeface
425
+ const text = runs.map((one) => (one.name === 'a:br' ? '\n' : /<a:t(?:\s[^>]*)?>([\s\S]*?)<\/a:t>/.exec(one.inner)?.[1] ?? '')).join('')
426
+ .replace(/(&[^;\s]+;)|[^&]+/g, (some, entity) => entity ?? (own('cap') === 'all' ? some.toLocaleUpperCase() : some))
427
+ const room = Math.max(1, Math.floor((w - l - r) / (size * (own('b') === '1' ? 0.6 : 0.55))))
428
+ for (const line of text.split('\n')) {
429
+ // Wrapped where its words would pass the box's width.
430
+ const fits = (/** @type {string} */ words) => words.replace(/&[^;\s]+;/g, '_').length <= room
431
+ const pieces = !wraps ? [line] : line.split(' ').reduce((/** @type {string[]} */ kept, word) => (kept.length && fits(`${kept[kept.length - 1]} ${word}`) ? [...kept.slice(0, -1), `${kept[kept.length - 1]} ${word}`] : [...kept, word]), [])
432
+ for (const words of pieces.length ? pieces : ['']) lines.push({ words, size, align, colour, face, bold: own('b') === '1', italic: own('i') === '1' })
433
+ }
434
+ }
435
+ const tall = lines.reduce((sum, line) => sum + line.size * 1.2, 0), anchor = attr(bodyPr?.tag ?? '', 'anchor') ?? 't'
436
+ let top = anchor === 'ctr' ? t + (h - t - b - tall) / 2 : anchor === 'b' ? h - b - tall : t
437
+ return lines.map((line) => {
438
+ const y = top + line.size * 0.95, [x, at] = line.align === 'ctr' ? [l + (w - l - r) / 2, 'middle'] : line.align === 'r' ? [w - r, 'end'] : [l, 'start']
439
+ top += line.size * 1.2
440
+ return line.words.trim() ? `<text x="${n(x)}" y="${n(y)}" font-size="${n(line.size)}" font-family="${[line.face.replace(/["'&<>]/g, ''), 'ui-sans-serif', 'system-ui', 'sans-serif'].filter(Boolean).map((one) => (/\s/.test(one) ? `'${one}'` : one)).join(', ')}" fill="${
441
+ hexOf(line.colour.rgb)}"${line.colour.alpha < 1 ? ` fill-opacity="${n(line.colour.alpha)}"` : ''}${at === 'start' ? '' : ` text-anchor="${at}"`}${line.bold ? ' font-weight="bold"' : ''}${line.italic ? ' font-style="italic"' : ''} xml:space="preserve">${line.words}</text>` : ''
442
+ }).join('')
443
+ }
444
+
445
+ /** A shape (or a line between shapes): its outline filled and stroked, its words over it; a placeholder is the grid's. @param {string} xml @param {Art} art */
446
+ async function shapeOf(xml, art) {
447
+ if (/<p:ph\b/.test((child(xml, 'p:nvSpPr') ?? child(xml, 'p:nvCxnSpPr'))?.inner ?? '')) return ''
448
+ const spPr = child(xml, 'p:spPr')?.inner ?? '', style = child(xml, 'p:style')?.inner ?? '', at = frameOf(child(spPr, 'a:xfrm')), own = { ...art, defs: /** @type {string[]} */ ([]) }
449
+ if (!at) return ''
450
+ const paths = outlineOf(spPr, at.w, at.h)
451
+ if (paths === undefined) own.noted.add(`the shape ${attr(child(spPr, 'a:prstGeom')?.tag ?? '', 'prst')} is left out`)
452
+ const fill = paths?.some((one) => one.fill) ? await fillOf(spPr, style, own) : 'fill="none"', stroke = paths?.some((one) => one.stroke) ? await strokeOf(spPr, style, own) : ''
453
+ const drawn = (paths ?? []).filter((one) => (one.fill && fill !== 'fill="none"') || (one.stroke && stroke)).map((one) => `<path d="${one.d}" ${one.fill ? fill : 'fill="none"'}${one.stroke ? stroke : ''}/>`).join('')
454
+ const words = wordsIn(child(xml, 'p:txBody')?.inner ?? '', at.w, at.h, style, own)
455
+ return `${own.defs.join('')}${drawn ? `<g transform="${at.move}${at.flip}">${drawn}</g>` : ''}${words ? `<g transform="${at.move}">${words}</g>` : ''}`
456
+ }
457
+
458
+ /** A picture where it sits, cropped as its template crops it, in its shape, see-through as it says, its line round it. @param {string} xml @param {Art} art */
459
+ async function pictureOf(xml, art) {
460
+ if (/<p:ph\b/.test(child(xml, 'p:nvPicPr')?.inner ?? '')) return ''
461
+ const fill = child(xml, 'p:blipFill')?.inner ?? '', blip = child(fill, 'a:blip'), spPr = child(xml, 'p:spPr')?.inner ?? '', at = frameOf(child(spPr, 'a:xfrm'))
462
+ // An SVG beside a picture PowerPoint draws in its stead.
463
+ const svg = /<asvg:svgBlip\b[^>]*\br:embed="([^"]+)"/.exec(blip?.inner ?? '')?.[1], href = (svg && await hrefOf(svg, art)) || await hrefOf(attr(blip?.tag ?? '', 'r:embed'), art)
464
+ if (!at || !href) return ''
465
+ const crop = child(fill, 'a:srcRect')?.tag ?? '', [l, t, r, b] = ['l', 't', 'r', 'b'].map((key) => Number(attr(crop, key) ?? 0) / 100000), w = at.w / (1 - l - r || 1), h = at.h / (1 - t - b || 1)
466
+ const amount = attr(child(blip?.inner, 'a:alphaModFix')?.tag ?? '', 'amt'), outline = outlineOf(spPr, at.w, at.h)?.[0], id = outline && !/\bprst="rect"/.test(spPr) ? `a${++art.ids.n}` : ''
467
+ const own = { ...art, defs: /** @type {string[]} */ ([]) }, stroke = outline ? await strokeOf(spPr, child(xml, 'p:style')?.inner ?? '', own) : '', filter = filterOf(blip?.inner ?? '', own)
468
+ return `${own.defs.join('')}<g transform="${at.move}${at.flip}"${amount ? ` opacity="${n(Number(amount) / 100000)}"` : ''}>${id ? `<clipPath id="${id}"><path d="${outline?.d}"/></clipPath><g clip-path="url(#${id})">` : '<g>'}<svg width="${
469
+ n(at.w)}" height="${n(at.h)}"><image x="${n(-l * w)}" y="${n(-t * h)}" width="${n(w)}" height="${n(h)}" preserveAspectRatio="none"${filter} xlink:href="${href}"/></svg></g>${stroke ? `<path d="${outline?.d}" fill="none"${stroke}/>` : ''}</g>`
470
+ }
471
+
472
+ /** A group: its shapes, from its own place and size into where it sits, filled where they take its fill. @param {string} xml @param {Art} art */
473
+ async function groupOf(xml, art) {
474
+ const pr = child(xml, 'p:grpSpPr')?.inner ?? '', xfrm = child(pr, 'a:xfrm'), at = frameOf(xfrm), own = { ...art, defs: /** @type {string[]} */ ([]) }
475
+ const [cx, cy, cw, ch] = [attr(child(xfrm?.inner, 'a:chOff')?.tag ?? '', 'x'), attr(child(xfrm?.inner, 'a:chOff')?.tag ?? '', 'y'), attr(child(xfrm?.inner, 'a:chExt')?.tag ?? '', 'cx'), attr(child(xfrm?.inner, 'a:chExt')?.tag ?? '', 'cy')].map((v) => Number(v ?? 0) / EMU)
476
+ const inner = await drawTree(xml, { ...own, group: await paintOf(pr, 'fill', own) })
477
+ if (!inner) return ''
478
+ return `${own.defs.join('')}<g${at ? ` transform="${at.move}${at.flip} scale(${n(cw ? at.w / cw : 1)} ${n(ch ? at.h / ch : 1)}) translate(${n(-cx)} ${n(-cy)})"` : ''}>${inner}</g>`
479
+ }
480
+
481
+ /** A tree of shapes, pictures and groups, drawn in its order; placeholders, tables and charts are left to the grid. @param {string} xml @param {Art} art @returns {Promise<string>} */
482
+ async function drawTree(xml, art) {
483
+ let out = ''
484
+ for (const one of children(xml)) {
485
+ if (one.name === 'mc:AlternateContent') out += await drawTree((child(one.inner, 'mc:Fallback') ?? child(one.inner, 'mc:Choice'))?.inner ?? '', art)
486
+ else if (one.name === 'p:sp' || one.name === 'p:cxnSp') out += await shapeOf(one.inner, art)
487
+ else if (one.name === 'p:pic') out += await pictureOf(one.inner, art)
488
+ else if (one.name === 'p:grpSp') out += await groupOf(one.inner, art)
99
489
  }
100
- const merged = mergeKits({ name: name ?? 'template', tokens, layouts })
101
- return { kit: { ...merged.kit, name: name ?? 'template' }, problems: [...problems, ...merged.problems] }
490
+ return out
491
+ }
492
+
493
+ /**
494
+ * A background: its colour where it is one (the first light where there is none), and the whole slide painted with it, its
495
+ * own fill or its theme's (bgRef), a colour, a gradient or a picture. @param {string | undefined} bg @param {Art} art
496
+ */
497
+ async function backOf(bg, art) {
498
+ const pr = child(bg, 'p:bgPr'), ref = child(bg, 'p:bgRef'), idx = Number(attr(ref?.tag ?? '', 'idx') ?? 0), own = { ...art, defs: /** @type {string[]} */ ([]) }
499
+ // A theme without its fills paints the reference's colour.
500
+ const fill = pr?.inner ?? (idx > 1000 ? art.styles.bg[idx - 1001] : idx ? art.styles.fill[idx - 1] : undefined) ?? (ref ? '<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>' : ''), ph = ref ? colourOf(ref.inner, art) : null
501
+ const solid = child(fill, 'a:solidFill'), rgb = solid ? colourOf(solid.inner, art, ph)?.rgb ?? null : bg ? null : art.scheme.lt1 ?? [255, 255, 255]
502
+ const paint = (await paintOf(fill, 'fill', pr ? own : { ...own, rels: art.themeRels }, ph)) ?? (rgb ? `fill="${hexOf(rgb)}"` : undefined)
503
+ return { rgb, svg: paint && paint !== 'fill="none"' ? `${own.defs.join('')}<rect width="${n(art.size[0])}" height="${n(art.size[1])}" ${paint}/>` : '' }
102
504
  }
103
505
 
506
+ /**
507
+ * Something a layout's title says of its words, a pattern's first group: on the layout, else on the master's title, else in
508
+ * the master's title style. @param {string} xml @param {string} masterXml @param {RegExp} pattern
509
+ */
510
+ function titleSays(xml, masterXml, pattern) {
511
+ const title = (/** @type {string} */ part) => pattern.exec(/<p:txBody>[\s\S]*?<\/p:txBody>/.exec([...part.matchAll(/<p:sp>[\s\S]*?<\/p:sp>/g)].find(([sp]) => /<p:ph\b[^>]*type="(title|ctrTitle)"/.test(sp))?.[0] ?? '')?.[0] ?? '')?.[1]
512
+ return title(xml) ?? title(masterXml) ?? pattern.exec(/<p:titleStyle>[\s\S]*?<\/p:titleStyle>/.exec(masterXml)?.[0] ?? '')?.[1]
513
+ }
514
+
515
+ /** The colour a layout's words are, as its title is; else the first dark. @param {string} xml @param {string} masterXml @param {Art} art */
516
+ function wordsOf(xml, masterXml, art) {
517
+ const fill = titleSays(xml, masterXml, /<a:solidFill>([\s\S]*?)<\/a:solidFill>/)
518
+ return (fill && colourOf(fill, art)?.rgb) ?? art.scheme[art.map.tx1 ?? 'dk1'] ?? null
519
+ }
520
+
521
+ /**
522
+ * A layer of art as a picture: an SVG of the slide's size in points, stretched to the slide, written out as a data: address
523
+ * (its pictures inside it already base64, so not again). @param {string} drawn @param {number[]} size
524
+ */
525
+ const svgOf = (drawn, [w, h]) => `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${n(w)}" height="${n(h)}" viewBox="0 0 ${n(w)} ${n(h)}" preserveAspectRatio="none">${drawn}</svg>`)
526
+ .replace(/%2F/g, '/').replace(/%2B/g, '+').replace(/%3D/g, '=')}`
527
+
104
528
  /**
105
529
  * Placeholders as a grid of slots: the titles the default slot, with the box under them across the same columns; the other
106
530
  * boxes by the names deck gave them (`body-right`, as its PowerPoint writes them), else `left`, `middle` and `right` side by
107
- * side, else `s1`, `s2`…; the margins left to deck. @param {{ type: string, name: string, box: number[] | null }[]} boxes
108
- * @param {number} cx @param {number} cy
531
+ * side, else `s1`, `s2`…; the margins left to deck, or, for a layout with art, the whole slide, each box where the template
532
+ * has it. @param {{ type: string, name: string, box: number[] | null }[]} boxes @param {number} cx @param {number} cy @param {boolean} [whole]
109
533
  */
110
- function gridOf(boxes, cx, cy) {
534
+ function gridOf(boxes, cx, cy, whole = false) {
111
535
  const isTitle = (/** @type {{ type: string }} */ one) => ['title', 'ctrTitle', 'subTitle'].includes(one.type)
112
536
  const box = (/** @type {{ box: number[] | null }} */ one) => /** @type {number[]} */ (one.box)
113
537
  const across = (/** @type {number[]} */ a, /** @type {number[]} */ b) => Math.abs(a[0] - b[0]) < cx * 0.05 && Math.abs(a[2] - b[2]) < cx * 0.05 && a[1] > b[1]
114
538
  const heading = boxes.filter(isTitle), own = (/** @type {{ name: string }} */ one) => /^body-([a-z][a-z0-9-]{0,31})$/.exec(one.name)?.[1]
115
539
  const sorted = boxes.filter((one) => !isTitle(one)).sort((a, b) => box(a)[0] - box(b)[0] || box(a)[1] - box(b)[1])
116
540
  // The heading's slot's own words: the only box, or the one under the heading across its columns.
117
- const under = !heading.length ? undefined : sorted.length === 1 && !own(sorted[0]) ? sorted[0] : sorted.find((one) => own(one) === 'default' || (!own(one) && across(box(one), box(heading[0]))))
541
+ const under = !heading.length ? undefined : sorted.length === 1 && !own(sorted[0]) && box(sorted[0])[1] >= box(heading[0])[1] ? sorted[0] : sorted.find((one) => own(one) === 'default' || (!own(one) && across(box(one), box(heading[0]))))
118
542
  const titles = under ? [...heading, under] : heading, others = sorted.filter((one) => one !== under)
119
543
  const union = (/** @type {number[][]} */ list) => (list.length ? ((xs, ys, xe, ye) => [xs, ys, xe - xs, ye - ys])(Math.min(...list.map((b) => b[0])), Math.min(...list.map((b) => b[1])),
120
544
  Math.max(...list.map((b) => b[0] + b[2])), Math.max(...list.map((b) => b[1] + b[3]))) : null)
@@ -127,15 +551,16 @@ function gridOf(boxes, cx, cy) {
127
551
  /** @type {[string, number[]][]} */
128
552
  const slots = [['default', first], ...rest.map((one, i) => /** @type {[string, number[]]} */ ([own(one) ?? names[i], box(one)]))]
129
553
  // The grid's lines, near ones as one, then each cell the slot whose box holds its middle.
130
- const lines = (/** @type {number} */ from, /** @type {number} */ size, /** @type {number} */ total) => [...new Set(slots.flatMap(([, b]) => [b[from], b[from] + b[size]]))].sort((a, b) => a - b)
554
+ const lines = (/** @type {number} */ from, /** @type {number} */ size, /** @type {number} */ total) => [...new Set([...(whole ? [0, total] : []), ...slots.flatMap(([, b]) => [b[from], b[from] + b[size]])])].sort((a, b) => a - b)
131
555
  .reduce((/** @type {number[]} */ kept, at) => (kept.length && at - kept[kept.length - 1] < total * 0.03 ? kept : [...kept, at]), [])
132
556
  const xs = lines(0, 2, cx), ys = lines(1, 3, cy)
133
- const holds = (/** @type {number[]} */ b, /** @type {number} */ x, /** @type {number} */ y) => x >= b[0] - cx * 0.03 && x <= b[0] + b[2] + cx * 0.03 && y >= b[1] - cy * 0.03 && y <= b[1] + b[3] + cy * 0.03
557
+ // Near enough to a box, but never a margin of the whole slide's.
558
+ const near = whole ? 0.01 : 0.03, holds = (/** @type {number[]} */ b, /** @type {number} */ x, /** @type {number} */ y) => x >= b[0] - cx * near && x <= b[0] + b[2] + cx * near && y >= b[1] - cy * near && y <= b[1] + b[3] + cy * near
134
559
  const areas = ys.slice(1).map((y, r) => xs.slice(1).map((x, c) => slots.find(([, b]) => holds(b, (xs[c] + x) / 2, (ys[r] + y) / 2))?.[0] ?? '.'))
135
- if (!areas.length || !areas[0].length) return null
560
+ if (!areas.length || !areas[0].length || !areas.flat().includes('default')) return null
136
561
  const fr = (/** @type {number[]} */ edges, /** @type {number} */ total) => (edges.length === 2 ? ['1fr'] : edges.slice(1).map((at, i) => `${Math.max(0.1, Math.round(((at - edges[i]) / total) * 100) / 10)}fr`))
137
- // A row that holds only the heading takes what it needs.
138
- const rows = fr(ys, cy).map((track, r) => (slots.length > 1 && areas[r].every((cell) => cell === 'default' || cell === '.') ? 'auto' : track))
562
+ // A row that holds only the heading takes what it needs, but where art is drawn for it.
563
+ const rows = fr(ys, cy).map((track, r) => (!whole && slots.length > 1 && areas[r].every((cell) => cell === 'default' || cell === '.') ? 'auto' : track))
139
564
  return { slots: slots.map(([one]) => one), areas: areas.map((row) => row.join(' ')).join(' | '), columns: fr(xs, cx).join(' '), rows: rows.join(' ') }
140
565
  }
141
566
 
package/src/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // deck's version, which a linked file names in its renderer's address. Kept equal to package.json's by a test.
2
- export const VERSION = '0.2.0'
2
+ export const VERSION = '0.3.0'