slidemind 0.3.20 → 0.4.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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +592 -16
- package/LICENSE +4 -0
- package/README.md +203 -8
- package/THIRD-PARTY-NOTICES.md +201 -0
- package/bin/slidemind.mjs +50 -2
- package/package.json +5 -3
- package/skills/create-slides/SKILL.md +484 -886
- package/skills/search/SKILL.md +46 -23
- package/src/bridge/build-and-push.mjs +548 -73
- package/src/bridge/bundle.mjs +100 -31
- package/src/bridge/capture.mjs +14 -17
- package/src/bridge/client/brands.mjs +157 -18
- package/src/bridge/client/engine-routing.mjs +48 -13
- package/src/bridge/client/engine-search.mjs +24 -1
- package/src/bridge/client/render.mjs +27 -1
- package/src/bridge/client/transport.mjs +499 -23
- package/src/bridge/client.mjs +10 -0
- package/src/bridge/config.mjs +67 -6
- package/src/bridge/engine-download.mjs +0 -10
- package/src/bridge/graph-auth.mjs +55 -3
- package/src/bridge/image-intent-envelope.mjs +239 -0
- package/src/bridge/package-preview.mjs +67 -0
- package/src/bridge/sharepoint.mjs +914 -0
- package/src/builders/asset-fetch.mjs +203 -0
- package/src/builders/block-dividers.mjs +83 -0
- package/src/builders/build.mjs +171 -87
- package/src/builders/card-height.mjs +294 -0
- package/src/builders/chart-hole.mjs +183 -0
- package/src/builders/chart-jsx.mjs +410 -0
- package/src/builders/chart-labels.mjs +326 -0
- package/src/builders/concept-geometry.mjs +370 -0
- package/src/builders/concept-intent.mjs +200 -0
- package/src/builders/concept-layout.mjs +79 -0
- package/src/builders/concept-lines.mjs +509 -0
- package/src/builders/concept-lists.mjs +270 -0
- package/src/builders/concept-marks.mjs +84 -0
- package/src/builders/concept-relationships.mjs +12 -0
- package/src/builders/concept-spec.mjs +645 -0
- package/src/builders/concept-text.mjs +140 -0
- package/src/builders/concept-waves.mjs +57 -0
- package/src/builders/copy-fit.mjs +139 -0
- package/src/builders/design-tokens.mjs +347 -0
- package/src/builders/draft-check.mjs +552 -340
- package/src/builders/draft-lines.mjs +369 -0
- package/src/builders/edit-route.mjs +29 -0
- package/src/builders/figure-sketch.mjs +375 -0
- package/src/builders/fold-sets.mjs +639 -0
- package/src/builders/gate-server.mjs +9 -4
- package/src/builders/gate-session.js +273 -12
- package/src/builders/gate-shell.js +1494 -0
- package/src/builders/image-intent-check.mjs +308 -0
- package/src/builders/image-use-reconcile.mjs +107 -0
- package/src/builders/kit-helpers.mjs +121 -0
- package/src/builders/layout-names.mjs +32 -0
- package/src/builders/legacy-reference-uses.mjs +179 -0
- package/src/builders/line-fit.mjs +224 -0
- package/src/builders/line-native.mjs +895 -0
- package/src/builders/line-relations.mjs +230 -0
- package/src/builders/lint-fix.mjs +117 -9
- package/src/builders/lint.mjs +25 -20
- package/src/builders/list-jsx.mjs +88 -0
- package/src/builders/placement.mjs +133 -0
- package/src/builders/plan-checks.mjs +135 -0
- package/src/builders/plan-comments.mjs +19 -0
- package/src/builders/plan-handlers.mjs +95 -0
- package/src/builders/plan-markdown-edit.mjs +3 -3
- package/src/builders/plan-overlay.js +1530 -1608
- package/src/builders/plan.mjs +689 -186
- package/src/builders/preset-definitions.json +1 -0
- package/src/builders/preset-eval.mjs +244 -0
- package/src/builders/preview.html +51 -120
- package/src/builders/red-thread-backbone.mjs +135 -36
- package/src/builders/repair.mjs +205 -0
- package/src/builders/repeat-groups.mjs +540 -0
- package/src/builders/resolve-skill.mjs +5 -4
- package/src/builders/review-overlay.js +296 -293
- package/src/builders/review.mjs +305 -84
- package/src/builders/rule-shift.mjs +96 -0
- package/src/builders/seat.mjs +354 -0
- package/src/builders/settle-driver.mjs +236 -0
- package/src/builders/settle.mjs +1448 -0
- package/src/builders/slide-prep.mjs +67 -0
- package/src/builders/spec-assets.mjs +236 -0
- package/src/builders/spec-match.mjs +56 -0
- package/src/builders/stack.mjs +413 -0
- package/src/builders/storyboard-markdown.mjs +70 -27
- package/src/builders/storyboard-schema.mjs +54 -114
- package/src/builders/text-blocks.mjs +212 -0
- package/src/builders/text-card-fit.mjs +552 -0
- package/src/builders/text-stack.mjs +276 -0
- package/src/builders/transcribe.mjs +1069 -0
- package/src/cli/assets.mjs +164 -0
- package/src/cli/common.mjs +173 -12
- package/src/cli/concept-spec-support.mjs +76 -0
- package/src/cli/concepts.mjs +1002 -103
- package/src/cli/deck.mjs +401 -65
- package/src/cli/diagnose.mjs +4 -2
- package/src/cli/engine-helpers.mjs +17 -10
- package/src/cli/engine-results.mjs +76 -3
- package/src/cli/help.mjs +232 -28
- package/src/cli/install.mjs +9 -0
- package/src/cli/maintenance.mjs +104 -0
- package/src/cli/read.mjs +2 -1
- package/src/cli/ready.mjs +6 -3
- package/src/cli/record.mjs +522 -0
- package/src/cli/remote.mjs +818 -0
- package/src/cli/revision.mjs +686 -0
- package/src/cli/search.mjs +30 -11
- package/src/cli/session.mjs +56 -13
- package/src/cli/setup.mjs +46 -2
- package/src/cli/status.mjs +142 -33
- package/src/cli/tasks.mjs +246 -0
- package/src/cli/transcribe.mjs +854 -0
- package/src/cli/workspace-ui.mjs +260 -0
- package/src/cli/workspace.mjs +212 -34
- package/src/install-channel.mjs +64 -7
- package/src/lib/adopt.mjs +346 -25
- package/src/lib/concept-spec-binding.mjs +96 -0
- package/src/lib/conversions.mjs +70 -6
- package/src/lib/convert-folded.mjs +107 -0
- package/src/lib/deck-paths.mjs +418 -0
- package/src/lib/durable-ids.mjs +47 -0
- package/src/lib/edit-operations.mjs +316 -0
- package/src/lib/edit-state.mjs +1244 -0
- package/src/lib/edit-transitions.mjs +120 -0
- package/src/lib/extensions.mjs +501 -0
- package/src/lib/image-assets.mjs +465 -0
- package/src/lib/image-derivatives.mjs +234 -0
- package/src/lib/image-intent-fingerprint.mjs +70 -0
- package/src/lib/image-intent-tags.mjs +176 -0
- package/src/lib/image-intent.mjs +802 -0
- package/src/lib/image-use-overrides.mjs +279 -0
- package/src/lib/inline-components.mjs +613 -0
- package/src/lib/lint-converted.mjs +68 -4
- package/src/lib/merge-json.mjs +163 -0
- package/src/lib/overwrite-guard.mjs +3 -3
- package/src/lib/presentation-context.mjs +531 -0
- package/src/lib/preservation-tags.mjs +157 -0
- package/src/lib/preservation-verdict.mjs +1342 -0
- package/src/lib/publish.mjs +1557 -0
- package/src/lib/rebase.mjs +945 -0
- package/src/lib/reconcile-ack.mjs +4 -6
- package/src/lib/reconcile.mjs +62 -24
- package/src/lib/record-client.mjs +175 -0
- package/src/lib/record-state.mjs +151 -0
- package/src/lib/remote.mjs +661 -0
- package/src/lib/revision.mjs +1210 -0
- package/src/lib/slide-guids.mjs +4 -6
- package/src/lib/slide-preservation.mjs +423 -0
- package/src/lib/slide-selection.mjs +9 -0
- package/src/lib/workspace-transaction.mjs +117 -0
- package/src/pptx/content-digest.mjs +334 -0
- package/src/pptx/deck-assemble.mjs +1278 -0
- package/src/pptx/fill/engine.mjs +2 -3
- package/src/pptx/fill/freeform.mjs +6 -3
- package/src/pptx/ooxml.mjs +20 -0
- package/src/pptx/package-evidence.mjs +383 -0
- package/src/pptx/tags.mjs +163 -11
- package/src/update-check.mjs +22 -4
- package/src/workspace/adapters.mjs +222 -0
- package/src/workspace/auth.mjs +256 -0
- package/src/workspace/autostart.mjs +204 -0
- package/src/workspace/daemon.mjs +360 -0
- package/src/workspace/discovery.mjs +180 -0
- package/src/workspace/edit.mjs +167 -0
- package/src/workspace/events.mjs +52 -0
- package/src/workspace/gate-client.mjs +290 -0
- package/src/workspace/handback.mjs +73 -0
- package/src/workspace/lifecycle.mjs +635 -0
- package/src/workspace/operations.mjs +200 -0
- package/src/workspace/pick-folder.mjs +152 -0
- package/src/workspace/registry.mjs +359 -0
- package/src/workspace/remote-links.mjs +397 -0
- package/src/workspace/remote-status.mjs +359 -0
- package/src/workspace/routes.mjs +1331 -0
- package/src/workspace/server.mjs +159 -0
- package/src/workspace/sync.mjs +1119 -0
- package/src/workspace/task-actions.mjs +215 -0
- package/src/workspace/tasks.mjs +507 -0
- package/src/workspace/ui/app.js +1886 -0
- package/src/workspace/ui/index.html +141 -0
- package/src/workspace/ui/plan-bridge.js +120 -0
- package/src/workspace/ui/sharepoint.svg +59 -0
- package/src/workspace/ui/styles.css +423 -0
- package/src/builders/visual-families.mjs +0 -46
|
@@ -0,0 +1,1448 @@
|
|
|
1
|
+
// Settle a transcribed slide against its own render — the machine closing the
|
|
2
|
+
// geometry loop the agent used to close by hand (x27–x30: 4–20 view-draft
|
|
3
|
+
// rounds per deck, most of them "move this text down", "give that card room",
|
|
4
|
+
// "this runs past the slot"). Pure: takes the spec and the measured boxes of
|
|
5
|
+
// one capture, returns an adjusted spec plus the changes it made. The caller
|
|
6
|
+
// re-renders and calls again until nothing changes (a few passes at most).
|
|
7
|
+
//
|
|
8
|
+
// Rules, in order, each in slot pixels:
|
|
9
|
+
// 1. escape — a text whose measured bottom passes its painted parent's
|
|
10
|
+
// bottom grows the parent by the overflow (plus a 4px margin)
|
|
11
|
+
// and pushes every element below the parent down by the same.
|
|
12
|
+
// 2. overlap — two texts that share pixels: the lower one moves down by the
|
|
13
|
+
// overlap (plus 2px), and so does everything below it inside
|
|
14
|
+
// the same parent.
|
|
15
|
+
// 3. slot — anything past the slot's bottom: lift it if there is room
|
|
16
|
+
// above it.
|
|
17
|
+
// Type size is never changed here: it is the role's rung on the design's ramp
|
|
18
|
+
// (transcribe.mjs), and a text that still does not fit is the author's call.
|
|
19
|
+
import { translateElement } from "./concept-geometry.mjs";
|
|
20
|
+
import { fixedAspect } from "./concept-layout.mjs";
|
|
21
|
+
import { contains, parsePointRef, sanitizeTrace, segmentHitsRect, traceLength } from "./line-fit.mjs";
|
|
22
|
+
import { connectorGeometry, nodePreset, nodeSites, resolveGlued, NATIVE_TOL } from "./line-native.mjs";
|
|
23
|
+
import { readLines } from "./concept-lines.mjs";
|
|
24
|
+
import { correspond, linePoints, lineRows, lineSpans, repeatGroups } from "./repeat-groups.mjs";
|
|
25
|
+
import { textLines } from "./concept-text.mjs";
|
|
26
|
+
import { stackEnv, stackUnder, stacksAlone } from "./text-stack.mjs";
|
|
27
|
+
import { shiftSeparator } from "./rule-shift.mjs";
|
|
28
|
+
import { applyStacks, rowExtent } from "./stack.mjs";
|
|
29
|
+
import { placeBlockDividers } from "./block-dividers.mjs";
|
|
30
|
+
|
|
31
|
+
export { pathDistance } from "./line-fit.mjs";
|
|
32
|
+
const EPS = 1.5;
|
|
33
|
+
// PowerPoint sets the same Arial a few percent wider than Chrome, so a frame
|
|
34
|
+
// at exactly the Chrome ink breaks its widest word there ("IMPLICATIO|N",
|
|
35
|
+
// dev run 7c5fe730). An unbreakable line keeps this much room. Calibration
|
|
36
|
+
// knob: raise it if package renders still break words the draft keeps whole.
|
|
37
|
+
const PPT_WRAP_SLACK = 0.04;
|
|
38
|
+
const MIN_RULE_PX = 24; // an ornament rule shorter than this reads as a dash
|
|
39
|
+
|
|
40
|
+
// A connector directive's own box is a stand-in (a few px at its frame's
|
|
41
|
+
// origin), not the line: a free one measures as the span of its two ends; one
|
|
42
|
+
// glued to a node is solved from the source (connectorPaths), never measured
|
|
43
|
+
// from that box. x114 PM 2: the set fold read the stand-in as each card's rule
|
|
44
|
+
// and folded the cards it paints inside, so the rule ran over their discs.
|
|
45
|
+
function connectorExtent(c) {
|
|
46
|
+
const a = c.fromNode ? null : c.fromPoint;
|
|
47
|
+
const b = c.toNode ? null : c.toPoint;
|
|
48
|
+
if (!Number.isFinite(a?.x) || !Number.isFinite(b?.x)) return null;
|
|
49
|
+
const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y);
|
|
50
|
+
return { x, y, w: Math.abs(b.x - a.x), h: Math.abs(b.y - a.y) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function measuredFromRender(render, slot) {
|
|
54
|
+
const roots = render?.boxes ?? [];
|
|
55
|
+
// The content slot is the first root box (the layout's content area); every
|
|
56
|
+
// rect below is expressed relative to it.
|
|
57
|
+
const origin = roots[0]?.rect ?? { x: 0, y: 0 };
|
|
58
|
+
const out = new Map();
|
|
59
|
+
// A text block (transcribe.mjs `P`, a flowed `L`'s items) measures as its
|
|
60
|
+
// paragraphs together: the block's box holds no text of its own.
|
|
61
|
+
const paragraphMetrics = (b) => {
|
|
62
|
+
const paras = (b.children ?? []).flatMap((c) => (c.elementId ? [] : c.textMetrics ? [c] : (c.children ?? []).filter((li) => !li.elementId && li.textMetrics)));
|
|
63
|
+
if (b.textMetrics || b.textRuns?.length || !paras.length) return null;
|
|
64
|
+
return {
|
|
65
|
+
lineCount: paras.reduce((n, c) => n + (c.textMetrics.lineCount ?? 0), 0),
|
|
66
|
+
paraLines: paras.map((c) => c.textMetrics.lineCount ?? 0),
|
|
67
|
+
maxLineW: Math.max(...paras.map((c) => c.textMetrics.maxLineW ?? 0)),
|
|
68
|
+
maxWordW: Math.max(...paras.map((c) => c.textMetrics.maxWordW ?? 0)),
|
|
69
|
+
rects: paras.flatMap((c) => c.textMetrics.rects ?? []),
|
|
70
|
+
// One box per paragraph (slot px): where a divider between two of them belongs (block-dividers.mjs).
|
|
71
|
+
paraRects: paras.map((c) => ({ y: c.rect.y - origin.y, h: c.rect.h })),
|
|
72
|
+
sizePx: paras[0].style?.font?.sizePx,
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
const walk = (b) => {
|
|
76
|
+
if (!b || typeof b !== "object") return;
|
|
77
|
+
const rect = b.connector?.kind === "connector" ? connectorExtent(b.connector) : b.rect;
|
|
78
|
+
if (b.elementId && rect) {
|
|
79
|
+
const block = paragraphMetrics(b);
|
|
80
|
+
const tm = b.textMetrics ?? block;
|
|
81
|
+
const sizePx = block ? block.sizePx : b.style?.font?.sizePx;
|
|
82
|
+
out.set(b.elementId, {
|
|
83
|
+
x: rect.x - origin.x,
|
|
84
|
+
y: rect.y - origin.y,
|
|
85
|
+
w: rect.w,
|
|
86
|
+
h: rect.h,
|
|
87
|
+
lines: tm?.lineCount ?? null,
|
|
88
|
+
...(tm?.paraLines ? { paraLines: tm.paraLines } : {}),
|
|
89
|
+
...(tm?.paraRects ? { paraRects: tm.paraRects } : {}),
|
|
90
|
+
text: Boolean(b.textRuns?.length || tm),
|
|
91
|
+
...(b.imgSrc || b.chart ? { content: true } : {}),
|
|
92
|
+
...(b.style?.bgColor && (b.style.bgColorAlpha ?? 1) > 0 ? { painted: true } : {}),
|
|
93
|
+
...(Number.isFinite(tm?.maxLineW) ? { inkWidth: tm.maxLineW } : {}),
|
|
94
|
+
...(tm?.maxWordW > 0 ? { wordWidth: tm.maxWordW } : {}),
|
|
95
|
+
// A list's marker indent: its words set in the width left of it (word-fit-check.ts).
|
|
96
|
+
...(b.style?.padding?.l + b.style?.padding?.r > 0 ? { padX: b.style.padding.l + b.style.padding.r } : {}),
|
|
97
|
+
...(tm?.rects?.length ? { inkY: Math.min(...tm.rects.map((r) => r.y)) - origin.y, inkHeight: Math.max(...tm.rects.map((r) => r.y + r.h)) - Math.min(...tm.rects.map((r) => r.y)) } : {}),
|
|
98
|
+
// Each rendered line's ink (slot px): whether a rule runs through the
|
|
99
|
+
// words or beside them is a question of lines, not of the text's box.
|
|
100
|
+
...(tm?.rects?.length ? { lineRects: tm.rects.map((r) => ({ x: r.x - origin.x, y: r.y - origin.y, w: r.w, h: r.h })) } : {}),
|
|
101
|
+
...(Number.isFinite(sizePx) ? { renderedFontPx: sizePx } : {}),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
for (const c of b.children ?? []) walk(c);
|
|
105
|
+
};
|
|
106
|
+
for (const r of roots) walk(r);
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The lines a text keeps whatever its width: textLines' breaks (a lower-case
|
|
112
|
+
* continuation flows), counted per paragraph of a folded block — joined, a
|
|
113
|
+
* paragraph opening lower-case read as a wrap of the one before.
|
|
114
|
+
*/
|
|
115
|
+
export const hardLineCount = (e) => (e.paragraphs?.length ? e.paragraphs.reduce((n, p) => n + textLines(p.text ?? "").length, 0) : textLines(e.text ?? "").length);
|
|
116
|
+
|
|
117
|
+
const PAINTED = new Set(["card", "panel", "badge", "shape", "disc", "bar"]);
|
|
118
|
+
const isText = (e) => (e.kind === "text" || e.kind === "number") && e.text != null && !e.textRotation;
|
|
119
|
+
const bottom = (r) => r.y + r.h;
|
|
120
|
+
const intersects = (a, b) => a.x < b.x + b.w - EPS && b.x < a.x + a.w - EPS && a.y < bottom(b) - EPS && b.y < bottom(a) - EPS;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The room a text owns in the image: from its own position to the nearest
|
|
124
|
+
* unrelated element beside / below it, its painted container's edge, or the
|
|
125
|
+
* slot. Overflowing into empty space is harmless; into a neighbour or out of
|
|
126
|
+
* its shape it is not.
|
|
127
|
+
*/
|
|
128
|
+
export function textRoom(s, e, { slot = s.slot, gap = 4, byId = new Map(s.elements.map((o) => [o.id, o])) } = {}) {
|
|
129
|
+
const related = (a, b) => a.id === b.parent || b.id === a.parent || descendsFromIn(byId, a, b.id) || descendsFromIn(byId, b, a.id);
|
|
130
|
+
const parent = e.parent ? byId.get(e.parent) : null;
|
|
131
|
+
const box = parent && parent.fill && parent.fill !== "none" ? parent : { x: 0, y: 0, w: slot.w, h: slot.h };
|
|
132
|
+
let right = box.x + box.w, left = box.x, below = box.y + box.h, above = box.y, under = null;
|
|
133
|
+
for (const o of s.elements) {
|
|
134
|
+
if (o === e || related(e, o) || !(o.w > 0 && o.h > 0)) continue;
|
|
135
|
+
const rowsOverlap = o.y < e.y + e.h && o.y + o.h > e.y;
|
|
136
|
+
if (rowsOverlap && o.x >= e.x + e.w - 1) right = Math.min(right, o.x - gap);
|
|
137
|
+
if (rowsOverlap && o.x + o.w <= e.x + 1) left = Math.max(left, o.x + o.w + gap);
|
|
138
|
+
const colsOverlap = o.x < e.x + e.w && o.x + o.w > e.x;
|
|
139
|
+
if (colsOverlap && o.y >= e.y + e.h - 1 && o.y - gap < below) [below, under] = [o.y - gap, o];
|
|
140
|
+
if (colsOverlap && o.y + o.h <= e.y + 1) above = Math.max(above, o.y + o.h + gap);
|
|
141
|
+
}
|
|
142
|
+
// A line in its rows is a neighbour too (a loop, the rule beside a title),
|
|
143
|
+
// and one in its columns a floor or a ceiling (the rule over a title).
|
|
144
|
+
let rule = null;
|
|
145
|
+
const own = new Set(s.elements.filter((o) => o === e || related(e, o)).map((o) => o.id));
|
|
146
|
+
for (const l of lineSpans(s, e.y, e.y + e.h, own)) {
|
|
147
|
+
if (l.x >= e.x + e.w - 1) {
|
|
148
|
+
if (l.x - gap < right) [right, rule] = [l.x - gap, l.line];
|
|
149
|
+
} else if (l.x + l.w <= e.x + 1) left = Math.max(left, l.x + l.w + gap);
|
|
150
|
+
}
|
|
151
|
+
for (const l of lineRows(s, e.x, e.x + e.w, own)) {
|
|
152
|
+
if (l.y >= e.y + e.h - 1 && l.y - gap < below) [below, under] = [l.y - gap, null];
|
|
153
|
+
else if (l.y + l.h <= e.y + 1) above = Math.max(above, l.y + l.h + gap);
|
|
154
|
+
}
|
|
155
|
+
const centred = e.align === "center";
|
|
156
|
+
const w = centred ? e.w + 2 * Math.max(0, Math.min(e.x - left, right - e.x - e.w)) : (e.align === "right" ? e.x + e.w - left : right - e.x);
|
|
157
|
+
return { w: Math.max(e.w, w), h: Math.max(e.h, below - e.y), up: Math.max(0, e.y - above), under, rule, edge: box.x + box.w };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Settle a transcribed slide against the image it was read from. Geometry is
|
|
162
|
+
* the image's; the type is the design's ramp, never a size read off the image,
|
|
163
|
+
* and where the image broke its lines is no target either. A text whose
|
|
164
|
+
* rendered ink is wider than its box widens into the free room beside it; one
|
|
165
|
+
* that still does not fit is left for the author. Returns the adjusted spec
|
|
166
|
+
* and one line per change.
|
|
167
|
+
*/
|
|
168
|
+
export function fitTextToImage(spec, measured, { slot = spec.slot, gap = 4, last = false } = {}) {
|
|
169
|
+
const s = JSON.parse(JSON.stringify(spec));
|
|
170
|
+
const byId = new Map(s.elements.map((e) => [e.id, e]));
|
|
171
|
+
const changes = [];
|
|
172
|
+
// The texts re-wrapped here: their measured height is stale until the next render.
|
|
173
|
+
const stale = new Set();
|
|
174
|
+
const related = (a, b) => a.id === b.parent || b.id === a.parent || descendsFromIn(byId, a, b.id) || descendsFromIn(byId, b, a.id);
|
|
175
|
+
const room = (e) => textRoom(s, e, { slot, gap, byId });
|
|
176
|
+
// A free level rule after a left-set line (a title's "——") is an ornament
|
|
177
|
+
// that fills the row: it starts after the ink instead, while at least
|
|
178
|
+
// MIN_RULE_PX of it is left (gtm-03 "4. Handoff rule ——" at the ramp size).
|
|
179
|
+
const giveWay = (c, from) => {
|
|
180
|
+
const pts = linePoints(c);
|
|
181
|
+
const ys = pts.map((p) => p.y);
|
|
182
|
+
if (c.from || c.to || Math.max(...ys) - Math.min(...ys) > 1) return null;
|
|
183
|
+
const x = Math.ceil(from + (Number.isFinite(c.strokeWidth) ? c.strokeWidth : 1) / 2);
|
|
184
|
+
const x0 = Math.min(...pts.map((p) => p.x));
|
|
185
|
+
if (Math.max(...pts.map((p) => p.x)) - x < MIN_RULE_PX) return null;
|
|
186
|
+
for (const p of pts) if (p.x <= x0 + 0.5) p.x = x;
|
|
187
|
+
return x;
|
|
188
|
+
};
|
|
189
|
+
const widen = (e, w, why) => {
|
|
190
|
+
if (e.align === "center") e.x = Math.round((e.x - (w - e.w) / 2) * 10) / 10;
|
|
191
|
+
else if (e.align === "right") e.x = Math.round((e.x - (w - e.w)) * 10) / 10;
|
|
192
|
+
changes.push(`${e.id}: width ${Math.round(e.w)}→${Math.round(w)}px ${why}`);
|
|
193
|
+
e.w = w;
|
|
194
|
+
stale.add(e.id);
|
|
195
|
+
};
|
|
196
|
+
for (const e of s.elements) {
|
|
197
|
+
if (!isText(e) || !(e.w > 0 && e.h > 0)) continue;
|
|
198
|
+
const m = measured.get(e.id);
|
|
199
|
+
if (!(m?.inkWidth > 0)) continue;
|
|
200
|
+
const r = room(e);
|
|
201
|
+
// The box holds the ink it renders. Chrome lets a word overflow a narrow
|
|
202
|
+
// box unbroken, but the box becomes the PPTX frame, and a frame narrower
|
|
203
|
+
// than a word breaks the word ("Exception|s", x67 cloud 6; 529 texts on
|
|
204
|
+
// the replay corpus). It grows into the room beside it, anchored by its
|
|
205
|
+
// alignment.
|
|
206
|
+
// A line that does not wrap (one line, or only the image's own breaks)
|
|
207
|
+
// and a word wider than its box are unbreakable: they keep the room
|
|
208
|
+
// PowerPoint needs. A wrapping paragraph re-wraps instead. The breaks the
|
|
209
|
+
// slide keeps are textLines' (a lower-case continuation flows); counting
|
|
210
|
+
// every `\n` took a 4-line paragraph for four unbreakable lines and
|
|
211
|
+
// widened it again on every pass as it re-wrapped (pm-03 team-body).
|
|
212
|
+
const hardLines = hardLineCount(e);
|
|
213
|
+
const unbroken = m.lines != null && m.lines <= hardLines;
|
|
214
|
+
// A wrapping paragraph needs its widest word (dom-serialiser maxWordW)
|
|
215
|
+
// with that room; without the measure, a line wider than its box is one.
|
|
216
|
+
const word = m.wordWidth ?? (m.inkWidth > e.w + 0.25 ? m.inkWidth : 0);
|
|
217
|
+
if (!unbroken && !(word > 0)) continue;
|
|
218
|
+
const pad = m.padX ?? 0;
|
|
219
|
+
const need = Math.ceil((unbroken ? m.inkWidth : word) * (1 + PPT_WRAP_SLACK)) + pad;
|
|
220
|
+
if (need <= e.w) continue;
|
|
221
|
+
let w = Math.min(Math.floor(r.w), need);
|
|
222
|
+
const leftSet = e.align !== "center" && e.align !== "right";
|
|
223
|
+
const moved = w < need && unbroken && r.rule && leftSet ? giveWay(r.rule, e.x + need + gap) : null;
|
|
224
|
+
if (moved != null) {
|
|
225
|
+
changes.push(`${r.rule.id}: starts at ${moved}px, after ${e.id}`);
|
|
226
|
+
w = Math.min(Math.floor(room(e).w), need);
|
|
227
|
+
} else if (w < need && unbroken && r.rule && leftSet) {
|
|
228
|
+
// A separator beside it moves into its gutter (rule-shift.mjs): at least
|
|
229
|
+
// clear of the ink, as far as PowerPoint's slack where the gutter allows.
|
|
230
|
+
const dx = shiftSeparator(s, e, (t) => {
|
|
231
|
+
const mt = measured.get(t.id);
|
|
232
|
+
const rt = room(t);
|
|
233
|
+
if (!(mt?.inkWidth > 0) || !rt.rule) return null;
|
|
234
|
+
const ink = Math.ceil(mt.inkWidth) + (mt.padX ?? 0);
|
|
235
|
+
return { rule: rt.rule, right: rt.edge, to: t.x + Math.ceil(mt.inkWidth * (1 + PPT_WRAP_SLACK)) + (mt.padX ?? 0) + gap, least: t.x + ink + gap,
|
|
236
|
+
beside: (o) => o !== t && !related(t, o), holds: () => room(t).w >= ink };
|
|
237
|
+
}, gap);
|
|
238
|
+
if (dx) {
|
|
239
|
+
changes.push(`${r.rule.id}: ${dx}px right, clear of ${e.id}`);
|
|
240
|
+
w = Math.min(Math.floor(room(e).w), need);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (w > e.w) widen(e, w, "to hold its ink");
|
|
244
|
+
// A line whose ink the room cannot hold — its neighbour, often a rule
|
|
245
|
+
// beside it, is nearer than its ink is long — wraps inside its box instead
|
|
246
|
+
// of running over it: `wrap` makes transcribe drop `nowrap` — only where
|
|
247
|
+
// the lines it gains have room: free rows below it; else, alone in its
|
|
248
|
+
// column, the room about its own centre (live 6bb93436: a title beside a
|
|
249
|
+
// divider on a one-row band); else the stack under it moving down by the
|
|
250
|
+
// rest (text-stack.mjs, on the next render). Wrapped onto a body that
|
|
251
|
+
// cannot move, a title trades a crossing for text on text (cloud-02).
|
|
252
|
+
// Missing only PowerPoint's slack is no reason to wrap; one word cannot,
|
|
253
|
+
// and a text with the image's own breaks already wraps (no nowrap).
|
|
254
|
+
const lineH = (m.h ?? e.h) / Math.max(1, m.lines ?? 1);
|
|
255
|
+
if (m.inkWidth > w - pad + 0.5 && unbroken && !e.wrap && !String(e.text).includes("\n") && /\S\s+\S/.test(String(e.text).trim())) {
|
|
256
|
+
const r2 = room(e);
|
|
257
|
+
const H = Math.ceil(m.inkWidth / (w - pad)) * lineH;
|
|
258
|
+
let dy = 0;
|
|
259
|
+
let fits = H <= r2.h;
|
|
260
|
+
if (!fits && !r2.under && H <= r2.h + r2.up) {
|
|
261
|
+
fits = true;
|
|
262
|
+
const cy = (m.y ?? e.y) + (m.h ?? e.h) / 2;
|
|
263
|
+
dy = Math.round(Math.min(Math.max(cy - H / 2, e.y - r2.up), e.y + r2.h - H) - e.y);
|
|
264
|
+
} else if (!fits && r2.under && !last && stacksAlone(s, e)) {
|
|
265
|
+
const under = stackUnder(stackEnv(s, measured, slot), e, { force: true, want: H - r2.h });
|
|
266
|
+
fits = under?.first === r2.under && H - r2.h <= under.room;
|
|
267
|
+
}
|
|
268
|
+
if (fits) {
|
|
269
|
+
e.wrap = true;
|
|
270
|
+
stale.add(e.id);
|
|
271
|
+
if (dy) translateElement(e, 0, dy);
|
|
272
|
+
changes.push(`${e.id}: wraps inside its ${Math.round(e.w)}px box (its line is ${Math.ceil(m.inkWidth)}px)${dy ? `, ${dy}px to keep its centre` : ""}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// A declared set keeps one inner layout (repeat-groups.mjs): a text widened
|
|
277
|
+
// in one member widens its counterparts alike, where each has the room.
|
|
278
|
+
for (const g of repeatGroups(s)) {
|
|
279
|
+
const kids = correspond(s.elements, g.members);
|
|
280
|
+
for (const [k, first] of (kids?.[0] ?? []).entries()) {
|
|
281
|
+
if (!isText(first)) continue;
|
|
282
|
+
const col = kids.map((l) => l[k]);
|
|
283
|
+
const w = Math.max(...col.map((e) => e.w));
|
|
284
|
+
if (col.every((e) => e.w === w) || col.some((e) => room(e).w < w)) continue;
|
|
285
|
+
for (const e of col) if (e.w < w) widen(e, w, `with its set ${g.id}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return { spec: s, changes, stale };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function settleSpec(spec, measured, { slot = spec.slot } = {}) {
|
|
292
|
+
const s = JSON.parse(JSON.stringify(spec));
|
|
293
|
+
const byId = new Map(s.elements.map((e) => [e.id, e]));
|
|
294
|
+
const changes = [];
|
|
295
|
+
// The free width beside a text at its rows: the edge of its painted parent
|
|
296
|
+
// (or the slot) and the nearest element either side of it.
|
|
297
|
+
const freeSpan = (e, h) => {
|
|
298
|
+
const top = e.y, bot = e.y + h;
|
|
299
|
+
const parent = e.parent ? byId.get(e.parent) : null;
|
|
300
|
+
let right = parent && PAINTED.has(parent.kind) ? parent.x + parent.w - 8 : slot.w;
|
|
301
|
+
let left = parent && PAINTED.has(parent.kind) ? parent.x + 8 : 0;
|
|
302
|
+
for (const o of s.elements) {
|
|
303
|
+
if (o === e || o.id === e.parent || descendsFromIn(byId, o, e.id) || descendsFromIn(byId, e, o.id)) continue;
|
|
304
|
+
if (o.y >= bot - EPS || o.y + o.h <= top + EPS) continue;
|
|
305
|
+
if (o.x >= e.x + e.w - EPS) right = Math.min(right, o.x - 4);
|
|
306
|
+
if (o.x + o.w <= e.x + EPS) left = Math.max(left, o.x + o.w + 4);
|
|
307
|
+
}
|
|
308
|
+
return { left, right };
|
|
309
|
+
};
|
|
310
|
+
// A nowrap line can overflow while its CSS box still fits perfectly. Use
|
|
311
|
+
// the capture's actual ink width and widen into free space; a line with no
|
|
312
|
+
// room beside it stays as it is for the author (the size is the ramp's).
|
|
313
|
+
for (const e of s.elements) {
|
|
314
|
+
const m = measured.get(e.id);
|
|
315
|
+
if (!isText(e) || !(e.w > 0) || !(m?.inkWidth > e.w + EPS)) continue;
|
|
316
|
+
const { left, right } = freeSpan(e, m.h ?? e.h);
|
|
317
|
+
const need = Math.ceil(m.inkWidth + 2);
|
|
318
|
+
const centred = e.align === "center";
|
|
319
|
+
const grow = need - e.w;
|
|
320
|
+
if (centred ? e.x - grow / 2 >= left && e.x + e.w + grow / 2 <= right : e.x + need <= right) {
|
|
321
|
+
if (centred) e.x = Math.round((e.x - grow / 2) * 10) / 10;
|
|
322
|
+
changes.push(`${e.id}: width ${Math.round(e.w)}→${need}px to hold its line`);
|
|
323
|
+
e.w = need;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const rectOf = (e) => {
|
|
327
|
+
const m = measured.get(e.id);
|
|
328
|
+
// A text's measured height is the truth; its declared rect is the plan.
|
|
329
|
+
return m ? { x: e.x, y: e.y, w: Math.max(e.w, isText(e) ? Math.min(m.w, e.w) : e.w), h: isText(e) ? m.h : e.h } : { x: e.x, y: e.y, w: e.w, h: e.h };
|
|
330
|
+
};
|
|
331
|
+
// `except` (and its subtree) stays put: the element we are moving things BELOW
|
|
332
|
+
// may itself sit at `fromY`, and without this it rides down with them. Returns
|
|
333
|
+
// how many moved, so a caller never records a change that nothing made.
|
|
334
|
+
const shiftBelow = (fromY, dy, within = null, except = null) => {
|
|
335
|
+
let moved = 0;
|
|
336
|
+
for (const el of s.elements) {
|
|
337
|
+
if (el.y + EPS < fromY) continue;
|
|
338
|
+
if (within && !(el.parent === within.id || el.id === within.id || descendsFrom(el, within.id))) continue;
|
|
339
|
+
if (except && (el.id === except.id || descendsFrom(el, except.id))) continue;
|
|
340
|
+
translateElement(el, 0, dy);
|
|
341
|
+
moved++;
|
|
342
|
+
}
|
|
343
|
+
return moved;
|
|
344
|
+
};
|
|
345
|
+
const descendsFrom = (el, id) => {
|
|
346
|
+
let p = el.parent ? byId.get(el.parent) : null;
|
|
347
|
+
let guard = 0;
|
|
348
|
+
while (p && guard++ < 32) {
|
|
349
|
+
if (p.id === id) return true;
|
|
350
|
+
p = p.parent ? byId.get(p.parent) : null;
|
|
351
|
+
}
|
|
352
|
+
return false;
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
// 0b. clear — a text block may not lie on a painted shape it does not
|
|
356
|
+
// belong to (x58 cloud 1: the reader put three labels 14px inside their
|
|
357
|
+
// node's circle). The whole column the text belongs to — texts sharing
|
|
358
|
+
// its left edge and stacked on it — moves by the smallest shift that
|
|
359
|
+
// clears the shape, so a title, its body and its label stay aligned.
|
|
360
|
+
const PAINTS = new Set(["disc", "badge", "card", "panel", "shape"]);
|
|
361
|
+
const movedCols = new Set();
|
|
362
|
+
for (const t of s.elements.filter(isText)) {
|
|
363
|
+
if (movedCols.has(t.id)) continue;
|
|
364
|
+
const rt = rectOf(t);
|
|
365
|
+
for (const p of s.elements) {
|
|
366
|
+
if (!PAINTS.has(p.kind) || !p.fill || p.fill === "none" || p === t) continue;
|
|
367
|
+
if (descendsFrom(t, p.id) || contains(p, rt) || contains(rt, p)) continue;
|
|
368
|
+
if (!intersects(rt, p)) continue;
|
|
369
|
+
const tcx = rt.x + rt.w / 2, pcx = p.x + p.w / 2;
|
|
370
|
+
const beside = tcx > p.x + p.w || tcx < p.x || Math.abs(tcx - pcx) > p.w / 2;
|
|
371
|
+
if (!beside) continue;
|
|
372
|
+
const dx = tcx >= pcx ? p.x + p.w + 6 - rt.x : p.x - 6 - (rt.x + rt.w);
|
|
373
|
+
if (Math.abs(dx) > Math.max(24, p.w * 0.5)) continue;
|
|
374
|
+
const column = s.elements.filter((o) => isText(o) && Math.abs(o.x - t.x) <= 3 && o.parent === t.parent
|
|
375
|
+
&& o.y < rt.y + rt.h + 80 && o.y + (measured.get(o.id)?.h ?? o.h) > rt.y - 80);
|
|
376
|
+
// Never out of the slot: a column that cannot clear the shape inside
|
|
377
|
+
// it stays, and the overlap is left for the draft check to name.
|
|
378
|
+
if (column.some((o) => o.x + dx < 0 || o.x + dx + Math.max(o.w, measured.get(o.id)?.w ?? 0) > slot.w)) continue;
|
|
379
|
+
for (const o of column) {
|
|
380
|
+
translateElement(o, dx, 0);
|
|
381
|
+
movedCols.add(o.id);
|
|
382
|
+
}
|
|
383
|
+
changes.push(`${column.map((o) => o.id).join(", ")}: ${dx > 0 ? "+" : ""}${Math.round(dx)}px clear of ${p.id}`);
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
// 1. escape
|
|
388
|
+
for (const e of s.elements) {
|
|
389
|
+
if (!isText(e) || !e.parent) continue;
|
|
390
|
+
const parent = byId.get(e.parent);
|
|
391
|
+
if (!parent || !PAINTED.has(parent.kind)) continue;
|
|
392
|
+
const r = rectOf(e);
|
|
393
|
+
const over = bottom(r) + 4 - bottom(parent);
|
|
394
|
+
if (over > EPS) {
|
|
395
|
+
// Geometry is meaning here: stretching a circle into an oval or a
|
|
396
|
+
// path away from its neighbours is not text fitting. The measured
|
|
397
|
+
// escape remains for the author to repair.
|
|
398
|
+
if (fixedAspect(parent)) continue;
|
|
399
|
+
const oldBottom = bottom(parent);
|
|
400
|
+
parent.h += over;
|
|
401
|
+
// Everything that started at or below the parent's old bottom edge,
|
|
402
|
+
// outside the parent, moves down with it.
|
|
403
|
+
for (const el of s.elements) {
|
|
404
|
+
if (el === parent || descendsFrom(el, parent.id)) continue;
|
|
405
|
+
if (el.y + EPS >= oldBottom && el.x < parent.x + parent.w && parent.x < el.x + el.w) translateElement(el, 0, over);
|
|
406
|
+
}
|
|
407
|
+
changes.push(`${parent.id}: +${Math.round(over)}px for ${e.id}`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
// 2. overlap between texts
|
|
411
|
+
const texts = s.elements.filter(isText);
|
|
412
|
+
for (let i = 0; i < texts.length; i++) {
|
|
413
|
+
for (let j = 0; j < texts.length; j++) {
|
|
414
|
+
if (i === j) continue;
|
|
415
|
+
const a = texts[i];
|
|
416
|
+
const b = texts[j];
|
|
417
|
+
if (a.y > b.y || (a.y === b.y && i > j)) continue; // b is the lower one
|
|
418
|
+
const ra = rectOf(a);
|
|
419
|
+
const rb = rectOf(b);
|
|
420
|
+
if (!intersects(ra, rb)) continue;
|
|
421
|
+
const dy = bottom(ra) - rb.y + 2;
|
|
422
|
+
if (dy <= EPS) continue;
|
|
423
|
+
const scope = b.parent ? byId.get(b.parent) : null;
|
|
424
|
+
// Two texts sharing a top edge: `a` is at `fromY` too, so it used to be
|
|
425
|
+
// shifted along with `b` — both moved by dy, still exactly as overlapped,
|
|
426
|
+
// one "change" recorded per pass, walking the pair toward the slot edge
|
|
427
|
+
// until rule 3 shrank their fonts. `a` is held.
|
|
428
|
+
if (!shiftBelow(b.y, dy, scope, a)) continue;
|
|
429
|
+
changes.push(`${b.id}: +${Math.round(dy)}px below ${a.id}`);
|
|
430
|
+
// A shifted text may now escape its parent — rule 1 on the next pass.
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
// 3. slot bottom
|
|
434
|
+
for (const e of s.elements) {
|
|
435
|
+
if (e.textRotation) continue;
|
|
436
|
+
const r = rectOf(e);
|
|
437
|
+
const over = bottom(r) - slot.h;
|
|
438
|
+
if (over <= EPS) continue;
|
|
439
|
+
const parent = e.parent ? byId.get(e.parent) : null;
|
|
440
|
+
const group = parent ? s.elements.filter((x) => x === parent || descendsFrom(x, parent.id)) : [e];
|
|
441
|
+
const top = Math.min(...group.map((x) => x.y));
|
|
442
|
+
if (top - over >= 0 && !parent) {
|
|
443
|
+
for (const x of group) translateElement(x, 0, -over);
|
|
444
|
+
changes.push(`${e.id}: lifted ${Math.round(over)}px`);
|
|
445
|
+
} else if (parent && parent.y - over >= 0) {
|
|
446
|
+
for (const x of group) translateElement(x, 0, -over);
|
|
447
|
+
changes.push(`${parent.id}: lifted ${Math.round(over)}px`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return { spec: s, changes };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function descendsFromIn(byId, el, id) {
|
|
454
|
+
let p = el.parent ? byId.get(el.parent) : null;
|
|
455
|
+
let guard = 0;
|
|
456
|
+
while (p && guard++ < 32) {
|
|
457
|
+
if (p.id === id) return true;
|
|
458
|
+
p = p.parent ? byId.get(p.parent) : null;
|
|
459
|
+
}
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ---- A hand-edited slide: read the transcribed helpers back, settle, rewrite.
|
|
464
|
+
// The transcribed JSX is regular (one `<B|T|I|R … />` per line with literal
|
|
465
|
+
// numbers), so its own output is the only JSX ever parsed here.
|
|
466
|
+
// `</B>` closes a folded card and a chart wrapper, and a chart placeholder line
|
|
467
|
+
// carries a trailing `{/* … */}`: without both, those lines matched nothing and
|
|
468
|
+
// the boxes were missing from `painted`, so containment parented their children
|
|
469
|
+
// onto the wrong box (or onto none).
|
|
470
|
+
const ELEMENT_RE = /^(\s*)<(B|T|I|L)\s+([^\n]*?)(?:\/>|>([^\n]*?)<\/(?:T|L|B)>)\s*(?:\{\/\*[^\n]*\*\/\})?\s*$/;
|
|
471
|
+
const ATTR_RE = /([A-Za-z]+)=(?:\{([^}]*)\}|"([^"]*)")|\b(nowrap|bold|shadow|ordered)\b/g;
|
|
472
|
+
|
|
473
|
+
export function parseTranscribedJsx(src) {
|
|
474
|
+
const elements = [];
|
|
475
|
+
const lines = String(src).split("\n");
|
|
476
|
+
lines.forEach((line, index) => {
|
|
477
|
+
const m = ELEMENT_RE.exec(line);
|
|
478
|
+
if (!m) return;
|
|
479
|
+
const attrs = {};
|
|
480
|
+
for (const a of m[3].matchAll(ATTR_RE)) {
|
|
481
|
+
if (a[4]) attrs[a[4]] = true;
|
|
482
|
+
else attrs[a[1]] = a[2] != null ? (Number.isFinite(Number(a[2])) ? Number(a[2]) : a[2]) : a[3];
|
|
483
|
+
}
|
|
484
|
+
if (!attrs.id) return;
|
|
485
|
+
const kind = { B: attrs.fill && attrs.fill !== "none" ? "card" : "shape", T: "text", I: "icon", L: "text" }[m[2]];
|
|
486
|
+
elements.push({
|
|
487
|
+
...(typeof attrs.stack === "string" ? { stack: attrs.stack } : {}),
|
|
488
|
+
line: index,
|
|
489
|
+
tag: m[2],
|
|
490
|
+
id: attrs.id,
|
|
491
|
+
kind,
|
|
492
|
+
x: attrs.x ?? 0,
|
|
493
|
+
y: attrs.y ?? 0,
|
|
494
|
+
w: attrs.w ?? 0,
|
|
495
|
+
h: attrs.h ?? (m[2] === "T" || m[2] === "L" ? 14 : 0),
|
|
496
|
+
fill: attrs.fill ?? "none",
|
|
497
|
+
shape: attrs.shape ?? null,
|
|
498
|
+
textRotation: attrs.rotation ?? null,
|
|
499
|
+
text: m[2] === "T" || m[2] === "L" ? (m[4] ?? "").replace(/<[^>]+>/g, " ").trim() : null,
|
|
500
|
+
parent: null,
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
// Parents by containment: the smallest painted box whose rect holds the
|
|
504
|
+
// element's centre (the JSX is flat; the spec's tree is gone).
|
|
505
|
+
const painted = elements.filter((e) => e.tag === "B" && e.w > 0 && e.h > 0);
|
|
506
|
+
for (const e of elements) {
|
|
507
|
+
const cx = e.x + e.w / 2;
|
|
508
|
+
const cy = e.y + e.h / 2;
|
|
509
|
+
let best = null;
|
|
510
|
+
for (const p of painted) {
|
|
511
|
+
if (p === e || cx < p.x || cx > p.x + p.w || cy < p.y || cy > p.y + p.h) continue;
|
|
512
|
+
if (!best || p.w * p.h < best.w * best.h) best = p;
|
|
513
|
+
}
|
|
514
|
+
e.parent = best ? best.id : null;
|
|
515
|
+
}
|
|
516
|
+
// The declared stacks the lines carry (`stack="n:r"` on a part, "n:gr" on
|
|
517
|
+
// what is drawn in the gap under row r; STACKS for align and spacing), so a
|
|
518
|
+
// repair re-lays a row as one unit instead of accepting one moved part.
|
|
519
|
+
const connectors = lines.map(parseConnectorLine).filter(Boolean);
|
|
520
|
+
const stacks = stacksFromMarks(src, elements, connectors);
|
|
521
|
+
const grids = gridsFromMarks(src, connectors);
|
|
522
|
+
// A divider marked `data-sm-divides="block:after:frac"` belongs to the boundary
|
|
523
|
+
// between two paragraphs of that block (block-dividers.mjs).
|
|
524
|
+
const byId = new Map(elements.map((e) => [e.id, e]));
|
|
525
|
+
for (const c of connectors) {
|
|
526
|
+
const m = c?.divides && /^(.+):(\d+):([\d.]+)$/.exec(c.divides);
|
|
527
|
+
const block = m && byId.get(m[1]);
|
|
528
|
+
if (!block) continue;
|
|
529
|
+
block.dividers = [...(block.dividers ?? []), { id: c.id, after: Number(m[2]), frac: Number(m[3]) }];
|
|
530
|
+
block.paragraphs ??= [];
|
|
531
|
+
}
|
|
532
|
+
return { elements, connectors: [], stacks, grids, lineMarks: connectors.filter((c) => c.stack || c.divides) };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** The grids a slide's GRIDS constant and "G<k>:g<r>:<f>" marks describe (stack ids as `stack-<n>`). */
|
|
536
|
+
export function gridsFromMarks(src, connectors = []) {
|
|
537
|
+
let declared = {};
|
|
538
|
+
try { declared = JSON.parse(GRIDS_RE.exec(String(src))?.[1] ?? "{}"); } catch { declared = {}; }
|
|
539
|
+
return Object.entries(declared).map(([k, g]) => ({
|
|
540
|
+
id: `grid-${k}`,
|
|
541
|
+
stacks: (g.stacks ?? []).map((n) => `stack-${n}`),
|
|
542
|
+
rows: g.rows,
|
|
543
|
+
gapMarks: connectors.filter((c) => c?.id && typeof c.stack === "string" && c.stack.startsWith(`G${k}:g`)).map((c) => {
|
|
544
|
+
const m = /^G\d+:g(\d+)(?::([\d.]+))?$/.exec(c.stack);
|
|
545
|
+
return m ? { id: c.id, gap: Number(m[1]) - 1, frac: m[2] != null ? Number(m[2]) : 0.5 } : null;
|
|
546
|
+
}).filter(Boolean),
|
|
547
|
+
})).filter((g) => g.stacks.length >= 2 && g.rows >= 2);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const STACKS_RE = /^\s*const STACKS = (\{.*\});\s*$/m;
|
|
551
|
+
const GRIDS_RE = /^\s*const GRIDS = (\{.*\});\s*$/m;
|
|
552
|
+
|
|
553
|
+
/** The stacks a transcribed slide's marks describe (see parseTranscribedJsx). */
|
|
554
|
+
export function stacksFromMarks(src, elements, connectors = []) {
|
|
555
|
+
let declared = {};
|
|
556
|
+
try { declared = JSON.parse(STACKS_RE.exec(String(src))?.[1] ?? "{}"); } catch { declared = {}; }
|
|
557
|
+
const groups = new Map();
|
|
558
|
+
const mark = (id, raw, line) => {
|
|
559
|
+
const m = /^(\d+):(g?)(\d+)(?::([\d.]+))?$/.exec(String(raw ?? ""));
|
|
560
|
+
if (!m) return;
|
|
561
|
+
const g = groups.get(m[1]) ?? { id: `stack-${m[1]}`, rows: new Map(), between: [] };
|
|
562
|
+
if (m[2]) g.between.push({ id, line, gap: Number(m[3]) - 1, ...(m[4] != null ? { frac: Number(m[4]) } : {}) });
|
|
563
|
+
else g.rows.set(Number(m[3]), [...(g.rows.get(Number(m[3])) ?? []), id]);
|
|
564
|
+
groups.set(m[1], g);
|
|
565
|
+
};
|
|
566
|
+
for (const e of elements) if (e.stack) mark(e.id, e.stack, false);
|
|
567
|
+
for (const c of connectors) if (c?.stack && c.id) mark(c.id, c.stack, true);
|
|
568
|
+
const byId = new Map(elements.map((e) => [e.id, e]));
|
|
569
|
+
const out = [];
|
|
570
|
+
for (const [n, g] of groups) {
|
|
571
|
+
const rows = [...g.rows.entries()].sort((a, b) => a[0] - b[0]).map(([, ids]) => ids);
|
|
572
|
+
if (rows.length < 2) continue;
|
|
573
|
+
const parents = new Set(rows.flat().map((id) => byId.get(id)?.parent ?? null));
|
|
574
|
+
const parent = parents.size === 1 ? [...parents][0] : null;
|
|
575
|
+
const d = declared[n] ?? {};
|
|
576
|
+
// Spacing on a repaired slide is always as drawn: the band's bounds (the
|
|
577
|
+
// header above the rows, the divider under them) are not in the marks, so an
|
|
578
|
+
// even redistribution could only be guessed. Rows and gaps keep their
|
|
579
|
+
// places; the rows themselves are re-laid as units.
|
|
580
|
+
out.push({ id: g.id, parent, rows, align: d.align ?? "middle", spacing: "drawn", gapMarks: g.between });
|
|
581
|
+
}
|
|
582
|
+
return out;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Write the settled x/y/h back onto the lines they came from; every other
|
|
586
|
+
// character of the file stays as the editor left it.
|
|
587
|
+
export function applySettledToJsx(src, parsed, settled) {
|
|
588
|
+
const lines = String(src).split("\n");
|
|
589
|
+
const byId = new Map(settled.elements.map((e) => [e.id, e]));
|
|
590
|
+
for (const orig of parsed.elements) {
|
|
591
|
+
const e = byId.get(orig.id);
|
|
592
|
+
if (!e) continue;
|
|
593
|
+
let line = lines[orig.line];
|
|
594
|
+
const put = (name, value) => {
|
|
595
|
+
const re = new RegExp(`\\b${name}=\\{[^}]*\\}`);
|
|
596
|
+
if (re.test(line)) line = line.replace(re, `${name}={${value}}`);
|
|
597
|
+
else if (value != null) line = line.replace(/^(\s*<[BTIL]\s+id="[^"]*")/, `$1 ${name}={${value}}`);
|
|
598
|
+
};
|
|
599
|
+
if (e.x !== orig.x) put("x", Math.round(e.x));
|
|
600
|
+
if (e.y !== orig.y) put("y", Math.round(e.y));
|
|
601
|
+
// A `<T>` / `<L>` takes no `h`: text takes the height it needs.
|
|
602
|
+
const sizedText = orig.tag === "T" || orig.tag === "L";
|
|
603
|
+
if (!sizedText && e.h !== orig.h) put("h", Math.round(e.h));
|
|
604
|
+
lines[orig.line] = line;
|
|
605
|
+
}
|
|
606
|
+
return lines.join("\n");
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
export function settleJsx(src, measured, opts = {}) {
|
|
610
|
+
const parsed = parseTranscribedJsx(src);
|
|
611
|
+
if (!parsed.elements.length) return { src, changes: [] };
|
|
612
|
+
// A declared stack first: its rows laid out again from the marks the lines
|
|
613
|
+
// carry, on the measured heights, every part of a row together — an edit
|
|
614
|
+
// that moved one part (the gate's "move #x 4px up") is answered by the row.
|
|
615
|
+
const { spec: stacked, changes: stackChanges, lineMoves } = relayStacks(src, parsed, measured, opts);
|
|
616
|
+
const { spec: settled, changes: settleChanges } = settleSpec({ elements: stacked.elements, connectors: [], slot: opts.slot }, measured, opts);
|
|
617
|
+
const changes = [...stackChanges, ...settleChanges];
|
|
618
|
+
if (!changes.length) return { src, changes };
|
|
619
|
+
let out = applySettledToJsx(src, parsed, settled);
|
|
620
|
+
for (const [index, dy] of lineMoves) out = moveLiteralLine(out, index, dy);
|
|
621
|
+
return { src: out, changes };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Stacks on the parsed slide: the free lines drawn in the gaps are moved by the
|
|
625
|
+
// same amount as their marks ask, written back as literal point refs.
|
|
626
|
+
function relayStacks(src, parsed, measured, opts) {
|
|
627
|
+
const lineMoves = new Map();
|
|
628
|
+
if (!parsed.stacks?.length && !parsed.elements.some((e) => e.dividers)) return { spec: { elements: parsed.elements }, changes: [], lineMoves };
|
|
629
|
+
const spec = { elements: parsed.elements.map((e) => ({ ...e })), connectors: [], slot: opts.slot, stacks: (parsed.stacks ?? []).map((g) => ({ ...g })), grids: (parsed.grids ?? []).map((g) => ({ ...g })) };
|
|
630
|
+
// The lines drawn in a gap, as the connectors stack.mjs moves (`points`).
|
|
631
|
+
for (const c of parsed.lineMarks ?? []) {
|
|
632
|
+
const pts = [c.fromEnd.point, c.toEnd.point].filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y));
|
|
633
|
+
if (pts.length !== 2) continue;
|
|
634
|
+
spec.connectors.push({ id: c.id, from: null, to: null, fromPoint: { ...pts[0] }, toPoint: { ...pts[1] }, points: pts.map((p) => ({ ...p })), line: c.line });
|
|
635
|
+
}
|
|
636
|
+
// Recorded structure from the marks: the band's gap items are the marked lines
|
|
637
|
+
// and elements, at their current fraction of the gap (nothing re-inferred).
|
|
638
|
+
const at = (o) => { const m = measured.get(o.id); return m && m.h > 0 ? { x: o.x, y: m.y ?? o.y, w: o.w, h: m.h } : o; };
|
|
639
|
+
const byId = new Map(spec.elements.map((e) => [e.id, e]));
|
|
640
|
+
for (const g of spec.stacks) {
|
|
641
|
+
const rows = g.rows.map((row) => row.map((id) => byId.get(id)).filter(Boolean)).filter((r) => r.length);
|
|
642
|
+
if (rows.length < 2) continue;
|
|
643
|
+
// A row's extent as stack.mjs reads it: its parts AND the lines listed with it.
|
|
644
|
+
const ext = g.rows.map((row) => { const x = rowExtent(spec, row, at); return x ? [x.top, x.bottom] : [0, 0]; });
|
|
645
|
+
const between = [];
|
|
646
|
+
for (const it of g.gapMarks ?? []) {
|
|
647
|
+
const [a, b] = [ext[it.gap]?.[1], ext[it.gap + 1]?.[0]];
|
|
648
|
+
if (a == null || b == null) continue;
|
|
649
|
+
const c = spec.connectors.find((x) => x.id === it.id);
|
|
650
|
+
const e = byId.get(it.id);
|
|
651
|
+
const mid = c ? (c.fromPoint.y + c.toPoint.y) / 2 : e ? at(e).y + at(e).h / 2 : null;
|
|
652
|
+
if (mid == null) continue;
|
|
653
|
+
// The drawn fraction the mark carries; else where it sits in the gap now.
|
|
654
|
+
between.push({ id: it.id, line: Boolean(c), gap: it.gap, frac: it.frac ?? (b > a ? Math.min(1, Math.max(0, (mid - a) / (b - a))) : 0.5) });
|
|
655
|
+
}
|
|
656
|
+
g.band = { before: [], after: [], sepBefore: null, sepAfter: null, gaps: ext.slice(1).map((r, i) => r[0] - ext[i][1]), lead: 0, lines: {}, between };
|
|
657
|
+
// The band it owns now: its parent's inner box; its first row stays (drawn lead).
|
|
658
|
+
const p = g.parent ? byId.get(g.parent) : null;
|
|
659
|
+
g.band.lead = ext[0][0] - (p ? p.y : 0);
|
|
660
|
+
delete g.gapMarks;
|
|
661
|
+
}
|
|
662
|
+
// A grid's own dividers, at their marked fraction of the shared gap.
|
|
663
|
+
for (const grid of spec.grids) {
|
|
664
|
+
grid.band = { between: (grid.gapMarks ?? []).filter((it) => spec.connectors.some((c) => c.id === it.id)).map((it) => ({ ...it, line: true })) };
|
|
665
|
+
delete grid.gapMarks;
|
|
666
|
+
}
|
|
667
|
+
const before = new Map(spec.connectors.map((c) => [c.id, c.fromPoint.y]));
|
|
668
|
+
const changes = applyStacks(spec, { at });
|
|
669
|
+
// A block's paragraph count on the repaired slide is what the render shows.
|
|
670
|
+
for (const e of spec.elements) if (e.dividers && measured.get(e.id)?.paraRects) e.paragraphs = measured.get(e.id).paraRects.map(() => ({}));
|
|
671
|
+
changes.push(...placeBlockDividers(spec, measured));
|
|
672
|
+
for (const c of spec.connectors) {
|
|
673
|
+
const dy = c.fromPoint.y - before.get(c.id);
|
|
674
|
+
if (dy) lineMoves.set(c.line, dy);
|
|
675
|
+
}
|
|
676
|
+
return { spec, changes, lineMoves };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Move a free line directive's literal point refs by dy.
|
|
680
|
+
function moveLiteralLine(src, index, dy) {
|
|
681
|
+
const lines = String(src).split("\n");
|
|
682
|
+
lines[index] = lines[index].replace(/(data-(?:from|to)=")(-?[\d.]+),(-?[\d.]+)"/g, (_, k, x, y) => `${k}${x},${Math.round((Number(y) + dy) * 2) / 2}"`);
|
|
683
|
+
return lines.join("\n");
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
// ---- Connectors against the whole slide, locally.
|
|
688
|
+
//
|
|
689
|
+
// A connector directive names its preset, adjusts, turn and — for a glued end
|
|
690
|
+
// — the connection site of its node's own preset (the nativeLinesV1 contract).
|
|
691
|
+
// Nodes never move here; a line that crosses a text or picture the concept
|
|
692
|
+
// does not cross is re-solved within its own preset: its adjusts, its turn and
|
|
693
|
+
// (where it pays for itself) a site. What cannot be cleared is reported.
|
|
694
|
+
const CONNECTOR_LINE_RE = /^(\s*)<div data-connector ([^\n]*?)\s*\/>\s*$/;
|
|
695
|
+
|
|
696
|
+
export function parseConnectorLine(line, index) {
|
|
697
|
+
const m = CONNECTOR_LINE_RE.exec(line);
|
|
698
|
+
if (!m) return null;
|
|
699
|
+
const attrs = {};
|
|
700
|
+
for (const a of m[2].matchAll(/(?:^|\s)(id|data-[a-z-]+)="([^"]*)"/g)) attrs[a[1].replace(/^data-/, "")] = a[2];
|
|
701
|
+
if (!attrs.from || !attrs.to) return null;
|
|
702
|
+
const stack = typeof attrs["sm-stack"] === "string" ? attrs["sm-stack"] : null;
|
|
703
|
+
const divides = typeof attrs["sm-divides"] === "string" ? attrs["sm-divides"] : null;
|
|
704
|
+
const end = (ref, site) => (ref.startsWith("#")
|
|
705
|
+
? { node: ref.slice(1), site: site != null && /^\d+$/.test(site) ? Number(site) : null }
|
|
706
|
+
: { point: parsePointRef(ref) });
|
|
707
|
+
const adj = {};
|
|
708
|
+
for (const kv of String(attrs.adj ?? "").split(",").filter(Boolean)) {
|
|
709
|
+
const [k, v] = kv.split(":");
|
|
710
|
+
if (k && Number.isFinite(Number(v))) adj[k.trim()] = Number(v);
|
|
711
|
+
}
|
|
712
|
+
return {
|
|
713
|
+
line: index,
|
|
714
|
+
id: attrs.id ?? null,
|
|
715
|
+
...(stack ? { stack } : {}),
|
|
716
|
+
...(divides ? { divides } : {}),
|
|
717
|
+
from: attrs.from.replace(/^#/, ""),
|
|
718
|
+
to: attrs.to.replace(/^#/, ""),
|
|
719
|
+
fromRef: attrs.from,
|
|
720
|
+
toRef: attrs.to,
|
|
721
|
+
fromEnd: end(attrs.from, attrs["from-site"]),
|
|
722
|
+
toEnd: end(attrs.to, attrs["to-site"]),
|
|
723
|
+
prst: attrs.prst ?? null,
|
|
724
|
+
adj,
|
|
725
|
+
rot: attrs.rot != null ? Number(attrs.rot) : 0,
|
|
726
|
+
fromSite: attrs["from-site"] ?? null,
|
|
727
|
+
toSite: attrs["to-site"] ?? null,
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const ROUND_SHAPES = new Set(["circle", "ellipse", "ring"]);
|
|
732
|
+
|
|
733
|
+
// A node's shape as the SLIDE declares it (the agent may have changed it since
|
|
734
|
+
// the spec was read): an explicit `prst`/`data-prst` (+ adj) or a helper's
|
|
735
|
+
// `shape="…"`. Null when the line names neither.
|
|
736
|
+
function jsxNodeShape(src, id) {
|
|
737
|
+
const line = String(src ?? "").split("\n").find((l) => l.includes(`id="${id}"`) && !/data-connector\b/.test(l));
|
|
738
|
+
if (!line) return null;
|
|
739
|
+
const attr = (k) => new RegExp(`\\s(?:data-)?${k}="([^"]*)"`).exec(line)?.[1] ?? null;
|
|
740
|
+
const prst = attr("prst");
|
|
741
|
+
if (prst) {
|
|
742
|
+
const av = Object.fromEntries((attr("adj") ?? "").split(",").map((kv) => kv.split(":")).filter(([k, v]) => k && Number.isFinite(Number(v))).map(([k, v]) => [k, Number(v)]));
|
|
743
|
+
return { prst, av };
|
|
744
|
+
}
|
|
745
|
+
const shape = attr("shape");
|
|
746
|
+
return shape ? { kind: "card", shape, fill: "light" } : null;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// The nodes a slide's connectors glue to, as presets over their MEASURED rects
|
|
750
|
+
// — shaped as the slide's own source declares them, the spec only where the
|
|
751
|
+
// source is silent.
|
|
752
|
+
function measuredNodes(connectors, measured, byId = new Map(), src = null) {
|
|
753
|
+
const nodes = new Map();
|
|
754
|
+
for (const c of connectors) for (const e of [c.fromEnd, c.toEnd]) {
|
|
755
|
+
if (!e.node || nodes.has(e.node) || !measured.get(e.node)) continue;
|
|
756
|
+
const m = measured.get(e.node);
|
|
757
|
+
const rect = { x: m.x, y: m.y, w: m.w, h: m.h };
|
|
758
|
+
const own = jsxNodeShape(src, e.node);
|
|
759
|
+
const n = own?.prst ? { prst: own.prst, av: own.av, box: rect } : nodePreset(own ?? byId.get(e.node) ?? { kind: "card" }, rect);
|
|
760
|
+
if (n) nodes.set(e.node, n);
|
|
761
|
+
}
|
|
762
|
+
return nodes;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// The path a parsed directive draws over the given nodes (null when an end
|
|
766
|
+
// cannot be resolved).
|
|
767
|
+
function drawnPath(c, nodes) {
|
|
768
|
+
const at = (e) => {
|
|
769
|
+
if (e.point) return e.point;
|
|
770
|
+
const node = nodes.get(e.node);
|
|
771
|
+
return node && e.site != null ? nodeSites(node)[e.site] ?? null : null;
|
|
772
|
+
};
|
|
773
|
+
const S = at(c.fromEnd), E = at(c.toEnd);
|
|
774
|
+
if (!S || !E || !c.prst) return null;
|
|
775
|
+
return connectorGeometry(c.prst, S, E, c.rot, c.adj)?.points ?? null;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Write a re-solved connector's sites, turn and adjusts back onto its line.
|
|
779
|
+
function rewriteConnector(line, n) {
|
|
780
|
+
let out = line;
|
|
781
|
+
const setAttr = (name, value) => {
|
|
782
|
+
const re = new RegExp(`\\sdata-${name}="[^"]*"`);
|
|
783
|
+
if (value == null || value === "") out = out.replace(re, "");
|
|
784
|
+
else if (re.test(out)) out = out.replace(re, ` data-${name}="${value}"`);
|
|
785
|
+
else out = out.replace(/ data-prst="/, ` data-${name}="${value}" data-prst="`);
|
|
786
|
+
};
|
|
787
|
+
setAttr("from-site", n.from.node ? n.from.site : null);
|
|
788
|
+
setAttr("to-site", n.to.node ? n.to.site : null);
|
|
789
|
+
setAttr("rot", n.rot || null);
|
|
790
|
+
const adj = Object.entries(n.av ?? {}).map(([k, v]) => `${k}:${Math.round(v)}`).join(",");
|
|
791
|
+
if (/\sdata-adj="/.test(out) || adj) {
|
|
792
|
+
if (/\sdata-adj="/.test(out)) out = out.replace(/\sdata-adj="[^"]*"/, adj ? ` data-adj="${adj}"` : "");
|
|
793
|
+
else out = out.replace(/(\sdata-prst="[^"]*")/, `$1 data-adj="${adj}"`);
|
|
794
|
+
}
|
|
795
|
+
return out;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Every top-level connector directive of a slide with the path it draws over
|
|
800
|
+
* the MEASURED nodes (`[{ id, prst, fromEnd, toEnd, points, … }]`) — the
|
|
801
|
+
* exporter's placement, evaluated by the shared preset evaluator.
|
|
802
|
+
*/
|
|
803
|
+
export function connectorPaths(src, measured, spec = null) {
|
|
804
|
+
const byId = new Map((spec?.elements ?? []).map((e) => [e.id, e]));
|
|
805
|
+
const connectors = String(src ?? "").split("\n").map(parseConnectorLine).filter((c) => c?.prst);
|
|
806
|
+
const nodes = measuredNodes(connectors, measured, byId, src);
|
|
807
|
+
return connectors.map((c) => ({ ...c, points: drawnPath(c, nodes), fromBox: nodes.get(c.fromEnd.node)?.box ?? null, toBox: nodes.get(c.toEnd.node)?.box ?? null })).filter((c) => c.points);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const obstaclesOf = (measured) => [...measured.entries()].filter(([, m]) => (m.text || m.content || m.painted) && m.w > 0 && m.h > 0).map(([id, m]) => ({ id, ...m }));
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Re-solve every glued connector that crosses a text, picture or painted box
|
|
814
|
+
* (`measured`, slot px) within its own preset. Returns `{ src, changes,
|
|
815
|
+
* unresolved }`.
|
|
816
|
+
*/
|
|
817
|
+
export function routeConnectors(src, measured, { minGap = 2 } = {}) {
|
|
818
|
+
const lines = String(src).split("\n");
|
|
819
|
+
const connectors = lines.map(parseConnectorLine).filter((c) => c && (c.fromEnd.node || c.toEnd.node));
|
|
820
|
+
if (!connectors.length) return { src, changes: [], unresolved: [] };
|
|
821
|
+
const nodes = measuredNodes(connectors, measured, new Map(), src);
|
|
822
|
+
const obstacles = obstaclesOf(measured);
|
|
823
|
+
const changes = [];
|
|
824
|
+
const unresolved = [];
|
|
825
|
+
const crossings = (points, c) => {
|
|
826
|
+
const own = [c.fromEnd.node, c.toEnd.node].filter(Boolean).map((id) => measured.get(id)).filter(Boolean);
|
|
827
|
+
const hits = [];
|
|
828
|
+
for (const t of obstacles) {
|
|
829
|
+
if (own.some((r) => contains(r, t) || (!t.text && !t.content && contains(t, r)))) continue;
|
|
830
|
+
const box = { x: t.x - minGap, y: t.y - minGap, w: t.w + 2 * minGap, h: t.h + 2 * minGap };
|
|
831
|
+
for (let i = 0; i + 1 < points.length; i++) if (segmentHitsRect(points[i], points[i + 1], box)) { hits.push(t.id); break; }
|
|
832
|
+
}
|
|
833
|
+
return hits;
|
|
834
|
+
};
|
|
835
|
+
for (const c of connectors) {
|
|
836
|
+
const current = drawnPath(c, nodes);
|
|
837
|
+
if (!current) {
|
|
838
|
+
const bad = [c.fromEnd, c.toEnd].filter((e) => e.node && nodes.has(e.node) && !nodeSites(nodes.get(e.node))[e.site]);
|
|
839
|
+
if (bad.length) unresolved.push(`${c.from} → ${c.to}: ${bad.map((e) => `site ${e.site} does not exist on ${e.node} (${nodes.get(e.node).prst}, sites 0–${nodeSites(nodes.get(e.node)).length - 1})`).join("; ")} — name one of its sites`);
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
const hits = crossings(current, c);
|
|
843
|
+
if (!hits.length) continue;
|
|
844
|
+
const n = resolveGlued({ prst: c.prst, from: c.fromEnd, to: c.toEnd }, nodes, { obstacles, crossPenalty: NATIVE_TOL.routeCrossPenaltyPx });
|
|
845
|
+
const after = n ? connectorGeometry(n.prst, n.start, n.end, n.rot, n.av)?.points : null;
|
|
846
|
+
if (!after || crossings(after, c).length) {
|
|
847
|
+
unresolved.push(`${c.from} → ${c.to} crosses ${hits.join(", ")}; no turn, adjust or site of its ${c.prst} clears it with the nodes where they are — move the text or the node`);
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
lines[c.line] = rewriteConnector(lines[c.line], n);
|
|
851
|
+
changes.push(`${c.from} → ${c.to}: ${c.prst} re-solved (sites ${n.from.site ?? "point"}→${n.to.site ?? "point"}${n.rot ? `, rot ${n.rot}` : ""}) clears ${hits.join(", ")}`);
|
|
852
|
+
}
|
|
853
|
+
return { src: lines.join("\n"), changes, unresolved };
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
// ---- May the settling core rewrite THIS file? (safeguard-slide-editing 6.1)
|
|
858
|
+
//
|
|
859
|
+
// A ledger entry proves ancestry, not syntax eligibility: `state/transcribed.json`
|
|
860
|
+
// says these bytes were once machine-written, not that they still ARE. So the
|
|
861
|
+
// repair gate re-reads the bytes in front of it every time, BEFORE any mutation,
|
|
862
|
+
// and a node it does not understand fails the whole FILE — it is never skipped
|
|
863
|
+
// while the rest of the slide is rewritten around it. That silent partial
|
|
864
|
+
// rewrite is the bug this exists to prevent.
|
|
865
|
+
//
|
|
866
|
+
// Pure like the rest of this module: no fs, no throw, no write. The caller reads
|
|
867
|
+
// the deck's conversion record (src/lib/conversions.mjs → `loadConversions`) and
|
|
868
|
+
// passes it in; `conversion: undefined` means nobody looked, which is a refusal
|
|
869
|
+
// of its own — missing evidence is unknown, not passed. Converted/native source
|
|
870
|
+
// always refuses: its geometry belongs to the original package, not to us.
|
|
871
|
+
const SLOT_OPEN_RE = /^\s*<div style=\{\{\s*position:\s*"relative",\s*width:\s*(\d+(?:\.\d+)?),\s*height:\s*(\d+(?:\.\d+)?)\s*\}\}>\s*$/;
|
|
872
|
+
const SLOT_CLOSE_RE = /^\s*<\/div>\s*$/;
|
|
873
|
+
const FIGURE_RE = /^\s*<div id="[^"]+"(?: data-sm-plot-owner="[^"]+")? style=\{\{[^{}]*\}\}><svg [^\n]*<\/svg><\/div>\s*$/;
|
|
874
|
+
const COMMENT_RE = /^\s*\{\/\*[^\n]*\*\/\}\s*$/;
|
|
875
|
+
const CONNECTOR_START_RE = /^\s*<div data-connector\b/;
|
|
876
|
+
// A native line shape or a chain group: one self-contained line the settle
|
|
877
|
+
// core never rewrites (its geometry is its own coordinates).
|
|
878
|
+
const LINE_SHAPE_RE = /^\s*<div data-sm-line(?:-group)?\s[^\n]*(?:\/>|<\/div>)\s*$/;
|
|
879
|
+
const HELPER_START_RE = /^\s*<[BTIL]\s/;
|
|
880
|
+
const DIALECT_RE = /^\s*\/\/\s*SLIDEMIND_DIALECT:\s*converted\b/m;
|
|
881
|
+
const NATIVE_RE = /\bdata-sm-(?:native-object|source-id)\b|<ConvertedHost/;
|
|
882
|
+
const NUMBER_RE = /^-?\d+(?:\.\d+)?$/;
|
|
883
|
+
const GEOMETRY_IN_STYLE_RE = /(?:^|[,{\s])(?:left|top|width|height)\s*:/;
|
|
884
|
+
// A `style` is opaque to the rewrite but not to this gate: the transcriber
|
|
885
|
+
// writes a closed vocabulary there — the kit's own maps or a literal —
|
|
886
|
+
// and anything else (a ternary, an identifier, a call) is a value nobody here
|
|
887
|
+
// can evaluate, so the slide's paint is not what the measurement assumed.
|
|
888
|
+
const KIT_REF = String.raw`(?:TONE|FILL|ROLE|RADIUS)(?:\["[^"]*"\]|\.[A-Za-z_$][\w$]*)`;
|
|
889
|
+
const STYLE_ENTRY_RE = new RegExp(
|
|
890
|
+
String.raw`^(?:\.\.\.${KIT_REF}|[A-Za-z][\w-]*\s*:\s*(?:"[^"]*"|-?\d+(?:\.\d+)?|${KIT_REF}))$`,
|
|
891
|
+
);
|
|
892
|
+
|
|
893
|
+
// An object literal's top-level entries: commas inside strings, brackets,
|
|
894
|
+
// braces or parens belong to the value, not to the list.
|
|
895
|
+
function styleEntries(text) {
|
|
896
|
+
const out = [];
|
|
897
|
+
let depth = 0;
|
|
898
|
+
let quote = null;
|
|
899
|
+
let from = 0;
|
|
900
|
+
for (let i = 0; i < text.length; i++) {
|
|
901
|
+
const c = text[i];
|
|
902
|
+
if (quote) {
|
|
903
|
+
if (c === quote && text[i - 1] !== "\\") quote = null;
|
|
904
|
+
} else if (c === '"' || c === "'" || c === "`") quote = c;
|
|
905
|
+
else if ("([{".includes(c)) depth++;
|
|
906
|
+
else if (")]}".includes(c)) depth--;
|
|
907
|
+
else if (c === "," && depth === 0) {
|
|
908
|
+
out.push(text.slice(from, i).trim());
|
|
909
|
+
from = i + 1;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
out.push(text.slice(from).trim());
|
|
913
|
+
return out.filter(Boolean);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// The props each helper in the transcriber's kit actually destructures; an
|
|
917
|
+
// attribute outside its list is a no-op the author believes in.
|
|
918
|
+
// Every prop the generated helpers take — `stack` (a declared stack's row
|
|
919
|
+
// mark) on all four, `block` (one text, text-blocks.mjs) on T and L.
|
|
920
|
+
const HELPER_ATTRS = {
|
|
921
|
+
B: ["id", "x", "y", "w", "h", "plotOwner", "prst", "adj", "fill", "fillVar", "shape", "shadow", "align", "valign", "style", "stack"],
|
|
922
|
+
T: ["id", "x", "y", "w", "h", "valign", "px", "role", "bold", "tone", "colorVar", "align", "nowrap", "rotation", "stack", "block"],
|
|
923
|
+
L: ["id", "x", "y", "w", "px", "indent", "align", "role", "tone", "colorVar", "bullet", "ordered", "markerVar", "stack", "block"],
|
|
924
|
+
I: ["id", "x", "y", "w", "h", "src", "use", "fit", "pos", "crop", "view", "stack"],
|
|
925
|
+
};
|
|
926
|
+
const REQUIRED_ATTRS = {
|
|
927
|
+
B: ["id", "x", "y", "w", "h"],
|
|
928
|
+
T: ["id", "x", "y", "w"],
|
|
929
|
+
L: ["id", "x", "y", "w"],
|
|
930
|
+
I: ["id", "x", "y", "w", "h", "src"],
|
|
931
|
+
};
|
|
932
|
+
// The five the settle pass writes back: a computed one cannot be measured, and
|
|
933
|
+
// rewriting it would replace the author's expression with a number.
|
|
934
|
+
const NUMERIC_ATTRS = new Set(["x", "y", "w", "h", "px", "rotation", "indent"]);
|
|
935
|
+
const FLAG_ATTRS = new Set(["nowrap", "bold", "shadow", "ordered"]);
|
|
936
|
+
|
|
937
|
+
// One JSX attribute list, left to right: every character must be accounted for.
|
|
938
|
+
// `bad` is the first run this cannot read — a ternary, an identifier, a template
|
|
939
|
+
// literal, an unclosed brace — and the caller turns it into a refusal.
|
|
940
|
+
function scanAttrs(text) {
|
|
941
|
+
const attrs = new Map();
|
|
942
|
+
let i = 0;
|
|
943
|
+
while (i < text.length) {
|
|
944
|
+
if (/\s/.test(text[i])) {
|
|
945
|
+
i++;
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
const rest = text.slice(i);
|
|
949
|
+
const flag = /^(nowrap|bold|shadow|ordered)(?=\s|$)/.exec(rest);
|
|
950
|
+
if (flag) {
|
|
951
|
+
attrs.set(flag[1], { kind: "flag", value: true });
|
|
952
|
+
i += flag[1].length;
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
const name = /^([A-Za-z][A-Za-z0-9-]*)=/.exec(rest);
|
|
956
|
+
if (!name) return { attrs, bad: rest.trim() };
|
|
957
|
+
i += name[0].length;
|
|
958
|
+
if (text[i] === '"') {
|
|
959
|
+
const end = text.indexOf('"', i + 1);
|
|
960
|
+
if (end < 0) return { attrs, bad: text.slice(i).trim() };
|
|
961
|
+
attrs.set(name[1], { kind: "string", value: text.slice(i + 1, end) });
|
|
962
|
+
i = end + 1;
|
|
963
|
+
} else if (text[i] === "{") {
|
|
964
|
+
let depth = 0;
|
|
965
|
+
let j = i;
|
|
966
|
+
for (; j < text.length; j++) {
|
|
967
|
+
if (text[j] === "{") depth++;
|
|
968
|
+
else if (text[j] === "}" && --depth === 0) break;
|
|
969
|
+
}
|
|
970
|
+
if (depth !== 0) return { attrs, bad: text.slice(i).trim() };
|
|
971
|
+
attrs.set(name[1], { kind: "expr", value: text.slice(i + 1, j).trim() });
|
|
972
|
+
i = j + 1;
|
|
973
|
+
} else return { attrs, bad: text.slice(i).trim() };
|
|
974
|
+
}
|
|
975
|
+
return { attrs, bad: null };
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Validate one slide file's CURRENT representation against the generated helper
|
|
980
|
+
* subset — the only JSX the settling core knows how to rewrite.
|
|
981
|
+
*
|
|
982
|
+
* `conversion` is the slide's `conversions/<stem>.json` record as
|
|
983
|
+
* `loadConversions()` returns it, or `null` when the caller checked and there is
|
|
984
|
+
* none. Leaving it out is itself a refusal (`provenance_unknown`).
|
|
985
|
+
*
|
|
986
|
+
* Returns `{ ok, reasons }`; `reasons` is `[{ code, line, message }]` with a
|
|
987
|
+
* 1-based line or null. Never throws, never writes, mutates nothing.
|
|
988
|
+
*/
|
|
989
|
+
export function validateSettleTarget(src, { conversion } = {}) {
|
|
990
|
+
const reasons = [];
|
|
991
|
+
const add = (code, line, message) => reasons.push({ code, line, message });
|
|
992
|
+
try {
|
|
993
|
+
const source = String(src ?? "");
|
|
994
|
+
if (conversion === undefined) {
|
|
995
|
+
add("provenance_unknown", null, "no conversion provenance was supplied — read conversions/<stem>.json first and pass `conversion` (null when the slide has no record); missing evidence is unknown, not passed");
|
|
996
|
+
} else if (conversion) {
|
|
997
|
+
add("converted_provenance", null, `converted slide (dialect ${conversion.dialect ?? "converted"}): its geometry belongs to the original package, so bounded repair never rewrites it — edit its JSX directly`);
|
|
998
|
+
}
|
|
999
|
+
if (DIALECT_RE.test(source) || NATIVE_RE.test(source)) {
|
|
1000
|
+
add("converted_provenance", null, "the source carries converted/native markers (SLIDEMIND_DIALECT, ConvertedHost or data-sm-*) — bounded repair never rewrites converted source");
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const lines = source.split("\n");
|
|
1004
|
+
const opens = lines.map((l, i) => (SLOT_OPEN_RE.test(l) ? i : -1)).filter((i) => i >= 0);
|
|
1005
|
+
if (!opens.length) {
|
|
1006
|
+
add("no_slot_container", null, 'no slot container: the generated slide holds its elements in one <div style={{ position: "relative", width: N, height: N }}>');
|
|
1007
|
+
}
|
|
1008
|
+
for (const extra of opens.slice(1)) {
|
|
1009
|
+
add("unsupported_node", extra + 1, "a second slot container — the settling core rewrites one slot per slide");
|
|
1010
|
+
}
|
|
1011
|
+
const start = opens.length ? opens[0] : -1;
|
|
1012
|
+
let end = lines.length;
|
|
1013
|
+
if (start >= 0) {
|
|
1014
|
+
end = lines.findIndex((l, i) => i > start && SLOT_CLOSE_RE.test(l));
|
|
1015
|
+
if (end < 0) {
|
|
1016
|
+
end = lines.length;
|
|
1017
|
+
add("no_slot_container", start + 1, "the slot container is never closed on a line of its own");
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
const ids = new Set();
|
|
1022
|
+
const tagsUsed = new Set();
|
|
1023
|
+
lines.forEach((line, index) => {
|
|
1024
|
+
const at = index + 1;
|
|
1025
|
+
const inside = start >= 0 && index > start && index < end;
|
|
1026
|
+
const elementish = ELEMENT_RE.test(line) || CONNECTOR_START_RE.test(line) || LINE_SHAPE_RE.test(line) || HELPER_START_RE.test(line);
|
|
1027
|
+
if (!inside) {
|
|
1028
|
+
// Outside the slot the core rewrites nothing — but it READS the whole
|
|
1029
|
+
// file line by line, so an element out here would be mutated by a rule
|
|
1030
|
+
// that never saw its context.
|
|
1031
|
+
if (elementish) add("element_outside_slot", at, `an element or connector outside the slot container: ${line.trim().slice(0, 80)}`);
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
if (!line.trim() || COMMENT_RE.test(line) || FIGURE_RE.test(line) || LINE_SHAPE_RE.test(line)) return;
|
|
1035
|
+
if (CONNECTOR_START_RE.test(line)) {
|
|
1036
|
+
const parsed = parseConnectorLine(line, index);
|
|
1037
|
+
// An end is a literal node ref ("#id") or a literal point ("x,y").
|
|
1038
|
+
const end = String.raw`(?:#[^"\s]+|-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)?)`;
|
|
1039
|
+
const literal = new RegExp(String.raw`\sdata-from="${end}"`).test(line) && new RegExp(String.raw`\sdata-to="${end}"`).test(line);
|
|
1040
|
+
if (!parsed || !literal) {
|
|
1041
|
+
add("computed_connector_endpoint", at, "a connector's data-from/data-to must be literal \"#id\" or \"x,y\" strings on one self-closing line — the lint and the router read them statically");
|
|
1042
|
+
}
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
const m = ELEMENT_RE.exec(line);
|
|
1046
|
+
if (!m) {
|
|
1047
|
+
add("unsupported_node", at, `not generated helper syntax: ${line.trim().slice(0, 80)}`);
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
const tag = m[2];
|
|
1051
|
+
tagsUsed.add(tag);
|
|
1052
|
+
const { attrs, bad } = scanAttrs(m[3]);
|
|
1053
|
+
if (bad) {
|
|
1054
|
+
add("unsupported_expression", at, `<${tag}> carries an attribute the reader cannot evaluate: ${bad.slice(0, 60)}`);
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
for (const name of REQUIRED_ATTRS[tag]) {
|
|
1058
|
+
if (!attrs.has(name)) add("missing_attribute", at, `<${tag}> is missing the required ${name}`);
|
|
1059
|
+
}
|
|
1060
|
+
for (const [name, value] of attrs) {
|
|
1061
|
+
if (!HELPER_ATTRS[tag].includes(name)) {
|
|
1062
|
+
add("unsupported_node", at, `<${tag}> takes no ${name} — the helper ignores it, so the file is not the generated one`);
|
|
1063
|
+
} else if (NUMERIC_ATTRS.has(name)) {
|
|
1064
|
+
if (value.kind !== "expr" || !NUMBER_RE.test(value.value)) {
|
|
1065
|
+
add("unsupported_expression", at, `<${tag}> ${name} must be a literal number, not ${value.kind === "string" ? `"${value.value}"` : value.value}`);
|
|
1066
|
+
}
|
|
1067
|
+
} else if (name === "style") {
|
|
1068
|
+
if (value.kind !== "expr" || !value.value.startsWith("{")) add("unsupported_expression", at, `<${tag}> style must be an object literal`);
|
|
1069
|
+
else if (GEOMETRY_IN_STYLE_RE.test(value.value)) add("unsupported_expression", at, `<${tag}> sets left/top/width/height in style — geometry the settle pass cannot see or move`);
|
|
1070
|
+
else for (const entry of styleEntries(value.value.replace(/^\{|\}$/g, ""))) {
|
|
1071
|
+
if (!STYLE_ENTRY_RE.test(entry)) add("unsupported_expression", at, `<${tag}> style carries a computed value: ${entry.slice(0, 60)}`);
|
|
1072
|
+
}
|
|
1073
|
+
} else if (FLAG_ATTRS.has(name)) {
|
|
1074
|
+
if (value.kind !== "flag") add("unsupported_expression", at, `<${tag}> ${name} is a bare flag, not ${name}={…}`);
|
|
1075
|
+
} else if (value.kind !== "string") {
|
|
1076
|
+
add("unsupported_expression", at, `<${tag}> ${name} must be a literal string, not {${value.value.slice(0, 40)}}`);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
const id = attrs.get("id");
|
|
1080
|
+
if (id?.kind === "string") {
|
|
1081
|
+
if (ids.has(id.value)) add("duplicate_element_id", at, `two elements share the id "${id.value}" — the rewrite keys by id and would move the wrong one`);
|
|
1082
|
+
ids.add(id.value);
|
|
1083
|
+
}
|
|
1084
|
+
});
|
|
1085
|
+
for (const tag of tagsUsed) {
|
|
1086
|
+
if (!new RegExp(`const ${tag} = \\(\\{`).test(source)) {
|
|
1087
|
+
add("missing_helper", null, `<${tag}> is used but the file declares no ${tag} helper — its geometry is not the kit's`);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
} catch (err) {
|
|
1091
|
+
add("unsupported_node", null, `the source could not be read as generated helper syntax: ${err?.message ?? err}`);
|
|
1092
|
+
}
|
|
1093
|
+
return { ok: reasons.length === 0, reasons };
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
// ---- The repair budget (safeguard-slide-editing 6.2)
|
|
1098
|
+
//
|
|
1099
|
+
// What an explicit `repair` operation is allowed to change, and by how much.
|
|
1100
|
+
// CONSERVATIVE STARTING LIMITS — not experimentally validated quality claims.
|
|
1101
|
+
// They exist so a bounded repair cannot quietly turn into a redesign; widening
|
|
1102
|
+
// one is an explicit amendment on the operation record (`target.repairPolicy`),
|
|
1103
|
+
// never something targeted-edit escalates into by itself.
|
|
1104
|
+
//
|
|
1105
|
+
// These are a different kind of number from the verdict module's measurement
|
|
1106
|
+
// tolerances (`RENDER_GEOMETRY_TOLERANCE_PX` and friends, src/lib/preservation-
|
|
1107
|
+
// verdict.mjs): those say how much noise two renders of the SAME slide may
|
|
1108
|
+
// show, these say how far repair may move a slide that really did change.
|
|
1109
|
+
export const REPAIR_BUDGET = Object.freeze({
|
|
1110
|
+
/** Mutating passes. A verification render after the last one is not a pass. */
|
|
1111
|
+
maxPasses: 3,
|
|
1112
|
+
/** Slot px an element may travel, CUMULATIVELY over the whole operation. */
|
|
1113
|
+
maxTranslationPx: 8,
|
|
1114
|
+
/** Fraction of the original width/height an element may grow or shrink by. */
|
|
1115
|
+
maxSizeChangeRatio: 0.1,
|
|
1116
|
+
/** Font fitting is off unless the operation explicitly permits it. */
|
|
1117
|
+
allowFontFitting: false,
|
|
1118
|
+
/** With font fitting on: the most a size may drop, cumulatively. */
|
|
1119
|
+
maxFontShrinkPx: 1,
|
|
1120
|
+
/** …and never below the design's own minimum. */
|
|
1121
|
+
minFontPx: 10,
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
/** How far past the slot a measured rect may sit before it counts as escaped. */
|
|
1125
|
+
export const REPAIR_BOUNDS_SLACK_PX = 1;
|
|
1126
|
+
|
|
1127
|
+
const POLICY_NUMBERS = ["maxPasses", "maxTranslationPx", "maxSizeChangeRatio", "maxFontShrinkPx", "minFontPx"];
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* The budget for one operation target: the defaults with that target's
|
|
1131
|
+
* `repairPolicy` overrides applied. Returns `{ policy, errors }` — an override
|
|
1132
|
+
* this build cannot read is an error, never a silently ignored key, because a
|
|
1133
|
+
* policy nobody applied would read as a policy that passed.
|
|
1134
|
+
*/
|
|
1135
|
+
export function repairPolicyFor(target = null) {
|
|
1136
|
+
const errors = [];
|
|
1137
|
+
const override = target?.repairPolicy ?? null;
|
|
1138
|
+
const policy = { ...REPAIR_BUDGET };
|
|
1139
|
+
if (override != null && (typeof override !== "object" || Array.isArray(override))) {
|
|
1140
|
+
return { policy, errors: [{ code: "invalid_policy", message: "repairPolicy must be an object" }] };
|
|
1141
|
+
}
|
|
1142
|
+
for (const [key, value] of Object.entries(override ?? {})) {
|
|
1143
|
+
if (!(key in REPAIR_BUDGET)) {
|
|
1144
|
+
errors.push({ code: "unknown_policy_key", message: `repairPolicy.${key} is not part of the repair budget (${Object.keys(REPAIR_BUDGET).join(", ")})` });
|
|
1145
|
+
continue;
|
|
1146
|
+
}
|
|
1147
|
+
if (key === "allowFontFitting") {
|
|
1148
|
+
if (typeof value !== "boolean") errors.push({ code: "invalid_policy", message: "repairPolicy.allowFontFitting must be a boolean" });
|
|
1149
|
+
else policy[key] = value;
|
|
1150
|
+
continue;
|
|
1151
|
+
}
|
|
1152
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
1153
|
+
errors.push({ code: "invalid_policy", message: `repairPolicy.${key} must be a non-negative number` });
|
|
1154
|
+
continue;
|
|
1155
|
+
}
|
|
1156
|
+
if (key === "maxPasses" && (!Number.isInteger(value) || value < 1)) {
|
|
1157
|
+
errors.push({ code: "invalid_policy", message: "repairPolicy.maxPasses must be a whole number of at least 1" });
|
|
1158
|
+
continue;
|
|
1159
|
+
}
|
|
1160
|
+
policy[key] = value;
|
|
1161
|
+
}
|
|
1162
|
+
for (const key of POLICY_NUMBERS) if (!Number.isFinite(policy[key])) errors.push({ code: "invalid_policy", message: `repairPolicy.${key} is not a number` });
|
|
1163
|
+
return { policy, errors };
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/** The slot the slide's own container declares, or null when it has none. */
|
|
1167
|
+
export function readSlot(src) {
|
|
1168
|
+
for (const line of String(src ?? "").split("\n")) {
|
|
1169
|
+
const m = SLOT_OPEN_RE.exec(line);
|
|
1170
|
+
if (m) return { w: Number(m[1]), h: Number(m[2]) };
|
|
1171
|
+
}
|
|
1172
|
+
return null;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// Every helper element and connector of one file, by identity — the shape the
|
|
1176
|
+
// before/after comparison needs. Attribute values are raw strings; the caller
|
|
1177
|
+
// coerces the five it cares about.
|
|
1178
|
+
function inventory(src) {
|
|
1179
|
+
const elements = new Map();
|
|
1180
|
+
const connectors = [];
|
|
1181
|
+
String(src ?? "").split("\n").forEach((line, index) => {
|
|
1182
|
+
const m = ELEMENT_RE.exec(line);
|
|
1183
|
+
if (m) {
|
|
1184
|
+
const { attrs } = scanAttrs(m[3]);
|
|
1185
|
+
const id = attrs.get("id");
|
|
1186
|
+
if (id?.kind !== "string") return;
|
|
1187
|
+
const props = {};
|
|
1188
|
+
for (const [name, value] of attrs) if (name !== "id") props[name] = value.kind === "flag" ? true : value.value;
|
|
1189
|
+
elements.set(id.value, { tag: m[2], line: index, props, text: (m[4] ?? "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim() });
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
const c = parseConnectorLine(line, index);
|
|
1193
|
+
if (c) connectors.push(c);
|
|
1194
|
+
});
|
|
1195
|
+
return { elements, connectors };
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
const num = (v) => (v == null ? null : Number(v));
|
|
1199
|
+
// What repair itself writes back, so a difference here is the engine's own work
|
|
1200
|
+
// rather than somebody else's edit. Everything else is compared verbatim.
|
|
1201
|
+
const REPAIRED_PROPS = new Set(["x", "y", "h", "px"]);
|
|
1202
|
+
const ROUTED_ATTRS = ["prst", "adj", "rot", "fromSite", "toSite"];
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* The structured diff between the source the operation was prepared against and
|
|
1206
|
+
* a candidate — measured from the ORIGINAL every time. Three passes of "only
|
|
1207
|
+
* 7px" is 21px of drift when each pass is measured against the one before it,
|
|
1208
|
+
* which is exactly the escalation the budget exists to stop.
|
|
1209
|
+
*/
|
|
1210
|
+
export function repairDelta(originalSrc, candidateSrc) {
|
|
1211
|
+
const before = inventory(originalSrc);
|
|
1212
|
+
const after = inventory(candidateSrc);
|
|
1213
|
+
const elements = [];
|
|
1214
|
+
const added = [...after.elements.keys()].filter((id) => !before.elements.has(id));
|
|
1215
|
+
const removed = [...before.elements.keys()].filter((id) => !after.elements.has(id));
|
|
1216
|
+
for (const [id, a] of before.elements) {
|
|
1217
|
+
const b = after.elements.get(id);
|
|
1218
|
+
if (!b) continue;
|
|
1219
|
+
const props = {};
|
|
1220
|
+
for (const key of new Set([...Object.keys(a.props), ...Object.keys(b.props)])) {
|
|
1221
|
+
if (a.props[key] !== b.props[key]) props[key] = { from: a.props[key] ?? null, to: b.props[key] ?? null };
|
|
1222
|
+
}
|
|
1223
|
+
const dw = (num(b.props.w) ?? 0) - (num(a.props.w) ?? 0);
|
|
1224
|
+
const dh = (num(b.props.h) ?? 0) - (num(a.props.h) ?? 0);
|
|
1225
|
+
elements.push({
|
|
1226
|
+
id,
|
|
1227
|
+
dx: (num(b.props.x) ?? 0) - (num(a.props.x) ?? 0),
|
|
1228
|
+
dy: (num(b.props.y) ?? 0) - (num(a.props.y) ?? 0),
|
|
1229
|
+
dw,
|
|
1230
|
+
dh,
|
|
1231
|
+
w0: num(a.props.w) ?? 0,
|
|
1232
|
+
h0: num(a.props.h) ?? 0,
|
|
1233
|
+
dFontPx: (num(b.props.px) ?? num(a.props.px) ?? 0) - (num(a.props.px) ?? 0),
|
|
1234
|
+
fontPx: num(b.props.px),
|
|
1235
|
+
textChanged: a.text !== b.text,
|
|
1236
|
+
assetChanged: (a.props.src ?? null) !== (b.props.src ?? null),
|
|
1237
|
+
// A property repair never writes: the author's fill, role, tone, align…
|
|
1238
|
+
otherProps: Object.fromEntries(Object.entries(props).filter(([k]) => !REPAIRED_PROPS.has(k) && k !== "src")),
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
const endpoints = (inv) => inv.connectors.map((c) => `${c.from}→${c.to}`);
|
|
1242
|
+
const routed = [];
|
|
1243
|
+
if (before.connectors.length === after.connectors.length) {
|
|
1244
|
+
before.connectors.forEach((c, i) => {
|
|
1245
|
+
const d = after.connectors[i];
|
|
1246
|
+
const changed = ROUTED_ATTRS.filter((k) => String(c[k] ?? "") !== String(d[k] ?? ""));
|
|
1247
|
+
if (changed.length) routed.push({ from: d.from, to: d.to, changed });
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
return {
|
|
1251
|
+
elements,
|
|
1252
|
+
added,
|
|
1253
|
+
removed,
|
|
1254
|
+
routed,
|
|
1255
|
+
endpointsChanged: endpoints(before).join("|") !== endpoints(after).join("|"),
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* The budget read over one slide's delta. Returns one reason per violation —
|
|
1261
|
+
* a rejected candidate names every limit it broke, not just the first.
|
|
1262
|
+
*/
|
|
1263
|
+
export function budgetViolations(delta, policy = REPAIR_BUDGET, { slide = null } = {}) {
|
|
1264
|
+
const out = [];
|
|
1265
|
+
const add = (code, elementId, message) => out.push({ code, slide, elementId, message });
|
|
1266
|
+
for (const id of delta.added) add("element_added", id, `repair may not add elements; ${id} is new`);
|
|
1267
|
+
for (const id of delta.removed) add("element_removed", id, `repair may not delete elements; ${id} is gone`);
|
|
1268
|
+
if (delta.endpointsChanged) add("endpoint_changed", null, "repair may re-route a connector but never re-point it: the from/to identities changed");
|
|
1269
|
+
for (const r of delta.routed) {
|
|
1270
|
+
if (r.changed.includes("prst")) add("connector_preset_changed", null, `repair re-solves a connector within its own preset; ${r.from} → ${r.to} changed preset`);
|
|
1271
|
+
}
|
|
1272
|
+
for (const e of delta.elements) {
|
|
1273
|
+
if (e.textChanged) add("text_changed", e.id, `repair may not change text; ${e.id} reads differently`);
|
|
1274
|
+
if (e.assetChanged) add("asset_changed", e.id, `repair may not change assets; ${e.id} points at another file`);
|
|
1275
|
+
const travelled = Math.hypot(e.dx, e.dy);
|
|
1276
|
+
if (travelled > policy.maxTranslationPx + 1e-9) {
|
|
1277
|
+
add("translation_exceeded", e.id, `${e.id} moved ${travelled.toFixed(1)}px from its original position; the budget is ${policy.maxTranslationPx}px over the whole operation`);
|
|
1278
|
+
}
|
|
1279
|
+
for (const [axis, d, base] of [["width", e.dw, e.w0], ["height", e.dh, e.h0]]) {
|
|
1280
|
+
const allowed = Math.abs(base) * policy.maxSizeChangeRatio;
|
|
1281
|
+
if (Math.abs(d) > allowed + 1e-9) {
|
|
1282
|
+
add("size_exceeded", e.id, `${e.id} changed ${axis} by ${Math.abs(d).toFixed(1)}px (${base ? Math.round((Math.abs(d) / base) * 100) : "∞"}%); the budget is ${Math.round(policy.maxSizeChangeRatio * 100)}%`);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
if (e.dFontPx !== 0) {
|
|
1286
|
+
if (!policy.allowFontFitting) add("font_changed", e.id, `${e.id}'s font size changed by ${e.dFontPx}px; this operation does not permit font fitting`);
|
|
1287
|
+
else if (e.dFontPx > 0) add("font_changed", e.id, `${e.id}'s font grew by ${e.dFontPx}px; font fitting may only shrink`);
|
|
1288
|
+
else if (-e.dFontPx > policy.maxFontShrinkPx + 1e-9) add("font_changed", e.id, `${e.id}'s font shrank ${-e.dFontPx}px; the budget is ${policy.maxFontShrinkPx}px`);
|
|
1289
|
+
if (e.fontPx != null && e.fontPx < policy.minFontPx) add("font_below_minimum", e.id, `${e.id} would render at ${e.fontPx}px, below the design's minimum of ${policy.minFontPx}px`);
|
|
1290
|
+
}
|
|
1291
|
+
for (const [key, change] of Object.entries(e.otherProps)) {
|
|
1292
|
+
add("undeclared_change", e.id, `${e.id}.${key} changed (${JSON.stringify(change.from)} → ${JSON.stringify(change.to)}); repair writes geometry and connector routing, nothing else`);
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
return out;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/** Elements the final render leaves outside their own slot. */
|
|
1299
|
+
export function boundsDefects(measured, slot, ids = null) {
|
|
1300
|
+
const out = [];
|
|
1301
|
+
for (const [id, m] of measured) {
|
|
1302
|
+
if (ids && !ids.has(id)) continue;
|
|
1303
|
+
const past = [];
|
|
1304
|
+
if (m.x < -REPAIR_BOUNDS_SLACK_PX) past.push("left");
|
|
1305
|
+
if (m.y < -REPAIR_BOUNDS_SLACK_PX) past.push("top");
|
|
1306
|
+
if (m.x + m.w > slot.w + REPAIR_BOUNDS_SLACK_PX) past.push("right");
|
|
1307
|
+
if (m.y + m.h > slot.h + REPAIR_BOUNDS_SLACK_PX) past.push("bottom");
|
|
1308
|
+
if (past.length) out.push({ code: "bounds_escaped", elementId: id, message: `${id} sits past the slot's ${past.join(" and ")} edge after the final pass` });
|
|
1309
|
+
}
|
|
1310
|
+
return out;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/**
|
|
1314
|
+
* Run the bounded repair passes over a set of slides, in memory.
|
|
1315
|
+
*
|
|
1316
|
+
* `slides` is `[{ key, path, original, policy?, slot? }]`; `render(sources)`
|
|
1317
|
+
* takes `Map<path, src>` and returns `Map<path, measuredMap>` — the caller
|
|
1318
|
+
* writes those sources into its candidate workspace and captures them. Nothing
|
|
1319
|
+
* here touches a disk, so the candidate can be discarded by simply not using
|
|
1320
|
+
* the result.
|
|
1321
|
+
*
|
|
1322
|
+
* The loop always ends on a render that observed the LAST mutation: either a
|
|
1323
|
+
* pass that measured the previous pass's work and found nothing left to do, or
|
|
1324
|
+
* an extra verification render once the pass budget is spent. A pass that
|
|
1325
|
+
* mutates and is never rendered is the bug this ordering exists to prevent.
|
|
1326
|
+
*/
|
|
1327
|
+
export async function repairPasses({ slides, render, cancelled = () => false }) {
|
|
1328
|
+
if (!slides?.length) return { ok: false, passes: 0, verified: false, slides: [], reasons: [{ code: "no_targets", message: "bounded repair was given no slides to repair" }] };
|
|
1329
|
+
const state = slides.map((s) => ({
|
|
1330
|
+
...s,
|
|
1331
|
+
policy: s.policy ?? REPAIR_BUDGET,
|
|
1332
|
+
slot: s.slot ?? readSlot(s.original),
|
|
1333
|
+
src: s.original,
|
|
1334
|
+
changes: [],
|
|
1335
|
+
routeChanges: [],
|
|
1336
|
+
unresolved: [],
|
|
1337
|
+
}));
|
|
1338
|
+
// Settling measures against the slot the slide's own container declares; a
|
|
1339
|
+
// source without one has nothing to settle into.
|
|
1340
|
+
const slotless = state.find((s) => !s.slot);
|
|
1341
|
+
if (slotless) return { ok: false, passes: 0, verified: false, slides: [], reasons: [{ code: "no_slot", message: `${slotless.path} declares no content slot (its relative container with width and height); bounded repair settles only transcribed source`, slide: slotless.key }] };
|
|
1342
|
+
const maxPasses = Math.min(...state.map((s) => s.policy.maxPasses));
|
|
1343
|
+
const sources = () => new Map(state.map((s) => [s.path, s.src]));
|
|
1344
|
+
const fail = (code, message, extra = {}) => ({ ok: false, passes, verified, slides: state, reasons: [{ code, message, ...extra }] });
|
|
1345
|
+
let passes = 0;
|
|
1346
|
+
let verified = false;
|
|
1347
|
+
const capture = async () => {
|
|
1348
|
+
try {
|
|
1349
|
+
return { measured: await render(sources()) };
|
|
1350
|
+
} catch (err) {
|
|
1351
|
+
return { error: err };
|
|
1352
|
+
}
|
|
1353
|
+
};
|
|
1354
|
+
|
|
1355
|
+
while (passes < maxPasses) {
|
|
1356
|
+
if (cancelled()) return fail("cancelled", "the repair was cancelled; nothing was published");
|
|
1357
|
+
const { measured, error } = await capture();
|
|
1358
|
+
if (error) return fail("capture_failed", `the candidate could not be rendered: ${error?.message ?? error}`);
|
|
1359
|
+
passes++;
|
|
1360
|
+
let mutated = 0;
|
|
1361
|
+
for (const s of state) {
|
|
1362
|
+
const m = measured.get(s.path) ?? new Map();
|
|
1363
|
+
const settled = settleJsx(s.src, m, { slot: s.slot });
|
|
1364
|
+
if (settled.changes.length) {
|
|
1365
|
+
s.src = settled.src;
|
|
1366
|
+
s.changes.push(...settled.changes);
|
|
1367
|
+
mutated += settled.changes.length;
|
|
1368
|
+
continue;
|
|
1369
|
+
}
|
|
1370
|
+
// Geometry stands: route the connectors against the same measurement.
|
|
1371
|
+
const routed = routeConnectors(s.src, m);
|
|
1372
|
+
s.unresolved = routed.unresolved;
|
|
1373
|
+
if (routed.changes.length) {
|
|
1374
|
+
s.src = routed.src;
|
|
1375
|
+
s.routeChanges.push(...routed.changes);
|
|
1376
|
+
mutated += routed.changes.length;
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
// The budget is read from the ORIGINAL after every pass, so drift is caught
|
|
1380
|
+
// on the pass that causes it rather than at the end.
|
|
1381
|
+
const violations = state.flatMap((s) => budgetViolations(repairDelta(s.original, s.src), s.policy, { slide: s.key }));
|
|
1382
|
+
if (violations.length) return { ok: false, passes, verified, slides: state, reasons: violations };
|
|
1383
|
+
if (!mutated) {
|
|
1384
|
+
// This render measured the last mutation and asked for nothing more.
|
|
1385
|
+
verified = true;
|
|
1386
|
+
const escaped = state.flatMap((s) => boundsDefects(measured.get(s.path) ?? new Map(), s.slot, new Set(inventory(s.src).elements.keys())).map((d) => ({ ...d, slide: s.key })));
|
|
1387
|
+
if (escaped.length) return { ok: false, passes, verified, slides: state, reasons: escaped };
|
|
1388
|
+
return { ok: true, passes, verified, slides: state, reasons: [] };
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
// The pass budget is spent and the last pass mutated: verify that mutation.
|
|
1393
|
+
if (cancelled()) return fail("cancelled", "the repair was cancelled; nothing was published");
|
|
1394
|
+
const { measured, error } = await capture();
|
|
1395
|
+
if (error) return fail("capture_failed", `the repaired candidate could not be verified: ${error?.message ?? error}`);
|
|
1396
|
+
verified = true;
|
|
1397
|
+
const defects = [];
|
|
1398
|
+
for (const s of state) {
|
|
1399
|
+
const m = measured.get(s.path) ?? new Map();
|
|
1400
|
+
const { changes } = settleJsx(s.src, m, { slot: s.slot });
|
|
1401
|
+
for (const c of changes) defects.push({ code: "not_converged", slide: s.key, message: `${maxPasses} passes were not enough: ${c}` });
|
|
1402
|
+
defects.push(...boundsDefects(m, s.slot, new Set(inventory(s.src).elements.keys())).map((d) => ({ ...d, slide: s.key })));
|
|
1403
|
+
}
|
|
1404
|
+
// An unresolved crossing is REPORTED, never fatal: the router has already
|
|
1405
|
+
// established that the only thing left would be moving a node, which is
|
|
1406
|
+
// outside the repair budget by design.
|
|
1407
|
+
if (defects.length) return { ok: false, passes, verified, slides: state, reasons: defects };
|
|
1408
|
+
return { ok: true, passes, verified, slides: state, reasons: [] };
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
|
|
1412
|
+
// ---- Lines re-solved on the final geometry.
|
|
1413
|
+
//
|
|
1414
|
+
// Every line was fitted to the concept when the slide was written
|
|
1415
|
+
// (line-native.mjs). The render places the nodes where they really are, so a
|
|
1416
|
+
// glued connector is solved once more against the MEASURED rects: its preset
|
|
1417
|
+
// and its glue stay, its adjusts, turn and sites follow the nodes, scored by
|
|
1418
|
+
// its distance from the concept's path plus a penalty per crossing the concept
|
|
1419
|
+
// does not have. Nodes never move.
|
|
1420
|
+
export function fitConnectors(src, spec, measured) {
|
|
1421
|
+
// `fitted`: one entry per glued line — its preset, glue and distance from
|
|
1422
|
+
// the concept (the line census and the draft notes read it).
|
|
1423
|
+
const result = { src, changes: [], unresolved: [], fitted: [] };
|
|
1424
|
+
if (!spec) return result;
|
|
1425
|
+
const lines = String(src).split("\n");
|
|
1426
|
+
const byId = new Map((spec.elements ?? []).map((e) => [e.id, e]));
|
|
1427
|
+
const traces = new Map(readLines(structuredClone(spec)).lines.map((l) => [l.id, (l.path?.points ?? []).filter((p) => Number.isFinite(p?.x) && Number.isFinite(p?.y))]));
|
|
1428
|
+
const connectors = lines.map(parseConnectorLine).filter((c) => c?.prst && (c.fromEnd.node || c.toEnd.node));
|
|
1429
|
+
const nodes = measuredNodes(connectors, measured, byId, src);
|
|
1430
|
+
const obstacles = obstaclesOf(measured);
|
|
1431
|
+
for (const c of connectors) {
|
|
1432
|
+
const raw = traces.get(c.id);
|
|
1433
|
+
const round = (id) => { const e = byId.get(id); return e?.kind === "disc" || ROUND_SHAPES.has(e?.shape); };
|
|
1434
|
+
const box = (e) => (e.node ? nodes.get(e.node)?.box ?? null : null);
|
|
1435
|
+
const trace = raw?.length >= 2 ? sanitizeTrace(raw, box(c.fromEnd), box(c.toEnd), { fromRound: round(c.fromEnd.node), toRound: round(c.toEnd.node) }) : null;
|
|
1436
|
+
const n = resolveGlued({ prst: c.prst, from: c.fromEnd, to: c.toEnd }, nodes, { trace, obstacles });
|
|
1437
|
+
if (!n) continue;
|
|
1438
|
+
const tolerance = trace ? Math.max(NATIVE_TOL.gluedPx, NATIVE_TOL.gluedFrac * traceLength(trace)) : null;
|
|
1439
|
+
result.fitted.push({ id: c.id, prst: n.prst, glued: Boolean(n.from.node && n.to.node), distance: trace ? n.devPx : null, tolerance });
|
|
1440
|
+
const out = rewriteConnector(lines[c.line], n);
|
|
1441
|
+
if (out === lines[c.line]) continue;
|
|
1442
|
+
lines[c.line] = out;
|
|
1443
|
+
const beyond = trace && n.devPx > tolerance ? ` — ${Math.round(n.devPx)}px from the concept's line, beyond tolerance: the closest this ${n.prst} glued at its nodes' sites can draw it` : "";
|
|
1444
|
+
result.changes.push(`${c.id ?? `${c.from} → ${c.to}`}: ${n.prst} re-solved on the measured nodes (sites ${n.from.site ?? "point"}→${n.to.site ?? "point"}${n.rot ? `, rot ${n.rot}` : ""})${beyond}`);
|
|
1445
|
+
}
|
|
1446
|
+
result.src = lines.join("\n");
|
|
1447
|
+
return result;
|
|
1448
|
+
}
|