@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.
Files changed (111) hide show
  1. package/README.md +3 -2
  2. package/dist/api/color.d.ts +1 -24
  3. package/dist/api/color.js +2 -278
  4. package/dist/api/content.d.ts +7 -2
  5. package/dist/api/content.js +17 -7
  6. package/dist/api/descriptor.js +30 -3
  7. package/dist/api/flow.d.ts +55 -0
  8. package/dist/api/flow.js +71 -0
  9. package/dist/api/gradient.d.ts +1 -53
  10. package/dist/api/gradient.js +2 -97
  11. package/dist/api/index.d.ts +2 -0
  12. package/dist/api/index.js +2 -0
  13. package/dist/api/layout.js +4 -3
  14. package/dist/api/structure.d.ts +1 -1
  15. package/dist/api/table.js +3 -1
  16. package/dist/api/text.d.ts +8 -8
  17. package/dist/api/text.js +6 -0
  18. package/dist/canvas/painter.d.ts +2 -2
  19. package/dist/canvas/painter.js +2 -2
  20. package/dist/common/color-input.d.ts +24 -0
  21. package/dist/common/color-input.js +278 -0
  22. package/dist/common/diagnostics.d.ts +9 -1
  23. package/dist/common/gradient.d.ts +53 -0
  24. package/dist/common/gradient.js +97 -0
  25. package/dist/elements/container-element.d.ts +3 -0
  26. package/dist/elements/container-element.js +17 -5
  27. package/dist/elements/image-element.d.ts +37 -26
  28. package/dist/elements/image-element.js +88 -50
  29. package/dist/elements/index.d.ts +3 -0
  30. package/dist/elements/index.js +3 -0
  31. package/dist/elements/layout/column-break-element.d.ts +17 -0
  32. package/dist/elements/layout/column-break-element.js +26 -0
  33. package/dist/elements/layout/deferred-element.d.ts +9 -1
  34. package/dist/elements/layout/deferred-element.js +18 -3
  35. package/dist/elements/layout/expanded-element.d.ts +4 -0
  36. package/dist/elements/layout/expanded-element.js +14 -1
  37. package/dist/elements/layout/flow-element.d.ts +109 -0
  38. package/dist/elements/layout/flow-element.js +317 -0
  39. package/dist/elements/layout/keep-together-element.js +14 -2
  40. package/dist/elements/layout/padding-element.d.ts +3 -0
  41. package/dist/elements/layout/padding-element.js +9 -2
  42. package/dist/elements/layout/page-builder-element.js +2 -2
  43. package/dist/elements/layout/positioned-element.d.ts +9 -2
  44. package/dist/elements/layout/positioned-element.js +6 -2
  45. package/dist/elements/layout/proxy-element.d.ts +4 -0
  46. package/dist/elements/layout/proxy-element.js +12 -0
  47. package/dist/elements/layout/region-element.d.ts +53 -0
  48. package/dist/elements/layout/region-element.js +109 -0
  49. package/dist/elements/layout/repeating-header-element.d.ts +3 -0
  50. package/dist/elements/layout/repeating-header-element.js +16 -1
  51. package/dist/elements/layout/rotated-element.d.ts +2 -0
  52. package/dist/elements/layout/rotated-element.js +8 -0
  53. package/dist/elements/pdf-element.d.ts +32 -3
  54. package/dist/elements/pdf-element.js +50 -1
  55. package/dist/elements/rectangle-element.d.ts +12 -6
  56. package/dist/elements/rectangle-element.js +31 -12
  57. package/dist/elements/row-element.d.ts +6 -12
  58. package/dist/elements/row-element.js +34 -4
  59. package/dist/elements/svg-element.js +4 -10
  60. package/dist/elements/text-element.d.ts +17 -1
  61. package/dist/elements/text-element.js +63 -40
  62. package/dist/ir/display-list.d.ts +6 -1
  63. package/dist/layout/box-constraints.d.ts +12 -0
  64. package/dist/layout/box-constraints.js +17 -0
  65. package/dist/layout/collect-images.js +8 -4
  66. package/dist/layout/flex-item.d.ts +2 -0
  67. package/dist/layout/flex-item.js +14 -2
  68. package/dist/layout/fragmentation.d.ts +11 -5
  69. package/dist/layout/fragmentation.js +27 -7
  70. package/dist/layout/layout-cache.d.ts +8 -8
  71. package/dist/layout/layout-cache.js +10 -7
  72. package/dist/platform/browser-fs.d.ts +1 -2
  73. package/dist/platform/browser-fs.js +3 -6
  74. package/dist/platform/node-fs.d.ts +0 -1
  75. package/dist/platform/node-fs.js +0 -4
  76. package/dist/renderer/column-break-renderer.d.ts +8 -0
  77. package/dist/renderer/column-break-renderer.js +15 -0
  78. package/dist/renderer/emoji-image.js +3 -1
  79. package/dist/renderer/flow-renderer.d.ts +16 -0
  80. package/dist/renderer/flow-renderer.js +21 -0
  81. package/dist/renderer/image-renderer.js +31 -54
  82. package/dist/renderer/index.d.ts +2 -0
  83. package/dist/renderer/index.js +2 -0
  84. package/dist/renderer/page-renderer.js +3 -4
  85. package/dist/renderer/pdf-backend.js +9 -2
  86. package/dist/renderer/pdf-document-renderer.js +2 -1
  87. package/dist/renderer/pdf-renderer.js +9 -0
  88. package/dist/renderer/positioned-renderer.d.ts +14 -2
  89. package/dist/renderer/positioned-renderer.js +40 -4
  90. package/dist/renderer/rectangle-renderer.d.ts +1 -1
  91. package/dist/renderer/rectangle-renderer.js +11 -6
  92. package/dist/renderer/rotated-renderer.js +3 -2
  93. package/dist/renderer/text-renderer.js +25 -28
  94. package/dist/svg/index.d.ts +1 -1
  95. package/dist/svg/index.js +26 -5
  96. package/dist/svg/style.js +1 -1
  97. package/dist/text/line-breaker.d.ts +23 -0
  98. package/dist/text/line-breaker.js +142 -65
  99. package/dist/text/line-metrics.d.ts +2 -0
  100. package/dist/text/line-metrics.js +5 -1
  101. package/dist/text/word-splitting.js +11 -5
  102. package/dist/utils/flex-layout.d.ts +3 -1
  103. package/dist/utils/flex-layout.js +141 -33
  104. package/dist/utils/image-helper.d.ts +59 -11
  105. package/dist/utils/image-helper.js +207 -80
  106. package/dist/utils/pdf-object-manager.d.ts +6 -3
  107. package/dist/utils/pdf-object-manager.js +31 -7
  108. package/dist/utils/renderer-registry.d.ts +6 -0
  109. package/dist/utils/renderer-registry.js +1 -1
  110. package/dist/utils/ttf-parser.js +3 -2
  111. 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, fontSize, color, spans, fontFamily, fontStyle, textAlignment, maxLines, overflow, lineHeight, underline, strikethrough, skipInk, letterSpacing, direction, wordSpacing, textIndent, ligatures, role, breaking, } = textElement.getProps();
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, fontSize, fontFamily, fontStyle, objectManager, width ?? Number.NaN, textAlignment, color,
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, letterSpacing, direction, wordSpacing, textIndent, ligatures);
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, 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, ligatures = true) {
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 = (line, naturalWidth, isLast, indent = 0) => {
272
+ const justifyExtra = (segments, naturalWidth, isLast, indent = 0) => {
273
273
  if (align !== HorizontalAlignment.justify)
274
274
  return 0;
275
- const gaps = [...line].filter((c) => c === " ").length;
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 crushed.
282
- // A space as the breaker counts it: word-spacing included.
283
- const spaceWidth = runAdvance(objectManager, " ", { fontFamily, fontSize, fontStyle }, letterSpacing) +
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 (!skipInk)
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, ligatures ? undefined : []);
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 extra = wordSpacing +
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 family = segment.fontFamily || fontFamily;
405
- const size = segment.fontSize || fontSize;
406
- const style = segment.fontStyle || fontStyle;
407
- const segLetterSpacing = segment.letterSpacing ?? letterSpacing;
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, extra, {
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
- { fontFamily: family, fontSize: size, fontStyle: style, ligatures }, segLetterSpacing);
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.
@@ -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
- const declaredW = Number(attributes["width"]);
89
- const declaredH = Number(attributes["height"]);
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: Number.isFinite(declaredW) && declaredW > 0 ? declaredW : hasBox ? box[2] : target.width,
92
- height: Number.isFinite(declaredH) && declaredH > 0 ? declaredH : hasBox ? box[3] : target.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, size.width, size.height];
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 "../api/color.js";
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, wordSpacing = 0, shrink = 0 } = defaults;
108
+ const { indent = 0, shrink = 0 } = defaults;
66
109
  let width = 0;
