前端H5实现用户签名并生成包含该签名的PDF发票

395 阅读22分钟

一个前端H5实现用户签名并生成包含该签名的PDF发票的功能。

这个任务涉及几个关键技术点:

  1. HTML5 Canvas: 用于绘制用户签名。我们需要捕捉用户的鼠标或触摸事件,并在Canvas上绘制路径。
  2. JavaScript: 处理用户交互(绘图、清除、生成PDF)、操作Canvas API、处理表单数据。
  3. CSS: 美化界面,定义Canvas区域样式。
  4. PDF生成库: 在前端生成PDF。常用的库有 jsPDFhtml2canvashtml2canvas 可以将HTML元素渲染成Canvas图像,然后 jsPDF 可以将这个图像或其他内容添加到PDF文档中。这种组合对于将包含复杂布局(如发票)和签名图像的HTML结构转换为PDF非常有用。

项目结构:

/signature-invoice-app
|-- index.html       # HTML 结构
|-- style.css        # CSS 样式
|-- script.js        # JavaScript 逻辑
|-- /libs            # (可选) 存放本地库文件
    |-- jspdf.umd.min.js
    |-- html2canvas.min.js

我们将使用CDN链接来引入 jsPDFhtml2canvas,这样更简单。如果需要离线使用,可以下载库文件放到 libs 目录并在HTML中引用本地路径。


1. HTML (index.html)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>H5 签名与 PDF 发票生成</title>
    <link rel="stylesheet" href="style.css">
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
</head>
<body>

    <h1>电子发票签名与生成</h1>

    <div id="invoice-form" class="invoice-section">
        <h2>发票信息</h2>
        <div class="form-group">
            <label for="invoice-number">发票号码:</label>
            <input type="text" id="invoice-number" value="INV-20250503-001">
        </div>
        <div class="form-group">
            <label for="customer-name">客户名称:</label>
            <input type="text" id="customer-name" value="张三">
        </div>
        <div class="form-group">
            <label for="invoice-date">开票日期:</label>
            <input type="date" id="invoice-date">
        </div>
        <div class="form-group">
            <label for="item-description">项目名称:</label>
            <input type="text" id="item-description" value="技术咨询服务">
        </div>
        <div class="form-group">
            <label for="item-quantity">数量:</label>
            <input type="number" id="item-quantity" value="1">
        </div>
        <div class="form-group">
            <label for="item-price">单价 (元):</label>
            <input type="number" id="item-price" step="0.01" value="500.00">
        </div>
        <div class="form-group">
            <label for="total-amount">总金额 (元):</label>
            <input type="text" id="total-amount" readonly> </div>
        <div class="form-group">
            <label for="company-name">销售方名称:</label>
            <input type="text" id="company-name" value="示例科技有限公司">
        </div>
        <div class="form-group">
            <label for="company-tax-id">销售方税号:</label>
            <input type="text" id="company-tax-id" value="91310000MA1FL12345">
        </div>
         <div class="form-group">
            <label for="company-address">销售方地址:</label>
            <input type="text" id="company-address" value="XX市XX区XX路123号">
        </div>
        <div class="form-group">
            <label for="company-phone">销售方电话:</label>
            <input type="text" id="company-phone" value="010-12345678">
        </div>
    </div>

    <div id="signature-section" class="invoice-section">
        <h2>客户签名确认</h2>
        <div class="canvas-container">
            <canvas id="signature-pad" width="400" height="200"></canvas>
            <p class="signature-placeholder">请在此处签名</p>
        </div>
        <div class="signature-controls">
            <button id="clear-button">清除签名</button>
            <button id="undo-button">撤销上一步</button> </div>
    </div>

    <div class="action-buttons">
        <button id="generate-pdf-button">生成并预览 PDF 发票</button>
    </div>

    <div id="printable-invoice" style="position: absolute; left: -9999px; top: auto; width: 800px; padding: 20px; border: 1px solid #ccc; background-color: white; font-family: 'SimSun', serif;">
        <h2 style="text-align: center; margin-bottom: 20px;">电子发票</h2>
        <table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
            <tr>
                <td style="border: 1px solid #ddd; padding: 8px;"><strong>发票号码:</strong></td>
                <td style="border: 1px solid #ddd; padding: 8px;" id="pdf-invoice-number"></td>
                <td style="border: 1px solid #ddd; padding: 8px;"><strong>开票日期:</strong></td>
                <td style="border: 1px solid #ddd; padding: 8px;" id="pdf-invoice-date"></td>
            </tr>
            <tr>
                <td style="border: 1px solid #ddd; padding: 8px;"><strong>客户名称:</strong></td>
                <td style="border: 1px solid #ddd; padding: 8px;" colspan="3" id="pdf-customer-name"></td>
            </tr>
        </table>

        <h3 style="margin-top: 20px; border-bottom: 1px solid #eee; padding-bottom: 5px;">货物或应税劳务、服务名称</h3>
        <table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
            <thead>
                <tr>
                    <th style="border: 1px solid #ddd; padding: 8px; text-align: left;">项目名称</th>
                    <th style="border: 1px solid #ddd; padding: 8px; text-align: right;">数量</th>
                    <th style="border: 1px solid #ddd; padding: 8px; text-align: right;">单价 (元)</th>
                    <th style="border: 1px solid #ddd; padding: 8px; text-align: right;">金额 (元)</th>
                </tr>
            </thead>
            <tbody>
                <tr>
                    <td style="border: 1px solid #ddd; padding: 8px;" id="pdf-item-description"></td>
                    <td style="border: 1px solid #ddd; padding: 8px; text-align: right;" id="pdf-item-quantity"></td>
                    <td style="border: 1px solid #ddd; padding: 8px; text-align: right;" id="pdf-item-price"></td>
                    <td style="border: 1px solid #ddd; padding: 8px; text-align: right;" id="pdf-item-total"></td>
                </tr>
                </tbody>
        </table>

        <table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
             <tr>
                <td style="border: 1px solid #ddd; padding: 8px;"><strong>价税合计 (大写):</strong></td>
                <td style="border: 1px solid #ddd; padding: 8px; text-align: right;" id="pdf-total-amount-chinese" colspan="3"></td>
             </tr>
             <tr>
                 <td style="border: 1px solid #ddd; padding: 8px;"><strong>价税合计 (小写):</strong></td>
                 <td style="border: 1px solid #ddd; padding: 8px; text-align: right;" id="pdf-total-amount" colspan="3"></td>
             </tr>
        </table>


        <h3 style="margin-top: 20px; border-bottom: 1px solid #eee; padding-bottom: 5px;">销售方信息</h3>
        <table style="width: 100%; border-collapse: collapse; margin-bottom: 30px;">
             <tr>
                 <td style="border: 1px solid #ddd; padding: 8px;"><strong>名称:</strong></td>
                 <td style="border: 1px solid #ddd; padding: 8px;" colspan="3" id="pdf-company-name"></td>
             </tr>
              <tr>
                 <td style="border: 1px solid #ddd; padding: 8px;"><strong>纳税人识别号:</strong></td>
                 <td style="border: 1px solid #ddd; padding: 8px;" colspan="3" id="pdf-company-tax-id"></td>
             </tr>
             <tr>
                 <td style="border: 1px solid #ddd; padding: 8px;"><strong>地址、电话:</strong></td>
                 <td style="border: 1px solid #ddd; padding: 8px;" colspan="3" id="pdf-company-address-phone"></td>
             </tr>
        </table>

        <div style="margin-top: 40px; padding-top: 20px; border-top: 1px dashed #ccc;">
            <h3 style="margin-bottom: 10px;">客户签名确认:</h3>
            <div id="pdf-signature-area" style="width: 300px; height: 150px; border: 1px solid #eee; background-color: #f9f9f9;">
                 <img id="pdf-signature-image" src="" alt="客户签名" style="display: block; max-width: 100%; max-height: 100%;">
            </div>
        </div>

        <p style="text-align: center; margin-top: 30px; font-size: 12px; color: #666;">
            打印日期: <span id="pdf-print-date"></span>
        </p>
    </div>


    <script src="script.js"></script>
