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.
- make_drawio_png-0.1.0/.gitignore +6 -0
- make_drawio_png-0.1.0/LICENSE +21 -0
- make_drawio_png-0.1.0/PKG-INFO +247 -0
- make_drawio_png-0.1.0/README.md +199 -0
- make_drawio_png-0.1.0/pyproject.toml +51 -0
- make_drawio_png-0.1.0/src/make_drawio_png/__init__.py +384 -0
- make_drawio_png-0.1.0/src/make_drawio_png/__main__.py +75 -0
- make_drawio_png-0.1.0/tests/test_make_drawio_png.py +262 -0
|
@@ -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
|
+
[](https://pypi.org/project/make-drawio-png/)
|
|
52
|
+
[](https://pypi.org/project/make-drawio-png/)
|
|
53
|
+
[](https://github.com/phssakaigawa/make-drawio-png/blob/main/LICENSE)
|
|
54
|
+
[](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
|
+

|
|
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
|
+
[](https://pypi.org/project/make-drawio-png/)
|
|
4
|
+
[](https://pypi.org/project/make-drawio-png/)
|
|
5
|
+
[](https://github.com/phssakaigawa/make-drawio-png/blob/main/LICENSE)
|
|
6
|
+
[](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
|
+

|
|
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")
|