|
#!/usr/bin/env -S uv -q run --no-project -s |
|
""" |
|
Draw.io Exporter |
|
|
|
A command-line tool to export all pages from a draw.io file to multiple formats, |
|
using the actual page names from the diagram as output filenames. |
|
""" |
|
# /// script |
|
# requires-python = ">=3.12" |
|
# dependencies = [ |
|
# "lxml>=4.9.0", |
|
# "lxml-stubs>=0.5.1", |
|
# "typer>=0.9.0", |
|
# ] |
|
# /// |
|
|
|
import os |
|
import shutil |
|
import subprocess |
|
import sys |
|
import tempfile |
|
from enum import Enum |
|
from pathlib import Path |
|
from typing import Annotated |
|
|
|
import typer |
|
from lxml import etree |
|
|
|
|
|
class OutputFormat(Enum): |
|
""" |
|
Enum representing the output format for the exported files. |
|
""" |
|
PNG = "png" |
|
PDF_ONE_PAGE = "pdf_one_page" |
|
PDF_ALL_PAGES = "pdf_all_pages" |
|
|
|
|
|
def find_drawio_command() -> str: |
|
""" |
|
Find the draw.io executable on the system. |
|
|
|
Searches for common draw.io command names and the macOS application path. |
|
|
|
Returns: |
|
str: Path to the draw.io executable |
|
|
|
Raises: |
|
typer.Exit: If draw.io command is not found |
|
""" |
|
# Try common command names |
|
for cmd in ["drawio", "draw.io"]: |
|
if shutil.which(cmd): |
|
return cmd |
|
|
|
# Try macOS application path |
|
macos_path = "/Applications/draw.io.app/Contents/MacOS/draw.io" |
|
if os.path.isfile(macos_path) and os.access(macos_path, os.X_OK): |
|
return macos_path |
|
|
|
typer.secho("Error: Draw.io command not found", fg=typer.colors.RED, err=True) |
|
typer.secho("Please install draw.io from https://www.diagrams.net/", fg=typer.colors.YELLOW, err=True) |
|
raise typer.Exit(code=1) |
|
|
|
|
|
def get_page_info(xml_file: str) -> list[tuple[int, str]]: |
|
""" |
|
Extract page information from the draw.io XML file. |
|
|
|
Parses the XML structure to find all diagram elements and extracts |
|
their names and indices. |
|
|
|
Args: |
|
xml_file: Path to the XML file to parse |
|
|
|
Returns: |
|
list[tuple[int, str]]: List of tuples containing (page_index, sanitized_page_name) |
|
|
|
Raises: |
|
typer.Exit: If XML parsing fails |
|
""" |
|
try: |
|
tree = etree.parse(xml_file) |
|
root = tree.getroot() |
|
|
|
# Find all diagram elements |
|
diagrams = root.findall('.//diagram') |
|
|
|
page_info: list[tuple[int, str]] = [] |
|
for idx, diagram in enumerate(diagrams): |
|
# Get the page name from the 'name' attribute |
|
page_name = diagram.get('name', f'page-{idx}') |
|
|
|
# Sanitize the page name for use as filename |
|
safe_name = "".join( |
|
c if c.isalnum() or c in (' ', '-', '_') else '_' |
|
for c in page_name |
|
) |
|
safe_name = safe_name.strip().replace(' ', '-') |
|
|
|
if not safe_name: |
|
safe_name = f'page-{idx}' |
|
|
|
page_info.append((idx, safe_name)) |
|
|
|
return page_info |
|
|
|
except Exception as e: |
|
typer.secho(f"Error parsing XML: {e}", fg=typer.colors.RED, err=True) |
|
raise typer.Exit(code=1) |
|
|
|
|
|
def export_document( |
|
drawio_cmd: str, |
|
input_file: Path, |
|
page_info: list[tuple[int, str]], |
|
output_format: OutputFormat, |
|
output_dir: Path | None = None, |
|
border: int = 15 |
|
) -> None: |
|
""" |
|
Export each page to PNG with the page name as filename. |
|
|
|
Args: |
|
drawio_cmd: Path to the draw.io executable |
|
input_file: Path to the input draw.io file |
|
page_info: List of tuples containing (page_index, page_name) |
|
output_dir: Optional directory for output files. If None, uses input file's directory |
|
border: Border size in pixels for exported images (default: 15) |
|
|
|
Raises: |
|
typer.Exit: If any export operation fails |
|
""" |
|
base_name = input_file.stem |
|
|
|
if output_dir: |
|
output_path = output_dir |
|
output_path.mkdir(parents=True, exist_ok=True) |
|
else: |
|
output_path = input_file.parent |
|
|
|
if output_format in [OutputFormat.PDF_ONE_PAGE, OutputFormat.PDF_ALL_PAGES]: |
|
output_extension = "pdf" |
|
elif output_format == OutputFormat.PNG: |
|
output_extension = "png" |
|
else: |
|
raise ValueError(f"Invalid output format: {output_format}") |
|
|
|
if output_format == OutputFormat.PDF_ALL_PAGES: |
|
|
|
output_file = output_path / f"{base_name}.{output_extension}" |
|
typer.echo(f"Exporting {output_format.value.upper()} -> {output_file}") |
|
export_command = [drawio_cmd, "--export", "--format", "pdf", "--all-pages", "--output", str(output_file), str(input_file)] |
|
try: |
|
_ = subprocess.run( |
|
export_command, |
|
check=True, |
|
capture_output=True, |
|
text=True |
|
) |
|
except subprocess.CalledProcessError as e: |
|
typer.secho(f"Error exporting document: {e.stderr}", fg=typer.colors.RED, err=True) |
|
raise typer.Exit(code=1) |
|
|
|
else: |
|
|
|
total_pages = len(page_info) |
|
digits = len(str(total_pages)) |
|
|
|
for page_index, page_name in page_info: |
|
output_file = output_path / f"{base_name}-{page_index+1:0{digits}d}-{page_name}.{output_extension}" |
|
typer.echo(f"Exporting {output_format.value.upper()} page {page_index+1}: {page_name} -> {output_file}") |
|
export_command = [drawio_cmd, "--export", "--format", output_format.value, "--page-index", str(page_index), "--border", str(border), "--output", str(output_file), str(input_file)] |
|
try: |
|
_ = subprocess.run( |
|
export_command, |
|
check=True, |
|
capture_output=True, |
|
text=True |
|
) |
|
except subprocess.CalledProcessError as e: |
|
typer.secho(f"Error exporting page {page_index}: {e.stderr}", fg=typer.colors.RED, err=True) |
|
raise typer.Exit(code=1) |
|
|
|
|
|
def main( |
|
input_file: Annotated[Path, typer.Argument(exists=True, file_okay=True, dir_okay=False, readable=True, help="Path to the draw.io file to export")], |
|
output_formats: Annotated[list[OutputFormat], typer.Option("--format", "-f", help="Output format for exported files (default: png)")] = [OutputFormat.PNG], |
|
output_dir: Annotated[Path | None, typer.Argument(file_okay=False, dir_okay=True, help="Optional output directory for PNG files (default: same as input file)")] = None, |
|
border: Annotated[int, typer.Option("--border", "-b", min=0, help="Border size in pixels for exported images")] = 15, |
|
verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enable verbose output")] = False, |
|
) -> int: |
|
""" |
|
Export all pages from a draw.io file to multiple formats. |
|
|
|
Examples: |
|
Export to same directory as input file: |
|
$ python script.py diagram.drawio |
|
|
|
Export to specific directory: |
|
$ python script.py diagram.drawio ./output |
|
|
|
Export with custom border: |
|
$ python script.py diagram.drawio --border 20 |
|
""" |
|
# Find draw.io command |
|
drawio_cmd = find_drawio_command() |
|
if verbose: |
|
typer.echo(f"Using draw.io command: {drawio_cmd}") |
|
|
|
# Get pages info |
|
with tempfile.NamedTemporaryFile(mode='w', suffix='.xml',) as tmp_file: |
|
tmp_filename = tmp_file.name |
|
|
|
try: |
|
# Export to uncompressed XML |
|
typer.echo(f"Exporting {input_file} to XML...") |
|
_ = subprocess.run( |
|
[drawio_cmd, "--export", "--format", "xml", "--output", tmp_filename, "--uncompressed", str(input_file)], |
|
check=True, |
|
capture_output=True, |
|
text=True |
|
) |
|
|
|
# Parse XML to get page information |
|
typer.echo("Analyzing pages...") |
|
page_info = get_page_info(tmp_filename) |
|
typer.secho(f"Found {len(page_info)} page(s)", fg=typer.colors.GREEN) |
|
|
|
except subprocess.CalledProcessError as e: |
|
typer.secho(f"Error running draw.io: {e.stderr}", fg=typer.colors.RED, err=True) |
|
raise typer.Exit(code=1) |
|
|
|
for output_format in output_formats: |
|
typer.echo(f"Exporting {input_file} to {output_format.value}...") |
|
export_document(drawio_cmd, input_file, page_info, output_format, output_dir, border) |
|
|
|
typer.secho("✓ Export complete!", fg=typer.colors.GREEN, bold=True) |
|
return 0 |
|
|
|
|
|
if __name__ == "__main__": |
|
sys.exit(typer.run(main)) |