Skip to content

Web 浏览器示例

在 Web 浏览器中使用 HTML Layout Parser 进行实时 HTML 布局解析和 Canvas 渲染。

准备工作

方法 1:直接导入(推荐)

对于 Vite 用户,首先在 vite.config.ts 中添加:

typescript
export default defineConfig({
  optimizeDeps: {
    exclude: ['html-layout-parser']
  }
})

然后使用直接导入:

typescript
// 直接从 npm 包导入
import { HtmlLayoutParser } from 'html-layout-parser/web';

async function basicParsing() {
  const parser = new HtmlLayoutParser();
  await parser.init(); // 自动从 node_modules 加载 WASM
  // ... 其余代码
}

方法 2:手动复制(备选)

⚠️ 仅在直接导入遇到打包器问题时使用。

直接导入方法(方法1)现在是推荐方法。手动复制作为边缘情况的备选方案。

bash
# 仅在直接导入不工作时
cp -r node_modules/html-layout-parser/web public/html-layout-parser
typescript
import { HtmlLayoutParser } from 'html-layout-parser/web';

async function basicParsing() {
  const parser = new HtmlLayoutParser();
  await parser.init('/html-layout-parser/html_layout_parser.mjs'); // 自定义路径
  // ... 其余代码
}

基本 Web 使用

HTML 结构

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>HTML Layout Parser Web 示例</title>
</head>
<body>
    <div class="container">
        <h1>HTML Layout Parser 演示</h1>
        
        <div class="input-section">
            <label for="html-input">HTML 内容:</label>
            <textarea id="html-input" rows="10" cols="50">
<div style="color: #333; font-size: 18px;">
    <h2 style="color: #007acc;">欢迎使用 HTML Layout Parser</h2>
    <p style="color: #666; font-size: 14px;">这是一个高性能的 HTML 布局解析器。</p>
    <div style="background-color: #f0f0f0; padding: 10px;">
        <span style="color: red;">红色文字</span>
        <span style="color: blue; font-weight: bold;">蓝色粗体</span>
    </div>
</div>
            </textarea>
            
            <label for="css-input">CSS 样式 (可选):</label>
            <textarea id="css-input" rows="5" cols="50">
h2 { text-decoration: underline; }
p { font-style: italic; }
            </textarea>
            
            <button id="parse-btn">解析并渲染</button>
        </div>
        
        <div class="output-section">
            <canvas id="output-canvas" width="800" height="600"></canvas>
            <div id="info-panel"></div>
        </div>
    </div>
    
    <script type="module" src="./app.js"></script>
</body>
</html>

CSS 样式

css
/* styles.css */
.container {
    max-width: 1200px;
    margin: 0 auto;
    padding: 20px;
    font-family: Arial, sans-serif;
}

.input-section {
    margin-bottom: 20px;
}

.input-section label {
    display: block;
    margin-top: 10px;
    margin-bottom: 5px;
    font-weight: bold;
}

.input-section textarea {
    width: 100%;
    padding: 10px;
    border: 1px solid #ccc;
    border-radius: 4px;
    font-family: monospace;
}

.input-section button {
    margin-top: 10px;
    padding: 10px 20px;
    background-color: #007acc;
    color: white;
    border: none;
    border-radius: 4px;
    cursor: pointer;
}

.input-section button:hover {
    background-color: #005a9e;
}

.output-section {
    display: flex;
    gap: 20px;
}

#output-canvas {
    border: 1px solid #ccc;
    background-color: white;
}

#info-panel {
    flex: 1;
    padding: 10px;
    background-color: #f5f5f5;
    border-radius: 4px;
    font-family: monospace;
    font-size: 12px;
}

JavaScript 应用

typescript
// app.ts
// 从环境特定入口点导入
import { HtmlLayoutParser } from 'html-layout-parser/web';

class WebParserDemo {
    private parser: HtmlLayoutParser;
    private canvas: HTMLCanvasElement;
    private ctx: CanvasRenderingContext2D;
    private infoPanel: HTMLElement;
    private isInitialized = false;

    constructor() {
        this.parser = new HtmlLayoutParser();
        this.canvas = document.getElementById('output-canvas') as HTMLCanvasElement;
        this.ctx = this.canvas.getContext('2d')!;
        this.infoPanel = document.getElementById('info-panel')!;
        
        this.setupEventListeners();
        this.init();
    }

