rapier-markdown-kit 1.1.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.
Files changed (55) hide show
  1. package/LICENSE +40 -0
  2. package/README.md +102 -0
  3. package/dist/agent/vendor/pretext/LICENSE +21 -0
  4. package/dist/agent/vendor/pretext/NOTICE +15 -0
  5. package/dist/agent/vendor/pretext/SOURCE.json +85 -0
  6. package/dist/agent/vendor/pretext/analysis.js +1234 -0
  7. package/dist/agent/vendor/pretext/bidi.js +151 -0
  8. package/dist/agent/vendor/pretext/generated/bidi-data.js +3831 -0
  9. package/dist/agent/vendor/pretext/layout.js +308 -0
  10. package/dist/agent/vendor/pretext/line-break.js +636 -0
  11. package/dist/agent/vendor/pretext/line-text.js +44 -0
  12. package/dist/agent/vendor/pretext/measurement.js +189 -0
  13. package/dist/agent/vendor/pretext/package.json +3 -0
  14. package/dist/agent/vendor/pretext/rich-inline.js +291 -0
  15. package/dist/agent/will.mjs +167 -0
  16. package/dist/kit/assets.mjs +3 -0
  17. package/dist/kit/conformance/01-inline.expected.json +82 -0
  18. package/dist/kit/conformance/01-inline.md +10 -0
  19. package/dist/kit/conformance/02-width-x-align.expected.json +83 -0
  20. package/dist/kit/conformance/02-width-x-align.md +12 -0
  21. package/dist/kit/conformance/03-wrap-around-silhouette.expected.json +177 -0
  22. package/dist/kit/conformance/03-wrap-around-silhouette.md +12 -0
  23. package/dist/kit/conformance/04-wrap-box.expected.json +173 -0
  24. package/dist/kit/conformance/04-wrap-box.md +12 -0
  25. package/dist/kit/conformance/05-behind.expected.json +122 -0
  26. package/dist/kit/conformance/05-behind.md +12 -0
  27. package/dist/kit/conformance/06-front.expected.json +122 -0
  28. package/dist/kit/conformance/06-front.md +12 -0
  29. package/dist/kit/conformance/07-rotate-raster.expected.json +179 -0
  30. package/dist/kit/conformance/07-rotate-raster.md +12 -0
  31. package/dist/kit/conformance/08-drawing-rotation.expected.json +188 -0
  32. package/dist/kit/conformance/08-drawing-rotation.md +12 -0
  33. package/dist/kit/conformance/09-drawing-ring-interior.expected.json +229 -0
  34. package/dist/kit/conformance/09-drawing-ring-interior.md +12 -0
  35. package/dist/kit/conformance/10-both-sides.expected.json +221 -0
  36. package/dist/kit/conformance/10-both-sides.md +10 -0
  37. package/dist/kit/conformance/11-neighbour-skips-image.expected.json +160 -0
  38. package/dist/kit/conformance/11-neighbour-skips-image.md +13 -0
  39. package/dist/kit/conformance/12-heading-barrier.expected.json +164 -0
  40. package/dist/kit/conformance/12-heading-barrier.md +12 -0
  41. package/dist/kit/conformance/13-rtl-text.expected.json +179 -0
  42. package/dist/kit/conformance/13-rtl-text.md +8 -0
  43. package/dist/kit/conformance/README.md +99 -0
  44. package/dist/kit/conformance/measurer.mjs +0 -0
  45. package/dist/kit/conformance/run.mjs +123 -0
  46. package/dist/kit/index.mjs +6 -0
  47. package/dist/kit/layout.mjs +2 -0
  48. package/dist/kit/marks.mjs +2 -0
  49. package/dist/kit/model.mjs +3 -0
  50. package/dist/kit/will.mjs +2 -0
  51. package/dist/layout/model.mjs +364 -0
  52. package/dist/spec/md-assets.mjs +323 -0
  53. package/dist/spec/md-layout.mjs +117 -0
  54. package/dist/spec/md-marks.mjs +78 -0
  55. package/package.json +33 -0
