import { BadRequestException } from '@nestjs/common'; import ExcelJS from 'exceljs'; import JSZip from 'jszip'; import { randomUUID } from 'node:crypto'; import type { EmbeddedImage, EmbeddedImageMetadata, ReportWorkbookFormat } from './report-materials.contracts'; import { normalizeImageExtension, readEmbeddedImages } from './report-materials.helpers'; const MAX_WORKBOOK_BYTES = 100 * 1024 * 1024; const MAX_EXPANDED_BYTES = 500 * 1024 * 1024; const MAX_IMAGE_BYTES = 20 * 1024 * 1024; const MAX_TOTAL_IMAGE_BYTES = 300 * 1024 * 1024; const DISPIMG_FORMULA = /^_xlfn\.DISPIMG\(["'](ID_[A-F0-9]{32})["'],1\)$/i; function decodeXml(value: string) { return value .replace(/"/g, '"') .replace(/'/g, "'") .replace(/</g, '<') .replace(/>/g, '>') .replace(/&/g, '&'); } function attributes(source: string) { return Object.fromEntries( [...source.matchAll(/([\w:-]+)="([^"]*)"/g)].map((match) => [match[1], decodeXml(match[2])]), ); } function packagePath(target: string) { return `xl/${target.replace(/^\/?xl\//, '').replace(/^\//, '')}`.replace(/\\/g, '/'); } function coordinates(address: string) { const match = /^([A-Z]+)(\d+)$/.exec(address.toUpperCase()); if (!match) return null; let column = 0; for (const character of match[1]) column = column * 26 + character.charCodeAt(0) - 64; return { row: Number(match[2]), column }; } async function text(zip: JSZip, path: string) { return zip.file(path)?.async('string') ?? ''; } function validImageSignature(extension: string, buffer: Buffer) { if (extension === 'png') return buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); if (extension === 'jpeg') return buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff; if (extension === 'gif') return buffer.subarray(0, 3).toString('ascii') === 'GIF'; return false; } function validateWorkbookValues(workbook: ExcelJS.Workbook, allowedWpsIds: Set) { for (const worksheet of workbook.worksheets) { worksheet.eachRow((row) => row.eachCell((cell) => { const value = cell.value; if (value && typeof value === 'object' && ('formula' in value || 'sharedFormula' in value)) { const formula = typeof (value as { formula?: unknown }).formula === 'string' ? (value as { formula: string }).formula.trim() : ''; const match = DISPIMG_FORMULA.exec(formula); if (!match || !allowedWpsIds.has(match[1])) throw new BadRequestException(`工作表 ${worksheet.name} 包含不允许的公式`); } const valueText = typeof value === 'string' ? value.trimStart() : ''; if (/^[=+@]/.test(valueText) || /^-[^\d.]/.test(valueText)) throw new BadRequestException(`工作表 ${worksheet.name} 包含公式或可执行单元格`); }), ); } } async function inspectWpsImages(zip: JSZip, includeImageData: boolean) { const cellImagesXml = await text(zip, 'xl/cellimages.xml'); if (!cellImagesXml) return { allowedIds: new Set(), imagesBySheet: new Map>(), mediaTargets: new Set(), }; const relXml = await text(zip, 'xl/_rels/cellimages.xml.rels'); const relTargets = new Map(); const seenRelations = new Set(); for (const match of relXml.matchAll(/]*)\/?>(?:<\/Relationship>)?/g)) { const attrs = attributes(match[1]); if (seenRelations.has(attrs.Id)) { relTargets.delete(attrs.Id); continue; } seenRelations.add(attrs.Id); if (attrs.Id && attrs.Target && attrs.TargetMode !== 'External' && /\/image$/.test(attrs.Type ?? '')) relTargets.set(attrs.Id, packagePath(attrs.Target)); } const imageTargets = new Map(); const seenImageIds = new Set(); const duplicateImageIds = new Set(); // Bound every lookup to one node: a damaged node must never consume its neighbour's blip. for (const match of cellImagesXml.matchAll(/]*?(?:\/>|>([\s\S]*?)<\/etc:cellImage>)/g)) { const node = match[1] ?? ''; const id = attributes(node.match(/]*)>/)?.[1] ?? '').name; if (!id) continue; if (seenImageIds.has(id)) { duplicateImageIds.add(id); imageTargets.delete(id); continue; } seenImageIds.add(id); const target = relTargets.get(attributes(node.match(/]*)>/)?.[1] ?? '')['r:embed']); if (id && target) imageTargets.set(id, target); } const workbookXml = await text(zip, 'xl/workbook.xml'); const workbookRelsXml = await text(zip, 'xl/_rels/workbook.xml.rels'); const sheetRelTargets = new Map(); for (const match of workbookRelsXml.matchAll(/]*)\/?>(?:<\/Relationship>)?/g)) { const attrs = attributes(match[1]); if (attrs.Id && attrs.Target && /\/worksheet$/.test(attrs.Type ?? '')) sheetRelTargets.set(attrs.Id, packagePath(attrs.Target)); } const allowedIds = new Set(); const imagesBySheet = new Map>(); let totalImageBytes = 0; for (const match of workbookXml.matchAll(/]*)\/?>(?:<\/sheet>)?/g)) { const attrs = attributes(match[1]); const path = sheetRelTargets.get(attrs['r:id']); if (!attrs.name || !path) continue; const sheetXml = await text(zip, path); const images: Array = []; for (const cell of sheetXml.matchAll(/]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)) { const formulaText = decodeXml((cell[2] ?? '').match(/]*)?>([\s\S]*?)<\/f>/)?.[1]?.trim() ?? ''); if (!formulaText) continue; const formula = DISPIMG_FORMULA.exec(formulaText); if (!formula) throw new BadRequestException(`工作簿包含不允许的公式:${formulaText.slice(0, 80)}`); const target = imageTargets.get(formula[1]); const address = attributes(cell[1]).r ?? ''; const cellPosition = coordinates(address); const location = `工作表【${attrs.name}】${cellPosition ? address : '未知单元格'}`; if (duplicateImageIds.has(formula[1])) throw new BadRequestException(`${location}的WPS图片ID重复,无法确定对应图片,请重新插入图片`); if (!cellPosition) throw new BadRequestException(`${location}的WPS图片单元格地址无效`); if (!target) throw new BadRequestException(`${location}的WPS图片引用缺失或不唯一,请重新插入图片或清空该单元格`); const imageEntry = zip.file(target); if (!imageEntry) throw new BadRequestException(`${location}的WPS图片文件缺失,请重新插入图片或清空该单元格`); const extension = normalizeImageExtension(target.split('.').pop() ?? 'png'); if (!['png', 'jpeg', 'gif'].includes(extension)) throw new BadRequestException('WPS单元格图片格式不受支持'); const size = Number( (imageEntry as unknown as { _data?: { uncompressedSize?: number } })._data?.uncompressedSize ?? 0, ); if (size > MAX_IMAGE_BYTES) throw new BadRequestException('单张WPS图片不能超过20MB'); totalImageBytes += size; if (includeImageData) { const imageBuffer = await imageEntry.async('nodebuffer'); if (!validImageSignature(extension, imageBuffer)) throw new BadRequestException('WPS单元格图片内容与格式不匹配'); images.push({ ...cellPosition, extension, buffer: imageBuffer }); } else { images.push({ ...cellPosition, extension, size, target }); } allowedIds.add(formula[1]); } imagesBySheet.set(attrs.name, images); } if (totalImageBytes > MAX_TOTAL_IMAGE_BYTES) throw new BadRequestException('WPS图片总量不能超过300MB'); return { allowedIds, imagesBySheet, mediaTargets: new Set(imageTargets.values()) }; } export async function loadCompatibleWorkbook( buffer: Buffer, options: { includeImageData?: boolean; onProgress?: (progress: number, stage: string) => void | Promise } = {}, ) { if (buffer.length > MAX_WORKBOOK_BYTES) throw new BadRequestException('导入文件不能超过100MB'); let zip: JSZip; try { zip = await JSZip.loadAsync(buffer); } catch { throw new BadRequestException('仅支持有效的 XLSX 文件'); } await options.onProgress?.(35, '已读取工作簿压缩包'); const expandedBytes = Object.values(zip.files).reduce( (sum, entry) => sum + Number((entry as unknown as { _data?: { uncompressedSize?: number } })._data?.uncompressedSize ?? 0), 0, ); if (Object.keys(zip.files).length > 3000) throw new BadRequestException('工作簿ZIP条目数量超过安全限制'); if (expandedBytes > MAX_EXPANDED_BYTES) throw new BadRequestException('工作簿解压后超过500MB安全限制'); if (Object.keys(zip.files).some((name) => /(^|\/)(vbaProject|externalLinks|embeddings|activeX)(\/|\.)/i.test(name))) throw new BadRequestException('工作簿包含不允许的外部对象或宏'); const wps = await inspectWpsImages(zip, options.includeImageData !== false); await options.onProgress?.(55, '已定位WPS单元格图片'); let excelJsInput = buffer; if (options.includeImageData === false && wps.mediaTargets.size) { for (const target of wps.mediaTargets) zip.remove(target); zip.remove('xl/cellimages.xml'); zip.remove('xl/_rels/cellimages.xml.rels'); excelJsInput = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE', compressionOptions: { level: 1 }, }); await options.onProgress?.(70, '已剥离解析阶段无需加载的WPS图片数据'); } const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(excelJsInput as never); await options.onProgress?.(80, '已解析工作表内容'); validateWorkbookValues(workbook, wps.allowedIds); await options.onProgress?.(90, '已完成工作簿安全校验'); return { workbook, wpsImagesBySheet: wps.imagesBySheet }; } export function compatibleImages( workbook: ExcelJS.Workbook, worksheet: ExcelJS.Worksheet, wpsImagesBySheet: Map>, ) { const merged = new Map(); const wpsImages = wpsImagesBySheet.get(worksheet.name) ?? []; for (const image of readEmbeddedImages(workbook, worksheet)) { if (wpsImages.length && image.buffer.length <= 128) continue; const extension = normalizeImageExtension(image.extension); if (image.buffer.length > MAX_IMAGE_BYTES) throw new BadRequestException('单张工作簿图片不能超过20MB'); if (!validImageSignature(extension, image.buffer)) throw new BadRequestException('工作簿图片内容与格式不匹配'); merged.set(`${image.row}:${image.column}`, image); } for (const image of wpsImages) { if (!('buffer' in image)) throw new BadRequestException('图片元数据不能用于提交导入'); merged.set(`${image.row}:${image.column}`, image); } return [...merged.values()]; } export function compatibleImagePositions( workbook: ExcelJS.Workbook, worksheet: ExcelJS.Worksheet, wpsImagesBySheet: Map>, ) { const wpsImages = wpsImagesBySheet.get(worksheet.name) ?? []; const positions = new Map(); for (const image of readEmbeddedImages(workbook, worksheet)) { if (wpsImages.length && image.buffer.length <= 128) continue; positions.set(`${image.row}:${image.column}`, { row: image.row, column: image.column }); } for (const image of wpsImages) positions.set(`${image.row}:${image.column}`, { row: image.row, column: image.column }); return [...positions.values()]; } export async function convertWorkbookOutput(content: Buffer, outputFormat: ReportWorkbookFormat = 'excel_drawing') { if (outputFormat === 'excel_drawing') return content; if (outputFormat !== 'wps_cell_image') throw new BadRequestException('不支持的报备文件格式'); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(content as never); const pictures: Array<{ sheetName: string; address: string; extension: string; buffer: Buffer; id: string; relId: string; mediaPath: string; }> = []; for (const sheet of workbook.worksheets) { for (const image of readEmbeddedImages(workbook, sheet)) { const extension = normalizeImageExtension(image.extension); pictures.push({ sheetName: sheet.name, address: `${sheet.getColumn(image.column).letter}${image.row}`, extension, buffer: image.buffer, id: `ID_${randomUUID().replace(/-/g, '').toUpperCase()}`, relId: `rId${pictures.length + 1}`, mediaPath: `xl/media/wps-cell-image-${pictures.length + 1}.${extension === 'jpeg' ? 'jpg' : extension}`, }); } } if (!pictures.length) return content; const zip = await JSZip.loadAsync(content); Object.keys(zip.files) .filter((name) => /^xl\/media\//.test(name)) .forEach((name) => zip.remove(name)); const workbookXml = await text(zip, 'xl/workbook.xml'); let workbookRels = await text(zip, 'xl/_rels/workbook.xml.rels'); const relTargets = new Map(); for (const match of workbookRels.matchAll(/]*)\/?>(?:<\/Relationship>)?/g)) { const attrs = attributes(match[1]); if (attrs.Id && attrs.Target && /\/worksheet$/.test(attrs.Type ?? '')) relTargets.set(attrs.Id, packagePath(attrs.Target)); } const sheetTargets = new Map(); for (const match of workbookXml.matchAll(/]*)\/?>(?:<\/sheet>)?/g)) { const attrs = attributes(match[1]); const target = relTargets.get(attrs['r:id']); if (attrs.name && target) sheetTargets.set(attrs.name, target); } for (const [sheetName, path] of sheetTargets) { const sheetPictures = pictures.filter((picture) => picture.sheetName === sheetName); if (!sheetPictures.length) continue; let sheetXml = await text(zip, path); sheetXml = sheetXml.replace(/]*\/?>(?:<\/drawing>)?/g, ''); for (const picture of sheetPictures) { const fullCell = new RegExp(`]*\\br="${picture.address}"[^>]*)>[\\s\\S]*?<\\/c>`); const emptyCell = new RegExp(`]*\\br="${picture.address}"[^>]*)\\/>`); const replaceCell = (opening: string) => `_xlfn.DISPIMG("${picture.id}",1)=DISPIMG("${picture.id}",1)`; if (fullCell.test(sheetXml)) sheetXml = sheetXml.replace(fullCell, (_match, opening: string) => replaceCell(opening)); else if (emptyCell.test(sheetXml)) sheetXml = sheetXml.replace(emptyCell, (_match, opening: string) => replaceCell(opening)); else throw new BadRequestException(`无法生成WPS单元格图片:${sheetName}!${picture.address}`); zip.file(picture.mediaPath, picture.buffer); } zip.file(path, sheetXml); const sheetRelsPath = path.replace(/\/([^/]+)$/, '/_rels/$1.rels'); const sheetRels = await text(zip, sheetRelsPath); if (sheetRels) zip.file( sheetRelsPath, sheetRels.replace(/]*Type="[^"]*\/drawing"[^>]*\/?>(?:<\/Relationship>)?/g, ''), ); } Object.keys(zip.files) .filter((name) => /^xl\/drawings\//.test(name)) .forEach((name) => zip.remove(name)); const cellImages = pictures .map( (picture, index) => ``, ) .join(''); zip.file( 'xl/cellimages.xml', `${cellImages}`, ); zip.file( 'xl/_rels/cellimages.xml.rels', `${pictures.map((picture) => ``).join('')}`, ); const nextRel = Math.max(0, ...[...workbookRels.matchAll(/Id="rId(\d+)"/g)].map((match) => Number(match[1]))) + 1; workbookRels = workbookRels.replace( '', ``, ); zip.file('xl/_rels/workbook.xml.rels', workbookRels); let contentTypes = await text(zip, '[Content_Types].xml'); contentTypes = contentTypes.replace( /]*PartName="\/xl\/drawings\/[^"]+"[^>]*\/?>(?:<\/Override>)?/g, '', ); if (!contentTypes.includes('/xl/cellimages.xml')) contentTypes = contentTypes.replace( '', '', ); zip.file('[Content_Types].xml', contentTypes); return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE', compressionOptions: { level: 6 } }); }