UNPKG

declarative-pdf

Version:

A tool for generating PDF documents from declarative HTML templates

1,077 lines (1,053 loc) 36.5 kB
// src/index.ts import { PDFDocument as PDFDocument3, StandardFonts, PageSizes, rgb } from "pdf-lib"; // src/models/document-page.ts import { PDFDocument } from "pdf-lib"; // src/models/element.ts var BodyElement = class { constructor(opts) { this.buffer = opts.buffer; this.pdf = opts.pdf; this.layout = opts.layout; } get x() { return this.layout.x; } get y() { return this.layout.y; } get width() { return this.layout.width; } get height() { return this.layout.height; } async embedPageIdx(targetPage, idx) { return await targetPage.doc.embedPdf(this.pdf, [idx]); } }; var SectionElement = class { constructor(opts) { this.buffer = opts.buffer; this.pdf = opts.pdf; this.setting = opts.setting; this.layout = opts.layout; this.debug = opts.debug; this._name = `${opts.debug.pageNumber}-${opts.debug.type}.pdf`; } get x() { return this.layout.x; } get y() { return this.layout.y; } get width() { return this.layout.width; } get height() { return this.setting.height; } get name() { return this._name; } async embedPage(targetPage) { if (this.embeddedPage) return this.embeddedPage; const pages = await targetPage.doc.embedPdf(this.pdf); this.embeddedPage = pages[0]; return pages[0]; } }; // src/utils/layout/calculate-page-layout.ts var getMaxHeight = (els) => { return els.reduce((x, s) => Math.max(x, s.height ?? 0), 0); }; function calculatePageLayout(sectionSettings, opts) { const { pageHeight, pageWidth, bodyHeightMinimumFactor } = opts; const headerHeight = getMaxHeight(sectionSettings?.headers ?? []); const footerHeight = getMaxHeight(sectionSettings?.footers ?? []); const bodyHeight = pageHeight ? pageHeight - headerHeight - footerHeight + 2 : 0; const backgroundHeight = pageHeight; const headerY = pageHeight - headerHeight; const footerY = footerHeight ? -1 : 0; const bodyY = footerHeight - (pageHeight ? 1 : 0); const backgroundY = 0; if (bodyHeight < pageHeight * bodyHeightMinimumFactor) { throw new Error( `Header/footer too big. Page height: ${pageHeight}px, header: ${headerHeight}px, footer: ${footerHeight}px, body: ${bodyHeight}px.` ); } return { header: { width: pageWidth, height: headerHeight, x: 0, y: headerY }, footer: { width: pageWidth, height: footerHeight, x: 0, y: footerY }, body: { width: pageWidth, height: bodyHeight, x: 0, y: bodyY }, background: { width: pageWidth, height: backgroundHeight, x: 0, y: backgroundY } }; } // src/utils/layout/has-page-numbers.ts function hasPageNumbers(sectionSettings) { if (!sectionSettings) return false; for (const elements of Object.values(sectionSettings)) { for (const el of elements) { if (el.hasCurrentPageNumber || el.hasTotalPagesNumber) { return true; } } } return false; } function hasSectionPageNumbers(sectionSettings) { if (!sectionSettings) return false; return sectionSettings.some((ss) => ss.hasCurrentPageNumber || ss.hasTotalPagesNumber); } // src/utils/layout/create-page-layout-settings.ts function createPageLayoutSettings(sectionSettings, opts) { const pageLayout = calculatePageLayout(sectionSettings, opts); const transparentBg = !!sectionSettings?.backgrounds.length; const { headers = [], footers = [], backgrounds = [] } = sectionSettings ?? {}; const hasAnySection = !!(headers.length || footers.length || backgrounds.length); return { height: opts.pageHeight, width: opts.pageWidth, hasPageNumbers: hasPageNumbers(sectionSettings), hasAnySection, pageCount: 0, body: { ...pageLayout.body, transparentBg }, header: headers.length ? { ...pageLayout.header, transparentBg, hasPageNumbers: hasSectionPageNumbers(headers), settings: headers } : void 0, footer: footers.length ? { ...pageLayout.footer, transparentBg, hasPageNumbers: hasSectionPageNumbers(footers), settings: footers } : void 0, background: backgrounds.length ? { ...pageLayout.background, transparentBg: false, hasPageNumbers: hasSectionPageNumbers(backgrounds), settings: backgrounds } : void 0 }; } // src/models/document-page.ts var DocumentPage = class { constructor(opts) { this.sectionElements = []; this.parent = opts.parent; this.index = opts.index; this.width = opts.width; this.height = opts.height; this.bodyMarginTop = opts.bodyMarginTop; this.bodyMarginBottom = opts.bodyMarginBottom; this.hasSections = opts.hasSections; } get viewPort() { return { width: this.width, height: this.height }; } get html() { return this.parent.html; } /** * Create the layout and body element. * * This method will figure out heights and positions of * all existing elements on this page and create a body * element which will, in turn, give us a total page * count for this document page. * * We need to know the number of pages to be able to * construct other elements that might need to display * current page / total page number. */ async createLayoutAndBody(sectionSettings, logger) { const layoutOpts = { pageHeight: this.height, pageWidth: this.width, bodyHeightMinimumFactor: this.parent.documentOptions?.bodyHeightMinimumFactor ?? 1 / 3 }; this.layout = createPageLayoutSettings(sectionSettings, layoutOpts); await this.html.prepareSection({ documentPageIndex: this.index }); logger?.level3().start("[5.3.1] Print body to pdf buffer"); const uint8Array = await this.html.pdf({ width: this.layout.width, height: this.layout.body.height, margin: { top: this.bodyMarginTop, bottom: this.bodyMarginBottom }, transparentBg: this.layout.body.transparentBg }); logger?.level3().end(); const buffer = Buffer.from(uint8Array); logger?.level3().start("[5.3.2] Load body pdf as PDFDocument"); const pdf = await PDFDocument.load(uint8Array); logger?.level3().end(); await this.html.resetVisibility(); this.layout.pageCount = pdf.getPageCount(); this.body = new BodyElement({ buffer, pdf, layout: this.layout.body }); } get previousDocumentPages() { return this.parent.documentPages.slice(0, this.index); } get pageCountOffset() { return this.previousDocumentPages.reduce((acc, doc) => acc + doc.layout.pageCount, 0); } }; // src/consts/paper-size.ts var PAPER_SIZE = { a0: { width: 841, height: 1189 }, a1: { width: 594, height: 841 }, a2: { width: 420, height: 594 }, a3: { width: 297, height: 420 }, a4: { width: 210, height: 297 }, a5: { width: 148, height: 210 }, a6: { width: 105, height: 148 }, letter: { width: 216, height: 279 }, legal: { width: 216, height: 356 }, tabloid: { width: 279, height: 432 }, ledger: { width: 432, height: 279 } }; // src/utils/paper-defaults.ts var hasFormat = (obj) => !!obj && "format" in obj && typeof obj.format === "string" && Object.keys(PAPER_SIZE).includes(obj.format); var hasPpi = (obj) => !!obj && "ppi" in obj && typeof obj?.ppi === "number" && !isNaN(obj.ppi) && obj.ppi > 18 && obj.ppi < 42e3; var hasWidth = (obj) => !!obj && "width" in obj && typeof obj?.width === "number" && !isNaN(obj.width) && obj.width > 1 && obj.width <= 42e4; var hasHeight = (obj) => !!obj && "height" in obj && typeof obj?.height === "number" && !isNaN(obj.height) && obj.height > 1 && obj.height <= 42e4; var convertMmToPx = (mm, ppi) => Math.round(mm * (ppi / 25.4)); var DEFAULT_FORMAT = "a4"; var DEFAULT_PPI = 72; var DEFAULT_WIDTH = 595; var DEFAULT_HEIGHT = 842; var DEFAULT_BODY_MARGIN_TOP = 0; var DEFAULT_BODY_MARGIN_BOTTOM = 0; var PaperDefaults = class { constructor(opts) { this.ppi = hasPpi(opts) ? opts.ppi : DEFAULT_PPI; if (hasFormat(opts)) { this.format = opts.format; this.width = convertMmToPx(PAPER_SIZE[this.format].width, this.ppi); this.height = convertMmToPx(PAPER_SIZE[this.format].height, this.ppi); } else if (hasWidth(opts) && hasHeight(opts)) { this.format = void 0; this.width = opts.width; this.height = opts.height; } else if (hasWidth(opts)) { this.format = void 0; this.width = opts.width; this.height = DEFAULT_HEIGHT; } else if (hasHeight(opts)) { this.format = void 0; this.width = DEFAULT_WIDTH; this.height = opts.height; } else { this.format = DEFAULT_FORMAT; this.width = DEFAULT_WIDTH; this.height = DEFAULT_HEIGHT; } } }; // src/utils/normalize-setting.ts var capNumber = (num, min, max, base) => { if (typeof num !== "number" || isNaN(num)) return base; return Math.min(Math.max(num, min), max); }; function normalizeSetting(setting) { return { index: setting.index, width: capNumber(setting.width, 1, 42e4, DEFAULT_WIDTH), height: capNumber(setting.height, 1, 42e4, DEFAULT_HEIGHT), bodyMarginTop: capNumber(setting.bodyMarginTop, 0, 42e4, DEFAULT_BODY_MARGIN_TOP), bodyMarginBottom: capNumber(setting.bodyMarginBottom, 0, 42e4, DEFAULT_BODY_MARGIN_BOTTOM), hasSections: setting.hasSections || false }; } // src/evaluators/section-settings.ts function evalSectionSettings(documentPageIndex) { const variants = ["first", "last", "even", "odd", "default"]; const getElementHeight = (el) => { return Math.ceil( Math.max(el.clientHeight ?? 0, el.offsetHeight ?? 0, el.scrollHeight ?? 0, el.getBoundingClientRect().height ?? 0) ); }; const hasCurrentPageNumber = (el) => { const currentPageNumber = el.querySelector("current-page-number, span.page-number"); return !!currentPageNumber; }; const hasTotalPagesNumber = (el) => { const totalPagesNumber = el.querySelector("total-pages-number, span.total-pages"); return !!totalPagesNumber; }; const isVariant = (s) => { return !!s && variants.includes(s); }; const getSettings = (el, physicalPageIndex) => { let physicalPageType; if (physicalPageIndex === void 0) { physicalPageType = void 0; } else { const selectAttr = el.getAttribute("select"); physicalPageType = isVariant(selectAttr) ? selectAttr : "default"; } return { height: getElementHeight(el), physicalPageIndex, physicalPageType, hasCurrentPageNumber: hasCurrentPageNumber(el), hasTotalPagesNumber: hasTotalPagesNumber(el) }; }; const getSection = (docPageEl2, type) => { const sectionEl = docPageEl2.querySelector(`page-${type}`); if (!sectionEl) return []; const physicalPageEl = Array.from(sectionEl.querySelectorAll("physical-page")); if (!physicalPageEl.length) { return [getSettings(sectionEl)]; } return physicalPageEl.map((el, index) => getSettings(el, index)); }; const docPageEls = document.querySelectorAll("document-page"); const docPageEl = docPageEls[documentPageIndex]; if (!docPageEl) return { headers: [], footers: [], backgrounds: [] }; const filterSections = (s) => s && s.height > 0; return { headers: getSection(docPageEl, "header").filter(filterSections), footers: getSection(docPageEl, "footer").filter(filterSections), backgrounds: getSection(docPageEl, "background").filter(filterSections) }; } // src/evaluators/prepare-section.ts function evalPrepareSection(opts) { function hideAllExcept(els, target) { let shownElement; Array.from(els).forEach((el, index) => { if (typeof target === "number" && index === target || el.tagName.toLowerCase() === target) { el.style.display = "block"; shownElement = el; } else { el.style.display = "none"; } }); return shownElement; } function injectNumbers(el) { if (opts.currentPageNumber) { Array.from(el.querySelectorAll("current-page-number, span.page-number")).forEach((el2) => { el2.textContent = String(opts.currentPageNumber); }); } if (opts.totalPagesNumber) { Array.from(el.querySelectorAll("total-pages-number, span.total-pages")).forEach((el2) => { el2.textContent = String(opts.totalPagesNumber); }); } } const secType = opts.sectionType ? `page-${opts.sectionType}` : "page-body"; const docPage = hideAllExcept(document.querySelectorAll("document-page"), opts.documentPageIndex); if (!docPage) return false; const sectionEl = hideAllExcept( docPage.querySelectorAll("page-background, page-header, page-body, page-footer"), secType ); if (!sectionEl) return false; if (secType === "page-body") { sectionEl.style.marginTop = "0px"; sectionEl.style.marginBottom = "0px"; } if (!opts.sectionType || opts.physicalPageIndex === void 0) { injectNumbers(sectionEl); return true; } const subSecEl = hideAllExcept(sectionEl.querySelectorAll("physical-page"), opts.physicalPageIndex); if (!subSecEl) return false; injectNumbers(subSecEl); return true; } // src/evaluators/template-normalize.ts function evalTemplateNormalize(opts) { const addPdfClass = opts?.addPdfClass ?? true; const setBodyMargin = opts?.setBodyMargin ?? true; const setBodyPadding = opts?.setBodyPadding ?? true; const setBodyTransparent = opts?.setBodyTransparent ?? true; const normalizeBody = opts?.normalizeBody ?? true; const normalizeDocumentPage = opts?.normalizeDocumentPage ?? true; if (addPdfClass) document.body.classList.add("pdf"); if (setBodyMargin) document.body.style.margin = "0"; if (setBodyPadding) document.body.style.padding = "0"; if (setBodyTransparent) document.body.style.backgroundColor = "transparent"; if (normalizeBody) { const freeEls = Array.from(document.body.childNodes).filter( (el) => !["DOCUMENT-PAGE", "SCRIPT", "STYLE"].includes(el.nodeName) ); const hasDocumentPage = Array.from(document.body.children).some((el) => el.tagName === "DOCUMENT-PAGE"); if (freeEls.length && !hasDocumentPage) { const docPage = document.createElement("document-page"); docPage.append(...freeEls); document.body.append(docPage); } else if (hasDocumentPage) { freeEls.forEach((el) => el.remove()); } } if (normalizeDocumentPage) { Array.from(document.querySelectorAll("document-page")).forEach((doc) => { const docFreeEls = Array.from(doc.childNodes).filter( (el) => !["PAGE-BODY", "PAGE-BACKGROUND", "PAGE-FOOTER", "PAGE-HEADER", "SCRIPT", "STYLE"].includes(el.nodeName) ); const hasPageBody = Array.from(doc.children).some((el) => el.tagName === "PAGE-BODY"); if (docFreeEls.length && !hasPageBody) { const pageBody = document.createElement("page-body"); pageBody.append(...docFreeEls); doc.append(pageBody); } else if (hasPageBody) { docFreeEls.forEach((el) => el.remove()); } Array.from(doc.querySelectorAll("page-body, page-header, page-footer, page-background")).forEach((el) => { if (!el.childNodes.length) { el.remove(); } }); if (!doc.querySelectorAll("page-body, script, style").length) { doc.remove(); } }); } } // src/evaluators/template-settings.ts function evalTemplateSettings(opts) { const isFormat = (format) => typeof format === "string" && Object.keys(opts.size).includes(format); const convertMmToPx2 = (mm, ppi) => Math.round(mm * (ppi / 25.4)); const getWxH = (str) => { const guard = (x) => x && !isNaN(x) ? x : void 0; const [w, h] = str.split("x").map((x) => guard(Number(x))); return [w, h ?? w]; }; const getPageSettings = (docPageEl, index) => { const attrFormat = docPageEl.getAttribute("format"); const attrPpi = Number(docPageEl.getAttribute("ppi")); const attrSize = docPageEl.getAttribute("size"); const [attrWidth, attrHeight] = attrSize ? getWxH(attrSize) : []; const ppi = attrPpi && attrPpi > 0 ? attrPpi : opts.default.ppi; const hasSections = !!docPageEl.querySelector("page-header, page-footer, page-background"); let bodyMarginBottom = 0; let bodyMarginTop = 0; const pageBodyEl = docPageEl.querySelector("page-body"); if (pageBodyEl) { const pageBodyStyle = window.getComputedStyle(pageBodyEl); const marginTop = parseFloat(pageBodyStyle.marginTop); const marginBottom = parseFloat(pageBodyStyle.marginBottom); bodyMarginTop = isNaN(marginTop) ? 0 : Math.ceil(marginTop); bodyMarginBottom = isNaN(marginBottom) ? 0 : Math.ceil(marginBottom); } let width, height; if (isFormat(attrFormat)) { const size = opts.size[attrFormat]; width = convertMmToPx2(size.width, ppi); height = convertMmToPx2(size.height, ppi); } else if (attrWidth && attrHeight) { width = attrWidth; height = attrHeight; } else { width = opts.default.width; height = opts.default.height; } return { index, width, height, bodyMarginTop, bodyMarginBottom, hasSections }; }; const docPageEls = Array.from(document.querySelectorAll("document-page")); return docPageEls.map(getPageSettings); } // src/evaluators/reset-visibility.ts function evalResetVisibility() { const hideables = [ "document-page", "page-background", "page-header", "page-body", "page-footer", "physical-page" ].join(", "); Array.from(document.querySelectorAll(hideables)).forEach((el) => { if (el.style.display === "none") { el.style.display = "block"; } }); } // src/utils/adapter-puppeteer.ts var HTMLAdapter = class { constructor(browser) { this._browser = browser; } get browser() { if (!this._browser) throw new Error("Browser not set"); if ("connected" in this._browser) { if (!this._browser.connected) throw new Error("Browser not connected"); } else { if (!this._browser?.isConnected?.()) throw new Error("Browser not connected"); } return this._browser; } get page() { if (!this._page) throw new Error("Page not set"); if (this._page.isClosed()) throw new Error("Page is closed"); return this._page; } async newPage() { if (this._page) throw new Error("Page already set"); this._page = await this.browser.newPage(); } setPage(page) { if (this._page) throw new Error("Page already set"); this._page = page; } releasePage() { this._page = void 0; } setContent(content) { return this.page.setContent(content, { waitUntil: ["load", "networkidle0"] }); } setViewport(opts) { return this.page.setViewport(opts); } normalize(opts) { return this.page.evaluate(evalTemplateNormalize, opts); } async getTemplateSettings(opts) { return this.page.evaluate(evalTemplateSettings, { default: opts, size: PAPER_SIZE }); } getSectionSettings(opts) { return this.page.evaluate(evalSectionSettings, opts.index); } prepareSection(opts) { return this.page.evaluate(evalPrepareSection, opts); } resetVisibility() { return this.page.evaluate(evalResetVisibility); } /** * There is some bug in the PDF generation process, where the height and * the width of the resulting PDF page get smaller by approximate factor * of 0.75. During this process, some rounding issues occur and sometimes, * we end up with 2 pages instead of 1. Also, backgrounds sometimes get * a narrow white line at the bottom. * * To mitigate this, we scale up the width and height by 0.75, as well as * the scale, to keep the same appearance. */ pdf(opts) { return this.page.pdf({ width: opts.width / 0.75, height: opts.height / 0.75, scale: 1 / 0.75, margin: opts.margin, omitBackground: opts.transparentBg, printBackground: true }); } async close() { if (this._page && !this._page.isClosed()) { await this._page?.close(); this._page = void 0; } } }; // src/utils/debug/time-logger.ts function buildReportLine(obj, lvl, totalMs, totalLen) { const pctNum = totalMs ? obj.duration / totalMs * 100 : 0; const pct = pctNum === 100 ? " 100" : pctNum.toFixed(1).padStart(4, " "); const dur = obj.duration.toString().padStart(totalLen, " "); return `${dur}ms | ${pct}% | ${"".padStart(lvl * 2, " ")}${obj.name}`; } var TimeLogger = class { constructor() { this._nodes = /* @__PURE__ */ new Map(); this._report = ""; this.setupNode("session"); this.setupNode("level 1", "session"); this.setupNode("level 2", "level 1"); this.setupNode("level 3", "level 2"); } setupNode(key, parentKey) { const parent = parentKey ? this._nodes.get(parentKey) : void 0; const defaultName = key[0].toUpperCase() + key.slice(1); const timeNode = { defaultName, current: { name: "", start: 0, duration: 0 }, report: [], parent }; if (parent) parent.child = timeNode; this._nodes.set(key, timeNode); } handleNode(key) { const node = this._nodes.get(key); return { start: (name) => { this.endNode(node); this.startNode(node, name); }, end: () => this.endNode(node) }; } startNode(node, name) { if (node.parent && !node.parent.current.name) { this.startNode(node.parent); } node.current.name = name || node.defaultName; node.current.start = Date.now(); } endNode(node) { if (!node.current.name) return; if (node.child) this.endNode(node.child); node.current.duration = Date.now() - node.current.start; const reportNode = { ...node.current }; if (node.child) { this.endNode(node.child); reportNode.children = [...node.child.report]; node.child.report = []; } node.report.push(reportNode); this.resetTimeObject(node.current); if (!node.parent) this.generateReport(); } // Public methods remain similar but use handleNode session() { return this.handleNode("session"); } level1() { return this.handleNode("level 1"); } level2() { return this.handleNode("level 2"); } level3() { return this.handleNode("level 3"); } resetTimeObject(timeObj) { timeObj.name = ""; timeObj.start = 0; timeObj.duration = 0; } generateReport() { const session = this._nodes.get("session").report[0]; const totalMs = session.duration; const totalLen = totalMs.toString().length; const title = `${totalMs}ms | ${session.name}`; const report = [ title, "".padEnd(title.length, "="), ...session.children.flatMap((level1) => [ buildReportLine(level1, 0, totalMs, totalLen), ...level1.children.flatMap((level2) => [ buildReportLine(level2, 1, totalMs, totalLen), ...level2.children.map((level3) => buildReportLine(level3, 2, totalMs, totalLen)) ]) ]) ]; this._report = report.join("\n"); } get report() { return this._report; } }; // src/utils/layout/build-pages.ts import { PDFDocument as PDFDocument2 } from "pdf-lib"; // src/consts/physical-page.ts var PhysicalPageSelector = /* @__PURE__ */ ((PhysicalPageSelector2) => { PhysicalPageSelector2["FIRST"] = "first"; PhysicalPageSelector2["LAST"] = "last"; PhysicalPageSelector2["EVEN"] = "even"; PhysicalPageSelector2["ODD"] = "odd"; PhysicalPageSelector2["DEFAULT"] = "default"; return PhysicalPageSelector2; })(PhysicalPageSelector || {}); var physical_page_default = PhysicalPageSelector; // src/utils/select-section.ts function containVariants(sectionSettings) { return sectionSettings.some( (section) => section.physicalPageType !== void 0 || section.physicalPageIndex !== void 0 ); } function findVariant(variants, condition) { return variants.find((variant) => variant.physicalPageType === condition); } function selectSection(sectionSettings, pageIndex, offset, count) { if (!sectionSettings.length) return; if (!containVariants(sectionSettings)) return sectionSettings[0]; const isFirst = pageIndex === 0; const isLast = pageIndex === count - 1; const isOdd = (pageIndex + 1 + offset) % 2 === 1; return (isLast || isFirst ? findVariant(sectionSettings, isLast ? physical_page_default.LAST : physical_page_default.FIRST) : void 0) || findVariant(sectionSettings, isOdd ? physical_page_default.ODD : physical_page_default.EVEN) || findVariant(sectionSettings, physical_page_default.DEFAULT); } // src/utils/layout/build-pages.ts async function createSectionElement(sectionType, setting, opts) { const { documentPageIndex, currentPageNumber, totalPagesNumber, html, layout, logger } = opts; const { physicalPageIndex } = setting; html.prepareSection({ documentPageIndex, sectionType, physicalPageIndex, currentPageNumber, totalPagesNumber }); logger?.level3().start(`[6.1.1] Rendering ${sectionType} section`); const uint8Array = await html.pdf({ width: layout.width, height: setting.height, transparentBg: !!layout?.[sectionType]?.transparentBg }); logger?.level3().end(); const buffer = Buffer.from(uint8Array); const pdf = await PDFDocument2.load(buffer); return new SectionElement({ buffer, pdf, setting, debug: { type: sectionType, pageNumber: currentPageNumber }, layout: { width: layout.width, height: layout[sectionType].height, x: 0, y: layout[sectionType].y } }); } async function resolveSectionElement(sectionType, opts) { const setting = selectSection( opts.layout?.[sectionType]?.settings ?? [], opts.pageIndex, opts.pageCountOffset, opts.layout.pageCount ); if (!setting) return void 0; const element = opts.elements.find( (el) => el.setting === setting && !el.setting.hasCurrentPageNumber && !el.setting.hasTotalPagesNumber ); if (element) return element; const newElement = await createSectionElement(sectionType, setting, opts); opts.elements.push(newElement); return newElement; } async function buildPages(opts) { const { documentPageIndex, pageCountOffset, totalPagesNumber, layout, body, target, html, logger } = opts; const { pageCount } = layout; if (!pageCount) throw new Error("Document page has no pages"); if (!target) throw new Error("No target PDF document provided"); const pages = []; const elements = []; if (!layout.hasAnySection) { logger?.level2().start("[6.3] Copy body pages (no sections)"); const copiedPages = await target.copyPages(body.pdf, body.pdf.getPageIndices()); copiedPages.forEach((page) => target.addPage(page)); logger?.level2().end(); return { pages, elements }; } const pageIndices = Array.from({ length: pageCount }, (_, i) => i); for (const pageIndex of pageIndices) { const currentPageNumber = pageIndex + 1 + pageCountOffset; const opts2 = { documentPageIndex, pageIndex, pageCountOffset, currentPageNumber, totalPagesNumber, elements, layout, html, logger }; logger?.level2().start(`[6.1] Resolve page ${pageIndex} section elements`); const header = await resolveSectionElement("header", opts2); const footer = await resolveSectionElement("footer", opts2); const background = await resolveSectionElement("background", opts2); logger?.level2().end(); logger?.level2().start(`[6.2] Embed and place page ${pageIndex} sections`); const targetPage = target.addPage([layout.width, layout.height]); await embedAndPlaceSection(targetPage, background); await embedAndPlaceSection(targetPage, header); await embedAndPlaceSection(targetPage, footer); await embedAndPlaceBody(targetPage, body, pageIndex); logger?.level2().end(); pages.push({ pageIndex, currentPageNumber, header, footer, background }); } if (opts.attachSegmentsForDebugging) { for (const element of elements) { await target.attach(element.buffer, element.name, { mimeType: "application/pdf", description: `${element.debug.type} section first created for page ${element.debug.pageNumber}`, creationDate: /* @__PURE__ */ new Date(), modificationDate: /* @__PURE__ */ new Date() }); } await target.attach(body.buffer, `${pageCountOffset + 1}-body.pdf`, { mimeType: "application/pdf", description: `Body element first created for page ${pageCountOffset + 1}`, creationDate: /* @__PURE__ */ new Date(), modificationDate: /* @__PURE__ */ new Date() }); } return { pages, elements }; } async function embedAndPlaceSection(page, section) { if (!section) return; const embeddedPage = await section.embedPage(page); page.drawPage(embeddedPage, { x: section.x, y: section.y, width: section.width, height: section.height }); } async function embedAndPlaceBody(page, body, idx) { const [embeddedPage] = await body.embedPageIdx(page, idx); page.drawPage(embeddedPage, { x: body.x, y: body.y, width: body.width, height: body.height }); } // src/utils/set-document-metadata.ts function isValidDate(date) { return date instanceof Date && !isNaN(date.getTime()); } function isValidKeyword(keyword) { return Array.isArray(keyword) && !!keyword.length && keyword.every((k) => typeof k === "string"); } function typedEntries(obj) { return Object.entries(obj); } var adapters = { title: (pdf, value) => pdf.setTitle(value), author: (pdf, value) => pdf.setAuthor(value), subject: (pdf, value) => pdf.setSubject(value), keywords: (pdf, value) => isValidKeyword(value) && pdf.setKeywords(value), producer: (pdf, value) => pdf.setProducer(value), creator: (pdf, value) => pdf.setCreator(value), creationDate: (pdf, value) => isValidDate(value) && pdf.setCreationDate(value), modificationDate: (pdf, value) => isValidDate(value) && pdf.setModificationDate(value) }; function setDocumentMetadata(pdf, meta) { if (!meta || Object.keys(meta).length === 0) return; for (const [key, value] of typedEntries(meta)) { if (value !== void 0 && value !== null && key in adapters) { adapters[key](pdf, value); } } } // package.json var version = "1.1.1"; // src/index.ts var DeclarativePDF = class { /** * * @param browser A puupeteer browser instance, prepared for use * @param opts Various options for the PDF generator */ constructor(browser, opts) { this.documentPages = []; this.html = new HTMLAdapter(browser); this.defaults = new PaperDefaults(opts?.defaults); this.normalize = opts?.normalize; this.debug = opts?.debug ?? {}; this.documentOptions = opts?.document; } get totalPagesNumber() { return this.documentPages.reduce((acc, doc) => acc + doc.layout.pageCount, 0); } /** * Generates a pdf buffer from string containing html template. * * When calling this method, it is expected that: * - the browser is initialized and ready * - the template is a valid HTML document -OR- a valid Page instance */ async generate(input) { const isPageHandledInternally = typeof input === "string"; const logger = this.debug.timeLog ? new TimeLogger() : void 0; logger?.session().start(`DeclarativePDF v${version} rendering ${this.debug.pdfName ?? "PDF"}`); this.documentPages = []; try { if (isPageHandledInternally) { logger?.level1().start("[1] Opening new tab"); await this.html.newPage(); logger?.level1().start("[2] Setting content and loading html"); await this.html.setContent(input); } else { this.html.setPage(input); } logger?.level1().start("[3] Normalizing content"); await this.html.normalize(this.normalize); logger?.level1().start("[4] Getting document page settings from DOM"); await this.getDocumentPageSettings(); if (!this.documentPages.length) throw new Error("No document pages found"); logger?.level1().start("[5] Build page layout and body"); await this.buildLayoutForEachDocumentPage(logger); logger?.level1().end(); if (this.documentPages.length === 1 && !this.documentPages[0].hasSections) { const meta = this.documentOptions?.meta; if (meta) { const pdf2 = await PDFDocument3.load(this.documentPages[0].body.buffer); setDocumentMetadata(pdf2, meta); return Buffer.from(await pdf2.save()); } return this.documentPages[0].body.buffer; } logger?.level1().start("[6] Process sections and build final PDF"); const pdf = await this.buildPDF(logger); logger?.level1().end(); if (isPageHandledInternally) { logger?.level1().start("[7] Closing tab"); await this.html.close(); } else { this.html.releasePage(); } logger?.session().end(); const report = logger?.report; if (report) { console.log(report); const reportPdf = await PDFDocument3.create(); const font = await reportPdf.embedFont(StandardFonts.Courier); const page = reportPdf.addPage(PageSizes.A4); page.setFont(font); report.split("\n").forEach((line, index) => { let color = rgb(0, 0, 0.8); if (line.includes("| [")) color = rgb(0.6, 0.6, 0.6); else if (line.includes("| [")) color = rgb(0.8, 0.8, 0.8); page.drawText(line, { x: 50, y: 750 - index * 12, size: 10, color }); }); const reportBytes = await reportPdf.save(); pdf.attach(reportBytes, "time-log.pdf", { mimeType: "application/pdf", description: "Time log report", creationDate: /* @__PURE__ */ new Date(), modificationDate: /* @__PURE__ */ new Date() }); } return Buffer.from(await pdf.save()); } catch (error) { if (isPageHandledInternally) { logger?.level1().start("[x] Closing tab after error"); await this.html.close(); } else { this.html.releasePage(); } logger?.session().end(); const report = logger?.report; if (report) console.log(report); throw error; } } /** * Creates the document page models. * * This method will evaluate the template settings and create a new * document page model for each setting parsed from the HTML template. */ async getDocumentPageSettings() { const documentPageSettings = await this.html.getTemplateSettings({ width: this.defaults.width, height: this.defaults.height, ppi: this.defaults.ppi }); documentPageSettings.forEach((setting) => { this.documentPages.push(new DocumentPage({ parent: this, ...normalizeSetting(setting) })); }); } /** * Initializes the document page models. * * For every created document page model, this method sets desired * viewport and gets section settings from the DOM to create layout * (heights and positions). It then convert to pdf the body element * from which we get the number of pages and finally have all the * information needed to build the final PDF. */ async buildLayoutForEachDocumentPage(logger) { for (const [index, doc] of this.documentPages.entries()) { logger?.level2().start("[5.1] Set viewport"); await this.html.setViewport(doc.viewPort); logger?.level2().end(); let settings; if (doc.hasSections) { logger?.level2().start("[5.2] Get section settings"); settings = await this.html.getSectionSettings({ index }); logger?.level2().end(); } logger?.level2().start("[5.3] Create layout and body"); await doc.createLayoutAndBody(settings, logger); logger?.level2().end(); } } async buildPDF(logger) { const outputPDF = await PDFDocument3.create(); for (const doc of this.documentPages) { await buildPages({ documentPageIndex: doc.index, pageCountOffset: doc.pageCountOffset, totalPagesNumber: this.totalPagesNumber, layout: doc.layout, body: doc.body, target: outputPDF, html: this.html, logger, attachSegmentsForDebugging: this.debug.attachSegments }); } const meta = this.documentOptions?.meta; if (meta) setDocumentMetadata(outputPDF, meta); return outputPDF; } }; export { DeclarativePDF as default };