@jasy/pdf 1.0.0-alpha.10 → 1.0.0-alpha.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/structure.d.ts +7 -0
- package/dist/api/structure.js +9 -1
- package/dist/api/text.d.ts +15 -0
- package/dist/api/text.js +6 -0
- package/dist/elements/text-element.d.ts +19 -1
- package/dist/elements/text-element.js +38 -4
- package/dist/renderer/text-renderer.js +21 -6
- package/dist/text/glyph-coverage.d.ts +15 -0
- package/dist/text/glyph-coverage.js +77 -0
- package/dist/text/line-breaker.d.ts +5 -0
- package/dist/text/line-breaker.js +136 -65
- package/dist/text/text-style.d.ts +9 -0
- package/dist/text/text-style.js +4 -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/font-metrics.d.ts +6 -0
- package/dist/utils/pdf-object-manager.d.ts +17 -0
- package/dist/utils/pdf-object-manager.js +34 -2
- package/package.json +2 -1
package/dist/api/structure.d.ts
CHANGED
|
@@ -151,6 +151,13 @@ export interface RenderOptions {
|
|
|
151
151
|
/** What to do when content is taller than a page and cannot break: `"error"` throws (default),
|
|
152
152
|
* `"warn"` logs and clips, `"ignore"` clips silently. It is always clipped either way. */
|
|
153
153
|
onOverflow?: OverflowPolicy;
|
|
154
|
+
/**
|
|
155
|
+
* Called once, after the render, with the characters no font could draw - they were removed rather
|
|
156
|
+
* than emitted as `.notdef`, which PDF/A forbids. Deliberately a callback and not a policy: a
|
|
157
|
+
* missing tick mark must never stop an invoice from being produced, but the application should be
|
|
158
|
+
* able to SAY which character went missing, where a server log would never be read.
|
|
159
|
+
*/
|
|
160
|
+
onMissingGlyphs?: (characters: string[]) => void;
|
|
154
161
|
/** Encrypt the PDF with a password (AES-256, the newest standard). NOT compatible with PDF/A
|
|
155
162
|
* (ZUGFeRD invoices) - encrypting one throws, since PDF/A forbids encryption. */
|
|
156
163
|
encrypt?: EncryptOptions;
|
package/dist/api/structure.js
CHANGED
|
@@ -143,10 +143,14 @@ export async function renderPdf(doc, options) {
|
|
|
143
143
|
// A throwaway PDFDocument whose build() yields this tree, reusing the engine's standard
|
|
144
144
|
// font registration + config handling (the constructor does both). Custom fonts are
|
|
145
145
|
// registered here, before layout/render, so both the metrics and the backend see them.
|
|
146
|
+
// Captured so the caller can be told what had to be dropped: `render()` is static and builds its own
|
|
147
|
+
// instance, so this is the only place the object manager surfaces.
|
|
148
|
+
let manager;
|
|
146
149
|
const Anon = class extends PDFDocument {
|
|
147
150
|
constructor() {
|
|
148
151
|
super(config);
|
|
149
152
|
const om = this.objectManager;
|
|
153
|
+
manager = om;
|
|
150
154
|
om.setCompress(options?.compress !== false); // FlateDecode streams by default
|
|
151
155
|
om.setOverflowPolicy(options?.onOverflow ?? "error");
|
|
152
156
|
om.setKerning(options?.kerning !== false); // on by default (experimental); `kerning:false` opts out
|
|
@@ -207,7 +211,11 @@ export async function renderPdf(doc, options) {
|
|
|
207
211
|
return doc;
|
|
208
212
|
}
|
|
209
213
|
};
|
|
210
|
-
|
|
214
|
+
const pdf = await Anon.render();
|
|
215
|
+
const missing = manager?.getMissingGlyphs() ?? [];
|
|
216
|
+
if (missing.length > 0)
|
|
217
|
+
options?.onMissingGlyphs?.(missing.map((cp) => String.fromCodePoint(cp)));
|
|
218
|
+
return pdf;
|
|
211
219
|
}
|
|
212
220
|
/** Renders a `Document(...)` tree to PDF bytes (e.g. for a download / save dialog). */
|
|
213
221
|
export async function renderToBytes(doc, options) {
|
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. */
|
|
@@ -125,6 +134,13 @@ export declare class TextElement extends SizedPDFElement implements Fragmentable
|
|
|
125
134
|
* same width in most fonts).
|
|
126
135
|
*/
|
|
127
136
|
private display;
|
|
137
|
+
/**
|
|
138
|
+
* Drops or substitutes whatever the RESOLVED font cannot draw. Runs last, after the fallback stack
|
|
139
|
+
* has had its say, and before anything is measured - a character removed at draw time only would
|
|
140
|
+
* make the measured line and the drawn one disagree. A font that draws everything gets its input
|
|
141
|
+
* back unchanged, so an ordinary document is byte-identical.
|
|
142
|
+
*/
|
|
143
|
+
private covered;
|
|
128
144
|
/** The content with `text-transform` applied, and nothing else. */
|
|
129
145
|
private recased;
|
|
130
146
|
private lineOptions;
|
|
@@ -174,6 +190,8 @@ export declare class TextElement extends SizedPDFElement implements Fragmentable
|
|
|
174
190
|
direction: Direction;
|
|
175
191
|
wordSpacing: number;
|
|
176
192
|
textIndent: number;
|
|
193
|
+
breakWord: boolean;
|
|
194
|
+
hyphenate: Hyphenator | undefined;
|
|
177
195
|
role: TextRole | undefined;
|
|
178
196
|
};
|
|
179
197
|
}
|
|
@@ -10,6 +10,8 @@ import { singleLineWidth, MAX_SPACE_SHRINK, wrapStringIntoLines, breakSegmentsIn
|
|
|
10
10
|
import { adjustForOrphansWidows, DEFAULT_ORPHANS, DEFAULT_WIDOWS, } from "../text/orphans-widows.js";
|
|
11
11
|
import { lineBoxForSegmentLine, lineBoxForString } from "../text/line-metrics.js";
|
|
12
12
|
import { HorizontalAlignment, SizedPDFElement } from "./pdf-element.js";
|
|
13
|
+
import { normalizeContent } from "../text/whitespace.js";
|
|
14
|
+
import { coverText } from "../text/glyph-coverage.js";
|
|
13
15
|
/** How far a run sits off the baseline, as a fraction of its font size (up is positive). */
|
|
14
16
|
export const VERTICAL_TEXT_SHIFT = {
|
|
15
17
|
baseline: 0,
|
|
@@ -28,7 +30,7 @@ function finitePoints(value, name) {
|
|
|
28
30
|
return value;
|
|
29
31
|
}
|
|
30
32
|
export class TextElement extends SizedPDFElement {
|
|
31
|
-
constructor({ fontSize, content, fontFamily, fontStyle, color, textAlignment, maxLines, orphans, widows, overflow = "clip", lineHeight, underline, strikethrough, skipInk, letterSpacing, direction, fontFallback, textTransform, wordSpacing, textIndent, role, }) {
|
|
33
|
+
constructor({ fontSize, content, fontFamily, fontStyle, color, textAlignment, maxLines, orphans, widows, overflow = "clip", lineHeight, underline, strikethrough, skipInk, letterSpacing, direction, fontFallback, textTransform, wordSpacing, textIndent, breakWord, hyphenate, role, }) {
|
|
32
34
|
super({ x: 0, y: 0 });
|
|
33
35
|
this.role = role;
|
|
34
36
|
this.rawFontSize = fontSize;
|
|
@@ -46,7 +48,11 @@ export class TextElement extends SizedPDFElement {
|
|
|
46
48
|
this.rawTextTransform = textTransform;
|
|
47
49
|
this.rawWordSpacing = finitePoints(wordSpacing, "wordSpacing");
|
|
48
50
|
this.rawTextIndent = finitePoints(textIndent, "textIndent");
|
|
49
|
-
this.
|
|
51
|
+
this.rawBreakWord = breakWord;
|
|
52
|
+
this.rawHyphenate = hyphenate;
|
|
53
|
+
// Control characters have no glyph and would be DRAWN as .notdef boxes; `\n` survives
|
|
54
|
+
// because the breaker treats it as a hard break. See text/whitespace.ts.
|
|
55
|
+
this.content = normalizeContent(content);
|
|
50
56
|
this.maxLines = maxLines;
|
|
51
57
|
this.orphans = orphans;
|
|
52
58
|
this.widows = widows;
|
|
@@ -69,8 +75,10 @@ export class TextElement extends SizedPDFElement {
|
|
|
69
75
|
// A SPAN may bring its own stack, so the element's being empty is not enough to skip the pass.
|
|
70
76
|
const anyStack = this.fontFallback.length > 0 ||
|
|
71
77
|
(typeof cased !== "string" && cased.some((seg) => (seg.fontFallback?.length ?? 0) > 0));
|
|
72
|
-
if (!
|
|
78
|
+
if (!metrics)
|
|
73
79
|
return cased;
|
|
80
|
+
if (!anyStack)
|
|
81
|
+
return this.covered(cased, metrics);
|
|
74
82
|
// Font fallback turns the content into spans - one per family - which every later pass already
|
|
75
83
|
// knows how to handle. Nothing new downstream.
|
|
76
84
|
const pieces = typeof cased === "string" ? [{ content: cased }] : cased;
|
|
@@ -83,7 +91,28 @@ export class TextElement extends SizedPDFElement {
|
|
|
83
91
|
for (const run of runs)
|
|
84
92
|
out.push({ ...seg, content: run.text, fontFamily: run.fontFamily });
|
|
85
93
|
}
|
|
86
|
-
return out;
|
|
94
|
+
return this.covered(out, metrics);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Drops or substitutes whatever the RESOLVED font cannot draw. Runs last, after the fallback stack
|
|
98
|
+
* has had its say, and before anything is measured - a character removed at draw time only would
|
|
99
|
+
* make the measured line and the drawn one disagree. A font that draws everything gets its input
|
|
100
|
+
* back unchanged, so an ordinary document is byte-identical.
|
|
101
|
+
*/
|
|
102
|
+
covered(content, metrics) {
|
|
103
|
+
const report = (dropped) => metrics.reportMissingGlyph?.(dropped);
|
|
104
|
+
if (typeof content === "string") {
|
|
105
|
+
const { text, dropped } = coverText(content, this.fontFamily, this.fontStyle, metrics);
|
|
106
|
+
if (dropped.length > 0)
|
|
107
|
+
report(dropped);
|
|
108
|
+
return text;
|
|
109
|
+
}
|
|
110
|
+
return content.map((seg) => {
|
|
111
|
+
const { text, dropped } = coverText(seg.content, seg.fontFamily ?? this.fontFamily, seg.fontStyle ?? this.fontStyle, metrics);
|
|
112
|
+
if (dropped.length > 0)
|
|
113
|
+
report(dropped);
|
|
114
|
+
return text === seg.content ? seg : { ...seg, content: text };
|
|
115
|
+
});
|
|
87
116
|
}
|
|
88
117
|
/** The content with `text-transform` applied, and nothing else. */
|
|
89
118
|
recased() {
|
|
@@ -104,6 +133,7 @@ export class TextElement extends SizedPDFElement {
|
|
|
104
133
|
return {
|
|
105
134
|
wordSpacing: this.wordSpacing,
|
|
106
135
|
indent: this.textIndent,
|
|
136
|
+
splitting: { breakWord: this.breakWord, hyphenate: this.hyphenate },
|
|
107
137
|
// Justified lines may be squeezed to keep a word; every other alignment gets no slack.
|
|
108
138
|
shrink: this.textAlignment === HorizontalAlignment.justify ? MAX_SPACE_SHRINK : 0,
|
|
109
139
|
};
|
|
@@ -124,6 +154,8 @@ export class TextElement extends SizedPDFElement {
|
|
|
124
154
|
this.textTransform = this.rawTextTransform ?? ts.textTransform;
|
|
125
155
|
this.wordSpacing = this.rawWordSpacing ?? ts.wordSpacing;
|
|
126
156
|
this.textIndent = this.rawTextIndent ?? ts.textIndent;
|
|
157
|
+
this.breakWord = this.rawBreakWord ?? ts.breakWord;
|
|
158
|
+
this.hyphenate = this.rawHyphenate ?? ts.hyphenate;
|
|
127
159
|
}
|
|
128
160
|
/**
|
|
129
161
|
* Splits the paragraph at line boxes (Slice 1). The lines that fit in `maxHeight` stay;
|
|
@@ -281,6 +313,8 @@ export class TextElement extends SizedPDFElement {
|
|
|
281
313
|
direction: this.direction,
|
|
282
314
|
wordSpacing: this.wordSpacing,
|
|
283
315
|
textIndent: this.textIndent,
|
|
316
|
+
breakWord: this.breakWord,
|
|
317
|
+
hyphenate: this.hyphenate,
|
|
284
318
|
role: this.role,
|
|
285
319
|
};
|
|
286
320
|
}
|
|
@@ -18,17 +18,25 @@ export class TextRenderer {
|
|
|
18
18
|
const box = lineBoxForString(objectManager, fontFamily, fontStyle, fontSize, lineHeight);
|
|
19
19
|
return lines.length * box.height;
|
|
20
20
|
}
|
|
21
|
-
// Segments: each line's box comes from the fonts actually on it.
|
|
22
|
-
|
|
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 = [
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { FontMetrics } from "../utils/font-metrics.js";
|
|
2
|
+
import type { FontStyle } from "../utils/pdf-object-manager.js";
|
|
3
|
+
export interface CoverageResult {
|
|
4
|
+
/** The text as it can actually be drawn. */
|
|
5
|
+
text: string;
|
|
6
|
+
/** Code points that had to go - empty for every document that never hits this. */
|
|
7
|
+
dropped: number[];
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Replaces or removes what `fontFamily` cannot draw. Returns the input unchanged (and no allocation
|
|
11
|
+
* beyond the scan) when everything is drawable, which is the overwhelmingly common case.
|
|
12
|
+
*/
|
|
13
|
+
export declare function coverText(text: string, fontFamily: string, fontStyle: FontStyle, metrics: FontMetrics): CoverageResult;
|
|
14
|
+
/** `U+2713` - how a dropped code point is named to the caller. */
|
|
15
|
+
export declare function formatCodePoint(cp: number): string;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A code point the chosen font cannot draw becomes glyph 0 - which IS `.notdef`, and referencing it is
|
|
3
|
+
* forbidden by PDF/A (ISO 19005-3, 6.2.11.8). So an invoice carrying a "✓" in its description is a
|
|
4
|
+
* conforming file right up until someone validates it.
|
|
5
|
+
*
|
|
6
|
+
* The check has to be DYNAMIC, against the font actually resolved: which characters are missing is a
|
|
7
|
+
* property of that font, not of Unicode. `@jasy/e-invoice` embeds Liberation, and its gaps look
|
|
8
|
+
* arbitrary from the outside - "→" draws, "⇒" does not; "₴" draws, "₽" does not.
|
|
9
|
+
*
|
|
10
|
+
* What we do about it, in this order:
|
|
11
|
+
* 1. The font has it -> keep, untouched. This is every ordinary document, unchanged.
|
|
12
|
+
* 2. A plain equivalent MEANS the same and the font has THAT -> substitute.
|
|
13
|
+
* 3. Otherwise -> drop it, and report it, so the application can say which character
|
|
14
|
+
* went missing instead of shipping a box or a broken file.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Substitutes that keep the meaning. Deliberately tiny: only where dropping would CHANGE the text
|
|
18
|
+
* rather than thin it. `E‑Rechnung` with a non-breaking hyphen must not become `ERechnung`.
|
|
19
|
+
*
|
|
20
|
+
* Not a list of "what Word pastes" - most of that (quotes, dashes, the ellipsis) is in WinAnsi and in
|
|
21
|
+
* every normal font, so it never reaches rule 3. Entries earn their place by being measured, not
|
|
22
|
+
* guessed: each one is a code point seen missing from a real embedded font.
|
|
23
|
+
*/
|
|
24
|
+
const EQUIVALENTS = {
|
|
25
|
+
0x2010: "-", // hyphen
|
|
26
|
+
0x2011: "-", // non-breaking hyphen - the one that turns E‑Rechnung into ERechnung
|
|
27
|
+
0x2012: "-", // figure dash
|
|
28
|
+
0x2015: "-", // horizontal bar
|
|
29
|
+
0x2007: " ", // figure space
|
|
30
|
+
0x2008: " ", // punctuation space
|
|
31
|
+
0x2009: " ", // thin space
|
|
32
|
+
0x200a: " ", // hair space
|
|
33
|
+
0x202f: " ", // narrow no-break space
|
|
34
|
+
0x2032: "'", // prime
|
|
35
|
+
0x2033: '"', // double prime
|
|
36
|
+
0x02bc: "'", // modifier letter apostrophe
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Replaces or removes what `fontFamily` cannot draw. Returns the input unchanged (and no allocation
|
|
40
|
+
* beyond the scan) when everything is drawable, which is the overwhelmingly common case.
|
|
41
|
+
*/
|
|
42
|
+
export function coverText(text, fontFamily, fontStyle, metrics) {
|
|
43
|
+
let out = null; // built only once something has to change
|
|
44
|
+
const dropped = [];
|
|
45
|
+
let index = 0;
|
|
46
|
+
for (const char of text) {
|
|
47
|
+
const cp = char.codePointAt(0);
|
|
48
|
+
// A space and a line break are handled by the breaker, never drawn as a glyph.
|
|
49
|
+
const drawable = cp === 0x20 ||
|
|
50
|
+
cp === 0x0a ||
|
|
51
|
+
metrics.hasGlyph(cp, fontFamily, fontStyle) ||
|
|
52
|
+
// Colour emoji is drawn from a fallback font or an image, NOT from the text font - it is missing
|
|
53
|
+
// there by design, and removing it would delete the very thing `Document({ emoji })` exists for.
|
|
54
|
+
metrics.rendersAsEmoji?.(cp, fontFamily, fontStyle) === true;
|
|
55
|
+
if (!drawable) {
|
|
56
|
+
if (out === null)
|
|
57
|
+
out = text.slice(0, index);
|
|
58
|
+
const equivalent = EQUIVALENTS[cp];
|
|
59
|
+
if (equivalent !== undefined &&
|
|
60
|
+
metrics.hasGlyph(equivalent.codePointAt(0), fontFamily, fontStyle)) {
|
|
61
|
+
out += equivalent;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
dropped.push(cp);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
else if (out !== null) {
|
|
68
|
+
out += char;
|
|
69
|
+
}
|
|
70
|
+
index += char.length;
|
|
71
|
+
}
|
|
72
|
+
return out === null ? { text, dropped } : { text: out, dropped };
|
|
73
|
+
}
|
|
74
|
+
/** `U+2713` - how a dropped code point is named to the caller. */
|
|
75
|
+
export function formatCodePoint(cp) {
|
|
76
|
+
return "U+" + cp.toString(16).toUpperCase().padStart(4, "0");
|
|
77
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { FontStyle } from "../utils/pdf-object-manager.js";
|
|
2
2
|
import type { FontMetrics } from "../utils/font-metrics.js";
|
|
3
3
|
import type { TextSegment } from "../elements/text-element.js";
|
|
4
|
+
import { type WordSplitting } from "./word-splitting.js";
|
|
4
5
|
/**
|
|
5
6
|
* How far a JUSTIFIED line's spaces may be squeezed to keep one more word on it.
|
|
6
7
|
*
|
|
@@ -22,6 +23,8 @@ export interface LineOptions {
|
|
|
22
23
|
indent?: number;
|
|
23
24
|
/** How far a justified line's spaces may be squeezed to keep one more word (0 = not justified). */
|
|
24
25
|
shrink?: number;
|
|
26
|
+
/** How a word wider than its box is split - hyphenation, break-word, or neither (the default). */
|
|
27
|
+
splitting?: WordSplitting;
|
|
25
28
|
}
|
|
26
29
|
/** Default font for segments that don't override it. */
|
|
27
30
|
export interface SegmentDefaults {
|
|
@@ -30,6 +33,8 @@ export interface SegmentDefaults {
|
|
|
30
33
|
fontStyle: FontStyle;
|
|
31
34
|
/** Extra space after every glyph, in points; a segment may override it. Default 0. */
|
|
32
35
|
letterSpacing?: number;
|
|
36
|
+
/** Same knobs as the string path - see `LineOptions.splitting`. */
|
|
37
|
+
splitting?: WordSplitting;
|
|
33
38
|
}
|
|
34
39
|
/** What happens to text beyond `maxLines`: `"clip"` drops it, `"ellipsis"` ends the last kept line
|
|
35
40
|
* with an ellipsis. Mirrors Flutter's `TextOverflow`. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { runAdvance } from "./advance.js";
|
|
2
|
+
import { splitLongWord } from "./word-splitting.js";
|
|
2
3
|
/**
|
|
3
4
|
* How far a JUSTIFIED line's spaces may be squeezed to keep one more word on it.
|
|
4
5
|
*
|
|
@@ -31,6 +32,9 @@ const ELLIPSIS = "...";
|
|
|
31
32
|
* hypothetical: it is what split a footer sized to its own content.
|
|
32
33
|
*/
|
|
33
34
|
export function singleLineWidth(text, font, metrics, letterSpacing = 0, wordSpacing = 0) {
|
|
35
|
+
// A hard break makes this several lines; the natural width is the widest of them.
|
|
36
|
+
if (text.includes("\n"))
|
|
37
|
+
return Math.max(...text.split("\n").map((p) => singleLineWidth(p, font, metrics, letterSpacing, wordSpacing)));
|
|
34
38
|
const space = runAdvance(metrics, " ", font, letterSpacing) + wordSpacing;
|
|
35
39
|
return text
|
|
36
40
|
.split(" ")
|
|
@@ -39,50 +43,78 @@ export function singleLineWidth(text, font, metrics, letterSpacing = 0, wordSpac
|
|
|
39
43
|
: width + (space + runAdvance(metrics, word, font, letterSpacing)), 0);
|
|
40
44
|
}
|
|
41
45
|
export function wrapStringIntoLines(text, fontFamily, fontSize, fontStyle, maxWidth, metrics, maxLines, overflow, letterSpacing = 0, options = {}) {
|
|
42
|
-
const { wordSpacing = 0, indent = 0, shrink = 0 } = options;
|
|
46
|
+
const { wordSpacing = 0, indent = 0, shrink = 0, splitting = {} } = options;
|
|
43
47
|
let currentLine = "";
|
|
44
48
|
let currentWidth = 0;
|
|
45
49
|
let gaps = 0; // spaces on the current line, which is what a justified line can squeeze
|
|
46
50
|
const lines = [];
|
|
47
51
|
const font = { fontFamily, fontSize, fontStyle };
|
|
48
|
-
|
|
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) {
|
|
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());
|
|
@@ -116,41 +148,80 @@ export function breakSegmentsIntoLines(segments, defaults, maxWidth, metrics, ma
|
|
|
116
148
|
const letterSpacing = segment.letterSpacing ?? defaults.letterSpacing ?? 0;
|
|
117
149
|
const font = { fontFamily: family, fontSize: size, fontStyle: style };
|
|
118
150
|
const spaceWidth = runAdvance(metrics, " ", font, letterSpacing);
|
|
119
|
-
|
|
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) {
|
|
151
|
+
// A `\n` inside a span is a HARD break, exactly as in the string path: close the line where it
|
|
152
|
+
// sits, whatever else was already on it, and start the next one with this segment's style.
|
|
153
|
+
segment.content.split("\n").forEach((paragraph, paragraphIndex) => {
|
|
154
|
+
if (paragraphIndex > 0) {
|
|
137
155
|
lines.push({ segments: lineSegments, width });
|
|
138
|
-
width =
|
|
156
|
+
width = 0;
|
|
139
157
|
lineSegments = [];
|
|
140
|
-
combined = word;
|
|
141
|
-
lineSegments.push({ ...segment, content: combined });
|
|
142
158
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
159
|
+
const words = paragraph.split(" ");
|
|
160
|
+
// Start this segment's piece empty; its content is filled word-by-word below. (Not
|
|
161
|
+
// the original content - otherwise a segment whose FIRST word overflows would carry
|
|
162
|
+
// its whole text into the line that just closed.)
|
|
163
|
+
lineSegments.push({ ...segment, fontFamily: family, content: "" });
|
|
164
|
+
combined = "";
|
|
165
|
+
words.forEach((word, wordIndex) => {
|
|
166
|
+
const wordWidth = runAdvance(metrics, word, font, letterSpacing);
|
|
167
|
+
// Same treatment as the string path: a word too wide for the box is split into pieces that fit.
|
|
168
|
+
// Each finished piece closes the line it sits on, keeping this segment's style.
|
|
169
|
+
const split = splitLongWord(word, maxWidth, font, metrics, letterSpacing, defaults.splitting ?? {});
|
|
170
|
+
if (split) {
|
|
171
|
+
// Close whatever is already on the line FIRST. The pieces were measured against the full box,
|
|
172
|
+
// not against what is left of this line - appending the first one put a line 80 wide into a
|
|
173
|
+
// 50 box, and reported 50, which is exactly why it went unnoticed. The string path has always
|
|
174
|
+
// pushed its current line before splitting; this is the same move.
|
|
175
|
+
if (width > 0) {
|
|
176
|
+
lines.push({ segments: lineSegments, width });
|
|
177
|
+
lineSegments = [{ ...segment, fontFamily: family, content: "" }];
|
|
178
|
+
combined = "";
|
|
179
|
+
}
|
|
180
|
+
for (const piece of split.slice(0, -1)) {
|
|
181
|
+
lineSegments[lineSegments.length - 1].content = piece;
|
|
182
|
+
// The width a line REPORTS is what alignment and line-metrics work from: measure it.
|
|
183
|
+
lines.push({
|
|
184
|
+
segments: lineSegments,
|
|
185
|
+
width: runAdvance(metrics, piece, font, letterSpacing),
|
|
186
|
+
});
|
|
187
|
+
lineSegments = [{ ...segment, fontFamily: family, content: "" }];
|
|
188
|
+
}
|
|
189
|
+
combined = split[split.length - 1];
|
|
190
|
+
width = runAdvance(metrics, combined, font, letterSpacing);
|
|
191
|
+
lineSegments[lineSegments.length - 1].content = combined;
|
|
192
|
+
return;
|
|
148
193
|
}
|
|
149
|
-
|
|
150
|
-
|
|
194
|
+
// A space joins this word to what precedes it only INSIDE a segment; segments butt together
|
|
195
|
+
// with nothing between them, which is what `span("a") + span("b")` draws.
|
|
196
|
+
const joiner = wordIndex > 0 ? spaceWidth : 0;
|
|
197
|
+
// ONE expression for the test and for the running total, grouped exactly as `singleLineWidth`
|
|
198
|
+
// groups it - see the note there. A width built any other way can be a bit off the one the box
|
|
199
|
+
// was sized with, and the text wraps inside a box made to hold it.
|
|
200
|
+
const candidate = width + (joiner + wordWidth);
|
|
201
|
+
// Same guard as the string path: don't open a phantom empty line for an over-wide first word -
|
|
202
|
+
// place it (overflowing) on the current empty line instead.
|
|
203
|
+
if (candidate > maxWidth && width > 0) {
|
|
204
|
+
lines.push({ segments: lineSegments, width });
|
|
205
|
+
width = wordWidth;
|
|
206
|
+
lineSegments = [];
|
|
207
|
+
combined = word;
|
|
208
|
+
lineSegments.push({ ...segment, content: combined });
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
combined += wordIndex === 0 ? word : " " + word;
|
|
212
|
+
width = candidate;
|
|
213
|
+
if (lineSegments.length === 0) {
|
|
214
|
+
lineSegments.push({ ...segment, fontFamily: family, content: combined });
|
|
215
|
+
}
|
|
216
|
+
lineSegments[lineSegments.length - 1].content = combined;
|
|
217
|
+
}
|
|
218
|
+
});
|
|
151
219
|
});
|
|
152
220
|
});
|
|
153
|
-
if
|
|
221
|
+
// Only if something actually landed on it. The string path ends with `if (currentLine)`, so a text
|
|
222
|
+
// ending in `\n` is ONE line there - the two must not disagree about the same text. An empty
|
|
223
|
+
// paragraph in the MIDDLE still becomes a line; that one is pushed at the break, not here.
|
|
224
|
+
if (lineSegments.some((seg) => seg.content !== "")) {
|
|
154
225
|
lines.push({ segments: lineSegments, width });
|
|
155
226
|
}
|
|
156
227
|
// Open-end by default; cap only when maxLines is set.
|
|
@@ -2,6 +2,7 @@ import { Color } from "../common/color.js";
|
|
|
2
2
|
import { FontStyle } from "../utils/pdf-object-manager.js";
|
|
3
3
|
import { HorizontalAlignment } from "../elements/pdf-element.js";
|
|
4
4
|
import type { Direction } from "./bidi.js";
|
|
5
|
+
import type { Hyphenator } from "./word-splitting.js";
|
|
5
6
|
/** CSS `text-transform`. `capitalize` upper-cases the first letter of every word, as CSS does. */
|
|
6
7
|
export type TextTransform = "none" | "uppercase" | "lowercase" | "capitalize";
|
|
7
8
|
/**
|
|
@@ -50,6 +51,14 @@ export interface ResolvedTextStyle {
|
|
|
50
51
|
wordSpacing: number;
|
|
51
52
|
/** CSS `text-indent`, in points: how far the FIRST line of a paragraph starts in. */
|
|
52
53
|
textIndent: number;
|
|
54
|
+
/** CSS `overflow-wrap: break-word`: split a word that is wider than its box, anywhere, no hyphen.
|
|
55
|
+
* The floor under `hyphenate` - it is what handles an e-mail, an IBAN or an invoice number, which
|
|
56
|
+
* have no valid hyphenation point anywhere. Default off: a too-wide word overflows, as in CSS. */
|
|
57
|
+
breakWord: boolean;
|
|
58
|
+
/** Language-aware word splitting: given a word, return its parts (`["Rechts", "schutz", ...]`). A
|
|
59
|
+
* hyphen is drawn at the break. Pluggable on purpose - no pattern data ships in this package, so
|
|
60
|
+
* there is no licence question and nothing is added to a bundle that never asks for it. */
|
|
61
|
+
hyphenate?: Hyphenator;
|
|
53
62
|
}
|
|
54
63
|
/**
|
|
55
64
|
* The root of the cascade: what a `Text` falls back to when neither it nor any ancestor sets a
|
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,4 @@
|
|
|
1
|
+
import type { TextSegment } from "../elements/text-element.js";
|
|
2
|
+
export declare function normalizeWhitespace(text: string): string;
|
|
3
|
+
/** The same, for content that may already be split into styled spans. */
|
|
4
|
+
export declare function normalizeContent(content: string | TextSegment[]): string | TextSegment[];
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A character with no glyph is still ENCODED and DRAWN: it comes out as the font's .notdef box, and
|
|
3
|
+
* veraPDF rejects the file for referencing .notdef (ISO 19005-3, 6.2.11.8). A `\n` in an invoice
|
|
4
|
+
* description was the first case we hit; zero-width characters are the same fault and arrive far more
|
|
5
|
+
* quietly - a paste out of Word or a web page carries them routinely.
|
|
6
|
+
*
|
|
7
|
+
* So nothing invisible survives into the breaker except `\n`, and that only because the breaker treats
|
|
8
|
+
* it as a hard break. The mapping follows a browser's `white-space: normal` apart from `\n`: CSS
|
|
9
|
+
* collapses that to a space, while a text COMPONENT breaks on it - Flutter's `Text` and react-pdf both
|
|
10
|
+
* do, and it is what anyone writing `"a\nb"` expects.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Everything that MEANS a line break, matched as a WHOLE so a two-character ending never becomes two
|
|
14
|
+
* breaks. `\n\r` is in here beside `\r\n` because it is what a mistyped CRLF looks like, and a stray
|
|
15
|
+
* blank line is a worse answer than reading it as the one break it was meant to be. U+2028/U+2029 are
|
|
16
|
+
* Unicode's own line and paragraph separators.
|
|
17
|
+
*/
|
|
18
|
+
const LINE_BREAKS = /\r\n|\n\r|\r|\u2028|\u2029/g;
|
|
19
|
+
/**
|
|
20
|
+
* Invisible and inert: a zero-width space and a byte-order mark carry no meaning we act on, so they are
|
|
21
|
+
* dropped rather than drawn. C0, C1 and DEL likewise.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately NOT here: U+200C/U+200D (zero-width non-joiner and joiner) drive Arabic shaping and
|
|
24
|
+
* emoji sequences, and U+200E/U+200F (the bidi marks) drive `text/bidi.ts`. Dropping those would
|
|
25
|
+
* silently change how correct text is laid out - worse than the box they can still leave behind in a
|
|
26
|
+
* run that neither shapes nor reorders.
|
|
27
|
+
*/
|
|
28
|
+
// oxlint-disable-next-line no-control-regex -- matching control characters is the whole point here.
|
|
29
|
+
const UNPRINTABLE = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u200B\uFEFF]/g;
|
|
30
|
+
export function normalizeWhitespace(text) {
|
|
31
|
+
return (text
|
|
32
|
+
// Every spelling of a break becomes `\n` first, so the breaker only ever sees one form.
|
|
33
|
+
.replace(LINE_BREAKS, "\n")
|
|
34
|
+
// A tab is collapsible whitespace; a PDF text operator has no tab stops to advance to.
|
|
35
|
+
.replace(/\t/g, " ")
|
|
36
|
+
.replace(UNPRINTABLE, ""));
|
|
37
|
+
}
|
|
38
|
+
/** The same, for content that may already be split into styled spans. */
|
|
39
|
+
export function normalizeContent(content) {
|
|
40
|
+
if (typeof content === "string")
|
|
41
|
+
return normalizeWhitespace(content);
|
|
42
|
+
return content.map((seg) => ({ ...seg, content: normalizeWhitespace(seg.content) }));
|
|
43
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { FontStyle } from "../utils/pdf-object-manager.js";
|
|
2
|
+
import type { FontMetrics } from "../utils/font-metrics.js";
|
|
3
|
+
/**
|
|
4
|
+
* What to do with a word that is wider than the box it has to fit in.
|
|
5
|
+
*
|
|
6
|
+
* Until now: nothing. It stayed on its line and drew straight over whatever was beside it - which on an
|
|
7
|
+
* invoice means a mandatory field (§14 UStG) painted across its own label. CSS overflows too, so this
|
|
8
|
+
* was never a bug; it was a missing feature, and it is the one that bites in German.
|
|
9
|
+
*
|
|
10
|
+
* Two layers, and they are NOT alternatives - CSS stacks them the same way:
|
|
11
|
+
*
|
|
12
|
+
* 1. HYPHENATION splits a word at a linguistically valid point and marks it with a hyphen:
|
|
13
|
+
* `Rechtsschutzver-` / `sicherungsgesellschaften`. It needs language knowledge, which is why it is
|
|
14
|
+
* PLUGGABLE rather than bundled - no pattern data in this package, no licence question, nothing
|
|
15
|
+
* added to a bundle that never asks for it.
|
|
16
|
+
* 2. BREAK-WORD is the floor: split wherever the box ends, no hyphen. It is what handles the things
|
|
17
|
+
* hyphenation cannot touch - an e-mail address, an IBAN, an invoice number. Those are single
|
|
18
|
+
* "words" with no valid point anywhere, and they are exactly what an invoice is full of.
|
|
19
|
+
*
|
|
20
|
+
* With both on, hyphenation is tried first and break-word catches what it cannot split.
|
|
21
|
+
*/
|
|
22
|
+
/** Splits one word into its hyphenation points, e.g. `["Rechts", "schutz", "ver", "siche", ...]`. */
|
|
23
|
+
export type Hyphenator = (word: string) => string[];
|
|
24
|
+
export interface WordSplitting {
|
|
25
|
+
/** Language-aware splitting, with a hyphen at the break. Supply `hyphen`, `hyphenopoly`, your own. */
|
|
26
|
+
hyphenate?: Hyphenator;
|
|
27
|
+
/** Split anywhere as a last resort, without a hyphen. CSS `overflow-wrap: break-word`. */
|
|
28
|
+
breakWord?: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Splits `word` into pieces that each fit within `room`. Returns null when nothing applies - no
|
|
32
|
+
* splitting configured, or the word fits anyway - and the caller keeps its current behaviour, so a
|
|
33
|
+
* document that asks for neither is untouched.
|
|
34
|
+
*
|
|
35
|
+
* Every piece but the last is a finished line. The last is what carries on, so the caller keeps
|
|
36
|
+
* filling it as usual.
|
|
37
|
+
*/
|
|
38
|
+
export declare function splitLongWord(word: string, room: number, font: {
|
|
39
|
+
fontFamily: string;
|
|
40
|
+
fontSize: number;
|
|
41
|
+
fontStyle: FontStyle;
|
|
42
|
+
}, metrics: FontMetrics, letterSpacing: number, options: WordSplitting): string[] | null;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { runAdvance } from "./advance.js";
|
|
2
|
+
/** The hyphen actually drawn at a hyphenation break. U+002D, in every font we can encode. */
|
|
3
|
+
const HYPHEN = "-";
|
|
4
|
+
/**
|
|
5
|
+
* Splits `word` into pieces that each fit within `room`. Returns null when nothing applies - no
|
|
6
|
+
* splitting configured, or the word fits anyway - and the caller keeps its current behaviour, so a
|
|
7
|
+
* document that asks for neither is untouched.
|
|
8
|
+
*
|
|
9
|
+
* Every piece but the last is a finished line. The last is what carries on, so the caller keeps
|
|
10
|
+
* filling it as usual.
|
|
11
|
+
*/
|
|
12
|
+
export function splitLongWord(word, room, font, metrics, letterSpacing, options) {
|
|
13
|
+
const { hyphenate, breakWord } = options;
|
|
14
|
+
if (!hyphenate && !breakWord)
|
|
15
|
+
return null;
|
|
16
|
+
if (room <= 0)
|
|
17
|
+
return null;
|
|
18
|
+
if (runAdvance(metrics, word, font, letterSpacing) <= room)
|
|
19
|
+
return null;
|
|
20
|
+
const width = (s) => runAdvance(metrics, s, font, letterSpacing);
|
|
21
|
+
const pieces = [];
|
|
22
|
+
let rest = word;
|
|
23
|
+
// Bounded by construction: every pass either emits a piece and shortens `rest`, or gives up.
|
|
24
|
+
while (width(rest) > room) {
|
|
25
|
+
// The layering: a linguistically valid split if one is offered and reaches far enough, otherwise
|
|
26
|
+
// the blunt one - which is what an e-mail address or an IBAN needs, having no valid point at all.
|
|
27
|
+
const head = (hyphenate ? longestHyphenatedPrefix(rest, room, width, hyphenate) : null) ??
|
|
28
|
+
(breakWord ? longestPrefix(rest, room, width) : null);
|
|
29
|
+
// Nothing fits - not even one character, or hyphenation found nothing and break-word is off.
|
|
30
|
+
// Leave the remainder whole and let it overflow, exactly as before.
|
|
31
|
+
if (head === null)
|
|
32
|
+
break;
|
|
33
|
+
pieces.push(head.text);
|
|
34
|
+
rest = head.rest;
|
|
35
|
+
}
|
|
36
|
+
if (pieces.length === 0)
|
|
37
|
+
return null;
|
|
38
|
+
return [...pieces, rest];
|
|
39
|
+
}
|
|
40
|
+
/** The longest prefix that fits, plus a hyphen - split only where the hyphenator allows. */
|
|
41
|
+
function longestHyphenatedPrefix(word, room, width, hyphenate) {
|
|
42
|
+
const parts = hyphenate(word);
|
|
43
|
+
if (parts.length < 2)
|
|
44
|
+
return null;
|
|
45
|
+
let taken = "";
|
|
46
|
+
let best = null;
|
|
47
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
48
|
+
taken += parts[i];
|
|
49
|
+
// The hyphen has to fit too, or the line it lands on overflows by exactly that hyphen.
|
|
50
|
+
if (width(taken + HYPHEN) > room)
|
|
51
|
+
break;
|
|
52
|
+
best = { text: taken + HYPHEN, rest: parts.slice(i + 1).join("") };
|
|
53
|
+
}
|
|
54
|
+
return best;
|
|
55
|
+
}
|
|
56
|
+
/** The longest prefix that fits, split anywhere. Code points, so an astral character stays whole. */
|
|
57
|
+
function longestPrefix(word, room, width) {
|
|
58
|
+
const chars = [...word];
|
|
59
|
+
let taken = "";
|
|
60
|
+
let count = 0;
|
|
61
|
+
for (const char of chars) {
|
|
62
|
+
if (width(taken + char) > room)
|
|
63
|
+
break;
|
|
64
|
+
taken += char;
|
|
65
|
+
count += 1;
|
|
66
|
+
}
|
|
67
|
+
// Not even one character fits: the box is narrower than a single glyph. Splitting cannot help, and
|
|
68
|
+
// looping on it would never terminate.
|
|
69
|
+
if (count === 0 || count === chars.length)
|
|
70
|
+
return null;
|
|
71
|
+
return { text: taken, rest: chars.slice(count).join("") };
|
|
72
|
+
}
|
|
@@ -25,6 +25,12 @@ export interface FontMetrics {
|
|
|
25
25
|
getKernPairs(text: string, fontFamily: string, fontStyle: FontStyle): number[];
|
|
26
26
|
/** Whether this family can draw the code point at all - what picks a face out of a fallback stack. */
|
|
27
27
|
hasGlyph(codePoint: number, fontFamily: string, fontStyle: FontStyle): boolean;
|
|
28
|
+
/** Whether the document draws this code point as colour emoji rather than from the text font - it
|
|
29
|
+
* is missing from that font on purpose, so glyph coverage must not touch it. */
|
|
30
|
+
rendersAsEmoji?(codePoint: number, fontFamily: string, fontStyle: FontStyle): boolean;
|
|
31
|
+
/** Told when a code point had to be removed because no font could draw it. Optional: a metrics
|
|
32
|
+
* object used only for measuring (a test double) has nothing to report to. */
|
|
33
|
+
reportMissingGlyph?(codePoints: number[]): void;
|
|
28
34
|
/** How many glyphs the run will DRAW, when a ligature made that differ from its code-point count.
|
|
29
35
|
* Absent or `undefined` means "the same", which is every Latin run. */
|
|
30
36
|
shapedGlyphCount?(text: string, fontFamily?: string, fontStyle?: FontStyle): number | undefined;
|
|
@@ -65,6 +65,16 @@ export declare class PDFObjectManager implements FontMetrics {
|
|
|
65
65
|
addObject(content: string): number;
|
|
66
66
|
replaceObject(objectNumber: number, content: string): void;
|
|
67
67
|
setCompress(on: boolean): void;
|
|
68
|
+
/**
|
|
69
|
+
* Code points no font in the document could draw, so they were removed rather than emitted as
|
|
70
|
+
* `.notdef` (which PDF/A forbids). Collected here because this object is the one thing threaded
|
|
71
|
+
* through BOTH passes; the caller gets them back with the bytes, so an application can say which
|
|
72
|
+
* character went missing instead of a server log nobody reads.
|
|
73
|
+
*/
|
|
74
|
+
private readonly missingGlyphs;
|
|
75
|
+
reportMissingGlyph(codePoints: number[]): void;
|
|
76
|
+
/** The dropped code points, in the order first seen. Empty for a document that hit none. */
|
|
77
|
+
getMissingGlyphs(): number[];
|
|
68
78
|
setOverflowPolicy(policy: OverflowPolicy): void;
|
|
69
79
|
getOverflowPolicy(): OverflowPolicy;
|
|
70
80
|
private encToken;
|
|
@@ -142,6 +152,13 @@ export declare class PDFObjectManager implements FontMetrics {
|
|
|
142
152
|
* Used to pick a family out of a fallback stack, per code point.
|
|
143
153
|
*/
|
|
144
154
|
hasGlyph(codePoint: number, fontFamily: string, fontStyle?: FontStyle): boolean;
|
|
155
|
+
/**
|
|
156
|
+
* Whether the document draws this code point through the colour-emoji path instead of the text
|
|
157
|
+
* font - a fallback emoji FONT, or an image source (`Document({ emoji })`). Such a code point is
|
|
158
|
+
* missing from the text font ON PURPOSE, so glyph coverage must leave it alone. Mirrors exactly
|
|
159
|
+
* what `getCharWidth` measures it as, or a dropped emoji would have been measured and not drawn.
|
|
160
|
+
*/
|
|
161
|
+
rendersAsEmoji(codePoint: number, fontFamily: string, fontStyle: FontStyle): boolean;
|
|
145
162
|
private colorRenders;
|
|
146
163
|
getCustomFontResource(name: string, style?: FontStyle): FontIndexes | undefined;
|
|
147
164
|
/** How many glyphs a run draws - its code-point count unless a ligature merged some. `letterSpacing`
|
|
@@ -189,6 +189,15 @@ export class PDFObjectManager {
|
|
|
189
189
|
// Interactive form fields (AcroForm). Empty unless a form field was placed; then finalize() emits the
|
|
190
190
|
// catalog /AcroForm dict and each field's widget lands in its page /Annots.
|
|
191
191
|
this._acroform = new AcroFormCollector();
|
|
192
|
+
// Overflow policy for unbreakable content taller than a page region (default "error"); the renderer
|
|
193
|
+
// seeds it into the layout-context root so packChildren can act on it.
|
|
194
|
+
/**
|
|
195
|
+
* Code points no font in the document could draw, so they were removed rather than emitted as
|
|
196
|
+
* `.notdef` (which PDF/A forbids). Collected here because this object is the one thing threaded
|
|
197
|
+
* through BOTH passes; the caller gets them back with the bytes, so an application can say which
|
|
198
|
+
* character went missing instead of a server log nobody reads.
|
|
199
|
+
*/
|
|
200
|
+
this.missingGlyphs = new Set();
|
|
192
201
|
// Whether to kern. Document-level: measuring (via `runAdvance`, which reads this off the metrics)
|
|
193
202
|
// and drawing (the backend emits a `TJ`) both consult it, so they can never disagree. OFF while the
|
|
194
203
|
// embedded-font path (kern/GPOS) is unbuilt - a standard-14-only kerning would set the 14 system
|
|
@@ -216,8 +225,14 @@ export class PDFObjectManager {
|
|
|
216
225
|
setCompress(on) {
|
|
217
226
|
this.compress = on;
|
|
218
227
|
}
|
|
219
|
-
|
|
220
|
-
|
|
228
|
+
reportMissingGlyph(codePoints) {
|
|
229
|
+
for (const cp of codePoints)
|
|
230
|
+
this.missingGlyphs.add(cp);
|
|
231
|
+
}
|
|
232
|
+
/** The dropped code points, in the order first seen. Empty for a document that hit none. */
|
|
233
|
+
getMissingGlyphs() {
|
|
234
|
+
return [...this.missingGlyphs];
|
|
235
|
+
}
|
|
221
236
|
setOverflowPolicy(policy) {
|
|
222
237
|
this.overflowPolicy = policy;
|
|
223
238
|
}
|
|
@@ -694,6 +709,23 @@ endstream`;
|
|
|
694
709
|
return ttf.getGlyphIndex(codePoint) !== 0;
|
|
695
710
|
return isWindows1252(codePoint);
|
|
696
711
|
}
|
|
712
|
+
/**
|
|
713
|
+
* Whether the document draws this code point through the colour-emoji path instead of the text
|
|
714
|
+
* font - a fallback emoji FONT, or an image source (`Document({ emoji })`). Such a code point is
|
|
715
|
+
* missing from the text font ON PURPOSE, so glyph coverage must leave it alone. Mirrors exactly
|
|
716
|
+
* what `getCharWidth` measures it as, or a dropped emoji would have been measured and not drawn.
|
|
717
|
+
*/
|
|
718
|
+
rendersAsEmoji(codePoint, fontFamily, fontStyle) {
|
|
719
|
+
const ttf = this.getCustomFont(fontFamily, fontStyle);
|
|
720
|
+
if (ttf && this.colorRenders(ttf, codePoint))
|
|
721
|
+
return true;
|
|
722
|
+
if (this.emojiFontName && this.emojiFontName !== fontFamily) {
|
|
723
|
+
const emoji = this.getColorFont(this.emojiFontName, fontStyle);
|
|
724
|
+
if (emoji && this.colorRenders(emoji, codePoint))
|
|
725
|
+
return true;
|
|
726
|
+
}
|
|
727
|
+
return this.emojiImage !== undefined && isEmojiCodePoint(codePoint);
|
|
728
|
+
}
|
|
697
729
|
colorRenders(ttf, codePoint) {
|
|
698
730
|
return ttf.getColorGlyph(ttf.getGlyphIndex(codePoint)) !== null;
|
|
699
731
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jasy/pdf",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
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",
|