Add support for gradients (#127)

Fixes #121.
pull/130/head
Atul Varma 2021-05-27 16:09:05 -04:00 zatwierdzone przez GitHub
rodzic 82134eec9d
commit 902dfa23fa
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
9 zmienionych plików z 348 dodań i 10 usunięć

Wyświetl plik

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Moho 13.5 build 20210422 -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Frame_0" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="720px" height="720px">
<g id="BW_gradients">
<g id="tree1">
<radialGradient id="96CD3641-8761-4EDA-BBC7-B375063AFA62_0" cx="49.97%" cy="76.69%" r="68.79%">
<stop offset="0.00%" style="stop-color:rgb(0,0,0);stop-opacity:1.00" />
<stop offset="100.00%" style="stop-color:rgb(255,255,255);stop-opacity:1.00" />
</radialGradient>
<path id="S1" fill="url(#96CD3641-8761-4EDA-BBC7-B375063AFA62_0)" fill-rule="evenodd" stroke="none" d="M 360.000 9.479 C 360.022 9.523 639.277 216.190 579.984 446.590 C 545.544 580.416 439.312 626.576 360.000 626.576 C 280.688 626.576 174.456 580.416 140.016 446.590 C 80.723 216.190 359.978 9.523 360.000 9.479 Z"/>
<path id="S2" fill="#000000" fill-rule="evenodd" stroke="none" d="M 343.817 530.987 C 345.366 464.010 346.915 397.033 348.463 330.056 C 303.242 281.968 211.489 187.166 212.798 185.791 C 213.978 184.552 349.015 303.092 349.028 303.103 C 349.029 303.077 356.928 40.000 360.000 40.000 C 363.125 40.000 367.314 215.402 370.972 303.103 C 370.985 303.092 506.021 184.552 507.202 185.791 C 508.513 187.165 416.822 281.902 371.632 329.957 C 373.279 396.842 374.927 463.727 376.575 530.611 C 376.594 530.594 569.667 356.489 571.381 358.264 C 573.262 360.212 377.200 564.739 377.181 564.759 C 377.181 564.773 380.000 699.986 380.000 700.000 C 379.996 700.000 340.004 700.000 340.000 700.000 C 340.000 699.986 342.219 610.194 343.329 565.292 C 278.426 496.282 146.739 360.213 148.619 358.264 C 150.352 356.467 343.798 530.970 343.817 530.987 Z"/>
</g>
<g id="specs">
<path fill="#ff00ff" fill-rule="evenodd" stroke="none" d="M 223.420 496.580 C 223.447 496.580 496.553 496.580 496.580 496.580 C 496.580 496.553 496.580 223.447 496.580 223.420 C 496.553 223.420 223.447 223.420 223.420 223.420 C 223.420 223.447 223.420 496.553 223.420 496.580 Z"/>
<path fill="#ff0000" fill-rule="evenodd" stroke="none" d="M 360.000 677.423 C 360.001 677.427 372.416 712.189 372.417 712.193 C 372.416 712.192 360.001 700.640 360.000 700.639 C 359.999 700.640 347.584 712.192 347.583 712.193 C 347.584 712.189 359.999 677.427 360.000 677.423 Z"/>
<path fill="#0000ff" fill-rule="evenodd" stroke="none" d="M 360.000 18.197 C 360.001 18.199 367.212 38.392 367.213 38.394 C 367.212 38.394 360.001 31.684 360.000 31.683 C 359.999 31.684 352.788 38.394 352.787 38.394 C 352.788 38.392 359.999 18.199 360.000 18.197 Z"/>
<path fill="#00ff00" fill-rule="evenodd" stroke="none" d="M 18.822 361.002 C 18.824 361.002 38.981 353.687 38.983 353.687 C 38.982 353.687 32.308 360.933 32.308 360.934 C 32.308 360.935 39.055 368.112 39.055 368.113 C 39.053 368.112 18.824 361.003 18.822 361.002 Z"/>
<path fill="#ffff00" fill-rule="evenodd" stroke="none" d="M 118.004 603.196 C 118.005 603.194 126.954 583.709 126.955 583.707 C 126.955 583.708 127.426 593.548 127.426 593.549 C 127.427 593.549 137.274 593.787 137.275 593.787 C 137.273 593.788 118.006 603.195 118.004 603.196 Z"/>
<path fill="#ffff00" fill-rule="evenodd" stroke="none" d="M 601.996 602.958 C 601.995 602.956 592.657 583.654 592.656 583.652 C 592.656 583.653 592.383 593.500 592.383 593.501 C 592.382 593.501 582.540 593.937 582.539 593.937 C 582.541 593.938 601.994 602.957 601.996 602.958 Z"/>
<path fill="#00ff00" fill-rule="evenodd" stroke="none" d="M 701.178 361.002 C 701.176 361.002 681.019 353.687 681.017 353.687 C 681.018 353.687 687.692 360.933 687.692 360.934 C 687.692 360.935 680.945 368.112 680.945 368.113 C 680.947 368.112 701.176 361.003 701.178 361.002 Z"/>
<path fill="#00ffff" fill-rule="evenodd" stroke="none" d="M 601.061 118.017 C 601.060 118.019 593.902 138.231 593.901 138.233 C 593.901 138.232 592.546 128.475 592.546 128.474 C 592.545 128.474 582.716 129.123 582.715 129.123 C 582.717 129.122 601.059 118.018 601.061 118.017 Z"/>
<path fill="#00ffff" fill-rule="evenodd" stroke="none" d="M 119.051 118.687 C 119.051 118.689 127.541 138.379 127.542 138.381 C 127.542 138.380 128.244 128.554 128.244 128.553 C 128.245 128.553 138.095 128.546 138.096 128.546 C 138.094 128.545 119.052 118.688 119.051 118.687 Z"/>
<path fill="#be0027" fill-rule="evenodd" stroke="none" d="M 360.000 691.430 C 360.001 691.427 372.416 656.664 372.417 656.661 C 372.416 656.662 360.001 668.214 360.000 668.215 C 359.999 668.214 347.584 656.662 347.583 656.661 C 347.584 656.664 359.999 691.427 360.000 691.430 Z"/>
</g>
</g>
</svg>

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 4.5 KiB

Wyświetl plik

@ -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 [],

Wyświetl plik

@ -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 (
<radialGradient
id={uidMap.getStrict(def.id)}
cx={def.cx}
cy={def.cy}
r={def.r}
>
{def.stops.map((stop, i) => (
<stop
key={i}
offset={stop.offset}
stopColor={getColor(ctx, stop.color)}
/>
))}
</radialGradient>
);
}
};
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 (
<g data-symbol-name={d.name}>
{props.data.layers.map(reactifySvgSymbolElement.bind(null, props))}
{d.defs &&
d.defs.map((def, i) => (
<SvgSymbolDef key={i} {...props} def={def} uidMap={uidMap} />
))}
{props.data.layers.map(
reactifySvgSymbolElement.bind(null, props, uidMap)
)}
{props.showSpecs && d.specs && <VisibleSpecs specs={d.specs} />}
</g>
);

