Repository: Hopding/pdf-lib
Pull request #198 (2019-09-24 21:27:17)

Draw wrapped text
Addresses https://github.com/Hopding/pdf-lib/pull/173 and https://github.com/Hopding/pdf-lib/pull/193.


Patch:
diff --git a/src/api/PDFDocument.ts b/src/api/PDFDocument.ts
index a868dbe..9eaf22f 100644
--- a/src/api/PDFDocument.ts
+++ b/src/api/PDFDocument.ts
@@ -158,6 +158,9 @@ export default class PDFDocument {
   /** Whether or not this document is encrypted. */
   readonly isEncrypted: boolean;
 
+  /** The default word breaks used in PDFPage.drawText */
+  defaultWordBreaks: string[] = [' '];
+
   private fontkit?: Fontkit;
   private pageCount: number | undefined;
   private readonly pageCache: Cache<PDFPage[]>;
diff --git a/src/api/PDFPage.ts b/src/api/PDFPage.ts
index 94639f8..a8fda53 100644
--- a/src/api/PDFPage.ts
+++ b/src/api/PDFPage.ts
@@ -38,6 +38,8 @@ import {
   assertIs,
   assertMultiple,
   assertOrUndefined,
+  breakTextIntoLines,
+  cleanText,
 } from 'src/utils';
 
 /**
@@ -580,15 +582,25 @@ export default class PDFPage {
     assertOrUndefined(options.x, 'options.x', ['number']);
     assertOrUndefined(options.y, 'options.y', ['number']);
     assertOrUndefined(options.lineHeight, 'options.lineHeight', ['number']);
+    assertOrUndefined(options.maxWidth, 'options.maxWidth', ['number']);
+    assertOrUndefined(options.wordBreaks, 'options.wordBreaks', [Array]);
 
     const [originalFont] = this.getFont();
     if (options.font) this.setFont(options.font);
     const [font, fontKey] = this.getFont();
 
-    const preprocessedLines = this.preprocessText(text);
-    const encodedLines = new Array(preprocessedLines.length) as PDFHexString[];
-    for (let idx = 0, len = preprocessedLines.length; idx < len; idx++) {
-      encodedLines[idx] = font.encodeText(preprocessedLines[idx]);
+    const fontSize = options.size || this.fontSize;
+
+    const wordBreaks = options.wordBreaks || this.doc.defaultWordBreaks;
+    const textWidth = (t: string) => font.widthOfTextAtSize(t, fontSize);
+    const lines =
+      options.maxWidth === undefined
+        ? cleanText(text).split(/[\r\n\f]/)
+        : breakTextIntoLines(text, wordBreaks, options.maxWidth, textWidth);
+
+    const encodedLines = new Array(lines.length) as PDFHexString[];
+    for (let idx = 0, len = lines.length; idx < len; idx++) {
+      encodedLines[idx] = font.encodeText(lines[idx]);
     }
 
     const contentStream = this.getContentStream();
@@ -596,7 +608,7 @@ export default class PDFPage {
       ...drawLinesOfText(encodedLines, {
         color: options.color || this.fontColor,
         font: fontKey,
-        size: options.size || this.fontSize,
+        size: fontSize,
         rotate: options.rotate || degrees(0),
         xSkew: options.xSkew || degrees(0),
         ySkew: options.ySkew || degrees(0),
@@ -803,13 +815,6 @@ export default class PDFPage {
     this.drawEllipse({ ...options, xScale: size, yScale: size });
   }
 
-  private preprocessText(text: string): string[] {
-    return text
-      .replace(/\t/g, '    ')
-      .replace(/[\b\v]/g, '')
-      .split(/[\r\n\f]/);
-  }
-
   private getFont(): [PDFFont, string] {
     if (!this.font || !this.fontKey) {
       const font = this.doc.embedStandardFont(StandardFonts.Helvetica);
diff --git a/src/api/PDFPageOptions.ts b/src/api/PDFPageOptions.ts
index f29a806..96f0841 100644
--- a/src/api/PDFPageOptions.ts
+++ b/src/api/PDFPageOptions.ts
@@ -12,6 +12,8 @@ export interface PDFPageDrawTextOptions {
   x?: number;
   y?: number;
   lineHeight?: number;
+  maxWidth?: number;
+  wordBreaks?: string[];
 }
 
 export interface PDFPageDrawImageOptions {
diff --git a/src/utils/strings.ts b/src/utils/strings.ts
index a7aeeae..285de84 100644
--- a/src/utils/strings.ts
+++ b/src/utils/strings.ts
@@ -33,3 +33,58 @@ export const copyStringIntoBuffer = (
 
 export const addRandomSuffix = (prefix: string, suffixLength = 4) =>
   `${prefix}-${Math.floor(Math.random() * 10 ** suffixLength)}`;
+
+export const escapeRegExp = (str: string) =>
+  str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+
+export const cleanText = (text: string) =>
+  text.replace(/\t/g, '    ').replace(/[\b\v]/g, '');
+
+const buildWordBreakRegex = (wordBreaks: string[]) => {
+  const escapedRules: string[] = ['$'];
+  for (let idx = 0, len = wordBreaks.length; idx < len; idx++) {
+    const wordBreak = wordBreaks[idx];
+    if (wordBreak.includes('\n') || wordBreak.includes('\r')) {
+      throw new TypeError('`wordBreak` must not include \\n or \\r');
+    }
+    escapedRules.push(wordBreak === '' ? '.' : escapeRegExp(wordBreak));
+  }
+  const breakRules = escapedRules.join('|');
+  return new RegExp(`(\\n|\\r)|((.*?)(${breakRules}))`, 'gm');
+};
+
+export const breakTextIntoLines = (
+  text: string,
+  wordBreaks: string[],
+  maxWidth: number,
+  computeWidthOfText: (t: string) => number,
+): string[] => {
+  const regex = buildWordBreakRegex(wordBreaks);
+
+  const words = cleanText(text).match(regex)!;
+
+  let currLine = '';
+  let currWidth = 0;
+  const lines: string[] = [];
+
+  const pushCurrLine = () => {
+    if (currLine !== '') lines.push(currLine);
+    currLine = '';
+    currWidth = 0;
+  };
+
+  for (let idx = 0, len = words.length; idx < len; idx++) {
+    const word = words[idx];
+    if (word === '\n' || word === '\r') {
+      pushCurrLine();
+    } else {
+      const width = computeWidthOfText(word);
+      if (currWidth + width > maxWidth) pushCurrLine();
+      currLine += word;
+      currWidth += width;
+    }
+  }
+  pushCurrLine();
+
+  return lines;
+};


Test patch:
diff --git a/apps/node/tests/test6.ts b/apps/node/tests/test6.ts
index f96fb6e..d595ff3 100644
--- a/apps/node/tests/test6.ts
+++ b/apps/node/tests/test6.ts
@@ -20,16 +20,13 @@ export default async (assets: Assets) => {
 
   const page = pdfDoc.getPages()[0];
 
-  const lines = [
-    'This is an image of Mario running.',
-    'This image and text was drawn on',
-    'top of an existing PDF using pdf-lib!',
-  ];
+  const text =
+    'This is an image of Mario running. This image and text was drawn on top of an existing PDF using pdf-lib!';
   const fontSize = 24;
   const solarizedWhite = rgb(253 / 255, 246 / 255, 227 / 255);
   const solarizedGray = rgb(101 / 255, 123 / 255, 131 / 255);
 
-  const textWidth = ubuntuFont.widthOfTextAtSize(lines[2], fontSize);
+  const boxWidth = 387.5;
 
   const { width, height } = page.getSize();
   const centerX = width / 2;
@@ -47,11 +44,11 @@ export default async (assets: Assets) => {
     xSkew: degrees(35),
     ySkew: degrees(35),
   });
-  const boxHeight = (fontSize + 5) * lines.length;
+  const boxHeight = (fontSize + 5) * 3;
   page.drawRectangle({
-    x: centerX - textWidth / 2 - 5,
+    x: centerX - boxWidth / 2 - 5,
     y: centerY - 60 - boxHeight + fontSize + 3,
-    width: textWidth + 10,
+    width: boxWidth + 10,
     height: boxHeight,
     color: solarizedWhite,
     borderColor: solarizedGray,
@@ -61,11 +58,12 @@ export default async (assets: Assets) => {
   });
   page.setFont(ubuntuFont);
   page.setFontColor(solarizedGray);
-  page.drawText(lines.join('\n'), {
-    x: centerX - textWidth / 2,
+  page.drawText(text, {
+    x: centerX - boxWidth / 2 + 5,
     y: centerY - 60,
     rotate: degrees(10),
     ySkew: degrees(15),
+    maxWidth: boxWidth + 5,
   });
 
   page.setSize(page.getWidth() + 100, page.getHeight() + 100);
diff --git a/apps/rn/src/tests/test6.js b/apps/rn/src/tests/test6.js
index f973b9f..8ece6ce 100644
--- a/apps/rn/src/tests/test6.js
+++ b/apps/rn/src/tests/test6.js
@@ -20,16 +20,13 @@ export default async () => {
 
   const page = pdfDoc.getPages()[0];
 
-  const lines = [
-    'This is an image of Mario running.',
-    'This image and text was drawn on',
-    'top of an existing PDF using pdf-lib!',
-  ];
+  const text =
+    'This is an image of Mario running. This image and text was drawn on top of an existing PDF using pdf-lib!';
   const fontSize = 24;
   const solarizedWhite = rgb(253 / 255, 246 / 255, 227 / 255);
   const solarizedGray = rgb(101 / 255, 123 / 255, 131 / 255);
 
-  const textWidth = ubuntuFont.widthOfTextAtSize(lines[2], fontSize);
+  const boxWidth = 387.5;
 
   const { width, height } = page.getSize();
   const centerX = width / 2;
@@ -47,11 +44,11 @@ export default async () => {
     xSkew: degrees(35),
     ySkew: degrees(35),
   });
-  const boxHeight = (fontSize + 5) * lines.length;
+  const boxHeight = (fontSize + 5) * 3;
   page.drawRectangle({
-    x: centerX - textWidth / 2 - 5,
+    x: centerX - boxWidth / 2 - 5,
     y: centerY - 60 - boxHeight + fontSize + 3,
-    width: textWidth + 10,
+    width: boxWidth + 10,
     height: boxHeight,
     color: solarizedWhite,
     borderColor: solarizedGray,
@@ -61,11 +58,12 @@ export default async () => {
   });
   page.setFont(ubuntuFont);
   page.setFontColor(solarizedGray);
-  page.drawText(lines.join('\n'), {
-    x: centerX - textWidth / 2,
+  page.drawText(text, {
+    x: centerX - boxWidth / 2 + 5,
     y: centerY - 60,
     rotate: degrees(10),
     ySkew: degrees(15),
+    maxWidth: boxWidth + 5,
   });
 
   page.setSize(page.getWidth() + 100, page.getHeight() + 100);
diff --git a/apps/web/test6.html b/apps/web/test6.html
index 0c279c4..3428960 100644
--- a/apps/web/test6.html
+++ b/apps/web/test6.html
@@ -69,16 +69,13 @@
 
       const page = pdfDoc.getPages()[0];
 
-      const lines = [
-        'This is an image of Mario running.',
-        'This image and text was drawn on',
-        'top of an existing PDF using pdf-lib!',
-      ];
+      const text =
+        'This is an image of Mario running. This image and text was drawn on top of an existing PDF using pdf-lib!';
       const fontSize = 24;
       const solarizedWhite = rgb(253 / 255, 246 / 255, 227 / 255);
       const solarizedGray = rgb(101 / 255, 123 / 255, 131 / 255);
 
-      const textWidth = ubuntuFont.widthOfTextAtSize(lines[2], fontSize);
+      const boxWidth = 387.5;
 
       const { width, height } = page.getSize();
       const centerX = width / 2;
@@ -96,11 +93,11 @@
         xSkew: degrees(35),
         ySkew: degrees(35),
       });
-      const boxHeight = (fontSize + 5) * lines.length;
+      const boxHeight = (fontSize + 5) * 3;
       page.drawRectangle({
-        x: centerX - textWidth / 2 - 5,
+        x: centerX - boxWidth / 2 - 5,
         y: centerY - 60 - boxHeight + fontSize + 3,
-        width: textWidth + 10,
+        width: boxWidth + 10,
         height: boxHeight,
         color: solarizedWhite,
         borderColor: solarizedGray,
@@ -110,11 +107,12 @@
       });
       page.setFont(ubuntuFont);
       page.setFontColor(solarizedGray);
-      page.drawText(lines.join('\n'), {
-        x: centerX - textWidth / 2,
+      page.drawText(text, {
+        x: centerX - boxWidth / 2 + 5,
         y: centerY - 60,
         rotate: degrees(10),
         ySkew: degrees(15),
+        maxWidth: boxWidth + 5,
       });
 
       page.setSize(page.getWidth() + 100, page.getHeight() + 100);
diff --git a/tests/utils/strings.spec.ts b/tests/utils/strings.spec.ts
new file mode 100644
index 0000000..ac3f13a
--- /dev/null
+++ b/tests/utils/strings.spec.ts
@@ -0,0 +1,98 @@
+import fontkit from '@pdf-lib/fontkit';
+import { FontNames } from '@pdf-lib/standard-fonts';
+import fs from 'fs';
+
+import { CustomFontEmbedder, StandardFontEmbedder } from 'src/core';
+import { breakTextIntoLines } from 'src/utils';
+
+const font = StandardFontEmbedder.for(FontNames.Helvetica);
+
+const textSize = 24;
+
+const computeTextWidth = (text: string) =>
+  font.widthOfTextAtSize(text, textSize);
+
+describe(`breakTextIntoLines`, () => {
+  it(`handles empty wordBreaks arrays`, () => {
+    const input = 'foobar-quxbaz';
+    const expected = ['foobar-quxbaz'];
+    const actual = breakTextIntoLines(input, [], 21, computeTextWidth);
+    expect(actual).toEqual(expected);
+  });
+
+  it(`handles trailing newlines`, () => {
+    const input = 'foo\n';
+    const expected = ['foo'];
+    const actual = breakTextIntoLines(input, [], 21, computeTextWidth);
+    expect(actual).toEqual(expected);
+  });
+
+  it(`handles trailing carriage returns`, () => {
+    const input = 'foo\r';
+    const expected = ['foo'];
+    const actual = breakTextIntoLines(input, [], 21, computeTextWidth);
+    expect(actual).toEqual(expected);
+  });
+
+  it(`always breaks lines when EOLs are encountered`, () => {
+    const input = 'foo\nbar-qux\rbaz\n';
+    const expected = ['foo', 'bar-qux', 'baz'];
+    const actual = breakTextIntoLines(input, [], 90000, computeTextWidth);
+    expect(actual).toEqual(expected);
+  });
+
+  it(`breaks at the last possible 'wordBreak' before exceeding 'maxWidth' (1)`, () => {
+    const input =
+      'Lorem Test ipsum dolor sit amet, consectetur adipiscing\nelit';
+    const expected = [
+      'Lorem T',
+      'est ipsu',
+      'm dolor s',
+      'it amet, c',
+      'onsectet',
+      'ur adipis',
+      'cing',
+      'elit',
+    ];
+    const actual = breakTextIntoLines(
+      input,
+      ['', 'Test'],
+      100,
+      computeTextWidth,
+    );
+    expect(actual).toEqual(expected);
+  });
+
+  it(`breaks at the last possible 'wordBreak' before exceeding 'maxWidth' (2)`, () => {
+    const input = 'Foo%bar%baz';
+    const expected = ['Foo%', 'bar%baz'];
+    const actual = breakTextIntoLines(input, ['%'], 100, computeTextWidth);
+    expect(actual).toEqual(expected);
+  });
+
+  it(`handles non-ascii code points and empty breaks`, async () => {
+    const sourceHansBytes = fs.readFileSync(
+      'assets/fonts/source_hans_jp/SourceHanSerifJP-Regular.otf',
+    );
+    const sourceHansFont = await CustomFontEmbedder.for(
+      fontkit,
+      sourceHansBytes,
+    );
+
+    const input =
+      '遅未亮惑職界転藤柔索名午納，問通桑転加料演載満経信回込町者訟窃。';
+    const expected = [
+      '遅未亮惑職',
+      '界転藤柔索',
+      '名午納，問',
+      '通桑転加料',
+      '演載満経信',
+      '回込町者訟',
+      '窃。',
+    ];
+    const actual = breakTextIntoLines(input, [''], 125, (text: string) =>
+      sourceHansFont.widthOfTextAtSize(text, 24),
+    );
+    expect(actual).toEqual(expected);
+  });
+});