slidemind 0.3.20 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +592 -16
- package/LICENSE +4 -0
- package/README.md +203 -8
- package/THIRD-PARTY-NOTICES.md +201 -0
- package/bin/slidemind.mjs +50 -2
- package/package.json +5 -3
- package/skills/create-slides/SKILL.md +484 -886
- package/skills/search/SKILL.md +46 -23
- package/src/bridge/build-and-push.mjs +548 -73
- package/src/bridge/bundle.mjs +100 -31
- package/src/bridge/capture.mjs +14 -17
- package/src/bridge/client/brands.mjs +157 -18
- package/src/bridge/client/engine-routing.mjs +48 -13
- package/src/bridge/client/engine-search.mjs +24 -1
- package/src/bridge/client/render.mjs +27 -1
- package/src/bridge/client/transport.mjs +499 -23
- package/src/bridge/client.mjs +10 -0
- package/src/bridge/config.mjs +67 -6
- package/src/bridge/engine-download.mjs +0 -10
- package/src/bridge/graph-auth.mjs +55 -3
- package/src/bridge/image-intent-envelope.mjs +239 -0
- package/src/bridge/package-preview.mjs +67 -0
- package/src/bridge/sharepoint.mjs +914 -0
- package/src/builders/asset-fetch.mjs +203 -0
- package/src/builders/block-dividers.mjs +83 -0
- package/src/builders/build.mjs +171 -87
- package/src/builders/card-height.mjs +294 -0
- package/src/builders/chart-hole.mjs +183 -0
- package/src/builders/chart-jsx.mjs +410 -0
- package/src/builders/chart-labels.mjs +326 -0
- package/src/builders/concept-geometry.mjs +370 -0
- package/src/builders/concept-intent.mjs +200 -0
- package/src/builders/concept-layout.mjs +79 -0
- package/src/builders/concept-lines.mjs +509 -0
- package/src/builders/concept-lists.mjs +270 -0
- package/src/builders/concept-marks.mjs +84 -0
- package/src/builders/concept-relationships.mjs +12 -0
- package/src/builders/concept-spec.mjs +645 -0
- package/src/builders/concept-text.mjs +140 -0
- package/src/builders/concept-waves.mjs +57 -0
- package/src/builders/copy-fit.mjs +139 -0
- package/src/builders/design-tokens.mjs +347 -0
- package/src/builders/draft-check.mjs +552 -340
- package/src/builders/draft-lines.mjs +369 -0
- package/src/builders/edit-route.mjs +29 -0
- package/src/builders/figure-sketch.mjs +375 -0
- package/src/builders/fold-sets.mjs +639 -0
- package/src/builders/gate-server.mjs +9 -4
- package/src/builders/gate-session.js +273 -12
- package/src/builders/gate-shell.js +1494 -0
- package/src/builders/image-intent-check.mjs +308 -0
- package/src/builders/image-use-reconcile.mjs +107 -0
- package/src/builders/kit-helpers.mjs +121 -0
- package/src/builders/layout-names.mjs +32 -0
- package/src/builders/legacy-reference-uses.mjs +179 -0
- package/src/builders/line-fit.mjs +224 -0
- package/src/builders/line-native.mjs +895 -0
- package/src/builders/line-relations.mjs +230 -0
- package/src/builders/lint-fix.mjs +117 -9
- package/src/builders/lint.mjs +25 -20
- package/src/builders/list-jsx.mjs +88 -0
- package/src/builders/placement.mjs +133 -0
- package/src/builders/plan-checks.mjs +135 -0
- package/src/builders/plan-comments.mjs +19 -0
- package/src/builders/plan-handlers.mjs +95 -0
- package/src/builders/plan-markdown-edit.mjs +3 -3
- package/src/builders/plan-overlay.js +1530 -1608
- package/src/builders/plan.mjs +689 -186
- package/src/builders/preset-definitions.json +1 -0
- package/src/builders/preset-eval.mjs +244 -0
- package/src/builders/preview.html +51 -120
- package/src/builders/red-thread-backbone.mjs +135 -36
- package/src/builders/repair.mjs +205 -0
- package/src/builders/repeat-groups.mjs +540 -0
- package/src/builders/resolve-skill.mjs +5 -4
- package/src/builders/review-overlay.js +296 -293
- package/src/builders/review.mjs +305 -84
- package/src/builders/rule-shift.mjs +96 -0
- package/src/builders/seat.mjs +354 -0
- package/src/builders/settle-driver.mjs +236 -0
- package/src/builders/settle.mjs +1448 -0
- package/src/builders/slide-prep.mjs +67 -0
- package/src/builders/spec-assets.mjs +236 -0
- package/src/builders/spec-match.mjs +56 -0
- package/src/builders/stack.mjs +413 -0
- package/src/builders/storyboard-markdown.mjs +70 -27
- package/src/builders/storyboard-schema.mjs +54 -114
- package/src/builders/text-blocks.mjs +212 -0
- package/src/builders/text-card-fit.mjs +552 -0
- package/src/builders/text-stack.mjs +276 -0
- package/src/builders/transcribe.mjs +1069 -0
- package/src/cli/assets.mjs +164 -0
- package/src/cli/common.mjs +173 -12
- package/src/cli/concept-spec-support.mjs +76 -0
- package/src/cli/concepts.mjs +1002 -103
- package/src/cli/deck.mjs +401 -65
- package/src/cli/diagnose.mjs +4 -2
- package/src/cli/engine-helpers.mjs +17 -10
- package/src/cli/engine-results.mjs +76 -3
- package/src/cli/help.mjs +232 -28
- package/src/cli/install.mjs +9 -0
- package/src/cli/maintenance.mjs +104 -0
- package/src/cli/read.mjs +2 -1
- package/src/cli/ready.mjs +6 -3
- package/src/cli/record.mjs +522 -0
- package/src/cli/remote.mjs +818 -0
- package/src/cli/revision.mjs +686 -0
- package/src/cli/search.mjs +30 -11
- package/src/cli/session.mjs +56 -13
- package/src/cli/setup.mjs +46 -2
- package/src/cli/status.mjs +142 -33
- package/src/cli/tasks.mjs +246 -0
- package/src/cli/transcribe.mjs +854 -0
- package/src/cli/workspace-ui.mjs +260 -0
- package/src/cli/workspace.mjs +212 -34
- package/src/install-channel.mjs +64 -7
- package/src/lib/adopt.mjs +346 -25
- package/src/lib/concept-spec-binding.mjs +96 -0
- package/src/lib/conversions.mjs +70 -6
- package/src/lib/convert-folded.mjs +107 -0
- package/src/lib/deck-paths.mjs +418 -0
- package/src/lib/durable-ids.mjs +47 -0
- package/src/lib/edit-operations.mjs +316 -0
- package/src/lib/edit-state.mjs +1244 -0
- package/src/lib/edit-transitions.mjs +120 -0
- package/src/lib/extensions.mjs +501 -0
- package/src/lib/image-assets.mjs +465 -0
- package/src/lib/image-derivatives.mjs +234 -0
- package/src/lib/image-intent-fingerprint.mjs +70 -0
- package/src/lib/image-intent-tags.mjs +176 -0
- package/src/lib/image-intent.mjs +802 -0
- package/src/lib/image-use-overrides.mjs +279 -0
- package/src/lib/inline-components.mjs +613 -0
- package/src/lib/lint-converted.mjs +68 -4
- package/src/lib/merge-json.mjs +163 -0
- package/src/lib/overwrite-guard.mjs +3 -3
- package/src/lib/presentation-context.mjs +531 -0
- package/src/lib/preservation-tags.mjs +157 -0
- package/src/lib/preservation-verdict.mjs +1342 -0
- package/src/lib/publish.mjs +1557 -0
- package/src/lib/rebase.mjs +945 -0
- package/src/lib/reconcile-ack.mjs +4 -6
- package/src/lib/reconcile.mjs +62 -24
- package/src/lib/record-client.mjs +175 -0
- package/src/lib/record-state.mjs +151 -0
- package/src/lib/remote.mjs +661 -0
- package/src/lib/revision.mjs +1210 -0
- package/src/lib/slide-guids.mjs +4 -6
- package/src/lib/slide-preservation.mjs +423 -0
- package/src/lib/slide-selection.mjs +9 -0
- package/src/lib/workspace-transaction.mjs +117 -0
- package/src/pptx/content-digest.mjs +334 -0
- package/src/pptx/deck-assemble.mjs +1278 -0
- package/src/pptx/fill/engine.mjs +2 -3
- package/src/pptx/fill/freeform.mjs +6 -3
- package/src/pptx/ooxml.mjs +20 -0
- package/src/pptx/package-evidence.mjs +383 -0
- package/src/pptx/tags.mjs +163 -11
- package/src/update-check.mjs +22 -4
- package/src/workspace/adapters.mjs +222 -0
- package/src/workspace/auth.mjs +256 -0
- package/src/workspace/autostart.mjs +204 -0
- package/src/workspace/daemon.mjs +360 -0
- package/src/workspace/discovery.mjs +180 -0
- package/src/workspace/edit.mjs +167 -0
- package/src/workspace/events.mjs +52 -0
- package/src/workspace/gate-client.mjs +290 -0
- package/src/workspace/handback.mjs +73 -0
- package/src/workspace/lifecycle.mjs +635 -0
- package/src/workspace/operations.mjs +200 -0
- package/src/workspace/pick-folder.mjs +152 -0
- package/src/workspace/registry.mjs +359 -0
- package/src/workspace/remote-links.mjs +397 -0
- package/src/workspace/remote-status.mjs +359 -0
- package/src/workspace/routes.mjs +1331 -0
- package/src/workspace/server.mjs +159 -0
- package/src/workspace/sync.mjs +1119 -0
- package/src/workspace/task-actions.mjs +215 -0
- package/src/workspace/tasks.mjs +507 -0
- package/src/workspace/ui/app.js +1886 -0
- package/src/workspace/ui/index.html +141 -0
- package/src/workspace/ui/plan-bridge.js +120 -0
- package/src/workspace/ui/sharepoint.svg +59 -0
- package/src/workspace/ui/styles.css +423 -0
- package/src/builders/visual-families.mjs +0 -46
|
@@ -0,0 +1,1342 @@
|
|
|
1
|
+
// Did the slide keep its promises? — the two comparisons behind the verdict.
|
|
2
|
+
//
|
|
3
|
+
// Contract from `openspec/changes/safeguard-slide-editing/implementation-guide.md`
|
|
4
|
+
// section 5 ("Preservation and render verification"), task 5.3. Mirrored
|
|
5
|
+
// byte-for-byte into the add-in (`scripts/sync-transcription-runtime.mjs`,
|
|
6
|
+
// group `edit-contracts`), because a preservation check that runs in only one
|
|
7
|
+
// client is not a safeguard — it is a local opinion.
|
|
8
|
+
//
|
|
9
|
+
// Two complementary comparisons, exactly as the guide asks:
|
|
10
|
+
//
|
|
11
|
+
// 1. compareSlideSource() — source/IR. What the edit INTENDED: which
|
|
12
|
+
// elements were added, removed or had a
|
|
13
|
+
// property changed, plus every construct the
|
|
14
|
+
// reader could not statically resolve. This is
|
|
15
|
+
// the half that can prove a property was NOT
|
|
16
|
+
// touched, where text layout has normal
|
|
17
|
+
// rendering variability.
|
|
18
|
+
// 2. compareRenderEvidence() — rendered IR. Did the object actually SURVIVE
|
|
19
|
+
// comparePackageEvidence() rendering and splicing? "Visible nonzero
|
|
20
|
+
// bounds and endpoints matter; an element in
|
|
21
|
+
// source but absent in output is not success."
|
|
22
|
+
//
|
|
23
|
+
// Three rules the shape of this module exists to keep:
|
|
24
|
+
//
|
|
25
|
+
// • **Three outcomes, never two.** Every check answers `passed`, `failed`,
|
|
26
|
+
// `unknown` or `unsupported` (`EVIDENCE_STATUSES`). Missing evidence is
|
|
27
|
+
// `unknown` and rolls the verdict up to `unknown`; it never reads as a pass.
|
|
28
|
+
// An element the render says nothing about is not an element that survived.
|
|
29
|
+
// • **No ZIP byte streams.** Package timestamps, relationship ids and part
|
|
30
|
+
// ordering legitimately change on every save. What is compared here is the
|
|
31
|
+
// NORMALIZED projection of a slide (`computeProjection`, src/pptx/tags.mjs)
|
|
32
|
+
// and the RESOLVED targets of its relationships — never bytes of the
|
|
33
|
+
// container. Native binary payloads are the exception that proves it: their
|
|
34
|
+
// digests must hold, because nothing but the user editing that object in
|
|
35
|
+
// PowerPoint may change them.
|
|
36
|
+
// • **The verdict carries its binding context.** sourceHash, liveRevision,
|
|
37
|
+
// operationId and requirementSetHash travel with it, so a later phase can
|
|
38
|
+
// say "this verdict was about a slide that no longer exists" instead of
|
|
39
|
+
// trusting it (`verdictIsStale`).
|
|
40
|
+
//
|
|
41
|
+
// Everything here is pure: no disk, no network, no mutation of an argument and
|
|
42
|
+
// no throw. The caller computes the projections (the CLI through
|
|
43
|
+
// `src/pptx/tags.mjs`, the add-in through its own reader) and hands them in —
|
|
44
|
+
// that dependency injection is what lets one comparison serve both clients
|
|
45
|
+
// without this file importing either client's package machinery.
|
|
46
|
+
|
|
47
|
+
import { createHash } from "node:crypto";
|
|
48
|
+
|
|
49
|
+
import { PROPERTY_SCOPES } from "./edit-operations.mjs";
|
|
50
|
+
import { EVIDENCE_STATUSES } from "./slide-preservation.mjs";
|
|
51
|
+
import { isDurableId } from "./durable-ids.mjs";
|
|
52
|
+
import { inlineComponents } from "./inline-components.mjs";
|
|
53
|
+
|
|
54
|
+
/** Schema version of the verdict this module returns. */
|
|
55
|
+
export const PRESERVATION_VERDICT_VERSION = 1;
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Numeric tolerances
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
//
|
|
61
|
+
// Named, exported and justified — a magic number inline is a tolerance nobody
|
|
62
|
+
// can argue with later. The guide's rule: reuse the renderer's own units and
|
|
63
|
+
// rounding where they exist, and otherwise allow at most one OOXML EMU of
|
|
64
|
+
// quantization for an exact transform.
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The quantization the structural projection ALREADY applies to every EMU
|
|
68
|
+
* coordinate (`roundEmu` in src/pptx/tags.mjs and its TypeScript twin:
|
|
69
|
+
* `Math.round(n / 100) * 100`). It is the renderer's own rounding, so it is
|
|
70
|
+
* reused rather than re-derived — which is why the tolerance ON TOP of it is
|
|
71
|
+
* zero. Worst-case error of the pair is therefore one quantum, 100 EMU
|
|
72
|
+
* ≈ 0.0079 pt ≈ 1/9600 inch: far below anything a PowerPoint user can nudge.
|
|
73
|
+
*/
|
|
74
|
+
export const PROJECTION_QUANTUM_EMU = 100;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Slack allowed on an already-quantized EMU coordinate: none. Two renders of an
|
|
78
|
+
* untouched transform land in the same 100-EMU bucket, so any difference at all
|
|
79
|
+
* is a real move — and accepting "one bucket either way" would silently permit
|
|
80
|
+
* a 200 EMU drift per pass, which accumulates across the repair budget's three
|
|
81
|
+
* passes.
|
|
82
|
+
*/
|
|
83
|
+
export const TRANSFORM_TOLERANCE_EMU = 0;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Slack on a RENDERED rect, in slot px. Both clients measure with the same
|
|
87
|
+
* pinned Chrome (AGENTS.md: identical Chromium ⇒ identical text measurement),
|
|
88
|
+
* so this absorbs `getBoundingClientRect`'s double-precision noise, not layout
|
|
89
|
+
* differences. Half a CSS px is under one device pixel at 1x — smaller than
|
|
90
|
+
* anything that can paint differently.
|
|
91
|
+
*/
|
|
92
|
+
export const RENDER_GEOMETRY_TOLERANCE_PX = 0.5;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The smallest rendered extent that can still paint, in slot px. Below it the
|
|
96
|
+
* element is hidden however present it is in the source. Chosen at half a CSS
|
|
97
|
+
* px for the same reason as above: a genuine hairline rule (1px) stays above
|
|
98
|
+
* it, a collapsed box (0px) does not.
|
|
99
|
+
*/
|
|
100
|
+
export const RENDER_MIN_VISIBLE_PX = 0.5;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The smallest opacity that still reads as visible. 1% is below the JPEG-noise
|
|
104
|
+
* floor of any capture; a value beneath it is an element that was hidden, not
|
|
105
|
+
* an element that was faint.
|
|
106
|
+
*/
|
|
107
|
+
export const RENDER_MIN_VISIBLE_OPACITY = 0.01;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* How far past the canvas edge a rect may sit before it counts as off-canvas,
|
|
111
|
+
* in slot px. One px: a shape flush with the edge rounds outwards, a shape
|
|
112
|
+
* parked outside the slide does not.
|
|
113
|
+
*/
|
|
114
|
+
export const OFF_CANVAS_SLACK_PX = 1;
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Small shared helpers
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
const isPlainObject = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
|
|
121
|
+
const isNonEmptyString = (v) => typeof v === "string" && v.trim().length > 0;
|
|
122
|
+
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
|
123
|
+
|
|
124
|
+
/** Stable JSON: object keys sorted at every depth, so a hash is order-free. */
|
|
125
|
+
function canonical(value) {
|
|
126
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
127
|
+
if (isPlainObject(value)) {
|
|
128
|
+
return `{${Object.keys(value)
|
|
129
|
+
.sort()
|
|
130
|
+
.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`)
|
|
131
|
+
.join(",")}}`;
|
|
132
|
+
}
|
|
133
|
+
return JSON.stringify(value ?? null);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const sha256 = (s) => createHash("sha256").update(s).digest("hex");
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// 1. Source / IR comparison
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
//
|
|
142
|
+
// A very small JSX reader. Both dialects the product writes are regular:
|
|
143
|
+
// transcribed helpers (`<B id="…" x={44} …>`) and the converted dialect
|
|
144
|
+
// (`<div data-sm-source-id={10} style={{…}}>`). This reads the tag tree, buckets
|
|
145
|
+
// each element's attributes into the SAME property scopes the operation and
|
|
146
|
+
// preservation records speak (`PROPERTY_SCOPES`), and refuses to guess about
|
|
147
|
+
// anything it cannot resolve statically.
|
|
148
|
+
//
|
|
149
|
+
// ponytail: this scanner is a near-twin of the unexported one in
|
|
150
|
+
// src/lib/lint-converted.mjs. It is copied rather than imported because this
|
|
151
|
+
// module is byte-mirrored into the add-in and may only import its own group;
|
|
152
|
+
// lift lint-converted's reader onto this file once that module is free to
|
|
153
|
+
// depend on a mirrored one.
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Where the string, template literal or comment opening at `i` ends (the index
|
|
157
|
+
* of its last character), or `i` itself when none opens there. Comments and
|
|
158
|
+
* template literals exist only inside an expression (`inExpr`, i.e. within
|
|
159
|
+
* `{…}`); at depth 0 a quote is an attribute string.
|
|
160
|
+
*
|
|
161
|
+
* A quote that never closes is not a string — a valid file has none — so it is
|
|
162
|
+
* a stray one the reader failed to see past (an apostrophe in a construct this
|
|
163
|
+
* reader does not model) and reads as one character. Read as a string instead,
|
|
164
|
+
* it swallows the rest of the file and every later element reads as removed.
|
|
165
|
+
*/
|
|
166
|
+
function literalEnd(src, i, inExpr) {
|
|
167
|
+
const c = src[i];
|
|
168
|
+
if (inExpr && c === "/" && src[i + 1] === "/") {
|
|
169
|
+
const nl = src.indexOf("\n", i);
|
|
170
|
+
return nl < 0 ? src.length - 1 : nl;
|
|
171
|
+
}
|
|
172
|
+
if (inExpr && c === "/" && src[i + 1] === "*") {
|
|
173
|
+
const close = src.indexOf("*/", i + 2);
|
|
174
|
+
return close < 0 ? i : close + 1;
|
|
175
|
+
}
|
|
176
|
+
if (c === '"' || c === "'" || (inExpr && c === "`")) {
|
|
177
|
+
for (let k = i + 1; k < src.length; k++) {
|
|
178
|
+
if (inExpr && src[k] === "\\") k++;
|
|
179
|
+
else if (src[k] === c) return k;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return i;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function* scanTags(src) {
|
|
186
|
+
for (let i = 0; i < src.length; i++) {
|
|
187
|
+
if (src[i] !== "<") continue;
|
|
188
|
+
const head = /^<(\/?)([A-Za-z][\w.:-]*)/.exec(src.slice(i, i + 64));
|
|
189
|
+
if (!head) continue;
|
|
190
|
+
let j = i + head[0].length;
|
|
191
|
+
let depth = 0;
|
|
192
|
+
for (; j < src.length; j++) {
|
|
193
|
+
const c = src[j];
|
|
194
|
+
const end = literalEnd(src, j, depth > 0);
|
|
195
|
+
if (end !== j) {
|
|
196
|
+
j = end;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (c === "{") depth++;
|
|
200
|
+
else if (c === "}") depth--;
|
|
201
|
+
else if (c === ">" && depth === 0) break;
|
|
202
|
+
}
|
|
203
|
+
const raw = src.slice(i, j + 1);
|
|
204
|
+
yield { close: head[1] === "/", name: head[2], raw, selfClose: raw.endsWith("/>"), start: i, end: j + 1 };
|
|
205
|
+
i = j;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Tag tree. Each node keeps its open tag, its content span and its children. */
|
|
210
|
+
function parseJsx(src) {
|
|
211
|
+
const root = { name: "#root", raw: "", start: 0, contentStart: 0, contentEnd: src.length, end: src.length, children: [] };
|
|
212
|
+
const stack = [root];
|
|
213
|
+
for (const t of scanTags(src)) {
|
|
214
|
+
if (t.close) {
|
|
215
|
+
if (stack.length > 1) {
|
|
216
|
+
const node = stack.pop();
|
|
217
|
+
node.contentEnd = t.start;
|
|
218
|
+
node.end = t.end;
|
|
219
|
+
}
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const node = {
|
|
223
|
+
name: t.name,
|
|
224
|
+
raw: t.raw,
|
|
225
|
+
start: t.start,
|
|
226
|
+
contentStart: t.end,
|
|
227
|
+
contentEnd: t.end,
|
|
228
|
+
end: t.end,
|
|
229
|
+
children: [],
|
|
230
|
+
};
|
|
231
|
+
stack[stack.length - 1].children.push(node);
|
|
232
|
+
if (!t.selfClose) stack.push(node);
|
|
233
|
+
}
|
|
234
|
+
// An unclosed tag runs to the end of the file; the reader reports it rather
|
|
235
|
+
// than silently re-parenting what follows.
|
|
236
|
+
while (stack.length > 1) {
|
|
237
|
+
const node = stack.pop();
|
|
238
|
+
node.contentEnd = src.length;
|
|
239
|
+
node.end = src.length;
|
|
240
|
+
node.unclosed = true;
|
|
241
|
+
}
|
|
242
|
+
return root;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Attributes of one open tag: `{ name: "quoted string" | { expr } | true }`.
|
|
247
|
+
* Brace values are read depth-aware, so `style={{ a: "b" }}` is one value.
|
|
248
|
+
*/
|
|
249
|
+
function attrsOf(raw) {
|
|
250
|
+
const out = {};
|
|
251
|
+
const body = raw.replace(/^<[A-Za-z][\w.:-]*/, "").replace(/\/?>$/, "");
|
|
252
|
+
let i = 0;
|
|
253
|
+
while (i < body.length) {
|
|
254
|
+
while (i < body.length && /\s/.test(body[i])) i++;
|
|
255
|
+
const name = /^[A-Za-z_][\w:.-]*/.exec(body.slice(i))?.[0];
|
|
256
|
+
if (!name) {
|
|
257
|
+
i += 1;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
i += name.length;
|
|
261
|
+
let j = i;
|
|
262
|
+
while (j < body.length && /\s/.test(body[j])) j++;
|
|
263
|
+
if (body[j] !== "=") {
|
|
264
|
+
out[name] = true; // valueless flag: `nowrap`, `data-connector`
|
|
265
|
+
i = j;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
j += 1;
|
|
269
|
+
while (j < body.length && /\s/.test(body[j])) j++;
|
|
270
|
+
const opener = body[j];
|
|
271
|
+
if (opener === '"' || opener === "'") {
|
|
272
|
+
const end = body.indexOf(opener, j + 1);
|
|
273
|
+
out[name] = end < 0 ? body.slice(j + 1) : body.slice(j + 1, end);
|
|
274
|
+
i = end < 0 ? body.length : end + 1;
|
|
275
|
+
} else if (opener === "{") {
|
|
276
|
+
let depth = 0;
|
|
277
|
+
let k = j;
|
|
278
|
+
for (; k < body.length; k++) {
|
|
279
|
+
const c = body[k];
|
|
280
|
+
const end = literalEnd(body, k, depth > 0);
|
|
281
|
+
if (end !== k) {
|
|
282
|
+
k = end;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (c === "{") depth += 1;
|
|
286
|
+
else if (c === "}") {
|
|
287
|
+
depth -= 1;
|
|
288
|
+
if (depth === 0) break;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
out[name] = { expr: body.slice(j + 1, k).trim() };
|
|
292
|
+
i = k + 1;
|
|
293
|
+
} else {
|
|
294
|
+
i = j; // `name=` with nothing after it — malformed, skipped
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* An expression this reader will not pretend to understand: a call, a template
|
|
302
|
+
* literal, a spread, an arrow, a ternary or a boolean operator. The add-in's
|
|
303
|
+
* own gate says the same thing about computed style values — unreadable is a
|
|
304
|
+
* reported state, not an error and not a pass.
|
|
305
|
+
*/
|
|
306
|
+
const UNREADABLE_EXPR = /[(`]|\.\.\.|=>|\?|&&|\|\|/;
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* String literals (a template literal without `${` is one) and comments. They
|
|
310
|
+
* are stripped BEFORE `UNREADABLE_EXPR` is tested: a `(` inside
|
|
311
|
+
* `"var(--x)"` or `"polygon(…)"` is part of a value, not a call.
|
|
312
|
+
*/
|
|
313
|
+
const LITERAL_OR_COMMENT =
|
|
314
|
+
/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\$]|\\.|\$(?!\{))*`|\/\/[^\n]*|\/\*[\s\S]*?\*\//g;
|
|
315
|
+
|
|
316
|
+
/** Words that ARE their own value, as opposed to naming one somewhere else. */
|
|
317
|
+
const LITERAL_WORDS = new Set(["true", "false", "null", "undefined"]);
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* An expression is readable when every word in it is a literal or an object
|
|
321
|
+
* key. A bare identifier (`x={col * 96}`, `src={m}`) names a value this reader
|
|
322
|
+
* has no access to — it is reported, never resolved and never assumed equal.
|
|
323
|
+
*/
|
|
324
|
+
function exprIsUnreadable(expr) {
|
|
325
|
+
const stripped = String(expr ?? "").replace(LITERAL_OR_COMMENT, (m) => (m[0] === "/" ? " " : '""'));
|
|
326
|
+
if (UNREADABLE_EXPR.test(stripped)) return true;
|
|
327
|
+
for (const m of stripped.matchAll(/[A-Za-z_$][\w$]*/g)) {
|
|
328
|
+
if (LITERAL_WORDS.has(m[0])) continue;
|
|
329
|
+
if (/^\s*:/.test(stripped.slice(m.index + m[0].length))) continue;
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const isUnreadable = (value) => isPlainObject(value) && exprIsUnreadable(value.expr);
|
|
336
|
+
|
|
337
|
+
const attrText = (value) => {
|
|
338
|
+
if (value === true) return "true";
|
|
339
|
+
if (isPlainObject(value)) return `{${value.expr}}`;
|
|
340
|
+
return String(value ?? "");
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const ENTITIES = { "<": "<", ">": ">", """: '"', "'": "'", " ": " ", "&": "&" };
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Readable text of a source fragment: JSX string expressions become their
|
|
347
|
+
* value, every other expression contributes nothing (it is reported as
|
|
348
|
+
* unreadable elsewhere), entities are decoded once and whitespace collapses.
|
|
349
|
+
*/
|
|
350
|
+
function cleanText(raw) {
|
|
351
|
+
return String(raw ?? "")
|
|
352
|
+
.replace(/\{\s*"((?:[^"\\]|\\.)*)"\s*\}/g, (_, q) => {
|
|
353
|
+
try {
|
|
354
|
+
return JSON.parse(`"${q}"`);
|
|
355
|
+
} catch {
|
|
356
|
+
return q;
|
|
357
|
+
}
|
|
358
|
+
})
|
|
359
|
+
.replace(/\{[^{}]*\}/g, "")
|
|
360
|
+
.replace(/&(?:lt|gt|quot|apos|nbsp|amp);/g, (m) => ENTITIES[m])
|
|
361
|
+
.replace(/\s+/g, " ")
|
|
362
|
+
.trim();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* The element's OWN text: its direct text plus that of inline children which
|
|
367
|
+
* carry no identity of their own (`<b>`, `<span>`). A child element with an id
|
|
368
|
+
* is a separate obligation and its text belongs to it, not to its parent.
|
|
369
|
+
*/
|
|
370
|
+
function ownText(node, src) {
|
|
371
|
+
let out = "";
|
|
372
|
+
let cursor = node.contentStart;
|
|
373
|
+
for (const child of node.children) {
|
|
374
|
+
out += src.slice(cursor, child.start);
|
|
375
|
+
if (!elementIdOf(child)) out += ownText(child, src);
|
|
376
|
+
cursor = child.end;
|
|
377
|
+
}
|
|
378
|
+
return cleanText(out + src.slice(cursor, node.contentEnd));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Attribute → property scope. The scope vocabulary is the operation record's
|
|
383
|
+
* (`PROPERTY_SCOPES`), which is itself the structural projection's own field
|
|
384
|
+
* set — so a scope always names something the render evidence can compare.
|
|
385
|
+
* Anything absent from both tables lands in `other`, and a change there is
|
|
386
|
+
* reported as `unknown` rather than ignored.
|
|
387
|
+
*/
|
|
388
|
+
const ATTR_SCOPE = {
|
|
389
|
+
x: "geom", y: "geom", w: "geom", h: "geom", rot: "geom", transform: "geom",
|
|
390
|
+
fill: "fill", bg: "fill",
|
|
391
|
+
stroke: "line", head: "line",
|
|
392
|
+
// A native line directive's own geometry and ends (nativeLinesV1).
|
|
393
|
+
"data-prst": "line", "data-adj": "line", "data-rot": "line", "data-flip": "line",
|
|
394
|
+
"data-from-site": "line", "data-to-site": "line", "data-cap-start": "line", "data-cap-end": "line",
|
|
395
|
+
"data-dash": "line", "data-linecap": "line",
|
|
396
|
+
src: "media", fit: "media", imgFit: "media", "data-asset-use": "media", "data-sm-src-crop": "media",
|
|
397
|
+
"data-sm-chart": "chart",
|
|
398
|
+
"data-sm-native-object": "native", "data-sm-preserved": "native",
|
|
399
|
+
px: "text", role: "text", align: "text", nowrap: "text", bold: "text",
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const STYLE_SCOPE = {
|
|
403
|
+
left: "geom", top: "geom", width: "geom", height: "geom", position: "geom",
|
|
404
|
+
transform: "geom", clipPath: "geom", "clip-path": "geom",
|
|
405
|
+
background: "fill", backgroundColor: "fill", backgroundImage: "fill", opacity: "fill",
|
|
406
|
+
border: "line", borderTop: "line", borderRight: "line", borderBottom: "line", borderLeft: "line",
|
|
407
|
+
borderWidth: "line", borderStyle: "line", borderColor: "line", fill: "fill",
|
|
408
|
+
outline: "line", borderRadius: "line", boxShadow: "line",
|
|
409
|
+
color: "text", fontSize: "text", fontWeight: "text", fontFamily: "text",
|
|
410
|
+
fontStyle: "text", lineHeight: "text", textAlign: "text", letterSpacing: "text",
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Ids that name a slide element, in the two dialects the product writes. An id
|
|
415
|
+
* bound to a name (`<div id={id}>`) is a helper's template, stamped with a real
|
|
416
|
+
* id at each call site: it names no element of its own.
|
|
417
|
+
*/
|
|
418
|
+
function elementIdOf(node) {
|
|
419
|
+
const attrs = node.attrs ?? (node.attrs = attrsOf(node.raw));
|
|
420
|
+
const id = attrs.id;
|
|
421
|
+
if (typeof id === "string" && id.trim()) return id.trim();
|
|
422
|
+
const literal = isPlainObject(id) && /^(?:(["'`])([\w.:-]+)\1|(\d+))$/.exec(id.expr);
|
|
423
|
+
if (literal) return literal[2] ?? literal[3];
|
|
424
|
+
// The converted dialect's handle on a source shape. Normalized to the bare
|
|
425
|
+
// number string, which is exactly the `p:cNvPr/@id` the package carries — so
|
|
426
|
+
// a requirement, the source and the projection all spell it one way.
|
|
427
|
+
const src = attrs["data-sm-source-id"];
|
|
428
|
+
const raw = isPlainObject(src) ? src.expr : src;
|
|
429
|
+
if (typeof raw === "string" && /^["']?\d+["']?$/.test(raw.trim())) return raw.trim().replace(/["']/g, "");
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** `#node-a` → `node-a`; a bare id stays as it is. */
|
|
434
|
+
const refTarget = (value) => String(attrText(value)).replace(/^\{?["']?#?/, "").replace(/["']?\}?$/, "").trim();
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Read one slide's source into an element inventory.
|
|
438
|
+
*
|
|
439
|
+
* @param {string} jsx
|
|
440
|
+
* @returns {{
|
|
441
|
+
* elements: Array<object>,
|
|
442
|
+
* byId: Map<string, object>,
|
|
443
|
+
* unsupported: Array<{elementId: string|null, line: number, construct: string, reason: string}>,
|
|
444
|
+
* ambiguous: Array<{elementId: string|null, line: number, reason: string}>,
|
|
445
|
+
* }}
|
|
446
|
+
*/
|
|
447
|
+
export function readSlideElements(jsx) {
|
|
448
|
+
// A set component's members are read where they are drawn: each call site
|
|
449
|
+
// inlined with its literal props, on its own lines (inline-components.mjs).
|
|
450
|
+
const inlined = inlineComponents(String(jsx ?? ""));
|
|
451
|
+
const src = inlined.src;
|
|
452
|
+
const root = parseJsx(src);
|
|
453
|
+
const lineAt = (offset) => src.slice(0, offset).split("\n").length;
|
|
454
|
+
const elements = [];
|
|
455
|
+
const byId = new Map();
|
|
456
|
+
const unsupported = inlined.unreadable.map((u) => ({
|
|
457
|
+
elementId: null,
|
|
458
|
+
line: u.line,
|
|
459
|
+
construct: `<${u.component}>`,
|
|
460
|
+
reason: "unreadable_component",
|
|
461
|
+
}));
|
|
462
|
+
const ambiguous = [];
|
|
463
|
+
|
|
464
|
+
// `wrapperFrame`: the text frame that follows an id-less wrapper around one
|
|
465
|
+
// identified child (see `textFrameAfter`), handed down to that child.
|
|
466
|
+
const visit = (node, insideIdentified, wrapperFrame = null) => {
|
|
467
|
+
node.children.forEach((child, i) => {
|
|
468
|
+
if (child.unclosed) {
|
|
469
|
+
unsupported.push({
|
|
470
|
+
elementId: elementIdOf(child),
|
|
471
|
+
line: lineAt(child.start),
|
|
472
|
+
construct: `<${child.name}>`,
|
|
473
|
+
reason: "unclosed_element",
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
const elementId = elementIdOf(child);
|
|
477
|
+
const attrs = child.attrs ?? (child.attrs = attrsOf(child.raw));
|
|
478
|
+
if (elementId) {
|
|
479
|
+
const frame = textFrameAfter(node.children[i + 1]) ?? wrapperFrame;
|
|
480
|
+
const entry = describeElement(child, elementId, attrs, src, lineAt, frame);
|
|
481
|
+
for (const note of entry.unreadable) {
|
|
482
|
+
unsupported.push({ elementId, line: entry.line, construct: note, reason: "unreadable_expression" });
|
|
483
|
+
}
|
|
484
|
+
if (byId.has(elementId)) {
|
|
485
|
+
ambiguous.push({ elementId, line: entry.line, reason: "duplicate_element_id" });
|
|
486
|
+
} else {
|
|
487
|
+
byId.set(elementId, entry);
|
|
488
|
+
}
|
|
489
|
+
elements.push(entry);
|
|
490
|
+
} else if (isSlideElement(child, attrs) && !insideIdentified) {
|
|
491
|
+
// An element that paints but names nothing, and sits under nothing that
|
|
492
|
+
// names it either, cannot carry an obligation. Surfaced, never matched
|
|
493
|
+
// by shape — the guide asks for ambiguity to be reported rather than
|
|
494
|
+
// resolved with a confident guess. (A `<Chart>` inside `<B id="…">` is
|
|
495
|
+
// not ambiguous: the JSX itself says whose chart it is, and
|
|
496
|
+
// `describeElement` folds its attributes into that parent.)
|
|
497
|
+
ambiguous.push({ elementId: null, line: lineAt(child.start), reason: `unidentified_${child.name}` });
|
|
498
|
+
}
|
|
499
|
+
const wraps = !elementId && child.children.length === 1 && elementIdOf(child.children[0]);
|
|
500
|
+
visit(child, insideIdentified || Boolean(elementId), wraps ? textFrameAfter(node.children[i + 1]) : null);
|
|
501
|
+
});
|
|
502
|
+
};
|
|
503
|
+
visit(root, false);
|
|
504
|
+
|
|
505
|
+
// List rendering multiplies one source element into many rendered ones, so a
|
|
506
|
+
// per-element source comparison cannot speak about what is inside it.
|
|
507
|
+
for (const m of src.matchAll(/\.map\s*\(/g)) {
|
|
508
|
+
unsupported.push({ elementId: null, line: lineAt(m.index), construct: ".map(", reason: "list_rendering" });
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return { elements, byId, unsupported, ambiguous };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Bind concept requirements to the ids the transcriber actually writes. One
|
|
516
|
+
* spec element becomes several when it is drawn as a multi-trace line or a
|
|
517
|
+
* mark's dots — `<id>-1`, `<id>-2`, … (src/builders/transcribe.mjs,
|
|
518
|
+
* concept-lines.mjs) — so a requirement seeded on the spec id names an element
|
|
519
|
+
* the source never has, and fails `removed_from_source` on untouched output.
|
|
520
|
+
* Such a requirement is replaced by one per piece, owing the same.
|
|
521
|
+
*
|
|
522
|
+
* Unchanged: a requirement whose own id is in the source, one with no pieces
|
|
523
|
+
* there, and any piece id that already has a requirement of its own.
|
|
524
|
+
*/
|
|
525
|
+
export function bindSplitIds(requirements, jsx) {
|
|
526
|
+
const present = [...readSlideElements(jsx).byId.keys()];
|
|
527
|
+
const owned = new Set(requirements.map((r) => r?.elementId));
|
|
528
|
+
const out = [];
|
|
529
|
+
for (const req of requirements) {
|
|
530
|
+
const id = req?.elementId;
|
|
531
|
+
const pieces =
|
|
532
|
+
req?.kind === "element" && typeof id === "string" && !present.includes(id)
|
|
533
|
+
? present.filter((p) => p.startsWith(`${id}-`) && /^\d+$/.test(p.slice(id.length + 1)) && !owned.has(p))
|
|
534
|
+
: [];
|
|
535
|
+
if (pieces.length === 0) {
|
|
536
|
+
out.push(req);
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
for (const piece of pieces) {
|
|
540
|
+
out.push({ ...req, requirementId: `${req.requirementId}${piece.slice(id.length)}`, elementId: piece });
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return out;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* The converted dialect writes a filled shape as TWO siblings: its paint
|
|
548
|
+
* element carries `data-sm-source-id`, its text frame (`data-sm-text-frame`)
|
|
549
|
+
* does not — exactly one of them declares the id (reverse/codegen.ts,
|
|
550
|
+
* `sourceIdAttr`). That frame's text is the shape's text. A non-rect shape's
|
|
551
|
+
* paint is an `<svg>` inside an id-less wrapper `<div>`, and the frame follows
|
|
552
|
+
* the WRAPPER (reverse/codegen-svg.ts), so `readSlideElements` hands a
|
|
553
|
+
* wrapper's frame down to the one identified child it wraps.
|
|
554
|
+
*/
|
|
555
|
+
function textFrameAfter(next) {
|
|
556
|
+
if (!next || elementIdOf(next)) return null;
|
|
557
|
+
return next.attrs["data-sm-text-frame"] !== undefined ? next : null;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Does this tag paint something on the slide (as opposed to being a wrapper)? */
|
|
561
|
+
function isSlideElement(node, attrs) {
|
|
562
|
+
if (/^(B|T|I|R|L|Chart|img|table)$/.test(node.name)) return true;
|
|
563
|
+
return attrs["data-connector"] !== undefined || attrs["data-sm-native-object"] !== undefined;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** One element's per-scope fingerprints, relationships and unreadable parts. */
|
|
567
|
+
function describeElement(node, elementId, attrs, src, lineAt, frame = null) {
|
|
568
|
+
const buckets = Object.fromEntries([...PROPERTY_SCOPES, "other"].map((s) => [s, []]));
|
|
569
|
+
const relationships = [];
|
|
570
|
+
const unreadable = [];
|
|
571
|
+
let kind = node.name;
|
|
572
|
+
|
|
573
|
+
// The element's own attributes, plus those of every descendant that carries
|
|
574
|
+
// no identity of its own — an unnamed `<Chart>` or `<b>` is part of THIS
|
|
575
|
+
// obligation, not a second one.
|
|
576
|
+
const absorb = (n, own) => {
|
|
577
|
+
const a = n.attrs ?? (n.attrs = attrsOf(n.raw));
|
|
578
|
+
const prefix = own ? "" : `${n.name}.`;
|
|
579
|
+
for (const [name, value] of Object.entries(a)) {
|
|
580
|
+
if (name === "id" || name === "data-sm-source-id" || name === "key") continue;
|
|
581
|
+
if (name === "data-from" || name === "data-to") {
|
|
582
|
+
relationships.push({ role: name === "data-from" ? "start" : "end", target: refTarget(value) });
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
if (isUnreadable(value)) unreadable.push(`${prefix}${name}={${value.expr}}`);
|
|
586
|
+
if (name === "style") {
|
|
587
|
+
// The style object is split per property, so a geometry change and a
|
|
588
|
+
// colour change in the same attribute do not read as one opaque diff.
|
|
589
|
+
for (const [prop, raw] of styleEntries(value)) {
|
|
590
|
+
(buckets[STYLE_SCOPE[prop]] ?? buckets.other).push(`${prefix}style.${prop}=${raw}`);
|
|
591
|
+
}
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
// `type`/`data` mean a chart only on a chart element; elsewhere they are
|
|
595
|
+
// ordinary attributes and must not be mistaken for chart evidence.
|
|
596
|
+
const scope = n.name === "Chart" && (name === "type" || name === "data") ? "chart" : ATTR_SCOPE[name];
|
|
597
|
+
(buckets[scope] ?? buckets.other).push(`${prefix}${name}=${attrText(value)}`);
|
|
598
|
+
}
|
|
599
|
+
if (n.name === "Chart" || a["data-sm-chart"] !== undefined) kind = "chart";
|
|
600
|
+
for (const child of n.children) if (!elementIdOf(child)) absorb(child, false);
|
|
601
|
+
};
|
|
602
|
+
absorb(node, true);
|
|
603
|
+
|
|
604
|
+
const text = [ownText(node, src), frame ? ownText(frame, src) : ""].filter(Boolean).join(" ");
|
|
605
|
+
if (text) buckets.text.push(`#text=${text}`);
|
|
606
|
+
|
|
607
|
+
const scopes = {};
|
|
608
|
+
for (const scope of [...PROPERTY_SCOPES, "other"]) {
|
|
609
|
+
scopes[scope] = buckets[scope].length ? buckets[scope].sort().join("\u001f") : null;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
return {
|
|
613
|
+
elementId,
|
|
614
|
+
tag: node.name,
|
|
615
|
+
line: lineAt(node.start),
|
|
616
|
+
kind,
|
|
617
|
+
scopes,
|
|
618
|
+
text,
|
|
619
|
+
relationships,
|
|
620
|
+
unreadable,
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** `{{ left: "66px", top: "36px" }}` → [["left", '"66px"'], …]. */
|
|
625
|
+
function* styleEntries(value) {
|
|
626
|
+
const expr = isPlainObject(value) ? value.expr : String(value ?? "");
|
|
627
|
+
const inner = expr.replace(/^\{/, "").replace(/\}$/, "");
|
|
628
|
+
for (const m of inner.matchAll(/([A-Za-z-][\w-]*)\s*:\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^,}]+)/g)) {
|
|
629
|
+
yield [m[1], m[2].trim()];
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Compare two versions of one slide's source.
|
|
635
|
+
*
|
|
636
|
+
* `changes` is what the edit INTENDED, keyed to the operation record's own
|
|
637
|
+
* property scopes. `unknown` holds elements whose only difference is in an
|
|
638
|
+
* attribute this reader does not model — seen, unattributable, and therefore
|
|
639
|
+
* never a silent pass. `unsupported` and `ambiguous` carry everything the
|
|
640
|
+
* reader refused to resolve.
|
|
641
|
+
*
|
|
642
|
+
* @param {string} before
|
|
643
|
+
* @param {string} after
|
|
644
|
+
*/
|
|
645
|
+
export function compareSlideSource(before, after) {
|
|
646
|
+
const a = readSlideElements(before);
|
|
647
|
+
const b = readSlideElements(after);
|
|
648
|
+
const changes = [];
|
|
649
|
+
const unknown = [];
|
|
650
|
+
|
|
651
|
+
const ids = new Set([...a.byId.keys(), ...b.byId.keys()]);
|
|
652
|
+
for (const elementId of ids) {
|
|
653
|
+
const prev = a.byId.get(elementId);
|
|
654
|
+
const next = b.byId.get(elementId);
|
|
655
|
+
if (!prev) {
|
|
656
|
+
changes.push({
|
|
657
|
+
elementId,
|
|
658
|
+
action: "add",
|
|
659
|
+
properties: PROPERTY_SCOPES.filter((s) => next.scopes[s] != null),
|
|
660
|
+
relationships: next.relationships,
|
|
661
|
+
});
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
if (!next) {
|
|
665
|
+
changes.push({
|
|
666
|
+
elementId,
|
|
667
|
+
action: "remove",
|
|
668
|
+
properties: PROPERTY_SCOPES.filter((s) => prev.scopes[s] != null),
|
|
669
|
+
relationships: prev.relationships,
|
|
670
|
+
});
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
const properties = PROPERTY_SCOPES.filter((s) => prev.scopes[s] !== next.scopes[s]);
|
|
674
|
+
const relationshipsChanged = canonical(prev.relationships) !== canonical(next.relationships);
|
|
675
|
+
if (prev.scopes.other !== next.scopes.other) {
|
|
676
|
+
unknown.push({
|
|
677
|
+
elementId,
|
|
678
|
+
reason: "unmodelled_attribute_change",
|
|
679
|
+
detail: "an attribute outside the property-scope vocabulary changed; the scope of this edit cannot be named",
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
if (properties.length || relationshipsChanged) {
|
|
683
|
+
changes.push({
|
|
684
|
+
elementId,
|
|
685
|
+
action: "update",
|
|
686
|
+
properties,
|
|
687
|
+
...(relationshipsChanged ? { relationships: { before: prev.relationships, after: next.relationships } } : {}),
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
changes.sort((x, y) => String(x.elementId).localeCompare(String(y.elementId)));
|
|
693
|
+
return {
|
|
694
|
+
changes,
|
|
695
|
+
unknown,
|
|
696
|
+
unsupported: [...a.unsupported, ...b.unsupported],
|
|
697
|
+
ambiguous: [...a.ambiguous, ...b.ambiguous],
|
|
698
|
+
before: a,
|
|
699
|
+
after: b,
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// ---------------------------------------------------------------------------
|
|
704
|
+
// 2a. Rendered-IR comparison — did the object actually paint?
|
|
705
|
+
// ---------------------------------------------------------------------------
|
|
706
|
+
|
|
707
|
+
/** Reasons an element is present in the render tree but cannot be seen. */
|
|
708
|
+
export const HIDDEN_REASONS = [
|
|
709
|
+
"hidden:display-none",
|
|
710
|
+
"hidden:zero-size",
|
|
711
|
+
"hidden:zero-opacity",
|
|
712
|
+
"hidden:clipped",
|
|
713
|
+
"hidden:off-canvas",
|
|
714
|
+
];
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Is this clip-path empty? Returns true (clips everything), false (leaves area)
|
|
718
|
+
* or null (this reader cannot tell — the caller reports `unsupported`, never a
|
|
719
|
+
* pass). `polygon()` is resolved by area rather than refused, because the
|
|
720
|
+
* transcriber writes chevrons and trapezoids that way on ordinary slides.
|
|
721
|
+
*/
|
|
722
|
+
function clipHidesEverything(clipPath) {
|
|
723
|
+
const value = String(clipPath ?? "").trim();
|
|
724
|
+
if (!value || value === "none") return false;
|
|
725
|
+
const inset = /^inset\(\s*([^)]*?)\s*(?:round[^)]*)?\)$/.exec(value);
|
|
726
|
+
if (inset) {
|
|
727
|
+
const parts = inset[1].trim().split(/\s+/).map((p) => percent(p));
|
|
728
|
+
if (parts.some((p) => p === null)) return null;
|
|
729
|
+
const [t, r = t, b = t, l = r] = parts;
|
|
730
|
+
return t + b >= 100 || l + r >= 100;
|
|
731
|
+
}
|
|
732
|
+
const circle = /^circle\(\s*([^\s)]+)/.exec(value);
|
|
733
|
+
if (circle) {
|
|
734
|
+
const radius = percent(circle[1]) ?? num(Number.parseFloat(circle[1]));
|
|
735
|
+
return radius === null ? null : radius <= 0;
|
|
736
|
+
}
|
|
737
|
+
const polygon = /^polygon\(\s*([^)]*)\)$/.exec(value);
|
|
738
|
+
if (polygon) {
|
|
739
|
+
const points = [];
|
|
740
|
+
for (const pair of polygon[1].split(",")) {
|
|
741
|
+
const [px, py] = pair.trim().split(/\s+/).map((p) => percent(p));
|
|
742
|
+
if (px === null || py === null) return null;
|
|
743
|
+
points.push([px, py]);
|
|
744
|
+
}
|
|
745
|
+
if (points.length < 3) return true;
|
|
746
|
+
// Shoelace: a polygon with no area paints nothing.
|
|
747
|
+
let twiceArea = 0;
|
|
748
|
+
for (let i = 0; i < points.length; i++) {
|
|
749
|
+
const [x1, y1] = points[i];
|
|
750
|
+
const [x2, y2] = points[(i + 1) % points.length];
|
|
751
|
+
twiceArea += x1 * y2 - x2 * y1;
|
|
752
|
+
}
|
|
753
|
+
return Math.abs(twiceArea) / 2 < 1e-6;
|
|
754
|
+
}
|
|
755
|
+
return null;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** "50%" → 50, "0" → 0, "12px" → null (no basis to compare against). */
|
|
759
|
+
function percent(token) {
|
|
760
|
+
const t = String(token ?? "").trim();
|
|
761
|
+
if (/^-?\d+(\.\d+)?%$/.test(t)) return Number.parseFloat(t);
|
|
762
|
+
if (/^-?0(\.0+)?$/.test(t)) return 0;
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/** Which sides of a rect moved past the rendered-px tolerance. */
|
|
767
|
+
function rectDrift(want, got) {
|
|
768
|
+
if (!isPlainObject(want) || !isPlainObject(got)) return null;
|
|
769
|
+
return ["x", "y", "w", "h"].filter((k) => {
|
|
770
|
+
const a = num(want[k]);
|
|
771
|
+
const b = num(got[k]);
|
|
772
|
+
return a !== null && b !== null && Math.abs(b - a) > RENDER_GEOMETRY_TOLERANCE_PX;
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** Index a captured box tree by element identity, carrying inherited hiding. */
|
|
777
|
+
function indexRender(boxes, canvas) {
|
|
778
|
+
const byId = new Map();
|
|
779
|
+
let seen = 0;
|
|
780
|
+
const walk = (box, inherited) => {
|
|
781
|
+
if (!isPlainObject(box)) return;
|
|
782
|
+
seen += 1;
|
|
783
|
+
const local = boxHidden(box, canvas);
|
|
784
|
+
const state = inherited ?? local;
|
|
785
|
+
const id = isNonEmptyString(box.elementId)
|
|
786
|
+
? box.elementId
|
|
787
|
+
: box.smSourceId != null
|
|
788
|
+
? String(box.smSourceId)
|
|
789
|
+
: null;
|
|
790
|
+
if (id && !byId.has(id)) byId.set(id, { box, hidden: state });
|
|
791
|
+
for (const child of box.children ?? []) walk(child, state);
|
|
792
|
+
};
|
|
793
|
+
for (const box of Array.isArray(boxes) ? boxes : []) walk(box, null);
|
|
794
|
+
return { byId, seen };
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Why this box cannot be seen, or null. `{ unsupported: "…" }` when the reader
|
|
799
|
+
* will not decide — a clip it cannot resolve is not a clip that passed.
|
|
800
|
+
*/
|
|
801
|
+
function boxHidden(box, canvas) {
|
|
802
|
+
const style = isPlainObject(box.style) ? box.style : {};
|
|
803
|
+
if (style.display === "none") return { reason: "hidden:display-none" };
|
|
804
|
+
|
|
805
|
+
const rect = isPlainObject(box.rect) ? box.rect : null;
|
|
806
|
+
// A connector is measured from its endpoints, not from its own rect: the
|
|
807
|
+
// native `<p:cxnSp>` for a horizontal link legitimately has zero height.
|
|
808
|
+
const measuredByEndpoints = Boolean(box.connector);
|
|
809
|
+
if (rect && !measuredByEndpoints) {
|
|
810
|
+
const w = num(rect.w);
|
|
811
|
+
const h = num(rect.h);
|
|
812
|
+
if (w === null || h === null) return { unsupported: "rect_unreadable" };
|
|
813
|
+
if (w < RENDER_MIN_VISIBLE_PX || h < RENDER_MIN_VISIBLE_PX) return { reason: "hidden:zero-size" };
|
|
814
|
+
} else if (!rect && !measuredByEndpoints) {
|
|
815
|
+
return { unsupported: "rect_missing" };
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const opacity = num(style.opacity);
|
|
819
|
+
if (opacity !== null && opacity < RENDER_MIN_VISIBLE_OPACITY) return { reason: "hidden:zero-opacity" };
|
|
820
|
+
|
|
821
|
+
const clipped = clipHidesEverything(style.clipPath);
|
|
822
|
+
if (clipped === null) return { unsupported: "clip_path_unreadable" };
|
|
823
|
+
if (clipped) return { reason: "hidden:clipped" };
|
|
824
|
+
|
|
825
|
+
// A connector's rect is degenerate by construction (see above), so the
|
|
826
|
+
// off-canvas test cannot read it either: a zero-width link at x=0 has
|
|
827
|
+
// `right <= OFF_CANVAS_SLACK_PX` and would report hidden:off-canvas — a
|
|
828
|
+
// false deletion on a slide nobody touched.
|
|
829
|
+
if (canvas && rect && !measuredByEndpoints) {
|
|
830
|
+
const w = num(canvas.w);
|
|
831
|
+
const h = num(canvas.h);
|
|
832
|
+
if (w !== null && h !== null) {
|
|
833
|
+
const right = num(rect.x) + num(rect.w);
|
|
834
|
+
const bottom = num(rect.y) + num(rect.h);
|
|
835
|
+
const outside =
|
|
836
|
+
right <= OFF_CANVAS_SLACK_PX ||
|
|
837
|
+
bottom <= OFF_CANVAS_SLACK_PX ||
|
|
838
|
+
num(rect.x) >= w - OFF_CANVAS_SLACK_PX ||
|
|
839
|
+
num(rect.y) >= h - OFF_CANVAS_SLACK_PX;
|
|
840
|
+
if (outside) return { reason: "hidden:off-canvas" };
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
return null;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Prove each element survived the render.
|
|
848
|
+
*
|
|
849
|
+
* `render` is one captured slide (`{ boxes: [...] }` — the CapturedDeck slide
|
|
850
|
+
* both clients already produce). `elementIds` are the identities being asked
|
|
851
|
+
* about; each gets one of:
|
|
852
|
+
*
|
|
853
|
+
* passed — a box with that identity paints something visible
|
|
854
|
+
* failed — the render HAS evidence and the element is absent from it,
|
|
855
|
+
* or is present and hidden (zero-size, zero-opacity,
|
|
856
|
+
* display:none, fully clipped, off-canvas)
|
|
857
|
+
* unknown — there is no render evidence at all to speak from
|
|
858
|
+
* unsupported — the box is there but this reader cannot decide (an
|
|
859
|
+
* unresolvable clip-path, a missing rect)
|
|
860
|
+
*
|
|
861
|
+
* The failed case is the point of this function: an element present in the JSX
|
|
862
|
+
* and missing or hidden in the render must never read as preserved.
|
|
863
|
+
*
|
|
864
|
+
* `expectRects`, when given, additionally holds each element to the rect it
|
|
865
|
+
* occupied in an earlier capture, within `RENDER_GEOMETRY_TOLERANCE_PX`. That
|
|
866
|
+
* is the only place a rendered-px tolerance applies: EMU geometry is the
|
|
867
|
+
* package comparison's business, at its own quantization.
|
|
868
|
+
*
|
|
869
|
+
* @param {{boxes?: Array<object>}} render
|
|
870
|
+
* @param {{
|
|
871
|
+
* canvas?: {w:number,h:number}|null,
|
|
872
|
+
* elementIds?: Array<string>,
|
|
873
|
+
* expectRects?: Record<string, {x:number,y:number,w:number,h:number}>|null,
|
|
874
|
+
* }} [opts]
|
|
875
|
+
*/
|
|
876
|
+
export function compareRenderEvidence(render, { canvas = null, elementIds = [], expectRects = null } = {}) {
|
|
877
|
+
const boxes = isPlainObject(render) ? render.boxes : null;
|
|
878
|
+
const { byId, seen } = indexRender(boxes, canvas);
|
|
879
|
+
const hasEvidence = seen > 0;
|
|
880
|
+
const byIdOut = {};
|
|
881
|
+
|
|
882
|
+
for (const elementId of elementIds) {
|
|
883
|
+
if (!hasEvidence) {
|
|
884
|
+
byIdOut[elementId] = {
|
|
885
|
+
status: "unknown",
|
|
886
|
+
reason: "no_render_evidence",
|
|
887
|
+
detail: "the slide was not captured, so nothing can be said about what survived",
|
|
888
|
+
};
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
const found = byId.get(elementId);
|
|
892
|
+
if (!found) {
|
|
893
|
+
byIdOut[elementId] = {
|
|
894
|
+
status: "failed",
|
|
895
|
+
reason: "absent_from_render",
|
|
896
|
+
detail: "the source declares this element and the render does not contain it",
|
|
897
|
+
};
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
const hidden = found.hidden;
|
|
901
|
+
if (hidden?.unsupported) {
|
|
902
|
+
byIdOut[elementId] = { status: "unsupported", reason: hidden.unsupported, rect: found.box.rect ?? null };
|
|
903
|
+
continue;
|
|
904
|
+
}
|
|
905
|
+
if (hidden?.reason) {
|
|
906
|
+
byIdOut[elementId] = {
|
|
907
|
+
status: "failed",
|
|
908
|
+
reason: hidden.reason,
|
|
909
|
+
detail: "the element is in the render but paints nothing a viewer can see",
|
|
910
|
+
rect: found.box.rect ?? null,
|
|
911
|
+
};
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
const want = expectRects?.[elementId];
|
|
915
|
+
const drift = want ? rectDrift(want, found.box.rect) : null;
|
|
916
|
+
if (drift?.length) {
|
|
917
|
+
byIdOut[elementId] = {
|
|
918
|
+
status: "failed",
|
|
919
|
+
reason: "geometry_moved",
|
|
920
|
+
detail: `past ${RENDER_GEOMETRY_TOLERANCE_PX}px on: ${drift.join(", ")}`,
|
|
921
|
+
rect: found.box.rect ?? null,
|
|
922
|
+
};
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
byIdOut[elementId] = { status: "passed", rect: found.box.rect ?? null };
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
return { byId: byIdOut, evidence: hasEvidence ? "present" : "absent", indexed: [...byId.keys()] };
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// ---------------------------------------------------------------------------
|
|
932
|
+
// 2b. Final-package comparison — did it survive the splice, unchanged?
|
|
933
|
+
// ---------------------------------------------------------------------------
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Compare two NORMALIZED projections of one slide, keyed by element identity.
|
|
937
|
+
*
|
|
938
|
+
* Both arguments are `{ [elementId]: shape }`, where `shape` is a structural
|
|
939
|
+
* projection entry (`computeProjection`, src/pptx/tags.mjs, or the add-in's
|
|
940
|
+
* twin): `{ geom, text, fill, line, mediaSha256, chart }`. The caller supplies
|
|
941
|
+
* the keying, because only the caller knows how its package spells identity —
|
|
942
|
+
* an identity this module invented would be a confident guess, which the guide
|
|
943
|
+
* forbids. A `native` field may be attached per shape (`{ parts: { path: sha } }`)
|
|
944
|
+
* to carry an opaque object's dependency closure.
|
|
945
|
+
*
|
|
946
|
+
* ZIP bytes are deliberately NOT compared: timestamps, relationship ids and
|
|
947
|
+
* part ordering legitimately change on a save that changed nothing.
|
|
948
|
+
*
|
|
949
|
+
* @param {Record<string, object>} before
|
|
950
|
+
* @param {Record<string, object>} after
|
|
951
|
+
* @param {{resolvedTargets?: Array<string>|Set<string>|null}} [opts]
|
|
952
|
+
*/
|
|
953
|
+
export function comparePackageEvidence(before, after, { resolvedTargets = null } = {}) {
|
|
954
|
+
const a = isPlainObject(before) ? before : {};
|
|
955
|
+
const b = isPlainObject(after) ? after : {};
|
|
956
|
+
const targets = resolvedTargets == null ? null : new Set(resolvedTargets);
|
|
957
|
+
const byId = {};
|
|
958
|
+
|
|
959
|
+
for (const elementId of new Set([...Object.keys(a), ...Object.keys(b)])) {
|
|
960
|
+
const prev = a[elementId];
|
|
961
|
+
const next = b[elementId];
|
|
962
|
+
if (!isPlainObject(next)) {
|
|
963
|
+
byId[elementId] = {
|
|
964
|
+
status: "failed",
|
|
965
|
+
reason: "absent_from_package",
|
|
966
|
+
detail: "the element is not in the delivered package",
|
|
967
|
+
scopes: {},
|
|
968
|
+
};
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
if (!isPlainObject(prev)) {
|
|
972
|
+
byId[elementId] = {
|
|
973
|
+
status: "unknown",
|
|
974
|
+
reason: "no_package_baseline",
|
|
975
|
+
detail: "nothing to compare the delivered element against",
|
|
976
|
+
scopes: {},
|
|
977
|
+
};
|
|
978
|
+
continue;
|
|
979
|
+
}
|
|
980
|
+
const scopes = {
|
|
981
|
+
geom: compareGeom(prev.geom, next.geom),
|
|
982
|
+
text: sameOrChanged(canonical(prev.text ?? null), canonical(next.text ?? null)),
|
|
983
|
+
fill: sameOrChanged(prev.fill ?? null, next.fill ?? null),
|
|
984
|
+
line: sameOrChanged(prev.line ?? null, next.line ?? null),
|
|
985
|
+
media: compareDigest(prev.mediaSha256, next.mediaSha256, "media_digest_unavailable"),
|
|
986
|
+
// The CLI's projection cannot produce a chart hash (src/pptx/tags.mjs
|
|
987
|
+
// documents why: it would need the add-in's whole chart reader). Both
|
|
988
|
+
// sides null is therefore `unsupported`, never a pass.
|
|
989
|
+
chart: compareDigest(prev.chart, next.chart, "chart_hash_unavailable"),
|
|
990
|
+
native: compareNative(prev.native, next.native),
|
|
991
|
+
};
|
|
992
|
+
// The element-level status rolls up only the scopes the projection always
|
|
993
|
+
// models, plus the three payload scopes that are actually present on one
|
|
994
|
+
// side or the other. A text shape has no chart, so "no chart hash here" is
|
|
995
|
+
// not an unsupported CHECK — it is the absence of a thing to check. Where a
|
|
996
|
+
// payload really does exist and its digest is unavailable (the CLI's chart
|
|
997
|
+
// hash), the `unsupported` stays in `scopes` and reaches the verdict
|
|
998
|
+
// through the requirement that protects it.
|
|
999
|
+
const carried = [
|
|
1000
|
+
...["geom", "text", "fill", "line"],
|
|
1001
|
+
...(prev.mediaSha256 != null || next.mediaSha256 != null ? ["media"] : []),
|
|
1002
|
+
...(prev.chart != null || next.chart != null ? ["chart"] : []),
|
|
1003
|
+
...(isPlainObject(prev.native) || isPlainObject(next.native) ? ["native"] : []),
|
|
1004
|
+
];
|
|
1005
|
+
byId[elementId] = { status: rollUp(carried.map((s) => scopes[s].status)), scopes };
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
if (targets) {
|
|
1009
|
+
byId["#relationships"] = { status: "passed", resolved: [...targets] };
|
|
1010
|
+
}
|
|
1011
|
+
return { byId, resolvedTargets: targets };
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function compareGeom(prev, next) {
|
|
1015
|
+
if (!isPlainObject(prev) || !isPlainObject(next)) {
|
|
1016
|
+
return { status: "unknown", reason: "geometry_unavailable" };
|
|
1017
|
+
}
|
|
1018
|
+
const moved = ["x", "y", "w", "h", "rot"].filter((k) => {
|
|
1019
|
+
const dp = num(prev[k]);
|
|
1020
|
+
const dn = num(next[k]);
|
|
1021
|
+
if (dp === null || dn === null) return false;
|
|
1022
|
+
return Math.abs(dn - dp) > TRANSFORM_TOLERANCE_EMU;
|
|
1023
|
+
});
|
|
1024
|
+
return moved.length
|
|
1025
|
+
? { status: "failed", reason: "geometry_changed", detail: `changed: ${moved.join(", ")}`, tolerance: { quantumEmu: PROJECTION_QUANTUM_EMU, slackEmu: TRANSFORM_TOLERANCE_EMU } }
|
|
1026
|
+
: { status: "passed" };
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const sameOrChanged = (prev, next) =>
|
|
1030
|
+
prev === next ? { status: "passed" } : { status: "failed", reason: "property_changed" };
|
|
1031
|
+
|
|
1032
|
+
function compareDigest(prev, next, missingReason) {
|
|
1033
|
+
if (prev == null && next == null) return { status: "unsupported", reason: missingReason };
|
|
1034
|
+
if (prev == null || next == null) return { status: "failed", reason: "digest_appeared_or_vanished" };
|
|
1035
|
+
return prev === next
|
|
1036
|
+
? { status: "passed" }
|
|
1037
|
+
: { status: "failed", reason: "digest_changed", detail: "a binary payload changed without the user editing it" };
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function compareNative(prev, next) {
|
|
1041
|
+
const prevParts = isPlainObject(prev?.parts) ? prev.parts : null;
|
|
1042
|
+
const nextParts = isPlainObject(next?.parts) ? next.parts : null;
|
|
1043
|
+
if (!prevParts && !nextParts) return { status: "unsupported", reason: "native_closure_not_supplied" };
|
|
1044
|
+
if (!prevParts || !nextParts) return { status: "unknown", reason: "native_closure_partial" };
|
|
1045
|
+
const missing = Object.keys(prevParts).filter((p) => nextParts[p] === undefined);
|
|
1046
|
+
if (missing.length) {
|
|
1047
|
+
return { status: "failed", reason: "native_part_missing", detail: `dropped: ${missing.join(", ")}` };
|
|
1048
|
+
}
|
|
1049
|
+
const rehashed = Object.keys(prevParts).filter((p) => nextParts[p] !== prevParts[p]);
|
|
1050
|
+
if (rehashed.length) {
|
|
1051
|
+
return { status: "failed", reason: "native_part_changed", detail: `rewritten: ${rehashed.join(", ")}` };
|
|
1052
|
+
}
|
|
1053
|
+
return { status: "passed" };
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/** failed beats unsupported beats unknown beats passed. */
|
|
1057
|
+
function rollUp(statuses) {
|
|
1058
|
+
if (statuses.includes("failed")) return "failed";
|
|
1059
|
+
if (statuses.includes("unsupported")) return "unsupported";
|
|
1060
|
+
if (statuses.includes("unknown")) return "unknown";
|
|
1061
|
+
return statuses.length ? "passed" : "unknown";
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// ---------------------------------------------------------------------------
|
|
1065
|
+
// The verdict
|
|
1066
|
+
// ---------------------------------------------------------------------------
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* Hash of the obligations a verdict was about. Covers the active requirements'
|
|
1070
|
+
* identity, kind, protected scopes and relationships — not the baseline hash,
|
|
1071
|
+
* because source changes on every edit while the promises do not. A later
|
|
1072
|
+
* phase compares this against the record on disk to know the verdict is stale.
|
|
1073
|
+
*/
|
|
1074
|
+
export function requirementSetHash(entry) {
|
|
1075
|
+
const active = Array.isArray(entry?.active) ? entry.active : [];
|
|
1076
|
+
const shape = active
|
|
1077
|
+
.filter(isPlainObject)
|
|
1078
|
+
.map((r) => ({
|
|
1079
|
+
requirementId: r.requirementId ?? null,
|
|
1080
|
+
elementId: r.elementId ?? null,
|
|
1081
|
+
kind: r.kind ?? null,
|
|
1082
|
+
properties: [...(Array.isArray(r.properties) ? r.properties : [])].sort(),
|
|
1083
|
+
relationships: [...(Array.isArray(r.relationships) ? r.relationships : [])]
|
|
1084
|
+
.filter(isPlainObject)
|
|
1085
|
+
.map((rel) => ({ role: rel.role ?? null, target: rel.target ?? null }))
|
|
1086
|
+
.sort((x, y) => canonical(x).localeCompare(canonical(y))),
|
|
1087
|
+
useId: r.useId ?? null,
|
|
1088
|
+
}))
|
|
1089
|
+
.sort((x, y) => String(x.requirementId).localeCompare(String(y.requirementId)));
|
|
1090
|
+
return sha256(canonical(shape));
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/** What a restyle may change on any element: how it looks, not what it says. */
|
|
1094
|
+
const LOOK_SCOPES = ["geom", "fill", "line"];
|
|
1095
|
+
const CONTENT_SCOPES = new Set(["text", "media", "chart", "native"]);
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Only an IMPORT's decoration may be restyled away. Its seed claims a content
|
|
1099
|
+
* scope exactly where the shape HAS that content (its own text, a picture
|
|
1100
|
+
* fill), so a requirement without one owes only a look. Elsewhere the absence
|
|
1101
|
+
* proves nothing: a concept's drawn marks (a bar, a sparkline, a rule) carry
|
|
1102
|
+
* data with no text, and `legacy-current` claims only media/native for every
|
|
1103
|
+
* element.
|
|
1104
|
+
*/
|
|
1105
|
+
const WAIVABLE_ORIGIN = "converted";
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* What this operation declared it may change on the requirement's element.
|
|
1109
|
+
*
|
|
1110
|
+
* Two sources: the target's explicit `changes`, and the operation's KIND. A
|
|
1111
|
+
* redesign is the user asking for a new look, so on a slide it targets the look
|
|
1112
|
+
* scopes are declared by the kind itself, and an imported element that carried
|
|
1113
|
+
* none of the slide's content may go. Content — text, pictures, charts, native objects,
|
|
1114
|
+
* connections — stays owed: a restyle must keep the user's words and images,
|
|
1115
|
+
* which is what keeps a real loss refusable.
|
|
1116
|
+
*/
|
|
1117
|
+
function declaredFor(operation, slideKey, req, origin) {
|
|
1118
|
+
const out = { scopes: new Set(), removable: false, declared: false };
|
|
1119
|
+
for (const target of Array.isArray(operation?.targets) ? operation.targets : []) {
|
|
1120
|
+
if (!isPlainObject(target)) continue;
|
|
1121
|
+
if (slideKey != null && target.slideKey !== slideKey) continue;
|
|
1122
|
+
if (operation.kind === "redesign") {
|
|
1123
|
+
out.declared = true;
|
|
1124
|
+
for (const scope of LOOK_SCOPES) out.scopes.add(scope);
|
|
1125
|
+
const properties = Array.isArray(req.properties) ? req.properties : [];
|
|
1126
|
+
if (req.kind === "element" && origin === WAIVABLE_ORIGIN && !properties.some((s) => CONTENT_SCOPES.has(s))) {
|
|
1127
|
+
out.removable = true;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
for (const change of Array.isArray(target.changes) ? target.changes : []) {
|
|
1131
|
+
if (!isPlainObject(change) || change.elementId !== req.elementId) continue;
|
|
1132
|
+
out.declared = true;
|
|
1133
|
+
if (change.action === "remove") out.removable = true;
|
|
1134
|
+
for (const scope of Array.isArray(change.properties) ? change.properties : []) out.scopes.add(scope);
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
return out;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/** A declared removal the evidence confirms: the element is simply gone. */
|
|
1141
|
+
const declaredRemoval = (at) => ({ ...at, scope: null, status: "passed", reason: "declared_removal" });
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* Compose the two comparisons into one verdict about one slide.
|
|
1145
|
+
*
|
|
1146
|
+
* `entry` is the slide's preservation record (its `active` requirements),
|
|
1147
|
+
* `operation` the record authorizing this run, and `source` / `render` /
|
|
1148
|
+
* `packageEvidence` are the results of the three comparisons above — each
|
|
1149
|
+
* optional, because a draft has no package and an offline check has no render.
|
|
1150
|
+
* Whatever is absent produces `unknown` checks, never passes.
|
|
1151
|
+
*
|
|
1152
|
+
* @param {{
|
|
1153
|
+
* entry?: object|null,
|
|
1154
|
+
* operation?: object|null,
|
|
1155
|
+
* slideKey?: string|null,
|
|
1156
|
+
* source?: object|null,
|
|
1157
|
+
* render?: object|null,
|
|
1158
|
+
* packageEvidence?: object|null,
|
|
1159
|
+
* binding?: {sourceHash?: string, liveRevision?: string, operationId?: string},
|
|
1160
|
+
* }} [input]
|
|
1161
|
+
*/
|
|
1162
|
+
export function preservationVerdict({
|
|
1163
|
+
entry = null,
|
|
1164
|
+
operation = null,
|
|
1165
|
+
slideKey = null,
|
|
1166
|
+
source = null,
|
|
1167
|
+
render = null,
|
|
1168
|
+
packageEvidence = null,
|
|
1169
|
+
binding = {},
|
|
1170
|
+
} = {}) {
|
|
1171
|
+
const active = Array.isArray(entry?.active) ? entry.active.filter(isPlainObject) : [];
|
|
1172
|
+
const key = slideKey ?? (isDurableId(entry?.slideKey) ? entry.slideKey : null);
|
|
1173
|
+
const checks = [];
|
|
1174
|
+
|
|
1175
|
+
for (const req of active) {
|
|
1176
|
+
const elementId = req.elementId;
|
|
1177
|
+
const at = { requirementId: req.requirementId ?? null, elementId: elementId ?? null, kind: req.kind ?? null };
|
|
1178
|
+
const allowed = declaredFor(operation, key, req, entry?.origin ?? null);
|
|
1179
|
+
|
|
1180
|
+
// --- source: is the obligation still in the file the agent wrote?
|
|
1181
|
+
if (source) {
|
|
1182
|
+
const unsupported = source.unsupported?.find((u) => u.elementId === elementId);
|
|
1183
|
+
const unknown = source.unknown?.find((u) => u.elementId === elementId);
|
|
1184
|
+
const ambiguous = source.ambiguous?.find((u) => u.elementId === elementId);
|
|
1185
|
+
const change = source.changes?.find((c) => c.elementId === elementId);
|
|
1186
|
+
if (unsupported) {
|
|
1187
|
+
checks.push({ ...at, scope: null, status: "unsupported", reason: unsupported.reason });
|
|
1188
|
+
} else if (ambiguous) {
|
|
1189
|
+
checks.push({ ...at, scope: null, status: "unknown", reason: ambiguous.reason });
|
|
1190
|
+
} else if (change?.action === "remove" && !allowed.removable) {
|
|
1191
|
+
checks.push({
|
|
1192
|
+
...at,
|
|
1193
|
+
scope: null,
|
|
1194
|
+
status: "failed",
|
|
1195
|
+
reason: "removed_from_source",
|
|
1196
|
+
detail: "the element is gone from the source and this operation did not declare its removal",
|
|
1197
|
+
});
|
|
1198
|
+
} else if (unknown) {
|
|
1199
|
+
checks.push({ ...at, scope: null, status: "unknown", reason: unknown.reason });
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// --- render: did it paint?
|
|
1204
|
+
if (render && elementId != null) {
|
|
1205
|
+
const seen = render.byId?.[elementId];
|
|
1206
|
+
if (seen?.reason === "absent_from_render" && allowed.removable) {
|
|
1207
|
+
checks.push(declaredRemoval(at));
|
|
1208
|
+
} else if (seen) {
|
|
1209
|
+
checks.push({
|
|
1210
|
+
...at,
|
|
1211
|
+
scope: null,
|
|
1212
|
+
status: seen.status,
|
|
1213
|
+
reason: seen.reason ?? null,
|
|
1214
|
+
...(seen.detail ? { detail: seen.detail } : {}),
|
|
1215
|
+
});
|
|
1216
|
+
} else {
|
|
1217
|
+
checks.push({ ...at, scope: null, status: "unknown", reason: "not_checked_in_render" });
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// --- package: did the protected properties survive the splice?
|
|
1222
|
+
if (packageEvidence && elementId != null) {
|
|
1223
|
+
const seen = packageEvidence.byId?.[elementId];
|
|
1224
|
+
if (!seen) {
|
|
1225
|
+
checks.push({ ...at, scope: null, status: "unknown", reason: "not_checked_in_package" });
|
|
1226
|
+
} else if (seen.status === "failed" && seen.reason === "absent_from_package") {
|
|
1227
|
+
checks.push(
|
|
1228
|
+
allowed.removable
|
|
1229
|
+
? declaredRemoval(at)
|
|
1230
|
+
: { ...at, scope: null, status: "failed", reason: seen.reason, detail: seen.detail },
|
|
1231
|
+
);
|
|
1232
|
+
} else {
|
|
1233
|
+
const protectedScopes = Array.isArray(req.properties) && req.properties.length
|
|
1234
|
+
? req.properties
|
|
1235
|
+
: PROPERTY_SCOPES;
|
|
1236
|
+
for (const scope of protectedScopes) {
|
|
1237
|
+
const result = seen.scopes?.[scope];
|
|
1238
|
+
if (!result) {
|
|
1239
|
+
checks.push({ ...at, scope, status: "unknown", reason: "scope_not_compared" });
|
|
1240
|
+
continue;
|
|
1241
|
+
}
|
|
1242
|
+
// A property the operation declared changeable is allowed to differ;
|
|
1243
|
+
// it is still reported, so the verdict says what it let through.
|
|
1244
|
+
if (result.status === "failed" && allowed.scopes.has(scope)) {
|
|
1245
|
+
checks.push({ ...at, scope, status: "passed", reason: "declared_change" });
|
|
1246
|
+
continue;
|
|
1247
|
+
}
|
|
1248
|
+
checks.push({
|
|
1249
|
+
...at,
|
|
1250
|
+
scope,
|
|
1251
|
+
status: result.status,
|
|
1252
|
+
reason: result.reason ?? null,
|
|
1253
|
+
...(result.detail ? { detail: result.detail } : {}),
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// --- relationships: an endpoint or package part that no longer resolves.
|
|
1260
|
+
const targets = packageEvidence?.resolvedTargets ?? null;
|
|
1261
|
+
for (const rel of Array.isArray(req.relationships) ? req.relationships : []) {
|
|
1262
|
+
if (!isPlainObject(rel)) continue;
|
|
1263
|
+
const scope = req.kind === "native-object" ? "native" : "geom";
|
|
1264
|
+
if (!targets) {
|
|
1265
|
+
checks.push({ ...at, scope, status: "unknown", reason: "relationships_not_resolved", detail: rel.role });
|
|
1266
|
+
continue;
|
|
1267
|
+
}
|
|
1268
|
+
checks.push(
|
|
1269
|
+
targets.has(rel.target)
|
|
1270
|
+
? { ...at, scope, status: "passed", reason: `relationship:${rel.role}` }
|
|
1271
|
+
: {
|
|
1272
|
+
...at,
|
|
1273
|
+
scope,
|
|
1274
|
+
status: "failed",
|
|
1275
|
+
reason: "relationship_broken",
|
|
1276
|
+
detail: `${rel.role} → ${rel.target} no longer resolves`,
|
|
1277
|
+
},
|
|
1278
|
+
);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
if (!checks.some((c) => c.requirementId === at.requirementId)) {
|
|
1282
|
+
checks.push({ ...at, scope: null, status: "unknown", reason: "no_evidence_supplied" });
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// A construct the reader could not resolve that belongs to no single element
|
|
1287
|
+
// — list rendering, an unclosed tag — makes the source comparison unable to
|
|
1288
|
+
// speak for the WHOLE slide, so it is a check of its own rather than a note
|
|
1289
|
+
// nobody reads.
|
|
1290
|
+
for (const u of source?.unsupported ?? []) {
|
|
1291
|
+
if (u.elementId != null) continue;
|
|
1292
|
+
checks.push({ requirementId: null, elementId: null, kind: null, scope: null, status: "unsupported", reason: u.reason });
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
const counts = Object.fromEntries(EVIDENCE_STATUSES.map((s) => [s, 0]));
|
|
1296
|
+
for (const c of checks) counts[c.status] = (counts[c.status] ?? 0) + 1;
|
|
1297
|
+
|
|
1298
|
+
// `unsupported` is not a pass: it rolls the verdict to `unknown`, which is
|
|
1299
|
+
// what a final delivery refuses on.
|
|
1300
|
+
const status = checks.length === 0 && active.length === 0
|
|
1301
|
+
? "unknown"
|
|
1302
|
+
: rollUp(checks.map((c) => c.status)) === "unsupported"
|
|
1303
|
+
? "unknown"
|
|
1304
|
+
: rollUp(checks.map((c) => c.status));
|
|
1305
|
+
|
|
1306
|
+
return {
|
|
1307
|
+
version: PRESERVATION_VERDICT_VERSION,
|
|
1308
|
+
status,
|
|
1309
|
+
slideKey: key,
|
|
1310
|
+
binding: {
|
|
1311
|
+
sourceHash: binding.sourceHash ?? null,
|
|
1312
|
+
liveRevision: binding.liveRevision ?? null,
|
|
1313
|
+
operationId: isPlainObject(operation) ? (operation.operationId ?? null) : (binding.operationId ?? null),
|
|
1314
|
+
requirementSetHash: requirementSetHash(entry),
|
|
1315
|
+
},
|
|
1316
|
+
checks,
|
|
1317
|
+
counts,
|
|
1318
|
+
unsupported: [
|
|
1319
|
+
...(source?.unsupported ?? []),
|
|
1320
|
+
...checks.filter((c) => c.status === "unsupported").map((c) => ({
|
|
1321
|
+
elementId: c.elementId,
|
|
1322
|
+
reason: c.reason,
|
|
1323
|
+
construct: c.scope ? `scope:${c.scope}` : "element",
|
|
1324
|
+
})),
|
|
1325
|
+
],
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/**
|
|
1330
|
+
* Has anything the verdict was bound to moved since? Any difference in the
|
|
1331
|
+
* source hash, the live revision, the operation or the requirement set
|
|
1332
|
+
* invalidates it — a verdict is about one exact state of one exact slide.
|
|
1333
|
+
*/
|
|
1334
|
+
export function verdictIsStale(verdict, current = {}) {
|
|
1335
|
+
if (!isPlainObject(verdict) || verdict.version !== PRESERVATION_VERDICT_VERSION) return true;
|
|
1336
|
+
const bound = isPlainObject(verdict.binding) ? verdict.binding : {};
|
|
1337
|
+
for (const field of ["sourceHash", "liveRevision", "operationId", "requirementSetHash"]) {
|
|
1338
|
+
if (current[field] === undefined) continue;
|
|
1339
|
+
if (bound[field] !== current[field]) return true;
|
|
1340
|
+
}
|
|
1341
|
+
return false;
|
|
1342
|
+
}
|