This commit is contained in:
parent
747e42e788
commit
76d6cc7f74
12 changed files with 1715 additions and 13 deletions
3
.github/workflows/tests.yml
vendored
3
.github/workflows/tests.yml
vendored
|
|
@ -102,7 +102,8 @@ jobs:
|
|||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install "pyyaml>=6.0" pytest pytest-timeout pytest-asyncio pytest-shard
|
||||
# Office parsers stay optional at runtime; CI installs them explicitly for the workspace preview tests.
|
||||
pip install "pyyaml>=6.0" pytest pytest-timeout pytest-asyncio pytest-shard python-docx openpyxl python-pptx
|
||||
# ruff is installed so tests/test_ruff_forward_lint.py runs its E9/F821
|
||||
# tree-clean assertions in-suite (mirrors how eslint is available for
|
||||
# tests/test_static_js_runtime_lint.py). If install fails the test
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@
|
|||
|
||||
- **No more 57–70 s cold-startup stalls from the profile skills-stats thundering herd.** At container boot the frontend fires several profile-data requests at once; with `ThreadingHTTPServer` (one thread per request) they all missed the empty skills-stats cache simultaneously and each walked + parsed every profile's skill tree, stacking thousands of concurrent `stat()` calls under Docker's overlay2 filesystem. `_get_profile_skills_stats()` now serializes per-profile with double-checked locking (concurrent misses on one profile collapse to a single compute; independent profiles still compute in parallel), and `list_profiles_api()` single-flights the row build under `_LIST_PROFILES_CACHE_LOCK` so one thread builds while the rest wait for the cached result. The every-call cheap mtime probe (the #4783 out-of-band change-detection contract) is unchanged. (#5364)
|
||||
|
||||
### Added
|
||||
|
||||
- **The workspace file viewer now previews modern Office documents (`.docx` / `.xlsx` / `.pptx`) and lets you edit plain, structurally-simple `.docx` files in place.** A shared backend Office authority owns previewability, editability, and archive-safety: parsers are optional at runtime (lazy-imported; a lean install degrades to a clean 503 install hint, not a crash), every OOXML archive is preflighted against decompression-bomb limits on *actual inflated bytes* before any parser runs, and preview size is bounded during accumulation. `.docx` editing is fail-closed — only documents whose body is plain paragraphs (no custom sections/tables/headers/rich runs) are editable, save rebuilds from the original package so document metadata/styles/theme are preserved, and the saved bytes are re-verified before write. Previews render as escaped text (no HTML injection). Thanks @rodboev. (#5142, part of #540)
|
||||
|
||||
## [v0.51.792] — 2026-07-01
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
647
api/office_documents.py
Normal file
647
api/office_documents.py
Normal file
|
|
@ -0,0 +1,647 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import posixpath
|
||||
import zipfile
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
CLAIMED_OFFICE_EXTENSIONS = frozenset({".docx", ".xlsx", ".pptx"})
|
||||
CLAIMED_OFFICE_FORMATS = frozenset({"docx", "xlsx", "pptx"})
|
||||
OFFICE_PREVIEW_KIND = "office"
|
||||
OFFICE_RENDER_MODE = "code"
|
||||
OFFICE_DEPENDENCY_HINT = (
|
||||
"Office preview is not available on this server. Install python-docx, "
|
||||
"openpyxl, and python-pptx to enable it: pip install python-docx openpyxl "
|
||||
"python-pptx"
|
||||
)
|
||||
OFFICE_PREVIEW_TRUNCATED_NOTICE = "[Preview truncated: Office content exceeds safe limits]"
|
||||
MAX_OFFICE_PREVIEW_CHARS = 120_000
|
||||
MAX_DOCX_PREVIEW_BLOCKS = 2_000
|
||||
MAX_DOCX_TABLE_CELLS = 5_000
|
||||
MAX_XLSX_PREVIEW_SHEETS = 20
|
||||
MAX_XLSX_PREVIEW_ROWS_PER_SHEET = 500
|
||||
MAX_XLSX_PREVIEW_CELLS_PER_SHEET = 5_000
|
||||
MAX_PPTX_PREVIEW_SLIDES = 100
|
||||
MAX_PPTX_PREVIEW_SHAPES_PER_SLIDE = 200
|
||||
MAX_OFFICE_ARCHIVE_MEMBERS = 256
|
||||
MAX_OFFICE_ARCHIVE_TOTAL_UNCOMPRESSED_BYTES = 8_000_000
|
||||
MAX_OFFICE_ARCHIVE_MEMBER_BYTES = 4_000_000
|
||||
MAX_OFFICE_ARCHIVE_MAX_COMPRESSION_RATIO = 200
|
||||
MAX_DOCX_ARCHIVE_DOCUMENT_BYTES = 4_000_000
|
||||
MAX_XLSX_ARCHIVE_SHARED_STRINGS_BYTES = 4_000_000
|
||||
MAX_XLSX_ARCHIVE_WORKSHEET_BYTES = 2_000_000
|
||||
MAX_XLSX_ARCHIVE_METADATA_BYTES = 512_000
|
||||
MAX_PPTX_ARCHIVE_SLIDE_BYTES = 1_000_000
|
||||
MAX_PPTX_ARCHIVE_MEDIA_BYTES = 2_000_000
|
||||
|
||||
_WORD_NAMESPACE = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
|
||||
_DEFAULT_DOCX_SECTION_SIGNATURE = None
|
||||
|
||||
_DOCX_BODY_CHILDREN = {f"{_WORD_NAMESPACE}p", f"{_WORD_NAMESPACE}sectPr"}
|
||||
_DOCX_PARAGRAPH_CHILDREN = {f"{_WORD_NAMESPACE}pPr", f"{_WORD_NAMESPACE}r"}
|
||||
_DOCX_SAFE_PARAGRAPH_PROPERTY_CHILDREN = {f"{_WORD_NAMESPACE}pStyle"}
|
||||
_DOCX_RUN_CHILDREN = {f"{_WORD_NAMESPACE}t"}
|
||||
|
||||
|
||||
def _office_dependency_import_error() -> ImportError:
|
||||
return ImportError(OFFICE_DEPENDENCY_HINT)
|
||||
|
||||
def _office_archive_limit_error() -> ValueError:
|
||||
return ValueError("Office preview exceeds safe archive limits")
|
||||
|
||||
def _office_preview_read_error(office_format: str) -> ValueError:
|
||||
return ValueError(f"Unable to read {office_format.upper()} preview")
|
||||
|
||||
|
||||
def _load_docx_document():
|
||||
try:
|
||||
from docx import Document as document_factory
|
||||
except ImportError as exc: # pragma: no cover - depends on local install shape
|
||||
raise _office_dependency_import_error() from exc
|
||||
return document_factory
|
||||
|
||||
|
||||
def _load_workbook_reader():
|
||||
try:
|
||||
from openpyxl import load_workbook as workbook_reader
|
||||
except ImportError as exc: # pragma: no cover - depends on local install shape
|
||||
raise _office_dependency_import_error() from exc
|
||||
return workbook_reader
|
||||
|
||||
|
||||
def _load_presentation_ctor():
|
||||
try:
|
||||
from pptx import Presentation as presentation_ctor
|
||||
except ImportError as exc: # pragma: no cover - depends on local install shape
|
||||
raise _office_dependency_import_error() from exc
|
||||
return presentation_ctor
|
||||
|
||||
def _normalise_archive_member_name(name: str) -> str:
|
||||
normalized = posixpath.normpath(name.replace("\\", "/"))
|
||||
if name.startswith(("/", "\\")) or normalized in {"", ".", ".."} or normalized.startswith("../"):
|
||||
raise _office_archive_limit_error()
|
||||
return normalized
|
||||
|
||||
def _archive_member_byte_limit(office_format: str, member_name: str) -> int:
|
||||
if office_format == "docx":
|
||||
if member_name == "word/document.xml":
|
||||
return MAX_DOCX_ARCHIVE_DOCUMENT_BYTES
|
||||
elif office_format == "xlsx":
|
||||
if member_name == "xl/sharedStrings.xml":
|
||||
return MAX_XLSX_ARCHIVE_SHARED_STRINGS_BYTES
|
||||
if member_name.startswith("xl/worksheets/"):
|
||||
return MAX_XLSX_ARCHIVE_WORKSHEET_BYTES
|
||||
if member_name.startswith("xl/theme/") or member_name.startswith("docProps/") or member_name == "xl/workbook.xml":
|
||||
return MAX_XLSX_ARCHIVE_METADATA_BYTES
|
||||
elif office_format == "pptx":
|
||||
if member_name.startswith("ppt/slides/"):
|
||||
return MAX_PPTX_ARCHIVE_SLIDE_BYTES
|
||||
if member_name.startswith("ppt/media/"):
|
||||
return MAX_PPTX_ARCHIVE_MEDIA_BYTES
|
||||
return MAX_OFFICE_ARCHIVE_MEMBER_BYTES
|
||||
|
||||
def _preflight_office_archive(office_format: str, raw: bytes) -> None:
|
||||
try:
|
||||
archive = zipfile.ZipFile(io.BytesIO(raw))
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise _office_preview_read_error(office_format) from exc
|
||||
|
||||
with archive:
|
||||
file_infos = [info for info in archive.infolist() if not info.is_dir()]
|
||||
if len(file_infos) > MAX_OFFICE_ARCHIVE_MEMBERS:
|
||||
raise _office_archive_limit_error()
|
||||
|
||||
total_uncompressed = 0
|
||||
for info in file_infos:
|
||||
member_name = _normalise_archive_member_name(info.filename)
|
||||
member_limit = _archive_member_byte_limit(office_format, member_name)
|
||||
member_size = 0
|
||||
try:
|
||||
member = archive.open(info)
|
||||
except Exception as exc: # pragma: no cover - malformed archive path
|
||||
raise _office_preview_read_error(office_format) from exc
|
||||
with member:
|
||||
try:
|
||||
while True:
|
||||
chunk = member.read(64 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
member_size += len(chunk)
|
||||
total_uncompressed += len(chunk)
|
||||
if member_size > member_limit or total_uncompressed > MAX_OFFICE_ARCHIVE_TOTAL_UNCOMPRESSED_BYTES:
|
||||
raise _office_archive_limit_error()
|
||||
except (zipfile.BadZipFile, zlib.error, EOFError, OSError) as exc:
|
||||
# Corrupt/truncated member data (bad CRC, short read, etc.) —
|
||||
# mundane for partially-uploaded files, not adversarial. Raise
|
||||
# the module's intended read-error ValueError (handled as a
|
||||
# clean 4xx) instead of letting BadZipFile/zlib.error escape to
|
||||
# the route's catch-all as an unhandled 500 + traceback. The
|
||||
# limit-exceeded ValueError above is deliberately NOT caught
|
||||
# here (it is not one of these decompression exception types).
|
||||
raise _office_preview_read_error(office_format) from exc
|
||||
if member_size > max(info.compress_size, 1) * MAX_OFFICE_ARCHIVE_MAX_COMPRESSION_RATIO:
|
||||
raise _office_archive_limit_error()
|
||||
|
||||
|
||||
def is_claimed_office_path(path: str | Path) -> bool:
|
||||
return Path(str(path)).suffix.lower() in CLAIMED_OFFICE_EXTENSIONS
|
||||
|
||||
|
||||
def _office_format_for_path(path: str | Path) -> str:
|
||||
return Path(str(path)).suffix.lower().lstrip(".")
|
||||
|
||||
|
||||
def _normalise_preview_text(value, max_chars: int | None = None) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
text = str(value)
|
||||
if max_chars is not None and max_chars >= 0 and len(text) > max_chars:
|
||||
text = text[:max_chars]
|
||||
return text.replace("\r", "\n").replace("\n", " ").strip()
|
||||
|
||||
|
||||
def _preview_line_count(content: str) -> int:
|
||||
if not content:
|
||||
return 1
|
||||
return content.count("\n") + 1
|
||||
|
||||
|
||||
def _finalize_preview_text(content: str, truncated: bool = False, strip_edges: bool = True) -> tuple[str, bool]:
|
||||
# docx passes strip_edges=False: leading/trailing blank paragraphs are
|
||||
# meaningful body content and the editor textarea is prefilled from this
|
||||
# text, so stripping edge whitespace would silently drop those paragraphs on
|
||||
# an unedited save (interior blanks already round-trip). xlsx/pptx keep the
|
||||
# strip — their previews are read-only and edge whitespace is just noise.
|
||||
text = (content or "").strip() if strip_edges else (content or "")
|
||||
if len(text) > MAX_OFFICE_PREVIEW_CHARS:
|
||||
text = text[:MAX_OFFICE_PREVIEW_CHARS].rstrip()
|
||||
truncated = True
|
||||
if truncated:
|
||||
text = f"{text}\n\n{OFFICE_PREVIEW_TRUNCATED_NOTICE}" if text else OFFICE_PREVIEW_TRUNCATED_NOTICE
|
||||
return text, truncated
|
||||
|
||||
|
||||
class _PreviewBuilder:
|
||||
def __init__(self, char_limit: int | None = None) -> None:
|
||||
self._char_limit = MAX_OFFICE_PREVIEW_CHARS if char_limit is None else max(char_limit, 0)
|
||||
self._parts: list[str] = []
|
||||
self._length = 0
|
||||
self._started = False
|
||||
self.truncated = False
|
||||
|
||||
@property
|
||||
def remaining_chars(self) -> int:
|
||||
return max(self._char_limit - self._length, 0)
|
||||
|
||||
@property
|
||||
def started(self) -> bool:
|
||||
return self._started
|
||||
|
||||
@property
|
||||
def has_content(self) -> bool:
|
||||
return bool(self._parts)
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
return "".join(self._parts)
|
||||
|
||||
def _append_piece(self, piece: str) -> bool:
|
||||
if self.truncated:
|
||||
return False
|
||||
if not piece:
|
||||
return True
|
||||
remaining = self.remaining_chars
|
||||
if remaining <= 0:
|
||||
self.truncated = True
|
||||
return False
|
||||
if len(piece) > remaining:
|
||||
piece = piece[:remaining].rstrip()
|
||||
self.truncated = True
|
||||
if piece:
|
||||
self._parts.append(piece)
|
||||
self._length += len(piece)
|
||||
return not self.truncated
|
||||
|
||||
def start_line(self) -> bool:
|
||||
if self._started:
|
||||
return self._append_piece("\n")
|
||||
self._started = True
|
||||
return True
|
||||
|
||||
def start_section(self) -> bool:
|
||||
if self._started:
|
||||
return self._append_piece("\n\n")
|
||||
self._started = True
|
||||
return True
|
||||
|
||||
def append_text(self, text: str) -> bool:
|
||||
if not self._started:
|
||||
self._started = True
|
||||
return self._append_piece(text)
|
||||
|
||||
def finish(self, strip_edges: bool = True) -> tuple[str, bool]:
|
||||
return _finalize_preview_text(self.text, self.truncated, strip_edges=strip_edges)
|
||||
|
||||
|
||||
def _append_normalized_preview_text(builder: _PreviewBuilder, value) -> bool:
|
||||
if builder.truncated:
|
||||
return False
|
||||
remaining = builder.remaining_chars
|
||||
if remaining <= 0:
|
||||
builder.truncated = True
|
||||
return False
|
||||
raw_text = "" if value is None else str(value)
|
||||
clipped = len(raw_text) > remaining
|
||||
if not builder.append_text(_normalise_preview_text(raw_text, remaining)):
|
||||
return False
|
||||
if clipped:
|
||||
builder.truncated = True
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _append_verbatim_preview_text(builder: _PreviewBuilder, value) -> bool:
|
||||
"""Append run text WITHOUT per-node strip/space-normalization (docx runs).
|
||||
|
||||
A docx paragraph is split across multiple ``<w:t>`` runs and the whitespace
|
||||
at run boundaries is significant: ``p.add_run("Hello ") + p.add_run("world")``
|
||||
stores two runs whose text is ``"Hello "`` and ``"world"``. The general
|
||||
``_normalise_preview_text`` path ``.strip()``s each node and concatenates
|
||||
with no separator, which corrupts that to ``"Helloworld"`` — and because the
|
||||
editor textarea is prefilled from the preview text, opening + saving such a
|
||||
file (even with no edits) persisted the corruption to disk. That
|
||||
normalization is still correct for xlsx cells / pptx shapes (whole-cell
|
||||
values, not run fragments), so only the docx run path switches to verbatim
|
||||
append. Text is budget-clipped but never stripped/space-collapsed.
|
||||
"""
|
||||
if builder.truncated:
|
||||
return False
|
||||
remaining = builder.remaining_chars
|
||||
if remaining <= 0:
|
||||
builder.truncated = True
|
||||
return False
|
||||
raw_text = "" if value is None else str(value)
|
||||
clipped = len(raw_text) > remaining
|
||||
if not builder.append_text(raw_text):
|
||||
return False
|
||||
if clipped:
|
||||
builder.truncated = True
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _iter_docx_text_nodes(element):
|
||||
for node in element.iter():
|
||||
if node.tag == f"{_WORD_NAMESPACE}t" and node.text:
|
||||
yield node.text
|
||||
|
||||
|
||||
def _append_docx_element_text(builder: _PreviewBuilder, element) -> bool:
|
||||
for text in _iter_docx_text_nodes(element):
|
||||
# Verbatim (not _normalise_preview_text): preserve run-boundary
|
||||
# whitespace so multi-run paragraphs round-trip losslessly. See
|
||||
# _append_verbatim_preview_text for why docx differs from xlsx/pptx.
|
||||
if not _append_verbatim_preview_text(builder, text):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _append_docx_cell_text(builder: _PreviewBuilder, cell_element) -> bool:
|
||||
first_paragraph = True
|
||||
for child in cell_element:
|
||||
if child.tag != f"{_WORD_NAMESPACE}p":
|
||||
continue
|
||||
if not first_paragraph and not builder.append_text("\n"):
|
||||
return False
|
||||
if not _append_docx_element_text(builder, child):
|
||||
return False
|
||||
first_paragraph = False
|
||||
return True
|
||||
|
||||
|
||||
def _docx_preview_text(document) -> tuple[str, bool]:
|
||||
builder = _PreviewBuilder()
|
||||
body_blocks_seen = 0
|
||||
table_cells_seen = 0
|
||||
table_index = 0
|
||||
for child in document._element.body:
|
||||
if child.tag == f"{_WORD_NAMESPACE}sectPr":
|
||||
continue
|
||||
body_blocks_seen += 1
|
||||
if body_blocks_seen > MAX_DOCX_PREVIEW_BLOCKS:
|
||||
builder.truncated = True
|
||||
break
|
||||
if child.tag == f"{_WORD_NAMESPACE}p":
|
||||
if not builder.start_line() or not _append_docx_element_text(builder, child):
|
||||
break
|
||||
continue
|
||||
if child.tag != f"{_WORD_NAMESPACE}tbl":
|
||||
continue
|
||||
table_index += 1
|
||||
if not builder.start_line() or not builder.append_text(f"Table {table_index}"):
|
||||
break
|
||||
for row in child:
|
||||
if row.tag != f"{_WORD_NAMESPACE}tr":
|
||||
continue
|
||||
if not builder.start_line():
|
||||
break
|
||||
first_cell = True
|
||||
for cell in row:
|
||||
if cell.tag != f"{_WORD_NAMESPACE}tc":
|
||||
continue
|
||||
table_cells_seen += 1
|
||||
if table_cells_seen > MAX_DOCX_TABLE_CELLS:
|
||||
builder.truncated = True
|
||||
break
|
||||
if not first_cell and not builder.append_text("\t"):
|
||||
break
|
||||
if not _append_docx_cell_text(builder, cell):
|
||||
break
|
||||
first_cell = False
|
||||
if builder.truncated:
|
||||
break
|
||||
if builder.truncated:
|
||||
break
|
||||
# strip_edges=False: preserve leading/trailing blank paragraphs so an
|
||||
# unedited open->save round-trips them (the editor prefills from this text).
|
||||
return builder.finish(strip_edges=False)
|
||||
|
||||
|
||||
def _docx_paragraph_properties_are_safe(properties) -> bool:
|
||||
for child in properties:
|
||||
if child.tag not in _DOCX_SAFE_PARAGRAPH_PROPERTY_CHILDREN:
|
||||
return False
|
||||
if child.tag == f"{_WORD_NAMESPACE}pStyle" and child.get(f"{_WORD_NAMESPACE}val") != "Normal":
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _docx_xml_signature(element) -> tuple:
|
||||
attributes = tuple(
|
||||
sorted(
|
||||
(key, value)
|
||||
for key, value in element.attrib.items()
|
||||
if not key.rsplit("}", 1)[-1].startswith("rsid")
|
||||
)
|
||||
)
|
||||
children = tuple(_docx_xml_signature(child) for child in element)
|
||||
text = (element.text or "").strip()
|
||||
return element.tag, attributes, text, children
|
||||
|
||||
|
||||
def _default_docx_section_signature() -> tuple:
|
||||
global _DEFAULT_DOCX_SECTION_SIGNATURE
|
||||
if _DEFAULT_DOCX_SECTION_SIGNATURE is None:
|
||||
document = _load_docx_document()()
|
||||
_DEFAULT_DOCX_SECTION_SIGNATURE = tuple(
|
||||
_docx_xml_signature(child) for child in document._element.body.sectPr
|
||||
)
|
||||
return _DEFAULT_DOCX_SECTION_SIGNATURE
|
||||
|
||||
|
||||
def _docx_section_properties_are_safe(section_properties) -> bool:
|
||||
return tuple(_docx_xml_signature(child) for child in section_properties) == _default_docx_section_signature()
|
||||
|
||||
|
||||
def _docx_editability(document) -> tuple[bool, str | None]:
|
||||
body = document._element.body
|
||||
for child in body:
|
||||
if child.tag not in _DOCX_BODY_CHILDREN:
|
||||
return False, "docx contains unsupported structures"
|
||||
if child.tag == f"{_WORD_NAMESPACE}sectPr" and not _docx_section_properties_are_safe(child):
|
||||
return False, "docx contains unsupported section content"
|
||||
for paragraph in document.paragraphs:
|
||||
for child in paragraph._p:
|
||||
if child.tag not in _DOCX_PARAGRAPH_CHILDREN:
|
||||
return False, "docx contains unsupported paragraph structures"
|
||||
if child.tag == f"{_WORD_NAMESPACE}pPr" and not _docx_paragraph_properties_are_safe(child):
|
||||
return False, "docx contains unsupported paragraph structures"
|
||||
for run in paragraph.runs:
|
||||
for child in run._r:
|
||||
if child.tag not in _DOCX_RUN_CHILDREN:
|
||||
return False, "docx contains unsupported inline content"
|
||||
return True, None
|
||||
|
||||
|
||||
def _preview_docx(raw: bytes) -> tuple[str, bool, str | None, bool]:
|
||||
_preflight_office_archive("docx", raw)
|
||||
try:
|
||||
document = _load_docx_document()(io.BytesIO(raw))
|
||||
except ImportError:
|
||||
raise
|
||||
except Exception as exc: # pragma: no cover - library-specific failure mode
|
||||
raise ValueError("Unable to read DOCX preview") from exc
|
||||
content, truncated = _docx_preview_text(document)
|
||||
if truncated:
|
||||
return content, False, "docx preview exceeds safe limits", True
|
||||
editable, reason = _docx_editability(document)
|
||||
return content, editable, reason, truncated
|
||||
|
||||
|
||||
def _preview_xlsx(raw: bytes) -> tuple[str, bool]:
|
||||
_preflight_office_archive("xlsx", raw)
|
||||
try:
|
||||
workbook = _load_workbook_reader()(io.BytesIO(raw), data_only=True, read_only=True)
|
||||
except ImportError:
|
||||
raise
|
||||
except Exception as exc: # pragma: no cover - library-specific failure mode
|
||||
raise ValueError("Unable to read XLSX preview") from exc
|
||||
builder = _PreviewBuilder()
|
||||
try:
|
||||
for sheet_index, sheet in enumerate(workbook.worksheets, start=1):
|
||||
if sheet_index > MAX_XLSX_PREVIEW_SHEETS:
|
||||
builder.truncated = True
|
||||
break
|
||||
if not builder.start_section() or not builder.append_text(f"Sheet: {sheet.title}"):
|
||||
break
|
||||
rows_seen = 0
|
||||
cells_seen = 0
|
||||
# openpyxl read-only mode reports max_row/max_column as None for any
|
||||
# workbook lacking a <dimension> record — which includes everything
|
||||
# openpyxl.Workbook(write_only=True) produces. `getattr(..., DEFAULT)`
|
||||
# does NOT help (the attribute exists; its value is None).
|
||||
#
|
||||
# When the dimension IS known, bound iter_rows to the capped extent.
|
||||
# When it's UNKNOWN, pass None so openpyxl yields each row's NATURAL
|
||||
# width — passing the cap instead would make openpyxl pad every row
|
||||
# out to `max_col` with None cells, exhausting the per-sheet cell
|
||||
# budget on row 1 and dropping the rest of the sheet. The rows_seen /
|
||||
# cells_seen counters below bound the work in the unknown case.
|
||||
sheet_max_row = getattr(sheet, "max_row", None)
|
||||
sheet_max_col = getattr(sheet, "max_column", None)
|
||||
max_row = min(sheet_max_row, MAX_XLSX_PREVIEW_ROWS_PER_SHEET) if sheet_max_row else None
|
||||
max_col = min(sheet_max_col, MAX_XLSX_PREVIEW_CELLS_PER_SHEET) if sheet_max_col else None
|
||||
for row in sheet.iter_rows(values_only=True, max_row=max_row, max_col=max_col):
|
||||
rows_seen += 1
|
||||
if rows_seen > MAX_XLSX_PREVIEW_ROWS_PER_SHEET:
|
||||
builder.truncated = True
|
||||
break
|
||||
row_budget = builder.remaining_chars - (1 if builder.started else 0)
|
||||
row_builder = _PreviewBuilder(row_budget)
|
||||
for value in row:
|
||||
cells_seen += 1
|
||||
if cells_seen > MAX_XLSX_PREVIEW_CELLS_PER_SHEET:
|
||||
builder.truncated = True
|
||||
break
|
||||
if row_builder.has_content and not row_builder.append_text("\t"):
|
||||
break
|
||||
if not _append_normalized_preview_text(row_builder, value):
|
||||
if not row_builder.truncated:
|
||||
builder.truncated = True
|
||||
break
|
||||
if builder.truncated:
|
||||
break
|
||||
if row_builder.has_content:
|
||||
if not builder.start_line() or not builder.append_text(row_builder.text):
|
||||
break
|
||||
if row_builder.truncated:
|
||||
builder.truncated = True
|
||||
break
|
||||
if builder.truncated:
|
||||
break
|
||||
if (getattr(sheet, "max_row", None) or rows_seen) > MAX_XLSX_PREVIEW_ROWS_PER_SHEET:
|
||||
builder.truncated = True
|
||||
break
|
||||
if (getattr(sheet, "max_column", None) or 0) > MAX_XLSX_PREVIEW_CELLS_PER_SHEET:
|
||||
builder.truncated = True
|
||||
break
|
||||
finally:
|
||||
close = getattr(workbook, "close", None)
|
||||
if callable(close):
|
||||
close()
|
||||
if not builder.has_content:
|
||||
return _finalize_preview_text("Empty workbook", builder.truncated)
|
||||
return builder.finish()
|
||||
|
||||
|
||||
def _preview_pptx(raw: bytes) -> tuple[str, bool]:
|
||||
_preflight_office_archive("pptx", raw)
|
||||
try:
|
||||
presentation = _load_presentation_ctor()(io.BytesIO(raw))
|
||||
except ImportError:
|
||||
raise
|
||||
except Exception as exc: # pragma: no cover - library-specific failure mode
|
||||
raise ValueError("Unable to read PPTX preview") from exc
|
||||
builder = _PreviewBuilder()
|
||||
for slide_index, slide in enumerate(presentation.slides, start=1):
|
||||
if slide_index > MAX_PPTX_PREVIEW_SLIDES:
|
||||
builder.truncated = True
|
||||
break
|
||||
if not builder.start_section() or not builder.append_text(f"Slide {slide_index}"):
|
||||
break
|
||||
shapes_seen = 0
|
||||
has_text = False
|
||||
for shape in slide.shapes:
|
||||
shapes_seen += 1
|
||||
if shapes_seen > MAX_PPTX_PREVIEW_SHAPES_PER_SLIDE:
|
||||
builder.truncated = True
|
||||
break
|
||||
text = getattr(shape, "text", "")
|
||||
if not _normalise_preview_text(text):
|
||||
continue
|
||||
if not builder.start_line() or not _append_normalized_preview_text(builder, text):
|
||||
break
|
||||
has_text = True
|
||||
if builder.truncated:
|
||||
break
|
||||
if not has_text and (not builder.start_line() or not builder.append_text("(empty slide)")):
|
||||
break
|
||||
if not builder.has_content:
|
||||
return _finalize_preview_text("Empty presentation", builder.truncated)
|
||||
return builder.finish()
|
||||
|
||||
|
||||
def preview_office_document(path: str | Path, raw: bytes) -> dict:
|
||||
office_format = _office_format_for_path(path)
|
||||
if office_format not in CLAIMED_OFFICE_FORMATS:
|
||||
raise ValueError(f"Unsupported Office format: {path}")
|
||||
|
||||
truncated = False
|
||||
if office_format == "docx":
|
||||
content, editable, reason, truncated = _preview_docx(raw)
|
||||
elif office_format == "xlsx":
|
||||
content, truncated = _preview_xlsx(raw)
|
||||
editable, reason = False, "xlsx preview is read-only in this slice"
|
||||
elif office_format == "pptx":
|
||||
content, truncated = _preview_pptx(raw)
|
||||
editable, reason = False, "pptx preview is read-only in this slice"
|
||||
else: # pragma: no cover - exhaustive guard
|
||||
raise ValueError(f"Unsupported Office format: {path}")
|
||||
|
||||
payload = {
|
||||
"path": str(path),
|
||||
"content": content,
|
||||
"size": len(raw),
|
||||
"lines": _preview_line_count(content),
|
||||
"preview_kind": OFFICE_PREVIEW_KIND,
|
||||
"office_format": office_format,
|
||||
"render_mode": OFFICE_RENDER_MODE,
|
||||
"editable": editable,
|
||||
}
|
||||
if reason:
|
||||
payload["edit_blocked_reason"] = reason
|
||||
if truncated:
|
||||
payload["truncated"] = True
|
||||
return payload
|
||||
|
||||
|
||||
def _docx_bytes_from_text(content: str, current_bytes: bytes | None = None) -> bytes:
|
||||
"""Rebuild a docx from edited plain text, preserving the original package.
|
||||
|
||||
We reload the CURRENT document (styles.xml, docProps/core.xml, theme,
|
||||
settings, sectPr) and replace only the body's paragraph content, rather than
|
||||
starting from python-docx's blank template. Building from the blank template
|
||||
silently wiped author/title/custom styles on an unedited open→save — the
|
||||
same fail-closed round-trip class the sectPr and run-whitespace fixes closed.
|
||||
When current_bytes is unavailable we fall back to a fresh document.
|
||||
|
||||
Content is bounded BEFORE the (quadratic — python-docx scans for sectPr on
|
||||
every add_paragraph) build loop: an editable preview is capped at
|
||||
MAX_OFFICE_PREVIEW_CHARS and MAX_DOCX_PREVIEW_BLOCKS lines, so any legitimate
|
||||
editor save fits well within these bounds. Rejecting oversized input up front
|
||||
prevents a write-surface CPU/RSS DoS (measured ~20s CPU for 50k lines).
|
||||
"""
|
||||
text = str(content or "").replace("\r\n", "\n").replace("\r", "\n")
|
||||
if len(text) > MAX_OFFICE_PREVIEW_CHARS:
|
||||
raise ValueError("DOCX content exceeds the editable size limit")
|
||||
lines = text.split("\n")
|
||||
if len(lines) > MAX_DOCX_PREVIEW_BLOCKS:
|
||||
raise ValueError("DOCX content exceeds the editable paragraph limit")
|
||||
|
||||
if current_bytes is not None:
|
||||
try:
|
||||
document = _load_docx_document()(io.BytesIO(current_bytes))
|
||||
except ImportError:
|
||||
raise
|
||||
except Exception:
|
||||
document = _load_docx_document()()
|
||||
else:
|
||||
document = _load_docx_document()()
|
||||
body = document._element.body
|
||||
for child in list(body):
|
||||
if child.tag != f"{_WORD_NAMESPACE}sectPr":
|
||||
body.remove(child)
|
||||
for line in lines:
|
||||
document.add_paragraph(line)
|
||||
buffer = io.BytesIO()
|
||||
document.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def save_office_document(path: str | Path, current_bytes: bytes, content: str) -> tuple[dict, bytes]:
|
||||
office_format = _office_format_for_path(path)
|
||||
if office_format != "docx":
|
||||
raise ValueError(f"{office_format or 'office file'} is preview-only in this slice")
|
||||
|
||||
current_preview = preview_office_document(path, current_bytes)
|
||||
if not current_preview.get("editable"):
|
||||
raise ValueError(current_preview.get("edit_blocked_reason") or "DOCX document is not editable")
|
||||
|
||||
# Rebuild from the CURRENT package so styles/docProps/theme survive an
|
||||
# unedited round-trip; the body is still fully replaced and re-verified.
|
||||
saved_bytes = _docx_bytes_from_text(content, current_bytes)
|
||||
saved_preview = preview_office_document(path, saved_bytes)
|
||||
if not saved_preview.get("editable"):
|
||||
raise ValueError(saved_preview.get("edit_blocked_reason") or "Saved DOCX is not editable")
|
||||
return saved_preview, saved_bytes
|
||||
|
|
@ -13940,6 +13940,9 @@ def handle_post(handler, parsed) -> bool:
|
|||
if parsed.path == "/api/file/save":
|
||||
return _handle_file_save(handler, body)
|
||||
|
||||
if parsed.path == "/api/file/office-save":
|
||||
return _handle_office_file_save(handler, body)
|
||||
|
||||
if parsed.path == "/api/file/create":
|
||||
return _handle_file_create(handler, body)
|
||||
|
||||
|
|
@ -15470,6 +15473,10 @@ def _handle_escape_file_read(handler, parsed):
|
|||
return bad(handler, _sanitize_error(exc), 404)
|
||||
except EscapeAuthorizationExpiredError as exc:
|
||||
return bad(handler, _sanitize_error(exc), 403)
|
||||
except ImportError as exc:
|
||||
# Optional Office parsers absent on a lean install — mirror
|
||||
# _handle_file_read: a 503 with the install hint, not a 500 traceback.
|
||||
return bad(handler, _sanitize_error(exc), 503)
|
||||
except ValueError as exc:
|
||||
return bad(handler, _sanitize_error(exc), 404)
|
||||
|
||||
|
|
@ -17331,10 +17338,23 @@ def _handle_file_read(handler, parsed):
|
|||
return bad(handler, "path is required")
|
||||
try:
|
||||
return j(handler, read_file_content(Path(s.workspace), rel))
|
||||
except ImportError as e:
|
||||
return bad(handler, str(e), 503)
|
||||
except (FileNotFoundError, ValueError) as e:
|
||||
return bad(handler, _sanitize_error(e), 404)
|
||||
|
||||
|
||||
def _read_anchored_file_bytes(ws_root: Path, target: Path) -> bytes:
|
||||
fd = open_anchored_fd(ws_root, target, want_dir=False)
|
||||
with os.fdopen(fd, "rb", closefd=True) as fh:
|
||||
st = os.fstat(fh.fileno())
|
||||
if not _stat.S_ISREG(st.st_mode):
|
||||
raise FileNotFoundError(f"Not a file: {target}")
|
||||
if st.st_size > MAX_FILE_BYTES:
|
||||
raise ValueError(f"File too large ({st.st_size} bytes, max {MAX_FILE_BYTES})")
|
||||
return fh.read(MAX_FILE_BYTES + 1)
|
||||
|
||||
|
||||
def _handle_approval_pending(handler, parsed):
|
||||
sid = parse_qs(parsed.query).get("session_id", [""])[0]
|
||||
with _lock:
|
||||
|
|
@ -20800,6 +20820,8 @@ def _handle_file_save(handler, body):
|
|||
return bad(handler, "File not found", 404)
|
||||
if target.is_dir():
|
||||
return bad(handler, "Cannot save: path is a directory")
|
||||
if Path(str(body["path"])).suffix.lower() in {".docx", ".xlsx", ".pptx"}:
|
||||
return bad(handler, "Use /api/file/office-save for Office documents")
|
||||
data = str(body.get("content", "")).encode("utf-8")
|
||||
fd = open_anchored_write_fd(ws_root, target)
|
||||
with os.fdopen(fd, "wb", closefd=True) as fh:
|
||||
|
|
@ -20811,6 +20833,41 @@ def _handle_file_save(handler, body):
|
|||
return bad(handler, _sanitize_error(e))
|
||||
|
||||
|
||||
def _handle_office_file_save(handler, body):
|
||||
try:
|
||||
require(body, "session_id", "path")
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e))
|
||||
try:
|
||||
s = get_session_for_file_ops(body["session_id"])
|
||||
except KeyError:
|
||||
return bad(handler, "Session not found", 404)
|
||||
try:
|
||||
ws_root = Path(s.workspace)
|
||||
target = safe_resolve(ws_root, body["path"])
|
||||
if (ws_root / body["path"]).is_symlink():
|
||||
return bad(handler, "Cannot save to a symlinked entry")
|
||||
if not target.exists():
|
||||
return bad(handler, "File not found", 404)
|
||||
if target.is_dir():
|
||||
return bad(handler, "Cannot save: path is a directory")
|
||||
if Path(str(body["path"])).suffix.lower() not in {".docx", ".xlsx", ".pptx"}:
|
||||
return bad(handler, "Office save is only available for .docx, .xlsx, and .pptx files")
|
||||
from api.office_documents import save_office_document
|
||||
|
||||
current_bytes = _read_anchored_file_bytes(ws_root, target)
|
||||
preview, updated_bytes = save_office_document(body["path"], current_bytes, body.get("content", ""))
|
||||
fd = open_anchored_write_fd(ws_root, target)
|
||||
with os.fdopen(fd, "wb", closefd=True) as fh:
|
||||
fh.write(updated_bytes)
|
||||
preview.update({"ok": True, "path": body["path"], "size": len(updated_bytes)})
|
||||
return j(handler, preview)
|
||||
except ImportError as e:
|
||||
return bad(handler, str(e), 503)
|
||||
except (ValueError, FileNotFoundError, PermissionError, OSError) as e:
|
||||
return bad(handler, _sanitize_error(e))
|
||||
|
||||
|
||||
def _handle_file_create(handler, body):
|
||||
try:
|
||||
require(body, "session_id", "path")
|
||||
|
|
|
|||
|
|
@ -1471,6 +1471,10 @@ def read_file_content(workspace: Path, rel: str) -> dict:
|
|||
if st.st_size > MAX_FILE_BYTES:
|
||||
raise ValueError(f"File too large ({st.st_size} bytes, max {MAX_FILE_BYTES})")
|
||||
raw = fh.read(MAX_FILE_BYTES + 1)
|
||||
if Path(str(rel)).suffix.lower() in {".docx", ".xlsx", ".pptx"}:
|
||||
from api.office_documents import preview_office_document
|
||||
|
||||
return preview_office_document(rel, raw)
|
||||
content = raw.decode('utf-8', errors='replace')
|
||||
return {'path': rel, 'content': content, 'size': len(raw), 'lines': content.count('\n') + 1}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,3 +9,10 @@ pytest-asyncio
|
|||
pytest-shard
|
||||
ruff
|
||||
mcp
|
||||
# Optional Office parsers — runtime-optional (commented in requirements.txt), but
|
||||
# installed here so the workspace Office-preview tests (tests/test_issue540_*.py)
|
||||
# run under the documented ./scripts/test.sh entry point. These files
|
||||
# pytest.importorskip the parsers, so they skip cleanly if these are removed.
|
||||
python-docx
|
||||
openpyxl
|
||||
python-pptx
|
||||
|
|
|
|||
|
|
@ -12,3 +12,7 @@
|
|||
# CPU/RAM metrics on platforms without /proc: pip install psutil>=5.9
|
||||
pyyaml>=6.0
|
||||
cryptography>=42.0
|
||||
# OPTIONAL: Office workspace preview/edit support needs these parsers.
|
||||
# The workspace file routes return 503 with an install hint when they are absent.
|
||||
# Install them only if you want .docx/.xlsx/.pptx preview support:
|
||||
# pip install python-docx>=1.1.2 openpyxl>=3.1.5 python-pptx>=1.0.2
|
||||
|
|
|
|||
|
|
@ -778,7 +778,7 @@ const MD_PREVIEW_RICH_RENDER_MAX_BYTES = 256 * 1024;
|
|||
const MD_PREVIEW_RICH_RENDER_MAX_LINES = 5000;
|
||||
// Binary formats that should download rather than preview
|
||||
const DOWNLOAD_EXTS = new Set([
|
||||
'.docx','.doc','.xlsx','.xls','.pptx','.ppt','.odt','.ods','.odp',
|
||||
'.doc','.xls','.ppt','.odt','.ods','.odp',
|
||||
'.zip','.tar','.gz','.bz2','.7z','.rar',
|
||||
'.exe','.dmg','.pkg','.deb','.rpm',
|
||||
'.woff','.woff2','.ttf','.otf','.eot',
|
||||
|
|
@ -882,6 +882,10 @@ function forceRenderMarkdownPreview(){
|
|||
let _previewCurrentPath = ''; // relative path of currently previewed file
|
||||
let _previewCurrentMode = ''; // 'code' | 'csv' | 'md' | 'image' | 'html' | 'pdf' | 'audio' | 'video'
|
||||
let _previewDirty = false; // true when edits are unsaved
|
||||
let _previewServerEditable = null; // backend editability metadata when available
|
||||
let _previewSaveRoute = '/api/file/save'; // current save adapter for the open preview
|
||||
let _previewOfficeFormat = ''; // current claimed Office format, if any
|
||||
let _previewPreviewKind = ''; // preview family returned by the backend
|
||||
|
||||
function showPreview(mode){
|
||||
// mode: 'code' | 'csv' | 'image' | 'md' | 'html' | 'pdf' | 'audio' | 'video'
|
||||
|
|
@ -908,7 +912,9 @@ function updateEditBtn(){
|
|||
const btn=$('btnEditFile');
|
||||
if(!btn)return;
|
||||
const editable = !_workspacePathIsReadOnly(_previewCurrentPath)
|
||||
&& (_previewCurrentMode==='code'||_previewCurrentMode==='md'||_previewCurrentMode==='csv');
|
||||
&& (_previewServerEditable===null
|
||||
? (_previewCurrentMode==='code'||_previewCurrentMode==='md'||_previewCurrentMode==='csv')
|
||||
: !!_previewServerEditable);
|
||||
btn.style.display = editable?'':'none';
|
||||
const editing = $('previewEditArea').style.display!=='none';
|
||||
btn.innerHTML = editing ? `💾 ${t('save')}` : `✎ ${t('edit')}`;
|
||||
|
|
@ -923,23 +929,34 @@ async function toggleEditMode(){
|
|||
showToast(t('external_link_read_only'), 2000);
|
||||
return;
|
||||
}
|
||||
if(!editing && _previewServerEditable===false){
|
||||
showToast('This Office document is preview-only.', 3000, 'error');
|
||||
return;
|
||||
}
|
||||
if(editing){
|
||||
// Save
|
||||
if(!S.session||!_previewCurrentPath)return;
|
||||
const content=$('previewEditArea').value;
|
||||
try{
|
||||
await api('/api/file/save',{method:'POST',body:JSON.stringify({
|
||||
const saved=await api(_previewSaveRoute||'/api/file/save',{method:'POST',body:JSON.stringify({
|
||||
session_id:S.session.session_id, path:_previewCurrentPath, content
|
||||
})});
|
||||
const savedContent=saved&&typeof saved.content==='string'?saved.content:content;
|
||||
if(saved && typeof saved.editable==='boolean') _previewServerEditable = saved.editable;
|
||||
if(saved && saved.preview_kind) _previewPreviewKind = saved.preview_kind;
|
||||
if(saved && saved.office_format) _previewOfficeFormat = saved.office_format;
|
||||
if(saved && saved.preview_kind==='office' && saved.office_format==='docx'){
|
||||
_previewSaveRoute = '/api/file/office-save';
|
||||
}
|
||||
_previewDirty=false;
|
||||
// Update read-only views AND the cached raw content so a later
|
||||
// "Render as markdown anyway" force-render reflects the just-saved text
|
||||
// (not the stale pre-edit fetch). #3378 review (Codex).
|
||||
_previewRawContent = content;
|
||||
_previewRawContent = savedContent;
|
||||
_previewRawContentPath = _previewCurrentPath;
|
||||
if(_previewCurrentMode==='code') $('previewCode').textContent=content;
|
||||
else if(_previewCurrentMode==='csv') renderCsvPreviewContent(_previewCurrentPath, content);
|
||||
else renderMarkdownPreviewContent({content});
|
||||
if(_previewCurrentMode==='code') $('previewCode').textContent=savedContent;
|
||||
else if(_previewCurrentMode==='csv') renderCsvPreviewContent(_previewCurrentPath, savedContent);
|
||||
else renderMarkdownPreviewContent({content:savedContent});
|
||||
$('previewEditArea').style.display='none';
|
||||
if(_previewCurrentMode==='code') $('previewCode').style.display='';
|
||||
else $('previewMd').style.display='';
|
||||
|
|
@ -1022,6 +1039,11 @@ async function openFile(path, opts={}){
|
|||
return;
|
||||
}
|
||||
|
||||
_previewServerEditable = null;
|
||||
_previewSaveRoute = '/api/file/save';
|
||||
_previewOfficeFormat = '';
|
||||
_previewPreviewKind = '';
|
||||
|
||||
$('previewPathText').textContent=path;
|
||||
$('previewArea').classList.add('visible');
|
||||
$('fileTree').style.display='none';
|
||||
|
|
@ -1114,6 +1136,14 @@ async function openFile(path, opts={}){
|
|||
downloadFile(path);
|
||||
return;
|
||||
}
|
||||
if(data.preview_kind==='office'){
|
||||
_previewRawContent = data.content || '';
|
||||
_previewRawContentPath = path;
|
||||
_previewServerEditable = typeof data.editable === 'boolean' ? data.editable : null;
|
||||
_previewPreviewKind = data.preview_kind || '';
|
||||
_previewOfficeFormat = data.office_format || '';
|
||||
_previewSaveRoute = data.preview_kind==='office' ? '/api/file/office-save' : '/api/file/save';
|
||||
}
|
||||
renderCodePreviewContent(path, data.content);
|
||||
}catch(e){
|
||||
const grant = _workspaceEscapeGrantForPath(path);
|
||||
|
|
|
|||
|
|
@ -198,6 +198,6 @@ def test_csv_preview_preserves_edit_flow():
|
|||
assert '_previewRawContent = content' in csv_render or '_previewRawContent=content' in csv_render, \
|
||||
"renderCsvPreviewContent must cache raw CSV content for the edit flow"
|
||||
# save path re-renders the CSV table for csv mode
|
||||
save_section = src[src.find('async function toggleEditMode'):src.find('async function toggleEditMode') + 1200]
|
||||
assert "renderCsvPreviewContent(_previewCurrentPath, content)" in save_section, \
|
||||
save_section = src[src.find('async function toggleEditMode'):src.find('async function toggleEditMode') + 2200]
|
||||
assert "renderCsvPreviewContent(_previewCurrentPath, savedContent)" in save_section, \
|
||||
"saving an edited CSV must re-render the table, not markdown"
|
||||
|
|
|
|||
|
|
@ -120,10 +120,11 @@ def test_save_updates_cached_raw_content_for_force_render():
|
|||
"""#3378 review (Codex): saving a markdown file from the plain-text fallback
|
||||
must refresh _previewRawContent (and its path) so a later force-render shows the
|
||||
saved text, not the stale pre-edit fetch."""
|
||||
save_idx = WORKSPACE_JS.find("await api('/api/file/save'")
|
||||
save_idx = WORKSPACE_JS.find("const saved=await api(_previewSaveRoute||'/api/file/save'")
|
||||
assert save_idx != -1, "file save call not found"
|
||||
save_block = WORKSPACE_JS[save_idx:save_idx + 600]
|
||||
assert "_previewRawContent = content" in save_block
|
||||
save_block = WORKSPACE_JS[save_idx:save_idx + 1500]
|
||||
assert "const savedContent=saved&&typeof saved.content==='string'?saved.content:content;" in save_block
|
||||
assert "_previewRawContent = savedContent" in save_block
|
||||
assert "_previewRawContentPath = _previewCurrentPath" in save_block
|
||||
|
||||
|
||||
|
|
|
|||
707
tests/test_issue540_workspace_office_documents.py
Normal file
707
tests/test_issue540_workspace_office_documents.py
Normal file
|
|
@ -0,0 +1,707 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# The Office parsers are optional deps (commented-optional in requirements.txt;
|
||||
# installed by CI and requirements-dev.txt). On a lean install they are absent,
|
||||
# so importorskip these modules to skip this file cleanly instead of aborting
|
||||
# collection for the WHOLE suite with a module-level ImportError (repo idiom —
|
||||
# see the ~12 other test files that importorskip an optional dependency).
|
||||
pytest.importorskip("docx")
|
||||
pytest.importorskip("openpyxl")
|
||||
pytest.importorskip("pptx")
|
||||
|
||||
from docx import Document as DocxDocument
|
||||
from docx.oxml import OxmlElement
|
||||
from docx.oxml.ns import qn
|
||||
from openpyxl import Workbook
|
||||
from pptx import Presentation
|
||||
from pptx.util import Inches
|
||||
|
||||
import api.office_documents as office_documents
|
||||
from api.office_documents import (
|
||||
CLAIMED_OFFICE_EXTENSIONS,
|
||||
is_claimed_office_path,
|
||||
preview_office_document,
|
||||
save_office_document,
|
||||
)
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
WORKSPACE_JS = (ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
|
||||
NODE = shutil.which("node")
|
||||
|
||||
|
||||
def _simple_docx_bytes(*paragraphs: str) -> bytes:
|
||||
document = DocxDocument()
|
||||
for paragraph in paragraphs or ("",):
|
||||
document.add_paragraph(paragraph)
|
||||
buffer = io.BytesIO()
|
||||
document.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _rich_docx_bytes() -> bytes:
|
||||
document = DocxDocument()
|
||||
document.add_paragraph("Lead paragraph")
|
||||
table = document.add_table(rows=1, cols=2)
|
||||
table.cell(0, 0).text = "left"
|
||||
table.cell(0, 1).text = "right"
|
||||
document.add_paragraph("Tail paragraph")
|
||||
buffer = io.BytesIO()
|
||||
document.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _formatted_docx_bytes() -> bytes:
|
||||
document = DocxDocument()
|
||||
run = document.add_paragraph().add_run("Styled text")
|
||||
run.bold = True
|
||||
buffer = io.BytesIO()
|
||||
document.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _paragraph_style_docx_bytes(style: str) -> bytes:
|
||||
document = DocxDocument()
|
||||
paragraph = document.add_paragraph("Styled paragraph")
|
||||
properties = paragraph._p.get_or_add_pPr()
|
||||
paragraph_style = OxmlElement("w:pStyle")
|
||||
paragraph_style.set(qn("w:val"), style)
|
||||
properties.append(paragraph_style)
|
||||
buffer = io.BytesIO()
|
||||
document.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _header_docx_bytes() -> bytes:
|
||||
document = DocxDocument()
|
||||
document.add_paragraph("Body text")
|
||||
document.sections[0].header.paragraphs[0].text = "Header text"
|
||||
buffer = io.BytesIO()
|
||||
document.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _custom_section_docx_bytes() -> bytes:
|
||||
document = DocxDocument()
|
||||
document.add_paragraph("Body text")
|
||||
document.sections[0].left_margin = Inches(2)
|
||||
buffer = io.BytesIO()
|
||||
document.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _simple_xlsx_bytes() -> bytes:
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = "Summary"
|
||||
sheet["A1"] = "alpha"
|
||||
sheet["B1"] = "beta"
|
||||
sheet["A2"] = "gamma"
|
||||
buffer = io.BytesIO()
|
||||
workbook.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _simple_pptx_bytes() -> bytes:
|
||||
presentation = Presentation()
|
||||
slide = presentation.slides.add_slide(presentation.slide_layouts[6])
|
||||
box = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1))
|
||||
box.text = "Office preview"
|
||||
buffer = io.BytesIO()
|
||||
presentation.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
def _office_zip_bytes(*members: tuple[str, str | bytes]) -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr("[Content_Types].xml", "<Types/>")
|
||||
for name, payload in members:
|
||||
archive.writestr(name, payload)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _multi_slide_pptx_bytes() -> bytes:
|
||||
presentation = Presentation()
|
||||
for index in range(2):
|
||||
slide = presentation.slides.add_slide(presentation.slide_layouts[6])
|
||||
box = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1))
|
||||
box.text = f"Slide {index + 1}"
|
||||
buffer = io.BytesIO()
|
||||
presentation.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def test_office_registry_claims_docx_xlsx_pptx():
|
||||
assert CLAIMED_OFFICE_EXTENSIONS == {".docx", ".xlsx", ".pptx"}
|
||||
assert is_claimed_office_path("report.docx")
|
||||
assert is_claimed_office_path("budget.xlsx")
|
||||
assert is_claimed_office_path("deck.pptx")
|
||||
|
||||
docx_preview = preview_office_document("report.docx", _simple_docx_bytes("one", "two"))
|
||||
xlsx_preview = preview_office_document("budget.xlsx", _simple_xlsx_bytes())
|
||||
pptx_preview = preview_office_document("deck.pptx", _simple_pptx_bytes())
|
||||
|
||||
assert docx_preview["preview_kind"] == "office"
|
||||
assert xlsx_preview["preview_kind"] == "office"
|
||||
assert pptx_preview["preview_kind"] == "office"
|
||||
assert docx_preview["office_format"] == "docx"
|
||||
assert xlsx_preview["office_format"] == "xlsx"
|
||||
assert pptx_preview["office_format"] == "pptx"
|
||||
assert docx_preview["render_mode"] == "code"
|
||||
assert xlsx_preview["render_mode"] == "code"
|
||||
assert pptx_preview["render_mode"] == "code"
|
||||
assert docx_preview["editable"] is True
|
||||
assert xlsx_preview["editable"] is False
|
||||
assert pptx_preview["editable"] is False
|
||||
|
||||
|
||||
def test_docx_paragraph_projection_round_trips_simple_documents():
|
||||
original_bytes = _simple_docx_bytes("alpha", "beta")
|
||||
preview = preview_office_document("story.docx", original_bytes)
|
||||
|
||||
assert preview["editable"] is True
|
||||
assert preview["content"] == "alpha\nbeta"
|
||||
|
||||
saved_preview, saved_bytes = save_office_document("story.docx", original_bytes, "alpha\nbeta\ngamma")
|
||||
round_trip = DocxDocument(io.BytesIO(saved_bytes))
|
||||
|
||||
assert saved_preview["editable"] is True
|
||||
assert saved_preview["content"] == "alpha\nbeta\ngamma"
|
||||
assert [paragraph.text for paragraph in round_trip.paragraphs] == ["alpha", "beta", "gamma"]
|
||||
|
||||
|
||||
def test_docx_preview_preserves_interleaved_table_order():
|
||||
preview = preview_office_document("report.docx", _rich_docx_bytes())
|
||||
|
||||
assert preview["content"] == "Lead paragraph\nTable 1\nleft\tright\nTail paragraph"
|
||||
|
||||
|
||||
def test_docx_with_explicit_normal_style_stays_editable():
|
||||
original_bytes = _paragraph_style_docx_bytes("Normal")
|
||||
preview = preview_office_document("styled.docx", original_bytes)
|
||||
|
||||
assert preview["editable"] is True
|
||||
assert preview["content"] == "Styled paragraph"
|
||||
|
||||
saved_preview, saved_bytes = save_office_document("styled.docx", original_bytes, "Edited paragraph")
|
||||
round_trip = DocxDocument(io.BytesIO(saved_bytes))
|
||||
|
||||
assert saved_preview["editable"] is True
|
||||
assert saved_preview["content"] == "Edited paragraph"
|
||||
assert [paragraph.text for paragraph in round_trip.paragraphs] == ["Edited paragraph"]
|
||||
|
||||
|
||||
def test_docx_with_non_default_paragraph_style_stays_preview_only():
|
||||
preview = preview_office_document("heading.docx", _paragraph_style_docx_bytes("Heading1"))
|
||||
|
||||
assert preview["editable"] is False
|
||||
assert preview.get("edit_blocked_reason")
|
||||
|
||||
|
||||
def test_docx_preview_truncation_disables_editing(monkeypatch):
|
||||
monkeypatch.setattr(office_documents, "MAX_DOCX_PREVIEW_BLOCKS", 1)
|
||||
monkeypatch.setattr(
|
||||
office_documents,
|
||||
"_docx_editability",
|
||||
lambda _document: pytest.fail("truncated docx preview should not run full editability scan"),
|
||||
)
|
||||
|
||||
preview = preview_office_document("story.docx", _simple_docx_bytes("alpha", "beta"))
|
||||
|
||||
assert preview["truncated"] is True
|
||||
assert preview["editable"] is False
|
||||
assert preview["edit_blocked_reason"] == "docx preview exceeds safe limits"
|
||||
assert office_documents.OFFICE_PREVIEW_TRUNCATED_NOTICE in preview["content"]
|
||||
|
||||
|
||||
def test_docx_preview_char_budget_disables_editing(monkeypatch):
|
||||
monkeypatch.setattr(office_documents, "MAX_OFFICE_PREVIEW_CHARS", 5)
|
||||
monkeypatch.setattr(
|
||||
office_documents,
|
||||
"_docx_editability",
|
||||
lambda _document: pytest.fail("char-budget truncation should not run full editability scan"),
|
||||
)
|
||||
|
||||
preview = preview_office_document("story.docx", _simple_docx_bytes("alphabet", "beta"))
|
||||
|
||||
assert preview["truncated"] is True
|
||||
assert preview["editable"] is False
|
||||
assert preview["edit_blocked_reason"] == "docx preview exceeds safe limits"
|
||||
assert office_documents.OFFICE_PREVIEW_TRUNCATED_NOTICE in preview["content"]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "member_name", "loader_attr"),
|
||||
[
|
||||
("story.docx", "word/document.xml", "_load_docx_document"),
|
||||
("budget.xlsx", "xl/sharedStrings.xml", "_load_workbook_reader"),
|
||||
("deck.pptx", "ppt/media/image1.png", "_load_presentation_ctor"),
|
||||
],
|
||||
)
|
||||
def test_office_preview_preflight_rejects_zip_bombs_before_loader(
|
||||
monkeypatch, path: str, member_name: str, loader_attr: str
|
||||
):
|
||||
monkeypatch.setattr(office_documents, "MAX_OFFICE_ARCHIVE_TOTAL_UNCOMPRESSED_BYTES", 1024)
|
||||
monkeypatch.setattr(
|
||||
office_documents,
|
||||
loader_attr,
|
||||
lambda: lambda *_args, **_kwargs: pytest.fail("office parser should not run after archive preflight rejects the payload"),
|
||||
)
|
||||
|
||||
raw = _office_zip_bytes((member_name, "A" * 4096))
|
||||
|
||||
with pytest.raises(ValueError, match="safe archive limits"):
|
||||
preview_office_document(path, raw)
|
||||
|
||||
def test_office_preview_preflight_rejects_path_traversal_before_loader(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
office_documents,
|
||||
"_load_docx_document",
|
||||
lambda: lambda *_args, **_kwargs: pytest.fail("docx parser should not run after path validation fails"),
|
||||
)
|
||||
|
||||
raw = _office_zip_bytes(("../word/document.xml", "payload"))
|
||||
|
||||
with pytest.raises(ValueError, match="safe archive limits"):
|
||||
preview_office_document("story.docx", raw)
|
||||
|
||||
def test_xlsx_preview_preflight_rejects_oversized_shared_strings_before_loader(monkeypatch):
|
||||
monkeypatch.setattr(office_documents, "MAX_XLSX_ARCHIVE_SHARED_STRINGS_BYTES", 64)
|
||||
monkeypatch.setattr(
|
||||
office_documents,
|
||||
"_load_workbook_reader",
|
||||
lambda: lambda *_args, **_kwargs: pytest.fail("xlsx parser should not run after sharedStrings preflight rejects the payload"),
|
||||
)
|
||||
|
||||
raw = _office_zip_bytes(("xl/sharedStrings.xml", "A" * 256))
|
||||
|
||||
with pytest.raises(ValueError, match="safe archive limits"):
|
||||
preview_office_document("budget.xlsx", raw)
|
||||
|
||||
def test_xlsx_preview_preflight_measures_actual_bytes_when_zip_claims_zero(monkeypatch):
|
||||
class FakeInfo:
|
||||
filename = "xl/sharedStrings.xml"
|
||||
file_size = 0
|
||||
compress_size = 1
|
||||
|
||||
@staticmethod
|
||||
def is_dir():
|
||||
return False
|
||||
|
||||
class FakeArchive:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def infolist():
|
||||
return [FakeInfo()]
|
||||
|
||||
@staticmethod
|
||||
def open(_info):
|
||||
return io.BytesIO(b"A" * 2048)
|
||||
|
||||
monkeypatch.setattr(office_documents, "MAX_OFFICE_ARCHIVE_TOTAL_UNCOMPRESSED_BYTES", 1024)
|
||||
monkeypatch.setattr(office_documents.zipfile, "ZipFile", lambda *_args, **_kwargs: FakeArchive())
|
||||
monkeypatch.setattr(
|
||||
office_documents,
|
||||
"_load_workbook_reader",
|
||||
lambda: lambda *_args, **_kwargs: pytest.fail("xlsx parser should not run when actual inflated bytes exceed the preflight cap"),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="safe archive limits"):
|
||||
preview_office_document("budget.xlsx", b"placeholder")
|
||||
|
||||
|
||||
def _office_state_block() -> str:
|
||||
marker = "if(data.preview_kind==='office'){"
|
||||
start = WORKSPACE_JS.find(marker)
|
||||
assert start >= 0, "office preview state block not found in static/workspace.js"
|
||||
depth = 0
|
||||
for idx in range(start, len(WORKSPACE_JS)):
|
||||
char = WORKSPACE_JS[idx]
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return WORKSPACE_JS[start:idx + 1]
|
||||
raise AssertionError("could not find balanced office preview state block")
|
||||
|
||||
|
||||
def _run_office_state_block(payload: dict) -> dict:
|
||||
js = (
|
||||
"const data = " + json.dumps(payload) + ";\n"
|
||||
+ "const path = 'report';\n"
|
||||
+ "let _previewRawContent = null;\n"
|
||||
+ "let _previewRawContentPath = null;\n"
|
||||
+ "let _previewServerEditable = null;\n"
|
||||
+ "let _previewPreviewKind = '';\n"
|
||||
+ "let _previewOfficeFormat = '';\n"
|
||||
+ "let _previewSaveRoute = '/api/file/save';\n"
|
||||
+ _office_state_block()
|
||||
+ "\nconsole.log(JSON.stringify({_previewRawContent,_previewRawContentPath,_previewServerEditable,_previewPreviewKind,_previewOfficeFormat,_previewSaveRoute}));\n"
|
||||
)
|
||||
result = subprocess.run([NODE, "-e", js], check=True, capture_output=True, text=True, timeout=30)
|
||||
return json.loads(result.stdout.strip())
|
||||
|
||||
|
||||
def test_xlsx_stays_preview_only():
|
||||
path = "budget.xlsx"
|
||||
raw = _simple_xlsx_bytes()
|
||||
|
||||
preview = preview_office_document(path, raw)
|
||||
|
||||
assert preview["preview_kind"] == "office"
|
||||
assert preview["editable"] is False
|
||||
assert preview.get("edit_blocked_reason")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
save_office_document(path, raw, "edited text")
|
||||
|
||||
|
||||
def test_xlsx_preview_is_bounded(monkeypatch):
|
||||
monkeypatch.setattr(office_documents, "MAX_XLSX_PREVIEW_ROWS_PER_SHEET", 1)
|
||||
|
||||
preview = preview_office_document("budget.xlsx", _simple_xlsx_bytes())
|
||||
|
||||
assert preview["truncated"] is True
|
||||
assert office_documents.OFFICE_PREVIEW_TRUNCATED_NOTICE in preview["content"]
|
||||
|
||||
|
||||
def test_xlsx_preview_stops_when_char_budget_is_exhausted(monkeypatch):
|
||||
class FakeSheet:
|
||||
title = "Summary"
|
||||
max_row = 9_999
|
||||
max_column = 9_999
|
||||
|
||||
def iter_rows(self, *, values_only, max_row, max_col):
|
||||
assert values_only is True
|
||||
assert max_row == office_documents.MAX_XLSX_PREVIEW_ROWS_PER_SHEET
|
||||
assert max_col == office_documents.MAX_XLSX_PREVIEW_CELLS_PER_SHEET
|
||||
yield ("x" * 200, "y" * 200)
|
||||
pytest.fail("xlsx preview should stop after the preview budget is exhausted")
|
||||
|
||||
class FakeWorkbook:
|
||||
def __init__(self):
|
||||
self.worksheets = [FakeSheet()]
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(office_documents, "MAX_OFFICE_PREVIEW_CHARS", 32)
|
||||
monkeypatch.setattr(office_documents, "_preflight_office_archive", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(office_documents, "_load_workbook_reader", lambda: lambda *_args, **_kwargs: FakeWorkbook())
|
||||
|
||||
preview = preview_office_document("budget.xlsx", b"placeholder")
|
||||
|
||||
assert preview["truncated"] is True
|
||||
assert office_documents.OFFICE_PREVIEW_TRUNCATED_NOTICE in preview["content"]
|
||||
|
||||
|
||||
def test_pptx_stays_preview_only():
|
||||
path = "deck.pptx"
|
||||
raw = _simple_pptx_bytes()
|
||||
|
||||
preview = preview_office_document(path, raw)
|
||||
|
||||
assert preview["preview_kind"] == "office"
|
||||
assert preview["editable"] is False
|
||||
assert preview.get("edit_blocked_reason")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
save_office_document(path, raw, "edited text")
|
||||
|
||||
|
||||
def test_pptx_preview_is_bounded(monkeypatch):
|
||||
monkeypatch.setattr(office_documents, "MAX_PPTX_PREVIEW_SLIDES", 1)
|
||||
|
||||
preview = preview_office_document("deck.pptx", _multi_slide_pptx_bytes())
|
||||
|
||||
assert preview["truncated"] is True
|
||||
assert office_documents.OFFICE_PREVIEW_TRUNCATED_NOTICE in preview["content"]
|
||||
|
||||
|
||||
def test_rich_docx_stays_preview_only():
|
||||
path = "rich.docx"
|
||||
raw = _rich_docx_bytes()
|
||||
|
||||
preview = preview_office_document(path, raw)
|
||||
|
||||
assert preview["preview_kind"] == "office"
|
||||
assert preview["editable"] is False
|
||||
assert preview.get("edit_blocked_reason")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
save_office_document(path, raw, "edited text")
|
||||
|
||||
|
||||
def test_formatted_docx_stays_preview_only():
|
||||
path = "formatted.docx"
|
||||
raw = _formatted_docx_bytes()
|
||||
|
||||
preview = preview_office_document(path, raw)
|
||||
|
||||
assert preview["preview_kind"] == "office"
|
||||
assert preview["editable"] is False
|
||||
assert preview.get("edit_blocked_reason")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
save_office_document(path, raw, "edited text")
|
||||
|
||||
def test_header_docx_stays_preview_only():
|
||||
path = "headed.docx"
|
||||
raw = _header_docx_bytes()
|
||||
|
||||
preview = preview_office_document(path, raw)
|
||||
|
||||
assert preview["preview_kind"] == "office"
|
||||
assert preview["editable"] is False
|
||||
assert preview.get("edit_blocked_reason")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
save_office_document(path, raw, "edited text")
|
||||
|
||||
|
||||
def test_custom_section_docx_stays_preview_only():
|
||||
path = "custom-section.docx"
|
||||
raw = _custom_section_docx_bytes()
|
||||
|
||||
preview = preview_office_document(path, raw)
|
||||
|
||||
assert preview["preview_kind"] == "office"
|
||||
assert preview["editable"] is False
|
||||
assert preview.get("edit_blocked_reason")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
save_office_document(path, raw, "edited text")
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_workspace_office_state_block_routes_all_office_formats_through_office_save():
|
||||
xlsx_state = _run_office_state_block(
|
||||
{
|
||||
"preview_kind": "office",
|
||||
"office_format": "xlsx",
|
||||
"editable": False,
|
||||
"content": "sheet preview",
|
||||
}
|
||||
)
|
||||
docx_state = _run_office_state_block(
|
||||
{
|
||||
"preview_kind": "office",
|
||||
"office_format": "docx",
|
||||
"editable": True,
|
||||
"content": "doc preview",
|
||||
}
|
||||
)
|
||||
|
||||
assert xlsx_state["_previewSaveRoute"] == "/api/file/office-save"
|
||||
assert xlsx_state["_previewServerEditable"] is False
|
||||
assert xlsx_state["_previewOfficeFormat"] == "xlsx"
|
||||
assert docx_state["_previewSaveRoute"] == "/api/file/office-save"
|
||||
assert docx_state["_previewServerEditable"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regression coverage for the four blockers found in the v0.51.802 gate review
|
||||
# (all reproduced against legitimate-but-common inputs the earlier adversarial
|
||||
# rounds never exercised).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _write_only_xlsx_bytes() -> bytes:
|
||||
"""A workbook produced by openpyxl's streaming writer.
|
||||
|
||||
``Workbook(write_only=True)`` omits the ``<dimension>`` record, so when the
|
||||
preview re-opens it in read_only mode ``sheet.max_row``/``max_column`` are
|
||||
``None``. This is exactly issue #540's own use case (agents streaming large
|
||||
sheets) and previously crashed the preview with a ``TypeError`` 500.
|
||||
"""
|
||||
workbook = Workbook(write_only=True)
|
||||
sheet = workbook.create_sheet("Streamed")
|
||||
sheet.append(["alpha", "beta", "gamma"])
|
||||
sheet.append(["one", "two", "three"])
|
||||
buffer = io.BytesIO()
|
||||
workbook.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _multi_run_docx_bytes() -> bytes:
|
||||
"""A docx whose paragraph spans multiple runs with a run-boundary space.
|
||||
|
||||
``add_run("Hello ") + add_run("world")`` is ubiquitous in real Word files
|
||||
(and trivially produced by agent code). Each run is a separate ``<w:t>``
|
||||
node; the preview previously ``.strip()``-ed each node and concatenated with
|
||||
no separator, corrupting the text to "Helloworld".
|
||||
"""
|
||||
document = DocxDocument()
|
||||
paragraph = document.add_paragraph()
|
||||
paragraph.add_run("Hello ")
|
||||
paragraph.add_run("world")
|
||||
document.add_paragraph("Second paragraph.")
|
||||
buffer = io.BytesIO()
|
||||
document.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def test_write_only_xlsx_without_dimension_previews_without_crash():
|
||||
"""BLOCKING 1: dimension-less xlsx (max_row/max_column None) must not 500."""
|
||||
raw = _write_only_xlsx_bytes()
|
||||
# Must not raise TypeError — the pre-fix code did `min(None, CAP)`.
|
||||
preview = preview_office_document("streamed.xlsx", raw)
|
||||
assert preview["preview_kind"] == "office"
|
||||
assert preview["office_format"] == "xlsx"
|
||||
# The streamed cell values must actually appear in the preview.
|
||||
assert "alpha" in preview["content"]
|
||||
assert "three" in preview["content"]
|
||||
|
||||
|
||||
def test_multi_run_docx_preserves_run_boundary_whitespace():
|
||||
"""BLOCKING 2 (preview): run-boundary space must survive extraction."""
|
||||
raw = _multi_run_docx_bytes()
|
||||
preview = preview_office_document("multi-run.docx", raw)
|
||||
assert "Hello world" in preview["content"]
|
||||
assert "Helloworld" not in preview["content"]
|
||||
|
||||
|
||||
def test_multi_run_docx_round_trips_text_identically_on_save():
|
||||
"""BLOCKING 2 (save): opening the editor and saving unchanged preview text
|
||||
must NOT corrupt the on-disk document. This is the data-loss path: the
|
||||
editor textarea is prefilled from the preview text, so a no-op save writes
|
||||
the preview text back verbatim."""
|
||||
raw = _multi_run_docx_bytes()
|
||||
preview = preview_office_document("multi-run.docx", raw)
|
||||
assert preview["editable"] is True
|
||||
|
||||
# Simulate a save with the UNMODIFIED preview text (the no-op-save path).
|
||||
_saved_preview, saved_bytes = save_office_document(
|
||||
"multi-run.docx", raw, preview["content"]
|
||||
)
|
||||
|
||||
# Read the saved bytes back and confirm the paragraph text is intact.
|
||||
reloaded = DocxDocument(io.BytesIO(saved_bytes))
|
||||
paragraph_texts = [p.text for p in reloaded.paragraphs]
|
||||
assert "Hello world" in paragraph_texts
|
||||
assert "Helloworld" not in paragraph_texts
|
||||
|
||||
|
||||
def test_corrupt_archive_member_raises_value_error_not_unhandled():
|
||||
"""BLOCKING 3: a valid zip central directory with corrupt member DATA (bad
|
||||
CRC / truncated) must raise the module's ValueError (handled as a clean
|
||||
error) instead of an unhandled BadZipFile/zlib.error that escapes as a 500."""
|
||||
raw = bytearray(_simple_docx_bytes("hello"))
|
||||
# Corrupt the deflate stream bytes without touching the central directory,
|
||||
# so the archive opens but a member.read() fails a CRC/zlib check. Flip a
|
||||
# run of bytes in the middle of the compressed payload region.
|
||||
start = len(raw) // 3
|
||||
for i in range(start, start + 64):
|
||||
raw[i] ^= 0xFF
|
||||
with pytest.raises(ValueError):
|
||||
preview_office_document("corrupt.docx", bytes(raw))
|
||||
|
||||
|
||||
def test_archive_limit_error_still_raised_over_corrupt_catch():
|
||||
"""BLOCKING 3 guard: the limit-exceeded ValueError raised INSIDE the member
|
||||
read loop must not be swallowed by the new corrupt-member except clause
|
||||
(it catches only decompression exceptions, never ValueError)."""
|
||||
# A docx whose main document part inflates beyond the per-member cap.
|
||||
document = DocxDocument()
|
||||
document.add_paragraph("x" * 5_000)
|
||||
buffer = io.BytesIO()
|
||||
document.save(buffer)
|
||||
raw = buffer.getvalue()
|
||||
# Temporarily force a tiny member cap so the real (well-formed) archive
|
||||
# trips the limit path — proving the limit ValueError propagates.
|
||||
original = office_documents.MAX_OFFICE_ARCHIVE_MEMBER_BYTES
|
||||
office_documents.MAX_OFFICE_ARCHIVE_MEMBER_BYTES = 10
|
||||
try:
|
||||
with pytest.raises(ValueError):
|
||||
preview_office_document("big.docx", raw)
|
||||
finally:
|
||||
office_documents.MAX_OFFICE_ARCHIVE_MEMBER_BYTES = original
|
||||
|
||||
|
||||
def _authored_docx_bytes() -> bytes:
|
||||
"""A docx carrying core-properties metadata and body text (like every
|
||||
Word-authored file)."""
|
||||
document = DocxDocument()
|
||||
document.core_properties.author = "Jane Author"
|
||||
document.core_properties.title = "Quarterly Report"
|
||||
document.add_paragraph("Body text here")
|
||||
buffer = io.BytesIO()
|
||||
document.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def test_unedited_docx_save_preserves_core_properties():
|
||||
"""Round-trip fidelity: an unedited open->save must NOT wipe author/title.
|
||||
|
||||
The pre-fix save rebuilt the package from python-docx's blank template, so
|
||||
metadata/styles were silently lost even when the user changed nothing (the
|
||||
editor prefills the textarea from the preview). Rebuilding from the current
|
||||
package preserves docProps/styles/theme."""
|
||||
raw = _authored_docx_bytes()
|
||||
preview = preview_office_document("authored.docx", raw)
|
||||
assert preview["editable"] is True
|
||||
|
||||
_saved_preview, saved_bytes = save_office_document(
|
||||
"authored.docx", raw, preview["content"]
|
||||
)
|
||||
reloaded = DocxDocument(io.BytesIO(saved_bytes))
|
||||
assert reloaded.core_properties.author == "Jane Author"
|
||||
assert reloaded.core_properties.title == "Quarterly Report"
|
||||
assert "Body text here" in [p.text for p in reloaded.paragraphs]
|
||||
|
||||
|
||||
def test_oversized_docx_save_content_rejected_before_build():
|
||||
"""Write-surface DoS guard: content exceeding the editable char/line caps is
|
||||
rejected up front, before the quadratic add_paragraph build loop runs."""
|
||||
raw = _authored_docx_bytes()
|
||||
# Exceed the paragraph-count cap (MAX_DOCX_PREVIEW_BLOCKS).
|
||||
too_many_lines = "\n".join("x" for _ in range(office_documents.MAX_DOCX_PREVIEW_BLOCKS + 50))
|
||||
with pytest.raises(ValueError):
|
||||
save_office_document("authored.docx", raw, too_many_lines)
|
||||
# Exceed the char cap with few lines.
|
||||
too_many_chars = "a" * (office_documents.MAX_OFFICE_PREVIEW_CHARS + 1)
|
||||
with pytest.raises(ValueError):
|
||||
save_office_document("authored.docx", raw, too_many_chars)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"paragraphs",
|
||||
[
|
||||
["Hello", "", ""], # trailing blank paragraphs (normal Word convention)
|
||||
["", "World"], # leading blank paragraph
|
||||
["a", "", "b"], # interior blank (already worked — guard against regression)
|
||||
],
|
||||
)
|
||||
def test_unedited_docx_save_preserves_edge_blank_paragraphs(paragraphs):
|
||||
"""Round-trip fidelity: leading/trailing blank paragraphs must survive an
|
||||
unedited open->save. _finalize_preview_text used to .strip() the whole
|
||||
preview, and since the editor textarea is prefilled from the preview text,
|
||||
a no-op save silently dropped edge blank paragraphs. The docx preview path
|
||||
now preserves edge whitespace (strip_edges=False)."""
|
||||
document = DocxDocument()
|
||||
for text in paragraphs:
|
||||
document.add_paragraph(text)
|
||||
buffer = io.BytesIO()
|
||||
document.save(buffer)
|
||||
raw = buffer.getvalue()
|
||||
|
||||
preview = preview_office_document("edge.docx", raw)
|
||||
assert preview["editable"] is True
|
||||
|
||||
_saved_preview, saved_bytes = save_office_document("edge.docx", raw, preview["content"])
|
||||
reloaded = [p.text for p in DocxDocument(io.BytesIO(saved_bytes)).paragraphs]
|
||||
assert reloaded == paragraphs
|
||||
240
tests/test_issue540_workspace_office_preview.py
Normal file
240
tests/test_issue540_workspace_office_preview.py
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
|
||||
# Optional Office parsers — importorskip so a lean install (parsers absent)
|
||||
# skips this file cleanly rather than aborting collection suite-wide.
|
||||
pytest.importorskip("docx")
|
||||
pytest.importorskip("openpyxl")
|
||||
|
||||
from docx import Document as DocxDocument
|
||||
from openpyxl import Workbook
|
||||
|
||||
import api.office_documents as office_documents
|
||||
import api.routes as routes
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
WORKSPACE_JS = (ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
|
||||
NODE = shutil.which("node")
|
||||
|
||||
|
||||
def _simple_docx_bytes(*paragraphs: str) -> bytes:
|
||||
document = DocxDocument()
|
||||
for paragraph in paragraphs or ("",):
|
||||
document.add_paragraph(paragraph)
|
||||
buffer = io.BytesIO()
|
||||
document.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _simple_xlsx_bytes() -> bytes:
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet["A1"] = "alpha"
|
||||
buffer = io.BytesIO()
|
||||
workbook.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _extract_workspace_block(start_marker: str, end_marker: str) -> str:
|
||||
start = WORKSPACE_JS.find(start_marker)
|
||||
assert start >= 0, f"{start_marker!r} not found in static/workspace.js"
|
||||
end = WORKSPACE_JS.find(end_marker, start)
|
||||
assert end >= 0, f"{end_marker!r} not found after {start_marker!r}"
|
||||
return WORKSPACE_JS[start:end]
|
||||
|
||||
|
||||
def _extract_workspace_function(name: str) -> str:
|
||||
marker = f"async function {name}("
|
||||
start = WORKSPACE_JS.find(marker)
|
||||
assert start >= 0, f"{name} not found in static/workspace.js"
|
||||
params_depth = 0
|
||||
body_start = -1
|
||||
for idx in range(start, len(WORKSPACE_JS)):
|
||||
char = WORKSPACE_JS[idx]
|
||||
if char == "(":
|
||||
params_depth += 1
|
||||
elif char == ")":
|
||||
params_depth -= 1
|
||||
elif char == "{" and params_depth == 0:
|
||||
body_start = idx
|
||||
break
|
||||
assert body_start >= 0, f"could not find function body for {name}"
|
||||
depth = 0
|
||||
for idx in range(body_start, len(WORKSPACE_JS)):
|
||||
char = WORKSPACE_JS[idx]
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return WORKSPACE_JS[start:idx + 1]
|
||||
raise AssertionError(f"could not find balanced function body for {name}")
|
||||
|
||||
|
||||
def _patch_file_ops(monkeypatch, workspace: Path):
|
||||
session = SimpleNamespace(workspace=str(workspace))
|
||||
captured = {}
|
||||
|
||||
def fake_j(handler, payload, status=200, extra_headers=None):
|
||||
captured["ok"] = payload
|
||||
captured["status"] = status
|
||||
return True
|
||||
|
||||
def fake_bad(handler, message, status=400):
|
||||
captured["bad"] = (message, status)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(routes, "get_session_for_file_ops", lambda sid: session)
|
||||
monkeypatch.setattr(routes, "j", fake_j)
|
||||
monkeypatch.setattr(routes, "bad", fake_bad)
|
||||
return captured
|
||||
|
||||
|
||||
def test_file_read_returns_office_preview_payload_for_docx(tmp_path, monkeypatch):
|
||||
workspace = tmp_path / "ws"
|
||||
workspace.mkdir()
|
||||
(workspace / "story.docx").write_bytes(_simple_docx_bytes("alpha", "beta"))
|
||||
captured = _patch_file_ops(monkeypatch, workspace)
|
||||
|
||||
routes._handle_file_read(object(), urlparse("/api/file?session_id=sid&path=story.docx"))
|
||||
|
||||
payload = captured["ok"]
|
||||
assert payload["preview_kind"] == "office"
|
||||
assert payload["office_format"] == "docx"
|
||||
assert payload["render_mode"] == "code"
|
||||
assert payload["editable"] is True
|
||||
assert payload["content"] == "alpha\nbeta"
|
||||
|
||||
|
||||
def test_file_read_returns_503_when_office_parsers_are_missing(tmp_path, monkeypatch):
|
||||
workspace = tmp_path / "ws"
|
||||
workspace.mkdir()
|
||||
(workspace / "story.docx").write_bytes(_simple_docx_bytes("alpha"))
|
||||
captured = _patch_file_ops(monkeypatch, workspace)
|
||||
|
||||
def fail_read(*_args, **_kwargs):
|
||||
raise ImportError(office_documents.OFFICE_DEPENDENCY_HINT)
|
||||
|
||||
monkeypatch.setattr(routes, "read_file_content", fail_read)
|
||||
|
||||
routes._handle_file_read(object(), urlparse("/api/file?session_id=sid&path=story.docx"))
|
||||
|
||||
assert captured["bad"] == (office_documents.OFFICE_DEPENDENCY_HINT, 503)
|
||||
|
||||
|
||||
def test_office_save_route_accepts_safe_docx_and_rejects_preview_only_formats(tmp_path, monkeypatch):
|
||||
workspace = tmp_path / "ws"
|
||||
workspace.mkdir()
|
||||
docx_path = workspace / "story.docx"
|
||||
docx_path.write_bytes(_simple_docx_bytes("alpha", "beta"))
|
||||
xlsx_path = workspace / "budget.xlsx"
|
||||
xlsx_path.write_bytes(_simple_xlsx_bytes())
|
||||
|
||||
captured = _patch_file_ops(monkeypatch, workspace)
|
||||
|
||||
routes._handle_office_file_save(
|
||||
object(),
|
||||
{"session_id": "sid", "path": "story.docx", "content": "alpha\nbeta\ngamma"},
|
||||
)
|
||||
saved = captured["ok"]
|
||||
assert saved["preview_kind"] == "office"
|
||||
assert saved["office_format"] == "docx"
|
||||
assert saved["editable"] is True
|
||||
assert saved["content"] == "alpha\nbeta\ngamma"
|
||||
assert [paragraph.text for paragraph in DocxDocument(io.BytesIO(docx_path.read_bytes())).paragraphs] == [
|
||||
"alpha",
|
||||
"beta",
|
||||
"gamma",
|
||||
]
|
||||
|
||||
captured.clear()
|
||||
routes._handle_office_file_save(
|
||||
object(),
|
||||
{"session_id": "sid", "path": "budget.xlsx", "content": "ignored"},
|
||||
)
|
||||
assert captured["bad"][1] == 400
|
||||
assert "preview-only" in captured["bad"][0]
|
||||
|
||||
|
||||
def test_office_save_route_returns_503_when_office_parsers_are_missing(tmp_path, monkeypatch):
|
||||
workspace = tmp_path / "ws"
|
||||
workspace.mkdir()
|
||||
(workspace / "story.docx").write_bytes(_simple_docx_bytes("alpha"))
|
||||
captured = _patch_file_ops(monkeypatch, workspace)
|
||||
|
||||
def fail_save(*_args, **_kwargs):
|
||||
raise ImportError(office_documents.OFFICE_DEPENDENCY_HINT)
|
||||
|
||||
monkeypatch.setattr(office_documents, "save_office_document", fail_save)
|
||||
|
||||
routes._handle_office_file_save(
|
||||
object(),
|
||||
{"session_id": "sid", "path": "story.docx", "content": "beta"},
|
||||
)
|
||||
|
||||
assert captured["bad"] == (office_documents.OFFICE_DEPENDENCY_HINT, 503)
|
||||
|
||||
|
||||
def test_workspace_js_docx_routes_through_file_read_not_download():
|
||||
assert "'.docx'" not in WORKSPACE_JS.split("const DOWNLOAD_EXTS = new Set([", 1)[1].split("]);", 1)[0]
|
||||
assert "'.xlsx'" not in WORKSPACE_JS.split("const DOWNLOAD_EXTS = new Set([", 1)[1].split("]);", 1)[0]
|
||||
assert "'.pptx'" not in WORKSPACE_JS.split("const DOWNLOAD_EXTS = new Set([", 1)[1].split("]);", 1)[0]
|
||||
assert "'.doc'" in WORKSPACE_JS
|
||||
assert "'.xls'" in WORKSPACE_JS
|
||||
assert "'.ppt'" in WORKSPACE_JS
|
||||
assert "data.preview_kind==='office'" in WORKSPACE_JS
|
||||
assert "/api/file/office-save" in WORKSPACE_JS
|
||||
|
||||
|
||||
def test_workspace_js_edit_button_uses_server_editable_flag():
|
||||
assert "_previewServerEditable" in WORKSPACE_JS
|
||||
assert "_previewServerEditable===false" in WORKSPACE_JS
|
||||
assert "This Office document is preview-only." in WORKSPACE_JS
|
||||
assert "_previewSaveRoute" in WORKSPACE_JS
|
||||
assert "_previewSaveRoute = data.preview_kind==='office' ? '/api/file/office-save' : '/api/file/save';" in WORKSPACE_JS
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_openfile_download_only_path_preserves_existing_office_save_route():
|
||||
js = (
|
||||
_extract_workspace_block("const DOWNLOAD_EXTS = new Set([", "function fileExt")
|
||||
+ "function fileExt(p){ const i=p.lastIndexOf('.'); return i>=0?p.slice(i).toLowerCase():''; }\n"
|
||||
+ _extract_workspace_function("openFile")
|
||||
+ "\nconst S = { session: { session_id: 'sid-1' } };\n"
|
||||
+ "let _previewServerEditable = true;\n"
|
||||
+ "let _previewSaveRoute = '/api/file/office-save';\n"
|
||||
+ "let _previewOfficeFormat = 'docx';\n"
|
||||
+ "let _previewPreviewKind = 'office';\n"
|
||||
+ "let downloaded = null;\n"
|
||||
+ "function downloadFile(path){ downloaded = path; }\n"
|
||||
+ "(async()=>{ await openFile('legacy.doc'); console.log(JSON.stringify({downloaded,_previewServerEditable,_previewSaveRoute,_previewOfficeFormat,_previewPreviewKind})); })();\n"
|
||||
)
|
||||
result = subprocess.run([NODE, "-e", js], check=True, capture_output=True, text=True, timeout=30)
|
||||
state = json.loads(result.stdout.strip().splitlines()[-1])
|
||||
|
||||
assert state["downloaded"] == "legacy.doc"
|
||||
assert state["_previewSaveRoute"] == "/api/file/office-save"
|
||||
assert state["_previewServerEditable"] is True
|
||||
assert state["_previewOfficeFormat"] == "docx"
|
||||
assert state["_previewPreviewKind"] == "office"
|
||||
|
||||
|
||||
def test_pdf_preview_path_is_unchanged():
|
||||
assert "showPreview('pdf')" in WORKSPACE_JS
|
||||
assert "_workspaceRouteForPath(path, 'raw', {inline:true})" in WORKSPACE_JS
|
||||
|
||||
|
||||
def test_zip_and_legacy_office_formats_still_download():
|
||||
download_block = WORKSPACE_JS.split("const DOWNLOAD_EXTS = new Set([", 1)[1].split("]);", 1)[0]
|
||||
for ext in [".doc", ".xls", ".ppt", ".odt", ".ods", ".odp", ".zip", ".tar"]:
|
||||
assert ext in download_block
|
||||
Loading…
Reference in a new issue