188 lines
5.5 KiB
Python
188 lines
5.5 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from typing import Any
|
|
|
|
from lxml import etree
|
|
|
|
from app.core.config import settings
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
ALLOWED_MODES = {"passthrough", "optimized"}
|
|
|
|
|
|
def _parse_length(value: str | None) -> float | None:
|
|
if not value:
|
|
return None
|
|
cleaned = value.strip().replace("px", "")
|
|
try:
|
|
return float(cleaned)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _local_name(tag: str) -> str:
|
|
if "}" in tag:
|
|
return tag.split("}", 1)[1]
|
|
return tag
|
|
|
|
|
|
def _is_hidden(node: etree._Element) -> bool:
|
|
display = (node.attrib.get("display") or "").strip().lower()
|
|
visibility = (node.attrib.get("visibility") or "").strip().lower()
|
|
style = (node.attrib.get("style") or "").replace(" ", "").lower()
|
|
return (
|
|
display == "none"
|
|
or visibility == "hidden"
|
|
or "display:none" in style
|
|
or "visibility:hidden" in style
|
|
)
|
|
|
|
|
|
def _is_seat_related(node: etree._Element) -> bool:
|
|
probe = " ".join(
|
|
[
|
|
node.attrib.get("id", ""),
|
|
node.attrib.get("class", ""),
|
|
node.attrib.get("data-seat-id", ""),
|
|
node.attrib.get("data-sector-id", ""),
|
|
node.attrib.get("data-group-id", ""),
|
|
]
|
|
).lower()
|
|
return any(token in probe for token in ["seat", "sector", "group", "place"])
|
|
|
|
|
|
def _font_size(node: etree._Element) -> float | None:
|
|
direct = _parse_length(node.attrib.get("font-size"))
|
|
if direct is not None:
|
|
return direct
|
|
|
|
style = node.attrib.get("style") or ""
|
|
match = re.search(r"font-size\s*:\s*([0-9.]+)", style, flags=re.IGNORECASE)
|
|
if match:
|
|
return _parse_length(match.group(1))
|
|
return None
|
|
|
|
|
|
def _is_technical_text(node: etree._Element) -> bool:
|
|
patterns = [
|
|
item.strip().lower()
|
|
for item in settings.svg_display_technical_text_patterns.split(",")
|
|
if item.strip()
|
|
]
|
|
haystack = " ".join(
|
|
[
|
|
node.attrib.get("id", ""),
|
|
node.attrib.get("class", ""),
|
|
"".join(node.itertext()),
|
|
]
|
|
).lower()
|
|
return any(pattern in haystack for pattern in patterns)
|
|
|
|
|
|
def _force_viewbox(root: etree._Element) -> None:
|
|
if not settings.svg_display_force_viewbox:
|
|
return
|
|
if root.attrib.get("viewBox"):
|
|
return
|
|
|
|
width = _parse_length(root.attrib.get("width"))
|
|
height = _parse_length(root.attrib.get("height"))
|
|
if width and height:
|
|
w = int(width) if width.is_integer() else width
|
|
h = int(height) if height.is_integer() else height
|
|
root.attrib["viewBox"] = f"0 0 {w} {h}"
|
|
|
|
|
|
def _extract_meta(root: etree._Element) -> dict[str, Any]:
|
|
return {
|
|
"view_box": root.attrib.get("viewBox"),
|
|
"width": root.attrib.get("width"),
|
|
"height": root.attrib.get("height"),
|
|
}
|
|
|
|
|
|
def generate_display_svg(content: bytes, mode: str) -> tuple[bytes, dict[str, Any]]:
|
|
if mode not in ALLOWED_MODES:
|
|
raise ValueError(f"Unsupported display mode: {mode}")
|
|
|
|
parser = etree.XMLParser(
|
|
resolve_entities=False,
|
|
remove_blank_text=False,
|
|
remove_comments=False,
|
|
no_network=True,
|
|
recover=False,
|
|
huge_tree=True,
|
|
)
|
|
root = etree.fromstring(content, parser=parser)
|
|
|
|
defs_count = len(root.xpath("//*[local-name()='defs']"))
|
|
use_count = len(root.xpath("//*[local-name()='use']"))
|
|
style_count = len(root.xpath("//*[local-name()='style']"))
|
|
clip_count = len(root.xpath("//*[local-name()='clipPath']"))
|
|
|
|
logger.info(
|
|
"display_svg.generate mode=%s size_bytes=%s has_style=%s defs=%s use=%s clipPath=%s",
|
|
mode,
|
|
len(content),
|
|
bool(style_count),
|
|
defs_count,
|
|
use_count,
|
|
clip_count,
|
|
)
|
|
|
|
removed_hidden_count = 0
|
|
removed_small_text_count = 0
|
|
removed_technical_text_count = 0
|
|
|
|
if mode == "optimized":
|
|
for node in list(root.iter()):
|
|
tag_name = _local_name(node.tag)
|
|
|
|
if settings.svg_display_remove_hidden_elements and not _is_seat_related(node) and _is_hidden(node):
|
|
parent = node.getparent()
|
|
if parent is not None:
|
|
parent.remove(node)
|
|
removed_hidden_count += 1
|
|
continue
|
|
|
|
if tag_name in {"text", "tspan"}:
|
|
if settings.svg_display_hide_small_text and not _is_seat_related(node):
|
|
size = _font_size(node)
|
|
if size is not None and size < settings.svg_display_min_text_font_size:
|
|
parent = node.getparent()
|
|
if parent is not None:
|
|
parent.remove(node)
|
|
removed_small_text_count += 1
|
|
continue
|
|
|
|
if settings.svg_display_hide_technical_text and not _is_seat_related(node) and _is_technical_text(node):
|
|
parent = node.getparent()
|
|
if parent is not None:
|
|
parent.remove(node)
|
|
removed_technical_text_count += 1
|
|
continue
|
|
|
|
_force_viewbox(root)
|
|
|
|
output = etree.tostring(
|
|
root,
|
|
encoding="utf-8",
|
|
xml_declaration=True,
|
|
pretty_print=False,
|
|
)
|
|
|
|
meta = _extract_meta(root)
|
|
meta.update(
|
|
{
|
|
"mode": mode,
|
|
"removed_hidden_count": removed_hidden_count,
|
|
"removed_small_text_count": removed_small_text_count,
|
|
"removed_technical_text_count": removed_technical_text_count,
|
|
}
|
|
)
|
|
return output, meta
|