render-tag 0.1.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 ADDED
@@ -0,0 +1,159 @@
1
+ # render-tag
2
+
3
+ Render HTML rich text onto a canvas element using pure 2D canvas API. No SVG, no `foreignObject` — just `fillText`, `measureText`, and drawing primitives.
4
+
5
+ **Website & demos:** [https://polotno.com/render-tag/](https://polotno.com/render-tag/)
6
+
7
+ ## Why
8
+
9
+ Browsers can render HTML into canvas via SVG `foreignObject`, but it's slow (~100ms) and inconsistent across browsers. `render-tag` parses your HTML, resolves styles via `getComputedStyle`, then lays out and draws everything with canvas 2D calls. It's **10-60x faster** than SVG-based approaches.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install render-tag
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```typescript
20
+ import { renderHTML } from 'render-tag';
21
+
22
+ const { canvas, height } = renderHTML(
23
+ '<p>Hello <strong>world</strong></p>',
24
+ { width: 400 }
25
+ );
26
+
27
+ document.body.appendChild(canvas);
28
+ ```
29
+
30
+ ### With CSS
31
+
32
+ ```typescript
33
+ const { canvas } = renderHTML(
34
+ '<p class="title">Styled text</p>',
35
+ {
36
+ width: 600,
37
+ css: `
38
+ .title {
39
+ font-family: Georgia, serif;
40
+ font-size: 24px;
41
+ color: #1a1a1a;
42
+ }
43
+ `,
44
+ }
45
+ );
46
+ ```
47
+
48
+ ### With web fonts
49
+
50
+ ```typescript
51
+ const { canvas } = renderHTML(
52
+ '<p>Custom font text</p>',
53
+ {
54
+ width: 500,
55
+ css: `
56
+ @font-face {
57
+ font-family: 'MyFont';
58
+ src: url('https://example.com/font.woff2') format('woff2');
59
+ }
60
+ p { font-family: 'MyFont', sans-serif; font-size: 18px; }
61
+ `,
62
+ }
63
+ );
64
+ ```
65
+
66
+ ### High-DPI / Retina
67
+
68
+ ```typescript
69
+ const { canvas } = renderHTML(html, {
70
+ width: 600,
71
+ pixelRatio: window.devicePixelRatio,
72
+ });
73
+ ```
74
+
75
+ ### Render onto existing canvas
76
+
77
+ ```typescript
78
+ const canvas = document.getElementById('my-canvas');
79
+ renderHTML(html, { canvas, width: 800, height: 600 });
80
+ ```
81
+
82
+ ## API
83
+
84
+ ### `renderHTML(html, options): RenderResult`
85
+
86
+ | Option | Type | Default | Description |
87
+ |---|---|---|---|
88
+ | `width` | `number` | *required* | Layout width in CSS pixels |
89
+ | `height` | `number` | auto | Fixed height (auto-sized from content if omitted) |
90
+ | `css` | `string` | `''` | CSS stylesheet (supports `@font-face`, classes, selectors) |
91
+ | `canvas` | `HTMLCanvasElement` | created | Target canvas element |
92
+ | `pixelRatio` | `number` | `1` | Device pixel ratio for sharp rendering |
93
+ | `useDomMeasurements` | `boolean` | `true` | Use DOM probes for cross-browser line height accuracy. Disable for DOM-free rendering (pure canvas). |
94
+
95
+ Returns `{ canvas: HTMLCanvasElement, height: number }`.
96
+
97
+ The function is **synchronous**. Fonts must be loaded before calling.
98
+
99
+ ## What it renders
100
+
101
+ - Paragraphs, headings, divs, spans
102
+ - Bold, italic, underline, strikethrough, overline
103
+ - Text colors, background colors
104
+ - Font families, sizes, weights (100-900)
105
+ - Line height, letter spacing, text alignment (left/center/right/justify)
106
+ - Ordered and unordered lists with nesting
107
+ - Inline styles and CSS classes
108
+ - `@font-face` web fonts (loaded automatically)
109
+ - Flexbox layout (row/column)
110
+ - Table layout (basic)
111
+ - Text shadows, text stroke, gradient text
112
+ - Decoration styles: solid, dotted, dashed, double, wavy
113
+ - RTL text, CJK characters, emoji
114
+ - `pre-wrap` whitespace handling
115
+ - `overflow-wrap: break-word`
116
+
117
+ ## Cross-browser consistency
118
+
119
+ The library targets Chrome as the primary browser. For consistent rendering across Chrome and Firefox, add these CSS rules to your input:
120
+
121
+ ```css
122
+ /* Suppress Firefox's ::marker extra line height (~1.5px per list item).
123
+ render-tag draws list markers itself, so this loses nothing visually. */
124
+ li::marker { content: none; font-size: 0; line-height: 0; }
125
+
126
+ /* Fix Firefox emoji position drift (apply to elements with emoji).
127
+ Firefox's canvas kerning differs from DOM kerning for emoji characters,
128
+ causing cumulative X position shift. Disabling kerning makes them match.
129
+ Note: this slightly affects letter pair spacing for regular text. */
130
+ .has-emoji { font-kerning: none; }
131
+ ```
132
+
133
+ With `useDomMeasurements: true` (the default), the library uses hidden DOM probes to match Firefox's actual line box heights. If you disable DOM measurements (`useDomMeasurements: false`), the CSS above becomes especially important for Firefox consistency.
134
+
135
+ ## How it works
136
+
137
+ 1. **Parse** HTML with `DOMParser`
138
+ 2. **Resolve styles** via hidden DOM + `getComputedStyle` (CSS cascade for free)
139
+ 3. **Layout** with pure canvas `measureText` (block flow, inline wrapping, margin collapsing)
140
+ 4. **Render** with canvas 2D API (`fillText`, `fillRect`, `strokeText`, etc.)
141
+
142
+ Layout is computed from CSS values and canvas text metrics. Optional DOM probes (`useDomMeasurements`) improve cross-browser accuracy for line heights and mixed-font wrapping.
143
+
144
+ ## Development
145
+
146
+ ```bash
147
+ npm install
148
+ npx playwright install chromium
149
+
150
+ # Run visual comparison demo
151
+ npm run dev
152
+
153
+ # Run tests (60 cases, Chromium via Playwright)
154
+ npm test
155
+ ```
156
+
157
+ ## License
158
+
159
+ MIT
package/lib/index.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ import type { RenderOptions, RenderResult, LayoutLine } from './types.js';
2
+ export type { RenderOptions, RenderResult, LayoutLine };
3
+ /**
4
+ * Render an HTML string onto a canvas element using pure 2D canvas API.
5
+ * Fonts must already be loaded on the page before calling this function.
6
+ *
7
+ * @param html - HTML string to render
8
+ * @param options - Rendering options (width is required)
9
+ * @returns The canvas element and computed content height
10
+ */
11
+ export declare function renderHTML(html: string, options: RenderOptions): RenderResult;
12
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,UAAU,EAAc,MAAM,YAAY,CAAC;AAMtF,YAAY,EAAE,aAAa,EAAE,YAAY,EAAE,UAAU,EAAE,CAAC;AAExD;;;;;;;GAOG;AACH,wBAAgB,UAAU,CACxB,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,aAAa,GACrB,YAAY,CAgEd"}
package/lib/index.js ADDED
@@ -0,0 +1,69 @@
1
+ import { parseHTML } from './parse.js';
2
+ import { resolveStyles } from './style-resolver.js';
3
+ import { buildLayoutTree } from './layout.js';
4
+ import { renderNode } from './render.js';
5
+ /**
6
+ * Render an HTML string onto a canvas element using pure 2D canvas API.
7
+ * Fonts must already be loaded on the page before calling this function.
8
+ *
9
+ * @param html - HTML string to render
10
+ * @param options - Rendering options (width is required)
11
+ * @returns The canvas element and computed content height
12
+ */
13
+ export function renderHTML(html, options) {
14
+ const { width, height, css: extraCSS, pixelRatio = 1, useDomMeasurements = true, debug } = options;
15
+ // 1. Parse HTML and extract CSS
16
+ const { fragment, css } = parseHTML(html, extraCSS);
17
+ // 2. Resolve styles using hidden DOM (getComputedStyle only, no measurements)
18
+ const { tree, cleanup } = resolveStyles(fragment, css, width, height);
19
+ // 3. Create canvas
20
+ const canvas = options.canvas || document.createElement('canvas');
21
+ const tmpCanvas = document.createElement('canvas');
22
+ const ctx = tmpCanvas.getContext('2d');
23
+ ctx.fontKerning = 'normal';
24
+ // 4. Build layout tree using pure canvas measurement
25
+ const { root, height: contentHeight } = buildLayoutTree(ctx, tree, width, useDomMeasurements, debug);
26
+ // 5. Size the output canvas
27
+ const finalHeight = height || contentHeight;
28
+ canvas.width = Math.ceil(width * pixelRatio);
29
+ canvas.height = Math.ceil(finalHeight * pixelRatio);
30
+ canvas.style.width = `${width}px`;
31
+ canvas.style.height = `${finalHeight}px`;
32
+ const renderCtx = canvas.getContext('2d');
33
+ renderCtx.scale(pixelRatio, pixelRatio);
34
+ // 6. Render to canvas
35
+ renderNode(renderCtx, root);
36
+ // 7. Extract lines from layout tree — group by Y with tolerance.
37
+ // Use fontSize as proxy for height when grouping. Sub/sup text has
38
+ // different Y but belongs on the same visual line as parent text.
39
+ const wordPositions = [];
40
+ function walkLines(node) {
41
+ if (node.type === 'text' && node.text.trim()) {
42
+ wordPositions.push({ y: node.y, fontSize: node.style.fontSize, text: node.text });
43
+ }
44
+ if (node.type === 'box') {
45
+ for (const child of node.children)
46
+ walkLines(child);
47
+ }
48
+ }
49
+ walkLines(root);
50
+ wordPositions.sort((a, b) => a.y - b.y);
51
+ const lines = [];
52
+ let lineMaxFontSize = 0;
53
+ for (const wp of wordPositions) {
54
+ const lastLine = lines[lines.length - 1];
55
+ const tolerance = Math.max(lineMaxFontSize, wp.fontSize) * 0.5;
56
+ if (lastLine && Math.abs(wp.y - lastLine.y) < tolerance) {
57
+ lastLine.text += wp.text;
58
+ lineMaxFontSize = Math.max(lineMaxFontSize, wp.fontSize);
59
+ }
60
+ else {
61
+ lines.push({ y: Math.round(wp.y), text: wp.text });
62
+ lineMaxFontSize = wp.fontSize;
63
+ }
64
+ }
65
+ // 8. Cleanup
66
+ cleanup();
67
+ return { canvas, height: finalHeight, layoutRoot: root, lines };
68
+ }
69
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,16 @@
1
+ import type { StyledNode, LayoutBox, ResolvedStyle } from './types.js';
2
+ /**
3
+ * Build a canvas font string from resolved style.
4
+ */
5
+ export declare function buildCanvasFont(style: ResolvedStyle): string;
6
+ /**
7
+ * Build the layout tree from the styled tree using pure canvas measurement.
8
+ * No DOM measurements used — all positions computed from CSS values + canvas.measureText.
9
+ */
10
+ export declare function getDomMeasureCount(): number;
11
+ export declare function resetDomMeasureCount(): void;
12
+ export declare function buildLayoutTree(ctx: CanvasRenderingContext2D, styledTree: StyledNode, containerWidth: number, useDomMeasurements?: boolean, debug?: (entry: import('./types.ts').DebugEntry) => void): {
13
+ root: LayoutBox;
14
+ height: number;
15
+ };
16
+ //# sourceMappingURL=layout.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"layout.d.ts","sourceRoot":"","sources":["../src/layout.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAc,SAAS,EAAc,aAAa,EAAE,MAAM,YAAY,CAAC;AAqG/F;;GAEG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,aAAa,GAAG,MAAM,CAO5D;AA+0CD;;;GAGG;AACH,wBAAgB,kBAAkB,WAA+B;AACjE,wBAAgB,oBAAoB,SAA4B;AAEhE,wBAAgB,eAAe,CAC7B,GAAG,EAAE,wBAAwB,EAC7B,UAAU,EAAE,UAAU,EACtB,cAAc,EAAE,MAAM,EACtB,kBAAkB,UAAO,EACzB,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,YAAY,EAAE,UAAU,KAAK,IAAI,GACvD;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAcrC"}