Skip to content

第8章:文本渲染

8.1 章节概述

在前面的章节中,我们学习了如何绑制图形、处理图像、进行合成。但在实际应用中,文本是不可或缺的元素——标签、标题、说明文字、数据展示等都需要文本渲染。

Canvas 提供了完整的文本渲染能力,虽然不如 HTML 文本那样灵活(没有自动换行、没有 CSS 样式),但足够满足大多数图形应用的需求。

本章将深入讲解:

  • 文本绘制基础:fillText 和 strokeText
  • 字体设置:font 属性的完整语法
  • 文本对齐:水平和垂直对齐方式
  • 文本测量:获取文本尺寸
  • 高级技巧:多行文本、文本效果、文本路径
  • 实战应用:标签、数据可视化、艺术字

8.2 文本绘制基础

8.2.1 fillText - 填充文本

fillText() 使用当前填充样式绘制实心文本:

javascript
ctx.fillText(text, x, y);
ctx.fillText(text, x, y, maxWidth);  // 可选:限制最大宽度
参数说明
text要绘制的文本字符串
x文本起始位置的 X 坐标
y文本基线位置的 Y 坐标
maxWidth可选,文本最大宽度(超出会被压缩)
javascript
// 基础文本绘制
ctx.fillStyle = '#333';
ctx.font = '24px Arial';
ctx.fillText('Hello, Canvas!', 50, 50);

// 带最大宽度限制
ctx.fillText('This is a very long text that will be compressed', 50, 100, 200);

8.2.2 strokeText - 描边文本

strokeText() 使用当前描边样式绘制空心文本:

javascript
ctx.strokeText(text, x, y);
ctx.strokeText(text, x, y, maxWidth);
javascript
// 描边文本
ctx.strokeStyle = '#FF6B6B';
ctx.lineWidth = 2;
ctx.font = '48px Arial';
ctx.strokeText('Outline Text', 50, 100);

// 同时填充和描边(先填充后描边效果更好)
ctx.font = '48px Arial';
ctx.fillStyle = '#4D7CFF';
ctx.fillText('Styled Text', 50, 180);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.strokeText('Styled Text', 50, 180);

8.2.3 文本绘制位置的理解

重要fillText(text, x, y) 中的 y 是**基线(baseline)**位置,不是文本顶部!

文本 "Hello"
                    ← 顶线 (top)
    H   ll          ← 上升部分
    H e ll o        ← 基线 (baseline) ← y 参数指定这里
    H    l          ← 下降部分 (如 g, y, p)
                    ← 底线 (bottom)
javascript
// 可视化基线位置
const y = 100;

// 绘制参考线
ctx.strokeStyle = 'red';
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(canvas.width, y);
ctx.stroke();

// 绘制文本
ctx.fillStyle = '#333';
ctx.font = '36px Arial';
ctx.fillText('Hello Baseline', 50, y);
// 文本的底部会在红线上方,部分字母(如 y, g)可能在线下方

8.3 字体设置

8.3.1 font 属性

ctx.font 使用 CSS font 语法设置字体:

javascript
ctx.font = '[font-style] [font-variant] [font-weight] font-size[/line-height] font-family';
部分可选值必需
font-stylenormal, italic, oblique可选
font-variantnormal, small-caps可选
font-weightnormal, bold, 100-900可选
font-size12px, 1em, 100% 等必需
line-height1.5, 20px 等可选
font-familyArial, "Times New Roman" 等必需

8.3.2 常用字体设置示例

javascript
// 基础
ctx.font = '16px Arial';

// 粗体
ctx.font = 'bold 16px Arial';

// 斜体
ctx.font = 'italic 16px Arial';

// 粗斜体
ctx.font = 'italic bold 16px Arial';

// 特定字重
ctx.font = '300 16px Arial';  // 细体
ctx.font = '700 16px Arial';  // 粗体

// 多字体族(带回退)
ctx.font = '16px "Helvetica Neue", Helvetica, Arial, sans-serif';

// 小型大写字母
ctx.font = 'small-caps 16px Arial';

8.3.3 字体单位

javascript
// 像素(最常用)
ctx.font = '24px Arial';

