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

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/src/index.mjs CHANGED
@@ -18,6 +18,7 @@ export {
18
18
  renderProjection,
19
19
  } from "./render.mjs";
20
20
  export { renderAppProjection } from "./render-shared.mjs";
21
+ export { inspectProjection } from "./layout-inspection.mjs";
21
22
  export {
22
23
  decodeFigmaClipboard,
23
24
  importDraft,
@@ -0,0 +1,124 @@
1
+ import { canonicalJSON, createSemanticTree } from "@smallpen/core";
2
+ import { createHash } from "node:crypto";
3
+ import { renderAppProjection } from "./render-shared.mjs";
4
+
5
+ const hash = (value) =>
6
+ createHash("sha256").update(canonicalJSON(value)).digest("hex");
7
+ const box = (value) =>
8
+ value &&
9
+ ["x", "y", "width", "height"].every((key) => Number.isFinite(value[key]))
10
+ ? Object.fromEntries(
11
+ ["x", "y", "width", "height"].map((key) => [key, value[key]]),
12
+ )
13
+ : null;
14
+
15
+ // Specifications, diagnostics, wireframes and raster evidence consume this
16
+ // result. Authored constraints stay in projection; only native results are
17
+ // allowed to supply actual geometry. In particular, missing measurements
18
+ // must never silently fall back to the projection's seed rectangles.
19
+ export async function inspectProjection(product, projection, options = {}) {
20
+ const root = projection.nodes[projection.rootId];
21
+ let render, failure;
22
+ try {
23
+ render = await renderAppProjection(product, projection, {
24
+ ...options,
25
+ cropNodeId: options.crop
26
+ ? undefined
27
+ : (options.nodeId ?? projection.rootId),
28
+ measureOnly: !options.raster,
29
+ });
30
+ } catch (error) {
31
+ if (
32
+ options.raster ||
33
+ ![
34
+ "shared_renderer_unavailable",
35
+ "shared_render_failed",
36
+ "layout_not_settled",
37
+ "font_resource_unavailable",
38
+ "preview_fallback_not_renderable",
39
+ ].includes(error.code)
40
+ )
41
+ throw error;
42
+ failure = { code: error.code, message: error.message };
43
+ }
44
+ const origin = {
45
+ x: (root?.x ?? 0) + (options.crop?.x ?? 0),
46
+ y: (root?.y ?? 0) + (options.crop?.y ?? 0),
47
+ };
48
+ const translate = (value) => {
49
+ const actual = box(value);
50
+ return actual
51
+ ? { ...actual, x: actual.x + origin.x, y: actual.y + origin.y }
52
+ : null;
53
+ };
54
+ const tree = createSemanticTree(product, projection, {
55
+ selection: options.selection,
56
+ });
57
+ const ids = [];
58
+ const collect = (node) => {
59
+ ids.push(node.id);
60
+ node.children.forEach(collect);
61
+ };
62
+ collect(tree.root);
63
+ const nodes = Object.fromEntries(
64
+ ids.map((id) => {
65
+ const bounds = translate(render?.bounds[id]);
66
+ return [
67
+ id,
68
+ {
69
+ status: bounds ? "resolved" : "partial",
70
+ bounds,
71
+ layoutBox: translate(render?.layoutBoxes[id]),
72
+ ...(bounds
73
+ ? {}
74
+ : { reason: failure?.code ?? "native_bounds_unavailable" }),
75
+ },
76
+ ];
77
+ }),
78
+ );
79
+ const update = (node, parentVisible = true) => {
80
+ node.bounds = nodes[node.id]?.bounds ?? null;
81
+ node.visible = parentVisible && node.visible;
82
+ nodes[node.id].visible = node.visible;
83
+ for (const child of node.children) update(child, node.visible);
84
+ };
85
+ update(tree.root);
86
+ const diagnostics = failure
87
+ ? [failure]
88
+ : (render?.diagnostics ?? []).filter(
89
+ (item) => item.code !== "layout_projection_partial",
90
+ );
91
+ const snapshot = {
92
+ status: Object.values(nodes).every((node) => node.status === "resolved")
93
+ ? "resolved"
94
+ : "partial",
95
+ renderer: render?.renderer ?? "app-wasm",
96
+ kernelHash: render?.kernelHash ?? null,
97
+ runtimeHash: render?.runtimeHash ?? null,
98
+ resourcesHash: render?.resourcesHash ?? null,
99
+ revision: product.revision,
100
+ ...(options.foundation
101
+ ? { foundationRevision: options.foundation.revision }
102
+ : {}),
103
+ libraries: (options.libraries ?? []).map((library) => ({
104
+ packageId: library.manifest.packageId,
105
+ revision: library.revision,
106
+ })),
107
+ selection: options.selection ?? {},
108
+ coordinateSpace: "page",
109
+ viewport: options.crop
110
+ ? { ...origin, width: options.crop.width, height: options.crop.height }
111
+ : (nodes[options.nodeId ?? projection.rootId]?.bounds ?? null),
112
+ nodes,
113
+ diagnostics,
114
+ };
115
+ snapshot.id = hash({
116
+ ...snapshot,
117
+ // Two theme/viewport/context selections must not share an identity merely
118
+ // because they happen to paint the same pixels.
119
+ packageId: product.manifest.packageId,
120
+ projection,
121
+ });
122
+ snapshot.hash = snapshot.id;
123
+ return { snapshot, tree, render };
124
+ }
@@ -15,7 +15,7 @@ async function loadRenderer() {
15
15
  const manifest = JSON.parse(
16
16
  await readFile(join(runtime, "manifest.json"), "utf8"),
17
17
  );
18
- const [renderer, factory, wasmBytes] = await Promise.all([
18
+ const [renderer, factory, wasmBytes, rendererBytes, wrapperBytes] = await Promise.all([
19
19
  import(
20
20
  pathToFileURL(join(runtime, "renderer.mjs")).href +
21
21
  "?version=" +
@@ -27,10 +27,14 @@ async function loadRenderer() {
27
27
  manifest.wrapperHash
28
28
  ),
29
29
  readFile(join(runtime, "render-wasm.wasm")),
30
+ readFile(join(runtime, "renderer.mjs")),
31
+ readFile(join(runtime, "render-wasm.js")),
30
32
  ]);
31
33
  if (
32
34
  createHash("sha256").update(wasmBytes).digest("hex") !==
33
- manifest.kernelHash
35
+ manifest.kernelHash ||
36
+ createHash("sha256").update(rendererBytes).digest("hex") !== manifest.rendererHash ||
37
+ createHash("sha256").update(wrapperBytes).digest("hex") !== manifest.wrapperHash
34
38
  )
35
39
  throw new Error(
36
40
  "Rendering runtime changed during initialization; retry after the build finishes",
@@ -60,6 +64,7 @@ async function loadRenderer() {
60
64
  fonts,
61
65
  families,
62
66
  kernelHash: createHash("sha256").update(wasmBytes).digest("hex"),
67
+ runtimeHash: createHash("sha256").update(rendererBytes).update(wrapperBytes).digest("hex"),
63
68
  };
64
69
  } catch (cause) {
65
70
  throw new SmallPenError(
@@ -85,6 +90,7 @@ async function resources(engine, product, options) {
85
90
  const fontId = `custom-${snapshot.runtime.fonts[family.id]}`;
86
91
  fontFamilies.push({
87
92
  fontId,
93
+ names: [family.family],
88
94
  variants: family.variants.map((variant) => ({
89
95
  id: `${variant.style}-${variant.weight}`,
90
96
  weight: String(variant.weight),
@@ -151,9 +157,8 @@ export async function renderAppProjection(product, projection, options = {}) {
151
157
  !Number.isFinite(height) ||
152
158
  width < 1 ||
153
159
  height < 1 ||
154
- width > 8192 ||
155
- height > 8192 ||
156
- width * height > 32 * 1024 * 1024
160
+ (!options.measureOnly && !options.cropNodeId &&
161
+ (width > 8192 || height > 8192 || width * height > 32 * 1024 * 1024))
157
162
  )
158
163
  throw new SmallPenError(
159
164
  "render_size_exceeded",
@@ -223,6 +228,7 @@ export async function renderAppProjection(product, projection, options = {}) {
223
228
  nodes,
224
229
  nodeIds,
225
230
  rootId,
231
+ cropNodeId: options.cropNodeId ? nodeIds[options.cropNodeId] : undefined,
226
232
  scale,
227
233
  measureOnly: options.measureOnly === true,
228
234
  forExport: options.forExport === true,
@@ -323,10 +329,27 @@ export async function renderAppProjection(product, projection, options = {}) {
323
329
  ],
324
330
  renderer: "app-wasm",
325
331
  kernelHash: engine.kernelHash,
332
+ runtimeHash: engine.runtimeHash,
333
+ resourcesHash: createHash("sha256").update(JSON.stringify({
334
+ fonts: fontResources.fonts.map(face => ({
335
+ id: face.fontId, weight: face.weight, style: face.style,
336
+ hash: createHash("sha256").update(face.bytes).digest("hex"),
337
+ })).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
338
+ images: request.images.map(image => ({
339
+ id: image.id,
340
+ hash: createHash("sha256").update(image.svg ?? image.bytes).digest("hex"),
341
+ })).sort((a, b) => a.id.localeCompare(b.id)),
342
+ })).digest("hex"),
326
343
  renderHash: createHash("sha256").update(bytes).digest("hex"),
327
344
  geometry: result.geometry,
345
+ layoutBoxes: Object.fromEntries(Object.entries(result.geometry).map(([uuid, value]) => [sourceIds[uuid], value])),
328
346
  bounds,
329
347
  textMetrics,
348
+ fontRequests: described.fonts.filter(face => sourceIds[face.nodeId]).map(face => {
349
+ const names = assets.fontFamilies.find(family => family.fontId === face.fontId)?.names;
350
+ return { nodeId: sourceIds[face.nodeId], family: names?.[1] ?? names?.[0] ?? face.fontId,
351
+ fontId: face.fontId, weight: face.weight, style: face.style };
352
+ }),
330
353
  };
331
354
  } catch (error) {
332
355
  if (error instanceof SmallPenError) throw error;
package/src/render.mjs CHANGED
@@ -1,10 +1,10 @@
1
1
  import {
2
- createSemanticTree,
3
2
  projectDesignView,
4
3
  resolveDesignView,
5
4
  sha256Hex,
6
5
  } from "@smallpen/core";
7
6
  import { renderAppProjection } from "./render-shared.mjs";
7
+ import { inspectProjection } from "./layout-inspection.mjs";
8
8
 
9
9
  // Rendering and measurement both use the App projector and Skia/WASM runtime.
10
10
  export const renderProjection = renderAppProjection;
@@ -38,13 +38,6 @@ export async function measureProjectionText(product, projection, options = {}) {
38
38
  return { items: result.textMetrics, diagnostics: result.diagnostics };
39
39
  }
40
40
 
41
- function updateBounds(node, bounds, origin) {
42
- const actual = bounds[node.id];
43
- if (actual)
44
- node.bounds = { ...actual, x: actual.x + origin.x, y: actual.y + origin.y };
45
- for (const child of node.children) updateBounds(child, bounds, origin);
46
- }
47
-
48
41
  function flattenRegions(node, scale, width, height, origin, result = []) {
49
42
  const left = Math.max(0, Math.min(width, (node.bounds.x - origin.x) * scale));
50
43
  const top = Math.max(0, Math.min(height, (node.bounds.y - origin.y) * scale));
@@ -71,23 +64,19 @@ function flattenRegions(node, scale, width, height, origin, result = []) {
71
64
  export async function createEvidence(product, options = {}) {
72
65
  const resolved = resolveDesignView(product, options);
73
66
  const projection = projectDesignView(product, resolved, options);
74
- const render = await renderProjection(product, projection, options);
75
- const semanticTree = createSemanticTree(product, projection, {
76
- foundationRevision: options.foundation?.revision,
77
- scenarioId: resolved.scenarioId,
78
- selection: resolved.selection,
67
+ const inspection = await inspectProjection(product, projection, {
68
+ ...options, raster: true, selection: resolved.selection,
79
69
  });
80
- const root = projection.nodes[projection.rootId];
81
- const origin = {
82
- x: root.x + (options.crop?.x ?? 0),
83
- y: root.y + (options.crop?.y ?? 0),
84
- };
85
- updateBounds(semanticTree.root, render.bounds, origin);
70
+ const render = { ...inspection.render, diagnostics: inspection.snapshot.diagnostics };
71
+ const semanticTree = inspection.tree;
72
+ const origin = inspection.snapshot.viewport;
86
73
  return {
87
74
  projection,
88
75
  render,
89
76
  evidence: {
90
77
  diagnostics: render.diagnostics,
78
+ layout: inspection.snapshot,
79
+ snapshotId: inspection.snapshot.id,
91
80
  nodeRegions: flattenRegions(
92
81
  semanticTree.root,
93
82
  options.scale ?? 1,