@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.
- package/LICENSE +21 -0
- package/README.md +35 -21
- package/dist/api/args.d.ts +1 -1
- package/dist/api/args.js +2 -5
- package/dist/api/color.d.ts +4 -4
- package/dist/api/color.js +11 -17
- package/dist/api/content.d.ts +20 -13
- package/dist/api/content.js +44 -29
- package/dist/api/descriptor.d.ts +2 -2
- package/dist/api/descriptor.js +121 -31
- package/dist/api/dimension.d.ts +76 -0
- package/dist/api/dimension.js +43 -0
- package/dist/api/font-url.d.ts +28 -0
- package/dist/api/font-url.js +134 -0
- package/dist/api/forms.d.ts +347 -0
- package/dist/api/forms.js +189 -0
- package/dist/api/gradient.d.ts +53 -0
- package/dist/api/gradient.js +97 -0
- package/dist/api/index.d.ts +13 -8
- package/dist/api/index.js +13 -24
- package/dist/api/insets.d.ts +19 -12
- package/dist/api/insets.js +19 -12
- package/dist/api/layout.d.ts +149 -25
- package/dist/api/layout.js +182 -51
- package/dist/api/page-builder.d.ts +40 -0
- package/dist/api/page-builder.js +44 -0
- package/dist/api/structure.d.ts +107 -13
- package/dist/api/structure.js +187 -88
- package/dist/api/table.d.ts +5 -5
- package/dist/api/table.js +38 -26
- package/dist/api/text.d.ts +90 -4
- package/dist/api/text.js +116 -31
- package/dist/assets/font-data.d.ts +2 -0
- package/dist/assets/font-data.js +21 -0
- package/dist/assets/font-data.ts +37 -0
- package/dist/common/color.js +1 -5
- package/dist/constants/page-sizes.js +3 -6
- package/dist/constants/pdf-parts.js +1 -4
- package/dist/crypto/legacy-handler.d.ts +43 -0
- package/dist/crypto/legacy-handler.js +145 -0
- package/dist/crypto/security-handler.d.ts +62 -0
- package/dist/crypto/security-handler.js +155 -0
- package/dist/crypto/webcrypto.d.ts +11 -0
- package/dist/crypto/webcrypto.js +62 -0
- package/dist/edit/acroform-reader.d.ts +70 -0
- package/dist/edit/acroform-reader.js +126 -0
- package/dist/edit/appearance.d.ts +47 -0
- package/dist/edit/appearance.js +171 -0
- package/dist/edit/document.d.ts +145 -0
- package/dist/edit/document.js +642 -0
- package/dist/edit/fill.d.ts +65 -0
- package/dist/edit/fill.js +424 -0
- package/dist/edit/flatten.d.ts +54 -0
- package/dist/edit/flatten.js +462 -0
- package/dist/edit/index.d.ts +20 -0
- package/dist/edit/index.js +18 -0
- package/dist/edit/lexer.d.ts +30 -0
- package/dist/edit/lexer.js +285 -0
- package/dist/edit/objects.d.ts +68 -0
- package/dist/edit/objects.js +83 -0
- package/dist/edit/writer.d.ts +60 -0
- package/dist/edit/writer.js +283 -0
- package/dist/elements/container-element.d.ts +50 -5
- package/dist/elements/container-element.js +100 -39
- package/dist/elements/forms/checkbox-element.d.ts +51 -0
- package/dist/elements/forms/checkbox-element.js +48 -0
- package/dist/elements/forms/choice-element.d.ts +53 -0
- package/dist/elements/forms/choice-element.js +59 -0
- package/dist/elements/forms/push-button-element.d.ts +56 -0
- package/dist/elements/forms/push-button-element.js +49 -0
- package/dist/elements/forms/radio-element.d.ts +52 -0
- package/dist/elements/forms/radio-element.js +49 -0
- package/dist/elements/forms/signature-element.d.ts +54 -0
- package/dist/elements/forms/signature-element.js +47 -0
- package/dist/elements/forms/text-field-element.d.ts +61 -0
- package/dist/elements/forms/text-field-element.js +54 -0
- package/dist/elements/image-element.d.ts +58 -6
- package/dist/elements/image-element.js +145 -111
- package/dist/elements/index.d.ts +26 -10
- package/dist/elements/index.js +26 -28
- package/dist/elements/layout/anchor-element.d.ts +23 -0
- package/dist/elements/layout/anchor-element.js +27 -0
- package/dist/elements/layout/bookmark-element.d.ts +27 -0
- package/dist/elements/layout/bookmark-element.js +30 -0
- package/dist/elements/layout/default-text-style-element.d.ts +30 -0
- package/dist/elements/layout/default-text-style-element.js +47 -0
- package/dist/elements/layout/deferred-element.d.ts +3 -3
- package/dist/elements/layout/deferred-element.js +4 -8
- package/dist/elements/layout/expanded-element.d.ts +6 -4
- package/dist/elements/layout/expanded-element.js +22 -17
- package/dist/elements/layout/keep-together-element.d.ts +42 -0
- package/dist/elements/layout/keep-together-element.js +85 -0
- package/dist/elements/layout/link-element.d.ts +33 -0
- package/dist/elements/layout/link-element.js +39 -0
- package/dist/elements/layout/padding-element.d.ts +10 -5
- package/dist/elements/layout/padding-element.js +25 -16
- package/dist/elements/layout/page-break-element.d.ts +17 -0
- package/dist/elements/layout/page-break-element.js +24 -0
- package/dist/elements/layout/page-builder-element.d.ts +32 -0
- package/dist/elements/layout/page-builder-element.js +45 -0
- package/dist/elements/layout/positioned-element.d.ts +47 -0
- package/dist/elements/layout/positioned-element.js +73 -0
- package/dist/elements/layout/repeating-header-element.d.ts +3 -3
- package/dist/elements/layout/repeating-header-element.js +8 -12
- package/dist/elements/layout/rotated-box-element.d.ts +35 -0
- package/dist/elements/layout/rotated-box-element.js +54 -0
- package/dist/elements/layout/rotated-element.d.ts +32 -0
- package/dist/elements/layout/rotated-element.js +41 -0
- package/dist/elements/layout/sized-container-element.d.ts +2 -2
- package/dist/elements/layout/sized-container-element.js +6 -11
- package/dist/elements/layout/struct-group.d.ts +28 -0
- package/dist/elements/layout/struct-group.js +42 -0
- package/dist/elements/line-element.d.ts +3 -3
- package/dist/elements/line-element.js +5 -10
- package/dist/elements/page-element.d.ts +26 -6
- package/dist/elements/page-element.js +72 -27
- package/dist/elements/pdf-document-element.d.ts +10 -4
- package/dist/elements/pdf-document-element.js +11 -10
- package/dist/elements/pdf-element.d.ts +150 -4
- package/dist/elements/pdf-element.js +138 -20
- package/dist/elements/rectangle-element.d.ts +52 -11
- package/dist/elements/rectangle-element.js +129 -36
- package/dist/elements/row-element.d.ts +45 -4
- package/dist/elements/row-element.js +66 -18
- package/dist/elements/text-element.d.ts +139 -11
- package/dist/elements/text-element.js +234 -57
- package/dist/forms/acroform.d.ts +63 -0
- package/dist/forms/acroform.js +427 -0
- package/dist/forms/appearance.d.ts +58 -0
- package/dist/forms/appearance.js +204 -0
- package/dist/forms/field.d.ts +164 -0
- package/dist/forms/field.js +1 -0
- package/dist/forms/pdf.d.ts +19 -0
- package/dist/forms/pdf.js +28 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -19
- package/dist/ir/display-list.d.ts +197 -8
- package/dist/ir/display-list.js +8 -2
- package/dist/layout/alignment.d.ts +10 -0
- package/dist/layout/alignment.js +6 -0
- package/dist/layout/box-constraints.d.ts +61 -0
- package/dist/layout/box-constraints.js +88 -6
- package/dist/layout/collect-images.d.ts +9 -0
- package/dist/layout/collect-images.js +34 -0
- package/dist/layout/fragmentation.d.ts +20 -1
- package/dist/layout/fragmentation.js +74 -18
- package/dist/layout/insets.d.ts +28 -0
- package/dist/layout/insets.js +20 -0
- package/dist/platform/browser-fs.d.ts +2 -0
- package/dist/platform/browser-fs.js +9 -0
- package/dist/platform/browser-image.d.ts +5 -0
- package/dist/platform/browser-image.js +13 -0
- package/dist/platform/node-fs.d.ts +2 -0
- package/dist/platform/node-fs.js +10 -0
- package/dist/platform/node-image.d.ts +5 -0
- package/dist/platform/node-image.js +9 -0
- package/dist/renderer/anchor-renderer.d.ts +6 -0
- package/dist/renderer/anchor-renderer.js +11 -0
- package/dist/renderer/bookmark-renderer.d.ts +6 -0
- package/dist/renderer/bookmark-renderer.js +11 -0
- package/dist/renderer/container-renderer.d.ts +3 -3
- package/dist/renderer/container-renderer.js +11 -28
- package/dist/renderer/default-text-style-renderer.d.ts +6 -0
- package/dist/renderer/default-text-style-renderer.js +10 -0
- package/dist/renderer/deferred-renderer.d.ts +3 -3
- package/dist/renderer/deferred-renderer.js +8 -23
- package/dist/renderer/emoji-image.d.ts +9 -0
- package/dist/renderer/emoji-image.js +68 -0
- package/dist/renderer/expanded-renderer.d.ts +3 -3
- package/dist/renderer/expanded-renderer.js +6 -21
- package/dist/renderer/form-field-renderer.d.ts +20 -0
- package/dist/renderer/form-field-renderer.js +12 -0
- package/dist/renderer/image-renderer.d.ts +4 -4
- package/dist/renderer/image-renderer.js +86 -75
- package/dist/renderer/index.d.ts +10 -10
- package/dist/renderer/index.js +10 -26
- package/dist/renderer/keep-together-renderer.d.ts +10 -0
- package/dist/renderer/keep-together-renderer.js +12 -0
- package/dist/renderer/line-renderer.d.ts +3 -3
- package/dist/renderer/line-renderer.js +13 -28
- package/dist/renderer/link-renderer.d.ts +6 -0
- package/dist/renderer/link-renderer.js +11 -0
- package/dist/renderer/padding-renderer.d.ts +3 -3
- package/dist/renderer/padding-renderer.js +6 -21
- package/dist/renderer/page-break-renderer.d.ts +14 -0
- package/dist/renderer/page-break-renderer.js +15 -0
- package/dist/renderer/page-builder-renderer.d.ts +6 -0
- package/dist/renderer/page-builder-renderer.js +11 -0
- package/dist/renderer/page-renderer.d.ts +2 -2
- package/dist/renderer/page-renderer.js +115 -77
- package/dist/renderer/pdf-backend.d.ts +31 -4
- package/dist/renderer/pdf-backend.js +287 -49
- package/dist/renderer/pdf-config.js +4 -7
- package/dist/renderer/pdf-document-class.d.ts +5 -5
- package/dist/renderer/pdf-document-class.js +27 -41
- package/dist/renderer/pdf-document-renderer.d.ts +19 -10
- package/dist/renderer/pdf-document-renderer.js +135 -91
- package/dist/renderer/pdf-renderer.d.ts +2 -2
- package/dist/renderer/pdf-renderer.js +165 -90
- package/dist/renderer/positioned-renderer.d.ts +6 -0
- package/dist/renderer/positioned-renderer.js +10 -0
- package/dist/renderer/rectangle-renderer.d.ts +3 -3
- package/dist/renderer/rectangle-renderer.js +49 -44
- package/dist/renderer/repeating-header-renderer.d.ts +3 -3
- package/dist/renderer/repeating-header-renderer.js +10 -26
- package/dist/renderer/rotated-renderer.d.ts +7 -0
- package/dist/renderer/rotated-renderer.js +29 -0
- package/dist/renderer/row-renderer.d.ts +3 -3
- package/dist/renderer/row-renderer.js +11 -28
- package/dist/renderer/struct-group-renderer.d.ts +10 -0
- package/dist/renderer/struct-group-renderer.js +24 -0
- package/dist/renderer/text-renderer.d.ts +9 -5
- package/dist/renderer/text-renderer.js +437 -89
- package/dist/text/advance.d.ts +34 -0
- package/dist/text/advance.js +45 -0
- package/dist/text/arabic.d.ts +20 -0
- package/dist/text/arabic.js +101 -0
- package/dist/text/bidi.d.ts +33 -0
- package/dist/text/bidi.js +94 -0
- package/dist/text/emoji-codepoints.d.ts +1 -0
- package/dist/text/emoji-codepoints.js +29 -0
- package/dist/text/font-fallback.d.ts +25 -0
- package/dist/text/font-fallback.js +38 -0
- package/dist/text/glyph-coverage.d.ts +15 -0
- package/dist/text/glyph-coverage.js +77 -0
- package/dist/text/line-breaker.d.ts +50 -12
- package/dist/text/line-breaker.js +200 -54
- package/dist/text/line-metrics.d.ts +49 -0
- package/dist/text/line-metrics.js +57 -0
- package/dist/text/orphans-widows.d.ts +27 -0
- package/dist/text/orphans-widows.js +53 -0
- package/dist/text/shape.d.ts +29 -0
- package/dist/text/shape.js +67 -0
- package/dist/text/text-decoration.d.ts +50 -0
- package/dist/text/text-decoration.js +62 -0
- package/dist/text/text-style.d.ts +60 -0
- package/dist/text/text-style.js +79 -0
- package/dist/text/whitespace.d.ts +4 -0
- package/dist/text/whitespace.js +43 -0
- package/dist/utils/afm-parser.d.ts +34 -0
- package/dist/utils/afm-parser.js +76 -17
- package/dist/utils/bytes.d.ts +24 -0
- package/dist/utils/bytes.js +76 -0
- package/dist/utils/dest-registry.d.ts +22 -0
- package/dist/utils/dest-registry.js +32 -0
- package/dist/utils/flex-layout.d.ts +36 -9
- package/dist/utils/flex-layout.js +207 -30
- package/dist/utils/font-metrics.d.ts +26 -1
- package/dist/utils/font-metrics.js +1 -2
- package/dist/utils/font-path.js +3 -6
- package/dist/utils/gsub.d.ts +36 -0
- package/dist/utils/gsub.js +178 -0
- package/dist/utils/image-helper.d.ts +6 -5
- package/dist/utils/image-helper.js +101 -111
- package/dist/utils/inflate.d.ts +16 -0
- package/dist/utils/inflate.js +52 -0
- package/dist/utils/md5.d.ts +4 -0
- package/dist/utils/md5.js +79 -0
- package/dist/utils/outline.d.ts +21 -0
- package/dist/utils/outline.js +79 -0
- package/dist/utils/pdf-object-manager.d.ts +127 -8
- package/dist/utils/pdf-object-manager.js +615 -141
- package/dist/utils/renderer-registry.d.ts +13 -1
- package/dist/utils/renderer-registry.js +23 -6
- package/dist/utils/struct-tree.d.ts +44 -0
- package/dist/utils/struct-tree.js +129 -0
- package/dist/utils/ttf-parser.d.ts +134 -2
- package/dist/utils/ttf-parser.js +873 -50
- package/dist/utils/ttf-subsetter.d.ts +1 -1
- package/dist/utils/ttf-subsetter.js +40 -42
- package/dist/utils/ua-xmp.d.ts +9 -0
- package/dist/utils/ua-xmp.js +32 -0
- package/dist/utils/utf8-to-windows1252-encoder.d.ts +6 -0
- package/dist/utils/utf8-to-windows1252-encoder.js +11 -4
- package/dist/utils/woff.d.ts +10 -0
- package/dist/utils/woff.js +116 -0
- package/dist/validators/element-validator.d.ts +2 -2
- package/dist/validators/element-validator.js +31 -26
- package/package.json +22 -2
|
@@ -1,8 +1,20 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (
|
|
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 =
|
|
25
|
-
currentWidth =
|
|
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
|
-
|
|
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
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
|
84
|
-
|
|
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
|
-
|
|
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(
|
|
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]>;
|