open-document-lib 1.0.2__tar.gz → 1.2.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 (44) hide show
  1. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/PKG-INFO +1 -1
  2. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/README.md +15 -153
  3. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/odf_lib/odf_common.py +9 -2
  4. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/open_document_lib.egg-info/PKG-INFO +1 -1
  5. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/open_document_lib.egg-info/SOURCES.txt +2 -0
  6. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/pyproject.toml +1 -1
  7. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_libreoffice_integration.py +47 -1
  8. open_document_lib-1.2.0/tests/test_odg_styling.py +250 -0
  9. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_odp_master.py +10 -1
  10. open_document_lib-1.2.0/tests/test_odp_styling.py +186 -0
  11. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/LICENSE +0 -0
  12. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/docs/library-api.md +0 -0
  13. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/odf_lib/__init__.py +0 -0
  14. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/odf_lib/citation_mapping.py +0 -0
  15. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/odf_lib/py.typed +0 -0
  16. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/open_document_lib.egg-info/dependency_links.txt +0 -0
  17. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/open_document_lib.egg-info/requires.txt +0 -0
  18. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/open_document_lib.egg-info/top_level.txt +0 -0
  19. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/setup.cfg +0 -0
  20. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_benchmarks.py +0 -0
  21. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_citations.py +0 -0
  22. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_corpus.py +0 -0
  23. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_cross_refs.py +0 -0
  24. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_dao_template.py +0 -0
  25. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_docs.py +0 -0
  26. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_edge_cases.py +0 -0
  27. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_examples.py +0 -0
  28. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_flat_odf.py +0 -0
  29. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_footnotes.py +0 -0
  30. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_install.py +0 -0
  31. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_lib_odf_common.py +0 -0
  32. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_math.py +0 -0
  33. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_meta_lifecycle.py +0 -0
  34. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_odg_connectors.py +0 -0
  35. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_odg_gluepoints.py +0 -0
  36. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_odg_groups.py +0 -0
  37. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_odp_animations.py +0 -0
  38. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_odp_transitions.py +0 -0
  39. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_ods_charts.py +0 -0
  40. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_ods_named_ranges.py +0 -0
  41. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_ods_validation.py +0 -0
  42. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_property.py +0 -0
  43. {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_schema_validation.py +0 -0
  44. {open_document_lib-1.0.2 → open_document_lib-1.2.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.0.2
3
+ Version: 1.2.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
@@ -1,13 +1,20 @@
1
1
  # Open Document Skills
2
2
 
3
3
  [![Tests](https://github.com/leiverkus/open-document-skills/actions/workflows/tests.yml/badge.svg)](https://github.com/leiverkus/open-document-skills/actions/workflows/tests.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/open-document-lib)](https://pypi.org/project/open-document-lib/)
5
+ [![Python](https://img.shields.io/pypi/pyversions/open-document-lib)](https://pypi.org/project/open-document-lib/)
4
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
5
- [![Release](https://img.shields.io/github/v/release/leiverkus/open-document-skills)](https://github.com/leiverkus/open-document-skills/releases)
6
7
 
7
8
  **Native ODT / ODP / ODS / ODG generation and editing for agents — no DOCX round-trips, no LibreOffice dependency for the core path.**
8
9
 
9
10
  Four self-contained skills for Codex, Claude Code, and OpenCode that teach an agent to create, inspect, and edit OpenDocument files directly via Python (stdlib only). Edits preserve inline structure (`text:span`, `text:note`, `text:bookmark`, `text:a`), `meta.xml` is updated on every save, and flat single-XML formats (`.fodt`/`.fodp`/`.fods`/`.fodg`) give you Git-friendly diffs. LibreOffice is optional and only needed for rendering, recalculation, and PDF export.
10
11
 
12
+ <p align="center">
13
+ <img src="docs/assets/hero.png" alt="A branded presentation title slide generated from a JSON spec — deep-blue theme, white typography, logo" width="640">
14
+ <br>
15
+ <em>A branded title slide generated from a JSON spec and rendered with LibreOffice — a curated theme injected as <code>styles.xml</code>, in pure Python. See <a href="examples/deck/">examples/deck</a>.</em>
16
+ </p>
17
+
11
18
  ```bash
12
19
  # Generate, edit, validate, version — all from the agent shell:
13
20
  python skills/odt/scripts/create_minimal_odt.py spec.json doc.odt
@@ -31,11 +38,11 @@ python skills/odt/scripts/validate_refs.py out.odt
31
38
  - **Stdlib-only core.** Every generator, validator, and edit script runs without `pip install` — `xml.etree.ElementTree` and `zipfile` only. LibreOffice is needed only for rendering and recalculation.
32
39
  - **Structure-preserving edits.** `replace_text` keeps footnotes, hyperlinks, and inline formatting intact. `add_image` updates the manifest and `meta.xml`. `replace_cells` handles typed values and formulas.
33
40
  - **Audit-friendly.** Every edit writes `meta:modification-date`, `meta:generator`, and increments `meta:editing-cycles`. Pack to `.fodt` and `git diff` works.
34
- - **Tested.** 76 unit + integration tests run on every push; CI installs LibreOffice so the render/recalc paths are exercised too.
41
+ - **Tested.** Over 200 unit and integration tests run on every push across Python 3.10–3.13; CI installs LibreOffice so the render/recalc paths are exercised too.
35
42
 
36
43
  ## What this is not
37
44
 
38
- Not a LibreOffice replacement. Not a substitute for full ODF feature coverage (tracked changes, complex TOCs, Impress animations, Calc pivots, Draw glue points, RelaxNG schema validation are explicit non-goals — see [Current Limits](#current-limits)). The goal is to make the 80% of ODF automation that agents need safe, repeatable, and dependency-light.
45
+ Not a LibreOffice replacement, and not a substitute for full ODF feature coverage. Tracked changes, generated tables of contents, Calc pivot tables, and DOCX/PPTX/XLSX import-and-edit are explicit non-goals — see [Current Limits](#current-limits). The goal is to make the 80% of ODF automation that agents need safe, repeatable, and dependency-light.
39
46
 
40
47
  ## Repository Layout
41
48
 
@@ -80,6 +87,7 @@ Detailed documentation lives in [docs/index.md](docs/index.md):
80
87
  - [Agent Compatibility](docs/agent-compatibility.md)
81
88
  - [OpenDocument Workflows](docs/workflows.md)
82
89
  - [Script Reference](docs/script-reference.md)
90
+ - [Library API](docs/library-api.md)
83
91
 
84
92
  ## Installation
85
93
 
@@ -228,136 +236,6 @@ LibreOffice usually provides `soffice` inside the app bundle, not directly on th
228
236
 
229
237
  The render/recalc scripts look for that macOS path automatically. They also check common Linux and Windows locations.
230
238
 
231
- ## Skills
232
-
233
- ### ODT
234
-
235
- OpenDocument Text / LibreOffice Writer.
236
-
237
- Focus:
238
-
239
- - template-first document editing
240
- - direct ODT XML generation
241
- - headings, paragraphs, lists, tables, footnotes, images
242
- - style/page-layout awareness
243
- - PDF QA through LibreOffice
244
-
245
- Useful scripts:
246
-
247
- ```bash
248
- python skills/odt/scripts/create_minimal_odt.py document.json output.odt
249
- python skills/odt/scripts/extract_text.py output.odt
250
- python skills/odt/scripts/inspect_package.py output.odt
251
- python skills/odt/scripts/replace_text.py input.odt "{{NAME}}" "Patrick Leiverkus" -o output.odt
252
- python skills/odt/scripts/add_image.py input.odt figure.png -o output.odt
253
- python skills/odt/scripts/add_footnote.py input.odt --anchor "claim" --body "Source: ..." -o output.odt
254
- python skills/odt/scripts/fill_citations.py template.odt --source refs.bib -o output.odt
255
- python skills/odt/scripts/add_bookmark.py input.odt --name K1 --anchor "Chapter 1" -o output.odt
256
- python skills/odt/scripts/add_math.py input.odt --latex 'E = mc^2' --anchor "Equation" -o output.odt
257
- python skills/odt/scripts/pack_fodt.py output.odt -o output.fodt
258
- python skills/odt/scripts/validate_refs.py output.odt
259
- ```
260
-
261
- Script reference: see [docs/script-reference.md](docs/script-reference.md).
262
-
263
- ### ODP
264
-
265
- OpenDocument Presentation / LibreOffice Impress.
266
-
267
- Focus:
268
-
269
- - template-first presentations
270
- - direct ODP XML generation
271
- - `draw:page`, speaker notes, master pages
272
- - slide text/media inspection
273
- - package and visual QA
274
-
275
- Useful scripts:
276
-
277
- ```bash
278
- python skills/odp/scripts/create_minimal_odp.py slides.json output.odp
279
- python skills/odp/scripts/extract_text.py output.odp
280
- python skills/odp/scripts/inspect_package.py output.odp
281
- python skills/odp/scripts/clone_slide.py template.odp --source-slide 1 --name "Agenda" -o output.odp
282
- python skills/odp/scripts/add_image.py input.odp figure.png -o output.odp
283
- python skills/odp/scripts/validate_refs.py output.odp
284
- ```
285
-
286
- Script reference: see [docs/script-reference.md](docs/script-reference.md).
287
-
288
- ### ODS
289
-
290
- OpenDocument Spreadsheet / LibreOffice Calc.
291
-
292
- Focus:
293
-
294
- - direct ODS XML generation
295
- - template-first spreadsheet editing
296
- - typed cell values
297
- - formulas
298
- - repeated rows/cells
299
- - CSV export and formula QA
300
-
301
- Useful scripts:
302
-
303
- ```bash
304
- python skills/ods/scripts/create_minimal_ods.py workbook.json output.ods
305
- python skills/ods/scripts/extract_sheets.py output.ods
306
- python skills/ods/scripts/extract_formulas.py output.ods
307
- python skills/ods/scripts/replace_cells.py input.ods 'Data!B2=42' 'Data!C2=formula:of:=[.B2]*2' -o output.ods
308
- python skills/ods/scripts/export_csv.py output.ods --sheet Data --output data.csv
309
- python skills/ods/scripts/validate_refs.py output.ods
310
- ```
311
-
312
- Script reference: see [docs/script-reference.md](docs/script-reference.md).
313
-
314
- ### ODG
315
-
316
- OpenDocument Graphics / LibreOffice Draw.
317
-
318
- Focus:
319
-
320
- - direct ODG XML generation
321
- - template-first diagram editing
322
- - vector shapes, text boxes, lines, connectors, images
323
- - geometry inspection
324
- - PDF/SVG/PNG export QA
325
-
326
- Useful scripts:
327
-
328
- ```bash
329
- python skills/odg/scripts/create_minimal_odg.py drawing.json output.odg
330
- python skills/odg/scripts/extract_text.py output.odg
331
- python skills/odg/scripts/extract_shapes.py output.odg
332
- python skills/odg/scripts/inspect_package.py output.odg
333
- python skills/odg/scripts/replace_text.py input.odg "{{LABEL}}" "Updated label" -o output.odg
334
- python skills/odg/scripts/validate_refs.py output.odg
335
- ```
336
-
337
- Script reference: see [docs/script-reference.md](docs/script-reference.md).
338
-
339
- ## Testing
340
-
341
- Run the test suite:
342
-
343
- ```bash
344
- python -m unittest discover -s tests
345
- ```
346
-
347
- The tests create minimal ODT, ODP, ODS, and ODG files, then exercise extraction, validation, editing, media insertion, and export helpers.
348
-
349
- LibreOffice integration tests are included. They render ODT/ODP/ODG files and recalculate ODS files when `soffice` is available. If LibreOffice is not available, those tests are skipped.
350
-
351
- GitHub Actions runs the same suite on every push and pull request. The workflow installs LibreOffice and Poppler with `apt` on Ubuntu so the LibreOffice integration tests run in CI instead of being skipped.
352
-
353
- Reusable example inputs live in `tests/fixtures/`:
354
-
355
- - `odt_document.json`
356
- - `odp_slides.json`
357
- - `ods_workbook.json`
358
- - `odg_drawing.json`
359
- - `image.svg`
360
-
361
239
  ## Examples
362
240
 
363
241
  Runnable examples live in `examples/`. They are meant as a practical first test layer for users of the skills:
@@ -452,27 +330,11 @@ Out of scope — use LibreOffice for these:
452
330
 
453
331
  See [ROADMAP.md](ROADMAP.md) for what is planned next.
454
332
 
455
- ## Development
456
-
457
- Recommended loop:
458
-
459
- ```bash
460
- python -m unittest discover -s tests
461
- git status --short
462
- ```
463
-
464
- When adding a new script or behavior:
465
-
466
- 1. Add the smallest useful script interface.
467
- 2. Add or update a smoke test.
468
- 3. Run local tests.
469
- 4. Push and let GitHub Actions verify the repo.
470
-
471
- See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development and release checklist.
472
-
473
- ## Release Status
333
+ ## Contributing & releases
474
334
 
475
- Current release: `v0.9.0` a robustness release. Every helper is now exercised against a committed corpus of 17 LibreOffice-native ODF fixtures (`tests/test_corpus.py`), which uncovered and fixed two foreign-ODF bugs in `validate_refs` and the flat-ODF roundtrip. No new features — all four skills (ODT/ODP/ODS/ODG) remain at production-level depth. See [ROADMAP.md](ROADMAP.md) for v1.0 (PyPI publication + final polish + ecosystem maturity).
335
+ Development setup, the test loop, and the release checklist live in
336
+ [CONTRIBUTING.md](CONTRIBUTING.md). Version history is in
337
+ [CHANGELOG.md](CHANGELOG.md); planned work is in [ROADMAP.md](ROADMAP.md).
476
338
 
477
339
  ## License
478
340
 
@@ -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.0.2" # keep in sync with pyproject.toml (see CONTRIBUTING.md)
22
+ VERSION = "1.2.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",
@@ -221,7 +221,14 @@ def inject_styles_from_file(
221
221
  v = node.attrib.get(f"{{{text_ns}}}style-name")
222
222
  if v:
223
223
  used.add(v)
224
- missing: list[str] = sorted(used - defined_names - parent_names)
224
+ # Styles defined in content.xml's own automatic-styles satisfy a reference
225
+ # too — only swapping styles.xml never touches them.
226
+ content_defined: set[str] = set()
227
+ for style_el in content_root.iter(f"{{{style_ns}}}style"):
228
+ name = style_el.attrib.get(f"{{{style_ns}}}name")
229
+ if name:
230
+ content_defined.add(name)
231
+ missing: list[str] = sorted(used - defined_names - parent_names - content_defined)
225
232
 
226
233
  write_odf_with_replacements(
227
234
  input_path,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: open-document-lib
3
- Version: 1.0.2
3
+ Version: 1.2.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
@@ -29,8 +29,10 @@ tests/test_meta_lifecycle.py
29
29
  tests/test_odg_connectors.py
30
30
  tests/test_odg_gluepoints.py
31
31
  tests/test_odg_groups.py
32
+ tests/test_odg_styling.py
32
33
  tests/test_odp_animations.py
33
34
  tests/test_odp_master.py
35
+ tests/test_odp_styling.py
34
36
  tests/test_odp_transitions.py
35
37
  tests/test_ods_charts.py
36
38
  tests/test_ods_named_ranges.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "open-document-lib"
7
- version = "1.0.2"
7
+ version = "1.2.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"
@@ -6,7 +6,7 @@ import tempfile
6
6
  import unittest
7
7
  from pathlib import Path
8
8
 
9
- from helpers import FIXTURES, SKILLS, run_script
9
+ from helpers import FIXTURES, ROOT, SKILLS, run_script
10
10
 
11
11
  # Import shared find_soffice from lib
12
12
  _repo_root = Path(__file__).resolve().parents[1]
@@ -64,6 +64,52 @@ class LibreOfficeIntegrationTests(unittest.TestCase):
64
64
  self.assertTrue(pdf.exists())
65
65
  self.assertGreater(pdf.stat().st_size, 0)
66
66
 
67
+ def test_branded_odp_deck_renders_to_pdf(self) -> None:
68
+ """A base ODP with the branded deck styles.xml injected + logo embedded
69
+ must render to a non-empty PDF."""
70
+ with tempfile.TemporaryDirectory() as tmp:
71
+ tmp_path = Path(tmp)
72
+ scripts = SKILLS / "odp" / "scripts"
73
+ sys.path.insert(0, str(scripts))
74
+ from odp_common import embed_pictures, inject_styles_from_file
75
+
76
+ deck = ROOT / "examples" / "deck"
77
+ base = tmp_path / "base.odp"
78
+ run_script(scripts / "create_minimal_odp.py", deck / "spec.json", base)
79
+ styled = tmp_path / "styled.odp"
80
+ inject_styles_from_file(base, deck / "styles.xml", styled)
81
+ final = tmp_path / "deck.odp"
82
+ embed_pictures(styled, {"Pictures/logo.png": deck / "logo-placeholder.png"}, final)
83
+ run_script(scripts / "validate_refs.py", final)
84
+
85
+ outdir = tmp_path / "qa"
86
+ run_script(scripts / "render.py", final, "--outdir", outdir)
87
+ pdf = outdir / "deck.pdf"
88
+ self.assertTrue(pdf.exists())
89
+ self.assertGreater(pdf.stat().st_size, 0)
90
+
91
+ def test_branded_odg_diagram_renders_to_pdf(self) -> None:
92
+ """A base ODG with the branded diagram styles.xml injected must render
93
+ to a non-empty PDF."""
94
+ with tempfile.TemporaryDirectory() as tmp:
95
+ tmp_path = Path(tmp)
96
+ scripts = SKILLS / "odg" / "scripts"
97
+ sys.path.insert(0, str(scripts))
98
+ from odg_common import inject_styles_from_file
99
+
100
+ diagram = ROOT / "examples" / "diagram"
101
+ base = tmp_path / "base.odg"
102
+ run_script(scripts / "create_minimal_odg.py", diagram / "spec.json", base)
103
+ final = tmp_path / "diagram.odg"
104
+ inject_styles_from_file(base, diagram / "styles.xml", final)
105
+ run_script(scripts / "validate_refs.py", final)
106
+
107
+ outdir = tmp_path / "qa"
108
+ run_script(scripts / "render.py", final, "--outdir", outdir, "--formats", "pdf")
109
+ pdf = outdir / "diagram.pdf"
110
+ self.assertTrue(pdf.exists())
111
+ self.assertGreater(pdf.stat().st_size, 0)
112
+
67
113
  def test_libreoffice_opens_flat_fodt(self) -> None:
68
114
  import subprocess
69
115
 
@@ -0,0 +1,250 @@
1
+ """Tests for ODG drawing styling — designed graphic styles (no generic blue),
2
+ per-shape fill/stroke/text overrides, and the styles.xml inject path."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import sys
8
+ import tempfile
9
+ import unittest
10
+ import zipfile
11
+ from pathlib import Path
12
+ from xml.etree import ElementTree as ET
13
+
14
+ from helpers import ROOT, SKILLS, run_script
15
+
16
+ NS = {
17
+ "office": "urn:oasis:names:tc:opendocument:xmlns:office:1.0",
18
+ "style": "urn:oasis:names:tc:opendocument:xmlns:style:1.0",
19
+ "draw": "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0",
20
+ "fo": "urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0",
21
+ "text": "urn:oasis:names:tc:opendocument:xmlns:text:1.0",
22
+ "svg": "urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0",
23
+ }
24
+
25
+ ODG_SCRIPTS = SKILLS / "odg" / "scripts"
26
+ DIAGRAM_STYLES = ROOT / "examples" / "diagram" / "styles.xml"
27
+ GENERIC_BLUE = "#729fcf" # LibreOffice's default graphic-style fill
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
+ def make_drawing(tmp_path: Path) -> Path:
40
+ """Generate an ODG with a plain shape, a styled shape, and a text frame."""
41
+ spec = tmp_path / "d.json"
42
+ spec.write_text(
43
+ json.dumps(
44
+ {
45
+ "pages": [
46
+ {
47
+ "name": "P",
48
+ "items": [
49
+ {"type": "text", "text": "Heading", "x": "1cm", "y": "1cm"},
50
+ {"type": "rect", "name": "Plain", "text": "Plain", "x": "1cm", "y": "4cm"},
51
+ {
52
+ "type": "rect",
53
+ "name": "Styled",
54
+ "text": "Styled",
55
+ "x": "8cm",
56
+ "y": "4cm",
57
+ "fill": "#F4C542",
58
+ "stroke": "#7A5C00",
59
+ "text-color": "#3A2E00",
60
+ "font-size": "20pt",
61
+ },
62
+ {"type": "ellipse", "name": "Round", "text": "Round", "x": "15cm", "y": "4cm"},
63
+ {"type": "line", "x1": "1cm", "y1": "8cm", "x2": "10cm", "y2": "8cm"},
64
+ ],
65
+ }
66
+ ]
67
+ }
68
+ ),
69
+ encoding="utf-8",
70
+ )
71
+ odg = tmp_path / "d.odg"
72
+ run_script(ODG_SCRIPTS / "create_minimal_odg.py", spec, odg)
73
+ return odg
74
+
75
+
76
+ def styles_named(root: ET.Element) -> dict[str, ET.Element]:
77
+ out: dict[str, ET.Element] = {}
78
+ for st in root.iter(q("style", "style")):
79
+ name = st.attrib.get(q("style", "name"))
80
+ if name:
81
+ out[name] = st
82
+ return out
83
+
84
+
85
+ class GeneratedStylingTests(unittest.TestCase):
86
+ def test_no_generic_blue_anywhere(self) -> None:
87
+ with tempfile.TemporaryDirectory() as tmp:
88
+ odg = make_drawing(Path(tmp))
89
+ for part in ("styles.xml", "content.xml"):
90
+ raw = zipfile.ZipFile(odg).read(part).decode("utf-8").lower()
91
+ self.assertNotIn(GENERIC_BLUE, raw, f"{part} still carries LibreOffice's default blue")
92
+
93
+ def test_every_shape_references_a_style(self) -> None:
94
+ with tempfile.TemporaryDirectory() as tmp:
95
+ odg = make_drawing(Path(tmp))
96
+ content = member(odg, "content.xml")
97
+ shapes = [el for el in content.iter() if el.tag.split("}")[-1] in {"rect", "ellipse", "line", "frame"}]
98
+ self.assertGreaterEqual(len(shapes), 5)
99
+ for shape in shapes:
100
+ self.assertIsNotNone(
101
+ shape.attrib.get(q("draw", "style-name")),
102
+ f"{shape.tag} carries no draw:style-name",
103
+ )
104
+
105
+ def test_standard_style_has_chosen_properties(self) -> None:
106
+ with tempfile.TemporaryDirectory() as tmp:
107
+ odg = make_drawing(Path(tmp))
108
+ styles = styles_named(member(odg, "styles.xml"))
109
+ self.assertIn("standard", styles)
110
+ props = styles["standard"].find(q("style", "graphic-properties"))
111
+ assert props is not None
112
+ fill = props.attrib.get(q("draw", "fill-color"))
113
+ self.assertIsNotNone(fill)
114
+ self.assertNotEqual((fill or "").lower(), GENERIC_BLUE)
115
+
116
+ def test_text_and_image_roles_are_unfilled(self) -> None:
117
+ with tempfile.TemporaryDirectory() as tmp:
118
+ odg = make_drawing(Path(tmp))
119
+ styles = styles_named(member(odg, "styles.xml"))
120
+ for name in ("gr-text", "gr-image"):
121
+ self.assertIn(name, styles)
122
+ props = styles[name].find(q("style", "graphic-properties"))
123
+ assert props is not None
124
+ self.assertEqual(props.attrib.get(q("draw", "fill")), "none")
125
+ self.assertEqual(props.attrib.get(q("draw", "stroke")), "none")
126
+
127
+ def test_drawing_page_background_referenced_by_master(self) -> None:
128
+ with tempfile.TemporaryDirectory() as tmp:
129
+ odg = member(make_drawing(Path(tmp)), "styles.xml")
130
+ master = next(
131
+ m for m in odg.iter(q("style", "master-page")) if m.attrib.get(q("style", "name")) == "Default"
132
+ )
133
+ dp_name = master.attrib.get(q("draw", "style-name"))
134
+ self.assertIsNotNone(dp_name, "master page must reference a drawing-page style")
135
+ # The drawing-page style must sit in office:automatic-styles.
136
+ auto = odg.find(q("office", "automatic-styles"))
137
+ assert auto is not None
138
+ dp = next(
139
+ s
140
+ for s in auto.findall(q("style", "style"))
141
+ if s.attrib.get(q("style", "name")) == dp_name and s.attrib.get(q("style", "family")) == "drawing-page"
142
+ )
143
+ props = dp.find(q("style", "drawing-page-properties"))
144
+ assert props is not None
145
+ self.assertEqual(props.attrib.get(q("draw", "fill")), "solid")
146
+
147
+
148
+ class PerShapeStylingTests(unittest.TestCase):
149
+ def test_graphic_overrides_produce_an_automatic_style(self) -> None:
150
+ with tempfile.TemporaryDirectory() as tmp:
151
+ content = member(make_drawing(Path(tmp)), "content.xml")
152
+ styled = next(el for el in content.iter(q("draw", "rect")) if el.attrib.get(q("draw", "name")) == "Styled")
153
+ style_name = styled.attrib.get(q("draw", "style-name"))
154
+ self.assertTrue((style_name or "").startswith("gr-auto-"))
155
+ auto = content.find(q("office", "automatic-styles"))
156
+ assert auto is not None
157
+ style = next(s for s in auto.findall(q("style", "style")) if s.attrib.get(q("style", "name")) == style_name)
158
+ self.assertEqual(style.attrib.get(q("style", "parent-style-name")), "gr-shape")
159
+ props = style.find(q("style", "graphic-properties"))
160
+ assert props is not None
161
+ self.assertEqual(props.attrib.get(q("draw", "fill-color")), "#F4C542")
162
+ self.assertEqual(props.attrib.get(q("svg", "stroke-color")), "#7A5C00")
163
+
164
+ def test_text_overrides_produce_paragraph_and_text_styles(self) -> None:
165
+ with tempfile.TemporaryDirectory() as tmp:
166
+ content = member(make_drawing(Path(tmp)), "content.xml")
167
+ styled = next(el for el in content.iter(q("draw", "rect")) if el.attrib.get(q("draw", "name")) == "Styled")
168
+ paragraph = styled.find(q("text", "p"))
169
+ assert paragraph is not None
170
+ p_name = paragraph.attrib.get(q("text", "style-name"))
171
+ self.assertIsNotNone(p_name)
172
+ span = paragraph.find(q("text", "span"))
173
+ assert span is not None
174
+ t_name = span.attrib.get(q("text", "style-name"))
175
+ self.assertIsNotNone(t_name)
176
+ # The text style must carry the overridden colour.
177
+ auto = content.find(q("office", "automatic-styles"))
178
+ assert auto is not None
179
+ t_style = next(s for s in auto.findall(q("style", "style")) if s.attrib.get(q("style", "name")) == t_name)
180
+ tp = t_style.find(q("style", "text-properties"))
181
+ assert tp is not None
182
+ self.assertEqual(tp.attrib.get(q("fo", "color")), "#3A2E00")
183
+ self.assertEqual(tp.attrib.get(q("fo", "font-size")), "20pt")
184
+
185
+ def test_plain_shape_uses_role_style_directly(self) -> None:
186
+ with tempfile.TemporaryDirectory() as tmp:
187
+ content = member(make_drawing(Path(tmp)), "content.xml")
188
+ plain = next(el for el in content.iter(q("draw", "rect")) if el.attrib.get(q("draw", "name")) == "Plain")
189
+ self.assertEqual(plain.attrib.get(q("draw", "style-name")), "gr-shape")
190
+
191
+ def test_corner_radius_lands_on_the_rect(self) -> None:
192
+ with tempfile.TemporaryDirectory() as tmp:
193
+ tmp_path = Path(tmp)
194
+ spec = tmp_path / "s.json"
195
+ spec.write_text(
196
+ json.dumps(
197
+ {"pages": [{"name": "P", "items": [{"type": "rect", "name": "R", "corner-radius": "0.4cm"}]}]}
198
+ ),
199
+ encoding="utf-8",
200
+ )
201
+ odg = tmp_path / "s.odg"
202
+ run_script(ODG_SCRIPTS / "create_minimal_odg.py", spec, odg)
203
+ rect = next(member(odg, "content.xml").iter(q("draw", "rect")))
204
+ self.assertEqual(rect.attrib.get(q("draw", "corner-radius")), "0.4cm")
205
+
206
+
207
+ class InjectStylesTests(unittest.TestCase):
208
+ def test_inject_branded_styles_roundtrip(self) -> None:
209
+ with tempfile.TemporaryDirectory() as tmp:
210
+ tmp_path = Path(tmp)
211
+ odg = make_drawing(tmp_path)
212
+ sys.path.insert(0, str(ODG_SCRIPTS))
213
+ from odg_common import inject_styles_from_file
214
+
215
+ out = tmp_path / "branded.odg"
216
+ missing = inject_styles_from_file(odg, DIAGRAM_STYLES, out)
217
+ # The branded styles.xml redefines every role style, and per-shape
218
+ # P/T styles live in content.xml — so nothing should dangle.
219
+ self.assertEqual(missing, [])
220
+ styles = styles_named(member(out, "styles.xml"))
221
+ self.assertIn("standard", styles)
222
+ self.assertIn("gr-shape", styles)
223
+ with zipfile.ZipFile(out) as archive:
224
+ self.assertEqual(archive.namelist()[0], "mimetype")
225
+
226
+ def test_embed_pictures_adds_manifest_entry(self) -> None:
227
+ with tempfile.TemporaryDirectory() as tmp:
228
+ tmp_path = Path(tmp)
229
+ odg = make_drawing(tmp_path)
230
+ sys.path.insert(0, str(ODG_SCRIPTS))
231
+ from odg_common import embed_pictures
232
+
233
+ png = tmp_path / "logo.png"
234
+ png.write_bytes(
235
+ bytes.fromhex(
236
+ "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4"
237
+ "890000000a49444154789c6360000000020001e221bc330000000049454e44ae426082"
238
+ )
239
+ )
240
+ out = tmp_path / "withpic.odg"
241
+ embed_pictures(odg, {"Pictures/logo.png": png}, out)
242
+ with zipfile.ZipFile(out) as archive:
243
+ self.assertIn("Pictures/logo.png", archive.namelist())
244
+ manifest = ET.fromstring(archive.read("META-INF/manifest.xml"))
245
+ paths = {e.attrib.get("{urn:oasis:names:tc:opendocument:xmlns:manifest:1.0}full-path") for e in manifest}
246
+ self.assertIn("Pictures/logo.png", paths)
247
+
248
+
249
+ if __name__ == "__main__":
250
+ unittest.main()
@@ -63,7 +63,16 @@ class MasterPageTests(unittest.TestCase):
63
63
  master = next(
64
64
  m for m in styles.iter(q("style", "master-page")) if m.attrib.get(q("style", "name")) == "Default"
65
65
  )
66
- props = master.find(q("style", "drawing-page-properties"))
66
+ # The background lives in the drawing-page style the master
67
+ # references via draw:style-name — not on the master element.
68
+ dp_name = master.attrib.get(q("draw", "style-name"))
69
+ self.assertIsNotNone(dp_name)
70
+ dp_style = next(
71
+ s
72
+ for s in styles.iter(q("style", "style"))
73
+ if s.attrib.get(q("style", "name")) == dp_name and s.attrib.get(q("style", "family")) == "drawing-page"
74
+ )
75
+ props = dp_style.find(q("style", "drawing-page-properties"))
67
76
  assert props is not None
68
77
  self.assertEqual(props.attrib.get(q("draw", "fill")), "solid")
69
78
  self.assertEqual(props.attrib.get(q("draw", "fill-color")), "#02416C")
@@ -0,0 +1,186 @@
1
+ """Tests for ODP presentation styling — drawing-page background, graphic
2
+ frame styles (no blue boxes), text colours, and the styles.xml inject path."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
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 ROOT, SKILLS, run_script
14
+
15
+ NS = {
16
+ "office": "urn:oasis:names:tc:opendocument:xmlns:office:1.0",
17
+ "style": "urn:oasis:names:tc:opendocument:xmlns:style:1.0",
18
+ "draw": "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0",
19
+ "fo": "urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0",
20
+ "text": "urn:oasis:names:tc:opendocument:xmlns:text:1.0",
21
+ }
22
+
23
+ ODP_SCRIPTS = SKILLS / "odp" / "scripts"
24
+ DECK_STYLES = ROOT / "examples" / "deck" / "styles.xml"
25
+
26
+
27
+ def q(prefix: str, local: str) -> str:
28
+ return f"{{{NS[prefix]}}}{local}"
29
+
30
+
31
+ def member(path: Path, name: str) -> ET.Element:
32
+ with zipfile.ZipFile(path) as archive:
33
+ return ET.fromstring(archive.read(name))
34
+
35
+
36
+ def make_deck(tmp_path: Path) -> Path:
37
+ spec = tmp_path / "deck.json"
38
+ spec.write_text(
39
+ json.dumps(
40
+ {
41
+ "title": "Styling test",
42
+ "slides": [
43
+ {"name": "A", "title": "Hello", "body": ["one", "two"]},
44
+ {"name": "B", "title": "World", "body": "single"},
45
+ ],
46
+ }
47
+ ),
48
+ encoding="utf-8",
49
+ )
50
+ odp = tmp_path / "deck.odp"
51
+ run_script(ODP_SCRIPTS / "create_minimal_odp.py", spec, odp)
52
+ return odp
53
+
54
+
55
+ def graphic_styles(styles: ET.Element) -> dict[str, ET.Element]:
56
+ out: dict[str, ET.Element] = {}
57
+ for st in styles.iter(q("style", "style")):
58
+ if st.attrib.get(q("style", "family")) == "graphic":
59
+ name = st.attrib.get(q("style", "name"))
60
+ if name:
61
+ out[name] = st
62
+ return out
63
+
64
+
65
+ class GeneratedStylingTests(unittest.TestCase):
66
+ def test_frames_reference_a_graphic_style(self) -> None:
67
+ with tempfile.TemporaryDirectory() as tmp:
68
+ odp = make_deck(Path(tmp))
69
+ content = member(odp, "content.xml")
70
+ frames = list(content.iter(q("draw", "frame")))
71
+ self.assertGreater(len(frames), 0)
72
+ for frame in frames:
73
+ self.assertIsNotNone(
74
+ frame.attrib.get(q("draw", "style-name")),
75
+ "every generated frame must carry a draw:style-name",
76
+ )
77
+
78
+ def test_graphic_styles_suppress_default_fill(self) -> None:
79
+ with tempfile.TemporaryDirectory() as tmp:
80
+ odp = make_deck(Path(tmp))
81
+ styles = member(odp, "styles.xml")
82
+ grs = graphic_styles(styles)
83
+ for name in ("gr-title", "gr-body", "gr-notes", "gr-image"):
84
+ self.assertIn(name, grs, f"missing graphic style {name}")
85
+ props = grs[name].find(q("style", "graphic-properties"))
86
+ assert props is not None
87
+ self.assertEqual(props.attrib.get(q("draw", "fill")), "none")
88
+ self.assertEqual(props.attrib.get(q("draw", "stroke")), "none")
89
+
90
+ def test_drawing_page_background_style_is_referenced_by_master(self) -> None:
91
+ with tempfile.TemporaryDirectory() as tmp:
92
+ odp = make_deck(Path(tmp))
93
+ styles = member(odp, "styles.xml")
94
+ master = next(
95
+ m for m in styles.iter(q("style", "master-page")) if m.attrib.get(q("style", "name")) == "Default"
96
+ )
97
+ dp_name = master.attrib.get(q("draw", "style-name"))
98
+ self.assertIsNotNone(dp_name, "master page must reference a drawing-page style")
99
+ dp_style = next(
100
+ s
101
+ for s in styles.iter(q("style", "style"))
102
+ if s.attrib.get(q("style", "name")) == dp_name and s.attrib.get(q("style", "family")) == "drawing-page"
103
+ )
104
+ props = dp_style.find(q("style", "drawing-page-properties"))
105
+ assert props is not None
106
+ self.assertEqual(props.attrib.get(q("draw", "fill")), "solid")
107
+ self.assertIsNotNone(props.attrib.get(q("draw", "fill-color")))
108
+
109
+ def test_paragraph_styles_carry_explicit_colour(self) -> None:
110
+ with tempfile.TemporaryDirectory() as tmp:
111
+ odp = make_deck(Path(tmp))
112
+ styles = member(odp, "styles.xml")
113
+ for name in ("Title", "Body", "Notes"):
114
+ style = next(
115
+ s
116
+ for s in styles.iter(q("style", "style"))
117
+ if s.attrib.get(q("style", "name")) == name and s.attrib.get(q("style", "family")) == "paragraph"
118
+ )
119
+ tp = style.find(q("style", "text-properties"))
120
+ assert tp is not None
121
+ self.assertIsNotNone(tp.attrib.get(q("fo", "color")), f"{name} must set fo:color")
122
+
123
+
124
+ class InjectStylesTests(unittest.TestCase):
125
+ def test_inject_branded_styles_roundtrip(self) -> None:
126
+ with tempfile.TemporaryDirectory() as tmp:
127
+ tmp_path = Path(tmp)
128
+ odp = make_deck(tmp_path)
129
+
130
+ import sys
131
+
132
+ sys.path.insert(0, str(ODP_SCRIPTS))
133
+ from odp_common import inject_styles_from_file
134
+
135
+ out = tmp_path / "branded.odp"
136
+ missing = inject_styles_from_file(odp, DECK_STYLES, out)
137
+ # The branded styles.xml redefines every named style the
138
+ # generator emits, so no content reference should dangle.
139
+ self.assertEqual(missing, [])
140
+
141
+ styles = member(out, "styles.xml")
142
+ dp = next(s for s in styles.iter(q("style", "style")) if s.attrib.get(q("style", "name")) == "dp-default")
143
+ props = dp.find(q("style", "drawing-page-properties"))
144
+ assert props is not None
145
+ self.assertEqual(props.attrib.get(q("draw", "fill-color")), "#02416C")
146
+ # mimetype must stay the first, stored entry.
147
+ with zipfile.ZipFile(out) as archive:
148
+ self.assertEqual(archive.namelist()[0], "mimetype")
149
+
150
+
151
+ class CustomizeMasterBackgroundTests(unittest.TestCase):
152
+ def test_background_color_lands_in_drawing_page_style(self) -> None:
153
+ with tempfile.TemporaryDirectory() as tmp:
154
+ tmp_path = Path(tmp)
155
+ odp = make_deck(tmp_path)
156
+ out = tmp_path / "bg.odp"
157
+ run_script(
158
+ ODP_SCRIPTS / "customize_master.py",
159
+ odp,
160
+ "--master",
161
+ "Default",
162
+ "--background-color",
163
+ "#123456",
164
+ "-o",
165
+ out,
166
+ )
167
+ styles = member(out, "styles.xml")
168
+ master = next(
169
+ m for m in styles.iter(q("style", "master-page")) if m.attrib.get(q("style", "name")) == "Default"
170
+ )
171
+ dp_name = master.attrib.get(q("draw", "style-name"))
172
+ dp_style = next(
173
+ s
174
+ for s in styles.iter(q("style", "style"))
175
+ if s.attrib.get(q("style", "name")) == dp_name and s.attrib.get(q("style", "family")) == "drawing-page"
176
+ )
177
+ props = dp_style.find(q("style", "drawing-page-properties"))
178
+ assert props is not None
179
+ self.assertEqual(props.attrib.get(q("draw", "fill-color")), "#123456")
180
+ # The customized background must not be written onto the master
181
+ # element itself — that variant does not render.
182
+ self.assertIsNone(master.find(q("style", "drawing-page-properties")))
183
+
184
+
185
+ if __name__ == "__main__":
186
+ unittest.main()