rtflite 2.5.4__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.4 → rtflite-2.6.0}/PKG-INFO +14 -15
  2. rtflite-2.6.0/pyproject.toml +131 -0
  3. rtflite-2.5.4/pyproject.toml → rtflite-2.6.0/pyproject.toml.orig +7 -10
  4. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/convert.py +141 -69
  5. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/encode.py +9 -8
  6. rtflite-2.5.4/.gitignore +0 -28
  7. rtflite-2.5.4/.python-version +0 -1
  8. rtflite-2.5.4/CHANGELOG.md +0 -359
  9. rtflite-2.5.4/LICENSE +0 -20
  10. rtflite-2.5.4/LICENSES_THIRD_PARTY +0 -20
  11. {rtflite-2.5.4 → rtflite-2.6.0}/README.md +0 -0
  12. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/__init__.py +0 -0
  13. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/assemble.py +0 -0
  14. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/attributes.py +0 -0
  15. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/core/__init__.py +0 -0
  16. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/core/config.py +0 -0
  17. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/core/constants.py +0 -0
  18. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/data/HAMD17.parquet +0 -0
  19. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/data/__init__.py +0 -0
  20. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/data/adae.parquet +0 -0
  21. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/data/adsl.parquet +0 -0
  22. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/data/baseline.parquet +0 -0
  23. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/data/tbl1.parquet +0 -0
  24. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/data/tbl2.parquet +0 -0
  25. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/data/tbl3.parquet +0 -0
  26. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/dictionary/__init__.py +0 -0
  27. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/dictionary/color_table.py +0 -0
  28. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/dictionary/libreoffice.py +0 -0
  29. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/dictionary/unicode_latex.py +0 -0
  30. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/encoding/__init__.py +0 -0
  31. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/encoding/base.py +0 -0
  32. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/encoding/engine.py +0 -0
  33. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/encoding/renderer.py +0 -0
  34. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/encoding/unified_encoder.py +0 -0
  35. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/figure.py +0 -0
  36. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/fonts/README.md +0 -0
  37. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/fonts/__init__.py +0 -0
  38. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/fonts/cros/Caladea-Regular.ttf +0 -0
  39. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/fonts/cros/Carlito-Regular.ttf +0 -0
  40. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/fonts/cros/Gelasio-Regular.ttf +0 -0
  41. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/fonts/liberation/LiberationMono-Regular.ttf +0 -0
  42. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/fonts/liberation/LiberationSans-Regular.ttf +0 -0
  43. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/fonts/liberation/LiberationSerif-Regular.ttf +0 -0
  44. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/fonts_mapping.py +0 -0
  45. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/input.py +0 -0
  46. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/pagination/__init__.py +0 -0
  47. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/pagination/core.py +0 -0
  48. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/pagination/processor.py +0 -0
  49. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/pagination/strategies/__init__.py +0 -0
  50. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/pagination/strategies/base.py +0 -0
  51. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/pagination/strategies/defaults.py +0 -0
  52. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/pagination/strategies/grouping.py +0 -0
  53. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/pagination/strategies/registry.py +0 -0
  54. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/row.py +0 -0
  55. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/rtf/__init__.py +0 -0
  56. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/rtf/syntax.py +0 -0
  57. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/services/__init__.py +0 -0
  58. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/services/color_service.py +0 -0
  59. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/services/document_service.py +0 -0
  60. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/services/encoding_service.py +0 -0
  61. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/services/figure_service.py +0 -0
  62. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/services/grouping_service.py +0 -0
  63. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/services/text_conversion_service.py +0 -0
  64. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/strwidth.py +0 -0
  65. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/text_conversion/__init__.py +0 -0
  66. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/text_conversion/converter.py +0 -0
  67. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/text_conversion/symbols.py +0 -0
  68. {rtflite-2.5.4 → rtflite-2.6.0}/src/rtflite/type_guards.py +0 -0
