map-machine/map_machine/pictogram/icon.py
2023-08-11 03:20:06 +04:00

608 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Extract icons from SVG file."""
import json
import logging
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional
from xml.etree import ElementTree
from xml.etree.ElementTree import Element
import numpy as np
import svgwrite
from colour import Color
from svgwrite import Drawing
from svgwrite.base import BaseElement
from svgwrite.container import Group
from svgwrite.path import Path as SVGPath
from map_machine.color import is_bright
__author__ = "Sergey Vartanov"
__email__ = "me@enzet.ru"
DEFAULT_SHAPE_ID: str = "default"
DEFAULT_SMALL_SHAPE_ID: str = "default_small"
STANDARD_INKSCAPE_ID_MATCHER: re.Pattern = re.compile(
"^((circle|defs|ellipse|grid|guide|marker|metadata|path|rect|use)"
"[\\d-]+|base)$"
)
PATH_MATCHER: re.Pattern = re.compile("[Mm] ([0-9.e-]*)[, ]([0-9.e-]*)")
GRID_STEP: int = 16
USED_ICON_COLOR: str = "#000000"
UNUSED_ICON_COLORS: list[str] = ["#0000ff", "#ff0000"]
@dataclass
class Shape:
"""SVG icon path description."""
# String representation of SVG path commands.
path: str
# Vector that should be used to shift the path.
offset: np.ndarray
# Shape unique string identifier, e.g. `tree`.
id_: str
# Shape human-readable description.
name: Optional[str] = None
# If value is `None`, shape doesn't have distinct direction or its
# direction doesn't make sense. Shape is directed to the right if value is
# `True` and to the left if value is `False`.
#
# E.g. CCTV camera shape has direction and may be flipped horizontally to
# follow surveillance direction, whereas car shape has direction but
# flipping icon doesn't make any sense.
is_right_directed: Optional[bool] = None
# Set of emojis that represent the same entity. E.g. 🍐 (pear) for `pear`;
# 🍏 (green apple) and 🍎 (red apple) for `apple`.
emojis: set[str] = field(default_factory=set)
# If shape is used only as a part of other icons.
is_part: bool = False
# Hierarchical icon group. Is used for icon sorting.
group: str = ""
# Icon categories that is used in OpenStreetMap wiki. E.g. `barrier` means
# https://wiki.openstreetmap.org/wiki/Category:Barrier_icons.
categories: set[str] = field(default_factory=set)
@classmethod
def from_structure(
cls,
structure: dict[str, Any],
path: str,
offset: np.ndarray,
id_: str,
name: Optional[str] = None,
) -> "Shape":
"""
Parse shape description from structure.
:param structure: input structure
:param path: SVG path commands in string form
:param offset: shape offset in the input file
:param id_: shape unique identifier
:param name: shape text description
"""
shape: "Shape" = cls(path, offset, id_, name)
if "name" in structure:
shape.name = structure["name"]
if "directed" in structure:
if structure["directed"] == "right":
shape.is_right_directed = True
if structure["directed"] == "left":
shape.is_right_directed = False
if "emoji" in structure:
emojis = structure["emoji"]
shape.emojis = [emojis] if isinstance(emojis, str) else emojis
shape.is_part = structure.get("is_part", False)
shape.group = structure.get("group", "")
if "categories" in structure:
shape.categories = set(structure["categories"])
return shape
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,
point: np.ndarray,
offset: np.ndarray = np.array((0.0, 0.0)),
scale: np.ndarray = np.array((1.0, 1.0)),
) -> SVGPath:
"""
Draw icon into SVG file.
:param point: icon position
:param offset: additional offset
:param scale: scale resulting image
"""
transformations: list[str] = []
shift: np.ndarray = point + offset
transformations.append(f"translate({shift[0]},{shift[1]})")
if not np.allclose(scale, np.array((1.0, 1.0))):
transformations.append(f"scale({scale[0]},{scale[1]})")
transformations.append(f"translate({self.offset[0]},{self.offset[1]})")
return svgwrite.path.Path(
d=self.path, transform=" ".join(transformations)
)
def get_full_id(self) -> str:
"""Compute full shape identifier with group for sorting."""
return self.group + "_" + self.id_
def parse_length(text: str) -> float:
"""Parse length from SVG attribute."""
if text.endswith("px"):
text = text[:-2]
return float(text)
def verify_sketch_element(element: Element, id_: str) -> bool:
"""
Verify sketch SVG element from icon file.
:param element: sketch SVG element (element with standard Inkscape
identifier)
:param id_: element `id` attribute
:return: True iff SVG element has valid style
"""
if "style" not in element.attrib or not element.attrib["style"]:
return True
style: dict[str, str] = dict(
(x.split(":")[0], x.split(":")[1])
for x in element.attrib["style"].split(";")
)
# Sketch element (black 0.1 px stroke, no fill).
if (
style["fill"] == "none"
and style["stroke"] == "#000000"
and "stroke-width" in style
and np.allclose(parse_length(style["stroke-width"]), 0.1)
):
return True
# Sketch element (black 1 px stroke, no fill, 20% opacity).
if (
style["fill"] == "none"
and style["stroke"] == "#000000"
and "opacity" in style
and np.allclose(float(style["opacity"]), 0.2)
and (
"stroke-width" not in style
or np.allclose(parse_length(style["stroke-width"]), 0.7)
or np.allclose(parse_length(style["stroke-width"]), 1)
or np.allclose(parse_length(style["stroke-width"]), 2)
or np.allclose(parse_length(style["stroke-width"]), 3)
)
):
return True
# Experimental shape (blue or red fill, no stroke).
if (
style["fill"] in UNUSED_ICON_COLORS
and "stroke" in style
and style["stroke"] == "none"
):
return True
if style and not id_.startswith("use"):
return False
return True
def parse_configuration(root: dict, configuration: dict, group: str) -> None:
"""
Shape description is a probably empty dictionary with optional fields
`name`, `emoji`, `is_part`, `directed`, and `categories`. Shape
configuration is a dictionary that contains shape descriptions. Shape
descriptions may be grouped and the nesting level may be arbitrary:
{
<shape id>: {<shape description>},
<shape id>: {<shape description>},
<group>: {
<shape id>: {<shape description>},
<shape id>: {<shape description>}
},
<group>: {
<subgroup>: {
<shape id>: {<shape description>},
<shape id>: {<shape description>}
}
}
}
"""
for key, value in root.items():
if (
not value
or "name" in value
or "emoji" in value
or "is_part" in value
or "directed" in value
or "categories" in value
):
configuration[key] = value | {"group": group}
else:
parse_configuration(value, configuration, f"{group}_{key}")
class ShapeExtractor:
"""
Extract shapes from SVG file.
Shape is a single path with "id" attribute that aligned to 16×16 grid.
"""
def __init__(
self, svg_file_name: Path, configuration_file_name: Path
) -> None:
"""
:param svg_file_name: input SVG file name with icons. File may contain
any other irrelevant graphics.
:param configuration_file_name: JSON file with grouped shape
descriptions
"""
self.shapes: dict[str, Shape] = {}
self.configuration: dict[str, Any] = {}
parse_configuration(
json.load(configuration_file_name.open(encoding="utf-8")),
self.configuration,
"root",
)
root: Element = ElementTree.parse(svg_file_name).getroot()
self.parse(root)
for shape_id in self.configuration:
if shape_id not in self.shapes:
logging.warning(
f"Configuration for unknown shape `{shape_id}`."
)
def parse(self, node: Element) -> None:
"""
Extract icon paths into a map.
:param node: XML node that contains icon
"""
if node.tag.endswith("}g") or node.tag.endswith("}svg"):
for sub_node in node:
self.parse(sub_node)
return
if "id" not in node.attrib or not node.attrib["id"]:
return
id_: str = node.attrib["id"]
if STANDARD_INKSCAPE_ID_MATCHER.match(id_) is not None:
if not verify_sketch_element(node, id_):
path_part = ""
try:
path_part = f", {node.attrib['d'].split(' ')[:3]}."
except (KeyError, ValueError):
pass
logging.warning(f"Not verified SVG element `{id_}`{path_part}")
return
if "d" in node.attrib and node.attrib["d"]:
path: str = node.attrib["d"]
matcher = PATH_MATCHER.match(path)
if not matcher:
return
name: Optional[str] = None
def get_offset(value: str) -> float:
"""Get negated icon offset from the origin."""
return (
-int(float(value) / GRID_STEP) * GRID_STEP - GRID_STEP / 2.0
)
point: np.ndarray = np.array(
(get_offset(matcher.group(1)), get_offset(matcher.group(2)))
)
for child_node in node:
if isinstance(child_node, Element):
name = child_node.text
break
configuration: dict[str, Any] = {}
if id_ in self.configuration:
configuration = self.configuration[id_]
if "name" not in configuration:
logging.warning(f"Shape `{id_}` doesn't have name.")
else:
logging.warning(f"Shape `{id_}` doesn't have configuration.")
self.shapes[id_] = Shape.from_structure(
configuration, path, point, id_, name
)
else:
logging.error(f"Not standard ID {id_}.")
def get_shape(self, id_: str) -> Shape:
"""
Get shape or None if there is no shape with such identifier.
:param id_: string icon identifier
"""
if id_ in self.shapes:
return self.shapes[id_]
assert False, f"no shape with id {id_} in icons file"
@dataclass
class ShapeSpecification:
"""Specification for shape as a part of an icon."""
shape: Shape
color: Color
offset: np.ndarray = field(default_factory=lambda: np.array((0.0, 0.0)))
flip_horizontally: bool = False
flip_vertically: bool = False
use_outline: bool = True
def is_default(self) -> bool:
"""Check whether shape is default."""
return self.shape.id_ == DEFAULT_SHAPE_ID
def draw(
self,
svg: BaseElement,
point: np.ndarray,
tags: dict[str, Any] = None,
outline: bool = False,
outline_opacity: float = 1.0,
scale: float = 1.0,
) -> None:
"""
Draw icon shape into SVG file.
:param svg: output SVG file
:param point: 2D position of the shape centre
:param tags: tags to be displayed as a tooltip, if tooltip should not be
displayed, this argument should be None
:param outline: draw outline for the shape
:param outline_opacity: opacity of the outline
:param scale: scale icon by the magnitude
"""
scale_vector: np.ndarray = np.array((scale, scale))
if self.flip_vertically:
scale_vector = np.array((scale, -scale))
if self.flip_horizontally:
scale_vector = np.array((-scale, scale))
point: np.ndarray = np.array(list(map(int, point)))
path: SVGPath = self.shape.get_path(
point, self.offset * scale, scale_vector
)
path.update({"fill": self.color.hex})
if outline and self.use_outline:
bright: bool = is_bright(self.color)
color: Color = Color("black") if bright else Color("white")
style: dict[str, Any] = {
"fill": color.hex,
"stroke": color.hex,
"stroke-width": 2.2,
"stroke-linejoin": "round",
"opacity": outline_opacity,
}
path.update(style)
if tags:
title: str = "\n".join(map(lambda x: x + ": " + tags[x], tags))
path.set_desc(title=title)
svg.add(path)
def __eq__(self, other: "ShapeSpecification") -> bool:
return (
self.shape == other.shape
and self.color == other.color
and np.allclose(self.offset, other.offset)
)
def __lt__(self, other: "ShapeSpecification") -> bool:
return self.shape.id_ < other.shape.id_
@dataclass
class Icon:
"""Icon that consists of (probably) multiple shapes."""
shape_specifications: list[ShapeSpecification]
opacity: float = 1.0
def get_shape_ids(self) -> list[str]:
"""Get all shape identifiers in the icon."""
return [x.shape.id_ for x in self.shape_specifications]
def has_names(self) -> bool:
"""Check whether oll shape names are known."""
for specification in self.shape_specifications:
if not specification.shape.name:
return False
return True
def get_names(self) -> list[str]:
"""Get all shape names in the icon."""
return [
(x.shape.name if x.shape.name else "unknown")
for x in self.shape_specifications
]
def get_name(self) -> str:
"""Get combined human-readable icon name."""
names: list[str] = self.get_names()
if len(names) == 1:
return names[0]
return ", ".join(names[:-1]) + " and " + names[-1]
def has_categories(self) -> bool:
"""Check whether oll shape categories are known."""
for specification in self.shape_specifications:
if specification.shape.categories:
return True
return False
def get_categories(self) -> set[str]:
"""Get all shape names in the icon."""
result: set[str] = set()
for specification in self.shape_specifications:
result = result.union(specification.shape.categories)
return result
def draw(
self,
svg: svgwrite.Drawing,
point: np.ndarray,
tags: dict[str, Any] = None,
outline: bool = False,
scale: float = 1.0,
) -> None:
"""
Draw icon to SVG.
:param svg: output SVG file
:param point: 2D position of the icon centre
:param tags: tags to be displayed as a tooltip
:param outline: draw outline for the icon
:param scale: scale icon by the magnitude
"""
if outline:
bright: bool = is_bright(self.shape_specifications[0].color)
opacity: float = 0.7 if bright else 0.5
outline_group: Group = Group(opacity=opacity)
for shape_specification in self.shape_specifications:
shape_specification.draw(
outline_group, point, tags, True, scale=scale
)
svg.add(outline_group)
else:
group: Group = Group(opacity=self.opacity)
for shape_specification in self.shape_specifications:
shape_specification.draw(group, point, tags, scale=scale)
svg.add(group)
def draw_to_file(
self,
file_name: Path,
color: Optional[Color] = None,
outline: bool = False,
outline_opacity: float = 1.0,
) -> None:
"""
Draw icon to the SVG file.
:param file_name: output SVG file name
:param color: fill color
:param outline: if true, draw outline beneath the icon
:param outline_opacity: opacity of the outline
"""
svg: Drawing = Drawing(str(file_name), (16, 16))
if outline:
for shape_specification in self.shape_specifications:
if color:
shape_specification.color = color
shape_specification.draw(
svg,
np.array((8.0, 8.0)),
outline=outline,
outline_opacity=outline_opacity,
)
for shape_specification in self.shape_specifications:
if color:
shape_specification.color = color
shape_specification.draw(svg, np.array((8.0, 8.0)))
with file_name.open("w", encoding="utf-8") as output_file:
svg.write(output_file)
def is_default(self) -> bool:
"""Check whether first shape is default."""
return (
len(self.shape_specifications) == 1
and self.shape_specifications[0].is_default()
)
def recolor(self, color: Color, white: Optional[Color] = None) -> None:
"""Paint all shapes in the color."""
for shape_specification in self.shape_specifications:
if shape_specification.color == Color("white") and white:
shape_specification.color = white
else:
shape_specification.color = color
def add_specifications(
self, specifications: list[ShapeSpecification]
) -> None:
"""Add shape specifications to the icon."""
self.shape_specifications += specifications
def __eq__(self, other: "Icon") -> bool:
return sorted(self.shape_specifications) == sorted(
other.shape_specifications
)
def __lt__(self, other: "Icon") -> bool:
return "".join(
[x.shape.get_full_id() for x in self.shape_specifications]
) < "".join([x.shape.get_full_id() for x in other.shape_specifications])
@dataclass
class IconSet:
"""Node representation: icons and color."""
main_icon: Icon
extra_icons: list[Icon]
# Icon to use if the point is hidden by overlapped icons but still need to
# be shown.
default_icon: Optional[Icon]
# Tag keys that were processed to create icon set (other tag keys should be
# displayed by text or ignored)
processed: set[str]