@effing/canvas 0.19.3 → 0.20.1

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/README.md CHANGED
@@ -69,7 +69,7 @@ JSX → Yoga layout → Skia canvas → PNG
69
69
  2. **Skia** draws each node to a canvas context (backgrounds, borders, text, images, SVG, gradients)
70
70
  3. **Encode** the canvas to PNG or JPEG via `canvas.encode()` or `canvas.encodeSync()`
71
71
 
72
- Canvas dimensions are taken from the context itself (`ctx.canvas.width` / `ctx.canvas.height`), so there are no `width`/`height` options just create a canvas at the size you need.
72
+ Canvas dimensions default to `ctx.canvas.width` / `ctx.canvas.height`, but you can override them with the `width` and `height` options. This is useful for HiDPI rendering where the canvas is larger than the logical layout size.
73
73
 
74
74
  ### Font Loading
75
75
 
@@ -123,6 +123,114 @@ renderLottieFrame(ctx, anim, 0); // render frame 0
123
123
  const png = canvas.encodeSync("png");
124
124
  ```
125
125
 
126
+ ## Supported CSS Properties
127
+
128
+ ### Layout
129
+
130
+ | Property | Values / Notes |
131
+ | -------------------------------- | --------------------------------------------------- |
132
+ | `display` | `flex`, `none` |
133
+ | `position` | `relative`, `absolute` |
134
+ | `top`, `right`, `bottom`, `left` | Length, percentage |
135
+ | `overflow` | `visible`, `hidden` (also `overflowX`, `overflowY`) |
136
+
137
+ ### Flexbox
138
+
139
+ | Property | Values / Notes |
140
+ | --------------------- | ----------------------------------------------------------------------------------- |
141
+ | `flexDirection` | `row`, `column`, `row-reverse`, `column-reverse` |
142
+ | `flexWrap` | `nowrap`, `wrap`, `wrap-reverse` |
143
+ | `justifyContent` | `flex-start`, `flex-end`, `center`, `space-between`, `space-around`, `space-evenly` |
144
+ | `alignItems` | `flex-start`, `flex-end`, `center`, `stretch`, `baseline` |
145
+ | `alignSelf` | `auto`, `flex-start`, `flex-end`, `center`, `stretch`, `baseline` |
146
+ | `alignContent` | `flex-start`, `flex-end`, `center`, `stretch`, `space-between`, `space-around` |
147
+ | `flex` | Shorthand for `flexGrow`, `flexShrink`, `flexBasis` |
148
+ | `flexGrow` | Number |
149
+ | `flexShrink` | Number |
150
+ | `flexBasis` | Length, percentage |
151
+ | `gap` | Shorthand for `rowGap`, `columnGap` |
152
+ | `rowGap`, `columnGap` | Number |
153
+
154
+ ### Dimensions
155
+
156
+ | Property | Values / Notes |
157
+ | ----------------------- | ------------------ |
158
+ | `width`, `height` | Length, percentage |
159
+ | `minWidth`, `minHeight` | Length, percentage |
160
+ | `maxWidth`, `maxHeight` | Length, percentage |
161
+
162
+ ### Spacing
163
+
164
+ | Property | Values / Notes |
165
+ | ------------------------------------------------------------ | --------------------------------------- |
166
+ | `margin` | Shorthand (1–4 values), supports `auto` |
167
+ | `marginTop`, `marginRight`, `marginBottom`, `marginLeft` | Length, percentage, `auto` |
168
+ | `padding` | Shorthand (1–4 values) |
169
+ | `paddingTop`, `paddingRight`, `paddingBottom`, `paddingLeft` | Length, percentage |
170
+
171
+ ### Border
172
+
173
+ | Property | Values / Notes |
174
+ | -------------------------------------------------------- | ----------------------------------- |
175
+ | `border` | Shorthand, e.g. `"1px solid black"` |
176
+ | `borderTop`, `borderRight`, `borderBottom`, `borderLeft` | Per-side shorthand |
177
+ | `borderWidth`, `borderColor`, `borderStyle` | Shorthand (1–4 values) |
178
+ | `border{Top,Right,Bottom,Left}{Width,Color,Style}` | Per-side longhands |
179
+ | `borderRadius` | Shorthand (1–4 values) |
180
+ | `border{TopLeft,TopRight,BottomRight,BottomLeft}Radius` | Per-corner longhands |
181
+
182
+ ### Color & Background
183
+
184
+ | Property | Values / Notes |
185
+ | ----------------- | -------------------------------------------------- |
186
+ | `color` | Any CSS color (inherited) |
187
+ | `backgroundColor` | Any CSS color |
188
+ | `opacity` | Number (0–1) |
189
+ | `background` | Shorthand → `backgroundColor` or `backgroundImage` |
190
+ | `backgroundImage` | `linear-gradient()`, `radial-gradient()`, `url()` |
191
+ | `backgroundSize` | `cover`, `contain`, length, percentage |
192
+
193
+ ### Typography
194
+
195
+ | Property | Values / Notes |
196
+ | ---------------- | ------------------------------------------------------------- |
197
+ | `fontFamily` | Font name (inherited) |
198
+ | `fontSize` | Number or CSS length (inherited) |
199
+ | `fontWeight` | Numeric weight (inherited) |
200
+ | `fontStyle` | `normal`, `italic` (inherited) |
201
+ | `textAlign` | `left`, `center`, `right`, `justify` (inherited) |
202
+ | `textDecoration` | String (inherited) |
203
+ | `textTransform` | `none`, `uppercase`, `lowercase`, `capitalize` (inherited) |
204
+ | `lineHeight` | Number or string (inherited) |
205
+ | `letterSpacing` | Number or CSS length (inherited) |
206
+ | `whiteSpace` | `normal`, `nowrap`, `pre`, `pre-wrap`, `pre-line` (inherited) |
207
+ | `wordBreak` | `normal`, `break-all`, `break-word`, `keep-all` (inherited) |
208
+ | `textOverflow` | `clip`, `ellipsis` (inherited) |
209
+ | `lineClamp` | Number — max visible lines (adds ellipsis) |
210
+ | `textBox` | Shorthand for `textBoxTrim` and `textBoxEdge` |
211
+ | `textBoxTrim` | `none`, `trim-start`, `trim-end`, `trim-both` (inherited) |
212
+ | `textBoxEdge` | e.g. `"cap alphabetic"` (inherited) |
213
+
214
+ ### Effects
215
+
216
+ | Property | Values / Notes |
217
+ | ----------------- | ------------------------------------------------ |
218
+ | `boxShadow` | CSS box-shadow string |
219
+ | `textShadow` | CSS text-shadow string |
220
+ | `transform` | `translate`, `scale`, `rotate`, `skewX`, `skewY` |
221
+ | `transformOrigin` | CSS transform-origin string |
222
+ | `filter` | CSS filter string |
223
+
224
+ ### Image
225
+
226
+ | Property | Values / Notes |
227
+ | ----------- | ------------------------------------------------ |
228
+ | `objectFit` | `contain`, `cover`, `fill`, `none`, `scale-down` |
229
+
230
+ ### Units
231
+
232
+ `px`, `em`, `rem`, `%`, `vw`, `vh`, `vmin`, `vmax`, `pt`, `pc`, `in`, `cm`, `mm`
233
+
126
234
  ## API Overview
127
235
 
128
236
  ### `createCanvas(width, height)`
@@ -176,11 +284,13 @@ function renderLottieFrame(
176
284
 
177
285
  ### Options
178
286
 
179
- | Option | Type | Required | Description |
180
- | ------- | ---------------------- | -------- | ---------------------------------------- |
181
- | `fonts` | `FontData[]` | Yes | Font data for text rendering |
182
- | `debug` | `boolean` | No | Draw layout bounding boxes for debugging |
183
- | `emoji` | `EmojiStyle \| "none"` | No | Emoji style (default: `"twemoji"`) |
287
+ | Option | Type | Required | Description |
288
+ | -------- | ---------------------- | -------- | ----------------------------------------------------- |
289
+ | `fonts` | `FontData[]` | Yes | Font data for text rendering |
290
+ | `width` | `number` | No | Layout width override (default: `ctx.canvas.width`) |
291
+ | `height` | `number` | No | Layout height override (default: `ctx.canvas.height`) |
292
+ | `debug` | `boolean` | No | Draw layout bounding boxes for debugging |
293
+ | `emoji` | `EmojiStyle \| "none"` | No | Emoji style (default: `"twemoji"`) |
184
294
 
185
295
  ### Types
186
296
 
@@ -276,6 +386,25 @@ await renderReactElement(ctx, <MyComponent />, {
276
386
  });
277
387
  ```
