@jasy/pdf 1.0.0-alpha.6 → 1.0.0-alpha.7
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/descriptor.js +3 -1
- package/dist/api/layout.d.ts +47 -14
- package/dist/api/layout.js +47 -11
- package/dist/api/structure.d.ts +5 -0
- package/dist/api/structure.js +1 -0
- package/dist/api/text.d.ts +18 -2
- package/dist/api/text.js +15 -0
- package/dist/elements/container-element.d.ts +10 -1
- package/dist/elements/container-element.js +26 -8
- package/dist/elements/index.d.ts +2 -0
- package/dist/elements/index.js +2 -0
- package/dist/elements/layout/keep-together-element.d.ts +42 -0
- package/dist/elements/layout/keep-together-element.js +85 -0
- package/dist/elements/layout/padding-element.d.ts +3 -0
- package/dist/elements/layout/padding-element.js +12 -0
- package/dist/elements/layout/page-break-element.d.ts +17 -0
- package/dist/elements/layout/page-break-element.js +24 -0
- package/dist/elements/layout/positioned-element.d.ts +10 -7
- package/dist/elements/layout/positioned-element.js +21 -9
- package/dist/elements/page-element.d.ts +11 -1
- package/dist/elements/page-element.js +41 -16
- package/dist/elements/pdf-element.d.ts +28 -0
- package/dist/elements/pdf-element.js +32 -0
- package/dist/elements/rectangle-element.d.ts +10 -1
- package/dist/elements/rectangle-element.js +27 -6
- package/dist/elements/row-element.d.ts +9 -1
- package/dist/elements/row-element.js +9 -1
- package/dist/elements/text-element.d.ts +30 -4
- package/dist/elements/text-element.js +40 -17
- package/dist/ir/display-list.d.ts +1 -0
- package/dist/layout/fragmentation.d.ts +12 -0
- package/dist/layout/fragmentation.js +45 -8
- package/dist/renderer/keep-together-renderer.d.ts +10 -0
- package/dist/renderer/keep-together-renderer.js +12 -0
- package/dist/renderer/page-break-renderer.d.ts +14 -0
- package/dist/renderer/page-break-renderer.js +15 -0
- package/dist/renderer/pdf-backend.d.ts +7 -0
- package/dist/renderer/pdf-backend.js +40 -6
- package/dist/renderer/pdf-document-class.js +3 -0
- package/dist/renderer/pdf-document-renderer.js +26 -7
- package/dist/renderer/pdf-renderer.js +6 -0
- package/dist/renderer/text-renderer.d.ts +1 -1
- package/dist/renderer/text-renderer.js +107 -59
- package/dist/text/advance.d.ts +34 -0
- package/dist/text/advance.js +42 -0
- package/dist/text/line-breaker.d.ts +9 -8
- package/dist/text/line-breaker.js +28 -21
- package/dist/text/line-metrics.d.ts +49 -0
- package/dist/text/line-metrics.js +57 -0
- package/dist/text/text-decoration.d.ts +50 -0
- package/dist/text/text-decoration.js +62 -0
- package/dist/text/text-style.d.ts +13 -3
- package/dist/text/text-style.js +10 -3
- package/dist/utils/afm-parser.d.ts +34 -0
- package/dist/utils/afm-parser.js +67 -0
- package/dist/utils/font-metrics.d.ts +14 -0
- package/dist/utils/pdf-object-manager.d.ts +27 -1
- package/dist/utils/pdf-object-manager.js +134 -20
- package/dist/utils/ttf-parser.d.ts +53 -0
- package/dist/utils/ttf-parser.js +368 -0
- package/package.json +1 -1
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
import { TextRenderer } from "../renderer/text-renderer.js";
|
|
6
6
|
import { DEFAULT_TEXT_STYLE } from "../text/text-style.js";
|
|
7
7
|
import { wrapStringIntoLines, breakSegmentsIntoLines, segmentLinesToSegments, } from "../text/line-breaker.js";
|
|
8
|
+
import { lineBoxForSegmentLine, lineBoxForString } from "../text/line-metrics.js";
|
|
9
|
+
import { runAdvance } from "../text/advance.js";
|
|
8
10
|
import { SizedPDFElement } from "./pdf-element.js";
|
|
9
11
|
export class TextElement extends SizedPDFElement {
|
|
10
|
-
constructor({ fontSize, content, fontFamily, fontStyle, color, textAlignment, maxLines, overflow = "clip", lineHeight, role, }) {
|
|
12
|
+
constructor({ fontSize, content, fontFamily, fontStyle, color, textAlignment, maxLines, overflow = "clip", lineHeight, underline, strikethrough, skipInk, letterSpacing, role, }) {
|
|
11
13
|
super({ x: 0, y: 0 });
|
|
12
14
|
this.role = role;
|
|
13
15
|
this.rawFontSize = fontSize;
|
|
@@ -16,6 +18,10 @@ export class TextElement extends SizedPDFElement {
|
|
|
16
18
|
this.rawColor = color;
|
|
17
19
|
this.rawTextAlignment = textAlignment;
|
|
18
20
|
this.rawLineHeight = lineHeight;
|
|
21
|
+
this.rawUnderline = underline;
|
|
22
|
+
this.rawStrikethrough = strikethrough;
|
|
23
|
+
this.rawSkipInk = skipInk;
|
|
24
|
+
this.rawLetterSpacing = letterSpacing;
|
|
19
25
|
this.content = content;
|
|
20
26
|
this.maxLines = maxLines;
|
|
21
27
|
this.overflow = overflow;
|
|
@@ -32,6 +38,10 @@ export class TextElement extends SizedPDFElement {
|
|
|
32
38
|
this.color = this.rawColor ?? ts.color;
|
|
33
39
|
this.textAlignment = this.rawTextAlignment ?? ts.textAlignment;
|
|
34
40
|
this.lineHeight = this.rawLineHeight ?? ts.lineHeight;
|
|
41
|
+
this.underline = this.rawUnderline ?? ts.underline;
|
|
42
|
+
this.strikethrough = this.rawStrikethrough ?? ts.strikethrough;
|
|
43
|
+
this.skipInk = this.rawSkipInk ?? ts.skipInk;
|
|
44
|
+
this.letterSpacing = this.rawLetterSpacing ?? ts.letterSpacing;
|
|
35
45
|
}
|
|
36
46
|
/**
|
|
37
47
|
* Splits the paragraph at line boxes (Slice 1). The lines that fit in `maxHeight` stay;
|
|
@@ -45,11 +55,12 @@ export class TextElement extends SizedPDFElement {
|
|
|
45
55
|
? this.fragmentString(this.content, maxHeight, width, ctx)
|
|
46
56
|
: this.fragmentSegments(this.content, maxHeight, width, ctx);
|
|
47
57
|
}
|
|
48
|
-
// Plain string: every wrapped line
|
|
49
|
-
// so `floor(maxHeight /
|
|
58
|
+
// Plain string: every wrapped line gets the same box (the same one calculateTextHeight uses),
|
|
59
|
+
// so `floor(maxHeight / lineBox)` lines fit.
|
|
50
60
|
fragmentString(content, maxHeight, width, ctx) {
|
|
51
|
-
const lines = wrapStringIntoLines(content, this.fontFamily, this.fontSize, this.fontStyle, width, ctx.metrics, this.maxLines, this.overflow);
|
|
52
|
-
const
|
|
61
|
+
const lines = wrapStringIntoLines(content, this.fontFamily, this.fontSize, this.fontStyle, width, ctx.metrics, this.maxLines, this.overflow, this.letterSpacing);
|
|
62
|
+
const box = lineBoxForString(ctx.metrics, this.fontFamily, this.fontStyle, this.fontSize, this.lineHeight);
|
|
63
|
+
const fittedLineCount = Math.floor(maxHeight / box.height);
|
|
53
64
|
if (fittedLineCount <= 0)
|
|
54
65
|
return { fitted: null, remainder: this };
|
|
55
66
|
if (fittedLineCount >= lines.length)
|
|
@@ -59,18 +70,20 @@ export class TextElement extends SizedPDFElement {
|
|
|
59
70
|
remainder: this.cloneWithContent(lines.slice(fittedLineCount).join(" ")),
|
|
60
71
|
};
|
|
61
72
|
}
|
|
62
|
-
// Styled segments: each line's height
|
|
63
|
-
//
|
|
73
|
+
// Styled segments: each line's height comes from the fonts on it, so pack lines by cumulative
|
|
74
|
+
// box height. Rebuild the fitted/remainder halves back into TextSegment[].
|
|
64
75
|
fragmentSegments(content, maxHeight, width, ctx) {
|
|
65
|
-
const
|
|
76
|
+
const defaults = {
|
|
66
77
|
fontFamily: this.fontFamily,
|
|
67
78
|
fontSize: this.fontSize,
|
|
68
79
|
fontStyle: this.fontStyle,
|
|
69
|
-
|
|
80
|
+
letterSpacing: this.letterSpacing,
|
|
81
|
+
};
|
|
82
|
+
const lines = breakSegmentsIntoLines(content, defaults, width, ctx.metrics, this.maxLines, this.overflow);
|
|
70
83
|
let used = 0;
|
|
71
84
|
let fittedLineCount = 0;
|
|
72
85
|
for (const line of lines) {
|
|
73
|
-
const lineBox = line.
|
|
86
|
+
const lineBox = lineBoxForSegmentLine(line, defaults, ctx.metrics, this.lineHeight).height;
|
|
74
87
|
if (used + lineBox > maxHeight)
|
|
75
88
|
break;
|
|
76
89
|
used += lineBox;
|
|
@@ -98,6 +111,10 @@ export class TextElement extends SizedPDFElement {
|
|
|
98
111
|
maxLines: this.maxLines,
|
|
99
112
|
overflow: this.overflow,
|
|
100
113
|
lineHeight: this.lineHeight,
|
|
114
|
+
underline: this.underline,
|
|
115
|
+
strikethrough: this.strikethrough,
|
|
116
|
+
skipInk: this.skipInk,
|
|
117
|
+
letterSpacing: this.letterSpacing,
|
|
101
118
|
role: this.role,
|
|
102
119
|
}).adoptStructId(this); // a wrapped remainder is the SAME logical paragraph (one P across pages)
|
|
103
120
|
}
|
|
@@ -112,7 +129,7 @@ export class TextElement extends SizedPDFElement {
|
|
|
112
129
|
? constraints.maxWidth
|
|
113
130
|
: this.naturalWidth(ctx.metrics);
|
|
114
131
|
const wrapWidth = this.width ?? 0;
|
|
115
|
-
this.height = TextRenderer.calculateTextHeight(this.content, this.fontSize, this.fontFamily, this.fontStyle, ctx.metrics, wrapWidth, this.maxLines, this.overflow, this.lineHeight);
|
|
132
|
+
this.height = TextRenderer.calculateTextHeight(this.content, this.fontSize, this.fontFamily, this.fontStyle, ctx.metrics, wrapWidth, this.maxLines, this.overflow, this.lineHeight, this.letterSpacing);
|
|
116
133
|
// Top-left coordinates (y = top of the text box). The baseline offset and the
|
|
117
134
|
// Y-flip are applied downstream (the line-builder positions baselines, the seam
|
|
118
135
|
// flips to PDF), so the element stays coordinate-system-blind.
|
|
@@ -127,22 +144,24 @@ export class TextElement extends SizedPDFElement {
|
|
|
127
144
|
* - enough to tip a borderline string (e.g. "20 Jun 2026", wider than "04 Jul 2026" only because
|
|
128
145
|
* 'n' beats 'l') one bit over its own width, dropping the last word onto a second line. */
|
|
129
146
|
naturalWidth(metrics) {
|
|
130
|
-
const oneLine = (text, family, size, style) => {
|
|
147
|
+
const oneLine = (text, family, size, style, letterSpacing) => {
|
|
148
|
+
const font = { fontFamily: family, fontSize: size, fontStyle: style };
|
|
131
149
|
const words = text.split(" ");
|
|
132
|
-
const space = metrics
|
|
150
|
+
const space = runAdvance(metrics, " ", font, letterSpacing);
|
|
133
151
|
let width = 0;
|
|
134
152
|
words.forEach((word, i) => {
|
|
135
|
-
const w = metrics
|
|
136
|
-
// Group (word + space) as one term, exactly like the breaker - see the note above.
|
|
153
|
+
const w = runAdvance(metrics, word, font, letterSpacing);
|
|
154
|
+
// Group (word + space) as one term, exactly like the breaker - see the note above. Both use
|
|
155
|
+
// the same `runAdvance`, so the two agree bit for bit even with letterSpacing.
|
|
137
156
|
width += i < words.length - 1 ? w + space : w;
|
|
138
157
|
});
|
|
139
158
|
return width;
|
|
140
159
|
};
|
|
141
160
|
if (typeof this.content === "string") {
|
|
142
|
-
return oneLine(this.content, this.fontFamily, this.fontSize, this.fontStyle);
|
|
161
|
+
return oneLine(this.content, this.fontFamily, this.fontSize, this.fontStyle, this.letterSpacing);
|
|
143
162
|
}
|
|
144
163
|
return this.content.reduce((sum, seg) => sum +
|
|
145
|
-
oneLine(seg.content, seg.fontFamily ?? this.fontFamily, seg.fontSize ?? this.fontSize, seg.fontStyle ?? this.fontStyle), 0);
|
|
164
|
+
oneLine(seg.content, seg.fontFamily ?? this.fontFamily, seg.fontSize ?? this.fontSize, seg.fontStyle ?? this.fontStyle, seg.letterSpacing ?? this.letterSpacing), 0);
|
|
146
165
|
}
|
|
147
166
|
getProps() {
|
|
148
167
|
return {
|
|
@@ -159,6 +178,10 @@ export class TextElement extends SizedPDFElement {
|
|
|
159
178
|
maxLines: this.maxLines,
|
|
160
179
|
overflow: this.overflow,
|
|
161
180
|
lineHeight: this.lineHeight,
|
|
181
|
+
underline: this.underline,
|
|
182
|
+
strikethrough: this.strikethrough,
|
|
183
|
+
skipInk: this.skipInk,
|
|
184
|
+
letterSpacing: this.letterSpacing,
|
|
162
185
|
role: this.role,
|
|
163
186
|
};
|
|
164
187
|
}
|
|
@@ -12,6 +12,10 @@ import { LayoutContext, PDFElement } from "../elements/pdf-element.js";
|
|
|
12
12
|
export interface FragmentResult {
|
|
13
13
|
fitted: PDFElement | null;
|
|
14
14
|
remainder: PDFElement | null;
|
|
15
|
+
/** The split ended at a FORCED page break (a `PageBreak`), not because the region filled up. The
|
|
16
|
+
* remainder must start a fresh page, and an enclosing flow must stop packing too - so this bubbles
|
|
17
|
+
* up through nested `fragment()` calls. Absent/false for an ordinary height-driven break. */
|
|
18
|
+
forceBreak?: boolean;
|
|
15
19
|
}
|
|
16
20
|
/**
|
|
17
21
|
* An element that knows how to split itself across regions. Width is already finalized
|
|
@@ -29,6 +33,13 @@ export declare function isFragmentable(element: PDFElement): element is PDFEleme
|
|
|
29
33
|
* logs and clips, `"ignore"` clips silently.
|
|
30
34
|
*/
|
|
31
35
|
export type OverflowPolicy = "error" | "warn" | "ignore";
|
|
36
|
+
export declare function reportOverflow(child: PDFElement, childHeight: number, maxHeight: number, policy: OverflowPolicy): void;
|
|
37
|
+
/**
|
|
38
|
+
* True if packing `children` will hit a forced cut: a child that IS a break, contains one deeper in its
|
|
39
|
+
* subtree, or asks to break before/after itself. A flow container reports this from `hasForcedBreak` so
|
|
40
|
+
* its parent fragments it (runs `packChildren`) instead of placing it whole and swallowing the cut.
|
|
41
|
+
*/
|
|
42
|
+
export declare function childrenForceBreak(children: PDFElement[]): boolean;
|
|
32
43
|
/**
|
|
33
44
|
* Packs a vertical stack of children into `maxHeight`, in order. Children are measured
|
|
34
45
|
* against `width` (unbounded height) and added until one would overflow; that straddling
|
|
@@ -46,4 +57,5 @@ export type OverflowPolicy = "error" | "warn" | "ignore";
|
|
|
46
57
|
export declare function packChildren(children: PDFElement[], maxHeight: number, width: number, ctx: LayoutContext, gap?: number): {
|
|
47
58
|
fitted: PDFElement[];
|
|
48
59
|
remainder: PDFElement[];
|
|
60
|
+
forceBreak: boolean;
|
|
49
61
|
};
|
|
@@ -10,7 +10,7 @@ export function isFragmentable(element) {
|
|
|
10
10
|
const canFragment = element.canFragment;
|
|
11
11
|
return typeof canFragment !== "function" || canFragment.call(element);
|
|
12
12
|
}
|
|
13
|
-
function reportOverflow(child, childHeight, maxHeight, policy) {
|
|
13
|
+
export function reportOverflow(child, childHeight, maxHeight, policy) {
|
|
14
14
|
if (policy === "ignore")
|
|
15
15
|
return;
|
|
16
16
|
const name = child.constructor.name.replace(/Element$/, "");
|
|
@@ -21,6 +21,14 @@ function reportOverflow(child, childHeight, maxHeight, policy) {
|
|
|
21
21
|
throw new Error(`Layout overflow: ${detail}`);
|
|
22
22
|
console.warn(`Layout overflow (clipped): ${detail}`);
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* True if packing `children` will hit a forced cut: a child that IS a break, contains one deeper in its
|
|
26
|
+
* subtree, or asks to break before/after itself. A flow container reports this from `hasForcedBreak` so
|
|
27
|
+
* its parent fragments it (runs `packChildren`) instead of placing it whole and swallowing the cut.
|
|
28
|
+
*/
|
|
29
|
+
export function childrenForceBreak(children) {
|
|
30
|
+
return children.some((child) => child.hasForcedBreak() || child.breaksBefore() || child.breaksAfter());
|
|
31
|
+
}
|
|
24
32
|
/**
|
|
25
33
|
* Packs a vertical stack of children into `maxHeight`, in order. Children are measured
|
|
26
34
|
* against `width` (unbounded height) and added until one would overflow; that straddling
|
|
@@ -39,30 +47,61 @@ export function packChildren(children, maxHeight, width, ctx, gap = 0) {
|
|
|
39
47
|
const fitted = [];
|
|
40
48
|
const remainder = [];
|
|
41
49
|
let usedHeight = 0;
|
|
50
|
+
// Everything from index `from` onward spills to the next region; `broke` says whether the cut was
|
|
51
|
+
// a FORCED page break (so an enclosing flow stops too) rather than the region filling up.
|
|
52
|
+
const spill = (from, broke) => {
|
|
53
|
+
for (let j = from; j < children.length; j++)
|
|
54
|
+
remainder.push(children[j]);
|
|
55
|
+
return { fitted, remainder, forceBreak: broke };
|
|
56
|
+
};
|
|
42
57
|
for (let i = 0; i < children.length; i++) {
|
|
43
58
|
const child = children[i];
|
|
59
|
+
// A forced break: consume the marker (it draws nothing) and send everything after it to a fresh
|
|
60
|
+
// page, regardless of how much room is left.
|
|
61
|
+
if (child.isPageBreak())
|
|
62
|
+
return spill(i + 1, true);
|
|
63
|
+
// break-before: this child wants a fresh page. Honour it only if something is already on this region
|
|
64
|
+
// (CSS ignores break-before at the top of a page); that guard also prevents an empty page + a loop.
|
|
65
|
+
if (child.breaksBefore() && fitted.length > 0)
|
|
66
|
+
return spill(i, true);
|
|
44
67
|
const childHeight = child.calculateLayout(BoxConstraints.loose(width, Infinity), { x: 0, y: 0 }, ctx).height;
|
|
45
68
|
// A gap precedes every child except the first one placed in this region.
|
|
46
69
|
const lead = fitted.length > 0 ? gap : 0;
|
|
47
|
-
if
|
|
70
|
+
// Place whole only if it fits AND holds no forced break: a child that fits by height but contains
|
|
71
|
+
// a `PageBreak` (or a break-before/after) in its subtree must still be fragmented, or the break
|
|
72
|
+
// would be swallowed.
|
|
73
|
+
if (usedHeight + lead + childHeight <= maxHeight && !child.hasForcedBreak()) {
|
|
48
74
|
fitted.push(child);
|
|
49
75
|
usedHeight += lead + childHeight;
|
|
76
|
+
// break-after: everything after this child starts a fresh page.
|
|
77
|
+
if (child.breaksAfter())
|
|
78
|
+
return spill(i + 1, true);
|
|
50
79
|
continue;
|
|
51
80
|
}
|
|
52
|
-
// `child` straddles the boundary
|
|
81
|
+
// `child` straddles the boundary, or carries a forced break. Split it; otherwise place/defer whole.
|
|
53
82
|
const remaining = maxHeight - usedHeight - lead;
|
|
54
83
|
let placedPart = false;
|
|
84
|
+
let childBroke = false;
|
|
55
85
|
if (isFragmentable(child)) {
|
|
56
86
|
const split = child.fragment(Math.max(0, remaining), width, ctx);
|
|
87
|
+
childBroke = split.forceBreak ?? false;
|
|
57
88
|
if (split.fitted) {
|
|
58
89
|
fitted.push(split.fitted);
|
|
90
|
+
// The child fully fit and triggered no cut: it only reached this path because its subtree
|
|
91
|
+
// FLAGGED a possible break (hasForcedBreak) that turned out to be a no-op here - e.g. a
|
|
92
|
+
// break-before at the top of the region, which CSS ignores. Keep packing the siblings after
|
|
93
|
+
// it instead of spilling them, so break-before does not accidentally push later content away.
|
|
94
|
+
if (!split.remainder && !childBroke) {
|
|
95
|
+
usedHeight += lead + childHeight;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
59
98
|
if (split.remainder)
|
|
60
99
|
remainder.push(split.remainder);
|
|
61
100
|
placedPart = true;
|
|
62
101
|
}
|
|
63
102
|
}
|
|
64
103
|
if (!placedPart) {
|
|
65
|
-
if (fitted.length === 0) {
|
|
104
|
+
if (fitted.length === 0 && !childBroke) {
|
|
66
105
|
// Taller than the whole region and unsplittable: force it on (it overflows and is clipped)
|
|
67
106
|
// so the next region still advances - and surface it per the overflow policy.
|
|
68
107
|
reportOverflow(child, childHeight, maxHeight, ctx.onOverflow ?? "ignore");
|
|
@@ -72,9 +111,7 @@ export function packChildren(children, maxHeight, width, ctx, gap = 0) {
|
|
|
72
111
|
remainder.push(child);
|
|
73
112
|
}
|
|
74
113
|
}
|
|
75
|
-
|
|
76
|
-
remainder.push(children[j]);
|
|
77
|
-
break;
|
|
114
|
+
return spill(i + 1, childBroke);
|
|
78
115
|
}
|
|
79
|
-
return { fitted, remainder };
|
|
116
|
+
return { fitted, remainder, forceBreak: false };
|
|
80
117
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { KeepTogetherElement } from "../elements/layout/keep-together-element.js";
|
|
2
|
+
import { PDFObjectManager } from "../utils/pdf-object-manager.js";
|
|
3
|
+
import { IRNode } from "../ir/display-list.js";
|
|
4
|
+
/**
|
|
5
|
+
* A `keepTogether` is layout-transparent: it just renders its child. All of its work happens in the
|
|
6
|
+
* layout pass (`fragment`), never here.
|
|
7
|
+
*/
|
|
8
|
+
export declare class KeepTogetherRenderer {
|
|
9
|
+
static render(group: KeepTogetherElement, objectManager: PDFObjectManager): Promise<IRNode[]>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { RendererRegistry } from "../utils/renderer-registry.js";
|
|
2
|
+
/**
|
|
3
|
+
* A `keepTogether` is layout-transparent: it just renders its child. All of its work happens in the
|
|
4
|
+
* layout pass (`fragment`), never here.
|
|
5
|
+
*/
|
|
6
|
+
export class KeepTogetherRenderer {
|
|
7
|
+
static async render(group, objectManager) {
|
|
8
|
+
const { child } = group.getProps();
|
|
9
|
+
const renderer = RendererRegistry.getRenderer(child);
|
|
10
|
+
return renderer ? await renderer(child, objectManager) : [];
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { PDFObjectManager } from "../utils/pdf-object-manager.js";
|
|
2
|
+
import { PageBreakElement } from "../elements/layout/page-break-element.js";
|
|
3
|
+
import { IRNode } from "../ir/display-list.js";
|
|
4
|
+
/**
|
|
5
|
+
* A `PageBreak` draws nothing - the pagination packer consumes it at the cut, so in a vertical flow it
|
|
6
|
+
* never reaches this pass. Reaching it therefore means the break was ineffective: it sat somewhere that
|
|
7
|
+
* does not paginate, like inside a horizontal `Row` (a row is one line, you cannot split it across
|
|
8
|
+
* pages - CSS and react-pdf ignore a forced break there too). We degrade to drawing nothing, but warn
|
|
9
|
+
* once so the mistake is not silent. This one choke-point catches every ineffective break; no element
|
|
10
|
+
* needs a special case.
|
|
11
|
+
*/
|
|
12
|
+
export declare class PageBreakRenderer {
|
|
13
|
+
static render(_element: PageBreakElement, _objectManager: PDFObjectManager): Promise<IRNode[]>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `PageBreak` draws nothing - the pagination packer consumes it at the cut, so in a vertical flow it
|
|
3
|
+
* never reaches this pass. Reaching it therefore means the break was ineffective: it sat somewhere that
|
|
4
|
+
* does not paginate, like inside a horizontal `Row` (a row is one line, you cannot split it across
|
|
5
|
+
* pages - CSS and react-pdf ignore a forced break there too). We degrade to drawing nothing, but warn
|
|
6
|
+
* once so the mistake is not silent. This one choke-point catches every ineffective break; no element
|
|
7
|
+
* needs a special case.
|
|
8
|
+
*/
|
|
9
|
+
export class PageBreakRenderer {
|
|
10
|
+
static async render(_element, _objectManager) {
|
|
11
|
+
console.warn("PageBreak had no effect: it is not in a paginating flow (e.g. it is inside a Row, which is a " +
|
|
12
|
+
"single horizontal line). Put the break as a sibling in a Column to break after that content.");
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -39,6 +39,13 @@ export declare class PdfBackend {
|
|
|
39
39
|
* text closes the string early and the remaining characters leak as raw operators.
|
|
40
40
|
*/
|
|
41
41
|
static escapePdfString(text: string): string;
|
|
42
|
+
/**
|
|
43
|
+
* Builds the `[ ... ]` operand of a `TJ` from a run and its per-gap kern adjustments (em/1000, the
|
|
44
|
+
* sign the font declares - negative tightens). A `TJ` number moves the pen LEFT, so it is the
|
|
45
|
+
* NEGATED kern. Consecutive un-kerned glyphs stay in one encoded chunk, so `[(T) 40 (otal)]` rather
|
|
46
|
+
* than one chunk per glyph. `kerns[i]` is the adjustment AFTER code point `i`.
|
|
47
|
+
*/
|
|
48
|
+
static kernedArray(text: string, kerns: number[], encode: (chunk: string) => string): string;
|
|
42
49
|
/**
|
|
43
50
|
* Returns the `/GSx gs` operator that selects a transparency state, or `""` when both
|
|
44
51
|
* alphas are fully opaque. Opaque draws emit nothing here, so output stays
|
|
@@ -152,6 +152,27 @@ export class PdfBackend {
|
|
|
152
152
|
static escapePdfString(text) {
|
|
153
153
|
return text.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
|
|
154
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* Builds the `[ ... ]` operand of a `TJ` from a run and its per-gap kern adjustments (em/1000, the
|
|
157
|
+
* sign the font declares - negative tightens). A `TJ` number moves the pen LEFT, so it is the
|
|
158
|
+
* NEGATED kern. Consecutive un-kerned glyphs stay in one encoded chunk, so `[(T) 40 (otal)]` rather
|
|
159
|
+
* than one chunk per glyph. `kerns[i]` is the adjustment AFTER code point `i`.
|
|
160
|
+
*/
|
|
161
|
+
static kernedArray(text, kerns, encode) {
|
|
162
|
+
const chars = [...text];
|
|
163
|
+
let out = "";
|
|
164
|
+
let chunk = chars[0] ?? "";
|
|
165
|
+
for (let i = 0; i < kerns.length; i++) {
|
|
166
|
+
if (kerns[i] === 0) {
|
|
167
|
+
chunk += chars[i + 1];
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
out += encode(chunk) + ` ${-kerns[i]} `;
|
|
171
|
+
chunk = chars[i + 1];
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return `[${out}${encode(chunk)}]`;
|
|
175
|
+
}
|
|
155
176
|
/**
|
|
156
177
|
* Returns the `/GSx gs` operator that selects a transparency state, or `""` when both
|
|
157
178
|
* alphas are fully opaque. Opaque draws emit nothing here, so output stays
|
|
@@ -242,18 +263,31 @@ export class PdfBackend {
|
|
|
242
263
|
const font = isCustom
|
|
243
264
|
? om.getCustomFontResource(node.fontFamily, node.fontStyle)
|
|
244
265
|
: om.registerFont(node.fontFamily, node.fontStyle);
|
|
245
|
-
const
|
|
246
|
-
? `<${om.encodeCustomText(node.fontFamily,
|
|
247
|
-
: `(${PdfBackend.escapePdfString(
|
|
266
|
+
const encode = (t) => isCustom
|
|
267
|
+
? `<${om.encodeCustomText(node.fontFamily, t, node.fontStyle)}>`
|
|
268
|
+
: `(${PdfBackend.escapePdfString(t)})`;
|
|
269
|
+
// Kerning (if the document has it on): a `TJ` array with the font's per-pair adjustments
|
|
270
|
+
// between glyph chunks, measured into the layout by the SAME numbers (runAdvance). A plain
|
|
271
|
+
// `Tj` when the run has no kerning, byte-identical.
|
|
272
|
+
const kerns = om.kerningEnabled
|
|
273
|
+
? om.getKernPairs(node.text, node.fontFamily, node.fontStyle)
|
|
274
|
+
: [];
|
|
275
|
+
const showOp = kerns.some((k) => k !== 0)
|
|
276
|
+
? `${PdfBackend.kernedArray(node.text, kerns, encode)} TJ`
|
|
277
|
+
: `${encode(node.text)} Tj`;
|
|
278
|
+
// letterSpacing is the `Tc` operator: extra advance after EVERY glyph (an embedded
|
|
279
|
+
// Identity-H font too - that is `Tw`, word spacing, which only touches single-byte code 32).
|
|
280
|
+
const spacing = node.letterSpacing ? `${node.letterSpacing.toFixed(3)} Tc\n` : "";
|
|
248
281
|
const block = `BT\n` +
|
|
249
282
|
`${node.color.toPDFColorString()} rg ` +
|
|
250
283
|
`/F${font.fontIndex} ${node.fontSize} Tf ` +
|
|
251
284
|
`${node.x.toFixed(3)} ${node.y.toFixed(3)} Td ` +
|
|
252
|
-
`${
|
|
285
|
+
`${showOp}\n` +
|
|
253
286
|
`ET\n`;
|
|
254
|
-
// Transparent text gets an isolating q/Q + gs; opaque text is byte-identical.
|
|
255
287
|
const gs = PdfBackend.alphaPrefix(om, node.color.getAlpha(), 1);
|
|
256
|
-
|
|
288
|
+
// `Tc` is graphics state and would leak into the next run, so isolate it in q/Q - the same
|
|
289
|
+
// reason transparent text is isolated. A run with neither stays byte-identical.
|
|
290
|
+
return gs || spacing ? `q\n${gs}${spacing}${block}Q\n` : block;
|
|
257
291
|
}
|
|
258
292
|
case "image": {
|
|
259
293
|
// The backend owns PDF resource creation: register the XObject (using the
|
|
@@ -88,6 +88,9 @@ export class PDFDocument {
|
|
|
88
88
|
}
|
|
89
89
|
if (config)
|
|
90
90
|
this._objectManager.changePDFConfig(config);
|
|
91
|
+
// Kerning is on by default at the document level (the `renderToBytes` path sets the same). A
|
|
92
|
+
// bare `PDFObjectManager` still defaults OFF, so unit tests keep predictable un-kerned metrics.
|
|
93
|
+
this._objectManager.setKerning(true);
|
|
91
94
|
}
|
|
92
95
|
get objectManager() {
|
|
93
96
|
return this._objectManager;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { layoutPageBands, PageElement, resolvePageSize, } from "../elements/page-element.js";
|
|
1
|
+
import { layoutPageBands, pageFrame, PageElement, resolvePageSize, } from "../elements/page-element.js";
|
|
2
2
|
import { BoxConstraints } from "../layout/box-constraints.js";
|
|
3
|
-
import { isFragmentable } from "../layout/fragmentation.js";
|
|
3
|
+
import { isFragmentable, reportOverflow } from "../layout/fragmentation.js";
|
|
4
4
|
import { PageRenderer } from "./page-renderer.js";
|
|
5
5
|
export class PDFDocumentRenderer {
|
|
6
6
|
static async render(document, objectManager, ctx) {
|
|
@@ -66,13 +66,19 @@ export class PDFDocumentRenderer {
|
|
|
66
66
|
}
|
|
67
67
|
// Header/footer repeat on every physical page, so the body only ever flows into the
|
|
68
68
|
// band between them. Resolve that band once (config is already merged by pass 1).
|
|
69
|
+
// The frame is a throwaway: this pass only wants the band HEIGHTS, and an out-of-flow child
|
|
70
|
+
// contributes none. It exists because a `Positioned` in a band demands one; pass 2 builds the
|
|
71
|
+
// real frame (in `PageElement`) and drains it.
|
|
69
72
|
const pageCtx = {
|
|
70
73
|
metrics: ctx.metrics,
|
|
71
74
|
pageConfig: config,
|
|
72
75
|
textStyle: ctx.textStyle,
|
|
73
76
|
onOverflow: ctx.onOverflow,
|
|
77
|
+
frame: pageFrame(config),
|
|
74
78
|
};
|
|
75
79
|
const { bodyWidth: width, bodyHeight: height } = layoutPageBands(config, header, footer, pageCtx);
|
|
80
|
+
// A `keepTogether` group compares its height against a FULL fresh page (this band), so thread it in.
|
|
81
|
+
pageCtx.pageBodyHeight = height;
|
|
76
82
|
const pages = [];
|
|
77
83
|
let region = children[0];
|
|
78
84
|
let isFirstRegion = true;
|
|
@@ -84,17 +90,30 @@ export class PDFDocumentRenderer {
|
|
|
84
90
|
}
|
|
85
91
|
// pageCtx, not the document ctx: a descendant that reads `pageConfig` while being measured (a
|
|
86
92
|
// `PageBuilder` sizing its provisional build) must see THIS page's geometry, not the document default.
|
|
87
|
-
const { fitted, remainder } = region.fragment(height, width, pageCtx);
|
|
93
|
+
const { fitted, remainder, forceBreak } = region.fragment(height, width, pageCtx);
|
|
88
94
|
// Everything fits on one page: keep the ORIGINAL page so output is unchanged. Measuring inside
|
|
89
95
|
// fragment() left its children at the measuring origin; pass B lays the page out again, which
|
|
90
|
-
// restores their real positions (layout is deterministic).
|
|
91
|
-
|
|
96
|
+
// restores their real positions (layout is deterministic). A consumed trailing `PageBreak`
|
|
97
|
+
// (forceBreak with no remainder) is the exception: render the packed `fitted` instead, which no
|
|
98
|
+
// longer holds the break marker, so the original with the break does not leak into the render pass.
|
|
99
|
+
if (isFirstRegion && remainder === null && !forceBreak) {
|
|
92
100
|
pages.push({ kind: "whole", page });
|
|
93
101
|
break;
|
|
94
102
|
}
|
|
95
|
-
|
|
96
|
-
|
|
103
|
+
// TERMINATION GUARANTEE. Every page here has the full body height, so `fitted === null` means
|
|
104
|
+
// nothing fit even on a whole page - the region did not get smaller. Advancing to the
|
|
105
|
+
// (identical) remainder would loop forever. So we stop: place the region whole (clipped to the
|
|
106
|
+
// page) and surface it per the overflow policy. A step that shrinks nothing ends the loop, for
|
|
107
|
+
// ANY region - an oversized unbreakable block, a future keep-together group, or an engine bug.
|
|
108
|
+
if (fitted === null) {
|
|
109
|
+
// Measure the region's true height for the message (rare path, so the extra pass is free);
|
|
110
|
+
// pass B lays it out again, so this mutation does not leak.
|
|
111
|
+
const needed = region.calculateLayout(BoxConstraints.loose(width, Infinity), { x: 0, y: 0 }, pageCtx).height;
|
|
112
|
+
reportOverflow(region, needed, height, pageCtx.onOverflow ?? "ignore");
|
|
113
|
+
pages.push({ kind: "fragment", config, content: region, header, footer });
|
|
114
|
+
break;
|
|
97
115
|
}
|
|
116
|
+
pages.push({ kind: "fragment", config, content: fitted, header, footer });
|
|
98
117
|
region = remainder;
|
|
99
118
|
isFirstRegion = false;
|
|
100
119
|
}
|
|
@@ -26,7 +26,11 @@ import { LinkRenderer } from "./link-renderer.js";
|
|
|
26
26
|
import { BookmarkElement } from "../elements/layout/bookmark-element.js";
|
|
27
27
|
import { BookmarkRenderer } from "./bookmark-renderer.js";
|
|
28
28
|
import { AnchorElement } from "../elements/layout/anchor-element.js";
|
|
29
|
+
import { PageBreakElement } from "../elements/layout/page-break-element.js";
|
|
29
30
|
import { AnchorRenderer } from "./anchor-renderer.js";
|
|
31
|
+
import { PageBreakRenderer } from "./page-break-renderer.js";
|
|
32
|
+
import { KeepTogetherElement } from "../elements/layout/keep-together-element.js";
|
|
33
|
+
import { KeepTogetherRenderer } from "./keep-together-renderer.js";
|
|
30
34
|
import { PageBuilderElement } from "../elements/layout/page-builder-element.js";
|
|
31
35
|
import { PageBuilderRenderer } from "./page-builder-renderer.js";
|
|
32
36
|
import { RotatedElement } from "../elements/layout/rotated-element.js";
|
|
@@ -45,6 +49,8 @@ export class PDFRenderer {
|
|
|
45
49
|
RendererRegistry.register(RectangleElement, RectangleRenderer.render);
|
|
46
50
|
RendererRegistry.register(ExpandedElement, ExpandedRenderer.render);
|
|
47
51
|
RendererRegistry.register(PaddingElement, PaddingRenderer.render);
|
|
52
|
+
RendererRegistry.register(PageBreakElement, PageBreakRenderer.render);
|
|
53
|
+
RendererRegistry.register(KeepTogetherElement, KeepTogetherRenderer.render);
|
|
48
54
|
RendererRegistry.register(DefaultTextStyleElement, DefaultTextStyleRenderer.render);
|
|
49
55
|
RendererRegistry.register(ImageElement, ImageRenderer.render);
|
|
50
56
|
RendererRegistry.register(LineElement, LineRenderer.render);
|
|
@@ -4,7 +4,7 @@ import type { FontMetrics } from "../utils/font-metrics.js";
|
|
|
4
4
|
import { IRNode } from "../ir/display-list.js";
|
|
5
5
|
import { TextOverflow } from "../text/line-breaker.js";
|
|
6
6
|
export declare class TextRenderer {
|
|
7
|
-
static calculateTextHeight(content: string | TextSegment[], fontSize: number, fontFamily: string, fontStyle: FontStyle, objectManager: FontMetrics, maxWidth: number, maxLines?: number, overflow?: TextOverflow, lineHeight?: number): number;
|
|
7
|
+
static calculateTextHeight(content: string | TextSegment[], fontSize: number, fontFamily: string, fontStyle: FontStyle, objectManager: FontMetrics, maxWidth: number, maxLines?: number, overflow?: TextOverflow, lineHeight?: number, letterSpacing?: number): number;
|
|
8
8
|
static render(textElement: TextElement, objectManager: PDFObjectManager): Promise<IRNode[]>;
|
|
9
9
|
private static _expandColorGlyphs;
|
|
10
10
|
private static _glyphToPath;
|