@jasy/pdf 1.0.0-beta.2 → 1.0.0-beta.4

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 (210) hide show
  1. package/dist/api/content.d.ts +5 -5
  2. package/dist/api/content.js +12 -6
  3. package/dist/api/font-url.d.ts +1 -2
  4. package/dist/api/font-url.js +8 -74
  5. package/dist/api/forms.d.ts +3 -0
  6. package/dist/api/forms.js +3 -0
  7. package/dist/api/index.d.ts +2 -0
  8. package/dist/api/index.js +1 -0
  9. package/dist/api/layout.d.ts +6 -0
  10. package/dist/api/layout.js +7 -2
  11. package/dist/api/page-builder.d.ts +4 -0
  12. package/dist/api/page-builder.js +4 -0
  13. package/dist/api/structure.d.ts +10 -2
  14. package/dist/api/structure.js +9 -6
  15. package/dist/api/table.d.ts +5 -3
  16. package/dist/api/table.js +99 -22
  17. package/dist/common/diagnostics.d.ts +53 -0
  18. package/dist/common/diagnostics.js +77 -0
  19. package/dist/edit/acroform-reader.js +7 -7
  20. package/dist/edit/appearance.js +3 -3
  21. package/dist/edit/attachments.d.ts +52 -0
  22. package/dist/edit/attachments.js +178 -0
  23. package/dist/edit/document.d.ts +38 -8
  24. package/dist/edit/document.js +208 -51
  25. package/dist/edit/fill.js +8 -23
  26. package/dist/edit/filters.d.ts +15 -0
  27. package/dist/edit/filters.js +157 -0
  28. package/dist/edit/index.d.ts +4 -1
  29. package/dist/edit/index.js +3 -1
  30. package/dist/edit/objects.d.ts +3 -4
  31. package/dist/edit/objects.js +5 -18
  32. package/dist/elements/canvas-element.d.ts +3 -0
  33. package/dist/elements/canvas-element.js +14 -3
  34. package/dist/elements/container-element.d.ts +5 -1
  35. package/dist/elements/container-element.js +35 -15
  36. package/dist/elements/forms/checkbox-element.d.ts +2 -0
  37. package/dist/elements/forms/checkbox-element.js +12 -3
  38. package/dist/elements/forms/choice-element.d.ts +6 -1
  39. package/dist/elements/forms/choice-element.js +17 -6
  40. package/dist/elements/forms/field-font.d.ts +8 -0
  41. package/dist/elements/forms/field-font.js +5 -0
  42. package/dist/elements/forms/push-button-element.d.ts +6 -1
  43. package/dist/elements/forms/push-button-element.js +16 -4
  44. package/dist/elements/forms/radio-element.d.ts +2 -0
  45. package/dist/elements/forms/radio-element.js +12 -3
  46. package/dist/elements/forms/signature-element.d.ts +6 -1
  47. package/dist/elements/forms/signature-element.js +16 -4
  48. package/dist/elements/forms/text-field-element.d.ts +6 -1
  49. package/dist/elements/forms/text-field-element.js +16 -4
  50. package/dist/elements/image-element.d.ts +39 -1
  51. package/dist/elements/image-element.js +95 -12
  52. package/dist/elements/layout/anchor-element.d.ts +8 -10
  53. package/dist/elements/layout/anchor-element.js +10 -18
  54. package/dist/elements/layout/bookmark-element.d.ts +9 -11
  55. package/dist/elements/layout/bookmark-element.js +11 -20
  56. package/dist/elements/layout/default-text-style-element.d.ts +11 -13
  57. package/dist/elements/layout/default-text-style-element.js +14 -31
  58. package/dist/elements/layout/deferred-element.d.ts +12 -4
  59. package/dist/elements/layout/deferred-element.js +24 -7
  60. package/dist/elements/layout/expanded-element.d.ts +7 -0
  61. package/dist/elements/layout/expanded-element.js +40 -18
  62. package/dist/elements/layout/keep-together-element.d.ts +8 -18
  63. package/dist/elements/layout/keep-together-element.js +21 -45
  64. package/dist/elements/layout/link-element.d.ts +8 -13
  65. package/dist/elements/layout/link-element.js +9 -19
  66. package/dist/elements/layout/padding-element.d.ts +15 -1
  67. package/dist/elements/layout/padding-element.js +55 -27
  68. package/dist/elements/layout/page-break-element.d.ts +4 -1
  69. package/dist/elements/layout/page-break-element.js +6 -1
  70. package/dist/elements/layout/page-builder-element.d.ts +5 -0
  71. package/dist/elements/layout/page-builder-element.js +11 -0
  72. package/dist/elements/layout/positioned-element.d.ts +14 -2
  73. package/dist/elements/layout/positioned-element.js +36 -10
  74. package/dist/elements/layout/proxy-element.d.ts +43 -0
  75. package/dist/elements/layout/proxy-element.js +94 -0
  76. package/dist/elements/layout/repeating-header-element.d.ts +6 -0
  77. package/dist/elements/layout/repeating-header-element.js +40 -13
  78. package/dist/elements/layout/rotated-box-element.d.ts +6 -0
  79. package/dist/elements/layout/rotated-box-element.js +27 -14
  80. package/dist/elements/layout/rotated-element.d.ts +7 -0
  81. package/dist/elements/layout/rotated-element.js +25 -2
  82. package/dist/elements/layout/struct-group.d.ts +9 -17
  83. package/dist/elements/layout/struct-group.js +10 -35
  84. package/dist/elements/line-element.d.ts +1 -0
  85. package/dist/elements/line-element.js +6 -0
  86. package/dist/elements/page-element.d.ts +10 -3
  87. package/dist/elements/page-element.js +39 -31
  88. package/dist/elements/pdf-document-element.d.ts +3 -0
  89. package/dist/elements/pdf-document-element.js +7 -0
  90. package/dist/elements/pdf-element.d.ts +75 -15
  91. package/dist/elements/pdf-element.js +68 -18
  92. package/dist/elements/rectangle-element.d.ts +14 -0
  93. package/dist/elements/rectangle-element.js +97 -52
  94. package/dist/elements/row-element.d.ts +11 -0
  95. package/dist/elements/row-element.js +58 -8
  96. package/dist/elements/svg-element.d.ts +30 -6
  97. package/dist/elements/svg-element.js +88 -17
  98. package/dist/elements/text-element.d.ts +40 -13
  99. package/dist/elements/text-element.js +144 -99
  100. package/dist/forms/acroform.d.ts +9 -1
  101. package/dist/forms/acroform.js +83 -34
  102. package/dist/forms/appearance.d.ts +17 -6
  103. package/dist/forms/appearance.js +18 -16
  104. package/dist/ir/display-list.d.ts +12 -2
  105. package/dist/layout/box-constraints.d.ts +5 -0
  106. package/dist/layout/box-constraints.js +14 -0
  107. package/dist/layout/collect-images.d.ts +6 -4
  108. package/dist/layout/collect-images.js +6 -4
  109. package/dist/layout/flex-item.d.ts +9 -0
  110. package/dist/layout/flex-item.js +39 -0
  111. package/dist/layout/fragmentation.d.ts +12 -4
  112. package/dist/layout/fragmentation.js +37 -14
  113. package/dist/layout/layout-cache.d.ts +53 -0
  114. package/dist/layout/layout-cache.js +107 -0
  115. package/dist/renderer/anchor-renderer.d.ts +2 -1
  116. package/dist/renderer/anchor-renderer.js +5 -4
  117. package/dist/renderer/bookmark-renderer.d.ts +2 -1
  118. package/dist/renderer/bookmark-renderer.js +5 -4
  119. package/dist/renderer/canvas-renderer.d.ts +2 -1
  120. package/dist/renderer/canvas-renderer.js +5 -2
  121. package/dist/renderer/container-renderer.d.ts +2 -1
  122. package/dist/renderer/container-renderer.js +6 -5
  123. package/dist/renderer/default-text-style-renderer.d.ts +2 -1
  124. package/dist/renderer/default-text-style-renderer.js +2 -3
  125. package/dist/renderer/deferred-renderer.d.ts +2 -1
  126. package/dist/renderer/deferred-renderer.js +2 -3
  127. package/dist/renderer/expanded-renderer.d.ts +2 -1
  128. package/dist/renderer/expanded-renderer.js +2 -3
  129. package/dist/renderer/form-field-renderer.d.ts +2 -1
  130. package/dist/renderer/form-field-renderer.js +16 -3
  131. package/dist/renderer/image-renderer.d.ts +2 -1
  132. package/dist/renderer/image-renderer.js +5 -2
  133. package/dist/renderer/keep-together-renderer.d.ts +2 -1
  134. package/dist/renderer/keep-together-renderer.js +2 -3
  135. package/dist/renderer/line-renderer.d.ts +2 -1
  136. package/dist/renderer/line-renderer.js +7 -2
  137. package/dist/renderer/link-renderer.d.ts +2 -1
  138. package/dist/renderer/link-renderer.js +36 -5
  139. package/dist/renderer/padding-renderer.d.ts +2 -1
  140. package/dist/renderer/padding-renderer.js +5 -5
  141. package/dist/renderer/page-break-renderer.d.ts +6 -5
  142. package/dist/renderer/page-break-renderer.js +14 -7
  143. package/dist/renderer/page-builder-renderer.d.ts +2 -1
  144. package/dist/renderer/page-builder-renderer.js +2 -3
  145. package/dist/renderer/page-renderer.js +28 -7
  146. package/dist/renderer/pdf-backend.d.ts +4 -3
  147. package/dist/renderer/pdf-backend.js +5 -5
  148. package/dist/renderer/pdf-document-renderer.d.ts +1 -8
  149. package/dist/renderer/pdf-document-renderer.js +15 -16
  150. package/dist/renderer/pdf-renderer.js +11 -11
  151. package/dist/renderer/positioned-renderer.d.ts +6 -1
  152. package/dist/renderer/positioned-renderer.js +6 -5
  153. package/dist/renderer/rectangle-renderer.d.ts +2 -1
  154. package/dist/renderer/rectangle-renderer.js +9 -4
  155. package/dist/renderer/repeating-header-renderer.d.ts +2 -1
  156. package/dist/renderer/repeating-header-renderer.js +6 -5
  157. package/dist/renderer/rotated-renderer.d.ts +2 -1
  158. package/dist/renderer/rotated-renderer.js +8 -5
  159. package/dist/renderer/row-renderer.d.ts +2 -1
  160. package/dist/renderer/row-renderer.js +6 -5
  161. package/dist/renderer/struct-group-renderer.d.ts +2 -1
  162. package/dist/renderer/struct-group-renderer.js +2 -3
  163. package/dist/renderer/svg-renderer.d.ts +2 -1
  164. package/dist/renderer/svg-renderer.js +8 -2
  165. package/dist/renderer/text-renderer.d.ts +4 -6
  166. package/dist/renderer/text-renderer.js +101 -111
  167. package/dist/svg/gradients.js +5 -0
  168. package/dist/svg/index.js +10 -1
  169. package/dist/svg/style.d.ts +2 -0
  170. package/dist/svg/style.js +11 -0
  171. package/dist/text/advance.d.ts +4 -0
  172. package/dist/text/advance.js +49 -4
  173. package/dist/text/bidi.d.ts +0 -2
  174. package/dist/text/bidi.js +0 -10
  175. package/dist/text/glyph-coverage.d.ts +1 -1
  176. package/dist/text/glyph-coverage.js +7 -3
  177. package/dist/text/hyphen-breaks.d.ts +17 -0
  178. package/dist/text/hyphen-breaks.js +54 -0
  179. package/dist/text/line-breaker.d.ts +34 -31
  180. package/dist/text/line-breaker.js +364 -176
  181. package/dist/text/line-metrics.d.ts +6 -3
  182. package/dist/text/line-metrics.js +7 -2
  183. package/dist/text/pdf-text-string.d.ts +21 -0
  184. package/dist/text/pdf-text-string.js +114 -0
  185. package/dist/text/text-decoration.d.ts +5 -5
  186. package/dist/text/text-decoration.js +5 -5
  187. package/dist/text/word-splitting.d.ts +15 -2
  188. package/dist/text/word-splitting.js +34 -8
  189. package/dist/utils/afm-parser.d.ts +3 -0
  190. package/dist/utils/afm-parser.js +19 -1
  191. package/dist/utils/dest-registry.d.ts +1 -1
  192. package/dist/utils/dest-registry.js +3 -2
  193. package/dist/utils/fetch-bytes.d.ts +19 -0
  194. package/dist/utils/fetch-bytes.js +79 -0
  195. package/dist/utils/flex-layout.d.ts +13 -13
  196. package/dist/utils/flex-layout.js +318 -216
  197. package/dist/utils/font-metrics.d.ts +4 -3
  198. package/dist/utils/image-helper.js +2 -1
  199. package/dist/utils/pdf-object-manager.d.ts +11 -3
  200. package/dist/utils/pdf-object-manager.js +41 -15
  201. package/dist/utils/renderer-registry.d.ts +43 -2
  202. package/dist/utils/renderer-registry.js +40 -0
  203. package/dist/utils/struct-tree.d.ts +24 -0
  204. package/dist/utils/struct-tree.js +61 -9
  205. package/dist/utils/ttf-parser.d.ts +4 -0
  206. package/dist/utils/ttf-parser.js +27 -4
  207. package/dist/validators/element-validator.js +2 -1
  208. package/package.json +1 -1
  209. package/dist/elements/layout/sized-container-element.d.ts +0 -14
  210. package/dist/elements/layout/sized-container-element.js +0 -32
