buninu-browser 0.2.3 → 0.3.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.
Files changed (116) hide show
  1. package/README.md +86 -1
  2. package/lib/cdp-server.js +47 -14
  3. package/lib/clipboard.js +22 -0
  4. package/lib/happy-dom/document-base.js +33 -0
  5. package/lib/happy-dom/parser.js +15 -6
  6. package/lib/happy-dom/selector-matcher.js +2 -2
  7. package/lib/happy-dom/selector-pseudo-adapter.js +19 -0
  8. package/lib/happy-dom/svg-attribute-adapter.js +50 -0
  9. package/lib/headless-shell.js +40 -11
  10. package/lib/input/hit-test.js +67 -19
  11. package/lib/layout/text-layout.js +213 -91
  12. package/lib/media/byte-pipe.js +2 -2
  13. package/lib/media/position-audio.js +10 -1
  14. package/lib/media/source-snapshots.js +126 -0
  15. package/lib/media/video-keyframe.js +17 -4
  16. package/lib/media/webm-stream.js +106 -0
  17. package/lib/network/network-service.js +268 -120
  18. package/lib/paint/display-list.js +8 -28
  19. package/lib/paint/frame-cache.js +25 -0
  20. package/lib/paint/frame.js +77 -16
  21. package/lib/paint/paragraph-text.js +24 -0
  22. package/lib/paint/raster-readback.js +41 -0
  23. package/lib/paint/raw-frames.js +47 -0
  24. package/lib/paint/rounded-rect.js +82 -0
  25. package/lib/paint/shared-frame-pool.js +105 -0
  26. package/lib/render-tree/index.js +38 -6
  27. package/lib/render-tree/svg.js +15 -7
  28. package/lib/renderer/bindings.js +233 -80
  29. package/lib/renderer/broadcast-channel.js +76 -0
  30. package/lib/renderer/canvas-2d.js +257 -14
  31. package/lib/renderer/canvas-text.js +120 -0
  32. package/lib/renderer/compositor-channel.js +103 -0
  33. package/lib/renderer/compositor.js +34 -40
  34. package/lib/renderer/custom-element-reactions.js +25 -1
  35. package/lib/renderer/flags.js +1 -1
  36. package/lib/renderer/font-loading.js +227 -0
  37. package/lib/renderer/host.js +133 -98
  38. package/lib/renderer/image-resources.js +65 -0
  39. package/lib/renderer/interfaces.js +28 -0
  40. package/lib/renderer/layout-observers.js +236 -0
  41. package/lib/renderer/main.js +52 -5
  42. package/lib/renderer/media-source.js +126 -55
  43. package/lib/renderer/page-realm.js +287 -99
  44. package/lib/renderer/page-renderer.js +663 -239
  45. package/lib/renderer/prepared-script-cache.js +50 -0
  46. package/lib/renderer/rendered-text.js +108 -0
  47. package/lib/renderer/rewrite-worker.js +4 -3
  48. package/lib/renderer/script-rewrite.js +13 -2
  49. package/lib/renderer/scroll-into-view.js +28 -0
  50. package/lib/renderer/slot-assignment.js +60 -0
  51. package/lib/renderer/video-presentation.js +23 -4
  52. package/lib/renderer/workers.js +2 -2
  53. package/lib/style/computed-style.js +611 -104
  54. package/lib/style/selector-traits.js +49 -5
  55. package/package.json +1 -1
  56. package/test/broadcast-channel.test.js +26 -0
  57. package/test/canvas-2d.test.js +83 -2
  58. package/test/canvas-paint.test.js +9 -0
  59. package/test/canvas-text.test.js +41 -0
  60. package/test/cdp-stream.test.js +56 -0
  61. package/test/clipboard.test.js +35 -0
  62. package/test/computed-style-binding.test.js +151 -0
  63. package/test/computed-style.test.js +258 -1
  64. package/test/display-list.test.js +12 -0
  65. package/test/document-base.test.js +87 -0
  66. package/test/dom-platform.test.js +461 -0
  67. package/test/fetch-lifecycle.test.js +161 -0
  68. package/test/fixtures/browser-raw-demo.html +36 -0
  69. package/test/fixtures/canvas-paint.js +117 -0
  70. package/test/fixtures/canvas-text.js +42 -0
  71. package/test/fixtures/image-lifetime.js +90 -0
  72. package/test/fixtures/media-seek-snapshot.js +77 -0
  73. package/test/fixtures/mse-audio-chirp.webm +0 -0
  74. package/test/fixtures/mse-switch-large.mp4 +0 -0
  75. package/test/fixtures/mse-switch-small.mp4 +0 -0
  76. package/test/fixtures/raw-capture-benchmark.json +76 -0
  77. package/test/fixtures/renderer-memory.js +110 -0
  78. package/test/fixtures/summary-style.js +28 -0
  79. package/test/font-loading.test.js +97 -0
  80. package/test/grid.test.js +58 -0
  81. package/test/hit-test.test.js +21 -1
  82. package/test/image-lifetime.test.js +12 -0
  83. package/test/image-resources.test.js +94 -0
  84. package/test/local-media.test.js +84 -0
  85. package/test/media-seek-snapshot.test.js +11 -0
  86. package/test/media-source-config.test.js +98 -0
  87. package/test/media-source-webm-accounting.test.js +98 -0
  88. package/test/media-source-webm.test.js +108 -0
  89. package/test/navigation.test.js +1 -0
  90. package/test/network-service.test.js +388 -0
  91. package/test/page-scripts.test.js +1 -1
  92. package/test/paint-frame.test.js +103 -0
  93. package/test/paint-paragraph-cache.test.js +87 -0
  94. package/test/position-audio.test.js +24 -0
  95. package/test/positioning.test.js +22 -0
  96. package/test/prepared-script-cache.test.js +45 -0
  97. package/test/pulse-client.test.js +10 -3
  98. package/test/raster-readback.test.js +40 -0
  99. package/test/raw-capture.test.js +152 -0
  100. package/test/render-tree.test.js +28 -0
  101. package/test/renderer-memory.test.js +11 -0
  102. package/test/selector-traits.test.js +24 -0
  103. package/test/shared-frame-pool.test.js +63 -0
  104. package/test/style-sheets.test.js +51 -0
  105. package/test/svg.test.js +29 -0
  106. package/test/text-layout.test.js +106 -0
  107. package/test/video-keyframe.test.js +61 -0
  108. package/test/video-presentation.test.js +69 -0
  109. package/test/window-resize.test.js +36 -0
  110. package/tools/bench-raw-capture.js +32 -0
  111. package/tools/browser-lab.js +78 -0
  112. package/tools/browser-soak.js +75 -0
  113. package/tools/network-reference.js +49 -0
  114. package/tools/process-memory.js +38 -0
  115. package/tools/script-benchmark.js +25 -0
  116. package/ai-debug.log +0 -5537
