UNPKG

printmaker

Version:

Generate PDF documents and from JavaScript objects

293 lines (271 loc) 9.82 kB
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 }; }