@jasy/pdf 1.0.0-beta.4 → 1.0.0-beta.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 +3 -2
- package/dist/api/color.d.ts +1 -24
- package/dist/api/color.js +2 -278
- package/dist/api/content.d.ts +7 -2
- package/dist/api/content.js +17 -7
- package/dist/api/descriptor.js +30 -3
- package/dist/api/flow.d.ts +55 -0
- package/dist/api/flow.js +71 -0
- package/dist/api/gradient.d.ts +1 -53
- package/dist/api/gradient.js +2 -97
- package/dist/api/index.d.ts +2 -0
- package/dist/api/index.js +2 -0
- package/dist/api/layout.js +4 -3
- package/dist/api/structure.d.ts +1 -1
- package/dist/api/table.js +3 -1
- package/dist/api/text.d.ts +8 -8
- package/dist/api/text.js +6 -0
- package/dist/canvas/painter.d.ts +2 -2
- package/dist/canvas/painter.js +2 -2
- package/dist/common/color-input.d.ts +24 -0
- package/dist/common/color-input.js +278 -0
- package/dist/common/diagnostics.d.ts +9 -1
- package/dist/common/gradient.d.ts +53 -0
- package/dist/common/gradient.js +97 -0
- package/dist/elements/container-element.d.ts +3 -0
- package/dist/elements/container-element.js +17 -5
- package/dist/elements/image-element.d.ts +37 -26
- package/dist/elements/image-element.js +88 -50
- package/dist/elements/index.d.ts +3 -0
- package/dist/elements/index.js +3 -0
- package/dist/elements/layout/column-break-element.d.ts +17 -0
- package/dist/elements/layout/column-break-element.js +26 -0
- package/dist/elements/layout/deferred-element.d.ts +9 -1
- package/dist/elements/layout/deferred-element.js +18 -3
- package/dist/elements/layout/expanded-element.d.ts +4 -0
- package/dist/elements/layout/expanded-element.js +14 -1
- package/dist/elements/layout/flow-element.d.ts +109 -0
- package/dist/elements/layout/flow-element.js +317 -0
- package/dist/elements/layout/keep-together-element.js +14 -2
- package/dist/elements/layout/padding-element.d.ts +3 -0
- package/dist/elements/layout/padding-element.js +9 -2
- package/dist/elements/layout/page-builder-element.js +2 -2
- package/dist/elements/layout/positioned-element.d.ts +9 -2
- package/dist/elements/layout/positioned-element.js +6 -2
- package/dist/elements/layout/proxy-element.d.ts +4 -0
- package/dist/elements/layout/proxy-element.js +12 -0
- package/dist/elements/layout/region-element.d.ts +53 -0
- package/dist/elements/layout/region-element.js +109 -0
- package/dist/elements/layout/repeating-header-element.d.ts +3 -0
- package/dist/elements/layout/repeating-header-element.js +16 -1
- package/dist/elements/layout/rotated-element.d.ts +2 -0
- package/dist/elements/layout/rotated-element.js +8 -0
- package/dist/elements/pdf-element.d.ts +32 -3
- package/dist/elements/pdf-element.js +50 -1
- package/dist/elements/rectangle-element.d.ts +12 -6
- package/dist/elements/rectangle-element.js +31 -12
- package/dist/elements/row-element.d.ts +6 -12
- package/dist/elements/row-element.js +34 -4
- package/dist/elements/svg-element.js +4 -10
- package/dist/elements/text-element.d.ts +17 -1
- package/dist/elements/text-element.js +63 -40
- package/dist/ir/display-list.d.ts +6 -1
- package/dist/layout/box-constraints.d.ts +12 -0
- package/dist/layout/box-constraints.js +17 -0
- package/dist/layout/collect-images.js +8 -4
- package/dist/layout/flex-item.d.ts +2 -0
- package/dist/layout/flex-item.js +14 -2
- package/dist/layout/fragmentation.d.ts +11 -5
- package/dist/layout/fragmentation.js +27 -7
- package/dist/layout/layout-cache.d.ts +8 -8
- package/dist/layout/layout-cache.js +10 -7
- package/dist/platform/browser-fs.d.ts +1 -2
- package/dist/platform/browser-fs.js +3 -6
- package/dist/platform/node-fs.d.ts +0 -1
- package/dist/platform/node-fs.js +0 -4
- package/dist/renderer/column-break-renderer.d.ts +8 -0
- package/dist/renderer/column-break-renderer.js +15 -0
- package/dist/renderer/emoji-image.js +3 -1
- package/dist/renderer/flow-renderer.d.ts +16 -0
- package/dist/renderer/flow-renderer.js +21 -0
- package/dist/renderer/image-renderer.js +31 -54
- package/dist/renderer/index.d.ts +2 -0
- package/dist/renderer/index.js +2 -0
- package/dist/renderer/page-renderer.js +3 -4
- package/dist/renderer/pdf-backend.js +9 -2
- package/dist/renderer/pdf-document-renderer.js +2 -1
- package/dist/renderer/pdf-renderer.js +9 -0
- package/dist/renderer/positioned-renderer.d.ts +14 -2
- package/dist/renderer/positioned-renderer.js +40 -4
- package/dist/renderer/rectangle-renderer.d.ts +1 -1
- package/dist/renderer/rectangle-renderer.js +11 -6
- package/dist/renderer/rotated-renderer.js +3 -2
- package/dist/renderer/text-renderer.js +25 -28
- package/dist/svg/index.d.ts +1 -1
- package/dist/svg/index.js +26 -5
- package/dist/svg/style.js +1 -1
- package/dist/text/line-breaker.d.ts +23 -0
- package/dist/text/line-breaker.js +142 -65
- package/dist/text/line-metrics.d.ts +2 -0
- package/dist/text/line-metrics.js +5 -1
- package/dist/text/word-splitting.js +11 -5
- package/dist/utils/flex-layout.d.ts +3 -1
- package/dist/utils/flex-layout.js +141 -33
- package/dist/utils/image-helper.d.ts +59 -11
- package/dist/utils/image-helper.js +207 -80
- package/dist/utils/pdf-object-manager.d.ts +6 -3
- package/dist/utils/pdf-object-manager.js +31 -7
- package/dist/utils/renderer-registry.d.ts +6 -0
- package/dist/utils/renderer-registry.js +1 -1
- package/dist/utils/ttf-parser.js +3 -2
- package/package.json +9 -2
|
@@ -3,7 +3,7 @@ import { HorizontalAlignment } from "../elements/pdf-element.js";
|
|
|
3
3
|
import { VERTICAL_TEXT_SHIFT } from "../elements/text-element.js";
|
|
4
4
|
import { isEmojiCodePoint } from "../text/emoji-codepoints.js";
|
|
5
5
|
import { emojiImageNode } from "./emoji-image.js";
|
|
6
|
-
import { MAX_SPACE_SHRINK, breakSegmentsIntoLines, } from "../text/line-breaker.js";
|
|
6
|
+
import { MAX_SPACE_SHRINK, breakSegmentsIntoLines, lineGaps, runStyleOf, } from "../text/line-breaker.js";
|
|
7
7
|
import { lineBoxForSegmentLine } from "../text/line-metrics.js";
|
|
8
8
|
import { runAdvance } from "../text/advance.js";
|
|
9
9
|
import { visualRunsOf } from "../text/bidi.js";
|
|
@@ -21,12 +21,12 @@ function spokenText(pieces) {
|
|
|
21
21
|
}
|
|
22
22
|
export class TextRenderer {
|
|
23
23
|
static async render(textElement, objectManager, scope) {
|
|
24
|
-
const { x, y, width,
|
|
24
|
+
const { x, y, width, color, spans, textAlignment, maxLines, overflow, lineHeight, underline, strikethrough, skipInk, direction, textIndent, role, breaking, } = textElement.getProps();
|
|
25
25
|
// Component -> display list: the lines come from the one breaker, positioning stays here; the
|
|
26
26
|
// backend turns each run into BT/Tf/Td/Tj/ET.
|
|
27
|
-
const { runs, links, decorations, linkGroups } = TextRenderer._buildRuns(spans, breaking,
|
|
27
|
+
const { runs, links, decorations, linkGroups } = TextRenderer._buildRuns(spans, breaking, objectManager, width ?? Number.NaN, textAlignment, color,
|
|
28
28
|
// Every run, decoration and link rect derives from this one absolute origin.
|
|
29
|
-
scope.origin.x + (x ?? 0), scope.origin.y + (y ?? 0), maxLines, overflow, lineHeight, underline, strikethrough, skipInk,
|
|
29
|
+
scope.origin.x + (x ?? 0), scope.origin.y + (y ?? 0), maxLines, overflow, lineHeight, underline, strikethrough, skipInk, direction, textIndent);
|
|
30
30
|
// Accessible tagging: this whole text block is one structure element (a paragraph P, or a heading
|
|
31
31
|
// from `role`); each run's text lands under it. Emoji sub-nodes produced below stay untagged (drawn
|
|
32
32
|
// as artifacts). Keyed by structId so a block split across pages stays one element.
|
|
@@ -233,7 +233,7 @@ export class TextRenderer {
|
|
|
233
233
|
// only the output form changed from PDF strings to `TextRun`s.
|
|
234
234
|
static _buildRuns(content,
|
|
235
235
|
// How the text is broken - the element's own settings, the ones it was measured with.
|
|
236
|
-
breaking,
|
|
236
|
+
breaking, objectManager, maxWidth, textAlignment, color, initialX, yPosition, maxLines, overflow, lineHeight, underline = false, strikethrough = false, skipInk = false, direction = "ltr", textIndent = 0) {
|
|
237
237
|
const runs = [];
|
|
238
238
|
// /Link annotation rects for any `href` spans, collected as we place segments. Empty for the
|
|
239
239
|
// common (no-href) case, so plain text keeps producing exactly the runs it did before.
|
|
@@ -269,20 +269,18 @@ export class TextRenderer {
|
|
|
269
269
|
* Spaces are stretched by moving the pen, not by the `Tw` operator: `Tw` applies only to the
|
|
270
270
|
* single byte 32, so an embedded (Identity-H) font would ignore it entirely.
|
|
271
271
|
*/
|
|
272
|
-
const justifyExtra = (
|
|
272
|
+
const justifyExtra = (segments, naturalWidth, isLast, indent = 0) => {
|
|
273
273
|
if (align !== HorizontalAlignment.justify)
|
|
274
274
|
return 0;
|
|
275
|
-
const gaps
|
|
275
|
+
const { gaps, width: gapWidth } = lineGaps(segments, breaking, objectManager);
|
|
276
276
|
if (gaps === 0)
|
|
277
277
|
return 0;
|
|
278
278
|
const slack = maxWidth - indent - naturalWidth;
|
|
279
279
|
// Negative slack is real now: the breaker keeps a word when the line can be SQUEEZED to hold
|
|
280
280
|
// it, so justification has to deliver that squeeze. The floor is the same limit the breaker
|
|
281
|
-
// used, which is what stops a line it never promised from being
|
|
282
|
-
//
|
|
283
|
-
const
|
|
284
|
-
wordSpacing;
|
|
285
|
-
const extra = Math.max(slack / gaps, -spaceWidth * MAX_SPACE_SHRINK);
|
|
281
|
+
// used - the gaps' widths, summed - which is what stops a line it never promised from being
|
|
282
|
+
// crushed.
|
|
283
|
+
const extra = Math.max(slack / gaps, (-gapWidth * MAX_SPACE_SHRINK) / gaps);
|
|
286
284
|
// The last line is never STRETCHED - the rule print and CSS follow. It may still be SQUEEZED:
|
|
287
285
|
// the breaker was allowed to pack it that way, and drawing it loose would push it out of its box.
|
|
288
286
|
return isLast ? Math.min(0, extra) : extra;
|
|
@@ -323,7 +321,7 @@ export class TextRenderer {
|
|
|
323
321
|
};
|
|
324
322
|
// The decoration strokes for one drawn run, at the geometry ITS OWN font declares. A mixed-size
|
|
325
323
|
// line therefore gets a thicker line under the bigger span, which is what a browser does too.
|
|
326
|
-
const decorate = (text, runX, runWidth, baselineY, family, style, size, runColor, wantsUnderline, wantsStrikethrough, runLetterSpacing) => {
|
|
324
|
+
const decorate = (text, runX, runWidth, baselineY, family, style, size, runColor, wantsUnderline, wantsStrikethrough, runLetterSpacing, runSkipInk) => {
|
|
327
325
|
if ((!wantsUnderline && !wantsStrikethrough) || runWidth <= 0)
|
|
328
326
|
return;
|
|
329
327
|
const metrics = objectManager.getFontDecoration(family, style);
|
|
@@ -340,7 +338,7 @@ export class TextRenderer {
|
|
|
340
338
|
};
|
|
341
339
|
if (wantsUnderline) {
|
|
342
340
|
const stroke = underlineStroke(metrics, size, baselineY);
|
|
343
|
-
for (const [from, to] of underlineSegments(text, runWidth, baselineY, size, family, style, stroke, runLetterSpacing)) {
|
|
341
|
+
for (const [from, to] of underlineSegments(text, runWidth, baselineY, size, family, style, stroke, runLetterSpacing, runSkipInk)) {
|
|
344
342
|
push(from, to, stroke);
|
|
345
343
|
}
|
|
346
344
|
}
|
|
@@ -352,8 +350,8 @@ export class TextRenderer {
|
|
|
352
350
|
};
|
|
353
351
|
// The x-ranges the underline actually draws. Without `skipInk` that is the whole run; with it,
|
|
354
352
|
// the descenders are cut out of it (which needs the real outlines, i.e. an embedded font).
|
|
355
|
-
const underlineSegments = (text, runWidth, baselineY, size, family, style, stroke, runLetterSpacing) => {
|
|
356
|
-
if (!
|
|
353
|
+
const underlineSegments = (text, runWidth, baselineY, size, family, style, stroke, runLetterSpacing, runSkipInk) => {
|
|
354
|
+
if (!runSkipInk)
|
|
357
355
|
return [[0, runWidth]];
|
|
358
356
|
if (!objectManager.isCustomFont(family, style)) {
|
|
359
357
|
throw new Error(`skipInk needs an embedded font, but "${family}" is a standard-14 font whose glyph outlines ` +
|
|
@@ -372,10 +370,10 @@ export class TextRenderer {
|
|
|
372
370
|
* GLYPHS for an rtl run. A font that does not shape keeps the old path exactly: draw the visual
|
|
373
371
|
* text, no glyph list, byte-identical output.
|
|
374
372
|
*/
|
|
375
|
-
const shapedPiece = (part, family, style) => {
|
|
373
|
+
const shapedPiece = (part, family, style, runLigatures) => {
|
|
376
374
|
// The run's OWN setting, not the default: measuring asked with it (`runAdvance` -> RunFont), and
|
|
377
375
|
// drawing has to ask the same question or the line is measured one width and drawn another.
|
|
378
|
-
const shaped = objectManager.shapeText(part.logical, family, style,
|
|
376
|
+
const shaped = objectManager.shapeText(part.logical, family, style, runLigatures ? undefined : []);
|
|
379
377
|
if (!shaped)
|
|
380
378
|
return { text: part.text };
|
|
381
379
|
const ordered = part.rtl ? [...shaped].reverse() : shaped;
|
|
@@ -391,8 +389,7 @@ export class TextRenderer {
|
|
|
391
389
|
let x = initialX + indent + alignmentOffset(line.width, indent);
|
|
392
390
|
// Word-spacing is a per-gap advance just like the justification slack, so it rides the same
|
|
393
391
|
// mechanism: `Tw` would reach only the single byte 32 and miss every embedded font.
|
|
394
|
-
const
|
|
395
|
-
justifyExtra(line.segments.map((seg) => seg.content).join(""), line.width, isLast, indent);
|
|
392
|
+
const justify = justifyExtra(line.segments, line.width, isLast, indent);
|
|
396
393
|
// Across span boundaries, so a Hebrew span is ordered against its neighbours rather than inside
|
|
397
394
|
// itself. Each run keeps the segment it came from, and with it that span's style.
|
|
398
395
|
const ordered = visualRunsOf(line.segments.map((segment) => ({ text: segment.content, source: segment })), direction);
|
|
@@ -401,11 +398,11 @@ export class TextRenderer {
|
|
|
401
398
|
if (part.text === "")
|
|
402
399
|
return;
|
|
403
400
|
const segment = part.source;
|
|
404
|
-
const
|
|
405
|
-
const size =
|
|
406
|
-
const
|
|
407
|
-
const
|
|
408
|
-
const piece = shapedPiece(part, family, style);
|
|
401
|
+
const run = runStyleOf(segment, breaking);
|
|
402
|
+
const { fontFamily: family, fontSize: size, fontStyle: style } = run.font;
|
|
403
|
+
const segLetterSpacing = run.letterSpacing;
|
|
404
|
+
const runLigatures = run.font.ligatures ?? true;
|
|
405
|
+
const piece = shapedPiece(part, family, style, runLigatures);
|
|
409
406
|
const runText = piece.text;
|
|
410
407
|
const runColor = segment.fontColor || color;
|
|
411
408
|
// `vertical-align`: this run alone leaves the line's baseline. Its own decoration and link box
|
|
@@ -413,7 +410,7 @@ export class TextRenderer {
|
|
|
413
410
|
const shift = VERTICAL_TEXT_SHIFT[segment.verticalAlign ?? "baseline"] * size;
|
|
414
411
|
const runY = lineY - shift;
|
|
415
412
|
const firstRun = runs.length;
|
|
416
|
-
const drawn = emitPiece(piece, (word) => shapedPiece({ text: word, logical: word, rtl: part.rtl }, family, style), part.rtl, x,
|
|
413
|
+
const drawn = emitPiece(piece, (word) => shapedPiece({ text: word, logical: word, rtl: part.rtl }, family, style, runLigatures), part.rtl, x, run.wordSpacing + justify, {
|
|
417
414
|
y: runY,
|
|
418
415
|
fontFamily: family,
|
|
419
416
|
fontStyle: style,
|
|
@@ -422,8 +419,8 @@ export class TextRenderer {
|
|
|
422
419
|
...(segLetterSpacing ? { letterSpacing: segLetterSpacing } : {}),
|
|
423
420
|
},
|
|
424
421
|
// `ligatures` is part of the font: the advances must be the glyphs that are drawn.
|
|
425
|
-
|
|
426
|
-
decorate(runText, x, drawn, runY, family, style, size, runColor, segment.underline ?? underline, segment.strikethrough ?? strikethrough, segLetterSpacing);
|
|
422
|
+
run.font, segLetterSpacing);
|
|
423
|
+
decorate(runText, x, drawn, runY, family, style, size, runColor, segment.underline ?? underline, segment.strikethrough ?? strikethrough, segLetterSpacing, segment.skipInk ?? skipInk);
|
|
427
424
|
// An href/to span becomes a /Link annotation over exactly this run's glyph box: from its own
|
|
428
425
|
// ascent above the baseline down to its own descender. A span wrapped across lines yields
|
|
429
426
|
// one rect per line.
|
package/dist/svg/index.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export interface SvgTarget {
|
|
|
7
7
|
width: number;
|
|
8
8
|
height: number;
|
|
9
9
|
}
|
|
10
|
-
/** The intrinsic size of an SVG: its `width`/`height`, else its `viewBox
|
|
10
|
+
/** The intrinsic size of an SVG in points: its `width`/`height`, else its `viewBox`, else 300 x 150 px. */
|
|
11
11
|
export interface SvgSize {
|
|
12
12
|
width: number;
|
|
13
13
|
height: number;
|
package/dist/svg/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import { parseXml } from "./xml.js";
|
|
|
2
2
|
import { SvgParseError, SvgUnsupportedError } from "./errors.js";
|
|
3
3
|
import { IDENTITY, isIdentity, multiply, parseTransform } from "./transform.js";
|
|
4
4
|
import { shapeOf } from "./shapes.js";
|
|
5
|
+
import { length } from "./units.js";
|
|
5
6
|
import { transformCommands } from "../vector/path.js";
|
|
6
7
|
import { declarationsFor, parseStylesheet } from "./css.js";
|
|
7
8
|
import { ROOT_STYLE, isPaintRef, cascadedReader, paintAlpha, resolveStyle, } from "./style.js";
|
|
@@ -72,6 +73,21 @@ function stylesheetOf(node, out = [], depth = 0) {
|
|
|
72
73
|
const elements = (node) => node.type === "element"
|
|
73
74
|
? node.children.filter((child) => child.type === "element")
|
|
74
75
|
: [];
|
|
76
|
+
/** SVG's user unit is the CSS px: 1/96 inch, so 0.75pt - the resolution an image without one gets too. */
|
|
77
|
+
const PT_PER_USER_UNIT = 72 / 96;
|
|
78
|
+
/** The size of a replaced element that declares none (CSS). */
|
|
79
|
+
const DEFAULT_VIEWPORT = { width: 300, height: 150 };
|
|
80
|
+
/**
|
|
81
|
+
* A root `width`/`height` in user units, or `undefined` when it gives none. A percentage sizes against a
|
|
82
|
+
* container, which a drawing on its own does not have - a browser treats it as not given, and so do we.
|
|
83
|
+
*/
|
|
84
|
+
function rootLength(value, name) {
|
|
85
|
+
const text = value?.trim() ?? "";
|
|
86
|
+
if (text === "" || text.endsWith("%"))
|
|
87
|
+
return undefined;
|
|
88
|
+
const n = length(text, `the root <svg> ${name}`, NaN);
|
|
89
|
+
return n > 0 ? n : undefined;
|
|
90
|
+
}
|
|
75
91
|
/**
|
|
76
92
|
* Reads the root `<svg>` and returns the matrix that maps its user space onto `target`, plus the
|
|
77
93
|
* intrinsic size. The `viewBox` scales UNIFORMLY and centres - SVG's default `preserveAspectRatio`
|
|
@@ -85,11 +101,16 @@ function rootMapping(root, target) {
|
|
|
85
101
|
.split(/[\s,]+/)
|
|
86
102
|
.map(Number);
|
|
87
103
|
const hasBox = box.length === 4 && box.every((n) => Number.isFinite(n)) && box[2] > 0 && box[3] > 0;
|
|
88
|
-
|
|
89
|
-
|
|
104
|
+
// The viewport in user units. One side given and a viewBox: the other follows its ratio, as in a
|
|
105
|
+
// browser. Nothing to go on: CSS's 300 x 150 for a replaced element without a size.
|
|
106
|
+
const w = rootLength(attributes["width"], "width");
|
|
107
|
+
const h = rootLength(attributes["height"], "height");
|
|
108
|
+
const ratio = hasBox ? box[2] / box[3] : undefined;
|
|
109
|
+
const userWidth = w ?? (h !== undefined && ratio ? h * ratio : hasBox ? box[2] : DEFAULT_VIEWPORT.width);
|
|
110
|
+
const userHeight = h ?? (w !== undefined && ratio ? w / ratio : hasBox ? box[3] : DEFAULT_VIEWPORT.height);
|
|
90
111
|
const size = {
|
|
91
|
-
width:
|
|
92
|
-
height:
|
|
112
|
+
width: userWidth * PT_PER_USER_UNIT,
|
|
113
|
+
height: userHeight * PT_PER_USER_UNIT,
|
|
93
114
|
};
|
|
94
115
|
// We always scale uniformly and centre, which is SVG's default "xMidYMid meet". Any other value
|
|
95
116
|
// moves or crops the drawing, so it is named rather than quietly ignored.
|
|
@@ -97,7 +118,7 @@ function rootMapping(root, target) {
|
|
|
97
118
|
if (par !== "" && !/^xMidYMid(\s+meet)?$/.test(par)) {
|
|
98
119
|
throw new SvgUnsupportedError(`preserveAspectRatio="${par}"`, 'Only the default "xMidYMid meet" is honoured. Size the drawing through width/height instead.');
|
|
99
120
|
}
|
|
100
|
-
const [vx, vy, vw, vh] = hasBox ? box : [0, 0,
|
|
121
|
+
const [vx, vy, vw, vh] = hasBox ? box : [0, 0, userWidth, userHeight];
|
|
101
122
|
const scale = Math.min(target.width / vw, target.height / vh);
|
|
102
123
|
const dx = target.x + (target.width - vw * scale) / 2;
|
|
103
124
|
const dy = target.y + (target.height - vh * scale) / 2;
|
package/dist/svg/style.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Color } from "../common/color.js";
|
|
2
|
-
import { toColor } from "../
|
|
2
|
+
import { toColor } from "../common/color-input.js";
|
|
3
3
|
import { SvgParseError, SvgUnsupportedError } from "./errors.js";
|
|
4
4
|
import { length, ratio } from "./units.js";
|
|
5
5
|
export const isPaintRef = (paint) => paint !== undefined && !(paint instanceof Color);
|
|
@@ -42,6 +42,18 @@ export interface RunDefaults {
|
|
|
42
42
|
}
|
|
43
43
|
/** Everything the breaker reads besides the spans: what they inherit, and how lines end. */
|
|
44
44
|
export type SegmentDefaults = RunDefaults & LineOptions;
|
|
45
|
+
/** How one span is measured, broken and drawn. */
|
|
46
|
+
export interface RunStyle {
|
|
47
|
+
font: RunFont;
|
|
48
|
+
letterSpacing: number;
|
|
49
|
+
wordSpacing: number;
|
|
50
|
+
splitting: WordSplitting;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* A span's run style: what it sets itself, else what the paragraph sets. The ONE place a span inherits
|
|
54
|
+
* it, so breaking, drawing and the element's own sizing ask the same question.
|
|
55
|
+
*/
|
|
56
|
+
export declare function runStyleOf(seg: TextSegment, defaults: SegmentDefaults): RunStyle;
|
|
45
57
|
/** What happens to text beyond `maxLines`: `"clip"` drops it, `"ellipsis"` ends the last kept line
|
|
46
58
|
* with an ellipsis. Mirrors Flutter's `TextOverflow`. */
|
|
47
59
|
export type TextOverflow = "clip" | "ellipsis";
|
|
@@ -53,7 +65,18 @@ export interface SegmentLine {
|
|
|
53
65
|
/** Where the line begins in the source: the span, and the offset in that span's content. */
|
|
54
66
|
start: SegmentPosition;
|
|
55
67
|
end: LineEnd;
|
|
68
|
+
/** How far the line, as drawn, runs past the room it was broken in: above 0 only for a piece that
|
|
69
|
+
* could not break. */
|
|
70
|
+
overrun: number;
|
|
56
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* A line's spaces, each as wide as its own span makes it - what justification spreads the slack over,
|
|
74
|
+
* and squeezes by at most `shrink` of their width.
|
|
75
|
+
*/
|
|
76
|
+
export declare function lineGaps(segments: TextSegment[], defaults: SegmentDefaults, metrics: FontMetrics): {
|
|
77
|
+
gaps: number;
|
|
78
|
+
width: number;
|
|
79
|
+
};
|
|
57
80
|
export interface SegmentPosition {
|
|
58
81
|
segment: number;
|
|
59
82
|
offset: number;
|
|
@@ -14,11 +14,54 @@ import { headAt, hyphenBreaks, mayBreak, NO_BREAKS } from "./hyphen-breaks.js";
|
|
|
14
14
|
* Only justified text uses it: under any other alignment a squeezed line would simply overflow.
|
|
15
15
|
*/
|
|
16
16
|
export const MAX_SPACE_SHRINK = 1 / 4;
|
|
17
|
+
/**
|
|
18
|
+
* A span's run style: what it sets itself, else what the paragraph sets. The ONE place a span inherits
|
|
19
|
+
* it, so breaking, drawing and the element's own sizing ask the same question.
|
|
20
|
+
*/
|
|
21
|
+
export function runStyleOf(seg, defaults) {
|
|
22
|
+
return {
|
|
23
|
+
font: {
|
|
24
|
+
fontFamily: seg.fontFamily || defaults.fontFamily,
|
|
25
|
+
fontSize: seg.fontSize || defaults.fontSize,
|
|
26
|
+
fontStyle: seg.fontStyle || defaults.fontStyle,
|
|
27
|
+
ligatures: seg.ligatures ?? defaults.ligatures,
|
|
28
|
+
},
|
|
29
|
+
letterSpacing: seg.letterSpacing ?? defaults.letterSpacing ?? 0,
|
|
30
|
+
wordSpacing: seg.wordSpacing ?? defaults.wordSpacing ?? 0,
|
|
31
|
+
splitting: {
|
|
32
|
+
breakWord: seg.breakWord ?? defaults.splitting?.breakWord,
|
|
33
|
+
hyphenate: seg.hyphenate ?? defaults.splitting?.hyphenate,
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
17
37
|
/** Three ASCII dots, NOT the "…" glyph (U+2026): plain dots encode in every font - standard-14
|
|
18
38
|
* (WinAnsi) and any embedded TTF - whereas U+2026 needs a glyph the font may not carry. */
|
|
19
39
|
const ELLIPSIS = "...";
|
|
20
40
|
/** The spaces that end a line: they hang past its edge, neither drawn nor in its width. */
|
|
21
41
|
const TRAILING_SPACES = / +$/;
|
|
42
|
+
/**
|
|
43
|
+
* A line's spaces, each as wide as its own span makes it - what justification spreads the slack over,
|
|
44
|
+
* and squeezes by at most `shrink` of their width.
|
|
45
|
+
*/
|
|
46
|
+
export function lineGaps(segments, defaults, metrics) {
|
|
47
|
+
let gaps = 0;
|
|
48
|
+
let width = 0;
|
|
49
|
+
for (const seg of segments) {
|
|
50
|
+
const spaces = [...seg.content].filter((c) => c === " ").length;
|
|
51
|
+
if (spaces === 0)
|
|
52
|
+
continue;
|
|
53
|
+
const run = runStyleOf(seg, defaults);
|
|
54
|
+
gaps += spaces;
|
|
55
|
+
width += spaces * (runAdvance(metrics, " ", run.font, run.letterSpacing) + run.wordSpacing);
|
|
56
|
+
}
|
|
57
|
+
return { gaps, width };
|
|
58
|
+
}
|
|
59
|
+
/** How far `line` runs past `room` once a justified line has been squeezed as far as it may be. */
|
|
60
|
+
function overrunOf(line, room, defaults, metrics) {
|
|
61
|
+
const shrink = defaults.shrink ?? 0;
|
|
62
|
+
const squeeze = shrink > 0 ? lineGaps(line.segments, defaults, metrics).width * shrink : 0;
|
|
63
|
+
return line.width - squeeze - room;
|
|
64
|
+
}
|
|
22
65
|
/**
|
|
23
66
|
* The width of `text` set as ONE line, accumulated in EXACTLY the order the breaker builds a line: the
|
|
24
67
|
* first word, then `+ (space + word)` each time. The order matters because floating-point
|
|
@@ -62,9 +105,11 @@ export function breakStringIntoLines(text, fontFamily, fontSize, fontStyle, maxW
|
|
|
62
105
|
*/
|
|
63
106
|
export function breakSegmentsIntoLines(segments, defaults, maxWidth, metrics, maxLines, overflow) {
|
|
64
107
|
const lines = [];
|
|
65
|
-
const { indent = 0,
|
|
108
|
+
const { indent = 0, shrink = 0 } = defaults;
|
|
66
109
|
let width = 0;
|
|
67
|
-
|
|
110
|
+
// The spaces on the line, summed - what a justified line can squeeze. Summed, not counted: each space
|
|
111
|
+
// is as wide as its own span makes it.
|
|
112
|
+
let gapWidth = 0;
|
|
68
113
|
let lineSegments = [];
|
|
69
114
|
let combined = "";
|
|
70
115
|
let lineStart = { segment: 0, offset: 0 }; // where the current line begins
|
|
@@ -94,13 +139,8 @@ export function breakSegmentsIntoLines(segments, defaults, maxWidth, metrics, ma
|
|
|
94
139
|
if (glued &&
|
|
95
140
|
last?.content.endsWith(SOFT_HYPHEN) &&
|
|
96
141
|
!withoutSoftHyphens(last.content).endsWith("-")) {
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
fontSize: last.fontSize || defaults.fontSize,
|
|
100
|
-
fontStyle: last.fontStyle || defaults.fontStyle,
|
|
101
|
-
ligatures: defaults.ligatures,
|
|
102
|
-
};
|
|
103
|
-
width += runAdvance(metrics, HYPHEN, font, last.letterSpacing ?? defaults.letterSpacing ?? 0);
|
|
142
|
+
const run = runStyleOf(last, defaults);
|
|
143
|
+
width += runAdvance(metrics, HYPHEN, run.font, run.letterSpacing);
|
|
104
144
|
segs = segs.map((seg, i) => i === lastIndex ? { ...seg, content: withoutSoftHyphens(seg.content) + HYPHEN } : seg);
|
|
105
145
|
ends = "hyphen";
|
|
106
146
|
}
|
|
@@ -117,11 +157,14 @@ export function breakSegmentsIntoLines(segments, defaults, maxWidth, metrics, ma
|
|
|
117
157
|
? { ...seg, content: withoutSoftHyphens(seg.content) }
|
|
118
158
|
: seg);
|
|
119
159
|
const drawn = stripped.filter((seg) => seg.content !== "");
|
|
160
|
+
const kept = drawn.length > 0 ? drawn : stripped.slice(0, 1);
|
|
161
|
+
const room = lines.length === 0 ? maxWidth - indent : maxWidth;
|
|
120
162
|
lines.push({
|
|
121
|
-
segments:
|
|
163
|
+
segments: kept,
|
|
122
164
|
width,
|
|
123
165
|
start: lineStart,
|
|
124
166
|
end: ends,
|
|
167
|
+
overrun: overrunOf({ segments: kept, width }, room, defaults, metrics),
|
|
125
168
|
});
|
|
126
169
|
};
|
|
127
170
|
// Where each piece of the line begins in the source: a word that runs across a span boundary can move
|
|
@@ -138,13 +181,7 @@ export function breakSegmentsIntoLines(segments, defaults, maxWidth, metrics, ma
|
|
|
138
181
|
let ink = 0;
|
|
139
182
|
let open = false;
|
|
140
183
|
for (const piece of pieces) {
|
|
141
|
-
const font =
|
|
142
|
-
fontFamily: piece.fontFamily || defaults.fontFamily,
|
|
143
|
-
fontSize: piece.fontSize || defaults.fontSize,
|
|
144
|
-
fontStyle: piece.fontStyle || defaults.fontStyle,
|
|
145
|
-
ligatures: defaults.ligatures,
|
|
146
|
-
};
|
|
147
|
-
const spacing = piece.letterSpacing ?? defaults.letterSpacing ?? 0;
|
|
184
|
+
const { font, letterSpacing: spacing, wordSpacing } = runStyleOf(piece, defaults);
|
|
148
185
|
const space = runAdvance(metrics, " ", font, spacing) + wordSpacing;
|
|
149
186
|
piece.content.split(" ").forEach((word, i) => {
|
|
150
187
|
total = total + ((open && i > 0 ? space : 0) + runAdvance(metrics, word, font, spacing));
|
|
@@ -195,16 +232,8 @@ export function breakSegmentsIntoLines(segments, defaults, maxWidth, metrics, ma
|
|
|
195
232
|
return null;
|
|
196
233
|
};
|
|
197
234
|
segments.forEach((segment, segmentIndex) => {
|
|
198
|
-
const
|
|
199
|
-
const
|
|
200
|
-
const style = segment.fontStyle || defaults.fontStyle;
|
|
201
|
-
const letterSpacing = segment.letterSpacing ?? defaults.letterSpacing ?? 0;
|
|
202
|
-
const font = {
|
|
203
|
-
fontFamily: family,
|
|
204
|
-
fontSize: size,
|
|
205
|
-
fontStyle: style,
|
|
206
|
-
ligatures: defaults.ligatures,
|
|
207
|
-
};
|
|
235
|
+
const { font, letterSpacing, wordSpacing, splitting } = runStyleOf(segment, defaults);
|
|
236
|
+
const family = font.fontFamily;
|
|
208
237
|
const spaceWidth = runAdvance(metrics, " ", font, letterSpacing) + wordSpacing;
|
|
209
238
|
const at = (offset) => ({ segment: segmentIndex, offset });
|
|
210
239
|
// A `\n` is a HARD break: close the line where it sits, whatever else was already on it, and start
|
|
@@ -215,7 +244,7 @@ export function breakSegmentsIntoLines(segments, defaults, maxWidth, metrics, ma
|
|
|
215
244
|
if (paragraphIndex > 0) {
|
|
216
245
|
close(lineSegments, ink, "hard");
|
|
217
246
|
width = 0;
|
|
218
|
-
|
|
247
|
+
gapWidth = 0;
|
|
219
248
|
open = false;
|
|
220
249
|
inked = false;
|
|
221
250
|
ink = 0;
|
|
@@ -242,10 +271,63 @@ export function breakSegmentsIntoLines(segments, defaults, maxWidth, metrics, ma
|
|
|
242
271
|
lineStart = glued.from;
|
|
243
272
|
width = carried.width;
|
|
244
273
|
ink = carried.ink;
|
|
245
|
-
|
|
274
|
+
gapWidth = 0;
|
|
246
275
|
open = true;
|
|
247
276
|
inked = true;
|
|
248
277
|
};
|
|
278
|
+
/**
|
|
279
|
+
* No regular break before a word glued to the line, and it does not fit: Chrome's `overflow-wrap`
|
|
280
|
+
* emergency break, where a break after a character is allowed by THAT character's own setting.
|
|
281
|
+
* As much of the word as fits stays here if its span may split; else the line ends at the span
|
|
282
|
+
* boundary if the span before it may split; else one character is forced on if this span may
|
|
283
|
+
* split at all. `false`: none applies, and the word overflows on this line as before.
|
|
284
|
+
*/
|
|
285
|
+
const emergencyBreak = (word, wordAt, room) => {
|
|
286
|
+
let before;
|
|
287
|
+
for (let i = lineSegments.length - 1; i >= 0 && !before; i--)
|
|
288
|
+
if (lineSegments[i].content !== "")
|
|
289
|
+
before = lineSegments[i];
|
|
290
|
+
const beforeMaySplit = before !== undefined && runStyleOf(before, defaults).splitting.breakWord === true;
|
|
291
|
+
let head = null; // how much of the word's source stays on this line
|
|
292
|
+
if (splitting.breakWord) {
|
|
293
|
+
const split = splitLongWord(word, room - width, font, metrics, letterSpacing, {
|
|
294
|
+
breakWord: true,
|
|
295
|
+
});
|
|
296
|
+
// A glyph forced on past the edge is the last resort, below a break at the span boundary.
|
|
297
|
+
const fits = split && runAdvance(metrics, split[0].text, font, letterSpacing) <= room - width;
|
|
298
|
+
if (fits)
|
|
299
|
+
head = split[0].source;
|
|
300
|
+
}
|
|
301
|
+
if (head === null && beforeMaySplit) {
|
|
302
|
+
close(lineSegments, ink, "split");
|
|
303
|
+
startLine(wordAt);
|
|
304
|
+
placeWord(word, wordAt, false);
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
if (head === null && splitting.breakWord)
|
|
308
|
+
head = String.fromCodePoint(word.codePointAt(0)).length;
|
|
309
|
+
if (head === null)
|
|
310
|
+
return false;
|
|
311
|
+
combined += word.slice(0, head);
|
|
312
|
+
width += runAdvance(metrics, word.slice(0, head), font, letterSpacing);
|
|
313
|
+
ink = width;
|
|
314
|
+
lineSegments[lineSegments.length - 1].content = combined;
|
|
315
|
+
close(lineSegments, ink, "split");
|
|
316
|
+
startLine(wordAt + head);
|
|
317
|
+
placeWord(word.slice(head), wordAt + head, false);
|
|
318
|
+
return true;
|
|
319
|
+
};
|
|
320
|
+
/** An empty line, starting at source offset `startAt` in this segment. */
|
|
321
|
+
const startLine = (startAt) => {
|
|
322
|
+
lineSegments = [started({ ...segment, fontFamily: family, content: "" }, at(startAt))];
|
|
323
|
+
combined = "";
|
|
324
|
+
width = 0;
|
|
325
|
+
gapWidth = 0;
|
|
326
|
+
open = false;
|
|
327
|
+
inked = false;
|
|
328
|
+
ink = 0;
|
|
329
|
+
lineStart = at(startAt);
|
|
330
|
+
};
|
|
249
331
|
// One word, placed at source offset `wordAt`; `joined`: a space joins it to the word before.
|
|
250
332
|
const placeWord = (word, wordAt, joined) => {
|
|
251
333
|
const afterSpace = hung;
|
|
@@ -254,14 +336,7 @@ export function breakSegmentsIntoLines(segments, defaults, maxWidth, metrics, ma
|
|
|
254
336
|
// one. It must not slip in behind the space it was written after, however narrow it is.
|
|
255
337
|
if (afterSpace && inked && word !== "") {
|
|
256
338
|
close(lineSegments, ink, "soft");
|
|
257
|
-
|
|
258
|
-
combined = "";
|
|
259
|
-
width = 0;
|
|
260
|
-
gaps = 0;
|
|
261
|
-
open = false;
|
|
262
|
-
inked = false;
|
|
263
|
-
ink = 0;
|
|
264
|
-
lineStart = at(wordAt);
|
|
339
|
+
startLine(wordAt);
|
|
265
340
|
}
|
|
266
341
|
// Only a word that follows something on this line is joined to it by the space.
|
|
267
342
|
const joins = open && joined;
|
|
@@ -271,23 +346,14 @@ export function breakSegmentsIntoLines(segments, defaults, maxWidth, metrics, ma
|
|
|
271
346
|
const room = lines.length === 0 ? maxWidth - indent : maxWidth;
|
|
272
347
|
// How much a JUSTIFIED line may be squeezed to keep one more word: the spaces already on the
|
|
273
348
|
// line plus the one that would join it, each giving up at most `shrink` of its width. Zero for
|
|
274
|
-
// every other alignment, where a squeezed line would simply overflow instead.
|
|
275
|
-
|
|
349
|
+
// every other alignment, where a squeezed line would simply overflow instead. The drawing
|
|
350
|
+
// spreads the same total evenly over the gaps (`justifyExtra`).
|
|
351
|
+
const squeeze = (gapWidth + (joins ? spaceWidth : 0)) * shrink;
|
|
276
352
|
// A hyphen inside the word is a break opportunity, taken only when the whole word does not fit
|
|
277
353
|
// here - the same test, squeeze included, as below.
|
|
278
354
|
const fitsHere = (w) => !open ? w <= room : width + ((joins ? spaceWidth : 0) + w) - squeeze <= room;
|
|
279
355
|
const breaks = mayBreak(word) ? hyphenBreaks(word) : NO_BREAKS;
|
|
280
356
|
if (breaks.length > 0 && !fitsHere(wordWidth)) {
|
|
281
|
-
const freshLine = (startAt) => {
|
|
282
|
-
lineSegments = [started({ ...segment, fontFamily: family, content: "" }, at(startAt))];
|
|
283
|
-
combined = "";
|
|
284
|
-
width = 0;
|
|
285
|
-
gaps = 0;
|
|
286
|
-
open = false;
|
|
287
|
-
inked = false;
|
|
288
|
-
ink = 0;
|
|
289
|
-
lineStart = at(startAt);
|
|
290
|
-
};
|
|
291
357
|
let head = 0;
|
|
292
358
|
for (const k of breaks) {
|
|
293
359
|
if (!fitsHere(runAdvance(metrics, headAt(word, k), font, letterSpacing)))
|
|
@@ -306,7 +372,7 @@ export function breakSegmentsIntoLines(segments, defaults, maxWidth, metrics, ma
|
|
|
306
372
|
lineSegments.push(started({ ...segment, fontFamily: family, content: "" }, at(wordAt)));
|
|
307
373
|
lineSegments[lineSegments.length - 1].content = combined;
|
|
308
374
|
close(lineSegments, ink, endAt(word, head));
|
|
309
|
-
|
|
375
|
+
startLine(wordAt + head);
|
|
310
376
|
placeWord(word.slice(head), wordAt + head, false);
|
|
311
377
|
return;
|
|
312
378
|
}
|
|
@@ -319,22 +385,33 @@ export function breakSegmentsIntoLines(segments, defaults, maxWidth, metrics, ma
|
|
|
319
385
|
}
|
|
320
386
|
if (glued !== null) {
|
|
321
387
|
close(lineSegments, ink, "soft", !joined);
|
|
322
|
-
|
|
388
|
+
startLine(wordAt);
|
|
323
389
|
placeWord(word, wordAt, false);
|
|
324
390
|
return;
|
|
325
391
|
}
|
|
326
392
|
}
|
|
327
393
|
placeWord(headAt(word, breaks[0]), wordAt, joined);
|
|
328
394
|
close(lineSegments, ink, endAt(word, breaks[0]));
|
|
329
|
-
|
|
395
|
+
startLine(wordAt + breaks[0]);
|
|
330
396
|
placeWord(word.slice(breaks[0]), wordAt + breaks[0], false);
|
|
331
397
|
return;
|
|
332
398
|
}
|
|
399
|
+
// A word glued to the line - it continues one from an earlier span - that does not fit here.
|
|
400
|
+
if (!joined && !afterSpace && inked && word !== "" && !fitsHere(wordWidth)) {
|
|
401
|
+
const glued = gluedTail(word, afterSpace);
|
|
402
|
+
if (glued && glued.moved.length > 0) {
|
|
403
|
+
carryOver(glued, wordAt);
|
|
404
|
+
placeWord(word, wordAt, false);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (glued === null && emergencyBreak(word, wordAt, room))
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
333
410
|
const splitRoom = lines.length === 0 && !open ? room : maxWidth;
|
|
334
411
|
// A word too wide for the box is split into pieces that fit (hyphenation first, break-word as
|
|
335
412
|
// the floor). Each finished piece closes the line it sits on, keeping this segment's style. Off
|
|
336
413
|
// by default, so a document that asks for neither never gets here.
|
|
337
|
-
const split = splitLongWord(word, splitRoom, font, metrics, letterSpacing,
|
|
414
|
+
const split = splitLongWord(word, splitRoom, font, metrics, letterSpacing, splitting);
|
|
338
415
|
if (split) {
|
|
339
416
|
// Close whatever is already on the line FIRST: the pieces were measured against the full box,
|
|
340
417
|
// not against what is left of this line.
|
|
@@ -356,7 +433,7 @@ export function breakSegmentsIntoLines(segments, defaults, maxWidth, metrics, ma
|
|
|
356
433
|
lineStart = at(pieceAt);
|
|
357
434
|
combined = split[split.length - 1].text;
|
|
358
435
|
width = runAdvance(metrics, combined, font, letterSpacing);
|
|
359
|
-
|
|
436
|
+
gapWidth = 0;
|
|
360
437
|
open = true;
|
|
361
438
|
inked = true;
|
|
362
439
|
ink = width;
|
|
@@ -391,7 +468,7 @@ export function breakSegmentsIntoLines(segments, defaults, maxWidth, metrics, ma
|
|
|
391
468
|
close(lineSegments, ink, "soft", !joined);
|
|
392
469
|
lineStart = at(wordAt);
|
|
393
470
|
width = wordWidth;
|
|
394
|
-
|
|
471
|
+
gapWidth = 0;
|
|
395
472
|
ink = wordWidth;
|
|
396
473
|
inked = true;
|
|
397
474
|
lineSegments = [];
|
|
@@ -403,7 +480,7 @@ export function breakSegmentsIntoLines(segments, defaults, maxWidth, metrics, ma
|
|
|
403
480
|
combined += joins ? " " + word : word;
|
|
404
481
|
width = candidate;
|
|
405
482
|
if (joins)
|
|
406
|
-
|
|
483
|
+
gapWidth += spaceWidth;
|
|
407
484
|
open = true;
|
|
408
485
|
if (word !== "") {
|
|
409
486
|
ink = width;
|
|
@@ -436,7 +513,9 @@ export function breakSegmentsIntoLines(segments, defaults, maxWidth, metrics, ma
|
|
|
436
513
|
if (overflow === "ellipsis") {
|
|
437
514
|
// The same room that line was broken against: the first one lost the indent to it.
|
|
438
515
|
const room = kept.length === 1 ? maxWidth - indent : maxWidth;
|
|
439
|
-
|
|
516
|
+
const last = kept[kept.length - 1];
|
|
517
|
+
ellipsizeLine(last, defaults, room, metrics);
|
|
518
|
+
last.overrun = overrunOf(last, room, defaults, metrics);
|
|
440
519
|
}
|
|
441
520
|
}
|
|
442
521
|
// A piece of a paragraph split across pages draws only its own lines.
|
|
@@ -474,18 +553,16 @@ function ellipsizeLine(line, defaults, maxWidth, metrics) {
|
|
|
474
553
|
const segs = line.segments;
|
|
475
554
|
if (segs.length === 0)
|
|
476
555
|
return;
|
|
477
|
-
const lsOf = (seg) => seg.letterSpacing ?? defaults.letterSpacing ?? 0;
|
|
478
556
|
// Summed word by word, as the breaker sums a line - one owner for a line's width.
|
|
479
|
-
const widthOf = (seg) =>
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
ligatures: defaults.ligatures,
|
|
484
|
-
}, metrics, lsOf(seg), defaults.wordSpacing);
|
|
557
|
+
const widthOf = (seg) => {
|
|
558
|
+
const run = runStyleOf(seg, defaults);
|
|
559
|
+
return singleLineWidth(seg.content, run.font, metrics, run.letterSpacing, run.wordSpacing);
|
|
560
|
+
};
|
|
485
561
|
let prefix = 0;
|
|
486
562
|
for (let i = 0; i < segs.length - 1; i++)
|
|
487
563
|
prefix += widthOf(segs[i]);
|
|
488
564
|
const last = segs[segs.length - 1];
|
|
489
|
-
|
|
565
|
+
const run = runStyleOf(last, defaults);
|
|
566
|
+
last.content = ellipsize(last.content, run.font.fontFamily, run.font.fontSize, run.font.fontStyle, maxWidth - prefix, metrics, run.letterSpacing, run.wordSpacing, run.font.ligatures);
|
|
490
567
|
line.width = prefix + widthOf(last);
|
|
491
568
|
}
|
|
@@ -50,3 +50,5 @@ export declare function lineBoxForString(metrics: FontMetrics, fontFamily: strin
|
|
|
50
50
|
export declare function lineBoxForSegmentLine(line: SegmentLine, defaults: SegmentDefaults, metrics: FontMetrics, lineHeight?: number): LineBox;
|
|
51
51
|
/** How tall a text is at `maxWidth`: its lines, from the one breaker, each in its own line box. */
|
|
52
52
|
export declare function textHeight(spans: TextSegment[], defaults: SegmentDefaults, maxWidth: number, metrics: FontMetrics, maxLines?: number, overflow?: TextOverflow, lineHeight?: number): number;
|
|
53
|
+
/** How tall these lines stand, each in its own line box. */
|
|
54
|
+
export declare function linesHeight(lines: SegmentLine[], defaults: SegmentDefaults, metrics: FontMetrics, lineHeight?: number): number;
|
|
@@ -58,5 +58,9 @@ export function lineBoxForSegmentLine(line, defaults, metrics, lineHeight) {
|
|
|
58
58
|
}
|
|
59
59
|
/** How tall a text is at `maxWidth`: its lines, from the one breaker, each in its own line box. */
|
|
60
60
|
export function textHeight(spans, defaults, maxWidth, metrics, maxLines, overflow, lineHeight) {
|
|
61
|
-
return breakSegmentsIntoLines(spans, defaults, maxWidth, metrics, maxLines, overflow)
|
|
61
|
+
return linesHeight(breakSegmentsIntoLines(spans, defaults, maxWidth, metrics, maxLines, overflow), defaults, metrics, lineHeight);
|
|
62
|
+
}
|
|
63
|
+
/** How tall these lines stand, each in its own line box. */
|
|
64
|
+
export function linesHeight(lines, defaults, metrics, lineHeight) {
|
|
65
|
+
return lines.reduce((total, line) => total + lineBoxForSegmentLine(line, defaults, metrics, lineHeight).height, 0);
|
|
62
66
|
}
|