143 lines
6.7 KiB
TypeScript
143 lines
6.7 KiB
TypeScript
import ExcelJS from 'exceljs';
|
|
import JSZip from 'jszip';
|
|
import {
|
|
compatibleImagePositions,
|
|
compatibleImages,
|
|
convertWorkbookOutput,
|
|
loadCompatibleWorkbook,
|
|
} from './workbook-compatibility';
|
|
|
|
const PNG = Buffer.from(
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZrYQAAAAASUVORK5CYII=',
|
|
'base64',
|
|
);
|
|
|
|
async function standardWorkbook() {
|
|
const workbook = new ExcelJS.Workbook();
|
|
const sheet = workbook.addWorksheet('签名报备');
|
|
sheet.getCell('A1').value = '营业执照';
|
|
sheet.getCell('A2').value = 'license.png';
|
|
const imageId = workbook.addImage({ buffer: PNG as never, extension: 'png' });
|
|
sheet.addImage(imageId, { tl: { col: 0, row: 1 }, br: { col: 0.9, row: 1.9 }, editAs: 'oneCell' } as never);
|
|
return Buffer.from(await workbook.xlsx.writeBuffer());
|
|
}
|
|
|
|
const BROKEN_ID = 'ID_8E811861F79046DDAB7EDBD3AEE9DE89';
|
|
const BROKEN_NODE = `<etc:cellImage><xdr:pic><xdr:nvPicPr><xdr:cNvPr id="522" name="${BROKEN_ID}"/></xdr:nvPicPr><xdr:blipFill><a:stretch/></xdr:blipFill></xdr:pic></etc:cellImage>`;
|
|
|
|
async function wpsFixture() {
|
|
return JSZip.loadAsync(await convertWorkbookOutput(await standardWorkbook(), 'wps_cell_image'));
|
|
}
|
|
|
|
async function addBrokenNode(zip: JSZip, referenced: boolean) {
|
|
const xml = await zip.file('xl/cellimages.xml')!.async('string');
|
|
zip.file('xl/cellimages.xml', xml.replace('<etc:cellImage>', `${BROKEN_NODE}<etc:cellImage>`));
|
|
if (referenced) {
|
|
const sheet = await zip.file('xl/worksheets/sheet1.xml')!.async('string');
|
|
zip.file(
|
|
'xl/worksheets/sheet1.xml',
|
|
sheet.replace(
|
|
'</sheetData>',
|
|
`<row r="3"><c r="B3"><f>_xlfn.DISPIMG("${BROKEN_ID}",1)</f></c></row></sheetData>`,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
describe('WPS workbook compatibility', () => {
|
|
describe.each([false, true])('includeImageData=%s', (includeImageData) => {
|
|
it('ignores an unreferenced damaged node without stealing the next image or shifting a blank cell', async () => {
|
|
const zip = await wpsFixture();
|
|
await addBrokenNode(zip, false);
|
|
const sheet = await zip.file('xl/worksheets/sheet1.xml')!.async('string');
|
|
zip.file('xl/worksheets/sheet1.xml', sheet.replace('<c r="A2"', '<c r="B2"/><c r="A2"'));
|
|
const loaded = await loadCompatibleWorkbook(await zip.generateAsync({ type: 'nodebuffer' }), {
|
|
includeImageData,
|
|
});
|
|
const images = loaded.wpsImagesBySheet.get('签名报备')!;
|
|
expect(images).toHaveLength(1);
|
|
expect(images[0]).toMatchObject({ row: 2, column: 1, extension: 'png' });
|
|
if (includeImageData) expect(images[0]).toMatchObject({ buffer: PNG });
|
|
else expect(images[0]).not.toHaveProperty('buffer');
|
|
});
|
|
|
|
it('reports the actually damaged referenced cell, not the valid adjacent image', async () => {
|
|
const zip = await wpsFixture();
|
|
await addBrokenNode(zip, true);
|
|
await expect(
|
|
loadCompatibleWorkbook(await zip.generateAsync({ type: 'nodebuffer' }), { includeImageData }),
|
|
).rejects.toThrow('工作表【签名报备】B3的WPS图片引用缺失或不唯一');
|
|
});
|
|
|
|
it('rejects duplicate image IDs even when the later node is damaged', async () => {
|
|
const zip = await wpsFixture();
|
|
const xml = await zip.file('xl/cellimages.xml')!.async('string');
|
|
const id = xml.match(/name="(ID_[A-F0-9]{32})"/)![1];
|
|
const duplicate = BROKEN_NODE.replace(BROKEN_ID, id);
|
|
zip.file('xl/cellimages.xml', xml.replace('</etc:cellImages>', `${duplicate}</etc:cellImages>`));
|
|
await expect(
|
|
loadCompatibleWorkbook(await zip.generateAsync({ type: 'nodebuffer' }), { includeImageData }),
|
|
).rejects.toThrow('工作表【签名报备】A2的WPS图片ID重复');
|
|
});
|
|
|
|
it('reports a missing media file with the exact worksheet and cell', async () => {
|
|
const zip = await wpsFixture();
|
|
for (const name of Object.keys(zip.files).filter((name) => name.startsWith('xl/media/'))) zip.remove(name);
|
|
await expect(
|
|
loadCompatibleWorkbook(await zip.generateAsync({ type: 'nodebuffer' }), { includeImageData }),
|
|
).rejects.toThrow('工作表【签名报备】A2的WPS图片文件缺失');
|
|
});
|
|
|
|
it.each(['missing', 'duplicate', 'external'])('rejects %s image relationships', async (kind) => {
|
|
const zip = await wpsFixture();
|
|
const path = 'xl/_rels/cellimages.xml.rels';
|
|
const xml = await zip.file(path)!.async('string');
|
|
const relationship = xml.match(/<Relationship\b[^>]*\/>/)![0];
|
|
zip.file(
|
|
path,
|
|
kind === 'missing'
|
|
? xml.replace(relationship, '')
|
|
: kind === 'duplicate'
|
|
? xml.replace(relationship, relationship + relationship)
|
|
: xml.replace(relationship, relationship.replace('/>', ' TargetMode="External"/>')),
|
|
);
|
|
await expect(
|
|
loadCompatibleWorkbook(await zip.generateAsync({ type: 'nodebuffer' }), { includeImageData }),
|
|
).rejects.toThrow('工作表【签名报备】A2的WPS图片引用缺失或不唯一');
|
|
});
|
|
});
|
|
|
|
it('converts a standard Drawing image to DISPIMG and reads it back from cellimages.xml', async () => {
|
|
const converted = await convertWorkbookOutput(await standardWorkbook(), 'wps_cell_image');
|
|
const zip = await JSZip.loadAsync(converted);
|
|
await expect(zip.file('xl/cellimages.xml')!.async('string')).resolves.toContain('ID_');
|
|
const loaded = await loadCompatibleWorkbook(converted);
|
|
const images = compatibleImages(
|
|
loaded.workbook,
|
|
loaded.workbook.getWorksheet('签名报备')!,
|
|
loaded.wpsImagesBySheet,
|
|
);
|
|
expect(images).toHaveLength(1);
|
|
expect(images[0]).toMatchObject({ row: 2, column: 1, extension: 'png' });
|
|
expect(images[0].buffer.equals(PNG)).toBe(true);
|
|
});
|
|
|
|
it('still rejects ordinary formulas instead of weakening spreadsheet safety', async () => {
|
|
const workbook = new ExcelJS.Workbook();
|
|
workbook.addWorksheet('危险').getCell('A1').value = { formula: 'HYPERLINK("https://example.com")' };
|
|
const content = Buffer.from(await workbook.xlsx.writeBuffer());
|
|
await expect(loadCompatibleWorkbook(content)).rejects.toThrow('不允许的公式');
|
|
});
|
|
|
|
it('reads WPS image positions without inflating image buffers during analysis', async () => {
|
|
const converted = await convertWorkbookOutput(await standardWorkbook(), 'wps_cell_image');
|
|
const loaded = await loadCompatibleWorkbook(converted, { includeImageData: false });
|
|
const wpsImage = loaded.wpsImagesBySheet.get('签名报备')?.[0];
|
|
expect(wpsImage).toMatchObject({ row: 2, column: 1, extension: 'png', size: PNG.length });
|
|
expect(wpsImage).not.toHaveProperty('buffer');
|
|
expect(
|
|
compatibleImagePositions(loaded.workbook, loaded.workbook.getWorksheet('签名报备')!, loaded.wpsImagesBySheet),
|
|
).toEqual([{ row: 2, column: 1 }]);
|
|
});
|
|
});
|