</body>
</html>

2. CSS (style.css)

body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
    line-height: 1.6;
    margin: 20px;
    background-color: #f4f7f6;
    color: #333;
}

h1, h2, h3 {
    color: #2c3e50;
    margin-bottom: 1em;
}

h1 {
    text-align: center;
    border-bottom: 2px solid #3498db;
    padding-bottom: 10px;
    margin-bottom: 30px;
}

.invoice-section {
    background-color: #ffffff;
    padding: 25px;
    margin-bottom: 25px;
    border-radius: 8px;
    box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}

.form-group {
    margin-bottom: 15px;
    display: flex; /* 使用flex布局让label和input对齐 */
    align-items: center; /* 垂直居中对齐 */
}

.form-group label {
    display: block;
    margin-bottom: 5px;
    font-weight: bold;
    color: #555;
    min-width: 120px; /* 固定标签宽度 */
    text-align: right; /* 标签右对齐 */
    margin-right: 10px; /* 标签和输入框间距 */
}

.form-group input[type="text"],
.form-group input[type="date"],
.form-group input[type="number"] {
    flex-grow: 1; /* 输入框占据剩余空间 */
    padding: 10px;
    border: 1px solid #ccc;
    border-radius: 4px;
    box-sizing: border-box; /* 内边距和边框包含在宽度内 */
    font-size: 1rem;
}

.form-group input[readonly] {
    background-color: #eee;
    cursor: not-allowed;
}

/* 签名区域特定样式 */
#signature-section h2 {
    margin-bottom: 15px;
}

.canvas-container {
    position: relative; /* 为了占位符的绝对定位 */
    width: 400px; /* 显式设置容器宽度 */
    height: 200px; /* 显式设置容器高度 */
    margin-bottom: 10px; /* 与下方按钮的间距 */
}

#signature-pad {
    border: 2px dashed #3498db;
    background-color: #f0f8ff; /* 淡蓝色背景 */
    cursor: crosshair;
    display: block; /* 消除可能的下方空白 */
    box-sizing: border-box; /* 边框计入宽高 */
    width: 100%; /* 填充容器 */
    height: 100%; /* 填充容器 */
}

.signature-placeholder {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    color: #aaa;
    pointer-events: none; /* 不干扰鼠标事件 */
    font-style: italic;
    transition: opacity 0.3s ease; /* 添加淡出效果 */
    z-index: 1; /* 在 Canvas 上方,但会被绘制覆盖 */
}

/* 如果 Canvas 上有内容,隐藏占位符 */
.canvas-container.has-content .signature-placeholder {
    opacity: 0;
}


.signature-controls {
    margin-top: 10px;
    display: flex; /* 横向排列按钮 */
    gap: 10px; /* 按钮间距 */
}

.action-buttons {
    text-align: center; /* 居中主操作按钮 */
    margin-top: 20px;
}

button {
    padding: 12px 25px;
    font-size: 1rem;
    font-weight: bold;
    cursor: pointer;
    border: none;
    border-radius: 5px;
    transition: background-color 0.3s ease, transform 0.1s ease;
    background-color: #3498db;
    color: white;
}

button:hover {
    background-color: #2980b9;
}

button:active {
    transform: translateY(1px); /* 点击时轻微下沉 */
}

#clear-button, #undo-button {
    background-color: #e74c3c;
}

#clear-button:hover, #undo-button:hover {
    background-color: #c0392b;
}

#generate-pdf-button {
    background-color: #2ecc71;
}

#generate-pdf-button:hover {
    background-color: #27ae60;
}

/* 响应式调整 */
@media (max-width: 768px) {
    .form-group {
        flex-direction: column; /* 在小屏幕上垂直排列 */
        align-items: flex-start; /* 左对齐 */
    }
    .form-group label {
        min-width: auto; /* 取消最小宽度 */
        text-align: left; /* 左对齐 */
        margin-right: 0;
        margin-bottom: 5px; /* 标签和输入框间距 */
    }
    .form-group input[type="text"],
    .form-group input[type="date"],
    .form-group input[type="number"] {
        width: 100%; /* 输入框占满宽度 */
    }
    .canvas-container {
        width: 100%; /* 画布容器适应屏幕 */
        max-width: 400px; /* 但不超过最大宽度 */
        height: auto; /* 高度自适应 */
        aspect-ratio: 2 / 1; /* 保持 2:1 的宽高比 */
    }
    #signature-pad {
        /* 宽高由容器决定 */
    }
}

/* 打印相关的隐藏div,确保其内容在html2canvas中正常渲染 */
#printable-invoice {
    font-size: 14px; /* 为PDF设置合适的字体大小 */
    line-height: 1.5;
}

#printable-invoice h2, #printable-invoice h3 {
    color: #000; /* PDF中通常用黑色 */
}

#printable-invoice table {
    font-size: 12px; /* 表格内字体稍小 */
}

#printable-invoice td, #printable-invoice th {
    border: 1px solid #333 !important; /* 确保边框在PDF中清晰 */
    padding: 6px !important;
}

#printable-invoice th {
    background-color: #f2f2f2; /* 表头背景色 */
    text-align: center;
}

