From ea3e1c98bbba95933508ea49a69d5b8a1e7c84f6 Mon Sep 17 00:00:00 2001 From: Caprika <56862773+alchemistklk@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:18:08 +0800 Subject: [PATCH] Fix document previews for uploaded files (#63) * feat(files): preview uploaded documents * fix(files): harden document preview --- daemon/document-preview.js | 293 ++++++++++++++++++++++++++++ daemon/projects.js | 8 + daemon/server.js | 12 ++ package-lock.json | 43 +++- package.json | 1 + src/components/DesignFilesPanel.tsx | 14 ++ src/components/FileViewer.tsx | 133 +++++++++++-- src/components/FileWorkspace.tsx | 2 +- src/i18n/locales/en.ts | 9 + src/i18n/locales/zh-CN.ts | 9 + src/i18n/types.ts | 9 + src/index.css | 28 +++ src/providers/registry.ts | 26 +++ src/types.ts | 4 + 14 files changed, 571 insertions(+), 20 deletions(-) create mode 100644 daemon/document-preview.js diff --git a/daemon/document-preview.js b/daemon/document-preview.js new file mode 100644 index 0000000000..7d9c2f3be4 --- /dev/null +++ b/daemon/document-preview.js @@ -0,0 +1,293 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import JSZip from 'jszip'; +import { kindFor } from './projects.js'; + +const execFileP = promisify(execFile); +const MAX_COMPRESSED_PREVIEW_BYTES = 10 * 1024 * 1024; +const MAX_UNCOMPRESSED_PREVIEW_BYTES = 50 * 1024 * 1024; +const MAX_XML_ENTRY_BYTES = 5 * 1024 * 1024; +const MAX_PDF_PREVIEW_CONCURRENCY = 2; +const pdfPreviewQueue = createLimiter(MAX_PDF_PREVIEW_CONCURRENCY); + +export async function buildDocumentPreview(file) { + const kind = kindFor(file.name); + if (!['pdf', 'document', 'presentation', 'spreadsheet'].includes(kind)) { + const err = new Error('unsupported preview type'); + err.statusCode = 415; + throw err; + } + + if (kind === 'pdf') { + return { + kind, + title: path.basename(file.name), + sections: await pdfPreviewQueue(() => previewPdf(file.buffer)), + }; + } + + assertPreviewInputSize(file.buffer.length); + const zip = await JSZip.loadAsync(file.buffer); + assertZipPreviewSize(zip); + if (kind === 'document') { + return { + kind, + title: path.basename(file.name), + sections: await previewDocx(zip), + }; + } + if (kind === 'presentation') { + return { + kind, + title: path.basename(file.name), + sections: await previewPptx(zip), + }; + } + return { + kind, + title: path.basename(file.name), + sections: await previewXlsx(zip), + }; +} + +async function previewPdf(buffer) { + assertPreviewInputSize(buffer.length); + const tmpDir = await mkdtemp(path.join(tmpdir(), 'od-preview-')); + const tmpFile = path.join(tmpDir, 'input.pdf'); + await writeFile(tmpFile, buffer, { flag: 'wx' }); + try { + const { stdout } = await execFileP('pdftotext', ['-layout', tmpFile, '-'], { + timeout: 5000, + maxBuffer: 2 * 1024 * 1024, + }); + const lines = stdout + .split(/\r?\n/) + .map((line) => line.trimEnd()) + .filter((line) => line.trim().length > 0); + return [ + { + title: 'PDF', + lines: lines.length > 0 ? lines : ['No readable text found.'], + }, + ]; + } catch { + return [ + { + title: 'PDF', + lines: ['Text preview is unavailable. Use Open or Download to inspect the PDF.'], + }, + ]; + } finally { + rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } +} + +async function previewDocx(zip) { + const xml = await readZipText(zip, 'word/document.xml'); + const paragraphs = extractParagraphs(xml, //g); + return [ + { + title: 'Document', + lines: paragraphs.length > 0 ? paragraphs : ['No readable text found.'], + }, + ]; +} + +async function previewPptx(zip) { + const slideNames = Object.keys(zip.files) + .filter((name) => /^ppt\/slides\/slide\d+\.xml$/i.test(name)) + .sort(numericPathSort); + const sections = []; + for (let i = 0; i < slideNames.length; i += 1) { + const xml = await readZipText(zip, slideNames[i]); + const lines = extractTextRuns(xml); + sections.push({ + title: `Slide ${i + 1}`, + lines: lines.length > 0 ? lines : ['No readable text found.'], + }); + } + return sections.length > 0 + ? sections + : [{ title: 'Presentation', lines: ['No readable slides found.'] }]; +} + +async function previewXlsx(zip) { + const sharedStrings = await readSharedStrings(zip); + const workbook = await readWorkbook(zip); + const sections = []; + for (const sheet of workbook) { + const xml = await readZipText(zip, sheet.path).catch(() => ''); + const lines = extractWorksheetRows(xml, sharedStrings); + sections.push({ + title: sheet.name, + lines: lines.length > 0 ? lines : ['No readable cell values found.'], + }); + } + return sections.length > 0 + ? sections + : [{ title: 'Spreadsheet', lines: ['No readable sheets found.'] }]; +} + +async function readSharedStrings(zip) { + const xml = await readZipText(zip, 'xl/sharedStrings.xml').catch(() => ''); + if (!xml) return []; + return Array.from(xml.matchAll(//g)).map((m) => + extractTextRuns(m[0]).join(''), + ); +} + +async function readWorkbook(zip) { + const workbookXml = await readZipText(zip, 'xl/workbook.xml').catch(() => ''); + const relsXml = await readZipText(zip, 'xl/_rels/workbook.xml.rels').catch(() => ''); + const rels = new Map(); + for (const rel of relsXml.matchAll(/]*)\/?>/g)) { + const attrs = parseAttrs(rel[1]); + if (attrs.Id && attrs.Target) rels.set(attrs.Id, attrs.Target); + } + const sheets = []; + for (const sheet of workbookXml.matchAll(/]*)\/?>/g)) { + const attrs = parseAttrs(sheet[1]); + const relId = attrs['r:id']; + const target = relId ? rels.get(relId) : null; + if (!target) continue; + sheets.push({ + name: attrs.name || `Sheet ${sheets.length + 1}`, + path: `xl/${target.replace(/^\/?xl\//, '')}`, + }); + } + if (sheets.length > 0) return sheets; + return Object.keys(zip.files) + .filter((name) => /^xl\/worksheets\/sheet\d+\.xml$/i.test(name)) + .sort(numericPathSort) + .map((name, i) => ({ name: `Sheet ${i + 1}`, path: name })); +} + +function extractWorksheetRows(xml, sharedStrings) { + const rows = []; + for (const row of xml.matchAll(//g)) { + const values = []; + for (const cell of row[0].matchAll(/]*)>([\s\S]*?)<\/c>/g)) { + const attrs = parseAttrs(cell[1]); + const body = cell[2]; + let value = ''; + if (attrs.t === 's') { + const idx = Number(extractFirst(body, /([\s\S]*?)<\/v>/)); + value = Number.isInteger(idx) ? sharedStrings[idx] ?? '' : ''; + } else if (attrs.t === 'inlineStr') { + value = extractTextRuns(body).join(''); + } else { + value = decodeXml(extractFirst(body, /([\s\S]*?)<\/v>/)); + } + if (value.trim()) values.push(value.trim()); + } + if (values.length > 0) rows.push(values.join(' | ')); + } + return rows; +} + +function extractParagraphs(xml, paragraphPattern) { + return Array.from(xml.matchAll(paragraphPattern)) + .map((m) => extractTextRuns(m[0]).join(' ').replace(/\s+/g, ' ').trim()) + .filter(Boolean); +} + +function extractTextRuns(xml) { + return Array.from(xml.matchAll(/]*>([\s\S]*?)<\/a:t>|]*>([\s\S]*?)<\/w:t>|]*>([\s\S]*?)<\/t>/g)) + .map((m) => decodeXml(m[1] ?? m[2] ?? m[3] ?? '').trim()) + .filter(Boolean); +} + +async function readZipText(zip, name) { + const entry = zip.file(name); + if (!entry) throw new Error(`missing ${name}`); + const size = entry._data?.uncompressedSize ?? 0; + if (size > MAX_XML_ENTRY_BYTES) { + const err = new Error('document section too large to preview'); + err.statusCode = 413; + throw err; + } + const xml = await entry.async('text'); + assertSafeXml(xml); + return xml; +} + +function parseAttrs(raw) { + const attrs = {}; + for (const m of raw.matchAll(/([\w:-]+)="([^"]*)"/g)) { + attrs[m[1]] = decodeXml(m[2]); + } + return attrs; +} + +function extractFirst(raw, pattern) { + const m = raw.match(pattern); + return m ? m[1] ?? '' : ''; +} + +function decodeXml(raw) { + return String(raw) + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&'); +} + +function assertPreviewInputSize(size) { + if (size > MAX_COMPRESSED_PREVIEW_BYTES) { + const err = new Error('document too large to preview'); + err.statusCode = 413; + throw err; + } +} + +function assertZipPreviewSize(zip) { + let total = 0; + for (const entry of Object.values(zip.files)) { + total += entry._data?.uncompressedSize ?? 0; + if (total > MAX_UNCOMPRESSED_PREVIEW_BYTES) { + const err = new Error('document too large to preview'); + err.statusCode = 413; + throw err; + } + } +} + +function assertSafeXml(xml) { + if (/ { + if (active >= limit || pending.length === 0) return; + active += 1; + const { task, resolve, reject } = pending.shift(); + Promise.resolve() + .then(task) + .then(resolve, reject) + .finally(() => { + active -= 1; + runNext(); + }); + }; + return (task) => + new Promise((resolve, reject) => { + pending.push({ task, resolve, reject }); + runNext(); + }); +} + +function numericPathSort(a, b) { + const an = Number(a.match(/(\d+)(?=\.xml$)/)?.[1] ?? 0); + const bn = Number(b.match(/(\d+)(?=\.xml$)/)?.[1] ?? 0); + return an - bn || a.localeCompare(b); +} diff --git a/daemon/projects.js b/daemon/projects.js index b09d5a3cdf..bc798a0d00 100644 --- a/daemon/projects.js +++ b/daemon/projects.js @@ -181,6 +181,10 @@ const EXT_MIME = { '.json': 'application/json; charset=utf-8', '.md': 'text/markdown; charset=utf-8', '.txt': 'text/plain; charset=utf-8', + '.pdf': 'application/pdf', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', @@ -211,5 +215,9 @@ export function kindFor(name) { if (['.js', '.mjs', '.cjs', '.ts', '.tsx', '.json', '.css'].includes(ext)) { return 'code'; } + if (ext === '.pdf') return 'pdf'; + if (ext === '.docx') return 'document'; + if (ext === '.pptx') return 'presentation'; + if (ext === '.xlsx') return 'spreadsheet'; return 'binary'; } diff --git a/daemon/server.js b/daemon/server.js index f45fbc6006..3aa4cb21a3 100644 --- a/daemon/server.js +++ b/daemon/server.js @@ -20,6 +20,7 @@ import { createCopilotStreamHandler } from './copilot-stream.js'; import { renderDesignSystemPreview } from './design-system-preview.js'; import { renderDesignSystemShowcase } from './design-system-showcase.js'; import { importClaudeDesignZip } from './claude-design-import.js'; +import { buildDocumentPreview } from './document-preview.js'; import { lintArtifact, renderFindingsForAgent } from './lint-artifact.js'; import { deleteProjectFile, @@ -731,6 +732,17 @@ export async function startServer({ port = 7456 } = {}) { } }); + app.get('/api/projects/:id/files/:name/preview', async (req, res) => { + try { + const file = await readProjectFile(PROJECTS_DIR, req.params.id, req.params.name); + const preview = await buildDocumentPreview(file); + res.json(preview); + } catch (err) { + const status = err && err.statusCode ? err.statusCode : err && err.code === 'ENOENT' ? 404 : 400; + res.status(status).json({ error: err?.message || 'preview unavailable' }); + } + }); + app.get('/api/projects/:id/files/:name', async (req, res) => { try { const file = await readProjectFile(PROJECTS_DIR, req.params.id, req.params.name); diff --git a/package-lock.json b/package-lock.json index 60fa789b05..8f26fca37e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,10 +7,12 @@ "": { "name": "open-design", "version": "0.1.0", + "license": "Apache-2.0", "dependencies": { "@anthropic-ai/sdk": "^0.32.1", "better-sqlite3": "^11.10.0", "express": "^4.19.2", + "jszip": "^3.10.1", "multer": "^1.4.5-lts.1", "react": "^18.3.1", "react-dom": "^18.3.1" @@ -27,7 +29,7 @@ "vite": "^5.4.11" }, "engines": { - "node": ">=18" + "node": ">=20 <23" } }, "node_modules/@anthropic-ai/sdk": { @@ -2382,6 +2384,12 @@ ], "license": "BSD-3-Clause" }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2451,6 +2459,27 @@ "node": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -2753,6 +2782,12 @@ "wrappy": "1" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -3134,6 +3169,12 @@ "node": ">= 0.8.0" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", diff --git a/package.json b/package.json index b0ef53fa21..7c4b48a14a 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@anthropic-ai/sdk": "^0.32.1", "better-sqlite3": "^11.10.0", "express": "^4.19.2", + "jszip": "^3.10.1", "multer": "^1.4.5-lts.1", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/src/components/DesignFilesPanel.tsx b/src/components/DesignFilesPanel.tsx index 0239f4ae71..3e56752049 100644 --- a/src/components/DesignFilesPanel.tsx +++ b/src/components/DesignFilesPanel.tsx @@ -356,6 +356,12 @@ function sectionFor(file: ProjectFile): Section { if (file.kind === 'sketch') return 'sketches'; if (file.kind === 'code') return 'scripts'; if (file.kind === 'image') return 'images'; + if ( + file.kind === 'pdf' || + file.kind === 'document' || + file.kind === 'presentation' || + file.kind === 'spreadsheet' + ) return 'pages'; return 'other'; } @@ -365,6 +371,10 @@ function kindGlyph(kind: ProjectFileKind): string { if (kind === 'sketch') return '✎'; if (kind === 'text') return '¶'; if (kind === 'code') return '{}'; + if (kind === 'pdf') return 'PDF'; + if (kind === 'document') return 'DOC'; + if (kind === 'presentation') return 'PPT'; + if (kind === 'spreadsheet') return 'XLS'; return '·'; } @@ -374,6 +384,10 @@ function kindLabel(kind: ProjectFileKind, t: TranslateFn): string { if (kind === 'sketch') return t('designFiles.kindSketch'); if (kind === 'text') return t('designFiles.kindText'); if (kind === 'code') return t('designFiles.kindCode'); + if (kind === 'pdf') return t('designFiles.kindPdf'); + if (kind === 'document') return t('designFiles.kindDocument'); + if (kind === 'presentation') return t('designFiles.kindPresentation'); + if (kind === 'spreadsheet') return t('designFiles.kindSpreadsheet'); return t('designFiles.kindBinary'); } diff --git a/src/components/FileViewer.tsx b/src/components/FileViewer.tsx index 20a90222b1..810257351d 100644 --- a/src/components/FileViewer.tsx +++ b/src/components/FileViewer.tsx @@ -1,12 +1,21 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useT } from '../i18n'; -import { fetchProjectFileText, projectFileUrl, projectRawUrl } from '../providers/registry'; +import type { Dict } from '../i18n/types'; +import { + fetchProjectFilePreview, + fetchProjectFileText, + projectFileUrl, + projectRawUrl, +} from '../providers/registry'; +import type { ProjectFilePreview } from '../providers/registry'; import { exportAsHtml, exportAsPdf, exportAsZip } from '../runtime/exports'; import { buildSrcdoc } from '../runtime/srcdoc'; import { saveTemplate } from '../state/projects'; import type { ProjectFile } from '../types'; import { Icon } from './Icon'; +type TranslateFn = (key: keyof Dict, vars?: Record) => string; + interface Props { projectId: string; file: ProjectFile; @@ -45,9 +54,46 @@ export function FileViewer({ if (file.kind === 'text' || file.kind === 'code') { return ; } + if ( + file.kind === 'pdf' || + file.kind === 'document' || + file.kind === 'presentation' || + file.kind === 'spreadsheet' + ) { + return ; + } return ; } +function FileActions({ + projectId, + file, +}: { + projectId: string; + file: ProjectFile; +}) { + const t = useT(); + return ( + + ); +} + function BinaryViewer({ projectId, file, @@ -64,23 +110,7 @@ function BinaryViewer({ {t('fileViewer.binaryMeta', { size: humanSize(file.size) })} - +
@@ -91,6 +121,65 @@ function BinaryViewer({ ); } +function DocumentPreviewViewer({ + projectId, + file, +}: { + projectId: string; + file: ProjectFile; +}) { + const t = useT(); + const [preview, setPreview] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setPreview(null); + void fetchProjectFilePreview(projectId, file.name).then((next) => { + if (!cancelled) { + setPreview(next); + setLoading(false); + } + }); + return () => { + cancelled = true; + }; + }, [projectId, file.name, file.mtime]); + + return ( +
+
+
+ + {documentMetaLabel(file, t)} · {humanSize(file.size)} + +
+ +
+
+ {loading ? ( +
{t('fileViewer.loading')}
+ ) : preview ? ( +
+

{preview.title}

+ {preview.sections.map((section, idx) => ( +
+

{section.title}

+ {section.lines.map((line, lineIdx) => ( +

{line}

+ ))} +
+ ))} +
+ ) : ( +
{t('fileViewer.previewUnavailable')}
+ )} +
+
+ ); +} + function HtmlViewer({ projectId, file, @@ -801,3 +890,11 @@ function humanSize(bytes: number): string { if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / 1024 / 1024).toFixed(1)} MB`; } + +function documentMetaLabel(file: ProjectFile, t: TranslateFn): string { + if (file.kind === 'pdf') return t('fileViewer.pdfMeta'); + if (file.kind === 'document') return t('fileViewer.documentMeta'); + if (file.kind === 'presentation') return t('fileViewer.presentationMeta'); + if (file.kind === 'spreadsheet') return t('fileViewer.spreadsheetMeta'); + return t('fileViewer.binaryMeta', { size: humanSize(file.size) }); +} diff --git a/src/components/FileWorkspace.tsx b/src/components/FileWorkspace.tsx index 8c12879506..1f872991c2 100644 --- a/src/components/FileWorkspace.tsx +++ b/src/components/FileWorkspace.tsx @@ -446,7 +446,7 @@ function Tab({ onActivate: () => void; onClose?: () => void; closable?: boolean; - kind?: 'html' | 'image' | 'sketch' | 'text' | 'code' | 'binary'; + kind?: ProjectFile['kind']; }) { const t = useT(); const iconName = kindIconName(kind); diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index c992b811b8..daff6e0bc7 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -337,6 +337,10 @@ export const en: Dict = { 'designFiles.kindSketch': 'Sketch', 'designFiles.kindText': 'Text', 'designFiles.kindCode': 'Script', + 'designFiles.kindPdf': 'PDF', + 'designFiles.kindDocument': 'Document', + 'designFiles.kindPresentation': 'Presentation', + 'designFiles.kindSpreadsheet': 'Spreadsheet', 'designFiles.kindBinary': 'Binary', 'pasteDialog.title': 'Paste text', 'pasteDialog.hint': 'Saved into the project folder. Pick any name.', @@ -361,6 +365,11 @@ export const en: Dict = { 'fileViewer.binaryMeta': 'Binary · {size}', 'fileViewer.binaryNote': 'Binary file ({size} bytes). Download or open from disk to inspect.', + 'fileViewer.pdfMeta': 'PDF · {size}', + 'fileViewer.documentMeta': 'Document', + 'fileViewer.presentationMeta': 'Presentation', + 'fileViewer.spreadsheetMeta': 'Spreadsheet', + 'fileViewer.previewUnavailable': 'Preview unavailable. Download or open the file to inspect.', 'fileViewer.download': 'Download', 'fileViewer.open': 'Open', 'fileViewer.imageMeta': 'Image · {size}', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index ae47c0363f..7d00e834bb 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -329,6 +329,10 @@ export const zhCN: Dict = { 'designFiles.kindSketch': '草图', 'designFiles.kindText': '文本', 'designFiles.kindCode': '脚本', + 'designFiles.kindPdf': 'PDF', + 'designFiles.kindDocument': '文档', + 'designFiles.kindPresentation': '演示文稿', + 'designFiles.kindSpreadsheet': '电子表格', 'designFiles.kindBinary': '二进制', 'pasteDialog.title': '粘贴文本', 'pasteDialog.hint': '将保存到项目文件夹中,名称随你定。', @@ -352,6 +356,11 @@ export const zhCN: Dict = { 'fileViewer.share': '分享', 'fileViewer.binaryMeta': '二进制 · {size}', 'fileViewer.binaryNote': '二进制文件({size} 字节)。请下载或在本地打开查看。', + 'fileViewer.pdfMeta': 'PDF · {size}', + 'fileViewer.documentMeta': '文档', + 'fileViewer.presentationMeta': '演示文稿', + 'fileViewer.spreadsheetMeta': '电子表格', + 'fileViewer.previewUnavailable': '无法生成预览,请下载或打开文件查看。', 'fileViewer.download': '下载', 'fileViewer.open': '打开', 'fileViewer.imageMeta': '图片 · {size}', diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 6af00d4881..70d584ab4e 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -344,6 +344,10 @@ export interface Dict { 'designFiles.kindSketch': string; 'designFiles.kindText': string; 'designFiles.kindCode': string; + 'designFiles.kindPdf': string; + 'designFiles.kindDocument': string; + 'designFiles.kindPresentation': string; + 'designFiles.kindSpreadsheet': string; 'designFiles.kindBinary': string; 'pasteDialog.title': string; 'pasteDialog.hint': string; @@ -367,6 +371,11 @@ export interface Dict { 'fileViewer.share': string; 'fileViewer.binaryMeta': string; 'fileViewer.binaryNote': string; + 'fileViewer.pdfMeta': string; + 'fileViewer.documentMeta': string; + 'fileViewer.presentationMeta': string; + 'fileViewer.spreadsheetMeta': string; + 'fileViewer.previewUnavailable': string; 'fileViewer.download': string; 'fileViewer.open': string; 'fileViewer.imageMeta': string; diff --git a/src/index.css b/src/index.css index 520ad62444..0589ce6fec 100644 --- a/src/index.css +++ b/src/index.css @@ -2621,6 +2621,34 @@ code { color: var(--text-muted); font-size: 13px; } +.document-preview { + max-width: 860px; + margin: 0 auto; + padding: 32px 40px 56px; + color: var(--text); +} +.document-preview h2 { + margin: 0 0 24px; + font-size: 20px; + line-height: 1.25; +} +.document-preview section { + border-top: 1px solid var(--border-soft); + padding-top: 18px; + margin-top: 18px; +} +.document-preview h3 { + margin: 0 0 12px; + font-size: 13px; + color: var(--text-muted); + font-weight: 600; +} +.document-preview p { + margin: 0 0 8px; + font-size: 14px; + line-height: 1.65; + white-space: pre-wrap; +} .image-body { display: flex; align-items: center; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index e824b02a7c..7fe47d05d8 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -97,6 +97,32 @@ export function projectFileUrl(projectId: string, name: string): string { return projectRawUrl(projectId, name); } +export interface ProjectFilePreviewSection { + title: string; + lines: string[]; +} + +export interface ProjectFilePreview { + kind: 'pdf' | 'document' | 'presentation' | 'spreadsheet'; + title: string; + sections: ProjectFilePreviewSection[]; +} + +export async function fetchProjectFilePreview( + projectId: string, + name: string, +): Promise { + try { + const resp = await fetch( + `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(name)}/preview`, + ); + if (!resp.ok) return null; + return (await resp.json()) as ProjectFilePreview; + } catch { + return null; + } +} + export async function fetchProjectFileText( projectId: string, name: string, diff --git a/src/types.ts b/src/types.ts index 03433148dc..a9b0d9c02d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -149,6 +149,10 @@ export type ProjectFileKind = | 'sketch' | 'text' | 'code' + | 'pdf' + | 'document' + | 'presentation' + | 'spreadsheet' | 'binary'; export interface ProjectFile {