From ea77557da95021c8fa6dd388b43b220c49f99c9b Mon Sep 17 00:00:00 2001 From: Jake Holland Date: Wed, 8 Jan 2025 18:10:47 +0000 Subject: [PATCH] Add support for dynamic sizing - Enable defining sizes using any units (defaulting to Points) - This also allows us to define sizes based on the current font context i.e. em's - The new public `sizeToPoint` method allows users to also interact with these sizes to generate the correct point sizes --- CHANGELOG.md | 1 + docs/getting_started.md | 22 +++++--- lib/mixins/fonts.js | 96 +++++++++++++++++++++++++++++++-- lib/page.js | 32 +++++------ lib/utils.js | 102 ++++++++++++++++++++++++++++++++++++ tests/unit/document.spec.js | 4 +- tests/unit/font.spec.js | 43 ++++++++++++++- tests/unit/utils.spec.js | 33 ++++++++++++ 8 files changed, 305 insertions(+), 28 deletions(-) create mode 100644 tests/unit/utils.spec.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 3454cf26..9ff98e05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### Unreleased - Fix precision rounding issues in LineWrapper +- Add support for dynamic sizing ### [v0.16.0] - 2024-12-29 diff --git a/docs/getting_started.md b/docs/getting_started.md index f4da4e9d..5538b9b6 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -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({ diff --git a/lib/mixins/fonts.js b/lib/mixins/fonts.js index 3a6e85fc..314a8645 100644 --- a/lib/mixins/fonts.js +++ b/lib/mixins/fonts.js @@ -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 @@ -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); } }, @@ -50,6 +62,8 @@ export default { } } + this._fontSource = src; + this._fontFamily = family; if (size != null) { this.fontSize(size); } @@ -84,7 +98,7 @@ export default { }, fontSize(_fontSize) { - this._fontSize = _fontSize; + this._fontSize = this.sizeToPoint(_fontSize); return this; }, @@ -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); + + 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]); } }; diff --git a/lib/page.js b/lib/page.js index d3f1837a..dcea632d 100644 --- a/lib/page.js +++ b/lib/page.js @@ -3,11 +3,16 @@ PDFPage - represents a single page in the PDF document By Devon Govett */ +import { normalizeSides } from './utils'; + +/** + * @type {SideDefinition} + */ const DEFAULT_MARGINS = { top: 72, left: 72, bottom: 72, - right: 72 + right: 72, }; const SIZES = { @@ -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 @@ -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'] diff --git a/lib/utils.js b/lib/utils.js index c58274ad..8883c394 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -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 + * - 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 + **/ + +/** + * @template T + * @typedef {{ top: T; right: T; bottom: T; left: T }} ExpandedSideDefinition + */ + +/** + * Convert any side definition into a static structure + * + * @template S + * @template D + * @template O + * @template {S | D} T + * @param {SideDefinition} sides - The sides to convert + * @param {SideDefinition} 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} + */ +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 + ) + ) { + 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 diff --git a/tests/unit/document.spec.js b/tests/unit/document.spec.js index f748de1c..dcded055 100644 --- a/tests/unit/document.spec.js +++ b/tests/unit/document.spec.js @@ -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', () => { diff --git a/tests/unit/font.spec.js b/tests/unit/font.spec.js index 33e19d6a..8dfb6258 100644 --- a/tests/unit/font.spec.js +++ b/tests/unit/font.spec.js @@ -54,7 +54,7 @@ describe('EmbeddedFont', () => { }); }); - describe.only('toUnicodeMap', () => { + describe('toUnicodeMap', () => { test('bfrange lines should not cross highcode boundary', () => { const doc = new PDFDocument({ compress: false }); const font = PDFFontFactory.open( @@ -96,3 +96,44 @@ describe('EmbeddedFont', () => { }); }); }); + +describe('sizeToPoint', () => { + let doc; + beforeEach(() => { + doc = new PDFDocument({ + font: 'Helvetica', + fontSize: 12, + size: [250, 500], + margin: { top: 10, right: 5, bottom: 10, left: 5 }, + }); + }); + + test.each([ + [1, 1], + ['1', 1], + [true, 1], + [false, 0], + ['1em', 12], + ['1in', 72], + ['1px', 0.75], + ['1cm', 28.3465], + ['1mm', 2.8346], + ['1pc', 12], + ['1ex', 11.1], + ['1ch', 6.672], + ['1vw', 2.5], + ['1vh', 5], + ['1vmin', 2.5], + ['1vmax', 5], + ['1%', 0.12], + ['1pt', 1], + ])('%o -> %s', (size, expected) => { + expect(doc.sizeToPoint(size)).toBeCloseTo(expected, 4); + }); + + test('1rem -> 12', () => { + doc.fontSize(15); + expect(doc.sizeToPoint('1em')).toEqual(15); + expect(doc.sizeToPoint('1rem')).toEqual(12); + }); +}); diff --git a/tests/unit/utils.spec.js b/tests/unit/utils.spec.js new file mode 100644 index 00000000..9e1abb8f --- /dev/null +++ b/tests/unit/utils.spec.js @@ -0,0 +1,33 @@ +import {normalizeSides} from '../../lib/utils'; + +describe('normalizeSides', () => { + test.each([ + [1, { top: 1, right: 1, bottom: 1, left: 1 }], + [[1, 2], { top: 1, right: 2, bottom: 1, left: 2 }], + [ + { vertical: 1, horizontal: 2 }, + { top: 1, right: 2, bottom: 1, left: 2 }, + ], + [[1, 2, 3, 4], { top: 1, right: 2, bottom: 3, left: 4 }], + [ + { top: 1, right: 2, bottom: 3, left: 4 }, + { top: 1, right: 2, bottom: 3, left: 4 }, + ], + [{ a: 'hi' }, { top: { a: 'hi' }, right: { a: 'hi' }, bottom: { a: 'hi' }, left: { a: 'hi' } }], + [ + { vertical: 'hi' }, + { top: 'hi', right: undefined, bottom: 'hi', left: undefined }, + ], + ])('%s -> %s', (size, expected) => { + expect(normalizeSides(size)).toEqual(expected); + }); + + test('with transformer', () => { + expect(normalizeSides(undefined, { top: '1', right: '2', bottom: '3', left: '4' }, Number)).toEqual({ + top: 1, + right: 2, + bottom: 3, + left: 4, + }); + }); +});