@smallpen/local-package 0.1.0-alpha.1 → 0.1.0-alpha.11

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.
Files changed (37) hide show
  1. package/assets/fonts/SourceSansPro-OFL.txt +46 -0
  2. package/assets/fonts/WorkSans-OFL.txt +46 -0
  3. package/package.json +4 -4
  4. package/runtime/fonts/OFL.txt +46 -0
  5. package/runtime/fonts/sourcesanspro-black.ttf +0 -0
  6. package/runtime/fonts/sourcesanspro-blackitalic.ttf +0 -0
  7. package/runtime/fonts/sourcesanspro-bold.ttf +0 -0
  8. package/runtime/fonts/sourcesanspro-bolditalic.ttf +0 -0
  9. package/runtime/fonts/sourcesanspro-extralight.ttf +0 -0
  10. package/runtime/fonts/sourcesanspro-extralightitalic.ttf +0 -0
  11. package/runtime/fonts/sourcesanspro-italic.ttf +0 -0
  12. package/runtime/fonts/sourcesanspro-light.ttf +0 -0
  13. package/runtime/fonts/sourcesanspro-lightitalic.ttf +0 -0
  14. package/runtime/fonts/sourcesanspro-regular.ttf +0 -0
  15. package/runtime/fonts/sourcesanspro-semibold.ttf +0 -0
  16. package/runtime/fonts/sourcesanspro-semibolditalic.ttf +0 -0
  17. package/runtime/manifest.json +7 -0
  18. package/runtime/render-wasm.js +15 -0
  19. package/runtime/render-wasm.wasm +0 -0
  20. package/runtime/renderer.mjs +169652 -0
  21. package/src/draft-import.mjs +1 -11
  22. package/src/font-convert.mjs +23 -1
  23. package/src/fs-utils.mjs +95 -0
  24. package/src/index.mjs +19 -3
  25. package/src/initialize.mjs +363 -136
  26. package/src/kiwi-runtime.mjs +38 -36
  27. package/src/library-network.mjs +189 -0
  28. package/src/local-package-backend.mjs +215 -24
  29. package/src/local-package.mjs +589 -177
  30. package/src/local-workspace-session.mjs +278 -96
  31. package/src/migrate-themes.mjs +116 -0
  32. package/src/remote-library.mjs +167 -28
  33. package/src/render-fonts.mjs +172 -0
  34. package/src/render-shared.mjs +346 -0
  35. package/src/render.mjs +71 -3557
  36. package/src/workspace.mjs +178 -17
  37. package/src/webp-decode.mjs +0 -73