// em(相对于父元素)
ctx.font = '1.5em Arial';

// rem(相对于根元素)
ctx.font = '1.5rem Arial';

// 百分比
ctx.font = '150% Arial';

// pt(印刷点)
ctx.font = '12pt Arial';

8.3.4 使用 Web 字体

javascript
// 确保字体已加载
async function useWebFont() {
    // 使用 FontFace API 加载字体
    const font = new FontFace('CustomFont', 'url(/fonts/custom.woff2)');
    await font.load();
    document.fonts.add(font);
    
    // 现在可以使用
    ctx.font = '24px CustomFont';
    ctx.fillText('Custom Font Text', 50, 50);
}

// 或等待所有字体加载
document.fonts.ready.then(() => {
    ctx.font = '24px CustomFont';
    ctx.fillText('Text with custom font', 50, 50);
});

8.3.5 动态字体大小

javascript
/**
 * 根据容器宽度自动调整字体大小
 */
function fitTextToWidth(ctx, text, maxWidth, fontFamily = 'Arial') {
    let fontSize = 100;  // 从大字号开始
    
    do {
        ctx.font = `${fontSize}px ${fontFamily}`;
        const metrics = ctx.measureText(text);
        
        if (metrics.width <= maxWidth) {
            break;
        }
        
        fontSize--;
    } while (fontSize > 1);
    
    return fontSize;
}

// 使用
const text = 'This text will fit';
const fontSize = fitTextToWidth(ctx, text, 300);
ctx.font = `${fontSize}px Arial`;
ctx.fillText(text, 50, 100);

8.4 文本对齐

8.4.1 水平对齐 - textAlign

textAlign 控制文本相对于 x 坐标的水平对齐方式:

javascript
ctx.textAlign = 'start' | 'end' | 'left' | 'right' | 'center';
说明
start文本起点在 x 位置(默认,与文字方向有关)
end文本终点在 x 位置
left文本左边缘在 x 位置
right文本右边缘在 x 位置
center文本中心在 x 位置
textAlign 效果图示 (x = 中间虚线位置):


left    │Hello World


center  │   Hello World
        │   (文本中心对齐)


right   │           Hello World
javascript
const x = canvas.width / 2;

// 绘制参考线
ctx.strokeStyle = '#ccc';
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, canvas.height);
ctx.stroke();

ctx.font = '24px Arial';
ctx.fillStyle = '#333';

// 左对齐
ctx.textAlign = 'left';
ctx.fillText('Left aligned', x, 50);

// 居中对齐
ctx.textAlign = 'center';
ctx.fillText('Center aligned', x, 100);

// 右对齐
ctx.textAlign = 'right';
ctx.fillText('Right aligned', x, 150);

8.4.2 垂直对齐 - textBaseline

textBaseline 控制文本相对于 y 坐标的垂直对齐方式:

javascript
ctx.textBaseline = 'alphabetic' | 'top' | 'hanging' | 'middle' | 'ideographic' | 'bottom';
说明
alphabetic字母基线(默认)
top文本顶部
hanging悬挂基线(用于某些文字系统)
middle文本中间
ideographic表意文字基线
bottom文本底部
textBaseline 效果图示 (y = 水平线位置):

─────────────────── top
     H e l l o      
─────────────────── hanging
     H e l l o      
─────────────────── middle
     H e l l o      
─────────────────── alphabetic (默认)
     H e l l o      
         y p g      (下降部分)
─────────────────── bottom
javascript
const y = canvas.height / 2;

// 绘制参考线
ctx.strokeStyle = '#ccc';
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(canvas.width, y);
ctx.stroke();

ctx.font = '24px Arial';
ctx.fillStyle = '#333';

const baselines = ['top', 'hanging', 'middle', 'alphabetic', 'ideographic', 'bottom'];

baselines.forEach((baseline, index) => {
    ctx.textBaseline = baseline;
    ctx.fillText(baseline, 50 + index * 120, y);
});

8.4.3 实现完美居中

javascript
/**
 * 在矩形区域内居中绘制文本
 */
function drawCenteredText(ctx, text, x, y, width, height) {
    ctx.save();
    
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    
    const centerX = x + width / 2;
    const centerY = y + height / 2;
    
    ctx.fillText(text, centerX, centerY);
    
    ctx.restore();
}

// 在 Canvas 正中央绘制文本
drawCenteredText(ctx, 'Centered!', 0, 0, canvas.width, canvas.height);

8.5 文本测量

8.5.1 measureText 方法

measureText() 返回文本的测量信息:

javascript
const metrics = ctx.measureText(text);

8.5.2 TextMetrics 对象

javascript
const metrics = ctx.measureText('Hello World');

// 常用属性
console.log(metrics.width);                    // 文本宽度
console.log(metrics.actualBoundingBoxLeft);    // 从基线到左边界的距离
console.log(metrics.actualBoundingBoxRight);   // 从基线到右边界的距离
console.log(metrics.actualBoundingBoxAscent);  // 从基线到顶部的距离
console.log(metrics.actualBoundingBoxDescent); // 从基线到底部的距离

// 计算实际高度
const actualHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;

TextMetrics 属性图示

                        actualBoundingBoxAscent

    ┌─────────────────────────┴─────────────────────────┐
    │                    Hello World                     │
    ├─────────────────────────┬─────────────────────────┤
                              │ ← baseline (y 位置)
    └─────────────────────────┬─────────────────────────┘

                        actualBoundingBoxDescent
    
    ←─────── width ───────→

8.5.3 获取文本边界框

javascript
/**
 * 获取文本的完整边界框
 */
function getTextBoundingBox(ctx, text, x, y) {
    const metrics = ctx.measureText(text);
    
    return {
        x: x - metrics.actualBoundingBoxLeft,
        y: y - metrics.actualBoundingBoxAscent,
        width: metrics.actualBoundingBoxLeft + metrics.actualBoundingBoxRight,
        height: metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent
    };
}

// 使用:绘制文本及其边界框
ctx.font = '36px Arial';
const text = 'Hello World';
const x = 50, y = 100;

// 获取边界框
const bbox = getTextBoundingBox(ctx, text, x, y);

// 绘制背景框
ctx.fillStyle = '#eee';
ctx.fillRect(bbox.x, bbox.y, bbox.width, bbox.height);

// 绘制文本
ctx.fillStyle = '#333';
ctx.fillText(text, x, y);

8.5.4 文本是否超出容器

javascript
/**
 * 检查文本是否会超出指定宽度
 */
function isTextOverflow(ctx, text, maxWidth) {
    const metrics = ctx.measureText(text);
    return metrics.width > maxWidth;
}

/**
 * 截断文本并添加省略号
 */
function truncateText(ctx, text, maxWidth, ellipsis = '...') {
    if (!isTextOverflow(ctx, text, maxWidth)) {
        return text;
    }
    
    const ellipsisWidth = ctx.measureText(ellipsis).width;
    const availableWidth = maxWidth - ellipsisWidth;
    
    let truncated = '';
    for (let i = 0; i < text.length; i++) {
        const testText = text.substring(0, i + 1);
        if (ctx.measureText(testText).width > availableWidth) {
            break;
        }
        truncated = testText;
    }
    
    return truncated + ellipsis;
}

// 使用
ctx.font = '16px Arial';
const longText = 'This is a very long text that needs to be truncated';
const truncated = truncateText(ctx, longText, 200);
ctx.fillText(truncated, 50, 50);  // "This is a very long..."

8.6 多行文本

Canvas 的 fillText() 不支持自动换行,需要手动处理。

8.6.1 手动换行

javascript
/**
 * 绘制多行文本
 */
function drawMultilineText(ctx, text, x, y, lineHeight) {
    const lines = text.split('\n');
    
    lines.forEach((line, index) => {
        ctx.fillText(line, x, y + index * lineHeight);
    });
}

// 使用
ctx.font = '16px Arial';
const text = `第一行文本
第二行文本
第三行文本`;

drawMultilineText(ctx, text, 50, 50, 24);

8.6.2 自动换行

javascript
/**
 * 自动换行绘制文本
 * @param {string} text - 文本内容
 * @param {number} x - X 坐标
 * @param {number} y - Y 坐标
 * @param {number} maxWidth - 最大宽度
 * @param {number} lineHeight - 行高
 */
function wrapText(ctx, text, x, y, maxWidth, lineHeight) {
    const words = text.split(' ');
    let line = '';
    let currentY = y;
    const lines = [];
    
    for (let i = 0; i < words.length; i++) {
        const testLine = line + words[i] + ' ';
        const metrics = ctx.measureText(testLine);
        
        if (metrics.width > maxWidth && i > 0) {
            lines.push({ text: line.trim(), y: currentY });
            line = words[i] + ' ';
            currentY += lineHeight;
        } else {
            line = testLine;
        }
    }
    
    lines.push({ text: line.trim(), y: currentY });
    
    // 绘制
    lines.forEach(lineObj => {
        ctx.fillText(lineObj.text, x, lineObj.y);
    });
    
    return lines;
}

// 使用
ctx.font = '16px Arial';
const paragraph = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.';
wrapText(ctx, paragraph, 50, 50, 300, 24);

8.6.3 支持中文的自动换行

javascript
/**
 * 支持中文的自动换行
 * 中文每个字符都可以作为换行点
 */
function wrapTextChinese(ctx, text, x, y, maxWidth, lineHeight) {
    const lines = [];
    let currentLine = '';
    let currentY = y;
    
    for (let i = 0; i < text.length; i++) {
        const char = text[i];
        
        // 遇到换行符
        if (char === '\n') {
            lines.push({ text: currentLine, y: currentY });
            currentLine = '';
            currentY += lineHeight;
            continue;
        }
        
        const testLine = currentLine + char;
        const metrics = ctx.measureText(testLine);
        
        if (metrics.width > maxWidth && currentLine.length > 0) {
            lines.push({ text: currentLine, y: currentY });
            currentLine = char;
            currentY += lineHeight;
        } else {
            currentLine = testLine;
        }
    }
    
    if (currentLine) {
        lines.push({ text: currentLine, y: currentY });
    }
    
    // 绘制
    lines.forEach(lineObj => {
        ctx.fillText(lineObj.text, x, lineObj.y);
    });
    
    return lines;
}

// 使用
ctx.font = '16px "Microsoft YaHei", sans-serif';
const chineseText = '这是一段很长的中文文本,需要自动换行显示。Canvas 本身不支持自动换行,所以我们需要自己实现这个功能。';
wrapTextChinese(ctx, chineseText, 50, 50, 300, 28);

8.6.4 文本框组件

javascript
/**
 * 文本框组件 - 支持背景、内边距、对齐
 */
class TextBox {
    constructor(ctx, options = {}) {
        this.ctx = ctx;
        this.x = options.x || 0;
        this.y = options.y || 0;
        this.width = options.width || 200;
        this.maxHeight = options.maxHeight || Infinity;
        this.padding = options.padding || 10;
        this.lineHeight = options.lineHeight || 1.4;
        this.font = options.font || '16px Arial';
        this.textColor = options.textColor || '#333';
        this.backgroundColor = options.backgroundColor || null;
        this.borderColor = options.borderColor || null;
        this.borderWidth = options.borderWidth || 1;
        this.textAlign = options.textAlign || 'left';
    }
    
    draw(text) {
        const { ctx, x, y, width, padding, font, textColor } = this;
        
        ctx.save();
        ctx.font = font;
        
        // 计算行高(像素)
        const fontSize = parseInt(font);
        const lineHeightPx = fontSize * this.lineHeight;
        
        // 换行处理
        const maxTextWidth = width - padding * 2;
        const lines = this.wrapText(text, maxTextWidth);
        
        // 计算总高度
        const textHeight = lines.length * lineHeightPx;
        const totalHeight = Math.min(textHeight + padding * 2, this.maxHeight);
        
        // 绘制背景
        if (this.backgroundColor) {
            ctx.fillStyle = this.backgroundColor;
            ctx.fillRect(x, y, width, totalHeight);
        }
        
        // 绘制边框
        if (this.borderColor) {
            ctx.strokeStyle = this.borderColor;
            ctx.lineWidth = this.borderWidth;
            ctx.strokeRect(x, y, width, totalHeight);
        }
        
        // 绘制文本
        ctx.fillStyle = textColor;
        ctx.textBaseline = 'top';
        ctx.textAlign = this.textAlign;
        
        const textX = this.textAlign === 'center' ? x + width / 2 :
                      this.textAlign === 'right' ? x + width - padding :
                      x + padding;
        
        lines.forEach((line, index) => {
            const lineY = y + padding + index * lineHeightPx;
            if (lineY + lineHeightPx <= y + totalHeight) {
                ctx.fillText(line, textX, lineY);
            }
        });
        
        ctx.restore();
        
        return { width, height: totalHeight };
    }
    
    wrapText(text, maxWidth) {
        const lines = [];
        let currentLine = '';
        
        for (const char of text) {
            if (char === '\n') {
                lines.push(currentLine);
                currentLine = '';
                continue;
            }
            
            const testLine = currentLine + char;
            if (this.ctx.measureText(testLine).width > maxWidth && currentLine) {
                lines.push(currentLine);
                currentLine = char;
            } else {
                currentLine = testLine;
            }
        }
        
        if (currentLine) {
            lines.push(currentLine);
        }
        
        return lines;
    }
}

// 使用
const textBox = new TextBox(ctx, {
    x: 50,
    y: 50,
    width: 300,
    padding: 15,
    font: '16px Arial',
    backgroundColor: '#f5f5f5',
    borderColor: '#ddd',
    textAlign: 'left'
});

textBox.draw('这是一个文本框组件示例,支持自动换行、背景色、边框和内边距。');

8.7 文本效果

8.7.1 文本阴影

javascript
ctx.font = '48px Arial';
ctx.fillStyle = '#333';

// 设置阴影
ctx.shadowColor = 'rgba(0, 0, 0, 0.5)';
ctx.shadowBlur = 4;
ctx.shadowOffsetX = 2;
ctx.shadowOffsetY = 2;

ctx.fillText('Shadow Text', 50, 100);

// 重置阴影
ctx.shadowColor = 'transparent';

8.7.2 渐变文本

javascript
ctx.font = 'bold 48px Arial';

// 创建线性渐变
const gradient = ctx.createLinearGradient(50, 50, 50, 100);
gradient.addColorStop(0, '#FF6B6B');
gradient.addColorStop(0.5, '#4D7CFF');
gradient.addColorStop(1, '#51CF66');

ctx.fillStyle = gradient;
ctx.fillText('Gradient Text', 50, 100);

8.7.3 描边 + 填充

javascript
ctx.font = 'bold 72px Arial';

// 先填充
ctx.fillStyle = '#4D7CFF';
ctx.fillText('OUTLINED', 50, 100);

// 再描边
ctx.strokeStyle = '#fff';
ctx.lineWidth = 3;
ctx.strokeText('OUTLINED', 50, 100);

8.7.4 3D 文字效果

javascript
/**
 * 绘制 3D 文字
 */
function draw3DText(ctx, text, x, y, depth, color, shadowColor) {
    ctx.font = 'bold 72px Arial';
    
    // 绘制阴影层(从后往前)
    for (let i = depth; i > 0; i--) {
        ctx.fillStyle = shadowColor;
        ctx.fillText(text, x + i, y + i);
    }
    
    // 绘制主文字
    ctx.fillStyle = color;
    ctx.fillText(text, x, y);
}

draw3DText(ctx, '3D TEXT', 50, 150, 5, '#4D7CFF', '#2a5298');

8.7.5 霓虹灯效果

javascript
/**
 * 霓虹灯文字效果
 */
function drawNeonText(ctx, text, x, y, color) {
    ctx.font = 'bold 48px Arial';
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    
    // 多层发光
    const glowLayers = [
        { blur: 20, alpha: 0.3 },
        { blur: 10, alpha: 0.5 },
        { blur: 5, alpha: 0.7 },
        { blur: 2, alpha: 0.9 }
    ];
    
    glowLayers.forEach(layer => {
        ctx.save();
        ctx.shadowColor = color;
        ctx.shadowBlur = layer.blur;
        ctx.globalAlpha = layer.alpha;
        ctx.fillStyle = color;
        ctx.fillText(text, x, y);
        ctx.restore();
    });
    
    // 中心亮色
    ctx.fillStyle = '#fff';
    ctx.fillText(text, x, y);
}

