@jasy/pdf 1.0.0-alpha.10 → 1.0.0-alpha.11

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.
@@ -151,6 +151,13 @@ export interface RenderOptions {
151
151
  /** What to do when content is taller than a page and cannot break: `"error"` throws (default),
152
152
  * `"warn"` logs and clips, `"ignore"` clips silently. It is always clipped either way. */
153
153
  onOverflow?: OverflowPolicy;
154
+ /**
155
+ * Called once, after the render, with the characters no font could draw - they were removed rather
156
+ * than emitted as `.notdef`, which PDF/A forbids. Deliberately a callback and not a policy: a
157
+ * missing tick mark must never stop an invoice from being produced, but the application should be
158
+ * able to SAY which character went missing, where a server log would never be read.
159
+ */
160
+ onMissingGlyphs?: (characters: string[]) => void;
154
161
  /** Encrypt the PDF with a password (AES-256, the newest standard). NOT compatible with PDF/A
155
162
  * (ZUGFeRD invoices) - encrypting one throws, since PDF/A forbids encryption. */
156
163
  encrypt?: EncryptOptions;
@@ -143,10 +143,14 @@ export async function renderPdf(doc, options) {
143
143
  // A throwaway PDFDocument whose build() yields this tree, reusing the engine's standard
144
144
  // font registration + config handling (the constructor does both). Custom fonts are
145
145
  // registered here, before layout/render, so both the metrics and the backend see them.
146
+ // Captured so the caller can be told what had to be dropped: `render()` is static and builds its own
147
+ // instance, so this is the only place the object manager surfaces.
148
+ let manager;
146
149
  const Anon = class extends PDFDocument {
147
150
  constructor() {
148
151
  super(config);
149
152
  const om = this.objectManager;
153
+ manager = om;
150
154
  om.setCompress(options?.compress !== false); // FlateDecode streams by default
151
155
  om.setOverflowPolicy(options?.onOverflow ?? "error");
152
156
  om.setKerning(options?.kerning !== false); // on by default (experimental); `kerning:false` opts out
@@ -207,7 +211,11 @@ export async function renderPdf(doc, options) {
207
211
  return doc;
208
212
  }
209
213
  };
210
- return Anon.render();
214
+ const pdf = await Anon.render();
215
+ const missing = manager?.getMissingGlyphs() ?? [];
216
+ if (missing.length > 0)
217
+ options?.onMissingGlyphs?.(missing.map((cp) => String.fromCodePoint(cp)));
218
+ return pdf;
211
219
  }
212
220
  /** Renders a `Document(...)` tree to PDF bytes (e.g. for a download / save dialog). */
213
221
  export async function renderToBytes(doc, options) {
@@ -125,6 +125,13 @@ export declare class TextElement extends SizedPDFElement implements Fragmentable
125
125
  * same width in most fonts).
126
126
  */
127
127
  private display;
128
+ /**
129
+ * Drops or substitutes whatever the RESOLVED font cannot draw. Runs last, after the fallback stack
130
+ * has had its say, and before anything is measured - a character removed at draw time only would
131
+ * make the measured line and the drawn one disagree. A font that draws everything gets its input
132
+ * back unchanged, so an ordinary document is byte-identical.
133
+ */
134
+ private covered;
128
135
  /** The content with `text-transform` applied, and nothing else. */
129
136
  private recased;
130
137
  private lineOptions;
@@ -10,6 +10,8 @@ import { singleLineWidth, MAX_SPACE_SHRINK, wrapStringIntoLines, breakSegmentsIn
10
10
  import { adjustForOrphansWidows, DEFAULT_ORPHANS, DEFAULT_WIDOWS, } from "../text/orphans-widows.js";
11
11
  import { lineBoxForSegmentLine, lineBoxForString } from "../text/line-metrics.js";
12
12
  import { HorizontalAlignment, SizedPDFElement } from "./pdf-element.js";
13
+ import { normalizeContent } from "../text/whitespace.js";
14
+ import { coverText } from "../text/glyph-coverage.js";
13
15
  /** How far a run sits off the baseline, as a fraction of its font size (up is positive). */
14
16
  export const VERTICAL_TEXT_SHIFT = {
15
17
  baseline: 0,
@@ -46,7 +48,9 @@ export class TextElement extends SizedPDFElement {
46
48
  this.rawTextTransform = textTransform;
47
49
  this.rawWordSpacing = finitePoints(wordSpacing, "wordSpacing");
48
50
  this.rawTextIndent = finitePoints(textIndent, "textIndent");
49
- this.content = content;
51
+ // Control characters have no glyph and would be DRAWN as .notdef boxes; `\n` survives
52
+ // because the breaker treats it as a hard break. See text/whitespace.ts.
53
+ this.content = normalizeContent(content);
50
54
  this.maxLines = maxLines;
51
55
  this.orphans = orphans;
52
56
  this.widows = widows;
@@ -69,8 +73,10 @@ export class TextElement extends SizedPDFElement {
69
73
  // A SPAN may bring its own stack, so the element's being empty is not enough to skip the pass.
70
74
  const anyStack = this.fontFallback.length > 0 ||
71
75
  (typeof cased !== "string" && cased.some((seg) => (seg.fontFallback?.length ?? 0) > 0));
72
- if (!anyStack || !metrics)
76
+ if (!metrics)
73
77
  return cased;
78
+ if (!anyStack)
79
+ return this.covered(cased, metrics);
74
80
  // Font fallback turns the content into spans - one per family - which every later pass already
75
81
  // knows how to handle. Nothing new downstream.
76
82
  const pieces = typeof cased === "string" ? [{ content: cased }] : cased;
@@ -83,7 +89,28 @@ export class TextElement extends SizedPDFElement {
83
89
  for (const run of runs)
84
90
  out.push({ ...seg, content: run.text, fontFamily: run.fontFamily });
85
91
  }
86
- return out;
92
+ return this.covered(out, metrics);
93
+ }
94
+ /**
95
+ * Drops or substitutes whatever the RESOLVED font cannot draw. Runs last, after the fallback stack
96
+ * has had its say, and before anything is measured - a character removed at draw time only would
97
+ * make the measured line and the drawn one disagree. A font that draws everything gets its input
98
+ * back unchanged, so an ordinary document is byte-identical.
99
+ */
100
+ covered(content, metrics) {
101
+ const report = (dropped) => metrics.reportMissingGlyph?.(dropped);
102
+ if (typeof content === "string") {
103
+ const { text, dropped } = coverText(content, this.fontFamily, this.fontStyle, metrics);
104
+ if (dropped.length > 0)
105
+ report(dropped);
106
+ return text;
107
+ }
108
+ return content.map((seg) => {
109
+ const { text, dropped } = coverText(seg.content, seg.fontFamily ?? this.fontFamily, seg.fontStyle ?? this.fontStyle, metrics);
110
+ if (dropped.length > 0)
111
+ report(dropped);
112
+ return text === seg.content ? seg : { ...seg, content: text };
113
+ });
87
114
  }
88
115
  /** The content with `text-transform` applied, and nothing else. */
89
116
  recased() {
@@ -0,0 +1,15 @@
1
+ import type { FontMetrics } from "../utils/font-metrics.js";
2
+ import type { FontStyle } from "../utils/pdf-object-manager.js";
3
+ export interface CoverageResult {
4
+ /** The text as it can actually be drawn. */
5
+ text: string;
6
+ /** Code points that had to go - empty for every document that never hits this. */
7
+ dropped: number[];
8
+ }
9
+ /**
10
+ * Replaces or removes what `fontFamily` cannot draw. Returns the input unchanged (and no allocation
11
+ * beyond the scan) when everything is drawable, which is the overwhelmingly common case.
12
+ */
13
+ export declare function coverText(text: string, fontFamily: string, fontStyle: FontStyle, metrics: FontMetrics): CoverageResult;
14
+ /** `U+2713` - how a dropped code point is named to the caller. */
15
+ export declare function formatCodePoint(cp: number): string;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * A code point the chosen font cannot draw becomes glyph 0 - which IS `.notdef`, and referencing it is
3
+ * forbidden by PDF/A (ISO 19005-3, 6.2.11.8). So an invoice carrying a "✓" in its description is a
4
+ * conforming file right up until someone validates it.
5
+ *
6
+ * The check has to be DYNAMIC, against the font actually resolved: which characters are missing is a
7
+ * property of that font, not of Unicode. `@jasy/e-invoice` embeds Liberation, and its gaps look
8
+ * arbitrary from the outside - "→" draws, "⇒" does not; "₴" draws, "₽" does not.
9
+ *
10
+ * What we do about it, in this order:
11
+ * 1. The font has it -> keep, untouched. This is every ordinary document, unchanged.
12
+ * 2. A plain equivalent MEANS the same and the font has THAT -> substitute.
13
+ * 3. Otherwise -> drop it, and report it, so the application can say which character
14
+ * went missing instead of shipping a box or a broken file.
15
+ */
16
+ /**
17
+ * Substitutes that keep the meaning. Deliberately tiny: only where dropping would CHANGE the text
18
+ * rather than thin it. `E‑Rechnung` with a non-breaking hyphen must not become `ERechnung`.
19
+ *
20
+ * Not a list of "what Word pastes" - most of that (quotes, dashes, the ellipsis) is in WinAnsi and in
21
+ * every normal font, so it never reaches rule 3. Entries earn their place by being measured, not
22
+ * guessed: each one is a code point seen missing from a real embedded font.
23
+ */
24
+ const EQUIVALENTS = {
25
+ 0x2010: "-", // hyphen
26
+ 0x2011: "-", // non-breaking hyphen - the one that turns E‑Rechnung into ERechnung
27
+ 0x2012: "-", // figure dash
28
+ 0x2015: "-", // horizontal bar
29
+ 0x2007: " ", // figure space
30
+ 0x2008: " ", // punctuation space
31
+ 0x2009: " ", // thin space
32
+ 0x200a: " ", // hair space
33
+ 0x202f: " ", // narrow no-break space
34
+ 0x2032: "'", // prime
35
+ 0x2033: '"', // double prime
36
+ 0x02bc: "'", // modifier letter apostrophe
37
+ };
38
+ /**
39
+ * Replaces or removes what `fontFamily` cannot draw. Returns the input unchanged (and no allocation
40
+ * beyond the scan) when everything is drawable, which is the overwhelmingly common case.
41
+ */
42
+ export function coverText(text, fontFamily, fontStyle, metrics) {
43
+ let out = null; // built only once something has to change
44
+ const dropped = [];
45
+ let index = 0;
46
+ for (const char of text) {
47
+ const cp = char.codePointAt(0);
48
+ // A space and a line break are handled by the breaker, never drawn as a glyph.
49
+ const drawable = cp === 0x20 ||
50
+ cp === 0x0a ||
51
+ metrics.hasGlyph(cp, fontFamily, fontStyle) ||
52
+ // Colour emoji is drawn from a fallback font or an image, NOT from the text font - it is missing
53
+ // there by design, and removing it would delete the very thing `Document({ emoji })` exists for.
54
+ metrics.rendersAsEmoji?.(cp, fontFamily, fontStyle) === true;
55
+ if (!drawable) {
56
+ if (out === null)
57
+ out = text.slice(0, index);
58
+ const equivalent = EQUIVALENTS[cp];
59
+ if (equivalent !== undefined &&
60
+ metrics.hasGlyph(equivalent.codePointAt(0), fontFamily, fontStyle)) {
61
+ out += equivalent;
62
+ }
63
+ else {
64
+ dropped.push(cp);
65
+ }
66
+ }
67
+ else if (out !== null) {
68
+ out += char;
69
+ }
70
+ index += char.length;
71
+ }
72
+ return out === null ? { text, dropped } : { text: out, dropped };
73
+ }
74
+ /** `U+2713` - how a dropped code point is named to the caller. */
75
+ export function formatCodePoint(cp) {
76
+ return "U+" + cp.toString(16).toUpperCase().padStart(4, "0");
77
+ }
@@ -31,6 +31,9 @@ const ELLIPSIS = "...";
31
31
  * hypothetical: it is what split a footer sized to its own content.
32
32
  */
33
33
  export function singleLineWidth(text, font, metrics, letterSpacing = 0, wordSpacing = 0) {
34
+ // A hard break makes this several lines; the natural width is the widest of them.
35
+ if (text.includes("\n"))
36
+ return Math.max(...text.split("\n").map((p) => singleLineWidth(p, font, metrics, letterSpacing, wordSpacing)));
34
37
  const space = runAdvance(metrics, " ", font, letterSpacing) + wordSpacing;
35
38
  return text
36
39
  .split(" ")
@@ -45,44 +48,54 @@ export function wrapStringIntoLines(text, fontFamily, fontSize, fontStyle, maxWi
45
48
  let gaps = 0; // spaces on the current line, which is what a justified line can squeeze
46
49
  const lines = [];
47
50
  const font = { fontFamily, fontSize, fontStyle };
48
- const words = text.split(" ");
49
- words.forEach((word) => {
50
- // Word and space advances come from the one shared primitive (`advance.ts`), the same one
51
- // `naturalWidth` uses - so a bounded and an unbounded layout of the same text agree bit for bit.
52
- const wordWidth = runAdvance(metrics, word, font, letterSpacing);
53
- const spaceWidth = runAdvance(metrics, " ", font, letterSpacing) + wordSpacing;
54
- // The FIRST line has the indent taken out of its room; every later one gets the full box.
55
- const room = lines.length === 0 ? maxWidth - indent : maxWidth;
56
- // Break before a word that won't fit - counting the SPACE that would join it, which the old test
57
- // forgot. It went unnoticed on the first line, where the very first word added a space too many
58
- // and cancelled the error out; after a break `currentWidth` is the bare word, so every following
59
- // test was short by one space and the line could overrun its box by that much.
60
- //
61
- // A single word wider than maxWidth still sits on its (empty) line and overflows, rather than
62
- // pushing a phantom empty line before it (which would over-count the height by a line).
63
- const candidate = currentWidth + (spaceWidth + wordWidth);
64
- if (currentLine === "") {
65
- currentLine = word;
66
- currentWidth = wordWidth;
67
- gaps = 0;
68
- // How much a JUSTIFIED line may be squeezed to keep one more word: the spaces already on the
69
- // line plus the one that would join it, each giving up at most `shrink` of its width. Zero for
70
- // every other alignment, where a squeezed line would simply overflow instead.
71
- // ONE expression, used for the test AND for the running total. Adding the same three numbers in
72
- // a different order gives a different last bit, and a line that lands exactly on `maxWidth` is
73
- // then broken by that bit alone - which is how a footer sized to its own text wrapped.
74
- }
75
- else if (candidate - (gaps + 1) * spaceWidth * shrink > room) {
51
+ // A `\n` is a HARD break: wrap each paragraph on its own, then cut. An EMPTY paragraph still has to
52
+ // produce a line, or a deliberate blank line between two blocks silently disappears.
53
+ text.split("\n").forEach((paragraph, paragraphIndex) => {
54
+ if (paragraphIndex > 0) {
76
55
  lines.push(currentLine.trim());
77
- currentLine = word;
78
- currentWidth = wordWidth;
56
+ currentLine = "";
57
+ currentWidth = 0;
79
58
  gaps = 0;
80
59
  }
81
- else {
82
- currentLine += " " + word;
83
- currentWidth = candidate;
84
- gaps += 1;
85
- }
60
+ const words = paragraph.split(" ");
61
+ words.forEach((word) => {
62
+ // Word and space advances come from the one shared primitive (`advance.ts`), the same one
63
+ // `naturalWidth` uses - so a bounded and an unbounded layout of the same text agree bit for bit.
64
+ const wordWidth = runAdvance(metrics, word, font, letterSpacing);
65
+ const spaceWidth = runAdvance(metrics, " ", font, letterSpacing) + wordSpacing;
66
+ // The FIRST line has the indent taken out of its room; every later one gets the full box.
67
+ const room = lines.length === 0 ? maxWidth - indent : maxWidth;
68
+ // Break before a word that won't fit - counting the SPACE that would join it, which the old test
69
+ // forgot. It went unnoticed on the first line, where the very first word added a space too many
70
+ // and cancelled the error out; after a break `currentWidth` is the bare word, so every following
71
+ // test was short by one space and the line could overrun its box by that much.
72
+ //
73
+ // A single word wider than maxWidth still sits on its (empty) line and overflows, rather than
74
+ // pushing a phantom empty line before it (which would over-count the height by a line).
75
+ const candidate = currentWidth + (spaceWidth + wordWidth);
76
+ if (currentLine === "") {
77
+ currentLine = word;
78
+ currentWidth = wordWidth;
79
+ gaps = 0;
80
+ // How much a JUSTIFIED line may be squeezed to keep one more word: the spaces already on the
81
+ // line plus the one that would join it, each giving up at most `shrink` of its width. Zero for
82
+ // every other alignment, where a squeezed line would simply overflow instead.
83
+ // ONE expression, used for the test AND for the running total. Adding the same three numbers in
84
+ // a different order gives a different last bit, and a line that lands exactly on `maxWidth` is
85
+ // then broken by that bit alone - which is how a footer sized to its own text wrapped.
86
+ }
87
+ else if (candidate - (gaps + 1) * spaceWidth * shrink > room) {
88
+ lines.push(currentLine.trim());
89
+ currentLine = word;
90
+ currentWidth = wordWidth;
91
+ gaps = 0;
92
+ }
93
+ else {
94
+ currentLine += " " + word;
95
+ currentWidth = candidate;
96
+ gaps += 1;
97
+ }
98
+ });
86
99
  });
87
100
  if (currentLine)
88
101
  lines.push(currentLine.trim());
@@ -116,41 +129,53 @@ export function breakSegmentsIntoLines(segments, defaults, maxWidth, metrics, ma
116
129
  const letterSpacing = segment.letterSpacing ?? defaults.letterSpacing ?? 0;
117
130
  const font = { fontFamily: family, fontSize: size, fontStyle: style };
118
131
  const spaceWidth = runAdvance(metrics, " ", font, letterSpacing);
119
- const words = segment.content.split(" ");
120
- // Start this segment's piece empty; its content is filled word-by-word below. (Not
121
- // the original content - otherwise a segment whose FIRST word overflows would carry
122
- // its whole text into the line that just closed.)
123
- lineSegments.push({ ...segment, fontFamily: family, content: "" });
124
- combined = "";
125
- words.forEach((word, wordIndex) => {
126
- const wordWidth = runAdvance(metrics, word, font, letterSpacing);
127
- // A space joins this word to what precedes it only INSIDE a segment; segments butt together
128
- // with nothing between them, which is what `span("a") + span("b")` draws.
129
- const joiner = wordIndex > 0 ? spaceWidth : 0;
130
- // ONE expression for the test and for the running total, grouped exactly as `singleLineWidth`
131
- // groups it - see the note there. A width built any other way can be a bit off the one the box
132
- // was sized with, and the text wraps inside a box made to hold it.
133
- const candidate = width + (joiner + wordWidth);
134
- // Same guard as the string path: don't open a phantom empty line for an over-wide first word -
135
- // place it (overflowing) on the current empty line instead.
136
- if (candidate > maxWidth && width > 0) {
132
+ // A `\n` inside a span is a HARD break, exactly as in the string path: close the line where it
133
+ // sits, whatever else was already on it, and start the next one with this segment's style.
134
+ segment.content.split("\n").forEach((paragraph, paragraphIndex) => {
135
+ if (paragraphIndex > 0) {
137
136
  lines.push({ segments: lineSegments, width });
138
- width = wordWidth;
137
+ width = 0;
139
138
  lineSegments = [];
140
- combined = word;
141
- lineSegments.push({ ...segment, content: combined });
142
139
  }
143
- else {
144
- combined += wordIndex === 0 ? word : " " + word;
145
- width = candidate;
146
- if (lineSegments.length === 0) {
147
- lineSegments.push({ ...segment, fontFamily: family, content: combined });
140
+ const words = paragraph.split(" ");
141
+ // Start this segment's piece empty; its content is filled word-by-word below. (Not
142
+ // the original content - otherwise a segment whose FIRST word overflows would carry
143
+ // its whole text into the line that just closed.)
144
+ lineSegments.push({ ...segment, fontFamily: family, content: "" });
145
+ combined = "";
146
+ words.forEach((word, wordIndex) => {
147
+ const wordWidth = runAdvance(metrics, word, font, letterSpacing);
148
+ // A space joins this word to what precedes it only INSIDE a segment; segments butt together
149
+ // with nothing between them, which is what `span("a") + span("b")` draws.
150
+ const joiner = wordIndex > 0 ? spaceWidth : 0;
151
+ // ONE expression for the test and for the running total, grouped exactly as `singleLineWidth`
152
+ // groups it - see the note there. A width built any other way can be a bit off the one the box
153
+ // was sized with, and the text wraps inside a box made to hold it.
154
+ const candidate = width + (joiner + wordWidth);
155
+ // Same guard as the string path: don't open a phantom empty line for an over-wide first word -
156
+ // place it (overflowing) on the current empty line instead.
157
+ if (candidate > maxWidth && width > 0) {
158
+ lines.push({ segments: lineSegments, width });
159
+ width = wordWidth;
160
+ lineSegments = [];
161
+ combined = word;
162
+ lineSegments.push({ ...segment, content: combined });
148
163
  }
149
- lineSegments[lineSegments.length - 1].content = combined;
150
- }
164
+ else {
165
+ combined += wordIndex === 0 ? word : " " + word;
166
+ width = candidate;
167
+ if (lineSegments.length === 0) {
168
+ lineSegments.push({ ...segment, fontFamily: family, content: combined });
169
+ }
170
+ lineSegments[lineSegments.length - 1].content = combined;
171
+ }
172
+ });
151
173
  });
152
174
  });
153
- if (lineSegments.length > 0) {
175
+ // Only if something actually landed on it. The string path ends with `if (currentLine)`, so a text
176
+ // ending in `\n` is ONE line there - the two must not disagree about the same text. An empty
177
+ // paragraph in the MIDDLE still becomes a line; that one is pushed at the break, not here.
178
+ if (lineSegments.some((seg) => seg.content !== "")) {
154
179
  lines.push({ segments: lineSegments, width });
155
180
  }
156
181
  // Open-end by default; cap only when maxLines is set.
@@ -0,0 +1,4 @@
1
+ import type { TextSegment } from "../elements/text-element.js";
2
+ export declare function normalizeWhitespace(text: string): string;
3
+ /** The same, for content that may already be split into styled spans. */
4
+ export declare function normalizeContent(content: string | TextSegment[]): string | TextSegment[];
@@ -0,0 +1,43 @@
1
+ /**
2
+ * A character with no glyph is still ENCODED and DRAWN: it comes out as the font's .notdef box, and
3
+ * veraPDF rejects the file for referencing .notdef (ISO 19005-3, 6.2.11.8). A `\n` in an invoice
4
+ * description was the first case we hit; zero-width characters are the same fault and arrive far more
5
+ * quietly - a paste out of Word or a web page carries them routinely.
6
+ *
7
+ * So nothing invisible survives into the breaker except `\n`, and that only because the breaker treats
8
+ * it as a hard break. The mapping follows a browser's `white-space: normal` apart from `\n`: CSS
9
+ * collapses that to a space, while a text COMPONENT breaks on it - Flutter's `Text` and react-pdf both
10
+ * do, and it is what anyone writing `"a\nb"` expects.
11
+ */
12
+ /**
13
+ * Everything that MEANS a line break, matched as a WHOLE so a two-character ending never becomes two
14
+ * breaks. `\n\r` is in here beside `\r\n` because it is what a mistyped CRLF looks like, and a stray
15
+ * blank line is a worse answer than reading it as the one break it was meant to be. U+2028/U+2029 are
16
+ * Unicode's own line and paragraph separators.
17
+ */
18
+ const LINE_BREAKS = /\r\n|\n\r|\r|\u2028|\u2029/g;
19
+ /**
20
+ * Invisible and inert: a zero-width space and a byte-order mark carry no meaning we act on, so they are
21
+ * dropped rather than drawn. C0, C1 and DEL likewise.
22
+ *
23
+ * Deliberately NOT here: U+200C/U+200D (zero-width non-joiner and joiner) drive Arabic shaping and
24
+ * emoji sequences, and U+200E/U+200F (the bidi marks) drive `text/bidi.ts`. Dropping those would
25
+ * silently change how correct text is laid out - worse than the box they can still leave behind in a
26
+ * run that neither shapes nor reorders.
27
+ */
28
+ // oxlint-disable-next-line no-control-regex -- matching control characters is the whole point here.
29
+ const UNPRINTABLE = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u200B\uFEFF]/g;
30
+ export function normalizeWhitespace(text) {
31
+ return (text
32
+ // Every spelling of a break becomes `\n` first, so the breaker only ever sees one form.
33
+ .replace(LINE_BREAKS, "\n")
34
+ // A tab is collapsible whitespace; a PDF text operator has no tab stops to advance to.
35
+ .replace(/\t/g, " ")
36
+ .replace(UNPRINTABLE, ""));
37
+ }
38
+ /** The same, for content that may already be split into styled spans. */
39
+ export function normalizeContent(content) {
40
+ if (typeof content === "string")
41
+ return normalizeWhitespace(content);
42
+ return content.map((seg) => ({ ...seg, content: normalizeWhitespace(seg.content) }));
43
+ }
@@ -25,6 +25,12 @@ export interface FontMetrics {
25
25
  getKernPairs(text: string, fontFamily: string, fontStyle: FontStyle): 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
36
  shapedGlyphCount?(text: string, fontFamily?: string, fontStyle?: FontStyle): number | undefined;
@@ -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,6 +152,13 @@ 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`
@@ -189,6 +189,15 @@ export class PDFObjectManager {
189
189
  // Interactive form fields (AcroForm). Empty unless a form field was placed; then finalize() emits the
190
190
  // catalog /AcroForm dict and each field's widget lands in its page /Annots.
191
191
  this._acroform = new AcroFormCollector();
192
+ // Overflow policy for unbreakable content taller than a page region (default "error"); the renderer
193
+ // seeds it into the layout-context root so packChildren can act on it.
194
+ /**
195
+ * Code points no font in the document could draw, so they were removed rather than emitted as
196
+ * `.notdef` (which PDF/A forbids). Collected here because this object is the one thing threaded
197
+ * through BOTH passes; the caller gets them back with the bytes, so an application can say which
198
+ * character went missing instead of a server log nobody reads.
199
+ */
200
+ this.missingGlyphs = new Set();
192
201
  // Whether to kern. Document-level: measuring (via `runAdvance`, which reads this off the metrics)
193
202
  // and drawing (the backend emits a `TJ`) both consult it, so they can never disagree. OFF while the
194
203
  // embedded-font path (kern/GPOS) is unbuilt - a standard-14-only kerning would set the 14 system
@@ -216,8 +225,14 @@ export class PDFObjectManager {
216
225
  setCompress(on) {
217
226
  this.compress = on;
218
227
  }
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.
228
+ reportMissingGlyph(codePoints) {
229
+ for (const cp of codePoints)
230
+ this.missingGlyphs.add(cp);
231
+ }
232
+ /** The dropped code points, in the order first seen. Empty for a document that hit none. */
233
+ getMissingGlyphs() {
234
+ return [...this.missingGlyphs];
235
+ }
221
236
  setOverflowPolicy(policy) {
222
237
  this.overflowPolicy = policy;
223
238
  }
@@ -694,6 +709,23 @@ endstream`;
694
709
  return ttf.getGlyphIndex(codePoint) !== 0;
695
710
  return isWindows1252(codePoint);
696
711
  }
712
+ /**
713
+ * Whether the document draws this code point through the colour-emoji path instead of the text
714
+ * font - a fallback emoji FONT, or an image source (`Document({ emoji })`). Such a code point is
715
+ * missing from the text font ON PURPOSE, so glyph coverage must leave it alone. Mirrors exactly
716
+ * what `getCharWidth` measures it as, or a dropped emoji would have been measured and not drawn.
717
+ */
718
+ rendersAsEmoji(codePoint, fontFamily, fontStyle) {
719
+ const ttf = this.getCustomFont(fontFamily, fontStyle);
720
+ if (ttf && this.colorRenders(ttf, codePoint))
721
+ return true;
722
+ if (this.emojiFontName && this.emojiFontName !== fontFamily) {
723
+ const emoji = this.getColorFont(this.emojiFontName, fontStyle);
724
+ if (emoji && this.colorRenders(emoji, codePoint))
725
+ return true;
726
+ }
727
+ return this.emojiImage !== undefined && isEmojiCodePoint(codePoint);
728
+ }
697
729
  colorRenders(ttf, codePoint) {
698
730
  return ttf.getColorGlyph(ttf.getGlyphIndex(codePoint)) !== null;
699
731
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jasy/pdf",
3
- "version": "1.0.0-alpha.10",
3
+ "version": "1.0.0-alpha.11",
4
4
  "description": "Declarative, component-based PDF generation in pure TypeScript - Flutter-style components, real AFM font metrics, a hand-rolled PDF writer. No headless browser, no Java.",
5
5
  "keywords": [
6
6
  "declarative",