@jasy/pdf 1.0.0-alpha.8 → 1.0.0-alpha.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +4 -3
  2. package/dist/api/content.d.ts +5 -4
  3. package/dist/api/content.js +13 -7
  4. package/dist/api/dimension.d.ts +56 -0
  5. package/dist/api/dimension.js +27 -0
  6. package/dist/api/font-url.d.ts +28 -0
  7. package/dist/api/font-url.js +134 -0
  8. package/dist/api/gradient.d.ts +53 -0
  9. package/dist/api/gradient.js +97 -0
  10. package/dist/api/index.d.ts +2 -0
  11. package/dist/api/index.js +2 -0
  12. package/dist/api/insets.d.ts +19 -12
  13. package/dist/api/insets.js +18 -7
  14. package/dist/api/layout.d.ts +20 -6
  15. package/dist/api/layout.js +31 -6
  16. package/dist/api/page-builder.d.ts +8 -0
  17. package/dist/api/page-builder.js +12 -0
  18. package/dist/api/structure.d.ts +5 -0
  19. package/dist/api/structure.js +16 -4
  20. package/dist/api/text.d.ts +42 -6
  21. package/dist/api/text.js +29 -5
  22. package/dist/edit/document.d.ts +14 -4
  23. package/dist/edit/document.js +15 -22
  24. package/dist/edit/fill.d.ts +19 -2
  25. package/dist/edit/fill.js +63 -34
  26. package/dist/edit/flatten.d.ts +13 -1
  27. package/dist/edit/flatten.js +38 -13
  28. package/dist/edit/index.d.ts +2 -0
  29. package/dist/edit/index.js +3 -1
  30. package/dist/edit/objects.d.ts +5 -0
  31. package/dist/edit/objects.js +27 -0
  32. package/dist/edit/writer.d.ts +7 -0
  33. package/dist/edit/writer.js +23 -1
  34. package/dist/elements/container-element.d.ts +21 -1
  35. package/dist/elements/container-element.js +25 -10
  36. package/dist/elements/image-element.d.ts +18 -4
  37. package/dist/elements/image-element.js +27 -12
  38. package/dist/elements/layout/expanded-element.d.ts +3 -1
  39. package/dist/elements/layout/expanded-element.js +12 -3
  40. package/dist/elements/layout/padding-element.d.ts +4 -2
  41. package/dist/elements/layout/padding-element.js +4 -2
  42. package/dist/elements/layout/page-builder-element.js +2 -0
  43. package/dist/elements/pdf-element.d.ts +59 -1
  44. package/dist/elements/pdf-element.js +62 -1
  45. package/dist/elements/rectangle-element.d.ts +20 -6
  46. package/dist/elements/rectangle-element.js +25 -12
  47. package/dist/elements/row-element.d.ts +24 -1
  48. package/dist/elements/row-element.js +25 -10
  49. package/dist/elements/text-element.d.ts +60 -1
  50. package/dist/elements/text-element.js +132 -33
  51. package/dist/ir/display-list.d.ts +23 -6
  52. package/dist/ir/display-list.js +8 -1
  53. package/dist/layout/alignment.d.ts +10 -0
  54. package/dist/layout/alignment.js +6 -0
  55. package/dist/layout/box-constraints.d.ts +53 -0
  56. package/dist/layout/box-constraints.js +72 -0
  57. package/dist/layout/insets.d.ts +28 -0
  58. package/dist/layout/insets.js +20 -0
  59. package/dist/renderer/image-renderer.js +2 -1
  60. package/dist/renderer/pdf-backend.d.ts +6 -0
  61. package/dist/renderer/pdf-backend.js +78 -31
  62. package/dist/renderer/pdf-document-renderer.js +8 -1
  63. package/dist/renderer/rectangle-renderer.js +8 -2
  64. package/dist/renderer/text-renderer.d.ts +2 -2
  65. package/dist/renderer/text-renderer.js +150 -42
  66. package/dist/text/advance.js +5 -2
  67. package/dist/text/arabic.d.ts +20 -0
  68. package/dist/text/arabic.js +101 -0
  69. package/dist/text/bidi.d.ts +33 -0
  70. package/dist/text/bidi.js +94 -0
  71. package/dist/text/font-fallback.d.ts +25 -0
  72. package/dist/text/font-fallback.js +38 -0
  73. package/dist/text/line-breaker.d.ts +35 -1
  74. package/dist/text/line-breaker.js +76 -16
  75. package/dist/text/orphans-widows.d.ts +27 -0
  76. package/dist/text/orphans-widows.js +53 -0
  77. package/dist/text/shape.d.ts +29 -0
  78. package/dist/text/shape.js +67 -0
  79. package/dist/text/text-style.d.ts +25 -0
  80. package/dist/text/text-style.js +44 -1
  81. package/dist/utils/flex-layout.d.ts +24 -4
  82. package/dist/utils/flex-layout.js +169 -7
  83. package/dist/utils/font-metrics.d.ts +5 -0
  84. package/dist/utils/gsub.d.ts +36 -0
  85. package/dist/utils/gsub.js +178 -0
  86. package/dist/utils/inflate.d.ts +16 -0
  87. package/dist/utils/inflate.js +52 -0
  88. package/dist/utils/pdf-object-manager.d.ts +21 -0
  89. package/dist/utils/pdf-object-manager.js +107 -9
  90. package/dist/utils/ttf-parser.d.ts +6 -0
  91. package/dist/utils/ttf-parser.js +19 -0
  92. package/dist/utils/utf8-to-windows1252-encoder.d.ts +6 -0
  93. package/dist/utils/utf8-to-windows1252-encoder.js +10 -0
  94. package/dist/utils/woff.d.ts +10 -0
  95. package/dist/utils/woff.js +116 -0
  96. package/dist/validators/element-validator.js +9 -2
  97. package/package.json +2 -1
