rtflite 2.5.3__tar.gz → 2.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. {rtflite-2.5.3 → rtflite-2.6.0}/PKG-INFO +14 -15
  2. rtflite-2.6.0/pyproject.toml +131 -0
  3. rtflite-2.5.3/pyproject.toml → rtflite-2.6.0/pyproject.toml.orig +16 -22
  4. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/convert.py +141 -69
  5. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/encode.py +30 -9
  6. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/encoding/unified_encoder.py +6 -6
  7. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/type_guards.py +5 -7
  8. rtflite-2.5.3/.gitignore +0 -28
  9. rtflite-2.5.3/.python-version +0 -1
  10. rtflite-2.5.3/CHANGELOG.md +0 -338
  11. rtflite-2.5.3/LICENSE +0 -20
  12. rtflite-2.5.3/LICENSES_THIRD_PARTY +0 -20
  13. {rtflite-2.5.3 → rtflite-2.6.0}/README.md +0 -0
  14. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/__init__.py +0 -0
  15. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/assemble.py +0 -0
  16. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/attributes.py +0 -0
  17. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/core/__init__.py +0 -0
  18. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/core/config.py +0 -0
  19. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/core/constants.py +0 -0
  20. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/data/HAMD17.parquet +0 -0
  21. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/data/__init__.py +0 -0
  22. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/data/adae.parquet +0 -0
  23. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/data/adsl.parquet +0 -0
  24. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/data/baseline.parquet +0 -0
  25. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/data/tbl1.parquet +0 -0
  26. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/data/tbl2.parquet +0 -0
  27. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/data/tbl3.parquet +0 -0
  28. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/dictionary/__init__.py +0 -0
  29. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/dictionary/color_table.py +0 -0
  30. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/dictionary/libreoffice.py +0 -0
  31. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/dictionary/unicode_latex.py +0 -0
  32. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/encoding/__init__.py +0 -0
  33. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/encoding/base.py +0 -0
  34. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/encoding/engine.py +0 -0
  35. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/encoding/renderer.py +0 -0
  36. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/figure.py +0 -0
  37. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/fonts/README.md +0 -0
  38. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/fonts/__init__.py +0 -0
  39. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/fonts/cros/Caladea-Regular.ttf +0 -0
  40. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/fonts/cros/Carlito-Regular.ttf +0 -0
  41. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/fonts/cros/Gelasio-Regular.ttf +0 -0
  42. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/fonts/liberation/LiberationMono-Regular.ttf +0 -0
  43. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/fonts/liberation/LiberationSans-Regular.ttf +0 -0
  44. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/fonts/liberation/LiberationSerif-Regular.ttf +0 -0
  45. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/fonts_mapping.py +0 -0
  46. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/input.py +0 -0
  47. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/pagination/__init__.py +0 -0
  48. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/pagination/core.py +0 -0
  49. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/pagination/processor.py +0 -0
  50. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/pagination/strategies/__init__.py +0 -0
  51. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/pagination/strategies/base.py +0 -0
  52. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/pagination/strategies/defaults.py +0 -0
  53. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/pagination/strategies/grouping.py +0 -0
  54. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/pagination/strategies/registry.py +0 -0
  55. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/row.py +0 -0
  56. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/rtf/__init__.py +0 -0
  57. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/rtf/syntax.py +0 -0
  58. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/services/__init__.py +0 -0
  59. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/services/color_service.py +0 -0
  60. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/services/document_service.py +0 -0
  61. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/services/encoding_service.py +0 -0
  62. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/services/figure_service.py +0 -0
  63. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/services/grouping_service.py +0 -0
  64. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/services/text_conversion_service.py +0 -0
  65. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/strwidth.py +0 -0
  66. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/text_conversion/__init__.py +0 -0
  67. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/text_conversion/converter.py +0 -0
  68. {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/text_conversion/symbols.py +0 -0
@@ -1,38 +1,37 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rtflite
3
- Version: 2.5.3
3
+ Version: 2.6.0
4
4
  Summary: Lightweight RTF composer for Python
5
- Project-URL: Homepage, https://pharmaverse.github.io/rtflite/
6
- Project-URL: Documentation, https://pharmaverse.github.io/rtflite/
7
- Project-URL: Repository, https://github.com/pharmaverse/rtflite
8
- Project-URL: Issues, https://github.com/pharmaverse/rtflite/issues
9
- Project-URL: Changelog, https://github.com/pharmaverse/rtflite/blob/main/CHANGELOG.md
5
+ Author: Yilong Zhang, Nan Xiao
10
6
  Author-email: Yilong Zhang <elong0527@gmail.com>, Nan Xiao <me@nanx.me>
11
- License-File: LICENSE
12
- License-File: LICENSES_THIRD_PARTY
7
+ License-Expression: MIT
13
8
  Classifier: Development Status :: 3 - Alpha
14
9
  Classifier: Intended Audience :: Developers
10
+ Classifier: Intended Audience :: Science/Research
15
11
  Classifier: Intended Audience :: Healthcare Industry
16
12
  Classifier: Intended Audience :: Information Technology
17
- Classifier: Intended Audience :: Science/Research
18
- Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Topic :: Text Processing
14
+ Classifier: Topic :: Office/Business :: Office Suites
19
15
  Classifier: Programming Language :: Python :: 3
20
16
  Classifier: Programming Language :: Python :: 3.10
21
17
  Classifier: Programming Language :: Python :: 3.11
22
18
  Classifier: Programming Language :: Python :: 3.12
23
19
  Classifier: Programming Language :: Python :: 3.13
24
20
  Classifier: Programming Language :: Python :: 3.14
25
- Classifier: Topic :: Office/Business :: Office Suites
26
- Classifier: Topic :: Text Processing
27
- Requires-Python: >=3.10
28
21
  Requires-Dist: narwhals>=1.0.0
29
22
  Requires-Dist: pillow>=8.0.0
30
23
  Requires-Dist: polars>=1.0.0
31
24
  Requires-Dist: pydantic>=2.0.0
25
+ Requires-Dist: python-docx>=1.0.0 ; extra == 'docx'
26
+ Requires-Dist: pypdf>=5.0.0 ; extra == 'pdf'
27
+ Requires-Python: >=3.10
28
+ Project-URL: Homepage, https://pharmaverse.github.io/rtflite/
29
+ Project-URL: Documentation, https://pharmaverse.github.io/rtflite/
30
+ Project-URL: Repository, https://github.com/pharmaverse/rtflite
31
+ Project-URL: Issues, https://github.com/pharmaverse/rtflite/issues
32
+ Project-URL: Changelog, https://github.com/pharmaverse/rtflite/blob/main/CHANGELOG.md
32
33
  Provides-Extra: docx
33
- Requires-Dist: python-docx>=1.0.0; extra == 'docx'
34
34
  Provides-Extra: pdf
35
- Requires-Dist: pypdf>=5.0.0; extra == 'pdf'
36
35
  Description-Content-Type: text/markdown
37
36
 
38
37
  # rtflite <img src="https://github.com/pharmaverse/rtflite/raw/main/docs/assets/logo.png" align="right" width="120" />
@@ -0,0 +1,131 @@
1
+ [project]
2
+ name = "rtflite"
3
+ version = "2.6.0"
4
+ description = "Lightweight RTF composer for Python"
5
+ dependencies = [
6
+ "narwhals>=1.0.0",
7
+ "pillow>=8.0.0",
8
+ "polars>=1.0.0",
9
+ "pydantic>=2.0.0",
10
+ ]
11
+ readme = "README.md"
12
+ license = "MIT"
13
+ requires-python = ">= 3.10"
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Intended Audience :: Science/Research",
18
+ "Intended Audience :: Healthcare Industry",
19
+ "Intended Audience :: Information Technology",
20
+ "Topic :: Text Processing",
21
+ "Topic :: Office/Business :: Office Suites",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Programming Language :: Python :: 3.14",
28
+ ]
29
+
30
+ [[project.authors]]
31
+ name = "Yilong Zhang"
32
+ email = "elong0527@gmail.com"
33
+
34
+ [[project.authors]]
35
+ name = "Nan Xiao"
36
+ email = "me@nanx.me"
37
+
38
+ [project.optional-dependencies]
39
+ docx = ["python-docx>=1.0.0"]
40
+ pdf = ["pypdf>=5.0.0"]
41
+
42
+ [project.urls]
43
+ Homepage = "https://pharmaverse.github.io/rtflite/"
44
+ Documentation = "https://pharmaverse.github.io/rtflite/"
45
+ Repository = "https://github.com/pharmaverse/rtflite"
46
+ Issues = "https://github.com/pharmaverse/rtflite/issues"
47
+ Changelog = "https://github.com/pharmaverse/rtflite/blob/main/CHANGELOG.md"
48
+
49
+ [build-system]
50
+ requires = ["uv_build>=0.12.10,<0.13.0"]
51
+ build-backend = "uv_build"
52
+
53
+ [tool.uv.build-backend]
54
+ source-exclude = [
55
+ "/docs",
56
+ "/tests",
57
+ "/scripts",
58
+ "/example_output",
59
+ "/htmlcov",
60
+ "/.github",
61
+ "/site",
62
+ "/zensical.toml",
63
+ "/AGENTS.md",
64
+ "/CLAUDE.md",
65
+ "/CONTRIBUTING.md",
66
+ "/CODE_OF_CONDUCT.md",
67
+ "/coverage.xml",
68
+ "/uv.lock",
69
+ "**/.gitignore",
70
+ "/.claude",
71
+ "/.mypy_cache",
72
+ "/.pytest_cache",
73
+ "/.ruff_cache",
74
+ "/.coverage",
75
+ ]
76
+ wheel-exclude = [
77
+ "/docs",
78
+ "/tests",
79
+ "/scripts",
80
+ "/example_output",
81
+ "/htmlcov",
82
+ "/.github",
83
+ "/site",
84
+ "/zensical.toml",
85
+ "/AGENTS.md",
86
+ "/CLAUDE.md",
87
+ "/CONTRIBUTING.md",
88
+ "/CODE_OF_CONDUCT.md",
89
+ "/coverage.xml",
90
+ "/uv.lock",
91
+ "**/.gitignore",
92
+ "/.claude",
93
+ "/.mypy_cache",
94
+ "/.pytest_cache",
95
+ "/.ruff_cache",
96
+ "/.coverage",
97
+ ]
98
+
99
+ [tool.ruff.lint]
100
+ select = [
101
+ "E",
102
+ "F",
103
+ "UP",
104
+ "B",
105
+ "SIM",
106
+ "I",
107
+ ]
108
+
109
+ [[tool.mypy.overrides]]
110
+ module = [
111
+ "docx",
112
+ "docx.*",
113
+ "pypdf",
114
+ "pypdf.*",
115
+ ]
116
+ ignore_missing_imports = true
117
+
118
+ [dependency-groups]
119
+ dev = [
120
+ "griffe-pydantic>=1.1.8",
121
+ "isort>=5.13.2",
122
+ "markdown-exec[ansi]>=1.11.0",
123
+ "matplotlib>=3.10.5",
124
+ "mkdocstrings-python>=1.12.2",
125
+ "mypy>=1.17.1",
126
+ "pytest>=8.3.3",
127
+ "pytest-cov>=6.0.0",
128
+ "pytest-r-snapshot>=0.1.0",
129
+ "ruff>=0.7.4",
130
+ "zensical>=0.0.15",
131
+ ]
@@ -1,18 +1,19 @@
1
1
  [project]
