Revert "fix(file-viewers): 把 xlsx 图片绑定到它真正所属的 sheet"

This reverts commit 516130dd8258f8ac2e2f68e0e5b3d1ba38b98c9a.

修复留在 3.0 线,2.6 不再跟进——客户后续升级到 3.0 时一并拿到。
This commit is contained in:
LineWalker
2026-08-12 17:57:53 +08:00
parent 516130dd85
commit e8132a78ee
14 changed files with 229 additions and 488 deletions
@@ -15,8 +15,7 @@
"previewFailed": "Preview failed",
"downloadOriginal": "Download original file",
"imageLoadFailed": "Image failed to load",
"imageRef": "Image reference",
"imagesOutsideTable": "Images outside the table area"
"imageRef": "Image reference"
}
}
}
@@ -15,8 +15,7 @@
"previewFailed": "プレビュー失敗",
"downloadOriginal": "元ファイルをダウンロード",
"imageLoadFailed": "画像の読み込みに失敗しました",
"imageRef": "画像参照",
"imagesOutsideTable": "表領域外の画像"
"imageRef": "画像参照"
}
}
}
@@ -15,8 +15,7 @@
"previewFailed": "预览失败",
"downloadOriginal": "下载原始文件",
"imageLoadFailed": "图片加载失败",
"imageRef": "图片引用",
"imagesOutsideTable": "表格区域外的图片"
"imageRef": "图片引用"
}
}
}
@@ -1,18 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import * as XLSX from 'xlsx';
import XlsxPopulate from 'xlsx-populate/browser/xlsx-populate';
import { ImageGallery } from './ImageGallery';
import {
cleanData,
extractImageIdFromFormula,
getFileExtension,
getTableColumnCount,
numberToColumnLetters,
parseCSV,
} from './sheetUtils';
import type { ExtractedImage, ResolvedImage, SheetData, SheetImageIndex } from './types';
import { extractSheetImages } from './xlsxImages';
import XlsxPopulate, { type Workbook } from 'xlsx-populate/browser/xlsx-populate';
export interface ExcelPreviewProps {
filePath: string;
@@ -22,9 +11,205 @@ export interface ExcelPreviewProps {
loadingIcon?: ReactNode;
}
interface SheetCoordinateMaps {
rowMap: number[];
colMap: number[];
interface ExtractedImage {
id: string;
path: string;
ext: string;
base64: string;
mimeType: string;
}
type SheetData = string[][];
const VALID_EXTENSIONS = ['csv', 'xlsx', 'xls', 'et', 'txt'];
const MIME_TYPES: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
bmp: 'image/bmp',
jfif: 'image/jpeg',
tiff: 'image/tiff',
tif: 'image/tiff',
svg: 'image/svg+xml',
};
function getFileExtension(filePath: string): string {
if (!filePath) return '';
const withoutQuery = filePath.split('?')[0];
const parts = withoutQuery.split('.');
if (parts.length < 2) return '';
const ext = parts.pop()?.toLowerCase() || '';
return VALID_EXTENSIONS.includes(ext) ? ext : '';
}
function numberToColumnLetters(num: number): string {
let result = '';
while (num >= 0) {
result = String.fromCharCode(65 + (num % 26)) + result;
num = Math.floor(num / 26) - 1;
}
return result;
}
function extractImageIdFromFormula(formula: unknown): string | null {
if (!formula || typeof formula !== 'string') return null;
const patterns = [
/DISPIMG\("([^"]+)"\)/i,
/DISPIMG\('([^']+)'\)/i,
/DISPIMG\("([^"]+)",\s*\d+\)/i,
/DISPIMG\('([^']+)',\s*\d+\)/i,
];
for (const pattern of patterns) {
const match = formula.match(pattern);
if (match && match[1]) return match[1];
}
return null;
}
function parseCSV(csvStr: string): SheetData {
try {
if (!csvStr || typeof csvStr !== 'string') return [];
const lines = csvStr.split(/\r?\n/).filter((line) => line.trim() !== '');
const rows: SheetData = [];
const delimiters = [',', '\t', ';', '|'];
let detectedDelimiter = ',';
let maxColumns = 0;
for (const delimiter of delimiters) {
const testRow = lines[0]?.split(delimiter) || [];
if (testRow.length > maxColumns && testRow.some((col) => col.trim() !== '')) {
maxColumns = testRow.length;
detectedDelimiter = delimiter;
}
}
lines.forEach((line) => {
const columns = line.split(detectedDelimiter).map((col) => col.replace(/^["']|["']$/g, '').trim());
if (columns.some((col) => col !== '')) rows.push(columns);
});
return rows;
} catch (err) {
console.error('CSV parsing error:', err);
return [];
}
}
function cleanData(data: unknown[][]): SheetData {
if (!Array.isArray(data) || data.length === 0) return [];
const nonEmptyRows = data.filter((row) =>
row.some((cell) => cell !== undefined && cell !== null && String(cell).trim() !== ''),
);
if (nonEmptyRows.length === 0) return [];
const columnCount = Math.max(...nonEmptyRows.map((row) => row.length));
const hasDataColumns: number[] = [];
for (let col = 0; col < columnCount; col++) {
const hasData = nonEmptyRows.some(
(row) => row[col] !== undefined && row[col] !== null && String(row[col]).trim() !== '',
);
if (hasData) hasDataColumns.push(col);
}
return nonEmptyRows.map((row) => hasDataColumns.map((colIndex) => (row[colIndex] ? String(row[colIndex]).trim() : '')));
}
function getTableColumnCount(data: SheetData): number {
if (!Array.isArray(data) || data.length === 0) return 0;
return Math.max(...data.map((row) => row.length));
}
async function extractImagesWithPositions(workbook: Workbook): Promise<{
images: ExtractedImage[];
imagePositions: Record<string, string[]>;
}> {
interface PendingImage {
id: string;
path: string;
ext: string;
base64Promise: Promise<string>;
}
const pending: PendingImage[] = [];
const imagePositions: Record<string, string[]> = {};
const zip = workbook._zip;
zip.forEach((relativePath, zipEntry) => {
if (relativePath.startsWith('xl/media/') && !zipEntry.dir) {
const ext = relativePath.split('.').pop()?.toLowerCase() || '';
if (['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tiff', 'tif', 'jfif'].includes(ext)) {
const id = relativePath.split('/').pop() || relativePath;
pending.push({ id, path: relativePath, ext, base64Promise: zipEntry.async('base64') });
}
}
});
const drawingFiles = Object.keys(zip.files).filter((p) => p.startsWith('xl/drawings/') && p.endsWith('.xml'));
for (const drawingPath of drawingFiles) {
try {
const xmlStr = await zip.file(drawingPath)?.async('text');
if (!xmlStr) continue;
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlStr, 'text/xml');
const anchors = xmlDoc.getElementsByTagName('xdr:twoCellAnchor');
const relsPath = drawingPath.replace('drawings/', 'drawings/_rels/') + '.rels';
const rIdMap: Record<string, string> = {};
const relsEntry = zip.file(relsPath);
if (relsEntry) {
const relsXml = await relsEntry.async('text');
const relsDoc = parser.parseFromString(relsXml, 'text/xml');
const relationships = relsDoc.getElementsByTagName('Relationship');
for (let j = 0; j < relationships.length; j++) {
const r = relationships[j];
const id = r.getAttribute('Id');
const target = r.getAttribute('Target');
if (id && target) rIdMap[id] = target.split('/').pop() || target;
}
}
for (let i = 0; i < anchors.length; i++) {
const anchor = anchors[i];
const from = anchor.getElementsByTagName('xdr:from')[0];
const pic = anchor.getElementsByTagName('xdr:pic')[0];
if (!from || !pic) continue;
const colNode = from.getElementsByTagName('xdr:col')[0];
const rowNode = from.getElementsByTagName('xdr:row')[0];
const blip = pic.getElementsByTagName('a:blip')[0];
if (!colNode || !rowNode || !blip) continue;
const col = parseInt(colNode.textContent || '0', 10);
const row = parseInt(rowNode.textContent || '0', 10);
const cellAddress = numberToColumnLetters(col) + (row + 1);
const rId = blip.getAttribute('r:embed');
if (!rId || !rIdMap[rId]) continue;
const mediaFileName = rIdMap[rId];
if (pending.some((img) => img.id === mediaFileName)) {
if (!imagePositions[cellAddress]) imagePositions[cellAddress] = [];
imagePositions[cellAddress].push(mediaFileName);
}
}
} catch (e) {
console.warn('Failed to parse drawing xml:', e);
}
}
const images: ExtractedImage[] = [];
for (const img of pending) {
images.push({
id: img.id,
path: img.path,
ext: img.ext,
base64: await img.base64Promise,
mimeType: MIME_TYPES[img.ext] || 'image/png',
});
}
return { images, imagePositions };
}
function DefaultSpinner() {
@@ -44,66 +229,36 @@ export function ExcelPreview({ filePath, fileExt: fileExtProp, loadingIcon }: Ex
const [sheets, setSheets] = useState<string[]>([]);
const [activeSheet, setActiveSheet] = useState('');
const [excelData, setExcelData] = useState<Record<string, SheetData>>({});
const [sheetMaps, setSheetMaps] = useState<Record<string, SheetCoordinateMaps>>({});
const [images, setImages] = useState<ExtractedImage[]>([]);
const [sheetImages, setSheetImages] = useState<SheetImageIndex>({});
const [imagePositions, setImagePositions] = useState<Record<string, string[]>>({});
const tableContainerRef = useRef<HTMLDivElement>(null);
const fileExt = fileExtProp || getFileExtension(filePath);
const isCSV = fileExt === 'csv';
const isXLSX = fileExt === 'xlsx' || fileExt === 'et';
/**
* Resolve the active sheet's pictures against the rendered grid. Anchors address
* the original grid, while cleanData() has dropped blank rows/columns — so the
* anchor coordinates are translated back through the row/column maps. Anything
* that lands outside the used range is shown below the table instead of silently
* disappearing.
*/
const { placedImages, looseImages } = useMemo(() => {
const placed = new Map<string, ExtractedImage>();
const loose: ResolvedImage[] = [];
const anchors = sheetImages[activeSheet];
if (!anchors?.length) return { placedImages: placed, looseImages: loose };
const maps = sheetMaps[activeSheet];
const toRendered = (originals: number[] | undefined) =>
new Map((originals ?? []).map((original, rendered): [number, number] => [original, rendered]));
const rowIndexOf = toRendered(maps?.rowMap);
const colIndexOf = toRendered(maps?.colMap);
for (const anchor of anchors) {
const image = images.find((img) => img.id === anchor.imageId);
if (!image) continue;
const row = anchor.floating ? undefined : rowIndexOf.get(anchor.from.row);
const col = anchor.floating ? undefined : colIndexOf.get(anchor.from.col);
if (row === undefined || col === undefined) {
loose.push({ image, anchor });
continue;
}
placed.set(`${row}:${col}`, image);
}
return { placedImages: placed, looseImages: loose };
}, [sheetImages, sheetMaps, images, activeSheet]);
const getCellImage = useCallback(
(rowIndex: number, colIndex: number, cellContent: string): ExtractedImage | null => {
const anchored = placedImages.get(`${rowIndex}:${colIndex}`);
if (anchored) return anchored;
const cellAddress = `${numberToColumnLetters(colIndex)}${rowIndex + 1}`;
const imageIds = imagePositions[cellAddress];
// WPS keeps in-cell pictures as a =DISPIMG("ID_...") formula, not a drawing anchor.
if (typeof cellContent === 'string' && cellContent.startsWith('=DISPIMG')) {
if (imageIds?.length) {
const foundImage = images.find((img) => imageIds.includes(img.id));
if (foundImage) return foundImage;
}
if (cellContent && typeof cellContent === 'string' && cellContent.startsWith('=DISPIMG')) {
const imageId = extractImageIdFromFormula(cellContent);
if (imageId) {
return images.find((img) => img.path.includes(imageId) || img.id === imageId) ?? null;
const imageById = images.find((img) => img.path.includes(imageId) || img.id === imageId);
if (imageById) return imageById;
if (images.length > 0) return images[0];
}
}
return null;
},
[placedImages, images],
[images, imagePositions],
);
useEffect(() => {
@@ -111,9 +266,8 @@ export function ExcelPreview({ filePath, fileExt: fileExtProp, loadingIcon }: Ex
try {
setLoading(true);
setImages([]);
setSheetImages({});
setImagePositions({});
setExcelData({});
setSheetMaps({});
setSheets([]);
setActiveSheet('');
@@ -142,9 +296,8 @@ export function ExcelPreview({ filePath, fileExt: fileExtProp, loadingIcon }: Ex
}
if (!decodedStr) decodedStr = new TextDecoder().decode(uint8Array);
const cleaned = cleanData(parseCSV(decodedStr));
setExcelData({ Sheet1: cleaned.data });
setSheetMaps({ Sheet1: { rowMap: cleaned.rowMap, colMap: cleaned.colMap } });
const cleanedData = cleanData(parseCSV(decodedStr));
setExcelData({ Sheet1: cleanedData });
setSheets(['Sheet1']);
setActiveSheet('Sheet1');
} else if (isXLSX || fileExt === 'xls') {
@@ -160,18 +313,14 @@ export function ExcelPreview({ filePath, fileExt: fileExtProp, loadingIcon }: Ex
const sheetNames = wb.SheetNames;
const parsedData: Record<string, SheetData> = {};
const parsedMaps: Record<string, SheetCoordinateMaps> = {};
sheetNames.forEach((sheetName) => {
const aoa = XLSX.utils.sheet_to_json(wb.Sheets[sheetName], {
header: 1,
defval: '',
}) as unknown[][];
const cleaned = cleanData(aoa);
parsedData[sheetName] = cleaned.data;
parsedMaps[sheetName] = { rowMap: cleaned.rowMap, colMap: cleaned.colMap };
parsedData[sheetName] = cleanData(aoa);
});
setExcelData(parsedData);
setSheetMaps(parsedMaps);
setSheets(sheetNames);
setActiveSheet(sheetNames[0] || '');
@@ -180,9 +329,9 @@ export function ExcelPreview({ filePath, fileExt: fileExtProp, loadingIcon }: Ex
if (isXLSX) {
try {
const workbook = await XlsxPopulate.fromDataAsync(arrayBuffer);
const extracted = await extractSheetImages(workbook._zip);
const extracted = await extractImagesWithPositions(workbook);
setImages(extracted.images);
setSheetImages(extracted.index);
setImagePositions(extracted.imagePositions);
} catch (e) {
console.warn('[ExcelPreview] image extraction failed, skipping:', e);
}
@@ -211,14 +360,6 @@ export function ExcelPreview({ filePath, fileExt: fileExtProp, loadingIcon }: Ex
const renderContent = () => {
const sheetData = excelData[activeSheet];
if (!Array.isArray(sheetData) || sheetData.length === 0) {
// A sheet can legitimately hold pictures and no cells at all.
if (looseImages.length > 0) {
return (
<div className="flex-1 min-h-0 overflow-auto border border-gray-200 bg-white p-4">
<ImageGallery items={looseImages} />
</div>
);
}
return (
<div className="flex flex-1 min-h-0 items-center justify-center text-gray-500">
{t('currentSheetNoData')}
@@ -417,12 +558,6 @@ export function ExcelPreview({ filePath, fileExt: fileExtProp, loadingIcon }: Ex
})}
</tbody>
</table>
{looseImages.length > 0 && (
<div className="border-t border-gray-200 p-4">
<ImageGallery items={looseImages} title={t('imagesOutsideTable')} />
</div>
)}
</div>
</div>
</div>
@@ -1,34 +0,0 @@
import { useTranslation } from 'react-i18next';
import type { ResolvedImage } from './types';
interface ImageGalleryProps {
items: ResolvedImage[];
/** Optional caption, used when the gallery sits below a rendered table. */
title?: string;
}
/**
* Pictures that cannot live inside a cell: sheets holding nothing but a drawing
* (report exporters do this), and pictures anchored outside the used range.
* They keep their authored size instead of being squeezed into a table cell.
*/
export function ImageGallery({ items, title }: ImageGalleryProps) {
const { t } = useTranslation('shared', { keyPrefix: 'knowledge.excelPreview' });
if (items.length === 0) return null;
return (
<div className="flex flex-col gap-4">
{title ? <div className="text-xs font-medium text-gray-500">{title}</div> : null}
{items.map(({ image, anchor }, index) => (
<img
key={`${image.id}-${index}`}
src={`data:${image.mimeType};base64,${image.base64}`}
alt={`${t('imageRef')} ${index + 1}`}
className="h-auto max-w-full object-contain"
style={{ width: anchor.sizePx ? `${anchor.sizePx.w}px` : undefined }}
/>
))}
</div>
);
}
@@ -1,112 +0,0 @@
import type { CleanedSheet, SheetData } from './types';
const VALID_EXTENSIONS = ['csv', 'xlsx', 'xls', 'et', 'txt'];
export const MIME_TYPES: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
bmp: 'image/bmp',
jfif: 'image/jpeg',
tiff: 'image/tiff',
tif: 'image/tiff',
svg: 'image/svg+xml',
};
export function getFileExtension(filePath: string): string {
if (!filePath) return '';
const withoutQuery = filePath.split('?')[0];
const parts = withoutQuery.split('.');
if (parts.length < 2) return '';
const ext = parts.pop()?.toLowerCase() || '';
return VALID_EXTENSIONS.includes(ext) ? ext : '';
}
export function numberToColumnLetters(num: number): string {
let result = '';
while (num >= 0) {
result = String.fromCharCode(65 + (num % 26)) + result;
num = Math.floor(num / 26) - 1;
}
return result;
}
export function extractImageIdFromFormula(formula: unknown): string | null {
if (!formula || typeof formula !== 'string') return null;
const patterns = [
/DISPIMG\("([^"]+)"\)/i,
/DISPIMG\('([^']+)'\)/i,
/DISPIMG\("([^"]+)",\s*\d+\)/i,
/DISPIMG\('([^']+)',\s*\d+\)/i,
];
for (const pattern of patterns) {
const match = formula.match(pattern);
if (match && match[1]) return match[1];
}
return null;
}
export function parseCSV(csvStr: string): SheetData {
try {
if (!csvStr || typeof csvStr !== 'string') return [];
const lines = csvStr.split(/\r?\n/).filter((line) => line.trim() !== '');
const rows: SheetData = [];
const delimiters = [',', '\t', ';', '|'];
let detectedDelimiter = ',';
let maxColumns = 0;
for (const delimiter of delimiters) {
const testRow = lines[0]?.split(delimiter) || [];
if (testRow.length > maxColumns && testRow.some((col) => col.trim() !== '')) {
maxColumns = testRow.length;
detectedDelimiter = delimiter;
}
}
lines.forEach((line) => {
const columns = line.split(detectedDelimiter).map((col) => col.replace(/^["']|["']$/g, '').trim());
if (columns.some((col) => col !== '')) rows.push(columns);
});
return rows;
} catch (err) {
console.error('CSV parsing error:', err);
return [];
}
}
function hasValue(cell: unknown): boolean {
return cell !== undefined && cell !== null && String(cell).trim() !== '';
}
/**
* Drop fully blank rows and columns, and report which original row/column each
* surviving index came from — drawing anchors address the original grid.
*/
export function cleanData(data: unknown[][]): CleanedSheet {
const empty: CleanedSheet = { data: [], rowMap: [], colMap: [] };
if (!Array.isArray(data) || data.length === 0) return empty;
const rowMap: number[] = [];
data.forEach((row, index) => {
if (Array.isArray(row) && row.some(hasValue)) rowMap.push(index);
});
if (rowMap.length === 0) return empty;
const columnCount = Math.max(...rowMap.map((index) => data[index].length));
const colMap: number[] = [];
for (let col = 0; col < columnCount; col++) {
if (rowMap.some((index) => hasValue(data[index][col]))) colMap.push(col);
}
return {
data: rowMap.map((index) => colMap.map((col) => (hasValue(data[index][col]) ? String(data[index][col]).trim() : ''))),
rowMap,
colMap,
};
}
export function getTableColumnCount(data: SheetData): number {
if (!Array.isArray(data) || data.length === 0) return 0;
return Math.max(...data.map((row) => row.length));
}
@@ -1,46 +0,0 @@
export type SheetData = string[][];
export interface ExtractedImage {
id: string;
path: string;
ext: string;
base64: string;
mimeType: string;
}
/** Anchor of one picture, in original 0-based Excel coordinates. */
export interface ImageAnchor {
/** Media file name inside xl/media, e.g. "image1.png". */
imageId: string;
from: { col: number; row: number };
/** Present on twoCellAnchor only. */
to?: { col: number; row: number };
/** Display size in CSS pixels, converted from the EMU extent. */
sizePx?: { w: number; h: number };
/** absoluteAnchor pictures float over the grid and have no cell to sit in. */
floating: boolean;
}
/**
* Picture anchors keyed by sheet name. A picture belongs to exactly one sheet —
* keeping the index flat (address -> image) leaks pictures across every sheet.
*/
export type SheetImageIndex = Record<string, ImageAnchor[]>;
/**
* cleanData() drops blank rows/columns, so rendered indexes no longer match the
* addresses drawing anchors use. The maps translate back to original coordinates.
*/
export interface CleanedSheet {
data: SheetData;
/** rowMap[renderedRowIndex] = original 0-based row */
rowMap: number[];
/** colMap[renderedColIndex] = original 0-based column */
colMap: number[];
}
/** An anchor paired with the media it resolves to. */
export interface ResolvedImage {
image: ExtractedImage;
anchor: ImageAnchor;
}
@@ -1,193 +0,0 @@
import { MIME_TYPES } from './sheetUtils';
import type { ExtractedImage, ImageAnchor, SheetImageIndex } from './types';
const EMU_PER_PX = 914400 / 96;
const RELS_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tiff', 'tif', 'jfif'];
interface ZipEntry {
dir: boolean;
async(type: 'base64' | 'text'): Promise<string>;
}
/** Minimal shape of the JSZip instance that xlsx-populate keeps on the workbook. */
export interface ZipLike {
file(path: string): ZipEntry | null;
forEach(callback: (relativePath: string, entry: ZipEntry) => void): void;
}
function dirOf(path: string): string {
const index = path.lastIndexOf('/');
return index === -1 ? '' : path.slice(0, index);
}
/** Resolve an OPC relationship target (often "../drawings/x.xml") into a package path. */
function resolvePath(baseDir: string, target: string): string {
if (target.startsWith('/')) return target.replace(/^\/+/, '');
const segments = baseDir ? baseDir.split('/') : [];
for (const part of target.split('/')) {
if (part === '' || part === '.') continue;
if (part === '..') segments.pop();
else segments.push(part);
}
return segments.join('/');
}
function relsPathFor(partPath: string): string {
const dir = dirOf(partPath);
const name = dir ? partPath.slice(dir.length + 1) : partPath;
return `${dir ? `${dir}/` : ''}_rels/${name}.rels`;
}
/**
* Namespace prefixes in OOXML are arbitrary — "xdr:" is a convention, not a rule.
* Always match on local name so files from WPS/report exporters still parse.
*/
function tags(root: Document | Element, localName: string): Element[] {
return Array.from(root.getElementsByTagNameNS('*', localName));
}
function relAttr(element: Element, name: string): string | null {
return element.getAttributeNS(RELS_NS, name) ?? element.getAttribute(`r:${name}`);
}
async function readXml(zip: ZipLike, path: string): Promise<Document | null> {
const entry = zip.file(path);
if (!entry) return null;
const xml = await entry.async('text');
const doc = new DOMParser().parseFromString(xml, 'text/xml');
return doc.getElementsByTagName('parsererror').length > 0 ? null : doc;
}
async function readRelationships(
zip: ZipLike,
partPath: string,
): Promise<Record<string, { type: string; target: string }>> {
const map: Record<string, { type: string; target: string }> = {};
const doc = await readXml(zip, relsPathFor(partPath));
if (!doc) return map;
const baseDir = dirOf(partPath);
for (const rel of tags(doc, 'Relationship')) {
const id = rel.getAttribute('Id');
const target = rel.getAttribute('Target');
if (!id || !target || rel.getAttribute('TargetMode') === 'External') continue;
map[id] = { type: rel.getAttribute('Type') ?? '', target: resolvePath(baseDir, target) };
}
return map;
}
function readMarker(anchor: Element, localName: 'from' | 'to'): { col: number; row: number } | null {
const marker = tags(anchor, localName)[0];
if (!marker) return null;
const col = parseInt(tags(marker, 'col')[0]?.textContent ?? '', 10);
const row = parseInt(tags(marker, 'row')[0]?.textContent ?? '', 10);
if (Number.isNaN(col) || Number.isNaN(row)) return null;
return { col, row };
}
function readExtent(anchor: Element): { w: number; h: number } | undefined {
const ext = tags(anchor, 'ext')[0];
if (!ext) return undefined;
const cx = Number(ext.getAttribute('cx'));
const cy = Number(ext.getAttribute('cy'));
if (!Number.isFinite(cx) || !Number.isFinite(cy) || cx <= 0 || cy <= 0) return undefined;
return { w: Math.round(cx / EMU_PER_PX), h: Math.round(cy / EMU_PER_PX) };
}
const ANCHOR_KINDS: Array<{ tag: string; floating: boolean }> = [
{ tag: 'twoCellAnchor', floating: false },
{ tag: 'oneCellAnchor', floating: false },
// Absolute anchors are positioned in EMU over the grid, with no cell to sit in.
{ tag: 'absoluteAnchor', floating: true },
];
async function parseDrawingAnchors(zip: ZipLike, drawingPath: string, mediaIds: Set<string>): Promise<ImageAnchor[]> {
const anchors: ImageAnchor[] = [];
try {
const doc = await readXml(zip, drawingPath);
if (!doc) return anchors;
const rels = await readRelationships(zip, drawingPath);
for (const kind of ANCHOR_KINDS) {
for (const anchor of tags(doc, kind.tag)) {
const blip = tags(anchor, 'blip')[0];
const rId = blip ? relAttr(blip, 'embed') : null;
const target = rId ? rels[rId]?.target : undefined;
if (!target) continue;
const imageId = target.split('/').pop() ?? target;
if (!mediaIds.has(imageId)) continue;
const from = readMarker(anchor, 'from');
anchors.push({
imageId,
from: from ?? { col: 0, row: 0 },
to: readMarker(anchor, 'to') ?? undefined,
sizePx: readExtent(anchor),
floating: kind.floating || !from,
});
}
}
} catch (e) {
console.warn('[ExcelPreview] failed to parse drawing:', drawingPath, e);
}
return anchors;
}
/**
* Collect every embedded picture and the sheet it is anchored to.
*
* The sheet a drawing belongs to is only discoverable through the relationship
* chain — workbook.xml (r:id) -> workbook.xml.rels -> worksheets/sheetN.xml ->
* sheetN.xml.rels -> drawings/drawingM.xml. Sheet order and file numbering are
* unrelated (deleting a sheet leaves gaps), so never pair them positionally.
*/
export async function extractSheetImages(zip: ZipLike): Promise<{
images: ExtractedImage[];
index: SheetImageIndex;
}> {
const pending: Array<{ id: string; path: string; ext: string; base64Promise: Promise<string> }> = [];
zip.forEach((relativePath, entry) => {
if (!relativePath.startsWith('xl/media/') || entry.dir) return;
const ext = relativePath.split('.').pop()?.toLowerCase() || '';
if (!IMAGE_EXTENSIONS.includes(ext)) return;
const id = relativePath.split('/').pop() || relativePath;
pending.push({ id, path: relativePath, ext, base64Promise: entry.async('base64') });
});
const index: SheetImageIndex = {};
const mediaIds = new Set(pending.map((img) => img.id));
if (mediaIds.size > 0) {
const workbook = await readXml(zip, 'xl/workbook.xml');
const workbookRels = workbook ? await readRelationships(zip, 'xl/workbook.xml') : {};
for (const sheetEl of workbook ? tags(workbook, 'sheet') : []) {
const name = sheetEl.getAttribute('name');
const rId = relAttr(sheetEl, 'id');
const sheetPath = rId ? workbookRels[rId]?.target : undefined;
if (!name || !sheetPath) continue;
const sheetRels = await readRelationships(zip, sheetPath);
const drawing = Object.values(sheetRels).find((rel) => rel.type.endsWith('/drawing'));
if (!drawing) continue;
const anchors = await parseDrawingAnchors(zip, drawing.target, mediaIds);
if (anchors.length > 0) index[name] = anchors;
}
}
const images: ExtractedImage[] = [];
for (const img of pending) {
images.push({
id: img.id,
path: img.path,
ext: img.ext,
base64: await img.base64Promise,
mimeType: MIME_TYPES[img.ext] || 'image/png',
});
}
return { images, index };
}
@@ -15,8 +15,7 @@
"previewFailed": "Preview failed",
"downloadOriginal": "Download original file",
"imageLoadFailed": "Image failed to load",
"imageRef": "Image reference",
"imagesOutsideTable": "Images outside the table area"
"imageRef": "Image reference"
}
}
}
@@ -15,8 +15,7 @@
"previewFailed": "プレビュー失敗",
"downloadOriginal": "元ファイルをダウンロード",
"imageLoadFailed": "画像の読み込みに失敗しました",
"imageRef": "画像参照",
"imagesOutsideTable": "表領域外の画像"
"imageRef": "画像参照"
}
}
}
@@ -15,8 +15,7 @@
"previewFailed": "预览失败",
"downloadOriginal": "下载原始文件",
"imageLoadFailed": "图片加载失败",
"imageRef": "图片引用",
"imagesOutsideTable": "表格区域外的图片"
"imageRef": "图片引用"
}
}
}
@@ -15,8 +15,7 @@
"previewFailed": "Preview failed",
"downloadOriginal": "Download original file",
"imageLoadFailed": "Image failed to load",
"imageRef": "Image reference",
"imagesOutsideTable": "Images outside the table area"
"imageRef": "Image reference"
}
}
}
@@ -15,8 +15,7 @@
"previewFailed": "プレビュー失敗",
"downloadOriginal": "元ファイルをダウンロード",
"imageLoadFailed": "画像の読み込みに失敗しました",
"imageRef": "画像参照",
"imagesOutsideTable": "表領域外の画像"
"imageRef": "画像参照"
}
}
}
@@ -15,8 +15,7 @@
"previewFailed": "预览失败",
"downloadOriginal": "下载原始文件",
"imageLoadFailed": "图片加载失败",
"imageRef": "图片引用",
"imagesOutsideTable": "表格区域外的图片"
"imageRef": "图片引用"
}
}
}