@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 +135 -6
- package/dist/index.d.ts +22 -3
- package/dist/index.js +74 -232
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
|
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
|
|
180
|
-
|
|
|
181
|
-
| `fonts`
|
|
182
|
-
| `
|
|
183
|
-
| `
|
|
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
|
|
76
|
-
* `
|
|
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
|
|
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
|
|
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 =
|
|
744
|
-
const hasRoundedCorners = borderRadius
|
|
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
|
|
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
|
|
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 *
|
|
1618
|
+
width: textWidth + letterSpacing * currentGraphemeCount
|
|
1592
1619
|
});
|
|
1593
|
-
currentX = textStartX + textWidth + letterSpacing *
|
|
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 *
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|