@jasy/pdf 1.0.0-alpha.1 → 1.0.0-alpha.11

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 (279) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +35 -21
  3. package/dist/api/args.d.ts +1 -1
  4. package/dist/api/args.js +2 -5
  5. package/dist/api/color.d.ts +4 -4
  6. package/dist/api/color.js +11 -17
  7. package/dist/api/content.d.ts +20 -13
  8. package/dist/api/content.js +44 -29
  9. package/dist/api/descriptor.d.ts +2 -2
  10. package/dist/api/descriptor.js +121 -31
  11. package/dist/api/dimension.d.ts +76 -0
  12. package/dist/api/dimension.js +43 -0
  13. package/dist/api/font-url.d.ts +28 -0
  14. package/dist/api/font-url.js +134 -0
  15. package/dist/api/forms.d.ts +347 -0
  16. package/dist/api/forms.js +189 -0
  17. package/dist/api/gradient.d.ts +53 -0
  18. package/dist/api/gradient.js +97 -0
  19. package/dist/api/index.d.ts +13 -8
  20. package/dist/api/index.js +13 -24
  21. package/dist/api/insets.d.ts +19 -12
  22. package/dist/api/insets.js +19 -12
  23. package/dist/api/layout.d.ts +149 -25
  24. package/dist/api/layout.js +182 -51
  25. package/dist/api/page-builder.d.ts +40 -0
  26. package/dist/api/page-builder.js +44 -0
  27. package/dist/api/structure.d.ts +107 -13
  28. package/dist/api/structure.js +187 -88
  29. package/dist/api/table.d.ts +5 -5
  30. package/dist/api/table.js +38 -26
  31. package/dist/api/text.d.ts +90 -4
  32. package/dist/api/text.js +116 -31
  33. package/dist/assets/font-data.d.ts +2 -0
  34. package/dist/assets/font-data.js +21 -0
  35. package/dist/assets/font-data.ts +37 -0
  36. package/dist/common/color.js +1 -5
  37. package/dist/constants/page-sizes.js +3 -6
  38. package/dist/constants/pdf-parts.js +1 -4
  39. package/dist/crypto/legacy-handler.d.ts +43 -0
  40. package/dist/crypto/legacy-handler.js +145 -0
  41. package/dist/crypto/security-handler.d.ts +62 -0
  42. package/dist/crypto/security-handler.js +155 -0
  43. package/dist/crypto/webcrypto.d.ts +11 -0
  44. package/dist/crypto/webcrypto.js +62 -0
  45. package/dist/edit/acroform-reader.d.ts +70 -0
  46. package/dist/edit/acroform-reader.js +126 -0
  47. package/dist/edit/appearance.d.ts +47 -0
  48. package/dist/edit/appearance.js +171 -0
  49. package/dist/edit/document.d.ts +145 -0
  50. package/dist/edit/document.js +642 -0
  51. package/dist/edit/fill.d.ts +65 -0
  52. package/dist/edit/fill.js +424 -0
  53. package/dist/edit/flatten.d.ts +54 -0
  54. package/dist/edit/flatten.js +462 -0
  55. package/dist/edit/index.d.ts +20 -0
  56. package/dist/edit/index.js +18 -0
  57. package/dist/edit/lexer.d.ts +30 -0
  58. package/dist/edit/lexer.js +285 -0
  59. package/dist/edit/objects.d.ts +68 -0
  60. package/dist/edit/objects.js +83 -0
  61. package/dist/edit/writer.d.ts +60 -0
  62. package/dist/edit/writer.js +283 -0
  63. package/dist/elements/container-element.d.ts +50 -5
  64. package/dist/elements/container-element.js +100 -39
  65. package/dist/elements/forms/checkbox-element.d.ts +51 -0
  66. package/dist/elements/forms/checkbox-element.js +48 -0
  67. package/dist/elements/forms/choice-element.d.ts +53 -0
  68. package/dist/elements/forms/choice-element.js +59 -0
  69. package/dist/elements/forms/push-button-element.d.ts +56 -0
  70. package/dist/elements/forms/push-button-element.js +49 -0
  71. package/dist/elements/forms/radio-element.d.ts +52 -0
  72. package/dist/elements/forms/radio-element.js +49 -0
  73. package/dist/elements/forms/signature-element.d.ts +54 -0
  74. package/dist/elements/forms/signature-element.js +47 -0
  75. package/dist/elements/forms/text-field-element.d.ts +61 -0
  76. package/dist/elements/forms/text-field-element.js +54 -0
  77. package/dist/elements/image-element.d.ts +58 -6
  78. package/dist/elements/image-element.js +145 -111
  79. package/dist/elements/index.d.ts +26 -10
  80. package/dist/elements/index.js +26 -28
  81. package/dist/elements/layout/anchor-element.d.ts +23 -0
  82. package/dist/elements/layout/anchor-element.js +27 -0
  83. package/dist/elements/layout/bookmark-element.d.ts +27 -0
  84. package/dist/elements/layout/bookmark-element.js +30 -0
  85. package/dist/elements/layout/default-text-style-element.d.ts +30 -0
  86. package/dist/elements/layout/default-text-style-element.js +47 -0
  87. package/dist/elements/layout/deferred-element.d.ts +3 -3
  88. package/dist/elements/layout/deferred-element.js +4 -8
  89. package/dist/elements/layout/expanded-element.d.ts +6 -4
  90. package/dist/elements/layout/expanded-element.js +22 -17
  91. package/dist/elements/layout/keep-together-element.d.ts +42 -0
  92. package/dist/elements/layout/keep-together-element.js +85 -0
  93. package/dist/elements/layout/link-element.d.ts +33 -0
  94. package/dist/elements/layout/link-element.js +39 -0
  95. package/dist/elements/layout/padding-element.d.ts +10 -5
  96. package/dist/elements/layout/padding-element.js +25 -16
  97. package/dist/elements/layout/page-break-element.d.ts +17 -0
  98. package/dist/elements/layout/page-break-element.js +24 -0
  99. package/dist/elements/layout/page-builder-element.d.ts +32 -0
  100. package/dist/elements/layout/page-builder-element.js +45 -0
  101. package/dist/elements/layout/positioned-element.d.ts +47 -0
  102. package/dist/elements/layout/positioned-element.js +73 -0
  103. package/dist/elements/layout/repeating-header-element.d.ts +3 -3
  104. package/dist/elements/layout/repeating-header-element.js +8 -12
  105. package/dist/elements/layout/rotated-box-element.d.ts +35 -0
  106. package/dist/elements/layout/rotated-box-element.js +54 -0
  107. package/dist/elements/layout/rotated-element.d.ts +32 -0
  108. package/dist/elements/layout/rotated-element.js +41 -0
  109. package/dist/elements/layout/sized-container-element.d.ts +2 -2
  110. package/dist/elements/layout/sized-container-element.js +6 -11
  111. package/dist/elements/layout/struct-group.d.ts +28 -0
  112. package/dist/elements/layout/struct-group.js +42 -0
  113. package/dist/elements/line-element.d.ts +3 -3
  114. package/dist/elements/line-element.js +5 -10
  115. package/dist/elements/page-element.d.ts +26 -6
  116. package/dist/elements/page-element.js +72 -27
  117. package/dist/elements/pdf-document-element.d.ts +10 -4
  118. package/dist/elements/pdf-document-element.js +11 -10
  119. package/dist/elements/pdf-element.d.ts +150 -4
  120. package/dist/elements/pdf-element.js +138 -20
  121. package/dist/elements/rectangle-element.d.ts +52 -11
  122. package/dist/elements/rectangle-element.js +129 -36
  123. package/dist/elements/row-element.d.ts +45 -4
  124. package/dist/elements/row-element.js +66 -18
  125. package/dist/elements/text-element.d.ts +139 -11
  126. package/dist/elements/text-element.js +234 -57
  127. package/dist/forms/acroform.d.ts +63 -0
  128. package/dist/forms/acroform.js +427 -0
  129. package/dist/forms/appearance.d.ts +58 -0
  130. package/dist/forms/appearance.js +204 -0
  131. package/dist/forms/field.d.ts +164 -0
  132. package/dist/forms/field.js +1 -0
  133. package/dist/forms/pdf.d.ts +19 -0
  134. package/dist/forms/pdf.js +28 -0
  135. package/dist/index.d.ts +3 -3
  136. package/dist/index.js +3 -19
  137. package/dist/ir/display-list.d.ts +197 -8
  138. package/dist/ir/display-list.js +8 -2
  139. package/dist/layout/alignment.d.ts +10 -0
  140. package/dist/layout/alignment.js +6 -0
  141. package/dist/layout/box-constraints.d.ts +61 -0
  142. package/dist/layout/box-constraints.js +88 -6
  143. package/dist/layout/collect-images.d.ts +9 -0
  144. package/dist/layout/collect-images.js +34 -0
  145. package/dist/layout/fragmentation.d.ts +20 -1
  146. package/dist/layout/fragmentation.js +74 -18
  147. package/dist/layout/insets.d.ts +28 -0
  148. package/dist/layout/insets.js +20 -0
  149. package/dist/platform/browser-fs.d.ts +2 -0
  150. package/dist/platform/browser-fs.js +9 -0
  151. package/dist/platform/browser-image.d.ts +5 -0
  152. package/dist/platform/browser-image.js +13 -0
  153. package/dist/platform/node-fs.d.ts +2 -0
  154. package/dist/platform/node-fs.js +10 -0
  155. package/dist/platform/node-image.d.ts +5 -0
  156. package/dist/platform/node-image.js +9 -0
  157. package/dist/renderer/anchor-renderer.d.ts +6 -0
  158. package/dist/renderer/anchor-renderer.js +11 -0
  159. package/dist/renderer/bookmark-renderer.d.ts +6 -0
  160. package/dist/renderer/bookmark-renderer.js +11 -0
  161. package/dist/renderer/container-renderer.d.ts +3 -3
  162. package/dist/renderer/container-renderer.js +11 -28
  163. package/dist/renderer/default-text-style-renderer.d.ts +6 -0
  164. package/dist/renderer/default-text-style-renderer.js +10 -0
  165. package/dist/renderer/deferred-renderer.d.ts +3 -3
  166. package/dist/renderer/deferred-renderer.js +8 -23
  167. package/dist/renderer/emoji-image.d.ts +9 -0
  168. package/dist/renderer/emoji-image.js +68 -0
  169. package/dist/renderer/expanded-renderer.d.ts +3 -3
  170. package/dist/renderer/expanded-renderer.js +6 -21
  171. package/dist/renderer/form-field-renderer.d.ts +20 -0
  172. package/dist/renderer/form-field-renderer.js +12 -0
  173. package/dist/renderer/image-renderer.d.ts +4 -4
  174. package/dist/renderer/image-renderer.js +86 -75
  175. package/dist/renderer/index.d.ts +10 -10
  176. package/dist/renderer/index.js +10 -26
  177. package/dist/renderer/keep-together-renderer.d.ts +10 -0
  178. package/dist/renderer/keep-together-renderer.js +12 -0
  179. package/dist/renderer/line-renderer.d.ts +3 -3
  180. package/dist/renderer/line-renderer.js +13 -28
  181. package/dist/renderer/link-renderer.d.ts +6 -0
  182. package/dist/renderer/link-renderer.js +11 -0
  183. package/dist/renderer/padding-renderer.d.ts +3 -3
  184. package/dist/renderer/padding-renderer.js +6 -21
  185. package/dist/renderer/page-break-renderer.d.ts +14 -0
  186. package/dist/renderer/page-break-renderer.js +15 -0
  187. package/dist/renderer/page-builder-renderer.d.ts +6 -0
  188. package/dist/renderer/page-builder-renderer.js +11 -0
  189. package/dist/renderer/page-renderer.d.ts +2 -2
  190. package/dist/renderer/page-renderer.js +115 -77
  191. package/dist/renderer/pdf-backend.d.ts +31 -4
  192. package/dist/renderer/pdf-backend.js +287 -49
  193. package/dist/renderer/pdf-config.js +4 -7
  194. package/dist/renderer/pdf-document-class.d.ts +5 -5
  195. package/dist/renderer/pdf-document-class.js +27 -41
  196. package/dist/renderer/pdf-document-renderer.d.ts +19 -10
  197. package/dist/renderer/pdf-document-renderer.js +135 -91
  198. package/dist/renderer/pdf-renderer.d.ts +2 -2
  199. package/dist/renderer/pdf-renderer.js +165 -90
  200. package/dist/renderer/positioned-renderer.d.ts +6 -0
  201. package/dist/renderer/positioned-renderer.js +10 -0
  202. package/dist/renderer/rectangle-renderer.d.ts +3 -3
  203. package/dist/renderer/rectangle-renderer.js +49 -44
  204. package/dist/renderer/repeating-header-renderer.d.ts +3 -3
  205. package/dist/renderer/repeating-header-renderer.js +10 -26
  206. package/dist/renderer/rotated-renderer.d.ts +7 -0
  207. package/dist/renderer/rotated-renderer.js +29 -0
  208. package/dist/renderer/row-renderer.d.ts +3 -3
  209. package/dist/renderer/row-renderer.js +11 -28
  210. package/dist/renderer/struct-group-renderer.d.ts +10 -0
  211. package/dist/renderer/struct-group-renderer.js +24 -0
  212. package/dist/renderer/text-renderer.d.ts +9 -5
  213. package/dist/renderer/text-renderer.js +437 -89
  214. package/dist/text/advance.d.ts +34 -0
  215. package/dist/text/advance.js +45 -0
  216. package/dist/text/arabic.d.ts +20 -0
  217. package/dist/text/arabic.js +101 -0
  218. package/dist/text/bidi.d.ts +33 -0
  219. package/dist/text/bidi.js +94 -0
  220. package/dist/text/emoji-codepoints.d.ts +1 -0
  221. package/dist/text/emoji-codepoints.js +29 -0
  222. package/dist/text/font-fallback.d.ts +25 -0
  223. package/dist/text/font-fallback.js +38 -0
  224. package/dist/text/glyph-coverage.d.ts +15 -0
  225. package/dist/text/glyph-coverage.js +77 -0
  226. package/dist/text/line-breaker.d.ts +50 -12
  227. package/dist/text/line-breaker.js +200 -54
  228. package/dist/text/line-metrics.d.ts +49 -0
  229. package/dist/text/line-metrics.js +57 -0
  230. package/dist/text/orphans-widows.d.ts +27 -0
  231. package/dist/text/orphans-widows.js +53 -0
  232. package/dist/text/shape.d.ts +29 -0
  233. package/dist/text/shape.js +67 -0
  234. package/dist/text/text-decoration.d.ts +50 -0
  235. package/dist/text/text-decoration.js +62 -0
  236. package/dist/text/text-style.d.ts +60 -0
  237. package/dist/text/text-style.js +79 -0
  238. package/dist/text/whitespace.d.ts +4 -0
  239. package/dist/text/whitespace.js +43 -0
  240. package/dist/utils/afm-parser.d.ts +34 -0
  241. package/dist/utils/afm-parser.js +76 -17
  242. package/dist/utils/bytes.d.ts +24 -0
  243. package/dist/utils/bytes.js +76 -0
  244. package/dist/utils/dest-registry.d.ts +22 -0
  245. package/dist/utils/dest-registry.js +32 -0
  246. package/dist/utils/flex-layout.d.ts +36 -9
  247. package/dist/utils/flex-layout.js +207 -30
  248. package/dist/utils/font-metrics.d.ts +26 -1
  249. package/dist/utils/font-metrics.js +1 -2
  250. package/dist/utils/font-path.js +3 -6
  251. package/dist/utils/gsub.d.ts +36 -0
  252. package/dist/utils/gsub.js +178 -0
  253. package/dist/utils/image-helper.d.ts +6 -5
  254. package/dist/utils/image-helper.js +101 -111
  255. package/dist/utils/inflate.d.ts +16 -0
  256. package/dist/utils/inflate.js +52 -0
  257. package/dist/utils/md5.d.ts +4 -0
  258. package/dist/utils/md5.js +79 -0
  259. package/dist/utils/outline.d.ts +21 -0
  260. package/dist/utils/outline.js +79 -0
  261. package/dist/utils/pdf-object-manager.d.ts +127 -8
  262. package/dist/utils/pdf-object-manager.js +615 -141
  263. package/dist/utils/renderer-registry.d.ts +13 -1
  264. package/dist/utils/renderer-registry.js +23 -6
  265. package/dist/utils/struct-tree.d.ts +44 -0
  266. package/dist/utils/struct-tree.js +129 -0
  267. package/dist/utils/ttf-parser.d.ts +134 -2
  268. package/dist/utils/ttf-parser.js +873 -50
  269. package/dist/utils/ttf-subsetter.d.ts +1 -1
  270. package/dist/utils/ttf-subsetter.js +40 -42
  271. package/dist/utils/ua-xmp.d.ts +9 -0
  272. package/dist/utils/ua-xmp.js +32 -0
  273. package/dist/utils/utf8-to-windows1252-encoder.d.ts +6 -0
  274. package/dist/utils/utf8-to-windows1252-encoder.js +11 -4
  275. package/dist/utils/woff.d.ts +10 -0
  276. package/dist/utils/woff.js +116 -0
  277. package/dist/validators/element-validator.d.ts +2 -2
  278. package/dist/validators/element-validator.js +31 -26
  279. package/package.json +22 -2
