"""Hiển thị nội dung file theo từng loại — R08-T12. PDF, Word, Excel, PowerPoint, ảnh, HTML, mã nguồn, và nhị phân. Mỗi loại một đường riêng vì cách đọc khác hẳn nhau. Điểm cần biết: ``_ensure_engine`` và ``_ensure_pdf_view`` dựng lười — QtWebEngine và bộ đọc PDF đều nặng, mở thư mục toàn file .txt thì không nên trả giá cho chúng. Cùng kiểu mixin, xem ghi chú ở đầu ``presentation/shell/nav_rail.py``. """ from __future__ import annotations from .file_helpers import ( DOC_SUFFIXES, _EXCEL_SUFFIXES, _HAS_PDF, _HAS_WEB, _HTML_SUFFIXES, _IMAGE_SUFFIXES, _MAX_EDIT_BYTES, _PPTX_SUFFIXES, _is_probably_text, _pptx_available, _read_text, ) import os from pathlib import Path from PySide6.QtCore import Qt from PySide6.QtWidgets import QTableWidget, QTableWidgetItem, QTabWidget, QTextBrowser from ...core.worker import AgentWorker from ...i18n import tr from ...ui.libreoffice_view import DOC_SUFFIXES class DocumentPreviewMixin: """Trộn vào FolderTab.""" def open_file(self, path: str, reset: bool = True) -> None: # Switching to a DIFFERENT file starts a fresh AI-edit conversation, so # the previous file's chat can't bleed into (hallucinate) the new file. # (reset=False when the AI just CREATED this file — keep that chat.) if reset and path != self._current_file: self._reset_ai_conversation() self._current_file = path self.file_label.setText(path) suffix = Path(path).suffix.lower() self.mode_btn.setVisible(False) self.save_btn.setVisible(False) self.ext_btn.setVisible(False) self._edit_kind = None try: size = os.path.getsize(path) except OSError: size = 0 if suffix in _IMAGE_SUFFIXES: self._show_image(path) elif suffix in _HTML_SUFFIXES: self._show_html(path, mode_preview=True) elif suffix in _PPTX_SUFFIXES and _pptx_available(): self._show_pptx(path, mode_preview=True) elif suffix in _EXCEL_SUFFIXES: self._show_excel(path) elif suffix in DOC_SUFFIXES: self._show_document(path) elif size > _MAX_EDIT_BYTES or not _is_probably_text(path): self._show_binary(path) else: self._show_code(path) def _show_code(self, path: str) -> None: text = _read_text(path) self.editor.setReadOnly(False) self.editor.load_file(path, text) self.save_btn.setVisible(True) self.stack.setCurrentWidget(self.editor) def _show_html(self, path: str, mode_preview: bool) -> None: self._edit_kind = "html" self.mode_btn.setVisible(True) self.mode_btn.setChecked(not mode_preview) # checked = Edit self._retranslate_mode_btn() if mode_preview: from PySide6.QtCore import QUrl html = _read_text(path) engine = self._ensure_engine() if engine is not None: engine.setHtml(html, QUrl.fromLocalFile(path)) self.stack.setCurrentWidget(engine) else: self.web.setHtml(html) self.stack.setCurrentWidget(self.web) self.save_btn.setVisible(False) else: self._show_code(path) def _show_pptx(self, path: str, mode_preview: bool) -> None: """PPTX: Preview renders the slides (PDF via LibreOffice); Edit shows the deck's text (marker-delimited per box) in the editor. Saving/AI-editing writes the text back into the .pptx silently (no PowerPoint window).""" self._edit_kind = "pptx" self.mode_btn.setVisible(True) self.mode_btn.setChecked(not mode_preview) # checked = Edit self._retranslate_mode_btn() self.ext_btn.setVisible(True) if mode_preview: self._show_document(path) # PDF render of the slides self.mode_btn.setVisible(True) # _show_document doesn't touch it else: from ...core.pptx_edit import pptx_to_text try: text = pptx_to_text(path) except Exception as exc: # noqa: BLE001 text = f"[could not read pptx text: {exc}]" self.editor.setReadOnly(False) self.editor.load_file(path + ".txt", text) # .txt → plain highlighting self.save_btn.setVisible(True) self.stack.setCurrentWidget(self.editor) def _ensure_engine(self): """Create the QWebEngineView on first HTML preview (only when WebEngine is safe to use); otherwise stay on the QTextBrowser fallback.""" if not _HAS_WEB: return None if self._engine is None: try: from PySide6.QtWebEngineWidgets import QWebEngineView self._engine = QWebEngineView() self.stack.addWidget(self._engine) except Exception: # noqa: BLE001 self._engine = None return self._engine def _toggle_edit_mode(self) -> None: if not self._current_file: return preview = not self.mode_btn.isChecked() # checked = Edit if self._edit_kind == "pptx": self._show_pptx(self._current_file, mode_preview=preview) else: self._show_html(self._current_file, mode_preview=preview) def _show_excel(self, path: str) -> None: """View a spreadsheet as a real TABLE (openpyxl) — one tab per sheet — so Excel is viewable WITHOUT LibreOffice/PowerPoint. Bounded rows/cols keep large workbooks snappy. Falls back to the document (PDF/text) path if the workbook can't be read.""" self.ext_btn.setVisible(True) try: from ...core.deps import ensure_module ensure_module("openpyxl", "openpyxl") from openpyxl import load_workbook wb = load_workbook(path, read_only=True, data_only=True) except Exception: # noqa: BLE001 - no openpyxl / unreadable → try PDF/text self._show_document(path) return MAX_ROWS, MAX_COLS = 2000, 100 if self._xlsx_view is None: self._xlsx_view = QTabWidget() self.stack.addWidget(self._xlsx_view) tabs = self._xlsx_view while tabs.count(): w = tabs.widget(0); tabs.removeTab(0); w.deleteLater() try: for ws in wb.worksheets: rows = list(ws.iter_rows(max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True)) ncols = max((len(r) for r in rows), default=0) table = QTableWidget(len(rows), ncols) table.setEditTriggers(QTableWidget.NoEditTriggers) table.horizontalHeader().setVisible(False) for r, row in enumerate(rows): for c, val in enumerate(row): if val is not None: table.setItem(r, c, QTableWidgetItem(str(val))) table.resizeColumnsToContents() title = ws.title + (" (…)" if (ws.max_row or 0) > MAX_ROWS or (ws.max_column or 0) > MAX_COLS else "") tabs.addTab(table, title) finally: wb.close() if tabs.count() == 0: self._show_document(path) return self.stack.setCurrentWidget(tabs) def _show_document(self, path: str) -> None: """Office docs (ppt/pptx/doc/docx/xls/…) + PDF are RENDERED via QtPdf — LibreOffice converts them to PDF first. Falls back to text extraction when QtPdf/LibreOffice aren't available.""" self.ext_btn.setVisible(True) suffix = Path(path).suffix.lower() if not _HAS_PDF: self._show_document_text(path) return if suffix == ".pdf": self._render_pdf(path) return # Cached conversion (per path+mtime) → render immediately. try: mtime = os.path.getmtime(path) except OSError: mtime = 0 cached = self._pdf_cache.get((path, mtime)) if cached and os.path.exists(cached): self._render_pdf(cached) return # Convert to PDF off the UI thread (LibreOffice → MS Office COM). Only # skip to text when NEITHER is possible (no LibreOffice AND not Windows, # where COM may drive an installed Office). This is what lets a large # .pptx/.docx render via MS Office when LibreOffice isn't installed. from ...core.doc_extract import convert_to_pdf, find_soffice if not find_soffice() and os.name != "nt": self._show_document_text(path) return self.doc_view.setPlainText(tr("folder.converting")) self.stack.setCurrentWidget(self.doc_view) if self._pdf_tmp is None: import tempfile self._pdf_tmp = tempfile.mkdtemp(prefix="cowork_folder_pdf_") src, out_dir = path, self._pdf_tmp def job(worker): return {"src": src, "mtime": mtime, "pdf": convert_to_pdf(src, out_dir)} def done(result): if result.get("src") != self._current_file: return # user moved on to another file pdf = result.get("pdf") if pdf: self._pdf_cache[(result["src"], result["mtime"])] = pdf self._render_pdf(pdf) else: self._show_document_text(src) worker = AgentWorker(job) worker.finished_ok.connect(done) worker.failed.connect(lambda _e, p=src: self._show_document_text(p)) self._convert_worker = worker worker.start() def _ensure_pdf_view(self): if not _HAS_PDF: return None if self._pdf_view is None: from PySide6.QtPdf import QPdfDocument from PySide6.QtPdfWidgets import QPdfView self._pdf_doc = QPdfDocument(self) self._pdf_view = QPdfView(self) self._pdf_view.setDocument(self._pdf_doc) try: self._pdf_view.setPageMode(QPdfView.PageMode.MultiPage) self._pdf_view.setZoomMode(QPdfView.ZoomMode.FitToWidth) except Exception: # noqa: BLE001 - enum names vary slightly across versions pass self.stack.addWidget(self._pdf_view) return self._pdf_view def _render_pdf(self, pdf_path: str) -> None: view = self._ensure_pdf_view() if view is None: self._show_document_text(pdf_path) return self._pdf_doc.load(pdf_path) self.stack.setCurrentWidget(view) def _show_document_text(self, path: str) -> None: from ...core.doc_extract import extract_text try: text, note = extract_text(path) except Exception as exc: # noqa: BLE001 text, note = None, str(exc) body = text if text else tr("folder.doc_unreadable", note=note or "?") self.doc_view.setPlainText(body) self.stack.setCurrentWidget(self.doc_view) def _show_image(self, path: str) -> None: from PySide6.QtGui import QPixmap pix = QPixmap(path) if pix.isNull(): self._show_binary(path) return self._img_label.setPixmap(pix) self._img_label.resize(pix.size()) self.ext_btn.setVisible(True) self.stack.setCurrentWidget(self._img_scroll) def _show_binary(self, path: str) -> None: self._placeholder.setText(tr("folder.binary_file")) self.ext_btn.setVisible(True) self.stack.setCurrentWidget(self._placeholder) def _save(self) -> None: if not self._current_file: return try: if self._edit_kind == "pptx": if not self._write_pptx(self.editor.toPlainText()): return else: Path(self._current_file).write_text(self.editor.toPlainText(), encoding="utf-8") self.status_message.emit(tr("folder.saved", name=Path(self._current_file).name)) except Exception as exc: # noqa: BLE001 self.status_message.emit(tr("folder.save_error", err=str(exc))) def _write_pptx(self, content: str, skip_confirm: bool = False) -> bool: """Write edited pptx text back into the deck. If the edit REPLACES any image, ask the user to confirm first (image edits are gated so a future image-processing model can't touch pictures without an explicit OK). ``skip_confirm`` is used when the image was already confirmed (e.g. just generated). Returns False if the user declined.""" from ...core import pptx_edit if not skip_confirm and pptx_edit.image_change_requested(content): from PySide6.QtWidgets import QMessageBox ok = QMessageBox.question(self, tr("folder.ai_image_confirm_title"), tr("folder.ai_image_confirm")) if ok != QMessageBox.Yes: self.status_message.emit(tr("folder.ai_image_declined")) return False pptx_edit.apply_text_to_pptx(self._current_file, content) return True def _open_external(self) -> None: if self._current_file: from ...ui.osutil import open_location open_location(self._current_file)