Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions coverage/coverage-summary.json
Original file line number Diff line number Diff line change
@@ -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}}
}
27 changes: 27 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?: {
Expand Down Expand Up @@ -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;
}
```

Expand Down Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -31,20 +32,46 @@ 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<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 {
/** Should we normalize the content (remove excess elements, set some defaults...) */
normalize?: NormalizeOptions;
/** Override for paper defaults (A4 / 72ppi) */
defaults?: PaperOpts;
/** Debug options (attaches parts, logs timings) */
debug?: DebugOptions;
/** Override for pdf document metadata and rules */
document?: DocumentOptions;
}

export default class DeclarativePDF {
declare html: HTMLAdapter;
declare defaults: PaperDefaults;
declare normalize?: NormalizeOptions;
declare debug: DebugOptions;
declare documentOptions?: DocumentOptions;

documentPages: DocumentPage[] = [];

Expand All @@ -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() {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -248,6 +283,9 @@ export default class DeclarativePDF {
});
}

const meta = this.documentOptions?.meta;
if (meta) setDocumentMetadata(outputPDF, meta);

return outputPDF;
}
}
12 changes: 9 additions & 3 deletions src/models/document-page.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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});

Expand Down
2 changes: 1 addition & 1 deletion src/utils/layout/build-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
14 changes: 10 additions & 4 deletions src/utils/layout/calculate-page-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.`
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,21 +28,26 @@ 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 ?? {};

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,
Expand Down
35 changes: 35 additions & 0 deletions src/utils/set-document-metadata.ts
Original file line number Diff line number Diff line change
@@ -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<T extends object>(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<DocumentMeta>): 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);
}
}
}
Binary file modified test/data/actualPdfs/a4-72-multipage.pdf
Binary file not shown.
Binary file modified test/data/actualPdfs/a4-72-standard.pdf
Binary file not shown.
Binary file modified test/data/actualPdfs/elegant.pdf
Binary file not shown.
Binary file modified test/data/actualPdfs/standard.pdf
Binary file not shown.
85 changes: 85 additions & 0 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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('<html><body><h1>Test</h1></body></html>');
expect(buffer).toBeInstanceOf(Buffer);
});

test('throws an error if the browser is faulty', () => {
const fakeBrowser = {} as unknown as Browser;
const pdf = new DeclarativePDF(fakeBrowser);
Expand Down Expand Up @@ -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 = `
<!DOCTYPE html>
<html>
<head>
<style>
page-header, page-footer {
display: block;
height: 600px;
}
</style>
</head>
<body>
<document-page format="a4">
<page-header>Huge Header</page-header>
<page-body>Small Body</page-body>
<page-footer>Huge Footer</page-footer>
</document-page>
</body>
</html>
`;

const pdf = new DeclarativePDF(browser, {
document: {
bodyHeightMinimumFactor: 0.5,
},
});

await expect(pdf.generate(largeHeaderFooterHtml)).rejects.toThrow(/Header\/footer too big/);
});
});
Loading