@jasy/pdf 1.0.0-alpha.8 → 1.0.0-alpha.9
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 +4 -3
- package/dist/api/content.d.ts +5 -4
- package/dist/api/content.js +13 -7
- package/dist/api/dimension.d.ts +56 -0
- package/dist/api/dimension.js +27 -0
- package/dist/api/font-url.d.ts +28 -0
- package/dist/api/font-url.js +134 -0
- package/dist/api/gradient.d.ts +53 -0
- package/dist/api/gradient.js +97 -0
- package/dist/api/index.d.ts +2 -0
- package/dist/api/index.js +2 -0
- package/dist/api/insets.d.ts +19 -12
- package/dist/api/insets.js +18 -7
- package/dist/api/layout.d.ts +20 -6
- package/dist/api/layout.js +31 -6
- package/dist/api/page-builder.d.ts +8 -0
- package/dist/api/page-builder.js +12 -0
- package/dist/api/structure.d.ts +5 -0
- package/dist/api/structure.js +16 -4
- package/dist/api/text.d.ts +42 -6
- package/dist/api/text.js +29 -5
- package/dist/edit/document.d.ts +14 -4
- package/dist/edit/document.js +15 -22
- package/dist/edit/fill.d.ts +19 -2
- package/dist/edit/fill.js +63 -34
- package/dist/edit/flatten.d.ts +13 -1
- package/dist/edit/flatten.js +38 -13
- package/dist/edit/index.d.ts +2 -0
- package/dist/edit/index.js +3 -1
- package/dist/edit/objects.d.ts +5 -0
- package/dist/edit/objects.js +27 -0
- package/dist/edit/writer.d.ts +7 -0
- package/dist/edit/writer.js +23 -1
- package/dist/elements/container-element.d.ts +21 -1
- package/dist/elements/container-element.js +25 -10
- package/dist/elements/image-element.d.ts +18 -4
- package/dist/elements/image-element.js +27 -12
- package/dist/elements/layout/expanded-element.d.ts +3 -1
- package/dist/elements/layout/expanded-element.js +12 -3
- package/dist/elements/layout/padding-element.d.ts +4 -2
- package/dist/elements/layout/padding-element.js +4 -2
- package/dist/elements/layout/page-builder-element.js +2 -0
- package/dist/elements/pdf-element.d.ts +59 -1
- package/dist/elements/pdf-element.js +62 -1
- package/dist/elements/rectangle-element.d.ts +20 -6
- package/dist/elements/rectangle-element.js +25 -12
- package/dist/elements/row-element.d.ts +24 -1
- package/dist/elements/row-element.js +25 -10
- package/dist/elements/text-element.d.ts +60 -1
- package/dist/elements/text-element.js +132 -33
- package/dist/ir/display-list.d.ts +23 -6
- package/dist/ir/display-list.js +8 -1
- package/dist/layout/alignment.d.ts +10 -0
- package/dist/layout/alignment.js +6 -0
- package/dist/layout/box-constraints.d.ts +53 -0
- package/dist/layout/box-constraints.js +72 -0
- package/dist/layout/insets.d.ts +28 -0
- package/dist/layout/insets.js +20 -0
- package/dist/renderer/image-renderer.js +2 -1
- package/dist/renderer/pdf-backend.d.ts +6 -0
- package/dist/renderer/pdf-backend.js +78 -31
- package/dist/renderer/pdf-document-renderer.js +8 -1
- package/dist/renderer/rectangle-renderer.js +8 -2
- package/dist/renderer/text-renderer.d.ts +2 -2
- package/dist/renderer/text-renderer.js +150 -42
- package/dist/text/advance.js +5 -2
- package/dist/text/arabic.d.ts +20 -0
- package/dist/text/arabic.js +101 -0
- package/dist/text/bidi.d.ts +33 -0
- package/dist/text/bidi.js +94 -0
- package/dist/text/font-fallback.d.ts +25 -0
- package/dist/text/font-fallback.js +38 -0
- package/dist/text/line-breaker.d.ts +35 -1
- package/dist/text/line-breaker.js +76 -16
- package/dist/text/orphans-widows.d.ts +27 -0
- package/dist/text/orphans-widows.js +53 -0
- package/dist/text/shape.d.ts +29 -0
- package/dist/text/shape.js +67 -0
- package/dist/text/text-style.d.ts +25 -0
- package/dist/text/text-style.js +44 -1
- package/dist/utils/flex-layout.d.ts +24 -4
- package/dist/utils/flex-layout.js +169 -7
- package/dist/utils/font-metrics.d.ts +5 -0
- package/dist/utils/gsub.d.ts +36 -0
- package/dist/utils/gsub.js +178 -0
- package/dist/utils/inflate.d.ts +16 -0
- package/dist/utils/inflate.js +52 -0
- package/dist/utils/pdf-object-manager.d.ts +21 -0
- package/dist/utils/pdf-object-manager.js +107 -9
- package/dist/utils/ttf-parser.d.ts +6 -0
- package/dist/utils/ttf-parser.js +19 -0
- package/dist/utils/utf8-to-windows1252-encoder.d.ts +6 -0
- package/dist/utils/utf8-to-windows1252-encoder.js +10 -0
- package/dist/utils/woff.d.ts +10 -0
- package/dist/utils/woff.js +116 -0
- package/dist/validators/element-validator.js +9 -2
- package/package.json +2 -1
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import { Color } from "../common/color.js";
|
|
2
2
|
import { HorizontalAlignment } from "../elements/pdf-element.js";
|
|
3
|
+
import { VERTICAL_TEXT_SHIFT } from "../elements/text-element.js";
|
|
3
4
|
import { isEmojiCodePoint } from "../text/emoji-codepoints.js";
|
|
4
5
|
import { emojiImageNode } from "./emoji-image.js";
|
|
5
|
-
import { wrapStringIntoLines, breakSegmentsIntoLines, } from "../text/line-breaker.js";
|
|
6
|
+
import { singleLineWidth, MAX_SPACE_SHRINK, wrapStringIntoLines, breakSegmentsIntoLines, } from "../text/line-breaker.js";
|
|
6
7
|
import { lineBoxForSegmentLine, lineBoxForString } from "../text/line-metrics.js";
|
|
7
8
|
import { runAdvance } from "../text/advance.js";
|
|
9
|
+
import { visualRuns, visualRunsOf } from "../text/bidi.js";
|
|
8
10
|
import { skipInkSegments, strikethroughStroke, underlineStroke, } from "../text/text-decoration.js";
|
|
9
11
|
export class TextRenderer {
|
|
10
12
|
// Measuring only needs metrics, not the full object manager. (The render pass below
|
|
11
13
|
// still receives the manager because it also registers fonts/images.)
|
|
12
|
-
static calculateTextHeight(content, fontSize, fontFamily, fontStyle, objectManager, maxWidth, maxLines, overflow, lineHeight, letterSpacing = 0) {
|
|
14
|
+
static calculateTextHeight(content, fontSize, fontFamily, fontStyle, objectManager, maxWidth, maxLines, overflow, lineHeight, letterSpacing = 0, lineOptions = {}) {
|
|
13
15
|
// Plain string: every wrapped line gets the same box.
|
|
14
16
|
if (typeof content === "string") {
|
|
15
|
-
const lines = wrapStringIntoLines(content, fontFamily, fontSize, fontStyle, maxWidth, objectManager, maxLines, overflow, letterSpacing);
|
|
17
|
+
const lines = wrapStringIntoLines(content, fontFamily, fontSize, fontStyle, maxWidth, objectManager, maxLines, overflow, letterSpacing, lineOptions);
|
|
16
18
|
const box = lineBoxForString(objectManager, fontFamily, fontStyle, fontSize, lineHeight);
|
|
17
19
|
return lines.length * box.height;
|
|
18
20
|
}
|
|
@@ -22,11 +24,11 @@ export class TextRenderer {
|
|
|
22
24
|
return lines.reduce((total, line) => total + lineBoxForSegmentLine(line, defaults, objectManager, lineHeight).height, 0);
|
|
23
25
|
}
|
|
24
26
|
static async render(textElement, objectManager) {
|
|
25
|
-
const { x, y, width, fontSize, color, content, fontFamily, fontStyle, textAlignment, maxLines, overflow, lineHeight, underline, strikethrough, skipInk, letterSpacing, role, } = textElement.getProps();
|
|
27
|
+
const { x, y, width, fontSize, color, content, fontFamily, fontStyle, textAlignment, maxLines, overflow, lineHeight, underline, strikethrough, skipInk, letterSpacing, direction, wordSpacing, textIndent, role, } = textElement.getProps();
|
|
26
28
|
// Component -> display list. Wrapping and positioning stay here; the backend
|
|
27
29
|
// turns each run into BT/Tf/Td/Tj/ET. The wrapping algorithm is unchanged from
|
|
28
30
|
// the original renderer - unifying it into the engine is Phase 3.
|
|
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);
|
|
31
|
+
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, direction, wordSpacing, textIndent);
|
|
30
32
|
// Accessible tagging: this whole text block is one structure element (a paragraph P, or a heading
|
|
31
33
|
// from `role`); each run's text lands under it. Emoji sub-nodes produced below stay untagged (drawn
|
|
32
34
|
// as artifacts). Keyed by structId so a block split across pages stays one element.
|
|
@@ -61,6 +63,13 @@ export class TextRenderer {
|
|
|
61
63
|
const imageSource = om.getEmojiImageSource();
|
|
62
64
|
if (!own && !fallback && !imageSource)
|
|
63
65
|
return [run];
|
|
66
|
+
// A SHAPED run cannot be split here: the sub-runs are cut by code point, while `glyphs` is a
|
|
67
|
+
// drawn-order list in which a ligature is one glyph for several code points - so no sub-run could
|
|
68
|
+
// be given the right slice, and inheriting the whole list would draw every glyph again per piece.
|
|
69
|
+
// Passed through whole instead: an emoji inside Arabic then comes from the text font rather than
|
|
70
|
+
// the emoji source (`todo.md`).
|
|
71
|
+
if (run.glyphs)
|
|
72
|
+
return [run];
|
|
64
73
|
const nodes = [];
|
|
65
74
|
let cursorX = run.x; // absolute x of the next glyph
|
|
66
75
|
let pending = ""; // run of consecutive non-color characters
|
|
@@ -215,7 +224,7 @@ export class TextRenderer {
|
|
|
215
224
|
// Lay the content out into absolutely-positioned text runs. Glyph positions match
|
|
216
225
|
// the previous hand-written operators exactly (verified by pixel-identical render);
|
|
217
226
|
// only the output form changed from PDF strings to `TextRun`s.
|
|
218
|
-
static _buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, maxWidth, textAlignment, color, initialX, yPosition, maxLines, overflow, lineHeight, underline = false, strikethrough = false, skipInk = false, letterSpacing = 0) {
|
|
227
|
+
static _buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, maxWidth, textAlignment, color, initialX, yPosition, maxLines, overflow, lineHeight, underline = false, strikethrough = false, skipInk = false, letterSpacing = 0, direction = "ltr", wordSpacing = 0, textIndent = 0) {
|
|
219
228
|
const runs = [];
|
|
220
229
|
// /Link annotation rects for any `href` spans, collected as we place segments. Empty for the
|
|
221
230
|
// common (no-href) case, so plain text keeps producing exactly the runs it did before.
|
|
@@ -224,13 +233,74 @@ export class TextRenderer {
|
|
|
224
233
|
// per line, and a decorated span only spans its own glyphs).
|
|
225
234
|
const decorations = [];
|
|
226
235
|
// Horizontal offset of a line of the given width under the current alignment.
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
236
|
+
// `start` is the one alignment that depends on the direction, resolved at the single place
|
|
237
|
+
// alignment is consumed.
|
|
238
|
+
const align = textAlignment === HorizontalAlignment.start
|
|
239
|
+
? direction === "rtl"
|
|
240
|
+
? HorizontalAlignment.right
|
|
241
|
+
: HorizontalAlignment.left
|
|
242
|
+
: textAlignment;
|
|
243
|
+
const alignmentOffset = (lineWidth, indent = 0) => {
|
|
244
|
+
const room = maxWidth - indent;
|
|
245
|
+
if (align === HorizontalAlignment.center)
|
|
246
|
+
return (room - lineWidth) / 2;
|
|
247
|
+
if (align === HorizontalAlignment.right)
|
|
248
|
+
return room - lineWidth;
|
|
232
249
|
return 0;
|
|
233
250
|
};
|
|
251
|
+
/**
|
|
252
|
+
* Justification: how much extra to put in EACH space of a line so it reaches the full width.
|
|
253
|
+
* The LAST line of a paragraph keeps its natural spacing, as it does in CSS and in print - a
|
|
254
|
+
* justified last line is the classic sign of a broken text engine.
|
|
255
|
+
*
|
|
256
|
+
* Spaces are stretched by moving the pen, not by the `Tw` operator: `Tw` applies only to the
|
|
257
|
+
* single byte 32, so an embedded (Identity-H) font would ignore it entirely.
|
|
258
|
+
*/
|
|
259
|
+
const justifyExtra = (line, naturalWidth, isLast, indent = 0) => {
|
|
260
|
+
if (align !== HorizontalAlignment.justify)
|
|
261
|
+
return 0;
|
|
262
|
+
const gaps = [...line].filter((c) => c === " ").length;
|
|
263
|
+
if (gaps === 0)
|
|
264
|
+
return 0;
|
|
265
|
+
const slack = maxWidth - indent - naturalWidth;
|
|
266
|
+
// Negative slack is real now: the breaker keeps a word when the line can be SQUEEZED to hold
|
|
267
|
+
// it, so justification has to deliver that squeeze. The floor is the same limit the breaker
|
|
268
|
+
// used, which is what stops a line it never promised from being crushed.
|
|
269
|
+
const spaceWidth = runAdvance(objectManager, " ", { fontFamily, fontSize, fontStyle }, letterSpacing);
|
|
270
|
+
const extra = Math.max(slack / gaps, -spaceWidth * MAX_SPACE_SHRINK);
|
|
271
|
+
// The last line is never STRETCHED - the rule print and CSS follow. It may still be SQUEEZED:
|
|
272
|
+
// the breaker was allowed to pack it that way, and drawing it loose would push it out of its box.
|
|
273
|
+
return isLast ? Math.min(0, extra) : extra;
|
|
274
|
+
};
|
|
275
|
+
/**
|
|
276
|
+
* Push one drawn piece and return the advance it took. When the line is justified the pen jumps an
|
|
277
|
+
* extra `extra` at every space, which means one run per word - the only way that works for an
|
|
278
|
+
* embedded font, whose spaces `Tw` cannot reach.
|
|
279
|
+
*
|
|
280
|
+
* A SHAPED piece is never split: its glyph list is in drawn order with ligatures merged, so no
|
|
281
|
+
* slice of it is correct. Such a piece keeps its natural spacing.
|
|
282
|
+
*/
|
|
283
|
+
const emitPiece = (piece, x, extra, style, runFont, spacing) => {
|
|
284
|
+
const push = (at, text, glyphs) => {
|
|
285
|
+
runs.push({ type: "text", x: at, text, ...(glyphs ? { glyphs } : {}), ...style });
|
|
286
|
+
};
|
|
287
|
+
if (extra === 0 || piece.glyphs || !piece.text.includes(" ")) {
|
|
288
|
+
push(x, piece.text, piece.glyphs);
|
|
289
|
+
return runAdvance(objectManager, piece.text, runFont, spacing);
|
|
290
|
+
}
|
|
291
|
+
const spaceWidth = runAdvance(objectManager, " ", runFont, spacing);
|
|
292
|
+
const words = piece.text.split(" ");
|
|
293
|
+
let pen = x;
|
|
294
|
+
words.forEach((word, i) => {
|
|
295
|
+
if (word !== "") {
|
|
296
|
+
push(pen, word);
|
|
297
|
+
pen += runAdvance(objectManager, word, runFont, spacing);
|
|
298
|
+
}
|
|
299
|
+
if (i < words.length - 1)
|
|
300
|
+
pen += spaceWidth + extra;
|
|
301
|
+
});
|
|
302
|
+
return pen - x;
|
|
303
|
+
};
|
|
234
304
|
// The decoration strokes for one drawn run, at the geometry ITS OWN font declares. A mixed-size
|
|
235
305
|
// line therefore gets a thicker line under the bigger span, which is what a browser does too.
|
|
236
306
|
const decorate = (text, runX, runWidth, baselineY, family, style, size, runColor, wantsUnderline, wantsStrikethrough, runLetterSpacing) => {
|
|
@@ -276,31 +346,58 @@ export class TextRenderer {
|
|
|
276
346
|
const inkSpans = objectManager.getInkSpansInBand(text, size, family, style, -below + half, -below - half, runLetterSpacing);
|
|
277
347
|
return skipInkSegments(runWidth, inkSpans, stroke.thickness);
|
|
278
348
|
};
|
|
349
|
+
/**
|
|
350
|
+
* One drawn piece of a line. SHAPING must see the run's LOGICAL text - a letter's form comes from
|
|
351
|
+
* its neighbours, and a reversed run has those swapped - so we shape that and then reverse the
|
|
352
|
+
* GLYPHS for an rtl run. A font that does not shape keeps the old path exactly: draw the visual
|
|
353
|
+
* text, no glyph list, byte-identical output.
|
|
354
|
+
*/
|
|
355
|
+
const shapedPiece = (part, family, style) => {
|
|
356
|
+
const shaped = objectManager.shapeText(part.logical, family, style);
|
|
357
|
+
if (!shaped)
|
|
358
|
+
return { text: part.text };
|
|
359
|
+
const ordered = part.rtl ? [...shaped].reverse() : shaped;
|
|
360
|
+
return { text: part.logical, glyphs: ordered.map((g) => g.glyph) };
|
|
361
|
+
};
|
|
279
362
|
// The advance the viewer will use: plain glyph widths (a `Tj` never kerns) plus one
|
|
280
363
|
// `letterSpacing` per code point (the `Tc` operator), from the one shared `advance.ts` primitive.
|
|
281
364
|
const font = { fontFamily, fontSize, fontStyle };
|
|
282
365
|
// --- Plain string: one run per wrapped line. ---
|
|
283
366
|
if (typeof content === "string") {
|
|
284
|
-
const lines = wrapStringIntoLines(content, fontFamily, fontSize, fontStyle, maxWidth, objectManager, maxLines, overflow, letterSpacing
|
|
367
|
+
const lines = wrapStringIntoLines(content, fontFamily, fontSize, fontStyle, maxWidth, objectManager, maxLines, overflow, letterSpacing,
|
|
368
|
+
// The SAME knobs the measure pass used (`TextElement.lineOptions`), or a line would be
|
|
369
|
+
// measured one width and drawn another.
|
|
370
|
+
{
|
|
371
|
+
wordSpacing,
|
|
372
|
+
indent: textIndent,
|
|
373
|
+
shrink: align === HorizontalAlignment.justify ? MAX_SPACE_SHRINK : 0,
|
|
374
|
+
});
|
|
285
375
|
// yPosition is the top of the text box (top-left). The line box seats its own baseline; lines
|
|
286
376
|
// then stack by that box's height. Same numbers as calculateTextHeight, by construction.
|
|
287
377
|
const box = lineBoxForString(objectManager, fontFamily, fontStyle, fontSize, lineHeight);
|
|
288
378
|
lines.forEach((line, index) => {
|
|
289
|
-
const lineWidth =
|
|
290
|
-
|
|
379
|
+
const lineWidth = singleLineWidth(line, font, objectManager, letterSpacing, wordSpacing);
|
|
380
|
+
// CSS `text-indent`: the FIRST line starts in, and had that much less room to fill.
|
|
381
|
+
const indent = index === 0 ? textIndent : 0;
|
|
382
|
+
let x = initialX + indent + alignmentOffset(lineWidth, indent);
|
|
291
383
|
const baseline = yPosition + box.baseline + box.height * index;
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
fontFamily,
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
384
|
+
// Latin-only text in an `ltr` paragraph comes back as one unchanged piece.
|
|
385
|
+
// Word-spacing is a per-gap advance just like the justification slack, so it rides the same
|
|
386
|
+
// mechanism: `Tw` would reach only the single byte 32 and miss every embedded font.
|
|
387
|
+
const extra = wordSpacing + justifyExtra(line, lineWidth, index === lines.length - 1, indent);
|
|
388
|
+
for (const part of visualRuns(line, direction)) {
|
|
389
|
+
const piece = shapedPiece(part, fontFamily, fontStyle);
|
|
390
|
+
const partWidth = emitPiece(piece, x, extra, {
|
|
391
|
+
y: baseline,
|
|
392
|
+
fontFamily,
|
|
393
|
+
fontStyle,
|
|
394
|
+
fontSize,
|
|
395
|
+
color,
|
|
396
|
+
...(letterSpacing ? { letterSpacing } : {}),
|
|
397
|
+
}, font, letterSpacing);
|
|
398
|
+
decorate(piece.text, x, partWidth, baseline, fontFamily, fontStyle, fontSize, color, underline, strikethrough, letterSpacing);
|
|
399
|
+
x += partWidth;
|
|
400
|
+
}
|
|
304
401
|
});
|
|
305
402
|
return { runs, links, decorations };
|
|
306
403
|
}
|
|
@@ -309,27 +406,34 @@ export class TextRenderer {
|
|
|
309
406
|
// the previous segment's kerning-free advance. Each line drops by its OWN leading
|
|
310
407
|
// (tallest font on that line), so mixed-font lines space correctly and the drawn
|
|
311
408
|
// height matches the measured height.
|
|
312
|
-
const pushLine = (line, lineY) => {
|
|
409
|
+
const pushLine = (line, lineY, isLast) => {
|
|
313
410
|
let x = initialX + alignmentOffset(line.width);
|
|
314
|
-
line.segments.
|
|
411
|
+
const extra = justifyExtra(line.segments.map((seg) => seg.content).join(""), line.width, isLast);
|
|
412
|
+
// Across span boundaries, so a Hebrew span is ordered against its neighbours rather than inside
|
|
413
|
+
// itself. Each run keeps the segment it came from, and with it that span's style.
|
|
414
|
+
const ordered = visualRunsOf(line.segments.map((segment) => ({ text: segment.content, source: segment })), direction);
|
|
415
|
+
ordered.forEach((part) => {
|
|
416
|
+
const segment = part.source;
|
|
315
417
|
const family = segment.fontFamily || fontFamily;
|
|
316
418
|
const size = segment.fontSize || fontSize;
|
|
317
419
|
const style = segment.fontStyle || fontStyle;
|
|
318
420
|
const segLetterSpacing = segment.letterSpacing ?? letterSpacing;
|
|
319
|
-
const
|
|
421
|
+
const piece = shapedPiece(part, family, style);
|
|
422
|
+
const runText = piece.text;
|
|
320
423
|
const runColor = segment.fontColor || color;
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
424
|
+
// `vertical-align`: this run alone leaves the line's baseline. Its own decoration and link box
|
|
425
|
+
// ride along, since both are placed from the same y.
|
|
426
|
+
const shift = VERTICAL_TEXT_SHIFT[segment.verticalAlign ?? "baseline"] * size;
|
|
427
|
+
const runY = lineY - shift;
|
|
428
|
+
const drawn = emitPiece(piece, x, extra, {
|
|
429
|
+
y: runY,
|
|
326
430
|
fontFamily: family,
|
|
327
431
|
fontStyle: style,
|
|
328
432
|
fontSize: size,
|
|
329
433
|
color: runColor,
|
|
330
434
|
...(segLetterSpacing ? { letterSpacing: segLetterSpacing } : {}),
|
|
331
|
-
});
|
|
332
|
-
decorate(
|
|
435
|
+
}, { fontFamily: family, fontSize: size, fontStyle: style }, segLetterSpacing);
|
|
436
|
+
decorate(runText, x, drawn, runY, family, style, size, runColor, segment.underline ?? underline, segment.strikethrough ?? strikethrough, segLetterSpacing);
|
|
333
437
|
// An href/to span becomes a /Link annotation over exactly this run's glyph box: from its own
|
|
334
438
|
// ascent above the baseline down to its own descender. A span wrapped across lines yields
|
|
335
439
|
// one rect per line.
|
|
@@ -338,14 +442,14 @@ export class TextRenderer {
|
|
|
338
442
|
links.push({
|
|
339
443
|
type: "link",
|
|
340
444
|
x,
|
|
341
|
-
y:
|
|
342
|
-
width:
|
|
445
|
+
y: runY - v.ascent * size,
|
|
446
|
+
width: drawn,
|
|
343
447
|
height: (v.ascent + v.descent) * size,
|
|
344
448
|
href: segment.href,
|
|
345
449
|
dest: segment.dest,
|
|
346
450
|
});
|
|
347
451
|
}
|
|
348
|
-
x +=
|
|
452
|
+
x += drawn;
|
|
349
453
|
});
|
|
350
454
|
};
|
|
351
455
|
// yPosition is the top of the text box (top-left). Each line seats its own baseline inside its
|
|
@@ -355,11 +459,15 @@ export class TextRenderer {
|
|
|
355
459
|
// measure path (calculateTextHeight) uses, or segmented spaced text mis-wraps (measured != drawn).
|
|
356
460
|
const defaults = { fontFamily, fontSize, fontStyle, letterSpacing };
|
|
357
461
|
let top = yPosition;
|
|
358
|
-
|
|
462
|
+
// Materialised, because justification has to know which line is the LAST one of the paragraph.
|
|
463
|
+
const segmentLines = [
|
|
464
|
+
...breakSegmentsIntoLines(content, defaults, maxWidth, objectManager, maxLines, overflow),
|
|
465
|
+
];
|
|
466
|
+
segmentLines.forEach((line, index) => {
|
|
359
467
|
const box = lineBoxForSegmentLine(line, defaults, objectManager, lineHeight);
|
|
360
|
-
pushLine(line, top + box.baseline);
|
|
468
|
+
pushLine(line, top + box.baseline, index === segmentLines.length - 1);
|
|
361
469
|
top += box.height;
|
|
362
|
-
}
|
|
470
|
+
});
|
|
363
471
|
return { runs, links, decorations };
|
|
364
472
|
}
|
|
365
473
|
}
|
package/dist/text/advance.js
CHANGED
|
@@ -30,8 +30,11 @@ export function codePointCount(text) {
|
|
|
30
30
|
* plus one `letterSpacing` per code point. */
|
|
31
31
|
export function runAdvance(metrics, text, font, letterSpacing = 0) {
|
|
32
32
|
let advance = metrics.getStringWidth(text, font.fontFamily, font.fontSize, font.fontStyle);
|
|
33
|
-
if (letterSpacing !== 0)
|
|
34
|
-
|
|
33
|
+
if (letterSpacing !== 0) {
|
|
34
|
+
// Per DRAWN glyph, which is what `Tc` applies to; only a ligature makes that differ.
|
|
35
|
+
const glyphs = metrics.shapedGlyphCount?.(text, font.fontFamily, font.fontStyle) ?? codePointCount(text);
|
|
36
|
+
advance += glyphs * letterSpacing;
|
|
37
|
+
}
|
|
35
38
|
if (metrics.kerningEnabled) {
|
|
36
39
|
let units = 0;
|
|
37
40
|
for (const k of metrics.getKernPairs(text, font.fontFamily, font.fontStyle))
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Arabic (and Syriac / N'Ko / Adlam) JOINING: which of the four positional forms each letter takes -
|
|
3
|
+
* the half of shaping Unicode owns. Runs on the LOGICAL string.
|
|
4
|
+
*
|
|
5
|
+
* Name the SIDES, not the neighbours: a letter is joined on its right when the preceding one can join
|
|
6
|
+
* forwards (type D or L), on its left when the following one can join backwards (D or R). Thinking in
|
|
7
|
+
* "previous/next" puts a final form where an initial belongs.
|
|
8
|
+
*/
|
|
9
|
+
export type JoiningType = "D" | "R" | "L" | "C" | "T" | "U";
|
|
10
|
+
/** The four positional forms, named as the OpenType features that realise them. */
|
|
11
|
+
export type JoiningForm = "isol" | "init" | "medi" | "fina";
|
|
12
|
+
/** The joining type of one code point. `U` for everything the table does not mention. */
|
|
13
|
+
export declare function joiningType(codePoint: number): JoiningType;
|
|
14
|
+
/** Whether a run contains anything the joining pass would touch. Cheap, and false for Latin. */
|
|
15
|
+
export declare function needsJoining(codePoints: readonly number[]): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* The positional form of every code point, or `null` where none applies (space, digit, mark).
|
|
18
|
+
* `C` (tatweel, ZWJ) counts when deciding a NEIGHBOUR's form but takes none itself.
|
|
19
|
+
*/
|
|
20
|
+
export declare function joiningForms(codePoints: readonly number[]): (JoiningForm | null)[];
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Arabic (and Syriac / N'Ko / Adlam) JOINING: which of the four positional forms each letter takes -
|
|
3
|
+
* the half of shaping Unicode owns. Runs on the LOGICAL string.
|
|
4
|
+
*
|
|
5
|
+
* Name the SIDES, not the neighbours: a letter is joined on its right when the preceding one can join
|
|
6
|
+
* forwards (type D or L), on its left when the following one can join backwards (D or R). Thinking in
|
|
7
|
+
* "previous/next" puts a final form where an initial belongs.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Joining types, from Unicode 17.0.0 `ArabicShaping.txt`, packed as `start,length,type` in hex.
|
|
11
|
+
* Anything not listed is `U` (non-joining), which is the standard's own default.
|
|
12
|
+
*
|
|
13
|
+
* `T` (transparent - combining marks) is mostly NOT in that file: the standard says an unlisted mark
|
|
14
|
+
* (category Mn/Me/Cf) is transparent, so those ranges are folded in from the character database.
|
|
15
|
+
*/
|
|
16
|
+
const PACKED = "300,6f,5 483,6,5 591,2c,5 5bf,0,5 5c1,1,5 5c4,1,5 5c7,0,5 610,a,5 61c,0,5 620,0,1 622,3,2" +
|
|
17
|
+
" 626,0,1 627,0,2 628,0,1 629,0,2 62a,4,1 62f,3,2 633,c,1 640,0,4 641,6,1 648,0,2 649,1,1 64b,14,5" +
|
|
18
|
+
" 66e,1,1 670,0,5 671,2,2 675,2,2 678,f,1 688,11,2 69a,25,1 6c0,0,2 6c1,1,1 6c3,8,2 6cc,0,1" +
|
|
19
|
+
" 6cd,0,2 6ce,0,1 6cf,0,2 6d0,1,1 6d2,1,2 6d5,0,2 6d6,6,5 6df,5,5 6e7,1,5 6ea,3,5 6ee,1,2 6fa,2,1" +
|
|
20
|
+
" 6ff,0,1 70f,0,5 710,0,2 711,0,5 712,2,1 715,4,2 71a,3,1 71e,0,2 71f,8,1 728,0,2 729,0,1 72a,0,2" +
|
|
21
|
+
" 72b,0,1 72c,0,2 72d,1,1 72f,0,2 730,1a,5 74d,0,2 74e,a,1 759,2,2 75c,e,1 76b,1,2 76d,3,1 771,0,2" +
|
|
22
|
+
" 772,0,1 773,1,2 775,2,1 778,1,2 77a,5,1 7a6,a,5 7ca,20,1 7eb,8,5 7fa,0,4 7fd,0,5 816,3,5 81b,8,5" +
|
|
23
|
+
" 825,2,5 829,4,5 840,0,2 841,4,1 846,1,2 848,0,1 849,0,2 84a,9,1 854,0,2 855,0,1 856,2,2 859,2,5" +
|
|
24
|
+
" 860,0,1 862,3,1 867,0,2 868,0,1 869,1,2 870,12,2 883,2,4 886,0,1 889,4,1 88e,0,2 88f,0,1 897,8,5" +
|
|
25
|
+
" 8a0,9,1 8aa,2,2 8ae,0,2 8af,1,1 8b1,1,2 8b3,5,1 8b9,0,2 8ba,e,1 8ca,17,5 8e3,1c,5 1807,0,1" +
|
|
26
|
+
" 180a,0,4 1820,58,1 1885,1,5 1887,21,1 18aa,0,1 200d,0,4 a840,31,1 a872,0,3 fe00,f,5 fe20,f,5" +
|
|
27
|
+
" 10ac0,4,1 10ac5,0,2 10ac7,0,2 10ac9,1,2 10acd,0,3 10ace,4,2 10ad3,3,1 10ad7,0,3 10ad8,4,1" +
|
|
28
|
+
" 10add,0,2 10ade,2,1 10ae1,0,2 10ae4,0,2 10aeb,3,1 10aef,0,2 10b80,0,1 10b81,0,2 10b82,0,1" +
|
|
29
|
+
" 10b83,2,2 10b86,2,1 10b89,0,2 10b8a,1,1 10b8c,0,2 10b8d,0,1 10b8e,1,2 10b90,0,1 10b91,0,2" +
|
|
30
|
+
" 10ba9,3,2 10bad,1,1 10d00,0,3 10d01,20,1 10d22,0,2 10d23,0,1 10ec2,0,2 10ec3,1,1 10ec6,1,1" +
|
|
31
|
+
" 10f30,2,1 10f33,0,2 10f34,10,1 10f51,2,1 10f54,0,2 10f70,3,1 10f74,1,2 10f76,b,1 10fb0,0,1" +
|
|
32
|
+
" 10fb2,1,1 10fb4,2,2 10fb8,0,1 10fb9,1,2 10fbb,1,1 10fbd,0,2 10fbe,1,1 10fc1,0,1 10fc2,1,2" +
|
|
33
|
+
" 10fc4,0,1 10fc9,0,2 10fca,0,1 10fcb,0,3 1e900,43,1 1e94b,0,5";
|
|
34
|
+
const TYPES = ["U", "D", "R", "L", "C", "T"];
|
|
35
|
+
// [start, end, typeIndex] triples, sorted by start - binary searched, so the table costs one lookup.
|
|
36
|
+
const RANGES = PACKED.trim()
|
|
37
|
+
.split(/\s+/)
|
|
38
|
+
.map((entry) => {
|
|
39
|
+
const [start, length, type] = entry.split(",");
|
|
40
|
+
const from = parseInt(start, 16);
|
|
41
|
+
return [from, from + parseInt(length, 16), Number(type)];
|
|
42
|
+
});
|
|
43
|
+
/** The joining type of one code point. `U` for everything the table does not mention. */
|
|
44
|
+
export function joiningType(codePoint) {
|
|
45
|
+
let lo = 0;
|
|
46
|
+
let hi = RANGES.length - 1;
|
|
47
|
+
while (lo <= hi) {
|
|
48
|
+
const mid = (lo + hi) >> 1;
|
|
49
|
+
const [from, to, type] = RANGES[mid];
|
|
50
|
+
if (codePoint < from)
|
|
51
|
+
hi = mid - 1;
|
|
52
|
+
else if (codePoint > to)
|
|
53
|
+
lo = mid + 1;
|
|
54
|
+
else
|
|
55
|
+
return TYPES[type];
|
|
56
|
+
}
|
|
57
|
+
return "U";
|
|
58
|
+
}
|
|
59
|
+
/** Whether a run contains anything the joining pass would touch. Cheap, and false for Latin. */
|
|
60
|
+
export function needsJoining(codePoints) {
|
|
61
|
+
return codePoints.some((cp) => {
|
|
62
|
+
const t = joiningType(cp);
|
|
63
|
+
return t === "D" || t === "R" || t === "L" || t === "C";
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The positional form of every code point, or `null` where none applies (space, digit, mark).
|
|
68
|
+
* `C` (tatweel, ZWJ) counts when deciding a NEIGHBOUR's form but takes none itself.
|
|
69
|
+
*/
|
|
70
|
+
export function joiningForms(codePoints) {
|
|
71
|
+
const types = codePoints.map(joiningType);
|
|
72
|
+
/** The nearest neighbour that is not transparent - a mark never breaks a join. */
|
|
73
|
+
const neighbour = (from, step) => {
|
|
74
|
+
for (let i = from + step; i >= 0 && i < types.length; i += step) {
|
|
75
|
+
if (types[i] !== "T")
|
|
76
|
+
return types[i];
|
|
77
|
+
}
|
|
78
|
+
return "U";
|
|
79
|
+
};
|
|
80
|
+
return types.map((type, i) => {
|
|
81
|
+
if (type !== "D" && type !== "R" && type !== "L")
|
|
82
|
+
return null;
|
|
83
|
+
const before = neighbour(i, -1);
|
|
84
|
+
const after = neighbour(i, +1);
|
|
85
|
+
const joinedRight = before === "D" || before === "L" || before === "C";
|
|
86
|
+
const joinedLeft = after === "D" || after === "R" || after === "C";
|
|
87
|
+
if (type === "D") {
|
|
88
|
+
if (joinedRight && joinedLeft)
|
|
89
|
+
return "medi";
|
|
90
|
+
if (joinedRight)
|
|
91
|
+
return "fina";
|
|
92
|
+
if (joinedLeft)
|
|
93
|
+
return "init";
|
|
94
|
+
return "isol";
|
|
95
|
+
}
|
|
96
|
+
// R joins only on its right; L (a handful of Syriac letters) only on its left.
|
|
97
|
+
if (type === "R")
|
|
98
|
+
return joinedRight ? "fina" : "isol";
|
|
99
|
+
return joinedLeft ? "init" : "isol";
|
|
100
|
+
});
|
|
101
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type Direction = "ltr" | "rtl";
|
|
2
|
+
/** A piece of a line, already in the order it is drawn. */
|
|
3
|
+
export interface VisualRun {
|
|
4
|
+
/** VISUAL order: hand it to the drawing code as it stands, left to right. */
|
|
5
|
+
text: string;
|
|
6
|
+
/**
|
|
7
|
+
* The same characters in LOGICAL order. SHAPING must use this: a letter's form comes from its
|
|
8
|
+
* neighbours, and reversing swaps them - shaping the visual text gives every letter the wrong form
|
|
9
|
+
* (measured: a word 8pt too wide). Shape this, then reverse the GLYPHS.
|
|
10
|
+
*/
|
|
11
|
+
logical: string;
|
|
12
|
+
/** True when this run came out of right-to-left text - the caller may need it for alignment. */
|
|
13
|
+
rtl: boolean;
|
|
14
|
+
}
|
|
15
|
+
/** A run that remembers which input piece it came from, so a span keeps its font and colour. */
|
|
16
|
+
export interface AttributedRun<T> extends VisualRun {
|
|
17
|
+
source: T;
|
|
18
|
+
}
|
|
19
|
+
/** Whether this text could be reordered at all. Cheap, and false for every Latin-only document. */
|
|
20
|
+
export declare const needsBidi: (text: string) => boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Reorder one LINE of pieces into the runs to draw. The algorithm runs over the pieces JOINED, not
|
|
23
|
+
* each on its own, or a Hebrew span beside a Latin one would stay in source order. A run ends where
|
|
24
|
+
* the direction or the source piece changes.
|
|
25
|
+
*
|
|
26
|
+
* Line breaking happens before this, in logical order - what UAX #9 prescribes.
|
|
27
|
+
*/
|
|
28
|
+
export declare function visualRunsOf<T>(pieces: {
|
|
29
|
+
text: string;
|
|
30
|
+
source: T;
|
|
31
|
+
}[], base?: Direction): AttributedRun<T>[];
|
|
32
|
+
/** The same for one plain string. An empty string still yields one run, or a blank line would vanish. */
|
|
33
|
+
export declare const visualRuns: (text: string, base?: Direction) => VisualRun[];
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import bidiFactory from "bidi-js";
|
|
2
|
+
/**
|
|
3
|
+
* Bidirectional text (UAX #9): one logical line in, the runs to draw left to right out. ORDERING
|
|
4
|
+
* only - joining letters into shapes is `text/shape.ts`.
|
|
5
|
+
*
|
|
6
|
+
* The whole seam to `bidi-js`: nothing outside this file knows it exists, so replacing it with our
|
|
7
|
+
* own is a change here alone (`todo.md`).
|
|
8
|
+
*/
|
|
9
|
+
const bidi = bidiFactory();
|
|
10
|
+
// Every block whose script runs right to left, plus the explicit bidi control characters. Used only
|
|
11
|
+
// as a fast path: no match and a left-to-right base means the algorithm cannot reorder anything.
|
|
12
|
+
const RTL_OR_CONTROL = /[\u0590-\u08FF\u200F\u202B\u202E\u2067\uFB1D-\uFDFF\uFE70-\uFEFF]|[\uD802\uD803\uD83A\uD83B][\uDC00-\uDFFF]/;
|
|
13
|
+
/** Whether this text could be reordered at all. Cheap, and false for every Latin-only document. */
|
|
14
|
+
export const needsBidi = (text) => RTL_OR_CONTROL.test(text);
|
|
15
|
+
const isHighSurrogate = (c) => c >= "\uD800" && c <= "\uDBFF";
|
|
16
|
+
const isLowSurrogate = (c) => c >= "\uDC00" && c <= "\uDFFF";
|
|
17
|
+
/**
|
|
18
|
+
* Reorder one LINE of pieces into the runs to draw. The algorithm runs over the pieces JOINED, not
|
|
19
|
+
* each on its own, or a Hebrew span beside a Latin one would stay in source order. A run ends where
|
|
20
|
+
* the direction or the source piece changes.
|
|
21
|
+
*
|
|
22
|
+
* Line breaking happens before this, in logical order - what UAX #9 prescribes.
|
|
23
|
+
*/
|
|
24
|
+
export function visualRunsOf(pieces, base = "ltr") {
|
|
25
|
+
const text = pieces.map((p) => p.text).join("");
|
|
26
|
+
// Verbatim - same count, same order, empty pieces included - so untouched documents stay
|
|
27
|
+
// byte-identical.
|
|
28
|
+
if (text === "" || (base === "ltr" && !needsBidi(text))) {
|
|
29
|
+
return pieces.map((p) => ({ ...p, logical: p.text, rtl: false }));
|
|
30
|
+
}
|
|
31
|
+
// Which piece each code unit came from, so a visual run can be traced back to its span.
|
|
32
|
+
const owner = new Int32Array(text.length);
|
|
33
|
+
let at = 0;
|
|
34
|
+
pieces.forEach((piece, index) => {
|
|
35
|
+
owner.fill(index, at, at + piece.text.length);
|
|
36
|
+
at += piece.text.length;
|
|
37
|
+
});
|
|
38
|
+
const levels = bidi.getEmbeddingLevels(text, base);
|
|
39
|
+
const order = bidi.getReorderedIndices(text, levels);
|
|
40
|
+
// `.levels`, NOT the result object the library's README passes: it indexes its argument directly,
|
|
41
|
+
// and given the object it silently returns an empty map - no bracket ever mirrored.
|
|
42
|
+
const mirrored = bidi.getMirroredCharactersMap(text, levels.levels);
|
|
43
|
+
const runs = [];
|
|
44
|
+
let current = []; // visual order, mirrored
|
|
45
|
+
let written = []; // the same run as the author wrote it
|
|
46
|
+
let currentRtl = false;
|
|
47
|
+
let currentOwner = -1;
|
|
48
|
+
const flush = () => {
|
|
49
|
+
if (current.length > 0) {
|
|
50
|
+
runs.push({
|
|
51
|
+
text: current.join(""),
|
|
52
|
+
// Tracked separately rather than un-reversing the visual text: that text has been MIRRORED,
|
|
53
|
+
// so a bracket would come back as its twin and shaping - and `ToUnicode` after it - would see
|
|
54
|
+
// a character the author never wrote.
|
|
55
|
+
logical: (currentRtl ? [...written].reverse() : written).join(""),
|
|
56
|
+
rtl: currentRtl,
|
|
57
|
+
source: pieces[currentOwner].source,
|
|
58
|
+
});
|
|
59
|
+
current = [];
|
|
60
|
+
written = [];
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
for (let k = 0; k < order.length; k++) {
|
|
64
|
+
const i = order[k];
|
|
65
|
+
const rtl = (levels.levels[i] & 1) === 1;
|
|
66
|
+
if (rtl !== currentRtl || owner[i] !== currentOwner)
|
|
67
|
+
flush();
|
|
68
|
+
currentRtl = rtl;
|
|
69
|
+
currentOwner = owner[i];
|
|
70
|
+
// A surrogate pair is two code units at one level, so reversing splits an astral character (an
|
|
71
|
+
// emoji in Hebrew text) into two broken halves. They arrive adjacent and swapped.
|
|
72
|
+
const next = k + 1 < order.length ? order[k + 1] : -1;
|
|
73
|
+
if (isLowSurrogate(text[i]) && next === i - 1 && isHighSurrogate(text[next])) {
|
|
74
|
+
current.push(text[next], text[i]);
|
|
75
|
+
written.push(text[next], text[i]);
|
|
76
|
+
k++;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
current.push(mirrored.get(i) ?? text[i]);
|
|
80
|
+
written.push(text[i]);
|
|
81
|
+
}
|
|
82
|
+
flush();
|
|
83
|
+
return runs;
|
|
84
|
+
}
|
|
85
|
+
/** The same for one plain string. An empty string still yields one run, or a blank line would vanish. */
|
|
86
|
+
export const visualRuns = (text, base = "ltr") => {
|
|
87
|
+
if (text === "")
|
|
88
|
+
return [{ text, logical: text, rtl: false }];
|
|
89
|
+
return visualRunsOf([{ text, source: null }], base).map(({ text: t, logical, rtl }) => ({
|
|
90
|
+
text: t,
|
|
91
|
+
logical,
|
|
92
|
+
rtl,
|
|
93
|
+
}));
|
|
94
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { FontStyle } from "../utils/pdf-object-manager.js";
|
|
2
|
+
import type { FontMetrics } from "../utils/font-metrics.js";
|
|
3
|
+
/**
|
|
4
|
+
* Font FALLBACK: picking, per code point, the first family in a stack that can draw it.
|
|
5
|
+
*
|
|
6
|
+
* `Text({ font: ["Inter", "NotoSansCJK"] })` should just work for mixed-script text. Without this a
|
|
7
|
+
* code point the first family lacks comes out as `.notdef` - the empty box.
|
|
8
|
+
*
|
|
9
|
+
* The output is a list of pieces, each in ONE family, which is exactly the shape a styled `span`
|
|
10
|
+
* already has. So the fallback reuses the whole segment machinery - breaking, bidi, shaping and
|
|
11
|
+
* drawing all handle per-piece fonts already - instead of adding a second one beside it.
|
|
12
|
+
*/
|
|
13
|
+
/** A stretch of text that one family can draw. */
|
|
14
|
+
export interface FontRun {
|
|
15
|
+
text: string;
|
|
16
|
+
fontFamily: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Split `text` into runs by family. Returns `undefined` when there is nothing to split - no stack, or
|
|
20
|
+
* the first family draws everything - so a document that never asks for a fallback keeps its old path.
|
|
21
|
+
*
|
|
22
|
+
* A code point that NO family in the stack can draw stays with the first one, which then draws its
|
|
23
|
+
* `.notdef`. That is what a browser does too: the fallback chain ends, and the missing glyph shows.
|
|
24
|
+
*/
|
|
25
|
+
export declare function splitByFont(text: string, fontFamily: string, fallback: readonly string[], fontStyle: FontStyle, metrics: FontMetrics): FontRun[] | undefined;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Split `text` into runs by family. Returns `undefined` when there is nothing to split - no stack, or
|
|
3
|
+
* the first family draws everything - so a document that never asks for a fallback keeps its old path.
|
|
4
|
+
*
|
|
5
|
+
* A code point that NO family in the stack can draw stays with the first one, which then draws its
|
|
6
|
+
* `.notdef`. That is what a browser does too: the fallback chain ends, and the missing glyph shows.
|
|
7
|
+
*/
|
|
8
|
+
export function splitByFont(text, fontFamily, fallback, fontStyle, metrics) {
|
|
9
|
+
if (fallback.length === 0)
|
|
10
|
+
return undefined;
|
|
11
|
+
const familyFor = (codePoint) => {
|
|
12
|
+
if (metrics.hasGlyph(codePoint, fontFamily, fontStyle))
|
|
13
|
+
return fontFamily;
|
|
14
|
+
for (const family of fallback) {
|
|
15
|
+
if (metrics.hasGlyph(codePoint, family, fontStyle))
|
|
16
|
+
return family;
|
|
17
|
+
}
|
|
18
|
+
return fontFamily; // nobody has it - the first family shows the missing glyph
|
|
19
|
+
};
|
|
20
|
+
const runs = [];
|
|
21
|
+
let current = "";
|
|
22
|
+
let currentFamily = fontFamily;
|
|
23
|
+
for (const ch of text) {
|
|
24
|
+
const family = familyFor(ch.codePointAt(0));
|
|
25
|
+
if (current !== "" && family !== currentFamily) {
|
|
26
|
+
runs.push({ text: current, fontFamily: currentFamily });
|
|
27
|
+
current = "";
|
|
28
|
+
}
|
|
29
|
+
currentFamily = family;
|
|
30
|
+
current += ch;
|
|
31
|
+
}
|
|
32
|
+
if (current !== "")
|
|
33
|
+
runs.push({ text: current, fontFamily: currentFamily });
|
|
34
|
+
// Nothing substituted - no runs at all (empty text), or one in the family we started with. Say so,
|
|
35
|
+
// and the caller keeps its cheaper path instead of being handed an empty list to draw.
|
|
36
|
+
const unchanged = runs.length === 0 || (runs.length === 1 && runs[0].fontFamily === fontFamily);
|
|
37
|
+
return unchanged ? undefined : runs;
|
|
38
|
+
}
|