MENU

  • 一、知識點梳理
  • 簡版
  • 詳解
  • 二、邏輯技巧與核心流程簡版
  • 1、圖片上傳與預覽
  • 2、參數化繪製
  • 3、Canvas圖像融合
  • 4、彈窗預覽與下載
  • 5、容錯與用户體驗優化
  • 三、邏輯技巧詳解
  • 四、核心功能流程
  • 五、總結與優化點
  • 完整代碼
  • html
  • JavaScrip
  • style

一、知識點梳理

簡版

HTML
<dialog>彈窗、<img />顯示圖片、<button>觸發操作。

CSS
Flex佈局、偽類選擇器、固定尺寸與彈窗遮罩。

JavaScript
DOM操作、事件綁定、時間與隨機字符串處理。

現代瀏覽器API
File System Access API、FileReader、Canvas API、devicePixelRatio。


詳解

HTML 基礎
1、<dialog>彈窗標籤。
2、<div>容器佈局。
3、<img>圖片顯示。
4、<button>操作觸發。

CSS樣式與佈局
1、類選擇器、子選擇器、偽類選擇器。
2、Flex佈局:display: flex + justify-content + align-items。
3、固定尺寸與百分比自適應。
4、按鈕樣式與hover效果。
5、<dialog>背景遮罩:::backdrop。

JavaScript基礎操作
1、DOM操作:getElementById、createElement、appendChild。
2、事件綁定:onclick、addEventListener。
3、時間操作與字符串拼接。
4、隨機字符串生成用於文件命名。

現代瀏覽器API
1、File System Access API:window.showOpenFilePicker()讀取本地文件。
2、FileReader:讀取文件內容生成base64 URL。
3、Canvas API:canvas.getContext(‘2d’)、drawImage繪製圖像。
4、devicePixelRatio:高分屏清晰顯示。


二、邏輯技巧與核心流程簡版

1、圖片上傳與預覽

文件選擇
1、使用window.showOpenFilePicker()彈出文件選擇器。
2、判斷文件類型是否為圖片。

動態DOM渲染
1、用FileReader讀取文件生成base64 URL。
2、動態創建<img />顯示在頁面指定容器。
3、清空舊內容,避免重複疊加。


2、參數化繪製

const params = [ { key: 'sign', value: imgB, x: 0.1, y: 0.7, w: 0.3, h: 0.15 }, { key: 'commonSeal', value: imgC, x: 0.7, y: 0.7, w: 0.2, h: 0.2 } ];

使用百分比參數控制簽名和公章的位置與大小,使其在不同尺寸底圖上仍能正確定位。


3、Canvas圖像融合

1、創建canvas並獲取上下文:canvas.getContext(‘2d’)。
2、高清處理:
2.1、獲取 devicePixelRatio。
2.2、canvas寬高乘以dpr,ctx進行scale。
3、繪製順序:
3.1、繪製底圖。
3.2、繪製簽名、公章。
4、輸出base64圖片:canvas.toDataURL(‘image/png’, 1)。


4、彈窗預覽與下載

彈窗展示
1、使用<dialog>的showModal()彈出結果。

圖片點擊下載
1、動態創建<a>標籤。
2、設置href為base64 URL,download為時間戳 + 隨機字符串。
3、自動觸發點擊事件下載圖片。

優點
1、用户體驗流暢,無需刷新頁面。


5、容錯與用户體驗優化

1、檢查圖片是否上傳完整,未選擇則提示。
2、檢查文件類型是否為圖片,避免錯誤操作。
3、點擊圖片可下載,彈窗有遮罩,按鈕 hover 提示操作。


三、邏輯技巧詳解

參數化位置
1、使用百分比參數(x, y, w, h)控制簽名/公章在底圖上的位置和大小,保證適配各種尺寸底圖。

容錯判斷
1、判斷圖片是否選擇完整,未選擇提示用户。
2、判斷文件類型是否為圖片。
3、下載圖片時判斷URL是否存在。

高清顯示處理
根據window.devicePixelRatio調整canvas尺寸和縮放,保證高清效果。

動態DOM渲染
1、上傳文件時動態生成<img>標籤。
2、生成結果時動態更新預覽區域,避免覆蓋原DOM。

事件與回調管理
1、reader.onload:文件讀取完成後執行回調。
2、圖片點擊下載:addEventListener(‘click’, …)綁定下載事件。
3、彈窗打開/關閉控制。

文件命名技巧
1、時間戳 + 隨機字符串保證下載文件唯一性。


四、核心功能流程

