62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
- 74
- 75
- 76
- 77
- 78
- 79
- 80
- 81
- 82
- 83
- 84
- 85
- 86
- 87
- 88
- 89
- 90
- 91
- 92
- 93
- 94
- 95
- 96
- 97
- 98
- 99
-100
-101
-102
-103
-104
-105
-106
-107
-108
-109
-110
-111
-112
-113
-114
-115
-116
-117
-118
-119
-120
-121
-122
-123
-124
-125
-126
-127
-128
-129
-130
-131
-132
-133
-134
-135
-136
-137
-138
-139
-140
-141
-142
-143
-144
-145
-146
-147
-148
-149
-150
-151
-152
-153
-154
-155
-156
-157
-158
-159
-160
-161
-162
-163
-164
-165
-166
-167
-168
-169
-170
-171
-172
-173
-174
-175
-176
-177
-178
-179
-180
-181
-182
-183
-184
-185
-186
-187
-188
-189
-190
-191
-192
-193
-194
-195
-196
-197
-198
-199
-200
-201
-202
-203
-204
-205
-206
-207
-208
-209
-210
-211
-212
-213
-214
-215
-216
-217
-218
-219
-220
-221
+ 221
222
223
224
@@ -14957,440 +14906,627 @@
492
493
494
-495 | class Container:
- """Representation of the ODF file."""
-
- def __init__(self, path: Path | str | io.BytesIO | None = None) -> None:
- self.__parts: dict[str, bytes | None] = {}
- self.__parts_ts: dict[str, int] = {}
- self.__path_like: Path | str | io.BytesIO | None = None
- self.__packaging: str = ZIP
- self.path: Path | None = None # or Path
- if path:
- self.open(path)
-
- def __repr__(self) -> str:
- return f"<{self.__class__.__name__} type={self.mimetype} path={self.path}>"
-
- def open(self, path_or_file: Path | str | io.BytesIO) -> None:
- """Load the content of an ODF file."""
- self.__path_like = path_or_file
- if isinstance(path_or_file, (str, Path)):
- self.path = Path(path_or_file).expanduser()
- if not self.path.exists():
- raise FileNotFoundError(str(self.path))
- self.__path_like = self.path
- if (self.path or isinstance(self.__path_like, io.BytesIO)) and is_zipfile(
- self.__path_like # type: ignore
- ):
- self.__packaging = ZIP
- return self._read_zip()
- if self.path:
- is_folder = False
- with contextlib.suppress(OSError):
- is_folder = self.path.is_dir()
- if is_folder:
- self.__packaging = FOLDER
- return self._read_folder()
- raise TypeError(f"Document format not managed by odfdo: {type(path_or_file)}.")
-
- def _read_zip(self) -> None:
- if isinstance(self.__path_like, io.BytesIO):
- self.__path_like.seek(0)
- with ZipFile(self.__path_like) as zf: # type: ignore
- mimetype = bytes_to_str(zf.read("mimetype"))
- if mimetype not in ODF_MIMETYPES:
- raise ValueError(f"Document of unknown type {mimetype}")
- self.__parts["mimetype"] = str_to_bytes(mimetype)
- if self.path is None:
- if isinstance(self.__path_like, io.BytesIO):
- self.__path_like.seek(0)
- # read the full file at once and forget file
- with ZipFile(self.__path_like) as zf: # type: ignore
- for name in zf.namelist():
- upath = normalize_path(name)
- self.__parts[upath] = zf.read(name)
- self.__path_like = None
-
- def _read_folder(self) -> None:
- try:
- mimetype, timestamp = self._get_folder_part("mimetype")
- except OSError:
- printwarn("Corrupted or not an OpenDocument folder (missing mimetype)")
- mimetype = b""
- timestamp = int(time.time())
- if bytes_to_str(mimetype) not in ODF_MIMETYPES:
- message = f"Document of unknown type {mimetype!r}, try with ODF Text."
- printwarn(message)
- self.__parts["mimetype"] = str_to_bytes(ODF_EXTENSIONS["odt"])
- self.__parts_ts["mimetype"] = timestamp
-
- def _parse_folder(self, folder: str) -> list[str]:
- parts = []
- if self.path is None:
- raise ValueError("Document path is not defined")
- root = self.path / folder
- for path in root.iterdir():
- if path.name.startswith("."): # no hidden files
- continue
- relative_path = path.relative_to(self.path)
- if path.is_file():
- parts.append(relative_path.as_posix())
- if path.is_dir():
- sub_parts = self._parse_folder(str(relative_path))
- if sub_parts:
- parts.extend(sub_parts)
- else:
- # store leaf directories
- parts.append(relative_path.as_posix() + "/")
- return parts
-
- def _get_folder_parts(self) -> list[str]:
- """Get the list of members in the ODF folder."""
- return self._parse_folder("")
-
- def _get_folder_part(self, name: str) -> tuple[bytes, int]:
- """Get bytes of a part from the ODF folder, with timestamp."""
- if self.path is None:
- raise ValueError("Document path is not defined")
- path = self.path / name
- timestamp = int(path.stat().st_mtime)
- if path.is_dir():
- return (b"", timestamp)
- return (path.read_bytes(), timestamp)
-
- def _get_folder_part_timestamp(self, name: str) -> int:
- if self.path is None:
- raise ValueError("Document path is not defined")
- path = self.path / name
- try:
- timestamp = int(path.stat().st_mtime)
- except OSError:
- timestamp = -1
- return timestamp
-
- def _get_zip_part(self, name: str) -> bytes | None:
- """Get bytes of a part from the Zip ODF file. No cache."""
- if self.path is None:
- raise ValueError("Document path is not defined")
- try:
- with ZipFile(self.path) as zf:
- upath = normalize_path(name)
- self.__parts[upath] = zf.read(name)
- return self.__parts[upath]
- except BadZipfile:
- return None
-
- def _get_all_zip_part(self) -> None:
- """Read all parts. No cache."""
- if self.path is None:
- raise ValueError("Document path is not defined")
- try:
- with ZipFile(self.path) as zf:
- for name in zf.namelist():
- upath = normalize_path(name)
- self.__parts[upath] = zf.read(name)
- except BadZipfile:
- pass
-
- def _save_zip(self, target: str | Path | io.BytesIO) -> None:
- """Save a Zip ODF from the available parts."""
- parts = self.__parts
- with ZipFile(target, "w", compression=ZIP_DEFLATED) as filezip:
- # Parts to save, except manifest at the end
- part_names = list(parts.keys())
- try:
- part_names.remove(ODF_MANIFEST)
- except ValueError:
- printwarn(f"Missing '{ODF_MANIFEST}'")
- # "Pretty-save" parts in some order
- # mimetype requires to be first and uncompressed
- mimetype = parts.get("mimetype")
- if mimetype is None:
- raise ValueError("Mimetype is not defined")
- try:
- filezip.writestr("mimetype", mimetype, ZIP_STORED)
- part_names.remove("mimetype")
- except (ValueError, KeyError):
- printwarn("Missing 'mimetype'")
- # XML parts
- for path in ODF_CONTENT, ODF_META, ODF_SETTINGS, ODF_STYLES:
- if path not in parts:
- printwarn(f"Missing '{path}'")
- continue
- part = parts[path]
- if part is None:
- continue
- filezip.writestr(path, part)
- part_names.remove(path)
- # Everything else
- for path in part_names:
- data = parts[path]
- if data is None:
- # Deleted
- continue
- filezip.writestr(path, data)
- # Manifest
- with contextlib.suppress(KeyError):
- part = parts[ODF_MANIFEST]
- if part is not None:
- filezip.writestr(ODF_MANIFEST, part)
-
- def _save_folder(self, folder: Path | str) -> None:
- """Save a folder ODF from the available parts."""
-
- def dump(part_path: str, content: bytes) -> None:
- if part_path.endswith("/"): # folder
- is_folder = True
- pure_path = PurePath(folder, part_path[:-1])
- else:
- is_folder = False
- pure_path = PurePath(folder, part_path)
- path = Path(pure_path)
- if is_folder:
- path.mkdir(parents=True, exist_ok=True)
- else:
- path.parent.mkdir(parents=True, exist_ok=True)
- path.write_bytes(content)
- path.chmod(0o666)
-
- for part_path, data in self.__parts.items():
- if data is None:
- # Deleted
- continue
- dump(part_path, data)
-
- def _xml_content(self) -> bytes:
- mimetype = self.__parts["mimetype"].decode("utf8")
- doc_xml = (
- OFFICE_PREFIX.decode("utf8")
- + f'office:version="{OFFICE_VERSION}"\n'
- + f'office:mimetype="{mimetype}">'
- + "</office:document>"
- )
- root = fromstring(doc_xml.encode("utf8"))
- for path in ODF_META, ODF_SETTINGS, ODF_STYLES, ODF_CONTENT:
- if path not in self.__parts:
- printwarn(f"Missing '{path}'")
- continue
- part = self.__parts[path]
- if part is None:
- continue
- if isinstance(part, bytes):
- xpart = fromstring(part)
- else:
- xpart = part
- for child in xpart:
- root.append(child)
- return tostring(root, encoding="UTF-8", xml_declaration=True)
+495
+496
+497
+498
+499
+500
+501
+502
+503
+504
+505
+506
+507
+508
+509
+510
+511
+512
+513
+514
+515
+516
+517
+518
+519
+520
+521
+522
+523
+524
+525
+526
+527
+528
+529
+530
+531
+532
+533
+534
+535
+536
+537
+538
+539
+540
+541
+542
+543
+544
+545
+546
+547
+548
+549
+550
+551
+552
+553
+554
+555
+556
+557
+558
+559
+560
+561
+562
+563
+564
+565
+566
+567
+568
+569
+570
+571
+572
+573
+574
+575
+576
+577
+578
+579
+580
+581
+582
+583
+584
+585
+586
+587
+588
+589
+590
+591
+592
+593
+594
+595
+596
+597
+598
+599
+600
+601
+602
+603
+604
+605
+606
+607
+608
+609
+610
+611
+612
+613
+614
+615
+616
+617
+618
+619
+620
+621
+622
+623
+624
+625
+626
+627
+628
+629
+630
+631
+632
+633
+634
+635
+636
+637
+638
+639
+640
+641
+642
+643
+644
+645
+646
+647
+648
+649
+650
+651
+652
+653
+654
+655
+656
+657
+658
+659
+660
+661
+662
+663
+664
+665
+666
+667
+668
| class Container:
+ """Representation of the ODF file."""
+
+ def __init__(self, path: Path | str | io.BytesIO | None = None) -> None:
+ self.__parts: dict[str, bytes | None] = {}
+ self.__parts_ts: dict[str, int] = {}
+ self.__path_like: Path | str | io.BytesIO | None = None
+ self.__packaging: str = ZIP
+ self.path: Path | None = None # or Path
+ if path:
+ self.open(path)
+
+ def __repr__(self) -> str:
+ return f"<{self.__class__.__name__} type={self.mimetype} path={self.path}>"
+
+ def open(self, path_or_file: Path | str | io.BytesIO) -> None:
+ """Load the content of an ODF file."""
+ self.__path_like = path_or_file
+ if isinstance(path_or_file, (str, Path)):
+ self.path = Path(path_or_file).expanduser()
+ if not self.path.exists():
+ raise FileNotFoundError(str(self.path))
+ self.__path_like = self.path
+ if (self.path or isinstance(self.__path_like, io.BytesIO)) and is_zipfile(
+ self.__path_like # type: ignore
+ ):
+ self.__packaging = ZIP
+ return self._read_zip()
+ if self.path:
+ is_folder = False
+ with contextlib.suppress(OSError):
+ is_folder = self.path.is_dir()
+ if is_folder:
+ self.__packaging = FOLDER
+ return self._read_folder()
+ raise TypeError(f"Document format not managed by odfdo: {type(path_or_file)}.")
+
+ def _read_zip(self) -> None:
+ if isinstance(self.__path_like, io.BytesIO):
+ self.__path_like.seek(0)
+ with ZipFile(self.__path_like) as zf: # type: ignore
+ mimetype = bytes_to_str(zf.read("mimetype"))
+ if mimetype not in ODF_MIMETYPES:
+ raise ValueError(f"Document of unknown type {mimetype}")
+ self.__parts["mimetype"] = str_to_bytes(mimetype)
+ if self.path is None:
+ if isinstance(self.__path_like, io.BytesIO):
+ self.__path_like.seek(0)
+ # read the full file at once and forget file
+ with ZipFile(self.__path_like) as zf: # type: ignore
+ for name in zf.namelist():
+ upath = normalize_path(name)
+ self.__parts[upath] = zf.read(name)
+ self.__path_like = None
+
+ def _read_folder(self) -> None:
+ try:
+ mimetype, timestamp = self._get_folder_part("mimetype")
+ except OSError:
+ printwarn("Corrupted or not an OpenDocument folder (missing mimetype)")
+ mimetype = b""
+ timestamp = int(time.time())
+ if bytes_to_str(mimetype) not in ODF_MIMETYPES:
+ message = f"Document of unknown type {mimetype!r}, try with ODF Text."
+ printwarn(message)
+ self.__parts["mimetype"] = str_to_bytes(ODF_EXTENSIONS["odt"])
+ self.__parts_ts["mimetype"] = timestamp
- def _save_xml(self, target: Path | str) -> None:
- """Save a XML flat ODF format from the available parts."""
- target = Path(target).with_suffix(".xml")
- target.write_bytes(self._xml_content())
-
- # Public API
-
- def get_parts(self) -> list[str]:
- """Get the list of members."""
- if not self.path:
- # maybe a file like zip archive
- return list(self.__parts.keys())
- if self.__packaging == ZIP:
- parts = []
- with ZipFile(self.path) as zf:
- for name in zf.namelist():
- upath = normalize_path(name)
- parts.append(upath)
- return parts
- elif self.__packaging == FOLDER:
- return self._get_folder_parts()
- else:
- raise ValueError("Unable to provide parts of the document")
+ def _parse_folder(self, folder: str) -> list[str]:
+ parts = []
+ if self.path is None:
+ raise ValueError("Document path is not defined")
+ root = self.path / folder
+ for path in root.iterdir():
+ if path.name.startswith("."): # no hidden files
+ continue
+ relative_path = path.relative_to(self.path)
+ if path.is_file():
+ parts.append(relative_path.as_posix())
+ if path.is_dir():
+ sub_parts = self._parse_folder(str(relative_path))
+ if sub_parts:
+ parts.extend(sub_parts)
+ else:
+ # store leaf directories
+ parts.append(relative_path.as_posix() + "/")
+ return parts
+
+ def _get_folder_parts(self) -> list[str]:
+ """Get the list of members in the ODF folder."""
+ return self._parse_folder("")
- @property
- def parts(self) -> list[str]:
- """Get the list of members."""
- return self.get_parts()
-
- def get_part(self, path: str) -> str | bytes | None:
- """Get the bytes of a part of the ODF."""
- path = str(path)
- if path in self.__parts:
- part = self.__parts[path]
- if part is None:
- raise ValueError(f'Part "{path}" is deleted')
- if self.__packaging == FOLDER:
- cache_ts = self.__parts_ts.get(path, -1)
- current_ts = self._get_folder_part_timestamp(path)
- if current_ts != cache_ts:
- part, timestamp = self._get_folder_part(path)
- self.__parts[path] = part
- self.__parts_ts[path] = timestamp
- return part
- if self.__packaging == ZIP:
- return self._get_zip_part(path)
- if self.__packaging == FOLDER:
- part, timestamp = self._get_folder_part(path)
- self.__parts[path] = part
- self.__parts_ts[path] = timestamp
- return part
- return None
-
- @property
- def mimetype(self) -> str:
- """Return str value of mimetype of the document."""
- with contextlib.suppress(Exception):
- b_mimetype = self.get_part("mimetype")
- if isinstance(b_mimetype, bytes):
- return bytes_to_str(b_mimetype)
- return ""
-
- @mimetype.setter
- def mimetype(self, mimetype: str | bytes) -> None:
- """Set mimetype value of the document."""
- if isinstance(mimetype, str):
- self.__parts["mimetype"] = str_to_bytes(mimetype)
- elif isinstance(mimetype, bytes):
- self.__parts["mimetype"] = mimetype
- else:
- raise TypeError(f'Wrong mimetype "{mimetype!r}"')
-
- def set_part(self, path: str, data: bytes) -> None:
- """Replace or add a new part."""
- self.__parts[path] = data
-
- def del_part(self, path: str) -> None:
- """Mark a part for deletion."""
- self.__parts[path] = None
-
- @property
- def clone(self) -> Container:
- """Make a copy of this container with no path."""
- if self.path and self.__packaging == ZIP:
- self._get_all_zip_part()
- clone = deepcopy(self)
- clone.path = None
- return clone
-
- def _backup_or_unlink(self, backup: bool, target: str | Path) -> None:
- if backup:
- self._do_backup(target)
- else:
- self._do_unlink(target)
-
- @staticmethod
- def _do_backup(target: str | Path) -> None:
- path = Path(target)
- if not path.exists():
- return
- back_file = Path(path.stem + ".backup" + path.suffix)
- if back_file.is_dir():
- try:
- shutil.rmtree(back_file)
- except OSError as e:
- printwarn(str(e))
- try:
- shutil.move(target, back_file)
- except OSError as e:
- printwarn(str(e))
+ def _get_folder_part(self, name: str) -> tuple[bytes, int]:
+ """Get bytes of a part from the ODF folder, with timestamp."""
+ if self.path is None:
+ raise ValueError("Document path is not defined")
+ path = self.path / name
+ timestamp = int(path.stat().st_mtime)
+ if path.is_dir():
+ return (b"", timestamp)
+ return (path.read_bytes(), timestamp)
+
+ def _get_folder_part_timestamp(self, name: str) -> int:
+ if self.path is None:
+ raise ValueError("Document path is not defined")
+ path = self.path / name
+ try:
+ timestamp = int(path.stat().st_mtime)
+ except OSError:
+ timestamp = -1
+ return timestamp
+
+ def _get_zip_part(self, name: str) -> bytes | None:
+ """Get bytes of a part from the Zip ODF file. No cache."""
+ if self.path is None:
+ raise ValueError("Document path is not defined")
+ try:
+ with ZipFile(self.path) as zf:
+ upath = normalize_path(name)
+ self.__parts[upath] = zf.read(name)
+ return self.__parts[upath]
+ except BadZipfile:
+ return None
+
+ def _get_all_zip_part(self) -> None:
+ """Read all parts. No cache."""
+ if self.path is None:
+ raise ValueError("Document path is not defined")
+ try:
+ with ZipFile(self.path) as zf:
+ for name in zf.namelist():
+ upath = normalize_path(name)
+ self.__parts[upath] = zf.read(name)
+ except BadZipfile:
+ pass
+
+ def _save_zip(self, target: str | Path | io.BytesIO) -> None:
+ """Save a Zip ODF from the available parts."""
+ parts = self.__parts
+ with ZipFile(target, "w", compression=ZIP_DEFLATED) as filezip:
+ # Parts to save, except manifest at the end
+ part_names = list(parts.keys())
+ try:
+ part_names.remove(ODF_MANIFEST)
+ except ValueError:
+ printwarn(f"Missing '{ODF_MANIFEST}'")
+ # "Pretty-save" parts in some order
+ # mimetype requires to be first and uncompressed
+ mimetype = parts.get("mimetype")
+ if mimetype is None:
+ raise ValueError("Mimetype is not defined")
+ try:
+ filezip.writestr("mimetype", mimetype, ZIP_STORED)
+ part_names.remove("mimetype")
+ except (ValueError, KeyError):
+ printwarn("Missing 'mimetype'")
+ # XML parts
+ for path in ODF_CONTENT, ODF_META, ODF_SETTINGS, ODF_STYLES:
+ if path not in parts:
+ printwarn(f"Missing '{path}'")
+ continue
+ part = parts[path]
+ if part is None:
+ continue
+ filezip.writestr(path, part)
+ part_names.remove(path)
+ # Everything else
+ for path in part_names:
+ data = parts[path]
+ if data is None:
+ # Deleted
+ continue
+ filezip.writestr(path, data)
+ # Manifest
+ with contextlib.suppress(KeyError):
+ part = parts[ODF_MANIFEST]
+ if part is not None:
+ filezip.writestr(ODF_MANIFEST, part)
- @staticmethod
- def _do_unlink(target: str | Path) -> None:
- path = Path(target)
- if path.exists():
- try:
- shutil.rmtree(path)
- except OSError as e:
- printwarn(str(e))
-
- def _clean_save_packaging(self, packaging: str | None) -> str:
- if not packaging:
- packaging = self.__packaging if self.__packaging else ZIP
- packaging = packaging.strip().lower()
- if packaging not in PACKAGING:
- raise ValueError(f'Packaging of type "{packaging}" is not supported')
- return packaging
-
- def _clean_save_target(
- self,
- target: str | Path | io.BytesIO | None,
- ) -> str | io.BytesIO:
- if target is None:
- target = self.path
- if isinstance(target, Path):
- target = str(target)
- if isinstance(target, str):
- while target.endswith(os.sep):
- target = target[:-1]
- while target.endswith(".folder"):
- target = target.split(".folder", 1)[0]
- return target # type: ignore
-
- def _save_as_zip(self, target: str | Path | io.BytesIO, backup: bool) -> None:
- if isinstance(target, (str, Path)) and backup:
- self._do_backup(target)
- self._save_zip(target)
-
- def _save_as_folder(self, target: str | Path, backup: bool) -> None:
- if not isinstance(target, (str, Path)):
- raise TypeError(
- f"Saving in folder format requires a folder name, not '{target!r}'"
- )
- if not str(target).endswith(".folder"):
- target = str(target) + ".folder"
- self._backup_or_unlink(backup, target)
- self._save_folder(target)
-
- def _save_as_xml(self, target: str | Path | io.BytesIO, backup: bool) -> None:
- if not isinstance(target, (str, Path)):
- raise TypeError(
- f"Saving in XML format requires a folder name, not '{target!r}'"
- )
- if not str(target).endswith(".xml"):
- target = str(target) + ".xml"
- if isinstance(target, (str, Path)) and backup:
- self._do_backup(target)
- self._save_xml(target)
-
- def save(
- self,
- target: str | Path | io.BytesIO | None,
- packaging: str | None = None,
- backup: bool = False,
- ) -> None:
- """Save the container to the given target, a path or a file-like
- object.
-
- Package the output document in the same format than current document,
- unless "packaging" is different.
-
- Arguments:
-
- target -- str or file-like or Path
-
- packaging -- 'zip', or for debugging purpose 'xml' or 'folder'
-
- backup -- boolean
- """
- parts = self.__parts
- packaging = self._clean_save_packaging(packaging)
- # Load parts else they will be considered deleted
- for path in self.parts:
- if path not in parts:
- self.get_part(path)
- target = self._clean_save_target(target)
- if packaging == FOLDER:
- if isinstance(target, io.BytesIO):
- raise TypeError(
- "Impossible to save on io.BytesIO with 'folder' packaging"
- )
- self._save_as_folder(target, backup)
- elif packaging == XML:
- self._save_as_xml(target, backup)
- else:
- # default:
- self._save_as_zip(target, backup)
+ def _save_folder(self, folder: Path | str) -> None:
+ """Save a folder ODF from the available parts."""
+
+ def dump(part_path: str, content: bytes) -> None:
+ if part_path.endswith("/"): # folder
+ is_folder = True
+ pure_path = PurePath(folder, part_path[:-1])
+ else:
+ is_folder = False
+ pure_path = PurePath(folder, part_path)
+ path = Path(pure_path)
+ if is_folder:
+ path.mkdir(parents=True, exist_ok=True)
+ else:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(content)
+ path.chmod(0o666)
+
+ for part_path, data in self.__parts.items():
+ if data is None:
+ # Deleted
+ continue
+ dump(part_path, data)
+
+ def _xml_content(self, pretty: bool = True) -> bytes:
+ mimetype = self.__parts["mimetype"].decode("utf8")
+ doc_xml = (
+ OFFICE_PREFIX.decode("utf8")
+ + f'office:version="{OFFICE_VERSION}"\n'
+ + f'office:mimetype="{mimetype}">'
+ + "</office:document>"
+ )
+ root = fromstring(doc_xml.encode("utf8"))
+ for path in ODF_META, ODF_SETTINGS, ODF_STYLES, ODF_CONTENT:
+ if path not in self.__parts:
+ printwarn(f"Missing '{path}'")
+ continue
+ part = self.__parts[path]
+ if part is None:
+ continue
+ if isinstance(part, bytes):
+ xpart = fromstring(part)
+ else:
+ xpart = part
+ for child in xpart:
+ root.append(child)
+ if pretty:
+ xml_header = b'<?xml version="1.0" encoding="UTF-8"?>\n'
+ bytes_tree = tostring(
+ pretty_indent(root),
+ encoding="unicode",
+ ).encode("utf8")
+ return xml_header + bytes_tree
+ else:
+ return tostring(root, encoding="UTF-8", xml_declaration=True)
+
+ def _save_xml(self, target: Path | str, pretty: bool = True) -> None:
+ """Save a XML flat ODF format from the available parts."""
+ target = Path(target).with_suffix(".xml")
+ target.write_bytes(self._xml_content(pretty))
+
+ # Public API
+
+ def get_parts(self) -> list[str]:
+ """Get the list of members."""
+ if not self.path:
+ # maybe a file like zip archive
+ return list(self.__parts.keys())
+ if self.__packaging == ZIP:
+ parts = []
+ with ZipFile(self.path) as zf:
+ for name in zf.namelist():
+ upath = normalize_path(name)
+ parts.append(upath)
+ return parts
+ elif self.__packaging == FOLDER:
+ return self._get_folder_parts()
+ else:
+ raise ValueError("Unable to provide parts of the document")
+
+ @property
+ def parts(self) -> list[str]:
+ """Get the list of members."""
+ return self.get_parts()
+
+ def get_part(self, path: str) -> str | bytes | None:
+ """Get the bytes of a part of the ODF."""
+ path = str(path)
+ if path in self.__parts:
+ part = self.__parts[path]
+ if part is None:
+ raise ValueError(f'Part "{path}" is deleted')
+ if self.__packaging == FOLDER:
+ cache_ts = self.__parts_ts.get(path, -1)
+ current_ts = self._get_folder_part_timestamp(path)
+ if current_ts != cache_ts:
+ part, timestamp = self._get_folder_part(path)
+ self.__parts[path] = part
+ self.__parts_ts[path] = timestamp
+ return part
+ if self.__packaging == ZIP:
+ return self._get_zip_part(path)
+ if self.__packaging == FOLDER:
+ part, timestamp = self._get_folder_part(path)
+ self.__parts[path] = part
+ self.__parts_ts[path] = timestamp
+ return part
+ return None
+
+ @property
+ def mimetype(self) -> str:
+ """Return str value of mimetype of the document."""
+ with contextlib.suppress(Exception):
+ b_mimetype = self.get_part("mimetype")
+ if isinstance(b_mimetype, bytes):
+ return bytes_to_str(b_mimetype)
+ return ""
+
+ @mimetype.setter
+ def mimetype(self, mimetype: str | bytes) -> None:
+ """Set mimetype value of the document."""
+ if isinstance(mimetype, str):
+ self.__parts["mimetype"] = str_to_bytes(mimetype)
+ elif isinstance(mimetype, bytes):
+ self.__parts["mimetype"] = mimetype
+ else:
+ raise TypeError(f'Wrong mimetype "{mimetype!r}"')
+
+ def set_part(self, path: str, data: bytes) -> None:
+ """Replace or add a new part."""
+ self.__parts[path] = data
+
+ def del_part(self, path: str) -> None:
+ """Mark a part for deletion."""
+ self.__parts[path] = None
+
+ @property
+ def clone(self) -> Container:
+ """Make a copy of this container with no path."""
+ if self.path and self.__packaging == ZIP:
+ self._get_all_zip_part()
+ clone = deepcopy(self)
+ clone.path = None
+ return clone
+
+ def _backup_or_unlink(self, backup: bool, target: str | Path) -> None:
+ if backup:
+ self._do_backup(target)
+ else:
+ self._do_unlink(target)
+
+ @staticmethod
+ def _do_backup(target: str | Path) -> None:
+ path = Path(target)
+ if not path.exists():
+ return
+ back_file = Path(path.stem + ".backup" + path.suffix)
+ if back_file.is_dir():
+ try:
+ shutil.rmtree(back_file)
+ except OSError as e:
+ printwarn(str(e))
+ try:
+ shutil.move(target, back_file)
+ except OSError as e:
+ printwarn(str(e))
+
+ @staticmethod
+ def _do_unlink(target: str | Path) -> None:
+ path = Path(target)
+ if path.exists():
+ try:
+ shutil.rmtree(path)
+ except OSError as e:
+ printwarn(str(e))
+
+ def _clean_save_packaging(self, packaging: str | None) -> str:
+ if not packaging:
+ packaging = self.__packaging if self.__packaging else ZIP
+ packaging = packaging.strip().lower()
+ if packaging not in PACKAGING:
+ raise ValueError(f'Packaging of type "{packaging}" is not supported')
+ return packaging
+
+ def _clean_save_target(
+ self,
+ target: str | Path | io.BytesIO | None,
+ ) -> str | io.BytesIO:
+ if target is None:
+ target = self.path
+ if isinstance(target, Path):
+ target = str(target)
+ if isinstance(target, str):
+ while target.endswith(os.sep):
+ target = target[:-1]
+ while target.endswith(".folder"):
+ target = target.split(".folder", 1)[0]
+ return target # type: ignore
+
+ def _save_as_zip(self, target: str | Path | io.BytesIO, backup: bool) -> None:
+ if isinstance(target, (str, Path)) and backup:
+ self._do_backup(target)
+ self._save_zip(target)
+
+ def _save_as_folder(self, target: str | Path, backup: bool) -> None:
+ if not isinstance(target, (str, Path)):
+ raise TypeError(
+ f"Saving in folder format requires a folder name, not '{target!r}'"
+ )
+ if not str(target).endswith(".folder"):
+ target = str(target) + ".folder"
+ self._backup_or_unlink(backup, target)
+ self._save_folder(target)
+
+ def _save_as_xml(
+ self,
+ target: str | Path | io.BytesIO,
+ backup: bool,
+ pretty: bool = True,
+ ) -> None:
+ if not isinstance(target, (str, Path)):
+ raise TypeError(
+ f"Saving in XML format requires a path name, not '{target!r}'"
+ )
+ if not str(target).endswith(".xml"):
+ target = str(target) + ".xml"
+ if isinstance(target, (str, Path)) and backup:
+ self._do_backup(target)
+ self._save_xml(target, pretty)
+
+ def save(
+ self,
+ target: str | Path | io.BytesIO | None,
+ packaging: str | None = None,
+ backup: bool = False,
+ pretty: bool = False,
+ ) -> None:
+ """Save the container to the given target, a path or a file-like
+ object.
+
+ Package the output document in the same format than current document,
+ unless "packaging" is different.
+
+ Arguments:
+
+ target -- str or file-like or Path
+
+ packaging -- 'zip', or for debugging purpose 'xml' or 'folder'
+
+ backup -- boolean
+ """
+ parts = self.__parts
+ packaging = self._clean_save_packaging(packaging)
+ # Load parts else they will be considered deleted
+ for path in self.parts:
+ if path not in parts:
+ self.get_part(path)
+ target = self._clean_save_target(target)
+ if packaging == FOLDER:
+ if isinstance(target, io.BytesIO):
+ raise TypeError(
+ "Impossible to save on io.BytesIO with 'folder' packaging"
+ )
+ self._save_as_folder(target, backup)
+ elif packaging == XML:
+ self._save_as_xml(target, backup, pretty)
+ else:
+ # default:
+ self._save_as_zip(target, backup)
|
@@ -15485,11 +15621,11 @@
Source code in odfdo/container.py
- | def del_part(self, path: str) -> None:
- """Mark a part for deletion."""
- self.__parts[path] = None
+ | def del_part(self, path: str) -> None:
+ """Mark a part for deletion."""
+ self.__parts[path] = None
|
@@ -15511,51 +15647,51 @@
Source code in odfdo/container.py
- 318
-319
-320
-321
-322
-323
-324
-325
-326
-327
-328
-329
-330
-331
-332
-333
-334
-335
-336
-337
-338
-339
-340 | def get_part(self, path: str) -> str | bytes | None:
- """Get the bytes of a part of the ODF."""
- path = str(path)
- if path in self.__parts:
- part = self.__parts[path]
- if part is None:
- raise ValueError(f'Part "{path}" is deleted')
- if self.__packaging == FOLDER:
- cache_ts = self.__parts_ts.get(path, -1)
- current_ts = self._get_folder_part_timestamp(path)
- if current_ts != cache_ts:
- part, timestamp = self._get_folder_part(path)
- self.__parts[path] = part
- self.__parts_ts[path] = timestamp
- return part
- if self.__packaging == ZIP:
- return self._get_zip_part(path)
- if self.__packaging == FOLDER:
- part, timestamp = self._get_folder_part(path)
- self.__parts[path] = part
- self.__parts_ts[path] = timestamp
- return part
- return None
+ 485
+486
+487
+488
+489
+490
+491
+492
+493
+494
+495
+496
+497
+498
+499
+500
+501
+502
+503
+504
+505
+506
+507 | def get_part(self, path: str) -> str | bytes | None:
+ """Get the bytes of a part of the ODF."""
+ path = str(path)
+ if path in self.__parts:
+ part = self.__parts[path]
+ if part is None:
+ raise ValueError(f'Part "{path}" is deleted')
+ if self.__packaging == FOLDER:
+ cache_ts = self.__parts_ts.get(path, -1)
+ current_ts = self._get_folder_part_timestamp(path)
+ if current_ts != cache_ts:
+ part, timestamp = self._get_folder_part(path)
+ self.__parts[path] = part
+ self.__parts_ts[path] = timestamp
+ return part
+ if self.__packaging == ZIP:
+ return self._get_zip_part(path)
+ if self.__packaging == FOLDER:
+ part, timestamp = self._get_folder_part(path)
+ self.__parts[path] = part
+ self.__parts_ts[path] = timestamp
+ return part
+ return None
|
@@ -15577,37 +15713,37 @@
Source code in odfdo/container.py
- 296
-297
-298
-299
-300
-301
-302
-303
-304
-305
-306
-307
-308
-309
-310
-311 | def get_parts(self) -> list[str]:
- """Get the list of members."""
- if not self.path:
- # maybe a file like zip archive
- return list(self.__parts.keys())
- if self.__packaging == ZIP:
- parts = []
- with ZipFile(self.path) as zf:
- for name in zf.namelist():
- upath = normalize_path(name)
- parts.append(upath)
- return parts
- elif self.__packaging == FOLDER:
- return self._get_folder_parts()
- else:
- raise ValueError("Unable to provide parts of the document")
+ 463
+464
+465
+466
+467
+468
+469
+470
+471
+472
+473
+474
+475
+476
+477
+478 | def get_parts(self) -> list[str]:
+ """Get the list of members."""
+ if not self.path:
+ # maybe a file like zip archive
+ return list(self.__parts.keys())
+ if self.__packaging == ZIP:
+ parts = []
+ with ZipFile(self.path) as zf:
+ for name in zf.namelist():
+ upath = normalize_path(name)
+ parts.append(upath)
+ return parts
+ elif self.__packaging == FOLDER:
+ return self._get_folder_parts()
+ else:
+ raise ValueError("Unable to provide parts of the document")
|
@@ -15629,47 +15765,47 @@
Source code in odfdo/container.py
- 77
-78
-79
-80
-81
-82
-83
-84
-85
-86
-87
-88
-89
-90
-91
-92
-93
-94
-95
-96
-97 | def open(self, path_or_file: Path | str | io.BytesIO) -> None:
- """Load the content of an ODF file."""
- self.__path_like = path_or_file
- if isinstance(path_or_file, (str, Path)):
- self.path = Path(path_or_file).expanduser()
- if not self.path.exists():
- raise FileNotFoundError(str(self.path))
- self.__path_like = self.path
- if (self.path or isinstance(self.__path_like, io.BytesIO)) and is_zipfile(
- self.__path_like # type: ignore
- ):
- self.__packaging = ZIP
- return self._read_zip()
- if self.path:
- is_folder = False
- with contextlib.suppress(OSError):
- is_folder = self.path.is_dir()
- if is_folder:
- self.__packaging = FOLDER
- return self._read_folder()
- raise TypeError(f"Document format not managed by odfdo: {type(path_or_file)}.")
+ 236
+237
+238
+239
+240
+241
+242
+243
+244
+245
+246
+247
+248
+249
+250
+251
+252
+253
+254
+255
+256 | def open(self, path_or_file: Path | str | io.BytesIO) -> None:
+ """Load the content of an ODF file."""
+ self.__path_like = path_or_file
+ if isinstance(path_or_file, (str, Path)):
+ self.path = Path(path_or_file).expanduser()
+ if not self.path.exists():
+ raise FileNotFoundError(str(self.path))
+ self.__path_like = self.path
+ if (self.path or isinstance(self.__path_like, io.BytesIO)) and is_zipfile(
+ self.__path_like # type: ignore
+ ):
+ self.__packaging = ZIP
+ return self._read_zip()
+ if self.path:
+ is_folder = False
+ with contextlib.suppress(OSError):
+ is_folder = self.path.is_dir()
+ if is_folder:
+ self.__packaging = FOLDER
+ return self._read_folder()
+ raise TypeError(f"Document format not managed by odfdo: {type(path_or_file)}.")
|
@@ -15680,7 +15816,7 @@
- save(target, packaging=None, backup=False)
+ save(target, packaging=None, backup=False, pretty=False)
@@ -15701,81 +15837,83 @@
Source code in odfdo/container.py
- 458
-459
-460
-461
-462
-463
-464
-465
-466
-467
-468
-469
-470
-471
-472
-473
-474
-475
-476
-477
-478
-479
-480
-481
-482
-483
-484
-485
-486
-487
-488
-489
-490
-491
-492
-493
-494
-495 | def save(
- self,
- target: str | Path | io.BytesIO | None,
- packaging: str | None = None,
- backup: bool = False,
-) -> None:
- """Save the container to the given target, a path or a file-like
- object.
-
- Package the output document in the same format than current document,
- unless "packaging" is different.
-
- Arguments:
-
- target -- str or file-like or Path
-
- packaging -- 'zip', or for debugging purpose 'xml' or 'folder'
-
- backup -- boolean
- """
- parts = self.__parts
- packaging = self._clean_save_packaging(packaging)
- # Load parts else they will be considered deleted
- for path in self.parts:
- if path not in parts:
- self.get_part(path)
- target = self._clean_save_target(target)
- if packaging == FOLDER:
- if isinstance(target, io.BytesIO):
- raise TypeError(
- "Impossible to save on io.BytesIO with 'folder' packaging"
- )
- self._save_as_folder(target, backup)
- elif packaging == XML:
- self._save_as_xml(target, backup)
- else:
- # default:
- self._save_as_zip(target, backup)
+ 630
+631
+632
+633
+634
+635
+636
+637
+638
+639
+640
+641
+642
+643
+644
+645
+646
+647
+648
+649
+650
+651
+652
+653
+654
+655
+656
+657
+658
+659
+660
+661
+662
+663
+664
+665
+666
+667
+668 | def save(
+ self,
+ target: str | Path | io.BytesIO | None,
+ packaging: str | None = None,
+ backup: bool = False,
+ pretty: bool = False,
+) -> None:
+ """Save the container to the given target, a path or a file-like
+ object.
+
+ Package the output document in the same format than current document,
+ unless "packaging" is different.
+
+ Arguments:
+
+ target -- str or file-like or Path
+
+ packaging -- 'zip', or for debugging purpose 'xml' or 'folder'
+
+ backup -- boolean
+ """
+ parts = self.__parts
+ packaging = self._clean_save_packaging(packaging)
+ # Load parts else they will be considered deleted
+ for path in self.parts:
+ if path not in parts:
+ self.get_part(path)
+ target = self._clean_save_target(target)
+ if packaging == FOLDER:
+ if isinstance(target, io.BytesIO):
+ raise TypeError(
+ "Impossible to save on io.BytesIO with 'folder' packaging"
+ )
+ self._save_as_folder(target, backup)
+ elif packaging == XML:
+ self._save_as_xml(target, backup, pretty)
+ else:
+ # default:
+ self._save_as_zip(target, backup)
|
@@ -15797,11 +15935,11 @@
Source code in odfdo/container.py
- | def set_part(self, path: str, data: bytes) -> None:
- """Replace or add a new part."""
- self.__parts[path] = data
+ | def set_part(self, path: str, data: bytes) -> None:
+ """Replace or add a new part."""
+ self.__parts[path] = data
|
@@ -16270,9 +16408,7 @@
Source code in odfdo/document.py
- 162
- 163
- 164
+ 164
165
166
167
@@ -17386,1177 +17522,1121 @@
1275
1276
1277
-1278
-1279
-1280
-1281
-1282
-1283
-1284
-1285
-1286
-1287
-1288
-1289
-1290
-1291
-1292
-1293
-1294
-1295
-1296
-1297
-1298
-1299
-1300
-1301
-1302
-1303
-1304
-1305 | class Document(MDDocument):
- """Abstraction of the ODF document.
-
- To create a new Document, several possibilities:
+1278
| class Document(MDDocument):
+ """Abstraction of the ODF document.
- - Document() or Document("text") or Document("odt")
- -> an "empty" document of type text
- - Document("spreadsheet") or Document("ods")
- -> an "empty" document of type spreadsheet
- - Document("presentation") or Document("odp")
- -> an "empty" document of type presentation
- - Document("drawing") or Document("odg")
- -> an "empty" document of type drawing
-
- Meaning of “empty”: these documents are copies of the default
- templates documents provided with this library, which, as templates,
- are not really empty. It may be useful to clear the newly created
- document: document.body.clear(), or adjust meta informations like
- description or default language: document.meta.language = 'fr-FR'
-
- If the argument is not a known template type, or is a Path,
- Document(file) will load the content of the ODF file.
-
- To explicitly create a document from a custom template, use the
- Document.new(path) method whose argument is the path to the template file.
- """
-
- def __init__(
- self,
- target: str | bytes | Path | Container | io.BytesIO | None = "text",
- ) -> None:
- # Cache of XML parts
- self.__xmlparts: dict[str, XmlPart] = {}
- # Cache of the body
- self.__body: Element | None = None
- self.container: Container | None = None
- if isinstance(target, bytes):
- # eager conversion
- target = bytes_to_str(target)
- if target is None:
- # empty document, you probably don't wnat this.
- self.container = Container()
- return
- if isinstance(target, Path):
- # let's assume we open a container on existing file
- self.container = Container(target)
- return
- if isinstance(target, Container):
- # special internal case, use an existing container
- self.container = target
- return
- if isinstance(target, str):
- if target in ODF_TEMPLATES:
- # assuming a new document from templates
- self.container = container_from_template(target)
- return
- # let's assume we open a container on existing file
- self.container = Container(target)
- return
- if isinstance(target, io.BytesIO):
- self.container = Container(target)
- return
- raise TypeError(f"Unknown Document source type: '{target!r}'")
-
- def __repr__(self) -> str:
- return f"<{self.__class__.__name__} type={self.get_type()} path={self.path}>"
-
- def __str__(self) -> str:
- try:
- return str(self.get_formatted_text())
- except NotImplementedError:
- return str(self.body)
-
- @classmethod
- def new(cls, template: str | Path | io.BytesIO = "text") -> Document:
- """Create a Document from a template.
-
- The template argument is expected to be the path to a ODF template.
+ To create a new Document, several possibilities:
+
+ - Document() or Document("text") or Document("odt")
+ -> an "empty" document of type text
+ - Document("spreadsheet") or Document("ods")
+ -> an "empty" document of type spreadsheet
+ - Document("presentation") or Document("odp")
+ -> an "empty" document of type presentation
+ - Document("drawing") or Document("odg")
+ -> an "empty" document of type drawing
+
+ Meaning of “empty”: these documents are copies of the default
+ templates documents provided with this library, which, as templates,
+ are not really empty. It may be useful to clear the newly created
+ document: document.body.clear(), or adjust meta informations like
+ description or default language: document.meta.language = 'fr-FR'
+
+ If the argument is not a known template type, or is a Path,
+ Document(file) will load the content of the ODF file.
+
+ To explicitly create a document from a custom template, use the
+ Document.new(path) method whose argument is the path to the template file.
+ """
+
+ def __init__(
+ self,
+ target: str | bytes | Path | Container | io.BytesIO | None = "text",
+ ) -> None:
+ # Cache of XML parts
+ self.__xmlparts: dict[str, XmlPart] = {}
+ # Cache of the body
+ self.__body: Element | None = None
+ self.container: Container | None = None
+ if isinstance(target, bytes):
+ # eager conversion
+ target = bytes_to_str(target)
+ if target is None:
+ # empty document, you probably don't wnat this.
+ self.container = Container()
+ return
+ if isinstance(target, Path):
+ # let's assume we open a container on existing file
+ self.container = Container(target)
+ return
+ if isinstance(target, Container):
+ # special internal case, use an existing container
+ self.container = target
+ return
+ if isinstance(target, str):
+ if target in ODF_TEMPLATES:
+ # assuming a new document from templates
+ self.container = container_from_template(target)
+ return
+ # let's assume we open a container on existing file
+ self.container = Container(target)
+ return
+ if isinstance(target, io.BytesIO):
+ self.container = Container(target)
+ return
+ raise TypeError(f"Unknown Document source type: '{target!r}'")
+
+ def __repr__(self) -> str:
+ return f"<{self.__class__.__name__} type={self.get_type()} path={self.path}>"
+
+ def __str__(self) -> str:
+ try:
+ return str(self.get_formatted_text())
+ except NotImplementedError:
+ return str(self.body)
+
+ @classmethod
+ def new(cls, template: str | Path | io.BytesIO = "text") -> Document:
+ """Create a Document from a template.
- Arguments:
+ The template argument is expected to be the path to a ODF template.
- template -- str or Path or file-like (io.BytesIO)
+ Arguments:
- Return : ODF document -- Document
- """
- container = container_from_template(template)
- return cls(container)
-
- # Public API
+ template -- str or Path or file-like (io.BytesIO)
+
+ Return : ODF document -- Document
+ """
+ container = container_from_template(template)
+ return cls(container)
- @property
- def path(self) -> Path | None:
- """Shortcut to Document.Container.path."""
- if not self.container:
- return None
- return self.container.path
-
- @path.setter
- def path(self, path_or_str: str | Path) -> None:
- """Shortcut to Document.Container.path
-
- Only accepting str or Path."""
- if not self.container:
- return
- self.container.path = Path(path_or_str)
-
- def get_parts(self) -> list[str]:
- """Return available part names with path inside the archive, e.g.
- ['content.xml', ..., 'Pictures/100000000000032000000258912EB1C3.jpg']
- """
- if not self.container:
- raise ValueError("Empty Container")
- return self.container.parts
-
- @property
- def parts(self) -> list[str]:
- """Return available part names with path inside the archive, e.g.
- ['content.xml', ..., 'Pictures/100000000000032000000258912EB1C3.jpg']
- """
- return self.get_parts()
-
- def get_part(self, path: str) -> XmlPart | str | bytes | None:
- """Return the bytes of the given part. The path is relative to the
- archive, e.g. "Pictures/1003200258912EB1C3.jpg".
-
- 'content', 'meta', 'settings', 'styles' and 'manifest' are shortcuts
- to the real path, e.g. content.xml, and return a dedicated object with
- its own API.
-
- path formated as URI, so always use '/' separator
- """
- if not self.container:
- raise ValueError("Empty Container")
- # "./ObjectReplacements/Object 1"
- path = path.lstrip("./")
- path = _get_part_path(path)
- cls = _get_part_class(path)
- # Raw bytes
- if cls is None:
- return self.container.get_part(path)
- # XML part
- part = self.__xmlparts.get(path)
- if part is None:
- self.__xmlparts[path] = part = cls(path, self.container)
- return part
-
- def set_part(self, path: str, data: bytes) -> None:
- """Set the bytes of the given part. The path is relative to the
- archive, e.g. "Pictures/1003200258912EB1C3.jpg".
-
- path formated as URI, so always use '/' separator
- """
- if not self.container:
- raise ValueError("Empty Container")
- # "./ObjectReplacements/Object 1"
- path = path.lstrip("./")
- path = _get_part_path(path)
- cls = _get_part_class(path)
- # XML part overwritten
- if cls is not None:
- with suppress(KeyError):
- self.__xmlparts[path]
- self.container.set_part(path, data)
-
- def del_part(self, path: str) -> None:
- """Mark a part for deletion. The path is relative to the archive,
- e.g. "Pictures/1003200258912EB1C3.jpg"
- """
- if not self.container:
- raise ValueError("Empty Container")
- path = _get_part_path(path)
- cls = _get_part_class(path)
- if path == ODF_MANIFEST or cls is not None:
- raise ValueError(f"part '{path}' is mandatory")
- self.container.del_part(path)
-
- @property
- def mimetype(self) -> str:
- if not self.container:
- raise ValueError("Empty Container")
- return self.container.mimetype
-
- @mimetype.setter
- def mimetype(self, mimetype: str) -> None:
- if not self.container:
- raise ValueError("Empty Container")
- self.container.mimetype = mimetype
-
- def get_type(self) -> str:
- """Get the ODF type (also called class) of this document.
-
- Return: 'chart', 'database', 'formula', 'graphics',
- 'graphics-template', 'image', 'presentation',
- 'presentation-template', 'spreadsheet', 'spreadsheet-template',
- 'text', 'text-master', 'text-template' or 'text-web'
- """
- # The mimetype must be with the form:
- # application/vnd.oasis.opendocument.text
-
- # Isolate and return the last part
- return self.mimetype.rsplit(".", 1)[-1]
-
- @property
- def body(self) -> Element:
- """Return the body element of the content part, where actual content
- is stored.
- """
- if self.__body is None:
- self.__body = self.content.body
- return self.__body
-
- @property
- def meta(self) -> Meta:
- """Return the meta part (meta.xml) of the document, where meta data
- are stored."""
- metadata = self.get_part(ODF_META)
- if metadata is None or not isinstance(metadata, Meta):
- raise ValueError("Empty Meta")
- return metadata
-
- @property
- def manifest(self) -> Manifest:
- """Return the manifest part (manifest.xml) of the document."""
- manifest = self.get_part(ODF_MANIFEST)
- if manifest is None or not isinstance(manifest, Manifest):
- raise ValueError("Empty Manifest")
- return manifest
-
- def _get_formatted_text_footnotes(
- self,
- result: list[str],
- context: dict,
- rst_mode: bool,
- ) -> None:
- # Separate text from notes
- if rst_mode:
- result.append("\n")
- else:
- result.append("----\n")
- for citation, body in context["footnotes"]:
- if rst_mode:
- result.append(f".. [#] {body}\n")
- else:
- result.append(f"[{citation}] {body}\n")
- # Append a \n after the notes
- result.append("\n")
- # Reset for the next paragraph
- context["footnotes"] = []
-
- def _get_formatted_text_annotations(
- self,
- result: list[str],
- context: dict,
- rst_mode: bool,
- ) -> None:
- # Insert the annotations
- # With a separation
- if rst_mode:
- result.append("\n")
- else:
- result.append("----\n")
- for annotation in context["annotations"]:
- if rst_mode:
- result.append(f".. [#] {annotation}\n")
- else:
- result.append(f"[*] {annotation}\n")
- context["annotations"] = []
-
- def _get_formatted_text_images(
- self,
- result: list[str],
- context: dict,
- rst_mode: bool,
- ) -> None:
- # Insert the images ref, only in rst mode
- result.append("\n")
- for ref, filename, (width, height) in context["images"]:
- result.append(f".. {ref} image:: {filename}\n")
- if width is not None:
- result.append(f" :width: {width}\n")
- if height is not None:
- result.append(f" :height: {height}\n")
- context["images"] = []
-
- def _get_formatted_text_endnotes(
- self,
- result: list[str],
- context: dict,
- rst_mode: bool,
- ) -> None:
- # Append the end notes
- if rst_mode:
- result.append("\n\n")
- else:
- result.append("\n========\n")
- for citation, body in context["endnotes"]:
- if rst_mode:
- result.append(f".. [*] {body}\n")
- else:
- result.append(f"({citation}) {body}\n")
-
- def get_formatted_text(self, rst_mode: bool = False) -> str:
- """Return content as text, with some formatting."""
- # For the moment, only "type='text'"
- doc_type = self.get_type()
- if doc_type == "spreadsheet":
- return self._tables_csv()
- if doc_type in {
- "text",
- "text-template",
- "presentation",
- "presentation-template",
- }:
- return self._formatted_text(rst_mode)
- raise NotImplementedError(f"Type of document '{doc_type}' not supported yet")
-
- def _tables_csv(self) -> str:
- return "\n\n".join(str(table) for table in self.body.tables)
-
- def _formatted_text(self, rst_mode: bool) -> str:
- # Initialize an empty context
- context = {
- "document": self,
- "footnotes": [],
- "endnotes": [],
- "annotations": [],
- "rst_mode": rst_mode,
- "img_counter": 0,
- "images": [],
- "no_img_level": 0,
- }
- body = self.body
- # Get the text
- result = []
- for child in body.children:
- # self._get_formatted_text_child(result, element, context, rst_mode)
- # if child.tag == "table:table":
- # result.append(child.get_formatted_text(context))
- # return
- result.append(child.get_formatted_text(context))
- if context["footnotes"]:
- self._get_formatted_text_footnotes(result, context, rst_mode)
- if context["annotations"]:
- self._get_formatted_text_annotations(result, context, rst_mode)
- # Insert the images ref, only in rst mode
- if context["images"]:
- self._get_formatted_text_images(result, context, rst_mode)
- if context["endnotes"]:
- self._get_formatted_text_endnotes(result, context, rst_mode)
- return "".join(result)
-
- def get_formated_meta(self) -> str:
- """Return meta informations as text, with some formatting."""
- result: list[str] = []
-
- # Simple values
- def print_info(name: str, value: Any) -> None:
- if value:
- result.append(f"{name}: {value}")
+ # Public API
+
+ @property
+ def path(self) -> Path | None:
+ """Shortcut to Document.Container.path."""
+ if not self.container:
+ return None
+ return self.container.path
+
+ @path.setter
+ def path(self, path_or_str: str | Path) -> None:
+ """Shortcut to Document.Container.path
+
+ Only accepting str or Path."""
+ if not self.container:
+ return
+ self.container.path = Path(path_or_str)
+
+ def get_parts(self) -> list[str]:
+ """Return available part names with path inside the archive, e.g.
+ ['content.xml', ..., 'Pictures/100000000000032000000258912EB1C3.jpg']
+ """
+ if not self.container:
+ raise ValueError("Empty Container")
+ return self.container.parts
+
+ @property
+ def parts(self) -> list[str]:
+ """Return available part names with path inside the archive, e.g.
+ ['content.xml', ..., 'Pictures/100000000000032000000258912EB1C3.jpg']
+ """
+ return self.get_parts()
+
+ def get_part(self, path: str) -> XmlPart | str | bytes | None:
+ """Return the bytes of the given part. The path is relative to the
+ archive, e.g. "Pictures/1003200258912EB1C3.jpg".
+
+ 'content', 'meta', 'settings', 'styles' and 'manifest' are shortcuts
+ to the real path, e.g. content.xml, and return a dedicated object with
+ its own API.
+
+ path formated as URI, so always use '/' separator
+ """
+ if not self.container:
+ raise ValueError("Empty Container")
+ # "./ObjectReplacements/Object 1"
+ path = path.lstrip("./")
+ path = _get_part_path(path)
+ cls = _get_part_class(path)
+ # Raw bytes
+ if cls is None:
+ return self.container.get_part(path)
+ # XML part
+ part = self.__xmlparts.get(path)
+ if part is None:
+ self.__xmlparts[path] = part = cls(path, self.container)
+ return part
+
+ def set_part(self, path: str, data: bytes) -> None:
+ """Set the bytes of the given part. The path is relative to the
+ archive, e.g. "Pictures/1003200258912EB1C3.jpg".
+
+ path formated as URI, so always use '/' separator
+ """
+ if not self.container:
+ raise ValueError("Empty Container")
+ # "./ObjectReplacements/Object 1"
+ path = path.lstrip("./")
+ path = _get_part_path(path)
+ cls = _get_part_class(path)
+ # XML part overwritten
+ if cls is not None:
+ with suppress(KeyError):
+ self.__xmlparts[path]
+ self.container.set_part(path, data)
+
+ def del_part(self, path: str) -> None:
+ """Mark a part for deletion. The path is relative to the archive,
+ e.g. "Pictures/1003200258912EB1C3.jpg"
+ """
+ if not self.container:
+ raise ValueError("Empty Container")
+ path = _get_part_path(path)
+ cls = _get_part_class(path)
+ if path == ODF_MANIFEST or cls is not None:
+ raise ValueError(f"part '{path}' is mandatory")
+ self.container.del_part(path)
+
+ @property
+ def mimetype(self) -> str:
+ if not self.container:
+ raise ValueError("Empty Container")
+ return self.container.mimetype
+
+ @mimetype.setter
+ def mimetype(self, mimetype: str) -> None:
+ if not self.container:
+ raise ValueError("Empty Container")
+ self.container.mimetype = mimetype
+
+ def get_type(self) -> str:
+ """Get the ODF type (also called class) of this document.
+
+ Return: 'chart', 'database', 'formula', 'graphics',
+ 'graphics-template', 'image', 'presentation',
+ 'presentation-template', 'spreadsheet', 'spreadsheet-template',
+ 'text', 'text-master', 'text-template' or 'text-web'
+ """
+ # The mimetype must be with the form:
+ # application/vnd.oasis.opendocument.text
+
+ # Isolate and return the last part
+ return self.mimetype.rsplit(".", 1)[-1]
+
+ @property
+ def body(self) -> Element:
+ """Return the body element of the content part, where actual content
+ is stored.
+ """
+ if self.__body is None:
+ self.__body = self.content.body
+ return self.__body
+
+ @property
+ def meta(self) -> Meta:
+ """Return the meta part (meta.xml) of the document, where meta data
+ are stored."""
+ metadata = self.get_part(ODF_META)
+ if metadata is None or not isinstance(metadata, Meta):
+ raise ValueError("Empty Meta")
+ return metadata
+
+ @property
+ def manifest(self) -> Manifest:
+ """Return the manifest part (manifest.xml) of the document."""
+ manifest = self.get_part(ODF_MANIFEST)
+ if manifest is None or not isinstance(manifest, Manifest):
+ raise ValueError("Empty Manifest")
+ return manifest
+
+ def _get_formatted_text_footnotes(
+ self,
+ result: list[str],
+ context: dict,
+ rst_mode: bool,
+ ) -> None:
+ # Separate text from notes
+ if rst_mode:
+ result.append("\n")
+ else:
+ result.append("----\n")
+ for citation, body in context["footnotes"]:
+ if rst_mode:
+ result.append(f".. [#] {body}\n")
+ else:
+ result.append(f"[{citation}] {body}\n")
+ # Append a \n after the notes
+ result.append("\n")
+ # Reset for the next paragraph
+ context["footnotes"] = []
+
+ def _get_formatted_text_annotations(
+ self,
+ result: list[str],
+ context: dict,
+ rst_mode: bool,
+ ) -> None:
+ # Insert the annotations
+ # With a separation
+ if rst_mode:
+ result.append("\n")
+ else:
+ result.append("----\n")
+ for annotation in context["annotations"]:
+ if rst_mode:
+ result.append(f".. [#] {annotation}\n")
+ else:
+ result.append(f"[*] {annotation}\n")
+ context["annotations"] = []
+
+ def _get_formatted_text_images(
+ self,
+ result: list[str],
+ context: dict,
+ rst_mode: bool,
+ ) -> None:
+ # Insert the images ref, only in rst mode
+ result.append("\n")
+ for ref, filename, (width, height) in context["images"]:
+ result.append(f".. {ref} image:: {filename}\n")
+ if width is not None:
+ result.append(f" :width: {width}\n")
+ if height is not None:
+ result.append(f" :height: {height}\n")
+ context["images"] = []
+
+ def _get_formatted_text_endnotes(
+ self,
+ result: list[str],
+ context: dict,
+ rst_mode: bool,
+ ) -> None:
+ # Append the end notes
+ if rst_mode:
+ result.append("\n\n")
+ else:
+ result.append("\n========\n")
+ for citation, body in context["endnotes"]:
+ if rst_mode:
+ result.append(f".. [*] {body}\n")
+ else:
+ result.append(f"({citation}) {body}\n")
+
+ def get_formatted_text(self, rst_mode: bool = False) -> str:
+ """Return content as text, with some formatting."""
+ # For the moment, only "type='text'"
+ doc_type = self.get_type()
+ if doc_type == "spreadsheet":
+ return self._tables_csv()
+ if doc_type in {
+ "text",
+ "text-template",
+ "presentation",
+ "presentation-template",
+ }:
+ return self._formatted_text(rst_mode)
+ raise NotImplementedError(f"Type of document '{doc_type}' not supported yet")
+
+ def _tables_csv(self) -> str:
+ return "\n\n".join(str(table) for table in self.body.tables)
+
+ def _formatted_text(self, rst_mode: bool) -> str:
+ # Initialize an empty context
+ context = {
+ "document": self,
+ "footnotes": [],
+ "endnotes": [],
+ "annotations": [],
+ "rst_mode": rst_mode,
+ "img_counter": 0,
+ "images": [],
+ "no_img_level": 0,
+ }
+ body = self.body
+ # Get the text
+ result = []
+ for child in body.children:
+ # self._get_formatted_text_child(result, element, context, rst_mode)
+ # if child.tag == "table:table":
+ # result.append(child.get_formatted_text(context))
+ # return
+ result.append(child.get_formatted_text(context))
+ if context["footnotes"]:
+ self._get_formatted_text_footnotes(result, context, rst_mode)
+ if context["annotations"]:
+ self._get_formatted_text_annotations(result, context, rst_mode)
+ # Insert the images ref, only in rst mode
+ if context["images"]:
+ self._get_formatted_text_images(result, context, rst_mode)
+ if context["endnotes"]:
+ self._get_formatted_text_endnotes(result, context, rst_mode)
+ return "".join(result)
+
+ def get_formated_meta(self) -> str:
+ """Return meta informations as text, with some formatting.
+
+ (Redirection to new implementation for compatibility.) """
+ return self.meta.as_text()
+
- meta = self.meta
- print_info("Title", meta.title)
- print_info("Subject", meta.subject)
- print_info("Description", meta.description)
- print_info("Language", meta.language)
- print_info("Modification date", meta.date)
- print_info("Creation date", meta.creation_date)
- print_info("Initial creator", meta.initial_creator)
- print_info("Keyword", meta.keyword)
- print_info("Editing duration", meta.editing_duration)
- print_info("Editing cycles", meta.editing_cycles)
- print_info("Generator", meta.generator)
+ def to_markdown(self) -> str:
+ doc_type = self.get_type()
+ if doc_type not in {
+ "text",
+ }:
+ raise NotImplementedError(
+ f"Type of document '{doc_type}' not supported yet"
+ )
+ return self._markdown_export()
+
+ def add_file(self, path_or_file: str | Path) -> str:
+ """Insert a file from a path or a file-like object in the container.
- # Statistic
- result.append("Statistic:")
- statistic = meta.statistic
- if statistic:
- for name, data in statistic.items():
- result.append(f" - {name[5:].replace('-', ' ').capitalize()}: {data}")
-
- # User defined metadata
- result.append("User defined metadata:")
- user_metadata = meta.user_defined_metadata
- for name, data2 in user_metadata.items():
- result.append(f" - {name}: {data2}")
-
- # And the description
- print_info("Description", meta.get_description())
+ Return the full path to reference in the content.
+
+ Arguments:
+
+ path_or_file -- str or Path or file-like
+
+ Return: str (URI)
+ """
+ if not self.container:
+ raise ValueError("Empty Container")
+ name = ""
+ # Folder for added files (FIXME hard-coded and copied)
+ manifest = self.manifest
+ medias = manifest.get_paths()
+ # uuid = str(uuid4())
- return "\n".join(result)
-
- def to_markdown(self) -> str:
- doc_type = self.get_type()
- if doc_type not in {
- "text",
- }:
- raise NotImplementedError(
- f"Type of document '{doc_type}' not supported yet"
- )
- return self._markdown_export()
-
- def add_file(self, path_or_file: str | Path) -> str:
- """Insert a file from a path or a file-like object in the container.
-
- Return the full path to reference in the content.
-
- Arguments:
-
- path_or_file -- str or Path or file-like
-
- Return: str (URI)
- """
- if not self.container:
- raise ValueError("Empty Container")
- name = ""
- # Folder for added files (FIXME hard-coded and copied)
- manifest = self.manifest
- medias = manifest.get_paths()
- # uuid = str(uuid4())
-
- if isinstance(path_or_file, (str, Path)):
- path = Path(path_or_file)
- extension = path.suffix.lower()
- name = f"{path.stem}{extension}"
- if posixpath.join("Pictures", name) in medias:
- name = f"{path.stem}_{uuid4()}{extension}"
- else:
- path = None
- name = getattr(path_or_file, "name", None)
- if not name:
- name = str(uuid4())
- media_type, _encoding = guess_type(name)
- if not media_type:
- media_type = "application/octet-stream"
- if manifest.get_media_type("Pictures/") is None:
- manifest.add_full_path("Pictures/")
- full_path = posixpath.join("Pictures", name)
- if path is None:
- self.container.set_part(full_path, path_or_file.read()) # type:ignore
- else:
- self.container.set_part(full_path, path.read_bytes())
- manifest.add_full_path(full_path, media_type)
- return full_path
-
- @property
- def clone(self) -> Document:
- """Return an exact copy of the document.
-
- Return: Document
- """
- clone = object.__new__(self.__class__)
- for name in self.__dict__:
- if name == "_Document__body":
- setattr(clone, name, None)
- elif name == "_Document__xmlparts":
- setattr(clone, name, {})
- elif name == "container":
- if not self.container:
- raise ValueError("Empty Container")
- setattr(clone, name, self.container.clone)
- else:
- value = deepcopy(getattr(self, name))
- setattr(clone, name, value)
- return clone
-
- def save(
- self,
- target: str | Path | io.BytesIO | None = None,
- packaging: str = ZIP,
- pretty: bool | None = None,
- backup: bool = False,
- ) -> None:
- """Save the document, at the same place it was opened or at the given
- target path. Target can also be a file-like object. It can be saved
- as a Zip file (default), flat XML format or as files in a folder
- (for debugging purpose). XML parts can be pretty printed (the default
- for 'folder' and 'xml' packaging).
-
- Note: 'xml' packaging is an experimental work in progress.
-
- Arguments:
-
- target -- str or file-like object
-
- packaging -- 'zip', 'folder', 'xml'
-
- pretty -- bool | None
+ if isinstance(path_or_file, (str, Path)):
+ path = Path(path_or_file)
+ extension = path.suffix.lower()
+ name = f"{path.stem}{extension}"
+ if posixpath.join("Pictures", name) in medias:
+ name = f"{path.stem}_{uuid4()}{extension}"
+ else:
+ path = None
+ name = getattr(path_or_file, "name", None)
+ if not name:
+ name = str(uuid4())
+ media_type, _encoding = guess_type(name)
+ if not media_type:
+ media_type = "application/octet-stream"
+ if manifest.get_media_type("Pictures/") is None:
+ manifest.add_full_path("Pictures/")
+ full_path = posixpath.join("Pictures", name)
+ if path is None:
+ self.container.set_part(full_path, path_or_file.read()) # type:ignore
+ else:
+ self.container.set_part(full_path, path.read_bytes())
+ manifest.add_full_path(full_path, media_type)
+ return full_path
+
+ @property
+ def clone(self) -> Document:
+ """Return an exact copy of the document.
+
+ Return: Document
+ """
+ clone = object.__new__(self.__class__)
+ for name in self.__dict__:
+ if name == "_Document__body":
+ setattr(clone, name, None)
+ elif name == "_Document__xmlparts":
+ setattr(clone, name, {})
+ elif name == "container":
+ if not self.container:
+ raise ValueError("Empty Container")
+ setattr(clone, name, self.container.clone)
+ else:
+ value = deepcopy(getattr(self, name))
+ setattr(clone, name, value)
+ return clone
+
+ def save(
+ self,
+ target: str | Path | io.BytesIO | None = None,
+ packaging: str = ZIP,
+ pretty: bool | None = None,
+ backup: bool = False,
+ ) -> None:
+ """Save the document, at the same place it was opened or at the given
+ target path. Target can also be a file-like object. It can be saved
+ as a Zip file (default), flat XML format or as files in a folder
+ (for debugging purpose). XML parts can be pretty printed (the default
+ for 'folder' and 'xml' packaging).
+
+ Note: 'xml' packaging is an experimental work in progress.
+
+ Arguments:
+
+ target -- str or file-like object
+
+ packaging -- 'zip', 'folder', 'xml'
+
+ pretty -- bool | None
+
+ backup -- bool
+ """
+ if not self.container:
+ raise ValueError("Empty Container")
+ if packaging not in PACKAGING:
+ raise ValueError(f'Packaging of type "{packaging}" is not supported')
+ # Some advertising
+ self.meta.set_generator_default()
+ # Synchronize data with container
+ container = self.container
+ if pretty is None:
+ pretty = packaging in {"folder", "xml"}
+ pretty = bool(pretty)
+ backup = bool(backup)
+ if pretty and packaging != XML:
+ for path, part in self.__xmlparts.items():
+ if part is not None:
+ container.set_part(path, part.pretty_serialize())
+ for path in (ODF_CONTENT, ODF_META, ODF_SETTINGS, ODF_STYLES):
+ if path in self.__xmlparts:
+ continue
+ cls = _get_part_class(path)
+ # XML part
+ self.__xmlparts[path] = part = cls(path, container)
+ container.set_part(path, part.pretty_serialize())
+ else:
+ for path, part in self.__xmlparts.items():
+ if part is not None:
+ container.set_part(path, part.serialize())
+ container.save(target, packaging=packaging, backup=backup, pretty=pretty)
- backup -- bool
- """
- if not self.container:
- raise ValueError("Empty Container")
- # Some advertising
- self.meta.set_generator_default()
- # Synchronize data with container
- container = self.container
- if pretty is None:
- pretty = packaging in {"folder", "xml"}
- if pretty:
- for path, part in self.__xmlparts.items():
- if part is not None:
- container.set_part(path, part.pretty_serialize())
- for path in (ODF_CONTENT, ODF_META, ODF_SETTINGS, ODF_STYLES):
- if path in self.__xmlparts:
- continue
- cls = _get_part_class(path)
- # XML part
- self.__xmlparts[path] = part = cls(path, container)
- container.set_part(path, part.pretty_serialize())
- else:
- for path, part in self.__xmlparts.items():
- if part is not None:
- container.set_part(path, part.serialize())
- container.save(target, packaging=packaging, backup=backup)
-
- @property
- def content(self) -> Content:
- content: Content | None = self.get_part(ODF_CONTENT) # type:ignore
- if content is None:
- raise ValueError("Empty Content")
- return content
-
- @property
- def styles(self) -> Styles:
- styles: Styles | None = self.get_part(ODF_STYLES) # type:ignore
- if styles is None:
- raise ValueError("Empty Styles")
- return styles
-
- # Styles over several parts
+ @property
+ def content(self) -> Content:
+ content: Content | None = self.get_part(ODF_CONTENT) # type:ignore
+ if content is None:
+ raise ValueError("Empty Content")
+ return content
+
+ @property
+ def styles(self) -> Styles:
+ styles: Styles | None = self.get_part(ODF_STYLES) # type:ignore
+ if styles is None:
+ raise ValueError("Empty Styles")
+ return styles
+
+ # Styles over several parts
+
+ def get_styles(
+ self,
+ family: str | bytes = "",
+ automatic: bool = False,
+ ) -> list[Style | Element]:
+ # compatibility with old versions:
+
+ if isinstance(family, bytes):
+ family = bytes_to_str(family)
+ return self.content.get_styles(family=family) + self.styles.get_styles(
+ family=family, automatic=automatic
+ )
+
+ def get_style(
+ self,
+ family: str,
+ name_or_element: str | Style | None = None,
+ display_name: str | None = None,
+ ) -> Style | None:
+ """Return the style uniquely identified by the name/family pair. If
+ the argument is already a style object, it will return it.
+
+ If the name is None, the default style is fetched.
+
+ If the name is not the internal name but the name you gave in a
+ desktop application, use display_name instead.
- def get_styles(
- self,
- family: str | bytes = "",
- automatic: bool = False,
- ) -> list[Style | Element]:
- # compatibility with old versions:
+ Arguments:
+
+ family -- 'paragraph', 'text', 'graphic', 'table', 'list',
+ 'number', 'page-layout', 'master-page'
+
+ name -- str or Element or None
- if isinstance(family, bytes):
- family = bytes_to_str(family)
- return self.content.get_styles(family=family) + self.styles.get_styles(
- family=family, automatic=automatic
- )
-
- def get_style(
- self,
- family: str,
- name_or_element: str | Style | None = None,
- display_name: str | None = None,
- ) -> Style | None:
- """Return the style uniquely identified by the name/family pair. If
- the argument is already a style object, it will return it.
-
- If the name is None, the default style is fetched.
+ display_name -- str
+
+ Return: Style or None if not found.
+ """
+ # 1. content.xml
+ element = self.content.get_style(
+ family, name_or_element=name_or_element, display_name=display_name
+ )
+ if element is not None:
+ return element
+ # 2. styles.xml
+ return self.styles.get_style(
+ family,
+ name_or_element=name_or_element,
+ display_name=display_name,
+ )
- If the name is not the internal name but the name you gave in a
- desktop application, use display_name instead.
-
- Arguments:
-
- family -- 'paragraph', 'text', 'graphic', 'table', 'list',
- 'number', 'page-layout', 'master-page'
-
- name -- str or Element or None
-
- display_name -- str
-
- Return: Style or None if not found.
- """
- # 1. content.xml
- element = self.content.get_style(
- family, name_or_element=name_or_element, display_name=display_name
- )
- if element is not None:
- return element
- # 2. styles.xml
- return self.styles.get_style(
- family,
- name_or_element=name_or_element,
- display_name=display_name,
- )
-
- def get_parent_style(self, style: Style) -> Style | None:
- family = style.family
- parent_style_name = style.parent_style
- if not parent_style_name:
- return None
- return self.get_style(family, parent_style_name)
-
- def get_list_style(self, style: Style) -> Style | None:
- list_style_name = style.list_style_name
- if not list_style_name:
- return None
- return self.get_style("list", list_style_name)
-
- @staticmethod
- def _pseudo_style_attribute(style_element: Style | Element, attribute: str) -> Any:
- if hasattr(style_element, attribute):
- return getattr(style_element, attribute)
- return ""
-
- def _set_automatic_name(self, style: Style, family: str) -> None:
- """Generate a name for the new automatic style."""
- if not hasattr(style, "name"):
- # do nothing
- return
- styles = self.get_styles(family=family, automatic=True)
- max_index = 0
- for existing_style in styles:
- if not hasattr(existing_style, "name"):
- continue
- if not existing_style.name.startswith(AUTOMATIC_PREFIX):
- continue
- try:
- index = int(existing_style.name[len(AUTOMATIC_PREFIX) :]) # type: ignore
- except ValueError:
- continue
- max_index = max(max_index, index)
-
- style.name = f"{AUTOMATIC_PREFIX}{max_index+1}"
-
- def _insert_style_get_common_styles(
- self,
+ def get_parent_style(self, style: Style) -> Style | None:
+ family = style.family
+ parent_style_name = style.parent_style
+ if not parent_style_name:
+ return None
+ return self.get_style(family, parent_style_name)
+
+ def get_list_style(self, style: Style) -> Style | None:
+ list_style_name = style.list_style_name
+ if not list_style_name:
+ return None
+ return self.get_style("list", list_style_name)
+
+ @staticmethod
+ def _pseudo_style_attribute(style_element: Style | Element, attribute: str) -> Any:
+ if hasattr(style_element, attribute):
+ return getattr(style_element, attribute)
+ return ""
+
+ def _set_automatic_name(self, style: Style, family: str) -> None:
+ """Generate a name for the new automatic style."""
+ if not hasattr(style, "name"):
+ # do nothing
+ return
+ styles = self.get_styles(family=family, automatic=True)
+ max_index = 0
+ for existing_style in styles:
+ if not hasattr(existing_style, "name"):
+ continue
+ if not existing_style.name.startswith(AUTOMATIC_PREFIX):
+ continue
+ try:
+ index = int(existing_style.name[len(AUTOMATIC_PREFIX) :]) # type: ignore
+ except ValueError:
+ continue
+ max_index = max(max_index, index)
+
+ style.name = f"{AUTOMATIC_PREFIX}{max_index+1}"
+
+ def _insert_style_get_common_styles(
+ self,
+ family: str,
+ name: str,
+ ) -> tuple[Any, Any]:
+ style_container = self.styles.get_element("office:styles")
+ existing = self.styles.get_style(family, name)
+ return existing, style_container
+
+ def _insert_style_get_automatic_styles(
+ self,
+ style: Style,
+ family: str,
+ name: str,
+ ) -> tuple[Any, Any]:
+ style_container = self.content.get_element("office:automatic-styles")
+ # A name ?
+ if name:
+ if hasattr(style, "name"):
+ style.name = name
+ existing = self.content.get_style(family, name)
+ else:
+ self._set_automatic_name(style, family)
+ existing = None
+ return existing, style_container
+
+ def _insert_style_get_default_styles(
+ self,
+ style: Style,
family: str,
name: str,
) -> tuple[Any, Any]:
style_container = self.styles.get_element("office:styles")
- existing = self.styles.get_style(family, name)
- return existing, style_container
-
- def _insert_style_get_automatic_styles(
- self,
- style: Style,
- family: str,
- name: str,
- ) -> tuple[Any, Any]:
- style_container = self.content.get_element("office:automatic-styles")
- # A name ?
- if name:
- if hasattr(style, "name"):
- style.name = name
- existing = self.content.get_style(family, name)
- else:
- self._set_automatic_name(style, family)
- existing = None
- return existing, style_container
-
- def _insert_style_get_default_styles(
- self,
- style: Style,
- family: str,
- name: str,
- ) -> tuple[Any, Any]:
- style_container = self.styles.get_element("office:styles")
- style.tag = "style:default-style"
- if name:
- style.del_attribute("style:name")
- existing = self.styles.get_style(family)
+ style.tag = "style:default-style"
+ if name:
+ style.del_attribute("style:name")
+ existing = self.styles.get_style(family)
+ return existing, style_container
+
+ def _insert_style_get_master_page(
+ self,
+ family: str,
+ name: str,
+ ) -> tuple[Any, Any]:
+ style_container = self.styles.get_element("office:master-styles")
+ existing = self.styles.get_style(family, name)
+ return existing, style_container
+
+ def _insert_style_get_font_face_default(
+ self,
+ family: str,
+ name: str,
+ ) -> tuple[Any, Any]:
+ style_container = self.styles.get_element("office:font-face-decls")
+ existing = self.styles.get_style(family, name)
+ return existing, style_container
+
+ def _insert_style_get_font_face(
+ self,
+ family: str,
+ name: str,
+ ) -> tuple[Any, Any]:
+ style_container = self.content.get_element("office:font-face-decls")
+ existing = self.content.get_style(family, name)
return existing, style_container
- def _insert_style_get_master_page(
+ def _insert_style_get_page_layout(
self,
family: str,
name: str,
) -> tuple[Any, Any]:
- style_container = self.styles.get_element("office:master-styles")
- existing = self.styles.get_style(family, name)
- return existing, style_container
-
- def _insert_style_get_font_face_default(
- self,
- family: str,
+ # force to automatic
+ style_container = self.styles.get_element("office:automatic-styles")
+ existing = self.styles.get_style(family, name)
+ return existing, style_container
+
+ def _insert_style_get_draw_fill_image(
+ self,
name: str,
) -> tuple[Any, Any]:
- style_container = self.styles.get_element("office:font-face-decls")
- existing = self.styles.get_style(family, name)
- return existing, style_container
-
- def _insert_style_get_font_face(
- self,
- family: str,
- name: str,
- ) -> tuple[Any, Any]:
- style_container = self.content.get_element("office:font-face-decls")
- existing = self.content.get_style(family, name)
- return existing, style_container
-
- def _insert_style_get_page_layout(
- self,
- family: str,
- name: str,
- ) -> tuple[Any, Any]:
- # force to automatic
- style_container = self.styles.get_element("office:automatic-styles")
- existing = self.styles.get_style(family, name)
- return existing, style_container
-
- def _insert_style_get_draw_fill_image(
- self,
- name: str,
- ) -> tuple[Any, Any]:
- # special case for 'draw:fill-image' pseudo style
- # not family and style_element.__class__.__name__ == "DrawFillImage"
- style_container = self.styles.get_element("office:styles")
- existing = self.styles.get_style("", name)
- return existing, style_container
-
- def _insert_style_standard(
- self,
- style: Style,
- name: str,
- family: str,
- automatic: bool,
- default: bool,
- ) -> tuple[Any, Any]:
- # Common style
- if name and automatic is False and default is False:
- return self._insert_style_get_common_styles(family, name)
- # Automatic style
- elif automatic is True and default is False:
- return self._insert_style_get_automatic_styles(style, family, name)
- # Default style
- elif automatic is False and default is True:
- return self._insert_style_get_default_styles(style, family, name)
- else:
- raise AttributeError("Invalid combination of arguments")
+ # special case for 'draw:fill-image' pseudo style
+ # not family and style_element.__class__.__name__ == "DrawFillImage"
+ style_container = self.styles.get_element("office:styles")
+ existing = self.styles.get_style("", name)
+ return existing, style_container
+
+ def _insert_style_standard(
+ self,
+ style: Style,
+ name: str,
+ family: str,
+ automatic: bool,
+ default: bool,
+ ) -> tuple[Any, Any]:
+ # Common style
+ if name and automatic is False and default is False:
+ return self._insert_style_get_common_styles(family, name)
+ # Automatic style
+ elif automatic is True and default is False:
+ return self._insert_style_get_automatic_styles(style, family, name)
+ # Default style
+ elif automatic is False and default is True:
+ return self._insert_style_get_default_styles(style, family, name)
+ else:
+ raise AttributeError("Invalid combination of arguments")
+
+ def insert_style( # noqa: C901
+ self,
+ style: Style | str,
+ name: str = "",
+ automatic: bool = False,
+ default: bool = False,
+ ) -> Any:
+ """Insert the given style object in the document, as required by the
+ style family and type.
+
+ The style is expected to be a common style with a name. In case it
+ was created with no name, the given can be set on the fly.
+
+ If automatic is True, the style will be inserted as an automatic
+ style.
+
+ If default is True, the style will be inserted as a default style and
+ would replace any existing default style of the same family. Any name
+ or display name would be ignored.
+
+ Automatic and default arguments are mutually exclusive.
+
+ All styles can't be used as default styles. Default styles are
+ allowed for the following families: paragraph, text, section, table,
+ table-column, table-row, table-cell, table-page, chart, drawing-page,
+ graphic, presentation, control and ruby.
- def insert_style( # noqa: C901
- self,
- style: Style | str,
- name: str = "",
- automatic: bool = False,
- default: bool = False,
- ) -> Any:
- """Insert the given style object in the document, as required by the
- style family and type.
+ Arguments:
+
+ style -- Style or str
+
+ name -- str
+
+ automatic -- bool
+
+ default -- bool
- The style is expected to be a common style with a name. In case it
- was created with no name, the given can be set on the fly.
+ Return : style name -- str
+ """
- If automatic is True, the style will be inserted as an automatic
- style.
-
- If default is True, the style will be inserted as a default style and
- would replace any existing default style of the same family. Any name
- or display name would be ignored.
-
- Automatic and default arguments are mutually exclusive.
-
- All styles can't be used as default styles. Default styles are
- allowed for the following families: paragraph, text, section, table,
- table-column, table-row, table-cell, table-page, chart, drawing-page,
- graphic, presentation, control and ruby.
-
- Arguments:
-
- style -- Style or str
-
- name -- str
-
- automatic -- bool
-
- default -- bool
-
- Return : style name -- str
- """
-
- # if style is a str, assume it is the Style definition
- if isinstance(style, str):
- style_element: Style = Element.from_tag(style) # type: ignore
- else:
- style_element = style
- if not isinstance(style_element, Element):
- raise TypeError(f"Unknown Style type: '{style!r}'")
-
- # Get family and name
- family = self._pseudo_style_attribute(style_element, "family")
- if not name:
- name = self._pseudo_style_attribute(style_element, "name")
-
- # Master page style
- if family == "master-page":
- existing, style_container = self._insert_style_get_master_page(family, name)
- # Font face declarations
- elif family == "font-face":
- if default:
- existing, style_container = self._insert_style_get_font_face_default(
- family, name
- )
- else:
- existing, style_container = self._insert_style_get_font_face(
- family, name
- )
- # page layout style
- elif family == "page-layout":
- existing, style_container = self._insert_style_get_page_layout(family, name)
- # Common style
- elif family in FAMILY_ODF_STD or family in {"number"}:
- existing, style_container = self._insert_style_standard(
- style_element, name, family, automatic, default
- )
- elif not family and style_element.__class__.__name__ == "DrawFillImage":
- # special case for 'draw:fill-image' pseudo style
- existing, style_container = self._insert_style_get_draw_fill_image(name)
- # Invalid style
- else:
- raise ValueError(
- "Invalid style: "
- f"{style_element}, tag:{style_element.tag}, family:{family}"
- )
-
- # Insert it!
- if existing is not None:
- style_container.delete(existing)
- style_container.append(style_element)
- return self._pseudo_style_attribute(style_element, "name")
-
- def get_styled_elements(self, name: str = "") -> list[Element]:
- """Brute-force to find paragraphs, tables, etc. using the given style
- name (or all by default).
-
- Arguments:
-
- name -- str
-
- Return: list
- """
- # Header, footer, etc. have styles too
- return self.content.root.get_styled_elements(
- name
- ) + self.styles.root.get_styled_elements(name)
-
- def show_styles(
- self,
- automatic: bool = True,
- common: bool = True,
- properties: bool = False,
- ) -> str:
- infos = []
- for style in self.get_styles():
- try:
- name = style.name # type: ignore
- except AttributeError:
- print("--------------")
- print(style.__class__)
- print(style.serialize())
- raise
- if style.__class__.__name__ == "DrawFillImage":
- family = ""
- else:
- family = str(style.family) # type: ignore
- parent = style.parent
- is_auto = parent and parent.tag == "office:automatic-styles"
- if is_auto and automatic is False or not is_auto and common is False:
- continue
- is_used = bool(self.get_styled_elements(name))
- infos.append(
- {
- "type": "auto " if is_auto else "common",
- "used": "y" if is_used else "n",
- "family": family,
- "parent": self._pseudo_style_attribute(style, "parent_style") or "",
- "name": name or "",
- "display_name": self._pseudo_style_attribute(style, "display_name")
- or "",
- "properties": style.get_properties() if properties else None, # type: ignore
- }
- )
- if not infos:
- return ""
- # Sort by family and name
- infos.sort(key=itemgetter("family", "name"))
- # Show common and used first
- infos.sort(key=itemgetter("type", "used"), reverse=True)
- max_family = str(max([len(x["family"]) for x in infos])) # type: ignore
- max_parent = str(max([len(x["parent"]) for x in infos])) # type: ignore
- formater = (
- "%(type)s used:%(used)s family:%(family)-0"
- + max_family
- + "s parent:%(parent)-0"
- + max_parent
- + "s name:%(name)s"
- )
- output = []
- for info in infos:
- line = formater % info
- if info["display_name"]:
- line += " display_name:" + info["display_name"] # type: ignore
- output.append(line)
- if info["properties"]:
- for name, value in info["properties"].items(): # type: ignore
- output.append(f" - {name}: {value}")
- output.append("")
- return "\n".join(output)
-
- def delete_styles(self) -> int:
- """Remove all style information from content and all styles.
-
- Return: number of deleted styles
- """
- # First remove references to styles
- for element in self.get_styled_elements():
- for attribute in (
- "text:style-name",
- "draw:style-name",
- "draw:text-style-name",
- "table:style-name",
- "style:page-layout-name",
- ):
- try:
- element.del_attribute(attribute)
- except KeyError:
- continue
- # Then remove supposedly orphaned styles
- deleted = 0
- for style in self.get_styles():
- if style.name is None: # type: ignore
- # Don't delete default styles
- continue
- # elif type(style) is odf_master_page:
- # # Don't suppress header and footer, just styling was removed
- # continue
- style.delete()
- deleted += 1
- return deleted
-
- def merge_styles_from(self, document: Document) -> None:
- """Copy all the styles of a document into ourself.
-
- Styles with the same type and name will be replaced, so only unique
- styles will be preserved.
- """
- manifest = self.manifest
- document_manifest = document.manifest
- for style in document.get_styles():
- tagname = style.tag
- family = self._pseudo_style_attribute(style, "family")
- stylename = style.name # type: ignore
- container = style.parent
- container_name = container.tag # type: ignore
- partname = container.parent.tag # type: ignore
- # The destination part
- if partname == "office:document-styles":
- part: Content | Styles = self.styles
- elif partname == "office:document-content":
- part = self.content
- else:
- raise NotImplementedError(partname)
- # Implemented containers
- if container_name not in {
- "office:styles",
- "office:automatic-styles",
- "office:master-styles",
- "office:font-face-decls",
- }:
- raise NotImplementedError(container_name)
- dest = part.get_element(f"//{container_name}")
- # Implemented style types
- # if tagname not in registered_styles:
- # raise NotImplementedError(tagname)
- duplicate = part.get_style(family, stylename)
- if duplicate is not None:
- duplicate.delete()
- dest.append(style)
- # Copy images from the header/footer
- if tagname == "style:master-page":
- query = "descendant::draw:image"
- for image in style.get_elements(query):
- url = image.url # type: ignore
- part_url = document.get_part(url)
- # Manually add the part to keep the name
- self.set_part(url, part_url) # type: ignore
- media_type = document_manifest.get_media_type(url)
- manifest.add_full_path(url, media_type) # type: ignore
- # Copy images from the fill-image
- elif tagname == "draw:fill-image":
- url = style.url # type: ignore
- part_url = document.get_part(url)
- self.set_part(url, part_url) # type: ignore
- media_type = document_manifest.get_media_type(url)
- manifest.add_full_path(url, media_type) # type: ignore
-
- def add_page_break_style(self) -> None:
- """Ensure that the document contains the style required for a manual page break.
-
- Then a manual page break can be added to the document with:
- from paragraph import PageBreak
- ...
- document.body.append(PageBreak())
-
- Note: this style uses the property 'fo:break-after', another
- possibility could be the property 'fo:break-before'
- """
- if existing := self.get_style( # noqa: SIM102
- family="paragraph",
- name_or_element="odfdopagebreak",
- ):
- if properties := existing.get_properties(): # noqa: SIM102
- if properties["fo:break-after"] == "page":
- return
- style = (
- '<style:style style:family="paragraph" style:parent-style-name="Standard" '
- 'style:name="odfdopagebreak">'
- '<style:paragraph-properties fo:break-after="page"/></style:style>'
- )
- self.insert_style(style, automatic=False)
-
- def get_style_properties(
- self, family: str, name: str, area: str | None = None
- ) -> dict[str, str] | None:
- """Return the properties of the required style as a dict."""
- style = self.get_style(family, name)
- if style is None:
- return None
- return style.get_properties(area=area) # type: ignore
-
- def _get_table(self, table: int | str) -> Table | None:
- if not (isinstance(table, int) or isinstance(table, str)):
- raise TypeError(f"Table parameter must be int or str: {table!r}")
- if isinstance(table, int):
- return self.body.get_table(position=table) # type: ignore
- return self.body.get_table(name=table) # type: ignore
-
- def get_cell_style_properties( # noqa: C901
- self, table: str | int, coord: tuple | list | str
- ) -> dict[str, str]: # type: ignore
- """Return the style properties of a table cell of a .ods document,
- from the cell style or from the row style."""
-
- if not (sheet := self._get_table(table)):
- return {}
- cell = sheet.get_cell(coord, clone=False)
- if cell.style:
- return (
- self.get_style_properties("table-cell", cell.style, "table-cell") or {}
- )
- try:
- row = sheet.get_row(cell.y, clone=False, create=False) # type: ignore
- if row.style: # noqa: SIM102
- if props := self.get_style_properties(
- "table-row", row.style, "table-cell"
- ):
- return props
- column = sheet.get_column(cell.x) # type: ignore
- style = column.default_cell_style
- if style: # noqa: SIM102
- if props := self.get_style_properties(
- "table-cell", style, "table-cell"
- ):
- return props
- except ValueError:
- pass
- return {}
+ # if style is a str, assume it is the Style definition
+ if isinstance(style, str):
+ style_element: Style = Element.from_tag(style) # type: ignore
+ else:
+ style_element = style
+ if not isinstance(style_element, Element):
+ raise TypeError(f"Unknown Style type: '{style!r}'")
+
+ # Get family and name
+ family = self._pseudo_style_attribute(style_element, "family")
+ if not name:
+ name = self._pseudo_style_attribute(style_element, "name")
+
+ # Master page style
+ if family == "master-page":
+ existing, style_container = self._insert_style_get_master_page(family, name)
+ # Font face declarations
+ elif family == "font-face":
+ if default:
+ existing, style_container = self._insert_style_get_font_face_default(
+ family, name
+ )
+ else:
+ existing, style_container = self._insert_style_get_font_face(
+ family, name
+ )
+ # page layout style
+ elif family == "page-layout":
+ existing, style_container = self._insert_style_get_page_layout(family, name)
+ # Common style
+ elif family in FAMILY_ODF_STD or family in {"number"}:
+ existing, style_container = self._insert_style_standard(
+ style_element, name, family, automatic, default
+ )
+ elif not family and style_element.__class__.__name__ == "DrawFillImage":
+ # special case for 'draw:fill-image' pseudo style
+ existing, style_container = self._insert_style_get_draw_fill_image(name)
+ # Invalid style
+ else:
+ raise ValueError(
+ "Invalid style: "
+ f"{style_element}, tag:{style_element.tag}, family:{family}"
+ )
+
+ # Insert it!
+ if existing is not None:
+ style_container.delete(existing)
+ style_container.append(style_element)
+ return self._pseudo_style_attribute(style_element, "name")
+
+ def get_styled_elements(self, name: str = "") -> list[Element]:
+ """Brute-force to find paragraphs, tables, etc. using the given style
+ name (or all by default).
+
+ Arguments:
+
+ name -- str
+
+ Return: list
+ """
+ # Header, footer, etc. have styles too
+ return self.content.root.get_styled_elements(
+ name
+ ) + self.styles.root.get_styled_elements(name)
+
+ def show_styles(
+ self,
+ automatic: bool = True,
+ common: bool = True,
+ properties: bool = False,
+ ) -> str:
+ infos = []
+ for style in self.get_styles():
+ try:
+ name = style.name # type: ignore
+ except AttributeError:
+ print("--------------")
+ print(style.__class__)
+ print(style.serialize())
+ raise
+ if style.__class__.__name__ == "DrawFillImage":
+ family = ""
+ else:
+ family = str(style.family) # type: ignore
+ parent = style.parent
+ is_auto = parent and parent.tag == "office:automatic-styles"
+ if is_auto and automatic is False or not is_auto and common is False:
+ continue
+ is_used = bool(self.get_styled_elements(name))
+ infos.append(
+ {
+ "type": "auto " if is_auto else "common",
+ "used": "y" if is_used else "n",
+ "family": family,
+ "parent": self._pseudo_style_attribute(style, "parent_style") or "",
+ "name": name or "",
+ "display_name": self._pseudo_style_attribute(style, "display_name")
+ or "",
+ "properties": style.get_properties() if properties else None, # type: ignore
+ }
+ )
+ if not infos:
+ return ""
+ # Sort by family and name
+ infos.sort(key=itemgetter("family", "name"))
+ # Show common and used first
+ infos.sort(key=itemgetter("type", "used"), reverse=True)
+ max_family = str(max([len(x["family"]) for x in infos])) # type: ignore
+ max_parent = str(max([len(x["parent"]) for x in infos])) # type: ignore
+ formater = (
+ "%(type)s used:%(used)s family:%(family)-0"
+ + max_family
+ + "s parent:%(parent)-0"
+ + max_parent
+ + "s name:%(name)s"
+ )
+ output = []
+ for info in infos:
+ line = formater % info
+ if info["display_name"]:
+ line += " display_name:" + info["display_name"] # type: ignore
+ output.append(line)
+ if info["properties"]:
+ for name, value in info["properties"].items(): # type: ignore
+ output.append(f" - {name}: {value}")
+ output.append("")
+ return "\n".join(output)
+
+ def delete_styles(self) -> int:
+ """Remove all style information from content and all styles.
+
+ Return: number of deleted styles
+ """
+ # First remove references to styles
+ for element in self.get_styled_elements():
+ for attribute in (
+ "text:style-name",
+ "draw:style-name",
+ "draw:text-style-name",
+ "table:style-name",
+ "style:page-layout-name",
+ ):
+ try:
+ element.del_attribute(attribute)
+ except KeyError:
+ continue
+ # Then remove supposedly orphaned styles
+ deleted = 0
+ for style in self.get_styles():
+ if style.name is None: # type: ignore
+ # Don't delete default styles
+ continue
+ # elif type(style) is odf_master_page:
+ # # Don't suppress header and footer, just styling was removed
+ # continue
+ style.delete()
+ deleted += 1
+ return deleted
+
+ def merge_styles_from(self, document: Document) -> None:
+ """Copy all the styles of a document into ourself.
+
+ Styles with the same type and name will be replaced, so only unique
+ styles will be preserved.
+ """
+ manifest = self.manifest
+ document_manifest = document.manifest
+ for style in document.get_styles():
+ tagname = style.tag
+ family = self._pseudo_style_attribute(style, "family")
+ stylename = style.name # type: ignore
+ container = style.parent
+ container_name = container.tag # type: ignore
+ partname = container.parent.tag # type: ignore
+ # The destination part
+ if partname == "office:document-styles":
+ part: Content | Styles = self.styles
+ elif partname == "office:document-content":
+ part = self.content
+ else:
+ raise NotImplementedError(partname)
+ # Implemented containers
+ if container_name not in {
+ "office:styles",
+ "office:automatic-styles",
+ "office:master-styles",
+ "office:font-face-decls",
+ }:
+ raise NotImplementedError(container_name)
+ dest = part.get_element(f"//{container_name}")
+ # Implemented style types
+ # if tagname not in registered_styles:
+ # raise NotImplementedError(tagname)
+ duplicate = part.get_style(family, stylename)
+ if duplicate is not None:
+ duplicate.delete()
+ dest.append(style)
+ # Copy images from the header/footer
+ if tagname == "style:master-page":
+ query = "descendant::draw:image"
+ for image in style.get_elements(query):
+ url = image.url # type: ignore
+ part_url = document.get_part(url)
+ # Manually add the part to keep the name
+ self.set_part(url, part_url) # type: ignore
+ media_type = document_manifest.get_media_type(url)
+ manifest.add_full_path(url, media_type) # type: ignore
+ # Copy images from the fill-image
+ elif tagname == "draw:fill-image":
+ url = style.url # type: ignore
+ part_url = document.get_part(url)
+ self.set_part(url, part_url) # type: ignore
+ media_type = document_manifest.get_media_type(url)
+ manifest.add_full_path(url, media_type) # type: ignore
+
+ def add_page_break_style(self) -> None:
+ """Ensure that the document contains the style required for a manual page break.
+
+ Then a manual page break can be added to the document with:
+ from paragraph import PageBreak
+ ...
+ document.body.append(PageBreak())
+
+ Note: this style uses the property 'fo:break-after', another
+ possibility could be the property 'fo:break-before'
+ """
+ if existing := self.get_style( # noqa: SIM102
+ family="paragraph",
+ name_or_element="odfdopagebreak",
+ ):
+ if properties := existing.get_properties(): # noqa: SIM102
+ if properties["fo:break-after"] == "page":
+ return
+ style = (
+ '<style:style style:family="paragraph" style:parent-style-name="Standard" '
+ 'style:name="odfdopagebreak">'
+ '<style:paragraph-properties fo:break-after="page"/></style:style>'
+ )
+ self.insert_style(style, automatic=False)
+
+ def get_style_properties(
+ self, family: str, name: str, area: str | None = None
+ ) -> dict[str, str] | None:
+ """Return the properties of the required style as a dict."""
+ style = self.get_style(family, name)
+ if style is None:
+ return None
+ return style.get_properties(area=area) # type: ignore
+
+ def _get_table(self, table: int | str) -> Table | None:
+ if not (isinstance(table, int) or isinstance(table, str)):
+ raise TypeError(f"Table parameter must be int or str: {table!r}")
+ if isinstance(table, int):
+ return self.body.get_table(position=table) # type: ignore
+ return self.body.get_table(name=table) # type: ignore
+
+ def get_cell_style_properties( # noqa: C901
+ self, table: str | int, coord: tuple | list | str
+ ) -> dict[str, str]: # type: ignore
+ """Return the style properties of a table cell of a .ods document,
+ from the cell style or from the row style."""
+
+ if not (sheet := self._get_table(table)):
+ return {}
+ cell = sheet.get_cell(coord, clone=False)
+ if cell.style:
+ return (
+ self.get_style_properties("table-cell", cell.style, "table-cell") or {}
+ )
+ try:
+ row = sheet.get_row(cell.y, clone=False, create=False) # type: ignore
+ if row.style: # noqa: SIM102
+ if props := self.get_style_properties(
+ "table-row", row.style, "table-cell"
+ ):
+ return props
+ column = sheet.get_column(cell.x) # type: ignore
+ style = column.default_cell_style
+ if style: # noqa: SIM102
+ if props := self.get_style_properties(
+ "table-cell", style, "table-cell"
+ ):
+ return props
+ except ValueError:
+ pass
+ return {}
+
+ def get_cell_background_color(
+ self,
+ table: str | int,
+ coord: tuple | list | str,
+ default: str = "#ffffff",
+ ) -> str:
+ """Return the background color of a table cell of a .ods document,
+ from the cell style, or from the row or column.
+
+ If color is not defined, return default value.."""
+ found = self.get_cell_style_properties(table, coord).get("fo:background-color")
+ return found or default
+
+ def get_table_style( # noqa: C901
+ self,
+ table: str | int,
+ ) -> Style | None: # type: ignore
+ """Return the Style instance the table.
+
+ Arguments:
+
+ table -- name or index of the table
+ """
+ if not (sheet := self._get_table(table)):
+ return None
+ return self.get_style("table", sheet.style)
- def get_cell_background_color(
- self,
- table: str | int,
- coord: tuple | list | str,
- default: str = "#ffffff",
- ) -> str:
- """Return the background color of a table cell of a .ods document,
- from the cell style, or from the row or column.
+ def get_table_displayed(self, table: str | int) -> bool:
+ """Return the table:display property of the style of the table, ie if
+ the table should be displayed in a graphical interface.
+
+ Note: that method replaces the broken Table.displayd() method from previous
+ odfdo versions.
+
+ Arguments:
- If color is not defined, return default value.."""
- found = self.get_cell_style_properties(table, coord).get("fo:background-color")
- return found or default
-
- def get_table_style( # noqa: C901
- self,
- table: str | int,
- ) -> Style | None: # type: ignore
- """Return the Style instance the table.
-
- Arguments:
-
- table -- name or index of the table
- """
- if not (sheet := self._get_table(table)):
- return None
- return self.get_style("table", sheet.style)
-
- def get_table_displayed(self, table: str | int) -> bool:
- """Return the table:display property of the style of the table, ie if
- the table should be displayed in a graphical interface.
-
- Note: that method replaces the broken Table.displayd() method from previous
- odfdo versions.
+ table -- name or index of the table
+ """
+ style = self.get_table_style(table)
+ if not style:
+ # should not happen, but assume that a table without style is
+ # displayed by default
+ return True
+ properties = style.get_properties() or {}
+ property_str = str(properties.get("table:display", "true"))
+ return Boolean.decode(property_str)
+
+ def _unique_style_name(self, base: str) -> str:
+ current = {style.name for style in self.get_styles()}
+ idx = 0
+ while True:
+ name = f"{base}_{idx}"
+ if name in current:
+ idx += 1
+ continue
+ return name
+
+ def set_table_displayed(self, table: str | int, displayed: bool) -> None:
+ """Set the table:display property of the style of the table, ie if
+ the table should be displayed in a graphical interface.
- Arguments:
-
- table -- name or index of the table
- """
- style = self.get_table_style(table)
- if not style:
- # should not happen, but assume that a table without style is
- # displayed by default
- return True
- properties = style.get_properties() or {}
- property_str = str(properties.get("table:display", "true"))
- return Boolean.decode(property_str)
-
- def _unique_style_name(self, base: str) -> str:
- current = {style.name for style in self.get_styles()}
- idx = 0
- while True:
- name = f"{base}_{idx}"
- if name in current:
- idx += 1
- continue
- return name
-
- def set_table_displayed(self, table: str | int, displayed: bool) -> None:
- """Set the table:display property of the style of the table, ie if
- the table should be displayed in a graphical interface.
-
- Note: that method replaces the broken Table.displayd() method from previous
- odfdo versions.
-
- Arguments:
-
- table -- name or index of the table
-
- displayed -- boolean flag
- """
- orig_style = self.get_table_style(table)
- if not orig_style:
- name = self._unique_style_name("ta")
- orig_style = Element.from_tag(
- (
- f'<style:style style:name="{name}" style:family="table" '
- 'style:master-page-name="Default">'
- '<style:table-properties table:display="true" '
- 'style:writing-mode="lr-tb"/></style:style>'
- )
- )
- self.insert_style(orig_style, automatic=True) # type:ignore
- new_style = orig_style.clone
- new_name = self._unique_style_name("ta")
- new_style.name = new_name # type:ignore
- self.insert_style(new_style, automatic=True) # type:ignore
- sheet = self._get_table(table)
- sheet.style = new_name # type: ignore
- properties = {"table:display": Boolean.encode(displayed)}
- new_style.set_properties(properties) # type: ignore
+ Note: that method replaces the broken Table.displayd() method from previous
+ odfdo versions.
+
+ Arguments:
+
+ table -- name or index of the table
+
+ displayed -- boolean flag
+ """
+ orig_style = self.get_table_style(table)
+ if not orig_style:
+ name = self._unique_style_name("ta")
+ orig_style = Element.from_tag(
+ (
+ f'<style:style style:name="{name}" style:family="table" '
+ 'style:master-page-name="Default">'
+ '<style:table-properties table:display="true" '
+ 'style:writing-mode="lr-tb"/></style:style>'
+ )
+ )
+ self.insert_style(orig_style, automatic=True) # type:ignore
+ new_style = orig_style.clone
+ new_name = self._unique_style_name("ta")
+ new_style.name = new_name # type:ignore
+ self.insert_style(new_style, automatic=True) # type:ignore
+ sheet = self._get_table(table)
+ sheet.style = new_name # type: ignore
+ properties = {"table:display": Boolean.encode(displayed)}
+ new_style.set_properties(properties) # type: ignore
|
@@ -18723,7 +18803,38 @@
Source code in odfdo/document.py
- 563
+ 532
+533
+534
+535
+536
+537
+538
+539
+540
+541
+542
+543
+544
+545
+546
+547
+548
+549
+550
+551
+552
+553
+554
+555
+556
+557
+558
+559
+560
+561
+562
+563
564
565
566
@@ -18733,79 +18844,48 @@
570
571
572
-573
-574
-575
-576
-577
-578
-579
-580
-581
-582
-583
-584
-585
-586
-587
-588
-589
-590
-591
-592
-593
-594
-595
-596
-597
-598
-599
-600
-601
-602
-603
-604 | def add_file(self, path_or_file: str | Path) -> str:
- """Insert a file from a path or a file-like object in the container.
-
- Return the full path to reference in the content.
-
- Arguments:
-
- path_or_file -- str or Path or file-like
-
- Return: str (URI)
- """
- if not self.container:
- raise ValueError("Empty Container")
- name = ""
- # Folder for added files (FIXME hard-coded and copied)
- manifest = self.manifest
- medias = manifest.get_paths()
- # uuid = str(uuid4())
-
- if isinstance(path_or_file, (str, Path)):
- path = Path(path_or_file)
- extension = path.suffix.lower()
- name = f"{path.stem}{extension}"
- if posixpath.join("Pictures", name) in medias:
- name = f"{path.stem}_{uuid4()}{extension}"
- else:
- path = None
- name = getattr(path_or_file, "name", None)
- if not name:
- name = str(uuid4())
- media_type, _encoding = guess_type(name)
- if not media_type:
- media_type = "application/octet-stream"
- if manifest.get_media_type("Pictures/") is None:
- manifest.add_full_path("Pictures/")
- full_path = posixpath.join("Pictures", name)
- if path is None:
- self.container.set_part(full_path, path_or_file.read()) # type:ignore
- else:
- self.container.set_part(full_path, path.read_bytes())
- manifest.add_full_path(full_path, media_type)
- return full_path
+573
| def add_file(self, path_or_file: str | Path) -> str:
+ """Insert a file from a path or a file-like object in the container.
+
+ Return the full path to reference in the content.
+
+ Arguments:
+
+ path_or_file -- str or Path or file-like
+
+ Return: str (URI)
+ """
+ if not self.container:
+ raise ValueError("Empty Container")
+ name = ""
+ # Folder for added files (FIXME hard-coded and copied)
+ manifest = self.manifest
+ medias = manifest.get_paths()
+ # uuid = str(uuid4())
+
+ if isinstance(path_or_file, (str, Path)):
+ path = Path(path_or_file)
+ extension = path.suffix.lower()
+ name = f"{path.stem}{extension}"
+ if posixpath.join("Pictures", name) in medias:
+ name = f"{path.stem}_{uuid4()}{extension}"
+ else:
+ path = None
+ name = getattr(path_or_file, "name", None)
+ if not name:
+ name = str(uuid4())
+ media_type, _encoding = guess_type(name)
+ if not media_type:
+ media_type = "application/octet-stream"
+ if manifest.get_media_type("Pictures/") is None:
+ manifest.add_full_path("Pictures/")
+ full_path = posixpath.join("Pictures", name)
+ if path is None:
+ self.container.set_part(full_path, path_or_file.read()) # type:ignore
+ else:
+ self.container.set_part(full_path, path.read_bytes())
+ manifest.add_full_path(full_path, media_type)
+ return full_path
|
@@ -18836,53 +18916,53 @@
Source code in odfdo/document.py
- 1144
-1145
-1146
-1147
-1148
-1149
-1150
-1151
-1152
-1153
-1154
-1155
-1156
-1157
-1158
-1159
-1160
-1161
-1162
-1163
-1164
-1165
-1166
-1167 | def add_page_break_style(self) -> None:
- """Ensure that the document contains the style required for a manual page break.
-
- Then a manual page break can be added to the document with:
- from paragraph import PageBreak
- ...
- document.body.append(PageBreak())
-
- Note: this style uses the property 'fo:break-after', another
- possibility could be the property 'fo:break-before'
- """
- if existing := self.get_style( # noqa: SIM102
- family="paragraph",
- name_or_element="odfdopagebreak",
- ):
- if properties := existing.get_properties(): # noqa: SIM102
- if properties["fo:break-after"] == "page":
- return
- style = (
- '<style:style style:family="paragraph" style:parent-style-name="Standard" '
- 'style:name="odfdopagebreak">'
- '<style:paragraph-properties fo:break-after="page"/></style:style>'
- )
- self.insert_style(style, automatic=False)
+ 1117
+1118
+1119
+1120
+1121
+1122
+1123
+1124
+1125
+1126
+1127
+1128
+1129
+1130
+1131
+1132
+1133
+1134
+1135
+1136
+1137
+1138
+1139
+1140 | def add_page_break_style(self) -> None:
+ """Ensure that the document contains the style required for a manual page break.
+
+ Then a manual page break can be added to the document with:
+ from paragraph import PageBreak
+ ...
+ document.body.append(PageBreak())
+
+ Note: this style uses the property 'fo:break-after', another
+ possibility could be the property 'fo:break-before'
+ """
+ if existing := self.get_style( # noqa: SIM102
+ family="paragraph",
+ name_or_element="odfdopagebreak",
+ ):
+ if properties := existing.get_properties(): # noqa: SIM102
+ if properties["fo:break-after"] == "page":
+ return
+ style = (
+ '<style:style style:family="paragraph" style:parent-style-name="Standard" '
+ 'style:name="odfdopagebreak">'
+ '<style:paragraph-properties fo:break-after="page"/></style:style>'
+ )
+ self.insert_style(style, automatic=False)
|
@@ -18905,9 +18985,7 @@
Source code in odfdo/document.py
- 326
-327
-328
+ 328
329
330
331
@@ -18915,17 +18993,19 @@
333
334
335
-336 | def del_part(self, path: str) -> None:
- """Mark a part for deletion. The path is relative to the archive,
- e.g. "Pictures/1003200258912EB1C3.jpg"
- """
- if not self.container:
- raise ValueError("Empty Container")
- path = _get_part_path(path)
- cls = _get_part_class(path)
- if path == ODF_MANIFEST or cls is not None:
- raise ValueError(f"part '{path}' is mandatory")
- self.container.del_part(path)
+336
+337
+338
| def del_part(self, path: str) -> None:
+ """Mark a part for deletion. The path is relative to the archive,
+ e.g. "Pictures/1003200258912EB1C3.jpg"
+ """
+ if not self.container:
+ raise ValueError("Empty Container")
+ path = _get_part_path(path)
+ cls = _get_part_class(path)
+ if path == ODF_MANIFEST or cls is not None:
+ raise ValueError(f"part '{path}' is mandatory")
+ self.container.del_part(path)
|
@@ -18948,65 +19028,65 @@
Source code in odfdo/document.py
- 1057
+ 1030
+1031
+1032
+1033
+1034
+1035
+1036
+1037
+1038
+1039
+1040
+1041
+1042
+1043
+1044
+1045
+1046
+1047
+1048
+1049
+1050
+1051
+1052
+1053
+1054
+1055
+1056
+1057
1058
-1059
-1060
-1061
-1062
-1063
-1064
-1065
-1066
-1067
-1068
-1069
-1070
-1071
-1072
-1073
-1074
-1075
-1076
-1077
-1078
-1079
-1080
-1081
-1082
-1083
-1084
-1085
-1086 | def delete_styles(self) -> int:
- """Remove all style information from content and all styles.
-
- Return: number of deleted styles
- """
- # First remove references to styles
- for element in self.get_styled_elements():
- for attribute in (
- "text:style-name",
- "draw:style-name",
- "draw:text-style-name",
- "table:style-name",
- "style:page-layout-name",
- ):
- try:
- element.del_attribute(attribute)
- except KeyError:
- continue
- # Then remove supposedly orphaned styles
- deleted = 0
- for style in self.get_styles():
- if style.name is None: # type: ignore
- # Don't delete default styles
- continue
- # elif type(style) is odf_master_page:
- # # Don't suppress header and footer, just styling was removed
- # continue
- style.delete()
- deleted += 1
- return deleted
+1059
| def delete_styles(self) -> int:
+ """Remove all style information from content and all styles.
+
+ Return: number of deleted styles
+ """
+ # First remove references to styles
+ for element in self.get_styled_elements():
+ for attribute in (
+ "text:style-name",
+ "draw:style-name",
+ "draw:text-style-name",
+ "table:style-name",
+ "style:page-layout-name",
+ ):
+ try:
+ element.del_attribute(attribute)
+ except KeyError:
+ continue
+ # Then remove supposedly orphaned styles
+ deleted = 0
+ for style in self.get_styles():
+ if style.name is None: # type: ignore
+ # Don't delete default styles
+ continue
+ # elif type(style) is odf_master_page:
+ # # Don't suppress header and footer, just styling was removed
+ # continue
+ style.delete()
+ deleted += 1
+ return deleted
|
@@ -19030,29 +19110,29 @@
Source code in odfdo/document.py
- 1216
-1217
-1218
-1219
-1220
-1221
-1222
-1223
-1224
-1225
-1226
-1227 | def get_cell_background_color(
- self,
- table: str | int,
- coord: tuple | list | str,
- default: str = "#ffffff",
-) -> str:
- """Return the background color of a table cell of a .ods document,
- from the cell style, or from the row or column.
-
- If color is not defined, return default value.."""
- found = self.get_cell_style_properties(table, coord).get("fo:background-color")
- return found or default
+ 1189
+1190
+1191
+1192
+1193
+1194
+1195
+1196
+1197
+1198
+1199
+1200 | def get_cell_background_color(
+ self,
+ table: str | int,
+ coord: tuple | list | str,
+ default: str = "#ffffff",
+) -> str:
+ """Return the background color of a table cell of a .ods document,
+ from the cell style, or from the row or column.
+
+ If color is not defined, return default value.."""
+ found = self.get_cell_style_properties(table, coord).get("fo:background-color")
+ return found or default
|
@@ -19075,65 +19155,65 @@
Source code in odfdo/document.py
- 1185
+ 1158
+1159
+1160
+1161
+1162
+1163
+1164
+1165
+1166
+1167
+1168
+1169
+1170
+1171
+1172
+1173
+1174
+1175
+1176
+1177
+1178
+1179
+1180
+1181
+1182
+1183
+1184
+1185
1186
-1187
-1188
-1189
-1190
-1191
-1192
-1193
-1194
-1195
-1196
-1197
-1198
-1199
-1200
-1201
-1202
-1203
-1204
-1205
-1206
-1207
-1208
-1209
-1210
-1211
-1212
-1213
-1214 | def get_cell_style_properties( # noqa: C901
- self, table: str | int, coord: tuple | list | str
-) -> dict[str, str]: # type: ignore
- """Return the style properties of a table cell of a .ods document,
- from the cell style or from the row style."""
-
- if not (sheet := self._get_table(table)):
- return {}
- cell = sheet.get_cell(coord, clone=False)
- if cell.style:
- return (
- self.get_style_properties("table-cell", cell.style, "table-cell") or {}
- )
- try:
- row = sheet.get_row(cell.y, clone=False, create=False) # type: ignore
- if row.style: # noqa: SIM102
- if props := self.get_style_properties(
- "table-row", row.style, "table-cell"
- ):
- return props
- column = sheet.get_column(cell.x) # type: ignore
- style = column.default_cell_style
- if style: # noqa: SIM102
- if props := self.get_style_properties(
- "table-cell", style, "table-cell"
- ):
- return props
- except ValueError:
- pass
- return {}
+1187
| def get_cell_style_properties( # noqa: C901
+ self, table: str | int, coord: tuple | list | str
+) -> dict[str, str]: # type: ignore
+ """Return the style properties of a table cell of a .ods document,
+ from the cell style or from the row style."""
+
+ if not (sheet := self._get_table(table)):
+ return {}
+ cell = sheet.get_cell(coord, clone=False)
+ if cell.style:
+ return (
+ self.get_style_properties("table-cell", cell.style, "table-cell") or {}
+ )
+ try:
+ row = sheet.get_row(cell.y, clone=False, create=False) # type: ignore
+ if row.style: # noqa: SIM102
+ if props := self.get_style_properties(
+ "table-row", row.style, "table-cell"
+ ):
+ return props
+ column = sheet.get_column(cell.x) # type: ignore
+ style = column.default_cell_style
+ if style: # noqa: SIM102
+ if props := self.get_style_properties(
+ "table-cell", style, "table-cell"
+ ):
+ return props
+ except ValueError:
+ pass
+ return {}
|
@@ -19152,86 +19232,19 @@ |
|
|
|
|
|
|
|
|
|
|
|
|