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

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 (61) hide show
  1. package/dist/api/descriptor.js +3 -1
  2. package/dist/api/layout.d.ts +47 -14
  3. package/dist/api/layout.js +47 -11
  4. package/dist/api/structure.d.ts +5 -0
  5. package/dist/api/structure.js +1 -0
  6. package/dist/api/text.d.ts +18 -2
  7. package/dist/api/text.js +15 -0
  8. package/dist/elements/container-element.d.ts +10 -1
  9. package/dist/elements/container-element.js +26 -8
  10. package/dist/elements/index.d.ts +2 -0
  11. package/dist/elements/index.js +2 -0
  12. package/dist/elements/layout/keep-together-element.d.ts +42 -0
  13. package/dist/elements/layout/keep-together-element.js +85 -0
  14. package/dist/elements/layout/padding-element.d.ts +3 -0
  15. package/dist/elements/layout/padding-element.js +12 -0
  16. package/dist/elements/layout/page-break-element.d.ts +17 -0
  17. package/dist/elements/layout/page-break-element.js +24 -0
  18. package/dist/elements/layout/positioned-element.d.ts +10 -7
  19. package/dist/elements/layout/positioned-element.js +21 -9
  20. package/dist/elements/page-element.d.ts +11 -1
  21. package/dist/elements/page-element.js +41 -16
  22. package/dist/elements/pdf-element.d.ts +28 -0
  23. package/dist/elements/pdf-element.js +32 -0
  24. package/dist/elements/rectangle-element.d.ts +10 -1
  25. package/dist/elements/rectangle-element.js +27 -6
  26. package/dist/elements/row-element.d.ts +9 -1
  27. package/dist/elements/row-element.js +9 -1
  28. package/dist/elements/text-element.d.ts +30 -4
  29. package/dist/elements/text-element.js +40 -17
  30. package/dist/ir/display-list.d.ts +1 -0
  31. package/dist/layout/fragmentation.d.ts +12 -0
  32. package/dist/layout/fragmentation.js +45 -8
  33. package/dist/renderer/keep-together-renderer.d.ts +10 -0
  34. package/dist/renderer/keep-together-renderer.js +12 -0
  35. package/dist/renderer/page-break-renderer.d.ts +14 -0
  36. package/dist/renderer/page-break-renderer.js +15 -0
  37. package/dist/renderer/pdf-backend.d.ts +7 -0
  38. package/dist/renderer/pdf-backend.js +40 -6
  39. package/dist/renderer/pdf-document-class.js +3 -0
  40. package/dist/renderer/pdf-document-renderer.js +26 -7
  41. package/dist/renderer/pdf-renderer.js +6 -0
  42. package/dist/renderer/text-renderer.d.ts +1 -1
  43. package/dist/renderer/text-renderer.js +107 -59
  44. package/dist/text/advance.d.ts +34 -0
  45. package/dist/text/advance.js +42 -0
  46. package/dist/text/line-breaker.d.ts +9 -8
  47. package/dist/text/line-breaker.js +28 -21
  48. package/dist/text/line-metrics.d.ts +49 -0
  49. package/dist/text/line-metrics.js +57 -0
  50. package/dist/text/text-decoration.d.ts +50 -0
  51. package/dist/text/text-decoration.js +62 -0
  52. package/dist/text/text-style.d.ts +13 -3
  53. package/dist/text/text-style.js +10 -3
  54. package/dist/utils/afm-parser.d.ts +34 -0
  55. package/dist/utils/afm-parser.js +67 -0
  56. package/dist/utils/font-metrics.d.ts +14 -0
  57. package/dist/utils/pdf-object-manager.d.ts +27 -1
  58. package/dist/utils/pdf-object-manager.js +134 -20
  59. package/dist/utils/ttf-parser.d.ts +53 -0
  60. package/dist/utils/ttf-parser.js +368 -0
  61. package/package.json +1 -1
@@ -3,28 +3,30 @@ import { HorizontalAlignment } from "../elements/pdf-element.js";
3
3
  import { isEmojiCodePoint } from "../text/emoji-codepoints.js";
4
4
  import { emojiImageNode } from "./emoji-image.js";
5
5
  import { wrapStringIntoLines, breakSegmentsIntoLines, } from "../text/line-breaker.js";
