@jasy/pdf 1.0.0-alpha.7 → 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.
Files changed (140) hide show
  1. package/README.md +11 -10
  2. package/dist/api/content.d.ts +5 -4
  3. package/dist/api/content.js +13 -7
  4. package/dist/api/descriptor.js +23 -0
  5. package/dist/api/dimension.d.ts +56 -0
  6. package/dist/api/dimension.js +27 -0
  7. package/dist/api/font-url.d.ts +28 -0
  8. package/dist/api/font-url.js +134 -0
  9. package/dist/api/forms.d.ts +347 -0
  10. package/dist/api/forms.js +189 -0
  11. package/dist/api/gradient.d.ts +53 -0
  12. package/dist/api/gradient.js +97 -0
  13. package/dist/api/index.d.ts +3 -0
  14. package/dist/api/index.js +3 -0
  15. package/dist/api/insets.d.ts +19 -12
  16. package/dist/api/insets.js +18 -7
  17. package/dist/api/layout.d.ts +20 -6
  18. package/dist/api/layout.js +31 -6
  19. package/dist/api/page-builder.d.ts +8 -0
  20. package/dist/api/page-builder.js +12 -0
  21. package/dist/api/structure.d.ts +11 -0
  22. package/dist/api/structure.js +17 -4
  23. package/dist/api/text.d.ts +42 -6
  24. package/dist/api/text.js +29 -5
  25. package/dist/crypto/legacy-handler.d.ts +43 -0
  26. package/dist/crypto/legacy-handler.js +145 -0
  27. package/dist/crypto/security-handler.d.ts +16 -0
  28. package/dist/crypto/security-handler.js +26 -0
  29. package/dist/edit/acroform-reader.d.ts +70 -0
  30. package/dist/edit/acroform-reader.js +126 -0
  31. package/dist/edit/appearance.d.ts +47 -0
  32. package/dist/edit/appearance.js +171 -0
  33. package/dist/edit/document.d.ts +145 -0
  34. package/dist/edit/document.js +642 -0
  35. package/dist/edit/fill.d.ts +65 -0
  36. package/dist/edit/fill.js +424 -0
  37. package/dist/edit/flatten.d.ts +54 -0
  38. package/dist/edit/flatten.js +462 -0
  39. package/dist/edit/index.d.ts +20 -0
  40. package/dist/edit/index.js +18 -0
  41. package/dist/edit/lexer.d.ts +30 -0
  42. package/dist/edit/lexer.js +285 -0
  43. package/dist/edit/objects.d.ts +68 -0
  44. package/dist/edit/objects.js +83 -0
  45. package/dist/edit/writer.d.ts +60 -0
  46. package/dist/edit/writer.js +283 -0
  47. package/dist/elements/container-element.d.ts +21 -1
  48. package/dist/elements/container-element.js +25 -10
  49. package/dist/elements/forms/checkbox-element.d.ts +51 -0
  50. package/dist/elements/forms/checkbox-element.js +48 -0
  51. package/dist/elements/forms/choice-element.d.ts +53 -0
  52. package/dist/elements/forms/choice-element.js +59 -0
  53. package/dist/elements/forms/push-button-element.d.ts +56 -0
  54. package/dist/elements/forms/push-button-element.js +49 -0
  55. package/dist/elements/forms/radio-element.d.ts +52 -0
  56. package/dist/elements/forms/radio-element.js +49 -0
  57. package/dist/elements/forms/signature-element.d.ts +54 -0
  58. package/dist/elements/forms/signature-element.js +47 -0
  59. package/dist/elements/forms/text-field-element.d.ts +61 -0
  60. package/dist/elements/forms/text-field-element.js +54 -0
  61. package/dist/elements/image-element.d.ts +18 -4
  62. package/dist/elements/image-element.js +27 -12
  63. package/dist/elements/index.d.ts +6 -0
  64. package/dist/elements/index.js +6 -0
  65. package/dist/elements/layout/expanded-element.d.ts +3 -1
  66. package/dist/elements/layout/expanded-element.js +12 -3
  67. package/dist/elements/layout/padding-element.d.ts +4 -2
  68. package/dist/elements/layout/padding-element.js +4 -2
  69. package/dist/elements/layout/page-builder-element.js +2 -0
  70. package/dist/elements/pdf-element.d.ts +59 -1
  71. package/dist/elements/pdf-element.js +62 -1
  72. package/dist/elements/rectangle-element.d.ts +20 -6
  73. package/dist/elements/rectangle-element.js +25 -12
  74. package/dist/elements/row-element.d.ts +24 -1
  75. package/dist/elements/row-element.js +25 -10
  76. package/dist/elements/text-element.d.ts +60 -1
  77. package/dist/elements/text-element.js +132 -33
  78. package/dist/forms/acroform.d.ts +63 -0
  79. package/dist/forms/acroform.js +427 -0
  80. package/dist/forms/appearance.d.ts +58 -0
  81. package/dist/forms/appearance.js +204 -0
  82. package/dist/forms/field.d.ts +164 -0
  83. package/dist/forms/field.js +1 -0
  84. package/dist/forms/pdf.d.ts +19 -0
  85. package/dist/forms/pdf.js +28 -0
  86. package/dist/ir/display-list.d.ts +40 -7
  87. package/dist/ir/display-list.js +8 -1
  88. package/dist/layout/alignment.d.ts +10 -0
  89. package/dist/layout/alignment.js +6 -0
  90. package/dist/layout/box-constraints.d.ts +53 -0
  91. package/dist/layout/box-constraints.js +72 -0
  92. package/dist/layout/insets.d.ts +28 -0
  93. package/dist/layout/insets.js +20 -0
  94. package/dist/renderer/form-field-renderer.d.ts +20 -0
  95. package/dist/renderer/form-field-renderer.js +12 -0
  96. package/dist/renderer/image-renderer.js +2 -1
  97. package/dist/renderer/page-renderer.js +8 -2
  98. package/dist/renderer/pdf-backend.d.ts +6 -0
  99. package/dist/renderer/pdf-backend.js +86 -31
  100. package/dist/renderer/pdf-document-renderer.js +8 -1
  101. package/dist/renderer/pdf-renderer.js +19 -1
  102. package/dist/renderer/rectangle-renderer.js +8 -2
  103. package/dist/renderer/text-renderer.d.ts +2 -2
  104. package/dist/renderer/text-renderer.js +150 -42
  105. package/dist/text/advance.js +5 -2
  106. package/dist/text/arabic.d.ts +20 -0
  107. package/dist/text/arabic.js +101 -0
  108. package/dist/text/bidi.d.ts +33 -0
  109. package/dist/text/bidi.js +94 -0
  110. package/dist/text/font-fallback.d.ts +25 -0
  111. package/dist/text/font-fallback.js +38 -0
  112. package/dist/text/line-breaker.d.ts +35 -1
  113. package/dist/text/line-breaker.js +76 -16
  114. package/dist/text/orphans-widows.d.ts +27 -0
  115. package/dist/text/orphans-widows.js +53 -0
  116. package/dist/text/shape.d.ts +29 -0
  117. package/dist/text/shape.js +67 -0
  118. package/dist/text/text-style.d.ts +25 -0
  119. package/dist/text/text-style.js +44 -1
  120. package/dist/utils/dest-registry.d.ts +5 -2
  121. package/dist/utils/dest-registry.js +5 -5
  122. package/dist/utils/flex-layout.d.ts +24 -4
  123. package/dist/utils/flex-layout.js +169 -7
  124. package/dist/utils/font-metrics.d.ts +5 -0
  125. package/dist/utils/gsub.d.ts +36 -0
  126. package/dist/utils/gsub.js +178 -0
  127. package/dist/utils/inflate.d.ts +16 -0
  128. package/dist/utils/inflate.js +52 -0
  129. package/dist/utils/outline.js +1 -2
  130. package/dist/utils/pdf-object-manager.d.ts +39 -0
  131. package/dist/utils/pdf-object-manager.js +166 -15
  132. package/dist/utils/struct-tree.js +2 -3
  133. package/dist/utils/ttf-parser.d.ts +6 -0
  134. package/dist/utils/ttf-parser.js +19 -0
  135. package/dist/utils/utf8-to-windows1252-encoder.d.ts +6 -0
  136. package/dist/utils/utf8-to-windows1252-encoder.js +10 -0
  137. package/dist/utils/woff.d.ts +10 -0
  138. package/dist/utils/woff.js +116 -0
  139. package/dist/validators/element-validator.js +14 -3
  140. package/package.json +10 -2
