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", + `${CIRCLE}${RADIAL_GRADIENT}` + ); + 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; }