@@ -1,18 +1,16 @@
1
- // Import the renderer DIRECTLY, not via the "../renderer" barrel: the barrel pulls in
2
- // pdf-renderer (and every element) while this element module is still loading, which under
3
- // ESM (Vite/vitest, and the future framework bindings) duplicates the element classes and
4
- // breaks the constructor-keyed RendererRegistry. A direct import keeps the graph acyclic.
5
- import { TextRenderer } from "../renderer/text-renderer.js";
6
1
  import { DEFAULT_TEXT_STYLE } from "../text/text-style.js";
2
+ import { BoxConstraints } from "../layout/box-constraints.js";
7
3
  import { applyTextTransform } from "../text/text-style.js";
8
4
  import { splitByFont } from "../text/font-fallback.js";
9
5
  import { runAdvance } from "../text/advance.js";
10
- import { singleLineWidth, MAX_SPACE_SHRINK, wrapStringIntoLines, breakSegmentsIntoLines, segmentLinesToSegments, } from "../text/line-breaker.js";
6
+ import { singleLineWidth, MAX_SPACE_SHRINK, breakSegmentsIntoLines, } from "../text/line-breaker.js";
7
+ import { hyphenPieces } from "../text/hyphen-breaks.js";
11
8
  import { adjustForOrphansWidows, DEFAULT_ORPHANS, DEFAULT_WIDOWS, } from "../text/orphans-widows.js";
