@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
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Where an underline and a strikethrough sit, and how thick they are.
3
+ *
4
+ * These are GLYPH metrics, and they are deliberately NOT part of `FontVerticals` in
5
+ * `line-metrics.ts`, which holds LINE metrics. Confusing the two is exactly what produced ISSUE-5:
6
+ * the AFM's `Ascender` (the height of a `b`) was used to seat a baseline, a job that belongs to the
7
+ * `FontBBox`. Keep the two kinds of number in two places so they cannot be mixed up again.
8
+ *
9
+ * Every number here comes out of the font: the standard-14 AFM header, or a TrueType `post` +
10
+ * `OS/2` table. Nothing is invented. Verified against `google-chrome --headless --print-to-pdf` on
11
+ * the same font: Chrome's strikethrough centre lands on `xHeight / 2` (measured 0.260 em vs the
12
+ * font's 0.262), and its underline stroke with `text-decoration-thickness: from-font` is the font's
13
+ * `underlineThickness` (0.048 em vs 0.050). Chrome does NOT use the font's underline POSITION - it
14
+ * has its own offset - so we follow the font there and land within 0.02 em of it.
15
+ */
16
+ /** A font's decoration metrics, as fractions of the em. */
17
+ export interface FontDecoration {
18
+ /** Distance BELOW the baseline to the CENTRE of the underline stroke. Positive.
19
+ * (The AFM defines it as "the distance from the baseline for centering underlining strokes";
20
+ * FreeType reads TrueType's `post.underlinePosition` the same way.) */
21
+ underlinePosition: number;
22
+ /** Stroke thickness, used by both the underline and the strikethrough. */
23
+ underlineThickness: number;
24
+ /** Height of a lowercase `x` above the baseline. Sets where a strikethrough crosses. */
25
+ xHeight: number;
26
+ /** Height of a capital above the baseline. */
27
+ capHeight: number;
28
+ }
29
+ /** A decoration stroke: the y of its centre line, and how thick it is. Points, engine coords. */
30
+ export interface DecorationStroke {
31
+ y: number;
32
+ thickness: number;
33
+ }
34
+ /** The underline for a run whose baseline sits at `baselineY` (engine coords, y grows down). */
35
+ export declare function underlineStroke(decoration: FontDecoration, fontSize: number, baselineY: number): DecorationStroke;
36
+ /**
37
+ * The strikethrough for a run whose baseline sits at `baselineY`. It crosses at half the x-height,
38
+ * which is what a browser does and what looks right: through the middle of the lowercase letters,
39
+ * not through the middle of the capitals.
40
+ */
41
+ export declare function strikethroughStroke(decoration: FontDecoration, fontSize: number, baselineY: number): DecorationStroke;
42
+ /**
43
+ * Cuts `[0, runWidth]` at every place the glyphs put ink, leaving the segments an underline should
44
+ * actually draw. `inkSpans` are x-intervals in points from the start of the run.
45
+ *
46
+ * This is CSS `text-decoration-skip-ink`, and it needs the real glyph outlines - so it only works
47
+ * for an embedded font. Segments narrower than the padding are dropped: a sliver of a line between
48
+ * two descenders reads as dirt, not as an underline.
49
+ */
50
+ export declare function skipInkSegments(runWidth: number, inkSpans: Array<[number, number]>, thickness: number): Array<[number, number]>;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Where an underline and a strikethrough sit, and how thick they are.
3
+ *
4
+ * These are GLYPH metrics, and they are deliberately NOT part of `FontVerticals` in
5
+ * `line-metrics.ts`, which holds LINE metrics. Confusing the two is exactly what produced ISSUE-5:
6
+ * the AFM's `Ascender` (the height of a `b`) was used to seat a baseline, a job that belongs to the
7
+ * `FontBBox`. Keep the two kinds of number in two places so they cannot be mixed up again.
8
+ *
9
+ * Every number here comes out of the font: the standard-14 AFM header, or a TrueType `post` +
10
+ * `OS/2` table. Nothing is invented. Verified against `google-chrome --headless --print-to-pdf` on
11
+ * the same font: Chrome's strikethrough centre lands on `xHeight / 2` (measured 0.260 em vs the
12
+ * font's 0.262), and its underline stroke with `text-decoration-thickness: from-font` is the font's
13
+ * `underlineThickness` (0.048 em vs 0.050). Chrome does NOT use the font's underline POSITION - it
14
+ * has its own offset - so we follow the font there and land within 0.02 em of it.
15
+ */
16
+ /** The underline for a run whose baseline sits at `baselineY` (engine coords, y grows down). */
17
+ export function underlineStroke(decoration, fontSize, baselineY) {
18
+ return {
19
+ y: baselineY + decoration.underlinePosition * fontSize,
20
+ thickness: decoration.underlineThickness * fontSize,
21
+ };
22
+ }
23
+ /**
24
+ * The strikethrough for a run whose baseline sits at `baselineY`. It crosses at half the x-height,
25
+ * which is what a browser does and what looks right: through the middle of the lowercase letters,
26
+ * not through the middle of the capitals.
27
+ */
28
+ export function strikethroughStroke(decoration, fontSize, baselineY) {
29
+ return {
30
+ y: baselineY - (decoration.xHeight / 2) * fontSize,
31
+ thickness: decoration.underlineThickness * fontSize,
32
+ };
33
+ }
34
+ /**
35
+ * Horizontal breathing room left on each side of a descender that interrupts an underline. Unlike
36
+ * everything else in this file this is a DESIGN choice, not a number read from the font - so it is
37
+ * expressed in the one quantity the font does give us, the stroke thickness.
38
+ */
39
+ const SKIP_INK_PADDING = 1.0;
40
+ /**
41
+ * Cuts `[0, runWidth]` at every place the glyphs put ink, leaving the segments an underline should
42
+ * actually draw. `inkSpans` are x-intervals in points from the start of the run.
43
+ *
44
+ * This is CSS `text-decoration-skip-ink`, and it needs the real glyph outlines - so it only works
45
+ * for an embedded font. Segments narrower than the padding are dropped: a sliver of a line between
46
+ * two descenders reads as dirt, not as an underline.
47
+ */
48
+ export function skipInkSegments(runWidth, inkSpans, thickness) {
49
+ const pad = SKIP_INK_PADDING * thickness;
50
+ const segments = [];
51
+ let cursor = 0;
52
+ for (const [start, end] of inkSpans) {
53
+ const gapStart = Math.max(0, start - pad);
54
+ const gapEnd = Math.min(runWidth, end + pad);
55
+ if (gapStart > cursor + pad)
56
+ segments.push([cursor, gapStart]);
57
+ cursor = Math.max(cursor, gapEnd);
58
+ }
59
+ if (runWidth > cursor + pad)
60
+ segments.push([cursor, runWidth]);
61
+ return segments;
62
+ }
@@ -13,12 +13,22 @@ export interface ResolvedTextStyle {
13
13
  fontStyle: FontStyle;
14
14
  color: Color;
15
15
  textAlignment: HorizontalAlignment;
16
- lineHeight: number;
16
+ /** Multiplier of the font size. `undefined` means the font's natural line height
17
+ * (`ascent + descent + lineGap`), i.e. CSS `line-height: normal`. */
18
+ lineHeight?: number;
19
+ /** Draw a line under the text, at the position and thickness the font declares. */
20
+ underline: boolean;
21
+ /** Draw a line through the text, at half its x-height. */
22
+ strikethrough: boolean;
23
+ /** Let the underline step around descenders (CSS `text-decoration-skip-ink`). Needs an EMBEDDED
24
+ * font: the standard-14 outlines live in the viewer, not in the AFM. */
25
+ skipInk: boolean;
26
+ /** Extra space after every glyph, in points (CSS `letter-spacing`). Default 0. */
27
+ letterSpacing: number;
17
28
  }
