diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ff98e05..438fb666 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/text.md b/docs/text.md index bbfdaad5..411505ae 100644 --- a/docs/text.md +++ b/docs/text.md @@ -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, diff --git a/lib/mixins/text.js b/lib/mixins/text.js index 9ee6d28c..d8bc693c 100644 --- a/lib/mixins/text.js +++ b/lib/mixins/text.js @@ -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; }, diff --git a/tests/visual/__image_snapshots__/text-spec-js-text-rotated-text-1-snap.png b/tests/visual/__image_snapshots__/text-spec-js-text-rotated-text-1-snap.png new file mode 100644 index 00000000..3f84cba9 Binary files /dev/null and b/tests/visual/__image_snapshots__/text-spec-js-text-rotated-text-1-snap.png differ diff --git a/tests/visual/text.spec.js b/tests/visual/text.spec.js index 5e13c649..f4941040 100644 --- a/tests/visual/text.spec.js +++ b/tests/visual/text.spec.js @@ -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(); + }); + }); });