@@ -1,6 +1,28 @@
1
1
  import type { FontStyle } from "../utils/pdf-object-manager.js";
2
2
  import type { FontMetrics } from "../utils/font-metrics.js";
3
3
  import type { TextSegment } from "../elements/text-element.js";
4
+ /**
5
+ * How far a JUSTIFIED line's spaces may be squeezed to keep one more word on it.
6
+ *
7
+ * A quarter, because our breaker is GREEDY: it will use the whole allowance every time that saves a
8
+ * line, so the limit has to be the tightest word space still worth reading - about three quarters of
9
+ * natural, the usual typographic floor. TeX allows a third, but its badness model rarely spends it.
10
+ * For reference, react-pdf reaches -19% in practice (measured: a 3.18pt space squeezed to 2.58pt),
11
+ * which this covers. Its packing can still differ by a word, because it spreads the squeeze over the
12
+ * CHARACTERS as well as the spaces.
13
+ *
14
+ * Only justified text uses it: under any other alignment a squeezed line would simply overflow.
15
+ */
16
+ export declare const MAX_SPACE_SHRINK: number;
17
+ /** The knobs that change where a line ENDS, beyond the font and the box. */
18
+ export interface LineOptions {
19
+ /** Extra advance at every space (CSS `word-spacing`), in points. */
20
+ wordSpacing?: number;
21
+ /** How far the FIRST line starts in (CSS `text-indent`), in points - it has that much less room. */
22
+ indent?: number;
23
+ /** How far a justified line's spaces may be squeezed to keep one more word (0 = not justified). */
24
+ shrink?: number;
25
+ }
4
26
  /** Default font for segments that don't override it. */