    private async init(): Promise<void> {
        try {
            this.updateInfo('正在初始化解析器...');
            
            // 初始化解析器
            await this.parser.init();
            
            // 加载默认字体
            await this.loadDefaultFont();
            
            this.isInitialized = true;
            this.updateInfo('解析器初始化完成');
            
            // 执行初始解析
            this.parseAndRender();
            
        } catch (error) {
            this.updateInfo(`初始化失败: ${error.message}`);
            console.error('初始化错误:', error);
        }
    }

    private async loadDefaultFont(): Promise<void> {
        try {
            // 尝试加载 Arial 字体
            const fontResponse = await fetch('/fonts/arial.ttf');
            
            if (fontResponse.ok) {
                const fontData = new Uint8Array(await fontResponse.arrayBuffer());
                const fontId = this.parser.loadFont(fontData, 'Arial');
                
                if (fontId > 0) {
                    this.parser.setDefaultFont(fontId);
                    this.updateInfo('Arial 字体加载成功');
                } else {
                    throw new Error('字体加载失败');
                }
            } else {
                throw new Error('字体文件下载失败');
            }
        } catch (error) {
            this.updateInfo(`字体加载失败: ${error.message},将使用系统默认字体`);
            console.warn('字体加载警告:', error);
        }
    }

    private setupEventListeners(): void {
        const parseBtn = document.getElementById('parse-btn')!;
        const htmlInput = document.getElementById('html-input') as HTMLTextAreaElement;
        const cssInput = document.getElementById('css-input') as HTMLTextAreaElement;

        parseBtn.addEventListener('click', () => {
            if (this.isInitialized) {
                this.parseAndRender();
            } else {
                this.updateInfo('解析器尚未初始化完成,请稍候...');
            }
        });

        // 实时解析(防抖)
        let debounceTimer: number;
        const debouncedParse = () => {
            clearTimeout(debounceTimer);
            debounceTimer = window.setTimeout(() => {
                if (this.isInitialized) {
                    this.parseAndRender();
                }
            }, 500);
        };

        htmlInput.addEventListener('input', debouncedParse);
        cssInput.addEventListener('input', debouncedParse);
    }

    private parseAndRender(): void {
        try {
            const htmlInput = document.getElementById('html-input') as HTMLTextAreaElement;
            const cssInput = document.getElementById('css-input') as HTMLTextAreaElement;
            
            const html = htmlInput.value.trim();
            const css = cssInput.value.trim();
            
            if (!html) {
                this.updateInfo('请输入 HTML 内容');
                return;
            }

            this.updateInfo('正在解析...');
            
            const startTime = performance.now();
            
            // 解析 HTML
            const result = this.parser.parseWithDiagnostics(html, {
                viewportWidth: this.canvas.width,
                css: css || undefined,
                enableMetrics: true
            });
            
            const endTime = performance.now();
            
            if (result.success) {
                // 渲染到 Canvas
                this.renderToCanvas(result.data);
                
                // 更新信息面板
                this.updateParseInfo(result, endTime - startTime);
                
            } else {
                this.updateInfo('解析失败:\n' + 
                    result.errors?.map(e => `[${e.code}] ${e.message}`).join('\n'));
            }
            
        } catch (error) {
            this.updateInfo(`解析异常: ${error.message}`);
            console.error('解析错误:', error);
        }
    }

    private renderToCanvas(layouts: CharLayout[]): void {
        // 清空画布
        this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
        
        // 设置背景
        this.ctx.fillStyle = '#ffffff';
        this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
        
        // 渲染字符
        for (const char of layouts) {
            this.renderCharacter(char);
        }
    }

    private renderCharacter(char: CharLayout): void {
        // 保存当前状态
        this.ctx.save();
        
        try {
            // 设置字体
            this.ctx.font = `${char.fontStyle} ${char.fontWeight} ${char.fontSize}px ${char.fontFamily}`;
            
            // 设置透明度
            this.ctx.globalAlpha = char.opacity;
            
            // 绘制背景
            if (char.backgroundColor !== '#00000000') {
                this.ctx.fillStyle = char.backgroundColor;
                this.ctx.fillRect(
                    char.x, 
                    char.y - char.fontSize, 
                    char.width, 
                    char.height
                );
            }
            
            // 绘制字符
            this.ctx.fillStyle = char.color;
            this.ctx.fillText(char.character, char.x, char.baseline);
            
            // 绘制文本装饰
            this.renderTextDecoration(char);
            
        } finally {
            // 恢复状态
            this.ctx.restore();
        }
    }

    private renderTextDecoration(char: CharLayout): void {
        const decoration = char.textDecoration;
        
        if (!decoration.underline && !decoration.overline && !decoration.lineThrough) {
            return;
        }
        
        this.ctx.strokeStyle = decoration.color || char.color;
        this.ctx.lineWidth = decoration.thickness;
        
        // 设置线条样式
        switch (decoration.style) {
            case 'dashed':
                this.ctx.setLineDash([5, 5]);
                break;
            case 'dotted':
                this.ctx.setLineDash([2, 2]);
                break;
            default:
                this.ctx.setLineDash([]);
        }
        
        this.ctx.beginPath();
        
        if (decoration.underline) {
            const y = char.baseline + 2;
            this.ctx.moveTo(char.x, y);
            this.ctx.lineTo(char.x + char.width, y);
        }
        
        if (decoration.overline) {
            const y = char.y;
            this.ctx.moveTo(char.x, y);
            this.ctx.lineTo(char.x + char.width, y);
        }
        
        if (decoration.lineThrough) {
            const y = char.baseline - char.fontSize / 3;
            this.ctx.moveTo(char.x, y);
            this.ctx.lineTo(char.x + char.width, y);
        }
        
        this.ctx.stroke();
        this.ctx.setLineDash([]); // 重置线条样式
    }

    private updateParseInfo(result: any, parseTime: number): void {
        const layouts = result.data;
        const metrics = result.metrics;
        
        let info = `解析完成!\n`;
        info += `解析时间: ${parseTime.toFixed(2)}ms\n`;
        info += `字符数量: ${layouts.length}\n`;
        
        if (metrics) {
            info += `HTML 解析: ${metrics.parseTime.toFixed(2)}ms\n`;
            info += `布局计算: ${metrics.layoutTime.toFixed(2)}ms\n`;
            info += `序列化: ${metrics.serializationTime.toFixed(2)}ms\n`;
            info += `解析速度: ${metrics.charsPerSecond.toFixed(0)} 字符/秒\n`;
        }
        
        // 字体统计
        const fontUsage: Record<string, number> = {};
        layouts.forEach((char: CharLayout) => {
            fontUsage[char.fontFamily] = (fontUsage[char.fontFamily] || 0) + 1;
        });
        
        info += `\n字体使用:\n`;
        Object.entries(fontUsage).forEach(([font, count]) => {
            info += `  ${font}: ${count} 字符\n`;
        });
        
        // 内存使用
        const memoryMetrics = this.parser.getMemoryMetrics();
        if (memoryMetrics) {
            info += `\n内存使用:\n`;
            info += `  总计: ${(memoryMetrics.totalMemoryUsage / 1024 / 1024).toFixed(2)} MB\n`;
            info += `  字体: ${memoryMetrics.fontCount} 个\n`;
        }
        
        // 缓存统计
        const cacheStats = this.parser.getCacheStats();
        info += `\n缓存统计:\n`;
        info += `  命中率: ${(cacheStats.hitRate * 100).toFixed(1)}%\n`;
        info += `  条目数: ${cacheStats.entries}\n`;
        
        this.updateInfo(info);
    }

    private updateInfo(message: string): void {
        this.infoPanel.textContent = message;
    }

    // 清理资源
    public destroy(): void {
        if (this.parser) {
            this.parser.destroy();
        }
    }
}

// 启动应用
const demo = new WebParserDemo();

// 页面卸载时清理资源
window.addEventListener('beforeunload', () => {
    demo.destroy();
});

高级 Web 示例

交互式编辑器

typescript
// interactive-editor.ts
class InteractiveHTMLEditor {
    private parser: HtmlLayoutParser;
    private canvas: HTMLCanvasElement;
    private ctx: CanvasRenderingContext2D;
    private layouts: CharLayout[] = [];
    private selectedChar: CharLayout | null = null;

    constructor(canvasId: string) {
        this.parser = new HtmlLayoutParser();
        this.canvas = document.getElementById(canvasId) as HTMLCanvasElement;
        this.ctx = this.canvas.getContext('2d')!;
        
        this.setupCanvasEvents();
        this.init();
    }

    private setupCanvasEvents(): void {
        this.canvas.addEventListener('click', (event) => {
            const rect = this.canvas.getBoundingClientRect();
            const x = event.clientX - rect.left;
            const y = event.clientY - rect.top;
            
            this.handleCanvasClick(x, y);
        });

        this.canvas.addEventListener('mousemove', (event) => {
            const rect = this.canvas.getBoundingClientRect();
            const x = event.clientX - rect.left;
            const y = event.clientY - rect.top;
            
            this.handleCanvasHover(x, y);
        });
    }

