Remove legacy Office canvas affordances

Route DOCX, spreadsheets, and presentations exclusively through the Xpra desktop LibreOffice session. Keep the custom canvas path focused on Markdown source editing, remove the old dashboard/preview/native LibreOfficeKit code, and update tests and runtime package declarations to match the new Office surface.
This commit is contained in:
Alessandro 2026-05-02 19:24:49 +02:00
parent 739c0a18a3
commit e64b9b2538
15 changed files with 353 additions and 2594 deletions

View file

@ -59,10 +59,6 @@ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
libreoffice-calc \
libreoffice-impress \
libreoffice-gtk3 \
libreofficekit-data \
libreofficekit-dev \
gir1.2-lokdocview-0.1 \
python3-gi \
python3-uno \
xpra \
xpra-x11 \

View file

@ -1,7 +1,7 @@
from __future__ import annotations
from helpers.api import ApiHandler, Request
from plugins._office.helpers import document_store, libreoffice, libreoffice_desktop, libreofficekit_sessions
from plugins._office.helpers import document_store, libreoffice, libreoffice_desktop, markdown_sessions
class OfficeSession(ApiHandler):
@ -13,24 +13,14 @@ class OfficeSession(ApiHandler):
return libreoffice.collect_status()
if action == "home":
return {"ok": True, "path": document_store.default_open_path(context_id)}
if action == "recent":
return {"ok": True, "documents": _public_docs(document_store.get_recent_documents())}
if action == "open_documents":
return {"ok": True, "documents": _public_docs(document_store.get_open_documents(limit=24))}
if action == "desktop":
return self._desktop()
if action == "sync_open_sessions":
session_ids = input.get("session_ids")
if not isinstance(session_ids, list):
session_ids = []
closed = document_store.sync_open_sessions(session_ids)
return {"ok": True, "closed": closed, "documents": _public_docs(document_store.get_open_documents(limit=24))}
if action == "close":
closed = document_store.close_session(
session_id=str(input.get("session_id") or ""),
file_id=str(input.get("file_id") or ""),
)
return {"ok": True, "closed": closed, "documents": _public_docs(document_store.get_open_documents(limit=24))}
return {"ok": True, "closed": closed}
if action == "create":
try:
doc = document_store.create_document(
@ -65,32 +55,6 @@ class OfficeSession(ApiHandler):
return self._desktop_save(input)
if action == "desktop_sync":
return self._desktop_sync(input)
if action == "desktop_close":
return self._desktop_close(input)
if action == "key":
return libreofficekit_sessions.get_manager().key(
str(input.get("session_id") or ""),
input.get("key") if isinstance(input.get("key"), dict) else {},
)
if action == "mouse":
return libreofficekit_sessions.get_manager().mouse(
str(input.get("session_id") or ""),
input.get("mouse") if isinstance(input.get("mouse"), dict) else {},
)
if action == "command":
return libreofficekit_sessions.get_manager().command(
str(input.get("session_id") or ""),
str(input.get("command") or ""),
arguments=input.get("arguments"),
notify=bool(input.get("notify", True)),
)
if action == "command_values":
return libreofficekit_sessions.get_manager().command_values(
str(input.get("session_id") or ""),
str(input.get("command") or ""),
)
if action == "export":
return self._export(input)
return {"ok": False, "error": f"Unsupported office session action: {action}"}
async def _open_document(self, doc: dict, input: dict, request: Request) -> dict:
@ -120,22 +84,21 @@ class OfficeSession(ApiHandler):
"extension": doc["extension"],
"path": doc["path"],
"text": "",
"tiles": [],
"document": _public_doc(doc),
"version": document_store.item_version(doc),
"libreoffice": libreoffice.collect_status(),
"native": {"available": False, "mode": "desktop"},
"desktop": desktop,
"store_session_id": store_session["session_id"],
"preview": document_store.build_preview(doc),
"mode": mode,
}
editor = libreofficekit_sessions.get_manager().open(doc, sid="")
try:
editor = markdown_sessions.get_manager().open(doc, sid="")
except ValueError as exc:
document_store.close_session(session_id=store_session["session_id"])
return {"ok": False, "error": str(exc)}
return {
**editor,
"store_session_id": store_session["session_id"],
"session_id": editor["session_id"],
"preview": document_store.build_preview(doc),
"mode": mode,
}
@ -143,7 +106,7 @@ class OfficeSession(ApiHandler):
session_id = str(input.get("session_id") or "").strip()
if not session_id:
return {"ok": False, "error": "session_id is required."}
return libreofficekit_sessions.get_manager().save(session_id, text=input.get("text"))
return markdown_sessions.get_manager().save(session_id, text=input.get("text"))
def _desktop(self) -> dict:
desktop = libreoffice_desktop.get_manager().ensure_system_desktop()
@ -162,7 +125,6 @@ class OfficeSession(ApiHandler):
"extension": "desktop",
"size": 0,
"version": 0,
"preview": {},
}
return {
"ok": True,
@ -173,14 +135,10 @@ class OfficeSession(ApiHandler):
"extension": "desktop",
"path": desktop["path"],
"text": "",
"tiles": [],
"document": document,
"version": 0,
"libreoffice": libreoffice.collect_status(),
"native": {"available": False, "mode": "desktop"},
"desktop": desktop,
"store_session_id": "",
"preview": {},
"mode": "desktop",
}
@ -199,34 +157,10 @@ class OfficeSession(ApiHandler):
file_id=str(input.get("file_id") or ""),
)
def _desktop_close(self, input: dict) -> dict:
session_id = str(input.get("desktop_session_id") or input.get("session_id") or "").strip()
if not session_id:
return {"ok": False, "error": "desktop_session_id is required."}
return libreoffice_desktop.get_manager().close(
session_id,
save_first=bool(input.get("save_first", True)),
)
def _export(self, input: dict) -> dict:
file_id = str(input.get("file_id") or "").strip()
path = str(input.get("path") or "").strip()
target_format = str(input.get("target_format") or input.get("format") or "pdf").lower().lstrip(".")
doc = document_store.get_document(file_id) if file_id else document_store.register_document(path)
result = libreoffice.convert_document(doc["path"], target_format)
if not result.get("ok"):
return result
return {"ok": True, "path": document_store.display_path(result["path"]), "source": _public_doc(doc)}
def _origin(self, request: Request) -> str:
origin = request.headers.get("Origin") or request.host_url.rstrip("/")
return origin.rstrip("/")
def _public_docs(docs: list[dict]) -> list[dict]:
return [_public_doc(doc) for doc in docs]
def _public_doc(doc: dict) -> dict:
result = {
"file_id": doc["file_id"],
@ -237,7 +171,6 @@ def _public_doc(doc: dict) -> dict:
"size": doc["size"],
"version": document_store.item_version(doc),
"last_modified": doc["last_modified"],
"preview": doc.get("preview") or document_store.build_preview(doc),
}
for key in ("open_sessions", "last_opened_at", "session_expires_at"):
if key in doc:

View file

