@jasy/pdf 1.0.0-alpha.9 → 1.0.0-beta.2

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 (113) hide show
  1. package/README.md +27 -9
  2. package/dist/api/color.js +69 -0
  3. package/dist/api/content.d.ts +40 -1
  4. package/dist/api/content.js +83 -0
  5. package/dist/api/descriptor.js +6 -1
  6. package/dist/api/dimension.d.ts +3 -2
  7. package/dist/api/font-url.js +3 -2
  8. package/dist/api/structure.d.ts +7 -0
  9. package/dist/api/structure.js +38 -3
  10. package/dist/api/text.d.ts +23 -2
  11. package/dist/api/text.js +9 -0
  12. package/dist/canvas/painter.d.ts +102 -0
  13. package/dist/canvas/painter.js +183 -0
  14. package/dist/elements/canvas-element.d.ts +38 -0
  15. package/dist/elements/canvas-element.js +47 -0
  16. package/dist/elements/container-element.d.ts +2 -0
  17. package/dist/elements/container-element.js +6 -0
  18. package/dist/elements/index.d.ts +2 -0
  19. package/dist/elements/index.js +2 -0
  20. package/dist/elements/layout/expanded-element.d.ts +2 -0
  21. package/dist/elements/layout/expanded-element.js +4 -0
  22. package/dist/elements/layout/keep-together-element.d.ts +2 -0
  23. package/dist/elements/layout/keep-together-element.js +4 -0
  24. package/dist/elements/layout/padding-element.d.ts +5 -0
  25. package/dist/elements/layout/padding-element.js +8 -0
  26. package/dist/elements/layout/struct-group.d.ts +2 -0
  27. package/dist/elements/layout/struct-group.js +4 -0
  28. package/dist/elements/pdf-element.d.ts +19 -3
  29. package/dist/elements/pdf-element.js +22 -4
  30. package/dist/elements/rectangle-element.d.ts +2 -0
  31. package/dist/elements/rectangle-element.js +6 -0
  32. package/dist/elements/row-element.d.ts +2 -0
  33. package/dist/elements/row-element.js +6 -0
  34. package/dist/elements/svg-element.d.ts +41 -0
  35. package/dist/elements/svg-element.js +93 -0
  36. package/dist/elements/text-element.d.ts +33 -1
  37. package/dist/elements/text-element.js +80 -4
  38. package/dist/ir/display-list.d.ts +44 -5
  39. package/dist/layout/min-intrinsic.d.ts +29 -0
  40. package/dist/layout/min-intrinsic.js +37 -0
  41. package/dist/renderer/anchor-renderer.js +1 -1
  42. package/dist/renderer/bookmark-renderer.js +1 -1
  43. package/dist/renderer/canvas-renderer.d.ts +14 -0
  44. package/dist/renderer/canvas-renderer.js +36 -0
  45. package/dist/renderer/container-renderer.js +1 -3
  46. package/dist/renderer/default-text-style-renderer.js +1 -1
  47. package/dist/renderer/deferred-renderer.js +1 -1
  48. package/dist/renderer/expanded-renderer.js +1 -1
  49. package/dist/renderer/index.d.ts +2 -0
  50. package/dist/renderer/index.js +2 -0
  51. package/dist/renderer/keep-together-renderer.js +1 -1
  52. package/dist/renderer/link-renderer.js +1 -1
  53. package/dist/renderer/padding-renderer.js +1 -1
  54. package/dist/renderer/page-builder-renderer.js +1 -1
  55. package/dist/renderer/page-renderer.js +1 -3
  56. package/dist/renderer/pdf-backend.d.ts +15 -4
  57. package/dist/renderer/pdf-backend.js +125 -35
  58. package/dist/renderer/pdf-renderer.js +5 -1
  59. package/dist/renderer/positioned-renderer.js +1 -1
  60. package/dist/renderer/rectangle-renderer.js +1 -3
  61. package/dist/renderer/repeating-header-renderer.js +1 -2
  62. package/dist/renderer/rotated-renderer.js +1 -1
  63. package/dist/renderer/row-renderer.js +1 -3
  64. package/dist/renderer/struct-group-renderer.js +1 -1
  65. package/dist/renderer/svg-renderer.d.ts +11 -0
  66. package/dist/renderer/svg-renderer.js +27 -0
  67. package/dist/renderer/text-renderer.js +30 -19
  68. package/dist/svg/css.d.ts +38 -0
  69. package/dist/svg/css.js +115 -0
  70. package/dist/svg/errors.d.ts +24 -0
  71. package/dist/svg/errors.js +30 -0
  72. package/dist/svg/gradients.d.ts +34 -0
  73. package/dist/svg/gradients.js +156 -0
  74. package/dist/svg/index.d.ts +22 -0
  75. package/dist/svg/index.js +331 -0
  76. package/dist/svg/shapes.d.ts +6 -0
  77. package/dist/svg/shapes.js +177 -0
  78. package/dist/svg/style.d.ts +61 -0
  79. package/dist/svg/style.js +144 -0
  80. package/dist/svg/transform.d.ts +13 -0
  81. package/dist/svg/transform.js +68 -0
  82. package/dist/svg/units.d.ts +11 -0
  83. package/dist/svg/units.js +73 -0
  84. package/dist/svg/xml.d.ts +24 -0
  85. package/dist/svg/xml.js +158 -0
  86. package/dist/text/advance.d.ts +3 -0
  87. package/dist/text/advance.js +51 -8
  88. package/dist/text/glyph-coverage.d.ts +15 -0
  89. package/dist/text/glyph-coverage.js +77 -0
  90. package/dist/text/line-breaker.d.ts +8 -0
  91. package/dist/text/line-breaker.js +146 -70
  92. package/dist/text/shape.d.ts +3 -3
  93. package/dist/text/shape.js +34 -10
  94. package/dist/text/text-style.d.ts +13 -0
  95. package/dist/text/text-style.js +6 -0
  96. package/dist/text/whitespace.d.ts +4 -0
  97. package/dist/text/whitespace.js +43 -0
  98. package/dist/text/word-splitting.d.ts +42 -0
  99. package/dist/text/word-splitting.js +72 -0
  100. package/dist/utils/afm-parser.d.ts +0 -1
  101. package/dist/utils/afm-parser.js +15 -16
  102. package/dist/utils/flex-layout.js +67 -18
  103. package/dist/utils/font-metrics.d.ts +12 -3
  104. package/dist/utils/pdf-object-manager.d.ts +31 -5
  105. package/dist/utils/pdf-object-manager.js +107 -21
  106. package/dist/utils/renderer-registry.d.ts +13 -1
  107. package/dist/utils/renderer-registry.js +22 -1
  108. package/dist/utils/utf8-to-windows1252-encoder.js +5 -3
  109. package/dist/utils/woff2.d.ts +6 -0
  110. package/dist/utils/woff2.js +651 -0
  111. package/dist/vector/path.d.ts +33 -0
  112. package/dist/vector/path.js +81 -0
  113. package/package.json +5 -2
