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

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 (110) 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 +4 -3
  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/flex-layout.js +67 -18
  101. package/dist/utils/font-metrics.d.ts +9 -3
  102. package/dist/utils/pdf-object-manager.d.ts +25 -5
  103. package/dist/utils/pdf-object-manager.js +82 -18
  104. package/dist/utils/renderer-registry.d.ts +13 -1
  105. package/dist/utils/renderer-registry.js +22 -1
  106. package/dist/utils/woff2.d.ts +6 -0
  107. package/dist/utils/woff2.js +651 -0
  108. package/dist/vector/path.d.ts +33 -0
  109. package/dist/vector/path.js +81 -0
  110. 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`). */
@@ -22,10 +22,16 @@ export interface FontMetrics {
22
22
  readonly kerningEnabled: boolean;
23
23
  /** Per-adjacent-pair kerning of `text`, in em/1000 (negative tightens); length `codePoints - 1`,
24
24
  * zero next to a space. Only meaningful when `kerningEnabled`. */
25
- getKernPairs(text: string, fontFamily: string, fontStyle: FontStyle): number[];
25
+ getKernPairs(text: string, fontFamily: string, fontStyle: FontStyle, ligatures?: boolean): number[];
26
26
  /** Whether this family can draw the code point at all - what picks a face out of a fallback stack. */
27
27
  hasGlyph(codePoint: number, fontFamily: string, fontStyle: FontStyle): boolean;
28
+ /** Whether the document draws this code point as colour emoji rather than from the text font - it
29
+ * is missing from that font on purpose, so glyph coverage must not touch it. */
30
+ rendersAsEmoji?(codePoint: number, fontFamily: string, fontStyle: FontStyle): boolean;
31
+ /** Told when a code point had to be removed because no font could draw it. Optional: a metrics
32
+ * object used only for measuring (a test double) has nothing to report to. */
33
+ reportMissingGlyph?(codePoints: number[]): void;
28
34
  /** How many glyphs the run will DRAW, when a ligature made that differ from its code-point count.
29
35
  * Absent or `undefined` means "the same", which is every Latin run. */
30
- shapedGlyphCount?(text: string, fontFamily?: string, fontStyle?: FontStyle): number | undefined;
36
+ shapedGlyphCount?(text: string, fontFamily?: string, fontStyle?: FontStyle, ligatures?: boolean): number | undefined;
31
37
  }
@@ -65,6 +65,16 @@ export declare class PDFObjectManager implements FontMetrics {
65
65
  addObject(content: string): number;
66
66
  replaceObject(objectNumber: number, content: string): void;
67
67
  setCompress(on: boolean): void;
68
+ /**
69
+ * Code points no font in the document could draw, so they were removed rather than emitted as
70
+ * `.notdef` (which PDF/A forbids). Collected here because this object is the one thing threaded
71
+ * through BOTH passes; the caller gets them back with the bytes, so an application can say which
72
+ * character went missing instead of a server log nobody reads.
73
+ */
74
+ private readonly missingGlyphs;
75
+ reportMissingGlyph(codePoints: number[]): void;
76
+ /** The dropped code points, in the order first seen. Empty for a document that hit none. */
77
+ getMissingGlyphs(): number[];
68
78
  setOverflowPolicy(policy: OverflowPolicy): void;
69
79
  getOverflowPolicy(): OverflowPolicy;
70
80
  private encToken;
@@ -142,21 +152,28 @@ export declare class PDFObjectManager implements FontMetrics {
142
152
  * Used to pick a family out of a fallback stack, per code point.
143
153
  */
144
154
  hasGlyph(codePoint: number, fontFamily: string, fontStyle?: FontStyle): boolean;
155
+ /**
156
+ * Whether the document draws this code point through the colour-emoji path instead of the text
157
+ * font - a fallback emoji FONT, or an image source (`Document({ emoji })`). Such a code point is
158
+ * missing from the text font ON PURPOSE, so glyph coverage must leave it alone. Mirrors exactly
159
+ * what `getCharWidth` measures it as, or a dropped emoji would have been measured and not drawn.
160
+ */
161
+ rendersAsEmoji(codePoint: number, fontFamily: string, fontStyle: FontStyle): boolean;
145
162
  private colorRenders;
146
163
  getCustomFontResource(name: string, style?: FontStyle): FontIndexes | undefined;
147
164
  /** How many glyphs a run draws - its code-point count unless a ligature merged some. `letterSpacing`
148
165
  * is per glyph (`Tc`), so this is what it multiplies. */
149
- shapedGlyphCount(text: string, fontFamily?: string, fontStyle?: FontStyle): number | undefined;
166
+ shapedGlyphCount(text: string, fontFamily?: string, fontStyle?: FontStyle, ligatures?: boolean): number | undefined;
150
167
  /**
151
168
  * The shaped glyphs of a run, or undefined when nothing needed shaping. The ONE place shaping is
152
169
  * decided, so measuring and drawing cannot disagree. Memoised: a paragraph is measured several
153
170
  * times (layout, pagination, drawing).
154
171
  */
155
- shapeText(text: string, fontFamily?: string, fontStyle?: FontStyle): ShapedGlyph[] | undefined;
172
+ shapeText(text: string, fontFamily?: string, fontStyle?: FontStyle, features?: readonly string[]): ShapedGlyph[] | undefined;
156
173
  /** Hex Identity-H for glyphs the producer already resolved (a shaped run), which cannot be given as
157
174
  * text. Recording each keeps it in the subset. */
158
175
  registerGlyphs(name: string, glyphs: readonly number[], style?: FontStyle): string;
159
- encodeCustomText(name: string, text: string, style?: FontStyle): string;
176
+ encodeCustomText(name: string, text: string, style?: FontStyle, shape?: boolean): string;
160
177
  private buildFontFile2;
161
178
  private buildFontDescriptor;
162
179
  private buildCIDFont;
@@ -175,7 +192,10 @@ export declare class PDFObjectManager implements FontMetrics {
175
192
  * Standard-14 answers from the AFM `KPX` pairs. An embedded font has no kerning yet (its `kern` /
176
193
  * `GPOS` tables are unparsed), so it returns zeros - never a wrong value, just no adjustment.
177
194
  */
178
- getKernPairs(text: string, fontFamily: string, fontStyle: FontStyle): number[];
195
+ /** Kerning between adjacent GLYPHS - what a shaped run needs, and what the backend already holds.
196
+ * Same lookup as the character path; `getKerning` is keyed by glyph id either way. */
197
+ getGlyphKernPairs(glyphs: readonly number[], fontFamily: string, fontStyle: FontStyle): number[];
198
+ getKernPairs(text: string, fontFamily: string, fontStyle: FontStyle, ligatures?: boolean): number[];
179
199
  /**
180
200
  * Where the glyphs of `text` put ink inside a horizontal band, as x-intervals in POINTS measured
181
201
  * from the start of the run. This is what an underline steps around ("skip-ink").
@@ -185,7 +205,7 @@ export declare class PDFObjectManager implements FontMetrics {
185
205
  * Callers must check `isCustomFont` first rather than treat an empty result as "no descenders".
186
206
  */
187
207
  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;
208
+ getStringWidth(text: string, fontFamily: string, fontSize: number, fontStyle: FontStyle, ligatures?: boolean): number;
189
209
  private getAVMParserByFont;
190
210
  getCharWidth(char: string, fontSize: number, fullFontName?: string, fontName?: string, fontStyle?: FontStyle): number;
191
211
  getAllFontsRaw(): Map<string, FontIndexes>;
@@ -5,9 +5,16 @@ 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
+ /** What a run is shaped with. Arabic joining is unconditional; this is only the Latin part. */
17
+ const features = (ligatures) => (ligatures ? LATIN_FEATURES : []);
11
18
  import { getArrayBuffer, isWindows1252 } from "./utf8-to-windows1252-encoder.js";
12
19
  import { isEmojiCodePoint } from "../text/emoji-codepoints.js";
13
20
  import { StructTree } from "./struct-tree.js";
@@ -189,6 +196,15 @@ export class PDFObjectManager {
189
196
  // Interactive form fields (AcroForm). Empty unless a form field was placed; then finalize() emits the
190
197
  // catalog /AcroForm dict and each field's widget lands in its page /Annots.
191
198
  this._acroform = new AcroFormCollector();
199
+ // Overflow policy for unbreakable content taller than a page region (default "error"); the renderer
200
+ // seeds it into the layout-context root so packChildren can act on it.
201
+ /**
202
+ * Code points no font in the document could draw, so they were removed rather than emitted as
203
+ * `.notdef` (which PDF/A forbids). Collected here because this object is the one thing threaded
204
+ * through BOTH passes; the caller gets them back with the bytes, so an application can say which
205
+ * character went missing instead of a server log nobody reads.
206
+ */
207
+ this.missingGlyphs = new Set();
192
208
  // Whether to kern. Document-level: measuring (via `runAdvance`, which reads this off the metrics)
193
209
  // and drawing (the backend emits a `TJ`) both consult it, so they can never disagree. OFF while the
194
210
  // embedded-font path (kern/GPOS) is unbuilt - a standard-14-only kerning would set the 14 system
@@ -216,8 +232,14 @@ export class PDFObjectManager {
216
232
  setCompress(on) {
217
233
  this.compress = on;
218
234
  }
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.
235
+ reportMissingGlyph(codePoints) {
236
+ for (const cp of codePoints)
237
+ this.missingGlyphs.add(cp);
238
+ }
239
+ /** The dropped code points, in the order first seen. Empty for a document that hit none. */
240
+ getMissingGlyphs() {
241
+ return [...this.missingGlyphs];
242
+ }
221
243
  setOverflowPolicy(policy) {
222
244
  this.overflowPolicy = policy;
223
245
  }
@@ -568,6 +590,14 @@ endstream`;
568
590
  // A WOFF is the same sfnt behind a small wrapper, so it is unpacked HERE - the one place every
