@effing/canvas 0.19.2 → 0.20.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.
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
@@ -266,8 +266,7 @@ function layoutText(text, style, maxWidth, ctx, emojiEnabled) {
266
266
  processedText = text.replace(/\b\w/g, (c) => c.toUpperCase());
267
267
  }
268
268
  const noWrap = whiteSpace === "nowrap" || whiteSpace === "pre";
269
- const preserveWhitespace = whiteSpace === "pre" || whiteSpace === "pre-wrap";
270
- const paragraphs = preserveWhitespace ? processedText.split("\n") : processedText.split("\n");
269
+ const paragraphs = processedText.split("\n");
271
270
  const lines = [];
272
271
  for (const paragraph of paragraphs) {
273
272
  if (noWrap) {
@@ -304,6 +303,21 @@ function layoutText(text, style, maxWidth, ctx, emojiEnabled) {
304
303
  );
305
304
  }
306
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
+ }
307
321
  const segments = [];
308
322
  let totalHeight = 0;
309
323
  let maxLineWidth = 0;
@@ -491,7 +505,7 @@ function truncateWithEllipsis(text, maxWidth, fontSize, fontFamily, fontWeight,
491
505
  letterSpacing
492
506
  );
493
507
  const availWidth = maxWidth - ellipsisWidth;
