render-tag 0.1.0 → 0.1.2

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
@@ -1,6 +1,6 @@
1
1
  # render-tag
2
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.
3
+ Render HTML rich text onto canvas with the 2D API. No SVG, no `foreignObject` — just `fillText`, `measureText`, and drawing primitives.
4
4
 
5
5
  **Website & demos:** [https://polotno.com/render-tag/](https://polotno.com/render-tag/)
6
6
 
@@ -8,6 +8,8 @@ Render HTML rich text onto a canvas element using pure 2D canvas API. No SVG, no
8
8
 
9
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
10
 
11
+ By design, render-tag focuses on **rich text only** — paragraphs, headings, lists, tables, inline formatting. It is not designed for interactive elements (buttons, inputs, iframes) or complex HTML layouts. This focus is what makes it fast.
12
+
11
13
  ## Install
12
14
 
13
15
  ```bash
@@ -17,85 +19,145 @@ npm install render-tag
17
19
  ## Usage
18
20
 
19
21
  ```typescript
20
- import { renderHTML } from 'render-tag';
22
+ import { render } from 'render-tag';
21
23
 
22
- const { canvas, height } = renderHTML(
23
- '<p>Hello <strong>world</strong></p>',
24
- { width: 400 }
25
- );
24
+ const { canvas, height } = render({
25
+ html: '<p>Hello <strong>world</strong></p>',
26
+ width: 400,
27
+ });
26
28
 
27
29
  document.body.appendChild(canvas);
28
30
  ```
29
31
 
30
32
  ### With CSS
31
33
 
34
+ Include `<style>` tags in your HTML string:
35
+
32
36
  ```typescript
33
- const { canvas } = renderHTML(
34
- '<p class="title">Styled text</p>',
35
- {
36
- width: 600,
37
- css: `
37
+ const { canvas } = render({
38
+ html: `
39
+ <style>
38
40
  .title {
39
41
  font-family: Georgia, serif;
40
42
  font-size: 24px;
41
43
  color: #1a1a1a;
42
44
  }
43
- `,
44
- }
45
- );
45
+ </style>
46
+ <p class="title">Styled text</p>
47
+ `,
48
+ width: 600,
49
+ });
46
50
  ```
47
51
 
48
- ### With web fonts
52
+ ### Font loading
53
+
54
+ `render` is **synchronous** and does not load fonts. You must ensure fonts are loaded before calling it. If a font isn't loaded, the browser falls back to a default font and text metrics will be wrong.
49
55
 
50
56
  ```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
- );
57
+ // Load fonts before rendering
58
+ await document.fonts.load('400 16px "Roboto"');
59
+ await document.fonts.load('700 16px "Roboto"');
60
+
61
+ // Now render — fonts are guaranteed to be available
62
+ const { canvas } = render({ html, width: 500 });
63
+
64
+ // Re-render if fonts load later
65
+ document.fonts.onloadingdone = () => {
66
+ render({ html, width: 500 });
67
+ };
64
68
  ```
65
69
 
70
+ You do **not** need to pass `@font-face` rules separately. As long as fonts are loaded in the document (via `<link>`, `@font-face` in a stylesheet, or the CSS Font Loading API), `render` can use them.
71
+
66
72
  ### High-DPI / Retina
67
73
 
74
+ `pixelRatio` defaults to `devicePixelRatio`, so HiDPI displays are sharp out of the box. Override if needed:
75
+
68
76
  ```typescript
69
- const { canvas } = renderHTML(html, {
70
- width: 600,
71
- pixelRatio: window.devicePixelRatio,
72
- });
77
+ const { canvas } = render({ html, width: 600, pixelRatio: 1 });
73
78
  ```
74
79
 
75
80
  ### Render onto existing canvas
76
81
 
77
82
  ```typescript
78
83
  const canvas = document.getElementById('my-canvas');
