tableio 0.3__tar.gz → 0.4__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.
- {tableio-0.3/tableio.egg-info → tableio-0.4}/PKG-INFO +7 -6
- {tableio-0.3 → tableio-0.4}/README.md +2 -2
- {tableio-0.3 → tableio-0.4}/README_pypi.md +4 -3
- {tableio-0.3 → tableio-0.4}/pyproject.toml +1 -1
- {tableio-0.3 → tableio-0.4}/setup.py +3 -3
- tableio-0.4/src/tableio/_archive_rewrite.py +80 -0
- {tableio-0.3 → tableio-0.4}/src/tableio/factory.py +15 -9
- {tableio-0.3 → tableio-0.4}/src/tableio/tableio.py +2 -0
- {tableio-0.3 → tableio-0.4}/src/tableio/tableio_excel_openpyxl.py +57 -1
- {tableio-0.3 → tableio-0.4}/src/tableio/tableio_excel_pylightxl.py +82 -60
- {tableio-0.3 → tableio-0.4}/src/tableio/tableio_ods_odfdo.py +102 -2
- {tableio-0.3 → tableio-0.4/tableio.egg-info}/PKG-INFO +7 -6
- {tableio-0.3 → tableio-0.4}/tableio.egg-info/SOURCES.txt +1 -0
- {tableio-0.3 → tableio-0.4}/tableio.egg-info/requires.txt +2 -2
- {tableio-0.3 → tableio-0.4}/LICENSE.txt +0 -0
- {tableio-0.3 → tableio-0.4}/setup.cfg +0 -0
- {tableio-0.3 → tableio-0.4}/src/tableio/__init__.py +0 -0
- {tableio-0.3 → tableio-0.4}/src/tableio/capability.py +0 -0
- {tableio-0.3 → tableio-0.4}/src/tableio/color.py +0 -0
- {tableio-0.3 → tableio-0.4}/src/tableio/optional_args.py +0 -0
- {tableio-0.3 → tableio-0.4}/src/tableio/py.typed +0 -0
- {tableio-0.3 → tableio-0.4}/src/tableio/reg_pkg_formats.py +0 -0
- {tableio-0.3 → tableio-0.4}/src/tableio/tableio_csv.py +0 -0
- {tableio-0.3 → tableio-0.4}/src/tableio/tableio_excel_xlsxwriter.py +0 -0
- {tableio-0.3 → tableio-0.4}/src/tableio/tableio_excelbased.py +0 -0
- {tableio-0.3 → tableio-0.4}/src/tableio/tableio_mformat.py +0 -0
- {tableio-0.3 → tableio-0.4}/src/tableio/tableio_mformatbased.py +0 -0
- {tableio-0.3 → tableio-0.4}/src/tableio/tableio_spreadsheetbased.py +0 -0
- {tableio-0.3 → tableio-0.4}/src/tableio/tableio_textbased.py +0 -0
- {tableio-0.3 → tableio-0.4}/src/tableio/tableio_types.py +0 -0
- {tableio-0.3 → tableio-0.4}/src/tableio/value_type.py +0 -0
- {tableio-0.3 → tableio-0.4}/src/tableio/valueconversion.py +0 -0
- {tableio-0.3 → tableio-0.4}/tableio.egg-info/dependency_links.txt +0 -0
- {tableio-0.3 → tableio-0.4}/tableio.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tableio
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4
|
|
4
4
|
Summary: Uniform way to write table data to and read from different file formats
|
|
5
5
|
Author: Tom Björkholm
|
|
6
6
|
Author-email: Tom Björkholm <klausuler_linnet0q@icloud.com>
|
|
@@ -17,8 +17,8 @@ Requires-Dist: build>=1.4.2
|
|
|
17
17
|
Requires-Dist: wheel>=0.46.3
|
|
18
18
|
Requires-Dist: mformat-ext>=0.6
|
|
19
19
|
Requires-Dist: openpyxl>=3.1.5
|
|
20
|
-
Requires-Dist: types-openpyxl>=3.1.5.
|
|
21
|
-
Requires-Dist: odfdo>=3.22.
|
|
20
|
+
Requires-Dist: types-openpyxl>=3.1.5.20260402
|
|
21
|
+
Requires-Dist: odfdo>=3.22.4
|
|
22
22
|
Requires-Dist: xlsxwriter>=3.2.9
|
|
23
23
|
Requires-Dist: pylightxl>=1.61
|
|
24
24
|
Dynamic: author
|
|
@@ -138,14 +138,15 @@ a better start by reading the examples.
|
|
|
138
138
|
|
|
139
139
|
| Version | Date | Python version | Comment |
|
|
140
140
|
|---------|-------------|----------------|-----------------------------|
|
|
141
|
-
| 0.
|
|
141
|
+
| 0.4 | 2026 Apr 06 | 3.12 or newer | Fix .xlsx and .ods syntax |
|
|
142
|
+
| 0.3 | 2026 Apr 01 | 3.12 or newer | Improved API, more backends |
|
|
142
143
|
| 0.2 | 2026 Mar 28 | 3.12 or newer | More features, API changes |
|
|
143
144
|
| 0.1 | 2026 Mar 23 | 3.12 or newer | First released version |
|
|
144
145
|
|
|
145
146
|
## Test summary
|
|
146
147
|
|
|
147
|
-
- Test result:
|
|
148
|
+
- Test result: 1126 passed in 17s
|
|
148
149
|
- No flake8 warnings.
|
|
149
150
|
- No mypy errors found.
|
|
150
|
-
- Built version(s): 0.
|
|
151
|
+
- Built version(s): 0.4
|
|
151
152
|
- Build and test using Python 3.14.3
|
|
@@ -93,8 +93,8 @@ information about the build system. This README can also be viewed at
|
|
|
93
93
|
|
|
94
94
|
## Test summary
|
|
95
95
|
|
|
96
|
-
- Test result:
|
|
96
|
+
- Test result: 1126 passed in 17s
|
|
97
97
|
- No flake8 warnings.
|
|
98
98
|
- No mypy errors found.
|
|
99
|
-
- Built version(s): 0.
|
|
99
|
+
- Built version(s): 0.4
|
|
100
100
|
- Build and test using Python 3.14.3
|
|
@@ -110,14 +110,15 @@ a better start by reading the examples.
|
|
|
110
110
|
|
|
111
111
|
| Version | Date | Python version | Comment |
|
|
112
112
|
|---------|-------------|----------------|-----------------------------|
|
|
113
|
-
| 0.
|
|
113
|
+
| 0.4 | 2026 Apr 06 | 3.12 or newer | Fix .xlsx and .ods syntax |
|
|
114
|
+
| 0.3 | 2026 Apr 01 | 3.12 or newer | Improved API, more backends |
|
|
114
115
|
| 0.2 | 2026 Mar 28 | 3.12 or newer | More features, API changes |
|
|
115
116
|
| 0.1 | 2026 Mar 23 | 3.12 or newer | First released version |
|
|
116
117
|
|
|
117
118
|
## Test summary
|
|
118
119
|
|
|
119
|
-
- Test result:
|
|
120
|
+
- Test result: 1126 passed in 17s
|
|
120
121
|
- No flake8 warnings.
|
|
121
122
|
- No mypy errors found.
|
|
122
|
-
- Built version(s): 0.
|
|
123
|
+
- Built version(s): 0.4
|
|
123
124
|
- Build and test using Python 3.14.3
|
|
@@ -5,7 +5,7 @@ from setuptools import setup
|
|
|
5
5
|
|
|
6
6
|
setup(
|
|
7
7
|
name='tableio',
|
|
8
|
-
version='0.
|
|
8
|
+
version='0.4',
|
|
9
9
|
description='Uniform way to write table data to and read from different ' \
|
|
10
10
|
'file formats',
|
|
11
11
|
author='Tom Björkholm',
|
|
@@ -21,8 +21,8 @@ setup(
|
|
|
21
21
|
'wheel >= 0.46.3',
|
|
22
22
|
'mformat-ext >= 0.6',
|
|
23
23
|
'openpyxl >= 3.1.5',
|
|
24
|
-
'types-openpyxl >= 3.1.5.
|
|
25
|
-
'odfdo >= 3.22.
|
|
24
|
+
'types-openpyxl >= 3.1.5.20260402',
|
|
25
|
+
'odfdo >= 3.22.4',
|
|
26
26
|
'xlsxwriter >= 3.2.9',
|
|
27
27
|
'pylightxl >= 1.61'
|
|
28
28
|
]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#! /usr/bin/env python3
|
|
2
|
+
"""Helpers for rewriting spreadsheet ZIP archives safely.
|
|
3
|
+
|
|
4
|
+
Spreadsheet writers first save library output to a temporary archive.
|
|
5
|
+
These helpers then build a rewritten copy in a second temporary archive
|
|
6
|
+
and replace the first archive only after both ZIP files are closed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
# Copyright (c) 2026 Tom Björkholm
|
|
10
|
+
# MIT License
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from tempfile import NamedTemporaryFile
|
|
14
|
+
from typing import Callable, Optional
|
|
15
|
+
from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def temporary_output_path(source_path: Path, suffix: str) -> Path:
|
|
19
|
+
"""Return one missing temporary path next to ``source_path``.
|
|
20
|
+
|
|
21
|
+
The temporary file is created in the same directory as
|
|
22
|
+
``source_path`` so a later ``Path.replace()`` stays on the same
|
|
23
|
+
filesystem. The file name uses ``suffix`` and the returned path does
|
|
24
|
+
not exist when this function returns.
|
|
25
|
+
"""
|
|
26
|
+
with NamedTemporaryFile(delete=False, dir=source_path.parent,
|
|
27
|
+
suffix=suffix) as temp_file:
|
|
28
|
+
temp_path = Path(temp_file.name)
|
|
29
|
+
temp_path.unlink()
|
|
30
|
+
return temp_path
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def rewrite_zip_archive(
|
|
34
|
+
archive_path: Path,
|
|
35
|
+
rewrite_entry: Callable[[ZipInfo, bytes], Optional[bytes]],
|
|
36
|
+
extra_entries: Optional[dict[str, bytes]] = None) -> None:
|
|
37
|
+
"""Rewrite one ZIP archive by copying it into a new archive.
|
|
38
|
+
|
|
39
|
+
The original archive at ``archive_path`` is read entry by entry and
|
|
40
|
+
a rewritten archive is written to a sibling temporary file. The
|
|
41
|
+
callback receives each original ``ZipInfo`` together with its bytes.
|
|
42
|
+
Returning ``None`` drops the entry. Any mapping passed in
|
|
43
|
+
``extra_entries`` is appended after copied entries, except for names
|
|
44
|
+
that were already written.
|
|
45
|
+
|
|
46
|
+
The original archive is replaced only after both ZIP files have been
|
|
47
|
+
closed. This avoids replacing an archive while it is still open,
|
|
48
|
+
which is a safer pattern on Windows as well as on Unix-like systems.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
archive_path: Path to the archive to rewrite in place.
|
|
52
|
+
rewrite_entry: Callback invoked once for each original ZIP entry.
|
|
53
|
+
The callback receives the original ``ZipInfo`` together with
|
|
54
|
+
the entry bytes. It returns replacement bytes for the output
|
|
55
|
+
archive, or ``None`` to drop the entry completely.
|
|
56
|
+
extra_entries: Optional mapping of extra archive members to add
|
|
57
|
+
after copying rewritten entries. Names that were already
|
|
58
|
+
written are left unchanged.
|
|
59
|
+
"""
|
|
60
|
+
temp_path = temporary_output_path(archive_path, archive_path.suffix)
|
|
61
|
+
try:
|
|
62
|
+
with ZipFile(archive_path, 'r') as source_zip, \
|
|
63
|
+
ZipFile(temp_path, 'w',
|
|
64
|
+
compression=ZIP_DEFLATED) as target_zip:
|
|
65
|
+
written_names: set[str] = set()
|
|
66
|
+
for item in source_zip.infolist():
|
|
67
|
+
data = rewrite_entry(item, source_zip.read(item.filename))
|
|
68
|
+
if data is None:
|
|
69
|
+
continue
|
|
70
|
+
target_zip.writestr(item, data)
|
|
71
|
+
written_names.add(item.filename)
|
|
72
|
+
if extra_entries is not None:
|
|
73
|
+
for entry_name, entry_data in extra_entries.items():
|
|
74
|
+
if entry_name in written_names:
|
|
75
|
+
continue
|
|
76
|
+
target_zip.writestr(entry_name, entry_data)
|
|
77
|
+
temp_path.replace(archive_path)
|
|
78
|
+
finally:
|
|
79
|
+
if temp_path.exists():
|
|
80
|
+
temp_path.unlink()
|
|
@@ -274,6 +274,17 @@ class FactoryFormatInfo:
|
|
|
274
274
|
raise TableIOFactoryNoCapabilityMatch(msg)
|
|
275
275
|
return ret
|
|
276
276
|
|
|
277
|
+
def correct_implementation_name(self, implementation_name: str) -> str:
|
|
278
|
+
"""Correct the implementation name to the correct case."""
|
|
279
|
+
if implementation_name in self._registry:
|
|
280
|
+
return implementation_name
|
|
281
|
+
if implementation_name.lower() in self._lower2correct:
|
|
282
|
+
return self._lower2correct[implementation_name.lower()]
|
|
283
|
+
raise TableIOFactoryNoSuchError(
|
|
284
|
+
f'Implementation "{implementation_name}" is not registered. ' +
|
|
285
|
+
'Available implementations: ' +
|
|
286
|
+
f'{", ".join(list(self._registry.keys()))}')
|
|
287
|
+
|
|
277
288
|
|
|
278
289
|
class TableIOFactory:
|
|
279
290
|
"""Factory class for creating instances of TableIO subclasses.
|
|
@@ -416,21 +427,16 @@ class TableIOFactory:
|
|
|
416
427
|
best_matches = format_info.best_match_names(
|
|
417
428
|
capabilities=capabilities, empty_is_ok=False)
|
|
418
429
|
if implementation is not None:
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
raise TableIOFactoryNoSuchError(
|
|
422
|
-
f'Implementation "{implementation}" is not registered. ' +
|
|
423
|
-
'Available implementations: ' +
|
|
424
|
-
f'{", ".join(list(fikeys))}'
|
|
425
|
-
)
|
|
430
|
+
corrected_implementation = \
|
|
431
|
+
format_info.correct_implementation_name(implementation)
|
|
426
432
|
bmatches_impls = [impl.implementation
|
|
427
433
|
for impl in best_matches.combined()]
|
|
428
|
-
if
|
|
434
|
+
if corrected_implementation not in bmatches_impls:
|
|
429
435
|
msg3 = f'Implementation "{implementation}" '
|
|
430
436
|
msg3 += 'does not match the capabilities. Available matches: '
|
|
431
437
|
msg3 += f'{", ".join(bmatches_impls)}'
|
|
432
438
|
raise TableIOFactoryNoCapabilityMatch(msg3)
|
|
433
|
-
chosen_impl =
|
|
439
|
+
chosen_impl = corrected_implementation
|
|
434
440
|
else:
|
|
435
441
|
chosen_impl = best_matches.combined()[0].implementation
|
|
436
442
|
format_class = format_info._registry[chosen_impl] # pylint: disable=protected-access # noqa: E501
|
|
@@ -85,6 +85,8 @@ class TableIO:
|
|
|
85
85
|
"""Return the file name with the extension."""
|
|
86
86
|
if not extension.startswith('.'):
|
|
87
87
|
extension = f'.{extension}'
|
|
88
|
+
if str(file_name).lower().endswith(extension.lower()):
|
|
89
|
+
return str(file_name)
|
|
88
90
|
return f'{str(file_name)}{extension}'
|
|
89
91
|
|
|
90
92
|
@classmethod
|
|
@@ -4,13 +4,17 @@
|
|
|
4
4
|
# Copyright (c) 2026 Tom Björkholm
|
|
5
5
|
# MIT License
|
|
6
6
|
|
|
7
|
+
from pathlib import Path
|
|
7
8
|
from typing import Callable, Optional
|
|
9
|
+
from xml.etree import ElementTree as ET
|
|
8
10
|
from openpyxl import Workbook, load_workbook
|
|
9
11
|
from openpyxl.styles import Font, PatternFill
|
|
10
12
|
from openpyxl.utils.cell import get_column_letter, range_boundaries
|
|
11
13
|
from openpyxl.worksheet.table import Table
|
|
12
14
|
from openpyxl.worksheet.worksheet import Worksheet
|
|
13
15
|
from mformat.mformat import PathLike
|
|
16
|
+
from tableio._archive_rewrite import rewrite_zip_archive, \
|
|
17
|
+
temporary_output_path
|
|
14
18
|
from tableio.color import Color
|
|
15
19
|
from tableio.tableio import Descriptor, FileAccess
|
|
16
20
|
from tableio.tableio_excelbased import TableIOExcelBased
|
|
@@ -22,6 +26,50 @@ _HIGHLIGHT_RGB: dict[Color, str] = {
|
|
|
22
26
|
Color.GREEN: 'FFC6EFCE',
|
|
23
27
|
Color.YELLOW: 'FFFFFF00'
|
|
24
28
|
}
|
|
29
|
+
_XML_NS = {'main': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
|
|
30
|
+
_FONT_CHILD_ORDER = [
|
|
31
|
+
'b',
|
|
32
|
+
'i',
|
|
33
|
+
'u',
|
|
34
|
+
'strike',
|
|
35
|
+
'outline',
|
|
36
|
+
'shadow',
|
|
37
|
+
'condense',
|
|
38
|
+
'extend',
|
|
39
|
+
'sz',
|
|
40
|
+
'color',
|
|
41
|
+
'name',
|
|
42
|
+
'family',
|
|
43
|
+
'charset',
|
|
44
|
+
'scheme'
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _font_child_sort_key(element: ET.Element) -> int:
|
|
49
|
+
"""Return the schema-order key for one Excel font child element."""
|
|
50
|
+
tag_name = element.tag.split('}', 1)[-1]
|
|
51
|
+
if tag_name in _FONT_CHILD_ORDER:
|
|
52
|
+
return _FONT_CHILD_ORDER.index(tag_name)
|
|
53
|
+
return len(_FONT_CHILD_ORDER)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _styles_xml_with_sorted_fonts(data: bytes) -> bytes:
|
|
57
|
+
"""Return styles XML with font child elements in schema order."""
|
|
58
|
+
root = ET.fromstring(data)
|
|
59
|
+
for font in root.findall('main:fonts/main:font', _XML_NS):
|
|
60
|
+
font[:] = sorted(list(font), key=_font_child_sort_key)
|
|
61
|
+
return bytes(ET.tostring(root, encoding='utf-8',
|
|
62
|
+
xml_declaration=True))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _rewrite_saved_workbook(file_name: Path) -> None:
|
|
66
|
+
"""Rewrite the saved workbook so styles XML follows validator order."""
|
|
67
|
+
def rewrite_entry(item: object, data: bytes) -> bytes:
|
|
68
|
+
"""Rewrite one workbook archive entry when needed."""
|
|
69
|
+
if getattr(item, 'filename') == 'xl/styles.xml':
|
|
70
|
+
return _styles_xml_with_sorted_fonts(data)
|
|
71
|
+
return data
|
|
72
|
+
rewrite_zip_archive(file_name, rewrite_entry)
|
|
25
73
|
|
|
26
74
|
|
|
27
75
|
class TableIOExcelOpenPyXL(TableIOExcelBased):
|
|
@@ -91,7 +139,15 @@ class TableIOExcelOpenPyXL(TableIOExcelBased):
|
|
|
91
139
|
if self.file_access == FileAccess.READ:
|
|
92
140
|
return
|
|
93
141
|
assert self.workbook is not None
|
|
94
|
-
|
|
142
|
+
file_path = Path(self.file_name)
|
|
143
|
+
temp_path = temporary_output_path(file_path, '.xlsx')
|
|
144
|
+
try:
|
|
145
|
+
self.workbook.save(temp_path)
|
|
146
|
+
_rewrite_saved_workbook(temp_path)
|
|
147
|
+
temp_path.replace(file_path)
|
|
148
|
+
finally:
|
|
149
|
+
if temp_path.exists():
|
|
150
|
+
temp_path.unlink()
|
|
95
151
|
|
|
96
152
|
def _close(self) -> None:
|
|
97
153
|
"""Close any open workbook handles."""
|
|
@@ -6,15 +6,15 @@
|
|
|
6
6
|
|
|
7
7
|
from datetime import datetime, timedelta
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from tempfile import NamedTemporaryFile
|
|
10
9
|
from typing import Callable, Optional, Protocol, cast
|
|
11
10
|
from xml.etree import ElementTree as ET
|
|
12
|
-
from zipfile import
|
|
13
|
-
|
|
11
|
+
from zipfile import ZipFile
|
|
14
12
|
from mformat.mformat import PathLike
|
|
13
|
+
from openpyxl.writer.theme import theme_xml
|
|
15
14
|
from pylightxl import Database, writexl # type: ignore[import-untyped]
|
|
16
15
|
from pylightxl import pylightxl as pylightxl_impl
|
|
17
|
-
|
|
16
|
+
from tableio._archive_rewrite import rewrite_zip_archive, \
|
|
17
|
+
temporary_output_path
|
|
18
18
|
from tableio.capability import CAP_IMPLEMENTED, CAP_IGNORED, Capabilities
|
|
19
19
|
from tableio.tableio import Descriptor, FileAccess
|
|
20
20
|
from tableio.tableio_excelbased import TableIOExcelBased
|
|
@@ -292,6 +292,11 @@ def _styles_xml() -> bytes:
|
|
|
292
292
|
return text.encode('utf-8')
|
|
293
293
|
|
|
294
294
|
|
|
295
|
+
def _theme_xml() -> bytes:
|
|
296
|
+
"""Return the standard Excel theme XML."""
|
|
297
|
+
return theme_xml.encode('utf-8')
|
|
298
|
+
|
|
299
|
+
|
|
295
300
|
class TableIOExcelPylightxl(TableIOExcelBased):
|
|
296
301
|
"""TableIO reader/writer class for Excel files using pylightxl.
|
|
297
302
|
|
|
@@ -399,11 +404,7 @@ class TableIOExcelPylightxl(TableIOExcelBased):
|
|
|
399
404
|
@staticmethod
|
|
400
405
|
def _temporary_workbook_path(source_path: Path) -> Path:
|
|
401
406
|
"""Return a temporary workbook path that does not yet exist."""
|
|
402
|
-
|
|
403
|
-
suffix='.xlsx') as temp_file:
|
|
404
|
-
temp_path = Path(temp_file.name)
|
|
405
|
-
temp_path.unlink()
|
|
406
|
-
return temp_path
|
|
407
|
+
return temporary_output_path(source_path, '.xlsx')
|
|
407
408
|
|
|
408
409
|
@staticmethod
|
|
409
410
|
def _invalid_placeholder_cell(cell: ET.Element) -> bool:
|
|
@@ -431,70 +432,91 @@ class TableIOExcelPylightxl(TableIOExcelBased):
|
|
|
431
432
|
return True
|
|
432
433
|
|
|
433
434
|
def _rewrite_workbook_xml(self, file_name: Path) -> None:
|
|
434
|
-
"""Clean written worksheet XML and add
|
|
435
|
-
style_metadata = any(self._sheet_style_codes.values())
|
|
435
|
+
"""Clean written worksheet XML and add required workbook metadata."""
|
|
436
436
|
sheet_targets = _sheet_xml_targets(str(file_name))
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
def _content_types_with_styles(self, data: bytes) -> bytes:
|
|
462
|
-
"""Return content types XML updated with the styles part."""
|
|
437
|
+
|
|
438
|
+
def rewrite_entry(item: object, data: bytes) -> Optional[bytes]:
|
|
439
|
+
"""Rewrite or drop one workbook archive entry when needed."""
|
|
440
|
+
file_name_text = getattr(item, 'filename')
|
|
441
|
+
if file_name_text in ['xl/styles.xml', 'xl/theme/theme1.xml']:
|
|
442
|
+
return None
|
|
443
|
+
if file_name_text == '[Content_Types].xml':
|
|
444
|
+
return self._content_types_with_required_parts(data)
|
|
445
|
+
if file_name_text == 'xl/_rels/workbook.xml.rels':
|
|
446
|
+
return self._workbook_rels_with_required_parts(data)
|
|
447
|
+
if file_name_text.startswith('xl/worksheets/'):
|
|
448
|
+
return self._worksheet_xml_for_output(file_name_text,
|
|
449
|
+
data,
|
|
450
|
+
sheet_targets)
|
|
451
|
+
return data
|
|
452
|
+
rewrite_zip_archive(
|
|
453
|
+
file_name,
|
|
454
|
+
rewrite_entry,
|
|
455
|
+
{'xl/styles.xml': _styles_xml(),
|
|
456
|
+
'xl/theme/theme1.xml': _theme_xml()}
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
def _content_types_with_required_parts(self, data: bytes) -> bytes:
|
|
460
|
+
"""Return content types XML updated with styles and theme parts."""
|
|
463
461
|
root = ET.fromstring(data)
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
462
|
+
required_parts = {
|
|
463
|
+
'/xl/styles.xml': (
|
|
464
|
+
'application/vnd.openxmlformats-officedocument.'
|
|
465
|
+
'spreadsheetml.styles+xml'
|
|
466
|
+
),
|
|
467
|
+
'/xl/theme/theme1.xml': (
|
|
468
|
+
'application/vnd.openxmlformats-officedocument.theme+xml'
|
|
469
|
+
)
|
|
470
|
+
}
|
|
471
|
+
existing_parts = {
|
|
472
|
+
override.get('PartName'): override
|
|
473
|
+
for override in root.findall('content:Override', _XML_NS)
|
|
474
|
+
if override.get('PartName') is not None
|
|
475
|
+
}
|
|
476
|
+
for part_name, content_type in required_parts.items():
|
|
477
|
+
override = existing_parts.get(part_name)
|
|
478
|
+
if override is not None:
|
|
469
479
|
override.set('ContentType', content_type)
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
480
|
+
continue
|
|
481
|
+
ET.SubElement(root, '{%s}Override' % _XML_NS['content'],
|
|
482
|
+
{'PartName': part_name,
|
|
483
|
+
'ContentType': content_type})
|
|
473
484
|
return _xml_bytes(root)
|
|
474
485
|
|
|
475
|
-
def
|
|
476
|
-
"""Return workbook relations XML updated with
|
|
486
|
+
def _workbook_rels_with_required_parts(self, data: bytes) -> bytes:
|
|
487
|
+
"""Return workbook relations XML updated with styles and theme."""
|
|
477
488
|
root = ET.fromstring(data)
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
489
|
+
required_relationships = {
|
|
490
|
+
('http://schemas.openxmlformats.org/officeDocument/2006/'
|
|
491
|
+
'relationships/styles'): 'styles.xml',
|
|
492
|
+
('http://schemas.openxmlformats.org/officeDocument/2006/'
|
|
493
|
+
'relationships/theme'): 'theme/theme1.xml'
|
|
494
|
+
}
|
|
495
|
+
existing_relationships = {
|
|
496
|
+
relationship.get('Type'): relationship
|
|
497
|
+
for relationship in root.findall('rels:Relationship', _XML_NS)
|
|
498
|
+
if relationship.get('Type') is not None
|
|
499
|
+
}
|
|
484
500
|
used_ids = {
|
|
485
501
|
relationship.get('Id')
|
|
486
502
|
for relationship in root.findall('rels:Relationship', _XML_NS)
|
|
487
503
|
if relationship.get('Id') is not None
|
|
488
504
|
}
|
|
489
505
|
next_index = 1
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
506
|
+
for relationship_type, target in required_relationships.items():
|
|
507
|
+
relationship = existing_relationships.get(relationship_type)
|
|
508
|
+
if relationship is not None:
|
|
509
|
+
relationship.set('Target', target)
|
|
510
|
+
continue
|
|
493
511
|
next_id = f'rId{next_index}'
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
512
|
+
while next_id in used_ids:
|
|
513
|
+
next_index += 1
|
|
514
|
+
next_id = f'rId{next_index}'
|
|
515
|
+
ET.SubElement(root, '{%s}Relationship' % _XML_NS['rels'],
|
|
516
|
+
{'Id': next_id,
|
|
517
|
+
'Type': relationship_type,
|
|
518
|
+
'Target': target})
|
|
519
|
+
used_ids.add(next_id)
|
|
498
520
|
return _xml_bytes(root)
|
|
499
521
|
|
|
500
522
|
def _entry_style_codes(
|
|
@@ -4,11 +4,15 @@
|
|
|
4
4
|
# Copyright (c) 2026 Tom Björkholm
|
|
5
5
|
# MIT License
|
|
6
6
|
|
|
7
|
+
from pathlib import Path
|
|
7
8
|
from typing import Any, Callable, Optional
|
|
9
|
+
from xml.etree import ElementTree as ET
|
|
8
10
|
from odfdo import Cell, Document, Element, Style, Table
|
|
9
11
|
from odfdo.body import Spreadsheet
|
|
10
12
|
from odfdo.utils import is_RFC3066
|
|
11
13
|
from mformat.mformat import PathLike
|
|
14
|
+
from tableio._archive_rewrite import rewrite_zip_archive, \
|
|
15
|
+
temporary_output_path
|
|
12
16
|
from tableio.color import Color
|
|
13
17
|
from tableio.tableio import Descriptor, FileAccess
|
|
14
18
|
from tableio.tableio_spreadsheetbased import TableIOSpreadsheetBased, \
|
|
@@ -23,8 +27,96 @@ _HIGHLIGHT_RGB: dict[Color, str] = {
|
|
|
23
27
|
Color.GREEN: '#c6efce',
|
|
24
28
|
Color.YELLOW: '#ffff00'
|
|
25
29
|
}
|
|
26
|
-
|
|
27
30
|
_COLUMN_WIDTH_CM_PER_UNIT = 0.25
|
|
31
|
+
_XML_NS = {
|
|
32
|
+
'manifest': 'urn:oasis:names:tc:opendocument:xmlns:manifest:1.0',
|
|
33
|
+
'office': 'urn:oasis:names:tc:opendocument:xmlns:office:1.0',
|
|
34
|
+
'style': 'urn:oasis:names:tc:opendocument:xmlns:style:1.0',
|
|
35
|
+
'table': 'urn:oasis:names:tc:opendocument:xmlns:table:1.0',
|
|
36
|
+
'text': 'urn:oasis:names:tc:opendocument:xmlns:text:1.0',
|
|
37
|
+
'calcext': 'urn:org:documentfoundation:names:experimental:calc:'
|
|
38
|
+
'xmlns:calcext:1.0',
|
|
39
|
+
'svg': 'urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for _prefix, _namespace in _XML_NS.items():
|
|
43
|
+
ET.register_namespace(_prefix, _namespace)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _manifest_xml_without_configuration_entries(data: bytes) -> bytes:
|
|
47
|
+
"""Return manifest XML without unused Configurations2 file entries."""
|
|
48
|
+
root = ET.fromstring(data)
|
|
49
|
+
for entry in list(root.findall('manifest:file-entry', _XML_NS)):
|
|
50
|
+
full_path = entry.get('{%s}full-path' % _XML_NS['manifest'])
|
|
51
|
+
if full_path is None or not full_path.startswith('Configurations2/'):
|
|
52
|
+
continue
|
|
53
|
+
root.remove(entry)
|
|
54
|
+
return bytes(ET.tostring(root, encoding='utf-8',
|
|
55
|
+
xml_declaration=True))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _referenced_style_names(root: ET.Element) -> set[str]:
|
|
59
|
+
"""Return the set of style names referenced from one XML tree."""
|
|
60
|
+
referenced: set[str] = set()
|
|
61
|
+
for element in root.iter():
|
|
62
|
+
for attr_name, attr_value in element.attrib.items():
|
|
63
|
+
if attr_name.endswith('}style-name') and attr_value:
|
|
64
|
+
referenced.add(attr_value)
|
|
65
|
+
return referenced
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _content_xml_without_unused_styles(data: bytes) -> bytes:
|
|
69
|
+
"""Return content XML with unused automatic styles removed."""
|
|
70
|
+
root = ET.fromstring(data)
|
|
71
|
+
automatic_styles = root.find('office:automatic-styles', _XML_NS)
|
|
72
|
+
if automatic_styles is None:
|
|
73
|
+
return bytes(ET.tostring(root, encoding='utf-8',
|
|
74
|
+
xml_declaration=True))
|
|
75
|
+
referenced_names = _referenced_style_names(root)
|
|
76
|
+
for style in list(automatic_styles.findall('style:style', _XML_NS)):
|
|
77
|
+
style_name = style.get('{%s}name' % _XML_NS['style'])
|
|
78
|
+
if style_name is None or style_name in referenced_names:
|
|
79
|
+
continue
|
|
80
|
+
automatic_styles.remove(style)
|
|
81
|
+
return bytes(ET.tostring(root, encoding='utf-8',
|
|
82
|
+
xml_declaration=True))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _styles_xml_with_required_defaults(data: bytes) -> bytes:
|
|
86
|
+
"""Return styles XML with default table and table-row styles added."""
|
|
87
|
+
root = ET.fromstring(data)
|
|
88
|
+
office_styles = root.find('office:styles', _XML_NS)
|
|
89
|
+
if office_styles is None:
|
|
90
|
+
return bytes(ET.tostring(root, encoding='utf-8',
|
|
91
|
+
xml_declaration=True))
|
|
92
|
+
existing_families = {
|
|
93
|
+
style.get('{%s}family' % _XML_NS['style'])
|
|
94
|
+
for style in office_styles.findall('style:default-style', _XML_NS)
|
|
95
|
+
}
|
|
96
|
+
for family in ['table', 'table-row']:
|
|
97
|
+
if family in existing_families:
|
|
98
|
+
continue
|
|
99
|
+
ET.SubElement(office_styles, '{%s}default-style' % _XML_NS['style'],
|
|
100
|
+
{'{%s}family' % _XML_NS['style']: family})
|
|
101
|
+
return bytes(ET.tostring(root, encoding='utf-8',
|
|
102
|
+
xml_declaration=True))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _rewrite_saved_document(file_name: Path) -> None:
|
|
106
|
+
"""Rewrite one saved ODS archive to remove validator complaints."""
|
|
107
|
+
def rewrite_entry(item: object, data: bytes) -> Optional[bytes]:
|
|
108
|
+
"""Rewrite or drop one ODS archive entry when needed."""
|
|
109
|
+
file_name_text = getattr(item, 'filename')
|
|
110
|
+
if file_name_text.startswith('Configurations2/'):
|
|
111
|
+
return None
|
|
112
|
+
if file_name_text == 'META-INF/manifest.xml':
|
|
113
|
+
return _manifest_xml_without_configuration_entries(data)
|
|
114
|
+
if file_name_text == 'content.xml':
|
|
115
|
+
return _content_xml_without_unused_styles(data)
|
|
116
|
+
if file_name_text == 'styles.xml':
|
|
117
|
+
return _styles_xml_with_required_defaults(data)
|
|
118
|
+
return data
|
|
119
|
+
rewrite_zip_archive(file_name, rewrite_entry)
|
|
28
120
|
|
|
29
121
|
|
|
30
122
|
class TableIOOdsOdfdo(TableIOSpreadsheetBased):
|
|
@@ -138,7 +230,15 @@ class TableIOOdsOdfdo(TableIOSpreadsheetBased):
|
|
|
138
230
|
if self.file_access == FileAccess.READ:
|
|
139
231
|
return
|
|
140
232
|
assert self.document is not None
|
|
141
|
-
|
|
233
|
+
file_path = Path(self.file_name)
|
|
234
|
+
temp_path = temporary_output_path(file_path, '.ods')
|
|
235
|
+
try:
|
|
236
|
+
self.document.save(temp_path)
|
|
237
|
+
_rewrite_saved_document(temp_path)
|
|
238
|
+
temp_path.replace(file_path)
|
|
239
|
+
finally:
|
|
240
|
+
if temp_path.exists():
|
|
241
|
+
temp_path.unlink()
|
|
142
242
|
|
|
143
243
|
def _close(self) -> None:
|
|
144
244
|
"""Release document references."""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tableio
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4
|
|
4
4
|
Summary: Uniform way to write table data to and read from different file formats
|
|
5
5
|
Author: Tom Björkholm
|
|
6
6
|
Author-email: Tom Björkholm <klausuler_linnet0q@icloud.com>
|
|
@@ -17,8 +17,8 @@ Requires-Dist: build>=1.4.2
|
|
|
17
17
|
Requires-Dist: wheel>=0.46.3
|
|
18
18
|
Requires-Dist: mformat-ext>=0.6
|
|
19
19
|
Requires-Dist: openpyxl>=3.1.5
|
|
20
|
-
Requires-Dist: types-openpyxl>=3.1.5.
|
|
21
|
-
Requires-Dist: odfdo>=3.22.
|
|
20
|
+
Requires-Dist: types-openpyxl>=3.1.5.20260402
|
|
21
|
+
Requires-Dist: odfdo>=3.22.4
|
|
22
22
|
Requires-Dist: xlsxwriter>=3.2.9
|
|
23
23
|
Requires-Dist: pylightxl>=1.61
|
|
24
24
|
Dynamic: author
|
|
@@ -138,14 +138,15 @@ a better start by reading the examples.
|
|
|
138
138
|
|
|
139
139
|
| Version | Date | Python version | Comment |
|
|
140
140
|
|---------|-------------|----------------|-----------------------------|
|
|
141
|
-
| 0.
|
|
141
|
+
| 0.4 | 2026 Apr 06 | 3.12 or newer | Fix .xlsx and .ods syntax |
|
|
142
|
+
| 0.3 | 2026 Apr 01 | 3.12 or newer | Improved API, more backends |
|
|
142
143
|
| 0.2 | 2026 Mar 28 | 3.12 or newer | More features, API changes |
|
|
143
144
|
| 0.1 | 2026 Mar 23 | 3.12 or newer | First released version |
|
|
144
145
|
|
|
145
146
|
## Test summary
|
|
146
147
|
|
|
147
|
-
- Test result:
|
|
148
|
+
- Test result: 1126 passed in 17s
|
|
148
149
|
- No flake8 warnings.
|
|
149
150
|
- No mypy errors found.
|
|
150
|
-
- Built version(s): 0.
|
|
151
|
+
- Built version(s): 0.4
|
|
151
152
|
- Build and test using Python 3.14.3
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|