pagyra-js 0.0.21 → 0.0.23

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.
Files changed (92) hide show
  1. package/README.md +283 -264
  2. package/dist/browser/pagyra.min.js +30 -30
  3. package/dist/browser/pagyra.min.js.map +4 -4
  4. package/dist/src/css/apply-declarations.js +2 -1
  5. package/dist/src/css/clip-path-types.d.ts +9 -1
  6. package/dist/src/css/compute-style/overrides.js +10 -1
  7. package/dist/src/css/parsers/clip-path-parser.js +51 -0
  8. package/dist/src/css/parsers/register-parsers.js +21 -0
  9. package/dist/src/css/properties/visual.d.ts +2 -0
  10. package/dist/src/css/style.d.ts +5 -0
  11. package/dist/src/css/style.js +3 -0
  12. package/dist/src/css/ua-defaults/element-defaults.js +13 -0
  13. package/dist/src/dom/node.d.ts +2 -0
  14. package/dist/src/dom/node.js +1 -0
  15. package/dist/src/fonts/woff2/decoder.d.ts +1 -9
  16. package/dist/src/fonts/woff2/decoder.js +6 -565
  17. package/dist/src/fonts/woff2/glyf-reconstructor.d.ts +54 -0
  18. package/dist/src/fonts/woff2/glyf-reconstructor.js +357 -0
  19. package/dist/src/fonts/woff2/hmtx-reconstructor.d.ts +5 -0
  20. package/dist/src/fonts/woff2/hmtx-reconstructor.js +42 -0
  21. package/dist/src/fonts/woff2/sfnt-builder.d.ts +7 -0
  22. package/dist/src/fonts/woff2/sfnt-builder.js +55 -0
  23. package/dist/src/fonts/woff2/utils.d.ts +12 -0
  24. package/dist/src/fonts/woff2/utils.js +111 -0
  25. package/dist/src/html-to-pdf/render-finalize.js +5 -1
  26. package/dist/src/layout/inline/run-placer.js +1 -1
  27. package/dist/src/layout/strategies/flex/alignment.d.ts +10 -0
  28. package/dist/src/layout/strategies/flex/alignment.js +91 -0
  29. package/dist/src/layout/strategies/flex/distributor.d.ts +5 -0
  30. package/dist/src/layout/strategies/flex/distributor.js +56 -0
  31. package/dist/src/layout/strategies/flex/line-builder.d.ts +5 -0
  32. package/dist/src/layout/strategies/flex/line-builder.js +55 -0
  33. package/dist/src/layout/strategies/flex/types.d.ts +27 -0
  34. package/dist/src/layout/strategies/flex/types.js +2 -0
  35. package/dist/src/layout/strategies/flex/utils.d.ts +12 -0
  36. package/dist/src/layout/strategies/flex/utils.js +113 -0
  37. package/dist/src/layout/strategies/flex.js +4 -308
  38. package/dist/src/layout/strategies/grid.js +0 -3
  39. package/dist/src/layout/strategies/table.js +85 -58
  40. package/dist/src/layout/utils/text-metrics.js +16 -8
  41. package/dist/src/pdf/font/embedder.js +3 -3
  42. package/dist/src/pdf/font/font-subset.js +1 -3
  43. package/dist/src/pdf/font/to-unicode.js +16 -16
  44. package/dist/src/pdf/layout-tree-builder.js +15 -9
  45. package/dist/src/pdf/renderer/box-painter.js +74 -9
  46. package/dist/src/pdf/renderers/text-renderer.d.ts +4 -2
  47. package/dist/src/pdf/renderers/text-renderer.js +52 -2
  48. package/dist/src/pdf/types.d.ts +16 -1
  49. package/dist/src/pdf/utils/clip-path-resolver.js +28 -12
  50. package/dist/src/pdf/utils/mask-resolver.d.ts +7 -0
  51. package/dist/src/pdf/utils/mask-resolver.js +25 -0
  52. package/dist/src/pdf/utils/node-text-run-factory.d.ts +2 -1
  53. package/dist/src/pdf/utils/node-text-run-factory.js +5 -26
  54. package/dist/src/pdf/utils/rounded-rect-to-path.d.ts +7 -0
  55. package/dist/src/pdf/utils/rounded-rect-to-path.js +86 -0
  56. package/dist/src/render/offset.d.ts +5 -0
  57. package/dist/src/render/offset.js +93 -9
  58. package/dist/src/text/line-breaker.js +31 -0
  59. package/dist/tests/css/clip-path-parser.spec.js +15 -8
  60. package/dist/tests/environment/path-resolution.spec.js +2 -1
  61. package/dist/tests/helpers/ai-layout-diagnostics.js +6 -6
  62. package/dist/tests/layout/container-query-units.spec.js +0 -7
  63. package/dist/tests/layout/inline-background-alignment.spec.js +6 -6
  64. package/dist/tests/layout/table-image-cell.spec.js +95 -0
  65. package/dist/tests/pdf/alignments.spec.js +12 -12
  66. package/dist/tests/pdf/clip-path.spec.js +3 -1
  67. package/dist/tests/pdf/form-text-encoding.spec.js +1 -1
  68. package/dist/tests/pdf/svg-stroke-dash.spec.js +8 -8
  69. package/dist/tests/pdf/text-transform-matrix.spec.js +1 -1
  70. package/dist/tests/pdf/xref-integrity.spec.js +1 -1
  71. package/dist/tests/verify-subset-multi.spec.js +14 -14
  72. package/dist/tests/verify-subset.spec.js +12 -12
  73. package/package.json +89 -71
  74. package/dist/src/image/js-png-backend.d.ts +0 -7
  75. package/dist/src/image/js-png-backend.js +0 -9
  76. package/dist/src/image/png-backend.d.ts +0 -5
  77. package/dist/src/image/png-wasm-loader.d.ts +0 -5
  78. package/dist/src/image/png-wasm-loader.js +0 -59
  79. package/dist/src/image/wasm/png_decoder_wasm.d.ts +0 -8
  80. package/dist/src/image/wasm/png_decoder_wasm.js +0 -24
  81. package/dist/src/image/wasm/png_decoder_wasm_bg.js +0 -16
  82. package/dist/src/image/wasm-png-backend.d.ts +0 -6
  83. package/dist/src/image/wasm-png-backend.js +0 -17
  84. package/dist/src/layout/table/cell_layout.d.ts +0 -2
  85. package/dist/src/layout/table/cell_layout.js +0 -26
  86. package/dist/tests/image/png-backend.spec.d.ts +0 -1
  87. package/dist/tests/image/png-backend.spec.js +0 -34
  88. package/dist/tests/pdf/font-subset-registry-key.spec.d.ts +0 -1
  89. package/dist/tests/pdf/font-subset-registry-key.spec.js +0 -66
  90. package/dist/tests/pdf/header-footer.spec.d.ts +0 -1
  91. package/dist/tests/pdf/header-footer.spec.js +0 -46
  92. /package/dist/{src/image/png-backend.js → tests/layout/table-image-cell.spec.d.ts} +0 -0
