CI / test (push) Canceled after 0s
## Summary epic r04 - begin refactor ## Change Type - [x] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com> Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Reviewed-on: #7 Co-authored-by: Duy Le Huu <duylh19@fpt.com>
314 lines
13 KiB
Python
314 lines
13 KiB
Python
"""Embed a LibreOffice editor window inside a Qt panel (Windows only).
|
|
|
|
LibreOffice has no official embedding API, so this launches a *private*
|
|
LibreOffice instance (its own UserInstallation profile so the window is easy to
|
|
find) opening the document, then reparents that top-level window (window class
|
|
``SALFRAME``) into a Qt container via Win32 ``SetParent`` / ``SetWindowLong``.
|
|
|
|
This is best-effort and experimental. On non-Windows, when LibreOffice isn't
|
|
installed, or if the window can't be located/reparented, it degrades gracefully
|
|
to an "Open in LibreOffice" button (a normal external window) so nothing breaks.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
from PySide6.QtCore import Qt, QTimer, QUrl
|
|
from PySide6.QtGui import QDesktopServices, QWindow
|
|
from PySide6.QtWidgets import QApplication, QLabel, QPushButton, QVBoxLayout, QWidget
|
|
|
|
from ..core.doc_extract import find_soffice
|
|
from ..i18n import on_language_changed, tr
|
|
from .icons import icon
|
|
|
|
# Binary document formats that LibreOffice should own (plain text / csv stay in
|
|
# the built-in text editor, so they are intentionally excluded here).
|
|
DOC_SUFFIXES = {
|
|
".doc", ".docx", ".odt", ".rtf",
|
|
".xls", ".xlsx", ".ods",
|
|
".ppt", ".pptx", ".odp",
|
|
".pdf",
|
|
}
|
|
|
|
|
|
def is_document(path) -> bool:
|
|
"""Đuôi tệp này có phải tài liệu LibreOffice mở được không."""
|
|
return Path(path).suffix.lower() in DOC_SUFFIXES
|
|
|
|
|
|
def _win_user32():
|
|
"""user32 with explicit HWND-safe signatures.
|
|
|
|
Without argtypes, ctypes passes Python ints as 32-bit ``c_int``, which
|
|
truncates 64-bit window handles on Win64 and makes every call operate on the
|
|
wrong window. Declaring HWND (a pointer) keeps the full handle."""
|
|
import ctypes
|
|
from ctypes import wintypes
|
|
|
|
u = ctypes.windll.user32
|
|
u.IsWindowVisible.argtypes = [wintypes.HWND]
|
|
u.IsWindowVisible.restype = wintypes.BOOL
|
|
u.GetClassNameW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int]
|
|
u.GetClassNameW.restype = ctypes.c_int
|
|
u.GetWindowTextW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int]
|
|
u.GetWindowTextW.restype = ctypes.c_int
|
|
u.GetWindowLongW.argtypes = [wintypes.HWND, ctypes.c_int]
|
|
u.GetWindowLongW.restype = ctypes.c_long
|
|
u.SetWindowLongW.argtypes = [wintypes.HWND, ctypes.c_int, ctypes.c_long]
|
|
u.SetWindowLongW.restype = ctypes.c_long
|
|
u.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int]
|
|
u.ShowWindow.restype = wintypes.BOOL
|
|
u.PostMessageW.argtypes = [wintypes.HWND, ctypes.c_uint, wintypes.WPARAM, wintypes.LPARAM]
|
|
u.PostMessageW.restype = wintypes.BOOL
|
|
return u
|
|
|
|
|
|
class LibreOfficeView(QWidget):
|
|
"""Hosts an embedded LibreOffice window (Windows) or an external-open fallback."""
|
|
|
|
POLL_MS = 400
|
|
MAX_TRIES = 40 # ~16s to find the window before giving up
|
|
|
|
def __init__(self):
|
|
"""Khung nhúng cửa sổ LibreOffice vào trong ứng dụng.
|
|
|
|
Chưa chạy tiến trình nào; hồ sơ riêng và cửa sổ được dựng ở lần mở tệp đầu
|
|
tiên.
|
|
"""
|
|
super().__init__()
|
|
self._proc: subprocess.Popen | None = None
|
|
self._profile_dir: Path | None = None
|
|
self._container: QWidget | None = None
|
|
self._foreign: QWindow | None = None
|
|
self._hwnd: int | None = None
|
|
self._path: str | None = None
|
|
self._tries = 0
|
|
|
|
self._poll = QTimer(self)
|
|
self._poll.setInterval(self.POLL_MS)
|
|
self._poll.timeout.connect(self._try_embed)
|
|
|
|
self._lay = QVBoxLayout(self)
|
|
self._lay.setContentsMargins(0, 0, 0, 0)
|
|
self._lay.setSpacing(8)
|
|
self._info = QLabel("", alignment=Qt.AlignCenter)
|
|
self._info.setObjectName("hint")
|
|
self._info.setWordWrap(True)
|
|
self._lay.addWidget(self._info)
|
|
self._open_btn = QPushButton()
|
|
self._open_btn.setIcon(icon("document"))
|
|
self._open_btn.setObjectName("primary")
|
|
self._open_btn.clicked.connect(self._open_external)
|
|
self._open_btn.setVisible(False)
|
|
self._lay.addWidget(self._open_btn, alignment=Qt.AlignCenter)
|
|
|
|
# Make sure any embedded instance is torn down when the app quits.
|
|
app = QApplication.instance()
|
|
if app is not None:
|
|
app.aboutToQuit.connect(self.close_document)
|
|
on_language_changed(self._retranslate)
|
|
|
|
def _retranslate(self) -> None:
|
|
"""Áp lại chữ theo ngôn ngữ đang chọn."""
|
|
self._open_btn.setText(tr("libreoffice.open_btn"))
|
|
|
|
# ---- public API --------------------------------------------------
|
|
def open_document(self, path: str) -> None:
|
|
"""Mở một tài liệu: đóng cái đang mở, khởi động soffice rồi nhúng cửa sổ của nó."""
|
|
self.close_document()
|
|
self._path = str(path)
|
|
soffice = find_soffice()
|
|
if not soffice:
|
|
self._show_message(tr("libreoffice.not_found"), offer_open=False)
|
|
return
|
|
if sys.platform != "win32":
|
|
self._show_message(tr("libreoffice.windows_only"), offer_open=True)
|
|
return
|
|
self._launch_and_embed(soffice)
|
|
|
|
def close_document(self) -> None:
|
|
"""Đóng tài liệu: dừng bộ đếm dò, gỡ cửa sổ nhúng và tắt tiến trình soffice."""
|
|
self._poll.stop()
|
|
self._tries = 0
|
|
if self._container is not None:
|
|
self._container.setParent(None)
|
|
self._container.deleteLater()
|
|
self._container = None
|
|
self._foreign = None
|
|
# Ask LibreOffice to close its window gracefully (lets it prompt to save),
|
|
# then clean up the throwaway profile.
|
|
if self._hwnd is not None:
|
|
self._post_close(self._hwnd)
|
|
self._hwnd = None
|
|
self._cleanup_proc(graceful=True)
|
|
self._clear_info()
|
|
|
|
# ---- launch + embed (Windows) ------------------------------------
|
|
def _launch_and_embed(self, soffice: str) -> None:
|
|
"""Khởi động soffice với một profile RIÊNG rồi bắt đầu dò cửa sổ của nó.
|
|
|
|
Profile riêng trong thư mục tạm là bắt buộc: dùng chung profile mặc định thì
|
|
instance thứ hai sẽ nối vào instance đang chạy của người dùng và không có
|
|
cửa sổ mới nào để nhúng.
|
|
"""
|
|
try:
|
|
self._profile_dir = Path(tempfile.mkdtemp(prefix=f"lo-embed-{uuid.uuid4().hex[:8]}-"))
|
|
profile_url = "file:///" + str(self._profile_dir).replace("\\", "/")
|
|
args = [
|
|
soffice,
|
|
f"-env:UserInstallation={profile_url}",
|
|
"--norestore", "--nologo", "--nofirststartwizard",
|
|
"--minimized", # don't flash an external window before we embed it
|
|
self._path,
|
|
]
|
|
self._proc = subprocess.Popen(args)
|
|
except Exception as exc: # noqa: BLE001
|
|
self._show_message(tr("libreoffice.start_failed", err=exc), offer_open=True)
|
|
return
|
|
self._show_message(tr("libreoffice.opening"))
|
|
self._tries = 0
|
|
self._poll.start()
|
|
|
|
def _try_embed(self) -> None:
|
|
"""Một nhịp dò: tìm cửa sổ soffice và nhúng, quá ``MAX_TRIES`` thì bỏ cuộc.
|
|
|
|
Phải dò theo nhịp vì soffice tạo cửa sổ sau khi tiến trình đã khởi động —
|
|
không có tín hiệu nào báo "cửa sổ đã sẵn sàng".
|
|
"""
|
|
self._tries += 1
|
|
if self._tries > self.MAX_TRIES:
|
|
self._poll.stop()
|
|
self._show_message(tr("libreoffice.embed_failed"), offer_open=True)
|
|
return
|
|
hwnd = self._find_lo_window()
|
|
if hwnd:
|
|
self._poll.stop()
|
|
self._embed_hwnd(hwnd)
|
|
|
|
def _find_lo_window(self) -> int | None:
|
|
"""Find a visible top-level LibreOffice frame for this document."""
|
|
try:
|
|
import ctypes
|
|
from ctypes import wintypes
|
|
user32 = _win_user32()
|
|
except Exception: # noqa: BLE001
|
|
return None
|
|
stem = Path(self._path).stem.lower() if self._path else ""
|
|
found: list[int] = []
|
|
|
|
@ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
|
|
def _cb(hwnd, _lparam):
|
|
"""Callback duyệt cửa sổ của Windows: nhận cửa sổ đang hiện và đúng lớp của soffice."""
|
|
if not user32.IsWindowVisible(hwnd):
|
|
return True
|
|
cls = ctypes.create_unicode_buffer(256)
|
|
user32.GetClassNameW(hwnd, cls, 256)
|
|
if "SALFRAME" not in cls.value:
|
|
return True
|
|
title = ctypes.create_unicode_buffer(512)
|
|
user32.GetWindowTextW(hwnd, title, 512)
|
|
# Match the document we just opened (title is "<file> - LibreOffice …").
|
|
if stem and stem in title.value.lower():
|
|
found.append(int(hwnd))
|
|
return False
|
|
return True
|
|
|
|
try:
|
|
user32.EnumWindows(_cb, 0)
|
|
except Exception: # noqa: BLE001
|
|
return None
|
|
return found[0] if found else None
|
|
|
|
def _embed_hwnd(self, hwnd: int) -> None:
|
|
"""Đổi cha của cửa sổ soffice sang widget này và bỏ khung viền của nó."""
|
|
try:
|
|
user32 = _win_user32()
|
|
GWL_STYLE = -16
|
|
WS_CHILD = 0x40000000
|
|
WS_VISIBLE = 0x10000000
|
|
WS_CAPTION = 0x00C00000
|
|
WS_THICKFRAME = 0x00040000
|
|
WS_POPUP = 0x80000000
|
|
style = user32.GetWindowLongW(hwnd, GWL_STYLE)
|
|
style = (style & ~WS_CAPTION & ~WS_THICKFRAME & ~WS_POPUP) | WS_CHILD | WS_VISIBLE
|
|
user32.SetWindowLongW(hwnd, GWL_STYLE, style)
|
|
|
|
self._hwnd = hwnd
|
|
self._foreign = QWindow.fromWinId(hwnd)
|
|
self._container = QWidget.createWindowContainer(self._foreign, self)
|
|
self._container.setFocusPolicy(Qt.StrongFocus)
|
|
self._clear_info()
|
|
self._lay.addWidget(self._container, 1)
|
|
# Reveal only now that it's reparented, so it never flashes outside.
|
|
user32.ShowWindow(hwnd, 1) # SW_SHOWNORMAL
|
|
except Exception as exc: # noqa: BLE001
|
|
self._show_message(tr("libreoffice.embed_error", err=exc), offer_open=True)
|
|
|
|
# ---- cleanup -----------------------------------------------------
|
|
def _post_close(self, hwnd: int) -> None:
|
|
"""Gửi lệnh đóng tới cửa sổ soffice đang nhúng.
|
|
|
|
Đóng lịch sự thay vì giết tiến trình, để LibreOffice kịp dọn file khoá — bỏ
|
|
qua bước này thì lần mở sau nó báo tài liệu đang bị dùng.
|
|
"""
|
|
try:
|
|
WM_CLOSE = 0x0010
|
|
_win_user32().PostMessageW(hwnd, WM_CLOSE, 0, 0)
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
def _cleanup_proc(self, graceful: bool = True) -> None:
|
|
"""Dọn tiến trình soffice và thư mục profile tạm của nó.
|
|
|
|
``graceful=True`` đóng lịch sự trước rồi mới giết; profile tạm luôn bị xoá,
|
|
nếu không mỗi lần mở tài liệu lại để lại một thư mục rác.
|
|
"""
|
|
proc, self._proc = self._proc, None
|
|
profile, self._profile_dir = self._profile_dir, None
|
|
if proc is not None and proc.poll() is None:
|
|
try:
|
|
if not graceful:
|
|
proc.terminate()
|
|
proc.wait(timeout=3)
|
|
except Exception: # noqa: BLE001
|
|
try:
|
|
proc.kill()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
if profile is not None:
|
|
shutil.rmtree(profile, ignore_errors=True)
|
|
|
|
def _open_external(self) -> None:
|
|
"""Mở tài liệu bằng ứng dụng mặc định của hệ điều hành — lối thoát khi không
|
|
nhúng được cửa sổ.
|
|
"""
|
|
if not self._path:
|
|
return
|
|
soffice = find_soffice()
|
|
try:
|
|
if soffice:
|
|
subprocess.Popen([soffice, self._path])
|
|
return
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
QDesktopServices.openUrl(QUrl.fromLocalFile(self._path))
|
|
|
|
# ---- small helpers -----------------------------------------------
|
|
def _show_message(self, text: str, offer_open: bool = False) -> None:
|
|
"""Hiện một dòng thông báo thay cho khung xem, kèm nút mở bằng ứng dụng ngoài
|
|
nếu có.
|
|
"""
|
|
self._info.setText(text)
|
|
self._info.setVisible(True)
|
|
self._open_btn.setVisible(bool(offer_open and self._path))
|
|
|
|
def _clear_info(self) -> None:
|
|
"""Ẩn dòng thông báo và nút mở ngoài."""
|
|
self._info.setVisible(False)
|
|
self._open_btn.setVisible(False)
|