// 黑色背景
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, canvas.width, canvas.height);

drawNeonText(ctx, 'NEON', canvas.width / 2, 100, '#FF6B6B');
drawNeonText(ctx, 'GLOW', canvas.width / 2, 180, '#4D7CFF');

8.7.6 打字机效果

javascript
/**
 * 打字机效果
 */
class TypewriterEffect {
    constructor(ctx, text, x, y, options = {}) {
        this.ctx = ctx;
        this.text = text;
        this.x = x;
        this.y = y;
        this.font = options.font || '24px monospace';
        this.color = options.color || '#333';
        this.speed = options.speed || 50;  // 毫秒/字符
        this.currentIndex = 0;
        this.isComplete = false;
    }
    
    start() {
        this.interval = setInterval(() => {
            this.currentIndex++;
            this.draw();
            
            if (this.currentIndex >= this.text.length) {
                clearInterval(this.interval);
                this.isComplete = true;
            }
        }, this.speed);
    }
    
    draw() {
        const displayText = this.text.substring(0, this.currentIndex);
        
        // 清除区域
        this.ctx.clearRect(this.x - 5, this.y - 30, 500, 40);
        
        this.ctx.font = this.font;
        this.ctx.fillStyle = this.color;
        this.ctx.fillText(displayText, this.x, this.y);
        
        // 绘制光标
        if (!this.isComplete) {
            const cursorX = this.x + this.ctx.measureText(displayText).width;
            this.ctx.fillRect(cursorX + 2, this.y - 20, 2, 25);
        }
    }
}

// 使用
const typewriter = new TypewriterEffect(ctx, 'Hello, World!', 50, 100, {
    font: '24px monospace',
    speed: 100
});
typewriter.start();

8.8 实战应用

8.8.1 数据标签

javascript
/**
 * 在图表上绘制数据标签
 */
function drawDataLabel(ctx, value, x, y, options = {}) {
    const {
        font = '12px Arial',
        textColor = '#333',
        backgroundColor = '#fff',
        padding = 4,
        borderRadius = 3
    } = options;
    
    ctx.font = font;
    const text = typeof value === 'number' ? value.toFixed(1) : value;
    const metrics = ctx.measureText(text);
    const width = metrics.width + padding * 2;
    const height = parseInt(font) + padding * 2;
    
    // 绘制背景
    ctx.fillStyle = backgroundColor;
    roundRect(ctx, x - width / 2, y - height / 2, width, height, borderRadius);
    ctx.fill();
    
    // 绘制文本
    ctx.fillStyle = textColor;
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText(text, x, y);
}

// 辅助函数:圆角矩形
function roundRect(ctx, x, y, width, height, radius) {
    ctx.beginPath();
    ctx.moveTo(x + radius, y);
    ctx.lineTo(x + width - radius, y);
    ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
    ctx.lineTo(x + width, y + height - radius);
    ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
    ctx.lineTo(x + radius, y + height);
    ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
    ctx.lineTo(x, y + radius);
    ctx.quadraticCurveTo(x, y, x + radius, y);
    ctx.closePath();
}

8.8.2 工具提示

javascript
/**
 * 工具提示组件
 */
class Tooltip {
    constructor(ctx) {
        this.ctx = ctx;
        this.visible = false;
        this.x = 0;
        this.y = 0;
        this.text = '';
    }
    
    show(x, y, text) {
        this.x = x;
        this.y = y;
        this.text = text;
        this.visible = true;
    }
    
    hide() {
        this.visible = false;
    }
    
