1
0
mirror of https://github.com/svg/svgo.git synced 2025-07-29 20:21:14 +03:00

Add better parser errors (#1553)

Old SVGO errors were not very helpful. Packages like cssnano
(postcss-svgo) had to deal with a lot of issues which are hard to debug
with old errors.

```
Error: Error in parsing SVG: Unquoted attribute value
Line: 1
Column: 29
Char: 6
File: input.svg
```

New errors are more informative and may solve many struggles

```
Error: SvgoParserError: input.svg:2:29: Unquoted attribute value

  1 | <svg viewBox="0 0 120 120">
> 2 |   <circle fill="#ff0000" cx=60.444444" cy="60" r="50"/>
    |                             ^
  3 | </svg>
  4 |
```
This commit is contained in:
Bogdan Chadkin
2021-09-12 01:09:10 +03:00
committed by GitHub
parent e8321f0c27
commit 77102ed096
12 changed files with 327 additions and 139 deletions

View File

@ -29,7 +29,12 @@ const optimize = (input, config) => {
}
for (let i = 0; i < maxPassCount; i += 1) {
info.multipassCount = i;
svgjs = svg2js(input);
// TODO throw this error in v3
try {
svgjs = svg2js(input, config.path);
} catch (error) {
return { error: error.toString(), modernError: error };
}
if (svgjs.error != null) {
if (config.path != null) {
svgjs.path = config.path;

View File

@ -12,12 +12,11 @@ test('allow to setup default preset', () => {
<circle fill="#ff0000" cx="60" cy="60" r="50"/>
</svg>
`;
expect(
optimize(svg, {
const { data } = optimize(svg, {
plugins: ['preset-default'],
js2svg: { pretty: true, indent: 2 },
}).data
).toMatchInlineSnapshot(`
});
expect(data).toMatchInlineSnapshot(`
"<svg viewBox=\\"0 0 120 120\\">
<circle fill=\\"red\\" cx=\\"60\\" cy=\\"60\\" r=\\"50\\"/>
</svg>
@ -35,8 +34,7 @@ test('allow to disable and customize plugins in preset', () => {
<circle fill="#ff0000" cx="60" cy="60" r="50"/>
</svg>
`;
expect(
optimize(svg, {
const { data } = optimize(svg, {
plugins: [
{
name: 'preset-default',
@ -51,8 +49,8 @@ test('allow to disable and customize plugins in preset', () => {
},
],
js2svg: { pretty: true, indent: 2 },
}).data
).toMatchInlineSnapshot(`
});
expect(data).toMatchInlineSnapshot(`
"<?xml version=\\"1.0\\" encoding=\\"utf-8\\"?>
<svg viewBox=\\"0 0 120 120\\">
<desc>
@ -186,8 +184,7 @@ test('allow to customize precision for preset', () => {
<circle fill="#ff0000" cx="60.444444" cy="60" r="50"/>
</svg>
`;
expect(
optimize(svg, {
const { data } = optimize(svg, {
plugins: [
{
name: 'preset-default',
@ -197,8 +194,8 @@ test('allow to customize precision for preset', () => {
},
],
js2svg: { pretty: true, indent: 2 },
}).data
).toMatchInlineSnapshot(`
});
expect(data).toMatchInlineSnapshot(`
"<svg viewBox=\\"0 0 120 120\\">
<circle fill=\\"red\\" cx=\\"60.4444\\" cy=\\"60\\" r=\\"50\\"/>
</svg>
@ -212,8 +209,7 @@ test('plugin precision should override preset precision', () => {
<circle fill="#ff0000" cx="60.444444" cy="60" r="50"/>
</svg>
`;
expect(
optimize(svg, {
const { data } = optimize(svg, {
plugins: [
{
name: 'preset-default',
@ -228,11 +224,103 @@ test('plugin precision should override preset precision', () => {
},
],
js2svg: { pretty: true, indent: 2 },
}).data
).toMatchInlineSnapshot(`
});
expect(data).toMatchInlineSnapshot(`
"<svg viewBox=\\"0 0 120 120\\">
<circle fill=\\"red\\" cx=\\"60.44444\\" cy=\\"60\\" r=\\"50\\"/>
</svg>
"
`);
});
test('provides informative error in result', () => {
const svg = `<svg viewBox="0 0 120 120">
<circle fill="#ff0000" cx=60.444444" cy="60" r="50"/>
</svg>
`;
const { modernError: error } = optimize(svg, { path: 'test.svg' });
expect(error.name).toEqual('SvgoParserError');
expect(error.message).toEqual('test.svg:2:33: Unquoted attribute value');
expect(error.reason).toEqual('Unquoted attribute value');
expect(error.line).toEqual(2);
expect(error.column).toEqual(33);
expect(error.source).toEqual(svg);
});
test('provides code snippet in rendered error', () => {
const svg = `<svg viewBox="0 0 120 120">
<circle fill="#ff0000" cx=60.444444" cy="60" r="50"/>
</svg>
`;
const { modernError: error } = optimize(svg, { path: 'test.svg' });
expect(error.toString())
.toEqual(`SvgoParserError: test.svg:2:29: Unquoted attribute value
1 | <svg viewBox="0 0 120 120">
> 2 | <circle fill="#ff0000" cx=60.444444" cy="60" r="50"/>
| ^
3 | </svg>
4 |
`);
});
test('supports errors without path', () => {
const svg = `<svg viewBox="0 0 120 120">
<circle/>
<circle/>
<circle/>
<circle/>
<circle/>
<circle/>
<circle/>
<circle/>
<circle/>
<circle fill="#ff0000" cx=60.444444" cy="60" r="50"/>
</svg>
`;
const { modernError: error } = optimize(svg);
expect(error.toString())
.toEqual(`SvgoParserError: <input>:11:29: Unquoted attribute value
9 | <circle/>
10 | <circle/>
> 11 | <circle fill="#ff0000" cx=60.444444" cy="60" r="50"/>
| ^
12 | </svg>
13 |
`);
});
test('slices long line in error code snippet', () => {
const svg = `<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" viewBox="0 0 230 120">
<path d="M318.198 551.135 530.33 918.56l-289.778-77.646 38.823-144.889c77.646-289.778 294.98-231.543 256.156-86.655s178.51 203.124 217.334 58.235q58.234-217.334 250.955 222.534t579.555 155.292z stroke-width="1.5" fill="red" stroke="red" />
</svg>
`;
const { modernError: error } = optimize(svg);
expect(error.toString())
.toEqual(`SvgoParserError: <input>:2:211: Invalid attribute name
1 | …-0.dtd" viewBox="0 0 230 120">
> 2 | …7.334 250.955 222.534t579.555 155.292z stroke-width="1.5" fill="red" strok…
| ^
3 |
4 |
`);
});
test('provides legacy error message', () => {
const svg = `<svg viewBox="0 0 120 120">
<circle fill="#ff0000" cx=60.444444" cy="60" r="50"/>
</svg>
`;
const { error } = optimize(svg, { path: 'test.svg' });
expect(error)
.toEqual(`SvgoParserError: test.svg:2:29: Unquoted attribute value
1 | <svg viewBox="0 0 120 120">
> 2 | <circle fill="#ff0000" cx=60.444444" cy="60" r="50"/>
| ^
3 | </svg>
4 |
`);
});

View File

@ -2,7 +2,7 @@
const FS = require('fs');
const PATH = require('path');
const { green } = require('colorette');
const { green, red } = require('colorette');
const { loadConfig, optimize } = require('../svgo-node.js');
const pluginsMap = require('../../plugins/plugins.js');
const PKG = require('../../package.json');
@ -383,12 +383,9 @@ function processSVGData(config, info, data, output, input) {
prevFileSize = Buffer.byteLength(data, 'utf8');
const result = optimize(data, { ...config, ...info });
if (result.error) {
let message = result.error;
if (result.path != null) {
message += `\nFile: ${result.path}`;
}
throw Error(message);
if (result.modernError) {
console.error(red(result.modernError.toString()));
process.exit(1);
}
if (config.datauri) {
result.data = encodeSVGDatauri(result.data, config.datauri);

View File

@ -4,6 +4,55 @@ const SAX = require('@trysound/sax');
const JSAPI = require('./jsAPI.js');
const { textElems } = require('../../plugins/_collections.js');
class SvgoParserError extends Error {
constructor(message, line, column, source, file) {
super(message);
this.name = 'SvgoParserError';
this.message = `${file || '<input>'}:${line}:${column}: ${message}`;
this.reason = message;
this.line = line;
this.column = column;
this.source = source;
if (Error.captureStackTrace) {
Error.captureStackTrace(this, SvgoParserError);
}
}
toString() {
const lines = this.source.split(/\r?\n/);
const startLine = Math.max(this.line - 3, 0);
const endLine = Math.min(this.line + 2, lines.length);
const lineNumberWidth = String(endLine).length;
const startColumn = Math.max(this.column - 54, 0);
const endColumn = Math.max(this.column + 20, 80);
const code = lines
.slice(startLine, endLine)
.map((line, index) => {
const lineSlice = line.slice(startColumn, endColumn);
let ellipsisPrefix = '';
let ellipsisSuffix = '';
if (startColumn !== 0) {
ellipsisPrefix = startColumn > line.length - 1 ? ' ' : '…';
}
if (endColumn < line.length - 1) {
ellipsisSuffix = '…';
}
const number = startLine + 1 + index;
const gutter = ` ${number.toString().padStart(lineNumberWidth)} | `;
if (number === this.line) {
const gutterSpacing = gutter.replace(/[^|]/g, ' ');
const lineSpacing = (
ellipsisPrefix + line.slice(startColumn, this.column - 1)
).replace(/[^\t]/g, ' ');
const spacing = gutterSpacing + lineSpacing;
return `>${gutter}${ellipsisPrefix}${lineSlice}${ellipsisSuffix}\n ${spacing}^`;
}
return ` ${gutter}${ellipsisPrefix}${lineSlice}${ellipsisSuffix}`;
})
.join('\n');
return `${this.name}: ${this.message}\n\n${code}\n`;
}
}
const entityDeclaration = /<!ENTITY\s+(\S+)\s+(?:'([^']+)'|"([^"]+)")\s*>/g;
const config = {
@ -20,7 +69,7 @@ const config = {
*
* @param {String} data input data
*/
module.exports = function (data) {
module.exports = function (data, from) {
const sax = SAX.parser(config.strict, config);
const root = new JSAPI({ type: 'root', children: [] });
let current = root;
@ -115,16 +164,18 @@ module.exports = function (data) {
};
sax.onerror = function (e) {
e.message = 'Error in parsing SVG: ' + e.message;
if (e.message.indexOf('Unexpected end') < 0) {
throw e;
const error = new SvgoParserError(
e.reason,
e.line + 1,
e.column,
data,
from
);
if (e.message.indexOf('Unexpected end') === -1) {
throw error;
}
};
try {
sax.write(data).close();
return root;
} catch (e) {
return { error: e.message };
}
};

79
package-lock.json generated
View File

@ -617,6 +617,17 @@
"rimraf": "^3.0.0",
"slash": "^3.0.0",
"strip-ansi": "^6.0.0"
},
"dependencies": {
"strip-ansi": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
"integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==",
"dev": true,
"requires": {
"ansi-regex": "^5.0.0"
}
}
}
},
"@jest/environment": {
@ -867,9 +878,9 @@
"dev": true
},
"@trysound/sax": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.1.1.tgz",
"integrity": "sha512-Z6DoceYb/1xSg5+e+ZlPZ9v0N16ZvZ+wYMraFue4HYrE4ttONKtsvruIRf6t9TBR0YvSOfi1hUU0fJfBLCDYow=="
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
"integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA=="
},
"@types/babel__core": {
"version": "7.1.15",
@ -1403,6 +1414,17 @@
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^7.0.0"
},
"dependencies": {
"strip-ansi": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
"integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==",
"dev": true,
"requires": {
"ansi-regex": "^5.0.0"
}
}
}
},
"co": {
@ -1852,6 +1874,15 @@
"resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz",
"integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==",
"dev": true
},
"strip-ansi": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
"integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==",
"dev": true,
"requires": {
"ansi-regex": "^5.0.0"
}
}
}
},
@ -3946,6 +3977,17 @@
"requires": {
"char-regex": "^1.0.2",
"strip-ansi": "^6.0.0"
},
"dependencies": {
"strip-ansi": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
"integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==",
"dev": true,
"requires": {
"ansi-regex": "^5.0.0"
}
}
}
},
"string-width": {
@ -3957,6 +3999,17 @@
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.0"
},
"dependencies": {
"strip-ansi": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
"integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==",
"dev": true,
"requires": {
"ansi-regex": "^5.0.0"
}
}
}
},
"string_decoder": {
@ -4059,6 +4112,15 @@
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true
},
"strip-ansi": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
"integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==",
"dev": true,
"requires": {
"ansi-regex": "^5.0.0"
}
}
}
},
@ -4339,6 +4401,17 @@
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"dependencies": {
"strip-ansi": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
"integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==",
"dev": true,
"requires": {
"ansi-regex": "^5.0.0"
}
}
}
},
"wrappy": {

View File

@ -92,7 +92,7 @@
]
},
"dependencies": {
"@trysound/sax": "0.1.1",
"@trysound/sax": "0.2.0",
"colorette": "^1.3.0",
"commander": "^7.2.0",
"css-select": "^4.1.3",
@ -118,6 +118,7 @@
"prettier": "^2.3.2",
"rollup": "^2.56.3",
"rollup-plugin-terser": "^7.0.2",
"strip-ansi": "^6.0.0",
"tar-stream": "^2.2.0",
"typescript": "^4.4.2"
},

33
test/cli/cli.test.js Normal file
View File

@ -0,0 +1,33 @@
'use strict';
const { spawn } = require('child_process');
const stripAnsi = require('strip-ansi');
test('should exit with 1 code on syntax error', async () => {
const proc = spawn('node', ['../../bin/svgo', 'invalid.svg'], {
cwd: __dirname,
});
const [code, stderr] = await Promise.all([
new Promise((resolve) => {
proc.on('close', (code) => {
resolve(code);
});
}),
new Promise((resolve) => {
proc.stderr.on('data', (error) => {
resolve(error.toString());
});
}),
]);
expect(code).toEqual(1);
expect(stripAnsi(stderr))
.toEqual(`SvgoParserError: invalid.svg:2:27: Unquoted attribute value
1 | <svg>
> 2 | <rect x="0" y="0" width=10" height="20" />
| ^
3 | </svg>
4 |
`);
});

3
test/cli/invalid.svg Normal file
View File

@ -0,0 +1,3 @@
<svg>
<rect x="0" y="0" width=10" height="20" />
</svg>

After

Width:  |  Height:  |  Size: 58 B

View File

@ -341,46 +341,4 @@ describe('svg2js', function () {
});
});
});
describe('malformed svg', function () {
var filepath = PATH.resolve(__dirname, './test.bad.svg'),
root,
error;
beforeAll(function (done) {
FS.readFile(filepath, 'utf8', function (err, data) {
if (err) {
throw err;
}
try {
root = SVG2JS(data);
} catch (e) {
error = e;
}
done();
});
});
describe('root', function () {
it('should have property "error"', function () {
expect(root).toHaveProperty('error');
});
});
describe('root.error', function () {
it('should be "Error in parsing SVG: Unexpected close tag"', function () {
expect(root.error).toEqual(
'Error in parsing SVG: Unexpected close tag\nLine: 10\nColumn: 15\nChar: >'
);
});
});
describe('error', function () {
it('should not be thrown', function () {
expect(error).not.toEqual(expect.anything());
});
});
});
});

View File

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 15.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="120px" height="120px" viewBox="0 0 120 120"
enable-background="new 0 0 120 120" xml:space="preserve"
>
style type="text/css"><![CDATA[
svg { fill: red; }
]]></style>
<g>
<g>
<circle fill="#ff0000" cx="60px" cy="60px" r="50px"/>
<text>test</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 692 B

View File

@ -46,14 +46,6 @@ describe('svgo', () => {
const result = optimize(original, { input: 'file', path: 'input.svg' });
expect(normalize(result.data)).toEqual(expected);
});
it('should handle parse error', async () => {
const fixture = await fs.promises.readFile(
path.resolve(__dirname, 'invalid.svg')
);
const result = optimize(fixture, { input: 'file', path: 'input.svg' });
expect(result.error).toMatch(/Error in parsing SVG/);
expect(result.path).toEqual('input.svg');
});
it('should handle empty svg tag', async () => {
const result = optimize('<svg />', { input: 'file', path: 'input.svg' });
expect(result.data).toEqual('<svg/>');

View File

@ -10,7 +10,12 @@
"resolveJsonModule": true,
"noImplicitAny": true
},
"include": ["plugins/**/*", "lib/xast.test.js", "lib/path.test.js"],
"include": [
"plugins/**/*",
"lib/xast.test.js",
"lib/path.test.js",
"test/cli/**/*"
],
"exclude": [
"plugins/_applyTransforms.js",
"plugins/collapseGroups.js",