Skip to content

Commit ff82f62

Browse files
committed
fix(svg): support maxPixels
1 parent 1d2638a commit ff82f62

3 files changed

Lines changed: 26 additions & 7 deletions

File tree

‎src/image.ts‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,11 @@ export interface RenderSVGOptions {
395395
* Default: `1`
396396
*/
397397
scale?: number;
398+
/**
399+
* The most pixels to render, and the most pixels in any embedded image. Default: `268402689` (16383²), `Infinity`
400+
* for no limit
401+
*/
402+
maxPixels?: number;
398403
/**
399404
* Fonts for `<text>` (TrueType, OpenType): each text gets the face its `font-family`, `font-weight`, `font-style`
400405
* and `font-stretch` match best among them, by the names, weights and styles in the fonts (like browsers match
@@ -674,13 +679,15 @@ export class Image {
674679
* @param height The height in pixels
675680
*/
676681
constructor(width: number, height: number) {
677-
width = ~~width;
678-
height = ~~height;
682+
width = Math.trunc(finite(width, 'width'));
683+
height = Math.trunc(finite(height, 'height'));
679684

680685
if (width < 1)
681686
throw new RangeError('Image has to be at least 1 pixel wide');
682687
if (height < 1)
683688
throw new RangeError('Image has to be at least 1 pixel high');
689+
if (width > 0x7fffffff) throw new RangeError('Image width is too large');
690+
if (height > 0x7fffffff) throw new RangeError('Image height is too large');
684691

685692
this.__width__ = width;
686693
this.__height__ = height;
@@ -2708,7 +2715,8 @@ export class Image {
27082715
static renderSVG(svg: string | BinaryData, options?: RenderSVGOptions): Promise<Image>;
27092716
static async renderSVG(svg: string | BinaryData, options: RenderSVGOptions = {}): Promise<Image> {
27102717
checkOptions(options, 'renderSVG(svg, { width: 512 })');
2711-
const { width: givenWidth, height: givenHeight, scale, fonts = [], images: givenImages = {} } = options;
2718+
const { width: givenWidth, height: givenHeight, scale, maxPixels = MAX_PIXELS, fonts = [], images: givenImages = {} } = options;
2719+
if (!(Number(maxPixels) > 0)) throw new RangeError(`maxPixels must be a positive number, not ${maxPixels}`);
27122720
const sizes = [givenWidth, givenHeight, scale].filter(value => value !== undefined).length;
27132721
if (sizes > 1) throw new TypeError('renderSVG() takes one of width, height and scale');
27142722
const mode = givenWidth !== undefined ? 'width' : givenHeight !== undefined ? 'height' : 'scale';
@@ -2741,18 +2749,18 @@ export class Image {
27412749
images.set(href, { width: value.width, height: value.height, pixels: value.bitmap });
27422750
} else if (ImageType.getType(value) === 'gif') {
27432751
// (a GIF's first frame)
2744-
const decoded = gif.decode(view(value), true);
2752+
const decoded = gif.decode(view(value), true, maxPixels);
27452753
images.set(href, { width: decoded.width, height: decoded.height, pixels: gif.composite(decoded)[0].pixels });
27462754
} else {
2747-
const image = await Image.decode(value);
2755+
const image = await Image.decode(value, { maxPixels });
27482756
images.set(href, { width: image.width, height: image.height, pixels: image.bitmap });
27492757
}
27502758
} catch {
27512759
// (images that can't be decoded aren't drawn, like resvg's)
27522760
}
27532761
}
27542762

2755-
const {width, height, pixels} = svgRenderer.render(source, mode, size, images, (Array.isArray(fonts) ? fonts : [fonts]).map(font => view(font)));
2763+
const {width, height, pixels} = svgRenderer.render(source, mode, size, images, (Array.isArray(fonts) ? fonts : [fonts]).map(font => view(font)), maxPixels);
27562764
const image = new Image(width, height);
27572765
image.bitmap.set(pixels);
27582766

‎src/render/svg.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { FontFace, FontStyle, fontFace, layOutText, parseFontFamilies, parseVari
2222
import { rasterizeGlyph } from './text.ts';
2323
import { renderPaintGlyph } from './colr.ts';
2424
import { decodeSync as decodePNG } from '../codecs/png.ts';
25+
import { MAX_PIXELS, checkPixels } from '../util/limits.ts';
2526

2627
const UNSUPPORTED = new Set(['filter', 'pattern', 'marker', 'foreignObject', 'script', 'style', 'title', 'desc', 'metadata',
2728
'defs', 'clipPath', 'mask', 'linearGradient', 'radialGradient', 'stop', 'symbol', 'font', 'font-face', 'animate', 'animateTransform', 'animateMotion', 'set']);
@@ -159,7 +160,7 @@ const OFFSETS = Float64Array.from({ length: SAMPLES }, (_, s) => (s + 0.5) / SAM
159160
* @param {number} size
160161
* @return {{width: number, height: number, pixels: Uint8Array}}
161162
*/
162-
export function render(source: string | Uint8Array, mode: 'scale' | 'width' | 'height', size: number, images: Map<string, RasterImage> = new Map(), fonts: Uint8Array[] = []): { width: number, height: number, pixels: Uint8Array } {
163+
export function render(source: string | Uint8Array, mode: 'scale' | 'width' | 'height', size: number, images: Map<string, RasterImage> = new Map(), fonts: Uint8Array[] = [], maxPixels = MAX_PIXELS): { width: number, height: number, pixels: Uint8Array } {
163164
if (typeof source !== 'string') source = decodeUTF8(source);
164165

165166
const root = parseXML(source).children.find(node => node.name === 'svg' && node.svg);
@@ -186,6 +187,10 @@ export function render(source: string | Uint8Array, mode: 'scale' | 'width' | 'h
186187
outWidth = Math.max(1, outWidth);
187188
outHeight = Math.max(1, outHeight);
188189

190+
if (!Number.isSafeInteger(outWidth) || !Number.isSafeInteger(outHeight))
191+
throw new RangeError(`SVG output dimensions are too large: ${outWidth}x${outHeight}`);
192+
checkPixels(outWidth, outHeight, maxPixels);
193+
189194
const canvas = new Layer(outWidth, outHeight);
190195
// (a transform on the root element (SVG 2) applies before the viewBox mapping)
191196
doc.renderRoot([outWidth / width, 0, 0, outHeight / height, 0, 0], canvas, 0);

‎tests/option-checks.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,13 @@ const rejects = async (name, fn, Type, pattern) => {
5555
await rejects('Animation loopCount 1.5', () => new Animation([new Frame(new Image(8, 8))], {loopCount: 1.5}), RangeError, /loop count/);
5656
await rejects('dominantColor threshold NaN', () => image.dominantColor({threshold: NaN}), RangeError, /threshold/);
5757
await rejects('renderSVG scale NaN', () => Image.renderSVG('<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4"/>', {scale: NaN}), RangeError, /SVG scale/);
58+
await rejects('renderSVG maxPixels 0', () => Image.renderSVG('<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4"/>', {maxPixels: 0}), RangeError, /maxPixels/);
59+
await rejects('renderSVG maxPixels exceeded', () => Image.renderSVG('<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4"/>', {maxPixels: 15}), RangeError, /maxPixels/);
60+
if ((await Image.renderSVG('<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4"/>', {maxPixels: 16})).width !== 4)
61+
panic('renderSVG: maxPixels should allow an output that fits');
5862
await rejects('renderText size -1', () => Image.renderText('x', { font: new Uint8Array(0), size: -1 }), RangeError, /font size/);
63+
if (new Image(2.9, 1).width !== 2) panic('Image constructor: fractional dimensions should still be truncated');
64+
await rejects('Image width wraps to small positive dimension', () => new Image(2 ** 32 + 2, 1), RangeError, /width is too large/);
5965
const png = await image.encodePNG();
6066
for (const maxPixels of [NaN, -1, 0]) {
6167
await rejects(`Image.decode maxPixels ${maxPixels}`, () => Image.decode(png, {maxPixels}), RangeError, /maxPixels/);

0 commit comments

Comments
 (0)