Enable rotatable text (#1589)

This commit is contained in:
Jake Holland 2025-01-15 13:06:15 +00:00 committed by GitHub
parent 6603d6ae76
commit f4466085d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 190 additions and 2 deletions

View File

@ -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

View File

@ -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)
@ -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,

View File

@ -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;
@ -72,6 +78,9 @@ export default {
}
}
// Cleanup if there was a rotation
if (options.rotation !== 0) this.restore();
return this;
},
@ -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;
@ -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;
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

View File

@ -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();
});
});
});