gongxiangp2/lib/performance-survey-image-source-guard.ts
2026-07-17 10:59:55 +08:00

133 lines
5.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

export const PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS = {
maxWidth: 12_000,
// 视觉评分的源图通常是 1600px 左右的长图。这里允许 1600×75,000 这类长图,
// 但仍在浏览器解码前挡住会显著拖垮员工设备的超大像素文件。
maxPixels: 120_000_000,
maxOutputWidth: 1_600,
sliceHeight: 1_500,
maxSlicesPerCase: 60,
headerReadBytes: 256 * 1024,
} as const
export type PerformanceSurveySourceImageDimensions = {
width: number
height: number
}
export class PerformanceSurveySourceImageError extends Error {}
function readUInt16BE(bytes: Uint8Array, offset: number) {
return (bytes[offset] << 8) | bytes[offset + 1]
}
function readUInt16LE(bytes: Uint8Array, offset: number) {
return bytes[offset] | (bytes[offset + 1] << 8)
}
function readUInt24LE(bytes: Uint8Array, offset: number) {
return bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16)
}
function startsWithAscii(bytes: Uint8Array, text: string, offset = 0) {
if (bytes.length < offset + text.length) return false
return Array.from(text).every((character, index) => bytes[offset + index] === character.charCodeAt(0))
}
function readPngDimensions(bytes: Uint8Array): PerformanceSurveySourceImageDimensions | null {
const pngSignature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
if (bytes.length < 24 || !pngSignature.every((value, index) => bytes[index] === value)) return null
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
return { width: view.getUint32(16), height: view.getUint32(20) }
}
function readGifDimensions(bytes: Uint8Array): PerformanceSurveySourceImageDimensions | null {
if (bytes.length < 10 || (!startsWithAscii(bytes, 'GIF87a') && !startsWithAscii(bytes, 'GIF89a'))) return null
return { width: readUInt16LE(bytes, 6), height: readUInt16LE(bytes, 8) }
}
function readWebpDimensions(bytes: Uint8Array): PerformanceSurveySourceImageDimensions | null {
if (bytes.length < 30 || !startsWithAscii(bytes, 'RIFF') || !startsWithAscii(bytes, 'WEBP', 8)) return null
const chunkType = String.fromCharCode(bytes[12], bytes[13], bytes[14], bytes[15])
if (chunkType === 'VP8X') {
return { width: readUInt24LE(bytes, 24) + 1, height: readUInt24LE(bytes, 27) + 1 }
}
if (chunkType === 'VP8L' && bytes.length >= 25 && bytes[20] === 0x2f) {
const b1 = bytes[21]
const b2 = bytes[22]
const b3 = bytes[23]
const b4 = bytes[24]
return {
width: 1 + (b1 | ((b2 & 0x3f) << 8)),
height: 1 + ((b2 >> 6) | (b3 << 2) | ((b4 & 0x0f) << 10)),
}
}
if (chunkType === 'VP8 ' && bytes.length >= 30 && bytes[23] === 0x9d && bytes[24] === 0x01 && bytes[25] === 0x2a) {
return { width: readUInt16LE(bytes, 26) & 0x3fff, height: readUInt16LE(bytes, 28) & 0x3fff }
}
return null
}
function readJpegDimensions(bytes: Uint8Array): PerformanceSurveySourceImageDimensions | null {
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null
let offset = 2
while (offset + 9 < bytes.length) {
if (bytes[offset] !== 0xff) {
offset += 1
continue
}
while (bytes[offset] === 0xff) offset += 1
const marker = bytes[offset]
offset += 1
if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01) continue
if (offset + 1 >= bytes.length) return null
const segmentLength = readUInt16BE(bytes, offset)
if (segmentLength < 2 || offset + segmentLength > bytes.length) return null
const isStartOfFrame = marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)
if (isStartOfFrame) {
return {
height: readUInt16BE(bytes, offset + 3),
width: readUInt16BE(bytes, offset + 5),
}
}
offset += segmentLength
}
return null
}
export function readPerformanceSurveySourceImageDimensions(bytes: Uint8Array) {
return readPngDimensions(bytes)
|| readJpegDimensions(bytes)
|| readGifDimensions(bytes)
|| readWebpDimensions(bytes)
}
export function assertPerformanceSurveyBrowserSourceImageDimensions(
dimensions: PerformanceSurveySourceImageDimensions | null,
sourceName: string,
) {
if (!dimensions || !Number.isInteger(dimensions.width) || !Number.isInteger(dimensions.height)
|| dimensions.width <= 0 || dimensions.height <= 0) {
throw new PerformanceSurveySourceImageError(
`图片「${sourceName}」无法在解码前读取尺寸,请使用 PNG、JPG、GIF 或 WebP 格式`,
)
}
if (dimensions.width > PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxWidth) {
throw new PerformanceSurveySourceImageError(
`图片「${sourceName}」宽度超过 ${PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxWidth}px`,
)
}
if (dimensions.width * dimensions.height > PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxPixels) {
throw new PerformanceSurveySourceImageError(
`图片「${sourceName}」像素总量超过 ${PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxPixels.toLocaleString()},请先压缩或切图`,
)
}
const outputScale = Math.min(1, PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxOutputWidth / dimensions.width)
const outputHeight = Math.max(1, Math.round(dimensions.height * outputScale))
const sliceCount = Math.ceil(outputHeight / PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.sliceHeight)
if (sliceCount > PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxSlicesPerCase) {
throw new PerformanceSurveySourceImageError(
`图片「${sourceName}」处理后需要 ${sliceCount} 张切片,单个案例最多支持 ${PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxSlicesPerCase} 张`,
)
}
}