@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,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;
@@ -0,0 +1,177 @@
1
+ import svgpath from "svgpath";
2
+ import { quadToCubic } from "../vector/path.js";
3
+ import { SvgParseError } from "./errors.js";
4
+ import { length } from "./units.js";
5
+ /**
6
+ * Every drawable SVG shape becomes one list of path commands, so the rest of the pipeline knows about
7
+ * exactly one kind of geometry. A rect is a path, a circle is four arcs, `<path>` is a path already.
8
+ *
9
+ * The `d` grammar - relative coordinates, the `S`/`T` shorthands, and elliptical arcs - is handled by
10
+ * `svgpath`: `.abs().unshort().unarc()` leaves only M/L/H/V/C/Q/Z. Arcs are the reason: converting one
11
+ * to Béziers is the SVG spec's own appendix, and a subtly wrong version looks ALMOST right.
12
+ */
13
+ const num = (attributes, name, fallback = 0) => length(attributes[name], `the "${name}" attribute`, fallback);
14
+ /** Four cubic arcs approximating a full ellipse. The constant is the standard circle-to-Bézier one. */
15
+ const KAPPA = 0.5522847498307936;
16
+ function ellipse(cx, cy, rx, ry) {
17
+ if (rx <= 0 || ry <= 0)
18
+ return [];
19
+ const [ox, oy] = [rx * KAPPA, ry * KAPPA];
20
+ return [
21
+ { op: "m", x: cx - rx, y: cy },
22
+ { op: "c", x1: cx - rx, y1: cy - oy, x2: cx - ox, y2: cy - ry, x: cx, y: cy - ry },
23
+ { op: "c", x1: cx + ox, y1: cy - ry, x2: cx + rx, y2: cy - oy, x: cx + rx, y: cy },
24
+ { op: "c", x1: cx + rx, y1: cy + oy, x2: cx + ox, y2: cy + ry, x: cx, y: cy + ry },
25
+ { op: "c", x1: cx - ox, y1: cy + ry, x2: cx - rx, y2: cy + oy, x: cx - rx, y: cy },
26
+ { op: "z" },
27
+ ];
28
+ }
29
+ function rect(attributes) {
30
+ const [x, y] = [num(attributes, "x"), num(attributes, "y")];
31
+ const [w, h] = [num(attributes, "width"), num(attributes, "height")];
32
+ if (w <= 0 || h <= 0)
33
+ return [];
34
+ // An `rx` alone implies the same `ry`, and vice versa; both clamp to half the side.
35
+ const rxRaw = attributes["rx"] !== undefined ? num(attributes, "rx") : num(attributes, "ry");
36
+ const ryRaw = attributes["ry"] !== undefined ? num(attributes, "ry") : rxRaw;
37
+ const rx = Math.min(rxRaw, w / 2);
38
+ const ry = Math.min(ryRaw, h / 2);
39
+ if (rx <= 0 || ry <= 0) {
40
+ return [
41
+ { op: "m", x, y },
42
+ { op: "l", x: x + w, y },
43
+ { op: "l", x: x + w, y: y + h },
44
+ { op: "l", x, y: y + h },
45
+ { op: "z" },
46
+ ];
47
+ }
48
+ const [ox, oy] = [rx * KAPPA, ry * KAPPA];
49
+ return [
50
+ { op: "m", x: x + rx, y },
51
+ { op: "l", x: x + w - rx, y },
52
+ { op: "c", x1: x + w - rx + ox, y1: y, x2: x + w, y2: y + ry - oy, x: x + w, y: y + ry },
53
+ { op: "l", x: x + w, y: y + h - ry },
54
+ {
55
+ op: "c",
56
+ x1: x + w,
57
+ y1: y + h - ry + oy,
58
+ x2: x + w - rx + ox,
59
+ y2: y + h,
60
+ x: x + w - rx,
61
+ y: y + h,
62
+ },
63
+ { op: "l", x: x + rx, y: y + h },
64
+ { op: "c", x1: x + rx - ox, y1: y + h, x2: x, y2: y + h - ry + oy, x, y: y + h - ry },
65
+ { op: "l", x, y: y + ry },
66
+ { op: "c", x1: x, y1: y + ry - oy, x2: x + rx - ox, y2: y, x: x + rx, y },
67
+ { op: "z" },
68
+ ];
69
+ }
70
+ function points(value) {
71
+ const found = String(value ?? "").match(/[+-]?(?:\d*\.\d+|\d+\.?)(?:[eE][+-]?\d+)?/g);
72
+ const out = [];
73
+ for (let i = 0; found && i + 1 < found.length; i += 2) {
74
+ out.push([Number(found[i]), Number(found[i + 1])]);
75
+ }
76
+ return out;
77
+ }
78
+ function polyline(value, close) {
79
+ const list = points(value);
80
+ if (list.length === 0)
81
+ return [];
82
+ const out = [{ op: "m", x: list[0][0], y: list[0][1] }];
83
+ for (const [x, y] of list.slice(1))
84
+ out.push({ op: "l", x, y });
85
+ if (close)
86
+ out.push({ op: "z" });
87
+ return out;
88
+ }
89
+ /** `d` through svgpath, then the remaining segment kinds mapped onto the IR's m/l/c/z. */
90
+ export function pathData(d) {
91
+ const out = [];
92
+ let [cx, cy] = [0, 0];
93
+ let [startX, startY] = [0, 0];
94
+ try {
95
+ const parsed = svgpath(d);
96
+ // svgpath REPORTS a malformed `d` on the instance instead of throwing, so an unchecked call
97
+ // quietly yields an empty path - the shape simply disappears.
98
+ // Its bundled types omit `err`, though it IS the API: svgpath never throws on malformed input,
99
+ // it records the reason and yields no segments - so the shape would just disappear.
100
+ const err = parsed.err;
101
+ if (err)
102
+ throw new SvgParseError(`path data could not be read: ${err}`);
103
+ parsed
104
+ .abs()
105
+ .unshort()
106
+ .unarc()
107
+ .iterate((segment) => {
108
+ const [op, ...a] = segment;
109
+ switch (op) {
110
+ case "M":
111
+ [cx, cy] = [a[0], a[1]];
112
+ [startX, startY] = [cx, cy];
113
+ out.push({ op: "m", x: cx, y: cy });
114
+ break;
115
+ case "L":
116
+ [cx, cy] = [a[0], a[1]];
117
+ out.push({ op: "l", x: cx, y: cy });
118
+ break;
119
+ case "H":
120
+ cx = a[0];
121
+ out.push({ op: "l", x: cx, y: cy });
122
+ break;
123
+ case "V":
124
+ cy = a[0];
125
+ out.push({ op: "l", x: cx, y: cy });
126
+ break;
127
+ case "C":
128
+ out.push({ op: "c", x1: a[0], y1: a[1], x2: a[2], y2: a[3], x: a[4], y: a[5] });
129
+ [cx, cy] = [a[4], a[5]];
130
+ break;
131
+ case "Q":
132
+ // PDF has no quadratic operator; the same conversion a TrueType outline needs.
133
+ out.push(quadToCubic(cx, cy, a[0], a[1], a[2], a[3]));
134
+ [cx, cy] = [a[2], a[3]];
135
+ break;
136
+ case "Z":
137
+ out.push({ op: "z" });
138
+ [cx, cy] = [startX, startY];
139
+ break;
140
+ default:
141
+ throw new SvgParseError(`path data: unexpected segment "${op}"`);
142
+ }
143
+ });
144
+ }
145
+ catch (error) {
146
+ if (error instanceof SvgParseError)
147
+ throw error;
148
+ throw new SvgParseError(`path data could not be read: ${error.message}`);
149
+ }
150
+ return out;
151
+ }
152
+ /** The geometry of one shape element, or null if the tag is not a shape. */
153
+ export function shapeOf(tagName, attributes) {
154
+ switch (tagName) {
155
+ case "path":
156
+ return attributes["d"] ? pathData(String(attributes["d"])) : [];
157
+ case "rect":
158
+ return rect(attributes);
159
+ case "circle": {
160
+ const r = num(attributes, "r");
161
+ return ellipse(num(attributes, "cx"), num(attributes, "cy"), r, r);
162
+ }
163
+ case "ellipse":
164
+ return ellipse(num(attributes, "cx"), num(attributes, "cy"), num(attributes, "rx"), num(attributes, "ry"));
165
+ case "line":
166
+ return [
167
+ { op: "m", x: num(attributes, "x1"), y: num(attributes, "y1") },
168
+ { op: "l", x: num(attributes, "x2"), y: num(attributes, "y2") },
169
+ ];
170
+ case "polyline":
171
+ return polyline(attributes["points"], false);
172
+ case "polygon":
173
+ return polyline(attributes["points"], true);
174
+ default:
175
+ return null;
176
+ }
177
+ }
@@ -0,0 +1,61 @@
1
+ import { Color } from "../common/color.js";
2
+ import type { StrokeStyle } from "../ir/display-list.js";
3
+ /**
4
+ * The presentation style of one element, resolved against its parent's.
5
+ *
6
+ * SVG styles two ways at once: presentation ATTRIBUTES (`fill="red"`) and a CSS `style=""` bag, with
7
+ * the style bag winning. Most of them INHERIT, which is what lets a logo set `fill` once on the root
8
+ * `<g>`. A `<style>` block with class selectors is a third way and is not resolved yet - it is a
9
+ * NAMED error, not a silent loss, because that is exactly the case that leaves a logo blank.
10
+ */
11
+ /** A paint is a colour, or a `url(#id)` reference that only the SHAPE can resolve - a gradient in
12
+ * the default `objectBoundingBox` units is positioned on the bounding box of the thing it fills. */
13
+ export type PaintRef = {
14
+ ref: string;
15
+ };
16
+ export type SvgPaint = Color | PaintRef;
17
+ export declare const isPaintRef: (paint: SvgPaint | undefined) => paint is PaintRef;
18
+ export interface SvgStyle {
19
+ /** Absent = `fill="none"`. The RAW paint - see `paintAlpha` for why the opacity is not in it. */
20
+ fill?: SvgPaint;
21
+ fillRule: "nonzero" | "evenodd";
22
+ stroke?: StrokeStyle;
23
+ /** `currentColor` resolves to this; set by the `color` attribute, black at the root. */
24
+ color: Color;
25
+ /**
26
+ * `fill-opacity` / `stroke-opacity` (both inherit) and the accumulated `opacity` of this element
27
+ * and its ancestors. They are kept OUT of the colours and applied once, at the leaf that paints:
28
+ * folding them in at every level would multiply a group's opacity in again for each generation.
29
+ */
30
+ fillOpacity: number;
31
+ strokeOpacity: number;
32
+ opacity: number;
33
+ }
34
+ /** The colour a shape actually paints with: its raw colour, times the opacities that reached it. */
35
+ export declare function paintAlpha(color: Color, opacity: number): Color;
36
+ export declare const ROOT_STYLE: SvgStyle;
37
+ /** Raw attributes, exactly as written. Our own XML reader never coerces them (`svg-parser` did,
38
+ * turning `id="58310095e0"` into a number). */
39
+ export type Attributes = Record<string, string | undefined>;
40
+ /**
41
+ * Reads a property from all three sources, in CSS order: a presentation attribute is the weakest, a
42
+ * matching `<style>` rule beats it, and the inline `style=""` bag beats both.
43
+ *
44
+ * Exported because anything that INSPECTS a property has to see the same value the style resolver
45
+ * would - `filter="url(#f)"` and `style="filter:url(#f)"` are the same instruction, and checking only
46
+ * the attribute lets the second one through unnoticed.
47
+ */
48
+ export declare function cascadedReader(attributes: Attributes, css?: Record<string, string>): (name: string) => string | undefined;
49
+ /**
50
+ * A colour, with the value quoted in the message. `toColor` throws a bare `Unknown color: "x"`, which
51
+ * inside a 900-element logo says nothing about WHERE - and a malformed file reaches here with pieces
52
+ * of its own markup as the value, so the raw error is actively misleading.
53
+ */
54
+ export declare function svgColor(value: string): Color;
55
+ /**
56
+ * Resolves one element's style against its parent's. Everything SVG inherits is inherited; `opacity`
57
+ * is the exception - it applies to the element itself, so it is MULTIPLIED down and folded into the
58
+ * paint of each leaf. That differs from a browser for a `<g opacity>` whose children OVERLAP (a real
59
+ * group needs one transparency group), and is the one approximation in this file.
60
+ */
61
+ export declare function resolveStyle(parent: SvgStyle, attributes: Attributes, css?: Record<string, string>): SvgStyle;