@jasy/pdf 1.0.0-alpha.9 → 1.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +27 -9
  2. package/dist/api/color.js +69 -0
  3. package/dist/api/content.d.ts +40 -1
  4. package/dist/api/content.js +83 -0
  5. package/dist/api/descriptor.js +6 -1
  6. package/dist/api/dimension.d.ts +3 -2
  7. package/dist/api/font-url.js +3 -2
  8. package/dist/api/structure.d.ts +7 -0
  9. package/dist/api/structure.js +38 -3
  10. package/dist/api/text.d.ts +23 -2
  11. package/dist/api/text.js +9 -0
  12. package/dist/canvas/painter.d.ts +102 -0
  13. package/dist/canvas/painter.js +183 -0
  14. package/dist/elements/canvas-element.d.ts +38 -0
  15. package/dist/elements/canvas-element.js +47 -0
  16. package/dist/elements/container-element.d.ts +2 -0
  17. package/dist/elements/container-element.js +6 -0
  18. package/dist/elements/index.d.ts +2 -0
  19. package/dist/elements/index.js +2 -0
  20. package/dist/elements/layout/expanded-element.d.ts +2 -0
  21. package/dist/elements/layout/expanded-element.js +4 -0
  22. package/dist/elements/layout/keep-together-element.d.ts +2 -0
  23. package/dist/elements/layout/keep-together-element.js +4 -0
  24. package/dist/elements/layout/padding-element.d.ts +5 -0
  25. package/dist/elements/layout/padding-element.js +8 -0
  26. package/dist/elements/layout/struct-group.d.ts +2 -0
  27. package/dist/elements/layout/struct-group.js +4 -0
  28. package/dist/elements/pdf-element.d.ts +19 -3
  29. package/dist/elements/pdf-element.js +22 -4
  30. package/dist/elements/rectangle-element.d.ts +2 -0
  31. package/dist/elements/rectangle-element.js +6 -0
  32. package/dist/elements/row-element.d.ts +2 -0
  33. package/dist/elements/row-element.js +6 -0
  34. package/dist/elements/svg-element.d.ts +41 -0
  35. package/dist/elements/svg-element.js +93 -0
  36. package/dist/elements/text-element.d.ts +33 -1
  37. package/dist/elements/text-element.js +80 -4
  38. package/dist/ir/display-list.d.ts +44 -5
  39. package/dist/layout/min-intrinsic.d.ts +29 -0
  40. package/dist/layout/min-intrinsic.js +37 -0
  41. package/dist/renderer/anchor-renderer.js +1 -1
  42. package/dist/renderer/bookmark-renderer.js +1 -1
  43. package/dist/renderer/canvas-renderer.d.ts +14 -0
  44. package/dist/renderer/canvas-renderer.js +36 -0
  45. package/dist/renderer/container-renderer.js +1 -3
  46. package/dist/renderer/default-text-style-renderer.js +1 -1
  47. package/dist/renderer/deferred-renderer.js +1 -1
  48. package/dist/renderer/expanded-renderer.js +1 -1
  49. package/dist/renderer/index.d.ts +2 -0
  50. package/dist/renderer/index.js +2 -0
  51. package/dist/renderer/keep-together-renderer.js +1 -1
  52. package/dist/renderer/link-renderer.js +1 -1
  53. package/dist/renderer/padding-renderer.js +1 -1
  54. package/dist/renderer/page-builder-renderer.js +1 -1
  55. package/dist/renderer/page-renderer.js +1 -3
  56. package/dist/renderer/pdf-backend.d.ts +15 -4
  57. package/dist/renderer/pdf-backend.js +125 -35
  58. package/dist/renderer/pdf-renderer.js +5 -1
  59. package/dist/renderer/positioned-renderer.js +1 -1
  60. package/dist/renderer/rectangle-renderer.js +1 -3
  61. package/dist/renderer/repeating-header-renderer.js +1 -2
  62. package/dist/renderer/rotated-renderer.js +1 -1
  63. package/dist/renderer/row-renderer.js +1 -3
  64. package/dist/renderer/struct-group-renderer.js +1 -1
  65. package/dist/renderer/svg-renderer.d.ts +11 -0
  66. package/dist/renderer/svg-renderer.js +27 -0
  67. package/dist/renderer/text-renderer.js +30 -19
  68. package/dist/svg/css.d.ts +38 -0
  69. package/dist/svg/css.js +115 -0
  70. package/dist/svg/errors.d.ts +24 -0
  71. package/dist/svg/errors.js +30 -0
  72. package/dist/svg/gradients.d.ts +34 -0
  73. package/dist/svg/gradients.js +156 -0
  74. package/dist/svg/index.d.ts +22 -0
  75. package/dist/svg/index.js +331 -0
  76. package/dist/svg/shapes.d.ts +6 -0
  77. package/dist/svg/shapes.js +177 -0
  78. package/dist/svg/style.d.ts +61 -0
  79. package/dist/svg/style.js +144 -0
  80. package/dist/svg/transform.d.ts +13 -0
  81. package/dist/svg/transform.js +68 -0
  82. package/dist/svg/units.d.ts +11 -0
  83. package/dist/svg/units.js +73 -0
  84. package/dist/svg/xml.d.ts +24 -0
  85. package/dist/svg/xml.js +158 -0
  86. package/dist/text/advance.d.ts +3 -0
  87. package/dist/text/advance.js +51 -8
  88. package/dist/text/glyph-coverage.d.ts +15 -0
  89. package/dist/text/glyph-coverage.js +77 -0
  90. package/dist/text/line-breaker.d.ts +8 -0
  91. package/dist/text/line-breaker.js +146 -70
  92. package/dist/text/shape.d.ts +3 -3
  93. package/dist/text/shape.js +34 -10
  94. package/dist/text/text-style.d.ts +13 -0
  95. package/dist/text/text-style.js +6 -0
  96. package/dist/text/whitespace.d.ts +4 -0
  97. package/dist/text/whitespace.js +43 -0
  98. package/dist/text/word-splitting.d.ts +42 -0
  99. package/dist/text/word-splitting.js +72 -0
  100. package/dist/utils/afm-parser.d.ts +0 -1
  101. package/dist/utils/afm-parser.js +15 -16
  102. package/dist/utils/flex-layout.js +67 -18
  103. package/dist/utils/font-metrics.d.ts +12 -3
  104. package/dist/utils/pdf-object-manager.d.ts +31 -5
  105. package/dist/utils/pdf-object-manager.js +107 -21
  106. package/dist/utils/renderer-registry.d.ts +13 -1
  107. package/dist/utils/renderer-registry.js +22 -1
  108. package/dist/utils/utf8-to-windows1252-encoder.js +5 -3
  109. package/dist/utils/woff2.d.ts +6 -0
  110. package/dist/utils/woff2.js +651 -0
  111. package/dist/vector/path.d.ts +33 -0
  112. package/dist/vector/path.js +81 -0
  113. package/package.json +5 -2
