@jasy/pdf 1.0.0-alpha.6 → 1.0.0-alpha.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/descriptor.js +3 -1
- package/dist/api/layout.d.ts +47 -14
- package/dist/api/layout.js +47 -11
- package/dist/api/structure.d.ts +5 -0
- package/dist/api/structure.js +1 -0
- package/dist/api/text.d.ts +18 -2
- package/dist/api/text.js +15 -0
- package/dist/elements/container-element.d.ts +10 -1
- package/dist/elements/container-element.js +26 -8
- package/dist/elements/index.d.ts +2 -0
- package/dist/elements/index.js +2 -0
- package/dist/elements/layout/keep-together-element.d.ts +42 -0
- package/dist/elements/layout/keep-together-element.js +85 -0
- package/dist/elements/layout/padding-element.d.ts +3 -0
- package/dist/elements/layout/padding-element.js +12 -0
- package/dist/elements/layout/page-break-element.d.ts +17 -0
- package/dist/elements/layout/page-break-element.js +24 -0
- package/dist/elements/layout/positioned-element.d.ts +10 -7
- package/dist/elements/layout/positioned-element.js +21 -9
- package/dist/elements/page-element.d.ts +11 -1
- package/dist/elements/page-element.js +41 -16
- package/dist/elements/pdf-element.d.ts +28 -0
- package/dist/elements/pdf-element.js +32 -0
- package/dist/elements/rectangle-element.d.ts +10 -1
- package/dist/elements/rectangle-element.js +27 -6
- package/dist/elements/row-element.d.ts +9 -1
- package/dist/elements/row-element.js +9 -1
- package/dist/elements/text-element.d.ts +30 -4
- package/dist/elements/text-element.js +40 -17
- package/dist/ir/display-list.d.ts +1 -0
- package/dist/layout/fragmentation.d.ts +12 -0
- package/dist/layout/fragmentation.js +45 -8
- package/dist/renderer/keep-together-renderer.d.ts +10 -0
- package/dist/renderer/keep-together-renderer.js +12 -0
- package/dist/renderer/page-break-renderer.d.ts +14 -0
- package/dist/renderer/page-break-renderer.js +15 -0
- package/dist/renderer/pdf-backend.d.ts +7 -0
- package/dist/renderer/pdf-backend.js +40 -6
- package/dist/renderer/pdf-document-class.js +3 -0
- package/dist/renderer/pdf-document-renderer.js +26 -7
- package/dist/renderer/pdf-renderer.js +6 -0
- package/dist/renderer/text-renderer.d.ts +1 -1
- package/dist/renderer/text-renderer.js +107 -59
- package/dist/text/advance.d.ts +34 -0
- package/dist/text/advance.js +42 -0
- package/dist/text/line-breaker.d.ts +9 -8
- package/dist/text/line-breaker.js +28 -21
- package/dist/text/line-metrics.d.ts +49 -0
- package/dist/text/line-metrics.js +57 -0
- package/dist/text/text-decoration.d.ts +50 -0
- package/dist/text/text-decoration.js +62 -0
- package/dist/text/text-style.d.ts +13 -3
- package/dist/text/text-style.js +10 -3
- package/dist/utils/afm-parser.d.ts +34 -0
- package/dist/utils/afm-parser.js +67 -0
- package/dist/utils/font-metrics.d.ts +14 -0
- package/dist/utils/pdf-object-manager.d.ts +27 -1
- package/dist/utils/pdf-object-manager.js +134 -20
- package/dist/utils/ttf-parser.d.ts +53 -0
- package/dist/utils/ttf-parser.js +368 -0
- package/package.json +1 -1
|
@@ -3,28 +3,30 @@ import { HorizontalAlignment } from "../elements/pdf-element.js";
|
|
|
3
3
|
import { isEmojiCodePoint } from "../text/emoji-codepoints.js";
|
|
4
4
|
import { emojiImageNode } from "./emoji-image.js";
|
|
5
5
|
import { wrapStringIntoLines, breakSegmentsIntoLines, } from "../text/line-breaker.js";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
import { lineBoxForSegmentLine, lineBoxForString } from "../text/line-metrics.js";
|
|
7
|
+
import { runAdvance } from "../text/advance.js";
|
|
8
|
+
import { skipInkSegments, strikethroughStroke, underlineStroke, } from "../text/text-decoration.js";
|
|
9
9
|
export class TextRenderer {
|
|
10
10
|
// Measuring only needs metrics, not the full object manager. (The render pass below
|
|
11
11
|
// still receives the manager because it also registers fonts/images.)
|
|
12
|
-
static calculateTextHeight(content, fontSize, fontFamily, fontStyle, objectManager, maxWidth, maxLines, overflow, lineHeight =
|
|
13
|
-
// Plain string:
|
|
12
|
+
static calculateTextHeight(content, fontSize, fontFamily, fontStyle, objectManager, maxWidth, maxLines, overflow, lineHeight, letterSpacing = 0) {
|
|
13
|
+
// Plain string: every wrapped line gets the same box.
|
|
14
14
|
if (typeof content === "string") {
|
|
15
|
-
const lines = wrapStringIntoLines(content, fontFamily, fontSize, fontStyle, maxWidth, objectManager, maxLines, overflow);
|
|
16
|
-
|
|
15
|
+
const lines = wrapStringIntoLines(content, fontFamily, fontSize, fontStyle, maxWidth, objectManager, maxLines, overflow, letterSpacing);
|
|
16
|
+
const box = lineBoxForString(objectManager, fontFamily, fontStyle, fontSize, lineHeight);
|
|
17
|
+
return lines.length * box.height;
|
|
17
18
|
}
|
|
18
|
-
// Segments: each line
|
|
19
|
-
const
|
|
20
|
-
|
|
19
|
+
// Segments: each line's box comes from the fonts actually on it.
|
|
20
|
+
const defaults = { fontFamily, fontSize, fontStyle, letterSpacing };
|
|
21
|
+
const lines = breakSegmentsIntoLines(content, defaults, maxWidth, objectManager, maxLines, overflow);
|
|
22
|
+
return lines.reduce((total, line) => total + lineBoxForSegmentLine(line, defaults, objectManager, lineHeight).height, 0);
|
|
21
23
|
}
|
|
22
24
|
static async render(textElement, objectManager) {
|
|
23
|
-
const { x, y, width, fontSize, color, content, fontFamily, fontStyle, textAlignment, maxLines, overflow, lineHeight, role, } = textElement.getProps();
|
|
25
|
+
const { x, y, width, fontSize, color, content, fontFamily, fontStyle, textAlignment, maxLines, overflow, lineHeight, underline, strikethrough, skipInk, letterSpacing, role, } = textElement.getProps();
|
|
24
26
|
// Component -> display list. Wrapping and positioning stay here; the backend
|
|
25
27
|
// turns each run into BT/Tf/Td/Tj/ET. The wrapping algorithm is unchanged from
|
|
26
28
|
// the original renderer - unifying it into the engine is Phase 3.
|
|
27
|
-
const { runs, links } = TextRenderer._buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, width ?? Number.NaN, textAlignment, color, x, y, maxLines, overflow, lineHeight);
|
|
29
|
+
const { runs, links, decorations } = TextRenderer._buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, width ?? Number.NaN, textAlignment, color, x, y, maxLines, overflow, lineHeight, underline, strikethrough, skipInk, letterSpacing);
|
|
28
30
|
// Accessible tagging: this whole text block is one structure element (a paragraph P, or a heading
|
|
29
31
|
// from `role`); each run's text lands under it. Emoji sub-nodes produced below stay untagged (drawn
|
|
30
32
|
// as artifacts). Keyed by structId so a block split across pages stays one element.
|
|
@@ -37,9 +39,11 @@ export class TextRenderer {
|
|
|
37
39
|
// Color fonts (COLR/CPAL): expand each run's emoji into filled vector layers (or fetched images
|
|
38
40
|
// for an image emoji source); plain runs and monochrome fonts pass straight through unchanged.
|
|
39
41
|
const drawn = (await Promise.all(runs.map((run) => TextRenderer._expandColorGlyphs(run, objectManager)))).flat();
|
|
42
|
+
// Underline / strikethrough go on TOP of the glyphs (a strikethrough must), and stay untagged,
|
|
43
|
+
// so the structure tree treats them as artifacts rather than as text.
|
|
40
44
|
// Inline hyperlinks (href spans) ride along as /Link annotations - they draw nothing, so order
|
|
41
45
|
// vs the drawn runs does not matter; the page renderer peels them off into /Annots.
|
|
42
|
-
return
|
|
46
|
+
return [...drawn, ...decorations, ...links];
|
|
43
47
|
}
|
|
44
48
|
// Splits a text run into normal text sub-runs + filled `Path` layers for any COLR color glyphs,
|
|
45
49
|
// walking code point by code point and advancing the pen by each glyph's width. Returns `[run]`
|
|
@@ -82,8 +86,10 @@ export class TextRenderer {
|
|
|
82
86
|
if (l)
|
|
83
87
|
[source, layers] = [fallback, l];
|
|
84
88
|
}
|
|
85
|
-
// The advance matches measuring (getCharWidth applies the same emoji fallback)
|
|
86
|
-
|
|
89
|
+
// The advance matches measuring (getCharWidth applies the same emoji fallback), plus the run's
|
|
90
|
+
// letter-spacing so a spaced run's emoji sit where `Tc` advances them.
|
|
91
|
+
const advance = om.getCharWidth(ch, run.fontSize, undefined, run.fontFamily, run.fontStyle) +
|
|
92
|
+
(run.letterSpacing ?? 0);
|
|
87
93
|
if (source && layers) {
|
|
88
94
|
flushPending();
|
|
89
95
|
const scale = run.fontSize / source.unitsPerEm;
|
|
@@ -209,11 +215,14 @@ export class TextRenderer {
|
|
|
209
215
|
// Lay the content out into absolutely-positioned text runs. Glyph positions match
|
|
210
216
|
// the previous hand-written operators exactly (verified by pixel-identical render);
|
|
211
217
|
// only the output form changed from PDF strings to `TextRun`s.
|
|
212
|
-
static _buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, maxWidth, textAlignment, color, initialX, yPosition, maxLines, overflow, lineHeight =
|
|
218
|
+
static _buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, maxWidth, textAlignment, color, initialX, yPosition, maxLines, overflow, lineHeight, underline = false, strikethrough = false, skipInk = false, letterSpacing = 0) {
|
|
213
219
|
const runs = [];
|
|
214
220
|
// /Link annotation rects for any `href` spans, collected as we place segments. Empty for the
|
|
215
221
|
// common (no-href) case, so plain text keeps producing exactly the runs it did before.
|
|
216
222
|
const links = [];
|
|
223
|
+
// Underline / strikethrough strokes, one per decorated run (so a wrapped paragraph gets one
|
|
224
|
+
// per line, and a decorated span only spans its own glyphs).
|
|
225
|
+
const decorations = [];
|
|
217
226
|
// Horizontal offset of a line of the given width under the current alignment.
|
|
218
227
|
const alignmentOffset = (lineWidth) => {
|
|
219
228
|
if (textAlignment === HorizontalAlignment.center)
|
|
@@ -222,39 +231,78 @@ export class TextRenderer {
|
|
|
222
231
|
return maxWidth - lineWidth;
|
|
223
232
|
return 0;
|
|
224
233
|
};
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
234
|
+
// The decoration strokes for one drawn run, at the geometry ITS OWN font declares. A mixed-size
|
|
235
|
+
// line therefore gets a thicker line under the bigger span, which is what a browser does too.
|
|
236
|
+
const decorate = (text, runX, runWidth, baselineY, family, style, size, runColor, wantsUnderline, wantsStrikethrough, runLetterSpacing) => {
|
|
237
|
+
if ((!wantsUnderline && !wantsStrikethrough) || runWidth <= 0)
|
|
238
|
+
return;
|
|
239
|
+
const metrics = objectManager.getFontDecoration(family, style);
|
|
240
|
+
const push = (from, to, stroke) => {
|
|
241
|
+
decorations.push({
|
|
242
|
+
type: "line",
|
|
243
|
+
x1: runX + from,
|
|
244
|
+
y1: stroke.y,
|
|
245
|
+
x2: runX + to,
|
|
246
|
+
y2: stroke.y,
|
|
247
|
+
stroke: runColor,
|
|
248
|
+
strokeWidth: stroke.thickness,
|
|
249
|
+
});
|
|
250
|
+
};
|
|
251
|
+
if (wantsUnderline) {
|
|
252
|
+
const stroke = underlineStroke(metrics, size, baselineY);
|
|
253
|
+
for (const [from, to] of underlineSegments(text, runWidth, baselineY, size, family, style, stroke, runLetterSpacing)) {
|
|
254
|
+
push(from, to, stroke);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// A strikethrough crosses the letters by definition, so it is never interrupted.
|
|
258
|
+
if (wantsStrikethrough) {
|
|
259
|
+
const stroke = strikethroughStroke(metrics, size, baselineY);
|
|
260
|
+
push(0, runWidth, stroke);
|
|
232
261
|
}
|
|
233
|
-
return width;
|
|
234
262
|
};
|
|
263
|
+
// The x-ranges the underline actually draws. Without `skipInk` that is the whole run; with it,
|
|
264
|
+
// the descenders are cut out of it (which needs the real outlines, i.e. an embedded font).
|
|
265
|
+
const underlineSegments = (text, runWidth, baselineY, size, family, style, stroke, runLetterSpacing) => {
|
|
266
|
+
if (!skipInk)
|
|
267
|
+
return [[0, runWidth]];
|
|
268
|
+
if (!objectManager.isCustomFont(family, style)) {
|
|
269
|
+
throw new Error(`skipInk needs an embedded font, but "${family}" is a standard-14 font whose glyph outlines ` +
|
|
270
|
+
`live in the PDF viewer, not in the document - we cannot see where its ink is. Register a ` +
|
|
271
|
+
`font (the document's \`fonts\` option) or drop \`skipInk\`.`);
|
|
272
|
+
}
|
|
273
|
+
// The band the stroke covers, in points from the baseline, y UP (so both values are negative).
|
|
274
|
+
const below = stroke.y - baselineY;
|
|
275
|
+
const half = stroke.thickness / 2;
|
|
276
|
+
const inkSpans = objectManager.getInkSpansInBand(text, size, family, style, -below + half, -below - half, runLetterSpacing);
|
|
277
|
+
return skipInkSegments(runWidth, inkSpans, stroke.thickness);
|
|
278
|
+
};
|
|
279
|
+
// The advance the viewer will use: plain glyph widths (a `Tj` never kerns) plus one
|
|
280
|
+
// `letterSpacing` per code point (the `Tc` operator), from the one shared `advance.ts` primitive.
|
|
281
|
+
const font = { fontFamily, fontSize, fontStyle };
|
|
235
282
|
// --- Plain string: one run per wrapped line. ---
|
|
236
283
|
if (typeof content === "string") {
|
|
237
|
-
const lines = wrapStringIntoLines(content, fontFamily, fontSize, fontStyle, maxWidth, objectManager, maxLines, overflow);
|
|
238
|
-
// yPosition is the top of the text box (top-left)
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
// box instead of clinging to the top. At lineHeight 1 the half-leading is 0 -> byte-identical.
|
|
242
|
-
const halfLeading = (fontSize * (lineHeight - 1)) / 2;
|
|
243
|
-
const baseline = yPosition + halfLeading + fontSize * BASELINE_RATIO;
|
|
284
|
+
const lines = wrapStringIntoLines(content, fontFamily, fontSize, fontStyle, maxWidth, objectManager, maxLines, overflow, letterSpacing);
|
|
285
|
+
// yPosition is the top of the text box (top-left). The line box seats its own baseline; lines
|
|
286
|
+
// then stack by that box's height. Same numbers as calculateTextHeight, by construction.
|
|
287
|
+
const box = lineBoxForString(objectManager, fontFamily, fontStyle, fontSize, lineHeight);
|
|
244
288
|
lines.forEach((line, index) => {
|
|
245
|
-
const lineWidth = objectManager
|
|
289
|
+
const lineWidth = runAdvance(objectManager, line, font, letterSpacing);
|
|
290
|
+
const x = initialX + alignmentOffset(lineWidth);
|
|
291
|
+
const baseline = yPosition + box.baseline + box.height * index;
|
|
246
292
|
runs.push({
|
|
247
293
|
type: "text",
|
|
248
|
-
x
|
|
249
|
-
y: baseline
|
|
294
|
+
x,
|
|
295
|
+
y: baseline,
|
|
250
296
|
text: line,
|
|
251
297
|
fontFamily,
|
|
252
298
|
fontStyle,
|
|
253
299
|
fontSize,
|
|
254
300
|
color,
|
|
301
|
+
...(letterSpacing ? { letterSpacing } : {}),
|
|
255
302
|
});
|
|
303
|
+
decorate(line, x, lineWidth, baseline, fontFamily, fontStyle, fontSize, color, underline, strikethrough, letterSpacing);
|
|
256
304
|
});
|
|
257
|
-
return { runs, links };
|
|
305
|
+
return { runs, links, decorations };
|
|
258
306
|
}
|
|
259
307
|
// --- Segments: break into lines (shared breaker), then emit one run per segment.
|
|
260
308
|
// Segment 0 starts at the aligned line origin; each following segment is offset by
|
|
@@ -267,7 +315,9 @@ export class TextRenderer {
|
|
|
267
315
|
const family = segment.fontFamily || fontFamily;
|
|
268
316
|
const size = segment.fontSize || fontSize;
|
|
269
317
|
const style = segment.fontStyle || fontStyle;
|
|
270
|
-
const
|
|
318
|
+
const segLetterSpacing = segment.letterSpacing ?? letterSpacing;
|
|
319
|
+
const advance = runAdvance(objectManager, segment.content, { fontFamily: family, fontSize: size, fontStyle: style }, segLetterSpacing);
|
|
320
|
+
const runColor = segment.fontColor || color;
|
|
271
321
|
runs.push({
|
|
272
322
|
type: "text",
|
|
273
323
|
x,
|
|
@@ -276,18 +326,21 @@ export class TextRenderer {
|
|
|
276
326
|
fontFamily: family,
|
|
277
327
|
fontStyle: style,
|
|
278
328
|
fontSize: size,
|
|
279
|
-
color:
|
|
329
|
+
color: runColor,
|
|
330
|
+
...(segLetterSpacing ? { letterSpacing: segLetterSpacing } : {}),
|
|
280
331
|
});
|
|
281
|
-
|
|
282
|
-
//
|
|
283
|
-
// ascent
|
|
332
|
+
decorate(segment.content, x, advance, lineY, family, style, size, runColor, segment.underline ?? underline, segment.strikethrough ?? strikethrough, segLetterSpacing);
|
|
333
|
+
// An href/to span becomes a /Link annotation over exactly this run's glyph box: from its own
|
|
334
|
+
// ascent above the baseline down to its own descender. A span wrapped across lines yields
|
|
335
|
+
// one rect per line.
|
|
284
336
|
if (segment.href !== undefined || segment.dest !== undefined) {
|
|
337
|
+
const v = objectManager.getFontVerticals(family, style);
|
|
285
338
|
links.push({
|
|
286
339
|
type: "link",
|
|
287
340
|
x,
|
|
288
|
-
y: lineY -
|
|
341
|
+
y: lineY - v.ascent * size,
|
|
289
342
|
width: advance,
|
|
290
|
-
height: size,
|
|
343
|
+
height: (v.ascent + v.descent) * size,
|
|
291
344
|
href: segment.href,
|
|
292
345
|
dest: segment.dest,
|
|
293
346
|
});
|
|
@@ -295,23 +348,18 @@ export class TextRenderer {
|
|
|
295
348
|
x += advance;
|
|
296
349
|
});
|
|
297
350
|
};
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
// the whole thing to PDF space.
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
// Half-leading: shift this line's baseline down by half its own extra leading, so the line
|
|
310
|
-
// sits centered in its box (CSS/Flutter). At lineHeight 1 the shift is 0 -> byte-identical.
|
|
311
|
-
const halfLeading = (line.maxFontSize * (lineHeight - 1)) / 2;
|
|
312
|
-
pushLine(line, lineY + halfLeading);
|
|
313
|
-
lineY += line.maxFontSize * lineHeight;
|
|
351
|
+
// yPosition is the top of the text box (top-left). Each line seats its own baseline inside its
|
|
352
|
+
// own box (tallest ascent / deepest descent ON THAT LINE), then the next line starts below it.
|
|
353
|
+
// The seam flips the whole thing to PDF space. `letterSpacing` MUST be in the defaults so a span
|
|
354
|
+
// that does not override it wraps and aligns with the element's spacing - the same defaults the
|
|
355
|
+
// measure path (calculateTextHeight) uses, or segmented spaced text mis-wraps (measured != drawn).
|
|
356
|
+
const defaults = { fontFamily, fontSize, fontStyle, letterSpacing };
|
|
357
|
+
let top = yPosition;
|
|
358
|
+
for (const line of breakSegmentsIntoLines(content, defaults, maxWidth, objectManager, maxLines, overflow)) {
|
|
359
|
+
const box = lineBoxForSegmentLine(line, defaults, objectManager, lineHeight);
|
|
360
|
+
pushLine(line, top + box.baseline);
|
|
361
|
+
top += box.height;
|
|
314
362
|
}
|
|
315
|
-
return { runs, links };
|
|
363
|
+
return { runs, links, decorations };
|
|
316
364
|
}
|
|
317
365
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How wide a run of text is: the horizontal counterpart to `line-metrics.ts` (how tall a line is)
|
|
3
|
+
* and `line-breaker.ts` (where a line breaks). The ONE canonical answer to "how far does the pen
|
|
4
|
+
* move", so measuring and drawing can never disagree about it.
|
|
5
|
+
*
|
|
6
|
+
* An advance has three parts, each with its own PDF mechanism:
|
|
7
|
+
*
|
|
8
|
+
* advance = sum(glyph widths) + sum(kerning) + n * letterSpacing
|
|
9
|
+
* /Widths TJ array Tc
|
|
10
|
+
*
|
|
11
|
+
* `letterSpacing` is added after EVERY code point, the last one included - exactly what the `Tc`
|
|
12
|
+
* operator does, and exactly what CSS `letter-spacing` does (which is why right-aligned spaced text
|
|
13
|
+
* looks a hair offset: a spacing hangs off the final glyph). Measuring `(n - 1)` spacings while `Tc`
|
|
14
|
+
* applies `n` would make every line draw one spacing wider than it was wrapped - text out of its box.
|
|
15
|
+
*
|
|
16
|
+
* Kerning is added ONLY when the document has it on (`metrics.kerningEnabled`), and then the backend
|
|
17
|
+
* emits a `TJ` with the same per-pair adjustments - so a measured advance always equals the drawn
|
|
18
|
+
* one. With kerning off, `getStringWidth` is the whole advance (its plain glyph sum), byte-identical
|
|
19
|
+
* to before. Folding kerning into the measurement while the output ignored it was the 2026-07-10 bug.
|
|
20
|
+
*/
|
|
21
|
+
import type { FontStyle } from "../utils/pdf-object-manager.js";
|
|
22
|
+
import type { FontMetrics } from "../utils/font-metrics.js";
|
|
23
|
+
/** The font a run is set in. */
|
|
24
|
+
export interface RunFont {
|
|
25
|
+
fontFamily: string;
|
|
26
|
+
fontSize: number;
|
|
27
|
+
fontStyle: FontStyle;
|
|
28
|
+
}
|
|
29
|
+
/** Number of Unicode code points (not UTF-16 units): an astral char is ONE, so it takes one
|
|
30
|
+
* letter-spacing, not two. Matches how `getStringWidth` iterates. */
|
|
31
|
+
export declare function codePointCount(text: string): number;
|
|
32
|
+
/** The advance of `text` in points: the plain glyph widths, plus kerning (if the document has it on),
|
|
33
|
+
* plus one `letterSpacing` per code point. */
|
|
34
|
+
export declare function runAdvance(metrics: FontMetrics, text: string, font: RunFont, letterSpacing?: number): number;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How wide a run of text is: the horizontal counterpart to `line-metrics.ts` (how tall a line is)
|
|
3
|
+
* and `line-breaker.ts` (where a line breaks). The ONE canonical answer to "how far does the pen
|
|
4
|
+
* move", so measuring and drawing can never disagree about it.
|
|
5
|
+
*
|
|
6
|
+
* An advance has three parts, each with its own PDF mechanism:
|
|
7
|
+
*
|
|
8
|
+
* advance = sum(glyph widths) + sum(kerning) + n * letterSpacing
|
|
9
|
+
* /Widths TJ array Tc
|
|
10
|
+
*
|
|
11
|
+
* `letterSpacing` is added after EVERY code point, the last one included - exactly what the `Tc`
|
|
12
|
+
* operator does, and exactly what CSS `letter-spacing` does (which is why right-aligned spaced text
|
|
13
|
+
* looks a hair offset: a spacing hangs off the final glyph). Measuring `(n - 1)` spacings while `Tc`
|
|
14
|
+
* applies `n` would make every line draw one spacing wider than it was wrapped - text out of its box.
|
|
15
|
+
*
|
|
16
|
+
* Kerning is added ONLY when the document has it on (`metrics.kerningEnabled`), and then the backend
|
|
17
|
+
* emits a `TJ` with the same per-pair adjustments - so a measured advance always equals the drawn
|
|
18
|
+
* one. With kerning off, `getStringWidth` is the whole advance (its plain glyph sum), byte-identical
|
|
19
|
+
* to before. Folding kerning into the measurement while the output ignored it was the 2026-07-10 bug.
|
|
20
|
+
*/
|
|
21
|
+
/** Number of Unicode code points (not UTF-16 units): an astral char is ONE, so it takes one
|
|
22
|
+
* letter-spacing, not two. Matches how `getStringWidth` iterates. */
|
|
23
|
+
export function codePointCount(text) {
|
|
24
|
+
let n = 0;
|
|
25
|
+
for (const _ of text)
|
|
26
|
+
n++;
|
|
27
|
+
return n;
|
|
28
|
+
}
|
|
29
|
+
/** The advance of `text` in points: the plain glyph widths, plus kerning (if the document has it on),
|
|
30
|
+
* plus one `letterSpacing` per code point. */
|
|
31
|
+
export function runAdvance(metrics, text, font, letterSpacing = 0) {
|
|
32
|
+
let advance = metrics.getStringWidth(text, font.fontFamily, font.fontSize, font.fontStyle);
|
|
33
|
+
if (letterSpacing !== 0)
|
|
34
|
+
advance += codePointCount(text) * letterSpacing;
|
|
35
|
+
if (metrics.kerningEnabled) {
|
|
36
|
+
let units = 0;
|
|
37
|
+
for (const k of metrics.getKernPairs(text, font.fontFamily, font.fontStyle))
|
|
38
|
+
units += k;
|
|
39
|
+
advance += (units / 1000) * font.fontSize; // kern units are em/1000
|
|
40
|
+
}
|
|
41
|
+
return advance;
|
|
42
|
+
}
|
|
@@ -6,16 +6,17 @@ export interface SegmentDefaults {
|
|
|
6
6
|
fontFamily: string;
|
|
7
7
|
fontSize: number;
|
|
8
8
|
fontStyle: FontStyle;
|
|
9
|
+
/** Extra space after every glyph, in points; a segment may override it. Default 0. */
|
|
10
|
+
letterSpacing?: number;
|
|
9
11
|
}
|
|
10
12
|
/** What happens to text beyond `maxLines`: `"clip"` drops it, `"ellipsis"` ends the last kept line
|
|
11
13
|
* with an ellipsis. Mirrors Flutter's `TextOverflow`. */
|
|
12
14
|
export type TextOverflow = "clip" | "ellipsis";
|
|
13
|
-
/** One laid-out line of segments.
|
|
14
|
-
*
|
|
15
|
+
/** One laid-out line of segments. How TALL it is does not live here: the breaker owns the horizontal
|
|
16
|
+
* half, `text/line-metrics.ts` owns the vertical one and derives the box from the fonts on the line. */
|
|
15
17
|
export interface SegmentLine {
|
|
16
18
|
segments: TextSegment[];
|
|
17
19
|
width: number;
|
|
18
|
-
maxFontSize: number;
|
|
19
20
|
}
|
|
20
21
|
/**
|
|
21
22
|
* Break a plain string into lines that each fit within `maxWidth`, splitting on
|
|
@@ -25,12 +26,12 @@ export interface SegmentLine {
|
|
|
25
26
|
* rendering call this, so they can never disagree. Depends only on `FontMetrics`,
|
|
26
27
|
* not the PDF byte writer - the future fragmentation pass can reuse it.
|
|
27
28
|
*/
|
|
28
|
-
export declare function wrapStringIntoLines(text: string, fontFamily: string, fontSize: number, fontStyle: FontStyle, maxWidth: number, metrics: FontMetrics, maxLines?: number, overflow?: TextOverflow): string[];
|
|
29
|
+
export declare function wrapStringIntoLines(text: string, fontFamily: string, fontSize: number, fontStyle: FontStyle, maxWidth: number, metrics: FontMetrics, maxLines?: number, overflow?: TextOverflow, letterSpacing?: number): string[];
|
|
29
30
|
/**
|
|
30
|
-
* Break styled segments into lines that fit within `maxWidth`. Same greedy
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
31
|
+
* Break styled segments into lines that fit within `maxWidth`. Same greedy word-splitting as the
|
|
32
|
+
* string breaker; each line keeps the segments that landed on it, which is what `line-metrics.ts`
|
|
33
|
+
* later derives the line's height from. Single source of truth: both height measurement and
|
|
34
|
+
* rendering call this.
|
|
34
35
|
*/
|
|
35
36
|
export declare function breakSegmentsIntoLines(segments: TextSegment[], defaults: SegmentDefaults, maxWidth: number, metrics: FontMetrics, maxLines?: number, overflow?: TextOverflow): SegmentLine[];
|
|
36
37
|
/**
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { runAdvance } from "./advance.js";
|
|
1
2
|
/** Three ASCII dots, NOT the "…" glyph (U+2026): plain dots encode in every font - standard-14
|
|
2
3
|
* (WinAnsi) and any embedded TTF - whereas U+2026 needs a glyph the font may not carry. */
|
|
3
4
|
const ELLIPSIS = "...";
|
|
@@ -9,14 +10,17 @@ const ELLIPSIS = "...";
|
|
|
9
10
|
* rendering call this, so they can never disagree. Depends only on `FontMetrics`,
|
|
10
11
|
* not the PDF byte writer - the future fragmentation pass can reuse it.
|
|
11
12
|
*/
|
|
12
|
-
export function wrapStringIntoLines(text, fontFamily, fontSize, fontStyle, maxWidth, metrics, maxLines, overflow) {
|
|
13
|
+
export function wrapStringIntoLines(text, fontFamily, fontSize, fontStyle, maxWidth, metrics, maxLines, overflow, letterSpacing = 0) {
|
|
13
14
|
let currentLine = "";
|
|
14
15
|
let currentWidth = 0;
|
|
15
16
|
const lines = [];
|
|
17
|
+
const font = { fontFamily, fontSize, fontStyle };
|
|
16
18
|
const words = text.split(" ");
|
|
17
19
|
words.forEach((word, index) => {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
// Word and space advances come from the one shared primitive (`advance.ts`), the same one
|
|
21
|
+
// `naturalWidth` uses - so a bounded and an unbounded layout of the same text agree bit for bit.
|
|
22
|
+
const wordWidth = runAdvance(metrics, word, font, letterSpacing);
|
|
23
|
+
const spaceWidth = runAdvance(metrics, " ", font, letterSpacing);
|
|
20
24
|
// Break before a word that won't fit - but only once the line has content. A single word wider
|
|
21
25
|
// than maxWidth must sit on its (empty) line and overflow, not push a phantom empty line before
|
|
22
26
|
// it (which would over-count the height by a line and shift the text down at render).
|
|
@@ -38,44 +42,41 @@ export function wrapStringIntoLines(text, fontFamily, fontSize, fontStyle, maxWi
|
|
|
38
42
|
const kept = lines.slice(0, maxLines);
|
|
39
43
|
if (overflow === "ellipsis") {
|
|
40
44
|
const last = kept.length - 1;
|
|
41
|
-
kept[last] = ellipsize(kept[last], fontFamily, fontSize, fontStyle, maxWidth, metrics);
|
|
45
|
+
kept[last] = ellipsize(kept[last], fontFamily, fontSize, fontStyle, maxWidth, metrics, letterSpacing);
|
|
42
46
|
}
|
|
43
47
|
return kept;
|
|
44
48
|
}
|
|
45
49
|
/**
|
|
46
|
-
* Break styled segments into lines that fit within `maxWidth`. Same greedy
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
+
* Break styled segments into lines that fit within `maxWidth`. Same greedy word-splitting as the
|
|
51
|
+
* string breaker; each line keeps the segments that landed on it, which is what `line-metrics.ts`
|
|
52
|
+
* later derives the line's height from. Single source of truth: both height measurement and
|
|
53
|
+
* rendering call this.
|
|
50
54
|
*/
|
|
51
55
|
export function breakSegmentsIntoLines(segments, defaults, maxWidth, metrics, maxLines, overflow) {
|
|
52
56
|
const lines = [];
|
|
53
57
|
let width = 0;
|
|
54
|
-
let maxFontSize = 0; // per line: starts at 0, grows to the tallest font on the line
|
|
55
58
|
let lineSegments = [];
|
|
56
59
|
let combined = "";
|
|
57
60
|
segments.forEach((segment) => {
|
|
58
61
|
const family = segment.fontFamily || defaults.fontFamily;
|
|
59
62
|
const size = segment.fontSize || defaults.fontSize;
|
|
60
63
|
const style = segment.fontStyle || defaults.fontStyle;
|
|
61
|
-
const
|
|
64
|
+
const letterSpacing = segment.letterSpacing ?? defaults.letterSpacing ?? 0;
|
|
65
|
+
const font = { fontFamily: family, fontSize: size, fontStyle: style };
|
|
66
|
+
const spaceWidth = runAdvance(metrics, " ", font, letterSpacing);
|
|
62
67
|
const words = segment.content.split(" ");
|
|
63
68
|
// Start this segment's piece empty; its content is filled word-by-word below. (Not
|
|
64
69
|
// the original content - otherwise a segment whose FIRST word overflows would carry
|
|
65
70
|
// its whole text into the line that just closed.)
|
|
66
71
|
lineSegments.push({ ...segment, fontFamily: family, content: "" });
|
|
67
72
|
combined = "";
|
|
68
|
-
if (maxFontSize < size)
|
|
69
|
-
maxFontSize = size;
|
|
70
73
|
words.forEach((word, wordIndex) => {
|
|
71
|
-
const wordWidth = metrics
|
|
74
|
+
const wordWidth = runAdvance(metrics, word, font, letterSpacing);
|
|
72
75
|
// Same guard as the string path: don't open a phantom empty line for an over-wide first word -
|
|
73
76
|
// place it (overflowing) on the current empty line instead.
|
|
74
77
|
if (width + wordWidth > maxWidth && width > 0) {
|
|
75
|
-
lines.push({ segments: lineSegments, width
|
|
76
|
-
// Start the next line; its leading resets to the wrapping segment's size.
|
|
78
|
+
lines.push({ segments: lineSegments, width });
|
|
77
79
|
width = 0;
|
|
78
|
-
maxFontSize = size;
|
|
79
80
|
lineSegments = [];
|
|
80
81
|
combined = word;
|
|
81
82
|
width += wordWidth + spaceWidth;
|
|
@@ -92,7 +93,7 @@ export function breakSegmentsIntoLines(segments, defaults, maxWidth, metrics, ma
|
|
|
92
93
|
});
|
|
93
94
|
});
|
|
94
95
|
if (lineSegments.length > 0) {
|
|
95
|
-
lines.push({ segments: lineSegments, width
|
|
96
|
+
lines.push({ segments: lineSegments, width });
|
|
96
97
|
}
|
|
97
98
|
// Open-end by default; cap only when maxLines is set.
|
|
98
99
|
if (maxLines == null || lines.length <= maxLines)
|
|
@@ -123,8 +124,9 @@ export function segmentLinesToSegments(lines) {
|
|
|
123
124
|
}
|
|
124
125
|
/** Appends "…" to a single line, dropping trailing words (then characters) until the line plus the
|
|
125
126
|
* ellipsis fits `maxWidth`. Falls back to a bare "…" if not even one character fits. */
|
|
126
|
-
function ellipsize(line, fontFamily, fontSize, fontStyle, maxWidth, metrics) {
|
|
127
|
-
const
|
|
127
|
+
function ellipsize(line, fontFamily, fontSize, fontStyle, maxWidth, metrics, letterSpacing = 0) {
|
|
128
|
+
const font = { fontFamily, fontSize, fontStyle };
|
|
129
|
+
const fits = (s) => runAdvance(metrics, s + ELLIPSIS, font, letterSpacing) <= maxWidth;
|
|
128
130
|
if (fits(line))
|
|
129
131
|
return line + ELLIPSIS;
|
|
130
132
|
const words = line.split(" ");
|
|
@@ -150,11 +152,16 @@ function ellipsizeSegmentLine(line, defaults, maxWidth, metrics) {
|
|
|
150
152
|
const segs = line.segments;
|
|
151
153
|
if (segs.length === 0)
|
|
152
154
|
return;
|
|
153
|
-
const
|
|
155
|
+
const lsOf = (seg) => seg.letterSpacing ?? defaults.letterSpacing ?? 0;
|
|
156
|
+
const widthOf = (seg) => runAdvance(metrics, seg.content, {
|
|
157
|
+
fontFamily: seg.fontFamily || defaults.fontFamily,
|
|
158
|
+
fontSize: seg.fontSize || defaults.fontSize,
|
|
159
|
+
fontStyle: seg.fontStyle || defaults.fontStyle,
|
|
160
|
+
}, lsOf(seg));
|
|
154
161
|
let prefix = 0;
|
|
155
162
|
for (let i = 0; i < segs.length - 1; i++)
|
|
156
163
|
prefix += widthOf(segs[i]);
|
|
157
164
|
const last = segs[segs.length - 1];
|
|
158
|
-
last.content = ellipsize(last.content, last.fontFamily || defaults.fontFamily, last.fontSize || defaults.fontSize, last.fontStyle || defaults.fontStyle, maxWidth - prefix, metrics);
|
|
165
|
+
last.content = ellipsize(last.content, last.fontFamily || defaults.fontFamily, last.fontSize || defaults.fontSize, last.fontStyle || defaults.fontStyle, maxWidth - prefix, metrics, lsOf(last));
|
|
159
166
|
line.width = prefix + widthOf(last);
|
|
160
167
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vertical half of text layout: how tall a line box is, and where its baseline sits.
|
|
3
|
+
*
|
|
4
|
+
* One canonical answer, like `line-breaker.ts` is the one canonical answer for the horizontal
|
|
5
|
+
* half. Measuring (`calculateTextHeight`), fragmenting (`TextElement.fragment`) and drawing
|
|
6
|
+
* (`TextRenderer._buildRuns`) all call in here, so they can never disagree about a line's height
|
|
7
|
+
* again - the divergence that produced ISSUE-5.
|
|
8
|
+
*/
|
|
9
|
+
import type { FontStyle } from "../utils/pdf-object-manager.js";
|
|
10
|
+
import type { FontMetrics } from "../utils/font-metrics.js";
|
|
11
|
+
import type { SegmentDefaults, SegmentLine } from "./line-breaker.js";
|
|
12
|
+
/** A font's vertical metrics, as fractions of the em (so they scale by multiplying with fontSize). */
|
|
13
|
+
export interface FontVerticals {
|
|
14
|
+
/** Distance from the baseline up to the ascender. Positive. */
|
|
15
|
+
ascent: number;
|
|
16
|
+
/** Distance from the baseline down to the descender. Positive (unlike the AFM/hhea sign). */
|
|
17
|
+
descent: number;
|
|
18
|
+
/** Extra leading the font asks for between two lines. Often 0 for a TrueType face. */
|
|
19
|
+
lineGap: number;
|
|
20
|
+
}
|
|
21
|
+
/** One font used on a line: its metrics and the size it is set at. */
|
|
22
|
+
export interface LinePart {
|
|
23
|
+
verticals: FontVerticals;
|
|
24
|
+
fontSize: number;
|
|
25
|
+
}
|
|
26
|
+
/** A laid-out line box, in points, relative to the top of the box. */
|
|
27
|
+
export interface LineBox {
|
|
28
|
+
/** Total height of the line box. Lines stack by this. */
|
|
29
|
+
height: number;
|
|
30
|
+
/** Distance from the top of the line box down to the baseline. */
|
|
31
|
+
baseline: number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The line box for a set of fonts sharing one line.
|
|
35
|
+
*
|
|
36
|
+
* `lineHeight` is a multiplier of the font size, like CSS. Leave it `undefined` for the font's
|
|
37
|
+
* NATURAL line height (`ascent + descent + lineGap`), which is what CSS calls `line-height: normal`.
|
|
38
|
+
*
|
|
39
|
+
* Whatever is left over between the box and the font's content is split evenly above and below
|
|
40
|
+
* ("half-leading", as CSS and Flutter do), so the glyphs sit centred in their own box. An explicit
|
|
41
|
+
* `lineHeight` smaller than the content makes the leading negative and the glyphs overflow - again
|
|
42
|
+
* exactly like CSS, and the author asked for it.
|
|
43
|
+
*/
|
|
44
|
+
export declare function lineBoxFor(parts: LinePart[], lineHeight?: number): LineBox;
|
|
45
|
+
/** The line box of a single-font line (the plain-string path). */
|
|
46
|
+
export declare function lineBoxForString(metrics: FontMetrics, fontFamily: string, fontStyle: FontStyle, fontSize: number, lineHeight?: number): LineBox;
|
|
47
|
+
/** The line box of a mixed-font line: the tallest ascent and the deepest descent on it win, so a
|
|
48
|
+
* big or a deep-descending span pushes the whole line apart rather than colliding with it. */
|
|
49
|
+
export declare function lineBoxForSegmentLine(line: SegmentLine, defaults: SegmentDefaults, metrics: FontMetrics, lineHeight?: number): LineBox;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vertical half of text layout: how tall a line box is, and where its baseline sits.
|
|
3
|
+
*
|
|
4
|
+
* One canonical answer, like `line-breaker.ts` is the one canonical answer for the horizontal
|
|
5
|
+
* half. Measuring (`calculateTextHeight`), fragmenting (`TextElement.fragment`) and drawing
|
|
6
|
+
* (`TextRenderer._buildRuns`) all call in here, so they can never disagree about a line's height
|
|
7
|
+
* again - the divergence that produced ISSUE-5.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* The line box for a set of fonts sharing one line.
|
|
11
|
+
*
|
|
12
|
+
* `lineHeight` is a multiplier of the font size, like CSS. Leave it `undefined` for the font's
|
|
13
|
+
* NATURAL line height (`ascent + descent + lineGap`), which is what CSS calls `line-height: normal`.
|
|
14
|
+
*
|
|
15
|
+
* Whatever is left over between the box and the font's content is split evenly above and below
|
|
16
|
+
* ("half-leading", as CSS and Flutter do), so the glyphs sit centred in their own box. An explicit
|
|
17
|
+
* `lineHeight` smaller than the content makes the leading negative and the glyphs overflow - again
|
|
18
|
+
* exactly like CSS, and the author asked for it.
|
|
19
|
+
*/
|
|
20
|
+
export function lineBoxFor(parts, lineHeight) {
|
|
21
|
+
if (parts.length === 0)
|
|
22
|
+
return { height: 0, baseline: 0 };
|
|
23
|
+
let ascent = 0;
|
|
24
|
+
let descent = 0;
|
|
25
|
+
let gap = 0;
|
|
26
|
+
let maxFontSize = 0;
|
|
27
|
+
for (const { verticals, fontSize } of parts) {
|
|
28
|
+
ascent = Math.max(ascent, verticals.ascent * fontSize);
|
|
29
|
+
descent = Math.max(descent, verticals.descent * fontSize);
|
|
30
|
+
gap = Math.max(gap, verticals.lineGap * fontSize);
|
|
31
|
+
maxFontSize = Math.max(maxFontSize, fontSize);
|
|
32
|
+
}
|
|
33
|
+
const content = ascent + descent;
|
|
34
|
+
const height = lineHeight === undefined ? content + gap : maxFontSize * lineHeight;
|
|
35
|
+
return { height, baseline: (height - content) / 2 + ascent };
|
|
36
|
+
}
|
|
37
|
+
/** The line box of a single-font line (the plain-string path). */
|
|
38
|
+
export function lineBoxForString(metrics, fontFamily, fontStyle, fontSize, lineHeight) {
|
|
39
|
+
return lineBoxFor([{ verticals: metrics.getFontVerticals(fontFamily, fontStyle), fontSize }], lineHeight);
|
|
40
|
+
}
|
|
41
|
+
/** The line box of a mixed-font line: the tallest ascent and the deepest descent on it win, so a
|
|
42
|
+
* big or a deep-descending span pushes the whole line apart rather than colliding with it. */
|
|
43
|
+
export function lineBoxForSegmentLine(line, defaults, metrics, lineHeight) {
|
|
44
|
+
const parts = line.segments.map((segment) => {
|
|
45
|
+
const fontFamily = segment.fontFamily ?? defaults.fontFamily;
|
|
46
|
+
const fontStyle = segment.fontStyle ?? defaults.fontStyle;
|
|
47
|
+
return {
|
|
48
|
+
verticals: metrics.getFontVerticals(fontFamily, fontStyle),
|
|
49
|
+
fontSize: segment.fontSize ?? defaults.fontSize,
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
// A line with no segments still occupies its default font's box (an empty paragraph keeps height).
|
|
53
|
+
if (parts.length === 0) {
|
|
54
|
+
return lineBoxForString(metrics, defaults.fontFamily, defaults.fontStyle, defaults.fontSize, lineHeight);
|
|
55
|
+
}
|
|
56
|
+
return lineBoxFor(parts, lineHeight);
|
|
57
|
+
}
|