@jasy/pdf 1.0.0-alpha.4 → 1.0.0-alpha.6

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 (96) hide show
  1. package/README.md +5 -2
  2. package/dist/api/content.d.ts +9 -3
  3. package/dist/api/content.js +13 -3
  4. package/dist/api/descriptor.js +22 -1
  5. package/dist/api/dimension.d.ts +20 -0
  6. package/dist/api/dimension.js +16 -0
  7. package/dist/api/index.d.ts +2 -0
  8. package/dist/api/index.js +2 -0
  9. package/dist/api/layout.d.ts +65 -3
  10. package/dist/api/layout.js +71 -2
  11. package/dist/api/page-builder.d.ts +32 -0
  12. package/dist/api/page-builder.js +32 -0
  13. package/dist/api/structure.d.ts +19 -0
  14. package/dist/api/structure.js +25 -0
  15. package/dist/api/table.js +13 -5
  16. package/dist/api/text.d.ts +10 -1
  17. package/dist/api/text.js +32 -4
  18. package/dist/elements/container-element.d.ts +17 -1
  19. package/dist/elements/container-element.js +54 -22
  20. package/dist/elements/image-element.d.ts +23 -1
  21. package/dist/elements/image-element.js +54 -11
  22. package/dist/elements/index.d.ts +6 -0
  23. package/dist/elements/index.js +6 -0
  24. package/dist/elements/layout/anchor-element.d.ts +23 -0
  25. package/dist/elements/layout/anchor-element.js +27 -0
  26. package/dist/elements/layout/bookmark-element.d.ts +27 -0
  27. package/dist/elements/layout/bookmark-element.js +30 -0
  28. package/dist/elements/layout/link-element.d.ts +33 -0
  29. package/dist/elements/layout/link-element.js +39 -0
  30. package/dist/elements/layout/page-builder-element.d.ts +32 -0
  31. package/dist/elements/layout/page-builder-element.js +43 -0
  32. package/dist/elements/layout/rotated-box-element.d.ts +35 -0
  33. package/dist/elements/layout/rotated-box-element.js +54 -0
  34. package/dist/elements/layout/rotated-element.d.ts +32 -0
  35. package/dist/elements/layout/rotated-element.js +41 -0
  36. package/dist/elements/layout/struct-group.d.ts +28 -0
  37. package/dist/elements/layout/struct-group.js +42 -0
  38. package/dist/elements/page-element.d.ts +8 -0
  39. package/dist/elements/page-element.js +13 -1
  40. package/dist/elements/pdf-element.d.ts +35 -0
  41. package/dist/elements/pdf-element.js +34 -0
  42. package/dist/elements/rectangle-element.d.ts +11 -1
  43. package/dist/elements/rectangle-element.js +49 -13
  44. package/dist/elements/row-element.d.ts +11 -1
  45. package/dist/elements/row-element.js +37 -8
  46. package/dist/elements/text-element.d.ts +11 -1
  47. package/dist/elements/text-element.js +5 -2
  48. package/dist/ir/display-list.d.ts +137 -1
  49. package/dist/layout/box-constraints.d.ts +8 -0
  50. package/dist/layout/box-constraints.js +14 -0
  51. package/dist/layout/collect-images.d.ts +9 -0
  52. package/dist/layout/collect-images.js +34 -0
  53. package/dist/layout/fragmentation.js +9 -2
  54. package/dist/renderer/anchor-renderer.d.ts +6 -0
  55. package/dist/renderer/anchor-renderer.js +11 -0
  56. package/dist/renderer/bookmark-renderer.d.ts +6 -0
  57. package/dist/renderer/bookmark-renderer.js +11 -0
  58. package/dist/renderer/emoji-image.d.ts +9 -0
  59. package/dist/renderer/emoji-image.js +68 -0
  60. package/dist/renderer/image-renderer.d.ts +1 -1
  61. package/dist/renderer/image-renderer.js +10 -2
  62. package/dist/renderer/link-renderer.d.ts +6 -0
  63. package/dist/renderer/link-renderer.js +11 -0
  64. package/dist/renderer/page-builder-renderer.d.ts +6 -0
  65. package/dist/renderer/page-builder-renderer.js +11 -0
  66. package/dist/renderer/page-renderer.js +54 -4
  67. package/dist/renderer/pdf-backend.d.ts +16 -2
  68. package/dist/renderer/pdf-backend.js +135 -3
  69. package/dist/renderer/pdf-document-renderer.d.ts +16 -7
  70. package/dist/renderer/pdf-document-renderer.js +65 -33
  71. package/dist/renderer/pdf-renderer.js +58 -2
  72. package/dist/renderer/rotated-renderer.d.ts +7 -0
  73. package/dist/renderer/rotated-renderer.js +29 -0
  74. package/dist/renderer/struct-group-renderer.d.ts +10 -0
  75. package/dist/renderer/struct-group-renderer.js +24 -0
  76. package/dist/renderer/text-renderer.d.ts +3 -0
  77. package/dist/renderer/text-renderer.js +206 -5
  78. package/dist/text/emoji-codepoints.d.ts +1 -0
  79. package/dist/text/emoji-codepoints.js +29 -0
  80. package/dist/text/line-breaker.js +6 -3
  81. package/dist/utils/afm-parser.js +6 -4
  82. package/dist/utils/dest-registry.d.ts +19 -0
  83. package/dist/utils/dest-registry.js +32 -0
  84. package/dist/utils/flex-layout.d.ts +10 -3
  85. package/dist/utils/flex-layout.js +26 -6
  86. package/dist/utils/outline.d.ts +21 -0
  87. package/dist/utils/outline.js +80 -0
  88. package/dist/utils/pdf-object-manager.d.ts +26 -1
  89. package/dist/utils/pdf-object-manager.js +0 -0
  90. package/dist/utils/struct-tree.d.ts +44 -0
  91. package/dist/utils/struct-tree.js +130 -0
  92. package/dist/utils/ttf-parser.d.ts +73 -0
  93. package/dist/utils/ttf-parser.js +456 -16
  94. package/dist/utils/ua-xmp.d.ts +9 -0
  95. package/dist/utils/ua-xmp.js +32 -0
  96. package/package.json +1 -1
