Skip to content

Commit

Permalink
Draw wrapped text (#198)
Browse files Browse the repository at this point in the history
* Draw wrapped text (#193)

* Implemented PDFPage.drawWrappedText

* Improve the function documentation.

* Fix off-by-one error.

* Implemented maxWidth in drawText.

Also improved the performance and added support for multiple word break characters.

* Fixed Regex DoS attack

* Fixed new-lines

* Ensure at least one breaking character is given

* Fixed linting issues

* Allow empty string as word breaking character

* Refactor text wrapping code

* Add unit test for breakTextIntoLines

* Update tests
  • Loading branch information
Hopding authored Sep 24, 2019
1 parent 623a69e commit 5257414
Show file tree
Hide file tree
Showing 8 changed files with 202 additions and 45 deletions.
20 changes: 9 additions & 11 deletions apps/node/tests/test6.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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);
Expand Down
20 changes: 9 additions & 11 deletions apps/rn/src/tests/test6.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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);
Expand Down
20 changes: 9 additions & 11 deletions apps/web/test6.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions src/api/PDFDocument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]>;
Expand Down
29 changes: 17 additions & 12 deletions src/api/PDFPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ import {
assertIs,
assertMultiple,
assertOrUndefined,
breakTextIntoLines,
cleanText,
} from 'src/utils';

/**
Expand Down Expand Up @@ -580,23 +582,33 @@ 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();
contentStream.push(
...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),
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/api/PDFPageOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export interface PDFPageDrawTextOptions {
x?: number;
y?: number;
lineHeight?: number;
maxWidth?: number;
wordBreaks?: string[];
}

export interface PDFPageDrawImageOptions {
Expand Down
55 changes: 55 additions & 0 deletions src/utils/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
98 changes: 98 additions & 0 deletions tests/utils/strings.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});

0 comments on commit 5257414

Please # to comment.