Skip to content

Commit f446608

Browse files
authored
Enable rotatable text (#1589)
1 parent 6603d6a commit f446608

File tree

5 files changed

+190
-2
lines changed

5 files changed

+190
-2
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
- Fix precision rounding issues in LineWrapper
66
- Add support for dynamic sizing
7+
- Add support for rotatable text
78

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

docs/text.md

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

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

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

141146
The `list` method creates a bulleted list. It accepts as arguments an array of strings,

lib/mixins/text.js

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ export default {
5151
}
5252
};
5353

54+
// We can save some bytes if there is no rotation
55+
if (options.rotation !== 0) {
56+
this.save();
57+
this.rotate(-options.rotation, { origin: [this.x, this.y] });
58+
}
59+
5460
// word wrapping
5561
if (options.width) {
5662
let wrapper = this._wrapper;
@@ -72,6 +78,9 @@ export default {
7278
}
7379
}
7480

81+
// Cleanup if there was a rotation
82+
if (options.rotation !== 0) this.restore();
83+
7584
return this;
7685
},
7786

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

96+
/**
97+
* Compute the bounding box of a string
98+
* based on what will actually be rendered by `doc.text()`
99+
*
100+
* @param string - The string
101+
* @param x - X position of text (defaults to this.x)
102+
* @param y - Y position of text (defaults to this.y)
103+
* @param options - Any text options (The same you would apply to `doc.text()`)
104+
* @returns {{x: number, y: number, width: number, height: number}}
105+
*/
106+
boundsOfString(string, x, y, options) {
107+
options = this._initOptions(x, y, options);
108+
({ x, y } = this);
109+
const lineGap = options.lineGap ?? this._lineGap ?? 0;
110+
const lineHeight = this.currentLineHeight(true) + lineGap;
111+
let contentWidth = 0,
112+
contentHeight = 0;
113+
114+
// Convert text to a string
115+
string = String(string ?? '');
116+
117+
// if the wordSpacing option is specified, remove multiple consecutive spaces
118+
if (options.wordSpacing) {
119+
string = string.replace(/\s{2,}/g, ' ');
120+
}
121+
122+
// word wrapping
123+
if (options.width) {
124+
let wrapper = new LineWrapper(this, options);
125+
wrapper.on('line', (text, options) => {
126+
contentHeight += lineHeight;
127+
text = text.replace(/\n/g, '');
128+
129+
if (text.length) {
130+
// handle options
131+
let wordSpacing = options.wordSpacing ?? 0;
132+
const characterSpacing = options.characterSpacing ?? 0;
133+
134+
// justify alignments
135+
if (options.width && options.align === 'justify') {
136+
// calculate the word spacing value
137+
const words = text.trim().split(/\s+/);
138+
const textWidth = this.widthOfString(
139+
text.replace(/\s+/g, ''),
140+
options,
141+
);
142+
const spaceWidth = this.widthOfString(' ') + characterSpacing;
143+
wordSpacing = Math.max(
144+
0,
145+
(options.lineWidth - textWidth) / Math.max(1, words.length - 1) -
146+
spaceWidth,
147+
);
148+
}
149+
150+
// calculate the actual rendered width of the string after word and character spacing
151+
contentWidth = Math.max(
152+
contentWidth,
153+
options.textWidth +
154+
wordSpacing * (options.wordCount - 1) +
155+
characterSpacing * (text.length - 1),
156+
);
157+
}
158+
});
159+
wrapper.wrap(string, options);
160+
} else {
161+
// render paragraphs as single lines
162+
for (let line of string.split('\n')) {
163+
const lineWidth = this.widthOfString(line, options);
164+
contentHeight += lineHeight;
165+
contentWidth = Math.max(contentWidth, lineWidth);
166+
}
167+
}
168+
169+
/**
170+
* Rotates around top left corner
171+
* [x1,y1] > [x2,y2]
172+
* ⌃ ⌄
173+
* [x4,y4] < [x3,y3]
174+
*/
175+
if (options.rotation === 0) {
176+
// No rotation so we can use the existing values
177+
return { x, y, width: contentWidth, height: contentHeight };
178+
// Use fast computation without explicit trig
179+
} else if (options.rotation === 90) {
180+
return {
181+
x: x,
182+
y: y - contentWidth,
183+
width: contentHeight,
184+
height: contentWidth,
185+
};
186+
} else if (options.rotation === 180) {
187+
return {
188+
x: x - contentWidth,
189+
y: y - contentHeight,
190+
width: contentWidth,
191+
height: contentHeight,
192+
};
193+
} else if (options.rotation === 270) {
194+
return {
195+
x: x - contentHeight,
196+
y: y,
197+
width: contentHeight,
198+
height: contentWidth,
199+
};
200+
}
201+
202+
// Non-trivial values so time for trig
203+
const angleRad = (options.rotation * Math.PI) / 180;
204+
const cos = Math.cos(angleRad);
205+
const sin = Math.sin(angleRad);
206+
207+
const x1 = x;
208+
const y1 = y;
209+
const x2 = x + contentWidth * cos;
210+
const y2 = y - contentWidth * sin;
211+
const x3 = x + contentWidth * cos + contentHeight * sin;
212+
const y3 = y - contentWidth * sin + contentHeight * cos;
213+
const x4 = x + contentHeight * sin;
214+
const y4 = y + contentHeight * cos;
215+
216+
const xMin = Math.min(x1, x2, x3, x4);
217+
const xMax = Math.max(x1, x2, x3, x4);
218+
const yMin = Math.min(y1, y2, y3, y4);
219+
const yMax = Math.max(y1, y2, y3, y4);
220+
221+
return { x: xMin, y: yMin, width: xMax - xMin, height: yMax - yMin };
222+
},
223+
87224
heightOfString(text, options) {
88225
const { x, y } = this;
89226

@@ -272,6 +409,10 @@ export default {
272409
result.columnGap = 18;
273410
} // 1/4 inch
274411

412+
// Normalize rotation to between 0 - 360
413+
result.rotation = Number(options.rotation ?? 0) % 360;
414+
if (result.rotation < 0) result.rotation += 360;
415+
275416
return result;
276417
},
277418

138 KB
Loading

tests/visual/text.spec.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,4 +101,45 @@ describe('text', function() {
101101
});
102102
});
103103

104+
test('rotated text', function () {
105+
let i = 0;
106+
const cols = [
107+
'#292f56',
108+
'#492d73',
109+
'#8c2f94',
110+
'#b62d78',
111+
'#d82d31',
112+
'#e69541',
113+
'#ecf157',
114+
'#acfa70',
115+
];
116+
function randColor() {
117+
return cols[i++ % cols.length];
118+
}
119+
120+
return runDocTest(function (doc) {
121+
doc.font('tests/fonts/Roboto-Regular.ttf');
122+
for (let i = -360; i < 360; i += 5) {
123+
const withLabel = i % 45 === 0;
124+
const margin = i < 0 ? ' ' : ' ';
125+
let text = `—————————> ${withLabel ? `${margin}${i}` : ''}`;
126+
127+
if (withLabel) {
128+
const bounds = doc.boundsOfString(text, 200, 200, { rotation: i });
129+
doc
130+
.save()
131+
.rect(bounds.x, bounds.y, bounds.width, bounds.height)
132+
.stroke(randColor())
133+
.restore();
134+
}
135+
136+
doc
137+
.save()
138+
.fill(withLabel ? 'red' : 'black')
139+
.text(text, 200, 200, { rotation: i })
140+
.restore();
141+
}
142+
doc.save().circle(200, 200, 1).fill('blue').restore();
143+
});
144+
});
104145
});

0 commit comments

Comments
 (0)