3. JavaScript (script.js)

// 确保在 DOM 加载完成后执行脚本
window.addEventListener('DOMContentLoaded', (event) => {
    console.log('DOM fully loaded and parsed');

    // --- Canvas 签名相关变量 ---
    const canvas = document.getElementById('signature-pad');
    const ctx = canvas.getContext('2d');
    const clearButton = document.getElementById('clear-button');
    const undoButton = document.getElementById('undo-button'); // 撤销按钮
    const placeholder = document.querySelector('.signature-placeholder');
    const canvasContainer = document.querySelector('.canvas-container');

    let isDrawing = false;
    let lastX = 0;
    let lastY = 0;
    let history = []; // 用于存储绘制步骤,实现撤销功能

    // --- 发票表单元素 ---
    const invoiceNumberInput = document.getElementById('invoice-number');
    const customerNameInput = document.getElementById('customer-name');
    const invoiceDateInput = document.getElementById('invoice-date');
    const itemDescriptionInput = document.getElementById('item-description');
    const itemQuantityInput = document.getElementById('item-quantity');
    const itemPriceInput = document.getElementById('item-price');
    const totalAmountInput = document.getElementById('total-amount');
    const companyNameInput = document.getElementById('company-name');
    const companyTaxIdInput = document.getElementById('company-tax-id');
    const companyAddressInput = document.getElementById('company-address');
    const companyPhoneInput = document.getElementById('company-phone');

    // --- PDF 生成按钮 ---
    const generatePdfButton = document.getElementById('generate-pdf-button');

    // --- PDF 模板中的元素 ---
    const pdfInvoiceNumber = document.getElementById('pdf-invoice-number');
    const pdfInvoiceDate = document.getElementById('pdf-invoice-date');
    const pdfCustomerName = document.getElementById('pdf-customer-name');
    const pdfItemDescription = document.getElementById('pdf-item-description');
    const pdfItemQuantity = document.getElementById('pdf-item-quantity');
    const pdfItemPrice = document.getElementById('pdf-item-price');
    const pdfItemTotal = document.getElementById('pdf-item-total');
    const pdfTotalAmount = document.getElementById('pdf-total-amount');
    const pdfTotalAmountChinese = document.getElementById('pdf-total-amount-chinese');
    const pdfCompanyName = document.getElementById('pdf-company-name');
    const pdfCompanyTaxId = document.getElementById('pdf-company-tax-id');
    const pdfCompanyAddressPhone = document.getElementById('pdf-company-address-phone');
    const pdfSignatureImage = document.getElementById('pdf-signature-image');
    const pdfPrintDate = document.getElementById('pdf-print-date');


    // --- 初始化 ---

    // 设置 Canvas 绘图上下文的样式
    function setupCanvasContext() {
        ctx.strokeStyle = '#000000'; // 笔触颜色:黑色
        ctx.lineWidth = 2;         // 笔触宽度
        ctx.lineCap = 'round';     // 线条末端样式:圆形
        ctx.lineJoin = 'round';    // 线条连接处样式:圆形
    }

    // 调整 Canvas 尺寸以适应高 DPI 屏幕,防止模糊
    function resizeCanvas() {
        const ratio = Math.max(window.devicePixelRatio || 1, 1);
        // 这里我们从CSS获取画布的显示尺寸
        const cssWidth = canvas.clientWidth;
        const cssHeight = canvas.clientHeight;

        // 设置画布的实际绘图缓冲区大小
        canvas.width = cssWidth * ratio;
        canvas.height = cssHeight * ratio;

        // 使用 scale 方法放大上下文,使得绘图坐标与CSS像素匹配
        ctx.scale(ratio, ratio);

        console.log(`Canvas resized: ${canvas.width}x${canvas.height} (CSS: ${cssWidth}x${cssHeight}), Ratio: ${ratio}`);

        // 重新设置绘图样式,因为尺寸变化可能重置上下文状态
        setupCanvasContext();
        // 重绘历史记录
        redrawHistory();
    }

    // 保存当前画布状态到历史记录
    function saveHistory() {
        // 优化:仅在画布内容实际发生变化时保存
        // 这里简单起见,每次绘制结束都保存,但实际应用中可以优化
        if (ctx.getImageData(0, 0, canvas.width, canvas.height).data.some(channel => channel !== 0)) {
             // 将当前画布内容转换为图像数据URL,并存入历史数组
            history.push(canvas.toDataURL());
            console.log(`History saved. Length: ${history.length}`);
        }
    }

     // 从历史记录中恢复上一步状态
    function restoreHistory() {
        if (history.length > 1) { // 至少需要有初始状态和一步操作
            // 移除最后一步
            history.pop();
            console.log(`History popped. Length: ${history.length}`);
            // 获取上一步的图像数据
            const previousImageDataUrl = history[history.length - 1];
            // 创建一个图像对象来加载这个数据URL
            const img = new Image();
            img.onload = () => {
                 // 清除当前画布
                clearCanvasInternal(false); // 不记录清除操作本身
                // 将上一步的图像绘制回画布
                // 注意:因为我们调整了 context 的 scale,所以直接画图即可
                ctx.drawImage(img, 0, 0, canvas.clientWidth, canvas.clientHeight);
                checkCanvasContent(); // 检查恢复后画布是否有内容
            };
            img.src = previousImageDataUrl;
        } else if (history.length === 1) {
             // 如果只剩下初始的空状态,则直接清空画布
            clearCanvasInternal(false); // 不记录清除操作本身
            history.pop(); // 清空历史
            checkCanvasContent();
            console.log('History restored to empty state.');
        } else {
            console.log('No history to restore.');
        }
    }


    // 重绘历史记录(用于resize等情况)
    function redrawHistory() {
        if (history.length > 0) {
            const lastImageDataUrl = history[history.length - 1];
            const img = new Image();
            img.onload = () => {
                // 清除当前画布
                clearCanvasInternal(false);
                // 绘制最后的状态
                ctx.drawImage(img, 0, 0, canvas.clientWidth, canvas.clientHeight);
                checkCanvasContent(); // 检查恢复后画布是否有内容
            };
            img.src = lastImageDataUrl;
        } else {
             clearCanvasInternal(false); // 如果没历史,确保画布是干净的
        }
    }


    // 清除 Canvas 内容 (内部方法,可选是否保存历史)
    function clearCanvasInternal(save = true) {
        // 清除指定矩形区域的内容
        // 由于设置了 scale,这里的宽高应该是画布的CSS宽高
        ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
        console.log('Canvas cleared.');
        if (save) {
            // 保存空白状态到历史
            history = []; // 清除历史记录
            history.push(canvas.toDataURL()); // 保存空白状态
            console.log('History reset with empty state.');
        }
         checkCanvasContent(); // 检查画布内容状态
    }

    // 公开的清除画布方法(供按钮调用)
    function clearCanvas() {
        clearCanvasInternal(true);
    }

    // 检查画布是否有内容,并相应地更新样式(例如隐藏占位符)
    function checkCanvasContent() {
        // 稍微复杂一点的检查:获取画布像素数据,检查是否有非透明/非背景色像素
        // 一个简化的方法是假设只要历史记录大于1(意味着有绘制操作)就有内容
        // 或者直接比较当前画布与空白画布的 DataURL (可能效率不高)
        // 这里我们采用一个更可靠的方式:检查是否有非透明像素
        let hasContent = false;
        try {
            // getImageData 可能会因为画布被污染(tainted)而报错,但在此场景下不太可能
            const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
            // 检查 Alpha 通道,只要有一个像素不是完全透明,就认为有内容
            // 注意,这里检查的是乘以 D P I 后的画布缓冲区
            for (let i = 3; i < imageData.data.length; i += 4) { // i=3 是第一个像素的 Alpha通道
                if (imageData.data[i] !== 0) {
                    hasContent = true;
                    break;
                }
            }
        } catch (e) {
            console.error("Error checking canvas content:", e);
            // 出错时保守地认为可能有内容,或者根据历史记录判断
            hasContent = history.length > 1;
        }


        if (hasContent) {
            canvasContainer.classList.add('has-content');
            // placeholder.style.display = 'none'; // 可以用CSS的opacity代替
        } else {
            canvasContainer.classList.remove('has-content');
            // placeholder.style.display = 'block'; // 可以用CSS的opacity代替
        }
        console.log(`Canvas has content: ${hasContent}`);
    }

    // --- 绘图事件处理 ---

    // 获取鼠标或触摸在 Canvas 上的相对坐标
    function getCoordinates(event) {
        event.preventDefault(); // 阻止默认行为,如页面滚动(对触摸事件尤其重要)
        const rect = canvas.getBoundingClientRect();
        let x, y;

        if (event.touches && event.touches.length > 0) {
            // 触摸事件
            x = event.touches[0].clientX - rect.left;
            y = event.touches[0].clientY - rect.top;
        } else {
            // 鼠标事件
            x = event.clientX - rect.left;
            y = event.clientY - rect.top;
        }
        // console.log(`Coords: x=${x.toFixed(2)}, y=${y.toFixed(2)}`);
        return { x, y };
    }

    // 开始绘制(鼠标按下或触摸开始)
    function startDrawing(event) {
        isDrawing = true;
        const coords = getCoordinates(event);
        [lastX, lastY] = [coords.x, coords.y];
        // console.log('Start drawing at:', lastX, lastY);

        // 开始新路径,避免连接到上一次绘制的结束点
        ctx.beginPath();
        ctx.moveTo(lastX, lastY); // 将画笔移动到起点
        checkCanvasContent(); // 检查内容状态,隐藏占位符
    }

    // 绘制过程(鼠标移动或触摸移动)
    function draw(event) {
        if (!isDrawing) return; // 如果不是正在绘制状态,则不执行

        const coords = getCoordinates(event);
        // console.log('Drawing to:', coords.x, coords.y);

        // 从上一个点绘制一条线到当前点
        ctx.lineTo(coords.x, coords.y);
        ctx.stroke(); // 执行绘制

        // 更新上一个点的位置
        [lastX, lastY] = [coords.x, coords.y];
    }

    // 停止绘制(鼠标松开、触摸结束、鼠标离开画布)
    function stopDrawing() {
        if (!isDrawing) return;
        isDrawing = false;
        // console.log('Stop drawing.');
        // 可以在这里添加优化:如果绘制的路径很短或没有实际绘制,则不保存历史
        saveHistory(); // 保存这次绘制的结果
        checkCanvasContent(); // 再次检查内容状态
    }

    // --- 发票金额计算 ---
    function calculateTotal() {
        const quantity = parseFloat(itemQuantityInput.value) || 0;
        const price = parseFloat(itemPriceInput.value) || 0;
        const total = (quantity * price).toFixed(2); // 保留两位小数
        totalAmountInput.value = total;
    }

    // 数字金额转中文大写
    function amountToChinese(n) {
        if (n === null || n === undefined || isNaN(parseFloat(n))) {
            return "零元整";
        }
        n = parseFloat(n); // 确保是数字

        if (Math.abs(n) >= 1000000000000) {
            return "金额过大,无法转换";
        }

        var fraction = ['角', '分'];
        var digit = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖'];
        var unit = [['元', '万', '亿'], ['', '拾', '佰', '仟']];
        var head = n < 0 ? '负' : '';
        n = Math.abs(n);
        var s = '';

        // 处理小数部分
        var小数部分 = "";
        var rawFraction = ((n * 100) % 100).toFixed(0); // 取小数后两位并转为整数
        if (rawFraction === "0") {
            小数部分 = '整';
        } else {
             let jiao = Math.floor(rawFraction / 10);
             let fen = rawFraction % 10;
             if (jiao > 0) {
                 小数部分 += digit[jiao] + fraction[0];
             } else if (fen > 0) {
                 // 如果角为0,分不为0,需要加“零”
                 小数部分 += '零';
             }
             if (fen > 0) {
                 小数部分 += digit[fen] + fraction[1];
             }
        }


        n = Math.floor(n); // 取整数部分
        if (n === 0 && 小数部分 !== '整') {
             s = 小数部分; // 如果只有小数部分,则直接返回,例如 零角伍分
        } else if (n === 0 && 小数部分 === '整') {
            return "零元整"; // 处理 0.00 的情况
        }
        else {
            // 处理整数部分
            for (var i = 0; i < unit[0].length && n > 0; i++) {
                var p = '';
                for (var j = 0; j < unit[1].length && n > 0; j++) {
                    p = digit[n % 10] + unit[1][j] + p;
                    n = Math.floor(n / 10);
                }
                // 优化零的显示:
                p = p.replace(/零(拾|佰|仟)/g, '零'); // 零拾、零佰、零仟 -> 零
                p = p.replace(/零+/g, '零'); // 多个零 -> 一个零
                p = p.replace(/零(万|亿|元)/g, '$1'); // 零万 -> 万, 零亿 -> 亿, 零元 -> 元
                p = p.replace(/^元/, ''); // 如果以元开头(整数部分为0),去掉元
                p = p.replace(/亿万/g, '亿'); // 亿万 -> 亿

                s = p + unit[0][i] + s;
            }
             // 再次清理零和元
            s = s.replace(/零(万|亿|元)/g, '$1');
            s = s.replace(/零+/g, '零'); // 清理可能产生的连续零
            s = s.replace(/零$/, ''); // 去掉末尾的零(在元之前)

            // 确保整数部分不为空时,有“元”字
            if (!s.includes('元') && n === 0 && s !== '') {
                 // 如果整数部分处理后为空(例如 0.5 元的情况),需要加上元
                 // 但如果原数是 10 元,s 会是 "壹拾",此时也需要加元
                 // 检查 s 是否以 '角' 或 '分' 结尾
                if (!/(角|分)$/.test(小数部分)) { // 如果小数部分不是 "整"
                    s += '元';
                }
            } else if (!s.includes('元') && s.length > 0 && !/(角|分)$/.test(小数部分)){
                // 处理 15 元 这种情况, s = "壹拾伍",需要加元
                 s += '元';
            }


            s = head + s + 小数部分; // 组合整数和小数部分
            s = s.replace(/^元/, '零元'); // 如果结果是 "元整" 或 "元X角X分", 加上 "零元"
            s = s.replace(/零元整$/, '零元整'); // 防止出现 零元整整

        }

        // 最终清理
        s = s.replace(/零(角|分)/g, '零'); // 零角/零分 -> 零
        s = s.replace(/零+/g, '零'); // 再清理一次零
        s = s.replace(/零(元|万|亿)/g, '$1');
        s = s.replace(/亿万/g, '亿');
        if (s.endsWith('零') && !s.endsWith('零元整') && s !== '零元整') { // 去掉非 "零元整" 结尾的 "零"
             s = s.slice(0, -1);
        }
         if (s === '元整' || s === '元'){ // 处理特殊情况 1.00
             s = '壹元整';
         }


        return s;
    }

    // --- PDF 生成逻辑 ---
    async function generatePDF() {
        console.log('Starting PDF generation...');

        // 1. 检查签名是否存在
        const signatureDataUrl = canvas.toDataURL('image/png'); // 获取签名图像
        const blankCanvasDataUrl = document.createElement('canvas').toDataURL('image/png'); // 获取一个空白 Canvas 的 DataURL 用于比较

         // 更可靠的检查空画布方法
        let isCanvasBlank = true;
        try {
            const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
            for (let i = 3; i < imageData.data.length; i += 4) {
                if (imageData.data[i] !== 0) { // 检查 alpha 通道
                    isCanvasBlank = false;
                    break;
                }
            }
        } catch(e) {
            console.warn("Could not accurately check if canvas is blank, assuming not blank if history exists.", e);
            isCanvasBlank = history.length <= 1; // 回退到基于历史记录的判断
        }


        if (isCanvasBlank) {
            alert('请先在指定区域完成签名!');
            console.warn('PDF generation stopped: Signature is missing.');
            return; // 终止执行
        }
        console.log('Signature found.');

        // 2. 获取表单数据并填充到隐藏的 PDF 模板区域
        console.log('Populating PDF template data...');
        const currentDate = new Date();
        const formattedDate = `${currentDate.getFullYear()}-${(currentDate.getMonth() + 1).toString().padStart(2, '0')}-${currentDate.getDate().toString().padStart(2, '0')}`;
        const formattedTime = `${currentDate.getHours().toString().padStart(2, '0')}:${currentDate.getMinutes().toString().padStart(2, '0')}:${currentDate.getSeconds().toString().padStart(2, '0')}`;

        // 填充文本信息
        pdfInvoiceNumber.textContent = invoiceNumberInput.value;
        pdfInvoiceDate.textContent = invoiceDateInput.value || formattedDate; // 如果没选日期用当前日期
        pdfCustomerName.textContent = customerNameInput.value;
        pdfItemDescription.textContent = itemDescriptionInput.value;
        pdfItemQuantity.textContent = itemQuantityInput.value;
        const price = parseFloat(itemPriceInput.value).toFixed(2);
        pdfItemPrice.textContent = price;
        const total = (parseFloat(itemQuantityInput.value) * parseFloat(price)).toFixed(2);
        pdfItemTotal.textContent = total;
        pdfTotalAmount.textContent = `¥${total}`;
        pdfTotalAmountChinese.textContent = amountToChinese(total);
        pdfCompanyName.textContent = companyNameInput.value;
        pdfCompanyTaxId.textContent = companyTaxIdInput.value;
        pdfCompanyAddressPhone.textContent = `${companyAddressInput.value} ${companyPhoneInput.value}`;
        pdfPrintDate.textContent = `${formattedDate} ${formattedTime}`;

        // 填充签名图片
        pdfSignatureImage.src = signatureDataUrl;
        pdfSignatureImage.style.display = 'block'; // 确保图片可见

        console.log('PDF template populated.');

        // 3. 使用 html2canvas 将填充好的模板区域转换为 Canvas
        const printableElement = document.getElementById('printable-invoice');
        console.log('Running html2canvas on element:', printableElement);

        try {
            const generatedCanvas = await html2canvas(printableElement, {
                 scale: 2, // 提高截图分辨率,使 PDF 更清晰
                 useCORS: true, // 如果包含跨域图片需要这个,这里签名是 DataURL 不需要
                 logging: true, // 开启日志方便调试
                 backgroundColor: '#ffffff' // 显式设置背景色,防止透明
            });
            console.log('html2canvas generated canvas successfully.');

            // 4. 将 html2canvas 生成的 Canvas 转换为图像数据
            const imgData = generatedCanvas.toDataURL('image/jpeg', 0.9); // 使用JPEG减少文件大小,质量90%
            console.log('Converted generated canvas to JPEG data URL.');

            // 5. 使用 jsPDF 创建 PDF 文档并添加图像
            // 引入jsPDF (确保已在HTML中通过<script>引入)
            const { jsPDF } = window.jspdf;
            // 创建 jsPDF 实例 (默认 A4 纸张, 纵向)
            // 单位可以是 'pt', 'mm', 'cm', 'in',A4 尺寸约为 210mm x 297mm
            const pdf = new jsPDF('p', 'mm', 'a4');
            console.log('jsPDF instance created.');

            const pdfWidth = pdf.internal.pageSize.getWidth(); // A4 宽度 mm
            const pdfHeight = pdf.internal.pageSize.getHeight(); // A4 高度 mm

            // 计算图像在 PDF 中的尺寸,保持宽高比
            const imgProps = pdf.getImageProperties(imgData); // 获取图像原始尺寸 (px)
            const imgWidth = imgProps.width;
            const imgHeight = imgProps.height;
            console.log(`Image properties: width=${imgWidth}px, height=${imgHeight}px`);

            // 计算缩放比例,使得图像能适应 PDF 页面宽度,并留出一些边距
            const margin = 10; // PDF 页面边距 (mm)
            const availableWidth = pdfWidth - 2 * margin;
            const scale = availableWidth / imgWidth; // 以宽度为基准进行缩放
            const scaledHeight = imgHeight * scale;

             // 检查缩放后的高度是否超过页面高度
             let finalHeight = scaledHeight;
             let finalWidth = availableWidth;
             let yPosition = margin; // 图片在 PDF 中的起始 Y 坐标

             if (scaledHeight > (pdfHeight - 2 * margin)) {
                 console.warn("Scaled image height exceeds PDF page height. Scaling based on height instead.");
                 // 如果高度超出,则改为基于高度进行缩放
                 const availableHeight = pdfHeight - 2 * margin;
                 const scaleHeight = availableHeight / imgHeight;
                 finalHeight = availableHeight;
                 finalWidth = imgWidth * scaleHeight;
             } else {
                  // 垂直居中(可选)
                 // yPosition = (pdfHeight - scaledHeight) / 2;
             }


            console.log(`Calculated PDF image size: width=${finalWidth}mm, height=${finalHeight}mm`);

            // 添加图像到 PDF
            // addImage(imageData, format, x, y, width, height, alias, compression, rotation)
            pdf.addImage(imgData, 'JPEG', margin, yPosition, finalWidth, finalHeight);
            console.log('Image added to PDF.');

            // 6. 保存或预览 PDF
            // pdf.save('invoice-with-signature.pdf'); // 直接下载
            // 在新窗口预览 PDF
            pdf.output('dataurlnewwindow');
            console.log('PDF generated and opened for preview.');

        } catch (error) {
            console.error('Error during PDF generation:', error);
            alert('生成 PDF 时出错,请检查控制台获取详细信息。');
        } finally {
            // 可选:生成后隐藏签名图片,避免在模板区域一直显示
            // pdfSignatureImage.style.display = 'none';
            // pdfSignatureImage.src = '';
        }
    }

    // --- 事件监听器绑定 ---

    // 绘图事件 (鼠标)
    canvas.addEventListener('mousedown', startDrawing);
    canvas.addEventListener('mousemove', draw);
    canvas.addEventListener('mouseup', stopDrawing);
    canvas.addEventListener('mouseleave', stopDrawing); // 鼠标移出画布也停止绘制

    // 绘图事件 (触摸屏)
    canvas.addEventListener('touchstart', startDrawing, { passive: false }); // passive: false 允许 preventDefault
    canvas.addEventListener('touchmove', draw, { passive: false });
    canvas.addEventListener('touchend', stopDrawing);
    canvas.addEventListener('touchcancel', stopDrawing); // 触摸取消时也停止

    // 清除按钮
    clearButton.addEventListener('click', clearCanvas);

    // 撤销按钮
    undoButton.addEventListener('click', restoreHistory);

    // 生成 PDF 按钮
    generatePdfButton.addEventListener('click', generatePDF);

    // 输入框变化时自动计算总价
    itemQuantityInput.addEventListener('input', calculateTotal);
    itemPriceInput.addEventListener('input', calculateTotal);

    // --- 页面加载后的初始化操作 ---
    console.log('Setting up initial state...');
    // 设置 Canvas 初始尺寸和上下文
    resizeCanvas(); // 初始调整 Canvas 尺寸
    // 监听窗口大小变化,重新调整Canvas(可选,但推荐)
    let resizeTimeout;
    window.addEventListener('resize', () => {
        // 使用防抖(debounce)避免频繁触发 resize
        clearTimeout(resizeTimeout);
        resizeTimeout = setTimeout(() => {
            console.log('Window resized, resizing canvas...');
            resizeCanvas();
        }, 250); // 延迟 250ms 执行
    });

    // 初始化总金额
    calculateTotal();
    // 设置默认开票日期为今天
    if (!invoiceDateInput.value) {
        invoiceDateInput.valueAsDate = new Date();
    }
    // 初始检查画布状态(应该是空的)
    checkCanvasContent();
     // 保存初始空白状态到历史
    clearCanvasInternal(true); // 确保 history[0] 是空白状态

    console.log('Initialization complete. Application ready.');
});