@@ -0,0 +1,76 @@
1
+ // Bun supplies the structured-clone transport. Names are scoped by browser
2
+ // origin; the native channels and their event objects never reach page code.
3
+ const NativeBroadcastChannel = globalThis.BroadcastChannel;
4
+
5
+ export function createBroadcastChannels({ origin, EventTarget, realm, runTask, DOMException }) {
6
+ // Renderer workers have separate module state but share Bun's channel
7
+ // transport, so a counter would collide for unrelated opaque documents.
8
+ const scope = origin === "null" ? `opaque:${crypto.randomUUID()}` : origin;
9
+ const states = new WeakMap();
10
+ const live = new Set();
11
+ let disposed = false;
12
+ const stateOf = object => {
13
+ const state = states.get(object);
14
+ if (!state) throw new TypeError("Illegal invocation");
15
+ return state;
16
+ };
17
+ function BroadcastChannel(name) {
18
+ if (!new.target) throw new TypeError("Constructor BroadcastChannel requires 'new'");
19
+ if (!arguments.length) throw new TypeError("BroadcastChannel requires a name");
20
+ if (disposed) throw new DOMException("The document is no longer active", "InvalidStateError");
21
+ const self = Reflect.construct(EventTarget, [], new.target);
22
+ const channelName = String(name);
23
+ const native = new NativeBroadcastChannel(JSON.stringify([scope, channelName]));
24
+ const state = { native, name: channelName, closed: false, handlers: new Map() };
25
+ states.set(self, state);
26
+ live.add(state);
27
+ for (const type of ["message", "messageerror"]) {
28
+ native.addEventListener(type, incoming => {
29
+ if (disposed || state.closed) return;
30
+ runTask(() => {
31
+ const event = realm.messageEvent(type, { data: incoming.data, origin });
32
+ realm.markTrusted(event);
33
+ EventTarget.prototype.dispatchEvent.call(self, event);
34
+ });
35
+ });
36
+ }
37
+ return self;
38
+ }
39
+ BroadcastChannel.prototype = Object.create(EventTarget.prototype, {
40
+ constructor: { value: BroadcastChannel, writable: true, configurable: true },
41
+ name: { get() { return stateOf(this).name; }, enumerable: true, configurable: true },
42
+ postMessage: { value(message) {
43
+ const state = stateOf(this);
44
+ if (state.closed) throw new DOMException("The channel is closed", "InvalidStateError");
45
+ if (!arguments.length) throw new TypeError("postMessage requires a message");
46
+ try { state.native.postMessage(message); }
47
+ catch (error) { throw new DOMException(String(error.message ?? error), error.name === "DataCloneError" ? "DataCloneError" : "InvalidStateError"); }
48
+ }, writable: true, enumerable: true, configurable: true },
49
+ close: { value() {
50
+ const state = stateOf(this);
51
+ if (state.closed) return;
52
+ state.closed = true;
53
+ state.native.close();
54
+ live.delete(state);
55
+ }, writable: true, enumerable: true, configurable: true },
56
+ [Symbol.toStringTag]: { value: "BroadcastChannel", configurable: true },
57
+ });
58
+ for (const type of ["message", "messageerror"]) Object.defineProperty(BroadcastChannel.prototype, `on${type}`, {
59
+ get() { return stateOf(this).handlers.get(type) ?? null; },
60
+ set(handler) {
61
+ const state = stateOf(this);
62
+ const previous = state.handlers.get(type);
63
+ if (previous) EventTarget.prototype.removeEventListener.call(this, type, previous);
64
+ state.handlers.delete(type);
65
+ if (typeof handler === "function") {
66
+ state.handlers.set(type, handler);
67
+ EventTarget.prototype.addEventListener.call(this, type, handler);
68
+ }
69
+ }, enumerable: true, configurable: true,
70
+ });
71
+ return { BroadcastChannel, dispose() {
72
+ disposed = true;
73
+ for (const state of live) { state.closed = true; state.native.close(); }
74
+ live.clear();
75
+ } };
76
+ }
@@ -22,9 +22,90 @@ export function setCanvasKit(kit) {
22
22
  }
