open-document-lib 1.3.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.
Files changed (45) hide show
  1. {open_document_lib-1.3.0/open_document_lib.egg-info → open_document_lib-1.4.0}/PKG-INFO +12 -1
  2. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/docs/library-api.md +8 -0
  3. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/odf_lib/__init__.py +7 -0
  4. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/odf_lib/odf_common.py +147 -1
  5. {open_document_lib-1.3.0 → open_document_lib-1.4.0/open_document_lib.egg-info}/PKG-INFO +12 -1
  6. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/open_document_lib.egg-info/requires.txt +4 -0
  7. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/pyproject.toml +3 -2
  8. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/tests/test_lib_odf_common.py +49 -0
  9. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/tests/test_libreoffice_integration.py +39 -0
  10. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/LICENSE +0 -0
  11. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/README.md +0 -0
  12. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/odf_lib/citation_mapping.py +0 -0
  13. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/odf_lib/py.typed +0 -0
  14. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/open_document_lib.egg-info/SOURCES.txt +0 -0
  15. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/open_document_lib.egg-info/dependency_links.txt +0 -0
  16. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/open_document_lib.egg-info/top_level.txt +0 -0
  17. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/setup.cfg +0 -0
  18. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/tests/test_benchmarks.py +0 -0
  19. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/tests/test_citations.py +0 -0
  20. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/tests/test_corpus.py +0 -0
  21. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/tests/test_cross_refs.py +0 -0
  22. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/tests/test_dao_template.py +0 -0
  23. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/tests/test_docs.py +0 -0
  24. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/tests/test_edge_cases.py +0 -0
  25. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/tests/test_examples.py +0 -0
  26. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/tests/test_flat_odf.py +0 -0
  27. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/tests/test_footnotes.py +0 -0
  28. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/tests/test_install.py +0 -0
  29. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/tests/test_markdown_to_odt.py +0 -0
  30. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/tests/test_math.py +0 -0
  31. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/tests/test_meta_lifecycle.py +0 -0
  32. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/tests/test_odg_connectors.py +0 -0
  33. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/tests/test_odg_gluepoints.py +0 -0
  34. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/tests/test_odg_groups.py +0 -0
  35. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/tests/test_odg_styling.py +0 -0
  36. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/tests/test_odp_animations.py +0 -0
  37. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/tests/test_odp_master.py +0 -0
  38. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/tests/test_odp_styling.py +0 -0
  39. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/tests/test_odp_transitions.py +0 -0
  40. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/tests/test_ods_charts.py +0 -0
  41. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/tests/test_ods_named_ranges.py +0 -0
  42. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/tests/test_ods_validation.py +0 -0
  43. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/tests/test_property.py +0 -0
  44. {open_document_lib-1.3.0 → open_document_lib-1.4.0}/tests/test_schema_validation.py +0 -0
  45. {open_document_lib-1.3.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.0
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).
@@ -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.3.0" # keep in sync with pyproject.toml (see CONTRIBUTING.md)
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.0
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).
@@ -6,10 +6,14 @@ pytest-cov>=5.0
6
6
  hypothesis>=6.0
7
7
  build>=1.0
8
8
  mypy>=1.8
9
+ Pillow>=10
9
10
 
10
11
  [odf]
11
12
  odfpy>=1.4.0
12
13
 
14
+ [render]
15
+ Pillow>=10
16
+
13
17
  [scholarly]
14
18
  bibtexparser>=1.4
15
19
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "open-document-lib"
7
- version = "1.3.0"
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
- dev = ["pytest>=8.0", "ruff>=0.9", "pytest-cov>=5.0", "hypothesis>=6.0", "build>=1.0", "mypy>=1.8"]
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,45 @@ 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
+
91
130
  def test_markdown_to_odt_renders_to_pdf(self) -> None:
92
131
  """An ODT built from Markdown must render to a non-empty PDF."""
93
132
  with tempfile.TemporaryDirectory() as tmp: