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.
Files changed (187) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +592 -16
  4. package/LICENSE +4 -0
  5. package/README.md +203 -8
  6. package/THIRD-PARTY-NOTICES.md +201 -0
  7. package/bin/slidemind.mjs +50 -2
  8. package/package.json +5 -3
  9. package/skills/create-slides/SKILL.md +484 -886
  10. package/skills/search/SKILL.md +46 -23
  11. package/src/bridge/build-and-push.mjs +548 -73
  12. package/src/bridge/bundle.mjs +100 -31
  13. package/src/bridge/capture.mjs +14 -17
  14. package/src/bridge/client/brands.mjs +157 -18
  15. package/src/bridge/client/engine-routing.mjs +48 -13
  16. package/src/bridge/client/engine-search.mjs +24 -1
  17. package/src/bridge/client/render.mjs +27 -1
  18. package/src/bridge/client/transport.mjs +499 -23
  19. package/src/bridge/client.mjs +10 -0
  20. package/src/bridge/config.mjs +67 -6
  21. package/src/bridge/engine-download.mjs +0 -10
  22. package/src/bridge/graph-auth.mjs +55 -3
  23. package/src/bridge/image-intent-envelope.mjs +239 -0
  24. package/src/bridge/package-preview.mjs +67 -0
  25. package/src/bridge/sharepoint.mjs +914 -0
  26. package/src/builders/asset-fetch.mjs +203 -0
  27. package/src/builders/block-dividers.mjs +83 -0
  28. package/src/builders/build.mjs +171 -87
  29. package/src/builders/card-height.mjs +294 -0
  30. package/src/builders/chart-hole.mjs +183 -0
  31. package/src/builders/chart-jsx.mjs +410 -0
  32. package/src/builders/chart-labels.mjs +326 -0
  33. package/src/builders/concept-geometry.mjs +370 -0
  34. package/src/builders/concept-intent.mjs +200 -0
  35. package/src/builders/concept-layout.mjs +79 -0
  36. package/src/builders/concept-lines.mjs +509 -0
  37. package/src/builders/concept-lists.mjs +270 -0
  38. package/src/builders/concept-marks.mjs +84 -0
  39. package/src/builders/concept-relationships.mjs +12 -0
  40. package/src/builders/concept-spec.mjs +645 -0
  41. package/src/builders/concept-text.mjs +140 -0
  42. package/src/builders/concept-waves.mjs +57 -0
  43. package/src/builders/copy-fit.mjs +139 -0
  44. package/src/builders/design-tokens.mjs +347 -0
  45. package/src/builders/draft-check.mjs +552 -340
  46. package/src/builders/draft-lines.mjs +369 -0
  47. package/src/builders/edit-route.mjs +29 -0
  48. package/src/builders/figure-sketch.mjs +375 -0
  49. package/src/builders/fold-sets.mjs +639 -0
  50. package/src/builders/gate-server.mjs +9 -4
  51. package/src/builders/gate-session.js +273 -12
  52. package/src/builders/gate-shell.js +1494 -0
  53. package/src/builders/image-intent-check.mjs +308 -0
  54. package/src/builders/image-use-reconcile.mjs +107 -0
  55. package/src/builders/kit-helpers.mjs +121 -0
  56. package/src/builders/layout-names.mjs +32 -0
  57. package/src/builders/legacy-reference-uses.mjs +179 -0
  58. package/src/builders/line-fit.mjs +224 -0
  59. package/src/builders/line-native.mjs +895 -0
  60. package/src/builders/line-relations.mjs +230 -0
  61. package/src/builders/lint-fix.mjs +117 -9
  62. package/src/builders/lint.mjs +25 -20
  63. package/src/builders/list-jsx.mjs +88 -0
  64. package/src/builders/placement.mjs +133 -0
  65. package/src/builders/plan-checks.mjs +135 -0
  66. package/src/builders/plan-comments.mjs +19 -0
  67. package/src/builders/plan-handlers.mjs +95 -0
  68. package/src/builders/plan-markdown-edit.mjs +3 -3
  69. package/src/builders/plan-overlay.js +1530 -1608
  70. package/src/builders/plan.mjs +689 -186
  71. package/src/builders/preset-definitions.json +1 -0
  72. package/src/builders/preset-eval.mjs +244 -0
  73. package/src/builders/preview.html +51 -120
  74. package/src/builders/red-thread-backbone.mjs +135 -36
  75. package/src/builders/repair.mjs +205 -0
  76. package/src/builders/repeat-groups.mjs +540 -0
  77. package/src/builders/resolve-skill.mjs +5 -4
  78. package/src/builders/review-overlay.js +296 -293
  79. package/src/builders/review.mjs +305 -84
  80. package/src/builders/rule-shift.mjs +96 -0
  81. package/src/builders/seat.mjs +354 -0
  82. package/src/builders/settle-driver.mjs +236 -0
  83. package/src/builders/settle.mjs +1448 -0
  84. package/src/builders/slide-prep.mjs +67 -0
  85. package/src/builders/spec-assets.mjs +236 -0
  86. package/src/builders/spec-match.mjs +56 -0
  87. package/src/builders/stack.mjs +413 -0
  88. package/src/builders/storyboard-markdown.mjs +70 -27
  89. package/src/builders/storyboard-schema.mjs +54 -114
  90. package/src/builders/text-blocks.mjs +212 -0
  91. package/src/builders/text-card-fit.mjs +552 -0
  92. package/src/builders/text-stack.mjs +276 -0
  93. package/src/builders/transcribe.mjs +1069 -0
  94. package/src/cli/assets.mjs +164 -0
  95. package/src/cli/common.mjs +173 -12
  96. package/src/cli/concept-spec-support.mjs +76 -0
  97. package/src/cli/concepts.mjs +1002 -103
  98. package/src/cli/deck.mjs +401 -65
  99. package/src/cli/diagnose.mjs +4 -2
  100. package/src/cli/engine-helpers.mjs +17 -10
  101. package/src/cli/engine-results.mjs +76 -3
  102. package/src/cli/help.mjs +232 -28
  103. package/src/cli/install.mjs +9 -0
  104. package/src/cli/maintenance.mjs +104 -0
  105. package/src/cli/read.mjs +2 -1
  106. package/src/cli/ready.mjs +6 -3
  107. package/src/cli/record.mjs +522 -0
  108. package/src/cli/remote.mjs +818 -0
  109. package/src/cli/revision.mjs +686 -0
  110. package/src/cli/search.mjs +30 -11
  111. package/src/cli/session.mjs +56 -13
  112. package/src/cli/setup.mjs +46 -2
  113. package/src/cli/status.mjs +142 -33
  114. package/src/cli/tasks.mjs +246 -0
  115. package/src/cli/transcribe.mjs +854 -0
  116. package/src/cli/workspace-ui.mjs +260 -0
  117. package/src/cli/workspace.mjs +212 -34
  118. package/src/install-channel.mjs +64 -7
  119. package/src/lib/adopt.mjs +346 -25
  120. package/src/lib/concept-spec-binding.mjs +96 -0
  121. package/src/lib/conversions.mjs +70 -6
  122. package/src/lib/convert-folded.mjs +107 -0
  123. package/src/lib/deck-paths.mjs +418 -0
  124. package/src/lib/durable-ids.mjs +47 -0
  125. package/src/lib/edit-operations.mjs +316 -0
  126. package/src/lib/edit-state.mjs +1244 -0
  127. package/src/lib/edit-transitions.mjs +120 -0
  128. package/src/lib/extensions.mjs +501 -0
  129. package/src/lib/image-assets.mjs +465 -0
  130. package/src/lib/image-derivatives.mjs +234 -0
  131. package/src/lib/image-intent-fingerprint.mjs +70 -0
  132. package/src/lib/image-intent-tags.mjs +176 -0
  133. package/src/lib/image-intent.mjs +802 -0
  134. package/src/lib/image-use-overrides.mjs +279 -0
  135. package/src/lib/inline-components.mjs +613 -0
  136. package/src/lib/lint-converted.mjs +68 -4
  137. package/src/lib/merge-json.mjs +163 -0
  138. package/src/lib/overwrite-guard.mjs +3 -3
  139. package/src/lib/presentation-context.mjs +531 -0
  140. package/src/lib/preservation-tags.mjs +157 -0
  141. package/src/lib/preservation-verdict.mjs +1342 -0
  142. package/src/lib/publish.mjs +1557 -0
  143. package/src/lib/rebase.mjs +945 -0
  144. package/src/lib/reconcile-ack.mjs +4 -6
  145. package/src/lib/reconcile.mjs +62 -24
  146. package/src/lib/record-client.mjs +175 -0
  147. package/src/lib/record-state.mjs +151 -0
  148. package/src/lib/remote.mjs +661 -0
  149. package/src/lib/revision.mjs +1210 -0
  150. package/src/lib/slide-guids.mjs +4 -6
  151. package/src/lib/slide-preservation.mjs +423 -0
  152. package/src/lib/slide-selection.mjs +9 -0
  153. package/src/lib/workspace-transaction.mjs +117 -0
  154. package/src/pptx/content-digest.mjs +334 -0
  155. package/src/pptx/deck-assemble.mjs +1278 -0
  156. package/src/pptx/fill/engine.mjs +2 -3
  157. package/src/pptx/fill/freeform.mjs +6 -3
  158. package/src/pptx/ooxml.mjs +20 -0
  159. package/src/pptx/package-evidence.mjs +383 -0
  160. package/src/pptx/tags.mjs +163 -11
  161. package/src/update-check.mjs +22 -4
  162. package/src/workspace/adapters.mjs +222 -0
  163. package/src/workspace/auth.mjs +256 -0
  164. package/src/workspace/autostart.mjs +204 -0
  165. package/src/workspace/daemon.mjs +360 -0
  166. package/src/workspace/discovery.mjs +180 -0
  167. package/src/workspace/edit.mjs +167 -0
  168. package/src/workspace/events.mjs +52 -0
  169. package/src/workspace/gate-client.mjs +290 -0
  170. package/src/workspace/handback.mjs +73 -0
  171. package/src/workspace/lifecycle.mjs +635 -0
  172. package/src/workspace/operations.mjs +200 -0
  173. package/src/workspace/pick-folder.mjs +152 -0
  174. package/src/workspace/registry.mjs +359 -0
  175. package/src/workspace/remote-links.mjs +397 -0
  176. package/src/workspace/remote-status.mjs +359 -0
  177. package/src/workspace/routes.mjs +1331 -0
  178. package/src/workspace/server.mjs +159 -0
  179. package/src/workspace/sync.mjs +1119 -0
  180. package/src/workspace/task-actions.mjs +215 -0
  181. package/src/workspace/tasks.mjs +507 -0
  182. package/src/workspace/ui/app.js +1886 -0
  183. package/src/workspace/ui/index.html +141 -0
  184. package/src/workspace/ui/plan-bridge.js +120 -0
  185. package/src/workspace/ui/sharepoint.svg +59 -0
  186. package/src/workspace/ui/styles.css +423 -0
  187. 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
+ }