Initial commit: svg backend
This commit is contained in:
187
backend/app/services/svg_display_processor.py
Normal file
187
backend/app/services/svg_display_processor.py
Normal file
@@ -0,0 +1,187 @@
|
||||
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
|
||||
Reference in New Issue
Block a user