import fs from 'fs'; import { DataField } from '../openapi-parser'; import { deepOverride } from '../utils/obj-utils'; import { EFooterOptions } from '../input-args'; const PDFDocument = require('pdfkit'); class Font { constructor( public face: string, public style?: string ) {} } interface ITextStyle { font?: Font; fontSize?: number; fillColor?: string; leftMargin?: number; lineGap?: number; } interface ITextOptions { continued?: boolean; lineBreak?: boolean; underline?: boolean; destination?: string; goTo?: string | null; indent?: number; align?: string; x?: number; y?: number; }; export class PdfWriter { // configurable PDF style style = { color: { main: '#333333', secondary: '#6B7B8E', highlight: '#8A3324', headers: '#2A4D69', subHeaders: '#4B86B4', getMethod: '#4A90E2', putMethod: '#6B8E23', postMethod: '#D87F0A', patchMethod: '#C2A000', deleteMethod: '#D0021B', otherMethods: '#2A4D69' }, font: { baseSize: 10, main: { norm: new Font('Helvetica'), bold: new Font('Helvetica-Bold'), italic: new Font('Helvetica-Oblique'), }, mono: { norm: new Font('Courier'), bold: new Font('Courier-Bold'), italic: new Font('Courier-Oblique') } }, format: { indentStep: 12, horizontalMargin: 70, verticalMargin: 50 } }; private doc; private currentSectionName = ''; private pageNumber = 0; private pageHeaderNodes: string[] = []; private docOutlines: any[] = []; private styleStack: ITextStyle[] = []; private headerGap = 0.7; private paraGap = 0.5; private baseStyle: ITextStyle; constructor( outputFile?: string, private options?: { style?: any, footerContent?: string } ) { if (options?.style) { this.style = deepOverride(this.style, options?.style); } this.baseStyle = { font: this.style.font.main.norm, fontSize: this.style.font.baseSize, fillColor: this.style.color.main, leftMargin: this.style.format.horizontalMargin, lineGap: 0, }; this.doc = new PDFDocument({ bufferPages: true, autoFirstPage: false, margins: { left: this.style.format.horizontalMargin, right: this.style.format.horizontalMargin, top: this.style.format.verticalMargin, bottom: this.style.format.verticalMargin } }); if (outputFile) { const writeStream = fs.createWriteStream(outputFile); this.doc.pipe(writeStream); } // NOTE: it is impossible to edit pages in pageAdded as it is sometimes invoked after one text already placed on the page // which produces unexpected formatting issues this.doc.on('pageAdded', () => { // debugLog(`Page: ${this.pageNumber}, headerNote: ${this.currentSectionName}`); this.pageNumber++; this.pageHeaderNodes.push(this.currentSectionName); }); } addTitlePage(title: string, subtitle?: string, date?: string) { // NOTE: font sizes on the title screen don't depent on base fontSize this.doc.addPage(); this.doc.y = this.doc.page.height * 0.3; this.text(title, { font: this.style.font.main.bold, fontSize: 20 }, { align: 'center' }); if (subtitle) { this.lineBreak(this.paraGap); this.text(subtitle, { fontSize: 14 }, { align: 'center' }); } if (date) { this.lineBreak(3); this.text(date, { fontSize: 12, fillColor: this.style.color.secondary }, { align: 'center' }); } } newSection(name: string) { this.currentSectionName = name; this.resetStyle(); this.doc.addPage(); } indentStart(): PdfWriter { this.pushStyle({ leftMargin: this.style.format.indentStep }); return this; } indentEnd(): PdfWriter { this.popStyle(); return this; } nextLine(): PdfWriter { return this.text('').lineBreak(); } lineBreak(n: number = 1): PdfWriter { this.doc.moveDown(n); return this; } paraBreak(): PdfWriter { return this.lineBreak(this.paraGap); } text(str: string, style?: ITextStyle, options?: ITextOptions): PdfWriter { if (style && Object.keys(style).length > 0) { this.withStyle(style, () => this.textImpl(str, options)); } else { this.textImpl(str, options); } return this; } textRef(str: string, anchor?: string) { this.text(str, { fillColor: this.style.color.highlight }, { goTo: anchor ?? undefined, underline: anchor ? true : false }); } private textImpl(str: string, options?: ITextOptions): PdfWriter { const style = this.currentStyle(); const styledOpt = { lineGap: style.lineGap, ...options }; const absolutePos = styledOpt.x !== undefined || styledOpt.y !== undefined; if (absolutePos) { this.doc.text(str, styledOpt.x ?? this.doc.x, styledOpt.y ?? this.doc.y, styledOpt); } else { this.doc.text(str, styledOpt); } return this; } header(level: number, str: string, anchor?: string) { this.withStyle({ fillColor: this.style.color.headers, font: this.style.font.main.bold, fontSize: this.style.font.baseSize + 4 - level * 2 }, () => { this.text(str, {}, { destination: anchor }); this.lineBreak(this.headerGap); }); this.addOutline(level, str); } apiHeader(method: string, endpoint: string, headerLevel: number) { const fontSize = this.style.font.baseSize + 2; const colorByMethod: Record = { 'get': this.style.color.getMethod, 'put': this.style.color.putMethod, 'post': this.style.color.postMethod, 'patch': this.style.color.patchMethod, 'delete': this.style.color.deleteMethod, }; this.withStyle({ font: this.style.font.main.bold, fontSize }, () => { const width = this.doc.widthOfString(method); const height = this.doc.heightOfString(method); const color = colorByMethod[method.toLowerCase()] ?? this.style.color.otherMethods; // bugfix: make sure we are already on a new page if needed - to dray rect correctly this.text(method, {}, { continued: true }); this.text(' '); this.doc.moveUp(); this.doc .lineJoin('round').lineWidth(4) .rect(this.doc.x, this.doc.y - fontSize / 4, width, height) .fillAndStroke(color, color); this.text(method, { fillColor: 'white' }, { continued: true }); this.text(` ${endpoint}`, { fillColor: this.style.color.headers }); this.lineBreak(this.headerGap); }); this.addOutline(headerLevel, `${method} ${endpoint}`); } private addOutline(level: number, str: string) { let newOutline; const outlinesLen = this.docOutlines.length; let levelError = false; // debugLog(`header: level=${level}, text="${text}"`); if (level === 0) { newOutline = this.doc.outline.addItem(str); } else if (level > 0 && level <= outlinesLen) { newOutline = this.docOutlines[level - 1].addItem(str); } else { levelError = true; } if (!levelError) { if (level === outlinesLen) { this.docOutlines.push(newOutline); } else if (level < outlinesLen) { this.docOutlines[level] = newOutline; this.docOutlines.splice(level + 1); // remore remainings } else if (level > outlinesLen) { levelError = true; } } if (levelError) { throw new Error(`A header can only be nested inside headers with level - 1. level=${level}, previousLevel=${outlinesLen-1}`); } } subHeader(str: string) { this.withStyle({ fillColor: this.style.color.subHeaders, font: this.style.font.main.bold, fontSize: this.style.font.baseSize }, () => { this.text(str); this.lineBreak(this.headerGap); }); } para(str: string): PdfWriter { this.text(str); this.lineBreak(this.paraGap); return this; } description(str: string) { this.text(str, { fillColor: this.style.color.secondary }); this.lineBreak(this.paraGap); } dataFields(dataFields: DataField[]) { const origX = this.doc.x; dataFields.forEach((field) => { const fieldName = `${field.name}${(field.required ?? true) ? '':'?'}` const fieldType = field.type?.text ? `${field.type?.text};` : undefined; this.text(fieldName, {}, { continued: fieldType ? true : false }); if (fieldType) { this.text(': ', {}, { continued: true }); this.textRef(fieldType, field.type?.anchor); } if (field.description) { this.doc.moveUp(); let nameAndType = fieldName + (fieldType ? `: ${fieldType}` : ''); this.text(` // ${field.description}`, { fillColor: this.style.color.secondary }, { x: origX + this.style.format.indentStep, indent: this.doc.widthOfString(nameAndType) - this.style.format.indentStep }); } this.doc.x = origX; }); } contentType(contentType: string, options?: { lineContinues?: boolean }) { this.text('Content: ', {}, { continued: true }); this.text(contentType, { fillColor: this.style.color.highlight }, { continued: options?.lineContinues }); } schemaType(typeName: string) { this.text('Type: ', {}, { continued: true }); this.text(typeName ?? '-', { fillColor: this.style.color.highlight }); this.lineBreak(this.paraGap); } objectSchema(dataFields: DataField[]) { this.text('{').indentStart(); this.dataFields(dataFields); this.indentEnd().text('}'); } example(name: string, body: string) { this.text(`Example "${name}":`, { font: this.style.font.main.bold }); this.lineBreak(this.paraGap); this.text(body, { fillColor: this.style.color.secondary, fontSize: this.style.font.baseSize - 2, font: this.style.font.mono.bold }); this.lineBreak(this.paraGap); } enumValues(values: string[]) { this.text('Values: '); this.doc.moveUp(); const nextLineIndent = this.doc.x + this.style.format.indentStep; const indent = this.doc.widthOfString('Values: ') - this.style.format.indentStep; this.withStyle({ fillColor: this.style.color.highlight }, () => { values.forEach((value, index, array) => { const str = (index < array.length - 1) ? `${value}, ` : value; const continued = (index < array.length - 1) ? true : false; if (index === 0) { this.text(str, {}, { x: nextLineIndent, indent, continued }); } else { this.text(str, {}, { continued }); } }); }); } finish() { const doc = this.doc; const footerOptions = this.options?.footerContent?.trim().split(/\s+/); const showPageNumbers = footerOptions?.includes(EFooterOptions.pageNumber); // Add headers and footers to all pages let pages = doc.bufferedPageRange(); for (let i = 0; i < pages.count; i++) { doc.switchToPage(i); const origTop = this.doc.page.margins.top; const origBottom = this.doc.page.margins.bottom; doc.page.margins.top = 0; doc.page.margins.bottom = 0; if (i > 0) { this.withStyle({ fontSize: 9, fillColor: this.style.color.secondary }, () => { if (this.pageHeaderNodes[i]) { this.text(this.pageHeaderNodes[i], {}, { y: origTop / 2, align: 'right' }); } if (showPageNumbers) { this.text(`Page ${i} / ${pages.count - 1}`, {}, { y: this.doc.page.height - origBottom / 2, align: 'right' }); } }); } doc.page.margins.top = origTop; doc.page.margins.bottom = origBottom; } doc.end(); } private setStyle(style: ITextStyle) { this.doc.font(style.font!.face, style.font?.style || undefined).fontSize(style.fontSize).fillColor(style.fillColor); } private resetStyle() { this.styleStack = []; this.setStyle(this.baseStyle); } private withStyle(style: ITextStyle, fn: (style: ITextStyle) => void) { const newStyle = this.pushStyle(style); fn(newStyle); this.popStyle(); } private pushStyle(style: ITextStyle): ITextStyle { const mergedStyle = { ...this.currentStyle(), ...style }; mergedStyle.leftMargin = (this.currentStyle().leftMargin ?? 0) + (style.leftMargin ?? 0); // nested indent this.setStyle(mergedStyle); this.doc.x = mergedStyle.leftMargin; this.styleStack.push(mergedStyle); return mergedStyle; } private popStyle(): ITextStyle { this.styleStack.pop(); const prevStyle = this.currentStyle(); this.setStyle(prevStyle); this.doc.x = prevStyle.leftMargin; return prevStyle; } private currentStyle(): ITextStyle { return (this.styleStack.length > 0) ? this.styleStack[this.styleStack.length-1] : this.baseStyle; } }