From 11cc5350c8fde35cd2239e11faad2fba53a56632 Mon Sep 17 00:00:00 2001 From: Laurent Dubertrand Date: Wed, 11 Mar 2026 09:40:03 +0100 Subject: [PATCH] documents preview - image --- client/src/app/chat-page.component.html | 15 ++-- client/src/app/chat-page.component.scss | 15 ++-- client/src/app/chat-page.component.ts | 25 ++---- client/src/app/chat-session.service.ts | 41 +++++---- .../src/app/office-pdf-preview.component.scss | 13 --- .../src/app/office-pdf-preview.component.ts | 45 ---------- server/dist/index.js | 72 +++++++++++---- server/src/index.ts | 90 +++++++++++++++---- 8 files changed, 176 insertions(+), 140 deletions(-) delete mode 100644 client/src/app/office-pdf-preview.component.scss delete mode 100644 client/src/app/office-pdf-preview.component.ts diff --git a/client/src/app/chat-page.component.html b/client/src/app/chat-page.component.html index 54e90b4..2c5e3c2 100644 --- a/client/src/app/chat-page.component.html +++ b/client/src/app/chat-page.component.html @@ -235,17 +235,14 @@ Download } - @if (hasPdfPreview(entry)) { + @if (hasDocumentPreviewImage(entry)) {
Preview
- @defer (on viewport) { - - } @placeholder { -
Loading preview…
- } +
} diff --git a/client/src/app/chat-page.component.scss b/client/src/app/chat-page.component.scss index 0c4253d..606f2d2 100644 --- a/client/src/app/chat-page.component.scss +++ b/client/src/app/chat-page.component.scss @@ -538,17 +538,14 @@ opacity: 0.78; } -.bubble-preview-placeholder { - display: grid; - place-items: center; +.bubble-preview-image { + display: block; width: min(240px, 100%); - min-height: 320px; - padding: 1rem; - border: 1px dashed var(--input-border); + max-width: 100%; + height: auto; + border: 1px solid var(--surface-border); border-radius: 1rem; - color: var(--page-text-soft); - background: color-mix(in srgb, var(--surface-background) 85%, white); - text-align: center; + background: #fff; } .bubble-image, diff --git a/client/src/app/chat-page.component.ts b/client/src/app/chat-page.component.ts index 43ff54b..2c87342 100644 --- a/client/src/app/chat-page.component.ts +++ b/client/src/app/chat-page.component.ts @@ -3,7 +3,6 @@ import { Component, computed, effect, ElementRef, inject, NgZone, OnDestroy, sig import { toSignal } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; -import { OfficePdfPreviewComponent } from './office-pdf-preview.component'; import { PeerCallModalComponent } from './peer-call-modal.component'; import { ChatSessionService } from './chat-session.service'; @@ -17,7 +16,6 @@ import type { CallMode, ChatEntry, ConnectionState, PeerSummary } from './models FormsModule, RouterLink, JsonFileViewerComponent, - OfficePdfPreviewComponent, PeerCallModalComponent, ], templateUrl: './chat-page.component.html', @@ -646,31 +644,20 @@ export class ChatPageComponent implements OnDestroy { ); } - hasPdfPreview(entry: ChatEntry): boolean { + hasDocumentPreviewImage(entry: ChatEntry): boolean { return ( entry.kind === 'file' && - ( - ( - entry.previewMimeType === 'application/pdf' && - !!entry.previewDownloadUrl - ) || - ( - !!entry.downloadUrl && - ( - entry.fileMimeType === 'application/pdf' || - entry.fileName?.toLowerCase().endsWith('.pdf') === true - ) - ) - ) + !!entry.previewDownloadUrl && + (entry.previewMimeType?.startsWith('image/') ?? false) ); } - pdfPreviewUrl(entry: ChatEntry): string | null { - if (!this.hasPdfPreview(entry)) { + documentPreviewImageUrl(entry: ChatEntry): string | null { + if (!this.hasDocumentPreviewImage(entry)) { return null; } - return entry.previewDownloadUrl ?? entry.downloadUrl ?? null; + return entry.previewDownloadUrl ?? null; } isPeerTyping(peerId: string): boolean { diff --git a/client/src/app/chat-session.service.ts b/client/src/app/chat-session.service.ts index 4fbd9b4..7728691 100644 --- a/client/src/app/chat-session.service.ts +++ b/client/src/app/chat-session.service.ts @@ -98,9 +98,9 @@ type PersistedChatEntryContent = { previewMimeType?: string; }; -type OfficePreviewResponse = { +type DocumentPreviewImageResponse = { mimeType: string; - pdfBase64: string; + imageBase64: string; }; type RuntimeEnv = { @@ -1586,13 +1586,13 @@ export class ChatSessionService { let previewMimeType: string | undefined; let previewDownloadUrl: string | undefined; - if (transfer.kind === 'file' && this.isOfficeDocumentFile(transfer.name, transfer.mimeType)) { - const officePreview = await this.generateOfficeDocumentPreview(transfer.name, blob); + if (transfer.kind === 'file' && this.isPreviewableDocumentFile(transfer.name, transfer.mimeType)) { + const imagePreview = await this.generateDocumentPreviewImage(transfer.name, blob); - if (officePreview) { - previewBlob = officePreview.blob; - previewMimeType = officePreview.mimeType; - previewDownloadUrl = URL.createObjectURL(officePreview.blob); + if (imagePreview) { + previewBlob = imagePreview.blob; + previewMimeType = imagePreview.mimeType; + previewDownloadUrl = URL.createObjectURL(imagePreview.blob); } } @@ -2287,7 +2287,7 @@ export class ChatSessionService { Uint8Array.from(entry.previewIv).buffer, ); const previewBlob = new Blob([decryptedPreview], { - type: content.previewMimeType || 'application/pdf', + type: content.previewMimeType || 'image/png', }); previewDownloadUrl = URL.createObjectURL(previewBlob); } @@ -2344,7 +2344,7 @@ export class ChatSessionService { : undefined; const previewBlob = entry.previewBlob ? new Blob([await entry.previewBlob.arrayBuffer()], { - type: entry.previewMimeType || 'application/pdf', + type: entry.previewMimeType || 'image/png', }) : undefined; @@ -2931,7 +2931,7 @@ export class ChatSessionService { return new Blob([bytes], { type: mimeType }); } - private async generateOfficeDocumentPreview( + private async generateDocumentPreviewImage( fileName: string, fileBlob: Blob, ): Promise<{ blob: Blob; mimeType: string } | null> { @@ -2943,8 +2943,8 @@ export class ChatSessionService { try { const response = await firstValueFrom( - this.http.post( - `${this.serverUrl()}/api/files/office-preview`, + this.http.post( + `${this.serverUrl()}/api/files/document-preview-image`, { fileName, mimeType: fileBlob.type || 'application/octet-stream', @@ -2957,15 +2957,19 @@ export class ChatSessionService { ); return { - blob: this.base64ToBlob(response.pdfBase64, response.mimeType), + blob: this.base64ToBlob(response.imageBase64, response.mimeType), mimeType: response.mimeType, }; } catch (error) { - console.warn('Could not generate office document preview.', error); + console.warn('Could not generate document preview image.', error); return null; } } + private isPreviewableDocumentFile(fileName?: string, mimeType?: string): boolean { + return this.isOfficeDocumentFile(fileName, mimeType) || this.isPdfFile(fileName, mimeType); + } + private isOfficeDocumentFile(fileName?: string, mimeType?: string): boolean { const normalizedName = fileName?.trim().toLowerCase() ?? ''; const normalizedMimeType = mimeType?.trim().toLowerCase() ?? ''; @@ -2981,6 +2985,13 @@ export class ChatSessionService { return /\.(docx|xlsx|xslx|pptx)$/i.test(normalizedName); } + private isPdfFile(fileName?: string, mimeType?: string): boolean { + const normalizedName = fileName?.trim().toLowerCase() ?? ''; + const normalizedMimeType = mimeType?.trim().toLowerCase() ?? ''; + + return normalizedMimeType === 'application/pdf' || normalizedName.endsWith('.pdf'); + } + private fileExtensionForMimeType(mimeType: string): string { const normalizedMimeType = mimeType.split(';', 1)[0]?.trim().toLowerCase() || 'application/octet-stream'; diff --git a/client/src/app/office-pdf-preview.component.scss b/client/src/app/office-pdf-preview.component.scss deleted file mode 100644 index 91557e8..0000000 --- a/client/src/app/office-pdf-preview.component.scss +++ /dev/null @@ -1,13 +0,0 @@ -:host { - display: block; - width: min(240px, 100%); -} - -.office-pdf-preview-shell { - width: min(240px, 100%); - overflow: hidden; - border: 1px solid var(--surface-border); - border-radius: 1rem; - background: #fff; - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16); -} diff --git a/client/src/app/office-pdf-preview.component.ts b/client/src/app/office-pdf-preview.component.ts deleted file mode 100644 index 6411639..0000000 --- a/client/src/app/office-pdf-preview.component.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Component, Input } from '@angular/core'; -import { NgxExtendedPdfViewerModule } from 'ngx-extended-pdf-viewer'; - -@Component({ - selector: 'app-office-pdf-preview', - imports: [NgxExtendedPdfViewerModule], - template: ` -
- -
- `, - styleUrl: './office-pdf-preview.component.scss', -}) -export class OfficePdfPreviewComponent { - @Input({ required: true }) src!: string; - @Input() fileName = 'document.pdf'; -} diff --git a/server/dist/index.js b/server/dist/index.js index 699ab4f..4728ddd 100644 --- a/server/dist/index.js +++ b/server/dist/index.js @@ -1,5 +1,7 @@ import crypto from 'node:crypto'; +import { execFile } from 'node:child_process'; import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify, TextEncoder } from 'node:util'; @@ -48,7 +50,7 @@ const adminDeleteUserParamsSchema = z.object({ const webBundleFileParamsSchema = z.object({ '*': z.string().min(1), }); -const officePreviewSchema = z.object({ +const documentPreviewSchema = z.object({ fileName: z.string().trim().min(1).max(256), mimeType: z.string().trim().min(1).max(256), fileBase64: z.string().min(1).max(96_000_000), @@ -118,6 +120,7 @@ const webAuthnUserVerification = resolveWebAuthnUserVerification(process.env.WEB const frontendIndexPath = path.join(frontendDistPath, 'index.html'); const hasFrontendBuild = fs.existsSync(frontendIndexPath); const convertOfficeDocument = promisify(libreOffice.convertWithOptions); +const execFileAsync = promisify(execFile); const speechTranscriber = new SpeechTranscriber({ serviceUrl: speechTranscriptionServiceUrl, language: speechTranscriptionLanguage, @@ -469,32 +472,32 @@ app.get('/api/auth/session', async (request, reply) => { messageEncryptionKey: authContext.user.messageEncryptionKey, }; }); -app.post('/api/files/office-preview', { bodyLimit: 64 * 1024 * 1024 }, async (request, reply) => { +app.post('/api/files/document-preview-image', { bodyLimit: 64 * 1024 * 1024 }, async (request, reply) => { const authContext = await authenticateRequest(request, reply); if (!authContext) { return; } - const parsed = officePreviewSchema.safeParse(request.body); + const parsed = documentPreviewSchema.safeParse(request.body); if (!parsed.success) { return reply.code(400).send({ - message: 'Invalid office preview payload.', + message: 'Invalid document preview payload.', issues: parsed.error.flatten(), }); } - if (!isSupportedOfficeDocument(parsed.data.fileName, parsed.data.mimeType)) { - return reply.code(400).send({ message: 'Only DOCX, XLSX, and PPTX files can be previewed.' }); + if (!isSupportedPreviewDocument(parsed.data.fileName, parsed.data.mimeType)) { + return reply.code(400).send({ message: 'Only PDF, DOCX, XLSX, and PPTX files can be previewed.' }); } try { - const pdfBuffer = await convertOfficeDocumentToPdf(parsed.data.fileName, parsed.data.fileBase64); + const previewImageBuffer = await createDocumentPreviewImage(parsed.data.fileName, parsed.data.mimeType, parsed.data.fileBase64); return { - mimeType: 'application/pdf', - pdfBase64: pdfBuffer.toString('base64'), + mimeType: 'image/png', + imageBase64: previewImageBuffer.toString('base64'), }; } catch (error) { - app.log.warn({ err: error, userId: authContext.user.id }, 'Office preview generation failed'); + app.log.warn({ err: error, userId: authContext.user.id }, 'Document preview generation failed'); return reply.code(422).send({ - message: describeOfficePreviewFailure(error), + message: describeDocumentPreviewFailure(error), }); } }); @@ -879,6 +882,40 @@ async function convertOfficeDocumentToPdf(fileName, fileBase64) { const normalizedFileName = normalizeOfficeDocumentFileName(fileName); return convertOfficeDocument(inputBuffer, '.pdf', undefined, { fileName: normalizedFileName }); } +async function createDocumentPreviewImage(fileName, mimeType, fileBase64) { + const normalizedMimeType = mimeType.trim().toLowerCase(); + const pdfBuffer = normalizedMimeType === 'application/pdf' + ? decodeBase64File(fileBase64, 'The uploaded PDF is empty.') + : await convertOfficeDocumentToPdf(fileName, fileBase64); + return renderPdfFirstPageToPng(pdfBuffer); +} +async function renderPdfFirstPageToPng(pdfBuffer) { + const tempDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'privatechat-preview-')); + const pdfPath = path.join(tempDirectory, 'source.pdf'); + const outputBasePath = path.join(tempDirectory, 'page-preview'); + const imagePath = `${outputBasePath}.png`; + try { + await fs.promises.writeFile(pdfPath, pdfBuffer); + await execFileAsync('pdftoppm', ['-png', '-f', '1', '-singlefile', pdfPath, outputBasePath]); + return await fs.promises.readFile(imagePath); + } + finally { + await fs.promises.rm(tempDirectory, { recursive: true, force: true }); + } +} +function decodeBase64File(fileBase64, emptyMessage) { + const inputBuffer = Buffer.from(fileBase64, 'base64'); + if (inputBuffer.byteLength === 0) { + throw new Error(emptyMessage); + } + return inputBuffer; +} +function isSupportedPreviewDocument(fileName, mimeType) { + if (isPdfFile(fileName, mimeType)) { + return true; + } + return isSupportedOfficeDocument(fileName, mimeType); +} function isSupportedOfficeDocument(fileName, mimeType) { const normalizedFileName = fileName.trim().toLowerCase(); const normalizedMimeType = mimeType.trim().toLowerCase(); @@ -889,17 +926,22 @@ function isSupportedOfficeDocument(fileName, mimeType) { } return /\.(docx|xlsx|xslx|pptx)$/i.test(normalizedFileName); } +function isPdfFile(fileName, mimeType) { + const normalizedFileName = fileName.trim().toLowerCase(); + const normalizedMimeType = mimeType.trim().toLowerCase(); + return normalizedMimeType === 'application/pdf' || normalizedFileName.endsWith('.pdf'); +} function normalizeOfficeDocumentFileName(fileName) { return fileName.trim().replace(/\.xslx$/i, '.xlsx'); } -function describeOfficePreviewFailure(error) { +function describeDocumentPreviewFailure(error) { if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { - return 'Office preview generation failed because LibreOffice is not installed on the server.'; + return 'Document preview generation failed because a required conversion tool is missing on the server.'; } if (error instanceof Error && error.message.trim()) { - return `Office preview generation failed: ${error.message}`; + return `Document preview generation failed: ${error.message}`; } - return 'Office preview generation failed.'; + return 'Document preview generation failed.'; } function createUser(input) { const createdAt = new Date().toISOString(); diff --git a/server/src/index.ts b/server/src/index.ts index 447679f..b30cfc9 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,5 +1,7 @@ import crypto from 'node:crypto'; +import { execFile } from 'node:child_process'; import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify, TextEncoder } from 'node:util'; @@ -272,7 +274,7 @@ const webBundleFileParamsSchema = z.object({ '*': z.string().min(1), }); -const officePreviewSchema = z.object({ +const documentPreviewSchema = z.object({ fileName: z.string().trim().min(1).max(256), mimeType: z.string().trim().min(1).max(256), fileBase64: z.string().min(1).max(96_000_000), @@ -354,6 +356,7 @@ const webAuthnUserVerification = resolveWebAuthnUserVerification( const frontendIndexPath = path.join(frontendDistPath, 'index.html'); const hasFrontendBuild = fs.existsSync(frontendIndexPath); const convertOfficeDocument = promisify(libreOffice.convertWithOptions); +const execFileAsync = promisify(execFile); const speechTranscriber = new SpeechTranscriber( { @@ -803,37 +806,41 @@ app.get('/api/auth/session', async (request, reply) => { }; }); -app.post('/api/files/office-preview', { bodyLimit: 64 * 1024 * 1024 }, async (request, reply) => { +app.post('/api/files/document-preview-image', { bodyLimit: 64 * 1024 * 1024 }, async (request, reply) => { const authContext = await authenticateRequest(request, reply); if (!authContext) { return; } - const parsed = officePreviewSchema.safeParse(request.body); + const parsed = documentPreviewSchema.safeParse(request.body); if (!parsed.success) { return reply.code(400).send({ - message: 'Invalid office preview payload.', + message: 'Invalid document preview payload.', issues: parsed.error.flatten(), }); } - if (!isSupportedOfficeDocument(parsed.data.fileName, parsed.data.mimeType)) { - return reply.code(400).send({ message: 'Only DOCX, XLSX, and PPTX files can be previewed.' }); + if (!isSupportedPreviewDocument(parsed.data.fileName, parsed.data.mimeType)) { + return reply.code(400).send({ message: 'Only PDF, DOCX, XLSX, and PPTX files can be previewed.' }); } try { - const pdfBuffer = await convertOfficeDocumentToPdf(parsed.data.fileName, parsed.data.fileBase64); + const previewImageBuffer = await createDocumentPreviewImage( + parsed.data.fileName, + parsed.data.mimeType, + parsed.data.fileBase64, + ); return { - mimeType: 'application/pdf', - pdfBase64: pdfBuffer.toString('base64'), + mimeType: 'image/png', + imageBase64: previewImageBuffer.toString('base64'), }; } catch (error) { - app.log.warn({ err: error, userId: authContext.user.id }, 'Office preview generation failed'); + app.log.warn({ err: error, userId: authContext.user.id }, 'Document preview generation failed'); return reply.code(422).send({ - message: describeOfficePreviewFailure(error), + message: describeDocumentPreviewFailure(error), }); } }); @@ -1348,6 +1355,52 @@ async function convertOfficeDocumentToPdf(fileName: string, fileBase64: string): return convertOfficeDocument(inputBuffer, '.pdf', undefined, { fileName: normalizedFileName }); } +async function createDocumentPreviewImage( + fileName: string, + mimeType: string, + fileBase64: string, +): Promise { + const normalizedMimeType = mimeType.trim().toLowerCase(); + const pdfBuffer = normalizedMimeType === 'application/pdf' + ? decodeBase64File(fileBase64, 'The uploaded PDF is empty.') + : await convertOfficeDocumentToPdf(fileName, fileBase64); + + return renderPdfFirstPageToPng(pdfBuffer); +} + +async function renderPdfFirstPageToPng(pdfBuffer: Buffer): Promise { + const tempDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'privatechat-preview-')); + const pdfPath = path.join(tempDirectory, 'source.pdf'); + const outputBasePath = path.join(tempDirectory, 'page-preview'); + const imagePath = `${outputBasePath}.png`; + + try { + await fs.promises.writeFile(pdfPath, pdfBuffer); + await execFileAsync('pdftoppm', ['-png', '-f', '1', '-singlefile', pdfPath, outputBasePath]); + return await fs.promises.readFile(imagePath); + } finally { + await fs.promises.rm(tempDirectory, { recursive: true, force: true }); + } +} + +function decodeBase64File(fileBase64: string, emptyMessage: string): Buffer { + const inputBuffer = Buffer.from(fileBase64, 'base64'); + + if (inputBuffer.byteLength === 0) { + throw new Error(emptyMessage); + } + + return inputBuffer; +} + +function isSupportedPreviewDocument(fileName: string, mimeType: string): boolean { + if (isPdfFile(fileName, mimeType)) { + return true; + } + + return isSupportedOfficeDocument(fileName, mimeType); +} + function isSupportedOfficeDocument(fileName: string, mimeType: string): boolean { const normalizedFileName = fileName.trim().toLowerCase(); const normalizedMimeType = mimeType.trim().toLowerCase(); @@ -1363,20 +1416,27 @@ function isSupportedOfficeDocument(fileName: string, mimeType: string): boolean return /\.(docx|xlsx|xslx|pptx)$/i.test(normalizedFileName); } +function isPdfFile(fileName: string, mimeType: string): boolean { + const normalizedFileName = fileName.trim().toLowerCase(); + const normalizedMimeType = mimeType.trim().toLowerCase(); + + return normalizedMimeType === 'application/pdf' || normalizedFileName.endsWith('.pdf'); +} + function normalizeOfficeDocumentFileName(fileName: string): string { return fileName.trim().replace(/\.xslx$/i, '.xlsx'); } -function describeOfficePreviewFailure(error: unknown): string { +function describeDocumentPreviewFailure(error: unknown): string { if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { - return 'Office preview generation failed because LibreOffice is not installed on the server.'; + return 'Document preview generation failed because a required conversion tool is missing on the server.'; } if (error instanceof Error && error.message.trim()) { - return `Office preview generation failed: ${error.message}`; + return `Document preview generation failed: ${error.message}`; } - return 'Office preview generation failed.'; + return 'Document preview generation failed.'; } function createUser(input: {