mirror of
https://github.com/enzet/map-machine.git
synced 2025-05-04 12:46:41 +02:00
* Create icon shape specification with color. * Change icon generation rules. * Add height label processing.
137 lines
3.8 KiB
Python
137 lines
3.8 KiB
Python
"""
|
||
Extract icons from SVG file.
|
||
|
||
Author: Sergey Vartanov (me@enzet.ru).
|
||
"""
|
||
import re
|
||
from dataclasses import dataclass
|
||
from typing import Dict, Optional
|
||
from xml.dom.minidom import Document, Element, Node, parse
|
||
|
||
import numpy as np
|
||
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]})"
|
||
)
|
||
|
||
|
||
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.shapes: Dict[str, Shape] = {}
|
||
|
||
with open(svg_file_name) as input_file:
|
||
content: Document = parse(input_file)
|
||
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 re.match(STANDARD_INKSCAPE_ID, id_) is not None:
|
||
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.shapes[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.shapes:
|
||
return self.shapes[id_], True
|
||
|
||
ui.error(f"no such shape ID {id_}")
|
||
return self.shapes[DEFAULT_SHAPE_ID], False
|