@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.
- package/dist/api/text.d.ts +15 -0
- package/dist/api/text.js +6 -0
- package/dist/elements/text-element.d.ts +12 -1
- package/dist/elements/text-element.js +8 -1
- package/dist/renderer/text-renderer.js +21 -6
- package/dist/text/line-breaker.d.ts +5 -0
- package/dist/text/line-breaker.js +47 -1
- package/dist/text/text-style.d.ts +9 -0
- package/dist/text/text-style.js +4 -0
- package/dist/text/word-splitting.d.ts +42 -0
- package/dist/text/word-splitting.js +72 -0
- package/package.json +2 -1
package/dist/api/text.d.ts
CHANGED
|
@@ -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
|
-
|
|
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 = {
|
|
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
|
package/dist/text/text-style.js
CHANGED
|
@@ -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.
|
|
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",
|