可在 Google Apps Script 中實際進行 OCR 及圖片分析

 以下提供一個 「可在 Google Apps Script 中實際進行 OCR 及圖片分析」 的範例,使用 Google Cloud Vision API 完成「文字辨識 (OCR)」和「影像標籤分析」的功能。請注意,若要在 Apps Script 中使用此功能,需要:

  1. 在 Google Cloud Console 中啟用「Cloud Vision API」並建立 API Key
  2. 於 Apps Script 專案中啟用「UrlFetchApp」的權限(預設大多已開啟)。
  3. 有計費專案,因為 Cloud Vision API 屬於付費方案(有免費額度)。

步驟總覽

  1. 建立或開啟一個 Google Apps Script 專案。
  2. 建立一個檔案(例如 Code.gs)貼上伺服器端程式碼。
  3. 建立另一個檔案(例如 Index.html)貼上網頁前端程式碼。
  4. Code.gs 中將 YOUR_API_KEY 替換成你在 Google Cloud Console 申請的實際 API Key。
  5. 部署為新 Web App(Deploy → New deployment → Select type: Web App),設定好權限後,即可使用。

1. 伺服器端:Code.gs

/**
 * 主入口:GET 請求時回傳 HTML 頁面
 */
function doGet() {
  return HtmlService.createTemplateFromFile('Index')
    .evaluate()
    .setTitle("File Upload");
}

/**
 * 分析影像:呼叫 Cloud Vision API 的 LABEL_DETECTION
 * @param {string} imageDataUrl - Base64 的 Data URL
 * @return {string} 分析結果文字
 */
function analyzeImageServer(imageDataUrl) {
  // 從 Data URL 中取出 base64 編碼
  var base64Data = imageDataUrl.split(',')[1];
  
  // 你的 Cloud Vision API Key
  var apiKey = "YOUR_API_KEY";  // <--- 請替換成實際的 API Key
  
  // Vision API 的端點
  var endpoint = "https://vision.googleapis.com/v1/images:annotate?key=" + apiKey;
  
  // 建構請求 JSON
  var requestBody = {
    "requests": [
      {
        "image": {
          "content": base64Data
        },
        "features": [
          {
            "type": "LABEL_DETECTION",
            "maxResults": 5
          }
        ]
      }
    ]
  };
  
  // 透過 UrlFetchApp 呼叫 Vision API
  var response = UrlFetchApp.fetch(endpoint, {
    method: "post",
    contentType: "application/json",
    payload: JSON.stringify(requestBody),
    muteHttpExceptions: true
  });
  
  var json = JSON.parse(response.getContentText());
  
  // 若有錯誤,則顯示錯誤訊息
  if (json.error) {
    throw new Error(json.error.message);
  }
  
  // 從回傳結果中取出 labelAnnotations
  var labels = json.responses[0].labelAnnotations || [];
  
  if (labels.length === 0) {
    return "未偵測到明顯的標籤。";
  }
  
  // 將每個標籤與信心分數做簡易串接
  var result = labels.map(function(label) {
    var desc = label.description || "未知";
    var score = label.score ? (label.score * 100).toFixed(1) + "%" : "無";
    return desc + " (信心: " + score + ")";
  });
  
  return "圖像標籤分析結果:\n" + result.join("\n");
}

/**
 * 文字辨識:呼叫 Cloud Vision API 的 TEXT_DETECTION
 * @param {string} imageDataUrl - Base64 的 Data URL
 * @return {string} OCR 文字辨識結果
 */
