From 6634144d82e03b367fd58a82b326d0b52588904c Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Tue, 31 Oct 2023 02:08:20 +0200 Subject: [PATCH] feat: optimize documents printing --- .env.example | 5 +- docker-compose.yml | 5 ++ .../server/src/lib/Chromiumly/Chromiumly.ts | 23 +++++++ .../server/src/lib/Chromiumly/ConvertUtils.ts | 66 +++++++++++++++++++ .../server/src/lib/Chromiumly/Converter.ts | 9 +++ .../src/lib/Chromiumly/GotenbergUtils.ts | 25 +++++++ .../server/src/lib/Chromiumly/HTMLConvert.ts | 38 +++++++++++ .../server/src/lib/Chromiumly/UrlConvert.ts | 38 +++++++++++ packages/server/src/lib/Chromiumly/_types.ts | 51 ++++++++++++++ 9 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/lib/Chromiumly/Chromiumly.ts create mode 100644 packages/server/src/lib/Chromiumly/ConvertUtils.ts create mode 100644 packages/server/src/lib/Chromiumly/Converter.ts create mode 100644 packages/server/src/lib/Chromiumly/GotenbergUtils.ts create mode 100644 packages/server/src/lib/Chromiumly/HTMLConvert.ts create mode 100644 packages/server/src/lib/Chromiumly/UrlConvert.ts create mode 100644 packages/server/src/lib/Chromiumly/_types.ts diff --git a/.env.example b/.env.example index f07f28c5f..fa38f373f 100644 --- a/.env.example +++ b/.env.example @@ -49,4 +49,7 @@ SIGNUP_ALLOWED_DOMAINS= SIGNUP_ALLOWED_EMAILS= # API rate limit (points,duration,block duration). -API_RATE_LIMIT=120,60,600 \ No newline at end of file +API_RATE_LIMIT=120,60,600 + +# Gotenberg API for PDF printing. +GOTENBERG_url=http://localhost:9000 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 9563ae91e..3cd95670d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,6 +47,11 @@ services: restart_policy: condition: unless-stopped + gotenberg: + image: gotenberg/gotenberg:7 + ports: + - "9000:3000" + # Volumes volumes: mysql: diff --git a/packages/server/src/lib/Chromiumly/Chromiumly.ts b/packages/server/src/lib/Chromiumly/Chromiumly.ts new file mode 100644 index 000000000..8cd5d57b0 --- /dev/null +++ b/packages/server/src/lib/Chromiumly/Chromiumly.ts @@ -0,0 +1,23 @@ +import { ChromiumRoute, LibreOfficeRoute, PdfEngineRoute } from './_types'; + +export class Chromiumly { + public static readonly GOTENBERG_ENDPOINT = ''; + + public static readonly CHROMIUM_PATH = 'forms/chromium/convert'; + public static readonly PDF_ENGINES_PATH = 'forms/pdfengines'; + public static readonly LIBRE_OFFICE_PATH = 'forms/libreoffice'; + + public static readonly CHROMIUM_ROUTES = { + url: ChromiumRoute.URL, + html: ChromiumRoute.HTML, + markdown: ChromiumRoute.MARKDOWN, + }; + + public static readonly PDF_ENGINE_ROUTES = { + merge: PdfEngineRoute.MERGE, + }; + + public static readonly LIBRE_OFFICE_ROUTES = { + convert: LibreOfficeRoute.CONVERT, + }; +} diff --git a/packages/server/src/lib/Chromiumly/ConvertUtils.ts b/packages/server/src/lib/Chromiumly/ConvertUtils.ts new file mode 100644 index 000000000..38d27fd99 --- /dev/null +++ b/packages/server/src/lib/Chromiumly/ConvertUtils.ts @@ -0,0 +1,66 @@ +import FormData from 'form-data'; +import { GotenbergUtils } from './GotenbergUtils'; +import { PageProperties } from './_types'; + +export class ConverterUtils { + public static injectPageProperties( + data: FormData, + pageProperties: PageProperties + ): void { + if (pageProperties.size) { + GotenbergUtils.assert( + pageProperties.size.width >= 1.0 && pageProperties.size.height >= 1.5, + 'size is smaller than the minimum printing requirements (i.e. 1.0 x 1.5 in)' + ); + + data.append('paperWidth', pageProperties.size.width); + data.append('paperHeight', pageProperties.size.height); + } + if (pageProperties.margins) { + GotenbergUtils.assert( + pageProperties.margins.top >= 0 && + pageProperties.margins.bottom >= 0 && + pageProperties.margins.left >= 0 && + pageProperties.margins.left >= 0, + 'negative margins are not allowed' + ); + data.append('marginTop', pageProperties.margins.top); + data.append('marginBottom', pageProperties.margins.bottom); + data.append('marginLeft', pageProperties.margins.left); + data.append('marginRight', pageProperties.margins.right); + } + if (pageProperties.preferCssPageSize) { + data.append( + 'preferCssPageSize', + String(pageProperties.preferCssPageSize) + ); + } + if (pageProperties.printBackground) { + data.append('printBackground', String(pageProperties.printBackground)); + } + if (pageProperties.landscape) { + data.append('landscape', String(pageProperties.landscape)); + } + if (pageProperties.scale) { + GotenbergUtils.assert( + pageProperties.scale >= 0.1 && pageProperties.scale <= 2.0, + 'scale is outside of [0.1 - 2] range' + ); + data.append('scale', pageProperties.scale); + } + + if (pageProperties.nativePageRanges) { + GotenbergUtils.assert( + pageProperties.nativePageRanges.from > 0 && + pageProperties.nativePageRanges.to > 0 && + pageProperties.nativePageRanges.to >= + pageProperties.nativePageRanges.from, + 'page ranges syntax error' + ); + data.append( + 'nativePageRanges', + `${pageProperties.nativePageRanges.from}-${pageProperties.nativePageRanges.to}` + ); + } + } +} diff --git a/packages/server/src/lib/Chromiumly/Converter.ts b/packages/server/src/lib/Chromiumly/Converter.ts new file mode 100644 index 000000000..0d5249cae --- /dev/null +++ b/packages/server/src/lib/Chromiumly/Converter.ts @@ -0,0 +1,9 @@ +import { Chromiumly, ChromiumRoute } from '../../main.config'; + +export abstract class Converter { + readonly endpoint: string; + + constructor(route: ChromiumRoute) { + this.endpoint = `${Chromiumly.GOTENBERG_ENDPOINT}/${Chromiumly.CHROMIUM_PATH}/${Chromiumly.CHROMIUM_ROUTES[route]}`; + } +} diff --git a/packages/server/src/lib/Chromiumly/GotenbergUtils.ts b/packages/server/src/lib/Chromiumly/GotenbergUtils.ts new file mode 100644 index 000000000..ed894c80b --- /dev/null +++ b/packages/server/src/lib/Chromiumly/GotenbergUtils.ts @@ -0,0 +1,25 @@ +import FormData from 'form-data'; +import fetch from 'node-fetch'; + +export class GotenbergUtils { + public static assert(condition: boolean, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } + } + + public static async fetch(endpoint: string, data: FormData): Promise { + const response = await fetch(endpoint, { + method: 'post', + body: data, + headers: { + ...data.getHeaders(), + }, + }); + + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + return response.buffer(); + } +} diff --git a/packages/server/src/lib/Chromiumly/HTMLConvert.ts b/packages/server/src/lib/Chromiumly/HTMLConvert.ts new file mode 100644 index 000000000..3eb109405 --- /dev/null +++ b/packages/server/src/lib/Chromiumly/HTMLConvert.ts @@ -0,0 +1,38 @@ +import { constants, createReadStream, PathLike, promises } from 'fs'; +import FormData from 'form-data'; +import { GotenbergUtils } from './GotenbergUtils'; +import { IConverter, PageProperties } from './_types'; +import { PdfFormat, ChromiumRoute } from './_types'; +import { ConverterUtils } from './ConvertUtils'; +import { Converter } from './Converter'; + +export class HtmlConverter extends Converter implements IConverter { + constructor() { + super(ChromiumRoute.HTML); + } + + async convert({ + html, + properties, + pdfFormat, + }: { + html: PathLike; + properties?: PageProperties; + pdfFormat?: PdfFormat; + }): Promise { + try { + await promises.access(html, constants.R_OK); + const data = new FormData(); + if (pdfFormat) { + data.append('pdfFormat', pdfFormat); + } + data.append('index.html', createReadStream(html)); + if (properties) { + ConverterUtils.injectPageProperties(data, properties); + } + return GotenbergUtils.fetch(this.endpoint, data); + } catch (error) { + throw error; + } + } +} diff --git a/packages/server/src/lib/Chromiumly/UrlConvert.ts b/packages/server/src/lib/Chromiumly/UrlConvert.ts new file mode 100644 index 000000000..d1a462124 --- /dev/null +++ b/packages/server/src/lib/Chromiumly/UrlConvert.ts @@ -0,0 +1,38 @@ +import FormData from 'form-data'; +import { IConverter, PageProperties, PdfFormat, ChromiumRoute } from './_types'; +import { ConverterUtils } from './ConvertUtils'; +import { Converter } from './Converter'; +import { GotenbergUtils } from './GotenbergUtils'; + +export class UrlConverter extends Converter implements IConverter { + constructor() { + super(ChromiumRoute.URL); + } + + async convert({ + url, + properties, + pdfFormat, + }: { + url: string; + properties?: PageProperties; + pdfFormat?: PdfFormat; + }): Promise { + try { + const _url = new URL(url); + const data = new FormData(); + + if (pdfFormat) { + data.append('pdfFormat', pdfFormat); + } + data.append('url', _url.href); + + if (properties) { + ConverterUtils.injectPageProperties(data, properties); + } + return GotenbergUtils.fetch(this.endpoint, data); + } catch (error) { + throw error; + } + } +} diff --git a/packages/server/src/lib/Chromiumly/_types.ts b/packages/server/src/lib/Chromiumly/_types.ts new file mode 100644 index 000000000..453f59ed8 --- /dev/null +++ b/packages/server/src/lib/Chromiumly/_types.ts @@ -0,0 +1,51 @@ +import { PathLike } from 'fs'; + +export type PageSize = { + width: number; // Paper width, in inches (default 8.5) + height: number; //Paper height, in inches (default 11) +}; + +export type PageMargins = { + top: number; // Top margin, in inches (default 0.39) + bottom: number; // Bottom margin, in inches (default 0.39) + left: number; // Left margin, in inches (default 0.39) + right: number; // Right margin, in inches (default 0.39) +}; + +export type PageProperties = { + size?: PageSize; + margins?: PageMargins; + preferCssPageSize?: boolean; // Define whether to prefer page size as defined by CSS (default false) + printBackground?: boolean; // Print the background graphics (default false) + landscape?: boolean; // Set the paper orientation to landscape (default false) + scale?: number; // The scale of the page rendering (default 1.0) + nativePageRanges?: { from: number; to: number }; // Page ranges to print +}; + +export interface IConverter { + convert({ + ...args + }: { + [x: string]: string | PathLike | PageProperties | PdfFormat; + }): Promise; +} + +export enum PdfFormat { + A_1a = 'PDF/A-1a', + A_2b = 'PDF/A-2b', + A_3b = 'PDF/A-3b', +} + +export enum ChromiumRoute { + URL = 'url', + HTML = 'html', + MARKDOWN = 'markdown', +} + +export enum PdfEngineRoute { + MERGE = 'merge', +} + +export enum LibreOfficeRoute { + CONVERT = 'convert', +}