slidemind 0.3.19 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +591 -15
- package/README.md +202 -7
- package/bin/slidemind.mjs +50 -2
- package/package.json +2 -2
- package/skills/create-slides/SKILL.md +484 -886
- package/skills/search/SKILL.md +46 -23
- package/src/bridge/build-and-push.mjs +548 -73
- package/src/bridge/bundle.mjs +100 -31
- package/src/bridge/capture.mjs +14 -17
- package/src/bridge/client/brands.mjs +157 -18
- package/src/bridge/client/engine-routing.mjs +48 -13
- package/src/bridge/client/engine-search.mjs +24 -1
- package/src/bridge/client/engine-slides.mjs +1 -1
- package/src/bridge/client/render.mjs +27 -1
- package/src/bridge/client/transport.mjs +499 -23
- package/src/bridge/client.mjs +10 -0
- package/src/bridge/config.mjs +67 -6
- package/src/bridge/engine-download.mjs +0 -10
- package/src/bridge/graph-auth.mjs +55 -3
- package/src/bridge/image-intent-envelope.mjs +239 -0
- package/src/bridge/package-preview.mjs +67 -0
- package/src/bridge/sharepoint.mjs +914 -0
- package/src/builders/asset-fetch.mjs +203 -0
- package/src/builders/block-dividers.mjs +83 -0
- package/src/builders/build.mjs +171 -87
- package/src/builders/card-height.mjs +294 -0
- package/src/builders/chart-hole.mjs +183 -0
- package/src/builders/chart-jsx.mjs +410 -0
- package/src/builders/chart-labels.mjs +326 -0
- package/src/builders/concept-geometry.mjs +370 -0
- package/src/builders/concept-intent.mjs +200 -0
- package/src/builders/concept-layout.mjs +79 -0
- package/src/builders/concept-lines.mjs +509 -0
- package/src/builders/concept-lists.mjs +270 -0
- package/src/builders/concept-marks.mjs +84 -0
- package/src/builders/concept-relationships.mjs +12 -0
- package/src/builders/concept-spec.mjs +645 -0
- package/src/builders/concept-text.mjs +140 -0
- package/src/builders/concept-waves.mjs +57 -0
- package/src/builders/copy-fit.mjs +139 -0
- package/src/builders/design-tokens.mjs +347 -0
- package/src/builders/draft-check.mjs +552 -340
- package/src/builders/draft-lines.mjs +369 -0
- package/src/builders/edit-route.mjs +29 -0
- package/src/builders/figure-sketch.mjs +375 -0
- package/src/builders/fold-sets.mjs +639 -0
- package/src/builders/gate-server.mjs +9 -4
- package/src/builders/gate-session.js +273 -12
- package/src/builders/gate-shell.js +1494 -0
- package/src/builders/image-intent-check.mjs +308 -0
- package/src/builders/image-use-reconcile.mjs +107 -0
- package/src/builders/kit-helpers.mjs +121 -0
- package/src/builders/layout-names.mjs +32 -0
- package/src/builders/legacy-reference-uses.mjs +179 -0
- package/src/builders/line-fit.mjs +224 -0
- package/src/builders/line-native.mjs +895 -0
- package/src/builders/line-relations.mjs +230 -0
- package/src/builders/lint-fix.mjs +117 -9
- package/src/builders/lint.mjs +25 -20
- package/src/builders/list-jsx.mjs +88 -0
- package/src/builders/placement.mjs +133 -0
- package/src/builders/plan-checks.mjs +135 -0
- package/src/builders/plan-comments.mjs +19 -0
- package/src/builders/plan-handlers.mjs +95 -0
- package/src/builders/plan-markdown-edit.mjs +3 -3
- package/src/builders/plan-overlay.js +1530 -1608
- package/src/builders/plan.mjs +689 -186
- package/src/builders/preset-definitions.json +1 -0
- package/src/builders/preset-eval.mjs +244 -0
- package/src/builders/preview.html +51 -120
- package/src/builders/red-thread-backbone.mjs +135 -36
- package/src/builders/repair.mjs +205 -0
- package/src/builders/repeat-groups.mjs +540 -0
- package/src/builders/resolve-skill.mjs +5 -4
- package/src/builders/review-overlay.js +296 -293
- package/src/builders/review.mjs +305 -84
- package/src/builders/rule-shift.mjs +96 -0
- package/src/builders/seat.mjs +354 -0
- package/src/builders/settle-driver.mjs +236 -0
- package/src/builders/settle.mjs +1448 -0
- package/src/builders/slide-prep.mjs +67 -0
- package/src/builders/spec-assets.mjs +236 -0
- package/src/builders/spec-match.mjs +56 -0
- package/src/builders/stack.mjs +413 -0
- package/src/builders/storyboard-markdown.mjs +70 -27
- package/src/builders/storyboard-schema.mjs +54 -114
- package/src/builders/text-blocks.mjs +212 -0
- package/src/builders/text-card-fit.mjs +552 -0
- package/src/builders/text-stack.mjs +276 -0
- package/src/builders/transcribe.mjs +1069 -0
- package/src/cli/assets.mjs +164 -0
- package/src/cli/common.mjs +173 -12
- package/src/cli/concept-spec-support.mjs +76 -0
- package/src/cli/concepts.mjs +1002 -103
- package/src/cli/deck.mjs +401 -65
- package/src/cli/diagnose.mjs +4 -2
- package/src/cli/engine-helpers.mjs +17 -10
- package/src/cli/engine-results.mjs +76 -3
- package/src/cli/help.mjs +232 -28
- package/src/cli/install.mjs +9 -0
- package/src/cli/maintenance.mjs +104 -0
- package/src/cli/read.mjs +2 -1
- package/src/cli/ready.mjs +6 -3
- package/src/cli/record.mjs +522 -0
- package/src/cli/remote.mjs +818 -0
- package/src/cli/revision.mjs +686 -0
- package/src/cli/search.mjs +30 -11
- package/src/cli/session.mjs +56 -13
- package/src/cli/setup.mjs +46 -2
- package/src/cli/status.mjs +142 -33
- package/src/cli/tasks.mjs +246 -0
- package/src/cli/transcribe.mjs +854 -0
- package/src/cli/workspace-ui.mjs +260 -0
- package/src/cli/workspace.mjs +212 -34
- package/src/install-channel.mjs +64 -7
- package/src/lib/adopt.mjs +346 -25
- package/src/lib/concept-spec-binding.mjs +96 -0
- package/src/lib/conversions.mjs +70 -6
- package/src/lib/convert-folded.mjs +107 -0
- package/src/lib/deck-paths.mjs +418 -0
- package/src/lib/durable-ids.mjs +47 -0
- package/src/lib/edit-operations.mjs +316 -0
- package/src/lib/edit-state.mjs +1244 -0
- package/src/lib/edit-transitions.mjs +120 -0
- package/src/lib/extensions.mjs +501 -0
- package/src/lib/image-assets.mjs +465 -0
- package/src/lib/image-derivatives.mjs +234 -0
- package/src/lib/image-intent-fingerprint.mjs +70 -0
- package/src/lib/image-intent-tags.mjs +176 -0
- package/src/lib/image-intent.mjs +802 -0
- package/src/lib/image-use-overrides.mjs +279 -0
- package/src/lib/inline-components.mjs +613 -0
- package/src/lib/lint-converted.mjs +68 -4
- package/src/lib/merge-json.mjs +163 -0
- package/src/lib/overwrite-guard.mjs +3 -3
- package/src/lib/presentation-context.mjs +531 -0
- package/src/lib/preservation-tags.mjs +157 -0
- package/src/lib/preservation-verdict.mjs +1342 -0
- package/src/lib/publish.mjs +1557 -0
- package/src/lib/rebase.mjs +945 -0
- package/src/lib/reconcile-ack.mjs +4 -6
- package/src/lib/reconcile.mjs +62 -24
- package/src/lib/record-client.mjs +175 -0
- package/src/lib/record-state.mjs +151 -0
- package/src/lib/remote.mjs +661 -0
- package/src/lib/revision.mjs +1210 -0
- package/src/lib/slide-guids.mjs +4 -6
- package/src/lib/slide-preservation.mjs +423 -0
- package/src/lib/slide-selection.mjs +9 -0
- package/src/lib/workspace-transaction.mjs +117 -0
- package/src/pptx/content-digest.mjs +334 -0
- package/src/pptx/deck-assemble.mjs +1278 -0
- package/src/pptx/fill/engine.mjs +2 -3
- package/src/pptx/fill/freeform.mjs +6 -3
- package/src/pptx/ooxml.mjs +20 -0
- package/src/pptx/package-evidence.mjs +383 -0
- package/src/pptx/tags.mjs +163 -11
- package/src/update-check.mjs +22 -4
- package/src/workspace/adapters.mjs +222 -0
- package/src/workspace/auth.mjs +256 -0
- package/src/workspace/autostart.mjs +204 -0
- package/src/workspace/daemon.mjs +360 -0
- package/src/workspace/discovery.mjs +180 -0
- package/src/workspace/edit.mjs +167 -0
- package/src/workspace/events.mjs +52 -0
- package/src/workspace/gate-client.mjs +290 -0
- package/src/workspace/handback.mjs +73 -0
- package/src/workspace/lifecycle.mjs +635 -0
- package/src/workspace/operations.mjs +200 -0
- package/src/workspace/pick-folder.mjs +152 -0
- package/src/workspace/registry.mjs +359 -0
- package/src/workspace/remote-links.mjs +397 -0
- package/src/workspace/remote-status.mjs +359 -0
- package/src/workspace/routes.mjs +1331 -0
- package/src/workspace/server.mjs +159 -0
- package/src/workspace/sync.mjs +1119 -0
- package/src/workspace/task-actions.mjs +215 -0
- package/src/workspace/tasks.mjs +507 -0
- package/src/workspace/ui/app.js +1886 -0
- package/src/workspace/ui/index.html +141 -0
- package/src/workspace/ui/plan-bridge.js +120 -0
- package/src/workspace/ui/sharepoint.svg +59 -0
- package/src/workspace/ui/styles.css +423 -0
- package/src/builders/visual-families.mjs +0 -46
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// Explicit geometry, never inferred from a noisy polygon. Coordinates are in
|
|
2
|
+
// slot pixels; angles are clockwise degrees from three o'clock.
|
|
3
|
+
export function geometryPath(g) {
|
|
4
|
+
if (g?.type === 'spline') return splinePath(g.points, g.closed);
|
|
5
|
+
if (g?.type === 'path') return commandPath(g.commands);
|
|
6
|
+
if (!g || !['arc', 'sector'].includes(g.type)) return null;
|
|
7
|
+
const { cx, cy, rx, ry, start, sweep } = g;
|
|
8
|
+
// An open arc is a stroke: its hole ratio means nothing (the reader wrote 1
|
|
9
|
+
// on four ring arcs, x58 cloud 6, and the whole transcribe refused).
|
|
10
|
+
const innerRatio = g.type === 'arc' ? 0 : (g.innerRatio ?? 0);
|
|
11
|
+
if (![cx, cy, rx, ry, start, sweep, innerRatio].every(Number.isFinite) ||
|
|
12
|
+
rx <= 0 || ry <= 0 || sweep === 0 || Math.abs(sweep) > 360 ||
|
|
13
|
+
innerRatio < 0 || innerRatio >= 1) return null;
|
|
14
|
+
const n = (v) => String(Math.round(v * 1e6) / 1e6);
|
|
15
|
+
const point = (angle, ratio = 1) => {
|
|
16
|
+
const a = angle * Math.PI / 180;
|
|
17
|
+
return `${n(rx + rx * ratio * Math.cos(a))} ${n(ry + ry * ratio * Math.sin(a))}`;
|
|
18
|
+
};
|
|
19
|
+
// Split full circles (SVG's coincident-endpoint arc draws nothing) and use
|
|
20
|
+
// at most half-circle arcs for consistent preview/export interpretation.
|
|
21
|
+
const arc = (angle, extent, ratio) => {
|
|
22
|
+
const count = Math.ceil(Math.abs(extent) / 180);
|
|
23
|
+
return Array.from({ length: count }, (_, i) =>
|
|
24
|
+
`A ${n(rx * ratio)} ${n(ry * ratio)} 0 0 ${extent > 0 ? 1 : 0} ${point(angle + extent * (i + 1) / count, ratio)}`).join(' ');
|
|
25
|
+
};
|
|
26
|
+
let d = `M ${point(start)} ${arc(start, sweep, 1)}`;
|
|
27
|
+
if (g.type === 'sector') {
|
|
28
|
+
d += innerRatio > 0
|
|
29
|
+
? ` L ${point(start + sweep, innerRatio)} ${arc(start + sweep, -sweep, innerRatio)} Z`
|
|
30
|
+
: ` L ${n(rx)} ${n(ry)} Z`;
|
|
31
|
+
}
|
|
32
|
+
return { x: cx - rx, y: cy - ry, w: rx * 2, h: ry * 2, d, extent: arcExtent(g) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The box an arc or sector covers: its outer rim, its inner rim (or centre),
|
|
36
|
+
// sampled along the sweep.
|
|
37
|
+
export function arcExtent(g) {
|
|
38
|
+
const { cx, cy, rx, ry, start, sweep } = g;
|
|
39
|
+
const inner = g.type === 'sector' ? (g.innerRatio ?? 0) : null;
|
|
40
|
+
const pts = [];
|
|
41
|
+
const steps = Math.max(8, Math.ceil(Math.abs(sweep) / 5));
|
|
42
|
+
for (let i = 0; i <= steps; i++) {
|
|
43
|
+
const a = (start + (sweep * i) / steps) * Math.PI / 180;
|
|
44
|
+
pts.push([cx + rx * Math.cos(a), cy + ry * Math.sin(a)]);
|
|
45
|
+
if (inner != null) pts.push([cx + rx * inner * Math.cos(a), cy + ry * inner * Math.sin(a)]);
|
|
46
|
+
}
|
|
47
|
+
const xs = pts.map((p) => p[0]), ys = pts.map((p) => p[1]);
|
|
48
|
+
return { x: Math.min(...xs), y: Math.min(...ys), w: Math.max(...xs) - Math.min(...xs), h: Math.max(...ys) - Math.min(...ys) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const iou = (a, b) => {
|
|
52
|
+
const w = Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x));
|
|
53
|
+
const h = Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
|
|
54
|
+
const i = w * h;
|
|
55
|
+
return i ? i / (a.w * a.h + b.w * b.h - i) : 0;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* An arc's angles, reconciled with the box the reader measured around it.
|
|
60
|
+
* The reader's box and sweep size are reliable; its start angle and sweep
|
|
61
|
+
* direction are not (x61 QBR 6: a right-hand sector read with a sweep that
|
|
62
|
+
* drew it over the left one). When the angles draw somewhere the box does
|
|
63
|
+
* not, the start and direction that best cover the box win; the sweep size
|
|
64
|
+
* is kept. Returns the geometry to draw.
|
|
65
|
+
*/
|
|
66
|
+
export function reconcileArc(g, box) {
|
|
67
|
+
if (!g || !['arc', 'sector'].includes(g.type) || !(box?.w > 0 && box?.h > 0)) return g;
|
|
68
|
+
if (![g.cx, g.cy, g.rx, g.ry, g.start, g.sweep].every(Number.isFinite) || !g.sweep || Math.abs(g.sweep) >= 360) return g;
|
|
69
|
+
const fit = iou(arcExtent(g), box);
|
|
70
|
+
if (fit >= 0.8) return g;
|
|
71
|
+
let best = { g, fit };
|
|
72
|
+
// Rotated only, never reversed: an arc's direction says which end carries
|
|
73
|
+
// its head, and a reversed arc covering the same extent put the head on the
|
|
74
|
+
// wrong end (x67 cloud 6, a clockwise cycle drawn backwards).
|
|
75
|
+
for (let start = 0; start < 360; start += 5) {
|
|
76
|
+
const c = { ...g, start };
|
|
77
|
+
const f = iou(arcExtent(c), box);
|
|
78
|
+
if (f > best.fit + 1e-9) best = { g: c, fit: f };
|
|
79
|
+
}
|
|
80
|
+
return best.fit > fit + 0.1 ? best.g : g;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The reader can observe points ON a contour; Bezier handles are invisible.
|
|
84
|
+
// Centripetal Catmull-Rom interpolates those ordered observations without
|
|
85
|
+
// asking a vision model to invent control points. Straight/cornered shapes
|
|
86
|
+
// still use explicit M/L/Q/C/Z commands.
|
|
87
|
+
function splinePath(input, closed = false) {
|
|
88
|
+
if (!Array.isArray(input) || input.some(p => !Number.isFinite(p?.x) || !Number.isFinite(p?.y))) return null;
|
|
89
|
+
const points = input.filter((p,i) => !i || p.x !== input[i-1].x || p.y !== input[i-1].y);
|
|
90
|
+
if (closed && points.length > 1 && points[0].x === points.at(-1).x && points[0].y === points.at(-1).y) points.pop();
|
|
91
|
+
if (points.length < (closed ? 3 : 2)) return null;
|
|
92
|
+
const n = points.length;
|
|
93
|
+
const at = i => {
|
|
94
|
+
if (closed) return points[(i+n)%n];
|
|
95
|
+
if (i < 0) return {x:2*points[0].x-points[1].x,y:2*points[0].y-points[1].y};
|
|
96
|
+
if (i >= n) return {x:2*points[n-1].x-points[n-2].x,y:2*points[n-1].y-points[n-2].y};
|
|
97
|
+
return points[i];
|
|
98
|
+
};
|
|
99
|
+
const commands = [{op:'M',...points[0]}];
|
|
100
|
+
for (let i=0;i<(closed?n:n-1);i++) {
|
|
101
|
+
const [a,b,c,d]=[at(i-1),at(i),at(i+1),at(i+2)];
|
|
102
|
+
const dt=(p,q)=>Math.max(1e-6,Math.sqrt(Math.hypot(q.x-p.x,q.y-p.y)));
|
|
103
|
+
const ab=dt(a,b),bc=dt(b,c),cd=dt(c,d);
|
|
104
|
+
const control = k => [
|
|
105
|
+
b[k]+bc*((b[k]-a[k])/ab-(c[k]-a[k])/(ab+bc)+(c[k]-b[k])/bc)/3,
|
|
106
|
+
c[k]-bc*((c[k]-b[k])/bc-(d[k]-b[k])/(bc+cd)+(d[k]-c[k])/cd)/3,
|
|
107
|
+
];
|
|
108
|
+
const [x1,x2]=control('x'),[y1,y2]=control('y');
|
|
109
|
+
commands.push({op:'C',x1,y1,x2,y2,x:c.x,y:c.y});
|
|
110
|
+
}
|
|
111
|
+
if (closed) commands.push({op:'Z'});
|
|
112
|
+
return commandPath(commands);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Structured path commands keep control points, subpaths and winding intact.
|
|
116
|
+
// Bounds include the control hull, so a curve cannot be clipped to its chord.
|
|
117
|
+
function commandPath(commands) {
|
|
118
|
+
if (!Array.isArray(commands) || !commands.length || commands[0]?.op !== 'M') return null;
|
|
119
|
+
const fields = { M: ['x', 'y'], L: ['x', 'y'], Q: ['x1', 'y1', 'x', 'y'], C: ['x1', 'y1', 'x2', 'y2', 'x', 'y'], Z: [] };
|
|
120
|
+
const xs = [], ys = [];
|
|
121
|
+
for (const c of commands) {
|
|
122
|
+
if (!c || !Object.hasOwn(fields, c.op)) return null;
|
|
123
|
+
const keys = fields[c.op];
|
|
124
|
+
if (!keys.every(k => Number.isFinite(c[k]))) return null;
|
|
125
|
+
for (let i = 0; i < keys.length; i += 2) { xs.push(c[keys[i]]); ys.push(c[keys[i + 1]]); }
|
|
126
|
+
}
|
|
127
|
+
const x = Math.min(...xs), y = Math.min(...ys);
|
|
128
|
+
const d = commands.map(c => c.op + (fields[c.op].length ? ' ' + fields[c.op].map((k, i) =>
|
|
129
|
+
String(Math.round((c[k] - (i % 2 ? y : x)) * 1e6) / 1e6)).join(' ') : '')).join(' ');
|
|
130
|
+
// What the curve actually covers (sampled), not its control hull: the box a
|
|
131
|
+
// shape is placed in is the box it draws.
|
|
132
|
+
const pts = [];
|
|
133
|
+
let cur = null, start = null;
|
|
134
|
+
for (const c of commands) {
|
|
135
|
+
if (c.op === 'M') { cur = { x: c.x, y: c.y }; start = cur; pts.push(cur); continue; }
|
|
136
|
+
if (c.op === 'Z') { cur = start; continue; }
|
|
137
|
+
const p0 = cur;
|
|
138
|
+
for (let i = 1; i <= 16; i++) {
|
|
139
|
+
const t = i / 16, u = 1 - t;
|
|
140
|
+
if (c.op === 'L') pts.push({ x: p0.x + (c.x - p0.x) * t, y: p0.y + (c.y - p0.y) * t });
|
|
141
|
+
else if (c.op === 'Q') pts.push({ x: u * u * p0.x + 2 * u * t * c.x1 + t * t * c.x, y: u * u * p0.y + 2 * u * t * c.y1 + t * t * c.y });
|
|
142
|
+
else pts.push({ x: u ** 3 * p0.x + 3 * u * u * t * c.x1 + 3 * u * t * t * c.x2 + t ** 3 * c.x, y: u ** 3 * p0.y + 3 * u * u * t * c.y1 + 3 * u * t * t * c.y2 + t ** 3 * c.y });
|
|
143
|
+
}
|
|
144
|
+
cur = { x: c.x, y: c.y };
|
|
145
|
+
}
|
|
146
|
+
const ex = Math.min(...pts.map((p) => p.x)), ey = Math.min(...pts.map((p) => p.y));
|
|
147
|
+
const extent = { x: ex, y: ey, w: Math.max(2, Math.max(...pts.map((p) => p.x)) - ex), h: Math.max(2, Math.max(...pts.map((p) => p.y)) - ey) };
|
|
148
|
+
return { x, y, w: Math.max(2, Math.max(...xs) - x), h: Math.max(2, Math.max(...ys) - y), d, extent };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The geometry mapped by p' = (p - from) * k + to: uniform scale k about
|
|
153
|
+
* `from`, landing at `to`. Used to fit a shape into the box it was measured in
|
|
154
|
+
* by changing its own coordinates, so the svg that draws it stays 1:1 with its
|
|
155
|
+
* coordinate plane.
|
|
156
|
+
*/
|
|
157
|
+
export function scaleGeometry(g, k, from, to) {
|
|
158
|
+
const X = (x) => (x - from.x) * k + to.x, Y = (y) => (y - from.y) * k + to.y;
|
|
159
|
+
if (g.type === 'spline') return { ...g, points: g.points.map((p) => ({ ...p, x: X(p.x), y: Y(p.y) })) };
|
|
160
|
+
if (g.type === 'path') return { ...g, commands: g.commands.map((c) => {
|
|
161
|
+
const o = { ...c };
|
|
162
|
+
for (const key of ['x', 'x1', 'x2']) if (Number.isFinite(o[key])) o[key] = X(o[key]);
|
|
163
|
+
for (const key of ['y', 'y1', 'y2']) if (Number.isFinite(o[key])) o[key] = Y(o[key]);
|
|
164
|
+
return o;
|
|
165
|
+
}) };
|
|
166
|
+
if (g.type === 'arc' || g.type === 'sector') return { ...g, cx: X(g.cx), cy: Y(g.cy), rx: g.rx * k, ry: g.ry * k };
|
|
167
|
+
return g;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The top the slide writer gives an element (transcribe.mjs): a text that sits
|
|
172
|
+
* on the slot's bottom edge in the image is a hair taller in our rung, so it
|
|
173
|
+
* is written 4px higher — the export gate refuses a slide over 2px. A move
|
|
174
|
+
* into or out of that band is no plain translation of the render.
|
|
175
|
+
*/
|
|
176
|
+
export const writtenTop = (e, slot) => (e.text != null && e.y + e.h >= slot.h - 2 ? Math.max(0, e.y - 4) : e.y);
|
|
177
|
+
|
|
178
|
+
export function translateElement(e, dx, dy) {
|
|
179
|
+
e.x += dx;
|
|
180
|
+
e.y += dy;
|
|
181
|
+
if (e.textFrame) { e.textFrame.x += dx; e.textFrame.y += dy; }
|
|
182
|
+
for (const p of [...(e.points ?? []), ...(e.outline ?? [])]) { p.x += dx; p.y += dy; }
|
|
183
|
+
const g = e.geometry;
|
|
184
|
+
if (g?.type === 'spline') {
|
|
185
|
+
for (const p of g.points ?? []) { p.x += dx; p.y += dy; }
|
|
186
|
+
} else if (g?.type === 'path') {
|
|
187
|
+
for (const c of g.commands ?? []) {
|
|
188
|
+
for (const k of ['x', 'x1', 'x2']) if (Number.isFinite(c[k])) c[k] += dx;
|
|
189
|
+
for (const k of ['y', 'y1', 'y2']) if (Number.isFinite(c[k])) c[k] += dy;
|
|
190
|
+
}
|
|
191
|
+
} else if (g) { g.cx += dx; g.cy += dy; }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Only payloads this compiler can represent may replace the observed marks.
|
|
195
|
+
// In particular, no invented zeroes, truncated series or guessed chart type.
|
|
196
|
+
export function nativeChartData(chart) {
|
|
197
|
+
if (!chart || !['column', 'bar', 'line', 'area', 'pie', 'doughnut', 'waterfall'].includes(chart.type)) return null;
|
|
198
|
+
const series = chart.series;
|
|
199
|
+
if (!Array.isArray(series) || !series.length) return null;
|
|
200
|
+
const n = series[0]?.values?.length;
|
|
201
|
+
if (!n || !series.every(s => Array.isArray(s.values) && s.values.length === n && s.values.every(Number.isFinite))) return null;
|
|
202
|
+
if (['pie', 'doughnut', 'waterfall'].includes(chart.type) && series.length !== 1) return null;
|
|
203
|
+
const categories = chart.categories ?? [];
|
|
204
|
+
if (!Array.isArray(categories) || categories.length > n) return null;
|
|
205
|
+
const data = {
|
|
206
|
+
categories: [...categories, ...Array(n - categories.length).fill(' ')],
|
|
207
|
+
series: series.map(s => ({ name: s.name, values: [...s.values] })),
|
|
208
|
+
};
|
|
209
|
+
// A waterfall carries what the reader judged on the painting: which bars
|
|
210
|
+
// stand on the axis, and whether lines join each bar to the next.
|
|
211
|
+
if (chart.type === 'waterfall') {
|
|
212
|
+
data.totals = Array.isArray(chart.totals)
|
|
213
|
+
? [...new Set(chart.totals.filter(i => Number.isInteger(i) && i >= 0 && i < n))].sort((a, b) => a - b)
|
|
214
|
+
: null;
|
|
215
|
+
data.connectors = chart.connectors === true;
|
|
216
|
+
}
|
|
217
|
+
return data;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* A reader's path commands made drawable against the element they belong to.
|
|
222
|
+
* The reader writes `M` as a whole segment (start in x1/y1, next point in
|
|
223
|
+
* x/y) and fills unused fields with 0, which maps far outside the slide:
|
|
224
|
+
* x53 cloud 2 drew `»` as half-strokes and `</>` as a 400px diagonal (11 of 30
|
|
225
|
+
* command paths). A point well outside the element's own rect is not part of
|
|
226
|
+
* its outline; a subpath that loses its start restarts at its next point.
|
|
227
|
+
*/
|
|
228
|
+
export function repairCommands(commands, rect) {
|
|
229
|
+
if (!Array.isArray(commands) || !rect || !(rect.w > 0) || !(rect.h > 0)) return commands;
|
|
230
|
+
const padX = Math.max(8, rect.w * 0.5), padY = Math.max(8, rect.h * 0.5);
|
|
231
|
+
const inside = (x, y) => Number.isFinite(x) && Number.isFinite(y)
|
|
232
|
+
&& x >= rect.x - padX && x <= rect.x + rect.w + padX && y >= rect.y - padY && y <= rect.y + rect.h + padY;
|
|
233
|
+
const out = [];
|
|
234
|
+
let open = false;
|
|
235
|
+
for (const c of commands) {
|
|
236
|
+
if (!c) continue;
|
|
237
|
+
if (c.op === 'Z') { if (open) out.push({ op: 'Z' }); open = false; continue; }
|
|
238
|
+
if (c.op === 'M' && inside(c.x1, c.y1) && (c.x1 !== c.x || c.y1 !== c.y)) {
|
|
239
|
+
out.push({ op: 'M', x: c.x1, y: c.y1 });
|
|
240
|
+
open = true;
|
|
241
|
+
if (inside(c.x, c.y)) out.push({ op: 'L', x: c.x, y: c.y });
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (!inside(c.x, c.y)) continue;
|
|
245
|
+
const controls = c.op === 'Q' ? [['x1', 'y1']] : c.op === 'C' ? [['x1', 'y1'], ['x2', 'y2']] : [];
|
|
246
|
+
const straight = controls.some(([kx, ky]) => !inside(c[kx], c[ky]));
|
|
247
|
+
if (c.op === 'M' || !open) { out.push({ op: 'M', x: c.x, y: c.y }); open = true; continue; }
|
|
248
|
+
out.push(straight ? { op: 'L', x: c.x, y: c.y } : { ...c });
|
|
249
|
+
}
|
|
250
|
+
return out.length >= 2 ? out : commands;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The two ends of a free path with the direction the line runs there: `start`
|
|
255
|
+
* points back out of the path, `end` points forward. Slot coordinates. Null
|
|
256
|
+
* when the geometry has no open ends to put a head on.
|
|
257
|
+
*/
|
|
258
|
+
export function pathEnds(e) {
|
|
259
|
+
const g = e?.geometry;
|
|
260
|
+
const seg = (p, q) => ({ at: q, dir: { x: q.x - p.x, y: q.y - p.y } });
|
|
261
|
+
if (g?.type === 'arc') {
|
|
262
|
+
const rad = (d) => (d * Math.PI) / 180;
|
|
263
|
+
const pt = (d) => ({ x: g.cx + g.rx * Math.cos(rad(d)), y: g.cy + g.ry * Math.sin(rad(d)) });
|
|
264
|
+
const tan = (d, sign) => ({ x: -g.rx * Math.sin(rad(d)) * sign, y: g.ry * Math.cos(rad(d)) * sign });
|
|
265
|
+
const sign = g.sweep >= 0 ? 1 : -1;
|
|
266
|
+
return { start: { at: pt(g.start), dir: tan(g.start, -sign) }, end: { at: pt(g.start + g.sweep), dir: tan(g.start + g.sweep, sign) } };
|
|
267
|
+
}
|
|
268
|
+
let pts = null;
|
|
269
|
+
if (g?.type === 'spline' && !g.closed) pts = g.points;
|
|
270
|
+
else if (g?.type === 'path' && !(g.commands ?? []).some((c) => c?.op === 'Z')) {
|
|
271
|
+
pts = [];
|
|
272
|
+
for (const c of g.commands ?? []) {
|
|
273
|
+
if (c?.op === 'C' && Number.isFinite(c.x2)) pts.push({ x: c.x2, y: c.y2 });
|
|
274
|
+
else if (c?.op === 'Q' && Number.isFinite(c.x1)) pts.push({ x: c.x1, y: c.y1 });
|
|
275
|
+
if (Number.isFinite(c?.x) && Number.isFinite(c?.y)) pts.push({ x: c.x, y: c.y });
|
|
276
|
+
}
|
|
277
|
+
} else if (!g && Array.isArray(e?.points)) pts = e.points;
|
|
278
|
+
if (!pts || pts.length < 2) return null;
|
|
279
|
+
return { start: seg(pts[1], pts[0]), end: seg(pts.at(-2), pts.at(-1)) };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* A waterfall's total columns when the reader named none: the first bar, and
|
|
284
|
+
* every later bar whose value is the running sum of the steps before it (17.8,
|
|
285
|
+
* +0.9, +0.3 → 19.0) — an identity in the data, not a guess from the drawing.
|
|
286
|
+
* The rest are steps.
|
|
287
|
+
*/
|
|
288
|
+
export function waterfallSubtotals(values) {
|
|
289
|
+
const out = [0];
|
|
290
|
+
let run = values[0] ?? 0;
|
|
291
|
+
for (let i = 1; i < values.length; i++) {
|
|
292
|
+
const v = values[i];
|
|
293
|
+
if (Math.abs(v - run) <= Math.max(1e-6, Math.abs(run) * 0.005)) out.push(i);
|
|
294
|
+
else run += v;
|
|
295
|
+
}
|
|
296
|
+
return out;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* A waterfall as native stacked columns, the construction PowerPoint users
|
|
301
|
+
* build by hand and fully editable (ChartEx waterfalls are Phase B: lint refuses
|
|
302
|
+
* authoring them, and LibreOffice renders none). The reader names the totals
|
|
303
|
+
* (`data.totals`): they stand on the axis at their own value. Every other bar is
|
|
304
|
+
* a step that starts where the bar before it ends, left to right; a run with no
|
|
305
|
+
* total before it starts at 0. A total that is not where the steps arrive is
|
|
306
|
+
* built as declared and reported in `mismatches`, never moved.
|
|
307
|
+
*
|
|
308
|
+
* Series in stack order (`index` names them):
|
|
309
|
+
* base invisible; lifts a bar to its end nearer the axis. A bar wholly
|
|
310
|
+
* under the axis hangs from it; one that crosses the axis has none;
|
|
311
|
+
* bars the visible bar (its part above the axis when it crosses);
|
|
312
|
+
* below the part under the axis of a bar that crosses it (only if any);
|
|
313
|
+
* change invisible, as tall as each step's change, on top of the step: a
|
|
314
|
+
* label at its base sits just above the bar and reads the change —
|
|
315
|
+
* stacked columns cannot set a label beyond a bar's end. Only for the
|
|
316
|
+
* steps in `labelled`;
|
|
317
|
+
* connectors one line per gap at the level the earlier bar ends, when the
|
|
318
|
+
* image draws them.
|
|
319
|
+
*/
|
|
320
|
+
export function waterfallAsStacked(data, { labelled = [] } = {}) {
|
|
321
|
+
const values = data.series[0].values;
|
|
322
|
+
const declared = Array.isArray(data.totals) && data.totals.length > 0;
|
|
323
|
+
const totals = new Set(declared ? data.totals : waterfallSubtotals(values));
|
|
324
|
+
const decimals = Math.max(0, ...values.map((v) => (String(v).split('.')[1] ?? '').length));
|
|
325
|
+
const tolerance = Math.max(1e-9, 0.5 * 10 ** -Math.min(decimals, 6));
|
|
326
|
+
const base = [], bars = [], below = [], ends = [], mismatches = [];
|
|
327
|
+
let run = 0;
|
|
328
|
+
let stepsSince = false;
|
|
329
|
+
values.forEach((v, i) => {
|
|
330
|
+
let lo, hi, end;
|
|
331
|
+
if (totals.has(i)) {
|
|
332
|
+
if (stepsSince && Math.abs(run - v) > tolerance) mismatches.push({ index: i, running: Number(run.toFixed(Math.min(decimals, 6))), total: v });
|
|
333
|
+
lo = Math.min(0, v); hi = Math.max(0, v); end = v; stepsSince = false;
|
|
334
|
+
} else {
|
|
335
|
+
end = run + v; lo = Math.min(run, end); hi = Math.max(run, end); stepsSince = true;
|
|
336
|
+
}
|
|
337
|
+
run = end;
|
|
338
|
+
ends.push(end);
|
|
339
|
+
if (lo >= 0) { base.push(lo); bars.push(hi - lo); below.push(null); }
|
|
340
|
+
else if (hi <= 0) { base.push(hi); bars.push(lo - hi); below.push(null); }
|
|
341
|
+
else { base.push(0); bars.push(hi); below.push(lo); }
|
|
342
|
+
});
|
|
343
|
+
const round = (x) => (x == null ? null : Number(x.toFixed(Math.min(decimals + 2, 10))));
|
|
344
|
+
const series = [
|
|
345
|
+
{ name: 'Waterfall base', values: base.map(round), grouping: 'stacked' },
|
|
346
|
+
{ name: data.series[0].name, values: bars.map(round), grouping: 'stacked' },
|
|
347
|
+
];
|
|
348
|
+
const index = { base: 0, bars: 1, below: null, change: null, connectors: [] };
|
|
349
|
+
if (below.some((x) => x != null)) {
|
|
350
|
+
index.below = series.push({ name: 'Below axis', values: below.map(round), grouping: 'stacked' }) - 1;
|
|
351
|
+
}
|
|
352
|
+
const steps = new Set(labelled.filter((i) => !totals.has(i)));
|
|
353
|
+
if (steps.size) {
|
|
354
|
+
index.change = series.push({ name: 'Change label', values: values.map((v, i) => (steps.has(i) ? round(Math.abs(v)) : null)), grouping: 'stacked' }) - 1;
|
|
355
|
+
}
|
|
356
|
+
if (data.connectors) {
|
|
357
|
+
for (let i = 0; i < values.length - 1; i++) {
|
|
358
|
+
const line = values.map(() => null);
|
|
359
|
+
line[i] = line[i + 1] = round(ends[i]);
|
|
360
|
+
index.connectors.push(series.push({ name: `Connector ${i + 1}`, values: line, kind: 'line' }) - 1);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
data: { categories: data.categories, series },
|
|
365
|
+
index,
|
|
366
|
+
totals: [...totals].sort((a, b) => a - b),
|
|
367
|
+
declared,
|
|
368
|
+
mismatches,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// What image intent each concept mockup was generated FROM.
|
|
2
|
+
//
|
|
3
|
+
// <workspace>/state/concept-intent.json
|
|
4
|
+
// { "version": 1, "slides": { "3": { "fingerprint": "<sha256>" } } }
|
|
5
|
+
//
|
|
6
|
+
// A concept PNG on disk is reused as-is by `slidemind concepts` — that fast
|
|
7
|
+
// path is what keeps a timed-out harness from regenerating a deck's mockups
|
|
8
|
+
// (and it is load-bearing; see the comment at its call site). But a mockup made
|
|
9
|
+
// before the user attached a screenshot, or before they changed what the
|
|
10
|
+
// screenshot is FOR, no longer depicts the plan it is supposed to depict, and
|
|
11
|
+
// reusing it silently carries that mistake all the way into the .pptx.
|
|
12
|
+
//
|
|
13
|
+
// So each slide's concept output records the canonical fingerprint of the
|
|
14
|
+
// intent it was made under (design decision 6: instruction, crop, fit, use set
|
|
15
|
+
// and source digests count; display names and workspace paths do not). On the
|
|
16
|
+
// next run the fingerprints are recomputed and compared:
|
|
17
|
+
//
|
|
18
|
+
// * a slide with no uses and no entry is a LEGACY concept — valid, reused,
|
|
19
|
+
// nothing recorded;
|
|
20
|
+
// * a slide that has uses but no entry is stale (the mockup predates them);
|
|
21
|
+
// * a changed fingerprint is stale;
|
|
22
|
+
// * an entry whose slide no longer has uses is stale too — the picture was
|
|
23
|
+
// generated with an image the plan has since dropped.
|
|
24
|
+
//
|
|
25
|
+
// Stale means STALE, not regenerated behind the user's back: the command
|
|
26
|
+
// deletes exactly the affected derived outputs (that slide's PNG and its spec)
|
|
27
|
+
// and lets its normal generation path refill them. Nothing here reads, writes
|
|
28
|
+
// or knows about slide JSX — a hand-edited slide is never touched by a concept
|
|
29
|
+
// being out of date.
|
|
30
|
+
|
|
31
|
+
import fs from "node:fs";
|
|
32
|
+
import path from "node:path";
|
|
33
|
+
|
|
34
|
+
import { statePath } from "../lib/deck-paths.mjs";
|
|
35
|
+
|
|
36
|
+
export const CONCEPT_INTENT_FILE = "concept-intent.json";
|
|
37
|
+
const VERSION = 1;
|
|
38
|
+
|
|
39
|
+
export function conceptIntentPath(workspaceDir) {
|
|
40
|
+
return statePath(workspaceDir, CONCEPT_INTENT_FILE);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The recorded fingerprints, `{ [slide: number]: string }`.
|
|
45
|
+
*
|
|
46
|
+
* An absent or unreadable file is an empty record on purpose: this sidecar is
|
|
47
|
+
* cache bookkeeping, and the failure it can cause — regenerating a mockup that
|
|
48
|
+
* did not need it — is the harmless direction. Every other image-intent file
|
|
49
|
+
* refuses to be read loosely, because losing THOSE loses an obligation.
|
|
50
|
+
*/
|
|
51
|
+
export function readConceptIntent(workspaceDir) {
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(fs.readFileSync(conceptIntentPath(workspaceDir), "utf8"));
|
|
55
|
+
} catch {
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
if (!parsed || parsed.version !== VERSION || typeof parsed.slides !== "object") return {};
|
|
59
|
+
const out = {};
|
|
60
|
+
for (const [key, entry] of Object.entries(parsed.slides ?? {})) {
|
|
61
|
+
const slide = Number(key);
|
|
62
|
+
const fingerprint = entry?.fingerprint;
|
|
63
|
+
if (Number.isInteger(slide) && slide > 0 && typeof fingerprint === "string") {
|
|
64
|
+
out[slide] = fingerprint;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function write(workspaceDir, slides) {
|
|
71
|
+
const file = conceptIntentPath(workspaceDir);
|
|
72
|
+
const numbers = Object.keys(slides)
|
|
73
|
+
.map(Number)
|
|
74
|
+
.sort((a, b) => a - b);
|
|
75
|
+
if (!numbers.length) {
|
|
76
|
+
fs.rmSync(file, { force: true });
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const doc = { version: VERSION, slides: {} };
|
|
80
|
+
for (const slide of numbers) doc.slides[slide] = { fingerprint: slides[slide] };
|
|
81
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
82
|
+
// Write-then-rename, like every other deck sidecar: a reader sees the old
|
|
83
|
+
// record or the new one, never half of either.
|
|
84
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
85
|
+
fs.writeFileSync(tmp, JSON.stringify(doc, null, 2) + "\n");
|
|
86
|
+
fs.renameSync(tmp, file);
|
|
87
|
+
return file;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Record what the named slides were generated under.
|
|
92
|
+
*
|
|
93
|
+
* `fingerprints` is a `Map<slide, string|null>` (or a plain object). A null
|
|
94
|
+
* fingerprint means the slide has no image uses, so its entry is REMOVED —
|
|
95
|
+
* "nothing recorded" is exactly what a legacy no-intent concept looks like, and
|
|
96
|
+
* it is what makes the two states round-trip. Slides the map does not mention
|
|
97
|
+
* keep whatever they had.
|
|
98
|
+
*/
|
|
99
|
+
export function recordConceptIntent(workspaceDir, fingerprints) {
|
|
100
|
+
const next = readConceptIntent(workspaceDir);
|
|
101
|
+
for (const [slide, fingerprint] of entriesOf(fingerprints)) {
|
|
102
|
+
if (fingerprint) next[slide] = fingerprint;
|
|
103
|
+
else delete next[slide];
|
|
104
|
+
}
|
|
105
|
+
return write(workspaceDir, next);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The slides whose concept output no longer matches the intent it was made
|
|
110
|
+
* under, in ascending order.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} workspaceDir
|
|
113
|
+
* @param {Map<number, string|null>|Record<number, string|null>} fingerprints
|
|
114
|
+
* the intent fingerprint of each slide NOW (null = no image uses).
|
|
115
|
+
*/
|
|
116
|
+
export function staleConceptSlides(workspaceDir, fingerprints) {
|
|
117
|
+
const recorded = readConceptIntent(workspaceDir);
|
|
118
|
+
const stale = [];
|
|
119
|
+
for (const [slide, fingerprint] of entriesOf(fingerprints)) {
|
|
120
|
+
if ((recorded[slide] ?? null) !== (fingerprint ?? null)) stale.push(slide);
|
|
121
|
+
}
|
|
122
|
+
return stale.sort((a, b) => a - b);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Forget every slide the plan no longer has (same sweep that prunes its PNG). */
|
|
126
|
+
export function pruneConceptIntent(workspaceDir, keepSlides) {
|
|
127
|
+
const keep = new Set([...(keepSlides ?? [])].map(Number));
|
|
128
|
+
const recorded = readConceptIntent(workspaceDir);
|
|
129
|
+
const next = {};
|
|
130
|
+
for (const [slide, fingerprint] of Object.entries(recorded)) {
|
|
131
|
+
if (keep.has(Number(slide))) next[slide] = fingerprint;
|
|
132
|
+
}
|
|
133
|
+
if (Object.keys(next).length === Object.keys(recorded).length) return null;
|
|
134
|
+
return write(workspaceDir, next);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function* entriesOf(fingerprints) {
|
|
138
|
+
const entries =
|
|
139
|
+
fingerprints instanceof Map ? fingerprints.entries() : Object.entries(fingerprints ?? {});
|
|
140
|
+
for (const [slide, fingerprint] of entries) {
|
|
141
|
+
const n = Number(slide);
|
|
142
|
+
if (Number.isInteger(n) && n > 0) yield [n, fingerprint ?? null];
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// What each mockup was made WITH beyond its pixels, as the concept route
|
|
147
|
+
// returned it: the slide it continues (only when generation applied the plan's
|
|
148
|
+
// link — another layout, a frame or a failed predecessor drops it) and the
|
|
149
|
+
// approved image uses a user's own blueprint mockup does not show.
|
|
150
|
+
//
|
|
151
|
+
// <workspace>/state/concept-notes.json { "4": { "continues": 3 } }
|
|
152
|
+
//
|
|
153
|
+
// Cache bookkeeping like the record above: unreadable reads as empty.
|
|
154
|
+
export const CONCEPT_NOTES_FILE = "concept-notes.json";
|
|
155
|
+
|
|
156
|
+
export function readConceptNotes(workspaceDir) {
|
|
157
|
+
try {
|
|
158
|
+
const parsed = JSON.parse(fs.readFileSync(statePath(workspaceDir, CONCEPT_NOTES_FILE), "utf8"));
|
|
159
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
160
|
+
} catch {
|
|
161
|
+
return {};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Record the notes of the mockups just written; every other slide keeps its own. */
|
|
166
|
+
export function recordConceptNotes(workspaceDir, images, keepSlides) {
|
|
167
|
+
const keep = new Set([...(keepSlides ?? [])].map(Number));
|
|
168
|
+
const notes = Object.fromEntries(Object.entries(readConceptNotes(workspaceDir)).filter(([s]) => keep.has(Number(s))));
|
|
169
|
+
for (const img of images) {
|
|
170
|
+
const n = Number(img?.slideNumber);
|
|
171
|
+
if (!Number.isInteger(n) || n < 1) continue;
|
|
172
|
+
const note = {
|
|
173
|
+
...(Number.isInteger(img.continues) ? { continues: img.continues } : {}),
|
|
174
|
+
...(img.pendingUseIds?.length ? { pendingUseIds: img.pendingUseIds.map(String) } : {}),
|
|
175
|
+
};
|
|
176
|
+
if (Object.keys(note).length) notes[n] = note;
|
|
177
|
+
else delete notes[n];
|
|
178
|
+
}
|
|
179
|
+
const file = statePath(workspaceDir, CONCEPT_NOTES_FILE);
|
|
180
|
+
if (!Object.keys(notes).length) return fs.rmSync(file, { force: true });
|
|
181
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
182
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
183
|
+
fs.writeFileSync(tmp, JSON.stringify(notes, null, 2) + "\n");
|
|
184
|
+
fs.renameSync(tmp, file);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The agent's lines for the mockups on disk — the add-in's concept intro, same words. */
|
|
188
|
+
export function conceptNoteLines(notes, slides) {
|
|
189
|
+
const on = [...slides].map(Number).sort((a, b) => a - b).filter((n) => notes[n]);
|
|
190
|
+
const continuing = on.filter((n) => Number.isInteger(notes[n].continues)).map((n) => `slide ${n} continues slide ${notes[n].continues}`);
|
|
191
|
+
const pending = on.filter((n) => notes[n].pendingUseIds?.length).map((n) => `slide ${n} (${notes[n].pendingUseIds.join(", ")})`);
|
|
192
|
+
return [
|
|
193
|
+
...(continuing.length
|
|
194
|
+
? [`Sequenced slides: ${continuing.join("; ")}. A continuing slide is its predecessor one step later — build it from the predecessor's JSX and change only what its own content changes.`]
|
|
195
|
+
: []),
|
|
196
|
+
...(pending.length
|
|
197
|
+
? [`These mockups are the user's own blueprint image for their slide and therefore show ONLY it: ${pending.join("; ")}. The approved image uses named in brackets are not in the mockup and are yours to compose — check IMAGE USES, and never treat the mockup as the finished slide.`]
|
|
198
|
+
: []),
|
|
199
|
+
];
|
|
200
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Geometry contracts shared by normalization, transcription and settling.
|
|
2
|
+
import { cleanText, textIsPrinted } from './concept-text.mjs';
|
|
3
|
+
|
|
4
|
+
// Text rotation describes an UNROTATED frame, centred on the observed rect.
|
|
5
|
+
export function textFrame(e) {
|
|
6
|
+
if (!e) return {};
|
|
7
|
+
const rotation = Number.isFinite(e.textRotation) ? e.textRotation : 0;
|
|
8
|
+
if (e.textFrame && ['x', 'y', 'w', 'h'].every(k => Number.isFinite(e.textFrame[k])) && e.textFrame.w > 0 && e.textFrame.h > 0) {
|
|
9
|
+
return { ...e, ...e.textFrame, rotation };
|
|
10
|
+
}
|
|
11
|
+
const a = Math.abs(rotation % 180) * Math.PI / 180;
|
|
12
|
+
const c = Math.abs(Math.cos(a)), s = Math.abs(Math.sin(a));
|
|
13
|
+
const determinant = c * c - s * s;
|
|
14
|
+
if (!rotation || Math.abs(determinant) < 0.01) return { ...e, rotation };
|
|
15
|
+
const w = (c * e.w - s * e.h) / determinant;
|
|
16
|
+
const h = (c * e.h - s * e.w) / determinant;
|
|
17
|
+
if (!(w > 0 && h > 0)) return { ...e, rotation };
|
|
18
|
+
return { ...e, x: e.x + (e.w - w) / 2, y: e.y + (e.h - h) / 2, w, h, rotation };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function fixedAspect(e) {
|
|
22
|
+
return e.shape === 'circle' || e.shape === 'ellipse' || e.shape === 'ring' || e.kind === 'disc' || e.geometry != null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const CARRIER_KINDS = new Set(['panel', 'card', 'disc', 'badge', 'shape']);
|
|
26
|
+
|
|
27
|
+
// An icon or picture the reader put ON a carrier (a disc, badge or tile) is
|
|
28
|
+
// seated there: the writer nests it in the carrier, whose flexbox centres it,
|
|
29
|
+
// so the pair exports centred and grouped, and moves as one — two absolute
|
|
30
|
+
// boxes drifted 0.5–3px apart. The relation is the reader's declared `parent`,
|
|
31
|
+
// never inferred from geometry (bb782591); the rect only checks that the asset
|
|
32
|
+
// sits in the carrier's middle. Ids in `skip` (drawn as lines, figures) take
|
|
33
|
+
// part in neither role; an asset a line glues to (`glued`) or that holds
|
|
34
|
+
// children of its own stays where it was drawn.
|
|
35
|
+
// Returns Map(carrier id → asset).
|
|
36
|
+
export function seatedAssets(elements, { skip = new Set(), glued = new Set() } = {}) {
|
|
37
|
+
const byId = new Map(elements.map(e => [e.id, e]));
|
|
38
|
+
const childCount = new Map();
|
|
39
|
+
for (const e of elements) if (e.parent) childCount.set(e.parent, (childCount.get(e.parent) ?? 0) + 1);
|
|
40
|
+
const painted = p => (p.fill && p.fill !== 'none') || p.shape === 'ring'
|
|
41
|
+
|| (p.stroke && !(Number.isFinite(p.stroke.width) && p.stroke.width <= 0));
|
|
42
|
+
const seats = new Map();
|
|
43
|
+
for (const a of elements) {
|
|
44
|
+
if (!['icon', 'image'].includes(a.kind) || a.image?.assetUseId || skip.has(a.id) || glued.has(a.id) || childCount.has(a.id)) continue;
|
|
45
|
+
const p = byId.get(a.parent);
|
|
46
|
+
if (!p || skip.has(p.id) || !CARRIER_KINDS.has(p.kind) || !painted(p) || cleanText(p.text, { printed: textIsPrinted(p) }) != null) continue;
|
|
47
|
+
if (childCount.get(p.id) !== 1 || !(p.w > 0 && p.h > 0)) continue;
|
|
48
|
+
// A seat the reader declared (seat.mjs) is the relation; the rect check
|
|
49
|
+
// stands in only where none was declared.
|
|
50
|
+
const declared = a.seat?.x === 'center' && a.seat?.y === 'middle';
|
|
51
|
+
const dx = a.x + a.w / 2 - (p.x + p.w / 2), dy = a.y + a.h / 2 - (p.y + p.h / 2);
|
|
52
|
+
if (!declared && (Math.abs(dx) > p.w / 4 || Math.abs(dy) > p.h / 4)) continue;
|
|
53
|
+
seats.set(p.id, a);
|
|
54
|
+
}
|
|
55
|
+
return seats;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Paint containers before their content even when the reader interleaves a
|
|
59
|
+
// foreground label with a later, unrelated background. Stable within a layer.
|
|
60
|
+
// Native charts own their marks and are foreground, just like images/text.
|
|
61
|
+
// A shape that carries its own label (a card header bar, a pill) is a shape:
|
|
62
|
+
// its words ride on it, so it keeps the reader's order among the shapes. As
|
|
63
|
+
// foreground it painted over every shape the reader drew after it — the disc
|
|
64
|
+
// that straddles a header bar came out half covered (x113 PM 2, x112 GTM 2,
|
|
65
|
+
// x109 QBR 6, x108 cloud 1: 14 flips in x108–x113, every one of them a disc
|
|
66
|
+
// or chevron buried under a labelled shape the reader drew earlier).
|
|
67
|
+
export function paintOrdered(elements) {
|
|
68
|
+
const foreground = e => ['text', 'number', 'icon', 'image', 'chart', 'table'].includes(e.kind);
|
|
69
|
+
const byId = new Map(elements.map(e => [e.id, e]));
|
|
70
|
+
const pending = [...elements], placed = new Set(), ordered = [];
|
|
71
|
+
while (pending.length) {
|
|
72
|
+
const eligible = pending.filter(e => !byId.has(e.parent) || placed.has(e.parent));
|
|
73
|
+
// A container's own label does not make its children go behind it.
|
|
74
|
+
// Parent-before-child is a dependency, not a comparator exception.
|
|
75
|
+
const next = eligible.find(e => !foreground(e)) ?? eligible[0] ?? pending[0];
|
|
76
|
+
ordered.push(next); placed.add(next.id); pending.splice(pending.indexOf(next), 1);
|
|
77
|
+
}
|
|
78
|
+
return ordered;
|
|
79
|
+
}
|