function recognizeTextServer(imageDataUrl) {
  // 從 Data URL 中取出 base64 編碼
  var base64Data = imageDataUrl.split(',')[1];
  
  // 你的 Cloud Vision API Key
  var apiKey = "YOUR_API_KEY";  // <--- 請替換成實際的 API Key
  
  // Vision API 的端點
  var endpoint = "https://vision.googleapis.com/v1/images:annotate?key=" + apiKey;
  
  // 建構請求 JSON
  var requestBody = {
    "requests": [
      {
        "image": {
          "content": base64Data
        },
        "features": [
          {
            "type": "TEXT_DETECTION"
          }
        ]
      }
    ]
  };
  
  // 透過 UrlFetchApp 呼叫 Vision API
  var response = UrlFetchApp.fetch(endpoint, {
    method: "post",
    contentType: "application/json",
    payload: JSON.stringify(requestBody),
    muteHttpExceptions: true
  });
  
  var json = JSON.parse(response.getContentText());
  
  // 若有錯誤,則顯示錯誤訊息
  if (json.error) {
    throw new Error(json.error.message);
  }
  
  // 取得偵測到的文字
  var textAnnotation = json.responses[0].fullTextAnnotation;
  if (!textAnnotation || !textAnnotation.text) {
    return "未偵測到文字。";
  }
  
  return textAnnotation.text;
}

提醒:請務必將 YOUR_API_KEY 替換成你在 Google Cloud Console 上為 Cloud Vision API 申請的 實際 API Key,並確認已啟用 Cloud Vision API、綁定計費專案。


