@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
@@ -1,5 +1,3 @@
1
- // A PDF literal string escape (backslash first, then the string delimiters).
2
- const esc = (s) => s.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
3
1
  const num = (n) => (Number.isFinite(n) ? Number(n.toFixed(2)) : 0);
4
2
  /**
5
3
  * Collects named destinations - the jump targets an internal `Link({ to })` points at, declared with
@@ -19,13 +17,15 @@ export class DestRegistry {
19
17
  return this.dests.size === 0;
20
18
  }
21
19
  /** 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() {
20
+ * `/EmbeddedFiles`). Returns "" when empty. Names are sorted lexically - a name tree requires it.
21
+ * Takes the object manager only so the names go through its string choke-point and get encrypted
22
+ * with everything else. */
23
+ finalize(om) {
24
24
  if (this.dests.size === 0)
25
25
  return "";
26
26
  const entries = [...this.dests.keys()].sort().map((name) => {
27
27
  const d = this.dests.get(name);
28
- return `(${esc(name)}) [${d.pageObjNum} 0 R /XYZ null ${num(d.top)} null]`;
28
+ return `${om.pdfString(name)} [${d.pageObjNum} 0 R /XYZ null ${num(d.top)} null]`;
29
29
  });
30
30
  return `/Dests << /Names [ ${entries.join(" ")} ] >>`;
31
31
  }
@@ -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
  }
@@ -1,5 +1,4 @@
1
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
2
  const num = (n) => Number(n.toFixed(2));
4
3
  /**
5
4
  * Builds the PDF document outline (`/Outlines`) - the viewer's bookmark panel. Entries are collected
@@ -52,7 +51,7 @@ export class OutlineBuilder {
52
51
  // Emit each item dict, wired to its parent, siblings (prev/next) and first/last child.
53
52
  const emit = (nodes, parentNum) => {
54
53
  nodes.forEach((n, i) => {
55
- const parts = [`/Title (${esc(n.title)})`, `/Parent ${parentNum} 0 R`];
54
+ const parts = [`/Title ${om.pdfString(n.title)}`, `/Parent ${parentNum} 0 R`];
56
55
  if (i > 0)
57
56
  parts.push(`/Prev ${objOf.get(nodes[i - 1])} 0 R`);
58
57
  if (i < nodes.length - 1)
@@ -2,9 +2,12 @@ import type { OverflowPolicy } from "../layout/fragmentation.js";
2
2
  import { TTFParser } from "./ttf-parser.js";
3
3
  import type { SecurityHandler } from "../crypto/security-handler.js";
4
4
  import type { Gradient } from "../ir/display-list.js";
5
+ import type { FontVerticals } from "../text/line-metrics.js";
6
+ import type { FontDecoration } from "../text/text-decoration.js";
5
7
  import { StructTree } from "./struct-tree.js";
6
8
  import { OutlineBuilder } from "./outline.js";
7
9
  import { DestRegistry } from "./dest-registry.js";
10
+ import { AcroFormCollector } from "../forms/acroform.js";
8
11
  import type { PDFConfig } from "../renderer/pdf-document-class.js";
9
12
  import type { FontMetrics } from "./font-metrics.js";
10
13
  interface FontIndexes {
@@ -33,6 +36,8 @@ export declare class PDFObjectManager implements FontMetrics {
33
36
  pageFormat: [number, number];
34
37
  private afmParsers;
35
38
  private customFonts;
39
+ private verticalsCache;
40
+ private decorationCache;
36
41
  private customFontEmit;
37
42
  private attachments;
38
43
  private xmpMetadata?;
@@ -43,6 +48,7 @@ export declare class PDFObjectManager implements FontMetrics {
43
48
  private overflowPolicy;
44
49
  private security?;
45
50
  private encJobs;
51
+ private strJobs;
46
52
  private encryptObjNum?;
47
53
  private _struct;
48
54
  get struct(): StructTree;
@@ -50,6 +56,8 @@ export declare class PDFObjectManager implements FontMetrics {
50
56
  get outline(): OutlineBuilder;
51
57
  private _dests;
52
58
  get dests(): DestRegistry;
59
+ private _acroform;
60
+ get acroform(): AcroFormCollector;
53
61
  constructor();
54
62
  addObject(content: string): number;
55
63
  replaceObject(objectNumber: number, content: string): void;
@@ -57,9 +65,23 @@ export declare class PDFObjectManager implements FontMetrics {
57
65
  setOverflowPolicy(policy: OverflowPolicy): void;
58
66
  getOverflowPolicy(): OverflowPolicy;
59
67
  private encToken;
68
+ private strToken;
69
+ /**
70
+ * The single choke-point every literal string in an OBJECT goes through - a field's `/T` and `/V`, a
71
+ * bookmark `/Title`, a link `/URI`, an `/Alt` text, a destination name.
72
+ *
73
+ * Without a security handler this is the escaped literal we have always written, so output stays
74
+ * byte-identical. With one, the raw bytes are registered for the finalize pass and a placeholder takes
75
+ * their place: ISO 32000-1 7.6.2 requires ALL strings and streams to be encrypted, and the ones missed
76
+ * here were exactly the values a password is supposed to protect (ISSUE-7).
77
+ *
78
+ * A string INSIDE a content stream needs none of this - the stream is enciphered as a whole.
79
+ */
80
+ pdfString(text: string): string;
60
81
  private streamPayload;
