@jasy/pdf 1.0.0-alpha.9 → 1.0.0-beta.1
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/README.md +27 -9
- package/dist/api/color.js +69 -0
- package/dist/api/content.d.ts +40 -1
- package/dist/api/content.js +83 -0
- package/dist/api/descriptor.js +6 -1
- package/dist/api/dimension.d.ts +3 -2
- package/dist/api/font-url.js +3 -2
- package/dist/api/structure.d.ts +7 -0
- package/dist/api/structure.js +38 -3
- package/dist/api/text.d.ts +23 -2
- package/dist/api/text.js +9 -0
- package/dist/canvas/painter.d.ts +102 -0
- package/dist/canvas/painter.js +183 -0
- package/dist/elements/canvas-element.d.ts +38 -0
- package/dist/elements/canvas-element.js +47 -0
- package/dist/elements/container-element.d.ts +2 -0
- package/dist/elements/container-element.js +6 -0
- package/dist/elements/index.d.ts +2 -0
- package/dist/elements/index.js +2 -0
- package/dist/elements/layout/expanded-element.d.ts +2 -0
- package/dist/elements/layout/expanded-element.js +4 -0
- package/dist/elements/layout/keep-together-element.d.ts +2 -0
- package/dist/elements/layout/keep-together-element.js +4 -0
- package/dist/elements/layout/padding-element.d.ts +5 -0
- package/dist/elements/layout/padding-element.js +8 -0
- package/dist/elements/layout/struct-group.d.ts +2 -0
- package/dist/elements/layout/struct-group.js +4 -0
- package/dist/elements/pdf-element.d.ts +19 -3
- package/dist/elements/pdf-element.js +22 -4
- package/dist/elements/rectangle-element.d.ts +2 -0
- package/dist/elements/rectangle-element.js +6 -0
- package/dist/elements/row-element.d.ts +2 -0
- package/dist/elements/row-element.js +6 -0
- package/dist/elements/svg-element.d.ts +41 -0
- package/dist/elements/svg-element.js +93 -0
- package/dist/elements/text-element.d.ts +33 -1
- package/dist/elements/text-element.js +80 -4
- package/dist/ir/display-list.d.ts +44 -5
- package/dist/layout/min-intrinsic.d.ts +29 -0
- package/dist/layout/min-intrinsic.js +37 -0
- package/dist/renderer/anchor-renderer.js +1 -1
- package/dist/renderer/bookmark-renderer.js +1 -1
- package/dist/renderer/canvas-renderer.d.ts +14 -0
- package/dist/renderer/canvas-renderer.js +36 -0
- package/dist/renderer/container-renderer.js +1 -3
- package/dist/renderer/default-text-style-renderer.js +1 -1
- package/dist/renderer/deferred-renderer.js +1 -1
- package/dist/renderer/expanded-renderer.js +1 -1
- package/dist/renderer/index.d.ts +2 -0
- package/dist/renderer/index.js +2 -0
- package/dist/renderer/keep-together-renderer.js +1 -1
- package/dist/renderer/link-renderer.js +1 -1
- package/dist/renderer/padding-renderer.js +1 -1
- package/dist/renderer/page-builder-renderer.js +1 -1
- package/dist/renderer/page-renderer.js +1 -3
- package/dist/renderer/pdf-backend.d.ts +15 -4
- package/dist/renderer/pdf-backend.js +125 -35
- package/dist/renderer/pdf-renderer.js +5 -1
- package/dist/renderer/positioned-renderer.js +1 -1
- package/dist/renderer/rectangle-renderer.js +1 -3
- package/dist/renderer/repeating-header-renderer.js +1 -2
- package/dist/renderer/rotated-renderer.js +1 -1
- package/dist/renderer/row-renderer.js +1 -3
- package/dist/renderer/struct-group-renderer.js +1 -1
- package/dist/renderer/svg-renderer.d.ts +11 -0
- package/dist/renderer/svg-renderer.js +27 -0
- package/dist/renderer/text-renderer.js +30 -19
- package/dist/svg/css.d.ts +38 -0
- package/dist/svg/css.js +115 -0
- package/dist/svg/errors.d.ts +24 -0
- package/dist/svg/errors.js +30 -0
- package/dist/svg/gradients.d.ts +34 -0
- package/dist/svg/gradients.js +156 -0
- package/dist/svg/index.d.ts +22 -0
- package/dist/svg/index.js +331 -0
- package/dist/svg/shapes.d.ts +6 -0
- package/dist/svg/shapes.js +177 -0
- package/dist/svg/style.d.ts +61 -0
- package/dist/svg/style.js +144 -0
- package/dist/svg/transform.d.ts +13 -0
- package/dist/svg/transform.js +68 -0
- package/dist/svg/units.d.ts +11 -0
- package/dist/svg/units.js +73 -0
- package/dist/svg/xml.d.ts +24 -0
- package/dist/svg/xml.js +158 -0
- package/dist/text/advance.d.ts +3 -0
- package/dist/text/advance.js +4 -3
- package/dist/text/glyph-coverage.d.ts +15 -0
- package/dist/text/glyph-coverage.js +77 -0
- package/dist/text/line-breaker.d.ts +8 -0
- package/dist/text/line-breaker.js +146 -70
- package/dist/text/shape.d.ts +3 -3
- package/dist/text/shape.js +34 -10
- package/dist/text/text-style.d.ts +13 -0
- package/dist/text/text-style.js +6 -0
- package/dist/text/whitespace.d.ts +4 -0
- package/dist/text/whitespace.js +43 -0
- package/dist/text/word-splitting.d.ts +42 -0
- package/dist/text/word-splitting.js +72 -0
- package/dist/utils/flex-layout.js +67 -18
- package/dist/utils/font-metrics.d.ts +9 -3
- package/dist/utils/pdf-object-manager.d.ts +25 -5
- package/dist/utils/pdf-object-manager.js +82 -18
- package/dist/utils/renderer-registry.d.ts +13 -1
- package/dist/utils/renderer-registry.js +22 -1
- package/dist/utils/woff2.d.ts +6 -0
- package/dist/utils/woff2.js +651 -0
- package/dist/vector/path.d.ts +33 -0
- package/dist/vector/path.js +81 -0
- 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[];
|
package/dist/svg/xml.js
ADDED
|
@@ -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
|
+
}
|
package/dist/text/advance.d.ts
CHANGED
|
@@ -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. */
|
package/dist/text/advance.js
CHANGED
|
@@ -29,15 +29,16 @@ export function codePointCount(text) {
|
|
|
29
29
|
/** The advance of `text` in points: the plain glyph widths, plus kerning (if the document has it on),
|
|
30
30
|
* plus one `letterSpacing` per code point. */
|
|
31
31
|
export function runAdvance(metrics, text, font, letterSpacing = 0) {
|
|
32
|
-
let advance = metrics.getStringWidth(text, font.fontFamily, font.fontSize, font.fontStyle);
|
|
32
|
+
let advance = metrics.getStringWidth(text, font.fontFamily, font.fontSize, font.fontStyle, font.ligatures);
|
|
33
33
|
if (letterSpacing !== 0) {
|
|
34
34
|
// 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) ??
|
|
35
|
+
const glyphs = metrics.shapedGlyphCount?.(text, font.fontFamily, font.fontStyle, font.ligatures) ??
|
|
36
|
+
codePointCount(text);
|
|
36
37
|
advance += glyphs * letterSpacing;
|
|
37
38
|
}
|
|
38
39
|
if (metrics.kerningEnabled) {
|
|
39
40
|
let units = 0;
|
|
40
|
-
for (const k of metrics.getKernPairs(text, font.fontFamily, font.fontStyle))
|
|
41
|
+
for (const k of metrics.getKernPairs(text, font.fontFamily, font.fontStyle, font.ligatures))
|
|
41
42
|
units += k;
|
|
42
43
|
advance += (units / 1000) * font.fontSize; // kern units are em/1000
|
|
43
44
|
}
|
|
@@ -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
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { FontStyle } from "../utils/pdf-object-manager.js";
|
|
2
2
|
import type { FontMetrics } from "../utils/font-metrics.js";
|
|
3
3
|
import type { TextSegment } from "../elements/text-element.js";
|
|
4
|
+
import { type WordSplitting } from "./word-splitting.js";
|
|
4
5
|
/**
|
|
5
6
|
* How far a JUSTIFIED line's spaces may be squeezed to keep one more word on it.
|
|
6
7
|
*
|
|
@@ -22,6 +23,10 @@ export interface LineOptions {
|
|
|
22
23
|
indent?: number;
|
|
23
24
|
/** How far a justified line's spaces may be squeezed to keep one more word (0 = not justified). */
|
|
24
25
|
shrink?: number;
|
|
26
|
+
/** How a word wider than its box is split - hyphenation, break-word, or neither (the default). */
|
|
27
|
+
splitting?: WordSplitting;
|
|
28
|
+
/** Whether the run gets the font's ligatures. Changes which glyphs are drawn, hence the width. */
|
|
29
|
+
ligatures?: boolean;
|
|
25
30
|
}
|
|
26
31
|
/** Default font for segments that don't override it. */
|
|
27
32
|
export interface SegmentDefaults {
|
|
@@ -30,6 +35,9 @@ export interface SegmentDefaults {
|
|
|
30
35
|
fontStyle: FontStyle;
|
|
31
36
|
/** Extra space after every glyph, in points; a segment may override it. Default 0. */
|
|
32
37
|
letterSpacing?: number;
|
|
38
|
+
/** Same knobs as the string path - see `LineOptions.splitting`. */
|
|
39
|
+
splitting?: WordSplitting;
|
|
40
|
+
ligatures?: boolean;
|
|
33
41
|
}
|
|
34
42
|
/** What happens to text beyond `maxLines`: `"clip"` drops it, `"ellipsis"` ends the last kept line
|
|
35
43
|
* with an ellipsis. Mirrors Flutter's `TextOverflow`. */
|