@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.
Files changed (42) hide show
  1. package/README.md +5 -2
  2. package/dist/api/content.d.ts +3 -0
  3. package/dist/api/content.js +1 -0
  4. package/dist/api/structure.d.ts +19 -0
  5. package/dist/api/structure.js +25 -0
  6. package/dist/api/table.js +13 -5
  7. package/dist/api/text.d.ts +4 -1
  8. package/dist/api/text.js +1 -0
  9. package/dist/elements/image-element.d.ts +5 -1
  10. package/dist/elements/image-element.js +3 -1
  11. package/dist/elements/layout/struct-group.d.ts +28 -0
  12. package/dist/elements/layout/struct-group.js +42 -0
  13. package/dist/elements/pdf-element.d.ts +4 -0
  14. package/dist/elements/pdf-element.js +15 -0
  15. package/dist/elements/text-element.d.ts +7 -1
  16. package/dist/elements/text-element.js +5 -2
  17. package/dist/ir/display-list.d.ts +79 -1
  18. package/dist/layout/fragmentation.js +9 -2
  19. package/dist/renderer/emoji-image.d.ts +9 -0
  20. package/dist/renderer/emoji-image.js +68 -0
  21. package/dist/renderer/image-renderer.d.ts +1 -1
  22. package/dist/renderer/image-renderer.js +10 -2
  23. package/dist/renderer/page-renderer.js +19 -4
  24. package/dist/renderer/pdf-backend.d.ts +6 -2
  25. package/dist/renderer/pdf-backend.js +59 -3
  26. package/dist/renderer/pdf-renderer.js +11 -0
  27. package/dist/renderer/struct-group-renderer.d.ts +10 -0
  28. package/dist/renderer/struct-group-renderer.js +24 -0
  29. package/dist/renderer/text-renderer.d.ts +3 -0
  30. package/dist/renderer/text-renderer.js +182 -2
  31. package/dist/text/emoji-codepoints.d.ts +1 -0
  32. package/dist/text/emoji-codepoints.js +29 -0
  33. package/dist/text/line-breaker.js +6 -3
  34. package/dist/utils/pdf-object-manager.d.ts +20 -1
  35. package/dist/utils/pdf-object-manager.js +0 -0
  36. package/dist/utils/struct-tree.d.ts +44 -0
  37. package/dist/utils/struct-tree.js +130 -0
  38. package/dist/utils/ttf-parser.d.ts +73 -0
  39. package/dist/utils/ttf-parser.js +456 -16
  40. package/dist/utils/ua-xmp.d.ts +9 -0
  41. package/dist/utils/ua-xmp.js +32 -0
  42. 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, _objectManager) {
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
- const pageContent = PdfBackend.serialize(PdfBackend.flipY(nodes, height), objectManager);
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
- const pageObject = `<< /Type /Page /Parent ${parentObjectNumber} 0 R /Contents ${contentObjectNumber} 0 R /Resources <<\n/Font <<\n${fontReferences.join("\n")}\n>>\n${imageCode}${extGStateCode}>>\n/MediaBox [0 0 ${width} ${height}] >>`;
62
- // Add page as new object and return the page number
63
- return objectManager.addObject(pageObject);
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
- static serialize(nodes: IRNode[], om: PDFObjectManager): string;
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
- static serialize(nodes, om) {
56
- return nodes.map((node) => PdfBackend.serializeNode(node, om)).join("");
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
- return TextRenderer._buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, width ?? Number.NaN, textAlignment, color, x, y, maxLines, overflow, lineHeight);
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
- let single = words[0] ?? "";
137
- while (single.length > 1) {
138
- single = single.slice(0, -1);
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
+ }