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 pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./
|
||||||
COPY packages/shared ./packages/shared
|
COPY packages/shared ./packages/shared
|
||||||
COPY apps/api ./apps/api
|
COPY apps/api ./apps/api
|
||||||
|
COPY deploy/fonts ./deploy/fonts
|
||||||
RUN pnpm install --frozen-lockfile --filter @dorfteich/api... \
|
RUN pnpm install --frozen-lockfile --filter @dorfteich/api... \
|
||||||
&& pnpm --filter @dorfteich/shared build \
|
&& pnpm --filter @dorfteich/shared build \
|
||||||
&& pnpm --filter @dorfteich/api 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
|
# Self-contained production bundle (prod deps only, incl. the prisma CLI
|
||||||
# needed for migrate-on-start) at /out.
|
# needed for migrate-on-start) at /out.
|
||||||
&& pnpm --filter @dorfteich/api deploy --prod --legacy /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
|
FROM node:22.15-alpine
|
||||||
ARG APP_VERSION=0.0.0-dev
|
ARG APP_VERSION=0.0.0-dev
|
||||||
|
|||||||
@ -40,6 +40,7 @@ export class ReadinessService {
|
|||||||
await this.databaseReachable(),
|
await this.databaseReachable(),
|
||||||
await this.migrationsApplied(),
|
await this.migrationsApplied(),
|
||||||
await this.converterReachable(),
|
await this.converterReachable(),
|
||||||
|
await this.rendererReachable(),
|
||||||
];
|
];
|
||||||
return {
|
return {
|
||||||
status: checks.some((c) => c.status === 'failed') ? 'unready' : 'ok',
|
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> {
|
private async databaseReachable(): Promise<ReadinessCheck> {
|
||||||
try {
|
try {
|
||||||
await this.prisma.$queryRaw`SELECT 1`;
|
await this.prisma.$queryRaw`SELECT 1`;
|
||||||
|
|||||||
@ -6,8 +6,9 @@ import { PinoLogger } from 'nestjs-pino';
|
|||||||
import { AppConfig } from '../config/app-config.service';
|
import { AppConfig } from '../config/app-config.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
import { GotenbergRenderer, RenderError } from './gotenberg.renderer';
|
||||||
import { IMPORT_PROCESSOR, ImportProcessor, isImportKind } from './import.constants';
|
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
|
/** 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
|
* that makes a queued conversion survive an API restart even if no new
|
||||||
@ -38,6 +39,7 @@ export class ConversionWorker implements OnModuleInit, OnModuleDestroy {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly converter: PandocConverter,
|
private readonly converter: PandocConverter,
|
||||||
|
private readonly renderer: GotenbergRenderer,
|
||||||
private readonly config: AppConfig,
|
private readonly config: AppConfig,
|
||||||
private readonly logger: PinoLogger,
|
private readonly logger: PinoLogger,
|
||||||
// Resolved lazily to break the construction cycle (the import service
|
// 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);
|
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 {
|
onModuleInit(): void {
|
||||||
if (this.config.env.NODE_ENV === 'test') return; // tests drive drain() directly
|
if (this.config.env.NODE_ENV === 'test') return; // tests drive drain() directly
|
||||||
this.timer = setInterval(() => void this.drain(), SWEEP_MS);
|
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);
|
await this.moduleRef.get<ImportProcessor>(IMPORT_PROCESSOR, { strict: false }).run(job);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await this.converter.convert({
|
// PDF export renders HTML through Gotenberg (#67); everything else is a
|
||||||
from: job.sourceFormat,
|
// pandoc byte→byte conversion (#62/#65).
|
||||||
to: job.targetFormat,
|
const output =
|
||||||
input: Buffer.from(job.input),
|
job.targetFormat === 'pdf'
|
||||||
standalone: job.standalone,
|
? {
|
||||||
});
|
bytes: await this.renderer.renderHtmlToPdf(Buffer.from(job.input).toString('utf8')),
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
}
|
||||||
|
: await this.convert(job);
|
||||||
await this.prisma.conversionJob.update({
|
await this.prisma.conversionJob.update({
|
||||||
where: { id: job.id },
|
where: { id: job.id },
|
||||||
data: {
|
data: {
|
||||||
status: 'SUCCEEDED',
|
status: 'SUCCEEDED',
|
||||||
// Prisma Bytes = Uint8Array<ArrayBuffer>; copy the Buffer in (#40).
|
// Prisma Bytes = Uint8Array<ArrayBuffer>; copy the Buffer in (#40).
|
||||||
result: new Uint8Array(result.output),
|
result: new Uint8Array(output.bytes),
|
||||||
resultMimeType: result.mimeType,
|
resultMimeType: output.mimeType,
|
||||||
errorCode: null,
|
errorCode: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -141,8 +156,9 @@ export class ConversionWorker implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async recordFailure(job: ConversionJob, error: unknown): Promise<void> {
|
private async recordFailure(job: ConversionJob, error: unknown): Promise<void> {
|
||||||
const code = error instanceof ConversionError ? error.code : 'conversion_failed';
|
const typed = error instanceof ConversionError || error instanceof RenderError ? error : null;
|
||||||
const retryable = error instanceof ConversionError ? error.retryable : false;
|
const code = typed?.code ?? 'conversion_failed';
|
||||||
|
const retryable = typed?.retryable ?? false;
|
||||||
// `attempts` was already incremented by the claim, so it reflects this try.
|
// `attempts` was already incremented by the claim, so it reflects this try.
|
||||||
if (retryable && job.attempts < MAX_ATTEMPTS) {
|
if (retryable && job.attempts < MAX_ATTEMPTS) {
|
||||||
await this.prisma.conversionJob.update({
|
await this.prisma.conversionJob.update({
|
||||||
|
|||||||
@ -12,12 +12,13 @@ import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
|||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
import { ConversionWorker } from './conversion-worker.service';
|
import { ConversionWorker } from './conversion-worker.service';
|
||||||
|
import { GotenbergRenderer, RenderError } from './gotenberg.renderer';
|
||||||
import { ConversionRequest, ConversionResult, PandocConverter } from './pandoc.converter';
|
import { ConversionRequest, ConversionResult, PandocConverter } from './pandoc.converter';
|
||||||
|
|
||||||
/** Export (issue #65): pond ZIP of Markdown + per-page docx/odt job. The ZIP
|
/** Export (issues #65/#67): pond ZIP of Markdown, per-page docx/odt (pandoc),
|
||||||
* path needs no converter; the docx path uses an injected fake that records the
|
* and per-page PDF (Gotenberg). The office/PDF paths use injected fakes that
|
||||||
* Markdown it is handed, so the image-inlining / wikilink-flattening is checked
|
* record the input they are handed, so image-inlining / font-inlining is checked
|
||||||
* without a live pandoc. */
|
* without a live sidecar. */
|
||||||
|
|
||||||
const PNG_BASE64 =
|
const PNG_BASE64 =
|
||||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
|
'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. */
|
/** Filenames of every entry in a ZIP buffer. */
|
||||||
function zipEntries(buffer: Buffer): Record<string, Uint8Array> {
|
function zipEntries(buffer: Buffer): Record<string, Uint8Array> {
|
||||||
return unzipSync(new Uint8Array(buffer));
|
return unzipSync(new Uint8Array(buffer));
|
||||||
@ -44,6 +58,7 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => {
|
|||||||
let worker: ConversionWorker;
|
let worker: ConversionWorker;
|
||||||
let files: FilesService;
|
let files: FilesService;
|
||||||
let fake: RecordingConverter;
|
let fake: RecordingConverter;
|
||||||
|
let renderer: RecordingRenderer;
|
||||||
const suffix = uniqueSuffix();
|
const suffix = uniqueSuffix();
|
||||||
const password = 'exportiere meine sachen 1';
|
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
|
/** Create a page with a crafted content-cache Markdown (the exporter reads the
|
||||||
* cache, not the Yjs state), returning its slug. */
|
* 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, '-');
|
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
||||||
await prisma.page.create({
|
await prisma.page.create({
|
||||||
data: {
|
data: {
|
||||||
@ -74,7 +94,7 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => {
|
|||||||
ydocState: new Uint8Array(),
|
ydocState: new Uint8Array(),
|
||||||
sortKey: title,
|
sortKey: title,
|
||||||
createdBy: ownerId,
|
createdBy: ownerId,
|
||||||
contentCache: { create: { plainText: markdown, markdown, html: '', outline: [] } },
|
contentCache: { create: { plainText: markdown, markdown, html, outline: [] } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return slug;
|
return slug;
|
||||||
@ -84,8 +104,13 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => {
|
|||||||
prisma = createTestPrisma();
|
prisma = createTestPrisma();
|
||||||
await prisma.rateLimit.deleteMany({});
|
await prisma.rateLimit.deleteMany({});
|
||||||
fake = new RecordingConverter();
|
fake = new RecordingConverter();
|
||||||
|
renderer = new RecordingRenderer();
|
||||||
app = await createTestApp((builder) =>
|
app = await createTestApp((builder) =>
|
||||||
builder.overrideProvider(PandocConverter).useValue(fake),
|
builder
|
||||||
|
.overrideProvider(PandocConverter)
|
||||||
|
.useValue(fake)
|
||||||
|
.overrideProvider(GotenbergRenderer)
|
||||||
|
.useValue(renderer),
|
||||||
);
|
);
|
||||||
worker = app.get(ConversionWorker);
|
worker = app.get(ConversionWorker);
|
||||||
files = app.get(FilesService);
|
files = app.get(FilesService);
|
||||||
@ -298,6 +323,79 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => {
|
|||||||
expect(result.text).toBe('OFFICE-BYTES');
|
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 () => {
|
it('streams a large pond export (500 pages) without buffering it all', async () => {
|
||||||
const big = await prisma.pond.create({
|
const big = await prisma.pond.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@ -1,10 +1,20 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
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 { User } from '@prisma/client';
|
||||||
import archiver from 'archiver';
|
import archiver from 'archiver';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
|
||||||
|
import { AppConfig } from '../config/app-config.service';
|
||||||
import { FileStorageService } from '../files/file-storage.service';
|
import { FileStorageService } from '../files/file-storage.service';
|
||||||
import { PermissionService } from '../permissions/permission.service';
|
import { PermissionService } from '../permissions/permission.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
@ -16,6 +26,7 @@ import {
|
|||||||
markdownForDocument,
|
markdownForDocument,
|
||||||
markdownForZip,
|
markdownForZip,
|
||||||
} from './export-markdown';
|
} from './export-markdown';
|
||||||
|
import { buildPdfHtml } from './pdf-html';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export (ADR 0009, issue #65): a whole pond as a ZIP of Markdown (one `.md`
|
* 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 permissions: PermissionService,
|
||||||
private readonly storage: FileStorageService,
|
private readonly storage: FileStorageService,
|
||||||
private readonly jobs: ConversionJobService,
|
private readonly jobs: ConversionJobService,
|
||||||
|
private readonly config: AppConfig,
|
||||||
private readonly logger: PinoLogger,
|
private readonly logger: PinoLogger,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(ExportService.name);
|
this.logger.setContext(ExportService.name);
|
||||||
@ -136,6 +148,8 @@ export class ExportService {
|
|||||||
pageId: string,
|
pageId: string,
|
||||||
format: ExportFormat,
|
format: ExportFormat,
|
||||||
): Promise<ConversionJobView> {
|
): Promise<ConversionJobView> {
|
||||||
|
if (format === 'pdf') return this.enqueuePdfExport(user, pageId);
|
||||||
|
|
||||||
const page = await this.prisma.page.findFirst({
|
const page = await this.prisma.page.findFirst({
|
||||||
where: { id: pageId, deletedAt: null },
|
where: { id: pageId, deletedAt: null },
|
||||||
include: { contentCache: { select: { markdown: true } } },
|
include: { contentCache: { select: { markdown: true } } },
|
||||||
@ -161,6 +175,88 @@ export class ExportService {
|
|||||||
return this.jobs.viewOf(job);
|
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
|
/** 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). */
|
* images, so holding them briefly is fine — unlike the streamed pond ZIP). */
|
||||||
private async inlineImages(pondId: string, ids: string[]): Promise<Map<string, string>> {
|
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 { ConversionWorker } from './conversion-worker.service';
|
||||||
import { ExportController } from './export.controller';
|
import { ExportController } from './export.controller';
|
||||||
import { ExportService } from './export.service';
|
import { ExportService } from './export.service';
|
||||||
|
import { GotenbergHttpRenderer, GotenbergRenderer } from './gotenberg.renderer';
|
||||||
import { IMPORT_PROCESSOR } from './import.constants';
|
import { IMPORT_PROCESSOR } from './import.constants';
|
||||||
import { ImportController } from './import.controller';
|
import { ImportController } from './import.controller';
|
||||||
import { ImportService } from './import.service';
|
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
|
// Bind the abstract converter to the HTTP implementation; tests override
|
||||||
// this provider with a fake so the queue mechanics need no live sidecar.
|
// this provider with a fake so the queue mechanics need no live sidecar.
|
||||||
{ provide: PandocConverter, useClass: PandocServerConverter },
|
{ provide: PandocConverter, useClass: PandocServerConverter },
|
||||||
|
// Same pattern for the Gotenberg PDF renderer (#67).
|
||||||
|
{ provide: GotenbergRenderer, useClass: GotenbergHttpRenderer },
|
||||||
],
|
],
|
||||||
exports: [ConversionJobService, PandocConverter],
|
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();
|
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`
|
* Document export buttons for the page menu (issues #65/#67): `.docx`/`.odt`
|
||||||
* run a conversion job and download the result; PDF is a disabled placeholder
|
* (pandoc) and PDF (Gotenberg) each run a conversion job and download the
|
||||||
* until Gotenberg (#67). Markdown copy/download live in the page menu already
|
* result. Markdown copy/download live in the page menu already (#30).
|
||||||
* (#30).
|
|
||||||
*/
|
*/
|
||||||
export function DocumentExportMenu({ pageId, slug }: DocumentExportMenuProps): React.JSX.Element {
|
export function DocumentExportMenu({ pageId, slug }: DocumentExportMenuProps): React.JSX.Element {
|
||||||
const { t } = useTranslation('export');
|
const { t } = useTranslation('export');
|
||||||
@ -37,9 +36,6 @@ export function DocumentExportMenu({ pageId, slug }: DocumentExportMenuProps): R
|
|||||||
{label(format)}
|
{label(format)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<button type="button" className="button" disabled title={t('pdfSoon')}>
|
|
||||||
{t('pdf')}
|
|
||||||
</button>
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,9 +33,9 @@ export interface UseDocumentExport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export a page to `.docx`/`.odt` (issue #65): enqueue the conversion job, poll
|
* Export a page to `.docx`/`.odt`/`.pdf` (issues #65/#67): enqueue the
|
||||||
* it, and download the result when it succeeds. Markdown export stays a direct
|
* conversion job, poll it, and download the result when it succeeds. Markdown
|
||||||
* link (#30); PDF waits for Gotenberg (#67).
|
* export stays a direct link (#30).
|
||||||
*/
|
*/
|
||||||
export function useDocumentExport(): UseDocumentExport {
|
export function useDocumentExport(): UseDocumentExport {
|
||||||
const [status, setStatus] = useState<Partial<Record<ExportFormat, ExportStatus>>>({});
|
const [status, setStatus] = useState<Partial<Record<ExportFormat, ExportStatus>>>({});
|
||||||
|
|||||||
@ -62,6 +62,8 @@ services:
|
|||||||
UPLOADS_DIR: /data/uploads
|
UPLOADS_DIR: /data/uploads
|
||||||
# Internal pandoc-server sidecar for import/export (ADR 0009, issue #62).
|
# Internal pandoc-server sidecar for import/export (ADR 0009, issue #62).
|
||||||
PANDOC_URL: http://pandoc:3030
|
PANDOC_URL: http://pandoc:3030
|
||||||
|
# Internal Gotenberg sidecar for PDF export (ADR 0009, issue #67).
|
||||||
|
GOTENBERG_URL: http://gotenberg:3000
|
||||||
ports:
|
ports:
|
||||||
- '127.0.0.1:${API_PORT:-8101}:3000'
|
- '127.0.0.1:${API_PORT:-8101}:3000'
|
||||||
networks: [frontend, internal]
|
networks: [frontend, internal]
|
||||||
@ -72,6 +74,8 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
pandoc:
|
pandoc:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
gotenberg:
|
||||||
|
condition: service_healthy
|
||||||
<<: *logging
|
<<: *logging
|
||||||
|
|
||||||
collab:
|
collab:
|
||||||
@ -136,6 +140,20 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
<<: *logging
|
<<: *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:
|
networks:
|
||||||
frontend:
|
frontend:
|
||||||
internal:
|
internal:
|
||||||
|
|||||||
@ -20,7 +20,9 @@ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|||||||
const { FONT_CATALOG, fontSlug } = await import(
|
const { FONT_CATALOG, fontSlug } = await import(
|
||||||
join(ROOT, 'packages', 'shared', 'dist', 'index.mjs')
|
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 GWFH = 'https://gwfh.mranftl.com/api/fonts';
|
||||||
const SUBSETS = 'latin,latin-ext';
|
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_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.",
|
"converter_timeout": "Die Konvertierung hat zu lange gedauert und wurde abgebrochen.",
|
||||||
"conversion_failed": "Dieses Dokument konnte nicht konvertiert werden.",
|
"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.",
|
"import_unsupported_format": "Nur Word- (.docx) und OpenDocument-Dokumente (.odt) können importiert werden.",
|
||||||
"network": "Der Server war nicht erreichbar.",
|
"network": "Der Server war nicht erreichbar.",
|
||||||
"grant_exists": "Diese Berechtigung existiert bereits.",
|
"grant_exists": "Diese Berechtigung existiert bereits.",
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
"docx": "Word (.docx)",
|
"docx": "Word (.docx)",
|
||||||
"odt": "OpenDocument (.odt)",
|
"odt": "OpenDocument (.odt)",
|
||||||
"pdf": "PDF",
|
"pdf": "PDF",
|
||||||
"pdfSoon": "PDF-Export folgt in Kürze",
|
|
||||||
"exporting": "Wird exportiert…",
|
"exporting": "Wird exportiert…",
|
||||||
"failed": "Export fehlgeschlagen",
|
"failed": "Export fehlgeschlagen",
|
||||||
"pond": {
|
"pond": {
|
||||||
|
|||||||
@ -34,6 +34,8 @@
|
|||||||
"converter_unavailable": "The document converter is currently unavailable. Please try again later.",
|
"converter_unavailable": "The document converter is currently unavailable. Please try again later.",
|
||||||
"converter_timeout": "The conversion took too long and was cancelled.",
|
"converter_timeout": "The conversion took too long and was cancelled.",
|
||||||
"conversion_failed": "This document could not be converted.",
|
"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.",
|
"import_unsupported_format": "Only Word (.docx) and OpenDocument (.odt) documents can be imported.",
|
||||||
"network": "The server could not be reached.",
|
"network": "The server could not be reached.",
|
||||||
"grant_exists": "This grant already exists.",
|
"grant_exists": "This grant already exists.",
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
"docx": "Word (.docx)",
|
"docx": "Word (.docx)",
|
||||||
"odt": "OpenDocument (.odt)",
|
"odt": "OpenDocument (.odt)",
|
||||||
"pdf": "PDF",
|
"pdf": "PDF",
|
||||||
"pdfSoon": "PDF export is coming soon",
|
|
||||||
"exporting": "Exporting…",
|
"exporting": "Exporting…",
|
||||||
"failed": "Export failed",
|
"failed": "Export failed",
|
||||||
"pond": {
|
"pond": {
|
||||||
|
|||||||
@ -31,11 +31,11 @@ export interface ConversionJobView {
|
|||||||
export const IMPORT_EXTENSIONS = ['docx', 'odt', 'md', 'markdown'] as const;
|
export const IMPORT_EXTENSIONS = ['docx', 'odt', 'md', 'markdown'] as const;
|
||||||
export type ImportExtension = (typeof IMPORT_EXTENSIONS)[number];
|
export type ImportExtension = (typeof IMPORT_EXTENSIONS)[number];
|
||||||
|
|
||||||
/** Office formats a single page exports to (issue #65) — each runs a
|
/** Formats a single page exports to via a conversion job whose result is
|
||||||
* `markdown → pandoc → file` conversion job whose result is downloaded from
|
* downloaded from `GET /jobs/:id/result` (issues #65/#67). `docx`/`odt` run
|
||||||
* `GET /jobs/:id/result`. Markdown export is a separate direct download (#30);
|
* `markdown → pandoc`; `pdf` renders HTML through Gotenberg. Markdown export is
|
||||||
* PDF is a placeholder until Gotenberg (#67). */
|
* a separate direct download (#30). */
|
||||||
export const EXPORT_FORMATS = ['docx', 'odt'] as const;
|
export const EXPORT_FORMATS = ['docx', 'odt', 'pdf'] as const;
|
||||||
export type ExportFormat = (typeof EXPORT_FORMATS)[number];
|
export type ExportFormat = (typeof EXPORT_FORMATS)[number];
|
||||||
|
|
||||||
export const pageExportInputSchema = z.object({
|
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).
|
* readiness check is warning-level, so an unset sidecar never fails readyz).
|
||||||
*/
|
*/
|
||||||
PANDOC_URL: z.string().url().default('http://pandoc:3030'),
|
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>;
|
export type ApiEnv = z.infer<typeof apiEnvSchema>;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user