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"(? "Box": return Box(self.kind, self.label, self.x1 - amount, self.y1 - amount, self.x2 + amount, self.y2 + amount) 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 parse_translate(value: str | None) -> tuple[float, float]: if not value: return 0.0, 0.0 match = TRANSLATE_RE.search(value) if not match: return 0.0, 0.0 return parse_number(match.group(1)), parse_number(match.group(2), 0.0) def overlaps(a: Box, b: Box) -> bool: return a.x1 < b.x2 and a.x2 > b.x1 and a.y1 < b.y2 and a.y2 > b.y1 def horizontal_overlap(a: Box, b: Box) -> bool: return a.x1 < b.x2 and a.x2 > b.x1 def vertical_gap(a: Box, b: Box) -> float: if a.y2 <= b.y1: return b.y1 - a.y2 if b.y2 <= a.y1: return a.y1 - b.y2 return 0.0 def parse_data_box(node: ET.Element, ox: float, oy: float) -> Box | None: match = DATA_BOX_RE.match(node.attrib.get("data-box") or "") if not match: return None x, y, width, height = (parse_number(value) for value in match.groups()) return Box("fraction", node_text(node) or "fraction", ox + x, oy + y, ox + x + width, oy + y + height) def text_box(node: ET.Element, styles: dict[str, dict[str, str]], ox: float, oy: float) -> Box | None: content = node_text(node) if not content: return None size = font_size(node, styles) width = estimated_text_width(content, size) x = ox + text_x(node) y = oy + text_y(node) anchor = node.attrib.get("text-anchor", "start") if anchor == "middle": x1 = x - width / 2 x2 = x + width / 2 elif anchor == "end": x1 = x - width x2 = x else: x1 = x x2 = x + width return Box("text", content, x1, y - size * 0.85, x2, y + size * 0.25) def shape_box(node: ET.Element, ox: float, oy: float) -> Box | None: tag = local_name(node.tag) classes = class_names(node) if tag == "rect": if classes.intersection({"bg", "panel", "soft", "card", "white"}): return None x = ox + parse_number(node.attrib.get("x")) y = oy + parse_number(node.attrib.get("y")) return Box("shape", "rect", x, y, x + parse_number(node.attrib.get("width")), y + parse_number(node.attrib.get("height"))) if tag == "circle": cx = ox + parse_number(node.attrib.get("cx")) cy = oy + parse_number(node.attrib.get("cy")) radius = parse_number(node.attrib.get("r")) return Box("shape", "circle", cx - radius, cy - radius, cx + radius, cy + radius) return None def panel_box_from_rect(node: ET.Element, ox: float, oy: float) -> Box | None: if local_name(node.tag) != "rect" or not class_names(node).intersection({"panel", "soft", "card", "white"}): return None x = ox + parse_number(node.attrib.get("x")) y = oy + parse_number(node.attrib.get("y")) return Box("panel", "panel", x, y, x + parse_number(node.attrib.get("width")), y + parse_number(node.attrib.get("height"))) def collect_layout_boxes( node: ET.Element, styles: dict[str, dict[str, str]], ox: float = 0.0, oy: float = 0.0, inside_fraction: bool = False, ) -> list[Box]: tx, ty = parse_translate(node.attrib.get("transform")) ox += tx oy += ty if local_name(node.tag) == "g" and has_class(node, "fraction-g"): box = parse_data_box(node, ox, oy) return [box] if box else [] boxes: list[Box] = [] if not inside_fraction: if local_name(node.tag) == "text": box = text_box(node, styles, ox, oy) if box: boxes.append(box) else: box = shape_box(node, ox, oy) if box: boxes.append(box) for child in node: boxes.extend(collect_layout_boxes(child, styles, ox, oy, inside_fraction)) return boxes def validate_layout_collisions(path: Path, root: ET.Element, styles: dict[str, dict[str, str]]) -> list[Issue]: issues: list[Issue] = [] boxes = collect_layout_boxes(root, styles) for index, first in enumerate(boxes): for second in boxes[index + 1 :]: if first.kind != "fraction" and second.kind != "fraction": continue if first.kind == "text" or second.kind == "text": continue if overlaps(first, second): issues.append( Issue( "error", path, f"Chevauchement autour d'une fraction: {first.kind} {first.label!r} avec {second.kind} {second.label!r}.", ) ) elif first.kind == "fraction" and second.kind == "fraction" and horizontal_overlap(first, second): gap = vertical_gap(first, second) if gap < 12: issues.append( Issue( "error", path, f"Marge verticale insuffisante entre fractions: {first.label!r} et {second.label!r} ({gap:.1f}px).", ) ) return issues def validate_panel_containment( path: Path, node: ET.Element, styles: dict[str, dict[str, str]], ox: float = 0.0, oy: float = 0.0, ) -> list[Issue]: issues: list[Issue] = [] tx, ty = parse_translate(node.attrib.get("transform")) ox += tx oy += ty if local_name(node.tag) == "g": panel_children = [panel_box_from_rect(child, ox, oy) for child in node if panel_box_from_rect(child, ox, oy)] panel = panel_children[0] if len(panel_children) == 1 else None if panel: safe = Box("panel", panel.label, panel.x1, panel.y1, panel.x2, panel.y2) for box in collect_layout_boxes(node, styles, ox - tx, oy - ty): if box.kind == "shape" and box.label == "line": continue tolerance = 0.0 if box.kind == "fraction" else 70.0 if ( box.x1 < safe.x1 - tolerance or box.y1 < safe.y1 - tolerance or box.x2 > safe.x2 + tolerance or box.y2 > safe.y2 + tolerance ): issues.append( Issue( "error", path, f"Element hors panneau: {box.kind} {box.label!r} depasse la zone utile du panneau.", ) ) for child in node: issues.extend(validate_panel_containment(path, child, styles, ox, oy)) return issues 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 "data-box" not in group.attrib: issues.append(Issue("warning", path, "Une fraction-g doit declarer data-box pour les controles anti-chevauchement.")) 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) numerator_bottom = num_y + num_size * 0.25 denominator_top = den_y - den_size * 0.85 top_clearance = bar_y - numerator_bottom bottom_clearance = denominator_top - bar_y min_visible_clearance = 3.0 max_visible_clearance = max(14.0, num_size * 0.50) if top_clearance < min_visible_clearance: issues.append( Issue( "warning", path, f"Chevauchement visuel numerateur/barre ({top_clearance:.1f}px, attendu >= {min_visible_clearance:.1f}px).", ) ) if bottom_clearance < min_visible_clearance: issues.append( Issue( "warning", path, f"Chevauchement visuel barre/denominateur ({bottom_clearance:.1f}px, attendu >= {min_visible_clearance:.1f}px).", ) ) if top_clearance > max_visible_clearance: issues.append( Issue( "warning", path, f"Espace trop grand entre numerateur et barre ({top_clearance:.1f}px, attendu <= {max_visible_clearance:.1f}px).", ) ) if bottom_clearance > max_visible_clearance: issues.append( Issue( "warning", path, f"Espace trop grand entre barre et denominateur ({bottom_clearance:.1f}px, attendu <= {max_visible_clearance:.1f}px).", ) ) # La baseline du numérateur est volontairement plus proche du trait que celle # du dénominateur : c'est ce qui équilibre l'espace visible autour de la barre. min_top_gap = max(8.0, num_size * 0.24) min_bottom_gap = max(8.0, den_size * 0.30) max_top_gap = max(14.0, num_size * 0.50) max_bottom_gap = max(46.0, den_size * 1.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).", ) ) if bar_y - num_y > max_top_gap: issues.append( Issue( "warning", path, f"Espace trop grand entre numerateur et barre ({bar_y - num_y:.1f}px, attendu <= {max_top_gap:.1f}px).", ) ) if den_y - bar_y > max_bottom_gap: issues.append( Issue( "warning", path, f"Espace trop grand entre barre et denominateur ({den_y - bar_y:.1f}px, attendu <= {max_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 .")) 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) issues.extend(validate_layout_collisions(path, root, styles)) issues.extend(validate_panel_containment(path, root, styles)) 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 SUSPICIOUS_REPLACEMENT_RE.search(content): issues.append( Issue( "error", path, f"Caractère de remplacement suspect dans le texte 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())