/ src / elements / text.ts
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);