fix(libreoffice): address import review comments

This commit is contained in:
yuhao
2026-04-29 12:15:40 +00:00
parent 202241885b
commit ed894b917c
4 changed files with 76 additions and 13 deletions
@@ -156,7 +156,11 @@ def _apply_metadata(project: Dict[str, Any], meta_xml: str, source_path: str) ->
if not meta_xml:
return
root = ET.fromstring(meta_xml)
try:
root = ET.fromstring(meta_xml)
except ET.ParseError as e:
raise ValueError(f"Invalid ODF meta.xml in: {source_path}") from e
mappings = {
"title": ("dc", "title"),
"author": ("dc", "creator"),
@@ -295,7 +299,7 @@ def _parse_calc_row(row_elem: ET.Element, row_index: int) -> Dict[str, Dict[str,
def _cell_data(cell_elem: ET.Element) -> Optional[Dict[str, Any]]:
value_type = _attr(cell_elem, "office", "value-type") or "string"
formula = _attr(cell_elem, "table", "formula")
formula = _normalize_formula(_attr(cell_elem, "table", "formula"))
text = _text_content(cell_elem)
numeric_value = _attr(cell_elem, "office", "value")
@@ -322,6 +326,16 @@ def _cell_data(cell_elem: ET.Element) -> Optional[Dict[str, Any]]:
return data
def _normalize_formula(formula: Optional[str]) -> Optional[str]:
"""Strip ODF formula namespace prefixes before storing project state."""
if formula is None:
return None
for prefix in ("of:", "oooc:"):
if formula.startswith(prefix):
return formula[len(prefix):]
return formula
def _parse_impress_content(root: ET.Element) -> List[Dict[str, Any]]:
body = root.find(_q("office", "body"))
presentation = body.find(_q("office", "presentation")) if body is not None else None
@@ -4,9 +4,9 @@
| File | Test Classes | Test Count | Focus |
|------|-------------|------------|-------|
| `test_core.py` | 7 | 97 | Unit tests for document, import, Writer, Calc, Impress, styles, session |
| `test_core.py` | 7 | 99 | Unit tests for document, import, Writer, Calc, Impress, styles, session |
| `test_full_e2e.py` | 17 | 73 | E2E workflows: ODF ZIP structure, XML validity, import, export formats, CLI subprocess |
| **Total** | **24** | **170** | |
| **Total** | **24** | **172** | |
## Unit Tests (`test_core.py`)
@@ -23,14 +23,16 @@ All unit tests use synthetic/in-memory data only. No LibreOffice installation re
- List available profiles
- Metadata populated on creation (title, created date)
### TestImport (7 tests)
### TestImport (9 tests)
- List supported import formats, including ODF and Microsoft Office extensions
- Import generated ODT into Writer project content
- Import generated ODS into Calc sheets and cells
- Normalize imported Calc formulas before re-export
- Import generated ODP into Impress slides
- Route DOCX import through LibreOffice conversion without requiring LibreOffice in unit tests
- Reject unsupported import formats
- Reject invalid ODF files with a clean error
- Reject malformed ODF meta.xml with a clean error
### TestWriter (18 tests)
- Add paragraph with default and custom style
@@ -171,6 +173,6 @@ E2E tests produce real ODF files (ODT/ODS/ODP) and validate ZIP structure, XML c
## Test Results
```
test_core.py: 97 passed in 0.15s
test_core.py: 99 passed in 0.15s
test_full_e2e.py: 73 passed in 58.33s
```
@@ -8,6 +8,7 @@ import os
import sys
import tempfile
import shutil
import zipfile
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
@@ -35,6 +36,7 @@ from cli_anything.libreoffice.core.styles import (
from cli_anything.libreoffice.core.session import Session
from cli_anything.libreoffice.core.export import to_odt, to_ods, to_odp
from cli_anything.libreoffice.core import importer as importer_mod
from cli_anything.libreoffice.utils.odf_utils import parse_odf
# ── Document Tests ───────────────────────────────────────────────
@@ -186,6 +188,25 @@ class TestImport:
assert imported["sheets"][0]["cells"]["B1"]["value"] == 42.0
assert imported["sheets"][0]["cells"]["B1"]["type"] == "float"
def test_import_calc_formula_normalizes_odf_prefix(self):
proj = create_document(doc_type="calc", name="formula_calc")
set_cell(proj, "A1", "1", cell_type="float")
set_cell(proj, "A2", "2", cell_type="float")
set_cell(proj, "A3", "0", cell_type="float", formula="=A1+A2")
with tempfile.TemporaryDirectory() as tmp:
source = os.path.join(tmp, "formula.ods")
roundtrip = os.path.join(tmp, "roundtrip.ods")
to_ods(proj, source)
imported = importer_mod.import_document(source)
to_ods(imported, roundtrip)
content_xml = parse_odf(roundtrip)["content_xml"]
formula = imported["sheets"][0]["cells"]["A3"]["formula"]
assert formula == "=A1+A2"
assert 'table:formula="of:=A1+A2"' in content_xml
assert "of:of:=" not in content_xml
def test_import_impress_odp(self):
proj = create_document(doc_type="impress", name="import_impress")
add_slide(proj, title="Intro", content="Welcome")
@@ -246,6 +267,25 @@ class TestImport:
finally:
os.unlink(path)
def test_reject_malformed_odf_meta_xml(self):
proj = create_document(doc_type="writer")
add_paragraph(proj, text="body")
with tempfile.TemporaryDirectory() as tmp:
source = os.path.join(tmp, "source.odt")
malformed = os.path.join(tmp, "malformed.odt")
to_odt(proj, source)
with zipfile.ZipFile(source, "r") as zin, zipfile.ZipFile(malformed, "w") as zout:
for info in zin.infolist():
data = zin.read(info.filename)
if info.filename == "meta.xml":
data = b"<broken>"
zout.writestr(info, data)
with pytest.raises(ValueError, match="Invalid ODF meta.xml"):
importer_mod.import_document(malformed)
# ── Writer Tests ─────────────────────────────────────────────────
@@ -14,6 +14,7 @@ import os
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Optional
@@ -112,20 +113,26 @@ def convert(
tempfile.TemporaryDirectory(prefix="lo-runtime-") as runtime_dir, \
tempfile.TemporaryDirectory(prefix="lo-config-") as config_dir, \
tempfile.TemporaryDirectory(prefix="lo-cache-") as cache_dir:
os.chmod(runtime_dir, 0o700)
env = os.environ.copy()
env.update({
"XDG_RUNTIME_DIR": runtime_dir,
"XDG_CONFIG_HOME": config_dir,
"XDG_CACHE_HOME": cache_dir,
})
if os.name == "posix":
try:
os.chmod(runtime_dir, 0o700)
except OSError:
pass
env.update({
"XDG_RUNTIME_DIR": runtime_dir,
"XDG_CONFIG_HOME": config_dir,
"XDG_CACHE_HOME": cache_dir,
})
profile_uri = Path(profile_dir).resolve().as_uri()
cmd = [
lo,
"--headless",
"--nologo",
"--nofirststartwizard",
f"-env:UserInstallation=file://{profile_dir}",
f"-env:UserInstallation={profile_uri}",
"--convert-to", output_format,
"--outdir", output_dir,
input_path,