2. 客戶端:Index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>File Upload</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
      font-family: Arial, sans-serif;
    }
    
    body {
      background-color: #f5f5f5;
      display: flex;
      justify-content: center;
      align-items: center;
      min-height: 100vh;
      padding: 20px;
    }
    
    .container {
      background-color: white;
      border-radius: 10px;
      box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
      padding: 30px;
      width: 100%;
      max-width: 500px;
    }
    
    h1 {
      text-align: center;
      margin-bottom: 20px;
      color: #333;
    }
    
    .upload-area {
      border: 2px dashed #3498db;
      border-radius: 5px;
      padding: 30px;
      text-align: center;
      margin-bottom: 20px;
      transition: background-color 0.3s, border-color 0.3s;
      cursor: pointer;
    }
    
    .upload-area:hover, .upload-area.dragover {
      background-color: rgba(52, 152, 219, 0.1);
      border-color: #2980b9;
    }
    
    .upload-icon {
      color: #3498db;
      margin-bottom: 15px;
    }
    
    .browse-link {
      color: #3498db;
      cursor: pointer;
      text-decoration: underline;
    }
    
    .file-list {
      margin-bottom: 20px;
    }
    
    .file-item {
      display: flex;
      flex-direction: column;
      padding: 12px;
      border: 1px solid #eee;
      border-radius: 5px;
      margin-bottom: 12px;
      box-shadow: 0 1px 3px rgba(0,0,0,0.05);
      transition: box-shadow 0.2s;
    }
    
    .file-item:hover {
      box-shadow: 0 2px 5px rgba(0,0,0,0.1);
    }
    
    .file-item-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 5px;
      width: 100%;
    }
    
    .file-info {
      display: flex;
      align-items: center;
      width: 80%;
    }
    
    .file-icon {
      margin-right: 10px;
      color: #555;
      min-width: 24px;
    }
    
    .file-preview {
      width: 40px;
      height: 40px;
      margin-right: 10px;
      object-fit: cover;
      border-radius: 3px;
    }
    
    .file-name {
      margin-right: 10px;
      word-break: break-all;
    }
    
    .file-size {
      color: #777;
      font-size: 0.8rem;
    }
    
    .file-buttons {
      display: flex;
      align-items: center;
    }
    
    .preview-file, .remove-file, .analyze-file, .text-recognize-file {
      cursor: pointer;
      background: none;
      border: none;
      display: flex;
      align-items: center;
      justify-content: center;
      margin-left: 5px;
      padding: 5px;
      border-radius: 3px;
      transition: background-color 0.2s;
    }
    
    .preview-file {
      color: #3498db;
    }
    
    .preview-file:hover {
      background-color: rgba(52, 152, 219, 0.1);
    }
    
    .remove-file {
      color: #e74c3c;
      font-size: 1.2rem;
    }
    
    .remove-file:hover {
      background-color: rgba(231, 76, 60, 0.1);
    }
    
    .analyze-file {
      color: #9b59b6;
    }
    
    .analyze-file:hover {
      background-color: rgba(155, 89, 182, 0.1);
    }
    
    .text-recognize-file {
      color: #27ae60;
    }
    
    .text-recognize-file:hover {
      background-color: rgba(39, 174, 96, 0.1);
    }
    
    .upload-button {
      background-color: #3498db;
      color: white;
      border: none;
      border-radius: 5px;
      padding: 10px 20px;
      width: 100%;
      cursor: pointer;
      font-size: 1rem;
      transition: background-color 0.3s;
    }
    
    .upload-button:hover:not(:disabled) {
      background-color: #2980b9;
    }
    
    .upload-button:disabled {
      background-color: #bdc3c7;
      cursor: not-allowed;
    }
    
    .progress-bar {
      height: 5px;
      background-color: #eee;
      border-radius: 5px;
      margin-top: 5px;
      overflow: hidden;
    }
    
    .progress {
      height: 100%;
      background-color: #2ecc71;
      width: 0;
      transition: width 0.3s;
    }
    
    .analyzing .progress {
      transition: width 0.5s;
    }
    
    .analysis-result, .text-recognition-result {
      width: 100%;
      margin-top: 10px;
      padding: 12px;
      border-radius: 6px;
      background-color: #f8f9fa;
      font-size: 0.9rem;
      border: 1px solid #e9ecef;
      box-shadow: 0 1px 3px rgba(0,0,0,0.05);
      overflow-wrap: break-word;
      white-space: pre-line;
    }
    
    .analysis-loading, .text-loading {
      color: #3498db;
      font-style: italic;
      padding: 5px 0;
    }
    
    .analysis-content, .text-content {
      color: #333;
      line-height: 1.6;
      max-height: 300px;
      overflow-y: auto;
      padding-right: 5px;
    }
    
    .analysis-error, .text-error {
      color: #e74c3c;
      font-style: italic;
      padding: 5px 0;
    }
    
    .analysis-content::-webkit-scrollbar, .text-content::-webkit-scrollbar {
      width: 6px;
    }
    
    .analysis-content::-webkit-scrollbar-track, .text-content::-webkit-scrollbar-track {
      background: #f1f1f1;
      border-radius: 3px;
    }
    
    .analysis-content::-webkit-scrollbar-thumb, .text-content::-webkit-scrollbar-thumb {
      background: #c1c1c1;
      border-radius: 3px;
    }
    
    .analysis-content::-webkit-scrollbar-thumb:hover, .text-content::-webkit-scrollbar-thumb:hover {
      background: #a8a8a8;
    }
    
    .preview-modal {
      display: none;
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background-color: rgba(0, 0, 0, 0.7);
      z-index: 1000;
      justify-content: center;
      align-items: center;
      backdrop-filter: blur(3px);
    }
    
    .preview-container {
      background-color: white;
      border-radius: 8px;
      padding: 20px;
      max-width: 90%;
      max-height: 90%;
      overflow: hidden;
      position: relative;
      box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
    }
    
    .preview-close {
      position: absolute;
      top: 10px;
      right: 10px;
      background: rgba(0, 0, 0, 0.2);
      border: none;
      color: white;
      border-radius: 50%;
      width: 30px;
      height: 30px;
      display: flex;
      align-items: center;
      justify-content: center;
      cursor: pointer;
      font-size: 20px;
      transition: background-color 0.2s;
    }
    
    .preview-close:hover {
      background: rgba(0, 0, 0, 0.4);
    }
    
    .preview-content {
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      max-height: calc(90vh - 40px);
      overflow: auto;
    }
    
    .preview-image, .preview-video {
      max-width: 100%;
      max-height: 70vh;
      object-fit: contain;
    }
    
    .preview-audio {
      width: 100%;
      max-width: 400px;
    }
    
    .preview-icon {
      font-size: 64px;
      color: #555;
      margin: 20px 0;
    }
    
    .preview-name {
      font-weight: bold;
      margin: 10px 0;
      word-break: break-all;
      text-align: center;
    }
    
    .preview-info {
      color: #777;
      text-align: center;
    }
  </style>
