@jasy/pdf 1.0.0-alpha.10 → 1.0.0-alpha.11
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/elements/text-element.d.ts +7 -0
- package/dist/elements/text-element.js +30 -3
- package/dist/text/glyph-coverage.d.ts +15 -0
- package/dist/text/glyph-coverage.js +77 -0
- package/dist/text/line-breaker.js +89 -64
- package/dist/text/whitespace.d.ts +4 -0
- package/dist/text/whitespace.js +43 -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 +1 -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) {
|
|
@@ -125,6 +125,13 @@ export declare class TextElement extends SizedPDFElement implements Fragmentable
|
|
|
125
125
|
* same width in most fonts).
|
|
126
126
|
*/
|
|
127
127
|
private display;
|
|
128
|
+
/**
|
|
129
|
+
* Drops or substitutes whatever the RESOLVED font cannot draw. Runs last, after the fallback stack
|
|
130
|
+
* has had its say, and before anything is measured - a character removed at draw time only would
|
|
131
|
+
* make the measured line and the drawn one disagree. A font that draws everything gets its input
|
|
132
|
+
* back unchanged, so an ordinary document is byte-identical.
|
|
133
|
+
*/
|
|
134
|
+
private covered;
|
|
128
135
|
/** The content with `text-transform` applied, and nothing else. */
|
|
129
136
|
private recased;
|
|
130
137
|
private lineOptions;
|
|
@@ -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,
|
|
@@ -46,7 +48,9 @@ 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
|
-
|
|
51
|
+
// Control characters have no glyph and would be DRAWN as .notdef boxes; `\n` survives
|
|
52
|
+
// because the breaker treats it as a hard break. See text/whitespace.ts.
|
|
53
|
+
this.content = normalizeContent(content);
|
|
50
54
|
this.maxLines = maxLines;
|
|
51
55
|
this.orphans = orphans;
|
|
52
56
|
this.widows = widows;
|
|
@@ -69,8 +73,10 @@ export class TextElement extends SizedPDFElement {
|
|
|
69
73
|
// A SPAN may bring its own stack, so the element's being empty is not enough to skip the pass.
|
|
70
74
|
const anyStack = this.fontFallback.length > 0 ||
|
|
71
75
|
(typeof cased !== "string" && cased.some((seg) => (seg.fontFallback?.length ?? 0) > 0));
|
|
72
|
-
if (!
|
|
76
|
+
if (!metrics)
|
|
73
77
|
return cased;
|
|
78
|
+
if (!anyStack)
|
|
79
|
+
return this.covered(cased, metrics);
|
|
74
80
|
// Font fallback turns the content into spans - one per family - which every later pass already
|
|
75
81
|
// knows how to handle. Nothing new downstream.
|
|
76
82
|
const pieces = typeof cased === "string" ? [{ content: cased }] : cased;
|
|
@@ -83,7 +89,28 @@ export class TextElement extends SizedPDFElement {
|
|
|
83
89
|
for (const run of runs)
|
|
84
90
|
out.push({ ...seg, content: run.text, fontFamily: run.fontFamily });
|
|
85
91
|
}
|
|
86
|
-
return out;
|
|
92
|
+
return this.covered(out, metrics);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Drops or substitutes whatever the RESOLVED font cannot draw. Runs last, after the fallback stack
|
|
96
|
+
* has had its say, and before anything is measured - a character removed at draw time only would
|
|
97
|
+
* make the measured line and the drawn one disagree. A font that draws everything gets its input
|
|
98
|
+
* back unchanged, so an ordinary document is byte-identical.
|
|
99
|
+
*/
|
|
100
|
+
covered(content, metrics) {
|
|
101
|
+
const report = (dropped) => metrics.reportMissingGlyph?.(dropped);
|
|
102
|
+
if (typeof content === "string") {
|
|
103
|
+
const { text, dropped } = coverText(content, this.fontFamily, this.fontStyle, metrics);
|
|
104
|
+
if (dropped.length > 0)
|
|
105
|
+
report(dropped);
|
|
106
|
+
return text;
|
|
107
|
+
}
|
|
108
|
+
return content.map((seg) => {
|
|
109
|
+
const { text, dropped } = coverText(seg.content, seg.fontFamily ?? this.fontFamily, seg.fontStyle ?? this.fontStyle, metrics);
|
|
110
|
+
if (dropped.length > 0)
|
|
111
|
+
report(dropped);
|
|
112
|
+
return text === seg.content ? seg : { ...seg, content: text };
|
|
113
|
+
});
|
|
87
114
|
}
|
|
88
115
|
/** The content with `text-transform` applied, and nothing else. */
|
|
89
116
|
recased() {
|
|
@@ -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
|
+
}
|
|
@@ -31,6 +31,9 @@ const ELLIPSIS = "...";
|
|
|
31
31
|
* hypothetical: it is what split a footer sized to its own content.
|
|
32
32
|
*/
|
|
33
33
|
export function singleLineWidth(text, font, metrics, letterSpacing = 0, wordSpacing = 0) {
|
|
34
|
+
// A hard break makes this several lines; the natural width is the widest of them.
|
|
35
|
+
if (text.includes("\n"))
|
|
36
|
+
return Math.max(...text.split("\n").map((p) => singleLineWidth(p, font, metrics, letterSpacing, wordSpacing)));
|
|
34
37
|
const space = runAdvance(metrics, " ", font, letterSpacing) + wordSpacing;
|
|
35
38
|
return text
|
|
36
39
|
.split(" ")
|
|
@@ -45,44 +48,54 @@ export function wrapStringIntoLines(text, fontFamily, fontSize, fontStyle, maxWi
|
|
|
45
48
|
let gaps = 0; // spaces on the current line, which is what a justified line can squeeze
|
|
46
49
|
const lines = [];
|
|
47
50
|
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
|
+
// A `\n` is a HARD break: wrap each paragraph on its own, then cut. An EMPTY paragraph still has to
|
|
52
|
+
// produce a line, or a deliberate blank line between two blocks silently disappears.
|
|
53
|
+
text.split("\n").forEach((paragraph, paragraphIndex) => {
|
|
54
|
+
if (paragraphIndex > 0) {
|
|
76
55
|
lines.push(currentLine.trim());
|
|
77
|
-
currentLine =
|
|
78
|
-
currentWidth =
|
|
56
|
+
currentLine = "";
|
|
57
|
+
currentWidth = 0;
|
|
79
58
|
gaps = 0;
|
|
80
59
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
60
|
+
const words = paragraph.split(" ");
|
|
61
|
+
words.forEach((word) => {
|
|
62
|
+
// Word and space advances come from the one shared primitive (`advance.ts`), the same one
|
|
63
|
+
// `naturalWidth` uses - so a bounded and an unbounded layout of the same text agree bit for bit.
|
|
64
|
+
const wordWidth = runAdvance(metrics, word, font, letterSpacing);
|
|
65
|
+
const spaceWidth = runAdvance(metrics, " ", font, letterSpacing) + wordSpacing;
|
|
66
|
+
// The FIRST line has the indent taken out of its room; every later one gets the full box.
|
|
67
|
+
const room = lines.length === 0 ? maxWidth - indent : maxWidth;
|
|
68
|
+
// Break before a word that won't fit - counting the SPACE that would join it, which the old test
|
|
69
|
+
// forgot. It went unnoticed on the first line, where the very first word added a space too many
|
|
70
|
+
// and cancelled the error out; after a break `currentWidth` is the bare word, so every following
|
|
71
|
+
// test was short by one space and the line could overrun its box by that much.
|
|
72
|
+
//
|
|
73
|
+
// A single word wider than maxWidth still sits on its (empty) line and overflows, rather than
|
|
74
|
+
// pushing a phantom empty line before it (which would over-count the height by a line).
|
|
75
|
+
const candidate = currentWidth + (spaceWidth + wordWidth);
|
|
76
|
+
if (currentLine === "") {
|
|
77
|
+
currentLine = word;
|
|
78
|
+
currentWidth = wordWidth;
|
|
79
|
+
gaps = 0;
|
|
80
|
+
// How much a JUSTIFIED line may be squeezed to keep one more word: the spaces already on the
|
|
81
|
+
// line plus the one that would join it, each giving up at most `shrink` of its width. Zero for
|
|
82
|
+
// every other alignment, where a squeezed line would simply overflow instead.
|
|
83
|
+
// ONE expression, used for the test AND for the running total. Adding the same three numbers in
|
|
84
|
+
// a different order gives a different last bit, and a line that lands exactly on `maxWidth` is
|
|
85
|
+
// then broken by that bit alone - which is how a footer sized to its own text wrapped.
|
|
86
|
+
}
|
|
87
|
+
else if (candidate - (gaps + 1) * spaceWidth * shrink > room) {
|
|
88
|
+
lines.push(currentLine.trim());
|
|
89
|
+
currentLine = word;
|
|
90
|
+
currentWidth = wordWidth;
|
|
91
|
+
gaps = 0;
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
currentLine += " " + word;
|
|
95
|
+
currentWidth = candidate;
|
|
96
|
+
gaps += 1;
|
|
97
|
+
}
|
|
98
|
+
});
|
|
86
99
|
});
|
|
87
100
|
if (currentLine)
|
|
88
101
|
lines.push(currentLine.trim());
|
|
@@ -116,41 +129,53 @@ export function breakSegmentsIntoLines(segments, defaults, maxWidth, metrics, ma
|
|
|
116
129
|
const letterSpacing = segment.letterSpacing ?? defaults.letterSpacing ?? 0;
|
|
117
130
|
const font = { fontFamily: family, fontSize: size, fontStyle: style };
|
|
118
131
|
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) {
|
|
132
|
+
// A `\n` inside a span is a HARD break, exactly as in the string path: close the line where it
|
|
133
|
+
// sits, whatever else was already on it, and start the next one with this segment's style.
|
|
134
|
+
segment.content.split("\n").forEach((paragraph, paragraphIndex) => {
|
|
135
|
+
if (paragraphIndex > 0) {
|
|
137
136
|
lines.push({ segments: lineSegments, width });
|
|
138
|
-
width =
|
|
137
|
+
width = 0;
|
|
139
138
|
lineSegments = [];
|
|
140
|
-
combined = word;
|
|
141
|
-
lineSegments.push({ ...segment, content: combined });
|
|
142
139
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
140
|
+
const words = paragraph.split(" ");
|
|
141
|
+
// Start this segment's piece empty; its content is filled word-by-word below. (Not
|
|
142
|
+
// the original content - otherwise a segment whose FIRST word overflows would carry
|
|
143
|
+
// its whole text into the line that just closed.)
|
|
144
|
+
lineSegments.push({ ...segment, fontFamily: family, content: "" });
|
|
145
|
+
combined = "";
|
|
146
|
+
words.forEach((word, wordIndex) => {
|
|
147
|
+
const wordWidth = runAdvance(metrics, word, font, letterSpacing);
|
|
148
|
+
// A space joins this word to what precedes it only INSIDE a segment; segments butt together
|
|
149
|
+
// with nothing between them, which is what `span("a") + span("b")` draws.
|
|
150
|
+
const joiner = wordIndex > 0 ? spaceWidth : 0;
|
|
151
|
+
// ONE expression for the test and for the running total, grouped exactly as `singleLineWidth`
|
|
152
|
+
// groups it - see the note there. A width built any other way can be a bit off the one the box
|
|
153
|
+
// was sized with, and the text wraps inside a box made to hold it.
|
|
154
|
+
const candidate = width + (joiner + wordWidth);
|
|
155
|
+
// Same guard as the string path: don't open a phantom empty line for an over-wide first word -
|
|
156
|
+
// place it (overflowing) on the current empty line instead.
|
|
157
|
+
if (candidate > maxWidth && width > 0) {
|
|
158
|
+
lines.push({ segments: lineSegments, width });
|
|
159
|
+
width = wordWidth;
|
|
160
|
+
lineSegments = [];
|
|
161
|
+
combined = word;
|
|
162
|
+
lineSegments.push({ ...segment, content: combined });
|
|
148
163
|
}
|
|
149
|
-
|
|
150
|
-
|
|
164
|
+
else {
|
|
165
|
+
combined += wordIndex === 0 ? word : " " + word;
|
|
166
|
+
width = candidate;
|
|
167
|
+
if (lineSegments.length === 0) {
|
|
168
|
+
lineSegments.push({ ...segment, fontFamily: family, content: combined });
|
|
169
|
+
}
|
|
170
|
+
lineSegments[lineSegments.length - 1].content = combined;
|
|
171
|
+
}
|
|
172
|
+
});
|
|
151
173
|
});
|
|
152
174
|
});
|
|
153
|
-
if
|
|
175
|
+
// Only if something actually landed on it. The string path ends with `if (currentLine)`, so a text
|
|
176
|
+
// ending in `\n` is ONE line there - the two must not disagree about the same text. An empty
|
|
177
|
+
// paragraph in the MIDDLE still becomes a line; that one is pushed at the break, not here.
|
|
178
|
+
if (lineSegments.some((seg) => seg.content !== "")) {
|
|
154
179
|
lines.push({ segments: lineSegments, width });
|
|
155
180
|
}
|
|
156
181
|
// Open-end by default; cap only when maxLines is set.
|
|
@@ -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
|
+
}
|
|
@@ -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.11",
|
|
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",
|