@@ -1,4 +1,7 @@
1
+ import { Color } from "../common/color.js";
1
2
  import { HorizontalAlignment } from "../elements/pdf-element.js";
3
+ import { isEmojiCodePoint } from "../text/emoji-codepoints.js";
4
+ import { emojiImageNode } from "./emoji-image.js";
2
5
  import { wrapStringIntoLines, breakSegmentsIntoLines, } from "../text/line-breaker.js";
3
6
  // Distance from the top of a line down to its baseline, as a fraction of the font
4
7
  // size. ~0.683 is the standard-14 ascent ratio used to seat the first baseline.
@@ -17,17 +20,200 @@ export class TextRenderer {
17
20
  return lines.reduce((total, line) => total + line.maxFontSize * lineHeight, 0);
18
21
  }
19
22
  static async render(textElement, objectManager) {
20
- const { x, y, width, fontSize, color, content, fontFamily, fontStyle, textAlignment, maxLines, overflow, lineHeight, } = textElement.getProps();
23
+ const { x, y, width, fontSize, color, content, fontFamily, fontStyle, textAlignment, maxLines, overflow, lineHeight, role, } = textElement.getProps();
21
24
  // Component -> display list. Wrapping and positioning stay here; the backend
22
25
  // turns each run into BT/Tf/Td/Tj/ET. The wrapping algorithm is unchanged from
23
26
  // the original renderer - unifying it into the engine is Phase 3.
24
- return TextRenderer._buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, width ?? Number.NaN, textAlignment, color, x, y, maxLines, overflow, lineHeight);
27
+ const { runs, links } = TextRenderer._buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, width ?? Number.NaN, textAlignment, color, x, y, maxLines, overflow, lineHeight);
28
+ // Accessible tagging: this whole text block is one structure element (a paragraph P, or a heading
29
+ // from `role`); each run's text lands under it. Emoji sub-nodes produced below stay untagged (drawn
30
+ // as artifacts). Keyed by structId so a block split across pages stays one element.
31
+ if (objectManager.struct.enabled) {
32
+ const pdfRole = role ? role.toUpperCase() : "P"; // "h1" -> "H1"; default paragraph
33
+ const key = objectManager.struct.openElement(textElement.structId, pdfRole);
34
+ for (const run of runs)
35
+ run.tag = { role: pdfRole, key };
36
+ }
37
+ // Color fonts (COLR/CPAL): expand each run's emoji into filled vector layers (or fetched images
38
+ // for an image emoji source); plain runs and monochrome fonts pass straight through unchanged.
39
+ const drawn = (await Promise.all(runs.map((run) => TextRenderer._expandColorGlyphs(run, objectManager)))).flat();
40
+ // Inline hyperlinks (href spans) ride along as /Link annotations - they draw nothing, so order
41
+ // vs the drawn runs does not matter; the page renderer peels them off into /Annots.
42
+ return links.length > 0 ? [...drawn, ...links] : drawn;
43
+ }
44
+ // Splits a text run into normal text sub-runs + filled `Path` layers for any COLR color glyphs,
45
+ // walking code point by code point and advancing the pen by each glyph's width. Returns `[run]`
46
+ // untouched when the font has no color glyphs (the common case).
47
+ static async _expandColorGlyphs(run, om) {
48
+ // The run's own font (if it's a color font) draws emoji directly; otherwise a document-level
49
+ // emoji source (`Document({ emoji })`) supplies them - a fallback FONT (color glyphs, native
50
+ // vector) or an IMAGE source (fetched emoji images) - for code points the run's font lacks, so
51
+ // `Text("Hallo 😅")` works in one string. No emoji source at all -> pass the run through.
52
+ const own = om.getColorFont(run.fontFamily, run.fontStyle);
53
+ const emojiName = om.getEmojiFont();
54
+ const fallback = emojiName && emojiName !== run.fontFamily
55
+ ? om.getColorFont(emojiName, run.fontStyle)
56
+ : undefined;
57
+ const imageSource = om.getEmojiImageSource();
58
+ if (!own && !fallback && !imageSource)
59
+ return [run];
60
+ const nodes = [];
61
+ let cursorX = run.x; // absolute x of the next glyph
62
+ let pending = ""; // run of consecutive non-color characters
63
+ let pendingX = run.x; // where that run started
64
+ const flushPending = () => {
65
+ if (pending) {
66
+ nodes.push({ ...run, x: pendingX, text: pending });
67
+ pending = "";
68
+ }
69
+ };
70
+ for (const ch of run.text) {
71
+ const cp = ch.codePointAt(0);
72
+ // Which color font renders this code point: the run's own font first, then the emoji fallback.
73
+ let source;
74
+ let layers = null;
75
+ if (own) {
76
+ const l = own.getColorGlyph(own.getGlyphIndex(cp));
77
+ if (l)
78
+ [source, layers] = [own, l];
79
+ }
80
+ if (!layers && fallback) {
81
+ const l = fallback.getColorGlyph(fallback.getGlyphIndex(cp));
82
+ if (l)
83
+ [source, layers] = [fallback, l];
84
+ }
85
+ // The advance matches measuring (getCharWidth applies the same emoji fallback).
86
+ const advance = om.getCharWidth(ch, run.fontSize, undefined, run.fontFamily, run.fontStyle);
87
+ if (source && layers) {
88
+ flushPending();
89
+ const scale = run.fontSize / source.unitsPerEm;
90
+ for (const layer of layers) {
91
+ const commands = TextRenderer._glyphToPath(source.getGlyphPath(layer.glyphId), cursorX, run.y, scale, layer.transform);
92
+ if (commands.length === 0)
93
+ continue; // an empty layer outline draws nothing
94
+ const paint = layer.paint;
95
+ let fill;
96
+ if (paint.type === "solid") {
97
+ const c = paint.color;
98
+ fill = c ? new Color(c.r, c.g, c.b, c.a / 255) : run.color; // null -> foreground
99
+ }
100
+ else {
101
+ fill = TextRenderer._toGradient(paint, cursorX, run.y, scale, run.color, layer.transform);
102
+ }
103
+ nodes.push({ type: "path", commands, fill });
104
+ }
105
+ }
106
+ else if (imageSource && isEmojiCodePoint(cp)) {
107
+ // Image emoji source: fetch + embed a 1em square. The emoji breaks the text run either way;
108
+ // a failed fetch (offline/404) leaves a blank em gap (advance already applied) rather than
109
+ // drawing a broken glyph.
110
+ flushPending();
111
+ const node = await emojiImageNode(cp, imageSource, cursorX, run.y, run.fontSize);
112
+ if (node)
113
+ nodes.push(node);
114
+ }
115
+ else {
116
+ if (!pending)
117
+ pendingX = cursorX;
118
+ pending += ch;
119
+ }
120
+ cursorX += advance;
121
+ }
122
+ flushPending();
123
+ return nodes;
124
+ }
125
+ // Maps a glyph outline (font units, y-up) to absolute IR path commands at the pen origin, scaling
126
+ // by `scale` and flipping the axis into the engine's top-left space (the seam flips it once more
127
+ // to PDF space). TrueType quadratics become cubics: C1 = P0 + 2/3(C-P0), C2 = P1 + 2/3(C-P1).
128
+ static _glyphToPath(cmds, originX, baselineY, scale, transform) {
129
+ // Apply the layer's affine (font units) first, then scale to points, position at the pen origin
130
+ // and flip into engine space. `mapX`/`mapY` take BOTH glyph coords because an affine mixes them.
131
+ const [a, b, c, d, e, f] = transform ?? [1, 0, 0, 1, 0, 0];
132
+ const mapX = (gx, gy) => originX + (a * gx + c * gy + e) * scale;
133
+ const mapY = (gx, gy) => baselineY - (b * gx + d * gy + f) * scale;
134
+ const out = [];
135
+ let curX = 0;
136
+ let curY = 0;
137
+ for (const cmd of cmds) {
138
+ if (cmd.type === "M") {
139
+ curX = mapX(cmd.x, cmd.y);
140
+ curY = mapY(cmd.x, cmd.y);
141
+ out.push({ op: "m", x: curX, y: curY });
142
+ }
143
+ else if (cmd.type === "L") {
144
+ curX = mapX(cmd.x, cmd.y);
145
+ curY = mapY(cmd.x, cmd.y);
146
+ out.push({ op: "l", x: curX, y: curY });
147
+ }
148
+ else if (cmd.type === "Q") {
149
+ const ctrlX = mapX(cmd.cx, cmd.cy);
150
+ const ctrlY = mapY(cmd.cx, cmd.cy);
151
+ const endX = mapX(cmd.x, cmd.y);
152
+ const endY = mapY(cmd.x, cmd.y);
153
+ out.push({
154
+ op: "c",
155
+ x1: curX + (2 / 3) * (ctrlX - curX),
156
+ y1: curY + (2 / 3) * (ctrlY - curY),
157
+ x2: endX + (2 / 3) * (ctrlX - endX),
158
+ y2: endY + (2 / 3) * (ctrlY - endY),
159
+ x: endX,
160
+ y: endY,
161
+ });
162
+ curX = endX;
163
+ curY = endY;
164
+ }
165
+ else {
166
+ out.push({ op: "z" });
167
+ }
168
+ }
169
+ return out;
170
+ }
171
+ // Maps a COLR v1 gradient paint (font-unit coordinates) into an IR `Gradient` positioned at the
172
+ // pen origin, using the SAME scale + baseline flip as the glyph outline so the gradient axis lines
173
+ // up with the shape. Radii scale uniformly (the em scale is uniform). A null stop color (the COLR
174
+ // "use foreground" sentinel) becomes the text color.
175
+ static _toGradient(paint, originX, baselineY, scale, foreground, transform) {
176
+ const [a, b, c, d, e, f] = transform ?? [1, 0, 0, 1, 0, 0];
177
+ const mapX = (gx, gy) => originX + (a * gx + c * gy + e) * scale;
178
+ const mapY = (gx, gy) => baselineY - (b * gx + d * gy + f) * scale;
179
+ // A radius has no single value under a general affine (a circle can shear into an ellipse); use
180
+ // the affine's geometric-mean scale sqrt(|det|) times the em scale - exact for uniform scaling.
181
+ const radiusScale = scale * Math.sqrt(Math.abs(a * d - b * c));
182
+ const stops = paint.stops.map((s) => ({
183
+ offset: s.offset,
184
+ color: s.color ? new Color(s.color.r, s.color.g, s.color.b, s.color.a / 255) : foreground,
185
+ }));
186
+ if (paint.type === "linearGradient") {
187
+ return {
188
+ type: "linear",
189
+ x0: mapX(paint.p0[0], paint.p0[1]),
190
+ y0: mapY(paint.p0[0], paint.p0[1]),
191
+ x1: mapX(paint.p1[0], paint.p1[1]),
192
+ y1: mapY(paint.p1[0], paint.p1[1]),
193
+ stops,
194
+ extend: paint.extend,
195
+ };
196
+ }
197
+ return {
198
+ type: "radial",
199
+ x0: mapX(paint.c0[0], paint.c0[1]),
200
+ y0: mapY(paint.c0[0], paint.c0[1]),
201
+ r0: paint.c0[2] * radiusScale,
202
+ x1: mapX(paint.c1[0], paint.c1[1]),
203
+ y1: mapY(paint.c1[0], paint.c1[1]),
204
+ r1: paint.c1[2] * radiusScale,
205
+ stops,
206
+ extend: paint.extend,
207
+ };
25
208
  }
26
209
  // Lay the content out into absolutely-positioned text runs. Glyph positions match
27
210
  // the previous hand-written operators exactly (verified by pixel-identical render);
28
211
  // only the output form changed from PDF strings to `TextRun`s.
29
212
  static _buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, maxWidth, textAlignment, color, initialX, yPosition, maxLines, overflow, lineHeight = 1) {
30
213
  const runs = [];
214
+ // /Link annotation rects for any `href` spans, collected as we place segments. Empty for the
215
+ // common (no-href) case, so plain text keeps producing exactly the runs it did before.
216
+ const links = [];
31
217
  // Horizontal offset of a line of the given width under the current alignment.
32
218
  const alignmentOffset = (lineWidth) => {
33
219
  if (textAlignment === HorizontalAlignment.center)
@@ -68,7 +254,7 @@ export class TextRenderer {
68
254
  color,
69
255
  });
70
256
  });
71
- return runs;
257
+ return { runs, links };
72
258
  }
73
259
  // --- Segments: break into lines (shared breaker), then emit one run per segment.
74
260
  // Segment 0 starts at the aligned line origin; each following segment is offset by
@@ -81,6 +267,7 @@ export class TextRenderer {
81
267
  const family = segment.fontFamily || fontFamily;
82
268
  const size = segment.fontSize || fontSize;
83
269
  const style = segment.fontStyle || fontStyle;
270
+ const advance = advanceNoKerning(segment.content, family, size, style);
84
271
  runs.push({
85
272
  type: "text",
86
273
  x,
@@ -91,7 +278,21 @@ export class TextRenderer {
91
278
  fontSize: size,
92
279
  color: segment.fontColor || color,
93
280
  });
94
- x += advanceNoKerning(segment.content, family, size, style);
281
+ // An href/to span becomes a /Link annotation over exactly this run's glyph box. `lineY` is the
282
+ // baseline; the box spans from the ascent (BASELINE_RATIO above it) down by the full size, so
283
+ // ascent + descent are covered. A span wrapped across lines yields one rect per line.
284
+ if (segment.href !== undefined || segment.dest !== undefined) {
285
+ links.push({
286
+ type: "link",
287
+ x,
288
+ y: lineY - size * BASELINE_RATIO,
289
+ width: advance,
290
+ height: size,
291
+ href: segment.href,
292
+ dest: segment.dest,
293
+ });
294
+ }
295
+ x += advance;
95
296
  });