@@ -12,6 +12,11 @@ export interface PageVerticalMarginsOptions {
12
12
  /** Footer height in pixels (reduces available content area) */
13
13
  footerHeightPx?: number;
14
14
  }
15
+ /**
16
+ * Handles 'break-inside: avoid' by pushing content to the next page
17
+ * when a box would otherwise cross a page boundary.
18
+ */
19
+ export declare function applyBreakInsideAvoid(root: RenderBox, usablePageHeight: number): void;
15
20
  export declare function applyPageVerticalMargins(root: RenderBox, pageHeight: number, margins: PageMarginsPx): void;
16
21
  /**
17
22
  * Applies vertical margins and header/footer offsets to the render tree.
@@ -7,6 +7,20 @@ export function offsetRect(rect, dx, dy) {
7
7
  rect.x += dx;
8
8
  rect.y += dy;
9
9
  }
10
+ function offsetClipPath(box, dx, dy) {
11
+ if (!box.clipPath)
12
+ return;
13
+ if (box.clipPath.type === "polygon") {
14
+ for (const point of box.clipPath.points) {
15
+ point.x += dx;
16
+ point.y += dy;
17
+ }
18
+ }
19
+ else if (box.clipPath.type === "ellipse") {
20
+ box.clipPath.cx += dx;
21
+ box.clipPath.cy += dy;
22
+ }
23
+ }
10
24
  function offsetBackground(background, dx, dy) {
11
25
  if (!background) {
12
26
  return;
@@ -36,15 +50,14 @@ export function offsetRenderTree(root, dx, dy, _debug) {
36
50
  offsetRect(box.paddingBox, dx, dy);
37
51
  offsetRect(box.borderBox, dx, dy);
38
52
  offsetRect(box.visualOverflow, dx, dy);
39
- if (box.clipPath && box.clipPath.points) {
40
- for (const point of box.clipPath.points) {
41
- point.x += dx;
42
- point.y += dy;
43
- }
44
- }
53
+ offsetClipPath(box, dx, dy);
45
54
  if (box.markerRect) {
46
55
  offsetRect(box.markerRect, dx, dy);
47
56
  }
57
+ if (box.maskGradient) {
58
+ offsetRect(box.maskGradient.rect, dx, dy);
59
+ offsetRect(box.maskGradient.originRect, dx, dy);
60
+ }
48
61
  offsetBackground(box.background, dx, dy);
49
62
  for (const link of box.links) {
50
63
  offsetRect(link.rect, dx, dy);
@@ -60,6 +73,68 @@ export function offsetRenderTree(root, dx, dy, _debug) {
60
73
  }
61
74
  }
62
75
  }
76
+ /**
77
+ * Handles 'break-inside: avoid' by pushing content to the next page
78
+ * when a box would otherwise cross a page boundary.
79
+ */
80
+ export function applyBreakInsideAvoid(root, usablePageHeight) {
81
+ let globalOffset = 0;
82
+ function traverse(box) {
83
+ // Apply cumulative offset from previous breaks
84
+ if (globalOffset > 0) {
85
+ offsetBox(box, 0, globalOffset);
86
+ }
87
+ const rect = box.borderBox ?? box.contentBox;
88
+ const top = rect.y;
89
+ const bottom = rect.y + rect.height;
90
+ const startPage = Math.floor(top / usablePageHeight);
91
+ const endPage = Math.floor((bottom - 0.001) / usablePageHeight);
92
+ if (box.breakInside === "avoid" && startPage !== endPage) {
93
+ const nextPageTop = (startPage + 1) * usablePageHeight;
94
+ const pushDown = nextPageTop - top;
95
+ if (pushDown > 0) {
96
+ log("layout", "debug", `break-inside: avoid triggered for ${box.tagName} id:${box.id}. Pushing down by ${pushDown}px`, {
97
+ tagName: box.tagName,
98
+ id: box.id,
99
+ top,
100
+ bottom,
101
+ nextPageTop,
102
+ });
103
+ offsetBox(box, 0, pushDown);
104
+ globalOffset += pushDown;
105
+ }
106
+ }
107
+ // Recurse into children. They will be shifted by current globalOffset.
108
+ for (const child of box.children) {
109
+ traverse(child);
110
+ }
111
+ }
112
+ function offsetBox(box, dx, dy) {
113
+ offsetRect(box.contentBox, dx, dy);
114
+ offsetRect(box.paddingBox, dx, dy);
115
+ offsetRect(box.borderBox, dx, dy);
116
+ offsetRect(box.visualOverflow, dx, dy);
117
+ offsetClipPath(box, dx, dy);
118
+ if (box.markerRect) {
119
+ offsetRect(box.markerRect, dx, dy);
120
+ }
121
+ if (box.maskGradient) {
122
+ offsetRect(box.maskGradient.rect, dx, dy);
123
+ offsetRect(box.maskGradient.originRect, dx, dy);
124
+ }
125
+ offsetBackground(box.background, dx, dy);
126
+ for (const link of box.links) {
127
+ offsetRect(link.rect, dx, dy);
128
+ }
129
+ for (const run of box.textRuns) {
130
+ if (run.lineMatrix) {
131
+ run.lineMatrix.e += dx;
132
+ run.lineMatrix.f += dy;
133
+ }
134
+ }
135
+ }
136
+ traverse(root);
137
+ }
63
138
  export function applyPageVerticalMargins(root, pageHeight, margins) {
64
139
  applyPageVerticalMarginsWithHf(root, { pageHeight, margins });
65
140
  }
