@jasy/pdf 1.0.0-beta.2 → 1.0.0-beta.4
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/dist/api/content.d.ts +5 -5
- package/dist/api/content.js +12 -6
- package/dist/api/font-url.d.ts +1 -2
- package/dist/api/font-url.js +8 -74
- package/dist/api/forms.d.ts +3 -0
- package/dist/api/forms.js +3 -0
- package/dist/api/index.d.ts +2 -0
- package/dist/api/index.js +1 -0
- package/dist/api/layout.d.ts +6 -0
- package/dist/api/layout.js +7 -2
- package/dist/api/page-builder.d.ts +4 -0
- package/dist/api/page-builder.js +4 -0
- package/dist/api/structure.d.ts +10 -2
- package/dist/api/structure.js +9 -6
- package/dist/api/table.d.ts +5 -3
- package/dist/api/table.js +99 -22
- package/dist/common/diagnostics.d.ts +53 -0
- package/dist/common/diagnostics.js +77 -0
- package/dist/edit/acroform-reader.js +7 -7
- package/dist/edit/appearance.js +3 -3
- package/dist/edit/attachments.d.ts +52 -0
- package/dist/edit/attachments.js +178 -0
- package/dist/edit/document.d.ts +38 -8
- package/dist/edit/document.js +208 -51
- package/dist/edit/fill.js +8 -23
- package/dist/edit/filters.d.ts +15 -0
- package/dist/edit/filters.js +157 -0
- package/dist/edit/index.d.ts +4 -1
- package/dist/edit/index.js +3 -1
- package/dist/edit/objects.d.ts +3 -4
- package/dist/edit/objects.js +5 -18
- package/dist/elements/canvas-element.d.ts +3 -0
- package/dist/elements/canvas-element.js +14 -3
- package/dist/elements/container-element.d.ts +5 -1
- package/dist/elements/container-element.js +35 -15
- package/dist/elements/forms/checkbox-element.d.ts +2 -0
- package/dist/elements/forms/checkbox-element.js +12 -3
- package/dist/elements/forms/choice-element.d.ts +6 -1
- package/dist/elements/forms/choice-element.js +17 -6
- package/dist/elements/forms/field-font.d.ts +8 -0
- package/dist/elements/forms/field-font.js +5 -0
- package/dist/elements/forms/push-button-element.d.ts +6 -1
- package/dist/elements/forms/push-button-element.js +16 -4
- package/dist/elements/forms/radio-element.d.ts +2 -0
- package/dist/elements/forms/radio-element.js +12 -3
- package/dist/elements/forms/signature-element.d.ts +6 -1
- package/dist/elements/forms/signature-element.js +16 -4
- package/dist/elements/forms/text-field-element.d.ts +6 -1
- package/dist/elements/forms/text-field-element.js +16 -4
- package/dist/elements/image-element.d.ts +39 -1
- package/dist/elements/image-element.js +95 -12
- package/dist/elements/layout/anchor-element.d.ts +8 -10
- package/dist/elements/layout/anchor-element.js +10 -18
- package/dist/elements/layout/bookmark-element.d.ts +9 -11
- package/dist/elements/layout/bookmark-element.js +11 -20
- package/dist/elements/layout/default-text-style-element.d.ts +11 -13
- package/dist/elements/layout/default-text-style-element.js +14 -31
- package/dist/elements/layout/deferred-element.d.ts +12 -4
- package/dist/elements/layout/deferred-element.js +24 -7
- package/dist/elements/layout/expanded-element.d.ts +7 -0
- package/dist/elements/layout/expanded-element.js +40 -18
- package/dist/elements/layout/keep-together-element.d.ts +8 -18
- package/dist/elements/layout/keep-together-element.js +21 -45
- package/dist/elements/layout/link-element.d.ts +8 -13
- package/dist/elements/layout/link-element.js +9 -19
- package/dist/elements/layout/padding-element.d.ts +15 -1
- package/dist/elements/layout/padding-element.js +55 -27
- package/dist/elements/layout/page-break-element.d.ts +4 -1
- package/dist/elements/layout/page-break-element.js +6 -1
- package/dist/elements/layout/page-builder-element.d.ts +5 -0
- package/dist/elements/layout/page-builder-element.js +11 -0
- package/dist/elements/layout/positioned-element.d.ts +14 -2
- package/dist/elements/layout/positioned-element.js +36 -10
- package/dist/elements/layout/proxy-element.d.ts +43 -0
- package/dist/elements/layout/proxy-element.js +94 -0
- package/dist/elements/layout/repeating-header-element.d.ts +6 -0
- package/dist/elements/layout/repeating-header-element.js +40 -13
- package/dist/elements/layout/rotated-box-element.d.ts +6 -0
- package/dist/elements/layout/rotated-box-element.js +27 -14
- package/dist/elements/layout/rotated-element.d.ts +7 -0
- package/dist/elements/layout/rotated-element.js +25 -2
- package/dist/elements/layout/struct-group.d.ts +9 -17
- package/dist/elements/layout/struct-group.js +10 -35
- package/dist/elements/line-element.d.ts +1 -0
- package/dist/elements/line-element.js +6 -0
- package/dist/elements/page-element.d.ts +10 -3
- package/dist/elements/page-element.js +39 -31
- package/dist/elements/pdf-document-element.d.ts +3 -0
- package/dist/elements/pdf-document-element.js +7 -0
- package/dist/elements/pdf-element.d.ts +75 -15
- package/dist/elements/pdf-element.js +68 -18
- package/dist/elements/rectangle-element.d.ts +14 -0
- package/dist/elements/rectangle-element.js +97 -52
- package/dist/elements/row-element.d.ts +11 -0
- package/dist/elements/row-element.js +58 -8
- package/dist/elements/svg-element.d.ts +30 -6
- package/dist/elements/svg-element.js +88 -17
- package/dist/elements/text-element.d.ts +40 -13
- package/dist/elements/text-element.js +144 -99
- package/dist/forms/acroform.d.ts +9 -1
- package/dist/forms/acroform.js +83 -34
- package/dist/forms/appearance.d.ts +17 -6
- package/dist/forms/appearance.js +18 -16
- package/dist/ir/display-list.d.ts +12 -2
- package/dist/layout/box-constraints.d.ts +5 -0
- package/dist/layout/box-constraints.js +14 -0
- package/dist/layout/collect-images.d.ts +6 -4
- package/dist/layout/collect-images.js +6 -4
- package/dist/layout/flex-item.d.ts +9 -0
- package/dist/layout/flex-item.js +39 -0
- package/dist/layout/fragmentation.d.ts +12 -4
- package/dist/layout/fragmentation.js +37 -14
- package/dist/layout/layout-cache.d.ts +53 -0
- package/dist/layout/layout-cache.js +107 -0
- package/dist/renderer/anchor-renderer.d.ts +2 -1
- package/dist/renderer/anchor-renderer.js +5 -4
- package/dist/renderer/bookmark-renderer.d.ts +2 -1
- package/dist/renderer/bookmark-renderer.js +5 -4
- package/dist/renderer/canvas-renderer.d.ts +2 -1
- package/dist/renderer/canvas-renderer.js +5 -2
- package/dist/renderer/container-renderer.d.ts +2 -1
- package/dist/renderer/container-renderer.js +6 -5
- package/dist/renderer/default-text-style-renderer.d.ts +2 -1
- package/dist/renderer/default-text-style-renderer.js +2 -3
- package/dist/renderer/deferred-renderer.d.ts +2 -1
- package/dist/renderer/deferred-renderer.js +2 -3
- package/dist/renderer/expanded-renderer.d.ts +2 -1
- package/dist/renderer/expanded-renderer.js +2 -3
- package/dist/renderer/form-field-renderer.d.ts +2 -1
- package/dist/renderer/form-field-renderer.js +16 -3
- package/dist/renderer/image-renderer.d.ts +2 -1
- package/dist/renderer/image-renderer.js +5 -2
- package/dist/renderer/keep-together-renderer.d.ts +2 -1
- package/dist/renderer/keep-together-renderer.js +2 -3
- package/dist/renderer/line-renderer.d.ts +2 -1
- package/dist/renderer/line-renderer.js +7 -2
- package/dist/renderer/link-renderer.d.ts +2 -1
- package/dist/renderer/link-renderer.js +36 -5
- package/dist/renderer/padding-renderer.d.ts +2 -1
- package/dist/renderer/padding-renderer.js +5 -5
- package/dist/renderer/page-break-renderer.d.ts +6 -5
- package/dist/renderer/page-break-renderer.js +14 -7
- package/dist/renderer/page-builder-renderer.d.ts +2 -1
- package/dist/renderer/page-builder-renderer.js +2 -3
- package/dist/renderer/page-renderer.js +28 -7
- package/dist/renderer/pdf-backend.d.ts +4 -3
- package/dist/renderer/pdf-backend.js +5 -5
- package/dist/renderer/pdf-document-renderer.d.ts +1 -8
- package/dist/renderer/pdf-document-renderer.js +15 -16
- package/dist/renderer/pdf-renderer.js +11 -11
- package/dist/renderer/positioned-renderer.d.ts +6 -1
- package/dist/renderer/positioned-renderer.js +6 -5
- package/dist/renderer/rectangle-renderer.d.ts +2 -1
- package/dist/renderer/rectangle-renderer.js +9 -4
- package/dist/renderer/repeating-header-renderer.d.ts +2 -1
- package/dist/renderer/repeating-header-renderer.js +6 -5
- package/dist/renderer/rotated-renderer.d.ts +2 -1
- package/dist/renderer/rotated-renderer.js +8 -5
- package/dist/renderer/row-renderer.d.ts +2 -1
- package/dist/renderer/row-renderer.js +6 -5
- package/dist/renderer/struct-group-renderer.d.ts +2 -1
- package/dist/renderer/struct-group-renderer.js +2 -3
- package/dist/renderer/svg-renderer.d.ts +2 -1
- package/dist/renderer/svg-renderer.js +8 -2
- package/dist/renderer/text-renderer.d.ts +4 -6
- package/dist/renderer/text-renderer.js +101 -111
- package/dist/svg/gradients.js +5 -0
- package/dist/svg/index.js +10 -1
- package/dist/svg/style.d.ts +2 -0
- package/dist/svg/style.js +11 -0
- package/dist/text/advance.d.ts +4 -0
- package/dist/text/advance.js +49 -4
- package/dist/text/bidi.d.ts +0 -2
- package/dist/text/bidi.js +0 -10
- package/dist/text/glyph-coverage.d.ts +1 -1
- package/dist/text/glyph-coverage.js +7 -3
- package/dist/text/hyphen-breaks.d.ts +17 -0
- package/dist/text/hyphen-breaks.js +54 -0
- package/dist/text/line-breaker.d.ts +34 -31
- package/dist/text/line-breaker.js +364 -176
- package/dist/text/line-metrics.d.ts +6 -3
- package/dist/text/line-metrics.js +7 -2
- package/dist/text/pdf-text-string.d.ts +21 -0
- package/dist/text/pdf-text-string.js +114 -0
- package/dist/text/text-decoration.d.ts +5 -5
- package/dist/text/text-decoration.js +5 -5
- package/dist/text/word-splitting.d.ts +15 -2
- package/dist/text/word-splitting.js +34 -8
- package/dist/utils/afm-parser.d.ts +3 -0
- package/dist/utils/afm-parser.js +19 -1
- package/dist/utils/dest-registry.d.ts +1 -1
- package/dist/utils/dest-registry.js +3 -2
- package/dist/utils/fetch-bytes.d.ts +19 -0
- package/dist/utils/fetch-bytes.js +79 -0
- package/dist/utils/flex-layout.d.ts +13 -13
- package/dist/utils/flex-layout.js +318 -216
- package/dist/utils/font-metrics.d.ts +4 -3
- package/dist/utils/image-helper.js +2 -1
- package/dist/utils/pdf-object-manager.d.ts +11 -3
- package/dist/utils/pdf-object-manager.js +41 -15
- package/dist/utils/renderer-registry.d.ts +43 -2
- package/dist/utils/renderer-registry.js +40 -0
- package/dist/utils/struct-tree.d.ts +24 -0
- package/dist/utils/struct-tree.js +61 -9
- package/dist/utils/ttf-parser.d.ts +4 -0
- package/dist/utils/ttf-parser.js +27 -4
- package/dist/validators/element-validator.js +2 -1
- package/package.json +1 -1
- package/dist/elements/layout/sized-container-element.d.ts +0 -14
- package/dist/elements/layout/sized-container-element.js +0 -32
|
@@ -3,42 +3,30 @@ import { HorizontalAlignment } from "../elements/pdf-element.js";
|
|
|
3
3
|
import { VERTICAL_TEXT_SHIFT } from "../elements/text-element.js";
|
|
4
4
|
import { isEmojiCodePoint } from "../text/emoji-codepoints.js";
|
|
5
5
|
import { emojiImageNode } from "./emoji-image.js";
|
|
6
|
-
import {
|
|
7
|
-
import { lineBoxForSegmentLine
|
|
6
|
+
import { MAX_SPACE_SHRINK, breakSegmentsIntoLines, } from "../text/line-breaker.js";
|
|
7
|
+
import { lineBoxForSegmentLine } from "../text/line-metrics.js";
|
|
8
8
|
import { runAdvance } from "../text/advance.js";
|
|
9
|
-
import {
|
|
9
|
+
import { visualRunsOf } from "../text/bidi.js";
|
|
10
10
|
import { skipInkSegments, strikethroughStroke, underlineStroke, } from "../text/text-decoration.js";
|
|
11
11
|
import { quadToCubic } from "../vector/path.js";
|
|
12
|
+
/** What a link says when read aloud (its `/Contents`): its pieces, a space where a line ended. */
|
|
13
|
+
function spokenText(pieces) {
|
|
14
|
+
let out = "";
|
|
15
|
+
pieces.forEach((piece, i) => {
|
|
16
|
+
if (i > 0 && piece.y !== pieces[i - 1].y)
|
|
17
|
+
out += " ";
|
|
18
|
+
out += piece.text;
|
|
19
|
+
});
|
|
20
|
+
return out.replace(/\s+/g, " ").trim();
|
|
21
|
+
}
|
|
12
22
|
export class TextRenderer {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
return lines.length * box.height;
|
|
21
|
-
}
|
|
22
|
-
// Segments: each line's box comes from the fonts actually on it. `splitting` has to travel with
|
|
23
|
-
// them for the same reason `letterSpacing` does: it changes where a line ENDS, so leaving it out
|
|
24
|
-
// measures a paragraph at one line count and draws it at another.
|
|
25
|
-
const defaults = {
|
|
26
|
-
fontFamily,
|
|
27
|
-
fontSize,
|
|
28
|
-
fontStyle,
|
|
29
|
-
letterSpacing,
|
|
30
|
-
splitting: lineOptions.splitting,
|
|
31
|
-
ligatures: lineOptions.ligatures,
|
|
32
|
-
};
|
|
33
|
-
const lines = breakSegmentsIntoLines(content, defaults, maxWidth, objectManager, maxLines, overflow);
|
|
34
|
-
return lines.reduce((total, line) => total + lineBoxForSegmentLine(line, defaults, objectManager, lineHeight).height, 0);
|
|
35
|
-
}
|
|
36
|
-
static async render(textElement, objectManager) {
|
|
37
|
-
const { x, y, width, fontSize, color, content, fontFamily, fontStyle, textAlignment, maxLines, overflow, lineHeight, underline, strikethrough, skipInk, letterSpacing, direction, wordSpacing, textIndent, breakWord, hyphenate, ligatures, role, } = textElement.getProps();
|
|
38
|
-
// Component -> display list. Wrapping and positioning stay here; the backend
|
|
39
|
-
// turns each run into BT/Tf/Td/Tj/ET. The wrapping algorithm is unchanged from
|
|
40
|
-
// the original renderer - unifying it into the engine is Phase 3.
|
|
41
|
-
const { runs, links, decorations } = TextRenderer._buildRuns(content, fontSize, fontFamily, fontStyle, objectManager, width ?? Number.NaN, textAlignment, color, x, y, maxLines, overflow, lineHeight, underline, strikethrough, skipInk, letterSpacing, direction, wordSpacing, textIndent, breakWord, hyphenate, ligatures);
|
|
23
|
+
static async render(textElement, objectManager, scope) {
|
|
24
|
+
const { x, y, width, fontSize, color, spans, fontFamily, fontStyle, textAlignment, maxLines, overflow, lineHeight, underline, strikethrough, skipInk, letterSpacing, direction, wordSpacing, textIndent, ligatures, role, breaking, } = textElement.getProps();
|
|
25
|
+
// Component -> display list: the lines come from the one breaker, positioning stays here; the
|
|
26
|
+
// backend turns each run into BT/Tf/Td/Tj/ET.
|
|
27
|
+
const { runs, links, decorations, linkGroups } = TextRenderer._buildRuns(spans, breaking, fontSize, fontFamily, fontStyle, objectManager, width ?? Number.NaN, textAlignment, color,
|
|
28
|
+
// Every run, decoration and link rect derives from this one absolute origin.
|
|
29
|
+
scope.origin.x + (x ?? 0), scope.origin.y + (y ?? 0), maxLines, overflow, lineHeight, underline, strikethrough, skipInk, letterSpacing, direction, wordSpacing, textIndent, ligatures);
|
|
42
30
|
// Accessible tagging: this whole text block is one structure element (a paragraph P, or a heading
|
|
43
31
|
// from `role`); each run's text lands under it. Emoji sub-nodes produced below stay untagged (drawn
|
|
44
32
|
// as artifacts). Keyed by structId so a block split across pages stays one element.
|
|
@@ -47,6 +35,25 @@ export class TextRenderer {
|
|
|
47
35
|
const key = objectManager.struct.openElement(textElement.structId, pdfRole);
|
|
48
36
|
for (const run of runs)
|
|
49
37
|
run.tag = { role: pdfRole, key };
|
|
38
|
+
// An inline link is a /Link element inside the paragraph: it holds its own text and its
|
|
39
|
+
// annotations, which describe themselves by that text (PDF/UA 7.18.5). One cut by a page break
|
|
40
|
+
// stays ONE element: the next piece of the paragraph continues the link its last one ended in.
|
|
41
|
+
const struct = objectManager.struct;
|
|
42
|
+
const carried = struct.takeCarriedLink(textElement.structId);
|
|
43
|
+
linkGroups.forEach((group, i) => {
|
|
44
|
+
const continues = i === 0 && group.runs[0] === runs[0] && carried?.target === group.target;
|
|
45
|
+
const linkKey = continues ? carried.key : struct.addElement("Link", key);
|
|
46
|
+
for (const run of group.runs)
|
|
47
|
+
run.tag = { role: "Link", key: linkKey };
|
|
48
|
+
const first = group.links[0];
|
|
49
|
+
const alt = spokenText(group.pieces) || (first.href ?? first.dest ?? "");
|
|
50
|
+
for (const link of group.links)
|
|
51
|
+
Object.assign(link, { structKey: linkKey, alt });
|
|
52
|
+
const last = group.runs[group.runs.length - 1];
|
|
53
|
+
if (i === linkGroups.length - 1 && last === runs[runs.length - 1]) {
|
|
54
|
+
struct.carryLink(textElement.structId, group.target, linkKey);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
50
57
|
}
|
|
51
58
|
// Color fonts (COLR/CPAL): expand each run's emoji into filled vector layers (or fetched images
|
|
52
59
|
// for an image emoji source); plain runs and monochrome fonts pass straight through unchanged.
|
|
@@ -224,11 +231,17 @@ export class TextRenderer {
|
|
|
224
231
|
// Lay the content out into absolutely-positioned text runs. Glyph positions match
|
|
225
232
|
// the previous hand-written operators exactly (verified by pixel-identical render);
|
|
226
233
|
// only the output form changed from PDF strings to `TextRun`s.
|
|
227
|
-
static _buildRuns(content,
|
|
234
|
+
static _buildRuns(content,
|
|
235
|
+
// How the text is broken - the element's own settings, the ones it was measured with.
|
|
236
|
+
breaking, fontSize, fontFamily, fontStyle, objectManager, maxWidth, textAlignment, color, initialX, yPosition, maxLines, overflow, lineHeight, underline = false, strikethrough = false, skipInk = false, letterSpacing = 0, direction = "ltr", wordSpacing = 0, textIndent = 0, ligatures = true) {
|
|
228
237
|
const runs = [];
|
|
229
238
|
// /Link annotation rects for any `href` spans, collected as we place segments. Empty for the
|
|
230
239
|
// common (no-href) case, so plain text keeps producing exactly the runs it did before.
|
|
231
240
|
const links = [];
|
|
241
|
+
// The same links grouped as the reader meets them: consecutive runs of one target are ONE link,
|
|
242
|
+
// across a line break too, with the text they cover (for the structure tree).
|
|
243
|
+
const linkGroups = [];
|
|
244
|
+
let openLink;
|
|
232
245
|
// Underline / strikethrough strokes, one per decorated run (so a wrapped paragraph gets one
|
|
233
246
|
// per line, and a decorated span only spans its own glyphs).
|
|
234
247
|
const decorations = [];
|
|
@@ -266,34 +279,41 @@ export class TextRenderer {
|
|
|
266
279
|
// Negative slack is real now: the breaker keeps a word when the line can be SQUEEZED to hold
|
|
267
280
|
// it, so justification has to deliver that squeeze. The floor is the same limit the breaker
|
|
268
281
|
// used, which is what stops a line it never promised from being crushed.
|
|
269
|
-
|
|
282
|
+
// A space as the breaker counts it: word-spacing included.
|
|
283
|
+
const spaceWidth = runAdvance(objectManager, " ", { fontFamily, fontSize, fontStyle }, letterSpacing) +
|
|
284
|
+
wordSpacing;
|
|
270
285
|
const extra = Math.max(slack / gaps, -spaceWidth * MAX_SPACE_SHRINK);
|
|
271
286
|
// The last line is never STRETCHED - the rule print and CSS follow. It may still be SQUEEZED:
|
|
272
287
|
// the breaker was allowed to pack it that way, and drawing it loose would push it out of its box.
|
|
273
288
|
return isLast ? Math.min(0, extra) : extra;
|
|
274
289
|
};
|
|
275
290
|
/**
|
|
276
|
-
* Push one drawn piece and return the advance it took. When the
|
|
277
|
-
* extra `extra` at every space, which means one run per word - the
|
|
278
|
-
* embedded font, whose spaces `Tw` cannot reach.
|
|
291
|
+
* Push one drawn piece and return the advance it took. When the spaces get more room (word-spacing,
|
|
292
|
+
* justification) the pen jumps an extra `extra` at every space, which means one run per word - the
|
|
293
|
+
* only way that works for an embedded font, whose spaces `Tw` cannot reach.
|
|
279
294
|
*
|
|
280
|
-
* A SHAPED piece is
|
|
281
|
-
*
|
|
295
|
+
* A SHAPED piece is split there too, each word shaped on its own (`shape`): no slice of its glyph
|
|
296
|
+
* list is correct, but neither a ligature nor Arabic joining crosses a space - and a word plus a
|
|
297
|
+
* space is what the breaker measured.
|
|
282
298
|
*/
|
|
283
|
-
const emitPiece = (piece, x, extra, style, runFont, spacing) => {
|
|
299
|
+
const emitPiece = (piece, shape, rtl, x, extra, style, runFont, spacing) => {
|
|
284
300
|
const push = (at, text, glyphs) => {
|
|
285
301
|
runs.push({ type: "text", x: at, text, ...(glyphs ? { glyphs } : {}), ...style });
|
|
286
302
|
};
|
|
287
|
-
if (extra === 0 ||
|
|
303
|
+
if (extra === 0 || !piece.text.includes(" ")) {
|
|
288
304
|
push(x, piece.text, piece.glyphs);
|
|
289
305
|
return runAdvance(objectManager, piece.text, runFont, spacing);
|
|
290
306
|
}
|
|
291
307
|
const spaceWidth = runAdvance(objectManager, " ", runFont, spacing);
|
|
308
|
+
// A shaped piece holds its LOGICAL text: drawn right to left, its words come in reverse.
|
|
292
309
|
const words = piece.text.split(" ");
|
|
310
|
+
if (piece.glyphs && rtl)
|
|
311
|
+
words.reverse();
|
|
293
312
|
let pen = x;
|
|
294
313
|
words.forEach((word, i) => {
|
|
295
314
|
if (word !== "") {
|
|
296
|
-
|
|
315
|
+
const drawn = piece.glyphs ? shape(word) : { text: word };
|
|
316
|
+
push(pen, drawn.text, drawn.glyphs);
|
|
297
317
|
pen += runAdvance(objectManager, word, runFont, spacing);
|
|
298
318
|
}
|
|
299
319
|
if (i < words.length - 1)
|
|
@@ -361,62 +381,25 @@ export class TextRenderer {
|
|
|
361
381
|
const ordered = part.rtl ? [...shaped].reverse() : shaped;
|
|
362
382
|
return { text: part.logical, glyphs: ordered.map((g) => g.glyph) };
|
|
363
383
|
};
|
|
364
|
-
//
|
|
365
|
-
// `letterSpacing` per code point (the `Tc` operator), from the one shared `advance.ts` primitive.
|
|
366
|
-
const font = { fontFamily, fontSize, fontStyle, ligatures };
|
|
367
|
-
// --- Plain string: one run per wrapped line. ---
|
|
368
|
-
if (typeof content === "string") {
|
|
369
|
-
const lines = wrapStringIntoLines(content, fontFamily, fontSize, fontStyle, maxWidth, objectManager, maxLines, overflow, letterSpacing,
|
|
370
|
-
// The SAME knobs the measure pass used (`TextElement.lineOptions`), or a line would be
|
|
371
|
-
// measured one width and drawn another.
|
|
372
|
-
{
|
|
373
|
-
wordSpacing,
|
|
374
|
-
indent: textIndent,
|
|
375
|
-
shrink: align === HorizontalAlignment.justify ? MAX_SPACE_SHRINK : 0,
|
|
376
|
-
splitting: { breakWord, hyphenate },
|
|
377
|
-
ligatures,
|
|
378
|
-
});
|
|
379
|
-
// yPosition is the top of the text box (top-left). The line box seats its own baseline; lines
|
|
380
|
-
// then stack by that box's height. Same numbers as calculateTextHeight, by construction.
|
|
381
|
-
const box = lineBoxForString(objectManager, fontFamily, fontStyle, fontSize, lineHeight);
|
|
382
|
-
lines.forEach((line, index) => {
|
|
383
|
-
const lineWidth = singleLineWidth(line, font, objectManager, letterSpacing, wordSpacing);
|
|
384
|
-
// CSS `text-indent`: the FIRST line starts in, and had that much less room to fill.
|
|
385
|
-
const indent = index === 0 ? textIndent : 0;
|
|
386
|
-
let x = initialX + indent + alignmentOffset(lineWidth, indent);
|
|
387
|
-
const baseline = yPosition + box.baseline + box.height * index;
|
|
388
|
-
// Latin-only text in an `ltr` paragraph comes back as one unchanged piece.
|
|
389
|
-
// Word-spacing is a per-gap advance just like the justification slack, so it rides the same
|
|
390
|
-
// mechanism: `Tw` would reach only the single byte 32 and miss every embedded font.
|
|
391
|
-
const extra = wordSpacing + justifyExtra(line, lineWidth, index === lines.length - 1, indent);
|
|
392
|
-
for (const part of visualRuns(line, direction)) {
|
|
393
|
-
const piece = shapedPiece(part, fontFamily, fontStyle);
|
|
394
|
-
const partWidth = emitPiece(piece, x, extra, {
|
|
395
|
-
y: baseline,
|
|
396
|
-
fontFamily,
|
|
397
|
-
fontStyle,
|
|
398
|
-
fontSize,
|
|
399
|
-
color,
|
|
400
|
-
...(letterSpacing ? { letterSpacing } : {}),
|
|
401
|
-
}, font, letterSpacing);
|
|
402
|
-
decorate(piece.text, x, partWidth, baseline, fontFamily, fontStyle, fontSize, color, underline, strikethrough, letterSpacing);
|
|
403
|
-
x += partWidth;
|
|
404
|
-
}
|
|
405
|
-
});
|
|
406
|
-
return { runs, links, decorations };
|
|
407
|
-
}
|
|
408
|
-
// --- Segments: break into lines (shared breaker), then emit one run per segment.
|
|
384
|
+
// Break into lines (the one breaker), then emit one run per segment.
|
|
409
385
|
// Segment 0 starts at the aligned line origin; each following segment is offset by
|
|
410
386
|
// the previous segment's kerning-free advance. Each line drops by its OWN leading
|
|
411
387
|
// (tallest font on that line), so mixed-font lines space correctly and the drawn
|
|
412
388
|
// height matches the measured height.
|
|
413
|
-
const pushLine = (line, lineY, isLast) => {
|
|
414
|
-
|
|
415
|
-
|
|
389
|
+
const pushLine = (line, lineY, isLast, indent) => {
|
|
390
|
+
// CSS `text-indent`: the FIRST line starts in, with that much less room.
|
|
391
|
+
let x = initialX + indent + alignmentOffset(line.width, indent);
|
|
392
|
+
// Word-spacing is a per-gap advance just like the justification slack, so it rides the same
|
|
393
|
+
// mechanism: `Tw` would reach only the single byte 32 and miss every embedded font.
|
|
394
|
+
const extra = wordSpacing +
|
|
395
|
+
justifyExtra(line.segments.map((seg) => seg.content).join(""), line.width, isLast, indent);
|
|
416
396
|
// Across span boundaries, so a Hebrew span is ordered against its neighbours rather than inside
|
|
417
397
|
// itself. Each run keeps the segment it came from, and with it that span's style.
|
|
418
398
|
const ordered = visualRunsOf(line.segments.map((segment) => ({ text: segment.content, source: segment })), direction);
|
|
419
399
|
ordered.forEach((part) => {
|
|
400
|
+
// A blank line keeps its height (its box is sized from the piece), but there is nothing to draw.
|
|
401
|
+
if (part.text === "")
|
|
402
|
+
return;
|
|
420
403
|
const segment = part.source;
|
|
421
404
|
const family = segment.fontFamily || fontFamily;
|
|
422
405
|
const size = segment.fontSize || fontSize;
|
|
@@ -429,21 +412,24 @@ export class TextRenderer {
|
|
|
429
412
|
// ride along, since both are placed from the same y.
|
|
430
413
|
const shift = VERTICAL_TEXT_SHIFT[segment.verticalAlign ?? "baseline"] * size;
|
|
431
414
|
const runY = lineY - shift;
|
|
432
|
-
const
|
|
415
|
+
const firstRun = runs.length;
|
|
416
|
+
const drawn = emitPiece(piece, (word) => shapedPiece({ text: word, logical: word, rtl: part.rtl }, family, style), part.rtl, x, extra, {
|
|
433
417
|
y: runY,
|
|
434
418
|
fontFamily: family,
|
|
435
419
|
fontStyle: style,
|
|
436
420
|
fontSize: size,
|
|
437
421
|
color: runColor,
|
|
438
422
|
...(segLetterSpacing ? { letterSpacing: segLetterSpacing } : {}),
|
|
439
|
-
},
|
|
423
|
+
},
|
|
424
|
+
// `ligatures` is part of the font: the advances must be the glyphs that are drawn.
|
|
425
|
+
{ fontFamily: family, fontSize: size, fontStyle: style, ligatures }, segLetterSpacing);
|
|
440
426
|
decorate(runText, x, drawn, runY, family, style, size, runColor, segment.underline ?? underline, segment.strikethrough ?? strikethrough, segLetterSpacing);
|
|
441
427
|
// An href/to span becomes a /Link annotation over exactly this run's glyph box: from its own
|
|
442
428
|
// ascent above the baseline down to its own descender. A span wrapped across lines yields
|
|
443
429
|
// one rect per line.
|
|
444
430
|
if (segment.href !== undefined || segment.dest !== undefined) {
|
|
445
431
|
const v = objectManager.getFontVerticals(family, style);
|
|
446
|
-
|
|
432
|
+
const link = {
|
|
447
433
|
type: "link",
|
|
448
434
|
x,
|
|
449
435
|
y: runY - v.ascent * size,
|
|
@@ -451,34 +437,38 @@ export class TextRenderer {
|
|
|
451
437
|
height: (v.ascent + v.descent) * size,
|
|
452
438
|
href: segment.href,
|
|
453
439
|
dest: segment.dest,
|
|
454
|
-
}
|
|
440
|
+
};
|
|
441
|
+
links.push(link);
|
|
442
|
+
const target = segment.href !== undefined ? `href:${segment.href}` : `dest:${segment.dest}`;
|
|
443
|
+
if (openLink?.target !== target) {
|
|
444
|
+
openLink = { target, group: { target, links: [], runs: [], pieces: [] } };
|
|
445
|
+
linkGroups.push(openLink.group);
|
|
446
|
+
}
|
|
447
|
+
openLink.group.links.push(link);
|
|
448
|
+
openLink.group.runs.push(...runs.slice(firstRun));
|
|
449
|
+
openLink.group.pieces.push({ text: runText, y: runY });
|
|
450
|
+
}
|
|
451
|
+
else if (runText.trim() !== "") {
|
|
452
|
+
openLink = undefined;
|
|
455
453
|
}
|
|
456
454
|
x += drawn;
|
|
457
455
|
});
|
|
458
456
|
};
|
|
459
457
|
// yPosition is the top of the text box (top-left). Each line seats its own baseline inside its
|
|
460
458
|
// own box (tallest ascent / deepest descent ON THAT LINE), then the next line starts below it.
|
|
461
|
-
// The seam flips the whole thing to PDF space.
|
|
462
|
-
//
|
|
463
|
-
|
|
464
|
-
const defaults = {
|
|
465
|
-
fontFamily,
|
|
466
|
-
fontSize,
|
|
467
|
-
fontStyle,
|
|
468
|
-
letterSpacing,
|
|
469
|
-
splitting: { breakWord, hyphenate },
|
|
470
|
-
ligatures,
|
|
471
|
-
};
|
|
459
|
+
// The seam flips the whole thing to PDF space. The lines are broken with the element's own
|
|
460
|
+
// settings - the ones `textHeight` measured with - so measured equals drawn by construction.
|
|
461
|
+
const defaults = breaking;
|
|
472
462
|
let top = yPosition;
|
|
473
|
-
|
|
474
|
-
const segmentLines = [
|
|
475
|
-
...breakSegmentsIntoLines(content, defaults, maxWidth, objectManager, maxLines, overflow),
|
|
476
|
-
];
|
|
463
|
+
const segmentLines = breakSegmentsIntoLines(content, defaults, maxWidth, objectManager, maxLines, overflow);
|
|
477
464
|
segmentLines.forEach((line, index) => {
|
|
478
465
|
const box = lineBoxForSegmentLine(line, defaults, objectManager, lineHeight);
|
|
479
|
-
|
|
466
|
+
// Only a line that WRAPPED is stretched: the end of a paragraph and the line before a `\n` keep
|
|
467
|
+
// their natural spacing (CSS). The last line of a piece continued on the next page wrapped.
|
|
468
|
+
const ends = line.end === "hard" || line.end === "end";
|
|
469
|
+
pushLine(line, top + box.baseline, ends, index === 0 ? textIndent : 0);
|
|
480
470
|
top += box.height;
|
|
481
471
|
});
|
|
482
|
-
return { runs, links, decorations };
|
|
472
|
+
return { runs, links, decorations, linkGroups };
|
|
483
473
|
}
|
|
484
474
|
}
|
package/dist/svg/gradients.js
CHANGED
|
@@ -135,6 +135,11 @@ export function resolveSvgGradient(def, defs, commands) {
|
|
|
135
135
|
];
|
|
136
136
|
const scale = Math.abs(matrix[0]);
|
|
137
137
|
const extend = SPREAD[attributes["spreadMethod"] ?? "pad"] ?? "pad";
|
|
138
|
+
if (extend !== "pad") {
|
|
139
|
+
// A PDF shading only clamps its end colours; repeating or mirroring would need a tiling pattern.
|
|
140
|
+
// Drawn as `pad` it is a different picture - so it is named.
|
|
141
|
+
throw new SvgUnsupportedError(`spreadMethod="${extend}" on a gradient`, "Only pad (the default) is drawn. Expand the repeats into stops in your editor.");
|
|
142
|
+
}
|
|
138
143
|
if (def.kind === "linear") {
|
|
139
144
|
// Both anchors go through the transform, so a rotation or a shear lands correctly by itself.
|
|
140
145
|
const [x0, y0] = point(px(num(attributes["x1"], 0)), py(num(attributes["y1"], 0)));
|
package/dist/svg/index.js
CHANGED
|
@@ -112,9 +112,14 @@ function rootMapping(root, target) {
|
|
|
112
112
|
* it draws something the file did not describe: a drop-shadow logo as a hard silhouette. `clip-path`
|
|
113
113
|
* used to be listed here; it is resolved now, which is the honest version of the same rule.
|
|
114
114
|
*/
|
|
115
|
+
const MARKER_HINT = "Markers (arrowheads, dots on a line) are not drawn yet; expand them into shapes in your editor.";
|
|
115
116
|
const REFUSED_ATTRIBUTES = {
|
|
116
117
|
mask: "Masks are pixel operations; flatten the artwork in your editor instead.",
|
|
117
118
|
filter: "Filters are pixel operations; rasterise that part to a PNG instead.",
|
|
119
|
+
marker: MARKER_HINT,
|
|
120
|
+
"marker-start": MARKER_HINT,
|
|
121
|
+
"marker-mid": MARKER_HINT,
|
|
122
|
+
"marker-end": MARKER_HINT,
|
|
118
123
|
};
|
|
119
124
|
function refuseAttributes(tagName, read) {
|
|
120
125
|
for (const [name, hint] of Object.entries(REFUSED_ATTRIBUTES)) {
|
|
@@ -210,6 +215,10 @@ function walk(node, parentStyle, rules, clips, gradients, out, depth = 0) {
|
|
|
210
215
|
// Everything below reads through the cascade, not the raw attributes: `filter="url(#f)"` and
|
|
211
216
|
// `style="filter:url(#f)"` are the same instruction, and a `<style>` rule can set either.
|
|
212
217
|
const read = cascadedReader(attributes, css);
|
|
218
|
+
// `display: none` takes the element out with everything inside it - nothing of it is drawn, so
|
|
219
|
+
// nothing of it can be unsupported either.
|
|
220
|
+
if (read("display")?.trim() === "none")
|
|
221
|
+
return;
|
|
213
222
|
refuseAttributes(tagName, read);
|
|
214
223
|
const style = resolveStyle(parentStyle, attributes, css);
|
|
215
224
|
const transform = attributes["transform"]
|
|
@@ -248,7 +257,7 @@ function walk(node, parentStyle, rules, clips, gradients, out, depth = 0) {
|
|
|
248
257
|
}
|
|
249
258
|
// A shape with neither fill nor stroke, or with no geometry, paints nothing - and an empty Path
|
|
250
259
|
// node would only cost bytes.
|
|
251
|
-
if (commands.length > 0 && (style.fill || style.stroke)) {
|
|
260
|
+
if (commands.length > 0 && style.visible && (style.fill || style.stroke)) {
|
|
252
261
|
// A gradient is resolved HERE too, and for the same reason: in its default units its anchors
|
|
253
262
|
// are fractions of the SHAPE's bounding box, which only exists at this point.
|
|
254
263
|
const shapeAlpha = style.fillOpacity * style.opacity;
|
package/dist/svg/style.d.ts
CHANGED
|
@@ -30,6 +30,8 @@ export interface SvgStyle {
|
|
|
30
30
|
fillOpacity: number;
|
|
31
31
|
strokeOpacity: number;
|
|
32
32
|
opacity: number;
|
|
33
|
+
/** `visibility` (inherits): a hidden shape paints nothing, but a child may set `visible` again. */
|
|
34
|
+
visible: boolean;
|
|
33
35
|
}
|
|
34
36
|
/** The colour a shape actually paints with: its raw colour, times the opacities that reached it. */
|
|
35
37
|
export declare function paintAlpha(color: Color, opacity: number): Color;
|
package/dist/svg/style.js
CHANGED
|
@@ -18,6 +18,7 @@ export const ROOT_STYLE = {
|
|
|
18
18
|
fillOpacity: 1,
|
|
19
19
|
strokeOpacity: 1,
|
|
20
20
|
opacity: 1,
|
|
21
|
+
visible: true,
|
|
21
22
|
};
|
|
22
23
|
/** Splits a `style=""` bag into declarations. It wins over both other sources, as in CSS. */
|
|
23
24
|
function styleBag(value) {
|
|
@@ -140,5 +141,15 @@ export function resolveStyle(parent, attributes, css = {}) {
|
|
|
140
141
|
fillOpacity: ratio(read("fill-opacity"), parent.fillOpacity),
|
|
141
142
|
strokeOpacity: ratio(read("stroke-opacity"), parent.strokeOpacity),
|
|
142
143
|
opacity,
|
|
144
|
+
visible: visibility(read("visibility"), parent.visible),
|
|
143
145
|
};
|
|
144
146
|
}
|
|
147
|
+
/** `hidden` and `collapse` hide, `visible` shows, anything else (`inherit`, unset) keeps the parent's. */
|
|
148
|
+
function visibility(value, parent) {
|
|
149
|
+
const v = value?.trim();
|
|
150
|
+
if (v === "hidden" || v === "collapse")
|
|
151
|
+
return false;
|
|
152
|
+
if (v === "visible")
|
|
153
|
+
return true;
|
|
154
|
+
return parent;
|
|
155
|
+
}
|
package/dist/text/advance.d.ts
CHANGED
|
@@ -32,6 +32,10 @@ export interface RunFont {
|
|
|
32
32
|
/** Number of Unicode code points (not UTF-16 units): an astral char is ONE, so it takes one
|
|
33
33
|
* letter-spacing, not two. Matches how `getStringWidth` iterates. */
|
|
34
34
|
export declare function codePointCount(text: string): number;
|
|
35
|
+
/** U+00AD: "a line may break here, with a hyphen". Invisible wherever it does not. */
|
|
36
|
+
export declare const SOFT_HYPHEN = "\u00AD";
|
|
37
|
+
/** `text` as it is drawn: without its soft hyphens. */
|
|
38
|
+
export declare function withoutSoftHyphens(text: string): string;
|
|
35
39
|
/** The advance of `text` in points: the plain glyph widths, plus kerning (if the document has it on),
|
|
36
40
|
* plus one `letterSpacing` per code point. */
|
|
37
41
|
export declare function runAdvance(metrics: FontMetrics, text: string, font: RunFont, letterSpacing?: number): number;
|
package/dist/text/advance.js
CHANGED
|
@@ -39,6 +39,12 @@ export function codePointCount(text) {
|
|
|
39
39
|
* is thrown away when it flips; the same document can be rendered twice with different options.
|
|
40
40
|
*/
|
|
41
41
|
const ADVANCE_CACHE = new WeakMap();
|
|
42
|
+
/**
|
|
43
|
+
* The innermost map a `RunFont` resolves to, remembered on the font object itself. The breaker builds
|
|
44
|
+
* one font per run and asks for every word with it, so without this each word walks five maps of which
|
|
45
|
+
* four hold a single entry.
|
|
46
|
+
*/
|
|
47
|
+
const BY_FONT = new WeakMap();
|
|
42
48
|
function baseAdvance(metrics, text, font) {
|
|
43
49
|
let entry = ADVANCE_CACHE.get(metrics);
|
|
44
50
|
const epoch = metrics.fontEpoch ?? 0;
|
|
@@ -49,6 +55,20 @@ function baseAdvance(metrics, text, font) {
|
|
|
49
55
|
entry = { epoch, kerning, byFamily: new Map() };
|
|
50
56
|
ADVANCE_CACHE.set(metrics, entry);
|
|
51
57
|
}
|
|
58
|
+
// Same font object as last time, and the metrics entry has not been replaced: the map is already known.
|
|
59
|
+
// Its FIELDS are compared, not just its identity: a caller that reuses one object and changes the
|
|
60
|
+
// size on it would otherwise be handed the previous size's map.
|
|
61
|
+
const known = BY_FONT.get(font);
|
|
62
|
+
if (known?.entry === entry &&
|
|
63
|
+
known.family === font.fontFamily &&
|
|
64
|
+
known.size === font.fontSize &&
|
|
65
|
+
known.style === font.fontStyle &&
|
|
66
|
+
known.ligatures === font.ligatures) {
|
|
67
|
+
const seen = known.byText.get(text);
|
|
68
|
+
if (seen !== undefined)
|
|
69
|
+
return seen;
|
|
70
|
+
return measureAndStore(metrics, text, font, known.byText);
|
|
71
|
+
}
|
|
52
72
|
let byStyle = entry.byFamily.get(font.fontFamily);
|
|
53
73
|
if (!byStyle)
|
|
54
74
|
entry.byFamily.set(font.fontFamily, (byStyle = new Map()));
|
|
@@ -61,9 +81,26 @@ function baseAdvance(metrics, text, font) {
|
|
|
61
81
|
let byText = bySize.get(font.fontSize);
|
|
62
82
|
if (!byText)
|
|
63
83
|
bySize.set(font.fontSize, (byText = new Map()));
|
|
84
|
+
BY_FONT.set(font, {
|
|
85
|
+
entry,
|
|
86
|
+
family: font.fontFamily,
|
|
87
|
+
size: font.fontSize,
|
|
88
|
+
style: font.fontStyle,
|
|
89
|
+
ligatures: font.ligatures,
|
|
90
|
+
byText,
|
|
91
|
+
});
|
|
64
92
|
const hit = byText.get(text);
|
|
65
93
|
if (hit !== undefined)
|
|
66
94
|
return hit;
|
|
95
|
+
return measureAndStore(metrics, text, font, byText);
|
|
96
|
+
}
|
|
97
|
+
/** The measurement itself, once a miss has found the map it belongs in. */
|
|
98
|
+
function measureAndStore(metrics, text, font, byText) {
|
|
99
|
+
// A soft hyphen is never drawn where it stands (a break there draws a real hyphen instead), so it has
|
|
100
|
+
// no width - taken out BEFORE measuring, so the kerning pair across it is the one drawn. Here, on a
|
|
101
|
+
// cache miss, and not on every call: the stored width is the drawn one, keyed by the source text.
|
|
102
|
+
const key = text;
|
|
103
|
+
text = withoutSoftHyphens(text);
|
|
67
104
|
let advance = metrics.getStringWidth(text, font.fontFamily, font.fontSize, font.fontStyle, font.ligatures);
|
|
68
105
|
if (metrics.kerningEnabled) {
|
|
69
106
|
let units = 0;
|
|
@@ -71,17 +108,25 @@ function baseAdvance(metrics, text, font) {
|
|
|
71
108
|
units += k;
|
|
72
109
|
advance += (units / 1000) * font.fontSize; // kern units are em/1000
|
|
73
110
|
}
|
|
74
|
-
byText.set(
|
|
111
|
+
byText.set(key, advance);
|
|
75
112
|
return advance;
|
|
76
113
|
}
|
|
114
|
+
/** U+00AD: "a line may break here, with a hyphen". Invisible wherever it does not. */
|
|
115
|
+
export const SOFT_HYPHEN = "\u00AD";
|
|
116
|
+
/** `text` as it is drawn: without its soft hyphens. */
|
|
117
|
+
export function withoutSoftHyphens(text) {
|
|
118
|
+
return text.includes(SOFT_HYPHEN) ? text.split(SOFT_HYPHEN).join("") : text;
|
|
119
|
+
}
|
|
77
120
|
/** The advance of `text` in points: the plain glyph widths, plus kerning (if the document has it on),
|
|
78
121
|
* plus one `letterSpacing` per code point. */
|
|
79
122
|
export function runAdvance(metrics, text, font, letterSpacing = 0) {
|
|
80
123
|
let advance = baseAdvance(metrics, text, font);
|
|
81
124
|
if (letterSpacing !== 0) {
|
|
82
|
-
// Per DRAWN glyph, which is what `Tc` applies to; only a ligature makes that differ.
|
|
83
|
-
|
|
84
|
-
|
|
125
|
+
// Per DRAWN glyph, which is what `Tc` applies to; only a ligature makes that differ. A soft hyphen is
|
|
126
|
+
// not drawn.
|
|
127
|
+
const drawn = withoutSoftHyphens(text);
|
|
128
|
+
const glyphs = metrics.shapedGlyphCount?.(drawn, font.fontFamily, font.fontStyle, font.ligatures) ??
|
|
129
|
+
codePointCount(drawn);
|
|
85
130
|
advance += glyphs * letterSpacing;
|
|
86
131
|
}
|
|
87
132
|
return advance;
|
package/dist/text/bidi.d.ts
CHANGED
|
@@ -29,5 +29,3 @@ export declare function visualRunsOf<T>(pieces: {
|
|
|
29
29
|
text: string;
|
|
30
30
|
source: T;
|
|
31
31
|
}[], base?: Direction): AttributedRun<T>[];
|
|
32
|
-
/** The same for one plain string. An empty string still yields one run, or a blank line would vanish. */
|
|
33
|
-
export declare const visualRuns: (text: string, base?: Direction) => VisualRun[];
|
package/dist/text/bidi.js
CHANGED
|
@@ -82,13 +82,3 @@ export function visualRunsOf(pieces, base = "ltr") {
|
|
|
82
82
|
flush();
|
|
83
83
|
return runs;
|
|
84
84
|
}
|
|
85
|
-
/** The same for one plain string. An empty string still yields one run, or a blank line would vanish. */
|
|
86
|
-
export const visualRuns = (text, base = "ltr") => {
|
|
87
|
-
if (text === "")
|
|
88
|
-
return [{ text, logical: text, rtl: false }];
|
|
89
|
-
return visualRunsOf([{ text, source: null }], base).map(({ text: t, logical, rtl }) => ({
|
|
90
|
-
text: t,
|
|
91
|
-
logical,
|
|
92
|
-
rtl,
|
|
93
|
-
}));
|
|
94
|
-
};
|
|
@@ -10,6 +10,6 @@ export interface CoverageResult {
|
|
|
10
10
|
* Replaces or removes what `fontFamily` cannot draw. Returns the input unchanged (and no allocation
|
|
11
11
|
* beyond the scan) when everything is drawable, which is the overwhelmingly common case.
|
|
12
12
|
*/
|
|
13
|
-
export declare function coverText(text: string, fontFamily: string, fontStyle: FontStyle, metrics: FontMetrics): CoverageResult;
|
|
13
|
+
export declare function coverText(text: string, fontFamily: string, fontStyle: FontStyle, metrics: FontMetrics, emoji?: boolean): CoverageResult;
|
|
14
14
|
/** `U+2713` - how a dropped code point is named to the caller. */
|
|
15
15
|
export declare function formatCodePoint(cp: number): string;
|
|
@@ -39,19 +39,23 @@ const EQUIVALENTS = {
|
|
|
39
39
|
* Replaces or removes what `fontFamily` cannot draw. Returns the input unchanged (and no allocation
|
|
40
40
|
* beyond the scan) when everything is drawable, which is the overwhelmingly common case.
|
|
41
41
|
*/
|
|
42
|
-
export function coverText(text, fontFamily, fontStyle, metrics
|
|
42
|
+
export function coverText(text, fontFamily, fontStyle, metrics,
|
|
43
|
+
// Whether colour emoji count as drawable: in `Text` they are drawn from the emoji source; a form
|
|
44
|
+
// field's appearance has only its own font.
|
|
45
|
+
emoji = true) {
|
|
43
46
|
let out = null; // built only once something has to change
|
|
44
47
|
const dropped = [];
|
|
45
48
|
let index = 0;
|
|
46
49
|
for (const char of text) {
|
|
47
50
|
const cp = char.codePointAt(0);
|
|
48
|
-
// A space
|
|
51
|
+
// A space, a line break and a soft hyphen are handled by the breaker, never drawn as a glyph.
|
|
49
52
|
const drawable = cp === 0x20 ||
|
|
50
53
|
cp === 0x0a ||
|
|
54
|
+
cp === 0xad ||
|
|
51
55
|
metrics.hasGlyph(cp, fontFamily, fontStyle) ||
|
|
52
56
|
// Colour emoji is drawn from a fallback font or an image, NOT from the text font - it is missing
|
|
53
57
|
// there by design, and removing it would delete the very thing `Document({ emoji })` exists for.
|
|
54
|
-
metrics.rendersAsEmoji?.(cp, fontFamily, fontStyle) === true;
|
|
58
|
+
(emoji && metrics.rendersAsEmoji?.(cp, fontFamily, fontStyle) === true);
|
|
55
59
|
if (!drawable) {
|
|
56
60
|
if (out === null)
|
|
57
61
|
out = text.slice(0, index);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare const NO_BREAKS: readonly number[];
|
|
2
|
+
/** Whether a word holds anything it may break after: a hyphen or a soft hyphen. */
|
|
3
|
+
export declare const mayBreak: (word: string) => boolean;
|
|
4
|
+
/**
|
|
5
|
+
* Where a word may break, as offsets into it - after a hyphen (U+002D) as UAX #14 has it: never before
|
|
6
|
+
* it, never before a digit ("CODE-2", "2026-10-16", "-5" stay whole), never after one that starts the
|
|
7
|
+
* word; and after a soft hyphen (U+00AD), the break its author marked.
|
|
8
|
+
*/
|
|
9
|
+
export declare function hyphenBreaks(word: string): number[];
|
|
10
|
+
/**
|
|
11
|
+
* What the line ending at break `k` draws of `word`: everything before it, and at a soft hyphen a REAL
|
|
12
|
+
* hyphen in its place - the only time a soft hyphen shows. The breaker draws this; measuring it measures
|
|
13
|
+
* what is drawn.
|
|
14
|
+
*/
|
|
15
|
+
export declare function headAt(word: string, k: number): string;
|
|
16
|
+
/** A word cut at its break opportunities: the pieces no line can split, as they are drawn. */
|
|
17
|
+
export declare function hyphenPieces(word: string): string[];
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { SOFT_HYPHEN } from "./advance.js";
|
|
2
|
+
import { HYPHEN } from "./word-splitting.js";
|
|
3
|
+
// Where a word MAY break inside itself - pure text rules, no font and no line. Filling lines is
|
|
4
|
+
// `line-breaker.ts`; this only answers which offsets are allowed.
|
|
5
|
+
export const NO_BREAKS = [];
|
|
6
|
+
/** Whether a word holds anything it may break after: a hyphen or a soft hyphen. */
|
|
7
|
+
export const mayBreak = (word) => word.includes("-") || word.includes(SOFT_HYPHEN);
|
|
8
|
+
/**
|
|
9
|
+
* Where a word may break, as offsets into it - after a hyphen (U+002D) as UAX #14 has it: never before
|
|
10
|
+
* it, never before a digit ("CODE-2", "2026-10-16", "-5" stay whole), never after one that starts the
|
|
11
|
+
* word; and after a soft hyphen (U+00AD), the break its author marked.
|
|
12
|
+
*/
|
|
13
|
+
export function hyphenBreaks(word) {
|
|
14
|
+
const out = [];
|
|
15
|
+
for (let i = 1; i < word.length - 1; i++) {
|
|
16
|
+
const next = word[i + 1];
|
|
17
|
+
if (NO_BREAK_BEFORE.has(next))
|
|
18
|
+
continue;
|
|
19
|
+
if (word[i] === SOFT_HYPHEN) {
|
|
20
|
+
out.push(i + 1);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (word[i] !== "-")
|
|
24
|
+
continue;
|
|
25
|
+
if (next === "-" || (next >= "0" && next <= "9"))
|
|
26
|
+
continue;
|
|
27
|
+
out.push(i + 1);
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
// UAX #14 never breaks before closing punctuation, a slash or a quote (LB13, LB19): "Lohn-, Gehalts-",
|
|
32
|
+
// "(well-known)", "Ein-/Ausgang" stay together.
|
|
33
|
+
const NO_BREAK_BEFORE = new Set(")]}!?,.:;/\"'«»‘’“”");
|
|
34
|
+
/**
|
|
35
|
+
* What the line ending at break `k` draws of `word`: everything before it, and at a soft hyphen a REAL
|
|
36
|
+
* hyphen in its place - the only time a soft hyphen shows. The breaker draws this; measuring it measures
|
|
37
|
+
* what is drawn.
|
|
38
|
+
*/
|
|
39
|
+
export function headAt(word, k) {
|
|
40
|
+
if (word[k - 1] !== SOFT_HYPHEN)
|
|
41
|
+
return word.slice(0, k);
|
|
42
|
+
// A soft hyphen right after a real one adds nothing: "ab-" + "cd", not "ab--".
|
|
43
|
+
return word[k - 2] === "-" ? word.slice(0, k - 1) : word.slice(0, k - 1) + HYPHEN;
|
|
44
|
+
}
|
|
45
|
+
/** A word cut at its break opportunities: the pieces no line can split, as they are drawn. */
|
|
46
|
+
export function hyphenPieces(word) {
|
|
47
|
+
const breaks = mayBreak(word) ? hyphenBreaks(word) : NO_BREAKS;
|
|
48
|
+
if (breaks.length === 0)
|
|
49
|
+
return [word];
|
|
50
|
+
return [0, ...breaks].map((from, k) => {
|
|
51
|
+
const to = breaks[k];
|
|
52
|
+
return to === undefined ? word.slice(from) : headAt(word, to).slice(from);
|
|
53
|
+
});
|
|
54
|
+
}
|