569
591
  // font path meets (a file, raw bytes, a URL, a browser upload) - and TTFParser never learns a
570
592
  // second container format.
593
+ // WOFF2 cannot be unwrapped here: its Brotli decoder is imported lazily, which makes it async,
594
+ // and this is a synchronous constructor path. `renderPdf` unwraps it one step earlier - so the
595
+ // only way to arrive here still packed is by calling the engine directly.
596
+ if (isWoff2(data)) {
597
+ throw new WoffError("a WOFF2 font reached the object manager still packed. Pass it through " +
598
+ "renderToBytes(doc, { fonts }) or doc.addFont(...) - unpacking WOFF2 needs Brotli, which " +
599
+ "is loaded asynchronously, so it happens one step earlier in renderPdf.");
600
+ }
571
601
  byStyle.set(style, new TTFParser(isWoff(data) ? woffToSfnt(data) : data));
572
602
  this.verticalsCache.delete(name); // this family now answers from the .ttf, not from an AFM
573
603
  this.decorationCache.delete(name);
@@ -694,6 +724,23 @@ endstream`;
694
724
  return ttf.getGlyphIndex(codePoint) !== 0;
695
725
  return isWindows1252(codePoint);
696
726
  }
727
+ /**
728
+ * Whether the document draws this code point through the colour-emoji path instead of the text
729
+ * font - a fallback emoji FONT, or an image source (`Document({ emoji })`). Such a code point is
730
+ * missing from the text font ON PURPOSE, so glyph coverage must leave it alone. Mirrors exactly
731
+ * what `getCharWidth` measures it as, or a dropped emoji would have been measured and not drawn.
732
+ */
733
+ rendersAsEmoji(codePoint, fontFamily, fontStyle) {
734
+ const ttf = this.getCustomFont(fontFamily, fontStyle);
735
+ if (ttf && this.colorRenders(ttf, codePoint))
736
+ return true;
737
+ if (this.emojiFontName && this.emojiFontName !== fontFamily) {
738
+ const emoji = this.getColorFont(this.emojiFontName, fontStyle);
739
+ if (emoji && this.colorRenders(emoji, codePoint))
740
+ return true;
741
+ }
742
+ return this.emojiImage !== undefined && isEmojiCodePoint(codePoint);
743
+ }
697
744
  colorRenders(ttf, codePoint) {
698
745
  return ttf.getColorGlyph(ttf.getGlyphIndex(codePoint)) !== null;
699
746
  }
@@ -708,24 +755,24 @@ endstream`;
708
755
  }