@@ -1,38 +1,37 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rtflite
3
- Version: 2.5.4
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "rtflite"
3
- version = "2.5.4"
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" },
@@ -13,6 +13,7 @@ dependencies = [
13
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",
@@ -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
@@ -574,9 +574,9 @@ class RTFDocument(BaseModel):
574
574
  ```
575
575
 
576
576
  Note:
577
- LibreOffice may create a companion directory (for example
578
- `report.html_files`) for embedded resources. When present, it is moved
579
- 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.
580
580
  """
581
581
  target_path = Path(file_path).expanduser()
582
582
  target_path.parent.mkdir(parents=True, exist_ok=True)
@@ -602,12 +602,13 @@ class RTFDocument(BaseModel):
602
602
  f"{type(converted)!r} with value {converted!r}."
603
603
  )
604
604
  html_path = converted
605
- resources_dir = html_path.with_name(f"{html_path.name}_files")
606
605
  shutil.move(str(html_path), target_path)
607
- if resources_dir.is_dir():
608
- shutil.move(
609
- str(resources_dir), target_path.parent / resources_dir.name
610
- )
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)
611
612
 
612
613
  print(target_path)
613
614
 
rtflite-2.5.4/.gitignore DELETED
@@ -1,28 +0,0 @@
1
- # Python-generated files
2
- __pycache__/
3
- *.py[oc]
4
- build/
5
- dist/
6
- site/
7
- wheels/
8
- *.egg-info
9
- *.xml
10
-
11
- # Virtual environments
12
- .venv
13
-
14
- # OS
15
- .DS_Store
16
-
17
- # pytest
18
- .coverage
19
-
20
- # r2rtf
21
- Rplots.pdf
22
-
23
- # markdown-exec artifacts
24
- /docs/articles/pdf/
25
- /docs/articles/images/
26
-
27
- # Generated docx artifacts (not bitwise deterministic)
28
- /docs/articles/rtf/*.docx
@@ -1 +0,0 @@
1
- 3.14.3
@@ -1,359 +0,0 @@
1
- # Changelog
2
-
3
- ## rtflite 2.5.4
4
-
5
- ### Bug fixes
6
-
7
- - Normalize `rtf_body` sequences at the document boundary and fix mypy
8
- type narrowing for multi-section `rtf_body` handling (#202).
9
- `RTFDocument.rtf_body` is now typed as `RTFBody | list[RTFBody] | None`,
10
- with a field validator that converts non-list `Sequence[RTFBody]` inputs
11
- to `list[RTFBody]`. Single-section documents now reject multi-body
12
- containers with a clear error instead of failing with attribute access
13
- errors downstream.
14
-
15
- ### Dependencies
16
-
17
- - Remove pandas and pyarrow from development dependencies (#194).
18
-
19
- ### Maintenance
20
-
21
- - Add a script to automate sorting dependencies in `pyproject.toml` and
22
- re-sort pyproject dependencies using the script (#193).
23
-
24
- ## rtflite 2.5.3
25
-
26
- ### Documentation
27
-
28
- - Update mkdocstrings settings to render Pydantic field metadata
29
- via `griffe-pydantic` in API reference docs (#189).
30
- - Declare `RTFDocument._table_space` as a Pydantic private attribute to
31
- prevent mkdocstrings render errors in static mode (#189).
32
- - Add Code Wiki link as README badge and site navigation link.
33
- This is a useful resource that helps developers and AI agents
34
- understand the codebase fast (#191).
35
-
36
- ## rtflite 2.5.2
37
-
38
- ### Documentation
39
-
40
- - Migrated documentation site to use Zensical (#184, #185).
41
-
42
- ## rtflite 2.5.1
43
-
44
- ### Testing
45
-
46
- - Migrated the RTF snapshot tests implementation to a proper solution
47
- `pytest-r-snapshot` to reduce boilerplate code and improve maintainability.
48
- The legacy fixture generation workflow is removed and the snapshots are
49
- stored under the standard location `tests/__r_snapshots__/` (#181).
50
-
51
- ## rtflite 2.5.0
52
-
53
- ### New features
54
-
55
- - Added `RTFDocument.write_html` and `RTFDocument.write_pdf` for exporting RTF
56
- documents to HTML and PDF via LibreOffice, matching the `write_docx`
57
- conversion workflow (#176).
58
-
59
- ### Testing
60
-
61
- - Added parameterized tests covering DOCX, HTML, and PDF exports, with a
62
- new `pdf` extra (`pypdf`) for PDF text extraction.
63
- Improved LibreOffice availability checks to skip integration tests
64
- when conversion is not working (#176).
65
-
66
- ## rtflite 2.4.0
67
-
68
- ### Breaking changes
69
-
70
- - Removed the `executable_path` argument from `RTFDocument.write_docx`; pass
71
- `converter=LibreOfficeConverter(executable_path=...)` instead (#172).
72
-
73
- ### Improvements
74
-
75
- - `RTFDocument.write_docx` now accepts a `converter=` instance to configure
76
- LibreOffice invocation (including custom executable paths) and to enable
77
- reusing a converter across multiple conversions (#172).
78
-
79
- ### Converters
80
-
81
- - `LibreOfficeConverter(executable_path=...)` now accepts `Path` objects
82
- and resolves executable names via `PATH` when given
83
- (for example, `"soffice"`) (#172).
84
-
85
- ## rtflite 2.3.1
86
-
87
- ### Improvements
88
-
89
- - Added `executable_path` parameter to `write_docx()` method (#170).
90
- - Added `/tmp/soffice` and `/tmp/libreoffice` paths for Linux (#170).
91
-
92
- ## rtflite 2.3.0
93
-
94
- ### New features
95
-
96
- - Added `concatenate_docx` function to merge DOCX outputs without manual
97
- field refreshes, preserving per-section orientation (#160).
98
-
99
- ### Testing
100
-
101
- - Added DOCX concatenation coverage and centralized optional dependency
102
- skip markers for `python-docx` and LibreOffice to keep tests gated
103
- appropriately (#160).
104
-
105
- ### Documentation
106
-
107
- - Updated the assembly article to use `concatenate_docx` in code examples and
108
- added a reference page for assemble function to the documentation site (#160).
109
-
110
- ## rtflite 2.2.0
111
-
112
- ### New features
113
-
114
- - Added `RTFDocument.write_docx` to export tables as DOCX via LibreOffice,
115
- with `str`/`Path` input support and automatic parent directory creation (#156).
116
- - Improved `write_rtf` to accept `Path` inputs and create missing output
117
- directories (#156).
118
-
119
- ### Documentation
120
-
121
- - Expanded DOCX assembly guidance with toggle field caveats and `python-docx`
122
- concatenation examples (#157).
123
- - Documented installing the `docx` extra in the README and assemble article,
124
- including `uv sync --extra docx` for developers (#155).
125
-
126
- ## rtflite 2.1.1
127
-
128
- ### Bug fixes
129
-
130
- - Reduced packaging files size by excluding unnecessary files and directories (#152).
131
- - Fixed an issue when paginating tables with `page_by` and `subline_by` (#152).
132
-
133
- ## rtflite 2.1.0
134
-
135
- ### New features
136
-
137
- - Added `assemble_rtf` and `assemble_docx` functions for RTF and DOCX assembly (#142).
138
-
139
- ### Bug fixes
140
-
141
- - Fixed an issue where `RTFPage` orientation was not correctly respected during assembly in certain edge cases (#141).
142
-
143
- ## rtflite 2.0.0
144
-
145
- ### Breaking changes
146
-
147
- - Removed legacy pagination/encoding APIs, including `ContentDistributor`,
148
- `PageDict`/`AdvancedPaginationService`, and the `SinglePageStrategy`/
149
- `PaginatedStrategy` classes; dropped backwards-compatibility helpers
150
- such as `text_convert` and `get_color_index` (#138).
151
-
152
- ### Refactors
153
-
154
- - Introduced a unified rendering pipeline (`UnifiedRTFEncoder`) with a strategy
155
- registry, page feature processor, and renderer that handle pagination,
156
- borders, and page headers consistently, including combined `page_by` and
157
- `subline_by` grouping (#138).
158
- - Simplified public exports by re-exporting `RTFEncodingEngine`,
159
- `TableAttributes`, `RTFSubline`, and `get_string_width` from the top-level
160
- package while reorganizing core config/constants imports (#138).
161
-
162
- ### Testing and documentation
163
-
164
- - Added regression tests for combined grouping and `page_by` column alignment,
165
- and removed obsolete advanced pagination tests; cleaned up RTF doc fixtures
166
- and trimmed pagination reference docs to match the new architecture (#138).
167
-
168
- ## rtflite 1.2.0
169
-
170
- ### Bug fixes
171
-
172
- - Fixed `page_by` pagination when using `RTFPage(orientation="landscape")`,
173
- covering single- and multi-page tables and aligning documentation examples
174
- (#128, #131, #134).
175
-
176
- ### Maintenance
177
-
178
- - Added GitHub Actions workflow to run `ruff check` for code linting,
179
- updated GitHub Actions workflows to use `actions/checkout@v6`,
180
- and updated badges in `README.md` (#133).
181
-
182
- ## rtflite 1.1.1
183
-
184
- ### New features
185
-
186
- - Enhanced group-by divider filtering functionality to support the
187
- `-----` syntax (#118).
188
-
189
- ### Bug fixes
190
-
191
- - Fixed text conversion issue for greater than or equal (>=) and less than or
192
- equal (<=) symbols (#119).
193
-
194
- ## rtflite 1.1.0
195
-
196
- ### Python version support
197
-
198
- - Added Python 3.14 support by conditionally requiring pyarrow >= 22.0.0
199
- under Python 3.14 (#114).
200
-
201
- ### Linting and typing
202
-
203
- - Added ruff linter configuration to `pyproject.toml` with popular rule sets
204
- and fixed all linting issues (#115).
205
- - Refactored type annotations to use built-in generics and abstract base classes
206
- following modern typing best practices (#107).
207
-
208
- ### Documentation
209
-
210
- - Added pharmaverse badge to the README (#108).
211
- - Added `AGENTS.md` with guidelines for AI coding agents (#109).
212
-
213
- ### Maintenance
214
-
215
- - Updated GitHub Actions workflows to use the latest `checkout` and
216
- `setup-python` versions (#114).
217
- - Refactored the logo generation script to use ImageMagick, removing the
218
- R and hexSticker dependency (#111).
219
-
220
- ## rtflite 1.0.2
221
-
222
- ### Typing
223
-
224
- - Adopted modern typing best practices: use `|` unions instead of
225
- `Union`/`Optional`, and built-in generics instead of `typing` aliases (#95).
226
- - Resolved all mypy issues; type checks now pass cleanly (#97, #99, #101).
227
- - Added a mypy GitHub Actions workflow for continuous type checking (#100).
228
-
229
- ### Testing
230
-
231
- - Added a developer script to compare current RTF outputs with snapshots
232
- generated from documentation site articles (#102).
233
-
234
- ### Documentation
235
-
236
- - Updated `CLAUDE.md` to replace outdated `.qmd`-based rendering instructions
237
- with the markdown-exec approach (#93).
238
-
239
- ## rtflite 1.0.1
240
-
241
- ### Bug fixes
242
-
243
- - Fixed hard-coded font in pagination calculations to properly use
244
- user-selected fonts (#91).
245
-
246
- ### Dependencies
247
-
248
- - Lowered minimum Pillow version to 8.0.0 with automatic font size type
249
- coercion to `int` for Pillow < 10.0.0 compatibility (#89).
250
-
251
- ### Documentation
252
-
253
- - Improved readability and technical accuracy of documentation (#85, #86, #87).
254
-
255
- ## rtflite 1.0.0
256
-
257
- This major release marks rtflite as production-ready for table, listing,
258
- and figure generation in RTF format.
259
- It introduces advanced pagination features, enhanced group handling,
260
- complete color support, and significant architectural improvements
261
- for better maintainability and performance.
262
-
263
- ### New features
264
-
265
- - **Advanced pagination features**
266
- - Added the `subline_by` parameter for creating paragraph headers before each page group.
267
- - Enhanced `group_by` functionality with hierarchical value suppression within groups.
268
- - Implemented page context restoration for multi-page tables with `group_by`.
269
-
270
- - **Enhanced color system**
271
- - Complete 657-color support with full r2rtf R package compatibility.
272
-
273
- - **Text conversion improvements**
274
- - Text conversion (LaTeX to Unicode) enabled by default for all components.
275
- - Better handling of special characters and symbols.
276
- - Enhanced validation for text conversion operations.
277
-
278
- - **Table formatting**
279
- - Added the `as_table` parameter for `RTFFootnote` and `RTFSource` components.
280
- - Auto-inheritance of `col_rel_width` from `rtf_body` to `rtf_column_header`.
281
- - Improved handling of table borders and footnote placement.
282
-
283
- ### Architecture improvements
284
-
285
- - **Service-oriented architecture**
286
- - Introduced dedicated service layer for complex operations.
287
- - Implemented strategy pattern for encoding (`SinglePageStrategy`, `PaginatedStrategy`).
288
- - Created `RTFEncodingEngine` for strategy orchestration.
289
-
290
- - **Code organization**
291
- - Consolidated constants and eliminated magic numbers throughout the codebase.
292
- - Method decomposition and improved input validation.
293
- - Cleaner public interfaces with thorough error handling.
294
-
295
- ### Dependency changes
296
-
297
- - Removed numpy and pandas as hard dependencies.
298
- - Moved pyarrow to development dependencies.
299
- - Now uses narwhals for DataFrame abstraction.
300
- - Prefer polars as the primary DataFrame interface.
301
-
302
- ### Documentation
303
-
304
- - Added vignette-style articles to document the new features.
305
- - Reorganized API reference for better user experience.
306
- - Renamed documentation files to use hyphens consistently.
307
- - Updated all examples to use modern best practices.
308
- - Fixed Polars `DataOrientationWarning` in documentation examples.
309
-
310
- ### Testing
311
-
312
- - Added single-page RTF tests with fixtures generated by r2rtf.
313
- - Added extensive multi-page tests for the `as_table` feature.
314
-
315
- ## rtflite 0.1.3
316
-
317
- ### Documentation
318
-
319
- - Add contributing guidelines to make it easy for onboarding new developers
320
- to the recommended development workflow (#25).
321
- - Update `README.md` to add hyperlink to the R package r2rtf (#24).
322
-
323
- ### Maintenance
324
-
325
- - Remove the strict version requirement for the development dependency
326
- mkdocs-autorefs (#21).
327
-
328
- ## rtflite 0.1.2
329
-
330
- ### Maintenance
331
-
332
- - Manage project with uv (#19).
333
- - Update the logo image generation workflow to use web fonts (#18).
334
-
335
- ## rtflite 0.1.1
336
-
337
- ### Documentation
338
-
339
- - Use absolute URL to replace relative path for logo image in `README.md`,
340
- for proper rendering on PyPI (#16).
341
-
342
- ## rtflite 0.1.0
343
-
344
- ### New features
345
-
346
- - Introduced core RTF document components, such as `RTFDocument`, `RTFPage`,
347
- `RTFTitle`, `RTFColumnHeader`, and `RTFBody`. These classes establish the
348
- foundation for composing structured RTF documents with a text encoding
349
- pipeline. Use Pydantic for data validation.
350
- - Implemented string width calculation using Pillow with metric-compatible fonts.
351
- This will be incorporated in the pagination and layout algorithms in
352
- future releases.
353
- - Implemented a LibreOffice-based document converter for RTF to PDF conversion
354
- with automatic LibreOffice detection mechanisms under Linux, macOS, and Windows.
355
-
356
- ### Documentation
357
-
358
- - Added an article on creating baseline characteristics tables.
359
- - Integrated code coverage reports via pytest-cov into the documentation site.
rtflite-2.5.4/LICENSE DELETED
@@ -1,20 +0,0 @@
1
- Copyright (c) 2025 pharmaverse
2
-
3
- Permission is hereby granted, free of charge, to any person obtaining
4
- a copy of this software and associated documentation files (the
5
- "Software"), to deal in the Software without restriction, including
6
- without limitation the rights to use, copy, modify, merge, publish,
7
- distribute, sublicense, and/or sell copies of the Software, and to
8
- permit persons to whom the Software is furnished to do so, subject to
9
- the following conditions:
10
-
11
- The above copyright notice and this permission notice shall be
12
- included in all copies or substantial portions of the Software.
13
-
14
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
- EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
- MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
- NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
- LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
- OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
- WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -1,20 +0,0 @@
1
- rtflite uses third-party Python packages which may be distributed under
2
- different licenses. We have listed all of these third party packages and
3
- their licenses below. However, the most up-to-date information can be found
4
- via inspecting the dependencies field in the pyproject.toml file.
5
-
6
- You must agree to the terms of these licenses, in addition to the rtflite
7
- source code license, in order to use this software.
8
-
9
- --------------------------------------------------
10
- Third party Python packages listed by license type
11
- [Format: Name - URL]
12
- --------------------------------------------------
13
-
14
- MIT License (https://opensource.org/license/mit)
15
- * narwhals - https://github.com/narwhals-dev/narwhals/blob/main/LICENSE.md
16
- * polars - https://github.com/pola-rs/polars/blob/main/LICENSE
17
- * pydantic - https://github.com/pydantic/pydantic/blob/main/LICENSE
18
-
19
- MIT-CMU License (https://opensource.org/license/cmu-license)
20
- * pillow - https://github.com/python-pillow/Pillow/blob/main/LICENSE
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes