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

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) {
@@ -3,6 +3,7 @@ import { TextOverflow } from "../text/line-breaker.js";
3
3
  import type { Direction } from "../text/bidi.js";
4
4
  import { ResolvedTextStyle, type TextTransform } from "../text/text-style.js";
5
5
  import { ColorInput } from "./color.js";
6
+ import type { Hyphenator } from "../text/word-splitting.js";
6
7
  export type { TextOverflow };
7
8
  /** Text styling shared by `Text`, `Paragraph` and `span`. `bold`/`italic` are booleans
8
9
  * (locked §7.3) and combine into one engine `FontStyle`. */
@@ -35,6 +36,13 @@ export interface TextStyle {
35
36
  wordSpacing?: number;
36
37
  /** CSS `text-indent`, in points: how far the FIRST line of a paragraph starts in. */
37
38
  textIndent?: number;
39
+ /** Split a word that is wider than its box, anywhere, no hyphen (CSS `overflow-wrap:
40
+ * break-word`). The floor under `hyphenate`: an e-mail, an IBAN or an invoice number has no
41
+ * valid hyphenation point anywhere. Off by default - a too-wide word overflows, as in CSS. */
42
+ breakWord?: boolean;
43
+ /** Language-aware splitting: given a word, return its parts. A hyphen is drawn at the break.
44
+ * Pluggable, so no pattern data ships here: pass `hyphen`, `hyphenopoly`, or your own. */
45
+ hyphenate?: Hyphenator;
38
46
  /** CSS `vertical-align` for a SPAN: raises a footnote marker or lowers an index. It shifts the
39
47
  * baseline only - pass a smaller `size` too if you want the browser's `<sup>` look. */
40
48
  verticalAlign?: VerticalTextAlign;
@@ -108,6 +116,13 @@ export interface TextDefaults {
108
116
  wordSpacing?: number;
109
117
  /** CSS `text-indent`, in points: how far the FIRST line of a paragraph starts in. */
110
118
  textIndent?: number;
119
+ /** Split a word that is wider than its box, anywhere, no hyphen (CSS `overflow-wrap:
120
+ * break-word`). The floor under `hyphenate`: an e-mail, an IBAN or an invoice number has no
121
+ * valid hyphenation point anywhere. Off by default - a too-wide word overflows, as in CSS. */
122
+ breakWord?: boolean;
123
+ /** Language-aware splitting: given a word, return its parts. A hyphen is drawn at the break.
124
+ * Pluggable, so no pattern data ships here: pass `hyphen`, `hyphenopoly`, or your own. */
125
+ hyphenate?: Hyphenator;
111
126
  }
112
127
  /** Maps the `Text`-style option names onto a partial engine `ResolvedTextStyle` (only the set
113
128
  * fields), for seeding the inheritance cascade. */
package/dist/api/text.js CHANGED
@@ -100,6 +100,8 @@ export function Text(content, opts = {}) {
100
100
  textTransform: opts.textTransform,
101
101
  wordSpacing: opts.wordSpacing,
102
102
  textIndent: opts.textIndent,
103
+ breakWord: opts.breakWord,
104
+ hyphenate: opts.hyphenate,
103
105
  role: opts.role,
104
106
  });
105
107
  }
@@ -142,5 +144,9 @@ export function toTextStyleOverride(opts) {
142
144
  style.wordSpacing = opts.wordSpacing;
143
145
  if (opts.textIndent !== undefined)
144
146
  style.textIndent = opts.textIndent;
147
+ if (opts.breakWord !== undefined)
148
+ style.breakWord = opts.breakWord;
149
+ if (opts.hyphenate !== undefined)
150
+ style.hyphenate = opts.hyphenate;
145
151
  return style;
146
152
  }
@@ -6,6 +6,7 @@ import { type TextTransform } from "../text/text-style.js";
6
6
  import { Fragmentable, FragmentResult } from "../layout/fragmentation.js";
7
7
  import { TextOverflow } from "../text/line-breaker.js";
8
8
  import { HorizontalAlignment, LayoutContext, SizedPDFElement } from "./pdf-element.js";
