Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable rotatable text #1589

Merged
merged 1 commit into from
Jan 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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();
});
});
});
Loading