# Nuxt3 - html2canvas(html區塊轉圖檔)
## 功能
- html區塊轉為圖檔
- 禁止右鍵點擊
- 添加浮水印
## 使用套件
- [html2canvas](https://github.com/niklasvh/html2canvas?tab=readme-ov-file)
- [moment](https://www.npmjs.com/package/moment)
## 程式碼
### index.vue
```htmlembedded=
<template>
<div id="officialDocumentBox">
<template v-if="isImage">
<ClientOnly>
<div class="imgBox">
<img :src="imgUrl" alt="Captured Image" v-if="imgUrl" @contextmenu="prohibitContextmenu()"/>
</div>
</ClientOnly>
</template>
<template v-else>
<!-- 文件內容及樣式 -->
<div class="officialDocument" id="officialDocument">
<div class="watermark"></div>
<h2 class="title">公文標題</h2>
<ul class="undertakerDataBox">
<template v-if="undertakerData?.address">
<li>地址:{{ undertakerData.address }}</li>
</template>
<template v-if="undertakerData?.undertaker">
<li>承辦人:{{ undertakerData.undertaker }}</li>
</template>
<template v-if="undertakerData?.email">
<li>電子信箱:{{ undertakerData.email }}</li>
</template>
<template v-if="undertakerData?.tel">
<li>電話:{{ undertakerData.tel }}
<template v-if="undertakerData?.extension">
<span style="margin-left: 1.9rem;">分機:{{ undertakerData.extension }}</span>
</template>
</li>
</template>
<template v-if="undertakerData?.fax">
<li>傳真:{{ undertakerData.fax }}</li>
</template>
</ul>
<div class="recipient">
<template v-if="recipientData?.name">
<h3>受文者:{{ recipientData.name }}</h3>
</template>
<ul class="recipientList">
<template v-if="recipientData?.date">
<li>發文日期:中華民國 {{ rocYear(recipientData.dat) }} 年 {{ formatDate(recipientData.date, "MM 月 DD 日") }}</li>
</template>
<template v-if="recipientData?.documentID">
<li>發文字號:{{ recipientData.documentID }}</li>
</template>
<template v-if="recipientData?.speed">
<li>速 別:{{ recipientData.speed }}</li>
</template>
<template v-if="recipientData?.lavel">
<li>密等及解密條件或保密期限:{{ recipientData.lavel }}</li>
</template>
<li class="appendixBox">
<span style="white-space: nowrap;">附 件:</span>
<template v-if="recipientData?.appendix && recipientData?.appendix?.length > 0">
<ol class="appendixList">
<template v-for="(item, index) in recipientData.appendix">
<li>{{ item }}</li>
</template>
</ol>
</template>
<template v-else>
<span>無</span>
</template>
</li>
</ul>
</div>
<div class="purpose">
<template v-if="purpose">
<h3>主旨:{{ purpose }}</h3>
</template>
<div>
<b>說明:</b>
<template v-if="illustrateListData && illustrateListData?.length>0">
<ul class="illustrateList">
<template v-for="(item, index) in illustrateListData">
<li><span class="serialNumber">{{toChineseNumber(index+1)}}</span>{{ item }}</li>
</template>
</ul>
</template>
<template v-else>
<span>無</span>
</template>
</div>
</div>
<div class="otherBox">
<div class="original">
正本:
<template v-if="originalList && originalList.length > 0">
<ol class="originalList">
<template v-for="(item, index) in originalList">
<li>{{ item }}</li>
</template>
</ol>
</template>
<template v-else>
<span style="margin-left: 3rem;">
無
</span>
</template>
</div>
<div class="counterpart">
副本:
<template v-if="counterpartList && counterpartList.length > 0">
<ol class="counterpartList">
<template v-for="(item, index) in counterpartList">
<li>{{ item }}</li>
</template>
</ol>
</template>
<template v-else>
<span style="margin-left: 3rem;">無</span>
</template>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup>
import html2canvas from 'html2canvas'
// composables
const { prohibitContextmenu } = useContextmenu(); // 禁止右鍵點擊
const { formatDate, rocYear } = useMoment(); // 時間轉檔
const { toChineseNumber } = useToChineseNumber(); // 阿拉伯數字轉中文數字
const props = defineProps({
undertakerData: Object,
recipientData: Object,
purpose: String,
illustrateListData: Array,
originalList: Array,
counterpartList: Array
})
const imgUrl = ref("")
const isImage = ref(false)
// 添加浮水印
const addWatermark = (canvas) => {
const width = canvas.width
const height = canvas.height
const tempCanvas = document.createElement('canvas')
const tempContext = tempCanvas.getContext('2d')
tempCanvas.width = width
tempCanvas.height = height
// 將原始畫布繪製到臨時畫布上
tempContext.drawImage(canvas, 0, 0)
tempContext.font = '48px serif'
tempContext.fillStyle = 'rgba(255, 0, 0, 0.2)'
tempContext.textAlign = 'center'
tempContext.textBaseline = 'middle'
const text = '非正式文件,僅供參考' // 浮水印文字
const textWidth = tempContext.measureText(text).width
const stepX = textWidth * 2.5 // 浮水印文字左右距離
const stepY = 600 // 浮水印文字上下距離
const angle = Math.PI / 4 // 浮水印文字旋轉 45deg
// 循環添加浮水印
for (let x = -width; x < width * 2; x += stepX) {
for (let y = -height; y < height * 2; y += stepY) {
tempContext.save()
tempContext.translate(x, y)
tempContext.rotate(angle)
tempContext.fillText(text, 0, 0)
tempContext.restore()
}
}
// 輸出圖檔
imgUrl.value = tempCanvas.toDataURL('image/jpeg')
isImage.value = true
}
// 區塊轉圖檔
const capture = async () => {
isImage.value = false
const element = document.querySelector('#officialDocument')
if (element) {
try {
const canvas = await html2canvas(element)
addWatermark(canvas)
} catch (error) {
console.error('轉檔圖片失敗:', error)
}
}
}
onMounted(() => {
capture()
})
</script>
```
### composables
#### useContextmenu.js
```javascript=
//禁止右鍵點擊
export const useContextmenu = () => {
const prohibitContextmenu = ()=> {
window.event.returnValue=false;
}
return {
prohibitContextmenu,
};
};
```
#### useMoment.js
```javascript=
// 引入日期套件變更日期顯示格式
import moment from "moment";
export const useMoment = () => {
// 使用時間套件
const formatDate = (date, format) => {
return moment(date).format(format);
};
// 西元換算民國
const rocYear = (date) =>{
return moment(date).format('YYYY') - 1911;
}
return {
formatDate,
rocYear
}
}
```
#### useToChineseNumber.js
```javascript=
// 阿拉伯數字轉中文數字
export const useToChineseNumber = () => {
const toChineseNumber = (num) =>{
const chineseNumbers = ['', '一', '二', '三', '四', '五', '六', '七', '八', '九'];
const chineseTens = ['', '十', '二十', '三十', '四十', '五十', '六十', '七十', '八十', '九十'];
if (num >= 1 && num <= 99) {
const tensDigit = Math.floor(num / 10);
const onesDigit = num % 10;
let result = '';
if (tensDigit > 0) {
result += chineseTens[tensDigit];
}
if (onesDigit > 0) {
result += chineseNumbers[onesDigit];
}
return result;
}
return num.toString();
}
return {
toChineseNumber
}
}
```
## Error
### 原檔設置陰影在轉換成圖檔後,造成背景色彩出現色區塊
解決方法:原檔陰影改設置到轉換後的圖檔上
### list設置list-style-type:cjk-ideographic 與 list-style-position:inside,轉換成圖檔後位置出現偏移
解決方法:改用自製的composables-useToChineseNumber.js 將v-for的index轉為中文數字
# Nuxt3 - html2canvas(html區塊轉圖檔)
將 HTML 區塊轉換為圖片,並加入浮水印與防護功能。
## 功能特色
- HTML 區塊轉換為圖片格式
- 禁用右鍵選單保護內容
- 自動添加浮水印防止盜用
## 使用套件
- [html2canvas](https://github.com/niklasvh/html2canvas) - HTML 轉圖片核心套件
- [moment](https://www.npmjs.com/package/moment) - 日期格式處理
---
## 實作程式碼
### 主要元件:index.vue
```htmlembedded=
<template>
<div id="officialDocumentBox">
<!-- 圖片顯示模式 -->
<template v-if="isImage">
<ClientOnly>
<div class="imgBox">
<img
:src="imgUrl"
alt="Captured Image"
v-if="imgUrl"
@contextmenu="prohibitContextmenu()"
/>
</div>
</ClientOnly>
</template>
<!-- 文件內容模式 -->
<template v-else>
<div class="officialDocument" id="officialDocument">
<div class="watermark"></div>
<!-- 標題 -->
<h2 class="title">公文標題</h2>
<!-- 承辦人資訊 -->
<ul class="undertakerDataBox">
<li v-if="undertakerData?.address">
地址:{{ undertakerData.address }}
</li>
<li v-if="undertakerData?.undertaker">
承辦人:{{ undertakerData.undertaker }}
</li>
<li v-if="undertakerData?.email">
電子信箱:{{ undertakerData.email }}
</li>
<li v-if="undertakerData?.tel">
電話:{{ undertakerData.tel }}
<span v-if="undertakerData?.extension" style="margin-left: 1.9rem;">
分機:{{ undertakerData.extension }}
</span>
</li>
<li v-if="undertakerData?.fax">
傳真:{{ undertakerData.fax }}
</li>
</ul>
<!-- 受文者資訊 -->
<div class="recipient">
<h3 v-if="recipientData?.name">
受文者:{{ recipientData.name }}
</h3>
<ul class="recipientList">
<li v-if="recipientData?.date">
發文日期:中華民國 {{ rocYear(recipientData.date) }} 年
{{ formatDate(recipientData.date, "MM 月 DD 日") }}
</li>
<li v-if="recipientData?.documentID">
發文字號:{{ recipientData.documentID }}
</li>
<li v-if="recipientData?.speed">
速 別:{{ recipientData.speed }}
</li>
<li v-if="recipientData?.lavel">
密等及解密條件或保密期限:{{ recipientData.lavel }}
</li>
<li class="appendixBox">
<span style="white-space: nowrap;">附 件:</span>
<ol v-if="recipientData?.appendix?.length > 0" class="appendixList">
<li v-for="item in recipientData.appendix" :key="item">
{{ item }}
</li>
</ol>
<span v-else>無</span>
</li>
</ul>
</div>
<!-- 主旨與說明 -->
<div class="purpose">
<h3 v-if="purpose">主旨:{{ purpose }}</h3>
<div>
<b>說明:</b>
<ul v-if="illustrateListData?.length > 0" class="illustrateList">
<li v-for="(item, index) in illustrateListData" :key="index">
<span class="serialNumber">{{ toChineseNumber(index + 1) }}</span>
{{ item }}
</li>
</ul>
<span v-else>無</span>
</div>
</div>
<!-- 正本與副本 -->
<div class="otherBox">
<div class="original">
正本:
<ol v-if="originalList?.length > 0" class="originalList">
<li v-for="item in originalList" :key="item">{{ item }}</li>
</ol>
<span v-else style="margin-left: 3rem;">無</span>
</div>
<div class="counterpart">
副本:
<ol v-if="counterpartList?.length > 0" class="counterpartList">
<li v-for="item in counterpartList" :key="item">{{ item }}</li>
</ol>
<span v-else style="margin-left: 3rem;">無</span>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup>
import html2canvas from 'html2canvas'
// Composables
const { prohibitContextmenu } = useContextmenu()
const { formatDate, rocYear } = useMoment()
const { toChineseNumber } = useToChineseNumber()
// Props
const props = defineProps({
undertakerData: Object,
recipientData: Object,
purpose: String,
illustrateListData: Array,
originalList: Array,
counterpartList: Array
})
// State
const imgUrl = ref("")
const isImage = ref(false)
/**
* 添加浮水印到畫布
* @param {HTMLCanvasElement} canvas - 原始畫布
*/
const addWatermark = (canvas) => {
const width = canvas.width
const height = canvas.height
const tempCanvas = document.createElement('canvas')
const tempContext = tempCanvas.getContext('2d')
tempCanvas.width = width
tempCanvas.height = height
// 繪製原始內容
tempContext.drawImage(canvas, 0, 0)
// 設定浮水印樣式
tempContext.font = '48px serif'
tempContext.fillStyle = 'rgba(255, 0, 0, 0.2)'
tempContext.textAlign = 'center'
tempContext.textBaseline = 'middle'
const text = '非正式文件,僅供參考'
const textWidth = tempContext.measureText(text).width
const stepX = textWidth * 2.5 // 水平間距
const stepY = 600 // 垂直間距
const angle = Math.PI / 4 // 旋轉 45 度
// 繪製浮水印網格
for (let x = -width; x < width * 2; x += stepX) {
for (let y = -height; y < height * 2; y += stepY) {
tempContext.save()
tempContext.translate(x, y)
tempContext.rotate(angle)
tempContext.fillText(text, 0, 0)
tempContext.restore()
}
}
// 輸出圖片
imgUrl.value = tempCanvas.toDataURL('image/jpeg')
isImage.value = true
}
/**
* 將 HTML 區塊轉換為圖片
*/
const capture = async () => {
isImage.value = false
const element = document.querySelector('#officialDocument')
if (element) {
try {
const canvas = await html2canvas(element)
addWatermark(canvas)
} catch (error) {
console.error('轉換圖片失敗:', error)
}
}
}
onMounted(() => {
capture()
})
</script>
```
---
### Composables
#### useContextmenu.js
禁用右鍵選單功能。
```javascript
/**
* 禁止右鍵點擊
*/
export const useContextmenu = () => {
const prohibitContextmenu = () => {
window.event.returnValue = false
}
return {
prohibitContextmenu
}
}
```
#### useMoment.js
日期格式轉換與民國年份計算。
```javascript
import moment from "moment"
/**
* 日期格式化工具
*/
export const useMoment = () => {
/**
* 格式化日期
* @param {string} date - 日期字串
* @param {string} format - 格式模板
* @returns {string} 格式化後的日期
*/
const formatDate = (date, format) => {
return moment(date).format(format)
}
/**
* 西元年轉民國年
* @param {string} date - 日期字串
* @returns {number} 民國年份
*/
const rocYear = (date) => {
return moment(date).format('YYYY') - 1911
}
return {
formatDate,
rocYear
}
}
```
#### useToChineseNumber.js
阿拉伯數字轉中文數字(1-99)。
```javascript
/**
* 阿拉伯數字轉中文數字
*/
export const useToChineseNumber = () => {
/**
* 轉換數字為中文
* @param {number} num - 要轉換的數字 (1-99)
* @returns {string} 中文數字
*/
const toChineseNumber = (num) => {
const chineseNumbers = ['', '一', '二', '三', '四', '五', '六', '七', '八', '九']
const chineseTens = ['', '十', '二十', '三十', '四十', '五十', '六十', '七十', '八十', '九十']
if (num >= 1 && num <= 99) {
const tensDigit = Math.floor(num / 10)
const onesDigit = num % 10
let result = ''
if (tensDigit > 0) {
result += chineseTens[tensDigit]
}
if (onesDigit > 0) {
result += chineseNumbers[onesDigit]
}
return result
}
return num.toString()
}
return {
toChineseNumber
}
}
```
---
## 已知問題與解決方案
### 問題 1:陰影造成色塊
**現象**
原始 HTML 設定的陰影效果在轉換為圖片後,背景出現色塊。
**解決方法**
將陰影樣式從原始 HTML 移除,改為在轉換後的圖片元素上設置陰影。
### 問題 2:清單樣式位置偏移
**現象**
使用 `list-style-type: cjk-ideographic` 和 `list-style-position: inside` 時,轉換後的圖片中清單位置出現偏移。
**解決方法**
改用自製的 `useToChineseNumber.js`,將 `v-for` 的 `index` 手動轉換為中文數字顯示。
---
## 使用範例
```vue
<template>
<OfficialDocument
:undertaker-data="undertaker"
:recipient-data="recipient"
:purpose="mainPurpose"
:illustrate-list-data="illustrations"
:original-list="originals"
:counterpart-list="counterparts"
/>
</template>
<script setup>
const undertaker = {
address: '台北市信義區...',
undertaker: '王小明',
email: 'wang@example.com',
tel: '02-12345678',
extension: '123',
fax: '02-87654321'
}
const recipient = {
name: '某某單位',
date: '2024-11-19',
documentID: 'ABC-1234567',
speed: '普通',
lavel: '普通',
appendix: ['附件一', '附件二']
}
const mainPurpose = '關於某某事項之說明'
const illustrations = ['說明事項一', '說明事項二']
const originals = ['單位A', '單位B']
const counterparts = ['單位C']
</script>
```
---
## 注意事項
- 浮水印文字與樣式可在 `addWatermark()` 函數中自訂
- 圖片格式預設為 JPEG,可修改為 PNG 以獲得更好畫質
- 右鍵保護僅能防止一般使用者,無法完全防止截圖或開發者工具
- 建議搭配伺服器端權限控管,確保文件安全性