    private handleCanvasClick(x: number, y: number): void {
        // 查找点击位置的字符
        const clickedChar = this.findCharacterAt(x, y);
        
        if (clickedChar) {
            this.selectedChar = clickedChar;
            this.highlightCharacter(clickedChar);
            this.showCharacterInfo(clickedChar);
        } else {
            this.selectedChar = null;
            this.redraw();
        }
    }

    private handleCanvasHover(x: number, y: number): void {
        const hoveredChar = this.findCharacterAt(x, y);
        
        if (hoveredChar) {
            this.canvas.style.cursor = 'pointer';
            this.showTooltip(hoveredChar, x, y);
        } else {
            this.canvas.style.cursor = 'default';
            this.hideTooltip();
        }
    }

    private findCharacterAt(x: number, y: number): CharLayout | null {
        for (const char of this.layouts) {
            if (x >= char.x && 
                x <= char.x + char.width && 
                y >= char.y - char.fontSize && 
                y <= char.y) {
                return char;
            }
        }
        return null;
    }

    private highlightCharacter(char: CharLayout): void {
        this.redraw();
        
        // 绘制高亮框
        this.ctx.strokeStyle = '#ff0000';
        this.ctx.lineWidth = 2;
        this.ctx.strokeRect(
            char.x - 1, 
            char.y - char.fontSize - 1, 
            char.width + 2, 
            char.height + 2
        );
    }

    private showCharacterInfo(char: CharLayout): void {
        const info = `
字符: "${char.character}"
位置: (${char.x.toFixed(1)}, ${char.y.toFixed(1)})
尺寸: ${char.width.toFixed(1)} × ${char.height.toFixed(1)}
字体: ${char.fontFamily} ${char.fontSize}px
颜色: ${char.color}
        `.trim();
        
        const infoElement = document.getElementById('char-info');
        if (infoElement) {
            infoElement.textContent = info;
        }
    }

    private showTooltip(char: CharLayout, x: number, y: number): void {
        // 创建或更新工具提示
        let tooltip = document.getElementById('canvas-tooltip') as HTMLElement;
        
        if (!tooltip) {
            tooltip = document.createElement('div');
            tooltip.id = 'canvas-tooltip';
            tooltip.style.cssText = `
                position: absolute;
                background: rgba(0, 0, 0, 0.8);
                color: white;
                padding: 5px 10px;
                border-radius: 4px;
                font-size: 12px;
                pointer-events: none;
                z-index: 1000;
            `;
            document.body.appendChild(tooltip);
        }
        
        tooltip.textContent = `"${char.character}" (${char.fontFamily} ${char.fontSize}px)`;
        tooltip.style.left = `${x + 10}px`;
        tooltip.style.top = `${y - 30}px`;
        tooltip.style.display = 'block';
    }

    private hideTooltip(): void {
        const tooltip = document.getElementById('canvas-tooltip');
        if (tooltip) {
            tooltip.style.display = 'none';
        }
    }

    private redraw(): void {
        this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
        
        for (const char of this.layouts) {
            this.renderCharacter(char);
        }
    }

    // ... 其他方法与基本示例相同
}

实时预览组件

typescript
// real-time-preview.ts
class RealTimePreview {
    private parser: HtmlLayoutParser;
    private canvas: HTMLCanvasElement;
    private htmlEditor: HTMLTextAreaElement;
    private cssEditor: HTMLTextAreaElement;
    private debounceTimer: number = 0;

    constructor() {
        this.parser = new HtmlLayoutParser();
        this.canvas = document.getElementById('preview-canvas') as HTMLCanvasElement;
        this.htmlEditor = document.getElementById('html-editor') as HTMLTextAreaElement;
        this.cssEditor = document.getElementById('css-editor') as HTMLTextAreaElement;
        
        this.setupRealtimePreview();
        this.init();
    }

    private setupRealtimePreview(): void {
        const updatePreview = () => {
            clearTimeout(this.debounceTimer);
            this.debounceTimer = window.setTimeout(() => {
                this.updatePreview();
            }, 300); // 300ms 防抖
        };

        this.htmlEditor.addEventListener('input', updatePreview);
        this.cssEditor.addEventListener('input', updatePreview);
        
        // 支持 Ctrl+S 保存并更新
        document.addEventListener('keydown', (event) => {
            if (event.ctrlKey && event.key === 's') {
                event.preventDefault();
                this.updatePreview();
            }
        });
    }

