diff --git a/src/frontend/client/src/locales/en/shared.gen.json b/src/frontend/client/src/locales/en/shared.gen.json index d59e27053..d512cbc24 100644 --- a/src/frontend/client/src/locales/en/shared.gen.json +++ b/src/frontend/client/src/locales/en/shared.gen.json @@ -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" } } } diff --git a/src/frontend/client/src/locales/ja/shared.gen.json b/src/frontend/client/src/locales/ja/shared.gen.json index 37cc9c6fc..a2badfa83 100644 --- a/src/frontend/client/src/locales/ja/shared.gen.json +++ b/src/frontend/client/src/locales/ja/shared.gen.json @@ -15,8 +15,7 @@ "previewFailed": "プレビュー失敗", "downloadOriginal": "元ファイルをダウンロード", "imageLoadFailed": "画像の読み込みに失敗しました", - "imageRef": "画像参照", - "imagesOutsideTable": "表領域外の画像" + "imageRef": "画像参照" } } } diff --git a/src/frontend/client/src/locales/zh-Hans/shared.gen.json b/src/frontend/client/src/locales/zh-Hans/shared.gen.json index b7363272a..15060c74d 100644 --- a/src/frontend/client/src/locales/zh-Hans/shared.gen.json +++ b/src/frontend/client/src/locales/zh-Hans/shared.gen.json @@ -15,8 +15,7 @@ "previewFailed": "预览失败", "downloadOriginal": "下载原始文件", "imageLoadFailed": "图片加载失败", - "imageRef": "图片引用", - "imagesOutsideTable": "表格区域外的图片" + "imageRef": "图片引用" } } } diff --git a/src/frontend/packages/file-viewers/src/ExcelPreview.tsx b/src/frontend/packages/file-viewers/src/ExcelPreview.tsx index fd605f122..8e6c2c0ce 100644 --- a/src/frontend/packages/file-viewers/src/ExcelPreview.tsx +++ b/src/frontend/packages/file-viewers/src/ExcelPreview.tsx @@ -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 = { + 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; +}> { + interface PendingImage { + id: string; + path: string; + ext: string; + base64Promise: Promise; + } + const pending: PendingImage[] = []; + const imagePositions: Record = {}; + 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 = {}; + 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([]); const [activeSheet, setActiveSheet] = useState(''); const [excelData, setExcelData] = useState>({}); - const [sheetMaps, setSheetMaps] = useState>({}); const [images, setImages] = useState([]); - const [sheetImages, setSheetImages] = useState({}); + const [imagePositions, setImagePositions] = useState>({}); const tableContainerRef = useRef(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(); - 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 = {}; - const parsedMaps: Record = {}; 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 ( -
- -
- ); - } return (
{t('currentSheetNoData')} @@ -417,12 +558,6 @@ export function ExcelPreview({ filePath, fileExt: fileExtProp, loadingIcon }: Ex })} - - {looseImages.length > 0 && ( -
- -
- )}
diff --git a/src/frontend/packages/file-viewers/src/ImageGallery.tsx b/src/frontend/packages/file-viewers/src/ImageGallery.tsx deleted file mode 100644 index 6d44cc302..000000000 --- a/src/frontend/packages/file-viewers/src/ImageGallery.tsx +++ /dev/null @@ -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 ( -
- {title ?
{title}
: null} - {items.map(({ image, anchor }, index) => ( - {`${t('imageRef')} - ))} -
- ); -} diff --git a/src/frontend/packages/file-viewers/src/sheetUtils.ts b/src/frontend/packages/file-viewers/src/sheetUtils.ts deleted file mode 100644 index 32137a328..000000000 --- a/src/frontend/packages/file-viewers/src/sheetUtils.ts +++ /dev/null @@ -1,112 +0,0 @@ -import type { CleanedSheet, SheetData } from './types'; - -const VALID_EXTENSIONS = ['csv', 'xlsx', 'xls', 'et', 'txt']; - -export const MIME_TYPES: Record = { - 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)); -} diff --git a/src/frontend/packages/file-viewers/src/types.ts b/src/frontend/packages/file-viewers/src/types.ts deleted file mode 100644 index 1a7a93a25..000000000 --- a/src/frontend/packages/file-viewers/src/types.ts +++ /dev/null @@ -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; - -/** - * 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; -} diff --git a/src/frontend/packages/file-viewers/src/xlsxImages.ts b/src/frontend/packages/file-viewers/src/xlsxImages.ts deleted file mode 100644 index 72569bdb9..000000000 --- a/src/frontend/packages/file-viewers/src/xlsxImages.ts +++ /dev/null @@ -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; -} - -/** 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 { - 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> { - const map: Record = {}; - 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): Promise { - 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 }> = []; - 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 }; -} diff --git a/src/frontend/packages/locales/src/shared/en.json b/src/frontend/packages/locales/src/shared/en.json index d59e27053..d512cbc24 100644 --- a/src/frontend/packages/locales/src/shared/en.json +++ b/src/frontend/packages/locales/src/shared/en.json @@ -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" } } } diff --git a/src/frontend/packages/locales/src/shared/ja.json b/src/frontend/packages/locales/src/shared/ja.json index 37cc9c6fc..a2badfa83 100644 --- a/src/frontend/packages/locales/src/shared/ja.json +++ b/src/frontend/packages/locales/src/shared/ja.json @@ -15,8 +15,7 @@ "previewFailed": "プレビュー失敗", "downloadOriginal": "元ファイルをダウンロード", "imageLoadFailed": "画像の読み込みに失敗しました", - "imageRef": "画像参照", - "imagesOutsideTable": "表領域外の画像" + "imageRef": "画像参照" } } } diff --git a/src/frontend/packages/locales/src/shared/zh-Hans.json b/src/frontend/packages/locales/src/shared/zh-Hans.json index b7363272a..15060c74d 100644 --- a/src/frontend/packages/locales/src/shared/zh-Hans.json +++ b/src/frontend/packages/locales/src/shared/zh-Hans.json @@ -15,8 +15,7 @@ "previewFailed": "预览失败", "downloadOriginal": "下载原始文件", "imageLoadFailed": "图片加载失败", - "imageRef": "图片引用", - "imagesOutsideTable": "表格区域外的图片" + "imageRef": "图片引用" } } } diff --git a/src/frontend/platform/public/locales/en-US/shared.json b/src/frontend/platform/public/locales/en-US/shared.json index d59e27053..d512cbc24 100644 --- a/src/frontend/platform/public/locales/en-US/shared.json +++ b/src/frontend/platform/public/locales/en-US/shared.json @@ -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" } } } diff --git a/src/frontend/platform/public/locales/ja/shared.json b/src/frontend/platform/public/locales/ja/shared.json index 37cc9c6fc..a2badfa83 100644 --- a/src/frontend/platform/public/locales/ja/shared.json +++ b/src/frontend/platform/public/locales/ja/shared.json @@ -15,8 +15,7 @@ "previewFailed": "プレビュー失敗", "downloadOriginal": "元ファイルをダウンロード", "imageLoadFailed": "画像の読み込みに失敗しました", - "imageRef": "画像参照", - "imagesOutsideTable": "表領域外の画像" + "imageRef": "画像参照" } } } diff --git a/src/frontend/platform/public/locales/zh-Hans/shared.json b/src/frontend/platform/public/locales/zh-Hans/shared.json index b7363272a..15060c74d 100644 --- a/src/frontend/platform/public/locales/zh-Hans/shared.json +++ b/src/frontend/platform/public/locales/zh-Hans/shared.json @@ -15,8 +15,7 @@ "previewFailed": "预览失败", "downloadOriginal": "下载原始文件", "imageLoadFailed": "图片加载失败", - "imageRef": "图片引用", - "imagesOutsideTable": "表格区域外的图片" + "imageRef": "图片引用" } } }