529 lines
18 KiB
Python
529 lines
18 KiB
Python
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"Ã.|Â.|’|“|â€")
|
|
SUSPICIOUS_REPLACEMENT_RE = re.compile(r"[A-Za-zÀ-ÿ]\?|[?]\s*[A-Za-zÀ-ÿ]")
|
|
TRANSLATE_RE = re.compile(r"translate\(\s*([-\d.]+)(?:[\s,]+([-\d.]+))?\s*\)")
|
|
DATA_BOX_RE = re.compile(r"^\s*([-\d.]+)\s+([-\d.]+)\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
|
|
|
|
|
|
@dataclass
|
|
class Box:
|
|
kind: str
|
|
label: str
|
|
x1: float
|
|
y1: float
|
|
x2: float
|
|
y2: float
|
|
|
|
def padded(self, amount: float) -> "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 <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)
|
|
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())
|