drawio2tikz 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,160 @@
1
+ Metadata-Version: 2.4
2
+ Name: drawio2tikz
3
+ Version: 0.1.0
4
+ Summary: Convert diagrams.net/draw.io diagrams to TikZ via SVG.
5
+ Keywords: drawio,diagrams.net,svg,tikz,latex
6
+ Author: Daiki Okayama
7
+ Author-email: Daiki Okayama <lawliet.d.k.m@gmail.com>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Topic :: Multimedia :: Graphics :: Editors :: Vector-Based
17
+ Classifier: Topic :: Text Processing :: Markup :: LaTeX
18
+ Requires-Dist: rich>=15.0
19
+ Requires-Dist: svg2tikz>=3.3.4
20
+ Requires-Dist: typer>=0.15
21
+ Requires-Python: >=3.14
22
+ Project-URL: Homepage, https://github.com/daikiokayama/drawio2tikz
23
+ Project-URL: Issues, https://github.com/daikiokayama/drawio2tikz/issues
24
+ Description-Content-Type: text/markdown
25
+
26
+ # drawio2tikz
27
+
28
+ `drawio2tikz` is a thin wrapper around [`svg2tikz`](https://github.com/xyz2tex/svg2tikz) that converts draw.io diagrams to TikZ code for embedding in LaTeX documents.
29
+
30
+ ## Features
31
+
32
+ - Converts either a single draw.io page or all pages
33
+ - Preserves label colors, bold text, font sizes, and simple line breaks
34
+ - **Ignores draw.io font family names**, allowing output to use the host LaTeX document font
35
+ - Removes draw.io SVG CSS that `svg2tikz` cannot parse, including `light-dark(...)` from draw.io's dark mode color scheme
36
+ - Restores draw.io labels emitted as SVG `foreignObject` elements, which draw.io exports as HTML fragments
37
+
38
+ ## Requirements
39
+
40
+ - **Python 3.14 or newer**
41
+ - **`drawio` CLI** available on `PATH` (install from [diagrams.net](https://diagrams.net) or via Homebrew: `brew install --cask drawio`)
42
+
43
+ ## Installation
44
+
45
+ ### Homebrew (macOS)
46
+
47
+ ```bash
48
+ brew install drawio2tikz
49
+ ```
50
+
51
+ ### pip / uv
52
+
53
+ Install from PyPI:
54
+
55
+ ```bash
56
+ uv tool install drawio2tikz
57
+ ```
58
+
59
+ Or with pip:
60
+
61
+ ```bash
62
+ pip install drawio2tikz
63
+ ```
64
+
65
+ ### Development Setup
66
+
67
+ Clone the repository and install in development mode:
68
+
69
+ ```bash
70
+ git clone https://github.com/daikiokayama/drawio2tikz.git
71
+ cd drawio2tikz
72
+ uv sync
73
+ uv run drawio2tikz --help
74
+ ```
75
+
76
+ ## Usage
77
+
78
+ ### Basic Usage
79
+
80
+ Convert a single page from a draw.io file to TikZ:
81
+
82
+ ```bash
83
+ drawio2tikz path/to/figure.drawio -o output_dir
84
+ ```
85
+
86
+ This generates `figure.tikz` in the output directory.
87
+
88
+ ### Convert All Pages
89
+
90
+ To convert all pages in a multi-page `.drawio` file:
91
+
92
+ ```bash
93
+ drawio2tikz path/to/multipage.drawio --all-pages -o output_dir
94
+ ```
95
+
96
+ Each page is saved as `figure_page{N}.tikz`.
97
+
98
+ ### Keep Intermediate SVG
99
+
100
+ To debug or inspect the intermediate SVG (after sanitization):
101
+
102
+ ```bash
103
+ drawio2tikz path/to/figure.drawio -o output_dir --keep-svg --svg-dir output_dir/svg
104
+ ```
105
+
106
+ ### View Help
107
+
108
+ ```bash
109
+ drawio2tikz --help
110
+ ```
111
+
112
+ ## LaTeX Setup
113
+
114
+ Add these packages to your LaTeX document preamble:
115
+
116
+ ```tex
117
+ \usepackage{xcolor}
118
+ \usepackage{tikz}
119
+ ```
120
+
121
+ Include the generated TikZ file:
122
+
123
+ ```tex
124
+ \input{path/to/figure.tikz}
125
+ ```
126
+
127
+ For support of arbitrary large font sizes, use a scalable font:
128
+
129
+ ```tex
130
+ \usepackage{mlmodern}
131
+ ```
132
+
133
+ ### Example LaTeX Document
134
+
135
+ ```tex
136
+ \documentclass{article}
137
+ \usepackage{xcolor}
138
+ \usepackage{tikz}
139
+ \usepackage{mlmodern}
140
+
141
+ \begin{document}
142
+
143
+ \section{My Diagram}
144
+
145
+ \begin{figure}
146
+ \centering
147
+ \input{figures/diagram.tikz}
148
+ \caption{A diagram created in draw.io}
149
+ \end{figure}
150
+
151
+ \end{document}
152
+ ```
153
+
154
+ ## Contributing
155
+
156
+ Contributions are welcome! Please feel free to open issues or submit pull requests on [GitHub](https://github.com/daikiokayama/drawio2tikz).
157
+
158
+ ## License
159
+
160
+ MIT
@@ -0,0 +1,135 @@
1
+ # drawio2tikz
2
+
3
+ `drawio2tikz` is a thin wrapper around [`svg2tikz`](https://github.com/xyz2tex/svg2tikz) that converts draw.io diagrams to TikZ code for embedding in LaTeX documents.
4
+
5
+ ## Features
6
+
7
+ - Converts either a single draw.io page or all pages
8
+ - Preserves label colors, bold text, font sizes, and simple line breaks
9
+ - **Ignores draw.io font family names**, allowing output to use the host LaTeX document font
10
+ - Removes draw.io SVG CSS that `svg2tikz` cannot parse, including `light-dark(...)` from draw.io's dark mode color scheme
11
+ - Restores draw.io labels emitted as SVG `foreignObject` elements, which draw.io exports as HTML fragments
12
+
13
+ ## Requirements
14
+
15
+ - **Python 3.14 or newer**
16
+ - **`drawio` CLI** available on `PATH` (install from [diagrams.net](https://diagrams.net) or via Homebrew: `brew install --cask drawio`)
17
+
18
+ ## Installation
19
+
20
+ ### Homebrew (macOS)
21
+
22
+ ```bash
23
+ brew install drawio2tikz
24
+ ```
25
+
26
+ ### pip / uv
27
+
28
+ Install from PyPI:
29
+
30
+ ```bash
31
+ uv tool install drawio2tikz
32
+ ```
33
+
34
+ Or with pip:
35
+
36
+ ```bash
37
+ pip install drawio2tikz
38
+ ```
39
+
40
+ ### Development Setup
41
+
42
+ Clone the repository and install in development mode:
43
+
44
+ ```bash
45
+ git clone https://github.com/daikiokayama/drawio2tikz.git
46
+ cd drawio2tikz
47
+ uv sync
48
+ uv run drawio2tikz --help
49
+ ```
50
+
51
+ ## Usage
52
+
53
+ ### Basic Usage
54
+
55
+ Convert a single page from a draw.io file to TikZ:
56
+
57
+ ```bash
58
+ drawio2tikz path/to/figure.drawio -o output_dir
59
+ ```
60
+
61
+ This generates `figure.tikz` in the output directory.
62
+
63
+ ### Convert All Pages
64
+
65
+ To convert all pages in a multi-page `.drawio` file:
66
+
67
+ ```bash
68
+ drawio2tikz path/to/multipage.drawio --all-pages -o output_dir
69
+ ```
70
+
71
+ Each page is saved as `figure_page{N}.tikz`.
72
+
73
+ ### Keep Intermediate SVG
74
+
75
+ To debug or inspect the intermediate SVG (after sanitization):
76
+
77
+ ```bash
78
+ drawio2tikz path/to/figure.drawio -o output_dir --keep-svg --svg-dir output_dir/svg
79
+ ```
80
+
81
+ ### View Help
82
+
83
+ ```bash
84
+ drawio2tikz --help
85
+ ```
86
+
87
+ ## LaTeX Setup
88
+
89
+ Add these packages to your LaTeX document preamble:
90
+
91
+ ```tex
92
+ \usepackage{xcolor}
93
+ \usepackage{tikz}
94
+ ```
95
+
96
+ Include the generated TikZ file:
97
+
98
+ ```tex
99
+ \input{path/to/figure.tikz}
100
+ ```
101
+
102
+ For support of arbitrary large font sizes, use a scalable font:
103
+
104
+ ```tex
105
+ \usepackage{mlmodern}
106
+ ```
107
+
108
+ ### Example LaTeX Document
109
+
110
+ ```tex
111
+ \documentclass{article}
112
+ \usepackage{xcolor}
113
+ \usepackage{tikz}
114
+ \usepackage{mlmodern}
115
+
116
+ \begin{document}
117
+
118
+ \section{My Diagram}
119
+
120
+ \begin{figure}
121
+ \centering
122
+ \input{figures/diagram.tikz}
123
+ \caption{A diagram created in draw.io}
124
+ \end{figure}
125
+
126
+ \end{document}
127
+ ```
128
+
129
+ ## Contributing
130
+
131
+ Contributions are welcome! Please feel free to open issues or submit pull requests on [GitHub](https://github.com/daikiokayama/drawio2tikz).
132
+
133
+ ## License
134
+
135
+ MIT
@@ -0,0 +1,60 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.8.14,<0.9.0"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "drawio2tikz"
7
+ version = "0.1.0"
8
+ description = "Convert diagrams.net/draw.io diagrams to TikZ via SVG."
9
+ readme = "README.md"
10
+ authors = [{ name = "Daiki Okayama", email = "lawliet.d.k.m@gmail.com" }]
11
+ requires-python = ">=3.14"
12
+ license = "MIT"
13
+ keywords = ["drawio", "diagrams.net", "svg", "tikz", "latex"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Science/Research",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.14",
21
+ "Operating System :: OS Independent",
22
+ "Topic :: Multimedia :: Graphics :: Editors :: Vector-Based",
23
+ "Topic :: Text Processing :: Markup :: LaTeX",
24
+ ]
25
+ dependencies = ["rich>=15.0", "svg2tikz>=3.3.4", "typer>=0.15"]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/daikiokayama/drawio2tikz"
29
+ Issues = "https://github.com/daikiokayama/drawio2tikz/issues"
30
+
31
+ [project.scripts]
32
+ drawio2tikz = "drawio2tikz.cli:app"
33
+
34
+ [dependency-groups]
35
+ dev = ["basedpyright>=1.34.0", "pytest>=9.0", "ruff>=0.8"]
36
+
37
+ [tool.ruff]
38
+ line-length = 100
39
+ target-version = "py314"
40
+
41
+ [tool.ruff.lint]
42
+ select = ["ALL"]
43
+ ignore = [
44
+ "E501", # Line too long
45
+ "D203", # Insert 1 blank line before class docstring
46
+ "D213", # Multi-line docstring summary should start at the next line
47
+ "S101", # Use of assert (standard in pytest)
48
+ "PLR0913", # Too many arguments (CLI requirement for Typer)
49
+ ]
50
+
51
+ [tool.basedpyright]
52
+ typeCheckingMode = "strict"
53
+ venvPath = "."
54
+ venv = ".venv"
55
+ stubPath = "./stubs"
56
+
57
+ [tool.uv.workspace]
58
+ members = [
59
+ "test",
60
+ ]
@@ -0,0 +1,5 @@
1
+ """Convert diagrams.net/draw.io diagrams to TikZ."""
2
+
3
+ from .converter import ConversionResult, ConvertOptions, convert
4
+
5
+ __all__ = ["ConversionResult", "ConvertOptions", "convert"]
@@ -0,0 +1,5 @@
1
+ """Entry point for drawio2tikz CLI."""
2
+
3
+ from .cli import app
4
+
5
+ app()
@@ -0,0 +1,117 @@
1
+ """Command-line interface for drawio2tikz."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Annotated
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from .converter import ConvertOptions, convert
11
+
12
+ if TYPE_CHECKING:
13
+ from pathlib import Path
14
+
15
+ app = typer.Typer(
16
+ add_completion=False,
17
+ help="Convert diagrams.net/draw.io diagrams to TikZ via sanitized SVG.",
18
+ )
19
+ console = Console()
20
+
21
+
22
+ @app.command()
23
+ def main(
24
+ input_path: Annotated[
25
+ Path,
26
+ typer.Argument(exists=True, dir_okay=False, readable=True, help="Input .drawio file."),
27
+ ],
28
+ output: Annotated[
29
+ Path | None,
30
+ typer.Option("--output", "-o", help="Output .tex file or output directory."),
31
+ ] = None,
32
+ page_index: Annotated[
33
+ int,
34
+ typer.Option("--page-index", min=1, help="1-based draw.io page index."),
35
+ ] = 1,
36
+ *,
37
+ all_pages: Annotated[
38
+ bool,
39
+ typer.Option("--all-pages", help="Export every page found in a .drawio file."),
40
+ ] = False,
41
+ keep_svg: Annotated[
42
+ bool,
43
+ typer.Option("--keep-svg", help="Keep the sanitized intermediate SVG."),
44
+ ] = False,
45
+ svg_dir: Annotated[
46
+ Path | None,
47
+ typer.Option("--svg-dir", help="Directory for kept SVG files. Requires --keep-svg."),
48
+ ] = None,
49
+ drawio_bin: Annotated[
50
+ str,
51
+ typer.Option("--drawio-bin", help="draw.io CLI executable."),
52
+ ] = "drawio",
53
+ output_unit: Annotated[
54
+ str,
55
+ typer.Option("--output-unit", help="Output unit passed to svg2tikz."),
56
+ ] = "pt",
57
+ scale: Annotated[
58
+ float,
59
+ typer.Option("--scale", help="Scale passed to svg2tikz."),
60
+ ] = 1.0,
61
+ round_number: Annotated[
62
+ int,
63
+ typer.Option("--round-number", min=0, help="Coordinate precision for svg2tikz."),
64
+ ] = 3,
65
+ texmode: Annotated[
66
+ str,
67
+ typer.Option("--texmode", help="svg2tikz text mode. raw preserves injected TeX labels."),
68
+ ] = "raw",
69
+ markings: Annotated[
70
+ str,
71
+ typer.Option("--markings", help="svg2tikz marker handling."),
72
+ ] = "interpret",
73
+ quiet: Annotated[
74
+ bool,
75
+ typer.Option("--quiet", "-q", help="Suppress external command echoing."),
76
+ ] = False,
77
+ ) -> None:
78
+ """Convert draw.io diagrams to TikZ LaTeX code."""
79
+ if svg_dir and not keep_svg:
80
+ msg = "--svg-dir requires --keep-svg"
81
+ raise typer.BadParameter(msg)
82
+
83
+ options = ConvertOptions(
84
+ input_path=input_path,
85
+ output=output,
86
+ page_index=page_index,
87
+ all_pages=all_pages,
88
+ keep_svg=keep_svg,
89
+ svg_dir=svg_dir,
90
+ drawio_bin=drawio_bin,
91
+ output_unit=output_unit,
92
+ scale=scale,
93
+ round_number=round_number,
94
+ texmode=texmode,
95
+ markings=markings,
96
+ quiet=quiet,
97
+ )
98
+
99
+ try:
100
+ results = convert(options)
101
+ except Exception as exc:
102
+ console.print(f"[red]drawio2tikz:[/red] {exc}")
103
+ raise typer.Exit(1) from exc
104
+
105
+ for result in results:
106
+ svg_note = f", svg: {result.svg_path}" if result.svg_path else ""
107
+ console.print(f"wrote: [green]{result.tex_path}[/green]{svg_note}")
108
+ if result.remaining_foreign_objects:
109
+ console.print(
110
+ "[yellow]warning:[/yellow] sanitized SVG still contains "
111
+ f"{result.remaining_foreign_objects} foreignObject node(s); "
112
+ "some draw.io HTML text may be omitted.",
113
+ )
114
+ if result.text_nodes:
115
+ console.print(f"note: sanitized SVG contains {result.text_nodes} SVG text node(s).")
116
+
117
+ console.print(f"converted {len(results)} page(s)")
@@ -0,0 +1,209 @@
1
+ """SVG to TikZ conversion with sanitization for draw.io diagrams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ from contextlib import contextmanager
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING
14
+
15
+ from svg2tikz import convert_svg
16
+
17
+ from .drawio import count_pages, drawio_stem, parse_labels
18
+ from .svg import sanitize_svg
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Generator
22
+
23
+ from .drawio import Label
24
+
25
+ XML_DECL_RE = re.compile(r"^\s*<\?xml[^>]*\?>\s*", re.IGNORECASE)
26
+ DOCTYPE_RE = re.compile(r"^\s*<!DOCTYPE[^>]*(?:\[[\s\S]*?\]\s*)?>\s*", re.IGNORECASE)
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class ConvertOptions:
31
+ """Options for SVG to TikZ conversion."""
32
+
33
+ input_path: Path
34
+ output: Path | None = None
35
+ page_index: int = 1
36
+ all_pages: bool = False
37
+ keep_svg: bool = False
38
+ svg_dir: Path | None = None
39
+ drawio_bin: str = "drawio"
40
+ output_unit: str = "pt"
41
+ scale: float = 1.0
42
+ round_number: int = 3
43
+ texmode: str = "raw"
44
+ markings: str = "interpret"
45
+ quiet: bool = False
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class ConversionResult:
50
+ """Result of a single conversion."""
51
+
52
+ tex_path: Path
53
+ svg_path: Path | None
54
+ remaining_foreign_objects: int
55
+ text_nodes: int
56
+
57
+
58
+ def convert(options: ConvertOptions) -> list[ConversionResult]:
59
+ """Convert a draw.io file to TikZ LaTeX code."""
60
+ if not options.input_path.exists():
61
+ raise FileNotFoundError(options.input_path)
62
+ if shutil.which(options.drawio_bin) is None and not Path(options.drawio_bin).exists():
63
+ msg = f"draw.io CLI not found: {options.drawio_bin}"
64
+ raise RuntimeError(msg)
65
+
66
+ labels = parse_labels(options.input_path)
67
+ page_indexes = _page_indexes(options)
68
+
69
+ return [_convert_one(options, labels, page_index) for page_index in page_indexes]
70
+
71
+
72
+ def _page_indexes(options: ConvertOptions) -> list[int]:
73
+ """Get list of page indexes to convert."""
74
+ if not options.all_pages:
75
+ return [options.page_index]
76
+
77
+ page_count = count_pages(options.input_path)
78
+ if page_count == 0:
79
+ msg = "--all-pages needs a plain .drawio XML file with diagram pages."
80
+ raise RuntimeError(msg)
81
+ return list(range(1, page_count + 1))
82
+
83
+
84
+ def _convert_one(
85
+ options: ConvertOptions,
86
+ labels: dict[str, Label],
87
+ page_index: int,
88
+ ) -> ConversionResult:
89
+ """Convert a single page to TikZ."""
90
+ tex_path = _default_output_path(
91
+ options.input_path,
92
+ options.output,
93
+ page_index,
94
+ all_pages=options.all_pages,
95
+ )
96
+ tex_path.parent.mkdir(parents=True, exist_ok=True)
97
+
98
+ with tempfile.TemporaryDirectory(prefix="drawio2tikz-") as tmp:
99
+ tmp_dir = Path(tmp)
100
+ raw_svg = tmp_dir / f"{tex_path.stem}.raw.svg"
101
+ sanitized_svg = tmp_dir / f"{tex_path.stem}.svg"
102
+
103
+ _run_drawio_export(options, page_index, raw_svg)
104
+ stats = sanitize_svg(raw_svg, sanitized_svg, labels)
105
+
106
+ kept_svg = None
107
+ if options.keep_svg:
108
+ svg_dir = options.svg_dir or tex_path.parent
109
+ svg_dir.mkdir(parents=True, exist_ok=True)
110
+ kept_svg = svg_dir / f"{tex_path.stem}.svg"
111
+ shutil.copyfile(sanitized_svg, kept_svg)
112
+
113
+ tikz = _convert_svg_source(
114
+ sanitized_svg.read_text(encoding="utf-8"),
115
+ options,
116
+ )
117
+ tex_path.write_text(
118
+ _source_comment(options.input_path, page_index) + tikz,
119
+ encoding="utf-8",
120
+ )
121
+
122
+ return ConversionResult(
123
+ tex_path=tex_path,
124
+ svg_path=kept_svg,
125
+ remaining_foreign_objects=stats.remaining_foreign_objects,
126
+ text_nodes=stats.text_nodes,
127
+ )
128
+
129
+
130
+ def _run_drawio_export(options: ConvertOptions, page_index: int, raw_svg: Path) -> None:
131
+ """Run draw.io CLI to export SVG."""
132
+ command = [
133
+ options.drawio_bin,
134
+ "--export",
135
+ "--format",
136
+ "svg",
137
+ "--page-index",
138
+ str(page_index),
139
+ "--output",
140
+ str(raw_svg),
141
+ str(options.input_path),
142
+ ]
143
+ if not options.quiet:
144
+ sys.stdout.write(f"+ {' '.join(command)}\n")
145
+ sys.stdout.flush()
146
+ subprocess.run(command, check=True) # noqa: S603
147
+
148
+
149
+ def _convert_svg_source(svg_source: str, options: ConvertOptions) -> str:
150
+ """Convert SVG source to TikZ code."""
151
+ # svg2tikz exposes a library API, but internally it still calls
152
+ # argparse.parse_args(). Isolate it from drawio2tikz's CLI arguments.
153
+ with _isolated_argv():
154
+ result: str = convert_svg(
155
+ _strip_xml_prolog(svg_source),
156
+ no_output=True,
157
+ returnstring=True,
158
+ codeoutput="figonly",
159
+ output_unit=options.output_unit,
160
+ texmode=options.texmode,
161
+ markings=options.markings,
162
+ arrow="stealth",
163
+ scale=options.scale,
164
+ round_number=options.round_number,
165
+ )
166
+ return result
167
+
168
+
169
+ def _strip_xml_prolog(svg_source: str) -> str:
170
+ """Strip XML declaration and DOCTYPE from SVG source."""
171
+ svg_source = XML_DECL_RE.sub("", svg_source, count=1)
172
+ return DOCTYPE_RE.sub("", svg_source, count=1)
173
+
174
+
175
+ @contextmanager
176
+ def _isolated_argv() -> Generator[None]:
177
+ """Context manager to isolate sys.argv for svg2tikz."""
178
+ original = sys.argv
179
+ sys.argv = ["drawio2tikz-svg2tikz"]
180
+ try:
181
+ yield
182
+ finally:
183
+ sys.argv = original
184
+
185
+
186
+ def _default_output_path(
187
+ input_path: Path,
188
+ output: Path | None,
189
+ page_index: int,
190
+ *,
191
+ all_pages: bool,
192
+ ) -> Path:
193
+ """Determine output path for TikZ file."""
194
+ stem = drawio_stem(input_path)
195
+ filename = f"{stem}-{page_index:02d}.tex" if all_pages else f"{stem}.tex"
196
+
197
+ if output is None:
198
+ return input_path.parent / "tikz" / filename
199
+ if output.suffix == ".tex" and not all_pages:
200
+ return output
201
+ return output / filename
202
+
203
+
204
+ def _source_comment(input_path: Path, page_index: int) -> str:
205
+ """Generate LaTeX comment with conversion metadata."""
206
+ return (
207
+ f"% Generated from {input_path} page {page_index} via drawio SVG and svg2tikz.\n"
208
+ "% The intermediate SVG was sanitized by drawio2tikz.\n"
209
+ )
@@ -0,0 +1,232 @@
1
+ """Parse and handle labels from draw.io diagrams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import xml.etree.ElementTree as ET
7
+ from dataclasses import dataclass
8
+ from html.parser import HTMLParser
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from pathlib import Path
13
+
14
+ DRAWIO_PX_TO_TEX_PT = 0.75
15
+ FONT_SIZE_RE = re.compile(r"font-size:\s*([0-9.]+)px", re.IGNORECASE)
16
+ CSS_COLOR_RE = re.compile(r"color:\s*([^;]+)", re.IGNORECASE)
17
+ RGB_COLOR_RE = re.compile(r"rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*[^)]+)?\)", re.IGNORECASE)
18
+ HEX_COLOR_RE = re.compile(r"#([0-9a-fA-F]{6})")
19
+ FONT_WEIGHT_RE = re.compile(r"font-weight:\s*([^;]+)", re.IGNORECASE)
20
+
21
+ TEX_SPECIALS = {
22
+ "&": r"\&",
23
+ "%": r"\%",
24
+ "#": r"\#",
25
+ "_": r"\_",
26
+ "{": r"\{",
27
+ "}": r"\}",
28
+ }
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class LabelLine:
33
+ """A single line of text in a label."""
34
+
35
+ text: str
36
+ font_size: float | None
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Label:
41
+ """A label extracted from a draw.io cell."""
42
+
43
+ lines: list[LabelLine]
44
+ font_size: float | None
45
+
46
+
47
+ @dataclass
48
+ class TextStyle:
49
+ """Text styling attributes."""
50
+
51
+ bold: bool = False
52
+ color: str | None = None
53
+ font_size: float | None = None
54
+
55
+
56
+ @dataclass
57
+ class TextRun:
58
+ """A run of text with associated styling."""
59
+
60
+ text: str
61
+ bold: bool = False
62
+ color: str | None = None
63
+ font_size: float | None = None
64
+
65
+
66
+ class DrawioLabelParser(HTMLParser):
67
+ """Parse HTML labels from draw.io cells into TeX-compatible text."""
68
+
69
+ def __init__(self) -> None:
70
+ """Initialize the parser."""
71
+ super().__init__(convert_charrefs=True)
72
+ self.lines: list[list[TextRun]] = [[]]
73
+ self.stack: list[TextStyle] = [TextStyle()]
74
+ self.pushed_tags: list[str] = []
75
+
76
+ @property
77
+ def current_style(self) -> TextStyle:
78
+ """Get the current text style."""
79
+ return self.stack[-1]
80
+
81
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
82
+ """Handle start of HTML tags."""
83
+ tag = tag.lower()
84
+ if tag in {"div", "p"}:
85
+ self._newline()
86
+ self._push_style(tag, attrs)
87
+ elif tag == "br":
88
+ self._newline()
89
+ elif tag in {"b", "strong"}:
90
+ self._push_style(tag, attrs, force_bold=True)
91
+ elif tag in {"font", "span", "i", "em", "u"}:
92
+ self._push_style(tag, attrs)
93
+
94
+ def handle_endtag(self, tag: str) -> None:
95
+ """Handle end of HTML tags."""
96
+ tag = tag.lower()
97
+ if tag in {"div", "p", "b", "strong", "font", "span", "i", "em", "u"}:
98
+ self._pop_style(tag)
99
+
100
+ def handle_data(self, data: str) -> None:
101
+ """Handle text data."""
102
+ text = data.replace("\xa0", " ")
103
+ if not text:
104
+ return
105
+ style = self.current_style
106
+ self.lines[-1].append(
107
+ TextRun(
108
+ text=text,
109
+ bold=style.bold,
110
+ color=style.color,
111
+ font_size=style.font_size,
112
+ ),
113
+ )
114
+
115
+ def _newline(self) -> None:
116
+ if self.lines[-1]:
117
+ self.lines.append([])
118
+
119
+ def _push_style(
120
+ self,
121
+ tag: str,
122
+ attrs: list[tuple[str, str | None]],
123
+ *,
124
+ force_bold: bool = False,
125
+ ) -> None:
126
+ style = TextStyle(**self.current_style.__dict__)
127
+ parsed = _style_from_attrs(attrs)
128
+ style.color = parsed.color or style.color
129
+ style.font_size = parsed.font_size or style.font_size
130
+ style.bold = style.bold or parsed.bold or force_bold
131
+ self.stack.append(style)
132
+ self.pushed_tags.append(tag)
133
+
134
+ def _pop_style(self, tag: str) -> None:
135
+ if tag not in self.pushed_tags:
136
+ return
137
+ index = len(self.pushed_tags) - 1 - self.pushed_tags[::-1].index(tag)
138
+ del self.pushed_tags[index]
139
+ del self.stack[index + 1]
140
+
141
+
142
+ def parse_labels(path: Path) -> dict[str, Label]:
143
+ """Parse all labels from a draw.io file."""
144
+ root = ET.parse(path).getroot() # noqa: S314
145
+ labels: dict[str, Label] = {}
146
+
147
+ for cell in root.findall(".//mxCell"):
148
+ cell_id = cell.get("id")
149
+ value = cell.get("value")
150
+ if not cell_id or not value:
151
+ continue
152
+ label = parse_label(value)
153
+ if label.lines:
154
+ labels[cell_id] = label
155
+
156
+ return labels
157
+
158
+
159
+ def count_pages(path: Path) -> int:
160
+ """Count the number of pages in a draw.io file."""
161
+ root = ET.parse(path).getroot() # noqa: S314
162
+ return len(root.findall("diagram"))
163
+
164
+
165
+ def drawio_stem(path: Path) -> str:
166
+ """Extract the stem of a draw.io file name."""
167
+ name = path.name
168
+ if name.endswith(".drawio.png"):
169
+ return name[: -len(".drawio.png")]
170
+ if name.endswith(".drawio"):
171
+ return name[: -len(".drawio")]
172
+ return path.stem
173
+
174
+
175
+ def parse_label(value: str) -> Label:
176
+ """Parse a single label from HTML string."""
177
+ parser = DrawioLabelParser()
178
+ parser.feed(value)
179
+
180
+ lines: list[LabelLine] = []
181
+ font_sizes: list[float] = []
182
+
183
+ for runs in parser.lines:
184
+ line_font_sizes = [run.font_size for run in runs if run.font_size]
185
+ font_sizes.extend(line_font_sizes)
186
+ line_text = _text_from_runs(runs)
187
+ if line_text:
188
+ first_font = line_font_sizes[0] if line_font_sizes else None
189
+ lines.append(LabelLine(text=line_text, font_size=first_font))
190
+
191
+ if not lines:
192
+ return Label(lines=[], font_size=None)
193
+
194
+ label_font_size = font_sizes[0] if font_sizes else None
195
+ return Label(lines=lines, font_size=label_font_size)
196
+
197
+
198
+ def _text_from_runs(runs: list[TextRun]) -> str:
199
+ parts: list[str] = []
200
+ for run in runs:
201
+ text = run.text
202
+ for char, replacement in TEX_SPECIALS.items():
203
+ text = text.replace(char, replacement)
204
+ if run.bold:
205
+ text = rf"\textbf{{{text}}}"
206
+ if run.color:
207
+ text = rf"\textcolor[HTML]{{{run.color}}}{{{text}}}"
208
+ parts.append(text)
209
+ return "".join(parts)
210
+
211
+
212
+ def _style_from_attrs(attrs: list[tuple[str, str | None]]) -> TextStyle:
213
+ style = TextStyle()
214
+ attrs_dict = dict(attrs)
215
+ if "style" in attrs_dict:
216
+ css = attrs_dict["style"] or ""
217
+ if match := FONT_SIZE_RE.search(css):
218
+ style.font_size = float(match.group(1))
219
+ if match := CSS_COLOR_RE.search(css):
220
+ color_str = match.group(1).strip()
221
+ if color_match := RGB_COLOR_RE.search(color_str):
222
+ r, g, b = color_match.groups()
223
+ style.color = f"{int(r):02X}{int(g):02X}{int(b):02X}"
224
+ elif hex_match := HEX_COLOR_RE.search(color_str):
225
+ style.color = hex_match.group(1).upper()
226
+ if "color" in attrs_dict and (
227
+ hex_match := HEX_COLOR_RE.search(attrs_dict["color"] or "")
228
+ ):
229
+ style.color = hex_match.group(1).upper()
230
+ if "weight" in attrs_dict and attrs_dict["weight"] in {"bold", "700"}:
231
+ style.bold = True
232
+ return style
@@ -0,0 +1,120 @@
1
+ """SVG sanitization for draw.io diagrams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import re
7
+ from dataclasses import dataclass
8
+ from typing import TYPE_CHECKING
9
+
10
+ from .drawio import DRAWIO_PX_TO_TEX_PT, Label, LabelLine
11
+
12
+ if TYPE_CHECKING:
13
+ from pathlib import Path
14
+
15
+ STYLE_ELEMENT_RE = re.compile(r"<style\b[^>]*>.*?</style>", re.DOTALL)
16
+ STYLE_ATTR_RE = re.compile(r'\sstyle="[^"]*"')
17
+ SWITCH_FOREIGN_OBJECT_RE = re.compile(
18
+ r"<switch>\s*<foreignObject\b.*?</foreignObject>\s*<image\s+([^>]*)/>\s*</switch>",
19
+ re.DOTALL,
20
+ )
21
+ ATTR_RE = re.compile(r'([:\w-]+)="([^"]*)"')
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class SVGStats:
26
+ """Statistics about SVG sanitization."""
27
+
28
+ remaining_foreign_objects: int
29
+ text_nodes: int
30
+
31
+
32
+ def sanitize_svg(
33
+ raw_svg: Path,
34
+ sanitized_svg: Path,
35
+ labels: dict[str, Label],
36
+ ) -> SVGStats:
37
+ """Sanitize SVG by converting foreign objects to text nodes."""
38
+ text = raw_svg.read_text(encoding="utf-8")
39
+ text = _restore_foreign_object_text(text, labels)
40
+ text = STYLE_ELEMENT_RE.sub("", text)
41
+ text = STYLE_ATTR_RE.sub("", text)
42
+ sanitized_svg.write_text(text, encoding="utf-8")
43
+ return SVGStats(
44
+ remaining_foreign_objects=text.count("foreignObject"),
45
+ text_nodes=text.count("<text"),
46
+ )
47
+
48
+
49
+ def _restore_foreign_object_text(
50
+ svg_text: str,
51
+ labels: dict[str, Label],
52
+ ) -> str:
53
+ """Restore text in foreign objects using parsed labels."""
54
+
55
+ def replace(match: re.Match[str]) -> str:
56
+ cell_id = _nearest_cell_id(svg_text, match.start())
57
+ if not cell_id or cell_id not in labels:
58
+ return match.group(0)
59
+ label_obj = labels[cell_id]
60
+ replacement = _text_svg_for_label(label_obj, _parse_attrs(match.group(1)))
61
+ return replacement or match.group(0)
62
+
63
+ return SWITCH_FOREIGN_OBJECT_RE.sub(replace, svg_text)
64
+
65
+
66
+ def _nearest_cell_id(svg_text: str, offset: int) -> str | None:
67
+ """Find the nearest cell ID before the given offset."""
68
+ marker = 'data-cell-id="'
69
+ start = svg_text.rfind(marker, 0, offset)
70
+ if start == -1:
71
+ return None
72
+ start += len(marker)
73
+ end = svg_text.find('"', start)
74
+ if end == -1:
75
+ return None
76
+ return svg_text[start:end]
77
+
78
+
79
+ def _parse_attrs(raw_attrs: str) -> dict[str, str]:
80
+ """Parse attributes from a string."""
81
+ return {name: html.unescape(value) for name, value in ATTR_RE.findall(raw_attrs)}
82
+
83
+
84
+ def _text_svg_for_label(label: Label, image_attrs: dict[str, str]) -> str:
85
+ """Generate SVG text nodes for a label."""
86
+ try:
87
+ x = float(image_attrs["x"])
88
+ y = float(image_attrs["y"])
89
+ width = float(image_attrs["width"])
90
+ height = float(image_attrs["height"])
91
+ except (KeyError, ValueError):
92
+ return ""
93
+
94
+ if not label.lines:
95
+ return ""
96
+
97
+ font_size = label.font_size or height / len(label.lines) * 0.8
98
+ font_size = max(8.0, min(font_size, height * 0.95))
99
+ line_height = font_size * 1.2
100
+ first_baseline = y + height / 2 - (len(label.lines) - 1) * line_height / 2 + font_size * 0.35
101
+ cx = x + width / 2
102
+
103
+ text_nodes: list[str] = []
104
+ for index, line in enumerate(label.lines):
105
+ line_text = _line_text_with_fallback_size(line, font_size)
106
+ text_nodes.append(
107
+ f'<text x="{cx:.3f}" y="{first_baseline + index * line_height:.3f}" '
108
+ f'text-anchor="middle" font-size="{font_size:.3f}px">'
109
+ f"{html.escape(line_text, quote=False)}</text>",
110
+ )
111
+ return "".join(text_nodes)
112
+
113
+
114
+ def _line_text_with_fallback_size(line: LabelLine, font_size_px: float) -> str:
115
+ """Generate TeX text for a label line with fallback font size."""
116
+ if line.font_size:
117
+ return line.text
118
+ size_pt = font_size_px * DRAWIO_PX_TO_TEX_PT
119
+ leading_pt = size_pt * 1.2
120
+ return rf"\fontsize{{{size_pt:.1f}pt}}{{{leading_pt:.1f}pt}}\selectfont {line.text}"