@jasy/pdf 1.0.0-alpha.9 → 1.0.0-beta.1

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 (110) hide show
  1. package/README.md +27 -9
  2. package/dist/api/color.js +69 -0
  3. package/dist/api/content.d.ts +40 -1
  4. package/dist/api/content.js +83 -0
  5. package/dist/api/descriptor.js +6 -1
  6. package/dist/api/dimension.d.ts +3 -2
  7. package/dist/api/font-url.js +3 -2
  8. package/dist/api/structure.d.ts +7 -0
  9. package/dist/api/structure.js +38 -3
  10. package/dist/api/text.d.ts +23 -2
  11. package/dist/api/text.js +9 -0
  12. package/dist/canvas/painter.d.ts +102 -0
  13. package/dist/canvas/painter.js +183 -0
  14. package/dist/elements/canvas-element.d.ts +38 -0
  15. package/dist/elements/canvas-element.js +47 -0
  16. package/dist/elements/container-element.d.ts +2 -0
  17. package/dist/elements/container-element.js +6 -0
  18. package/dist/elements/index.d.ts +2 -0
  19. package/dist/elements/index.js +2 -0
  20. package/dist/elements/layout/expanded-element.d.ts +2 -0
  21. package/dist/elements/layout/expanded-element.js +4 -0
  22. package/dist/elements/layout/keep-together-element.d.ts +2 -0
  23. package/dist/elements/layout/keep-together-element.js +4 -0
  24. package/dist/elements/layout/padding-element.d.ts +5 -0
  25. package/dist/elements/layout/padding-element.js +8 -0
  26. package/dist/elements/layout/struct-group.d.ts +2 -0
  27. package/dist/elements/layout/struct-group.js +4 -0
  28. package/dist/elements/pdf-element.d.ts +19 -3
  29. package/dist/elements/pdf-element.js +22 -4
  30. package/dist/elements/rectangle-element.d.ts +2 -0
  31. package/dist/elements/rectangle-element.js +6 -0
  32. package/dist/elements/row-element.d.ts +2 -0
  33. package/dist/elements/row-element.js +6 -0
  34. package/dist/elements/svg-element.d.ts +41 -0
  35. package/dist/elements/svg-element.js +93 -0
  36. package/dist/elements/text-element.d.ts +33 -1
  37. package/dist/elements/text-element.js +80 -4
  38. package/dist/ir/display-list.d.ts +44 -5
  39. package/dist/layout/min-intrinsic.d.ts +29 -0
  40. package/dist/layout/min-intrinsic.js +37 -0
  41. package/dist/renderer/anchor-renderer.js +1 -1
  42. package/dist/renderer/bookmark-renderer.js +1 -1
  43. package/dist/renderer/canvas-renderer.d.ts +14 -0
  44. package/dist/renderer/canvas-renderer.js +36 -0
  45. package/dist/renderer/container-renderer.js +1 -3
  46. package/dist/renderer/default-text-style-renderer.js +1 -1
  47. package/dist/renderer/deferred-renderer.js +1 -1
  48. package/dist/renderer/expanded-renderer.js +1 -1
  49. package/dist/renderer/index.d.ts +2 -0
  50. package/dist/renderer/index.js +2 -0
  51. package/dist/renderer/keep-together-renderer.js +1 -1
  52. package/dist/renderer/link-renderer.js +1 -1
  53. package/dist/renderer/padding-renderer.js +1 -1
  54. package/dist/renderer/page-builder-renderer.js +1 -1
  55. package/dist/renderer/page-renderer.js +1 -3
  56. package/dist/renderer/pdf-backend.d.ts +15 -4
  57. package/dist/renderer/pdf-backend.js +125 -35
  58. package/dist/renderer/pdf-renderer.js +5 -1
  59. package/dist/renderer/positioned-renderer.js +1 -1
  60. package/dist/renderer/rectangle-renderer.js +1 -3
  61. package/dist/renderer/repeating-header-renderer.js +1 -2
  62. package/dist/renderer/rotated-renderer.js +1 -1
  63. package/dist/renderer/row-renderer.js +1 -3
  64. package/dist/renderer/struct-group-renderer.js +1 -1
  65. package/dist/renderer/svg-renderer.d.ts +11 -0
  66. package/dist/renderer/svg-renderer.js +27 -0
  67. package/dist/renderer/text-renderer.js +30 -19
  68. package/dist/svg/css.d.ts +38 -0
  69. package/dist/svg/css.js +115 -0
  70. package/dist/svg/errors.d.ts +24 -0
  71. package/dist/svg/errors.js +30 -0
  72. package/dist/svg/gradients.d.ts +34 -0
  73. package/dist/svg/gradients.js +156 -0
  74. package/dist/svg/index.d.ts +22 -0
  75. package/dist/svg/index.js +331 -0
  76. package/dist/svg/shapes.d.ts +6 -0
  77. package/dist/svg/shapes.js +177 -0
  78. package/dist/svg/style.d.ts +61 -0
  79. package/dist/svg/style.js +144 -0
  80. package/dist/svg/transform.d.ts +13 -0
  81. package/dist/svg/transform.js +68 -0
  82. package/dist/svg/units.d.ts +11 -0
  83. package/dist/svg/units.js +73 -0
  84. package/dist/svg/xml.d.ts +24 -0
  85. package/dist/svg/xml.js +158 -0
  86. package/dist/text/advance.d.ts +3 -0
  87. package/dist/text/advance.js +4 -3
  88. package/dist/text/glyph-coverage.d.ts +15 -0
  89. package/dist/text/glyph-coverage.js +77 -0
  90. package/dist/text/line-breaker.d.ts +8 -0
  91. package/dist/text/line-breaker.js +146 -70
  92. package/dist/text/shape.d.ts +3 -3
  93. package/dist/text/shape.js +34 -10
  94. package/dist/text/text-style.d.ts +13 -0
  95. package/dist/text/text-style.js +6 -0
  96. package/dist/text/whitespace.d.ts +4 -0
  97. package/dist/text/whitespace.js +43 -0
  98. package/dist/text/word-splitting.d.ts +42 -0
  99. package/dist/text/word-splitting.js +72 -0
  100. package/dist/utils/flex-layout.js +67 -18
  101. package/dist/utils/font-metrics.d.ts +9 -3
  102. package/dist/utils/pdf-object-manager.d.ts +25 -5
  103. package/dist/utils/pdf-object-manager.js +82 -18
  104. package/dist/utils/renderer-registry.d.ts +13 -1
  105. package/dist/utils/renderer-registry.js +22 -1
  106. package/dist/utils/woff2.d.ts +6 -0
  107. package/dist/utils/woff2.js +651 -0
  108. package/dist/vector/path.d.ts +33 -0
  109. package/dist/vector/path.js +81 -0
  110. package/package.json +5 -2
