Skip to content

Commit

Permalink
Enable rotatable text (#1589)
Browse files Browse the repository at this point in the history
  • Loading branch information
hollandjake authored Jan 15, 2025
1 parent 6603d6a commit f446608
Show file tree
Hide file tree
Showing 5 changed files with 190 additions and 2 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- Fix precision rounding issues in LineWrapper
- Add support for dynamic sizing
- Add support for rotatable text

### [v0.16.0] - 2024-12-29

Expand Down
9 changes: 7 additions & 2 deletions docs/text.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ below.
* `lineBreak` - set to `false` to disable line wrapping all together
* `width` - the width that text should be wrapped to (by default, the page width minus the left and right margin)
* `height` - the maximum height that text should be clipped to
* `rotation` - the rotation of the text in degrees (by default 0)
* `ellipsis` - the character to display at the end of the text when it is too long. Set to `true` to use the default character.
* `columns` - the number of columns to flow the text into
* `columnGap` - the amount of space between each column (1/4 inch by default)
Expand Down Expand Up @@ -132,10 +133,14 @@ The output looks like this:
## Text measurements

If you're working with documents that require precise layout, you may need to know the
size of a piece of text. PDFKit has two methods to achieve this: `widthOfString(text, options)`
and `heightOfString(text, options)`. Both methods use the same options described in the
size of a piece of text. PDFKit has three methods to achieve this: `widthOfString(text, options)`
, `heightOfString(text, options)` and `boundsOfString(text, options)/boundsOfString(text, x, y, options)`. All methods use the same options described in the
Text styling section, and take into account the eventual line wrapping.

However `boundsOfString` factors in text rotations and multi-line wrapped text,
effectively producing the bounding box of the text, `{x: number, y: number, width: number, height: number}`.
If `x` and `y` are not defined they will default to use `this.x` and `this.y`.

## Lists

The `list` method creates a bulleted list. It accepts as arguments an array of strings,
Expand Down
141 changes: 141 additions & 0 deletions lib/mixins/text.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ export default {
}
};

// We can save some bytes if there is no rotation
if (options.rotation !== 0) {
this.save();
this.rotate(-options.rotation, { origin: [this.x, this.y] });
}

// word wrapping
if (options.width) {
let wrapper = this._wrapper;
Expand All @@ -72,6 +78,9 @@ export default {
}
}

// Cleanup if there was a rotation
if (options.rotation !== 0) this.restore();

return this;
},

Expand All @@ -84,6 +93,134 @@ export default {
return ((this._font.widthOfString(string, this._fontSize, options.features) + (options.characterSpacing || 0) * (string.length - 1)) * horizontalScaling) / 100;
},

/**
* Compute the bounding box of a string
* based on what will actually be rendered by `doc.text()`
*
* @param string - The string
* @param x - X position of text (defaults to this.x)
* @param y - Y position of text (defaults to this.y)
* @param options - Any text options (The same you would apply to `doc.text()`)
* @returns {{x: number, y: number, width: number, height: number}}
*/
boundsOfString(string, x, y, options) {
options = this._initOptions(x, y, options);
({ x, y } = this);
const lineGap = options.lineGap ?? this._lineGap ?? 0;
const lineHeight = this.currentLineHeight(true) + lineGap;
let contentWidth = 0,
contentHeight = 0;

// Convert text to a string
string = String(string ?? '');

// if the wordSpacing option is specified, remove multiple consecutive spaces
if (options.wordSpacing) {
string = string.replace(/\s{2,}/g, ' ');
}

// word wrapping
if (options.width) {
let wrapper = new LineWrapper(this, options);
wrapper.on('line', (text, options) => {
contentHeight += lineHeight;
text = text.replace(/\n/g, '');

if (text.length) {
// handle options
let wordSpacing = options.wordSpacing ?? 0;
const characterSpacing = options.characterSpacing ?? 0;

// justify alignments
if (options.width && options.align === 'justify') {
// calculate the word spacing value
const words = text.trim().split(/\s+/);
const textWidth = this.widthOfString(
text.replace(/\s+/g, ''),
options,
);
const spaceWidth = this.widthOfString(' ') + characterSpacing;
wordSpacing = Math.max(
0,
(options.lineWidth - textWidth) / Math.max(1, words.length - 1) -
spaceWidth,
);
}

// calculate the actual rendered width of the string after word and character spacing
contentWidth = Math.max(
contentWidth,
options.textWidth +
wordSpacing * (options.wordCount - 1) +
characterSpacing * (text.length - 1),
);
}
});
wrapper.wrap(string, options);
} else {
// render paragraphs as single lines
for (let line of string.split('\n')) {
const lineWidth = this.widthOfString(line, options);
contentHeight += lineHeight;
contentWidth = Math.max(contentWidth, lineWidth);
}
}

/**
* Rotates around top left corner
* [x1,y1] > [x2,y2]
* ⌃ ⌄
* [x4,y4] < [x3,y3]
*/
if (options.rotation === 0) {
// No rotation so we can use the existing values
return { x, y, width: contentWidth, height: contentHeight };
// Use fast computation without explicit trig
} else if (options.rotation === 90) {
return {
x: x,
y: y - contentWidth,
width: contentHeight,
height: contentWidth,
};
} else if (options.rotation === 180) {
return {
x: x - contentWidth,
y: y - contentHeight,
width: contentWidth,
height: contentHeight,
};
} else if (options.rotation === 270) {
return {
x: x - contentHeight,
y: y,
width: contentHeight,
height: contentWidth,
};
}

// Non-trivial values so time for trig
const angleRad = (options.rotation * Math.PI) / 180;
const cos = Math.cos(angleRad);
const sin = Math.sin(angleRad);

const x1 = x;
const y1 = y;
const x2 = x + contentWidth * cos;
const y2 = y - contentWidth * sin;
const x3 = x + contentWidth * cos + contentHeight * sin;
const y3 = y - contentWidth * sin + contentHeight * cos;
const x4 = x + contentHeight * sin;
const y4 = y + contentHeight * cos;

const xMin = Math.min(x1, x2, x3, x4);
const xMax = Math.max(x1, x2, x3, x4);
const yMin = Math.min(y1, y2, y3, y4);
const yMax = Math.max(y1, y2, y3, y4);

return { x: xMin, y: yMin, width: xMax - xMin, height: yMax - yMin };
},

heightOfString(text, options) {
const { x, y } = this;

Expand Down Expand Up @@ -272,6 +409,10 @@ export default {
result.columnGap = 18;
} // 1/4 inch

// Normalize rotation to between 0 - 360
result.rotation = Number(options.rotation ?? 0) % 360;
if (result.rotation < 0) result.rotation += 360;

return result;
},

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
41 changes: 41 additions & 0 deletions tests/visual/text.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,45 @@ describe('text', function() {
});
});

test('rotated text', function () {
let i = 0;
const cols = [
'#292f56',
'#492d73',
'#8c2f94',
'#b62d78',
'#d82d31',
'#e69541',
'#ecf157',
'#acfa70',
];
function randColor() {
return cols[i++ % cols.length];
}

return runDocTest(function (doc) {
doc.font('tests/fonts/Roboto-Regular.ttf');
for (let i = -360; i < 360; i += 5) {
const withLabel = i % 45 === 0;
const margin = i < 0 ? ' ' : ' ';
let text = `—————————> ${withLabel ? `${margin}${i}` : ''}`;

if (withLabel) {
const bounds = doc.boundsOfString(text, 200, 200, { rotation: i });
doc
.save()
.rect(bounds.x, bounds.y, bounds.width, bounds.height)
.stroke(randColor())
.restore();
}

doc
.save()
.fill(withLabel ? 'red' : 'black')
.text(text, 200, 200, { rotation: i })
.restore();
}
doc.save().circle(200, 200, 1).fill('blue').restore();
});
});
});

0 comments on commit f446608

Please sign in to comment.