170 lines
5.2 KiB
Python
170 lines
5.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Auto-fix mechanical issues in a .drawio file.
|
|
|
|
Fixes (in-place, writes if --write given):
|
|
* Snap every x/y/width/height in <mxGeometry> to the 10-px grid (round half up)
|
|
* Quantize floating-point widths (e.g. 239.9999...) to integers
|
|
* Uppercase all hex color values in styles (outside data: URIs)
|
|
* Add `absoluteArcSize=1` anywhere `arcSize=<n>` is set without it
|
|
* Replace `strokeWidth=1.2` and similar odd values with the nearest allowed weight
|
|
* Normalise the font family to `Helvetica` (warn only — style strings preserved)
|
|
|
|
Usage:
|
|
autofix.py <file.drawio> # dry-run, prints summary
|
|
autofix.py --write <file.drawio> # edits in place (makes a .bak copy)
|
|
|
|
Run validate.py afterwards — any remaining issues are authorial (bent edges,
|
|
overflow, palette deviations).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
import shutil
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
GRID = 10
|
|
ALLOWED_STROKE_VALUES = (1.0, 1.5, 2.0, 3.0, 4.0)
|
|
|
|
|
|
def snap(v: float) -> int:
|
|
return int(round(v / GRID) * GRID)
|
|
|
|
|
|
def fix_geometry(text: str, stats: dict[str, int]) -> str:
|
|
def repl(m: re.Match[str]) -> str:
|
|
attr, num = m.group(1), float(m.group(2))
|
|
snapped = snap(num)
|
|
if abs(num - snapped) > 0.001:
|
|
stats["geometry"] += 1
|
|
return f'{attr}="{snapped}"'
|
|
|
|
return re.sub(r'\b(x|y|width|height)="(-?\d+(?:\.\d+)?)"', repl, text)
|
|
|
|
|
|
def fix_hex_case(text: str, stats: dict[str, int]) -> str:
|
|
out_chunks: list[str] = []
|
|
i = 0
|
|
# naive split around data: URIs so we don't touch hex inside SVG payloads
|
|
while i < len(text):
|
|
m = re.search(r"data:image/[^&\";]+", text[i:])
|
|
if not m:
|
|
out_chunks.append(_fix_hex_chunk(text[i:], stats))
|
|
break
|
|
start, end = m.span()
|
|
out_chunks.append(_fix_hex_chunk(text[i : i + start], stats))
|
|
out_chunks.append(text[i + start : i + end])
|
|
i += end
|
|
return "".join(out_chunks)
|
|
|
|
|
|
def _fix_hex_chunk(chunk: str, stats: dict[str, int]) -> str:
|
|
def repl(m: re.Match[str]) -> str:
|
|
hex_val = m.group(0)
|
|
up = hex_val.upper()
|
|
if hex_val != up:
|
|
stats["hex_case"] += 1
|
|
return up
|
|
|
|
return re.sub(r"#[0-9a-fA-F]{6}\b", repl, chunk)
|
|
|
|
|
|
def fix_arc_size(text: str, stats: dict[str, int]) -> str:
|
|
# For each style="..." attribute value, if arcSize= appears without absoluteArcSize=1, add it.
|
|
def repl(m: re.Match[str]) -> str:
|
|
style_val = m.group(1)
|
|
if "arcSize=" in style_val and "absoluteArcSize=" not in style_val:
|
|
stats["arc_size"] += 1
|
|
trailing = "" if style_val.endswith(";") else ";"
|
|
return f'style="{style_val}{trailing}absoluteArcSize=1;"'
|
|
return m.group(0)
|
|
|
|
return re.sub(r'style="([^"]*)"', repl, text)
|
|
|
|
|
|
def fix_stroke_width(text: str, stats: dict[str, int]) -> str:
|
|
def repl(m: re.Match[str]) -> str:
|
|
val = float(m.group(1))
|
|
nearest = min(ALLOWED_STROKE_VALUES, key=lambda x: abs(x - val))
|
|
if abs(nearest - val) < 0.01:
|
|
return m.group(0)
|
|
stats["stroke_width"] += 1
|
|
formatted = f"{nearest:g}"
|
|
return f"strokeWidth={formatted}"
|
|
|
|
return re.sub(r"strokeWidth=([0-9.]+)", repl, text)
|
|
|
|
|
|
def fix_font_family(text: str, stats: dict[str, int]) -> str:
|
|
def repl(m: re.Match[str]) -> str:
|
|
val = m.group(1)
|
|
if val.lower() == "helvetica":
|
|
return m.group(0)
|
|
stats["font_family"] += 1
|
|
return "fontFamily=Helvetica"
|
|
|
|
return re.sub(r"fontFamily=([^;\"]+)", repl, text)
|
|
|
|
|
|
_COMMENT_RE = re.compile(r"<!--.*?-->", re.S)
|
|
|
|
|
|
def fix_strip_comments(text: str, stats: dict[str, int]) -> str:
|
|
n = len(_COMMENT_RE.findall(text))
|
|
if n:
|
|
stats["xml_comments"] += n
|
|
text = _COMMENT_RE.sub("", text)
|
|
return text
|
|
|
|
|
|
def apply_all(text: str) -> tuple[str, dict[str, int]]:
|
|
stats = {
|
|
"geometry": 0,
|
|
"hex_case": 0,
|
|
"arc_size": 0,
|
|
"stroke_width": 0,
|
|
"font_family": 0,
|
|
"xml_comments": 0,
|
|
}
|
|
text = fix_strip_comments(text, stats)
|
|
text = fix_geometry(text, stats)
|
|
text = fix_hex_case(text, stats)
|
|
text = fix_arc_size(text, stats)
|
|
text = fix_stroke_width(text, stats)
|
|
text = fix_font_family(text, stats)
|
|
return text, stats
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("file")
|
|
ap.add_argument("--write", action="store_true", help="write the fix in place (backup .bak)")
|
|
args = ap.parse_args()
|
|
|
|
p = Path(args.file)
|
|
if not p.exists():
|
|
print(f"{p}: not found", file=sys.stderr)
|
|
return 1
|
|
|
|
original = p.read_text(encoding="utf-8")
|
|
fixed, stats = apply_all(original)
|
|
total = sum(stats.values())
|
|
|
|
summary = ", ".join(f"{k}={v}" for k, v in stats.items() if v)
|
|
if total == 0:
|
|
print(f"{p}: no fixes needed")
|
|
return 0
|
|
|
|
if args.write:
|
|
shutil.copyfile(p, p.with_suffix(p.suffix + ".bak"))
|
|
p.write_text(fixed, encoding="utf-8")
|
|
print(f"{p}: wrote ({total} fixes — {summary}); backup at {p.name}.bak")
|
|
else:
|
|
print(f"{p}: would fix ({total} changes — {summary}); re-run with --write")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|