2
0
mirror of https://github.com/iconify/iconify.git synced 2024-11-09 14:50:56 +00:00

feat: parse svg in utils, handle defs correctly

This commit is contained in:
Vjacheslav Trushkin 2023-08-17 13:30:29 +03:00
parent 957a9eda66
commit c5f818ecab
7 changed files with 391 additions and 14 deletions

View File

@ -342,6 +342,11 @@
"import": "./lib/svg/build.mjs",
"types": "./lib/svg/build.d.ts"
},
"./lib/svg/defs": {
"require": "./lib/svg/defs.cjs",
"import": "./lib/svg/defs.mjs",
"types": "./lib/svg/defs.d.ts"
},
"./lib/svg/encode-svg-for-css": {
"require": "./lib/svg/encode-svg-for-css.cjs",
"import": "./lib/svg/encode-svg-for-css.mjs",
@ -362,6 +367,11 @@
"import": "./lib/svg/inner-html.mjs",
"types": "./lib/svg/inner-html.d.ts"
},
"./lib/svg/parse": {
"require": "./lib/svg/parse.cjs",
"import": "./lib/svg/parse.mjs",
"types": "./lib/svg/parse.d.ts"
},
"./lib/svg/size": {
"require": "./lib/svg/size.cjs",
"import": "./lib/svg/size.mjs",
@ -376,6 +386,11 @@
"require": "./lib/svg/url.cjs",
"import": "./lib/svg/url.mjs",
"types": "./lib/svg/url.d.ts"
},
"./lib/svg/viewbox": {
"require": "./lib/svg/viewbox.cjs",
"import": "./lib/svg/viewbox.mjs",
"types": "./lib/svg/viewbox.d.ts"
}
},
"files": [

View File

@ -4,6 +4,8 @@ import {
IconifyIconCustomisations,
} from '../customisations/defaults';
import { calculateSize } from './size';
import { SVGViewBox } from './viewbox';
import { wrapSVGContent } from './defs';
/**
* Interface for getSVGData() result
@ -16,6 +18,9 @@ export interface IconifyIconBuildResult {
viewBox: string;
};
// viewBox as numbers
viewBox: SVGViewBox;
// Content
body: string;
}
@ -163,12 +168,11 @@ export function iconToSVG(
}
if (transformations.length) {
body =
'<g transform="' +
transformations.join(' ') +
'">' +
body +
'</g>';
body = wrapSVGContent(
body,
'<g transform="' + transformations.join(' ') + '">',
'</g>'
);
}
});
@ -211,17 +215,12 @@ export function iconToSVG(
setAttr('width', width);
setAttr('height', height);
attributes.viewBox =
box.left.toString() +
' ' +
box.top.toString() +
' ' +
boxWidth.toString() +
' ' +
boxHeight.toString();
const viewBox: SVGViewBox = [box.left, box.top, boxWidth, boxHeight];
attributes.viewBox = viewBox.join(' ');
return {
attributes,
viewBox,
body,
};
}

View File

@ -0,0 +1,50 @@
interface SplitSVGDefsResult {
defs: string;
content: string;
}
/**
* Extract definitions from SVG
*/
export function splitSVGDefs(content: string): SplitSVGDefsResult {
let defs = '';
const index = content.indexOf('<defs');
while (index >= 0) {
const start = content.indexOf('>', index);
const end = content.indexOf('</defs');
if (start === -1 || end === -1) {
// Fail
break;
}
const endEnd = content.indexOf('>', end);
if (endEnd === -1) {
break;
}
defs += content.slice(start + 1, end).trim();
content = content.slice(0, index).trim() + content.slice(endEnd + 1);
}
return {
defs,
content,
};
}
/**
* Merge defs and content
*/
export function mergeDefsAndContent(defs: string, content: string): string {
return '<defs>' + defs + '</defs>' + content;
}
/**
* Wrap SVG content, without wrapping definitions
*/
export function wrapSVGContent(
body: string,
start: string,
end: string
): string {
const { defs, content } = splitSVGDefs(body);
return (defs ? '<defs>' + defs + '</defs>' : '') + start + content + end;
}

View File

@ -0,0 +1,92 @@
import { IconifyIconBuildResult } from './build';
import { wrapSVGContent } from './defs';
import { getSVGViewBox } from './viewbox';
/**
* Parsed SVG content
*/
export interface ParsedSVGContent {
// Attributes for SVG element
attribs: Record<string, string>;
// Content
body: string;
}
/**
* Extract attributes and content from SVG
*/
export function parseSVGContent(content: string): ParsedSVGContent | undefined {
// Split SVG attributes and body
const match = content
.trim()
.match(
/(?:<(?:\?xml|!DOCTYPE)[^>]+>\s*)*<svg([^>]+)>([\s\S]+)<\/svg[^>]*>/
);
if (!match) {
return;
}
const body = match[2].trim();
// Split attributes
const attribsList = match[1].match(/[\w:-]+="[^"]*"/g);
const attribs = Object.create(null) as Record<string, string>;
attribsList?.forEach((row) => {
const match = row.match(/([\w:-]+)="([^"]*)"/);
if (match) {
attribs[match[1]] = match[2];
}
});
return {
attribs,
body,
};
}
/**
* Convert parsed SVG to IconifyIconBuildResult
*/
export function buildParsedSVG(
data: ParsedSVGContent
): IconifyIconBuildResult | undefined {
const attribs = data.attribs;
const viewBox = getSVGViewBox(attribs['viewBox'] ?? '');
if (!viewBox) {
return;
}
// Split presentation attributes
const groupAttributes: string[] = [];
for (const key in attribs) {
if (
key === 'style' ||
key.startsWith('fill') ||
key.startsWith('stroke')
) {
groupAttributes.push(`${key}="${attribs[key]}"`);
}
}
let body = data.body;
if (groupAttributes.length) {
// Wrap content in group, except for defs
body = wrapSVGContent(
body,
'<g ' + groupAttributes.join(' ') + '>',
'</g>'
);
}
return {
attributes: {
// Copy dimensions if exist
width: attribs.width,
height: attribs.height,
// Merge viewBox
viewBox: viewBox.join(' '),
},
viewBox,
body,
};
}