18
29
  /**
19
30
  * The root of the cascade: what a `Text` falls back to when neither it nor any ancestor sets a
20
- * property. These MUST match the historical `Text` defaults so a document that sets nothing renders
21
- * byte-for-byte as before.
31
+ * property.
22
32
  */
23
33
  export declare const DEFAULT_TEXT_STYLE: ResolvedTextStyle;
24
34
  /** Layers a partial override onto a complete style; an unset (undefined) field keeps the base. */
@@ -3,8 +3,7 @@ import { FontStyle } from "../utils/pdf-object-manager.js";
3
3
  import { HorizontalAlignment } from "../elements/pdf-element.js";
4
4
  /**
5
5
  * The root of the cascade: what a `Text` falls back to when neither it nor any ancestor sets a
6
- * property. These MUST match the historical `Text` defaults so a document that sets nothing renders
7
- * byte-for-byte as before.
6
+ * property.
8
7
  */
9
8
  export const DEFAULT_TEXT_STYLE = {
10
9
  fontSize: 12,
@@ -12,7 +11,11 @@ export const DEFAULT_TEXT_STYLE = {
12
11
  fontStyle: FontStyle.Normal,
13
12
  color: new Color(0, 0, 0),
14
13
  textAlignment: HorizontalAlignment.left,
15
- lineHeight: 1,
14
+ lineHeight: undefined,
15
+ underline: false,
16
+ strikethrough: false,
17
+ skipInk: false,
18
+ letterSpacing: 0,
16
19
  };
17
20
  /** Layers a partial override onto a complete style; an unset (undefined) field keeps the base. */
18
21
  export function mergeTextStyle(base, override) {
@@ -25,5 +28,9 @@ export function mergeTextStyle(base, override) {
25
28
  color: override.color ?? base.color,
26
29
  textAlignment: override.textAlignment ?? base.textAlignment,
27
30
  lineHeight: override.lineHeight ?? base.lineHeight,
31
+ underline: override.underline ?? base.underline,
32
+ strikethrough: override.strikethrough ?? base.strikethrough,
33
+ skipInk: override.skipInk ?? base.skipInk,
34
+ letterSpacing: override.letterSpacing ?? base.letterSpacing,
28
35
  };
29
36
  }
@@ -1,12 +1,46 @@
1
+ import type { FontVerticals } from "../text/line-metrics.js";
2
+ import type { FontDecoration } from "../text/text-decoration.js";
1
3
  export declare class AFMParser {
2
4
  private advanceWidths;
3
5
  private kerningPairs;
4
6
  private glyphMap;
7
+ private bbox;
8
+ private cachedVerticals?;
9
+ private underlinePosition;
10
+ private underlineThickness;
11
+ private capHeight;
12
+ private xHeight;
13
+ private cachedDecoration?;
5
14
  constructor(afmData: string);
6
15
  private loadGlyphList;
7
16
  private getGlyphName;
8
17
  private parseAFMData;
9
18
  private parseCharMetrics;
19
+ /**
20
+ * The font's LINE metrics, as fractions of the em: how far a line box must reach above and below
21
+ * the baseline. Taken from the `FontBBox`.
22
+ *
23
+ * Not from the AFM's `Ascender` / `Descender` lines, and that distinction is the whole point.
24
+ * `Ascender 718` is a GLYPH metric - the height of `b`, `d`, `h` - and for Helvetica it happens to
25
+ * equal the cap height. The line-metric equivalent is TrueType's `hhea.ascent`, which is much
26
+ * taller because it has to clear the accented capitals: Arial declares 0.905 where its caps reach
27
+ * 0.716. That surplus above the caps (0.189) is about as large as the descent below the baseline
28
+ * (0.212), which is exactly why capitals look vertically centred in a browser.
29
+ *
30
+ * Helvetica's `FontBBox` is `-166 -225 1000 931`: 0.931 up, 0.225 down - within a hair of Arial's
31
+ * hhea numbers. So the bbox IS the standard-14 line metric, and there is no leftover slack to
32
+ * call a lineGap. (Seating the baseline at `Ascender` instead put every capital too high; see
33
+ * ISSUE-5.)
34
+ */
35
+ verticals(): FontVerticals;
36
+ /**
37
+ * The font's decoration metrics (underline / strikethrough), as fractions of the em.
38
+ *
39
+ * Symbol and ZapfDingbats declare no `CapHeight`/`XHeight` - they have no lowercase letters. For
40
+ * those the bbox top is the only height the font gives us, so the capitals reach it and a
41
+ * strikethrough crosses at half of that. Still read from the font, not guessed.
42
+ */
43
+ decoration(): FontDecoration;
10
44
  getAdvanceWidth(charName: string): number;
11
45
  getKerning(firstChar: string, secondChar: string): number;
12
46
  }
@@ -4,6 +4,15 @@ export class AFMParser {
4
4
  this.advanceWidths = {};
5
5
  this.kerningPairs = {};
6
6
  this.glyphMap = {};
7
+ // The font's bounding box in glyph space (1000 units / em), from the AFM header. This - not the
8
+ // `Ascender` line - is what a line box is built from; see `verticals()`.
9
+ this.bbox = [0, 0, 0, 0];
10
+ // Glyph metrics from the AFM header, for underline / strikethrough. All 14 standard fonts carry
11
+ // the underline pair; Symbol and ZapfDingbats have no CapHeight/XHeight (they have no lowercase).
12
+ this.underlinePosition = 0; // negative, as the AFM writes it: below the baseline
13
+ this.underlineThickness = 0;
14
+ this.capHeight = 0;
15
+ this.xHeight = 0;
7
16
  this.parseAFMData(afmData);
8
17
  this.loadGlyphList();
9
18
  }
@@ -45,6 +54,22 @@ export class AFMParser {
45
54
  this.advanceWidths[charName] = wx;
46
55
  }
47
56
  }
