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.
- {rtflite-2.5.3 → rtflite-2.6.0}/PKG-INFO +14 -15
- rtflite-2.6.0/pyproject.toml +131 -0
- rtflite-2.5.3/pyproject.toml → rtflite-2.6.0/pyproject.toml.orig +16 -22
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/convert.py +141 -69
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/encode.py +30 -9
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/encoding/unified_encoder.py +6 -6
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/type_guards.py +5 -7
- rtflite-2.5.3/.gitignore +0 -28
- rtflite-2.5.3/.python-version +0 -1
- rtflite-2.5.3/CHANGELOG.md +0 -338
- rtflite-2.5.3/LICENSE +0 -20
- rtflite-2.5.3/LICENSES_THIRD_PARTY +0 -20
- {rtflite-2.5.3 → rtflite-2.6.0}/README.md +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/__init__.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/assemble.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/attributes.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/core/__init__.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/core/config.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/core/constants.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/data/HAMD17.parquet +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/data/__init__.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/data/adae.parquet +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/data/adsl.parquet +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/data/baseline.parquet +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/data/tbl1.parquet +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/data/tbl2.parquet +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/data/tbl3.parquet +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/dictionary/__init__.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/dictionary/color_table.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/dictionary/libreoffice.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/dictionary/unicode_latex.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/encoding/__init__.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/encoding/base.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/encoding/engine.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/encoding/renderer.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/figure.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/fonts/README.md +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/fonts/__init__.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/fonts/cros/Caladea-Regular.ttf +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/fonts/cros/Carlito-Regular.ttf +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/fonts/cros/Gelasio-Regular.ttf +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/fonts/liberation/LiberationMono-Regular.ttf +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/fonts/liberation/LiberationSans-Regular.ttf +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/fonts/liberation/LiberationSerif-Regular.ttf +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/fonts_mapping.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/input.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/pagination/__init__.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/pagination/core.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/pagination/processor.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/pagination/strategies/__init__.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/pagination/strategies/base.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/pagination/strategies/defaults.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/pagination/strategies/grouping.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/pagination/strategies/registry.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/row.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/rtf/__init__.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/rtf/syntax.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/services/__init__.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/services/color_service.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/services/document_service.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/services/encoding_service.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/services/figure_service.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/services/grouping_service.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/services/text_conversion_service.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/strwidth.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/text_conversion/__init__.py +0 -0
- {rtflite-2.5.3 → rtflite-2.6.0}/src/rtflite/text_conversion/converter.py +0 -0
- {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.
|
|
3
|
+
Version: 2.6.0
|
|
4
4
|
Summary: Lightweight RTF composer for Python
|
|
5
|
-
|
|
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-
|
|
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:
|
|
18
|
-
Classifier:
|
|
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.
|
|
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 = ["
|
|
51
|
-
build-backend = "
|
|
49
|
+
requires = ["uv_build>=0.12.10,<0.13.0"]
|
|
50
|
+
build-backend = "uv_build"
|
|
52
51
|
|
|
53
|
-
[tool.
|
|
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
|
-
"
|
|
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__(
|
|
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
|
|
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
|
-
|
|
59
|
-
or
|
|
60
|
-
or
|
|
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
|
-
|
|
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
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
if not
|
|
183
|
-
|
|
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(
|
|
188
|
-
if not input_path.
|
|
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(
|
|
246
|
+
input_paths = [Path(f).expanduser() for f in input_files]
|
|
194
247
|
for path in input_paths:
|
|
195
|
-
if not path.
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
"
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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 |
|
|
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
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
|