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

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 (116) hide show
  1. package/README.md +7 -7
  2. package/dist/api/descriptor.js +26 -1
  3. package/dist/api/forms.d.ts +347 -0
  4. package/dist/api/forms.js +189 -0
  5. package/dist/api/index.d.ts +1 -0
  6. package/dist/api/index.js +1 -0
  7. package/dist/api/layout.d.ts +47 -14
  8. package/dist/api/layout.js +47 -11
  9. package/dist/api/structure.d.ts +11 -0
  10. package/dist/api/structure.js +2 -0
  11. package/dist/api/text.d.ts +18 -2
  12. package/dist/api/text.js +15 -0
  13. package/dist/crypto/legacy-handler.d.ts +43 -0
  14. package/dist/crypto/legacy-handler.js +145 -0
  15. package/dist/crypto/security-handler.d.ts +16 -0
  16. package/dist/crypto/security-handler.js +26 -0
  17. package/dist/edit/acroform-reader.d.ts +70 -0
  18. package/dist/edit/acroform-reader.js +126 -0
  19. package/dist/edit/appearance.d.ts +47 -0
  20. package/dist/edit/appearance.js +171 -0
  21. package/dist/edit/document.d.ts +135 -0
  22. package/dist/edit/document.js +649 -0
  23. package/dist/edit/fill.d.ts +48 -0
  24. package/dist/edit/fill.js +395 -0
  25. package/dist/edit/flatten.d.ts +42 -0
  26. package/dist/edit/flatten.js +437 -0
  27. package/dist/edit/index.d.ts +18 -0
  28. package/dist/edit/index.js +16 -0
  29. package/dist/edit/lexer.d.ts +30 -0
  30. package/dist/edit/lexer.js +285 -0
  31. package/dist/edit/objects.d.ts +63 -0
  32. package/dist/edit/objects.js +56 -0
  33. package/dist/edit/writer.d.ts +53 -0
  34. package/dist/edit/writer.js +261 -0
  35. package/dist/elements/container-element.d.ts +10 -1
  36. package/dist/elements/container-element.js +26 -8
  37. package/dist/elements/forms/checkbox-element.d.ts +51 -0
  38. package/dist/elements/forms/checkbox-element.js +48 -0
  39. package/dist/elements/forms/choice-element.d.ts +53 -0
  40. package/dist/elements/forms/choice-element.js +59 -0
  41. package/dist/elements/forms/push-button-element.d.ts +56 -0
  42. package/dist/elements/forms/push-button-element.js +49 -0
  43. package/dist/elements/forms/radio-element.d.ts +52 -0
  44. package/dist/elements/forms/radio-element.js +49 -0
  45. package/dist/elements/forms/signature-element.d.ts +54 -0
  46. package/dist/elements/forms/signature-element.js +47 -0
  47. package/dist/elements/forms/text-field-element.d.ts +61 -0
  48. package/dist/elements/forms/text-field-element.js +54 -0
  49. package/dist/elements/index.d.ts +8 -0
  50. package/dist/elements/index.js +8 -0
  51. package/dist/elements/layout/keep-together-element.d.ts +42 -0
  52. package/dist/elements/layout/keep-together-element.js +85 -0
  53. package/dist/elements/layout/padding-element.d.ts +3 -0
  54. package/dist/elements/layout/padding-element.js +12 -0
  55. package/dist/elements/layout/page-break-element.d.ts +17 -0
  56. package/dist/elements/layout/page-break-element.js +24 -0
  57. package/dist/elements/layout/positioned-element.d.ts +10 -7
  58. package/dist/elements/layout/positioned-element.js +21 -9
  59. package/dist/elements/page-element.d.ts +11 -1
  60. package/dist/elements/page-element.js +41 -16
  61. package/dist/elements/pdf-element.d.ts +28 -0
  62. package/dist/elements/pdf-element.js +32 -0
  63. package/dist/elements/rectangle-element.d.ts +10 -1
  64. package/dist/elements/rectangle-element.js +27 -6
  65. package/dist/elements/row-element.d.ts +9 -1
  66. package/dist/elements/row-element.js +9 -1
  67. package/dist/elements/text-element.d.ts +30 -4
  68. package/dist/elements/text-element.js +40 -17
  69. package/dist/forms/acroform.d.ts +63 -0
  70. package/dist/forms/acroform.js +427 -0
  71. package/dist/forms/appearance.d.ts +58 -0
  72. package/dist/forms/appearance.js +204 -0
  73. package/dist/forms/field.d.ts +164 -0
  74. package/dist/forms/field.js +1 -0
  75. package/dist/forms/pdf.d.ts +19 -0
  76. package/dist/forms/pdf.js +28 -0
  77. package/dist/ir/display-list.d.ts +18 -1
  78. package/dist/layout/fragmentation.d.ts +12 -0
  79. package/dist/layout/fragmentation.js +45 -8
  80. package/dist/renderer/form-field-renderer.d.ts +20 -0
  81. package/dist/renderer/form-field-renderer.js +12 -0
  82. package/dist/renderer/keep-together-renderer.d.ts +10 -0
  83. package/dist/renderer/keep-together-renderer.js +12 -0
  84. package/dist/renderer/page-break-renderer.d.ts +14 -0
  85. package/dist/renderer/page-break-renderer.js +15 -0
  86. package/dist/renderer/page-renderer.js +8 -2
  87. package/dist/renderer/pdf-backend.d.ts +7 -0
  88. package/dist/renderer/pdf-backend.js +48 -6
  89. package/dist/renderer/pdf-document-class.js +3 -0
  90. package/dist/renderer/pdf-document-renderer.js +26 -7
  91. package/dist/renderer/pdf-renderer.js +25 -1
  92. package/dist/renderer/text-renderer.d.ts +1 -1
  93. package/dist/renderer/text-renderer.js +107 -59
  94. package/dist/text/advance.d.ts +34 -0
  95. package/dist/text/advance.js +42 -0
  96. package/dist/text/line-breaker.d.ts +9 -8
  97. package/dist/text/line-breaker.js +28 -21
  98. package/dist/text/line-metrics.d.ts +49 -0
  99. package/dist/text/line-metrics.js +57 -0
  100. package/dist/text/text-decoration.d.ts +50 -0
  101. package/dist/text/text-decoration.js +62 -0
  102. package/dist/text/text-style.d.ts +13 -3
  103. package/dist/text/text-style.js +10 -3
  104. package/dist/utils/afm-parser.d.ts +34 -0
  105. package/dist/utils/afm-parser.js +67 -0
  106. package/dist/utils/dest-registry.d.ts +5 -2
  107. package/dist/utils/dest-registry.js +5 -5
  108. package/dist/utils/font-metrics.d.ts +14 -0
  109. package/dist/utils/outline.js +1 -2
  110. package/dist/utils/pdf-object-manager.d.ts +45 -1
  111. package/dist/utils/pdf-object-manager.js +193 -26
  112. package/dist/utils/struct-tree.js +2 -3
  113. package/dist/utils/ttf-parser.d.ts +53 -0
  114. package/dist/utils/ttf-parser.js +368 -0
  115. package/dist/validators/element-validator.js +7 -3
  116. package/package.json +9 -2
@@ -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
+ }
@@ -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,3 +1,4 @@
1
+ import type { PDFObjectManager } from "./pdf-object-manager.js";
1
2
  interface Dest {
2
3
  pageObjNum: number;
3
4
  top: number;
@@ -13,7 +14,9 @@ export declare class DestRegistry {
13
14
  add(name: string, dest: Dest): void;
14
15
  get isEmpty(): boolean;
15
16
  /** 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;
17
+ * `/EmbeddedFiles`). Returns "" when empty. Names are sorted lexically - a name tree requires it.
18
+ * Takes the object manager only so the names go through its string choke-point and get encrypted
19
+ * with everything else. */
20
+ finalize(om: PDFObjectManager): string;
18
21
  }
19
22
  export {};