57
+ if (line.startsWith("UnderlinePosition ")) {
58
+ this.underlinePosition = parseFloat(line.slice(18));
59
+ }
60
+ if (line.startsWith("UnderlineThickness ")) {
61
+ this.underlineThickness = parseFloat(line.slice(19));
62
+ }
63
+ if (line.startsWith("CapHeight ")) {
64
+ this.capHeight = parseFloat(line.slice(10));
65
+ }
66
+ if (line.startsWith("XHeight ")) {
67
+ this.xHeight = parseFloat(line.slice(8));
68
+ }
69
+ if (line.startsWith("FontBBox ")) {
70
+ const [x0, y0, x1, y1] = line.slice(9).trim().split(/\s+/).map(parseFloat);
71
+ this.bbox = [x0, y0, x1, y1];
72
+ }
48
73
  if (line.startsWith("KPX")) {
49
74
  const parts = line.split(/\s+/);
50
75
  const firstChar = parts[1];
@@ -74,6 +99,48 @@ export class AFMParser {
74
99
  }
75
100
  return null;
76
101
  }
102
+ /**
103
+ * The font's LINE metrics, as fractions of the em: how far a line box must reach above and below
104
+ * the baseline. Taken from the `FontBBox`.
105
+ *
106
+ * Not from the AFM's `Ascender` / `Descender` lines, and that distinction is the whole point.
107
+ * `Ascender 718` is a GLYPH metric - the height of `b`, `d`, `h` - and for Helvetica it happens to
108
+ * equal the cap height. The line-metric equivalent is TrueType's `hhea.ascent`, which is much
109
+ * taller because it has to clear the accented capitals: Arial declares 0.905 where its caps reach
110
+ * 0.716. That surplus above the caps (0.189) is about as large as the descent below the baseline
111
+ * (0.212), which is exactly why capitals look vertically centred in a browser.
112
+ *
113
+ * Helvetica's `FontBBox` is `-166 -225 1000 931`: 0.931 up, 0.225 down - within a hair of Arial's
114
+ * hhea numbers. So the bbox IS the standard-14 line metric, and there is no leftover slack to
115
+ * call a lineGap. (Seating the baseline at `Ascender` instead put every capital too high; see
116
+ * ISSUE-5.)
117
+ */
118
+ verticals() {
119
+ if (this.cachedVerticals)
120
+ return this.cachedVerticals;
121
+ const [, yMin, , yMax] = this.bbox;
122
+ this.cachedVerticals = { ascent: yMax / 1000, descent: -yMin / 1000, lineGap: 0 };
123
+ return this.cachedVerticals;
124
+ }
125
+ /**
126
+ * The font's decoration metrics (underline / strikethrough), as fractions of the em.
127
+ *
128
+ * Symbol and ZapfDingbats declare no `CapHeight`/`XHeight` - they have no lowercase letters. For
129
+ * those the bbox top is the only height the font gives us, so the capitals reach it and a
130
+ * strikethrough crosses at half of that. Still read from the font, not guessed.
131
+ */
132
+ decoration() {
133
+ if (this.cachedDecoration)
134
+ return this.cachedDecoration;
135
+ const capHeight = this.capHeight || this.bbox[3];
136
+ this.cachedDecoration = {
137
+ underlinePosition: -this.underlinePosition / 1000, // AFM writes it negative (below the baseline)
138
+ underlineThickness: this.underlineThickness / 1000,
139
+ capHeight: capHeight / 1000,
140
+ xHeight: (this.xHeight || capHeight / 2) / 1000,
141
+ };
142
+ return this.cachedDecoration;
143
+ }
77
144
  getAdvanceWidth(charName) {
78
145
  return this.advanceWidths[this.getGlyphName(charName)] || 0;
79
146
  }