96
297
  };
97
298
  // The overall tallest font seats the first baseline; each line then steps DOWN by
@@ -111,6 +312,6 @@ export class TextRenderer {
111
312
  pushLine(line, lineY + halfLeading);
112
313
  lineY += line.maxFontSize * lineHeight;
113
314
  }
114
- return runs;
315
+ return { runs, links };
115
316
  }
116
317
  }
@@ -0,0 +1 @@
1
+ export declare function isEmojiCodePoint(codePoint: number): boolean;
@@ -0,0 +1,29 @@
1
+ // Whether a code point is (very likely) an emoji, used ONLY by the image/CDN emoji source: unlike a
2
+ // font source, there is no font to ask "do you have a color glyph for this?", so we decide from the
3
+ // well-known emoji blocks. This is a curated, dependency-free subset - it covers the emoji people
4
+ // actually type, not the full Unicode Emoji property list, and intentionally errs toward the common
5
+ // blocks. A font source does NOT use this (it asks the font).
6
+ // [lo, hi] inclusive ranges of the main emoji / pictograph blocks.
7
+ const RANGES = [
8
+ [0x1f000, 0x1faff], // Mahjong/Dominoes/Cards, Misc Symbols & Pictographs, Emoticons, Transport,
9
+ // Supplemental Symbols & Pictographs, Symbols & Pictographs Extended-A (incl. flags)
10
+ [0x2190, 0x21ff], // Arrows
11
+ [0x2300, 0x23ff], // Misc Technical (⌚ ⏰ ⏳, media controls)
12
+ [0x2460, 0x24ff], // Enclosed Alphanumerics (Ⓜ)
13
+ [0x25a0, 0x25ff], // Geometric Shapes (▪ ◼ ⬛)
14
+ [0x2600, 0x27bf], // Misc Symbols + Dingbats (☀ ★ ✂ ✅ ✈ ✉ ✊ ✋)
15
+ [0x2900, 0x297f], // Supplemental Arrows-B (⤴ ⤵)
16
+ [0x2b00, 0x2bff], // Misc Symbols and Arrows (⭐ ⬅ ⬛)
17
+ [0x3000, 0x303f], // CJK Symbols and Punctuation (〰 〽)
18
+ [0x3200, 0x32ff], // Enclosed CJK Letters and Months (㊗ ㊙)
19
+ ];
20
+ // Emoji-presentation singletons outside the ranges above.
21
+ const SINGLES = new Set([0x00a9, 0x00ae, 0x203c, 0x2049, 0x2122, 0x2139, 0xfe0f]);
22
+ export function isEmojiCodePoint(codePoint) {
23
+ if (SINGLES.has(codePoint))
24
+ return true;
25
+ for (const [lo, hi] of RANGES)
26
+ if (codePoint >= lo && codePoint <= hi)
27
+ return true;
28
+ return false;
29
+ }
@@ -133,9 +133,12 @@ function ellipsize(line, fontFamily, fontSize, fontStyle, maxWidth, metrics) {
133
133
  if (fits(words.join(" ")))
134
134
  return words.join(" ") + ELLIPSIS;
135
135
  }
