Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for dynamic sizing #1576

Merged
merged 3 commits into from
Jan 12, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
### Unreleased

- Fix precision rounding issues in LineWrapper
- Add support for dynamic sizing

### [v0.16.0] - 2024-12-29

Expand Down
22 changes: 16 additions & 6 deletions docs/getting_started.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,18 +101,28 @@ Passing a page options object to the `PDFDocument` constructor will
set the default paper size and layout for every page in the document, which is
then overridden by individual options passed to the `addPage` method.

You can set the page margins in two ways. The first is by setting the `margin`
property (singular) to a number, which applies that margin to all edges. The
other way is to set the `margins` property (plural) to an object with `top`,
`bottom`, `left`, and `right` values. The default is a 1 inch (72 point) margin
You can set the page margins in two ways. The first is by setting the `margin` / `margins`
property to a single value, which applies that to all edges. The
other way is to provide an object with `top`, `right`, `bottom`, and `left` values.
By default, using a number this will be in points (the default PDF unit),
however you can provide any of the following units inside a string
and this will be converted for you:
`em`, `in`, `px`, `cm`, `mm`, `pc`, `ex`, `ch`, `rem`, `vw`, `vmin`, `vmax`, `%`, `pt`.
For those which are based on text sizes this will take the size of the font for the page
(excluding `rem` which is always the document root font size)
The default is a 1 inch (72 point) margin
on all sides.

For example:

// Add a 50 point margin on all sides
doc.addPage({
margin: 50});
doc.addPage({ margin: 50 });

// Add a 2 inch margin on all sides
doc.addPage({ margin: '2in' });

// Add a 2em(28pt) margin using the font size
doc.addPage({ fontSize: 14, margin: '2em' });

