open-document-lib 1.2.0__tar.gz → 1.3.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.2.0/open_document_lib.egg-info → open_document_lib-1.3.0}/PKG-INFO +1 -1
  2. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/README.md +2 -2
  3. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/odf_lib/odf_common.py +1 -1
  4. {open_document_lib-1.2.0 → open_document_lib-1.3.0/open_document_lib.egg-info}/PKG-INFO +1 -1
  5. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/open_document_lib.egg-info/SOURCES.txt +1 -0
  6. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/pyproject.toml +1 -1
  7. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/tests/test_libreoffice_integration.py +20 -0
  8. open_document_lib-1.3.0/tests/test_markdown_to_odt.py +262 -0
  9. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/LICENSE +0 -0
  10. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/docs/library-api.md +0 -0
  11. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/odf_lib/__init__.py +0 -0
  12. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/odf_lib/citation_mapping.py +0 -0
  13. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/odf_lib/py.typed +0 -0
  14. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/open_document_lib.egg-info/dependency_links.txt +0 -0
  15. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/open_document_lib.egg-info/requires.txt +0 -0
  16. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/open_document_lib.egg-info/top_level.txt +0 -0
  17. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/setup.cfg +0 -0
  18. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/tests/test_benchmarks.py +0 -0
  19. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/tests/test_citations.py +0 -0
  20. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/tests/test_corpus.py +0 -0
  21. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/tests/test_cross_refs.py +0 -0
  22. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/tests/test_dao_template.py +0 -0
  23. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/tests/test_docs.py +0 -0
  24. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/tests/test_edge_cases.py +0 -0
  25. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/tests/test_examples.py +0 -0
  26. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/tests/test_flat_odf.py +0 -0
  27. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/tests/test_footnotes.py +0 -0
  28. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/tests/test_install.py +0 -0
  29. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/tests/test_lib_odf_common.py +0 -0
  30. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/tests/test_math.py +0 -0
  31. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/tests/test_meta_lifecycle.py +0 -0
  32. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/tests/test_odg_connectors.py +0 -0
  33. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/tests/test_odg_gluepoints.py +0 -0
  34. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/tests/test_odg_groups.py +0 -0
  35. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/tests/test_odg_styling.py +0 -0
  36. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/tests/test_odp_animations.py +0 -0
  37. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/tests/test_odp_master.py +0 -0
  38. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/tests/test_odp_styling.py +0 -0
  39. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/tests/test_odp_transitions.py +0 -0
  40. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/tests/test_ods_charts.py +0 -0
  41. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/tests/test_ods_named_ranges.py +0 -0
  42. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/tests/test_ods_validation.py +0 -0
  43. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/tests/test_property.py +0 -0
  44. {open_document_lib-1.2.0 → open_document_lib-1.3.0}/tests/test_schema_validation.py +0 -0
  45. {open_document_lib-1.2.0 → open_document_lib-1.3.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.2.0
3
+ Version: 1.3.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
@@ -17,7 +17,7 @@ Four self-contained skills for Codex, Claude Code, and OpenCode that teach an ag
17
17
 
18
18
  ```bash
19
19
  # Generate, edit, validate, version — all from the agent shell:
20
- python skills/odt/scripts/create_minimal_odt.py spec.json doc.odt
20
+ python skills/odt/scripts/create_from_markdown.py article.md doc.odt # rich text from Markdown
21
21
  python skills/odt/scripts/replace_text.py doc.odt "{{NAME}}" "Patrick" -o out.odt
22
22
  python skills/odt/scripts/pack_fodt.py out.odt -o out.fodt # diff-friendly XML
23
23
  python skills/odt/scripts/validate_refs.py out.odt
@@ -27,7 +27,7 @@ python skills/odt/scripts/validate_refs.py out.odt
27
27
 
28
28
  | Skill | LibreOffice app | Smithery | Triggers |
29
29
  | --- | --- | --- | --- |
30
- | [`odt`](skills/odt) | Writer | [smithery.ai/skills/leiverkus/odt](https://smithery.ai/skills/leiverkus/odt) | edit ODT, footnotes, citations (BibTeX/CSL-JSON), bookmarks, cross-references, figure/table sequences, MathML formulas, render to PDF |
30
+ | [`odt`](skills/odt) | Writer | [smithery.ai/skills/leiverkus/odt](https://smithery.ai/skills/leiverkus/odt) | author from Markdown, edit ODT, footnotes, citations (BibTeX/CSL-JSON), bookmarks, cross-references, figure/table sequences, MathML formulas, render to PDF |
31
31
  | [`odp`](skills/odp) | Impress | [smithery.ai/skills/leiverkus/odp](https://smithery.ai/skills/leiverkus/odp) | clone slide, edit notes, add image, animations (entrance/exit/emphasis/motion), slide transitions, master-page customization (background, header/footer, logo), render deck |
32
32
  | [`ods`](skills/ods) | Calc | [smithery.ai/skills/leiverkus/ods](https://smithery.ai/skills/leiverkus/ods) | set cells/formulas, named ranges, dropdowns + data validation, embedded charts (bar/line/pie/scatter), export CSV, recalculate |
33
33
  | [`odg`](skills/odg) | Draw | [smithery.ai/skills/leiverkus/odg](https://smithery.ai/skills/leiverkus/odg) | edit labels, add shape image, glue points, connectors with shape binding, groups, flowcharts, org charts, export SVG/PNG |
@@ -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.2.0" # keep in sync with pyproject.toml (see CONTRIBUTING.md)
22
+ VERSION = "1.3.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",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: open-document-lib
3
- Version: 1.2.0
3
+ Version: 1.3.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
@@ -24,6 +24,7 @@ tests/test_footnotes.py
24
24
  tests/test_install.py
25
25
  tests/test_lib_odf_common.py
26
26
  tests/test_libreoffice_integration.py
27
+ tests/test_markdown_to_odt.py
27
28
  tests/test_math.py
28
29
  tests/test_meta_lifecycle.py
29
30
  tests/test_odg_connectors.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "open-document-lib"
7
- version = "1.2.0"
7
+ version = "1.3.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"
@@ -88,6 +88,26 @@ class LibreOfficeIntegrationTests(unittest.TestCase):
88
88
  self.assertTrue(pdf.exists())
89
89
  self.assertGreater(pdf.stat().st_size, 0)
90
90
 
91
+ def test_markdown_to_odt_renders_to_pdf(self) -> None:
92
+ """An ODT built from Markdown must render to a non-empty PDF."""
93
+ with tempfile.TemporaryDirectory() as tmp:
94
+ tmp_path = Path(tmp)
95
+ scripts = SKILLS / "odt" / "scripts"
96
+ src = tmp_path / "doc.md"
97
+ src.write_text(
98
+ "# Title\n\nText with **bold** and a [link](https://x.io).\n\n"
99
+ "- one\n- two\n\n| A | B |\n|---|---|\n| 1 | 2 |\n",
100
+ encoding="utf-8",
101
+ )
102
+ odt = tmp_path / "doc.odt"
103
+ run_script(scripts / "create_from_markdown.py", src, odt)
104
+ run_script(scripts / "validate_refs.py", odt, "--strict")
105
+ outdir = tmp_path / "qa"
106
+ run_script(scripts / "render.py", odt, "--outdir", outdir)
107
+ pdf = outdir / "doc.pdf"
108
+ self.assertTrue(pdf.exists())
109
+ self.assertGreater(pdf.stat().st_size, 0)
110
+
91
111
  def test_branded_odg_diagram_renders_to_pdf(self) -> None:
92
112
  """A base ODG with the branded diagram styles.xml injected must render
93
113
  to a non-empty PDF."""
@@ -0,0 +1,262 @@
1
+ """Tests for the Markdown → ODT authoring path — the stdlib parser and the
2
+ inline rich-text emitter (text:span, text:a, footnotes, tables, images)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import sys
7
+ import tempfile
8
+ import unittest
9
+ import zipfile
10
+ from pathlib import Path
11
+ from xml.etree import ElementTree as ET
12
+
13
+ from helpers import SKILLS, run_script
14
+
15
+ ODT_SCRIPTS = SKILLS / "odt" / "scripts"
16
+ sys.path.insert(0, str(ODT_SCRIPTS))
17
+
18
+ import md_parser as md # noqa: E402
19
+
20
+ NS = {
21
+ "office": "urn:oasis:names:tc:opendocument:xmlns:office:1.0",
22
+ "text": "urn:oasis:names:tc:opendocument:xmlns:text:1.0",
23
+ "table": "urn:oasis:names:tc:opendocument:xmlns:table:1.0",
24
+ "draw": "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0",
25
+ "xlink": "http://www.w3.org/1999/xlink",
26
+ "style": "urn:oasis:names:tc:opendocument:xmlns:style:1.0",
27
+ }
28
+
29
+
30
+ def q(prefix: str, local: str) -> str:
31
+ return f"{{{NS[prefix]}}}{local}"
32
+
33
+
34
+ def member(path: Path, name: str) -> ET.Element:
35
+ with zipfile.ZipFile(path) as archive:
36
+ return ET.fromstring(archive.read(name))
37
+
38
+
39
+ # --------------------------------------------------------------------------
40
+ # Parser unit tests
41
+ # --------------------------------------------------------------------------
42
+
43
+
44
+ class ParserBlockTests(unittest.TestCase):
45
+ def test_headings_all_levels(self) -> None:
46
+ doc = md.parse("\n\n".join(f"{'#' * n} Level {n}" for n in range(1, 7)))
47
+ self.assertEqual([type(b).__name__ for b in doc.children], ["Heading"] * 6)
48
+ self.assertEqual([b.level for b in doc.children], [1, 2, 3, 4, 5, 6])
49
+
50
+ def test_nested_bullet_list(self) -> None:
51
+ doc = md.parse("- a\n- b\n - b1\n - b2\n- c")
52
+ lst = doc.children[0]
53
+ self.assertIsInstance(lst, md.ListNode)
54
+ self.assertFalse(lst.ordered)
55
+ self.assertEqual(len(lst.items), 3)
56
+ nested = [b for b in lst.items[1].children if isinstance(b, md.ListNode)]
57
+ self.assertEqual(len(nested), 1)
58
+ self.assertEqual(len(nested[0].items), 2)
59
+
60
+ def test_ordered_list_start(self) -> None:
61
+ doc = md.parse("3. three\n4. four")
62
+ lst = doc.children[0]
63
+ self.assertIsInstance(lst, md.ListNode)
64
+ self.assertTrue(lst.ordered)
65
+ self.assertEqual(lst.start, 3)
66
+
67
+ def test_fenced_code_block(self) -> None:
68
+ doc = md.parse("```python\nx = 1\ny = 2\n```")
69
+ block = doc.children[0]
70
+ self.assertIsInstance(block, md.CodeBlock)
71
+ self.assertEqual(block.language, "python")
72
+ self.assertEqual(block.text, "x = 1\ny = 2")
73
+
74
+ def test_blockquote(self) -> None:
75
+ doc = md.parse("> quoted line one\n> quoted line two")
76
+ quote = doc.children[0]
77
+ self.assertIsInstance(quote, md.BlockQuote)
78
+ self.assertIsInstance(quote.children[0], md.Paragraph)
79
+
80
+ def test_thematic_break(self) -> None:
81
+ doc = md.parse("text\n\n---\n\nmore")
82
+ self.assertIsInstance(doc.children[1], md.ThematicBreak)
83
+
84
+ def test_gfm_table_with_alignment(self) -> None:
85
+ doc = md.parse("| A | B | C |\n|:--|:-:|--:|\n| 1 | 2 | 3 |")
86
+ table = doc.children[0]
87
+ self.assertIsInstance(table, md.Table)
88
+ self.assertEqual(table.alignments, ["left", "center", "right"])
89
+ self.assertEqual(len(table.header), 3)
90
+ self.assertEqual(len(table.rows), 1)
91
+
92
+ def test_block_image(self) -> None:
93
+ doc = md.parse("![alt text](pic.png)")
94
+ image = doc.children[0]
95
+ self.assertIsInstance(image, md.BlockImage)
96
+ self.assertEqual(image.src, "pic.png")
97
+
98
+
99
+ class ParserInlineTests(unittest.TestCase):
100
+ def _inline(self, text: str) -> list[md.Inline]:
101
+ doc = md.parse(text)
102
+ para = doc.children[0]
103
+ assert isinstance(para, md.Paragraph)
104
+ return para.children
105
+
106
+ def test_bold_italic_code(self) -> None:
107
+ nodes = self._inline("a **bold** and *italic* and `code` end")
108
+ kinds = [type(n).__name__ for n in nodes]
109
+ self.assertIn("Strong", kinds)
110
+ self.assertIn("Emphasis", kinds)
111
+ self.assertIn("Code", kinds)
112
+
113
+ def test_bold_italic_combined(self) -> None:
114
+ nodes = self._inline("***both***")
115
+ self.assertEqual(len(nodes), 1)
116
+ self.assertIsInstance(nodes[0], md.Strong)
117
+ self.assertIsInstance(nodes[0].children[0], md.Emphasis)
118
+
119
+ def test_inline_link(self) -> None:
120
+ nodes = self._inline("see [the docs](https://example.org) now")
121
+ link = next(n for n in nodes if isinstance(n, md.Link))
122
+ self.assertEqual(link.href, "https://example.org")
123
+
124
+ def test_reference_link(self) -> None:
125
+ nodes = self._inline("see [the docs][ref] now\n\n[ref]: https://ref.example")
126
+ link = next(n for n in nodes if isinstance(n, md.Link))
127
+ self.assertEqual(link.href, "https://ref.example")
128
+
129
+ def test_footnote_ref_and_def(self) -> None:
130
+ doc = md.parse("text with a note.[^x]\n\n[^x]: the note body")
131
+ para = doc.children[0]
132
+ assert isinstance(para, md.Paragraph)
133
+ self.assertTrue(any(isinstance(n, md.FootnoteRef) for n in para.children))
134
+ self.assertIn("x", doc.footnotes)
135
+
136
+ def test_underscore_in_word_is_literal(self) -> None:
137
+ nodes = self._inline("a snake_case_name here")
138
+ self.assertEqual(len(nodes), 1)
139
+ self.assertIsInstance(nodes[0], md.Text)
140
+
141
+ def test_backslash_escape(self) -> None:
142
+ nodes = self._inline(r"not \*emphasised\* here")
143
+ self.assertEqual(len(nodes), 1)
144
+ self.assertIsInstance(nodes[0], md.Text)
145
+ self.assertEqual(nodes[0].value, "not *emphasised* here")
146
+
147
+
148
+ # --------------------------------------------------------------------------
149
+ # End-to-end MD → ODT
150
+ # --------------------------------------------------------------------------
151
+
152
+ SAMPLE_MD = """# Report Title
153
+
154
+ A paragraph with **bold**, *italic*, `code`, and a [link](https://example.org).
155
+ A footnote follows.[^a]
156
+
157
+ ## Section Two
158
+
159
+ - one
160
+ - two
161
+ - nested
162
+
163
+ 1. first
164
+ 2. second
165
+
166
+ > a quoted line
167
+
168
+ ```python
169
+ print("hi")
170
+ ```
171
+
172
+ | Name | Score |
173
+ |:-----|------:|
174
+ | Alice | 42 |
175
+
176
+ ---
177
+
178
+ Done.
179
+
180
+ [^a]: The footnote text.
181
+ """
182
+
183
+
184
+ class EndToEndTests(unittest.TestCase):
185
+ def _build(self, tmp: Path, markdown: str = SAMPLE_MD) -> Path:
186
+ src = tmp / "doc.md"
187
+ src.write_text(markdown, encoding="utf-8")
188
+ odt = tmp / "doc.odt"
189
+ run_script(ODT_SCRIPTS / "create_from_markdown.py", src, odt)
190
+ return odt
191
+
192
+ def test_inline_runs_become_spans(self) -> None:
193
+ with tempfile.TemporaryDirectory() as tmp:
194
+ content = member(self._build(Path(tmp)), "content.xml")
195
+ spans = list(content.iter(q("text", "span")))
196
+ styles = {s.attrib.get(q("text", "style-name")) for s in spans}
197
+ self.assertIn("Strong", styles)
198
+ self.assertIn("Emphasis", styles)
199
+ self.assertIn("Code", styles)
200
+
201
+ def test_link_becomes_text_a(self) -> None:
202
+ with tempfile.TemporaryDirectory() as tmp:
203
+ content = member(self._build(Path(tmp)), "content.xml")
204
+ anchor = next(content.iter(q("text", "a")))
205
+ self.assertEqual(anchor.attrib.get(q("xlink", "href")), "https://example.org")
206
+
207
+ def test_headings_carry_outline_levels(self) -> None:
208
+ with tempfile.TemporaryDirectory() as tmp:
209
+ content = member(self._build(Path(tmp)), "content.xml")
210
+ levels = sorted({h.attrib.get(q("text", "outline-level")) for h in content.iter(q("text", "h"))})
211
+ self.assertEqual(levels, ["1", "2"])
212
+
213
+ def test_lists_and_table_and_footnote(self) -> None:
214
+ with tempfile.TemporaryDirectory() as tmp:
215
+ content = member(self._build(Path(tmp)), "content.xml")
216
+ self.assertGreaterEqual(len(list(content.iter(q("text", "list")))), 2)
217
+ self.assertEqual(len(list(content.iter(q("table", "table")))), 1)
218
+ notes = list(content.iter(q("text", "note")))
219
+ self.assertEqual(len(notes), 1)
220
+ self.assertTrue(notes[0].attrib.get(q("text", "id")))
221
+
222
+ def test_code_block_uses_codeblock_style(self) -> None:
223
+ with tempfile.TemporaryDirectory() as tmp:
224
+ content = member(self._build(Path(tmp)), "content.xml")
225
+ code_paras = [
226
+ p for p in content.iter(q("text", "p")) if p.attrib.get(q("text", "style-name")) == "CodeBlock"
227
+ ]
228
+ self.assertGreater(len(code_paras), 0)
229
+
230
+ def test_title_from_first_heading(self) -> None:
231
+ with tempfile.TemporaryDirectory() as tmp:
232
+ meta = member(self._build(Path(tmp)), "meta.xml")
233
+ titles = [t.text for t in meta.iter("{http://purl.org/dc/elements/1.1/}title")]
234
+ self.assertEqual(titles, ["Report Title"])
235
+
236
+ def test_embedded_image(self) -> None:
237
+ with tempfile.TemporaryDirectory() as tmp:
238
+ tmp_path = Path(tmp)
239
+ png = tmp_path / "p.png"
240
+ png.write_bytes(
241
+ bytes.fromhex(
242
+ "89504e470d0a1a0a0000000d49484452000000040000000408060000"
243
+ "00b6f8b4570000000a49444154789c63600000000200013fd7e2d6"
244
+ "0000000049454e44ae426082"
245
+ )
246
+ )
247
+ odt = self._build(tmp_path, f"# Pic\n\n![logo]({png})\n")
248
+ with zipfile.ZipFile(odt) as archive:
249
+ pictures = [n for n in archive.namelist() if n.startswith("Pictures/")]
250
+ self.assertEqual(len(pictures), 1)
251
+ content = member(odt, "content.xml")
252
+ self.assertEqual(len(list(content.iter(q("draw", "image")))), 1)
253
+
254
+ def test_output_passes_strict_validation(self) -> None:
255
+ with tempfile.TemporaryDirectory() as tmp:
256
+ odt = self._build(Path(tmp))
257
+ result = run_script(ODT_SCRIPTS / "validate_refs.py", odt, "--strict", check=False)
258
+ self.assertIn('"status": "ok"', result.stdout)
259
+
260
+
261
+ if __name__ == "__main__":
262
+ unittest.main()