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,1069 @@
|
|
|
1
|
+
// Transcribe a concept spec into a slide JSX skeleton — mechanically.
|
|
2
|
+
//
|
|
3
|
+
// The spec (src/builders/concept-spec.mjs) is the add-in's vision inventory of
|
|
4
|
+
// a mockup: every element with its rect in content-slot pixels, verbatim text,
|
|
5
|
+
// role, fill, icon query, chart series, and every connector between ids. Each
|
|
6
|
+
// line becomes one element here; nothing is regrouped, merged or dropped. The
|
|
7
|
+
// agent's job starts AFTER this file exists: icons (the fetcher writes the
|
|
8
|
+
// paths this skeleton already references), the residual defects view-draft
|
|
9
|
+
// measures, and the side-by-side check against the PNG. Sims 2026-09-18/19
|
|
10
|
+
// showed that a coding agent transcribing by hand loses elements at the first
|
|
11
|
+
// write; a generator does not.
|
|
12
|
+
//
|
|
13
|
+
// Pure: no I/O. `scaffoldSlideJsx(spec, opts)` → JSX source.
|
|
14
|
+
import { cleanText, dropChartMarks, dropStandIns, foldLabelsIntoShape, foldTextBlocks, textIsPrinted } from "./concept-spec.mjs";
|
|
15
|
+
import { paragraphIndent } from "./text-blocks.mjs";
|
|
16
|
+
import { bulletItems, textLines } from "./concept-text.mjs";
|
|
17
|
+
import { paintOrdered, seatedAssets, textFrame } from "./concept-layout.mjs";
|
|
18
|
+
import { applySeats } from "./seat.mjs";
|
|
19
|
+
import { applyStacks, gridStacks, remapGrids, remapStacks } from "./stack.mjs";
|
|
20
|
+
import { contrastRatio, hexToRgb, paletteRoles, deltaE, rankTokens, typeLadder } from "./design-tokens.mjs";
|
|
21
|
+
import { chartJsx } from "./chart-jsx.mjs";
|
|
22
|
+
import { centreHoles } from "./chart-hole.mjs";
|
|
23
|
+
import { foldChartLabels } from "./chart-labels.mjs";
|
|
24
|
+
import { geometryPath, nativeChartData, reconcileArc, repairCommands, scaleGeometry, writtenTop } from "./concept-geometry.mjs";
|
|
25
|
+
import { applyLines, readLines, LINE_TOL } from "./concept-lines.mjs";
|
|
26
|
+
import { illustrationOwner as plotOwner } from "./concept-relationships.mjs";
|
|
27
|
+
import { strokeTraces } from "./line-fit.mjs";
|
|
28
|
+
import { alignRails, bandNative, capAttr, chainNative, fitNative, lineDirective, loopCircles, nativePoints, nodePreset, ringNative, weightClass, LINE_WEIGHT_RUNGS_PX } from "./line-native.mjs";
|
|
29
|
+
import { capRoutePx, headRoutePx, runsClear, seatLines, stretched } from "./placement.mjs";
|
|
30
|
+
import { foldListMarkers } from "./concept-lists.mjs";
|
|
31
|
+
import { LIST_HELPER, RUN_HELPER, blockChildren, markerAttrs, wrapRuns } from "./list-jsx.mjs";
|
|
32
|
+
import { ICON_HELPER, KIT, KIT_EXTRAS, PARAGRAPH_HELPER } from "./kit-helpers.mjs";
|
|
33
|
+
import { ICON_STYLE_WORDS, nameWords, glyphMarkOf, glyphMarkStrokes } from "./concept-marks.mjs";
|
|
34
|
+
import { expandSketch, figureJsxAttrs, fitFigureToSlot } from "./figure-sketch.mjs";
|
|
35
|
+
import { buildRepeats, remapRepeats, repeatGroups } from "./repeat-groups.mjs";
|
|
36
|
+
// Measurement knob (scripts/fidelity): names of normalization steps to skip,
|
|
37
|
+
// so a replay can show what each one contributes. Unset in production.
|
|
38
|
+
const ablated = (name) => (process.env.SLIDEMIND_ABLATE ?? "").split(",").includes(name);
|
|
39
|
+
// The presets the exporter derives from a box's own border radius (contract E1).
|
|
40
|
+
|
|
41
|
+
export { glyphMarkOf } from "./concept-marks.mjs";
|
|
42
|
+
export { bulletItems, textLines, LIST_HELPER, ICON_HELPER, KIT, PARAGRAPH_HELPER };
|
|
43
|
+
|
|
44
|
+
// An observed colour closer than this to a design token IS that token; beyond
|
|
45
|
+
// it the design has no such colour (a green status dot on a blue/grey
|
|
46
|
+
// palette) and the tone word decides — reported, never invented as raw hex.
|
|
47
|
+
|
|
48
|
+
const q = (s) => JSON.stringify(String(s));
|
|
49
|
+
// Roles set on the ramp's heading rungs (--fs-h3 bold and up): WCAG large text.
|
|
50
|
+
const LARGE_ROLES = new Set(["number"]);
|
|
51
|
+
// JSX text: braces and angle brackets are syntax, `\n` in the spec is the
|
|
52
|
+
// image's own wrapping (the box width reproduces it).
|
|
53
|
+
const escapeJsx = (s) =>
|
|
54
|
+
String(s)
|
|
55
|
+
.replace(/&/g, "&")
|
|
56
|
+
.replace(/[{}<>]/g, (c) => ({ "{": "{", "}": "}", "<": "<", ">": ">" })[c]);
|
|
57
|
+
// The spec's `\n` is where the image breaks the line (concept-text.mjs textLines).
|
|
58
|
+
const jsxText = (s) => textLines(s).map(escapeJsx).join("<br />");
|
|
59
|
+
|
|
60
|
+
// The layout's chrome props from its `// PLACEHOLDERS: {...}` header: the
|
|
61
|
+
// headline prop (role "headline"), the kicker prop (type "title"), and every
|
|
62
|
+
// other chrome prop, which the skeleton leaves empty.
|
|
63
|
+
/** The layout's own background colour, as its PLACEHOLDERS header states it. */
|
|
64
|
+
export function layoutBackgroundHex(layoutSource) {
|
|
65
|
+
const m = /^\/\/\s*PLACEHOLDERS:\s*(\{.*\})\s*$/m.exec(layoutSource ?? "");
|
|
66
|
+
if (!m) return null;
|
|
67
|
+
try {
|
|
68
|
+
const hex = JSON.parse(m[1])?.background?.hex;
|
|
69
|
+
return hexToRgb(hex) ? hex : null;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function layoutChromeProps(layoutSource) {
|
|
76
|
+
const m = /^\/\/\s*PLACEHOLDERS:\s*(\{.*\})\s*$/m.exec(layoutSource ?? "");
|
|
77
|
+
const out = { headline: null, kicker: null, others: [] };
|
|
78
|
+
if (!m) return out;
|
|
79
|
+
let ph;
|
|
80
|
+
try {
|
|
81
|
+
ph = JSON.parse(m[1]);
|
|
82
|
+
} catch {
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
for (const c of ph.chrome ?? []) {
|
|
86
|
+
if (!c?.prop) continue;
|
|
87
|
+
if (c.role === "headline" && !out.headline) out.headline = c.prop;
|
|
88
|
+
else if (c.type === "title" && !out.kicker) out.kicker = c.prop;
|
|
89
|
+
else out.others.push(c.prop);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
/** A use's normalized `{x, y}` fractions as an `object-position` value. */
|
|
96
|
+
export const positionValue = (p) =>
|
|
97
|
+
`${Math.round((p?.x ?? 0.5) * 1000) / 10}% ${Math.round((p?.y ?? 0.5) * 1000) / 10}%`;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The approved source region in the two forms one picture needs: the
|
|
101
|
+
* exporter's `<a:srcRect>` (OOXML 100000ths, `"l,t,r,b"`) and the browser's
|
|
102
|
+
* `object-view-box` inset — the same four numbers, so preview and export crop
|
|
103
|
+
* the same pixels. Null when the use asks for no crop.
|
|
104
|
+
*/
|
|
105
|
+
export function cropAttrs(crop) {
|
|
106
|
+
if (!crop) return null;
|
|
107
|
+
const k = (v) => Math.max(0, Math.min(100000, Math.round(v * 100000)));
|
|
108
|
+
const sides = [k(crop.x), k(crop.y), k(1 - (crop.x + crop.w)), k(1 - (crop.y + crop.h))];
|
|
109
|
+
const [l, t, r, b] = sides;
|
|
110
|
+
const pct = (v) => `${Math.round(v / 10) / 100}%`;
|
|
111
|
+
return { crop: sides.join(","), view: `inset(${pct(t)} ${pct(r)} ${pct(b)} ${pct(l)})` };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// The rung of the slide's ladder each role sets on.
|
|
115
|
+
const ROLE_STEP = { subtitle: "body", section: "body", title: "body", body: "body", label: "small", caption: "small", badge: "small", number: "number" };
|
|
116
|
+
/** KIT's ROLE block on the slide's ladder; the KIT's own map without a ramp. */
|
|
117
|
+
const roleLadder = (block, ladder) => (ladder
|
|
118
|
+
? block.replace(/(\n\s+(\w+): \{ fontSize: )"var\(--fs-[\w-]+\)"/g, (m, head, role) => (ROLE_STEP[role] ? `${head}"var(${ladder[ROLE_STEP[role]]})"` : m))
|
|
119
|
+
: block);
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* One spec → one slide file. `slide` is the slide number (icon file names are
|
|
123
|
+
* `NN-<icon id>.svg`), `layout` the component name, `layoutSource` its JSX
|
|
124
|
+
* (for the chrome prop names). Returns `{ jsx, icons, warnings }` — `icons`
|
|
125
|
+
* is the fetcher's list: `{ id, file, query, color, colorHex }`.
|
|
126
|
+
*
|
|
127
|
+
* `normalize: false` for a re-scaffold after a settle pass: the row/centre
|
|
128
|
+
* normalizers below read the ORIGINAL image's intent, and re-running them on a
|
|
129
|
+
* spec the settle loop just moved snaps every member of a row back to the row
|
|
130
|
+
* median — a shift under 8% of the card height disappears entirely, so the
|
|
131
|
+
* overlap the pass measured ships anyway while `SETTLED: n changes` reports it
|
|
132
|
+
* as fixed.
|
|
133
|
+
*
|
|
134
|
+
* `bound` is `Map<useId, resolved>` — the deck's approved image uses already
|
|
135
|
+
* resolved to verified bytes (src/builders/spec-assets.mjs →
|
|
136
|
+
* `resolveBoundImageUses`). An image element that names one is placed from it
|
|
137
|
+
* and is left OUT of `icons`: the engine is never asked for a picture the user
|
|
138
|
+
* already supplied. Pure either way — the resolution happened in the caller.
|
|
139
|
+
*/
|
|
140
|
+
// The kinds a sketched figure may replace (never text, icons, images, charts, discs).
|
|
141
|
+
const FIGURE_KINDS = new Set(["path", "shape", "panel", "card", "badge", "bar"]);
|
|
142
|
+
|
|
143
|
+
/** Every colour a spec's reference paints (any `…Hex` field), as a palette. */
|
|
144
|
+
function referencePalette(spec) {
|
|
145
|
+
const seen = new Map();
|
|
146
|
+
const walk = (v, key) => {
|
|
147
|
+
if (typeof v === "string") {
|
|
148
|
+
const rgb = key.endsWith("Hex") ? hexToRgb(v) : null;
|
|
149
|
+
const hex = rgb && `#${rgb.map((c) => c.toString(16).padStart(2, "0")).join("").toUpperCase()}`;
|
|
150
|
+
if (hex) seen.set(hex, { name: hex, hex, rgb });
|
|
151
|
+
} else if (v && typeof v === "object") {
|
|
152
|
+
for (const [k, c] of Object.entries(v)) walk(c, Array.isArray(v) ? key : k);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
walk(spec, "");
|
|
156
|
+
return [...seen.values()];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function scaffoldSlideJsx(spec, { slide, layout, layoutSource = "", tokens = [], ramp = null, contrastFloor = 4.5, normalize = true, bound = new Map(), literal = false }) {
|
|
160
|
+
// A whole-slide reconstruction is faithful to its reference, not to the
|
|
161
|
+
// design's token ladder (workflow/create.md): its palette is the colours the
|
|
162
|
+
// reference paints, written as those literal values.
|
|
163
|
+
if (literal) tokens = referencePalette(spec);
|
|
164
|
+
const nn = String(slide).padStart(2, "0");
|
|
165
|
+
const chrome = layoutChromeProps(layoutSource);
|
|
166
|
+
let byId = new Map((spec.elements ?? []).map((e) => [e.id, e]));
|
|
167
|
+
const lines = [];
|
|
168
|
+
const icons = [];
|
|
169
|
+
const warnings = applyLines(spec);
|
|
170
|
+
// The ids this skeleton deliberately leaves out, each also named in
|
|
171
|
+
// `warnings`: preservation seeding releases exactly these (edit-state.mjs).
|
|
172
|
+
const dropped = [];
|
|
173
|
+
const illustrationOwner = (e) => plotOwner(spec.elements ?? [], e);
|
|
174
|
+
const unmatched = new Map();
|
|
175
|
+
// The design's palette is authoritative: an observed colour becomes the
|
|
176
|
+
// design colour it perceptually IS (CIEDE2000 —
|
|
177
|
+
// the mockup's green is the design's green, its near-black navy is the
|
|
178
|
+
// design's ink). A colour far from every design colour is still drawn in
|
|
179
|
+
// the nearest one and named in a note.
|
|
180
|
+
const ref = (t) => (t.name.startsWith("#") ? t.name : `var(${t.name})`);
|
|
181
|
+
const tokenByVar = (v) => tokens.find((t) => ref(t) === v) ?? null;
|
|
182
|
+
const snapVar = (hex, prefer = /^--fg-/) => {
|
|
183
|
+
if (!hex || !tokens.length) return null;
|
|
184
|
+
const best = rankTokens(hex, tokens, { prefer })[0];
|
|
185
|
+
if (!best) return null;
|
|
186
|
+
// Far from every design colour: the reader's tone word (its role —
|
|
187
|
+
// accent, muted, dark) decides, and the colour is named in a note.
|
|
188
|
+
if (best.d > 20) {
|
|
189
|
+
if (!unmatched.has(hex)) unmatched.set(hex, best.t.name);
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
return ref(best.t);
|
|
193
|
+
};
|
|
194
|
+
const roles = paletteRoles(tokens);
|
|
195
|
+
// A reconstruction's palette has no semantic names, so no token is the
|
|
196
|
+
// accent: take the reference's most chromatic colour (its ink when it has
|
|
197
|
+
// none), or an accent with no hex of its own would paint nothing.
|
|
198
|
+
if (literal && !roles.tone.accent) {
|
|
199
|
+
const chroma = (t) => Math.max(...t.rgb) - Math.min(...t.rgb);
|
|
200
|
+
const vivid = [...tokens].sort((a, b) => chroma(b) - chroma(a))[0];
|
|
201
|
+
roles.tone.accent = roles.fill.accent = vivid && chroma(vivid) >= 40 ? vivid : roles.tone.dark;
|
|
202
|
+
}
|
|
203
|
+
const variable = (t) => t ? ref(t) : null;
|
|
204
|
+
const hexOf = new Map(tokens.map((t) => [variable(t), t.hex]));
|
|
205
|
+
const layoutBgHex = layoutBackgroundHex(layoutSource);
|
|
206
|
+
// The reader is shown this design's palette and may name the colour it
|
|
207
|
+
// means (`fillToken` / `colorToken`). A name this bundle lists is used when
|
|
208
|
+
// it is a plausible reading of the colour the image shows.
|
|
209
|
+
const palette = new Map(tokens.map((t) => [t.name, t]));
|
|
210
|
+
const namedVar = (name, observed = null) => {
|
|
211
|
+
if (typeof name !== "string" || !palette.has(name)) return null;
|
|
212
|
+
const d = observed ? deltaE(observed, palette.get(name).hex) : null;
|
|
213
|
+
return d == null || d <= 15 ? `var(${name})` : null;
|
|
214
|
+
};
|
|
215
|
+
// What a figure sits on in the image: the smallest painted element that
|
|
216
|
+
// contains it. Geometry, not the reader's parent or paint order.
|
|
217
|
+
const groundOf = (e) => (spec.elements ?? []).filter((p) => p !== e && p.fill && p.fill !== "none" && p.fillHex
|
|
218
|
+
&& p.x <= e.x + 1 && p.y <= e.y + 1 && p.x + p.w >= e.x + e.w - 1 && p.y + p.h >= e.y + e.h - 1 && p.w * p.h > e.w * e.h)
|
|
219
|
+
.sort((a, b) => a.w * a.h - b.w * b.h)[0] ?? null;
|
|
220
|
+
// A fill is a design token: the reader's named token, else the token of its
|
|
221
|
+
// fill role, else the nearest token to what the image shows. Where the
|
|
222
|
+
// image draws the figure apart from its ground (or a clipped silhouette
|
|
223
|
+
// carries a shadow that never renders), it may not land on the ground's own
|
|
224
|
+
// colour: the next token that differs from the ground is taken.
|
|
225
|
+
const SILHOUETTES = new Set(["chevron", "triangle", "trapezoid", "arrow", "diamond", "hexagon", "pentagon", "parallelogram"]);
|
|
226
|
+
const silhouette = (e) => SILHOUETTES.has(e.shape) || (e.shape === "other" && Array.isArray(e.outline) && e.outline.length >= 3);
|
|
227
|
+
const fillMemo = new Map();
|
|
228
|
+
const fillVarOf = (e) => {
|
|
229
|
+
if (!e.fill || e.fill === "none") return null;
|
|
230
|
+
if (fillMemo.has(e.id)) return fillMemo.get(e.id);
|
|
231
|
+
fillMemo.set(e.id, null);
|
|
232
|
+
let pick = null;
|
|
233
|
+
if (e.fillHex && tokens.length) {
|
|
234
|
+
const named = namedVar(e.fillToken, e.fillHex);
|
|
235
|
+
const all = rankTokens(e.fillHex, tokens, { prefer: /^--bg-/ });
|
|
236
|
+
if (all[0]?.d > 20 && !unmatched.has(e.fillHex)) unmatched.set(e.fillHex, all[0].t.name);
|
|
237
|
+
const role = tokenByVar(variable(roles.fill[e.fill]));
|
|
238
|
+
const ranked = [...(named ? [tokenByVar(named)] : []), ...(role ? [role] : []), ...all.map((v) => v.t)];
|
|
239
|
+
const ground = groundOf(e);
|
|
240
|
+
const seenGround = ground ? ground.fillHex : layoutBgHex;
|
|
241
|
+
const paintedGround = ground ? paintHexOf(ground) : layoutBgHex;
|
|
242
|
+
const apartInImage = (seenGround && (deltaE(e.fillHex, seenGround) ?? 0) >= 3) || (e.elevated && silhouette(e));
|
|
243
|
+
const apart = (t) => !apartInImage || !paintedGround || (deltaE(t.hex, paintedGround) ?? Infinity) >= 3;
|
|
244
|
+
const t = ranked.find(apart) ?? ranked[0];
|
|
245
|
+
if (t && t !== ranked[0]) warnings.push(`${e.id}: ${ranked[0].name} would merge into the colour under it; ${t.name} keeps it apart as the image does`);
|
|
246
|
+
pick = t ? ref(t) : null;
|
|
247
|
+
}
|
|
248
|
+
pick ??= variable(roles.fill[e.fill]);
|
|
249
|
+
fillMemo.set(e.id, pick);
|
|
250
|
+
return pick;
|
|
251
|
+
};
|
|
252
|
+
// The paint of a drawn FIGURE (a silhouette, a sector): its fill only. A
|
|
253
|
+
// stroke is never svg — an outline the reader saw on a figure is named.
|
|
254
|
+
const svgPaint = (e) => {
|
|
255
|
+
if (e.stroke && Number(e.stroke.width) > 0) warnings.push(`${e.id}: a drawn figure's outline is not written (a stroke is a native line, never svg); outline it by hand if the concept needs it`);
|
|
256
|
+
return `fill=${fillVarOf(e) ? q(fillVarOf(e)) : `{FILL[${q(e.fill)}]}`}`;
|
|
257
|
+
};
|
|
258
|
+
// Check the emitted fill, not the model's unsnapped colour or a guessed
|
|
259
|
+
// palette extreme. Unknown backgrounds remain unknown and are reported.
|
|
260
|
+
const groundHexOf = (e) => {
|
|
261
|
+
let p = e;
|
|
262
|
+
const seen = new Set();
|
|
263
|
+
while (p && !seen.has(p.id)) {
|
|
264
|
+
seen.add(p.id);
|
|
265
|
+
if (p.fill && p.fill !== "none") return paintHexOf(p);
|
|
266
|
+
p = byId.get(p.parent);
|
|
267
|
+
}
|
|
268
|
+
const under = (spec.elements ?? []).filter((p) => p.id !== e.id && p.fill && p.fill !== "none"
|
|
269
|
+
&& p.x <= e.x && p.y <= e.y && p.x + p.w >= e.x + e.w && p.y + p.h >= e.y + e.h)
|
|
270
|
+
.sort((a, b) => a.w * a.h - b.w * b.h)[0];
|
|
271
|
+
return under ? paintHexOf(under) : layoutBgHex;
|
|
272
|
+
};
|
|
273
|
+
// The colour a fill actually paints.
|
|
274
|
+
function paintHexOf(e) {
|
|
275
|
+
return hexOf.get(fillVarOf(e)) ?? null;
|
|
276
|
+
}
|
|
277
|
+
// The paint of an item's marker, when the reader named one (the concept's
|
|
278
|
+
// accent dots): a design colour, else nothing — the brand's marker applies.
|
|
279
|
+
const markerVar = (e) => (e.markerToken || e.markerHex ? namedVar(e.markerToken, e.markerHex) ?? snapVar(e.markerHex) : null);
|
|
280
|
+
const textPaint = (e, floor = contrastFloor) => {
|
|
281
|
+
// The reader's own pick, when it named one this palette lists.
|
|
282
|
+
const chosen = namedVar(e.colorToken, e.colorHex);
|
|
283
|
+
const colorVar = chosen
|
|
284
|
+
?? snapVar(e.colorHex)
|
|
285
|
+
?? variable(roles.tone[e.color ?? "dark"]);
|
|
286
|
+
const groundHex = groundHexOf(e);
|
|
287
|
+
if (!groundHex) {
|
|
288
|
+
if (tokens.length) warnings.push(`${e.id}: background colour unknown; contrast not verified`);
|
|
289
|
+
return { colorVar, tone: e.color };
|
|
290
|
+
}
|
|
291
|
+
const ratio = contrastRatio(hexOf.get(colorVar), groundHex);
|
|
292
|
+
if (ratio != null && ratio >= floor) return { colorVar, tone: e.color };
|
|
293
|
+
// A colour the reader CHOSE is never swapped out from under it: it judged
|
|
294
|
+
// the ground with the image in front of it, and a rule that overrules it
|
|
295
|
+
// silently is a second opinion the agent cannot see. Say so and move on.
|
|
296
|
+
if (chosen) {
|
|
297
|
+
warnings.push(
|
|
298
|
+
`${e.id}: the spec paints this text ${e.colorToken} on its background — ${ratio == null ? "contrast unknown" : `${ratio.toFixed(1)}:1`}, below ${floor}:1; change the colour in the JSX if the mockup disagrees`,
|
|
299
|
+
);
|
|
300
|
+
return { colorVar: chosen, tone: e.color };
|
|
301
|
+
}
|
|
302
|
+
// A deliberate colour — the image paints the words in a hue (a green
|
|
303
|
+
// gain, a red loss) and the design has that hue — is colour coding, not a
|
|
304
|
+
// readability accident: the design's own colour stays and the contrast is
|
|
305
|
+
// reported. Only neutral text is swapped for a more legible neutral.
|
|
306
|
+
const rgbSeen = hexToRgb(e.colorHex), rgbToken = hexToRgb(hexOf.get(colorVar));
|
|
307
|
+
const chroma = (c) => (c ? Math.max(...c) - Math.min(...c) : 0);
|
|
308
|
+
if (chroma(rgbSeen) >= 60 && chroma(rgbToken) >= 60 && (deltaE(hexOf.get(colorVar), groundHex) ?? 0) >= 25) {
|
|
309
|
+
warnings.push(`${e.id}: colour-coded text in ${colorVar.replace(/^var\((.*)\)$/, "$1")} on its background — ${ratio == null ? "contrast unknown" : `${ratio.toFixed(1)}:1`}, below ${floor}:1; kept, as the design's colour for what the image codes`);
|
|
310
|
+
return { colorVar, tone: e.color };
|
|
311
|
+
}
|
|
312
|
+
// No choice was made, so this colour is OUR match of an observed hex and
|
|
313
|
+
// repairing it is fair game.
|
|
314
|
+
const ranked = tokens.map((t) => ({ t, ratio: contrastRatio(t.hex, groundHex) }))
|
|
315
|
+
.filter((v) => v.ratio != null).sort((a, b) => b.ratio - a.ratio);
|
|
316
|
+
const best = ranked[0];
|
|
317
|
+
if (!best || best.ratio < floor) warnings.push(`${e.id}: palette cannot meet contrast ${floor}:1 on its rendered background`);
|
|
318
|
+
return { colorVar: best ? variable(best.t) : colorVar, tone: e.color };
|
|
319
|
+
};
|
|
320
|
+
const maps = (entries) => Object.fromEntries(Object.entries(entries).map(([k, t]) => [k, variable(t) ?? "inherit"]));
|
|
321
|
+
const kit = KIT.replace(/const FILL = .*?;/, `const FILL = ${JSON.stringify({ ...maps(roles.fill), none: "transparent" })};`)
|
|
322
|
+
.replace(/const TONE = .*?;/, `const TONE = ${JSON.stringify(maps(roles.tone))};`)
|
|
323
|
+
.replace(/const ROLE = \{[\s\S]*?\n \};/, (block) => roleLadder(block, typeLadder(ramp, spec.typeStepOffset ?? 0)));
|
|
324
|
+
const slot = spec.slot;
|
|
325
|
+
// Also for a spec written before the read learned these: the model's
|
|
326
|
+
// stand-ins on painted boxes.
|
|
327
|
+
// A chart draws its own marks; anything inside its rect would be painted
|
|
328
|
+
// over the native chart a second time.
|
|
329
|
+
const read = new Map((spec.elements ?? []).map((e) => [e.id, e]));
|
|
330
|
+
spec.elements = dropChartMarks(spec.elements ?? []);
|
|
331
|
+
if (!ablated("foldListMarkers")) spec.elements = foldListMarkers(spec.elements, spec.connectors ?? [], spec.repeats ?? []);
|
|
332
|
+
// A chart's own legend and labels drawn beside it go back into the chart.
|
|
333
|
+
foldChartLabels(spec, warnings);
|
|
334
|
+
if (!ablated("dropStandIns")) dropStandIns(spec.elements ?? []);
|
|
335
|
+
if (normalize) {
|
|
336
|
+
}
|
|
337
|
+
// Folding drops the children it absorbed, so the index and any connector
|
|
338
|
+
// that named one have to follow it. Only those: a connector pointing at an
|
|
339
|
+
// element that never existed is the spec talking, and it still gets reported.
|
|
340
|
+
const beforeFold = new Set((spec.elements ?? []).map((e) => e.id));
|
|
341
|
+
if (!ablated("foldLabelsIntoShape")) spec.elements = foldLabelsIntoShape(spec.elements ?? [], { connectors: spec.connectors ?? [] });
|
|
342
|
+
// A block that still cannot be one text says so (text-blocks.mjs).
|
|
343
|
+
spec.elements = foldTextBlocks(spec.elements ?? [], { connectors: spec.connectors ?? [], notes: warnings });
|
|
344
|
+
byId = new Map((spec.elements ?? []).map((e) => [e.id, e]));
|
|
345
|
+
const absorbed = new Set([...beforeFold].filter((id) => !byId.has(id)));
|
|
346
|
+
spec.connectors = (spec.connectors ?? []).filter((c) => !absorbed.has(c.from) && !absorbed.has(c.to));
|
|
347
|
+
// Repeated organisms the reader declared: one template, one exact
|
|
348
|
+
// arrangement (repeat-groups.mjs) — before any line is fitted to the nodes.
|
|
349
|
+
if (normalize) {
|
|
350
|
+
warnings.push(...remapRepeats(spec, read), ...buildRepeats(spec).filter((n) => n.endsWith("kept as drawn")));
|
|
351
|
+
// A seated child keeps its seat in the template's size (seat.mjs), what
|
|
352
|
+
// stands in a doughnut's hole stays centred in its ring (chart-hole.mjs); a
|
|
353
|
+
// declared stack is laid out over the built members (stack.mjs).
|
|
354
|
+
applySeats(spec);
|
|
355
|
+
centreHoles(spec);
|
|
356
|
+
warnings.push(...remapStacks(spec, read), ...remapGrids(spec, read));
|
|
357
|
+
applyStacks(spec);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// What each id is in a declared stack (stack.mjs): "n:r" a part of row r,
|
|
361
|
+
// "n:gr" drawn in the gap under row r. Written onto the lines as `stack` /
|
|
362
|
+
// `data-sm-stack`, so every later reader and writer of the JSX keeps the
|
|
363
|
+
// rows together (settle.mjs parseTranscribedJsx rebuilds the stacks from it).
|
|
364
|
+
const stackMark = new Map();
|
|
365
|
+
(spec.stacks ?? []).forEach((g, gi) => {
|
|
366
|
+
g.rows.forEach((row, ri) => { for (const id of row) stackMark.set(id, `${gi + 1}:${ri + 1}`); });
|
|
367
|
+
for (const it of g.band?.between ?? []) stackMark.set(it.id, `${gi + 1}:g${it.gap + 1}:${Math.round((it.frac ?? 0.5) * 100) / 100}`);
|
|
368
|
+
});
|
|
369
|
+
// A grid's own divider (across its columns): "G<k>:g<r>:<f>" — GRIDS names the member stacks.
|
|
370
|
+
(spec.grids ?? []).forEach((grid, ki) => {
|
|
371
|
+
for (const it of grid.band?.between ?? []) stackMark.set(it.id, `G${ki + 1}:g${it.gap + 1}:${Math.round((it.frac ?? 0.5) * 100) / 100}`);
|
|
372
|
+
});
|
|
373
|
+
const gridsConst = (spec.grids ?? []).length
|
|
374
|
+
? ` const GRIDS = ${JSON.stringify(Object.fromEntries((spec.grids ?? []).map((grid, ki) => [ki + 1, { stacks: gridStacks(spec, grid).map((g) => (spec.stacks ?? []).indexOf(g) + 1), rows: grid.rows }])))};\n`
|
|
375
|
+
: "";
|
|
376
|
+
const stackAttr = (id) => (stackMark.has(id) ? ` stack=${q(stackMark.get(id))}` : "");
|
|
377
|
+
// A divider between two paragraphs of one block: "block:after:frac" (block-dividers.mjs).
|
|
378
|
+
const divideMark = new Map();
|
|
379
|
+
for (const e of spec.elements ?? []) for (const d of e.dividers ?? []) divideMark.set(d.id, `${e.id}:${d.after}:${Math.round(d.frac * 100) / 100}`);
|
|
380
|
+
// ---- Every line, as ONE native object (line-native.mjs), decided before
|
|
381
|
+
// any element is written: a node a line glues to carries its preset, and an
|
|
382
|
+
// element a line stands for — a rule, a ring, a mark, a plot stroke, the
|
|
383
|
+
// arrowhead or end dot that became its cap — is written as the line only.
|
|
384
|
+
const { lines: readIn } = readLines(spec);
|
|
385
|
+
// The line id each connector is written under, from the list THIS skeleton
|
|
386
|
+
// read (`readLines` returns the connectors' lines first, in order): a spec
|
|
387
|
+
// index read elsewhere drifts once normalization filtered a connector out.
|
|
388
|
+
const connectorLines = (spec.connectors ?? []).map((c, i) => ({ id: readIn[i].id, from: c.from ?? null, to: c.to ?? null }));
|
|
389
|
+
// The reader connector a line was read from (its declared ids and width).
|
|
390
|
+
const rawConnector = (line) => (line.source?.kind === "connector" ? spec.connectors?.[line.source.index] ?? null : null);
|
|
391
|
+
const consumed = new Set(readIn.filter((l) => l.source?.kind !== "connector").map((l) => l.source.id));
|
|
392
|
+
const plotOf = new Map();
|
|
393
|
+
const dashOf = (d) => (d === "dashed" ? "dash" : d === "dotted" ? "sysDot" : "solid");
|
|
394
|
+
const drawnLines = [];
|
|
395
|
+
for (const e of spec.elements ?? []) {
|
|
396
|
+
if (consumed.has(e.id)) continue;
|
|
397
|
+
const owner = illustrationOwner(e);
|
|
398
|
+
// Rules (whatever words the reader put on one) and the strokes of a kept
|
|
399
|
+
// illustration: the lines the intake leaves to the writer.
|
|
400
|
+
const traces = e.kind === "rule" && (e.w > 0 || e.h > 0)
|
|
401
|
+
? [e.w >= e.h ? [{ x: e.x, y: e.y + e.h / 2 }, { x: e.x + e.w, y: e.y + e.h / 2 }] : [{ x: e.x + e.w / 2, y: e.y }, { x: e.x + e.w / 2, y: e.y + e.h }]]
|
|
402
|
+
: owner ? strokeTraces(e) : [];
|
|
403
|
+
if (!traces.length) continue;
|
|
404
|
+
consumed.add(e.id);
|
|
405
|
+
if (owner) plotOf.set(e.id, owner.id);
|
|
406
|
+
traces.forEach((t, k) => drawnLines.push({
|
|
407
|
+
id: traces.length > 1 ? `${e.id}-${k + 1}` : e.id,
|
|
408
|
+
role: owner ? "plot" : "divider",
|
|
409
|
+
source: { kind: e.kind === "rule" ? "rule" : "path", id: e.id },
|
|
410
|
+
ends: [t[0], t.at(-1)].map((at) => ({ node: null, at, cap: { type: "none" }, contact: "none" })),
|
|
411
|
+
path: { kind: t.length === 2 ? "straight" : "curve", points: t, corners: "sharp" },
|
|
412
|
+
stroke: { widthPx: e.stroke?.width ?? null, colorHex: e.stroke?.colorHex ?? e.colorHex ?? (e.kind === "rule" ? e.fillHex : null) ?? null, token: e.stroke?.colorToken ?? e.colorToken ?? null, dash: dashOf(e.stroke?.dash ?? e.dash) },
|
|
413
|
+
read: { color: e.color ?? null },
|
|
414
|
+
}));
|
|
415
|
+
}
|
|
416
|
+
const allLines = [...readIn, ...drawnLines];
|
|
417
|
+
// A ring stays a glue target as a line shape (it has its own sites).
|
|
418
|
+
const nodes = new Map();
|
|
419
|
+
for (const e of spec.elements ?? []) {
|
|
420
|
+
if (consumed.has(e.id) && !readIn.some((l) => l.role === "frame" && l.source.id === e.id)) continue;
|
|
421
|
+
const n = nodePreset(e);
|
|
422
|
+
if (n) nodes.set(e.id, n);
|
|
423
|
+
}
|
|
424
|
+
const loops = loopCircles(allLines, nodes);
|
|
425
|
+
const natives = [];
|
|
426
|
+
for (const line of allLines) {
|
|
427
|
+
if (line.role === "frame") {
|
|
428
|
+
const n = ringNative(line.id, byId.get(line.source.id)?.geometry);
|
|
429
|
+
if (n) natives.push({ line, n });
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
const n = fitNative(line, nodes, loops.get(line.id) ?? null, { slot });
|
|
433
|
+
if (!n) {
|
|
434
|
+
const c = rawConnector(line);
|
|
435
|
+
warnings.push(c ? `connector ${c.from ?? "(point)"} -> ${c.to ?? "(point)"} names an unknown element; skipped` : `line ${line.id}: an end names no element on this slide and carries no point; skipped`);
|
|
436
|
+
dropped.push(line.id);
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
warnings.push(...n.notes);
|
|
440
|
+
natives.push({ line, n });
|
|
441
|
+
}
|
|
442
|
+
// Elbows off one site share one rail where the concept draws them so.
|
|
443
|
+
alignRails(natives.map(({ n }) => n));
|
|
444
|
+
const glueTargets = new Set(natives.flatMap(({ n }) => [n.from?.node, n.to?.node]).filter(Boolean));
|
|
445
|
+
// Width and colour are the design's tokens: the reader's weight class and
|
|
446
|
+
// the design colour its hex snaps to — never a measured px, never a hex.
|
|
447
|
+
// They are defaults; the agent weighs lines against the slide's shapes.
|
|
448
|
+
const accentVar = variable(roles.tone.accent);
|
|
449
|
+
const CLASS_COLOR = { "connector-accent": "var(--fg-accent)", "connector-muted": "var(--fg-3)" };
|
|
450
|
+
const CLASS_WEIGHT = { "connector-accent": "medium", "connector-muted": "default" };
|
|
451
|
+
const capIds = new Set();
|
|
452
|
+
// What a line is drawn on: the smallest painted element holding all of it.
|
|
453
|
+
// null when none does — a line between drawn figures has no one ground this
|
|
454
|
+
// can name, and the gate measures it on the render instead.
|
|
455
|
+
const lineGroundHex = (line) => {
|
|
456
|
+
const pts = [...(line.ends ?? []).map((end) => end.at), ...(line.path?.points ?? [])]
|
|
457
|
+
.filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y));
|
|
458
|
+
if (!pts.length) return null;
|
|
459
|
+
const x0 = Math.min(...pts.map((p) => p.x)), x1 = Math.max(...pts.map((p) => p.x));
|
|
460
|
+
const y0 = Math.min(...pts.map((p) => p.y)), y1 = Math.max(...pts.map((p) => p.y));
|
|
461
|
+
const holder = (spec.elements ?? []).filter((p) => p.fill && p.fill !== "none" && p.w > 0 && p.h > 0
|
|
462
|
+
&& p.x <= x0 && p.y <= y0 && p.x + p.w >= x1 && p.y + p.h >= y1)
|
|
463
|
+
.sort((a, b) => a.w * a.h - b.w * b.h)[0];
|
|
464
|
+
return holder ? paintHexOf(holder) : null;
|
|
465
|
+
};
|
|
466
|
+
const linePaint = (line, n) => {
|
|
467
|
+
const st = line.stroke ?? {};
|
|
468
|
+
// The width the READER declared (null when it declared none: the design
|
|
469
|
+
// default weight).
|
|
470
|
+
const declared = st.widthPx;
|
|
471
|
+
const colorVar = namedVar(st.token, st.colorHex) ?? snapVar(st.colorHex);
|
|
472
|
+
const tone = line.read?.color ?? null;
|
|
473
|
+
const className = (colorVar ? colorVar === accentVar || colorVar === CLASS_COLOR["connector-accent"] : tone === "accent") ? "connector-accent" : "connector-muted";
|
|
474
|
+
let color = colorVar ?? (tone && tone !== "accent" && roles.tone[tone] ? variable(roles.tone[tone]) : null);
|
|
475
|
+
const style = [];
|
|
476
|
+
const weight = weightClass(declared);
|
|
477
|
+
if (weight !== "default" && weight !== CLASS_WEIGHT[className]) style.push(`borderWidth: "var(--bw-${weight})"`);
|
|
478
|
+
const dash = st.dash === "dash" || st.dash === "sysDot" ? st.dash : null;
|
|
479
|
+
const w = st.widthPx ?? LINE_TOL.defaultWidthPx;
|
|
480
|
+
// The head the route carries: the concept's size, or the largest smaller
|
|
481
|
+
// one the gate accepts on this weight. ponytail: the canonical --bw-*
|
|
482
|
+
// rungs, not the brand's px (a brand bold wider than 4px can still refuse).
|
|
483
|
+
const strokePx = LINE_WEIGHT_RUNGS_PX[weight !== "default" ? weight : CLASS_WEIGHT[className]] ?? 0;
|
|
484
|
+
const caps = (line.ends ?? []).map((end, i) => {
|
|
485
|
+
const c = end.cap;
|
|
486
|
+
if (!c || !c.type || c.type === "none") return null;
|
|
487
|
+
// A dot larger than the largest oval line end, or on a route too short
|
|
488
|
+
// for even the smallest, stays the reader's drawn dot.
|
|
489
|
+
if (c.type === "oval" && (c.sizePx > LINE_TOL.capLen.lg * w || (c.from && headRoutePx("sm", strokePx) > capRoutePx(n, i) - 0.5))) return null;
|
|
490
|
+
if (c.from) capIds.add(c.from);
|
|
491
|
+
return capAttr(c, capRoutePx(n, i), strokePx);
|
|
492
|
+
});
|
|
493
|
+
// The image draws the line apart from what it sits on; a snap onto the
|
|
494
|
+
// ground's own colour draws nothing. Keep the visibility gate's floor
|
|
495
|
+
// (diagram-geometry.ts: 1.3:1 with a head or glued end, 1.05:1 for a
|
|
496
|
+
// plain rule) with the design colour nearest the one the image shows.
|
|
497
|
+
const ground = lineGroundHex(line);
|
|
498
|
+
const strokeHex = hexOf.get(color ?? CLASS_COLOR[className]);
|
|
499
|
+
const floor = caps.some(Boolean) || n?.from?.node || n?.to?.node ? 1.3 : 1.05;
|
|
500
|
+
if (ground && strokeHex && (contrastRatio(strokeHex, ground) ?? Infinity) < floor) {
|
|
501
|
+
const alt = rankTokens(st.colorHex ?? strokeHex, tokens).map((v) => v.t).find((t) => (contrastRatio(t.hex, ground) ?? 0) >= floor);
|
|
502
|
+
if (alt) {
|
|
503
|
+
warnings.push(`${line.id ?? line.source?.id ?? "a line"}: ${color ?? CLASS_COLOR[className]} would vanish into the colour it is drawn on; ${alt.name} keeps it visible`);
|
|
504
|
+
color = ref(alt);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if (color && color !== CLASS_COLOR[className]) style.push(`color: ${q(color)}`);
|
|
508
|
+
return { className, style, caps, dash, linecap: dash === "sysDot" ? "rnd" : null, strokePx };
|
|
509
|
+
};
|
|
510
|
+
// A free line (no glue, not a mark or a plot stroke) is seated among the
|
|
511
|
+
// shapes it runs through (placement.mjs); every other line paints last.
|
|
512
|
+
const lineOut = natives.map(({ line, n }) => {
|
|
513
|
+
const paint = linePaint(line, n);
|
|
514
|
+
const jsx = lineDirective(n, { ...paint, plotOwner: plotOf.get(line.source?.id) ?? null, stack: stackMark.get(line.id) ?? stackMark.get(line.source?.id) ?? null, divides: divideMark.get(line.id) ?? divideMark.get(line.source?.id) ?? null });
|
|
515
|
+
const free = !n.from?.node && !n.to?.node && line.role !== "mark" && !plotOf.has(line.source?.id);
|
|
516
|
+
return free ? { jsx, points: nativePoints(n), capped: paint.caps.some(Boolean), strokePx: paint.strokePx } : { jsx };
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
// Drawn figures the reader sketched: one <Figure> each, compiled exactly at
|
|
520
|
+
// render (figure-sketch.mjs) — straight edges straight, arcs on their
|
|
521
|
+
// circle, copies and mirrored halves identical. A figure a line glues to
|
|
522
|
+
// keeps its preset box: a custom outline has no connection sites.
|
|
523
|
+
const sketch = spec.sketch ? expandSketch(spec.sketch) : null;
|
|
524
|
+
if (sketch) warnings.push(...sketch.issues);
|
|
525
|
+
const elementById = new Map((spec.elements ?? []).map((e) => [e.id, e]));
|
|
526
|
+
const sketched = new Map([...(sketch?.figures ?? [])].filter(([id]) => {
|
|
527
|
+
const e = elementById.get(id);
|
|
528
|
+
return e && FIGURE_KINDS.has(e.kind) && e.fill && e.fill !== "none" && !glueTargets.has(id);
|
|
529
|
+
}).map(([id, props]) => {
|
|
530
|
+
const fit = fitFigureToSlot(props, slot);
|
|
531
|
+
if (fit.moved) warnings.push(`${id}: its figure reached outside the content slot; moved in`);
|
|
532
|
+
return [id, fit.props];
|
|
533
|
+
}));
|
|
534
|
+
const figureLine = (e, fig) => {
|
|
535
|
+
const fillVar = fillVarOf(e);
|
|
536
|
+
const attrs = [`id=${q(e.id)}`, figureJsxAttrs(fig), `fill=${fillVar ? q(fillVar) : `{FILL[${q(e.fill)}]}`}`];
|
|
537
|
+
if (e.stroke && Number(e.stroke.width) > 0) {
|
|
538
|
+
const v = namedVar(e.stroke.colorToken) ?? snapVar(e.stroke.colorHex);
|
|
539
|
+
attrs.push(`stroke=${v ? q(v) : `{TONE[${q(e.color ?? "dark")}]}`}`, `strokeWidth=${q(`var(--bw-${weightClass(e.stroke.width)})`)}`);
|
|
540
|
+
}
|
|
541
|
+
return `<Figure ${attrs.join(" ")} />`;
|
|
542
|
+
};
|
|
543
|
+
const seated = ablated("seatedAssets") ? new Map() : seatedAssets(spec.elements ?? [], {
|
|
544
|
+
skip: new Set([...consumed, ...capIds, ...sketched.keys()]),
|
|
545
|
+
glued: glueTargets,
|
|
546
|
+
});
|
|
547
|
+
const isMark = (e) => e.kind === "icon" && glyphMarkOf(e.icon?.query, e.icon?.keywords ?? []) != null;
|
|
548
|
+
const assetPaint = (e) => (e.kind === "icon" ? textPaint({ ...e, color: e.icon?.color === "white" ? "light" : e.icon?.color ?? "dark" }, 3) : {});
|
|
549
|
+
// Adds an engine asset's fetch row (flat or seated) and returns its reference.
|
|
550
|
+
const requestAsset = (e, paint = assetPaint(e)) => {
|
|
551
|
+
const file = `${nn}-${e.id}.asset`;
|
|
552
|
+
icons.push({ id: e.id, kind: e.kind, file, query: e.image?.query ?? e.icon?.query ?? e.id,
|
|
553
|
+
...(e.icon?.keywords?.length ? { keywords: e.icon.keywords } : {}),
|
|
554
|
+
color: e.icon?.color ?? "dark", colorHex: e.kind === "icon" ? hexOf.get(paint.colorVar) ?? null : null,
|
|
555
|
+
// A picture fetched from a pictogram library (no photo engine) is a
|
|
556
|
+
// line drawing in its source colour, black: tint it the design accent.
|
|
557
|
+
...(e.kind === "image" ? { tintHex: hexOf.get(variable(roles.tone.accent)) ?? null } : {}) });
|
|
558
|
+
return `./assets/${file}`;
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
// Where each element's JSX starts: a free line is seated before the first
|
|
562
|
+
// painted one it runs through (placement.mjs).
|
|
563
|
+
const placed = [];
|
|
564
|
+
for (const raw of paintOrdered(spec.elements ?? [])) {
|
|
565
|
+
if (consumed.has(raw.id) || capIds.has(raw.id)) continue;
|
|
566
|
+
placed.push({ el: raw, at: lines.length });
|
|
567
|
+
// One element that cannot be written is one element reported, never a
|
|
568
|
+
// slide (or a deck) lost: x58 lost a whole deck to one malformed arc.
|
|
569
|
+
try {
|
|
570
|
+
// A text on the slot's bottom edge is written a hair higher (writtenTop).
|
|
571
|
+
const top = writtenTop(raw, slot);
|
|
572
|
+
const e = top !== raw.y ? { ...raw, y: top } : raw;
|
|
573
|
+
const text = cleanText(e.text, { printed: textIsPrinted(e) });
|
|
574
|
+
const base = `id=${q(e.id)} x={${e.x}} y={${e.y}} w={${e.w}}${stackAttr(e.id)}`;
|
|
575
|
+
// A sketched figure: the shape is the Figure; its words (a named shape's
|
|
576
|
+
// label) ride on an unpainted box at the element's rect below.
|
|
577
|
+
const fig = sketched.get(e.id);
|
|
578
|
+
if (fig) {
|
|
579
|
+
lines.push(figureLine(e, fig));
|
|
580
|
+
if (text == null || e.kind === "path") continue;
|
|
581
|
+
}
|
|
582
|
+
// The lead-in and the emphasis spans the spec folded into this text.
|
|
583
|
+
const emphasise = (body, t = e) => {
|
|
584
|
+
if (t.leadIn) {
|
|
585
|
+
const lead = escapeJsx(t.leadIn.trim());
|
|
586
|
+
if (body.startsWith(lead)) body = `<b>${lead}</b>${body.slice(lead.length)}`;
|
|
587
|
+
}
|
|
588
|
+
for (const em of t.emphasis ?? []) {
|
|
589
|
+
const needle = escapeJsx(String(em).trim());
|
|
590
|
+
if (needle && body.includes(needle) && !body.includes(`<b>${needle}</b>`)) body = body.replace(needle, `<b>${needle}</b>`);
|
|
591
|
+
}
|
|
592
|
+
// The pieces of the sentence painted otherwise: one `<R>` each (text-blocks.mjs).
|
|
593
|
+
if (t.runs?.length) {
|
|
594
|
+
const role = t.role ?? (t.kind === "number" ? "number" : "body");
|
|
595
|
+
const floorOf = (r) => (LARGE_ROLES.has(r) ? 3 : contrastFloor);
|
|
596
|
+
const own = textPaint({ ...e, ...t }, floorOf(role));
|
|
597
|
+
body = wrapRuns(body, t.runs, (run) => {
|
|
598
|
+
const runRole = run.role ?? (run.kind === "number" ? "number" : role);
|
|
599
|
+
const paint = textPaint({ ...e, ...t, ...run }, floorOf(runRole));
|
|
600
|
+
const out = runRole !== role ? [`role=${q(runRole)}`] : [];
|
|
601
|
+
if (paint.colorVar && paint.colorVar !== own.colorVar) out.push(`colorVar=${q(paint.colorVar)}`);
|
|
602
|
+
else if (!paint.colorVar && paint.tone && paint.tone !== own.tone) out.push(`tone=${q(paint.tone)}`);
|
|
603
|
+
if (run.weight === "bold" && t.weight !== "bold") out.push("bold");
|
|
604
|
+
return out;
|
|
605
|
+
}, escapeJsx);
|
|
606
|
+
}
|
|
607
|
+
return body;
|
|
608
|
+
};
|
|
609
|
+
const textEl = (id, role) => {
|
|
610
|
+
const frame = textFrame(e);
|
|
611
|
+
const attrs = [`id=${q(id)}`, `x={${frame.x}}`, `y={${frame.y}}`, `w={${frame.w}}`];
|
|
612
|
+
if (stackMark.has(e.id)) attrs.push(`stack=${q(stackMark.get(e.id))}`);
|
|
613
|
+
if (frame.rotation) attrs.push(`h={${frame.h}}`, `rotation={${frame.rotation}}`);
|
|
614
|
+
if (e.placement === "center" && e.valign === "middle") attrs.push(`h={${e.h}}`, 'valign="middle"');
|
|
615
|
+
attrs.push(`role=${q(role)}`);
|
|
616
|
+
// WCAG's large text (≥24px, or bold ≥18.67px) reads at 3:1; holding a
|
|
617
|
+
// coloured heading to 4.5:1 turned it black (2.4% of coloured texts).
|
|
618
|
+
// The heading roles sit on those rungs of the ramp.
|
|
619
|
+
const paint = textPaint(e, LARGE_ROLES.has(role) ? 3 : contrastFloor);
|
|
620
|
+
if (paint.colorVar) attrs.push(`colorVar=${q(paint.colorVar)}`);
|
|
621
|
+
// A lone list item: a list of one, never a glyph typed into a paragraph.
|
|
622
|
+
if (e.marker) {
|
|
623
|
+
if (paint.tone) attrs.push(`tone=${q(paint.tone)}`);
|
|
624
|
+
const marker = markerVar(e);
|
|
625
|
+
if (marker) attrs.push(`markerVar=${q(marker)}`);
|
|
626
|
+
return `<L ${[...attrs, ...markerAttrs(e.marker)].join(" ")}><li>${emphasise(jsxText(text))}</li></L>`;
|
|
627
|
+
}
|
|
628
|
+
// One line in the image stays one line: no wrapping into the row below —
|
|
629
|
+
// unless the settle measured that its room cannot hold it (`wrap`,
|
|
630
|
+
// settle.mjs fitTextToImage): then it wraps inside its box instead of
|
|
631
|
+
// running past it.
|
|
632
|
+
if (!String(text).includes("\n") && String(text).trim().length <= 60 && !raw.wrap) attrs.push("nowrap");
|
|
633
|
+
if (e.weight === "bold") attrs.push("bold");
|
|
634
|
+
if (paint.tone) attrs.push(`tone=${q(paint.tone)}`);
|
|
635
|
+
if (e.align && e.align !== "left") attrs.push(`align=${q(e.align)}`);
|
|
636
|
+
return `<T ${attrs.join(" ")}>${emphasise(jsxText(text))}</T>`;
|
|
637
|
+
};
|
|
638
|
+
// A text block: one T placing the block, one P per paragraph, a run of
|
|
639
|
+
// items as one L (list-jsx.mjs). Each paragraph is painted and wrapped
|
|
640
|
+
// like a T of its own role; a block that is only a list is the L itself.
|
|
641
|
+
if (e.paragraphs?.length) {
|
|
642
|
+
const frame = textFrame(e);
|
|
643
|
+
const paintOf = (para) => {
|
|
644
|
+
const role = para.role ?? (para.kind === "number" ? "number" : "body");
|
|
645
|
+
return { role, paint: textPaint({ ...e, ...para }, LARGE_ROLES.has(role) ? 3 : contrastFloor) };
|
|
646
|
+
};
|
|
647
|
+
// Each paragraph is aligned by its own `align` (one block may mix them:
|
|
648
|
+
// a centred figure over a left label); an indent is written only where
|
|
649
|
+
// it is structure (paragraphIndent).
|
|
650
|
+
const attrs = (para, nowrap = false) => {
|
|
651
|
+
const { role, paint } = paintOf(para);
|
|
652
|
+
const out = [`role=${q(role)}`];
|
|
653
|
+
if (paint.colorVar) out.push(`colorVar=${q(paint.colorVar)}`);
|
|
654
|
+
const indent = paragraphIndent(para, e, spec.elements ?? []);
|
|
655
|
+
if (indent > 1) out.push(`indent={${indent}}`);
|
|
656
|
+
if (nowrap && !String(para.text).includes("\n") && String(para.text).trim().length <= 60 && !raw.wrap) out.push("nowrap");
|
|
657
|
+
if (nowrap && para.weight === "bold") out.push("bold");
|
|
658
|
+
if (paint.tone) out.push(`tone=${q(paint.tone)}`);
|
|
659
|
+
if (para.align && para.align !== "left") out.push(`align=${q(para.align)}`);
|
|
660
|
+
if (para.marker) {
|
|
661
|
+
const marker = markerVar({ ...e, ...para });
|
|
662
|
+
if (marker) out.push(`markerVar=${q(marker)}`);
|
|
663
|
+
}
|
|
664
|
+
return out;
|
|
665
|
+
};
|
|
666
|
+
const { children, single } = blockChildren(e.paragraphs, {
|
|
667
|
+
attrs,
|
|
668
|
+
para: (para) => `<P ${attrs(para, true).join(" ")}>${emphasise(jsxText(para.text), { ...e, ...para })}</P>`,
|
|
669
|
+
item: (para, head) => {
|
|
670
|
+
const body = emphasise(jsxText(para.text), { ...e, ...para });
|
|
671
|
+
const own = paintOf(para).paint;
|
|
672
|
+
const list = paintOf(head).paint;
|
|
673
|
+
if (own.colorVar === list.colorVar && own.tone === list.tone) return body;
|
|
674
|
+
return `<span style={{ color: ${own.colorVar ? q(own.colorVar) : `TONE[${q(own.tone ?? "dark")}]`} }}>${body}</span>`;
|
|
675
|
+
},
|
|
676
|
+
});
|
|
677
|
+
// `block`: this T is ONE text — two elements carrying it are that text split apart (lint, [split:error]).
|
|
678
|
+
const place = `id=${q(e.id)} x={${frame.x}} y={${frame.y}} w={${frame.w}}${stackAttr(e.id)} block=${q(e.id)}`;
|
|
679
|
+
lines.push(single ? children.replace("<L ", `<L ${place} `) : `<T ${place}>${children}</T>`);
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
if ((e.kind === "text" || e.kind === "number") && !(e.shape && e.fill && e.fill !== "none")) {
|
|
683
|
+
if (text == null) continue;
|
|
684
|
+
lines.push(textEl(e.id, e.role ?? (e.kind === "number" ? "number" : "body")));
|
|
685
|
+
} else if (e.kind === "icon" || e.kind === "image") {
|
|
686
|
+
// A bound original: the user's own picture. It is placed from the deck's
|
|
687
|
+
// registry and never joins the fetch list below — searching for a
|
|
688
|
+
// lookalike is exactly the substitution the contract exists to prevent —
|
|
689
|
+
// and it has no query to fall back to either (normalizeImagePayload drops
|
|
690
|
+
// one the moment a binding is present).
|
|
691
|
+
const useId = e.image?.assetUseId ?? null;
|
|
692
|
+
if (useId) {
|
|
693
|
+
const b = bound.get(useId);
|
|
694
|
+
if (!b) {
|
|
695
|
+
warnings.push(
|
|
696
|
+
`${e.id}: the spec binds image use ${useId}, which this deck's plan and registry do not resolve — nothing was searched for; import the source, record the use, then transcribe again`,
|
|
697
|
+
);
|
|
698
|
+
dropped.push(e.id);
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
const attrs = [base, `h={${e.h}}`, `src=${q(b.src)}`, `use=${q(useId)}`, `fit=${q(b.fit ?? "contain")}`, `pos=${q(positionValue(b.position))}`];
|
|
702
|
+
const crop = cropAttrs(b.crop);
|
|
703
|
+
if (crop) attrs.push(`crop=${q(crop.crop)}`, `view=${q(crop.view)}`);
|
|
704
|
+
lines.push(`<I ${attrs.join(" ")} />`);
|
|
705
|
+
for (const note of b.notes ?? []) warnings.push(`${e.id}: ${note}`);
|
|
706
|
+
if (b.slot) {
|
|
707
|
+
warnings.push(
|
|
708
|
+
`${e.id}: image use ${useId} names layout image slot "${b.slot}"; this skeleton places the picture at the concept's own rect instead`,
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
const paint = assetPaint(e);
|
|
714
|
+
// A mark of a few plain strokes is drawn, not searched for: the icon
|
|
715
|
+
// libraries carry pictures of things, and x51 missed every tick, chevron
|
|
716
|
+
// and ellipsis (23 of 79 misses), each shipping as an empty carrier. Its
|
|
717
|
+
// strokes are native line pieces with round caps (a chain group), an
|
|
718
|
+
// arrow one line with its head, an ellipsis three small discs. A seated
|
|
719
|
+
// mark is drawn at its carrier's centre.
|
|
720
|
+
const carrier = seated.get(raw.parent) === raw ? elementById.get(raw.parent) : null;
|
|
721
|
+
const mark = e.kind === "icon" ? glyphMarkStrokes(carrier ? { ...e, x: carrier.x + (carrier.w - e.w) / 2, y: carrier.y + (carrier.h - e.h) / 2 } : e) : null;
|
|
722
|
+
if (mark) {
|
|
723
|
+
const color = paint.colorVar ?? variable(roles.tone[paint.tone ?? "dark"]);
|
|
724
|
+
if (mark.kind === "dots") {
|
|
725
|
+
mark.dots.forEach((d, k) => lines.push(`<B id=${q(`${e.id}-${k + 1}`)} x={${d.x - d.r}} y={${d.y - d.r}} w={${2 * d.r}} h={${2 * d.r}} shape="circle"${color ? ` fillVar=${q(color)}` : ""} />`));
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
const pieces = mark.strokes.flatMap((st) => st.slice(1).map((p, i) => ({ object: "cxn", prst: "straightConnector1", av: {}, rot: 0, from: { point: st[i] }, to: { point: p }, start: st[i], end: p })));
|
|
729
|
+
let n = pieces.length === 1 ? { ...pieces[0], id: e.id } : chainNative(e.id, pieces);
|
|
730
|
+
// An arrow too short for even the smallest head grows to carry it,
|
|
731
|
+
// where that stays in the free room around the glyph.
|
|
732
|
+
const long = mark.kind === "arrow" ? stretched(n, Math.ceil(headRoutePx("sm"))) : null;
|
|
733
|
+
if (long && runsClear(long.start, long.end, (spec.elements ?? []).filter((o) => o.id !== e.id))) n = long;
|
|
734
|
+
lineOut.push({ jsx: lineDirective(n, {
|
|
735
|
+
className: "connector-muted",
|
|
736
|
+
style: color && color !== CLASS_COLOR["connector-muted"] ? [`color: ${q(color)}`] : [],
|
|
737
|
+
caps: [null, mark.kind === "arrow" ? capAttr({ type: "triangle", size: "med" }, capRoutePx(n, 1)) : null],
|
|
738
|
+
linecap: "rnd",
|
|
739
|
+
}) });
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
lines.push(`<I ${base} h={${e.h}} src=${q(requestAsset(e, paint))} />`);
|
|
743
|
+
} else if (e.kind === "rule") {
|
|
744
|
+
// Every rule with a length is a line (above); a rule with none draws nothing.
|
|
745
|
+
continue;
|
|
746
|
+
} else if (e.kind === "path" && e.geometry?.type === "arc" && Math.abs(e.geometry.sweep) >= 360 && !(e.fill && e.fill !== "none")) {
|
|
747
|
+
// A full unfilled ring the intake did not take as a line (it carries
|
|
748
|
+
// words): a native ellipse box with the design's line weight.
|
|
749
|
+
const g0 = e.geometry;
|
|
750
|
+
const ringVar = namedVar(e.stroke?.colorToken ?? e.colorToken, e.stroke?.colorHex ?? e.colorHex) ?? snapVar(e.stroke?.colorHex ?? e.colorHex);
|
|
751
|
+
lines.push(`<B id=${q(e.id)} x={${g0.cx - g0.rx}} y={${g0.cy - g0.ry}} w={${2 * g0.rx}} h={${2 * g0.ry}} shape="ellipse" style={{ borderWidth: ${q(`var(--bw-${weightClass(e.stroke?.width)})`)}, borderStyle: ${q(e.stroke?.dash === "dashed" ? "dashed" : "solid")}, borderColor: ${ringVar ? q(ringVar) : `TONE[${q(e.color ?? "muted")}]`} }} />`);
|
|
752
|
+
} else if (e.kind === "path" && ["arc", "sector"].includes(e.geometry?.type) && e.fill && e.fill !== "none" && e.geometry.innerRatio > 0 && e.geometry.innerRatio < 1) {
|
|
753
|
+
// A filled arc with a hole is a band: a native donut (full) or blockArc
|
|
754
|
+
// (a segment), filled with the design colour. The outline takes the
|
|
755
|
+
// fill's own colour at the lightest weight, so no second colour rims it.
|
|
756
|
+
const n = bandNative(e.id, reconcileArc(e.geometry, e));
|
|
757
|
+
if (!n) {
|
|
758
|
+
warnings.push(`${e.id}: its band geometry cannot be drawn; left out — redraw it by hand from the concept`);
|
|
759
|
+
dropped.push(e.id);
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
const fillVar = fillVarOf(e) ?? variable(roles.fill[e.fill]);
|
|
763
|
+
const fill = fillVar ? q(fillVar) : `FILL[${q(e.fill)}]`;
|
|
764
|
+
// A band is a shape: it paints in its place, under the labels on it.
|
|
765
|
+
lines.push(lineDirective(n, { className: "connector-muted", style: [`borderWidth: "var(--bw-hairline)"`, `color: ${fill}`, `fill: ${fill}`] }));
|
|
766
|
+
} else if (e.kind === "path" && e.geometry) {
|
|
767
|
+
// Only a closed outline can hold paint: an open spline or path is a
|
|
768
|
+
// line, and filling it drew a trend line as a wedge (x53 cloud 5, x54
|
|
769
|
+
// GTM 4) whatever fill the reader named.
|
|
770
|
+
const g0 = e.geometry;
|
|
771
|
+
const closed = g0.type === "sector" || (g0.type === "spline" && g0.closed === true)
|
|
772
|
+
|| (g0.type === "path" && (g0.commands ?? []).some((c) => c?.op === "Z"));
|
|
773
|
+
// Every open stroke is a native line (above); one that reaches here
|
|
774
|
+
// could not be read as one — named, never drawn as svg.
|
|
775
|
+
if (!closed || !(e.fill && e.fill !== "none")) {
|
|
776
|
+
warnings.push(`${e.id}: a stroke whose points cannot be read as a line; left out — draw it as a native line directive from the concept`);
|
|
777
|
+
dropped.push(e.id);
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
const geom0 = g0.type === "path" ? { ...g0, commands: repairCommands(g0.commands, e) } : reconcileArc(g0, e);
|
|
781
|
+
let g = geometryPath(geom0);
|
|
782
|
+
// A drawn shape may not reach outside the content slot (the export
|
|
783
|
+
// refuses it). Where its extent does, its own coordinates are moved in —
|
|
784
|
+
// and shrunk uniformly only if it is larger than the slot — so it is
|
|
785
|
+
// never distorted and its svg stays 1:1. The reader's box is NOT the
|
|
786
|
+
// bound: it can contradict the reader's own points (x66 QBR 6).
|
|
787
|
+
const ext0 = g?.extent;
|
|
788
|
+
const out = ext0 && (ext0.x < -1 || ext0.y < -1 || ext0.x + ext0.w > slot.w + 1 || ext0.y + ext0.h > slot.h + 1);
|
|
789
|
+
if (out) {
|
|
790
|
+
const k = Math.min(1, slot.w / ext0.w, slot.h / ext0.h);
|
|
791
|
+
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
792
|
+
const to = { x: clamp(ext0.x, 0, slot.w - ext0.w * k), y: clamp(ext0.y, 0, slot.h - ext0.h * k) };
|
|
793
|
+
g = geometryPath(scaleGeometry(geom0, k, { x: ext0.x, y: ext0.y }, to)) ?? g;
|
|
794
|
+
}
|
|
795
|
+
// Never a rectangle in its place, and never the whole deck lost to it:
|
|
796
|
+
// the element is left out and named (x58: one bad arc aborted every
|
|
797
|
+
// slide and the agent hand-authored them).
|
|
798
|
+
if (!g) {
|
|
799
|
+
warnings.push(`${e.id}: its geometry cannot be drawn (${g0.type}); left out — redraw it by hand from the concept`);
|
|
800
|
+
dropped.push(e.id);
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
// A filled figure (a sector, a closed silhouette): its fill only, placed
|
|
804
|
+
// in the box it draws.
|
|
805
|
+
const r2 = (v) => Math.round(v * 100) / 100;
|
|
806
|
+
const b = g.extent ?? { x: g.x, y: g.y, w: g.w, h: g.h };
|
|
807
|
+
const [bx, by, bw, bh] = [b.x, b.y, Math.max(2, b.w), Math.max(2, b.h)].map(r2);
|
|
808
|
+
lines.push(`<div id=${q(e.id)} style={{ position: "absolute", left: ${bx}, top: ${by}, width: ${bw}, height: ${bh} }}><svg viewBox={\`${r2(bx - g.x)} ${r2(by - g.y)} ${bw} ${bh}\`} width={${bw}} height={${bh}} style={{ display: "block", overflow: "visible" }}><path d=${q(g.d)} ${svgPaint(e)} /></svg></div>`);
|
|
809
|
+
} else if (e.kind === "path" && Array.isArray(e.points) && e.points.length >= 2) {
|
|
810
|
+
// A filled silhouette (roof, arch, funnel side): one inline svg the size
|
|
811
|
+
// of its rect, points relative to it, fill only. An open stroke never
|
|
812
|
+
// reaches here as svg: it is a native line.
|
|
813
|
+
if (!(e.points.length >= 3 && e.fill && e.fill !== "none")) {
|
|
814
|
+
warnings.push(`${e.id}: a stroke whose points cannot be read as a line; left out — draw it as a native line directive from the concept`);
|
|
815
|
+
dropped.push(e.id);
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
const x0 = Math.min(...e.points.map((p) => p.x));
|
|
819
|
+
const y0 = Math.min(...e.points.map((p) => p.y));
|
|
820
|
+
const w = Math.max(2, Math.max(...e.points.map((p) => p.x)) - x0);
|
|
821
|
+
const h = Math.max(2, Math.max(...e.points.map((p) => p.y)) - y0);
|
|
822
|
+
const pts = e.points.map((p) => `${p.x - x0},${p.y - y0}`).join(" ");
|
|
823
|
+
lines.push(
|
|
824
|
+
`<div id=${q(e.id)} style={{ position: "absolute", left: ${x0}, top: ${y0}, width: ${w}, height: ${h} }}><svg viewBox={\`0 0 ${w} ${h}\`} width={${w}} height={${h}} style={{ display: "block", overflow: "visible" }}><polygon points="${pts}" ${svgPaint(e)} /></svg></div>`,
|
|
825
|
+
);
|
|
826
|
+
} else if (e.kind === "chart" && e.chart) {
|
|
827
|
+
const data = nativeChartData(e.chart);
|
|
828
|
+
if (!data) {
|
|
829
|
+
// Do not erase the observed marks or fabricate data when the model
|
|
830
|
+
// recognized a chart but did not recover a renderable payload.
|
|
831
|
+
lines.push(`{/* unresolved chart ${escapeJsx(e.id)}: observed marks retained; requires supported type and verified series */}`);
|
|
832
|
+
warnings.push(`chart ${e.id} has no readable values or an unsupported/inconsistent payload; observed marks retained, native chart unresolved — supply verified chart data, never invent values from an unlabeled illustration`);
|
|
833
|
+
dropped.push(e.id);
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
lines.push(chartJsx(e, spec, tokens, data, base, warnings));
|
|
837
|
+
} else {
|
|
838
|
+
const attrs = [base, `h={${e.h}}`];
|
|
839
|
+
if (!fig && e.fill && e.fill !== "none") attrs.push(`fill=${q(e.fill)}`);
|
|
840
|
+
const fillVar = fig ? null : fillVarOf(e);
|
|
841
|
+
if (fillVar) attrs.push(`fillVar=${q(fillVar)}`);
|
|
842
|
+
if (!fig && e.shape && e.shape !== "rect") attrs.push(`shape=${q(e.shape)}`);
|
|
843
|
+
const boxStyle = [];
|
|
844
|
+
// Legacy bar specs predate explicit strokes. An unfilled bar is an
|
|
845
|
+
// outline, but a transparent grouping shape is not.
|
|
846
|
+
const outline = fig ? null : e.stroke ?? (e.kind === "bar" && e.fill === "none" && !Object.hasOwn(e, "stroke") ? { width: null } : null);
|
|
847
|
+
if (outline && !(Number.isFinite(outline.width) && outline.width <= 0)) {
|
|
848
|
+
// The reader's weight as the design's weight token, never its px.
|
|
849
|
+
const strokeVar = namedVar(outline.colorToken) ?? snapVar(outline.colorHex);
|
|
850
|
+
boxStyle.push(`borderWidth: ${q(`var(--bw-${weightClass(outline.width)})`)}`, `borderStyle: ${q(outline.dash === "dashed" ? "dashed" : "solid")}`, `borderColor: ${strokeVar ? q(strokeVar) : `TONE[${q(e.color ?? "dark")}]`}`);
|
|
851
|
+
}
|
|
852
|
+
// A node a line glues to ALWAYS names the preset its sites were fitted
|
|
853
|
+
// on: the exporter's box rule reads a token radius differently (a
|
|
854
|
+
// pill-radius circle is not an ellipse to it), and a site index means
|
|
855
|
+
// nothing against another preset's site list (x74: every disc's
|
|
856
|
+
// bottom site 4 refused as out of range on a 4-site box).
|
|
857
|
+
if (glueTargets.has(e.id) && nodes.get(e.id)) {
|
|
858
|
+
const node = nodes.get(e.id);
|
|
859
|
+
attrs.push(`prst=${q(node.prst)}`);
|
|
860
|
+
const adj = Object.entries(node.av ?? {}).map(([k, v]) => `${k}:${Math.round(v)}`).join(",");
|
|
861
|
+
if (adj) attrs.push(`adj=${q(adj)}`);
|
|
862
|
+
}
|
|
863
|
+
if (!fig && e.shape === "ring" && !outline) boxStyle.push(`borderWidth: "var(--bw-medium)", borderStyle: "solid", borderColor: TONE.accent`);
|
|
864
|
+
// Silhouettes the box can take with a clip-path (x28: chevrons shipped
|
|
865
|
+
// as pale squares). A chevron/triangle points the way the row runs.
|
|
866
|
+
const CLIP = {
|
|
867
|
+
chevron: "polygon(0 0, 75% 0, 100% 50%, 75% 100%, 0 100%, 25% 50%)",
|
|
868
|
+
triangle: e.w >= e.h ? "polygon(0 0, 100% 50%, 0 100%)" : "polygon(0 100%, 50% 0, 100% 100%)",
|
|
869
|
+
trapezoid: "polygon(12% 0, 88% 0, 100% 100%, 0 100%)",
|
|
870
|
+
// A filled block arrow pointing the way its long side runs.
|
|
871
|
+
arrow: e.w >= e.h ? "polygon(0 28%, 62% 28%, 62% 0, 100% 50%, 62% 100%, 62% 72%, 0 72%)" : "polygon(28% 0, 72% 0, 72% 62%, 100% 62%, 50% 100%, 0 62%, 28% 62%)",
|
|
872
|
+
diamond: "polygon(50% 0, 100% 50%, 50% 100%, 0 50%)",
|
|
873
|
+
hexagon: e.w >= e.h ? "polygon(25% 0, 75% 0, 100% 50%, 75% 100%, 25% 100%, 0 50%)" : "polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%)",
|
|
874
|
+
pentagon: "polygon(50% 0, 100% 38%, 81% 100%, 19% 100%, 0 38%)",
|
|
875
|
+
parallelogram: "polygon(18% 0, 100% 0, 82% 100%, 0 100%)",
|
|
876
|
+
};
|
|
877
|
+
// A silhouette none of the named shapes draws: the outline the reader
|
|
878
|
+
// traced, relative to the box (the exporter fits a preset to it, or
|
|
879
|
+
// keeps it as a freeform).
|
|
880
|
+
const traced = e.shape === "other" && Array.isArray(e.outline) && e.outline.length >= 3 && e.w > 0 && e.h > 0
|
|
881
|
+
? `polygon(${e.outline.map((p) => `${Math.round(((p.x - e.x) / e.w) * 1000) / 10}% ${Math.round(((p.y - e.y) / e.h) * 1000) / 10}%`).join(", ")})`
|
|
882
|
+
: null;
|
|
883
|
+
if (!fig && (CLIP[e.shape] || traced)) boxStyle.push(`clipPath: ${q(CLIP[e.shape] ?? traced)}`);
|
|
884
|
+
if (e.textInsets) for (const side of ["left", "right", "top", "bottom"]) {
|
|
885
|
+
const inset = e.textInsets[side];
|
|
886
|
+
if (Number.isFinite(inset) && inset >= 0) boxStyle.push(`padding${side[0].toUpperCase() + side.slice(1)}: ${inset}`);
|
|
887
|
+
}
|
|
888
|
+
// A shape's own words never sit flush with its edge (BP 04-02: badge
|
|
889
|
+
// labels clipped at the pill's side). On a badge, pill or round carrier
|
|
890
|
+
// they centre; elsewhere they keep the read alignment inside the
|
|
891
|
+
// design's inner padding. Folded words carry their measured insets.
|
|
892
|
+
const ownWords = text != null && !e.textInsets;
|
|
893
|
+
const centred = ownWords && (e.kind === "badge" || e.kind === "disc" || ["pill", "circle", "ring"].includes(e.shape));
|
|
894
|
+
const align = centred ? "center" : e.align;
|
|
895
|
+
if (ownWords && !centred && (align === "left" || align === "right")) boxStyle.push(`paddingLeft: "var(--sp-2)"`, `paddingRight: "var(--sp-2)"`);
|
|
896
|
+
if (align) boxStyle.push(`textAlign: ${q(align)}`);
|
|
897
|
+
if (e.elevated && !fig) attrs.push("shadow");
|
|
898
|
+
if (text == null) {
|
|
899
|
+
// A seated icon or picture rides inside, on one line: the box centres it.
|
|
900
|
+
const seat = seated.get(e.id);
|
|
901
|
+
const inner = seat && !isMark(seat) ? `<I id=${q(seat.id)} w={${Math.min(seat.w, e.w)}} h={${Math.min(seat.h, e.h)}} src=${q(requestAsset(seat))} />` : null;
|
|
902
|
+
if (inner) {
|
|
903
|
+
attrs.push(`align="center"`, `valign="middle"`);
|
|
904
|
+
consumed.add(seat.id);
|
|
905
|
+
}
|
|
906
|
+
if (boxStyle.length) attrs.push(`style={{ ${boxStyle.join(", ")} }}`);
|
|
907
|
+
lines.push(inner ? `<B ${attrs.join(" ")}>${inner}</B>` : `<B ${attrs.join(" ")} />`);
|
|
908
|
+
} else {
|
|
909
|
+
// A badge's digit, a disc's label: the words belong TO the shape. The
|
|
910
|
+
// type goes on the BOX, not on a span inside it — the export reads the
|
|
911
|
+
// anchor off the box that carries the run, so a span would ship a
|
|
912
|
+
// centred-looking preview and a top-anchored shape in PowerPoint. One
|
|
913
|
+
// shape with its own centred text body then sits in its middle on both
|
|
914
|
+
// axes, instead of at whatever rect the read gave a separate text box
|
|
915
|
+
// (a child's rect is the noisiest part of a spec — a glyph or digit
|
|
916
|
+
// half out of its disc, x37 cloud 2 and 6).
|
|
917
|
+
const paint = textPaint(e);
|
|
918
|
+
const v = paint.colorVar;
|
|
919
|
+
boxStyle.unshift(
|
|
920
|
+
// A digit on a disc or badge is a badge label, not a KPI number.
|
|
921
|
+
`...ROLE[${q(e.role === "number" || e.kind === "badge" ? "badge" : (e.role ?? "body"))}]`,
|
|
922
|
+
`color: ${v ? q(v) : `TONE[${q(paint.tone ?? "dark")}]`}`,
|
|
923
|
+
...(e.weight === "bold" ? [`fontWeight: "var(--fw-bold)"`] : []),
|
|
924
|
+
`lineHeight: "var(--lh-tight)"`,
|
|
925
|
+
);
|
|
926
|
+
// Where the words sit is the geometry the reader measured (the fold
|
|
927
|
+
// pass derives it from the block's margins inside the box); a box's own
|
|
928
|
+
// one-line text centres. No shape name decides this.
|
|
929
|
+
attrs.push(`align=${q(align ?? "center")}`);
|
|
930
|
+
attrs.push(`valign=${q(e.valign ?? "middle")}`);
|
|
931
|
+
attrs.push(`style={{ ${boxStyle.join(", ")} }}`);
|
|
932
|
+
lines.push(`<B ${attrs.join(" ")}>${emphasise(jsxText(text))}</B>`);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
} catch (err) {
|
|
936
|
+
warnings.push(`${raw.id}: not written — ${err?.message ?? err}; the rest of the slide stands`);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
// Retained illustration marks have an owner even though no numeric chart
|
|
940
|
+
// can be emitted. Capture uses that ownership to reject a second, native
|
|
941
|
+
// plot placed on top later. Annotations (text/icons) are not plot marks.
|
|
942
|
+
const illustrations = (spec.elements ?? []).filter(e => e.kind === "chart" && !nativeChartData(e.chart));
|
|
943
|
+
for (const e of spec.elements ?? []) {
|
|
944
|
+
if (e.text != null || !["path", "rule", "bar", "shape"].includes(e.kind)) continue;
|
|
945
|
+
const owner = illustrations.find(c => e.x >= c.x - 2 && e.y >= c.y - 2 && e.x + e.w <= c.x + c.w + 2 && e.y + e.h <= c.y + c.h + 2);
|
|
946
|
+
if (!owner) continue;
|
|
947
|
+
const at = lines.findIndex(line => line.includes(`id=${q(e.id)} `));
|
|
948
|
+
if (at < 0) continue;
|
|
949
|
+
const name = lines[at].startsWith("<div") ? "data-sm-plot-owner" : "plotOwner";
|
|
950
|
+
lines[at] = lines[at].replace(`id=${q(e.id)}`, `id=${q(e.id)} ${name}=${q(owner.id)}`);
|
|
951
|
+
}
|
|
952
|
+
// Lines paint above the shapes they join; a free rail, ring or divider
|
|
953
|
+
// paints behind the shapes on it (placement.mjs).
|
|
954
|
+
const painted = (e) => (e.fill && e.fill !== "none") || ["icon", "image", "chart"].includes(e.kind) || sketched.has(e.id);
|
|
955
|
+
const seats = placed.filter((p, k) => painted(p.el) && (placed[k + 1]?.at ?? lines.length) > p.at);
|
|
956
|
+
lines.splice(0, lines.length, ...seatLines(lines, seats, lineOut));
|
|
957
|
+
// Chrome props fill the layout's own placeholders, and a placeholder wraps
|
|
958
|
+
// its text at its own width and master size. The spec's `\n` is only where
|
|
959
|
+
// the MOCKUP wrapped, so it becomes a space (2026-09-27: a two-line headline
|
|
960
|
+
// shipped with the concept's break although its placeholder needed none;
|
|
961
|
+
// this retires the x37 keep-the-break and x51 char-estimate rules).
|
|
962
|
+
const flat = (t) => String(t ?? "").split("\n").join(" ").replace(/\s+/g, " ").trim();
|
|
963
|
+
if (unmatched.size) {
|
|
964
|
+
warnings.push(
|
|
965
|
+
`${unmatched.size} observed colour${unmatched.size === 1 ? "" : "s"} ha${unmatched.size === 1 ? "s" : "ve"} no token in this design (${[...unmatched].slice(0, 3).map(([h, n]) => `${h} nearest ${n}`).join(", ")}); the tone word decided`,
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
const props = [];
|
|
969
|
+
if (chrome.kicker) props.push(`${chrome.kicker}=${q(flat(spec.chrome?.kicker))}`);
|
|
970
|
+
if (chrome.headline) props.push(`${chrome.headline}=${q(flat(spec.chrome?.headline))}`);
|
|
971
|
+
// Meta chrome: the slide number as the layout's page number, nothing else
|
|
972
|
+
// (a layout's own placeholder copy must not ship). The spec's `footer` is
|
|
973
|
+
// deliberately NOT fed back: the mockup was painted over this layout's own
|
|
974
|
+
// chrome, so whatever the model read in the footer band — the wordmark, the
|
|
975
|
+
// date, the section, the page number — is what the layout already draws.
|
|
976
|
+
// Re-emitting it shipped a second wordmark next to the real one and, where
|
|
977
|
+
// the chrome renders the full strip, one crowded string in the footnote
|
|
978
|
+
// placeholder (x36 / hold-out 3: ten judged slides).
|
|
979
|
+
for (const p of chrome.others) {
|
|
980
|
+
if (/pagenumber|slidenum/i.test(p)) props.push(`${p}=${q(String(slide))}`);
|
|
981
|
+
else props.push(`${p}=""`);
|
|
982
|
+
}
|
|
983
|
+
if (!chrome.headline && !chrome.kicker) {
|
|
984
|
+
warnings.push(`layout ${layout} declares no chrome props the skeleton knows; the headline was not placed`);
|
|
985
|
+
}
|
|
986
|
+
const jsx =
|
|
987
|
+
`// Transcribed from the reader's inventory of the concept, at the painting's coordinates. Edit in place: align, even and centre what the painting only approximates; delete what it drew by accident (a chrome-band mark, a guide box, a stray stroke).\n` +
|
|
988
|
+
(literal ? `// A reconstruction: its colours are the reference's own values, not design tokens — keep them.\n` : "") +
|
|
989
|
+
`export default function Slide() {\n${kit}${(spec.stacks ?? []).length ? ` // Declared stacks (reader): how each one's rows are aligned and spaced.\n const STACKS = ${JSON.stringify(Object.fromEntries((spec.stacks ?? []).map((g, gi) => [gi + 1, { align: g.align ?? "middle", spacing: g.spacing ?? "even" }])))};\n${gridsConst}` : ""}${KIT_EXTRAS}\n return (\n <${layout}${props.length ? " " + props.join(" ") : ""}>\n` +
|
|
990
|
+
` <div style={{ position: "relative", width: ${slot.w}, height: ${slot.h} }}>\n` +
|
|
991
|
+
repeatGroups(spec).map((g) => ` {/* ${g.id}: ${g.members.map((m) => (m.ids ? `(${m.ids.join(" + ")})` : m.id)).join(", ")} are one organism (${g.arrangement}${g.hub ? ` around ${g.hub.id}` : ""}) — one size, one inner layout; change them alike. */}\n`).join("") +
|
|
992
|
+
lines.map((l) => ` ${l}`).join("\n") +
|
|
993
|
+
`\n </div>\n </${layout}>\n );\n}\n`;
|
|
994
|
+
return { jsx, icons, warnings, dropped, connectorLines };
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// The engine's own ranking is noise for icons: nine results come back inside
|
|
998
|
+
// 0.002 of each other, so "first result" is a coin toss — the judge's most
|
|
999
|
+
// frequent defect across every run is "several icons differ materially from
|
|
1000
|
+
// the concept" ("revenue growth chart" returned Decreasing-Chart first with
|
|
1001
|
+
// Increasing-Chart fourth; "marketing megaphone" returned Loudspeaker with
|
|
1002
|
+
// Megaphone fourth). The file name behind each result is the signal the score
|
|
1003
|
+
// is not, so pick by it: most query words matched, then the plainest name
|
|
1004
|
+
// (`Clock-1` over `School-Clock`), then the engine's order.
|
|
1005
|
+
// ponytail: overlap only — "revenue growth chart" still ties Increasing- and
|
|
1006
|
+
// Decreasing-Chart. Add a direction synonym/antonym table if wrong-direction
|
|
1007
|
+
// glyphs show up as more than the occasional defect.
|
|
1008
|
+
export function iconSearchQueries(query, keywords = []) {
|
|
1009
|
+
const words = String(query).split(/\s+/).filter(Boolean);
|
|
1010
|
+
const content = words.filter(w => !ICON_STYLE_WORDS.has(w.toLowerCase()));
|
|
1011
|
+
// Never reduce a compound motif to its last noun ("magnifying glass" ->
|
|
1012
|
+
// "glass", "check mark" -> "mark"). A retry keeps its semantic content; the
|
|
1013
|
+
// reader's own synonyms follow, as the words a library names files with.
|
|
1014
|
+
const own = new Set(content.map((w) => w.toLowerCase()));
|
|
1015
|
+
const synonyms = keywords.map((k) => String(k).trim().toLowerCase()).filter((k) => k && !own.has(k) && !ICON_STYLE_WORDS.has(k));
|
|
1016
|
+
return [...new Set([query, content.join(' '), ...synonyms].filter(Boolean))].slice(0, 4);
|
|
1017
|
+
}
|
|
1018
|
+
const motifWords = query => {
|
|
1019
|
+
const words = nameWords(query).filter(w => !ICON_STYLE_WORDS.has(w));
|
|
1020
|
+
// A shared object noun alone does not establish a compound motif.
|
|
1021
|
+
return words.length > 1 ? words.filter(w => !['glass', 'coin'].includes(w)) : words;
|
|
1022
|
+
};
|
|
1023
|
+
|
|
1024
|
+
const resultName = (r) => {
|
|
1025
|
+
const raw = String(r?.sourceUrl ?? r?.title ?? "").split("/").pop() ?? "";
|
|
1026
|
+
let name = raw;
|
|
1027
|
+
try {
|
|
1028
|
+
name = decodeURIComponent(raw);
|
|
1029
|
+
} catch {
|
|
1030
|
+
// A malformed escape: the raw segment still carries the words.
|
|
1031
|
+
}
|
|
1032
|
+
return name.replace(/\.[a-z0-9]+$/i, "");
|
|
1033
|
+
};
|
|
1034
|
+
|
|
1035
|
+
/** How many of the query's words the result's file name carries. */
|
|
1036
|
+
// `keywords` are the reader's file-name synonyms for the motif (x51: "person
|
|
1037
|
+
// outline" never matched Male-2, "magnifying glass" never matched Search-Plus,
|
|
1038
|
+
// "speedometer gauge" never matched Speed-Meter). A synonym counts as a hit;
|
|
1039
|
+
// the query's own words still rank first.
|
|
1040
|
+
/** How many name hits make a full match for this motif (its own words, at most two). */
|
|
1041
|
+
export const iconMotifSize = (query) => Math.min(2, Math.max(1, motifWords(query).length));
|
|
1042
|
+
|
|
1043
|
+
export function iconNameHits(query, result, keywords = []) {
|
|
1044
|
+
const parts = nameWords(resultName(result));
|
|
1045
|
+
if (!parts.length) return 0;
|
|
1046
|
+
const own = motifWords(query);
|
|
1047
|
+
const syn = [...new Set(keywords.flatMap((k) => nameWords(k)))].filter((w) => !own.includes(w) && !ICON_STYLE_WORDS.has(w));
|
|
1048
|
+
return own.filter((w) => parts.includes(w)).length + (syn.some((w) => parts.includes(w)) ? 1 : 0);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
export function pickIconResult(query, results, keywords = []) {
|
|
1052
|
+
const list = Array.isArray(results) ? results.filter(r => r && iconNameHits(query, r, keywords) > 0) : [];
|
|
1053
|
+
if (list.length < 2) return list[0] ?? null;
|
|
1054
|
+
const score = (r) => {
|
|
1055
|
+
const parts = nameWords(resultName(r));
|
|
1056
|
+
if (!parts.length) return -Infinity;
|
|
1057
|
+
return iconNameHits(query, r, keywords) * 10 - parts.length;
|
|
1058
|
+
};
|
|
1059
|
+
let best = list[0];
|
|
1060
|
+
let bestScore = score(list[0]);
|
|
1061
|
+
for (const r of list.slice(1)) {
|
|
1062
|
+
const s = score(r);
|
|
1063
|
+
if (s > bestScore) {
|
|
1064
|
+
best = r;
|
|
1065
|
+
bestScore = s;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
return best;
|
|
1069
|
+
}
|