61
82
  private stream;
62
83
  addContentStream(content: string): number;
84
+ addFormXObject(bbox: string, content: string, resources?: string): number;
63
85
  changePDFConfig(config: PDFConfig): void;
64
86
  getPDFConfig(): PDFConfig;
65
87
  private fillConfigWithStandardValues;
@@ -119,10 +141,32 @@ export declare class PDFObjectManager implements FontMetrics {
119
141
  private buildCIDFont;
120
142
  private buildToUnicode;
121
143
  private buildType0;
144
+ getFontVerticals(fontFamily: string, fontStyle: FontStyle): FontVerticals;
145
+ getFontDecoration(fontFamily: string, fontStyle: FontStyle): FontDecoration;
146
+ kerningEnabled: boolean;
147
+ setKerning(enabled: boolean): void;
148
+ /**
149
+ * The kerning adjustment for each adjacent code-point pair of `text`, in em/1000 (the sign the
150
+ * font declares: negative tightens). Length is `codePointCount - 1`. Zero at any pair next to a
151
+ * space - letters are never kerned against a space, which also keeps a per-word measure equal to a
152
+ * whole-line one (the space pairs contribute nothing either way).
153
+ *
154
+ * Standard-14 answers from the AFM `KPX` pairs. An embedded font has no kerning yet (its `kern` /
155
+ * `GPOS` tables are unparsed), so it returns zeros - never a wrong value, just no adjustment.
156
+ */
157
+ getKernPairs(text: string, fontFamily: string, fontStyle: FontStyle): number[];
158
+ /**
159
+ * Where the glyphs of `text` put ink inside a horizontal band, as x-intervals in POINTS measured
160
+ * from the start of the run. This is what an underline steps around ("skip-ink").
161
+ *
162
+ * `bandTop` / `bandBottom` are in points relative to the baseline, y UP (so both negative under
163
+ * it). Only an EMBEDDED font can answer: the standard-14 outlines live in the viewer, not here.
164
+ * Callers must check `isCustomFont` first rather than treat an empty result as "no descenders".
165
+ */
166
+ getInkSpansInBand(text: string, fontSize: number, fontFamily: string, fontStyle: FontStyle, bandTop: number, bandBottom: number, letterSpacing?: number): Array<[number, number]>;
122
167
  getStringWidth(text: string, fontFamily: string, fontSize: number, fontStyle: FontStyle): number;
123
168
  private getAVMParserByFont;
124
169
  getCharWidth(char: string, fontSize: number, fullFontName?: string, fontName?: string, fontStyle?: FontStyle): number;
125
- private getKerning;
126
170
  getAllFontsRaw(): Map<string, FontIndexes>;
127
171
  getAllImagesRaw(): Map<string, number>;
128
172
  getRenderedObjects(): string;
@@ -4,13 +4,14 @@ import { md5, md5Hex } from "./md5.js";
4
4
  import { zlibSync } from "fflate";
5
5
  import { bytesFromLatin1, latin1FromBytes } from "./bytes.js";
6
6
  import { AFMParser } from "./afm-parser.js";
7
- import { TTFParser } from "./ttf-parser.js";
7
+ import { mergeSpans, TTFParser } from "./ttf-parser.js";
8
8
  import { subsetTTF } from "./ttf-subsetter.js";
9
9
  import { getArrayBuffer } from "./utf8-to-windows1252-encoder.js";
10
10
  import { isEmojiCodePoint } from "../text/emoji-codepoints.js";
11
11
  import { StructTree } from "./struct-tree.js";
12
12
  import { OutlineBuilder } from "./outline.js";
13
13
  import { DestRegistry } from "./dest-registry.js";
14
+ import { AcroFormCollector } from "../forms/acroform.js";
14
15
  // Enums come from the leaf config module (never in a cycle); the config type is
15
16
  // erased at runtime so it can come from the cyclic module safely.
16
17
  import { ColorMode, Orientation } from "../renderer/pdf-config.js";
@@ -134,6 +135,9 @@ export class PDFObjectManager {
134
135
  get dests() {
135
136
  return this._dests;
136
137
  }
138
+ get acroform() {
139
+ return this._acroform;
140
+ }
137
141
  constructor(pageSize) {
138
142
  this.objects = [];
139
143
  this.objectPositions = [];
@@ -150,6 +154,10 @@ export class PDFObjectManager {
150
154
  // every lookup, and the lookups happen once per character - the string allocation alone was a third
151
155
  // of a custom-font render. `customFontEmit` below keeps the flat key: it is touched once per face.
152
156
  this.customFonts = new Map();
157
+ // Memoised getFontVerticals answers, family -> style -> metrics. Cleared when a font registers.
158
+ this.verticalsCache = new Map();
159
+ // Same, for getFontDecoration.
160
+ this.decorationCache = new Map();
153
161
  // Per-registered face: the reserved font-object numbers + the glyph ids actually used. The font
154
162
  // program is subsetted and the objects filled in by finalizeCustomFonts(), after the render pass.
155
163
  this.customFontEmit = new Map();
@@ -163,6 +171,7 @@ export class PDFObjectManager {
163
171
  this.compress = false;
164
172
  this.overflowPolicy = "error";
165
173
  this.encJobs = [];
174
+ this.strJobs = [];
166
175
  // Accessible (PDF/UA) tagging, off by default (byte-identical output); the API turns it on.
167
176
  this._struct = new StructTree();
168
177
  // Document outline (bookmarks). Empty unless a `Bookmark` was placed; then finalize() emits /Outlines.
@@ -170,6 +179,14 @@ export class PDFObjectManager {
170
179
  // Named destinations (internal-link jump targets). Empty unless an `Anchor` was placed; then it emits
171
180
  // a /Names /Dests tree.
172
181
  this._dests = new DestRegistry();
182
+ // Interactive form fields (AcroForm). Empty unless a form field was placed; then finalize() emits the
183
+ // catalog /AcroForm dict and each field's widget lands in its page /Annots.
184
+ this._acroform = new AcroFormCollector();
185
+ // Whether to kern. Document-level: measuring (via `runAdvance`, which reads this off the metrics)
186
+ // and drawing (the backend emits a `TJ`) both consult it, so they can never disagree. OFF while the
187
+ // embedded-font path (kern/GPOS) is unbuilt - a standard-14-only kerning would set the 14 system
188
+ // fonts finer than a user's own font, which is worse than none. Flip to on when GPOS lands.
189
+ this.kerningEnabled = false;
173
190
  if (pageSize)
174
191
  this.pageFormat = pageFormats[pageSize];
175
192
  this.fillConfigWithStandardValues();
@@ -204,6 +221,32 @@ export class PDFObjectManager {
204
221
  encToken(id) {
205
222
  return `JENC:${id}`;
206
223
  }
224
+ // The same trick for a literal STRING. A separate marker because the two are swapped differently: a
225
+ // stream keeps its delimiters and only the payload changes, a string is replaced whole by a hex one.
226
+ strToken(id) {
227
+ return `\x00\x02JSTR:${id}\x02\x00`;
228
+ }
229
+ /**
230
+ * The single choke-point every literal string in an OBJECT goes through - a field's `/T` and `/V`, a
231
+ * bookmark `/Title`, a link `/URI`, an `/Alt` text, a destination name.
232
+ *
233
+ * Without a security handler this is the escaped literal we have always written, so output stays
234
+ * byte-identical. With one, the raw bytes are registered for the finalize pass and a placeholder takes
235
+ * their place: ISO 32000-1 7.6.2 requires ALL strings and streams to be encrypted, and the ones missed
236
+ * here were exactly the values a password is supposed to protect (ISSUE-7).
237
+ *
238
+ * A string INSIDE a content stream needs none of this - the stream is enciphered as a whole.
239
+ */
240
+ pdfString(text) {
241
+ if (!this.security) {
242
+ return `(${text.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)")})`;
243
+ }
244
+ // Encrypt the bytes the unencrypted path would have written, so a viewer decrypts to the same value.
245
+ // No escaping is applied: the result is emitted as a HEX string, which has no escapes to get wrong.
246
+ const bytes = new Uint8Array(getArrayBuffer(text));
247
+ const id = this.strJobs.push(bytes) - 1;
248
+ return this.strToken(id);
249
+ }
207
250
  // The single encryption choke-point every stream emitter routes through (stream(), registerImage). Without
208
251
  // a security handler: the bytes as a latin1 string + their length. With one: register the raw bytes for the
209
252
  // finalize pass and return a placeholder + the pre-computable encrypted length (16-byte IV + PKCS#7 padding).
@@ -237,6 +280,14 @@ export class PDFObjectManager {
237
280
  addContentStream(content) {
238
281
  return this.addObject(this.stream("", new Uint8Array(getArrayBuffer(content))));
239
282
  }
283
+ // A Form XObject: a self-contained content stream with its own /BBox (+ optional /Resources). Used for
284
+ // form-field appearance streams (/AP). Routes through the same choke-point as page content, so
285
+ // compression and encryption apply. Returns the object number.
286
+ addFormXObject(bbox, content, resources) {
287
+ const dict = `/Type /XObject /Subtype /Form /BBox ${bbox}` +
288
+ (resources ? ` /Resources << ${resources} >>` : "");
289
+ return this.addObject(this.stream(dict, new Uint8Array(getArrayBuffer(content))));
290
+ }
240
291
  changePDFConfig(config) {
241
292
  this.pdfConfig = { ...this.pdfConfig, ...config };
242
293
  }
@@ -318,9 +369,8 @@ endstream`;
318
369
  attachFile(name, data, opts = {}) {
319
370
  const subtype = (opts.mimeType ?? "application/octet-stream").replace(/\//g, "#2F");
320
371
  const embedded = this.addObject(this.stream(`/Type /EmbeddedFile /Subtype /${subtype} /Params << /Size ${data.length} >>`, data));
321
- const escaped = (s) => s.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
322
- const desc = opts.description ? ` /Desc (${escaped(opts.description)})` : "";
323
- const filespec = this.addObject(`<< /Type /Filespec /F (${escaped(name)}) /UF (${escaped(name)}) ` +
372
+ const desc = opts.description ? ` /Desc ${this.pdfString(opts.description)}` : "";
373
+ const filespec = this.addObject(`<< /Type /Filespec /F ${this.pdfString(name)} /UF ${this.pdfString(name)} ` +
324
374
  `/AFRelationship /${opts.relationship ?? "Unspecified"}${desc} ` +
325
375
  `/EF << /F ${embedded} 0 R /UF ${embedded} 0 R >> >>`);
326
376
  this.attachments.push({ name, filespec });
@@ -341,8 +391,8 @@ endstream`;
341
391
  setOutputIntent(icc, opts = {}) {
342
392
  const profile = this.addObject(this.stream("/N 3", icc));
343
393
  this.outputIntent = this.addObject(`<< /Type /OutputIntent /S /GTS_PDFA1 ` +
344
- `/OutputConditionIdentifier (${opts.identifier ?? "sRGB"}) ` +
345
- `/Info (${opts.info ?? "sRGB IEC61966-2.1"}) /DestOutputProfile ${profile} 0 R >>`);
394
+ `/OutputConditionIdentifier ${this.pdfString(opts.identifier ?? "sRGB")} ` +
395
+ `/Info ${this.pdfString(opts.info ?? "sRGB IEC61966-2.1")} /DestOutputProfile ${profile} 0 R >>`);
346
396
  }
347
397
  getOutputIntent() {
348
398
  return this.outputIntent;
@@ -390,6 +440,18 @@ endstream`;
390
440
  if (idx >= 0)
391
441
  this.objects[idx] = this.objects[idx].replace(token, () => ciphertext);
392
442
  }
443
+ // Strings: the same idea, but a string is replaced WHOLE - `(text)` becomes `<ciphertext>`. Hex,
444
+ // because the ciphertext is binary and a hex string has no escape rules to get wrong.
445
+ for (let id = 0; id < this.strJobs.length; id++) {
446
+ const cipher = await this.security.encrypt(this.strJobs[id]);
447
+ const hex = Array.from(cipher, (b) => b.toString(16).padStart(2, "0").toUpperCase()).join("");
448
+ const token = this.strToken(id);
449
+ const idx = this.objects.findIndex((o) => o.includes(token));
450
+ if (idx >= 0)
451
+ this.objects[idx] = this.objects[idx].replace(token, () => `<${hex}>`);
452
+ }
453
+ // Added LAST on purpose: the /Encrypt dictionary's own strings must stay in the clear (7.6.2), and
454
+ // they would otherwise be swept up by the pass above.
393
455
  this.encryptObjNum = this.addObject(`<< ${this.security.encryptDict()} >>`);
394
456
  }
395
457
  // Registers (or reuses) a transparency graphics state and returns its resource name
@@ -462,6 +524,8 @@ endstream`;
462
524
  if (this.fonts.hasFont(fontName, fontStyle)) {
463
525
  return this.fonts.getFont(fontName, fontStyle); // Already exists? Return it!
464
526
  }
527
+ this.verticalsCache.delete(fontName);
528
+ this.decorationCache.delete(fontName);
465
529
  const data = STANDARD_AFM[fullName];
466
530
  if (data !== undefined) {
467
531
  this.afmParsers.push({
@@ -495,6 +559,8 @@ endstream`;
495
559
  if (byStyle.has(style))
496
560
  return;
497
561
  byStyle.set(style, new TTFParser(data));
562
+ this.verticalsCache.delete(name); // this family now answers from the .ttf, not from an AFM
563
+ this.decorationCache.delete(name);
498
564
  // Emission (the PDF font objects + page resource) is DEFERRED to first use via ensureEmitted(),
499
565
  // so a registered-but-never-rendered face - e.g. a bundled family the document doesn't touch -
500
566
  // costs nothing in the output. The metrics above are still available for layout immediately.
@@ -660,7 +726,7 @@ endstream`;
660
726
  .map((g) => `${g} [${all[g] ?? 1000}]`)
661
727
  .join(" ");
662
728
  return (`<< /Type /Font /Subtype /CIDFontType2 /BaseFont /${pdfName(name)} ` +
663
- `/CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> ` +
729
+ `/CIDSystemInfo << /Registry ${this.pdfString("Adobe")} /Ordering ${this.pdfString("Identity")} /Supplement 0 >> ` +
664
730
  `/FontDescriptor ${descriptor} 0 R /CIDToGIDMap /Identity ` +
665
731
  `/DW 1000 /W [${w}] >>`);
666
732
  }
@@ -690,24 +756,132 @@ endstream`;
690
756
  return (`<< /Type /Font /Subtype /Type0 /BaseFont /${pdfName(name)} /Encoding /Identity-H ` +
691
757
  `/DescendantFonts [${cidFont} 0 R] /ToUnicode ${toUnicode} 0 R >>`);
692
758
  }
759
+ // The vertical metrics of a face, in em fractions - what seats the baseline and sizes the line
760
+ // box (see text/line-metrics.ts). An embedded font answers from its own `hhea`; a standard-14
761
+ // font from its AFM header. Nothing here is guessed: both numbers come out of the font data.
762
+ //
763
+ // Asked once per LINE (not per character), but a long document has a lot of lines and the
764
+ // standard-14 lookup below is a linear scan, so the answer is memoised per face. Registering a
765
+ // font clears the cache - a name can go from standard-14 to embedded.
766
+ getFontVerticals(fontFamily, fontStyle) {
767
+ let byStyle = this.verticalsCache.get(fontFamily);
768
+ const hit = byStyle?.get(fontStyle);
769
+ if (hit)
770
+ return hit;
771
+ const ttf = this.getCustomFont(fontFamily, fontStyle);
772
+ const verticals = ttf
773
+ ? {
774
+ ascent: ttf.ascent / 1000,
775
+ descent: -ttf.descent / 1000, // hhea writes the descent negative
776
+ lineGap: ttf.lineGap / 1000,
777
+ }
778
+ : this.getAVMParserByFont(undefined, fontFamily, fontStyle).parser.verticals();
779
+ if (!byStyle) {
780
+ byStyle = new Map();
781
+ this.verticalsCache.set(fontFamily, byStyle);
782
+ }
783
+ byStyle.set(fontStyle, verticals);
784
+ return verticals;
785
+ }
786
+ // Underline / strikethrough geometry of a face, in em fractions. Same shape as getFontVerticals:
787
+ // an embedded font answers from its `post`/`OS/2` tables, a standard-14 one from its AFM header,
788
+ // and the answer is memoised per face because it is asked once per decorated run.
789
+ getFontDecoration(fontFamily, fontStyle) {
790
+ let byStyle = this.decorationCache.get(fontFamily);
791
+ const hit = byStyle?.get(fontStyle);
792
+ if (hit)
793
+ return hit;
794
+ const ttf = this.getCustomFont(fontFamily, fontStyle);
795
+ const decoration = ttf
796
+ ? ttf.decoration()
797
+ : this.getAVMParserByFont(undefined, fontFamily, fontStyle).parser.decoration();
798
+ if (!byStyle) {
799
+ byStyle = new Map();
800
+ this.decorationCache.set(fontFamily, byStyle);
801
+ }
802
+ byStyle.set(fontStyle, decoration);
803
+ return decoration;
804
+ }
805
+ setKerning(enabled) {
806
+ this.kerningEnabled = enabled;
807
+ }
808
+ /**
809
+ * The kerning adjustment for each adjacent code-point pair of `text`, in em/1000 (the sign the
810
+ * font declares: negative tightens). Length is `codePointCount - 1`. Zero at any pair next to a
811
+ * space - letters are never kerned against a space, which also keeps a per-word measure equal to a
812
+ * whole-line one (the space pairs contribute nothing either way).
813
+ *
814
+ * Standard-14 answers from the AFM `KPX` pairs. An embedded font has no kerning yet (its `kern` /
815
+ * `GPOS` tables are unparsed), so it returns zeros - never a wrong value, just no adjustment.
816
+ */
817
+ getKernPairs(text, fontFamily, fontStyle) {
818
+ const chars = [...text];
819
+ if (chars.length < 2)
820
+ return [];
821
+ const out = [];
822
+ // An embedded font kerns by GLYPH id (kern table / GPOS); a standard-14 one by character (AFM).
823
+ const ttf = this.getCustomFont(fontFamily, fontStyle);
824
+ const parser = ttf
825
+ ? undefined
826
+ : this.getAVMParserByFont(undefined, fontFamily, fontStyle).parser;
827
+ for (let i = 0; i < chars.length - 1; i++) {
828
+ const a = chars[i];
829
+ const b = chars[i + 1];
830
+ if (a === " " || b === " ") {
831
+ out.push(0);
832
+ }
833
+ else if (ttf) {
834
+ out.push(ttf.getKerning(ttf.getGlyphIndex(a.codePointAt(0)), ttf.getGlyphIndex(b.codePointAt(0))));
835
+ }
836
+ else {
837
+ out.push(parser.getKerning(a, b));
838
+ }
839
+ }
840
+ return out;
841
+ }
842
+ /**
843
+ * Where the glyphs of `text` put ink inside a horizontal band, as x-intervals in POINTS measured
844
+ * from the start of the run. This is what an underline steps around ("skip-ink").
845
+ *
846
+ * `bandTop` / `bandBottom` are in points relative to the baseline, y UP (so both negative under
847
+ * it). Only an EMBEDDED font can answer: the standard-14 outlines live in the viewer, not here.
848
+ * Callers must check `isCustomFont` first rather than treat an empty result as "no descenders".
849
+ */
850
+ getInkSpansInBand(text, fontSize, fontFamily, fontStyle, bandTop, bandBottom, letterSpacing = 0) {
851
+ const ttf = this.getCustomFont(fontFamily, fontStyle);
852
+ if (!ttf)
853
+ return [];
854
+ const scale = fontSize / ttf.unitsPerEm;
855
+ const spans = [];
856
+ let pen = 0;
857
+ for (const ch of text) {
858
+ const codePoint = ch.codePointAt(0);
859
+ if (codePoint !== undefined) {
860
+ for (const [x0, x1] of ttf.inkSpansInBand(codePoint, bandTop / scale, bandBottom / scale)) {
861
+ spans.push([pen + x0 * scale, pen + x1 * scale]);
862
+ }
863
+ }
864
+ // The pen advances by the glyph AND its letter-spacing, so the ink of the next glyph lands
865
+ // where `Tc` actually draws it - otherwise the skipped gaps drift under the wrong letters.
866
+ pen += this.getCharWidth(ch, fontSize, undefined, fontFamily, fontStyle) + letterSpacing;
867
+ }
868
+ return mergeSpans(spans);
869
+ }
693
870
  // Returns the current width of a text, included kernings
694
871
  getStringWidth(text, fontFamily, fontSize, fontStyle) {
695
872
  let width = 0;
696
873
  // Iterate code points, not UTF-16 units: an astral char (emoji, CJK-ext) is a surrogate pair,
697
874
  // and indexing by unit would measure each half as its own (zero-width) "char". The spread splits
698
875
  // on code points; for BMP text this is one unit each, so the result is unchanged.
699
- const chars = [...text];
700
- for (let i = 0; i < chars.length; i++) {
701
- const char = chars[i];
702
- const nextChar = chars[i + 1] || null;
703
- // Get signs width
704
- const charWidth = this.getCharWidth(char, fontSize, undefined, fontFamily, fontStyle);
705
- width += charWidth;
706
- // If a next sign available calculate the kerning
707
- if (nextChar) {
708
- const kerning = this.getKerning(char, nextChar, undefined, fontFamily, fontStyle);
709
- width += kerning * fontSize; // Kerning must be scaled with the font size
710
- }
876
+ //
877
+ // NO KERNING. We write a run as a single `Tj`, and a viewer advances that by the font's plain
878
+ // widths - PDF never kerns on its own; a producer has to say so with a `TJ` array. Folding the
879
+ // AFM's kerning into the MEASUREMENT while the OUTPUT ignores it made every kerned string draw
880
+ // wider than its box: "AVATAR Wave" at 40pt by 19pt, "Total" at 11pt by 5.7%. Measured must equal
881
+ // drawn. The AFM kerning pairs stay parsed (`getKerning`) for the day we emit `TJ` - see the
882
+ // "real kerning" item in todo.md, which must cover embedded fonts too (`kern`/`GPOS`).
883
+ for (const char of text) {
884
+ width += this.getCharWidth(char, fontSize, undefined, fontFamily, fontStyle);
711
885
  }
712
886
  return width;
713
887
  }
@@ -767,13 +941,6 @@ endstream`;
767
941
  return (advanceWidth / 1000) * fontSize;
768
942
  }
769
943
  // Method to get the kerning, if available, between two signs
770
- getKerning(char, nextChar, fullFontName, fontName, fontStyle) {
771
- // TrueType kerning (the kern/GPOS tables) is not wired up yet - no kerning for custom fonts.
772
- if (this.getCustomFont(fullFontName ?? fontName, fontStyle))
773
- return 0;
774
- const currentParser = this.getAVMParserByFont(fullFontName, fontName, fontStyle);
775
- return currentParser.parser.getKerning(char, nextChar) / 1000;
776
- }
777
944
  // Returns all fonts
778
945
  getAllFontsRaw() {
779
946
  return this.fonts.getAllFonts();
@@ -1,5 +1,4 @@
1
1
  // Escapes a PDF literal string ( ... ) - the language tag is safe ASCII, but /Alt can carry arbitrary text.
2
- const esc = (s) => s.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
3
2
  // A structure role reaches the PDF as a raw name (/H1, /S /Table). Roles come from a controlled set,
4
3
  // but restrict them to a safe PDF-name token anyway (defence-in-depth) so a stray "/", whitespace, or
5
4
  // operator can never corrupt the content stream / object dictionary. Empty after stripping -> /Span.
@@ -90,7 +89,7 @@ export class StructTree {
90
89
  ...childrenOf(e.key).map((c) => `${objNum.get(c.key)} 0 R`),
91
90
  ...e.mc.map((m) => `<< /Type /MCR /Pg ${pg(m.structParents)} 0 R /MCID ${m.mcid} >>`),
92
91
  ].join(" ");
93
- const alt = e.alt ? ` /Alt (${esc(e.alt)})` : "";
92
+ const alt = e.alt ? ` /Alt ${om.pdfString(e.alt)}` : "";
94
93
  // PDF/UA 7.5: a header cell needs a Scope so a reader can associate data cells with it. Our headers
95
94
  // are a header ROW (the Table `header` option), so each TH is the head of its Column.
96
95
  const attr = e.role === "TH" ? " /A << /O /Table /Scope /Column >>" : "";
@@ -125,6 +124,6 @@ export class StructTree {
125
124
  om.replaceObject(parentTreeNum, `<< /Nums [ ${nums.join(" ")} ] >>`);
126
125
  om.replaceObject(rootNum, `<< /Type /StructTreeRoot /K ${docNum} 0 R /ParentTree ${parentTreeNum} 0 R ` +
127
126
  `/ParentTreeNextKey ${this.structParentsCounter} >>`);
128
- return `/MarkInfo << /Marked true >> /StructTreeRoot ${rootNum} 0 R /Lang (${esc(this.lang)})`;
127
+ return `/MarkInfo << /Marked true >> /StructTreeRoot ${rootNum} 0 R /Lang ${om.pdfString(this.lang)}`;
129
128
  }
130
129
  }
@@ -1,3 +1,4 @@
1
+ import type { FontDecoration } from "../text/text-decoration.js";
1
2
  export type GlyphPathCommand = {
2
3
  type: "M";
3
4
  x: number;
@@ -47,11 +48,19 @@ export interface ColorGlyphLayer {
47
48
  paint: Paint;
48
49
  transform?: Affine;
49
50
  }
51
+ /** Sorts x-intervals and unions the overlapping ones. */
52
+ export declare function mergeSpans(spans: Array<[number, number]>): Array<[number, number]>;
50
53
  export declare class TTFParser {
51
54
  private data;
52
55
  unitsPerEm: number;
53
56
  ascent: number;
54
57
  descent: number;
58
+ lineGap: number;
59
+ private underlinePositionRaw;
60
+ private underlineThicknessRaw;
61
+ private xHeightRaw;
62
+ private capHeightRaw;
63
+ private cachedDecoration?;
55
64
  bbox: [number, number, number, number];
56
65
  private numGlyphs;
57
66
  private numHMetrics;
@@ -65,6 +74,9 @@ export declare class TTFParser {
65
74
  private palette;
66
75
  private colrBaseV1;
67
76
  private colrLayerListOffset;
77
+ private kernPairs;
78
+ private hasKerning;
79
+ private gposPairPos;
68
80
  constructor(data: Uint8Array);
69
81
  getData(): Uint8Array;
70
82
  glyphCount(): number;
@@ -75,6 +87,47 @@ export declare class TTFParser {
75
87
  getAdvanceWidth(glyphIndex: number): number;
76
88
  getCharWidth(char: string, fontSize: number): number;
77
89
  getStringWidth(text: string, fontSize: number): number;
90
+ private readDecorationMetrics;
91
+ /**
92
+ * Underline / strikethrough geometry, as fractions of the em.
93
+ *
94
+ * A font without `post` (rare) gets the classic 10% below the baseline at 5% thickness - the same
95
+ * numbers all 14 standard AFMs happen to declare, so it is a measured convention rather than an
96
+ * invention. A font whose `OS/2` predates version 2 has no x- or cap-height, so we MEASURE them off
97
+ * the outlines of `x` and `H` instead of guessing.
98
+ */
99
+ decoration(): FontDecoration;
100
+ /** The highest ON-CURVE point of a code point's outline, in font units; 0 for a missing glyph.
101
+ * A quadratic's control point may sit above the curve, so it is not a bound - and `x`/`H` have
102
+ * flat tops in any face where this fallback matters. */
103
+ private outlineTop;
104
+ /**
105
+ * Where a glyph's INK crosses a horizontal band, as x-intervals in font units (pen origin at 0).
106
+ * This is what lets an underline step around a descender ("skip-ink"), the way a browser does.
107
+ *
108
+ * Scanline fill: sample a few horizontal lines across the band, find every place the outline
109
+ * crosses them, and pair the crossings up into filled spans. A handful of scanlines is plenty -
110
+ * the band is a few percent of the em tall, and a descender stem does not wander inside it.
111
+ *
112
+ * `yTop` / `yBottom` are in font units, y UP from the baseline (so both are negative under it).
113
+ * Returns [] for a glyph with no outline (a space, an unmapped code point, a composite we cannot
114
+ * decompose) - the caller then leaves the line unbroken there, which is the safe direction.
115
+ */
116
+ inkSpansInBand(codePoint: number, yTop: number, yBottom: number): Array<[number, number]>;
117
+ /** True if the font declares any horizontal kerning (kern table or GPOS). */
118
+ kerns(): boolean;
119
+ private kernCache;
120
+ /** Kerning between two glyphs, in em/1000 (the standard-14 unit); 0 if the pair is not kerned.
121
+ * Negative tightens, matching the AFM sign. GPOS wins over the legacy `kern` table when both
122
+ * exist (it is what a modern shaper uses, and can carry more pairs). */
123
+ getKerning(leftGid: number, rightGid: number): number;
124
+ private readKern;
125
+ private readGpos;
126
+ private gposKern;
127
+ private static valueRecordSize;
128
+ private xAdvance;
129
+ private coverageIndex;
130
+ private classOf;
78
131
  private table;
79
132
  private readTableDirectory;
80
133
  private readHmtx;