@@ -0,0 +1,144 @@
1
+ import { Color } from "../common/color.js";
2
+ import { toColor } from "../api/color.js";
3
+ import { SvgParseError, SvgUnsupportedError } from "./errors.js";
4
+ import { length, ratio } from "./units.js";
5
+ export const isPaintRef = (paint) => paint !== undefined && !(paint instanceof Color);
6
+ /** The colour a shape actually paints with: its raw colour, times the opacities that reached it. */
7
+ export function paintAlpha(color, opacity) {
8
+ if (opacity >= 1)
9
+ return color;
10
+ const [r, g, b] = color.toArray();
11
+ return new Color(r, g, b, color.getAlpha() * opacity);
12
+ }
13
+ export const ROOT_STYLE = {
14
+ fill: new Color(0, 0, 0),
15
+ fillRule: "nonzero",
16
+ stroke: undefined,
17
+ color: new Color(0, 0, 0),
18
+ fillOpacity: 1,
19
+ strokeOpacity: 1,
20
+ opacity: 1,
21
+ };
22
+ /** Splits a `style=""` bag into declarations. It wins over both other sources, as in CSS. */
23
+ function styleBag(value) {
24
+ if (typeof value !== "string")
25
+ return {};
26
+ const out = {};
27
+ for (const declaration of value.split(";")) {
28
+ const colon = declaration.indexOf(":");
29
+ if (colon === -1)
30
+ continue;
31
+ const property = declaration.slice(0, colon).trim();
32
+ if (property)
33
+ out[property] = declaration.slice(colon + 1).trim();
34
+ }
35
+ return out;
36
+ }
37
+ /**
38
+ * Reads a property from all three sources, in CSS order: a presentation attribute is the weakest, a
39
+ * matching `<style>` rule beats it, and the inline `style=""` bag beats both.
40
+ *
41
+ * Exported because anything that INSPECTS a property has to see the same value the style resolver
42
+ * would - `filter="url(#f)"` and `style="filter:url(#f)"` are the same instruction, and checking only
43
+ * the attribute lets the second one through unnoticed.
44
+ */
45
+ export function cascadedReader(attributes, css = {}) {
46
+ const bag = styleBag(attributes["style"]);
47
+ return (name) => {
48
+ const value = bag[name] ?? css[name] ?? attributes[name];
49
+ return value === undefined ? undefined : String(value);
50
+ };
51
+ }
52
+ const dashNumbers = (list) => {
53
+ const found = list.match(/[+-]?(?:\d*\.\d+|\d+\.?)(?:[eE][+-]?\d+)?/g);
54
+ return found ? found.map(Number) : [];
55
+ };
56
+ /**
57
+ * A paint value. `none` is the absence of paint, which is NOT the same as transparent black - a shape
58
+ * with `fill="none"` and a stroke draws its outline only. A `url(#...)` reference means a gradient or
59
+ * pattern, which we do not resolve yet and therefore name.
60
+ */
61
+ function paint(value, current) {
62
+ const v = value.trim();
63
+ if (v === "none")
64
+ return undefined;
65
+ if (v === "currentColor")
66
+ return current;
67
+ const reference = /^url\(\s*#([^)\s]+)\s*\)/.exec(v);
68
+ // A paint reference may name a fallback after it (`url(#g) red`), which is what a renderer uses
69
+ // when the reference is broken - so it is kept as the reference, and resolution decides.
70
+ if (reference)
71
+ return { ref: reference[1] };
72
+ if (v.startsWith("url(")) {
73
+ throw new SvgUnsupportedError(`the paint "${v}"`, "Only a `url(#id)` reference to a gradient is resolved.");
74
+ }
75
+ return svgColor(v);
76
+ }
77
+ /**
78
+ * A colour, with the value quoted in the message. `toColor` throws a bare `Unknown color: "x"`, which
79
+ * inside a 900-element logo says nothing about WHERE - and a malformed file reaches here with pieces
80
+ * of its own markup as the value, so the raw error is actively misleading.
81
+ */
82
+ export function svgColor(value) {
83
+ try {
84
+ return toColor(value);
85
+ }
86
+ catch {
87
+ throw new SvgParseError(`"${value}" is not a colour this SVG can use - expected a name, #hex, rgb() or hsl().`);
88
+ }
89
+ }
90
+ /**
91
+ * Resolves one element's style against its parent's. Everything SVG inherits is inherited; `opacity`
92
+ * is the exception - it applies to the element itself, so it is MULTIPLIED down and folded into the
93
+ * paint of each leaf. That differs from a browser for a `<g opacity>` whose children OVERLAP (a real
94
+ * group needs one transparency group), and is the one approximation in this file.
95
+ */
96
+ export function resolveStyle(parent, attributes, css = {}) {
97
+ const read = cascadedReader(attributes, css);
98
+ const color = read("color") !== undefined ? svgColor(read("color")) : parent.color;
99
+ // `Number("50%")` is NaN and `Number("")` is 0, both of which travel silently into the paint.
100
+ const opacity = parent.opacity * ratio(read("opacity"));
101
+ const fillValue = read("fill");
102
+ const strokeValue = read("stroke");
103
+ const dashValue = read("stroke-dasharray");
104
+ const strokePaint = strokeValue !== undefined ? paint(strokeValue, color) : parent.stroke?.color;
105
+ if (isPaintRef(strokePaint)) {
106
+ // PDF strokes with a colour, never with a shading; painting the outline solid would be a colour
107
+ // the file did not name.
108
+ throw new SvgUnsupportedError("a gradient on a stroke", "PDF strokes with a colour only. Use a solid stroke, or convert the outline to a filled shape.");
109
+ }
110
+ const parts = {
111
+ color: strokePaint,
112
+ width: length(read("stroke-width"), 'the "stroke-width" attribute', parent.stroke?.width ?? 1),
113
+ cap: read("stroke-linecap") ?? parent.stroke?.cap,
114
+ join: read("stroke-linejoin") ?? parent.stroke?.join,
115
+ miterLimit: length(read("stroke-miterlimit"), 'the "stroke-miterlimit" attribute', parent.stroke?.miterLimit ?? 4),
116
+ dash: dashValue !== undefined
117
+ ? dashValue.trim() === "none"
118
+ ? undefined
119
+ : dashNumbers(dashValue)
120
+ : parent.stroke?.dash,
121
+ dashOffset: length(read("stroke-dashoffset"), 'the "stroke-dashoffset" attribute', parent.stroke?.dashOffset ?? 0),
122
+ };
123
+ return {
124
+ fill: fillValue !== undefined ? paint(fillValue, color) : parent.fill,
125
+ fillRule: read("fill-rule") ?? parent.fillRule,
126
+ // A stroke with no colour or no width paints nothing; dropping it here keeps every consumer from
127
+ // having to ask twice.
128
+ stroke: parts.color && parts.width > 0
129
+ ? {
130
+ color: parts.color,
131
+ width: parts.width,
132
+ cap: parts.cap,
133
+ join: parts.join,
134
+ miterLimit: parts.miterLimit,
135
+ dash: parts.dash,
136
+ dashOffset: parts.dashOffset,
137
+ }
138
+ : undefined,
139
+ color,
140
+ fillOpacity: ratio(read("fill-opacity"), parent.fillOpacity),
141
+ strokeOpacity: ratio(read("stroke-opacity"), parent.strokeOpacity),
142
+ opacity,
143
+ };
144
+ }
@@ -0,0 +1,13 @@
1
+ import type { Affine } from "../utils/ttf-parser.js";
2
+ /**
3
+ * The `transform` attribute. SVG's `matrix(a b c d e f)` is exactly the PDF `cm` operand and exactly
4
+ * our IR's `TransformPush.matrix`, both read in a top-left, y-down space - so a transform maps across
5
+ * with no sign flips. That is also why we keep transforms in the graphics state instead of baking them
6
+ * into the coordinates: a stroke inside a scaled group scales WITH it, which is what SVG specifies.
7
+ */
8
+ export declare const IDENTITY: Affine;
9
+ /** `a` then `b` - the matrix that applies `b` in the space `a` established. */
10
+ export declare function multiply(a: Affine, b: Affine): Affine;
11
+ export declare const isIdentity: (m: Affine) => boolean;
12
+ /** Parses a whole `transform` attribute; several functions compose left to right, as in SVG. */
13
+ export declare function parseTransform(value: string): Affine;
@@ -0,0 +1,68 @@
1
+ import { SvgParseError } from "./errors.js";
2
+ /**
3
+ * The `transform` attribute. SVG's `matrix(a b c d e f)` is exactly the PDF `cm` operand and exactly
4
+ * our IR's `TransformPush.matrix`, both read in a top-left, y-down space - so a transform maps across
5
+ * with no sign flips. That is also why we keep transforms in the graphics state instead of baking them
6
+ * into the coordinates: a stroke inside a scaled group scales WITH it, which is what SVG specifies.
7
+ */
8
+ export const IDENTITY = [1, 0, 0, 1, 0, 0];
9
+ /** `a` then `b` - the matrix that applies `b` in the space `a` established. */
10
+ export function multiply(a, b) {
11
+ return [
12
+ a[0] * b[0] + a[2] * b[1],
13
+ a[1] * b[0] + a[3] * b[1],
14
+ a[0] * b[2] + a[2] * b[3],
15
+ a[1] * b[2] + a[3] * b[3],
16
+ a[0] * b[4] + a[2] * b[5] + a[4],
17
+ a[1] * b[4] + a[3] * b[5] + a[5],
18
+ ];
19
+ }
20
+ export const isIdentity = (m) => m.every((n, i) => n === IDENTITY[i]);
21
+ const TRANSFORM = /([a-zA-Z]+)\s*\(([^)]*)\)/g;
22
+ const radians = (deg) => (deg * Math.PI) / 180;
23
+ /** Numbers in an SVG list: separated by commas, whitespace, or nothing at all before a minus sign. */
24
+ function numbers(list) {
25
+ const found = list.match(/[+-]?(?:\d*\.\d+|\d+\.?)(?:[eE][+-]?\d+)?/g);
26
+ return found ? found.map(Number) : [];
27
+ }
28
+ /** Parses a whole `transform` attribute; several functions compose left to right, as in SVG. */
29
+ export function parseTransform(value) {
30
+ let out = IDENTITY;
31
+ TRANSFORM.lastIndex = 0;
32
+ let match;
33
+ while ((match = TRANSFORM.exec(value)) !== null) {
34
+ out = multiply(out, single(match[1], numbers(match[2])));
35
+ }
36
+ return out;
37
+ }
38
+ function single(name, a) {
39
+ switch (name) {
40
+ case "matrix":
41
+ if (a.length < 6)
42
+ throw new SvgParseError(`transform: matrix() needs six numbers`);
43
+ return [a[0], a[1], a[2], a[3], a[4], a[5]];
44
+ case "translate":
45
+ return [1, 0, 0, 1, a[0] ?? 0, a[1] ?? 0];
46
+ case "scale": {
47
+ const sx = a[0] ?? 1;
48
+ // One number scales both axes - the SVG rule, and a common source of squashed logos if missed.
49
+ return [sx, 0, 0, a[1] ?? sx, 0, 0];
50
+ }
51
+ case "rotate": {
52
+ const t = radians(a[0] ?? 0);
53
+ const [cos, sin] = [Math.cos(t), Math.sin(t)];
54
+ const r = [cos, sin, -sin, cos, 0, 0];
55
+ if (a.length < 3)
56
+ return r;
57
+ // rotate(angle cx cy) = translate(cx,cy) rotate(angle) translate(-cx,-cy).
58
+ const [cx, cy] = [a[1], a[2]];
59
+ return multiply(multiply([1, 0, 0, 1, cx, cy], r), [1, 0, 0, 1, -cx, -cy]);
60
+ }
61
+ case "skewX":
62
+ return [1, 0, Math.tan(radians(a[0] ?? 0)), 1, 0, 0];
63
+ case "skewY":
64
+ return [1, Math.tan(radians(a[0] ?? 0)), 0, 1, 0, 0];
65
+ default:
66
+ throw new SvgParseError(`transform: unknown function "${name}()"`);
67
+ }
68
+ }
@@ -0,0 +1,11 @@
1
+ /** A plain number, with nothing else in the string. */
2
+ export declare function strictNumber(text: string): number | undefined;
3
+ /**
4
+ * A length in user units. `undefined` and `""` mean "not given" and return `fallback`; a resolvable
5
+ * absolute unit is converted; a relative one (`%`, `em`, `rem`, `ex`, `ch`, `vw`, …) is NAMED,
6
+ * because resolving it needs a font or a viewport that a shape attribute does not carry.
7
+ */
8
+ export declare function length(value: string | undefined, where: string, fallback?: number): number;
9
+ /** A 0..1 ratio, written as a number or a percentage, clamped - an opacity outside the range is a
10
+ * malformed file, and CSS clamps rather than rejecting. */
11
+ export declare function ratio(value: string | undefined, fallback?: number): number;
@@ -0,0 +1,73 @@
1
+ import { SvgUnsupportedError } from "./errors.js";
2
+ /**
3
+ * Reading NUMBERS out of an SVG.
4
+ *
5
+ * `Number.parseFloat` is the wrong tool on its own: it stops at the first character it cannot use and
6
+ * returns what it had, so `"10px"` is 10 and `"20abc"` is 20. That is convenient exactly once and
7
+ * wrong everywhere else - a length we silently misread draws a shape the file did not describe.
8
+ *
9
+ * So the rules here are explicit: a plain number is a number, `px` is the user unit and means the
10
+ * same thing, a percentage is only valid where a ratio is expected, and anything else is NAMED. In
11
+ * 10,819 real files, 53 use `px` on a shape attribute (we drew nothing for them) and one uses a
12
+ * percentage opacity.
13
+ */
14
+ /** Absolute CSS units are defined against the user unit, which SVG pins to 1/96 inch. */
15
+ const ABSOLUTE = {
16
+ px: 1,
17
+ pt: 96 / 72,
18
+ pc: 16,
19
+ in: 96,
20
+ cm: 96 / 2.54,
21
+ mm: 96 / 25.4,
22
+ q: 96 / 101.6,
23
+ };
24
+ const NUMBER = /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/;
25
+ const WITH_UNIT = /^([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*([a-zA-Z%]+)$/;
26
+ /** A plain number, with nothing else in the string. */
27
+ export function strictNumber(text) {
28
+ const t = text.trim();
29
+ if (!NUMBER.test(t))
30
+ return undefined;
31
+ const n = Number(t);
32
+ // `1e400` matches the grammar and overflows to Infinity. A non-finite number reaches the content
33
+ // stream as the literal text `Infinity`, which viewers discard the rest of the page over.
34
+ return Number.isFinite(n) ? n : undefined;
35
+ }
36
+ /**
37
+ * A length in user units. `undefined` and `""` mean "not given" and return `fallback`; a resolvable
38
+ * absolute unit is converted; a relative one (`%`, `em`, `rem`, `ex`, `ch`, `vw`, …) is NAMED,
39
+ * because resolving it needs a font or a viewport that a shape attribute does not carry.
40
+ */
41
+ export function length(value, where, fallback = 0) {
42
+ if (value === undefined || value.trim() === "")
43
+ return fallback;
44
+ const plain = strictNumber(value);
45
+ if (plain !== undefined)
46
+ return plain;
47
+ const parts = WITH_UNIT.exec(value.trim());
48
+ if (parts) {
49
+ const factor = ABSOLUTE[parts[2].toLowerCase()];
50
+ if (factor !== undefined) {
51
+ // The conversion itself can overflow (`1e308in`), and a non-finite number reaches the content
52
+ // stream as the literal text `Infinity`.
53
+ const converted = Number(parts[1]) * factor;
54
+ return Number.isFinite(converted) ? converted : fallback;
55
+ }
56
+ throw new SvgUnsupportedError(`the length "${value.trim()}" on ${where}`, "Only absolute units (px, pt, pc, in, cm, mm, q) and plain numbers are resolved.");
57
+ }
58
+ // Not a number at all: the file is malformed here. Treat it as absent rather than as zero-with-a-
59
+ // shrug, so a caller sees the same thing as for a missing attribute.
60
+ return fallback;
61
+ }
62
+ /** A 0..1 ratio, written as a number or a percentage, clamped - an opacity outside the range is a
63
+ * malformed file, and CSS clamps rather than rejecting. */
64
+ export function ratio(value, fallback = 1) {
65
+ if (value === undefined || value.trim() === "")
66
+ return fallback;
67
+ const t = value.trim();
68
+ const raw = t.endsWith("%") ? strictNumber(t.slice(0, -1)) : strictNumber(t);
69
+ if (raw === undefined)
70
+ return fallback;
71
+ const scaled = t.endsWith("%") ? raw / 100 : raw;
72
+ return Math.max(0, Math.min(1, scaled));
73
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * A minimal XML reader for SVG.
3
+ *
4
+ * We used `svg-parser` first. It COERCES an attribute that looks numeric, so `id="58310095e0"` came
5
+ * back as the number 58310095 and `id="1e999"` as Infinity - the original string simply gone. That
6
+ * breaks the one thing it is needed for, resolving `url(#id)` references, and no amount of care on
7
+ * our side can undo it. An XML attribute is a string; this reader treats it as one.
8
+ *
9
+ * The scope is what SVG actually needs: elements, attributes, text, CDATA, comments, the XML
10
+ * declaration and a doctype. No entity expansion beyond the five predefined ones plus numeric
11
+ * references, no DTD processing, no namespace resolution (the caller strips prefixes itself).
12
+ */
13
+ export interface XmlElement {
14
+ type: "element";
15
+ tagName: string;
16
+ attributes: Record<string, string>;
17
+ children: XmlNode[];
18
+ }
19
+ export interface XmlText {
20
+ type: "text";
21
+ value: string;
22
+ }
23
+ export type XmlNode = XmlElement | XmlText;
24
+ export declare function parseXml(source: string): XmlElement[];
@@ -0,0 +1,158 @@
1
+ import { SvgParseError } from "./errors.js";
2
+ const ENTITIES = {
3
+ amp: "&",
4
+ lt: "<",
5
+ gt: ">",
6
+ quot: '"',
7
+ apos: "'",
8
+ };
9
+ /** The five predefined entities plus numeric references; anything else is left as written. */
10
+ function decodeEntities(text) {
11
+ if (!text.includes("&"))
12
+ return text;
13
+ return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (whole, body) => {
14
+ if (body.startsWith("#")) {
15
+ const code = body.startsWith("#x")
16
+ ? parseInt(body.slice(2), 16)
17
+ : parseInt(body.slice(1), 10);
18
+ return Number.isFinite(code) && code >= 0 && code <= 0x10ffff
19
+ ? String.fromCodePoint(code)
20
+ : whole;
21
+ }
22
+ return ENTITIES[body] ?? whole;
23
+ });
24
+ }
25
+ const ATTRIBUTE = /([^\s=/>]+)\s*(?:=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
26
+ function parseAttributes(text) {
27
+ const out = {};
28
+ ATTRIBUTE.lastIndex = 0;
29
+ let match;
30
+ while ((match = ATTRIBUTE.exec(text)) !== null) {
31
+ // A valueless attribute (`hidden`) is its own name, as in HTML; SVG has none, but it must not
32
+ // swallow the following one.
33
+ out[match[1]] = decodeEntities(match[3] ?? match[4] ?? match[5] ?? "");
34
+ }
35
+ return out;
36
+ }
37
+ /**
38
+ * Parses a document into its root elements. Text outside an element is dropped, which is what the
39
+ * whitespace between tags is.
40
+ *
41
+ * Mismatched closing tags are tolerated rather than fatal: real exports contain them, browsers
42
+ * recover, and a logo that draws is worth more than a purist error. An unclosed element simply ends
43
+ * at the end of the document.
44
+ */
45
+ /**
46
+ * The index of the `>` that ends a tag, skipping any inside a quoted attribute value - `d="M0 0>10"`
47
+ * is legal XML, and a plain `indexOf(">")` would cut the tag in half there.
48
+ */
49
+ function tagEnd(source, from) {
50
+ let quote = "";
51
+ for (let i = from + 1; i < source.length; i++) {
52
+ const ch = source[i];
53
+ if (quote) {
54
+ if (ch === quote)
55
+ quote = "";
56
+ }
57
+ else if (ch === '"' || ch === "'") {
58
+ quote = ch;
59
+ }
60
+ else if (ch === ">") {
61
+ return i;
62
+ }
63
+ }
64
+ return -1;
65
+ }
66
+ export function parseXml(source) {
67
+ const roots = [];
68
+ const stack = [];
69
+ let i = 0;
70
+ const push = (node) => {
71
+ if (stack.length === 0) {
72
+ if (node.type === "element")
73
+ roots.push(node);
74
+ }
75
+ else {
76
+ stack[stack.length - 1].children.push(node);
77
+ }
78
+ };
79
+ while (i < source.length) {
80
+ const open = source.indexOf("<", i);
81
+ if (open === -1)
82
+ break;
83
+ if (open > i) {
84
+ const raw = source.slice(i, open);
85
+ if (/\S/.test(raw))
86
+ push({ type: "text", value: decodeEntities(raw) });
87
+ }
88
+ // `<!-- -->`, `<![CDATA[ ]]>`, `<?xml ?>` and `<!DOCTYPE >` are skipped or unwrapped whole.
89
+ if (source.startsWith("<!--", open)) {
90
+ const end = source.indexOf("-->", open + 4);
91
+ i = end === -1 ? source.length : end + 3;
92
+ continue;
93
+ }
94
+ if (source.startsWith("<![CDATA[", open)) {
95
+ const end = source.indexOf("]]>", open + 9);
96
+ const value = source.slice(open + 9, end === -1 ? source.length : end);
97
+ if (value)
98
+ push({ type: "text", value });
99
+ i = end === -1 ? source.length : end + 3;
100
+ continue;
101
+ }
102
+ if (source.startsWith("<?", open)) {
103
+ const end = source.indexOf("?>", open + 2);
104
+ i = end === -1 ? source.length : end + 2;
105
+ continue;
106
+ }
107
+ if (source.startsWith("<!", open)) {
108
+ // A doctype may carry an internal subset in brackets, which can itself contain '>'.
109
+ let depth = 0;
110
+ let j = open + 2;
111
+ for (; j < source.length; j++) {
112
+ const ch = source[j];
113
+ if (ch === "[")
114
+ depth++;
115
+ else if (ch === "]")
116
+ depth--;
117
+ else if (ch === ">" && depth <= 0)
118
+ break;
119
+ }
120
+ i = j + 1;
121
+ continue;
122
+ }
123
+ const close = tagEnd(source, open);
124
+ if (close === -1)
125
+ break;
126
+ const inner = source.slice(open + 1, close);
127
+ if (inner.startsWith("/")) {
128
+ const name = inner.slice(1).trim();
129
+ // Close the nearest matching ancestor. A stray closing tag with no match is ignored.
130
+ const at = stack.map((e) => e.tagName).lastIndexOf(name);
131
+ if (at !== -1)
132
+ stack.length = at;
133
+ i = close + 1;
134
+ continue;
135
+ }
136
+ const selfClosing = inner.trimEnd().endsWith("/");
137
+ const body = selfClosing ? inner.slice(0, inner.lastIndexOf("/")) : inner;
138
+ const nameEnd = body.search(/[\s/]/);
139
+ const tagName = (nameEnd === -1 ? body : body.slice(0, nameEnd)).trim();
140
+ if (tagName === "") {
141
+ i = close + 1;
142
+ continue;
143
+ }
144
+ const element = {
145
+ type: "element",
146
+ tagName,
147
+ attributes: nameEnd === -1 ? {} : parseAttributes(body.slice(nameEnd)),
148
+ children: [],
149
+ };
150
+ push(element);
151
+ if (!selfClosing)
152
+ stack.push(element);
153
+ i = close + 1;
154
+ }
155
+ if (roots.length === 0)
156
+ throw new SvgParseError("the file contains no XML elements at all.");
157
+ return roots;
158
+ }
@@ -25,6 +25,9 @@ export interface RunFont {
25
25
  fontFamily: string;
26
26
  fontSize: number;
27
27
  fontStyle: FontStyle;
28
+ /** Whether this run gets the font's ligatures. Part of the FONT, because it changes which glyphs are
29
+ * drawn and therefore how wide the run is - measuring without it would not match the drawing. */
30
+ ligatures?: boolean;
28
31
  }
29
32
  /** Number of Unicode code points (not UTF-16 units): an astral char is ONE, so it takes one
30
33
  * letter-spacing, not two. Matches how `getStringWidth` iterates. */
@@ -26,20 +26,63 @@ export function codePointCount(text) {
26
26
  n++;
27
27
  return n;
28
28
  }
29
+ /**
30
+ * Glyph widths plus kerning, memoised per metrics object - the line breaker asks for the same run
31
+ * many times over.
32
+ *
33
+ * Keyed by nested maps on primitives, never a composed string key: building one allocates per call,
34
+ * which costs more than the lookup saves.
35
+ *
36
+ * `letterSpacing` is not in the key: it is added outside, so a spaced and an unspaced run share the
37
+ * width. Font size IS in it - scaling a cached em width drifts in the last bit, which is enough to
38
+ * move a line break. Kerning is a flag on the metrics rather than a key, so the entry records it and
39
+ * is thrown away when it flips; the same document can be rendered twice with different options.
40
+ */
41
+ const ADVANCE_CACHE = new WeakMap();
42
+ function baseAdvance(metrics, text, font) {
43
+ let entry = ADVANCE_CACHE.get(metrics);
44
+ const epoch = metrics.fontEpoch ?? 0;
45
+ const kerning = metrics.kerningEnabled;
46
+ // A face registered since we measured makes a family name mean something else; a flipped kerning
47
+ // flag makes every width wrong. Either way the answers are stale.
48
+ if (!entry || entry.epoch !== epoch || entry.kerning !== kerning) {
49
+ entry = { epoch, kerning, byFamily: new Map() };
50
+ ADVANCE_CACHE.set(metrics, entry);
51
+ }
52
+ let byStyle = entry.byFamily.get(font.fontFamily);
53
+ if (!byStyle)
54
+ entry.byFamily.set(font.fontFamily, (byStyle = new Map()));
55
+ let byLigatures = byStyle.get(font.fontStyle);
56
+ if (!byLigatures)
57
+ byStyle.set(font.fontStyle, (byLigatures = new Map()));
58
+ let bySize = byLigatures.get(font.ligatures);
59
+ if (!bySize)
60
+ byLigatures.set(font.ligatures, (bySize = new Map()));
61
+ let byText = bySize.get(font.fontSize);
62
+ if (!byText)
63
+ bySize.set(font.fontSize, (byText = new Map()));
64
+ const hit = byText.get(text);
65
+ if (hit !== undefined)
66
+ return hit;
67
+ let advance = metrics.getStringWidth(text, font.fontFamily, font.fontSize, font.fontStyle, font.ligatures);
68
+ if (metrics.kerningEnabled) {
69
+ let units = 0;
70
+ for (const k of metrics.getKernPairs(text, font.fontFamily, font.fontStyle, font.ligatures))
71
+ units += k;
72
+ advance += (units / 1000) * font.fontSize; // kern units are em/1000
73
+ }
74
+ byText.set(text, advance);
75
+ return advance;
76
+ }
29
77
  /** The advance of `text` in points: the plain glyph widths, plus kerning (if the document has it on),
30
78
  * plus one `letterSpacing` per code point. */
31
79
  export function runAdvance(metrics, text, font, letterSpacing = 0) {
32
- let advance = metrics.getStringWidth(text, font.fontFamily, font.fontSize, font.fontStyle);
80
+ let advance = baseAdvance(metrics, text, font);
33
81
  if (letterSpacing !== 0) {
34
82
  // Per DRAWN glyph, which is what `Tc` applies to; only a ligature makes that differ.
35
- const glyphs = metrics.shapedGlyphCount?.(text, font.fontFamily, font.fontStyle) ?? codePointCount(text);
83
+ const glyphs = metrics.shapedGlyphCount?.(text, font.fontFamily, font.fontStyle, font.ligatures) ??
84
+ codePointCount(text);
36
85
  advance += glyphs * letterSpacing;
37
86
  }
38
- if (metrics.kerningEnabled) {
39
- let units = 0;
40
- for (const k of metrics.getKernPairs(text, font.fontFamily, font.fontStyle))
41
- units += k;
42
- advance += (units / 1000) * font.fontSize; // kern units are em/1000
43
- }
44
87
  return advance;
45
88
  }
@@ -0,0 +1,15 @@
1
+ import type { FontMetrics } from "../utils/font-metrics.js";
2
+ import type { FontStyle } from "../utils/pdf-object-manager.js";
3
+ export interface CoverageResult {
4
+ /** The text as it can actually be drawn. */
5
+ text: string;
6
+ /** Code points that had to go - empty for every document that never hits this. */
7
+ dropped: number[];
8
+ }
9
+ /**
10
+ * Replaces or removes what `fontFamily` cannot draw. Returns the input unchanged (and no allocation
11
+ * beyond the scan) when everything is drawable, which is the overwhelmingly common case.
12
+ */
13
+ export declare function coverText(text: string, fontFamily: string, fontStyle: FontStyle, metrics: FontMetrics): CoverageResult;
14
+ /** `U+2713` - how a dropped code point is named to the caller. */
15
+ export declare function formatCodePoint(cp: number): string;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * A code point the chosen font cannot draw becomes glyph 0 - which IS `.notdef`, and referencing it is
3
+ * forbidden by PDF/A (ISO 19005-3, 6.2.11.8). So an invoice carrying a "✓" in its description is a
4
+ * conforming file right up until someone validates it.
5
+ *
6
+ * The check has to be DYNAMIC, against the font actually resolved: which characters are missing is a
7
+ * property of that font, not of Unicode. `@jasy/e-invoice` embeds Liberation, and its gaps look
8
+ * arbitrary from the outside - "→" draws, "⇒" does not; "₴" draws, "₽" does not.
9
+ *
10
+ * What we do about it, in this order:
11
+ * 1. The font has it -> keep, untouched. This is every ordinary document, unchanged.
12
+ * 2. A plain equivalent MEANS the same and the font has THAT -> substitute.
13
+ * 3. Otherwise -> drop it, and report it, so the application can say which character
14
+ * went missing instead of shipping a box or a broken file.
15
+ */
16
+ /**
17
+ * Substitutes that keep the meaning. Deliberately tiny: only where dropping would CHANGE the text
18
+ * rather than thin it. `E‑Rechnung` with a non-breaking hyphen must not become `ERechnung`.
19
+ *
20
+ * Not a list of "what Word pastes" - most of that (quotes, dashes, the ellipsis) is in WinAnsi and in
21
+ * every normal font, so it never reaches rule 3. Entries earn their place by being measured, not
22
+ * guessed: each one is a code point seen missing from a real embedded font.
23
+ */
24
+ const EQUIVALENTS = {
25
+ 0x2010: "-", // hyphen
26
+ 0x2011: "-", // non-breaking hyphen - the one that turns E‑Rechnung into ERechnung
27
+ 0x2012: "-", // figure dash
28
+ 0x2015: "-", // horizontal bar
29
+ 0x2007: " ", // figure space
30
+ 0x2008: " ", // punctuation space
31
+ 0x2009: " ", // thin space
32
+ 0x200a: " ", // hair space
33
+ 0x202f: " ", // narrow no-break space
34
+ 0x2032: "'", // prime
35
+ 0x2033: '"', // double prime
36
+ 0x02bc: "'", // modifier letter apostrophe
37
+ };
38
+ /**
39
+ * Replaces or removes what `fontFamily` cannot draw. Returns the input unchanged (and no allocation
40
+ * beyond the scan) when everything is drawable, which is the overwhelmingly common case.
41
+ */
42
+ export function coverText(text, fontFamily, fontStyle, metrics) {
43
+ let out = null; // built only once something has to change
44
+ const dropped = [];
45
+ let index = 0;
46
+ for (const char of text) {
47
+ const cp = char.codePointAt(0);
48
+ // A space and a line break are handled by the breaker, never drawn as a glyph.
49
+ const drawable = cp === 0x20 ||
50
+ cp === 0x0a ||
51
+ metrics.hasGlyph(cp, fontFamily, fontStyle) ||
52
+ // Colour emoji is drawn from a fallback font or an image, NOT from the text font - it is missing
53
+ // there by design, and removing it would delete the very thing `Document({ emoji })` exists for.
54
+ metrics.rendersAsEmoji?.(cp, fontFamily, fontStyle) === true;
55
+ if (!drawable) {
56
+ if (out === null)
57
+ out = text.slice(0, index);
58
+ const equivalent = EQUIVALENTS[cp];
59
+ if (equivalent !== undefined &&
60
+ metrics.hasGlyph(equivalent.codePointAt(0), fontFamily, fontStyle)) {
61
+ out += equivalent;
62
+ }
63
+ else {
64
+ dropped.push(cp);
65
+ }
66
+ }
67
+ else if (out !== null) {
68
+ out += char;
69
+ }
70
+ index += char.length;
71
+ }
72
+ return out === null ? { text, dropped } : { text: out, dropped };
73
+ }
74
+ /** `U+2713` - how a dropped code point is named to the caller. */
75
+ export function formatCodePoint(cp) {
76
+ return "U+" + cp.toString(16).toUpperCase().padStart(4, "0");
77
+ }