6
- // Distance from the top of a line down to its baseline, as a fraction of the font
7
- // size. ~0.683 is the standard-14 ascent ratio used to seat the first baseline.
8
- const BASELINE_RATIO = 683 / 1000;
6
+ import { lineBoxForSegmentLine, lineBoxForString } from "../text/line-metrics.js";
7
+ import { runAdvance } from "../text/advance.js";
8
+ import { skipInkSegments, strikethroughStroke, underlineStroke, } from "../text/text-decoration.js";
9
9
  export class TextRenderer {
10
10
  // Measuring only needs metrics, not the full object manager. (The render pass below
11
11
  // still receives the manager because it also registers fonts/images.)
12
- static calculateTextHeight(content, fontSize, fontFamily, fontStyle, objectManager, maxWidth, maxLines, overflow, lineHeight = 1) {
13
- // Plain string: one line box (fontSize * lineHeight) per wrapped line.
12
+ static calculateTextHeight(content, fontSize, fontFamily, fontStyle, objectManager, maxWidth, maxLines, overflow, lineHeight, letterSpacing = 0) {
13
+ // Plain string: every wrapped line gets the same box.
14
14
  if (typeof content === "string") {
15
- const lines = wrapStringIntoLines(content, fontFamily, fontSize, fontStyle, maxWidth, objectManager, maxLines, overflow);
16
- return lines.length * fontSize * lineHeight;
15
+ const lines = wrapStringIntoLines(content, fontFamily, fontSize, fontStyle, maxWidth, objectManager, maxLines, overflow, letterSpacing);
16
+ const box = lineBoxForString(objectManager, fontFamily, fontStyle, fontSize, lineHeight);
17
+ return lines.length * box.height;
17
18
  }
18
- // Segments: each line contributes its own (tallest-on-line) leading, scaled by lineHeight.
19
- const lines = breakSegmentsIntoLines(content, { fontFamily, fontSize, fontStyle }, maxWidth, objectManager, maxLines, overflow);
20
- return lines.reduce((total, line) => total + line.maxFontSize * lineHeight, 0);
19
+ // Segments: each line's box comes from the fonts actually on it.
20
+ const defaults = { fontFamily, fontSize, fontStyle, letterSpacing };
21
+ const lines = breakSegmentsIntoLines(content, defaults, maxWidth, objectManager, maxLines, overflow);
22
+ return lines.reduce((total, line) => total + lineBoxForSegmentLine(line, defaults, objectManager, lineHeight).height, 0);
21
23
  }
22
24
  static async render(textElement, objectManager) {
23
- const { x, y, width, fontSize, color, content, fontFamily, fontStyle, textAlignment, maxLines, overflow, lineHeight, role, } = textElement.getProps();
25
+ const { x, y, width, fontSize, color, content, fontFamily, fontStyle, textAlignment, maxLines, overflow, lineHeight, underline, strikethrough, skipInk, letterSpacing, role, } = textElement.getProps();
24
26
  // Component -> display list. Wrapping and positioning stay here; the backend
25
27
  // turns each run into BT/Tf/Td/Tj/ET. The wrapping algorithm is unchanged from
26
28
  // the original renderer - unifying it into the engine is Phase 3.
27
- const { runs, links } = TextRenderer._buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, width ?? Number.NaN, textAlignment, color, x, y, maxLines, overflow, lineHeight);
29
+ const { runs, links, decorations } = TextRenderer._buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, width ?? Number.NaN, textAlignment, color, x, y, maxLines, overflow, lineHeight, underline, strikethrough, skipInk, letterSpacing);
28
30
  // Accessible tagging: this whole text block is one structure element (a paragraph P, or a heading
29
31
  // from `role`); each run's text lands under it. Emoji sub-nodes produced below stay untagged (drawn
30
32
  // as artifacts). Keyed by structId so a block split across pages stays one element.
@@ -37,9 +39,11 @@ export class TextRenderer {
37
39
  // Color fonts (COLR/CPAL): expand each run's emoji into filled vector layers (or fetched images
38
40
  // for an image emoji source); plain runs and monochrome fonts pass straight through unchanged.
39
41
  const drawn = (await Promise.all(runs.map((run) => TextRenderer._expandColorGlyphs(run, objectManager)))).flat();
42
+ // Underline / strikethrough go on TOP of the glyphs (a strikethrough must), and stay untagged,
43
+ // so the structure tree treats them as artifacts rather than as text.
40
44
  // Inline hyperlinks (href spans) ride along as /Link annotations - they draw nothing, so order
41
45
  // vs the drawn runs does not matter; the page renderer peels them off into /Annots.
42
- return links.length > 0 ? [...drawn, ...links] : drawn;
46
+ return [...drawn, ...decorations, ...links];
43
47
  }
44
48
  // Splits a text run into normal text sub-runs + filled `Path` layers for any COLR color glyphs,
45
49
  // walking code point by code point and advancing the pen by each glyph's width. Returns `[run]`
@@ -82,8 +86,10 @@ export class TextRenderer {
82
86
  if (l)
83
87
  [source, layers] = [fallback, l];
84
88
  }
85
- // The advance matches measuring (getCharWidth applies the same emoji fallback).
86
- const advance = om.getCharWidth(ch, run.fontSize, undefined, run.fontFamily, run.fontStyle);
89
+ // The advance matches measuring (getCharWidth applies the same emoji fallback), plus the run's
90
+ // letter-spacing so a spaced run's emoji sit where `Tc` advances them.
91
+ const advance = om.getCharWidth(ch, run.fontSize, undefined, run.fontFamily, run.fontStyle) +
92
+ (run.letterSpacing ?? 0);
87
93
  if (source && layers) {
88
94
  flushPending();
89
95
  const scale = run.fontSize / source.unitsPerEm;
@@ -209,11 +215,14 @@ export class TextRenderer {
209
215
  // Lay the content out into absolutely-positioned text runs. Glyph positions match
210
216
  // the previous hand-written operators exactly (verified by pixel-identical render);
211
217
  // only the output form changed from PDF strings to `TextRun`s.
212
- static _buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, maxWidth, textAlignment, color, initialX, yPosition, maxLines, overflow, lineHeight = 1) {
218
+ static _buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, maxWidth, textAlignment, color, initialX, yPosition, maxLines, overflow, lineHeight, underline = false, strikethrough = false, skipInk = false, letterSpacing = 0) {
213
219
  const runs = [];
214
220
  // /Link annotation rects for any `href` spans, collected as we place segments. Empty for the
215
221
  // common (no-href) case, so plain text keeps producing exactly the runs it did before.
216
222
  const links = [];
223
+ // Underline / strikethrough strokes, one per decorated run (so a wrapped paragraph gets one
224
+ // per line, and a decorated span only spans its own glyphs).
225
+ const decorations = [];
217
226
  // Horizontal offset of a line of the given width under the current alignment.
218
227
  const alignmentOffset = (lineWidth) => {
219
228
  if (textAlignment === HorizontalAlignment.center)
@@ -222,39 +231,78 @@ export class TextRenderer {
222
231
  return maxWidth - lineWidth;
223
232
  return 0;
224
233
  };
225
- // Advance width WITHOUT kerning. This is how Tj moves the text cursor, so
226
- // segments flowing after each other land here. (Standard-14 fonts carry no
227
- // /Widths array; the viewer advances by the AFM widths - same source as below.)
228
- const advanceNoKerning = (text, family, size, style) => {
229
- let width = 0;
230
- for (const ch of text) {
231
- width += objectManager.getCharWidth(ch, size, undefined, family, style);
234
+ // The decoration strokes for one drawn run, at the geometry ITS OWN font declares. A mixed-size
235
+ // line therefore gets a thicker line under the bigger span, which is what a browser does too.
236
+ const decorate = (text, runX, runWidth, baselineY, family, style, size, runColor, wantsUnderline, wantsStrikethrough, runLetterSpacing) => {
237
+ if ((!wantsUnderline && !wantsStrikethrough) || runWidth <= 0)
238
+ return;
239
+ const metrics = objectManager.getFontDecoration(family, style);
240
+ const push = (from, to, stroke) => {
241
+ decorations.push({
242
+ type: "line",
243
+ x1: runX + from,
244
+ y1: stroke.y,
245
+ x2: runX + to,
246
+ y2: stroke.y,
247
+ stroke: runColor,
248
+ strokeWidth: stroke.thickness,
249
+ });
250
+ };
251
+ if (wantsUnderline) {
252
+ const stroke = underlineStroke(metrics, size, baselineY);
253
+ for (const [from, to] of underlineSegments(text, runWidth, baselineY, size, family, style, stroke, runLetterSpacing)) {
254
+ push(from, to, stroke);
255
+ }
256
+ }
257
+ // A strikethrough crosses the letters by definition, so it is never interrupted.
258
+ if (wantsStrikethrough) {
259
+ const stroke = strikethroughStroke(metrics, size, baselineY);
260
+ push(0, runWidth, stroke);
232
261
  }
233
- return width;
234
262
  };
263
+ // The x-ranges the underline actually draws. Without `skipInk` that is the whole run; with it,
264
+ // the descenders are cut out of it (which needs the real outlines, i.e. an embedded font).
265
+ const underlineSegments = (text, runWidth, baselineY, size, family, style, stroke, runLetterSpacing) => {
266
+ if (!skipInk)
267
+ return [[0, runWidth]];
268
+ if (!objectManager.isCustomFont(family, style)) {
269
+ throw new Error(`skipInk needs an embedded font, but "${family}" is a standard-14 font whose glyph outlines ` +
270
+ `live in the PDF viewer, not in the document - we cannot see where its ink is. Register a ` +
271
+ `font (the document's \`fonts\` option) or drop \`skipInk\`.`);
272
+ }
273
+ // The band the stroke covers, in points from the baseline, y UP (so both values are negative).
274
+ const below = stroke.y - baselineY;
275
+ const half = stroke.thickness / 2;
276
+ const inkSpans = objectManager.getInkSpansInBand(text, size, family, style, -below + half, -below - half, runLetterSpacing);
277
+ return skipInkSegments(runWidth, inkSpans, stroke.thickness);
278
+ };
279
+ // The advance the viewer will use: plain glyph widths (a `Tj` never kerns) plus one
280
+ // `letterSpacing` per code point (the `Tc` operator), from the one shared `advance.ts` primitive.
281
+ const font = { fontFamily, fontSize, fontStyle };
235
282
  // --- Plain string: one run per wrapped line. ---
236
283
  if (typeof content === "string") {
237
- const lines = wrapStringIntoLines(content, fontFamily, fontSize, fontStyle, maxWidth, objectManager, maxLines, overflow);
238
- // yPosition is the top of the text box (top-left); seat line 0's baseline below it, then step
239
- // DOWN by one line box (fontSize * lineHeight) per line. The lineHeight EXTRA leading is split
240
- // half above / half below (CSS/Flutter "half-leading"), so the text sits centered in its line
241
- // box instead of clinging to the top. At lineHeight 1 the half-leading is 0 -> byte-identical.
242
- const halfLeading = (fontSize * (lineHeight - 1)) / 2;
243
- const baseline = yPosition + halfLeading + fontSize * BASELINE_RATIO;
284
+ const lines = wrapStringIntoLines(content, fontFamily, fontSize, fontStyle, maxWidth, objectManager, maxLines, overflow, letterSpacing);
285
+ // yPosition is the top of the text box (top-left). The line box seats its own baseline; lines
286
+ // then stack by that box's height. Same numbers as calculateTextHeight, by construction.
287
+ const box = lineBoxForString(objectManager, fontFamily, fontStyle, fontSize, lineHeight);
244
288
  lines.forEach((line, index) => {
245
- const lineWidth = objectManager.getStringWidth(line, fontFamily, fontSize, fontStyle);
289
+ const lineWidth = runAdvance(objectManager, line, font, letterSpacing);
290
+ const x = initialX + alignmentOffset(lineWidth);
291
+ const baseline = yPosition + box.baseline + box.height * index;
246
292
  runs.push({
247
293
  type: "text",
248
- x: initialX + alignmentOffset(lineWidth),
249
- y: baseline + fontSize * lineHeight * index,
294
+ x,
295
+ y: baseline,
250
296
  text: line,
251
297
  fontFamily,
252
298
  fontStyle,
253
299
  fontSize,
254
300
  color,
301
+ ...(letterSpacing ? { letterSpacing } : {}),
255
302
  });
303
+ decorate(line, x, lineWidth, baseline, fontFamily, fontStyle, fontSize, color, underline, strikethrough, letterSpacing);
256
304
  });
257
- return { runs, links };
305
+ return { runs, links, decorations };
258
306
  }
259
307
  // --- Segments: break into lines (shared breaker), then emit one run per segment.
260
308
  // Segment 0 starts at the aligned line origin; each following segment is offset by
@@ -267,7 +315,9 @@ export class TextRenderer {
267
315
  const family = segment.fontFamily || fontFamily;
268
316
  const size = segment.fontSize || fontSize;
269
317
  const style = segment.fontStyle || fontStyle;
270
- const advance = advanceNoKerning(segment.content, family, size, style);
318
+ const segLetterSpacing = segment.letterSpacing ?? letterSpacing;
319
+ const advance = runAdvance(objectManager, segment.content, { fontFamily: family, fontSize: size, fontStyle: style }, segLetterSpacing);
320
+ const runColor = segment.fontColor || color;
271
321
  runs.push({
272
322
  type: "text",
273
323
  x,
@@ -276,18 +326,21 @@ export class TextRenderer {
276
326
  fontFamily: family,
277
327
  fontStyle: style,
278
328
  fontSize: size,
279
- color: segment.fontColor || color,
329
+ color: runColor,
330
+ ...(segLetterSpacing ? { letterSpacing: segLetterSpacing } : {}),
280
331
  });
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.
332
+ decorate(segment.content, x, advance, lineY, family, style, size, runColor, segment.underline ?? underline, segment.strikethrough ?? strikethrough, segLetterSpacing);
333
+ // An href/to span becomes a /Link annotation over exactly this run's glyph box: from its own
334
+ // ascent above the baseline down to its own descender. A span wrapped across lines yields
335
+ // one rect per line.
284
336
  if (segment.href !== undefined || segment.dest !== undefined) {
337
+ const v = objectManager.getFontVerticals(family, style);
285
338
  links.push({
286
339
  type: "link",
287
340
  x,
288
- y: lineY - size * BASELINE_RATIO,
341
+ y: lineY - v.ascent * size,
289
342
  width: advance,
290
- height: size,
343
+ height: (v.ascent + v.descent) * size,
291
344
  href: segment.href,
292
345
  dest: segment.dest,
293
346
  });
@@ -295,23 +348,18 @@ export class TextRenderer {
295
348
  x += advance;
296
349
  });
297
350
  };
298
- // The overall tallest font seats the first baseline; each line then steps DOWN by
299
- // its own leading. yPosition is the top of the text box (top-left); the seam flips
300
- // the whole thing to PDF space.
301
- let overallMaxFont = fontSize;
302
- for (const segment of content) {
303
- const size = segment.fontSize || fontSize;
304
- if (size > overallMaxFont)
305
- overallMaxFont = size;
306
- }
307
- let lineY = yPosition + overallMaxFont * BASELINE_RATIO;
308
- for (const line of breakSegmentsIntoLines(content, { fontFamily, fontSize, fontStyle }, maxWidth, objectManager, maxLines, overflow)) {
309
- // Half-leading: shift this line's baseline down by half its own extra leading, so the line
310
- // sits centered in its box (CSS/Flutter). At lineHeight 1 the shift is 0 -> byte-identical.
311
- const halfLeading = (line.maxFontSize * (lineHeight - 1)) / 2;
312
- pushLine(line, lineY + halfLeading);
313
- lineY += line.maxFontSize * lineHeight;
351
+ // yPosition is the top of the text box (top-left). Each line seats its own baseline inside its
352
+ // own box (tallest ascent / deepest descent ON THAT LINE), then the next line starts below it.
353
+ // The seam flips the whole thing to PDF space. `letterSpacing` MUST be in the defaults so a span
354
+ // that does not override it wraps and aligns with the element's spacing - the same defaults the
355
+ // measure path (calculateTextHeight) uses, or segmented spaced text mis-wraps (measured != drawn).
356
+ const defaults = { fontFamily, fontSize, fontStyle, letterSpacing };
357
+ let top = yPosition;
358
+ for (const line of breakSegmentsIntoLines(content, defaults, maxWidth, objectManager, maxLines, overflow)) {
359
+ const box = lineBoxForSegmentLine(line, defaults, objectManager, lineHeight);
360
+ pushLine(line, top + box.baseline);
361
+ top += box.height;
314
362
  }
315
- return { runs, links };
363
+ return { runs, links, decorations };
316
364
  }
317
365
  }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * How wide a run of text is: the horizontal counterpart to `line-metrics.ts` (how tall a line is)
3
+ * and `line-breaker.ts` (where a line breaks). The ONE canonical answer to "how far does the pen
4
+ * move", so measuring and drawing can never disagree about it.
5
+ *
6
+ * An advance has three parts, each with its own PDF mechanism:
7
+ *
8
+ * advance = sum(glyph widths) + sum(kerning) + n * letterSpacing
9
+ * /Widths TJ array Tc
10
+ *
11
+ * `letterSpacing` is added after EVERY code point, the last one included - exactly what the `Tc`
12
+ * operator does, and exactly what CSS `letter-spacing` does (which is why right-aligned spaced text
13
+ * looks a hair offset: a spacing hangs off the final glyph). Measuring `(n - 1)` spacings while `Tc`
14
+ * applies `n` would make every line draw one spacing wider than it was wrapped - text out of its box.
15
+ *
16
+ * Kerning is added ONLY when the document has it on (`metrics.kerningEnabled`), and then the backend
17
+ * emits a `TJ` with the same per-pair adjustments - so a measured advance always equals the drawn
18
+ * one. With kerning off, `getStringWidth` is the whole advance (its plain glyph sum), byte-identical
19
+ * to before. Folding kerning into the measurement while the output ignored it was the 2026-07-10 bug.
20
+ */
21
+ import type { FontStyle } from "../utils/pdf-object-manager.js";
22
+ import type { FontMetrics } from "../utils/font-metrics.js";
23
+ /** The font a run is set in. */
24
+ export interface RunFont {
25
+ fontFamily: string;
26
+ fontSize: number;
27
+ fontStyle: FontStyle;
28
+ }
29
+ /** Number of Unicode code points (not UTF-16 units): an astral char is ONE, so it takes one
30
+ * letter-spacing, not two. Matches how `getStringWidth` iterates. */
31
+ export declare function codePointCount(text: string): number;
32
+ /** The advance of `text` in points: the plain glyph widths, plus kerning (if the document has it on),
33
+ * plus one `letterSpacing` per code point. */
34
+ export declare function runAdvance(metrics: FontMetrics, text: string, font: RunFont, letterSpacing?: number): number;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * How wide a run of text is: the horizontal counterpart to `line-metrics.ts` (how tall a line is)
3
+ * and `line-breaker.ts` (where a line breaks). The ONE canonical answer to "how far does the pen
4
+ * move", so measuring and drawing can never disagree about it.
5
+ *
6
+ * An advance has three parts, each with its own PDF mechanism:
7
+ *
8
+ * advance = sum(glyph widths) + sum(kerning) + n * letterSpacing
9
+ * /Widths TJ array Tc
10
+ *
11
+ * `letterSpacing` is added after EVERY code point, the last one included - exactly what the `Tc`
12
+ * operator does, and exactly what CSS `letter-spacing` does (which is why right-aligned spaced text
13
+ * looks a hair offset: a spacing hangs off the final glyph). Measuring `(n - 1)` spacings while `Tc`
14
+ * applies `n` would make every line draw one spacing wider than it was wrapped - text out of its box.
15
+ *
16
+ * Kerning is added ONLY when the document has it on (`metrics.kerningEnabled`), and then the backend
17
+ * emits a `TJ` with the same per-pair adjustments - so a measured advance always equals the drawn
18
+ * one. With kerning off, `getStringWidth` is the whole advance (its plain glyph sum), byte-identical
19
+ * to before. Folding kerning into the measurement while the output ignored it was the 2026-07-10 bug.
20
+ */
21
+ /** Number of Unicode code points (not UTF-16 units): an astral char is ONE, so it takes one
22
+ * letter-spacing, not two. Matches how `getStringWidth` iterates. */
23
+ export function codePointCount(text) {
24
+ let n = 0;
25
+ for (const _ of text)
26
+ n++;
27
+ return n;
28
+ }
29
+ /** The advance of `text` in points: the plain glyph widths, plus kerning (if the document has it on),
30
+ * plus one `letterSpacing` per code point. */
31
+ export function runAdvance(metrics, text, font, letterSpacing = 0) {
32
+ let advance = metrics.getStringWidth(text, font.fontFamily, font.fontSize, font.fontStyle);
33
+ if (letterSpacing !== 0)
34
+ advance += codePointCount(text) * letterSpacing;
35
+ if (metrics.kerningEnabled) {
36
+ let units = 0;
37
+ for (const k of metrics.getKernPairs(text, font.fontFamily, font.fontStyle))
38
+ units += k;
39
+ advance += (units / 1000) * font.fontSize; // kern units are em/1000
40
+ }
41
+ return advance;
42
+ }
@@ -6,16 +6,17 @@ export interface SegmentDefaults {
6
6
  fontFamily: string;
7
7
  fontSize: number;
8
8
  fontStyle: FontStyle;
9
+ /** Extra space after every glyph, in points; a segment may override it. Default 0. */
10
+ letterSpacing?: number;
9
11
  }
10
12
  /** What happens to text beyond `maxLines`: `"clip"` drops it, `"ellipsis"` ends the last kept line
11
13
  * with an ellipsis. Mirrors Flutter's `TextOverflow`. */
12
14
  export type TextOverflow = "clip" | "ellipsis";
13
- /** One laid-out line of segments. `maxFontSize` is the tallest font ON THIS LINE - its
14
- * leading - matching how real engines (and this lib's plain-string path) space lines. */
15
+ /** One laid-out line of segments. How TALL it is does not live here: the breaker owns the horizontal
16
+ * half, `text/line-metrics.ts` owns the vertical one and derives the box from the fonts on the line. */
15
17
  export interface SegmentLine {
16
18
  segments: TextSegment[];
17
19
  width: number;
18
- maxFontSize: number;
19
20
  }
20
21
  /**
21
22
  * Break a plain string into lines that each fit within `maxWidth`, splitting on
@@ -25,12 +26,12 @@ export interface SegmentLine {
25
26
  * rendering call this, so they can never disagree. Depends only on `FontMetrics`,
26
27
  * not the PDF byte writer - the future fragmentation pass can reuse it.
27
28
  */
28
- export declare function wrapStringIntoLines(text: string, fontFamily: string, fontSize: number, fontStyle: FontStyle, maxWidth: number, metrics: FontMetrics, maxLines?: number, overflow?: TextOverflow): string[];
29
+ export declare function wrapStringIntoLines(text: string, fontFamily: string, fontSize: number, fontStyle: FontStyle, maxWidth: number, metrics: FontMetrics, maxLines?: number, overflow?: TextOverflow, letterSpacing?: number): string[];
29
30
  /**
30
- * Break styled segments into lines that fit within `maxWidth`. Same greedy
31
- * word-splitting as the string breaker, but each line records the tallest font on
32
- * THAT line as its leading (per-line, not a paragraph-global maximum). Single source
33
- * of truth: both height measurement and rendering call this.
31
+ * Break styled segments into lines that fit within `maxWidth`. Same greedy word-splitting as the
32
+ * string breaker; each line keeps the segments that landed on it, which is what `line-metrics.ts`
33
+ * later derives the line's height from. Single source of truth: both height measurement and
34
+ * rendering call this.
34
35
  */
35
36
  export declare function breakSegmentsIntoLines(segments: TextSegment[], defaults: SegmentDefaults, maxWidth: number, metrics: FontMetrics, maxLines?: number, overflow?: TextOverflow): SegmentLine[];
36
37
  /**
@@ -1,3 +1,4 @@
1
+ import { runAdvance } from "./advance.js";
1
2
  /** Three ASCII dots, NOT the "…" glyph (U+2026): plain dots encode in every font - standard-14
2
3
  * (WinAnsi) and any embedded TTF - whereas U+2026 needs a glyph the font may not carry. */
3
4
  const ELLIPSIS = "...";
@@ -9,14 +10,17 @@ const ELLIPSIS = "...";
9
10
  * rendering call this, so they can never disagree. Depends only on `FontMetrics`,
10
11
  * not the PDF byte writer - the future fragmentation pass can reuse it.
11
12
  */
12
- export function wrapStringIntoLines(text, fontFamily, fontSize, fontStyle, maxWidth, metrics, maxLines, overflow) {
13
+ export function wrapStringIntoLines(text, fontFamily, fontSize, fontStyle, maxWidth, metrics, maxLines, overflow, letterSpacing = 0) {
13
14
  let currentLine = "";
14
15
  let currentWidth = 0;
15
16
  const lines = [];
17
+ const font = { fontFamily, fontSize, fontStyle };
16
18
  const words = text.split(" ");
17
19
  words.forEach((word, index) => {
18
- const wordWidth = metrics.getStringWidth(word, fontFamily, fontSize, fontStyle);
19
- const spaceWidth = metrics.getCharWidth(" ", fontSize, undefined, fontFamily, fontStyle);
20
+ // Word and space advances come from the one shared primitive (`advance.ts`), the same one
21
+ // `naturalWidth` uses - so a bounded and an unbounded layout of the same text agree bit for bit.
22
+ const wordWidth = runAdvance(metrics, word, font, letterSpacing);
23
+ const spaceWidth = runAdvance(metrics, " ", font, letterSpacing);
20
24
  // Break before a word that won't fit - but only once the line has content. A single word wider
21
25
  // than maxWidth must sit on its (empty) line and overflow, not push a phantom empty line before
22
26
  // it (which would over-count the height by a line and shift the text down at render).
@@ -38,44 +42,41 @@ export function wrapStringIntoLines(text, fontFamily, fontSize, fontStyle, maxWi
38
42
  const kept = lines.slice(0, maxLines);
39
43
  if (overflow === "ellipsis") {
40
44
  const last = kept.length - 1;
41
- kept[last] = ellipsize(kept[last], fontFamily, fontSize, fontStyle, maxWidth, metrics);
45
+ kept[last] = ellipsize(kept[last], fontFamily, fontSize, fontStyle, maxWidth, metrics, letterSpacing);
42
46
  }
43
47
  return kept;
44
48
  }
45
49
  /**
46
- * Break styled segments into lines that fit within `maxWidth`. Same greedy
47
- * word-splitting as the string breaker, but each line records the tallest font on
48
- * THAT line as its leading (per-line, not a paragraph-global maximum). Single source
49
- * of truth: both height measurement and rendering call this.
50
+ * Break styled segments into lines that fit within `maxWidth`. Same greedy word-splitting as the
51
+ * string breaker; each line keeps the segments that landed on it, which is what `line-metrics.ts`
52
+ * later derives the line's height from. Single source of truth: both height measurement and
53
+ * rendering call this.
50
54
  */
51
55
  export function breakSegmentsIntoLines(segments, defaults, maxWidth, metrics, maxLines, overflow) {
52
56
  const lines = [];
53
57
  let width = 0;
54
- let maxFontSize = 0; // per line: starts at 0, grows to the tallest font on the line
55
58
  let lineSegments = [];
56
59
  let combined = "";
57
60
  segments.forEach((segment) => {
58
61
  const family = segment.fontFamily || defaults.fontFamily;
59
62
  const size = segment.fontSize || defaults.fontSize;
60
63
  const style = segment.fontStyle || defaults.fontStyle;
61
- const spaceWidth = metrics.getCharWidth(" ", size, undefined, family, style);
64
+ const letterSpacing = segment.letterSpacing ?? defaults.letterSpacing ?? 0;
65
+ const font = { fontFamily: family, fontSize: size, fontStyle: style };
66
+ const spaceWidth = runAdvance(metrics, " ", font, letterSpacing);
62
67
  const words = segment.content.split(" ");
63
68
  // Start this segment's piece empty; its content is filled word-by-word below. (Not
64
69
  // the original content - otherwise a segment whose FIRST word overflows would carry
65
70
  // its whole text into the line that just closed.)
66
71
  lineSegments.push({ ...segment, fontFamily: family, content: "" });
67
72
  combined = "";
68
- if (maxFontSize < size)
69
- maxFontSize = size;
70
73
  words.forEach((word, wordIndex) => {
71
- const wordWidth = metrics.getStringWidth(word, family, size, style);
74
+ const wordWidth = runAdvance(metrics, word, font, letterSpacing);
72
75
  // Same guard as the string path: don't open a phantom empty line for an over-wide first word -
73
76
  // place it (overflowing) on the current empty line instead.
74
77
  if (width + wordWidth > maxWidth && width > 0) {
75
- lines.push({ segments: lineSegments, width, maxFontSize });
76
- // Start the next line; its leading resets to the wrapping segment's size.
78
+ lines.push({ segments: lineSegments, width });
77
79
  width = 0;
78
- maxFontSize = size;
79
80
  lineSegments = [];
80
81
  combined = word;
81
82
  width += wordWidth + spaceWidth;
@@ -92,7 +93,7 @@ export function breakSegmentsIntoLines(segments, defaults, maxWidth, metrics, ma
92
93
  });
93
94
  });
94
95
  if (lineSegments.length > 0) {
95
- lines.push({ segments: lineSegments, width, maxFontSize });
96
+ lines.push({ segments: lineSegments, width });
96
97
  }
97
98
  // Open-end by default; cap only when maxLines is set.
98
99
  if (maxLines == null || lines.length <= maxLines)
@@ -123,8 +124,9 @@ export function segmentLinesToSegments(lines) {
123
124
  }
124
125
  /** Appends "…" to a single line, dropping trailing words (then characters) until the line plus the
125
126
  * ellipsis fits `maxWidth`. Falls back to a bare "…" if not even one character fits. */
126
- function ellipsize(line, fontFamily, fontSize, fontStyle, maxWidth, metrics) {
127
- const fits = (s) => metrics.getStringWidth(s + ELLIPSIS, fontFamily, fontSize, fontStyle) <= maxWidth;
127
+ function ellipsize(line, fontFamily, fontSize, fontStyle, maxWidth, metrics, letterSpacing = 0) {
128
+ const font = { fontFamily, fontSize, fontStyle };
129
+ const fits = (s) => runAdvance(metrics, s + ELLIPSIS, font, letterSpacing) <= maxWidth;
128
130
  if (fits(line))
129
131
  return line + ELLIPSIS;
130
132
  const words = line.split(" ");
@@ -150,11 +152,16 @@ function ellipsizeSegmentLine(line, defaults, maxWidth, metrics) {
150
152
  const segs = line.segments;
151
153
  if (segs.length === 0)
152
154
  return;
153
- const widthOf = (seg) => metrics.getStringWidth(seg.content, seg.fontFamily || defaults.fontFamily, seg.fontSize || defaults.fontSize, seg.fontStyle || defaults.fontStyle);
155
+ const lsOf = (seg) => seg.letterSpacing ?? defaults.letterSpacing ?? 0;
156
+ const widthOf = (seg) => runAdvance(metrics, seg.content, {
157
+ fontFamily: seg.fontFamily || defaults.fontFamily,
158
+ fontSize: seg.fontSize || defaults.fontSize,
159
+ fontStyle: seg.fontStyle || defaults.fontStyle,
160
+ }, lsOf(seg));
154
161
  let prefix = 0;
155
162
  for (let i = 0; i < segs.length - 1; i++)
156
163
  prefix += widthOf(segs[i]);
157
164
  const last = segs[segs.length - 1];
158
- last.content = ellipsize(last.content, last.fontFamily || defaults.fontFamily, last.fontSize || defaults.fontSize, last.fontStyle || defaults.fontStyle, maxWidth - prefix, metrics);
165
+ last.content = ellipsize(last.content, last.fontFamily || defaults.fontFamily, last.fontSize || defaults.fontSize, last.fontStyle || defaults.fontStyle, maxWidth - prefix, metrics, lsOf(last));
159
166
  line.width = prefix + widthOf(last);
160
167
  }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The vertical half of text layout: how tall a line box is, and where its baseline sits.
3
+ *
4
+ * One canonical answer, like `line-breaker.ts` is the one canonical answer for the horizontal
5
+ * half. Measuring (`calculateTextHeight`), fragmenting (`TextElement.fragment`) and drawing
6
+ * (`TextRenderer._buildRuns`) all call in here, so they can never disagree about a line's height
7
+ * again - the divergence that produced ISSUE-5.
8
+ */
9
+ import type { FontStyle } from "../utils/pdf-object-manager.js";
10
+ import type { FontMetrics } from "../utils/font-metrics.js";
11
+ import type { SegmentDefaults, SegmentLine } from "./line-breaker.js";
12
+ /** A font's vertical metrics, as fractions of the em (so they scale by multiplying with fontSize). */
13
+ export interface FontVerticals {
14
+ /** Distance from the baseline up to the ascender. Positive. */
15
+ ascent: number;
16
+ /** Distance from the baseline down to the descender. Positive (unlike the AFM/hhea sign). */
17
+ descent: number;
18
+ /** Extra leading the font asks for between two lines. Often 0 for a TrueType face. */
19
+ lineGap: number;
20
+ }
21
+ /** One font used on a line: its metrics and the size it is set at. */
22
+ export interface LinePart {
23
+ verticals: FontVerticals;
24
+ fontSize: number;
25
+ }
26
+ /** A laid-out line box, in points, relative to the top of the box. */
27
+ export interface LineBox {
28
+ /** Total height of the line box. Lines stack by this. */
29
+ height: number;
30
+ /** Distance from the top of the line box down to the baseline. */
31
+ baseline: number;
32
+ }
33
+ /**
34
+ * The line box for a set of fonts sharing one line.
35
+ *
36
+ * `lineHeight` is a multiplier of the font size, like CSS. Leave it `undefined` for the font's
37
+ * NATURAL line height (`ascent + descent + lineGap`), which is what CSS calls `line-height: normal`.
38
+ *
39
+ * Whatever is left over between the box and the font's content is split evenly above and below
40
+ * ("half-leading", as CSS and Flutter do), so the glyphs sit centred in their own box. An explicit
41
+ * `lineHeight` smaller than the content makes the leading negative and the glyphs overflow - again
42
+ * exactly like CSS, and the author asked for it.
43
+ */
44
+ export declare function lineBoxFor(parts: LinePart[], lineHeight?: number): LineBox;
45
+ /** The line box of a single-font line (the plain-string path). */
46
+ export declare function lineBoxForString(metrics: FontMetrics, fontFamily: string, fontStyle: FontStyle, fontSize: number, lineHeight?: number): LineBox;
47
+ /** The line box of a mixed-font line: the tallest ascent and the deepest descent on it win, so a
48
+ * big or a deep-descending span pushes the whole line apart rather than colliding with it. */
49
+ export declare function lineBoxForSegmentLine(line: SegmentLine, defaults: SegmentDefaults, metrics: FontMetrics, lineHeight?: number): LineBox;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The vertical half of text layout: how tall a line box is, and where its baseline sits.
3
+ *
4
+ * One canonical answer, like `line-breaker.ts` is the one canonical answer for the horizontal
5
+ * half. Measuring (`calculateTextHeight`), fragmenting (`TextElement.fragment`) and drawing
6
+ * (`TextRenderer._buildRuns`) all call in here, so they can never disagree about a line's height
7
+ * again - the divergence that produced ISSUE-5.
8
+ */
9
+ /**
10
+ * The line box for a set of fonts sharing one line.
11
+ *
12
+ * `lineHeight` is a multiplier of the font size, like CSS. Leave it `undefined` for the font's
13
+ * NATURAL line height (`ascent + descent + lineGap`), which is what CSS calls `line-height: normal`.
14
+ *
15
+ * Whatever is left over between the box and the font's content is split evenly above and below
16
+ * ("half-leading", as CSS and Flutter do), so the glyphs sit centred in their own box. An explicit
17
+ * `lineHeight` smaller than the content makes the leading negative and the glyphs overflow - again
18
+ * exactly like CSS, and the author asked for it.
19
+ */
20
+ export function lineBoxFor(parts, lineHeight) {
21
+ if (parts.length === 0)
22
+ return { height: 0, baseline: 0 };
23
+ let ascent = 0;
24
+ let descent = 0;
25
+ let gap = 0;
26
+ let maxFontSize = 0;
27
+ for (const { verticals, fontSize } of parts) {
28
+ ascent = Math.max(ascent, verticals.ascent * fontSize);
29
+ descent = Math.max(descent, verticals.descent * fontSize);
30
+ gap = Math.max(gap, verticals.lineGap * fontSize);
31
+ maxFontSize = Math.max(maxFontSize, fontSize);
32
+ }
33
+ const content = ascent + descent;
34
+ const height = lineHeight === undefined ? content + gap : maxFontSize * lineHeight;
35
+ return { height, baseline: (height - content) / 2 + ascent };
36
+ }
37
+ /** The line box of a single-font line (the plain-string path). */
38
+ export function lineBoxForString(metrics, fontFamily, fontStyle, fontSize, lineHeight) {
39
+ return lineBoxFor([{ verticals: metrics.getFontVerticals(fontFamily, fontStyle), fontSize }], lineHeight);
40
+ }
41
+ /** The line box of a mixed-font line: the tallest ascent and the deepest descent on it win, so a
42
+ * big or a deep-descending span pushes the whole line apart rather than colliding with it. */
43
+ export function lineBoxForSegmentLine(line, defaults, metrics, lineHeight) {
44
+ const parts = line.segments.map((segment) => {
45
+ const fontFamily = segment.fontFamily ?? defaults.fontFamily;
46
+ const fontStyle = segment.fontStyle ?? defaults.fontStyle;
47
+ return {
48
+ verticals: metrics.getFontVerticals(fontFamily, fontStyle),
49
+ fontSize: segment.fontSize ?? defaults.fontSize,
50
+ };
51
+ });
52
+ // A line with no segments still occupies its default font's box (an empty paragraph keeps height).
53
+ if (parts.length === 0) {
54
+ return lineBoxForString(metrics, defaults.fontFamily, defaults.fontStyle, defaults.fontSize, lineHeight);
55
+ }
56
+ return lineBoxFor(parts, lineHeight);
57
+ }