    draw() {
        if (!this.visible) return;
        
        const { ctx, x, y, text } = this;
        
        ctx.save();
        
        ctx.font = '14px Arial';
        const padding = 8;
        const metrics = ctx.measureText(text);
        const width = metrics.width + padding * 2;
        const height = 14 + padding * 2;
        
        // 确定位置(避免超出边界)
        let tooltipX = x + 10;
        let tooltipY = y - height - 5;
        
        if (tooltipX + width > ctx.canvas.width) {
            tooltipX = x - width - 10;
        }
        if (tooltipY < 0) {
            tooltipY = y + 20;
        }
        
        // 阴影
        ctx.shadowColor = 'rgba(0, 0, 0, 0.2)';
        ctx.shadowBlur = 4;
        ctx.shadowOffsetY = 2;
        
        // 背景
        ctx.fillStyle = '#333';
        roundRect(ctx, tooltipX, tooltipY, width, height, 4);
        ctx.fill();
        
        // 文本
        ctx.shadowColor = 'transparent';
        ctx.fillStyle = '#fff';
        ctx.textBaseline = 'middle';
        ctx.fillText(text, tooltipX + padding, tooltipY + height / 2);
        
        ctx.restore();
    }
}

8.8.3 文字云

javascript
/**
 * 简单的文字云
 */
function drawWordCloud(ctx, words, centerX, centerY, maxRadius) {
    // words: [{ text: 'word', weight: 10 }, ...]
    
    // 按权重排序
    const sorted = [...words].sort((a, b) => b.weight - a.weight);
    const maxWeight = sorted[0].weight;
    
    // 螺旋放置
    let angle = 0;
    let radius = 0;
    const placed = [];
    
    sorted.forEach(word => {
        // 根据权重计算字号
        const fontSize = Math.max(12, (word.weight / maxWeight) * 48);
        ctx.font = `${fontSize}px Arial`;
        
        const metrics = ctx.measureText(word.text);
        const wordWidth = metrics.width;
        const wordHeight = fontSize;
        
        // 寻找不重叠的位置
        let attempts = 0;
        let x, y;
        
        while (attempts < 500) {
            x = centerX + radius * Math.cos(angle);
            y = centerY + radius * Math.sin(angle);
            
            // 检查是否重叠
            const overlaps = placed.some(p => 
                Math.abs(x - p.x) < (wordWidth + p.width) / 2 &&
                Math.abs(y - p.y) < (wordHeight + p.height) / 2
            );
            
            if (!overlaps && 
                x - wordWidth / 2 > 0 && 
                x + wordWidth / 2 < ctx.canvas.width &&
                y - wordHeight / 2 > 0 && 
                y + wordHeight / 2 < ctx.canvas.height) {
                break;
            }
            
            angle += 0.5;
            radius += 0.5;
            attempts++;
        }
        
        // 绘制
        ctx.fillStyle = `hsl(${Math.random() * 360}, 70%, 50%)`;
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        ctx.fillText(word.text, x, y);
        
        placed.push({ x, y, width: wordWidth, height: wordHeight });
    });
}

// 使用
const words = [
    { text: 'Canvas', weight: 100 },
    { text: 'JavaScript', weight: 80 },
    { text: 'HTML5', weight: 70 },
    { text: '图形', weight: 60 },
    { text: '动画', weight: 50 },
    // ... 更多词
];

drawWordCloud(ctx, words, canvas.width / 2, canvas.height / 2, 200);

8.9 本章小结

本章详细介绍了 Canvas 的文本渲染系统:

核心方法

方法作用
fillText(text, x, y)填充文本
strokeText(text, x, y)描边文本
measureText(text)测量文本尺寸

重要属性

属性作用
font字体样式(CSS 语法)
textAlign水平对齐
textBaseline垂直对齐

关键技巧

  • 基线理解:y 坐标指向基线
  • 完美居中:textAlign='center' + textBaseline='middle'
  • 自动换行:需要手动实现
  • 文本测量:measureText() 获取尺寸

8.10 练习题

基础练习

  1. 实现居中显示的标题文本

  2. 创建带背景色和圆角的标签组件

  3. 实现支持换行的段落文本显示

进阶练习

  1. 实现打字机动画效果

  2. 创建带阴影和渐变的艺术字

挑战练习

  1. 构建一个完整的文本编辑器组件,支持:
    • 多行文本输入
    • 光标显示
    • 文本选择高亮

下一章预告:在第9章中,我们将学习动画与帧循环——如何创建流畅的 Canvas 动画。


文档版本:v1.0
字数统计:约 13,000 字
代码示例:35+ 个

如有转载或 CV 的请标注本站原文地址