@@ -0,0 +1,364 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import {prepareWithSegments} from '../agent/vendor/pretext/layout.js';
3
+ import {layoutNextRichInlineLineRange, materializeRichInlineLineRange} from '../agent/vendor/pretext/rich-inline.js';
4
+ import {validLayout} from '../spec/md-layout.mjs';
5
+
6
+ const finite = value => typeof value === 'number' && Number.isFinite(value);
7
+ const space = code => code === 32 || code === 9 || code === 10 || code === 13 || code === 12;
8
+ let graphemeSegmenter;
9
+
10
+ function normalize(raw) {
11
+ let start = 0, end = raw.length;
12
+ while (start < end && space(raw.charCodeAt(start))) start++;
13
+ while (end > start && space(raw.charCodeAt(end - 1))) end--;
14
+ const pieces = [], offsets = [start];
15
+ for (let cursor = start; cursor < end;) {
16
+ if (space(raw.charCodeAt(cursor))) {
17
+ while (cursor < end && space(raw.charCodeAt(cursor))) cursor++;
18
+ pieces.push(' ');
19
+ offsets.push(cursor);
20
+ } else {
21
+ const from = cursor;
22
+ while (cursor < end && !space(raw.charCodeAt(cursor))) offsets.push(++cursor);
23
+ pieces.push(raw.slice(from, cursor));
24
+ }
25
+ }
26
+ return {text: pieces.join(''), offsets, trimStart: start, trimEnd: end};
27
+ }
28
+
29
+ export function prepareRun(raw, font, letterSpacing = 0) {
30
+ if (typeof raw !== 'string' || typeof font !== 'string' || !font.trim() || !finite(letterSpacing)) return null;
31
+ const normalized = normalize(raw);
32
+ const prepared = prepareWithSegments(normalized.text, font, {letterSpacing});
33
+ if (!Array.isArray(prepared.segments) || prepared.segments.join('') !== normalized.text) return null;
34
+ const segmentOffsets = [0];
35
+ for (const segment of prepared.segments) segmentOffsets.push(segmentOffsets.at(-1) + segment.length);
36
+ return {raw, font, letterSpacing, ...normalized, prepared, segmentOffsets, graphemes: new Map()};
37
+ }
38
+
39
+ function normalizedOffset(run, cursor) {
40
+ if (!run?.prepared || !cursor || !Number.isInteger(cursor.segmentIndex) || !Number.isInteger(cursor.graphemeIndex)) return null;
41
+ const {segmentIndex, graphemeIndex} = cursor;
42
+ const segments = run.prepared.segments;
43
+ if (segmentIndex < 0 || segmentIndex > segments.length || graphemeIndex < 0) return null;
44
+ if (segmentIndex === segments.length) return graphemeIndex === 0 ? run.text.length : null;
45
+ let offsets = run.graphemes.get(segmentIndex);
46
+ if (!offsets) {
47
+ graphemeSegmenter ||= new Intl.Segmenter(undefined, {granularity: 'grapheme'});
48
+ offsets = [0];
49
+ for (const part of graphemeSegmenter.segment(segments[segmentIndex])) offsets.push(part.index + part.segment.length);
50
+ run.graphemes.set(segmentIndex, offsets);
51
+ }
52
+ return graphemeIndex < offsets.length ? run.segmentOffsets[segmentIndex] + offsets[graphemeIndex] : null;
53
+ }
54
+
55
+ export function cursorOffset(run, cursor) {
56
+ const offset = normalizedOffset(run, cursor);
57
+ return offset === null ? null : run.offsets[offset];
58
+ }
59
+
60
+ export function mapFragment(run, fragment) {
61
+ if (typeof fragment?.text !== 'string') return null;
62
+ const from = normalizedOffset(run, fragment.start), to = normalizedOffset(run, fragment.end);
63
+ if (from === null || to === null || to < from) return null;
64
+ const source = run.text.slice(from, to);
65
+ const plain = source.replaceAll('\u00ad', '');
66
+ const hyphen = fragment.text === plain + '-' && source.endsWith('\u00ad') &&
67
+ fragment.end.graphemeIndex === 0 && fragment.end.segmentIndex < run.prepared.segments.length;
68
+ if (fragment.text !== plain && !hyphen) return null;
69
+ const offsets = [run.offsets[from]];
70
+ for (let index = from; index < to; index++) {
71
+ if (run.text.charCodeAt(index) === 0xad) {
72
+ if (hyphen && index === to - 1) {
73
+ offsets.push(run.offsets[index + 1]);
74
+ } else offsets[offsets.length - 1] = run.offsets[index + 1];
75
+ } else {
76
+ offsets.push(run.offsets[index + 1]);
77
+ }
78
+ }
79
+ return {text: fragment.text, offsets, start: run.offsets[from], end: run.offsets[to]};
80
+ }
81
+
82
+ export function imageBox(columnWidth, naturalWidth, naturalHeight, layout = {}, defaultWidth = naturalWidth) {
83
+ if (![columnWidth, naturalWidth, naturalHeight, defaultWidth].every(value => finite(value) && value > 0) ||
84
+ !validLayout(layout) || layout.align === 'justify') return null;
85
+ const requestedWidth = layout.width == null ? defaultWidth : columnWidth * layout.width / 100;
86
+ if (!finite(requestedWidth) || requestedWidth <= 0) return null;
87
+ const width = Math.min(columnWidth, requestedWidth), height = width * naturalHeight / naturalWidth;
88
+ const center = layout.x == null ? width / 2 : columnWidth * layout.x / 100;
89
+ const x = Math.max(0, Math.min(columnWidth - width, center - width / 2));
90
+ return finite(height) && height > 0 ? {x, width, height} : null;
91
+ }
92
+
93
+ // Fit-then-clamp: scale until the turned AABB fits the column, then clamp its x. One result for live editor, styled export and Share.
94
+ export function rasterReserved(columnWidth, unrotated, rad) {
95
+ if (!unrotated || ![columnWidth, unrotated.x, unrotated.width, unrotated.height].every(value => finite(value) && value >= 0) ||
96
+ columnWidth <= 0 || unrotated.width <= 0 || unrotated.height <= 0) return null;
97
+ if (!rad) return {fit: unrotated, reserved: unrotated, visualDeltaX: 0, visualDeltaY: 0};
98
+ let width = unrotated.width, height = unrotated.height, rotated = rotatedBoundsRad(width, height, rad);
99
+ if (rotated.width > columnWidth) {
100
+ const scale = columnWidth / rotated.width;
101
+ width *= scale;
102
+ height *= scale;
103
+ rotated = rotatedBoundsRad(width, height, rad);
104
+ }
105
+ const center = unrotated.x + unrotated.width / 2;
106
+ const x = Math.max(0, Math.min(columnWidth - rotated.width, center - rotated.width / 2));
107
+ const visualDeltaX = (rotated.width - width) / 2, visualDeltaY = (rotated.height - height) / 2;
108
+ return {fit: {x: x + visualDeltaX, width, height}, reserved: {x, width: rotated.width, height: rotated.height}, visualDeltaX, visualDeltaY};
109
+ }
110
+
111
+
112
+ const shapeGrid = 48, alphaThreshold = .1, profileBands = new WeakMap();
113
+
114
+ export function alphaProfile(image) {
115
+ const src = image.currentSrc || image.getAttribute('src') || '';
116
+ if (!image.complete || !image.naturalWidth || !image.naturalHeight || !src || /^data:image\/jpe?g[;,]/i.test(src)) return null;
117
+ const canvas = image.ownerDocument.createElement('canvas');
118
+ canvas.width = canvas.height = shapeGrid;
119
+ let data;
120
+ try {
121
+ const context = canvas.getContext('2d', {willReadFrequently: true});
122
+ if (!context) return null;
123
+ context.drawImage(image, 0, 0, shapeGrid, shapeGrid);
124
+ data = context.getImageData(0, 0, shapeGrid, shapeGrid).data;
125
+ } catch (_) { return null; }
126
+ finally { canvas.width = canvas.height = 0; }
127
+ const bands = [];
128
+ let opaque = true;
129
+ for (let y = 0; y < shapeGrid; y++) {
130
+ const runs = [];
131
+ let start = -1;
132
+ for (let x = 0; x <= shapeGrid; x++) {
133
+ const alpha = x < shapeGrid ? data[(y * shapeGrid + x) * 4 + 3] : 0;
134
+ if (x < shapeGrid && alpha < 250) opaque = false;
135
+ if (alpha > alphaThreshold * 255) { if (start < 0) start = x; }
136
+ else if (start >= 0) { runs.push([start / shapeGrid, x / shapeGrid]); start = -1; }
137
+ }
138
+ bands.push(runs);
139
+ }
140
+ if (opaque) return null;
141
+ const runsAt = t => bands[Math.max(0, Math.min(shapeGrid - 1, Math.floor(t * shapeGrid)))];
142
+ return {runsAt,
143
+ spanAt(t) { const runs = runsAt(t); return runs.length ? [runs[0][0], runs.at(-1)[1]] : null; }};
144
+ }
145
+
146
+ // Scanline intersection over a normalized polygon; the one implementation for live and export wrap=box.
147
+ export function polygonProfile(corners) {
148
+ return {spanAt(t) {
149
+ const xs = [];
150
+ for (let index = 0; index < corners.length; index++) {
151
+ const [x1, y1] = corners[index], [x2, y2] = corners[(index + 1) % corners.length];
152
+ if ((y1 <= t) === (y2 <= t)) continue;
153
+ xs.push(x1 + (t - y1) / (y2 - y1) * (x2 - x1));
154
+ }
155
+ if (xs.length < 2) return null;
156
+ xs.sort((a, b) => a - b);
157
+ return [xs[0], xs.at(-1)];
158
+ }};
159
+ }
160
+
161
+ // F75-11: w'=w|cos|+h|sin|, h'=w|sin|+h|cos| (markdown-standard.md). Shared by browser.js, interchange.js and share.js.
162
+ export function rotatedBoundsRad(width, height, rad) {
163
+ if (!rad) return {width, height};
164
+ return {width: width * Math.abs(Math.cos(rad)) + height * Math.abs(Math.sin(rad)),
165
+ height: width * Math.abs(Math.sin(rad)) + height * Math.abs(Math.cos(rad))};
166
+ }
167
+
168
+ // The `wrap=box` obstacle for a turned raster: its own untilted rectangle's four corners, rotated
169
+ // `rad` about its centre, normalized into the turned bounding box's own [0,1] space.
170
+ export function rasterTiltProfile(width, height, rad) {
171
+ const cs = Math.cos(rad), sn = Math.sin(rad), rotated = rotatedBoundsRad(width, height, rad);
172
+ const local = [[-width / 2, -height / 2], [width / 2, -height / 2], [width / 2, height / 2], [-width / 2, height / 2]];
173
+ return polygonProfile(local.map(([lx, ly]) => [(rotated.width / 2 + lx * cs - ly * sn) / rotated.width,
174
+ (rotated.height / 2 + lx * sn + ly * cs) / rotated.height]));
175
+ }
176
+
177
+ // Each 48x48 cell of the turned box is unrotated into the raster's own pixels and sampled.
178
+ export function rotatedRasterAlpha(baseAlpha, width, height, rad) {
179
+ if (!baseAlpha) return null;
180
+ if (!rad) return baseAlpha;
181
+ const grid = 48, cs = Math.cos(-rad), sn = Math.sin(-rad), rotated = rotatedBoundsRad(width, height, rad);
182
+ const pivotX = width / 2, pivotY = height / 2, viewX = pivotX - rotated.width / 2, viewY = pivotY - rotated.height / 2;
183
+ const bands = [];
184
+ for (let i = 0; i < grid; i++) {
185
+ const runs = [];
186
+ let start = -1;
187
+ for (let j = 0; j <= grid; j++) {
188
+ let occupied = false;
189
+ if (j < grid) {
190
+ const px = viewX + (j + .5) / grid * rotated.width, py = viewY + (i + .5) / grid * rotated.height;
191
+ const dx = px - pivotX, dy = py - pivotY;
192
+ const nx = (pivotX + dx * cs - dy * sn) / width, ny = (pivotY + dx * sn + dy * cs) / height;
193
+ if (nx >= 0 && nx < 1 && ny >= 0 && ny < 1) occupied = (baseAlpha.runsAt(ny) || []).some(([a, b]) => nx >= a && nx < b);
194
+ }
195
+ if (occupied) { if (start < 0) start = j; } else if (start >= 0) { runs.push([start / grid, j / grid]); start = -1; }
196
+ }
197
+ bands.push(runs);
198
+ }
199
+ return bandsProfile(bands);
200
+ }
201
+
202
+ // One occupancy descriptor (I02): the editor's profile sampled at 48 band centres, shipped as data-rapier-occupancy, read back here.
203
+ export function bandsProfile(bands) {
204
+ if (!Array.isArray(bands) || !bands.length) return null;
205
+ const at = t => bands[Math.max(0, Math.min(bands.length - 1, Math.floor(t * bands.length)))] || [];
206
+ return {runsAt: t => at(t), spanAt(t) { const runs = at(t); return runs.length ? [runs[0][0], runs.at(-1)[1]] : null; }};
207
+ }
208
+
209
+ export function serializeProfile(profile, count = shapeGrid) {
210
+ if (!profile) return '';
211
+ const bands = [];
212
+ let any = false;
213
+ for (let index = 0; index < count; index++) {
214
+ const t = (index + .5) / count;
215
+ let runs = profile.runsAt ? profile.runsAt(t) : null;
216
+ if (!runs) { const span = profile.spanAt ? profile.spanAt(t) : null; runs = span ? [span] : []; }
217
+ const clean = (runs || []).map(([a, b]) => [Math.max(0, Math.min(1, +a)), Math.max(0, Math.min(1, +b))]).filter(([a, b]) => Number.isFinite(a) && Number.isFinite(b) && b > a);
218
+ if (clean.length) any = true;
219
+ bands.push(clean.map(([a, b]) => {
220
+ let left = Math.round(a * 1e4), right = Math.round(b * 1e4);
221
+ // A narrow occupied run must not round to an invalid zero-width descriptor.
222
+ if (left === right) { left = Math.floor(a * 1e4); right = Math.ceil(b * 1e4); }
223
+ return (left / 1e4) + '-' + (right / 1e4);
224
+ }).join(','));
225
+ }
226
+ return any ? bands.join('|') : '';
227
+ }
228
+
229
+ export function parseProfile(text) {
230
+ if (typeof text !== 'string' || !text) return null;
231
+ const bands = text.split('|').map(band => band ? band.split(',').map(run => run.split('-').map(Number)) : []);
232
+ if (bands.length < 2 || bands.some(band => band.some(run => run.length !== 2 || !run.every(Number.isFinite) || run[0] < 0 || run[1] > 1 || run[1] <= run[0]))) return null;
233
+ return bandsProfile(bands);
234
+ }
235
+
236
+ export function pictureSlices(profile, x, y, width, height, gap = 10) {
237
+ if (!profile) return [{x: x - gap, y: y - gap, width: width + gap * 2, height: height + gap * 2}];
238
+ let bands = profileBands.get(profile);
239
+ if (!bands) {
240
+ bands = [];
241
+ let previous = new Map();
242
+ for (let index = 0; index < shapeGrid; index++) {
243
+ const top = index / shapeGrid, bottom = (index + 1) / shapeGrid;
244
+ const runs = [top, (top + bottom) / 2, bottom].flatMap(t => {
245
+ if (profile.runsAt) return profile.runsAt(t) || [];
246
+ const span = profile.spanAt(t);
247
+ return span ? [span] : [];
248
+ }).filter(run => Number.isFinite(run[0]) && Number.isFinite(run[1]) && run[1] > run[0])
249
+ .sort((a, b) => a[0] - b[0]);
250
+ const merged = [];
251
+ for (const run of runs) {
252
+ const last = merged.at(-1);
253
+ if (last && run[0] <= last[1]) last[1] = Math.max(last[1], run[1]);
254
+ else merged.push([...run]);
255
+ }
256
+ const current = new Map();
257
+ for (const [left, right] of merged) {
258
+ const key = left + ':' + right;
259
+ let band = previous.get(key);
260
+ if (band) band.bottom = bottom;
261
+ else { band = {left, right, top, bottom}; bands.push(band); }
262
+ current.set(key, band);
263
+ }
264
+ previous = current;
265
+ }
266
+ profileBands.set(profile, bands);
267
+ }
268
+ return bands.map(band => ({x: x + band.left * width - gap, y: y + band.top * height - gap,
269
+ width: (band.right - band.left) * width + gap * 2, height: (band.bottom - band.top) * height + gap * 2}));
270
+ }
271
+
272
+ export function slotsForBand(width, obstacles, top, height, minWidth = 40) {
273
+ if (!finite(width) || width <= 0 || !finite(top) || !finite(height) || height <= 0 ||
274
+ !finite(top + height) || !finite(minWidth) || minWidth <= 0 || !Array.isArray(obstacles)) return null;
275
+ const blocked = [];
276
+ for (const rect of obstacles) {
277
+ if (!rect || !finite(rect.x) || !finite(rect.y) || !finite(rect.width) || rect.width < 0 ||
278
+ !finite(rect.height) || rect.height < 0 || !finite(rect.x + rect.width) || !finite(rect.y + rect.height)) return null;
279
+ if (!rect.width || !rect.height || rect.y >= top + height || rect.y + rect.height <= top) continue;
280
+ const left = Math.max(0, rect.x), right = Math.min(width, rect.x + rect.width);
281
+ if (right > left) blocked.push({left, right});
282
+ }
283
+ blocked.sort((a, b) => a.left - b.left);
284
+ const slots = [];
285
+ let x = 0;
286
+ for (const interval of blocked) {
287
+ if (interval.left - x >= minWidth) slots.push({x, width: interval.left - x});
288
+ x = Math.max(x, interval.right);
289
+ }
290
+ if (width - x >= minWidth) slots.push({x, width: width - x});
291
+ return slots;
292
+ }
293
+
294
+ export function flowLines(flow, width, top, obstacles, lineHeight, minWidth, direction = 'ltr') {
295
+ const lines = [];
296
+ let cursor = {itemIndex: 0, segmentIndex: 0, graphemeIndex: 0}, y = 0, bottom = 0, rows = 0;
297
+ for (let step = 0; step < 4096 && rows < 768; step++) {
298
+ const bands = slotsForBand(width, obstacles, top + y, lineHeight, Math.min(minWidth, width));
299
+ if (!bands) return null;
300
+ if (direction === 'rtl') bands.reverse();
301
+ let advanced = false;
302
+ for (const band of bands) {
303
+ const range = layoutNextRichInlineLineRange(flow, band.width, cursor);
304
+ if (!range) return lines.length ? {lines, height: Math.max(lineHeight, bottom)} : null;
305
+ if (range.width > width + .5 || range.end.itemIndex === cursor.itemIndex &&
306
+ range.end.segmentIndex === cursor.segmentIndex && range.end.graphemeIndex === cursor.graphemeIndex) return null;
307
+ if (range.width > band.width + .5) continue;
308
+ if (lines.length === 4096) return null;
309
+ lines.push({...materializeRichInlineLineRange(flow, range), ...band, y});
310
+ cursor = range.end; advanced = true; bottom = y + lineHeight;
311
+ }
312
+ if (advanced) { y += lineHeight; rows++; continue; }
313
+ let next = Infinity;
314
+ for (const obstacle of obstacles) if (obstacle.x < width && obstacle.x + obstacle.width > 0 &&
315
+ obstacle.y < top + y + lineHeight && obstacle.y + obstacle.height > top + y)
316
+ next = Math.min(next, obstacle.y + obstacle.height - top);
317
+ if (!Number.isFinite(next) || next <= y) return null;
318
+ y = next;
319
+ }
320
+ return null;
321
+ }
322
+
323
+ // ---- The one wrap shape (live: globalThis.RapierImageLayout; export: modules["layout/model.mjs"]) ----
324
+ // `obstacles` in the paragraph's coordinates. Null when nothing to flow around or too narrow for a float.
325
+ export function wrapShape(side, obstacles, width, top) {
326
+ const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
327
+ const rows = obstacles.filter(obstacle => (obstacle.x > width - (obstacle.x + obstacle.width) ? 'right' : 'left') === side);
328
+ if (!rows.length) return null;
329
+ let reach = 0, first = Infinity, last = -Infinity;
330
+ for (const row of rows) {
331
+ reach = Math.max(reach, side === 'left' ? row.x + row.width : width - row.x);
332
+ first = Math.min(first, row.y); last = Math.max(last, row.y + row.height);
333
+ }
334
+ const boxWidth = clamp(reach, 0, width), startY = Math.max(0, first - top);
335
+ const boxHeight = last - top - startY;
336
+ if (boxWidth < 8 || boxHeight <= 0) return null;
337
+ const origin = top + startY, edge = side === 'left' ? 0 : boxWidth;
338
+ const levels = [...new Set([0, boxHeight, ...rows.flatMap(row =>
339
+ [clamp(row.y - origin, 0, boxHeight), clamp(row.y + row.height - origin, 0, boxHeight)])])].sort((a, b) => a - b);
340
+ const points = [[edge, 0]];
341
+ for (let i = 0; i + 1 < levels.length; i++) {
342
+ const a = levels[i], b = levels[i + 1];
343
+ let span = 0;
344
+ for (const row of rows) if (row.y < origin + b && row.y + row.height > origin + a)
345
+ span = Math.max(span, side === 'left' ? row.x + row.width : width - row.x);
346
+ span = clamp(span, 0, boxWidth);
347
+ const x = side === 'left' ? span : boxWidth - span;
348
+ const previous = points.at(-1);
349
+ if (previous[0] !== x) points.push([x, a]);
350
+ else if (points.length > 1) points.pop();
351
+ points.push([x, b]);
352
+ }
353
+ points.push([edge, boxHeight]);
354
+ return {boxWidth, boxHeight, startY, points};
355
+ }
356
+
357
+ // ---- The one "paragraph holds only the picture": one meaningful child, the image or an <a> wrapping only it ----
358
+ export function imageOnly(paragraph, image) {
359
+ const meaningful = node => [...node.childNodes].filter(child => child.nodeType !== 8 &&
360
+ !(child.nodeType === 3 && !child.textContent.trim()));
361
+ const children = meaningful(paragraph);
362
+ return children.length === 1 && (children[0] === image || children[0].nodeType === 1 &&
363
+ children[0].tagName === 'A' && meaningful(children[0]).length === 1 && meaningful(children[0])[0] === image);
364
+ }