@jasy/pdf 1.0.0-alpha.11 → 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.
@@ -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. */
@@ -181,6 +190,8 @@ export declare class TextElement extends SizedPDFElement implements Fragmentable
181
190
  direction: Direction;
182
191
  wordSpacing: number;
183
192
  textIndent: number;
193
+ breakWord: boolean;
194
+ hyphenate: Hyphenator | undefined;
184
195
  role: TextRole | undefined;
185
196
  };
186
197
  }
@@ -30,7 +30,7 @@ function finitePoints(value, name) {
30
30
  return value;
31
31
  }
32
32
  export class TextElement extends SizedPDFElement {
33
- 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, }) {
34
34
  super({ x: 0, y: 0 });
35
35
  this.role = role;
36
36
  this.rawFontSize = fontSize;
@@ -48,6 +48,8 @@ export class TextElement extends SizedPDFElement {
48
48
  this.rawTextTransform = textTransform;
49
49
  this.rawWordSpacing = finitePoints(wordSpacing, "wordSpacing");
50
50
  this.rawTextIndent = finitePoints(textIndent, "textIndent");
51
+ this.rawBreakWord = breakWord;
52
+ this.rawHyphenate = hyphenate;
51
53
  // Control characters have no glyph and would be DRAWN as .notdef boxes; `\n` survives
52
54
  // because the breaker treats it as a hard break. See text/whitespace.ts.
53
55
  this.content = normalizeContent(content);
@@ -131,6 +133,7 @@ export class TextElement extends SizedPDFElement {
131
133
  return {
132
134
  wordSpacing: this.wordSpacing,
133
135
  indent: this.textIndent,
136
+ splitting: { breakWord: this.breakWord, hyphenate: this.hyphenate },
134
137
  // Justified lines may be squeezed to keep a word; every other alignment gets no slack.
135
138
  shrink: this.textAlignment === HorizontalAlignment.justify ? MAX_SPACE_SHRINK : 0,
136
139
  };
@@ -151,6 +154,8 @@ export class TextElement extends SizedPDFElement {
151
154
  this.textTransform = this.rawTextTransform ?? ts.textTransform;
152
155
  this.wordSpacing = this.rawWordSpacing ?? ts.wordSpacing;
153
156
  this.textIndent = this.rawTextIndent ?? ts.textIndent;
157
+ this.breakWord = this.rawBreakWord ?? ts.breakWord;
158
+ this.hyphenate = this.rawHyphenate ?? ts.hyphenate;
154
159
  }
155
160
  /**
156
161
  * Splits the paragraph at line boxes (Slice 1). The lines that fit in `maxHeight` stay;
@@ -308,6 +313,8 @@ export class TextElement extends SizedPDFElement {
308
313
  direction: this.direction,
309
314
  wordSpacing: this.wordSpacing,
310
315
  textIndent: this.textIndent,
316
+ breakWord: this.breakWord,
317
+ hyphenate: this.hyphenate,
311
318
  role: this.role,
312
319
  };
313
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 = [
@@ -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
  *
@@ -42,7 +43,7 @@ export function singleLineWidth(text, font, metrics, letterSpacing = 0, wordSpac
42
43
  : width + (space + runAdvance(metrics, word, font, letterSpacing)), 0);
43
44
  }
44
45
  export function wrapStringIntoLines(text, fontFamily, fontSize, fontStyle, maxWidth, metrics, maxLines, overflow, letterSpacing = 0, options = {}) {
45
- const { wordSpacing = 0, indent = 0, shrink = 0 } = options;
46
+ const { wordSpacing = 0, indent = 0, shrink = 0, splitting = {} } = options;
46
47
  let currentLine = "";
47
48
  let currentWidth = 0;
48
49
  let gaps = 0; // spaces on the current line, which is what a justified line can squeeze
@@ -72,6 +73,24 @@ export function wrapStringIntoLines(text, fontFamily, fontSize, fontStyle, maxWi
72
73
  //
73
74
  // A single word wider than maxWidth still sits on its (empty) line and overflows, rather than
74
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
+ }
75
94
  const candidate = currentWidth + (spaceWidth + wordWidth);
76
95
  if (currentLine === "") {
77
96
  currentLine = word;
@@ -145,6 +164,33 @@ export function breakSegmentsIntoLines(segments, defaults, maxWidth, metrics, ma
145
164
  combined = "";
146
165
  words.forEach((word, wordIndex) => {
147
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;
193
+ }
148
194
  // A space joins this word to what precedes it only INSIDE a segment; segments butt together
149
195
  // with nothing between them, which is what `span("a") + span("b")` draws.
150
196
  const joiner = wordIndex > 0 ? spaceWidth : 0;
@@ -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,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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jasy/pdf",
3
- "version": "1.0.0-alpha.11",
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",