9
+ import type { Hyphenator } from "../text/word-splitting.js";
9
10
  export interface TextSegment {
10
11
  content: string;
11
12
  fontStyle?: FontStyle;
@@ -74,6 +75,10 @@ interface TextElementParams {
74
75
  wordSpacing?: number;
75
76
  /** CSS `text-indent`, in points: how far the first line starts in. */
76
77
  textIndent?: number;
78
+ /** CSS `overflow-wrap: break-word` - split a word wider than its box, anywhere. */
79
+ breakWord?: boolean;
80
+ /** Language-aware splitting; a hyphen is drawn at the break. See text/word-splitting.ts. */
81
+ hyphenate?: Hyphenator;
77
82
  /** Accessibility role for the tagged structure tree (heading level or paragraph; default `"p"`). */
78
83
  role?: TextRole;
79
84
  }
@@ -93,6 +98,8 @@ export declare class TextElement extends SizedPDFElement implements Fragmentable
93
98
  private readonly rawTextTransform?;
94
99
  private readonly rawWordSpacing?;
95
100
  private readonly rawTextIndent?;
101
+ private readonly rawBreakWord?;
102
+ private readonly rawHyphenate?;
96
103
  private fontSize;
97
104
  private fontFamily;
98
105
  private fontStyle;
@@ -109,13 +116,15 @@ export declare class TextElement extends SizedPDFElement implements Fragmentable
109
116
  private textTransform;
110
117
  private wordSpacing;
111
118
  private textIndent;
119
+ private breakWord;
120
+ private hyphenate?;
112
121
  private content;
113
122
  private maxLines?;
114
123
  private orphans?;
115
124
  private widows?;
116
125
  private overflow;
117
126
  private readonly role?;
118
- constructor({ fontSize, content, fontFamily, fontStyle, color, textAlignment, maxLines, orphans, widows, overflow, lineHeight, underline, strikethrough, skipInk, letterSpacing, direction, fontFallback, textTransform, wordSpacing, textIndent, role, }: TextElementParams);
127
+ constructor({ fontSize, content, fontFamily, fontStyle, color, textAlignment, maxLines, orphans, widows, overflow, lineHeight, underline, strikethrough, skipInk, letterSpacing, direction, fontFallback, textTransform, wordSpacing, textIndent, breakWord, hyphenate, role, }: TextElementParams);
119
128
  private resolveStyle;
120
129
  /** Justified lines may be squeezed to keep a word; every other alignment gets no slack. Read by the
121
130
  * breaker in BOTH passes, or a measured line and a drawn one would disagree about where it ends. */
@@ -125,6 +134,13 @@ export declare class TextElement extends SizedPDFElement implements Fragmentable
125
134
  * same width in most fonts).
126
135
  */
127
136
  private display;
137
+ /**
138
+ * Drops or substitutes whatever the RESOLVED font cannot draw. Runs last, after the fallback stack
139
+ * has had its say, and before anything is measured - a character removed at draw time only would
140
+ * make the measured line and the drawn one disagree. A font that draws everything gets its input
141
+ * back unchanged, so an ordinary document is byte-identical.
142
+ */
143
+ private covered;
128
144
  /** The content with `text-transform` applied, and nothing else. */
129
145
  private recased;
130
146
  private lineOptions;
@@ -174,6 +190,8 @@ export declare class TextElement extends SizedPDFElement implements Fragmentable
174
190
  direction: Direction;
175
191
  wordSpacing: number;
176
192
  textIndent: number;
193
+ breakWord: boolean;
194
+ hyphenate: Hyphenator | undefined;
177
195
  role: TextRole | undefined;
178
196
  };
179
197
  }
@@ -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,
@@ -28,7 +30,7 @@ function finitePoints(value, name) {
28
30
  return value;
29
31
  }
