@jasy/pdf 1.0.0-alpha.5 → 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 (105) hide show
  1. package/dist/api/content.d.ts +6 -3
  2. package/dist/api/content.js +12 -3
  3. package/dist/api/descriptor.js +24 -1
  4. package/dist/api/dimension.d.ts +20 -0
  5. package/dist/api/dimension.js +16 -0
  6. package/dist/api/index.d.ts +2 -0
  7. package/dist/api/index.js +2 -0
  8. package/dist/api/layout.d.ts +112 -17
  9. package/dist/api/layout.js +118 -13
  10. package/dist/api/page-builder.d.ts +32 -0
  11. package/dist/api/page-builder.js +32 -0
  12. package/dist/api/structure.d.ts +5 -0
  13. package/dist/api/structure.js +1 -0
  14. package/dist/api/text.d.ts +24 -2
  15. package/dist/api/text.js +46 -4
  16. package/dist/elements/container-element.d.ts +26 -1
  17. package/dist/elements/container-element.js +79 -29
  18. package/dist/elements/image-element.d.ts +19 -1
  19. package/dist/elements/image-element.js +52 -11
  20. package/dist/elements/index.d.ts +8 -0
  21. package/dist/elements/index.js +8 -0
  22. package/dist/elements/layout/anchor-element.d.ts +23 -0
  23. package/dist/elements/layout/anchor-element.js +27 -0
  24. package/dist/elements/layout/bookmark-element.d.ts +27 -0
  25. package/dist/elements/layout/bookmark-element.js +30 -0
  26. package/dist/elements/layout/keep-together-element.d.ts +42 -0
  27. package/dist/elements/layout/keep-together-element.js +85 -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/padding-element.d.ts +3 -0
  31. package/dist/elements/layout/padding-element.js +12 -0
  32. package/dist/elements/layout/page-break-element.d.ts +17 -0
  33. package/dist/elements/layout/page-break-element.js +24 -0
  34. package/dist/elements/layout/page-builder-element.d.ts +32 -0
  35. package/dist/elements/layout/page-builder-element.js +43 -0
  36. package/dist/elements/layout/positioned-element.d.ts +10 -7
  37. package/dist/elements/layout/positioned-element.js +21 -9
  38. package/dist/elements/layout/rotated-box-element.d.ts +35 -0
  39. package/dist/elements/layout/rotated-box-element.js +54 -0
  40. package/dist/elements/layout/rotated-element.d.ts +32 -0
  41. package/dist/elements/layout/rotated-element.js +41 -0
  42. package/dist/elements/page-element.d.ts +19 -1
  43. package/dist/elements/page-element.js +54 -17
  44. package/dist/elements/pdf-element.d.ts +59 -0
  45. package/dist/elements/pdf-element.js +51 -0
  46. package/dist/elements/rectangle-element.d.ts +20 -1
  47. package/dist/elements/rectangle-element.js +75 -18
  48. package/dist/elements/row-element.d.ts +19 -1
  49. package/dist/elements/row-element.js +45 -8
  50. package/dist/elements/text-element.d.ts +34 -4
  51. package/dist/elements/text-element.js +40 -17
  52. package/dist/ir/display-list.d.ts +60 -1
  53. package/dist/layout/box-constraints.d.ts +8 -0
  54. package/dist/layout/box-constraints.js +14 -0
  55. package/dist/layout/collect-images.d.ts +9 -0
  56. package/dist/layout/collect-images.js +34 -0
  57. package/dist/layout/fragmentation.d.ts +12 -0
  58. package/dist/layout/fragmentation.js +45 -8
  59. package/dist/renderer/anchor-renderer.d.ts +6 -0
  60. package/dist/renderer/anchor-renderer.js +11 -0
  61. package/dist/renderer/bookmark-renderer.d.ts +6 -0
  62. package/dist/renderer/bookmark-renderer.js +11 -0
  63. package/dist/renderer/keep-together-renderer.d.ts +10 -0
  64. package/dist/renderer/keep-together-renderer.js +12 -0
  65. package/dist/renderer/link-renderer.d.ts +6 -0
  66. package/dist/renderer/link-renderer.js +11 -0
  67. package/dist/renderer/page-break-renderer.d.ts +14 -0
  68. package/dist/renderer/page-break-renderer.js +15 -0
  69. package/dist/renderer/page-builder-renderer.d.ts +6 -0
  70. package/dist/renderer/page-builder-renderer.js +11 -0
  71. package/dist/renderer/page-renderer.js +37 -2
  72. package/dist/renderer/pdf-backend.d.ts +17 -0
  73. package/dist/renderer/pdf-backend.js +117 -7
  74. package/dist/renderer/pdf-document-class.js +3 -0
  75. package/dist/renderer/pdf-document-renderer.d.ts +16 -7
  76. package/dist/renderer/pdf-document-renderer.js +87 -36
  77. package/dist/renderer/pdf-renderer.js +53 -2
  78. package/dist/renderer/rotated-renderer.d.ts +7 -0
  79. package/dist/renderer/rotated-renderer.js +29 -0
  80. package/dist/renderer/text-renderer.d.ts +1 -1
  81. package/dist/renderer/text-renderer.js +123 -54
  82. package/dist/text/advance.d.ts +34 -0
  83. package/dist/text/advance.js +42 -0
  84. package/dist/text/line-breaker.d.ts +9 -8
  85. package/dist/text/line-breaker.js +28 -21
  86. package/dist/text/line-metrics.d.ts +49 -0
  87. package/dist/text/line-metrics.js +57 -0
  88. package/dist/text/text-decoration.d.ts +50 -0
  89. package/dist/text/text-decoration.js +62 -0
  90. package/dist/text/text-style.d.ts +13 -3
  91. package/dist/text/text-style.js +10 -3
  92. package/dist/utils/afm-parser.d.ts +34 -0
  93. package/dist/utils/afm-parser.js +73 -4
  94. package/dist/utils/dest-registry.d.ts +19 -0
  95. package/dist/utils/dest-registry.js +32 -0
  96. package/dist/utils/flex-layout.d.ts +10 -3
  97. package/dist/utils/flex-layout.js +26 -6
  98. package/dist/utils/font-metrics.d.ts +14 -0
  99. package/dist/utils/outline.d.ts +21 -0
  100. package/dist/utils/outline.js +80 -0
  101. package/dist/utils/pdf-object-manager.d.ts +33 -1
  102. package/dist/utils/pdf-object-manager.js +0 -0
  103. package/dist/utils/ttf-parser.d.ts +53 -0
  104. package/dist/utils/ttf-parser.js +368 -0
  105. 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
  }
