refactor: refactor assemblicon context

This commit is contained in:
2026-08-26 16:39:10 +08:00
parent c9740206d4
commit 55a922aafe
8 changed files with 512 additions and 237 deletions
@@ -1,19 +1,25 @@
import logging
from pathlib import Path
import PIL.Image
import PIL.ImageFile
from .utils import Artifact, ArtifactFile
def load_asset(p: Path) -> PIL.ImageFile.ImageFile:
def load_input_artifact(p: Path) -> ArtifactFile:
"""
Load input artifact used for building temporary and output artifact.
"""
logging.info("Loading asset: %s", p)
return PIL.Image.open(p, "r", ["PNG", "JPEG", "BMP"])
def load_artifact(p: Path) -> PIL.ImageFile.ImageFile:
def load_artifact(p: Path) -> ArtifactFile:
"""
Load artifact saved previously as the intermediary.
"""
logging.info("Loading artifact: %s", p)
return PIL.Image.open(p, "r", ["PNG"])
def save_artifact(art: PIL.Image.Image, p: Path) -> None:
def save_artifact(art: Artifact, p: Path) -> None:
logging.info("Saving artifact: %s", p)
art.save(p, format="PNG")
@@ -0,0 +1,211 @@
import logging
import os
import enum
import subprocess
from typing import Iterable, Iterator
from pathlib import Path
from dataclasses import dataclass
import PIL.Image
from .utils import Artifact, check_vanilla_artifact
class SvgRenderKind(enum.IntEnum):
Inkscape = enum.auto()
ReSvg = enum.auto()
@dataclass
class SvgConfig:
width: int
height: int
def __post_init__(self) -> None:
if self.width <= 0:
raise ValueError(
f"The image width in Inkscape config must be a positive integer"
)
if self.height <= 0:
raise ValueError(
f"The image height in Inkscape config must be a positive integer"
)
def render_svg(src: Path, dst: Path, kind: SvgRenderKind, cfg: SvgConfig) -> None:
"""
Render given SVG file to PNG file.
:param src: The path to SVG file to be rendered
:param dst: The path to rendered PNG file.
:param kind: The selected SVG render kind.
:param cfg: The configuration when rendering.
"""
logging.info("Rendering SVG artifact with render {kind.name}: %s -> %s", src, dst)
match kind:
case SvgRenderKind.Inkscape:
inkscape_bin = os.getenv("ASSEMBLICON_INKSCAPE", "inkscape")
cmd = [
inkscape_bin,
"--export-area-page",
"--export-width",
str(cfg.width),
"--export-height",
str(cfg.height),
"--export-type",
"png",
"--export-png-color-mode",
"RGBA_8",
"--export-background-opacity",
"0.0",
"--export-png-use-dithering",
"false",
"--export-filename",
str(dst),
str(src),
]
case SvgRenderKind.ReSvg:
resvg_bin = os.getenv("ASSEMBLICON_RESVG", "resvg")
cmd = [
resvg_bin,
"--width",
str(cfg.width),
"--height",
str(cfg.height),
str(src),
str(dst),
]
proc = subprocess.run(cmd, capture_output=False)
if proc.returncode != 0:
raise RuntimeError(
f"Fail to execute SVG render {kind.name}. "
f"Return code is {proc.returncode}"
)
@dataclass
class BlenderConfig:
width: int
height: int
def __post_init__(self) -> None:
if self.width <= 0:
raise ValueError(
f"The image width in Blender config must be a positive integer"
)
if self.height <= 0:
raise ValueError(
f"The image height in Blender config must be a positive integer"
)
_BLENDER_RENDER_SCRIPT = """\
import sys
import traceback
try:
import bpy
render = bpy.context.scene.render
render.resolution_x = {width}
render.resolution_y = {height}
render.resolution_percentage = 100
render.image_settings.file_format = "PNG"
render.image_settings.color_mode = "RGBA"
render.image_settings.color_depth = "8"
render.film_transparent = True
render.use_file_extension = False
render.filepath = {dst}
bpy.ops.render.render(write_still=True)
except BaseException:
traceback.print_exc()
sys.exit(1)
"""
def render_blender(src: Path, dst: Path, temp_script: Path, cfg: BlenderConfig) -> None:
"""
Render given Blender composition into PNG file.
:param src: The path to Blender composition file to be rendered
:param dst: The path to rendered PNG file.
:param temp_script: The path to temporary Python script instructing Blender rendering.
The caller must make sure that this file is immutable during calling this function.
:param cfg: The configuration when rendering.
"""
logging.info("Rendering Blender artifact: %s -> %s", src, dst)
script = _BLENDER_RENDER_SCRIPT.format(
width=cfg.width, height=cfg.height, dst=repr(str(dst))
)
temp_script.write_text(script, encoding="utf-8")
blender_bin = os.getenv("ASSEMBLICON_BLENDER", "blender")
cmd = [
blender_bin,
"--background",
str(src),
"--python",
str(temp_script),
]
proc = subprocess.run(cmd, capture_output=False)
if proc.returncode != 0:
raise RuntimeError(f"Fail to execute Blender. Return code is {proc.returncode}")
if not dst.exists():
raise RuntimeError(
f"Blender exited successfully but output file {dst} is missing"
)
def render_thumbnail(art: Artifact, hws: Iterable[int]) -> Iterator[Artifact]:
"""
Render given artifact as multiple resolution thumbnails.
:param art: The artifact to be rendered.
:param hws: The int iterable holding all resolution of thumbnails (square thumbnail only)
:return: A generator outputing thumbnail one by one with the order of given resolution.
"""
check_vanilla_artifact(art)
for hw in hws:
logging.info("Rendering thumbnail %dx%d.", hw, hw)
thumbnail = art.copy()
thumbnail.thumbnail((hw, hw), PIL.Image.Resampling.LANCZOS)
yield thumbnail
def render_ico(art: Artifact, dst: Path) -> None:
"""
Render given artifact as Windows ICO in given path.
:param art: The artifact to be rendered.
:param dst: The path to rendered result.
"""
logging.info("Rendering .ICO artifact: -> %s", dst)
check_vanilla_artifact(art)
sizes = [16, 32, 48, 64, 128, 256]
# provide every frame explicitly so that we never depend on
# undocumented plugin-side resizing behavior
frames = list(render_thumbnail(art, sizes))
art.save(
dst,
format="ICO",
sizes=[(size, size) for size in sizes],
append_images=frames,
)
def render_icns(art: Artifact, dst: Path) -> None:
"""
Render given artifact as macOS ICNS in given path.
:param art: The artifact to be rendered.
:param dst: The path to rendered result.
"""
logging.info("Rendering .ICNS artifact: -> %s", dst)
check_vanilla_artifact(art)
# provide every smaller frame explicitly; the 1024x1024 entry is
# covered by the art itself
frames = list(render_thumbnail(art, (512, 256, 128, 64, 32)))
art.save(dst, format="ICNS", append_images=frames)
@@ -1,36 +1,53 @@
import logging
import tempfile
import os
import enum
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from types import TracebackType
from .utils import Artifact, ArtifactFile, VANILLA_ARTIFACT_HW
from .artrender import SvgRenderKind, SvgConfig, BlenderConfig
from . import artio, artrender
@dataclass(frozen=True)
class Prerequisite:
input_directory: Path
output_directory: Path
svg_render: SvgRenderKind
def __post_init__(self) -> None:
if not self.input_directory.is_dir():
raise ValueError(
f"Given input path {self.input_directory} is not existing directory."
)
if not self.output_directory.is_dir():
raise ValueError(
f"Given output path {self.output_directory} is not existing directory."
)
class Context:
__input_dir: Path
__output_dir: Path
__prerequisite: Prerequisite
__temp_dir: tempfile.TemporaryDirectory[str]
def __init__(self, input_dir: Path, output_dir: Path) -> None:
self.__input_dir = input_dir
self.__output_dir = output_dir
def __init__(self, prerequisite: Prerequisite) -> None:
self.__prerequisite = prerequisite
self.__temp_dir = tempfile.TemporaryDirectory()
@property
def prerequisite(self) -> Prerequisite:
return self.__prerequisite
def input_artifact(self, *args: str) -> Path:
return self.__input_dir / Path(*args)
return self.__prerequisite.input_directory / Path(*args)
def output_artifact(self, *args: str) -> Path:
p = self.__output_dir / Path(*args)
p.parent.mkdir(parents=True, exist_ok=True)
return p
return self.__prerequisite.output_directory / Path(*args)
def temporary_artifact(self, *args: str) -> Path:
p = Path(self.__temp_dir.name) / "user" / Path(*args)
p.parent.mkdir(parents=True, exist_ok=True)
return p
def _intern_temporary_artifact(self, *args: str) -> Path:
p = Path(self.__temp_dir.name) / "assemblicon" / Path(*args)
p.parent.mkdir(parents=True, exist_ok=True)
return p
return Path(self.__temp_dir.name) / Path(*args)
def __enter__(self) -> "Context":
return self
@@ -42,3 +59,214 @@ class Context:
traceback: TracebackType | None,
) -> None:
self.__temp_dir.cleanup()
class FdContext(enum.StrEnum):
Actions = "actions"
Animations = "animations"
Applications = "apps"
Categories = "categories"
Devices = "devices"
Emblems = "emblems"
Emotes = "emotes"
International = "intl"
MimeTypes = "mimetypes"
Places = "places"
Status = "status"
class WinCategory(enum.StrEnum):
Application = "apps"
"""Icons for applications"""
Extension = "exts"
"""Icons for file extensions"""
class MacCategory(enum.StrEnum):
Application = "apps"
"""Icons for applications"""
Extension = "exts"
"""Icons for file extensions"""
_TEMP_USER_DIR: str = "user"
_TEMP_LIB_DIR: str = "assemblicon"
_TEMP_LIB_ALLOC_DIR: str = "alloc"
_TEMP_LIB_STABLE_DIR: str = "stable"
_ART_WIN: str = "windows"
_ART_MAC: str = "macos"
_DEFAULT_SVG_CONFIG = artrender.SvgConfig(VANILLA_ARTIFACT_HW, VANILLA_ARTIFACT_HW)
_DEFAULT_BLD_CONFIG = artrender.BlenderConfig(VANILLA_ARTIFACT_HW, VANILLA_ARTIFACT_HW)
# TODO: For easy testing, we only build 48x48.
# Enable full generation after testing.
_FD_THUMBNAIL_HWS: tuple[int, ...] = (48,)
# _FD_THUMBNAIL_HWS: tuple[int, ...] = (16, 32, 48, 64, 128, 256)
class AdvancedContext:
__context: Context
def __init__(self, ctx: Context) -> None:
self.__context = ctx
# construct basic layout of temporary directory and output directory.
def __enter__(self) -> "AdvancedContext":
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
self.__context.__exit__(exc_type, exc_value, traceback)
# region: Input Artifact
def input_artifact(self, *args: str) -> Path:
"""
Get the path to input artifact.
"""
return self.__context.input_artifact(*args)
def load_input_bitmap_artifact(self, *args: str) -> ArtifactFile:
"""
Load given input bitmap asset.
"""
return artio.load_input_artifact(self.input_artifact(*args))
# endregion
# region: Temporary Artifact
def __user_temporary_artifact(self, *args: str) -> Path:
return self.__context.temporary_artifact(_TEMP_USER_DIR, *args)
def __lib_temporary_artifact(self, *args: str) -> Path:
return self.__context.temporary_artifact(_TEMP_LIB_DIR, *args)
def temporary_artifact(self, *args: str) -> Path:
"""
Get the path to temporary artifact.
All existence of sub-directory should be ensured on your own.
"""
return self.__user_temporary_artifact(*args)
def load_temporary_artifact(self, *args: str) -> ArtifactFile:
return artio.load_artifact(self.temporary_artifact(*args))
def save_temporary_artifact(self, art: Artifact, *args: str) -> None:
"""
Save given artifact as temporary artifact.
All existence of sub-directory should be ensured on your own.
"""
artio.save_artifact(art, self.temporary_artifact(*args))
def __allocate_lib_temporary_artifact(self, suffix: Optional[str] = None) -> Path:
p = self.__lib_temporary_artifact(_TEMP_LIB_ALLOC_DIR)
(fd, pf) = tempfile.mkstemp(suffix=suffix, prefix=None, dir=str(p), text=False)
os.close(fd)
return Path(pf)
def __fetch_const_lib_temporary_artifact(self, name: str) -> Path:
return self.__lib_temporary_artifact(_TEMP_LIB_STABLE_DIR, name)
# endregion
# region: Render Wrapper
def __intern_render_svg(
self, src: Path, dst: Optional[Path], cfg: SvgConfig
) -> Path:
if dst is None:
dst = self.__allocate_lib_temporary_artifact(".png")
artrender.render_svg(src, dst, self.__context.prerequisite.svg_render, cfg)
return dst
def render_svg(
self, src: Path, dst: Optional[Path], cfg: SvgConfig = _DEFAULT_SVG_CONFIG
) -> None:
self.__intern_render_svg(src, dst, cfg)
def render_svg_then_load(
self, src: Path, dst: Optional[Path], cfg: SvgConfig = _DEFAULT_SVG_CONFIG
) -> Artifact:
dst = self.__intern_render_svg(src, dst, cfg)
return artio.load_artifact(dst)
def __intern_render_blender(
self, src: Path, dst: Optional[Path], cfg: BlenderConfig
) -> Path:
temp_script = self.__fetch_const_lib_temporary_artifact("blender.py")
if dst is None:
dst = self.__allocate_lib_temporary_artifact(".png")
artrender.render_blender(src, dst, temp_script, cfg)
return dst
def render_blender(
self, src: Path, dst: Optional[Path], cfg: BlenderConfig = _DEFAULT_BLD_CONFIG
) -> None:
self.__intern_render_blender(src, dst, cfg)
def render_blender_then_load(
self, src: Path, dst: Optional[Path], cfg: BlenderConfig = _DEFAULT_BLD_CONFIG
) -> Artifact:
dst = self.__intern_render_blender(src, dst, cfg)
return artio.load_artifact(dst)
# endregion
# region: Advanced Saver
def __fd_artifact(self, hw: int, kind: FdContext, name: str) -> Path:
return self.__context.output_artifact(f"{hw}x{hw}", str(kind), f"{name}.png")
def save_fd_artifact(self, art: Artifact, kind: FdContext, name: str) -> None:
"""
Save icon following FreeDesktop icon theme specification.
:param name: The name of saved artifact. The file extension ".png" is not required.
"""
logging.info(
"Saving FreeDesktop artifact with kind %s and name %s.", kind, name
)
for hw, thumbnail in zip(
_FD_THUMBNAIL_HWS, artrender.render_thumbnail(art, _FD_THUMBNAIL_HWS)
):
dst = self.__fd_artifact(hw, kind, name)
artio.save_artifact(thumbnail, dst)
def __win_artifact(self, kind: WinCategory, name: str) -> Path:
return self.__context.output_artifact(_ART_WIN, str(kind), f"{name}.ico")
def save_win_artifact(self, art: Artifact, kind: WinCategory, name: str) -> None:
"""
Save icon for Windows-only
:param name: The name of saved artifact. The file extension ".ico" is not required.
"""
logging.info(
"Saving Windows-only artifact with kind %s and name %s.", kind, name
)
artrender.render_ico(art, self.__win_artifact(kind, name))
def __mac_artifact(self, kind: MacCategory, name: str) -> Path:
return self.__context.output_artifact(_ART_MAC, str(kind), f"{name}.icns")
def save_mac_artifact(self, art: Artifact, kind: MacCategory, name: str) -> None:
"""
Save icon for macOS-only
:param name: The name of saved artifact. The file extension ".icns" is not required.
"""
logging.info("Saving macOS-only artifact with kind %s and name %s.", kind, name)
artrender.render_icns(art, self.__mac_artifact(kind, name))
# endregion
@@ -1,176 +0,0 @@
import logging
import os
import subprocess
from pathlib import Path
from dataclasses import dataclass
import PIL.Image
import PIL.ImageFile
from .context import Context
from .utils import Artifact
from .artio import load_artifact
@dataclass
class InkscapeConfig:
width: int
height: int
def __post_init__(self) -> None:
if self.width <= 0:
raise ValueError(
f"The image width in Inkscape config must be a positive integer"
)
if self.height <= 0:
raise ValueError(
f"The image height in Inkscape config must be a positive integer"
)
# TODO: Add "resvg" alternative for Inkscape
# because Inkscape is too slow.
def inkscape_render(
src: Path, dst: Path, cfg: InkscapeConfig
) -> PIL.ImageFile.ImageFile:
logging.info("Rendering Inkscape artifact: %s -> %s", src, dst)
inkscape_bin = os.getenv("ASSEMBLICON_INKSCAPE", "inkscape")
cmd = [
inkscape_bin,
"--export-area-page",
"--export-width",
str(cfg.width),
"--export-height",
str(cfg.height),
"--export-type",
"png",
"--export-png-color-mode",
"RGBA_8",
"--export-background-opacity",
"0.0",
"--export-png-use-dithering",
"false",
"--export-filename",
str(dst),
str(src),
]
proc = subprocess.run(cmd, capture_output=False)
if proc.returncode != 0:
raise RuntimeError(
f"Fail to execute Inkscape. Return code is {proc.returncode}"
)
return load_artifact(dst)
@dataclass
class BlenderConfig:
width: int
height: int
def __post_init__(self) -> None:
if self.width <= 0:
raise ValueError(
f"The image width in Blender config must be a positive integer"
)
if self.height <= 0:
raise ValueError(
f"The image height in Blender config must be a positive integer"
)
_BLENDER_RENDER_SCRIPT = """\
import sys
import traceback
try:
import bpy
render = bpy.context.scene.render
render.resolution_x = {width}
render.resolution_y = {height}
render.resolution_percentage = 100
render.image_settings.file_format = "PNG"
render.image_settings.color_mode = "RGBA"
render.image_settings.color_depth = "8"
render.film_transparent = True
render.use_file_extension = False
render.filepath = {dst}
bpy.ops.render.render(write_still=True)
except BaseException:
traceback.print_exc()
sys.exit(1)
"""
def blender_render(
ctx: Context, src: Path, dst: Path, cfg: BlenderConfig
) -> PIL.ImageFile.ImageFile:
logging.info("Rendering Blender artifact: %s -> %s", src, dst)
script = _BLENDER_RENDER_SCRIPT.format(
width=cfg.width, height=cfg.height, dst=repr(str(dst))
)
script_path = ctx._intern_temporary_artifact("blender_render.py")
script_path.write_text(script, encoding="utf-8")
blender_bin = os.getenv("ASSEMBLICON_BLENDER", "blender")
cmd = [
blender_bin,
"--background",
str(src),
"--python",
str(script_path),
]
proc = subprocess.run(cmd, capture_output=False)
if proc.returncode != 0:
raise RuntimeError(f"Fail to execute Blender. Return code is {proc.returncode}")
if not dst.exists():
raise RuntimeError(
f"Blender exited successfully but output file {dst} is missing"
)
return load_artifact(dst)
_ICON_ART_SIZE = 1024
def _ensure_1024_art(art: Artifact) -> None:
if art.size != (_ICON_ART_SIZE, _ICON_ART_SIZE):
raise ValueError(
f"Icon art must be {_ICON_ART_SIZE}x{_ICON_ART_SIZE}, "
f"got {art.width}x{art.height} instead"
)
def _scaled_frame(art: Artifact, size: int) -> Artifact:
_ensure_1024_art(art)
frame = art.copy()
frame.thumbnail((size, size), PIL.Image.Resampling.LANCZOS)
return frame
def ico_render(ctx: Context, art: Artifact, dst: Path) -> None:
logging.info("Rendering .ICO artifact via Pillow: -> %s", dst)
_ensure_1024_art(art)
sizes = [16, 32, 48, 64, 128, 256]
# provide every frame explicitly so that we never depend on
# undocumented plugin-side resizing behavior
frames = [_scaled_frame(art, size) for size in sizes]
art.save(
dst,
format="ICO",
sizes=[(size, size) for size in sizes],
append_images=frames,
)
def icns_render(ctx: Context, art: Artifact, dst: Path) -> None:
logging.info("Rendering .ICNS artifact via Pillow: -> %s", dst)
_ensure_1024_art(art)
# provide every smaller frame explicitly; the 1024x1024 entry is
# covered by the art itself
frames = [_scaled_frame(art, size) for size in (512, 256, 128, 64, 32)]
art.save(dst, format="ICNS", append_images=frames)
@@ -1,7 +1,20 @@
import logging
from PIL.Image import Image
from PIL.ImageFile import ImageFile
type Artifact = Image
type ArtifactFile = ImageFile
def setup_logging() -> None:
logging.basicConfig(format="[%(levelname)s] %(message)s", level=logging.INFO)
VANILLA_ARTIFACT_HW: int = 1024
def check_vanilla_artifact(art: Artifact) -> None:
if art.size != (VANILLA_ARTIFACT_HW, VANILLA_ARTIFACT_HW):
raise ValueError(
f"Icon art must be {VANILLA_ARTIFACT_HW}x{VANILLA_ARTIFACT_HW}, got {art.width}x{art.height} instead"
)
@@ -1,10 +1,10 @@
import PIL.Image
import PIL.ImageEnhance
from ..assemblicon.render import inkscape_render, ico_render, InkscapeConfig
from ..assemblicon.artrender import render_svg, render_ico, SvgConfig
from ..assemblicon.context import Context
from ..assemblicon.utils import Artifact
from ..assemblicon.geometry import MinMaxRect, PosSizeRect
from ..assemblicon.artio import load_asset, save_artifact
from ..assemblicon.artio import load_input_artifact, save_artifact
from ..assemblicon.artproc import (
aspect_ratio_scale_and_crop,
VerticalAnchor,
@@ -17,10 +17,10 @@ IMAGE_INNER_RECT_POSSIZE = IMAGE_INNER_RECT_MINMAX.to_pos_size_rect()
def build_image_icons(ctx: Context) -> None:
# build image base
image_base = inkscape_render(
image_base = render_svg(
ctx.input_artifact("component", "image-base.svg"),
ctx.temporary_artifact("component", "image-base.png"),
InkscapeConfig(1024, 1024),
SvgConfig(1024, 1024),
)
# load image base
with image_base:
@@ -30,7 +30,7 @@ def build_image_icons(ctx: Context) -> None:
def _build_jpg_icon(ctx: Context, base: Artifact) -> None:
jpg_image = base.copy()
jpg_image_inner = load_asset(
jpg_image_inner = load_input_artifact(
ctx.input_artifact("ext", "bitmap", "Niinsaare_järv.jpg")
)
with jpg_image_inner:
@@ -60,7 +60,7 @@ def _build_jpg_icon(ctx: Context, base: Artifact) -> None:
)
# build artifacts
save_artifact(jpg_image, ctx.temporary_artifact("ext", "jpg-image.png"))
ico_render(
render_ico(
ctx,
jpg_image,
ctx.output_artifact("ext", "jpg-image.ico"),
@@ -0,0 +1,5 @@
from ..assemblicon.context import Context
def build_non_standard_icons(ctx: Context) -> None:
pass
@@ -1,6 +1,6 @@
from pathlib import Path
import PIL.Image
from ..assemblicon.render import inkscape_render, InkscapeConfig
from ..assemblicon.artrender import render_svg, SvgConfig
from ..assemblicon.context import Context
from ..assemblicon.utils import Artifact
from ..assemblicon.artio import save_artifact
@@ -12,44 +12,32 @@ def build_action_icons(ctx: Context) -> None:
def _build_go_icons(ctx: Context) -> None:
# build go-* base
go_base = inkscape_render(
go_base = render_svg(
ctx.input_artifact("component", "go-base.svg"),
ctx.temporary_artifact("component", "go-base.png"),
InkscapeConfig(1024, 1024),
SvgConfig(1024, 1024),
)
# build go-* shining
go_shining = inkscape_render(
go_inner_shining = render_svg(
ctx.input_artifact("component", "go-inner-shining.svg"),
ctx.temporary_artifact("component", "go-inner-shining.png"),
InkscapeConfig(1024, 1024),
SvgConfig(1024, 1024),
)
# build go-* inner
go_inner = render_svg(
ctx.input_artifact("component", "go-inner.svg"),
ctx.temporary_artifact("component", "go-inner.png"),
SvgConfig(1024, 1024),
)
with go_base, go_shining:
_build_go_icon(
ctx,
go_base,
go_shining,
ctx.input_artifact("go-previous.svg"),
ctx.temporary_artifact("go-previous.png"),
ctx.output_artifact("go-previous.png")
)
_build_go_icon(
ctx,
go_base,
go_shining,
ctx.input_artifact("go-next.svg"),
ctx.temporary_artifact("go-next.png"),
ctx.output_artifact("go-next.png")
)
def _build_go_icon(
ctx: Context, base: Artifact, shining: Artifact, mask: Path, mask_dst: Path, dst: Path
) -> None:
go = base.copy()
# render shining with mask
go_mask = inkscape_render(mask, mask_dst, InkscapeConfig(1024, 1024))
go.paste(shining, (0, 0), go_mask)
# save as image
save_artifact(go, dst)
with go_base, go_inner_shining, go_inner:
go_mask = go_inner
for part in ("previous", "down", "next", "up"):
go = go_base.copy()
# render shining with inner mask
go.paste(go_inner_shining, (0, 0), go_mask)
# save as image
save_artifact(go, ctx.output_artifact(f"go-{part}.png"))
# rotate it for next calling
go_mask = go_mask.transpose(PIL.Image.Transpose.ROTATE_90)