slidemind 0.3.20 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +591 -15
  4. package/README.md +202 -7
  5. package/bin/slidemind.mjs +50 -2
  6. package/package.json +2 -2
  7. package/skills/create-slides/SKILL.md +484 -886
  8. package/skills/search/SKILL.md +46 -23
  9. package/src/bridge/build-and-push.mjs +548 -73
  10. package/src/bridge/bundle.mjs +100 -31
  11. package/src/bridge/capture.mjs +14 -17
  12. package/src/bridge/client/brands.mjs +157 -18
  13. package/src/bridge/client/engine-routing.mjs +48 -13
  14. package/src/bridge/client/engine-search.mjs +24 -1
  15. package/src/bridge/client/render.mjs +27 -1
  16. package/src/bridge/client/transport.mjs +499 -23
  17. package/src/bridge/client.mjs +10 -0
  18. package/src/bridge/config.mjs +67 -6
  19. package/src/bridge/engine-download.mjs +0 -10
  20. package/src/bridge/graph-auth.mjs +55 -3
  21. package/src/bridge/image-intent-envelope.mjs +239 -0
  22. package/src/bridge/package-preview.mjs +67 -0
  23. package/src/bridge/sharepoint.mjs +914 -0
  24. package/src/builders/asset-fetch.mjs +203 -0
  25. package/src/builders/block-dividers.mjs +83 -0
  26. package/src/builders/build.mjs +171 -87
  27. package/src/builders/card-height.mjs +294 -0
  28. package/src/builders/chart-hole.mjs +183 -0
  29. package/src/builders/chart-jsx.mjs +410 -0
  30. package/src/builders/chart-labels.mjs +326 -0
  31. package/src/builders/concept-geometry.mjs +370 -0
  32. package/src/builders/concept-intent.mjs +200 -0
  33. package/src/builders/concept-layout.mjs +79 -0
  34. package/src/builders/concept-lines.mjs +509 -0
  35. package/src/builders/concept-lists.mjs +270 -0
  36. package/src/builders/concept-marks.mjs +84 -0
  37. package/src/builders/concept-relationships.mjs +12 -0
  38. package/src/builders/concept-spec.mjs +645 -0
  39. package/src/builders/concept-text.mjs +140 -0
  40. package/src/builders/concept-waves.mjs +57 -0
  41. package/src/builders/copy-fit.mjs +139 -0
  42. package/src/builders/design-tokens.mjs +347 -0
  43. package/src/builders/draft-check.mjs +552 -340
  44. package/src/builders/draft-lines.mjs +369 -0
  45. package/src/builders/edit-route.mjs +29 -0
  46. package/src/builders/figure-sketch.mjs +375 -0
  47. package/src/builders/fold-sets.mjs +639 -0
  48. package/src/builders/gate-server.mjs +9 -4
  49. package/src/builders/gate-session.js +273 -12
  50. package/src/builders/gate-shell.js +1494 -0
  51. package/src/builders/image-intent-check.mjs +308 -0
  52. package/src/builders/image-use-reconcile.mjs +107 -0
  53. package/src/builders/kit-helpers.mjs +121 -0
  54. package/src/builders/layout-names.mjs +32 -0
  55. package/src/builders/legacy-reference-uses.mjs +179 -0
  56. package/src/builders/line-fit.mjs +224 -0
  57. package/src/builders/line-native.mjs +895 -0
  58. package/src/builders/line-relations.mjs +230 -0
  59. package/src/builders/lint-fix.mjs +117 -9
  60. package/src/builders/lint.mjs +25 -20
  61. package/src/builders/list-jsx.mjs +88 -0
  62. package/src/builders/placement.mjs +133 -0
  63. package/src/builders/plan-checks.mjs +135 -0
  64. package/src/builders/plan-comments.mjs +19 -0
  65. package/src/builders/plan-handlers.mjs +95 -0
  66. package/src/builders/plan-markdown-edit.mjs +3 -3
  67. package/src/builders/plan-overlay.js +1530 -1608
  68. package/src/builders/plan.mjs +689 -186
  69. package/src/builders/preset-definitions.json +1 -0
  70. package/src/builders/preset-eval.mjs +244 -0
  71. package/src/builders/preview.html +51 -120
  72. package/src/builders/red-thread-backbone.mjs +135 -36
  73. package/src/builders/repair.mjs +205 -0
  74. package/src/builders/repeat-groups.mjs +540 -0
  75. package/src/builders/resolve-skill.mjs +5 -4
  76. package/src/builders/review-overlay.js +296 -293
  77. package/src/builders/review.mjs +305 -84
  78. package/src/builders/rule-shift.mjs +96 -0
  79. package/src/builders/seat.mjs +354 -0
  80. package/src/builders/settle-driver.mjs +236 -0
  81. package/src/builders/settle.mjs +1448 -0
  82. package/src/builders/slide-prep.mjs +67 -0
  83. package/src/builders/spec-assets.mjs +236 -0
  84. package/src/builders/spec-match.mjs +56 -0
  85. package/src/builders/stack.mjs +413 -0
  86. package/src/builders/storyboard-markdown.mjs +70 -27
  87. package/src/builders/storyboard-schema.mjs +54 -114
  88. package/src/builders/text-blocks.mjs +212 -0
  89. package/src/builders/text-card-fit.mjs +552 -0
  90. package/src/builders/text-stack.mjs +276 -0
  91. package/src/builders/transcribe.mjs +1069 -0
  92. package/src/cli/assets.mjs +164 -0
  93. package/src/cli/common.mjs +173 -12
  94. package/src/cli/concept-spec-support.mjs +76 -0
  95. package/src/cli/concepts.mjs +1002 -103
  96. package/src/cli/deck.mjs +401 -65
  97. package/src/cli/diagnose.mjs +4 -2
  98. package/src/cli/engine-helpers.mjs +17 -10
  99. package/src/cli/engine-results.mjs +76 -3
  100. package/src/cli/help.mjs +232 -28
  101. package/src/cli/install.mjs +9 -0
  102. package/src/cli/maintenance.mjs +104 -0
  103. package/src/cli/read.mjs +2 -1
  104. package/src/cli/ready.mjs +6 -3
  105. package/src/cli/record.mjs +522 -0
  106. package/src/cli/remote.mjs +818 -0
  107. package/src/cli/revision.mjs +686 -0
  108. package/src/cli/search.mjs +30 -11
  109. package/src/cli/session.mjs +56 -13
  110. package/src/cli/setup.mjs +46 -2
  111. package/src/cli/status.mjs +142 -33
  112. package/src/cli/tasks.mjs +246 -0
  113. package/src/cli/transcribe.mjs +854 -0
  114. package/src/cli/workspace-ui.mjs +260 -0
  115. package/src/cli/workspace.mjs +212 -34
  116. package/src/install-channel.mjs +64 -7
  117. package/src/lib/adopt.mjs +346 -25
  118. package/src/lib/concept-spec-binding.mjs +96 -0
  119. package/src/lib/conversions.mjs +70 -6
  120. package/src/lib/convert-folded.mjs +107 -0
  121. package/src/lib/deck-paths.mjs +418 -0
  122. package/src/lib/durable-ids.mjs +47 -0
  123. package/src/lib/edit-operations.mjs +316 -0
  124. package/src/lib/edit-state.mjs +1244 -0
  125. package/src/lib/edit-transitions.mjs +120 -0
  126. package/src/lib/extensions.mjs +501 -0
  127. package/src/lib/image-assets.mjs +465 -0
  128. package/src/lib/image-derivatives.mjs +234 -0
  129. package/src/lib/image-intent-fingerprint.mjs +70 -0
  130. package/src/lib/image-intent-tags.mjs +176 -0
  131. package/src/lib/image-intent.mjs +802 -0
  132. package/src/lib/image-use-overrides.mjs +279 -0
  133. package/src/lib/inline-components.mjs +613 -0
  134. package/src/lib/lint-converted.mjs +68 -4
  135. package/src/lib/merge-json.mjs +163 -0
  136. package/src/lib/overwrite-guard.mjs +3 -3
  137. package/src/lib/presentation-context.mjs +531 -0
  138. package/src/lib/preservation-tags.mjs +157 -0
  139. package/src/lib/preservation-verdict.mjs +1342 -0
  140. package/src/lib/publish.mjs +1557 -0
  141. package/src/lib/rebase.mjs +945 -0
  142. package/src/lib/reconcile-ack.mjs +4 -6
  143. package/src/lib/reconcile.mjs +62 -24
  144. package/src/lib/record-client.mjs +175 -0
  145. package/src/lib/record-state.mjs +151 -0
  146. package/src/lib/remote.mjs +661 -0
  147. package/src/lib/revision.mjs +1210 -0
  148. package/src/lib/slide-guids.mjs +4 -6
  149. package/src/lib/slide-preservation.mjs +423 -0
  150. package/src/lib/slide-selection.mjs +9 -0
  151. package/src/lib/workspace-transaction.mjs +117 -0
  152. package/src/pptx/content-digest.mjs +334 -0
  153. package/src/pptx/deck-assemble.mjs +1278 -0
  154. package/src/pptx/fill/engine.mjs +2 -3
  155. package/src/pptx/fill/freeform.mjs +6 -3
  156. package/src/pptx/ooxml.mjs +20 -0
  157. package/src/pptx/package-evidence.mjs +383 -0
  158. package/src/pptx/tags.mjs +163 -11
  159. package/src/update-check.mjs +22 -4
  160. package/src/workspace/adapters.mjs +222 -0
  161. package/src/workspace/auth.mjs +256 -0
  162. package/src/workspace/autostart.mjs +204 -0
  163. package/src/workspace/daemon.mjs +360 -0
  164. package/src/workspace/discovery.mjs +180 -0
  165. package/src/workspace/edit.mjs +167 -0
  166. package/src/workspace/events.mjs +52 -0
  167. package/src/workspace/gate-client.mjs +290 -0
  168. package/src/workspace/handback.mjs +73 -0
  169. package/src/workspace/lifecycle.mjs +635 -0
  170. package/src/workspace/operations.mjs +200 -0
  171. package/src/workspace/pick-folder.mjs +152 -0
  172. package/src/workspace/registry.mjs +359 -0
  173. package/src/workspace/remote-links.mjs +397 -0
  174. package/src/workspace/remote-status.mjs +359 -0
  175. package/src/workspace/routes.mjs +1331 -0
  176. package/src/workspace/server.mjs +159 -0
  177. package/src/workspace/sync.mjs +1119 -0
  178. package/src/workspace/task-actions.mjs +215 -0
  179. package/src/workspace/tasks.mjs +507 -0
  180. package/src/workspace/ui/app.js +1886 -0
  181. package/src/workspace/ui/index.html +141 -0
  182. package/src/workspace/ui/plan-bridge.js +120 -0
  183. package/src/workspace/ui/sharepoint.svg +59 -0
  184. package/src/workspace/ui/styles.css +423 -0
  185. package/src/builders/visual-families.mjs +0 -46
@@ -0,0 +1,179 @@
1
+ // `--concept` / `--style` as adapters onto the image-intent contract.
2
+ //
3
+ // Both flags predate the contract: they read a file inline and shipped it to
4
+ // the concept model as a transient `{role, image}` reference that nothing
5
+ // recorded. The image was gone the moment the request came back, so a resumed
6
+ // session, a re-run or the .pptx itself had no idea it ever existed.
7
+ //
8
+ // They keep working, and they now mean something durable (migration plan step
9
+ // 5):
10
+ //
11
+ // --concept file@2,3 import the file, then a reconstruct/slide use on 2 and 3
12
+ // --style file@2,3 import the file, then a reference use on 2 and 3
13
+ // (no @) the CURRENT planned set — never an evergreen
14
+ // "all future slides" record
15
+ //
16
+ // Four rules the implementation exists to keep:
17
+ //
18
+ // * **Import first, plan second.** The bytes are registered before any use
19
+ // names them, so a use can never point at something the deck does not have.
20
+ // * **Idempotent.** The registry deduplicates by digest and this deduplicates
21
+ // equivalent uses, so the same flag with the same bytes and the same scope
22
+ // twice is a no-op — which is what leaves an approved plan approved.
23
+ // * **A conflict is refused, not guessed.** If the slide already carries a
24
+ // canonical use of those bytes with a different obligation, the flag and
25
+ // the plan disagree about what the picture is FOR. Picking one silently is
26
+ // how an `original` becomes a mood board. `image_intent_conflict` names
27
+ // both choices and the agent resolves it in the plan.
28
+ // * **No approval is bypassed.** This writes a PENDING plan revision. The
29
+ // existing plan hash gate then re-arms by itself (the storyboard record is
30
+ // part of the story hash), so the user re-signs the plan that now carries
31
+ // the obligation. Nothing here touches plan-review.json.
32
+
33
+ import fs from "node:fs";
34
+ import path from "node:path";
35
+
36
+ import { importImageAsset } from "../lib/image-assets.mjs";
37
+ import { describeUse } from "../lib/image-intent.mjs";
38
+ import { replaceStoryboardSegment, splitPlanMarkdownIntoSegments } from "./storyboard-markdown.mjs";
39
+
40
+ /** `--concept` → a whole-slide blueprint; `--style` → guidance only. */
41
+ const ROLE_USES = {
42
+ concept: {
43
+ fidelity: "reconstruct",
44
+ reconstructionScope: "slide",
45
+ suffix: "rc",
46
+ instruction: (name) => `Rebuild this slide from ${name} (supplied with --concept).`,
47
+ },
48
+ style: {
49
+ fidelity: "reference",
50
+ suffix: "rf",
51
+ instruction: (name) => `Use ${name} as a visual reference for this slide (supplied with --style).`,
52
+ },
53
+ };
54
+
55
+ function conflict(message, extra) {
56
+ return {
57
+ code: "image_intent_conflict",
58
+ message,
59
+ action: "Resolve the competing record in the plan before generating mockups.",
60
+ ...extra,
61
+ };
62
+ }
63
+
64
+ /** The slide numbers the plan currently names — the scope of a flag with no `@`. */
65
+ export function plannedSlidesInPlan(planMarkdown) {
66
+ return splitPlanMarkdownIntoSegments(planMarkdown)
67
+ .filter((segment) => segment.type === "storyboard" && segment.data?.slide)
68
+ .map((segment) => segment.data.slide);
69
+ }
70
+
71
+ /**
72
+ * Turn legacy reference flags into imported assets plus plan uses.
73
+ *
74
+ * Pure with respect to the plan: it returns the next markdown, it does not
75
+ * write it. The caller decides what to do with a changed plan (`concepts` lets
76
+ * the existing sign-off gate refuse the run).
77
+ *
78
+ * @param {string} deckDir
79
+ * @param {Array<{role: "concept"|"style", file: string, slides?: number[]}>} flags
80
+ * @param {{planMarkdown: string}} ctx
81
+ * @returns {Promise<{planMarkdown: string, changed: boolean,
82
+ * imported: Array, added: Array, issues: Array}>}
83
+ */
84
+ export async function applyLegacyReferenceFlags(deckDir, flags, { planMarkdown }) {
85
+ const out = { planMarkdown, changed: false, imported: [], added: [], issues: [] };
86
+ if (!flags?.length) return out;
87
+
88
+ const planned = plannedSlidesInPlan(planMarkdown);
89
+ for (const flag of flags) {
90
+ const spec = ROLE_USES[flag.role];
91
+ if (!spec) continue;
92
+
93
+ // Import BEFORE planning. Same bytes as an earlier import reuse the entry
94
+ // they already have, which is what makes the whole adapter idempotent.
95
+ const { asset, reused } = await importImageAsset(deckDir, flag.file, {
96
+ displayName: path.basename(flag.file),
97
+ provenance: { kind: "local" },
98
+ });
99
+ out.imported.push({ file: flag.file, assetId: asset.assetId, reused });
100
+
101
+ const targets = flag.slides?.length ? flag.slides : planned;
102
+ for (const slide of targets) {
103
+ const board = splitPlanMarkdownIntoSegments(out.planMarkdown).find(
104
+ (segment) => segment.type === "storyboard" && segment.data?.slide === slide,
105
+ );
106
+ if (!board) {
107
+ out.issues.push(
108
+ conflict(
109
+ `--${flag.role} names slide ${slide}, which the plan has no storyboard for.`,
110
+ { slide, assetId: asset.assetId },
111
+ ),
112
+ );
113
+ continue;
114
+ }
115
+ const existing = board.data.assetUses ?? [];
116
+
117
+ // Equivalent to something already recorded → nothing to do.
118
+ const same = existing.find(
119
+ (use) =>
120
+ use.assetId === asset.assetId &&
121
+ use.fidelity === spec.fidelity &&
122
+ use.reconstructionScope === spec.reconstructionScope,
123
+ );
124
+ if (same) continue;
125
+
126
+ // Same bytes, different obligation: the flag and the plan disagree about
127
+ // what this picture is for.
128
+ const other = existing.find((use) => use.assetId === asset.assetId);
129
+ if (other) {
130
+ out.issues.push(
131
+ conflict(
132
+ `slide ${slide} already uses ${asset.displayName} as ${describeUse(other)}, ` +
133
+ `but --${flag.role} asks for ${describeUse(spec)}.`,
134
+ { slide, assetId: asset.assetId, useId: other.useId },
135
+ ),
136
+ );
137
+ continue;
138
+ }
139
+
140
+ // A slide takes one whole-slide blueprint. The contract's own parser
141
+ // would refuse a second one; catching it here names the other source.
142
+ if (spec.reconstructionScope === "slide") {
143
+ const blueprint = existing.find(
144
+ (use) => use.fidelity === "reconstruct" && use.reconstructionScope === "slide",
145
+ );
146
+ if (blueprint) {
147
+ out.issues.push(
148
+ conflict(
149
+ `slide ${slide} already rebuilds from another source (${blueprint.useId}); ` +
150
+ `--concept ${path.basename(flag.file)} would be a second whole-slide blueprint.`,
151
+ { slide, assetId: asset.assetId, useId: blueprint.useId },
152
+ ),
153
+ );
154
+ continue;
155
+ }
156
+ }
157
+
158
+ const use = {
159
+ useId: `${asset.assetId}_s${slide}_${spec.suffix}`,
160
+ assetId: asset.assetId,
161
+ fidelity: spec.fidelity,
162
+ instruction: spec.instruction(asset.displayName),
163
+ ...(spec.reconstructionScope ? { reconstructionScope: spec.reconstructionScope } : {}),
164
+ };
165
+ out.planMarkdown = replaceStoryboardSegment(out.planMarkdown, board, {
166
+ ...board.data,
167
+ assetUses: [...existing, use],
168
+ });
169
+ out.changed = true;
170
+ out.added.push({ slide, useId: use.useId, fidelity: use.fidelity, assetId: asset.assetId });
171
+ }
172
+ }
173
+ return out;
174
+ }
175
+
176
+ /** Persist a pending plan revision. The sign-off gate re-arms by itself. */
177
+ export function writePlanMarkdown(deckDir, markdown) {
178
+ fs.writeFileSync(path.join(deckDir, "plan.md"), markdown);
179
+ }
@@ -0,0 +1,224 @@
1
+ // Trace utilities for the line fitter (line-native.mjs) and the line intake
2
+ // (concept-lines.mjs): the strokes an element draws, cleaned of what runs
3
+ // inside its own nodes, and the ordered distance between two paths. WHICH
4
+ // native object draws a stroke is line-native's business.
5
+ //
6
+ // Shared by both clients; mirrored by scripts/sync-transcription-runtime.mjs —
7
+ // no Node or CLI imports here.
8
+
9
+ import { repairCommands } from "./concept-geometry.mjs";
10
+
11
+
12
+ const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
13
+ const rectCenter = (r) => ({ x: r.x + r.w / 2, y: r.y + r.h / 2 });
14
+
15
+ /**
16
+ * Which side of its node a line end sits on: the nearest edge of a box, or
17
+ * one of eight 45° sectors of a round node. Two ends on the same side are the
18
+ * same attachment for the line's shape; a different side is a different line.
19
+ */
20
+ export function sideOf(p, rect, round = false) {
21
+ const c = rectCenter(rect);
22
+ const dx = (p.x - c.x) / Math.max(1, rect.w / 2), dy = (p.y - c.y) / Math.max(1, rect.h / 2);
23
+ if (round) {
24
+ const k = Math.round(Math.atan2(dy, dx) / (Math.PI / 4));
25
+ return ["right", "bottom-right", "bottom", "bottom-left", "left", "top-left", "top", "top-right"][((k % 8) + 8) % 8];
26
+ }
27
+ return Math.abs(dx) >= Math.abs(dy) ? (dx >= 0 ? "right" : "left") : dy >= 0 ? "bottom" : "top";
28
+ }
29
+ const inside = (p, r, e = 0.5) => p.x > r.x + e && p.x < r.x + r.w - e && p.y > r.y + e && p.y < r.y + r.h - e;
30
+ const finite = (p) => Number.isFinite(p?.x) && Number.isFinite(p?.y);
31
+ const r1 = (v) => Math.round(v * 10) / 10;
32
+
33
+ /** A point end as written in JSX (`data-from="x,y"`) and read back. */
34
+ export const pointRef = (p) => `${r1(p.x)},${r1(p.y)}`;
35
+ export function parsePointRef(ref) {
36
+ const m = /^\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*$/.exec(String(ref ?? ""));
37
+ return m ? { x: Number(m[1]), y: Number(m[2]) } : null;
38
+ }
39
+
40
+ export function traceLength(points) {
41
+ let len = 0;
42
+ for (let i = 1; i < points.length; i++) len += dist(points[i], points[i - 1]);
43
+ return len;
44
+ }
45
+
46
+ // Ordered distance between two paths (px): both resampled to the same number
47
+ // of points by arc length, then compared point for point, start to end. An
48
+ // unordered nearest-point distance let a square around the corners pass for
49
+ // a circle through them (14px against a 24px tolerance, x58 GTM 3); in order,
50
+ // the corner cuts far from the arc and the shape difference counts.
51
+ export function pathDistance(a, b, n = 32) {
52
+ return pathErrors(a, b, n).max;
53
+ }
54
+
55
+ /** Both measures of the ordered comparison: the worst point and the RMS. */
56
+ export function pathErrors(a, b, n = 32) {
57
+ // Linear interpolation by arc length along the polyline itself.
58
+ const resample = (pts) => {
59
+ const d = pts;
60
+ const cum = [0];
61
+ for (let i = 1; i < d.length; i++) cum.push(cum[i - 1] + dist(d[i], d[i - 1]));
62
+ const total = cum.at(-1) || 1;
63
+ const out = [];
64
+ let j = 0;
65
+ for (let k = 0; k < n; k++) {
66
+ const t = (total * k) / (n - 1);
67
+ while (j < cum.length - 2 && cum[j + 1] < t) j++;
68
+ const seg = cum[j + 1] - cum[j] || 1;
69
+ const f = Math.min(1, Math.max(0, (t - cum[j]) / seg));
70
+ out.push({ x: d[j].x + (d[j + 1]?.x - d[j].x || 0) * f, y: d[j].y + (d[j + 1]?.y - d[j].y || 0) * f });
71
+ }
72
+ return out;
73
+ };
74
+ if (a.length < 2 || b.length < 2) return { max: Infinity, rms: Infinity };
75
+ const A = resample(a), B = resample(b);
76
+ // The worst point decides: a corner cut short of a curve is ONE place, and
77
+ // an average hides it. The RMS is the smooth measure a search descends on.
78
+ const d = A.map((p, i) => dist(p, B[i]));
79
+ return { max: Math.max(...d), rms: Math.sqrt(d.reduce((s, v) => s + v * v, 0) / d.length) };
80
+ }
81
+
82
+ export const segmentHitsRect = (p, q, r) => {
83
+ // Axis-aligned or diagonal segment vs rect: sample along the segment.
84
+ const n = Math.max(2, Math.ceil(dist(p, q) / 4));
85
+ for (let i = 0; i <= n; i++) {
86
+ const t = i / n;
87
+ if (inside({ x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t }, r)) return true;
88
+ }
89
+ return false;
90
+ };
91
+ export const contains = (outer, inner) => inner.x >= outer.x - 0.5 && inner.y >= outer.y - 0.5 && inner.x + inner.w <= outer.x + outer.w + 0.5 && inner.y + inner.h <= outer.y + outer.h + 0.5;
92
+
93
+ // ---- Traces.
94
+
95
+ // Inside the node's own outline: its ellipse when it is round (a disc's
96
+ // bounding-box corners are empty slide).
97
+ const insideNode = (p, r, round) => {
98
+ if (!round) return inside(p, r);
99
+ const rx = r.w / 2 - 0.5, ry = r.h / 2 - 0.5;
100
+ if (rx <= 0 || ry <= 0) return false;
101
+ const dx = (p.x - r.x - r.w / 2) / rx, dy = (p.y - r.y - r.h / 2) / ry;
102
+ return dx * dx + dy * dy < 1;
103
+ };
104
+
105
+ // Where a walk from `a` (inside) toward `b` (outside) leaves the node.
106
+ function exitPoint(a, b, r, round) {
107
+ let lo = 0, hi = 1;
108
+ for (let i = 0; i < 24; i++) {
109
+ const mid = (lo + hi) / 2;
110
+ if (insideNode({ x: a.x + (b.x - a.x) * mid, y: a.y + (b.y - a.y) * mid }, r, round)) lo = mid;
111
+ else hi = mid;
112
+ }
113
+ return { x: a.x + (b.x - a.x) * hi, y: a.y + (b.y - a.y) * hi };
114
+ }
115
+
116
+ function clipStart(pts, r, round) {
117
+ let k = -1;
118
+ for (let i = 0; i < pts.length - 1; i++) if (insideNode(pts[i], r, round)) k = i;
119
+ if (k < 0) return pts;
120
+ return [exitPoint(pts[k], pts[k + 1], r, round), ...pts.slice(k + 1)];
121
+ }
122
+
123
+ /**
124
+ * The trace with everything that runs INSIDE its own source or target cut
125
+ * away: a line is drawn between the nodes, not through them. A reader that put
126
+ * a fan's bus inside the source disc (2d9503ca slide 2) otherwise pulled two
127
+ * edges up through the disc and its label.
128
+ */
129
+ export function sanitizeTrace(points, fromRect, toRect, { fromRound = false, toRound = false } = {}) {
130
+ const raw = (points ?? []).filter(finite).map((p) => ({ x: p.x, y: p.y }));
131
+ if (raw.length < 2) return raw;
132
+ let pts = raw;
133
+ if (fromRect?.w > 0 && fromRect?.h > 0) pts = clipStart(pts, fromRect, fromRound);
134
+ if (toRect?.w > 0 && toRect?.h > 0) pts = clipStart([...pts].reverse(), toRect, toRound).reverse();
135
+ // Nothing left between the nodes (they overlap, or the reader traced only
136
+ // inside them): the reader's own trace is the better evidence.
137
+ return traceLength(pts) >= 8 ? pts : raw;
138
+ }
139
+
140
+ const bezier = (p0, c1, c2, p1, t) => {
141
+ const u = 1 - t;
142
+ return { x: u * u * u * p0.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * p1.x, y: u * u * u * p0.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * p1.y };
143
+ };
144
+
145
+ /**
146
+ * The open strokes a `kind:path` element draws, each as an ordered point list
147
+ * (curves sampled), or [] when it is a closed or filled shape — which stays a
148
+ * drawn shape. Several subpaths are several lines.
149
+ */
150
+ export function strokeTraces(e) {
151
+ if (e?.kind !== "path" || (e.text != null && String(e.text).trim() !== "")) return [];
152
+ const g = e.geometry;
153
+ const filled = e.fill && e.fill !== "none";
154
+ // Only a closed outline holds paint (the transcriber's own rule): an open
155
+ // spline, arc or path is a stroke whatever fill the reader named.
156
+ if (g?.type === "spline") return g.closed || !(g.points?.length >= 2) ? [] : [g.points.filter(finite)];
157
+ if (g?.type === "arc") {
158
+ // A full ring is a closed outline; a filled arc with a hole is a band.
159
+ if (Math.abs(g.sweep) >= 360 || (filled && (g.innerRatio ?? 0) > 0 && g.innerRatio < 1)) return [];
160
+ const at = (a) => ({ x: g.cx + g.rx * Math.cos((a * Math.PI) / 180), y: g.cy + g.ry * Math.sin((a * Math.PI) / 180) });
161
+ const n = Math.max(4, Math.ceil(Math.abs(g.sweep) / 10));
162
+ return [Array.from({ length: n + 1 }, (_, i) => at(g.start + (g.sweep * i) / n))];
163
+ }
164
+ if (g?.type === "path") {
165
+ // The commands as the transcriber draws them (the reader writes `M` as a
166
+ // whole segment, start in x1/y1).
167
+ const cmds = repairCommands(g.commands ?? [], e) ?? [];
168
+ if (cmds.some((c) => c?.op === "Z")) return [];
169
+ const out = [];
170
+ let cur = null, pen = null;
171
+ for (const c of cmds) {
172
+ const p = { x: c.x, y: c.y };
173
+ if (!finite(p)) continue;
174
+ if (c.op === "M") { if (cur?.length >= 2) out.push(cur); cur = [p]; }
175
+ else if (!cur) cur = [p];
176
+ else if (c.op === "C" || c.op === "Q") {
177
+ const c1 = { x: c.x1, y: c.y1 }, c2 = c.op === "C" ? { x: c.x2, y: c.y2 } : c1;
178
+ if (finite(c1) && finite(c2)) for (let k = 1; k <= 8; k++) cur.push(bezier(pen, c1, c2, p, k / 8));
179
+ else cur.push(p);
180
+ } else cur.push(p);
181
+ pen = p;
182
+ }
183
+ if (cur?.length >= 2) out.push(cur);
184
+ return out;
185
+ }
186
+ if (g || (filled && e.points?.length >= 3) || !(e.points?.length >= 2)) return [];
187
+ return [e.points.filter(finite)];
188
+ }
189
+
190
+ /**
191
+ * A few plain strokes the size of an icon with no arrowhead — a tick, a cross,
192
+ * a chevron — are a glyph mark (the intake's role `mark`), not a line between
193
+ * things: never glued, written as a chain of native pieces with round caps.
194
+ */
195
+ export function isGlyphStroke(e, traces = strokeTraces(e)) {
196
+ if (!traces.length) return false;
197
+ // A declared cap, or an end the reader says touches an element, makes it a
198
+ // line whatever its size.
199
+ const heads = e.ends && ["start", "end"].some((k) => e.ends[k] && e.ends[k] !== "none");
200
+ const declared = e.strokeEnds && ["start", "end"].some((k) => {
201
+ const s = e.strokeEnds[k];
202
+ return s && ((s.cap && s.cap !== "none") || (s.element && s.contact === "touches"));
203
+ });
204
+ if (heads || declared) return false;
205
+ const pts = traces.flat();
206
+ const w = Math.max(...pts.map((p) => p.x)) - Math.min(...pts.map((p) => p.x));
207
+ const h = Math.max(...pts.map((p) => p.y)) - Math.min(...pts.map((p) => p.y));
208
+ return Math.max(w, h) <= 32;
209
+ }
210
+
211
+ /** The point halfway along a trace, by length. */
212
+ export function traceMidpoint(pts) {
213
+ const total = traceLength(pts);
214
+ let acc = 0;
215
+ for (let i = 1; i < pts.length; i++) {
216
+ const seg = dist(pts[i], pts[i - 1]);
217
+ if (acc + seg >= total / 2) {
218
+ const f = seg ? (total / 2 - acc) / seg : 0;
219
+ return { x: pts[i - 1].x + (pts[i].x - pts[i - 1].x) * f, y: pts[i - 1].y + (pts[i].y - pts[i - 1].y) * f };
220
+ }
221
+ acc += seg;
222
+ }
223
+ return pts[0];
224
+ }