@jasy/pdf 1.0.0-alpha.6 → 1.0.0-alpha.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -7
- package/dist/api/descriptor.js +26 -1
- package/dist/api/forms.d.ts +347 -0
- package/dist/api/forms.js +189 -0
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/layout.d.ts +47 -14
- package/dist/api/layout.js +47 -11
- package/dist/api/structure.d.ts +11 -0
- package/dist/api/structure.js +2 -0
- package/dist/api/text.d.ts +18 -2
- package/dist/api/text.js +15 -0
- package/dist/crypto/legacy-handler.d.ts +43 -0
- package/dist/crypto/legacy-handler.js +145 -0
- package/dist/crypto/security-handler.d.ts +16 -0
- package/dist/crypto/security-handler.js +26 -0
- package/dist/edit/acroform-reader.d.ts +70 -0
- package/dist/edit/acroform-reader.js +126 -0
- package/dist/edit/appearance.d.ts +47 -0
- package/dist/edit/appearance.js +171 -0
- package/dist/edit/document.d.ts +135 -0
- package/dist/edit/document.js +649 -0
- package/dist/edit/fill.d.ts +48 -0
- package/dist/edit/fill.js +395 -0
- package/dist/edit/flatten.d.ts +42 -0
- package/dist/edit/flatten.js +437 -0
- package/dist/edit/index.d.ts +18 -0
- package/dist/edit/index.js +16 -0
- package/dist/edit/lexer.d.ts +30 -0
- package/dist/edit/lexer.js +285 -0
- package/dist/edit/objects.d.ts +63 -0
- package/dist/edit/objects.js +56 -0
- package/dist/edit/writer.d.ts +53 -0
- package/dist/edit/writer.js +261 -0
- package/dist/elements/container-element.d.ts +10 -1
- package/dist/elements/container-element.js +26 -8
- package/dist/elements/forms/checkbox-element.d.ts +51 -0
- package/dist/elements/forms/checkbox-element.js +48 -0
- package/dist/elements/forms/choice-element.d.ts +53 -0
- package/dist/elements/forms/choice-element.js +59 -0
- package/dist/elements/forms/push-button-element.d.ts +56 -0
- package/dist/elements/forms/push-button-element.js +49 -0
- package/dist/elements/forms/radio-element.d.ts +52 -0
- package/dist/elements/forms/radio-element.js +49 -0
- package/dist/elements/forms/signature-element.d.ts +54 -0
- package/dist/elements/forms/signature-element.js +47 -0
- package/dist/elements/forms/text-field-element.d.ts +61 -0
- package/dist/elements/forms/text-field-element.js +54 -0
- package/dist/elements/index.d.ts +8 -0
- package/dist/elements/index.js +8 -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/forms/acroform.d.ts +63 -0
- package/dist/forms/acroform.js +427 -0
- package/dist/forms/appearance.d.ts +58 -0
- package/dist/forms/appearance.js +204 -0
- package/dist/forms/field.d.ts +164 -0
- package/dist/forms/field.js +1 -0
- package/dist/forms/pdf.d.ts +19 -0
- package/dist/forms/pdf.js +28 -0
- package/dist/ir/display-list.d.ts +18 -1
- package/dist/layout/fragmentation.d.ts +12 -0
- package/dist/layout/fragmentation.js +45 -8
- package/dist/renderer/form-field-renderer.d.ts +20 -0
- package/dist/renderer/form-field-renderer.js +12 -0
- 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/page-renderer.js +8 -2
- package/dist/renderer/pdf-backend.d.ts +7 -0
- package/dist/renderer/pdf-backend.js +48 -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 +25 -1
- 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/dest-registry.d.ts +5 -2
- package/dist/utils/dest-registry.js +5 -5
- package/dist/utils/font-metrics.d.ts +14 -0
- package/dist/utils/outline.js +1 -2
- package/dist/utils/pdf-object-manager.d.ts +45 -1
- package/dist/utils/pdf-object-manager.js +193 -26
- package/dist/utils/struct-tree.js +2 -3
- package/dist/utils/ttf-parser.d.ts +53 -0
- package/dist/utils/ttf-parser.js +368 -0
- package/dist/validators/element-validator.js +7 -3
- package/package.json +9 -2
|
@@ -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
|
+
}
|
|
@@ -74,14 +74,20 @@ export class PageRenderer {
|
|
|
74
74
|
const num = (n) => Number(n.toFixed(2));
|
|
75
75
|
const annotRefs = [];
|
|
76
76
|
for (const node of flipped) {
|
|
77
|
+
// A form field: build its Widget annotation + register it as an /AcroForm field (the logic lives in
|
|
78
|
+
// forms/acroform.ts), then reference it here in the page /Annots - the same side channel as a link.
|
|
79
|
+
if (node.type === "formfield") {
|
|
80
|
+
annotRefs.push(`${objectManager.acroform.addField(node, objectManager)} 0 R`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
77
83
|
if (node.type !== "link")
|
|
78
84
|
continue;
|
|
79
85
|
const rect = `[${num(node.x)} ${num(node.y)} ${num(node.x + node.width)} ${num(node.y + node.height)}]`;
|
|
80
86
|
// Either an external URL (/URI) or an internal named destination (/GoTo, resolved via /Names /Dests).
|
|
81
87
|
// The Link IR guarantees exactly one is set; prefer `dest` when present.
|
|
82
88
|
const action = node.dest !== undefined
|
|
83
|
-
? `/A << /S /GoTo /D
|
|
84
|
-
: `/A << /S /URI /URI
|
|
89
|
+
? `/A << /S /GoTo /D ${objectManager.pdfString(node.dest)} >>`
|
|
90
|
+
: `/A << /S /URI /URI ${objectManager.pdfString(node.href ?? "")} >>`;
|
|
85
91
|
const annot = objectManager.addObject(`<< /Type /Annot /Subtype /Link /Rect ${rect} /Border [0 0 0] ${action} >>`);
|
|
86
92
|
annotRefs.push(`${annot} 0 R`);
|
|
87
93
|
}
|
|
@@ -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
|
|
@@ -65,6 +65,9 @@ export class PdfBackend {
|
|
|
65
65
|
case "link":
|
|
66
66
|
// A link's clickable rect flips around its bottom edge, exactly like a rect.
|
|
67
67
|
return { ...node, y: pageHeight - node.y - node.height };
|
|
68
|
+
case "formfield":
|
|
69
|
+
// A form field's box flips around its bottom edge, exactly like a link rect.
|
|
70
|
+
return { ...node, y: pageHeight - node.y - node.height };
|
|
68
71
|
case "outline":
|
|
69
72
|
// An outline anchor is a single point (the target's top); flip it like a text baseline.
|
|
70
73
|
return { ...node, y: pageHeight - node.y };
|
|
@@ -137,6 +140,7 @@ export class PdfBackend {
|
|
|
137
140
|
node.type === "link" ||
|
|
138
141
|
node.type === "outline" ||
|
|
139
142
|
node.type === "anchor" ||
|
|
143
|
+
node.type === "formfield" ||
|
|
140
144
|
ops === "")
|
|
141
145
|
return ops;
|
|
142
146
|
const { open, close } = struct.mark(node.tag);
|
|
@@ -152,6 +156,27 @@ export class PdfBackend {
|
|
|
152
156
|
static escapePdfString(text) {
|
|
153
157
|
return text.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
|
|
154
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* Builds the `[ ... ]` operand of a `TJ` from a run and its per-gap kern adjustments (em/1000, the
|
|
161
|
+
* sign the font declares - negative tightens). A `TJ` number moves the pen LEFT, so it is the
|
|
162
|
+
* NEGATED kern. Consecutive un-kerned glyphs stay in one encoded chunk, so `[(T) 40 (otal)]` rather
|
|
163
|
+
* than one chunk per glyph. `kerns[i]` is the adjustment AFTER code point `i`.
|
|
164
|
+
*/
|
|
165
|
+
static kernedArray(text, kerns, encode) {
|
|
166
|
+
const chars = [...text];
|
|
167
|
+
let out = "";
|
|
168
|
+
let chunk = chars[0] ?? "";
|
|
169
|
+
for (let i = 0; i < kerns.length; i++) {
|
|
170
|
+
if (kerns[i] === 0) {
|
|
171
|
+
chunk += chars[i + 1];
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
out += encode(chunk) + ` ${-kerns[i]} `;
|
|
175
|
+
chunk = chars[i + 1];
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return `[${out}${encode(chunk)}]`;
|
|
179
|
+
}
|
|
155
180
|
/**
|
|
156
181
|
* Returns the `/GSx gs` operator that selects a transparency state, or `""` when both
|
|
157
182
|
* alphas are fully opaque. Opaque draws emit nothing here, so output stays
|
|
@@ -242,18 +267,31 @@ export class PdfBackend {
|
|
|
242
267
|
const font = isCustom
|
|
243
268
|
? om.getCustomFontResource(node.fontFamily, node.fontStyle)
|
|
244
269
|
: om.registerFont(node.fontFamily, node.fontStyle);
|
|
245
|
-
const
|
|
246
|
-
? `<${om.encodeCustomText(node.fontFamily,
|
|
247
|
-
: `(${PdfBackend.escapePdfString(
|
|
270
|
+
const encode = (t) => isCustom
|
|
271
|
+
? `<${om.encodeCustomText(node.fontFamily, t, node.fontStyle)}>`
|
|
272
|
+
: `(${PdfBackend.escapePdfString(t)})`;
|
|
273
|
+
// Kerning (if the document has it on): a `TJ` array with the font's per-pair adjustments
|
|
274
|
+
// between glyph chunks, measured into the layout by the SAME numbers (runAdvance). A plain
|
|
275
|
+
// `Tj` when the run has no kerning, byte-identical.
|
|
276
|
+
const kerns = om.kerningEnabled
|
|
277
|
+
? om.getKernPairs(node.text, node.fontFamily, node.fontStyle)
|
|
278
|
+
: [];
|
|
279
|
+
const showOp = kerns.some((k) => k !== 0)
|
|
280
|
+
? `${PdfBackend.kernedArray(node.text, kerns, encode)} TJ`
|
|
281
|
+
: `${encode(node.text)} Tj`;
|
|
282
|
+
// letterSpacing is the `Tc` operator: extra advance after EVERY glyph (an embedded
|
|
283
|
+
// Identity-H font too - that is `Tw`, word spacing, which only touches single-byte code 32).
|
|
284
|
+
const spacing = node.letterSpacing ? `${node.letterSpacing.toFixed(3)} Tc\n` : "";
|
|
248
285
|
const block = `BT\n` +
|
|
249
286
|
`${node.color.toPDFColorString()} rg ` +
|
|
250
287
|
`/F${font.fontIndex} ${node.fontSize} Tf ` +
|
|
251
288
|
`${node.x.toFixed(3)} ${node.y.toFixed(3)} Td ` +
|
|
252
|
-
`${
|
|
289
|
+
`${showOp}\n` +
|
|
253
290
|
`ET\n`;
|
|
254
|
-
// Transparent text gets an isolating q/Q + gs; opaque text is byte-identical.
|
|
255
291
|
const gs = PdfBackend.alphaPrefix(om, node.color.getAlpha(), 1);
|
|
256
|
-
|
|
292
|
+
// `Tc` is graphics state and would leak into the next run, so isolate it in q/Q - the same
|
|
293
|
+
// reason transparent text is isolated. A run with neither stays byte-identical.
|
|
294
|
+
return gs || spacing ? `q\n${gs}${spacing}${block}Q\n` : block;
|
|
257
295
|
}
|
|
258
296
|
case "image": {
|
|
259
297
|
// The backend owns PDF resource creation: register the XObject (using the
|
|
@@ -323,6 +361,10 @@ export class PdfBackend {
|
|
|
323
361
|
case "anchor":
|
|
324
362
|
// A named destination draws nothing - it becomes a /Names /Dests entry (built in PageRenderer/PDFRenderer).
|
|
325
363
|
return "";
|
|
364
|
+
case "formfield":
|
|
365
|
+
// A form field draws nothing in the content stream - it becomes a Widget /Annot + /AcroForm field
|
|
366
|
+
// (built in PageRenderer via forms/acroform.ts).
|
|
367
|
+
return "";
|
|
326
368
|
default: {
|
|
327
369
|
// Exhaustiveness guard: if a new IRNode variant is added, this fails to compile.
|
|
328
370
|
const unknown = node;
|
|
@@ -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,18 @@ 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";
|
|
34
|
+
import { TextFieldElement } from "../elements/forms/text-field-element.js";
|
|
35
|
+
import { CheckboxElement } from "../elements/forms/checkbox-element.js";
|
|
36
|
+
import { RadioElement } from "../elements/forms/radio-element.js";
|
|
37
|
+
import { ChoiceElement } from "../elements/forms/choice-element.js";
|
|
38
|
+
import { PushButtonElement } from "../elements/forms/push-button-element.js";
|
|
39
|
+
import { SignatureFieldElement } from "../elements/forms/signature-element.js";
|
|
40
|
+
import { FormFieldRenderer } from "./form-field-renderer.js";
|
|
30
41
|
import { PageBuilderElement } from "../elements/layout/page-builder-element.js";
|
|
31
42
|
import { PageBuilderRenderer } from "./page-builder-renderer.js";
|
|
32
43
|
import { RotatedElement } from "../elements/layout/rotated-element.js";
|
|
@@ -45,6 +56,14 @@ export class PDFRenderer {
|
|
|
45
56
|
RendererRegistry.register(RectangleElement, RectangleRenderer.render);
|
|
46
57
|
RendererRegistry.register(ExpandedElement, ExpandedRenderer.render);
|
|
47
58
|
RendererRegistry.register(PaddingElement, PaddingRenderer.render);
|
|
59
|
+
RendererRegistry.register(PageBreakElement, PageBreakRenderer.render);
|
|
60
|
+
RendererRegistry.register(KeepTogetherElement, KeepTogetherRenderer.render);
|
|
61
|
+
RendererRegistry.register(TextFieldElement, FormFieldRenderer.render);
|
|
62
|
+
RendererRegistry.register(CheckboxElement, FormFieldRenderer.render);
|
|
63
|
+
RendererRegistry.register(RadioElement, FormFieldRenderer.render);
|
|
64
|
+
RendererRegistry.register(ChoiceElement, FormFieldRenderer.render);
|
|
65
|
+
RendererRegistry.register(PushButtonElement, FormFieldRenderer.render);
|
|
66
|
+
RendererRegistry.register(SignatureFieldElement, FormFieldRenderer.render);
|
|
48
67
|
RendererRegistry.register(DefaultTextStyleElement, DefaultTextStyleRenderer.render);
|
|
49
68
|
RendererRegistry.register(ImageElement, ImageRenderer.render);
|
|
50
69
|
RendererRegistry.register(LineElement, LineRenderer.render);
|
|
@@ -118,8 +137,13 @@ export class PDFRenderer {
|
|
|
118
137
|
const outlineCatalog = objectManager.outline.finalize(objectManager);
|
|
119
138
|
if (outlineCatalog)
|
|
120
139
|
catalogParts.push(outlineCatalog);
|
|
140
|
+
// Interactive form fields: emit the catalog /AcroForm dict collected as pages rendered. Returns ""
|
|
141
|
+
// (no-op) when no field was placed, so a plain document's catalog is unchanged.
|
|
142
|
+
const acroformCatalog = objectManager.acroform.finalize(objectManager);
|
|
143
|
+
if (acroformCatalog)
|
|
144
|
+
catalogParts.push(acroformCatalog);
|
|
121
145
|
// Named destinations (internal-link targets): a /Dests entry in the shared /Names dict.
|
|
122
|
-
const destsNames = objectManager.dests.finalize();
|
|
146
|
+
const destsNames = objectManager.dests.finalize(objectManager);
|
|
123
147
|
if (destsNames)
|
|
124
148
|
namesParts.push(destsNames);
|
|
125
149
|
// Emit the single /Names dict if either embedded files or destinations contributed to it.
|
|
@@ -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;
|
|
@@ -3,28 +3,30 @@ import { HorizontalAlignment } from "../elements/pdf-element.js";
|
|
|
3
3
|
import { isEmojiCodePoint } from "../text/emoji-codepoints.js";
|
|
4
4
|
import { emojiImageNode } from "./emoji-image.js";
|
|
5
5
|
import { wrapStringIntoLines, breakSegmentsIntoLines, } from "../text/line-breaker.js";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
import { lineBoxForSegmentLine, lineBoxForString } from "../text/line-metrics.js";
|
|
7
|
+
import { runAdvance } from "../text/advance.js";
|
|
8
|
+
import { skipInkSegments, strikethroughStroke, underlineStroke, } from "../text/text-decoration.js";
|
|
9
9
|
export class TextRenderer {
|
|
10
10
|
// Measuring only needs metrics, not the full object manager. (The render pass below
|
|
11
11
|
// still receives the manager because it also registers fonts/images.)
|
|
12
|
-
static calculateTextHeight(content, fontSize, fontFamily, fontStyle, objectManager, maxWidth, maxLines, overflow, lineHeight =
|
|
13
|
-
// Plain string:
|
|
12
|
+
static calculateTextHeight(content, fontSize, fontFamily, fontStyle, objectManager, maxWidth, maxLines, overflow, lineHeight, letterSpacing = 0) {
|
|
13
|
+
// Plain string: every wrapped line gets the same box.
|
|
14
14
|
if (typeof content === "string") {
|
|
15
|
-
const lines = wrapStringIntoLines(content, fontFamily, fontSize, fontStyle, maxWidth, objectManager, maxLines, overflow);
|
|
16
|
-
|
|
15
|
+
const lines = wrapStringIntoLines(content, fontFamily, fontSize, fontStyle, maxWidth, objectManager, maxLines, overflow, letterSpacing);
|
|
16
|
+
const box = lineBoxForString(objectManager, fontFamily, fontStyle, fontSize, lineHeight);
|
|
17
|
+
return lines.length * box.height;
|
|
17
18
|
}
|
|
18
|
-
// Segments: each line
|
|
19
|
-
const
|
|
20
|
-
|
|
19
|
+
// Segments: each line's box comes from the fonts actually on it.
|
|
20
|
+
const defaults = { fontFamily, fontSize, fontStyle, letterSpacing };
|
|
21
|
+
const lines = breakSegmentsIntoLines(content, defaults, maxWidth, objectManager, maxLines, overflow);
|
|
22
|
+
return lines.reduce((total, line) => total + lineBoxForSegmentLine(line, defaults, objectManager, lineHeight).height, 0);
|
|
21
23
|
}
|
|
22
24
|
static async render(textElement, objectManager) {
|
|
23
|
-
const { x, y, width, fontSize, color, content, fontFamily, fontStyle, textAlignment, maxLines, overflow, lineHeight, role, } = textElement.getProps();
|
|
25
|
+
const { x, y, width, fontSize, color, content, fontFamily, fontStyle, textAlignment, maxLines, overflow, lineHeight, underline, strikethrough, skipInk, letterSpacing, role, } = textElement.getProps();
|
|
24
26
|
// Component -> display list. Wrapping and positioning stay here; the backend
|
|
25
27
|
// turns each run into BT/Tf/Td/Tj/ET. The wrapping algorithm is unchanged from
|
|
26
28
|
// the original renderer - unifying it into the engine is Phase 3.
|
|
27
|
-
const { runs, links } = TextRenderer._buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, width ?? Number.NaN, textAlignment, color, x, y, maxLines, overflow, lineHeight);
|
|
29
|
+
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);
|
|
28
30
|
// Accessible tagging: this whole text block is one structure element (a paragraph P, or a heading
|
|
29
31
|
// from `role`); each run's text lands under it. Emoji sub-nodes produced below stay untagged (drawn
|
|
30
32
|
// as artifacts). Keyed by structId so a block split across pages stays one element.
|
|
@@ -37,9 +39,11 @@ export class TextRenderer {
|
|
|
37
39
|
// Color fonts (COLR/CPAL): expand each run's emoji into filled vector layers (or fetched images
|
|
38
40
|
// for an image emoji source); plain runs and monochrome fonts pass straight through unchanged.
|
|
39
41
|
const drawn = (await Promise.all(runs.map((run) => TextRenderer._expandColorGlyphs(run, objectManager)))).flat();
|
|
42
|
+
// Underline / strikethrough go on TOP of the glyphs (a strikethrough must), and stay untagged,
|
|
43
|
+
// so the structure tree treats them as artifacts rather than as text.
|
|
40
44
|
// Inline hyperlinks (href spans) ride along as /Link annotations - they draw nothing, so order
|
|
41
45
|
// vs the drawn runs does not matter; the page renderer peels them off into /Annots.
|
|
42
|
-
return
|
|
46
|
+
return [...drawn, ...decorations, ...links];
|
|
43
47
|
}
|
|
44
48
|
// Splits a text run into normal text sub-runs + filled `Path` layers for any COLR color glyphs,
|
|
45
49
|
// walking code point by code point and advancing the pen by each glyph's width. Returns `[run]`
|
|
@@ -82,8 +86,10 @@ export class TextRenderer {
|
|
|
82
86
|
if (l)
|
|
83
87
|
[source, layers] = [fallback, l];
|
|
84
88
|
}
|
|
85
|
-
// The advance matches measuring (getCharWidth applies the same emoji fallback)
|
|
86
|
-
|
|
89
|
+
// The advance matches measuring (getCharWidth applies the same emoji fallback), plus the run's
|
|
90
|
+
// letter-spacing so a spaced run's emoji sit where `Tc` advances them.
|
|
91
|
+
const advance = om.getCharWidth(ch, run.fontSize, undefined, run.fontFamily, run.fontStyle) +
|
|
92
|
+
(run.letterSpacing ?? 0);
|
|
87
93
|
if (source && layers) {
|
|
88
94
|
flushPending();
|
|
89
95
|
const scale = run.fontSize / source.unitsPerEm;
|
|
@@ -209,11 +215,14 @@ export class TextRenderer {
|
|
|
209
215
|
// Lay the content out into absolutely-positioned text runs. Glyph positions match
|
|
210
216
|
// the previous hand-written operators exactly (verified by pixel-identical render);
|
|
211
217
|
// only the output form changed from PDF strings to `TextRun`s.
|
|
212
|
-
static _buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, maxWidth, textAlignment, color, initialX, yPosition, maxLines, overflow, lineHeight =
|
|
218
|
+
static _buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, maxWidth, textAlignment, color, initialX, yPosition, maxLines, overflow, lineHeight, underline = false, strikethrough = false, skipInk = false, letterSpacing = 0) {
|
|
213
219
|
const runs = [];
|
|
214
220
|
// /Link annotation rects for any `href` spans, collected as we place segments. Empty for the
|
|
215
221
|
// common (no-href) case, so plain text keeps producing exactly the runs it did before.
|
|
216
222
|
const links = [];
|
|
223
|
+
// Underline / strikethrough strokes, one per decorated run (so a wrapped paragraph gets one
|
|
224
|
+
// per line, and a decorated span only spans its own glyphs).
|
|
225
|
+
const decorations = [];
|
|
217
226
|
// Horizontal offset of a line of the given width under the current alignment.
|
|
218
227
|
const alignmentOffset = (lineWidth) => {
|
|
219
228
|
if (textAlignment === HorizontalAlignment.center)
|
|
@@ -222,39 +231,78 @@ export class TextRenderer {
|
|
|
222
231
|
return maxWidth - lineWidth;
|
|
223
232
|
return 0;
|
|
224
233
|
};
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
234
|
+
// The decoration strokes for one drawn run, at the geometry ITS OWN font declares. A mixed-size
|
|
235
|
+
// line therefore gets a thicker line under the bigger span, which is what a browser does too.
|
|
236
|
+
const decorate = (text, runX, runWidth, baselineY, family, style, size, runColor, wantsUnderline, wantsStrikethrough, runLetterSpacing) => {
|
|
237
|
+
if ((!wantsUnderline && !wantsStrikethrough) || runWidth <= 0)
|
|
238
|
+
return;
|
|
239
|
+
const metrics = objectManager.getFontDecoration(family, style);
|
|
240
|
+
const push = (from, to, stroke) => {
|
|
241
|
+
decorations.push({
|
|
242
|
+
type: "line",
|
|
243
|
+
x1: runX + from,
|
|
244
|
+
y1: stroke.y,
|
|
245
|
+
x2: runX + to,
|
|
246
|
+
y2: stroke.y,
|
|
247
|
+
stroke: runColor,
|
|
248
|
+
strokeWidth: stroke.thickness,
|
|
249
|
+
});
|
|
250
|
+
};
|
|
251
|
+
if (wantsUnderline) {
|
|
252
|
+
const stroke = underlineStroke(metrics, size, baselineY);
|
|
253
|
+
for (const [from, to] of underlineSegments(text, runWidth, baselineY, size, family, style, stroke, runLetterSpacing)) {
|
|
254
|
+
push(from, to, stroke);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// A strikethrough crosses the letters by definition, so it is never interrupted.
|
|
258
|
+
if (wantsStrikethrough) {
|
|
259
|
+
const stroke = strikethroughStroke(metrics, size, baselineY);
|
|
260
|
+
push(0, runWidth, stroke);
|
|
232
261
|
}
|
|
233
|
-
return width;
|
|
234
262
|
};
|
|
263
|
+
// The x-ranges the underline actually draws. Without `skipInk` that is the whole run; with it,
|
|
264
|
+
// the descenders are cut out of it (which needs the real outlines, i.e. an embedded font).
|
|
265
|
+
const underlineSegments = (text, runWidth, baselineY, size, family, style, stroke, runLetterSpacing) => {
|
|
266
|
+
if (!skipInk)
|
|
267
|
+
return [[0, runWidth]];
|
|
268
|
+
if (!objectManager.isCustomFont(family, style)) {
|
|
269
|
+
throw new Error(`skipInk needs an embedded font, but "${family}" is a standard-14 font whose glyph outlines ` +
|
|
270
|
+
`live in the PDF viewer, not in the document - we cannot see where its ink is. Register a ` +
|
|
271
|
+
`font (the document's \`fonts\` option) or drop \`skipInk\`.`);
|
|
272
|
+
}
|
|
273
|
+
// The band the stroke covers, in points from the baseline, y UP (so both values are negative).
|
|
274
|
+
const below = stroke.y - baselineY;
|
|
275
|
+
const half = stroke.thickness / 2;
|
|
276
|
+
const inkSpans = objectManager.getInkSpansInBand(text, size, family, style, -below + half, -below - half, runLetterSpacing);
|
|
277
|
+
return skipInkSegments(runWidth, inkSpans, stroke.thickness);
|
|
278
|
+
};
|
|
279
|
+
// The advance the viewer will use: plain glyph widths (a `Tj` never kerns) plus one
|
|
280
|
+
// `letterSpacing` per code point (the `Tc` operator), from the one shared `advance.ts` primitive.
|
|
281
|
+
const font = { fontFamily, fontSize, fontStyle };
|
|
235
282
|
// --- Plain string: one run per wrapped line. ---
|
|
236
283
|
if (typeof content === "string") {
|
|
237
|
-
const lines = wrapStringIntoLines(content, fontFamily, fontSize, fontStyle, maxWidth, objectManager, maxLines, overflow);
|
|
238
|
-
// yPosition is the top of the text box (top-left)
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
// box instead of clinging to the top. At lineHeight 1 the half-leading is 0 -> byte-identical.
|
|
242
|
-
const halfLeading = (fontSize * (lineHeight - 1)) / 2;
|
|
243
|
-
const baseline = yPosition + halfLeading + fontSize * BASELINE_RATIO;
|
|
284
|
+
const lines = wrapStringIntoLines(content, fontFamily, fontSize, fontStyle, maxWidth, objectManager, maxLines, overflow, letterSpacing);
|
|
285
|
+
// yPosition is the top of the text box (top-left). The line box seats its own baseline; lines
|
|
286
|
+
// then stack by that box's height. Same numbers as calculateTextHeight, by construction.
|
|
287
|
+
const box = lineBoxForString(objectManager, fontFamily, fontStyle, fontSize, lineHeight);
|
|
244
288
|
lines.forEach((line, index) => {
|
|
245
|
-
const lineWidth = objectManager
|
|
289
|
+
const lineWidth = runAdvance(objectManager, line, font, letterSpacing);
|
|
290
|
+
const x = initialX + alignmentOffset(lineWidth);
|
|
291
|
+
const baseline = yPosition + box.baseline + box.height * index;
|
|
246
292
|
runs.push({
|
|
247
293
|
type: "text",
|
|
248
|
-
x
|
|
249
|
-
y: baseline
|
|
294
|
+
x,
|
|
295
|
+
y: baseline,
|
|
250
296
|
text: line,
|
|
251
297
|
fontFamily,
|
|
252
298
|
fontStyle,
|
|
253
299
|
fontSize,
|
|
254
300
|
color,
|
|
301
|
+
...(letterSpacing ? { letterSpacing } : {}),
|
|
255
302
|
});
|
|
303
|
+
decorate(line, x, lineWidth, baseline, fontFamily, fontStyle, fontSize, color, underline, strikethrough, letterSpacing);
|
|
256
304
|
});
|
|
257
|
-
return { runs, links };
|
|
305
|
+
return { runs, links, decorations };
|
|
258
306
|
}
|
|
259
307
|
// --- Segments: break into lines (shared breaker), then emit one run per segment.
|
|
260
308
|
// Segment 0 starts at the aligned line origin; each following segment is offset by
|
|
@@ -267,7 +315,9 @@ export class TextRenderer {
|
|
|
267
315
|
const family = segment.fontFamily || fontFamily;
|
|
268
316
|
const size = segment.fontSize || fontSize;
|
|
269
317
|
const style = segment.fontStyle || fontStyle;
|
|
270
|
-
const
|
|
318
|
+
const segLetterSpacing = segment.letterSpacing ?? letterSpacing;
|
|
319
|
+
const advance = runAdvance(objectManager, segment.content, { fontFamily: family, fontSize: size, fontStyle: style }, segLetterSpacing);
|
|
320
|
+
const runColor = segment.fontColor || color;
|
|
271
321
|
runs.push({
|
|
272
322
|
type: "text",
|
|
273
323
|
x,
|
|
@@ -276,18 +326,21 @@ export class TextRenderer {
|
|
|
276
326
|
fontFamily: family,
|
|
277
327
|
fontStyle: style,
|
|
278
328
|
fontSize: size,
|
|
279
|
-
color:
|
|
329
|
+
color: runColor,
|
|
330
|
+
...(segLetterSpacing ? { letterSpacing: segLetterSpacing } : {}),
|
|
280
331
|
});
|
|
281
|
-
|
|
282
|
-
//
|
|
283
|
-
// ascent
|
|
332
|
+
decorate(segment.content, x, advance, lineY, family, style, size, runColor, segment.underline ?? underline, segment.strikethrough ?? strikethrough, segLetterSpacing);
|
|
333
|
+
// An href/to span becomes a /Link annotation over exactly this run's glyph box: from its own
|
|
334
|
+
// ascent above the baseline down to its own descender. A span wrapped across lines yields
|
|
335
|
+
// one rect per line.
|
|
284
336
|
if (segment.href !== undefined || segment.dest !== undefined) {
|
|
337
|
+
const v = objectManager.getFontVerticals(family, style);
|
|
285
338
|
links.push({
|
|
286
339
|
type: "link",
|
|
287
340
|
x,
|
|
288
|
-
y: lineY -
|
|
341
|
+
y: lineY - v.ascent * size,
|
|
289
342
|
width: advance,
|
|
290
|
-
height: size,
|
|
343
|
+
height: (v.ascent + v.descent) * size,
|
|
291
344
|
href: segment.href,
|
|
292
345
|
dest: segment.dest,
|
|
293
346
|
});
|
|
@@ -295,23 +348,18 @@ export class TextRenderer {
|
|
|
295
348
|
x += advance;
|
|
296
349
|
});
|
|
297
350
|
};
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
// the whole thing to PDF space.
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
// Half-leading: shift this line's baseline down by half its own extra leading, so the line
|
|
310
|
-
// sits centered in its box (CSS/Flutter). At lineHeight 1 the shift is 0 -> byte-identical.
|
|
311
|
-
const halfLeading = (line.maxFontSize * (lineHeight - 1)) / 2;
|
|
312
|
-
pushLine(line, lineY + halfLeading);
|
|
313
|
-
lineY += line.maxFontSize * lineHeight;
|
|
351
|
+
// yPosition is the top of the text box (top-left). Each line seats its own baseline inside its
|
|
352
|
+
// own box (tallest ascent / deepest descent ON THAT LINE), then the next line starts below it.
|
|
353
|
+
// The seam flips the whole thing to PDF space. `letterSpacing` MUST be in the defaults so a span
|
|
354
|
+
// that does not override it wraps and aligns with the element's spacing - the same defaults the
|
|
355
|
+
// measure path (calculateTextHeight) uses, or segmented spaced text mis-wraps (measured != drawn).
|
|
356
|
+
const defaults = { fontFamily, fontSize, fontStyle, letterSpacing };
|
|
357
|
+
let top = yPosition;
|
|
358
|
+
for (const line of breakSegmentsIntoLines(content, defaults, maxWidth, objectManager, maxLines, overflow)) {
|
|
359
|
+
const box = lineBoxForSegmentLine(line, defaults, objectManager, lineHeight);
|
|
360
|
+
pushLine(line, top + box.baseline);
|
|
361
|
+
top += box.height;
|
|
314
362
|
}
|
|
315
|
-
return { runs, links };
|
|
363
|
+
return { runs, links, decorations };
|
|
316
364
|
}
|
|
317
365
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How wide a run of text is: the horizontal counterpart to `line-metrics.ts` (how tall a line is)
|
|
3
|
+
* and `line-breaker.ts` (where a line breaks). The ONE canonical answer to "how far does the pen
|
|
4
|
+
* move", so measuring and drawing can never disagree about it.
|
|
5
|
+
*
|
|
6
|
+
* An advance has three parts, each with its own PDF mechanism:
|
|
7
|
+
*
|
|
8
|
+
* advance = sum(glyph widths) + sum(kerning) + n * letterSpacing
|
|
9
|
+
* /Widths TJ array Tc
|
|
10
|
+
*
|
|
11
|
+
* `letterSpacing` is added after EVERY code point, the last one included - exactly what the `Tc`
|
|
12
|
+
* operator does, and exactly what CSS `letter-spacing` does (which is why right-aligned spaced text
|
|
13
|
+
* looks a hair offset: a spacing hangs off the final glyph). Measuring `(n - 1)` spacings while `Tc`
|
|
14
|
+
* applies `n` would make every line draw one spacing wider than it was wrapped - text out of its box.
|
|
15
|
+
*
|
|
16
|
+
* Kerning is added ONLY when the document has it on (`metrics.kerningEnabled`), and then the backend
|
|
17
|
+
* emits a `TJ` with the same per-pair adjustments - so a measured advance always equals the drawn
|
|
18
|
+
* one. With kerning off, `getStringWidth` is the whole advance (its plain glyph sum), byte-identical
|
|
19
|
+
* to before. Folding kerning into the measurement while the output ignored it was the 2026-07-10 bug.
|
|
20
|
+
*/
|
|
21
|
+
import type { FontStyle } from "../utils/pdf-object-manager.js";
|
|
22
|
+
import type { FontMetrics } from "../utils/font-metrics.js";
|
|
23
|
+
/** The font a run is set in. */
|
|
24
|
+
export interface RunFont {
|
|
25
|
+
fontFamily: string;
|
|
26
|
+
fontSize: number;
|
|
27
|
+
fontStyle: FontStyle;
|
|
28
|
+
}
|
|
29
|
+
/** Number of Unicode code points (not UTF-16 units): an astral char is ONE, so it takes one
|
|
30
|
+
* letter-spacing, not two. Matches how `getStringWidth` iterates. */
|
|
31
|
+
export declare function codePointCount(text: string): number;
|
|
32
|
+
/** The advance of `text` in points: the plain glyph widths, plus kerning (if the document has it on),
|
|
33
|
+
* plus one `letterSpacing` per code point. */
|
|
34
|
+
export declare function runAdvance(metrics: FontMetrics, text: string, font: RunFont, letterSpacing?: number): number;
|