@@ -0,0 +1,93 @@
1
+ import { readFileBytes } from "../platform/node-fs.js";
2
+ import { SvgParseError, svgSize, svgToIr } from "../svg/index.js";
3
+ import { extentSpecs, resolveSize, } from "../layout/box-constraints.js";
4
+ import { SizedPDFElement } from "./pdf-element.js";
5
+ /** Markup is recognised by its first non-space character - anything else is treated as a path. */
6
+ const looksLikeMarkup = (value) => /^\s*[<]/.test(value);
7
+ /**
8
+ * Reads the file, or says what went wrong in the caller's terms. A string that is neither markup nor
9
+ * a readable path would otherwise surface as a bare `ENOENT`, which never mentions that the argument
10
+ * was taken as a PATH in the first place.
11
+ */
12
+ function readSource(path) {
13
+ try {
14
+ return readFileBytes(path);
15
+ }
16
+ catch (error) {
17
+ const shown = path.length > 80 ? `${path.slice(0, 77)}...` : path;
18
+ throw new SvgParseError(`could not read "${shown}" as an SVG file. It does not start with "<", so it was taken as a ` +
19
+ `path: ${error.message}`);
20
+ }
21
+ }
22
+ /**
23
+ * An SVG drawing placed in the layout like an image.
24
+ *
25
+ * Unlike a bitmap it needs no decoder, so its intrinsic size is known SYNCHRONOUSLY, in the
26
+ * constructor - which also means a broken file fails on the line that asked for it rather than deep
27
+ * inside a later render, the same bargain `addFont` makes.
28
+ */
29
+ export class SvgElement extends SizedPDFElement {
30
+ constructor(params) {
31
+ super({ x: 0, y: 0, width: params.width, height: params.height });
32
+ const { source, alt, widthFactor, heightFactor, ...sizing } = params;
33
+ this.widthFactor = widthFactor;
34
+ this.heightFactor = heightFactor;
35
+ this.source =
36
+ typeof source === "string"
37
+ ? looksLikeMarkup(source)
38
+ ? source
39
+ : new TextDecoder().decode(readSource(source))
40
+ : new TextDecoder().decode(source);
41
+ // Parsed here on purpose: an unreadable file is an error where it was NAMED, not inside a render
42
+ // three call frames later. The whole document is walked, not just its root, so an element outside
43
+ // the subset surfaces here too - which is what makes the error predictable rather than a lottery
44
+ // between construction and render time. A logo parses in well under a millisecond.
45
+ this.intrinsic = svgSize(this.source);
46
+ svgToIr(this.source, { x: 0, y: 0, width: 1, height: 1 });
47
+ this.alt = alt;
48
+ this.sizing = sizing;
49
+ this.requested = { width: params.width, height: params.height };
50
+ }
51
+ getProps() {
52
+ return {
53
+ x: this.x,
54
+ y: this.y,
55
+ width: this.width,
56
+ height: this.height,
57
+ source: this.source,
58
+ intrinsic: this.intrinsic,
59
+ alt: this.alt,
60
+ };
61
+ }
62
+ calculateLayout(constraints, offset, _ctx) {
63
+ this.x = offset.x;
64
+ this.y = offset.y;
65
+ const specs = extentSpecs({
66
+ ...this.sizing,
67
+ width: this.requested.width,
68
+ height: this.requested.height,
69
+ widthFactor: this.widthFactor,
70
+ heightFactor: this.heightFactor,
71
+ });
72
+ const resolved = resolveSize(specs.width, specs.height, this.sizing.aspectRatio, constraints);
73
+ let w = resolved.width;
74
+ let h = resolved.height;
75
+ const bounds = resolved.constraints;
76
+ // One pinned axis derives the other from the drawing's own ratio (CSS `width: 120; height: auto`).
77
+ // A bitmap needs a decode pass before it can do this; an SVG knows it from its viewBox.
78
+ if (this.sizing.aspectRatio === undefined &&
79
+ this.intrinsic.width > 0 &&
80
+ this.intrinsic.height > 0) {
81
+ const ratio = this.intrinsic.width / this.intrinsic.height;
82
+ if (w !== undefined && h === undefined)
83
+ h = bounds.constrainHeight(w / ratio);
84
+ else if (h !== undefined && w === undefined)
85
+ w = bounds.constrainWidth(h * ratio);
86
+ }
87
+ // Nothing pinned: draw at the intrinsic size, as an unsized `<img>` does - never fill the region,
88
+ // which would blow a 24pt icon up to the whole page.
89
+ this.width = w ?? bounds.constrainWidth(this.intrinsic.width);
90
+ this.height = h ?? bounds.constrainHeight(this.intrinsic.height);
91
+ return { width: this.width, height: this.height };
92
+ }
93
+ }
@@ -6,6 +6,7 @@ import { type TextTransform } from "../text/text-style.js";
6
6
  import { Fragmentable, FragmentResult } from "../layout/fragmentation.js";
