diff --git a/assets/symbols/tree1_BWgradient.svg b/assets/symbols/tree1_BWgradient.svg
new file mode 100644
index 0000000..7f60d7d
--- /dev/null
+++ b/assets/symbols/tree1_BWgradient.svg
@@ -0,0 +1,27 @@
+
+
+
+
diff --git a/lib/__snapshots__/vocabulary-builder.test.ts.snap b/lib/__snapshots__/vocabulary-builder.test.ts.snap
index 0693b15..74dd8a8 100644
--- a/lib/__snapshots__/vocabulary-builder.test.ts.snap
+++ b/lib/__snapshots__/vocabulary-builder.test.ts.snap
@@ -18,6 +18,28 @@ Object {
}
`;
+exports[`convertSvgMarkupToSymbolData() processses radial gradients 1`] = `
+Array [
+ Object {
+ "cx": "49.97%",
+ "cy": "76.69%",
+ "id": "blargy",
+ "r": "68.79%",
+ "stops": Array [
+ Object {
+ "color": "#000000",
+ "offset": "0.00%",
+ },
+ Object {
+ "color": "#ffffff",
+ "offset": "100.00%",
+ },
+ ],
+ "type": "radialGradient",
+ },
+]
+`;
+
exports[`convertSvgMarkupToSymbolData() works with SVGs that just have a path and no specs 1`] = `
Object {
"bbox": Object {
@@ -30,6 +52,7 @@ Object {
"min": 27.751,
},
},
+ "defs": undefined,
"layers": Array [
Object {
"children": Array [],
diff --git a/lib/svg-symbol.tsx b/lib/svg-symbol.tsx
index 96769e4..aa101c7 100644
--- a/lib/svg-symbol.tsx
+++ b/lib/svg-symbol.tsx
@@ -1,9 +1,10 @@
-import React from "react";
+import React, { useMemo } from "react";
import { SVGProps } from "react";
import { BBox } from "../vendor/bezier-js";
import { FILL_REPLACEMENT_COLOR, STROKE_REPLACEMENT_COLOR } from "./colors";
import { AttachmentPointType, PointWithNormal, Specs } from "./specs";
import type { SvgSymbolMetadata } from "./svg-symbol-metadata";
+import { UniqueIdMap, useUniqueIdMap } from "./unique-id";
import { VisibleSpecs } from "./visible-specs";
const DEFAULT_UNIFORM_STROKE_WIDTH = 1;
@@ -12,10 +13,29 @@ export type SvgSymbolData = {
name: string;
bbox: BBox;
layers: SvgSymbolElement[];
+ defs?: SvgSymbolDef[];
meta?: SvgSymbolMetadata;
specs?: Specs;
};
+export type SvgSymbolGradientStop = {
+ offset: string;
+ color: string;
+};
+
+/**
+ * This represents a definition that will be referenced
+ * from elsewhere in an SVG, such as a radial gradient.
+ */
+export type SvgSymbolDef = {
+ type: "radialGradient";
+ id: string;
+ cx: string;
+ cy: string;
+ r: string;
+ stops: SvgSymbolGradientStop[];
+};
+
export const EMPTY_SVG_SYMBOL_DATA: SvgSymbolData = {
name: "",
bbox: {
@@ -113,6 +133,7 @@ function getColor(
function reactifySvgSymbolElement(
ctx: SvgSymbolContext,
+ uidMap: UniqueIdMap,
el: SvgSymbolElement,
key: number
): JSX.Element {
@@ -120,6 +141,9 @@ function reactifySvgSymbolElement(
let vectorEffect;
fill = getColor(ctx, fill);
stroke = getColor(ctx, stroke);
+ if (fill) {
+ fill = uidMap.rewriteUrl(fill);
+ }
if (strokeWidth !== undefined && typeof ctx.uniformStrokeWidth === "number") {
strokeWidth = ctx.uniformStrokeWidth;
vectorEffect = "non-scaling-stroke";
@@ -136,18 +160,50 @@ function reactifySvgSymbolElement(
return React.createElement(
el.tagName,
props,
- el.children.map(reactifySvgSymbolElement.bind(null, ctx))
+ el.children.map(reactifySvgSymbolElement.bind(null, ctx, uidMap))
);
}
+const SvgSymbolDef: React.FC<
+ { def: SvgSymbolDef; uidMap: UniqueIdMap } & SvgSymbolContext
+> = ({ def, uidMap, ...ctx }) => {
+ switch (def.type) {
+ case "radialGradient":
+ return (
+
+ {def.stops.map((stop, i) => (
+
+ ))}
+
+ );
+ }
+};
+
export const SvgSymbolContent: React.FC<
{ data: SvgSymbolData } & SvgSymbolContext
> = (props) => {
const d = props.data;
+ const origIds = useMemo(() => d.defs?.map((def) => def.id) ?? [], [d.defs]);
+ const uidMap = useUniqueIdMap(origIds);
return (
- {props.data.layers.map(reactifySvgSymbolElement.bind(null, props))}
+ {d.defs &&
+ d.defs.map((def, i) => (
+
+ ))}
+ {props.data.layers.map(
+ reactifySvgSymbolElement.bind(null, props, uidMap)
+ )}
{props.showSpecs && d.specs && }
);
diff --git a/lib/unique-id.test.tsx b/lib/unique-id.test.tsx
new file mode 100644
index 0000000..344bb3d
--- /dev/null
+++ b/lib/unique-id.test.tsx
@@ -0,0 +1,36 @@
+import { UniqueIdMap } from "./unique-id";
+
+describe("UniqueIdMap", () => {
+ describe("rewriteUrl()", () => {
+ it("rewrites a url reference if possible", () => {
+ const u = new UniqueIdMap([["foo", "bar"]]);
+ expect(u.rewriteUrl("url(#foo)")).toEqual("url(#bar)");
+ });
+
+ it("returns the value unmodified if it's not a url reference", () => {
+ const u = new UniqueIdMap([["foo", "bar"]]);
+ expect(u.rewriteUrl("foo")).toEqual("foo");
+ });
+
+ it("raises error when a value isn't found", () => {
+ const u = new UniqueIdMap([["foo", "bar"]]);
+ expect(() => u.rewriteUrl("url(#blop)")).toThrowError(
+ 'Unable to find a unique ID for "blop"'
+ );
+ });
+ });
+
+ describe("getStrict()", () => {
+ it("returns a value when found", () => {
+ const u = new UniqueIdMap([["foo", "bar"]]);
+ expect(u.getStrict("foo")).toEqual("bar");
+ });
+
+ it("raises error when a value isn't found", () => {
+ const u = new UniqueIdMap([["foo", "bar"]]);
+ expect(() => u.getStrict("blop")).toThrowError(
+ 'Unable to find a unique ID for "blop"'
+ );
+ });
+ });
+});
diff --git a/lib/unique-id.tsx b/lib/unique-id.tsx
new file mode 100644
index 0000000..6c0eceb
--- /dev/null
+++ b/lib/unique-id.tsx
@@ -0,0 +1,88 @@
+import React, { useContext, useMemo } from "react";
+
+type UniqueIdContextType = {
+ prefix: string;
+ counter: number;
+};
+
+const UniqueIdContext = React.createContext({
+ prefix: "uid_",
+ counter: 0,
+});
+
+function useUniqueIds(count: number): string[] {
+ const ctx = useContext(UniqueIdContext);
+ const result = useMemo(() => {
+ const result: string[] = [];
+
+ for (let i = 0; i < count; i++) {
+ result.push(`${ctx.prefix}${ctx.counter}`);
+ ctx.counter += 1;
+ }
+
+ return result;
+ }, [count]);
+
+ return result;
+}
+
+export class UniqueIdMap extends Map {
+ /**
+ * Returns the globally-unique identifier for the given
+ * locally-unique one, raising an exception if one
+ * doesn't exist.
+ */
+ getStrict(key: string): string {
+ const uid = this.get(key);
+
+ if (!uid) {
+ throw new Error(`Unable to find a unique ID for "${key}"`);
+ }
+
+ return uid;
+ }
+
+ /**
+ * If the given string is of the form `url(#id)`, where `id` is a
+ * locally-unique identifier, then this will replace `id` with
+ * its globally-unique analogue. If it does not have a
+ * globally-unique identifier for it, however, an error will be
+ * raised.
+ *
+ * If the string is *not* of the aforementioned form, however,
+ * it will be returned unmodified.
+ *
+ * This can be used to e.g. rewrite references in SVG attributes
+ * that may refer to locally-unique identifiers.
+ */
+ rewriteUrl(value: string): string {
+ const match = value.match(/^url\(\#(.+)\)$/);
+
+ if (!match) {
+ return value;
+ }
+
+ const uid = this.getStrict(match[1]);
+
+ return `url(#${uid})`;
+ }
+}
+
+/**
+ * We sometimes need to take locally-unique identifiers and make them
+ * globally-unique within some larger context; for example, an individual
+ * SVG may have defined a `` where `#boop` is
+ * unique to the SVG, but if we want to inline the SVG into an HTML page,
+ * it may no longer be unique.
+ *
+ * This React Hook takes an array of locally-unique identifiers and returns
+ * a mapping between them and globally-unique ones.
+ */
+export function useUniqueIdMap(originalIds: string[]): UniqueIdMap {
+ const uniqueIds = useUniqueIds(originalIds.length);
+
+ return useMemo(
+ () => new UniqueIdMap(originalIds.map((id, i) => [id, uniqueIds[i]])),
+ [originalIds]
+ );
+}
diff --git a/lib/util.test.tsx b/lib/util.test.tsx
index ec4c28c..af0f6b3 100644
--- a/lib/util.test.tsx
+++ b/lib/util.test.tsx
@@ -5,6 +5,7 @@ import {
rad2deg,
range,
toFriendlyDecimal,
+ withoutNulls,
} from "./util";
describe("float", () => {
@@ -46,3 +47,7 @@ test("inclusiveRange() works", () => {
test("toFriendlyDecimal() works", () => {
expect(toFriendlyDecimal(1.850000000143)).toEqual("1.85");
});
+
+test("withoutNulls() works", () => {
+ expect(withoutNulls([1, 2, 0, null, 3])).toEqual([1, 2, 0, 3]);
+});
diff --git a/lib/util.ts b/lib/util.ts
index 14483f2..1cf2aac 100644
--- a/lib/util.ts
+++ b/lib/util.ts
@@ -109,3 +109,18 @@ export function toFriendlyDecimal(value: number, maxDecimalDigits = 2): string {
return str.length < fixedStr.length ? str : fixedStr;
}
+
+/**
+ * Given an array consisting of a nullable type, filter out all the nulls.
+ */
+export function withoutNulls(arr: (T | null)[]): T[] {
+ const result: T[] = [];
+
+ for (let item of arr) {
+ if (item !== null) {
+ result.push(item);
+ }
+ }
+
+ return result;
+}
diff --git a/lib/vocabulary-builder.test.ts b/lib/vocabulary-builder.test.ts
index 9ea1edf..23727a0 100644
--- a/lib/vocabulary-builder.test.ts
+++ b/lib/vocabulary-builder.test.ts
@@ -3,6 +3,13 @@ import { convertSvgMarkupToSymbolData } from "./vocabulary-builder";
const CIRCLE = ``;
+const RADIAL_GRADIENT = [
+ ``,
+ ``,
+ ``,
+ ``,
+].join("");
+
function arrow(color: string) {
return ``;
}
@@ -14,6 +21,14 @@ describe("convertSvgMarkupToSymbolData()", () => {
).toMatchSnapshot();
});
+ it("processses radial gradients", () => {
+ const result = convertSvgMarkupToSymbolData(
+ "blarg.svg",
+ ``
+ );
+ expect(result.defs).toMatchSnapshot();
+ });
+
it("ignores empty layers", () => {
const sd1 = convertSvgMarkupToSymbolData(
"blarg.svg",
diff --git a/lib/vocabulary-builder.ts b/lib/vocabulary-builder.ts
index cc66752..1589a4b 100644
--- a/lib/vocabulary-builder.ts
+++ b/lib/vocabulary-builder.ts
@@ -3,9 +3,16 @@ import path from "path";
import cheerio from "cheerio";
import { getSvgBoundingBox } from "./bounding-box";
import { extractSpecs } from "./specs";
-import { SvgSymbolData, SvgSymbolElement } from "./svg-symbol";
+import {
+ SvgSymbolData,
+ SvgSymbolDef,
+ SvgSymbolElement,
+ SvgSymbolGradientStop,
+} from "./svg-symbol";
import toml from "toml";
import { validateSvgSymbolMetadata } from "./svg-symbol-metadata";
+import { clampedByteToHex } from "./random-colors";
+import { withoutNulls } from "./util";
const SUPPORTED_SVG_TAG_ARRAY: SvgSymbolElement["tagName"][] = ["g", "path"];
const SUPPORTED_SVG_TAGS = new Set(SUPPORTED_SVG_TAG_ARRAY);
@@ -70,14 +77,73 @@ function attribsToProps(el: cheerio.TagElement): any {
return result;
}
+function getNonEmptyAttrib(el: cheerio.TagElement, attr: string): string {
+ const result = el.attribs[attr];
+
+ if (!result) {
+ throw new Error(
+ `Expected <${el.tagName}> to have a non-empty '${attr}' attribute!`
+ );
+ }
+
+ return result;
+}
+
+function parseRadialGradient(el: cheerio.TagElement): SvgSymbolDef {
+ const stops: SvgSymbolGradientStop[] = [];
+ for (let child of el.children) {
+ if (child.type === "tag") {
+ if (child.tagName !== "stop") {
+ throw new Error(
+ `Expected an SVG gradient to only contain elements!`
+ );
+ }
+ const style = getNonEmptyAttrib(child, "style");
+ const color = style.match(/stop-color\:rgb\((\d+),(\d+),(\d+)\)/);
+ if (!color) {
+ throw new Error(`Expected "${style}" to contain a stop-color!`);
+ }
+ const rgb = Array.from(color)
+ .slice(1)
+ .map((value) => parseInt(value));
+ stops.push({
+ offset: getNonEmptyAttrib(child, "offset"),
+ color: "#" + rgb.map(clampedByteToHex).join(""),
+ });
+ }
+ }
+ return {
+ type: "radialGradient",
+ id: getNonEmptyAttrib(el, "id"),
+ cx: getNonEmptyAttrib(el, "cx"),
+ cy: getNonEmptyAttrib(el, "cy"),
+ r: getNonEmptyAttrib(el, "r"),
+ stops,
+ };
+}
+
+/**
+ * Attempt to convert the given SVG element into a `SvgSymbolElement`
+ * and/or a list of accompanying `SvgSymbolDef` objects.
+ *
+ * Note that the latter will be "returned" to the caller via the
+ * `defsOutput` argument.
+ */
function serializeSvgSymbolElement(
$: cheerio.Root,
- el: cheerio.TagElement
-): SvgSymbolElement {
- let children = onlyTags(el.children).map((child) =>
- serializeSvgSymbolElement($, child)
- );
+ el: cheerio.TagElement,
+ defsOutput: SvgSymbolDef[]
+): SvgSymbolElement | null {
const { tagName } = el;
+ if (tagName === "radialGradient") {
+ defsOutput.push(parseRadialGradient(el));
+ return null;
+ }
+ let children = withoutNulls(
+ onlyTags(el.children).map((child) =>
+ serializeSvgSymbolElement($, child, defsOutput)
+ )
+ );
if (isSupportedSvgTag(tagName)) {
return {
tagName,
@@ -104,17 +170,24 @@ export function convertSvgMarkupToSymbolData(
const name = path.basename(filename, SVG_EXT).toLowerCase();
const $ = cheerio.load(svgMarkup);
const svgEl = $("svg");
+ const outputDefs: SvgSymbolDef[] = [];
const rawLayers = removeEmptyGroups(
- onlyTags(svgEl.children()).map((ch) => serializeSvgSymbolElement($, ch))
+ withoutNulls(
+ onlyTags(svgEl.children()).map((ch) =>
+ serializeSvgSymbolElement($, ch, outputDefs)
+ )
+ )
);
const [specs, layers] = extractSpecs(rawLayers);
const bbox = getSvgBoundingBox(layers);
+ const defs = outputDefs.length ? outputDefs : undefined;
const symbol: SvgSymbolData = {
name,
bbox,
layers,
specs,
+ defs,
};
return symbol;
}