From dfcb2d4ee6ada76a500e207a8f628c22c18bd9ee Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Sun, 16 Aug 2026 22:08:13 +0100 Subject: [PATCH] Vendor image dimension parsing in Metro --- packages/metro/package.json | 1 - packages/metro/src/Assets.js | 31 +- packages/metro/src/__tests__/Assets-test.js | 37 +- .../metro/src/lib/__tests__/imageSize-test.js | 318 ++++++++++ packages/metro/src/lib/imageSize.js | 578 ++++++++++++++++++ yarn.lock | 16 +- 6 files changed, 945 insertions(+), 36 deletions(-) create mode 100644 packages/metro/src/lib/__tests__/imageSize-test.js create mode 100644 packages/metro/src/lib/imageSize.js diff --git a/packages/metro/package.json b/packages/metro/package.json index 33cf3e17de..a0b4607bc3 100644 --- a/packages/metro/package.json +++ b/packages/metro/package.json @@ -34,7 +34,6 @@ "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "flow-parser": "0.327.0", - "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", diff --git a/packages/metro/src/Assets.js b/packages/metro/src/Assets.js index f4816836ba..2b5ef3598a 100644 --- a/packages/metro/src/Assets.js +++ b/packages/metro/src/Assets.js @@ -11,10 +11,9 @@ import type {AssetPath} from './node-haste/lib/AssetPaths'; +import {getImageDimensions} from './lib/imageSize'; import {normalizePathSeparatorsToPosix} from './lib/pathUtils'; import * as AssetPaths from './node-haste/lib/AssetPaths'; -// $FlowFixMe[untyped-import] image-size -import getImageSize from 'image-size'; import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; @@ -51,8 +50,8 @@ export type AssetDataFiltered = { ... }; -// Test extension against all types supported by image-size module. -// If it's not one of these, we won't treat it as an image. +// If an extension isn't explicitly supported here, we won't treat it as an +// image. export function isAssetTypeAnImage(type: string): boolean { return ( [ @@ -81,8 +80,7 @@ export function getAssetSize( if (content.length === 0) { throw new Error(`Image asset \`${filePath}\` cannot be an empty file.`); } - const {width, height} = getImageSize(content); - return {width, height}; + return getImageDimensions(type, content, filePath); } export type AssetData = AssetDataWithoutFiles & { @@ -180,7 +178,7 @@ async function getAbsoluteAssetRecord( async function getAbsoluteAssetInfo( assetPath: string, platform: ?string = null, -): Promise { +): Promise<{assetInfo: AssetInfo, firstFileContent: Buffer}> { const nameData = AssetPaths.parse( assetPath, new Set(platform != null ? [platform] : []), @@ -198,7 +196,10 @@ async function getAbsoluteAssetInfo( hasher.update(data); } - return {files, hash: hasher.digest('hex'), name, scales, type}; + return { + assetInfo: {files, hash: hasher.digest('hex'), name, scales, type}, + firstFileContent: fileData[0], + }; } export async function getAssetData( @@ -218,13 +219,13 @@ export async function getAssetData( // On Windows, change backslashes to slashes to get proper URL path from file path. assetUrlPath = normalizePathSeparatorsToPosix(assetUrlPath); - const isImage = isAssetTypeAnImage(path.extname(assetPath).slice(1)); - const assetInfo = await getAbsoluteAssetInfo(assetPath, platform ?? null); - - const isImageInput = assetInfo.files[0].includes('.zip/') - ? fs.readFileSync(assetInfo.files[0]) - : assetInfo.files[0]; - const dimensions = isImage ? getImageSize(isImageInput) : null; + const {assetInfo, firstFileContent} = await getAbsoluteAssetInfo( + assetPath, + platform ?? null, + ); + const dimensions = isAssetTypeAnImage(assetInfo.type) + ? getAssetSize(assetInfo.type, firstFileContent, assetInfo.files[0]) + : null; const scale = assetInfo.scales[0]; const assetData = { diff --git a/packages/metro/src/__tests__/Assets-test.js b/packages/metro/src/__tests__/Assets-test.js index 66f91d46c9..4b35afd058 100644 --- a/packages/metro/src/__tests__/Assets-test.js +++ b/packages/metro/src/__tests__/Assets-test.js @@ -11,11 +11,17 @@ 'use strict'; jest.mock('node:fs', () => new (require('metro-memory-fs'))()); -jest.mock('image-size'); +jest.mock('../lib/imageSize', () => ({ + getImageDimensions: jest.fn(() => ({ + width: mockImageWidth, + height: mockImageHeight, + })), +})); jest.useRealTimers(); -const {getAsset, getAssetData} = require('../Assets'); +const {getAsset, getAssetData, getAssetSize} = require('../Assets'); +const getImageDimensions = require('../lib/imageSize').getImageDimensions; const crypto = require('node:crypto'); const path = require('node:path'); @@ -24,9 +30,18 @@ const fs = jest.requireMock('node:fs'); const mockImageWidth = 300; const mockImageHeight = 200; -require('image-size').mockReturnValue({ - width: mockImageWidth, - height: mockImageHeight, +describe('getAssetSize', () => { + test('returns null for non-image assets', () => { + expect(getAssetSize('mp4', Buffer.from('video'), '/root/video.mp4')).toBe( + null, + ); + }); + + test('rejects empty image assets', () => { + expect(() => + getAssetSize('png', Buffer.alloc(0), '/root/empty.png'), + ).toThrow('Image asset `/root/empty.png` cannot be an empty file.'); + }); }); describe('getAsset', () => { @@ -284,6 +299,18 @@ describe('getAssetData', () => { }); }); + test('parses dimensions from the first asset file buffer', async () => { + writeImages({'b@1x.png': 'b1 image', 'b@2x.png': 'b2 image'}); + + await getAssetData('/root/imgs/b.png', 'imgs/b.png', [], null, '/assets'); + + expect(getImageDimensions).toHaveBeenCalledWith( + 'png', + Buffer.from('b1 image'), + '/root/imgs/b@1x.png', + ); + }); + test('should get assetData for non-png images', async () => { writeImages({ 'b@1x.jpg': 'b1 image', diff --git a/packages/metro/src/lib/__tests__/imageSize-test.js b/packages/metro/src/lib/__tests__/imageSize-test.js new file mode 100644 index 0000000000..a86f160ff9 --- /dev/null +++ b/packages/metro/src/lib/__tests__/imageSize-test.js @@ -0,0 +1,318 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +'use strict'; + +/* eslint-disable no-bitwise */ + +const {getImageDimensions} = require('../imageSize'); + +const WIDTH = 300; +const HEIGHT = 200; + +describe('getImageDimensions', () => { + test.each([ + ['bmp', createBmp()], + ['bmp', createCoreBmp()], + ['gif', createGif()], + ['jpg', createJpeg()], + ['jpeg', createJpeg()], + ['png', createPng()], + ['png', createCgbiPng()], + ['psd', createPsd()], + ['svg', createSvg()], + ['svg', createSvgWithUnits()], + ['svg', createSvgWithViewBox()], + ['tiff', createTiff('little', 3)], + ['tiff', createTiff('big', 4)], + ['webp', createExtendedWebp()], + ['webp', createLosslessWebp()], + ['webp', createLossyWebp()], + ['ktx', createKtx1('little')], + ['ktx', createKtx1('big')], + ['ktx', createKtx2()], + ])('parses a valid %s image', (type, content) => { + expect(getImageDimensions(type, content, `/root/image.${type}`)).toEqual({ + width: WIDTH, + height: HEIGHT, + }); + }); + + test('rejects content that does not match the declared type', () => { + expect(() => + getImageDimensions('png', createJpeg(), '/root/disguised.png'), + ).toThrow('Invalid png image asset: /root/disguised.png'); + }); + + test.each([ + ['bmp', Buffer.from('BM')], + ['gif', Buffer.from('GIF89a')], + ['jpg', Buffer.from([0xff, 0xd8, 0xff])], + ['png', createPng().subarray(0, 20)], + ['psd', Buffer.from('8BPS')], + ['svg', Buffer.from(' { + expect(() => + getImageDimensions(type, content, `/root/truncated.${type}`), + ).toThrow(`Invalid ${type} image asset: /root/truncated.${type}`); + }); + + test('rejects non-positive dimensions', () => { + const content = createPng(); + content.writeUInt32BE(0, 16); + + expect(() => + getImageDimensions('png', content, '/root/zero-width.png'), + ).toThrow('Invalid png image asset: /root/zero-width.png'); + }); + + test.each([ + ['JXL', createZeroLengthJxlBox()], + ['HEIF', createZeroLengthHeifBox()], + ['ICNS', createZeroLengthIcnsEntry()], + ['JPEG', createZeroLengthJpegSegment()], + ])('rejects a malformed %s payload without hanging', (_, content) => { + expect(() => + getImageDimensions('png', content, '/root/malicious.png'), + ).toThrow('Invalid png image asset: /root/malicious.png'); + }); + + test('bounds SVG header parsing', () => { + const content = Buffer.from( + ``, + ); + expect(() => + getImageDimensions('svg', content, '/root/oversized.svg'), + ).toThrow('Invalid svg image asset: /root/oversized.svg'); + }); +}); + +function createBmp() { + const content = Buffer.alloc(26); + content.write('BM', 0); + content.writeUInt32LE(40, 14); + content.writeInt32LE(WIDTH, 18); + content.writeInt32LE(-HEIGHT, 22); + return content; +} + +function createCoreBmp() { + const content = Buffer.alloc(26); + content.write('BM', 0); + content.writeUInt32LE(12, 14); + content.writeUInt16LE(WIDTH, 18); + content.writeUInt16LE(HEIGHT, 20); + return content; +} + +function createGif() { + const content = Buffer.alloc(10); + content.write('GIF89a', 0); + content.writeUInt16LE(WIDTH, 6); + content.writeUInt16LE(HEIGHT, 8); + return content; +} + +function createJpeg() { + const content = Buffer.alloc(27); + content.set([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x04, 0x00, 0x00]); + content.set([0xff, 0xc2, 0x00, 0x11, 0x08], 8); + content.writeUInt16BE(HEIGHT, 13); + content.writeUInt16BE(WIDTH, 15); + return content; +} + +function createPng() { + const content = Buffer.alloc(33); + content.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + content.writeUInt32BE(13, 8); + content.write('IHDR', 12); + content.writeUInt32BE(WIDTH, 16); + content.writeUInt32BE(HEIGHT, 20); + return content; +} + +function createCgbiPng() { + const content = Buffer.alloc(49); + content.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + content.writeUInt32BE(4, 8); + content.write('CgBI', 12); + content.writeUInt32BE(13, 24); + content.write('IHDR', 28); + content.writeUInt32BE(WIDTH, 32); + content.writeUInt32BE(HEIGHT, 36); + return content; +} + +function createPsd() { + const content = Buffer.alloc(22); + content.write('8BPS', 0); + content.writeUInt16BE(1, 4); + content.writeUInt32BE(HEIGHT, 14); + content.writeUInt32BE(WIDTH, 18); + return content; +} + +function createSvg() { + return Buffer.from(``); +} + +function createSvgWithUnits() { + return Buffer.from(''); +} + +function createSvgWithViewBox() { + return Buffer.from(''); +} + +function createTiff(endianness: 'big' | 'little', type: number): Buffer { + const content = Buffer.alloc(38); + const bigEndian = endianness === 'big'; + content.write(bigEndian ? 'MM' : 'II', 0); + writeTiffUInt16(content, 2, 42, bigEndian); + writeTiffUInt32(content, 4, 8, bigEndian); + writeTiffUInt16(content, 8, 2, bigEndian); + writeTiffEntry(content, 10, 256, WIDTH, type, bigEndian); + writeTiffEntry(content, 22, 257, HEIGHT, type, bigEndian); + return content; +} + +function writeTiffEntry( + content: Buffer, + offset: number, + tag: number, + value: number, + type: number, + bigEndian: boolean, +): void { + writeTiffUInt16(content, offset, tag, bigEndian); + writeTiffUInt16(content, offset + 2, type, bigEndian); + writeTiffUInt32(content, offset + 4, 1, bigEndian); + if (type === 3) { + writeTiffUInt16(content, offset + 8, value, bigEndian); + } else { + writeTiffUInt32(content, offset + 8, value, bigEndian); + } +} + +function writeTiffUInt16( + content: Buffer, + offset: number, + value: number, + bigEndian: boolean, +): void { + if (bigEndian) { + content.writeUInt16BE(value, offset); + } else { + content.writeUInt16LE(value, offset); + } +} + +function writeTiffUInt32( + content: Buffer, + offset: number, + value: number, + bigEndian: boolean, +): void { + if (bigEndian) { + content.writeUInt32BE(value, offset); + } else { + content.writeUInt32LE(value, offset); + } +} + +function createExtendedWebp() { + const content = createWebpChunk('VP8X', 10); + content.writeUIntLE(WIDTH - 1, 24, 3); + content.writeUIntLE(HEIGHT - 1, 27, 3); + return content; +} + +function createLosslessWebp() { + const content = createWebpChunk('VP8L', 5); + const width = WIDTH - 1; + const height = HEIGHT - 1; + content[20] = 0x2f; + content[21] = width & 0xff; + content[22] = ((width >> 8) & 0x3f) | ((height & 0x03) << 6); + content[23] = (height >> 2) & 0xff; + content[24] = (height >> 10) & 0x0f; + return content; +} + +function createLossyWebp() { + const content = createWebpChunk('VP8 ', 10); + content.set([0x9d, 0x01, 0x2a], 23); + content.writeUInt16LE(WIDTH, 26); + content.writeUInt16LE(HEIGHT, 28); + return content; +} + +function createWebpChunk(type: string, length: number): Buffer { + const content = Buffer.alloc(20 + length); + content.write('RIFF', 0); + content.writeUInt32LE(content.length - 8, 4); + content.write('WEBP', 8); + content.write(type, 12); + content.writeUInt32LE(length, 16); + return content; +} + +function createKtx1(endianness: 'big' | 'little'): Buffer { + const content = Buffer.alloc(64); + content.set([ + 0xab, 0x4b, 0x54, 0x58, 0x20, 0x31, 0x31, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a, + ]); + if (endianness === 'little') { + content.set([0x01, 0x02, 0x03, 0x04], 12); + content.writeUInt32LE(WIDTH, 36); + content.writeUInt32LE(HEIGHT, 40); + } else { + content.set([0x04, 0x03, 0x02, 0x01], 12); + content.writeUInt32BE(WIDTH, 36); + content.writeUInt32BE(HEIGHT, 40); + } + return content; +} + +function createKtx2() { + const content = Buffer.alloc(80); + content.set([ + 0xab, 0x4b, 0x54, 0x58, 0x20, 0x32, 0x30, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a, + ]); + content.writeUInt32LE(WIDTH, 20); + content.writeUInt32LE(HEIGHT, 24); + return content; +} + +function createZeroLengthJxlBox() { + return Buffer.from([0x00, 0x00, 0x00, 0x00, 0x4a, 0x58, 0x4c, 0x20]); +} + +function createZeroLengthHeifBox() { + return Buffer.from([ + 0x00, 0x00, 0x00, 0x00, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66, + ]); +} + +function createZeroLengthIcnsEntry() { + return Buffer.from([ + 0x69, 0x63, 0x6e, 0x73, 0x00, 0x00, 0x00, 0x10, 0x69, 0x73, 0x33, 0x32, + 0x00, 0x00, 0x00, 0x00, + ]); +} + +function createZeroLengthJpegSegment() { + return Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x00]); +} diff --git a/packages/metro/src/lib/imageSize.js b/packages/metro/src/lib/imageSize.js new file mode 100644 index 0000000000..e862725457 --- /dev/null +++ b/packages/metro/src/lib/imageSize.js @@ -0,0 +1,578 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +/* eslint-disable no-bitwise */ + +/** + * Image dimension parsing is derived from image-size's format support, reduced + * to the formats Metro treats as images. See the third-party notice below. + */ + +export type Dimensions = { + readonly width: number, + readonly height: number, +}; + +type ImageParser = (content: Buffer) => ?Dimensions; + +const MAX_SVG_HEADER_LENGTH = 64 * 1024; + +const KTX1_IDENTIFIER = Buffer.from([ + 0xab, 0x4b, 0x54, 0x58, 0x20, 0x31, 0x31, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a, +]); +const KTX2_IDENTIFIER = Buffer.from([ + 0xab, 0x4b, 0x54, 0x58, 0x20, 0x32, 0x30, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a, +]); +const PNG_IDENTIFIER = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, +]); + +const parsers: {[string]: ImageParser} = { + bmp: parseBmp, + gif: parseGif, + jpeg: parseJpeg, + jpg: parseJpeg, + ktx: parseKtx, + png: parsePng, + psd: parsePsd, + svg: parseSvg, + tiff: parseTiff, + webp: parseWebp, +}; + +export function getImageDimensions( + type: string, + content: Buffer, + filePath: string, +): Dimensions { + const parser = parsers[type]; + let dimensions; + + try { + dimensions = parser?.(content); + } catch { + throw createInvalidImageError(type, filePath); + } + + if ( + dimensions == null || + !Number.isFinite(dimensions.width) || + !Number.isFinite(dimensions.height) || + dimensions.width <= 0 || + dimensions.height <= 0 + ) { + throw createInvalidImageError(type, filePath); + } + + return dimensions; +} + +function parseBmp(content: Buffer): ?Dimensions { + if (!hasBytes(content, 0, 26) || readAscii(content, 0, 2) !== 'BM') { + return null; + } + + const dibHeaderSize = content.readUInt32LE(14); + if (dibHeaderSize === 12) { + return { + width: content.readUInt16LE(18), + height: content.readUInt16LE(20), + }; + } + if (dibHeaderSize < 40) { + return null; + } + + return { + width: Math.abs(content.readInt32LE(18)), + height: Math.abs(content.readInt32LE(22)), + }; +} + +function parseGif(content: Buffer): ?Dimensions { + if ( + !hasBytes(content, 0, 10) || + !/^GIF8[79]a$/.test(readAscii(content, 0, 6)) + ) { + return null; + } + return { + width: content.readUInt16LE(6), + height: content.readUInt16LE(8), + }; +} + +function parseJpeg(content: Buffer): ?Dimensions { + if (!hasBytes(content, 0, 4) || content[0] !== 0xff || content[1] !== 0xd8) { + return null; + } + + let offset = 2; + while (offset < content.length) { + while (offset < content.length && content[offset] === 0xff) { + offset++; + } + if (offset >= content.length) { + return null; + } + + const marker = content[offset++]; + if (marker === 0x00) { + return null; + } + if (isStandaloneJpegMarker(marker)) { + if (marker === 0xd9) { + return null; + } + continue; + } + if (!hasBytes(content, offset, 2)) { + return null; + } + + const segmentLength = content.readUInt16BE(offset); + if (segmentLength < 2) { + return null; + } + const segmentEnd = offset + segmentLength; + if (segmentEnd <= offset || segmentEnd > content.length) { + return null; + } + + if (isJpegStartOfFrame(marker)) { + if (segmentLength < 7) { + return null; + } + return { + height: content.readUInt16BE(offset + 3), + width: content.readUInt16BE(offset + 5), + }; + } + if (marker === 0xda) { + return null; + } + offset = segmentEnd; + } + return null; +} + +function isStandaloneJpegMarker(marker: number): boolean { + return marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9); +} + +function isJpegStartOfFrame(marker: number): boolean { + return ( + marker >= 0xc0 && + marker <= 0xcf && + marker !== 0xc4 && + marker !== 0xc8 && + marker !== 0xcc + ); +} + +function parseKtx(content: Buffer): ?Dimensions { + if (hasBytes(content, 0, 64) && startsWith(content, KTX1_IDENTIFIER)) { + const endianness = content.subarray(12, 16); + if (endianness.equals(Buffer.from([0x01, 0x02, 0x03, 0x04]))) { + return { + width: content.readUInt32LE(36), + height: content.readUInt32LE(40), + }; + } + if (endianness.equals(Buffer.from([0x04, 0x03, 0x02, 0x01]))) { + return { + width: content.readUInt32BE(36), + height: content.readUInt32BE(40), + }; + } + return null; + } + + if (hasBytes(content, 0, 80) && startsWith(content, KTX2_IDENTIFIER)) { + return { + width: content.readUInt32LE(20), + height: content.readUInt32LE(24), + }; + } + return null; +} + +function parsePng(content: Buffer): ?Dimensions { + if (!hasBytes(content, 0, 24) || !startsWith(content, PNG_IDENTIFIER)) { + return null; + } + + const firstChunkLength = content.readUInt32BE(8); + const firstChunkType = readAscii(content, 12, 16); + if (firstChunkType === 'IHDR') { + if (firstChunkLength !== 13) { + return null; + } + return { + width: content.readUInt32BE(16), + height: content.readUInt32BE(20), + }; + } + + // Apple's PNG encoder may place a CgBI chunk before IHDR. + if (firstChunkType !== 'CgBI') { + return null; + } + const ihdrOffset = 8 + 12 + firstChunkLength; + if ( + !hasBytes(content, ihdrOffset, 24) || + content.readUInt32BE(ihdrOffset) !== 13 || + readAscii(content, ihdrOffset + 4, ihdrOffset + 8) !== 'IHDR' + ) { + return null; + } + return { + width: content.readUInt32BE(ihdrOffset + 8), + height: content.readUInt32BE(ihdrOffset + 12), + }; +} + +function parsePsd(content: Buffer): ?Dimensions { + if ( + !hasBytes(content, 0, 22) || + readAscii(content, 0, 4) !== '8BPS' || + (content.readUInt16BE(4) !== 1 && content.readUInt16BE(4) !== 2) + ) { + return null; + } + return { + height: content.readUInt32BE(14), + width: content.readUInt32BE(18), + }; +} + +const SVG_UNIT_FACTORS: {[string]: number} = { + in: 96, + cm: 96 / 2.54, + em: 16, + ex: 8, + m: (96 / 2.54) * 100, + mm: 96 / 2.54 / 10, + pc: 96 / 72 / 12, + pt: 96 / 72, + px: 1, +}; + +function parseSvg(content: Buffer): ?Dimensions { + const header = content + .subarray(0, Math.min(content.length, MAX_SVG_HEADER_LENGTH)) + .toString('utf8'); + const rootStartMatch = /)/.exec(header); + if (rootStartMatch == null || rootStartMatch.index == null) { + return null; + } + + const rootStart = rootStartMatch.index; + const rootEnd = findTagEnd(header, rootStart); + if (rootEnd == null) { + return null; + } + const root = header.slice(rootStart, rootEnd + 1); + const attributes: {[string]: string} = {}; + const attributePattern = + /\b(width|height|viewBox)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi; + let match = attributePattern.exec(root); + while (match != null) { + const name = match[1]; + const value = match[2] ?? match[3]; + if (name != null && value != null) { + attributes[name.toLowerCase()] = value; + } + match = attributePattern.exec(root); + } + + const width = parseSvgLength(attributes.width); + const height = parseSvgLength(attributes.height); + if (width != null && height != null) { + return {width, height}; + } + + const viewBox = parseSvgViewBox(attributes.viewbox); + if (viewBox == null) { + return null; + } + if (width != null) { + return { + width, + height: Math.floor(width / (viewBox.width / viewBox.height)), + }; + } + if (height != null) { + return { + width: Math.floor(height * (viewBox.width / viewBox.height)), + height, + }; + } + return viewBox; +} + +function findTagEnd(input: string, start: number): ?number { + let quote = null; + for (let index = start; index < input.length; index++) { + const character = input[index]; + if (quote != null) { + if (character === quote) { + quote = null; + } + } else if (character === '"' || character === "'") { + quote = character; + } else if (character === '>') { + return index; + } + } + return null; +} + +function parseSvgLength(value: ?string): ?number { + if (value == null || value.endsWith('%')) { + return null; + } + const match = /^([+]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)([a-z]*)$/i.exec( + value.trim(), + ); + if (match == null) { + return null; + } + const unit = match[2].toLowerCase(); + const factor = unit === '' ? 1 : SVG_UNIT_FACTORS[unit]; + if (factor == null) { + return null; + } + return Math.round(Number(match[1]) * factor); +} + +function parseSvgViewBox(value: ?string): ?Dimensions { + if (value == null) { + return null; + } + const values = value + .trim() + .split(/[\s,]+/) + .map(Number); + if ( + values.length !== 4 || + !values.every(value => Number.isFinite(value)) || + values[2] <= 0 || + values[3] <= 0 + ) { + return null; + } + return {width: values[2], height: values[3]}; +} + +function parseTiff(content: Buffer): ?Dimensions { + if (!hasBytes(content, 0, 8)) { + return null; + } + const byteOrder = readAscii(content, 0, 2); + const isBigEndian = byteOrder === 'MM'; + if ( + (!isBigEndian && byteOrder !== 'II') || + readTiffUInt16(content, 2, isBigEndian) !== 42 + ) { + return null; + } + + const ifdOffset = readTiffUInt32(content, 4, isBigEndian); + if (!hasBytes(content, ifdOffset, 2)) { + return null; + } + const entryCount = readTiffUInt16(content, ifdOffset, isBigEndian); + const entriesOffset = ifdOffset + 2; + if (entryCount > Math.floor((content.length - entriesOffset) / 12)) { + return null; + } + + let width; + let height; + for (let index = 0; index < entryCount; index++) { + const entryOffset = entriesOffset + index * 12; + const tag = readTiffUInt16(content, entryOffset, isBigEndian); + if (tag !== 256 && tag !== 257) { + continue; + } + const value = readTiffDimension(content, entryOffset, isBigEndian); + if (value == null) { + return null; + } + if (tag === 256) { + width = value; + } else { + height = value; + } + } + return width != null && height != null ? {width, height} : null; +} + +function readTiffDimension( + content: Buffer, + entryOffset: number, + isBigEndian: boolean, +): ?number { + const type = readTiffUInt16(content, entryOffset + 2, isBigEndian); + const count = readTiffUInt32(content, entryOffset + 4, isBigEndian); + if (count !== 1) { + return null; + } + if (type === 3) { + return readTiffUInt16(content, entryOffset + 8, isBigEndian); + } + if (type === 4) { + return readTiffUInt32(content, entryOffset + 8, isBigEndian); + } + return null; +} + +function readTiffUInt16( + content: Buffer, + offset: number, + isBigEndian: boolean, +): number { + return isBigEndian + ? content.readUInt16BE(offset) + : content.readUInt16LE(offset); +} + +function readTiffUInt32( + content: Buffer, + offset: number, + isBigEndian: boolean, +): number { + return isBigEndian + ? content.readUInt32BE(offset) + : content.readUInt32LE(offset); +} + +function parseWebp(content: Buffer): ?Dimensions { + if ( + !hasBytes(content, 0, 20) || + readAscii(content, 0, 4) !== 'RIFF' || + readAscii(content, 8, 12) !== 'WEBP' + ) { + return null; + } + + let offset = 12; + while (hasBytes(content, offset, 8)) { + const chunkType = readAscii(content, offset, offset + 4); + const chunkLength = content.readUInt32LE(offset + 4); + const dataOffset = offset + 8; + if (!hasBytes(content, dataOffset, chunkLength)) { + return null; + } + + if (chunkType === 'VP8X' && chunkLength >= 10) { + return { + width: 1 + readUInt24LE(content, dataOffset + 4), + height: 1 + readUInt24LE(content, dataOffset + 7), + }; + } + if ( + chunkType === 'VP8L' && + chunkLength >= 5 && + content[dataOffset] === 0x2f + ) { + return { + width: + 1 + + (((content[dataOffset + 2] & 0x3f) << 8) | content[dataOffset + 1]), + height: + 1 + + (((content[dataOffset + 4] & 0x0f) << 10) | + (content[dataOffset + 3] << 2) | + ((content[dataOffset + 2] & 0xc0) >> 6)), + }; + } + if ( + chunkType === 'VP8 ' && + chunkLength >= 10 && + content[dataOffset + 3] === 0x9d && + content[dataOffset + 4] === 0x01 && + content[dataOffset + 5] === 0x2a + ) { + return { + width: content.readUInt16LE(dataOffset + 6) & 0x3fff, + height: content.readUInt16LE(dataOffset + 8) & 0x3fff, + }; + } + + const nextOffset = dataOffset + chunkLength + (chunkLength % 2); + if (nextOffset <= offset) { + return null; + } + offset = nextOffset; + } + return null; +} + +function readUInt24LE(content: Buffer, offset: number): number { + return ( + content[offset] + content[offset + 1] * 256 + content[offset + 2] * 65536 + ); +} + +function hasBytes(content: Buffer, offset: number, length: number): boolean { + return ( + Number.isSafeInteger(offset) && + Number.isSafeInteger(length) && + offset >= 0 && + length >= 0 && + offset <= content.length && + length <= content.length - offset + ); +} + +function startsWith(content: Buffer, prefix: Buffer): boolean { + return ( + hasBytes(content, 0, prefix.length) && + content.subarray(0, prefix.length).equals(prefix) + ); +} + +function readAscii(content: Buffer, start: number, end: number): string { + return content.toString('ascii', start, end); +} + +function createInvalidImageError(type: string, filePath: string): Error { + return new Error(`Invalid ${type} image asset: ${filePath}`); +} + +/* + * Portions derived from image-size: + * https://codeberg.org/image-size/image-size + * + * The MIT License (MIT) + * + * Copyright © 2013-Present Aditya Yadav, http://netroy.in + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the “Software”), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ diff --git a/yarn.lock b/yarn.lock index d26551a116..2073bba884 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3497,13 +3497,6 @@ ignore@^7.0.5: resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.5.tgz#4cb5f6cd7d4c7ab0365738c7aea888baa6d7efd9" integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg== -image-size@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/image-size/-/image-size-1.0.2.tgz#d778b6d0ab75b2737c1556dd631652eb963bc486" - integrity sha512-xfOoWjceHntRb3qFCrh5ZFORYH8XCdYpASltMhZ/Q0KZiOwjdE/Yl2QCiWdwD+lygV5bMCvauzgu5PxBX/Yerg== - dependencies: - queue "6.0.2" - import-fresh@^3.2.1: version "3.3.0" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" @@ -3538,7 +3531,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@~2.0.3: +inherits@2: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -5046,13 +5039,6 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== -queue@6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/queue/-/queue-6.0.2.tgz#b91525283e2315c7553d2efa18d83e76432fed65" - integrity sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA== - dependencies: - inherits "~2.0.3" - react-is@^16.13.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"