html2canvas-pro
Version:
Screenshots with JavaScript. Next generation!
575 lines • 29.7 kB
JavaScript
"use strict";
/**
* Text Renderer
*
* Handles rendering of text content including:
* - Text with letter spacing
* - Text decorations (underline, overline, line-through)
* - Text shadows
* - Webkit line clamp
* - Text overflow ellipsis
* - Paint order (fill/stroke)
* - Font styles
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.TextRenderer = exports.hasCJKCharacters = void 0;
const text_1 = require("../../css/layout/text");
const color_utilities_1 = require("../../css/types/color-utilities");
const parser_1 = require("../../css/syntax/parser");
// iOS font fix - see https://github.com/niklasvh/html2canvas/pull/2645
const iOSBrokenFonts = ['-apple-system', 'system-ui'];
/**
* Detect CJK (Chinese, Japanese, Korean) characters in a string.
* CJK characters use the ideographic baseline in browsers, which differs
* from the alphabetic baseline used for Latin script.
*
* Covers:
* U+2E80–U+2FFF CJK Radicals Supplement, Kangxi Radicals
* U+3000–U+30FF CJK Symbols & Punctuation (。、「」…), Hiragana, Katakana
* U+3400–U+4DBF CJK Extension A
* U+4E00–U+9FFF CJK Unified Ideographs (most common Chinese/Japanese/Korean)
* U+AC00–U+D7AF Hangul Syllables
* U+F900–U+FAFF CJK Compatibility Ideographs
* U+FF01–U+FFEF Halfwidth and Fullwidth Forms (A B 1 2 ! ? etc.)
*/
const CJK_CHAR_REGEX = /[\u2E80-\u2FFF\u3000-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF\uFF01-\uFFEF]/;
const hasCJKCharacters = (text) => CJK_CHAR_REGEX.test(text);
exports.hasCJKCharacters = hasCJKCharacters;
/**
* Detect iOS version from user agent
* Returns null if not iOS or version cannot be determined
*/
const getIOSVersion = () => {
if (typeof navigator === 'undefined') {
return null;
}
const userAgent = navigator.userAgent;
// Check if it's iOS or iPadOS
// iPadOS 13+ may identify as Macintosh, check for touch support
const isIOS = /iPhone|iPad|iPod/.test(userAgent);
const isIPadOS = /Macintosh/.test(userAgent) && navigator.maxTouchPoints && navigator.maxTouchPoints > 1;
if (!isIOS && !isIPadOS) {
return null;
}
// Extract version number from various iOS user agent formats:
// - "iPhone OS 15_0" or "iPhone OS 15_0_1"
// - "CPU OS 15_0 like Mac OS X"
// - "CPU iPhone OS 15_0 like Mac OS X"
// - "Version/15.0" (for iPadOS)
const patterns = [
/(?:iPhone|CPU(?:\siPhone)?)\sOS\s(\d+)[\._](\d+)/, // iPhone OS, CPU OS, CPU iPhone OS
/Version\/(\d+)\.(\d+)/ // Version/15.0 (iPadOS)
];
for (const pattern of patterns) {
const match = userAgent.match(pattern);
if (match && match[1]) {
return parseInt(match[1], 10);
}
}
return null;
};
const fixIOSSystemFonts = (fontFamilies) => {
const iosVersion = getIOSVersion();
// On iOS 15.0 and 15.1, system fonts have rendering issues
// Fixed in iOS 17+
if (iosVersion !== null && iosVersion >= 15 && iosVersion < 17) {
return fontFamilies.map((fontFamily) => iOSBrokenFonts.indexOf(fontFamily) !== -1
? `-apple-system, "Helvetica Neue", Arial, sans-serif`
: fontFamily);
}
return fontFamilies;
};
/**
* Text Renderer
*
* Specialized renderer for text content.
* Extracted from CanvasRenderer to improve code organization and maintainability.
*/
class TextRenderer {
constructor(deps) {
this.ctx = deps.ctx;
// context stored but not used directly in this renderer
this.options = deps.options;
}
/**
* Iterate grapheme clusters one-by-one, applying correct letter-spacing and
* per-script baseline for each character.
*
* Issue #73: When letter-spacing is non-zero, text must be rendered character by
* character. This helper centralises two fixes applied during that iteration:
* 1. Add `letterSpacing` to each character's advance width (was previously
* omitted, causing characters to render without any spacing).
* 2. Switch to the ideographic baseline for CJK glyphs so their vertical
* position matches how browsers lay them out in the DOM.
*
* The `renderFn` callback receives (letter, x, y) and performs the actual draw
* call (fillText or strokeText), allowing fill and stroke paths to share one
* implementation.
*/
iterateLettersWithLetterSpacing(text, letterSpacing, baseline, renderFn) {
const letters = (0, text_1.segmentGraphemes)(text.text);
const y = text.bounds.top + baseline;
let left = text.bounds.left;
for (const letter of letters) {
if ((0, exports.hasCJKCharacters)(letter)) {
const savedBaseline = this.ctx.textBaseline;
this.ctx.textBaseline = 'ideographic';
renderFn(letter, left, y);
this.ctx.textBaseline = savedBaseline;
}
else {
renderFn(letter, left, y);
}
left += this.ctx.measureText(letter).width + letterSpacing;
}
}
/**
* Render text with letter-spacing applied (fill pass).
* When letterSpacing is 0 the whole string is drawn in one call; otherwise each
* grapheme is drawn individually so spacing and CJK baseline are applied correctly.
*/
renderTextWithLetterSpacing(text, letterSpacing, baseline) {
if (letterSpacing === 0) {
this.ctx.fillText(text.text, text.bounds.left, text.bounds.top + baseline);
}
else {
this.iterateLettersWithLetterSpacing(text, letterSpacing, baseline, (letter, x, y) => {
this.ctx.fillText(letter, x, y);
});
}
}
/**
* Helper method to render text with paint order support
* Reduces code duplication in line-clamp and normal rendering
*/
renderTextBoundWithPaintOrder(textBound, styles, paintOrderLayers) {
paintOrderLayers.forEach((paintOrderLayer) => {
switch (paintOrderLayer) {
case 0 /* PAINT_ORDER_LAYER.FILL */:
this.ctx.fillStyle = (0, color_utilities_1.asString)(styles.color);
this.renderTextWithLetterSpacing(textBound, styles.letterSpacing, styles.fontSize.number);
break;
case 1 /* PAINT_ORDER_LAYER.STROKE */:
if (styles.webkitTextStrokeWidth && textBound.text.trim().length) {
this.ctx.strokeStyle = (0, color_utilities_1.asString)(styles.webkitTextStrokeColor);
this.ctx.lineWidth = styles.webkitTextStrokeWidth;
this.ctx.lineJoin =
typeof window !== 'undefined' && !!window.chrome ? 'miter' : 'round';
if (styles.letterSpacing === 0) {
this.ctx.strokeText(textBound.text, textBound.bounds.left, textBound.bounds.top + styles.fontSize.number);
}
else {
this.iterateLettersWithLetterSpacing(textBound, styles.letterSpacing, styles.fontSize.number, (letter, x, y) => this.ctx.strokeText(letter, x, y));
}
this.ctx.strokeStyle = '';
this.ctx.lineWidth = 0;
this.ctx.lineJoin = 'miter';
}
break;
}
});
}
renderTextDecoration(bounds, styles) {
this.ctx.fillStyle = (0, color_utilities_1.asString)(styles.textDecorationColor || styles.color);
// Calculate decoration line thickness
let thickness = 1; // default
if (typeof styles.textDecorationThickness === 'number') {
thickness = styles.textDecorationThickness;
}
else if (styles.textDecorationThickness === 'from-font') {
// Use a reasonable default based on font size
thickness = Math.max(1, Math.floor(styles.fontSize.number * 0.05));
}
// 'auto' uses default thickness of 1
// Calculate underline offset
let underlineOffset = 0;
if (typeof styles.textUnderlineOffset === 'number') {
// It's a pixel value
underlineOffset = styles.textUnderlineOffset;
}
// 'auto' uses default offset of 0
const decorationStyle = styles.textDecorationStyle;
styles.textDecorationLine.forEach((textDecorationLine) => {
let y = 0;
switch (textDecorationLine) {
case 1 /* TEXT_DECORATION_LINE.UNDERLINE */:
y = bounds.top + bounds.height - thickness + underlineOffset;
break;
case 2 /* TEXT_DECORATION_LINE.OVERLINE */:
y = bounds.top;
break;
case 3 /* TEXT_DECORATION_LINE.LINE_THROUGH */:
y = bounds.top + (bounds.height / 2 - thickness / 2);
break;
default:
return;
}
this.drawDecorationLine(bounds.left, y, bounds.width, thickness, decorationStyle);
});
}
drawDecorationLine(x, y, width, thickness, style) {
switch (style) {
case 0 /* TEXT_DECORATION_STYLE.SOLID */:
// Solid line (default)
this.ctx.fillRect(x, y, width, thickness);
break;
case 1 /* TEXT_DECORATION_STYLE.DOUBLE */:
// Double line
const gap = Math.max(1, thickness);
this.ctx.fillRect(x, y, width, thickness);
this.ctx.fillRect(x, y + thickness + gap, width, thickness);
break;
case 2 /* TEXT_DECORATION_STYLE.DOTTED */:
// Dotted line
this.ctx.save();
this.ctx.beginPath();
this.ctx.setLineDash([thickness, thickness * 2]);
this.ctx.lineWidth = thickness;
this.ctx.strokeStyle = this.ctx.fillStyle;
this.ctx.moveTo(x, y + thickness / 2);
this.ctx.lineTo(x + width, y + thickness / 2);
this.ctx.stroke();
this.ctx.restore();
break;
case 3 /* TEXT_DECORATION_STYLE.DASHED */:
// Dashed line
this.ctx.save();
this.ctx.beginPath();
this.ctx.setLineDash([thickness * 3, thickness * 2]);
this.ctx.lineWidth = thickness;
this.ctx.strokeStyle = this.ctx.fillStyle;
this.ctx.moveTo(x, y + thickness / 2);
this.ctx.lineTo(x + width, y + thickness / 2);
this.ctx.stroke();
this.ctx.restore();
break;
case 4 /* TEXT_DECORATION_STYLE.WAVY */:
// Wavy line (approximation using quadratic curves)
this.ctx.save();
this.ctx.beginPath();
this.ctx.lineWidth = thickness;
this.ctx.strokeStyle = this.ctx.fillStyle;
const amplitude = thickness * 2;
const wavelength = thickness * 4;
let currentX = x;
this.ctx.moveTo(currentX, y + thickness / 2);
while (currentX < x + width) {
const nextX = Math.min(currentX + wavelength / 2, x + width);
this.ctx.quadraticCurveTo(currentX + wavelength / 4, y + thickness / 2 - amplitude, nextX, y + thickness / 2);
currentX = nextX;
if (currentX < x + width) {
const nextX2 = Math.min(currentX + wavelength / 2, x + width);
this.ctx.quadraticCurveTo(currentX + wavelength / 4, y + thickness / 2 + amplitude, nextX2, y + thickness / 2);
currentX = nextX2;
}
}
this.ctx.stroke();
this.ctx.restore();
break;
default:
// Fallback to solid
this.ctx.fillRect(x, y, width, thickness);
}
}
// Helper method to truncate text and add ellipsis if needed
truncateTextWithEllipsis(text, maxWidth, letterSpacing) {
// Use the Unicode ellipsis character (U+2026) whose width the browser measures
// as a single glyph, matching native text-overflow behaviour more closely.
const ellipsis = '\u2026';
const ellipsisWidth = this.ctx.measureText(ellipsis).width;
// Segment into grapheme clusters so multi-byte characters (emoji, composed
// sequences) are never split mid-character.
const graphemes = (0, text_1.segmentGraphemes)(text);
if (letterSpacing === 0) {
// Measure the whole candidate string for accuracy: the browser applies
// kerning and ligatures when rendering multiple glyphs together, so
// measuring them as one string is more precise than summing individual widths.
// Binary search reduces measurements from O(n) to O(log n).
const fits = (n) => this.ctx.measureText(graphemes.slice(0, n).join('')).width + ellipsisWidth <= maxWidth;
let lo = 0;
let hi = graphemes.length;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (fits(mid)) {
lo = mid;
}
else {
hi = mid - 1;
}
}
return graphemes.slice(0, lo).join('') + ellipsis;
}
else {
let width = ellipsisWidth;
const result = [];
for (const letter of graphemes) {
const glyphWidth = this.ctx.measureText(letter).width;
// Check against glyph width only (no trailing spacing): letter-spacing
// is applied *between* characters, not after the final glyph. Using
// `glyphWidth + letterSpacing` would incorrectly discard letters that
// fit as the last character before the ellipsis.
if (width + glyphWidth > maxWidth) {
break;
}
result.push(letter);
// Accumulate glyph + inter-character spacing for the *next* iteration.
width += glyphWidth + letterSpacing;
}
return result.join('') + ellipsis;
}
}
/**
* Create font style array
* Public method used by list rendering
*/
createFontStyle(styles) {
const fontVariant = styles.fontVariant
.filter((variant) => variant === 'normal' || variant === 'small-caps')
.join('');
const fontFamily = fixIOSSystemFonts(styles.fontFamily).join(', ');
const fontSize = (0, parser_1.isDimensionToken)(styles.fontSize)
? `${styles.fontSize.number}${styles.fontSize.unit}`
: `${styles.fontSize.number}px`;
return [
[styles.fontStyle, fontVariant, styles.fontWeight, fontSize, fontFamily].join(' '),
fontFamily,
fontSize
];
}
async renderTextNode(text, styles, containerBounds) {
const [font] = this.createFontStyle(styles);
this.ctx.font = font;
this.ctx.direction = styles.direction === 1 /* DIRECTION.RTL */ ? 'rtl' : 'ltr';
this.ctx.textAlign = 'left';
this.ctx.textBaseline = 'alphabetic';
const paintOrder = styles.paintOrder;
// Calculate line height for text layout detection (used by both line-clamp and ellipsis)
const lineHeight = styles.fontSize.number * 1.5;
// Check if we need to apply -webkit-line-clamp
// This limits text to a specific number of lines with ellipsis
const shouldApplyLineClamp = styles.webkitLineClamp > 0 &&
(styles.display & 2 /* DISPLAY.BLOCK */) !== 0 &&
styles.overflowY === 1 /* OVERFLOW.HIDDEN */ &&
text.textBounds.length > 0;
if (shouldApplyLineClamp) {
// Group text bounds by lines based on their Y position
const lines = [];
let currentLine = [];
let currentLineTop = text.textBounds[0].bounds.top;
text.textBounds.forEach((tb) => {
// If this text bound is on a different line, start a new line
if (Math.abs(tb.bounds.top - currentLineTop) >= lineHeight * 0.5) {
if (currentLine.length > 0) {
lines.push(currentLine);
}
currentLine = [tb];
currentLineTop = tb.bounds.top;
}
else {
currentLine.push(tb);
}
});
// Don't forget the last line
if (currentLine.length > 0) {
lines.push(currentLine);
}
// Only render up to webkitLineClamp lines
const maxLines = styles.webkitLineClamp;
if (lines.length > maxLines) {
// Render only the first (maxLines - 1) complete lines
for (let i = 0; i < maxLines - 1; i++) {
lines[i].forEach((textBound) => {
this.renderTextBoundWithPaintOrder(textBound, styles, paintOrder);
});
}
// For the last line, truncate with ellipsis
const lastLine = lines[maxLines - 1];
if (lastLine && lastLine.length > 0 && containerBounds) {
const lastLineText = lastLine.map((tb) => tb.text).join('');
const firstBound = lastLine[0];
const availableWidth = containerBounds.width - (firstBound.bounds.left - containerBounds.left);
const truncatedText = this.truncateTextWithEllipsis(lastLineText, availableWidth, styles.letterSpacing);
// Build TextBounds once; reused for fill and stroke without re-allocating.
const truncatedBounds = new text_1.TextBounds(truncatedText, firstBound.bounds);
paintOrder.forEach((paintOrderLayer) => {
switch (paintOrderLayer) {
case 0 /* PAINT_ORDER_LAYER.FILL */:
this.ctx.fillStyle = (0, color_utilities_1.asString)(styles.color);
if (styles.letterSpacing === 0) {
this.ctx.fillText(truncatedText, firstBound.bounds.left, firstBound.bounds.top + styles.fontSize.number);
}
else {
this.iterateLettersWithLetterSpacing(truncatedBounds, styles.letterSpacing, styles.fontSize.number, (letter, x, y) => this.ctx.fillText(letter, x, y));
}
break;
case 1 /* PAINT_ORDER_LAYER.STROKE */:
if (styles.webkitTextStrokeWidth && truncatedText.trim().length) {
this.ctx.strokeStyle = (0, color_utilities_1.asString)(styles.webkitTextStrokeColor);
this.ctx.lineWidth = styles.webkitTextStrokeWidth;
this.ctx.lineJoin =
typeof window !== 'undefined' && !!window.chrome ? 'miter' : 'round';
if (styles.letterSpacing === 0) {
this.ctx.strokeText(truncatedText, firstBound.bounds.left, firstBound.bounds.top + styles.fontSize.number);
}
else {
this.iterateLettersWithLetterSpacing(truncatedBounds, styles.letterSpacing, styles.fontSize.number, (letter, x, y) => this.ctx.strokeText(letter, x, y));
}
this.ctx.strokeStyle = '';
this.ctx.lineWidth = 0;
this.ctx.lineJoin = 'miter';
}
break;
}
});
}
return; // Don't render anything else
}
// If lines.length <= maxLines, fall through to normal rendering
}
// Check if we need to apply text-overflow: ellipsis
// Issue #203: Only apply ellipsis for single-line text overflow
// Multi-line text truncation (like -webkit-line-clamp) should not be affected
const shouldApplyEllipsis = styles.textOverflow === 1 /* TEXT_OVERFLOW.ELLIPSIS */ &&
containerBounds &&
styles.overflowX === 1 /* OVERFLOW.HIDDEN */ &&
text.textBounds.length > 0;
// Calculate total text width if ellipsis might be needed
let needsEllipsis = false;
let truncatedText = '';
if (shouldApplyEllipsis) {
// Check if all text bounds are on approximately the same line (single-line scenario)
// For multi-line text (like -webkit-line-clamp), textBounds will have different Y positions
const firstTop = text.textBounds[0].bounds.top;
const isSingleLine = text.textBounds.every((tb) => Math.abs(tb.bounds.top - firstTop) < lineHeight * 0.5);
if (isSingleLine) {
// Measure the full text content
// Note: text.textBounds may contain whitespace characters from HTML formatting
// We need to collapse them like the browser does for white-space: nowrap
let fullText = text.textBounds.map((tb) => tb.text).join('');
// Collapse whitespace: replace sequences of whitespace (including newlines) with single spaces
// and trim leading/trailing whitespace
fullText = fullText.replace(/\s+/g, ' ').trim();
const fullTextWidth = this.ctx.measureText(fullText).width;
const availableWidth = containerBounds.width;
if (fullTextWidth > availableWidth) {
needsEllipsis = true;
truncatedText = this.truncateTextWithEllipsis(fullText, availableWidth, styles.letterSpacing);
}
}
}
// If ellipsis is needed, render the truncated text once
if (needsEllipsis) {
const firstBound = text.textBounds[0];
// Build TextBounds once; reused across paint layers and every shadow pass
// to avoid repeated allocation inside forEach callbacks.
const truncatedBounds = new text_1.TextBounds(truncatedText, firstBound.bounds);
paintOrder.forEach((paintOrderLayer) => {
switch (paintOrderLayer) {
case 0 /* PAINT_ORDER_LAYER.FILL */: {
this.ctx.fillStyle = (0, color_utilities_1.asString)(styles.color);
if (styles.letterSpacing === 0) {
this.ctx.fillText(truncatedText, firstBound.bounds.left, firstBound.bounds.top + styles.fontSize.number);
}
else {
this.iterateLettersWithLetterSpacing(truncatedBounds, styles.letterSpacing, styles.fontSize.number, (letter, x, y) => this.ctx.fillText(letter, x, y));
}
const textShadows = styles.textShadow;
if (textShadows.length && truncatedText.trim().length) {
textShadows
.slice(0)
.reverse()
.forEach((textShadow) => {
this.ctx.shadowColor = (0, color_utilities_1.asString)(textShadow.color);
this.ctx.shadowOffsetX = textShadow.offsetX.number * this.options.scale;
this.ctx.shadowOffsetY = textShadow.offsetY.number * this.options.scale;
this.ctx.shadowBlur = textShadow.blur.number;
if (styles.letterSpacing === 0) {
this.ctx.fillText(truncatedText, firstBound.bounds.left, firstBound.bounds.top + styles.fontSize.number);
}
else {
this.iterateLettersWithLetterSpacing(truncatedBounds, styles.letterSpacing, styles.fontSize.number, (letter, x, y) => this.ctx.fillText(letter, x, y));
}
});
this.ctx.shadowColor = '';
this.ctx.shadowOffsetX = 0;
this.ctx.shadowOffsetY = 0;
this.ctx.shadowBlur = 0;
}
break;
}
case 1 /* PAINT_ORDER_LAYER.STROKE */:
if (styles.webkitTextStrokeWidth && truncatedText.trim().length) {
this.ctx.strokeStyle = (0, color_utilities_1.asString)(styles.webkitTextStrokeColor);
this.ctx.lineWidth = styles.webkitTextStrokeWidth;
this.ctx.lineJoin =
typeof window !== 'undefined' && !!window.chrome ? 'miter' : 'round';
if (styles.letterSpacing === 0) {
this.ctx.strokeText(truncatedText, firstBound.bounds.left, firstBound.bounds.top + styles.fontSize.number);
}
else {
this.iterateLettersWithLetterSpacing(truncatedBounds, styles.letterSpacing, styles.fontSize.number, (letter, x, y) => this.ctx.strokeText(letter, x, y));
}
this.ctx.strokeStyle = '';
this.ctx.lineWidth = 0;
this.ctx.lineJoin = 'miter';
}
break;
}
});
return;
}
// Normal rendering (no ellipsis needed)
text.textBounds.forEach((text) => {
paintOrder.forEach((paintOrderLayer) => {
switch (paintOrderLayer) {
case 0 /* PAINT_ORDER_LAYER.FILL */: {
this.ctx.fillStyle = (0, color_utilities_1.asString)(styles.color);
this.renderTextWithLetterSpacing(text, styles.letterSpacing, styles.fontSize.number);
const textShadows = styles.textShadow;
if (textShadows.length && text.text.trim().length) {
textShadows
.slice(0)
.reverse()
.forEach((textShadow) => {
this.ctx.shadowColor = (0, color_utilities_1.asString)(textShadow.color);
this.ctx.shadowOffsetX = textShadow.offsetX.number * this.options.scale;
this.ctx.shadowOffsetY = textShadow.offsetY.number * this.options.scale;
this.ctx.shadowBlur = textShadow.blur.number;
this.renderTextWithLetterSpacing(text, styles.letterSpacing, styles.fontSize.number);
});
this.ctx.shadowColor = '';
this.ctx.shadowOffsetX = 0;
this.ctx.shadowOffsetY = 0;
this.ctx.shadowBlur = 0;
}
if (styles.textDecorationLine.length) {
this.renderTextDecoration(text.bounds, styles);
}
break;
}
case 1 /* PAINT_ORDER_LAYER.STROKE */: {
if (styles.webkitTextStrokeWidth && text.text.trim().length) {
this.ctx.strokeStyle = (0, color_utilities_1.asString)(styles.webkitTextStrokeColor);
this.ctx.lineWidth = styles.webkitTextStrokeWidth;
this.ctx.lineJoin =
typeof window !== 'undefined' && !!window.chrome ? 'miter' : 'round';
const baseline = styles.fontSize.number;
if (styles.letterSpacing === 0) {
this.ctx.strokeText(text.text, text.bounds.left, text.bounds.top + baseline);
}
else {
this.iterateLettersWithLetterSpacing(text, styles.letterSpacing, baseline, (letter, x, y) => this.ctx.strokeText(letter, x, y));
}
this.ctx.strokeStyle = '';
this.ctx.lineWidth = 0;
this.ctx.lineJoin = 'miter';
}
break;
}
}
});
});
}
}
exports.TextRenderer = TextRenderer;
//# sourceMappingURL=text-renderer.js.map