View File

@ -0,0 +1,17 @@
/**
* SVG viewBox: x, y, width, height
*/
export type SVGViewBox = [x: number, y: number, width: number, height: number];
/**
* Get viewBox from string
*/
export function getSVGViewBox(value: string): SVGViewBox | undefined {
const result = value.trim().split(/\s+/).map(Number);
if (
result.length === 4 &&
result.reduce((prev, value) => prev && !isNaN(value), true)
) {
return result as SVGViewBox;
}
}

View File

@ -0,0 +1,191 @@
import { IconifyIconBuildResult } from '../lib/svg/build';
import { parseSVGContent, buildParsedSVG } from '../lib/svg/parse';
import { splitSVGDefs } from '../lib/svg/defs';
import { getSVGViewBox } from '../lib/svg/viewbox';
import { readFileSync } from 'node:fs';
const fixturesDir = './tests/fixtures';
describe('Testing parsing SVG content', () => {
test('Getting viewBox', () => {
// Valid numbers
expect(getSVGViewBox('1 2 3 4')).toEqual([1, 2, 3, 4]);
expect(getSVGViewBox('-1 0 25.5 -123.5')).toEqual([
-1, 0, 25.5, -123.5,
]);
expect(getSVGViewBox(' 1\t2 3\n4\t ')).toEqual([1, 2, 3, 4]);
// Bad numbers
expect(getSVGViewBox('1 2 3')).toBeUndefined();
expect(getSVGViewBox('1 2 3 4 5')).toBeUndefined();
expect(getSVGViewBox('a 1 2 3')).toBeUndefined();
expect(getSVGViewBox('0 1 2 b')).toBeUndefined();
expect(getSVGViewBox('1 2 3 4b')).toBeUndefined();
});
test('Simple SVG', () => {
const body =
'<path d="M12,21L15.6,16.2C14.6,15.45 13.35,15 12,15C10.65,15 9.4,15.45 8.4,16.2L12,21" opacity="0"><animate id="spinner_jbAr" begin="0;spinner_8ff3.end+0.2s" attributeName="opacity" calcMode="discrete" dur="0.25s" values="0;1" fill="freeze"/><animate id="spinner_8ff3" begin="spinner_aTlH.end+0.5s" attributeName="opacity" dur="0.001s" values="1;0" fill="freeze"/></path><path d="M12,9C9.3,9 6.81,9.89 4.8,11.4L6.6,13.8C8.1,12.67 9.97,12 12,12C14.03,12 15.9,12.67 17.4,13.8L19.2,11.4C17.19,9.89 14.7,9 12,9Z" opacity="0"><animate id="spinner_dof4" begin="spinner_jbAr.end" attributeName="opacity" calcMode="discrete" dur="0.25s" values="0;1" fill="freeze"/><animate begin="spinner_aTlH.end+0.5s" attributeName="opacity" dur="0.001s" values="1;0" fill="freeze"/></path><path d="M12,3C7.95,3 4.21,4.34 1.2,6.6L3,9C5.5,7.12 8.62,6 12,6C15.38,6 18.5,7.12 21,9L22.8,6.6C19.79,4.34 16.05,3 12,3" opacity="0"><animate id="spinner_aTlH" begin="spinner_dof4.end" attributeName="opacity" calcMode="discrete" dur="0.25s" values="0;1" fill="freeze"/><animate begin="spinner_aTlH.end+0.5s" attributeName="opacity" dur="0.001s" values="1;0" fill="freeze"/></path>';
const svg = `<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">${body}</svg>`;
// Parse
const parsed = parseSVGContent(svg);
expect(parsed).toBeTruthy();
if (!parsed) {
return;
}
expect(parsed.attribs).toEqual({
width: '24',
height: '24',
viewBox: '0 0 24 24',
xmlns: 'http://www.w3.org/2000/svg',
});
expect(parsed.body).toEqual(body);
// Build
const built = buildParsedSVG(parsed);
const expected: IconifyIconBuildResult = {
attributes: {
width: '24',
height: '24',
viewBox: '0 0 24 24',
},
viewBox: [0, 0, 24, 24],
body,
};
expect(built).toEqual(expected);
// Defs
expect(splitSVGDefs(body)).toEqual({
defs: '',
content: body,
});
});
test('SVG with XML heading', () => {
const svg = readFileSync(
fixturesDir + '/circle-xml-preface.svg',
'utf8'
);
const body = '<circle cx="60" cy="60" r="50"/>';
// Parse
const parsed = parseSVGContent(svg);
expect(parsed).toBeTruthy();
if (!parsed) {
return;
}
expect(parsed?.attribs).toEqual({
viewBox: '0 0 120 120',
xmlns: 'http://www.w3.org/2000/svg',
});
expect(parsed.body).toEqual(body);
// Build
const built = buildParsedSVG(parsed);
const expected: IconifyIconBuildResult = {
attributes: {
viewBox: '0 0 120 120',
},
viewBox: [0, 0, 120, 120],
body,
};
expect(built).toEqual(expected);
// Defs
expect(splitSVGDefs(body)).toEqual({
defs: '',
content: body,
});
});
test('SVG with style and junk', () => {
const body1 =
'<metadata id="metadata8"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/></cc:Work></rdf:RDF></metadata>';
const defs1 =
'<clipPath id="clipPath16" clipPathUnits="userSpaceOnUse"><path id="path18" d="M 0,38 38,38 38,0 0,0 0,38 Z"/></clipPath>';
const body2 =
'<g transform="matrix(1.25,0,0,-1.25,0,47.5)" id="g10"><g id="g12"><g clip-path="url(#clipPath16)" id="g14"><g transform="translate(37,3)" id="g20"><path id="path22" style="fill:#ffcc4d;fill-opacity:1;fill-rule:nonzero;stroke:none" d="m 0,0 c 0,-1.104 -0.896,-2 -2,-2 l -32,0 c -1.104,0 -2,0.896 -2,2 l 0,19 c 0,1.104 0.896,2 2,2 l 32,0 c 1.104,0 2,-0.896 2,-2 L 0,0 Z"/></g><g transform="translate(35,24)" id="g24"><path id="path26" style="fill:#6d6e71;fill-opacity:1;fill-rule:nonzero;stroke:none" d="m 0,0 -32,0 c -1.104,0 -2,-0.896 -2,-2 L 2,-2 C 2,-0.896 1.104,0 0,0"/></g><path id="path28" style="fill:#3b88c3;fill-opacity:1;fill-rule:nonzero;stroke:none" d="M 35,9 3,9 3,13 35,13 35,9 Z"/><path id="path30" style="fill:#3b88c3;fill-opacity:1;fill-rule:nonzero;stroke:none" d="m 35,15 -32,0 0,4 32,0 0,-4 z"/><path id="path32" style="fill:#3b88c3;fill-opacity:1;fill-rule:nonzero;stroke:none" d="M 35,3 3,3 3,7 35,7 35,3 Z"/><path id="path34" style="fill:#ffcc4d;fill-opacity:1;fill-rule:nonzero;stroke:none" d="m 31,2 -2,0 0,18 2,0 0,-18 z"/><g transform="translate(23,37)" id="g36"><path id="path38" style="fill:#ffe8b6;fill-opacity:1;fill-rule:nonzero;stroke:none" d="m 0,0 -16,0 c -1.104,0 -2,-0.896 -2,-2 l 0,-34 20,0 0,34 C 2,-0.896 1.104,0 0,0"/></g><g transform="translate(23,37)" id="g40"><path id="path42" style="fill:#808285;fill-opacity:1;fill-rule:nonzero;stroke:none" d="m 0,0 -16,0 c -1.104,0 -2,-0.896 -2,-2 L 2,-2 C 2,-0.896 1.104,0 0,0"/></g><path id="path44" style="fill:#55acee;fill-opacity:1;fill-rule:nonzero;stroke:none" d="m 23,15 -16,0 0,4 16,0 0,-4 z"/><path id="path46" style="fill:#55acee;fill-opacity:1;fill-rule:nonzero;stroke:none" d="M 23,9 7,9 7,13 23,13 23,9 Z"/><path id="path48" style="fill:#55acee;fill-opacity:1;fill-rule:nonzero;stroke:none" d="M 23,3 7,3 7,7 23,7 23,3 Z"/><path id="path50" style="fill:#ffe8b6;fill-opacity:1;fill-rule:nonzero;stroke:none" d="m 13,1 -2,0 0,29 2,0 0,-29 z"/><path id="path52" style="fill:#ffe8b6;fill-opacity:1;fill-rule:nonzero;stroke:none" d="m 19,1 -2,0 0,29 2,0 0,-29 z"/><path id="path54" style="fill:#226699;fill-opacity:1;fill-rule:nonzero;stroke:none" d="m 17,1 -4,0 0,6 4,0 0,-6 z"/><g transform="translate(21,28)" id="g56"><path id="path58" style="fill:#a7a9ac;fill-opacity:1;fill-rule:nonzero;stroke:none" d="m 0,0 c 0,-3.313 -2.687,-6 -6,-6 -3.313,0 -6,2.687 -6,6 0,3.313 2.687,6 6,6 3.313,0 6,-2.687 6,-6"/></g><g transform="translate(19,28)" id="g60"><path id="path62" style="fill:#e6e7e8;fill-opacity:1;fill-rule:nonzero;stroke:none" d="m 0,0 c 0,-2.209 -1.791,-4 -4,-4 -2.209,0 -4,1.791 -4,4 0,2.209 1.791,4 4,4 2.209,0 4,-1.791 4,-4"/></g><g transform="translate(18,27)" id="g64"><path id="path66" style="fill:#a0041e;fill-opacity:1;fill-rule:nonzero;stroke:none" d="m 0,0 -3,0 c -0.552,0 -1,0.448 -1,1 l 0,5 c 0,0.552 0.448,1 1,1 0.552,0 1,-0.448 1,-1 L -2,2 0,2 C 0.552,2 1,1.552 1,1 1,0.448 0.552,0 0,0"/></g></g></g></g>';
const body = `${body1}<defs id="defs6">${defs1}</defs>${body2}`;
const svg = `
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 47.5 47.5" style="enable-background:new 0 0 47.5 47.5;" xml:space="preserve" version="1.1" id="svg2">
${body}
</svg>`;
// Parse
const parsed = parseSVGContent(svg);
expect(parsed).toBeTruthy();
if (!parsed) {
return;
}
expect(parsed?.attribs).toEqual({
'xmlns:dc': 'http://purl.org/dc/elements/1.1/',
'xmlns:cc': 'http://creativecommons.org/ns#',
'xmlns:rdf': 'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
'xmlns:svg': 'http://www.w3.org/2000/svg',
'xmlns': 'http://www.w3.org/2000/svg',
'viewBox': '0 0 47.5 47.5',
'style': 'enable-background:new 0 0 47.5 47.5;',
'xml:space': 'preserve',
'version': '1.1',
'id': 'svg2',
});
expect(parsed.body).toEqual(body);
// Build
const built = buildParsedSVG(parsed);
const expected: IconifyIconBuildResult = {
attributes: {
viewBox: '0 0 47.5 47.5',
},
viewBox: [0, 0, 47.5, 47.5],
body: `<defs>${defs1}</defs><g style="enable-background:new 0 0 47.5 47.5;">${body1}${body2}</g>`,
};
expect(built).toEqual(expected);
// Defs
expect(splitSVGDefs(body)).toEqual({
defs: defs1,
content: body1 + body2,
});
});
test('SVG with fill', () => {
const body = `<g filter="url(#filter0_iii_18_1526)">
<path d="M14.0346 3.55204L18.2991 10.8362L12.2834 12.5469C8.12828 11.172 5.68075 8.52904 4.20532 5.8125C3.58307 4.66681 3.58813 2.5625 6.06108 2.5625H12.3087C13.0189 2.5625 13.6758 2.93914 14.0346 3.55204Z" fill="#4686EC"/>
<path d="M14.0346 3.55204L18.2991 10.8362L12.2834 12.5469C8.12828 11.172 5.68075 8.52904 4.20532 5.8125C3.58307 4.66681 3.58813 2.5625 6.06108 2.5625H12.3087C13.0189 2.5625 13.6758 2.93914 14.0346 3.55204Z" fill="url(#paint0_radial_18_1526)"/>
<path d="M14.0346 3.55204L18.2991 10.8362L12.2834 12.5469C8.12828 11.172 5.68075 8.52904 4.20532 5.8125C3.58307 4.66681 3.58813 2.5625 6.06108 2.5625H12.3087C13.0189 2.5625 13.6758 2.93914 14.0346 3.55204Z" fill="url(#paint1_linear_18_1526)"/>
</g>
`;
const svg = `<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">${body}</svg>`;
// Parse
const parsed = parseSVGContent(svg);
expect(parsed).toBeTruthy();
if (!parsed) {
return;
}
expect(parsed?.attribs).toEqual({
width: '32',
height: '32',
viewBox: '0 0 32 32',
fill: 'none',
xmlns: 'http://www.w3.org/2000/svg',
});
expect(parsed.body).toEqual(body.trim());
// Build
const built = buildParsedSVG(parsed);
const expected: IconifyIconBuildResult = {
attributes: {
width: '32',
height: '32',
viewBox: '0 0 32 32',
},
viewBox: [0, 0, 32, 32],
body: `<g fill="none">${body.trim()}</g>`,
};
expect(built).toEqual(expected);
});
});

