make-drawio-png 0.1.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.
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .pytest_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shoichiro Sakaigawa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,247 @@
1
+ Metadata-Version: 2.5
2
+ Name: make-drawio-png
3
+ Version: 0.1.0
4
+ Summary: Convert .drawio XML files into re-editable .drawio.png — zero external dependencies
5
+ Project-URL: Homepage, https://github.com/phssakaigawa/make-drawio-png
6
+ Project-URL: Repository, https://github.com/phssakaigawa/make-drawio-png
7
+ Project-URL: Bug Tracker, https://github.com/phssakaigawa/make-drawio-png/issues
8
+ Project-URL: Changelog, https://github.com/phssakaigawa/make-drawio-png/releases
9
+ Author: Shoichiro Sakaigawa
10
+ License: MIT License
11
+
12
+ Copyright (c) 2026 Shoichiro Sakaigawa
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy
15
+ of this software and associated documentation files (the "Software"), to deal
16
+ in the Software without restriction, including without limitation the rights
17
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
+ copies of the Software, and to permit persons to whom the Software is
19
+ furnished to do so, subject to the following conditions:
20
+
21
+ The above copyright notice and this permission notice shall be included in all
22
+ copies or substantial portions of the Software.
23
+
24
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30
+ SOFTWARE.
31
+ License-File: LICENSE
32
+ Keywords: ai-agent,diagram-as-code,diagrams,drawio,png
33
+ Classifier: Development Status :: 4 - Beta
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Programming Language :: Python :: 3.9
38
+ Classifier: Programming Language :: Python :: 3.10
39
+ Classifier: Programming Language :: Python :: 3.11
40
+ Classifier: Programming Language :: Python :: 3.12
41
+ Classifier: Programming Language :: Python :: 3.13
42
+ Classifier: Topic :: Multimedia :: Graphics
43
+ Classifier: Topic :: Utilities
44
+ Requires-Python: >=3.9
45
+ Provides-Extra: dev
46
+ Requires-Dist: pytest>=7; extra == 'dev'
47
+ Description-Content-Type: text/markdown
48
+
49
+ # make-drawio-png
50
+
51
+ [![PyPI version](https://img.shields.io/pypi/v/make-drawio-png)](https://pypi.org/project/make-drawio-png/)
52
+ [![Python](https://img.shields.io/pypi/pyversions/make-drawio-png)](https://pypi.org/project/make-drawio-png/)
53
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/phssakaigawa/make-drawio-png/blob/main/LICENSE)
54
+ [![CI](https://github.com/phssakaigawa/make-drawio-png/actions/workflows/ci.yml/badge.svg)](https://github.com/phssakaigawa/make-drawio-png/actions/workflows/ci.yml)
55
+
56
+ **[日本語版 README はこちら → README.ja.md](https://github.com/phssakaigawa/make-drawio-png/blob/main/README.ja.md)**
57
+
58
+ A Python library that implements the **`.drawio.png` file format** — the
59
+ re-editable PNG format used by [draw.io / diagrams.net](https://www.diagrams.net/).
60
+
61
+ With this library you can **generate `.drawio.png` files programmatically**,
62
+ without opening draw.io at all. The generated files are indistinguishable
63
+ from those exported by draw.io itself.
64
+
65
+ ### What is the `.drawio.png` format?
66
+
67
+ A `.drawio.png` file is simultaneously:
68
+ - a **standard PNG image** — viewable in any image viewer, browser, or AI agent
69
+ - a **fully editable draw.io source** — open it in draw.io to restore and edit the diagram
70
+
71
+ This is achieved by embedding the diagram XML inside a PNG `tEXt` chunk
72
+ (`keyword=mxfile`, `value=URL-encoded UTF-8 XML`), following the
73
+ [draw.io open-source specification](https://github.com/jgraph/drawio).
74
+ draw.io reads that chunk to restore the diagram; image viewers see only the PNG pixel data.
75
+
76
+ ### Use cases
77
+
78
+ - **AI agents / Bob**: generate diagrams as code, embed them in Markdown docs, preview as PNG
79
+ - **CI/CD pipelines**: produce architecture diagrams from `.drawio` XML without a GUI
80
+ - **Documentation automation**: keep diagrams as re-editable sources alongside rendered images
81
+
82
+ ---
83
+
84
+ ## Install
85
+
86
+ ```bash
87
+ pip install make-drawio-png
88
+ ```
89
+
90
+ > **Note for Windows users — read the PATH section below before installing.**
91
+
92
+ ---
93
+
94
+ ## Usage
95
+
96
+ ### CLI
97
+
98
+ ```bash
99
+ # embed only — re-editable but 1×1 placeholder image (no draw.io needed)
100
+ python -m make_drawio_png architecture.drawio
101
+
102
+ # render + embed — visually rendered AND re-editable (requires draw.io desktop)
103
+ python -m make_drawio_png --render architecture.drawio
104
+
105
+ # explicit output path
106
+ python -m make_drawio_png --render flow.drawio docs/images/flow.drawio.png
107
+ ```
108
+
109
+ > **Windows / Bob shell users:** Use `python -m make_drawio_png` instead of the
110
+ > `make-drawio-png` command — it works without any PATH changes. See the
111
+ > [PATH guide](#windows--python-path-guide) below.
112
+
113
+ ### Python API
114
+
115
+ ```python
116
+ from make_drawio_png import drawio_export, drawio_to_png
117
+
118
+ # Recommended: render + embed (requires draw.io desktop)
119
+ drawio_export("architecture.drawio")
120
+ # → architecture.drawio.png (rendered + re-editable)
121
+
122
+ # Zero dependencies: embed only (re-editable, 1×1 placeholder image)
123
+ drawio_to_png("architecture.drawio")
124
+ # → architecture.drawio.png (re-editable, no visual content)
125
+ ```
126
+
127
+ ### draw.io desktop (required for `--render` / `drawio_export`)
128
+
129
+ ```bash
130
+ winget install JGraph.Draw # Windows
131
+ brew install --cask drawio # macOS
132
+ snap install drawio # Linux
133
+ ```
134
+
135
+ The CLI is auto-detected after installation.
136
+ Set `DRAWIO_PATH` to override the location.
137
+
138
+ ---
139
+
140
+ ## Windows — Python PATH guide
141
+
142
+ On Windows, `pip install` places the `make-drawio-png` CLI script in a
143
+ `Scripts` directory that is **not always in PATH by default**.
144
+ The table below shows where scripts land depending on how you install:
145
+
146
+ | Install command | Scripts directory | In PATH by default? |
147
+ |---|---|---|
148
+ | `pip install make-drawio-png` (system-wide) | `%LOCALAPPDATA%\Python\pythoncore-X.Y-64\Scripts` | ❌ usually not |
149
+ | `pip install --user make-drawio-png` | `%APPDATA%\Python\PythonXYZ\Scripts` | ❌ usually not |
150
+ | `pip install make-drawio-png` (venv active) | `<venv>\Scripts` | ✅ while venv is active |
151
+
152
+ ### Checking your Scripts path
153
+
154
+ ```powershell
155
+ python -c "import sysconfig; print(sysconfig.get_path('scripts'))"
156
+ # e.g. C:\Users\you\AppData\Local\Python\pythoncore-3.14-64\Scripts
157
+
158
+ python -c "import sysconfig; print(sysconfig.get_path('scripts','nt_user'))"
159
+ # e.g. C:\Users\you\AppData\Roaming\Python\Python314\Scripts
160
+ ```
161
+
162
+ ### Adding Scripts to PATH (one-time setup)
163
+
164
+ ```powershell
165
+ # Append to your user PATH permanently
166
+ $scripts = python -c "import sysconfig; print(sysconfig.get_path('scripts'))"
167
+ [System.Environment]::SetEnvironmentVariable(
168
+ "PATH",
169
+ "$([System.Environment]::GetEnvironmentVariable('PATH','User'));$scripts",
170
+ "User"
171
+ )
172
+ # → restart your terminal / shell after this
173
+ ```
174
+
175
+ ### Workaround: use `python -m` instead of the CLI
176
+
177
+ If you don't want to touch PATH at all, call the module directly:
178
+
179
+ ```powershell
180
+ python -m make_drawio_png architecture.drawio
181
+ ```
182
+
183
+ This always works regardless of PATH because Python itself is on PATH.
184
+
185
+ ---
186
+
187
+ ## Bob / Claude Code shell
188
+
189
+ When using IBM Bob or Claude Code on Windows, the shell inherits the **same
190
+ PATH as the process that launched Bob/Claude** — there is no separate Python
191
+ environment.
192
+
193
+ | Scenario | `python3` resolves to | `make-drawio-png` CLI available? |
194
+ |---|---|---|
195
+ | Launched from a terminal where Python Scripts is in PATH | your Python installation | ✅ |
196
+ | Launched from Windows start menu / system tray | system PATH (Scripts likely missing) | ❌ use `python -m` |
197
+ | Launched inside an active venv | venv Python + Scripts | ✅ |
198
+
199
+ **Recommended practice for Bob/Claude Code users:**
200
+
201
+ Always invoke via `python -m` so the PATH question is irrelevant:
202
+
203
+ ```python
204
+ # In Bob's execute_command tool
205
+ python3 -m make_drawio_png "<path>.drawio"
206
+ ```
207
+
208
+ Or reference the script by its absolute path after installation:
209
+
210
+ ```python
211
+ import subprocess, sysconfig, pathlib
212
+ scripts = pathlib.Path(sysconfig.get_path("scripts"))
213
+ subprocess.run([scripts / "make-drawio-png", "architecture.drawio"])
214
+ ```
215
+
216
+ ---
217
+
218
+ ## How it works
219
+
220
+ ![How make-drawio-png works](https://raw.githubusercontent.com/phssakaigawa/make-drawio-png/main/docs/how-it-works.drawio.png)
221
+
222
+ The `.drawio.png` file is both a valid PNG image and a re-editable draw.io
223
+ source. draw.io reads the embedded `tEXt[mxfile]` chunk to restore the full
224
+ diagram; image viewers and AI agents see only the PNG pixel data.
225
+
226
+ ### PNG chunk layout
227
+
228
+ ```
229
+ PNG Signature (8 bytes)
230
+ IHDR chunk (25 bytes)
231
+ tEXt chunk (keyword="mxfile" \x00 URL-encoded-XML) ← diagram XML here
232
+ IDAT chunk (compressed pixel data)
233
+ IEND chunk
234
+ ```
235
+
236
+ ---
237
+
238
+ ## Requirements
239
+
240
+ - Python ≥ 3.9
241
+ - No external dependencies (uses only `struct`, `zlib`, `urllib.parse` from stdlib)
242
+
243
+ ---
244
+
245
+ ## License
246
+
247
+ MIT © 2026 Shoichiro Sakaigawa
@@ -0,0 +1,199 @@
1
+ # make-drawio-png
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/make-drawio-png)](https://pypi.org/project/make-drawio-png/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/make-drawio-png)](https://pypi.org/project/make-drawio-png/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/phssakaigawa/make-drawio-png/blob/main/LICENSE)
6
+ [![CI](https://github.com/phssakaigawa/make-drawio-png/actions/workflows/ci.yml/badge.svg)](https://github.com/phssakaigawa/make-drawio-png/actions/workflows/ci.yml)
7
+
8
+ **[日本語版 README はこちら → README.ja.md](https://github.com/phssakaigawa/make-drawio-png/blob/main/README.ja.md)**
9
+
10
+ A Python library that implements the **`.drawio.png` file format** — the
11
+ re-editable PNG format used by [draw.io / diagrams.net](https://www.diagrams.net/).
12
+
13
+ With this library you can **generate `.drawio.png` files programmatically**,
14
+ without opening draw.io at all. The generated files are indistinguishable
15
+ from those exported by draw.io itself.
16
+
17
+ ### What is the `.drawio.png` format?
18
+
19
+ A `.drawio.png` file is simultaneously:
20
+ - a **standard PNG image** — viewable in any image viewer, browser, or AI agent
21
+ - a **fully editable draw.io source** — open it in draw.io to restore and edit the diagram
22
+
23
+ This is achieved by embedding the diagram XML inside a PNG `tEXt` chunk
24
+ (`keyword=mxfile`, `value=URL-encoded UTF-8 XML`), following the
25
+ [draw.io open-source specification](https://github.com/jgraph/drawio).
26
+ draw.io reads that chunk to restore the diagram; image viewers see only the PNG pixel data.
27
+
28
+ ### Use cases
29
+
30
+ - **AI agents / Bob**: generate diagrams as code, embed them in Markdown docs, preview as PNG
31
+ - **CI/CD pipelines**: produce architecture diagrams from `.drawio` XML without a GUI
32
+ - **Documentation automation**: keep diagrams as re-editable sources alongside rendered images
33
+
34
+ ---
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install make-drawio-png
40
+ ```
41
+
42
+ > **Note for Windows users — read the PATH section below before installing.**
43
+
44
+ ---
45
+
46
+ ## Usage
47
+
48
+ ### CLI
49
+
50
+ ```bash
51
+ # embed only — re-editable but 1×1 placeholder image (no draw.io needed)
52
+ python -m make_drawio_png architecture.drawio
53
+
54
+ # render + embed — visually rendered AND re-editable (requires draw.io desktop)
55
+ python -m make_drawio_png --render architecture.drawio
56
+
57
+ # explicit output path
58
+ python -m make_drawio_png --render flow.drawio docs/images/flow.drawio.png
59
+ ```
60
+
61
+ > **Windows / Bob shell users:** Use `python -m make_drawio_png` instead of the
62
+ > `make-drawio-png` command — it works without any PATH changes. See the
63
+ > [PATH guide](#windows--python-path-guide) below.
64
+
65
+ ### Python API
66
+
67
+ ```python
68
+ from make_drawio_png import drawio_export, drawio_to_png
69
+
70
+ # Recommended: render + embed (requires draw.io desktop)
71
+ drawio_export("architecture.drawio")
72
+ # → architecture.drawio.png (rendered + re-editable)
73
+
74
+ # Zero dependencies: embed only (re-editable, 1×1 placeholder image)
75
+ drawio_to_png("architecture.drawio")
76
+ # → architecture.drawio.png (re-editable, no visual content)
77
+ ```
78
+
79
+ ### draw.io desktop (required for `--render` / `drawio_export`)
80
+
81
+ ```bash
82
+ winget install JGraph.Draw # Windows
83
+ brew install --cask drawio # macOS
84
+ snap install drawio # Linux
85
+ ```
86
+
87
+ The CLI is auto-detected after installation.
88
+ Set `DRAWIO_PATH` to override the location.
89
+
90
+ ---
91
+
92
+ ## Windows — Python PATH guide
93
+
94
+ On Windows, `pip install` places the `make-drawio-png` CLI script in a
95
+ `Scripts` directory that is **not always in PATH by default**.
96
+ The table below shows where scripts land depending on how you install:
97
+
98
+ | Install command | Scripts directory | In PATH by default? |
99
+ |---|---|---|
100
+ | `pip install make-drawio-png` (system-wide) | `%LOCALAPPDATA%\Python\pythoncore-X.Y-64\Scripts` | ❌ usually not |
101
+ | `pip install --user make-drawio-png` | `%APPDATA%\Python\PythonXYZ\Scripts` | ❌ usually not |
102
+ | `pip install make-drawio-png` (venv active) | `<venv>\Scripts` | ✅ while venv is active |
103
+
104
+ ### Checking your Scripts path
105
+
106
+ ```powershell
107
+ python -c "import sysconfig; print(sysconfig.get_path('scripts'))"
108
+ # e.g. C:\Users\you\AppData\Local\Python\pythoncore-3.14-64\Scripts
109
+
110
+ python -c "import sysconfig; print(sysconfig.get_path('scripts','nt_user'))"
111
+ # e.g. C:\Users\you\AppData\Roaming\Python\Python314\Scripts
112
+ ```
113
+
114
+ ### Adding Scripts to PATH (one-time setup)
115
+
116
+ ```powershell
117
+ # Append to your user PATH permanently
118
+ $scripts = python -c "import sysconfig; print(sysconfig.get_path('scripts'))"
119
+ [System.Environment]::SetEnvironmentVariable(
120
+ "PATH",
121
+ "$([System.Environment]::GetEnvironmentVariable('PATH','User'));$scripts",
122
+ "User"
123
+ )
124
+ # → restart your terminal / shell after this
125
+ ```
126
+
127
+ ### Workaround: use `python -m` instead of the CLI
128
+
129
+ If you don't want to touch PATH at all, call the module directly:
130
+
131
+ ```powershell
132
+ python -m make_drawio_png architecture.drawio
133
+ ```
134
+
135
+ This always works regardless of PATH because Python itself is on PATH.
136
+
137
+ ---
138
+
139
+ ## Bob / Claude Code shell
140
+
141
+ When using IBM Bob or Claude Code on Windows, the shell inherits the **same
142
+ PATH as the process that launched Bob/Claude** — there is no separate Python
143
+ environment.
144
+
145
+ | Scenario | `python3` resolves to | `make-drawio-png` CLI available? |
146
+ |---|---|---|
147
+ | Launched from a terminal where Python Scripts is in PATH | your Python installation | ✅ |
148
+ | Launched from Windows start menu / system tray | system PATH (Scripts likely missing) | ❌ use `python -m` |
149
+ | Launched inside an active venv | venv Python + Scripts | ✅ |
150
+
151
+ **Recommended practice for Bob/Claude Code users:**
152
+
153
+ Always invoke via `python -m` so the PATH question is irrelevant:
154
+
155
+ ```python
156
+ # In Bob's execute_command tool
157
+ python3 -m make_drawio_png "<path>.drawio"
158
+ ```
159
+
160
+ Or reference the script by its absolute path after installation:
161
+
162
+ ```python
163
+ import subprocess, sysconfig, pathlib
164
+ scripts = pathlib.Path(sysconfig.get_path("scripts"))
165
+ subprocess.run([scripts / "make-drawio-png", "architecture.drawio"])
166
+ ```
167
+
168
+ ---
169
+
170
+ ## How it works
171
+
172
+ ![How make-drawio-png works](https://raw.githubusercontent.com/phssakaigawa/make-drawio-png/main/docs/how-it-works.drawio.png)
173
+
174
+ The `.drawio.png` file is both a valid PNG image and a re-editable draw.io
175
+ source. draw.io reads the embedded `tEXt[mxfile]` chunk to restore the full
176
+ diagram; image viewers and AI agents see only the PNG pixel data.
177
+
178
+ ### PNG chunk layout
179
+
180
+ ```
181
+ PNG Signature (8 bytes)
182
+ IHDR chunk (25 bytes)
183
+ tEXt chunk (keyword="mxfile" \x00 URL-encoded-XML) ← diagram XML here
184
+ IDAT chunk (compressed pixel data)
185
+ IEND chunk
186
+ ```
187
+
188
+ ---
189
+
190
+ ## Requirements
191
+
192
+ - Python ≥ 3.9
193
+ - No external dependencies (uses only `struct`, `zlib`, `urllib.parse` from stdlib)
194
+
195
+ ---
196
+
197
+ ## License
198
+
199
+ MIT © 2026 Shoichiro Sakaigawa
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "make-drawio-png"
7
+ version = "0.1.0"
8
+ description = "Convert .drawio XML files into re-editable .drawio.png — zero external dependencies"
9
+ readme = "README.md"
10
+ license = { file = "LICENSE" }
11
+ authors = [{ name = "Shoichiro Sakaigawa" }]
12
+ keywords = ["drawio", "diagrams", "png", "diagram-as-code", "ai-agent"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.9",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Utilities",
24
+ "Topic :: Multimedia :: Graphics",
25
+ ]
26
+ requires-python = ">=3.9"
27
+ # No runtime dependencies — pure Python standard library only
28
+
29
+ [project.optional-dependencies]
30
+ dev = ["pytest>=7"]
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/phssakaigawa/make-drawio-png"
34
+ Repository = "https://github.com/phssakaigawa/make-drawio-png"
35
+ "Bug Tracker" = "https://github.com/phssakaigawa/make-drawio-png/issues"
36
+ Changelog = "https://github.com/phssakaigawa/make-drawio-png/releases"
37
+
38
+ [project.scripts]
39
+ make-drawio-png = "make_drawio_png.__main__:main"
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["src/make_drawio_png"]
43
+
44
+ [tool.hatch.build.targets.sdist]
45
+ include = [
46
+ "src/",
47
+ "tests/",
48
+ "README.md",
49
+ "LICENSE",
50
+ "pyproject.toml",
51
+ ]
@@ -0,0 +1,384 @@
1
+ """
2
+ make_drawio_png
3
+ ===============
4
+ Convert a .drawio XML file into a re-editable .drawio.png — zero external
5
+ dependencies, pure Python standard library.
6
+
7
+ The .drawio.png format embeds the diagram XML inside a PNG ``tEXt`` chunk
8
+ (keyword ``mxfile``, value URL-encoded UTF-8 XML). draw.io / diagrams.net
9
+ reads that chunk to restore the diagram, so the file is both a valid PNG
10
+ *and* a fully editable draw.io source.
11
+
12
+ Two rendering modes
13
+ -------------------
14
+ 1. **Embed only** (zero dependencies) — ``drawio_to_png()``
15
+ Produces a ``.drawio.png`` that is re-editable in draw.io but shows only a
16
+ 1×1 white pixel as a visual image. No external tool required.
17
+
18
+ 2. **Full render** (requires draw.io desktop) — ``drawio_render_png()``
19
+ Uses the draw.io CLI to export a fully-rendered PNG image.
20
+
21
+ 3. **Both at once** (recommended) — ``drawio_export()``
22
+ Calls the draw.io CLI to render, then embeds the XML into that PNG so the
23
+ result is *both* visually rich *and* re-editable.
24
+
25
+ Quick start
26
+ -----------
27
+ # CLI
28
+ make-drawio-png architecture.drawio # embed only
29
+ make-drawio-png --render architecture.drawio # full render + embed
30
+ python -m make_drawio_png --render flow.drawio
31
+
32
+ # Python API
33
+ from make_drawio_png import drawio_to_png, drawio_export
34
+ drawio_to_png("architecture.drawio") # embed only
35
+ drawio_export("architecture.drawio") # render + embed (recommended)
36
+
37
+ draw.io CLI installation
38
+ ------------------------
39
+ draw.io desktop bundles a headless CLI exporter. Install it once per machine:
40
+
41
+ Windows : winget install JGraph.Draw
42
+ macOS : brew install --cask drawio
43
+ Linux : snap install drawio
44
+ # or download .deb/.rpm from https://github.com/jgraph/drawio-desktop/releases
45
+
46
+ After installation the executable is auto-detected by this library.
47
+ You can also set the ``DRAWIO_PATH`` environment variable to point to a custom
48
+ location.
49
+ """
50
+
51
+ # SPDX-License-Identifier: MIT
52
+ # Copyright (c) 2026 Shoichiro Sakaigawa
53
+
54
+ from __future__ import annotations
55
+
56
+ import os
57
+ import shutil
58
+ import struct
59
+ import subprocess
60
+ import sys
61
+ import zlib
62
+ from pathlib import Path
63
+ from urllib.parse import quote
64
+
65
+ __version__ = "0.1.0"
66
+ __all__ = [
67
+ "drawio_to_png",
68
+ "drawio_render_png",
69
+ "drawio_export",
70
+ "find_drawio_cli",
71
+ "DrawioCLINotFoundError",
72
+ ]
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # draw.io CLI detection
77
+ # ---------------------------------------------------------------------------
78
+
79
+ # Per-platform candidate paths (in priority order)
80
+ _DRAWIO_CANDIDATES: dict[str, list[str]] = {
81
+ "win32": [
82
+ r"%LOCALAPPDATA%\Programs\draw.io\draw.io.exe",
83
+ r"C:\Program Files\draw.io\draw.io.exe",
84
+ r"C:\Program Files (x86)\draw.io\draw.io.exe",
85
+ ],
86
+ "darwin": [
87
+ "/Applications/draw.io.app/Contents/MacOS/draw.io",
88
+ "/usr/local/bin/drawio",
89
+ ],
90
+ "linux": [
91
+ "/snap/bin/drawio",
92
+ "/usr/bin/drawio",
93
+ "/usr/local/bin/drawio",
94
+ ],
95
+ }
96
+
97
+
98
+ class DrawioCLINotFoundError(FileNotFoundError):
99
+ """Raised when the draw.io CLI executable cannot be found.
100
+
101
+ Install draw.io desktop:
102
+ Windows : winget install JGraph.Draw
103
+ macOS : brew install --cask drawio
104
+ Linux : snap install drawio
105
+ Or set the DRAWIO_PATH environment variable to the executable path.
106
+ """
107
+
108
+
109
+ def find_drawio_cli() -> str:
110
+ """
111
+ Return the path to the draw.io CLI executable.
112
+
113
+ Resolution order:
114
+ 1. ``DRAWIO_PATH`` environment variable
115
+ 2. ``drawio`` / ``draw.io`` on ``PATH`` (via shutil.which)
116
+ 3. Platform-specific default install locations
117
+
118
+ Raises
119
+ ------
120
+ DrawioCLINotFoundError
121
+ If the executable cannot be found.
122
+ """
123
+ # 1. Explicit env override
124
+ env_path = os.environ.get("DRAWIO_PATH")
125
+ if env_path and Path(env_path).is_file():
126
+ return env_path
127
+
128
+ # 2. PATH lookup
129
+ for name in ("drawio", "draw.io"):
130
+ found = shutil.which(name)
131
+ if found:
132
+ return found
133
+
134
+ # 3. Platform default locations
135
+ platform = sys.platform
136
+ candidates = _DRAWIO_CANDIDATES.get(platform, [])
137
+ for raw in candidates:
138
+ expanded = Path(os.path.expandvars(raw))
139
+ if expanded.is_file():
140
+ return str(expanded)
141
+
142
+ install_hint = {
143
+ "win32": "winget install JGraph.Draw",
144
+ "darwin": "brew install --cask drawio",
145
+ "linux": "snap install drawio",
146
+ }.get(platform, "https://github.com/jgraph/drawio-desktop/releases")
147
+
148
+ raise DrawioCLINotFoundError(
149
+ "draw.io CLI not found.\n"
150
+ f"Install it with: {install_hint}\n"
151
+ "Or set the DRAWIO_PATH environment variable to the executable path."
152
+ )
153
+
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # Internal PNG helpers
157
+ # ---------------------------------------------------------------------------
158
+
159
+ def _png_chunk(chunk_type: bytes, data: bytes) -> bytes:
160
+ """Build a PNG chunk: [4-byte length][4-byte type][data][4-byte CRC]."""
161
+ body = chunk_type + data
162
+ crc = zlib.crc32(body) & 0xFFFFFFFF
163
+ return struct.pack(">I", len(data)) + body + struct.pack(">I", crc)
164
+
165
+
166
+ def _make_minimal_png() -> bytes:
167
+ """
168
+ Return a 1×1 white RGB PNG.
169
+
170
+ draw.io derives canvas dimensions from the embedded XML, not from the PNG
171
+ image size, so a 1×1 placeholder is sufficient when rendering is not needed.
172
+ """
173
+ PNG_SIG = b"\x89PNG\r\n\x1a\n"
174
+ ihdr_data = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)
175
+ ihdr = _png_chunk(b"IHDR", ihdr_data)
176
+ idat = _png_chunk(b"IDAT", zlib.compress(b"\x00\xff\xff\xff"))
177
+ iend = _png_chunk(b"IEND", b"")
178
+ return PNG_SIG + ihdr + idat + iend
179
+
180
+
181
+ def _embed_text_chunk(png_bytes: bytes, keyword: str, text: str) -> bytes:
182
+ """
183
+ Insert a ``tEXt`` chunk immediately before the first ``IDAT`` chunk.
184
+
185
+ draw.io expects:
186
+ - chunk type : ``tEXt``
187
+ - keyword : ``"mxfile"``
188
+ - text value : URL-encoded (UTF-8) mxfile XML (ASCII only result)
189
+ """
190
+ sig = png_bytes[:8]
191
+ body = png_bytes[8:]
192
+ chunk_data = keyword.encode("ascii") + b"\x00" + text.encode("ascii")
193
+ text_chunk = _png_chunk(b"tEXt", chunk_data)
194
+ idat_type_offset = body.find(b"IDAT")
195
+ insert_at = max(idat_type_offset - 4, 0)
196
+ return sig + body[:insert_at] + text_chunk + body[insert_at:]
197
+
198
+
199
+ def _encode_xml(drawio_path: str) -> str:
200
+ """Read a .drawio file and return its URL-encoded (UTF-8) content."""
201
+ with open(drawio_path, encoding="utf-8") as fh:
202
+ return quote(fh.read(), safe="", encoding="utf-8")
203
+
204
+
205
+ # ---------------------------------------------------------------------------
206
+ # Public API
207
+ # ---------------------------------------------------------------------------
208
+
209
+ def drawio_to_png(drawio_path: str, output_path: str | None = None) -> str:
210
+ """
211
+ Embed a ``.drawio`` XML into a re-editable ``.drawio.png`` (no rendering).
212
+
213
+ The output is a valid PNG containing only a 1×1 white placeholder image,
214
+ but with the full diagram XML embedded in a ``tEXt[mxfile]`` chunk so
215
+ draw.io can restore it. No external tools required.
216
+
217
+ For a visually rendered PNG use :func:`drawio_export` instead.
218
+
219
+ Parameters
220
+ ----------
221
+ drawio_path:
222
+ Path to the source ``.drawio`` file.
223
+ output_path:
224
+ Destination path. Defaults to ``drawio_path + ".png"``.
225
+
226
+ Returns
227
+ -------
228
+ str
229
+ Path of the written ``.drawio.png`` file.
230
+ """
231
+ if output_path is None:
232
+ output_path = drawio_path + ".png"
233
+
234
+ encoded = _encode_xml(drawio_path)
235
+ png = _make_minimal_png()
236
+ png = _embed_text_chunk(png, "mxfile", encoded)
237
+
238
+ with open(output_path, "wb") as fh:
239
+ fh.write(png)
240
+
241
+ print(f"[make_drawio_png] {output_path} ({len(png) / 1024:.1f} KB) [embed only]")
242
+ return output_path
243
+
244
+
245
+ def drawio_render_png(
246
+ drawio_path: str,
247
+ output_path: str | None = None,
248
+ *,
249
+ scale: float = 1.0,
250
+ quality: int = 100,
251
+ drawio_cli: str | None = None,
252
+ ) -> str:
253
+ """
254
+ Render a ``.drawio`` file to a PNG image using the draw.io CLI.
255
+
256
+ The output is a fully-rendered PNG image (not re-editable by draw.io).
257
+ For a re-editable rendered PNG use :func:`drawio_export` instead.
258
+
259
+ Requires draw.io desktop to be installed.
260
+
261
+ Parameters
262
+ ----------
263
+ drawio_path:
264
+ Path to the source ``.drawio`` file.
265
+ output_path:
266
+ Destination path. Defaults to ``drawio_path`` with ``.png`` extension.
267
+ scale:
268
+ Export scale factor (default 1.0).
269
+ quality:
270
+ PNG quality hint passed to draw.io (default 100).
271
+ drawio_cli:
272
+ Path to the draw.io executable. Auto-detected when omitted.
273
+
274
+ Returns
275
+ -------
276
+ str
277
+ Path of the written PNG file.
278
+
279
+ Raises
280
+ ------
281
+ DrawioCLINotFoundError
282
+ If draw.io CLI cannot be found.
283
+ subprocess.CalledProcessError
284
+ If the draw.io CLI exits with a non-zero status.
285
+ """
286
+ src = Path(drawio_path)
287
+ if output_path is None:
288
+ output_path = str(src.with_suffix(".png"))
289
+
290
+ cli = drawio_cli or find_drawio_cli()
291
+
292
+ cmd = [
293
+ cli,
294
+ "--export",
295
+ "--format", "png",
296
+ "--scale", str(scale),
297
+ "--quality", str(quality),
298
+ "--output", output_path,
299
+ str(src),
300
+ ]
301
+ subprocess.run(cmd, check=True, capture_output=True)
302
+
303
+ size_kb = Path(output_path).stat().st_size / 1024
304
+ print(f"[make_drawio_png] {output_path} ({size_kb:.1f} KB) [rendered]")
305
+ return output_path
306
+
307
+
308
+ def drawio_export(
309
+ drawio_path: str,
310
+ output_path: str | None = None,
311
+ *,
312
+ scale: float = 1.0,
313
+ quality: int = 100,
314
+ drawio_cli: str | None = None,
315
+ ) -> str:
316
+ """
317
+ Render a ``.drawio`` file and embed the XML — producing a fully-rendered,
318
+ re-editable ``.drawio.png``.
319
+
320
+ This is the **recommended** function: it combines :func:`drawio_render_png`
321
+ (visual rendering via draw.io CLI) and :func:`drawio_to_png` (XML embedding)
322
+ into a single step.
323
+
324
+ Requires draw.io desktop to be installed.
325
+
326
+ Parameters
327
+ ----------
328
+ drawio_path:
329
+ Path to the source ``.drawio`` file.
330
+ output_path:
331
+ Destination path. Defaults to ``drawio_path + ".png"``.
332
+ scale:
333
+ Export scale factor (default 1.0).
334
+ quality:
335
+ PNG quality hint (default 100).
336
+ drawio_cli:
337
+ Path to the draw.io executable. Auto-detected when omitted.
338
+
339
+ Returns
340
+ -------
341
+ str
342
+ Path of the written ``.drawio.png`` file.
343
+
344
+ Raises
345
+ ------
346
+ DrawioCLINotFoundError
347
+ If draw.io CLI cannot be found.
348
+ """
349
+ if output_path is None:
350
+ output_path = drawio_path + ".png"
351
+
352
+ # Step 1: render to a temp file (draw.io replaces .drawio ext with .png)
353
+ src = Path(drawio_path)
354
+ rendered_path = str(src.with_suffix(".png"))
355
+
356
+ cli = drawio_cli or find_drawio_cli()
357
+ cmd = [
358
+ cli,
359
+ "--export",
360
+ "--format", "png",
361
+ "--scale", str(scale),
362
+ "--quality", str(quality),
363
+ "--output", rendered_path,
364
+ str(src),
365
+ ]
366
+ subprocess.run(cmd, check=True, capture_output=True)
367
+
368
+ # Step 2: embed the XML tEXt chunk into the rendered PNG
369
+ with open(rendered_path, "rb") as fh:
370
+ png_bytes = fh.read()
371
+
372
+ encoded = _encode_xml(drawio_path)
373
+ png_bytes = _embed_text_chunk(png_bytes, "mxfile", encoded)
374
+
375
+ with open(output_path, "wb") as fh:
376
+ fh.write(png_bytes)
377
+
378
+ # Clean up intermediate file if it differs from the final output
379
+ if Path(rendered_path).resolve() != Path(output_path).resolve():
380
+ Path(rendered_path).unlink(missing_ok=True)
381
+
382
+ size_kb = len(png_bytes) / 1024
383
+ print(f"[make_drawio_png] {output_path} ({size_kb:.1f} KB) [rendered + embedded]")
384
+ return output_path
@@ -0,0 +1,75 @@
1
+ """CLI entry point — invoked as ``make-drawio-png`` or ``python -m make_drawio_png``."""
2
+
3
+ # SPDX-License-Identifier: MIT
4
+ # Copyright (c) 2026 Shoichiro Sakaigawa
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import sys
10
+
11
+ from make_drawio_png import (
12
+ DrawioCLINotFoundError,
13
+ drawio_export,
14
+ drawio_to_png,
15
+ find_drawio_cli,
16
+ __version__,
17
+ )
18
+
19
+
20
+ def main() -> None:
21
+ parser = argparse.ArgumentParser(
22
+ prog="make-drawio-png",
23
+ description=(
24
+ "Convert a .drawio XML file into a re-editable .drawio.png.\n\n"
25
+ "Modes:\n"
26
+ " (default) Embed only — XML embedded in tEXt chunk, 1x1 placeholder image.\n"
27
+ " Zero dependencies. draw.io can restore the diagram.\n\n"
28
+ " --render Render + embed — draw.io CLI renders the diagram visually,\n"
29
+ " then the XML is embedded. Requires draw.io desktop.\n"
30
+ " Install: winget install JGraph.Draw (Windows)\n"
31
+ " brew install --cask drawio (macOS)\n"
32
+ " snap install drawio (Linux)\n"
33
+ ),
34
+ formatter_class=argparse.RawDescriptionHelpFormatter,
35
+ )
36
+ parser.add_argument("input", help="Source .drawio file")
37
+ parser.add_argument("output", nargs="?", help="Output .drawio.png (default: <input>.png)")
38
+ parser.add_argument(
39
+ "--render", "-r",
40
+ action="store_true",
41
+ help="Use draw.io CLI to render a visual PNG before embedding XML",
42
+ )
43
+ parser.add_argument(
44
+ "--scale",
45
+ type=float,
46
+ default=1.0,
47
+ help="Render scale factor (default: 1.0, used with --render)",
48
+ )
49
+ parser.add_argument(
50
+ "--drawio-path",
51
+ metavar="PATH",
52
+ help="Path to draw.io executable (overrides DRAWIO_PATH env var)",
53
+ )
54
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
55
+
56
+ args = parser.parse_args()
57
+
58
+ if args.render:
59
+ try:
60
+ cli = args.drawio_path or find_drawio_cli()
61
+ except DrawioCLINotFoundError as e:
62
+ print(f"Error: {e}", file=sys.stderr)
63
+ sys.exit(1)
64
+ drawio_export(
65
+ args.input,
66
+ args.output,
67
+ scale=args.scale,
68
+ drawio_cli=cli,
69
+ )
70
+ else:
71
+ drawio_to_png(args.input, args.output)
72
+
73
+
74
+ if __name__ == "__main__":
75
+ main()
@@ -0,0 +1,262 @@
1
+ """Tests for make_drawio_png."""
2
+
3
+ import os
4
+ import struct
5
+ import zlib
6
+ from pathlib import Path
7
+ from urllib.parse import unquote
8
+
9
+ import pytest
10
+
11
+ from make_drawio_png import (
12
+ DrawioCLINotFoundError,
13
+ __version__,
14
+ drawio_export,
15
+ drawio_render_png,
16
+ drawio_to_png,
17
+ find_drawio_cli,
18
+ )
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Fixtures
23
+ # ---------------------------------------------------------------------------
24
+
25
+ MINIMAL_DRAWIO = """\
26
+ <mxfile host="app.diagrams.net">
27
+ <diagram id="test-diagram" name="Test">
28
+ <mxGraphModel>
29
+ <root>
30
+ <mxCell id="0"/>
31
+ <mxCell id="1" parent="0"/>
32
+ <mxCell id="2" value="Hello 日本語" style="rounded=1;" vertex="1" parent="1">
33
+ <mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
34
+ </mxCell>
35
+ </root>
36
+ </mxGraphModel>
37
+ </diagram>
38
+ </mxfile>
39
+ """
40
+
41
+
42
+ @pytest.fixture
43
+ def drawio_file(tmp_path: Path) -> Path:
44
+ p = tmp_path / "test.drawio"
45
+ p.write_text(MINIMAL_DRAWIO, encoding="utf-8")
46
+ return p
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Helpers
51
+ # ---------------------------------------------------------------------------
52
+
53
+ def _read_png_chunks(data: bytes) -> list[tuple[str, bytes]]:
54
+ """Parse PNG into list of (chunk_type, chunk_data) tuples."""
55
+ chunks = []
56
+ assert data[:8] == b"\x89PNG\r\n\x1a\n", "Not a valid PNG signature"
57
+ i = 8
58
+ while i < len(data):
59
+ length = struct.unpack(">I", data[i : i + 4])[0]
60
+ chunk_type = data[i + 4 : i + 8].decode("ascii")
61
+ chunk_data = data[i + 8 : i + 8 + length]
62
+ chunks.append((chunk_type, chunk_data))
63
+ i += 12 + length
64
+ return chunks
65
+
66
+
67
+ def _get_mxfile_xml(png_path: str) -> str:
68
+ """Extract and decode the mxfile XML embedded in a .drawio.png."""
69
+ data = Path(png_path).read_bytes()
70
+ chunks = _read_png_chunks(data)
71
+ text_chunks = [(t, d) for t, d in chunks if t == "tEXt"]
72
+ assert text_chunks, "No tEXt chunk found"
73
+ keyword, _, value = text_chunks[0][1].partition(b"\x00")
74
+ assert keyword == b"mxfile"
75
+ return unquote(value.decode("ascii"), encoding="utf-8")
76
+
77
+
78
+ def _has_drawio_cli() -> bool:
79
+ try:
80
+ find_drawio_cli()
81
+ return True
82
+ except DrawioCLINotFoundError:
83
+ return False
84
+
85
+
86
+ requires_drawio = pytest.mark.skipif(
87
+ not _has_drawio_cli(),
88
+ reason="draw.io CLI not installed (winget install JGraph.Draw / brew install --cask drawio)",
89
+ )
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Tests: version
94
+ # ---------------------------------------------------------------------------
95
+
96
+ class TestVersion:
97
+ def test_version_string(self):
98
+ assert isinstance(__version__, str)
99
+ assert __version__.count(".") >= 1
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # Tests: find_drawio_cli
104
+ # ---------------------------------------------------------------------------
105
+
106
+ class TestFindDrawioCli:
107
+ def test_env_var_override(self, tmp_path: Path):
108
+ fake_exe = tmp_path / "drawio.exe"
109
+ fake_exe.write_bytes(b"")
110
+ os.environ["DRAWIO_PATH"] = str(fake_exe)
111
+ try:
112
+ result = find_drawio_cli()
113
+ assert result == str(fake_exe)
114
+ finally:
115
+ del os.environ["DRAWIO_PATH"]
116
+
117
+ def test_not_found_raises(self, monkeypatch):
118
+ monkeypatch.delenv("DRAWIO_PATH", raising=False)
119
+ monkeypatch.setattr("shutil.which", lambda _: None)
120
+ # Patch candidates to empty so it always raises
121
+ import make_drawio_png as m
122
+ orig = m._DRAWIO_CANDIDATES
123
+ m._DRAWIO_CANDIDATES = {}
124
+ try:
125
+ with pytest.raises(DrawioCLINotFoundError):
126
+ find_drawio_cli()
127
+ finally:
128
+ m._DRAWIO_CANDIDATES = orig
129
+
130
+ @requires_drawio
131
+ def test_finds_installed_drawio(self):
132
+ path = find_drawio_cli()
133
+ assert Path(path).is_file()
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # Tests: drawio_to_png (embed only)
138
+ # ---------------------------------------------------------------------------
139
+
140
+ class TestDrawioToPng:
141
+ def test_creates_output_file(self, drawio_file: Path):
142
+ result = drawio_to_png(str(drawio_file))
143
+ assert Path(result).exists()
144
+
145
+ def test_default_output_path(self, drawio_file: Path):
146
+ result = drawio_to_png(str(drawio_file))
147
+ assert Path(result) == drawio_file.parent / (drawio_file.name + ".png")
148
+
149
+ def test_explicit_output_path(self, drawio_file: Path, tmp_path: Path):
150
+ out = tmp_path / "custom.drawio.png"
151
+ result = drawio_to_png(str(drawio_file), str(out))
152
+ assert Path(result) == out
153
+ assert out.exists()
154
+
155
+ def test_output_is_valid_png(self, drawio_file: Path):
156
+ result = drawio_to_png(str(drawio_file))
157
+ assert Path(result).read_bytes()[:8] == b"\x89PNG\r\n\x1a\n"
158
+
159
+ def test_contains_mxfile_text_chunk(self, drawio_file: Path):
160
+ result = drawio_to_png(str(drawio_file))
161
+ xml = _get_mxfile_xml(result)
162
+ assert xml.startswith("<mxfile")
163
+
164
+ def test_embedded_xml_roundtrips(self, drawio_file: Path):
165
+ result = drawio_to_png(str(drawio_file))
166
+ assert _get_mxfile_xml(result) == MINIMAL_DRAWIO
167
+
168
+ def test_japanese_survives_roundtrip(self, drawio_file: Path):
169
+ result = drawio_to_png(str(drawio_file))
170
+ assert "日本語" in _get_mxfile_xml(result)
171
+
172
+ def test_text_chunk_before_idat(self, drawio_file: Path):
173
+ result = drawio_to_png(str(drawio_file))
174
+ chunks = _read_png_chunks(Path(result).read_bytes())
175
+ types = [t for t, _ in chunks]
176
+ assert types.index("tEXt") < types.index("IDAT")
177
+
178
+ def test_all_chunk_crcs_valid(self, drawio_file: Path):
179
+ result = drawio_to_png(str(drawio_file))
180
+ data = Path(result).read_bytes()
181
+ i = 8
182
+ while i < len(data):
183
+ length = struct.unpack(">I", data[i : i + 4])[0]
184
+ chunk_type = data[i + 4 : i + 8]
185
+ chunk_data = data[i + 8 : i + 8 + length]
186
+ stored = struct.unpack(">I", data[i + 8 + length : i + 12 + length])[0]
187
+ expected = zlib.crc32(chunk_type + chunk_data) & 0xFFFFFFFF
188
+ assert stored == expected, f"CRC mismatch in chunk {chunk_type}"
189
+ i += 12 + length
190
+
191
+ def test_ends_with_iend(self, drawio_file: Path):
192
+ result = drawio_to_png(str(drawio_file))
193
+ chunks = _read_png_chunks(Path(result).read_bytes())
194
+ assert chunks[-1][0] == "IEND"
195
+
196
+
197
+ # ---------------------------------------------------------------------------
198
+ # Tests: drawio_render_png (render only, requires draw.io CLI)
199
+ # ---------------------------------------------------------------------------
200
+
201
+ @requires_drawio
202
+ class TestDrawioRenderPng:
203
+ def test_creates_rendered_png(self, drawio_file: Path):
204
+ out = str(drawio_file.with_suffix(".png"))
205
+ result = drawio_render_png(str(drawio_file), out)
206
+ assert Path(result).exists()
207
+ assert Path(result).stat().st_size > 1000 # real image, not placeholder
208
+
209
+ def test_output_is_valid_png(self, drawio_file: Path):
210
+ out = str(drawio_file.with_suffix(".png"))
211
+ result = drawio_render_png(str(drawio_file), out)
212
+ assert Path(result).read_bytes()[:8] == b"\x89PNG\r\n\x1a\n"
213
+
214
+ def test_no_mxfile_chunk(self, drawio_file: Path):
215
+ """drawio_render_png does NOT embed XML — that's drawio_export's job."""
216
+ out = str(drawio_file.with_suffix(".png"))
217
+ result = drawio_render_png(str(drawio_file), out)
218
+ chunks = _read_png_chunks(Path(result).read_bytes())
219
+ text_keywords = []
220
+ for t, d in chunks:
221
+ if t == "tEXt":
222
+ kw, _, _ = d.partition(b"\x00")
223
+ text_keywords.append(kw.decode("ascii", errors="replace"))
224
+ assert "mxfile" not in text_keywords
225
+
226
+
227
+ # ---------------------------------------------------------------------------
228
+ # Tests: drawio_export (render + embed, requires draw.io CLI)
229
+ # ---------------------------------------------------------------------------
230
+
231
+ @requires_drawio
232
+ class TestDrawioExport:
233
+ def test_creates_drawio_png(self, drawio_file: Path):
234
+ result = drawio_export(str(drawio_file))
235
+ assert Path(result).exists()
236
+
237
+ def test_default_output_path(self, drawio_file: Path):
238
+ result = drawio_export(str(drawio_file))
239
+ assert Path(result) == drawio_file.parent / (drawio_file.name + ".png")
240
+
241
+ def test_output_is_valid_png(self, drawio_file: Path):
242
+ result = drawio_export(str(drawio_file))
243
+ assert Path(result).read_bytes()[:8] == b"\x89PNG\r\n\x1a\n"
244
+
245
+ def test_has_mxfile_text_chunk(self, drawio_file: Path):
246
+ result = drawio_export(str(drawio_file))
247
+ xml = _get_mxfile_xml(result)
248
+ assert xml.startswith("<mxfile")
249
+
250
+ def test_japanese_survives_roundtrip(self, drawio_file: Path):
251
+ result = drawio_export(str(drawio_file))
252
+ assert "日本語" in _get_mxfile_xml(result)
253
+
254
+ def test_rendered_image_is_not_placeholder(self, drawio_file: Path):
255
+ result = drawio_export(str(drawio_file))
256
+ assert Path(result).stat().st_size > 1000 # real rendered content
257
+
258
+ def test_text_chunk_before_idat(self, drawio_file: Path):
259
+ result = drawio_export(str(drawio_file))
260
+ chunks = _read_png_chunks(Path(result).read_bytes())
261
+ types = [t for t, _ in chunks]
262
+ assert types.index("tEXt") < types.index("IDAT")