text.ts
1 import type { PDFFont, PDFPage } from "@cantoo/pdf-lib"; 2 import type { Node } from "yoga-layout"; 3 import type { Element } from "~/elements"; 4 import type { MemoryFont } from "~/utils/fonts"; 5 import { breakTextIntoLines, TextAlignment } from "@cantoo/pdf-lib"; 6 import Yoga from "yoga-layout"; 7 import { absoluteLeft, absoluteTop } from "~/utils/positions"; 8 9 export class Text implements Element { 10 private _font?: MemoryFont; 11 private _fontSize = 16; 12 private _lineHeight = 1.2; 13 private _node?: Node; 14 private _textAlign?: TextAlignment; 15 private _w?: "auto" | `${number}%` | number; 16 17 constructor(private _value: string) {} 18 19 draw(page: PDFPage, fonts: Map<MemoryFont, PDFFont>): void { 20 const node = this.getLayoutNode(page, fonts); 21 const font = this.getCurrentFont(page, fonts); 22 23 const textWidth = (t: string): number => font.widthOfTextAtSize(t, this._fontSize); 24 const widthForWrapping = node.getComputedWidth(); 25 26 const lines = breakTextIntoLines( 27 this._value, 28 page.doc.defaultWordBreaks, 29 widthForWrapping, 30 textWidth 31 ); 32 33 const lineHeight = this._fontSize * this._lineHeight; 34 const fontHeight = font.heightAtSize(this._fontSize); 35 36 // Estimate the baseline position - typically around 70-80% of font height from the bottom 37 // This approximation works well for most fonts 38 const baselineOffset = fontHeight * 0.75; 39 40 // Calculate total text height using the same logic as in measure function 41 const totalTextHeight = (lines.length - 1) * lineHeight + fontHeight; 42 43 // Calculate node position 44 const nodeTop = page.getHeight() - absoluteTop(node); 45 const nodeHeight = node.getComputedHeight(); 46 const boundsWidth = node.getComputedWidth(); 47 48 // Find vertical center of the node 49 const nodeCenterY = nodeTop - nodeHeight / 2; 50 51 // Position the text block with its center aligned to the node's center 52 const textBlockCenterY = nodeCenterY; 53 54 // Calculate the position of the first line's baseline 55 const firstLineY = textBlockCenterY + totalTextHeight / 2 - baselineOffset; 56 57 for (let i = 0; i < lines.length; i++) { 58 const line = lines[i]!; 59 const y = firstLineY - i * lineHeight; 60 61 let x = absoluteLeft(node); 62 const width = textWidth(line); 63 64 // prettier-ignore 65 x = this._textAlign === TextAlignment.Left 66 ? x 67 : this._textAlign === TextAlignment.Center 68 ? x + (boundsWidth / 2) - (width / 2) 69 : this._textAlign === TextAlignment.Right 70 ? x + boundsWidth - width 71 : x; 72 73 page.drawText(line, { 74 font, 75 size: this._fontSize, 76 x, 77 y 78 }); 79 } 80 } 81 82 font(font: MemoryFont): Text { 83 this._font = font; 84 return this; 85 } 86 87 getLayoutNode(page: PDFPage, fonts: Map<MemoryFont, PDFFont>): Node { 88 if (this._node) return this._node; 89 const node = Yoga.Node.create(); 90 console.log("text: construct layout"); 91 92 const font = this.getCurrentFont(page, fonts); 93 94 // When we're forcing a width to our text, we'll fix it absolutely. 95 if (this._w !== void 0) { 96 node.setWidth(this._w); 97 } 98 // Otherwise, when we're NOT forcing a width, we'll use the full width. 99 else { 100 const textWidth = (t: string): number => 101 font.widthOfTextAtSize(t, this._fontSize); 102 103 node.setMeasureFunc( 104 (availWidth, widthMode, _availHeight, _heightMode) => { 105 const widthForWrapping 106 = widthMode === Yoga.MEASURE_MODE_UNDEFINED ? Infinity : availWidth; 107 108 const lines = breakTextIntoLines( 109 this._value, 110 page.doc.defaultWordBreaks, 111 widthForWrapping, 112 textWidth 113 ); 114 115 const lineHeight = this._fontSize * this._lineHeight; 116 const fontHeight = font.heightAtSize(this._fontSize); 117 const height = (lines.length - 1) * lineHeight + fontHeight; 118 119 const maxLineWidth = lines.reduce( 120 (m, l) => Math.max(m, textWidth(l)), 121 0 122 ); 123 124 const width 125 = widthMode === Yoga.MEASURE_MODE_EXACTLY 126 ? availWidth 127 : Math.min(maxLineWidth, widthForWrapping); 128 129 return { height, width }; 130 } 131 ); 132 } 133 134 this._node = node; 135 return this._node; 136 } 137 138 /** 139 * @see https://tailwindcss.com/docs/line-height 140 */ 141 leading(lineHeight: number): Text { 142 this._lineHeight = lineHeight; 143 return this; 144 } 145 146 /** 147 * @see https://tailwindcss.com/docs/line-height 148 */ 149 leadingNone(): Text { 150 this._lineHeight = 1; 151 return this; 152 } 153 154 textCenter(): Text { 155 this._textAlign = TextAlignment.Center; 156 return this; 157 } 158 159 textLeft(): Text { 160 this._textAlign = TextAlignment.Left; 161 return this; 162 } 163 164 textRight(): Text { 165 this._textAlign = TextAlignment.Right; 166 return this; 167 } 168 169 private getCurrentFont(page: PDFPage, fonts: Map<MemoryFont, PDFFont>): PDFFont { 170 return this._font ? fonts.get(this._font)! : page.getFont()[0]; 171 } 172 } 173 export const text = (value: string): Text => new Text(value);