7
7
  import { TextOverflow } from "../text/line-breaker.js";
8
8
  import { HorizontalAlignment, LayoutContext, SizedPDFElement } from "./pdf-element.js";
9
+ import type { Hyphenator } from "../text/word-splitting.js";
9
10
  export interface TextSegment {
10
11
  content: string;
11
12
  fontStyle?: FontStyle;
@@ -74,6 +75,12 @@ interface TextElementParams {
74
75
  wordSpacing?: number;
75
76
  /** CSS `text-indent`, in points: how far the first line starts in. */
76
77
  textIndent?: number;
78
+ /** CSS `overflow-wrap: break-word` - split a word wider than its box, anywhere. */
79
+ breakWord?: boolean;
80
+ /** The font's ligatures; on unless switched off. */
81
+ ligatures?: boolean;
82
+ /** Language-aware splitting; a hyphen is drawn at the break. See text/word-splitting.ts. */
83
+ hyphenate?: Hyphenator;
77
84
  /** Accessibility role for the tagged structure tree (heading level or paragraph; default `"p"`). */
78
85
  role?: TextRole;
79
86
  }
@@ -93,6 +100,9 @@ export declare class TextElement extends SizedPDFElement implements Fragmentable
93
100
  private readonly rawTextTransform?;
94
101
  private readonly rawWordSpacing?;
95
102
  private readonly rawTextIndent?;
103
+ private readonly rawBreakWord?;
104
+ private readonly rawLigatures?;
105
+ private readonly rawHyphenate?;
96
106
  private fontSize;
97
107
  private fontFamily;
98
108
  private fontStyle;
@@ -109,13 +119,16 @@ export declare class TextElement extends SizedPDFElement implements Fragmentable
109
119
  private textTransform;
110
120
  private wordSpacing;
111
121
  private textIndent;
122
+ private breakWord;
123
+ private ligatures;
124
+ private hyphenate?;
112
125
  private content;
113
126
  private maxLines?;
114
127
  private orphans?;
115
128
  private widows?;
116
129
  private overflow;
117
130
  private readonly role?;
118
- constructor({ fontSize, content, fontFamily, fontStyle, color, textAlignment, maxLines, orphans, widows, overflow, lineHeight, underline, strikethrough, skipInk, letterSpacing, direction, fontFallback, textTransform, wordSpacing, textIndent, role, }: TextElementParams);
131
+ constructor({ fontSize, content, fontFamily, fontStyle, color, textAlignment, maxLines, orphans, widows, overflow, lineHeight, underline, strikethrough, skipInk, letterSpacing, direction, fontFallback, textTransform, wordSpacing, textIndent, breakWord, hyphenate, ligatures, role, }: TextElementParams);
119
132
  private resolveStyle;
120
133
  /** Justified lines may be squeezed to keep a word; every other alignment gets no slack. Read by the
121
134
  * breaker in BOTH passes, or a measured line and a drawn one would disagree about where it ends. */
@@ -125,6 +138,13 @@ export declare class TextElement extends SizedPDFElement implements Fragmentable
125
138
  * same width in most fonts).
126
139
  */
127
140
  private display;
141
+ /**
142
+ * Drops or substitutes whatever the RESOLVED font cannot draw. Runs last, after the fallback stack
143
+ * has had its say, and before anything is measured - a character removed at draw time only would
144
+ * make the measured line and the drawn one disagree. A font that draws everything gets its input
145
+ * back unchanged, so an ordinary document is byte-identical.
146
+ */
147
+ private covered;
128
148
  /** The content with `text-transform` applied, and nothing else. */
129
149
  private recased;
130
150
  private lineOptions;
@@ -151,6 +171,15 @@ export declare class TextElement extends SizedPDFElement implements Fragmentable
151
171
  * - enough to tip a borderline string (e.g. "20 Jun 2026", wider than "04 Jul 2026" only because
152
172
  * 'n' beats 'l') one bit over its own width, dropping the last word onto a second line. */
153
173
  private naturalWidth;
174
+ /**
175
+ * The widest piece of this text that cannot be broken - CSS's `min-content` width. A shrinking flex
176
+ * parent floors the width it hands us here, so a squeezed paragraph stops at its longest word
177
+ * instead of becoming one word per line.
178
+ *
179
+ * With `breakWord` or `hyphenate` on, the text may be cut mid-word, so the unbreakable piece is a
180
+ * single character - the same reasoning CSS applies to `word-break: break-all`.
181
+ */
182
+ minIntrinsicMain(horizontal: boolean, ctx: LayoutContext): number;
154
183
  getProps(): {
155
184
  x: number;
156
185
  y: number;
@@ -174,6 +203,9 @@ export declare class TextElement extends SizedPDFElement implements Fragmentable
174
203
  direction: Direction;
175
204
  wordSpacing: number;
176
205
  textIndent: number;
206
+ breakWord: boolean;
207
+ ligatures: boolean;
208
+ hyphenate: Hyphenator | undefined;
177
209
  role: TextRole | undefined;
178
210
  };