79
- renderHTML(html, { canvas, width: 800, height: 600 });
84
+ render({ html, canvas, width: 800, height: 600 });
85
+ ```
86
+
87
+ ### Render onto existing context
88
+
89
+ Draw directly onto a context you control (no canvas resizing or scaling):
90
+
91
+ ```typescript
92
+ const ctx = myCanvas.getContext('2d');
93
+ render({ html, ctx, width: 400 });
80
94
  ```
81
95
 
96
+ This is useful for compositing multiple renders onto one canvas or rendering onto an `OffscreenCanvas`.
97
+
82
98
  ## API
83
99
 
84
- ### `renderHTML(html, options): RenderResult`
100
+ ### `render(config): RenderResult`
101
+
102
+ All-in-one: compute layout and draw in a single call.
85
103
 
86
104
  | Option | Type | Default | Description |
87
105
  |---|---|---|---|
106
+ | `html` | `string` | *required* | HTML string to render (include `<style>` tags for CSS) |
88
107
  | `width` | `number` | *required* | Layout width in CSS pixels |
89
108
  | `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). |
109
+ | `ctx` | `CanvasRenderingContext2D` | | Existing context to draw onto (no resizing/scaling) |
110
+ | `canvas` | `HTMLCanvasElement \| OffscreenCanvas` | created | Target canvas element (mutually exclusive with `ctx`) |
111
+ | `pixelRatio` | `number` | `devicePixelRatio` | Device pixel ratio for sharp rendering |
112
+ | `accuracy` | `'balanced' \| 'performance'` | `'balanced'` | `'balanced'` uses DOM probes for cross-browser line height accuracy. `'performance'` uses pure canvas API only. |
94
113
 
95
- Returns `{ canvas: HTMLCanvasElement, height: number }`.
114
+ Returns `{ canvas, height, layoutRoot, lines }`.
96
115
 
97
116
  The function is **synchronous**. Fonts must be loaded before calling.
98
117
 
118
+ ### `layout(config): LayoutResult`
119
+
120
+ Compute layout without rendering. Use when you need to measure content or render the same layout onto multiple targets.
121
+
122
+ | Option | Type | Default | Description |
123
+ |---|---|---|---|
124
+ | `html` | `string` | *required* | HTML string (include `<style>` tags for CSS) |
125
+ | `width` | `number` | *required* | Layout width in CSS pixels |
126
+ | `height` | `number` | auto | Fixed height (auto-sized from content if omitted) |
127
+ | `accuracy` | `'balanced' \| 'performance'` | `'balanced'` | Measurement accuracy mode |
128
+
129
+ Returns `{ layoutRoot, height, lines }`.
130
+
131
+ ### `drawLayout(config): { canvas }`
132
+
133
+ Draw a pre-computed layout onto a canvas or context.
134
+
135
+ | Option | Type | Default | Description |
136
+ |---|---|---|---|
137
+ | `layout` | `LayoutResult` | *required* | Result from `layout()` |
138
+ | `width` | `number` | *required* | Width used during layout (must match) |
139
+ | `ctx` | `CanvasRenderingContext2D` | — | Existing context to draw onto (no resizing/scaling) |
140
+ | `canvas` | `HTMLCanvasElement \| OffscreenCanvas` | created | Target canvas (mutually exclusive with `ctx`) |
141
+ | `pixelRatio` | `number` | `devicePixelRatio` | Device pixel ratio |
142
+
143
+ ### Example: layout once, draw many
144
+
145
+ ```typescript
146
+ import { layout, drawLayout } from 'render-tag';
147
+
148
+ const result = layout({ html, width: 400 });
149
+ console.log('content height:', result.height);
150
+
151
+ // Draw onto a thumbnail canvas
152
+ drawLayout({ layout: result, width: 400, canvas: thumbnailCanvas });
153
+
154
+ // Draw onto the main canvas
155
+ drawLayout({ layout: result, width: 400, canvas: mainCanvas });
156
+
157
+ // Draw onto an existing context
158
+ drawLayout({ layout: result, width: 400, ctx: offscreenCtx });
159
+ ```
160
+
99
161
  ## What it renders
100
162
 
101
163
  - Paragraphs, headings, divs, spans
@@ -105,7 +167,6 @@ The function is **synchronous**. Fonts must be loaded before calling.
105
167
  - Line height, letter spacing, text alignment (left/center/right/justify)
106
168
  - Ordered and unordered lists with nesting
107
169
  - Inline styles and CSS classes
