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: {