View File

@ -13,6 +13,7 @@ describe('Testing iconToSVG', () => {
height: '1em',
viewBox: '0 0 16 16',
},
viewBox: [0, 0, 16, 16],
body: '',
};
@ -39,6 +40,7 @@ describe('Testing iconToSVG', () => {
height: '16',
viewBox: '0 0 16 16',
},
viewBox: [0, 0, 16, 16],
body: '<path d="" />',
};
@ -70,6 +72,7 @@ describe('Testing iconToSVG', () => {
height: '16',
viewBox: '0 0 16 16',
},
viewBox: [0, 0, 16, 16],
body: '<path d="" />',
};
@ -92,6 +95,7 @@ describe('Testing iconToSVG', () => {
height: '16',
viewBox: '0 0 20 16',
},
viewBox: [0, 0, 20, 16],
body: '<path d="..." />',
};
@ -115,6 +119,7 @@ describe('Testing iconToSVG', () => {
width: '20',
viewBox: '0 0 20 16',
},
viewBox: [0, 0, 20, 16],
body: '<path d="..." />',
};
@ -137,6 +142,7 @@ describe('Testing iconToSVG', () => {
attributes: {
viewBox: '0 0 20 16',
},
viewBox: [0, 0, 20, 16],
body: '<path d="..." />',
};
@ -160,6 +166,7 @@ describe('Testing iconToSVG', () => {
height: '40px',
viewBox: '0 0 16 20',
},
viewBox: [0, 0, 16, 20],
body: '<g transform="rotate(90 8 8)"><path d="..." /></g>',
};
@ -183,6 +190,7 @@ describe('Testing iconToSVG', () => {
height: '40px',
viewBox: '0 0 16 20',
},
viewBox: [0, 0, 16, 20],
body: '<g transform="rotate(-90 10 10)"><path d="..." /></g>',
};
@ -206,6 +214,7 @@ describe('Testing iconToSVG', () => {
height: '32',
viewBox: '0 0 20 16',
},
viewBox: [0, 0, 20, 16],
body: '<g transform="translate(20 0) scale(-1 1)"><path d="..." /></g>',
};
@ -229,6 +238,7 @@ describe('Testing iconToSVG', () => {
height: '1em',
viewBox: '0 0 16 20',
},
viewBox: [0, 0, 16, 20],
body: '<g transform="rotate(90 8 8) translate(20 0) scale(-1 1)"><path d="..." /></g>',
};
@ -255,6 +265,7 @@ describe('Testing iconToSVG', () => {
height: '1em',
viewBox: '0 0 16 20',
},
viewBox: [0, 0, 16, 20],
body: '<g transform="translate(16 0) scale(-1 1)"><g transform="rotate(90 8 8)"><path d="..." /></g></g>',
};
@ -281,6 +292,7 @@ describe('Testing iconToSVG', () => {
height: '16',
viewBox: '0 0 20 16',
},
viewBox: [0, 0, 20, 16],
body: '<path d="..." />',
};
@ -304,6 +316,7 @@ describe('Testing iconToSVG', () => {
height: '1em',
viewBox: '0 0 128 128',
},
viewBox: [0, 0, 128, 128],
body:
'<g transform="translate(128 0) scale(-1 1)">' +
iconBody +