12
- import { lineBoxForSegmentLine, lineBoxForString } from "../text/line-metrics.js";
9
+ import { lineBoxForSegmentLine, textHeight } from "../text/line-metrics.js";
13
10
  import { HorizontalAlignment, SizedPDFElement } from "./pdf-element.js";
14
11
  import { normalizeContent } from "../text/whitespace.js";
15
12
  import { coverText } from "../text/glyph-coverage.js";
13
+ import { pageOf } from "../common/diagnostics.js";
16
14
  /** How far a run sits off the baseline, as a fraction of its font size (up is positive). */
17
15
  export const VERTICAL_TEXT_SHIFT = {
18
16
  baseline: 0,
@@ -31,9 +29,14 @@ function finitePoints(value, name) {
31
29
  return value;
32
30
  }
33
31
  export class TextElement extends SizedPDFElement {
34
- constructor({ fontSize, content, fontFamily, fontStyle, color, textAlignment, maxLines, orphans, widows, overflow = "clip", lineHeight, underline, strikethrough, skipInk, letterSpacing, direction, fontFallback, textTransform, wordSpacing, textIndent, breakWord, hyphenate, ligatures, role, }) {
32
+ constructor({ fontSize, content, fontFamily, fontStyle, color, textAlignment, maxLines, orphans, widows, overflow = "clip", lineHeight, underline, strikethrough, skipInk, letterSpacing, direction, fontFallback, textTransform, wordSpacing, textIndent, breakWord, hyphenate, ligatures, role, shownLines, }) {
35
33
  super({ x: 0, y: 0 });
34
+ /** What `covered` had to remove, reported where the text is laid out on its page (see
35
+ * `calculateLayout`). A split paragraph's first piece carries its parent's, as `lostBefore`. */
36
+ this.lostGlyphs = [];
37
+ this.lostBefore = [];
36
38
  this.role = role;
39
+ this.shownLines = shownLines;
37
40
  this.rawFontSize = fontSize;
38
41
  this.rawFontFamily = fontFamily;
39
42
  this.rawFontStyle = fontStyle;
@@ -73,19 +76,39 @@ export class TextElement extends SizedPDFElement {
73
76
  * same width in most fonts).
74
77
  */
75
78
  display(metrics) {
79
+ const memo = this.displayed;
80
+ if (metrics &&
81
+ memo &&
82
+ memo.metrics === metrics &&
83
+ // A face registered since then changes what is drawable and how the fallback stack splits.
84
+ memo.epoch === (metrics.fontEpoch ?? 0) &&
85
+ memo.family === this.fontFamily &&
86
+ memo.style === this.fontStyle &&
87
+ memo.transform === this.textTransform &&
88
+ memo.fallback === this.fontFallback)
89
+ return memo.value;
76
90
  const cased = this.recased();
77
91
  // A SPAN may bring its own stack, so the element's being empty is not enough to skip the pass.
78
- const anyStack = this.fontFallback.length > 0 ||
79
- (typeof cased !== "string" && cased.some((seg) => (seg.fontFallback?.length ?? 0) > 0));
92
+ const anyStack = this.fontFallback.length > 0 || cased.some((seg) => (seg.fontFallback?.length ?? 0) > 0);
80
93
  if (!metrics)
81
94
  return cased;
95
+ const remember = (value) => {
96
+ this.displayed = {
97
+ metrics,
98
+ epoch: metrics.fontEpoch ?? 0,
99
+ family: this.fontFamily,
100
+ style: this.fontStyle,
101
+ transform: this.textTransform,
102
+ fallback: this.fontFallback,
103
+ value,
104
+ };
105
+ return value;
106
+ };
82
107
  if (!anyStack)
83
- return this.covered(cased, metrics);
84
- // Font fallback turns the content into spans - one per family - which every later pass already
85
- // knows how to handle. Nothing new downstream.
86
- const pieces = typeof cased === "string" ? [{ content: cased }] : cased;
108
+ return remember(this.covered(cased, metrics));
109
+ // Font fallback splits a span into more spans - one per family. Nothing new downstream.
87
110
  const out = [];
88
- for (const seg of pieces) {
111
+ for (const seg of cased) {
89
112
  const runs = splitByFont(seg.content, seg.fontFamily ?? this.fontFamily, seg.fontFallback ?? this.fontFallback, seg.fontStyle ?? this.fontStyle, metrics);
90
113
  if (!runs)
91
114
  out.push(seg);
@@ -93,7 +116,7 @@ export class TextElement extends SizedPDFElement {
93
116
  for (const run of runs)
94
117
  out.push({ ...seg, content: run.text, fontFamily: run.fontFamily });
95
118
  }
96
- return this.covered(out, metrics);
119
+ return remember(this.covered(out, metrics));
97
120
  }
98
121
  /**
99
122
  * Drops or substitutes whatever the RESOLVED font cannot draw. Runs last, after the fallback stack
@@ -102,30 +125,26 @@ export class TextElement extends SizedPDFElement {
102
125
  * back unchanged, so an ordinary document is byte-identical.
103
126
  */
104
127
  covered(content, metrics) {
105
- const report = (dropped) => metrics.reportMissingGlyph?.(dropped);
106
- if (typeof content === "string") {
107
- const { text, dropped } = coverText(content, this.fontFamily, this.fontStyle, metrics);
108
- if (dropped.length > 0)
109
- report(dropped);
110
- return text;
111
- }
112
- return content.map((seg) => {
128
+ // Collected over every span: the element is reported once, with everything it lost, on the page it
129
+ // is laid out on - not here, which may be measuring it for an earlier page.
130
+ const lost = [];
131
+ const covered = content.map((seg) => {
113
132
  const { text, dropped } = coverText(seg.content, seg.fontFamily ?? this.fontFamily, seg.fontStyle ?? this.fontStyle, metrics);
114
- if (dropped.length > 0)
115
- report(dropped);
133
+ lost.push(...dropped);
116
134
  return text === seg.content ? seg : { ...seg, content: text };
117
135
  });
136
+ this.lostGlyphs = [...new Set(lost)];
137
+ return covered;
118
138
  }
119
- /** The content with `text-transform` applied, and nothing else. */
139
+ /** The content as spans, with `text-transform` applied, and nothing else. */
120
140
  recased() {
141
+ // A string is one span: from here on, measuring, breaking and drawing know only spans.
142
+ const spans = typeof this.content === "string" ? [{ content: this.content }] : this.content;
121
143
  if (this.textTransform === "none")
122
- return this.content;
123
- if (typeof this.content === "string") {
124
- return applyTextTransform(this.content, this.textTransform).text;
125
- }
144
+ return spans;
126
145
  // The capitalisation state runs THROUGH the spans: `span("hel") + span("lo")` is one word.
127
146
  let atWordStart = true;
128
- return this.content.map((seg) => {
147
+ return spans.map((seg) => {
129
148
  const done = applyTextTransform(seg.content, this.textTransform, atWordStart);
130
149
  atWordStart = done.atWordStart;
131
150
  return { ...seg, content: done.text };
@@ -139,6 +158,7 @@ export class TextElement extends SizedPDFElement {
139
158
  ligatures: this.ligatures,
140
159
  // Justified lines may be squeezed to keep a word; every other alignment gets no slack.
141
160
  shrink: this.textAlignment === HorizontalAlignment.justify ? MAX_SPACE_SHRINK : 0,
161
+ shownLines: this.shownLines,
142
162
  };
143
163
  }
144
164
  applyStyle(ts) {
@@ -165,43 +185,15 @@ export class TextElement extends SizedPDFElement {
165
185
  * Splits the paragraph at line boxes (Slice 1). The lines that fit in `maxHeight` stay;
166
186
  * the rest become a remainder `TextElement` re-wrapped on the next page. If not even one
167
187
  * line fits, nothing is forced here - the caller (the container) decides whether to move
168
- * the whole element on for progress. Handles both plain strings and styled segments.
188
+ * the whole element on for progress.
169
189
  */
170
190
  fragment(maxHeight, width, ctx) {
171
191
  this.resolveStyle(ctx);
172
192
  const content = this.display(ctx.metrics);
173
- return typeof content === "string"
174
- ? this.fragmentString(content, maxHeight, width, ctx)
175
- : this.fragmentSegments(content, maxHeight, width, ctx);
176
- }
177
- // Plain string: every wrapped line gets the same box (the same one calculateTextHeight uses),
178
- // so `floor(maxHeight / lineBox)` lines fit.
179
- fragmentString(content, maxHeight, width, ctx) {
180
- const lines = wrapStringIntoLines(content, this.fontFamily, this.fontSize, this.fontStyle, width, ctx.metrics, this.maxLines, this.overflow, this.letterSpacing, this.lineOptions());
181
- const box = lineBoxForString(ctx.metrics, this.fontFamily, this.fontStyle, this.fontSize, this.lineHeight);
182
- const fittedLineCount = adjustForOrphansWidows(Math.floor(maxHeight / box.height), lines.length, this.breakRule());
183
- if (fittedLineCount <= 0)
184
- return { fitted: null, remainder: this };
185
- if (fittedLineCount >= lines.length)
186
- return { fitted: this, remainder: null };
187
- return {
188
- fitted: this.cloneWithContent(lines.slice(0, fittedLineCount).join(" ")),
189
- // A continuation is no longer the paragraph's FIRST line, so it carries no indent.
190
- remainder: this.cloneWithContent(lines.slice(fittedLineCount).join(" "), 0),
191
- };
192
- }
193
- // Styled segments: each line's height comes from the fonts on it, so pack lines by cumulative
194
- // box height. Rebuild the fitted/remainder halves back into TextSegment[].
195
- fragmentSegments(content, maxHeight, width, ctx) {
196
- const defaults = {
197
- fontFamily: this.fontFamily,
198
- fontSize: this.fontSize,
199
- fontStyle: this.fontStyle,
200
- letterSpacing: this.letterSpacing,
201
- direction: this.direction,
202
- wordSpacing: this.wordSpacing,
203
- textIndent: this.textIndent,
204
- };
193
+ // The SAME knobs measuring and drawing break with (`textHeight`, the renderer), or this
194
+ // would cut a paragraph at a line count it is never drawn with. Each line's height comes from
195
+ // the fonts on it, so lines are packed by their cumulative height.
196
+ const defaults = this.segmentDefaults();
205
197
  const lines = breakSegmentsIntoLines(content, defaults, width, ctx.metrics, this.maxLines, this.overflow);
206
198
  let used = 0;
207
199
  let packed = 0;
@@ -217,9 +209,35 @@ export class TextElement extends SizedPDFElement {
217
209
  return { fitted: null, remainder: this };
218
210
  if (fittedLineCount >= lines.length)
219
211
  return { fitted: this, remainder: null };
212
+ // A piece is a window onto the SAME text, not the lines glued back together: the first page draws
213
+ // the first lines of the whole paragraph (broken exactly as here, same text, same width), and the
214
+ // next one continues from where its first line starts in the source - inside its span, keeping
215
+ // that span's style. Hard breaks, a word split over the break and the justified last line on the
216
+ // page all survive that way. A continuation is no longer the paragraph's FIRST line, so it
217
+ // carries no indent; whatever is left of a line cap goes with it.
218
+ const { segment, offset } = lines[fittedLineCount].start;
219
+ const rest = [
220
+ { ...content[segment], content: content[segment].content.slice(offset) },
221
+ ...content.slice(segment + 1),
222
+ ];
223
+ const fitted = this.cloneWithContent(content, this.textIndent, { shownLines: fittedLineCount });
224
+ // The piece is cut from already-covered text, so it would find nothing missing itself.
225
+ fitted.lostBefore = [...this.lostBefore, ...this.lostGlyphs];
220
226
  return {
221
- fitted: this.cloneWithContent(segmentLinesToSegments(lines.slice(0, fittedLineCount))),
222
- remainder: this.cloneWithContent(segmentLinesToSegments(lines.slice(fittedLineCount)), 0),
227
+ fitted,
228
+ remainder: this.cloneWithContent(rest, 0, {
229
+ maxLines: this.maxLines === undefined ? undefined : this.maxLines - fittedLineCount,
230
+ }),
231
+ };
232
+ }
233
+ /** How the text is broken: the knobs `textHeight` and the renderer pass too. */
234
+ segmentDefaults() {
235
+ return {
236
+ fontFamily: this.fontFamily,
237
+ fontSize: this.fontSize,
238
+ fontStyle: this.fontStyle,
239
+ letterSpacing: this.letterSpacing,
240
+ ...this.lineOptions(),
223
241
  };
224
242
  }
225
243
  /** The orphan/widow minimums for this paragraph; unset means the CSS default of 2 lines each. */
@@ -231,7 +249,7 @@ export class TextElement extends SizedPDFElement {
231
249
  }
232
250
  // A copy carrying the same style but different (already-wrapped) content. Re-wrapping at
233
251
  // the same width reproduces exactly those lines (greedy is deterministic).
234
- cloneWithContent(content, indent = this.textIndent) {
252
+ cloneWithContent(content, indent = this.textIndent, piece = {}) {
235
253
  return new TextElement({
236
254
  content,
237
255
  fontSize: this.fontSize,
@@ -239,7 +257,7 @@ export class TextElement extends SizedPDFElement {
239
257
  fontStyle: this.fontStyle,
240
258
  color: this.color,
241
259
  textAlignment: this.textAlignment,
242
- maxLines: this.maxLines,
260
+ maxLines: piece.maxLines ?? this.maxLines,
243
261
  orphans: this.orphans,
244
262
  widows: this.widows,
245
263
  overflow: this.overflow,
@@ -252,45 +270,69 @@ export class TextElement extends SizedPDFElement {
252
270
  wordSpacing: this.wordSpacing,
253
271
  textIndent: indent,
254
272
  role: this.role,
255
- }).adoptStructId(this); // a wrapped remainder is the SAME logical paragraph (one P across pages)
273
+ fontFallback: this.fontFallback,
274
+ // The content is cut from `display()`, already recased: `capitalize` again would capitalise
275
+ // the half of a word continued on the next page.
276
+ textTransform: "none",
277
+ breakWord: this.breakWord,
278
+ hyphenate: this.hyphenate,
279
+ ligatures: this.ligatures,
280
+ shownLines: piece.shownLines,
281
+ }).asPieceOf(this); // a wrapped remainder is the SAME logical paragraph (one P across pages)
256
282
  }
257
283
  calculateLayout(constraints, offset, ctx) {
284
+ // Bounded width (e.g. inside a Column) wraps to that width; an unbounded width
285
+ // (e.g. inside a Row) means the text takes its natural single-line width and does
286
+ // not wrap. Sized by `measure`, so a measured text and a laid-out one cannot differ. The style is
287
+ // resolved here too: a size from the cache skips `computeSize`, and drawing reads the style.
258
288
  this.resolveStyle(ctx);
289
+ const size = this.measure(constraints, ctx);
259
290
  this.x = offset.x;
260
291
  this.y = offset.y;
261
- // Bounded width (e.g. inside a Column) wraps to that width; an unbounded width
262
- // (e.g. inside a Row) means the text takes its natural single-line width and does
263
- // not wrap. Columns always bound the width, so this leaves their layout untouched.
264
292
  this.resolved = this.display(ctx.metrics);
265
- this.width = constraints.hasBoundedWidth
266
- ? constraints.maxWidth
267
- : this.naturalWidth(ctx.metrics);
268
- const wrapWidth = this.width ?? 0;
269
- this.height = TextRenderer.calculateTextHeight(this.resolved, this.fontSize, this.fontFamily, this.fontStyle, ctx.metrics, wrapWidth, this.maxLines, this.overflow, this.lineHeight, this.letterSpacing, this.lineOptions());
293
+ const lost = [...new Set([...this.lostBefore, ...this.lostGlyphs])];
294
+ if (lost.length > 0)
295
+ ctx.metrics.reportMissingGlyph?.(lost, this, pageOf(ctx));
296
+ this.width = size.width;
297
+ this.height = size.height;
270
298
  // Top-left coordinates (y = top of the text box). The baseline offset and the
271
299
  // Y-flip are applied downstream (the line-builder positions baselines, the seam
272
300
  // flips to PDF), so the element stays coordinate-system-blind.
273
- return { width: wrapWidth, height: this.height };
301
+ return size;
302
+ }
303
+ /**
304
+ * The size `calculateLayout` would take, without placing anything. Resolving the inherited style
305
+ * and filling the memos below are its only writes, and both are a pure function of the context:
306
+ * the same inputs leave the same state, which a later layout reads back unchanged.
307
+ */
308
+ /** A text's size reads only the width it may take: it wraps to it, and a height bound never changes
309
+ * the lines (a box that is too short cuts them, it does not reflow them). */
310
+ sizeInputs(constraints) {
311
+ return BoxConstraints.loose(constraints.maxWidth, Infinity);
312
+ }
313
+ computeSize(constraints, ctx) {
314
+ this.resolveStyle(ctx);
315
+ const content = this.display(ctx.metrics);
316
+ const width = constraints.hasBoundedWidth
317
+ ? constraints.maxWidth
318
+ : this.naturalWidth(ctx.metrics);
319
+ return { width, height: this.measuredHeight(ctx.metrics, width, content) };
320
+ }
321
+ measuredHeight(metrics, width, content) {
322
+ return textHeight(content, this.segmentDefaults(), width, metrics, this.maxLines, this.overflow, this.lineHeight);
274
323
  }
275
324
  /** The unwrapped single-line width of the content (used when width is unbounded, e.g. inside a Row).
276
- * Must match the LINE-BREAKER's one-line measure EXACTLY - not just algebraically but BIT-for-bit,
277
- * so a text laid out at this width never re-wraps inside its own natural-width box. The breaker
278
- * accumulates `currentWidth += wordWidth + spaceWidth`, grouping the word and its trailing space
279
- * into one term; we must group the same way. Adding word and space as two separate steps is
280
- * algebraically equal but, because floating-point addition is not associative, drifts by a sub-ULP
281
- * - enough to tip a borderline string (e.g. "20 Jun 2026", wider than "04 Jul 2026" only because
282
- * 'n' beats 'l') one bit over its own width, dropping the last word onto a second line. */
325
+ * Must match the breaker's one-line sum BIT for bit - it adds `width + (space + word)`, and the same
326
+ * numbers grouped differently drift by a sub-ULP, enough to wrap a text inside its own natural-width
327
+ * box. `singleLineWidth` is that sum. */
283
328
  naturalWidth(metrics) {
284
329
  // The breaker's own sum, to the last bit - see `singleLineWidth`.
285
- const oneLine = (text, family, size, style, letterSpacing) => singleLineWidth(text, { fontFamily: family, fontSize: size, fontStyle: style }, metrics, letterSpacing, this.wordSpacing);
330
+ const oneLine = (text, family, size, style, letterSpacing) => singleLineWidth(text,
331
+ // `ligatures` is part of the font: it decides which glyphs are drawn, hence the width.
332
+ { fontFamily: family, fontSize: size, fontStyle: style, ligatures: this.ligatures }, metrics, letterSpacing, this.wordSpacing);
286
333
  // The FIRST line starts `textIndent` in, so an unbounded box has to be that much wider.
287
334
  const indent = Math.max(0, this.textIndent);
288
- const content = this.display(metrics);
289
- if (typeof content === "string") {
290
- return (indent +
291
- oneLine(content, this.fontFamily, this.fontSize, this.fontStyle, this.letterSpacing));
292
- }
293
- return content.reduce((sum, seg) => sum +
335
+ return this.display(metrics).reduce((sum, seg) => sum +
294
336
  oneLine(seg.content, seg.fontFamily ?? this.fontFamily, seg.fontSize ?? this.fontSize, seg.fontStyle ?? this.fontStyle, seg.letterSpacing ?? this.letterSpacing), indent);
295
337
  }
296
338
  /**
@@ -317,14 +359,12 @@ export class TextElement extends SizedPDFElement {
317
359
  fontStyle: style,
318
360
  ligatures: this.ligatures,
319
361
  };
320
- // Split on the same boundaries the breaker uses - a hard break ends a line too.
321
- const pieces = this.breakWord || this.hyphenate ? [...text] : text.split(/[ \n]/);
362
+ // Split on the same boundaries the breaker uses - a hard break ends a line too, and so may a
363
+ // hyphen inside a word.
364
+ const pieces = this.breakWord || this.hyphenate ? [...text] : text.split(/[ \n]/).flatMap(hyphenPieces);
322
365
  return pieces.reduce((max, piece) => Math.max(max, runAdvance(metrics, piece, font, letterSpacing)), 0);
323
366
  };
324
367
  const content = this.display(metrics);
325
- if (typeof content === "string") {
326
- return widest(content, this.fontFamily, this.fontSize, this.fontStyle, this.letterSpacing);
327
- }
328
368
  // Segments join without a space, so a word may straddle two of them; taking the widest piece per
329
369
  // segment can therefore UNDER-estimate. That errs towards squeezing a little too far, which lands
330
370
  // back on the old overflow behaviour rather than on something new.
@@ -340,7 +380,11 @@ export class TextElement extends SizedPDFElement {
340
380
  fontFamily: this.fontFamily,
341
381
  fontStyle: this.fontStyle,
342
382
  color: this.color,
343
- content: this.resolved ?? this.content,
383
+ content: this.content,
384
+ /** The content as it is measured and drawn: spans, recased, split by font, drawable glyphs only. */
385
+ spans: this.resolved ?? this.display(),
386
+ /** How the text is broken - the settings it is measured with, so drawing breaks it the same. */
387
+ breaking: this.segmentDefaults(),
344
388
  textAlignment: this.textAlignment,
345
389
  maxLines: this.maxLines,
346
390
  orphans: this.orphans,
@@ -358,6 +402,7 @@ export class TextElement extends SizedPDFElement {
358
402
  ligatures: this.ligatures,
359
403
  hyphenate: this.hyphenate,
360
404
  role: this.role,
405
+ shownLines: this.shownLines,
361
406
  };
362
407
  }
363
408
  }
@@ -19,6 +19,14 @@ export declare class AcroFormCollector {
19
19
  /** The size to draw a value at. A field may ask for `0` - the PDF convention for "auto-size"; we then
20
20
  * fit the capitals comfortably inside the box, which is what a viewer's auto-size does. */
21
21
  private drawSize;
22
+ /**
23
+ * The font a field's baked appearance draws its text in: the font the field inherits when it is
24
+ * EMBEDDED, else the standard Helvetica. PDF/A and PDF/UA require every font that draws to be
25
+ * embedded, and a document that embeds its fonts means that. The /DA keeps naming Helvetica - it is
26
+ * what a viewer redraws with once someone types, and a subset font could not draw the new characters.
27
+ * Measuring and drawing both take the font from here.
28
+ */
29
+ private faceFont;
22
30
  /** Measure one line for the appearance generator. */
23
31
  private line;
24
32
  /** Bake a text field's value into an appearance stream; returns the XObject number. */
@@ -47,7 +55,7 @@ export declare class AcroFormCollector {
47
55
  private claimName;
48
56
  /** Emit the widget object for one form-field IR node, register it as a field, and return its object
49
57
  * number so the PageRenderer can add it to that page's /Annots. */
50
- addField(node: FormFieldNode, om: PDFObjectManager): number;
58
+ addField(node: FormFieldNode, om: PDFObjectManager, structParent?: number): number;
51
59
  /** A push button (/Btn, Pushbutton flag): no value, an optional action, and a baked caption. The
52
60
  * caption is measured here (the appearance module stays free of font metrics) and drawn into an
53
61
  * appearance stream that carries its own font resource. */
@@ -1,5 +1,6 @@
1
+ import { coverText } from "../text/glyph-coverage.js";
1
2
  import { NORMAL_STYLE, escName, num2, pdfColor } from "./pdf.js";
2
- import { checkboxOff, checkboxOn, listBoxFace, pushButtonFace, radioOff, radioOn, signatureFace, fieldDrawSize, textFieldFace, wrapFieldValue, } from "./appearance.js";
3
+ import { HELV, checkboxOff, checkboxOn, listBoxFace, pushButtonFace, radioOff, radioOn, signatureFace, fieldDrawSize, textFieldFace, wrapFieldValue, } from "./appearance.js";
3
4
  // AcroForm field flags (/Ff), by 1-based bit position per the PDF spec.
4
5
  const FF_READ_ONLY = 1 << 0; // bit 1
5
6
  const FF_REQUIRED = 1 << 1; // bit 2: must be filled in before the form is submitted
@@ -28,8 +29,12 @@ function boxChrome(node, extraMK = "") {
28
29
  out += ` /BS << /W ${num2(style.borderWidth)} /S /S >>`;
29
30
  return out;
30
31
  }
31
- /** The annotation flags (/F). Bit 2 = Hidden, bit 3 = Print. A widget prints by default; `hidden`
32
- * removes it from screen AND print, `print: false` keeps it on screen only. */
32
+ /** A widget in the structure tree (accessible tagging) names its slot in the ParentTree. */
33
+ function withStructParent(dict, structParent) {
34
+ return structParent === undefined
35
+ ? dict
36
+ : dict.replace(/^<< /, `<< /StructParent ${structParent} `);
37
+ }
33
38
  function annotFlags(f) {
34
39
  if (f.hidden)
35
40
  return 2;
@@ -162,9 +167,9 @@ function actionDict(a, om) {
162
167
  case "submit":
163
168
  // /Flags 4 = ExportFormat: post the field values as HTML form data rather than FDF, which is what
164
169
  // an ordinary web endpoint expects.
165
- return `/A << /S /SubmitForm /F << /FS /URL /F ${om.pdfString(a.url)} >> /Flags 4 >>`;
170
+ return `/A << /S /SubmitForm /F << /FS /URL /F ${om.pdfUri(a.url)} >> /Flags 4 >>`;
166
171
  case "url":
167
- return `/A << /S /URI /URI ${om.pdfString(a.url)} >>`;
172
+ return `/A << /S /URI /URI ${om.pdfUri(a.url)} >>`;
168
173
  }
169
174
  }
170
175
  /**
@@ -196,27 +201,66 @@ export class AcroFormCollector {
196
201
  drawSize(style, height, capHeight) {
197
202
  return fieldDrawSize(style.fontSize, height, style.borderWidth, capHeight);
198
203
  }
204
+ /**
205
+ * The font a field's baked appearance draws its text in: the font the field inherits when it is
206
+ * EMBEDDED, else the standard Helvetica. PDF/A and PDF/UA require every font that draws to be
207
+ * embedded, and a document that embeds its fonts means that. The /DA keeps naming Helvetica - it is
208
+ * what a viewer redraws with once someone types, and a subset font could not draw the new characters.
209
+ * Measuring and drawing both take the font from here.
210
+ */
211
+ faceFont(node, om) {
212
+ const inherited = node.font;
213
+ if (inherited && om.isCustomFont(inherited.family, inherited.style)) {
214
+ const { family, style } = inherited;
215
+ const resource = om.getCustomFontResource(family, style);
216
+ return {
217
+ family,
218
+ style,
219
+ // Drawn SHAPED (ligatures, joining forms) - the shaping `getStringWidth` measures with.
220
+ face: {
221
+ res: `F${resource.fontIndex}`,
222
+ encode: (text) => `<${om.encodeCustomText(family, text, style)}>`,
223
+ },
224
+ resources: `/Font << /F${resource.fontIndex} ${resource.resourceIndex} 0 R >>`,
225
+ // A glyph the font lacks would draw as .notdef, which PDF/A forbids.
226
+ cover: (text) => {
227
+ const { text: drawable, dropped } = coverText(text, family, style, om, false);
228
+ if (dropped.length > 0)
229
+ om.reportMissingGlyph?.(dropped);
230
+ return drawable;
231
+ },
232
+ };
233
+ }
234
+ return {
235
+ family: "Helvetica",
236
+ style: NORMAL_STYLE,
237
+ face: HELV,
238
+ resources: `/Font << /Helv ${this.helv(om)} 0 R >>`,
239
+ cover: (text) => text,
240
+ };
241
+ }
199
242
  /** Measure one line for the appearance generator. */
200
- line(om, text, size) {
201
- return { text, width: om.getStringWidth(text, "Helvetica", size, NORMAL_STYLE) };
243
+ line(om, font, text, size) {
244
+ return { text, width: om.getStringWidth(text, font.family, size, font.style) };
202
245
  }
203
246
  /** Bake a text field's value into an appearance stream; returns the XObject number. */
204
247
  bakeText(node, om) {
205
248
  const f = node.field;
206
249
  if (f.kind !== "text")
207
250
  throw new Error("bakeText: not a text field");
208
- const { capHeight } = om.getFontDecoration("Helvetica", NORMAL_STYLE);
251
+ const font = this.faceFont(node, om);
252
+ const { capHeight } = om.getFontDecoration(font.family, font.style);
209
253
  const size = this.drawSize(node.style, node.height, capHeight);
210
254
  // A password field shows the mask, never the characters - the value still lives in /V.
211
- const value = f.password ? "•".repeat([...(f.value ?? "")].length) : (f.value ?? "");
255
+ const value = font.cover(f.password ? "•".repeat([...(f.value ?? "")].length) : (f.value ?? ""));
212
256
  const innerWidth = Math.max(1, node.width - 2 * (2 + node.style.borderWidth));
213
257
  let lines = [];
214
258
  if (value) {
215
- const texts = wrapFieldValue(value, "Helvetica", size, NORMAL_STYLE, innerWidth, om, f.multiline ?? false);
216
- lines = texts.map((t) => this.line(om, t, size));
259
+ const texts = wrapFieldValue(value, font.family, size, font.style, innerWidth, om, f.multiline ?? false);
260
+ lines = texts.map((t) => this.line(om, font, t, size));
217
261
  }
218
- const face = textFieldFace(node.width, node.height, node.style, lines, capHeight, size, "Helv", f.multiline ?? false, f.align);
219
- return om.addFormXObject(`[0 0 ${num2(node.width)} ${num2(node.height)}]`, face, `/Font << /Helv ${this.helv(om)} 0 R >>`);
262
+ const face = textFieldFace(node.width, node.height, node.style, lines, capHeight, size, font.face, f.multiline ?? false, f.align);
263
+ return om.addFormXObject(`[0 0 ${num2(node.width)} ${num2(node.height)}]`, face, font.resources);
220
264
  }
221
265
  /** Bake a choice field: a combo shows its current value on one line, a list box shows its options
222
266
  * with the selected rows highlighted. */
@@ -224,24 +268,27 @@ export class AcroFormCollector {
224
268
  const f = node.field;
225
269
  if (f.kind !== "choice")
226
270
  throw new Error("bakeChoice: not a choice");
227
- const { capHeight } = om.getFontDecoration("Helvetica", NORMAL_STYLE);
271
+ const font = this.faceFont(node, om);
272
+ const { capHeight } = om.getFontDecoration(font.family, font.style);
228
273
  const size = this.drawSize(node.style, node.height, capHeight);
229
274
  const shown = (v) => f.options.find((o) => o.value === v)?.label ?? v;
230
275
  const chosen = choiceSelection(f);
231
276
  const selected = new Set(chosen);
232
277
  let face;
233
278
  if (f.combo) {
234
- const current = chosen.length ? [this.line(om, shown(chosen[0]), size)] : [];
235
- face = textFieldFace(node.width, node.height, node.style, current, capHeight, size, "Helv", false, f.align);
279
+ const current = chosen.length
280
+ ? [this.line(om, font, font.cover(shown(chosen[0])), size)]
281
+ : [];
282
+ face = textFieldFace(node.width, node.height, node.style, current, capHeight, size, font.face, false, f.align);
236
283
  }
237
284
  else {
238
285
  const rows = f.options.map((o) => ({
239
- ...this.line(om, o.label ?? o.value, size),
286
+ ...this.line(om, font, font.cover(o.label ?? o.value), size),
240
287
  selected: selected.has(o.value),
241
288
  }));
242
- face = listBoxFace(node.width, node.height, node.style, rows, capHeight, size, "Helv", f.align);
289
+ face = listBoxFace(node.width, node.height, node.style, rows, capHeight, size, font.face, f.align);
243
290
  }
244
- return om.addFormXObject(`[0 0 ${num2(node.width)} ${num2(node.height)}]`, face, `/Font << /Helv ${this.helv(om)} 0 R >>`);
291
+ return om.addFormXObject(`[0 0 ${num2(node.width)} ${num2(node.height)}]`, face, font.resources);
245
292
  }
246
293
  get isEmpty() {
247
294
  return this.fieldRefs.length === 0;
@@ -278,9 +325,9 @@ export class AcroFormCollector {
278
325
  }
279
326
  /** Emit the widget object for one form-field IR node, register it as a field, and return its object
280
327
  * number so the PageRenderer can add it to that page's /Annots. */
281
- addField(node, om) {
328
+ addField(node, om, structParent) {
282
329
  if (node.field.kind === "radio")
283
- return this.addRadio(node, om);
330
+ return this.addRadio(node, om, structParent);
284
331
  this.claimName(node.field.name, node.field.kind);
285
332
  let dict;
286
333
  if (node.field.kind === "checkbox") {
@@ -307,7 +354,7 @@ export class AcroFormCollector {
307
354
  if (!this.bake)
308
355
  this.needAppearances = true;
309
356
  }
310
- const objNum = om.addObject(dict);
357
+ const objNum = om.addObject(withStructParent(dict, structParent));
311
358
  this.fieldRefs.push(objNum);
312
359
  return objNum;
313
360
  }
@@ -318,13 +365,15 @@ export class AcroFormCollector {
318
365
  const f = node.field;
319
366
  if (f.kind !== "pushbutton")
320
367
  throw new Error("buildPushButtonWidget: not a push button");
321
- const { capHeight } = om.getFontDecoration("Helvetica", NORMAL_STYLE);
368
+ const font = this.faceFont(node, om);
369
+ const { capHeight } = om.getFontDecoration(font.family, font.style);
322
370
  // `fontSize: 0` means auto-size, here as everywhere: resolve it before measuring or drawing.
323
371
  const size = this.drawSize(node.style, node.height, capHeight);
324
- const width = om.getStringWidth(f.label, "Helvetica", size, NORMAL_STYLE);
325
- const fontNum = this.helv(om);
326
- const face = pushButtonFace(node.width, node.height, node.style, f.label, width, capHeight, "Helv", size);
327
- const apRef = om.addFormXObject(`[0 0 ${num2(node.width)} ${num2(node.height)}]`, face, `/Font << /Helv ${fontNum} 0 R >>`);
372
+ const caption = font.cover(f.label);
373
+ const width = om.getStringWidth(caption, font.family, size, font.style);
374
+ this.helv(om); // the /DA below names it
375
+ const face = pushButtonFace(node.width, node.height, node.style, caption, width, capHeight, font.face, size);
376
+ const apRef = om.addFormXObject(`[0 0 ${num2(node.width)} ${num2(node.height)}]`, face, font.resources);
328
377
  const parts = [`/Type /Annot /Subtype /Widget /FT /Btn`, `/T ${om.pdfString(f.name)}`];
329
378
  if (f.tooltip !== undefined)
330
379
  parts.push(`/TU ${om.pdfString(f.tooltip)}`);
@@ -343,7 +392,7 @@ export class AcroFormCollector {
343
392
  /** A radio button: one KID widget of its group's shared field. The group field is reserved on first
344
393
  * sight (and added to /Fields); each button adds itself to that field's /Kids. Returns the kid widget
345
394
  * number, which goes into the PAGE /Annots (the parent field does not). */
346
- addRadio(node, om) {
395
+ addRadio(node, om, structParent) {
347
396
  const f = node.field;
348
397
  if (f.kind !== "radio")
349
398
  throw new Error("addRadio: not a radio");
@@ -375,7 +424,7 @@ export class AcroFormCollector {
375
424
  `/AS /${escName(as)}`,
376
425
  `/DA ${om.pdfString(`/ZaDb 0 Tf ${pdfColor(node.style.color)} rg`)}`,
377
426
  ];
378
- const kidNum = om.addObject(`<< ${parts.join(" ")}${boxChrome(node)} >>`);
427
+ const kidNum = om.addObject(withStructParent(`<< ${parts.join(" ")}${boxChrome(node)} >>`, structParent));
379
428
  g.kids.push(kidNum);
380
429
  return kidNum;
381
430
  }
@@ -386,12 +435,12 @@ export class AcroFormCollector {
386
435
  if (f.kind !== "signature")
387
436
  throw new Error("buildSignatureWidget: not a signature");
388
437
  this.hasSignature = true;
389
- const label = f.label ?? "";
390
- const { capHeight } = om.getFontDecoration("Helvetica", NORMAL_STYLE);
438
+ const font = this.faceFont(node, om);
439
+ const label = font.cover(f.label ?? "");
440
+ const { capHeight } = om.getFontDecoration(font.family, font.style);
391
441
  const size = this.drawSize(node.style, node.height, capHeight);
392
- const labelWidth = label ? om.getStringWidth(label, "Helvetica", size, NORMAL_STYLE) : 0;
393
- const fontNum = this.helv(om);
394
- const apRef = om.addFormXObject(`[0 0 ${num2(node.width)} ${num2(node.height)}]`, signatureFace(node.width, node.height, node.style, label, labelWidth, "Helv", size), `/Font << /Helv ${fontNum} 0 R >>`);
442
+ const labelWidth = label ? om.getStringWidth(label, font.family, size, font.style) : 0;
443
+ const apRef = om.addFormXObject(`[0 0 ${num2(node.width)} ${num2(node.height)}]`, signatureFace(node.width, node.height, node.style, label, labelWidth, font.face, size), font.resources);
395
444
  const parts = [`/Type /Annot /Subtype /Widget /FT /Sig`, `/T ${om.pdfString(f.name)}`];
396
445
  if (f.tooltip !== undefined)
397
446
  parts.push(`/TU ${om.pdfString(f.tooltip)}`);