UNPKG

pdf-lib

Version:

Create and modify PDF files with JavaScript

344 lines (283 loc) 8.75 kB
import PDFFont from 'src/api/PDFFont'; import { CombedTextLayoutError } from 'src/api/errors'; import { TextAlignment } from 'src/api/text/alignment'; import { PDFHexString } from 'src/core'; import { cleanText, lineSplit, mergeLines, charAtIndex, charSplit, } from 'src/utils'; export interface TextPosition { text: string; encoded: PDFHexString; x: number; y: number; width: number; height: number; } export interface LayoutBounds { x: number; y: number; width: number; height: number; } const MIN_FONT_SIZE = 4; const MAX_FONT_SIZE = 500; const computeFontSize = ( lines: string[], font: PDFFont, bounds: LayoutBounds, multiline: boolean = false, ) => { let fontSize = MIN_FONT_SIZE; while (fontSize < MAX_FONT_SIZE) { let linesUsed = 0; for ( let lineIdx = 0, lineLen = lines.length; lineIdx < lineLen; lineIdx++ ) { linesUsed += 1; const line = lines[lineIdx]; const words = line.split(' '); // Layout the words using the current `fontSize`, line wrapping // whenever we reach the end of the current line. let spaceInLineRemaining = bounds.width; for (let idx = 0, len = words.length; idx < len; idx++) { const isLastWord = idx === len - 1; const word = isLastWord ? words[idx] : words[idx] + ' '; const widthOfWord = font.widthOfTextAtSize(word, fontSize); spaceInLineRemaining -= widthOfWord; if (spaceInLineRemaining <= 0) { linesUsed += 1; spaceInLineRemaining = bounds.width - widthOfWord; } } } // Return if we exceeded the allowed width if (!multiline && linesUsed > lines.length) return fontSize - 1; const height = font.heightAtSize(fontSize); const lineHeight = height + height * 0.2; const totalHeight = lineHeight * linesUsed; // Return if we exceeded the allowed height if (totalHeight > Math.abs(bounds.height)) return fontSize - 1; fontSize += 1; } return fontSize; }; const computeCombedFontSize = ( line: string, font: PDFFont, bounds: LayoutBounds, cellCount: number, ) => { const cellWidth = bounds.width / cellCount; const cellHeight = bounds.height; let fontSize = MIN_FONT_SIZE; const chars = charSplit(line); while (fontSize < MAX_FONT_SIZE) { for (let idx = 0, len = chars.length; idx < len; idx++) { const c = chars[idx]; const tooLong = font.widthOfTextAtSize(c, fontSize) > cellWidth * 0.75; if (tooLong) return fontSize - 1; } const height = font.heightAtSize(fontSize, { descender: false }); if (height > cellHeight) return fontSize - 1; fontSize += 1; } return fontSize; }; export interface LayoutTextOptions { alignment: TextAlignment; fontSize?: number; font: PDFFont; bounds: LayoutBounds; } export interface MultilineTextLayout { bounds: LayoutBounds; lines: TextPosition[]; fontSize: number; lineHeight: number; } const lastIndexOfWhitespace = (line: string) => { for (let idx = line.length; idx > 0; idx--) { if (/\s/.test(line[idx])) return idx; } return undefined; }; const splitOutLines = ( input: string, maxWidth: number, font: PDFFont, fontSize: number, ) => { let lastWhitespaceIdx = input.length; while (lastWhitespaceIdx > 0) { const line = input.substring(0, lastWhitespaceIdx); const encoded = font.encodeText(line); const width = font.widthOfTextAtSize(line, fontSize); if (width < maxWidth) { const remainder = input.substring(lastWhitespaceIdx) || undefined; return { line, encoded, width, remainder }; } lastWhitespaceIdx = lastIndexOfWhitespace(line) ?? 0; } // We were unable to split the input enough to get a chunk that would fit // within the specified `maxWidth` so we'll just return everything return { line: input, encoded: font.encodeText(input), width: font.widthOfTextAtSize(input, fontSize), remainder: undefined, }; }; export const layoutMultilineText = ( text: string, { alignment, fontSize, font, bounds }: LayoutTextOptions, ): MultilineTextLayout => { const lines = lineSplit(cleanText(text)); if (fontSize === undefined || fontSize === 0) { fontSize = computeFontSize(lines, font, bounds, true); } const height = font.heightAtSize(fontSize); const lineHeight = height + height * 0.2; const textLines: TextPosition[] = []; let minX = bounds.x; let minY = bounds.y; let maxX = bounds.x + bounds.width; let maxY = bounds.y + bounds.height; let y = bounds.y + bounds.height; for (let idx = 0, len = lines.length; idx < len; idx++) { let prevRemainder: string | undefined = lines[idx]; while (prevRemainder !== undefined) { const { line, encoded, width, remainder } = splitOutLines( prevRemainder, bounds.width, font, fontSize, ); // prettier-ignore const x = ( alignment === TextAlignment.Left ? bounds.x : alignment === TextAlignment.Center ? bounds.x + (bounds.width / 2) - (width / 2) : alignment === TextAlignment.Right ? bounds.x + bounds.width - width : bounds.x ); y -= lineHeight; if (x < minX) minX = x; if (y < minY) minY = y; if (x + width > maxX) maxX = x + width; if (y + height > maxY) maxY = y + height; textLines.push({ text: line, encoded, width, height, x, y }); // Only trim lines that we had to split ourselves. So we won't trim lines // that the user provided themselves with whitespace. prevRemainder = remainder?.trim(); } } return { fontSize, lineHeight, lines: textLines, bounds: { x: minX, y: minY, width: maxX - minX, height: maxY - minY, }, }; }; export interface LayoutCombedTextOptions { fontSize?: number; font: PDFFont; bounds: LayoutBounds; cellCount: number; } export interface CombedTextLayout { bounds: LayoutBounds; cells: TextPosition[]; fontSize: number; } export const layoutCombedText = ( text: string, { fontSize, font, bounds, cellCount }: LayoutCombedTextOptions, ): CombedTextLayout => { const line = mergeLines(cleanText(text)); if (line.length > cellCount) { throw new CombedTextLayoutError(line.length, cellCount); } if (fontSize === undefined || fontSize === 0) { fontSize = computeCombedFontSize(line, font, bounds, cellCount); } const cellWidth = bounds.width / cellCount; const height = font.heightAtSize(fontSize, { descender: false }); const y = bounds.y + (bounds.height / 2 - height / 2); const cells: TextPosition[] = []; let minX = bounds.x; let minY = bounds.y; let maxX = bounds.x + bounds.width; let maxY = bounds.y + bounds.height; let cellOffset = 0; let charOffset = 0; while (cellOffset < cellCount) { const [char, charLength] = charAtIndex(line, charOffset); const encoded = font.encodeText(char); const width = font.widthOfTextAtSize(char, fontSize); const cellCenter = bounds.x + (cellWidth * cellOffset + cellWidth / 2); const x = cellCenter - width / 2; if (x < minX) minX = x; if (y < minY) minY = y; if (x + width > maxX) maxX = x + width; if (y + height > maxY) maxY = y + height; cells.push({ text: line, encoded, width, height, x, y }); cellOffset += 1; charOffset += charLength; } return { fontSize, cells, bounds: { x: minX, y: minY, width: maxX - minX, height: maxY - minY, }, }; }; export interface LayoutSinglelineTextOptions { alignment: TextAlignment; fontSize?: number; font: PDFFont; bounds: LayoutBounds; } export interface SinglelineTextLayout { bounds: LayoutBounds; line: TextPosition; fontSize: number; } export const layoutSinglelineText = ( text: string, { alignment, fontSize, font, bounds }: LayoutSinglelineTextOptions, ): SinglelineTextLayout => { const line = mergeLines(cleanText(text)); if (fontSize === undefined || fontSize === 0) { fontSize = computeFontSize([line], font, bounds); } const encoded = font.encodeText(line); const width = font.widthOfTextAtSize(line, fontSize); const height = font.heightAtSize(fontSize, { descender: false }); // prettier-ignore const x = ( alignment === TextAlignment.Left ? bounds.x : alignment === TextAlignment.Center ? bounds.x + (bounds.width / 2) - (width / 2) : alignment === TextAlignment.Right ? bounds.x + bounds.width - width : bounds.x ); const y = bounds.y + (bounds.height / 2 - height / 2); return { fontSize, line: { text: line, encoded, width, height, x, y }, bounds: { x, y, width, height }, }; };