136
- let single = words[0] ?? "";
137
- while (single.length > 1) {
138
- single = single.slice(0, -1);
136
+ // Drop one code point at a time (not one UTF-16 unit) so an astral char is never split into a
137
+ // lone surrogate. `chars` is the code-point view; rebuild the string from its shrinking prefix.
138
+ const chars = [...(words[0] ?? "")];
139
+ while (chars.length > 1) {
140
+ chars.pop();
141
+ const single = chars.join("");
139
142
  if (fits(single))
140
143
  return single + ELLIPSIS;
141
144
  }
@@ -25,6 +25,7 @@ export class AFMParser {
25
25
  return this.glyphMap[char] || char;
26
26
  }
27
27
  parseAFMData(afmData) {
28
+ var _a;
28
29
  const lines = afmData.split("\n");
29
30
  let inCharMetrics = false;
30
31
  for (let line of lines) {
@@ -49,8 +50,10 @@ export class AFMParser {
49
50
  const firstChar = parts[1];
50
51
  const secondChar = parts[2];
51
52
  const kerning = parseFloat(parts[3]);
52
- const pair = `${firstChar}-${secondChar}`;
53
- this.kerningPairs[pair] = kerning;
53
+ // Nested by first glyph, then second. A flat `${a}-${b}` key would have to be BUILT on every
54
+ // lookup, and getKerning runs once per adjacent character pair - that string allocation was a
55
+ // quarter of a standard-font render.
56
+ ((_a = this.kerningPairs)[firstChar] ?? (_a[firstChar] = {}))[secondChar] = kerning;
54
57
  }
55
58
  }
56
59
  }
@@ -75,7 +78,6 @@ export class AFMParser {
75
78
  return this.advanceWidths[this.getGlyphName(charName)] || 0;
76
79
  }
77
80
  getKerning(firstChar, secondChar) {
78
- const pair = `${firstChar}-${secondChar}`;
79
- return this.kerningPairs[pair] || 0;
81
+ return this.kerningPairs[firstChar]?.[secondChar] ?? 0;
80
82
  }
81
83
  }
