From b0c7670c7aea6ba195bf858dd5e6ba10d1782b7c Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Mon, 26 Oct 2026 10:50:46 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=96=BC=EF=B8=8F=20fix:=20Preview=20Oversi?= =?UTF-8?q?zed=20LibreOffice=20PDFs=20from=20File=20Storage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A LibreOffice preview embeds its PDF in the stored preview HTML, so any PDF over ~380 KB (the 512 KB attachment.text cap after base64) was dropped silently. A deck with a handful of chart images crosses that, and users got the text-only slide list instead of the rendered slides. When storage-backed office previews are on and the PDF is too big to embed, store a PDF shell instead: the same file-shell markers plus a `lc-doc-format=pdf` marker and the empty data slot. The viewer fills it from the existing download routes with `?preview=pdf`, which convert the stored file on demand (same auth, same size limit as the file shell). - tryLibreOfficePreview returns the PDF shell over the cap when asked - officeFileToPdf / sendOfficePdfPreview serve `?preview=pdf` on the owner and shared-link download routes - useOfficeFileShell requests the PDF for a PDF shell Co-authored-by: Claude --- api/server/routes/__tests__/share.spec.js | 24 ++++++ api/server/routes/files/files.js | 12 ++- api/server/routes/files/files.test.js | 34 ++++++++ api/server/routes/share.js | 13 ++- client/src/data-provider/Files/queries.ts | 14 ++- .../__tests__/useOfficeFileShell.test.ts | 16 +++- .../src/hooks/Artifacts/useOfficeFileShell.ts | 6 +- packages/api/src/files/code/extract.spec.ts | 33 +++++-- packages/api/src/files/code/extract.ts | 17 ++-- packages/api/src/files/documents/html.ts | 19 ++-- .../src/files/documents/libreoffice.spec.ts | 58 +++++++++++++ .../api/src/files/documents/libreoffice.ts | 36 ++++++-- .../api/src/files/documents/pdfPreview.ts | 86 +++++++++++++++++++ packages/api/src/files/index.ts | 1 + packages/data-provider/src/data-service.ts | 9 +- .../data-provider/src/file-config.spec.ts | 10 +++ packages/data-provider/src/file-config.ts | 5 ++ 17 files changed, 357 insertions(+), 36 deletions(-) create mode 100644 packages/api/src/files/documents/pdfPreview.ts diff --git a/api/server/routes/__tests__/share.spec.js b/api/server/routes/__tests__/share.spec.js index 4084649e405..807c59672f3 100644 --- a/api/server/routes/__tests__/share.spec.js +++ b/api/server/routes/__tests__/share.spec.js @@ -79,6 +79,7 @@ const mockCreateShareContentPreflight = jest.fn((filters, options = {}) => { jest.mock('@librechat/api', () => ({ resolveDownloadPath: (file) => file.storageKey || file.filepath, + sendOfficePdfPreview: jest.fn(async ({ res }) => res.status(200).send('PDF-PREVIEW')), assertModelBoundContent: (...args) => mockAssertModelBoundContent(...args), assertSharedFileMetadataAllowed: (...args) => mockAssertSharedFileMetadataAllowed(...args), createShareContentPreflight: (...args) => mockCreateShareContentPreflight(...args), @@ -1811,6 +1812,29 @@ describe('share-scoped file routes', () => { expect(response.headers['x-content-type-options']).toBe('nosniff'); }); + it('serves a PDF preview of a snapshotted file when preview=pdf is requested', async () => { + const getDownloadStream = jest.fn(async () => Readable.from(['deck'])); + mockGetStrategyFunctions.mockReturnValue({ getDownloadStream }); + getSharedLinkFile.mockResolvedValue({ + file: { + file_id: 'file-1', + source: 'local', + filepath: '/uploads/owner/deck.pptx', + type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + filename: 'deck.pptx', + }, + hasSnapshots: true, + }); + + const response = await request(buildApp()).get( + '/api/share/share-123/files/file-1/download?preview=pdf', + ); + + expect(response.status).toBe(200); + expect(response.text).toBe('PDF-PREVIEW'); + expect(getDownloadStream).toHaveBeenCalled(); + }); + it('downloads a snapshotted file as an attachment', async () => { getSharedLinkFile.mockResolvedValue({ file: { diff --git a/api/server/routes/files/files.js b/api/server/routes/files/files.js index c7fbab4c8a8..e0dd94bb70e 100644 --- a/api/server/routes/files/files.js +++ b/api/server/routes/files/files.js @@ -23,6 +23,7 @@ const { checkToolResourceUploadPermission, resolveAssistantToolPermissions, resolveDownloadPath, + sendOfficePdfPreview, } = require('@librechat/api'); const { Time, @@ -714,7 +715,7 @@ router.get('/download/:userId/:file_id', fileAccess, async (req, res) => { stream.pipe(res); } else { - if (getDownloadURL && req.query.direct === 'true') { + if (getDownloadURL && req.query.direct === 'true' && req.query.preview !== 'pdf') { try { const downloadURL = await getDirectDownloadURL({ req, file }); if (downloadURL) { @@ -738,6 +739,15 @@ router.get('/download/:userId/:file_id', fileAccess, async (req, res) => { const fileStream = await getDownloadStream(req, resolveDownloadPath(file)); + if (req.query.preview === 'pdf') { + return await sendOfficePdfPreview({ + res, + stream: fileStream, + file, + fileConfig: req.config?.fileConfig, + }); + } + fileStream.on('error', (streamError) => { logger.error('[DOWNLOAD ROUTE] Stream error:', streamError); if (res.headersSent) { diff --git a/api/server/routes/files/files.test.js b/api/server/routes/files/files.test.js index 8a972484550..e55ff0d2d60 100644 --- a/api/server/routes/files/files.test.js +++ b/api/server/routes/files/files.test.js @@ -45,6 +45,7 @@ jest.mock('sharp', () => jest.mock('@librechat/api', () => ({ ...jest.requireActual('@librechat/api'), refreshS3FileUrls: jest.fn(), + sendOfficePdfPreview: jest.fn(async ({ res }) => res.status(200).send('PDF-PREVIEW')), getCodeExecutionBaseUrl: jest.fn((profile, environment) => { if (environment?.baseURL) { return environment.baseURL; @@ -1178,6 +1179,39 @@ describe('File Routes - Delete with Agent Access', () => { ); }); + it('serves a PDF preview instead of redirecting when preview=pdf is requested', async () => { + const { sendOfficePdfPreview } = require('@librechat/api'); + const userFileId = uuidv4(); + const getDownloadURL = jest + .fn() + .mockResolvedValue('https://cdn.example.com/deck.pptx?signed'); + const getDownloadStream = jest.fn().mockResolvedValue(Readable.from(['deck'])); + getStrategyFunctions.mockReturnValue({ getDownloadURL, getDownloadStream }); + + await createFile({ + user: otherUserId, + file_id: userFileId, + filename: 'deck.pptx', + filepath: 'uploads/user/deck.pptx', + bytes: 200, + type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + source: FileSources.cloudfront, + }); + + const response = await request(app).get( + `/files/download/${otherUserId}/${userFileId}?direct=true&preview=pdf`, + ); + + expect(response.status).toBe(200); + expect(response.text).toBe('PDF-PREVIEW'); + expect(getDownloadURL).not.toHaveBeenCalled(); + expect(sendOfficePdfPreview).toHaveBeenCalledWith( + expect.objectContaining({ + file: expect.objectContaining({ filename: 'deck.pptx' }), + }), + ); + }); + it('redirects to a direct signed download URL when explicitly requested', async () => { const userFileId = uuidv4(); const getDownloadURL = jest.fn().mockResolvedValue('https://cdn.example.com/file.pdf?signed'); diff --git a/api/server/routes/share.js b/api/server/routes/share.js index 3cd5761cb19..9549c0a82f8 100644 --- a/api/server/routes/share.js +++ b/api/server/routes/share.js @@ -26,6 +26,7 @@ const { recordShareLinkRejection, traceIdForMessage, resolveDownloadPath, + sendOfficePdfPreview, } = require('@librechat/api'); const { logger, @@ -276,8 +277,9 @@ const streamSharedFile = async (req, res, file, requestedDisposition) => { // Redirect to a signed storage URL only when explicitly requested (?direct=true); // by default stream through the server so blob (XHR) callers work without bucket CORS. + const isPdfPreview = req.query.preview === 'pdf'; const isDirectSource = source === FileSources.s3 || source === FileSources.cloudfront; - if (req.query.direct === 'true' && getDownloadURL && isDirectSource) { + if (req.query.direct === 'true' && getDownloadURL && isDirectSource && !isPdfPreview) { try { const url = await getDownloadURL({ req, @@ -303,6 +305,15 @@ const streamSharedFile = async (req, res, file, requestedDisposition) => { const streamPath = (resolveDownloadPath(file) || '').split('?')[0]; const fileStream = await getDownloadStream(req, streamPath); + if (isPdfPreview) { + return sendOfficePdfPreview({ + res, + stream: fileStream, + file, + fileConfig: req.config?.fileConfig, + }); + } + res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Content-Disposition', getContentDisposition(file.filename, disposition)); res.setHeader( diff --git a/client/src/data-provider/Files/queries.ts b/client/src/data-provider/Files/queries.ts index 3c455a4bef8..e4918122a1a 100644 --- a/client/src/data-provider/Files/queries.ts +++ b/client/src/data-provider/Files/queries.ts @@ -166,16 +166,24 @@ export const useFilePreviewBlob = ( userId?: string, fileId?: string, shareId?: string, + preview?: 'pdf', ): QueryObserverResult => useQuery( - [QueryKeys.fileDownload, 'previewBlob', shareId ? 'share' : 'owner', shareId ?? userId, fileId], + [ + QueryKeys.fileDownload, + 'previewBlob', + shareId ? 'share' : 'owner', + shareId ?? userId, + fileId, + preview ?? 'file', + ], async () => { if (!fileId || (!shareId && !userId)) { throw new Error('Preview identity unavailable'); } const response = shareId - ? await dataService.getSharedFileDownload(shareId, fileId) - : await dataService.getFileDownload(userId!, fileId); + ? await dataService.getSharedFileDownload(shareId, fileId, preview && { preview }) + : await dataService.getFileDownload(userId!, fileId, preview && { preview }); return response.data; }, { enabled: false, retry: false, cacheTime: 0 }, diff --git a/client/src/hooks/Artifacts/__tests__/useOfficeFileShell.test.ts b/client/src/hooks/Artifacts/__tests__/useOfficeFileShell.test.ts index 07dc4882e08..8e377aca36a 100644 --- a/client/src/hooks/Artifacts/__tests__/useOfficeFileShell.test.ts +++ b/client/src/hooks/Artifacts/__tests__/useOfficeFileShell.test.ts @@ -3,6 +3,7 @@ import { fillOfficeFileShell, OFFICE_DOC_DATA_SLOT, OFFICE_FILE_SHELL_MARKER, + OFFICE_PDF_SHELL_MARKER, } from 'librechat-data-provider'; import type { Artifact } from '~/common'; import useOfficeFileShell from '../useOfficeFileShell'; @@ -44,7 +45,7 @@ describe('useOfficeFileShell', () => { await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.content).toBe(fillOfficeFileShell(shell, 'QUJD')); expect(mockRefetch).toHaveBeenCalledTimes(1); - expect(mockUseFilePreviewBlob).toHaveBeenCalledWith('user-1', 'file-1', undefined); + expect(mockUseFilePreviewBlob).toHaveBeenCalledWith('user-1', 'file-1', undefined, undefined); }); it('passes the share id so shared viewers use the share-scoped route', async () => { @@ -52,7 +53,18 @@ describe('useOfficeFileShell', () => { mockRefetch.mockResolvedValue({ data: new Blob(['ABC']) }); const { result } = renderHook(() => useOfficeFileShell(shellArtifact)); await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(mockUseFilePreviewBlob).toHaveBeenCalledWith('user-1', 'file-1', 'share-9'); + expect(mockUseFilePreviewBlob).toHaveBeenCalledWith('user-1', 'file-1', 'share-9', undefined); + }); + + it('requests the converted PDF for a PDF shell', async () => { + mockRefetch.mockResolvedValue({ data: new Blob(['ABC']) }); + const pdfShell = shell.replace('', `${OFFICE_PDF_SHELL_MARKER}`); + const { result } = renderHook(() => + useOfficeFileShell({ ...shellArtifact, content: pdfShell }), + ); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(mockUseFilePreviewBlob).toHaveBeenCalledWith('user-1', 'file-1', undefined, 'pdf'); + expect(result.current.content).toBe(fillOfficeFileShell(pdfShell, 'QUJD')); }); it('keeps the stored shell when the fetch fails', async () => { diff --git a/client/src/hooks/Artifacts/useOfficeFileShell.ts b/client/src/hooks/Artifacts/useOfficeFileShell.ts index e67e2dcc8e6..fccabf01ca4 100644 --- a/client/src/hooks/Artifacts/useOfficeFileShell.ts +++ b/client/src/hooks/Artifacts/useOfficeFileShell.ts @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { fillOfficeFileShell, isOfficeFileShell } from 'librechat-data-provider'; +import { fillOfficeFileShell, isOfficeFileShell, isOfficePdfShell } from 'librechat-data-provider'; import type { Artifact } from '~/common'; import { useFilePreviewBlob } from '~/data-provider'; import { useShareContext } from '~/Providers'; @@ -25,16 +25,18 @@ export default function useOfficeFileShell(artifact: Artifact | null): { isLoading: boolean; } { const { shareId } = useShareContext(); + const stored = artifact?.content; + const preview = stored != null && isOfficePdfShell(stored) ? 'pdf' : undefined; const { refetch } = useFilePreviewBlob( artifact?.download?.user, artifact?.download?.file_id, shareId, + preview, ); const refetchRef = useRef(refetch); refetchRef.current = refetch; const [filled, setFilled] = useState(null); - const stored = artifact?.content; const isShell = stored != null && artifact?.download?.file_id != null && isOfficeFileShell(stored); diff --git a/packages/api/src/files/code/extract.spec.ts b/packages/api/src/files/code/extract.spec.ts index f83181e1151..326236f17a9 100644 --- a/packages/api/src/files/code/extract.spec.ts +++ b/packages/api/src/files/code/extract.spec.ts @@ -54,18 +54,27 @@ const mockOfficeHtml = jest.fn( async (_buffer: Buffer, _name: string, _mime: string) => null as string | null, ); const mockShellRender = jest.fn( - async (_buffer: Buffer, _options: { fileShell?: boolean }) => null as string | null, + async (_buffer: Buffer, _options: { fileShell?: boolean; pdfShell?: boolean }) => + null as string | null, ); +const mockInlineOptions = jest.fn((_options?: { pdfShell?: boolean }) => undefined); jest.mock('~/files/documents/html', () => { const actual = jest.requireActual('~/files/documents/html'); return { - pptxToHtml: (buffer: Buffer, options: { fileShell?: boolean }) => + pptxToHtml: (buffer: Buffer, options: { fileShell?: boolean; pdfShell?: boolean }) => mockShellRender(buffer, options), - wordDocToHtml: (buffer: Buffer, options: { fileShell?: boolean }) => + wordDocToHtml: (buffer: Buffer, options: { fileShell?: boolean; pdfShell?: boolean }) => mockShellRender(buffer, options), - bufferToOfficeHtml: (buffer: Buffer, name: string, mime: string) => - mockOfficeHtml(buffer, name, mime), + bufferToOfficeHtml: ( + buffer: Buffer, + name: string, + mime: string, + options?: { pdfShell?: boolean }, + ) => { + mockInlineOptions(options); + return mockOfficeHtml(buffer, name, mime); + }, officeHtmlBucket: actual.officeHtmlBucket, }; }); @@ -901,10 +910,22 @@ describe('office preview shell routing', () => { const buffer = Buffer.alloc(INLINE_CAP + 1); const text = await extractCodeArtifactText(buffer, name, mime, category, on); expect(text).toBe('shell'); - expect(mockShellRender).toHaveBeenCalledWith(buffer, { fileShell: true }); + expect(mockShellRender).toHaveBeenCalledWith(buffer, { fileShell: true, pdfShell: true }); expect(mockOfficeHtml).not.toHaveBeenCalled(); }); + it('lets a deck under the inline cap fall back to a PDF shell when enabled', async () => { + mockInlineOptions.mockClear(); + await extractCodeArtifactText(Buffer.alloc(INLINE_CAP), pptx, pptxMime, 'presentation', on); + expect(mockInlineOptions).toHaveBeenCalledWith({ pdfShell: true }); + mockInlineOptions.mockClear(); + await extractCodeArtifactText(Buffer.alloc(INLINE_CAP), pptx, pptxMime, 'presentation', { + enabled: false, + fileSizeLimit: LIMIT, + }); + expect(mockInlineOptions).toHaveBeenCalledWith({ pdfShell: false }); + }); + it('renders a shell for a 4 MB deck', async () => { const text = await extractCodeArtifactText( Buffer.alloc(4 * 1024 * 1024), diff --git a/packages/api/src/files/code/extract.ts b/packages/api/src/files/code/extract.ts index c8cbf15d5ab..6d78ec693ad 100644 --- a/packages/api/src/files/code/extract.ts +++ b/packages/api/src/files/code/extract.ts @@ -302,15 +302,15 @@ const renderOfficeHtml = async ( buffer: Buffer, name: string, mimeType: string, - fileShell = false, + { fileShell = false, pdfShell = false }: { fileShell?: boolean; pdfShell?: boolean } = {}, ): Promise => { const render = (): Promise => { if (!fileShell) { - return bufferToOfficeHtml(buffer, name, mimeType); + return bufferToOfficeHtml(buffer, name, mimeType, { pdfShell }); } return officeHtmlBucket(name, mimeType) === 'docx' - ? wordDocToHtml(buffer, { fileShell }) - : pptxToHtml(buffer, { fileShell }); + ? wordDocToHtml(buffer, { fileShell, pdfShell }) + : pptxToHtml(buffer, { fileShell, pdfShell }); }; try { const html = await officeHtmlLimit(() => @@ -383,9 +383,12 @@ export async function extractCodeArtifactText( * text gate keep the artifact off the panel and fall back to the * regular download UI, matching what PPTX already does. */ if (hasOfficeHtmlPath(name, mimeType)) { - const fileShell = - buffer.length > INLINE_OFFICE_BYTES && isShellEligible(name, mimeType, officePreview); - return await renderOfficeHtml(buffer, name, mimeType, fileShell); + const shellEligible = isShellEligible(name, mimeType, officePreview); + const fileShell = buffer.length > INLINE_OFFICE_BYTES && shellEligible; + return await renderOfficeHtml(buffer, name, mimeType, { + fileShell, + pdfShell: shellEligible, + }); } if (category === 'other') { return null; diff --git a/packages/api/src/files/documents/html.ts b/packages/api/src/files/documents/html.ts index b0009e6e435..dd9d68ddcd2 100644 --- a/packages/api/src/files/documents/html.ts +++ b/packages/api/src/files/documents/html.ts @@ -5,8 +5,8 @@ import { OFFICE_DOC_DATA_SLOT, OFFICE_FILE_SHELL_MARKER, } from 'librechat-data-provider'; -import { tryLibreOfficePreview } from './libreoffice'; import { METAFILE_KEY_JS, extractPptxMetafileSvgs } from './metafiles'; +import { tryLibreOfficePreview } from './libreoffice'; import { assertSafeZipSize } from './zipSafety'; /** @@ -636,7 +636,7 @@ function isOfficePreviewCdnDisabled(): boolean { */ export async function wordDocToHtml( buffer: Buffer, - options: { fileShell?: boolean } = {}, + options: { fileShell?: boolean; pdfShell?: boolean } = {}, ): Promise { const fileShell = options.fileShell === true; await assertSafeZipSize(buffer, { name: 'docx' }); @@ -646,7 +646,9 @@ export async function wordDocToHtml( * binary missing, conversion failed, or output exceeds the cache * cap — falls through to the existing pipeline in any of those * cases so a misconfiguration doesn't break previews. */ - const lo = await tryLibreOfficePreview(buffer, 'docx', OFFICE_HTML_OUTPUT_CAP); + const lo = await tryLibreOfficePreview(buffer, 'docx', OFFICE_HTML_OUTPUT_CAP, { + pdfShell: options.pdfShell === true || fileShell, + }); if (lo) { return lo; } @@ -1550,7 +1552,7 @@ async function pptxToHtmlViaCdn( */ export async function pptxToHtml( buffer: Buffer, - options: { fileShell?: boolean } = {}, + options: { fileShell?: boolean; pdfShell?: boolean } = {}, ): Promise { const fileShell = options.fileShell === true; await assertSafeZipSize(buffer, { name: 'pptx' }); @@ -1558,7 +1560,9 @@ export async function pptxToHtml( * fonts, charts, and embedded objects far better than the slide-list * fallback or even pptx-preview's canvas approach. Falls through on * any error per the contract in `tryLibreOfficePreview`. */ - const lo = await tryLibreOfficePreview(buffer, 'pptx', OFFICE_HTML_OUTPUT_CAP); + const lo = await tryLibreOfficePreview(buffer, 'pptx', OFFICE_HTML_OUTPUT_CAP, { + pdfShell: options.pdfShell === true || fileShell, + }); if (lo) { return lo; } @@ -1766,17 +1770,18 @@ export async function bufferToOfficeHtml( buffer: Buffer, name: string, mimeType: string, + options: { pdfShell?: boolean } = {}, ): Promise { const bucket = officeHtmlBucket(name, mimeType); switch (bucket) { case 'docx': - return wordDocToHtml(buffer); + return wordDocToHtml(buffer, options); case 'csv': return csvToHtml(buffer); case 'spreadsheet': return excelSheetToHtml(buffer); case 'presentation': - return pptxToHtml(buffer); + return pptxToHtml(buffer, options); default: return null; } diff --git a/packages/api/src/files/documents/libreoffice.spec.ts b/packages/api/src/files/documents/libreoffice.spec.ts index 5ca96ccbe14..19ce75b1f49 100644 --- a/packages/api/src/files/documents/libreoffice.spec.ts +++ b/packages/api/src/files/documents/libreoffice.spec.ts @@ -1,6 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { spawnSync } from 'child_process'; +import { fillOfficeFileShell, isOfficeFileShell, isOfficePdfShell } from 'librechat-data-provider'; import { _resetLibreOfficeProbeCache, buildPdfEmbedDocument, @@ -14,6 +15,7 @@ import { probeLibreOfficeBinary, tryLibreOfficePreview, } from './libreoffice'; +import { officeFileToPdf } from './pdfPreview'; /* Detect whether the host has a LibreOffice binary so the integration * tests below can conditionally engage. Most CI runners don't have @@ -215,6 +217,33 @@ describe('libreoffice (env gating + wrapper)', () => { }); }); + describe('buildPdfEmbedDocument (file shell)', () => { + it('emits both markers and an empty data slot instead of the PDF block', () => { + const html = buildPdfEmbedDocument('', { fileShell: true }); + expect(isOfficeFileShell(html)).toBe(true); + expect(isOfficePdfShell(html)).toBe(true); + expect(html).toContain( + '', + ); + expect(html).not.toContain('id="lc-pdf-data"'); + expect(html).toContain('pdf-data-unavailable'); + }); + + it('is filled by fillOfficeFileShell', () => { + const filled = fillOfficeFileShell(buildPdfEmbedDocument('', { fileShell: true }), 'QUJD'); + expect(filled).toContain('base64">QUJD'); + }); + + it('leaves non-shell output free of shell markers', () => { + const html = buildPdfEmbedDocument('QUJD'); + expect(isOfficeFileShell(html)).toBe(false); + expect(isOfficePdfShell(html)).toBe(false); + expect(html).toContain( + '', + ); + }); + }); + describe('tryLibreOfficePreview (gating + fallthrough contract)', () => { it('returns null when the env flag is unset (default behavior)', async () => { delete process.env.OFFICE_PREVIEW_LIBREOFFICE; @@ -381,4 +410,33 @@ describe('libreoffice integration (skipped unless LibreOffice is on $PATH)', () }, 35_000, ); + + itIfLibreOffice( + 'returns a PDF shell over the output cap only when pdfShell is set', + async () => { + process.env.OFFICE_PREVIEW_LIBREOFFICE = 'true'; + _resetLibreOfficeProbeCache(); + const buf = readFixture('sample.pptx'); + const shell = await tryLibreOfficePreview(buf, 'pptx', 1000, { pdfShell: true }); + expect(shell).not.toBeNull(); + expect(isOfficePdfShell(shell!)).toBe(true); + expect(await tryLibreOfficePreview(buf, 'pptx', 1000)).toBeNull(); + }, + 70_000, + ); + + itIfLibreOffice( + 'officeFileToPdf converts a PPTX and declines non-office files or a closed flag', + async () => { + const pptx = 'application/vnd.openxmlformats-officedocument.presentationml.presentation'; + process.env.OFFICE_PREVIEW_LIBREOFFICE = 'true'; + _resetLibreOfficeProbeCache(); + const pdf = await officeFileToPdf(readFixture('sample.pptx'), 'deck.pptx', pptx); + expect(pdf!.subarray(0, 4).toString('ascii')).toBe('%PDF'); + expect(await officeFileToPdf(Buffer.from('a,b'), 'a.csv', 'text/csv')).toBeNull(); + process.env.OFFICE_PREVIEW_LIBREOFFICE = 'false'; + expect(await officeFileToPdf(readFixture('sample.pptx'), 'deck.pptx', pptx)).toBeNull(); + }, + 70_000, + ); }); diff --git a/packages/api/src/files/documents/libreoffice.ts b/packages/api/src/files/documents/libreoffice.ts index 46251cd97b7..34cfbbd5b92 100644 --- a/packages/api/src/files/documents/libreoffice.ts +++ b/packages/api/src/files/documents/libreoffice.ts @@ -2,6 +2,11 @@ import { tmpdir } from 'os'; import { join } from 'path'; import { spawn } from 'child_process'; import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'; +import { + OFFICE_DOC_DATA_SLOT, + OFFICE_FILE_SHELL_MARKER, + OFFICE_PDF_SHELL_MARKER, +} from 'librechat-data-provider'; /** * LibreOffice-backed office preview pipeline. @@ -377,7 +382,18 @@ const PDF_JS_CDN = { worker: 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.worker.min.js', } as const; -export function buildPdfEmbedDocument(pdfBase64: string): string { +export function buildPdfEmbedDocument( + pdfBase64: string, + options: { fileShell?: boolean } = {}, +): string { + /* A file shell carries no PDF: the viewer fills the empty data slot from + * file storage (`?preview=pdf`). */ + const shellMarkers = options.fileShell + ? `${OFFICE_FILE_SHELL_MARKER}\n${OFFICE_PDF_SHELL_MARKER}\n` + : ''; + const dataElement = options.fileShell + ? OFFICE_DOC_DATA_SLOT + : ``; /* CSP scoping: * - `default-src 'none'`: lock everything down. * - `script-src https://cdn.jsdelivr.net 'unsafe-inline'`: load @@ -406,7 +422,7 @@ export function buildPdfEmbedDocument(pdfBase64: string): string { -Preview +${shellMarkers}Preview