feat(print): add optional paginated DOCX previews (#11036)

* feat(print): add optional paginated DOCX previews

Signed-off-by: Ratel-pwn <2448574643@qq.com>

* chore(print): align preview changes with repository checks

Signed-off-by: Ratel-pwn <2448574643@qq.com>

* style(print): normalize preview formatting

Signed-off-by: Ratel-pwn <2448574643@qq.com>

---------

Signed-off-by: Ratel-pwn <2448574643@qq.com>
This commit is contained in:
DawnRatel
2026-09-25 10:59:59 +02:00
committed by GitHub
parent e749ab9d7b
commit 5eb50ee39b
25 changed files with 783 additions and 58 deletions
+2 -1
View File
@@ -5,6 +5,7 @@
"DownloadAll": "Stáhnout vše",
"PrintFailed": "Tisk se nezdařil",
"PrintSettings": "Print settings",
"LandscapeMode": "Landscape mode"
"LandscapeMode": "Landscape mode",
"Retry": "Zkusit znovu"
}
}
+2 -1
View File
@@ -5,6 +5,7 @@
"DownloadAll": "Alle herunterladen",
"PrintFailed": "Druck fehlgeschlagen",
"PrintSettings": "Print settings",
"LandscapeMode": "Landscape mode"
"LandscapeMode": "Landscape mode",
"Retry": "Wiederholen"
}
}
+2 -1
View File
@@ -5,6 +5,7 @@
"DownloadAll": "Download all",
"PrintFailed": "Print failed",
"PrintSettings": "Print settings",
"LandscapeMode": "Landscape mode"
"LandscapeMode": "Landscape mode",
"Retry": "Retry"
}
}
+2 -1
View File
@@ -5,6 +5,7 @@
"DownloadAll": "Descargar todo",
"PrintFailed": "Error al imprimir",
"PrintSettings": "Print settings",
"LandscapeMode": "Landscape mode"
"LandscapeMode": "Landscape mode",
"Retry": "Reintentar"
}
}
+2 -1
View File
@@ -5,6 +5,7 @@
"DownloadAll": "Tout télécharger",
"PrintFailed": "Échec de l'impression",
"PrintSettings": "Print settings",
"LandscapeMode": "Landscape mode"
"LandscapeMode": "Landscape mode",
"Retry": "Réessayer"
}
}
+2 -1
View File
@@ -5,6 +5,7 @@
"DownloadAll": "Scarica tutto",
"PrintFailed": "Stampa non riuscita",
"PrintSettings": "Print settings",
"LandscapeMode": "Landscape mode"
"LandscapeMode": "Landscape mode",
"Retry": "Riprova"
}
}
+2 -1
View File
@@ -5,6 +5,7 @@
"DownloadAll": "すべてダウンロード",
"PrintFailed": "印刷に失敗しました",
"PrintSettings": "Print settings",
"LandscapeMode": "Landscape mode"
"LandscapeMode": "Landscape mode",
"Retry": "再試行"
}
}
+2 -1
View File
@@ -5,6 +5,7 @@
"DownloadAll": "모두 다운로드",
"PrintFailed": "인쇄 실패",
"PrintSettings": "Print settings",
"LandscapeMode": "Landscape mode"
"LandscapeMode": "Landscape mode",
"Retry": "다시 시도"
}
}
+2 -1
View File
@@ -5,6 +5,7 @@
"DownloadAll": "Pobierz wszystkie",
"PrintFailed": "Wydruk nie powiódł się",
"PrintSettings": "Ustawienia wydruku",
"LandscapeMode": "Orientacja pozioma"
"LandscapeMode": "Orientacja pozioma",
"Retry": "Ponów"
}
}
+2 -1
View File
@@ -5,6 +5,7 @@
"DownloadAll": "Baixar tudo",
"PrintFailed": "Falha na impressão",
"PrintSettings": "Print settings",
"LandscapeMode": "Landscape mode"
"LandscapeMode": "Landscape mode",
"Retry": "Tentar novamente"
}
}
+2 -1
View File
@@ -5,6 +5,7 @@
"DownloadAll": "Descarregar tudo",
"PrintFailed": "Falha na impressão",
"PrintSettings": "Print settings",
"LandscapeMode": "Landscape mode"
"LandscapeMode": "Landscape mode",
"Retry": "Reintentar"
}
}
+2 -1
View File
@@ -5,6 +5,7 @@
"DownloadAll": "Скачать все",
"PrintFailed": "Ошибка печати",
"PrintSettings": "Настройки печати",
"LandscapeMode": "Ландшафтный режим"
"LandscapeMode": "Ландшафтный режим",
"Retry": "Повторить"
}
}
+2 -1
View File
@@ -5,6 +5,7 @@
"DownloadAll": "Tümünü indir",
"PrintFailed": "Yazdırma başarısız",
"PrintSettings": "Print settings",
"LandscapeMode": "Landscape mode"
"LandscapeMode": "Landscape mode",
"Retry": "Tekrar Dene"
}
}
+2 -1
View File
@@ -5,6 +5,7 @@
"DownloadAll": "全部下载",
"PrintFailed": "打印失败",
"PrintSettings": "Print settings",
"LandscapeMode": "Landscape mode"
"LandscapeMode": "Landscape mode",
"Retry": "重试"
}
}
@@ -8,8 +8,9 @@
import { type Blob, type BlobMetadata, type Ref } from '@hcengineering/core'
import { getMetadata } from '@hcengineering/platform'
import presentation, { getFileUrl } from '@hcengineering/presentation'
import { convertToHTML } from '@hcengineering/print'
import { EmbeddedHTML, Spinner, themeStore } from '@hcengineering/ui'
import print, { convertForPreview, type ConvertedPreview } from '@hcengineering/print'
import { Button, EmbeddedHTML, EmbeddedPDF, Label, Spinner, themeStore } from '@hcengineering/ui'
import { onDestroy } from 'svelte'
export let value: Ref<Blob>
export let name: string
@@ -17,27 +18,49 @@
export let metadata: BlobMetadata | undefined
let isLoading = true
let convertedFile: string | undefined
let failed = false
let convertedFile: ConvertedPreview | undefined
let request: AbortController | undefined
const token = getMetadata(presentation.metadata.Token) ?? ''
$: if (value !== undefined) {
async function loadPreview (file: Ref<Blob> | undefined): Promise<void> {
request?.abort()
const controller = new AbortController()
request = controller
isLoading = true
failed = false
convertedFile = undefined
convertToHTML(value, token).then(
(res) => {
convertedFile = res
isLoading = false
},
(err: any) => {
try {
if (file === undefined) {
throw new Error('Missing document')
}
const result = await convertForPreview(file, token, controller.signal)
if (!controller.signal.aborted && file === value) {
convertedFile = result
}
} catch (err) {
if (!controller.signal.aborted && file === value) {
failed = true
Analytics.handleError(err)
}
} finally {
if (!controller.signal.aborted && file === value) {
isLoading = false
}
)
}
}
$: src = convertedFile === undefined ? '' : getFileUrl(convertedFile as Ref<Blob>, name)
$: void loadPreview(value)
onDestroy(() => {
request?.abort()
})
$: previewName = convertedFile?.contentType === 'application/pdf' ? name.replace(/\.docx$/i, '') + '.pdf' : name
$: src = convertedFile === undefined ? '' : getFileUrl(convertedFile.id as Ref<Blob>, previewName)
$: originalSrc = value === undefined ? '' : getFileUrl(value, name)
$: colors = $themeStore.dark
? `
@@ -264,26 +287,43 @@
`
</script>
{#if src}
{#if isLoading}
<div class="centered">
<Spinner size="medium" />
{#if isLoading}
<div class="centered">
<Spinner size="medium" />
</div>
{:else if failed}
<div class="centered failed">
<Label label={presentation.string.FailedToPreview} />
<div class="flex-row-center flex-gap-2">
<Button label={print.string.Retry} on:click={() => loadPreview(value)} />
{#if originalSrc}
<a href={originalSrc} download={name}>
<Label label={presentation.string.DownloadOriginal} />
</a>
{/if}
</div>
{:else}
<EmbeddedHTML {src} {name} {css} />
{/if}
</div>
{:else if src}
{#key src}
{#if convertedFile?.contentType === 'application/pdf'}
<EmbeddedPDF {src} name={previewName} />
{:else}
<EmbeddedHTML {src} {name} {css} />
{/if}
{/key}
{/if}
<style lang="scss">
iframe {
border: none;
}
.centered {
flex-grow: 1;
width: 100;
height: 100;
width: 100%;
min-height: 20rem;
display: flex;
justify-content: center;
align-items: center;
}
.failed {
flex-direction: column;
gap: 1rem;
}
</style>
+103
View File
@@ -0,0 +1,103 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
import { getMetadata } from '@hcengineering/platform'
import { convertForPreview, convertToHTML } from '../utils'
jest.mock('@hcengineering/platform', () => ({ getMetadata: jest.fn() }), { virtual: true })
jest.mock('../plugin', () => ({ __esModule: true, default: { metadata: { PrintURL: 'print-url' } } }))
describe('DOCX preview conversion', () => {
const fetchMock = jest.fn()
const originalFetch = globalThis.fetch
beforeEach(() => {
fetchMock.mockReset()
globalThis.fetch = fetchMock
jest.mocked(getMetadata).mockReturnValue('https://print.example')
})
afterAll(() => {
globalThis.fetch = originalFetch
})
function respond (body: unknown, status = 200): void {
fetchMock.mockResolvedValue(new Response(JSON.stringify(body), { status }))
}
it.each(['application/pdf', 'text/html'] as const)('returns a validated %s preview', async (contentType) => {
respond({ id: 'preview-id', contentType })
const controller = new AbortController()
await expect(convertForPreview('source-id', 'workspace-token', controller.signal)).resolves.toEqual({
id: 'preview-id',
contentType
})
const [url, options] = fetchMock.mock.calls[0]
expect(url.toString()).toBe('https://print.example/convert/source-id?format=preview')
expect(options).toMatchObject({
method: 'GET',
headers: { Authorization: 'Bearer workspace-token', Accept: 'application/json' },
signal: controller.signal
})
})
it('treats the legacy response as HTML', async () => {
respond({ id: 'legacy-id' })
await expect(convertForPreview('source-id', 'token')).resolves.toEqual({
id: 'legacy-id',
contentType: 'text/html'
})
})
it.each([
{},
null,
{ id: '' },
{ id: ' ' },
{ id: 123 },
{ id: 'id', contentType: null },
{ id: 'id', contentType: 'image/png' }
])('rejects malformed preview metadata: %j', async (body) => {
respond(body)
await expect(convertForPreview('source-id', 'token')).rejects.toThrow('Invalid preview response')
})
it('rejects a missing token without requesting conversion', async () => {
await expect(convertForPreview('source-id', '')).rejects.toThrow('Missing authentication token')
expect(fetchMock).not.toHaveBeenCalled()
})
it('reports a failed conversion even for a non-JSON service error', async () => {
fetchMock.mockResolvedValue(new Response('Gateway unavailable', { status: 503 }))
await expect(convertForPreview('source-id', 'token')).rejects.toThrow('503')
})
it('reports the service conversion error', async () => {
respond({ message: 'Conversion queue is full' }, 503)
await expect(convertForPreview('source-id', 'token')).rejects.toThrow('Conversion queue is full')
})
it('rejects malformed success JSON', async () => {
fetchMock.mockResolvedValue(new Response('not json'))
await expect(convertForPreview('source-id', 'token')).rejects.toThrow()
})
it('preserves an aborted request rejection', async () => {
const error = new DOMException('Aborted', 'AbortError')
fetchMock.mockRejectedValue(error)
await expect(convertForPreview('source-id', 'token')).rejects.toBe(error)
})
it('requests HTML explicitly for existing callers', async () => {
respond({ id: 'html-id', contentType: 'text/html' })
await expect(convertToHTML('source-id', 'token')).resolves.toBe('html-id')
expect(fetchMock.mock.calls[0][0].toString()).toBe('https://print.example/convert/source-id?format=html')
})
it('preserves the empty-token behavior of the HTML helper', async () => {
await expect(convertToHTML('source-id', '')).resolves.toBe('')
expect(fetchMock).not.toHaveBeenCalled()
})
})
+2 -1
View File
@@ -15,7 +15,8 @@ export const print = plugin(printId, {
DownloadAll: '' as IntlString,
PrintFailed: '' as IntlString,
PrintSettings: '' as IntlString,
LandscapeMode: '' as IntlString
LandscapeMode: '' as IntlString,
Retry: '' as IntlString
},
component: {
PrintToPDF: '' as AnyComponent,
+39
View File
@@ -51,12 +51,51 @@ export async function printToPDF (link: string, token: string, options?: PrintTo
return res.id
}
export interface ConvertedPreview {
id: string
contentType: 'application/pdf' | 'text/html'
}
export async function convertForPreview (file: string, token: string, signal?: AbortSignal): Promise<ConvertedPreview> {
if (token === '') {
throw new Error('Missing authentication token')
}
const url = new URL(`${getPrintBaseURL()}/convert/${file}`)
url.searchParams.set('format', 'preview')
const response = await fetch(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json'
},
signal
})
if (!response.ok) {
const error = await response.json().catch(() => undefined)
throw new Error(`Failed to convert preview: ${error?.message ?? response.status}`)
}
const result = await response.json()
if (typeof result?.id !== 'string' || result.id.trim() === '') {
throw new Error('Invalid preview response')
}
const contentType = result.contentType === undefined ? 'text/html' : result.contentType
if (contentType !== 'application/pdf' && contentType !== 'text/html') {
throw new Error('Invalid preview response')
}
return { id: result.id, contentType }
}
export async function convertToHTML (file: string, token: string): Promise<string> {
if (token === '') {
return ''
}
const url: URL = new URL(`${getPrintBaseURL()}/convert/${file}`)
url.searchParams.set('format', 'html')
const response = await fetch(url, {
method: 'GET',
+78
View File
@@ -0,0 +1,78 @@
# DOCX preview
Drive can preview DOCX files with their page layout by converting a copy to PDF
through Gotenberg / LibreOffice. The existing Huly PDF viewer displays the result;
the original DOCX stays available for download and is never overwritten.
## Enable
Deploy the updated Print service and frontend together. Retain the Print service's
existing `SECRET`, `ACCOUNTS_URL`, `FRONT_URL`, and storage configuration. Set the
frontend's `PRINT_URL` to its authenticated Print HTTP endpoint. If the deployment
does not already have Print, provision it first; enabling Gotenberg alone does not
make Drive previews available.
Merge `compose.docx-preview.yml` into a Compose deployment whose Print service is
named `print`. Adapt the existing application network name if it is not `default`.
The example adds `GOTENBERG_URL=http://gotenberg:3000`. Gotenberg has no published
port and only joins an internal network shared with Print. Do not expose its API
to browsers or the Internet. The endpoint must be an administrator-controlled URL.
The example pins Gotenberg 8.37.0, disables URL downloads, webhooks, Chromium
routes and external document references, and uses the engine's default disabled
macro execution. It limits the converter to one CPU, 1 GiB RAM and 512 MiB of
temporary storage. LibreOffice shuts down after 30 seconds idle. Large or complex
documents may fail within these limits; users can retry or download the original.
## Behavior and compatibility
- Only `application/vnd.openxmlformats-officedocument.wordprocessingml.document`
is registered and accepted. Legacy `.doc` is deliberately out of scope.
- The new client requests `GET /convert/:file?format=preview`, authenticated as
before. The response is `{ id, contentType }`, with `application/pdf` when the
converter is configured, otherwise `text/html`.
- Requests with no format and `format=html` retain the existing HTML behavior.
Explicit `format=pdf` returns 503 if no converter is configured. A new client
accepts a legacy server response without `contentType` as HTML.
- PDF cache IDs include source blob ID, ETag and `pdf-v1`; HTML cache IDs stay
unchanged. Cached data lives in the same workspace storage as the source.
Source changes trigger new previews. Old derived blobs follow the deployment's
existing storage retention policy; this change does not add automatic cleanup.
- Each Print process runs one PDF conversion and queues at most four more PDF jobs.
Requests for the same workspace/source version share a job. Queue overflow
returns 503. Each PDF conversion has a 60 second deadline; waiting for queued
jobs adds to that time. PDF conversion input is limited to 25 MiB and output to
50 MiB. HTML conversion runs independently of this queue and retains its existing
behavior without the PDF input limit.
- Conversion failure shows retry and original download controls. Navigating away
cancels the browser request; a conversion already running may finish and cache
its result for the next viewer.
LibreOffice preserves pages, tables, embedded images, headers and footers, but
does not guarantee pixel-identical Microsoft Word rendering. Gotenberg includes
Noto CJK, Carlito, Caladea and Liberation fonts. Missing corporate fonts can change
line wrapping and pagination; add properly licensed fonts to a derived converter
image when fidelity requires them. Changing fonts or the conversion engine does
not invalidate existing cached PDFs; bump the PDF cache revision when rolling out
a rendering change that should regenerate them.
Supporting `.doc` later is a small integration extension because LibreOffice
already reads it: register and validate its MIME type, send the appropriate
filename, and add legacy-document fixtures. Font and layout compatibility still
need testing. Password-protected, corrupt or unsupported documents currently show
the failure state rather than requesting a password.
## Validation
Focused Jest tests cover conversion limits, timeouts, output validation, queue
coalescing, authentication, cache separation, source versions and legacy HTML,
including large HTML sources and HTML conversion while a PDF job is stalled.
Client tests cover result negotiation and request errors. No full project build
is required for these targeted checks.
Before deployment, manually test Drive with a DOCX containing Chinese and English
text, explicit and automatic page breaks, tables, an image and page numbers. Check
the original download, reopening a cached preview, changing file versions,
switching documents while loading, and retry after a converter failure. Test with
the deployment's actual fonts and browser PDF settings; browsers configured to
download PDFs instead of displaying them may not provide inline preview.
+49
View File
@@ -0,0 +1,49 @@
# Merge into a deployment that already defines the print service with the updated image:
# docker compose -f compose.yml -f compose.docx-preview.yml up -d print gotenberg
# The print service retains its existing network, storage, secret and account settings.
services:
print:
environment:
GOTENBERG_URL: http://gotenberg:3000
networks:
default: {}
document-preview: {}
depends_on:
gotenberg:
condition: service_healthy
gotenberg:
# Gotenberg 8.37.0, pinned so conversion behavior does not change on restart.
image: gotenberg/gotenberg@sha256:f29984bd1e226bf1b93ba90af06000afa8b315853e99d27b9aaa41b93f15c769
restart: unless-stopped
cpus: 1
mem_limit: 1g
pids_limit: 256
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
tmpfs:
- /tmp:rw,nosuid,size=512m
command:
- gotenberg
- --api-timeout=60s
- --api-body-limit=26MB
- --api-disable-download-from
- --webhook-disable
- --chromium-disable-routes
- --libreoffice-deny-list=.*
- --libreoffice-max-queue-size=4
- --libreoffice-idle-shutdown-timeout=30s
healthcheck:
test: [CMD, curl, --fail, --silent, http://localhost:3000/health]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
networks:
document-preview: {}
networks:
document-preview:
internal: true
@@ -0,0 +1,167 @@
// Copyright © 2026 Huly Contributors.
import { type Server } from 'http'
import config from '../config'
import { convertToPdf } from '../preview'
import { createServer } from '../server'
const mockStorage = { stat: jest.fn(), read: jest.fn(), put: jest.fn(), close: jest.fn() }
const mockContext: { with: jest.Mock, error: jest.Mock } = {
with: jest.fn(async (_name, _attrs, operation) => await Promise.resolve(operation(mockContext))),
error: jest.fn()
}
jest.mock(
'cors',
() => () => (_req: unknown, _res: unknown, next: () => void) => {
next()
},
{ virtual: true }
)
jest.mock('@hcengineering/api-client', () => ({}), { virtual: true })
jest.mock('@hcengineering/core', () => ({ newMetrics: jest.fn() }), { virtual: true })
jest.mock('@hcengineering/server-core', () => ({ initStatisticsContext: () => mockContext }), { virtual: true })
jest.mock('@hcengineering/server-storage', () => ({ buildStorageFromConfig: () => mockStorage }), { virtual: true })
jest.mock('@hcengineering/server-guest-resources', () => ({}), { virtual: true })
jest.mock('@hcengineering/analytics-service', () => ({}), { virtual: true })
jest.mock(
'@hcengineering/account-client',
() => ({
getClient: (_url: string, token: string) => ({
getLoginInfoByToken: async () =>
token === 'invalid' ? {} : { workspace: token, workspaceDataId: token, workspaceUrl: token }
}),
isWorkspaceLoginInfo: (info: any) => info.workspace !== undefined
}),
{ virtual: true }
)
jest.mock('../config', () => ({
__esModule: true,
default: { GotenbergUrl: 'http://converter', AccountsUrl: 'http://accounts' }
}))
jest.mock('../print', () => ({}))
jest.mock('../convert', () => ({ convertToHtml: jest.fn(async () => '<p>HTML</p>') }))
jest.mock('../preview', () => ({
...jest.requireActual('../preview'),
convertToPdf: jest.fn(async () => Buffer.from('%PDF-fixture'))
}))
describe('authenticated conversion route', () => {
let server: Server
let base: string
let etag: string
let sourceType: string
let size: number
const cache = new Map<string, any>()
const docx = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
beforeAll(async () => {
const app = createServer({} as any, []).app
await new Promise<void>((resolve) => {
server = app.listen(0, '127.0.0.1', resolve)
})
base = `http://127.0.0.1:${(server.address() as any).port}`
})
afterAll(async () => {
await new Promise<void>((resolve) =>
server.close(() => {
resolve()
})
)
})
beforeEach(() => {
jest.clearAllMocks()
cache.clear()
etag = 'version-one'
sourceType = docx
size = 12
config.GotenbergUrl = 'http://converter'
mockStorage.stat.mockImplementation(async (_ctx, ws, id) =>
id === 'source' ? { contentType: sourceType, etag, size } : cache.get(`${ws.uuid}/${id}`)
)
mockStorage.read.mockResolvedValue([Buffer.from('document')])
mockStorage.put.mockImplementation(async (_ctx, ws, id, _bytes, contentType) => {
cache.set(`${ws.uuid}/${id}`, { contentType })
})
})
const request = async (query = '?format=preview', token = 'workspace-a'): Promise<Response> =>
await fetch(`${base}/convert/source${query}`, { headers: { Authorization: `Bearer ${token}` } })
it('authenticates before storage access', async () => {
expect((await request('', 'invalid')).status).toBe(401)
expect(mockStorage.stat).not.toHaveBeenCalled()
})
it('caches the PDF by workspace and source version while retaining the source', async () => {
const first = await (await request()).json()
expect(first.contentType).toBe('application/pdf')
expect(await (await request()).json()).toEqual(first)
expect(convertToPdf).toHaveBeenCalledTimes(1)
await request('?format=preview', 'workspace-b')
expect(convertToPdf).toHaveBeenCalledTimes(2)
etag = 'version-two'
expect((await (await request()).json()).id).not.toBe(first.id)
expect(convertToPdf).toHaveBeenCalledTimes(3)
expect(mockStorage.put.mock.calls.every((call) => call[2] !== 'source')).toBe(true)
})
it('keeps old clients on HTML and falls back when no PDF converter is configured', async () => {
expect((await (await request('')).json()).contentType).toBe('text/html')
config.GotenbergUrl = ''
expect((await (await request()).json()).contentType).toBe('text/html')
expect((await request('?format=pdf')).status).toBe(503)
expect(convertToPdf).not.toHaveBeenCalled()
})
it('rejects DOC and oversized sources before reading their bytes', async () => {
sourceType = 'application/msword'
expect((await request()).status).toBe(400)
sourceType = docx
size = 26 * 1024 * 1024
expect((await request()).status).toBe(413)
expect(mockStorage.read).not.toHaveBeenCalled()
})
it('completes HTML conversion while a PDF conversion is stalled', async () => {
let releasePdf!: (value: Buffer) => void
let markStarted!: () => void
const started = new Promise<void>((resolve) => {
markStarted = resolve
})
jest.mocked(convertToPdf).mockImplementationOnce(
async () =>
await new Promise<Buffer>((resolve) => {
releasePdf = resolve
markStarted()
})
)
const pdf = request()
await started
let deadline: ReturnType<typeof setTimeout> | undefined
try {
const html = await Promise.race([
request('?format=html'),
new Promise<never>((_resolve, reject) => {
deadline = setTimeout(() => {
reject(new Error('HTML is blocked by PDF conversion'))
}, 1000)
})
])
expect(html.status).toBe(200)
expect((await html.json()).contentType).toBe('text/html')
} finally {
clearTimeout(deadline)
releasePdf(Buffer.from('%PDF-fixture'))
await pdf
}
})
it.each(['', '?format=html'])('preserves oversized legacy HTML conversion for %s', async (query) => {
size = 26 * 1024 * 1024
mockStorage.read.mockResolvedValue([Buffer.alloc(size)])
const response = await request(query)
expect(response.status).toBe(200)
expect((await response.json()).contentType).toBe('text/html')
expect(mockStorage.read).toHaveBeenCalledTimes(1)
expect(convertToPdf).not.toHaveBeenCalled()
})
it('does not cache failed conversions and permits retry', async () => {
jest.mocked(convertToPdf).mockRejectedValueOnce(new Error('converter failed'))
expect((await request()).status).toBe(500)
expect(mockStorage.put).not.toHaveBeenCalled()
expect((await request()).status).toBe(200)
})
})
@@ -0,0 +1,98 @@
// Copyright © 2026 Huly Contributors.
import { convertToPdf, createPreviewQueue, getPreviewId } from '../preview'
describe('document preview', () => {
afterEach(() => jest.restoreAllMocks())
it('preserves legacy cache keys and separates PDF versions', () => {
expect(getPreviewId('file', '"etag"', 'html')).toBe('file@etag')
expect(getPreviewId('file', '"etag"', 'pdf')).not.toBe(getPreviewId('file', '"etag"', 'html'))
expect(getPreviewId('file', 'new', 'pdf')).not.toBe(getPreviewId('file', 'old', 'pdf'))
})
it('serializes conversions, coalesces duplicates and bounds the queue', async () => {
const queue = createPreviewQueue(1)
let release!: (value: string) => void
const first = jest.fn(
async () =>
await new Promise<string>((resolve) => {
release = resolve
})
)
const second = jest.fn(async () => 'second')
const a = queue.run('a', first)
const duplicate = queue.run('a', first)
const b = queue.run('b', second)
await expect(queue.run('c', second)).rejects.toMatchObject({ code: 503 })
expect(first).toHaveBeenCalledTimes(1)
expect(second).not.toHaveBeenCalled()
release('first')
expect(await a).toBe('first')
expect(await duplicate).toBe('first')
expect(await b).toBe('second')
})
it('releases failed jobs so retry is possible', async () => {
const queue = createPreviewQueue()
await expect(
queue.run('a', async () => {
throw new Error('broken')
})
).rejects.toThrow('broken')
await expect(queue.run('a', async () => 'retry')).resolves.toBe('retry')
})
it('sends document bytes and returns a validated PDF', async () => {
const request = jest.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response('%PDF-1.7\nfixture', {
headers: { 'Content-Type': 'application/pdf' }
})
)
expect((await convertToPdf(Buffer.from('docx'), 'http://converter:3000/')).toString()).toContain('%PDF-')
const [url, options] = request.mock.calls[0]
expect(url).toBe('http://converter:3000/forms/libreoffice/convert')
expect(options?.method).toBe('POST')
expect((options?.body as FormData).get('files')).toBeInstanceOf(Blob)
})
it.each([
[200, 'text/html', '%PDF-1.7'],
[200, 'application/pdf', 'not a pdf'],
[500, 'application/pdf', '%PDF-1.7']
])('rejects an invalid converter response %s %s', async (status, contentType, body) => {
jest.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(body, {
status,
headers: { 'Content-Type': contentType }
})
)
await expect(convertToPdf(Buffer.from('docx'), 'http://converter')).rejects.toMatchObject({ code: 502 })
})
it('bounds both input and streamed output', async () => {
const request = jest.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response('%PDF-too-large', {
headers: { 'Content-Type': 'application/pdf' }
})
)
await expect(convertToPdf(Buffer.alloc(26 * 1024 * 1024), 'http://converter')).rejects.toMatchObject({ code: 413 })
expect(request).not.toHaveBeenCalled()
await expect(convertToPdf(Buffer.from('docx'), 'http://converter', { maxOutputBytes: 8 })).rejects.toMatchObject({
code: 502
})
})
it('aborts stalled conversions', async () => {
jest.spyOn(globalThis, 'fetch').mockImplementation(
async (_url, options) =>
await new Promise((_resolve, reject) => {
options?.signal?.addEventListener('abort', () => {
reject(new Error('aborted'))
})
})
)
await expect(convertToPdf(Buffer.from('docx'), 'http://converter', { timeoutMs: 10 })).rejects.toMatchObject({
code: 504
})
})
})
+3 -1
View File
@@ -13,6 +13,7 @@ export interface Config {
FrontUrl: string
AllowedHostnames: string[]
PuppeteerArgs: string[]
GotenbergUrl: string
}
const parseNumber = (str: string | undefined): number | undefined => (str !== undefined ? Number(str) : undefined)
@@ -27,7 +28,8 @@ const config: Config = (() => {
AccountsUrl: process.env.ACCOUNTS_URL,
FrontUrl: process.env.FRONT_URL,
AllowedHostnames: allowedHostnames == null ? [] : allowedHostnames.split(','),
PuppeteerArgs: puppeteerArgs.split(',')
PuppeteerArgs: puppeteerArgs.split(','),
GotenbergUrl: process.env.GOTENBERG_URL ?? ''
}
const missingEnv = (Object.keys(params) as Array<keyof Config>).filter((key) => params[key] === undefined)
+117
View File
@@ -0,0 +1,117 @@
// Copyright © 2026 Huly Contributors.
import { ApiError } from './error'
export const maxDocumentBytes = 25 * 1024 * 1024
export function getPreviewId (file: string, etag: string, format: 'html' | 'pdf'): string {
const legacyId = `${file}@${etag.replaceAll('"', '')}`
return format === 'html' ? legacyId : `${legacyId}@pdf-v1`
}
/** One converter process, bounded waiting jobs, and shared results for identical source versions. */
export function createPreviewQueue (maxPending = 4): {
run: <T>(key: string, operation: () => Promise<T>) => Promise<T>
} {
const results = new Map<string, Promise<unknown>>()
const pending: Array<() => void> = []
let active = false
return {
run<T>(key: string, operation: () => Promise<T>): Promise<T> {
const existing = results.get(key)
if (existing !== undefined) return existing as Promise<T>
if (active && pending.length >= maxPending) {
return Promise.reject(new ApiError(503, 'Document preview is busy. Please try again.'))
}
let resolveJob!: (value: T) => void
let rejectJob!: (reason: unknown) => void
const result = new Promise<T>((resolve, reject) => {
resolveJob = resolve
rejectJob = reject
})
results.set(key, result)
const start = (): void => {
active = true
const finish = (): void => {
results.delete(key)
active = false
pending.shift()?.()
}
void Promise.resolve()
.then(operation)
.then(
(value) => {
finish()
resolveJob(value)
},
(error) => {
finish()
rejectJob(error)
}
)
}
if (active) pending.push(start)
else start()
return result
}
}
}
/** Send bytes, never a source URL: document storage stays behind Huly authentication. */
export async function convertToPdf (
document: Buffer,
endpoint: string,
options: { timeoutMs?: number, maxOutputBytes?: number } = {}
): Promise<Buffer> {
if (document.length > maxDocumentBytes) throw new ApiError(413, 'Document exceeds the 25 MiB preview limit')
const maxOutput = options.maxOutputBytes ?? 50 * 1024 * 1024
const controller = new AbortController()
const timer = setTimeout(() => {
controller.abort()
}, options.timeoutMs ?? 60000)
try {
const body = new FormData()
body.append('files', new Blob([new Uint8Array(document)]), 'document.docx')
const response = await fetch(`${endpoint.replace(/\/+$/, '')}/forms/libreoffice/convert`, {
method: 'POST',
body,
signal: controller.signal,
redirect: 'error'
})
if (!response.ok || response.headers.get('content-type')?.split(';')[0].trim() !== 'application/pdf') {
await response.body?.cancel()
throw new ApiError(502, 'Failed to convert document to PDF')
}
if (Number(response.headers.get('content-length')) > maxOutput) {
await response.body?.cancel()
throw new ApiError(502, 'Converted document exceeds the preview limit')
}
const reader = response.body?.getReader()
if (reader === undefined) throw new ApiError(502, 'Empty PDF response')
const chunks: Buffer[] = []
let size = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
size += value.length
if (size > maxOutput) {
await reader.cancel()
throw new ApiError(502, 'Converted document exceeds the preview limit')
}
chunks.push(Buffer.from(value))
}
} finally {
reader.releaseLock()
}
const pdf = Buffer.concat(chunks)
if (pdf.subarray(0, 5).toString() !== '%PDF-') throw new ApiError(502, 'Invalid PDF response')
return pdf
} catch (error) {
if (controller.signal.aborted) throw new ApiError(504, 'Document conversion timed out')
if (error instanceof ApiError) throw error
throw new ApiError(502, 'Document converter is unavailable')
} finally {
clearTimeout(timer)
}
}
+35 -18
View File
@@ -33,6 +33,7 @@ import { join } from 'path'
import config from './config'
import { convertToHtml } from './convert'
import { convertToPdf, createPreviewQueue, getPreviewId, maxDocumentBytes } from './preview'
import { ApiError } from './error'
import { PrintOptions, print, validKinds, validPageOrientations } from './print'
import { withMeasureContext } from './middleware'
@@ -177,6 +178,7 @@ export function createServer (
allowedHostnames: string[]
): { app: Express, close: () => void } {
const storageAdapter = buildStorageFromConfig(storageConfig)
const pdfPreviewQueue = createPreviewQueue()
const measureCtx = initStatisticsContext('print', {
factory: () =>
createOpenTelemetryMetricsContext(
@@ -255,29 +257,48 @@ export function createServer (
throw new ApiError(400, `File of this type (${stat.contentType}) cannot be converted`)
}
const convertId = getConvertId(file, stat.etag)
if (req.query.format !== undefined && !['preview', 'html', 'pdf'].includes(req.query.format as string)) {
throw new ApiError(400, 'Unsupported preview format')
}
const format =
(req.query.format === 'preview' || req.query.format === 'pdf') && config.GotenbergUrl !== '' ? 'pdf' : 'html'
if (req.query.format === 'pdf' && config.GotenbergUrl === '') {
throw new ApiError(503, 'PDF document preview is not configured')
}
const contentType = format === 'pdf' ? 'application/pdf' : 'text/html'
const convertId = getPreviewId(file, stat.etag, format)
const convertStats = await storageAdapter.stat(ctx, wsUuid, convertId)
if (convertStats === undefined) {
const originalFile = await storageAdapter.read(ctx, wsUuid, file)
const convert = async (): Promise<void> => {
// Another request may have filled the cache while this job waited for the converter.
if ((await storageAdapter.stat(ctx, wsUuid, convertId)) !== undefined) return
const originalFile = await storageAdapter.read(ctx, wsUuid, file)
if (originalFile === undefined) {
throw new ApiError(404, `File ${file} not found`)
if (originalFile === undefined) {
throw new ApiError(404, `File ${file} not found`)
}
const input = Buffer.concat(originalFile as any)
if (format === 'pdf' && input.length > maxDocumentBytes) {
throw new ApiError(413, 'Document exceeds the 25 MiB preview limit')
}
const output =
format === 'pdf'
? await ctx.with('convertToPdf', {}, () => convertToPdf(input, config.GotenbergUrl))
: Buffer.from(await ctx.with('convertToHtml', {}, () => convertToHtml(input)))
await storageAdapter.put(ctx, wsUuid, convertId, output, contentType, output.length)
}
const htmlRes = await ctx.with('convertToHtml', {}, () => convertToHtml(Buffer.concat(originalFile as any)))
if (htmlRes === undefined) {
throw new ApiError(400, 'Failed to convert')
if (format === 'pdf') {
if (stat.size > maxDocumentBytes) throw new ApiError(413, 'Document exceeds the 25 MiB preview limit')
await pdfPreviewQueue.run(JSON.stringify([wsUuid.uuid, wsUuid.dataId, convertId]), convert)
} else {
await convert()
}
const htmlBuf = Buffer.from(htmlRes)
await storageAdapter.put(ctx, wsUuid, convertId, htmlBuf, 'text/html', htmlBuf.length)
}
res.contentType('application/json')
res.send({ id: convertId })
res.send({ id: convertId, contentType })
})
)
@@ -350,7 +371,3 @@ export function listen (e: Express, port: number, host?: string): Server {
return host !== undefined ? e.listen(port, host, cb) : e.listen(port, cb)
}
function getConvertId (file: string, etag: string): string {
return `${file}@${etag.replaceAll('"', '')}`
}