@@ -0,0 +1,19 @@
1
+ interface Dest {
2
+ pageObjNum: number;
3
+ top: number;
4
+ }
5
+ /**
6
+ * Collects named destinations - the jump targets an internal `Link({ to })` points at, declared with
7
+ * `Anchor({ name })`. `finalize` emits the `/Dests` name-tree fragment for the catalog `/Names` dict, or
8
+ * "" when no anchor was placed. Names are strings the viewer resolves at click time, so a link can point
9
+ * at an anchor on a later (not-yet-rendered) page without any forward-reference bookkeeping.
10
+ */
11
+ export declare class DestRegistry {
12
+ private dests;
13
+ add(name: string, dest: Dest): void;
14
+ get isEmpty(): boolean;
15
+ /** The `/Dests << /Names [ ... ] >>` fragment for the catalog `/Names` dictionary (merged with any
16
+ * `/EmbeddedFiles`). Returns "" when empty. Names are sorted lexically - a name tree requires it. */
17
+ finalize(): string;
18
+ }
19
+ export {};
@@ -0,0 +1,32 @@
1
+ // A PDF literal string escape (backslash first, then the string delimiters).
2
+ const esc = (s) => s.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
3
+ const num = (n) => (Number.isFinite(n) ? Number(n.toFixed(2)) : 0);
4
+ /**
5
+ * Collects named destinations - the jump targets an internal `Link({ to })` points at, declared with
6
+ * `Anchor({ name })`. `finalize` emits the `/Dests` name-tree fragment for the catalog `/Names` dict, or
7
+ * "" when no anchor was placed. Names are strings the viewer resolves at click time, so a link can point
8
+ * at an anchor on a later (not-yet-rendered) page without any forward-reference bookkeeping.
9
+ */
10
+ export class DestRegistry {
11
+ constructor() {
12
+ // Keyed by name so a duplicate name resolves to its last definition (last anchor wins).
13
+ this.dests = new Map();
14
+ }
15
+ add(name, dest) {
16
+ this.dests.set(name, dest);
17
+ }
18
+ get isEmpty() {
19
+ return this.dests.size === 0;
20
+ }
21
+ /** The `/Dests << /Names [ ... ] >>` fragment for the catalog `/Names` dictionary (merged with any
22
+ * `/EmbeddedFiles`). Returns "" when empty. Names are sorted lexically - a name tree requires it. */
23
+ finalize() {
24
+ if (this.dests.size === 0)
25
+ return "";
26
+ const entries = [...this.dests.keys()].sort().map((name) => {
27
+ const d = this.dests.get(name);
28
+ return `(${esc(name)}) [${d.pageObjNum} 0 R /XYZ null ${num(d.top)} null]`;
29
+ });
30
+ return `/Dests << /Names [ ${entries.join(" ")} ] >>`;
31
+ }
32
+ }
@@ -12,18 +12,25 @@ export type CrossAlign = "start" | "center" | "end" | "stretch";
12
12
  * (main = width). "main" is the stacking direction; "cross" is perpendicular.
13
13
  */