67
- let gaps = 0; // spaces on the line, which is what a justified line can squeeze
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 font = {
98
- fontFamily: last.fontFamily || defaults.fontFamily,
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: drawn.length > 0 ? drawn : stripped.slice(0, 1),
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 family = segment.fontFamily || defaults.fontFamily;
199
- const size = segment.fontSize || defaults.fontSize;
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
- gaps = 0;
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
- gaps = 0;
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
- lineSegments = [started({ ...segment, fontFamily: family, content: "" }, at(wordAt))];
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
- const squeeze = (gaps + (joins ? 1 : 0)) * spaceWidth * shrink;
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
- freshLine(wordAt + head);
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
- freshLine(wordAt);
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
- freshLine(wordAt + breaks[0]);
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, defaults.splitting ?? {});
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
- gaps = 0;
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
- gaps = 0;
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
- gaps += 1;
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
- ellipsizeLine(kept[kept.length - 1], defaults, room, metrics);
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) => singleLineWidth(seg.content, {
480
- fontFamily: seg.fontFamily || defaults.fontFamily,
481
- fontSize: seg.fontSize || defaults.fontSize,
482
- fontStyle: seg.fontStyle || defaults.fontStyle,
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
- last.content = ellipsize(last.content, last.fontFamily || defaults.fontFamily, last.fontSize || defaults.fontSize, last.fontStyle || defaults.fontStyle, maxWidth - prefix, metrics, lsOf(last), defaults.wordSpacing, defaults.ligatures);
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).reduce((total, line) => total + lineBoxForSegmentLine(line, defaults, metrics, lineHeight).height, 0);
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
  }