179
211
  }
@@ -6,10 +6,13 @@ import { TextRenderer } from "../renderer/text-renderer.js";
6
6
  import { DEFAULT_TEXT_STYLE } from "../text/text-style.js";
7
7
  import { applyTextTransform } from "../text/text-style.js";
8
8
  import { splitByFont } from "../text/font-fallback.js";
9
+ import { runAdvance } from "../text/advance.js";
9
10
  import { singleLineWidth, MAX_SPACE_SHRINK, wrapStringIntoLines, breakSegmentsIntoLines, segmentLinesToSegments, } from "../text/line-breaker.js";
10
11
  import { adjustForOrphansWidows, DEFAULT_ORPHANS, DEFAULT_WIDOWS, } from "../text/orphans-widows.js";
11
12
  import { lineBoxForSegmentLine, lineBoxForString } from "../text/line-metrics.js";
12
13
  import { HorizontalAlignment, SizedPDFElement } from "./pdf-element.js";
14
+ import { normalizeContent } from "../text/whitespace.js";
15
+ import { coverText } from "../text/glyph-coverage.js";
13
16
  /** How far a run sits off the baseline, as a fraction of its font size (up is positive). */
14
17
  export const VERTICAL_TEXT_SHIFT = {
15
18
  baseline: 0,
@@ -28,7 +31,7 @@ function finitePoints(value, name) {
28
31
  return value;
29
32
  }
30
33
  export class TextElement extends SizedPDFElement {
31
- constructor({ fontSize, content, fontFamily, fontStyle, color, textAlignment, maxLines, orphans, widows, overflow = "clip", lineHeight, underline, strikethrough, skipInk, letterSpacing, direction, fontFallback, textTransform, wordSpacing, textIndent, role, }) {
34
+ constructor({ fontSize, content, fontFamily, fontStyle, color, textAlignment, maxLines, orphans, widows, overflow = "clip", lineHeight, underline, strikethrough, skipInk, letterSpacing, direction, fontFallback, textTransform, wordSpacing, textIndent, breakWord, hyphenate, ligatures, role, }) {
32
35
  super({ x: 0, y: 0 });
33
36
  this.role = role;
34
37
  this.rawFontSize = fontSize;
@@ -46,7 +49,12 @@ export class TextElement extends SizedPDFElement {
46
49
  this.rawTextTransform = textTransform;
47
50
  this.rawWordSpacing = finitePoints(wordSpacing, "wordSpacing");
48
51
  this.rawTextIndent = finitePoints(textIndent, "textIndent");
49
- this.content = content;
52
+ this.rawBreakWord = breakWord;
53
+ this.rawLigatures = ligatures;
54
+ this.rawHyphenate = hyphenate;
55
+ // Control characters have no glyph and would be DRAWN as .notdef boxes; `\n` survives
56
+ // because the breaker treats it as a hard break. See text/whitespace.ts.
57
+ this.content = normalizeContent(content);
50
58
  this.maxLines = maxLines;
51
59
  this.orphans = orphans;
52
60
  this.widows = widows;
@@ -69,8 +77,10 @@ export class TextElement extends SizedPDFElement {
69
77
  // A SPAN may bring its own stack, so the element's being empty is not enough to skip the pass.
70
78
  const anyStack = this.fontFallback.length > 0 ||
71
79
  (typeof cased !== "string" && cased.some((seg) => (seg.fontFallback?.length ?? 0) > 0));
72
- if (!anyStack || !metrics)
80
+ if (!metrics)
73
81
  return cased;
82
+ if (!anyStack)
83
+ return this.covered(cased, metrics);
74
84
  // Font fallback turns the content into spans - one per family - which every later pass already
75
85
  // knows how to handle. Nothing new downstream.
76
86
  const pieces = typeof cased === "string" ? [{ content: cased }] : cased;
@@ -83,7 +93,28 @@ export class TextElement extends SizedPDFElement {
83
93
  for (const run of runs)
84
94
  out.push({ ...seg, content: run.text, fontFamily: run.fontFamily });
85
95
  }
86
- return out;
96
+ return this.covered(out, metrics);
97
+ }
98
+ /**
99
+ * Drops or substitutes whatever the RESOLVED font cannot draw. Runs last, after the fallback stack
100
+ * has had its say, and before anything is measured - a character removed at draw time only would
101
+ * make the measured line and the drawn one disagree. A font that draws everything gets its input
102
+ * back unchanged, so an ordinary document is byte-identical.
103
+ */
104
+ covered(content, metrics) {
105
+ const report = (dropped) => metrics.reportMissingGlyph?.(dropped);
106
+ if (typeof content === "string") {
107
+ const { text, dropped } = coverText(content, this.fontFamily, this.fontStyle, metrics);
108
+ if (dropped.length > 0)
109
+ report(dropped);
110
+ return text;
111
+ }
112
+ return content.map((seg) => {
113
+ const { text, dropped } = coverText(seg.content, seg.fontFamily ?? this.fontFamily, seg.fontStyle ?? this.fontStyle, metrics);
114
+ if (dropped.length > 0)
115
+ report(dropped);
116
+ return text === seg.content ? seg : { ...seg, content: text };
117
+ });
87
118
  }
88
119
  /** The content with `text-transform` applied, and nothing else. */
89
120
  recased() {
@@ -104,6 +135,8 @@ export class TextElement extends SizedPDFElement {
104
135
  return {
105
136
  wordSpacing: this.wordSpacing,
106
137
  indent: this.textIndent,
138
+ splitting: { breakWord: this.breakWord, hyphenate: this.hyphenate },
139
+ ligatures: this.ligatures,
107
140
  // Justified lines may be squeezed to keep a word; every other alignment gets no slack.
108
141
  shrink: this.textAlignment === HorizontalAlignment.justify ? MAX_SPACE_SHRINK : 0,
109
142
  };
@@ -124,6 +157,9 @@ export class TextElement extends SizedPDFElement {
124
157
  this.textTransform = this.rawTextTransform ?? ts.textTransform;
125
158
  this.wordSpacing = this.rawWordSpacing ?? ts.wordSpacing;
126
159
  this.textIndent = this.rawTextIndent ?? ts.textIndent;
160
+ this.breakWord = this.rawBreakWord ?? ts.breakWord;
161
+ this.ligatures = this.rawLigatures ?? ts.ligatures;
162
+ this.hyphenate = this.rawHyphenate ?? ts.hyphenate;
127
163
  }
128
164
  /**
129
165
  * Splits the paragraph at line boxes (Slice 1). The lines that fit in `maxHeight` stay;
@@ -257,6 +293,43 @@ export class TextElement extends SizedPDFElement {
257
293
  return content.reduce((sum, seg) => sum +
258
294
  oneLine(seg.content, seg.fontFamily ?? this.fontFamily, seg.fontSize ?? this.fontSize, seg.fontStyle ?? this.fontStyle, seg.letterSpacing ?? this.letterSpacing), indent);
259
295
  }
296
+ /**
297
+ * The widest piece of this text that cannot be broken - CSS's `min-content` width. A shrinking flex
298
+ * parent floors the width it hands us here, so a squeezed paragraph stops at its longest word
299
+ * instead of becoming one word per line.
300
+ *
301
+ * With `breakWord` or `hyphenate` on, the text may be cut mid-word, so the unbreakable piece is a
302
+ * single character - the same reasoning CSS applies to `word-break: break-all`.
303
+ */
304
+ minIntrinsicMain(horizontal, ctx) {
305
+ // Only the width is a question here: the height of a text is whatever its lines add up to, and a
306
+ // column squeezing it vertically is the paginator's business, not the shrink pass's.
307
+ if (!horizontal)
308
+ return 0;
309
+ this.resolveStyle(ctx);
310
+ const metrics = ctx.metrics;
311
+ const widest = (text, family, size, style, letterSpacing) => {
312
+ // `ligatures` belongs to the FONT: it decides which glyphs are drawn and therefore how wide the
313
+ // piece is. Leaving it out here would measure a floor the drawing never matches.
314
+ const font = {
315
+ fontFamily: family,
316
+ fontSize: size,
317
+ fontStyle: style,
318
+ ligatures: this.ligatures,
319
+ };
320
+ // Split on the same boundaries the breaker uses - a hard break ends a line too.
321
+ const pieces = this.breakWord || this.hyphenate ? [...text] : text.split(/[ \n]/);
322
+ return pieces.reduce((max, piece) => Math.max(max, runAdvance(metrics, piece, font, letterSpacing)), 0);
323
+ };
324
+ const content = this.display(metrics);
325
+ if (typeof content === "string") {
326
+ return widest(content, this.fontFamily, this.fontSize, this.fontStyle, this.letterSpacing);
327
+ }
328
+ // Segments join without a space, so a word may straddle two of them; taking the widest piece per
329
+ // segment can therefore UNDER-estimate. That errs towards squeezing a little too far, which lands
330
+ // back on the old overflow behaviour rather than on something new.
331
+ return content.reduce((max, seg) => Math.max(max, widest(seg.content, seg.fontFamily ?? this.fontFamily, seg.fontSize ?? this.fontSize, seg.fontStyle ?? this.fontStyle, seg.letterSpacing ?? this.letterSpacing)), 0);
332
+ }
260
333
  getProps() {
261
334
  return {
262
335
  x: this.x,
@@ -281,6 +354,9 @@ export class TextElement extends SizedPDFElement {
281
354
  direction: this.direction,
282
355
  wordSpacing: this.wordSpacing,
283
356
  textIndent: this.textIndent,
357
+ breakWord: this.breakWord,
358
+ ligatures: this.ligatures,
359
+ hyphenate: this.hyphenate,
284
360
  role: this.role,
285
361
  };
286
362
  }
@@ -233,6 +233,9 @@ export interface LinearGradient {
233
233
  y1: number;
234
234
  stops: GradientStop[];
235
235
  extend: "pad" | "repeat" | "reflect";
236
+ /** One opacity for the whole shading (0-1). A PDF shading is DeviceRGB and has no alpha channel,
237
+ * so a PER-STOP alpha is not representable - but a uniform one is, as an ExtGState. */
238
+ alpha?: number;
236
239
  }
237
240
  /** A radial gradient between two circles, in absolute page coordinates. */
238
241
  export interface RadialGradient {
@@ -245,19 +248,55 @@ export interface RadialGradient {
245
248
  r1: number;
246
249
  stops: GradientStop[];
247
250
  extend: "pad" | "repeat" | "reflect";
251
+ /** One opacity for the whole shading (0-1). A PDF shading is DeviceRGB and has no alpha channel,
252
+ * so a PER-STOP alpha is not representable - but a uniform one is, as an ExtGState. */
253
+ alpha?: number;
248
254
  }
249
255
  export type Gradient = LinearGradient | RadialGradient;
250
256
  /**
251
- * A filled vector path - one or more closed subpaths sharing a single fill, using the nonzero
252
- * winding rule (so a glyph's inner contour cuts a hole). This is what a color glyph's layers draw
253
- * onto: each COLR layer is one `Path` filled with a solid `Color` (v0) or a `Gradient` (v1).
257
+ * How a path's outline is stroked. The names are SVG's, the semantics are the PDF operators they map
258
+ * to (`w`, `J`, `j`, `M`, `d`), and the defaults are SVG's initial values - not PDF's, which differ on
259
+ * the miter limit. Opacity rides on the `Color`, as everywhere else in the IR.
260
+ */
261
+ export interface StrokeStyle {
262
+ color: Color;
263
+ width: number;
264
+ /** `J`. Default `butt`. */
265
+ cap?: "butt" | "round" | "square";
266
+ /** `j`. Default `miter`. */
267
+ join?: "miter" | "round" | "bevel";
268
+ /** `M`. Default 4 (SVG); PDF's own default is 10, so we always emit it when a join can miter. */
269
+ miterLimit?: number;
270
+ /** `d`: dash lengths in points, plus the phase to start at. Absent or empty = solid. */
271
+ dash?: number[];
272
+ dashOffset?: number;
273
+ }
274
+ /**
275
+ * A vector path - one or more subpaths that share one fill and/or one stroke. `fill` uses the nonzero
276
+ * winding rule unless `fillRule` says otherwise, so a glyph's inner contour cuts a hole by default.
277
+ * A color glyph's layer is one `Path` filled with a solid `Color` (COLR v0) or a `Gradient` (v1); an
278
+ * SVG shape is one `Path` that may also carry a stroke, or carry ONLY a stroke.
254
279
  */
255
280
  export interface Path {
256
281
  type: "path";
257
282
  commands: PathCommand[];
258
- fill: Color | Gradient;
283
+ /** Absent = not filled (a stroke-only outline). */
284
+ fill?: Color | Gradient;
285
+ /** `evenodd` maps to the `*` operators; absent = nonzero, which is what glyphs need. */
286
+ fillRule?: "nonzero" | "evenodd";
287
+ stroke?: StrokeStyle;
259
288
  /** Accessibility structure tag (absent = drawn as an artifact); color-emoji layers stay untagged. */
260
289
  tag?: StructTag;
261
290
  }
291
+ /**
292
+ * Clips to an arbitrary path until the matching `clip-pop` - what SVG's `<clipPath>` needs, and what
293
+ * `ClipPush` cannot express, being a rectangle. The commands are in the same space as a `Path`'s.
294
+ */
295
+ export interface ClipPathPush {
296
+ type: "clip-path-push";
297
+ commands: PathCommand[];
298
+ /** `evenodd` maps to `W*`; absent = nonzero. SVG spells this `clip-rule`. */
299
+ fillRule?: "nonzero" | "evenodd";
300
+ }
262
301
  /** The closed set of primitives the PDF backend knows how to draw. */
263
- export type IRNode = TextRun | Rect | Line | Image | ClipPush | ClipPop | Path | Link | Outline | Anchor | FormFieldNode | TransformPush | TransformPop;
302
+ export type IRNode = TextRun | Rect | Line | Image | ClipPush | ClipPathPush | ClipPop | Path | Link | Outline | Anchor | FormFieldNode | TransformPush | TransformPop;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * `min-content` sizing - the smallest extent a subtree can be squeezed into and still hold together.
3
+ *
4
+ * The shrink pass in `FlexLayoutHelper` floors every child at this, so an overflowing line stops
5
+ * squeezing instead of grinding a paragraph down to one word per line. It is the same rule a browser
6
+ * applies through the automatic `min-width` of a flex item (CSS Flexbox 4.5).
7
+ *
8
+ * Only the leaves know a real number - a `Text` its longest word, everything else nothing in
9
+ * particular. These helpers are how a container folds its children's answers into its own.
10
+ */
11
+ import type { LayoutContext } from "../elements/pdf-element.js";
12
+ import type { PDFElement } from "../elements/pdf-element.js";
13
+ /**
14
+ * The children's floor, folded along one axis: children laid out ALONG the queried axis have to fit
15
+ * side by side, so their floors add up (plus the gaps between them); across it they merely overlap,
16
+ * so the widest one wins.
17
+ *
18
+ * A wrapping stack adds up nothing: it may put every child on its own line, so it is only as wide as
19
+ * its widest child - which is what CSS says about a multi-line flex container too.
20
+ */
21
+ export declare function stackMinIntrinsic(children: PDFElement[], gap: number, alongAxis: boolean, horizontal: boolean, ctx: LayoutContext, wraps?: boolean): number;
22
+ /**
23
+ * Folds an element's own declared extent into the floor its content asks for. CSS calls these the
24
+ * "specified size suggestion" and the "content size suggestion" and takes the SMALLER: a box that
25
+ * declares 400pt around content needing 50 may still be squeezed to 50, while one declaring 400
26
+ * around content needing 500 cannot go below 400. An extent given as a fraction is not a suggestion
27
+ * at all - it has no size of its own until the parent is known - so it is ignored here.
28
+ */
29
+ export declare function withDeclaredExtent(contentFloor: number, declared: number | undefined, explicitMin?: number): number;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The children's floor, folded along one axis: children laid out ALONG the queried axis have to fit
3
+ * side by side, so their floors add up (plus the gaps between them); across it they merely overlap,
4
+ * so the widest one wins.
5
+ *
6
+ * A wrapping stack adds up nothing: it may put every child on its own line, so it is only as wide as
7
+ * its widest child - which is what CSS says about a multi-line flex container too.
8
+ */
9
+ export function stackMinIntrinsic(children, gap, alongAxis, horizontal, ctx, wraps = false) {
10
+ const floors = children.map((c) => c.minIntrinsicMain(horizontal, ctx));
11
+ if (floors.length === 0)
12
+ return 0;
13
+ if (!alongAxis || wraps)
14
+ return Math.max(...floors);
15
+ return floors.reduce((sum, f) => sum + f, 0) + gap * (floors.length - 1);
16
+ }
17
+ /**
18
+ * Folds an element's own declared extent into the floor its content asks for. CSS calls these the
19
+ * "specified size suggestion" and the "content size suggestion" and takes the SMALLER: a box that
20
+ * declares 400pt around content needing 50 may still be squeezed to 50, while one declaring 400
21
+ * around content needing 500 cannot go below 400. An extent given as a fraction is not a suggestion
22
+ * at all - it has no size of its own until the parent is known - so it is ignored here.
23
+ */
24
+ export function withDeclaredExtent(contentFloor, declared, explicitMin) {
25
+ // An explicit `minWidth`/`minHeight` REPLACES the automatic minimum - it does not compete with it.
26
+ // CSS applies the automatic minimum only while `min-width` is `auto`, so a smaller explicit value
27
+ // wins too: `min-width: 0` is the standard way to let a flex item shrink past its longest word, and
28
+ // taking the larger of the two would quietly ignore it. Measured in Chrome on one long word in a
29
+ // 200pt line: 132.08pt with `auto`, 84.64pt with `0`.
30
+ if (explicitMin !== undefined)
31
+ return explicitMin;
32
+ // Without an explicit bound the floor is CSS's content-based minimum: the smaller of what the
33
+ // element declares and what its content actually needs. It matters because the shrink pass must not
34
+ // aim below a size the child will refuse - the share it was supposed to give would never arrive,
35
+ // and the line would stay over its width.
36
+ return declared === undefined ? contentFloor : Math.min(declared, contentFloor);
37
+ }
@@ -3,7 +3,7 @@ export class AnchorRenderer {
3
3
  static async render(element, objectManager) {
4
4
  const { name, child, y } = element.getProps();
5
5
  const renderer = RendererRegistry.getRenderer(child);
6
- const childNodes = renderer ? await renderer(child, objectManager) : [];
6
+ const childNodes = await renderer(child, objectManager);
7
7
  // The child draws normally; an Anchor IR node carries the name + scroll target for the page renderer
8
8
  // to register as a named destination (it produces no content-stream ops itself).
9
9
  return [...childNodes, { type: "anchor", y, name }];
@@ -3,7 +3,7 @@ export class BookmarkRenderer {
3
3
  static async render(element, objectManager) {
4
4
  const { title, level, child, y } = element.getProps();
5
5
  const renderer = RendererRegistry.getRenderer(child);
6
- const childNodes = renderer ? await renderer(child, objectManager) : [];
6
+ const childNodes = await renderer(child, objectManager);
7
7
  // The child draws normally; an Outline IR node carries the title + nesting + scroll target for the
8
8
  // page renderer to collect into the /Outlines tree (it produces no content-stream ops itself).
9
9
  return [...childNodes, { type: "outline", y, title, level }];
@@ -0,0 +1,14 @@
1
+ import { PDFObjectManager } from "../utils/pdf-object-manager.js";
2
+ import { CanvasElement } from "../elements/canvas-element.js";
3
+ import type { IRNode } from "../ir/display-list.js";
4
+ /**
5
+ * Runs the caller's paint callback and hands back what it drew.
6
+ *
7
+ * The painter works in CANVAS coordinates (0,0 is the box's own corner), so the whole drawing is
8
+ * wrapped in one translate rather than every point being offset - the same shape `Svg` uses, and it
9
+ * keeps the callback independent of where the box ended up. The clip is the box: a drawing that runs
10
+ * past its own edge would otherwise bleed into the layout beside it.
11
+ */
12
+ export declare class CanvasRenderer {
13
+ static render(element: CanvasElement, objectManager: PDFObjectManager): Promise<IRNode[]>;
14
+ }
@@ -0,0 +1,36 @@
1
+ import { CanvasPainter } from "../canvas/painter.js";
2
+ /**
3
+ * Runs the caller's paint callback and hands back what it drew.
4
+ *
5
+ * The painter works in CANVAS coordinates (0,0 is the box's own corner), so the whole drawing is
6
+ * wrapped in one translate rather than every point being offset - the same shape `Svg` uses, and it
7
+ * keeps the callback independent of where the box ended up. The clip is the box: a drawing that runs
8
+ * past its own edge would otherwise bleed into the layout beside it.
9
+ */
10
+ export class CanvasRenderer {
11
+ static async render(element, objectManager) {
12
+ const { x, y, width, height, paint, alt } = element.getProps();
13
+ // The layout bounds are kept as plain numbers, and the painter and the callback each get their
14
+ // OWN size object. Sharing one meant a callback that wrote to `size.width` moved the clip away
15
+ // from the box layout had reserved - the drawing would then bleed into what sits beside it.
16
+ const w = width ?? 0;
17
+ const h = height ?? 0;
18
+ const painter = new CanvasPainter({ width: w, height: h });
19
+ paint(painter, { width: w, height: h });
20
+ const drawn = painter.drawn();
21
+ const nodes = [
22
+ { type: "clip-push", x: x ?? 0, y: y ?? 0, width: w, height: h },
23
+ { type: "transform-push", matrix: [1, 0, 0, 1, x ?? 0, y ?? 0] },
24
+ ...drawn,
25
+ { type: "transform-pop" },
26
+ { type: "clip-pop" },
27
+ ];
28
+ if (!alt || !objectManager.struct.enabled)
29
+ return nodes;
30
+ const tag = {
31
+ role: "Figure",
32
+ key: objectManager.struct.openElement(element.structId, "Figure", { alt }),
33
+ };
34
+ return nodes.map((node) => (node.type === "path" ? { ...node, tag } : node));
35
+ }
36
+ }
@@ -6,9 +6,7 @@ export class ContainerRenderer {
6
6
  if (children)
7
7
  for (const child of children) {
8
8
  const renderer = RendererRegistry.getRenderer(child);
9
- if (renderer) {
10
- nodes.push(...(await renderer(child, objectManager)));
11
- }
9
+ nodes.push(...(await renderer(child, objectManager)));
12
10
  }
13
11
  return nodes;
14
12
  }
@@ -5,6 +5,6 @@ export class DefaultTextStyleRenderer {
5
5
  static async render(element, objectManager) {
6
6
  const { child } = element.getProps();
7
7
  const renderer = RendererRegistry.getRenderer(child);
8
- return renderer ? await renderer(child, objectManager) : [];
8
+ return renderer(child, objectManager);
9
9
  }
10
10
  }
@@ -5,6 +5,6 @@ export class DeferredRenderer {
5
5
  if (!composed)
6
6
  return [];
7
7
  const renderer = RendererRegistry.getRenderer(composed);
8
- return renderer ? renderer(composed, objectManager) : [];
8
+ return renderer(composed, objectManager);
9
9
  }
10
10
  }
@@ -3,6 +3,6 @@ export class ExpandedRenderer {
3
3
  static async render(expandedElement, objectManager) {
4
4
  const { child } = expandedElement.getProps();
5
5
  const renderer = RendererRegistry.getRenderer(child);
6
- return renderer ? await renderer(child, objectManager) : [];
6
+ return renderer(child, objectManager);
7
7
  }
8
8
  }
@@ -8,3 +8,5 @@ export * from "./text-renderer.js";
8
8
  export * from "./expanded-renderer.js";
9
9
  export * from "./line-renderer.js";
10
10
  export * from "./row-renderer.js";
11
+ export * from "./svg-renderer.js";
12
+ export * from "./canvas-renderer.js";
@@ -8,3 +8,5 @@ export * from "./text-renderer.js";
8
8
  export * from "./expanded-renderer.js";
9
9
  export * from "./line-renderer.js";
10
10
  export * from "./row-renderer.js";
11
+ export * from "./svg-renderer.js";
12
+ export * from "./canvas-renderer.js";
@@ -7,6 +7,6 @@ export class KeepTogetherRenderer {
7
7
  static async render(group, objectManager) {
8
8
  const { child } = group.getProps();
9
9
  const renderer = RendererRegistry.getRenderer(child);
10
- return renderer ? await renderer(child, objectManager) : [];
10
+ return renderer(child, objectManager);
11
11
  }
12
12
  }
@@ -3,7 +3,7 @@ export class LinkRenderer {
3
3
  static async render(element, objectManager) {
4
4
  const { href, dest, child, x, y, width, height } = element.getProps();
5
5
  const renderer = RendererRegistry.getRenderer(child);
6
- const childNodes = renderer ? await renderer(child, objectManager) : [];
6
+ const childNodes = await renderer(child, objectManager);
7
7
  // The child draws normally; a Link IR node carries the clickable rect + target (external href or
8
8
  // internal dest) for the page renderer to emit as a /Link annotation (no content-stream ops itself).
9
9
  return [...childNodes, { type: "link", x, y, width, height, href, dest }];
@@ -3,6 +3,6 @@ export class PaddingRenderer {
3
3
  static async render(paddingElement, objectManager) {
4
4
  const { child } = paddingElement.getProps();
5
5
  const renderer = RendererRegistry.getRenderer(child);
6
- return renderer ? await renderer(child, objectManager) : [];
6
+ return renderer(child, objectManager);
7
7
  }
8
8
  }
@@ -6,6 +6,6 @@ export class PageBuilderRenderer {
6
6
  if (!composed)
7
7
  return [];
8
8
  const renderer = RendererRegistry.getRenderer(composed);
9
- return renderer ? renderer(composed, objectManager) : [];
9
+ return renderer(composed, objectManager);
10
10
  }
11
11
  }
@@ -21,9 +21,7 @@ export class PageRenderer {
21
21
  const nodes = [];
22
22
  for (const element of renderables) {
23
23
  const renderer = RendererRegistry.getRenderer(element);
24
- if (renderer) {
25
- nodes.push(...(await renderer(element, objectManager)));
26
- }
24
+ nodes.push(...(await renderer(element, objectManager)));
27
25
  }
28
26
  // Accessible tagging: one struct context per page (allocates its StructParents index), threaded into
29
27
  // serialize so each drawable node is wrapped in marked content. Undefined = tagging off (byte-identical).