@jasy/pdf 1.0.0-alpha.8 → 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 +29 -10
- package/dist/api/color.js +69 -0
- package/dist/api/content.d.ts +45 -5
- package/dist/api/content.js +96 -7
- package/dist/api/descriptor.js +6 -1
- package/dist/api/dimension.d.ts +57 -0
- package/dist/api/dimension.js +27 -0
- package/dist/api/font-url.d.ts +28 -0
- package/dist/api/font-url.js +135 -0
- package/dist/api/gradient.d.ts +53 -0
- package/dist/api/gradient.js +97 -0
- package/dist/api/index.d.ts +2 -0
- package/dist/api/index.js +2 -0
- package/dist/api/insets.d.ts +19 -12
- package/dist/api/insets.js +18 -7
- package/dist/api/layout.d.ts +20 -6
- package/dist/api/layout.js +31 -6
- package/dist/api/page-builder.d.ts +8 -0
- package/dist/api/page-builder.js +12 -0
- package/dist/api/structure.d.ts +12 -0
- package/dist/api/structure.js +54 -7
- package/dist/api/text.d.ts +65 -8
- package/dist/api/text.js +38 -5
- package/dist/canvas/painter.d.ts +102 -0
- package/dist/canvas/painter.js +183 -0
- package/dist/edit/document.d.ts +14 -4
- package/dist/edit/document.js +15 -22
- package/dist/edit/fill.d.ts +19 -2
- package/dist/edit/fill.js +63 -34
- package/dist/edit/flatten.d.ts +13 -1
- package/dist/edit/flatten.js +38 -13
- package/dist/edit/index.d.ts +2 -0
- package/dist/edit/index.js +3 -1
- package/dist/edit/objects.d.ts +5 -0
- package/dist/edit/objects.js +27 -0
- package/dist/edit/writer.d.ts +7 -0
- package/dist/edit/writer.js +23 -1
- package/dist/elements/canvas-element.d.ts +38 -0
- package/dist/elements/canvas-element.js +47 -0
- package/dist/elements/container-element.d.ts +23 -1
- package/dist/elements/container-element.js +31 -10
- package/dist/elements/image-element.d.ts +18 -4
- package/dist/elements/image-element.js +27 -12
- package/dist/elements/index.d.ts +2 -0
- package/dist/elements/index.js +2 -0
- package/dist/elements/layout/expanded-element.d.ts +5 -1
- package/dist/elements/layout/expanded-element.js +16 -3
- 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 +9 -2
- package/dist/elements/layout/padding-element.js +12 -2
- package/dist/elements/layout/page-builder-element.js +2 -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 +75 -1
- package/dist/elements/pdf-element.js +80 -1
- package/dist/elements/rectangle-element.d.ts +22 -6
- package/dist/elements/rectangle-element.js +31 -12
- package/dist/elements/row-element.d.ts +26 -1
- package/dist/elements/row-element.js +31 -10
- package/dist/elements/svg-element.d.ts +41 -0
- package/dist/elements/svg-element.js +93 -0
- package/dist/elements/text-element.d.ts +92 -1
- package/dist/elements/text-element.js +208 -33
- package/dist/ir/display-list.d.ts +67 -11
- package/dist/ir/display-list.js +8 -1
- package/dist/layout/alignment.d.ts +10 -0
- package/dist/layout/alignment.js +6 -0
- package/dist/layout/box-constraints.d.ts +53 -0
- package/dist/layout/box-constraints.js +72 -0
- package/dist/layout/insets.d.ts +28 -0
- package/dist/layout/insets.js +20 -0
- 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/image-renderer.js +2 -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 +21 -4
- package/dist/renderer/pdf-backend.js +198 -61
- package/dist/renderer/pdf-document-renderer.js +8 -1
- package/dist/renderer/pdf-renderer.js +5 -1
- package/dist/renderer/positioned-renderer.js +1 -1
- package/dist/renderer/rectangle-renderer.js +9 -5
- 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.d.ts +2 -2
- package/dist/renderer/text-renderer.js +176 -57
- 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 +8 -4
- package/dist/text/arabic.d.ts +20 -0
- package/dist/text/arabic.js +101 -0
- package/dist/text/bidi.d.ts +33 -0
- package/dist/text/bidi.js +94 -0
- package/dist/text/font-fallback.d.ts +25 -0
- package/dist/text/font-fallback.js +38 -0
- package/dist/text/glyph-coverage.d.ts +15 -0
- package/dist/text/glyph-coverage.js +77 -0
- package/dist/text/line-breaker.d.ts +43 -1
- package/dist/text/line-breaker.js +181 -45
- package/dist/text/orphans-widows.d.ts +27 -0
- package/dist/text/orphans-widows.js +53 -0
- package/dist/text/shape.d.ts +29 -0
- package/dist/text/shape.js +91 -0
- package/dist/text/text-style.d.ts +38 -0
- package/dist/text/text-style.js +50 -1
- 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.d.ts +24 -4
- package/dist/utils/flex-layout.js +218 -7
- package/dist/utils/font-metrics.d.ts +13 -2
- package/dist/utils/gsub.d.ts +36 -0
- package/dist/utils/gsub.js +178 -0
- package/dist/utils/inflate.d.ts +16 -0
- package/dist/utils/inflate.js +52 -0
- package/dist/utils/pdf-object-manager.d.ts +44 -3
- package/dist/utils/pdf-object-manager.js +176 -14
- package/dist/utils/renderer-registry.d.ts +13 -1
- package/dist/utils/renderer-registry.js +22 -1
- package/dist/utils/ttf-parser.d.ts +6 -0
- package/dist/utils/ttf-parser.js +19 -0
- package/dist/utils/utf8-to-windows1252-encoder.d.ts +6 -0
- package/dist/utils/utf8-to-windows1252-encoder.js +10 -0
- package/dist/utils/woff.d.ts +10 -0
- package/dist/utils/woff.js +116 -0
- package/dist/utils/woff2.d.ts +6 -0
- package/dist/utils/woff2.js +651 -0
- package/dist/validators/element-validator.js +9 -2
- package/dist/vector/path.d.ts +33 -0
- package/dist/vector/path.js +81 -0
- package/package.json +6 -2
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { Color } from "../common/color.js";
|
|
2
|
+
import { SvgUnsupportedError } from "./errors.js";
|
|
3
|
+
import { IDENTITY, isIdentity, parseTransform } from "./transform.js";
|
|
4
|
+
import { svgColor } from "./style.js";
|
|
5
|
+
import { boundsOf } from "../vector/path.js";
|
|
6
|
+
import { ratio } from "./units.js";
|
|
7
|
+
const localName = (tagName) => {
|
|
8
|
+
const colon = tagName.indexOf(":");
|
|
9
|
+
return colon === -1 ? tagName : tagName.slice(colon + 1);
|
|
10
|
+
};
|
|
11
|
+
const num = (value, fallback) => {
|
|
12
|
+
if (value === undefined)
|
|
13
|
+
return fallback;
|
|
14
|
+
const n = Number.parseFloat(value);
|
|
15
|
+
if (!Number.isFinite(n))
|
|
16
|
+
return fallback;
|
|
17
|
+
return value.trim().endsWith("%") ? n / 100 : n;
|
|
18
|
+
};
|
|
19
|
+
/** One `<stop>`, plus its own opacity - which is kept apart because only a UNIFORM one survives. */
|
|
20
|
+
function readStop(element) {
|
|
21
|
+
const attributes = element.attributes;
|
|
22
|
+
const style = {};
|
|
23
|
+
for (const declaration of (attributes["style"] ?? "").split(";")) {
|
|
24
|
+
const colon = declaration.indexOf(":");
|
|
25
|
+
if (colon !== -1)
|
|
26
|
+
style[declaration.slice(0, colon).trim()] = declaration.slice(colon + 1).trim();
|
|
27
|
+
}
|
|
28
|
+
const read = (name) => style[name] ?? attributes[name];
|
|
29
|
+
const opacity = ratio(read("stop-opacity"));
|
|
30
|
+
const colorText = read("stop-color") ?? "#000000";
|
|
31
|
+
return {
|
|
32
|
+
stop: {
|
|
33
|
+
// Both are clamped: a PDF stitching function needs its bounds inside [0,1] and increasing, so
|
|
34
|
+
// a malformed file must not be able to produce one that a reader rejects.
|
|
35
|
+
offset: Math.max(0, Math.min(1, num(read("offset"), 0))),
|
|
36
|
+
color: colorText === "currentColor" ? new Color(0, 0, 0) : svgColor(colorText),
|
|
37
|
+
},
|
|
38
|
+
opacity,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** Every gradient definition in the document, by id - they are referenced, not drawn in place. */
|
|
42
|
+
export function gradientsOf(node, into = new Map(), depth = 0) {
|
|
43
|
+
// Bounded like the walk in `index.ts`: this recurses, and a pathological file must not blow the
|
|
44
|
+
// call stack with a raw RangeError.
|
|
45
|
+
if (node.type !== "element" || depth > 256)
|
|
46
|
+
return into;
|
|
47
|
+
const name = localName(node.tagName);
|
|
48
|
+
if (name === "linearGradient" || name === "radialGradient") {
|
|
49
|
+
const id = node.attributes["id"];
|
|
50
|
+
if (id !== undefined) {
|
|
51
|
+
const href = node.attributes["href"] ?? node.attributes["xlink:href"];
|
|
52
|
+
const read = node.children
|
|
53
|
+
.filter((c) => c.type === "element" && localName(c.tagName) === "stop")
|
|
54
|
+
.map(readStop);
|
|
55
|
+
into.set(id, {
|
|
56
|
+
kind: name === "linearGradient" ? "linear" : "radial",
|
|
57
|
+
attributes: node.attributes,
|
|
58
|
+
stops: read.map((r) => r.stop),
|
|
59
|
+
opacities: read.map((r) => r.opacity),
|
|
60
|
+
href: href?.startsWith("#") ? href.slice(1) : undefined,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return into;
|
|
64
|
+
}
|
|
65
|
+
for (const child of node.children)
|
|
66
|
+
gradientsOf(child, into, depth + 1);
|
|
67
|
+
return into;
|
|
68
|
+
}
|
|
69
|
+
/** Walks the `href` chain: the nearest definition wins for each attribute, stops come from the first
|
|
70
|
+
* ancestor that has any. A cycle stops the walk rather than hanging. */
|
|
71
|
+
function inherited(def, defs) {
|
|
72
|
+
const attributes = {};
|
|
73
|
+
let stops = [];
|
|
74
|
+
let opacities = [];
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
let current = def;
|
|
77
|
+
while (current && !seen.has(current)) {
|
|
78
|
+
seen.add(current);
|
|
79
|
+
for (const [key, value] of Object.entries(current.attributes)) {
|
|
80
|
+
if (attributes[key] === undefined)
|
|
81
|
+
attributes[key] = value;
|
|
82
|
+
}
|
|
83
|
+
if (stops.length === 0) {
|
|
84
|
+
stops = current.stops;
|
|
85
|
+
opacities = current.opacities;
|
|
86
|
+
}
|
|
87
|
+
current = current.href === undefined ? undefined : defs.get(current.href);
|
|
88
|
+
}
|
|
89
|
+
return { attributes, stops, opacities };
|
|
90
|
+
}
|
|
91
|
+
const SPREAD = {
|
|
92
|
+
pad: "pad",
|
|
93
|
+
repeat: "repeat",
|
|
94
|
+
reflect: "reflect",
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Resolves a reference into an IR gradient positioned on `commands`.
|
|
98
|
+
*
|
|
99
|
+
* The anchors are mapped into the shape's own space, then through `gradientTransform` - so they end
|
|
100
|
+
* up in the SAME coordinates as the path, which is what the backend and the Y-flip both assume.
|
|
101
|
+
*/
|
|
102
|
+
export function resolveSvgGradient(def, defs, commands) {
|
|
103
|
+
const { attributes, stops, opacities } = inherited(def, defs);
|
|
104
|
+
if (stops.length === 0) {
|
|
105
|
+
throw new SvgUnsupportedError("a gradient with no <stop> of its own or inherited", "Add stops, or replace the fill with a solid colour.");
|
|
106
|
+
}
|
|
107
|
+
// A PDF shading is DeviceRGB and has no alpha channel. One opacity across all stops IS
|
|
108
|
+
// expressible - it is just the shape's fill alpha - and 87 of the 125 real files that use
|
|
109
|
+
// `stop-opacity` at all are that case. Stops with DIFFERENT opacities would need a soft-mask
|
|
110
|
+
// luminosity group, so that stays named rather than drawn opaque.
|
|
111
|
+
const alpha = opacities[0] ?? 1;
|
|
112
|
+
if (opacities.some((o) => o !== alpha)) {
|
|
113
|
+
throw new SvgUnsupportedError("a gradient whose stops have DIFFERENT stop-opacity values", "A PDF shading has no alpha channel; only one opacity for the whole gradient is expressible.");
|
|
114
|
+
}
|
|
115
|
+
// SVG says an offset lower than the one before it is raised to match; a decreasing pair would
|
|
116
|
+
// otherwise become an invalid Domain in the shading function.
|
|
117
|
+
let previous = 0;
|
|
118
|
+
for (const stop of stops) {
|
|
119
|
+
stop.offset = previous = Math.max(previous, stop.offset);
|
|
120
|
+
}
|
|
121
|
+
const box = boundsOf(commands);
|
|
122
|
+
const onBox = (attributes["gradientUnits"] ?? "objectBoundingBox") === "objectBoundingBox";
|
|
123
|
+
// In objectBoundingBox units every coordinate is a FRACTION of the shape's box.
|
|
124
|
+
const px = (v) => (onBox ? box.x + v * box.width : v);
|
|
125
|
+
const py = (v) => (onBox ? box.y + v * box.height : v);
|
|
126
|
+
// A radius in box units is a fraction of the box's diagonal-ish size, per the SVG formula.
|
|
127
|
+
const pr = (v) => (onBox ? v * Math.sqrt((box.width ** 2 + box.height ** 2) / 2) : v);
|
|
128
|
+
const matrix = attributes["gradientTransform"]
|
|
129
|
+
? parseTransform(attributes["gradientTransform"])
|
|
130
|
+
: IDENTITY;
|
|
131
|
+
const skewed = !isIdentity(matrix) && (matrix[1] !== 0 || matrix[2] !== 0 || matrix[0] !== matrix[3]);
|
|
132
|
+
const point = (x, y) => [
|
|
133
|
+
matrix[0] * x + matrix[2] * y + matrix[4],
|
|
134
|
+
matrix[1] * x + matrix[3] * y + matrix[5],
|
|
135
|
+
];
|
|
136
|
+
const scale = Math.abs(matrix[0]);
|
|
137
|
+
const extend = SPREAD[attributes["spreadMethod"] ?? "pad"] ?? "pad";
|
|
138
|
+
if (def.kind === "linear") {
|
|
139
|
+
// Both anchors go through the transform, so a rotation or a shear lands correctly by itself.
|
|
140
|
+
const [x0, y0] = point(px(num(attributes["x1"], 0)), py(num(attributes["y1"], 0)));
|
|
141
|
+
const [x1, y1] = point(px(num(attributes["x2"], 1)), py(num(attributes["y2"], 0)));
|
|
142
|
+
return { type: "linear", x0, y0, x1, y1, stops, extend, alpha };
|
|
143
|
+
}
|
|
144
|
+
if (skewed) {
|
|
145
|
+
// A sheared or non-uniformly scaled radial gradient is an ELLIPSE. PDF's radial shading is
|
|
146
|
+
// circles only, so it cannot be expressed - naming it beats drawing a different shape.
|
|
147
|
+
throw new SvgUnsupportedError(`a radial gradient with gradientTransform="${attributes["gradientTransform"]}"`, "A non-uniform transform makes it elliptical, which a PDF shading cannot express.");
|
|
148
|
+
}
|
|
149
|
+
const cx = px(num(attributes["cx"], 0.5));
|
|
150
|
+
const cy = py(num(attributes["cy"], 0.5));
|
|
151
|
+
const r = pr(num(attributes["r"], 0.5)) * scale;
|
|
152
|
+
// The focal point defaults to the centre; it is where the gradient starts, at radius 0.
|
|
153
|
+
const [fx, fy] = point(attributes["fx"] === undefined ? cx : px(num(attributes["fx"], 0.5)), attributes["fy"] === undefined ? cy : py(num(attributes["fy"], 0.5)));
|
|
154
|
+
const [ox, oy] = point(cx, cy);
|
|
155
|
+
return { type: "radial", x0: fx, y0: fy, r0: 0, x1: ox, y1: oy, r1: r, stops, extend, alpha };
|
|
156
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { IRNode } from "../ir/display-list.js";
|
|
2
|
+
export { SvgParseError, SvgUnsupportedError } from "./errors.js";
|
|
3
|
+
/** Where the drawing goes on the page, in engine (top-left) points. */
|
|
4
|
+
export interface SvgTarget {
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
}
|
|
10
|
+
/** The intrinsic size of an SVG: its `width`/`height`, else its `viewBox`. */
|
|
11
|
+
export interface SvgSize {
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
}
|
|
15
|
+
/** The intrinsic size, for sizing an `<Image>` that gives only one dimension (or none). */
|
|
16
|
+
export declare function svgSize(source: string): SvgSize;
|
|
17
|
+
/**
|
|
18
|
+
* An SVG document as display-list nodes, placed in `target`. Coordinates stay in the SVG's own user
|
|
19
|
+
* space and the mapping rides in the graphics state, so a stroke scales with the drawing - which is
|
|
20
|
+
* what SVG specifies and what a flattened implementation gets wrong.
|
|
21
|
+
*/
|
|
22
|
+
export declare function svgToIr(source: string, target: SvgTarget): IRNode[];
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { parseXml } from "./xml.js";
|
|
2
|
+
import { SvgParseError, SvgUnsupportedError } from "./errors.js";
|
|
3
|
+
import { IDENTITY, isIdentity, multiply, parseTransform } from "./transform.js";
|
|
4
|
+
import { shapeOf } from "./shapes.js";
|
|
5
|
+
import { transformCommands } from "../vector/path.js";
|
|
6
|
+
import { declarationsFor, parseStylesheet } from "./css.js";
|
|
7
|
+
import { ROOT_STYLE, isPaintRef, cascadedReader, paintAlpha, resolveStyle, } from "./style.js";
|
|
8
|
+
import { gradientsOf, resolveSvgGradient } from "./gradients.js";
|
|
9
|
+
export { SvgParseError, SvgUnsupportedError } from "./errors.js";
|
|
10
|
+
/**
|
|
11
|
+
* Elements that carry no ink. They are SKIPPED rather than reported: they are legal, common, and
|
|
12
|
+
* drawing nothing is the correct result. `<defs>` is here because its contents are only ever drawn
|
|
13
|
+
* through a reference - and an unresolvable reference is reported where it is USED (`fill="url(#x)"`),
|
|
14
|
+
* which is the place that can say what is missing. `<style>` is here only because its text is
|
|
15
|
+
* collected BEFOREHAND (see `stylesheetOf`); skipping it without reading it turned an Illustrator
|
|
16
|
+
* export black.
|
|
17
|
+
*/
|
|
18
|
+
const IGNORED = new Set([
|
|
19
|
+
"defs",
|
|
20
|
+
"title",
|
|
21
|
+
"desc",
|
|
22
|
+
"metadata",
|
|
23
|
+
"style",
|
|
24
|
+
"linearGradient",
|
|
25
|
+
"radialGradient",
|
|
26
|
+
]);
|
|
27
|
+
/** What each unsupported element should tell the user to do instead. */
|
|
28
|
+
const HINTS = {
|
|
29
|
+
text: "Convert the text to outlines in your editor - a PDF cannot lay out SVG text.",
|
|
30
|
+
tspan: "Convert the text to outlines in your editor - a PDF cannot lay out SVG text.",
|
|
31
|
+
use: "Expand the <use> in your editor (Illustrator/Inkscape can flatten it).",
|
|
32
|
+
symbol: "Expand the <symbol> into plain shapes in your editor.",
|
|
33
|
+
filter: "Filters are pixel operations; rasterise that part to a PNG instead.",
|
|
34
|
+
mask: "Masks are pixel operations; flatten the artwork in your editor instead.",
|
|
35
|
+
clipPath: "Flatten the clip in your editor for now.",
|
|
36
|
+
image: "Place the bitmap with Image({ src }) instead of nesting it in the SVG.",
|
|
37
|
+
foreignObject: "Not renderable in a PDF; remove it.",
|
|
38
|
+
};
|
|
39
|
+
const attributesOf = (node) => node.attributes;
|
|
40
|
+
/**
|
|
41
|
+
* The tag without its namespace prefix, or null when the prefix is a FOREIGN one.
|
|
42
|
+
*
|
|
43
|
+
* Editors write their own state into the file - Inkscape's `<sodipodi:namedview>`, Adobe's `<i:pgf>`,
|
|
44
|
+
* RDF licence blocks. None of it draws anything, so refusing it made 12 of 10,819 real files fail for
|
|
45
|
+
* no reason. `svg:` itself is just the SVG namespace spelled out, so `<svg:rect>` is a rect.
|
|
46
|
+
*/
|
|
47
|
+
function localName(tagName) {
|
|
48
|
+
const colon = tagName.indexOf(":");
|
|
49
|
+
if (colon === -1)
|
|
50
|
+
return tagName;
|
|
51
|
+
return tagName.slice(0, colon) === "svg" ? tagName.slice(colon + 1) : null;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The text of every `<style>` block in the document, wherever it sits - commonly inside `<defs>`,
|
|
55
|
+
* which the walk skips, so it has to be gathered in its own pass over the whole tree.
|
|
56
|
+
*/
|
|
57
|
+
function stylesheetOf(node, out = [], depth = 0) {
|
|
58
|
+
if (depth > MAX_DEPTH)
|
|
59
|
+
return out;
|
|
60
|
+
if (node.type === "element" && localName(node.tagName) === "style") {
|
|
61
|
+
for (const child of node.children ?? []) {
|
|
62
|
+
if (child.type === "text")
|
|
63
|
+
out.push(child.value);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
for (const child of elements(node))
|
|
68
|
+
stylesheetOf(child, out, depth + 1);
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
/** The element children of a node; a text node is not one. */
|
|
72
|
+
const elements = (node) => node.type === "element"
|
|
73
|
+
? node.children.filter((child) => child.type === "element")
|
|
74
|
+
: [];
|
|
75
|
+
/**
|
|
76
|
+
* Reads the root `<svg>` and returns the matrix that maps its user space onto `target`, plus the
|
|
77
|
+
* intrinsic size. The `viewBox` scales UNIFORMLY and centres - SVG's default `preserveAspectRatio`
|
|
78
|
+
* ("xMidYMid meet"). Choosing the target box is the caller's job, so `BoxFit` keeps meaning the same
|
|
79
|
+
* thing it means for a bitmap.
|
|
80
|
+
*/
|
|
81
|
+
function rootMapping(root, target) {
|
|
82
|
+
const attributes = attributesOf(root);
|
|
83
|
+
const box = String(attributes["viewBox"] ?? "")
|
|
84
|
+
.trim()
|
|
85
|
+
.split(/[\s,]+/)
|
|
86
|
+
.map(Number);
|
|
87
|
+
const hasBox = box.length === 4 && box.every((n) => Number.isFinite(n)) && box[2] > 0 && box[3] > 0;
|
|
88
|
+
const declaredW = Number(attributes["width"]);
|
|
89
|
+
const declaredH = Number(attributes["height"]);
|
|
90
|
+
const size = {
|
|
91
|
+
width: Number.isFinite(declaredW) && declaredW > 0 ? declaredW : hasBox ? box[2] : target.width,
|
|
92
|
+
height: Number.isFinite(declaredH) && declaredH > 0 ? declaredH : hasBox ? box[3] : target.height,
|
|
93
|
+
};
|
|
94
|
+
// We always scale uniformly and centre, which is SVG's default "xMidYMid meet". Any other value
|
|
95
|
+
// moves or crops the drawing, so it is named rather than quietly ignored.
|
|
96
|
+
const par = String(attributes["preserveAspectRatio"] ?? "").trim();
|
|
97
|
+
if (par !== "" && !/^xMidYMid(\s+meet)?$/.test(par)) {
|
|
98
|
+
throw new SvgUnsupportedError(`preserveAspectRatio="${par}"`, 'Only the default "xMidYMid meet" is honoured. Size the drawing through width/height instead.');
|
|
99
|
+
}
|
|
100
|
+
const [vx, vy, vw, vh] = hasBox ? box : [0, 0, size.width, size.height];
|
|
101
|
+
const scale = Math.min(target.width / vw, target.height / vh);
|
|
102
|
+
const dx = target.x + (target.width - vw * scale) / 2;
|
|
103
|
+
const dy = target.y + (target.height - vh * scale) / 2;
|
|
104
|
+
// Place, then scale, then bring the viewBox origin to zero.
|
|
105
|
+
return {
|
|
106
|
+
matrix: multiply([scale, 0, 0, scale, dx, dy], [1, 0, 0, 1, -vx, -vy]),
|
|
107
|
+
size,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Attributes that CHANGE the picture and that we cannot honour. Ignoring one does not lose a detail -
|
|
112
|
+
* it draws something the file did not describe: a drop-shadow logo as a hard silhouette. `clip-path`
|
|
113
|
+
* used to be listed here; it is resolved now, which is the honest version of the same rule.
|
|
114
|
+
*/
|
|
115
|
+
const REFUSED_ATTRIBUTES = {
|
|
116
|
+
mask: "Masks are pixel operations; flatten the artwork in your editor instead.",
|
|
117
|
+
filter: "Filters are pixel operations; rasterise that part to a PNG instead.",
|
|
118
|
+
};
|
|
119
|
+
function refuseAttributes(tagName, read) {
|
|
120
|
+
for (const [name, hint] of Object.entries(REFUSED_ATTRIBUTES)) {
|
|
121
|
+
const value = read(name);
|
|
122
|
+
// `none` is the explicit absence of one, which is nothing to refuse.
|
|
123
|
+
if (value !== undefined && value.trim() !== "none") {
|
|
124
|
+
throw new SvgUnsupportedError(`"${name}" on <${tagName}>`, hint);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The `<clipPath>` definitions, by id. Their children are flattened into ONE command list, because a
|
|
130
|
+
* clip is a single path in PDF - which is also what SVG means: several children clip to their union.
|
|
131
|
+
*
|
|
132
|
+
* A child's own `transform` is baked into the coordinates rather than left in the graphics state: a
|
|
133
|
+
* clip is set from the path in the CURRENT space, so there is no state to put it in.
|
|
134
|
+
*/
|
|
135
|
+
function clipPathsOf(node, into = new Map(), depth = 0) {
|
|
136
|
+
if (depth > MAX_DEPTH)
|
|
137
|
+
return into;
|
|
138
|
+
if (node.type === "element" && localName(node.tagName) === "clipPath") {
|
|
139
|
+
const attributes = attributesOf(node);
|
|
140
|
+
const id = attributes["id"] === undefined ? undefined : String(attributes["id"]);
|
|
141
|
+
if (id !== undefined) {
|
|
142
|
+
// `objectBoundingBox` units scale the clip to each user's bounding box - a different geometry,
|
|
143
|
+
// not a harder one, and nothing in the corpus used it. Named rather than silently mistaken.
|
|
144
|
+
if (String(attributes["clipPathUnits"] ?? "userSpaceOnUse") !== "userSpaceOnUse") {
|
|
145
|
+
throw new SvgUnsupportedError('clipPathUnits="objectBoundingBox"', "Only userSpaceOnUse is resolved. Flatten the clip in your editor.");
|
|
146
|
+
}
|
|
147
|
+
const own = attributes["transform"]
|
|
148
|
+
? parseTransform(String(attributes["transform"]))
|
|
149
|
+
: IDENTITY;
|
|
150
|
+
const commands = [];
|
|
151
|
+
let fillRule;
|
|
152
|
+
for (const child of elements(node)) {
|
|
153
|
+
const childAttributes = attributesOf(child);
|
|
154
|
+
const shape = shapeOf(localName(child.tagName) ?? "", childAttributes);
|
|
155
|
+
if (!shape)
|
|
156
|
+
continue;
|
|
157
|
+
const childTransform = childAttributes["transform"]
|
|
158
|
+
? parseTransform(String(childAttributes["transform"]))
|
|
159
|
+
: IDENTITY;
|
|
160
|
+
const matrix = multiply(own, childTransform);
|
|
161
|
+
commands.push(...(isIdentity(matrix) ? shape : transformCommands(shape, matrix)));
|
|
162
|
+
// SVG spells the clip's winding rule `clip-rule`; it sits on the child, not the clipPath.
|
|
163
|
+
if (String(childAttributes["clip-rule"] ?? "") === "evenodd")
|
|
164
|
+
fillRule = "evenodd";
|
|
165
|
+
}
|
|
166
|
+
into.set(id, { commands, fillRule });
|
|
167
|
+
}
|
|
168
|
+
return into;
|
|
169
|
+
}
|
|
170
|
+
for (const child of elements(node))
|
|
171
|
+
clipPathsOf(child, into, depth + 1);
|
|
172
|
+
return into;
|
|
173
|
+
}
|
|
174
|
+
/** The shape's own `fill-opacity`/`opacity` multiply into the gradient's, exactly as they do into a
|
|
175
|
+
* solid colour's alpha. */
|
|
176
|
+
const gradientWithAlpha = (gradient, alpha) => alpha >= 1 ? gradient : { ...gradient, alpha: (gradient.alpha ?? 1) * alpha };
|
|
177
|
+
/** A referenced gradient, or a named error - SVG says an unresolvable paint reference is invalid. */
|
|
178
|
+
function gradientDef(defs, id) {
|
|
179
|
+
const def = defs.get(id);
|
|
180
|
+
if (!def) {
|
|
181
|
+
throw new SvgUnsupportedError(`fill="url(#${id})" - no gradient with that id in this file`, "It may be a <pattern>, which is not supported, or a broken reference.");
|
|
182
|
+
}
|
|
183
|
+
return def;
|
|
184
|
+
}
|
|
185
|
+
/** The id inside a `url(#id)` reference, or null if the value is not one. */
|
|
186
|
+
function referenceId(value) {
|
|
187
|
+
return /^url\(\s*#([^)\s]+)\s*\)$/.exec(value.trim())?.[1] ?? null;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* How deep the element tree may nest. The walk is recursive, so a pathological file - generated,
|
|
191
|
+
* corrupt, or hostile - would otherwise blow the call stack with a raw `RangeError` instead of an
|
|
192
|
+
* error the caller can act on. Real drawings nest a couple of dozen deep at most.
|
|
193
|
+
*/
|
|
194
|
+
const MAX_DEPTH = 256;
|
|
195
|
+
function walk(node, parentStyle, rules, clips, gradients, out, depth = 0) {
|
|
196
|
+
if (depth > MAX_DEPTH) {
|
|
197
|
+
throw new SvgParseError(`the SVG nests more than ${MAX_DEPTH} levels deep - it is not a drawing.`);
|
|
198
|
+
}
|
|
199
|
+
const tagName = localName(node.tagName);
|
|
200
|
+
if (tagName === null || IGNORED.has(tagName))
|
|
201
|
+
return;
|
|
202
|
+
// A clipPath's own children are geometry for the clip, never ink.
|
|
203
|
+
if (tagName === "clipPath")
|
|
204
|
+
return;
|
|
205
|
+
const attributes = attributesOf(node);
|
|
206
|
+
const css = declarationsFor(rules, tagName, {
|
|
207
|
+
class: attributes["class"],
|
|
208
|
+
id: attributes["id"],
|
|
209
|
+
});
|
|
210
|
+
// Everything below reads through the cascade, not the raw attributes: `filter="url(#f)"` and
|
|
211
|
+
// `style="filter:url(#f)"` are the same instruction, and a `<style>` rule can set either.
|
|
212
|
+
const read = cascadedReader(attributes, css);
|
|
213
|
+
refuseAttributes(tagName, read);
|
|
214
|
+
const style = resolveStyle(parentStyle, attributes, css);
|
|
215
|
+
const transform = attributes["transform"]
|
|
216
|
+
? parseTransform(String(attributes["transform"]))
|
|
217
|
+
: IDENTITY;
|
|
218
|
+
const wrapped = !isIdentity(transform);
|
|
219
|
+
if (wrapped)
|
|
220
|
+
out.push({ type: "transform-push", matrix: transform });
|
|
221
|
+
// The clip is pushed INSIDE the element's own transform, because that is the space its `url(#id)`
|
|
222
|
+
// reference is written in - the same rule SVG states for userSpaceOnUse.
|
|
223
|
+
const clipValue = read("clip-path") ?? "";
|
|
224
|
+
const clipId = clipValue.trim() === "none" ? null : referenceId(clipValue);
|
|
225
|
+
const clip = clipId === null ? undefined : clips.get(clipId);
|
|
226
|
+
if (clipId !== null && clip === undefined) {
|
|
227
|
+
// Per SVG a reference to a missing clipPath makes the element invalid, so silently drawing it
|
|
228
|
+
// unclipped would be the one thing we must not do.
|
|
229
|
+
throw new SvgUnsupportedError(`clip-path="${clipValue}" - no <clipPath id="${clipId}"> in this file`, "The reference is broken; remove it or add the definition.");
|
|
230
|
+
}
|
|
231
|
+
if (clip) {
|
|
232
|
+
out.push({ type: "clip-path-push", commands: clip.commands, fillRule: clip.fillRule });
|
|
233
|
+
}
|
|
234
|
+
if (tagName === "svg") {
|
|
235
|
+
// A nested <svg> establishes its own viewport: x/y/width/height plus a viewBox, and it clips.
|
|
236
|
+
// Walking it as a plain group would place and scale its children wrong, silently. None of the
|
|
237
|
+
// 10,819 files measured contains one, so it is named rather than built.
|
|
238
|
+
throw new SvgUnsupportedError("a nested <svg> element", "It establishes its own viewport. Flatten it into the outer drawing in your editor.");
|
|
239
|
+
}
|
|
240
|
+
if (tagName === "g") {
|
|
241
|
+
for (const child of elements(node))
|
|
242
|
+
walk(child, style, rules, clips, gradients, out, depth + 1);
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
const commands = shapeOf(tagName, attributes);
|
|
246
|
+
if (commands === null) {
|
|
247
|
+
throw new SvgUnsupportedError(`the SVG element <${tagName}>`, HINTS[tagName] ?? "It is not part of the supported subset yet.");
|
|
248
|
+
}
|
|
249
|
+
// A shape with neither fill nor stroke, or with no geometry, paints nothing - and an empty Path
|
|
250
|
+
// node would only cost bytes.
|
|
251
|
+
if (commands.length > 0 && (style.fill || style.stroke)) {
|
|
252
|
+
// A gradient is resolved HERE too, and for the same reason: in its default units its anchors
|
|
253
|
+
// are fractions of the SHAPE's bounding box, which only exists at this point.
|
|
254
|
+
const shapeAlpha = style.fillOpacity * style.opacity;
|
|
255
|
+
const fill = isPaintRef(style.fill)
|
|
256
|
+
? gradientWithAlpha(resolveSvgGradient(gradientDef(gradients, style.fill.ref), gradients, commands), shapeAlpha)
|
|
257
|
+
: style.fill && paintAlpha(style.fill, shapeAlpha);
|
|
258
|
+
// The opacities are applied HERE, once, on the shape that paints - see `paintAlpha`.
|
|
259
|
+
out.push({
|
|
260
|
+
type: "path",
|
|
261
|
+
commands,
|
|
262
|
+
fill,
|
|
263
|
+
fillRule: style.fillRule,
|
|
264
|
+
stroke: style.stroke && {
|
|
265
|
+
...style.stroke,
|
|
266
|
+
color: paintAlpha(style.stroke.color, style.strokeOpacity * style.opacity),
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (clip)
|
|
272
|
+
out.push({ type: "clip-pop" });
|
|
273
|
+
if (wrapped)
|
|
274
|
+
out.push({ type: "transform-pop" });
|
|
275
|
+
}
|
|
276
|
+
/** Parses `source` and returns the root element, rejecting anything that is not an SVG document. */
|
|
277
|
+
function rootOf(source) {
|
|
278
|
+
// A file named .svg is not necessarily SVG - the corpus turned up build-cache blobs with that
|
|
279
|
+
// extension. Say so here rather than let the parser die on the binary with a TypeError.
|
|
280
|
+
if (!/<svg[\s>]/i.test(source)) {
|
|
281
|
+
throw new SvgParseError(
|
|
282
|
+
// oxlint-disable-next-line no-control-regex -- spotting control bytes is the whole point here.
|
|
283
|
+
/[\u0000-\u0008\u000E-\u001F]/.test(source.slice(0, 200))
|
|
284
|
+
? "this is not an SVG file - it starts with binary data."
|
|
285
|
+
: "no <svg> element found - is this really an SVG file?");
|
|
286
|
+
}
|
|
287
|
+
const roots = parseXml(source);
|
|
288
|
+
const root = roots.find((node) => localName(node.tagName) === "svg");
|
|
289
|
+
if (!root)
|
|
290
|
+
throw new SvgParseError("no root <svg> element - is this really an SVG file?");
|
|
291
|
+
return root;
|
|
292
|
+
}
|
|
293
|
+
/** The intrinsic size, for sizing an `<Image>` that gives only one dimension (or none). */
|
|
294
|
+
export function svgSize(source) {
|
|
295
|
+
return rootMapping(rootOf(source), { x: 0, y: 0, width: 1, height: 1 }).size;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* An SVG document as display-list nodes, placed in `target`. Coordinates stay in the SVG's own user
|
|
299
|
+
* space and the mapping rides in the graphics state, so a stroke scales with the drawing - which is
|
|
300
|
+
* what SVG specifies and what a flattened implementation gets wrong.
|
|
301
|
+
*/
|
|
302
|
+
export function svgToIr(source, target) {
|
|
303
|
+
const root = rootOf(source);
|
|
304
|
+
const { matrix } = rootMapping(root, target);
|
|
305
|
+
// The outermost <svg> is a VIEWPORT: it clips to itself (CSS `overflow: hidden` on the root svg).
|
|
306
|
+
// Without this a shape that pokes past the viewBox - a stroke on the edge, a rounded cap - bleeds
|
|
307
|
+
// into whatever sits beside the drawing in the layout. Measured against headless Chrome, which
|
|
308
|
+
// clips exactly here.
|
|
309
|
+
const out = [
|
|
310
|
+
{ type: "clip-push", x: target.x, y: target.y, width: target.width, height: target.height },
|
|
311
|
+
{ type: "transform-push", matrix },
|
|
312
|
+
];
|
|
313
|
+
const rules = parseStylesheet(stylesheetOf(root).join("\n"));
|
|
314
|
+
const clips = clipPathsOf(root);
|
|
315
|
+
const gradients = gradientsOf(root);
|
|
316
|
+
// The root <svg> carries presentation attributes of its own, and 778 of 10,819 real files put
|
|
317
|
+
// `fill="none"` there - the Figma export default. Skipping them filled every shape that has no
|
|
318
|
+
// fill of its own with BLACK, which covered the drawing underneath.
|
|
319
|
+
const rootAttributes = attributesOf(root);
|
|
320
|
+
const rootCss = declarationsFor(rules, "svg", {
|
|
321
|
+
class: rootAttributes["class"],
|
|
322
|
+
id: rootAttributes["id"],
|
|
323
|
+
});
|
|
324
|
+
// The root is held to the same rule as its children: a filter or mask on it changes the picture.
|
|
325
|
+
refuseAttributes("svg", cascadedReader(rootAttributes, rootCss));
|
|
326
|
+
const rootStyle = resolveStyle(ROOT_STYLE, rootAttributes, rootCss);
|
|
327
|
+
for (const child of elements(root))
|
|
328
|
+
walk(child, rootStyle, rules, clips, gradients, out, 1);
|
|
329
|
+
out.push({ type: "transform-pop" }, { type: "clip-pop" });
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { PathCommand } from "../ir/display-list.js";
|
|
2
|
+
import type { Attributes } from "./style.js";
|
|
3
|
+
/** `d` through svgpath, then the remaining segment kinds mapped onto the IR's m/l/c/z. */
|
|
4
|
+
export declare function pathData(d: string): PathCommand[];
|
|
5
|
+
/** The geometry of one shape element, or null if the tag is not a shape. */
|
|
6
|
+
export declare function shapeOf(tagName: string, attributes: Attributes): PathCommand[] | null;
|