open-document-lib 1.2.0__tar.gz → 1.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {open_document_lib-1.2.0/open_document_lib.egg-info → open_document_lib-1.4.0}/PKG-INFO +12 -1
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/README.md +2 -2
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/docs/library-api.md +8 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/odf_lib/__init__.py +7 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/odf_lib/odf_common.py +147 -1
- {open_document_lib-1.2.0 → open_document_lib-1.4.0/open_document_lib.egg-info}/PKG-INFO +12 -1
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/open_document_lib.egg-info/SOURCES.txt +1 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/open_document_lib.egg-info/requires.txt +4 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/pyproject.toml +3 -2
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/tests/test_lib_odf_common.py +49 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/tests/test_libreoffice_integration.py +59 -0
- open_document_lib-1.4.0/tests/test_markdown_to_odt.py +262 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/LICENSE +0 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/odf_lib/citation_mapping.py +0 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/odf_lib/py.typed +0 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/open_document_lib.egg-info/dependency_links.txt +0 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/open_document_lib.egg-info/top_level.txt +0 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/setup.cfg +0 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/tests/test_benchmarks.py +0 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/tests/test_citations.py +0 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/tests/test_corpus.py +0 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/tests/test_cross_refs.py +0 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/tests/test_dao_template.py +0 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/tests/test_docs.py +0 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/tests/test_edge_cases.py +0 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/tests/test_examples.py +0 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/tests/test_flat_odf.py +0 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/tests/test_footnotes.py +0 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/tests/test_install.py +0 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/tests/test_math.py +0 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/tests/test_meta_lifecycle.py +0 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/tests/test_odg_connectors.py +0 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/tests/test_odg_gluepoints.py +0 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/tests/test_odg_groups.py +0 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/tests/test_odg_styling.py +0 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/tests/test_odp_animations.py +0 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/tests/test_odp_master.py +0 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/tests/test_odp_styling.py +0 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/tests/test_odp_transitions.py +0 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/tests/test_ods_charts.py +0 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/tests/test_ods_named_ranges.py +0 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/tests/test_ods_validation.py +0 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/tests/test_property.py +0 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/tests/test_schema_validation.py +0 -0
- {open_document_lib-1.2.0 → open_document_lib-1.4.0}/tests/test_smoke.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: open-document-lib
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: Standard-library toolkit for reading, editing, and writing OpenDocument Format files (ODT, ODP, ODS, ODG)
|
|
5
5
|
Author: Patrick Leiverkus
|
|
6
6
|
License: MIT
|
|
@@ -30,6 +30,8 @@ Provides-Extra: scholarly
|
|
|
30
30
|
Requires-Dist: bibtexparser>=1.4; extra == "scholarly"
|
|
31
31
|
Provides-Extra: validate
|
|
32
32
|
Requires-Dist: lxml>=4.9; extra == "validate"
|
|
33
|
+
Provides-Extra: render
|
|
34
|
+
Requires-Dist: Pillow>=10; extra == "render"
|
|
33
35
|
Provides-Extra: dev
|
|
34
36
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
35
37
|
Requires-Dist: ruff>=0.9; extra == "dev"
|
|
@@ -37,6 +39,7 @@ Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
|
37
39
|
Requires-Dist: hypothesis>=6.0; extra == "dev"
|
|
38
40
|
Requires-Dist: build>=1.0; extra == "dev"
|
|
39
41
|
Requires-Dist: mypy>=1.8; extra == "dev"
|
|
42
|
+
Requires-Dist: Pillow>=10; extra == "dev"
|
|
40
43
|
Dynamic: license-file
|
|
41
44
|
|
|
42
45
|
# open-document-lib
|
|
@@ -190,6 +193,14 @@ helpers) is internal and may change without notice.
|
|
|
190
193
|
| `find_pandoc() -> str \| None` | Locate the `pandoc` binary. |
|
|
191
194
|
| `latex_to_mathml(latex) -> bytes` | Convert a LaTeX snippet to MathML via Pandoc. |
|
|
192
195
|
|
|
196
|
+
### Rendering
|
|
197
|
+
|
|
198
|
+
| Signature | Description |
|
|
199
|
+
|---|---|
|
|
200
|
+
| `render_to_pdf(odf_path, outdir) -> Path` | Render an ODF file to PDF via LibreOffice (isolated profile). |
|
|
201
|
+
| `pdf_to_pngs(pdf_path, outdir, dpi=150) -> list[Path]` | Render each PDF page to a PNG via `pdftoppm` (Poppler). |
|
|
202
|
+
| `build_contact_sheet(images, output_path, columns=0) -> Path` | Compose page thumbnails into one labelled grid image. Requires Pillow (`pip install open-document-lib[render]`). |
|
|
203
|
+
|
|
193
204
|
## License
|
|
194
205
|
|
|
195
206
|
MIT. See [LICENSE](https://github.com/leiverkus/open-document-skills/blob/main/LICENSE).
|
|
@@ -17,7 +17,7 @@ Four self-contained skills for Codex, Claude Code, and OpenCode that teach an ag
|
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
19
|
# Generate, edit, validate, version — all from the agent shell:
|
|
20
|
-
python skills/odt/scripts/
|
|
20
|
+
python skills/odt/scripts/create_from_markdown.py article.md doc.odt # rich text from Markdown
|
|
21
21
|
python skills/odt/scripts/replace_text.py doc.odt "{{NAME}}" "Patrick" -o out.odt
|
|
22
22
|
python skills/odt/scripts/pack_fodt.py out.odt -o out.fodt # diff-friendly XML
|
|
23
23
|
python skills/odt/scripts/validate_refs.py out.odt
|
|
@@ -27,7 +27,7 @@ python skills/odt/scripts/validate_refs.py out.odt
|
|
|
27
27
|
|
|
28
28
|
| Skill | LibreOffice app | Smithery | Triggers |
|
|
29
29
|
| --- | --- | --- | --- |
|
|
30
|
-
| [`odt`](skills/odt) | Writer | [smithery.ai/skills/leiverkus/odt](https://smithery.ai/skills/leiverkus/odt) | edit ODT, footnotes, citations (BibTeX/CSL-JSON), bookmarks, cross-references, figure/table sequences, MathML formulas, render to PDF |
|
|
30
|
+
| [`odt`](skills/odt) | Writer | [smithery.ai/skills/leiverkus/odt](https://smithery.ai/skills/leiverkus/odt) | author from Markdown, edit ODT, footnotes, citations (BibTeX/CSL-JSON), bookmarks, cross-references, figure/table sequences, MathML formulas, render to PDF |
|
|
31
31
|
| [`odp`](skills/odp) | Impress | [smithery.ai/skills/leiverkus/odp](https://smithery.ai/skills/leiverkus/odp) | clone slide, edit notes, add image, animations (entrance/exit/emphasis/motion), slide transitions, master-page customization (background, header/footer, logo), render deck |
|
|
32
32
|
| [`ods`](skills/ods) | Calc | [smithery.ai/skills/leiverkus/ods](https://smithery.ai/skills/leiverkus/ods) | set cells/formulas, named ranges, dropdowns + data validation, embedded charts (bar/line/pie/scatter), export CSV, recalculate |
|
|
33
33
|
| [`odg`](skills/odg) | Draw | [smithery.ai/skills/leiverkus/odg](https://smithery.ai/skills/leiverkus/odg) | edit labels, add shape image, glue points, connectors with shape binding, groups, flowcharts, org charts, export SVG/PNG |
|
|
@@ -149,6 +149,14 @@ helpers) is internal and may change without notice.
|
|
|
149
149
|
| `find_pandoc() -> str \| None` | Locate the `pandoc` binary. |
|
|
150
150
|
| `latex_to_mathml(latex) -> bytes` | Convert a LaTeX snippet to MathML via Pandoc. |
|
|
151
151
|
|
|
152
|
+
### Rendering
|
|
153
|
+
|
|
154
|
+
| Signature | Description |
|
|
155
|
+
|---|---|
|
|
156
|
+
| `render_to_pdf(odf_path, outdir) -> Path` | Render an ODF file to PDF via LibreOffice (isolated profile). |
|
|
157
|
+
| `pdf_to_pngs(pdf_path, outdir, dpi=150) -> list[Path]` | Render each PDF page to a PNG via `pdftoppm` (Poppler). |
|
|
158
|
+
| `build_contact_sheet(images, output_path, columns=0) -> Path` | Compose page thumbnails into one labelled grid image. Requires Pillow (`pip install open-document-lib[render]`). |
|
|
159
|
+
|
|
152
160
|
## License
|
|
153
161
|
|
|
154
162
|
MIT. See [LICENSE](https://github.com/leiverkus/open-document-skills/blob/main/LICENSE).
|
|
@@ -23,6 +23,7 @@ from odf_lib.odf_common import (
|
|
|
23
23
|
ODF_NAMESPACES,
|
|
24
24
|
VERSION,
|
|
25
25
|
apply_strict_schema_check,
|
|
26
|
+
build_contact_sheet,
|
|
26
27
|
clear_children,
|
|
27
28
|
copy_into_package,
|
|
28
29
|
copy_with_multiple_members,
|
|
@@ -42,6 +43,8 @@ from odf_lib.odf_common import (
|
|
|
42
43
|
pack_dir_as_odf,
|
|
43
44
|
pack_flat_odf,
|
|
44
45
|
parse_xml_from_zip,
|
|
46
|
+
pdf_to_pngs,
|
|
47
|
+
render_to_pdf,
|
|
45
48
|
replace_pattern_with_element_in_element,
|
|
46
49
|
replace_text_in_element,
|
|
47
50
|
sniff_image_mime,
|
|
@@ -105,4 +108,8 @@ __all__ = [
|
|
|
105
108
|
"find_soffice",
|
|
106
109
|
"find_pandoc",
|
|
107
110
|
"latex_to_mathml",
|
|
111
|
+
# Rendering
|
|
112
|
+
"render_to_pdf",
|
|
113
|
+
"pdf_to_pngs",
|
|
114
|
+
"build_contact_sheet",
|
|
108
115
|
]
|
|
@@ -19,7 +19,7 @@ from datetime import datetime, timezone
|
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
from xml.etree import ElementTree as ET
|
|
21
21
|
|
|
22
|
-
VERSION = "1.
|
|
22
|
+
VERSION = "1.4.0" # keep in sync with pyproject.toml (see CONTRIBUTING.md)
|
|
23
23
|
|
|
24
24
|
ODF_NAMESPACES: dict[str, str] = {
|
|
25
25
|
"office": "urn:oasis:names:tc:opendocument:xmlns:office:1.0",
|
|
@@ -1630,3 +1630,149 @@ def local_name(tag: str) -> str:
|
|
|
1630
1630
|
The local name part (e.g. ``"text"``).
|
|
1631
1631
|
"""
|
|
1632
1632
|
return tag.split("}", 1)[1] if tag.startswith("{") else tag
|
|
1633
|
+
|
|
1634
|
+
|
|
1635
|
+
def render_to_pdf(odf_path: Path, outdir: Path) -> Path:
|
|
1636
|
+
"""Render an ODF file to PDF with LibreOffice.
|
|
1637
|
+
|
|
1638
|
+
Uses an isolated, throwaway user profile so concurrent renders do not
|
|
1639
|
+
clash. The PDF is written to *outdir* as ``<stem>.pdf``.
|
|
1640
|
+
|
|
1641
|
+
Args:
|
|
1642
|
+
odf_path: Source ODF file.
|
|
1643
|
+
outdir: Directory for the PDF (created if absent).
|
|
1644
|
+
|
|
1645
|
+
Returns:
|
|
1646
|
+
Path to the rendered PDF.
|
|
1647
|
+
|
|
1648
|
+
Raises:
|
|
1649
|
+
SystemExit: If LibreOffice fails or the PDF is not produced.
|
|
1650
|
+
"""
|
|
1651
|
+
import subprocess
|
|
1652
|
+
|
|
1653
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
1654
|
+
soffice: str = find_soffice()
|
|
1655
|
+
profile: str = tempfile.mkdtemp(prefix="odf-soffice-")
|
|
1656
|
+
try:
|
|
1657
|
+
result = subprocess.run(
|
|
1658
|
+
[
|
|
1659
|
+
soffice,
|
|
1660
|
+
f"-env:UserInstallation=file://{profile}",
|
|
1661
|
+
"--headless",
|
|
1662
|
+
"--convert-to",
|
|
1663
|
+
"pdf",
|
|
1664
|
+
"--outdir",
|
|
1665
|
+
str(outdir),
|
|
1666
|
+
str(odf_path),
|
|
1667
|
+
],
|
|
1668
|
+
text=True,
|
|
1669
|
+
stdout=subprocess.PIPE,
|
|
1670
|
+
stderr=subprocess.STDOUT,
|
|
1671
|
+
)
|
|
1672
|
+
finally:
|
|
1673
|
+
shutil.rmtree(profile, ignore_errors=True)
|
|
1674
|
+
pdf: Path = outdir / f"{odf_path.stem}.pdf"
|
|
1675
|
+
if result.returncode != 0 or not pdf.exists():
|
|
1676
|
+
raise SystemExit(f"LibreOffice failed to render {odf_path.name}:\n{result.stdout}")
|
|
1677
|
+
return pdf
|
|
1678
|
+
|
|
1679
|
+
|
|
1680
|
+
def pdf_to_pngs(pdf_path: Path, outdir: Path, dpi: int = 150) -> list[Path]:
|
|
1681
|
+
"""Render every page of a PDF to a PNG with ``pdftoppm`` (Poppler).
|
|
1682
|
+
|
|
1683
|
+
Args:
|
|
1684
|
+
pdf_path: Source PDF.
|
|
1685
|
+
outdir: Directory for the page PNGs (created if absent).
|
|
1686
|
+
dpi: Render resolution.
|
|
1687
|
+
|
|
1688
|
+
Returns:
|
|
1689
|
+
Sorted list of ``<stem>-N.png`` page images.
|
|
1690
|
+
|
|
1691
|
+
Raises:
|
|
1692
|
+
SystemExit: If ``pdftoppm`` is not installed or rendering fails.
|
|
1693
|
+
"""
|
|
1694
|
+
import subprocess
|
|
1695
|
+
|
|
1696
|
+
pdftoppm: str | None = shutil.which("pdftoppm")
|
|
1697
|
+
if pdftoppm is None:
|
|
1698
|
+
raise SystemExit(
|
|
1699
|
+
"pdftoppm not found — install Poppler (e.g. 'brew install poppler' or 'apt install poppler-utils')"
|
|
1700
|
+
)
|
|
1701
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
1702
|
+
prefix: Path = outdir / pdf_path.stem
|
|
1703
|
+
result = subprocess.run(
|
|
1704
|
+
[pdftoppm, "-png", "-r", str(dpi), str(pdf_path), str(prefix)],
|
|
1705
|
+
text=True,
|
|
1706
|
+
stdout=subprocess.PIPE,
|
|
1707
|
+
stderr=subprocess.STDOUT,
|
|
1708
|
+
)
|
|
1709
|
+
if result.returncode != 0:
|
|
1710
|
+
raise SystemExit(f"pdftoppm failed for {pdf_path.name}:\n{result.stdout}")
|
|
1711
|
+
return sorted(outdir.glob(f"{pdf_path.stem}-*.png"))
|
|
1712
|
+
|
|
1713
|
+
|
|
1714
|
+
def build_contact_sheet(images: list[Path], output_path: Path, columns: int = 0) -> Path:
|
|
1715
|
+
"""Compose page thumbnails into a single labelled grid image.
|
|
1716
|
+
|
|
1717
|
+
A contact sheet shows every page of a document at once, so layout and
|
|
1718
|
+
cross-page consistency can be judged in a single glance. Requires Pillow.
|
|
1719
|
+
|
|
1720
|
+
Args:
|
|
1721
|
+
images: Page images, in order.
|
|
1722
|
+
output_path: Destination PNG.
|
|
1723
|
+
columns: Grid columns; 0 picks 2 for landscape pages, 3 for portrait.
|
|
1724
|
+
|
|
1725
|
+
Returns:
|
|
1726
|
+
Path to the contact sheet PNG.
|
|
1727
|
+
|
|
1728
|
+
Raises:
|
|
1729
|
+
SystemExit: If Pillow is not installed or *images* is empty.
|
|
1730
|
+
"""
|
|
1731
|
+
try:
|
|
1732
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
1733
|
+
except ImportError:
|
|
1734
|
+
raise SystemExit(
|
|
1735
|
+
"Contact sheet rendering requires Pillow. Install with:\n"
|
|
1736
|
+
" pip install open-document-lib[render]\n"
|
|
1737
|
+
"Or render per-page PNGs with --png and inspect them individually."
|
|
1738
|
+
) from None
|
|
1739
|
+
|
|
1740
|
+
if not images:
|
|
1741
|
+
raise SystemExit("build_contact_sheet: no page images to compose")
|
|
1742
|
+
|
|
1743
|
+
thumb_w = 480
|
|
1744
|
+
pad = 16
|
|
1745
|
+
label_h = 22
|
|
1746
|
+
bg = (245, 245, 247)
|
|
1747
|
+
|
|
1748
|
+
thumbs: list[Image.Image] = []
|
|
1749
|
+
for path in images:
|
|
1750
|
+
img = Image.open(path).convert("RGB")
|
|
1751
|
+
ratio = thumb_w / img.width
|
|
1752
|
+
thumbs.append(img.resize((thumb_w, max(1, round(img.height * ratio))), Image.LANCZOS))
|
|
1753
|
+
|
|
1754
|
+
if columns <= 0:
|
|
1755
|
+
columns = 2 if thumbs[0].width >= thumbs[0].height else 3
|
|
1756
|
+
columns = min(columns, len(thumbs))
|
|
1757
|
+
rows = (len(thumbs) + columns - 1) // columns
|
|
1758
|
+
|
|
1759
|
+
cell_w = thumb_w + pad
|
|
1760
|
+
cell_h = max(t.height for t in thumbs) + pad + label_h
|
|
1761
|
+
sheet = Image.new("RGB", (columns * cell_w + pad, rows * cell_h + pad), bg)
|
|
1762
|
+
draw = ImageDraw.Draw(sheet)
|
|
1763
|
+
try:
|
|
1764
|
+
font = ImageFont.truetype("/System/Library/Fonts/Supplemental/Arial.ttf", 15)
|
|
1765
|
+
except OSError:
|
|
1766
|
+
font = ImageFont.load_default()
|
|
1767
|
+
|
|
1768
|
+
for index, thumb in enumerate(thumbs):
|
|
1769
|
+
col = index % columns
|
|
1770
|
+
row = index // columns
|
|
1771
|
+
x = pad + col * cell_w
|
|
1772
|
+
y = pad + row * cell_h
|
|
1773
|
+
draw.text((x, y), f"Page {index + 1}", fill=(60, 60, 70), font=font)
|
|
1774
|
+
sheet.paste(thumb, (x, y + label_h))
|
|
1775
|
+
|
|
1776
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1777
|
+
sheet.save(output_path)
|
|
1778
|
+
return output_path
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: open-document-lib
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: Standard-library toolkit for reading, editing, and writing OpenDocument Format files (ODT, ODP, ODS, ODG)
|
|
5
5
|
Author: Patrick Leiverkus
|
|
6
6
|
License: MIT
|
|
@@ -30,6 +30,8 @@ Provides-Extra: scholarly
|
|
|
30
30
|
Requires-Dist: bibtexparser>=1.4; extra == "scholarly"
|
|
31
31
|
Provides-Extra: validate
|
|
32
32
|
Requires-Dist: lxml>=4.9; extra == "validate"
|
|
33
|
+
Provides-Extra: render
|
|
34
|
+
Requires-Dist: Pillow>=10; extra == "render"
|
|
33
35
|
Provides-Extra: dev
|
|
34
36
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
35
37
|
Requires-Dist: ruff>=0.9; extra == "dev"
|
|
@@ -37,6 +39,7 @@ Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
|
37
39
|
Requires-Dist: hypothesis>=6.0; extra == "dev"
|
|
38
40
|
Requires-Dist: build>=1.0; extra == "dev"
|
|
39
41
|
Requires-Dist: mypy>=1.8; extra == "dev"
|
|
42
|
+
Requires-Dist: Pillow>=10; extra == "dev"
|
|
40
43
|
Dynamic: license-file
|
|
41
44
|
|
|
42
45
|
# open-document-lib
|
|
@@ -190,6 +193,14 @@ helpers) is internal and may change without notice.
|
|
|
190
193
|
| `find_pandoc() -> str \| None` | Locate the `pandoc` binary. |
|
|
191
194
|
| `latex_to_mathml(latex) -> bytes` | Convert a LaTeX snippet to MathML via Pandoc. |
|
|
192
195
|
|
|
196
|
+
### Rendering
|
|
197
|
+
|
|
198
|
+
| Signature | Description |
|
|
199
|
+
|---|---|
|
|
200
|
+
| `render_to_pdf(odf_path, outdir) -> Path` | Render an ODF file to PDF via LibreOffice (isolated profile). |
|
|
201
|
+
| `pdf_to_pngs(pdf_path, outdir, dpi=150) -> list[Path]` | Render each PDF page to a PNG via `pdftoppm` (Poppler). |
|
|
202
|
+
| `build_contact_sheet(images, output_path, columns=0) -> Path` | Compose page thumbnails into one labelled grid image. Requires Pillow (`pip install open-document-lib[render]`). |
|
|
203
|
+
|
|
193
204
|
## License
|
|
194
205
|
|
|
195
206
|
MIT. See [LICENSE](https://github.com/leiverkus/open-document-skills/blob/main/LICENSE).
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "open-document-lib"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.4.0"
|
|
8
8
|
description = "Standard-library toolkit for reading, editing, and writing OpenDocument Format files (ODT, ODP, ODS, ODG)"
|
|
9
9
|
readme = "docs/library-api.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -47,7 +47,8 @@ Issues = "https://github.com/leiverkus/open-document-skills/issues"
|
|
|
47
47
|
odf = ["odfpy>=1.4.0"]
|
|
48
48
|
scholarly = ["bibtexparser>=1.4"]
|
|
49
49
|
validate = ["lxml>=4.9"]
|
|
50
|
-
|
|
50
|
+
render = ["Pillow>=10"]
|
|
51
|
+
dev = ["pytest>=8.0", "ruff>=0.9", "pytest-cov>=5.0", "hypothesis>=6.0", "build>=1.0", "mypy>=1.8", "Pillow>=10"]
|
|
51
52
|
|
|
52
53
|
[tool.setuptools]
|
|
53
54
|
packages = ["odf_lib"]
|
|
@@ -15,6 +15,7 @@ from xml.etree import ElementTree as ET
|
|
|
15
15
|
|
|
16
16
|
from odf_lib.odf_common import (
|
|
17
17
|
VERSION,
|
|
18
|
+
build_contact_sheet,
|
|
18
19
|
clear_children,
|
|
19
20
|
copy_into_package,
|
|
20
21
|
copy_with_multiple_members,
|
|
@@ -559,5 +560,53 @@ class LibOdfCommonTests(unittest.TestCase):
|
|
|
559
560
|
find_soffice()
|
|
560
561
|
|
|
561
562
|
|
|
563
|
+
try:
|
|
564
|
+
from PIL import Image as _PILImage
|
|
565
|
+
|
|
566
|
+
HAVE_PILLOW = True
|
|
567
|
+
except ImportError:
|
|
568
|
+
HAVE_PILLOW = False
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
@unittest.skipUnless(HAVE_PILLOW, "Pillow not installed")
|
|
572
|
+
class ContactSheetTests(unittest.TestCase):
|
|
573
|
+
def _make_pages(self, tmp: Path, count: int, size: tuple[int, int] = (320, 240)) -> list[Path]:
|
|
574
|
+
paths: list[Path] = []
|
|
575
|
+
for i in range(count):
|
|
576
|
+
path = tmp / f"page-{i + 1}.png"
|
|
577
|
+
_PILImage.new("RGB", size, (200, 200 + i * 10, 220)).save(path)
|
|
578
|
+
paths.append(path)
|
|
579
|
+
return paths
|
|
580
|
+
|
|
581
|
+
def test_contact_sheet_composes_a_valid_grid(self) -> None:
|
|
582
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
583
|
+
tmp_path = Path(tmp)
|
|
584
|
+
pages = self._make_pages(tmp_path, 5)
|
|
585
|
+
out = tmp_path / "sheet.png"
|
|
586
|
+
result = build_contact_sheet(pages, out)
|
|
587
|
+
self.assertEqual(result, out)
|
|
588
|
+
self.assertTrue(out.exists())
|
|
589
|
+
with _PILImage.open(out) as sheet:
|
|
590
|
+
self.assertEqual(sheet.format, "PNG")
|
|
591
|
+
# 5 landscape pages -> 2 columns, 3 rows: sheet is wider than one cell.
|
|
592
|
+
self.assertGreater(sheet.width, 480)
|
|
593
|
+
self.assertGreater(sheet.height, 480)
|
|
594
|
+
|
|
595
|
+
def test_contact_sheet_respects_explicit_columns(self) -> None:
|
|
596
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
597
|
+
tmp_path = Path(tmp)
|
|
598
|
+
pages = self._make_pages(tmp_path, 4)
|
|
599
|
+
wide = build_contact_sheet(pages, tmp_path / "wide.png", columns=4)
|
|
600
|
+
narrow = build_contact_sheet(pages, tmp_path / "narrow.png", columns=1)
|
|
601
|
+
with _PILImage.open(wide) as w, _PILImage.open(narrow) as n:
|
|
602
|
+
self.assertGreater(w.width, n.width)
|
|
603
|
+
self.assertGreater(n.height, w.height)
|
|
604
|
+
|
|
605
|
+
def test_contact_sheet_rejects_empty_input(self) -> None:
|
|
606
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
607
|
+
with self.assertRaises(SystemExit):
|
|
608
|
+
build_contact_sheet([], Path(tmp) / "sheet.png")
|
|
609
|
+
|
|
610
|
+
|
|
562
611
|
if __name__ == "__main__":
|
|
563
612
|
unittest.main()
|
|
@@ -88,6 +88,65 @@ class LibreOfficeIntegrationTests(unittest.TestCase):
|
|
|
88
88
|
self.assertTrue(pdf.exists())
|
|
89
89
|
self.assertGreater(pdf.stat().st_size, 0)
|
|
90
90
|
|
|
91
|
+
def test_ods_render_to_pdf(self) -> None:
|
|
92
|
+
"""The new ODS render.py must produce a PDF."""
|
|
93
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
94
|
+
tmp_path = Path(tmp)
|
|
95
|
+
scripts = SKILLS / "ods" / "scripts"
|
|
96
|
+
ods = tmp_path / "book.ods"
|
|
97
|
+
outdir = tmp_path / "qa"
|
|
98
|
+
run_script(scripts / "create_minimal_ods.py", FIXTURES / "ods_workbook.json", ods)
|
|
99
|
+
run_script(scripts / "render.py", ods, "--outdir", outdir)
|
|
100
|
+
pdf = outdir / "book.pdf"
|
|
101
|
+
self.assertTrue(pdf.exists())
|
|
102
|
+
self.assertGreater(pdf.stat().st_size, 0)
|
|
103
|
+
|
|
104
|
+
def test_contact_sheet_render(self) -> None:
|
|
105
|
+
"""render.py --contact-sheet must compose all pages into one PNG."""
|
|
106
|
+
import shutil
|
|
107
|
+
|
|
108
|
+
if not shutil.which("pdftoppm"):
|
|
109
|
+
self.skipTest("pdftoppm (Poppler) not available")
|
|
110
|
+
try:
|
|
111
|
+
import PIL # noqa: F401
|
|
112
|
+
except ImportError:
|
|
113
|
+
self.skipTest("Pillow not installed")
|
|
114
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
115
|
+
tmp_path = Path(tmp)
|
|
116
|
+
scripts = SKILLS / "odp" / "scripts"
|
|
117
|
+
spec = tmp_path / "deck.json"
|
|
118
|
+
spec.write_text(
|
|
119
|
+
json.dumps({"slides": [{"name": f"S{i}", "title": f"Slide {i}"} for i in range(1, 4)]}),
|
|
120
|
+
encoding="utf-8",
|
|
121
|
+
)
|
|
122
|
+
odp = tmp_path / "deck.odp"
|
|
123
|
+
run_script(scripts / "create_minimal_odp.py", spec, odp)
|
|
124
|
+
outdir = tmp_path / "qa"
|
|
125
|
+
run_script(scripts / "render.py", odp, "--outdir", outdir, "--contact-sheet")
|
|
126
|
+
sheet = outdir / "deck-contact.png"
|
|
127
|
+
self.assertTrue(sheet.exists())
|
|
128
|
+
self.assertGreater(sheet.stat().st_size, 0)
|
|
129
|
+
|
|
130
|
+
def test_markdown_to_odt_renders_to_pdf(self) -> None:
|
|
131
|
+
"""An ODT built from Markdown must render to a non-empty PDF."""
|
|
132
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
133
|
+
tmp_path = Path(tmp)
|
|
134
|
+
scripts = SKILLS / "odt" / "scripts"
|
|
135
|
+
src = tmp_path / "doc.md"
|
|
136
|
+
src.write_text(
|
|
137
|
+
"# Title\n\nText with **bold** and a [link](https://x.io).\n\n"
|
|
138
|
+
"- one\n- two\n\n| A | B |\n|---|---|\n| 1 | 2 |\n",
|
|
139
|
+
encoding="utf-8",
|
|
140
|
+
)
|
|
141
|
+
odt = tmp_path / "doc.odt"
|
|
142
|
+
run_script(scripts / "create_from_markdown.py", src, odt)
|
|
143
|
+
run_script(scripts / "validate_refs.py", odt, "--strict")
|
|
144
|
+
outdir = tmp_path / "qa"
|
|
145
|
+
run_script(scripts / "render.py", odt, "--outdir", outdir)
|
|
146
|
+
pdf = outdir / "doc.pdf"
|
|
147
|
+
self.assertTrue(pdf.exists())
|
|
148
|
+
self.assertGreater(pdf.stat().st_size, 0)
|
|
149
|
+
|
|
91
150
|
def test_branded_odg_diagram_renders_to_pdf(self) -> None:
|
|
92
151
|
"""A base ODG with the branded diagram styles.xml injected must render
|
|
93
152
|
to a non-empty PDF."""
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Tests for the Markdown → ODT authoring path — the stdlib parser and the
|
|
2
|
+
inline rich-text emitter (text:span, text:a, footnotes, tables, images)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
import zipfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from xml.etree import ElementTree as ET
|
|
12
|
+
|
|
13
|
+
from helpers import SKILLS, run_script
|
|
14
|
+
|
|
15
|
+
ODT_SCRIPTS = SKILLS / "odt" / "scripts"
|
|
16
|
+
sys.path.insert(0, str(ODT_SCRIPTS))
|
|
17
|
+
|
|
18
|
+
import md_parser as md # noqa: E402
|
|
19
|
+
|
|
20
|
+
NS = {
|
|
21
|
+
"office": "urn:oasis:names:tc:opendocument:xmlns:office:1.0",
|
|
22
|
+
"text": "urn:oasis:names:tc:opendocument:xmlns:text:1.0",
|
|
23
|
+
"table": "urn:oasis:names:tc:opendocument:xmlns:table:1.0",
|
|
24
|
+
"draw": "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0",
|
|
25
|
+
"xlink": "http://www.w3.org/1999/xlink",
|
|
26
|
+
"style": "urn:oasis:names:tc:opendocument:xmlns:style:1.0",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def q(prefix: str, local: str) -> str:
|
|
31
|
+
return f"{{{NS[prefix]}}}{local}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def member(path: Path, name: str) -> ET.Element:
|
|
35
|
+
with zipfile.ZipFile(path) as archive:
|
|
36
|
+
return ET.fromstring(archive.read(name))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# --------------------------------------------------------------------------
|
|
40
|
+
# Parser unit tests
|
|
41
|
+
# --------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ParserBlockTests(unittest.TestCase):
|
|
45
|
+
def test_headings_all_levels(self) -> None:
|
|
46
|
+
doc = md.parse("\n\n".join(f"{'#' * n} Level {n}" for n in range(1, 7)))
|
|
47
|
+
self.assertEqual([type(b).__name__ for b in doc.children], ["Heading"] * 6)
|
|
48
|
+
self.assertEqual([b.level for b in doc.children], [1, 2, 3, 4, 5, 6])
|
|
49
|
+
|
|
50
|
+
def test_nested_bullet_list(self) -> None:
|
|
51
|
+
doc = md.parse("- a\n- b\n - b1\n - b2\n- c")
|
|
52
|
+
lst = doc.children[0]
|
|
53
|
+
self.assertIsInstance(lst, md.ListNode)
|
|
54
|
+
self.assertFalse(lst.ordered)
|
|
55
|
+
self.assertEqual(len(lst.items), 3)
|
|
56
|
+
nested = [b for b in lst.items[1].children if isinstance(b, md.ListNode)]
|
|
57
|
+
self.assertEqual(len(nested), 1)
|
|
58
|
+
self.assertEqual(len(nested[0].items), 2)
|
|
59
|
+
|
|
60
|
+
def test_ordered_list_start(self) -> None:
|
|
61
|
+
doc = md.parse("3. three\n4. four")
|
|
62
|
+
lst = doc.children[0]
|
|
63
|
+
self.assertIsInstance(lst, md.ListNode)
|
|
64
|
+
self.assertTrue(lst.ordered)
|
|
65
|
+
self.assertEqual(lst.start, 3)
|
|
66
|
+
|
|
67
|
+
def test_fenced_code_block(self) -> None:
|
|
68
|
+
doc = md.parse("```python\nx = 1\ny = 2\n```")
|
|
69
|
+
block = doc.children[0]
|
|
70
|
+
self.assertIsInstance(block, md.CodeBlock)
|
|
71
|
+
self.assertEqual(block.language, "python")
|
|
72
|
+
self.assertEqual(block.text, "x = 1\ny = 2")
|
|
73
|
+
|
|
74
|
+
def test_blockquote(self) -> None:
|
|
75
|
+
doc = md.parse("> quoted line one\n> quoted line two")
|
|
76
|
+
quote = doc.children[0]
|
|
77
|
+
self.assertIsInstance(quote, md.BlockQuote)
|
|
78
|
+
self.assertIsInstance(quote.children[0], md.Paragraph)
|
|
79
|
+
|
|
80
|
+
def test_thematic_break(self) -> None:
|
|
81
|
+
doc = md.parse("text\n\n---\n\nmore")
|
|
82
|
+
self.assertIsInstance(doc.children[1], md.ThematicBreak)
|
|
83
|
+
|
|
84
|
+
def test_gfm_table_with_alignment(self) -> None:
|
|
85
|
+
doc = md.parse("| A | B | C |\n|:--|:-:|--:|\n| 1 | 2 | 3 |")
|
|
86
|
+
table = doc.children[0]
|
|
87
|
+
self.assertIsInstance(table, md.Table)
|
|
88
|
+
self.assertEqual(table.alignments, ["left", "center", "right"])
|
|
89
|
+
self.assertEqual(len(table.header), 3)
|
|
90
|
+
self.assertEqual(len(table.rows), 1)
|
|
91
|
+
|
|
92
|
+
def test_block_image(self) -> None:
|
|
93
|
+
doc = md.parse("")
|
|
94
|
+
image = doc.children[0]
|
|
95
|
+
self.assertIsInstance(image, md.BlockImage)
|
|
96
|
+
self.assertEqual(image.src, "pic.png")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ParserInlineTests(unittest.TestCase):
|
|
100
|
+
def _inline(self, text: str) -> list[md.Inline]:
|
|
101
|
+
doc = md.parse(text)
|
|
102
|
+
para = doc.children[0]
|
|
103
|
+
assert isinstance(para, md.Paragraph)
|
|
104
|
+
return para.children
|
|
105
|
+
|
|
106
|
+
def test_bold_italic_code(self) -> None:
|
|
107
|
+
nodes = self._inline("a **bold** and *italic* and `code` end")
|
|
108
|
+
kinds = [type(n).__name__ for n in nodes]
|
|
109
|
+
self.assertIn("Strong", kinds)
|
|
110
|
+
self.assertIn("Emphasis", kinds)
|
|
111
|
+
self.assertIn("Code", kinds)
|
|
112
|
+
|
|
113
|
+
def test_bold_italic_combined(self) -> None:
|
|
114
|
+
nodes = self._inline("***both***")
|
|
115
|
+
self.assertEqual(len(nodes), 1)
|
|
116
|
+
self.assertIsInstance(nodes[0], md.Strong)
|
|
117
|
+
self.assertIsInstance(nodes[0].children[0], md.Emphasis)
|
|
118
|
+
|
|
119
|
+
def test_inline_link(self) -> None:
|
|
120
|
+
nodes = self._inline("see [the docs](https://example.org) now")
|
|
121
|
+
link = next(n for n in nodes if isinstance(n, md.Link))
|
|
122
|
+
self.assertEqual(link.href, "https://example.org")
|
|
123
|
+
|
|
124
|
+
def test_reference_link(self) -> None:
|
|
125
|
+
nodes = self._inline("see [the docs][ref] now\n\n[ref]: https://ref.example")
|
|
126
|
+
link = next(n for n in nodes if isinstance(n, md.Link))
|
|
127
|
+
self.assertEqual(link.href, "https://ref.example")
|
|
128
|
+
|
|
129
|
+
def test_footnote_ref_and_def(self) -> None:
|
|
130
|
+
doc = md.parse("text with a note.[^x]\n\n[^x]: the note body")
|
|
131
|
+
para = doc.children[0]
|
|
132
|
+
assert isinstance(para, md.Paragraph)
|
|
133
|
+
self.assertTrue(any(isinstance(n, md.FootnoteRef) for n in para.children))
|
|
134
|
+
self.assertIn("x", doc.footnotes)
|
|
135
|
+
|
|
136
|
+
def test_underscore_in_word_is_literal(self) -> None:
|
|
137
|
+
nodes = self._inline("a snake_case_name here")
|
|
138
|
+
self.assertEqual(len(nodes), 1)
|
|
139
|
+
self.assertIsInstance(nodes[0], md.Text)
|
|
140
|
+
|
|
141
|
+
def test_backslash_escape(self) -> None:
|
|
142
|
+
nodes = self._inline(r"not \*emphasised\* here")
|
|
143
|
+
self.assertEqual(len(nodes), 1)
|
|
144
|
+
self.assertIsInstance(nodes[0], md.Text)
|
|
145
|
+
self.assertEqual(nodes[0].value, "not *emphasised* here")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# --------------------------------------------------------------------------
|
|
149
|
+
# End-to-end MD → ODT
|
|
150
|
+
# --------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
SAMPLE_MD = """# Report Title
|
|
153
|
+
|
|
154
|
+
A paragraph with **bold**, *italic*, `code`, and a [link](https://example.org).
|
|
155
|
+
A footnote follows.[^a]
|
|
156
|
+
|
|
157
|
+
## Section Two
|
|
158
|
+
|
|
159
|
+
- one
|
|
160
|
+
- two
|
|
161
|
+
- nested
|
|
162
|
+
|
|
163
|
+
1. first
|
|
164
|
+
2. second
|
|
165
|
+
|
|
166
|
+
> a quoted line
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
print("hi")
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
| Name | Score |
|
|
173
|
+
|:-----|------:|
|
|
174
|
+
| Alice | 42 |
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
Done.
|
|
179
|
+
|
|
180
|
+
[^a]: The footnote text.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class EndToEndTests(unittest.TestCase):
|
|
185
|
+
def _build(self, tmp: Path, markdown: str = SAMPLE_MD) -> Path:
|
|
186
|
+
src = tmp / "doc.md"
|
|
187
|
+
src.write_text(markdown, encoding="utf-8")
|
|
188
|
+
odt = tmp / "doc.odt"
|
|
189
|
+
run_script(ODT_SCRIPTS / "create_from_markdown.py", src, odt)
|
|
190
|
+
return odt
|
|
191
|
+
|
|
192
|
+
def test_inline_runs_become_spans(self) -> None:
|
|
193
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
194
|
+
content = member(self._build(Path(tmp)), "content.xml")
|
|
195
|
+
spans = list(content.iter(q("text", "span")))
|
|
196
|
+
styles = {s.attrib.get(q("text", "style-name")) for s in spans}
|
|
197
|
+
self.assertIn("Strong", styles)
|
|
198
|
+
self.assertIn("Emphasis", styles)
|
|
199
|
+
self.assertIn("Code", styles)
|
|
200
|
+
|
|
201
|
+
def test_link_becomes_text_a(self) -> None:
|
|
202
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
203
|
+
content = member(self._build(Path(tmp)), "content.xml")
|
|
204
|
+
anchor = next(content.iter(q("text", "a")))
|
|
205
|
+
self.assertEqual(anchor.attrib.get(q("xlink", "href")), "https://example.org")
|
|
206
|
+
|
|
207
|
+
def test_headings_carry_outline_levels(self) -> None:
|
|
208
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
209
|
+
content = member(self._build(Path(tmp)), "content.xml")
|
|
210
|
+
levels = sorted({h.attrib.get(q("text", "outline-level")) for h in content.iter(q("text", "h"))})
|
|
211
|
+
self.assertEqual(levels, ["1", "2"])
|
|
212
|
+
|
|
213
|
+
def test_lists_and_table_and_footnote(self) -> None:
|
|
214
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
215
|
+
content = member(self._build(Path(tmp)), "content.xml")
|
|
216
|
+
self.assertGreaterEqual(len(list(content.iter(q("text", "list")))), 2)
|
|
217
|
+
self.assertEqual(len(list(content.iter(q("table", "table")))), 1)
|
|
218
|
+
notes = list(content.iter(q("text", "note")))
|
|
219
|
+
self.assertEqual(len(notes), 1)
|
|
220
|
+
self.assertTrue(notes[0].attrib.get(q("text", "id")))
|
|
221
|
+
|
|
222
|
+
def test_code_block_uses_codeblock_style(self) -> None:
|
|
223
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
224
|
+
content = member(self._build(Path(tmp)), "content.xml")
|
|
225
|
+
code_paras = [
|
|
226
|
+
p for p in content.iter(q("text", "p")) if p.attrib.get(q("text", "style-name")) == "CodeBlock"
|
|
227
|
+
]
|
|
228
|
+
self.assertGreater(len(code_paras), 0)
|
|
229
|
+
|
|
230
|
+
def test_title_from_first_heading(self) -> None:
|
|
231
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
232
|
+
meta = member(self._build(Path(tmp)), "meta.xml")
|
|
233
|
+
titles = [t.text for t in meta.iter("{http://purl.org/dc/elements/1.1/}title")]
|
|
234
|
+
self.assertEqual(titles, ["Report Title"])
|
|
235
|
+
|
|
236
|
+
def test_embedded_image(self) -> None:
|
|
237
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
238
|
+
tmp_path = Path(tmp)
|
|
239
|
+
png = tmp_path / "p.png"
|
|
240
|
+
png.write_bytes(
|
|
241
|
+
bytes.fromhex(
|
|
242
|
+
"89504e470d0a1a0a0000000d49484452000000040000000408060000"
|
|
243
|
+
"00b6f8b4570000000a49444154789c63600000000200013fd7e2d6"
|
|
244
|
+
"0000000049454e44ae426082"
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
odt = self._build(tmp_path, f"# Pic\n\n\n")
|
|
248
|
+
with zipfile.ZipFile(odt) as archive:
|
|
249
|
+
pictures = [n for n in archive.namelist() if n.startswith("Pictures/")]
|
|
250
|
+
self.assertEqual(len(pictures), 1)
|
|
251
|
+
content = member(odt, "content.xml")
|
|
252
|
+
self.assertEqual(len(list(content.iter(q("draw", "image")))), 1)
|
|
253
|
+
|
|
254
|
+
def test_output_passes_strict_validation(self) -> None:
|
|
255
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
256
|
+
odt = self._build(Path(tmp))
|
|
257
|
+
result = run_script(ODT_SCRIPTS / "validate_refs.py", odt, "--strict", check=False)
|
|
258
|
+
self.assertIn('"status": "ok"', result.stdout)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
if __name__ == "__main__":
|
|
262
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{open_document_lib-1.2.0 → open_document_lib-1.4.0}/open_document_lib.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{open_document_lib-1.2.0 → open_document_lib-1.4.0}/open_document_lib.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|