    private async updatePreview(): Promise<void> {
        try {
            const html = this.htmlEditor.value;
            const css = this.cssEditor.value;
            
            if (!html.trim()) {
                this.clearCanvas();
                return;
            }

            const result = this.parser.parseWithDiagnostics(html, {
                viewportWidth: this.canvas.width,
                css: css || undefined,
                enableMetrics: true
            });

            if (result.success) {
                this.renderLayouts(result.data);
                this.updateStatus('success', `解析成功 (${result.data.length} 字符)`);
                
                if (result.metrics) {
                    this.updatePerformanceInfo(result.metrics);
                }
            } else {
                this.updateStatus('error', '解析失败: ' + 
                    result.errors?.map(e => e.message).join(', '));
            }
            
        } catch (error) {
            this.updateStatus('error', `解析异常: ${error.message}`);
        }
    }

    private updateStatus(type: 'success' | 'error' | 'warning', message: string): void {
        const statusElement = document.getElementById('preview-status');
        if (statusElement) {
            statusElement.className = `status ${type}`;
            statusElement.textContent = message;
        }
    }

    private updatePerformanceInfo(metrics: any): void {
        const perfElement = document.getElementById('performance-info');
        if (perfElement) {
            perfElement.innerHTML = `
                <div>解析时间: ${metrics.parseTime.toFixed(2)}ms</div>
                <div>布局时间: ${metrics.layoutTime.toFixed(2)}ms</div>
                <div>解析速度: ${metrics.charsPerSecond.toFixed(0)} 字符/秒</div>
            `;
        }
    }

    // ... 其他渲染方法
}

性能优化示例

Canvas 虚拟化

typescript
// canvas-virtualization.ts
class VirtualizedCanvas {
    private parser: HtmlLayoutParser;
    private canvas: HTMLCanvasElement;
    private ctx: CanvasRenderingContext2D;
    private layouts: CharLayout[] = [];
    private viewportY: number = 0;
    private viewportHeight: number;

    constructor(canvasId: string) {
        this.canvas = document.getElementById(canvasId) as HTMLCanvasElement;
        this.ctx = this.canvas.getContext('2d')!;
        this.viewportHeight = this.canvas.height;
        this.parser = new HtmlLayoutParser();
        
        this.setupScrolling();
    }

    private setupScrolling(): void {
        this.canvas.addEventListener('wheel', (event) => {
            event.preventDefault();
            
            this.viewportY += event.deltaY;
            this.viewportY = Math.max(0, this.viewportY);
            
            this.renderVisibleArea();
        });
    }

    private renderVisibleArea(): void {
        this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
        
        // 只渲染可见区域内的字符
        const visibleChars = this.layouts.filter(char => {
            const charTop = char.y - char.fontSize;
            const charBottom = char.y;
            
            return charBottom >= this.viewportY && 
                   charTop <= this.viewportY + this.viewportHeight;
        });
        
        for (const char of visibleChars) {
            // 调整 Y 坐标以适应滚动
            const adjustedChar = {
                ...char,
                y: char.y - this.viewportY,
                baseline: char.baseline - this.viewportY
            };
            
            this.renderCharacter(adjustedChar);
        }
        
        // 显示滚动指示器
        this.renderScrollIndicator();
    }

    private renderScrollIndicator(): void {
        if (this.layouts.length === 0) return;
        
        const maxY = Math.max(...this.layouts.map(c => c.y));
        const totalHeight = maxY + 50; // 添加一些底部边距
        
        if (totalHeight <= this.viewportHeight) return;
        
        // 绘制滚动条
        const scrollbarWidth = 10;
        const scrollbarHeight = (this.viewportHeight / totalHeight) * this.viewportHeight;
        const scrollbarY = (this.viewportY / totalHeight) * this.viewportHeight;
        
        this.ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
        this.ctx.fillRect(
            this.canvas.width - scrollbarWidth, 
            scrollbarY, 
            scrollbarWidth, 
            scrollbarHeight
        );
    }

    // ... 其他方法
}

这些 Web 示例展示了如何在浏览器环境中有效使用 HTML Layout Parser,包括基本使用、交互功能、实时预览和性能优化等方面。

Released under the MIT License.