@zumer/snapdom-plugins 3.0.0 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +50 -16
- package/agent-map.d.ts +32 -0
- package/agent-map.js +5 -3
- package/ascii-export.d.ts +10 -0
- package/ascii-export.js +21 -6
- package/capture-frames.js +5 -5
- package/color-tint.js +4 -0
- package/context-export.d.ts +23 -0
- package/context-export.js +4 -1
- package/filter.js +1 -1
- package/gif-export.d.ts +6 -0
- package/gif-export.js +3 -3
- package/html-export.d.ts +8 -0
- package/html-export.js +6 -4
- package/index.d.ts +2 -1
- package/index.js +2 -1
- package/package.json +22 -6
- package/pdf/LICENSE +21 -0
- package/pdf/README.md +22 -0
- package/pdf/REFERENCE.md +520 -0
- package/pdf/src/frontmatter.js +200 -0
- package/pdf/src/furniture.js +398 -0
- package/pdf/src/index.js +2415 -0
- package/pdf/src/mega/README.md +47 -0
- package/pdf/src/mega/diag.js +148 -0
- package/pdf/src/mega/flow.js +56 -0
- package/pdf/src/mega/fonts.js +287 -0
- package/pdf/src/mega/forms.js +479 -0
- package/pdf/src/mega/labels.js +43 -0
- package/pdf/src/mega/meta.js +145 -0
- package/pdf/src/nav.js +357 -0
- package/pdf/src/paginate.js +583 -0
- package/pdf/src/tagged.js +355 -0
- package/pdf/src/text-layer.js +1771 -0
- package/pdf/src/writer/encrypt.js +218 -0
- package/pdf/src/writer/font.js +507 -0
- package/pdf/src/writer/index.js +1135 -0
- package/pdf/src/writer/subset.js +387 -0
- package/pdf.d.ts +294 -0
- package/pdf.js +1 -0
- package/redact-inputs.js +6 -6
- package/vector/CONTRACT.md +906 -0
- package/vector/LICENSE +21 -0
- package/vector/README.md +35 -0
- package/vector/src/adapters/snapdom.js +795 -0
- package/vector/src/collect/box.js +1676 -0
- package/vector/src/collect/font.js +487 -0
- package/vector/src/collect/image.js +1458 -0
- package/vector/src/collect/mathml.js +272 -0
- package/vector/src/collect/text.js +3027 -0
- package/vector/src/css/color.js +823 -0
- package/vector/src/css/gradient.js +1126 -0
- package/vector/src/css/matrix.js +398 -0
- package/vector/src/css/radius.js +124 -0
- package/vector/src/css/shadow.js +201 -0
- package/vector/src/emit/figma-json.js +1995 -0
- package/vector/src/emit/h2d-vector.js +215 -0
- package/vector/src/emit/h2d.js +1147 -0
- package/vector/src/emit/pdf.js +2571 -0
- package/vector/src/emit/svg-flat.js +4842 -0
- package/vector/src/exclusion.js +13 -0
- package/vector/src/index.js +2910 -0
- package/vector/src/paint.js +2908 -0
- package/vector/src/public.js +57 -0
- package/vector/src/writer/encrypt.js +227 -0
- package/vector/src/writer/font.js +507 -0
- package/vector/src/writer/index.js +1148 -0
- package/vector/src/writer/subset.js +387 -0
- package/vector/svd/index.js +72 -0
- package/vector/svd/schema.js +985 -0
- package/vector.d.ts +28 -0
- package/vector.js +1 -0
- package/video-export.d.ts +6 -0
- package/video-export.js +7 -7
- package/pdf-image.d.ts +0 -18
- package/pdf-image.js +0 -114
package/pdf/src/nav.js
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigation: internal destinations and the outline (what a viewer shows as
|
|
3
|
+
* "bookmarks").
|
|
4
|
+
*
|
|
5
|
+
* Both are read from the laid-out DOM mounted in an isolated iframe from
|
|
6
|
+
* SnapDOM's FINAL SVG artifact, in the same pass that measures the text layer. A
|
|
7
|
+
* destination is a point on the page, and a point is only knowable while that
|
|
8
|
+
* artifact's layout still exists. Nothing here writes PDF — it returns viewport
|
|
9
|
+
* CSS px and lets `index.js`, which is the only thing that knows where the page
|
|
10
|
+
* breaks fell, turn them into pages.
|
|
11
|
+
*
|
|
12
|
+
* The split with `text-layer.js` is deliberate. That file answers "what does the
|
|
13
|
+
* raster paint, and where" and has to be exact about glyphs. This one answers
|
|
14
|
+
* "what can the reader jump to", which is a question about DOCUMENT STRUCTURE and
|
|
15
|
+
* has no pixels in it at all: a destination is still useful pointing at a heading
|
|
16
|
+
* the reader can see, even if that heading's own runs were dropped as clipped.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { inspectTextPaint } from './text-layer.js'
|
|
20
|
+
|
|
21
|
+
/** Headings that become outline entries. ARIA's is handled separately — it carries its level. */
|
|
22
|
+
const HEADING_TAGS = { H1: 1, H2: 2, H3: 3, H4: 4, H5: 5, H6: 6 }
|
|
23
|
+
|
|
24
|
+
/** Never walked into, for the same reason the text layer skips them. */
|
|
25
|
+
const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'HEAD', 'TITLE'])
|
|
26
|
+
|
|
27
|
+
/** An outline entry nobody can read is worse than none — a runaway heading is cut. */
|
|
28
|
+
const TITLE_MAX = 240
|
|
29
|
+
|
|
30
|
+
/** Keep outline visibility in step with the text layer's own paint threshold. */
|
|
31
|
+
const ALPHA_MIN = 0.01
|
|
32
|
+
|
|
33
|
+
/** Flattened-tree parent: slots and shadow roots follow what the browser paints. */
|
|
34
|
+
function renderParent(node) {
|
|
35
|
+
if (node.assignedSlot) return node.assignedSlot
|
|
36
|
+
if (node.parentElement) return node.parentElement
|
|
37
|
+
const tree = node.getRootNode && node.getRootNode()
|
|
38
|
+
return tree instanceof ShadowRoot ? tree.host : null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function ariaHidden(el) {
|
|
42
|
+
return el.getAttribute && String(el.getAttribute('aria-hidden')).trim().toLowerCase() === 'true'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Whether a subtree is structurally suppressed. `ownText` additionally asks if
|
|
47
|
+
* THIS element's glyph paint is safe/visible; titleOf uses that only at each text
|
|
48
|
+
* node's actual parent, so a transparent wrapper does not hide a child that
|
|
49
|
+
* explicitly reasserts an opaque colour or `visibility:visible`.
|
|
50
|
+
*/
|
|
51
|
+
function hiddenHeading(el, root, ownText = false) {
|
|
52
|
+
let node = el
|
|
53
|
+
let alpha = 1
|
|
54
|
+
while (node) {
|
|
55
|
+
if (ariaHidden(node)) return true
|
|
56
|
+
const cs = getComputedStyle(node)
|
|
57
|
+
// `visibility` is inherited but a descendant may reassert it, so the computed
|
|
58
|
+
// value on the heading itself is authoritative. display:none is different: an
|
|
59
|
+
// ancestor suppresses the entire subtree and cannot be reasserted below it.
|
|
60
|
+
if (ownText && node === el && cs.visibility !== 'visible') return true
|
|
61
|
+
if (cs.display === 'none') return true
|
|
62
|
+
if (cs.display !== 'contents') {
|
|
63
|
+
alpha *= parseFloat(cs.opacity) || 0
|
|
64
|
+
const paint = inspectTextPaint(node, cs)
|
|
65
|
+
if (paint.unmodelled || (ownText && node === el &&
|
|
66
|
+
(paint.secured || paint.shadowOnly || !paint.paints))) return true
|
|
67
|
+
alpha *= paint.filterAlpha
|
|
68
|
+
}
|
|
69
|
+
if (node === root) break
|
|
70
|
+
node = renderParent(node)
|
|
71
|
+
}
|
|
72
|
+
return alpha < ALPHA_MIN
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The fragment a link points at within THIS document, or null.
|
|
77
|
+
*
|
|
78
|
+
* `el.href` is always absolute, so `#intro` arrives as `https://host/page#intro`
|
|
79
|
+
* and telling an internal link from an external one means comparing everything
|
|
80
|
+
* left of the hash. A bare `#` and `#top` are the document top by HTML's own
|
|
81
|
+
* rule, and both come back as the empty string so a caller can tell "the top"
|
|
82
|
+
* from "no fragment at all" (null).
|
|
83
|
+
*
|
|
84
|
+
* @param {string} href an ABSOLUTE url, as `HTMLAnchorElement.href` gives it
|
|
85
|
+
* @param {string} [base=location.href]
|
|
86
|
+
* @returns {string|null}
|
|
87
|
+
*/
|
|
88
|
+
export function fragmentOf(href, base) {
|
|
89
|
+
if (typeof href !== 'string') return null
|
|
90
|
+
const here = String(base ?? location.href).split('#')[0]
|
|
91
|
+
const hash = href.indexOf('#')
|
|
92
|
+
if (hash < 0) return null
|
|
93
|
+
if (href.slice(0, hash) !== here) return null
|
|
94
|
+
const raw = href.slice(hash + 1)
|
|
95
|
+
if (!raw || raw === 'top') return ''
|
|
96
|
+
try { return decodeURIComponent(raw) } catch { return raw }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* `root.contains` stops at a shadow boundary, so a link in the light DOM pointing
|
|
101
|
+
* at a captured heading would read as "outside the capture" and be dropped. This
|
|
102
|
+
* climbs through hosts the way the flattened tree does.
|
|
103
|
+
*/
|
|
104
|
+
function withinRoot(el, root) {
|
|
105
|
+
let node = el
|
|
106
|
+
while (node) {
|
|
107
|
+
if (node === root) return true
|
|
108
|
+
node = node.parentNode || node.host || null
|
|
109
|
+
}
|
|
110
|
+
return false
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Where a destination points: the top-left of the target's border box, in
|
|
115
|
+
* viewport CSS px. Null when the element has no box at all — `display: none`, or
|
|
116
|
+
* a named anchor in a collapsed subtree — because a point that is nowhere would
|
|
117
|
+
* silently become the page origin.
|
|
118
|
+
*/
|
|
119
|
+
function anchorPoint(el) {
|
|
120
|
+
const rects = el.getClientRects()
|
|
121
|
+
const rect = rects.length ? rects[0] : el.getBoundingClientRect()
|
|
122
|
+
if (!rect.width && !rect.height && !rect.top && !rect.left) return null
|
|
123
|
+
// The whole box, not just the corner: a generated index that cannot paint a
|
|
124
|
+
// heading's script has to be able to CROP that heading out of the raster
|
|
125
|
+
// instead, and a point is not a crop.
|
|
126
|
+
return { x: rect.left, y: rect.top, w: rect.width, h: rect.height }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The visible/accessibility title, not raw textContent: otherwise an aria-hidden
|
|
131
|
+
* span (or an opacity-zero one) becomes public bookmark/TOC text.
|
|
132
|
+
*/
|
|
133
|
+
function titleOf(el) {
|
|
134
|
+
const parts = []
|
|
135
|
+
const read = (node) => {
|
|
136
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
137
|
+
const parent = renderParent(node)
|
|
138
|
+
if (parent && !hiddenHeading(parent, el, true)) parts.push(node.data)
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return
|
|
142
|
+
const tag = node.tagName.toUpperCase()
|
|
143
|
+
if (SKIP_TAGS.has(tag) || (node !== el && hiddenHeading(node, el, false))) return
|
|
144
|
+
|
|
145
|
+
let kids
|
|
146
|
+
if (node.shadowRoot) kids = node.shadowRoot.childNodes
|
|
147
|
+
else if (tag === 'SLOT') kids = node.assignedNodes({ flatten: true })
|
|
148
|
+
else kids = node.childNodes
|
|
149
|
+
for (const kid of kids) read(kid)
|
|
150
|
+
}
|
|
151
|
+
read(el)
|
|
152
|
+
const text = parts.join('').replace(/\s+/g, ' ').trim()
|
|
153
|
+
return text.length > TITLE_MAX ? text.slice(0, TITLE_MAX - 1) + '…' : text
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The heading's own level. An `aria-level` on a `role="heading"` is authoritative
|
|
158
|
+
* where it exists, because that is the level assistive tech reads and the outline
|
|
159
|
+
* is the same promise made to a sighted reader.
|
|
160
|
+
*/
|
|
161
|
+
function levelOf(el, tag) {
|
|
162
|
+
if (el.getAttribute && el.getAttribute('role') === 'heading') {
|
|
163
|
+
const n = parseInt(el.getAttribute('aria-level'), 10)
|
|
164
|
+
if (n >= 1 && n <= 6) return n
|
|
165
|
+
}
|
|
166
|
+
return HEADING_TAGS[tag] || 0
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Flattened-tree walk, with the same dedup rule the text layer relies on: a
|
|
171
|
+
* host's light children are reached through their slot and never as children of
|
|
172
|
+
* the host, so a slotted heading produces ONE outline entry.
|
|
173
|
+
*/
|
|
174
|
+
function walk(node, S) {
|
|
175
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return
|
|
176
|
+
const tag = node.tagName.toUpperCase()
|
|
177
|
+
if (SKIP_TAGS.has(tag) || ariaHidden(node)) return
|
|
178
|
+
|
|
179
|
+
const level = levelOf(node, tag)
|
|
180
|
+
if (level && !hiddenHeading(node, S.root, false)) {
|
|
181
|
+
const at = anchorPoint(node)
|
|
182
|
+
const title = titleOf(node)
|
|
183
|
+
if (at && title) S.headings.push({ level, title, box: at, el: node })
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let kids
|
|
187
|
+
if (node.shadowRoot) {
|
|
188
|
+
if (!S.shadow) return
|
|
189
|
+
kids = node.shadowRoot.childNodes
|
|
190
|
+
} else if (tag === 'SLOT') {
|
|
191
|
+
kids = node.assignedNodes({ flatten: true })
|
|
192
|
+
} else {
|
|
193
|
+
kids = node.childNodes
|
|
194
|
+
}
|
|
195
|
+
for (const kid of kids) walk(kid, S)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Read everything a reader can jump to.
|
|
200
|
+
*
|
|
201
|
+
* @param {Element} root
|
|
202
|
+
* @param {object} [options]
|
|
203
|
+
* @param {Array<{href: string}>} [options.links=[]] the text layer's links, which
|
|
204
|
+
* is where the set of destinations worth resolving comes from: resolving every
|
|
205
|
+
* `id` on the page would cost a `getClientRects` per element for destinations
|
|
206
|
+
* nothing points at.
|
|
207
|
+
* @param {boolean} [options.shadow=true] enter open shadow roots
|
|
208
|
+
* @param {boolean} [options.outline=true] collect headings
|
|
209
|
+
* @param {string} [options.headings] selector overriding `h1`–`h6`. Levels
|
|
210
|
+
* then come from the tag or `aria-level`; an element the selector matches that
|
|
211
|
+
* is neither gets level 1.
|
|
212
|
+
* @returns {{dests: Map<string, {x:number, y:number}>,
|
|
213
|
+
* outline: Array<{level:number, title:string, dest:string}>,
|
|
214
|
+
* warnings: string[]}}
|
|
215
|
+
* `dests` is keyed by the NAME a link's fragment resolves to; the empty string
|
|
216
|
+
* is the document top and is present whenever something points at it. Every
|
|
217
|
+
* `outline[i].dest` is a key of `dests`.
|
|
218
|
+
*/
|
|
219
|
+
export function collectNav(root, options = {}) {
|
|
220
|
+
const { links = [], shadow = true, outline = true, headings = null } = options
|
|
221
|
+
const doc = root.ownerDocument || document
|
|
222
|
+
const dests = new Map()
|
|
223
|
+
const warnings = []
|
|
224
|
+
let unresolved = 0
|
|
225
|
+
let offRoot = 0
|
|
226
|
+
|
|
227
|
+
// The document top is a destination without an element: page 1, top edge. It is
|
|
228
|
+
// added only when something points at it, so a file with no `#top` link carries
|
|
229
|
+
// no name tree entry for it.
|
|
230
|
+
const wanted = new Set()
|
|
231
|
+
for (const link of links) {
|
|
232
|
+
const name = fragmentOf(link.href)
|
|
233
|
+
if (name !== null) wanted.add(name)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
for (const name of wanted) {
|
|
237
|
+
if (name === '') { dests.set('', { x: 0, y: 0, top: true }); continue }
|
|
238
|
+
// getElementById first, then the legacy `<a name>` HTML still honours.
|
|
239
|
+
const el = doc.getElementById(name) ||
|
|
240
|
+
(doc.getElementsByName ? doc.getElementsByName(name)[0] : null)
|
|
241
|
+
if (!el) { unresolved++; continue }
|
|
242
|
+
if (!withinRoot(el, root)) { offRoot++; continue }
|
|
243
|
+
const at = anchorPoint(el)
|
|
244
|
+
if (!at) { unresolved++; continue }
|
|
245
|
+
// A destination is a POINT — `/XYZ` takes nothing else. The rest of the rect
|
|
246
|
+
// is what an outline entry needs for its crop, and it travels on the entry.
|
|
247
|
+
dests.set(name, { x: at.x, y: at.y })
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const S = { headings: [], shadow, root }
|
|
251
|
+
if (outline) {
|
|
252
|
+
if (headings) {
|
|
253
|
+
// An explicit selector is the caller's tree, in document order. It cannot
|
|
254
|
+
// see into shadow roots — `querySelectorAll` never does — and says so.
|
|
255
|
+
let matches = null
|
|
256
|
+
try {
|
|
257
|
+
matches = root.querySelectorAll(headings)
|
|
258
|
+
} catch (error) {
|
|
259
|
+
warnings.push(`invalid outline selector \`${headings}\` — no outline written` +
|
|
260
|
+
(error && error.message ? `: ${error.message}` : '.'))
|
|
261
|
+
}
|
|
262
|
+
for (const el of matches || []) {
|
|
263
|
+
if (hiddenHeading(el, root, false)) continue
|
|
264
|
+
const at = anchorPoint(el)
|
|
265
|
+
const title = titleOf(el)
|
|
266
|
+
if (!at || !title) continue
|
|
267
|
+
S.headings.push({ level: levelOf(el, el.tagName.toUpperCase()) || 1, title, box: at, el })
|
|
268
|
+
}
|
|
269
|
+
// Only when something IS actually out of reach. `querySelectorAll` never
|
|
270
|
+
// enters a shadow root, but a slotted heading lives in the light DOM and is
|
|
271
|
+
// found perfectly well — warning on the mere presence of a shadow root
|
|
272
|
+
// would fire on most design systems and lose nothing, which is how a
|
|
273
|
+
// warning stops being read.
|
|
274
|
+
const missed = matches && shadow ? shadowMatches(root, headings) : 0
|
|
275
|
+
if (missed) {
|
|
276
|
+
warnings.push(`${missed} heading(s) matching \`${headings}\` live inside a shadow root, ` +
|
|
277
|
+
'which a selector cannot enter — they are not in the outline. The default h1–h6 walk ' +
|
|
278
|
+
'goes through the flattened tree and does reach them.')
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
walk(root, S)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// A heading needs a name to be jumped to. Its own `id` is the one a reader may
|
|
286
|
+
// already be linking to from outside the file, so it wins; the generated
|
|
287
|
+
// fallback is namespaced and checked, because colliding with a real id would
|
|
288
|
+
// silently retarget somebody's cross-reference.
|
|
289
|
+
const tree = []
|
|
290
|
+
for (let i = 0; i < S.headings.length; i++) {
|
|
291
|
+
const h = S.headings[i]
|
|
292
|
+
let name = h.el.id || ''
|
|
293
|
+
if (!name || (dests.has(name) && dests.get(name).y !== h.box.y)) name = ''
|
|
294
|
+
if (!name) {
|
|
295
|
+
name = `sd-h${i}`
|
|
296
|
+
while (dests.has(name) || doc.getElementById(name)) name += '_'
|
|
297
|
+
}
|
|
298
|
+
dests.set(name, { x: h.box.x, y: h.box.y })
|
|
299
|
+
// The box travels with the entry: it is what lets a generated index show a
|
|
300
|
+
// heading whose script the plugin's base-14 text cannot paint.
|
|
301
|
+
tree.push({ level: h.level, title: h.title, dest: name, box: h.box })
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (unresolved) {
|
|
305
|
+
warnings.push(`${unresolved} internal link(s) point at a fragment this document has no element for — ` +
|
|
306
|
+
'written as no annotation at all rather than a link that goes nowhere')
|
|
307
|
+
}
|
|
308
|
+
if (offRoot) {
|
|
309
|
+
warnings.push(`${offRoot} internal link(s) point outside the captured element, which is not in the ` +
|
|
310
|
+
'file — the annotation is dropped')
|
|
311
|
+
}
|
|
312
|
+
return { dests, outline: tree, warnings }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* How many elements matching `selector` are inside an open shadow root under
|
|
317
|
+
* `root` — the headings a `querySelectorAll` on the light tree cannot reach.
|
|
318
|
+
* Counts only what is genuinely lost, so the warning it feeds means something.
|
|
319
|
+
*/
|
|
320
|
+
function shadowMatches(root, selector) {
|
|
321
|
+
let n = 0
|
|
322
|
+
const stack = [root]
|
|
323
|
+
while (stack.length) {
|
|
324
|
+
const el = stack.pop()
|
|
325
|
+
if (el.shadowRoot) {
|
|
326
|
+
n += el.shadowRoot.querySelectorAll(selector).length
|
|
327
|
+
for (const kid of el.shadowRoot.children) stack.push(kid)
|
|
328
|
+
}
|
|
329
|
+
for (const kid of el.children) stack.push(kid)
|
|
330
|
+
}
|
|
331
|
+
return n
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Turn the flat, level-tagged list into the nested shape PDF outlines are.
|
|
336
|
+
*
|
|
337
|
+
* A document that starts at `h2`, or jumps `h1` → `h3`, is normal HTML and must
|
|
338
|
+
* not produce an empty top level: a level is nested under the closest PREVIOUS
|
|
339
|
+
* entry with a strictly smaller level, whatever the numeric gap is. That is also
|
|
340
|
+
* what a screen reader does with the same markup.
|
|
341
|
+
*
|
|
342
|
+
* @param {Array<{level:number, title:string, dest:string}>} flat
|
|
343
|
+
* @returns {Array<{title:string, dest:string, children:Array}>}
|
|
344
|
+
*/
|
|
345
|
+
export function outlineTree(flat) {
|
|
346
|
+
const roots = []
|
|
347
|
+
/** @type {Array<{level:number, node:{children:Array}}>} */
|
|
348
|
+
const stack = []
|
|
349
|
+
for (const item of flat) {
|
|
350
|
+
const node = { title: item.title, dest: item.dest, box: item.box || null, children: [] }
|
|
351
|
+
while (stack.length && stack[stack.length - 1].level >= item.level) stack.pop()
|
|
352
|
+
if (stack.length) stack[stack.length - 1].node.children.push(node)
|
|
353
|
+
else roots.push(node)
|
|
354
|
+
stack.push({ level: item.level, node })
|
|
355
|
+
}
|
|
356
|
+
return roots
|
|
357
|
+
}
|