23
23
 
24
24
  const surfaces = new WeakMap();
25
+ // A WeakMap does not release the malloc-backed CanvasKit surface. Track
26
+ // resources without retaining DOM nodes or context proxies, so either page
27
+ // teardown or collection of an unused canvas can release them.
28
+ const documentSurfaces = new WeakMap();
29
+ const documentChanges = new WeakMap();
30
+ const collectedContexts = new FinalizationRegistry(releaseSurface);
31
+ const DRAW_METHODS = new Set(["clearRect", "drawImage", "fill", "fillRect", "fillText", "stroke", "strokeRect", "strokeText", "putImageData"]);
32
+ const CONTEXT_METHODS = new Set(("arc arcTo beginPath bezierCurveTo clearRect clip closePath createImageData createLinearGradient createPattern createRadialGradient drawFocusIfNeeded drawImage ellipse fill fillRect fillText getImageData getLineDash getTransform isPointInPath isPointInStroke lineTo measureText moveTo putImageData quadraticCurveTo rect reset resetTransform restore rotate roundRect save scale setLineDash setTransform stroke strokeRect strokeText transform translate").split(" "));
33
+ const CONTEXT_PROPERTIES = new Set(("canvas direction fillStyle filter font fontKerning fontStretch fontVariantCaps globalAlpha globalCompositeOperation imageSmoothingEnabled imageSmoothingQuality letterSpacing lineCap lineDashOffset lineJoin lineWidth miterLimit shadowBlur shadowColor shadowOffsetX shadowOffsetY strokeStyle textAlign textBaseline textRendering wordSpacing").split(" "));
34
+ const nativeStyles = new WeakMap();
35
+ const publicStyles = new WeakMap();
36
+ const collectedStyles = new FinalizationRegistry(native => native.ie());
37
+
38
+ // CanvasKit's gradient/pattern shims expose shader handles and disposal
39
+ // methods. Only the standard author-facing methods may cross into a page.
40
+ function publicStyle(native) {
41
+ if (native === null || typeof native !== "object") return native;
42
+ let wrapper = publicStyles.get(native)?.deref();
43
+ if (wrapper) return wrapper;
44
+ const gradient = typeof native.addColorStop === "function";
45
+ const method = gradient ? "addColorStop" : "setTransform";
46
+ wrapper = Object.create(null);
47
+ Object.defineProperties(wrapper, {
48
+ [method]: { value(...args) {
49
+ // WebIDL converts these before entering the native implementation.
50
+ // The shim otherwise stores the original offset object in its stops,
51
+ // which could retain the facade/DOM through finalizer holdings.
52
+ if (gradient) native.addColorStop(Number(args[0]), String(args[1]));
53
+ else native.setTransform(...args);
54
+ }, writable: true, configurable: true, enumerable: true },
55
+ [Symbol.toStringTag]: { value: gradient ? "CanvasGradient" : "CanvasPattern", configurable: true },
56
+ });
57
+ // A gradient reused after its creator's bitmap reset can own a later
58
+ // shader outside that old context's cleanup list. Its facade owns that
59
+ // last shader; use a weak reverse link so finalizer holdings cannot retain
60
+ // the facade through this map. Context style/save state keeps it alive.
61
+ publicStyles.set(native, new WeakRef(wrapper));
62
+ nativeStyles.set(wrapper, native);
63
+ // The current pattern shim may retain its page-supplied image object;
64
+ // do not root that DOM graph in finalizer holdings. Gradients retain only
65
+ // numeric coordinates/colors and their private native shader.
66
+ if (gradient) {
67
+ collectedStyles.unregister(native);
68
+ collectedStyles.register(wrapper, native, native);
69
+ }
70
+ return wrapper;
71
+ }
72
+
73
+ /** Drawing changes paint, without requiring a DOM mutation or full layout. */
74
+ export function observeDocumentCanvases(document, changed) {
75
+ documentChanges.set(document, changed);
76
+ }
77
+
78
+ function changed(state) {
79
+ state.image?.delete();
80
+ state.image = null;
81
+ state.resource = null;
82
+ const node = state.node.deref();
83
+ if (node) documentChanges.get(node.ownerDocument)?.(node);
84
+ }
85
+
86
+ function releaseSurface(state) {
87
+ if (!state.surface) return;
88
+ state.image?.delete();
89
+ state.image = state.resource = null;
90
+ state.surface.dispose();
91
+ state.surface = null;
92
+ state.raster = null;
93
+ state.context = null;
94
+ state.owner?.delete(state);
95
+ collectedContexts.unregister(state);
96
+ }
97
+
98
+ /** Release connected and detached canvases belonging to a discarded document. */
99
+ export function disposeDocumentCanvases(document) {
100
+ documentChanges.delete(document);
101
+ const owned = documentSurfaces.get(document);
102
+ if (!owned) return;
103
+ for (const state of owned) releaseSurface(state);
104
+ documentSurfaces.delete(document);
105
+ }
25
106
 
26
107
  /** The element's 2d context, or null for any other context id or before CanvasKit loads. */