30
32
  export class TextElement extends SizedPDFElement {
31
- constructor({ fontSize, content, fontFamily, fontStyle, color, textAlignment, maxLines, orphans, widows, overflow = "clip", lineHeight, underline, strikethrough, skipInk, letterSpacing, direction, fontFallback, textTransform, wordSpacing, textIndent, role, }) {
33
+ constructor({ fontSize, content, fontFamily, fontStyle, color, textAlignment, maxLines, orphans, widows, overflow = "clip", lineHeight, underline, strikethrough, skipInk, letterSpacing, direction, fontFallback, textTransform, wordSpacing, textIndent, breakWord, hyphenate, role, }) {
32
34
  super({ x: 0, y: 0 });
33
35
  this.role = role;
34
36
  this.rawFontSize = fontSize;
@@ -46,7 +48,11 @@ 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
+ this.rawBreakWord = breakWord;
52
+ this.rawHyphenate = hyphenate;
53
+ // Control characters have no glyph and would be DRAWN as .notdef boxes; `\n` survives
54
+ // because the breaker treats it as a hard break. See text/whitespace.ts.
55
+ this.content = normalizeContent(content);
50
56
  this.maxLines = maxLines;
51
57
  this.orphans = orphans;
52
58
  this.widows = widows;
@@ -69,8 +75,10 @@ export class TextElement extends SizedPDFElement {
69
75
  // A SPAN may bring its own stack, so the element's being empty is not enough to skip the pass.
70
76
  const anyStack = this.fontFallback.length > 0 ||
71
77
  (typeof cased !== "string" && cased.some((seg) => (seg.fontFallback?.length ?? 0) > 0));
72
- if (!anyStack || !metrics)
78
+ if (!metrics)
73
79
  return cased;
80
+ if (!anyStack)
81
+ return this.covered(cased, metrics);
74
82
  // Font fallback turns the content into spans - one per family - which every later pass already
75
83
  // knows how to handle. Nothing new downstream.
76
84
  const pieces = typeof cased === "string" ? [{ content: cased }] : cased;
@@ -83,7 +91,28 @@ export class TextElement extends SizedPDFElement {
83
91
  for (const run of runs)
84
92
  out.push({ ...seg, content: run.text, fontFamily: run.fontFamily });
85
93
  }
86
- return out;
94
+ return this.covered(out, metrics);
95
+ }
96
+ /**
97
+ * Drops or substitutes whatever the RESOLVED font cannot draw. Runs last, after the fallback stack
98
+ * has had its say, and before anything is measured - a character removed at draw time only would
99
+ * make the measured line and the drawn one disagree. A font that draws everything gets its input
100
+ * back unchanged, so an ordinary document is byte-identical.
101
+ */
102
+ covered(content, metrics) {
103
+ const report = (dropped) => metrics.reportMissingGlyph?.(dropped);
104
+ if (typeof content === "string") {
105
+ const { text, dropped } = coverText(content, this.fontFamily, this.fontStyle, metrics);
106
+ if (dropped.length > 0)
107
+ report(dropped);
108
+ return text;
109
+ }
110
+ return content.map((seg) => {
111
+ const { text, dropped } = coverText(seg.content, seg.fontFamily ?? this.fontFamily, seg.fontStyle ?? this.fontStyle, metrics);
112
+ if (dropped.length > 0)
113
+ report(dropped);
114
+ return text === seg.content ? seg : { ...seg, content: text };
115
+ });
87
116
  }
88
117
  /** The content with `text-transform` applied, and nothing else. */
89
118
  recased() {
@@ -104,6 +133,7 @@ export class TextElement extends SizedPDFElement {
104
133
  return {
105
134
  wordSpacing: this.wordSpacing,
106
135
  indent: this.textIndent,
136
+ splitting: { breakWord: this.breakWord, hyphenate: this.hyphenate },
107
137
  // Justified lines may be squeezed to keep a word; every other alignment gets no slack.
108
138
  shrink: this.textAlignment === HorizontalAlignment.justify ? MAX_SPACE_SHRINK : 0,
109
139
  };
@@ -124,6 +154,8 @@ export class TextElement extends SizedPDFElement {
124
154
  this.textTransform = this.rawTextTransform ?? ts.textTransform;
125
155
  this.wordSpacing = this.rawWordSpacing ?? ts.wordSpacing;
126
156
  this.textIndent = this.rawTextIndent ?? ts.textIndent;
157
+ this.breakWord = this.rawBreakWord ?? ts.breakWord;
158
+ this.hyphenate = this.rawHyphenate ?? ts.hyphenate;
127
159
  }
128
160
  /**
129
161
  * Splits the paragraph at line boxes (Slice 1). The lines that fit in `maxHeight` stay;
@@ -281,6 +313,8 @@ export class TextElement extends SizedPDFElement {
281
313
  direction: this.direction,
282
314
  wordSpacing: this.wordSpacing,
283
315
  textIndent: this.textIndent,
316
+ breakWord: this.breakWord,
317
+ hyphenate: this.hyphenate,
284
318
  role: this.role,
285
319
  };
286
320
  }
@@ -18,17 +18,25 @@ export class TextRenderer {
18
18
  const box = lineBoxForString(objectManager, fontFamily, fontStyle, fontSize, lineHeight);
19
19
  return lines.length * box.height;
20
20
  }
21
- // Segments: each line's box comes from the fonts actually on it.
22
- const defaults = { fontFamily, fontSize, fontStyle, letterSpacing };
21
+ // Segments: each line's box comes from the fonts actually on it. `splitting` has to travel with
22
+ // them for the same reason `letterSpacing` does: it changes where a line ENDS, so leaving it out
23
+ // measures a paragraph at one line count and draws it at another.
24
+ const defaults = {
25
+ fontFamily,
26
+ fontSize,
27
+ fontStyle,
28
+ letterSpacing,
29
+ splitting: lineOptions.splitting,
30
+ };
23
31
  const lines = breakSegmentsIntoLines(content, defaults, maxWidth, objectManager, maxLines, overflow);
24
32
  return lines.reduce((total, line) => total + lineBoxForSegmentLine(line, defaults, objectManager, lineHeight).height, 0);
25
33
  }
26
34
  static async render(textElement, objectManager) {
27
- const { x, y, width, fontSize, color, content, fontFamily, fontStyle, textAlignment, maxLines, overflow, lineHeight, underline, strikethrough, skipInk, letterSpacing, direction, wordSpacing, textIndent, role, } = textElement.getProps();
35
+ const { x, y, width, fontSize, color, content, fontFamily, fontStyle, textAlignment, maxLines, overflow, lineHeight, underline, strikethrough, skipInk, letterSpacing, direction, wordSpacing, textIndent, breakWord, hyphenate, role, } = textElement.getProps();
28
36
  // Component -> display list. Wrapping and positioning stay here; the backend
29
37
  // turns each run into BT/Tf/Td/Tj/ET. The wrapping algorithm is unchanged from
30
38
  // the original renderer - unifying it into the engine is Phase 3.
31
- const { runs, links, decorations } = TextRenderer._buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, width ?? Number.NaN, textAlignment, color, x, y, maxLines, overflow, lineHeight, underline, strikethrough, skipInk, letterSpacing, direction, wordSpacing, textIndent);
39
+ const { runs, links, decorations } = TextRenderer._buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, width ?? Number.NaN, textAlignment, color, x, y, maxLines, overflow, lineHeight, underline, strikethrough, skipInk, letterSpacing, direction, wordSpacing, textIndent, breakWord, hyphenate);
32
40
  // Accessible tagging: this whole text block is one structure element (a paragraph P, or a heading
33
41
  // from `role`); each run's text lands under it. Emoji sub-nodes produced below stay untagged (drawn
34
42
  // as artifacts). Keyed by structId so a block split across pages stays one element.
@@ -224,7 +232,7 @@ export class TextRenderer {
224
232
  // Lay the content out into absolutely-positioned text runs. Glyph positions match
225
233
  // the previous hand-written operators exactly (verified by pixel-identical render);
226
234
  // only the output form changed from PDF strings to `TextRun`s.
227
- static _buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, maxWidth, textAlignment, color, initialX, yPosition, maxLines, overflow, lineHeight, underline = false, strikethrough = false, skipInk = false, letterSpacing = 0, direction = "ltr", wordSpacing = 0, textIndent = 0) {
235
+ static _buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, maxWidth, textAlignment, color, initialX, yPosition, maxLines, overflow, lineHeight, underline = false, strikethrough = false, skipInk = false, letterSpacing = 0, direction = "ltr", wordSpacing = 0, textIndent = 0, breakWord = false, hyphenate) {
228
236
  const runs = [];
229
237
  // /Link annotation rects for any `href` spans, collected as we place segments. Empty for the
230
238
  // common (no-href) case, so plain text keeps producing exactly the runs it did before.
@@ -371,6 +379,7 @@ export class TextRenderer {
371
379
  wordSpacing,
372
380
  indent: textIndent,
373
381
  shrink: align === HorizontalAlignment.justify ? MAX_SPACE_SHRINK : 0,
382
+ splitting: { breakWord, hyphenate },
374
383
  });
375
384
  // yPosition is the top of the text box (top-left). The line box seats its own baseline; lines
376
385
  // then stack by that box's height. Same numbers as calculateTextHeight, by construction.
@@ -457,7 +466,13 @@ export class TextRenderer {
457
466
  // The seam flips the whole thing to PDF space. `letterSpacing` MUST be in the defaults so a span
458
467
  // that does not override it wraps and aligns with the element's spacing - the same defaults the
459
468
  // measure path (calculateTextHeight) uses, or segmented spaced text mis-wraps (measured != drawn).
460
- const defaults = { fontFamily, fontSize, fontStyle, letterSpacing };
469
+ const defaults = {
470
+ fontFamily,
471
+ fontSize,
472
+ fontStyle,
473
+ letterSpacing,
474
+ splitting: { breakWord, hyphenate },
475
+ };
461
476
  let top = yPosition;
462
477
  // Materialised, because justification has to know which line is the LAST one of the paragraph.
463
478
  const segmentLines = [
@@ -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
+ }
@@ -1,6 +1,7 @@
1
1
  import type { FontStyle } from "../utils/pdf-object-manager.js";
2
2
  import type { FontMetrics } from "../utils/font-metrics.js";
3
3
  import type { TextSegment } from "../elements/text-element.js";
4
+ import { type WordSplitting } from "./word-splitting.js";
4
5
  /**
5
6
  * How far a JUSTIFIED line's spaces may be squeezed to keep one more word on it.
6
7
  *
@@ -22,6 +23,8 @@ export interface LineOptions {
22
23
  indent?: number;
23
24
  /** How far a justified line's spaces may be squeezed to keep one more word (0 = not justified). */
24
25
  shrink?: number;
26
+ /** How a word wider than its box is split - hyphenation, break-word, or neither (the default). */
27
+ splitting?: WordSplitting;
25
28
  }
26
29
  /** Default font for segments that don't override it. */
27
30
  export interface SegmentDefaults {
@@ -30,6 +33,8 @@ export interface SegmentDefaults {
30
33
  fontStyle: FontStyle;
31
34
  /** Extra space after every glyph, in points; a segment may override it. Default 0. */
32
35
  letterSpacing?: number;
36
+ /** Same knobs as the string path - see `LineOptions.splitting`. */
37
+ splitting?: WordSplitting;
33
38
  }
34
39
  /** What happens to text beyond `maxLines`: `"clip"` drops it, `"ellipsis"` ends the last kept line
35
40
  * with an ellipsis. Mirrors Flutter's `TextOverflow`. */
@@ -1,4 +1,5 @@
1
1
  import { runAdvance } from "./advance.js";
2
+ import { splitLongWord } from "./word-splitting.js";
2
3
  /**
3
4
  * How far a JUSTIFIED line's spaces may be squeezed to keep one more word on it.
4
5
  *
@@ -31,6 +32,9 @@ const ELLIPSIS = "...";
31
32
  * hypothetical: it is what split a footer sized to its own content.
32
33
  */
33
34
  export function singleLineWidth(text, font, metrics, letterSpacing = 0, wordSpacing = 0) {
35
+ // A hard break makes this several lines; the natural width is the widest of them.
36
+ if (text.includes("\n"))
37
+ return Math.max(...text.split("\n").map((p) => singleLineWidth(p, font, metrics, letterSpacing, wordSpacing)));
34
38
  const space = runAdvance(metrics, " ", font, letterSpacing) + wordSpacing;
35
39
  return text
36
40
  .split(" ")
@@ -39,50 +43,78 @@ export function singleLineWidth(text, font, metrics, letterSpacing = 0, wordSpac
39
43
  : width + (space + runAdvance(metrics, word, font, letterSpacing)), 0);
40
44
  }
41
45
  export function wrapStringIntoLines(text, fontFamily, fontSize, fontStyle, maxWidth, metrics, maxLines, overflow, letterSpacing = 0, options = {}) {
42
- const { wordSpacing = 0, indent = 0, shrink = 0 } = options;
46
+ const { wordSpacing = 0, indent = 0, shrink = 0, splitting = {} } = options;
43
47
  let currentLine = "";
44
48
  let currentWidth = 0;
45
49
  let gaps = 0; // spaces on the current line, which is what a justified line can squeeze
46
50
  const lines = [];
47
51
  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) {
52
+ // A `\n` is a HARD break: wrap each paragraph on its own, then cut. An EMPTY paragraph still has to
53
+ // produce a line, or a deliberate blank line between two blocks silently disappears.
54
+ text.split("\n").forEach((paragraph, paragraphIndex) => {
55
+ if (paragraphIndex > 0) {
76
56
  lines.push(currentLine.trim());
77
- currentLine = word;
78
- currentWidth = wordWidth;
57
+ currentLine = "";
58
+ currentWidth = 0;
79
59
  gaps = 0;
80
60
  }
81
- else {
82
- currentLine += " " + word;
83
- currentWidth = candidate;
84
- gaps += 1;
85
- }
61
+ const words = paragraph.split(" ");
62
+ words.forEach((word) => {
63
+ // Word and space advances come from the one shared primitive (`advance.ts`), the same one
64
+ // `naturalWidth` uses - so a bounded and an unbounded layout of the same text agree bit for bit.
65
+ const wordWidth = runAdvance(metrics, word, font, letterSpacing);
66
+ const spaceWidth = runAdvance(metrics, " ", font, letterSpacing) + wordSpacing;
67
+ // The FIRST line has the indent taken out of its room; every later one gets the full box.
68
+ const room = lines.length === 0 ? maxWidth - indent : maxWidth;
69
+ // Break before a word that won't fit - counting the SPACE that would join it, which the old test
70
+ // forgot. It went unnoticed on the first line, where the very first word added a space too many
71
+ // and cancelled the error out; after a break `currentWidth` is the bare word, so every following
72
+ // test was short by one space and the line could overrun its box by that much.
73
+ //
74
+ // A single word wider than maxWidth still sits on its (empty) line and overflows, rather than
75
+ // pushing a phantom empty line before it (which would over-count the height by a line).
76
+ // A word too wide for the box, split into pieces that fit (hyphenation first, break-word as the
77
+ // floor). Every piece but the last closes a line; the last carries on as an ordinary word. Off by
78
+ // default, so a document that asks for neither reaches the untouched path below.
79
+ // The pieces land on lines that are NOT the first one whenever the current line is closed below,
80
+ // so they get the full box - `room` only loses the indent while we are genuinely still filling
81
+ // the indented first line.
82
+ const splitRoom = lines.length === 0 && currentLine === "" ? room : maxWidth;
83
+ const split = splitLongWord(word, splitRoom, font, metrics, letterSpacing, splitting);
84
+ if (split) {
85
+ if (currentLine !== "")
86
+ lines.push(currentLine.trim());
87
+ for (const piece of split.slice(0, -1))
88
+ lines.push(piece);
89
+ currentLine = split[split.length - 1];
90
+ currentWidth = runAdvance(metrics, currentLine, font, letterSpacing);
91
+ gaps = 0;
92
+ return;
93
+ }
94
+ const candidate = currentWidth + (spaceWidth + wordWidth);
95
+ if (currentLine === "") {
96
+ currentLine = word;
97
+ currentWidth = wordWidth;
98
+ gaps = 0;
99
+ // How much a JUSTIFIED line may be squeezed to keep one more word: the spaces already on the
100
+ // line plus the one that would join it, each giving up at most `shrink` of its width. Zero for
101
+ // every other alignment, where a squeezed line would simply overflow instead.
102
+ // ONE expression, used for the test AND for the running total. Adding the same three numbers in
103
+ // a different order gives a different last bit, and a line that lands exactly on `maxWidth` is
104
+ // then broken by that bit alone - which is how a footer sized to its own text wrapped.
105
+ }
106
+ else if (candidate - (gaps + 1) * spaceWidth * shrink > room) {
107
+ lines.push(currentLine.trim());
108
+ currentLine = word;
109
+ currentWidth = wordWidth;
110
+ gaps = 0;
111
+ }
112
+ else {
113
+ currentLine += " " + word;
114
+ currentWidth = candidate;
115
+ gaps += 1;
116
+ }
117
+ });
86
118
  });
87
119
  if (currentLine)
88
120
  lines.push(currentLine.trim());
@@ -116,41 +148,80 @@ export function breakSegmentsIntoLines(segments, defaults, maxWidth, metrics, ma
116
148
  const letterSpacing = segment.letterSpacing ?? defaults.letterSpacing ?? 0;
117
149
  const font = { fontFamily: family, fontSize: size, fontStyle: style };
118
150
  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) {
151
+ // A `\n` inside a span is a HARD break, exactly as in the string path: close the line where it
152
+ // sits, whatever else was already on it, and start the next one with this segment's style.
153
+ segment.content.split("\n").forEach((paragraph, paragraphIndex) => {
154
+ if (paragraphIndex > 0) {
137
155
  lines.push({ segments: lineSegments, width });
138
- width = wordWidth;
156
+ width = 0;
139
157
  lineSegments = [];
140
- combined = word;
141
- lineSegments.push({ ...segment, content: combined });
142
158
  }
143
- else {
144
- combined += wordIndex === 0 ? word : " " + word;
145
- width = candidate;
146
- if (lineSegments.length === 0) {
147
- lineSegments.push({ ...segment, fontFamily: family, content: combined });
159
+ const words = paragraph.split(" ");
160
+ // Start this segment's piece empty; its content is filled word-by-word below. (Not
161
+ // the original content - otherwise a segment whose FIRST word overflows would carry
162
+ // its whole text into the line that just closed.)
163
+ lineSegments.push({ ...segment, fontFamily: family, content: "" });
164
+ combined = "";
165
+ words.forEach((word, wordIndex) => {
166
+ const wordWidth = runAdvance(metrics, word, font, letterSpacing);
167
+ // Same treatment as the string path: a word too wide for the box is split into pieces that fit.
168
+ // Each finished piece closes the line it sits on, keeping this segment's style.
169
+ const split = splitLongWord(word, maxWidth, font, metrics, letterSpacing, defaults.splitting ?? {});
170
+ if (split) {
171
+ // Close whatever is already on the line FIRST. The pieces were measured against the full box,
172
+ // not against what is left of this line - appending the first one put a line 80 wide into a
173
+ // 50 box, and reported 50, which is exactly why it went unnoticed. The string path has always
174
+ // pushed its current line before splitting; this is the same move.
175
+ if (width > 0) {
176
+ lines.push({ segments: lineSegments, width });
177
+ lineSegments = [{ ...segment, fontFamily: family, content: "" }];
178
+ combined = "";
179
+ }
180
+ for (const piece of split.slice(0, -1)) {
181
+ lineSegments[lineSegments.length - 1].content = piece;
182
+ // The width a line REPORTS is what alignment and line-metrics work from: measure it.
183
+ lines.push({
184
+ segments: lineSegments,
185
+ width: runAdvance(metrics, piece, font, letterSpacing),
186
+ });
187
+ lineSegments = [{ ...segment, fontFamily: family, content: "" }];
188
+ }
189
+ combined = split[split.length - 1];
190
+ width = runAdvance(metrics, combined, font, letterSpacing);
191
+ lineSegments[lineSegments.length - 1].content = combined;
192
+ return;
148
193
  }
149
- lineSegments[lineSegments.length - 1].content = combined;
150
- }
194
+ // A space joins this word to what precedes it only INSIDE a segment; segments butt together
195
+ // with nothing between them, which is what `span("a") + span("b")` draws.
196
+ const joiner = wordIndex > 0 ? spaceWidth : 0;
197
+ // ONE expression for the test and for the running total, grouped exactly as `singleLineWidth`
198
+ // groups it - see the note there. A width built any other way can be a bit off the one the box
199
+ // was sized with, and the text wraps inside a box made to hold it.
200
+ const candidate = width + (joiner + wordWidth);
201
+ // Same guard as the string path: don't open a phantom empty line for an over-wide first word -
202
+ // place it (overflowing) on the current empty line instead.
203
+ if (candidate > maxWidth && width > 0) {
204
+ lines.push({ segments: lineSegments, width });
205
+ width = wordWidth;
206
+ lineSegments = [];
207
+ combined = word;
208
+ lineSegments.push({ ...segment, content: combined });
209
+ }
210
+ else {
211
+ combined += wordIndex === 0 ? word : " " + word;
212
+ width = candidate;
213
+ if (lineSegments.length === 0) {
214
+ lineSegments.push({ ...segment, fontFamily: family, content: combined });
215
+ }
216
+ lineSegments[lineSegments.length - 1].content = combined;
217
+ }
218
+ });
151
219
  });
152
220
  });
153
- if (lineSegments.length > 0) {
221
+ // Only if something actually landed on it. The string path ends with `if (currentLine)`, so a text
222
+ // ending in `\n` is ONE line there - the two must not disagree about the same text. An empty
223
+ // paragraph in the MIDDLE still becomes a line; that one is pushed at the break, not here.
224
+ if (lineSegments.some((seg) => seg.content !== "")) {
154
225
  lines.push({ segments: lineSegments, width });
155
226
  }
156
227
  // Open-end by default; cap only when maxLines is set.
@@ -2,6 +2,7 @@ import { Color } from "../common/color.js";
2
2
  import { FontStyle } from "../utils/pdf-object-manager.js";
3
3
  import { HorizontalAlignment } from "../elements/pdf-element.js";
4
4
  import type { Direction } from "./bidi.js";
5
+ import type { Hyphenator } from "./word-splitting.js";
5
6
  /** CSS `text-transform`. `capitalize` upper-cases the first letter of every word, as CSS does. */
6
7
  export type TextTransform = "none" | "uppercase" | "lowercase" | "capitalize";
7
8
  /**
@@ -50,6 +51,14 @@ export interface ResolvedTextStyle {
50
51
  wordSpacing: number;
51
52
  /** CSS `text-indent`, in points: how far the FIRST line of a paragraph starts in. */
52
53
  textIndent: number;
54
+ /** CSS `overflow-wrap: break-word`: split a word that is wider than its box, anywhere, no hyphen.
55
+ * The floor under `hyphenate` - it is what handles an e-mail, an IBAN or an invoice number, which
56
+ * have no valid hyphenation point anywhere. Default off: a too-wide word overflows, as in CSS. */
57
+ breakWord: boolean;
58
+ /** Language-aware word splitting: given a word, return its parts (`["Rechts", "schutz", ...]`). A
59
+ * hyphen is drawn at the break. Pluggable on purpose - no pattern data ships in this package, so
60
+ * there is no licence question and nothing is added to a bundle that never asks for it. */
61
+ hyphenate?: Hyphenator;
53
62
  }
54
63
  /**
55
64
  * The root of the cascade: what a `Text` falls back to when neither it nor any ancestor sets a
@@ -54,6 +54,8 @@ export const DEFAULT_TEXT_STYLE = {
54
54
  textTransform: "none",
55
55
  wordSpacing: 0,
56
56
  textIndent: 0,
57
+ breakWord: false,
58
+ hyphenate: undefined,
57
59
  };
58
60
  /** Layers a partial override onto a complete style; an unset (undefined) field keeps the base. */
59
61
  export function mergeTextStyle(base, override) {
@@ -75,5 +77,7 @@ export function mergeTextStyle(base, override) {
75
77
  textTransform: override.textTransform ?? base.textTransform,
76
78
  wordSpacing: override.wordSpacing ?? base.wordSpacing,
77
79
  textIndent: override.textIndent ?? base.textIndent,
80
+ breakWord: override.breakWord ?? base.breakWord,
81
+ hyphenate: override.hyphenate ?? base.hyphenate,
78
82
  };
79
83
  }
@@ -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
+ }
@@ -0,0 +1,42 @@
1
+ import type { FontStyle } from "../utils/pdf-object-manager.js";
2
+ import type { FontMetrics } from "../utils/font-metrics.js";
3
+ /**
4
+ * What to do with a word that is wider than the box it has to fit in.
5
+ *
6
+ * Until now: nothing. It stayed on its line and drew straight over whatever was beside it - which on an
7
+ * invoice means a mandatory field (§14 UStG) painted across its own label. CSS overflows too, so this
8
+ * was never a bug; it was a missing feature, and it is the one that bites in German.
9
+ *
10
+ * Two layers, and they are NOT alternatives - CSS stacks them the same way:
11
+ *
12
+ * 1. HYPHENATION splits a word at a linguistically valid point and marks it with a hyphen:
13
+ * `Rechtsschutzver-` / `sicherungsgesellschaften`. It needs language knowledge, which is why it is
14
+ * PLUGGABLE rather than bundled - no pattern data in this package, no licence question, nothing
15
+ * added to a bundle that never asks for it.
16
+ * 2. BREAK-WORD is the floor: split wherever the box ends, no hyphen. It is what handles the things
17
+ * hyphenation cannot touch - an e-mail address, an IBAN, an invoice number. Those are single
18
+ * "words" with no valid point anywhere, and they are exactly what an invoice is full of.
19
+ *
20
+ * With both on, hyphenation is tried first and break-word catches what it cannot split.
21
+ */
22
+ /** Splits one word into its hyphenation points, e.g. `["Rechts", "schutz", "ver", "siche", ...]`. */
23
+ export type Hyphenator = (word: string) => string[];
24
+ export interface WordSplitting {
25
+ /** Language-aware splitting, with a hyphen at the break. Supply `hyphen`, `hyphenopoly`, your own. */
26
+ hyphenate?: Hyphenator;
27
+ /** Split anywhere as a last resort, without a hyphen. CSS `overflow-wrap: break-word`. */
28
+ breakWord?: boolean;
29
+ }
30
+ /**
31
+ * Splits `word` into pieces that each fit within `room`. Returns null when nothing applies - no
32
+ * splitting configured, or the word fits anyway - and the caller keeps its current behaviour, so a
33
+ * document that asks for neither is untouched.
34
+ *
35
+ * Every piece but the last is a finished line. The last is what carries on, so the caller keeps
36
+ * filling it as usual.
37
+ */
38
+ export declare function splitLongWord(word: string, room: number, font: {
39
+ fontFamily: string;
40
+ fontSize: number;
41
+ fontStyle: FontStyle;
42
+ }, metrics: FontMetrics, letterSpacing: number, options: WordSplitting): string[] | null;
@@ -0,0 +1,72 @@
1
+ import { runAdvance } from "./advance.js";
2
+ /** The hyphen actually drawn at a hyphenation break. U+002D, in every font we can encode. */
3
+ const HYPHEN = "-";
4
+ /**
5
+ * Splits `word` into pieces that each fit within `room`. Returns null when nothing applies - no
6
+ * splitting configured, or the word fits anyway - and the caller keeps its current behaviour, so a
7
+ * document that asks for neither is untouched.
8
+ *
9
+ * Every piece but the last is a finished line. The last is what carries on, so the caller keeps
10
+ * filling it as usual.
11
+ */
12
+ export function splitLongWord(word, room, font, metrics, letterSpacing, options) {
13
+ const { hyphenate, breakWord } = options;
14
+ if (!hyphenate && !breakWord)
15
+ return null;
16
+ if (room <= 0)
17
+ return null;
18
+ if (runAdvance(metrics, word, font, letterSpacing) <= room)
19
+ return null;
20
+ const width = (s) => runAdvance(metrics, s, font, letterSpacing);
21
+ const pieces = [];
22
+ let rest = word;
23
+ // Bounded by construction: every pass either emits a piece and shortens `rest`, or gives up.
24
+ while (width(rest) > room) {
25
+ // The layering: a linguistically valid split if one is offered and reaches far enough, otherwise
26
+ // the blunt one - which is what an e-mail address or an IBAN needs, having no valid point at all.
27
+ const head = (hyphenate ? longestHyphenatedPrefix(rest, room, width, hyphenate) : null) ??
28
+ (breakWord ? longestPrefix(rest, room, width) : null);
29
+ // Nothing fits - not even one character, or hyphenation found nothing and break-word is off.
30
+ // Leave the remainder whole and let it overflow, exactly as before.
31
+ if (head === null)
32
+ break;
33
+ pieces.push(head.text);
34
+ rest = head.rest;
35
+ }
36
+ if (pieces.length === 0)
37
+ return null;
38
+ return [...pieces, rest];
39
+ }
40
+ /** The longest prefix that fits, plus a hyphen - split only where the hyphenator allows. */
41
+ function longestHyphenatedPrefix(word, room, width, hyphenate) {
42
+ const parts = hyphenate(word);
43
+ if (parts.length < 2)
44
+ return null;
45
+ let taken = "";
46
+ let best = null;
47
+ for (let i = 0; i < parts.length - 1; i++) {
48
+ taken += parts[i];
49
+ // The hyphen has to fit too, or the line it lands on overflows by exactly that hyphen.
50
+ if (width(taken + HYPHEN) > room)
51
+ break;
52
+ best = { text: taken + HYPHEN, rest: parts.slice(i + 1).join("") };
53
+ }
54
+ return best;
55
+ }
56
+ /** The longest prefix that fits, split anywhere. Code points, so an astral character stays whole. */
57
+ function longestPrefix(word, room, width) {
58
+ const chars = [...word];
59
+ let taken = "";
60
+ let count = 0;
61
+ for (const char of chars) {
62
+ if (width(taken + char) > room)
63
+ break;
64
+ taken += char;
65
+ count += 1;
66
+ }
67
+ // Not even one character fits: the box is narrower than a single glyph. Splitting cannot help, and
68
+ // looping on it would never terminate.
69
+ if (count === 0 || count === chars.length)
70
+ return null;
71
+ return { text: taken, rest: chars.slice(count).join("") };
72
+ }
@@ -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.12",
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",
@@ -48,6 +48,7 @@
48
48
  "@jimp/types": "^1.6.0",
49
49
  "@types/node": "^25.9.3",
50
50
  "@vitest/coverage-v8": "^2.1.1",
51
+ "hyphen": "^1.14.1",
51
52
  "ncp": "^2.0.0",
52
53
  "oxfmt": "^0.55.0",
53
54
  "oxlint": "^1.70.0",