@@ -25,6 +34,7 @@ export class AFMParser {
25
34
  return this.glyphMap[char] || char;
26
35
  }
27
36
  parseAFMData(afmData) {
37
+ var _a;
28
38
  const lines = afmData.split("\n");
29
39
  let inCharMetrics = false;
30
40
  for (let line of lines) {
@@ -44,13 +54,31 @@ export class AFMParser {
44
54
  this.advanceWidths[charName] = wx;
45
55
  }
46
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
+ }
47
73
  if (line.startsWith("KPX")) {
48
74
  const parts = line.split(/\s+/);
49
75
  const firstChar = parts[1];
50
76
  const secondChar = parts[2];
51
77
  const kerning = parseFloat(parts[3]);
52
- const pair = `${firstChar}-${secondChar}`;
53
- this.kerningPairs[pair] = kerning;
78
+ // Nested by first glyph, then second. A flat `${a}-${b}` key would have to be BUILT on every
79
+ // lookup, and getKerning runs once per adjacent character pair - that string allocation was a
80
+ // quarter of a standard-font render.
81
+ ((_a = this.kerningPairs)[firstChar] ?? (_a[firstChar] = {}))[secondChar] = kerning;
54
82
  }
55
83
  }
56
84
  }
@@ -71,11 +99,52 @@ export class AFMParser {
71
99
  }
72
100
  return null;
73
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
+ }
74
144
  getAdvanceWidth(charName) {
75
145
  return this.advanceWidths[this.getGlyphName(charName)] || 0;
76
146
  }
77
147
  getKerning(firstChar, secondChar) {
78
- const pair = `${firstChar}-${secondChar}`;
79
- return this.kerningPairs[pair] || 0;
148
+ return this.kerningPairs[firstChar]?.[secondChar] ?? 0;
80
149
  }
81
150
  }
@@ -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
  }
@@ -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
  }
@@ -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
+ }