@@ -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
- const alignmentOffset = (lineWidth) => {
228
- if (textAlignment === HorizontalAlignment.center)
229
- return (maxWidth - lineWidth) / 2;
230
- if (textAlignment === HorizontalAlignment.right)
231
- return maxWidth - lineWidth;
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 = runAdvance(objectManager, line, font, letterSpacing);
290
- const x = initialX + alignmentOffset(lineWidth);
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
- runs.push({
293
- type: "text",
294
- x,
295
- y: baseline,
296
- text: line,
297
- fontFamily,
298
- fontStyle,
299
- fontSize,
300
- color,
301
- ...(letterSpacing ? { letterSpacing } : {}),
302
- });
303
- decorate(line, x, lineWidth, baseline, fontFamily, fontStyle, fontSize, color, underline, strikethrough, letterSpacing);
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.forEach((segment) => {
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 advance = runAdvance(objectManager, segment.content, { fontFamily: family, fontSize: size, fontStyle: style }, segLetterSpacing);
421
+ const piece = shapedPiece(part, family, style);
422
+ const runText = piece.text;
320
423
  const runColor = segment.fontColor || color;
321
- runs.push({
322
- type: "text",
323
- x,
324
- y: lineY,
325
- text: segment.content,
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(segment.content, x, advance, lineY, family, style, size, runColor, segment.underline ?? underline, segment.strikethrough ?? strikethrough, segLetterSpacing);
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: lineY - v.ascent * size,
342
- width: advance,
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 += advance;
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
- for (const line of breakSegmentsIntoLines(content, defaults, maxWidth, objectManager, maxLines, overflow)) {
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
  }
@@ -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
- advance += codePointCount(text) * letterSpacing;
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
+ }