printmaker
Version:
Generate PDF documents and from JavaScript objects
293 lines (271 loc) • 9.82 kB
text/typescript
import { PDFFont } from 'pdf-lib';
import { Box, parseEdges, parseLength, Pos, Size, subtractEdges, ZERO_EDGES } from './box.js';
import { Color } from './colors.js';
import { Alignment } from './content.js';
import { Document } from './document.js';
import { Font } from './fonts.js';
import { GraphicsObject, shiftGraphicsObject } from './graphics.js';
import { layoutColumns } from './layout-columns.js';
import { layoutImage } from './layout-image.js';
import { layoutRows } from './layout-rows.js';
import { Page } from './page.js';
import {
Block,
Columns,
ImageBlock,
Paragraph,
parseBlock,
parseContent,
parseInheritableAttrs,
Rows,
} from './text.js';
import { breakLine, extractTextSegments, flattenTextSegments, TextSegment } from './text.js';
import { asArray, asObject, getFrom, Obj, optional, pickDefined, required } from './types.js';
const pageSize = { width: parseLength('210mm'), height: parseLength('297mm') }; // A4, portrait
const defaultPageMargin = parseEdges('2cm');
/**
* Frames are created during the layout process. They have a position relative to their parent,
* a size, and drawable objects to be rendered.
* Frames can contain children, e.g. for rows within a paragraph or in a column.
*/
export type Frame = {
x: number;
y: number;
width: number;
height: number;
type?: string;
objects?: DrawableObject[];
children?: Frame[];
};
export type DrawableObject = TextObject | AnchorObject | LinkObject | GraphicsObject;
export type TextObject = {
type: 'text';
x: number;
y: number;
text: string;
font: PDFFont;
fontSize: number;
color?: Color;
};
export type LinkObject = {
type: 'link';
x: number;
y: number;
width: number;
height: number;
url: string;
};
export type AnchorObject = {
type: 'anchor';
name: string;
x: number;
y: number;
};
export function layoutPages(def: Obj, doc: Document): Page[] {
const content = getFrom(def, 'content', required(asArray));
const pageMargin = getFrom(def, 'margin', optional(parseEdges)) ?? defaultPageMargin;
const defaultStyle = getFrom(def, 'defaultStyle', optional(parseInheritableAttrs));
const guides = getFrom(def, 'dev', optional(asObject))?.guides;
const contentBox = subtractEdges({ x: 0, y: 0, ...pageSize }, pageMargin);
const blocks = parseContent(content, defaultStyle);
const pages = [];
let remainingBlocks = blocks;
while (remainingBlocks?.length) {
const { frame, remainder } = layoutPageContent(remainingBlocks, contentBox, doc);
remainingBlocks = remainder;
pages.push({ size: pageSize, content: frame, guides });
}
pages.map((page, idx) => {
const pageInfo = { pageCount: pages.length, pageNumber: idx + 1, pageSize };
const parse = (block) => parseBlock(asObject(resolveFn(block, pageInfo)), defaultStyle);
const header = getFrom(def, 'header', optional(parse));
const footer = getFrom(def, 'footer', optional(parse));
page.header = header && layoutHeader(header, doc);
page.footer = header && layoutFooter(footer, doc);
});
return pages.map(pickDefined) as Page[];
}
function resolveFn(value, ...args) {
if (typeof value !== 'function') return value;
try {
return value(...args);
} catch (error) {
throw new Error(`Function threw: ${error}`);
}
}
function layoutHeader(header: Block, doc: Document) {
const box = subtractEdges({ x: 0, y: 0, ...pageSize }, header.margin);
return layoutBlock(header, box, doc);
}
function layoutFooter(footer: Block, doc: Document) {
const box = subtractEdges({ x: 0, y: 0, ...pageSize }, footer.margin);
const frame = layoutBlock(footer, box, doc);
frame.y = pageSize.height - frame.height - footer.margin?.bottom ?? 0;
return frame;
}
export function layoutPageContent(blocks: Block[], box: Box, doc: Document) {
const { x, y, width, height } = box;
const children = [];
const pos = { x: 0, y: 0 };
let lastMargin = 0;
let remainingHeight = height;
let remainder;
for (const [idx, block] of blocks.entries()) {
const margin = block.margin ?? ZERO_EDGES;
const topMargin = Math.max(lastMargin, margin.top);
lastMargin = margin.bottom;
const nextPos = { x: pos.x + margin.left, y: pos.y + topMargin };
const maxSize = { width: width - margin.left - margin.right, height: remainingHeight };
const frame = layoutBlock(block, { ...nextPos, ...maxSize }, doc);
// If the first paragraph does not fit on the page, render it anyway.
// It wouldn't fit on the next page as well, ending in an endless loop.
if (remainingHeight < topMargin + frame.height && idx) {
remainder = blocks.slice(idx);
break;
}
children.push(frame);
pos.y += topMargin + frame.height;
remainingHeight = height - pos.y;
}
return { frame: { type: 'page', x, y, width, height, children }, remainder };
}
export function layoutBlock(block: Block, box: Box, doc: Document): Frame {
if ((block as Columns).columns) {
return layoutColumns(block as Columns, box, doc);
}
if ((block as Rows).rows) {
return layoutRows(block as Rows, box, doc);
}
if ((block as ImageBlock).image) {
return layoutImage(block as ImageBlock, box, doc);
}
return layoutParagraph(block as ImageBlock, box, doc);
}
export function layoutParagraph(paragraph: Paragraph, box: Box, doc: Document): Frame {
const padding = paragraph.padding ?? ZERO_EDGES;
const fixedWidth = paragraph.width;
const fixedHeight = paragraph.height;
const maxWidth = (fixedWidth ?? box.width) - padding.left - padding.right;
const maxHeight = (fixedHeight ?? box.height) - padding.top - padding.bottom;
const innerBox = { x: padding.left, y: padding.top, width: maxWidth, height: maxHeight };
const text = paragraph.text && layoutText(paragraph, innerBox, doc.fonts);
const graphics = paragraph.graphics && layoutGraphics(paragraph.graphics, innerBox);
const contentHeight = text?.size?.height ?? 0;
const objects = [
...(graphics ?? []),
...(paragraph.id ? [createAnchorObject(paragraph.id)] : []),
];
return {
type: 'paragraph',
...box,
width: fixedWidth ?? box.width,
height: fixedHeight ?? (contentHeight ?? 0) + padding.top + padding.bottom,
...(text?.rows?.length ? { children: text.rows } : undefined),
...(objects.length ? { objects } : undefined),
};
}
export function createAnchorObject(name: string, pos?: Pos): AnchorObject {
return {
type: 'anchor',
name,
x: pos?.x ?? 0,
y: pos?.y ?? 0,
};
}
function layoutText(paragraph: Paragraph, box: Box, fonts: Font[]) {
const { text, textAlign } = paragraph;
const textSpans = text;
const segments = extractTextSegments(textSpans, fonts);
const rows = [];
let remainingSegments = segments;
const remainingSpace = { ...box };
const size: Size = { width: 0, height: 0 };
while (remainingSegments?.length) {
const { row, remainder } = layoutTextRow(remainingSegments, remainingSpace, textAlign);
rows.push(row);
remainingSegments = remainder;
remainingSpace.height -= row.height;
remainingSpace.y += row.height;
size.width = Math.max(size.width, row.width);
size.height += row.height;
}
return { rows, size };
}
function layoutTextRow(segments: TextSegment[], box: Box, textAlign: Alignment) {
const [lineSegments, remainder] = breakLine(segments, box.width);
const pos = { x: 0, y: 0 };
const size = { width: 0, height: 0 };
let maxLineHeight = 0;
let maxDescent = 0;
const links = [];
const objects = [];
flattenTextSegments(lineSegments).forEach((seg) => {
const { text, width, height, lineHeight, font, fontSize, link, color } = seg;
const object: TextObject = { type: 'text', ...pos, text, font, fontSize, color };
objects.push(object);
if (link) {
links.push({ type: 'link', ...pos, width, height, url: link });
}
pos.x += width;
size.width += width;
size.height = Math.max(size.height, height);
maxDescent = Math.max(maxDescent, getDescent(font, fontSize));
maxLineHeight = Math.max(maxLineHeight, height * lineHeight);
});
objects.forEach((obj) => (obj.y -= maxDescent));
flattenLinks(links).forEach((link) => objects.push(link));
const row = {
type: 'row',
...alignRow(box, size, textAlign),
width: size.width,
height: maxLineHeight,
objects,
};
return { row, remainder };
}
function getDescent(font: PDFFont, fontSize: number) {
const fontkitFont = (font as any).embedder.font;
return Math.abs(((fontkitFont.descent ?? 0) * fontSize) / fontkitFont.unitsPerEm);
}
function layoutGraphics(graphics: GraphicsObject[], pos: Pos): GraphicsObject[] {
return graphics.map((object) => {
return shiftGraphicsObject(object, pos);
});
}
/**
* Merge adjacent link objects that point to the same target. Without this step, a link that
* consists of multiple text segments, e.g. because it includes normal and italic text, would be
* rendered as multiple independent links in the PDF. Example:
* ```js
* {text: ['foo', {text: 'bar', italic: true}], link: 'https://www.example.com'}
* ```
*/
function flattenLinks(links: LinkObject[]) {
const result = [];
let prev;
links.forEach((link) => {
if (prev?.url === link.url && prev?.x + prev?.width === link.x && prev?.y === link.y) {
prev.width += link.width;
prev.height = Math.max(prev.height, link.height);
} else {
prev = link;
result.push(prev);
}
});
return result;
}
function alignRow(box: Box, textSize: Size, textAlign?: Alignment): Pos {
if (textAlign === 'right') {
return {
x: box.x + box.width - textSize.width,
y: box.y,
};
}
if (textAlign === 'center') {
return {
x: box.x + (box.width - textSize.width) / 2,
y: box.y,
};
}
return { x: box.x, y: box.y };
}