# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Felix Faber /
# Otto von Guericke University Magdeburg, Thermal Process Engineering
import abc
import random
from pathlib import Path
from typing import Self
import numpy as np
from porescene.color import Color
from porescene.color.gradient import SmoothGradient
from porescene.utility import CompassDirection, MultiplicationSymbol, Orientation
[docs]
class Overlay(abc.ABC):
pass
[docs]
class BackgroundOverlay(Overlay):
"""
Base class for different types of layouts.
You can use this class to create a plain unicolored image without anything
else. It is also possible to add an bitmap image like PNG to the center.
Parameters
----------
res
Size of the image in pixels. The first entry specifies width, second
height.
pad
The padding of the image. Does not apply to the background color.
Examples
--------
This example creates the most simply overlay: just a simple colored image.
.. code-block::
:linenos:
:caption: Create a simple colored image.
from collib import Color
res = (1600, 900)
fpath = Path("unicolor_image.svg")
ovl = BackgroundOverlay(res)
ovl.background = Color("#382655")
ovl.save(fpath)
"""
_xml_declaration = '<?xml version="1.0" encoding="UTF-8"?>\n'
def __init__(
self,
pth: Path,
res: tuple[int, int] = (4096, 4096),
pad: tuple[int, int, int, int] = (100, 100, 100, 100),
bg: Color = Color("#FFF"),
):
self.path = pth
self.resolution = res
self.padding = pad
self.color_background = bg
[docs]
def get_svg(self) -> str:
"""
Returns the SVG element without XML declaration.
Get the <svg> element with all its childen elements.
"""
svg = self._get_tag_svg_open()
svg += self._build()
svg += "</svg>"
return svg
[docs]
def save(self) -> Self:
"""
Saves the SVG image as file with specified filename.
"""
with self.path.open(mode="w+", encoding="utf-8") as file:
file.write(self._xml_declaration)
file.write(self.get_svg())
return self
def _build(self) -> str:
svg = ""
if self.color_background:
svg += self._get_tag_background()
return svg
def _get_tag_background(self):
return ""
return (
f'<rect id="background" x="0" y="0" width="100%" '
f'height="100%" fill="{self.color_background.str_rgba}"/>'
)
def _get_tag_svg_open(self):
return (
f'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="'
f'http://www.w3.org/1999/xlink" width="4096px" height="4096px" '
f'viewBox="0 0 {self.resolution[0]} {self.resolution[1]} " '
f'font-weight="400">'
)
@property
def color_background(self) -> Color:
"""
Image background color as :class:`Color` object or `None` for
transparent background.
"""
return self._background
@color_background.setter
def color_background(self, arg: Color):
self._background = arg
@property
def padding(self) -> tuple[int, int, int, int]:
"""
Padding of the image contained in a 4-element `tuple` of
`int` in following order: top, right, bottom, left.
The padding does not apply to the background color.
"""
return self._padding
@padding.setter
def padding(self, arg: tuple[int, int, int, int]):
"""
Padding of the image, i.e. the width of the outer blank space of the image.
Padding must always grater than zero.
"""
if not all([x > 0 for x in arg]):
raise ValueError(f"{__name__}.padding must be positive")
self._padding = arg
@property
def resolution(self) -> tuple[int, int]:
"""
Resolution of the SVG file denoted as 2-element `tuple` containing
width ad height.
"""
return self._resolution
@resolution.setter
def resolution(self, arg: tuple[int, int]):
if arg[0] < 1 or arg[1] < 1:
raise ValueError(f"{__name__}.resolution must be greater than zero")
self._resolution = arg
[docs]
class TitleOverlay(BackgroundOverlay):
"""
Create a overlay image with heading, subheading an some lines of text.
Parameters
----------
res
Size of the image in pixels. The first entry specifies width, second
height.
Examples
--------
>>> from fefacolors import gray, red, light3
>>> ovl = TitleOverlay()
>>> ovl.heading = "My super topic!"
>>> ovl.subheading = "Maybe its only interesting for me, not for you?"
>>> ovl.text = [
"I do not know what to write here.",
"It's only a few lines",
"of text nobody cares."]
>>> ovl.color_text = gray
>>> ovl.color_heading = red
>>> ovl.background = light3
>>> ovl.align = 'SE'
>>> ovl.save('mytitle')
*Look for the save file!*
"""
def __init__(
self,
pth: Path,
res: tuple[int, int] = (4096, 4096),
pad: tuple[int, int, int, int] = (100, 100, 100, 100),
):
super().__init__(pth, res, pad)
self.align = CompassDirection.NORTH
self.color_heading = Color("#000")
self.color_subheading = Color("#000")
self.color_text = Color("#000")
self.font_family = "Inter, Arial, Helvetica Neue, Helvetica, sans-serif"
self.font_size_heading = 150
self.font_size_subheading = 100
self.font_size_text = 55
self.heading = None
self.line_height = 1.15
self.spacing = 25
self.subheading = None
self.text = []
def _build(self) -> str:
(
self._x,
self._y,
sign_x,
sign_y,
anchor,
baseline,
) = self._get_alignment_config()
svg = super()._build()
if self.heading:
svg += self._get_tag_heading(anchor, baseline)
self._y += self.font_size_heading * self._line_height * sign_y
if self.subheading:
svg += self._get_tag_subheading(anchor, baseline)
self._y += self.font_size_subheading * self._line_height * sign_y
if self.text:
svg += self._get_tag_text(sign_y, anchor, baseline)
return svg
def _get_tag_heading(self, a, b):
return (
f'<text id="title" x="{self._x}" y="{self._y}" dominant-baseline="{b}" '
f'text-anchor="{a}" fill="{self.color_heading.str_hex}" font-weight="400" '
f'font-family="{self.font_family}" font-size="{self.font_size_heading}">'
f"{self.heading}</text>"
)
def _get_tag_subheading(self, a, b):
return (
f'<text id="title" x="{self._x}" y="{self._y}" dominant-baseline="{b}" '
f'text-anchor="{a}" fill="{self.color_subheading.str_hex}" '
f'font-weight="500" font-family="{self.font_family}" '
f'font-size="{self.font_size_subheading}">{self.subheading}</text>'
)
def _get_tag_text(self, sign_y, a, b):
text = reversed(self.text) if sign_y == -1 else self.text
tag = ""
y = self._y
for i, line in enumerate(text):
tag += (
f'<text id="text-line-{i}" x="{self._x}" y="{y}" '
f'dominant-baseline="{b}" text-anchor="{a}" '
f'fill="{self.color_text.str_hex}" font-weight="400" '
f'font-family="{self.font_family}" font-size="{self.font_size_text}">'
f"{line}</text>"
)
y += self.font_size_text * self._line_height * sign_y
self._y = y
return tag
def _get_alignment_config(self):
match self.align:
case CompassDirection.SOUTHWEST:
x = self.padding[3]
y = self.resolution[1] - self.padding[2]
sign_x = 1
sign_y = -1
anchor = "start"
baseline = "auto"
case CompassDirection.SOUTH:
x = (
self.padding[3]
+ (self.resolution[0] - self.padding[1] - self.padding[3]) / 2
)
y = self.resolution[1] - self.padding[2]
sign_x = 1
sign_y = -1
anchor = "middle"
baseline = "auto"
case CompassDirection.SOUTHEAST:
x = self.resolution[0] - self.padding[1]
y = self.resolution[1] - self.padding[2]
sign_x = -1
sign_y = -1
anchor = "end"
baseline = "auto"
case CompassDirection.NORTHWEST:
x = self.padding[3]
y = self.padding[0]
sign_x = 1
sign_y = 1
anchor = "start"
baseline = "hanging"
case CompassDirection.NORTH:
x = (
self.padding[3]
+ (self.resolution[0] - self.padding[1] - self.padding[3]) / 2
)
y = self.padding[0]
sign_x = 1
sign_y = 1
anchor = "middle"
baseline = "hanging"
case CompassDirection.NORTHEAST:
x = self.resolution[0] - self.padding[1]
y = self.padding[0]
sign_x = -1
sign_y = 1
anchor = "end"
baseline = "hanging"
case CompassDirection.WEST:
x = self.padding[3]
y = self.padding[0]
sign_x = 1
sign_y = 1
anchor = "start"
baseline = "auto"
case CompassDirection.EAST:
x = self.resolution[0] - self.padding[1]
y = self.padding[0]
sign_x = -1
sign_y = 1
anchor = "end"
baseline = "auto"
return x, y, sign_x, sign_y, anchor, baseline
@property
def font_family(self) -> str:
"""
Font family used for headings and text.
"""
return self._font_family
@font_family.setter
def font_family(self, arg: str):
self._font_family = arg
@property
def font_size_heading(self) -> float:
"""
Font size of the heading.
"""
return self._font_size_heading
@font_size_heading.setter
def font_size_heading(self, arg: float):
self._font_size_heading = arg
@property
def font_size_subheading(self) -> float:
"""
Font size of the subheading.
"""
return self._font_size_subheading
@font_size_subheading.setter
def font_size_subheading(self, arg: float):
self._font_size_subheading = arg
@property
def font_size_text(self) -> float:
"""
Font size of the text.
"""
return self._font_size_text
@font_size_text.setter
def font_size_text(self, arg: float):
self._font_size_text = arg
@property
def heading(self) -> str | None:
"""
Headline of the image.
The text is displayed in bold typeface in a single line.
It should not be too long.
"""
return self._heading
@heading.setter
def heading(self, arg: str | None):
self._heading = arg
@property
def line_height(self) -> float:
"""Line height of texts"""
return self._line_height
@line_height.setter
def line_height(self, arg: float):
self._line_height = arg
@property
def align(self) -> CompassDirection:
"""
Location to place the text on the image.
There are 4 available options to choose: 'NW', 'NE', 'SE', 'SW' (like the
direction of compass). The default align is 'NW'.
"""
return self._align
@align.setter
def align(self, arg: CompassDirection):
self._align = arg
@property
def subheading(self) -> str | None:
"""
Second headline of the image.
The text is displayed in bold typeface but smaller font-size a heading in a
single line.
It should not be too long. You can minimize the property `font_size_subheading`
to fit more word in the line.
"""
return self._subheading
@subheading.setter
def subheading(self, arg: str | None):
if not isinstance(arg, str) and arg is not None:
raise TypeError(f"{__name__}.subheading expected 'str' or 'None'")
self._subheading = arg
@property
def text(self) -> list[str]:
"""
Multiline text on the image.
Since linebreaks are not inserted automatically by SVG, a list of
strings each representing a line is required.
"""
return self._text
@text.setter
def text(self, arg: list[str]):
self._text = arg
@property
def color_heading(self) -> Color:
"""
Color of the heading as :class:`Color`.
"""
return self._color_heading
@color_heading.setter
def color_heading(self, arg: Color):
self._color_heading = arg
@property
def color_subheading(self) -> Color:
"""
Color of the subheading as :class:`Color` object.
"""
return self._color_subheading
@color_subheading.setter
def color_subheading(self, arg: Color):
self._color_subheading = arg
@property
def color_text(self) -> Color:
"""
Color of the text as :class:`Color` object.
"""
return self._color_text
@color_text.setter
def color_text(self, arg: Color):
self._color_text = arg
@property
def spacing(self) -> float:
"""
Space between elements.
"""
return self._spacing
@spacing.setter
def spacing(self, arg: float):
self._spacing = arg
[docs]
class Gradient(TitleOverlay, abc.ABC):
"""
Create an overlay image with heading, subheading, text and a gradient
with scaclebars.
Parameters
----------
project
Directory of the current project. All other specified filepath are
relative to this directory.
res
Size of the image in pixels. The first entry specifies width, second
height.
Examples
--------
>>> ovl = Gradient('myproject')
>>> ovl.heading = 'My beautiful work:'
>>> ovl.subheading = 'You can see a '
>>>
"""
def __init__(
self,
pth: Path,
res: tuple[int, int] = (4096, 4096),
pad: tuple[int, int, int, int] = (100, 100, 100, 100),
):
super().__init__(pth, res, pad)
self.color_ticks = Color("#000")
self.color_nan = None
self.font_size_ticks = 90
self.exponent = None
self.gradient_colors = []
self.gradient_height = 120
self.gradient_length = 2000
self.line_width = 12
self.orientation = Orientation.VERTICAL
self.roundness = 10
self.seperator_exponent = MultiplicationSymbol.CROSS
self.seperator_decimal = "DOT"
self.text_nan = "NaN"
self.tick_length = 50
self.ticks = []
def _build(self):
x, y, sign_x, sign_y, anchor, baseline = self._get_alignment_config()
self._x = x
self._y = y
svg, id_grad = self._get_tag_defs()
svg += super()._build()
if any([self.text, self.heading, self.subheading]):
self._y += self.spacing * sign_y
svg += "<g>"
if self.gradient_colors:
svg += self._get_tag_gradient(self._x, self._y, id_grad)
if self.orientation is Orientation.VERTICAL:
self._x += (self.gradient_height + self.spacing) * sign_x
if self.orientation is Orientation.HORIZONTAL:
self._y += (self.gradient_height + self.spacing) * sign_y
if self.ticks:
svg += self._get_tag_axis(self._x, self._y, sign_x, sign_y)
svg += self._get_tag_ticks(self._x, self._y, sign_x, sign_y)
if self.orientation is Orientation.VERTICAL:
self._x += (
self.tick_length
+ self.spacing * 3
+ 29 * (sum(c.isdigit() for c in str(self.ticks[-1])))
) * sign_x
if "." in str(self.ticks[-1]):
self._x += 15 * sign_x
if "N" in self.align.value:
self._y += self.gradient_length
if self.orientation is Orientation.HORIZONTAL:
self._x += (self.gradient_length + self.spacing * 3) * sign_x
self._y += (self.tick_length + self.spacing) * sign_y
if self.exponent:
svg += self._get_tag_exponent()
if self.orientation is Orientation.VERTICAL:
self._x = x
if "E" in self.align.value:
self._x -= self.gradient_height
self._y += self.spacing * sign_y
if "S" in self.align.value:
self._y -= self.gradient_length + self.gradient_height
if self.orientation is Orientation.HORIZONTAL:
self._x = x
if "E" in self.align.value:
self._x -= self.gradient_height
self._y += self.spacing * sign_y
if "N" in self.align.value:
self._y += self.font_size_ticks * 0.5 + self.spacing
if "S" in self.align.value:
self._y -= (
self.font_size_ticks * 0.5 + self.tick_length + 2 * self.spacing
)
if "E" in self.align.value:
self._y += self.tick_length * 0.5 + self.spacing
if self.color_nan:
svg += self._get_tag_nan(sign_x, sign_y)
svg += "</g>"
return svg
@abc.abstractmethod
def _get_tag_defs(self) -> tuple[str, int]:
"""<defs>"""
# @abc.abstractmethod
# def _get_tag_gradient(self, x, y):
# """<rect />"""
def _get_tag_axis(self, x, y, sign_x, sign_y):
if self.orientation is Orientation.HORIZONTAL:
y1 = y2 = y
x1 = x + (self.line_width / 2) * sign_x
x2 = x + (self.gradient_length - self.line_width / 2) * sign_x
if (
self.align is CompassDirection.NORTH
or self.align is CompassDirection.SOUTH
):
x1 -= self.gradient_length / 2
x2 -= self.gradient_length / 2
else:
y1 = y + (self.line_width / 2) * sign_y
y2 = y + (self.gradient_length - self.line_width / 2) * sign_y
x1 = x2 = x
return (
f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke-linecap="round" '
f'stroke="{self.color_ticks.str_hex}" stroke-width="{self.line_width}">'
f"</line>"
)
def _get_tag_ticks(self, x, y, sign_x, sign_y):
svg = ""
w = (
self.gradient_length
if self.orientation is Orientation.HORIZONTAL
else self.gradient_height
)
h = (
self.gradient_length
if self.orientation is Orientation.VERTICAL
else self.gradient_height
)
step = self.gradient_length / (len(self.ticks) - 1)
if "E" in self.align.value and self.orientation is Orientation.HORIZONTAL:
x -= w
if "S" in self.align.value and self.orientation is Orientation.VERTICAL:
y -= h
if self.align is CompassDirection.NORTH or self.align is CompassDirection.SOUTH:
x -= w / 2
ticks = self.ticks
if self.orientation is Orientation.VERTICAL:
ticks.reverse()
for i, tick in enumerate(ticks):
if self.seperator_decimal == "COMMA":
tick = str(tick).replace(".", ",")
a = (
"middle"
if self.orientation is Orientation.HORIZONTAL
else ("start" if "W" in self.align.value else "end")
)
b = (
"middle"
if self.orientation is Orientation.VERTICAL
else ("hanging" if "N" in self.align.value else "auto")
)
if self.orientation is Orientation.HORIZONTAL:
x1 = x2 = x_tick = x
y1 = y2 = y
y2 += self.tick_length * sign_y
y_tick = y2 + self.spacing * sign_y
else:
y1 = y2 = y_tick = y
x1 = x2 = x
x2 += self.tick_length * sign_x
x_tick = x2 + self.spacing * sign_x
if i == 0:
if self.orientation is Orientation.HORIZONTAL:
# a = "start"
x1 = x2 = x_tick = x + self.line_width / 2
if self.orientation is Orientation.VERTICAL:
# b = "hanging"
y1 = y2 = y_tick = y + self.line_width / 2
if i == len(self.ticks) - 1:
if self.orientation is Orientation.HORIZONTAL:
# a = "end"
x1 = x2 = x_tick = x - self.line_width / 2
if self.orientation is Orientation.VERTICAL:
# b = "auto"
y1 = y2 = y_tick = y - self.line_width / 2
svg += (
f'<line id="tick-{i}" x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" '
f'stroke-linecap="round" stroke="{self.color_ticks.str_hex}" '
f'stroke-width="{self.line_width}"></line>'
)
svg += (
f'<text id="tick-label-{i}" x="{x_tick}" y="{y_tick}" '
f'fill="{self.color_ticks.str_hex}" font-family="{self.font_family}" '
f'font-weight="400" '
f'font-size="{self.font_size_ticks}" text-anchor="{a}" '
f'dominant-baseline="{b}">{tick}</text>'
)
if self.orientation is Orientation.VERTICAL:
y += step
else:
x += step
return svg
def _get_tag_exponent(self):
sep = self.seperator_exponent.value
exponent = str(self.exponent)
if self.seperator_decimal == "COMMA":
exponent = exponent.replace(".", ",")
anchor = "start" if "W" in self.align.value else "end"
if self.orientation is Orientation.VERTICAL:
baseline = "auto"
else:
baseline = "auto" if "S" in self.align.value else "hanging"
transform = (
"translate(0 0)"
if self.orientation is Orientation.HORIZONTAL
else "translate(0 4)"
)
if "E" in self.align.value:
return (
f'<text transform="{transform}" id="exponent" x="{self._x}" '
f'y="{self._y}" fill="{self.color_ticks.str_hex}" '
f'font-family="{self.font_family}" font-size="{self.font_size_ticks}" '
f'text-anchor="{anchor}" dominant-baseline="{baseline}"><tspan '
f'dominant-baseline="{baseline}">10</tspan><tspan dy="-15" '
f'dominant-baseline="{baseline}" '
f'font-size="{self.font_size_ticks * 0.8}">{exponent}</tspan><tspan '
f'dy="15" dominant-baseline="{baseline}"> {sep}</tspan></text>'
)
else:
return (
f'<text transform="{transform}" id="exponent" x="{self._x}" '
f'y="{self._y}" fill="{self.color_ticks.str_hex}" '
f'font-family="{self.font_family}" font-size="{self.font_size_ticks}" '
f'text-anchor="{anchor}" dominant-baseline="{baseline}">{sep} 10<tspan '
f'dy="-15" dominant-baseline="{baseline}" '
f'font-size="{self.font_size_ticks * 0.8}">{exponent}</tspan></text>'
)
def _get_tag_nan(self, sign_x, sign_y):
anchor = "start" if "W" in self.align.value else "end"
color_nan = self.color_nan.str_rgb if self.color_nan else ""
tag = (
f'<rect x="{self._x}" y="{self._y}" rx="{self.roundness}" '
f'ry="{self.roundness}" width="{self.gradient_height}" '
f'height="{self.gradient_height}" fill="{color_nan}"/>'
)
self._x += self.spacing * sign_x
if "W" in self.align.value:
self._x += self.gradient_height * sign_x
if self.align is CompassDirection.NORTH or self.align is CompassDirection.SOUTH:
self._x -= self.gradient_height * 0.5 + self.spacing
self._y += self.gradient_height / 2
tag += (
f'<text x="{self._x}" y="{self._y}" dy="6" '
f'fill="{self.color_ticks.str_hex}" dominant-baseline="middle" '
f'text-anchor="{anchor}" font-family="{self.font_family}" '
f'font-size="{self.font_size_ticks}">{self.text_nan}</text>'
)
return tag
def _get_tag_gradient(self, x, y, hsh):
w = (
self.gradient_length
if self.orientation is Orientation.HORIZONTAL
else self.gradient_height
)
h = (
self.gradient_length
if self.orientation is Orientation.VERTICAL
else self.gradient_height
)
if "E" in self.align.value:
x -= w
if "S" in self.align.value:
y -= h
if self.align is CompassDirection.NORTH or self.align is CompassDirection.SOUTH:
x -= w / 2
return (
f'<rect id="gradient" x="{x}" y="{y}" rx="{self.roundness}" '
f'ry="{self.roundness}" width="{w}" height="{h}" '
f'fill="url(#gradient-{hsh})"/>'
)
@property
def align(self) -> CompassDirection:
"""
Location to place the text on the image.
There are 4 available options to choose: 'NW', 'NE', 'SE',
'SW' (like the direction of compass).
The default align is 'NW'.
"""
return self._align
@align.setter
def align(self, arg: CompassDirection):
self._align = arg
@property
def color_nan(self) -> Color | None:
"""
:class:`Color` of NaN values.
If `None`, the label won't beshown on the image.
"""
return self._color_nan
@color_nan.setter
def color_nan(self, arg: Color | None):
self._color_nan = arg
@property
def color_ticks(self) -> Color:
"""
:class:`Color` of the ticks.
"""
return self._color_ticks
@color_ticks.setter
def color_ticks(self, arg: Color):
self._color_ticks = arg
@property
def exponent(self) -> float | int | str | None:
"""Exponent of the factor of the scale."""
return self._exponent
@exponent.setter
def exponent(self, arg: int | float | str | None):
if not (isinstance(arg, float | int | str) or arg is None):
raise TypeError(
f"{__name__}.exponent expected 'int', 'float', 'str' or 'None'"
)
self._exponent = arg
@property
def font_size_ticks(self) -> float:
"""
Font size of the color gradient axis ticks.
"""
return self._font_size_ticks
@font_size_ticks.setter
def font_size_ticks(self, arg: float):
self._font_size_ticks = arg
@property
def gradient_colors(self) -> list[Color]:
"""
Colors of the gradient bar.
"""
return self._gradient_colors
@gradient_colors.setter
def gradient_colors(self, arg: list[Color]):
self._gradient_colors = arg
@property
def gradient_height(self) -> float:
"""
Height of the gradient bar (always referred to horizontal
orientation).
"""
return self._gradient_height
@gradient_height.setter
def gradient_height(self, arg: float):
self._gradient_height = arg
@property
def gradient_length(self) -> float:
"""
Length of the gradient bar (always referred to horizontal
orientation).
"""
return self._gradient_length
@gradient_length.setter
def gradient_length(self, arg: float):
self._gradient_length = arg
@property
def line_width(self) -> float:
"""
Line width of the scalebar.
"""
return self._line_width
@line_width.setter
def line_width(self, arg: float):
self._line_width = arg
@property
def orientation(self) -> Orientation:
"""
Orientation of the text flow and gradient bar on the image.
There are 2 available options to choose: 'H', 'V'.
The default orientation is 'V'.
"""
return self._orientation
@orientation.setter
def orientation(self, arg: Orientation):
self._orientation = arg
@property
def roundness(self) -> float:
"""
Roundness of the lines of the scalebar.
"""
return self._roundness
@roundness.setter
def roundness(self, arg: float):
self._roundness = arg
@property
def spacing(self) -> float | int:
"""
Space between the individual components (heading, subheading,
gradient bar...).
"""
return self._spacing
@spacing.setter
def spacing(self, arg: float | int):
self._spacing = arg
@property
def seperator_decimal(self) -> str:
"""
The decimal seperator of numbers of the scalebar can be either
'COMMA' or 'DOT'.
"""
return self._seperator_decimal
@seperator_decimal.setter
def seperator_decimal(self, arg: str):
self._seperator_decimal = arg
@property
def seperator_exponent(self) -> MultiplicationSymbol:
"""
The multiplication sign of the scalebar exponent can be either
'CROSS' or 'DOT'.
"""
return self._seperator_exponent
@seperator_exponent.setter
def seperator_exponent(self, arg: MultiplicationSymbol):
self._seperator_exponent = arg
@property
def text_nan(self) -> str:
"""Description of the NaN label. Default is 'NaN'."""
return self._text_nan
@text_nan.setter
def text_nan(self, arg: str):
self._text_nan = arg
@property
def tick_length(self) -> float:
"""
Length of the ticks.
"""
return self._tick_length
@tick_length.setter
def tick_length(self, arg: float):
self._tick_length = arg
@property
def ticks(self) -> list[str]:
"""
Ticks of the color scalebar.
"""
return self._ticks
@ticks.setter
def ticks(self, arg: list[str]):
self._ticks = arg
[docs]
class SmoothGradientOverlay(Gradient):
""" """
def __init__(
self,
pth: Path,
res: tuple[int, int] = (4096, 4096),
pad: tuple[int, int, int, int] = (100, 100, 100, 100),
) -> None:
super().__init__(pth, res, pad)
def _get_tag_defs(self):
id_grad = random.getrandbits(32)
p2 = (0, 1) if self.orientation is Orientation.VERTICAL else (1, 0)
grad = SmoothGradient(self.gradient_colors)
tag = "<defs>"
tag += (
f'<linearGradient id="gradient-{id_grad}" x1="0" y1="0" '
f'x2="{p2[0]}" y2="{p2[1]}">'
)
for value in np.linspace(0.0, 1.0, 100):
tag += (
f'<stop offset="{round(value * 100, 6)}%" '
f'stop-color="{grad(value)[0].str_rgb}"/>'
)
tag += "</linearGradient>"
tag += "</defs>"
return tag, id_grad
[docs]
class SegmentedGradientOverlay(Gradient):
""" """
def __init__(
self,
pth: Path,
res: tuple[int, int] = (4096, 4096),
pad: tuple[int, int, int, int] = (100, 100, 100, 100),
) -> None:
super().__init__(pth, res, pad)
def _get_tag_defs(self):
id_grad = random.getrandbits(32)
p2 = (0, 1) if self.orientation is Orientation.VERTICAL else (1, 0)
tag = "<defs>"
tag += (
f'<linearGradient id="gradient-{id_grad}" x1="0" y1="0" '
f'x2="{p2[0]}" y2="{p2[1]}">'
)
length = len(self.gradient_colors)
frag = 1 / length
for i in range(length):
tag += (
f'<stop offset="{round(frag * i * 100, 6)}%" '
f'stop-color="{self.gradient_colors[i].str_rgb}"/>'
)
if i < length - 1:
tag += (
f'<stop offset="{round(frag * (i + 1) * 100, 6)}%" '
f'stop-color="{self.gradient_colors[i].str_rgb}"/>'
)
tag += "</linearGradient>"
tag += "</defs>"
return tag, id_grad
[docs]
class DiscreteGradientOverlay(SegmentedGradientOverlay):
""" """
def __init__(
self,
pth: Path,
res: tuple[int, int] = (4096, 4096),
pad: tuple[int, int, int, int] = (100, 100, 100, 100),
) -> None:
super().__init__(pth, res, pad)
def _get_tag_axis(self, x, y, sign_x, sign_y):
# return ""
if self._orientation is Orientation.HORIZONTAL:
y1 = y2 = y
x1 = x + (self.line_width / 2) * sign_x
x2 = x + (self.gradient_length - self.line_width / 2) * sign_x
else:
y1 = y + (self.line_width / 2) * sign_y
y2 = y + (self.gradient_length - self.line_width / 2) * sign_y
x1 = x2 = x
dasharray = []
stride = self.gradient_length / len(self.ticks) * 0.6
dasharray.append(self.gradient_length / len(self.ticks) - stride)
dasharray.append(stride)
offset = -0.5 * stride
return (
f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" '
f'stroke-dashoffset="{offset}" '
f'stroke-dasharray="{dasharray[0]} {dasharray[1]}" '
f'stroke-linecap="round" stroke="{self.color_ticks.str_hex}" '
f'stroke-width="{self.line_width}"></line>'
)
def _get_tag_ticks(self, x, y, sign_x, sign_y):
svg = ""
w = (
self.gradient_length
if self.orientation is Orientation.HORIZONTAL
else self.gradient_height
)
h = (
self.gradient_length
if self.orientation is Orientation.VERTICAL
else self.gradient_height
)
step = self.gradient_length / len(self.ticks)
if "E" in self.align.value and self.orientation is Orientation.HORIZONTAL:
x -= w
if "S" in self.align.value and self.orientation is Orientation.VERTICAL:
y -= h
if self.orientation is Orientation.VERTICAL:
y += 0.5 * step
if self.orientation is Orientation.HORIZONTAL:
x += 0.5 * step
if self.align is CompassDirection.NORTH or self.align is CompassDirection.SOUTH:
x -= w / 2
for i, tick in enumerate(self.ticks):
if self.seperator_decimal == "COMMA":
tick = str(tick).replace(".", ",")
transform = ""
a = (
"middle"
if self.orientation is Orientation.HORIZONTAL
else ("start" if "W" in self.align.value else "end")
)
b = (
"middle"
if self.orientation is Orientation.VERTICAL
else ("hanging" if "N" in self.align.value else "auto")
)
if self.orientation is Orientation.HORIZONTAL:
x1 = x2 = x_tick = x
y1 = y2 = y
y2 += self.tick_length * sign_y
y_tick = y2 + self.spacing * sign_y
else:
y1 = y2 = y_tick = y
x1 = x2 = x
x2 += self.tick_length * sign_x
x_tick = x2 + self.spacing * sign_x
svg += (
f'<line id="tick-{i}" x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" '
f'stroke-linecap="round" stroke="{self.color_ticks.str_hex}" '
f'stroke-width="{self.line_width}"></line>'
)
svg += (
f'<text id="tick-label-{i}" x="{x_tick}" y="{y_tick}" '
f'fill="{self.color_ticks.str_hex}" font-family="{self.font_family}" '
f'font-weight="400" transform="{transform}" '
f'font-size="{self.font_size_ticks}" text-anchor="{a}" '
f'dominant-baseline="{b}">{tick}</text>'
)
if "V" == self.orientation:
y += step
else:
x += step
return svg
[docs]
class LabelsOverlay(TitleOverlay):
def __init__(
self,
pth: Path,
res: tuple[int, int] = (4096, 4096),
pad: tuple[int, int, int, int] = (100, 100, 100, 100),
) -> None:
super().__init__(pth, res, pad)
self._labels = []
self.box_size = (120, 120)
self.color_labeltext = Color("#000")
self.font_size_labeltext = 100
self.roundness = 20
[docs]
def add_label(self, color: Color, text: str):
"""Add a label to the overlay."""
self._labels.append((color, text))
return self
def _build(self):
svg = super()._build()
_, _, sign_x, sign_y, anchor, _ = self._get_alignment_config()
if any([self.text, self.heading, self.subheading]):
self._y += sign_y * self.spacing
for label in self._labels:
if "EAST" in self.align.name:
self._x -= self.box_size[0]
if "SOUTH" in self.align.name:
self._y -= self.box_size[1]
svg += (
f'<rect x="{self._x}" y="{self._y}" rx="{self.roundness}" '
f'ry="{self.roundness}" width="{self.box_size[0]}" '
f'height="{self.box_size[1]}" fill="{label[0].str_rgb}"/>'
)
if "EAST" in self.align.name:
self._x += self.box_size[0]
if "SOUTH" in self.align.name:
self._y += self.box_size[1]
self._x += (self.box_size[0] + self.spacing / 2) * sign_x
self._y += self.box_size[1] / 2 * sign_y
svg += (
f'<text x="{self._x}" y="{self._y}" dy="6" '
f'fill="{self.color_labeltext}" dominant-baseline="middle" '
f'text-anchor="{anchor}" font-family="{self.font_family}" '
f'font-size="{self.font_size_labeltext}">{label[1]}</text>'
)
self._x -= (self.box_size[0] + self.spacing / 2) * sign_x
self._y += (self.spacing + self.box_size[1] / 2) * sign_y
return svg
@property
def box_size(self) -> tuple[float, float]:
"""Size of each colored box."""
return self._box_size
@box_size.setter
def box_size(self, arg: tuple[float, float]):
self._box_size = arg
@property
def color_labeltext(self):
return self._color_labeltext
@color_labeltext.setter
def color_labeltext(self, arg: Color):
if isinstance(arg, Color):
self._color_labeltext = arg
else:
raise TypeError
@property
def font_size_labeltext(self):
return self._font_size_labeltext
@font_size_labeltext.setter
def font_size_labeltext(self, arg: int):
if isinstance(arg, int):
self._font_size_labeltext = arg
else:
raise TypeError
@property
def roundness(self) -> float:
"""Roundness of the lines of the scalebar."""
return self._roundness
@roundness.setter
def roundness(self, arg: float):
self._roundness = arg