代码讲解:

HTML (index.html)

  1. <!DOCTYPE html><html> : 标准 HTML5 文档结构。

  2. <head> :

    • charset="UTF-8": 指定字符编码为 UTF-8,支持中文。
    • viewport: 设置移动设备视口,width=device-width 使宽度等于设备宽度,initial-scale=1.0 设置初始缩放比例。
    • title: 页面标题。
    • link rel="stylesheet" href="style.css": 引入外部 CSS 文件。
    • <script src="...">: 通过 CDN 引入 jsPDFhtml2canvas 库。必须在使用它们之前引入。
  3. <body> :

    • <h1>: 主标题。

    • #invoice-form: 包含发票信息输入的 div

      • <h2>: 表单区域标题。
      • .form-group: 每个输入项的容器,使用 Flexbox 布局(见 CSS)使标签和输入框对齐。
      • <label><input>: 标准的表单标签和输入字段。id 用于 JavaScript 获取元素,for 属性关联标签和输入框。
      • input[type="date"]: 提供日期选择器。
      • input[type="number"]: 用于数字输入,step="0.01" 允许小数。
      • input[readonly]: 只读字段,如自动计算的总金额。
    • #signature-section: 包含签名的 div

      • .canvas-container: 包裹 Canvas 的容器,用于相对定位占位符和控制尺寸。
      • <canvas id="signature-pad">: 核心的签名区域。widthheight 属性设置了 Canvas 的 绘图表面 的像素尺寸(注意这与 CSS 控制的 显示尺寸 不同)。JavaScript 中会根据设备像素比调整。
      • .signature-placeholder: 一个 <p> 元素,用 CSS 绝对定位到 Canvas 中央,提示用户在此签名。当 Canvas 上有内容时会被隐藏(通过 JS 添加/移除 .has-content 类)。
      • .signature-controls: 包含操作按钮(清除、撤销)的容器。
      • <button id="clear-button">, <button id="undo-button">: 清除签名和撤销上一步绘制的按钮。
    • .action-buttons: 包含主要操作按钮(生成 PDF)的容器。

      • <button id="generate-pdf-button">: 触发 PDF 生成过程的按钮。
    • #printable-invoice: 一个 非常重要div

      • 它在页面上是 隐藏 的(通过 CSS position: absolute; left: -9999px;),用户看不到它。
      • 它的内部结构 完全模仿 了最终 PDF 发票的布局,使用了 <table> 等元素来排版。
      • 包含了所有发票信息的占位符 <span><td> (如 id="pdf-invoice-number"),JavaScript 会在生成 PDF 前将表单数据填充到这里。
      • 包含一个 <img> 标签 (id="pdf-signature-image"),用于显示从 Canvas 获取到的签名图片。
      • 关键作用html2canvas 库会读取这个 div 的内容和样式,将其渲染成一个 Canvas 图像。这个图像随后会被 jsPDF 添加到 PDF 文件中。这避免了用 jsPDF 的 API 一点点绘制文本、线条和图像的复杂性,保证了 PDF 内容与 HTML 预览的高度一致性。
      • 内部样式(style="...")是为了确保在 html2canvas 渲染时有基本样式,即使外部 CSS 加载失败或被忽略。推荐使用更可靠的内联样式或确保 CSS 对此元素生效。设置了 font-family: 'SimSun' (宋体) 以便在中文字符渲染上更像传统发票。
    • <script src="script.js">: 在页面底部引入主要的 JavaScript 文件,确保执行时 HTML 元素已加载。