Wyświetl plik

@ -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"'
);
});
});
});

88
lib/unique-id.tsx 100644
Wyświetl plik

@ -0,0 +1,88 @@
import React, { useContext, useMemo } from "react";
type UniqueIdContextType = {
prefix: string;
counter: number;
};
const UniqueIdContext = React.createContext<UniqueIdContextType>({
prefix: "uid_",
counter: 0,
});
function useUniqueIds(count: number): string[] {
const ctx = useContext(UniqueIdContext);
const result = useMemo<string[]>(() => {
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<string, string> {
/**
* 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 `<radialGradient id="boop">` 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]
);
}

Wyświetl plik

@ -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]);
});

Wyświetl plik

@ -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<T>(arr: (T | null)[]): T[] {
const result: T[] = [];
for (let item of arr) {
if (item !== null) {
result.push(item);
}
}
return result;
}

Wyświetl plik

@ -3,6 +3,13 @@ import { convertSvgMarkupToSymbolData } from "./vocabulary-builder";
const CIRCLE = `<path fill="#ffffff" fill-rule="evenodd" stroke="#000000" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" d="M 360.000 29.751 C 542.791 29.751 690.249 177.209 690.249 360.000 C 690.249 542.791 542.791 690.249 360.000 690.249 C 177.209 690.249 29.751 542.791 29.751 360.000 C 29.751 177.209 177.209 29.751 360.000 29.751 Z"/>`;
const RADIAL_GRADIENT = [
`<radialGradient id="blargy" cx="49.97%" cy="76.69%" r="68.79%">`,
`<stop offset="0.00%" style="stop-color:rgb(0,0,0);stop-opacity:1.00" />`,
`<stop offset="100.00%" style="stop-color:rgb(255,255,255);stop-opacity:1.00" />`,
`</radialGradient>`,
].join("");
function arrow(color: string) {
return `<path fill="${color}" fill-rule="evenodd" stroke="none" d="M 360.000 679.153 C 360.001 679.156 372.114 713.074 372.116 713.077 C 372.114 713.076 360.001 701.805 360.000 701.804 C 359.999 701.805 347.886 713.076 347.884 713.077 C 347.886 713.074 359.999 679.156 360.000 679.153 Z"/>`;
}
@ -14,6 +21,14 @@ describe("convertSvgMarkupToSymbolData()", () => {
).toMatchSnapshot();
});
it("processses radial gradients", () => {
const result = convertSvgMarkupToSymbolData(
"blarg.svg",
`<svg>${CIRCLE}${RADIAL_GRADIENT}</svg>`
);
expect(result.defs).toMatchSnapshot();
});
it("ignores empty layers", () => {
const sd1 = convertSvgMarkupToSymbolData(
"blarg.svg",

Wyświetl plik

@ -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 <stop> 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;
}