fix: 修复WPS图片跨节点误配并定位损坏单元格
This commit is contained in:
@@ -22,7 +22,91 @@ async function standardWorkbook() {
|
||||
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);
|
||||
|
||||
@@ -82,17 +82,32 @@ async function inspectWpsImages(zip: JSZip, includeImageData: boolean) {
|
||||
};
|
||||
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 (attrs.Id && attrs.Target && /\/image$/.test(attrs.Type ?? ''))
|
||||
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>();
|
||||
for (const match of cellImagesXml.matchAll(
|
||||
/<etc:cellImage\b[^>]*>[\s\S]*?<xdr:cNvPr\b([^>]*)\/?>(?:[\s\S]*?)<a:blip\b([^>]*)\/?>(?:[\s\S]*?)<\/etc:cellImage>/g,
|
||||
)) {
|
||||
const id = attributes(match[1]).name;
|
||||
const target = relTargets.get(attributes(match[2])['r:embed']);
|
||||
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');
|
||||
@@ -112,16 +127,21 @@ async function inspectWpsImages(zip: JSZip, includeImageData: boolean) {
|
||||
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() ?? '');
|
||||
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 cellPosition = coordinates(attributes(cell[1]).r);
|
||||
if (!target || !cellPosition) throw new BadRequestException('WPS单元格图片关系不完整');
|
||||
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('WPS单元格图片文件缺失');
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user