Skip to content

Instantly share code, notes, and snippets.

@pHo9UBenaA
Created March 18, 2026 14:58
Show Gist options
  • Select an option

  • Save pHo9UBenaA/43e0ed489dfab9ea14303cadf36077e2 to your computer and use it in GitHub Desktop.

Select an option

Save pHo9UBenaA/43e0ed489dfab9ea14303cadf36077e2 to your computer and use it in GitHub Desktop.
#!/usr/bin/env python3
# Run: uv run python drawio-to-png-tabs.py <input_dir> <output_dir>
# Output:
# input/path/sample.drawio
# -> out/input/path/sample/<tabname>.png
from __future__ import annotations
import argparse
import os
import re
import shutil
import subprocess
import sys
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path
DRAWIO_CANDIDATES = ("drawio", "draw.io", "draw")
@dataclass(frozen=True)
class ExportSummary:
exported_files: int
exported_tabs: int
def sanitize_filename(name: str) -> str:
sanitized_name = name.strip()
sanitized_name = re.sub(r'[\\/:*?"<>|]', "_", sanitized_name)
sanitized_name = re.sub(r"\s+", " ", sanitized_name)
sanitized_name = sanitized_name.rstrip(".")
return sanitized_name or "untitled"
def unique_name(base_name: str, used_names: set[str]) -> str:
if base_name not in used_names:
used_names.add(base_name)
return base_name
suffix = 2
while True:
candidate_name = f"{base_name}_{suffix}"
if candidate_name not in used_names:
used_names.add(candidate_name)
return candidate_name
suffix += 1
def resolve_drawio_cmd(explicit_cmd: str | None = None) -> str:
candidates = []
if explicit_cmd:
candidates.append(explicit_cmd)
if env_value := os.environ.get("DRAWIO_CMD"):
candidates.append(env_value)
candidates.extend(DRAWIO_CANDIDATES)
for candidate in candidates:
if not candidate:
continue
resolved_path = shutil.which(candidate)
if resolved_path:
return resolved_path
candidate_path = Path(candidate)
if candidate_path.exists():
return str(candidate_path)
raise FileNotFoundError(
"draw.io CLI was not found. Add `drawio`, `draw.io`, or `draw` to PATH, "
"or provide `--drawio-cmd` or `DRAWIO_CMD`."
)
def iter_drawio_files(input_dir: Path) -> list[Path]:
return sorted(path for path in input_dir.rglob("*.drawio") if path.is_file())
def get_tab_names(drawio_file: Path) -> list[str]:
try:
diagrams = ET.parse(drawio_file).getroot().findall(".//diagram")
except ET.ParseError as error:
raise RuntimeError(f"Failed to parse XML: {drawio_file}\n{error}") from error
if not diagrams:
raise RuntimeError(f"No <diagram> elements were found: {drawio_file}")
used_names: set[str] = set()
tab_names: list[str] = []
for page_index, diagram in enumerate(diagrams, start=1):
raw_name = diagram.attrib.get("name") or f"tab_{page_index}"
tab_names.append(unique_name(sanitize_filename(raw_name), used_names))
return tab_names
def export_tab_png(drawio_cmd: str, src: Path, page_index: int, out_png: Path) -> None:
out_png.parent.mkdir(parents=True, exist_ok=True)
command = [
drawio_cmd,
"--export",
"--format",
"png",
"--crop",
"--page-index",
str(page_index),
"--output",
str(out_png),
str(src),
]
result = subprocess.run(
command,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise RuntimeError(
"PNG export failed\n"
f"src={src}\n"
f"page_index={page_index}\n"
f"out={out_png}\n"
f"cmd={' '.join(command)}\n"
f"stdout:\n{result.stdout}\n"
f"stderr:\n{result.stderr}"
)
if not out_png.exists():
raise RuntimeError(f"The command succeeded, but no output file was created: {out_png}")
def convert_drawio_file(drawio_cmd: str, input_dir: Path, output_dir: Path, src: Path) -> int:
relative_path = src.relative_to(input_dir)
relative_dir = relative_path.with_suffix("")
destination_dir = output_dir / relative_dir
tab_names = get_tab_names(src)
print(f"[INFO] {src}: {len(tab_names)} tabs")
for page_index, tab_name in enumerate(tab_names, start=1):
out_png = destination_dir / f"{tab_name}.png"
export_tab_png(drawio_cmd, src, page_index, out_png)
print(f" [OK] {out_png}")
return len(tab_names)
def convert_directory(input_dir: Path, output_dir: Path, drawio_cmd: str) -> ExportSummary:
drawio_files = iter_drawio_files(input_dir)
if not drawio_files:
print(f"[WARN] No .drawio files were found: {input_dir}")
return ExportSummary(exported_files=0, exported_tabs=0)
exported_tabs = 0
for src in drawio_files:
exported_tabs += convert_drawio_file(drawio_cmd, input_dir, output_dir, src)
return ExportSummary(exported_files=len(drawio_files), exported_tabs=exported_tabs)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Export each draw.io tab as an individual tabname.png file.",
)
parser.add_argument("input_dir", type=Path, help="Input directory containing .drawio files")
parser.add_argument("output_dir", type=Path, help="Output directory for generated PNG files")
parser.add_argument(
"--drawio-cmd",
default=None,
help="draw.io CLI command or absolute path",
)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
input_dir = args.input_dir
output_dir = args.output_dir
if not input_dir.is_dir():
print(f"[ERROR] Input directory does not exist: {input_dir}", file=sys.stderr)
return 1
try:
drawio_cmd = resolve_drawio_cmd(args.drawio_cmd)
summary = convert_directory(input_dir, output_dir, drawio_cmd)
except (FileNotFoundError, RuntimeError, OSError) as error:
print(f"[ERROR] {error}", file=sys.stderr)
return 1
print("")
print("===== summary =====")
print(f"input dir : {input_dir.resolve()}")
print(f"output root : {output_dir.resolve()}")
print(f"drawio cli : {drawio_cmd}")
print(f"files done : {summary.exported_files}")
print(f"tabs exported: {summary.exported_tabs}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment