mirror of
https://github.com/foliojs/pdfkit.git
synced 2025-12-08 20:15:54 +00:00
Enable rotatable text (#1589)
This commit is contained in:
parent
6603d6ae76
commit
f4466085d3
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 |
@ -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…
x
Reference in New Issue
Block a user