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.
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/PKG-INFO +1 -1
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/README.md +15 -153
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/odf_lib/odf_common.py +9 -2
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/open_document_lib.egg-info/PKG-INFO +1 -1
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/open_document_lib.egg-info/SOURCES.txt +2 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/pyproject.toml +1 -1
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_libreoffice_integration.py +47 -1
- open_document_lib-1.2.0/tests/test_odg_styling.py +250 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_odp_master.py +10 -1
- open_document_lib-1.2.0/tests/test_odp_styling.py +186 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/LICENSE +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/docs/library-api.md +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/odf_lib/__init__.py +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/odf_lib/citation_mapping.py +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/odf_lib/py.typed +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/open_document_lib.egg-info/dependency_links.txt +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/open_document_lib.egg-info/requires.txt +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/open_document_lib.egg-info/top_level.txt +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/setup.cfg +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_benchmarks.py +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_citations.py +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_corpus.py +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_cross_refs.py +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_dao_template.py +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_docs.py +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_edge_cases.py +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_examples.py +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_flat_odf.py +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_footnotes.py +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_install.py +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_lib_odf_common.py +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_math.py +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_meta_lifecycle.py +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_odg_connectors.py +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_odg_gluepoints.py +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_odg_groups.py +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_odp_animations.py +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_odp_transitions.py +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_ods_charts.py +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_ods_named_ranges.py +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_ods_validation.py +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_property.py +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_schema_validation.py +0 -0
- {open_document_lib-1.0.2 → open_document_lib-1.2.0}/tests/test_smoke.py +0 -0
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
# Open Document Skills
|
|
2
2
|
|
|
3
3
|
[](https://github.com/leiverkus/open-document-skills/actions/workflows/tests.yml)
|
|
4
|
+
[](https://pypi.org/project/open-document-lib/)
|
|
5
|
+
[](https://pypi.org/project/open-document-lib/)
|
|
4
6
|
[](LICENSE)
|
|
5
|
-
[](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.**
|
|
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
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
@@ -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
|
|
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
|
-
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{open_document_lib-1.0.2 → open_document_lib-1.2.0}/open_document_lib.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
{open_document_lib-1.0.2 → open_document_lib-1.2.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
|