mirror of
https://github.com/enzet/map-machine.git
synced 2025-05-04 04:36:54 +02:00
189 lines
5.3 KiB
Python
189 lines
5.3 KiB
Python
"""
|
||
Extract icons from SVG file.
|
||
|
||
Author: Sergey Vartanov (me@enzet.ru).
|
||
"""
|
||
import re
|
||
from dataclasses import dataclass
|
||
from typing import Any, Dict, Optional
|
||
from xml.dom.minidom import Document, Element, Node, parse
|
||
|
||
import numpy as np
|
||
import svgwrite
|
||
from colour import Color
|
||
from svgwrite import Drawing
|
||
|
||
from roentgen import ui
|
||
|
||
DEFAULT_SHAPE_ID: str = "default"
|
||
DEFAULT_SMALL_SHAPE_ID: str = "default_small"
|
||
STANDARD_INKSCAPE_ID: str = (
|
||
"^((circle|defs|ellipse|metadata|path|rect|use)[\\d-]+|base)$"
|
||
)
|
||
|
||
GRID_STEP: int = 16
|
||
|
||
|
||
@dataclass
|
||
class Shape:
|
||
"""
|
||
SVG icon path description.
|
||
"""
|
||
|
||
path: str # SVG icon path
|
||
offset: np.array # vector that should be used to shift the path
|
||
id_: str # shape identifier
|
||
name: Optional[str] = None # icon description
|
||
|
||
def is_default(self) -> bool:
|
||
"""
|
||
Return true if icon is has a default shape that doesn't represent
|
||
anything.
|
||
"""
|
||
return self.id_ in [DEFAULT_SHAPE_ID, DEFAULT_SMALL_SHAPE_ID]
|
||
|
||
def get_path(self, svg: Drawing, point: np.array):
|
||
"""
|
||
Draw icon into SVG file.
|
||
|
||
:param svg: SVG file to draw to
|
||
:param point: icon position
|
||
"""
|
||
shift: np.array = self.offset + point
|
||
|
||
return svg.path(
|
||
d=self.path, transform=f"translate({shift[0]},{shift[1]})"
|
||
)
|
||
|
||
def draw(
|
||
self, svg: svgwrite.Drawing, point: np.array, color: Color,
|
||
opacity: float = 1.0, tags: Dict[str, Any] = None, outline: bool = False
|
||
) -> None:
|
||
"""
|
||
Draw icon shape into SVG file.
|
||
|
||
:param svg: output SVG file
|
||
:param point: 2D position of the icon centre
|
||
:param color: fill color
|
||
:param opacity: icon opacity
|
||
:param tags: tags to be displayed as hint
|
||
:param outline: draw outline for the icon
|
||
"""
|
||
point = np.array(list(map(int, point)))
|
||
|
||
path: svgwrite.path.Path = self.get_path(svg, point)
|
||
path.update({"fill": color.hex})
|
||
if outline:
|
||
opacity: float = 0.5
|
||
|
||
path.update({
|
||
"fill": color.hex,
|
||
"stroke": color.hex,
|
||
"stroke-width": 2.2,
|
||
"stroke-linejoin": "round",
|
||
})
|
||
if opacity != 1.0:
|
||
path.update({"opacity": opacity})
|
||
if tags:
|
||
title: str = "\n".join(map(lambda x: x + ": " + tags[x], tags))
|
||
path.set_desc(title=title)
|
||
svg.add(path)
|
||
|
||
|
||
def is_standard(id_: str):
|
||
"""
|
||
Check whether SVG object ID is standard Inkscape ID.
|
||
"""
|
||
matcher = re.match(STANDARD_INKSCAPE_ID, id_)
|
||
return matcher is not None
|
||
if id_ == "base":
|
||
return True
|
||
for prefix in [
|
||
]:
|
||
matcher = re.match(prefix + "\\d+", id_)
|
||
if matcher:
|
||
return True
|
||
return False
|
||
|
||
|
||
class IconExtractor:
|
||
"""
|
||
Extract icons from SVG file.
|
||
|
||
Icon is a single path with "id" attribute that aligned to 16×16 grid.
|
||
"""
|
||
|
||
def __init__(self, svg_file_name: str):
|
||
"""
|
||
:param svg_file_name: input SVG file name with icons. File may contain
|
||
any other irrelevant graphics.
|
||
"""
|
||
self.icons: Dict[str, Shape] = {}
|
||
|
||
with open(svg_file_name) as input_file:
|
||
content = parse(input_file) # type: Document
|
||
for element in content.childNodes: # type: Element
|
||
if element.nodeName != "svg":
|
||
continue
|
||
for node in element.childNodes: # type: Node
|
||
if isinstance(node, Element):
|
||
self.parse(node)
|
||
|
||
def parse(self, node: Element) -> None:
|
||
"""
|
||
Extract icon paths into a map.
|
||
|
||
:param node: XML node that contains icon
|
||
"""
|
||
if node.nodeName == "g":
|
||
for sub_node in node.childNodes:
|
||
if isinstance(sub_node, Element):
|
||
self.parse(sub_node)
|
||
return
|
||
|
||
if not node.hasAttribute("id") or not node.getAttribute("id"):
|
||
return
|
||
|
||
id_: str = node.getAttribute("id")
|
||
if is_standard(id_):
|
||
return
|
||
|
||
if node.hasAttribute("d"):
|
||
path: str = node.getAttribute("d")
|
||
matcher = re.match("[Mm] ([0-9.e-]*)[, ]([0-9.e-]*)", path)
|
||
if not matcher:
|
||
return
|
||
|
||
name: Optional[str] = None
|
||
|
||
def get_offset(value: float):
|
||
"""
|
||
Get negated icon offset from the origin.
|
||
"""
|
||
return -int(value / GRID_STEP) * GRID_STEP - GRID_STEP / 2
|
||
|
||
point: np.array = np.array((
|
||
get_offset(float(matcher.group(1))),
|
||
get_offset(float(matcher.group(2))),
|
||
))
|
||
|
||
for child_node in node.childNodes:
|
||
if isinstance(child_node, Element):
|
||
name = child_node.childNodes[0].nodeValue
|
||
break
|
||
|
||
self.icons[id_] = Shape(path, point, id_, name)
|
||
else:
|
||
ui.error(f"not standard ID {id_}")
|
||
|
||
def get_path(self, id_: str) -> (Shape, bool):
|
||
"""
|
||
Get SVG path of the icon.
|
||
|
||
:param id_: string icon identifier
|
||
"""
|
||
if id_ in self.icons:
|
||
return self.icons[id_], True
|
||
|
||
ui.error(f"no such icon ID {id_}")
|
||
return self.icons[DEFAULT_SHAPE_ID], False
|