svg correction
This commit is contained in:
@@ -10,7 +10,9 @@ 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 = {
|
||||
@@ -25,6 +27,19 @@ class Issue:
|
||||
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]
|
||||
|
||||
@@ -105,11 +120,122 @@ def estimated_text_width(text: str, size: float) -> float:
|
||||
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 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 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 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}.",
|
||||
)
|
||||
)
|
||||
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(
|
||||
@@ -187,6 +313,7 @@ def validate_svg(path: Path) -> list[Issue]:
|
||||
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))
|
||||
|
||||
for node in root.iter():
|
||||
if local_name(node.tag) == "text":
|
||||
@@ -208,6 +335,14 @@ def validate_svg(path: Path) -> list[Issue]:
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user