@brydio/manifest 0.1.0-alpha.5 → 0.1.0-alpha.7
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/package.json +2 -2
- package/src/base.d.ts +17 -1
- package/src/base.js +13 -1
- package/src/bundle.d.ts +8 -1
- package/src/bundle.js +28 -3
- package/src/grants.d.ts +1 -1
- package/src/grants.js +1 -1
- package/src/index.d.ts +5 -4
- package/src/index.js +5 -4
- package/src/requirements.d.ts +122 -0
- package/src/requirements.js +692 -0
- package/src/schema.d.ts +167 -2
- package/src/schema.js +163 -7
- package/src/secrets.d.ts +14 -0
- package/src/secrets.js +22 -0
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two things every app must have before it is published (owner decisions of
|
|
3
|
+
* 22 September 2026), checked the same way by `brydio validate` and by
|
|
4
|
+
* `POST /apps/publish`.
|
|
5
|
+
*
|
|
6
|
+
* - **A logo and an icon, each in colour and in one colour** (ADR-A20). The
|
|
7
|
+
* manifest names four image files, and a package missing any of them is
|
|
8
|
+
* refused.
|
|
9
|
+
* - **Something for the assistant to call** (ADR-A19): generated tools from a
|
|
10
|
+
* collection, a custom tool, an MCP server or an integration.
|
|
11
|
+
*
|
|
12
|
+
* A copy of Brydio's `apps/api/src/apps/manifest/requirements.ts`, the
|
|
13
|
+
* checks `POST /apps/publish` runs. Nothing here imports anything, so the
|
|
14
|
+
* copy is the file: not even `node:zlib`, which a PNG is inflated with, since
|
|
15
|
+
* a screen may bundle `@brydio/manifest` and a worker has none.
|
|
16
|
+
* `test/requirements.test.ts` asks Brydio the same questions whenever a
|
|
17
|
+
* checkout is beside this one.
|
|
18
|
+
*
|
|
19
|
+
* ## What "one colour" means
|
|
20
|
+
*
|
|
21
|
+
* The mono variant is drawn tinted by the theme, so it must be a shape and
|
|
22
|
+
* nothing else: one colour on transparency.
|
|
23
|
+
*
|
|
24
|
+
* - An **SVG** is read for every colour it paints with: `fill`, `stroke`,
|
|
25
|
+
* `stop-color`, `flood-color` and `color`, as attributes and inside `style`.
|
|
26
|
+
* `none`, `transparent`, `inherit` and `currentColor` are not colours.
|
|
27
|
+
* A shape with no fill of its own paints in the default black, unless the
|
|
28
|
+
* root `<svg>` or a `<g>` sets one. More than one distinct colour is
|
|
29
|
+
* refused, and so is an embedded `<image>`, which could be anything.
|
|
30
|
+
* - A **PNG** is decoded, and every pixel at least 1/16 opaque must be within
|
|
31
|
+
* 32 of one colour on each channel, so anti-aliased edges of a black glyph
|
|
32
|
+
* (black at partial alpha) pass and a two-colour picture does not. A PNG
|
|
33
|
+
* with no transparent pixel at all is a solid block and is refused too.
|
|
34
|
+
* - A **JPEG** has no transparency, and a **WebP** is not decoded here, so
|
|
35
|
+
* neither can be the one-colour variant. Both are fine in colour.
|
|
36
|
+
*
|
|
37
|
+
* ## Sizes
|
|
38
|
+
*
|
|
39
|
+
* - An **icon** is square: from 48 to 1,024 pixels across as a raster, and a
|
|
40
|
+
* square view box as an SVG.
|
|
41
|
+
* - A **logo** may be wider than it is tall, never taller: from 48 to 1,024
|
|
42
|
+
* pixels tall, at most 2,048 wide, and at most four times as wide as it is
|
|
43
|
+
* tall. An SVG's view box is held to the same proportions.
|
|
44
|
+
* - Every file is at most 512 KB.
|
|
45
|
+
*/
|
|
46
|
+
export const BRAND_LIMITS = {
|
|
47
|
+
bytes: 512 * 1024,
|
|
48
|
+
iconMinSide: 48,
|
|
49
|
+
iconMaxSide: 1_024,
|
|
50
|
+
logoMinHeight: 48,
|
|
51
|
+
logoMaxHeight: 1_024,
|
|
52
|
+
logoMaxWidth: 2_048,
|
|
53
|
+
/** Width over height, at most. */
|
|
54
|
+
logoMaxRatio: 4,
|
|
55
|
+
};
|
|
56
|
+
/** The two images, and the two variants each one comes in. */
|
|
57
|
+
export const BRAND_FIELDS = [
|
|
58
|
+
'logo.color',
|
|
59
|
+
'logo.mono',
|
|
60
|
+
'icon.color',
|
|
61
|
+
'icon.mono',
|
|
62
|
+
];
|
|
63
|
+
/** A path the manifest wrote, as a bundle names the file: no `./`, forward slashes. */
|
|
64
|
+
export const brandFilePath = (declared) => declared.replace(/^\.\//, '');
|
|
65
|
+
/**
|
|
66
|
+
* Whether a declared brand path is one Brydio will follow: `./`-prefixed,
|
|
67
|
+
* relative, inside the package, and a name a bundle can hold.
|
|
68
|
+
*/
|
|
69
|
+
export function isBrandPath(declared) {
|
|
70
|
+
if (typeof declared !== 'string' || !declared.startsWith('./'))
|
|
71
|
+
return false;
|
|
72
|
+
const path = brandFilePath(declared);
|
|
73
|
+
return (path.length > 0 &&
|
|
74
|
+
path.length <= 200 &&
|
|
75
|
+
/^[A-Za-z0-9._\-/]+$/.test(path) &&
|
|
76
|
+
/\.(png|jpe?g|webp|svg)$/i.test(path) &&
|
|
77
|
+
path.split('/').every(segment => segment.length > 0 && !segment.startsWith('.')));
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The brand files a manifest names, by field, as bundle paths. Only fields
|
|
81
|
+
* written in the two-variant shape; a legacy `icon: "./icon.png"` names none.
|
|
82
|
+
*/
|
|
83
|
+
export function brandPathsOf(manifest) {
|
|
84
|
+
const found = new Map();
|
|
85
|
+
if (!manifest || typeof manifest !== 'object')
|
|
86
|
+
return found;
|
|
87
|
+
for (const field of BRAND_FIELDS) {
|
|
88
|
+
const [part, variant] = field.split('.');
|
|
89
|
+
const images = manifest[part];
|
|
90
|
+
const declared = images && typeof images === 'object' && !Array.isArray(images)
|
|
91
|
+
? images[variant]
|
|
92
|
+
: undefined;
|
|
93
|
+
if (isBrandPath(declared))
|
|
94
|
+
found.set(field, brandFilePath(declared));
|
|
95
|
+
}
|
|
96
|
+
return found;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Everything wrong with an app's logo and icon.
|
|
100
|
+
*
|
|
101
|
+
* `required` is off only where a package is read that is not being published
|
|
102
|
+
* (an import of somebody's plugin with no screens): the images it does name
|
|
103
|
+
* are still checked, and the ones it leaves out are not asked for.
|
|
104
|
+
*/
|
|
105
|
+
export function brandProblems(manifest, files, options = {}) {
|
|
106
|
+
const problems = [];
|
|
107
|
+
const required = options.required !== false;
|
|
108
|
+
for (const part of ['logo', 'icon']) {
|
|
109
|
+
const images = manifest[part];
|
|
110
|
+
if (images === undefined || images === null) {
|
|
111
|
+
if (!required)
|
|
112
|
+
continue;
|
|
113
|
+
problems.push(part === 'logo'
|
|
114
|
+
? {
|
|
115
|
+
code: 'brand_logo_missing',
|
|
116
|
+
path: 'logo',
|
|
117
|
+
message: 'An app needs a logo: set "logo" to { "color": "./…", "mono": "./…" }, one image in colour and one in a single colour.',
|
|
118
|
+
}
|
|
119
|
+
: {
|
|
120
|
+
code: 'brand_icon_missing',
|
|
121
|
+
path: 'icon',
|
|
122
|
+
message: 'An app needs an icon: set "icon" to { "color": "./…", "mono": "./…" }, one square image in colour and one in a single colour.',
|
|
123
|
+
});
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (typeof images === 'string' && part === 'icon') {
|
|
127
|
+
// The one image every app had before ADR-A20: still read, never enough.
|
|
128
|
+
if (required) {
|
|
129
|
+
problems.push({
|
|
130
|
+
code: 'brand_icon_missing',
|
|
131
|
+
path: 'icon',
|
|
132
|
+
message: 'An icon is now two images: set "icon" to { "color": "./…", "mono": "./…" } instead of one path.',
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (typeof images !== 'object' || Array.isArray(images)) {
|
|
138
|
+
problems.push({
|
|
139
|
+
code: part === 'logo' ? 'brand_logo_missing' : 'brand_icon_missing',
|
|
140
|
+
path: part,
|
|
141
|
+
message: `"${part}" must be { "color": "./…", "mono": "./…" }.`,
|
|
142
|
+
});
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
for (const variant of ['color', 'mono']) {
|
|
146
|
+
const field = `${part}.${variant}`;
|
|
147
|
+
const declared = images[variant];
|
|
148
|
+
if (!isBrandPath(declared)) {
|
|
149
|
+
problems.push({
|
|
150
|
+
code: 'brand_path_invalid',
|
|
151
|
+
path: field,
|
|
152
|
+
message: `${field} must be a path inside the package that starts with ./ and ends in .png, .jpg, .webp or .svg.`,
|
|
153
|
+
});
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const bytes = files.get(brandFilePath(declared));
|
|
157
|
+
if (!bytes) {
|
|
158
|
+
problems.push({
|
|
159
|
+
code: 'brand_file_missing',
|
|
160
|
+
path: field,
|
|
161
|
+
message: `${field} names ${declared}, which is not in the package.`,
|
|
162
|
+
});
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
problems.push(...imageProblems(field, bytes));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return problems;
|
|
169
|
+
}
|
|
170
|
+
/** One brand file, judged for its size, its kind, its shape and, for mono, its colours. */
|
|
171
|
+
function imageProblems(field, bytes) {
|
|
172
|
+
if (bytes.byteLength > BRAND_LIMITS.bytes) {
|
|
173
|
+
return [
|
|
174
|
+
{
|
|
175
|
+
code: 'brand_file_too_large',
|
|
176
|
+
path: field,
|
|
177
|
+
message: `${field} is ${Math.ceil(bytes.byteLength / 1024)} KB. A logo or icon file is at most 512 KB.`,
|
|
178
|
+
},
|
|
179
|
+
];
|
|
180
|
+
}
|
|
181
|
+
const shape = brandShapeOf(bytes);
|
|
182
|
+
if (!shape) {
|
|
183
|
+
return [
|
|
184
|
+
{
|
|
185
|
+
code: 'brand_file_unreadable',
|
|
186
|
+
path: field,
|
|
187
|
+
message: `${field} is not a PNG, JPEG, WebP or SVG that Brydio can read.`,
|
|
188
|
+
},
|
|
189
|
+
];
|
|
190
|
+
}
|
|
191
|
+
if (shape.type === 'image/svg+xml' && !svgIsInert(text(bytes))) {
|
|
192
|
+
return [
|
|
193
|
+
{
|
|
194
|
+
code: 'brand_file_unreadable',
|
|
195
|
+
path: field,
|
|
196
|
+
message: `${field} carries a script, an event handler or a link to somewhere else, which a logo or icon cannot.`,
|
|
197
|
+
},
|
|
198
|
+
];
|
|
199
|
+
}
|
|
200
|
+
const problems = [];
|
|
201
|
+
const size = `${shape.width}×${shape.height}`;
|
|
202
|
+
const svg = shape.type === 'image/svg+xml';
|
|
203
|
+
if (field.startsWith('icon.')) {
|
|
204
|
+
const square = svg
|
|
205
|
+
? Math.abs(shape.width - shape.height) <= shape.width / 100
|
|
206
|
+
: shape.width === shape.height;
|
|
207
|
+
const fits = svg ||
|
|
208
|
+
(shape.width >= BRAND_LIMITS.iconMinSide &&
|
|
209
|
+
shape.width <= BRAND_LIMITS.iconMaxSide);
|
|
210
|
+
if (!square || !fits) {
|
|
211
|
+
problems.push({
|
|
212
|
+
code: 'brand_icon_shape',
|
|
213
|
+
path: field,
|
|
214
|
+
message: `${field} is ${size}. An icon is square, from 48 to 1024 pixels across.`,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
const ratio = shape.width / shape.height;
|
|
220
|
+
const proportioned = ratio >= 0.99 && ratio <= BRAND_LIMITS.logoMaxRatio;
|
|
221
|
+
const fits = svg ||
|
|
222
|
+
(shape.height >= BRAND_LIMITS.logoMinHeight &&
|
|
223
|
+
shape.height <= BRAND_LIMITS.logoMaxHeight &&
|
|
224
|
+
shape.width <= BRAND_LIMITS.logoMaxWidth);
|
|
225
|
+
if (!proportioned || !fits) {
|
|
226
|
+
problems.push({
|
|
227
|
+
code: 'brand_logo_shape',
|
|
228
|
+
path: field,
|
|
229
|
+
message: `${field} is ${size}. A logo is 48 to 1024 pixels tall, at most 2048 wide, and from as wide as it is tall to four times as wide.`,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (field.endsWith('.mono'))
|
|
234
|
+
problems.push(...monoProblems(field, shape.type, bytes));
|
|
235
|
+
return problems;
|
|
236
|
+
}
|
|
237
|
+
/** The one-colour variant: a PNG or an SVG that paints in one colour on transparency. */
|
|
238
|
+
function monoProblems(field, type, bytes) {
|
|
239
|
+
if (type === 'image/svg+xml') {
|
|
240
|
+
const colours = svgColours(text(bytes));
|
|
241
|
+
if (colours === null) {
|
|
242
|
+
return [
|
|
243
|
+
{
|
|
244
|
+
code: 'brand_mono_colours',
|
|
245
|
+
path: field,
|
|
246
|
+
message: `${field} embeds an image, so it can't be read as one colour. A one-colour image paints in one colour, or in currentColor, on transparency.`,
|
|
247
|
+
},
|
|
248
|
+
];
|
|
249
|
+
}
|
|
250
|
+
return colours.length > 1
|
|
251
|
+
? [
|
|
252
|
+
{
|
|
253
|
+
code: 'brand_mono_colours',
|
|
254
|
+
path: field,
|
|
255
|
+
message: `${field} paints in ${colours.length} colours (${colours.join(', ')}). A one-colour image paints in one colour, or in currentColor, on transparency.`,
|
|
256
|
+
},
|
|
257
|
+
]
|
|
258
|
+
: [];
|
|
259
|
+
}
|
|
260
|
+
if (type !== 'image/png') {
|
|
261
|
+
return [
|
|
262
|
+
{
|
|
263
|
+
code: 'brand_mono_type',
|
|
264
|
+
path: field,
|
|
265
|
+
message: `${field} is a ${type === 'image/jpeg' ? 'JPEG' : 'WebP'}. A one-colour image is a PNG with transparency, or an SVG.`,
|
|
266
|
+
},
|
|
267
|
+
];
|
|
268
|
+
}
|
|
269
|
+
const verdict = pngVerdict(bytes);
|
|
270
|
+
if (verdict === 'unreadable') {
|
|
271
|
+
return [
|
|
272
|
+
{
|
|
273
|
+
code: 'brand_file_unreadable',
|
|
274
|
+
path: field,
|
|
275
|
+
message: `${field} is not a PNG, JPEG, WebP or SVG that Brydio can read.`,
|
|
276
|
+
},
|
|
277
|
+
];
|
|
278
|
+
}
|
|
279
|
+
if (verdict === 'opaque') {
|
|
280
|
+
return [
|
|
281
|
+
{
|
|
282
|
+
code: 'brand_mono_colours',
|
|
283
|
+
path: field,
|
|
284
|
+
message: `${field} has no transparent pixels, so it would draw as a solid block. A one-colour image paints in one colour on transparency.`,
|
|
285
|
+
},
|
|
286
|
+
];
|
|
287
|
+
}
|
|
288
|
+
if (verdict === 'colours') {
|
|
289
|
+
return [
|
|
290
|
+
{
|
|
291
|
+
code: 'brand_mono_colours',
|
|
292
|
+
path: field,
|
|
293
|
+
message: `${field} has pixels of more than one colour. A one-colour image paints in one colour on transparency.`,
|
|
294
|
+
},
|
|
295
|
+
];
|
|
296
|
+
}
|
|
297
|
+
return [];
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Whether the assistant has anything to call in this app (ADR-A19).
|
|
301
|
+
*
|
|
302
|
+
* `parts` counts what the package bundles beside the manifest: the servers in
|
|
303
|
+
* its `servers.json` and the files in its `integrations/`. Where they can't
|
|
304
|
+
* be seen (a published bundle holds only code), `requires` still counts.
|
|
305
|
+
*/
|
|
306
|
+
export function toolsProblem(manifest, parts = {}) {
|
|
307
|
+
const tools = (manifest.tools ?? {});
|
|
308
|
+
const data = manifest.data;
|
|
309
|
+
const collections = data && typeof data === 'object' && !Array.isArray(data)
|
|
310
|
+
? Object.keys(data).length
|
|
311
|
+
: 0;
|
|
312
|
+
const generated = tools.generated !== false && collections > 0;
|
|
313
|
+
const custom = Array.isArray(tools.custom) && tools.custom.length > 0;
|
|
314
|
+
const requires = (manifest.requires ?? {});
|
|
315
|
+
const named = (list) => (Array.isArray(list) ? list.length : 0);
|
|
316
|
+
const servers = (parts.servers ?? 0) + named(requires.servers);
|
|
317
|
+
const integrations = (parts.integrations ?? 0) + named(requires.integrations);
|
|
318
|
+
if (generated || custom || servers > 0 || integrations > 0)
|
|
319
|
+
return null;
|
|
320
|
+
return {
|
|
321
|
+
code: 'app_has_no_tools',
|
|
322
|
+
path: 'tools',
|
|
323
|
+
message: 'This app gives the assistant nothing to call. Keep a collection with generated tools, add a custom tool, or bundle an MCP server or an integration.',
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
const text = (bytes) => new TextDecoder().decode(bytes);
|
|
327
|
+
/** What an image is and how big, from its bytes, or null for anything else. */
|
|
328
|
+
export function brandShapeOf(bytes) {
|
|
329
|
+
return pngShape(bytes) ?? jpegShape(bytes) ?? webpShape(bytes) ?? svgShape(bytes);
|
|
330
|
+
}
|
|
331
|
+
const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
332
|
+
const view = (bytes) => new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
333
|
+
function pngShape(bytes) {
|
|
334
|
+
if (bytes.length < 24 || PNG_MAGIC.some((byte, at) => bytes[at] !== byte))
|
|
335
|
+
return null;
|
|
336
|
+
return {
|
|
337
|
+
type: 'image/png',
|
|
338
|
+
width: view(bytes).getUint32(16),
|
|
339
|
+
height: view(bytes).getUint32(20),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
function jpegShape(bytes) {
|
|
343
|
+
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8)
|
|
344
|
+
return null;
|
|
345
|
+
const data = view(bytes);
|
|
346
|
+
let at = 2;
|
|
347
|
+
while (at + 9 < bytes.length) {
|
|
348
|
+
if (bytes[at] !== 0xff)
|
|
349
|
+
return null;
|
|
350
|
+
const marker = bytes[at + 1];
|
|
351
|
+
const length = data.getUint16(at + 2);
|
|
352
|
+
if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) {
|
|
353
|
+
return {
|
|
354
|
+
type: 'image/jpeg',
|
|
355
|
+
height: data.getUint16(at + 5),
|
|
356
|
+
width: data.getUint16(at + 7),
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
at += 2 + length;
|
|
360
|
+
}
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
function webpShape(bytes) {
|
|
364
|
+
const ascii = (from, to) => String.fromCharCode(...bytes.slice(from, to));
|
|
365
|
+
if (bytes.length < 30 || ascii(0, 4) !== 'RIFF' || ascii(8, 12) !== 'WEBP')
|
|
366
|
+
return null;
|
|
367
|
+
const kind = ascii(12, 16);
|
|
368
|
+
const data = view(bytes);
|
|
369
|
+
if (kind === 'VP8X') {
|
|
370
|
+
return {
|
|
371
|
+
type: 'image/webp',
|
|
372
|
+
width: 1 + (bytes[24] | (bytes[25] << 8) | (bytes[26] << 16)),
|
|
373
|
+
height: 1 + (bytes[27] | (bytes[28] << 8) | (bytes[29] << 16)),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
if (kind === 'VP8 ') {
|
|
377
|
+
return {
|
|
378
|
+
type: 'image/webp',
|
|
379
|
+
width: data.getUint16(26, true) & 0x3fff,
|
|
380
|
+
height: data.getUint16(28, true) & 0x3fff,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
if (kind === 'VP8L') {
|
|
384
|
+
const bits = data.getUint32(21, true);
|
|
385
|
+
return {
|
|
386
|
+
type: 'image/webp',
|
|
387
|
+
width: 1 + (bits & 0x3fff),
|
|
388
|
+
height: 1 + ((bits >> 14) & 0x3fff),
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
/** An SVG's view box, or its width and height, or square when it says neither. */
|
|
394
|
+
function svgShape(bytes) {
|
|
395
|
+
const head = text(bytes.slice(0, 4096));
|
|
396
|
+
if (!/<svg[\s>]/i.test(head))
|
|
397
|
+
return null;
|
|
398
|
+
const box = /viewBox\s*=\s*["']\s*[\d.-]+[\s,]+[\d.-]+[\s,]+([\d.]+)[\s,]+([\d.]+)/i.exec(head);
|
|
399
|
+
if (box)
|
|
400
|
+
return { type: 'image/svg+xml', width: Number(box[1]), height: Number(box[2]) };
|
|
401
|
+
const root = /<svg\b[^>]*>/i.exec(head)?.[0] ?? '';
|
|
402
|
+
const width = /\swidth\s*=\s*["']?([\d.]+)/i.exec(root);
|
|
403
|
+
const height = /\sheight\s*=\s*["']?([\d.]+)/i.exec(root);
|
|
404
|
+
if (width && height)
|
|
405
|
+
return { type: 'image/svg+xml', width: Number(width[1]), height: Number(height[1]) };
|
|
406
|
+
return { type: 'image/svg+xml', width: 512, height: 512 };
|
|
407
|
+
}
|
|
408
|
+
/** No script, no event handler, no `javascript:` and no reference that leaves the file. */
|
|
409
|
+
export function svgIsInert(svg) {
|
|
410
|
+
return !(/<script\b/i.test(svg) ||
|
|
411
|
+
/<foreignObject\b/i.test(svg) ||
|
|
412
|
+
/\son[a-z-]+\s*=/i.test(svg) ||
|
|
413
|
+
/(javascript|vbscript|data:text\/html):/i.test(svg) ||
|
|
414
|
+
/(?:xlink:)?href\s*=\s*["']\s*(?!#)[^"']/i.test(svg) ||
|
|
415
|
+
/url\(\s*['"]?\s*(?:https?:)?\/\//i.test(svg));
|
|
416
|
+
}
|
|
417
|
+
const NOT_A_COLOUR = new Set([
|
|
418
|
+
'none',
|
|
419
|
+
'transparent',
|
|
420
|
+
'inherit',
|
|
421
|
+
'currentcolor',
|
|
422
|
+
'initial',
|
|
423
|
+
'unset',
|
|
424
|
+
'context-fill',
|
|
425
|
+
'context-stroke',
|
|
426
|
+
]);
|
|
427
|
+
const SHAPES = /<(path|rect|circle|ellipse|polygon|polyline|line|text|tspan|use)\b([^>]*)>/gi;
|
|
428
|
+
/**
|
|
429
|
+
* Every distinct colour an SVG paints with, normalised, or null when it
|
|
430
|
+
* embeds a raster whose colours can't be read from the markup.
|
|
431
|
+
*/
|
|
432
|
+
export function svgColours(svg) {
|
|
433
|
+
if (/<image\b/i.test(svg))
|
|
434
|
+
return null;
|
|
435
|
+
const found = new Set();
|
|
436
|
+
const add = (value) => {
|
|
437
|
+
const colour = normaliseColour(value);
|
|
438
|
+
if (colour)
|
|
439
|
+
found.add(colour);
|
|
440
|
+
};
|
|
441
|
+
for (const [, , value] of svg.matchAll(/\b(fill|stroke|stop-color|flood-color|lighting-color|color)\s*=\s*["']([^"']*)["']/gi)) {
|
|
442
|
+
add(value);
|
|
443
|
+
}
|
|
444
|
+
for (const [, value] of svg.matchAll(/(?:^|[;{\s"'])(?:fill|stroke|stop-color|flood-color|lighting-color|color)\s*:\s*([^;}"']+)/gi)) {
|
|
445
|
+
add(value);
|
|
446
|
+
}
|
|
447
|
+
// A shape that says nothing about its fill paints in black, unless
|
|
448
|
+
// something around it says otherwise. Groups are not followed: any fill on
|
|
449
|
+
// the root or on a group is taken to reach every shape.
|
|
450
|
+
const inherited = /<(svg|g)\b[^>]*\bfill\s*[=:]/i.test(svg) ||
|
|
451
|
+
/<style\b[^>]*>[\s\S]*\bfill\s*:/i.test(svg);
|
|
452
|
+
if (!inherited) {
|
|
453
|
+
for (const [, , attributes] of svg.matchAll(SHAPES)) {
|
|
454
|
+
if (!/\bfill\s*[=:]/i.test(attributes)) {
|
|
455
|
+
found.add('#000000');
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return [...found].sort();
|
|
461
|
+
}
|
|
462
|
+
const NAMED = {
|
|
463
|
+
black: '#000000',
|
|
464
|
+
white: '#ffffff',
|
|
465
|
+
red: '#ff0000',
|
|
466
|
+
green: '#008000',
|
|
467
|
+
blue: '#0000ff',
|
|
468
|
+
gray: '#808080',
|
|
469
|
+
grey: '#808080',
|
|
470
|
+
};
|
|
471
|
+
/** A colour as `#rrggbb`, or null for a non-colour or a paint server reference. */
|
|
472
|
+
function normaliseColour(raw) {
|
|
473
|
+
const value = raw
|
|
474
|
+
.trim()
|
|
475
|
+
.toLowerCase()
|
|
476
|
+
.replace(/\s*!important$/, '');
|
|
477
|
+
if (!value ||
|
|
478
|
+
NOT_A_COLOUR.has(value) ||
|
|
479
|
+
value.startsWith('url(') ||
|
|
480
|
+
value.startsWith('var('))
|
|
481
|
+
return null;
|
|
482
|
+
const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])([0-9a-f])?$/.exec(value);
|
|
483
|
+
if (short)
|
|
484
|
+
return `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`;
|
|
485
|
+
const long = /^#([0-9a-f]{6})([0-9a-f]{2})?$/.exec(value);
|
|
486
|
+
if (long)
|
|
487
|
+
return `#${long[1]}`;
|
|
488
|
+
const rgb = /^rgba?\(\s*(\d+)\s*[, ]\s*(\d+)\s*[, ]\s*(\d+)/.exec(value);
|
|
489
|
+
if (rgb) {
|
|
490
|
+
return `#${[rgb[1], rgb[2], rgb[3]].map(one => Math.min(255, Number(one)).toString(16).padStart(2, '0')).join('')}`;
|
|
491
|
+
}
|
|
492
|
+
return NAMED[value] ?? value;
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* What a PNG's pixels say about being one colour.
|
|
496
|
+
*
|
|
497
|
+
* Decodes the image (non-interlaced, every colour type and bit depth) and
|
|
498
|
+
* compares every pixel at least 1/16 opaque with the most opaque one.
|
|
499
|
+
*/
|
|
500
|
+
export function pngVerdict(bytes) {
|
|
501
|
+
const pixels = decodePng(bytes);
|
|
502
|
+
if (!pixels)
|
|
503
|
+
return 'unreadable';
|
|
504
|
+
const { rgba, width, height } = pixels;
|
|
505
|
+
let reference = -1;
|
|
506
|
+
let transparent = false;
|
|
507
|
+
for (let at = 0; at < width * height; at += 1) {
|
|
508
|
+
const alpha = rgba[at * 4 + 3];
|
|
509
|
+
if (alpha < 255)
|
|
510
|
+
transparent = true;
|
|
511
|
+
if (reference < 0 || alpha > rgba[reference * 4 + 3])
|
|
512
|
+
reference = at;
|
|
513
|
+
}
|
|
514
|
+
if (!transparent)
|
|
515
|
+
return 'opaque';
|
|
516
|
+
if (reference < 0 || rgba[reference * 4 + 3] < 16)
|
|
517
|
+
return 'one';
|
|
518
|
+
const [r, g, b] = [
|
|
519
|
+
rgba[reference * 4],
|
|
520
|
+
rgba[reference * 4 + 1],
|
|
521
|
+
rgba[reference * 4 + 2],
|
|
522
|
+
];
|
|
523
|
+
for (let at = 0; at < width * height; at += 1) {
|
|
524
|
+
if (rgba[at * 4 + 3] < 16)
|
|
525
|
+
continue;
|
|
526
|
+
if (Math.abs(rgba[at * 4] - r) > 32 ||
|
|
527
|
+
Math.abs(rgba[at * 4 + 1] - g) > 32 ||
|
|
528
|
+
Math.abs(rgba[at * 4 + 2] - b) > 32) {
|
|
529
|
+
return 'colours';
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
return 'one';
|
|
533
|
+
}
|
|
534
|
+
/** A PNG's pixels as 8-bit RGBA, or null for one this can't read. */
|
|
535
|
+
function decodePng(bytes) {
|
|
536
|
+
if (bytes.length < 33 || PNG_MAGIC.some((byte, at) => bytes[at] !== byte))
|
|
537
|
+
return null;
|
|
538
|
+
const data = view(bytes);
|
|
539
|
+
let at = 8;
|
|
540
|
+
let width = 0;
|
|
541
|
+
let height = 0;
|
|
542
|
+
let depth = 0;
|
|
543
|
+
let colour = 0;
|
|
544
|
+
let interlace = 0;
|
|
545
|
+
let palette = null;
|
|
546
|
+
let alphas = null;
|
|
547
|
+
const chunks = [];
|
|
548
|
+
while (at + 8 <= bytes.length) {
|
|
549
|
+
const length = data.getUint32(at);
|
|
550
|
+
const kind = String.fromCharCode(...bytes.slice(at + 4, at + 8));
|
|
551
|
+
const body = bytes.slice(at + 8, at + 8 + length);
|
|
552
|
+
if (kind === 'IHDR') {
|
|
553
|
+
width = data.getUint32(at + 8);
|
|
554
|
+
height = data.getUint32(at + 12);
|
|
555
|
+
depth = bytes[at + 16];
|
|
556
|
+
colour = bytes[at + 17];
|
|
557
|
+
interlace = bytes[at + 20];
|
|
558
|
+
}
|
|
559
|
+
else if (kind === 'PLTE')
|
|
560
|
+
palette = body;
|
|
561
|
+
else if (kind === 'tRNS')
|
|
562
|
+
alphas = body;
|
|
563
|
+
else if (kind === 'IDAT')
|
|
564
|
+
chunks.push(body);
|
|
565
|
+
else if (kind === 'IEND')
|
|
566
|
+
break;
|
|
567
|
+
at += 12 + length;
|
|
568
|
+
}
|
|
569
|
+
const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[colour];
|
|
570
|
+
if (!width ||
|
|
571
|
+
!height ||
|
|
572
|
+
!channels ||
|
|
573
|
+
interlace !== 0 ||
|
|
574
|
+
![1, 2, 4, 8, 16].includes(depth))
|
|
575
|
+
return null;
|
|
576
|
+
if (width * height > 4_194_304)
|
|
577
|
+
return null;
|
|
578
|
+
let raw;
|
|
579
|
+
try {
|
|
580
|
+
const joined = new Uint8Array(chunks.reduce((sum, one) => sum + one.length, 0));
|
|
581
|
+
let offset = 0;
|
|
582
|
+
for (const one of chunks) {
|
|
583
|
+
joined.set(one, offset);
|
|
584
|
+
offset += one.length;
|
|
585
|
+
}
|
|
586
|
+
raw = inflate(joined);
|
|
587
|
+
}
|
|
588
|
+
catch {
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
const bitsPerPixel = channels * depth;
|
|
592
|
+
const stride = Math.ceil((width * bitsPerPixel) / 8);
|
|
593
|
+
const step = Math.max(1, bitsPerPixel >> 3);
|
|
594
|
+
if (raw.length < (stride + 1) * height)
|
|
595
|
+
return null;
|
|
596
|
+
const lines = new Uint8Array(stride * height);
|
|
597
|
+
let previous = new Uint8Array(stride);
|
|
598
|
+
for (let row = 0; row < height; row += 1) {
|
|
599
|
+
const filter = raw[row * (stride + 1)];
|
|
600
|
+
const line = raw.slice(row * (stride + 1) + 1, (row + 1) * (stride + 1));
|
|
601
|
+
for (let x = 0; x < stride; x += 1) {
|
|
602
|
+
const left = x >= step ? line[x - step] : 0;
|
|
603
|
+
const up = previous[x];
|
|
604
|
+
const corner = x >= step ? previous[x - step] : 0;
|
|
605
|
+
let add = 0;
|
|
606
|
+
if (filter === 1)
|
|
607
|
+
add = left;
|
|
608
|
+
else if (filter === 2)
|
|
609
|
+
add = up;
|
|
610
|
+
else if (filter === 3)
|
|
611
|
+
add = (left + up) >> 1;
|
|
612
|
+
else if (filter === 4) {
|
|
613
|
+
const p = left + up - corner;
|
|
614
|
+
const [pa, pb, pc] = [Math.abs(p - left), Math.abs(p - up), Math.abs(p - corner)];
|
|
615
|
+
add = pa <= pb && pa <= pc ? left : pb <= pc ? up : corner;
|
|
616
|
+
}
|
|
617
|
+
else if (filter !== 0)
|
|
618
|
+
return null;
|
|
619
|
+
line[x] = (line[x] + add) & 0xff;
|
|
620
|
+
}
|
|
621
|
+
lines.set(line, row * stride);
|
|
622
|
+
previous = line;
|
|
623
|
+
}
|
|
624
|
+
const rgba = new Uint8Array(width * height * 4);
|
|
625
|
+
const sample = (row, index) => {
|
|
626
|
+
const base = row * stride;
|
|
627
|
+
if (depth === 8)
|
|
628
|
+
return lines[base + index];
|
|
629
|
+
if (depth === 16)
|
|
630
|
+
return lines[base + index * 2];
|
|
631
|
+
const bit = index * depth;
|
|
632
|
+
return (lines[base + (bit >> 3)] >> (8 - depth - (bit & 7))) & ((1 << depth) - 1);
|
|
633
|
+
};
|
|
634
|
+
const scale = (value) => depth >= 8 ? value : Math.round((value * 255) / ((1 << depth) - 1));
|
|
635
|
+
for (let row = 0; row < height; row += 1) {
|
|
636
|
+
for (let x = 0; x < width; x += 1) {
|
|
637
|
+
const out = (row * width + x) * 4;
|
|
638
|
+
let [r, g, b, a] = [0, 0, 0, 255];
|
|
639
|
+
if (colour === 3) {
|
|
640
|
+
const index = sample(row, x);
|
|
641
|
+
if (!palette || index * 3 + 2 >= palette.length)
|
|
642
|
+
return null;
|
|
643
|
+
[r, g, b] = [
|
|
644
|
+
palette[index * 3],
|
|
645
|
+
palette[index * 3 + 1],
|
|
646
|
+
palette[index * 3 + 2],
|
|
647
|
+
];
|
|
648
|
+
a = alphas && index < alphas.length ? alphas[index] : 255;
|
|
649
|
+
}
|
|
650
|
+
else if (colour === 0 || colour === 4) {
|
|
651
|
+
const grey = colour === 0 ? sample(row, x) : sample(row, x * 2);
|
|
652
|
+
r = g = b = scale(grey);
|
|
653
|
+
a = colour === 4 ? scale(sample(row, x * 2 + 1)) : 255;
|
|
654
|
+
if (colour === 0 && alphas && alphas.length >= 2) {
|
|
655
|
+
// The one grey that is see-through, as the file writes it: two
|
|
656
|
+
// bytes, of which a 16-bit sample keeps the high one here.
|
|
657
|
+
const key = depth === 16
|
|
658
|
+
? alphas[0]
|
|
659
|
+
: ((alphas[0] << 8) | alphas[1]) & ((1 << depth) - 1);
|
|
660
|
+
if (grey === key)
|
|
661
|
+
a = 0;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
else {
|
|
665
|
+
const base = x * channels;
|
|
666
|
+
[r, g, b] = [sample(row, base), sample(row, base + 1), sample(row, base + 2)];
|
|
667
|
+
a = colour === 6 ? sample(row, base + 3) : 255;
|
|
668
|
+
if (colour === 2 &&
|
|
669
|
+
alphas &&
|
|
670
|
+
alphas.length >= 6 &&
|
|
671
|
+
depth === 8 &&
|
|
672
|
+
r === alphas[1] &&
|
|
673
|
+
g === alphas[3] &&
|
|
674
|
+
b === alphas[5])
|
|
675
|
+
a = 0;
|
|
676
|
+
}
|
|
677
|
+
rgba.set([r, g, b, a], out);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
return { rgba, width, height };
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* `node:zlib`'s inflate, asked for when a PNG is read rather than imported
|
|
684
|
+
* at the top: this file is bundled into screens with `@brydio/manifest`, and
|
|
685
|
+
* a worker has no zlib to import. Where there is none, a PNG is unreadable.
|
|
686
|
+
*/
|
|
687
|
+
function inflate(data) {
|
|
688
|
+
const zlib = globalThis.process?.getBuiltinModule?.('node:zlib');
|
|
689
|
+
if (!zlib)
|
|
690
|
+
throw new Error('There is no zlib here to read a PNG with.');
|
|
691
|
+
return new Uint8Array(zlib.inflateSync(data));
|
|
692
|
+
}
|