@@ -1,4 +1,6 @@
1
1
  import type { FontStyle } from "./pdf-object-manager.js";
2
+ import type { FontVerticals } from "../text/line-metrics.js";
3
+ import type { FontDecoration } from "../text/text-decoration.js";
2
4
  /**
3
5
  * Read-only font measurement - the slice of the object manager that the layout pass
4
6
  * needs. Keeping layout (and, later, fragmentation) behind this interface means those
@@ -9,4 +11,16 @@ import type { FontStyle } from "./pdf-object-manager.js";
9
11
  export interface FontMetrics {
10
12
  getStringWidth(text: string, fontFamily: string, fontSize: number, fontStyle: FontStyle): number;
11
13
  getCharWidth(char: string, fontSize: number, fullFontName?: string, fontName?: string, fontStyle?: FontStyle): number;
14
+ /** Ascent / descent / lineGap of a face, in em fractions - the vertical counterpart of the widths
15
+ * above. Drives the line box and the baseline (see `text/line-metrics.ts`). */
16
+ getFontVerticals(fontFamily: string, fontStyle: FontStyle): FontVerticals;
17
+ /** Underline / strikethrough geometry of a face, in em fractions. GLYPH metrics, deliberately kept
18
+ * apart from the LINE metrics above (see `text/text-decoration.ts`). */
19
+ getFontDecoration(fontFamily: string, fontStyle: FontStyle): FontDecoration;
20
+ /** Whether kerning is on for this document. `runAdvance` reads it so a measured advance matches the
21
+ * drawn one (the backend emits a `TJ` under the same flag). */
22
+ readonly kerningEnabled: boolean;
23
+ /** Per-adjacent-pair kerning of `text`, in em/1000 (negative tightens); length `codePoints - 1`,
24
+ * zero next to a space. Only meaningful when `kerningEnabled`. */
25
+ getKernPairs(text: string, fontFamily: string, fontStyle: FontStyle): number[];
12
26
  }
