mirror of
https://github.com/dataelement/bisheng.git
synced 2026-08-30 17:58:00 +08:00
Revert "fix(file-viewers): 把 xlsx 图片绑定到它真正所属的 sheet"
This reverts commit 516130dd8258f8ac2e2f68e0e5b3d1ba38b98c9a. 修复留在 3.0 线,2.6 不再跟进——客户后续升级到 3.0 时一并拿到。
This commit is contained in:
@@ -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": "图片引用"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user