CSS (style.css)

  1. body: 基本页面样式,字体、行高、边距、背景色。

  2. h1, h2, h3: 标题样式。

  3. .invoice-section: 为表单和签名区域提供统一的背景、内边距、圆角和阴影,使其看起来像卡片。

  4. .form-group: 使用 display: flexalign-items: center 实现标签和输入框水平对齐。

  5. label: 设置标签样式,min-widthtext-align: right 使其右对齐且宽度一致。

  6. input: 输入框的基本样式,flex-grow: 1 让其填充剩余空间。

  7. input[readonly] : 设置只读输入框的样式。

  8. #signature-section: 签名区域特定样式。

  9. .canvas-container: 设置固定宽高(或响应式宽高),position: relative 是为了 .signature-placeholder 的绝对定位。

  10. #signature-pad:

    • 设置边框、背景色、光标样式。
    • display: block; 避免 canvas 元素下方产生意外空白。
    • width: 100%; height: 100%; 使 Canvas 填充其容器 .canvas-container 的尺寸。
  11. .signature-placeholder:

    • 使用绝对定位和 transform: translate(-50%, -50%); 将其居中。
    • pointer-events: none; 确保它不会捕获鼠标事件,让用户可以直接在 Canvas 上绘图。
    • opacitytransition 实现淡入淡出效果。
  12. .canvas-container.has-content .signature-placeholder: 当 .canvas-containerhas-content 类时(由 JS 添加),隐藏占位符。

  13. .signature-controls, .action-buttons: 按钮容器的布局。

  14. button: 统一的按钮样式,包括悬停 (:hover) 和点击 (:active) 效果。

  15. 特定按钮样式 (#clear-button, #generate-pdf-button) : 为不同功能的按钮设置不同的背景色。

  16. @media (max-width: 768px) : 响应式设计。在小屏幕设备上,将表单的标签和输入框改为垂直排列,画布容器宽度设为 100% 并保持宽高比。

  17. #printable-invoice: 设置其基础字体大小、行高,并确保其内部元素的样式(如边框、字体颜色)在 html2canvas 渲染时是预期的,特别是在生成 PDF 时通常需要更清晰的黑色边框和文本。使用 !important 可能有助于覆盖其他冲突样式,但应谨慎使用。

JavaScript (script.js)

  1. window.addEventListener('DOMContentLoaded', ...) : 确保所有 HTML 元素都加载完毕后再执行 JavaScript 代码。

  2. 变量声明: 获取所有需要操作的 DOM 元素(Canvas、按钮、输入框、PDF 模板中的占位符)以及 Canvas 的 2D 绘图上下文 (ctx)。声明绘图状态变量 (isDrawing, lastX, lastY) 和用于撤销功能的历史记录数组 (history)。

  3. 初始化 (Initialization) :

    • setupCanvasContext(): 设置 Canvas 绘图的默认样式(颜色、线宽、线帽样式)。
    • resizeCanvas(): 关键函数。用于解决高 DPI 屏幕上 Canvas 绘图模糊的问题。它读取 Canvas 的 CSS 显示尺寸,然后根据设备的像素比 (window.devicePixelRatio) 放大 Canvas 的实际绘图缓冲区 (canvas.width, canvas.height)。之后,使用 ctx.scale(ratio, ratio) 缩放绘图上下文,这样后续绘图时使用的坐标仍然是基于 CSS 像素的,但实际绘制会更精细。每次调整尺寸后,需要重新设置绘图样式并重绘历史记录。
    • saveHistory(): 在每次有效绘制操作结束后,将当前 Canvas 的内容保存为 Data URL 并添加到 history 数组。
    • restoreHistory(): 实现撤销功能。从 history 数组中移除最后一次保存的状态,然后加载并绘制倒数第二个状态(即上一步的状态)到 Canvas 上。处理了历史为空或只有一个(初始空白)状态的情况。
    • redrawHistory(): 当 Canvas 尺寸变化(如窗口 resize)后,需要根据历史记录中的最后状态重新绘制 Canvas 内容。
    • clearCanvasInternal(): 内部使用的清除函数,可以通过参数控制是否将清除后的空白状态保存到历史记录。
    • clearCanvas(): 公开给清除按钮调用的函数,会清除画布并重置历史记录(保存一个空白状态)。
    • checkCanvasContent(): 检查 Canvas 是否真的有绘制内容(不仅仅是空白)。它通过 getImageData 获取像素数据,检查是否存在非完全透明的像素。根据检查结果,添加或移除 .canvas-containerhas-content 类,从而控制占位符的显示/隐藏。
  4. 绘图事件处理 (Drawing Event Handling) :

    • getCoordinates(event): 获取事件(鼠标或触摸)相对于 Canvas 左上角的坐标。对触摸事件 (event.touches[0]) 和鼠标事件 (event.clientX, event.clientY) 分别处理,并减去 Canvas 的边界矩形 (getBoundingClientRect()) 的左上角坐标。调用 event.preventDefault() 阻止触摸事件可能引起的页面滚动。
    • startDrawing(event): 在 mousedowntouchstart 时触发。设置 isDrawingtrue,记录起始坐标 (lastX, lastY),调用 ctx.beginPath() 开始新的绘图路径。
    • draw(event): 在 mousemovetouchmove 时触发。如果 isDrawingtrue,获取当前坐标,使用 ctx.lineTo() 从上一个点画线到当前点,然后 ctx.stroke() 实际绘制线条。更新 lastX, lastY
    • stopDrawing(): 在 mouseup, touchend, mouseleave, touchcancel 时触发。设置 isDrawingfalse。调用 saveHistory() 保存本次绘制操作。
  5. 发票金额计算 (Invoice Amount Calculation) :

    • calculateTotal(): 读取数量和单价输入框的值,计算总金额,并更新只读的总金额输入框。使用 toFixed(2) 保证两位小数。
    • amountToChinese(n): 一个将阿拉伯数字金额转换为中文大写金额的函数。处理整数、小数、零、单位(元、角、分、拾、佰、仟、万、亿)等情况,并进行了一些格式优化。
  6. PDF 生成逻辑 (PDF Generation Logic - generatePDF()) :

    • 这是一个 async 函数,因为 html2canvas 是异步的。

    • 步骤 1: 检查签名: 使用 canvas.toDataURL() 获取签名的图像数据。通过比较像素数据(checkCanvasContent 的逻辑)判断 Canvas 是否为空。如果为空,弹出提示并返回。

    • 步骤 2: 填充 PDF 模板: 获取所有发票输入框的值,并将它们填充到隐藏的 #printable-invoice div 中对应的元素(textContentsrc)。同时计算并填充总金额、大写金额、销售方组合地址电话、打印日期等。将获取到的签名 signatureDataUrl 设置为 #pdf-signature-imagesrc

    • 步骤 3: html2canvas 渲染: 调用 html2canvas(printableElement, options)。传入填充好数据的 #printable-invoice 元素和一些选项(如 scale 提高分辨率,backgroundColor 避免透明)。await 等待 html2canvas 完成渲染并返回一个新的 Canvas 对象 (generatedCanvas),这个 Canvas 对象包含了 #printable-invoice 的视觉快照。

    • 步骤 4: 转换图像: 使用 generatedCanvas.toDataURL('image/jpeg', 0.9)html2canvas 生成的 Canvas 转换为 JPEG 格式的 Data URL。JPEG 通常比 PNG 文件小,适合嵌入 PDF。0.9 是图像质量参数。

    • 步骤 5: jsPDF 创建和添加图像:

      • const { jsPDF } = window.jspdf; 从全局 window 对象获取 jsPDF 构造函数(因为是通过 CDN 引入的)。
      • const pdf = new jsPDF('p', 'mm', 'a4'); 创建一个新的 PDF 文档实例。'p' 表示纵向 (portrait),'mm' 表示单位为毫米,'a4' 表示纸张大小为 A4。
      • 获取 PDF 页面的可用宽度和高度,并计算边距。
      • 使用 pdf.getImageProperties(imgData) 获取刚刚生成的 JPEG 图像的原始像素尺寸。
      • 计算缩放比例,使图像宽度适应 PDF 页面宽度(减去边距),并按比例计算缩放后的高度。如果缩放后高度超出页面,则改为基于高度进行缩放。
      • 调用 pdf.addImage(imgData, 'JPEG', margin, yPosition, finalWidth, finalHeight); 将图像添加到 PDF 中。参数指定了图像数据、格式、在 PDF 页面上的 X/Y 坐标(带边距)、最终宽度和高度。
    • 步骤 6: 输出 PDF:

      • pdf.output('dataurlnewwindow'); 在浏览器新标签页中打开生成的 PDF 进行预览。如果想直接触发下载,可以使用 pdf.save('filename.pdf');
    • 错误处理: 使用 try...catch 块捕获 html2canvasjsPDF 可能抛出的错误,并在控制台打印错误信息,同时给用户一个提示。

    • finally: 可以在这里添加一些清理操作(如果需要)。

  7. 事件监听器绑定 (Event Listener Binding) :

    • startDrawing, draw, stopDrawing 函数绑定到 Canvas 的鼠标和触摸事件。注意 passive: false 用于触摸事件,允许在监听器内部调用 preventDefault()
    • clearCanvas, restoreHistory, generatePDF 函数分别绑定到对应按钮的 click 事件。
    • calculateTotal 绑定到数量和单价输入框的 input 事件,实现实时计算总价。
  8. 页面加载后的初始化操作:

    • 调用 resizeCanvas() 进行初始尺寸设置。
    • 添加 window.resize 事件监听器,在窗口大小改变时(使用防抖 setTimeout 优化性能)调用 resizeCanvas() 重新调整画布。
    • 调用 calculateTotal() 初始化总金额。
    • 设置默认开票日期为当天。
    • 调用 checkCanvasContent() 更新初始画布状态(隐藏占位符)。
    • 调用 clearCanvasInternal(true) 确保历史记录以空白状态开始。
    • 打印日志表示初始化完成。

总结与注意事项:

  • 库依赖: 这个方案依赖 jsPDFhtml2canvas 两个外部库。确保它们在使用前被正确加载(通过 CDN 或本地文件)。
  • Canvas 尺寸与 DPI: resizeCanvas 函数对于在高分辨率屏幕上获得清晰的签名至关重要。
  • html2canvas 的局限性: html2canvas 是通过读取 DOM 和 CSS 来模拟渲染,它可能无法完美复现所有 CSS 效果(特别是复杂的渐变、阴影、某些 Flexbox/Grid 布局或外部字体)。对于发票这种结构相对规整的场景,效果通常较好。务必在 #printable-invoice 中使用稳定、兼容性好的 CSS 样式。
  • PDF 字体: 默认情况下,jsPDF 可能只支持有限的内置字体(如 Helvetica, Times, Courier)。如果 #printable-invoice 中使用了特殊字体(尤其是中文字体),html2canvas 会将其渲染为图像的一部分,所以文本在 PDF 中实际上是图像,而不是可选择的文本。如果需要 PDF 中的文本可选且支持特定字体,需要配置 jsPDF 加载字体文件(如 .ttf),这会更复杂。对于本方案,将文本渲染为图像是更简单的做法。
  • 性能: html2canvas 对复杂页面进行截图可能比较耗时。签名本身(Canvas 绘图)性能通常很好。
  • 安全性: 所有操作都在前端完成。如果需要将发票或签名数据保存到服务器,需要额外的后端接口和逻辑。签名数据(Data URL)可以直接发送到后端存储。
  • 撤销功能: 实现了一个基于图像快照的简单撤销功能。对于非常复杂的绘制,可能会消耗较多内存。
  • 用户体验: 提供了占位符、清除、撤销功能,实时计算总价,并在生成 PDF 前检查签名。响应式设计确保在不同设备上可用性。
  • 代码量: 这套代码(HTML+CSS+JS)加起来应该能达到或接近您期望的行数,并且提供了相对详细的功能和注释。