svg alignemment
This commit is contained in:
260
backend/tools/validate_svg_quality.py
Normal file
260
backend/tools/validate_svg_quality.py
Normal file
@@ -0,0 +1,260 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SLASH_FRACTION_RE = re.compile(r"(?<![\w.])\d+\s*/\s*\d+(?![\w.])")
|
||||
MOJIBAKE_RE = re.compile(r"Ã.|Â.|’|“|â€")
|
||||
TRANSLATE_RE = re.compile(r"translate\(\s*([-\d.]+)(?:[\s,]+([-\d.]+))?\s*\)")
|
||||
FONT_SIZE_RE = re.compile(r"font-size\s*:\s*([-\d.]+)px?")
|
||||
CLASS_STYLE_RE = re.compile(r"\.([A-Za-z0-9_-]+)\s*\{([^}]*)\}")
|
||||
FORBIDDEN_TEXT = {
|
||||
"Ençadrer": "Encadrer",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Issue:
|
||||
severity: str
|
||||
path: Path
|
||||
message: str
|
||||
|
||||
|
||||
def local_name(tag: str) -> str:
|
||||
return tag.rsplit("}", 1)[-1]
|
||||
|
||||
|
||||
def parse_number(value: str | None, default: float = 0.0) -> float:
|
||||
if not value:
|
||||
return default
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def node_text(node: ET.Element) -> str:
|
||||
return "".join(node.itertext()).strip()
|
||||
|
||||
|
||||
def class_names(node: ET.Element) -> set[str]:
|
||||
return set((node.attrib.get("class") or "").split())
|
||||
|
||||
|
||||
def has_class(node: ET.Element, name: str) -> bool:
|
||||
return name in class_names(node)
|
||||
|
||||
|
||||
def parse_styles(root: ET.Element) -> dict[str, dict[str, str]]:
|
||||
styles: dict[str, dict[str, str]] = {}
|
||||
for node in root.iter():
|
||||
if local_name(node.tag) != "style":
|
||||
continue
|
||||
text = node.text or ""
|
||||
for match in CLASS_STYLE_RE.finditer(text):
|
||||
declarations: dict[str, str] = {}
|
||||
for declaration in match.group(2).split(";"):
|
||||
if ":" not in declaration:
|
||||
continue
|
||||
key, value = declaration.split(":", 1)
|
||||
declarations[key.strip()] = value.strip()
|
||||
styles[match.group(1)] = declarations
|
||||
return styles
|
||||
|
||||
|
||||
def font_size(node: ET.Element, styles: dict[str, dict[str, str]], default: float = 24.0) -> float:
|
||||
direct = parse_number(node.attrib.get("font-size"), 0)
|
||||
if direct:
|
||||
return direct
|
||||
|
||||
style_attr = node.attrib.get("style") or ""
|
||||
style_match = FONT_SIZE_RE.search(style_attr)
|
||||
if style_match:
|
||||
return parse_number(style_match.group(1), default)
|
||||
|
||||
for class_name in class_names(node):
|
||||
value = styles.get(class_name, {}).get("font-size")
|
||||
if value:
|
||||
return parse_number(value.replace("px", ""), default)
|
||||
return default
|
||||
|
||||
|
||||
def line_width(node: ET.Element) -> float:
|
||||
return abs(parse_number(node.attrib.get("x2")) - parse_number(node.attrib.get("x1")))
|
||||
|
||||
|
||||
def line_y(node: ET.Element) -> float:
|
||||
return (parse_number(node.attrib.get("y1")) + parse_number(node.attrib.get("y2"))) / 2
|
||||
|
||||
|
||||
def text_x(node: ET.Element) -> float:
|
||||
return parse_number(node.attrib.get("x"))
|
||||
|
||||
|
||||
def text_y(node: ET.Element) -> float:
|
||||
return parse_number(node.attrib.get("y"))
|
||||
|
||||
|
||||
def estimated_text_width(text: str, size: float) -> float:
|
||||
# Chiffres scolaires en police sans-serif : estimation volontairement prudente.
|
||||
return max(1, len(text.strip())) * size * 0.62
|
||||
|
||||
|
||||
def validate_fraction_group(path: Path, group: ET.Element, styles: dict[str, dict[str, str]]) -> list[Issue]:
|
||||
issues: list[Issue] = []
|
||||
texts = [node for node in group if local_name(node.tag) == "text"]
|
||||
lines = [node for node in group if local_name(node.tag) == "line"]
|
||||
|
||||
if len(texts) < 2 or not lines:
|
||||
return [
|
||||
Issue(
|
||||
"error",
|
||||
path,
|
||||
"Un groupe fraction-g doit contenir au moins deux textes et une barre horizontale.",
|
||||
)
|
||||
]
|
||||
|
||||
numerator = texts[0]
|
||||
denominator = texts[-1]
|
||||
bar = lines[0]
|
||||
num_y = text_y(numerator)
|
||||
den_y = text_y(denominator)
|
||||
bar_y = line_y(bar)
|
||||
|
||||
if not num_y < bar_y < den_y:
|
||||
issues.append(Issue("error", path, "La barre d'une fraction-g n'est pas entre le numérateur et le dénominateur."))
|
||||
|
||||
num_size = font_size(numerator, styles)
|
||||
den_size = font_size(denominator, styles)
|
||||
min_top_gap = max(8.0, num_size * 0.30)
|
||||
min_bottom_gap = max(8.0, den_size * 0.30)
|
||||
|
||||
if bar_y - num_y < min_top_gap:
|
||||
issues.append(
|
||||
Issue(
|
||||
"warning",
|
||||
path,
|
||||
f"Espace faible entre numérateur et barre ({bar_y - num_y:.1f}px, attendu >= {min_top_gap:.1f}px).",
|
||||
)
|
||||
)
|
||||
if den_y - bar_y < min_bottom_gap:
|
||||
issues.append(
|
||||
Issue(
|
||||
"warning",
|
||||
path,
|
||||
f"Espace faible entre barre et dénominateur ({den_y - bar_y:.1f}px, attendu >= {min_bottom_gap:.1f}px).",
|
||||
)
|
||||
)
|
||||
|
||||
expected_width = max(
|
||||
estimated_text_width(node_text(numerator), num_size),
|
||||
estimated_text_width(node_text(denominator), den_size),
|
||||
)
|
||||
if line_width(bar) < expected_width * 0.95:
|
||||
issues.append(
|
||||
Issue(
|
||||
"warning",
|
||||
path,
|
||||
f"Barre de fraction possiblement trop courte ({line_width(bar):.1f}px pour ~{expected_width:.1f}px de texte).",
|
||||
)
|
||||
)
|
||||
|
||||
center = (parse_number(bar.attrib.get("x1")) + parse_number(bar.attrib.get("x2"))) / 2
|
||||
if abs(text_x(numerator) - center) > 4 or abs(text_x(denominator) - center) > 4:
|
||||
issues.append(Issue("warning", path, "Numérateur ou dénominateur non centré sur la barre de fraction."))
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def validate_svg(path: Path) -> list[Issue]:
|
||||
issues: list[Issue] = []
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
root = ET.fromstring(text)
|
||||
except Exception as exc:
|
||||
return [Issue("error", path, f"SVG illisible: {exc}")]
|
||||
|
||||
if local_name(root.tag) != "svg":
|
||||
issues.append(Issue("error", path, "Le fichier n'a pas de racine <svg>."))
|
||||
return issues
|
||||
|
||||
if not root.attrib.get("viewBox"):
|
||||
issues.append(Issue("warning", path, "SVG sans viewBox : recadrage et responsive moins fiables."))
|
||||
|
||||
styles = parse_styles(root)
|
||||
|
||||
for node in root.iter():
|
||||
if local_name(node.tag) == "text":
|
||||
content = node_text(node)
|
||||
for forbidden, expected in FORBIDDEN_TEXT.items():
|
||||
if forbidden in content:
|
||||
issues.append(
|
||||
Issue(
|
||||
"error",
|
||||
path,
|
||||
f"Texte incorrect détecté: {forbidden!r}. Utiliser {expected!r}.",
|
||||
)
|
||||
)
|
||||
if MOJIBAKE_RE.search(content):
|
||||
issues.append(
|
||||
Issue(
|
||||
"error",
|
||||
path,
|
||||
f"Encodage texte suspect dans le SVG: {content!r}.",
|
||||
)
|
||||
)
|
||||
if SLASH_FRACTION_RE.search(content):
|
||||
issues.append(
|
||||
Issue(
|
||||
"error",
|
||||
path,
|
||||
f"Fraction oblique détectée dans un texte SVG: {content!r}. Utiliser une fraction verticale.",
|
||||
)
|
||||
)
|
||||
|
||||
if local_name(node.tag) == "g" and has_class(node, "fraction-g"):
|
||||
issues.extend(validate_fraction_group(path, node, styles))
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def iter_svg_paths(paths: list[Path]) -> list[Path]:
|
||||
files: list[Path] = []
|
||||
for path in paths:
|
||||
if path.is_file() and path.suffix.lower() == ".svg":
|
||||
files.append(path)
|
||||
elif path.is_dir():
|
||||
files.extend(sorted(path.rglob("*.svg")))
|
||||
return sorted(set(files))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Valide les SVG pédagogiques sensibles.")
|
||||
parser.add_argument("paths", nargs="+", type=Path, help="Fichiers ou dossiers SVG à contrôler.")
|
||||
parser.add_argument("--warnings-as-errors", action="store_true", help="Retourne une erreur si des warnings existent.")
|
||||
args = parser.parse_args()
|
||||
|
||||
all_issues: list[Issue] = []
|
||||
files = iter_svg_paths(args.paths)
|
||||
for path in files:
|
||||
all_issues.extend(validate_svg(path))
|
||||
|
||||
for issue in all_issues:
|
||||
print(f"{issue.severity.upper()} {issue.path}: {issue.message}")
|
||||
|
||||
errors = [issue for issue in all_issues if issue.severity == "error"]
|
||||
warnings = [issue for issue in all_issues if issue.severity == "warning"]
|
||||
print(f"SVG contrôlés: {len(files)} | erreurs: {len(errors)} | warnings: {len(warnings)}")
|
||||
|
||||
if errors or (args.warnings_as_errors and warnings):
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user