printmaker
Version:
Generate PDF documents and from JavaScript objects
324 lines (294 loc) • 9.28 kB
text/typescript
import { PDFFont } from 'pdf-lib';
import { BoxEdges, parseEdges, parseLength } from './box.js';
import { Color, parseColor } from './colors.js';
import { Alignment } from './content.js';
import { Font, selectFont } from './fonts.js';
import { GraphicsObject, parseGraphics } from './graphics.js';
import {
asArray,
asBoolean,
asNonNegNumber,
asObject,
asString,
check,
getFrom,
isObject,
Obj,
optional,
pickDefined,
typeError,
} from './types.js';
const defaultFontSize = 18;
const defaultLineHeight = 1.2;
export type TextSegment = {
text: string;
width: number;
height: number;
lineHeight: number;
font: PDFFont;
fontSize: number;
color?: Color;
link?: string;
};
/**
* A range of text with attributes, similar to a `<span>` in HTML.
*/
export type TextSpan = {
text: string;
attrs: TextAttrs;
};
export type Block = Columns | Rows | Paragraph;
export type Columns = {
columns: Block[];
} & BlockAttrs;
export type Rows = {
rows: Block[];
} & BlockAttrs;
export type ImageBlock = {
image?: string;
padding?: BoxEdges;
} & BlockAttrs;
export type Paragraph = {
text?: TextSpan[];
graphics?: GraphicsObject[];
padding?: BoxEdges;
} & BlockAttrs &
InheritableAttrs;
export type TextAttrs = {
fontFamily?: string;
fontSize?: number;
lineHeight?: number;
bold?: boolean;
italic?: boolean;
color?: Color;
link?: string;
};
type BlockAttrs = {
margin?: BoxEdges;
width?: number;
height?: number;
id?: string;
};
type InheritableAttrs = TextAttrs & {
textAlign?: Alignment;
};
export function parseContent(content: unknown[], defaultStyle: InheritableAttrs): Paragraph[] {
return content.map((block, idx) =>
check(block, `content[${idx}]`, () => parseBlock(asObject(block), defaultStyle))
);
}
export function parseBlock(input: Obj, defaultAttrs?: InheritableAttrs): Block {
if (input.columns) {
return parseColumns(input, defaultAttrs);
}
if (input.rows) {
return parseRows(input, defaultAttrs);
}
return parseParagraph(input, defaultAttrs);
}
export function parseColumns(input: Obj, defaultAttrs?: InheritableAttrs): Columns {
const mergedAttrs = { ...defaultAttrs, ...parseInheritableAttrs(input) };
const parseColumns = (columns) =>
asArray(columns).map((col, idx) =>
check(col, `[${idx}]`, () => parseBlock(col as Obj, mergedAttrs))
);
return pickDefined({
columns: getFrom(input, 'columns', parseColumns),
...parseBlockAttrs(input),
}) as Columns;
}
export function parseRows(input: Obj, defaultAttrs?: InheritableAttrs): Columns {
const mergedAttrs = { ...defaultAttrs, ...parseInheritableAttrs(input) };
const parseRows = (rows) =>
asArray(rows).map((col, idx) =>
check(col, `[${idx}]`, () => parseBlock(col as Obj, mergedAttrs))
);
return pickDefined({
rows: getFrom(input, 'rows', parseRows),
...parseBlockAttrs(input),
}) as Columns;
}
export function parseParagraph(input: Obj, defaultAttrs?: InheritableAttrs): Paragraph {
const mergedAttrs = { ...defaultAttrs, ...input };
const textAttrs = parseTextAttrs(mergedAttrs);
const parseTextWithAttrs = (text) => parseText(text, textAttrs);
return pickDefined({
text: getFrom(input, 'text', optional(parseTextWithAttrs)),
image: getFrom(input, 'image', optional(asString)),
graphics: getFrom(input, 'graphics', optional(parseGraphics)),
padding: getFrom(input, 'padding', optional(parseEdges)),
textAlign: getFrom(mergedAttrs, 'textAlign', optional(asTextAlign)),
...parseBlockAttrs(input),
});
}
function parseBlockAttrs(input: Obj): BlockAttrs {
return pickDefined({
margin: getFrom(input, 'margin', optional(parseEdges)),
width: getFrom(input, 'width', optional(parseLength)),
height: getFrom(input, 'height', optional(parseLength)),
id: getFrom(input, 'id', optional(asString)),
});
}
export function parseTextAttrs(input: Obj): TextAttrs {
return pickDefined({
fontFamily: getFrom(input, 'fontFamily', optional(asString)),
fontSize: getFrom(input, 'fontSize', optional(asNonNegNumber)),
lineHeight: getFrom(input, 'lineHeight', optional(asNonNegNumber)),
bold: getFrom(input, 'bold', optional(asBoolean)),
italic: getFrom(input, 'italic', optional(asBoolean)),
color: getFrom(input, 'color', optional(parseColor)),
link: getFrom(input, 'link', optional(asString)),
});
}
export function parseInheritableAttrs(input: Obj): TextAttrs {
return pickDefined({
...parseTextAttrs(input),
textAlign: getFrom(input, 'textAlign', optional(asTextAlign)),
});
}
export function parseText(text: unknown, attrs: TextAttrs): TextSpan[] {
if (Array.isArray(text)) {
return text.flatMap((text) => parseText(text, attrs));
}
if (typeof text === 'string') {
return [{ text, attrs }];
}
if (isObject(text) && 'text' in text) {
return parseText((text as Obj).text, { ...attrs, ...parseTextAttrs(text as Obj) });
}
throw typeError('string, object with text attribute, or array of text', text);
}
function asTextAlign(input: unknown): Alignment {
if (input === 'left' || input === 'right' || input === 'center') return input;
throw typeError("'left', 'right', or 'center'", input);
}
export function extractTextSegments(textSpans: TextSpan[], fonts: Font[]): TextSegment[] {
return textSpans.flatMap((span) => {
const { text, attrs } = span;
const { fontSize = defaultFontSize, lineHeight = defaultLineHeight, color, link } = attrs;
const font = selectFont(fonts, attrs);
const height = font.heightAtSize(fontSize);
return splitChunks(text).map(
(text) =>
({
text,
width: font.widthOfTextAtSize(text, fontSize),
height,
lineHeight,
font,
fontSize,
color,
link,
} as TextSegment)
);
});
}
/**
* Split the given text into chunks of subsequent whitespace (`\s`) and non-whitespace (`\S`)
* characters. For example, the string `foo bar` would be split into `['foo', ' ', 'bar']`.
* Newlines (`\n`) are preserved, each in a chunk of its own. Any whitespace that surrounds
* newlines is removed.
*
* @param text The input string
* @returns an array of chunks
*/
export function splitChunks(text: string): string[] {
const segments: string[] = [];
let tail = text;
let match = /\s+/.exec(tail);
while (match) {
if (match.index) {
segments.push(tail.slice(0, match.index));
}
const wsSegment = tail.slice(match.index, match.index + match[0].length);
const newlineCount = wsSegment.match(/\n/g)?.length;
if (newlineCount) {
segments.push(...'\n'.repeat(newlineCount).split(''));
} else {
segments.push(wsSegment);
}
tail = tail.slice(match.index + match[0].length);
match = /\s+/.exec(tail);
}
if (tail) {
segments.push(tail);
}
return segments;
}
export function breakLine(segments: TextSegment[], maxWidth: number) {
const breakIdx = findLinebreak(segments, maxWidth);
if (breakIdx >= 0) {
const head = segments.slice(0, breakIdx);
const tail = segments.slice(breakIdx + 1);
return tail.length ? [head, tail] : [head];
}
return [segments];
}
function findLinebreak(segments, maxWidth): number {
let x = 0;
for (const [idx, segment] of segments.entries()) {
const { text, width } = segment;
if (text === '\n') {
return idx;
}
x += width;
if (x > maxWidth) {
return findLinebreakOpportunity(segments, idx);
}
}
}
/**
* Finds the next appropriate segment that allows for a linebreak, starting at the given index, and
* returns its index.
*
* @param segments A list of text segments.
* @param index The index of the element to start at.
* @returns The index of the next segment that allows for linebreak if found, `undefined` otherwise.
*/
export function findLinebreakOpportunity(
segments: TextSegment[],
index: number
): number | undefined {
// If the segment at the given index does not allow for a linebreak, look for a previous one.
for (let i = index; i >= 0; i--) {
if (isLineBreakOpportunity(segments[i])) {
return i;
}
}
// If no previous segment allows for a linebreak, find the next one after the index.
for (let i = index + 1; i < segments.length; i++) {
if (isLineBreakOpportunity(segments[i])) {
return i;
}
}
}
function isLineBreakOpportunity(segment: TextSegment): boolean {
return segment && /^\s+$/.test(segment.text);
}
/**
* Flatten a list of text segments by merging subsequent segments that have identical text
* attributes.
*
* @param segments a list of text segments
* @returns a possibly shorter list of text segments
*/
export function flattenTextSegments(segments: TextSegment[]): TextSegment[] {
const result: TextSegment[] = [];
let prev;
segments.forEach((segment) => {
if (
segment.font === prev?.font &&
segment.fontSize === prev?.fontSize &&
segment.lineHeight === prev?.lineHeight &&
segment.color === prev?.color &&
segment.link === prev?.link
) {
prev.text += segment.text;
prev.width += segment.width;
} else {
prev = { ...segment };
result.push(prev);
}
});
return result;
}