上傳圖片
1、用户點擊“選擇底圖/簽名/公章”,彈出文件選擇器。
2、JS獲取文件 => 判斷是否是圖片 => 使用FileReader讀取 => 顯示在對應容器。

生成融合結果
點擊“生成結果”按鈕,調用merge():
1、創建canvas。
2、設置canvas尺寸,按devicePixelRatio縮放。
3、繪製底圖。
4、按百分比繪製簽名、公章。
5、轉為base64 URL。

顯示與下載
1、將生成圖片顯示在 彈窗。
2、用户點擊圖片 => 創建<a>標籤 => 設置href為base64 URL => 設置download屬性 => 觸發下載。

總結
1、上傳圖片 => File Picker => 讀取 => 動態顯示。
2、生成融合 => Canvas => 按比例繪製 => 輸出base64。
3、預覽與下載 => 彈窗顯示 => 點擊圖片下載。
4、高清顯示 & 容錯處理 => devicePixelRatio + 參數化位置 + 文件類型判斷。


五、總結與優化點

分離關注點
1、HTML結構清晰。
2、CSS樣式獨立。
3、JS邏輯集中。

高清顯示處理,解決Canvas模糊問題。

參數化繪製,適配不同尺寸圖片。

現代API應用,File System Access API + Canvas + dialog。

用户體驗優化
1、點擊圖片下載。
2、彈窗遮罩。
3、hover效果。


完整代碼

html

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>canvas融合</title>
    <link rel="stylesheet" href="./index.css">
</head>

<body>
    <div class="box">
        <div>
            <div class="headBox">
                <h2>選擇操作</h2>

                <div class="btnBox">
                    <div class="selectBtnBox">
                        <button onclick="readFile('idBase','idBaseImg')">選擇底圖</button>

                        <button onclick="readFile('idSign','idSignImg')">選擇簽名</button>

                        <button onclick="readFile('idCommonSeal','idCommonSealImg')">
                            選擇公章
                        </button>
                    </div>

                    <button onclick="openPreviewPanel()" style="background: #67c23a;">生成結果</button>
                </div>
            </div>

            <div class="mainBox">
                <div id="idBase" class="item baseImgBox"></div>

                <div id="idSign" class="item signImgBox"></div>

                <div id="idCommonSeal" class="item commonSealImgBox"></div>
            </div>
        </div>
    </div>

    <dialog id="idDialog" class="dialog">
        <div class="headBox">
            <h3>結果預覽</h3>

            <div class="btn" onclick="idDialog.close()">×</div>
        </div>

        <div class="mainBox">
            <div id="idResult" class="resultBox"></div>

            <div class="tip">點擊圖片可下載哦!</div>
        </div>

        <div class="btnBox">
            <button onclick="idDialog.close()">關 閉</button>
        </div>
    </dialog>

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

</html>

JavaScrip

const getImgEl = (id = '') => document.getElementById(id);

/**
 * 不支持 xlsx / docx 等微軟文件格式
 * @param {*} idImgBox 容器id
 * @param {*} idImg 圖片id
 * @returns 
 */
async function readFile(idImgBox = '', idImg = '') {
    try {
        // 彈出文件選擇對話框
        const [fileHandle] = await window.showOpenFilePicker();
        const file = await fileHandle.getFile();
        // 獲取目標元素
        const previewEl = getImgEl(idImgBox);

        if (!previewEl) return;
        // 清空舊內容
        previewEl.innerHTML = '';
        // 僅處理圖片
        if (file.type.startsWith('image/')) {
            const reader = new FileReader();

            reader.onload = ({ target: { result } }) => {
                const img = document.createElement('img');

                img.id = idImg;
                img.src = result;
                previewEl.appendChild(img);
            }
            reader.readAsDataURL(file);
        } else {
            alert('請選擇圖片文件');
        }
    } catch (error) {
        console.error('Error selecting file: ', error);
    }
}

/**
 * 打開彈窗
 */
async function openPreviewPanel() {
    const isSelect = await merge();

    if (isSelect) {
        idDialog.showModal();
    } else {
        alert('請選擇圖片');
    }
}

/**
 * 生成融合結果
 * @returns 
 */
async function merge() {
    const imgA = getImgEl('idBaseImg');
    const imgB = getImgEl('idSignImg');
    const imgC = getImgEl('idCommonSealImg');
    // 百分比參數(0~1),可根據需求修改或綁定 UI 控件動態調整
    const params = [
        { key: 'sign', value: imgB, x: 0.1, y: 0.7, w: 0.3, h: 0.15 },
        { key: 'commonSeal', value: imgC, x: 0.7, y: 0.7, w: 0.2, h: 0.2 }
    ];

    if (!imgA || !imgB || !imgC) return false;

    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');
    // 清晰度
    const dpr = window.devicePixelRatio || 1;

    const width = imgA.naturalWidth;
    const height = imgA.naturalHeight;

    canvas.width = width * dpr;
    canvas.height = height * dpr;
    canvas.style.width = width + 'px';
    canvas.style.height = height + 'px';
    ctx.scale(dpr, dpr);
    ctx.drawImage(imgA, 0, 0, width, height);
    for (let i = 0; i < params.length; i++) {
        const { value, x, y, w, h } = params[i];

        ctx.drawImage(value, width * x, height * y, width * w, height * h);
    }

    const resultImg = canvas.toDataURL('image/png', 1);
    const result = getImgEl('idResult');
    const imgEl = document.createElement('img');

    imgEl.src = resultImg;
    imgEl.alt = '圖片加載失敗';
    imgEl.addEventListener('click', () => downloadImg(resultImg));
    result.innerHTML = '';
    result.appendChild(imgEl);
    return true;
}

/**
 * 下載圖片
 * @param {*} url 
 */
function downloadImg(url = '') {
    if (!url) return alert('沒有可下載的圖片');

    const a = document.createElement('a');
    const now = new Date();
    const pad = (n) => n.toString().padStart(2, '0');
    const uuid = Math.random().toString(36).slice(2, 10);
    let timeStr = now.getFullYear();
    let fileName = '';

    timeStr += pad(now.getMonth() + 1);
    timeStr += pad(now.getDate());
    timeStr += pad(now.getHours());
    timeStr += pad(now.getMinutes());
    timeStr += pad(now.getSeconds());
    fileName = `${timeStr}_${uuid}.png`;
    a.href = url;
    a.download = fileName;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
}

style

* {
    margin: 0;
    box-sizing: border-box;
    padding: 0px;
    font-family: Arial;
}

button {
    padding: 6px 18px;
    font-size: 12px;
    border: none;
    border-radius: 4px;
    background-color: #409eff;
    color: #fff;
    cursor: pointer;
    transition: background-color 0.2s;
}

button:hover {
    background-color: #66b1ff;
}

.box {
    padding: 28px;

    >div {
        padding: 18px;
        background: #5f5f5f;
        border-radius: 4px;

        .headBox {
            display: flex;
            justify-content: space-between;
            align-items: center;
            border-bottom: 1px solid #f3f3f3;

            h2 {
                flex: 1;
                color: #f8f8f8;
            }

            .btnBox {
                flex: 3;
                display: flex;
                justify-content: space-between;
                align-items: center;

                .selectBtnBox {
                    display: flex;
                    justify-content: space-between;
                    align-items: center;

                    button:nth-child(2) {
                        margin-left: 68px;
                    }

                    button:last-child {
                        margin-left: 68px;
                    }
                }
            }
        }

        .mainBox {
            display: flex;
            justify-content: space-between;
            margin-top: 18px;

            .item {
                img {
                    width: 100%;
                    height: 100%;
                }
            }

            .item:not(:first-child) {
                margin-top: 18px;
            }

            .baseImgBox {
                width: 210px;
                height: 297px;
            }

            .signImgBox {
                width: 70px;
                height: 30px;
            }

            .commonSealImgBox {
                width: 100px;
                height: 100px;
            }
        }
    }
}

.dialog {
    width: 50%;
    border-radius: 4px;
    margin: auto;
    border: none;
    padding: 8px;

    .headBox {
        display: flex;
        justify-content: space-between;
        align-items: center;
        padding-bottom: 2px;

        .btn {
            width: 20px;
            height: 20px;
            line-height: 20px;
            text-align: center;
            border: 1px solid #333333;
            border-radius: 50%;
            cursor: pointer;
        }
    }

    .mainBox {
        padding-top: 8px;
        padding-bottom: 8px;
        border-top: 1px solid #3f3f3f;
        border-bottom: 1px solid #3f3f3f;

        .resultBox {
            min-height: 200px;
            display: flex;
            justify-content: center;

            img {
                width: 630px;
                height: 891px;
                cursor: pointer;
            }
        }

        .tip {
            margin-top: 8px;
            text-align: right;
            color: #409eff;
        }
    }

    .btnBox {
        display: flex;
        justify-content: flex-end;
        align-items: center;
        padding-top: 4px;

        button {
            background: transparent;
            color: #3f3f3f;
            border: 1px solid #8f8f8f;
        }
    }
}

.dialog::backdrop {
    background: rgba(68, 68, 68, .8);
}