494
- for (let i = text.length - 1; i > 0; i--) {
508
+ for (let i = text.length; i > 0; i--) {
495
509
  const truncated = text.slice(0, i);
496
510
  const w = measureWord(
497
511
  truncated,
@@ -738,10 +752,30 @@ async function drawImage(ctx, src, x, y, width, height, style, preloadedImage) {
738
752
  }
739
753
  }
740
754
 
755
+ // src/jsx/draw/utils.ts
756
+ function parseCSSLength(value, referenceSize) {
757
+ if (value.endsWith("%")) return parseFloat(value) / 100 * referenceSize;
758
+ return parseFloat(value);
759
+ }
760
+ function toNumber(v) {
761
+ if (typeof v === "number") return v;
762
+ if (v === void 0 || v === null) return 0;
763
+ const n = parseFloat(String(v));
764
+ return isNaN(n) ? 0 : n;
765
+ }
766
+ function resolveBoxValue(v, referenceWidth) {
767
+ if (typeof v === "number") return v;
768
+ if (v === void 0 || v === null) return 0;
769
+ const s = String(v);
770
+ if (s.endsWith("%")) return parseFloat(s) / 100 * referenceWidth;
771
+ const n = parseFloat(s);
772
+ return isNaN(n) ? 0 : n;
773
+ }
774
+
741
775
  // src/jsx/draw/rect.ts
742
776
  function drawRect(ctx, x, y, width, height, style) {
743
- const borderRadius = getBorderRadius(style, width, height);
744
- const hasRoundedCorners = borderRadius.topLeft > 0 || borderRadius.topRight > 0 || borderRadius.bottomRight > 0 || borderRadius.bottomLeft > 0;
777
+ const borderRadius = getBorderRadiusFromStyle(style, width, height);
778
+ const hasRoundedCorners = hasRadius(borderRadius);
745
779
  if (style.boxShadow) {
746
780
  drawBoxShadow(ctx, x, y, width, height, style.boxShadow, borderRadius);
747
781
  }
@@ -771,7 +805,7 @@ function resolveRadius(v, width, height) {
771
805
  if (typeof v === "string") return parseCSSLength(v, Math.min(width, height));
772
806
  return toNumber(v);
773
807
  }
774
- function getBorderRadius(style, width, height) {
808
+ function getBorderRadiusFromStyle(style, width, height) {
775
809
  return {
776
810
  topLeft: resolveRadius(style.borderTopLeftRadius, width, height),
777
811
  topRight: resolveRadius(style.borderTopRightRadius, width, height),
@@ -779,11 +813,8 @@ function getBorderRadius(style, width, height) {
779
813
  bottomLeft: resolveRadius(style.borderBottomLeftRadius, width, height)
780
814
  };
781
815
  }
782
- function getBorderRadiusFromStyle(style, width, height) {
783
- return getBorderRadius(style, width, height);
784
- }
785
816
  function drawBorders(ctx, x, y, width, height, style, borderRadius) {
786
- const hasRoundedCorners = borderRadius.topLeft > 0 || borderRadius.topRight > 0 || borderRadius.bottomRight > 0 || borderRadius.bottomLeft > 0;
817
+ const hasRoundedCorners = hasRadius(borderRadius);
787
818
  const tw = resolveBoxValue(style.borderTopWidth, width);
788
819
  const rw = resolveBoxValue(style.borderRightWidth, width);
789
820
  const bw = resolveBoxValue(style.borderBottomWidth, width);
@@ -1573,25 +1604,22 @@ function splitTextIntoRuns(text, measureText2, emojiSize, letterSpacing = 0) {
1573
1604
  const runs = [];
1574
1605
  const segmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
1575
1606
  let currentText = "";
1607
+ let currentGraphemeCount = 0;
1576
1608
  let currentX = 0;
1577
1609
  let textStartX = 0;
1578
1610
  for (const { segment } of segmenter.segment(text)) {
1579
1611
  if (isEmojiGrapheme(segment)) {
1580
1612
  if (currentText) {
1581
1613
  const textWidth = measureText2(currentText);
1582
- const graphemeCount = [
1583
- ...new Intl.Segmenter(void 0, { granularity: "grapheme" }).segment(
1584
- currentText
1585
- )
1586
- ].length;
1587
1614
  runs.push({
1588
1615
  kind: "text",
1589
1616
  text: currentText,
1590
1617
  x: textStartX,
1591
- width: textWidth + letterSpacing * graphemeCount
1618
+ width: textWidth + letterSpacing * currentGraphemeCount
1592
1619
  });
1593
- currentX = textStartX + textWidth + letterSpacing * graphemeCount;
1620
+ currentX = textStartX + textWidth + letterSpacing * currentGraphemeCount;
1594
1621
  currentText = "";
1622
+ currentGraphemeCount = 0;
1595
1623
  }
1596
1624
  runs.push({
1597
1625
  kind: "emoji",
@@ -1604,20 +1632,16 @@ function splitTextIntoRuns(text, measureText2, emojiSize, letterSpacing = 0) {
1604
1632
  } else {
1605
1633
  if (!currentText) textStartX = currentX;
1606
1634
  currentText += segment;
1635
+ currentGraphemeCount++;
1607
1636
  }
1608
1637
  }
1609
1638
  if (currentText) {
1610
1639
  const textWidth = measureText2(currentText);
1611
- const graphemeCount = [
1612
- ...new Intl.Segmenter(void 0, { granularity: "grapheme" }).segment(
1613
- currentText
1614
- )
1615
- ].length;
1616
1640
  runs.push({
1617
1641
  kind: "text",
1618
1642
  text: currentText,
1619
1643
  x: textStartX,
1620
- width: textWidth + letterSpacing * graphemeCount
1644
+ width: textWidth + letterSpacing * currentGraphemeCount
1621
1645
  });
1622
1646
  }
1623
1647
  return runs;
@@ -1680,15 +1704,12 @@ async function drawSegmentWithEmoji(ctx, seg, x, y, textShadow, emojiStyle) {
1680
1704
  );
1681
1705
  for (const run of runs) {
1682
1706
  if (run.kind === "text") {
1707
+ if (textShadow) {
1708
+ drawTextShadow(ctx, run.text, x + run.x, y, textShadow);
1709
+ }
1683
1710
  if (letterSpacing !== 0) {
1684
- if (textShadow) {
1685
- drawTextShadow(ctx, run.text, x + run.x, y, textShadow);
1686
- }
1687
1711
  drawTextWithLetterSpacing(ctx, run.text, x + run.x, y, letterSpacing);
1688
1712
  } else {
1689
- if (textShadow) {
1690
- drawTextShadow(ctx, run.text, x + run.x, y, textShadow);
1691
- }
1692
1713
  ctx.fillText(run.text, x + run.x, y);
1693
1714
  }
1694
1715
  } else {
@@ -1803,7 +1824,7 @@ async function drawNode(ctx, node, parentX, parentY, debug, emojiStyle) {
1803
1824
  const [offscreen, offCtx] = acquireOffscreen(bufW, bufH);
1804
1825
  offCtx.save();
1805
1826
  offCtx.scale(qx, qy);
1806
- await drawNodeInner(
1827
+ await drawNodeCore(
1807
1828
  offCtx,
1808
1829
  node,
1809
1830
  parentX,
@@ -1845,182 +1866,7 @@ async function drawNode(ctx, node, parentX, parentY, debug, emojiStyle) {
1845
1866
  return;
1846
1867
  }
1847
1868
  }
1848
- ctx.save();
1849
- if (opacity < 1) {
1850
- ctx.globalAlpha *= opacity;
1851
- }
1852
- if (style.filter) {
1853
- ctx.filter = style.filter;
1854
- }
1855
- if (style.transform) {
1856
- applyTransform(
1857
- ctx,
1858
- style.transform,
1859
- x,
1860
- y,
1861
- width,
1862
- height,
1863
- style.transformOrigin
1864
- );
1865
- }
1866
- const isClipped = style.overflow === "hidden" || style.overflowX === "hidden" || style.overflowY === "hidden";
1867
- if (isClipped) {
1868
- const borderRadius = getBorderRadiusFromStyle(style, width, height);
1869
- applyClip(ctx, x, y, width, height, borderRadius);
1870
- }
1871
- if (style.backgroundColor || style.borderTopWidth || style.borderRightWidth || style.borderBottomWidth || style.borderLeftWidth || style.boxShadow) {
1872
- drawRect(ctx, x, y, width, height, style);
1873
- }
1874
- if (style.backgroundImage) {
1875
- const layers = splitGradientArgs(style.backgroundImage);
1876
- for (let i = layers.length - 1; i >= 0; i--) {
1877
- const layer = layers[i].trim();
1878
- const gradient = createGradientFromCSS(ctx, layer, x, y, width, height);
1879
- if (gradient) {
1880
- ctx.fillStyle = gradient;
1881
- const borderRadius = getBorderRadiusFromStyle(style, width, height);
1882
- if (borderRadius.topLeft > 0 || borderRadius.topRight > 0 || borderRadius.bottomRight > 0 || borderRadius.bottomLeft > 0) {
1883
- ctx.beginPath();
1884
- roundedRect(
1885
- ctx,
1886
- x,
1887
- y,
1888
- width,
1889
- height,
1890
- borderRadius.topLeft,
1891
- borderRadius.topRight,
1892
- borderRadius.bottomRight,
1893
- borderRadius.bottomLeft
1894
- );
1895
- ctx.fill();
1896
- } else {
1897
- ctx.fillRect(x, y, width, height);
1898
- }
1899
- } else {
1900
- const urlMatch = layer.match(/url\(["']?(.*?)["']?\)/);
1901
- if (urlMatch) {
1902
- const borderRadius = getBorderRadiusFromStyle(style, width, height);
1903
- const hasRadius2 = borderRadius.topLeft > 0 || borderRadius.topRight > 0 || borderRadius.bottomRight > 0 || borderRadius.bottomLeft > 0;
1904
- if (hasRadius2) {
1905
- applyClip(ctx, x, y, width, height, borderRadius);
1906
- }
1907
- const image = await loadImage3(urlMatch[1]);
1908
- const bgSize = style.backgroundSize;
1909
- if (bgSize === "cover") {
1910
- const r = computeCover(
1911
- image.width,
1912
- image.height,
1913
- x,
1914
- y,
1915
- width,
1916
- height
1917
- );
1918
- ctx.drawImage(
1919
- image,
1920
- r.sx,
1921
- r.sy,
1922
- r.sw,
1923
- r.sh,
1924
- r.dx,
1925
- r.dy,
1926
- r.dw,
1927
- r.dh
1928
- );
1929
- } else {
1930
- let tileW, tileH;
1931
- if (bgSize === "contain") {
1932
- const r = computeContain(
1933
- image.width,
1934
- image.height,
1935
- 0,
1936
- 0,
1937
- width,
1938
- height
1939
- );
1940
- tileW = r.dw;
1941
- tileH = r.dh;
1942
- } else if (bgSize === "100% 100%") {
1943
- tileW = width;
1944
- tileH = height;
1945
- } else {
1946
- tileW = image.width;
1947
- tileH = image.height;
1948
- }
1949
- for (let ty = y; ty < y + height; ty += tileH) {
1950
- for (let tx = x; tx < x + width; tx += tileW) {
1951
- ctx.drawImage(image, tx, ty, tileW, tileH);
1952
- }
1953
- }
1954
- }
1955
- }
1956
- }
1957
- }
1958
- }
1959
- if (debug) {
1960
- ctx.strokeStyle = "rgba(255, 0, 0, 0.5)";
1961
- ctx.lineWidth = 1;
1962
- ctx.strokeRect(x, y, width, height);
1963
- }
1964
- if (node.textContent !== void 0 && node.textContent !== "") {
1965
- const paddingTop = resolveBoxValue(style.paddingTop, width);
1966
- const paddingLeft = resolveBoxValue(style.paddingLeft, width);
1967
- const paddingRight = resolveBoxValue(style.paddingRight, width);
1968
- const borderTopW = resolveBoxValue(style.borderTopWidth, width);
1969
- const borderLeftW = resolveBoxValue(style.borderLeftWidth, width);
1970
- const borderRightW = resolveBoxValue(style.borderRightWidth, width);
1971
- const contentX = x + paddingLeft + borderLeftW;
1972
- const contentY = y + paddingTop + borderTopW;
1973
- const contentWidth = width - paddingLeft - paddingRight - borderLeftW - borderRightW;
1974
- const textLayout = layoutText(
1975
- node.textContent,
1976
- style,
1977
- contentWidth,
1978
- ctx,
1979
- !!emojiStyle
1980
- );
1981
- await drawText(
1982
- ctx,
1983
- textLayout.segments,
1984
- contentX,
1985
- contentY,
1986
- style.textShadow,
1987
- emojiStyle
1988
- );
1989
- }
1990
- if (node.type === "img" && node.props.src) {
1991
- const paddingTop = resolveBoxValue(style.paddingTop, width);
1992
- const paddingLeft = resolveBoxValue(style.paddingLeft, width);
1993
- const paddingRight = resolveBoxValue(style.paddingRight, width);
1994
- const paddingBottom = resolveBoxValue(style.paddingBottom, width);
1995
- const imgX = x + paddingLeft;
1996
- const imgY = y + paddingTop;
1997
- const imgW = width - paddingLeft - paddingRight;
1998
- const imgH = height - paddingTop - paddingBottom;
1999
- if (!isClipped) {
2000
- const borderRadius = getBorderRadiusFromStyle(style, width, height);
2001
- if (borderRadius.topLeft > 0 || borderRadius.topRight > 0 || borderRadius.bottomRight > 0 || borderRadius.bottomLeft > 0) {
2002
- applyClip(ctx, imgX, imgY, imgW, imgH, borderRadius);
2003
- }
2004
- }
2005
- await drawImage(
2006
- ctx,
2007
- node.props.src,
2008
- imgX,
2009
- imgY,
2010
- imgW,
2011
- imgH,
2012
- style,
2013
- node.props.__loadedImage
2014
- );
2015
- }
2016
- if (node.type === "svg") {
2017
- drawSvgContainer(ctx, node, x, y, width, height);
2018
- } else {
2019
- for (const child of node.children) {
2020
- await drawNode(ctx, child, x, y, debug, emojiStyle);
2021
- }
2022
- }
2023
- ctx.restore();
1869
+ await drawNodeCore(ctx, node, parentX, parentY, 0, 0, debug, emojiStyle);
2024
1870
  }
2025
1871
  function extractScale(transform) {
2026
1872
  const scaleMatch = transform.match(/\b(scale|scaleX|scaleY)\(([^)]+)\)/);
@@ -2032,7 +1878,7 @@ function extractScale(transform) {
2032
1878
  const remaining = transform.replace(fullMatch, "").trim();
2033
1879
  return { sx, sy, remaining };
2034
1880
  }
2035
- async function drawNodeInner(ctx, node, parentX, parentY, offsetX, offsetY, debug, emojiStyle, overrideTransform) {
1881
+ async function drawNodeCore(ctx, node, parentX, parentY, offsetX, offsetY, debug, emojiStyle, overrideTransform) {
2036
1882
  const x = parentX + node.x + offsetX;
2037
1883
  const y = parentY + node.y + offsetY;
2038
1884
  const { width, height, style } = node;
@@ -2074,7 +1920,7 @@ async function drawNodeInner(ctx, node, parentX, parentY, offsetX, offsetY, debu
2074
1920
  if (gradient) {
2075
1921
  ctx.fillStyle = gradient;
2076
1922
  const borderRadius = getBorderRadiusFromStyle(style, width, height);
2077
- if (borderRadius.topLeft > 0 || borderRadius.topRight > 0 || borderRadius.bottomRight > 0 || borderRadius.bottomLeft > 0) {
1923
+ if (hasRadius(borderRadius)) {
2078
1924
  ctx.beginPath();
2079
1925
  roundedRect(
2080
1926
  ctx,
@@ -2095,8 +1941,7 @@ async function drawNodeInner(ctx, node, parentX, parentY, offsetX, offsetY, debu
2095
1941
  const urlMatch = layer.match(/url\(["']?(.*?)["']?\)/);
2096
1942
  if (urlMatch) {
2097
1943
  const borderRadius = getBorderRadiusFromStyle(style, width, height);
2098
- const hasRadius2 = borderRadius.topLeft > 0 || borderRadius.topRight > 0 || borderRadius.bottomRight > 0 || borderRadius.bottomLeft > 0;
2099
- if (hasRadius2) {
1944
+ if (hasRadius(borderRadius)) {
2100
1945
  applyClip(ctx, x, y, width, height, borderRadius);
2101
1946
  }
2102
1947
  const image = await loadImage3(urlMatch[1]);
@@ -2193,7 +2038,7 @@ async function drawNodeInner(ctx, node, parentX, parentY, offsetX, offsetY, debu
2193
2038
  const imgH = height - paddingTop - paddingBottom;
2194
2039
  if (!isClipped) {
2195
2040
  const borderRadius = getBorderRadiusFromStyle(style, width, height);
2196
- if (borderRadius.topLeft > 0 || borderRadius.topRight > 0 || borderRadius.bottomRight > 0 || borderRadius.bottomLeft > 0) {
2041
+ if (hasRadius(borderRadius)) {
2197
2042
  applyClip(ctx, imgX, imgY, imgW, imgH, borderRadius);
2198
2043
  }
2199
2044
  }
@@ -2212,15 +2057,25 @@ async function drawNodeInner(ctx, node, parentX, parentY, offsetX, offsetY, debu
2212
2057
  drawSvgContainer(ctx, node, x, y, width, height);
2213
2058
  } else {
2214
2059
  for (const child of node.children) {
2215
- await drawNodeInner(ctx, child, x, y, 0, 0, debug, emojiStyle, void 0);
2060
+ if (offsetX === 0 && offsetY === 0) {
2061
+ await drawNode(ctx, child, x, y, debug, emojiStyle);
2062
+ } else {
2063
+ await drawNodeCore(
2064
+ ctx,
2065
+ child,
2066
+ x,
2067
+ y,
2068
+ 0,
2069
+ 0,
2070
+ debug,
2071
+ emojiStyle,
2072
+ void 0
2073
+ );
2074
+ }
2216
2075
  }
2217
2076
  }
2218
2077
  ctx.restore();
2219
2078
  }
2220
- function parseCSSLength(value, referenceSize) {
2221
- if (value.endsWith("%")) return parseFloat(value) / 100 * referenceSize;
2222
- return parseFloat(value);
2223
- }
2224
2079
  function applyTransform(ctx, transform, x, y, width, height, transformOrigin) {
2225
2080
  let ox = x + width / 2;
2226
2081
  let oy = y + height / 2;
@@ -2285,20 +2140,6 @@ function parseAngle(value) {
2285
2140
  if (value.endsWith("turn")) return parseFloat(value) * 2 * Math.PI;
2286
2141
  return parseFloat(value);
2287
2142
  }
2288
- function toNumber(v) {
2289
- if (typeof v === "number") return v;
2290
- if (v === void 0 || v === null) return 0;
2291
- const n = parseFloat(String(v));
2292
- return isNaN(n) ? 0 : n;
2293
- }
2294
- function resolveBoxValue(v, referenceWidth) {
2295
- if (typeof v === "number") return v;
2296
- if (v === void 0 || v === null) return 0;
2297
- const s = String(v);
2298
- if (s.endsWith("%")) return parseFloat(s) / 100 * referenceWidth;
2299
- const n = parseFloat(s);
2300
- return isNaN(n) ? 0 : n;
2301
- }
2302
2143
 
2303
2144
  // src/jsx/font.ts
2304
2145
  import { GlobalFonts } from "@napi-rs/canvas";
@@ -2537,6 +2378,7 @@ var INHERITABLE_PROPS = [
2537
2378
  "whiteSpace",
2538
2379
  "wordBreak",
2539
2380
  "textOverflow",
2381
+ "lineClamp",
2540
2382
  "textBoxTrim",
2541
2383
  "textBoxEdge"
2542
2384
  ];
@@ -3195,8 +3037,8 @@ function hasElementChildren(children) {
3195
3037
  // src/jsx/index.ts
3196
3038
  async function renderReactElement(ctx, element, options) {
3197
3039
  ensureFontsRegistered(options.fonts);
3198
- const width = ctx.canvas.width;
3199
- const height = ctx.canvas.height;
3040
+ const width = options.width ?? ctx.canvas.width;
3041
+ const height = options.height ?? ctx.canvas.height;
3200
3042
  const emojiStyle = options.emoji === "none" ? void 0 : options.emoji ?? "twemoji";
3201
3043
  const fontFamilies = [...new Set(options.fonts.map((f) => f.name))];
3202
3044
  const layoutTree = await buildLayoutTree(