5
27
  export interface SegmentDefaults {
6
28
  fontFamily: string;
@@ -26,7 +48,19 @@ export interface SegmentLine {
26
48
  * rendering call this, so they can never disagree. Depends only on `FontMetrics`,
27
49
  * not the PDF byte writer - the future fragmentation pass can reuse it.
28
50
  */
29
- export declare function wrapStringIntoLines(text: string, fontFamily: string, fontSize: number, fontStyle: FontStyle, maxWidth: number, metrics: FontMetrics, maxLines?: number, overflow?: TextOverflow, letterSpacing?: number): string[];
51
+ /**
52
+ * The width of `text` set as ONE line, accumulated in EXACTLY the order `wrapStringIntoLines` builds a
53
+ * line: the first word, then `+ (space + word)` each time. The order matters because floating-point
54
+ * addition is not associative - a box sized by a differently-grouped sum can be one bit too small for
55
+ * the very line the breaker then measures, and the text wraps inside a box made to fit it. That is not
56
+ * hypothetical: it is what split a footer sized to its own content.
57
+ */
58
+ export declare function singleLineWidth(text: string, font: {
59
+ fontFamily: string;
60
+ fontSize: number;
61
+ fontStyle: FontStyle;
62
+ }, metrics: FontMetrics, letterSpacing?: number, wordSpacing?: number): number;
63
+ export declare function wrapStringIntoLines(text: string, fontFamily: string, fontSize: number, fontStyle: FontStyle, maxWidth: number, metrics: FontMetrics, maxLines?: number, overflow?: TextOverflow, letterSpacing?: number, options?: LineOptions): string[];
30
64
  /**
31
65
  * Break styled segments into lines that fit within `maxWidth`. Same greedy word-splitting as the
32
66
  * string breaker; each line keeps the segments that landed on it, which is what `line-metrics.ts`
@@ -1,4 +1,17 @@
1
1
  import { runAdvance } from "./advance.js";
2
+ /**
3
+ * How far a JUSTIFIED line's spaces may be squeezed to keep one more word on it.
4
+ *
5
+ * A quarter, because our breaker is GREEDY: it will use the whole allowance every time that saves a
6
+ * line, so the limit has to be the tightest word space still worth reading - about three quarters of
7
+ * natural, the usual typographic floor. TeX allows a third, but its badness model rarely spends it.
8
+ * For reference, react-pdf reaches -19% in practice (measured: a 3.18pt space squeezed to 2.58pt),
9
+ * which this covers. Its packing can still differ by a word, because it spreads the squeeze over the
10
+ * CHARACTERS as well as the spaces.
11
+ *
12
+ * Only justified text uses it: under any other alignment a squeezed line would simply overflow.
13
+ */
14
+ export const MAX_SPACE_SHRINK = 1 / 4;
2
15
  /** Three ASCII dots, NOT the "…" glyph (U+2026): plain dots encode in every font - standard-14
3
16
  * (WinAnsi) and any embedded TTF - whereas U+2026 needs a glyph the font may not carry. */
4
17
  const ELLIPSIS = "...";
@@ -10,28 +23,65 @@ const ELLIPSIS = "...";
10
23
  * rendering call this, so they can never disagree. Depends only on `FontMetrics`,
11
24
  * not the PDF byte writer - the future fragmentation pass can reuse it.
12
25
  */
13
- export function wrapStringIntoLines(text, fontFamily, fontSize, fontStyle, maxWidth, metrics, maxLines, overflow, letterSpacing = 0) {
26
+ /**
27
+ * The width of `text` set as ONE line, accumulated in EXACTLY the order `wrapStringIntoLines` builds a
28
+ * line: the first word, then `+ (space + word)` each time. The order matters because floating-point
29
+ * addition is not associative - a box sized by a differently-grouped sum can be one bit too small for
30
+ * the very line the breaker then measures, and the text wraps inside a box made to fit it. That is not
31
+ * hypothetical: it is what split a footer sized to its own content.
32
+ */
33
+ export function singleLineWidth(text, font, metrics, letterSpacing = 0, wordSpacing = 0) {
34
+ const space = runAdvance(metrics, " ", font, letterSpacing) + wordSpacing;
35
+ return text
36
+ .split(" ")
37
+ .reduce((width, word, i) => i === 0
38
+ ? runAdvance(metrics, word, font, letterSpacing)
39
+ : width + (space + runAdvance(metrics, word, font, letterSpacing)), 0);
40
+ }
41
+ export function wrapStringIntoLines(text, fontFamily, fontSize, fontStyle, maxWidth, metrics, maxLines, overflow, letterSpacing = 0, options = {}) {
42
+ const { wordSpacing = 0, indent = 0, shrink = 0 } = options;
14
43
  let currentLine = "";
15
44
  let currentWidth = 0;
45
+ let gaps = 0; // spaces on the current line, which is what a justified line can squeeze
16
46
  const lines = [];
17
47
  const font = { fontFamily, fontSize, fontStyle };
18
48
  const words = text.split(" ");
19
- words.forEach((word, index) => {
49
+ words.forEach((word) => {
20
50
  // Word and space advances come from the one shared primitive (`advance.ts`), the same one
21
51
  // `naturalWidth` uses - so a bounded and an unbounded layout of the same text agree bit for bit.
22
52
  const wordWidth = runAdvance(metrics, word, font, letterSpacing);
23
- const spaceWidth = runAdvance(metrics, " ", font, letterSpacing);
24
- // Break before a word that won't fit - but only once the line has content. A single word wider
25
- // than maxWidth must sit on its (empty) line and overflow, not push a phantom empty line before
26
- // it (which would over-count the height by a line and shift the text down at render).
27
- if (currentWidth + wordWidth > maxWidth && currentLine !== "") {
53
+ const spaceWidth = runAdvance(metrics, " ", font, letterSpacing) + wordSpacing;
54
+ // The FIRST line has the indent taken out of its room; every later one gets the full box.
55
+ const room = lines.length === 0 ? maxWidth - indent : maxWidth;
56
+ // Break before a word that won't fit - counting the SPACE that would join it, which the old test
57
+ // forgot. It went unnoticed on the first line, where the very first word added a space too many
58
+ // and cancelled the error out; after a break `currentWidth` is the bare word, so every following
59
+ // test was short by one space and the line could overrun its box by that much.
60
+ //
61
+ // A single word wider than maxWidth still sits on its (empty) line and overflows, rather than
62
+ // pushing a phantom empty line before it (which would over-count the height by a line).
63
+ const candidate = currentWidth + (spaceWidth + wordWidth);
64
+ if (currentLine === "") {
65
+ currentLine = word;
66
+ currentWidth = wordWidth;
67
+ gaps = 0;
68
+ // How much a JUSTIFIED line may be squeezed to keep one more word: the spaces already on the
69
+ // line plus the one that would join it, each giving up at most `shrink` of its width. Zero for
70
+ // every other alignment, where a squeezed line would simply overflow instead.
71
+ // ONE expression, used for the test AND for the running total. Adding the same three numbers in
72
+ // a different order gives a different last bit, and a line that lands exactly on `maxWidth` is
73
+ // then broken by that bit alone - which is how a footer sized to its own text wrapped.
74
+ }
75
+ else if (candidate - (gaps + 1) * spaceWidth * shrink > room) {
28
76
  lines.push(currentLine.trim());
29
77
  currentLine = word;
30
78
  currentWidth = wordWidth;
79
+ gaps = 0;
31
80
  }
32
81
  else {
33
- currentLine += index === 0 ? word : " " + word;
34
- currentWidth += wordWidth + spaceWidth;
82
+ currentLine += " " + word;
83
+ currentWidth = candidate;
84
+ gaps += 1;
35
85
  }
36
86
  });
37
87
  if (currentLine)
@@ -42,7 +92,9 @@ export function wrapStringIntoLines(text, fontFamily, fontSize, fontStyle, maxWi
42
92
  const kept = lines.slice(0, maxLines);
43
93
  if (overflow === "ellipsis") {
44
94
  const last = kept.length - 1;
45
- kept[last] = ellipsize(kept[last], fontFamily, fontSize, fontStyle, maxWidth, metrics, letterSpacing);
95
+ kept[last] = ellipsize(kept[last], fontFamily, fontSize, fontStyle,
96
+ // The same room that line was broken against: the first one lost the indent to it.
97
+ last === 0 ? maxWidth - indent : maxWidth, metrics, letterSpacing, wordSpacing);
46
98
  }
47
99
  return kept;
48
100
  }
@@ -72,19 +124,25 @@ export function breakSegmentsIntoLines(segments, defaults, maxWidth, metrics, ma
72
124
  combined = "";
73
125
  words.forEach((word, wordIndex) => {
74
126
  const wordWidth = runAdvance(metrics, word, font, letterSpacing);
127
+ // A space joins this word to what precedes it only INSIDE a segment; segments butt together
128
+ // with nothing between them, which is what `span("a") + span("b")` draws.
129
+ const joiner = wordIndex > 0 ? spaceWidth : 0;
130
+ // ONE expression for the test and for the running total, grouped exactly as `singleLineWidth`
131
+ // groups it - see the note there. A width built any other way can be a bit off the one the box
132
+ // was sized with, and the text wraps inside a box made to hold it.
133
+ const candidate = width + (joiner + wordWidth);
75
134
  // Same guard as the string path: don't open a phantom empty line for an over-wide first word -
76
135
  // place it (overflowing) on the current empty line instead.
77
- if (width + wordWidth > maxWidth && width > 0) {
136
+ if (candidate > maxWidth && width > 0) {
78
137
  lines.push({ segments: lineSegments, width });
79
- width = 0;
138
+ width = wordWidth;
80
139
  lineSegments = [];
81
140
  combined = word;
82
- width += wordWidth + spaceWidth;
83
141
  lineSegments.push({ ...segment, content: combined });
84
142
  }
85
143
  else {
86
144
  combined += wordIndex === 0 ? word : " " + word;
87
- width += wordWidth + spaceWidth;
145
+ width = candidate;
88
146
  if (lineSegments.length === 0) {
89
147
  lineSegments.push({ ...segment, fontFamily: family, content: combined });
90
148
  }
@@ -124,9 +182,11 @@ export function segmentLinesToSegments(lines) {
124
182
  }
125
183
  /** Appends "…" to a single line, dropping trailing words (then characters) until the line plus the
126
184
  * ellipsis fits `maxWidth`. Falls back to a bare "…" if not even one character fits. */
127
- function ellipsize(line, fontFamily, fontSize, fontStyle, maxWidth, metrics, letterSpacing = 0) {
185
+ function ellipsize(line, fontFamily, fontSize, fontStyle, maxWidth, metrics, letterSpacing = 0, wordSpacing = 0) {
128
186
  const font = { fontFamily, fontSize, fontStyle };
129
- const fits = (s) => runAdvance(metrics, s + ELLIPSIS, font, letterSpacing) <= maxWidth;
187
+ // Measured through `singleLineWidth`, so the ellipsised line is judged by the same sum that decides
188
+ // every other line - word-spacing included.
189
+ const fits = (s) => singleLineWidth(s + ELLIPSIS, font, metrics, letterSpacing, wordSpacing) <= maxWidth;
130
190
  if (fits(line))
131
191
  return line + ELLIPSIS;
132
192
  const words = line.split(" ");
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Orphan and widow control for a paragraph split across a page break.
3
+ *
4
+ * An **orphan** is the first line of a paragraph left alone at the bottom of a page; a **widow** is
5
+ * the last line pushed alone to the top of the next. Both read as mistakes, and neither is prevented
6
+ * by splitting at line boxes - which is all the fragmenter does on its own.
7
+ *
8
+ * This is a correction of the split INDEX, not a second mechanism: the fragmenter works out how many
9
+ * lines fit, and this decides whether that number is acceptable.
10
+ */
11
+ /** How many lines must stay behind / must carry over. Both default to 2, the CSS initial value. */
12
+ export interface OrphanWidowRule {
13
+ orphans: number;
14
+ widows: number;
15
+ }
16
+ export declare const DEFAULT_ORPHANS = 2;
17
+ export declare const DEFAULT_WIDOWS = 2;
18
+ /**
19
+ * Correct a line count so the split leaves at least `orphans` lines behind and carries at least
20
+ * `widows` over. Returns 0 to mean "none of it fits here" - the caller then moves the whole paragraph
21
+ * on, exactly as it already does when not even one line fits.
22
+ *
23
+ * Terminating by construction: every answer is either the natural count, a SMALLER one, or 0. It
24
+ * never asks for more lines than fit, and the page driver's guard already handles a paragraph that
25
+ * does not fit on a whole page (it places it anyway rather than looping).
26
+ */
27
+ export declare function adjustForOrphansWidows(fitted: number, total: number, { orphans, widows }: OrphanWidowRule): number;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Orphan and widow control for a paragraph split across a page break.
3
+ *
4
+ * An **orphan** is the first line of a paragraph left alone at the bottom of a page; a **widow** is
5
+ * the last line pushed alone to the top of the next. Both read as mistakes, and neither is prevented
6
+ * by splitting at line boxes - which is all the fragmenter does on its own.
7
+ *
8
+ * This is a correction of the split INDEX, not a second mechanism: the fragmenter works out how many
9
+ * lines fit, and this decides whether that number is acceptable.
10
+ */
11
+ export const DEFAULT_ORPHANS = 2;
12
+ export const DEFAULT_WIDOWS = 2;
13
+ /**
14
+ * Correct a line count so the split leaves at least `orphans` lines behind and carries at least
15
+ * `widows` over. Returns 0 to mean "none of it fits here" - the caller then moves the whole paragraph
16
+ * on, exactly as it already does when not even one line fits.
17
+ *
18
+ * Terminating by construction: every answer is either the natural count, a SMALLER one, or 0. It
19
+ * never asks for more lines than fit, and the page driver's guard already handles a paragraph that
20
+ * does not fit on a whole page (it places it anyway rather than looping).
21
+ */
22
+ export function adjustForOrphansWidows(fitted, total, { orphans, widows }) {
23
+ // Both are LINE COUNTS and end up as a slice index, so anything but a positive whole number is
24
+ // meaningless: 0 or less silently disables the rule it belongs to, and a fraction would be truncated
25
+ // somewhere downstream rather than here. Named at the boundary instead.
26
+ check(orphans, "orphans");
27
+ check(widows, "widows");
28
+ if (fitted <= 0)
29
+ return 0; // nothing fits; the caller decides
30
+ if (fitted >= total)
31
+ return total; // no split needed, nothing to protect
32
+ // NOTE: an explicit "too short to satisfy both ends" rule (total < orphans + widows) sat here and was
33
+ // removed - it is unreachable. Either fewer lines stay than `orphans` allows, and the next check
34
+ // returns 0; or at least `orphans` stay, and then fewer than `widows` must carry, so the pull-back
35
+ // below lands under `orphans` and returns 0 as well. react-pdf spells the case out; we do not need to.
36
+ //
37
+ // Fewer lines would stay than an orphan is allowed to be.
38
+ if (fitted < orphans)
39
+ return 0;
40
+ // Fewer lines would carry over than a widow is allowed to be: pull the cut back so exactly `widows`
41
+ // go over - unless that would leave too few behind, in which case the paragraph moves whole.
42
+ if (total - fitted < widows) {
43
+ const pulled = total - widows;
44
+ return pulled >= orphans ? pulled : 0;
45
+ }
46
+ return fitted;
47
+ }
48
+ function check(value, name) {
49
+ if (!Number.isInteger(value) || value < 1) {
50
+ throw new Error(`Invalid ${name}: ${value}. It is a number of LINES, so it must be a whole number of at least 1 ` +
51
+ `(1 means no protection at that end).`);
52
+ }
53
+ }
@@ -0,0 +1,29 @@
1
+ import type { GsubTable } from "../utils/gsub.js";
2
+ /**
3
+ * SHAPING: code points in, the glyphs actually drawn out. Where the two halves meet - `arabic.ts`
4
+ * says WHICH positional form a letter takes, `gsub.ts` says which GLYPH that form is in this font.
5
+ *
6
+ * Not a string, because a ligature is one glyph for several code points. `codePoints` carries the
7
+ * origin so `ToUnicode` can still say what the glyph means.
8
+ */
9
+ /** One drawn glyph, with the code points it came from. */
10
+ export interface ShapedGlyph {
11
+ glyph: number;
12
+ /** Advance in FONT UNITS - the caller scales by fontSize / unitsPerEm, as everywhere else. */
13
+ advance: number;
14
+ /** The code points this glyph stands for; more than one only for a ligature. */
15
+ codePoints: number[];
16
+ }
17
+ /** The little a font has to answer for shaping - deliberately not the whole `TTFParser`. */
18
+ export interface ShapingFont {
19
+ gsub(): GsubTable | undefined;
20
+ getGlyphIndex(codePoint: number): number;
21
+ getAdvanceWidth(glyph: number): number;
22
+ }
23
+ /**
24
+ * Shape a run, or `undefined` when there is nothing to do - so untouched documents keep their path.
25
+ *
26
+ * `rlig` (required ligatures - lam-alef, wrong when drawn apart) but not `liga`, which is
27
+ * discretionary and would change how existing Latin text looks.
28
+ */
29
+ export declare function shapeRun(codePoints: readonly number[], font: ShapingFont): ShapedGlyph[] | undefined;
@@ -0,0 +1,67 @@
1
+ import { joiningForms, needsJoining } from "./arabic.js";
2
+ /** The features that realise the four positional forms, in the order a shaper applies them. */
3
+ const FORM_FEATURES = {
4
+ isol: "isol",
5
+ init: "init",
6
+ medi: "medi",
7
+ fina: "fina",
8
+ };
9
+ /**
10
+ * Shape a run, or `undefined` when there is nothing to do - so untouched documents keep their path.
11
+ *
12
+ * `rlig` (required ligatures - lam-alef, wrong when drawn apart) but not `liga`, which is
13
+ * discretionary and would change how existing Latin text looks.
14
+ */
15
+ export function shapeRun(codePoints, font) {
16
+ if (!needsJoining(codePoints))
17
+ return undefined;
18
+ const gsub = font.gsub();
19
+ if (!gsub || !gsub.hasScript("arab"))
20
+ return undefined;
21
+ const forms = joiningForms(codePoints);
22
+ let glyphs = codePoints.map((cp, i) => {
23
+ let glyph = font.getGlyphIndex(cp);
24
+ const form = forms[i];
25
+ if (form) {
26
+ // First lookup that covers the glyph wins. A missing `isol` is normal: the plain glyph already
27
+ // IS that form.
28
+ for (const lookup of gsub.lookups("arab", FORM_FEATURES[form])) {
29
+ const substituted = gsub.substituteSingle(lookup, glyph);
30
+ if (substituted !== null) {
31
+ glyph = substituted;
32
+ break;
33
+ }
34
+ }
35
+ }
36
+ return { glyph, advance: font.getAdvanceWidth(glyph), codePoints: [cp] };
37
+ });
38
+ glyphs = applyLigatures(glyphs, gsub, font);
39
+ return glyphs;
40
+ }
41
+ /** Collapse required ligatures, longest match first, carrying every component's code points along. */
42
+ function applyLigatures(glyphs, gsub, font) {
43
+ const lookups = gsub.lookups("arab", "rlig");
44
+ if (lookups.length === 0)
45
+ return glyphs;
46
+ const ids = glyphs.map((g) => g.glyph);
47
+ const out = [];
48
+ for (let i = 0; i < glyphs.length;) {
49
+ let applied = false;
50
+ for (const lookup of lookups) {
51
+ const match = gsub.substituteLigature(lookup, ids, i);
52
+ if (!match)
53
+ continue;
54
+ out.push({
55
+ glyph: match.glyph,
56
+ advance: font.getAdvanceWidth(match.glyph),
57
+ codePoints: glyphs.slice(i, i + match.consumed).flatMap((g) => g.codePoints),
58
+ });
59
+ i += match.consumed;
60
+ applied = true;
61
+ break;
62
+ }
63
+ if (!applied)
64
+ out.push(glyphs[i++]);
65
+ }
66
+ return out;
67
+ }
@@ -1,6 +1,19 @@
1
1
  import { Color } from "../common/color.js";
2
2
  import { FontStyle } from "../utils/pdf-object-manager.js";
3
3
  import { HorizontalAlignment } from "../elements/pdf-element.js";
4
+ import type { Direction } from "./bidi.js";
5
+ /** CSS `text-transform`. `capitalize` upper-cases the first letter of every word, as CSS does. */
6
+ export type TextTransform = "none" | "uppercase" | "lowercase" | "capitalize";
7
+ /**
8
+ * Apply a `text-transform`. One place, so the measured text and the drawn text are the same string.
9
+ *
10
+ * `atWordStart` carries the capitalisation state ACROSS pieces: a word split over two spans
11
+ * (`span("hel") + span("lo")`) is still one word, and only its first letter is raised.
12
+ */
13
+ export declare function applyTextTransform(text: string, transform: TextTransform, atWordStart?: boolean): {
14
+ text: string;
15
+ atWordStart: boolean;
16
+ };
4
17
  /**
5
18
  * The inheritable text properties - the same set CSS and Flutter cascade. A `Text` resolves each of
6
19
  * its own (possibly unset) properties against the nearest cascaded style: explicit > inherited >
@@ -10,6 +23,8 @@ import { HorizontalAlignment } from "../elements/pdf-element.js";
10
23
  export interface ResolvedTextStyle {
11
24
  fontSize: number;
12
25
  fontFamily: string;
26
+ /** The rest of the family stack: tried, in order, for a code point `fontFamily` cannot draw. */
27
+ fontFallback: string[];
13
28
  fontStyle: FontStyle;
14
29
  color: Color;
15
30
  textAlignment: HorizontalAlignment;
@@ -25,6 +40,16 @@ export interface ResolvedTextStyle {
25
40
  skipInk: boolean;
26
41
  /** Extra space after every glyph, in points (CSS `letter-spacing`). Default 0. */
27
42
  letterSpacing: number;
43
+ /** Base writing direction (CSS `direction`). It decides where a line STARTS and how neutral
44
+ * characters between two scripts resolve; the reordering itself follows Unicode UAX #9 either
45
+ * way, so Hebrew inside an `ltr` paragraph still comes out right. */
46
+ direction: Direction;
47
+ /** CSS `text-transform`: recase the text before it is measured or drawn. */
48
+ textTransform: TextTransform;
49
+ /** CSS `word-spacing`, in points: extra advance at every space. Negative tightens. */
50
+ wordSpacing: number;
51
+ /** CSS `text-indent`, in points: how far the FIRST line of a paragraph starts in. */
52
+ textIndent: number;
28
53
  }
29
54
  /**
30
55
  * The root of the cascade: what a `Text` falls back to when neither it nor any ancestor sets a
@@ -1,6 +1,39 @@
1
1
  import { Color } from "../common/color.js";
2
2
  import { FontStyle } from "../utils/pdf-object-manager.js";
3
3
  import { HorizontalAlignment } from "../elements/pdf-element.js";
4
+ /**
5
+ * Apply a `text-transform`. One place, so the measured text and the drawn text are the same string.
6
+ *
7
+ * `atWordStart` carries the capitalisation state ACROSS pieces: a word split over two spans
8
+ * (`span("hel") + span("lo")`) is still one word, and only its first letter is raised.
9
+ */
10
+ export function applyTextTransform(text, transform, atWordStart = true) {
11
+ const ends = text === "" ? atWordStart : /\s$/u.test(text);
12
+ if (transform === "none")
13
+ return { text, atWordStart: ends };
14
+ if (transform === "uppercase")
15
+ return { text: text.toUpperCase(), atWordStart: ends };
16
+ if (transform === "lowercase")
17
+ return { text: text.toLowerCase(), atWordStart: ends };
18
+ // CSS raises the first typographic LETTER of every word. Punctuation is not one, so a leading
19
+ // bracket or quote does not consume the word start - `(hello)` becomes `(Hello)`, as in a browser.
20
+ let start = atWordStart;
21
+ let out = "";
22
+ for (const ch of text) {
23
+ if (/\s/u.test(ch)) {
24
+ out += ch;
25
+ start = true;
26
+ }
27
+ else if (start && /\p{L}|\p{N}/u.test(ch)) {
28
+ out += ch.toUpperCase();
29
+ start = false;
30
+ }
31
+ else {
32
+ out += ch;
33
+ }
34
+ }
35
+ return { text: out, atWordStart: start };
36
+ }
4
37
  /**
5
38
  * The root of the cascade: what a `Text` falls back to when neither it nor any ancestor sets a
6
39
  * property.
@@ -8,14 +41,19 @@ import { HorizontalAlignment } from "../elements/pdf-element.js";
8
41
  export const DEFAULT_TEXT_STYLE = {
9
42
  fontSize: 12,
10
43
  fontFamily: "Helvetica",
44
+ fontFallback: [],
11
45
  fontStyle: FontStyle.Normal,
12
46
  color: new Color(0, 0, 0),
13
- textAlignment: HorizontalAlignment.left,
47
+ textAlignment: HorizontalAlignment.start,
14
48
  lineHeight: undefined,
15
49
  underline: false,
16
50
  strikethrough: false,
17
51
  skipInk: false,
18
52
  letterSpacing: 0,
53
+ direction: "ltr",
54
+ textTransform: "none",
55
+ wordSpacing: 0,
56
+ textIndent: 0,
19
57
  };
20
58
  /** Layers a partial override onto a complete style; an unset (undefined) field keeps the base. */
21
59
  export function mergeTextStyle(base, override) {
@@ -24,6 +62,7 @@ export function mergeTextStyle(base, override) {
24
62
  return {
25
63
  fontSize: override.fontSize ?? base.fontSize,
26
64
  fontFamily: override.fontFamily ?? base.fontFamily,
65
+ fontFallback: override.fontFallback ?? base.fontFallback,
27
66
  fontStyle: override.fontStyle ?? base.fontStyle,
28
67
  color: override.color ?? base.color,
29
68
  textAlignment: override.textAlignment ?? base.textAlignment,
@@ -32,5 +71,9 @@ export function mergeTextStyle(base, override) {
32
71
  strikethrough: override.strikethrough ?? base.strikethrough,
33
72
  skipInk: override.skipInk ?? base.skipInk,
34
73
  letterSpacing: override.letterSpacing ?? base.letterSpacing,
74
+ direction: override.direction ?? base.direction,
75
+ textTransform: override.textTransform ?? base.textTransform,
76
+ wordSpacing: override.wordSpacing ?? base.wordSpacing,
77
+ textIndent: override.textIndent ?? base.textIndent,
35
78
  };
36
79
  }
@@ -2,10 +2,8 @@ import { PDFElement, LayoutContext } from "../elements/pdf-element.js";
2
2
  import { BoxConstraints, Offset, Size } from "../layout/box-constraints.js";
3
3
  /** Distribution of the children ALONG the stacking (main) axis when there is leftover
4
4
  * space and no flex child to absorb it. */
5
- export type MainAlign = "start" | "center" | "end" | "between" | "around";
6
- /** Position/size of each child ACROSS the cross axis. `stretch` fills the cross extent;
7
- * the others place the child at its natural cross size. */
8
- export type CrossAlign = "start" | "center" | "end" | "stretch";
5
+ export type { MainAlign, CrossAlign } from "../layout/alignment.js";
6
+ import type { CrossAlign, MainAlign } from "../layout/alignment.js";
9
7
  /**
10
8
  * Maps the abstract MAIN/CROSS axes onto concrete width/height + x/y, so one flex
11
9
  * algorithm drives both a vertical Column (main = height) and a horizontal Row
@@ -42,6 +40,12 @@ export interface FlexOptions {
42
40
  gap?: number;
43
41
  main?: MainAlign;
44
42
  cross?: CrossAlign;
43
+ /** Lay the children out along the main axis backwards (CSS `row-reverse` / `column-reverse`). */
44
+ reverse?: boolean;
45
+ /** Let the children flow onto further lines when they do not fit (CSS `flex-wrap: wrap`). */
46
+ wrap?: boolean;
47
+ /** How the BLOCK of wrapped lines sits across the axis (CSS `align-content`). Default `start`. */
48
+ alignContent?: MainAlign;
45
49
  }
46
50
  export declare class FlexLayoutHelper {
47
51
  /**
@@ -53,8 +57,24 @@ export declare class FlexLayoutHelper {
53
57
  * extent occupied. Vertical with `gap 0`, `main start`, `cross stretch` reproduces the
54
58
  * previous Column layout exactly.
55
59
  */
60
+ /**
61
+ * Lays out the children along `axis`, on one line or several.
62
+ *
63
+ * Without `wrap` this is the single-line engine and nothing about it changed - the call goes
64
+ * straight through, which is what keeps every existing document byte-identical. With `wrap` the
65
+ * children are split into lines first and the SAME engine runs once per line.
66
+ */
56
67
  static layout(children: PDFElement[], axis: FlexAxis, mainAvail: number, crossAvail: number, mainStart: number, crossOrigin: number, options: FlexOptions, ctx: LayoutContext): {
57
68
  mainUsed: number;
58
69
  crossUsed: number;
59
70
  };
71
+ /**
72
+ * The wrapping path: split into lines, lay each one out, then place the lines across the axis.
73
+ *
74
+ * Lines are laid out TWICE - once to learn how tall each is, once at its final cross position. That
75
+ * is the same measure-then-place shape the single-line engine already uses, and it is what
76
+ * `alignContent` needs: the block of lines can only be centred once its total is known.
77
+ */
78
+ private static layoutWrapped;
79
+ private static layoutLine;
60
80
  }