Greasy Fork

Greasy Fork is available in English.

Manga Translator (Gemini) - Contextual Manga Title

Translate manga with Gemini, using detailed prompt (requesting percentages), corrected coordinates, configurable model, and manga title context. (DEBUG VERSION)

当前为 2025-05-15 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Manga Translator (Gemini) - Contextual Manga Title
// @namespace    http://tampermonkey.net/
// @version      1.96.20250515_PERCENT_PROMPT
// @description  Translate manga with Gemini, using detailed prompt (requesting percentages), corrected coordinates, configurable model, and manga title context. (DEBUG VERSION)
// @author       Your Name (Improved by AI)
// @match        *://*/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @connect      generativelanguage.googleapis.com
// ==/UserScript==

;(function () {
  'use strict'

  const SCRIPT_PREFIX = 'manga_translator_'
  const SCRIPT_VERSION = '1.93.20250514_PERCENT_PROMPT'

  // --- Configuration Constants ---
  const MIN_IMAGE_DIMENSION = 600
  const GEMINI_API_KEY_STORAGE = SCRIPT_PREFIX + 'gemini_api_key'
  const DEFAULT_GEMINI_MODEL = 'gemini-2.0-flash'
  const GEMINI_MODEL_STORAGE_KEY = SCRIPT_PREFIX + 'gemini_model'
  const DEFAULT_MANGA_TITLE = ''
  const MANGA_TITLE_STORAGE_KEY = SCRIPT_PREFIX + 'manga_title'
  const GEMINI_TARGET_PROCESSING_DIMENSION = 768
  const IMAGE_RESIZE_QUALITY = 0.9
  const BBOX_EXPANSION_PIXELS = 0 // Giữ là 0 vì đang thử nghiệm với % trực tiếp
  const ABSOLUTE_MIN_RESIZE_DIMENSION = 30
  const BBOX_FONT_SIZE = '14px'

  // --- Prompt Template (Yêu cầu float 0.0-1.0 cho bbox) ---
  const GEMINI_PROMPT_TEMPLATE = `
Bạn được cung cấp một hình ảnh manga có kích thước \${imageProcessedWidth}x\${imageProcessedHeight} pixel.

**Ngữ cảnh bổ sung:**
* Tên truyện: \${mangaTitle}

**Nhiệm vụ:**

1.  **Nhận diện Speech Bubbles:** Xác định vị trí và chiều rộng chiều cao chính xác của TẤT CẢ các "speech bubble" (bao gồm cả bong bóng thoại, suy nghĩ, v.v.) trong hình ảnh.
2.  **Trích xuất Văn bản:** Đối với mỗi "speech bubble" đã nhận diện, trích xuất toàn bộ văn bản gốc bên trong.
3.  **Dịch sang Tiếng Việt:** Dịch văn bản đã trích xuất sang tiếng Việt. Khi dịch, hãy cố gắng giữ phong cách tự nhiên, phù hợp với ngữ cảnh hội thoại trong manga và tên truyện đã cung cấp.
4.  **Xuất Dữ liệu:** Trả về kết quả dưới dạng một mảng JSON. Mỗi phần tử trong mảng là một đối tượng có cấu trúc sau:
    * \`"text"\`: (string) Văn bản đã được dịch sang tiếng Việt.
    * \`"bbox"\`: (object) Bounding box của "speech bubble", với các giá trị là **số thực (float) từ 0.0 đến 1.0**, đại diện cho tỷ lệ phần trăm so với kích thước đầy đủ của ảnh đã cung cấp.
        * \`"x_ratio"\`: (float) Tọa độ X của góc trên bên trái, tính bằng tỷ lệ của chiều rộng ảnh (ví dụ: 0.05 tương ứng 5% từ mép trái).
        * \`"y_ratio"\`: (float) Tọa độ Y của góc trên bên trái, tính bằng tỷ lệ của chiều cao ảnh (ví dụ: 0.10 tương ứng 10% từ mép trên).
        * \`"width_ratio"\`: (float) Chiều rộng của bounding box, tính bằng tỷ lệ của chiều rộng ảnh (ví dụ: 0.25 tương ứng 25% chiều rộng ảnh).
        * \`"height_ratio"\`: (float) Chiều cao của bounding box, tính bằng tỷ lệ của chiều cao ảnh (ví dụ: 0.15 tương ứng 15% chiều cao ảnh).

**Lưu ý về Bounding Box (quan trọng):**

*   Các giá trị trong \`"bbox"\` (\`"x_ratio"\`, \`"y_ratio"\`, \`"width_ratio"\`, \`"height_ratio"\`) **PHẢI LÀ SỐ THỰC (FLOAT)** nằm trong khoảng từ 0.0 đến 1.0.
*   Các tỷ lệ này phải tương ứng với kích thước của ảnh đã cung cấp (\${imageProcessedWidth}x\${imageProcessedHeight} pixel).

**Trường hợp không có Speech Bubble:**

Nếu không tìm thấy bất kỳ "speech bubble" nào trong ảnh, trả về một mảng JSON rỗng: \`[]\`.
`

  let activeImageTarget = null
  let translateIconElement = null

  let isDragging = false
  // ... (các biến drag/resize khác giữ nguyên) ...
  let activeDraggableBox = null
  let dragOffsetX = 0,
    dragOffsetY = 0

  let isResizing = false
  let activeResizeBox = null // Cần khai báo ở đây luôn
  let activeResizeHandle = null
  let initialResizeMouseX = 0,
    initialResizeMouseY = 0
  let initialResizeBoxWidth = 0,
    initialResizeBoxHeight = 0
  let minResizeWidth = 0, // Đơn vị sẽ là pixel
    minResizeHeight = 0; // Đơn vị sẽ là pixel
  let maxResizeWidth = 0, // Đơn vị sẽ là pixel
    maxResizeHeight = 0; // Đơn vị sẽ là pixel




  const style = document.createElement('style')
  style.textContent = `
        @import url('https://fonts.googleapis.com/css2?family=Patrick+Hand&family=Comic+Neue:wght@400;700&display=swap');
        .${SCRIPT_PREFIX}translate_icon {
            position: absolute; top: 10px; right: 10px; background-color: rgba(0,0,0,0.75); color: white;
            padding: 6px 10px; border-radius: 5px; cursor: pointer; font-family: Arial, sans-serif;
            font-size: 13px; z-index: 100000; border: 1px solid rgba(255,255,255,0.5);
            box-shadow: 0 1px 3px rgba(0,0,0,0.3); transition: background-color 0.2s, border-color 0.2s;
        }
        .${SCRIPT_PREFIX}translate_icon:hover { background-color: rgba(0,0,0,0.9); border-color: white; }
        .${SCRIPT_PREFIX}translate_icon.processing, .${SCRIPT_PREFIX}translate_icon.error {
            cursor: wait !important; background-color: #d35400;
        }
        .${SCRIPT_PREFIX}translate_icon.success { background-color: #27ae60; }
        .${SCRIPT_PREFIX}overlay_container {
            position: absolute; pointer-events: none;
            overflow: hidden; z-index: 9999;
        }
        .${SCRIPT_PREFIX}bbox {
            position: absolute; background-color: white; color: black;
            font-family: "Patrick Hand", "Comic Neue", cursive, sans-serif; font-size: ${BBOX_FONT_SIZE};
            font-weight: 400; text-align: center; overflow: hidden; box-sizing: border-box;
            pointer-events: all !important; display: flex; align-items: center; justify-content: center;
            padding: 1px 1px; border-radius: 15%; /* Giữ padding nhỏ */
            box-shadow: 0 0 2px 1.5px white, 0 0 3px 3px white;
            line-height: 1.15; letter-spacing: -0.03em; cursor: grab;
        }
        .${SCRIPT_PREFIX}bbox_dragging { cursor: grabbing !important; opacity: 0.85; z-index: 100001 !important; user-select: none; }
        .${SCRIPT_PREFIX}resize_handle {
            position: absolute; width: 20px; height: 20px; background-color: rgba(0,100,255,0.6);
            border: 1px solid rgba(255,255,255,0.8); border-radius: 3px; z-index: 100002;
            pointer-events: all; opacity: 0; visibility: hidden;
            transition: opacity 0.15s ease-in-out, visibility 0.15s ease-in-out;
        }
        .${SCRIPT_PREFIX}bbox:hover .${SCRIPT_PREFIX}resize_handle, .${SCRIPT_PREFIX}resize_handle_active {
            opacity: 1; visibility: visible;
        }
        .${SCRIPT_PREFIX}resize_handle_br { bottom: -1px; right: -1px; cursor: nwse-resize; }
    `
  document.head.appendChild(style)

  // ... (các hàm tiện ích như showTemporaryMessageOnIcon, promptAndSetApiKey, etc. giữ nguyên) ...
  function showTemporaryMessageOnIcon(
    icon,
    message,
    isError = false,
    duration = 3500
  ) {
    if (!icon) return
    const originalText = icon.dataset.originalText || 'Dịch'
    icon.textContent = message
    icon.classList.remove('success', 'error', 'processing')
    if (isError) icon.classList.add('error')
    else icon.classList.add('processing') // Changed: always show processing if not error, success will be set later
    setTimeout(() => {
      if (icon.textContent === message) {
        // Check if message is still the same
        icon.textContent = originalText
        icon.classList.remove('success', 'error', 'processing')
      }
    }, duration)
  }

  function promptAndSetApiKey() {
    const currentKey = GM_getValue(GEMINI_API_KEY_STORAGE, '')
    const apiKey = prompt(
      'Vui lòng nhập Google AI Gemini API Key của bạn:',
      currentKey
    )
    if (apiKey !== null) {
      GM_setValue(GEMINI_API_KEY_STORAGE, apiKey)
      const effectiveIcon =
        translateIconElement ||
        document.body.appendChild(document.createElement('div'))
      if (apiKey)
        showTemporaryMessageOnIcon(
          effectiveIcon,
          'Đã lưu API Key!',
          false,
          2000
        )
      else
        showTemporaryMessageOnIcon(
          effectiveIcon,
          'Đã xóa API Key!',
          false,
          2000
        )
      if (effectiveIcon.parentNode === document.body && !translateIconElement)
        document.body.removeChild(effectiveIcon)
    }
  }
  GM_registerMenuCommand('Cài đặt/Cập nhật API Key Gemini', promptAndSetApiKey)

  function getGeminiApiKey(iconForMessages) {
    const apiKey = GM_getValue(GEMINI_API_KEY_STORAGE)
    if (!apiKey) {
      const msgTarget =
        iconForMessages ||
        document.body.appendChild(document.createElement('div'))
      showTemporaryMessageOnIcon(
        msgTarget,
        'Chưa có API Key! Mở menu script để cài đặt.',
        true,
        5000
      )
      if (msgTarget.parentNode === document.body && !iconForMessages)
        document.body.removeChild(msgTarget)
    }
    return apiKey
  }

  function promptAndSetGeminiModel() {
    const currentModel = GM_getValue(
      GEMINI_MODEL_STORAGE_KEY,
      DEFAULT_GEMINI_MODEL
    )
    const newModel = prompt(
      'Nhập tên Model Gemini bạn muốn sử dụng (ví dụ: gemini-2.0-flash, gemini-2.5-flash-preview-04-17):', // Updated example
      currentModel
    )
    if (newModel !== null) {
      GM_setValue(GEMINI_MODEL_STORAGE_KEY, newModel.trim())
      const effectiveIcon =
        translateIconElement ||
        document.body.appendChild(document.createElement('div'))
      showTemporaryMessageOnIcon(
        effectiveIcon,
        `Đã đặt Model: ${newModel.trim() || DEFAULT_GEMINI_MODEL}`,
        false,
        3000
      )
      if (effectiveIcon.parentNode === document.body && !translateIconElement)
        document.body.removeChild(effectiveIcon)
    }
  }
  GM_registerMenuCommand(
    'Cài đặt/Cập nhật Model Gemini',
    promptAndSetGeminiModel
  )

  function getGeminiModelName() {
    return (
      GM_getValue(GEMINI_MODEL_STORAGE_KEY, DEFAULT_GEMINI_MODEL) ||
      DEFAULT_GEMINI_MODEL
    )
  }

  function promptAndSetMangaTitle() {
    const currentTitle = GM_getValue(
      MANGA_TITLE_STORAGE_KEY,
      DEFAULT_MANGA_TITLE
    )
    const newTitle = prompt(
      'Nhập tên truyện (để trống nếu không có):',
      currentTitle
    )
    if (newTitle !== null) {
      // Người dùng không nhấn Cancel
      GM_setValue(MANGA_TITLE_STORAGE_KEY, newTitle.trim())
      const effectiveIcon =
        translateIconElement ||
        document.body.appendChild(document.createElement('div'))
      showTemporaryMessageOnIcon(
        effectiveIcon,
        `Đã đặt tên truyện: ${newTitle.trim() || 'Không có'}`,
        false,
        3000
      )
      if (effectiveIcon.parentNode === document.body && !translateIconElement)
        document.body.removeChild(effectiveIcon)
    }
  }
  GM_registerMenuCommand('Cài đặt/Cập nhật Tên Truyện', promptAndSetMangaTitle)

  function getMangaTitle() {
    return (
      GM_getValue(MANGA_TITLE_STORAGE_KEY, DEFAULT_MANGA_TITLE) ||
      DEFAULT_MANGA_TITLE
    )
  }

  // --- Drag and Resize Handlers (minified) ---
  function onDragStart(event) {
    if (
      event.target.classList.contains(`${SCRIPT_PREFIX}resize_handle`) ||
      event.button !== 0
    )
      return
    activeDraggableBox = this
    isDragging = !0
    const t = parseFloat(activeDraggableBox.style.left || 0), // This will be in % if set by displayTranslations
      e = parseFloat(activeDraggableBox.style.top || 0)   // This will be in % if set by displayTranslations

    // For dragging, it's often easier to work with pixels relative to the viewport or parent
    // We'll convert % to px for initial offset calculation if needed, or adapt drag logic
    // For now, let's assume dragOffsetX/Y are calculated against clientX/Y and pixel position
    // This might need refinement if style.left/top are consistently in %
    const parentRect = activeDraggableBox.parentNode.getBoundingClientRect();
    const boxRect = activeDraggableBox.getBoundingClientRect();

    dragOffsetX = event.clientX - (boxRect.left - parentRect.left);
    dragOffsetY = event.clientY - (boxRect.top - parentRect.top);


    activeDraggableBox.classList.add(`${SCRIPT_PREFIX}bbox_dragging`)
    document.addEventListener('mousemove', onDragMove)
    document.addEventListener('mouseup', onDragEnd)
    document.addEventListener('mouseleave', onDocumentMouseLeave)
    event.preventDefault()
  }
  function onDragMove(event) {
    if (!isDragging || !activeDraggableBox) return
    event.preventDefault()
    const t = activeDraggableBox.parentNode // This is overlayContainer
    if (!t || !(t instanceof HTMLElement)) return

    let e_px = event.clientX - dragOffsetX; // new left in pixels relative to parent
    let n_px = event.clientY - dragOffsetY; // new top in pixels relative to parent

    const o_px = t.offsetWidth - activeDraggableBox.offsetWidth; // max left in pixels
    const i_px = t.offsetHeight - activeDraggableBox.offsetHeight; // max top in pixels

    e_px = Math.max(0, Math.min(e_px, o_px));
    n_px = Math.max(0, Math.min(n_px, i_px));

    // Convert back to percentage for styling
    activeDraggableBox.style.left = (e_px / t.offsetWidth) * 100 + '%';
    activeDraggableBox.style.top = (n_px / t.offsetHeight) * 100 + '%';
  }
  function onDragEnd() {
    if (!isDragging) return
    isDragging = !1
    if (activeDraggableBox)
      activeDraggableBox.classList.remove(`${SCRIPT_PREFIX}bbox_dragging`)
    activeDraggableBox = null
    document.removeEventListener('mousemove', onDragMove)
    document.removeEventListener('mouseup', onDragEnd)
    document.removeEventListener('mouseleave', onDocumentMouseLeave)
  }
  function onResizeStart(event) { // Resize logic remains in pixels for direct manipulation
    if (event.button !== 0) return
    event.stopPropagation()
    event.preventDefault()
    activeResizeHandle = this
    activeResizeBox = this.parentNode
    isResizing = !0
    initialResizeMouseX = event.clientX
    initialResizeMouseY = event.clientY
    // Get initial dimensions in pixels
    initialResizeBoxWidth = activeResizeBox.offsetWidth
    initialResizeBoxHeight = activeResizeBox.offsetHeight

    minResizeWidth = initialResizeBoxWidth * 0.2
    minResizeHeight = initialResizeBoxHeight * 0.2
    maxResizeWidth = initialResizeBoxWidth * 2.5
    maxResizeHeight = initialResizeBoxHeight * 2.5
    activeResizeBox.classList.add(`${SCRIPT_PREFIX}bbox_dragging`)
    activeResizeHandle.classList.add(`${SCRIPT_PREFIX}resize_handle_active`)
    document.addEventListener('mousemove', onResizeMove)
    document.addEventListener('mouseup', onResizeEnd)
    document.addEventListener('mouseleave', onDocumentMouseLeave)
  }
  function onResizeMove(event) {
    if (!isResizing || !activeResizeBox) return
    event.preventDefault()
    const t = event.clientX - initialResizeMouseX, // deltaX in pixels
      e = event.clientY - initialResizeMouseY  // deltaY in pixels
    let n_px = initialResizeBoxWidth + t, // new width in pixels
      o_px = initialResizeBoxHeight + e  // new height in pixels

    n_px = Math.max(minResizeWidth, Math.min(n_px, maxResizeWidth))
    o_px = Math.max(minResizeHeight, Math.min(o_px, maxResizeHeight))

    const i = activeResizeBox.parentNode // overlayContainer
    if (i && i instanceof HTMLElement) {
      const s_percent = parseFloat(activeResizeBox.style.left || 0); // current left in %
      const a_percent = parseFloat(activeResizeBox.style.top || 0);   // current top in %
      const s_px = (s_percent / 100) * i.offsetWidth; // current left in px
      // const a_px = (a_percent / 100) * i.offsetHeight; // current top in px (not directly used for width/height constraint here)

      const r_px = i.offsetWidth - s_px; // max width available in pixels
      const l_px = i.offsetHeight - ((a_percent / 100) * i.offsetHeight); // max height available in pixels

      n_px = Math.min(n_px, r_px);
      o_px = Math.min(o_px, l_px);
    }
    n_px = Math.max(ABSOLUTE_MIN_RESIZE_DIMENSION, n_px)
    o_px = Math.max(ABSOLUTE_MIN_RESIZE_DIMENSION, o_px)

    // Convert final pixel dimensions back to percentage for styling
    activeResizeBox.style.width = (n_px / i.offsetWidth) * 100 + '%';
    activeResizeBox.style.height = (o_px / i.offsetHeight) * 100 + '%';
  }
  function onResizeEnd() {
    if (!isResizing) return
    isResizing = !1
    if (activeResizeBox)
      activeResizeBox.classList.remove(`${SCRIPT_PREFIX}bbox_dragging`)
    if (activeResizeHandle)
      activeResizeHandle.classList.remove(
        `${SCRIPT_PREFIX}resize_handle_active`
      )
    activeResizeBox = null
    activeResizeHandle = null
    document.removeEventListener('mousemove', onResizeMove)
    document.removeEventListener('mouseup', onResizeEnd)
    document.removeEventListener('mouseleave', onDocumentMouseLeave)
  }
  function onDocumentMouseLeave(event) {
    if (isDragging) onDragEnd()
    if (isResizing) onResizeEnd()
  }


  function removeAllOverlays(imgElement) {
    const parentNode = imgElement.parentNode
    if (parentNode) {
      const existingContainers = parentNode.querySelectorAll(
        `.${SCRIPT_PREFIX}overlay_container[data-target-img-src="${imgElement.src}"]`
      )
      existingContainers.forEach((container) => container.remove())
    }
  }

  async function getImageData(imageUrl) {
    // ... (giữ nguyên) ...
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url: imageUrl,
        responseType: 'blob',
        onload: (response) => {
          if (response.status >= 200 && response.status < 300) {
            const blob = response.response
            const reader = new FileReader()
            reader.onloadend = () => {
              const dataUrl = reader.result
              resolve({
                dataUrl,
                base64Content: dataUrl.split(',')[1],
                mimeType: blob.type || 'image/jpeg',
              })
            }
            reader.onerror = (err) =>
              reject(new Error('FileReader error: ' + err.toString()))
            reader.readAsDataURL(blob)
          } else
            reject(
              new Error(
                `Fetch failed: ${response.status} ${response.statusText}`
              )
            )
        },
        onerror: (err) =>
          reject(
            new Error(`GM_xhr error: ${err.statusText || 'Network error'}`)
          ),
        ontimeout: () => reject(new Error('GM_xhr timeout fetching image.')),
      })
    })
  }

  async function preprocessImage(
    originalDataUrl,
    originalWidth,
    originalHeight,
    targetMaxDimension,
    targetMimeType
  ) {
    // ... (giữ nguyên, vì Gemini vẫn cần biết processedWidth/Height cho ngữ cảnh) ...
    return new Promise((resolve, reject) => {
      console.log(
        `[DEBUG] preprocessImage: original dimensions = ${originalWidth}x${originalHeight}, targetMaxDimension = ${targetMaxDimension}`
      )
      if (
        originalWidth <= targetMaxDimension &&
        originalHeight <= targetMaxDimension
      ) {
        console.log(
          '[DEBUG] preprocessImage: Image is already within target dimensions. Using original.'
        )
        resolve({
          base64Data: originalDataUrl.split(',')[1],
          processedWidth: originalWidth,
          processedHeight: originalHeight,
          mimeTypeToUse: targetMimeType,
        })
        return
      }
      const img = new Image()
      img.onload = () => {
        let ratio = Math.min(
          targetMaxDimension / originalWidth,
          targetMaxDimension / originalHeight
        )
        const resizedWidth = Math.floor(originalWidth * ratio)
        const resizedHeight = Math.floor(originalHeight * ratio)
        console.log(
          `[DEBUG] preprocessImage: Resized to ${resizedWidth}x${resizedHeight}`
        )
        const canvas = document.createElement('canvas')
        canvas.width = resizedWidth
        canvas.height = resizedHeight
        const ctx = canvas.getContext('2d')
        ctx.drawImage(img, 0, 0, resizedWidth, resizedHeight)
        resolve({
          base64Data: canvas
            .toDataURL(targetMimeType, IMAGE_RESIZE_QUALITY)
            .split(',')[1],
          processedWidth: resizedWidth,
          processedHeight: resizedHeight,
          mimeTypeToUse: targetMimeType,
        })
      }
      img.onerror = (err) =>
        reject(new Error('Image load for resize failed: ' + String(err)))
      img.src = originalDataUrl
    })
  }

  async function callGeminiApi(
    base64ImageData,
    apiKey,
    imageMimeType,
    imageProcessedWidth, // Vẫn cần để điền vào prompt
    imageProcessedHeight // Vẫn cần để điền vào prompt
  ) {
    const modelName = getGeminiModelName()
    const mangaTitleText = getMangaTitle().trim() || 'Không được cung cấp'

    console.log(
      `[DEBUG] callGeminiApi: Using model = ${modelName}, imageProcessedWidth = ${imageProcessedWidth}, imageProcessedHeight = ${imageProcessedHeight}, mangaTitle = "${mangaTitleText}" (Note: bbox expected as ratios)`
    )

    const promptText = GEMINI_PROMPT_TEMPLATE.replace(
      /\$\{imageProcessedWidth\}/g,
      imageProcessedWidth
    )
      .replace(/\$\{imageProcessedHeight\}/g, imageProcessedHeight)
      .replace(/\$\{mangaTitle\}/g, mangaTitleText)

    // console.log("[DEBUG] callGeminiApi: Generated Prompt:", promptText);

    const url = `https://generativelanguage.googleapis.com/v1beta/models/${modelName}:generateContent?key=${apiKey}`
    const payload = {
      contents: [
        {
          parts: [
            { text: promptText },
            {
              inline_data: { mime_type: imageMimeType, data: base64ImageData },
            },
          ],
        },
      ],
    }
    // ... (phần còn lại của GM_xmlhttpRequest giữ nguyên) ...
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'POST',
        url: url,
        headers: { 'Content-Type': 'application/json' },
        data: JSON.stringify(payload),
        timeout: 90000, // Increased timeout
        onload: (response) => {
          console.log(
            '[DEBUG] callGeminiApi: Raw Gemini Response Status:',
            response.status
          )
          console.log(
            '[DEBUG] callGeminiApi: Raw Gemini Response Text (first 500 chars):',
            response.responseText.substring(0, 500)
          )

          if (response.status >= 200 && response.status < 300) {
            try {
              const rd = JSON.parse(response.responseText)
              if (rd.candidates?.[0]?.content?.parts?.[0]?.text) {
                let rt = rd.candidates[0].content.parts[0].text
                  .trim()
                  .replace(/^```json\s*/, '')
                  .replace(/\s*```$/, '')
                console.log(
                  '[DEBUG] callGeminiApi: Parsed and cleaned Gemini text result (expecting ratios):',
                  rt
                )
                resolve(JSON.parse(rt))
              } else if (rd.promptFeedback?.blockReason) {
                console.error(
                  '[DEBUG] callGeminiApi: API blocked response:',
                  rd.promptFeedback
                )
                reject(
                  new Error(
                    `API blocked: ${rd.promptFeedback.blockReason} - ${
                      rd.promptFeedback.blockReasonMessage || 'No message.'
                    }`
                  )
                )
              } else {
                console.warn(
                  '[DEBUG] callGeminiApi: No text found in Gemini response, resolving with empty array. Full response:',
                  rd
                )
                resolve([])
              }
            } catch (e) {
              console.error(
                '[DEBUG] callGeminiApi: Error parsing Gemini response:',
                e
              )
              reject(
                new Error(
                  `Parse Error: ${
                    e.message
                  }. Resp: ${response.responseText.substring(0, 200)}...`
                )
              )
            }
          } else {
            let errorMsg = `API Error ${response.status}: ${response.statusText}`
            try {
              const errorJson = JSON.parse(response.responseText)
              errorMsg = `API Error ${response.status}: ${
                errorJson.error?.message || response.statusText
              }`
              console.error('[DEBUG] callGeminiApi: API Error JSON:', errorJson)
            } catch (e) {
              /* ignore parsing error of error message */
            }
            console.error('[DEBUG] callGeminiApi: API Error:', errorMsg)
            reject(new Error(errorMsg))
          }
        },
        onerror: (err) => {
          console.error('[DEBUG] callGeminiApi: Network/CORS Error:', err)
          reject(
            new Error(`Network/CORS Error: ${err.statusText || 'Unknown'}`)
          )
        },
        ontimeout: () => {
          console.error('[DEBUG] callGeminiApi: Timeout.')
          reject(new Error('Gemini API timed out.'))
        },
      })
    })
  }

  function displayTranslations(
    imgElement,
    translations,
    processedWidth, // Vẫn cần để tham chiếu nếu Gemini tính ratio dựa trên nó
    processedHeight // Tương tự
  ) {
    removeAllOverlays(imgElement)
    if (!translations || translations.length === 0) {
      console.log('[DEBUG] displayTranslations: No translations to display.')
      return
    }

    const parentNode = imgElement.parentNode
    if (!parentNode) {
      console.error('[DEBUG] displayTranslations: Image parent node not found.')
      return
    }
    if (getComputedStyle(parentNode).position === 'static')
      parentNode.style.position = 'relative'

    const imgRect = imgElement.getBoundingClientRect()
    // processedWidth, processedHeight không còn dùng để scale trực tiếp nếu Gemini trả về ratio
    // nhưng chúng được dùng trong prompt, nên log ở đây vẫn có ích
    console.log(
      `[DEBUG] displayTranslations: imgRect = ${imgRect.width}x${imgRect.height}, processed (for prompt context) = ${processedWidth}x${processedHeight}`
    )

    const overlayContainer = document.createElement('div')
    overlayContainer.className = `${SCRIPT_PREFIX}overlay_container`
    overlayContainer.dataset.targetImgSrc = imgElement.src
    overlayContainer.style.top = `${imgElement.offsetTop}px`
    overlayContainer.style.left = `${imgElement.offsetLeft}px`
    overlayContainer.style.width = `${imgRect.width}px`
    overlayContainer.style.height = `${imgRect.height}px`
    parentNode.appendChild(overlayContainer)

    translations.forEach((item, index) => {
      // Kiểm tra xem Gemini có trả về đúng các trường ratio không
      if (
        !item.bbox ||
        typeof item.bbox.x_ratio !== 'number' || // Đổi tên trường theo prompt
        typeof item.bbox.y_ratio !== 'number' ||
        typeof item.bbox.width_ratio !== 'number' ||
        typeof item.bbox.height_ratio !== 'number'
      ) {
        console.warn(
          `[DEBUG] displayTranslations: Invalid or missing bbox ratio data for item ${index}:`,
          item
        )
        return
      }

      // Lấy giá trị ratio từ Gemini
      let { x_ratio, y_ratio, width_ratio, height_ratio } = item.bbox
      console.log(
        `[DEBUG] displayTranslations: Item ${index} - Original bbox from Gemini (ratios 0.0-1.0): x_r=${x_ratio.toFixed(4)}, y_r=${y_ratio.toFixed(4)}, w_r=${width_ratio.toFixed(4)}, h_r=${height_ratio.toFixed(4)}`
      )

      // Chuyển đổi ratio (0.0-1.0) thành phần trăm (0-100)
      const percentX = x_ratio * 100
      const percentY = y_ratio * 100
      const percentWidth = width_ratio * 100
      const percentHeight = height_ratio * 100

      // Đảm bảo các giá trị không âm và width/height không quá nhỏ (vẫn có thể cần một ngưỡng tối thiểu trực quan)
      // Ví dụ: percentWidth = Math.max(percentWidth, 1); // 1% min width
      //        percentHeight = Math.max(percentHeight, 1); // 1% min height

      console.log(
        `[DEBUG] displayTranslations: Item ${index} - Calculated display percentages: x=${percentX.toFixed(
          4
        )}%, y=${percentY.toFixed(4)}%, w=${percentWidth.toFixed(
          4
        )}%, h=${percentHeight.toFixed(4)}%`
      )

      const bboxDiv = document.createElement('div')
      bboxDiv.className = `${SCRIPT_PREFIX}bbox`
      bboxDiv.style.left = `${percentX}%`
      bboxDiv.style.top = `${percentY}%`
      bboxDiv.style.width = `${percentWidth}%`
      bboxDiv.style.height = `${percentHeight}%`

      bboxDiv.textContent = item.text || ''
      bboxDiv.addEventListener('mousedown', onDragStart)
      const resizeHandle = document.createElement('div')
      resizeHandle.className = `${SCRIPT_PREFIX}resize_handle ${SCRIPT_PREFIX}resize_handle_br`
      resizeHandle.addEventListener('mousedown', onResizeStart)
      bboxDiv.appendChild(resizeHandle)
      overlayContainer.appendChild(bboxDiv)
    })
  }

  async function handleTranslateClick(event) {
    // ... (Giữ nguyên, vì processedWidth/Height vẫn được truyền xuống) ...
    event.stopPropagation()
    const icon = event.target
    translateIconElement = icon
    const currentImgElement = activeImageTarget
    if (!currentImgElement || icon.classList.contains('processing')) return

    const apiKey = getGeminiApiKey(icon)
    if (!apiKey) return

    const originalIconText = icon.dataset.originalText || icon.textContent
    icon.dataset.originalText = originalIconText
    showTemporaryMessageOnIcon(icon, 'Đang xử lý...', false, 120000) // Long timeout for message
    icon.classList.remove('success', 'error') // Clear previous status
    icon.classList.add('processing')
    removeAllOverlays(currentImgElement)

    try {
      const naturalWidth = currentImgElement.naturalWidth
      const naturalHeight = currentImgElement.naturalHeight
      console.log(
        `[DEBUG] handleTranslateClick: Image natural dimensions = ${naturalWidth}x${naturalHeight}`
      )
      if (naturalWidth === 0 || naturalHeight === 0)
        throw new Error('Ảnh gốc không hợp lệ (0x0).')

      const { dataUrl: originalDataUrl, mimeType: originalMimeType } =
        await getImageData(currentImgElement.src)
      console.log(
        `[DEBUG] handleTranslateClick: Got image data, mimeType = ${originalMimeType}`
      )

      const {
        base64Data: finalBase64ToSend,
        processedWidth,
        processedHeight,
        mimeTypeToUse,
      } = await preprocessImage(
        originalDataUrl,
        naturalWidth,
        naturalHeight,
        GEMINI_TARGET_PROCESSING_DIMENSION,
        originalMimeType
      )
      console.log(
        `[DEBUG] handleTranslateClick: Image preprocessed. Sent to Gemini as ${processedWidth}x${processedHeight}, mimeType = ${mimeTypeToUse}`
      )

      const translations = await callGeminiApi(
        finalBase64ToSend,
        apiKey,
        mimeTypeToUse,
        processedWidth, // Vẫn truyền xuống để điền vào prompt
        processedHeight // Vẫn truyền xuống để điền vào prompt
      )
      console.log(
        '[DEBUG] handleTranslateClick: Received translations from Gemini (expecting ratios):',
        translations
      )

      displayTranslations(
        currentImgElement,
        translations,
        processedWidth, // Truyền xuống để displayTranslations biết ngữ cảnh prompt
        processedHeight // (mặc dù nó sẽ dùng ratio từ Gemini để tính %)
      )

      if (translations?.length > 0) {
        showTemporaryMessageOnIcon(icon, 'Đã dịch!', false, 3000)
        icon.classList.remove('processing', 'error')
        icon.classList.add('success')
      } else {
        showTemporaryMessageOnIcon(icon, 'Không thấy chữ!', false, 3000)
        icon.classList.remove('processing', 'success', 'error') // Clear all status
      }
    } catch (error) {
      console.error('Manga Translator: Translation failed:', error)
      showTemporaryMessageOnIcon(
        icon,
        `Lỗi: ${error.message.substring(0, 100)}...`,
        true,
        7000
      )
      icon.classList.remove('processing', 'success')
      icon.classList.add('error')
    } finally {
      // Ensure icon state is reset if it's still 'processing' but not explicitly success/error
      if (
        icon.classList.contains('processing') &&
        !icon.classList.contains('success') &&
        !icon.classList.contains('error')
      ) {
        icon.textContent = originalIconText
        icon.classList.remove('processing')
      }
    }
  }
  // ... (addTranslateIcon, removeTranslateIcon, scanImages, observer giữ nguyên) ...
  function addTranslateIcon(imgElement) {
    const parentNode = imgElement.parentNode
    if (!parentNode) return null
    removeTranslateIcon(imgElement, parentNode) // Ensure no duplicates
    if (getComputedStyle(parentNode).position === 'static')
      parentNode.style.position = 'relative'
    const icon = document.createElement('div')
    icon.textContent = 'Dịch'
    icon.className = `${SCRIPT_PREFIX}translate_icon`
    icon.dataset.targetSrc = imgElement.src
    icon.dataset.originalText = 'Dịch' // Store original text for reset
    // Position calculation adjusted for robustness
    const imgRect = imgElement.getBoundingClientRect()
    const parentRect = parentNode.getBoundingClientRect()

    icon.style.top = `${imgElement.offsetTop + 5}px`
    icon.style.right = `${
      parentNode.offsetWidth -
      (imgElement.offsetLeft + imgElement.offsetWidth) +
      5
    }px`

    // Fallback positioning if offsetParent is weird or img is deeply nested
    if (
      imgElement.offsetTop === 0 &&
      imgElement.offsetLeft === 0 &&
      imgRect.top > parentRect.top
    ) {
      icon.style.top = `${imgRect.top - parentRect.top + 5}px`
      icon.style.right = `${parentRect.right - imgRect.right + 5}px`
    }

    icon.addEventListener('click', handleTranslateClick)
    parentNode.appendChild(icon)
    return icon
  }

  function removeTranslateIcon(imgElement, parentNodeOverride = null) {
    const parentNode = parentNodeOverride || imgElement.parentNode
    if (!parentNode) return
    const iconEl = parentNode.querySelector(
      `.${SCRIPT_PREFIX}translate_icon[data-target-src="${imgElement.src}"]`
    )
    if (iconEl) {
      iconEl.removeEventListener('click', handleTranslateClick)
      iconEl.remove()
    }
    if (translateIconElement === iconEl) translateIconElement = null
  }

  function scanImages() {
    const images = document.querySelectorAll(
      `img:not([data-${SCRIPT_PREFIX}processed="true"])`
    )
    images.forEach((img) => {
      if (!img.src || img.closest(`.${SCRIPT_PREFIX}bbox`)) {
        img.dataset[`${SCRIPT_PREFIX}processed`] = 'true'
        return
      }
      const processThisImg = () => {
        img.dataset[`${SCRIPT_PREFIX}processed`] = 'true' // Mark as processed
        const styles = getComputedStyle(img)
        if (
          styles.display === 'none' ||
          styles.visibility === 'hidden' ||
          img.offsetParent === null
        )
          return
        if (
          (img.offsetWidth >= MIN_IMAGE_DIMENSION ||
            img.offsetHeight >= MIN_IMAGE_DIMENSION) &&
          img.naturalWidth > 0 && // Ensure natural dimensions are loaded
          img.naturalHeight > 0
        ) {
          const parent = img.parentNode
          if (!parent) return

          img.addEventListener('mouseenter', () => {
            activeImageTarget = img
            // Check if icon already exists for this specific img src *within this parent*
            if (
              !parent.querySelector(
                `.${SCRIPT_PREFIX}translate_icon[data-target-src="${img.src}"]`
              )
            ) {
              translateIconElement = addTranslateIcon(img) // Store the created icon
            } else {
              // If icon exists, ensure translateIconElement points to it if it's the current one
              const existingIcon = parent.querySelector(
                `.${SCRIPT_PREFIX}translate_icon[data-target-src="${img.src}"]`
              )
              if (existingIcon) translateIconElement = existingIcon
            }
          })

          let leaveTimeout
          const commonMouseLeaveHandler = (event) => {
            clearTimeout(leaveTimeout)
            leaveTimeout = setTimeout(() => {
              const iconExists = parent.querySelector(
                `.${SCRIPT_PREFIX}translate_icon[data-target-src="${img.src}"]`
              )
              const isMouseOverImg = img.matches(':hover')
              const isMouseOverIcon = iconExists
                ? iconExists.matches(':hover')
                : false

              if (iconExists && !isMouseOverImg && !isMouseOverIcon) {
                // More robust check for relatedTarget
                let related = event.relatedTarget
                let shouldRemove = true
                while (related && related !== parent) {
                  if (related === img || related === iconExists) {
                    shouldRemove = false
                    break
                  }
                  related = related.parentNode
                }
                if (
                  related === parent &&
                  (event.target === img || event.target === iconExists)
                ) {
                  // If mouse moved from img/icon directly to parent, don't remove immediately
                  // This case is tricky, might need further refinement if issues persist
                } else if (shouldRemove) {
                  removeTranslateIcon(img, parent)
                  if (activeImageTarget === img) activeImageTarget = null
                }
              }
            }, 150) // Slightly increased delay
          }

          // Add mouseleave to parent as well for better icon removal
          parent.addEventListener('mouseleave', commonMouseLeaveHandler)
          img.addEventListener('mouseleave', (event) => {
            const iconExists = parent.querySelector(
              `.${SCRIPT_PREFIX}translate_icon[data-target-src="${img.src}"]`
            )
            // If mouse leaves img to something that is not the icon or the parent containing the icon
            if (
              event.relatedTarget !== iconExists &&
              event.relatedTarget !== parent &&
              (!iconExists || !iconExists.contains(event.relatedTarget))
            ) {
              commonMouseLeaveHandler(event)
            }
          })
        }
      }

      if (img.complete && img.naturalWidth > 0) {
        processThisImg()
      } else if (!img.complete) {
        img.addEventListener('load', processThisImg, { once: true })
        img.addEventListener(
          'error',
          () => {
            img.dataset[`${SCRIPT_PREFIX}processed`] = 'true' // Mark as processed on error too
          },
          { once: true }
        )
      } else {
        // If complete but naturalWidth is 0 (e.g. broken image link that reported complete)
        img.dataset[`${SCRIPT_PREFIX}processed`] = 'true'
      }
    })
  }

  // Initial scan
  if (
    document.readyState === 'complete' ||
    document.readyState === 'interactive'
  )
    scanImages()
  else document.addEventListener('DOMContentLoaded', scanImages, { once: true })

  // Observe DOM changes
  const observer = new MutationObserver((mutationsList) => {
    let needsScan = false
    for (const m of mutationsList) {
      if (m.type === 'childList' && m.addedNodes.length > 0) {
        m.addedNodes.forEach((n) => {
          if (
            n.nodeType === Node.ELEMENT_NODE &&
            (n.tagName === 'IMG' ||
              n.querySelector?.(
                `img:not([data-${SCRIPT_PREFIX}processed="true"])`
              ))
          )
            needsScan = true
        })
      } else if (
        m.type === 'attributes' &&
        m.target.tagName === 'IMG' &&
        m.attributeName === 'src'
      ) {
        m.target.removeAttribute(`data-${SCRIPT_PREFIX}processed`) // Re-process if src changes
        needsScan = true
      }
    }
    if (needsScan) scanImages()
  })
  observer.observe(document.body, {
    childList: true,
    subtree: true,
    attributes: true,
    attributeFilter: ['src'], // Only observe src attribute changes for IMG tags
  })


  console.log(
    `Manga Translator (Gemini) - Contextual Manga Title v${SCRIPT_VERSION} loaded. Open console for DEBUG logs.`
  )
})()