# 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 以獲得更好畫質 - 右鍵保護僅能防止一般使用者,無法完全防止截圖或開發者工具 - 建議搭配伺服器端權限控管,確保文件安全性