357 lines
18 KiB
TypeScript
357 lines
18 KiB
TypeScript
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<string>) {
|
|
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<string>(),
|
|
imagesBySheet: new Map<string, Array<EmbeddedImage | EmbeddedImageMetadata>>(),
|
|
mediaTargets: new Set<string>(),
|
|
};
|
|
const relXml = await text(zip, 'xl/_rels/cellimages.xml.rels');
|
|
const relTargets = new Map<string, string>();
|
|
const seenRelations = new Set<string>();
|
|
for (const match of relXml.matchAll(/<Relationship\b([^>]*)\/?>(?:<\/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<string, string>();
|
|
const seenImageIds = new Set<string>();
|
|
const duplicateImageIds = new Set<string>();
|
|
// Bound every lookup to one node: a damaged node must never consume its neighbour's blip.
|
|
for (const match of cellImagesXml.matchAll(/<etc:cellImage\b[^>]*?(?:\/>|>([\s\S]*?)<\/etc:cellImage>)/g)) {
|
|
const node = match[1] ?? '';
|
|
const id = attributes(node.match(/<xdr:cNvPr\b([^>]*)>/)?.[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(/<a:blip\b([^>]*)>/)?.[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<string, string>();
|
|
for (const match of workbookRelsXml.matchAll(/<Relationship\b([^>]*)\/?>(?:<\/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<string>();
|
|
const imagesBySheet = new Map<string, Array<EmbeddedImage | EmbeddedImageMetadata>>();
|
|
let totalImageBytes = 0;
|
|
for (const match of workbookXml.matchAll(/<sheet\b([^>]*)\/?>(?:<\/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<EmbeddedImage | EmbeddedImageMetadata> = [];
|
|
for (const cell of sheetXml.matchAll(/<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)) {
|
|
const formulaText = decodeXml((cell[2] ?? '').match(/<f(?:\s[^>]*)?>([\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<void> } = {},
|
|
) {
|
|
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<string, Array<EmbeddedImage | EmbeddedImageMetadata>>,
|
|
) {
|
|
const merged = new Map<string, EmbeddedImage>();
|
|
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<string, Array<EmbeddedImage | EmbeddedImageMetadata>>,
|
|
) {
|
|
const wpsImages = wpsImagesBySheet.get(worksheet.name) ?? [];
|
|
const positions = new Map<string, { row: number; column: number }>();
|
|
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<string, string>();
|
|
for (const match of workbookRels.matchAll(/<Relationship\b([^>]*)\/?>(?:<\/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<string, string>();
|
|
for (const match of workbookXml.matchAll(/<sheet\b([^>]*)\/?>(?:<\/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\b[^>]*\/?>(?:<\/drawing>)?/g, '');
|
|
for (const picture of sheetPictures) {
|
|
const fullCell = new RegExp(`<c\\b([^>]*\\br="${picture.address}"[^>]*)>[\\s\\S]*?<\\/c>`);
|
|
const emptyCell = new RegExp(`<c\\b([^>]*\\br="${picture.address}"[^>]*)\\/>`);
|
|
const replaceCell = (opening: string) =>
|
|
`<c${opening.replace(/\s+t="[^"]*"/g, '')} t="str"><f>_xlfn.DISPIMG("${picture.id}",1)</f><v>=DISPIMG("${picture.id}",1)</v></c>`;
|
|
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(/<Relationship\b[^>]*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) =>
|
|
`<etc:cellImage><xdr:pic><xdr:nvPicPr><xdr:cNvPr id="${index + 1}" name="${picture.id}"/><xdr:cNvPicPr><a:picLocks noChangeAspect="1"/></xdr:cNvPicPr></xdr:nvPicPr><xdr:blipFill><a:blip r:embed="${picture.relId}"/><a:stretch><a:fillRect/></a:stretch></xdr:blipFill><xdr:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/><a:ln w="9525"><a:noFill/></a:ln></xdr:spPr></xdr:pic></etc:cellImage>`,
|
|
)
|
|
.join('');
|
|
zip.file(
|
|
'xl/cellimages.xml',
|
|
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><etc:cellImages xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:etc="http://www.wps.cn/officeDocument/2017/etCustomData">${cellImages}</etc:cellImages>`,
|
|
);
|
|
zip.file(
|
|
'xl/_rels/cellimages.xml.rels',
|
|
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${pictures.map((picture) => `<Relationship Id="${picture.relId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="${picture.mediaPath.replace(/^xl\//, '')}"/>`).join('')}</Relationships>`,
|
|
);
|
|
const nextRel = Math.max(0, ...[...workbookRels.matchAll(/Id="rId(\d+)"/g)].map((match) => Number(match[1]))) + 1;
|
|
workbookRels = workbookRels.replace(
|
|
'</Relationships>',
|
|
`<Relationship Id="rId${nextRel}" Type="http://www.wps.cn/officeDocument/2020/cellImage" Target="cellimages.xml"/></Relationships>`,
|
|
);
|
|
zip.file('xl/_rels/workbook.xml.rels', workbookRels);
|
|
let contentTypes = await text(zip, '[Content_Types].xml');
|
|
contentTypes = contentTypes.replace(
|
|
/<Override\b[^>]*PartName="\/xl\/drawings\/[^"]+"[^>]*\/?>(?:<\/Override>)?/g,
|
|
'',
|
|
);
|
|
if (!contentTypes.includes('/xl/cellimages.xml'))
|
|
contentTypes = contentTypes.replace(
|
|
'</Types>',
|
|
'<Override PartName="/xl/cellimages.xml" ContentType="application/vnd.wps-officedocument.cellimage+xml"/></Types>',
|
|
);
|
|
zip.file('[Content_Types].xml', contentTypes);
|
|
return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE', compressionOptions: { level: 6 } });
|
|
}
|