@@ -0,0 +1,346 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { createHash } from "node:crypto";
3
+ import { join, dirname } from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+ import { provisionFonts } from "./render-fonts.mjs";
6
+ import { inspectMedia } from "./media-inspect.mjs";
7
+ import { SmallPenError, stableRuntimeUuid } from "@smallpen/core";
8
+
9
+ const runtime = join(dirname(fileURLToPath(import.meta.url)), "..", "runtime");
10
+ let initialized;
11
+ let pending = Promise.resolve();
12
+
13
+ async function loadRenderer() {
14
+ try {
15
+ const manifest = JSON.parse(
16
+ await readFile(join(runtime, "manifest.json"), "utf8"),
17
+ );
18
+ const [renderer, factory, wasmBytes] = await Promise.all([
19
+ import(
20
+ pathToFileURL(join(runtime, "renderer.mjs")).href +
21
+ "?version=" +
22
+ manifest.rendererHash
23
+ ),
24
+ import(
25
+ pathToFileURL(join(runtime, "render-wasm.js")).href +
26
+ "?version=" +
27
+ manifest.wrapperHash
28
+ ),
29
+ readFile(join(runtime, "render-wasm.wasm")),
30
+ ]);
31
+ if (
32
+ createHash("sha256").update(wasmBytes).digest("hex") !==
33
+ manifest.kernelHash
34
+ )
35
+ throw new Error(
36
+ "Rendering runtime changed during initialization; retry after the build finishes",
37
+ );
38
+ const module = await factory.default({
39
+ wasmBinary: wasmBytes,
40
+ noInitialRun: true,
41
+ });
42
+ renderer.initialize(module);
43
+ const fonts = [];
44
+ const families = renderer.builtinFonts().map((family) => ({
45
+ fontId: family.id,
46
+ names: [family.id, family.name, family.family],
47
+ variants: family.variants,
48
+ }));
49
+ for (const family of families)
50
+ for (const variant of family.variants)
51
+ fonts.push({
52
+ fontId: family.fontId,
53
+ weight: Number(variant.weight),
54
+ style: variant.style,
55
+ names: family.names,
56
+ bytes: await readFile(join(runtime, "fonts", variant["ttf-url"])),
57
+ });
58
+ return {
59
+ renderer,
60
+ fonts,
61
+ families,
62
+ kernelHash: createHash("sha256").update(wasmBytes).digest("hex"),
63
+ };
64
+ } catch (cause) {
65
+ throw new SmallPenError(
66
+ "shared_renderer_unavailable",
67
+ "The shared App rendering runtime is unavailable; build the SmallPen renderer first",
68
+ { cause: cause.message },
69
+ );
70
+ }
71
+ }
72
+
73
+ async function resources(engine, product, options) {
74
+ const fonts = [...engine.fonts],
75
+ fontFamilies = [...engine.families],
76
+ images = [];
77
+ for (const snapshot of [
78
+ product,
79
+ options.foundation,
80
+ ...(options.libraries ?? []),
81
+ ].filter(Boolean)) {
82
+ for (const entry of snapshot.manifest.entries.assets) {
83
+ const library = snapshot.entries[entry];
84
+ for (const family of library.fonts) {
85
+ const fontId = `custom-${snapshot.runtime.fonts[family.id]}`;
86
+ fontFamilies.push({
87
+ fontId,
88
+ variants: family.variants.map((variant) => ({
89
+ id: `${variant.style}-${variant.weight}`,
90
+ weight: String(variant.weight),
91
+ style: variant.style,
92
+ })),
93
+ });
94
+ for (const variant of family.variants) {
95
+ const file = variant.files.ttf ?? variant.files.otf;
96
+ if (file)
97
+ fonts.push({
98
+ fontId,
99
+ weight: variant.weight,
100
+ style: variant.style,
101
+ bytes: snapshot.blobs.get(file.blob),
102
+ });
103
+ }
104
+ }
105
+ for (const media of library.media)
106
+ images.push({
107
+ id: snapshot.runtime.media[media.id],
108
+ bytes: snapshot.blobs.get(media.blob),
109
+ mimeType: media.mimeType,
110
+ width: media.width,
111
+ height: media.height,
112
+ });
113
+ }
114
+ }
115
+ return { fonts, fontFamilies, images };
116
+ }
117
+
118
+ export async function renderAppProjection(product, projection, options = {}) {
119
+ if (projection.fallbackUsed)
120
+ throw new SmallPenError(
121
+ "preview_fallback_not_renderable",
122
+ "Preview fallback cannot be used for strict rendering",
123
+ );
124
+ const root = projection.nodes[projection.rootId];
125
+ if (!root)
126
+ throw new SmallPenError(
127
+ "missing_root_node",
128
+ "Projection root is unavailable",
129
+ );
130
+ const scale = options.scale ?? 1;
131
+ if (!Number.isFinite(scale) || scale <= 0 || scale > 8)
132
+ throw new SmallPenError(
133
+ "invalid_render_scale",
134
+ "Render scale must be within (0, 8]",
135
+ );
136
+ const crop = options.crop;
137
+ if (
138
+ crop &&
139
+ (![crop.x, crop.y, crop.width, crop.height].every(Number.isFinite) ||
140
+ crop.width <= 0 ||
141
+ crop.height <= 0)
142
+ )
143
+ throw new SmallPenError(
144
+ "invalid_render_crop",
145
+ "Crop must have finite x/y and positive width/height",
146
+ );
147
+ const width = Math.ceil((crop?.width ?? root.width) * scale),
148
+ height = Math.ceil((crop?.height ?? root.height) * scale);
149
+ if (
150
+ !Number.isFinite(width) ||
151
+ !Number.isFinite(height) ||
152
+ width < 1 ||
153
+ height < 1 ||
154
+ width > 8192 ||
155
+ height > 8192 ||
156
+ width * height > 32 * 1024 * 1024
157
+ )
158
+ throw new SmallPenError(
159
+ "render_size_exceeded",
160
+ "Rendered image exceeds safety limits",
161
+ );
162
+ const perform = async () => {
163
+ const engine = await (initialized ??= loadRenderer().catch((error) => {
164
+ initialized = undefined;
165
+ throw error;
166
+ }));
167
+ try {
168
+ const assets = await resources(engine, product, options);
169
+ let nodes = projection.nodes,
170
+ rootId = projection.rootId;
171
+ {
172
+ const crop = options.crop ?? {
173
+ x: 0,
174
+ y: 0,
175
+ width: root.width,
176
+ height: root.height,
177
+ };
178
+ let cropId = "__smallpen_crop__";
179
+ while (Object.hasOwn(nodes, cropId)) cropId += "_";
180
+ nodes = {
181
+ ...nodes,
182
+ [rootId]: { ...root, x: -crop.x, y: -crop.y },
183
+ [cropId]: {
184
+ id: cropId,
185
+ name: "Render crop",
186
+ type: "COMPONENT",
187
+ x: 0,
188
+ y: 0,
189
+ width: crop.width,
190
+ height: crop.height,
191
+ children: [rootId],
192
+ fills: [],
193
+ "show-content": false,
194
+ },
195
+ };
196
+ rootId = cropId;
197
+ }
198
+ const nodeIds = Object.fromEntries(
199
+ await Promise.all(
200
+ Object.keys(nodes).map(async (id) => [
201
+ id,
202
+ await stableRuntimeUuid(
203
+ product.manifest.packageId,
204
+ "render-node",
205
+ id,
206
+ ),
207
+ ]),
208
+ ),
209
+ );
210
+ const request = {
211
+ snapshot: {
212
+ manifest: product.manifest,
213
+ entries: product.entries,
214
+ runtime: product.runtime,
215
+ libraries: [options.foundation, ...(options.libraries ?? [])]
216
+ .filter(Boolean)
217
+ .map((snapshot) => ({
218
+ manifest: snapshot.manifest,
219
+ entries: snapshot.entries,
220
+ runtime: snapshot.runtime,
221
+ })),
222
+ },
223
+ nodes,
224
+ nodeIds,
225
+ rootId,
226
+ scale,
227
+ measureOnly: options.measureOnly === true,
228
+ forExport: options.forExport === true,
229
+ fontFamilies: assets.fontFamilies,
230
+ };
231
+ const sourceIds = Object.fromEntries(
232
+ Object.entries(nodeIds).map(([id, uuid]) => [uuid, id]),
233
+ );
234
+ const described = engine.renderer.describe(request);
235
+ const fontResources = await provisionFonts(
236
+ described.fonts,
237
+ assets.fonts,
238
+ sourceIds,
239
+ );
240
+ request.fonts = fontResources.fonts;
241
+ request.images = assets.images
242
+ .filter((image) => described.images.includes(image.id))
243
+ .map((image) => {
244
+ if (!image.bytes)
245
+ throw new SmallPenError(
246
+ "media_resource_unavailable",
247
+ "A referenced media blob is unavailable",
248
+ { id: image.id },
249
+ );
250
+ const actual = inspectMedia(image.bytes, image.mimeType);
251
+ if (
252
+ actual.width > 8192 ||
253
+ actual.height > 8192 ||
254
+ actual.width * actual.height > 32 * 1024 * 1024
255
+ )
256
+ throw new SmallPenError(
257
+ "media_size_exceeded",
258
+ "Image exceeds the renderer limits",
259
+ { id: image.id },
260
+ );
261
+ if (image.mimeType === "image/svg+xml") {
262
+ if (
263
+ !Number.isFinite(image.width) ||
264
+ !Number.isFinite(image.height) ||
265
+ image.width < 1 ||
266
+ image.height < 1 ||
267
+ image.width > 8192 ||
268
+ image.height > 8192 ||
269
+ image.width * image.height > 32 * 1024 * 1024
270
+ )
271
+ throw new SmallPenError(
272
+ "media_size_exceeded",
273
+ "SVG image exceeds the rendering resource limit",
274
+ );
275
+ // Skia parses and draws the SVG; this only supplies its intrinsic size.
276
+ image.svg = Buffer.from(image.bytes)
277
+ .toString("utf8")
278
+ .replace(
279
+ /<svg\b([^>]*)>/i,
280
+ (_match, attrs) =>
281
+ "<svg" +
282
+ attrs.replace(
283
+ /\s(?:width|height)\s*=\s*(?:"[^"]*"|'[^']*')/gi,
284
+ "",
285
+ ) +
286
+ ' width="' +
287
+ image.width +
288
+ '" height="' +
289
+ image.height +
290
+ '">',
291
+ );
292
+ }
293
+ return image;
294
+ });
295
+ const result = engine.renderer.render(request);
296
+ const bytes = result.bytes ?? new Uint8Array();
297
+ const buffer = Buffer.from(bytes);
298
+ const bounds = Object.fromEntries(
299
+ Object.entries(result.bounds).map(([uuid, box]) => [
300
+ sourceIds[uuid],
301
+ box,
302
+ ]),
303
+ );
304
+ const textMetrics = Object.entries(result.textMetrics).map(
305
+ ([uuid, metrics]) => ({
306
+ nodeId: sourceIds[uuid],
307
+ width: metrics["max-width"],
308
+ height: metrics.height,
309
+ growType: projection.nodes[sourceIds[uuid]]?.growType ?? "fixed",
310
+ missingGlyphs: [
311
+ ...(fontResources.missingGlyphs.get(sourceIds[uuid]) ?? []),
312
+ ],
313
+ }),
314
+ );
315
+ return {
316
+ bytes,
317
+ width: bytes.length ? buffer.readUInt32BE(16) : width,
318
+ height: bytes.length ? buffer.readUInt32BE(20) : height,
319
+ mimeType: "image/png",
320
+ diagnostics: [
321
+ ...(options.measureOnly ? [] : (projection.diagnostics ?? [])),
322
+ ...fontResources.diagnostics,
323
+ ],
324
+ renderer: "app-wasm",
325
+ kernelHash: engine.kernelHash,
326
+ renderHash: createHash("sha256").update(bytes).digest("hex"),
327
+ geometry: result.geometry,
328
+ bounds,
329
+ textMetrics,
330
+ };
331
+ } catch (error) {
332
+ if (error instanceof SmallPenError) throw error;
333
+ // A native panic invalidates the module. A later request can reload it.
334
+ if (error instanceof WebAssembly.RuntimeError) initialized = undefined;
335
+ const details = engine.renderer.errorDetails(error) ?? {};
336
+ throw new SmallPenError(
337
+ details.code?.replaceAll("-", "_") ?? "shared_render_failed",
338
+ error.message ?? "Shared App renderer failed",
339
+ details,
340
+ );
341
+ }
342
+ };
343
+ const result = pending.then(perform);
344
+ pending = result.catch(() => {});
345
+ return result;
346
+ }