diff --git a/coverage/coverage-summary.json b/coverage/coverage-summary.json index 3fbf0c8..1223798 100644 --- a/coverage/coverage-summary.json +++ b/coverage/coverage-summary.json @@ -1,16 +1,17 @@ -{"total": {"lines":{"total":377,"covered":377,"skipped":0,"pct":100},"statements":{"total":412,"covered":412,"skipped":0,"pct":100},"functions":{"total":91,"covered":91,"skipped":0,"pct":100},"branches":{"total":157,"covered":157,"skipped":0,"pct":100},"branchesTrue":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/Users/tibor/code/productive/declarative-pdf/src/index.ts": {"lines":{"total":85,"covered":85,"skipped":0,"pct":100},"functions":{"total":9,"covered":9,"skipped":0,"pct":100},"statements":{"total":90,"covered":90,"skipped":0,"pct":100},"branches":{"total":22,"covered":22,"skipped":0,"pct":100}} +{"total": {"lines":{"total":405,"covered":405,"skipped":0,"pct":100},"statements":{"total":443,"covered":443,"skipped":0,"pct":100},"functions":{"total":104,"covered":104,"skipped":0,"pct":100},"branches":{"total":175,"covered":175,"skipped":0,"pct":100},"branchesTrue":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"/Users/tibor/code/productive/declarative-pdf/src/index.ts": {"lines":{"total":94,"covered":94,"skipped":0,"pct":100},"functions":{"total":9,"covered":9,"skipped":0,"pct":100},"statements":{"total":100,"covered":100,"skipped":0,"pct":100},"branches":{"total":24,"covered":24,"skipped":0,"pct":100}} ,"/Users/tibor/code/productive/declarative-pdf/src/consts/paper-size.ts": {"lines":{"total":1,"covered":1,"skipped":0,"pct":100},"functions":{"total":0,"covered":0,"skipped":0,"pct":100},"statements":{"total":1,"covered":1,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} ,"/Users/tibor/code/productive/declarative-pdf/src/consts/physical-page.ts": {"lines":{"total":7,"covered":7,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":7,"covered":7,"skipped":0,"pct":100},"branches":{"total":2,"covered":2,"skipped":0,"pct":100}} -,"/Users/tibor/code/productive/declarative-pdf/src/models/document-page.ts": {"lines":{"total":28,"covered":28,"skipped":0,"pct":100},"functions":{"total":7,"covered":7,"skipped":0,"pct":100},"statements":{"total":29,"covered":29,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"/Users/tibor/code/productive/declarative-pdf/src/models/document-page.ts": {"lines":{"total":29,"covered":29,"skipped":0,"pct":100},"functions":{"total":7,"covered":7,"skipped":0,"pct":100},"statements":{"total":30,"covered":30,"skipped":0,"pct":100},"branches":{"total":2,"covered":2,"skipped":0,"pct":100}} ,"/Users/tibor/code/productive/declarative-pdf/src/models/element.ts": {"lines":{"total":25,"covered":25,"skipped":0,"pct":100},"functions":{"total":13,"covered":13,"skipped":0,"pct":100},"statements":{"total":26,"covered":26,"skipped":0,"pct":100},"branches":{"total":1,"covered":1,"skipped":0,"pct":100}} ,"/Users/tibor/code/productive/declarative-pdf/src/utils/adapter-puppeteer.ts": {"lines":{"total":32,"covered":32,"skipped":0,"pct":100},"functions":{"total":15,"covered":15,"skipped":0,"pct":100},"statements":{"total":39,"covered":39,"skipped":0,"pct":100},"branches":{"total":12,"covered":12,"skipped":0,"pct":100}} ,"/Users/tibor/code/productive/declarative-pdf/src/utils/normalize-setting.ts": {"lines":{"total":6,"covered":6,"skipped":0,"pct":100},"functions":{"total":2,"covered":2,"skipped":0,"pct":100},"statements":{"total":7,"covered":7,"skipped":0,"pct":100},"branches":{"total":5,"covered":5,"skipped":0,"pct":100}} ,"/Users/tibor/code/productive/declarative-pdf/src/utils/paper-defaults.ts": {"lines":{"total":37,"covered":37,"skipped":0,"pct":100},"functions":{"total":6,"covered":6,"skipped":0,"pct":100},"statements":{"total":38,"covered":38,"skipped":0,"pct":100},"branches":{"total":34,"covered":34,"skipped":0,"pct":100}} ,"/Users/tibor/code/productive/declarative-pdf/src/utils/select-section.ts": {"lines":{"total":11,"covered":11,"skipped":0,"pct":100},"functions":{"total":5,"covered":5,"skipped":0,"pct":100},"statements":{"total":14,"covered":14,"skipped":0,"pct":100},"branches":{"total":15,"covered":15,"skipped":0,"pct":100}} +,"/Users/tibor/code/productive/declarative-pdf/src/utils/set-document-metadata.ts": {"lines":{"total":17,"covered":17,"skipped":0,"pct":100},"functions":{"total":13,"covered":13,"skipped":0,"pct":100},"statements":{"total":19,"covered":19,"skipped":0,"pct":100},"branches":{"total":18,"covered":18,"skipped":0,"pct":100}} ,"/Users/tibor/code/productive/declarative-pdf/src/utils/debug/time-logger.ts": {"lines":{"total":53,"covered":53,"skipped":0,"pct":100},"functions":{"total":18,"covered":18,"skipped":0,"pct":100},"statements":{"total":57,"covered":57,"skipped":0,"pct":100},"branches":{"total":16,"covered":16,"skipped":0,"pct":100}} ,"/Users/tibor/code/productive/declarative-pdf/src/utils/layout/build-pages.ts": {"lines":{"total":60,"covered":60,"skipped":0,"pct":100},"functions":{"total":8,"covered":8,"skipped":0,"pct":100},"statements":{"total":67,"covered":67,"skipped":0,"pct":100},"branches":{"total":12,"covered":12,"skipped":0,"pct":100}} -,"/Users/tibor/code/productive/declarative-pdf/src/utils/layout/calculate-page-layout.ts": {"lines":{"total":14,"covered":14,"skipped":0,"pct":100},"functions":{"total":3,"covered":3,"skipped":0,"pct":100},"statements":{"total":16,"covered":16,"skipped":0,"pct":100},"branches":{"total":15,"covered":15,"skipped":0,"pct":100}} -,"/Users/tibor/code/productive/declarative-pdf/src/utils/layout/create-page-layout.ts": {"lines":{"total":8,"covered":8,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":8,"covered":8,"skipped":0,"pct":100},"branches":{"total":16,"covered":16,"skipped":0,"pct":100}} +,"/Users/tibor/code/productive/declarative-pdf/src/utils/layout/calculate-page-layout.ts": {"lines":{"total":15,"covered":15,"skipped":0,"pct":100},"functions":{"total":3,"covered":3,"skipped":0,"pct":100},"statements":{"total":17,"covered":17,"skipped":0,"pct":100},"branches":{"total":13,"covered":13,"skipped":0,"pct":100}} +,"/Users/tibor/code/productive/declarative-pdf/src/utils/layout/create-page-layout-settings.ts": {"lines":{"total":8,"covered":8,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":8,"covered":8,"skipped":0,"pct":100},"branches":{"total":14,"covered":14,"skipped":0,"pct":100}} ,"/Users/tibor/code/productive/declarative-pdf/src/utils/layout/has-page-numbers.ts": {"lines":{"total":10,"covered":10,"skipped":0,"pct":100},"functions":{"total":3,"covered":3,"skipped":0,"pct":100},"statements":{"total":13,"covered":13,"skipped":0,"pct":100},"branches":{"total":7,"covered":7,"skipped":0,"pct":100}} } diff --git a/docs/api.md b/docs/api.md index faf6e98..1677034 100644 --- a/docs/api.md +++ b/docs/api.md @@ -25,6 +25,29 @@ constructor(browser: MinimumBrowser, opts?: DeclarativePDFOpts) - `opts` - optional configuration object ```typescript +export interface DocumentMeta { + title?: string; + author?: string; + subject?: string; + keywords?: string[]; + producer?: string; + creator?: string; + creationDate?: Date; + modificationDate?: Date; +} + +export interface DocumentOptions { + /** If exists, will be used to set available metadata fields on the pdf document */ + meta?: DocumentMeta; + /** + * Controls the minimum space the body section must occupy on each page. + * Value is a decimal factor of the total page height (0.0 to 1.0). + * - Default: 1/3 (a third of the page) + * - Example: 0.25 means body must be at least 25% of page height + */ + bodyHeightMinimumFactor?: number; +} + interface DeclarativePDFOpts { /** Normalize HTML content options */ normalize?: { @@ -65,6 +88,9 @@ interface DeclarativePDFOpts { /** Attach generated PDF segments for debugging (default: false) */ attachSegments?: boolean; }; + + /** Override for pdf document metadata and rules */ + document?: DocumentOptions; } ``` @@ -112,6 +138,7 @@ import DeclarativePDF from 'declarative-pdf'; const browser = await puppeteer.launch(); const pdf = new DeclarativePDF(browser, { debug: {timeLog: true}, + document: {meta: {title: 'Document title'}} }); // From string diff --git a/src/index.ts b/src/index.ts index d33b9e1..7dfbdfc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import {PaperDefaults, type PaperOpts} from '@app/utils/paper-defaults'; import HTMLAdapter, {type MinimumBrowser, type MinimumPage} from '@app/utils/adapter-puppeteer'; import TimeLogger from '@app/utils/debug/time-logger'; import {buildPages} from '@app/utils/layout/build-pages'; +import {setDocumentMetadata} from '@app/utils/set-document-metadata'; import {version} from '../package.json'; interface DebugOptions { @@ -31,6 +32,29 @@ export interface NormalizeOptions { normalizeDocumentPage?: boolean; } +export interface DocumentMeta { + title: string; + author: string; + subject: string; + keywords: string[]; + producer: string; + creator: string; + creationDate: Date; + modificationDate: Date; +} + +export interface DocumentOptions { + /** If exists, will be used to set available metadata fields on the pdf document */ + meta?: Partial; + /** + * Controls the minimum space the body section must occupy on each page. + * Value is a decimal factor of the total page height (0.0 to 1.0). + * - Default: 1/3 (a third of the page) + * - Example: 0.25 means body must be at least 25% of page height + */ + bodyHeightMinimumFactor?: number; +} + interface DeclarativePDFOpts { /** Should we normalize the content (remove excess elements, set some defaults...) */ normalize?: NormalizeOptions; @@ -38,6 +62,8 @@ interface DeclarativePDFOpts { defaults?: PaperOpts; /** Debug options (attaches parts, logs timings) */ debug?: DebugOptions; + /** Override for pdf document metadata and rules */ + document?: DocumentOptions; } export default class DeclarativePDF { @@ -45,6 +71,7 @@ export default class DeclarativePDF { declare defaults: PaperDefaults; declare normalize?: NormalizeOptions; declare debug: DebugOptions; + declare documentOptions?: DocumentOptions; documentPages: DocumentPage[] = []; @@ -58,6 +85,7 @@ export default class DeclarativePDF { this.defaults = new PaperDefaults(opts?.defaults); this.normalize = opts?.normalize; this.debug = opts?.debug ?? {}; + this.documentOptions = opts?.document; } get totalPagesNumber() { @@ -120,6 +148,13 @@ export default class DeclarativePDF { * resulting PDF will be the same as the body buffer. */ if (this.documentPages.length === 1 && !this.documentPages[0].hasSections) { + const meta = this.documentOptions?.meta; + if (meta) { + const pdf = await PDFDocument.load(this.documentPages[0].body!.buffer); + setDocumentMetadata(pdf, meta); + return Buffer.from(await pdf.save()); + } + return this.documentPages[0].body!.buffer; } @@ -248,6 +283,9 @@ export default class DeclarativePDF { }); } + const meta = this.documentOptions?.meta; + if (meta) setDocumentMetadata(outputPDF, meta); + return outputPDF; } } diff --git a/src/models/document-page.ts b/src/models/document-page.ts index 20269fb..204dfc2 100644 --- a/src/models/document-page.ts +++ b/src/models/document-page.ts @@ -1,12 +1,12 @@ import {PDFDocument} from 'pdf-lib'; import {BodyElement} from '@app/models/element'; -import {createPageLayoutSettings} from '@app/utils/layout/create-page-layout'; +import {createPageLayoutSettings} from '@app/utils/layout/create-page-layout-settings'; import type TimeLogger from '@app/utils/debug/time-logger'; import type DeclarativePDF from '@app/index'; import type {SectionSettings} from '@app/evaluators/section-settings'; import type {SectionElement} from '@app/models/element'; -import type {PageLayout} from '@app/utils/layout/create-page-layout'; +import type {PageLayout} from '@app/utils/layout/create-page-layout-settings'; export type DocumentPageOpts = { parent: DeclarativePDF; @@ -73,7 +73,13 @@ export class DocumentPage { * current page / total page number. */ async createLayoutAndBody(sectionSettings?: SectionSettings, logger?: TimeLogger) { - this.layout = createPageLayoutSettings(sectionSettings, this.height, this.width); + 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}); diff --git a/src/utils/layout/build-pages.ts b/src/utils/layout/build-pages.ts index 532d1eb..a72e5a1 100644 --- a/src/utils/layout/build-pages.ts +++ b/src/utils/layout/build-pages.ts @@ -3,7 +3,7 @@ import {selectSection} from '@app/utils/select-section'; import {SectionElement} from '@app/models/element'; import type {PDFPage} from 'pdf-lib'; -import type {PageLayout} from '@app/utils/layout/create-page-layout'; +import type {PageLayout} from '@app/utils/layout/create-page-layout-settings'; import type {SectionSetting} from '@app/evaluators/section-settings'; import type {BodyElement} from '@app/models/element'; import type HTMLAdapter from '@app/utils/adapter-puppeteer'; diff --git a/src/utils/layout/calculate-page-layout.ts b/src/utils/layout/calculate-page-layout.ts index 9610b12..c4c7acb 100644 --- a/src/utils/layout/calculate-page-layout.ts +++ b/src/utils/layout/calculate-page-layout.ts @@ -4,11 +4,17 @@ export const getMaxHeight = (els: SectionSetting[]) => { return els.reduce((x, s) => Math.max(x, s.height ?? 0), 0); }; +interface CalculatePageLayoutOpts { + pageHeight: number; + pageWidth: number; + bodyHeightMinimumFactor: number; +} + export default function calculatePageLayout( - sectionSettings?: SectionSettings, - pageHeight: number = 0, - pageWidth: number = 0 + sectionSettings: SectionSettings | undefined, + opts: CalculatePageLayoutOpts ) { + const {pageHeight, pageWidth, bodyHeightMinimumFactor} = opts; const headerHeight = getMaxHeight(sectionSettings?.headers ?? []); const footerHeight = getMaxHeight(sectionSettings?.footers ?? []); // Add minimum overlap to avoid white lines between sections @@ -20,7 +26,7 @@ export default function calculatePageLayout( const bodyY = footerHeight - (pageHeight ? 1 : 0); const backgroundY = 0; - if (bodyHeight < pageHeight / 3) { + if (bodyHeight < pageHeight * bodyHeightMinimumFactor) { throw new Error( `Header/footer too big. Page height: ${pageHeight}px, header: ${headerHeight}px, footer: ${footerHeight}px, body: ${bodyHeight}px.` ); diff --git a/src/utils/layout/create-page-layout.ts b/src/utils/layout/create-page-layout-settings.ts similarity index 84% rename from src/utils/layout/create-page-layout.ts rename to src/utils/layout/create-page-layout-settings.ts index 331a01b..f33ef82 100644 --- a/src/utils/layout/create-page-layout.ts +++ b/src/utils/layout/create-page-layout-settings.ts @@ -28,12 +28,17 @@ export interface PageLayout { background?: SectionLayout; } +interface CreatePageLayoutSettingsOpts { + pageHeight: number; + pageWidth: number; + bodyHeightMinimumFactor: number; +} + export function createPageLayoutSettings( - sectionSettings?: SectionSettings, - pageHeight: number = 0, - pageWidth: number = 0 + sectionSettings: SectionSettings | undefined, + opts: CreatePageLayoutSettingsOpts ): PageLayout { - const pageLayout = calculatePageLayout(sectionSettings, pageHeight, pageWidth); + const pageLayout = calculatePageLayout(sectionSettings, opts); const transparentBg = !!sectionSettings?.backgrounds.length; const {headers = [], footers = [], backgrounds = []} = sectionSettings ?? {}; @@ -41,8 +46,8 @@ export function createPageLayoutSettings( const hasAnySection = !!(headers.length || footers.length || backgrounds.length); return { - height: pageHeight, - width: pageWidth, + height: opts.pageHeight, + width: opts.pageWidth, hasPageNumbers: hasPageNumbers(sectionSettings), hasAnySection, pageCount: 0, diff --git a/src/utils/set-document-metadata.ts b/src/utils/set-document-metadata.ts new file mode 100644 index 0000000..72bea2d --- /dev/null +++ b/src/utils/set-document-metadata.ts @@ -0,0 +1,35 @@ +import type {PDFDocument} from 'pdf-lib'; +import type {DocumentMeta} from '@app/index'; + +function isValidDate(date: any): date is Date { + return date instanceof Date && !isNaN(date.getTime()); +} + +function isValidKeyword(keyword: any): keyword is string[] { + return Array.isArray(keyword) && !!keyword.length && keyword.every((k) => typeof k === 'string'); +} + +function typedEntries(obj: T): Array<[keyof T, T[keyof T]]> { + return Object.entries(obj) as Array<[keyof T, T[keyof T]]>; +} + +const adapters: {[K in keyof DocumentMeta]: (pdf: PDFDocument, value: DocumentMeta[K]) => void} = { + 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), +}; + +export function setDocumentMetadata(pdf: PDFDocument, meta?: Partial): void { + if (!meta || Object.keys(meta).length === 0) return; + + for (const [key, value] of typedEntries(meta)) { + if (value !== undefined && value !== null && key in adapters) { + adapters[key](pdf, value as any); + } + } +} diff --git a/test/data/actualPdfs/a4-72-multipage.pdf b/test/data/actualPdfs/a4-72-multipage.pdf index ef6cf4c..117b73c 100644 Binary files a/test/data/actualPdfs/a4-72-multipage.pdf and b/test/data/actualPdfs/a4-72-multipage.pdf differ diff --git a/test/data/actualPdfs/a4-72-standard.pdf b/test/data/actualPdfs/a4-72-standard.pdf index e19c591..b7b5c74 100644 Binary files a/test/data/actualPdfs/a4-72-standard.pdf and b/test/data/actualPdfs/a4-72-standard.pdf differ diff --git a/test/data/actualPdfs/elegant.pdf b/test/data/actualPdfs/elegant.pdf index 51fa0de..f88c839 100644 Binary files a/test/data/actualPdfs/elegant.pdf and b/test/data/actualPdfs/elegant.pdf differ diff --git a/test/data/actualPdfs/standard.pdf b/test/data/actualPdfs/standard.pdf index f68952a..7d94708 100644 Binary files a/test/data/actualPdfs/standard.pdf and b/test/data/actualPdfs/standard.pdf differ diff --git a/test/index.test.ts b/test/index.test.ts index 2d40a73..ce6337c 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -6,6 +6,7 @@ import puppeteer, {type Browser, type Page} from 'puppeteer'; import DeclarativePDF from '@app/index'; import HTMLAdapter from '@app/utils/adapter-puppeteer'; import {PaperDefaults} from '@app/utils/paper-defaults'; +import * as setDocumentMetadataModule from '@app/utils/set-document-metadata'; describe('DeclarativePDF', () => { let browser: Browser; @@ -130,6 +131,27 @@ describe('DeclarativePDF', () => { expect(buffer).toBeInstanceOf(Buffer); }); + test('builds a PDF from body only with metadata', async () => { + const testDate = new Date('2025-01-01'); + const pdf = new DeclarativePDF(browser, { + document: { + meta: { + title: 'Test Title', + author: 'Test Author', + subject: 'Test Subject', + keywords: ['keyword1', 'keyword2'], + producer: 'Test Producer', + creator: 'Test Creator', + creationDate: testDate, + modificationDate: testDate, + }, + }, + }); + + const buffer = await pdf.generate('

Test

'); + expect(buffer).toBeInstanceOf(Buffer); + }); + test('throws an error if the browser is faulty', () => { const fakeBrowser = {} as unknown as Browser; const pdf = new DeclarativePDF(fakeBrowser); @@ -172,4 +194,67 @@ describe('DeclarativePDF', () => { await page2.close(); expect(buffer2).toBeInstanceOf(Buffer); }); + + test('sets document metadata when provided', async () => { + const testDate = new Date('2025-01-01'); + const pdf = new DeclarativePDF(browser, { + document: { + meta: { + title: 'Test Title', + author: 'Test Author', + subject: 'Test Subject', + keywords: ['keyword1', 'keyword2'], + producer: 'Test Producer', + creator: 'Test Creator', + creationDate: testDate, + modificationDate: testDate, + }, + }, + }); + + const setMetadataSpy = jest.spyOn(setDocumentMetadataModule, 'setDocumentMetadata'); + + const buffer = await pdf.generate(testHtml); + expect(buffer).toBeInstanceOf(Buffer); + expect(setMetadataSpy).toHaveBeenCalledWith( + expect.any(Object), // PDF document + expect.objectContaining({ + title: 'Test Title', + author: 'Test Author', + }) + ); + + setMetadataSpy.mockRestore(); + }); + + test('throws error when header/footer are too large with small bodyHeightMinimumFactor', async () => { + const largeHeaderFooterHtml = ` + + + + + + + + Huge Header + Small Body + Huge Footer + + + + `; + + const pdf = new DeclarativePDF(browser, { + document: { + bodyHeightMinimumFactor: 0.5, + }, + }); + + await expect(pdf.generate(largeHeaderFooterHtml)).rejects.toThrow(/Header\/footer too big/); + }); }); diff --git a/test/utils/layout/build-pages.test.ts b/test/utils/layout/build-pages.test.ts index 44eb7aa..cebcae9 100644 --- a/test/utils/layout/build-pages.test.ts +++ b/test/utils/layout/build-pages.test.ts @@ -4,7 +4,7 @@ import {PDFDocument} from 'pdf-lib'; import {BodyElement} from '@app/models/element'; import {buildPages} from '@app/utils/layout/build-pages'; -import {createPageLayoutSettings} from '@app/utils/layout/create-page-layout'; +import {createPageLayoutSettings} from '@app/utils/layout/create-page-layout-settings'; import type {SectionSetting} from '@app/evaluators/section-settings'; import type HTMLAdapter from '@app/utils/adapter-puppeteer'; @@ -51,6 +51,7 @@ interface MockLayoutOpts { }; height?: number; width?: number; + bodyHeightMinimumFactor?: number; pageCount?: number; } @@ -98,8 +99,11 @@ describe('buildPages', () => { backgrounds: [], ...opts?.settings, }, - opts?.height ?? 200, - opts?.width ?? 200 + { + pageHeight: opts?.height ?? 200, + pageWidth: opts?.width ?? 200, + bodyHeightMinimumFactor: opts?.bodyHeightMinimumFactor ?? 1 / 3, + } ); if (opts?.pageCount) layout.pageCount = opts.pageCount; return layout; diff --git a/test/utils/layout/calculate-page-layout.test.ts b/test/utils/layout/calculate-page-layout.test.ts index b5cbc1c..f3c2382 100644 --- a/test/utils/layout/calculate-page-layout.test.ts +++ b/test/utils/layout/calculate-page-layout.test.ts @@ -31,16 +31,24 @@ describe('getMaxHeight', () => { }); describe('calculatePageLayout', () => { + const makeLayoutOpts = (opts?: { + height?: number; + width?: number; + factor?: number; + }): {pageHeight: number; pageWidth: number; bodyHeightMinimumFactor: number} => ({ + pageHeight: opts?.height ?? 1000, + pageWidth: opts?.width ?? 800, + bodyHeightMinimumFactor: opts?.factor ?? 1 / 3, + }); + test('calculates correct layout with standard sizes', () => { const settings = { headers: [{height: 100}, {height: 200}] as SectionSetting[], footers: [{height: 50}, {height: 150}] as SectionSetting[], backgrounds: [{height: 500}] as SectionSetting[], }; - const pageHeight = 1000; - const pageWidth = 800; - const result = calculatePageLayout(settings, pageHeight, pageWidth); + const result = calculatePageLayout(settings, makeLayoutOpts()); expect(result).toEqual({ header: {width: 800, height: 200, x: 0, y: 800}, footer: {width: 800, height: 150, x: 0, y: -1}, @@ -55,15 +63,14 @@ describe('calculatePageLayout', () => { footers: [{height: 1000}] as SectionSetting[], backgrounds: [{height: 1000}] as SectionSetting[], }; - const pageHeight = 1000; - expect(() => calculatePageLayout(settings, pageHeight)).toThrow( + expect(() => calculatePageLayout(settings, makeLayoutOpts())).toThrow( `Header/footer too big. Page height: 1000px, header: 1000px, footer: 1000px, body: -998px.` ); }); test('throws error when page height is zero', () => { - const result = calculatePageLayout(); + const result = calculatePageLayout(undefined, makeLayoutOpts({height: 0, width: 0})); expect(result).toEqual({ header: {width: 0, height: 0, x: 0, y: 0}, footer: {width: 0, height: 0, x: 0, y: 0}, @@ -78,10 +85,8 @@ describe('calculatePageLayout', () => { footers: [{height: 0}] as SectionSetting[], backgrounds: [{height: 0}] as SectionSetting[], }; - const pageHeight = 1000; - const pageWidth = 800; - const result = calculatePageLayout(settings, pageHeight, pageWidth); + const result = calculatePageLayout(settings, makeLayoutOpts()); expect(result).toEqual({ header: {width: 800, height: 0, x: 0, y: 1000}, footer: {width: 800, height: 0, x: 0, y: 0}, diff --git a/test/utils/layout/create-page-layout.test.ts b/test/utils/layout/create-page-layout-settings.test.ts similarity index 83% rename from test/utils/layout/create-page-layout.test.ts rename to test/utils/layout/create-page-layout-settings.test.ts index e19be30..9bc6259 100644 --- a/test/utils/layout/create-page-layout.test.ts +++ b/test/utils/layout/create-page-layout-settings.test.ts @@ -1,7 +1,7 @@ /** * @jest-environment node */ -import {createPageLayoutSettings} from '@app/utils/layout/create-page-layout'; +import {createPageLayoutSettings} from '@app/utils/layout/create-page-layout-settings'; import type {SectionSetting, SectionSettings} from '@app/evaluators/section-settings'; describe('createPageLayoutSettings', () => { @@ -11,6 +11,16 @@ describe('createPageLayoutSettings', () => { backgrounds: [], }); + const makeLayoutOpts = (opts?: { + height?: number; + width?: number; + factor?: number; + }): {pageHeight: number; pageWidth: number; bodyHeightMinimumFactor: number} => ({ + pageHeight: opts?.height ?? 1000, + pageWidth: opts?.width ?? 500, + bodyHeightMinimumFactor: opts?.factor ?? 1 / 3, + }); + const makeSectionSetting = (props: Partial = {}): SectionSetting => ({ height: 100, hasCurrentPageNumber: false, @@ -20,7 +30,7 @@ describe('createPageLayoutSettings', () => { test('creates basic layout with empty sections', () => { const settings = makeBasicSettings(); - const result = createPageLayoutSettings(settings, 1000, 500); + const result = createPageLayoutSettings(settings, makeLayoutOpts()); expect(result).toEqual({ height: 1000, @@ -48,7 +58,7 @@ describe('createPageLayoutSettings', () => { backgrounds: [makeSectionSetting()], }; - const result = createPageLayoutSettings(settings, 1000, 500); + const result = createPageLayoutSettings(settings, makeLayoutOpts()); expect(result).toEqual({ height: 1000, @@ -90,7 +100,7 @@ describe('createPageLayoutSettings', () => { backgrounds: [], }; - const result = createPageLayoutSettings(settings, 1000, 500); + const result = createPageLayoutSettings(settings, makeLayoutOpts()); expect(result.hasPageNumbers).toBe(true); expect(result.hasAnySection).toBe(true); @@ -106,7 +116,7 @@ describe('createPageLayoutSettings', () => { backgrounds: [makeSectionSetting()], }; - const result = createPageLayoutSettings(settings, 1000, 500); + const result = createPageLayoutSettings(settings, makeLayoutOpts()); expect(result.header?.transparentBg).toBe(true); expect(result.footer?.transparentBg).toBe(true); @@ -120,7 +130,7 @@ describe('createPageLayoutSettings', () => { backgrounds: [makeSectionSetting({height: 1000})], }; - const result = createPageLayoutSettings(settings, 1000, 500); + const result = createPageLayoutSettings(settings, makeLayoutOpts()); expect(result.header?.height).toBe(200); expect(result.header?.y).toBe(800); // 1000 - 200 @@ -141,7 +151,7 @@ describe('createPageLayoutSettings', () => { backgrounds: [backgroundSetting], }; - const result = createPageLayoutSettings(settings, 1000, 500); + const result = createPageLayoutSettings(settings, makeLayoutOpts()); expect(result.header?.settings).toEqual([headerSetting]); expect(result.footer?.settings).toEqual([footerSetting]); @@ -149,7 +159,7 @@ describe('createPageLayoutSettings', () => { }); test('creates zero layout when no settings are provided', () => { - const result = createPageLayoutSettings(); + const result = createPageLayoutSettings(undefined, makeLayoutOpts({height: 0, width: 0})); expect(result).toEqual({ height: 0, diff --git a/test/utils/set-document-metadata.test.ts b/test/utils/set-document-metadata.test.ts new file mode 100644 index 0000000..ac52ed2 --- /dev/null +++ b/test/utils/set-document-metadata.test.ts @@ -0,0 +1,198 @@ +import {setDocumentMetadata} from '@app/utils/set-document-metadata'; +import type {DocumentMeta} from '@app/index'; +import type {PDFDocument} from 'pdf-lib'; + +describe('handle-pdf-metadata', () => { + let mockPdf: jest.Mocked; + + beforeEach(() => { + mockPdf = { + setTitle: jest.fn(), + setAuthor: jest.fn(), + setSubject: jest.fn(), + setKeywords: jest.fn(), + setProducer: jest.fn(), + setCreator: jest.fn(), + setCreationDate: jest.fn(), + setModificationDate: jest.fn(), + } as unknown as jest.Mocked; + }); + + test('should set all metadata fields when provided', () => { + const testDate = new Date('2025-01-01'); + const meta: DocumentMeta = { + title: 'Test Title', + author: 'Test Author', + subject: 'Test Subject', + keywords: ['keyword1', 'keyword2'], + producer: 'Test Producer', + creator: 'Test Creator', + creationDate: testDate, + modificationDate: testDate, + }; + + setDocumentMetadata(mockPdf, meta); + + expect(mockPdf.setTitle).toHaveBeenCalledWith('Test Title'); + expect(mockPdf.setAuthor).toHaveBeenCalledWith('Test Author'); + expect(mockPdf.setSubject).toHaveBeenCalledWith('Test Subject'); + expect(mockPdf.setKeywords).toHaveBeenCalledWith(['keyword1', 'keyword2']); + expect(mockPdf.setProducer).toHaveBeenCalledWith('Test Producer'); + expect(mockPdf.setCreator).toHaveBeenCalledWith('Test Creator'); + expect(mockPdf.setCreationDate).toHaveBeenCalledWith(testDate); + expect(mockPdf.setModificationDate).toHaveBeenCalledWith(testDate); + }); + + test('should set only provided metadata fields', () => { + const partialMeta: Partial = { + title: 'Test Title', + author: 'Test Author', + }; + + setDocumentMetadata(mockPdf, partialMeta); + + expect(mockPdf.setTitle).toHaveBeenCalledWith('Test Title'); + expect(mockPdf.setAuthor).toHaveBeenCalledWith('Test Author'); + expect(mockPdf.setSubject).not.toHaveBeenCalled(); + expect(mockPdf.setKeywords).not.toHaveBeenCalled(); + expect(mockPdf.setProducer).not.toHaveBeenCalled(); + expect(mockPdf.setCreator).not.toHaveBeenCalled(); + expect(mockPdf.setCreationDate).not.toHaveBeenCalled(); + expect(mockPdf.setModificationDate).not.toHaveBeenCalled(); + }); + + test('should not call any setters when meta is undefined', () => { + setDocumentMetadata(mockPdf, undefined); + + expect(mockPdf.setTitle).not.toHaveBeenCalled(); + expect(mockPdf.setAuthor).not.toHaveBeenCalled(); + expect(mockPdf.setSubject).not.toHaveBeenCalled(); + expect(mockPdf.setKeywords).not.toHaveBeenCalled(); + expect(mockPdf.setProducer).not.toHaveBeenCalled(); + expect(mockPdf.setCreator).not.toHaveBeenCalled(); + expect(mockPdf.setCreationDate).not.toHaveBeenCalled(); + expect(mockPdf.setModificationDate).not.toHaveBeenCalled(); + }); + + test('should not call any setters when meta is empty object', () => { + setDocumentMetadata(mockPdf, {}); + + expect(mockPdf.setTitle).not.toHaveBeenCalled(); + expect(mockPdf.setAuthor).not.toHaveBeenCalled(); + expect(mockPdf.setSubject).not.toHaveBeenCalled(); + expect(mockPdf.setKeywords).not.toHaveBeenCalled(); + expect(mockPdf.setProducer).not.toHaveBeenCalled(); + expect(mockPdf.setCreator).not.toHaveBeenCalled(); + expect(mockPdf.setCreationDate).not.toHaveBeenCalled(); + expect(mockPdf.setModificationDate).not.toHaveBeenCalled(); + }); + + test('should ignore undefined values', () => { + const metaWithUndefined: Partial = { + title: 'Test Title', + author: undefined as unknown as string, + }; + + setDocumentMetadata(mockPdf, metaWithUndefined); + + expect(mockPdf.setTitle).toHaveBeenCalledWith('Test Title'); + expect(mockPdf.setAuthor).not.toHaveBeenCalled(); + expect(mockPdf.setSubject).not.toHaveBeenCalled(); + expect(mockPdf.setKeywords).not.toHaveBeenCalled(); + expect(mockPdf.setProducer).not.toHaveBeenCalled(); + expect(mockPdf.setCreator).not.toHaveBeenCalled(); + expect(mockPdf.setCreationDate).not.toHaveBeenCalled(); + expect(mockPdf.setModificationDate).not.toHaveBeenCalled(); + }); + + test('should handle errors from PDF setters gracefully', () => { + mockPdf.setTitle.mockImplementation(() => { + throw new Error('PDF setter error'); + }); + + const meta: Partial = { + title: 'Test Title', + author: 'Test Author', + }; + + expect(() => { + setDocumentMetadata(mockPdf, meta); + }).toThrow('PDF setter error'); + + expect(mockPdf.setAuthor).not.toHaveBeenCalled(); + expect(mockPdf.setAuthor).not.toHaveBeenCalled(); + expect(mockPdf.setSubject).not.toHaveBeenCalled(); + expect(mockPdf.setKeywords).not.toHaveBeenCalled(); + expect(mockPdf.setProducer).not.toHaveBeenCalled(); + expect(mockPdf.setCreator).not.toHaveBeenCalled(); + expect(mockPdf.setCreationDate).not.toHaveBeenCalled(); + expect(mockPdf.setModificationDate).not.toHaveBeenCalled(); + }); + + test('should handle empty strings as valid values', () => { + const emptyStringMeta: Partial = { + title: '', + author: '', + subject: '', + }; + + setDocumentMetadata(mockPdf, emptyStringMeta); + + expect(mockPdf.setTitle).toHaveBeenCalledWith(''); + expect(mockPdf.setAuthor).toHaveBeenCalledWith(''); + expect(mockPdf.setSubject).toHaveBeenCalledWith(''); + }); + + test('should handle null values like undefined values', () => { + const nullMeta: Partial = { + title: 'Test Title', + author: null as unknown as string, + }; + + setDocumentMetadata(mockPdf, nullMeta); + + expect(mockPdf.setTitle).toHaveBeenCalledWith('Test Title'); + expect(mockPdf.setAuthor).not.toHaveBeenCalled(); + }); + + test('should handle empty keywords array', () => { + const meta: Partial = { + keywords: [], + }; + + setDocumentMetadata(mockPdf, meta); + + expect(mockPdf.setKeywords).not.toHaveBeenCalled(); + }); + + test('should handle invalid Date objects gracefully', () => { + const invalidDateMeta: Partial = { + title: 'Test Title', + creationDate: new Date('invalid-date'), + }; + + setDocumentMetadata(mockPdf, invalidDateMeta); + + expect(mockPdf.setTitle).toHaveBeenCalledWith('Test Title'); + expect(mockPdf.setCreationDate).not.toHaveBeenCalled(); + }); + + test('should ignore properties not in DocumentMeta interface', () => { + const extraPropsMeta = { + title: 'Test Title', + extraProperty: 'Should be ignored', + anotherExtra: 123, + }; + + setDocumentMetadata(mockPdf, extraPropsMeta); + + expect(mockPdf.setTitle).toHaveBeenCalledWith('Test Title'); + expect(mockPdf.setAuthor).not.toHaveBeenCalled(); + expect(mockPdf.setSubject).not.toHaveBeenCalled(); + expect(mockPdf.setKeywords).not.toHaveBeenCalled(); + expect(mockPdf.setProducer).not.toHaveBeenCalled(); + expect(mockPdf.setCreator).not.toHaveBeenCalled(); + expect(mockPdf.setCreationDate).not.toHaveBeenCalled(); + expect(mockPdf.setModificationDate).not.toHaveBeenCalled(); + }); +});