2
2
  name = "rtflite"
3
- version = "2.5.3"
3
+ version = "2.6.0"
4
4
  description = "Lightweight RTF composer for Python"
5
5
  authors = [
6
6
  { name = "Yilong Zhang", email = "elong0527@gmail.com" },
7
7
  { name = "Nan Xiao", email = "me@nanx.me" },
8
8
  ]
9
9
  dependencies = [
10
- "pydantic>=2.0.0",
11
- "pillow>=8.0.0",
12
10
  "narwhals>=1.0.0",
11
+ "pillow>=8.0.0",
13
12
  "polars>=1.0.0",
13
+ "pydantic>=2.0.0",
14
14
  ]
15
15
  readme = "README.md"
16
+ license = "MIT"
16
17
  requires-python = ">= 3.10"
17
18
  classifiers = [
18
19
  "Development Status :: 3 - Alpha",
@@ -25,8 +26,6 @@ classifiers = [
25
26
  "Topic :: Text Processing",
26
27
  "Topic :: Office/Business :: Office Suites",
27
28
 
28
- "License :: OSI Approved :: MIT License",
29
-
30
29
  "Programming Language :: Python :: 3",
31
30
  "Programming Language :: Python :: 3.10",
32
31
  "Programming Language :: Python :: 3.11",
@@ -47,11 +46,11 @@ Issues = "https://github.com/pharmaverse/rtflite/issues"
47
46
  Changelog = "https://github.com/pharmaverse/rtflite/blob/main/CHANGELOG.md"
48
47
 
49
48
  [build-system]
50
- requires = ["hatchling"]
51
- build-backend = "hatchling.build"
49
+ requires = ["uv_build>=0.12.10,<0.13.0"]
50
+ build-backend = "uv_build"
52
51
 
53
- [tool.hatch.build.targets.sdist]
54
- exclude = [
52
+ [tool.uv.build-backend]
53
+ source-exclude = [
55
54
  "/docs",
56
55
  "/tests",
57
56
  "/scripts",
@@ -73,9 +72,7 @@ exclude = [
73
72
  "/.ruff_cache",
74
73
  "/.coverage",
75
74
  ]
76
-
77
- [tool.hatch.build.targets.wheel]
78
- exclude = [
75
+ wheel-exclude = [
79
76
  "/docs",
80
77
  "/tests",
81
78
  "/scripts",
@@ -100,20 +97,17 @@ exclude = [
100
97
 
101
98
  [dependency-groups]
102
99
  dev = [
100
+ "griffe-pydantic>=1.1.8",
101
+ "isort>=5.13.2",
102
+ "markdown-exec[ansi]>=1.11.0",
103
+ "matplotlib>=3.10.5",
104
+ "mkdocstrings-python>=1.12.2",
105
+ "mypy>=1.17.1",
103
106
  "pytest>=8.3.3",
104
107
  "pytest-cov>=6.0.0",
105
108
  "pytest-r-snapshot>=0.1.0",
106
- "zensical>=0.0.15",
107
- "mkdocstrings-python>=1.12.2",
108
- "markdown-exec[ansi]>=1.11.0",
109
- "griffe-pydantic>=1.1.8",
110
109
  "ruff>=0.7.4",
111
- "isort>=5.13.2",
112
- "mypy>=1.17.1",
113
- "matplotlib>=3.10.5",
114
- "pandas>=2.2.3",
115
- "pyarrow >= 21.0.0; python_version < '3.14'",
116
- "pyarrow >= 22.0.0; python_version >= '3.14'",
110
+ "zensical>=0.0.15",
117
111
  ]
118
112
 
119
113
  [tool.ruff.lint]
@@ -3,11 +3,11 @@ import platform
3
3
  import re
4
4
  import shutil
5
5
  import subprocess
6
+ import tempfile
6
7
  from collections.abc import Sequence
8
+ from math import isfinite
7
9
  from pathlib import Path
8
10
 
9
- from packaging import version
10
-
11
11
  from .dictionary.libreoffice import DEFAULT_PATHS, MIN_VERSION
12
12
 
13
13
 
@@ -27,17 +27,28 @@ class LibreOfficeConverter:
27
27
  This makes it suitable for server environments and automated workflows.
28
28
  """
29
29
 
30
- def __init__(self, executable_path: str | Path | None = None):
30
+ def __init__(
31
+ self,
32
+ executable_path: str | Path | None = None,
33
+ *,
34
+ timeout: float | None = 120,
35
+ ) -> None:
31
36
  """Initialize converter with optional executable path.
32
37
 
33
38
  Args:
34
39
  executable_path: Path (or executable name) to LibreOffice. If None,
35
40
  searches standard installation locations for each platform.
41
+ timeout: Maximum seconds for each LibreOffice process, including
42
+ the version check. Defaults to 120. Use None to disable.
36
43
 
37
44
  Raises:
38
45
  FileNotFoundError: If LibreOffice executable cannot be found.
39
- ValueError: If LibreOffice version is below minimum requirement.
46
+ ValueError: If timeout is invalid or the version cannot be parsed.
47
+ RuntimeError: If LibreOffice is too old, fails to start, or times out.
40
48
  """
49
+ if timeout is not None and (not isfinite(timeout) or timeout <= 0):
50
+ raise ValueError("timeout must be a positive finite number or None.")
51
+ self.timeout = timeout
41
52
  self.executable_path = self._resolve_executable_path(executable_path)
42
53
 
43
54
  self._verify_version()
@@ -53,15 +64,15 @@ class LibreOfficeConverter:
53
64
  executable = os.fspath(executable_path)
54
65
  expanded = os.path.expanduser(executable)
55
66
  candidate = Path(expanded)
56
- candidate_str = str(candidate)
57
67
  looks_like_path = (
58
- candidate.is_absolute()
59
- or os.sep in candidate_str
60
- or (os.altsep is not None and os.altsep in candidate_str)
68
+ isinstance(executable_path, Path)
69
+ or candidate.is_absolute()
70
+ or os.sep in expanded
71
+ or (os.altsep is not None and os.altsep in expanded)
61
72
  )
62
73
  if looks_like_path:
63
74
  if candidate.is_file():
64
- return candidate
75
+ return candidate.absolute()
65
76
  raise FileNotFoundError(
66
77
  f"LibreOffice executable not found at: {candidate}."
67
78
  )
@@ -69,16 +80,20 @@ class LibreOfficeConverter:
69
80
  resolved_executable = shutil.which(executable)
70
81
  if resolved_executable is None:
71
82
  raise FileNotFoundError(f"Can't find LibreOffice executable: {executable}.")
72
- return Path(resolved_executable)
83
+ return Path(resolved_executable).absolute()
73
84
 
74
85
  def _find_executable(self) -> Path | None:
75
86
  """Find LibreOffice executable in default locations."""
76
- for name in ("soffice", "libreoffice"):
87
+ system = platform.system()
88
+ # Windows needs the console launcher to capture output and wait for exit.
89
+ names: tuple[str, ...] = ("soffice", "libreoffice")
90
+ if system == "Windows":
91
+ names = ("soffice.com", *names)
92
+ for name in names:
77
93
  resolved = shutil.which(name)
78
94
  if resolved is not None:
79
- return Path(resolved)
95
+ return Path(resolved).absolute()
80
96
 
81
- system = platform.system()
82
97
  if system not in DEFAULT_PATHS:
83
98
  raise RuntimeError(f"Unsupported operating system: {system}.")
84
99
 
@@ -88,34 +103,53 @@ class LibreOfficeConverter:
88
103
  return candidate
89
104
  return None
90
105
 
91
- def _verify_version(self):
92
- """Verify LibreOffice version meets minimum requirement."""
106
+ def _run_command(
107
+ self, cmd: list[str], action: str
108
+ ) -> subprocess.CompletedProcess[str]:
109
+ """Run LibreOffice with bounded execution and useful diagnostics."""
93
110
  try:
94
- result = subprocess.run(
95
- [str(self.executable_path), "--version"],
111
+ return subprocess.run(
112
+ cmd,
96
113
  capture_output=True,
97
114
  text=True,
115
+ errors="replace",
98
116
  check=True,
117
+ timeout=self.timeout,
99
118
  )
100
- version_str = result.stdout.strip()
101
- # Extract version number (for example, "24.8.3.2" from the output)
102
- match = re.search(r"LibreOffice (\d+\.\d+)", version_str)
103
- if not match:
104
- raise ValueError(
105
- f"Can't parse LibreOffice version from: {version_str}."
106
- )
107
-
108
- current_version = version.parse(match.group(1))
109
- min_version = version.parse(MIN_VERSION)
110
-
111
- if current_version < min_version:
112
- raise RuntimeError(
113
- "LibreOffice version "
114
- f"{current_version} is below minimum required "
115
- f"version {min_version}."
116
- )
119
+ except subprocess.TimeoutExpired as e:
120
+ raise RuntimeError(
121
+ f"LibreOffice {action} timed out after {self.timeout} seconds."
122
+ ) from e
117
123
  except subprocess.CalledProcessError as e:
118
- raise RuntimeError(f"Failed to get LibreOffice version: {e}.") from e
124
+ raise RuntimeError(
125
+ f"LibreOffice {action} failed (exit code {e.returncode}):\n"
126
+ f"Command output: {e.stdout}\n"
127
+ f"Error output: {e.stderr}"
128
+ ) from e
129
+ except OSError as e:
130
+ raise RuntimeError(
131
+ f"Failed to run LibreOffice at {self.executable_path}: {e}"
132
+ ) from e
133
+
134
+ def _verify_version(self) -> None:
135
+ """Verify LibreOffice version meets minimum requirement."""
136
+ result = self._run_command(
137
+ [str(self.executable_path), "--version"], "version check"
138
+ )
139
+ version_str = result.stdout.strip()
140
+ match = re.search(r"LibreOffice\s+(\d+\.\d+(?:\.\d+)*)", version_str)
141
+ if not match:
142
+ raise ValueError(f"Can't parse LibreOffice version from: {version_str}.")
143
+
144
+ # LibreOffice uses numeric versions, including calendar versions (24+).
145
+ # Compare numerically without requiring the optional packaging library.
146
+ current_version = tuple(int(part) for part in match.group(1).split("."))
147
+ min_version = tuple(int(part) for part in MIN_VERSION.split("."))
148
+ if current_version < min_version:
149
+ raise RuntimeError(
150
+ f"LibreOffice version {match.group(1)} is below minimum required "
151
+ f"version {MIN_VERSION}."
152
+ )
119
153
 
120
154
  def convert(
121
155
  self,
@@ -142,16 +176,30 @@ class LibreOfficeConverter:
142
176
  - `'html'`: HTML Document
143
177
  - `'odt'`: OpenDocument Text
144
178
  - `'txt'`: Plain Text
179
+
180
+ Also accepts LibreOffice's `extension:filter[:options]` syntax,
181
+ for example `'pdf:writer_pdf_Export'` or
182
+ `'txt:Text (encoded):UTF8'`. Filter names and options are passed
183
+ through unchanged; the extension determines the output filename.
145
184
  overwrite: If `True`, overwrites existing files in output directory.
146
- If `False`, raises error if output file already exists.
185
+ If `False`, raises error if output file already exists. Existing
186
+ output is preserved if LibreOffice fails to convert the input.
147
187
 
148
188
  Returns:
149
189
  Path | Sequence[Path]: For single file input, returns Path to the
150
190
  converted file. For multiple files, returns list of Paths.
151
191
 
152
192
  Raises:
193
+ FileNotFoundError: If an input file is missing or is not a file.
153
194
  FileExistsError: If output file exists and overwrite=False.
154
- RuntimeError: If LibreOffice conversion fails.
195
+ ValueError: If format does not start with a valid file extension.
196
+ RuntimeError: If LibreOffice conversion fails or times out.
197
+
198
+ Note:
199
+ Each file is converted with a temporary, isolated LibreOffice user
200
+ profile, independent of an open desktop session or other conversions.
201
+ Personal LibreOffice settings and extensions are not used. Batch
202
+ inputs are processed sequentially, with a new process for each file.
155
203
 
156
204
  Examples:
157
205
  Single file conversion:
@@ -178,21 +226,26 @@ class LibreOfficeConverter:
178
226
  print(f"Converted: {path}")
179
227
  ```
180
228
  """
181
- output_dir = Path(os.path.expanduser(str(output_dir)))
182
- if not output_dir.exists():
183
- output_dir.mkdir(parents=True)
229
+ extension = format.split(":", 1)[0]
230
+ if not re.fullmatch(r"[A-Za-z0-9]+", extension):
231
+ raise ValueError(
232
+ "format must be an extension or extension:filter[:options], "
233
+ f"got {format!r}."
234
+ )
235
+ output_dir = Path(output_dir).expanduser()
236
+ output_dir.mkdir(parents=True, exist_ok=True)
184
237
 
185
238
  # Handle single input file
186
239
  if isinstance(input_files, (str, Path)):
187
- input_path = Path(os.path.expanduser(str(input_files)))
188
- if not input_path.exists():
240
+ input_path = Path(input_files).expanduser()
241
+ if not input_path.is_file():
189
242
  raise FileNotFoundError(f"Input file not found: {input_path}.")
190
243
  return self._convert_single_file(input_path, output_dir, format, overwrite)
191
244
 
192
245
  # Handle multiple input files
193
- input_paths = [Path(os.path.expanduser(str(f))) for f in input_files]
246
+ input_paths = [Path(f).expanduser() for f in input_files]
194
247
  for path in input_paths:
195
- if not path.exists():
248
+ if not path.is_file():
196
249
  raise FileNotFoundError(f"Input file not found: {path}.")
197
250
 
198
251
  return [
@@ -204,7 +257,8 @@ class LibreOfficeConverter:
204
257
  self, input_file: Path, output_dir: Path, format: str, overwrite: bool
205
258
  ) -> Path:
206
259
  """Convert a single file using LibreOffice."""
207
- output_file = output_dir / f"{input_file.stem}.{format}"
260
+ extension = format.split(":", 1)[0]
261
+ output_file = output_dir / f"{input_file.stem}.{extension}"
208
262
 
209
263
  if output_file.exists() and not overwrite:
210
264
  raise FileExistsError(
@@ -212,33 +266,51 @@ class LibreOfficeConverter:
212
266
  "Use overwrite=True to force."
213
267
  )
214
268
 
215
- cmd = [
216
- str(self.executable_path),
217
- "--invisible",
218
- "--headless",
219
- "--nologo",
220
- "--convert-to",
221
- format,
222
- "--outdir",
223
- str(output_dir),
224
- str(input_file),
225
- ]
226
-
227
- try:
228
- result = subprocess.run(cmd, capture_output=True, text=True, check=True)
229
-
230
- if not output_file.exists():
269
+ # Stage output so an old file cannot be mistaken for a successful export.
270
+ # Keep it on the destination filesystem for the final file replacement.
271
+ with tempfile.TemporaryDirectory(prefix=".rtflite-", dir=output_dir) as tmpdir:
272
+ work_dir = Path(tmpdir).resolve()
273
+ converted_dir = work_dir / "output"
274
+ converted_dir.mkdir()
275
+ cmd = [
276
+ str(self.executable_path),
277
+ f"-env:UserInstallation={(work_dir / 'profile').as_uri()}",
278
+ "--headless",
279
+ "--nologo",
280
+ "--norestore",
281
+ "--convert-to",
282
+ format,
283
+ "--outdir",
284
+ str(converted_dir),
285
+ str(input_file.absolute()),
286
+ ]
287
+ result = self._run_command(cmd, "conversion")
288
+ converted_file = converted_dir / output_file.name
289
+ if not converted_file.is_file():
231
290
  raise RuntimeError(
232
291
  f"Conversion failed: Output file not created.\n"
233
292
  f"Command output: {result.stdout}\n"
234
293
  f"Error output: {result.stderr}"
235
294
  )
236
295
 
237
- return output_file
238
-
239
- except subprocess.CalledProcessError as e:
240
- raise RuntimeError(
241
- f"LibreOffice conversion failed:\n"
242
- f"Command output: {e.stdout}\n"
243
- f"Error output: {e.stderr}"
244
- ) from e
296
+ # Include companion files/directories created by formats such as HTML.
297
+ generated_paths = list(converted_dir.iterdir())
298
+ if not overwrite:
299
+ for path in generated_paths:
300
+ destination = output_dir / path.name
301
+ if destination.exists():
302
+ raise FileExistsError(
303
+ f"Output file already exists: {destination}. "
304
+ "Use overwrite=True to force."
305
+ )
306
+ for path in generated_paths:
307
+ if path == converted_file:
308
+ continue
309
+ destination = output_dir / path.name
310
+ if path.is_dir():
311
+ shutil.copytree(path, destination, dirs_exist_ok=overwrite)
312
+ else:
313
+ path.replace(destination)
314
+ converted_file.replace(output_file)
315
+
316
+ return output_file
@@ -168,7 +168,7 @@ class RTFDocument(BaseModel):
168
168
  "header for that section."
169
169
  ),
170
170
  )
171
- rtf_body: RTFBody | Sequence[RTFBody] | None = Field(
171
+ rtf_body: RTFBody | list[RTFBody] | None = Field(
172
172
  default_factory=lambda: RTFBody(),
173
173
  description=(
174
174
  "Table body section settings including column widths and "
@@ -196,6 +196,17 @@ class RTFDocument(BaseModel):
196
196
  return [v]
197
197
  return v
198
198
 
199
+ @field_validator("rtf_body", mode="before")
200
+ def normalize_rtf_body_sequence(cls, v):
201
+ """Convert non-list body sequences to lists before validation."""
202
+ if (
203
+ v is not None
204
+ and not isinstance(v, (RTFBody, list, str, bytes, bytearray))
205
+ and isinstance(v, Sequence)
206
+ ):
207
+ return list(v)
208
+ return v
209
+
199
210
  @model_validator(mode="before")
200
211
  @classmethod
201
212
  def validate_dataframe(cls, values):
@@ -301,6 +312,15 @@ class RTFDocument(BaseModel):
301
312
  ):
302
313
  self._validate_section_columns(section_df, section_body, i)
303
314
  else:
315
+ if self.rtf_body is None:
316
+ raise ValueError("When df is a single DataFrame, rtf_body is required")
317
+ if isinstance(self.rtf_body, list):
318
+ if len(self.rtf_body) != 1:
319
+ raise ValueError(
320
+ "When df is a single DataFrame, rtf_body must be a "
321
+ "single RTFBody"
322
+ )
323
+ self.rtf_body = self.rtf_body[0]
304
324
  # Single section validation (existing logic)
305
325
  self._validate_section_columns(self.df, self.rtf_body, 0)
306
326
 
@@ -554,9 +574,9 @@ class RTFDocument(BaseModel):
554
574
  ```
555
575
 
556
576
  Note:
557
- LibreOffice may create a companion directory (for example
558
- `report.html_files`) for embedded resources. When present, it is moved
559
- alongside the requested output path.
577
+ LibreOffice may create companion image files or directories for
578
+ embedded resources. These are saved alongside the requested output
579
+ path and must be kept with the HTML file.
560
580
  """
561
581
  target_path = Path(file_path).expanduser()
562
582
  target_path.parent.mkdir(parents=True, exist_ok=True)
@@ -582,12 +602,13 @@ class RTFDocument(BaseModel):
582
602
  f"{type(converted)!r} with value {converted!r}."
583
603
  )
584
604
  html_path = converted
585
- resources_dir = html_path.with_name(f"{html_path.name}_files")
586
605
  shutil.move(str(html_path), target_path)
587
- if resources_dir.is_dir():
588
- shutil.move(
589
- str(resources_dir), target_path.parent / resources_dir.name
590
- )
606
+ for resource in Path(convert_tmpdir).iterdir():
607
+ destination = target_path.parent / resource.name
608
+ if resource.is_dir():
609
+ shutil.copytree(resource, destination, dirs_exist_ok=True)
610
+ else:
611
+ shutil.move(str(resource), destination)
591
612
 
592
613
  print(target_path)
593
614