// Add different margins on each side
doc.addPage({
Expand Down
96 changes: 92 additions & 4 deletions lib/mixins/fonts.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import PDFFontFactory from '../font_factory';
import { CM_TO_IN, IN_TO_PT, MM_TO_CM, PC_TO_PT, PX_TO_IN } from '../utils';

const isEqualFont = (font1, font2) => {
// compare font checksum
Expand All @@ -15,20 +16,31 @@ const isEqualFont = (font1, font2) => {
}

export default {
initFonts(defaultFont = 'Helvetica') {
initFonts(
defaultFont = 'Helvetica',
defaultFontFamily = null,
defaultFontSize = 12
) {
// Lookup table for embedded fonts
this._fontFamilies = {};
this._fontCount = 0;

// Font state
this._fontSize = 12;
// Useful to export the font builder so that someone can create a snapshot of the current state
// (e.g. Reverting back to the previous font)
this._fontSource = defaultFont;
this._fontFamily = defaultFontFamily;
this._fontSize = defaultFontSize;
this._font = null;

// rem size is fixed per document as the document is the root element
this._remSize = defaultFontSize;

this._registeredFonts = {};

// Set the default font
if (defaultFont) {
this.font(defaultFont);
this.font(defaultFont, defaultFontFamily);
}
},

Expand All @@ -50,6 +62,8 @@ export default {
}
}

this._fontSource = src;
this._fontFamily = family;
if (size != null) {
this.fontSize(size);
}
Expand Down Expand Up @@ -84,7 +98,7 @@ export default {
},

fontSize(_fontSize) {
this._fontSize = _fontSize;
this._fontSize = this.sizeToPoint(_fontSize);
return this;
},

Expand All @@ -102,5 +116,79 @@ export default {
};

return this;
},

/**
* Convert a {@link Size} into a point measurement
*
* @param {Size | boolean | undefined} size - The size to convert
* @param {Size | boolean | undefined} defaultValue - The default value when undefined
* @param {PDFPage} page - The page used for computing font sizes
* @param {number} [percentageWidth] - The value to use for computing size based on `%`
*
* @returns number
*/
sizeToPoint(size, defaultValue = 0, page = this.page, percentageWidth = undefined) {
if (!percentageWidth) percentageWidth = this._fontSize;
if (typeof defaultValue !== 'number')
defaultValue = this.sizeToPoint(defaultValue);
if (size === undefined) return defaultValue;
if (typeof size === 'number') return size;
if (typeof size === 'boolean') return Number(size);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What situation a boolean value makes sense?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its mainly for a falsy value of saying margin: false to indicate that you don't want a margin to render at all

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

true would return 1 -> does not make sense

I am not sure how useful would be.

margin: 0 seems enough to not have a margin

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we are totally happy with not having margin: false i'm happy removing it, I only added it so devs could make it explicitly clear they don't want a margin, rather than confusingly saying they want a margin of width 0


const match = String(size).match(
/((\d+)?(\.\d+)?)(em|in|px|cm|mm|pc|ex|ch|rem|vw|vh|vmin|vmax|%|pt)?/
);
if (!match) throw new Error(`Unsupported size '${size}'`);
let multiplier;
switch (match[4]) {
case 'em':
multiplier = this._fontSize;
break;
case 'in':
multiplier = IN_TO_PT;
break;
case 'px':
multiplier = PX_TO_IN * IN_TO_PT;
break;
case 'cm':
multiplier = CM_TO_IN * IN_TO_PT;
break;
case 'mm':
multiplier = MM_TO_CM * CM_TO_IN * IN_TO_PT;
break;
case 'pc':
multiplier = PC_TO_PT;
break;
case 'ex':
multiplier = this.currentLineHeight();
break;
case 'ch':
multiplier = this.widthOfString('0');
break;
case 'rem':
multiplier = this._remSize;
break;
case 'vw':
multiplier = page.width / 100;
break;
case 'vh':
multiplier = page.height / 100;
break;
case 'vmin':
multiplier = Math.min(page.width, page.height) / 100;
break;
case 'vmax':
multiplier = Math.max(page.width, page.height) / 100;
break;
case '%':
multiplier = percentageWidth / 100;
break;
case 'pt':
default:
multiplier = 1;
}

return multiplier * Number(match[1]);
}
};
32 changes: 17 additions & 15 deletions lib/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@ PDFPage - represents a single page in the PDF document
By Devon Govett
*/

import { normalizeSides } from './utils';

/**
* @type {SideDefinition<Size>}
*/
const DEFAULT_MARGINS = {
top: 72,
left: 72,
bottom: 72,
right: 72
right: 72,
};

const SIZES = {
Expand Down Expand Up @@ -69,20 +74,6 @@ class PDFPage {
this.size = options.size || 'letter';
this.layout = options.layout || 'portrait';

// process margins
if (typeof options.margin === 'number') {
this.margins = {
top: options.margin,
left: options.margin,
bottom: options.margin,
right: options.margin
};

// default to 1 inch margins
} else {
this.margins = options.margins || DEFAULT_MARGINS;
}

// calculate page dimensions
const dimensions = Array.isArray(this.size)
? this.size
Expand All @@ -92,6 +83,17 @@ class PDFPage {

this.content = this.document.ref();

if (options.font) document.font(options.font, options.fontFamily);
if (options.fontSize) document.fontSize(options.fontSize);

// process margins
// Margin calculation must occur after font assignment to ensure any dynamic sizes are calculated correctly
this.margins = normalizeSides(
options.margin ?? options.margins,
DEFAULT_MARGINS,
x => document.sizeToPoint(x, 0, this)
)

// Initialize the Font, XObject, and ExtGState dictionaries
this.resources = this.document.ref({
ProcSet: ['PDF', 'Text', 'ImageB', 'ImageC', 'ImageI']
Expand Down
102 changes: 102 additions & 0 deletions lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,105 @@ export function PDFNumber(n) {
// @see ISO 32000-1 Annex C.2 (real numbers)
return Math.fround(n);
}

/**
* Measurement of size
*
* @typedef {number | `${number}` | `${number}${'em' | 'in' | 'px' | 'cm' | 'mm' | 'pc' | 'ex' | 'ch' | 'rem' | 'vw' | 'vmin' | 'vmax' | '%' | 'pt'}`} Size
*/

/**
* Measurement of how wide something is, false means 0 and true means 1
*
* @typedef {Size | boolean} Wideness
*/

/**
* Side definitions
* - To define all sides, use a single value
* - To define up-down left-right, use a `[Y, X]` array
* - To define each side, use `[top, right, bottom, left]` array
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is different how pdfmake defines: https://pdfmake.github.io/docs/0.1/document-definition-object/margins/

It can cause confusion

To be fair, the way you defined is compatible with the CSS order: /* top | right | bottom | left */ -> https://developer.mozilla.org/en-US/docs/Web/CSS/margin

@liborm85

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My goal was always to make it more like CSS than pdfmake. Im not quite sure the reason why pdfmake didnt do the same

Copy link
Collaborator

@liborm85 liborm85 Jan 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know why the parameters are defined in this order in pdfmake, but it would be better to use the CSS order in pdfkit.

* - Or `{vertical: SideValue, horizontal: SideValue}`
* - Or `{top: SideValue, right: SideValue, bottom: SideValue, left: SideValue}`
*
* @template T
* @typedef {T| [T, T]| [T, T, T, T]| { vertical: T; horizontal: T }| { top: T; right: T; bottom: T; left: T }} SideDefinition<T>
**/

/**
* @template T
* @typedef {{ top: T; right: T; bottom: T; left: T }} ExpandedSideDefinition<T>
*/

/**
* Convert any side definition into a static structure
*
* @template S
* @template D
* @template O
* @template {S | D} T
* @param {SideDefinition<S>} sides - The sides to convert
* @param {SideDefinition<D>} defaultDefinition - The value to use when no definition is provided
* @param {function(T): O} transformer - The transformation to apply to the sides once normalized
* @returns {ExpandedSideDefinition<O>}
*/
export function normalizeSides(
sides,
defaultDefinition = undefined,
transformer = (v) => v,
) {
if (
sides === undefined ||
(typeof sides === "object" && Object.keys(sides).length === 0)
) {
sides = defaultDefinition;
}
if (typeof sides !== "object" || sides === null || sides === undefined) {
sides = [sides, sides, sides, sides];
}
if (Array.isArray(sides)) {
if (sides.length === 2) {
sides = { vertical: sides[0], horizontal: sides[1] };
} else {
sides = {
top: sides[0],
right: sides[1],
bottom: sides[2],
left: sides[3],
};
}
}

if ("vertical" in sides || "horizontal" in sides) {
sides = {
top: sides.vertical,
right: sides.horizontal,
bottom: sides.vertical,
left: sides.horizontal,
};
}

if (
!(
"top" in sides ||
"right" in sides ||
"bottom" in sides ||
"left" in sides
)
Copy link
Member

@blikblum blikblum Jan 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe {top: 1, right: 1} should get invalid value: {top: 1, right: 1, left: {top: 1, right: 1}, bottom: {top: 1, right: 1}}

Or do i miss something?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would remove this check at all assuming is undefined is treated as 0. If i do {top: 1, right: 1} i expect left and bottom be treated as 0

Copy link
Contributor Author

@hollandjake hollandjake Jan 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the idea is to allow partials, so if anyone provides at least one of the keys it will be used and the others set to undefined, however if none of the keys match the object itself will be treated as the value for all keys.

e.g. {top: 1} would produce {top: 1, right: undefined, bottom: undefined, left: undefined}

but {hello: "world"} would produce {top: {hello: "world"}, right: {hello: "world"}, bottom: {hello: "world"}, left: {hello: "world"}}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other option we could take is to just say if its an object and you didnt provide the keys then it'll be undefined

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thinking about it now, it really doesnt make any sense to provide this, its the users fault

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@blikblum please see new changes which removes this logic

) {
sides = { top: sides, right: sides, bottom: sides, left: sides };
}

return {
top: transformer(sides.top),
right: transformer(sides.right),
bottom: transformer(sides.bottom),
left: transformer(sides.left),
};
}

export const MM_TO_CM = 1 / 10; // 1MM = 1CM
export const CM_TO_IN = 1 / 2.54; // 1CM = 1/2.54 IN
export const PX_TO_IN = 1 / 96; // 1 PX = 1/96 IN
export const IN_TO_PT = 72; // 1 IN = 72 PT
export const PC_TO_PT = 12; // 1 PC = 12 PT
4 changes: 2 additions & 2 deletions tests/unit/document.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ describe('PDFDocument', () => {
test('not defined', () => {
new PDFDocument();

expect(fontSpy).toBeCalledWith('Helvetica');
expect(fontSpy).toBeCalledWith('Helvetica', null);
});

test('a string value', () => {
new PDFDocument({ font: 'Roboto' });

expect(fontSpy).toBeCalledWith('Roboto');
expect(fontSpy).toBeCalledWith('Roboto', null);
});

test('a falsy value', () => {
Expand Down
Loading
Loading