27
- export function canvasContext(node, type) {
108
+ export function canvasContext(node, type, options = null) {
28
109
  if (type !== "2d" || !CanvasKit) return null;
29
110
  let entry = surfaces.get(node);
30
111
  if (!entry) {
@@ -32,6 +113,7 @@ export function canvasContext(node, type) {
32
113
  if (!entry) return null;
33
114
  surfaces.set(node, entry);
34
115
  }
116
+ if (options) entry.configure(options);
35
117
  return entry.proxy;
36
118
  }
37
119
 
@@ -40,6 +122,42 @@ export function resetCanvas(node) {
40
122
  surfaces.get(node)?.reopen();
41
123
  }
42
124
 
125
+ /** A refcounted native snapshot: no PNG/base64/readPixels round trip. */
126
+ export function canvasResource(node) {
127
+ const state = surfaces.get(node)?.state;
128
+ if (!state?.surface || !state.raster) return null;
129
+ if (!state.resource) {
130
+ state.raster.flush();
131
+ state.image = state.raster.makeImageSnapshot();
132
+ if (!state.image) return null;
133
+ state.resource = { image: state.image, width: state.width, height: state.height, originClean: state.originClean };
134
+ }
135
+ return state.resource;
136
+ }
137
+
138
+ /** Encoded bytes are only needed when the page explicitly exports its canvas. */
139
+ export function encodeCanvas(node, requestedType = "image/png", quality) {
140
+ if (!node.width || !node.height) return null;
141
+ canvasContext(node, "2d");
142
+ const state = surfaces.get(node).state;
143
+ if (!state.originClean) throw new DOMException("The canvas contains cross-origin image data.", "SecurityError");
144
+ const type = String(requestedType).toLowerCase();
145
+ const format = type === "image/jpeg" ? CanvasKit.ImageFormat.JPEG
146
+ : type === "image/webp" ? CanvasKit.ImageFormat.WEBP : CanvasKit.ImageFormat.PNG;
147
+ let supportedType = format === CanvasKit.ImageFormat.JPEG ? "image/jpeg"
148
+ : format === CanvasKit.ImageFormat.WEBP ? "image/webp" : "image/png";
149
+ const image = canvasResource(node)?.image;
150
+ const level = typeof quality === "number" && quality >= 0 && quality <= 1 ? Math.round(quality * 100) : 92;
151
+ let bytes = image?.encodeToBytes(format ?? CanvasKit.ImageFormat.PNG, level);
152
+ // CanvasKit builds may omit optional encoders. HTML requires the PNG
153
+ // fallback for unsupported types rather than a mislabeled or empty result.
154
+ if (!bytes && format !== CanvasKit.ImageFormat.PNG) {
155
+ bytes = image?.encodeToBytes(CanvasKit.ImageFormat.PNG, level);
156
+ supportedType = "image/png";
157
+ }
158
+ return bytes ? { bytes, type: supportedType } : null;
159
+ }
160
+
43
161
  // A zero bitmap makes MakeSurface fail and getImageData reject the rectangle.
44
162
  // The element can still report 0; drawing uses one pixel until it grows.
45
163
  function bitmapSize(node) {
@@ -47,32 +165,143 @@ function bitmapSize(node) {
47
165
  }
48
166
 
49
167
  function open(node) {
50
- let surface = makeSurface(node);
168
+ const surface = makeSurface(node);
51
169
  if (!surface) return null;
52
- let context = surface.getContext("2d");
170
+ let owner = null;
171
+ if (node.ownerDocument) {
172
+ owner = documentSurfaces.get(node.ownerDocument);
173
+ if (!owner) documentSurfaces.set(node.ownerDocument, owner = new Set());
174
+ }
175
+ const [width, height] = bitmapSize(node);
176
+ const state = { surface, raster: nativeSurface(surface), context: surface.getContext("2d"), owner,
177
+ node: new WeakRef(node), width, height, image: null, resource: null, originClean: true };
178
+ owner?.add(state);
179
+ const methods = new Map();
180
+ let publicCanvas = null, resolveImage = null, parseFont = null, text = null;
181
+ const textDefaults = () => ({ font: "10px sans-serif", parsedFont: { fontSize: 10, fontFamily: ["sans-serif"], fontWeight: 400, fontStyle: "normal", fontStretch: 100 },
182
+ textAlign: "start", textBaseline: "alphabetic", direction: "inherit" });
183
+ let settings = textDefaults();
184
+ const settingsStack = [];
185
+ let styleValues = { fillStyle: null, strokeStyle: null };
186
+ const styleStack = [];
187
+ const currentContext = () => {
188
+ if (!state.context) throw new DOMException("The canvas document has been discarded.", "InvalidStateError");
189
+ return state.context;
190
+ };
53
191
  const proxy = new Proxy(Object.create(null), {
54
192
  get(_target, property) {
55
- if (property === "getImageData") return (sx, sy, sw, sh) => copyImageData(context.getImageData(sx, sy, sw, sh));
193
+ if (property === "canvas") return publicCanvas;
194
+ if (!CONTEXT_METHODS.has(property) && !CONTEXT_PROPERTIES.has(property)) return undefined;
195
+ const context = currentContext();
196
+ if (text && Object.hasOwn(settings, property) && property !== "parsedFont") return settings[property];
197
+ // The shim's strokeStyle getter assumes every value is a color, even
198
+ // after accepting a gradient. Keep the page's branded style identity.
199
+ if ((property === "fillStyle" || property === "strokeStyle") && styleValues[property]) return styleValues[property];
56
200
  const value = context[property];
57
- return typeof value === "function" ? value.bind(context) : value;
201
+ if (property === "fillStyle" || property === "strokeStyle") return publicStyle(value);
202
+ const custom = property === "getTransform";
203
+ if (typeof value !== "function" && !custom) return value;
204
+ let method = methods.get(property);
205
+ if (!method) {
206
+ // A saved method follows width/height resets too. Binding the proxy
207
+ // keeps its resource alive while a caller retains only the method.
208
+ method = function (...args) {
209
+ const context = currentContext();
210
+ if (property === "getTransform") {
211
+ const matrix = state.raster.getCanvas().getTotalMatrix();
212
+ return { a: matrix[0], b: matrix[3], c: matrix[1], d: matrix[4], e: matrix[2], f: matrix[5], is2D: true };
213
+ }
214
+ if (text && ["fillText", "strokeText", "measureText"].includes(property)) {
215
+ const result = text({ operation: property, args, context, canvas: state.raster.getCanvas(), settings,
216
+ paintStyle: nativeStyles.get(styleValues[property === "strokeText" ? "strokeStyle" : "fillStyle"]),
217
+ });
218
+ if (property !== "measureText") changed(state);
219
+ return result;
220
+ }
221
+ if (property === "getImageData" && !state.originClean) {
222
+ throw new DOMException("The canvas contains cross-origin image data.", "SecurityError");
223
+ }
224
+ let source;
225
+ if (property === "drawImage" && resolveImage) {
226
+ source = resolveImage(args[0]);
227
+ if (!source?.image) return;
228
+ args[0] = source.image;
229
+ }
230
+ const result = context[property](...args);
231
+ if (property === "save") {
232
+ settingsStack.push({ ...settings });
233
+ styleStack.push({ ...styleValues });
234
+ } else if (property === "restore" && settingsStack.length) {
235
+ settings = settingsStack.pop();
236
+ styleValues = styleStack.pop();
237
+ // Native save() copies style shims. The DOM retains the actual
238
+ // gradient/pattern object, including later color-stop changes.
239
+ for (const name of ["fillStyle", "strokeStyle"]) if (styleValues[name]) context[name] = nativeStyles.get(styleValues[name]);
240
+ }
241
+ if (source?.originClean === false) state.originClean = false;
242
+ if (DRAW_METHODS.has(property)) changed(state);
243
+ if (property === "createLinearGradient" || property === "createRadialGradient" || property === "createPattern") return publicStyle(result);
244
+ return property === "getImageData" ? copyImageData(result) : result;
245
+ }.bind(proxy);
246
+ methods.set(property, method);
247
+ }
248
+ return method;
58
249
  },
59
250
  set(_target, property, value) {
60
- context[property] = value;
251
+ if (!CONTEXT_PROPERTIES.has(property) || property === "canvas") return false;
252
+ if (property === "fillStyle" || property === "strokeStyle") {
253
+ const native = value !== null && typeof value === "object" ? nativeStyles.get(value) : undefined;
254
+ // Do not pass arbitrary page objects to the shim's private pe()
255
+ // shader protocol. Only strings or our branded wrappers are styles.
256
+ if (native || typeof value === "string") {
257
+ currentContext()[property] = native ?? value;
258
+ styleValues[property] = native ? value : null;
259
+ }
260
+ return true;
261
+ }
262
+ if (text && property === "font") {
263
+ const parsed = parseFont?.(String(value));
264
+ if (parsed) { settings.font = String(value); settings.parsedFont = parsed; }
265
+ return true;
266
+ }
267
+ if (text && Object.hasOwn(settings, property)) {
268
+ const values = { textAlign: ["start", "end", "left", "right", "center"], textBaseline: ["top", "hanging", "middle", "alphabetic", "ideographic", "bottom"], direction: ["inherit", "ltr", "rtl"] };
269
+ if (values[property]?.includes(String(value))) settings[property] = String(value);
270
+ return true;
271
+ }
272
+ currentContext()[property] = value;
61
273
  return true;
62
274
  },
63
275
  has(_target, property) {
64
- return property in context;
276
+ return property === "canvas" || property === "getTransform" || (CONTEXT_METHODS.has(property) || CONTEXT_PROPERTIES.has(property)) && (property in currentContext() || text && Object.hasOwn(settings, property));
65
277
  },
66
278
  });
279
+ collectedContexts.register(proxy, state, state);
67
280
  return {
281
+ state,
68
282
  proxy,
283
+ configure(options) {
284
+ publicCanvas = options.canvas;
285
+ resolveImage = options.resolveImage;
286
+ parseFont = options.parseFont;
287
+ text = options.text;
288
+ },
69
289
  reopen() {
290
+ if (!state.surface) return;
70
291
  const next = makeSurface(node);
71
292
  if (!next) return;
72
- const previous = surface;
73
- surface = next;
74
- context = surface.getContext("2d");
75
- previous.dispose?.();
293
+ const previous = state.surface;
294
+ state.surface = next;
295
+ state.raster = nativeSurface(next);
296
+ state.context = next.getContext("2d");
297
+ state.originClean = true;
298
+ settings = textDefaults();
299
+ settingsStack.length = 0;
300
+ styleValues = { fillStyle: null, strokeStyle: null };
301
+ styleStack.length = 0;
302
+ [state.width, state.height] = bitmapSize(node);
303
+ changed(state);
304
+ previous.dispose();
76
305
  },
77
306
  };
78
307
  }
@@ -82,11 +311,25 @@ function makeSurface(node) {
82
311
  return CanvasKit.MakeCanvas(width, height);
83
312
  }
84
313
 
314
+ function nativeSurface(canvas) {
315
+ // The bundled CanvasKit canvas shim owns its SkSurface in an enumerable
316
+ // field, renamed by minification. Use its native API instead of depending
317
+ // on that generated field name or encoding/decoding a canvas data URL.
318
+ return Object.values(canvas).find(value => value && typeof value.makeImageSnapshot === "function"
319
+ && typeof value.getCanvas === "function") ?? null;
320
+ }
321
+
85
322
  function copyImageData(image) {
86
323
  if (!image?.data) throw new DOMException("The source rectangle is empty.", "IndexSizeError");
87
324
  const count = image.width * image.height * 4;
88
- const data = new Uint8ClampedArray(count);
89
- const source = new Uint8ClampedArray(image.data.buffer, image.data.byteOffset, Math.min(count, image.data.length));
90
- data.set(source);
325
+ // Current CanvasKit returns a fresh, exact-sized copy from readPixels.
326
+ // Keep that array; only copy when the backing buffer exposes other pixels
327
+ // (or the WASM heap), as some CanvasKit versions do.
328
+ let data = image.data;
329
+ if (!(data instanceof Uint8ClampedArray) || data.byteOffset !== 0 || data.buffer.byteLength !== count || data.length !== count) {
330
+ data = new Uint8ClampedArray(count);
331
+ const source = new Uint8ClampedArray(image.data.buffer, image.data.byteOffset, Math.min(count, image.data.length));
332
+ data.set(source);
333
+ }
91
334
  return { width: image.width, height: image.height, data, colorSpace: "srgb" };
92
335
  }
@@ -0,0 +1,120 @@
1
+ import { parseFontShorthand } from "../style/computed-style.js";
2
+
3
+ const STRETCH = { "ultra-condensed": 50, "extra-condensed": 62.5, condensed: 75, "semi-condensed": 87.5,
4
+ normal: 100, "semi-expanded": 112.5, expanded: 125, "extra-expanded": 150, "ultra-expanded": 200 };
5
+ const SIZES = { "xx-small": 9, "x-small": 10, small: 13, medium: 16, large: 18, "x-large": 24, "xx-large": 32, "xxx-large": 48 };
6
+
7
+ export function parseCanvasFont(value, base = 16, root = 16) {
8
+ const font = parseFontShorthand(String(value));
9
+ if (!font) return null;
10
+ const size = font.size.toLowerCase();
11
+ const length = size.match(/^(\d*\.?\d+)(px|pt|pc|in|cm|mm|q|em|rem|ex|ch|%)$/);
12
+ const factors = { px: 1, pt: 4 / 3, pc: 16, in: 96, cm: 96 / 2.54, mm: 96 / 25.4, q: 96 / 101.6,
13
+ em: base, rem: root, ex: base / 2, ch: base / 2, "%": base / 100 };
14
+ const fontSize = SIZES[size] ?? (size === "larger" ? base * 1.2 : size === "smaller" ? base / 1.2
15
+ : length ? Number(length[1]) * factors[length[2]] : NaN);
16
+ if (!Number.isFinite(fontSize) || fontSize <= 0) return null;
17
+ const fontFamily = font.family.match(/(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^,])+/g)?.map(family => family.trim().replace(/^(?:"(.*)"|'(.*)')$/, "$1$2"));
18
+ if (!fontFamily?.length || fontFamily.some(family => !family)) return null;
19
+ return { fontSize, fontFamily, fontWeight: font.weight === "bold" ? 700 : Number(font.weight) || 400,
20
+ fontStyle: font.style, fontStretch: STRETCH[font.stretch] ?? 100 };
21
+ }
22
+
23
+ /** Draw with the same shaped fonts, fallbacks and Unicode handling as DOM text. */
24
+ export function createCanvasTextRenderer(CanvasKit, makeParagraph) {
25
+ const paragraphs = new Map();
26
+ let characters = 0;
27
+ const render = request => canvasText(CanvasKit, makeParagraph, request, {
28
+ get(key) {
29
+ const entry = paragraphs.get(key);
30
+ if (entry) { paragraphs.delete(key); paragraphs.set(key, entry); }
31
+ return entry?.paragraph;
32
+ },
33
+ set(key, paragraph, count) {
34
+ paragraphs.set(key, { paragraph, count });
35
+ characters += count;
36
+ while (paragraphs.size > 128 || characters > 16000) {
37
+ const [old, entry] = paragraphs.entries().next().value;
38
+ entry.paragraph.delete(); characters -= entry.count; paragraphs.delete(old);
39
+ }
40
+ },
41
+ });
42
+ render.clear = () => {
43
+ for (const { paragraph } of paragraphs.values()) paragraph.delete();
44
+ paragraphs.clear(); characters = 0;
45
+ };
46
+ return render;
47
+ }
48
+
49
+ function canvasText(CanvasKit, makeParagraph, { context, canvas, settings, style, operation, args, paintStyle }, cache) {
50
+ const text = String(args[0]).replace(/[\t\n\f\r]/g, " ");
51
+ const stroke = operation === "strokeText";
52
+ const measure = operation === "measureText";
53
+ // CanvasKit's strokeStyle getter serializes every style as a color. The
54
+ // context facade supplies its already-unwrapped gradient/pattern directly.
55
+ const color = paintStyle ?? (stroke ? context.strokeStyle : context.fillStyle);
56
+ const retained = text.length <= 4096 && (measure || typeof color === "string");
57
+ const key = retained ? JSON.stringify([text, style, measure ? "measure" : [stroke, color, context.globalAlpha,
58
+ context.globalCompositeOperation, context.lineWidth, context.miterLimit, context.lineJoin]]) : null;
59
+ let paint, paragraph = retained ? cache.get(key) : null;
60
+ let owned = !paragraph;
61
+ try {
62
+ if (!paragraph && !measure) {
63
+ paint = new CanvasKit.Paint();
64
+ paint.setAntiAlias(true);
65
+ if (typeof color === "string") {
66
+ const rgba = CanvasKit.parseColorString(color);
67
+ rgba[3] *= context.globalAlpha;
68
+ paint.setColor(rgba);
69
+ } else if (typeof color?.pe === "function") {
70
+ paint.setShader(color.pe(canvas.getTotalMatrix()));
71
+ paint.setColor(CanvasKit.Color(0, 0, 0, context.globalAlpha));
72
+ }
73
+ if (stroke) {
74
+ paint.setStyle(CanvasKit.PaintStyle.Stroke);
75
+ paint.setStrokeWidth(context.lineWidth);
76
+ paint.setStrokeMiter(context.miterLimit);
77
+ paint.setStrokeJoin({ round: CanvasKit.StrokeJoin.Round, bevel: CanvasKit.StrokeJoin.Bevel }[context.lineJoin] ?? CanvasKit.StrokeJoin.Miter);
78
+ }
79
+ const blends = { "source-over": "SrcOver", "source-in": "SrcIn", "source-out": "SrcOut", "source-atop": "SrcATop",
80
+ "destination-over": "DstOver", "destination-in": "DstIn", "destination-out": "DstOut", "destination-atop": "DstATop",
81
+ lighter: "Plus", copy: "Src", xor: "Xor", multiply: "Multiply", screen: "Screen", overlay: "Overlay", darken: "Darken",
82
+ lighten: "Lighten", "color-dodge": "ColorDodge", "color-burn": "ColorBurn", "hard-light": "HardLight", "soft-light": "SoftLight",
83
+ difference: "Difference", exclusion: "Exclusion", hue: "Hue", saturation: "Saturation", color: "Color", luminosity: "Luminosity" };
84
+ paint.setBlendMode(CanvasKit.BlendMode[blends[context.globalCompositeOperation] ?? "SrcOver"]);
85
+ }
86
+ if (!paragraph) {
87
+ paragraph = makeParagraph(text, style, undefined, 1, paint);
88
+ paragraph.layout(1e7);
89
+ if (retained) { cache.set(key, paragraph, text.length); owned = false; }
90
+ }
91
+ const width = paragraph.getMaxIntrinsicWidth();
92
+ const line = paragraph.getLineMetrics()[0];
93
+ const alphabetic = paragraph.getAlphabeticBaseline();
94
+ const ascent = line?.ascent ?? alphabetic, descent = line?.descent ?? paragraph.getHeight() - alphabetic;
95
+ const baseline = { top: 0, hanging: alphabetic - ascent * 0.8, middle: (alphabetic - ascent + alphabetic + descent) / 2,
96
+ alphabetic, ideographic: paragraph.getIdeographicBaseline(), bottom: alphabetic + descent }[settings.textBaseline] ?? alphabetic;
97
+ let align = settings.textAlign;
98
+ if (align === "start") align = settings.direction === "rtl" ? "right" : "left";
99
+ if (align === "end") align = settings.direction === "rtl" ? "left" : "right";
100
+ const offset = align === "center" ? width / 2 : align === "right" ? width : 0;
101
+ if (measure) return { width, actualBoundingBoxLeft: offset, actualBoundingBoxRight: width - offset,
102
+ actualBoundingBoxAscent: baseline - (alphabetic - ascent), actualBoundingBoxDescent: alphabetic + descent - baseline,
103
+ fontBoundingBoxAscent: baseline, fontBoundingBoxDescent: paragraph.getHeight() - baseline,
104
+ emHeightAscent: ascent, emHeightDescent: descent, hangingBaseline: alphabetic - ascent * 0.8,
105
+ alphabeticBaseline: alphabetic - baseline, ideographicBaseline: paragraph.getIdeographicBaseline() - baseline };
106
+ const [x, y] = args.slice(1, 3).map(Number);
107
+ const maxWidth = args.length > 3 ? Number(args[3]) : Infinity;
108
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !(maxWidth > 0) || !text) return;
109
+ const scale = width > maxWidth ? maxWidth / width : 1;
110
+ canvas.save();
111
+ try {
112
+ canvas.translate(x - offset * scale, y - baseline);
113
+ canvas.scale(scale, 1);
114
+ canvas.drawParagraph(paragraph, 0, 0);
115
+ } finally { canvas.restore(); }
116
+ } finally {
117
+ if (owned) paragraph?.delete();
118
+ paint?.delete();
119
+ }
120
+ }
@@ -0,0 +1,103 @@
1
+ // Private loopback transport, connected before renderer seccomp. No inherited
2
+ // numeric descriptors: node:net also supports Windows sockets.
3
+ import {createServer, createConnection} from 'node:net';
4
+ import {randomBytes} from 'node:crypto';
5
+ const MAX_JSON = 96 * 1024 * 1024;
6
+ const MAX_PIXELS = 64 * 1024 * 1024;
7
+ const MAX_QUEUE = 128 * 1024 * 1024;
8
+
9
+ // Length-prefixed JSON followed by optional binary pixels. Copy once into an
10
+ // owned packet so a raster lease may be released before the socket drains.
11
+ export function encodePacket(message, pixels = null) {
12
+ const json = Buffer.from(JSON.stringify(message));
13
+ const size = pixels?.byteLength ?? 0;
14
+ if (json.length > MAX_JSON || size > MAX_PIXELS || json.length + size > MAX_QUEUE) throw Error('Compositor packet too large');
15
+ const packet = Buffer.allocUnsafe(8 + json.length + size);
16
+ packet.writeUInt32LE(json.length, 0); packet.writeUInt32LE(size, 4);
17
+ packet.set(json, 8); if (size) packet.set(pixels, 8 + json.length);
18
+ return packet;
19
+ }
20
+
21
+ export class PacketReader {
22
+ constructor(receive, limit = MAX_QUEUE) { this.receive = receive; this.limit = limit; this.header = Buffer.alloc(8); this.used = 0; this.packet = null; }
23
+ push(chunk) {
24
+ let offset = 0;
25
+ while (offset < chunk.length) {
26
+ const target = this.packet ?? this.header;
27
+ const count = Math.min(target.length - this.used, chunk.length - offset);
28
+ target.set(chunk.subarray(offset, offset + count), this.used);
29
+ this.used += count; offset += count;
30
+ if (this.used !== target.length) continue;
31
+ this.used = 0;
32
+ if (!this.packet) {
33
+ this.jsonLength = this.header.readUInt32LE(0);
34
+ const binaryLength = this.header.readUInt32LE(4);
35
+ const total = this.jsonLength + binaryLength;
36
+ if (!this.jsonLength || this.jsonLength > MAX_JSON || binaryLength > MAX_PIXELS || total > this.limit) throw Error('Invalid compositor packet size');
37
+ this.packet = Buffer.allocUnsafe(total);
38
+ } else {
39
+ const packet = this.packet; this.packet = null;
40
+ const message = JSON.parse(packet.subarray(0, this.jsonLength).toString());
41
+ const pixels = packet.subarray(this.jsonLength);
42
+ this.receive(message, pixels);
43
+ }
44
+ }
45
+ }
46
+ }
47
+
48
+ function attach(socket, receive, closed, limit) {
49
+ socket.setNoDelay(true);
50
+ const reader = new PacketReader(receive, limit);
51
+ socket.on('data', chunk => { try { reader.push(chunk); } catch { socket.destroy(); } });
52
+ socket.on('error', () => {});
53
+ socket.once('close', closed);
54
+ return {
55
+ reader,
56
+ send(message, pixels) {
57
+ if (socket.destroyed) throw Error('Compositor channel closed');
58
+ const packet = encodePacket(message, pixels);
59
+ if (socket.writableLength + packet.length > MAX_QUEUE) { socket.destroy(); throw Error('Compositor channel backpressure limit'); }
60
+ socket.write(packet);
61
+ },
62
+ close() { socket.destroy(); },
63
+ };
64
+ }
65
+
66
+ export async function listenCompositor(receive, closed) {
67
+ const token = randomBytes(32).toString('hex');
68
+ const sockets = new Set(); let peer = null, ended = false;
69
+ const server = createServer(socket => {
70
+ if (ended || peer || sockets.size >= 4) { socket.destroy(); return; }
71
+ sockets.add(socket);
72
+ const timer = setTimeout(() => socket.destroy(), 3000);
73
+ const candidate = attach(socket, (message, pixels) => {
74
+ if (!peer) {
75
+ if (pixels.length || message?.type !== 'hello' || message.token !== token) { socket.destroy(); return; }
76
+ peer = candidate; clearTimeout(timer); candidate.reader.limit = MAX_QUEUE;
77
+ for (const other of sockets) if (other !== socket) other.destroy();
78
+ server.close(); candidate.send({type:'accepted'});
79
+ } else if (peer === candidate) receive(message, pixels);
80
+ }, () => { clearTimeout(timer); sockets.delete(socket); if (peer === candidate && !ended) closed(); }, 4096);
81
+ });
82
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
83
+ return {
84
+ endpoint: {port:server.address().port, token},
85
+ send(message) { if (!peer || ended) throw Error('Compositor not connected'); peer.send(message); },
86
+ close() { ended = true; server.close(); for (const socket of sockets) socket.destroy(); },
87
+ };
88
+ }
89
+
90
+ export function connectCompositor(endpoint, receive, closed) {
91
+ return new Promise((resolve, reject) => {
92
+ const socket = createConnection({host:'127.0.0.1', port:endpoint.port});
93
+ let accepted = false;
94
+ const timer = setTimeout(() => socket.destroy(), 5000);
95
+ const peer = attach(socket, (message, pixels) => {
96
+ if (!accepted) {
97
+ if (message?.type !== 'accepted' || pixels.length) { socket.destroy(); return; }
98
+ accepted = true; clearTimeout(timer); resolve(peer);
99
+ } else receive(message, pixels);
100
+ }, () => { clearTimeout(timer); if (!accepted) reject(Error('Compositor connection failed')); else closed(); });
101
+ socket.once('connect', () => peer.send({type:'hello', token:endpoint.token}));
102
+ });
103
+ }