@jasy/pdf 1.0.0-alpha.9 → 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -9
- package/dist/api/color.js +69 -0
- package/dist/api/content.d.ts +40 -1
- package/dist/api/content.js +83 -0
- package/dist/api/descriptor.js +6 -1
- package/dist/api/dimension.d.ts +3 -2
- package/dist/api/font-url.js +3 -2
- package/dist/api/structure.d.ts +7 -0
- package/dist/api/structure.js +38 -3
- package/dist/api/text.d.ts +23 -2
- package/dist/api/text.js +9 -0
- package/dist/canvas/painter.d.ts +102 -0
- package/dist/canvas/painter.js +183 -0
- package/dist/elements/canvas-element.d.ts +38 -0
- package/dist/elements/canvas-element.js +47 -0
- package/dist/elements/container-element.d.ts +2 -0
- package/dist/elements/container-element.js +6 -0
- package/dist/elements/index.d.ts +2 -0
- package/dist/elements/index.js +2 -0
- package/dist/elements/layout/expanded-element.d.ts +2 -0
- package/dist/elements/layout/expanded-element.js +4 -0
- package/dist/elements/layout/keep-together-element.d.ts +2 -0
- package/dist/elements/layout/keep-together-element.js +4 -0
- package/dist/elements/layout/padding-element.d.ts +5 -0
- package/dist/elements/layout/padding-element.js +8 -0
- package/dist/elements/layout/struct-group.d.ts +2 -0
- package/dist/elements/layout/struct-group.js +4 -0
- package/dist/elements/pdf-element.d.ts +19 -3
- package/dist/elements/pdf-element.js +22 -4
- package/dist/elements/rectangle-element.d.ts +2 -0
- package/dist/elements/rectangle-element.js +6 -0
- package/dist/elements/row-element.d.ts +2 -0
- package/dist/elements/row-element.js +6 -0
- package/dist/elements/svg-element.d.ts +41 -0
- package/dist/elements/svg-element.js +93 -0
- package/dist/elements/text-element.d.ts +33 -1
- package/dist/elements/text-element.js +80 -4
- package/dist/ir/display-list.d.ts +44 -5
- package/dist/layout/min-intrinsic.d.ts +29 -0
- package/dist/layout/min-intrinsic.js +37 -0
- package/dist/renderer/anchor-renderer.js +1 -1
- package/dist/renderer/bookmark-renderer.js +1 -1
- package/dist/renderer/canvas-renderer.d.ts +14 -0
- package/dist/renderer/canvas-renderer.js +36 -0
- package/dist/renderer/container-renderer.js +1 -3
- package/dist/renderer/default-text-style-renderer.js +1 -1
- package/dist/renderer/deferred-renderer.js +1 -1
- package/dist/renderer/expanded-renderer.js +1 -1
- package/dist/renderer/index.d.ts +2 -0
- package/dist/renderer/index.js +2 -0
- package/dist/renderer/keep-together-renderer.js +1 -1
- package/dist/renderer/link-renderer.js +1 -1
- package/dist/renderer/padding-renderer.js +1 -1
- package/dist/renderer/page-builder-renderer.js +1 -1
- package/dist/renderer/page-renderer.js +1 -3
- package/dist/renderer/pdf-backend.d.ts +15 -4
- package/dist/renderer/pdf-backend.js +125 -35
- package/dist/renderer/pdf-renderer.js +5 -1
- package/dist/renderer/positioned-renderer.js +1 -1
- package/dist/renderer/rectangle-renderer.js +1 -3
- package/dist/renderer/repeating-header-renderer.js +1 -2
- package/dist/renderer/rotated-renderer.js +1 -1
- package/dist/renderer/row-renderer.js +1 -3
- package/dist/renderer/struct-group-renderer.js +1 -1
- package/dist/renderer/svg-renderer.d.ts +11 -0
- package/dist/renderer/svg-renderer.js +27 -0
- package/dist/renderer/text-renderer.js +30 -19
- package/dist/svg/css.d.ts +38 -0
- package/dist/svg/css.js +115 -0
- package/dist/svg/errors.d.ts +24 -0
- package/dist/svg/errors.js +30 -0
- package/dist/svg/gradients.d.ts +34 -0
- package/dist/svg/gradients.js +156 -0
- package/dist/svg/index.d.ts +22 -0
- package/dist/svg/index.js +331 -0
- package/dist/svg/shapes.d.ts +6 -0
- package/dist/svg/shapes.js +177 -0
- package/dist/svg/style.d.ts +61 -0
- package/dist/svg/style.js +144 -0
- package/dist/svg/transform.d.ts +13 -0
- package/dist/svg/transform.js +68 -0
- package/dist/svg/units.d.ts +11 -0
- package/dist/svg/units.js +73 -0
- package/dist/svg/xml.d.ts +24 -0
- package/dist/svg/xml.js +158 -0
- package/dist/text/advance.d.ts +3 -0
- package/dist/text/advance.js +4 -3
- package/dist/text/glyph-coverage.d.ts +15 -0
- package/dist/text/glyph-coverage.js +77 -0
- package/dist/text/line-breaker.d.ts +8 -0
- package/dist/text/line-breaker.js +146 -70
- package/dist/text/shape.d.ts +3 -3
- package/dist/text/shape.js +34 -10
- package/dist/text/text-style.d.ts +13 -0
- package/dist/text/text-style.js +6 -0
- package/dist/text/whitespace.d.ts +4 -0
- package/dist/text/whitespace.js +43 -0
- package/dist/text/word-splitting.d.ts +42 -0
- package/dist/text/word-splitting.js +72 -0
- package/dist/utils/flex-layout.js +67 -18
- package/dist/utils/font-metrics.d.ts +9 -3
- package/dist/utils/pdf-object-manager.d.ts +25 -5
- package/dist/utils/pdf-object-manager.js +82 -18
- package/dist/utils/renderer-registry.d.ts +13 -1
- package/dist/utils/renderer-registry.js +22 -1
- package/dist/utils/woff2.d.ts +6 -0
- package/dist/utils/woff2.js +651 -0
- package/dist/vector/path.d.ts +33 -0
- package/dist/vector/path.js +81 -0
- package/package.json +5 -2
|
@@ -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 = {}, ligatures } = 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
|
-
const font = { fontFamily, fontSize, fontStyle };
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const wordWidth = runAdvance(metrics, word, font, letterSpacing);
|
|
53
|
-
const spaceWidth = runAdvance(metrics, " ", font, letterSpacing) + wordSpacing;
|
|
54
|
-
// The FIRST line has the indent taken out of its room; every later one gets the full box.
|
|
55
|
-
const room = lines.length === 0 ? maxWidth - indent : maxWidth;
|
|
56
|
-
// Break before a word that won't fit - counting the SPACE that would join it, which the old test
|
|
57
|
-
// forgot. It went unnoticed on the first line, where the very first word added a space too many
|
|
58
|
-
// and cancelled the error out; after a break `currentWidth` is the bare word, so every following
|
|
59
|
-
// test was short by one space and the line could overrun its box by that much.
|
|
60
|
-
//
|
|
61
|
-
// A single word wider than maxWidth still sits on its (empty) line and overflows, rather than
|
|
62
|
-
// pushing a phantom empty line before it (which would over-count the height by a line).
|
|
63
|
-
const candidate = currentWidth + (spaceWidth + wordWidth);
|
|
64
|
-
if (currentLine === "") {
|
|
65
|
-
currentLine = word;
|
|
66
|
-
currentWidth = wordWidth;
|
|
67
|
-
gaps = 0;
|
|
68
|
-
// How much a JUSTIFIED line may be squeezed to keep one more word: the spaces already on the
|
|
69
|
-
// line plus the one that would join it, each giving up at most `shrink` of its width. Zero for
|
|
70
|
-
// every other alignment, where a squeezed line would simply overflow instead.
|
|
71
|
-
// ONE expression, used for the test AND for the running total. Adding the same three numbers in
|
|
72
|
-
// a different order gives a different last bit, and a line that lands exactly on `maxWidth` is
|
|
73
|
-
// then broken by that bit alone - which is how a footer sized to its own text wrapped.
|
|
74
|
-
}
|
|
75
|
-
else if (candidate - (gaps + 1) * spaceWidth * shrink > room) {
|
|
51
|
+
const font = { fontFamily, fontSize, fontStyle, ligatures };
|
|
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 =
|
|
78
|
-
currentWidth =
|
|
57
|
+
currentLine = "";
|
|
58
|
+
currentWidth = 0;
|
|
79
59
|
gaps = 0;
|
|
80
60
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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());
|
|
@@ -94,7 +126,7 @@ export function wrapStringIntoLines(text, fontFamily, fontSize, fontStyle, maxWi
|
|
|
94
126
|
const last = kept.length - 1;
|
|
95
127
|
kept[last] = ellipsize(kept[last], fontFamily, fontSize, fontStyle,
|
|
96
128
|
// The same room that line was broken against: the first one lost the indent to it.
|
|
97
|
-
last === 0 ? maxWidth - indent : maxWidth, metrics, letterSpacing, wordSpacing);
|
|
129
|
+
last === 0 ? maxWidth - indent : maxWidth, metrics, letterSpacing, wordSpacing, ligatures);
|
|
98
130
|
}
|
|
99
131
|
return kept;
|
|
100
132
|
}
|
|
@@ -114,43 +146,87 @@ export function breakSegmentsIntoLines(segments, defaults, maxWidth, metrics, ma
|
|
|
114
146
|
const size = segment.fontSize || defaults.fontSize;
|
|
115
147
|
const style = segment.fontStyle || defaults.fontStyle;
|
|
116
148
|
const letterSpacing = segment.letterSpacing ?? defaults.letterSpacing ?? 0;
|
|
117
|
-
const font = {
|
|
149
|
+
const font = {
|
|
150
|
+
fontFamily: family,
|
|
151
|
+
fontSize: size,
|
|
152
|
+
fontStyle: style,
|
|
153
|
+
ligatures: defaults.ligatures,
|
|
154
|
+
};
|
|
118
155
|
const spaceWidth = runAdvance(metrics, " ", font, letterSpacing);
|
|
119
|
-
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
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) {
|
|
156
|
+
// A `\n` inside a span is a HARD break, exactly as in the string path: close the line where it
|
|
157
|
+
// sits, whatever else was already on it, and start the next one with this segment's style.
|
|
158
|
+
segment.content.split("\n").forEach((paragraph, paragraphIndex) => {
|
|
159
|
+
if (paragraphIndex > 0) {
|
|
137
160
|
lines.push({ segments: lineSegments, width });
|
|
138
|
-
width =
|
|
161
|
+
width = 0;
|
|
139
162
|
lineSegments = [];
|
|
140
|
-
combined = word;
|
|
141
|
-
lineSegments.push({ ...segment, content: combined });
|
|
142
163
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
164
|
+
const words = paragraph.split(" ");
|
|
165
|
+
// Start this segment's piece empty; its content is filled word-by-word below. (Not
|
|
166
|
+
// the original content - otherwise a segment whose FIRST word overflows would carry
|
|
167
|
+
// its whole text into the line that just closed.)
|
|
168
|
+
lineSegments.push({ ...segment, fontFamily: family, content: "" });
|
|
169
|
+
combined = "";
|
|
170
|
+
words.forEach((word, wordIndex) => {
|
|
171
|
+
const wordWidth = runAdvance(metrics, word, font, letterSpacing);
|
|
172
|
+
// Same treatment as the string path: a word too wide for the box is split into pieces that fit.
|
|
173
|
+
// Each finished piece closes the line it sits on, keeping this segment's style.
|
|
174
|
+
const split = splitLongWord(word, maxWidth, font, metrics, letterSpacing, defaults.splitting ?? {});
|
|
175
|
+
if (split) {
|
|
176
|
+
// Close whatever is already on the line FIRST. The pieces were measured against the full box,
|
|
177
|
+
// not against what is left of this line - appending the first one put a line 80 wide into a
|
|
178
|
+
// 50 box, and reported 50, which is exactly why it went unnoticed. The string path has always
|
|
179
|
+
// pushed its current line before splitting; this is the same move.
|
|
180
|
+
if (width > 0) {
|
|
181
|
+
lines.push({ segments: lineSegments, width });
|
|
182
|
+
lineSegments = [{ ...segment, fontFamily: family, content: "" }];
|
|
183
|
+
combined = "";
|
|
184
|
+
}
|
|
185
|
+
for (const piece of split.slice(0, -1)) {
|
|
186
|
+
lineSegments[lineSegments.length - 1].content = piece;
|
|
187
|
+
// The width a line REPORTS is what alignment and line-metrics work from: measure it.
|
|
188
|
+
lines.push({
|
|
189
|
+
segments: lineSegments,
|
|
190
|
+
width: runAdvance(metrics, piece, font, letterSpacing),
|
|
191
|
+
});
|
|
192
|
+
lineSegments = [{ ...segment, fontFamily: family, content: "" }];
|
|
193
|
+
}
|
|
194
|
+
combined = split[split.length - 1];
|
|
195
|
+
width = runAdvance(metrics, combined, font, letterSpacing);
|
|
196
|
+
lineSegments[lineSegments.length - 1].content = combined;
|
|
197
|
+
return;
|
|
148
198
|
}
|
|
149
|
-
|
|
150
|
-
|
|
199
|
+
// A space joins this word to what precedes it only INSIDE a segment; segments butt together
|
|
200
|
+
// with nothing between them, which is what `span("a") + span("b")` draws.
|
|
201
|
+
const joiner = wordIndex > 0 ? spaceWidth : 0;
|
|
202
|
+
// ONE expression for the test and for the running total, grouped exactly as `singleLineWidth`
|
|
203
|
+
// groups it - see the note there. A width built any other way can be a bit off the one the box
|
|
204
|
+
// was sized with, and the text wraps inside a box made to hold it.
|
|
205
|
+
const candidate = width + (joiner + wordWidth);
|
|
206
|
+
// Same guard as the string path: don't open a phantom empty line for an over-wide first word -
|
|
207
|
+
// place it (overflowing) on the current empty line instead.
|
|
208
|
+
if (candidate > maxWidth && width > 0) {
|
|
209
|
+
lines.push({ segments: lineSegments, width });
|
|
210
|
+
width = wordWidth;
|
|
211
|
+
lineSegments = [];
|
|
212
|
+
combined = word;
|
|
213
|
+
lineSegments.push({ ...segment, content: combined });
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
combined += wordIndex === 0 ? word : " " + word;
|
|
217
|
+
width = candidate;
|
|
218
|
+
if (lineSegments.length === 0) {
|
|
219
|
+
lineSegments.push({ ...segment, fontFamily: family, content: combined });
|
|
220
|
+
}
|
|
221
|
+
lineSegments[lineSegments.length - 1].content = combined;
|
|
222
|
+
}
|
|
223
|
+
});
|
|
151
224
|
});
|
|
152
225
|
});
|
|
153
|
-
if
|
|
226
|
+
// Only if something actually landed on it. The string path ends with `if (currentLine)`, so a text
|
|
227
|
+
// ending in `\n` is ONE line there - the two must not disagree about the same text. An empty
|
|
228
|
+
// paragraph in the MIDDLE still becomes a line; that one is pushed at the break, not here.
|
|
229
|
+
if (lineSegments.some((seg) => seg.content !== "")) {
|
|
154
230
|
lines.push({ segments: lineSegments, width });
|
|
155
231
|
}
|
|
156
232
|
// Open-end by default; cap only when maxLines is set.
|
|
@@ -182,8 +258,8 @@ export function segmentLinesToSegments(lines) {
|
|
|
182
258
|
}
|
|
183
259
|
/** Appends "…" to a single line, dropping trailing words (then characters) until the line plus the
|
|
184
260
|
* ellipsis fits `maxWidth`. Falls back to a bare "…" if not even one character fits. */
|
|
185
|
-
function ellipsize(line, fontFamily, fontSize, fontStyle, maxWidth, metrics, letterSpacing = 0, wordSpacing = 0) {
|
|
186
|
-
const font = { fontFamily, fontSize, fontStyle };
|
|
261
|
+
function ellipsize(line, fontFamily, fontSize, fontStyle, maxWidth, metrics, letterSpacing = 0, wordSpacing = 0, ligatures) {
|
|
262
|
+
const font = { fontFamily, fontSize, fontStyle, ligatures };
|
|
187
263
|
// Measured through `singleLineWidth`, so the ellipsised line is judged by the same sum that decides
|
|
188
264
|
// every other line - word-spacing included.
|
|
189
265
|
const fits = (s) => singleLineWidth(s + ELLIPSIS, font, metrics, letterSpacing, wordSpacing) <= maxWidth;
|
package/dist/text/shape.d.ts
CHANGED
|
@@ -23,7 +23,7 @@ export interface ShapingFont {
|
|
|
23
23
|
/**
|
|
24
24
|
* Shape a run, or `undefined` when there is nothing to do - so untouched documents keep their path.
|
|
25
25
|
*
|
|
26
|
-
* `rlig`
|
|
27
|
-
*
|
|
26
|
+
* Arabic always applies `rlig` - lam-alef is wrong when drawn apart. Latin applies whatever `features`
|
|
27
|
+
* asks for, which is `liga` today; the list is the seam a general `fontFeatures` would use.
|
|
28
28
|
*/
|
|
29
|
-
export declare function shapeRun(codePoints: readonly number[], font: ShapingFont): ShapedGlyph[] | undefined;
|
|
29
|
+
export declare function shapeRun(codePoints: readonly number[], font: ShapingFont, features?: readonly string[]): ShapedGlyph[] | undefined;
|
package/dist/text/shape.js
CHANGED
|
@@ -9,14 +9,18 @@ const FORM_FEATURES = {
|
|
|
9
9
|
/**
|
|
10
10
|
* Shape a run, or `undefined` when there is nothing to do - so untouched documents keep their path.
|
|
11
11
|
*
|
|
12
|
-
* `rlig`
|
|
13
|
-
*
|
|
12
|
+
* Arabic always applies `rlig` - lam-alef is wrong when drawn apart. Latin applies whatever `features`
|
|
13
|
+
* asks for, which is `liga` today; the list is the seam a general `fontFeatures` would use.
|
|
14
14
|
*/
|
|
15
|
-
export function shapeRun(codePoints, font) {
|
|
16
|
-
if (!needsJoining(codePoints))
|
|
17
|
-
return undefined;
|
|
15
|
+
export function shapeRun(codePoints, font, features = []) {
|
|
18
16
|
const gsub = font.gsub();
|
|
19
|
-
if (!gsub
|
|
17
|
+
if (!gsub)
|
|
18
|
+
return undefined;
|
|
19
|
+
if (!needsJoining(codePoints)) {
|
|
20
|
+
// Latin: no joining, only the substitutions the caller asked for.
|
|
21
|
+
return features.length > 0 ? shapeLatin(codePoints, font, gsub, features) : undefined;
|
|
22
|
+
}
|
|
23
|
+
if (!gsub.hasScript("arab"))
|
|
20
24
|
return undefined;
|
|
21
25
|
const forms = joiningForms(codePoints);
|
|
22
26
|
let glyphs = codePoints.map((cp, i) => {
|
|
@@ -35,12 +39,32 @@ export function shapeRun(codePoints, font) {
|
|
|
35
39
|
}
|
|
36
40
|
return { glyph, advance: font.getAdvanceWidth(glyph), codePoints: [cp] };
|
|
37
41
|
});
|
|
38
|
-
glyphs = applyLigatures(glyphs, gsub, font);
|
|
42
|
+
glyphs = applyLigatures(glyphs, gsub, font, "arab", "rlig");
|
|
39
43
|
return glyphs;
|
|
40
44
|
}
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Latin: no joining, only ligatures. Returns undefined when the font offers none, so a document that
|
|
47
|
+
* asks for them but uses a font without them keeps the plain path and stays byte-identical.
|
|
48
|
+
*/
|
|
49
|
+
function shapeLatin(codePoints, font, gsub, features) {
|
|
50
|
+
if (!gsub.hasScript("latn"))
|
|
51
|
+
return undefined;
|
|
52
|
+
const wanted = features.filter((f) => gsub.lookups("latn", f).length > 0);
|
|
53
|
+
if (wanted.length === 0)
|
|
54
|
+
return undefined;
|
|
55
|
+
const plain = codePoints.map((cp) => {
|
|
56
|
+
const glyph = font.getGlyphIndex(cp);
|
|
57
|
+
return { glyph, advance: font.getAdvanceWidth(glyph), codePoints: [cp] };
|
|
58
|
+
});
|
|
59
|
+
let shaped = plain;
|
|
60
|
+
for (const feature of wanted)
|
|
61
|
+
shaped = applyLigatures(shaped, gsub, font, "latn", feature);
|
|
62
|
+
// Nothing merged: hand back undefined so the caller measures and draws the untouched run.
|
|
63
|
+
return shaped.length === plain.length ? undefined : shaped;
|
|
64
|
+
}
|
|
65
|
+
/** Collapse ligatures, longest match first, carrying every component's code points along. */
|
|
66
|
+
function applyLigatures(glyphs, gsub, font, script, feature) {
|
|
67
|
+
const lookups = gsub.lookups(script, feature);
|
|
44
68
|
if (lookups.length === 0)
|
|
45
69
|
return glyphs;
|
|
46
70
|
const ids = glyphs.map((g) => g.glyph);
|
|
@@ -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,18 @@ 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
|
+
/** The ligatures the font's designer drew (`fi`, `fl`, `ffi`), from its GSUB. ON, like kerning: CSS,
|
|
55
|
+
* browsers and every other renderer apply them, and nobody should need the word to get text set
|
|
56
|
+
* properly. `false` opts a document, a subtree or one `Text` out. Embedded fonts only. */
|
|
57
|
+
ligatures: boolean;
|
|
58
|
+
/** CSS `overflow-wrap: break-word`: split a word that is wider than its box, anywhere, no hyphen.
|
|
59
|
+
* The floor under `hyphenate` - it is what handles an e-mail, an IBAN or an invoice number, which
|
|
60
|
+
* have no valid hyphenation point anywhere. Default off: a too-wide word overflows, as in CSS. */
|
|
61
|
+
breakWord: boolean;
|
|
62
|
+
/** Language-aware word splitting: given a word, return its parts (`["Rechts", "schutz", ...]`). A
|
|
63
|
+
* hyphen is drawn at the break. Pluggable on purpose - no pattern data ships in this package, so
|
|
64
|
+
* there is no licence question and nothing is added to a bundle that never asks for it. */
|
|
65
|
+
hyphenate?: Hyphenator;
|
|
53
66
|
}
|
|
54
67
|
/**
|
|
55
68
|
* 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,9 @@ export const DEFAULT_TEXT_STYLE = {
|
|
|
54
54
|
textTransform: "none",
|
|
55
55
|
wordSpacing: 0,
|
|
56
56
|
textIndent: 0,
|
|
57
|
+
ligatures: true,
|
|
58
|
+
breakWord: false,
|
|
59
|
+
hyphenate: undefined,
|
|
57
60
|
};
|
|
58
61
|
/** Layers a partial override onto a complete style; an unset (undefined) field keeps the base. */
|
|
59
62
|
export function mergeTextStyle(base, override) {
|
|
@@ -75,5 +78,8 @@ export function mergeTextStyle(base, override) {
|
|
|
75
78
|
textTransform: override.textTransform ?? base.textTransform,
|
|
76
79
|
wordSpacing: override.wordSpacing ?? base.wordSpacing,
|
|
77
80
|
textIndent: override.textIndent ?? base.textIndent,
|
|
81
|
+
ligatures: override.ligatures ?? base.ligatures,
|
|
82
|
+
breakWord: override.breakWord ?? base.breakWord,
|
|
83
|
+
hyphenate: override.hyphenate ?? base.hyphenate,
|
|
78
84
|
};
|
|
79
85
|
}
|
|
@@ -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
|
+
}
|