@@ -2,6 +2,8 @@ import type { OverflowPolicy } from "../layout/fragmentation.js";
2
2
  import { TTFParser } from "./ttf-parser.js";
3
3
  import type { SecurityHandler } from "../crypto/security-handler.js";
4
4
  import type { Gradient } from "../ir/display-list.js";
5
+ import type { FontVerticals } from "../text/line-metrics.js";
6
+ import type { FontDecoration } from "../text/text-decoration.js";
5
7
  import { StructTree } from "./struct-tree.js";
6
8
  import { OutlineBuilder } from "./outline.js";
7
9
  import { DestRegistry } from "./dest-registry.js";
@@ -33,6 +35,8 @@ export declare class PDFObjectManager implements FontMetrics {
33
35
  pageFormat: [number, number];
34
36
  private afmParsers;
35
37
  private customFonts;
38
+ private verticalsCache;
39
+ private decorationCache;
36
40
  private customFontEmit;
37
41
  private attachments;
38
42
  private xmpMetadata?;
@@ -119,10 +123,32 @@ export declare class PDFObjectManager implements FontMetrics {
119
123
  private buildCIDFont;
120
124
  private buildToUnicode;
121
125
  private buildType0;
126
+ getFontVerticals(fontFamily: string, fontStyle: FontStyle): FontVerticals;
127
+ getFontDecoration(fontFamily: string, fontStyle: FontStyle): FontDecoration;
128
+ kerningEnabled: boolean;
129
+ setKerning(enabled: boolean): void;
130
+ /**
131
+ * The kerning adjustment for each adjacent code-point pair of `text`, in em/1000 (the sign the
132
+ * font declares: negative tightens). Length is `codePointCount - 1`. Zero at any pair next to a
133
+ * space - letters are never kerned against a space, which also keeps a per-word measure equal to a
134
+ * whole-line one (the space pairs contribute nothing either way).
135
+ *
136
+ * Standard-14 answers from the AFM `KPX` pairs. An embedded font has no kerning yet (its `kern` /
137
+ * `GPOS` tables are unparsed), so it returns zeros - never a wrong value, just no adjustment.
138
+ */
139
+ getKernPairs(text: string, fontFamily: string, fontStyle: FontStyle): number[];
140
+ /**
141
+ * Where the glyphs of `text` put ink inside a horizontal band, as x-intervals in POINTS measured
142
+ * from the start of the run. This is what an underline steps around ("skip-ink").
143
+ *
144
+ * `bandTop` / `bandBottom` are in points relative to the baseline, y UP (so both negative under
145
+ * it). Only an EMBEDDED font can answer: the standard-14 outlines live in the viewer, not here.
146
+ * Callers must check `isCustomFont` first rather than treat an empty result as "no descenders".
147
+ */
148
+ getInkSpansInBand(text: string, fontSize: number, fontFamily: string, fontStyle: FontStyle, bandTop: number, bandBottom: number, letterSpacing?: number): Array<[number, number]>;
122
149
  getStringWidth(text: string, fontFamily: string, fontSize: number, fontStyle: FontStyle): number;
123
150
  private getAVMParserByFont;
124
151
  getCharWidth(char: string, fontSize: number, fullFontName?: string, fontName?: string, fontStyle?: FontStyle): number;
125
- private getKerning;
126
152
  getAllFontsRaw(): Map<string, FontIndexes>;
127
153
  getAllImagesRaw(): Map<string, number>;
128
154
  getRenderedObjects(): string;
@@ -4,7 +4,7 @@ import { md5, md5Hex } from "./md5.js";
4
4
  import { zlibSync } from "fflate";
5
5
  import { bytesFromLatin1, latin1FromBytes } from "./bytes.js";
6
6
  import { AFMParser } from "./afm-parser.js";
7
- import { TTFParser } from "./ttf-parser.js";
7
+ import { mergeSpans, TTFParser } from "./ttf-parser.js";
8
8
  import { subsetTTF } from "./ttf-subsetter.js";
9
9
  import { getArrayBuffer } from "./utf8-to-windows1252-encoder.js";
10
10
  import { isEmojiCodePoint } from "../text/emoji-codepoints.js";
@@ -150,6 +150,10 @@ export class PDFObjectManager {
150
150
  // every lookup, and the lookups happen once per character - the string allocation alone was a third
151
151
  // of a custom-font render. `customFontEmit` below keeps the flat key: it is touched once per face.
152
152
  this.customFonts = new Map();
153
+ // Memoised getFontVerticals answers, family -> style -> metrics. Cleared when a font registers.
154
+ this.verticalsCache = new Map();
155
+ // Same, for getFontDecoration.
156
+ this.decorationCache = new Map();
153
157
  // Per-registered face: the reserved font-object numbers + the glyph ids actually used. The font
154
158
  // program is subsetted and the objects filled in by finalizeCustomFonts(), after the render pass.
155
159
  this.customFontEmit = new Map();
@@ -170,6 +174,11 @@ export class PDFObjectManager {
170
174
  // Named destinations (internal-link jump targets). Empty unless an `Anchor` was placed; then it emits
171
175
  // a /Names /Dests tree.
172
176
  this._dests = new DestRegistry();
177
+ // Whether to kern. Document-level: measuring (via `runAdvance`, which reads this off the metrics)
178
+ // and drawing (the backend emits a `TJ`) both consult it, so they can never disagree. OFF while the
179
+ // embedded-font path (kern/GPOS) is unbuilt - a standard-14-only kerning would set the 14 system
180
+ // fonts finer than a user's own font, which is worse than none. Flip to on when GPOS lands.
181
+ this.kerningEnabled = false;
173
182
  if (pageSize)
174
183
  this.pageFormat = pageFormats[pageSize];
175
184
  this.fillConfigWithStandardValues();
@@ -462,6 +471,8 @@ endstream`;
462
471
  if (this.fonts.hasFont(fontName, fontStyle)) {
463
472
  return this.fonts.getFont(fontName, fontStyle); // Already exists? Return it!
464
473
  }
474
+ this.verticalsCache.delete(fontName);
475
+ this.decorationCache.delete(fontName);
465
476
  const data = STANDARD_AFM[fullName];
466
477
  if (data !== undefined) {
467
478
  this.afmParsers.push({
@@ -495,6 +506,8 @@ endstream`;
495
506
  if (byStyle.has(style))
496
507
  return;
497
508
  byStyle.set(style, new TTFParser(data));
509
+ this.verticalsCache.delete(name); // this family now answers from the .ttf, not from an AFM
510
+ this.decorationCache.delete(name);
498
511
  // Emission (the PDF font objects + page resource) is DEFERRED to first use via ensureEmitted(),
499
512
  // so a registered-but-never-rendered face - e.g. a bundled family the document doesn't touch -
500
513
  // costs nothing in the output. The metrics above are still available for layout immediately.
@@ -690,24 +703,132 @@ endstream`;
690
703
  return (`<< /Type /Font /Subtype /Type0 /BaseFont /${pdfName(name)} /Encoding /Identity-H ` +
691
704
  `/DescendantFonts [${cidFont} 0 R] /ToUnicode ${toUnicode} 0 R >>`);
692
705
  }
706
+ // The vertical metrics of a face, in em fractions - what seats the baseline and sizes the line
707
+ // box (see text/line-metrics.ts). An embedded font answers from its own `hhea`; a standard-14
708
+ // font from its AFM header. Nothing here is guessed: both numbers come out of the font data.
709
+ //
710
+ // Asked once per LINE (not per character), but a long document has a lot of lines and the
711
+ // standard-14 lookup below is a linear scan, so the answer is memoised per face. Registering a
712
+ // font clears the cache - a name can go from standard-14 to embedded.
713
+ getFontVerticals(fontFamily, fontStyle) {
714
+ let byStyle = this.verticalsCache.get(fontFamily);
715
+ const hit = byStyle?.get(fontStyle);
716
+ if (hit)
717
+ return hit;
718
+ const ttf = this.getCustomFont(fontFamily, fontStyle);
719
+ const verticals = ttf
720
+ ? {
721
+ ascent: ttf.ascent / 1000,
722
+ descent: -ttf.descent / 1000, // hhea writes the descent negative
723
+ lineGap: ttf.lineGap / 1000,
724
+ }
725
+ : this.getAVMParserByFont(undefined, fontFamily, fontStyle).parser.verticals();
726
+ if (!byStyle) {
727
+ byStyle = new Map();
728
+ this.verticalsCache.set(fontFamily, byStyle);
729
+ }
730
+ byStyle.set(fontStyle, verticals);
731
+ return verticals;
732
+ }
733
+ // Underline / strikethrough geometry of a face, in em fractions. Same shape as getFontVerticals:
734
+ // an embedded font answers from its `post`/`OS/2` tables, a standard-14 one from its AFM header,
735
+ // and the answer is memoised per face because it is asked once per decorated run.
736
+ getFontDecoration(fontFamily, fontStyle) {
737
+ let byStyle = this.decorationCache.get(fontFamily);
738
+ const hit = byStyle?.get(fontStyle);
739
+ if (hit)
740
+ return hit;
741
+ const ttf = this.getCustomFont(fontFamily, fontStyle);
742
+ const decoration = ttf
743
+ ? ttf.decoration()
744
+ : this.getAVMParserByFont(undefined, fontFamily, fontStyle).parser.decoration();
745
+ if (!byStyle) {
746
+ byStyle = new Map();
747
+ this.decorationCache.set(fontFamily, byStyle);
748
+ }
749
+ byStyle.set(fontStyle, decoration);
750
+ return decoration;
751
+ }
752
+ setKerning(enabled) {
753
+ this.kerningEnabled = enabled;
754
+ }
755
+ /**
756
+ * The kerning adjustment for each adjacent code-point pair of `text`, in em/1000 (the sign the
757
+ * font declares: negative tightens). Length is `codePointCount - 1`. Zero at any pair next to a
758
+ * space - letters are never kerned against a space, which also keeps a per-word measure equal to a
759
+ * whole-line one (the space pairs contribute nothing either way).
760
+ *
761
+ * Standard-14 answers from the AFM `KPX` pairs. An embedded font has no kerning yet (its `kern` /
762
+ * `GPOS` tables are unparsed), so it returns zeros - never a wrong value, just no adjustment.
763
+ */
764
+ getKernPairs(text, fontFamily, fontStyle) {
765
+ const chars = [...text];
766
+ if (chars.length < 2)
767
+ return [];
768
+ const out = [];
769
+ // An embedded font kerns by GLYPH id (kern table / GPOS); a standard-14 one by character (AFM).
770
+ const ttf = this.getCustomFont(fontFamily, fontStyle);
771
+ const parser = ttf
772
+ ? undefined
773
+ : this.getAVMParserByFont(undefined, fontFamily, fontStyle).parser;
774
+ for (let i = 0; i < chars.length - 1; i++) {
775
+ const a = chars[i];
776
+ const b = chars[i + 1];
777
+ if (a === " " || b === " ") {
778
+ out.push(0);
779
+ }
780
+ else if (ttf) {
781
+ out.push(ttf.getKerning(ttf.getGlyphIndex(a.codePointAt(0)), ttf.getGlyphIndex(b.codePointAt(0))));
782
+ }
783
+ else {
784
+ out.push(parser.getKerning(a, b));
785
+ }
786
+ }
787
+ return out;
788
+ }
789
+ /**
790
+ * Where the glyphs of `text` put ink inside a horizontal band, as x-intervals in POINTS measured
791
+ * from the start of the run. This is what an underline steps around ("skip-ink").
792
+ *
793
+ * `bandTop` / `bandBottom` are in points relative to the baseline, y UP (so both negative under
794
+ * it). Only an EMBEDDED font can answer: the standard-14 outlines live in the viewer, not here.
795
+ * Callers must check `isCustomFont` first rather than treat an empty result as "no descenders".
796
+ */
797
+ getInkSpansInBand(text, fontSize, fontFamily, fontStyle, bandTop, bandBottom, letterSpacing = 0) {
798
+ const ttf = this.getCustomFont(fontFamily, fontStyle);
799
+ if (!ttf)
800
+ return [];
801
+ const scale = fontSize / ttf.unitsPerEm;
802
+ const spans = [];
803
+ let pen = 0;
804
+ for (const ch of text) {
805
+ const codePoint = ch.codePointAt(0);
806
+ if (codePoint !== undefined) {
807
+ for (const [x0, x1] of ttf.inkSpansInBand(codePoint, bandTop / scale, bandBottom / scale)) {
808
+ spans.push([pen + x0 * scale, pen + x1 * scale]);
809
+ }
810
+ }
811
+ // The pen advances by the glyph AND its letter-spacing, so the ink of the next glyph lands
812
+ // where `Tc` actually draws it - otherwise the skipped gaps drift under the wrong letters.
813
+ pen += this.getCharWidth(ch, fontSize, undefined, fontFamily, fontStyle) + letterSpacing;
814
+ }
815
+ return mergeSpans(spans);
816
+ }
693
817
  // Returns the current width of a text, included kernings
694
818
  getStringWidth(text, fontFamily, fontSize, fontStyle) {
695
819
  let width = 0;
696
820
  // Iterate code points, not UTF-16 units: an astral char (emoji, CJK-ext) is a surrogate pair,
697
821
  // and indexing by unit would measure each half as its own (zero-width) "char". The spread splits
698
822
  // on code points; for BMP text this is one unit each, so the result is unchanged.
699
- const chars = [...text];
700
- for (let i = 0; i < chars.length; i++) {
701
- const char = chars[i];
702
- const nextChar = chars[i + 1] || null;
703
- // Get signs width
704
- const charWidth = this.getCharWidth(char, fontSize, undefined, fontFamily, fontStyle);
705
- width += charWidth;
706
- // If a next sign available calculate the kerning
707
- if (nextChar) {
708
- const kerning = this.getKerning(char, nextChar, undefined, fontFamily, fontStyle);
709
- width += kerning * fontSize; // Kerning must be scaled with the font size
710
- }
823
+ //
824
+ // NO KERNING. We write a run as a single `Tj`, and a viewer advances that by the font's plain
825
+ // widths - PDF never kerns on its own; a producer has to say so with a `TJ` array. Folding the
826
+ // AFM's kerning into the MEASUREMENT while the OUTPUT ignores it made every kerned string draw
827
+ // wider than its box: "AVATAR Wave" at 40pt by 19pt, "Total" at 11pt by 5.7%. Measured must equal
828
+ // drawn. The AFM kerning pairs stay parsed (`getKerning`) for the day we emit `TJ` - see the
829
+ // "real kerning" item in todo.md, which must cover embedded fonts too (`kern`/`GPOS`).
830
+ for (const char of text) {
831
+ width += this.getCharWidth(char, fontSize, undefined, fontFamily, fontStyle);
711
832
  }
712
833
  return width;
713
834
  }
@@ -767,13 +888,6 @@ endstream`;
767
888
  return (advanceWidth / 1000) * fontSize;
768
889
  }
769
890
  // Method to get the kerning, if available, between two signs
770
- getKerning(char, nextChar, fullFontName, fontName, fontStyle) {
771
- // TrueType kerning (the kern/GPOS tables) is not wired up yet - no kerning for custom fonts.
772
- if (this.getCustomFont(fullFontName ?? fontName, fontStyle))
773
- return 0;
774
- const currentParser = this.getAVMParserByFont(fullFontName, fontName, fontStyle);
775
- return currentParser.parser.getKerning(char, nextChar) / 1000;
776
- }
777
891
  // Returns all fonts
778
892
  getAllFontsRaw() {
779
893
  return this.fonts.getAllFonts();