@@ -119,14 +194,23 @@ export function applyPageVerticalMarginsWithHf(root, options) {
119
194
  adjustRect(box.paddingBox);
120
195
  adjustRect(box.borderBox);
121
196
  adjustRect(box.visualOverflow);
122
- if (box.clipPath && box.clipPath.points) {
123
- for (const point of box.clipPath.points) {
124
- point.y = mapY(point.y);
197
+ if (box.clipPath) {
198
+ if (box.clipPath.type === "polygon") {
199
+ for (const point of box.clipPath.points) {
200
+ point.y = mapY(point.y);
201
+ }
202
+ }
203
+ else if (box.clipPath.type === "ellipse") {
204
+ box.clipPath.cy = mapY(box.clipPath.cy);
125
205
  }
126
206
  }
127
207
  if (box.markerRect) {
128
208
  adjustRect(box.markerRect);
129
209
  }
210
+ if (box.maskGradient) {
211
+ adjustRect(box.maskGradient.rect);
212
+ adjustRect(box.maskGradient.originRect);
213
+ }
130
214
  adjustBackground(box.background);
131
215
  for (const link of box.links) {
132
216
  adjustRect(link.rect);
@@ -91,6 +91,34 @@ function enforceOverflowWrap(items, style, availableWidth, mode) {
91
91
  }
92
92
  return adjusted.length ? adjusted : items;
93
93
  }
94
+ // Splits any word wider than the available width, regardless of the overflow-wrap mode.
95
+ // Unlike enforceOverflowWrap (which only acts for break-word/anywhere), this is a safety net to
96
+ // keep an over-long, otherwise-unbreakable token (e.g. a long URL) from running off the page.
97
+ function enforceEmergencyBreak(items, style, availableWidth) {
98
+ if (!(availableWidth > 0)) {
99
+ return items;
100
+ }
101
+ let needsBreak = false;
102
+ for (const item of items) {
103
+ if (item.type === "word" && item.width > availableWidth) {
104
+ needsBreak = true;
105
+ break;
106
+ }
107
+ }
108
+ if (!needsBreak) {
109
+ return items;
110
+ }
111
+ const adjusted = [];
112
+ for (const item of items) {
113
+ if (item.type === "word" && item.width > availableWidth) {
114
+ adjusted.push(...splitWordItem(item, style, availableWidth));
115
+ }
116
+ else {
117
+ adjusted.push(item);
118
+ }
119
+ }
120
+ return adjusted.length ? adjusted : items;
121
+ }
94
122
  function countJustifiableSpaces(items) {
95
123
  let count = 0;
96
124
  for (let index = 0; index < items.length; index++) {
@@ -156,6 +184,9 @@ export function breakTextIntoLines(text, style, availableWidth, fontEmbedder = n
156
184
  const rawItems = segmentText(effectiveText);
157
185
  let items = measureItems(rawItems, style, fontEmbedder);
158
186
  items = enforceOverflowWrap(items, style, availableWidth, style.overflowWrap);
187
+ // Last-resort break: a single word wider than the whole line would otherwise overflow off the
188
+ // page (lost content in a fixed-size PDF). Break it to fit even when overflow-wrap is `normal`.
189
+ items = enforceEmergencyBreak(items, style, availableWidth);
159
190
  const n = items.length;
160
191
  if (n === 0)
161
192
  return [];
@@ -12,18 +12,23 @@ describe("clip-path parser", () => {
12
12
  parseClipPath("polygon(50% 0, 0 100%, 100% 100%)", target);
13
13
  expect(target.clipPath).toBeDefined();
14
14
  expect(target.clipPath?.type).toBe("polygon");
15
- const points = target.clipPath?.points;
16
- expect(points).toBeDefined();
17
- expect(points?.length).toBe(3);
18
- expect(points?.[0].x.unit).toBe("percent");
19
- expect(points?.[1].y.unit).toBe("percent");
15
+ const clip = target.clipPath;
16
+ expect(clip.type).toBe("polygon");
17
+ if (clip.type !== "polygon")
18
+ return;
19
+ expect(clip.points).toBeDefined();
20
+ expect(clip.points.length).toBe(3);
21
+ expect(clip.points[0].x.unit).toBe("percent");
22
+ expect(clip.points[1].y.unit).toBe("percent");
20
23
  });
21
24
  it("parses polygon clip-path with px units", () => {
22
25
  const target = {};
23
26
  parseClipPath("polygon(0px 0px, 100px 0px, 0px 100px)", target);
24
27
  expect(target.clipPath).toBeDefined();
25
- expect(target.clipPath?.points[2].y.unit).toBe("px");
26
- expect(target.clipPath?.points[2].y.value).toBe(100);
28
+ if (target.clipPath?.type !== "polygon")
29
+ return;
30
+ expect(target.clipPath.points[2].y.unit).toBe("px");
31
+ expect(target.clipPath.points[2].y.value).toBe(100);
27
32
  });
28
33
  it("ignores unsupported clip-path values", () => {
29
34
  const target = {};
@@ -56,6 +61,8 @@ describe("clip-path parser", () => {
56
61
  const style = computeStyleForElement(element, [], new ComputedStyle(), makeUnitParsers({ viewport: { width: 800, height: 600 } }), 16);
57
62
  expect(style.clipPath).toBeDefined();
58
63
  expect(style.clipPath?.type).toBe("polygon");
59
- expect(style.clipPath?.points).toHaveLength(3);
64
+ if (style.clipPath?.type !== "polygon")
65
+ return;
66
+ expect(style.clipPath.points).toHaveLength(3);
60
67
  });
61
68
  });
@@ -1,9 +1,10 @@
1
1
  import { NodeEnvironment } from '../../src/environment/node-environment.js';
2
2
  import { BrowserEnvironment } from '../../src/environment/browser-environment.js';
3
3
  import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
4
5
  describe('Path Resolution', () => {
5
6
  describe('NodeEnvironment', () => {
6
- const toPath = (p) => p.startsWith('file:') ? require('node:url').fileURLToPath(p) : p;
7
+ const toPath = (p) => p.startsWith('file:') ? fileURLToPath(p) : p;
7
8
  it('resolves leading-slash paths relative to base', () => {
8
9
  const env = new NodeEnvironment();
9
10
  const base = path.resolve('/var/www/public');
@@ -51,12 +51,12 @@ export function renderAsciiLayout() {
51
51
  const contentH = Math.round(contentBox.height);
52
52
  const spanW = Math.round(spanBox.width);
53
53
  const spanH = Math.round(spanBox.height);
54
- const ascii = `Border Box (W:${borderW}, H:${borderH}) |#########################|
55
- Content Box at (${Math.round(contentX)}, ${Math.round(contentY)}) (W:${contentW}, H:${contentH}) | +---+ |
56
- | |###| |
57
- | +---+ |
58
- Span Box at (${Math.round(spanX)}, ${Math.round(spanY)}) (W:${spanW}, H:${spanH}) | [xxx] |
59
- Padding: T:${padding.top}, R:${padding.right}, B:${padding.bottom}, L:${padding.left}
54
+ const ascii = `Border Box (W:${borderW}, H:${borderH}) |#########################|
55
+ Content Box at (${Math.round(contentX)}, ${Math.round(contentY)}) (W:${contentW}, H:${contentH}) | +---+ |
56
+ | |###| |
57
+ | +---+ |
58
+ Span Box at (${Math.round(spanX)}, ${Math.round(spanY)}) (W:${spanW}, H:${spanH}) | [xxx] |
59
+ Padding: T:${padding.top}, R:${padding.right}, B:${padding.bottom}, L:${padding.left}
60
60
  Border: T:${border.top}, R:${border.right}, B:${border.bottom}, L:${border.left}`;
61
61
  context.ascii = ascii;
62
62
  return ascii;
@@ -1,11 +1,4 @@
1
1
  import { collectBoxes, renderTreeForHtml } from "../helpers/render-utils.js";
2
- function findByTag(boxes, tagName) {
3
- const found = boxes.find((b) => b.tagName === tagName);
4
- if (!found) {
5
- throw new Error(`Expected to find <${tagName}> in render tree`);
6
- }
7
- return found;
8
- }
9
2
  describe("container query unit layout", () => {
10
3
  it("resolves cqw/cqh/cqmin/cqmax against containing block dimensions", async () => {
11
4
  const html = "<!DOCTYPE html><html><body style=\"margin:0;\"><section style=\"width:400px;height:300px;\"><div id=\"a\" style=\"width:50cqw;height:25cqh;\"></div><div id=\"b\" style=\"width:10cqmin;height:10cqmax;\"></div></section></body></html>";
@@ -5,8 +5,8 @@ const POSITION_TOLERANCE_PX = 10;
5
5
  const WIDTH_TOLERANCE_RATIO = 0.9;
6
6
  describe("inline background alignment", () => {
7
7
  it("aligns background with text for inline span elements", async () => {
8
- const html = `
9
- <p>Normal text <span style="background-color: yellow">highlighted text</span> after.</p>
8
+ const html = `
9
+ <p>Normal text <span style="background-color: yellow">highlighted text</span> after.</p>
10
10
  `;
11
11
  const renderTree = await renderTreeForHtml(html);
12
12
  const boxes = collectBoxes(renderTree.root);
@@ -28,8 +28,8 @@ describe("inline background alignment", () => {
28
28
  }
29
29
  });
30
30
  it("calculates correct width for inline span with multiple text fragments", async () => {
31
- const html = `
32
- <p>Before <span style="background-color: lightblue">text one two three</span> after.</p>
31
+ const html = `
32
+ <p>Before <span style="background-color: lightblue">text one two three</span> after.</p>
33
33
  `;
34
34
  const renderTree = await renderTreeForHtml(html);
35
35
  const boxes = collectBoxes(renderTree.root);
@@ -56,8 +56,8 @@ describe("inline background alignment", () => {
56
56
  }
57
57
  });
58
58
  it("positions background correctly when span follows other content", async () => {
59
- const html = `
60
- <p>Some initial text <span style="background-color: pink">highlighted</span> end.</p>
59
+ const html = `
60
+ <p>Some initial text <span style="background-color: pink">highlighted</span> end.</p>
61
61
  `;
62
62
  const renderTree = await renderTreeForHtml(html);
63
63
  const boxes = collectBoxes(renderTree.root);
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { collectBoxes, collectRuns, renderTreeForHtml } from "../helpers/render-utils.js";
3
+ // Minimal valid 1×1 PNG used as image payload; intrinsic dimensions are
4
+ // overridden by the HTML width/height attributes in each test.
5
+ const ONE_BY_ONE_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADUlEQVR42mP8/5+hHgAHggJ/P95syQAAAABJRU5ErkJggg==";
6
+ describe("table cell with image content", () => {
7
+ it("row height equals the image height when the left cell contains only an image", async () => {
8
+ const html = `
9
+ <table>
10
+ <tr>
11
+ <td><img src="data:image/png;base64,${ONE_BY_ONE_PNG}" width="60" height="80" /></td>
12
+ <td><p style="font-size:12px;margin:0">Short text</p></td>
13
+ </tr>
14
+ </table>
15
+ `;
16
+ const tree = await renderTreeForHtml(html);
17
+ const boxes = collectBoxes(tree.root);
18
+ const table = boxes.find((b) => b.tagName === "table");
19
+ expect(table).toBeDefined();
20
+ // The row height must accommodate the 80px-tall image.
21
+ expect(table.contentBox.height).toBeGreaterThanOrEqual(80);
22
+ });
23
+ it("image-only cell has non-zero content height and the row has matching height", async () => {
24
+ const html = `
25
+ <table>
26
+ <tr>
27
+ <td><img src="data:image/png;base64,${ONE_BY_ONE_PNG}" width="40" height="50" /></td>
28
+ <td>label</td>
29
+ </tr>
30
+ </table>
31
+ `;
32
+ const tree = await renderTreeForHtml(html);
33
+ const boxes = collectBoxes(tree.root);
34
+ const img = boxes.find((b) => b.tagName === "img");
35
+ expect(img).toBeDefined();
36
+ expect(img.contentBox.height).toBeGreaterThanOrEqual(50);
37
+ });
38
+ it("two-row table with image cells renders without zero-height rows", async () => {
39
+ const html = `
40
+ <table>
41
+ <tr>
42
+ <td><img src="data:image/png;base64,${ONE_BY_ONE_PNG}" width="50" height="60" /></td>
43
+ <td>Row 1 text</td>
44
+ </tr>
45
+ <tr>
46
+ <td><img src="data:image/png;base64,${ONE_BY_ONE_PNG}" width="50" height="70" /></td>
47
+ <td>Row 2 text</td>
48
+ </tr>
49
+ </table>
50
+ `;
51
+ const tree = await renderTreeForHtml(html);
52
+ const boxes = collectBoxes(tree.root);
53
+ const table = boxes.find((b) => b.tagName === "table");
54
+ expect(table).toBeDefined();
55
+ // Total height must fit both image rows (60 + 70 = 130 minimum).
56
+ expect(table.contentBox.height).toBeGreaterThanOrEqual(130);
57
+ });
58
+ });
59
+ describe("table cell with long mixed inline content (SEI signature regression)", () => {
60
+ // Mirrors the SEI/ERJ signature block: a narrow image column on the left and a long
61
+ // paragraph with mixed inline content (text + <b> + <a>) on the right. The paragraph
62
+ // must wrap into several stacked lines instead of overlapping at a single position, and
63
+ // the table must not overflow the page width.
64
+ const html = `
65
+ <table>
66
+ <tr>
67
+ <td><img src="data:image/png;base64,${ONE_BY_ONE_PNG}" width="60" height="80" /></td>
68
+ <td>
69
+ <p style="margin:0;font-size:11px;font-family:Calibri">Documento assinado eletronicamente por <b>Carlos Eduardo Carvalho Mendes</b>, <b>Assessor</b>, em 30/05/2025, conforme horario oficial de Brasilia, com fundamento no artigo quinto do <a href="http://example.gov">Decreto numero 46.126 de 20 de outubro de 2017</a> publicado oficialmente.</p>
70
+ </td>
71
+ </tr>
72
+ </table>
73
+ `;
74
+ it("wraps the paragraph into multiple stacked lines (no overlap)", async () => {
75
+ const tree = await renderTreeForHtml(html);
76
+ const runs = collectRuns(tree.root).filter((r) => r.text.trim().length > 0);
77
+ expect(runs.length).toBeGreaterThan(1);
78
+ // Collect distinct baseline Y positions (lineMatrix.f). Wrapped text must occupy
79
+ // several different vertical positions, not a single overlapping line.
80
+ const ys = runs.map((r) => Math.round(r.lineMatrix.f));
81
+ const distinctYs = new Set(ys);
82
+ expect(distinctYs.size).toBeGreaterThan(2);
83
+ // No two runs may sit at exactly the same (x, y) origin — that is the overlap signature.
84
+ const origins = runs.map((r) => `${Math.round(r.lineMatrix.e)}:${Math.round(r.lineMatrix.f)}`);
85
+ expect(new Set(origins).size).toBe(origins.length);
86
+ });
87
+ it("does not overflow the page content width", async () => {
88
+ const tree = await renderTreeForHtml(html);
89
+ const boxes = collectBoxes(tree.root);
90
+ const table = boxes.find((b) => b.tagName === "table");
91
+ expect(table).toBeDefined();
92
+ // Default test page is 794px wide with 48px margins => 698px of content width.
93
+ expect(table.borderBox.width).toBeLessThanOrEqual(698 + 1);
94
+ });
95
+ });
@@ -1,18 +1,18 @@
1
1
  import { renderRuns } from "../helpers/render-utils.js";
2
2
  describe("PDF text alignment (non-justify)", () => {
3
3
  it("does not apply justification to left/center/right alignments", async () => {
4
- const html = `
5
- <div>
6
- <p style="width: 260px; text-align: left;">
7
- Some sample text that wraps into multiple lines for left.
8
- </p>
9
- <p style="width: 260px; text-align: center;">
10
- Some sample text that wraps into multiple lines for center.
11
- </p>
12
- <p style="width: 260px; text-align: right;">
13
- Some sample text that wraps into multiple lines for right.
14
- </p>
15
- </div>
4
+ const html = `
5
+ <div>
6
+ <p style="width: 260px; text-align: left;">
7
+ Some sample text that wraps into multiple lines for left.
8
+ </p>
9
+ <p style="width: 260px; text-align: center;">
10
+ Some sample text that wraps into multiple lines for center.
11
+ </p>
12
+ <p style="width: 260px; text-align: right;">
13
+ Some sample text that wraps into multiple lines for right.
14
+ </p>
15
+ </div>
16
16
  `;
17
17
  const runs = await renderRuns(html);
18
18
  // no positive wordSpacing anywhere
@@ -39,7 +39,7 @@ describe("clip-path rendering", () => {
39
39
  },
40
40
  background: { color: { r: 0.2, g: 0.6, b: 0.9, a: 1 } },
41
41
  opacity: 1,
42
- overflow: Overflow.Visible,
42
+ overflow: Overflow.Visible, overflowX: Overflow.Visible, overflowY: Overflow.Visible,
43
43
  textRuns: [],
44
44
  decorations: {},
45
45
  textShadows: [],
@@ -69,6 +69,8 @@ describe("clip-path rendering", () => {
69
69
  const clipPath = div.clipPath;
70
70
  expect(clipPath).toBeDefined();
71
71
  expect(clipPath?.type).toBe("polygon");
72
+ if (clipPath.type !== "polygon")
73
+ return;
72
74
  const points = clipPath.points;
73
75
  expect(points).toHaveLength(3);
74
76
  const [a, b, c] = points;
@@ -47,7 +47,7 @@ describe("form text encoding", () => {
47
47
  },
48
48
  background: {},
49
49
  opacity: 1,
50
- overflow: Overflow.Visible,
50
+ overflow: Overflow.Visible, overflowX: Overflow.Visible, overflowY: Overflow.Visible,
51
51
  textRuns: [],
52
52
  decorations: {},
53
53
  textShadows: [],
@@ -7,10 +7,10 @@ import { GraphicsStateManager } from "../../src/pdf/renderers/graphics-state-man
7
7
  import { parseHTML } from "linkedom";
8
8
  describe("SVG Stroke Dash Support", () => {
9
9
  it("should render path with stroke-dasharray", async () => {
10
- const { document } = parseHTML(`
11
- <svg>
12
- <path d="M10 10 L90 10" stroke="black" stroke-width="2" stroke-dasharray="5,5" />
13
- </svg>
10
+ const { document } = parseHTML(`
11
+ <svg>
12
+ <path d="M10 10 L90 10" stroke="black" stroke-width="2" stroke-dasharray="5,5" />
13
+ </svg>
14
14
  `);
15
15
  const pathElement = document.querySelector("path");
16
16
  const context = { warn: () => { } };
@@ -36,10 +36,10 @@ describe("SVG Stroke Dash Support", () => {
36
36
  expect(commands).toContain("[3.75 3.75] 0 d");
37
37
  });
38
38
  it("should render path with stroke-dasharray and stroke-dashoffset", async () => {
39
- const { document } = parseHTML(`
40
- <svg>
41
- <path d="M10 30 L90 30" stroke="black" stroke-width="2" stroke-dasharray="10 5" stroke-dashoffset="5" />
42
- </svg>
39
+ const { document } = parseHTML(`
40
+ <svg>
41
+ <path d="M10 30 L90 30" stroke="black" stroke-width="2" stroke-dasharray="10 5" stroke-dashoffset="5" />
42
+ </svg>
43
43
  `);
44
44
  const pathElement = document.querySelector("path");
45
45
  const context = { warn: () => { } };
@@ -1,4 +1,4 @@
1
- import { renderRuns, renderTreeForHtml, collectBoxes, collectRuns } from "../helpers/render-utils.js";
1
+ import { renderTreeForHtml, collectBoxes, collectRuns } from "../helpers/render-utils.js";
2
2
  const TAN_20 = Math.tan((20 * Math.PI) / 180);
3
3
  describe("PDF text transforms", () => {
4
4
  it("applies skewX to text runs (keeps linear components in CSS space)", async () => {
@@ -82,7 +82,7 @@ describe("xref table integrity", () => {
82
82
  const streamData = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]);
83
83
  const streamRef = doc.registerStream(streamData);
84
84
  const patternRef = doc.registerPattern("P0", "<< /PatternType 2 /Shading null >>");
85
- const customRef = doc.register({ Type: "/Custom", Value: 42 });
85
+ doc.register({ Type: "/Custom", Value: 42 });
86
86
  const fontRef = doc.registerStandardFont("Courier");
87
87
  const shadingRef = doc.registerShading("S0", "<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 1 1] /Function null >>");
88
88
  doc.addPage({
@@ -5,20 +5,20 @@ import fs from 'node:fs';
5
5
  describe('Multi-Font Subsetting Verification', () => {
6
6
  it('should generate a small PDF with Tinos and Arimo fonts', async () => {
7
7
  process.env.PAGYRA_FONTS_DIR = path.resolve(process.cwd(), 'assets/fonts');
8
- const html = `
9
- <!DOCTYPE html>
10
- <html>
11
- <head>
12
- <style>
13
- .serif { font-family: 'Tinos', serif; font-size: 24px; }
14
- .sans { font-family: 'Arimo', sans-serif; font-size: 24px; }
15
- </style>
16
- </head>
17
- <body>
18
- <div class="serif">Serif Text: Hello World with Tinos! - ação, acentuação, coração</div>
19
- <div class="sans">Sans-Serif Text: Hello World with Arimo! - ação, acentuação, coração</div>
20
- </body>
21
- </html>
8
+ const html = `
9
+ <!DOCTYPE html>
10
+ <html>
11
+ <head>
12
+ <style>
13
+ .serif { font-family: 'Tinos', serif; font-size: 24px; }
14
+ .sans { font-family: 'Arimo', sans-serif; font-size: 24px; }
15
+ </style>
16
+ </head>
17
+ <body>
18
+ <div class="serif">Serif Text: Hello World with Tinos! - ação, acentuação, coração</div>
19
+ <div class="sans">Sans-Serif Text: Hello World with Arimo! - ação, acentuação, coração</div>
20
+ </body>
21
+ </html>
22
22
  `;
23
23
  const pdfBuffer = await renderHtmlToPdf({
24
24
  html,
@@ -6,18 +6,18 @@ describe('Font Subsetting Verification', () => {
6
6
  it('should generate a small PDF with Tinos font', async () => {
7
7
  // Point to assets relative to CWD (project root)
8
8
  process.env.PAGYRA_FONTS_DIR = path.resolve(process.cwd(), 'assets/fonts');
9
- const html = `
10
- <!DOCTYPE html>
11
- <html>
12
- <head>
13
- <style>
14
- body { font-family: 'Tinos', serif; font-size: 24px; }
15
- </style>
16
- </head>
17
- <body>
18
- Hello World with Tinos!
19
- </body>
20
- </html>
9
+ const html = `
10
+ <!DOCTYPE html>
11
+ <html>
12
+ <head>
13
+ <style>
14
+ body { font-family: 'Tinos', serif; font-size: 24px; }
15
+ </style>
16
+ </head>
17
+ <body>
18
+ Hello World with Tinos!
19
+ </body>
20
+ </html>
21
21
  `;
22
22
  const pdfBuffer = await renderHtmlToPdf({
23
23
  html,