@jasy/pdf 1.0.0-alpha.4 → 1.0.0-alpha.5
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 +5 -2
- package/dist/api/content.d.ts +3 -0
- package/dist/api/content.js +1 -0
- package/dist/api/structure.d.ts +19 -0
- package/dist/api/structure.js +25 -0
- package/dist/api/table.js +13 -5
- package/dist/api/text.d.ts +4 -1
- package/dist/api/text.js +1 -0
- package/dist/elements/image-element.d.ts +5 -1
- package/dist/elements/image-element.js +3 -1
- package/dist/elements/layout/struct-group.d.ts +28 -0
- package/dist/elements/layout/struct-group.js +42 -0
- package/dist/elements/pdf-element.d.ts +4 -0
- package/dist/elements/pdf-element.js +15 -0
- package/dist/elements/text-element.d.ts +7 -1
- package/dist/elements/text-element.js +5 -2
- package/dist/ir/display-list.d.ts +79 -1
- package/dist/layout/fragmentation.js +9 -2
- package/dist/renderer/emoji-image.d.ts +9 -0
- package/dist/renderer/emoji-image.js +68 -0
- package/dist/renderer/image-renderer.d.ts +1 -1
- package/dist/renderer/image-renderer.js +10 -2
- package/dist/renderer/page-renderer.js +19 -4
- package/dist/renderer/pdf-backend.d.ts +6 -2
- package/dist/renderer/pdf-backend.js +59 -3
- package/dist/renderer/pdf-renderer.js +11 -0
- package/dist/renderer/struct-group-renderer.d.ts +10 -0
- package/dist/renderer/struct-group-renderer.js +24 -0
- package/dist/renderer/text-renderer.d.ts +3 -0
- package/dist/renderer/text-renderer.js +182 -2
- package/dist/text/emoji-codepoints.d.ts +1 -0
- package/dist/text/emoji-codepoints.js +29 -0
- package/dist/text/line-breaker.js +6 -3
- package/dist/utils/pdf-object-manager.d.ts +20 -1
- package/dist/utils/pdf-object-manager.js +0 -0
- package/dist/utils/struct-tree.d.ts +44 -0
- package/dist/utils/struct-tree.js +130 -0
- package/dist/utils/ttf-parser.d.ts +73 -0
- package/dist/utils/ttf-parser.js +456 -16
- package/dist/utils/ua-xmp.d.ts +9 -0
- package/dist/utils/ua-xmp.js +32 -0
- package/package.json +1 -1
|
@@ -2,9 +2,9 @@ import { BoxFit } from "../elements/image-element.js";
|
|
|
2
2
|
import { bytesFromLatin1 } from "../utils/bytes.js";
|
|
3
3
|
import { applyContainFit, applyCoverFit, applyFillFit, applyFitNone, decodePngToRgbFlate, } from "../utils/image-helper.js";
|
|
4
4
|
export class ImageRenderer {
|
|
5
|
-
static async render(imageElement,
|
|
5
|
+
static async render(imageElement, objectManager) {
|
|
6
6
|
// Load the image and convert it in a binary string
|
|
7
|
-
let { x, y, width, height, image, fit, radius } = imageElement.getProps();
|
|
7
|
+
let { x, y, width, height, image, fit, radius, alt } = imageElement.getProps();
|
|
8
8
|
await image.init(); // Load and initialize the image
|
|
9
9
|
const imageType = await image.getImageType(); // For the moment we can handle `png` and `jpg/jpeg` files
|
|
10
10
|
const fileData = await image.getFileData();
|
|
@@ -82,6 +82,14 @@ export class ImageRenderer {
|
|
|
82
82
|
}
|
|
83
83
|
: {}),
|
|
84
84
|
};
|
|
85
|
+
// Accessible tagging: an image WITH alt text is a Figure; without, it stays untagged and the backend
|
|
86
|
+
// treats it as decoration (an Artifact). One struct element per image.
|
|
87
|
+
if (objectManager.struct.enabled && alt) {
|
|
88
|
+
node.tag = {
|
|
89
|
+
role: "Figure",
|
|
90
|
+
key: objectManager.struct.openElement(imageElement.structId, "Figure", { alt }),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
85
93
|
return [node];
|
|
86
94
|
}
|
|
87
95
|
}
|
|
@@ -25,7 +25,10 @@ export class PageRenderer {
|
|
|
25
25
|
nodes.push(...(await renderer(element, objectManager)));
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
-
|
|
28
|
+
// Accessible tagging: one struct context per page (allocates its StructParents index), threaded into
|
|
29
|
+
// serialize so each drawable node is wrapped in marked content. Undefined = tagging off (byte-identical).
|
|
30
|
+
const structCtx = objectManager.struct.enabled ? objectManager.struct.beginPage() : undefined;
|
|
31
|
+
const pageContent = PdfBackend.serialize(PdfBackend.flipY(nodes, height), objectManager, structCtx);
|
|
29
32
|
// Add the page content as a new object (FlateDecode-compressed when enabled). The /Length is
|
|
30
33
|
// computed inside, with an explicit EOL before `endstream` (PDF/A clause 6.1.7.1).
|
|
31
34
|
const contentObjectNumber = objectManager.addContentStream(pageContent);
|
|
@@ -58,8 +61,20 @@ export class PageRenderer {
|
|
|
58
61
|
const extGStateCode = extGStateReferences.length > 0
|
|
59
62
|
? "/ExtGState <<\n" + extGStateReferences.join("\n") + "\n>>\n"
|
|
60
63
|
: "";
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
+
// - Gradient shadings (color-glyph fills), registered during serialize above
|
|
65
|
+
const shadingReferences = [];
|
|
66
|
+
objectManager.getAllShadingsRaw().forEach((objectNumber, name) => {
|
|
67
|
+
shadingReferences.push(`/${name} ${objectNumber} 0 R`);
|
|
68
|
+
});
|
|
69
|
+
const shadingCode = shadingReferences.length > 0 ? "/Shading <<\n" + shadingReferences.join("\n") + "\n>>\n" : "";
|
|
70
|
+
// Tagging only: /StructParents links this page's marked content to the ParentTree, /Tabs /S makes the
|
|
71
|
+
// logical structure order the tab/reading order (a PDF/UA requirement).
|
|
72
|
+
const structAttrs = structCtx ? ` /StructParents ${structCtx.structParents} /Tabs /S` : "";
|
|
73
|
+
const pageObject = `<< /Type /Page /Parent ${parentObjectNumber} 0 R /Contents ${contentObjectNumber} 0 R /Resources <<\n/Font <<\n${fontReferences.join("\n")}\n>>\n${imageCode}${extGStateCode}${shadingCode}>>\n/MediaBox [0 0 ${width} ${height}]${structAttrs} >>`;
|
|
74
|
+
// Add page as new object; register its object number with the struct tree (for /Pg refs), then return it.
|
|
75
|
+
const pageObjNum = objectManager.addObject(pageObject);
|
|
76
|
+
if (structCtx)
|
|
77
|
+
objectManager.struct.setPageObject(structCtx.structParents, pageObjNum);
|
|
78
|
+
return pageObjNum;
|
|
64
79
|
}
|
|
65
80
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { IRNode } from "../ir/display-list.js";
|
|
2
2
|
import { PDFObjectManager } from "../utils/pdf-object-manager.js";
|
|
3
|
+
import type { PageStructContext } from "../utils/struct-tree.js";
|
|
3
4
|
/**
|
|
4
5
|
* PDF backend - turns display-list primitives into content-stream operators.
|
|
5
6
|
*
|
|
@@ -17,8 +18,11 @@ export declare class PdfBackend {
|
|
|
17
18
|
* directly. (image is migrated in a later slice and still arrives pre-flipped.)
|
|
18
19
|
*/
|
|
19
20
|
static flipY(nodes: IRNode[], pageHeight: number): IRNode[];
|
|
20
|
-
/** Serialize a whole display list into one content stream (page-level entry point).
|
|
21
|
-
|
|
21
|
+
/** Serialize a whole display list into one content stream (page-level entry point). When a
|
|
22
|
+
* `PageStructContext` is given (accessible tagging on), each drawable node is wrapped in a marked-content
|
|
23
|
+
* sequence (`/Role <</MCID n>> BDC … EMC`, or `/Artifact` when untagged); graphics-state and empty nodes
|
|
24
|
+
* pass through untouched, so untagged output stays byte-identical. */
|
|
25
|
+
static serialize(nodes: IRNode[], om: PDFObjectManager, struct?: PageStructContext): string;
|
|
22
26
|
/**
|
|
23
27
|
* Escapes a string for a PDF literal string `( ... )`. The backslash must be doubled
|
|
24
28
|
* first, then the parentheses that delimit the string. Without this, a ")" in the
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Color } from "../common/color.js";
|
|
1
2
|
/**
|
|
2
3
|
* PDF backend - turns display-list primitives into content-stream operators.
|
|
3
4
|
*
|
|
@@ -44,6 +45,23 @@ export class PdfBackend {
|
|
|
44
45
|
return { ...node, y: pageHeight - node.y - node.height };
|
|
45
46
|
case "clip-pop":
|
|
46
47
|
return node;
|
|
48
|
+
case "path": {
|
|
49
|
+
// Flip every point's y directly (like a line endpoint); `z` carries no coordinates.
|
|
50
|
+
const commands = node.commands.map((c) => {
|
|
51
|
+
if (c.op === "z")
|
|
52
|
+
return c;
|
|
53
|
+
if (c.op === "c") {
|
|
54
|
+
return { ...c, y1: pageHeight - c.y1, y2: pageHeight - c.y2, y: pageHeight - c.y };
|
|
55
|
+
}
|
|
56
|
+
return { ...c, y: pageHeight - c.y };
|
|
57
|
+
});
|
|
58
|
+
// A gradient fill carries its own page-space anchor points; flip their y too (radii and
|
|
59
|
+
// x stay). A solid Color fill has no geometry.
|
|
60
|
+
const fill = node.fill instanceof Color
|
|
61
|
+
? node.fill
|
|
62
|
+
: { ...node.fill, y0: pageHeight - node.fill.y0, y1: pageHeight - node.fill.y1 };
|
|
63
|
+
return { ...node, commands, fill };
|
|
64
|
+
}
|
|
47
65
|
default: {
|
|
48
66
|
const unknown = node;
|
|
49
67
|
return unknown;
|
|
@@ -51,9 +69,20 @@ export class PdfBackend {
|
|
|
51
69
|
}
|
|
52
70
|
});
|
|
53
71
|
}
|
|
54
|
-
/** Serialize a whole display list into one content stream (page-level entry point).
|
|
55
|
-
|
|
56
|
-
|
|
72
|
+
/** Serialize a whole display list into one content stream (page-level entry point). When a
|
|
73
|
+
* `PageStructContext` is given (accessible tagging on), each drawable node is wrapped in a marked-content
|
|
74
|
+
* sequence (`/Role <</MCID n>> BDC … EMC`, or `/Artifact` when untagged); graphics-state and empty nodes
|
|
75
|
+
* pass through untouched, so untagged output stays byte-identical. */
|
|
76
|
+
static serialize(nodes, om, struct) {
|
|
77
|
+
return nodes
|
|
78
|
+
.map((node) => {
|
|
79
|
+
const ops = PdfBackend.serializeNode(node, om);
|
|
80
|
+
if (!struct || node.type === "clip-push" || node.type === "clip-pop" || ops === "")
|
|
81
|
+
return ops;
|
|
82
|
+
const { open, close } = struct.mark(node.tag);
|
|
83
|
+
return open + ops + close;
|
|
84
|
+
})
|
|
85
|
+
.join("");
|
|
57
86
|
}
|
|
58
87
|
/**
|
|
59
88
|
* Escapes a string for a PDF literal string `( ... )`. The backslash must be doubled
|
|
@@ -189,6 +218,33 @@ export class PdfBackend {
|
|
|
189
218
|
: `${node.x} ${node.y} ${node.width} ${node.height} re`;
|
|
190
219
|
return `q\n${path}\nW n\n`;
|
|
191
220
|
}
|
|
221
|
+
case "path": {
|
|
222
|
+
// A filled vector path (one color-glyph layer). Emit the subpath ops, then either a solid
|
|
223
|
+
// nonzero fill (a Color) or, for a gradient, clip to the path and paint a shading inside it.
|
|
224
|
+
const f = (n) => n.toFixed(3);
|
|
225
|
+
let path = "";
|
|
226
|
+
for (const c of node.commands) {
|
|
227
|
+
if (c.op === "m")
|
|
228
|
+
path += `${f(c.x)} ${f(c.y)} m\n`;
|
|
229
|
+
else if (c.op === "l")
|
|
230
|
+
path += `${f(c.x)} ${f(c.y)} l\n`;
|
|
231
|
+
else if (c.op === "c")
|
|
232
|
+
path += `${f(c.x1)} ${f(c.y1)} ${f(c.x2)} ${f(c.y2)} ${f(c.x)} ${f(c.y)} c\n`;
|
|
233
|
+
else
|
|
234
|
+
path += `h\n`;
|
|
235
|
+
}
|
|
236
|
+
if (!(node.fill instanceof Color)) {
|
|
237
|
+
// Gradient: clip to the path (W n keeps it as the clip without painting it), then paint
|
|
238
|
+
// the registered shading across that clip. The q/Q isolates the clip. Note: per-stop alpha
|
|
239
|
+
// is not represented (a DeviceRGB shading has no alpha channel - that would need a soft-mask
|
|
240
|
+
// group); COLR gradient stops are opaque in real fonts, so they render correctly.
|
|
241
|
+
const shading = om.registerShading(node.fill);
|
|
242
|
+
return `q\n${path}W n\n/${shading} sh\nQ\n`;
|
|
243
|
+
}
|
|
244
|
+
const body = `${node.fill.toPDFColorString()} rg\n${path}f\n`;
|
|
245
|
+
const gs = PdfBackend.alphaPrefix(om, node.fill.getAlpha(), 1);
|
|
246
|
+
return gs ? `q\n${gs}${body}Q\n` : body;
|
|
247
|
+
}
|
|
192
248
|
case "clip-pop":
|
|
193
249
|
return `Q\n`;
|
|
194
250
|
default: {
|
|
@@ -19,6 +19,8 @@ import { DeferredElement } from "../elements/layout/deferred-element.js";
|
|
|
19
19
|
import { DeferredRenderer } from "./deferred-renderer.js";
|
|
20
20
|
import { PositionedElement } from "../elements/layout/positioned-element.js";
|
|
21
21
|
import { PositionedRenderer } from "./positioned-renderer.js";
|
|
22
|
+
import { StructGroup } from "../elements/layout/struct-group.js";
|
|
23
|
+
import { StructGroupRenderer } from "./struct-group-renderer.js";
|
|
22
24
|
import { BoxConstraints } from "../layout/box-constraints.js";
|
|
23
25
|
import { DEFAULT_TEXT_STYLE, mergeTextStyle } from "../text/text-style.js";
|
|
24
26
|
export class PDFRenderer {
|
|
@@ -36,6 +38,7 @@ export class PDFRenderer {
|
|
|
36
38
|
RendererRegistry.register(RepeatingHeaderElement, RepeatingHeaderRenderer.render);
|
|
37
39
|
RendererRegistry.register(DeferredElement, DeferredRenderer.render);
|
|
38
40
|
RendererRegistry.register(PositionedElement, PositionedRenderer.render);
|
|
41
|
+
RendererRegistry.register(StructGroup, StructGroupRenderer.render);
|
|
39
42
|
let pdfContent = "";
|
|
40
43
|
// Header: version line + the PDF/A binary marker (the object manager owns it so its length
|
|
41
44
|
// matches the xref offset calculation).
|
|
@@ -69,6 +72,14 @@ export class PDFRenderer {
|
|
|
69
72
|
const af = attachments.map((a) => `${a.filespec} 0 R`).join(" ");
|
|
70
73
|
catalogParts.push(`/AF [${af}]`, `/Names << /EmbeddedFiles << /Names [${names}] >> >>`);
|
|
71
74
|
}
|
|
75
|
+
// Accessible tagging: finalize the structure tree (emits StructTreeRoot + StructElems + ParentTree) and
|
|
76
|
+
// fold /MarkInfo + /StructTreeRoot + /Lang into the catalog. Returns "" (no-op) when tagging is off.
|
|
77
|
+
const structCatalog = objectManager.struct.finalize(objectManager);
|
|
78
|
+
if (structCatalog) {
|
|
79
|
+
catalogParts.push(structCatalog);
|
|
80
|
+
// PDF/UA requires the viewer to show the document title (not the file name).
|
|
81
|
+
catalogParts.push("/ViewerPreferences << /DisplayDocTitle true >>");
|
|
82
|
+
}
|
|
72
83
|
const catalogObject = `<< ${catalogParts.join(" ")} >>`;
|
|
73
84
|
objectManager.addObject(catalogObject);
|
|
74
85
|
// Now that the render pass has revealed which glyphs each embedded font uses, fill the reserved
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { StructGroup } from "../elements/layout/struct-group.js";
|
|
2
|
+
import { PDFObjectManager } from "../utils/pdf-object-manager.js";
|
|
3
|
+
import { IRNode } from "../ir/display-list.js";
|
|
4
|
+
/**
|
|
5
|
+
* Renders a StructGroup: opens its structure element, renders the child under it, closes it. With accessible
|
|
6
|
+
* tagging off it is fully transparent - just the child's IR.
|
|
7
|
+
*/
|
|
8
|
+
export declare class StructGroupRenderer {
|
|
9
|
+
static render(group: StructGroup, objectManager: PDFObjectManager): Promise<IRNode[]>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { RendererRegistry } from "../utils/renderer-registry.js";
|
|
2
|
+
/**
|
|
3
|
+
* Renders a StructGroup: opens its structure element, renders the child under it, closes it. With accessible
|
|
4
|
+
* tagging off it is fully transparent - just the child's IR.
|
|
5
|
+
*/
|
|
6
|
+
export class StructGroupRenderer {
|
|
7
|
+
static async render(group, objectManager) {
|
|
8
|
+
const { role, child } = group.getProps();
|
|
9
|
+
const renderChild = async () => {
|
|
10
|
+
const renderer = RendererRegistry.getRenderer(child);
|
|
11
|
+
return renderer ? await renderer(child, objectManager) : [];
|
|
12
|
+
};
|
|
13
|
+
if (!objectManager.struct.enabled)
|
|
14
|
+
return renderChild();
|
|
15
|
+
const key = objectManager.struct.openElement(group.structId, role);
|
|
16
|
+
objectManager.struct.push(key);
|
|
17
|
+
try {
|
|
18
|
+
return await renderChild();
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
objectManager.struct.pop(); // keep the parent stack balanced even if a child throws
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -6,5 +6,8 @@ import { TextOverflow } from "../text/line-breaker.js";
|
|
|
6
6
|
export declare class TextRenderer {
|
|
7
7
|
static calculateTextHeight(content: string | TextSegment[], fontSize: number, fontFamily: string, fontStyle: FontStyle, objectManager: FontMetrics, maxWidth: number, maxLines?: number, overflow?: TextOverflow, lineHeight?: number): number;
|
|
8
8
|
static render(textElement: TextElement, objectManager: PDFObjectManager): Promise<IRNode[]>;
|
|
9
|
+
private static _expandColorGlyphs;
|
|
10
|
+
private static _glyphToPath;
|
|
11
|
+
private static _toGradient;
|
|
9
12
|
private static _buildRuns;
|
|
10
13
|
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { Color } from "../common/color.js";
|
|
1
2
|
import { HorizontalAlignment } from "../elements/pdf-element.js";
|
|
3
|
+
import { isEmojiCodePoint } from "../text/emoji-codepoints.js";
|
|
4
|
+
import { emojiImageNode } from "./emoji-image.js";
|
|
2
5
|
import { wrapStringIntoLines, breakSegmentsIntoLines, } from "../text/line-breaker.js";
|
|
3
6
|
// Distance from the top of a line down to its baseline, as a fraction of the font
|
|
4
7
|
// size. ~0.683 is the standard-14 ascent ratio used to seat the first baseline.
|
|
@@ -17,11 +20,188 @@ export class TextRenderer {
|
|
|
17
20
|
return lines.reduce((total, line) => total + line.maxFontSize * lineHeight, 0);
|
|
18
21
|
}
|
|
19
22
|
static async render(textElement, objectManager) {
|
|
20
|
-
const { x, y, width, fontSize, color, content, fontFamily, fontStyle, textAlignment, maxLines, overflow, lineHeight, } = textElement.getProps();
|
|
23
|
+
const { x, y, width, fontSize, color, content, fontFamily, fontStyle, textAlignment, maxLines, overflow, lineHeight, role, } = textElement.getProps();
|
|
21
24
|
// Component -> display list. Wrapping and positioning stay here; the backend
|
|
22
25
|
// turns each run into BT/Tf/Td/Tj/ET. The wrapping algorithm is unchanged from
|
|
23
26
|
// the original renderer - unifying it into the engine is Phase 3.
|
|
24
|
-
|
|
27
|
+
const runs = TextRenderer._buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, width ?? Number.NaN, textAlignment, color, x, y, maxLines, overflow, lineHeight);
|
|
28
|
+
// Accessible tagging: this whole text block is one structure element (a paragraph P, or a heading
|
|
29
|
+
// from `role`); each run's text lands under it. Emoji sub-nodes produced below stay untagged (drawn
|
|
30
|
+
// as artifacts). Keyed by structId so a block split across pages stays one element.
|
|
31
|
+
if (objectManager.struct.enabled) {
|
|
32
|
+
const pdfRole = role ? role.toUpperCase() : "P"; // "h1" -> "H1"; default paragraph
|
|
33
|
+
const key = objectManager.struct.openElement(textElement.structId, pdfRole);
|
|
34
|
+
for (const run of runs)
|
|
35
|
+
run.tag = { role: pdfRole, key };
|
|
36
|
+
}
|
|
37
|
+
// Color fonts (COLR/CPAL): expand each run's emoji into filled vector layers (or fetched images
|
|
38
|
+
// for an image emoji source); plain runs and monochrome fonts pass straight through unchanged.
|
|
39
|
+
return (await Promise.all(runs.map((run) => TextRenderer._expandColorGlyphs(run, objectManager)))).flat();
|
|
40
|
+
}
|
|
41
|
+
// Splits a text run into normal text sub-runs + filled `Path` layers for any COLR color glyphs,
|
|
42
|
+
// walking code point by code point and advancing the pen by each glyph's width. Returns `[run]`
|
|
43
|
+
// untouched when the font has no color glyphs (the common case).
|
|
44
|
+
static async _expandColorGlyphs(run, om) {
|
|
45
|
+
// The run's own font (if it's a color font) draws emoji directly; otherwise a document-level
|
|
46
|
+
// emoji source (`Document({ emoji })`) supplies them - a fallback FONT (color glyphs, native
|
|
47
|
+
// vector) or an IMAGE source (fetched emoji images) - for code points the run's font lacks, so
|
|
48
|
+
// `Text("Hallo 😅")` works in one string. No emoji source at all -> pass the run through.
|
|
49
|
+
const own = om.getColorFont(run.fontFamily, run.fontStyle);
|
|
50
|
+
const emojiName = om.getEmojiFont();
|
|
51
|
+
const fallback = emojiName && emojiName !== run.fontFamily
|
|
52
|
+
? om.getColorFont(emojiName, run.fontStyle)
|
|
53
|
+
: undefined;
|
|
54
|
+
const imageSource = om.getEmojiImageSource();
|
|
55
|
+
if (!own && !fallback && !imageSource)
|
|
56
|
+
return [run];
|
|
57
|
+
const nodes = [];
|
|
58
|
+
let cursorX = run.x; // absolute x of the next glyph
|
|
59
|
+
let pending = ""; // run of consecutive non-color characters
|
|
60
|
+
let pendingX = run.x; // where that run started
|
|
61
|
+
const flushPending = () => {
|
|
62
|
+
if (pending) {
|
|
63
|
+
nodes.push({ ...run, x: pendingX, text: pending });
|
|
64
|
+
pending = "";
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
for (const ch of run.text) {
|
|
68
|
+
const cp = ch.codePointAt(0);
|
|
69
|
+
// Which color font renders this code point: the run's own font first, then the emoji fallback.
|
|
70
|
+
let source;
|
|
71
|
+
let layers = null;
|
|
72
|
+
if (own) {
|
|
73
|
+
const l = own.getColorGlyph(own.getGlyphIndex(cp));
|
|
74
|
+
if (l)
|
|
75
|
+
[source, layers] = [own, l];
|
|
76
|
+
}
|
|
77
|
+
if (!layers && fallback) {
|
|
78
|
+
const l = fallback.getColorGlyph(fallback.getGlyphIndex(cp));
|
|
79
|
+
if (l)
|
|
80
|
+
[source, layers] = [fallback, l];
|
|
81
|
+
}
|
|
82
|
+
// The advance matches measuring (getCharWidth applies the same emoji fallback).
|
|
83
|
+
const advance = om.getCharWidth(ch, run.fontSize, undefined, run.fontFamily, run.fontStyle);
|
|
84
|
+
if (source && layers) {
|
|
85
|
+
flushPending();
|
|
86
|
+
const scale = run.fontSize / source.unitsPerEm;
|
|
87
|
+
for (const layer of layers) {
|
|
88
|
+
const commands = TextRenderer._glyphToPath(source.getGlyphPath(layer.glyphId), cursorX, run.y, scale, layer.transform);
|
|
89
|
+
if (commands.length === 0)
|
|
90
|
+
continue; // an empty layer outline draws nothing
|
|
91
|
+
const paint = layer.paint;
|
|
92
|
+
let fill;
|
|
93
|
+
if (paint.type === "solid") {
|
|
94
|
+
const c = paint.color;
|
|
95
|
+
fill = c ? new Color(c.r, c.g, c.b, c.a / 255) : run.color; // null -> foreground
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
fill = TextRenderer._toGradient(paint, cursorX, run.y, scale, run.color, layer.transform);
|
|
99
|
+
}
|
|
100
|
+
nodes.push({ type: "path", commands, fill });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else if (imageSource && isEmojiCodePoint(cp)) {
|
|
104
|
+
// Image emoji source: fetch + embed a 1em square. The emoji breaks the text run either way;
|
|
105
|
+
// a failed fetch (offline/404) leaves a blank em gap (advance already applied) rather than
|
|
106
|
+
// drawing a broken glyph.
|
|
107
|
+
flushPending();
|
|
108
|
+
const node = await emojiImageNode(cp, imageSource, cursorX, run.y, run.fontSize);
|
|
109
|
+
if (node)
|
|
110
|
+
nodes.push(node);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
if (!pending)
|
|
114
|
+
pendingX = cursorX;
|
|
115
|
+
pending += ch;
|
|
116
|
+
}
|
|
117
|
+
cursorX += advance;
|
|
118
|
+
}
|
|
119
|
+
flushPending();
|
|
120
|
+
return nodes;
|
|
121
|
+
}
|
|
122
|
+
// Maps a glyph outline (font units, y-up) to absolute IR path commands at the pen origin, scaling
|
|
123
|
+
// by `scale` and flipping the axis into the engine's top-left space (the seam flips it once more
|
|
124
|
+
// to PDF space). TrueType quadratics become cubics: C1 = P0 + 2/3(C-P0), C2 = P1 + 2/3(C-P1).
|
|
125
|
+
static _glyphToPath(cmds, originX, baselineY, scale, transform) {
|
|
126
|
+
// Apply the layer's affine (font units) first, then scale to points, position at the pen origin
|
|
127
|
+
// and flip into engine space. `mapX`/`mapY` take BOTH glyph coords because an affine mixes them.
|
|
128
|
+
const [a, b, c, d, e, f] = transform ?? [1, 0, 0, 1, 0, 0];
|
|
129
|
+
const mapX = (gx, gy) => originX + (a * gx + c * gy + e) * scale;
|
|
130
|
+
const mapY = (gx, gy) => baselineY - (b * gx + d * gy + f) * scale;
|
|
131
|
+
const out = [];
|
|
132
|
+
let curX = 0;
|
|
133
|
+
let curY = 0;
|
|
134
|
+
for (const cmd of cmds) {
|
|
135
|
+
if (cmd.type === "M") {
|
|
136
|
+
curX = mapX(cmd.x, cmd.y);
|
|
137
|
+
curY = mapY(cmd.x, cmd.y);
|
|
138
|
+
out.push({ op: "m", x: curX, y: curY });
|
|
139
|
+
}
|
|
140
|
+
else if (cmd.type === "L") {
|
|
141
|
+
curX = mapX(cmd.x, cmd.y);
|
|
142
|
+
curY = mapY(cmd.x, cmd.y);
|
|
143
|
+
out.push({ op: "l", x: curX, y: curY });
|
|
144
|
+
}
|
|
145
|
+
else if (cmd.type === "Q") {
|
|
146
|
+
const ctrlX = mapX(cmd.cx, cmd.cy);
|
|
147
|
+
const ctrlY = mapY(cmd.cx, cmd.cy);
|
|
148
|
+
const endX = mapX(cmd.x, cmd.y);
|
|
149
|
+
const endY = mapY(cmd.x, cmd.y);
|
|
150
|
+
out.push({
|
|
151
|
+
op: "c",
|
|
152
|
+
x1: curX + (2 / 3) * (ctrlX - curX),
|
|
153
|
+
y1: curY + (2 / 3) * (ctrlY - curY),
|
|
154
|
+
x2: endX + (2 / 3) * (ctrlX - endX),
|
|
155
|
+
y2: endY + (2 / 3) * (ctrlY - endY),
|
|
156
|
+
x: endX,
|
|
157
|
+
y: endY,
|
|
158
|
+
});
|
|
159
|
+
curX = endX;
|
|
160
|
+
curY = endY;
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
out.push({ op: "z" });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
// Maps a COLR v1 gradient paint (font-unit coordinates) into an IR `Gradient` positioned at the
|
|
169
|
+
// pen origin, using the SAME scale + baseline flip as the glyph outline so the gradient axis lines
|
|
170
|
+
// up with the shape. Radii scale uniformly (the em scale is uniform). A null stop color (the COLR
|
|
171
|
+
// "use foreground" sentinel) becomes the text color.
|
|
172
|
+
static _toGradient(paint, originX, baselineY, scale, foreground, transform) {
|
|
173
|
+
const [a, b, c, d, e, f] = transform ?? [1, 0, 0, 1, 0, 0];
|
|
174
|
+
const mapX = (gx, gy) => originX + (a * gx + c * gy + e) * scale;
|
|
175
|
+
const mapY = (gx, gy) => baselineY - (b * gx + d * gy + f) * scale;
|
|
176
|
+
// A radius has no single value under a general affine (a circle can shear into an ellipse); use
|
|
177
|
+
// the affine's geometric-mean scale sqrt(|det|) times the em scale - exact for uniform scaling.
|
|
178
|
+
const radiusScale = scale * Math.sqrt(Math.abs(a * d - b * c));
|
|
179
|
+
const stops = paint.stops.map((s) => ({
|
|
180
|
+
offset: s.offset,
|
|
181
|
+
color: s.color ? new Color(s.color.r, s.color.g, s.color.b, s.color.a / 255) : foreground,
|
|
182
|
+
}));
|
|
183
|
+
if (paint.type === "linearGradient") {
|
|
184
|
+
return {
|
|
185
|
+
type: "linear",
|
|
186
|
+
x0: mapX(paint.p0[0], paint.p0[1]),
|
|
187
|
+
y0: mapY(paint.p0[0], paint.p0[1]),
|
|
188
|
+
x1: mapX(paint.p1[0], paint.p1[1]),
|
|
189
|
+
y1: mapY(paint.p1[0], paint.p1[1]),
|
|
190
|
+
stops,
|
|
191
|
+
extend: paint.extend,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
type: "radial",
|
|
196
|
+
x0: mapX(paint.c0[0], paint.c0[1]),
|
|
197
|
+
y0: mapY(paint.c0[0], paint.c0[1]),
|
|
198
|
+
r0: paint.c0[2] * radiusScale,
|
|
199
|
+
x1: mapX(paint.c1[0], paint.c1[1]),
|
|
200
|
+
y1: mapY(paint.c1[0], paint.c1[1]),
|
|
201
|
+
r1: paint.c1[2] * radiusScale,
|
|
202
|
+
stops,
|
|
203
|
+
extend: paint.extend,
|
|
204
|
+
};
|
|
25
205
|
}
|
|
26
206
|
// Lay the content out into absolutely-positioned text runs. Glyph positions match
|
|
27
207
|
// the previous hand-written operators exactly (verified by pixel-identical render);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function isEmojiCodePoint(codePoint: number): boolean;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Whether a code point is (very likely) an emoji, used ONLY by the image/CDN emoji source: unlike a
|
|
2
|
+
// font source, there is no font to ask "do you have a color glyph for this?", so we decide from the
|
|
3
|
+
// well-known emoji blocks. This is a curated, dependency-free subset - it covers the emoji people
|
|
4
|
+
// actually type, not the full Unicode Emoji property list, and intentionally errs toward the common
|
|
5
|
+
// blocks. A font source does NOT use this (it asks the font).
|
|
6
|
+
// [lo, hi] inclusive ranges of the main emoji / pictograph blocks.
|
|
7
|
+
const RANGES = [
|
|
8
|
+
[0x1f000, 0x1faff], // Mahjong/Dominoes/Cards, Misc Symbols & Pictographs, Emoticons, Transport,
|
|
9
|
+
// Supplemental Symbols & Pictographs, Symbols & Pictographs Extended-A (incl. flags)
|
|
10
|
+
[0x2190, 0x21ff], // Arrows
|
|
11
|
+
[0x2300, 0x23ff], // Misc Technical (⌚ ⏰ ⏳, media controls)
|
|
12
|
+
[0x2460, 0x24ff], // Enclosed Alphanumerics (Ⓜ)
|
|
13
|
+
[0x25a0, 0x25ff], // Geometric Shapes (▪ ◼ ⬛)
|
|
14
|
+
[0x2600, 0x27bf], // Misc Symbols + Dingbats (☀ ★ ✂ ✅ ✈ ✉ ✊ ✋)
|
|
15
|
+
[0x2900, 0x297f], // Supplemental Arrows-B (⤴ ⤵)
|
|
16
|
+
[0x2b00, 0x2bff], // Misc Symbols and Arrows (⭐ ⬅ ⬛)
|
|
17
|
+
[0x3000, 0x303f], // CJK Symbols and Punctuation (〰 〽)
|
|
18
|
+
[0x3200, 0x32ff], // Enclosed CJK Letters and Months (㊗ ㊙)
|
|
19
|
+
];
|
|
20
|
+
// Emoji-presentation singletons outside the ranges above.
|
|
21
|
+
const SINGLES = new Set([0x00a9, 0x00ae, 0x203c, 0x2049, 0x2122, 0x2139, 0xfe0f]);
|
|
22
|
+
export function isEmojiCodePoint(codePoint) {
|
|
23
|
+
if (SINGLES.has(codePoint))
|
|
24
|
+
return true;
|
|
25
|
+
for (const [lo, hi] of RANGES)
|
|
26
|
+
if (codePoint >= lo && codePoint <= hi)
|
|
27
|
+
return true;
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
@@ -133,9 +133,12 @@ function ellipsize(line, fontFamily, fontSize, fontStyle, maxWidth, metrics) {
|
|
|
133
133
|
if (fits(words.join(" ")))
|
|
134
134
|
return words.join(" ") + ELLIPSIS;
|
|
135
135
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
136
|
+
// Drop one code point at a time (not one UTF-16 unit) so an astral char is never split into a
|
|
137
|
+
// lone surrogate. `chars` is the code-point view; rebuild the string from its shrinking prefix.
|
|
138
|
+
const chars = [...(words[0] ?? "")];
|
|
139
|
+
while (chars.length > 1) {
|
|
140
|
+
chars.pop();
|
|
141
|
+
const single = chars.join("");
|
|
139
142
|
if (fits(single))
|
|
140
143
|
return single + ELLIPSIS;
|
|
141
144
|
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { OverflowPolicy } from "../layout/fragmentation.js";
|
|
2
|
+
import { TTFParser } from "./ttf-parser.js";
|
|
2
3
|
import type { SecurityHandler } from "../crypto/security-handler.js";
|
|
4
|
+
import type { Gradient } from "../ir/display-list.js";
|
|
5
|
+
import { StructTree } from "./struct-tree.js";
|
|
3
6
|
import type { PDFConfig } from "../renderer/pdf-document-class.js";
|
|
4
7
|
import type { FontMetrics } from "./font-metrics.js";
|
|
5
8
|
interface FontIndexes {
|
|
@@ -21,6 +24,9 @@ export declare class PDFObjectManager implements FontMetrics {
|
|
|
21
24
|
private fonts;
|
|
22
25
|
private images;
|
|
23
26
|
private extGStates;
|
|
27
|
+
private shadings;
|
|
28
|
+
private emojiFontName?;
|
|
29
|
+
private emojiImage?;
|
|
24
30
|
private pdfConfig;
|
|
25
31
|
pageFormat: [number, number];
|
|
26
32
|
private afmParsers;
|
|
@@ -36,6 +42,8 @@ export declare class PDFObjectManager implements FontMetrics {
|
|
|
36
42
|
private security?;
|
|
37
43
|
private encJobs;
|
|
38
44
|
private encryptObjNum?;
|
|
45
|
+
private _struct;
|
|
46
|
+
get struct(): StructTree;
|
|
39
47
|
constructor();
|
|
40
48
|
addObject(content: string): number;
|
|
41
49
|
replaceObject(objectNumber: number, content: string): void;
|
|
@@ -77,6 +85,9 @@ export declare class PDFObjectManager implements FontMetrics {
|
|
|
77
85
|
finalizeEncryption(): Promise<void>;
|
|
78
86
|
registerExtGState(fillAlpha: number, strokeAlpha: number): string;
|
|
79
87
|
getAllExtGStatesRaw(): Map<string, number>;
|
|
88
|
+
registerShading(g: Gradient): string;
|
|
89
|
+
private buildShadingFunction;
|
|
90
|
+
getAllShadingsRaw(): Map<string, number>;
|
|
80
91
|
registerFont(fontName: string, fontStyle?: FontStyle, fullName?: string): FontIndexes;
|
|
81
92
|
registerCustomFont(name: string, data: Uint8Array, style?: FontStyle): void;
|
|
82
93
|
private ensureEmitted;
|
|
@@ -86,6 +97,15 @@ export declare class PDFObjectManager implements FontMetrics {
|
|
|
86
97
|
private resolveCustomStyle;
|
|
87
98
|
private getCustomFont;
|
|
88
99
|
isCustomFont(name?: string, style?: FontStyle): boolean;
|
|
100
|
+
getColorFont(name: string, style?: FontStyle): TTFParser | undefined;
|
|
101
|
+
setEmojiFont(name: string): void;
|
|
102
|
+
getEmojiFont(): string | undefined;
|
|
103
|
+
setEmojiImageSource(url: string, format: string): void;
|
|
104
|
+
getEmojiImageSource(): {
|
|
105
|
+
url: string;
|
|
106
|
+
format: string;
|
|
107
|
+
} | undefined;
|
|
108
|
+
private colorRenders;
|
|
89
109
|
getCustomFontResource(name: string, style?: FontStyle): FontIndexes | undefined;
|
|
90
110
|
encodeCustomText(name: string, text: string, style?: FontStyle): string;
|
|
91
111
|
private buildFontFile2;
|
|
@@ -94,7 +114,6 @@ export declare class PDFObjectManager implements FontMetrics {
|
|
|
94
114
|
private buildToUnicode;
|
|
95
115
|
private buildType0;
|
|
96
116
|
getStringWidth(text: string, fontFamily: string, fontSize: number, fontStyle: FontStyle): number;
|
|
97
|
-
private getCharCode;
|
|
98
117
|
private getAVMParserByFont;
|
|
99
118
|
getCharWidth(char: string, fontSize: number, fullFontName?: string, fontName?: string, fontStyle?: FontStyle): number;
|
|
100
119
|
private getKerning;
|
|
Binary file
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { StructTag } from "../ir/display-list.js";
|
|
2
|
+
import type { PDFObjectManager } from "./pdf-object-manager.js";
|
|
3
|
+
/** Per-page tagging context: hands out page-local MCIDs and links them to their StructElem. */
|
|
4
|
+
export declare class PageStructContext {
|
|
5
|
+
readonly structParents: number;
|
|
6
|
+
private readonly tree;
|
|
7
|
+
private mcid;
|
|
8
|
+
constructor(structParents: number, tree: StructTree);
|
|
9
|
+
/** Marked-content delimiters for one drawable node. Tagged content gets an MCID linked to its (already
|
|
10
|
+
* registered) StructElem; untagged content becomes an Artifact (decoration, skipped by assistive tech). */
|
|
11
|
+
mark(tag?: StructTag): {
|
|
12
|
+
open: string;
|
|
13
|
+
close: string;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/** Builds the PDF structure tree for accessible tagging. One instance per document (held by the object manager). */
|
|
17
|
+
export declare class StructTree {
|
|
18
|
+
enabled: boolean;
|
|
19
|
+
lang: string;
|
|
20
|
+
title?: string;
|
|
21
|
+
private counter;
|
|
22
|
+
private structParentsCounter;
|
|
23
|
+
private elems;
|
|
24
|
+
private byStructId;
|
|
25
|
+
private stack;
|
|
26
|
+
private pageObjByStructParents;
|
|
27
|
+
/** Register a structure element for a logical element (keyed by its stable `structId`, so a paragraph or
|
|
28
|
+
* table split across pages resolves to the SAME element - MCIDs then accrue from every page). Its parent
|
|
29
|
+
* is the currently-open container, else the Document. Containers also push()/pop() around their children. */
|
|
30
|
+
openElement(structId: number, role: string, opts?: {
|
|
31
|
+
alt?: string;
|
|
32
|
+
}): number;
|
|
33
|
+
push(key: number): void;
|
|
34
|
+
pop(): void;
|
|
35
|
+
/** Begin a page: allocates its StructParents index (its slot in the ParentTree). */
|
|
36
|
+
beginPage(): PageStructContext;
|
|
37
|
+
/** Tie a page's StructParents index to its now-known page object number (for /Pg references). */
|
|
38
|
+
setPageObject(structParents: number, pageObjNum: number): void;
|
|
39
|
+
/** Link a page-local MCID to an element (called by the backend while serializing). */
|
|
40
|
+
linkMarkedContent(key: number, structParents: number, mcid: number): void;
|
|
41
|
+
/** Emit the struct-tree objects and return the catalog additions (`/MarkInfo`, `/StructTreeRoot`, `/Lang`).
|
|
42
|
+
* A no-op returning "" when disabled or nothing was tagged, so a plain document stays byte-identical. */
|
|
43
|
+
finalize(om: PDFObjectManager): string;
|
|
44
|
+
}
|