</head>
<body>
  <div class="container">
    <h1>File Upload</h1>
    <div class="upload-area" id="uploadArea">
      <div class="upload-icon">
        <svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
          <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
          <polyline points="17 8 12 3 7 8"></polyline>
          <line x1="12" y1="3" x2="12" y2="15"></line>
        </svg>
      </div>
      <p>拖曳檔案至此或 <label for="fileInput" class="browse-link">瀏覽</label></p>
      <input type="file" id="fileInput" multiple hidden>
    </div>
    <div class="file-list" id="fileList"></div>
    <button id="uploadButton" class="upload-button" disabled>上傳檔案</button>
  </div>
  
  <!-- Preview Modal -->
  <div class="preview-modal" id="previewModal">
    <div class="preview-container">
      <button id="closeModal" class="preview-close">&times;</button>
      <div class="preview-content" id="previewContent"></div>
    </div>
  </div>
  
  <script>
    document.addEventListener('DOMContentLoaded', function() {
      const uploadArea = document.getElementById('uploadArea');
      const fileInput = document.getElementById('fileInput');
      const fileList = document.getElementById('fileList');
      const uploadButton = document.getElementById('uploadButton');
      const previewModal = document.getElementById('previewModal');
      const previewContent = document.getElementById('previewContent');
      const closeModal = document.getElementById('closeModal');
      
      let files = [];
      
      // 避免預設拖曳事件
      ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
        uploadArea.addEventListener(eventName, preventDefaults, false);
      });
      
      function preventDefaults(e) {
        e.preventDefault();
        e.stopPropagation();
      }
      
      ['dragenter', 'dragover'].forEach(eventName => {
        uploadArea.addEventListener(eventName, highlight, false);
      });
      
      ['dragleave', 'drop'].forEach(eventName => {
        uploadArea.addEventListener(eventName, unhighlight, false);
      });
      
      function highlight() {
        uploadArea.classList.add('dragover');
      }
      
      function unhighlight() {
        uploadArea.classList.remove('dragover');
      }
      
      uploadArea.addEventListener('drop', handleDrop, false);
      
      function handleDrop(e) {
        const dt = e.dataTransfer;
        const newFiles = dt.files;
        handleFiles(newFiles);
      }
      
      uploadArea.addEventListener('click', () => {
        fileInput.click();
      });
      
      fileInput.addEventListener('change', () => {
        handleFiles(fileInput.files);
      });
      
      function handleFiles(newFiles) {
        if (newFiles.length === 0) return;
        const filesArray = Array.from(newFiles);
        const processedFiles = filesArray.map(file => {
          if (file.type.startsWith('image/')) {
            file.previewUrl = URL.createObjectURL(file);
          }
          return file;
        });
        files = [...files, ...processedFiles];
        updateFileList();
        fileInput.value = '';
      }
      
      function updateFileList() {
        fileList.innerHTML = '';
        files.forEach((file, index) => {
          const fileItem = document.createElement('div');
          fileItem.className = 'file-item';
          
          const fileItemHeader = document.createElement('div');
          fileItemHeader.className = 'file-item-header';
          
          const fileInfo = document.createElement('div');
          fileInfo.className = 'file-info';
          
          if (file.type.startsWith('image/') && file.previewUrl) {
            const preview = document.createElement('img');
            preview.className = 'file-preview';
            preview.src = file.previewUrl;
            preview.alt = file.name;
            fileInfo.appendChild(preview);
          } else {
            const fileIcon = document.createElement('div');
            fileIcon.className = 'file-icon';
            fileIcon.innerHTML = getFileIcon(file);
            fileInfo.appendChild(fileIcon);
          }
          
          const fileName = document.createElement('div');
          fileName.className = 'file-name';
          fileName.textContent = file.name;
          
          const fileSize = document.createElement('div');
          fileSize.className = 'file-size';
          fileSize.textContent = formatFileSize(file.size);
          
          const buttonsContainer = document.createElement('div');
          buttonsContainer.className = 'file-buttons';
          
          const previewButton = document.createElement('button');
          previewButton.className = 'preview-file';
          previewButton.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>';
          previewButton.title = "預覽";
          previewButton.addEventListener('click', (e) => {
            e.stopPropagation();
            previewFile(file);
          });
          
          const analyzeButton = document.createElement('button');
          analyzeButton.className = 'analyze-file';
          analyzeButton.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><path d="M12 16v-4"></path><path d="M12 8h.01"></path></svg>';
          analyzeButton.title = "影像標籤分析";
          analyzeButton.addEventListener('click', (e) => {
            e.stopPropagation();
            if (file.type.startsWith('image/')) {
              analyzeImage(file, index);
            } else {
              alert('目前只支援圖片檔案的分析功能');
            }
          });
          
          const textRecognizeButton = document.createElement('button');
          textRecognizeButton.className = 'text-recognize-file';
          textRecognizeButton.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h16v3"></path><path d="M9 20h6"></path><path d="M12 4v16"></path></svg>';
          textRecognizeButton.title = "文字辨識";
          textRecognizeButton.addEventListener('click', (e) => {
            e.stopPropagation();
            if (file.type.startsWith('image/')) {
              recognizeText(file, index);
            } else {
              alert('目前只支援圖片檔案的文字辨識功能');
            }
          });
          
          const removeButton = document.createElement('button');
          removeButton.className = 'remove-file';
          removeButton.innerHTML = '&times;';
          removeButton.title = "移除";
          removeButton.addEventListener('click', (e) => {
            e.stopPropagation();
            removeFile(index);
          });
          
          buttonsContainer.appendChild(previewButton);
          buttonsContainer.appendChild(analyzeButton);
          buttonsContainer.appendChild(textRecognizeButton);
          buttonsContainer.appendChild(removeButton);
          
          fileInfo.appendChild(fileName);
          fileInfo.appendChild(fileSize);
          
          fileItemHeader.appendChild(fileInfo);
          fileItemHeader.appendChild(buttonsContainer);
          
          const progressBarContainer = document.createElement('div');
          progressBarContainer.className = 'progress-bar';
          
          const progressBar = document.createElement('div');
          progressBar.className = 'progress';
          progressBarContainer.appendChild(progressBar);
          
          fileItem.appendChild(fileItemHeader);
          fileItem.appendChild(progressBarContainer);
          
          fileList.appendChild(fileItem);
          
          // 若已經有分析或辨識結果(理論上初次不會有),可在此顯示
          if (file.analysisResult) {
            const analysisContainer = document.createElement('div');
            analysisContainer.className = 'analysis-result';
            analysisContainer.innerHTML = `<div class="analysis-content">${file.analysisResult}</div>`;
            fileItem.appendChild(analysisContainer);
            file.analysisContainer = analysisContainer;
          }
          
          if (file.textRecognitionResult) {
            const textContainer = document.createElement('div');
            textContainer.className = 'text-recognition-result';
            textContainer.innerHTML = `<div class="text-content">${file.textRecognitionResult}</div>`;
            fileItem.appendChild(textContainer);
            file.textContainer = textContainer;
          }
        });
        
        uploadButton.disabled = files.length === 0;
      }
      
      function removeFile(index) {
        if (files[index].previewUrl) {
          URL.revokeObjectURL(files[index].previewUrl);
        }
        files.splice(index, 1);
        updateFileList();
      }
      
      function getFileIcon(file) {
        const fileType = file.type.split('/')[0];
        let icon = '';
        switch (fileType) {
          case 'image':
            icon = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>';
            break;
          case 'video':
            icon = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"></rect><line x1="7" y1="2" x2="7" y2="22"></line><line x1="17" y1="2" x2="17" y2="22"></line><line x1="2" y1="12" x2="22" y2="12"></line><line x1="2" y1="7" x2="7" y2="7"></line><line x1="2" y1="17" x2="7" y2="17"></line><line x1="17" y1="17" x2="22" y2="17"></line><line x1="17" y1="7" x2="22" y2="7"></line></svg>';
            break;
          case 'audio':
            icon = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18v-6a9 9 0 0 1 18 0v6"></path><path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"></path></svg>';
            break;
          default:
            icon = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>';
        }
        return icon;
      }
      
      function formatFileSize(bytes) {
        const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
        if (bytes === 0) return '0 Byte';
        const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)));
        return Math.round(bytes / Math.pow(1024, i), 2) + ' ' + sizes[i];
      }
      
      function previewFile(file) {
        previewContent.innerHTML = '';
        if (file.type.startsWith('image/')) {
          const img = document.createElement('img');
          img.src = file.previewUrl;
          img.alt = file.name;
          img.className = 'preview-image';
          previewContent.appendChild(img);
        } else if (file.type.startsWith('video/')) {
          const video = document.createElement('video');
          video.src = URL.createObjectURL(file);
          video.controls = true;
          video.autoplay = false;
          video.className = 'preview-video';
          previewContent.appendChild(video);
        } else if (file.type.startsWith('audio/')) {
          const audio = document.createElement('audio');
          audio.src = URL.createObjectURL(file);
          audio.controls = true;
          audio.className = 'preview-audio';
          previewContent.appendChild(audio);
        } else {
          const icon = document.createElement('div');
          icon.className = 'preview-icon';
          icon.innerHTML = getFileIcon(file);
          const name = document.createElement('p');
          name.textContent = file.name;
          name.className = 'preview-name';
          const info = document.createElement('p');
          info.textContent = '此檔案類型無法預覽';
          info.className = 'preview-info';
          previewContent.appendChild(icon);
          previewContent.appendChild(name);
          previewContent.appendChild(info);
        }
        previewModal.style.display = 'flex';
      }
      
      closeModal.addEventListener('click', () => {
        previewModal.style.display = 'none';
        const videoOrAudio = previewContent.querySelector('video, audio');
        if (videoOrAudio && videoOrAudio.src) {
          URL.revokeObjectURL(videoOrAudio.src);
        }
        previewContent.innerHTML = '';
      });
      
      previewModal.addEventListener('click', (e) => {
        if (e.target === previewModal) {
          closeModal.click();
        }
      });
      
      uploadButton.addEventListener('click', () => {
        const progressBars = document.querySelectorAll('.progress');
        progressBars.forEach((progressBar, index) => {
          let progress = 0;
          const interval = setInterval(() => {
            progress += Math.random() * 10;
            if (progress >= 100) {
              progress = 100;
              clearInterval(interval);
              if (index === files.length - 1) {
                setTimeout(() => {
                  alert('所有檔案上傳完成!(示範用:實際未上傳至伺服器)');
                  files.forEach(file => {
                    if (file.previewUrl) {
                      URL.revokeObjectURL(file.previewUrl);
                    }
                  });
                  files = [];
                  updateFileList();
                }, 500);
              }
            }
            progressBar.style.width = progress + '%';
          }, 200);
        });
      });
      
      function analyzeImage(file, index) {
        const fileItems = document.querySelectorAll('.file-item');
        const fileItem = fileItems[index];
        fileItem.classList.add('analyzing');
        const progressBar = fileItem.querySelector('.progress');
        progressBar.style.width = '100%';
        progressBar.style.backgroundColor = '#3498db';
        
        readFileAsDataURL(file).then(imageDataUrl => {
          if (!file.analysisContainer) {
            const analysisContainer = document.createElement('div');
            analysisContainer.className = 'analysis-result';
            fileItem.appendChild(analysisContainer);
            file.analysisContainer = analysisContainer;
          }
          file.analysisContainer.innerHTML = '<div class="analysis-loading">影像標籤分析中...</div>';
          
          // 呼叫後端 Google Apps Script 函式
          google.script.run
            .withSuccessHandler(function(result) {
              file.analysisResult = result;
              file.analysisContainer.innerHTML = `<div class="analysis-content">${file.analysisResult}</div>`;
              fileItem.classList.remove('analyzing');
              progressBar.style.width = '0%';
            })
            .withFailureHandler(function(error) {
              console.error('Image analysis error:', error);
              file.analysisContainer.innerHTML = `<div class="analysis-error">辨識失敗: ${error.message}</div>`;
              fileItem.classList.remove('analyzing');
              progressBar.style.width = '0%';
            })
            .analyzeImageServer(imageDataUrl);
          
        }).catch(error => {
          console.error('File read error:', error);
          fileItem.classList.remove('analyzing');
          progressBar.style.width = '0%';
        });
      }
      
      function recognizeText(file, index) {
        const fileItems = document.querySelectorAll('.file-item');
        const fileItem = fileItems[index];
        fileItem.classList.add('analyzing');
        const progressBar = fileItem.querySelector('.progress');
        progressBar.style.width = '100%';
        progressBar.style.backgroundColor = '#9b59b6';
        
        readFileAsDataURL(file).then(imageDataUrl => {
          if (!file.textContainer) {
            const textContainer = document.createElement('div');
            textContainer.className = 'text-recognition-result';
            fileItem.appendChild(textContainer);
            file.textContainer = textContainer;
          }
          file.textContainer.innerHTML = '<div class="text-loading">文字辨識中...</div>';
          
          // 呼叫後端 Google Apps Script 函式
          google.script.run
            .withSuccessHandler(function(result) {
              file.textRecognitionResult = result;
              file.textContainer.innerHTML = `<div class="text-content">${file.textRecognitionResult}</div>`;
              fileItem.classList.remove('analyzing');
              progressBar.style.width = '0%';
            })
            .withFailureHandler(function(error) {
              console.error('Text recognition error:', error);
              file.textContainer.innerHTML = `<div class="text-error">文字辨識失敗: ${error.message}</div>`;
              fileItem.classList.remove('analyzing');
              progressBar.style.width = '0%';
            })
            .recognizeTextServer(imageDataUrl);
          
        }).catch(error => {
          console.error('File read error:', error);
          fileItem.classList.remove('analyzing');
          progressBar.style.width = '0%';
        });
      }
      
      function readFileAsDataURL(file) {
        return new Promise((resolve, reject) => {
          const reader = new FileReader();
          reader.onload = () => resolve(reader.result);
          reader.onerror = () => reject(new Error('檔案讀取失敗'));
          reader.readAsDataURL(file);
        });
      }
    });
  </script>
</body>
</html>

重要注意事項

  1. 啟用 Cloud Vision API

    • 前往 Google Cloud Console,建立或選擇已有的專案。
    • API & Services 裡面啟用 Cloud Vision API,並建立 API Key。
    • 需綁定計費專案才能使用文字辨識等功能(有免費額度)。
  2. 設定 API Key

    • Code.gsanalyzeImageServerrecognizeTextServer 兩處,將 YOUR_API_KEY 替換為實際的 API Key。
    • 或考量安全性,將 API Key 存於 Script Properties 讀取。
  3. 部署為 Web App

    • 進入 Apps Script 編輯器後,選擇 Deploy → New deployment → Web app
    • 設定好專案名稱、執行方式(Me)與存取層級(Anyone)。
    • 點選 Deploy 完成。
  4. 權限與測試

    • 首次部署可能需要授權 UrlFetchApp 等。
    • 部署成功後,複製該 Web App URL,在瀏覽器打開即可使用。

完成上述步驟後,你就能在 Web App 介面上上傳圖片,並實際呼叫 Google Cloud Vision API 進行文字辨識 (OCR) 與影像標籤 (Label Detection) 分析。