278
388
 
389
+ ### HiDPI Rendering
390
+
391
+ For sharp output on high-DPI displays, you can create a larger canvas and use `width`/`height` overrides to keep layout at the logical size:
392
+
393
+ ```typescript
394
+ const dpr = 2;
395
+ const canvas = createCanvas(1080 * dpr, 1080 * dpr);
396
+ const ctx = canvas.getContext("2d");
397
+ ctx.scale(dpr, dpr);
398
+
399
+ await renderReactElement(ctx, <MyComponent />, {
400
+ fonts,
401
+ width: 1080,
402
+ height: 1080,
403
+ });
404
+
405
+ const png = await canvas.encode("png");
406
+ ```
407
+
279
408
  ## Related Packages
280
409
 
281
410
  - [`@effing/tween`](../tween) — Step iteration and easing for frame generation
package/dist/index.d.ts CHANGED
@@ -63,6 +63,10 @@ type FontData = {
63
63
  type RenderReactElementOptions = {
64
64
  /** Font data for text rendering */
65
65
  fonts: FontData[];
66
+ /** Layout width override. Defaults to `ctx.canvas.width`. */
67
+ width?: number;
68
+ /** Layout height override. Defaults to `ctx.canvas.height`. */
69
+ height?: number;
66
70
  /** Draw layout bounding boxes for debugging */
67
71
  debug?: boolean;
68
72
  /** Emoji style for rendering emoji characters as images. Defaults to "twemoji". Pass "none" to disable. */
@@ -72,12 +76,13 @@ type RenderReactElementOptions = {
72
76
  /**
73
77
  * Render a React element tree to a canvas context.
74
78
  *
75
- * Width and height are taken from the canvas itself (`ctx.canvas.width` /
76
- * `ctx.canvas.height`).
79
+ * Width and height default to `ctx.canvas.width` / `ctx.canvas.height` but
80
+ * can be overridden via `options.width` and `options.height`. This is useful
81
+ * for HiDPI rendering where the canvas is larger than the logical layout size.
77
82
  *
78
83
  * @param ctx - Canvas 2D rendering context to draw into
79
84
  * @param element - React element tree to render
80
- * @param options - Rendering options (fonts, debug mode)
85
+ * @param options - Rendering options (fonts, dimensions, debug mode)
81
86
  *
82
87
  * @example
83
88
  * ```tsx
@@ -90,6 +95,20 @@ type RenderReactElementOptions = {
90
95
  *
91
96
  * const png = canvas.encodeSync("png");
92
97
  * ```
98
+ *
99
+ * @example HiDPI rendering (2x)
100
+ * ```tsx
101
+ * const dpr = 2;
102
+ * const canvas = createCanvas(1080 * dpr, 1080 * dpr);
103
+ * const ctx = canvas.getContext("2d");
104
+ * ctx.scale(dpr, dpr);
105
+ *
106
+ * await renderReactElement(ctx, <MyComponent />, {
107
+ * fonts: [myFont],
108
+ * width: 1080,
109
+ * height: 1080,
110
+ * });
111
+ * ```
93
112
  */
94
113
  declare function renderReactElement(ctx: SKRSContext2D, element: ReactNode, options: RenderReactElementOptions): Promise<void>;
95
114
 
package/dist/index.js CHANGED
@@ -21,7 +21,7 @@ function renderLottieFrame(ctx, animation, frame) {
21
21
  }
22
22
 
23
23
  // src/jsx/draw/index.ts
24
- import { createCanvas as createCanvas2, loadImage as loadImage3 } from "@napi-rs/canvas";
24
+ import { loadImage as loadImage3 } from "@napi-rs/canvas";
25
25
 
26
26
  // src/jsx/language.ts
27
27
  function isEmoji(char) {
@@ -303,6 +303,21 @@ function layoutText(text, style, maxWidth, ctx, emojiEnabled) {
303
303
  );
304
304
  }
305
305
  }
306
+ const lineClamp = style.lineClamp;
307
+ if (lineClamp && lineClamp > 0 && lines.length > lineClamp) {
308
+ const lastLineText = lines.slice(lineClamp - 1).join(" ");
309
+ lines.length = lineClamp;
310
+ lines[lineClamp - 1] = truncateWithEllipsis(
311
+ lastLineText,
312
+ maxWidth,
313
+ fontSize,
314
+ fontFamily,
315
+ fontWeight,
316
+ fontStyle,
317
+ ctx,
318
+ letterSpacing
319
+ );
320
+ }
306
321
  const segments = [];
307
322
  let totalHeight = 0;
308
323
  let maxLineWidth = 0;
@@ -490,7 +505,7 @@ function truncateWithEllipsis(text, maxWidth, fontSize, fontFamily, fontWeight,
490
505
  letterSpacing
491
506
  );
492
507
  const availWidth = maxWidth - ellipsisWidth;
493
- for (let i = text.length - 1; i > 0; i--) {
508
+ for (let i = text.length; i > 0; i--) {
494
509
  const truncated = text.slice(0, i);
495
510
  const w = measureWord(
496
511
  truncated,
@@ -737,6 +752,37 @@ async function drawImage(ctx, src, x, y, width, height, style, preloadedImage) {
737
752
  }
738
753
  }
739
754
 
755
+ // src/jsx/draw/offscreen.ts
756
+ import { createCanvas as createCanvas2 } from "@napi-rs/canvas";
757
+ var canvasPool = /* @__PURE__ */ new Map();
758
+ function acquireOffscreen(w, h) {
759
+ const key = `${w}x${h}`;
760
+ const stack = canvasPool.get(key);
761
+ if (stack) {
762
+ while (stack.length > 0) {
763
+ const ref = stack.pop();
764
+ const canvas2 = ref.deref();
765
+ if (canvas2) {
766
+ const ctx = canvas2.getContext("2d");
767
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
768
+ ctx.clearRect(0, 0, w, h);
769
+ return [canvas2, ctx];
770
+ }
771
+ }
772
+ }
773
+ const canvas = createCanvas2(w, h);
774
+ return [canvas, canvas.getContext("2d")];
775
+ }
776
+ function releaseOffscreen(canvas) {
777
+ const key = `${canvas.width}x${canvas.height}`;
778
+ let stack = canvasPool.get(key);
779
+ if (!stack) {
780
+ stack = [];
781
+ canvasPool.set(key, stack);
782
+ }
783
+ stack.push(new WeakRef(canvas));
784
+ }
785
+
740
786
  // src/jsx/draw/utils.ts
741
787
  function parseCSSLength(value, referenceSize) {
742
788
  if (value.endsWith("%")) return parseFloat(value) / 100 * referenceSize;
@@ -911,17 +957,19 @@ function mergeStyleIntoProps(props) {
911
957
  function collectDefs(children) {
912
958
  const clips = /* @__PURE__ */ new Map();
913
959
  const gradients = /* @__PURE__ */ new Map();
960
+ const masks = /* @__PURE__ */ new Map();
914
961
  for (const child of children) {
915
962
  if (child.type !== "defs") continue;
916
963
  for (const def of normalizeChildren(child)) {
917
964
  const id = def.props.id;
918
965
  if (!id) continue;
919
966
  if (def.type === "clipPath") clips.set(id, normalizeChildren(def));
967
+ else if (def.type === "mask") masks.set(id, normalizeChildren(def));
920
968
  else if (def.type === "radialGradient" || def.type === "linearGradient")
921
969
  gradients.set(id, def);
922
970
  }
923
971
  }
924
- return { clips, gradients };
972
+ return { clips, gradients, masks };
925
973
  }
926
974
  function parseUrlRef(value) {
927
975
  if (typeof value !== "string") return void 0;
@@ -1211,13 +1259,23 @@ function buildPath(child, vbW, vbH) {
1211
1259
  return p;
1212
1260
  }
1213
1261
  case "rect": {
1214
- const rx = svgLength(props.x, vbW);
1215
- const ry = svgLength(props.y, vbH);
1262
+ const x = svgLength(props.x, vbW);
1263
+ const y = svgLength(props.y, vbH);
1216
1264
  const w = svgLength(props.width, vbW);
1217
1265
  const h = svgLength(props.height, vbH);
1218
1266
  if (w <= 0 || h <= 0) return void 0;
1267
+ const rxRaw = svgLength(props.rx, vbW, -1);
1268
+ const ryRaw = svgLength(props.ry, vbH, -1);
1269
+ let rx = rxRaw >= 0 ? rxRaw : ryRaw >= 0 ? ryRaw : 0;
1270
+ let ry = ryRaw >= 0 ? ryRaw : rx;
1271
+ rx = Math.min(rx, w / 2);
1272
+ ry = Math.min(ry, h / 2);
1219
1273
  const p = new Path2D();
1220
- p.rect(rx, ry, w, h);
1274
+ if (rx > 0 || ry > 0) {
1275
+ p.roundRect(x, y, w, h, [rx]);
1276
+ } else {
1277
+ p.rect(x, y, w, h);
1278
+ }
1221
1279
  return p;
1222
1280
  }
1223
1281
  case "ellipse": {
@@ -1294,43 +1352,67 @@ function drawSvgContainer(ctx, node, x, y, width, height) {
1294
1352
  }
1295
1353
  var EMPTY_DEFS = {
1296
1354
  clips: /* @__PURE__ */ new Map(),
1297
- gradients: /* @__PURE__ */ new Map()
1355
+ gradients: /* @__PURE__ */ new Map(),
1356
+ masks: /* @__PURE__ */ new Map()
1298
1357
  };
1299
1358
  function drawSvgChild(ctx, child, inheritedFill, color, defs = EMPTY_DEFS, vbW = 0, vbH = 0) {
1300
1359
  const { type } = child;
1301
1360
  const props = mergeStyleIntoProps(child.props);
1302
- if (type === "defs" || type === "clipPath") return;
1361
+ if (type === "defs" || type === "clipPath" || type === "mask") return;
1303
1362
  const clipRef = parseUrlRef(props.clipPath ?? props["clip-path"]);
1304
1363
  const clipShapes = clipRef ? defs.clips.get(clipRef) : void 0;
1305
1364
  const clipPath = clipShapes ? buildClipPath(clipShapes, vbW, vbH) : void 0;
1365
+ const maskRef = parseUrlRef(props.mask);
1366
+ const maskShapes = maskRef ? defs.masks.get(maskRef) : void 0;
1306
1367
  if (clipPath) ctx.save();
1307
1368
  if (clipPath) ctx.clip(clipPath);
1369
+ let elemCanvas;
1370
+ const targetCtx = maskShapes ? (() => {
1371
+ const [c, cCtx] = acquireOffscreen(Math.ceil(vbW), Math.ceil(vbH));
1372
+ elemCanvas = c;
1373
+ return cCtx;
1374
+ })() : ctx;
1308
1375
  switch (type) {
1309
1376
  case "path":
1310
- drawPath(ctx, props, inheritedFill, color, defs);
1377
+ drawPath(targetCtx, props, inheritedFill, color, defs);
1311
1378
  break;
1312
1379
  case "circle":
1313
- drawCircle(ctx, props, inheritedFill, color, defs, vbW, vbH);
1380
+ drawCircle(targetCtx, props, inheritedFill, color, defs, vbW, vbH);
1314
1381
  break;
1315
1382
  case "rect":
1316
- drawSvgRect(ctx, props, inheritedFill, color, defs, vbW, vbH);
1383
+ drawSvgRect(targetCtx, props, inheritedFill, color, defs, vbW, vbH);
1317
1384
  break;
1318
1385
  case "line":
1319
- drawLine(ctx, props, color, defs, vbW, vbH);
1386
+ drawLine(targetCtx, props, color, defs, vbW, vbH);
1320
1387
  break;
1321
1388
  case "ellipse":
1322
- drawEllipse(ctx, props, inheritedFill, color, defs, vbW, vbH);
1389
+ drawEllipse(targetCtx, props, inheritedFill, color, defs, vbW, vbH);
1323
1390
  break;
1324
1391
  case "polygon":
1325
- drawPolygon(ctx, props, inheritedFill, color, defs);
1392
+ drawPolygon(targetCtx, props, inheritedFill, color, defs);
1326
1393
  break;
1327
1394
  case "polyline":
1328
- drawPolyline(ctx, props, inheritedFill, color, defs);
1395
+ drawPolyline(targetCtx, props, inheritedFill, color, defs);
1329
1396
  break;
1330
1397
  case "g":
1331
- drawGroup(ctx, child, inheritedFill, color, defs, vbW, vbH);
1398
+ drawGroup(targetCtx, child, inheritedFill, color, defs, vbW, vbH);
1332
1399
  break;
1333
1400
  }
1401
+ if (maskShapes && elemCanvas) {
1402
+ const [maskCanvas, maskCtx] = acquireOffscreen(
1403
+ Math.ceil(vbW),
1404
+ Math.ceil(vbH)
1405
+ );
1406
+ for (const shape of maskShapes) {
1407
+ drawSvgChild(maskCtx, shape, "white", "white", defs, vbW, vbH);
1408
+ }
1409
+ targetCtx.globalCompositeOperation = "destination-in";
1410
+ targetCtx.drawImage(maskCanvas, 0, 0);
1411
+ targetCtx.globalCompositeOperation = "source-over";
1412
+ ctx.drawImage(elemCanvas, 0, 0);
1413
+ releaseOffscreen(maskCanvas);
1414
+ releaseOffscreen(elemCanvas);
1415
+ }
1334
1416
  if (clipPath) ctx.restore();
1335
1417
  }
1336
1418
  function drawPath(ctx, props, inheritedFill, color, defs) {
@@ -1351,14 +1433,24 @@ function drawCircle(ctx, props, inheritedFill, color, defs, vbW = 0, vbH = 0) {
1351
1433
  applyFillAndStroke(ctx, props, path, inheritedFill, color, defs, bbox);
1352
1434
  }
1353
1435
  function drawSvgRect(ctx, props, inheritedFill, color, defs, vbW = 0, vbH = 0) {
1354
- const rx = svgLength(props.x, vbW);
1355
- const ry = svgLength(props.y, vbH);
1436
+ const x = svgLength(props.x, vbW);
1437
+ const y = svgLength(props.y, vbH);
1356
1438
  const w = svgLength(props.width, vbW);
1357
1439
  const h = svgLength(props.height, vbH);
1358
1440
  if (w <= 0 || h <= 0) return;
1441
+ const rxRaw = svgLength(props.rx, vbW, -1);
1442
+ const ryRaw = svgLength(props.ry, vbH, -1);
1443
+ let rx = rxRaw >= 0 ? rxRaw : ryRaw >= 0 ? ryRaw : 0;
1444
+ let ry = ryRaw >= 0 ? ryRaw : rx;
1445
+ rx = Math.min(rx, w / 2);
1446
+ ry = Math.min(ry, h / 2);
1359
1447
  const path = new Path2D();
1360
- path.rect(rx, ry, w, h);
1361
- const bbox = { x: rx, y: ry, width: w, height: h };
1448
+ if (rx > 0 || ry > 0) {
1449
+ path.roundRect(x, y, w, h, [rx]);
1450
+ } else {
1451
+ path.rect(x, y, w, h);
1452
+ }
1453
+ const bbox = { x, y, width: w, height: h };
1362
1454
  applyFillAndStroke(ctx, props, path, inheritedFill, color, defs, bbox);
1363
1455
  }
1364
1456
  function drawLine(ctx, props, color, defs, vbW = 0, vbH = 0) {
@@ -1761,34 +1853,6 @@ function drawTextDecoration(ctx, seg, offsetX, offsetY) {
1761
1853
  }
1762
1854
 
1763
1855
  // src/jsx/draw/index.ts
1764
- var canvasPool = /* @__PURE__ */ new Map();
1765
- function acquireOffscreen(w, h) {
1766
- const key = `${w}x${h}`;
1767
- const stack = canvasPool.get(key);
1768
- if (stack) {
1769
- while (stack.length > 0) {
1770
- const ref = stack.pop();
1771
- const canvas2 = ref.deref();
1772
- if (canvas2) {
1773
- const ctx = canvas2.getContext("2d");
1774
- ctx.setTransform(1, 0, 0, 1, 0, 0);
1775
- ctx.clearRect(0, 0, w, h);
1776
- return [canvas2, ctx];
1777
- }
1778
- }
1779
- }
1780
- const canvas = createCanvas2(w, h);
1781
- return [canvas, canvas.getContext("2d")];
1782
- }
1783
- function releaseOffscreen(canvas) {
1784
- const key = `${canvas.width}x${canvas.height}`;
1785
- let stack = canvasPool.get(key);
1786
- if (!stack) {
1787
- stack = [];
1788
- canvasPool.set(key, stack);
1789
- }
1790
- stack.push(new WeakRef(canvas));
1791
- }
1792
1856
  async function drawNode(ctx, node, parentX, parentY, debug, emojiStyle) {
1793
1857
  const x = parentX + node.x;
1794
1858
  const y = parentY + node.y;
@@ -2363,6 +2427,7 @@ var INHERITABLE_PROPS = [
2363
2427
  "whiteSpace",
2364
2428
  "wordBreak",
2365
2429
  "textOverflow",
2430
+ "lineClamp",
2366
2431
  "textBoxTrim",
2367
2432
  "textBoxEdge"
2368
2433
  ];
@@ -3021,8 +3086,8 @@ function hasElementChildren(children) {
3021
3086
  // src/jsx/index.ts
3022
3087
  async function renderReactElement(ctx, element, options) {
3023
3088
  ensureFontsRegistered(options.fonts);
3024
- const width = ctx.canvas.width;
3025
- const height = ctx.canvas.height;
3089
+ const width = options.width ?? ctx.canvas.width;
3090
+ const height = options.height ?? ctx.canvas.height;
3026
3091
  const emojiStyle = options.emoji === "none" ? void 0 : options.emoji ?? "twemoji";
3027
3092
  const fontFamilies = [...new Set(options.fonts.map((f) => f.name))];
3028
3093
  const layoutTree = await buildLayoutTree(