709
756
  /** How many glyphs a run draws - its code-point count unless a ligature merged some. `letterSpacing`
710
757
  * is per glyph (`Tc`), so this is what it multiplies. */
711
- shapedGlyphCount(text, fontFamily, fontStyle) {
712
- return this.shapeText(text, fontFamily, fontStyle)?.length;
758
+ shapedGlyphCount(text, fontFamily, fontStyle, ligatures = true) {
759
+ return this.shapeText(text, fontFamily, fontStyle, features(ligatures))?.length;
713
760
  }
714
761
  /**
715
762
  * The shaped glyphs of a run, or undefined when nothing needed shaping. The ONE place shaping is
716
763
  * decided, so measuring and drawing cannot disagree. Memoised: a paragraph is measured several
717
764
  * times (layout, pagination, drawing).
718
765
  */
719
- shapeText(text, fontFamily, fontStyle = FontStyle.Normal) {
766
+ shapeText(text, fontFamily, fontStyle = FontStyle.Normal, features = LATIN_FEATURES) {
720
767
  const resolved = this.resolveCustomStyle(fontFamily, fontStyle);
721
768
  if (!resolved)
722
769
  return undefined;
723
- const key = `${this.customKey(fontFamily, resolved)}\u0000${text}`;
770
+ const key = `${this.customKey(fontFamily, resolved)}\u0000${features.join(",")}\u0000${text}`;
724
771
  const cached = this.shapeCache.get(key);
725
772
  if (cached !== undefined)
726
773
  return cached ?? undefined;
727
774
  const ttf = this.customFonts.get(fontFamily).get(resolved);
728
- const shaped = shapeRun([...text].map((c) => c.codePointAt(0)), ttf) ?? null;
775
+ const shaped = shapeRun([...text].map((c) => c.codePointAt(0)), ttf, features) ?? null;
729
776
  this.shapeCache.set(key, shaped);
730
777
  if (shaped) {
731
778
  // Recorded at shaping time - the only moment the glyph and its code points are known together.
@@ -755,7 +802,7 @@ endstream`;
755
802
  }
756
803
  // Encodes text as a hex Identity-H string for an embedded font's Tj operator: each codepoint
757
804
  // becomes its 2-byte glyph id (CID == GID under /CIDToGIDMap /Identity).
758
- encodeCustomText(name, text, style = FontStyle.Normal) {
805
+ encodeCustomText(name, text, style = FontStyle.Normal, shape = true) {
759
806
  const resolved = this.resolveCustomStyle(name, style);
760
807
  if (!resolved)
761
808
  return "";
@@ -765,7 +812,9 @@ endstream`;
765
812
  const { ttf, used } = emit;
766
813
  const hex4 = (gid) => gid.toString(16).padStart(4, "0").toUpperCase();
767
814
  // The SAME shaped run the measuring path used, so the glyphs drawn are the glyphs measured.
768
- const shaped = this.shapeText(text, name, style);
815
+ // `shape: false` is for a caller that has ALREADY decided the run is unshaped - the renderer puts
816
+ // shaped glyphs in the IR node, so the backend's text path is the unshaped one by definition.
817
+ const shaped = shape ? this.shapeText(text, name, style) : undefined;
769
818
  if (shaped) {
770
819
  let out = "";
771
820
  for (const g of shaped) {
@@ -900,15 +949,30 @@ endstream`;
900
949
  * Standard-14 answers from the AFM `KPX` pairs. An embedded font has no kerning yet (its `kern` /
901
950
  * `GPOS` tables are unparsed), so it returns zeros - never a wrong value, just no adjustment.
902
951
  */
903
- getKernPairs(text, fontFamily, fontStyle) {
952
+ /** Kerning between adjacent GLYPHS - what a shaped run needs, and what the backend already holds.
953
+ * Same lookup as the character path; `getKerning` is keyed by glyph id either way. */
954
+ getGlyphKernPairs(glyphs, fontFamily, fontStyle) {
955
+ const ttf = this.getCustomFont(fontFamily, fontStyle);
956
+ if (!ttf)
957
+ return [];
958
+ const pairs = [];
959
+ for (let i = 0; i < glyphs.length - 1; i++) {
960
+ pairs.push(ttf.getKerning(glyphs[i], glyphs[i + 1]));
961
+ }
962
+ return pairs;
963
+ }
964
+ getKernPairs(text, fontFamily, fontStyle, ligatures = true) {
904
965
  const chars = [...text];
905
966
  if (chars.length < 2)
906
967
  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 [];
968
+ // A shaped run kerns over its SHAPED glyphs, not its characters: after shaping there may be fewer
969
+ // glyphs than code points (a ligature), and the pairs that matter are the ones actually drawn.
970
+ // `getKerning` is keyed by glyph id anyway, so this is the same lookup - and the backend now emits
971
+ // a `TJ` over glyph chunks, which is what used to be missing. One adjustment per adjacent DRAWN
972
+ // pair, either path, so `runAdvance` measures exactly what is drawn.
973
+ const shaped = this.shapeText(text, fontFamily, fontStyle, features(ligatures));
974
+ if (shaped)
975
+ return this.getGlyphKernPairs(shaped.map((g) => g.glyph), fontFamily, fontStyle);
912
976
  const out = [];
913
977
  // An embedded font kerns by GLYPH id (kern table / GPOS); a standard-14 one by character (AFM).
914
978
  const ttf = this.getCustomFont(fontFamily, fontStyle);
@@ -959,10 +1023,10 @@ endstream`;
959
1023
  return mergeSpans(spans);
960
1024
  }
961
1025
  // Returns the current width of a text, included kernings
962
- getStringWidth(text, fontFamily, fontSize, fontStyle) {
1026
+ getStringWidth(text, fontFamily, fontSize, fontStyle, ligatures = true) {
963
1027
  // Shaping first: a joined run is narrower than the same letters apart, so measuring the unshaped
964
1028
  // form while drawing the shaped one would break every line. Both sides ask `shapeText`.
965
- const shaped = this.shapeText(text, fontFamily, fontStyle);
1029
+ const shaped = this.shapeText(text, fontFamily, fontStyle, features(ligatures));
966
1030
  if (shaped) {
967
1031
  const ttf = this.getCustomFont(fontFamily, fontStyle);
968
1032
  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";
@@ -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>;