Add PDF export via Gotenberg (#67)
All checks were successful
CD / Build and push images (push) Successful in 4m3s
CI / Lint, typecheck, test (push) Successful in 3m5s
CI / Auth e2e pack (push) Successful in 4m7s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m17s
CD / Promote to Int (push) Successful in 12s
All checks were successful
CD / Build and push images (push) Successful in 4m3s
CI / Lint, typecheck, test (push) Successful in 3m5s
CI / Auth e2e pack (push) Successful in 4m7s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m17s
CD / Promote to Int (push) Successful in 12s
Server-side PDF export for reading/sharing (ADR 0009), rendered by a new internal Gotenberg (headless Chromium) sidecar. - Sidecar: `gotenberg/gotenberg:8` in the compose stack (internal, pinned, healthcheck); api `GOTENBERG_URL` env; a `renderer` readyz check at warning-level (mirrors the converter) so PDF export degrades gracefully when Gotenberg is down without failing readyz. - Export HTML: `buildPdfHtml` renders a self-contained document (no app chrome) — the page's content with images inlined as data URIs, the pond's fonts inlined as base64 `@font-face` + applied via CSS variables (ADR 0016), print CSS (A4, page-break rules, a title header), and page numbers from Gotenberg's footer. Plugin-block fallbacks are a marked TODO(#79) for M7. - Fonts in the api image: the api Dockerfile now bakes the font catalog in (`build-fonts.mjs` with FONTS_OUT) so the exporter can read a pond's chosen WOFF2 and inline them; a missing file falls back to the system stack. - Job flow: `POST /pages/:id/export {format: pdf}` builds the HTML (read permission checked by the guard) and enqueues an `export_pdf` job on the #62 queue with the HTML as input; the worker branches `to === 'pdf'` to the `GotenbergRenderer` (html → pdf) instead of pandoc, retrying an unreachable sidecar and failing a refused render (`renderer_unavailable`/`render_failed`, de+en). The client polls and downloads `GET /jobs/:id/result`. - Frontend: the page-menu PDF button is now a real export (PDF added to EXPORT_FORMATS; the disabled placeholder removed). - Tests: export.service.db PDF cases (HTML has title/font-variable/inlined image; renderer-down fails with `render_failed`); e2e PDF export self-skips without a Gotenberg sidecar (like the .docx case). Verified locally against real Gotenberg — a valid PDF with the pond font embedded. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
parent
f500198c5d
commit
8a68ef68e7
@ -7,13 +7,19 @@ RUN npm install -g pnpm@11
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./
|
||||
COPY packages/shared ./packages/shared
|
||||
COPY apps/api ./apps/api
|
||||
COPY deploy/fonts ./deploy/fonts
|
||||
RUN pnpm install --frozen-lockfile --filter @dorfteich/api... \
|
||||
&& pnpm --filter @dorfteich/shared build \
|
||||
&& pnpm --filter @dorfteich/api build \
|
||||
# Bake the self-hosted font catalog in so the PDF exporter can inline a
|
||||
# pond's fonts as base64 (ADR 0016). Same download as the web image; fails
|
||||
# the build if a family lacks license info.
|
||||
&& FONTS_OUT=/repo/fonts node deploy/fonts/build-fonts.mjs \
|
||||
# Self-contained production bundle (prod deps only, incl. the prisma CLI
|
||||
# needed for migrate-on-start) at /out.
|
||||
&& pnpm --filter @dorfteich/api deploy --prod --legacy /out \
|
||||
&& cp -r apps/api/dist /out/dist
|
||||
&& cp -r apps/api/dist /out/dist \
|
||||
&& cp -r /repo/fonts /out/fonts
|
||||
|
||||
FROM node:22.15-alpine
|
||||
ARG APP_VERSION=0.0.0-dev
|
||||
|
||||
@ -40,6 +40,7 @@ export class ReadinessService {
|
||||
await this.databaseReachable(),
|
||||
await this.migrationsApplied(),
|
||||
await this.converterReachable(),
|
||||
await this.rendererReachable(),
|
||||
];
|
||||
return {
|
||||
status: checks.some((c) => c.status === 'failed') ? 'unready' : 'ok',
|
||||
@ -64,6 +65,25 @@ export class ReadinessService {
|
||||
}
|
||||
}
|
||||
|
||||
/** The Gotenberg PDF renderer (issue #67) — warning-level like the converter:
|
||||
* PDF export degrades when it is down, but the instance stays ready. */
|
||||
private async rendererReachable(): Promise<ReadinessCheck> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), CONVERTER_PROBE_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(`${this.config.env.GOTENBERG_URL}/health`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
return response.ok
|
||||
? { name: 'renderer', status: 'ok' }
|
||||
: { name: 'renderer', status: 'warn', detail: `gotenberg returned ${response.status}` };
|
||||
} catch (error) {
|
||||
return { name: 'renderer', status: 'warn', detail: shortMessage(error) };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
private async databaseReachable(): Promise<ReadinessCheck> {
|
||||
try {
|
||||
await this.prisma.$queryRaw`SELECT 1`;
|
||||
|
||||
@ -6,8 +6,9 @@ import { PinoLogger } from 'nestjs-pino';
|
||||
import { AppConfig } from '../config/app-config.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
import { GotenbergRenderer, RenderError } from './gotenberg.renderer';
|
||||
import { IMPORT_PROCESSOR, ImportProcessor, isImportKind } from './import.constants';
|
||||
import { ConversionError, PandocConverter } from './pandoc.converter';
|
||||
import { ConversionError, ConversionResult, PandocConverter } from './pandoc.converter';
|
||||
|
||||
/** How often the worker sweeps for pending jobs on its own — the safety net
|
||||
* that makes a queued conversion survive an API restart even if no new
|
||||
@ -38,6 +39,7 @@ export class ConversionWorker implements OnModuleInit, OnModuleDestroy {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly converter: PandocConverter,
|
||||
private readonly renderer: GotenbergRenderer,
|
||||
private readonly config: AppConfig,
|
||||
private readonly logger: PinoLogger,
|
||||
// Resolved lazily to break the construction cycle (the import service
|
||||
@ -47,6 +49,16 @@ export class ConversionWorker implements OnModuleInit, OnModuleDestroy {
|
||||
this.logger.setContext(ConversionWorker.name);
|
||||
}
|
||||
|
||||
private async convert(job: ConversionJob): Promise<{ bytes: Buffer; mimeType: string }> {
|
||||
const result: ConversionResult = await this.converter.convert({
|
||||
from: job.sourceFormat,
|
||||
to: job.targetFormat,
|
||||
input: Buffer.from(job.input),
|
||||
standalone: job.standalone,
|
||||
});
|
||||
return { bytes: result.output, mimeType: result.mimeType };
|
||||
}
|
||||
|
||||
onModuleInit(): void {
|
||||
if (this.config.env.NODE_ENV === 'test') return; // tests drive drain() directly
|
||||
this.timer = setInterval(() => void this.drain(), SWEEP_MS);
|
||||
@ -115,19 +127,22 @@ export class ConversionWorker implements OnModuleInit, OnModuleDestroy {
|
||||
await this.moduleRef.get<ImportProcessor>(IMPORT_PROCESSOR, { strict: false }).run(job);
|
||||
return;
|
||||
}
|
||||
const result = await this.converter.convert({
|
||||
from: job.sourceFormat,
|
||||
to: job.targetFormat,
|
||||
input: Buffer.from(job.input),
|
||||
standalone: job.standalone,
|
||||
});
|
||||
// PDF export renders HTML through Gotenberg (#67); everything else is a
|
||||
// pandoc byte→byte conversion (#62/#65).
|
||||
const output =
|
||||
job.targetFormat === 'pdf'
|
||||
? {
|
||||
bytes: await this.renderer.renderHtmlToPdf(Buffer.from(job.input).toString('utf8')),
|
||||
mimeType: 'application/pdf',
|
||||
}
|
||||
: await this.convert(job);
|
||||
await this.prisma.conversionJob.update({
|
||||
where: { id: job.id },
|
||||
data: {
|
||||
status: 'SUCCEEDED',
|
||||
// Prisma Bytes = Uint8Array<ArrayBuffer>; copy the Buffer in (#40).
|
||||
result: new Uint8Array(result.output),
|
||||
resultMimeType: result.mimeType,
|
||||
result: new Uint8Array(output.bytes),
|
||||
resultMimeType: output.mimeType,
|
||||
errorCode: null,
|
||||
},
|
||||
});
|
||||
@ -141,8 +156,9 @@ export class ConversionWorker implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
private async recordFailure(job: ConversionJob, error: unknown): Promise<void> {
|
||||
const code = error instanceof ConversionError ? error.code : 'conversion_failed';
|
||||
const retryable = error instanceof ConversionError ? error.retryable : false;
|
||||
const typed = error instanceof ConversionError || error instanceof RenderError ? error : null;
|
||||
const code = typed?.code ?? 'conversion_failed';
|
||||
const retryable = typed?.retryable ?? false;
|
||||
// `attempts` was already incremented by the claim, so it reflects this try.
|
||||
if (retryable && job.attempts < MAX_ATTEMPTS) {
|
||||
await this.prisma.conversionJob.update({
|
||||
|
||||
@ -12,12 +12,13 @@ import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||
import { UsersService } from '../users/users.service';
|
||||
|
||||
import { ConversionWorker } from './conversion-worker.service';
|
||||
import { GotenbergRenderer, RenderError } from './gotenberg.renderer';
|
||||
import { ConversionRequest, ConversionResult, PandocConverter } from './pandoc.converter';
|
||||
|
||||
/** Export (issue #65): pond ZIP of Markdown + per-page docx/odt job. The ZIP
|
||||
* path needs no converter; the docx path uses an injected fake that records the
|
||||
* Markdown it is handed, so the image-inlining / wikilink-flattening is checked
|
||||
* without a live pandoc. */
|
||||
/** Export (issues #65/#67): pond ZIP of Markdown, per-page docx/odt (pandoc),
|
||||
* and per-page PDF (Gotenberg). The office/PDF paths use injected fakes that
|
||||
* record the input they are handed, so image-inlining / font-inlining is checked
|
||||
* without a live sidecar. */
|
||||
|
||||
const PNG_BASE64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
|
||||
@ -33,6 +34,19 @@ class RecordingConverter extends PandocConverter {
|
||||
}
|
||||
}
|
||||
|
||||
class RecordingRenderer extends GotenbergRenderer {
|
||||
lastHtml = '';
|
||||
failWith: RenderError | null = null;
|
||||
renderHtmlToPdf(html: string): Promise<Buffer> {
|
||||
this.lastHtml = html;
|
||||
if (this.failWith) return Promise.reject(this.failWith);
|
||||
return Promise.resolve(Buffer.from('%PDF-1.7 fake'));
|
||||
}
|
||||
reachable(): Promise<boolean> {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
}
|
||||
|
||||
/** Filenames of every entry in a ZIP buffer. */
|
||||
function zipEntries(buffer: Buffer): Record<string, Uint8Array> {
|
||||
return unzipSync(new Uint8Array(buffer));
|
||||
@ -44,6 +58,7 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => {
|
||||
let worker: ConversionWorker;
|
||||
let files: FilesService;
|
||||
let fake: RecordingConverter;
|
||||
let renderer: RecordingRenderer;
|
||||
const suffix = uniqueSuffix();
|
||||
const password = 'exportiere meine sachen 1';
|
||||
|
||||
@ -64,7 +79,12 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => {
|
||||
|
||||
/** Create a page with a crafted content-cache Markdown (the exporter reads the
|
||||
* cache, not the Yjs state), returning its slug. */
|
||||
async function seedPage(pondId: string, title: string, markdown: string): Promise<string> {
|
||||
async function seedPage(
|
||||
pondId: string,
|
||||
title: string,
|
||||
markdown: string,
|
||||
html = '',
|
||||
): Promise<string> {
|
||||
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
||||
await prisma.page.create({
|
||||
data: {
|
||||
@ -74,7 +94,7 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => {
|
||||
ydocState: new Uint8Array(),
|
||||
sortKey: title,
|
||||
createdBy: ownerId,
|
||||
contentCache: { create: { plainText: markdown, markdown, html: '', outline: [] } },
|
||||
contentCache: { create: { plainText: markdown, markdown, html, outline: [] } },
|
||||
},
|
||||
});
|
||||
return slug;
|
||||
@ -84,8 +104,13 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => {
|
||||
prisma = createTestPrisma();
|
||||
await prisma.rateLimit.deleteMany({});
|
||||
fake = new RecordingConverter();
|
||||
renderer = new RecordingRenderer();
|
||||
app = await createTestApp((builder) =>
|
||||
builder.overrideProvider(PandocConverter).useValue(fake),
|
||||
builder
|
||||
.overrideProvider(PandocConverter)
|
||||
.useValue(fake)
|
||||
.overrideProvider(GotenbergRenderer)
|
||||
.useValue(renderer),
|
||||
);
|
||||
worker = app.get(ConversionWorker);
|
||||
files = app.get(FilesService);
|
||||
@ -298,6 +323,79 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => {
|
||||
expect(result.text).toBe('OFFICE-BYTES');
|
||||
});
|
||||
|
||||
it('exports a page to PDF: content + image inlined, font CSS, via Gotenberg', async () => {
|
||||
const image = await files.upload({ id: ownerId } as never, personalPondId, {
|
||||
buffer: Buffer.from(PNG_BASE64, 'base64'),
|
||||
size: 70,
|
||||
originalname: 'pdf.png',
|
||||
});
|
||||
const slug = await seedPage(
|
||||
personalPondId,
|
||||
'Pdf Source',
|
||||
'# Pdf Source\n\nbody',
|
||||
`<p>A PDF body paragraph.</p><img data-file-id="${image.id}" alt="pic">`,
|
||||
);
|
||||
const page = await prisma.page.findFirstOrThrow({
|
||||
where: { pondId: personalPondId, slug },
|
||||
});
|
||||
|
||||
const enqueued = await api()
|
||||
.post(`/api/v1/pages/${page.id}/export`)
|
||||
.set('Cookie', ownerCookie)
|
||||
.send({ format: 'pdf' })
|
||||
.expect(201);
|
||||
expect(enqueued.body.kind).toBe('export_pdf');
|
||||
|
||||
await worker.drain();
|
||||
|
||||
// The HTML handed to Gotenberg carries the title, the pond's font stack as a
|
||||
// CSS variable, and the image inlined as a data URI (no network needed).
|
||||
expect(renderer.lastHtml).toContain('Pdf Source');
|
||||
expect(renderer.lastHtml).toContain("--font-body: 'Roboto'");
|
||||
expect(renderer.lastHtml).toContain('src="data:image/png;base64,');
|
||||
|
||||
const done = await api()
|
||||
.get(`/api/v1/jobs/${enqueued.body.id}`)
|
||||
.set('Cookie', ownerCookie)
|
||||
.expect(200);
|
||||
expect(done.body.status).toBe('succeeded');
|
||||
const result = await api()
|
||||
.get(`/api/v1/jobs/${enqueued.body.id}/result`)
|
||||
.set('Cookie', ownerCookie)
|
||||
.buffer(true)
|
||||
.parse((r, cb) => {
|
||||
const chunks: Buffer[] = [];
|
||||
r.on('data', (c: Buffer) => chunks.push(c));
|
||||
r.on('end', () => cb(null, Buffer.concat(chunks)));
|
||||
})
|
||||
.expect(200);
|
||||
expect(result.headers['content-type']).toContain('application/pdf');
|
||||
expect((result.body as Buffer).toString('utf8')).toContain('%PDF');
|
||||
});
|
||||
|
||||
it('fails a PDF export when the renderer is down', async () => {
|
||||
renderer.failWith = new RenderError('render_failed', false, 'gotenberg exploded');
|
||||
const slug = await seedPage(personalPondId, 'Pdf Fails', 'x', '<p>x</p>');
|
||||
const page = await prisma.page.findFirstOrThrow({
|
||||
where: { pondId: personalPondId, slug },
|
||||
});
|
||||
|
||||
const enqueued = await api()
|
||||
.post(`/api/v1/pages/${page.id}/export`)
|
||||
.set('Cookie', ownerCookie)
|
||||
.send({ format: 'pdf' })
|
||||
.expect(201);
|
||||
await worker.drain();
|
||||
renderer.failWith = null;
|
||||
|
||||
const done = await api()
|
||||
.get(`/api/v1/jobs/${enqueued.body.id}`)
|
||||
.set('Cookie', ownerCookie)
|
||||
.expect(200);
|
||||
expect(done.body.status).toBe('failed');
|
||||
expect(done.body.errorCode).toBe('render_failed');
|
||||
});
|
||||
|
||||
it('streams a large pond export (500 pages) without buffering it all', async () => {
|
||||
const big = await prisma.pond.create({
|
||||
data: {
|
||||
|
||||
@ -1,10 +1,20 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ConversionJobView, ExportFormat } from '@dorfteich/shared';
|
||||
import {
|
||||
ConversionJobView,
|
||||
ExportFormat,
|
||||
PondFonts,
|
||||
fontSlug,
|
||||
pondSettingsSchema,
|
||||
} from '@dorfteich/shared';
|
||||
import { User } from '@prisma/client';
|
||||
import archiver from 'archiver';
|
||||
import type { Response } from 'express';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { AppConfig } from '../config/app-config.service';
|
||||
import { FileStorageService } from '../files/file-storage.service';
|
||||
import { PermissionService } from '../permissions/permission.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@ -16,6 +26,7 @@ import {
|
||||
markdownForDocument,
|
||||
markdownForZip,
|
||||
} from './export-markdown';
|
||||
import { buildPdfHtml } from './pdf-html';
|
||||
|
||||
/**
|
||||
* Export (ADR 0009, issue #65): a whole pond as a ZIP of Markdown (one `.md`
|
||||
@ -31,6 +42,7 @@ export class ExportService {
|
||||
private readonly permissions: PermissionService,
|
||||
private readonly storage: FileStorageService,
|
||||
private readonly jobs: ConversionJobService,
|
||||
private readonly config: AppConfig,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
this.logger.setContext(ExportService.name);
|
||||
@ -136,6 +148,8 @@ export class ExportService {
|
||||
pageId: string,
|
||||
format: ExportFormat,
|
||||
): Promise<ConversionJobView> {
|
||||
if (format === 'pdf') return this.enqueuePdfExport(user, pageId);
|
||||
|
||||
const page = await this.prisma.page.findFirst({
|
||||
where: { id: pageId, deletedAt: null },
|
||||
include: { contentCache: { select: { markdown: true } } },
|
||||
@ -161,6 +175,88 @@ export class ExportService {
|
||||
return this.jobs.viewOf(job);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a PDF export (issue #67). The self-contained export HTML — content
|
||||
* with images inlined, the pond's fonts inlined as base64 `@font-face`, print
|
||||
* CSS — is built here (permission already checked by the guard) and stored as
|
||||
* the job input; the worker sends it to Gotenberg (`html → pdf`). Building it
|
||||
* up front keeps the job a plain byte→byte render the worker can retry.
|
||||
*/
|
||||
private async enqueuePdfExport(user: User, pageId: string): Promise<ConversionJobView> {
|
||||
const page = await this.prisma.page.findFirst({
|
||||
where: { id: pageId, deletedAt: null },
|
||||
include: {
|
||||
pond: { select: { name: true, settings: true } },
|
||||
contentCache: { select: { html: true } },
|
||||
},
|
||||
});
|
||||
if (!page) throw new NotFoundException();
|
||||
|
||||
const fonts = pondSettingsSchema.parse(page.pond.settings ?? {}).fonts;
|
||||
const bodyHtml = await this.inlineHtmlImages(page.pondId, page.contentCache?.html ?? '');
|
||||
const html = buildPdfHtml({
|
||||
title: page.title,
|
||||
pondName: page.pond.name,
|
||||
bodyHtml,
|
||||
fonts,
|
||||
fontFaceCss: await this.fontFaceCss(fonts),
|
||||
});
|
||||
|
||||
const job = await this.jobs.enqueue({
|
||||
ownerId: user.id,
|
||||
kind: 'export_pdf',
|
||||
from: 'html',
|
||||
to: 'pdf',
|
||||
input: Buffer.from(html, 'utf8'),
|
||||
standalone: true,
|
||||
});
|
||||
this.logger.info(
|
||||
{ jobId: job.id, pageId, format: 'pdf', userId: user.id },
|
||||
'audit: page export enqueued',
|
||||
);
|
||||
return this.jobs.viewOf(job);
|
||||
}
|
||||
|
||||
/** Replace each `<img data-file-id="X">` with an inlined `data:` URI so the
|
||||
* PDF render (isolated from the api) needs no network to fetch media. */
|
||||
private async inlineHtmlImages(pondId: string, html: string): Promise<string> {
|
||||
const ids = [...html.matchAll(/data-file-id="([A-Za-z0-9-]+)"/g)].map((m) => m[1]!);
|
||||
if (ids.length === 0) return html;
|
||||
const dataUriById = await this.inlineImages(pondId, ids);
|
||||
return html.replace(/data-file-id="([A-Za-z0-9-]+)"/g, (whole, id: string) => {
|
||||
const uri = dataUriById.get(id);
|
||||
return uri ? `src="${uri}" ${whole}` : whole;
|
||||
});
|
||||
}
|
||||
|
||||
/** Base64 `@font-face` rules for the pond's three fonts, read from the
|
||||
* catalog baked into the image (ADR 0016). A font file that is absent (a
|
||||
* native dev run without `FONTS_DIR` populated) is skipped — the render falls
|
||||
* back to the system stack rather than failing. */
|
||||
private async fontFaceCss(fonts: PondFonts): Promise<string> {
|
||||
const slots = [fonts.heading, fonts.body, fonts.mono];
|
||||
// Dedup identical family+weight so a doc that repeats a font embeds it once.
|
||||
const seen = new Set<string>();
|
||||
const faces: string[] = [];
|
||||
for (const slot of slots) {
|
||||
const key = `${slot.family}:${slot.weight}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const slug = fontSlug(slot.family);
|
||||
const file = join(this.config.env.FONTS_DIR, slug, `${slug}-${slot.weight}.woff2`);
|
||||
try {
|
||||
const bytes = await readFile(file);
|
||||
faces.push(
|
||||
`@font-face { font-family: '${slot.family}'; font-style: normal; font-weight: ${slot.weight};` +
|
||||
` src: url('data:font/woff2;base64,${bytes.toString('base64')}') format('woff2'); }`,
|
||||
);
|
||||
} catch {
|
||||
this.logger.warn({ font: key }, 'pdf export: catalog font file missing, using fallback');
|
||||
}
|
||||
}
|
||||
return faces.join('\n');
|
||||
}
|
||||
|
||||
/** Read each referenced attachment's bytes into a `data:` URI (a page has few
|
||||
* images, so holding them briefly is fine — unlike the streamed pond ZIP). */
|
||||
private async inlineImages(pondId: string, ids: string[]): Promise<Map<string, string>> {
|
||||
|
||||
114
apps/api/src/import-export/gotenberg.renderer.ts
Normal file
114
apps/api/src/import-export/gotenberg.renderer.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { AppConfig } from '../config/app-config.service';
|
||||
|
||||
import { MAX_CONVERSION_OUTPUT_BYTES } from './pandoc.converter';
|
||||
|
||||
export type RenderErrorCode = 'renderer_unavailable' | 'render_failed';
|
||||
|
||||
/** A PDF render failure with a stable, localizable code. Like the converter,
|
||||
* an unreachable sidecar is retryable; a render Gotenberg refuses is not. */
|
||||
export class RenderError extends Error {
|
||||
constructor(
|
||||
readonly code: RenderErrorCode,
|
||||
readonly retryable: boolean,
|
||||
message?: string,
|
||||
) {
|
||||
super(message ?? code);
|
||||
this.name = 'RenderError';
|
||||
}
|
||||
}
|
||||
|
||||
/** A single-page footer that prints "n / total" bottom-centre — Gotenberg's
|
||||
* Chromium route substitutes the `pageNumber`/`totalPages` spans. */
|
||||
const FOOTER_HTML =
|
||||
'<html><head><style>body{margin:0;font:9px system-ui;color:#64748b;width:100%;}' +
|
||||
'div{text-align:center;}</style></head><body><div>' +
|
||||
'<span class="pageNumber"></span> / <span class="totalPages"></span>' +
|
||||
'</div></body></html>';
|
||||
|
||||
/** Per ADR 0009: a single render may run for at most 60 s. */
|
||||
const RENDER_TIMEOUT_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Typed client for the Gotenberg PDF sidecar (ADR 0009, issue #67). Abstract so
|
||||
* the export worker and its tests depend on the contract, not the HTTP
|
||||
* transport — tests inject a fake, this real implementation is exercised against
|
||||
* a running container in the integration test.
|
||||
*/
|
||||
export abstract class GotenbergRenderer {
|
||||
/** Render a standalone HTML document (fonts/images already inlined) to PDF. */
|
||||
abstract renderHtmlToPdf(html: string): Promise<Buffer>;
|
||||
abstract reachable(): Promise<boolean>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GotenbergHttpRenderer extends GotenbergRenderer {
|
||||
protected timeoutMs = RENDER_TIMEOUT_MS;
|
||||
|
||||
constructor(private readonly config: AppConfig) {
|
||||
super();
|
||||
}
|
||||
|
||||
private get baseUrl(): string {
|
||||
return this.config.env.GOTENBERG_URL;
|
||||
}
|
||||
|
||||
async reachable(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/health`);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async renderHtmlToPdf(html: string): Promise<Buffer> {
|
||||
const form = new FormData();
|
||||
// Gotenberg's Chromium route requires the main document to be `index.html`.
|
||||
form.append('files', new Blob([html], { type: 'text/html' }), 'index.html');
|
||||
form.append('files', new Blob([FOOTER_HTML], { type: 'text/html' }), 'footer.html');
|
||||
// Page geometry: A4 with room at the bottom for the page-number footer. The
|
||||
// document's own `@page`/print CSS controls the rest of the layout.
|
||||
form.append('paperWidth', '8.27');
|
||||
form.append('paperHeight', '11.7');
|
||||
form.append('marginTop', '0.6');
|
||||
form.append('marginBottom', '0.8');
|
||||
form.append('marginLeft', '0.7');
|
||||
form.append('marginRight', '0.7');
|
||||
form.append('printBackground', 'true');
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${this.baseUrl}/forms/chromium/convert/html`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new RenderError('renderer_unavailable', true, 'gotenberg timed out');
|
||||
}
|
||||
throw new RenderError('renderer_unavailable', true, shortMessage(error));
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = (await response.text().catch(() => '')).slice(0, 300);
|
||||
throw new RenderError('render_failed', false, detail || `gotenberg ${response.status}`);
|
||||
}
|
||||
const output = Buffer.from(await response.arrayBuffer());
|
||||
if (output.byteLength > MAX_CONVERSION_OUTPUT_BYTES) {
|
||||
throw new RenderError('render_failed', false, 'PDF exceeds size limit');
|
||||
}
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
function shortMessage(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.split('\n')[0]?.slice(0, 200) ?? 'unknown error';
|
||||
}
|
||||
@ -7,6 +7,7 @@ import { ConversionJobService } from './conversion-job.service';
|
||||
import { ConversionWorker } from './conversion-worker.service';
|
||||
import { ExportController } from './export.controller';
|
||||
import { ExportService } from './export.service';
|
||||
import { GotenbergHttpRenderer, GotenbergRenderer } from './gotenberg.renderer';
|
||||
import { IMPORT_PROCESSOR } from './import.constants';
|
||||
import { ImportController } from './import.controller';
|
||||
import { ImportService } from './import.service';
|
||||
@ -33,6 +34,8 @@ import { PandocConverter, PandocServerConverter } from './pandoc.converter';
|
||||
// Bind the abstract converter to the HTTP implementation; tests override
|
||||
// this provider with a fake so the queue mechanics need no live sidecar.
|
||||
{ provide: PandocConverter, useClass: PandocServerConverter },
|
||||
// Same pattern for the Gotenberg PDF renderer (#67).
|
||||
{ provide: GotenbergRenderer, useClass: GotenbergHttpRenderer },
|
||||
],
|
||||
exports: [ConversionJobService, PandocConverter],
|
||||
})
|
||||
|
||||
80
apps/api/src/import-export/pdf-html.ts
Normal file
80
apps/api/src/import-export/pdf-html.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { PondFonts, fontStack } from '@dorfteich/shared';
|
||||
|
||||
export interface PdfHtmlParams {
|
||||
title: string;
|
||||
pondName: string;
|
||||
/** The page's cached body HTML with images already inlined as data URIs. */
|
||||
bodyHtml: string;
|
||||
fonts: PondFonts;
|
||||
/** Pre-built `@font-face` rules (base64 WOFF2) for the pond's fonts. */
|
||||
fontFaceCss: string;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the standalone HTML sent to Gotenberg for PDF export (ADR 0009/0016).
|
||||
* No app chrome; the pond's fonts are applied via CSS variables (their
|
||||
* `@font-face` rules are inlined as base64 so the render makes no network
|
||||
* request), a title header sits above the content, and print CSS sets the page
|
||||
* size and sensible break behaviour. Page numbers come from Gotenberg's footer.
|
||||
*
|
||||
* TODO(#79): once plugins land (M7), plugin blocks must render their declared
|
||||
* static `fallback` here (ADR 0008) instead of whatever the content cache holds.
|
||||
*/
|
||||
export function buildPdfHtml(params: PdfHtmlParams): string {
|
||||
const { fonts } = params;
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${escapeHtml(params.title)}</title>
|
||||
<style>
|
||||
${params.fontFaceCss}
|
||||
@page { size: A4; }
|
||||
:root {
|
||||
--font-heading: ${fontStack(fonts.heading.family)};
|
||||
--font-body: ${fontStack(fonts.body.family)};
|
||||
--font-mono: ${fontStack(fonts.mono.family)};
|
||||
}
|
||||
html { font-size: 11pt; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-weight: ${fonts.body.weight};
|
||||
line-height: 1.55;
|
||||
color: #111827;
|
||||
}
|
||||
h1, h2, h3, h4 { font-family: var(--font-heading); font-weight: ${fonts.heading.weight}; line-height: 1.25; page-break-after: avoid; }
|
||||
code, pre { font-family: var(--font-mono); font-weight: ${fonts.mono.weight}; }
|
||||
pre { background: #f3f4f6; padding: 0.6em 0.8em; border-radius: 4px; white-space: pre-wrap; word-wrap: break-word; }
|
||||
code { background: #f3f4f6; border-radius: 3px; padding: 0 0.25em; }
|
||||
pre code { background: none; padding: 0; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
table { border-collapse: collapse; }
|
||||
td, th { border: 1px solid #d1d5db; padding: 0.3em 0.5em; }
|
||||
blockquote { margin: 1em 0; padding-left: 1em; border-left: 3px solid #d1d5db; color: #4b5563; }
|
||||
figure, img, table, pre { page-break-inside: avoid; }
|
||||
.pdf-header { margin-bottom: 1.5rem; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.75rem; }
|
||||
.pdf-header__pond { color: #64748b; font-size: 0.85rem; margin: 0 0 0.25rem; }
|
||||
.pdf-header__title { margin: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="pdf-header">
|
||||
<p class="pdf-header__pond">${escapeHtml(params.pondName)}</p>
|
||||
<h1 class="pdf-header__title">${escapeHtml(params.title)}</h1>
|
||||
</header>
|
||||
<main>
|
||||
${params.bodyHtml}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
@ -58,3 +58,27 @@ test('exports a page to .docx from the page menu', async ({ browser }) => {
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('exports a page to PDF from the page menu', async ({ browser }) => {
|
||||
// PDF needs the Gotenberg sidecar reachable by the api (like the .docx case
|
||||
// needs pandoc). CI's e2e stack has none, so this runs locally / on a stage
|
||||
// with E2E_GOTENBERG set.
|
||||
test.skip(!process.env.E2E_GOTENBERG, 'needs a reachable Gotenberg sidecar');
|
||||
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||
const pond = await personalPond(context);
|
||||
const created = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, {
|
||||
data: { title: `Pdf Export ${Date.now()}` },
|
||||
});
|
||||
const created_page = await created.json();
|
||||
const page = await context.newPage();
|
||||
await page.goto(`/p/${pond.slug}/${created_page.slug}`);
|
||||
|
||||
// The third export button is PDF (docx, odt, pdf).
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download', { timeout: 30000 }),
|
||||
page.locator('.editor-page__export button').nth(2).click(),
|
||||
]);
|
||||
expect(download.suggestedFilename()).toBe(`${created_page.slug}.pdf`);
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
@ -9,10 +9,9 @@ interface DocumentExportMenuProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Office-format export buttons for the page menu (issue #65): `.docx`/`.odt`
|
||||
* run a conversion job and download the result; PDF is a disabled placeholder
|
||||
* until Gotenberg (#67). Markdown copy/download live in the page menu already
|
||||
* (#30).
|
||||
* Document export buttons for the page menu (issues #65/#67): `.docx`/`.odt`
|
||||
* (pandoc) and PDF (Gotenberg) each run a conversion job and download the
|
||||
* result. Markdown copy/download live in the page menu already (#30).
|
||||
*/
|
||||
export function DocumentExportMenu({ pageId, slug }: DocumentExportMenuProps): React.JSX.Element {
|
||||
const { t } = useTranslation('export');
|
||||
@ -37,9 +36,6 @@ export function DocumentExportMenu({ pageId, slug }: DocumentExportMenuProps): R
|
||||
{label(format)}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="button" disabled title={t('pdfSoon')}>
|
||||
{t('pdf')}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@ -33,9 +33,9 @@ export interface UseDocumentExport {
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a page to `.docx`/`.odt` (issue #65): enqueue the conversion job, poll
|
||||
* it, and download the result when it succeeds. Markdown export stays a direct
|
||||
* link (#30); PDF waits for Gotenberg (#67).
|
||||
* Export a page to `.docx`/`.odt`/`.pdf` (issues #65/#67): enqueue the
|
||||
* conversion job, poll it, and download the result when it succeeds. Markdown
|
||||
* export stays a direct link (#30).
|
||||
*/
|
||||
export function useDocumentExport(): UseDocumentExport {
|
||||
const [status, setStatus] = useState<Partial<Record<ExportFormat, ExportStatus>>>({});
|
||||
|
||||
@ -62,6 +62,8 @@ services:
|
||||
UPLOADS_DIR: /data/uploads
|
||||
# Internal pandoc-server sidecar for import/export (ADR 0009, issue #62).
|
||||
PANDOC_URL: http://pandoc:3030
|
||||
# Internal Gotenberg sidecar for PDF export (ADR 0009, issue #67).
|
||||
GOTENBERG_URL: http://gotenberg:3000
|
||||
ports:
|
||||
- '127.0.0.1:${API_PORT:-8101}:3000'
|
||||
networks: [frontend, internal]
|
||||
@ -72,6 +74,8 @@ services:
|
||||
condition: service_healthy
|
||||
pandoc:
|
||||
condition: service_healthy
|
||||
gotenberg:
|
||||
condition: service_healthy
|
||||
<<: *logging
|
||||
|
||||
collab:
|
||||
@ -136,6 +140,20 @@ services:
|
||||
retries: 3
|
||||
<<: *logging
|
||||
|
||||
# PDF export renderer (ADR 0009, issue #67): Gotenberg wraps headless Chromium
|
||||
# on the internal network only — never exposed. Pinned image; the api reaches
|
||||
# it at http://gotenberg:3000 and posts export HTML to its Chromium route.
|
||||
gotenberg:
|
||||
image: gotenberg/gotenberg:8
|
||||
restart: unless-stopped
|
||||
networks: [internal]
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'curl -sf http://127.0.0.1:3000/health || exit 1']
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
<<: *logging
|
||||
|
||||
networks:
|
||||
frontend:
|
||||
internal:
|
||||
|
||||
@ -20,7 +20,9 @@ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const { FONT_CATALOG, fontSlug } = await import(
|
||||
join(ROOT, 'packages', 'shared', 'dist', 'index.mjs')
|
||||
);
|
||||
const OUT_DIR = join(ROOT, 'apps', 'web', 'public', 'fonts');
|
||||
// Default output is the web app's public/fonts (served by the web image); the
|
||||
// api image build overrides FONTS_OUT to bake the same catalog in for PDF export.
|
||||
const OUT_DIR = process.env.FONTS_OUT ?? join(ROOT, 'apps', 'web', 'public', 'fonts');
|
||||
const GWFH = 'https://gwfh.mranftl.com/api/fonts';
|
||||
const SUBSETS = 'latin,latin-ext';
|
||||
|
||||
|
||||
@ -34,6 +34,8 @@
|
||||
"converter_unavailable": "Der Dokument-Konverter ist derzeit nicht verfügbar. Bitte versuche es später erneut.",
|
||||
"converter_timeout": "Die Konvertierung hat zu lange gedauert und wurde abgebrochen.",
|
||||
"conversion_failed": "Dieses Dokument konnte nicht konvertiert werden.",
|
||||
"renderer_unavailable": "Der PDF-Renderer ist derzeit nicht verfügbar. Bitte versuche es später erneut.",
|
||||
"render_failed": "Diese Seite konnte nicht als PDF gerendert werden.",
|
||||
"import_unsupported_format": "Nur Word- (.docx) und OpenDocument-Dokumente (.odt) können importiert werden.",
|
||||
"network": "Der Server war nicht erreichbar.",
|
||||
"grant_exists": "Diese Berechtigung existiert bereits.",
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
"docx": "Word (.docx)",
|
||||
"odt": "OpenDocument (.odt)",
|
||||
"pdf": "PDF",
|
||||
"pdfSoon": "PDF-Export folgt in Kürze",
|
||||
"exporting": "Wird exportiert…",
|
||||
"failed": "Export fehlgeschlagen",
|
||||
"pond": {
|
||||
|
||||
@ -34,6 +34,8 @@
|
||||
"converter_unavailable": "The document converter is currently unavailable. Please try again later.",
|
||||
"converter_timeout": "The conversion took too long and was cancelled.",
|
||||
"conversion_failed": "This document could not be converted.",
|
||||
"renderer_unavailable": "The PDF renderer is currently unavailable. Please try again later.",
|
||||
"render_failed": "This page could not be rendered as a PDF.",
|
||||
"import_unsupported_format": "Only Word (.docx) and OpenDocument (.odt) documents can be imported.",
|
||||
"network": "The server could not be reached.",
|
||||
"grant_exists": "This grant already exists.",
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
"docx": "Word (.docx)",
|
||||
"odt": "OpenDocument (.odt)",
|
||||
"pdf": "PDF",
|
||||
"pdfSoon": "PDF export is coming soon",
|
||||
"exporting": "Exporting…",
|
||||
"failed": "Export failed",
|
||||
"pond": {
|
||||
|
||||
@ -31,11 +31,11 @@ export interface ConversionJobView {
|
||||
export const IMPORT_EXTENSIONS = ['docx', 'odt', 'md', 'markdown'] as const;
|
||||
export type ImportExtension = (typeof IMPORT_EXTENSIONS)[number];
|
||||
|
||||
/** Office formats a single page exports to (issue #65) — each runs a
|
||||
* `markdown → pandoc → file` conversion job whose result is downloaded from
|
||||
* `GET /jobs/:id/result`. Markdown export is a separate direct download (#30);
|
||||
* PDF is a placeholder until Gotenberg (#67). */
|
||||
export const EXPORT_FORMATS = ['docx', 'odt'] as const;
|
||||
/** Formats a single page exports to via a conversion job whose result is
|
||||
* downloaded from `GET /jobs/:id/result` (issues #65/#67). `docx`/`odt` run
|
||||
* `markdown → pandoc`; `pdf` renders HTML through Gotenberg. Markdown export is
|
||||
* a separate direct download (#30). */
|
||||
export const EXPORT_FORMATS = ['docx', 'odt', 'pdf'] as const;
|
||||
export type ExportFormat = (typeof EXPORT_FORMATS)[number];
|
||||
|
||||
export const pageExportInputSchema = z.object({
|
||||
|
||||
@ -69,6 +69,19 @@ export const apiEnvSchema = z.object({
|
||||
* readiness check is warning-level, so an unset sidecar never fails readyz).
|
||||
*/
|
||||
PANDOC_URL: z.string().url().default('http://pandoc:3030'),
|
||||
/**
|
||||
* Base URL of the internal Gotenberg sidecar for PDF export (ADR 0009, issue
|
||||
* #67). Like the converter, its readiness check is warning-level, so an
|
||||
* unreachable renderer degrades PDF export without failing readyz. The default
|
||||
* matches the compose service name.
|
||||
*/
|
||||
GOTENBERG_URL: z.string().url().default('http://gotenberg:3000'),
|
||||
/**
|
||||
* Directory of the self-hosted font catalog (WOFF2, ADR 0016), baked into the
|
||||
* api image so the PDF exporter can inline a pond's fonts as base64. Native
|
||||
* dev/test runs point this at the web app's built `public/fonts`.
|
||||
*/
|
||||
FONTS_DIR: z.string().min(1).default('./fonts'),
|
||||
});
|
||||
|
||||
export type ApiEnv = z.infer<typeof apiEnvSchema>;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user