"""Hàm phụ trợ đọc và nhận dạng file — R08-T12. Thuần hàm, không widget. ``_is_probably_text`` là chỗ quyết định file mở bằng ô soạn thảo hay báo là nhị phân — đoán sai thì người dùng thấy một màn hình ký tự rác. """ from __future__ import annotations from ...ui.libreoffice_view import DOC_SUFFIXES # noqa: F401 — dùng lại ở cả gói # Nhận dạng loại file và các ngưỡng — gom về đây vì cả sáu file trong gói # đều hỏi tới, để rải ra thì mỗi lần thêm một đuôi file phải sửa vài chỗ. try: from ...graph.graph_web import _HAS_WEB except Exception: # pragma: no cover _HAS_WEB = False try: from PySide6.QtPdf import QPdfDocument # noqa: F401 from PySide6.QtPdfWidgets import QPdfView # noqa: F401 _HAS_PDF = True except Exception: # pragma: no cover - QtPdf not bundled _HAS_PDF = False _IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico"} _HTML_SUFFIXES = {".html", ".htm"} _PPTX_SUFFIXES = {".pptx"} # text-editable in-app via python-pptx (no PowerPoint) _EXCEL_SUFFIXES = {".xlsx", ".xlsm"} # viewed as a real table via openpyxl (no LibreOffice) _MAX_EDIT_BYTES = 2_000_000 # above this, view read-only / note only _MAX_HIGHLIGHT_CHARS = 300_000 # skip colouring huge files (keeps typing snappy) import os from pathlib import Path from PySide6.QtCore import Qt from PySide6.QtGui import QColor, QFont, QTextCharFormat from ...i18n import tr def _fmt(color: str, *, italic: bool = False, bold: bool = False) -> QTextCharFormat: f = QTextCharFormat() f.setForeground(QColor(color)) if italic: f.setFontItalic(True) if bold: f.setFontWeight(QFont.Bold) return f def _pptx_available() -> bool: """True when python-pptx is importable. If it's MISSING, auto-download & install it (via deps.ensure_module) so pptx editing 'just works' — cached so the (one-time) install is attempted only once.""" global _PPTX_READY if _PPTX_READY is None: try: from ...core.deps import ensure_module _PPTX_READY = ensure_module("pptx", "python-pptx") is not None except Exception: # noqa: BLE001 _PPTX_READY = False return _PPTX_READY def _split_code_block(text: str): """Split an AI reply into ``(file_content, summary)``. ``file_content`` is the first fenced code block (the edited file); ``summary`` is any prose before it. Returns ``(None, text)`` when there's no code block.""" import re m = re.search(r"```[^\n]*\n(.*?)```", text or "", re.DOTALL) if not m: return None, (text or "") return m.group(1), (text[:m.start()].strip()) def _parse_ai_output(text: str): """Parse an AI edit reply into ``(target, content, summary, image_gens)``. ``FILE: `` names a NEW file to create; ``IMAGE_GEN: => `` lines request generated illustration images (relative paths).""" import re content, summary = _split_code_block(text) target = None m = re.search(r"(?mi)^\s*FILE:\s*(.+?)\s*$", text or "") if m: target = m.group(1).strip().strip("`\"'") image_gens = [] for gm in re.finditer(r"(?mi)^\s*IMAGE_GEN:\s*(.+?)\s*=>\s*(\S+)\s*$", text or ""): image_gens.append((gm.group(1).strip(), gm.group(2).strip().strip("`\"'"))) # Strip the directive lines out of the shown summary. summary = re.sub(r"(?mi)^\s*(FILE|IMAGE_GEN):\s*.+?$", "", summary).strip() return target, content, summary, image_gens def _read_text(path: str) -> str: try: return Path(path).read_text(encoding="utf-8", errors="replace") except OSError as exc: return f"[could not read file: {exc}]" def _is_probably_text(path: str) -> bool: try: with open(path, "rb") as f: chunk = f.read(4096) except OSError: return False if b"\x00" in chunk: return False try: chunk.decode("utf-8") return True except UnicodeDecodeError: # Latin-ish text still edits fine via errors="replace"; only reject on # a hard binary signal (NUL above), so most source files pass. return True