14
14
  export interface FlexAxis {
15
+ /** Whether the MAIN (stacking) axis is horizontal - i.e. this is a Row. Used to ask a child for the
16
+ * right relative-size factor (width vs height) when resolving a percentage child. */
17
+ mainHorizontal: boolean;
15
18
  mainOf(size: Size): number;
16
19
  crossOf(size: Size): number;
17
20
  /**
18
- * Constraints for a fixed child: the main extent is unbounded (it takes its natural
19
- * size); the cross extent is ALWAYS capped to what the line offers. The cap is a hard
21
+ * Constraints for a fixed child: the main extent is unbounded by default (the child takes its
22
+ * natural size); the cross extent is ALWAYS capped to what the line offers. The cap is a hard
20
23
  * ceiling regardless of `cross` alignment - it is what guarantees nothing ever overflows
21
24
  * (a paragraph wraps at the column width instead of running one line off the page). A
22
25
  * child smaller than the cap keeps its size and is positioned by `crossOffset`; a child
23
26
  * that wants to fill (Container, Text) fills the cap. So `stretch` vs `start/center/end`
24
27
  * differ only in where a smaller child sits, never in the ceiling.
28
+ *
29
+ * `mainMax` bounds the main axis too (default unbounded): passed only for a PERCENTAGE child, so its
30
+ * `width: "50%"` can resolve against the space the line offers its items. A plain child keeps the
31
+ * unbounded main axis and shrink-wraps as before.
25
32
  */
26
- measureConstraints(crossAvail: number): BoxConstraints;
33
+ measureConstraints(crossAvail: number, mainMax?: number): BoxConstraints;
27
34
  /** Constraints for a flex child (fills the cross axis like a stretched child). */
28
35
  flexConstraints(mainExtent: number, crossAvail: number): BoxConstraints;
29
36
  /** Absolute offset for a child at main position `mainPos`, cross position `crossPos`. */
@@ -1,16 +1,18 @@
1
1
  import { FlexiblePDFElement } from "../elements/pdf-element.js";
2
2
  import { BoxConstraints } from "../layout/box-constraints.js";
3
3
  export const VERTICAL_AXIS = {
4
+ mainHorizontal: false,
4
5
  mainOf: (s) => s.height,
5
6
  crossOf: (s) => s.width,
6
- measureConstraints: (crossAvail) => BoxConstraints.loose(crossAvail, Infinity),
7
+ measureConstraints: (crossAvail, mainMax = Infinity) => BoxConstraints.loose(crossAvail, mainMax),
7
8
  flexConstraints: (mainExtent, crossAvail) => BoxConstraints.loose(crossAvail, mainExtent),
8
9
  offsetAt: (mainPos, crossPos) => ({ x: crossPos, y: mainPos }),
9
10
  };
10
11
  export const HORIZONTAL_AXIS = {
12
+ mainHorizontal: true,
11
13
  mainOf: (s) => s.width,
12
14
  crossOf: (s) => s.height,
13
- measureConstraints: (crossAvail) => BoxConstraints.loose(Infinity, crossAvail),
15
+ measureConstraints: (crossAvail, mainMax = Infinity) => BoxConstraints.loose(mainMax, crossAvail),
14
16
  flexConstraints: (mainExtent, crossAvail) => BoxConstraints.loose(mainExtent, crossAvail),
15
17
  offsetAt: (mainPos, crossPos) => ({ x: mainPos, y: crossPos }),
16
18
  };
@@ -37,6 +39,22 @@ export class FlexLayoutHelper {
37
39
  const main = options.main ?? "start";
38
40
  const cross = options.cross ?? "stretch";
39
41
  const count = children.length;
42
+ const totalGap = Math.max(0, count - 1) * gap;
43
+ // The main extent a percentage child resolves against: the line's own extent minus the gaps
44
+ // between the items. So N children at (100/N)% + gaps fit exactly, instead of overflowing by the
45
+ // gaps (the flexbox `width: 33%` + gap trap). Only finite when the line's main axis is bounded -
46
+ // a fraction of an unbounded main axis has no meaning, so `%` children fall back to shrink-wrap.
47
+ const percentBase = mainAvail !== Infinity ? Math.max(0, mainAvail - totalGap) : Infinity;
48
+ // The main cap to hand a child: `percentBase` for a percentage child (so its fraction resolves) and
49
+ // for a child that cannot lay itself out without a bound (a nested stack holding an `Expanded`/
50
+ // `Spacer`); unbounded for everyone else, who keep their natural size and never fill the line.
51
+ const mainCapFor = (child) => {
52
+ if (percentBase === Infinity)
53
+ return Infinity;
54
+ const needsBound = child.relativeSizeFactor(axis.mainHorizontal) !== undefined ||
55
+ child.needsBoundedMain(axis.mainHorizontal);
56
+ return needsBound ? percentBase : Infinity;
57
+ };
40
58
  // Pass 1: measure the fixed children (main extent + cross size) and total the flex.
41
59
  let fixedMain = 0;
42
60
  let totalFlex = 0;
@@ -47,15 +65,17 @@ export class FlexLayoutHelper {
47
65
  totalFlex += child.getFlex();
48
66
  }
49
67
  else {
50
- const size = child.calculateLayout(axis.measureConstraints(crossAvail), axis.offsetAt(mainStart, crossOrigin), ctx);
68
+ const size = child.calculateLayout(axis.measureConstraints(crossAvail, mainCapFor(child)), axis.offsetAt(mainStart, crossOrigin), ctx);
51
69
  fixedMain += axis.mainOf(size);
52
70
  crossUsed = Math.max(crossUsed, axis.crossOf(size));
53
71
  fixedSize.set(child, size);
54
72
  }
55
73
  }
56
- const totalGap = Math.max(0, count - 1) * gap;
57
74
  const leftover = mainAvail - fixedMain - totalGap;
58
- const remaining = Math.max(leftover, 0); // for flex children
75
+ // A flex child on an UNBOUNDED main axis has no leftover space to claim. It must collapse to zero,
76
+ // never to `Infinity`: an infinite extent would become the offset of every following sibling and get
77
+ // written into the content stream verbatim, silently corrupting the page from that point on.
78
+ const remaining = Number.isFinite(leftover) ? Math.max(leftover, 0) : 0;
59
79
  // Main-axis distribution only kicks in with no flex child and bounded, positive space.
60
80
  let leadingSpace = 0;
61
81
  let betweenSpace = gap;
@@ -101,7 +121,7 @@ export class FlexLayoutHelper {
101
121
  }
102
122
  else {
103
123
  const childCross = axis.crossOf(fixedSize.get(child));
104
- const size = child.calculateLayout(axis.measureConstraints(stretch ? crossExtent : crossAvail), axis.offsetAt(mainPos, crossOrigin + crossOffset(cross, crossExtent, childCross)), ctx);
124
+ const size = child.calculateLayout(axis.measureConstraints(stretch ? crossExtent : crossAvail, mainCapFor(child)), axis.offsetAt(mainPos, crossOrigin + crossOffset(cross, crossExtent, childCross)), ctx);
105
125
  mainExtent = axis.mainOf(size);
106
126
  placedCross = Math.max(placedCross, axis.crossOf(size));
107
127
  }
@@ -0,0 +1,21 @@
1
+ import type { PDFObjectManager } from "./pdf-object-manager.js";
2
+ interface Entry {
3
+ title: string;
4
+ level: number;
5
+ pageObjNum: number;
6
+ top: number;
7
+ }
8
+ /**
9
+ * Builds the PDF document outline (`/Outlines`) - the viewer's bookmark panel. Entries are collected
10
+ * flat in reading order with a 1-based `level`; `finalize` nests each entry under the nearest preceding
11
+ * entry of a smaller level (so level 2s hang under the last level 1), emits the outline objects and
12
+ * returns the catalog addition `/Outlines N 0 R`. A no-op returning "" when nothing was added, so a
13
+ * document without bookmarks stays byte-identical.
14
+ */
15
+ export declare class OutlineBuilder {
16
+ private entries;
17
+ add(entry: Entry): void;
18
+ get isEmpty(): boolean;
19
+ finalize(om: PDFObjectManager): string;
20
+ }
21
+ export {};
@@ -0,0 +1,80 @@
1
+ // A PDF literal string escape (same rule as elsewhere): backslash first, then the string delimiters.
2
+ const esc = (s) => s.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
3
+ const num = (n) => Number(n.toFixed(2));
4
+ /**
5
+ * Builds the PDF document outline (`/Outlines`) - the viewer's bookmark panel. Entries are collected
6
+ * flat in reading order with a 1-based `level`; `finalize` nests each entry under the nearest preceding
7
+ * entry of a smaller level (so level 2s hang under the last level 1), emits the outline objects and
8
+ * returns the catalog addition `/Outlines N 0 R`. A no-op returning "" when nothing was added, so a
9
+ * document without bookmarks stays byte-identical.
10
+ */
11
+ export class OutlineBuilder {
12
+ constructor() {
13
+ this.entries = [];
14
+ }
15
+ add(entry) {
16
+ this.entries.push(entry);
17
+ }
18
+ get isEmpty() {
19
+ return this.entries.length === 0;
20
+ }
21
+ finalize(om) {
22
+ if (this.entries.length === 0)
23
+ return "";
24
+ // Nest by level: a stack where stack[i] is the last open node at level i+1. An entry at level L
25
+ // becomes a child of stack[L-2] (the enclosing smaller level), or a root when L is 1. Levels that
26
+ // skip a step (1 -> 3) are clamped to one deeper than the current stack so nothing is orphaned.
27
+ const roots = [];
28
+ const stack = [];
29
+ for (const e of this.entries) {
30
+ const level = Math.max(1, Math.min(e.level, stack.length + 1));
31
+ stack.length = level - 1; // pop back to this entry's parent depth
32
+ const node = { ...e, children: [] };
33
+ if (level === 1)
34
+ roots.push(node);
35
+ else
36
+ stack[level - 2].children.push(node);
37
+ stack[level - 1] = node;
38
+ }
39
+ // Reserve an object number for every node up front - parent/child/prev/next all cross-reference.
40
+ const objOf = new Map();
41
+ const walk = (nodes) => {
42
+ for (const n of nodes) {
43
+ objOf.set(n, om.addObject(""));
44
+ walk(n.children);
45
+ }
46
+ };
47
+ walk(roots);
48
+ const rootNum = om.addObject(""); // the /Outlines dictionary itself
49
+ // Count of all descendants of a node (used for the /Count of an open item, which shows them all).
50
+ const descendantCount = (n) => n.children.reduce((sum, c) => sum + 1 + descendantCount(c), 0);
51
+ const totalCount = roots.reduce((sum, r) => sum + 1 + descendantCount(r), 0);
52
+ // Emit each item dict, wired to its parent, siblings (prev/next) and first/last child.
53
+ const emit = (nodes, parentNum) => {
54
+ nodes.forEach((n, i) => {
55
+ const parts = [`/Title (${esc(n.title)})`, `/Parent ${parentNum} 0 R`];
56
+ if (i > 0)
57
+ parts.push(`/Prev ${objOf.get(nodes[i - 1])} 0 R`);
58
+ if (i < nodes.length - 1)
59
+ parts.push(`/Next ${objOf.get(nodes[i + 1])} 0 R`);
60
+ if (n.children.length > 0) {
61
+ parts.push(`/First ${objOf.get(n.children[0])} 0 R`);
62
+ parts.push(`/Last ${objOf.get(n.children[n.children.length - 1])} 0 R`);
63
+ parts.push(`/Count ${descendantCount(n)}`); // positive = shown open
64
+ }
65
+ // /Dest: jump to this page, scrolling the target's top into view; null left/zoom keep the
66
+ // viewer's current horizontal position and magnification. Guard the top: a non-finite value
67
+ // (e.g. a bookmark under a Spacer in an unbounded column) would emit invalid PDF, so fall back
68
+ // to the page top (0 flips to the page top edge).
69
+ const top = Number.isFinite(n.top) ? num(n.top) : 0;
70
+ parts.push(`/Dest [${n.pageObjNum} 0 R /XYZ null ${top} null]`);
71
+ om.replaceObject(objOf.get(n), `<< ${parts.join(" ")} >>`);
72
+ emit(n.children, objOf.get(n));
73
+ });
74
+ };
75
+ emit(roots, rootNum);
76
+ om.replaceObject(rootNum, `<< /Type /Outlines /First ${objOf.get(roots[0])} 0 R ` +
77
+ `/Last ${objOf.get(roots[roots.length - 1])} 0 R /Count ${totalCount} >>`);
78
+ return `/Outlines ${rootNum} 0 R`;
79
+ }
80
+ }