@ -4,12 +4,12 @@ from typing import Any
from helpers.ws import WsHandler
from helpers.ws_manager import WsResult
from plugins._office.helpers import document_store, libreofficekit_sessions
from plugins._office.helpers import document_store, markdown_sessions
class WsOffice(WsHandler):
async def on_disconnect(self, sid: str) -> None:
libreofficekit_sessions.get_manager().close_sid(sid)
markdown_sessions.get_manager().close_sid(sid)
async def process(self, event: str, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult | None:
if not event.startswith("office_"):
@ -18,53 +18,18 @@ class WsOffice(WsHandler):
if event == "office_open":
return self._open(data, sid)
if event == "office_input":
return libreofficekit_sessions.get_manager().input(
return markdown_sessions.get_manager().input(
str(data.get("session_id") or ""),
text=data.get("text") if "text" in data else None,
patch=data.get("patch") if isinstance(data.get("patch"), dict) else None,
)
if event == "office_key":
return libreofficekit_sessions.get_manager().key(
str(data.get("session_id") or ""),
data.get("key") if isinstance(data.get("key"), dict) else {},
)
if event == "office_mouse":
return libreofficekit_sessions.get_manager().mouse(
str(data.get("session_id") or ""),
data.get("mouse") if isinstance(data.get("mouse"), dict) else {},
)
if event == "office_cursor":
return libreofficekit_sessions.get_manager().cursor(
str(data.get("session_id") or ""),
data.get("cursor") if isinstance(data.get("cursor"), dict) else {},
)
if event == "office_selection":
return libreofficekit_sessions.get_manager().selection(
str(data.get("session_id") or ""),
data.get("selection") if isinstance(data.get("selection"), dict) else {},
)
if event == "office_invalidated_tiles":
session_id = str(data.get("session_id") or "")
return {"session_id": session_id, "tiles": libreofficekit_sessions.get_manager().tiles(session_id)}
if event == "office_command":
return libreofficekit_sessions.get_manager().command(
str(data.get("session_id") or ""),
str(data.get("command") or ""),
arguments=data.get("arguments"),
notify=bool(data.get("notify", True)),
)
if event == "office_command_values":
return libreofficekit_sessions.get_manager().command_values(
str(data.get("session_id") or ""),
str(data.get("command") or ""),
)
if event == "office_save":
return libreofficekit_sessions.get_manager().save(
return markdown_sessions.get_manager().save(
str(data.get("session_id") or ""),
text=data.get("text") if "text" in data else None,
)
if event == "office_close":
return libreofficekit_sessions.get_manager().close(str(data.get("session_id") or ""))
return markdown_sessions.get_manager().close(str(data.get("session_id") or ""))
except FileNotFoundError as exc:
return WsResult.error(code="OFFICE_SESSION_NOT_FOUND", message=str(exc), correlation_id=data.get("correlationId"))
except Exception as exc:
@ -92,4 +57,4 @@ class WsOffice(WsHandler):
content=str(data.get("content") or ""),
context_id=context_id,
)
return libreofficekit_sessions.get_manager().open(doc, sid=sid)
return markdown_sessions.get_manager().open(doc, sid=sid)

View file

@ -110,9 +110,9 @@ def edit_artifact(
def _refresh_open_editor_sessions(file_id: str) -> None:
try:
from plugins._office.helpers import libreofficekit_sessions
from plugins._office.helpers import markdown_sessions
libreofficekit_sessions.get_manager().refresh_document(file_id)
markdown_sessions.get_manager().refresh_document(file_id)
except Exception:
# Direct artifact edits should never fail just because no canvas is open.
return

View file

@ -10,7 +10,6 @@ import sqlite3
import time
import uuid
import zipfile
import xml.etree.ElementTree as ET
from contextlib import contextmanager
from pathlib import Path
from typing import Any
@ -23,16 +22,7 @@ from plugins._office.helpers import pptx_writer
PLUGIN_NAME = "_office"
SUPPORTED_EXTENSIONS = {"md", "docx", "xlsx", "pptx"}
DEFAULT_TTL_SECONDS = 8 * 60 * 60
ORPHAN_SESSION_GRACE_SECONDS = 30
MAX_SAVE_BYTES = 512 * 1024 * 1024
PREVIEW_LINE_LIMIT = 5
PREVIEW_ROW_LIMIT = 5
PREVIEW_COLUMN_LIMIT = 4
PREVIEW_SLIDE_LIMIT = 2
W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
X_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME, "documents"))
DB_PATH = STATE_DIR / "documents.sqlite3"
@ -278,18 +268,6 @@ def get_document(file_id: str, conn: sqlite3.Connection | None = None) -> dict[s
return _fetch(active)
def get_recent_documents(limit: int = 12, include_preview: bool = True) -> list[dict[str, Any]]:
with connect() as conn:
rows = conn.execute(
"SELECT * FROM documents ORDER BY updated_at DESC LIMIT ?",
(limit,),
).fetchall()
documents = [dict(row) for row in rows]
if include_preview:
return [with_preview(document) for document in documents]
return documents
def get_open_documents(limit: int = 6) -> list[dict[str, Any]]:
with connect() as conn:
_clear_expired_sessions(conn)
@ -309,7 +287,7 @@ def get_open_documents(limit: int = 6) -> list[dict[str, Any]]:
""",
(now(), limit),
).fetchall()
return [with_preview(dict(row)) for row in rows]
return [dict(row) for row in rows]
def create_session(
@ -365,121 +343,11 @@ def close_session(session_id: str = "", file_id: str = "") -> int:
return len(rows)
def sync_open_sessions(active_session_ids: list[str] | tuple[str, ...] | set[str]) -> int:
active_ids = {str(session_id).strip() for session_id in active_session_ids if str(session_id).strip()}
with connect() as conn:
_clear_expired_sessions(conn)
cutoff = now() - ORPHAN_SESSION_GRACE_SECONDS
if active_ids:
placeholders = ",".join("?" for _ in active_ids)
rows = conn.execute(
f"SELECT session_id, file_id FROM sessions WHERE session_id NOT IN ({placeholders}) AND created_at < ?",
(*tuple(active_ids), cutoff),
).fetchall()
else:
rows = conn.execute("SELECT session_id, file_id FROM sessions WHERE created_at < ?", (cutoff,)).fetchall()
if not rows:
return 0
session_ids = tuple(row["session_id"] for row in rows)
placeholders = ",".join("?" for _ in session_ids)
conn.execute(f"DELETE FROM sessions WHERE session_id IN ({placeholders})", session_ids)
for row in rows:
conn.execute(
"INSERT INTO events (file_id, event_type, payload, created_at) VALUES (?, ?, ?, ?)",
(row["file_id"], "close_orphan_session", json.dumps({"session_id": row["session_id"]}), now()),
)
return len(rows)
def with_preview(document: dict[str, Any]) -> dict[str, Any]:
return {**document, "preview": build_preview(document)}
def build_preview(document: dict[str, Any]) -> dict[str, Any]:
ext = str(document.get("extension") or "").lower()
path = Path(str(document.get("path") or ""))
preview = {
"available": False,
"kind": _preview_kind(ext),
"lines": [],
"rows": [],
"slides": [],
}
if not path.exists():
return preview
try:
if ext == "md":
lines = _preview_markdown(path)
return {**preview, "available": bool(lines), "lines": lines}
if ext == "docx":
lines = _preview_docx(path)
return {**preview, "available": bool(lines), "lines": lines}
if ext == "xlsx":
rows = _preview_xlsx(path)
return {**preview, "available": bool(rows), "rows": rows}
if ext == "pptx":
slides = _preview_pptx(path)
return {**preview, "available": bool(slides), "slides": slides}
except Exception:
return preview
return preview
def _preview_kind(ext: str) -> str:
if ext == "xlsx":
return "spreadsheet"
if ext == "pptx":
return "presentation"
if ext in {"md", "docx"}:
return "document"
return "file"
def _qn(namespace: str, tag: str) -> str:
return f"{{{namespace}}}{tag}"
def _clean_preview_text(value: Any) -> str:
return re.sub(r"\s+", " ", str(value or "")).strip()
def _preview_markdown(path: Path) -> list[str]:
lines = []
for raw in path.read_text(encoding="utf-8", errors="replace").splitlines():
text = _clean_preview_text(raw.lstrip("#>-*0123456789.[]() "))
if text:
lines.append(text)
if len(lines) >= PREVIEW_LINE_LIMIT:
break
return lines
def _preview_docx(path: Path) -> list[str]:
return _docx_paragraphs(path, limit=PREVIEW_LINE_LIMIT)
def _docx_paragraphs(path: Path, limit: int | None = None) -> list[str]:
with zipfile.ZipFile(path) as archive:
root = ET.fromstring(archive.read("word/document.xml"))
lines = []
for paragraph in root.iter(_qn(W_NS, "p")):
text = _clean_preview_text("".join(node.text or "" for node in paragraph.iter(_qn(W_NS, "t"))))
if text:
lines.append(text)
if limit is not None and len(lines) >= limit:
break
return lines
def read_text_for_editor(doc: dict[str, Any]) -> str:
path = Path(doc["path"])
ext = str(doc["extension"]).lower()
if ext == "md":
return path.read_text(encoding="utf-8", errors="replace")
if ext == "docx":
return "\n\n".join(_docx_paragraphs(path))
raise ValueError(f"Text editing is not available for .{ext}.")
@ -487,79 +355,6 @@ def write_markdown(file_id: str, content: str) -> dict[str, Any]:
return replace_document_bytes(file_id, str(content or "").encode("utf-8"), actor="office:markdown")
def _preview_xlsx(path: Path) -> list[list[str]]:
with zipfile.ZipFile(path) as archive:
shared_strings = _xlsx_shared_strings(archive)
sheet_names = sorted(
(name for name in archive.namelist() if re.fullmatch(r"xl/worksheets/sheet\d+\.xml", name)),
key=_natural_name_key,
)
if not sheet_names:
return []
root = ET.fromstring(archive.read(sheet_names[0]))
rows = []
for row in root.iter(_qn(X_NS, "row")):
cells = []
for cell in list(row)[:PREVIEW_COLUMN_LIMIT]:
cells.append(_xlsx_cell_preview(cell, shared_strings))
if any(cells):
rows.append(cells)
if len(rows) >= PREVIEW_ROW_LIMIT:
break
return rows
def _xlsx_shared_strings(archive: zipfile.ZipFile) -> list[str]:
try:
root = ET.fromstring(archive.read("xl/sharedStrings.xml"))
except KeyError:
return []
strings = []
for item in root.iter(_qn(X_NS, "si")):
strings.append(_clean_preview_text("".join(node.text or "" for node in item.iter(_qn(X_NS, "t")))))
return strings
def _xlsx_cell_preview(cell: ET.Element, shared_strings: list[str]) -> str:
cell_type = cell.attrib.get("t", "")
if cell_type == "inlineStr":
return _clean_preview_text("".join(node.text or "" for node in cell.iter(_qn(X_NS, "t"))))
value_node = cell.find(_qn(X_NS, "v"))
value = _clean_preview_text(value_node.text if value_node is not None else "")
if cell_type == "s":
try:
return shared_strings[int(value)]
except (ValueError, IndexError):
return value
if cell_type == "b":
return "TRUE" if value == "1" else "FALSE"
return value
def _preview_pptx(path: Path) -> list[dict[str, Any]]:
with zipfile.ZipFile(path) as archive:
names = sorted(
(name for name in archive.namelist() if re.fullmatch(r"ppt/slides/slide\d+\.xml", name)),
key=_natural_name_key,
)
slides = []
for name in names[:PREVIEW_SLIDE_LIMIT]:
root = ET.fromstring(archive.read(name))
lines = []
for paragraph in root.iter(_qn(A_NS, "p")):
text = _clean_preview_text("".join(node.text or "" for node in paragraph.iter(_qn(A_NS, "t"))))
if text:
lines.append(text)
if lines:
slides.append({"title": lines[0], "lines": lines[1:PREVIEW_LINE_LIMIT]})
return slides
def _natural_name_key(value: str) -> list[int | str]:
return [int(part) if part.isdigit() else part for part in re.split(r"(\d+)", value)]
def replace_document_bytes(
file_id: str,
data: bytes,

View file

@ -3,10 +3,8 @@ from __future__ import annotations
import os
import shutil
import subprocess
import sys
import tempfile
import zipfile
from functools import lru_cache
from pathlib import Path
from typing import Any
@ -30,7 +28,6 @@ def collect_status() -> dict[str, Any]:
"state": "healthy" if soffice else "missing",
"healthy": bool(soffice),
"soffice": soffice,
"libreofficekit": _libreofficekit_available(),
"message": "LibreOffice is available." if soffice else "LibreOffice is not installed in this runtime.",
}
try:
@ -42,29 +39,6 @@ def collect_status() -> dict[str, Any]:
return status
@lru_cache(maxsize=1)
def _libreofficekit_available() -> bool:
system_dist_packages = Path("/usr/lib/python3/dist-packages")
if system_dist_packages.exists() and str(system_dist_packages) not in sys.path:
sys.path.append(str(system_dist_packages))
try:
import gi # type: ignore
gi.require_version("LOKDocView", "0.1")
return True
except Exception:
return _lokdocview_typelib_available()
def _lokdocview_typelib_available() -> bool:
candidates = [
Path("/usr/lib/x86_64-linux-gnu/girepository-1.0/LOKDocView-0.1.typelib"),
Path("/usr/lib/aarch64-linux-gnu/girepository-1.0/LOKDocView-0.1.typelib"),
Path("/usr/share/gir-1.0/LOKDocView-0.1.gir"),
]
return any(path.exists() for path in candidates)
def validate_docx(path: str | Path) -> dict[str, Any]:
source = Path(path)
if not source.exists():

View file

@ -1,423 +0,0 @@
from __future__ import annotations
import ctypes
import atexit
import base64
import math
import json
import os
import shutil
import struct
import tempfile
import zlib
from pathlib import Path
from typing import Any
PROGRAM_DIR = Path(os.environ.get("A0_LIBREOFFICE_PROGRAM_DIR") or "/usr/lib/libreoffice/program")
MERGED_LIBRARY = PROGRAM_DIR / "libmergedlo.so"
DEFAULT_TILE_WIDTH_PX = 920
MAX_TILE_HEIGHT_PX = 1800
MAX_TILES = 12
class LibreOfficeKitNativeError(RuntimeError):
pass
class _Office(ctypes.Structure):
pass
class _OfficeClass(ctypes.Structure):
pass
class _Document(ctypes.Structure):
pass
class _DocumentClass(ctypes.Structure):
pass
_OfficePtr = ctypes.POINTER(_Office)
_DocumentPtr = ctypes.POINTER(_Document)
_DestroyOffice = ctypes.CFUNCTYPE(None, _OfficePtr)
_DocumentLoad = ctypes.CFUNCTYPE(_DocumentPtr, _OfficePtr, ctypes.c_char_p)
_GetError = ctypes.CFUNCTYPE(ctypes.c_char_p, _OfficePtr)
_DocumentLoadWithOptions = ctypes.CFUNCTYPE(_DocumentPtr, _OfficePtr, ctypes.c_char_p, ctypes.c_char_p)
_FreeError = ctypes.CFUNCTYPE(None, ctypes.c_char_p)
_DestroyDocument = ctypes.CFUNCTYPE(None, _DocumentPtr)
_SaveAs = ctypes.CFUNCTYPE(ctypes.c_int, _DocumentPtr, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p)
_GetDocumentType = ctypes.CFUNCTYPE(ctypes.c_int, _DocumentPtr)
_GetParts = ctypes.CFUNCTYPE(ctypes.c_int, _DocumentPtr)
_GetPartPageRectangles = ctypes.CFUNCTYPE(ctypes.c_char_p, _DocumentPtr)
_GetPart = ctypes.CFUNCTYPE(ctypes.c_int, _DocumentPtr)
_SetPart = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_int)
_GetPartName = ctypes.CFUNCTYPE(ctypes.c_char_p, _DocumentPtr, ctypes.c_int)
_SetPartMode = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_int)
_PaintTile = ctypes.CFUNCTYPE(
None,
_DocumentPtr,
ctypes.POINTER(ctypes.c_ubyte),
ctypes.c_int,
ctypes.c_int,
ctypes.c_int,
ctypes.c_int,
ctypes.c_int,
ctypes.c_int,
)
_GetTileMode = ctypes.CFUNCTYPE(ctypes.c_int, _DocumentPtr)
_GetDocumentSize = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.POINTER(ctypes.c_long), ctypes.POINTER(ctypes.c_long))
_InitializeForRendering = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_char_p)
_RegisterDocumentCallback = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_void_p, ctypes.c_void_p)
_PostKeyEvent = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_int, ctypes.c_int, ctypes.c_int)
_PostMouseEvent = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int)
_PostUnoCommand = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_bool)
_SetTextSelection = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_int, ctypes.c_int, ctypes.c_int)
_GetTextSelection = ctypes.CFUNCTYPE(ctypes.c_char_p, _DocumentPtr, ctypes.c_char_p, ctypes.POINTER(ctypes.c_char_p))
_Paste = ctypes.CFUNCTYPE(ctypes.c_bool, _DocumentPtr, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_size_t)
_SetGraphicSelection = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_int, ctypes.c_int, ctypes.c_int)
_ResetSelection = ctypes.CFUNCTYPE(None, _DocumentPtr)
_GetCommandValues = ctypes.CFUNCTYPE(ctypes.c_char_p, _DocumentPtr, ctypes.c_char_p)
_Office._fields_ = [("pClass", ctypes.POINTER(_OfficeClass))]
_OfficeClass._fields_ = [
("nSize", ctypes.c_size_t),
("destroy", _DestroyOffice),
("documentLoad", _DocumentLoad),
("getError", _GetError),
("documentLoadWithOptions", _DocumentLoadWithOptions),
("freeError", _FreeError),
]
_Document._fields_ = [("pClass", ctypes.POINTER(_DocumentClass))]
_DocumentClass._fields_ = [
("nSize", ctypes.c_size_t),
("destroy", _DestroyDocument),
("saveAs", _SaveAs),
("getDocumentType", _GetDocumentType),
("getParts", _GetParts),
("getPartPageRectangles", _GetPartPageRectangles),
("getPart", _GetPart),
("setPart", _SetPart),
("getPartName", _GetPartName),
("setPartMode", _SetPartMode),
("paintTile", _PaintTile),
("getTileMode", _GetTileMode),
("getDocumentSize", _GetDocumentSize),
("initializeForRendering", _InitializeForRendering),
("registerCallback", _RegisterDocumentCallback),
("postKeyEvent", _PostKeyEvent),
("postMouseEvent", _PostMouseEvent),
("postUnoCommand", _PostUnoCommand),
("setTextSelection", _SetTextSelection),
("getTextSelection", _GetTextSelection),
("paste", _Paste),
("setGraphicSelection", _SetGraphicSelection),
("resetSelection", _ResetSelection),
("getCommandValues", _GetCommandValues),
]
def available() -> bool:
return PROGRAM_DIR.exists() and MERGED_LIBRARY.exists() and os.environ.get("A0_OFFICE_DISABLE_NATIVE_LOK") != "1"
def open_document(path: str | Path) -> Any:
from plugins._office.helpers import libreofficekit_worker
return libreofficekit_worker.open_document(path)
def open_document_in_process(path: str | Path) -> "NativeLokDocument":
return get_office().open_document(path)
def get_office() -> "NativeLokOffice":
global _office
try:
return _office
except NameError:
_office = NativeLokOffice()
atexit.register(_close_global_office)
return _office
def _close_global_office() -> None:
office = globals().get("_office")
if office:
try:
office.close()
except Exception:
pass
class NativeLokOffice:
def __init__(self) -> None:
if not available():
raise LibreOfficeKitNativeError("LibreOfficeKit native library is not available.")
os.environ.setdefault("HOME", "/tmp")
os.environ.setdefault("SAL_USE_VCLPLUGIN", "gen")
self._profile_dir = Path(tempfile.mkdtemp(prefix="a0-lok-profile-"))
self._library = ctypes.CDLL(str(MERGED_LIBRARY))
self._library.libreofficekit_hook_2.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
self._library.libreofficekit_hook_2.restype = _OfficePtr
profile_url = f"file://{self._profile_dir}".encode("utf-8")
self._office = self._library.libreofficekit_hook_2(str(PROGRAM_DIR).encode("utf-8"), profile_url)
if not self._office:
raise LibreOfficeKitNativeError("LibreOfficeKit hook returned no office instance.")
def open_document(self, path: str | Path) -> "NativeLokDocument":
source = Path(path)
if not source.exists():
raise FileNotFoundError(str(source))
loaded = self._office.contents.pClass.contents.documentLoad(self._office, str(source).encode("utf-8"))
if not loaded:
raise LibreOfficeKitNativeError(self.error() or f"LibreOfficeKit could not load {source}.")
document = NativeLokDocument(loaded, source)
document.initialize_for_rendering()
return document
def error(self) -> str:
get_error = self._office.contents.pClass.contents.getError
if not get_error:
return ""
value = get_error(self._office)
return _decode_c_string(value)
def close(self) -> None:
office = getattr(self, "_office", None)
if office:
office.contents.pClass.contents.destroy(office)
self._office = None
class NativeLokDocument:
def __init__(self, document: _DocumentPtr, path: Path) -> None:
self._document = document
self.path = path
@property
def _class(self) -> _DocumentClass:
return self._document.contents.pClass.contents
def initialize_for_rendering(self) -> None:
self._class.initializeForRendering(self._document, None)
def metadata(self) -> dict[str, Any]:
width = ctypes.c_long()
height = ctypes.c_long()
self._class.getDocumentSize(self._document, ctypes.byref(width), ctypes.byref(height))
page_rectangles = self.page_rectangles(width.value, height.value)
return {
"available": True,
"doctype": self._class.getDocumentType(self._document),
"parts": self._class.getParts(self._document),
"part": self._class.getPart(self._document),
"tile_mode": self._class.getTileMode(self._document),
"width_twips": int(width.value),
"height_twips": int(height.value),
"page_rectangles": page_rectangles,
}
def page_rectangles(self, width: int = 0, height: int = 0) -> list[dict[str, int]]:
raw = self._class.getPartPageRectangles(self._document)
rectangles = _parse_rectangles(_decode_c_string(raw))
if rectangles:
return rectangles
if not width or not height:
width_ref = ctypes.c_long()
height_ref = ctypes.c_long()
self._class.getDocumentSize(self._document, ctypes.byref(width_ref), ctypes.byref(height_ref))
width = int(width_ref.value)
height = int(height_ref.value)
return [{"x": 0, "y": 0, "width": int(width), "height": int(height)}]
def render_tiles(self, pixel_width: int = DEFAULT_TILE_WIDTH_PX, max_tiles: int = MAX_TILES) -> list[dict[str, Any]]:
tile_mode = int(self._class.getTileMode(self._document))
tiles: list[dict[str, Any]] = []
for index, rectangle in enumerate(self.page_rectangles()[:max_tiles]):
width_twips = max(1, int(rectangle["width"]))
height_twips = max(1, int(rectangle["height"]))
width_px = max(320, min(int(pixel_width), 1400))
height_px = max(320, min(MAX_TILE_HEIGHT_PX, math.ceil(width_px * (height_twips / width_twips))))
buffer = (ctypes.c_ubyte * (width_px * height_px * 4))()
self._class.paintTile(
self._document,
buffer,
width_px,
height_px,
int(rectangle["x"]),
int(rectangle["y"]),
width_twips,
height_twips,
)
png = _png_from_lok_buffer(buffer, width_px, height_px, tile_mode)
tiles.append({
"index": index,
"kind": "lok-tile",
"width": width_px,
"height": height_px,
"twips": rectangle,
"image": f"data:image/png;base64,{base64.b64encode(png).decode('ascii')}",
})
return tiles
def post_uno_command(self, command: str, arguments: dict[str, Any] | str | None = None, notify: bool = True) -> dict[str, Any]:
normalized = normalize_uno_command(command)
payload = _encode_arguments(arguments)
self._class.postUnoCommand(
self._document,
normalized.encode("utf-8"),
payload,
bool(notify),
)
return {"ok": True, "native": True, "command": normalized}
def post_key_event(self, kind: str, char_code: int = 0, key_code: int = 0) -> dict[str, Any]:
event_type = 1 if str(kind or "").lower() in {"up", "keyup"} else 0
self._class.postKeyEvent(self._document, event_type, int(char_code or 0), int(key_code or 0))
return {"ok": True, "native": True, "event": "key", "type": event_type}
def type_text(self, text: str) -> dict[str, Any]:
inserted = 0
for character in str(text or ""):
code = ord(character)
self._class.postKeyEvent(self._document, 0, code, code)
self._class.postKeyEvent(self._document, 1, code, code)
inserted += 1
return {"ok": True, "native": True, "event": "text", "inserted": inserted}
def post_mouse_event(
self,
kind: str,
x: int,
y: int,
count: int = 1,
buttons: int = 1,
modifier: int = 0,
) -> dict[str, Any]:
mapping = {"down": 0, "mousedown": 0, "up": 1, "mouseup": 1, "move": 2, "mousemove": 2}
event_type = mapping.get(str(kind or "").lower(), 0)
self._class.postMouseEvent(
self._document,
event_type,
int(x),
int(y),
int(count or 1),
int(buttons or 1),
int(modifier or 0),
)
return {"ok": True, "native": True, "event": "mouse", "type": event_type}
def command_values(self, command: str) -> dict[str, Any]:
normalized = normalize_uno_command(command)
raw = self._class.getCommandValues(self._document, normalized.encode("utf-8"))
text = _decode_c_string(raw)
try:
parsed = json.loads(text) if text else {}
except json.JSONDecodeError:
parsed = {"raw": text}
return {"ok": True, "native": True, "command": normalized, "values": parsed}
def save_as(self, path: str | Path | None = None, fmt: str | None = None) -> bool:
target = Path(path) if path else self.path
result = self._class.saveAs(
self._document,
str(target).encode("utf-8"),
fmt.encode("utf-8") if fmt else None,
None,
)
return result != 0
def save_to_bytes(self, suffix: str = ".docx", fmt: str | None = "docx") -> bytes:
temp_dir = Path(tempfile.mkdtemp(prefix="a0-lok-save-"))
try:
target = temp_dir / f"document{suffix}"
if not self.save_as(target, fmt):
raise LibreOfficeKitNativeError("LibreOfficeKit saveAs failed.")
return target.read_bytes()
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
def close(self) -> None:
document = getattr(self, "_document", None)
if document:
self._class.destroy(document)
self._document = None
def normalize_uno_command(command: str) -> str:
value = str(command or "").strip()
if not value:
raise ValueError("UNO command is required.")
return value if value.startswith(".uno:") else f".uno:{value}"
def _encode_arguments(arguments: dict[str, Any] | str | None) -> bytes | None:
if arguments is None or arguments == "":
return None
if isinstance(arguments, str):
return arguments.encode("utf-8")
return json.dumps(arguments, separators=(",", ":")).encode("utf-8")
def _decode_c_string(value: bytes | int | None) -> str:
if not value:
return ""
if isinstance(value, bytes):
return value.decode("utf-8", errors="replace")
return ctypes.string_at(value).decode("utf-8", errors="replace")
def _parse_rectangles(payload: str) -> list[dict[str, int]]:
rectangles: list[dict[str, int]] = []
for item in str(payload or "").split(";"):
numbers = [part.strip() for part in item.split(",")]
if len(numbers) < 4:
continue
try:
x, y, width, height = [int(float(value)) for value in numbers[:4]]
except ValueError:
continue
if width > 0 and height > 0:
rectangles.append({"x": x, "y": y, "width": width, "height": height})
return rectangles
def _png_from_lok_buffer(buffer: Any, width: int, height: int, tile_mode: int) -> bytes:
raw = bytes(buffer)
rows = []
stride = width * 4
for y in range(height):
source = raw[y * stride:(y + 1) * stride]
if tile_mode == 1:
row = bytearray(stride)
for index in range(0, stride, 4):
blue = source[index]
green = source[index + 1]
red = source[index + 2]
alpha = source[index + 3]
row[index:index + 4] = bytes((red, green, blue, alpha))
source = bytes(row)
rows.append(b"\x00" + source)
return _png_rgba(width, height, b"".join(rows))
def _png_rgba(width: int, height: int, scanlines: bytes) -> bytes:
def chunk(kind: bytes, payload: bytes) -> bytes:
return (
struct.pack(">I", len(payload))
+ kind
+ payload
+ struct.pack(">I", zlib.crc32(kind + payload) & 0xFFFFFFFF)
)
header = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", zlib.compress(scanlines, 6)) + chunk(b"IEND", b"")

View file

@ -1,348 +0,0 @@
from __future__ import annotations
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from plugins._office.helpers import document_store, libreoffice, libreofficekit_native
@dataclass
class EditorSession:
session_id: str
file_id: str
sid: str
extension: str
path: str
title: str
text: str = ""
native_document: Any | None = None
native_metadata: dict[str, Any] = field(default_factory=dict)
native_error: str = ""
cursor: dict[str, Any] = field(default_factory=dict)
selection: dict[str, Any] = field(default_factory=dict)
opened_at: float = field(default_factory=time.time)
updated_at: float = field(default_factory=time.time)
class LibreOfficeKitSessionManager:
"""Small session facade for the right canvas.
The public contract is shaped around LibreOfficeKit-style events: open,
input, cursor/selection, invalidated tiles, save, and close. When the native
Python LOK bridge is available the rendering path can be swapped underneath
this manager without changing the browser or tool APIs.
"""
def __init__(self) -> None:
self._sessions: dict[str, EditorSession] = {}
def open(self, doc: dict[str, Any], sid: str = "") -> dict[str, Any]:
ext = str(doc["extension"]).lower()
session_id = uuid.uuid4().hex
text = ""
if ext in {"md", "docx"}:
text = document_store.read_text_for_editor(doc)
native_document = None
native_metadata: dict[str, Any] = {}
native_error = ""
if ext == "docx":
try:
native_document = libreofficekit_native.open_document(doc["path"])
native_metadata = native_document.metadata()
except Exception as exc:
native_error = str(exc)
session = EditorSession(
session_id=session_id,
file_id=doc["file_id"],
sid=sid,
extension=ext,
path=doc["path"],
title=doc["basename"],
text=text,
native_document=native_document,
native_metadata=native_metadata,
native_error=native_error,
)
self._sessions[session_id] = session
return self._payload(session, doc)
def input(self, session_id: str, text: str | None = None, patch: dict[str, Any] | None = None) -> dict[str, Any]:
session = self._require(session_id)
if text is not None:
session.text = str(text)
elif patch:
session.text = _apply_text_patch(session.text, patch)
session.updated_at = time.time()
return {"ok": True, "session_id": session_id, "invalidated_tiles": self.tiles(session_id)}
def key(self, session_id: str, key: dict[str, Any]) -> dict[str, Any]:
session = self._require(session_id)
native_document = session.native_document
if not native_document:
return {"ok": False, "native": False, "error": session.native_error or "Native key input is not available."}
text = str(key.get("text") or "")
if text:
result = native_document.type_text(text)
else:
result = native_document.post_key_event(
str(key.get("type") or "down"),
char_code=int(key.get("char_code") or 0),
key_code=int(key.get("key_code") or 0),
)
session.native_metadata = native_document.metadata()
session.updated_at = time.time()
return {**result, "metadata": session.native_metadata, "tiles": self.tiles(session_id)}
def mouse(self, session_id: str, mouse: dict[str, Any]) -> dict[str, Any]:
session = self._require(session_id)
native_document = session.native_document
if not native_document:
return {"ok": False, "native": False, "error": session.native_error or "Native mouse input is not available."}
result = native_document.post_mouse_event(
str(mouse.get("type") or "down"),
int(mouse.get("x") or 0),
int(mouse.get("y") or 0),
count=int(mouse.get("count") or 1),
buttons=int(mouse.get("buttons") or 1),
modifier=int(mouse.get("modifier") or 0),
)
session.native_metadata = native_document.metadata()
session.updated_at = time.time()
return {**result, "metadata": session.native_metadata, "tiles": self.tiles(session_id)}
def cursor(self, session_id: str, cursor: dict[str, Any]) -> dict[str, Any]:
session = self._require(session_id)
session.cursor = dict(cursor or {})
session.updated_at = time.time()
return {"ok": True, "session_id": session_id, "cursor": session.cursor}
def selection(self, session_id: str, selection: dict[str, Any]) -> dict[str, Any]:
session = self._require(session_id)
session.selection = dict(selection or {})
session.updated_at = time.time()
return {"ok": True, "session_id": session_id, "selection": session.selection}
def tiles(self, session_id: str) -> list[dict[str, Any]]:
session = self._require(session_id)
if session.extension == "docx" and session.native_document:
try:
return session.native_document.render_tiles()
except Exception as exc:
session.native_error = str(exc)
if session.extension == "docx":
return _docx_text_tiles(session.text)
if session.extension == "md":
return _markdown_text_tiles(session.text)
doc = document_store.get_document(session.file_id)
preview = document_store.build_preview(doc)
return [{"index": 0, "kind": preview.get("kind") or "file", "preview": preview}]
def save(self, session_id: str, text: str | None = None) -> dict[str, Any]:
session = self._require(session_id)
if text is not None:
session.text = str(text)
doc = document_store.get_document(session.file_id)
if session.extension == "md":
updated = document_store.write_markdown(session.file_id, session.text)
session.updated_at = time.time()
return {"ok": True, "document": _public_doc(updated), "tiles": self.tiles(session_id), "native": self._native_payload(session)}
if session.extension == "docx":
from plugins._office.helpers import artifact_editor
if session.native_document and text is None:
updated = document_store.replace_document_bytes(
session.file_id,
session.native_document.save_to_bytes(".docx", "docx"),
actor="libreofficekit:save",
invalidate_sessions=False,
)
else:
updated, _payload = artifact_editor.edit_artifact(
doc,
operation="set_text",
content=session.text,
invalidate_sessions=False,
)
validation = libreoffice.validate_docx(updated["path"])
if not validation.get("ok"):
return {"ok": False, "error": validation.get("error") or "DOCX save verification failed."}
self._reopen_native_document(session, updated["path"])
session.updated_at = time.time()
return {
"ok": True,
"document": _public_doc(updated),
"tiles": self.tiles(session_id),
"validation": validation,
"native": self._native_payload(session),
}
return {"ok": False, "error": f"Canvas editing is not available for .{session.extension}."}
def command(self, session_id: str, command: str, arguments: Any = None, notify: bool = True) -> dict[str, Any]:
session = self._require(session_id)
native_document = session.native_document
if not native_document:
return {
"ok": False,
"native": False,
"error": session.native_error or f"Native LibreOfficeKit commands are not available for .{session.extension}.",
}
result = native_document.post_uno_command(command, arguments=arguments, notify=notify)
session.native_metadata = native_document.metadata()
session.updated_at = time.time()
return {**result, "metadata": session.native_metadata, "tiles": self.tiles(session_id)}
def command_values(self, session_id: str, command: str) -> dict[str, Any]:
session = self._require(session_id)
native_document = session.native_document
if not native_document:
return {
"ok": False,
"native": False,
"error": session.native_error or f"Native LibreOfficeKit command values are not available for .{session.extension}.",
}
return native_document.command_values(command)
def refresh_document(self, file_id: str) -> dict[str, Any]:
normalized = str(file_id or "").strip()
if not normalized:
return {"ok": True, "refreshed": 0, "sessions": []}
try:
doc = document_store.get_document(normalized)
except Exception:
return {"ok": False, "refreshed": 0, "sessions": []}
refreshed: list[str] = []
for session in self._sessions.values():
if session.file_id != normalized:
continue
if session.extension in {"md", "docx"}:
session.text = document_store.read_text_for_editor(doc)
if session.extension == "docx":
self._reopen_native_document(session, doc["path"])
session.updated_at = time.time()
refreshed.append(session.session_id)
return {"ok": True, "refreshed": len(refreshed), "sessions": refreshed}
def close(self, session_id: str) -> dict[str, Any]:
session = self._sessions.pop(str(session_id or ""), None)
if not session:
return {"ok": True, "closed": 0}
self._close_native_document(session)
return {"ok": True, "closed": 1, "session_id": session_id}
def close_sid(self, sid: str) -> int:
doomed = [session_id for session_id, session in self._sessions.items() if session.sid == sid]
for session_id in doomed:
session = self._sessions.pop(session_id, None)
if session:
self._close_native_document(session)
return len(doomed)
def _payload(self, session: EditorSession, doc: dict[str, Any]) -> dict[str, Any]:
return {
"ok": True,
"session_id": session.session_id,
"file_id": session.file_id,
"title": session.title,
"extension": session.extension,
"path": session.path,
"text": session.text,
"tiles": self.tiles(session.session_id),
"document": _public_doc(doc),
"version": document_store.item_version(doc),
"libreoffice": libreoffice.collect_status(),
"native": self._native_payload(session),
}
def _require(self, session_id: str) -> EditorSession:
normalized = str(session_id or "").strip()
session = self._sessions.get(normalized)
if not session:
raise FileNotFoundError(f"Editor session not found: {normalized}")
return session
def _native_payload(self, session: EditorSession) -> dict[str, Any]:
if session.native_document:
return {"available": True, **session.native_metadata}
return {"available": False, "error": session.native_error}
def _reopen_native_document(self, session: EditorSession, path: str) -> None:
self._close_native_document(session)
try:
session.native_document = libreofficekit_native.open_document(path)
session.native_metadata = session.native_document.metadata()
session.native_error = ""
except Exception as exc:
session.native_document = None
session.native_metadata = {}
session.native_error = str(exc)
def _close_native_document(self, session: EditorSession) -> None:
native_document = session.native_document
if native_document:
try:
native_document.close()
except Exception:
pass
session.native_document = None
def get_manager() -> LibreOfficeKitSessionManager:
global _manager
try:
return _manager
except NameError:
_manager = LibreOfficeKitSessionManager()
return _manager
def _public_doc(doc: dict[str, Any]) -> dict[str, Any]:
return {
"file_id": doc["file_id"],
"path": document_store.display_path(doc["path"]),
"basename": doc["basename"],
"extension": doc["extension"],
"size": doc["size"],
"version": document_store.item_version(doc),
"last_modified": doc["last_modified"],
"exists": Path(doc["path"]).exists(),
}
def _apply_text_patch(text: str, patch: dict[str, Any]) -> str:
if "content" in patch:
return str(patch.get("content") or "")
start = int(patch.get("start") or 0)
end = int(patch.get("end") if patch.get("end") is not None else start)
replacement = str(patch.get("text") or "")
start = max(0, min(len(text), start))
end = max(start, min(len(text), end))
return text[:start] + replacement + text[end:]
def _markdown_text_tiles(text: str) -> list[dict[str, Any]]:
lines = [line for line in str(text or "").splitlines() if line.strip()]
return [{"index": 0, "kind": "markdown", "lines": lines[:36]}]
def _docx_text_tiles(text: str) -> list[dict[str, Any]]:
paragraphs = [line.strip() for line in str(text or "").splitlines() if line.strip()]
if not paragraphs:
paragraphs = [""]
pages = []
for index in range(0, len(paragraphs), 18):
pages.append({
"index": len(pages),
"kind": "docx",
"lines": paragraphs[index:index + 18],
})
return pages

View file

@ -1,214 +0,0 @@
from __future__ import annotations
import base64
import json
import os
import select
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Any
REQUEST_TIMEOUT_SECONDS = 18
def open_document(path: str | Path) -> "WorkerLokDocument":
return WorkerLokDocument(path)
class WorkerLokDocument:
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
self._counter = 0
self._lock = threading.RLock()
self._process = subprocess.Popen(
[sys.executable, "-m", "plugins._office.helpers.libreofficekit_worker", "--worker"],
cwd=str(Path(__file__).resolve().parents[3]),
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
env={**os.environ, "PYTHONUNBUFFERED": "1", "SAL_USE_VCLPLUGIN": os.environ.get("SAL_USE_VCLPLUGIN", "gen")},
)
opened = self._request("open", {"path": str(self.path)}, timeout=REQUEST_TIMEOUT_SECONDS)
if not opened.get("ok"):
raise RuntimeError(opened.get("error") or "LibreOfficeKit worker could not open document.")
self._metadata = opened.get("metadata") or {}
def metadata(self) -> dict[str, Any]:
response = self._request("metadata")
if response.get("metadata"):
self._metadata = response["metadata"]
return dict(self._metadata)
def render_tiles(self, pixel_width: int = 920, max_tiles: int = 12) -> list[dict[str, Any]]:
response = self._request("tiles", {"pixel_width": pixel_width, "max_tiles": max_tiles}, timeout=REQUEST_TIMEOUT_SECONDS)
return response.get("tiles") or []
def post_uno_command(self, command: str, arguments: dict[str, Any] | str | None = None, notify: bool = True) -> dict[str, Any]:
return self._request("command", {"command": command, "arguments": arguments, "notify": notify})
def command_values(self, command: str) -> dict[str, Any]:
return self._request("command_values", {"command": command})
def post_mouse_event(
self,
kind: str,
x: int,
y: int,
count: int = 1,
buttons: int = 1,
modifier: int = 0,
) -> dict[str, Any]:
return self._request("mouse", {
"type": kind,
"x": x,
"y": y,
"count": count,
"buttons": buttons,
"modifier": modifier,
})
def post_key_event(self, kind: str, char_code: int = 0, key_code: int = 0) -> dict[str, Any]:
return self._request("key", {
"type": kind,
"char_code": char_code,
"key_code": key_code,
})
def type_text(self, text: str) -> dict[str, Any]:
return self._request("text", {"text": text})
def save_to_bytes(self, suffix: str = ".docx", fmt: str | None = "docx") -> bytes:
response = self._request("save", {"suffix": suffix, "format": fmt}, timeout=REQUEST_TIMEOUT_SECONDS)
data = response.get("bytes") or ""
return base64.b64decode(data.encode("ascii"))
def close(self) -> None:
process = self._process
if process.poll() is not None:
return
try:
self._request("close", timeout=3)
process.wait(timeout=3)
except Exception:
process.kill()
process.wait(timeout=3)
def _request(self, action: str, payload: dict[str, Any] | None = None, timeout: float = REQUEST_TIMEOUT_SECONDS) -> dict[str, Any]:
with self._lock:
return self._request_unlocked(action, payload=payload, timeout=timeout)
def _request_unlocked(self, action: str, payload: dict[str, Any] | None = None, timeout: float = REQUEST_TIMEOUT_SECONDS) -> dict[str, Any]:
process = self._process
if process.poll() is not None:
stderr = process.stderr.read() if process.stderr else ""
raise RuntimeError(f"LibreOfficeKit worker exited with {process.returncode}: {stderr.strip()}")
self._counter += 1
message = {"id": self._counter, "action": action, **(payload or {})}
assert process.stdin is not None
process.stdin.write(json.dumps(message, separators=(",", ":")) + "\n")
process.stdin.flush()
assert process.stdout is not None
deadline = time.time() + timeout
while time.time() < deadline:
ready, _, _ = select.select([process.stdout], [], [], max(0.05, min(0.5, deadline - time.time())))
if not ready:
continue
line = process.stdout.readline()
if not line:
break
try:
response = json.loads(line)
except json.JSONDecodeError:
continue
if response.get("id") == self._counter:
if response.get("ok") is False:
raise RuntimeError(response.get("error") or f"LibreOfficeKit worker {action} failed.")
return response
process.kill()
raise TimeoutError(f"LibreOfficeKit worker timed out during {action}.")
def _worker_loop() -> None:
from plugins._office.helpers import libreofficekit_native
document = None
for line in sys.stdin:
try:
request = json.loads(line)
action = request.get("action")
if action == "open":
document = libreofficekit_native.open_document_in_process(request["path"])
_respond(request, {"ok": True, "metadata": document.metadata()})
elif not document:
_respond(request, {"ok": False, "error": "Document is not open."})
elif action == "metadata":
_respond(request, {"ok": True, "metadata": document.metadata()})
elif action == "tiles":
_respond(request, {
"ok": True,
"tiles": document.render_tiles(
pixel_width=int(request.get("pixel_width") or 920),
max_tiles=int(request.get("max_tiles") or 12),
),
})
elif action == "command":
result = document.post_uno_command(
str(request.get("command") or ""),
arguments=request.get("arguments"),
notify=bool(request.get("notify", True)),
)
_respond(request, {"ok": True, **result, "metadata": document.metadata()})
elif action == "command_values":
_respond(request, document.command_values(str(request.get("command") or "")))
elif action == "mouse":
result = document.post_mouse_event(
str(request.get("type") or "down"),
int(request.get("x") or 0),
int(request.get("y") or 0),
count=int(request.get("count") or 1),
buttons=int(request.get("buttons") or 1),
modifier=int(request.get("modifier") or 0),
)
_respond(request, {"ok": True, **result, "metadata": document.metadata(), "tiles": document.render_tiles()})
elif action == "key":
result = document.post_key_event(
str(request.get("type") or "down"),
char_code=int(request.get("char_code") or 0),
key_code=int(request.get("key_code") or 0),
)
_respond(request, {"ok": True, **result, "metadata": document.metadata(), "tiles": document.render_tiles()})
elif action == "text":
result = document.type_text(str(request.get("text") or ""))
_respond(request, {"ok": True, **result, "metadata": document.metadata(), "tiles": document.render_tiles()})
elif action == "save":
data = document.save_to_bytes(
suffix=str(request.get("suffix") or ".docx"),
fmt=request.get("format") or "docx",
)
_respond(request, {"ok": True, "bytes": base64.b64encode(data).decode("ascii"), "metadata": document.metadata()})
elif action == "close":
if document:
document.close()
_respond(request, {"ok": True, "closed": True})
sys.stdout.flush()
os._exit(0)
else:
_respond(request, {"ok": False, "error": f"Unknown worker action: {action}"})
except Exception as exc:
_respond(json.loads(line) if line.strip().startswith("{") else {}, {"ok": False, "error": str(exc)})
os._exit(0)
def _respond(request: dict[str, Any], response: dict[str, Any]) -> None:
sys.stdout.write(json.dumps({"id": request.get("id"), **response}, separators=(",", ":")) + "\n")
sys.stdout.flush()
if __name__ == "__main__" and "--worker" in sys.argv:
_worker_loop()

View file

@ -0,0 +1,157 @@
from __future__ import annotations
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from plugins._office.helpers import document_store
@dataclass
class MarkdownSession:
session_id: str
file_id: str
sid: str
extension: str
path: str
title: str
text: str = ""
opened_at: float = field(default_factory=time.time)
updated_at: float = field(default_factory=time.time)
class MarkdownSessionManager:
"""Owns source-editor sessions for Markdown documents."""
def __init__(self) -> None:
self._sessions: dict[str, MarkdownSession] = {}
def open(self, doc: dict[str, Any], sid: str = "") -> dict[str, Any]:
ext = str(doc["extension"]).lower()
if ext != "md":
raise ValueError(f"Canvas editing is only available for Markdown. Open .{ext} files in the Desktop.")
session = MarkdownSession(
session_id=uuid.uuid4().hex,
file_id=doc["file_id"],
sid=sid,
extension=ext,
path=doc["path"],
title=doc["basename"],
text=document_store.read_text_for_editor(doc),
)
self._sessions[session.session_id] = session
return self._payload(session, doc)
def input(self, session_id: str, text: str | None = None, patch: dict[str, Any] | None = None) -> dict[str, Any]:
session = self._require(session_id)
if text is not None:
session.text = str(text)
elif patch:
session.text = _apply_text_patch(session.text, patch)
session.updated_at = time.time()
return {"ok": True, "session_id": session.session_id}
def save(self, session_id: str, text: str | None = None) -> dict[str, Any]:
session = self._require(session_id)
if text is not None:
session.text = str(text)
updated = document_store.write_markdown(session.file_id, session.text)
session.updated_at = time.time()
session.path = updated["path"]
session.title = updated["basename"]
return {
"ok": True,
"document": _public_doc(updated),
"version": document_store.item_version(updated),
}
def refresh_document(self, file_id: str) -> dict[str, Any]:
normalized = str(file_id or "").strip()
if not normalized:
return {"ok": True, "refreshed": 0, "sessions": []}
try:
doc = document_store.get_document(normalized)
except Exception:
return {"ok": False, "refreshed": 0, "sessions": []}
if str(doc.get("extension") or "").lower() != "md":
return {"ok": True, "refreshed": 0, "sessions": []}
refreshed: list[str] = []
for session in self._sessions.values():
if session.file_id != normalized:
continue
session.text = document_store.read_text_for_editor(doc)
session.path = doc["path"]
session.title = doc["basename"]
session.updated_at = time.time()
refreshed.append(session.session_id)
return {"ok": True, "refreshed": len(refreshed), "sessions": refreshed}
def close(self, session_id: str) -> dict[str, Any]:
session = self._sessions.pop(str(session_id or ""), None)
if not session:
return {"ok": True, "closed": 0}
return {"ok": True, "closed": 1, "session_id": session_id}
def close_sid(self, sid: str) -> int:
doomed = [session_id for session_id, session in self._sessions.items() if session.sid == sid]
for session_id in doomed:
self._sessions.pop(session_id, None)
return len(doomed)
def _payload(self, session: MarkdownSession, doc: dict[str, Any]) -> dict[str, Any]:
return {
"ok": True,
"session_id": session.session_id,
"file_id": session.file_id,
"title": session.title,
"extension": session.extension,
"path": session.path,
"text": session.text,
"document": _public_doc(doc),
"version": document_store.item_version(doc),
}
def _require(self, session_id: str) -> MarkdownSession:
normalized = str(session_id or "").strip()
session = self._sessions.get(normalized)
if not session:
raise FileNotFoundError(f"Editor session not found: {normalized}")
return session
def get_manager() -> MarkdownSessionManager:
global _manager
try:
return _manager
except NameError:
_manager = MarkdownSessionManager()
return _manager
def _public_doc(doc: dict[str, Any]) -> dict[str, Any]:
return {
"file_id": doc["file_id"],
"path": document_store.display_path(doc["path"]),
"basename": doc["basename"],
"extension": doc["extension"],
"size": doc["size"],
"version": document_store.item_version(doc),
"last_modified": doc["last_modified"],
"exists": Path(doc["path"]).exists(),
}
def _apply_text_patch(text: str, patch: dict[str, Any]) -> str:
if "content" in patch:
return str(patch.get("content") or "")
start = int(patch.get("start") or 0)
end = int(patch.get("end") if patch.get("end") is not None else start)
replacement = str(patch.get("text") or "")
start = max(0, min(len(text), start))
end = max(start, min(len(text), end))
return text[:start] + replacement + text[end:]

View file

@ -43,10 +43,6 @@ RUNTIME_PACKAGES = (
"libreoffice-calc",
"libreoffice-impress",
"libreoffice-gtk3",
"libreofficekit-data",
"libreofficekit-dev",
"gir1.2-lokdocview-0.1",
"python3-gi",
"python3-uno",
"xpra",
"xpra-x11",

View file

@ -9,99 +9,71 @@
<template x-if="$store.office">
<div class="office-shell">
<div class="office-toolbar">
<div class="office-tool-group">
<button type="button" class="office-icon-button office-command-button" aria-label="Open" @click="$store.office.openPrompt()">
<span class="material-symbols-outlined">folder_open</span>
<span class="office-button-label">Open</span>
</button>
<div class="office-toolbar-row is-primary">
<div class="office-tool-group">
<button type="button" class="office-icon-button" title="Open" aria-label="Open" @click="$store.office.openFileBrowser()">
<span class="material-symbols-outlined">folder_open</span>
</button>
</div>
<div class="office-tool-group">
<button type="button" class="office-icon-button office-command-button" aria-label="New Markdown" @click="$store.office.create('document', 'md')">
<span class="material-symbols-outlined">article</span>
<span class="office-button-label">Markdown</span>
</button>
<button type="button" class="office-icon-button office-command-button" aria-label="New DOCX" @click="$store.office.create('document', 'docx')">
<span class="material-symbols-outlined">description</span>
<span class="office-button-label">DOCX</span>
</button>
<button type="button" class="office-icon-button office-command-button" aria-label="New spreadsheet" @click="$store.office.create('spreadsheet', 'xlsx')">
<span class="material-symbols-outlined">table_chart</span>
<span class="office-button-label">Spreadsheet</span>
</button>
<button type="button" class="office-icon-button office-command-button" aria-label="New presentation" @click="$store.office.create('presentation', 'pptx')">
<span class="material-symbols-outlined">co_present</span>
<span class="office-button-label">Presentation</span>
</button>
</div>
<span class="office-toolbar-spacer"></span>
<div class="office-tool-group office-tool-actions" x-show="$store.office.session && !$store.office.isDesktopSession()" style="display: none;">
<button type="button" class="office-icon-button" title="Save" aria-label="Save" :class="{ 'is-primary': $store.office.dirty }" :disabled="$store.office.saving" @click="$store.office.save()">
<span class="material-symbols-outlined" :class="{ spinning: $store.office.saving }" x-text="$store.office.saving ? 'progress_activity' : 'save'"></span>
</button>
</div>
</div>
<div class="office-tool-group">
<button type="button" class="office-icon-button office-command-button" aria-label="New Markdown" @click="$store.office.create('document', 'md')">
<span class="material-symbols-outlined">article</span>
<span class="office-button-label">Markdown</span>
</button>
<button type="button" class="office-icon-button office-command-button" aria-label="New DOCX" @click="$store.office.create('document', 'docx')">
<span class="material-symbols-outlined">description</span>
<span class="office-button-label">DOCX</span>
</button>
<button type="button" class="office-icon-button office-command-button" aria-label="New spreadsheet" @click="$store.office.create('spreadsheet', 'xlsx')">
<span class="material-symbols-outlined">table_chart</span>
<span class="office-button-label">Spreadsheet</span>
</button>
<button type="button" class="office-icon-button office-command-button" aria-label="New presentation" @click="$store.office.create('presentation', 'pptx')">
<span class="material-symbols-outlined">co_present</span>
<span class="office-button-label">Presentation</span>
</button>
</div>
<div class="office-toolbar-row is-editor" x-show="$store.office.session && $store.office.isMarkdown()" style="display: none;">
<div class="office-tool-group">
<button type="button" class="office-icon-button" title="Undo" aria-label="Undo" :disabled="!$store.office.canUndo()" @click="$store.office.undo()">
<span class="material-symbols-outlined">undo</span>
</button>
<button type="button" class="office-icon-button" title="Redo" aria-label="Redo" :disabled="!$store.office.canRedo()" @click="$store.office.redo()">
<span class="material-symbols-outlined">redo</span>
</button>
</div>
<div class="office-tool-group" x-show="$store.office.session && !$store.office.hasOfficialOffice() && !$store.office.isPreviewOnly()" style="display: none;">
<button type="button" class="office-icon-button" title="Undo" aria-label="Undo" :disabled="!$store.office.canUndo()" @click="$store.office.undo()">
<span class="material-symbols-outlined">undo</span>
</button>
<button type="button" class="office-icon-button" title="Redo" aria-label="Redo" :disabled="!$store.office.canRedo()" @click="$store.office.redo()">
<span class="material-symbols-outlined">redo</span>
</button>
</div>
<div class="office-tool-group">
<button type="button" class="office-icon-button" title="Bold" aria-label="Bold" @click="$store.office.format('bold')">
<span class="material-symbols-outlined">format_bold</span>
</button>
<button type="button" class="office-icon-button" title="Italic" aria-label="Italic" @click="$store.office.format('italic')">
<span class="material-symbols-outlined">format_italic</span>
</button>
</div>
<div class="office-tool-group" x-show="$store.office.session && !$store.office.hasOfficialOffice() && !$store.office.isPreviewOnly()" style="display: none;">
<button type="button" class="office-icon-button" title="Bold" aria-label="Bold" @click="$store.office.format('bold')">
<span class="material-symbols-outlined">format_bold</span>
</button>
<button type="button" class="office-icon-button" title="Italic" aria-label="Italic" @click="$store.office.format('italic')">
<span class="material-symbols-outlined">format_italic</span>
</button>
<button type="button" class="office-icon-button" title="Underline" aria-label="Underline" @click="$store.office.format('underline')">
<span class="material-symbols-outlined">format_underlined</span>
</button>
<button type="button" class="office-icon-button" title="List" aria-label="List" @click="$store.office.format('list')">
<span class="material-symbols-outlined">format_list_bulleted</span>
</button>
<button type="button" class="office-icon-button" title="Numbered list" aria-label="Numbered list" @click="$store.office.format('numbered')">
<span class="material-symbols-outlined">format_list_numbered</span>
</button>
<button type="button" class="office-icon-button" title="Table" aria-label="Table" @click="$store.office.format('table')">
<span class="material-symbols-outlined">table</span>
</button>
<button type="button" class="office-icon-button" title="Align left" aria-label="Align left" @click="$store.office.format('alignLeft')">
<span class="material-symbols-outlined">format_align_left</span>
</button>
<button type="button" class="office-icon-button" title="Align center" aria-label="Align center" @click="$store.office.format('alignCenter')">
<span class="material-symbols-outlined">format_align_center</span>
</button>
<button type="button" class="office-icon-button" title="Align right" aria-label="Align right" @click="$store.office.format('alignRight')">
<span class="material-symbols-outlined">format_align_right</span>
</button>
<button type="button" class="office-icon-button" title="Source" aria-label="Source" :class="{ 'is-active': $store.office.sourceMode }" x-show="$store.office.isMarkdown()" @click="$store.office.toggleSource()">
<span class="material-symbols-outlined">code</span>
</button>
</div>
<div class="office-tool-group" x-show="$store.office.session && !$store.office.hasOfficialOffice()" style="display: none;">
<button type="button" class="office-icon-button" title="Zoom out" aria-label="Zoom out" @click="$store.office.zoomOut()">
<span class="material-symbols-outlined">zoom_out</span>
</button>
<span class="office-zoom" x-text="$store.office.zoomLabel()"></span>
<button type="button" class="office-icon-button" title="Zoom in" aria-label="Zoom in" @click="$store.office.zoomIn()">
<span class="material-symbols-outlined">zoom_in</span>
</button>
</div>
<span class="office-toolbar-spacer"></span>
<div class="office-tool-group" x-show="$store.office.session && !$store.office.isDesktopSession()" style="display: none;">
<button type="button" class="office-icon-button office-command-button" aria-label="Export PDF" :disabled="$store.office.loading" @click="$store.office.exportPdf()">
<span class="material-symbols-outlined">picture_as_pdf</span>
<span class="office-button-label">Export PDF</span>
</button>
<button type="button" class="office-icon-button office-command-button" aria-label="Save" :class="{ 'is-primary': $store.office.dirty }" :disabled="$store.office.saving" @click="$store.office.save()">
<span class="material-symbols-outlined" :class="{ spinning: $store.office.saving }" x-text="$store.office.saving ? 'progress_activity' : 'save'"></span>
<span class="office-button-label">Save</span>
</button>
<button type="button" class="office-icon-button office-command-button" aria-label="Close" @click="$confirmClick($event, () => $store.office.closeFile())">
<span class="material-symbols-outlined">close</span>
<span class="office-button-label">Close</span>
</button>
<div class="office-tool-group">
<button type="button" class="office-icon-button" title="List" aria-label="List" @click="$store.office.format('list')">
<span class="material-symbols-outlined">format_list_bulleted</span>
</button>
<button type="button" class="office-icon-button" title="Numbered list" aria-label="Numbered list" @click="$store.office.format('numbered')">
<span class="material-symbols-outlined">format_list_numbered</span>
</button>
<button type="button" class="office-icon-button" title="Table" aria-label="Table" @click="$store.office.format('table')">
<span class="material-symbols-outlined">table</span>
</button>
</div>
</div>
</div>
@ -124,100 +96,9 @@
<span x-text="$store.office.error || $store.office.message || 'Working'"></span>
</div>
<div class="office-body">
<div class="office-start" x-show="!$store.office.session" style="display: none;">
<section class="office-dashboard-section" aria-label="Create">
<div class="office-dashboard-heading">New</div>
<div class="office-template-grid">
<button type="button" class="office-create-tile is-markdown" @click="$store.office.create('document', 'md')">
<span class="material-symbols-outlined">article</span>
<strong>Markdown</strong>
<small>.md</small>
</button>
<button type="button" class="office-create-tile is-docx" @click="$store.office.create('document', 'docx')">
<span class="material-symbols-outlined">description</span>
<strong>DOCX</strong>
<small>.docx</small>
</button>
<button type="button" class="office-create-tile is-sheet" @click="$store.office.create('spreadsheet', 'xlsx')">
<span class="material-symbols-outlined">table_chart</span>
<strong>Sheet</strong>
<small>.xlsx</small>
</button>
<button type="button" class="office-create-tile is-deck" @click="$store.office.create('presentation', 'pptx')">
<span class="material-symbols-outlined">co_present</span>
<strong>Deck</strong>
<small>.pptx</small>
</button>
</div>
</section>
<section class="office-dashboard-section" x-show="$store.office.openCards().length" aria-label="Open" style="display: none;">
<div class="office-dashboard-heading">Open</div>
<div class="office-card-grid">
<template x-for="doc in $store.office.openCards()" :key="doc.tab_id || doc.file_id || doc.path">
<button type="button" class="office-document-card is-open" :title="doc.path" @click="$store.office.selectTab(doc.tab_id)">
<span class="office-card-badge">Open</span>
<div class="office-card-preview" :class="`is-${$store.office.previewKind(doc)}`">
<template x-if="$store.office.previewKind(doc) === 'spreadsheet' && $store.office.hasPreview(doc)">
<div class="office-sheet-preview">
<template x-for="(row, rowIndex) in $store.office.previewRows(doc)" :key="rowIndex">
<div class="office-sheet-row">
<template x-for="(cell, cellIndex) in row" :key="cellIndex"><span x-text="cell"></span></template>
</div>
</template>
</div>
</template>
<template x-if="$store.office.previewKind(doc) !== 'spreadsheet' && $store.office.hasPreview(doc)">
<div class="office-page-preview">
<template x-for="(line, index) in $store.office.previewLines(doc)" :key="index"><span x-text="line"></span></template>
</div>
</template>
<template x-if="!$store.office.hasPreview(doc)">
<div class="office-preview-fallback"><span class="material-symbols-outlined" x-text="$store.office.tabIcon(doc)"></span></div>
</template>
</div>
<span class="office-card-title" x-text="$store.office.dashboardTitle(doc)"></span>
<small x-text="$store.office.dashboardMeta(doc)"></small>
</button>
</template>
</div>
</section>
<section class="office-dashboard-section" x-show="$store.office.recentCards().length" aria-label="Recent" style="display: none;">
<div class="office-dashboard-heading">Recent</div>
<div class="office-card-grid">
<template x-for="doc in $store.office.recentCards()" :key="doc.file_id || doc.path">
<button type="button" class="office-document-card" :title="doc.path" @click="$store.office.openPath(doc.path)">
<div class="office-card-preview" :class="`is-${$store.office.previewKind(doc)}`">
<template x-if="$store.office.previewKind(doc) === 'spreadsheet' && $store.office.hasPreview(doc)">
<div class="office-sheet-preview">
<template x-for="(row, rowIndex) in $store.office.previewRows(doc)" :key="rowIndex">
<div class="office-sheet-row">
<template x-for="(cell, cellIndex) in row" :key="cellIndex"><span x-text="cell"></span></template>
</div>
</template>
</div>
</template>
<template x-if="$store.office.previewKind(doc) !== 'spreadsheet' && $store.office.hasPreview(doc)">
<div class="office-page-preview">
<template x-for="(line, index) in $store.office.previewLines(doc)" :key="index"><span x-text="line"></span></template>
</div>
</template>
<template x-if="!$store.office.hasPreview(doc)">
<div class="office-preview-fallback"><span class="material-symbols-outlined" x-text="$store.office.tabIcon(doc)"></span></div>
</template>
</div>
<span class="office-card-title" x-text="$store.office.dashboardTitle(doc)"></span>
<small x-text="$store.office.dashboardMeta(doc)"></small>
</button>
</template>
</div>
</section>
</div>
<div class="office-editor-wrap" x-show="$store.office.session" style="display: none;">
<div class="office-editor-scroll" :class="{ 'is-desktop': $store.office.hasOfficialOffice() }" :style="`--office-zoom: ${$store.office.zoom}`" @click.self="$store.office.focusEditor()">
<div class="office-body" :class="{ 'is-source': $store.office.isMarkdown() }">
<div class="office-editor-wrap" x-show="$store.office.session" style="display: none;">
<div class="office-editor-scroll" :class="{ 'is-desktop': $store.office.hasOfficialOffice(), 'is-source': $store.office.isMarkdown() }" @click.self="$store.office.focusEditor()">
<template x-if="$store.office.hasOfficialOffice()">
<div class="office-desktop-wrap">
<iframe
@ -236,7 +117,7 @@
class="office-source-editor"
data-office-source
aria-label="Markdown source"
x-show="$store.office.isMarkdown() && $store.office.sourceMode"
x-show="$store.office.isMarkdown()"
x-model="$store.office.editorText"
@input="$store.office.onSourceInput()"
@blur="$store.office.flushInput()"
@ -244,64 +125,6 @@
style="display: none;"
></textarea>
<article
class="office-rich-editor"
x-show="$store.office.isMarkdown() && !$store.office.sourceMode"
x-init="$store.office.bindEditorElement($el, 'markdown')"
contenteditable="true"
tabindex="0"
role="textbox"
aria-label="Markdown document"
spellcheck="true"
@input="$store.office.onRichInput($el)"
@blur="$store.office.flushInput()"
style="display: none;"
></article>
<div
class="office-docx-stage"
x-show="$store.office.isDocx() && !$store.office.hasOfficialOffice()"
style="display: none;"
>
<div
class="office-docx-pages"
:class="{ 'is-native': $store.office.hasNativeDocxTiles() }"
x-init="$store.office.bindEditorElement($el, 'docx')"
:contenteditable="$store.office.hasNativeDocxTiles() ? 'false' : 'true'"
tabindex="0"
role="textbox"
aria-label="DOCX document"
spellcheck="true"
@click="$store.office.onNativeDocxClick($event)"
@keydown="$store.office.onNativeDocxKeydown($event)"
@input="$store.office.onDocxInput($el)"
@blur="$store.office.flushInput()"
></div>
</div>
<div class="office-preview-editor" x-show="$store.office.isPreviewOnly() && !$store.office.hasOfficialOffice()" style="display: none;">
<div class="office-card-preview is-large" :class="`is-${$store.office.previewKind($store.office.session || {})}`">
<template x-if="$store.office.previewKind($store.office.session || {}) === 'spreadsheet' && $store.office.hasPreview($store.office.session || {})">
<div class="office-sheet-preview">
<template x-for="(row, rowIndex) in $store.office.previewRows($store.office.session || {})" :key="rowIndex">
<div class="office-sheet-row">
<template x-for="(cell, cellIndex) in row" :key="cellIndex"><span x-text="cell"></span></template>
</div>
</template>
</div>
</template>
<template x-if="$store.office.previewKind($store.office.session || {}) === 'presentation'">
<div class="office-slide-preview">
<template x-for="(slide, index) in $store.office.previewSlides($store.office.session || {})" :key="index">
<div class="office-slide-line">
<strong x-text="slide.title"></strong>
<span x-text="(slide.lines || []).join(' / ')"></span>
</div>
</template>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
@ -429,36 +252,47 @@
.office-toolbar {
display: flex;
align-items: center;
flex-direction: column;
align-items: stretch;
flex-wrap: nowrap;
gap: 10px;
min-height: 58px;
padding: 9px 12px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
gap: 8px;
min-height: 0;
padding: 8px 10px;
overflow: visible;
border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 20%);
background: color-mix(in srgb, var(--color-background), var(--color-panel) 48%);
}
.office-toolbar-row {
display: flex;
align-items: center;
flex: 0 0 auto;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
}
.office-toolbar-row.is-editor {
gap: 6px 8px;
padding-top: 1px;
}
.office-tool-group {
display: flex;
align-items: center;
flex-wrap: wrap;
flex: 0 0 auto;
gap: 6px;
min-width: 0;
}
.office-toolbar-spacer {
flex: 1 1 auto;
min-width: 8px;
.office-tool-actions {
justify-content: flex-end;
}
.office-toolbar-divider {
width: 1px;
height: 24px;
margin: 0 4px;
background: color-mix(in srgb, var(--color-border), transparent 15%);
.office-toolbar-spacer {
flex: 1 1 80px;
min-width: 0;
}
.office-icon-button,
@ -523,20 +357,11 @@
}
.office-icon-button .material-symbols-outlined,
.office-tab-icon,
.office-create-tile .material-symbols-outlined {
.office-tab-icon {
font-size: 21px;
line-height: 1;
}
.office-zoom {
min-width: 44px;
text-align: center;
font-size: 12px;
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
}
.office-tabs {
display: flex;
gap: 6px;
@ -636,170 +461,10 @@
linear-gradient(90deg, rgba(44, 123, 229, 0.05), transparent 38%),
linear-gradient(180deg, rgba(44, 165, 141, 0.04), transparent 46%),
#eef2f7;
color: #172033;
}
.office-start {
flex: 1 1 auto;
min-width: 0;
overflow: auto;
padding: 22px;
}
.office-dashboard-section {
margin: 0 0 22px;
}
.office-dashboard-heading {
margin: 0 0 9px;
color: #536274;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.office-template-grid,
.office-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
gap: 10px;
}
.office-create-tile {
display: grid;
grid-template-rows: 34px auto auto;
align-items: center;
min-height: 122px;
padding: 14px;
border: 1px solid #d9dee7;
border-radius: 8px;
background: #ffffff;
color: #172033;
box-shadow: 0 12px 30px rgba(35, 48, 68, 0.08);
text-align: left;
transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
}
.office-create-tile:hover,
.office-document-card:hover {
border-color: #8db5ef;
box-shadow: 0 18px 42px rgba(35, 48, 68, 0.13);
transform: translateY(-1px);
}
.office-create-tile strong,
.office-card-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
.office-create-tile small,
.office-document-card small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #536274;
font-size: 11px;
}
.office-create-tile.is-markdown .material-symbols-outlined { color: #2ca58d; }
.office-create-tile.is-docx .material-symbols-outlined { color: #2c7be5; }
.office-create-tile.is-sheet .material-symbols-outlined { color: #8f6f19; }
.office-create-tile.is-deck .material-symbols-outlined { color: #b84a62; }
.office-document-card {
position: relative;
display: grid;
grid-template-rows: 118px 18px 16px;
gap: 7px;
min-height: 172px;
padding: 10px;
border: 1px solid #d9dee7;
border-radius: 8px;
background: #ffffff;
color: #172033;
box-shadow: 0 12px 30px rgba(35, 48, 68, 0.08);
text-align: left;
transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
}
.office-card-badge {
position: absolute;
top: 9px;
right: 9px;
z-index: 1;
border-radius: 999px;
padding: 2px 7px;
background: color-mix(in srgb, #2ca58d, var(--color-panel) 20%);
color: white;
font-size: 10px;
font-weight: 700;
}
.office-card-preview {
min-width: 0;
min-height: 0;
overflow: hidden;
border: 1px solid #d9dee7;
border-radius: 6px;
background: #f8fafc;
color: #172033;
}
.office-card-preview.is-large {
width: min(720px, 100%);
min-height: 340px;
border-color: #d2d8e3;
background: #ffffff;
box-shadow: 0 18px 44px rgba(35, 48, 68, 0.12);
}
.office-page-preview,
.office-sheet-preview,
.office-slide-preview,
.office-preview-fallback {
display: flex;
flex-direction: column;
gap: 5px;
height: 100%;
padding: 10px;
font-size: 11px;
line-height: 1.35;
}
.office-page-preview span,
.office-sheet-row span,
.office-slide-line span,
.office-slide-line strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.office-sheet-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 5px;
}
.office-sheet-row span {
border-bottom: 1px solid #d9dee7;
padding-bottom: 2px;
}
.office-preview-fallback {
align-items: center;
justify-content: center;
color: #64748b;
}
.office-preview-fallback .material-symbols-outlined {
font-size: 36px;
.office-body.is-source {
background: transparent;
}
.office-editor-wrap {
@ -817,6 +482,13 @@
padding: 30px 24px;
}
.office-editor-scroll.is-source {
display: flex;
overflow: hidden;
padding: var(--spacing-md);
background: transparent;
}
.office-editor-scroll.is-desktop {
display: flex;
overflow: hidden;
@ -845,136 +517,28 @@
background: #20242a;
}
.office-rich-editor,
.office-source-editor,
.office-docx-stage,
.office-preview-editor {
transform: scale(var(--office-zoom));
transform-origin: top center;
margin: 0 auto;
}
.office-rich-editor {
box-sizing: border-box;
width: min(760px, 100%);
min-height: min(980px, calc(100vh - 170px));
padding: 54px 58px;
border: 1px solid #d2d8e3;
border-radius: 8px;
outline: none;
background: #ffffff;
box-shadow: 0 18px 44px rgba(35, 48, 68, 0.16);
color: #1f2937;
font-size: 15px;
line-height: 1.7;
}
.office-rich-editor:focus,
.office-source-editor:focus,
.office-docx-pages:focus-within .office-docx-page:first-child,
.office-docx-pages:focus .office-docx-page:first-child {
border-color: #8db5ef;
box-shadow:
0 18px 44px rgba(35, 48, 68, 0.16),
0 0 0 3px rgba(44, 123, 229, 0.16);
}
.office-rich-editor h1,
.office-rich-editor h2,
.office-rich-editor h3 {
line-height: 1.25;
margin: 0 0 0.65em;
}
.office-rich-editor p,
.office-rich-editor ul,
.office-rich-editor table {
margin: 0 0 1em;
}
.office-rich-editor table {
width: 100%;
border-collapse: collapse;
}
.office-rich-editor td,
.office-rich-editor th {
border: 1px solid #d9dee7;
padding: 6px 8px;
}
.office-source-editor {
box-sizing: border-box;
width: min(920px, 100%);
min-height: min(980px, calc(100vh - 170px));
padding: 22px;
border: 1px solid #d2d8e3;
border-radius: 8px;
flex: 1 1 auto;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
margin: 0;
padding: 0;
border: 0;
outline: none;
background: #ffffff;
color: #172033;
background: transparent;
box-shadow: none;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
line-height: 1.65;
resize: none;
}
.office-docx-stage {
width: min(860px, 100%);
outline: none;
}
.office-docx-pages {
outline: none;
}
.office-docx-pages.is-native {
display: grid;
gap: 20px;
justify-items: center;
}
.office-docx-page {
box-sizing: border-box;
width: min(760px, 100%);
min-height: 980px;
margin: 0 auto 20px;
padding: 70px 74px;
border: 1px solid #d2d8e3;
border-radius: 6px;
background: #ffffff;
box-shadow: 0 18px 44px rgba(35, 48, 68, 0.16);
color: #1f2937;
font-family: "Liberation Serif", "Times New Roman", serif;
font-size: 16px;
line-height: 1.55;
}
.office-docx-page.is-native-tile {
width: auto;
min-height: 0;
padding: 0;
overflow: hidden;
line-height: 0;
}
.office-docx-page.is-native-tile img {
display: block;
width: min(920px, 100%);
height: auto;
user-select: none;
}
.office-docx-page p {
margin: 0 0 0.85em;
}
.office-preview-editor {
display: grid;
place-items: start center;
width: min(860px, 100%);
min-height: 420px;
padding: 18px;
textarea:focus {
background: transparent;
filter: brightness(1) !important;
}
.office-panel .spinning {
@ -991,21 +555,15 @@
padding-inline: 8px;
}
.office-toolbar-row {
gap: 6px;
}
.office-command-button {
max-width: 132px;
padding-inline: 9px;
}
.office-template-grid,
.office-card-grid {
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
}
.office-rich-editor,
.office-docx-page {
min-height: 720px;
padding: 34px 28px;
}
}
</style>
</body>

View file

@ -1,6 +1,7 @@
import { createStore } from "/js/AlpineStore.js";
import { callJsonApi } from "/js/api.js";
import { getNamespacedClient } from "/js/websocket.js";
import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
const officeSocket = getNamespacedClient("/ws");
officeSocket.addHandlers(["ws_webui"]);
@ -22,20 +23,6 @@ function currentContextId() {
}
}
function formatBytes(value) {
const size = Number(value || 0);
if (!Number.isFinite(size) || size <= 0) return "";
const units = ["B", "KB", "MB", "GB"];
let amount = size;
let index = 0;
while (amount >= 1024 && index < units.length - 1) {
amount /= 1024;
index += 1;
}
const digits = amount >= 10 || index === 0 ? 0 : 1;
return `${amount.toFixed(digits)} ${units[index]}`;
}
function basename(path = "") {
const value = String(path || "").split("?")[0].split("#")[0];
return value.split("/").filter(Boolean).pop() || "Untitled";
@ -51,163 +38,6 @@ function uniqueTabId(session = {}) {
return String(session.file_id || session.session_id || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`);
}
function escapeHtml(value = "") {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function inlineMarkdown(value = "") {
return escapeHtml(value)
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>")
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
}
function markdownToHtml(markdown = "") {
const normalized = String(markdown || "").replace(/\r\n?/g, "\n");
const lines = normalized.split("\n");
const html = [];
let paragraph = [];
let list = [];
const flushParagraph = () => {
if (!paragraph.length) return;
html.push(`<p>${inlineMarkdown(paragraph.join(" "))}</p>`);
paragraph = [];
};
const flushList = () => {
if (!list.length) return;
html.push(`<ul>${list.map((line) => `<li>${inlineMarkdown(line)}</li>`).join("")}</ul>`);
list = [];
};
for (let index = 0; index < lines.length; index += 1) {
const raw = lines[index];
const line = raw.trimEnd();
if (!line.trim()) {
flushParagraph();
flushList();
continue;
}
const heading = /^(#{1,4})\s+(.+)$/.exec(line);
if (heading) {
flushParagraph();
flushList();
const level = Math.min(4, heading[1].length);
html.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`);
continue;
}
const bullet = /^\s*[-*]\s+(.+)$/.exec(line);
if (bullet) {
flushParagraph();
list.push(bullet[1]);
continue;
}
flushList();
paragraph.push(line.trim());
}
flushParagraph();
flushList();
if (!html.length || /\n\s*$/.test(normalized)) {
html.push("<p><br></p>");
}
return html.join("") || "<p></p>";
}
function htmlToMarkdown(root) {
if (!root) return "";
const walk = (node) => {
if (node.nodeType === Node.TEXT_NODE) return node.textContent || "";
if (node.nodeType !== Node.ELEMENT_NODE) return "";
const tag = node.tagName.toLowerCase();
const childText = () => Array.from(node.childNodes).map(walk).join("");
if (tag === "br") return "\n";
if (tag === "strong" || tag === "b") return `**${childText().trim()}**`;
if (tag === "em" || tag === "i") return `*${childText().trim()}*`;
if (tag === "code") return `\`${childText().trim()}\``;
if (tag === "a") {
const href = node.getAttribute("href") || "";
const label = childText().trim() || href;
return href ? `[${label}](${href})` : label;
}
if (/^h[1-6]$/.test(tag)) return `\n${"#".repeat(Number(tag[1]))} ${childText().trim()}\n\n`;
if (tag === "li") return `- ${childText().trim()}\n`;
if (tag === "ul" || tag === "ol") return `\n${childText()}\n`;
if (tag === "tr") {
const cells = Array.from(node.children).map((cell) => cell.textContent?.trim() || "");
return `| ${cells.join(" | ")} |\n`;
}
if (tag === "table") return `\n${Array.from(node.querySelectorAll("tr")).map(walk).join("")}\n`;
if (tag === "p" || tag === "div" || tag === "section" || tag === "article") {
const text = childText().trim();
return text ? `${text}\n\n` : "";
}
return childText();
};
return Array.from(root.childNodes)
.map(walk)
.join("")
.replace(/\n{3,}/g, "\n\n")
.trimEnd();
}
function textToPageHtml(text = "") {
const paragraphs = String(text || "")
.replace(/\r\n?/g, "\n")
.split(/\n+/)
.map((line) => line.trim())
.filter(Boolean);
const lines = paragraphs.length ? paragraphs : [""];
const pages = [];
for (let index = 0; index < lines.length; index += 18) {
pages.push(lines.slice(index, index + 18));
}
return pages
.map((page, index) => (
`<section class="office-docx-page" data-page="${index + 1}">`
+ page.map((line) => `<p>${escapeHtml(line)}</p>`).join("")
+ "</section>"
))
.join("");
}
function nativeTilesToHtml(tiles = []) {
return tiles
.filter((tile) => tile?.image)
.map((tile) => {
const twips = encodeURIComponent(JSON.stringify(tile.twips || {}));
const width = Number(tile.width || 1);
const height = Number(tile.height || 1);
return (
`<section class="office-docx-page is-native-tile" data-tile-index="${Number(tile.index || 0)}" data-twips="${twips}">`
+ `<img src="${escapeHtml(tile.image)}" width="${width}" height="${height}" alt="" draggable="false">`
+ "</section>"
);
})
.join("");
}
function docxEditorText(element) {
if (!element) return "";
const pages = Array.from(element.querySelectorAll(".office-docx-page"));
if (!pages.length) return element.innerText || "";
return pages
.map((page) => Array.from(page.querySelectorAll("p"))
.map((p) => p.innerText.trim())
.filter(Boolean)
.join("\n"))
.filter(Boolean)
.join("\n\n");
}
function editorContainsFocus(element) {
const active = document.activeElement;
return Boolean(element && active && (element === active || element.contains(active)));
@ -263,9 +93,6 @@ function normalizeSession(payload = {}) {
title: payload.title || document.title || document.basename || basename(document.path),
tab_id: uniqueTabId(payload),
text: String(payload.text || ""),
tiles: Array.isArray(payload.tiles) ? payload.tiles : [],
preview: payload.preview || document.preview || {},
native: payload.native || {},
desktop: payload.desktop || null,
desktop_session_id: payload.desktop_session_id || payload.desktop?.session_id || "",
dirty: false,
@ -306,8 +133,6 @@ function isOfficeSocketData(data) {
|| Object.prototype.hasOwnProperty.call(data, "ok")
|| Object.prototype.hasOwnProperty.call(data, "session_id")
|| Object.prototype.hasOwnProperty.call(data, "document")
|| Object.prototype.hasOwnProperty.call(data, "tiles")
|| Object.prototype.hasOwnProperty.call(data, "native")
|| Object.prototype.hasOwnProperty.call(data, "desktop")
|| Object.prototype.hasOwnProperty.call(data, "closed")
);
@ -315,8 +140,6 @@ function isOfficeSocketData(data) {
const model = {
status: null,
recent: [],
openDocuments: [],
tabs: [],
activeTabId: "",
session: null,
@ -325,22 +148,16 @@ const model = {
dirty: false,
error: "",
message: "",
sourceMode: false,
editorText: "",
zoom: 1,
_root: null,
_mode: "canvas",
_saveMessageTimer: null,
_inputTimer: null,
_history: [],
_historyIndex: -1,
_rendering: false,
_pendingFocus: false,
_pendingFocusEnd: true,
_focusAttempts: 0,
_richEditor: null,
_docxEditor: null,
_nativeEventQueue: Promise.resolve(),
_floatingCleanup: null,
_desktopHeartbeatTimer: null,
_desktopHeartbeatSessionId: "",
@ -404,22 +221,10 @@ const model = {
if (this._mode === "modal") this._root = null;
},
bindEditorElement(element, type) {
if (type === "markdown") this._richEditor = element;
if (type === "docx") this._docxEditor = element;
this.queueRender();
},
async refresh() {
try {
const [status, recent, openDocuments] = await Promise.all([
callOffice("status"),
callOffice("recent"),
callOffice("open_documents"),
]);
const status = await callOffice("status");
this.status = status || {};
this.recent = (recent?.documents || []).map(normalizeDocument);
this.openDocuments = (openDocuments?.documents || []).map(normalizeDocument);
this.error = "";
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
@ -485,17 +290,20 @@ const model = {
});
},
async openPrompt() {
let defaultPath = "/a0/usr/workdir/";
async openFileBrowser() {
let workdirPath = "/a0/usr/workdir";
try {
const home = await callOffice("home");
defaultPath = home?.path || defaultPath;
const response = await callJsonApi("settings_get", null);
workdirPath = response?.settings?.workdir_path || workdirPath;
} catch {
// The prompt still works with the static fallback.
try {
const home = await callOffice("home");
workdirPath = home?.path || workdirPath;
} catch {
// The file browser can still open with the static fallback.
}
}
const path = globalThis.prompt?.("Path", defaultPath);
if (!path) return;
await this.openPath(path);
await fileBrowserStore.open(workdirPath);
},
async openPath(path) {
@ -560,7 +368,6 @@ const model = {
basename: "Desktop",
title: "Desktop",
extension: "desktop",
preview: {},
},
dirty: false,
};
@ -569,7 +376,6 @@ const model = {
const desktopTabId = desktopTab.tab_id;
this.session = { ...session, tab_id: session.tab_id || uniqueTabId(session) };
this.activeTabId = desktopTabId;
this.sourceMode = false;
this.editorText = "";
this.dirty = false;
this.resetHistory("");
@ -581,7 +387,6 @@ const model = {
const tab = this.tabs.find((item) => item.tab_id === tabId) || this.tabs[0] || null;
this.session = tab;
this.activeTabId = tab?.tab_id || "";
this.sourceMode = false;
this.editorText = String(tab?.text || "");
this.dirty = Boolean(tab?.dirty);
this.resetHistory(this.editorText);
@ -598,41 +403,6 @@ const model = {
return Boolean(tab && tab.tab_id === this.activeTabId);
},
async closeFile() {
if (!this.session) return;
if (this.isDesktopOfficeDocument(this.session) && !this.tabs.some((tab) => tab.tab_id === this.session.tab_id)) {
await this.closeDesktopDocumentSession(this.session);
return;
}
await this.closeTab(this.session.tab_id);
},
async closeDesktopDocumentSession(session) {
try {
await callOffice("desktop_save", {
desktop_session_id: session.desktop_session_id || session.session_id,
file_id: session.file_id || "",
}).catch(() => null);
await callOffice("close", {
session_id: session.store_session_id || "",
file_id: session.file_id || "",
});
} catch (error) {
console.warn("Desktop document close skipped", error);
}
this.session = null;
this.activeTabId = "";
this.editorText = "";
this.dirty = false;
const desktopTab = this.tabs.find((tab) => this.isDesktopSession(tab));
if (desktopTab) {
this.selectTab(desktopTab.tab_id, { focus: false });
} else {
await this.ensureDesktopSession({ select: true });
}
await this.refresh();
},
async closeTab(tabId) {
const tab = this.tabs.find((item) => item.tab_id === tabId);
if (!tab) return;
@ -705,14 +475,12 @@ const model = {
}
return;
}
if (this.hasNativeDocxTiles()) await this.awaitNativeEvents();
if (!this.hasNativeDocxTiles()) this.syncEditorText();
this.syncEditorText();
this.saving = true;
this.error = "";
try {
let response;
const payload = { session_id: this.session.session_id };
if (!this.hasNativeDocxTiles()) payload.text = this.editorText;
const payload = { session_id: this.session.session_id, text: this.editorText };
try {
response = await requestOffice("office_save", payload, 10000);
} catch (_socketError) {
@ -727,8 +495,6 @@ const model = {
document,
path: document.path || this.session.path,
file_id: document.file_id || this.session.file_id,
tiles: Array.isArray(response.tiles) ? response.tiles : this.session.tiles,
native: response.native || this.session.native || {},
version: document.version || response.version || this.session.version,
};
this.replaceActiveSession(updated);
@ -742,26 +508,6 @@ const model = {
}
},
async exportPdf() {
if (!this.session) return;
if (this.isDesktopSession()) return;
this.loading = true;
this.error = "";
try {
const response = await callOffice("export", {
file_id: this.session.file_id,
path: this.session.path,
target_format: "pdf",
});
if (response?.ok === false) throw new Error(response.error || "Export failed.");
this.setMessage(response.path ? `Exported ${response.path}` : "Exported");
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
} finally {
this.loading = false;
}
},
replaceActiveSession(next) {
if (!this.session) return;
this.session = next;
@ -835,32 +581,9 @@ const model = {
this.scheduleInputPush();
},
onRichInput(element) {
if (this._rendering) return;
this.editorText = htmlToMarkdown(element);
this.markDirty();
this.pushHistory(this.editorText);
this.scheduleInputPush();
},
onDocxInput(element) {
if (this.hasNativeDocxTiles()) return;
if (this._rendering) return;
this.editorText = docxEditorText(element);
this.markDirty();
this.pushHistory(this.editorText);
this.scheduleInputPush();
},
syncEditorText() {
if (!this.session) return;
if (this.hasOfficialOffice()) return;
if (this.hasNativeDocxTiles()) return;
if (this.isMarkdown() && !this.sourceMode && this._richEditor) {
this.editorText = htmlToMarkdown(this._richEditor);
} else if (this.isDocx() && this._docxEditor) {
this.editorText = docxEditorText(this._docxEditor);
}
this.session.text = this.editorText;
},
@ -883,98 +606,10 @@ const model = {
}, 3000).catch(() => {});
},
toggleSource() {
if (!this.isMarkdown()) return;
if (!this.sourceMode) this.syncEditorText();
this.sourceMode = !this.sourceMode;
this.queueRender({ force: true, focus: true });
},
format(command) {
if (!this.session) return;
if (this.sourceMode) {
this.applySourceFormat(command);
return;
}
const editor = this.isDocx() ? this._docxEditor : this._richEditor;
editor?.focus?.();
const uno = this.unoCommand(command);
if (this.isDocx() && uno) {
void this.dispatchUnoCommand(uno.command, uno.arguments);
if (this.hasNativeDocxTiles()) {
this.markDirty();
return;
}
}
if (command === "bold") document.execCommand?.("bold");
if (command === "italic") document.execCommand?.("italic");
if (command === "underline") document.execCommand?.("underline");
if (command === "list") document.execCommand?.("insertUnorderedList");
if (command === "numbered") document.execCommand?.("insertOrderedList");
if (command === "alignLeft") document.execCommand?.("justifyLeft");
if (command === "alignCenter") document.execCommand?.("justifyCenter");
if (command === "alignRight") document.execCommand?.("justifyRight");
if (command === "table") {
document.execCommand?.(
"insertHTML",
false,
'<table><tbody><tr><th>Column</th><th>Value</th></tr><tr><td></td><td></td></tr></tbody></table>',
);
}
this.syncEditorText();
this.markDirty();
this.pushHistory(this.editorText);
this.scheduleInputPush();
},
unoCommand(command) {
const commands = {
bold: { command: ".uno:Bold" },
italic: { command: ".uno:Italic" },
underline: { command: ".uno:Underline" },
list: { command: ".uno:DefaultBullet" },
numbered: { command: ".uno:DefaultNumbering" },
alignLeft: { command: ".uno:LeftPara" },
alignCenter: { command: ".uno:CenterPara" },
alignRight: { command: ".uno:RightPara" },
};
return commands[command] || null;
},
async dispatchUnoCommand(command, argumentsPayload = null) {
if (!this.session?.session_id || !command) return null;
return await this.queueNativeEvent(async () => {
try {
let response;
try {
response = await requestOffice("office_command", {
session_id: this.session.session_id,
command,
arguments: argumentsPayload,
notify: true,
}, 5000);
} catch (_socketError) {
response = await callOffice("command", {
session_id: this.session.session_id,
command,
arguments: argumentsPayload,
notify: true,
});
}
if (response?.ok === false) throw new Error(response.error || `${command} failed.`);
if (response?.metadata && this.session) {
this.session.native = { ...(this.session.native || {}), ...response.metadata, available: true };
}
if (Array.isArray(response?.tiles) && this.session) {
this.session.tiles = response.tiles;
this.queueRender({ force: true, focus: true });
}
return response;
} catch (error) {
console.warn("LibreOffice command skipped", command, error);
return null;
}
});
if (!this.isMarkdown()) return;
this.applySourceFormat(command);
},
applySourceFormat(command) {
@ -999,18 +634,6 @@ const model = {
});
},
zoomIn() {
this.zoom = Math.min(1.6, Math.round((this.zoom + 0.1) * 10) / 10);
},
zoomOut() {
this.zoom = Math.max(0.7, Math.round((this.zoom - 0.1) * 10) / 10);
},
zoomLabel() {
return `${Math.round(this.zoom * 100)}%`;
},
queueRender(options = {}) {
const force = Boolean(options.force);
if (options.focus) {
@ -1019,7 +642,6 @@ const model = {
this._focusAttempts = 0;
}
const render = () => {
this.renderEditors(force);
if (this._pendingFocus && this.focusEditor({ end: this._pendingFocusEnd })) {
this._pendingFocus = false;
this._focusAttempts = 0;
@ -1035,35 +657,16 @@ const model = {
}
},
renderEditors(force = false) {
if (!this.session) return;
if (this.hasOfficialOffice()) return;
this._rendering = true;
try {
if (this._richEditor && this.isMarkdown() && (!editorContainsFocus(this._richEditor) || force)) {
this._richEditor.innerHTML = markdownToHtml(this.editorText);
}
if (this._docxEditor && this.isDocx() && this.hasNativeDocxTiles() && (!editorContainsFocus(this._docxEditor) || force)) {
this._docxEditor.innerHTML = nativeTilesToHtml(this.session.tiles || []);
} else if (this._docxEditor && this.isDocx() && (!editorContainsFocus(this._docxEditor) || force)) {
this._docxEditor.innerHTML = textToPageHtml(this.editorText);
}
} finally {
this._rendering = false;
}
},
focusEditor(options = {}) {
if (!this.session || this.isPreviewOnly()) return false;
if (!this.session) return false;
if (this.hasOfficialOffice()) {
return this.focusDesktopFrame(this.desktopFrame(), { arm: true });
}
const source = this._root?.querySelector?.("[data-office-source]");
const editor = this.sourceMode ? source : (this.isDocx() ? this._docxEditor : this._richEditor);
if (!editor) return false;
editor.focus?.({ preventScroll: true });
if (!editorContainsFocus(editor)) return false;
if (options.end !== false) placeCaretAtEnd(editor);
if (!this.isMarkdown() || !source) return false;
source.focus?.({ preventScroll: true });
if (!editorContainsFocus(source)) return false;
if (options.end !== false) placeCaretAtEnd(source);
return true;
},
@ -1072,11 +675,6 @@ const model = {
return ext === "md";
},
isDocx(tab = this.session) {
const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase();
return ext === "docx";
},
isBinaryOffice(tab = this.session) {
const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase();
return ext === "docx" || ext === "xlsx" || ext === "pptx";
@ -1964,123 +1562,6 @@ const model = {
await this.refresh();
},
hasNativeDocxTiles() {
return Boolean(
this.isDocx()
&& this.session?.native?.available
&& Array.isArray(this.session?.tiles)
&& this.session.tiles.some((tile) => tile?.image),
);
},
async onNativeDocxClick(event) {
if (!this.hasNativeDocxTiles()) return;
const page = event.target?.closest?.(".office-docx-page.is-native-tile");
const image = page?.querySelector?.("img");
if (!page || !image) return;
const twips = this.decodeTileTwips(page);
const rect = image.getBoundingClientRect();
const ratioX = Math.max(0, Math.min(1, (event.clientX - rect.left) / Math.max(1, rect.width)));
const ratioY = Math.max(0, Math.min(1, (event.clientY - rect.top) / Math.max(1, rect.height)));
const x = Math.round((twips.x || 0) + ratioX * (twips.width || 0));
const y = Math.round((twips.y || 0) + ratioY * (twips.height || 0));
this._docxEditor?.focus?.({ preventScroll: true });
await this.sendNativeMouse({ type: "down", x, y, count: 1, buttons: 1, modifier: 0 });
await this.sendNativeMouse({ type: "up", x, y, count: 1, buttons: 1, modifier: 0 });
},
onNativeDocxKeydown(event) {
if (!this.hasNativeDocxTiles()) return;
if (event.ctrlKey || event.metaKey || event.altKey) return;
const key = event.key || "";
if (key.length === 1) {
event.preventDefault();
void this.sendNativeKey({ text: key });
return;
}
const special = {
Enter: { text: "\n" },
Tab: { text: "\t" },
Backspace: { char_code: 0, key_code: 8 },
Delete: { char_code: 0, key_code: 127 },
ArrowLeft: { char_code: 0, key_code: 37 },
ArrowUp: { char_code: 0, key_code: 38 },
ArrowRight: { char_code: 0, key_code: 39 },
ArrowDown: { char_code: 0, key_code: 40 },
}[key];
if (!special) return;
event.preventDefault();
if (special.text != null) {
void this.sendNativeKey({ text: special.text });
} else {
void this.sendNativeKey({ type: "down", ...special }).then(() => this.sendNativeKey({ type: "up", ...special }));
}
},
decodeTileTwips(page) {
try {
return JSON.parse(decodeURIComponent(page?.dataset?.twips || "{}"));
} catch {
return {};
}
},
async sendNativeKey(key) {
if (!this.session?.session_id) return null;
return await this.queueNativeEvent(async () => {
const response = await this.sendNativeEvent("office_key", "key", key, "key");
if (response?.ok) this.markDirty();
return response;
});
},
async sendNativeMouse(mouse) {
if (!this.session?.session_id) return null;
return await this.queueNativeEvent(() => this.sendNativeEvent("office_mouse", "mouse", mouse, "mouse"));
},
async queueNativeEvent(task) {
const run = this._nativeEventQueue.catch(() => null).then(task);
this._nativeEventQueue = run.catch(() => null);
return await run;
},
async awaitNativeEvents() {
await this._nativeEventQueue.catch(() => null);
},
async sendNativeEvent(socketEvent, apiAction, payload, key) {
try {
let response;
try {
response = await requestOffice(socketEvent, {
session_id: this.session.session_id,
[key]: payload,
}, 7000);
} catch (_socketError) {
response = await callOffice(apiAction, {
session_id: this.session.session_id,
[key]: payload,
});
}
if (response?.metadata && this.session) {
this.session.native = { ...(this.session.native || {}), ...response.metadata, available: true };
}
if (Array.isArray(response?.tiles) && this.session) {
this.session.tiles = response.tiles;
this.queueRender({ force: true, focus: true });
}
return response;
} catch (error) {
console.warn("LibreOffice native event skipped", socketEvent, error);
return null;
}
},
isPreviewOnly() {
return Boolean(this.session && !this.hasOfficialOffice() && !this.isMarkdown() && !this.isDocx());
},
defaultTitle(kind, fmt) {
const date = new Date().toISOString().slice(0, 10);
if (fmt === "md") return `Document ${date}`;
@ -2109,70 +1590,6 @@ const model = {
return "draft";
},
documentPath() {
return this.session?.document?.path || this.session?.path || "";
},
documentMeta(doc = this.session?.document || this.session || {}) {
const parts = [String(doc.extension || "").toUpperCase(), formatBytes(doc.size)].filter(Boolean);
return parts.join(" · ");
},
openCards() {
return this.visibleTabs()
.filter((tab) => !this.isDesktopSession(tab))
.map((tab) => normalizeDocument({
...tab.document,
...tab,
open: true,
}));
},
recentCards() {
const openIds = new Set(this.tabs.map((tab) => tab.file_id).filter(Boolean));
return this.recent.filter((doc) => !openIds.has(doc.file_id)).slice(0, 8);
},
previewKind(doc = {}) {
const ext = String(doc.extension || "").toLowerCase();
if (ext === "xlsx") return "spreadsheet";
if (ext === "pptx") return "presentation";
if (ext === "md") return "markdown";
return "document";
},
hasPreview(doc = {}) {
const preview = doc.preview || {};
return Boolean(
(Array.isArray(preview.lines) && preview.lines.length)
|| (Array.isArray(preview.rows) && preview.rows.length)
|| (Array.isArray(preview.slides) && preview.slides.length)
);
},
previewLines(doc = {}) {
const preview = doc.preview || {};
return (preview.lines || []).slice(0, 8);
},
previewRows(doc = {}) {
const preview = doc.preview || {};
return (preview.rows || []).slice(0, 6);
},
previewSlides(doc = {}) {
const preview = doc.preview || {};
return (preview.slides || []).slice(0, 3);
},
dashboardTitle(doc = {}) {
return doc.title || doc.basename || basename(doc.path);
},
dashboardMeta(doc = {}) {
return [String(doc.extension || "").toUpperCase(), doc.open ? "Open" : "", formatBytes(doc.size)].filter(Boolean).join(" · ");
},
setupFloatingModal(element = null) {
const root = element || globalThis.document?.querySelector(".office-panel");
const inner = root?.closest?.(".modal-inner");

View file

@ -14,8 +14,10 @@ def test_document_canvas_uses_markdown_editor_and_official_libreoffice_desktop_f
encoding="utf-8",
)
assert "office-rich-editor" in panel
assert "office-docx-pages" in panel
assert "office-source-editor" in panel
assert "data-office-source" in panel
assert "office-rich-editor" not in panel
assert "office-docx-pages" not in panel
assert "office-desktop-frame" in panel
assert "data-office-desktop-frame" in panel
assert 'title="LibreOffice desktop"' not in panel
@ -28,12 +30,14 @@ def test_document_canvas_uses_markdown_editor_and_official_libreoffice_desktop_f
assert "office-modal-resizer" in panel
assert "resize: both" not in panel
assert 'tabindex="0"' in panel
assert "format_underlined" in panel
assert "format_align_center" in panel
assert "is-native-tile" in panel
assert "format_underlined" not in panel
assert "format_align_center" not in panel
assert "is-native-tile" not in panel
assert "hasOfficialOffice()" in panel
assert "office_save" in store
assert "desktop_save" in store
assert "--office-zoom" not in panel
assert "zoom: 1" not in store
assert 'callOffice("desktop")' in store
assert "ensureDesktopSession" in store
assert "handleOfficialOfficeClosed" in store
@ -82,16 +86,16 @@ def test_document_canvas_uses_markdown_editor_and_official_libreoffice_desktop_f
assert "officialOfficeUrl" in store
assert "hasOfficialOffice" in store
assert "isOfficeSocketData" in store
assert "office_command" in store
assert "office_key" in store
assert "office_mouse" in store
assert ".uno:Bold" in store
assert "nativeTilesToHtml" in store
assert "office_command" not in store
assert "office_key" not in store
assert "office_mouse" not in store
assert ".uno:Bold" not in store
assert "nativeTilesToHtml" not in store
assert "editorContainsFocus" in store
assert "_focusAttempts" in store
assert "_nativeEventQueue" in store
assert "await this.awaitNativeEvents()" in store
assert "<p><br></p>" in store
assert "_nativeEventQueue" not in store
assert "await this.awaitNativeEvents()" not in store
assert "<p><br></p>" not in store
assert "setupTitle()" not in panel
assert "Setup in progress" not in store
assert "office-log" not in panel
@ -112,7 +116,7 @@ def test_desktop_xpra_canvas_scroll_is_forwarded_to_the_remote_session():
assert "getModifierState: { value: getModifierState }" in store
def test_office_dashboard_uses_cards_and_filters_tabs_to_desktop_and_markdown():
def test_office_surface_filters_tabs_to_desktop_and_markdown_without_dashboard():
panel = (PROJECT_ROOT / "plugins" / "_office" / "webui" / "office-panel.html").read_text(
encoding="utf-8",
)
@ -120,14 +124,14 @@ def test_office_dashboard_uses_cards_and_filters_tabs_to_desktop_and_markdown():
encoding="utf-8",
)
assert "office-card-grid" in panel
assert "office-document-card" in panel
assert "office-card-grid" not in panel
assert "office-document-card" not in panel
assert "visibleTabs()" in panel
assert "openCards()" in panel
assert "recentCards()" in panel
assert "openCards()" not in panel
assert "recentCards()" not in panel
assert "office-editor-head" not in panel
assert "office-recent-row" not in panel
assert "open_documents" in store
assert "open_documents" not in store
assert "installDesktopDocumentSession" in store
assert "isDesktopOfficeDocument" in store
assert "isVisibleOfficeTab" in store
@ -248,6 +252,9 @@ def test_official_libreoffice_desktop_route_and_packages_are_declared():
assert "DESKTOP_FOLDER_LINKS" in desktop
assert "HIDDEN_XPRA_DESKTOP_ENTRIES" in desktop
assert "libreoffice-gtk3" in install
assert "libreofficekit" not in install
assert "gir1.2-lokdocview" not in install
assert "python3-gi" not in install
assert "xpra" in install
assert "xpra-x11" in install
assert "xpra-html5" in install

View file

@ -7,6 +7,7 @@ import os
import sys
import types
import zipfile
import xml.etree.ElementTree as ET
from pathlib import Path
import pytest
@ -23,9 +24,7 @@ from plugins._office.helpers import (
document_store,
libreoffice,
libreoffice_desktop,
libreofficekit_native,
libreofficekit_sessions,
libreofficekit_worker,
markdown_sessions,
)
@ -87,9 +86,10 @@ def test_blank_docx_includes_editable_body_paragraph(office_state):
doc = document_store.create_document("document", "Blank Memo", "docx", "")
with zipfile.ZipFile(doc["path"]) as archive:
xml = archive.read("word/document.xml").decode("utf-8")
root = document_store.ET.fromstring(xml)
root = ET.fromstring(xml)
assert len(list(root.iter(document_store._qn(document_store.W_NS, "p")))) >= 2
word_ns = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
assert len(list(root.iter(f"{{{word_ns}}}p"))) >= 2
assert 'xml:space="preserve">&#160;</w:t>' in xml
@ -239,16 +239,14 @@ def test_non_project_creation_uses_configured_workdir(office_state):
assert Path(spreadsheet["path"]).parent == office_state.documents
def test_sessions_recent_preview_and_canvas_context_are_neutral(office_state):
def test_sessions_and_canvas_context_are_neutral(office_state):
doc = document_store.create_document("document", "Canvas Context", "md", "Private body text.")
session = document_store.create_session(doc["file_id"], "user-a", "write", "http://localhost:32080")
open_docs = document_store.get_open_documents()
recent = document_store.get_recent_documents()
context = canvas_context.build_context()
assert open_docs[0]["file_id"] == doc["file_id"]
assert recent[0]["preview"]["lines"]
assert "document artifacts" in context
assert "Private body text" not in context
assert document_store.close_session(session_id=session["session_id"]) == 1
@ -266,8 +264,8 @@ def test_markdown_save_tracks_version_history(office_state):
def test_direct_markdown_edits_refresh_open_canvas_session(office_state, monkeypatch):
manager = libreofficekit_sessions.LibreOfficeKitSessionManager()
monkeypatch.setattr(libreofficekit_sessions, "_manager", manager, raising=False)
manager = markdown_sessions.MarkdownSessionManager()
monkeypatch.setattr(markdown_sessions, "_manager", manager, raising=False)
doc = document_store.create_document("document", "Receiver", "md", "First")
session = manager.open(doc)
@ -276,64 +274,12 @@ def test_direct_markdown_edits_refresh_open_canvas_session(office_state, monkeyp
assert manager._sessions[session["session_id"]].text == "# Receiver\n\nSecond"
def test_docx_session_dispatches_native_uno_commands(office_state, monkeypatch):
calls = []
def test_markdown_session_rejects_office_binaries(office_state):
manager = markdown_sessions.MarkdownSessionManager()
doc = document_store.create_document("document", "Desktop Only", "docx", "Native text")
class FakeNativeDocument:
def metadata(self):
return {"available": True, "doctype": 0, "parts": 1, "width_twips": 100, "height_twips": 200}
def post_uno_command(self, command, arguments=None, notify=True):
calls.append((command, arguments, notify))
return {"ok": True, "native": True, "command": command}
def command_values(self, command):
return {"ok": True, "native": True, "command": command, "values": {"commandName": command}}
def close(self):
calls.append(("close", None, None))
monkeypatch.setattr(libreofficekit_native, "open_document", lambda path: FakeNativeDocument())
manager = libreofficekit_sessions.LibreOfficeKitSessionManager()
doc = document_store.create_document("document", "Native", "docx", "Native text")
session = manager.open(doc)
result = manager.command(session["session_id"], ".uno:Bold", notify=True)
values = manager.command_values(session["session_id"], ".uno:StyleApply")
manager.close(session["session_id"])
assert session["native"]["available"] is True
assert result["ok"] is True
assert result["native"] is True
assert values["values"]["commandName"] == ".uno:StyleApply"
assert calls[0] == (".uno:Bold", None, True)
assert calls[-1] == ("close", None, None)
def test_lok_worker_serializes_concurrent_rpc_calls():
import concurrent.futures
import threading
import time
document = object.__new__(libreofficekit_worker.WorkerLokDocument)
document._lock = threading.RLock()
active = 0
max_active = 0
def fake_request_unlocked(action, payload=None, timeout=18):
nonlocal active, max_active
active += 1
max_active = max(max_active, active)
time.sleep(0.01)
active -= 1
return {"ok": True, "action": action, "payload": payload}
document._request_unlocked = fake_request_unlocked
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as pool:
results = list(pool.map(lambda index: document._request("key", {"index": index}), range(12)))
assert all(result["ok"] is True for result in results)
assert max_active == 1
with pytest.raises(ValueError, match="Open .docx files in the Desktop"):
manager.open(doc)
def test_official_libreoffice_desktop_status_and_url_contract(tmp_path, monkeypatch):