@@ -205,24 +205,69 @@ export class FlexLayoutHelper {
205
205
  const shrinkers = children.filter((c) => c.flexShrink > 0 && !(c instanceof FlexiblePDFElement));
206
206
  const overflow = fixedMain + totalBasis + totalGap - mainAvail;
207
207
  if (shrinkers.length > 0 && Number.isFinite(mainAvail) && overflow > 0) {
208
- const weight = (c) => c.flexShrink * axis.mainOf(fixedSize.get(c));
209
- const totalWeight = shrinkers.reduce((n, c) => n + weight(c), 0);
210
- if (totalWeight > 0) {
211
- for (const child of shrinkers) {
212
- const natural = axis.mainOf(fixedSize.get(child));
213
- // Never below zero: when the GAPS alone outgrow the line, a proportional share asks for more
214
- // than a child has. No test can tell this clamp apart from its absence - `constrainWidth`
215
- // floors at 0 further down either way - but handing out a negative constraint is nonsense,
216
- // and the next reader should not have to work out that it happens to be harmless.
217
- const target = Math.max(0, natural - (weight(child) / totalWeight) * overflow);
218
- shrunkCap.set(child, target);
219
- // Re-measure NOW, not in pass 2: a narrower child may wrap to more lines, and the line's
220
- // cross extent is settled just below.
221
- const size = child.calculateLayout(axis.measureConstraints(crossAvail, target), axis.offsetAt(mainStart, crossOrigin), ctx);
222
- fixedMain += axis.mainOf(size) - natural;
223
- crossUsed = Math.max(crossUsed, axis.crossOf(size));
224
- fixedSize.set(child, size);
208
+ // Every child is floored at what it can actually hold - its `min-content` extent, which is CSS's
209
+ // automatic `min-width` on a flex item. Without it a badly overflowing line would grind a
210
+ // paragraph down to one word per line: the box gets narrow, the text gets tall, and nothing is
211
+ // gained. Past that floor a child overflows again, which is what a browser does too.
212
+ const naturalOf = (c) => axis.mainOf(fixedSize.get(c));
213
+ const floorOf = new Map(shrinkers.map((c) => [
214
+ c,
215
+ // Never above its natural size: the shrink pass may only take space away, never hand it out.
216
+ // Never below zero either - when the GAPS alone outgrow the line a proportional share asks
217
+ // for more than a child has, and a negative constraint is nonsense even though
218
+ // `constrainWidth` would floor it further down anyway.
219
+ Math.min(naturalOf(c), Math.max(0, c.minIntrinsicMain(axis.mainHorizontal, ctx))),
220
+ ]));
221
+ // CSS resolves this iteratively (Flexbox 9.7): a child that lands on its floor is FROZEN there
222
+ // and the share it could not absorb is handed to the rest. A single pass would leave that share
223
+ // unspent, so one unsqueezable child would keep the whole line overflowing while its neighbours
224
+ // still had room to give.
225
+ const target = new Map(shrinkers.map((c) => [c, naturalOf(c)]));
226
+ const frozen = new Set();
227
+ let flexible = [...shrinkers];
228
+ // What the frozen ones took off the overflow; the rest is always shared out from scratch, never
229
+ // from what a previous round already assigned.
230
+ let absorbedByFrozen = 0;
231
+ while (flexible.length > 0) {
232
+ const toAbsorb = overflow - absorbedByFrozen;
233
+ if (toAbsorb <= 1e-9)
234
+ break;
235
+ const weight = (c) => c.flexShrink * naturalOf(c);
236
+ const totalWeight = flexible.reduce((n, c) => n + weight(c), 0);
237
+ if (totalWeight <= 0)
238
+ break;
239
+ const froze = [];
240
+ for (const child of flexible) {
241
+ const natural = naturalOf(child);
242
+ const floor = floorOf.get(child);
243
+ const wanted = natural - (weight(child) / totalWeight) * toAbsorb;
244
+ if (wanted < floor) {
245
+ target.set(child, floor);
246
+ froze.push(child);
247
+ absorbedByFrozen += natural - floor;
248
+ }
249
+ else {
250
+ target.set(child, wanted);
251
+ }
225
252
  }
253
+ if (froze.length === 0)
254
+ break;
255
+ for (const c of froze)
256
+ frozen.add(c);
257
+ flexible = flexible.filter((c) => !frozen.has(c));
258
+ }
259
+ for (const child of shrinkers) {
260
+ const natural = naturalOf(child);
261
+ const capped = target.get(child);
262
+ if (capped >= natural)
263
+ continue;
264
+ shrunkCap.set(child, capped);
265
+ // Re-measure NOW, not in pass 2: a narrower child may wrap to more lines, and the line's
266
+ // cross extent is settled just below.
267
+ const size = child.calculateLayout(axis.measureConstraints(crossAvail, capped), axis.offsetAt(mainStart, crossOrigin), ctx);
268
+ fixedMain += axis.mainOf(size) - natural;
269
+ crossUsed = Math.max(crossUsed, axis.crossOf(size));
270
+ fixedSize.set(child, size);
226
271
  }
227
272
  }
228
273
  const leftover = mainAvail - fixedMain - totalBasis - totalGap;
@@ -272,7 +317,11 @@ export class FlexLayoutHelper {
272
317
  const stretch = align === "stretch";
273
318
  let mainExtent;
274
319
  if (child instanceof FlexiblePDFElement) {
275
- mainExtent = child.getBasis(percentBase) + (child.getFlex() / totalFlex) * remaining;
320
+ // `totalFlex` is 0 when every flex child asked for `flex: 0` (a pure `flexBasis` slot). The
321
+ // share is then 0/0 - a NaN that becomes the offset of every later sibling, which is the
322
+ // shape of the old Spacer bug. Such a child simply takes its basis.
323
+ const share = totalFlex > 0 ? (child.getFlex() / totalFlex) * remaining : 0;
324
+ mainExtent = child.getBasis(percentBase) + share;
276
325
  // A flex child fills the MAIN axis, and its cross size is only known after layout - there is
277
326
  // nothing to align against. So alignSelf is a no-op here, and that has to hold for the CROSS
278
327
  // CONSTRAINT too: reading the per-child alignment would hand an `alignSelf: "start"` flex child
@@ -9,7 +9,7 @@ import type { FontDecoration } from "../text/text-decoration.js";
9
9
  * touching layout code.
10
10
  */
11
11
  export interface FontMetrics {
12
- getStringWidth(text: string, fontFamily: string, fontSize: number, fontStyle: FontStyle): number;
12
+ getStringWidth(text: string, fontFamily: string, fontSize: number, fontStyle: FontStyle, ligatures?: boolean): number;
13
13
  getCharWidth(char: string, fontSize: number, fullFontName?: string, fontName?: string, fontStyle?: FontStyle): number;
14
14
  /** Ascent / descent / lineGap of a face, in em fractions - the vertical counterpart of the widths
15
15
  * above. Drives the line box and the baseline (see `text/line-metrics.ts`). */
@@ -20,12 +20,21 @@ export interface FontMetrics {
20
20
  /** Whether kerning is on for this document. `runAdvance` reads it so a measured advance matches the
21
21
  * drawn one (the backend emits a `TJ` under the same flag). */
22
22
  readonly kerningEnabled: boolean;
23
+ /** Bumped whenever a face is registered, so a memoised measurement knows to drop it: the same family
24
+ * name can mean a different face afterwards. Optional - leaving it out means never invalidating. */
25
+ readonly fontEpoch?: number;
23
26
  /** Per-adjacent-pair kerning of `text`, in em/1000 (negative tightens); length `codePoints - 1`,
24
27
  * zero next to a space. Only meaningful when `kerningEnabled`. */
25
- getKernPairs(text: string, fontFamily: string, fontStyle: FontStyle): number[];
28
+ getKernPairs(text: string, fontFamily: string, fontStyle: FontStyle, ligatures?: boolean): number[];
26
29
  /** Whether this family can draw the code point at all - what picks a face out of a fallback stack. */
27
30
  hasGlyph(codePoint: number, fontFamily: string, fontStyle: FontStyle): boolean;
31
+ /** Whether the document draws this code point as colour emoji rather than from the text font - it
32
+ * is missing from that font on purpose, so glyph coverage must not touch it. */
33
+ rendersAsEmoji?(codePoint: number, fontFamily: string, fontStyle: FontStyle): boolean;
34
+ /** Told when a code point had to be removed because no font could draw it. Optional: a metrics
35
+ * object used only for measuring (a test double) has nothing to report to. */
36
+ reportMissingGlyph?(codePoints: number[]): void;
28
37
  /** How many glyphs the run will DRAW, when a ligature made that differ from its code-point count.
29
38
  * Absent or `undefined` means "the same", which is every Latin run. */
30
- shapedGlyphCount?(text: string, fontFamily?: string, fontStyle?: FontStyle): number | undefined;
39
+ shapedGlyphCount?(text: string, fontFamily?: string, fontStyle?: FontStyle, ligatures?: boolean): number | undefined;
31
40
  }
@@ -1,6 +1,7 @@
1
1
  import type { OverflowPolicy } from "../layout/fragmentation.js";
2
2
  import { TTFParser } from "./ttf-parser.js";
3
3
  import { type ShapedGlyph } from "../text/shape.js";
4
+ export declare function featureKey(f: readonly string[]): string;
4
5
  import type { SecurityHandler } from "../crypto/security-handler.js";
5
6
  import type { Gradient } from "../ir/display-list.js";
6
7
  import type { FontVerticals } from "../text/line-metrics.js";
@@ -40,7 +41,12 @@ export declare class PDFObjectManager implements FontMetrics {
40
41
  private verticalsCache;
41
42
  private decorationCache;
42
43
  private customFontEmit;
44
+ /** Shaped runs, cached face -> feature set -> text. Nested, not one map under a composed key: that
45
+ * key would carry the whole run's text, so every lookup would copy it into a fresh string. */
43
46
  private shapeCache;
47
+ /** Bumped by every font registration; `runAdvance` watches it to drop memoised widths, since a
48
+ * family name can mean a different face afterwards. */
49
+ fontEpoch: number;
44
50
  private shapedOrigins;
45
51
  private attachments;
46
52
  private xmpMetadata?;
@@ -65,6 +71,16 @@ export declare class PDFObjectManager implements FontMetrics {
65
71
  addObject(content: string): number;
66
72
  replaceObject(objectNumber: number, content: string): void;
67
73
  setCompress(on: boolean): void;
74
+ /**
75
+ * Code points no font in the document could draw, so they were removed rather than emitted as
76
+ * `.notdef` (which PDF/A forbids). Collected here because this object is the one thing threaded
77
+ * through BOTH passes; the caller gets them back with the bytes, so an application can say which
78
+ * character went missing instead of a server log nobody reads.
79
+ */
80
+ private readonly missingGlyphs;
81
+ reportMissingGlyph(codePoints: number[]): void;
82
+ /** The dropped code points, in the order first seen. Empty for a document that hit none. */
83
+ getMissingGlyphs(): number[];
68
84
  setOverflowPolicy(policy: OverflowPolicy): void;
69
85
  getOverflowPolicy(): OverflowPolicy;
70
86
  private encToken;
@@ -142,21 +158,28 @@ export declare class PDFObjectManager implements FontMetrics {
142
158
  * Used to pick a family out of a fallback stack, per code point.
143
159
  */
144
160
  hasGlyph(codePoint: number, fontFamily: string, fontStyle?: FontStyle): boolean;
161
+ /**
162
+ * Whether the document draws this code point through the colour-emoji path instead of the text
163
+ * font - a fallback emoji FONT, or an image source (`Document({ emoji })`). Such a code point is
164
+ * missing from the text font ON PURPOSE, so glyph coverage must leave it alone. Mirrors exactly
165
+ * what `getCharWidth` measures it as, or a dropped emoji would have been measured and not drawn.
166
+ */
167
+ rendersAsEmoji(codePoint: number, fontFamily: string, fontStyle: FontStyle): boolean;
145
168
  private colorRenders;
146
169
  getCustomFontResource(name: string, style?: FontStyle): FontIndexes | undefined;
147
170
  /** How many glyphs a run draws - its code-point count unless a ligature merged some. `letterSpacing`
148
171
  * is per glyph (`Tc`), so this is what it multiplies. */
149
- shapedGlyphCount(text: string, fontFamily?: string, fontStyle?: FontStyle): number | undefined;
172
+ shapedGlyphCount(text: string, fontFamily?: string, fontStyle?: FontStyle, ligatures?: boolean): number | undefined;
150
173
  /**
151
174
  * The shaped glyphs of a run, or undefined when nothing needed shaping. The ONE place shaping is
152
175
  * decided, so measuring and drawing cannot disagree. Memoised: a paragraph is measured several
153
176
  * times (layout, pagination, drawing).
154
177
  */
155
- shapeText(text: string, fontFamily?: string, fontStyle?: FontStyle): ShapedGlyph[] | undefined;
178
+ shapeText(text: string, fontFamily?: string, fontStyle?: FontStyle, features?: readonly string[]): ShapedGlyph[] | undefined;
156
179
  /** Hex Identity-H for glyphs the producer already resolved (a shaped run), which cannot be given as
157
180
  * text. Recording each keeps it in the subset. */
158
181
  registerGlyphs(name: string, glyphs: readonly number[], style?: FontStyle): string;
159
- encodeCustomText(name: string, text: string, style?: FontStyle): string;
182
+ encodeCustomText(name: string, text: string, style?: FontStyle, shape?: boolean): string;
160
183
  private buildFontFile2;
161
184
  private buildFontDescriptor;
162
185
  private buildCIDFont;
@@ -175,7 +198,10 @@ export declare class PDFObjectManager implements FontMetrics {
175
198
  * Standard-14 answers from the AFM `KPX` pairs. An embedded font has no kerning yet (its `kern` /
176
199
  * `GPOS` tables are unparsed), so it returns zeros - never a wrong value, just no adjustment.
177
200
  */
178
- getKernPairs(text: string, fontFamily: string, fontStyle: FontStyle): number[];
201
+ /** Kerning between adjacent GLYPHS - what a shaped run needs, and what the backend already holds.
202
+ * Same lookup as the character path; `getKerning` is keyed by glyph id either way. */
203
+ getGlyphKernPairs(glyphs: readonly number[], fontFamily: string, fontStyle: FontStyle): number[];
204
+ getKernPairs(text: string, fontFamily: string, fontStyle: FontStyle, ligatures?: boolean): number[];
179
205
  /**
180
206
  * Where the glyphs of `text` put ink inside a horizontal band, as x-intervals in POINTS measured
181
207
  * from the start of the run. This is what an underline steps around ("skip-ink").
@@ -185,7 +211,7 @@ export declare class PDFObjectManager implements FontMetrics {
185
211
  * Callers must check `isCustomFont` first rather than treat an empty result as "no descenders".
186
212
  */
187
213
  getInkSpansInBand(text: string, fontSize: number, fontFamily: string, fontStyle: FontStyle, bandTop: number, bandBottom: number, letterSpacing?: number): Array<[number, number]>;
188
- getStringWidth(text: string, fontFamily: string, fontSize: number, fontStyle: FontStyle): number;
214
+ getStringWidth(text: string, fontFamily: string, fontSize: number, fontStyle: FontStyle, ligatures?: boolean): number;
189
215
  private getAVMParserByFont;
190
216
  getCharWidth(char: string, fontSize: number, fullFontName?: string, fontName?: string, fontStyle?: FontStyle): number;
191
217
  getAllFontsRaw(): Map<string, FontIndexes>;
@@ -5,9 +5,26 @@ import { zlibSync } from "fflate";
5
5
  import { bytesFromLatin1, latin1FromBytes } from "./bytes.js";
6
6
  import { AFMParser } from "./afm-parser.js";
7
7
  import { mergeSpans, TTFParser } from "./ttf-parser.js";
8
- import { isWoff, woffToSfnt } from "./woff.js";
8
+ import { WoffError, isWoff, woffToSfnt } from "./woff.js";
9
+ import { isWoff2 } from "./woff2.js";
9
10
  import { subsetTTF } from "./ttf-subsetter.js";
10
11
  import { shapeRun } from "../text/shape.js";
12
+ /** What Latin text is shaped with unless a run says otherwise: the ligatures a font's designer
13
+ * drew for it. On by default like kerning - CSS, browsers and every other renderer do the same,
14
+ * and nobody should have to know the word to get text that is set properly. */
15
+ const LATIN_FEATURES = ["liga"];
16
+ /** Stable, so `features(false)` does not allocate per call. */
17
+ const NO_FEATURES = [];
18
+ /** A feature set's cache key. Memoised only for the two arrays this module owns - a caller's array is
19
+ * joined every time, since holding on to its key would outlive a mutation of it. */
20
+ const OWN_FEATURE_KEYS = new Map([
21
+ [LATIN_FEATURES, LATIN_FEATURES.join(",")],
22
+ [NO_FEATURES, ""],
23
+ ]);
24
+ export function featureKey(f) {
25
+ return OWN_FEATURE_KEYS.get(f) ?? f.join(",");
26
+ }
27
+ const features = (ligatures) => ligatures ? LATIN_FEATURES : NO_FEATURES;
11
28
  import { getArrayBuffer, isWindows1252 } from "./utf8-to-windows1252-encoder.js";
12
29
  import { isEmojiCodePoint } from "../text/emoji-codepoints.js";
13
30
  import { StructTree } from "./struct-tree.js";
@@ -164,7 +181,12 @@ export class PDFObjectManager {
164
181
  // program is subsetted and the objects filled in by finalizeCustomFonts(), after the render pass.
165
182
  this.customFontEmit = new Map();
166
183
  // Shaped runs, keyed by font + text; `null` means "asked, and there was nothing to shape".
184
+ /** Shaped runs, cached face -> feature set -> text. Nested, not one map under a composed key: that
185
+ * key would carry the whole run's text, so every lookup would copy it into a fresh string. */
167
186
  this.shapeCache = new Map();
187
+ /** Bumped by every font registration; `runAdvance` watches it to drop memoised widths, since a
188
+ * family name can mean a different face afterwards. */
189
+ this.fontEpoch = 0;
168
190
  // Per font, glyph -> the code points it stands for. A shaped glyph is often absent from the cmap (a
169
191
  // ligature) or maps back to a presentation form, so `ToUnicode` cannot come from the cmap alone.
170
192
  this.shapedOrigins = new Map();
@@ -189,6 +211,15 @@ export class PDFObjectManager {
189
211
  // Interactive form fields (AcroForm). Empty unless a form field was placed; then finalize() emits the
190
212
  // catalog /AcroForm dict and each field's widget lands in its page /Annots.
191
213
  this._acroform = new AcroFormCollector();
214
+ // Overflow policy for unbreakable content taller than a page region (default "error"); the renderer
215
+ // seeds it into the layout-context root so packChildren can act on it.
216
+ /**
217
+ * Code points no font in the document could draw, so they were removed rather than emitted as
218
+ * `.notdef` (which PDF/A forbids). Collected here because this object is the one thing threaded
219
+ * through BOTH passes; the caller gets them back with the bytes, so an application can say which
220
+ * character went missing instead of a server log nobody reads.
221
+ */
222
+ this.missingGlyphs = new Set();
192
223
  // Whether to kern. Document-level: measuring (via `runAdvance`, which reads this off the metrics)
193
224
  // and drawing (the backend emits a `TJ`) both consult it, so they can never disagree. OFF while the
194
225
  // embedded-font path (kern/GPOS) is unbuilt - a standard-14-only kerning would set the 14 system
@@ -216,8 +247,14 @@ export class PDFObjectManager {
216
247
  setCompress(on) {
217
248
  this.compress = on;
218
249
  }
219
- // Overflow policy for unbreakable content taller than a page region (default "error"); the renderer
220
- // seeds it into the layout-context root so packChildren can act on it.
250
+ reportMissingGlyph(codePoints) {
251
+ for (const cp of codePoints)
252
+ this.missingGlyphs.add(cp);
253
+ }
254
+ /** The dropped code points, in the order first seen. Empty for a document that hit none. */
255
+ getMissingGlyphs() {
256
+ return [...this.missingGlyphs];
257
+ }
221
258
  setOverflowPolicy(policy) {
222
259
  this.overflowPolicy = policy;
223
260
  }
@@ -528,6 +565,7 @@ endstream`;
528
565
  }
529
566
  // Registers a font
530
567
  registerFont(fontName, fontStyle = FontStyle.Normal, fullName = fontName) {
568
+ this.fontEpoch++;
531
569
  if (this.fonts.hasFont(fontName, fontStyle)) {
532
570
  return this.fonts.getFont(fontName, fontStyle); // Already exists? Return it!
533
571
  }
@@ -558,6 +596,7 @@ endstream`;
558
596
  // metrics and emits its PDF font objects. All variants share the family `name`; bold/italic are
559
597
  // separate .ttf files registered under the same name with a different style.
560
598
  registerCustomFont(name, data, style = FontStyle.Normal) {
599
+ this.fontEpoch++;
561
600
  let byStyle = this.customFonts.get(name);
562
601
  if (!byStyle) {
563
602
  byStyle = new Map();
@@ -568,6 +607,14 @@ endstream`;
568
607
  // A WOFF is the same sfnt behind a small wrapper, so it is unpacked HERE - the one place every
569
608
  // font path meets (a file, raw bytes, a URL, a browser upload) - and TTFParser never learns a
570
609
  // second container format.
610
+ // WOFF2 cannot be unwrapped here: its Brotli decoder is imported lazily, which makes it async,
611
+ // and this is a synchronous constructor path. `renderPdf` unwraps it one step earlier - so the
612
+ // only way to arrive here still packed is by calling the engine directly.
613
+ if (isWoff2(data)) {
614
+ throw new WoffError("a WOFF2 font reached the object manager still packed. Pass it through " +
615
+ "renderToBytes(doc, { fonts }) or doc.addFont(...) - unpacking WOFF2 needs Brotli, which " +
616
+ "is loaded asynchronously, so it happens one step earlier in renderPdf.");
617
+ }
571
618
  byStyle.set(style, new TTFParser(isWoff(data) ? woffToSfnt(data) : data));
572
619
  this.verticalsCache.delete(name); // this family now answers from the .ttf, not from an AFM
573
620
  this.decorationCache.delete(name);
@@ -694,6 +741,23 @@ endstream`;
694
741
  return ttf.getGlyphIndex(codePoint) !== 0;
695
742
  return isWindows1252(codePoint);
696
743
  }
744
+ /**
745
+ * Whether the document draws this code point through the colour-emoji path instead of the text
746
+ * font - a fallback emoji FONT, or an image source (`Document({ emoji })`). Such a code point is
747
+ * missing from the text font ON PURPOSE, so glyph coverage must leave it alone. Mirrors exactly
748
+ * what `getCharWidth` measures it as, or a dropped emoji would have been measured and not drawn.
749
+ */
750
+ rendersAsEmoji(codePoint, fontFamily, fontStyle) {
751
+ const ttf = this.getCustomFont(fontFamily, fontStyle);
752
+ if (ttf && this.colorRenders(ttf, codePoint))
753
+ return true;
754
+ if (this.emojiFontName && this.emojiFontName !== fontFamily) {
755
+ const emoji = this.getColorFont(this.emojiFontName, fontStyle);
756
+ if (emoji && this.colorRenders(emoji, codePoint))
757
+ return true;
758
+ }
759
+ return this.emojiImage !== undefined && isEmojiCodePoint(codePoint);
760
+ }
697
761
  colorRenders(ttf, codePoint) {
698
762
  return ttf.getColorGlyph(ttf.getGlyphIndex(codePoint)) !== null;
699
763
  }
@@ -708,25 +772,30 @@ endstream`;
708
772
  }
709
773
  /** How many glyphs a run draws - its code-point count unless a ligature merged some. `letterSpacing`
710
774
  * is per glyph (`Tc`), so this is what it multiplies. */
711
- shapedGlyphCount(text, fontFamily, fontStyle) {
712
- return this.shapeText(text, fontFamily, fontStyle)?.length;
775
+ shapedGlyphCount(text, fontFamily, fontStyle, ligatures = true) {
776
+ return this.shapeText(text, fontFamily, fontStyle, features(ligatures))?.length;
713
777
  }
714
778
  /**
715
779
  * The shaped glyphs of a run, or undefined when nothing needed shaping. The ONE place shaping is
716
780
  * decided, so measuring and drawing cannot disagree. Memoised: a paragraph is measured several
717
781
  * times (layout, pagination, drawing).
718
782
  */
719
- shapeText(text, fontFamily, fontStyle = FontStyle.Normal) {
783
+ shapeText(text, fontFamily, fontStyle = FontStyle.Normal, features = LATIN_FEATURES) {
720
784
  const resolved = this.resolveCustomStyle(fontFamily, fontStyle);
721
785
  if (!resolved)
722
786
  return undefined;
723
- const key = `${this.customKey(fontFamily, resolved)}\u0000${text}`;
724
- const cached = this.shapeCache.get(key);
787
+ const ttf = this.customFonts.get(fontFamily).get(resolved);
788
+ let byFeature = this.shapeCache.get(ttf);
789
+ if (!byFeature)
790
+ this.shapeCache.set(ttf, (byFeature = new Map()));
791
+ let byText = byFeature.get(featureKey(features));
792
+ if (!byText)
793
+ byFeature.set(featureKey(features), (byText = new Map()));
794
+ const cached = byText.get(text);
725
795
  if (cached !== undefined)
726
796
  return cached ?? undefined;
727
- const ttf = this.customFonts.get(fontFamily).get(resolved);
728
- const shaped = shapeRun([...text].map((c) => c.codePointAt(0)), ttf) ?? null;
729
- this.shapeCache.set(key, shaped);
797
+ const shaped = shapeRun([...text].map((c) => c.codePointAt(0)), ttf, features) ?? null;
798
+ byText.set(text, shaped);
730
799
  if (shaped) {
731
800
  // Recorded at shaping time - the only moment the glyph and its code points are known together.
732
801
  const fontKey = this.customKey(fontFamily, resolved);
@@ -755,7 +824,7 @@ endstream`;
755
824
  }
756
825
  // Encodes text as a hex Identity-H string for an embedded font's Tj operator: each codepoint
757
826
  // becomes its 2-byte glyph id (CID == GID under /CIDToGIDMap /Identity).
758
- encodeCustomText(name, text, style = FontStyle.Normal) {
827
+ encodeCustomText(name, text, style = FontStyle.Normal, shape = true) {
759
828
  const resolved = this.resolveCustomStyle(name, style);
760
829
  if (!resolved)
761
830
  return "";
@@ -765,7 +834,9 @@ endstream`;
765
834
  const { ttf, used } = emit;
766
835
  const hex4 = (gid) => gid.toString(16).padStart(4, "0").toUpperCase();
767
836
  // The SAME shaped run the measuring path used, so the glyphs drawn are the glyphs measured.
768
- const shaped = this.shapeText(text, name, style);
837
+ // `shape: false` is for a caller that has ALREADY decided the run is unshaped - the renderer puts
838
+ // shaped glyphs in the IR node, so the backend's text path is the unshaped one by definition.
839
+ const shaped = shape ? this.shapeText(text, name, style) : undefined;
769
840
  if (shaped) {
770
841
  let out = "";
771
842
  for (const g of shaped) {
@@ -900,15 +971,30 @@ endstream`;
900
971
  * Standard-14 answers from the AFM `KPX` pairs. An embedded font has no kerning yet (its `kern` /
901
972
  * `GPOS` tables are unparsed), so it returns zeros - never a wrong value, just no adjustment.
902
973
  */
903
- getKernPairs(text, fontFamily, fontStyle) {
974
+ /** Kerning between adjacent GLYPHS - what a shaped run needs, and what the backend already holds.
975
+ * Same lookup as the character path; `getKerning` is keyed by glyph id either way. */
976
+ getGlyphKernPairs(glyphs, fontFamily, fontStyle) {
977
+ const ttf = this.getCustomFont(fontFamily, fontStyle);
978
+ if (!ttf)
979
+ return [];
980
+ const pairs = [];
981
+ for (let i = 0; i < glyphs.length - 1; i++) {
982
+ pairs.push(ttf.getKerning(glyphs[i], glyphs[i + 1]));
983
+ }
984
+ return pairs;
985
+ }
986
+ getKernPairs(text, fontFamily, fontStyle, ligatures = true) {
904
987
  const chars = [...text];
905
988
  if (chars.length < 2)
906
989
  return [];
907
- // A shaped run does not kern here: these pairs are keyed by the UNSHAPED glyphs, and the `TJ`
908
- // path would re-shape each chunk on its own, turning every letter isolated. Arabic kerning is in
909
- // GPOS, applied during shaping - not built (`todo.md`).
910
- if (this.shapeText(text, fontFamily, fontStyle))
911
- return [];
990
+ // A shaped run kerns over its SHAPED glyphs, not its characters: after shaping there may be fewer
991
+ // glyphs than code points (a ligature), and the pairs that matter are the ones actually drawn.
992
+ // `getKerning` is keyed by glyph id anyway, so this is the same lookup - and the backend now emits
993
+ // a `TJ` over glyph chunks, which is what used to be missing. One adjustment per adjacent DRAWN
994
+ // pair, either path, so `runAdvance` measures exactly what is drawn.
995
+ const shaped = this.shapeText(text, fontFamily, fontStyle, features(ligatures));
996
+ if (shaped)
997
+ return this.getGlyphKernPairs(shaped.map((g) => g.glyph), fontFamily, fontStyle);
912
998
  const out = [];
913
999
  // An embedded font kerns by GLYPH id (kern table / GPOS); a standard-14 one by character (AFM).
914
1000
  const ttf = this.getCustomFont(fontFamily, fontStyle);
@@ -959,10 +1045,10 @@ endstream`;
959
1045
  return mergeSpans(spans);
960
1046
  }
961
1047
  // Returns the current width of a text, included kernings
962
- getStringWidth(text, fontFamily, fontSize, fontStyle) {
1048
+ getStringWidth(text, fontFamily, fontSize, fontStyle, ligatures = true) {
963
1049
  // Shaping first: a joined run is narrower than the same letters apart, so measuring the unshaped
964
1050
  // form while drawing the shaped one would break every line. Both sides ask `shapeText`.
965
- const shaped = this.shapeText(text, fontFamily, fontStyle);
1051
+ const shaped = this.shapeText(text, fontFamily, fontStyle, features(ligatures));
966
1052
  if (shaped) {
967
1053
  const ttf = this.getCustomFont(fontFamily, fontStyle);
968
1054
  return shaped.reduce((w, g) => w + ttf.unitsToPoints(g.advance, fontSize), 0);
@@ -1,6 +1,18 @@
1
+ /**
2
+ * Thrown when an element reaches the render pass with no registered renderer.
3
+ *
4
+ * This used to return undefined and every call site skipped the element silently, so a whole document
5
+ * could serialize to an empty content stream and still be a valid PDF - a blank page, no warning
6
+ * (ISSUE-11). The registry is keyed on the CONSTRUCTOR, which makes it the one place in the engine that
7
+ * two loaded copies of the library break: elements built by copy A are invisible to copy B.
8
+ */
9
+ export declare class MissingRendererError extends Error {
10
+ readonly elementName: string;
11
+ constructor(elementName: string);
12
+ }
1
13
  export declare class RendererRegistry {
2
14
  private static renderers;
3
15
  static register(elementClass: Function, renderer: Function): void;
4
- static getRenderer(element: object): Function | undefined;
16
+ static getRenderer(element: object): Function;
5
17
  static isRendererAsync(renderer: Function): boolean;
6
18
  }
@@ -1,3 +1,20 @@
1
+ /**
2
+ * Thrown when an element reaches the render pass with no registered renderer.
3
+ *
4
+ * This used to return undefined and every call site skipped the element silently, so a whole document
5
+ * could serialize to an empty content stream and still be a valid PDF - a blank page, no warning
6
+ * (ISSUE-11). The registry is keyed on the CONSTRUCTOR, which makes it the one place in the engine that
7
+ * two loaded copies of the library break: elements built by copy A are invisible to copy B.
8
+ */
9
+ export class MissingRendererError extends Error {
10
+ constructor(elementName) {
11
+ super(`@jasy/pdf: no renderer registered for "${elementName}". Either the element was never registered ` +
12
+ `in PDFRenderer.render(), or two copies of @jasy/pdf are loaded - the registry is keyed on the ` +
13
+ `element's constructor, so elements built by one copy cannot be rendered by the other. ` +
14
+ `Check the dependency tree for a duplicate (e.g. \`pnpm why @jasy/pdf\`).`);
15
+ this.elementName = elementName;
16
+ }
17
+ }
1
18
  export class RendererRegistry {
2
19
  static register(elementClass, renderer) {
3
20
  if (!RendererRegistry.renderers.has(elementClass)) {
@@ -5,8 +22,12 @@ export class RendererRegistry {
5
22
  }
6
23
  }
7
24
  // Keyed on the element's constructor, so it only needs an object - not `any`.
25
+ // Throws rather than returning undefined: a missing renderer is always a bug, and skipping it drew nothing.
8
26
  static getRenderer(element) {
9
- return RendererRegistry.renderers.get(element.constructor);
27
+ const renderer = RendererRegistry.renderers.get(element.constructor);
28
+ if (!renderer)
29
+ throw new MissingRendererError(element.constructor.name);
30
+ return renderer;
10
31
  }
11
32
  static isRendererAsync(renderer) {
12
33
  return renderer.constructor.name === "AsyncFunction";
@@ -39,9 +39,11 @@ const CP1252_FROM_UNICODE = {
39
39
  * except the C1 range, which the table above fills with printable punctuation instead.
40
40
  */
41
41
  export function isWindows1252(codePoint) {
42
- if (codePoint in CP1252_FROM_UNICODE)
43
- return true;
44
- return codePoint <= 0xff && !(codePoint >= 0x80 && codePoint <= 0x9f);
42
+ // Latin-1 first: every key in the table above is > 0xff, so this range can never be in it. Order
43
+ // matters - this runs per character of every measured string, and the object lookup is the slow half.
44
+ if (codePoint <= 0xff)
45
+ return codePoint < 0x80 || codePoint > 0x9f;
46
+ return codePoint in CP1252_FROM_UNICODE;
45
47
  }
46
48
  /** Encodes a JavaScript string to a Windows-1252 byte buffer (the PDF text encoding). */
47
49
  export function getArrayBuffer(data) {
@@ -0,0 +1,6 @@
1
+ export declare const isWoff2: (bytes: Uint8Array) => boolean;
2
+ /**
3
+ * The whole container: header, directory, one Brotli stream holding every table back to back, then
4
+ * `glyf`/`loca` put back together and the sfnt reassembled around them.
5
+ */
6
+ export declare function woff2ToSfnt(woff2: Uint8Array): Promise<Uint8Array>;