@jasy/pdf 1.0.0-alpha.4 → 1.0.0-alpha.6
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 +9 -3
- package/dist/api/content.js +13 -3
- package/dist/api/descriptor.js +22 -1
- package/dist/api/dimension.d.ts +20 -0
- package/dist/api/dimension.js +16 -0
- package/dist/api/index.d.ts +2 -0
- package/dist/api/index.js +2 -0
- package/dist/api/layout.d.ts +65 -3
- package/dist/api/layout.js +71 -2
- package/dist/api/page-builder.d.ts +32 -0
- package/dist/api/page-builder.js +32 -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 +10 -1
- package/dist/api/text.js +32 -4
- package/dist/elements/container-element.d.ts +17 -1
- package/dist/elements/container-element.js +54 -22
- package/dist/elements/image-element.d.ts +23 -1
- package/dist/elements/image-element.js +54 -11
- package/dist/elements/index.d.ts +6 -0
- package/dist/elements/index.js +6 -0
- package/dist/elements/layout/anchor-element.d.ts +23 -0
- package/dist/elements/layout/anchor-element.js +27 -0
- package/dist/elements/layout/bookmark-element.d.ts +27 -0
- package/dist/elements/layout/bookmark-element.js +30 -0
- package/dist/elements/layout/link-element.d.ts +33 -0
- package/dist/elements/layout/link-element.js +39 -0
- package/dist/elements/layout/page-builder-element.d.ts +32 -0
- package/dist/elements/layout/page-builder-element.js +43 -0
- package/dist/elements/layout/rotated-box-element.d.ts +35 -0
- package/dist/elements/layout/rotated-box-element.js +54 -0
- package/dist/elements/layout/rotated-element.d.ts +32 -0
- package/dist/elements/layout/rotated-element.js +41 -0
- package/dist/elements/layout/struct-group.d.ts +28 -0
- package/dist/elements/layout/struct-group.js +42 -0
- package/dist/elements/page-element.d.ts +8 -0
- package/dist/elements/page-element.js +13 -1
- package/dist/elements/pdf-element.d.ts +35 -0
- package/dist/elements/pdf-element.js +34 -0
- package/dist/elements/rectangle-element.d.ts +11 -1
- package/dist/elements/rectangle-element.js +49 -13
- package/dist/elements/row-element.d.ts +11 -1
- package/dist/elements/row-element.js +37 -8
- package/dist/elements/text-element.d.ts +11 -1
- package/dist/elements/text-element.js +5 -2
- package/dist/ir/display-list.d.ts +137 -1
- package/dist/layout/box-constraints.d.ts +8 -0
- package/dist/layout/box-constraints.js +14 -0
- package/dist/layout/collect-images.d.ts +9 -0
- package/dist/layout/collect-images.js +34 -0
- package/dist/layout/fragmentation.js +9 -2
- package/dist/renderer/anchor-renderer.d.ts +6 -0
- package/dist/renderer/anchor-renderer.js +11 -0
- package/dist/renderer/bookmark-renderer.d.ts +6 -0
- package/dist/renderer/bookmark-renderer.js +11 -0
- 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/link-renderer.d.ts +6 -0
- package/dist/renderer/link-renderer.js +11 -0
- package/dist/renderer/page-builder-renderer.d.ts +6 -0
- package/dist/renderer/page-builder-renderer.js +11 -0
- package/dist/renderer/page-renderer.js +54 -4
- package/dist/renderer/pdf-backend.d.ts +16 -2
- package/dist/renderer/pdf-backend.js +135 -3
- package/dist/renderer/pdf-document-renderer.d.ts +16 -7
- package/dist/renderer/pdf-document-renderer.js +65 -33
- package/dist/renderer/pdf-renderer.js +58 -2
- package/dist/renderer/rotated-renderer.d.ts +7 -0
- package/dist/renderer/rotated-renderer.js +29 -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 +206 -5
- 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/afm-parser.js +6 -4
- package/dist/utils/dest-registry.d.ts +19 -0
- package/dist/utils/dest-registry.js +32 -0
- package/dist/utils/flex-layout.d.ts +10 -3
- package/dist/utils/flex-layout.js +26 -6
- package/dist/utils/outline.d.ts +21 -0
- package/dist/utils/outline.js +80 -0
- package/dist/utils/pdf-object-manager.d.ts +26 -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
|
@@ -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,17 +20,200 @@ 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, links } = 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
|
+
const drawn = (await Promise.all(runs.map((run) => TextRenderer._expandColorGlyphs(run, objectManager)))).flat();
|
|
40
|
+
// Inline hyperlinks (href spans) ride along as /Link annotations - they draw nothing, so order
|
|
41
|
+
// vs the drawn runs does not matter; the page renderer peels them off into /Annots.
|
|
42
|
+
return links.length > 0 ? [...drawn, ...links] : drawn;
|
|
43
|
+
}
|
|
44
|
+
// Splits a text run into normal text sub-runs + filled `Path` layers for any COLR color glyphs,
|
|
45
|
+
// walking code point by code point and advancing the pen by each glyph's width. Returns `[run]`
|
|
46
|
+
// untouched when the font has no color glyphs (the common case).
|
|
47
|
+
static async _expandColorGlyphs(run, om) {
|
|
48
|
+
// The run's own font (if it's a color font) draws emoji directly; otherwise a document-level
|
|
49
|
+
// emoji source (`Document({ emoji })`) supplies them - a fallback FONT (color glyphs, native
|
|
50
|
+
// vector) or an IMAGE source (fetched emoji images) - for code points the run's font lacks, so
|
|
51
|
+
// `Text("Hallo 😅")` works in one string. No emoji source at all -> pass the run through.
|
|
52
|
+
const own = om.getColorFont(run.fontFamily, run.fontStyle);
|
|
53
|
+
const emojiName = om.getEmojiFont();
|
|
54
|
+
const fallback = emojiName && emojiName !== run.fontFamily
|
|
55
|
+
? om.getColorFont(emojiName, run.fontStyle)
|
|
56
|
+
: undefined;
|
|
57
|
+
const imageSource = om.getEmojiImageSource();
|
|
58
|
+
if (!own && !fallback && !imageSource)
|
|
59
|
+
return [run];
|
|
60
|
+
const nodes = [];
|
|
61
|
+
let cursorX = run.x; // absolute x of the next glyph
|
|
62
|
+
let pending = ""; // run of consecutive non-color characters
|
|
63
|
+
let pendingX = run.x; // where that run started
|
|
64
|
+
const flushPending = () => {
|
|
65
|
+
if (pending) {
|
|
66
|
+
nodes.push({ ...run, x: pendingX, text: pending });
|
|
67
|
+
pending = "";
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
for (const ch of run.text) {
|
|
71
|
+
const cp = ch.codePointAt(0);
|
|
72
|
+
// Which color font renders this code point: the run's own font first, then the emoji fallback.
|
|
73
|
+
let source;
|
|
74
|
+
let layers = null;
|
|
75
|
+
if (own) {
|
|
76
|
+
const l = own.getColorGlyph(own.getGlyphIndex(cp));
|
|
77
|
+
if (l)
|
|
78
|
+
[source, layers] = [own, l];
|
|
79
|
+
}
|
|
80
|
+
if (!layers && fallback) {
|
|
81
|
+
const l = fallback.getColorGlyph(fallback.getGlyphIndex(cp));
|
|
82
|
+
if (l)
|
|
83
|
+
[source, layers] = [fallback, l];
|
|
84
|
+
}
|
|
85
|
+
// The advance matches measuring (getCharWidth applies the same emoji fallback).
|
|
86
|
+
const advance = om.getCharWidth(ch, run.fontSize, undefined, run.fontFamily, run.fontStyle);
|
|
87
|
+
if (source && layers) {
|
|
88
|
+
flushPending();
|
|
89
|
+
const scale = run.fontSize / source.unitsPerEm;
|
|
90
|
+
for (const layer of layers) {
|
|
91
|
+
const commands = TextRenderer._glyphToPath(source.getGlyphPath(layer.glyphId), cursorX, run.y, scale, layer.transform);
|
|
92
|
+
if (commands.length === 0)
|
|
93
|
+
continue; // an empty layer outline draws nothing
|
|
94
|
+
const paint = layer.paint;
|
|
95
|
+
let fill;
|
|
96
|
+
if (paint.type === "solid") {
|
|
97
|
+
const c = paint.color;
|
|
98
|
+
fill = c ? new Color(c.r, c.g, c.b, c.a / 255) : run.color; // null -> foreground
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
fill = TextRenderer._toGradient(paint, cursorX, run.y, scale, run.color, layer.transform);
|
|
102
|
+
}
|
|
103
|
+
nodes.push({ type: "path", commands, fill });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else if (imageSource && isEmojiCodePoint(cp)) {
|
|
107
|
+
// Image emoji source: fetch + embed a 1em square. The emoji breaks the text run either way;
|
|
108
|
+
// a failed fetch (offline/404) leaves a blank em gap (advance already applied) rather than
|
|
109
|
+
// drawing a broken glyph.
|
|
110
|
+
flushPending();
|
|
111
|
+
const node = await emojiImageNode(cp, imageSource, cursorX, run.y, run.fontSize);
|
|
112
|
+
if (node)
|
|
113
|
+
nodes.push(node);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
if (!pending)
|
|
117
|
+
pendingX = cursorX;
|
|
118
|
+
pending += ch;
|
|
119
|
+
}
|
|
120
|
+
cursorX += advance;
|
|
121
|
+
}
|
|
122
|
+
flushPending();
|
|
123
|
+
return nodes;
|
|
124
|
+
}
|
|
125
|
+
// Maps a glyph outline (font units, y-up) to absolute IR path commands at the pen origin, scaling
|
|
126
|
+
// by `scale` and flipping the axis into the engine's top-left space (the seam flips it once more
|
|
127
|
+
// to PDF space). TrueType quadratics become cubics: C1 = P0 + 2/3(C-P0), C2 = P1 + 2/3(C-P1).
|
|
128
|
+
static _glyphToPath(cmds, originX, baselineY, scale, transform) {
|
|
129
|
+
// Apply the layer's affine (font units) first, then scale to points, position at the pen origin
|
|
130
|
+
// and flip into engine space. `mapX`/`mapY` take BOTH glyph coords because an affine mixes them.
|
|
131
|
+
const [a, b, c, d, e, f] = transform ?? [1, 0, 0, 1, 0, 0];
|
|
132
|
+
const mapX = (gx, gy) => originX + (a * gx + c * gy + e) * scale;
|
|
133
|
+
const mapY = (gx, gy) => baselineY - (b * gx + d * gy + f) * scale;
|
|
134
|
+
const out = [];
|
|
135
|
+
let curX = 0;
|
|
136
|
+
let curY = 0;
|
|
137
|
+
for (const cmd of cmds) {
|
|
138
|
+
if (cmd.type === "M") {
|
|
139
|
+
curX = mapX(cmd.x, cmd.y);
|
|
140
|
+
curY = mapY(cmd.x, cmd.y);
|
|
141
|
+
out.push({ op: "m", x: curX, y: curY });
|
|
142
|
+
}
|
|
143
|
+
else if (cmd.type === "L") {
|
|
144
|
+
curX = mapX(cmd.x, cmd.y);
|
|
145
|
+
curY = mapY(cmd.x, cmd.y);
|
|
146
|
+
out.push({ op: "l", x: curX, y: curY });
|
|
147
|
+
}
|
|
148
|
+
else if (cmd.type === "Q") {
|
|
149
|
+
const ctrlX = mapX(cmd.cx, cmd.cy);
|
|
150
|
+
const ctrlY = mapY(cmd.cx, cmd.cy);
|
|
151
|
+
const endX = mapX(cmd.x, cmd.y);
|
|
152
|
+
const endY = mapY(cmd.x, cmd.y);
|
|
153
|
+
out.push({
|
|
154
|
+
op: "c",
|
|
155
|
+
x1: curX + (2 / 3) * (ctrlX - curX),
|
|
156
|
+
y1: curY + (2 / 3) * (ctrlY - curY),
|
|
157
|
+
x2: endX + (2 / 3) * (ctrlX - endX),
|
|
158
|
+
y2: endY + (2 / 3) * (ctrlY - endY),
|
|
159
|
+
x: endX,
|
|
160
|
+
y: endY,
|
|
161
|
+
});
|
|
162
|
+
curX = endX;
|
|
163
|
+
curY = endY;
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
out.push({ op: "z" });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
// Maps a COLR v1 gradient paint (font-unit coordinates) into an IR `Gradient` positioned at the
|
|
172
|
+
// pen origin, using the SAME scale + baseline flip as the glyph outline so the gradient axis lines
|
|
173
|
+
// up with the shape. Radii scale uniformly (the em scale is uniform). A null stop color (the COLR
|
|
174
|
+
// "use foreground" sentinel) becomes the text color.
|
|
175
|
+
static _toGradient(paint, originX, baselineY, scale, foreground, transform) {
|
|
176
|
+
const [a, b, c, d, e, f] = transform ?? [1, 0, 0, 1, 0, 0];
|
|
177
|
+
const mapX = (gx, gy) => originX + (a * gx + c * gy + e) * scale;
|
|
178
|
+
const mapY = (gx, gy) => baselineY - (b * gx + d * gy + f) * scale;
|
|
179
|
+
// A radius has no single value under a general affine (a circle can shear into an ellipse); use
|
|
180
|
+
// the affine's geometric-mean scale sqrt(|det|) times the em scale - exact for uniform scaling.
|
|
181
|
+
const radiusScale = scale * Math.sqrt(Math.abs(a * d - b * c));
|
|
182
|
+
const stops = paint.stops.map((s) => ({
|
|
183
|
+
offset: s.offset,
|
|
184
|
+
color: s.color ? new Color(s.color.r, s.color.g, s.color.b, s.color.a / 255) : foreground,
|
|
185
|
+
}));
|
|
186
|
+
if (paint.type === "linearGradient") {
|
|
187
|
+
return {
|
|
188
|
+
type: "linear",
|
|
189
|
+
x0: mapX(paint.p0[0], paint.p0[1]),
|
|
190
|
+
y0: mapY(paint.p0[0], paint.p0[1]),
|
|
191
|
+
x1: mapX(paint.p1[0], paint.p1[1]),
|
|
192
|
+
y1: mapY(paint.p1[0], paint.p1[1]),
|
|
193
|
+
stops,
|
|
194
|
+
extend: paint.extend,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
type: "radial",
|
|
199
|
+
x0: mapX(paint.c0[0], paint.c0[1]),
|
|
200
|
+
y0: mapY(paint.c0[0], paint.c0[1]),
|
|
201
|
+
r0: paint.c0[2] * radiusScale,
|
|
202
|
+
x1: mapX(paint.c1[0], paint.c1[1]),
|
|
203
|
+
y1: mapY(paint.c1[0], paint.c1[1]),
|
|
204
|
+
r1: paint.c1[2] * radiusScale,
|
|
205
|
+
stops,
|
|
206
|
+
extend: paint.extend,
|
|
207
|
+
};
|
|
25
208
|
}
|
|
26
209
|
// Lay the content out into absolutely-positioned text runs. Glyph positions match
|
|
27
210
|
// the previous hand-written operators exactly (verified by pixel-identical render);
|
|
28
211
|
// only the output form changed from PDF strings to `TextRun`s.
|
|
29
212
|
static _buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, maxWidth, textAlignment, color, initialX, yPosition, maxLines, overflow, lineHeight = 1) {
|
|
30
213
|
const runs = [];
|
|
214
|
+
// /Link annotation rects for any `href` spans, collected as we place segments. Empty for the
|
|
215
|
+
// common (no-href) case, so plain text keeps producing exactly the runs it did before.
|
|
216
|
+
const links = [];
|
|
31
217
|
// Horizontal offset of a line of the given width under the current alignment.
|
|
32
218
|
const alignmentOffset = (lineWidth) => {
|
|
33
219
|
if (textAlignment === HorizontalAlignment.center)
|
|
@@ -68,7 +254,7 @@ export class TextRenderer {
|
|
|
68
254
|
color,
|
|
69
255
|
});
|
|
70
256
|
});
|
|
71
|
-
return runs;
|
|
257
|
+
return { runs, links };
|
|
72
258
|
}
|
|
73
259
|
// --- Segments: break into lines (shared breaker), then emit one run per segment.
|
|
74
260
|
// Segment 0 starts at the aligned line origin; each following segment is offset by
|
|
@@ -81,6 +267,7 @@ export class TextRenderer {
|
|
|
81
267
|
const family = segment.fontFamily || fontFamily;
|
|
82
268
|
const size = segment.fontSize || fontSize;
|
|
83
269
|
const style = segment.fontStyle || fontStyle;
|
|
270
|
+
const advance = advanceNoKerning(segment.content, family, size, style);
|
|
84
271
|
runs.push({
|
|
85
272
|
type: "text",
|
|
86
273
|
x,
|
|
@@ -91,7 +278,21 @@ export class TextRenderer {
|
|
|
91
278
|
fontSize: size,
|
|
92
279
|
color: segment.fontColor || color,
|
|
93
280
|
});
|
|
94
|
-
|
|
281
|
+
// An href/to span becomes a /Link annotation over exactly this run's glyph box. `lineY` is the
|
|
282
|
+
// baseline; the box spans from the ascent (BASELINE_RATIO above it) down by the full size, so
|
|
283
|
+
// ascent + descent are covered. A span wrapped across lines yields one rect per line.
|
|
284
|
+
if (segment.href !== undefined || segment.dest !== undefined) {
|
|
285
|
+
links.push({
|
|
286
|
+
type: "link",
|
|
287
|
+
x,
|
|
288
|
+
y: lineY - size * BASELINE_RATIO,
|
|
289
|
+
width: advance,
|
|
290
|
+
height: size,
|
|
291
|
+
href: segment.href,
|
|
292
|
+
dest: segment.dest,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
x += advance;
|
|
95
296
|
});
|
|
96
297
|
};
|
|
97
298
|
// The overall tallest font seats the first baseline; each line then steps DOWN by
|
|
@@ -111,6 +312,6 @@ export class TextRenderer {
|
|
|
111
312
|
pushLine(line, lineY + halfLeading);
|
|
112
313
|
lineY += line.maxFontSize * lineHeight;
|
|
113
314
|
}
|
|
114
|
-
return runs;
|
|
315
|
+
return { runs, links };
|
|
115
316
|
}
|
|
116
317
|
}
|
|
@@ -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
|
}
|
package/dist/utils/afm-parser.js
CHANGED
|
@@ -25,6 +25,7 @@ export class AFMParser {
|
|
|
25
25
|
return this.glyphMap[char] || char;
|
|
26
26
|
}
|
|
27
27
|
parseAFMData(afmData) {
|
|
28
|
+
var _a;
|
|
28
29
|
const lines = afmData.split("\n");
|
|
29
30
|
let inCharMetrics = false;
|
|
30
31
|
for (let line of lines) {
|
|
@@ -49,8 +50,10 @@ export class AFMParser {
|
|
|
49
50
|
const firstChar = parts[1];
|
|
50
51
|
const secondChar = parts[2];
|
|
51
52
|
const kerning = parseFloat(parts[3]);
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
// Nested by first glyph, then second. A flat `${a}-${b}` key would have to be BUILT on every
|
|
54
|
+
// lookup, and getKerning runs once per adjacent character pair - that string allocation was a
|
|
55
|
+
// quarter of a standard-font render.
|
|
56
|
+
((_a = this.kerningPairs)[firstChar] ?? (_a[firstChar] = {}))[secondChar] = kerning;
|
|
54
57
|
}
|
|
55
58
|
}
|
|
56
59
|
}
|
|
@@ -75,7 +78,6 @@ export class AFMParser {
|
|
|
75
78
|
return this.advanceWidths[this.getGlyphName(charName)] || 0;
|
|
76
79
|
}
|
|
77
80
|
getKerning(firstChar, secondChar) {
|
|
78
|
-
|
|
79
|
-
return this.kerningPairs[pair] || 0;
|
|
81
|
+
return this.kerningPairs[firstChar]?.[secondChar] ?? 0;
|
|
80
82
|
}
|
|
81
83
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
interface Dest {
|
|
2
|
+
pageObjNum: number;
|
|
3
|
+
top: number;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Collects named destinations - the jump targets an internal `Link({ to })` points at, declared with
|
|
7
|
+
* `Anchor({ name })`. `finalize` emits the `/Dests` name-tree fragment for the catalog `/Names` dict, or
|
|
8
|
+
* "" when no anchor was placed. Names are strings the viewer resolves at click time, so a link can point
|
|
9
|
+
* at an anchor on a later (not-yet-rendered) page without any forward-reference bookkeeping.
|
|
10
|
+
*/
|
|
11
|
+
export declare class DestRegistry {
|
|
12
|
+
private dests;
|
|
13
|
+
add(name: string, dest: Dest): void;
|
|
14
|
+
get isEmpty(): boolean;
|
|
15
|
+
/** The `/Dests << /Names [ ... ] >>` fragment for the catalog `/Names` dictionary (merged with any
|
|
16
|
+
* `/EmbeddedFiles`). Returns "" when empty. Names are sorted lexically - a name tree requires it. */
|
|
17
|
+
finalize(): string;
|
|
18
|
+
}
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// A PDF literal string escape (backslash first, then the string delimiters).
|
|
2
|
+
const esc = (s) => s.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
|
|
3
|
+
const num = (n) => (Number.isFinite(n) ? Number(n.toFixed(2)) : 0);
|
|
4
|
+
/**
|
|
5
|
+
* Collects named destinations - the jump targets an internal `Link({ to })` points at, declared with
|
|
6
|
+
* `Anchor({ name })`. `finalize` emits the `/Dests` name-tree fragment for the catalog `/Names` dict, or
|
|
7
|
+
* "" when no anchor was placed. Names are strings the viewer resolves at click time, so a link can point
|
|
8
|
+
* at an anchor on a later (not-yet-rendered) page without any forward-reference bookkeeping.
|
|
9
|
+
*/
|
|
10
|
+
export class DestRegistry {
|
|
11
|
+
constructor() {
|
|
12
|
+
// Keyed by name so a duplicate name resolves to its last definition (last anchor wins).
|
|
13
|
+
this.dests = new Map();
|
|
14
|
+
}
|
|
15
|
+
add(name, dest) {
|
|
16
|
+
this.dests.set(name, dest);
|
|
17
|
+
}
|
|
18
|
+
get isEmpty() {
|
|
19
|
+
return this.dests.size === 0;
|
|
20
|
+
}
|
|
21
|
+
/** The `/Dests << /Names [ ... ] >>` fragment for the catalog `/Names` dictionary (merged with any
|
|
22
|
+
* `/EmbeddedFiles`). Returns "" when empty. Names are sorted lexically - a name tree requires it. */
|
|
23
|
+
finalize() {
|
|
24
|
+
if (this.dests.size === 0)
|
|
25
|
+
return "";
|
|
26
|
+
const entries = [...this.dests.keys()].sort().map((name) => {
|
|
27
|
+
const d = this.dests.get(name);
|
|
28
|
+
return `(${esc(name)}) [${d.pageObjNum} 0 R /XYZ null ${num(d.top)} null]`;
|
|
29
|
+
});
|
|
30
|
+
return `/Dests << /Names [ ${entries.join(" ")} ] >>`;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -12,18 +12,25 @@ export type CrossAlign = "start" | "center" | "end" | "stretch";
|
|
|
12
12
|
* (main = width). "main" is the stacking direction; "cross" is perpendicular.
|
|
13
13
|
*/
|
|
14
14
|
export interface FlexAxis {
|
|
15
|
+
/** Whether the MAIN (stacking) axis is horizontal - i.e. this is a Row. Used to ask a child for the
|
|
16
|
+
* right relative-size factor (width vs height) when resolving a percentage child. */
|
|
17
|
+
mainHorizontal: boolean;
|
|
15
18
|
mainOf(size: Size): number;
|
|
16
19
|
crossOf(size: Size): number;
|
|
17
20
|
/**
|
|
18
|
-
* Constraints for a fixed child: the main extent is unbounded (
|
|
19
|
-
* size); the cross extent is ALWAYS capped to what the line offers. The cap is a hard
|
|
21
|
+
* Constraints for a fixed child: the main extent is unbounded by default (the child takes its
|
|
22
|
+
* natural size); the cross extent is ALWAYS capped to what the line offers. The cap is a hard
|
|
20
23
|
* ceiling regardless of `cross` alignment - it is what guarantees nothing ever overflows
|
|
21
24
|
* (a paragraph wraps at the column width instead of running one line off the page). A
|
|
22
25
|
* child smaller than the cap keeps its size and is positioned by `crossOffset`; a child
|
|
23
26
|
* that wants to fill (Container, Text) fills the cap. So `stretch` vs `start/center/end`
|
|
24
27
|
* differ only in where a smaller child sits, never in the ceiling.
|
|
28
|
+
*
|
|
29
|
+
* `mainMax` bounds the main axis too (default unbounded): passed only for a PERCENTAGE child, so its
|
|
30
|
+
* `width: "50%"` can resolve against the space the line offers its items. A plain child keeps the
|
|
31
|
+
* unbounded main axis and shrink-wraps as before.
|
|
25
32
|
*/
|
|
26
|
-
measureConstraints(crossAvail: number): BoxConstraints;
|
|
33
|
+
measureConstraints(crossAvail: number, mainMax?: number): BoxConstraints;
|
|
27
34
|
/** Constraints for a flex child (fills the cross axis like a stretched child). */
|
|
28
35
|
flexConstraints(mainExtent: number, crossAvail: number): BoxConstraints;
|
|
29
36
|
/** Absolute offset for a child at main position `mainPos`, cross position `crossPos`. */
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { FlexiblePDFElement } from "../elements/pdf-element.js";
|
|
2
2
|
import { BoxConstraints } from "../layout/box-constraints.js";
|
|
3
3
|
export const VERTICAL_AXIS = {
|
|
4
|
+
mainHorizontal: false,
|
|
4
5
|
mainOf: (s) => s.height,
|
|
5
6
|
crossOf: (s) => s.width,
|
|
6
|
-
measureConstraints: (crossAvail) => BoxConstraints.loose(crossAvail,
|
|
7
|
+
measureConstraints: (crossAvail, mainMax = Infinity) => BoxConstraints.loose(crossAvail, mainMax),
|
|
7
8
|
flexConstraints: (mainExtent, crossAvail) => BoxConstraints.loose(crossAvail, mainExtent),
|
|
8
9
|
offsetAt: (mainPos, crossPos) => ({ x: crossPos, y: mainPos }),
|
|
9
10
|
};
|
|
10
11
|
export const HORIZONTAL_AXIS = {
|
|
12
|
+
mainHorizontal: true,
|
|
11
13
|
mainOf: (s) => s.width,
|
|
12
14
|
crossOf: (s) => s.height,
|
|
13
|
-
measureConstraints: (crossAvail) => BoxConstraints.loose(
|
|
15
|
+
measureConstraints: (crossAvail, mainMax = Infinity) => BoxConstraints.loose(mainMax, crossAvail),
|
|
14
16
|
flexConstraints: (mainExtent, crossAvail) => BoxConstraints.loose(mainExtent, crossAvail),
|
|
15
17
|
offsetAt: (mainPos, crossPos) => ({ x: mainPos, y: crossPos }),
|
|
16
18
|
};
|
|
@@ -37,6 +39,22 @@ export class FlexLayoutHelper {
|
|
|
37
39
|
const main = options.main ?? "start";
|
|
38
40
|
const cross = options.cross ?? "stretch";
|
|
39
41
|
const count = children.length;
|
|
42
|
+
const totalGap = Math.max(0, count - 1) * gap;
|
|
43
|
+
// The main extent a percentage child resolves against: the line's own extent minus the gaps
|
|
44
|
+
// between the items. So N children at (100/N)% + gaps fit exactly, instead of overflowing by the
|
|
45
|
+
// gaps (the flexbox `width: 33%` + gap trap). Only finite when the line's main axis is bounded -
|
|
46
|
+
// a fraction of an unbounded main axis has no meaning, so `%` children fall back to shrink-wrap.
|
|
47
|
+
const percentBase = mainAvail !== Infinity ? Math.max(0, mainAvail - totalGap) : Infinity;
|
|
48
|
+
// The main cap to hand a child: `percentBase` for a percentage child (so its fraction resolves) and
|
|
49
|
+
// for a child that cannot lay itself out without a bound (a nested stack holding an `Expanded`/
|
|
50
|
+
// `Spacer`); unbounded for everyone else, who keep their natural size and never fill the line.
|
|
51
|
+
const mainCapFor = (child) => {
|
|
52
|
+
if (percentBase === Infinity)
|
|
53
|
+
return Infinity;
|
|
54
|
+
const needsBound = child.relativeSizeFactor(axis.mainHorizontal) !== undefined ||
|
|
55
|
+
child.needsBoundedMain(axis.mainHorizontal);
|
|
56
|
+
return needsBound ? percentBase : Infinity;
|
|
57
|
+
};
|
|
40
58
|
// Pass 1: measure the fixed children (main extent + cross size) and total the flex.
|
|
41
59
|
let fixedMain = 0;
|
|
42
60
|
let totalFlex = 0;
|
|
@@ -47,15 +65,17 @@ export class FlexLayoutHelper {
|
|
|
47
65
|
totalFlex += child.getFlex();
|
|
48
66
|
}
|
|
49
67
|
else {
|
|
50
|
-
const size = child.calculateLayout(axis.measureConstraints(crossAvail), axis.offsetAt(mainStart, crossOrigin), ctx);
|
|
68
|
+
const size = child.calculateLayout(axis.measureConstraints(crossAvail, mainCapFor(child)), axis.offsetAt(mainStart, crossOrigin), ctx);
|
|
51
69
|
fixedMain += axis.mainOf(size);
|
|
52
70
|
crossUsed = Math.max(crossUsed, axis.crossOf(size));
|
|
53
71
|
fixedSize.set(child, size);
|
|
54
72
|
}
|
|
55
73
|
}
|
|
56
|
-
const totalGap = Math.max(0, count - 1) * gap;
|
|
57
74
|
const leftover = mainAvail - fixedMain - totalGap;
|
|
58
|
-
|
|
75
|
+
// A flex child on an UNBOUNDED main axis has no leftover space to claim. It must collapse to zero,
|
|
76
|
+
// never to `Infinity`: an infinite extent would become the offset of every following sibling and get
|
|
77
|
+
// written into the content stream verbatim, silently corrupting the page from that point on.
|
|
78
|
+
const remaining = Number.isFinite(leftover) ? Math.max(leftover, 0) : 0;
|
|
59
79
|
// Main-axis distribution only kicks in with no flex child and bounded, positive space.
|
|
60
80
|
let leadingSpace = 0;
|
|
61
81
|
let betweenSpace = gap;
|
|
@@ -101,7 +121,7 @@ export class FlexLayoutHelper {
|
|
|
101
121
|
}
|
|
102
122
|
else {
|
|
103
123
|
const childCross = axis.crossOf(fixedSize.get(child));
|
|
104
|
-
const size = child.calculateLayout(axis.measureConstraints(stretch ? crossExtent : crossAvail), axis.offsetAt(mainPos, crossOrigin + crossOffset(cross, crossExtent, childCross)), ctx);
|
|
124
|
+
const size = child.calculateLayout(axis.measureConstraints(stretch ? crossExtent : crossAvail, mainCapFor(child)), axis.offsetAt(mainPos, crossOrigin + crossOffset(cross, crossExtent, childCross)), ctx);
|
|
105
125
|
mainExtent = axis.mainOf(size);
|
|
106
126
|
placedCross = Math.max(placedCross, axis.crossOf(size));
|
|
107
127
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { PDFObjectManager } from "./pdf-object-manager.js";
|
|
2
|
+
interface Entry {
|
|
3
|
+
title: string;
|
|
4
|
+
level: number;
|
|
5
|
+
pageObjNum: number;
|
|
6
|
+
top: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Builds the PDF document outline (`/Outlines`) - the viewer's bookmark panel. Entries are collected
|
|
10
|
+
* flat in reading order with a 1-based `level`; `finalize` nests each entry under the nearest preceding
|
|
11
|
+
* entry of a smaller level (so level 2s hang under the last level 1), emits the outline objects and
|
|
12
|
+
* returns the catalog addition `/Outlines N 0 R`. A no-op returning "" when nothing was added, so a
|
|
13
|
+
* document without bookmarks stays byte-identical.
|
|
14
|
+
*/
|
|
15
|
+
export declare class OutlineBuilder {
|
|
16
|
+
private entries;
|
|
17
|
+
add(entry: Entry): void;
|
|
18
|
+
get isEmpty(): boolean;
|
|
19
|
+
finalize(om: PDFObjectManager): string;
|
|
20
|
+
}
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// A PDF literal string escape (same rule as elsewhere): backslash first, then the string delimiters.
|
|
2
|
+
const esc = (s) => s.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
|
|
3
|
+
const num = (n) => Number(n.toFixed(2));
|
|
4
|
+
/**
|
|
5
|
+
* Builds the PDF document outline (`/Outlines`) - the viewer's bookmark panel. Entries are collected
|
|
6
|
+
* flat in reading order with a 1-based `level`; `finalize` nests each entry under the nearest preceding
|
|
7
|
+
* entry of a smaller level (so level 2s hang under the last level 1), emits the outline objects and
|
|
8
|
+
* returns the catalog addition `/Outlines N 0 R`. A no-op returning "" when nothing was added, so a
|
|
9
|
+
* document without bookmarks stays byte-identical.
|
|
10
|
+
*/
|
|
11
|
+
export class OutlineBuilder {
|
|
12
|
+
constructor() {
|
|
13
|
+
this.entries = [];
|
|
14
|
+
}
|
|
15
|
+
add(entry) {
|
|
16
|
+
this.entries.push(entry);
|
|
17
|
+
}
|
|
18
|
+
get isEmpty() {
|
|
19
|
+
return this.entries.length === 0;
|
|
20
|
+
}
|
|
21
|
+
finalize(om) {
|
|
22
|
+
if (this.entries.length === 0)
|
|
23
|
+
return "";
|
|
24
|
+
// Nest by level: a stack where stack[i] is the last open node at level i+1. An entry at level L
|
|
25
|
+
// becomes a child of stack[L-2] (the enclosing smaller level), or a root when L is 1. Levels that
|
|
26
|
+
// skip a step (1 -> 3) are clamped to one deeper than the current stack so nothing is orphaned.
|
|
27
|
+
const roots = [];
|
|
28
|
+
const stack = [];
|
|
29
|
+
for (const e of this.entries) {
|
|
30
|
+
const level = Math.max(1, Math.min(e.level, stack.length + 1));
|
|
31
|
+
stack.length = level - 1; // pop back to this entry's parent depth
|
|
32
|
+
const node = { ...e, children: [] };
|
|
33
|
+
if (level === 1)
|
|
34
|
+
roots.push(node);
|
|
35
|
+
else
|
|
36
|
+
stack[level - 2].children.push(node);
|
|
37
|
+
stack[level - 1] = node;
|
|
38
|
+
}
|
|
39
|
+
// Reserve an object number for every node up front - parent/child/prev/next all cross-reference.
|
|
40
|
+
const objOf = new Map();
|
|
41
|
+
const walk = (nodes) => {
|
|
42
|
+
for (const n of nodes) {
|
|
43
|
+
objOf.set(n, om.addObject(""));
|
|
44
|
+
walk(n.children);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
walk(roots);
|
|
48
|
+
const rootNum = om.addObject(""); // the /Outlines dictionary itself
|
|
49
|
+
// Count of all descendants of a node (used for the /Count of an open item, which shows them all).
|
|
50
|
+
const descendantCount = (n) => n.children.reduce((sum, c) => sum + 1 + descendantCount(c), 0);
|
|
51
|
+
const totalCount = roots.reduce((sum, r) => sum + 1 + descendantCount(r), 0);
|
|
52
|
+
// Emit each item dict, wired to its parent, siblings (prev/next) and first/last child.
|
|
53
|
+
const emit = (nodes, parentNum) => {
|
|
54
|
+
nodes.forEach((n, i) => {
|
|
55
|
+
const parts = [`/Title (${esc(n.title)})`, `/Parent ${parentNum} 0 R`];
|
|
56
|
+
if (i > 0)
|
|
57
|
+
parts.push(`/Prev ${objOf.get(nodes[i - 1])} 0 R`);
|
|
58
|
+
if (i < nodes.length - 1)
|
|
59
|
+
parts.push(`/Next ${objOf.get(nodes[i + 1])} 0 R`);
|
|
60
|
+
if (n.children.length > 0) {
|
|
61
|
+
parts.push(`/First ${objOf.get(n.children[0])} 0 R`);
|
|
62
|
+
parts.push(`/Last ${objOf.get(n.children[n.children.length - 1])} 0 R`);
|
|
63
|
+
parts.push(`/Count ${descendantCount(n)}`); // positive = shown open
|
|
64
|
+
}
|
|
65
|
+
// /Dest: jump to this page, scrolling the target's top into view; null left/zoom keep the
|
|
66
|
+
// viewer's current horizontal position and magnification. Guard the top: a non-finite value
|
|
67
|
+
// (e.g. a bookmark under a Spacer in an unbounded column) would emit invalid PDF, so fall back
|
|
68
|
+
// to the page top (0 flips to the page top edge).
|
|
69
|
+
const top = Number.isFinite(n.top) ? num(n.top) : 0;
|
|
70
|
+
parts.push(`/Dest [${n.pageObjNum} 0 R /XYZ null ${top} null]`);
|
|
71
|
+
om.replaceObject(objOf.get(n), `<< ${parts.join(" ")} >>`);
|
|
72
|
+
emit(n.children, objOf.get(n));
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
emit(roots, rootNum);
|
|
76
|
+
om.replaceObject(rootNum, `<< /Type /Outlines /First ${objOf.get(roots[0])} 0 R ` +
|
|
77
|
+
`/Last ${objOf.get(roots[roots.length - 1])} 0 R /Count ${totalCount} >>`);
|
|
78
|
+
return `/Outlines ${rootNum} 0 R`;
|
|
79
|
+
}
|
|
80
|
+
}
|