108
- - `@font-face` web fonts (loaded automatically)
109
170
  - Flexbox layout (row/column)
110
171
  - Table layout (basic)
111
172
  - Text shadows, text stroke, gradient text
@@ -113,6 +174,7 @@ The function is **synchronous**. Fonts must be loaded before calling.
113
174
  - RTL text, CJK characters, emoji
114
175
  - `pre-wrap` whitespace handling
115
176
  - `overflow-wrap: break-word`
177
+ - Soft hyphens (`&shy;`)
116
178
 
117
179
  ## Cross-browser consistency
118
180
 
@@ -130,16 +192,16 @@ li::marker { content: none; font-size: 0; line-height: 0; }
130
192
  .has-emoji { font-kerning: none; }
131
193
  ```
132
194
 
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.
195
+ With `accuracy: 'balanced'` (the default), the library uses hidden DOM probes to match Firefox's actual line box heights. With `accuracy: 'performance'`, the CSS above becomes especially important for Firefox consistency.
134
196
 
135
197
  ## How it works
136
198
 
137
199
  1. **Parse** HTML with `DOMParser`
138
200
  2. **Resolve styles** via hidden DOM + `getComputedStyle` (CSS cascade for free)
139
- 3. **Layout** with pure canvas `measureText` (block flow, inline wrapping, margin collapsing)
201
+ 3. **Layout** with canvas `measureText` (block flow, inline wrapping, margin collapsing)
140
202
  4. **Render** with canvas 2D API (`fillText`, `fillRect`, `strokeText`, etc.)
141
203
 
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.
204
+ Style resolution uses a hidden DOM element with `getComputedStyle` to get the full CSS cascade. Layout and rendering are done entirely with the canvas 2D API. Optional DOM probes (`accuracy: 'balanced'`) improve cross-browser accuracy for line heights and mixed-font wrapping.
143
205
 
144
206
  ## Development
145
207
 
package/lib/index.d.ts CHANGED
@@ -1,12 +1,25 @@
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
1
+ import type { RenderConfig, RenderOptions, RenderResult, LayoutConfig, LayoutResult, DrawConfig, LayoutLine, AnyCanvas } from './types.js';
2
+ export type { RenderConfig, RenderOptions, RenderResult, LayoutConfig, LayoutResult, DrawConfig, LayoutLine };
3
+ /**
4
+ * Compute layout for an HTML string without rendering.
5
+ * Returns a reusable LayoutResult that can be drawn onto multiple targets via drawLayout().
6
+ */
7
+ export declare function layout(config: LayoutConfig): LayoutResult;
8
+ /**
9
+ * Draw a pre-computed layout onto a canvas or context.
10
+ * Use with layout() to render the same content onto multiple targets.
11
+ */
12
+ export declare function drawLayout(config: DrawConfig): {
13
+ canvas: AnyCanvas;
14
+ };
15
+ /**
16
+ * Render an HTML string onto a canvas using pure 2D canvas API.
17
+ * Convenience function combining layout() + drawLayout().
18
+ * Fonts must already be loaded before calling this function.
19
+ */
20
+ export declare function render(config: RenderConfig): RenderResult;
21
+ /**
22
+ * @deprecated Use `render()` instead.
10
23
  */
11
24
  export declare function renderHTML(html: string, options: RenderOptions): RenderResult;
12
25
  //# sourceMappingURL=index.d.ts.map
@@ -1 +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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,YAAY,EAAE,aAAa,EAAE,YAAY,EACzC,YAAY,EAAE,YAAY,EAAE,UAAU,EACtC,UAAU,EAAc,SAAS,EAClC,MAAM,YAAY,CAAC;AAMpB,YAAY,EAAE,YAAY,EAAE,aAAa,EAAE,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,UAAU,EAAE,UAAU,EAAE,CAAC;AAmC9G;;;GAGG;AACH,wBAAgB,MAAM,CAAC,MAAM,EAAE,YAAY,GAAG,YAAY,CA6BzD;AAID;;;GAGG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,UAAU,GAAG;IAAE,MAAM,EAAE,SAAS,CAAA;CAAE,CAyCpE;AAID;;;;GAIG;AACH,wBAAgB,MAAM,CAAC,MAAM,EAAE,YAAY,GAAG,YAAY,CA2BzD;AAID;;GAEG;AACH,wBAAgB,UAAU,CACxB,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,aAAa,GACrB,YAAY,CAUd"}
package/lib/index.js CHANGED
@@ -2,51 +2,19 @@ import { parseHTML } from './parse.js';
2
2
  import { resolveStyles } from './style-resolver.js';
3
3
  import { buildLayoutTree } from './layout.js';
4
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.
5
+ // ─── Line extraction ─────────────────────────────────────────────────
6
+ function extractLines(root) {
39
7
  const wordPositions = [];
40
- function walkLines(node) {
8
+ function walk(node) {
41
9
  if (node.type === 'text' && node.text.trim()) {
42
10
  wordPositions.push({ y: node.y, fontSize: node.style.fontSize, text: node.text });
43
11
  }
44
12
  if (node.type === 'box') {
45
13
  for (const child of node.children)
46
- walkLines(child);
14
+ walk(child);
47
15
  }
48
16
  }
49
- walkLines(root);
17
+ walk(root);
50
18
  wordPositions.sort((a, b) => a.y - b.y);
51
19
  const lines = [];
52
20
  let lineMaxFontSize = 0;
@@ -62,8 +30,114 @@ export function renderHTML(html, options) {
62
30
  lineMaxFontSize = wp.fontSize;
63
31
  }
64
32
  }
65
- // 8. Cleanup
33
+ return lines;
34
+ }
35
+ // ─── layout() ────────────────────────────────────────────────────────
36
+ /**
37
+ * Compute layout for an HTML string without rendering.
38
+ * Returns a reusable LayoutResult that can be drawn onto multiple targets via drawLayout().
39
+ */
40
+ export function layout(config) {
41
+ const { html, width, height, accuracy = 'balanced', debug, } = config;
42
+ if (!width || width <= 0 || Number.isNaN(width)) {
43
+ throw new TypeError(`layout: width must be a positive number, got ${width}`);
44
+ }
45
+ const useDomMeasurements = accuracy === 'balanced';
46
+ const { fragment, css } = parseHTML(html);
47
+ const { tree, cleanup } = resolveStyles(fragment, css, width, height);
48
+ const tmpCanvas = document.createElement('canvas');
49
+ const measureCtx = tmpCanvas.getContext('2d');
50
+ measureCtx.fontKerning = 'normal';
51
+ const { root, height: contentHeight } = buildLayoutTree(measureCtx, tree, width, useDomMeasurements, debug);
52
+ const finalHeight = height || contentHeight;
53
+ const lines = extractLines(root);
66
54
  cleanup();
67
- return { canvas, height: finalHeight, layoutRoot: root, lines };
55
+ return { layoutRoot: root, height: finalHeight, lines };
56
+ }
57
+ // ─── drawLayout() ────────────────────────────────────────────────────
58
+ /**
59
+ * Draw a pre-computed layout onto a canvas or context.
60
+ * Use with layout() to render the same content onto multiple targets.
61
+ */
62
+ export function drawLayout(config) {
63
+ const { layout: layoutResult, width, pixelRatio = globalThis.devicePixelRatio ?? 1, } = config;
64
+ if (config.ctx && config.canvas) {
65
+ throw new TypeError('drawLayout: ctx and canvas are mutually exclusive — provide one or neither');
66
+ }
67
+ const finalHeight = layoutResult.height;
68
+ let canvas;
69
+ let renderCtx;
70
+ if (config.ctx) {
71
+ renderCtx = config.ctx;
72
+ canvas = config.ctx.canvas;
73
+ }
74
+ else if (config.canvas) {
75
+ canvas = config.canvas;
76
+ canvas.width = Math.ceil(width * pixelRatio);
77
+ canvas.height = Math.ceil(finalHeight * pixelRatio);
78
+ if ('style' in canvas) {
79
+ canvas.style.width = `${width}px`;
80
+ canvas.style.height = `${finalHeight}px`;
81
+ }
82
+ renderCtx = canvas.getContext('2d');
83
+ renderCtx.scale(pixelRatio, pixelRatio);
84
+ }
85
+ else {
86
+ canvas = document.createElement('canvas');
87
+ canvas.width = Math.ceil(width * pixelRatio);
88
+ canvas.height = Math.ceil(finalHeight * pixelRatio);
89
+ canvas.style.width = `${width}px`;
90
+ canvas.style.height = `${finalHeight}px`;
91
+ renderCtx = canvas.getContext('2d');
92
+ renderCtx.scale(pixelRatio, pixelRatio);
93
+ }
94
+ renderNode(renderCtx, layoutResult.layoutRoot);
95
+ return { canvas };
96
+ }
97
+ // ─── render() ────────────────────────────────────────────────────────
98
+ /**
99
+ * Render an HTML string onto a canvas using pure 2D canvas API.
100
+ * Convenience function combining layout() + drawLayout().
101
+ * Fonts must already be loaded before calling this function.
102
+ */
103
+ export function render(config) {
104
+ if (config.ctx && config.canvas) {
105
+ throw new TypeError('render: ctx and canvas are mutually exclusive — provide one or neither');
106
+ }
107
+ const layoutResult = layout({
108
+ html: config.html,
109
+ width: config.width,
110
+ height: config.height,
111
+ accuracy: config.accuracy,
112
+ debug: config.debug,
113
+ });
114
+ const { canvas } = drawLayout({
115
+ layout: layoutResult,
116
+ width: config.width,
117
+ ctx: config.ctx,
118
+ canvas: config.canvas,
119
+ pixelRatio: config.pixelRatio,
120
+ });
121
+ return {
122
+ canvas,
123
+ height: layoutResult.height,
124
+ layoutRoot: layoutResult.layoutRoot,
125
+ lines: layoutResult.lines,
126
+ };
127
+ }
128
+ // ─── Deprecated ──────────────────────────────────────────────────────
129
+ /**
130
+ * @deprecated Use `render()` instead.
131
+ */
132
+ export function renderHTML(html, options) {
133
+ return render({
134
+ html: options.css ? `<style>${options.css}</style>${html}` : html,
135
+ width: options.width,
136
+ height: options.height,
137
+ canvas: options.canvas,
138
+ pixelRatio: options.pixelRatio ?? 1,
139
+ accuracy: options.useDomMeasurements === false ? 'performance' : 'balanced',
140
+ debug: options.debug,
141
+ });
68
142
  }
69
143
  //# sourceMappingURL=index.js.map
package/lib/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -1 +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"}
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;AA83CD;;;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"}
package/lib/layout.js CHANGED
@@ -309,10 +309,31 @@ function getSegmenter() {
309
309
  function tokenizeString(ctx, text, run, allWords) {
310
310
  // Split on zero-width spaces and soft hyphens (break opportunities)
311
311
  if (text.includes('\u200B') || text.includes('\u00AD')) {
312
- const parts = text.split(/[\u200B\u00AD]/);
312
+ // Split but keep delimiters to distinguish soft hyphens from zero-width spaces
313
+ const parts = text.split(/(\u200B|\u00AD)/);
314
+ let nextIsSoftHyphen = false;
313
315
  for (const part of parts) {
314
- if (part)
315
- tokenizeString(ctx, part, run, allWords);
316
+ if (part === '\u00AD') {
317
+ // Mark the PREVIOUS word as a soft-hyphen break point
318
+ nextIsSoftHyphen = true;
319
+ continue;
320
+ }
321
+ if (part === '\u200B' || part === '') {
322
+ nextIsSoftHyphen = false;
323
+ continue;
324
+ }
325
+ const prevLen = allWords.length;
326
+ tokenizeString(ctx, part, run, allWords);
327
+ // If the previous delimiter was a soft hyphen, mark the word
328
+ // just before this part as having a soft-hyphen break opportunity
329
+ if (nextIsSoftHyphen && prevLen > 0) {
330
+ allWords[prevLen - 1].isSoftHyphenBreak = true;
331
+ }
332
+ nextIsSoftHyphen = false;
333
+ }
334
+ // If the text ends with a soft hyphen, mark the last word
335
+ if (nextIsSoftHyphen && allWords.length > 0) {
336
+ allWords[allWords.length - 1].isSoftHyphenBreak = true;
316
337
  }
317
338
  return;
318
339
  }
@@ -509,7 +530,7 @@ function isCJK(char) {
509
530
  function breakWordIfNeeded(ctx, word, contentWidth, currentLineWidth) {
510
531
  // Check if word has CJK characters — always break at character level
511
532
  const hasCJK = [...word.text].some(isCJK);
512
- // Check if word needs break-word splitting
533
+ // Check if word needs break-word splitting — when it won't fit on a fresh line
513
534
  const needsBreak = word.width > contentWidth &&
514
535
  (word.style.overflowWrap === 'break-word' || word.style.wordBreak === 'break-all');
515
536
  if (!hasCJK && !needsBreak)
@@ -555,13 +576,29 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
555
576
  let currentLine = { words: [], totalWidth: 0, lineHeight: 0 };
556
577
  const noWrap = whiteSpace === 'nowrap' || whiteSpace === 'pre';
557
578
  const isPreWrap = whiteSpace === 'pre-wrap' || whiteSpace === 'pre' || whiteSpace === 'pre-line';
558
- function pushLine() {
579
+ function pushLine(isSoftWrap = false) {
559
580
  const hadWords = currentLine.words.length > 0;
560
581
  // Trim trailing spaces
561
582
  while (currentLine.words.length > 0 && currentLine.words[currentLine.words.length - 1].isSpace) {
562
583
  currentLine.totalWidth -= currentLine.words[currentLine.words.length - 1].width;
563
584
  currentLine.words.pop();
564
585
  }
586
+ // Soft hyphen: if this is a soft wrap and the last word has a soft-hyphen
587
+ // break, append a visible '-' since the word is being broken here.
588
+ if (isSoftWrap && currentLine.words.length > 0) {
589
+ const lastWord = currentLine.words[currentLine.words.length - 1];
590
+ if (lastWord.isSoftHyphenBreak) {
591
+ applyFont(ctx, lastWord.style);
592
+ const hyphenWidth = ctx.measureText('-').width;
593
+ currentLine.words.push({
594
+ text: '-',
595
+ width: hyphenWidth,
596
+ style: lastWord.style,
597
+ isSpace: false,
598
+ });
599
+ currentLine.totalWidth += hyphenWidth;
600
+ }
601
+ }
565
602
  // In pre-wrap mode, space-only lines still need height (they are content)
566
603
  if (currentLine.words.length > 0 || (hadWords && isPreWrap)) {
567
604
  if (_debug) {
@@ -609,8 +646,14 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
609
646
  ? breakWordIfNeeded(ctx, word, contentWidth, currentLine.totalWidth)
610
647
  : [word];
611
648
  for (const piece of pieces) {
649
+ // Trailing punctuation (e.g. comma after </span>) should not wrap
650
+ // independently — browsers keep it with the preceding word.
651
+ const isTrailingPunct = !piece.isSpace && piece.text.length > 0 &&
652
+ /^[,.\;:!?\)\]\}'"»›]+$/.test(piece.text) &&
653
+ currentLine.words.length > 0 &&
654
+ !currentLine.words[currentLine.words.length - 1].isSpace;
612
655
  // Would this piece overflow?
613
- if (!piece.isSpace && currentLine.words.length > 0 &&
656
+ if (!piece.isSpace && !isTrailingPunct && currentLine.words.length > 0 &&
614
657
  currentLine.totalWidth + piece.width > contentWidth) {
615
658
  const overflow = currentLine.totalWidth + piece.width - contentWidth;
616
659
  // For borderline cases (overflow < 1px), word-by-word delta
@@ -638,7 +681,7 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
638
681
  data: { text: piece.text, overflow, lineWidth: currentLine.totalWidth, pieceWidth: piece.width, contentWidth, lineText },
639
682
  });
640
683
  }
641
- pushLine();
684
+ pushLine(true);
642
685
  afterHardBreak = false;
643
686
  }
644
687
  }
@@ -662,7 +705,7 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
662
705
  data: { text: piece.text, remaining, domWidth, contentWidth },
663
706
  });
664
707
  }
665
- pushLine();
708
+ pushLine(true);
666
709
  afterHardBreak = false;
667
710
  }
668
711
  }