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.
Files changed (34) hide show
  1. {tableio-0.3/tableio.egg-info → tableio-0.4}/PKG-INFO +7 -6
  2. {tableio-0.3 → tableio-0.4}/README.md +2 -2
  3. {tableio-0.3 → tableio-0.4}/README_pypi.md +4 -3
  4. {tableio-0.3 → tableio-0.4}/pyproject.toml +1 -1
  5. {tableio-0.3 → tableio-0.4}/setup.py +3 -3
  6. tableio-0.4/src/tableio/_archive_rewrite.py +80 -0
  7. {tableio-0.3 → tableio-0.4}/src/tableio/factory.py +15 -9
  8. {tableio-0.3 → tableio-0.4}/src/tableio/tableio.py +2 -0
  9. {tableio-0.3 → tableio-0.4}/src/tableio/tableio_excel_openpyxl.py +57 -1
  10. {tableio-0.3 → tableio-0.4}/src/tableio/tableio_excel_pylightxl.py +82 -60
  11. {tableio-0.3 → tableio-0.4}/src/tableio/tableio_ods_odfdo.py +102 -2
  12. {tableio-0.3 → tableio-0.4/tableio.egg-info}/PKG-INFO +7 -6
  13. {tableio-0.3 → tableio-0.4}/tableio.egg-info/SOURCES.txt +1 -0
  14. {tableio-0.3 → tableio-0.4}/tableio.egg-info/requires.txt +2 -2
  15. {tableio-0.3 → tableio-0.4}/LICENSE.txt +0 -0
  16. {tableio-0.3 → tableio-0.4}/setup.cfg +0 -0
  17. {tableio-0.3 → tableio-0.4}/src/tableio/__init__.py +0 -0
  18. {tableio-0.3 → tableio-0.4}/src/tableio/capability.py +0 -0
  19. {tableio-0.3 → tableio-0.4}/src/tableio/color.py +0 -0
  20. {tableio-0.3 → tableio-0.4}/src/tableio/optional_args.py +0 -0
  21. {tableio-0.3 → tableio-0.4}/src/tableio/py.typed +0 -0
  22. {tableio-0.3 → tableio-0.4}/src/tableio/reg_pkg_formats.py +0 -0
  23. {tableio-0.3 → tableio-0.4}/src/tableio/tableio_csv.py +0 -0
  24. {tableio-0.3 → tableio-0.4}/src/tableio/tableio_excel_xlsxwriter.py +0 -0
  25. {tableio-0.3 → tableio-0.4}/src/tableio/tableio_excelbased.py +0 -0
  26. {tableio-0.3 → tableio-0.4}/src/tableio/tableio_mformat.py +0 -0
  27. {tableio-0.3 → tableio-0.4}/src/tableio/tableio_mformatbased.py +0 -0
  28. {tableio-0.3 → tableio-0.4}/src/tableio/tableio_spreadsheetbased.py +0 -0
  29. {tableio-0.3 → tableio-0.4}/src/tableio/tableio_textbased.py +0 -0
  30. {tableio-0.3 → tableio-0.4}/src/tableio/tableio_types.py +0 -0
  31. {tableio-0.3 → tableio-0.4}/src/tableio/value_type.py +0 -0
  32. {tableio-0.3 → tableio-0.4}/src/tableio/valueconversion.py +0 -0
  33. {tableio-0.3 → tableio-0.4}/tableio.egg-info/dependency_links.txt +0 -0
  34. {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
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.20260322
21
- Requires-Dist: odfdo>=3.22.3
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.3 | 2023 Apr 01 | 3.12 or newer | Improved API, more backends |
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: 1010 passed in 14s
148
+ - Test result: 1126 passed in 17s
148
149
  - No flake8 warnings.
149
150
  - No mypy errors found.
150
- - Built version(s): 0.3
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: 1010 passed in 14s
96
+ - Test result: 1126 passed in 17s
97
97
  - No flake8 warnings.
98
98
  - No mypy errors found.
99
- - Built version(s): 0.3
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.3 | 2023 Apr 01 | 3.12 or newer | Improved API, more backends |
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: 1010 passed in 14s
120
+ - Test result: 1126 passed in 17s
120
121
  - No flake8 warnings.
121
122
  - No mypy errors found.
122
- - Built version(s): 0.3
123
+ - Built version(s): 0.4
123
124
  - Build and test using Python 3.14.3
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tableio"
7
- version = "0.3"
7
+ version = "0.4"
8
8
  authors = [
9
9
  { name="Tom Björkholm", email="klausuler_linnet0q@icloud.com" },
10
10
  ]
@@ -5,7 +5,7 @@ from setuptools import setup
5
5
 
6
6
  setup(
7
7
  name='tableio',
8
- version='0.3',
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.20260322',
25
- 'odfdo >= 3.22.3',
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
- fikeys = list(format_info._registry.keys()) # pylint: disable=protected-access # noqa: E501
420
- if implementation not in fikeys:
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 implementation not in bmatches_impls:
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 = implementation
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
- self.workbook.save(self.file_name)
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 ZIP_DEFLATED, ZipFile
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
- with NamedTemporaryFile(delete=False, dir=source_path.parent,
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 style metadata when needed."""
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
- temp_path = self._temporary_workbook_path(file_name)
438
- with ZipFile(file_name, 'r') as source_zip, \
439
- ZipFile(temp_path, 'w',
440
- compression=ZIP_DEFLATED) as target_zip:
441
- written_names: set[str] = set()
442
- for item in source_zip.infolist():
443
- if style_metadata and item.filename == 'xl/styles.xml':
444
- continue
445
- data = source_zip.read(item.filename)
446
- if style_metadata and item.filename == '[Content_Types].xml':
447
- data = self._content_types_with_styles(data)
448
- elif style_metadata and \
449
- item.filename == 'xl/_rels/workbook.xml.rels':
450
- data = self._workbook_rels_with_styles(data)
451
- elif item.filename.startswith('xl/worksheets/'):
452
- data = self._worksheet_xml_for_output(item.filename,
453
- data,
454
- sheet_targets)
455
- target_zip.writestr(item, data)
456
- written_names.add(item.filename)
457
- if style_metadata and 'xl/styles.xml' not in written_names:
458
- target_zip.writestr('xl/styles.xml', _styles_xml())
459
- temp_path.replace(file_name)
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
- part_name = '/xl/styles.xml'
465
- content_type = ('application/vnd.openxmlformats-officedocument.'
466
- 'spreadsheetml.styles+xml')
467
- for override in root.findall('content:Override', _XML_NS):
468
- if override.get('PartName') == part_name:
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
- return _xml_bytes(root)
471
- ET.SubElement(root, '{%s}Override' % _XML_NS['content'],
472
- {'PartName': part_name, 'ContentType': content_type})
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 _workbook_rels_with_styles(self, data: bytes) -> bytes:
476
- """Return workbook relations XML updated with the styles relation."""
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
- style_type = ('http://schemas.openxmlformats.org/officeDocument/2006/'
479
- 'relationships/styles')
480
- for relationship in root.findall('rels:Relationship', _XML_NS):
481
- if relationship.get('Type') == style_type:
482
- relationship.set('Target', 'styles.xml')
483
- return _xml_bytes(root)
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
- next_id = f'rId{next_index}'
491
- while next_id in used_ids:
492
- next_index += 1
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
- ET.SubElement(root, '{%s}Relationship' % _XML_NS['rels'],
495
- {'Id': next_id,
496
- 'Type': style_type,
497
- 'Target': 'styles.xml'})
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
- self.document.save(self.file_name)
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
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.20260322
21
- Requires-Dist: odfdo>=3.22.3
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.3 | 2023 Apr 01 | 3.12 or newer | Improved API, more backends |
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: 1010 passed in 14s
148
+ - Test result: 1126 passed in 17s
148
149
  - No flake8 warnings.
149
150
  - No mypy errors found.
150
- - Built version(s): 0.3
151
+ - Built version(s): 0.4
151
152
  - Build and test using Python 3.14.3
@@ -4,6 +4,7 @@ README_pypi.md
4
4
  pyproject.toml
5
5
  setup.py
6
6
  src/tableio/__init__.py
7
+ src/tableio/_archive_rewrite.py
7
8
  src/tableio/capability.py
8
9
  src/tableio/color.py
9
10
  src/tableio/factory.py
@@ -4,7 +4,7 @@ build>=1.4.2
4
4
  wheel>=0.46.3
5
5
  mformat-ext>=0.6
6
6
  openpyxl>=3.1.5
7
- types-openpyxl>=3.1.5.20260322
8
- odfdo>=3.22.3
7
+ types-openpyxl>=3.1.5.20260402
8
+ odfdo>=3.22.4
9
9
  xlsxwriter>=3.2.9
10
10
  pylightxl>=1.61
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes