---
# System prepended metadata

title: Nuxt3 - html2canvas(html區塊轉圖檔)
tags: [已重新整理]

---

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