slidemind 0.1.8 → 0.2.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.md +10 -6
- package/README.md +19 -7
- package/bin/slidemind.mjs +819 -86
- package/package.json +2 -1
- package/skills/create-slides/SKILL.md +70 -18
- package/skills/search/SKILL.md +25 -6
- package/src/bridge/build-and-push.mjs +11 -0
- package/src/bridge/bundle.mjs +36 -7
- package/src/bridge/capture.mjs +20 -2
- package/src/bridge/client.mjs +513 -7
- package/src/bridge/engine-download.mjs +229 -5
- package/src/bridge/image-dimensions.mjs +181 -0
- package/src/builders/build.mjs +53 -23
- package/src/builders/gate-server.mjs +163 -22
- package/src/builders/plan-markdown-edit.mjs +120 -0
- package/src/builders/plan-overlay.js +1261 -230
- package/src/builders/plan.mjs +146 -25
- package/src/builders/red-thread-backbone.mjs +194 -0
- package/src/builders/resolve-skill.mjs +45 -1
- package/src/builders/review-overlay.js +44 -7
- package/src/builders/review.mjs +76 -11
- package/src/builders/storyboard-markdown.mjs +118 -0
- package/src/builders/storyboard-schema.mjs +180 -0
- package/src/builders/visual-families.mjs +32 -0
- package/src/pptx/app-props.mjs +222 -0
- package/src/pptx/fill/engine.mjs +1273 -0
- package/src/pptx/fill/freeform.mjs +156 -0
- package/src/pptx/fill/parse-slides.mjs +154 -0
- package/src/pptx/fill/xml.mjs +486 -0
- package/src/pptx/hidden-ole.mjs +125 -0
- package/src/pptx/image-extensions.mjs +190 -0
- package/src/pptx/ooxml.mjs +176 -0
- package/src/pptx/read-local.mjs +206 -0
- package/src/pptx/slide-swap.mjs +429 -0
- package/src/pptx/zip.mjs +30 -0
|
@@ -9,8 +9,16 @@
|
|
|
9
9
|
import fs from "node:fs";
|
|
10
10
|
import path from "node:path";
|
|
11
11
|
|
|
12
|
+
import {
|
|
13
|
+
DIMENSION_PROBE_BYTES,
|
|
14
|
+
imageSizeFromBuffer,
|
|
15
|
+
svgSizeFromMarkup,
|
|
16
|
+
} from "./image-dimensions.mjs";
|
|
12
17
|
import { describeRecolor } from "./recolor.mjs";
|
|
13
18
|
|
|
19
|
+
// Every image format an engine accepts, plus the alias spellings seen in the
|
|
20
|
+
// wild. Mirrors the add-in's `searchImage` set (lib/image-formats.ts there);
|
|
21
|
+
// `extFromMime`'s `image/*` fallback below still covers anything newer.
|
|
14
22
|
const MIME_EXT = {
|
|
15
23
|
"image/svg+xml": "svg",
|
|
16
24
|
"image/png": "png",
|
|
@@ -18,6 +26,9 @@ const MIME_EXT = {
|
|
|
18
26
|
"image/jpg": "jpg",
|
|
19
27
|
"image/webp": "webp",
|
|
20
28
|
"image/gif": "gif",
|
|
29
|
+
"image/bmp": "bmp",
|
|
30
|
+
"image/x-ms-bmp": "bmp",
|
|
31
|
+
"image/tiff": "tiff",
|
|
21
32
|
};
|
|
22
33
|
|
|
23
34
|
// Derive a usable file extension from an engine result's MIME type. The brand
|
|
@@ -42,8 +53,186 @@ export function extFromMime(mimeType) {
|
|
|
42
53
|
return "bin";
|
|
43
54
|
}
|
|
44
55
|
|
|
56
|
+
// Below this, a transfer finishes faster than a progress line is worth reading.
|
|
57
|
+
// Most brand icons and slide slices are well under it, so the common case stays
|
|
58
|
+
// silent.
|
|
59
|
+
const PROGRESS_MIN_BYTES = 2 * 1024 * 1024;
|
|
60
|
+
/** Emit at most one line per this much progress. */
|
|
61
|
+
const PROGRESS_STEP_BYTES = 4 * 1024 * 1024;
|
|
62
|
+
|
|
63
|
+
const mb = (n) => (n / (1024 * 1024)).toFixed(1);
|
|
64
|
+
|
|
65
|
+
// Progress reporter for a streamed transfer.
|
|
66
|
+
//
|
|
67
|
+
// Deliberately NOT an in-place bar: this CLI's convention (see
|
|
68
|
+
// `renderProgressPrinter` in bin/slidemind.mjs — "one stderr line per state
|
|
69
|
+
// change — works in plain logs and TTYs alike") is milestone lines, because the
|
|
70
|
+
// primary consumer is an agent reading piped stdout, where `\r` repainting is
|
|
71
|
+
// noise rather than motion. So: nothing for small files, and a line every few MB
|
|
72
|
+
// for large ones, on stderr so it never pollutes parseable stdout.
|
|
73
|
+
export function createTransferProgress(label, write = process.stderr.write.bind(process.stderr)) {
|
|
74
|
+
let nextAt = PROGRESS_STEP_BYTES;
|
|
75
|
+
let announced = false;
|
|
76
|
+
return (bytesWritten, total) => {
|
|
77
|
+
if (total != null && total < PROGRESS_MIN_BYTES) return;
|
|
78
|
+
if (bytesWritten < nextAt) return;
|
|
79
|
+
nextAt = bytesWritten + PROGRESS_STEP_BYTES;
|
|
80
|
+
if (total != null && total > 0) {
|
|
81
|
+
const pct = Math.min(99, Math.floor((bytesWritten / total) * 100));
|
|
82
|
+
write(` ${label}: ${mb(bytesWritten)}/${mb(total)} MB (${pct}%)…\n`);
|
|
83
|
+
} else {
|
|
84
|
+
// No content-length (a decoded/chunked stream): report absolute progress.
|
|
85
|
+
write(` ${label}: ${mb(bytesWritten)} MB…\n`);
|
|
86
|
+
}
|
|
87
|
+
announced = true;
|
|
88
|
+
return announced;
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const ascii = (s) => [...s].map((c) => c.charCodeAt(0));
|
|
93
|
+
|
|
94
|
+
// Magic-number signatures for the image formats an engine ACCEPTS — the add-in's
|
|
95
|
+
// `searchImage` set (lib/image-formats.ts there, EngineFileExtensions.search_image
|
|
96
|
+
// in its API). An engine cannot hold a format it refuses at ingest, so this list
|
|
97
|
+
// is complete by construction; keep it in step with that set.
|
|
98
|
+
//
|
|
99
|
+
// `svg` is absent deliberately: it is XML with no signature, so it can only be
|
|
100
|
+
// trusted from its declared type.
|
|
101
|
+
const MAGIC_SIGNATURES = [
|
|
102
|
+
{ ext: "png", parts: [[0, [0x89, ...ascii("PNG"), 0x0d, 0x0a, 0x1a, 0x0a]]] },
|
|
103
|
+
{ ext: "jpg", parts: [[0, [0xff, 0xd8, 0xff]]] },
|
|
104
|
+
{ ext: "gif", parts: [[0, ascii("GIF8")]] },
|
|
105
|
+
{ ext: "bmp", parts: [[0, ascii("BM")]] },
|
|
106
|
+
// Endian-dependent: "II" + 42 little, "MM" + 42 big.
|
|
107
|
+
{ ext: "tiff", parts: [[0, [...ascii("II"), 0x2a, 0x00]]] },
|
|
108
|
+
{ ext: "tiff", parts: [[0, [...ascii("MM"), 0x00, 0x2a]]] },
|
|
109
|
+
// RIFF<4-byte size>WEBP — the format tag sits past the container header.
|
|
110
|
+
{ ext: "webp", parts: [[0, ascii("RIFF")], [8, ascii("WEBP")]] },
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
// Identify an image from its BYTES, ignoring any declared MIME type. Returns null
|
|
114
|
+
// when nothing matches (an SVG, or a format this system never ingests) so the
|
|
115
|
+
// caller keeps its MIME-derived guess rather than inventing one.
|
|
116
|
+
//
|
|
117
|
+
// Why sniff at all: the declared type is only as good as the upstream
|
|
118
|
+
// `Content-Type`, and it is demonstrably wrong in the wild — a 1024x1024 PNG in
|
|
119
|
+
// the corpus arrived as `data:image/jpeg`, so the CLI wrote PNG bytes into a
|
|
120
|
+
// `.jpg` file. A wrong extension is not cosmetic here: the asset is referenced
|
|
121
|
+
// from slide JSX as `<img src>` and the renderer picks its decoder from the file
|
|
122
|
+
// type, so a mislabelled asset can fail to render in the .pptx.
|
|
123
|
+
export function extFromBytes(buf) {
|
|
124
|
+
if (!buf || buf.length < 2) return null;
|
|
125
|
+
for (const { ext, parts } of MAGIC_SIGNATURES) {
|
|
126
|
+
const matched = parts.every(([offset, bytes]) => {
|
|
127
|
+
if (buf.length < offset + bytes.length) return false;
|
|
128
|
+
return bytes.every((b, i) => buf[offset + i] === b);
|
|
129
|
+
});
|
|
130
|
+
if (matched) return ext;
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The extension to write bytes under: what the bytes ARE beats what the wire
|
|
136
|
+
// claims. SVG is exempt — it is XML with no magic number, and the SVG path is
|
|
137
|
+
// already gated on `svgMarkup` being present.
|
|
138
|
+
export function resolveAssetExt(mimeType, buf) {
|
|
139
|
+
const declared = extFromMime(mimeType);
|
|
140
|
+
if (declared === "svg") return declared;
|
|
141
|
+
return extFromBytes(buf) ?? declared;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Stream one result into assetsDir via the add-in's streaming route.
|
|
145
|
+
//
|
|
146
|
+
// Returns the same shape as `downloadOneResult`, or null when the paired add-in
|
|
147
|
+
// has no streaming route (404/501) so the caller can fall back to the capped
|
|
148
|
+
// ack path. A real failure (bad selection, dead source) throws.
|
|
149
|
+
async function streamOneResult({
|
|
150
|
+
client,
|
|
151
|
+
presId,
|
|
152
|
+
backendURL,
|
|
153
|
+
searchId,
|
|
154
|
+
assetsDir,
|
|
155
|
+
selection,
|
|
156
|
+
iconColor,
|
|
157
|
+
}) {
|
|
158
|
+
if (typeof client.engineDownloadToFile !== "function") return null;
|
|
159
|
+
// Write under a provisional name: the true extension is only knowable from the
|
|
160
|
+
// bytes, and buffering the image just to sniff its header would defeat the
|
|
161
|
+
// purpose of streaming.
|
|
162
|
+
const tempPath = path.join(assetsDir, `.engine-${searchId}-${selection}.part`);
|
|
163
|
+
let res;
|
|
164
|
+
try {
|
|
165
|
+
res = await client.engineDownloadToFile({
|
|
166
|
+
presId,
|
|
167
|
+
backendURL,
|
|
168
|
+
searchId,
|
|
169
|
+
selection,
|
|
170
|
+
destPath: tempPath,
|
|
171
|
+
iconColor: iconColor ?? null,
|
|
172
|
+
onProgress: createTransferProgress(`downloading result ${selection}`),
|
|
173
|
+
});
|
|
174
|
+
} catch (err) {
|
|
175
|
+
fs.rmSync(tempPath, { force: true });
|
|
176
|
+
// Route absent → this add-in predates streaming; let the caller fall back.
|
|
177
|
+
// A 404 that carries a machine code is the ROUTE talking (`unknown_search`
|
|
178
|
+
// for a stale selection), not Express saying "no such path" — retrying that
|
|
179
|
+
// on the capped path would just fail again, more slowly.
|
|
180
|
+
if ((err?.status === 404 && !err?.code) || err?.status === 501) return null;
|
|
181
|
+
throw err;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
if (res?.svgMarkup) {
|
|
186
|
+
// SVG arrives inline, not as a stream.
|
|
187
|
+
fs.rmSync(tempPath, { force: true });
|
|
188
|
+
const filename = `engine-${searchId}-${selection}.svg`;
|
|
189
|
+
const target = path.join(assetsDir, filename);
|
|
190
|
+
fs.writeFileSync(target, res.svgMarkup, "utf8");
|
|
191
|
+
return {
|
|
192
|
+
selection,
|
|
193
|
+
path: target,
|
|
194
|
+
reference: `./assets/${filename}`,
|
|
195
|
+
mimeType: res.mimeType ?? "image/svg+xml",
|
|
196
|
+
recolor: describeRecolor(res.svgMarkup, res.mimeType, iconColor ?? null),
|
|
197
|
+
size: svgSizeFromMarkup(res.svgMarkup),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!res?.bytesWritten) {
|
|
202
|
+
throw new Error("stream produced no bytes");
|
|
203
|
+
}
|
|
204
|
+
// Read only the leading header block, not the whole file: enough to sniff
|
|
205
|
+
// the format AND to reach a JPEG's SOF segment past its EXIF/ICC blocks.
|
|
206
|
+
const fd = fs.openSync(tempPath, "r");
|
|
207
|
+
const probeBytes = Math.min(DIMENSION_PROBE_BYTES, res.bytesWritten);
|
|
208
|
+
const header = Buffer.alloc(probeBytes);
|
|
209
|
+
let read = 0;
|
|
210
|
+
try {
|
|
211
|
+
read = fs.readSync(fd, header, 0, probeBytes, 0);
|
|
212
|
+
} finally {
|
|
213
|
+
fs.closeSync(fd);
|
|
214
|
+
}
|
|
215
|
+
const probe = header.subarray(0, read);
|
|
216
|
+
const ext = resolveAssetExt(res.mimeType, probe);
|
|
217
|
+
const filename = `engine-${searchId}-${selection}.${ext}`;
|
|
218
|
+
const target = path.join(assetsDir, filename);
|
|
219
|
+
fs.renameSync(tempPath, target);
|
|
220
|
+
return {
|
|
221
|
+
selection,
|
|
222
|
+
path: target,
|
|
223
|
+
reference: `./assets/${filename}`,
|
|
224
|
+
mimeType: res.mimeType ?? null,
|
|
225
|
+
recolor: describeRecolor(null, res.mimeType, iconColor ?? null),
|
|
226
|
+
size: imageSizeFromBuffer(probe),
|
|
227
|
+
};
|
|
228
|
+
} catch (err) {
|
|
229
|
+
fs.rmSync(tempPath, { force: true });
|
|
230
|
+
throw err;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
45
234
|
// Download one chosen engine result via the bridge and write it into assetsDir.
|
|
46
|
-
// Returns { selection, path, reference, mimeType, recolor }. Throws on failure
|
|
235
|
+
// Returns { selection, path, reference, mimeType, recolor, size }. Throws on failure
|
|
47
236
|
// so callers can Promise.allSettled a batch without one bad selection sinking
|
|
48
237
|
// the rest.
|
|
49
238
|
export async function downloadOneResult({
|
|
@@ -55,6 +244,23 @@ export async function downloadOneResult({
|
|
|
55
244
|
selection,
|
|
56
245
|
iconColor,
|
|
57
246
|
}) {
|
|
247
|
+
// Preferred path: stream via the add-in server, which is unbounded in image
|
|
248
|
+
// size. The extension can only be decided once bytes exist, so stream to a
|
|
249
|
+
// temp name first and rename — cheaper than buffering a 100 MB image to sniff
|
|
250
|
+
// its header.
|
|
251
|
+
const streamed = await streamOneResult({
|
|
252
|
+
client,
|
|
253
|
+
presId,
|
|
254
|
+
backendURL,
|
|
255
|
+
searchId,
|
|
256
|
+
assetsDir,
|
|
257
|
+
selection,
|
|
258
|
+
iconColor,
|
|
259
|
+
});
|
|
260
|
+
if (streamed) return streamed;
|
|
261
|
+
|
|
262
|
+
// Fallback for an add-in that predates the streaming route: bytes ride the
|
|
263
|
+
// pane's Socket.IO ack, capped by its 10 MB frame limit.
|
|
58
264
|
const res = await client.engineDownload({
|
|
59
265
|
presId,
|
|
60
266
|
backendURL,
|
|
@@ -63,16 +269,28 @@ export async function downloadOneResult({
|
|
|
63
269
|
iconColor: iconColor ?? null,
|
|
64
270
|
});
|
|
65
271
|
if (!res?.ok) {
|
|
66
|
-
|
|
272
|
+
// `payload_too_large` is the add-in refusing an ack the relay's Socket.IO
|
|
273
|
+
// frame cap would otherwise DROP — which previously surfaced as an
|
|
274
|
+
// unexplained "task pane did not respond" timeout. Its message already
|
|
275
|
+
// carries the sizes, so pass it through as the cause rather than restating.
|
|
276
|
+
const err = new Error(res?.error ?? "download failed");
|
|
277
|
+
if (res?.code) err.code = res.code;
|
|
278
|
+
throw err;
|
|
67
279
|
}
|
|
68
|
-
|
|
280
|
+
// Decode first so the extension can be decided by the BYTES, not by a
|
|
281
|
+
// Content-Type the engine inherited from upstream and may have wrong.
|
|
282
|
+
const bytes =
|
|
283
|
+
typeof res.base64 === "string" && res.base64.length > 0
|
|
284
|
+
? Buffer.from(res.base64, "base64")
|
|
285
|
+
: null;
|
|
286
|
+
const ext = resolveAssetExt(res.mimeType, bytes);
|
|
69
287
|
const filename = `engine-${searchId}-${selection}.${ext}`;
|
|
70
288
|
const target = path.join(assetsDir, filename);
|
|
71
289
|
if (res.svgMarkup && ext === "svg") {
|
|
72
290
|
// The (possibly recoloured) SVG markup is the canonical asset.
|
|
73
291
|
fs.writeFileSync(target, res.svgMarkup, "utf8");
|
|
74
|
-
} else if (
|
|
75
|
-
fs.writeFileSync(target,
|
|
292
|
+
} else if (bytes) {
|
|
293
|
+
fs.writeFileSync(target, bytes);
|
|
76
294
|
} else if (res.svgMarkup) {
|
|
77
295
|
// SVG bytes arrived only as markup (mimeType lacked an svg hint).
|
|
78
296
|
fs.writeFileSync(target, res.svgMarkup, "utf8");
|
|
@@ -85,5 +303,11 @@ export async function downloadOneResult({
|
|
|
85
303
|
reference: `./assets/${filename}`,
|
|
86
304
|
mimeType: res.mimeType,
|
|
87
305
|
recolor: describeRecolor(res.svgMarkup ?? null, res.mimeType, iconColor ?? null),
|
|
306
|
+
// Measured from the asset actually written: SVG user units for markup,
|
|
307
|
+
// pixels for a raster. Null when the format carries no readable header.
|
|
308
|
+
size:
|
|
309
|
+
ext === "svg"
|
|
310
|
+
? svgSizeFromMarkup(res.svgMarkup ?? null)
|
|
311
|
+
: imageSizeFromBuffer(bytes),
|
|
88
312
|
};
|
|
89
313
|
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Intrinsic pixel dimensions of an image, read from its own header bytes.
|
|
2
|
+
//
|
|
3
|
+
// Why here and not from the add-in: the engine wire carries a MIME type and the
|
|
4
|
+
// bytes, never a size, and both transfer paths (the streaming route and the
|
|
5
|
+
// capped ack fallback) end with the file already on disk. Measuring locally
|
|
6
|
+
// therefore works against every add-in version with no wire change, and it
|
|
7
|
+
// measures the asset the deck will actually reference rather than a thumbnail.
|
|
8
|
+
//
|
|
9
|
+
// Consumers: `slidemind download-result` reports the size so the agent can size
|
|
10
|
+
// an `<img>` box to the real aspect ratio instead of guessing one.
|
|
11
|
+
|
|
12
|
+
// Scan far enough into a JPEG to reach its first SOF segment past the EXIF/ICC
|
|
13
|
+
// blocks that routinely occupy the first few dozen KB.
|
|
14
|
+
export const DIMENSION_PROBE_BYTES = 128 * 1024;
|
|
15
|
+
|
|
16
|
+
const ascii = (s) => [...s].map((c) => c.charCodeAt(0));
|
|
17
|
+
|
|
18
|
+
const startsWith = (buf, offset, bytes) =>
|
|
19
|
+
buf.length >= offset + bytes.length && bytes.every((b, i) => buf[offset + i] === b);
|
|
20
|
+
|
|
21
|
+
function pngSize(buf) {
|
|
22
|
+
// 8-byte signature, then the IHDR chunk: length(4) type(4) width(4) height(4).
|
|
23
|
+
if (!startsWith(buf, 12, ascii("IHDR"))) return null;
|
|
24
|
+
if (buf.length < 24) return null;
|
|
25
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function gifSize(buf) {
|
|
29
|
+
if (buf.length < 10) return null;
|
|
30
|
+
return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function bmpSize(buf) {
|
|
34
|
+
if (buf.length < 26) return null;
|
|
35
|
+
const headerSize = buf.readUInt32LE(14);
|
|
36
|
+
// BITMAPCOREHEADER is 16-bit; every later DIB header is 32-bit signed, with a
|
|
37
|
+
// negative height meaning a top-down bitmap.
|
|
38
|
+
if (headerSize === 12) {
|
|
39
|
+
return { width: buf.readUInt16LE(18), height: buf.readUInt16LE(20) };
|
|
40
|
+
}
|
|
41
|
+
if (buf.length < 27) return null;
|
|
42
|
+
return { width: Math.abs(buf.readInt32LE(18)), height: Math.abs(buf.readInt32LE(22)) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function webpSize(buf) {
|
|
46
|
+
// RIFF<size>WEBP, then one of three chunk flavours.
|
|
47
|
+
if (buf.length < 30) return null;
|
|
48
|
+
const chunk = String.fromCharCode(buf[12], buf[13], buf[14], buf[15]);
|
|
49
|
+
if (chunk === "VP8 ") {
|
|
50
|
+
// Lossy: 3-byte frame tag, 3-byte sync code, then 14-bit width/height.
|
|
51
|
+
return {
|
|
52
|
+
width: buf.readUInt16LE(26) & 0x3fff,
|
|
53
|
+
height: buf.readUInt16LE(28) & 0x3fff,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
if (chunk === "VP8L") {
|
|
57
|
+
// Lossless: signature byte 0x2f, then 14 bits width-1 and 14 bits height-1.
|
|
58
|
+
if (buf[20] !== 0x2f) return null;
|
|
59
|
+
const bits = buf.readUInt32LE(21);
|
|
60
|
+
return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 };
|
|
61
|
+
}
|
|
62
|
+
if (chunk === "VP8X") {
|
|
63
|
+
// Extended: canvas size as two 24-bit little-endian values, minus one.
|
|
64
|
+
return {
|
|
65
|
+
width: buf.readUIntLE(24, 3) + 1,
|
|
66
|
+
height: buf.readUIntLE(27, 3) + 1,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Markers that carry no payload and so must not be length-skipped.
|
|
73
|
+
const JPEG_STANDALONE = new Set([0x01, 0xd8, 0xd9, 0xd0, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7]);
|
|
74
|
+
// Every Start-Of-Frame flavour (baseline, progressive, lossless, …). The three
|
|
75
|
+
// holes are DHT (0xc4), JPG extension (0xc8) and DAC (0xcc), which are not SOFs.
|
|
76
|
+
const isJpegSof = (m) => m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc;
|
|
77
|
+
|
|
78
|
+
function jpegSize(buf) {
|
|
79
|
+
let pos = 2; // past SOI
|
|
80
|
+
while (pos + 3 < buf.length) {
|
|
81
|
+
if (buf[pos] !== 0xff) {
|
|
82
|
+
// Resync: fill bytes (0xff) and the odd stray byte are legal between
|
|
83
|
+
// segments; anything else means the header is not parseable.
|
|
84
|
+
pos++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
let marker = buf[pos + 1];
|
|
88
|
+
// Any number of 0xff fill bytes may precede the marker itself.
|
|
89
|
+
let markerPos = pos + 1;
|
|
90
|
+
while (marker === 0xff && markerPos + 1 < buf.length) {
|
|
91
|
+
marker = buf[++markerPos];
|
|
92
|
+
}
|
|
93
|
+
if (JPEG_STANDALONE.has(marker)) {
|
|
94
|
+
pos = markerPos + 1;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (markerPos + 3 >= buf.length) return null;
|
|
98
|
+
const length = buf.readUInt16BE(markerPos + 1);
|
|
99
|
+
if (isJpegSof(marker)) {
|
|
100
|
+
// SOF payload: precision(1) height(2) width(2).
|
|
101
|
+
if (markerPos + 7 >= buf.length) return null;
|
|
102
|
+
return {
|
|
103
|
+
height: buf.readUInt16BE(markerPos + 4),
|
|
104
|
+
width: buf.readUInt16BE(markerPos + 6),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (length < 2) return null;
|
|
108
|
+
pos = markerPos + 1 + length;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Intrinsic pixel size of a raster image, read from its header bytes.
|
|
115
|
+
*
|
|
116
|
+
* Takes the same "the bytes beat the declared type" stance as `extFromBytes`
|
|
117
|
+
* (engine-download.mjs): the format is sniffed, never taken from a MIME type.
|
|
118
|
+
* Returns null for anything unrecognised (TIFF, a truncated header, an SVG) so
|
|
119
|
+
* the caller reports "unknown" rather than a fabricated size.
|
|
120
|
+
*/
|
|
121
|
+
export function imageSizeFromBuffer(buf) {
|
|
122
|
+
if (!buf || buf.length < 16) return null;
|
|
123
|
+
let size = null;
|
|
124
|
+
if (startsWith(buf, 0, [0x89, ...ascii("PNG"), 0x0d, 0x0a, 0x1a, 0x0a])) size = pngSize(buf);
|
|
125
|
+
else if (startsWith(buf, 0, [0xff, 0xd8, 0xff])) size = jpegSize(buf);
|
|
126
|
+
else if (startsWith(buf, 0, ascii("GIF8"))) size = gifSize(buf);
|
|
127
|
+
else if (startsWith(buf, 0, ascii("BM"))) size = bmpSize(buf);
|
|
128
|
+
else if (startsWith(buf, 0, ascii("RIFF")) && startsWith(buf, 8, ascii("WEBP"))) size = webpSize(buf);
|
|
129
|
+
if (!size) return null;
|
|
130
|
+
if (!Number.isFinite(size.width) || !Number.isFinite(size.height)) return null;
|
|
131
|
+
if (size.width <= 0 || size.height <= 0) return null;
|
|
132
|
+
return size;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Intrinsic size of an SVG, in its own user units.
|
|
137
|
+
*
|
|
138
|
+
* `width`/`height` win when both are plain lengths; otherwise the viewBox
|
|
139
|
+
* supplies the aspect ratio (the common case for icon sets, which size
|
|
140
|
+
* themselves from CSS and carry only a viewBox). Percentage widths are ignored
|
|
141
|
+
* for the same reason — they describe the container, not the graphic.
|
|
142
|
+
*/
|
|
143
|
+
export function svgSizeFromMarkup(markup) {
|
|
144
|
+
if (typeof markup !== "string" || markup.length === 0) return null;
|
|
145
|
+
// Only the opening <svg …> tag may contribute; a nested <symbol width=…>
|
|
146
|
+
// further down must not be mistaken for the root size.
|
|
147
|
+
const openTag = /<svg\b[^>]*>/i.exec(markup)?.[0];
|
|
148
|
+
if (!openTag) return null;
|
|
149
|
+
|
|
150
|
+
const length = (name) => {
|
|
151
|
+
const m = new RegExp(`\\b${name}\\s*=\\s*["']?\\s*([\\d.]+)\\s*([a-z%]*)`, "i").exec(openTag);
|
|
152
|
+
if (!m) return null;
|
|
153
|
+
// Only unitless user units and `px` are a pixel count. `%` sizes the
|
|
154
|
+
// container, not the graphic, and physical units (mm/pt/in) would need a DPI
|
|
155
|
+
// — both fall through to the viewBox, which always states the true ratio.
|
|
156
|
+
const unit = m[2].toLowerCase();
|
|
157
|
+
if (unit !== "" && unit !== "px") return null;
|
|
158
|
+
const n = Number.parseFloat(m[1]);
|
|
159
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const width = length("width");
|
|
163
|
+
const height = length("height");
|
|
164
|
+
if (width && height) return { width, height };
|
|
165
|
+
|
|
166
|
+
const viewBox = /\bviewBox\s*=\s*["']?\s*([-\d.\s,]+)/i.exec(openTag);
|
|
167
|
+
if (viewBox) {
|
|
168
|
+
const parts = viewBox[1].trim().split(/[\s,]+/).map(Number.parseFloat);
|
|
169
|
+
if (parts.length === 4 && parts.every(Number.isFinite) && parts[2] > 0 && parts[3] > 0) {
|
|
170
|
+
return { width: width ?? parts[2], height: height ?? parts[3] };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** `1200×800` for display, or null when the size could not be read. */
|
|
177
|
+
export function formatSize(size) {
|
|
178
|
+
if (!size) return null;
|
|
179
|
+
const round = (n) => (Number.isInteger(n) ? n : Math.round(n * 100) / 100);
|
|
180
|
+
return `${round(size.width)}×${round(size.height)}`;
|
|
181
|
+
}
|
package/src/builders/build.mjs
CHANGED
|
@@ -945,31 +945,61 @@ function openerChain(target) {
|
|
|
945
945
|
return [{ cmd: "xdg-open", args: [target] }];
|
|
946
946
|
}
|
|
947
947
|
|
|
948
|
+
// How long to give a platform opener to exit before assuming it handed the URL
|
|
949
|
+
// off successfully. `open`/`xdg-open`/`start` all exit within milliseconds; an
|
|
950
|
+
// opener still running after this window is treated as launched, not failed.
|
|
951
|
+
const OPENER_EXIT_GRACE_MS = 2000;
|
|
952
|
+
|
|
948
953
|
// Open a local file path OR an http URL in the user's default browser, trying
|
|
949
|
-
// each platform opener in turn.
|
|
950
|
-
//
|
|
951
|
-
//
|
|
952
|
-
//
|
|
953
|
-
//
|
|
954
|
-
|
|
954
|
+
// each platform opener in turn. Must NEVER crash: if the opener binary is
|
|
955
|
+
// missing (e.g. a headless Linux box with no xdg-open) the spawned child would
|
|
956
|
+
// otherwise emit an unhandled 'error' and take down the whole CLI.
|
|
957
|
+
//
|
|
958
|
+
// Resolves `true` when an opener plausibly launched (exit 0, or still running
|
|
959
|
+
// after the grace window), `false` when the whole chain failed — missing
|
|
960
|
+
// binaries AND nonzero exits both advance the chain, so a present-but-broken
|
|
961
|
+
// `xdg-open` (headless box, no browser) is reported as a failure instead of
|
|
962
|
+
// silently swallowed. Never rejects. On `false` the target is printed for the
|
|
963
|
+
// user to open manually unless `quiet` — callers with their own messaging (the
|
|
964
|
+
// review/plan gates) pass quiet:true and speak for themselves.
|
|
965
|
+
export function openInBrowser(target, { quiet = false } = {}) {
|
|
955
966
|
const chain = openerChain(target);
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
967
|
+
return new Promise((resolve) => {
|
|
968
|
+
const tryAt = (i) => {
|
|
969
|
+
if (i >= chain.length) {
|
|
970
|
+
if (!quiet) {
|
|
971
|
+
process.stderr.write(`couldn't open a browser — open ${target} manually\n`);
|
|
972
|
+
}
|
|
973
|
+
resolve(false);
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
const { cmd, args } = chain[i];
|
|
977
|
+
let child;
|
|
978
|
+
try {
|
|
979
|
+
child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
980
|
+
} catch {
|
|
981
|
+
tryAt(i + 1);
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
let settled = false;
|
|
985
|
+
const settle = (ok) => {
|
|
986
|
+
if (settled) return;
|
|
987
|
+
settled = true;
|
|
988
|
+
clearTimeout(timer);
|
|
989
|
+
if (ok) resolve(true);
|
|
990
|
+
else tryAt(i + 1);
|
|
991
|
+
};
|
|
992
|
+
// Still running after the grace window ⇒ it took the URL; don't hold the
|
|
993
|
+
// caller hostage to an opener that lingers. unref'd so it can't keep the
|
|
994
|
+
// process alive either way.
|
|
995
|
+
const timer = setTimeout(() => settle(true), OPENER_EXIT_GRACE_MS);
|
|
996
|
+
if (timer.unref) timer.unref();
|
|
997
|
+
child.on("error", () => settle(false));
|
|
998
|
+
child.on("exit", (code) => settle(code === 0));
|
|
999
|
+
child.unref();
|
|
1000
|
+
};
|
|
1001
|
+
tryAt(0);
|
|
1002
|
+
});
|
|
973
1003
|
}
|
|
974
1004
|
|
|
975
1005
|
function parseArgs(argv) {
|