@@ -1,8 +1,20 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.wrapStringIntoLines = wrapStringIntoLines;
4
- exports.breakSegmentsIntoLines = breakSegmentsIntoLines;
5
- exports.segmentLinesToSegments = segmentLinesToSegments;
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;
15
+ /** Three ASCII dots, NOT the "…" glyph (U+2026): plain dots encode in every font - standard-14
16
+ * (WinAnsi) and any embedded TTF - whereas U+2026 needs a glyph the font may not carry. */
17
+ const ELLIPSIS = "...";
6
18
  /**
7
19
  * Break a plain string into lines that each fit within `maxWidth`, splitting on
8
20
  * spaces (greedy: a word stays on the current line unless it would overflow).
@@ -11,79 +23,168 @@ exports.segmentLinesToSegments = segmentLinesToSegments;
11
23
  * rendering call this, so they can never disagree. Depends only on `FontMetrics`,
12
24
  * not the PDF byte writer - the future fragmentation pass can reuse it.
13
25
  */
14
- function wrapStringIntoLines(text, fontFamily, fontSize, fontStyle, maxWidth, metrics) {
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
+ // A hard break makes this several lines; the natural width is the widest of them.
35
+ if (text.includes("\n"))
36
+ return Math.max(...text.split("\n").map((p) => singleLineWidth(p, font, metrics, letterSpacing, wordSpacing)));
37
+ const space = runAdvance(metrics, " ", font, letterSpacing) + wordSpacing;
38
+ return text
39
+ .split(" ")
40
+ .reduce((width, word, i) => i === 0
41
+ ? runAdvance(metrics, word, font, letterSpacing)
42
+ : width + (space + runAdvance(metrics, word, font, letterSpacing)), 0);
43
+ }
44
+ export function wrapStringIntoLines(text, fontFamily, fontSize, fontStyle, maxWidth, metrics, maxLines, overflow, letterSpacing = 0, options = {}) {
45
+ const { wordSpacing = 0, indent = 0, shrink = 0 } = options;
15
46
  let currentLine = "";
16
47
  let currentWidth = 0;
48
+ let gaps = 0; // spaces on the current line, which is what a justified line can squeeze
17
49
  const lines = [];
18
- const words = text.split(" ");
19
- words.forEach((word, index) => {
20
- const wordWidth = metrics.getStringWidth(word, fontFamily, fontSize, fontStyle);
21
- const spaceWidth = metrics.getCharWidth(" ", fontSize, undefined, fontFamily, fontStyle);
22
- if (currentWidth + wordWidth > maxWidth) {
50
+ const font = { fontFamily, fontSize, fontStyle };
51
+ // A `\n` is a HARD break: wrap each paragraph on its own, then cut. An EMPTY paragraph still has to
52
+ // produce a line, or a deliberate blank line between two blocks silently disappears.
53
+ text.split("\n").forEach((paragraph, paragraphIndex) => {
54
+ if (paragraphIndex > 0) {
23
55
  lines.push(currentLine.trim());
24
- currentLine = word;
25
- currentWidth = wordWidth;
26
- }
27
- else {
28
- currentLine += index === 0 ? word : " " + word;
29
- currentWidth += wordWidth + spaceWidth;
56
+ currentLine = "";
57
+ currentWidth = 0;
58
+ gaps = 0;
30
59
  }
60
+ const words = paragraph.split(" ");
61
+ words.forEach((word) => {
62
+ // Word and space advances come from the one shared primitive (`advance.ts`), the same one
63
+ // `naturalWidth` uses - so a bounded and an unbounded layout of the same text agree bit for bit.
64
+ const wordWidth = runAdvance(metrics, word, font, letterSpacing);
65
+ const spaceWidth = runAdvance(metrics, " ", font, letterSpacing) + wordSpacing;
66
+ // The FIRST line has the indent taken out of its room; every later one gets the full box.
67
+ const room = lines.length === 0 ? maxWidth - indent : maxWidth;
68
+ // Break before a word that won't fit - counting the SPACE that would join it, which the old test
69
+ // forgot. It went unnoticed on the first line, where the very first word added a space too many
70
+ // and cancelled the error out; after a break `currentWidth` is the bare word, so every following
71
+ // test was short by one space and the line could overrun its box by that much.
72
+ //
73
+ // A single word wider than maxWidth still sits on its (empty) line and overflows, rather than
74
+ // pushing a phantom empty line before it (which would over-count the height by a line).
75
+ const candidate = currentWidth + (spaceWidth + wordWidth);
76
+ if (currentLine === "") {
77
+ currentLine = word;
78
+ currentWidth = wordWidth;
79
+ gaps = 0;
80
+ // How much a JUSTIFIED line may be squeezed to keep one more word: the spaces already on the
81
+ // line plus the one that would join it, each giving up at most `shrink` of its width. Zero for
82
+ // every other alignment, where a squeezed line would simply overflow instead.
83
+ // ONE expression, used for the test AND for the running total. Adding the same three numbers in
84
+ // a different order gives a different last bit, and a line that lands exactly on `maxWidth` is
85
+ // then broken by that bit alone - which is how a footer sized to its own text wrapped.
86
+ }
87
+ else if (candidate - (gaps + 1) * spaceWidth * shrink > room) {
88
+ lines.push(currentLine.trim());
89
+ currentLine = word;
90
+ currentWidth = wordWidth;
91
+ gaps = 0;
92
+ }
93
+ else {
94
+ currentLine += " " + word;
95
+ currentWidth = candidate;
96
+ gaps += 1;
97
+ }
98
+ });
31
99
  });
32
100
  if (currentLine)
33
101
  lines.push(currentLine.trim());
34
- return lines;
102
+ // Open-end by default; cap only when maxLines is set (the others get undefined → untouched).
103
+ if (maxLines == null || lines.length <= maxLines)
104
+ return lines;
105
+ const kept = lines.slice(0, maxLines);
106
+ if (overflow === "ellipsis") {
107
+ const last = kept.length - 1;
108
+ kept[last] = ellipsize(kept[last], fontFamily, fontSize, fontStyle,
109
+ // The same room that line was broken against: the first one lost the indent to it.
110
+ last === 0 ? maxWidth - indent : maxWidth, metrics, letterSpacing, wordSpacing);
111
+ }
112
+ return kept;
35
113
  }
36
114
  /**
37
- * Break styled segments into lines that fit within `maxWidth`. Same greedy
38
- * word-splitting as the string breaker, but each line records the tallest font on
39
- * THAT line as its leading (per-line, not a paragraph-global maximum). Single source
40
- * of truth: both height measurement and rendering call this.
115
+ * Break styled segments into lines that fit within `maxWidth`. Same greedy word-splitting as the
116
+ * string breaker; each line keeps the segments that landed on it, which is what `line-metrics.ts`
117
+ * later derives the line's height from. Single source of truth: both height measurement and
118
+ * rendering call this.
41
119
  */
42
- function breakSegmentsIntoLines(segments, defaults, maxWidth, metrics) {
120
+ export function breakSegmentsIntoLines(segments, defaults, maxWidth, metrics, maxLines, overflow) {
43
121
  const lines = [];
44
122
  let width = 0;
45
- let maxFontSize = 0; // per line: starts at 0, grows to the tallest font on the line
46
123
  let lineSegments = [];
47
124
  let combined = "";
48
125
  segments.forEach((segment) => {
49
126
  const family = segment.fontFamily || defaults.fontFamily;
50
127
  const size = segment.fontSize || defaults.fontSize;
51
128
  const style = segment.fontStyle || defaults.fontStyle;
52
- const spaceWidth = metrics.getCharWidth(" ", size, undefined, family, style);
53
- const words = segment.content.split(" ");
54
- // Start this segment's piece empty; its content is filled word-by-word below. (Not
55
- // the original content - otherwise a segment whose FIRST word overflows would carry
56
- // its whole text into the line that just closed.)
57
- lineSegments.push(Object.assign(Object.assign({}, segment), { fontFamily: family, content: "" }));
58
- combined = "";
59
- if (maxFontSize < size)
60
- maxFontSize = size;
61
- words.forEach((word, wordIndex) => {
62
- const wordWidth = metrics.getStringWidth(word, family, size, style);
63
- if (width + wordWidth > maxWidth) {
64
- lines.push({ segments: lineSegments, width, maxFontSize });
65
- // Start the next line; its leading resets to the wrapping segment's size.
129
+ const letterSpacing = segment.letterSpacing ?? defaults.letterSpacing ?? 0;
130
+ const font = { fontFamily: family, fontSize: size, fontStyle: style };
131
+ const spaceWidth = runAdvance(metrics, " ", font, letterSpacing);
132
+ // A `\n` inside a span is a HARD break, exactly as in the string path: close the line where it
133
+ // sits, whatever else was already on it, and start the next one with this segment's style.
134
+ segment.content.split("\n").forEach((paragraph, paragraphIndex) => {
135
+ if (paragraphIndex > 0) {
136
+ lines.push({ segments: lineSegments, width });
66
137
  width = 0;
67
- maxFontSize = size;
68
138
  lineSegments = [];
69
- combined = word;
70
- width += wordWidth + spaceWidth;
71
- lineSegments.push(Object.assign(Object.assign({}, segment), { content: combined }));
72
139
  }
73
- else {
74
- combined += wordIndex === 0 ? word : " " + word;
75
- width += wordWidth + spaceWidth;
76
- if (lineSegments.length === 0) {
77
- lineSegments.push(Object.assign(Object.assign({}, segment), { fontFamily: family, content: combined }));
140
+ const words = paragraph.split(" ");
141
+ // Start this segment's piece empty; its content is filled word-by-word below. (Not
142
+ // the original content - otherwise a segment whose FIRST word overflows would carry
143
+ // its whole text into the line that just closed.)
144
+ lineSegments.push({ ...segment, fontFamily: family, content: "" });
145
+ combined = "";
146
+ words.forEach((word, wordIndex) => {
147
+ const wordWidth = runAdvance(metrics, word, font, letterSpacing);
148
+ // A space joins this word to what precedes it only INSIDE a segment; segments butt together
149
+ // with nothing between them, which is what `span("a") + span("b")` draws.
150
+ const joiner = wordIndex > 0 ? spaceWidth : 0;
151
+ // ONE expression for the test and for the running total, grouped exactly as `singleLineWidth`
152
+ // groups it - see the note there. A width built any other way can be a bit off the one the box
153
+ // was sized with, and the text wraps inside a box made to hold it.
154
+ const candidate = width + (joiner + wordWidth);
155
+ // Same guard as the string path: don't open a phantom empty line for an over-wide first word -
156
+ // place it (overflowing) on the current empty line instead.
157
+ if (candidate > maxWidth && width > 0) {
158
+ lines.push({ segments: lineSegments, width });
159
+ width = wordWidth;
160
+ lineSegments = [];
161
+ combined = word;
162
+ lineSegments.push({ ...segment, content: combined });
78
163
  }
79
- lineSegments[lineSegments.length - 1].content = combined;
80
- }
164
+ else {
165
+ combined += wordIndex === 0 ? word : " " + word;
166
+ width = candidate;
167
+ if (lineSegments.length === 0) {
168
+ lineSegments.push({ ...segment, fontFamily: family, content: combined });
169
+ }
170
+ lineSegments[lineSegments.length - 1].content = combined;
171
+ }
172
+ });
81
173
  });
82
174
  });
83
- if (lineSegments.length > 0) {
84
- lines.push({ segments: lineSegments, width, maxFontSize });
175
+ // Only if something actually landed on it. The string path ends with `if (currentLine)`, so a text
176
+ // ending in `\n` is ONE line there - the two must not disagree about the same text. An empty
177
+ // paragraph in the MIDDLE still becomes a line; that one is pushed at the break, not here.
178
+ if (lineSegments.some((seg) => seg.content !== "")) {
179
+ lines.push({ segments: lineSegments, width });
85
180
  }
86
- return lines;
181
+ // Open-end by default; cap only when maxLines is set.
182
+ if (maxLines == null || lines.length <= maxLines)
183
+ return lines;
184
+ const kept = lines.slice(0, maxLines);
185
+ if (overflow === "ellipsis")
186
+ ellipsizeSegmentLine(kept[kept.length - 1], defaults, maxWidth, metrics);
187
+ return kept;
87
188
  }
88
189
  /**
89
190
  * Inverse of `breakSegmentsIntoLines`: flatten broken lines back into a `TextSegment[]`
@@ -92,10 +193,10 @@ function breakSegmentsIntoLines(segments, defaults, maxWidth, metrics) {
92
193
  * last word of a line and the first of the next would fuse ("a b" + "c d" -> "a bc d").
93
194
  * Used by text fragmentation to rebuild the fitted/remainder halves of a split paragraph.
94
195
  */
95
- function segmentLinesToSegments(lines) {
196
+ export function segmentLinesToSegments(lines) {
96
197
  const result = [];
97
198
  lines.forEach((line, lineIndex) => {
98
- line.segments.forEach((segment) => result.push(Object.assign({}, segment)));
199
+ line.segments.forEach((segment) => result.push({ ...segment }));
99
200
  if (lineIndex < lines.length - 1) {
100
201
  const last = result[result.length - 1];
101
202
  if (last && !last.content.endsWith(" "))
@@ -104,3 +205,48 @@ function segmentLinesToSegments(lines) {
104
205
  });
105
206
  return result;
106
207
  }
208
+ /** Appends "…" to a single line, dropping trailing words (then characters) until the line plus the
209
+ * ellipsis fits `maxWidth`. Falls back to a bare "…" if not even one character fits. */
210
+ function ellipsize(line, fontFamily, fontSize, fontStyle, maxWidth, metrics, letterSpacing = 0, wordSpacing = 0) {
211
+ const font = { fontFamily, fontSize, fontStyle };
212
+ // Measured through `singleLineWidth`, so the ellipsised line is judged by the same sum that decides
213
+ // every other line - word-spacing included.
214
+ const fits = (s) => singleLineWidth(s + ELLIPSIS, font, metrics, letterSpacing, wordSpacing) <= maxWidth;
215
+ if (fits(line))
216
+ return line + ELLIPSIS;
217
+ const words = line.split(" ");
218
+ while (words.length > 1) {
219
+ words.pop();
220
+ if (fits(words.join(" ")))
221
+ return words.join(" ") + ELLIPSIS;
222
+ }
223
+ // Drop one code point at a time (not one UTF-16 unit) so an astral char is never split into a
224
+ // lone surrogate. `chars` is the code-point view; rebuild the string from its shrinking prefix.
225
+ const chars = [...(words[0] ?? "")];
226
+ while (chars.length > 1) {
227
+ chars.pop();
228
+ const single = chars.join("");
229
+ if (fits(single))
230
+ return single + ELLIPSIS;
231
+ }
232
+ return ELLIPSIS;
233
+ }
234
+ /** Ellipsizes the LAST segment of a truncated segment line in place (within the width left by the
235
+ * segments before it) and recomputes the line width. */
236
+ function ellipsizeSegmentLine(line, defaults, maxWidth, metrics) {
237
+ const segs = line.segments;
238
+ if (segs.length === 0)
239
+ return;
240
+ const lsOf = (seg) => seg.letterSpacing ?? defaults.letterSpacing ?? 0;
241
+ const widthOf = (seg) => runAdvance(metrics, seg.content, {
242
+ fontFamily: seg.fontFamily || defaults.fontFamily,
243
+ fontSize: seg.fontSize || defaults.fontSize,
244
+ fontStyle: seg.fontStyle || defaults.fontStyle,
245
+ }, lsOf(seg));
246
+ let prefix = 0;
247
+ for (let i = 0; i < segs.length - 1; i++)
248
+ prefix += widthOf(segs[i]);
249
+ const last = segs[segs.length - 1];
250
+ last.content = ellipsize(last.content, last.fontFamily || defaults.fontFamily, last.fontSize || defaults.fontSize, last.fontStyle || defaults.fontStyle, maxWidth - prefix, metrics, lsOf(last));
251
+ line.width = prefix + widthOf(last);
252
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The vertical half of text layout: how tall a line box is, and where its baseline sits.
3
+ *
4
+ * One canonical answer, like `line-breaker.ts` is the one canonical answer for the horizontal
5
+ * half. Measuring (`calculateTextHeight`), fragmenting (`TextElement.fragment`) and drawing
6
+ * (`TextRenderer._buildRuns`) all call in here, so they can never disagree about a line's height
7
+ * again - the divergence that produced ISSUE-5.
8
+ */
9
+ import type { FontStyle } from "../utils/pdf-object-manager.js";
10
+ import type { FontMetrics } from "../utils/font-metrics.js";
11
+ import type { SegmentDefaults, SegmentLine } from "./line-breaker.js";
12
+ /** A font's vertical metrics, as fractions of the em (so they scale by multiplying with fontSize). */
13
+ export interface FontVerticals {
14
+ /** Distance from the baseline up to the ascender. Positive. */
15
+ ascent: number;
16
+ /** Distance from the baseline down to the descender. Positive (unlike the AFM/hhea sign). */
17
+ descent: number;
18
+ /** Extra leading the font asks for between two lines. Often 0 for a TrueType face. */
19
+ lineGap: number;
20
+ }
21
+ /** One font used on a line: its metrics and the size it is set at. */
22
+ export interface LinePart {
23
+ verticals: FontVerticals;
24
+ fontSize: number;
25
+ }
26
+ /** A laid-out line box, in points, relative to the top of the box. */
27
+ export interface LineBox {
28
+ /** Total height of the line box. Lines stack by this. */
29
+ height: number;
30
+ /** Distance from the top of the line box down to the baseline. */
31
+ baseline: number;
32
+ }
33
+ /**
34
+ * The line box for a set of fonts sharing one line.
35
+ *
36
+ * `lineHeight` is a multiplier of the font size, like CSS. Leave it `undefined` for the font's
37
+ * NATURAL line height (`ascent + descent + lineGap`), which is what CSS calls `line-height: normal`.
38
+ *
39
+ * Whatever is left over between the box and the font's content is split evenly above and below
40
+ * ("half-leading", as CSS and Flutter do), so the glyphs sit centred in their own box. An explicit
41
+ * `lineHeight` smaller than the content makes the leading negative and the glyphs overflow - again
42
+ * exactly like CSS, and the author asked for it.
43
+ */
44
+ export declare function lineBoxFor(parts: LinePart[], lineHeight?: number): LineBox;
45
+ /** The line box of a single-font line (the plain-string path). */
46
+ export declare function lineBoxForString(metrics: FontMetrics, fontFamily: string, fontStyle: FontStyle, fontSize: number, lineHeight?: number): LineBox;
47
+ /** The line box of a mixed-font line: the tallest ascent and the deepest descent on it win, so a
48
+ * big or a deep-descending span pushes the whole line apart rather than colliding with it. */
49
+ export declare function lineBoxForSegmentLine(line: SegmentLine, defaults: SegmentDefaults, metrics: FontMetrics, lineHeight?: number): LineBox;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The vertical half of text layout: how tall a line box is, and where its baseline sits.
3
+ *
4
+ * One canonical answer, like `line-breaker.ts` is the one canonical answer for the horizontal
5
+ * half. Measuring (`calculateTextHeight`), fragmenting (`TextElement.fragment`) and drawing
6
+ * (`TextRenderer._buildRuns`) all call in here, so they can never disagree about a line's height
7
+ * again - the divergence that produced ISSUE-5.
8
+ */
9
+ /**
10
+ * The line box for a set of fonts sharing one line.
11
+ *
12
+ * `lineHeight` is a multiplier of the font size, like CSS. Leave it `undefined` for the font's
13
+ * NATURAL line height (`ascent + descent + lineGap`), which is what CSS calls `line-height: normal`.
14
+ *
15
+ * Whatever is left over between the box and the font's content is split evenly above and below
16
+ * ("half-leading", as CSS and Flutter do), so the glyphs sit centred in their own box. An explicit
17
+ * `lineHeight` smaller than the content makes the leading negative and the glyphs overflow - again
18
+ * exactly like CSS, and the author asked for it.
19
+ */
20
+ export function lineBoxFor(parts, lineHeight) {
21
+ if (parts.length === 0)
22
+ return { height: 0, baseline: 0 };
23
+ let ascent = 0;
24
+ let descent = 0;
25
+ let gap = 0;
26
+ let maxFontSize = 0;
27
+ for (const { verticals, fontSize } of parts) {
28
+ ascent = Math.max(ascent, verticals.ascent * fontSize);
29
+ descent = Math.max(descent, verticals.descent * fontSize);
30
+ gap = Math.max(gap, verticals.lineGap * fontSize);
31
+ maxFontSize = Math.max(maxFontSize, fontSize);
32
+ }
33
+ const content = ascent + descent;
34
+ const height = lineHeight === undefined ? content + gap : maxFontSize * lineHeight;
35
+ return { height, baseline: (height - content) / 2 + ascent };
36
+ }
37
+ /** The line box of a single-font line (the plain-string path). */
38
+ export function lineBoxForString(metrics, fontFamily, fontStyle, fontSize, lineHeight) {
39
+ return lineBoxFor([{ verticals: metrics.getFontVerticals(fontFamily, fontStyle), fontSize }], lineHeight);
40
+ }
41
+ /** The line box of a mixed-font line: the tallest ascent and the deepest descent on it win, so a
42
+ * big or a deep-descending span pushes the whole line apart rather than colliding with it. */
43
+ export function lineBoxForSegmentLine(line, defaults, metrics, lineHeight) {
44
+ const parts = line.segments.map((segment) => {
45
+ const fontFamily = segment.fontFamily ?? defaults.fontFamily;
46
+ const fontStyle = segment.fontStyle ?? defaults.fontStyle;
47
+ return {
48
+ verticals: metrics.getFontVerticals(fontFamily, fontStyle),
49
+ fontSize: segment.fontSize ?? defaults.fontSize,
50
+ };
51
+ });
52
+ // A line with no segments still occupies its default font's box (an empty paragraph keeps height).
53
+ if (parts.length === 0) {
54
+ return lineBoxForString(metrics, defaults.fontFamily, defaults.fontStyle, defaults.fontSize, lineHeight);
55
+ }
56
+ return lineBoxFor(parts, lineHeight);
57
+ }
@@ -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
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Where an underline and a strikethrough sit, and how thick they are.
3
+ *
4
+ * These are GLYPH metrics, and they are deliberately NOT part of `FontVerticals` in
5
+ * `line-metrics.ts`, which holds LINE metrics. Confusing the two is exactly what produced ISSUE-5:
6
+ * the AFM's `Ascender` (the height of a `b`) was used to seat a baseline, a job that belongs to the
7
+ * `FontBBox`. Keep the two kinds of number in two places so they cannot be mixed up again.
8
+ *
9
+ * Every number here comes out of the font: the standard-14 AFM header, or a TrueType `post` +
10
+ * `OS/2` table. Nothing is invented. Verified against `google-chrome --headless --print-to-pdf` on
11
+ * the same font: Chrome's strikethrough centre lands on `xHeight / 2` (measured 0.260 em vs the
12
+ * font's 0.262), and its underline stroke with `text-decoration-thickness: from-font` is the font's
13
+ * `underlineThickness` (0.048 em vs 0.050). Chrome does NOT use the font's underline POSITION - it
14
+ * has its own offset - so we follow the font there and land within 0.02 em of it.
15
+ */
16
+ /** A font's decoration metrics, as fractions of the em. */
17
+ export interface FontDecoration {
18
+ /** Distance BELOW the baseline to the CENTRE of the underline stroke. Positive.
19
+ * (The AFM defines it as "the distance from the baseline for centering underlining strokes";
20
+ * FreeType reads TrueType's `post.underlinePosition` the same way.) */
21
+ underlinePosition: number;
22
+ /** Stroke thickness, used by both the underline and the strikethrough. */
23
+ underlineThickness: number;
24
+ /** Height of a lowercase `x` above the baseline. Sets where a strikethrough crosses. */
25
+ xHeight: number;
26
+ /** Height of a capital above the baseline. */
27
+ capHeight: number;
28
+ }
29
+ /** A decoration stroke: the y of its centre line, and how thick it is. Points, engine coords. */
30
+ export interface DecorationStroke {
31
+ y: number;
32
+ thickness: number;
33
+ }
34
+ /** The underline for a run whose baseline sits at `baselineY` (engine coords, y grows down). */
35
+ export declare function underlineStroke(decoration: FontDecoration, fontSize: number, baselineY: number): DecorationStroke;
36
+ /**
37
+ * The strikethrough for a run whose baseline sits at `baselineY`. It crosses at half the x-height,
38
+ * which is what a browser does and what looks right: through the middle of the lowercase letters,
39
+ * not through the middle of the capitals.
40
+ */
41
+ export declare function strikethroughStroke(decoration: FontDecoration, fontSize: number, baselineY: number): DecorationStroke;
42
+ /**
43
+ * Cuts `[0, runWidth]` at every place the glyphs put ink, leaving the segments an underline should
44
+ * actually draw. `inkSpans` are x-intervals in points from the start of the run.
45
+ *
46
+ * This is CSS `text-decoration-skip-ink`, and it needs the real glyph outlines - so it only works
47
+ * for an embedded font. Segments narrower than the padding are dropped: a sliver of a line between
48
+ * two descenders reads as dirt, not as an underline.
49
+ */
50
+ export declare function skipInkSegments(runWidth: number, inkSpans: Array<[number, number]>, thickness: number): Array<[number, number]>;